diff --git a/.env.example b/.env.example index 8fcf8120..2db6325b 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ APP_ENV=production APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.0.2 +APP_VERSION=7.0.3 APP_LOCALE=ko APP_FALLBACK_LOCALE=ko diff --git a/.env.testing.example b/.env.testing.example index 96277f5a..7a637226 100644 --- a/.env.testing.example +++ b/.env.testing.example @@ -3,7 +3,7 @@ APP_ENV=testing APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.0.2 +APP_VERSION=7.0.3 APP_LOCALE=ko APP_FALLBACK_LOCALE=ko diff --git a/AGENTS.md b/AGENTS.md index 4ffdec6a..b70277e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,13 +6,14 @@ -### 백엔드 [backend/](docs/backend/) (31개) +### 백엔드 [backend/](docs/backend/) (32개) | 문서 | 설명 | TL;DR 핵심 | |------|------|-----------| | [activity-log-hooks.md](docs/backend/activity-log-hooks.md) | 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference) | 코어 66훅 + 이커머스 92훅 + 게시판 32훅 + 페이지 8훅 = 총 198훅 | | [activity-log.md](docs/backend/activity-log.md) | 활동 로그 시스템 (Activity Log System) | Monolog 기반: Service 훅 → Listener → Log::channel('activity... | | [admin-settings-access.md](docs/backend/admin-settings-access.md) | Admin 환경설정 값 접근 (`g7_core_settings` vs `config()`) | 동기화 SSoT: storage/app/settings/*.json → SettingsServicePr... | +| [api-documentation.md](docs/backend/api-documentation.md) | API 레퍼런스 문서 규정 (API Documentation) | 모든 API 엔드포인트는 레퍼런스 문서 필수 — 메서드/URI/파라미터/응답 필드 + 요청·응답 예시 ... | | [api-resources.md](docs/backend/api-resources.md) | API 리소스 | Resource: BaseApiResource 상속 필수 / Collection: BaseApiColl... | | [authentication.md](docs/backend/authentication.md) | 인증 및 세션 처리 | Laravel Sanctum 토큰 전용 인증 (Bearer 토큰만 사용) | | [broadcasting.md](docs/backend/broadcasting.md) | Broadcasting (실시간 이벤트) | Laravel Reverb 사용 (WebSocket) | @@ -116,7 +117,7 @@ | [module-i18n.md](docs/extension/module-i18n.md) | 모듈 다국어 시스템 | 백엔드: /lang/{locale}/*.php → __('vendor-module::key') | | [module-identity-settings.md](docs/extension/module-identity-settings.md) | 모듈/플러그인 본인인증(IDV) 설정 통합 가이드 | 정책/목적/메시지: module.php::getIdentity{Policies,Purposes,Mess... | | [module-layouts.md](docs/extension/module-layouts.md) | 모듈 레이아웃 시스템 | 위치: modules/_bundled/vendor-module/resources/layouts/admi... | -| [module-routing.md](docs/extension/module-routing.md) | 모듈 라우트 규칙 | URL prefix 자동: /api/admin/[vendor-module]/... | +| [module-routing.md](docs/extension/module-routing.md) | 모듈 라우트 규칙 | URL prefix 자동: /api/modules/[vendor-module]/... | | [module-settings.md](docs/extension/module-settings.md) | 모듈 환경설정 시스템 개발 가이드 | - | | [permissions.md](docs/extension/permissions.md) | 권한 시스템 | 구조: User → Role → Permission (기능 레벨) | | [plugin-development.md](docs/extension/plugin-development.md) | 플러그인 개발 가이드 | 디렉토리: plugins/vendor-plugin (예: sirsoft-payment) | @@ -143,6 +144,32 @@ | [e2e-testing.md](docs/testing/e2e-testing.md) | 그누보드7 Playwright E2E 테스트 가이드 | - | +### API 레퍼런스 진입점 + +> 엔드포인트별 요청 파라미터·응답 필드·요청/응답 예시. 공통 규약(Bearer 토큰 인증, 응답 봉투, 페이지네이션, 401/403/422/428)은 진입점 문서 상단에 정리되어 있다. + +| 대상 | 진입점 | 문서/엔드포인트 | +|------|--------|----------------| +| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 35 / 291 | + + +### 확장 API 레퍼런스 (9개 확장, 자동 스캔) + +> 각 확장이 소유하는 API 문서 목차. `php artisan api:docgen` 이 생성하며, 이 표는 `{modules,plugins}/_bundled/*/docs/api/README.md` 를 패턴 스캔해 자동 편입된다(확장명 하드코딩 없음). + +| 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 | +|------|------|--------------|----------------| +| `gnuboard7-hello_module` | 모듈 | [docs/api/](modules/_bundled/gnuboard7-hello_module/docs/api/README.md) | 1 / 2 | +| `sirsoft-board` | 모듈 | [docs/api/](modules/_bundled/sirsoft-board/docs/api/README.md) | 10 / 80 | +| `sirsoft-ecommerce` | 모듈 | [docs/api/](modules/_bundled/sirsoft-ecommerce/docs/api/README.md) | 33 / 231 | +| `sirsoft-page` | 모듈 | [docs/api/](modules/_bundled/sirsoft-page/docs/api/README.md) | 2 / 17 | +| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 | +| `sirsoft-gdpr` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 | +| `sirsoft-marketing` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 | +| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 22 | +| `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 1 / 1 | + + --- @@ -221,11 +248,22 @@ | 금지 | 올바른 사용 | |------|------------| -| `Icon className="w-4 h-4"` | `Icon size="sm"` 또는 `className="text-sm"` | +| `Icon className="w-4 h-4"` | `className="text-base"` (아래 등가표) 또는 `size` prop | | `Select valueKey/labelKey` | computed로 `{ value, label }` 변환 | | Form 내 `Button` type 없음 | `type="button"` 명시 (submit 방지) | | `options={{options}}` | `options={{options ?? []}}` (fallback) | +Icon 은 `` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은 박스만 정하고 글리프는 부모 `font-size` 를 상속하므로 어긋난다. 기존 `w-N h-N` 을 옮길 때는 아래 등가표를 쓴다 (Chrome 실측). + +| `w-N h-N` | px | 등가 `className` | +|---|---|---| +| `w-3 h-3` | 12 | `text-xs` | +| `w-3.5 h-3.5` | 14 | `text-sm` | +| `w-4 h-4` | 16 | `text-base` | +| `w-5 h-5` | 20 | `text-xl` | + +`size` prop 은 Font Awesome `fa-*` 클래스로 매핑되며 등가가 아니다 — `size="sm"` → `fa-sm` → `font-size: 0.875em`(상대값) + `line-height` 붕괴로 16px 이 12.25×0.88px 이 된다. 새 아이콘에는 써도 되지만, 기존 `w-N h-N` 의 치환용으로는 쓰지 않는다. + ### 상태 관리 | 금지 | 올바른 사용 | @@ -914,7 +952,7 @@ php artisan migrate:rollback | 수정 대상 파일 패턴 | 작업 전 필수 참조 | | ------------------- | ------------------ | -| `app/Http/Controllers/**` | [controllers.md](docs/backend/controllers.md) | +| `app/Http/Controllers/**` | [controllers.md](docs/backend/controllers.md), [api-documentation.md](docs/backend/api-documentation.md) | | `app/Services/**` | [service-repository.md](docs/backend/service-repository.md) | | `app/Http/Requests/**` | [validation.md](docs/backend/validation.md) | | `app/Repositories/**` | [service-repository.md](docs/backend/service-repository.md) | diff --git a/CHANGELOG.md b/CHANGELOG.md index 59e6c423..7dc3db24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [7.0.3] - 2026-07-10 + +### Fixed + +- 화면 구성 파일이 목록을 만들어 내는 과정에서 다국어 이름을 골라 쓸 때, 사이트를 어떤 언어로 보고 있든 항상 한국어 이름이 선택되던 문제를 수정했습니다. 이제 보고 있는 언어의 이름이 표시됩니다. 헤더의 배송국가 선택처럼 화면이 처음 열릴 때 목록을 만들어 두는 곳이 여기에 해당합니다. +- 메뉴를 눌러 화면을 이동했을 때, 필요한 정보를 여러 갈래로 나눠 받아오는 화면에서 먼저 도착한 정보가 뒤늦게 도착한 정보에 덮여 사라지는 경우가 있었습니다. 그 탓에 목록이 있어야 할 자리가 간헐적으로 비어 보이고 새로고침하면 정상으로 돌아오곤 했습니다. 이제 어느 정보가 먼저 도착하든 모두 반영됩니다. 이커머스 환경설정의 배송설정 탭에서 배송가능 국가 목록이 비어 보이던 것이 여기에 해당합니다. +- 이미 설치를 마친 환경의 설정 파일(`.env`)을 새 서버로 복사해, 데이터베이스가 아직 비어 있는 상태에서 설치를 이어가려 할 때 `php artisan migrate`로 테이블을 만들기도 전에 부팅 단계에서 오류가 나며 중단되던 문제를 수정했습니다. 이제 데이터베이스 작업 명령이 실행되는 동안에는 테이블 존재 여부를 실제로 확인하므로, 기존 서버의 설정을 재사용하는 신규 배포에서도 설치가 정상적으로 진행됩니다. (#41 @GyusoonKim 님께서 제보해주셨습니다.) + ## [7.0.2] - 2026-07-08 ### Added diff --git a/INSTALL.md b/INSTALL.md index 4c4d2f2b..ad65f5a1 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -246,7 +246,7 @@ unzip g7-release.zip # 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경 ls -la -# (필요 시) mv g7-7.0.2 g7 +# (필요 시) mv g7-7.0.3 g7 # ZIP 파일 정리 (선택) rm g7-release.zip diff --git a/README.md b/README.md index 8514de61..0c1abe3e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

- Version + Version PHP Laravel React @@ -16,6 +16,11 @@ Status

+

+ 데모 사이트 · + 관리자 데모 +

+ --- [소개](#그누보드7-소개) · [주요 기능](#주요-기능) · [기술 스택](#기술-스택) · [아키텍처](#아키텍처) · [빠른 시작](#빠른-시작) · [기본 제공 확장](#기본-제공-확장) · [비즈니스 모델](#비즈니스-모델) · [기존 사용자](#기존-그누보드-사용자) · [문서](#문서) · [기여하기](#기여하기) · [만든 사람들](#만든-사람들) · [커뮤니티](#커뮤니티) · [변경 기록](#변경-기록) · [라이선스](#라이선스) @@ -462,7 +467,8 @@ cp .env.example .env | 플러그인 개발 | [docs/extension/plugin-development.md](docs/extension/plugin-development.md) | | 템플릿 개발 | [docs/extension/template-basics.md](docs/extension/template-basics.md) | | 테스트 | [docs/testing-guide.md](docs/testing-guide.md) | -| API 레퍼런스 | 준비 중 | +| API 레퍼런스 | [docs/backend/api/README.md](docs/backend/api/README.md) | +| API 문서 작성 규정 | [docs/backend/api-documentation.md](docs/backend/api-documentation.md) | --- diff --git a/app/Console/Commands/ApiDocBackfillFieldsCommand.php b/app/Console/Commands/ApiDocBackfillFieldsCommand.php new file mode 100644 index 00000000..ba9b3a86 --- /dev/null +++ b/app/Console/Commands/ApiDocBackfillFieldsCommand.php @@ -0,0 +1,167 @@ +` 셀만 in-place 치환합니다. + * + * 채움 규칙 SSoT 는 ApiDocScaffolder::responseFieldTable 과 동일한 ResourceFieldDescriber + * 입니다 — 이후 정상 재생성(실측 서버 가동 시)도 같은 설명을 산출하므로 멱등합니다. + * + * 도메인 특이 필드(ResourceFieldDescriber 가 null 반환)는 TODO 를 그대로 둡니다. + * 응답 필드 표만 대상으로 하며, 파라미터 표(``)는 건드리지 않습니다. + */ +class ApiDocBackfillFieldsCommand extends Command +{ + /** + * @var string 커맨드 시그니처 + */ + protected $signature = 'api:docgen-backfill-fields + {--dry-run : 치환하지 않고 채울 건수만 리포트}'; + + /** + * @var string 커맨드 설명 + */ + protected $description = '기존 API 문서의 응답 필드 설명 TODO 를 리소스 계약 사전 설명으로 소급 채웁니다'; + + /** + * @var string 응답 필드 표를 식별하는 고유 헤더 + */ + private const FIELD_TABLE_HEADER = '| 필드 | 타입 | 실측 예시값 | 용도/설명 |'; + + /** + * @var string 응답 필드 설명 TODO 마커 + */ + private const TODO_MARKER = ''; + + /** + * 커맨드를 실행합니다. + * + * @return int 종료 코드 + */ + public function handle(): int + { + $describer = new ResourceFieldDescriber; + $dryRun = (bool) $this->option('dry-run'); + + $filled = 0; + $skipped = 0; + $filesChanged = 0; + + foreach ($this->apiDocFiles() as $file) { + $content = file_get_contents($file); + $result = $this->backfillFile($content, $describer, $filled, $skipped); + + if ($result !== $content) { + $filesChanged++; + if (! $dryRun) { + file_put_contents($file, $result); + } + } + } + + $prefix = $dryRun ? '[dry-run] ' : ''; + $this->info("{$prefix}응답 필드 설명 채움: {$filled}건, TODO 유지(도메인 특이): {$skipped}건, 변경 파일: {$filesChanged}개"); + + return self::SUCCESS; + } + + /** + * 대상 API 문서 파일 경로를 수집합니다. + * + * @return array 파일 경로 목록 + */ + private function apiDocFiles(): array + { + $dirs = array_filter([ + base_path('docs/backend/api'), + ...glob(base_path('modules/_bundled/*/docs/api')), + ...glob(base_path('plugins/_bundled/*/docs/api')), + ], 'is_dir'); + + if ($dirs === []) { + return []; + } + + $files = []; + foreach (Finder::create()->files()->in($dirs)->name('*.md') as $f) { + $files[] = $f->getRealPath(); + } + + return $files; + } + + /** + * 단일 문서의 응답 필드 표 TODO 를 채웁니다. + * + * 응답 필드 표 헤더 이후 표 행만 대상으로 하며, `` 셀을 + * ResourceFieldDescriber 결과로 치환합니다. 설명이 null(도메인 특이)이면 유지합니다. + * 파라미터 표는 헤더가 달라 진입하지 않으므로 건드리지 않습니다. + * + * @param string $content 문서 내용 + * @param ResourceFieldDescriber $describer 설명기 + * @param int $filled 채운 건수 (참조 누적) + * @param int $skipped 유지 건수 (참조 누적) + * @return string 치환된 문서 내용 + */ + private function backfillFile(string $content, ResourceFieldDescriber $describer, int &$filled, int &$skipped): string + { + $lines = explode("\n", $content); + $inFieldTable = false; + + foreach ($lines as $i => $line) { + // 응답 필드 표 헤더 진입 감지 + if (str_contains($line, self::FIELD_TABLE_HEADER)) { + $inFieldTable = true; + + continue; + } + + // 표 구분선(| --- | ...) 은 건너뜀 + if ($inFieldTable && preg_match('/^\|\s*-+\s*\|/', $line)) { + continue; + } + + // 표가 아닌 라인(빈 줄 또는 | 로 시작하지 않음) → 표 종료 + if ($inFieldTable && ! str_starts_with(ltrim($line), '|')) { + $inFieldTable = false; + + continue; + } + + if (! $inFieldTable || ! str_contains($line, self::TODO_MARKER)) { + continue; + } + + // 응답 필드 행 파싱: | name | type | `sample` | | + $cells = array_map('trim', explode('|', trim($line, '| '))); + if (count($cells) < 4) { + continue; + } + + [$name, $type] = [$cells[0], $cells[1]]; + $desc = $describer->describe($name, $type); + + if ($desc === null) { + $skipped++; + + continue; + } + + $safe = str_replace(['|', "\n", "\r"], ['\\|', ' ', ''], $desc); + $lines[$i] = str_replace(self::TODO_MARKER, $safe, $line); + $filled++; + } + + return implode("\n", $lines); + } +} diff --git a/app/Console/Commands/ApiDocBackfillParamsCommand.php b/app/Console/Commands/ApiDocBackfillParamsCommand.php new file mode 100644 index 00000000..0b7c8e79 --- /dev/null +++ b/app/Console/Commands/ApiDocBackfillParamsCommand.php @@ -0,0 +1,162 @@ +` 셀만 in-place 치환합니다. + * 채움 규칙 SSoT 는 ApiDocScaffolder 와 동일한 ParameterDescriber 입니다 — + * 이후 정상 재생성(실측 서버 가동 시)도 같은 설명을 산출하므로 멱등합니다. + * + * 도메인 특이 파라미터(ParameterDescriber 가 null 반환)는 TODO 를 그대로 둡니다. + */ +class ApiDocBackfillParamsCommand extends Command +{ + /** + * @var string 커맨드 시그니처 + */ + protected $signature = 'api:docgen-backfill-params + {--dry-run : 치환하지 않고 채울 건수만 리포트}'; + + /** + * @var string 커맨드 설명 + */ + protected $description = '기존 API 문서의 파라미터 용도 TODO 를 공통 파라미터 설명으로 소급 채웁니다'; + + /** + * @var string 파라미터 표를 식별하는 고유 헤더 + */ + private const PARAM_TABLE_HEADER = '| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |'; + + /** + * @var string 파라미터 용도 TODO 마커 + */ + private const TODO_MARKER = ''; + + /** + * 커맨드를 실행합니다. + * + * @return int 종료 코드 + */ + public function handle(): int + { + $describer = new ParameterDescriber; + $dryRun = (bool) $this->option('dry-run'); + + $filled = 0; + $skipped = 0; + $filesChanged = 0; + + foreach ($this->apiDocFiles() as $file) { + $content = file_get_contents($file); + $result = $this->backfillFile($content, $describer, $filled, $skipped); + + if ($result !== $content) { + $filesChanged++; + if (! $dryRun) { + file_put_contents($file, $result); + } + } + } + + $prefix = $dryRun ? '[dry-run] ' : ''; + $this->info("{$prefix}파라미터 용도 채움: {$filled}건, TODO 유지(도메인 특이): {$skipped}건, 변경 파일: {$filesChanged}개"); + + return self::SUCCESS; + } + + /** + * 대상 API 문서 파일 경로를 수집합니다. + * + * @return array 파일 경로 목록 + */ + private function apiDocFiles(): array + { + $dirs = array_filter([ + base_path('docs/backend/api'), + ...glob(base_path('modules/_bundled/*/docs/api')), + ...glob(base_path('plugins/_bundled/*/docs/api')), + ], 'is_dir'); + + if ($dirs === []) { + return []; + } + + $files = []; + foreach (Finder::create()->files()->in($dirs)->name('*.md') as $f) { + $files[] = $f->getRealPath(); + } + + return $files; + } + + /** + * 단일 문서의 파라미터 표 TODO 를 채웁니다. + * + * 파라미터 표 헤더 이후 표 행만 대상으로 하며, `` 셀을 + * ParameterDescriber 결과로 치환합니다. 설명이 null(도메인 특이)이면 유지합니다. + * + * @param string $content 문서 내용 + * @param ParameterDescriber $describer 설명기 + * @param int $filled 채운 건수 (참조 누적) + * @param int $skipped 유지 건수 (참조 누적) + * @return string 치환된 문서 내용 + */ + private function backfillFile(string $content, ParameterDescriber $describer, int &$filled, int &$skipped): string + { + $lines = explode("\n", $content); + $inParamTable = false; + + foreach ($lines as $i => $line) { + // 파라미터 표 헤더 진입 감지 + if (str_contains($line, self::PARAM_TABLE_HEADER)) { + $inParamTable = true; + + continue; + } + + // 표 구분선(| --- | ...) 은 건너뜀 + if ($inParamTable && preg_match('/^\|\s*-+\s*\|/', $line)) { + continue; + } + + // 표가 아닌 라인(빈 줄 또는 | 로 시작하지 않음) → 표 종료 + if ($inParamTable && ! str_starts_with(ltrim($line), '|')) { + $inParamTable = false; + + continue; + } + + if (! $inParamTable || ! str_contains($line, self::TODO_MARKER)) { + continue; + } + + // 파라미터 행 파싱: | name | location | type | 필수 | 허용값 | | + $cells = array_map('trim', explode('|', trim($line, '| '))); + if (count($cells) < 5) { + continue; + } + + [$name, $location, $type] = [$cells[0], $cells[1], $cells[2]]; + $desc = $describer->describe($name, $location, $type); + + if ($desc === null) { + $skipped++; + + continue; + } + + $safe = str_replace(['|', "\n", "\r"], ['\\|', ' ', ''], $desc); + $lines[$i] = str_replace(self::TODO_MARKER, $safe, $line); + $filled++; + } + + return implode("\n", $lines); + } +} diff --git a/app/Console/Commands/ApiDocgenCommand.php b/app/Console/Commands/ApiDocgenCommand.php new file mode 100644 index 00000000..a6596c00 --- /dev/null +++ b/app/Console/Commands/ApiDocgenCommand.php @@ -0,0 +1,937 @@ +option('scope'); + $routes = $inventory->collect($scope); + + if ($routes === []) { + $this->warn("범위 '{$scope}' 에 해당하는 API 라우트가 없습니다."); + + return self::SUCCESS; + } + + $this->info(count($routes)."개 라우트 수집 (scope={$scope})"); + + // 도메인 파일 단위로 그룹핑 + $grouped = []; + foreach ($routes as $route) { + $file = $this->targetFile($route); + $grouped[$file][] = $route; + } + + if ($this->option('dry-run')) { + foreach ($grouped as $file => $items) { + $this->line(sprintf(' %s (%d endpoints)', $file, count($items))); + } + + return self::SUCCESS; + } + + // --seed: 도메인별 완전 샘플을 시드해 상세 GET 실측 시 null 응답을 최소화 + // (인증보다 먼저 수행해 완전 샘플 사용자로 토큰을 발급 → /me 응답도 채워짐) + $sampleMap = []; + if ($this->option('seed')) { + if (app()->environment('production')) { + $this->error('--seed 는 개발 환경 전용입니다 (production 차단).'); + + return self::FAILURE; + } + + // 코어 샘플 우선 시드 — 확장 샘플의 소유자/actor 로 쓰이는 완전 사용자를 확보한다. + $sampleMap = (new ApiDocSampleService)->seed(); + $this->info('완전 샘플 시드: '.count($sampleMap).'개 도메인 (코어)'); + + // 확장 소유 라우트가 있으면 그 확장의 규약 시더를 발견해 도메인 샘플을 병합한다. + foreach ($this->discoverExtensionSeeders($routes) as $label => $seeder) { + $extMap = $seeder->seed(); + $sampleMap = array_merge($sampleMap, $extMap); + $this->info('완전 샘플 시드: '.count($extMap)."개 도메인 ({$label})"); + } + } + + $probe = new ApiEndpointProbe($this->option('base-url') ?: null); + // 인증 사용자: --user 명시 > 완전 샘플 사용자(/me 응답 충실) > 첫 사용자 + $authUserId = $this->option('user') + ? (int) $this->option('user') + : $this->sampleUserId($sampleMap); + $probed = $probe->authenticate($authUserId); + + if (! $probed) { + $this->warn('실측 토큰 발급 실패 — 실측 없이 정적 추출만 진행합니다.'); + } else { + $this->info('실측 기준 URL: '.$probe->baseUrl()); + } + + $stats = ['files' => 0, 'endpoints' => 0, 'probed' => 0, 'skipped' => 0, 'examples' => 0]; + $checkFindings = []; + $examplesOnly = (bool) $this->option('examples-only'); + + // 대상(코어/확장)별 README 목차 항목 누적: readmeKey => ['label' => ..., 'entries' => [...]] + $readmeIndex = []; + + foreach ($grouped as $file => $items) { + $sections = []; + $sectionKeys = []; + $exampleBlocks = []; + + // 목차용 도메인 파일 항목 (소유별). 예시-only/check 모드에서도 목차는 최신화한다. + $owner = $items[0]['owner']; + $readmeKey = $this->readmeFile($owner); + $readmeIndex[$readmeKey]['label'] = $owner['type'] === 'core' + ? '코어' + : ($owner['type'] === 'module' ? '모듈' : '플러그인')." `{$owner['id']}`"; + $readmeIndex[$readmeKey]['entries'][] = [ + 'domain' => $items[0]['domain_group'], + 'file' => basename($file), + 'count' => count($items), + ]; + + foreach ($items as $route) { + $request = $introspector->introspect($route['controller'], $route['controller_method']); + [$schema, $probeMeta] = $this->probeEndpoint($probe, $inferrer, $route, $probed, $sampleMap, $request); + + if ($probeMeta['skipped_reason'] === null) { + $stats['probed']++; + } else { + $stats['skipped']++; + $checkFindings[] = "{$route['method']} {$route['uri']} — {$probeMeta['skipped_reason']}"; + } + + $key = $route['name'] ?: $route['uri']; + + if ($examplesOnly) { + // 표·서술을 건드리지 않고 예시 2블록만 산출 (SSoT=스캐폴더 예시 메서드). + // 단, 응답 필드 표가 아직 실측 제외 마커(사람이 채운 셀 없음)인데 이번 실측으로 + // 스키마가 확보됐으면 그 마커를 실측 필드 표로 갱신한다(쓰기 메서드 실측 도입분). + $commentMap = $this->columnComments($route, $commentResolver, $sampleMap); + $exampleBlocks[$key] = [ + 'request' => $scaffolder->requestExampleBlock($route, $request, $probeMeta), + 'response' => $scaffolder->responseExampleBlock($schema, $probeMeta), + 'response_fields' => $schema !== null + ? $scaffolder->responseFieldTableBlock($schema, $probeMeta, $commentMap) + : null, + ]; + $stats['endpoints']++; + + continue; + } + + $commentMap = $this->columnComments($route, $commentResolver, $sampleMap); + $sections[] = $scaffolder->endpointSection($route, $request, $schema, $probeMeta, $commentMap); + $sectionKeys[] = $key; + $stats['endpoints']++; + } + + if ($this->option('check')) { + if (! File::exists($file)) { + $checkFindings[] = "문서 파일 없음: {$file}"; + } + + continue; + } + + // examples-only: 재생성 없이 기존 문서에 예시 블록만 in-place 삽입. + // 전체 재생성은 @generated 블록 내부 표(파라미터/응답 필드)의 사람 서술을 + // TODO 로 되돌리므로, 예시 방출을 위해 재생성을 쓰지 않는다. + if ($examplesOnly) { + if (! File::exists($file)) { + $this->warn("문서 없음(예시 삽입 건너뜀): {$file}"); + + continue; + } + + $existing = File::get($file); + [$updated, $inserted] = $scaffolder->insertExampleBlocks($existing, $exampleBlocks); + + if ($updated !== $existing) { + File::put($file, $updated); + $stats['files']++; + } + $stats['examples'] += $inserted; + + continue; + } + + $header = $this->documentHeader($file, $items[0]); + $existing = File::exists($file) ? File::get($file) : null; + $content = $scaffolder->mergeDocument($existing, $header, $sections, $sectionKeys); + + File::ensureDirectoryExists(dirname($file)); + File::put($file, $content); + $stats['files']++; + } + + $probe->cleanup(); + + // 대상별 API 문서 목차(README.md) 생성/갱신 — 확장 API 레퍼런스 발견성의 규약 진입점. + // check/dry-run 은 파일을 쓰지 않으므로 제외. examples-only 는 목차도 최신화한다. + if (! $this->option('check')) { + $coreReadme = $this->readmeFile(['type' => 'core', 'id' => null]); + + foreach ($readmeIndex as $readmePath => $meta) { + $existing = File::exists($readmePath) ? File::get($readmePath) : null; + // 코어 README 는 최상위 README 의 "API 레퍼런스" 진입점이므로 확장 목차를 함께 싣는다. + $extensions = $readmePath === $coreReadme ? $this->scanExtensionApiReadmes() : null; + $content = $scaffolder->readmeIndex($meta['label'], $meta['entries'], $existing, $extensions); + File::ensureDirectoryExists(dirname($readmePath)); + File::put($readmePath, $content); + } + + // 확장 스코프 실행이라 코어 README 를 다시 쓰지 않았어도, 그 확장의 엔드포인트 수가 + // 바뀌었을 수 있으므로 코어 README 의 확장 목차 블록만 in-place 갱신한다. + if (! array_key_exists($coreReadme, $readmeIndex) && File::exists($coreReadme)) { + $updated = $scaffolder->refreshExtensionIndex( + File::get($coreReadme), + $this->scanExtensionApiReadmes() + ); + if ($updated !== null) { + File::put($coreReadme, $updated); + } + } + } + + if ($this->option('check')) { + if ($checkFindings !== []) { + $this->warn('실측 제외/문서 누락 '.count($checkFindings).'건:'); + foreach (array_slice($checkFindings, 0, 50) as $f) { + $this->line(' - '.$f); + } + + return self::FAILURE; + } + + $this->info('drift 없음.'); + + return self::SUCCESS; + } + + $this->newLine(); + if ($examplesOnly) { + $this->info(sprintf( + '예시 삽입 완료: 파일 %d개, 예시 블록 %d개 (실측 %d, 제외 %d)', + $stats['files'], + $stats['examples'], + $stats['probed'], + $stats['skipped'] + )); + + return self::SUCCESS; + } + + $this->info(sprintf( + '완료: 파일 %d개, 엔드포인트 %d개 (실측 %d, 제외 %d)', + $stats['files'], + $stats['endpoints'], + $stats['probed'], + $stats['skipped'] + )); + + return self::SUCCESS; + } + + /** + * 수집된 라우트의 소유 확장에서 규약 위치의 샘플 시더를 발견합니다. + * + * 확장 컨트롤러 FQCN(`Modules\Vendor\Ext\Http\Controllers\...`)에서 확장 베이스 + * 네임스페이스를 추출하고, `{Base}\Support\ApiDoc\ApiDocSampleService` 규약 클래스가 + * 존재하며 ApiDocSampleSeeder 를 구현하면 인스턴스를 반환합니다. 각 확장은 1회만 처리합니다. + * + * @param array> $routes 수집된 라우트 목록 + * @return array 확장 라벨(key) => 시더 인스턴스 + */ + private function discoverExtensionSeeders(array $routes): array + { + $seeders = []; + $seen = []; + + foreach ($routes as $route) { + $owner = $route['owner']; + + if ($owner['type'] === 'core' || $owner['id'] === null) { + continue; + } + + $label = $owner['key']; + + if (isset($seen[$label])) { + continue; + } + $seen[$label] = true; + + $base = $this->extensionBaseNamespace($route['controller'] ?? null); + + if ($base === null) { + continue; + } + + $class = $base.'\\Support\\ApiDoc\\ApiDocSampleService'; + + if (class_exists($class) && is_subclass_of($class, ApiDocSampleSeeder::class)) { + $seeders[$label] = app($class); + } + } + + return $seeders; + } + + /** + * 확장 컨트롤러 FQCN 에서 확장 베이스 네임스페이스를 추출합니다. + * + * 두 컨트롤러 배치 규약을 모두 지원한다: + * - `\Http\Controllers\` (대다수 확장): `Modules\Sirsoft\Page\Http\Controllers\Admin\PageController` + * → `Modules\Sirsoft\Page` + * - `\Controllers\` (일부 플러그인): `Plugins\Sirsoft\PayKginicis\Controllers\PaymentSignatureController` + * → `Plugins\Sirsoft\PayKginicis` + * + * `\Http\Controllers\` 를 우선 판정하고, 없으면 `\Controllers\` 세그먼트 앞까지를 베이스로 본다. + * + * @param string|null $controller 컨트롤러 FQCN (클로저면 null) + * @return string|null 확장 베이스 네임스페이스 (추출 불가 시 null) + */ + private function extensionBaseNamespace(?string $controller): ?string + { + if ($controller === null) { + return null; + } + + if (Str::contains($controller, '\\Http\\Controllers\\')) { + return Str::before($controller, '\\Http\\Controllers\\'); + } + + if (Str::contains($controller, '\\Controllers\\')) { + return Str::before($controller, '\\Controllers\\'); + } + + return null; + } + + /** + * 엔드포인트를 실측하고 응답 스키마를 추론합니다. + * + * @param ApiEndpointProbe $probe 실측 프로브 + * @param ResponseSchemaInferrer $inferrer 스키마 추론기 + * @param array $route 라우트 메타데이터 + * @param bool $probed 실측 가능 여부 + * @param array $sampleMap 도메인별 대표 샘플 맵 + * @param array $request FormRequest 분석 결과 (쓰기 바디 구성용) + * @return array{0: array|null, 1: array} 스키마와 실측 메타 + */ + private function probeEndpoint(ApiEndpointProbe $probe, ResponseSchemaInferrer $inferrer, array $route, bool $probed, array $sampleMap = [], array $request = []): array + { + $baseUrl = $probe->baseUrl(); + + if (! $probed) { + return [null, ['status' => null, 'skipped_reason' => 'no-token', 'base_url' => $baseUrl, 'resolved_uri' => null, 'body' => null]]; + } + + $uri = $this->resolvePathParams($route, $sampleMap); + $isWrite = in_array($route['method'], ['POST', 'PUT', 'PATCH', 'DELETE'], true); + + // 요청 예시에 노출할 정규 URI (실측용 per_page 보정 제외). + $exampleUri = $this->exampleUri($route, $sampleMap); + + // 부수효과가 트랜잭션 롤백으로 되돌릴 수 없는 쓰기(확장 install/activate/update, + // 언어팩 설치, 코어 업데이트, 파일 업로드 등)는 in-process 실측에서 제외한다. + // 이들은 파일시스템·프로세스·외부 네트워크를 건드려 롤백 불가·hang 위험이 있다. + if ($isWrite && $this->isSideEffectfulWrite($route)) { + return [null, [ + 'status' => null, + 'skipped_reason' => 'side-effectful-write', + 'base_url' => $baseUrl, + 'resolved_uri' => $exampleUri, + 'body' => null, + ]]; + } + + // 쓰기 메서드는 FormRequest 규칙 기반 실측 바디를 만들어 in-process 롤백 실측에 사용한다. + $writeBody = in_array($route['method'], ['POST', 'PUT', 'PATCH'], true) + ? $this->buildWriteBody($request, $sampleMap) + : []; + $result = $probe->probe($route['method'], $uri, $writeBody); + + if (! $result['ok'] || $result['body'] === null) { + return [null, [ + 'status' => $result['status'], + 'skipped_reason' => $result['skipped_reason'] ?? ('http-'.$result['status']), + 'base_url' => $baseUrl, + 'resolved_uri' => $exampleUri, + 'body' => null, + ]]; + } + + return [$inferrer->infer($result['body']), [ + 'status' => $result['status'], + 'skipped_reason' => null, + 'base_url' => $baseUrl, + 'resolved_uri' => $exampleUri, + 'body' => $result['body'], + ]]; + } + + /** + * 부수효과가 트랜잭션 롤백으로 되돌릴 수 없는 쓰기 엔드포인트인지 판별합니다. + * + * 확장(모듈/플러그인/템플릿) 및 언어팩 설치·활성·업데이트, 코어 업데이트, 파일 업로드, + * 캐시/워밍업/생성 등 파일시스템·프로세스·외부 네트워크를 건드리는 쓰기는 in-process 롤백 + * 실측이 불가능(hang·비가역 부수효과)하므로 실측에서 제외한다. URI 세그먼트로 판정한다. + * + * @param array $route 라우트 메타데이터 + * @return bool 부수효과 쓰기 여부 + */ + private function isSideEffectfulWrite(array $route): bool + { + $uri = (string) $route['uri']; + + // 확장/언어팩 관리 도메인의 쓰기는 전량 제외 (install/activate/... 은 실환경 변경). + if (preg_match('#/api/admin/(modules|plugins|templates|language-packs|core-update)(/|$)#', $uri)) { + return true; + } + + // 동작성 세그먼트를 가진 쓰기 (도메인 무관). + $unsafeSegments = [ + 'install', 'install-from-file', 'install-from-github', 'install-from-url', 'install-from-bundled', + 'uninstall', 'activate', 'deactivate', 'bulk-activate', 'update', 'check-updates', + 'refresh-cache', 'clear', 'warmup', 'generate', 'sync', 'backup', 'restore', + 'upload', 'import', 'export', 'manifest-preview', 'preview', + ]; + + foreach ($unsafeSegments as $seg) { + if (str_contains($uri, '/'.$seg)) { + return true; + } + } + + return false; + } + + /** + * 요청 예시에 노출할 정규 URI 를 산출합니다. + * + * `resolvePathParams` 는 목록 GET 실측용으로 `?per_page=25` 를 덧붙이지만, + * 요청 예시에는 이 실측 보정을 제외한 순수 경로(치환된 path 파라미터 포함)를 노출한다. + * path 파라미터가 치환되지 않은(실측 제외) 경우 `{param}` placeholder 를 그대로 남긴다. + * + * @param array $route 라우트 메타데이터 + * @param array $sampleMap 도메인별 대표 샘플 맵 + * @return string 요청 예시용 URI + */ + private function exampleUri(array $route, array $sampleMap): string + { + $resolved = $this->resolvePathParams($route, $sampleMap); + + // 실측용 per_page 보정 쿼리를 제거해 정규 경로만 남긴다. + return preg_replace('/[?&]per_page=25$/', '', $resolved) ?? $resolved; + } + + /** + * 쓰기 메서드 in-process 실측용 요청 바디를 FormRequest 규칙 근거로 구성합니다. + * + * 필수 파라미터를 우선하고, 검증을 통과할 수 있도록 이름·타입·허용값에 맞는 유효값을 채웁니다. + * 파일(image/file) 파라미터는 실측 바디에서 제외한다(in-process dispatch 로 파일 전송 불가 → + * 해당 엔드포인트는 http-422 로 스킵되고 정적 요청 예시[multipart]만 방출된다). + * + * @param array $request FormRequest 분석 결과 + * @param array $sampleMap 도메인별 대표 샘플 맵 + * @return array 실측 요청 바디 + */ + private function buildWriteBody(array $request, array $sampleMap = []): array + { + $params = $request['params'] ?? []; + $body = []; + + foreach ($params as $p) { + $name = (string) $p['name']; + $type = (string) ($p['type'] ?? 'string'); + + // 파일 타입은 in-process 바디로 전송 불가 → 생략. + if (in_array($type, ['file', 'image'], true)) { + continue; + } + + $body[$name] = $this->writeValue($name, $type, (string) ($p['allowed'] ?? ''), $sampleMap); + } + + return $body; + } + + /** + * 쓰기 실측 바디의 파라미터 값을 검증 통과 가능하도록 생성합니다. + * + * @param string $name 파라미터명 + * @param string $type 타입 + * @param string $allowed 허용값 설명 (in: 열거 등) + * @param array $sampleMap 도메인 샘플 맵 + * @return mixed 유효 예시값 + */ + private function writeValue(string $name, string $type, string $allowed, array $sampleMap): mixed + { + $lower = strtolower($name); + + // 허용값 in: 열거(백틱)가 있으면 첫 값을 채택(검증 통과 보장). + if (preg_match('/`([^`]+)`/', $allowed, $m)) { + return $m[1]; + } + + return match (true) { + $type === 'boolean' => true, + $type === 'integer', $type === 'number' => 1, + $type === 'array' => [], + $type === 'email' || str_contains($lower, 'email') => 'probe_'.uniqid().'@example.com', + $type === 'uuid' => (string) Str::uuid(), + $type === 'date' => '2026-01-01', + str_contains($lower, 'password') => 'Password123!', + str_contains($lower, 'url') || str_contains($lower, 'homepage') => 'https://example.com', + // identifier/slug 는 소문자/숫자/밑줄만 허용하는 규칙이 흔하므로 하이픈 없이 생성. + str_contains($lower, 'identifier') || str_contains($lower, 'slug') => 'probe_'.uniqid(), + str_contains($lower, 'locale') || $lower === 'language' => 'ko', + str_contains($lower, 'country') => 'KR', + str_contains($lower, 'timezone') => 'Asia/Seoul', + default => '실측 예시값', + }; + } + + /** + * URI 의 path 파라미터를 바인딩된 모델의 실제 레코드로 치환합니다. + * + * @param array $route 라우트 메타데이터 + * @param array $sampleMap 도메인별 대표 샘플 맵 + * @return string 치환된 URI (치환 실패 시 원본 유지 → 프로브가 실측 제외) + */ + private function resolvePathParams(array $route, array $sampleMap = []): string + { + $uri = $route['uri']; + $domain = $route['domain_group'] ?? null; + + // path 파라미터가 있으면: 완전 샘플 대표 레코드 우선, 없으면 첫 레코드 키로 치환 + foreach ($route['path_params'] as $param) { + $modelClass = $route['path_bindings'][$param] ?? null; + + // 라우트-모델 바인딩이 없는 확장 패턴(예: show(int $id))은 도메인 샘플 맵의 + // 대표 모델로 폴백한다. 단, 파라미터명이 도메인의 단수 리소스명과 일치할 때만 + // (pages/{page} → Page). {slug}/{hash}/{versionId} 등 route key 가 다른 + // 문자열/보조 파라미터에는 폴백하지 않아 잘못된 치환(404)을 피한다. + if (! $modelClass && $domain !== null && isset($sampleMap[$domain]) && $this->paramMatchesDomain($param, $domain)) { + $modelClass = $sampleMap[$domain]['model']; + } + + if (! $modelClass) { + // route-model binding 도 도메인 폴백도 없는 문자열 path 파라미터 + // (예: board 의 boards/{slug}/posts/{id}). 도메인 대표 샘플이 명시한 + // path_params 맵(param 명 => 실제 값)에 해당 param 이 있으면 그 값으로 치환한다. + // slug 라우팅을 쓰는 확장이 route key(id) 와 무관한 slug/id 조합을 + // 실측할 수 있도록 하는 일반 경로다. (파라미터명 정확 일치만 허용 → 오치환 방지) + $explicit = $domain !== null ? ($sampleMap[$domain]['path_params'][$param] ?? null) : null; + + if ($explicit !== null) { + $uri = str_replace('{'.$param.'}', (string) $explicit, $uri); + + continue; + } + + // 코어 다수 라우트는 route-model binding 없이 `{id}`/`{identifier}`/`{key}` 문자열 + // 파라미터를 쓰고 컨트롤러가 직접 조회한다. 도메인 대표 샘플 모델의 route key(또는 + // 해당 컬럼)로 치환해 실측 가능하게 한다. (마지막 세그먼트 리소스 파라미터에 한함) + $generic = $this->resolveGenericPathParam($param, $domain, $sampleMap); + + if ($generic !== null) { + $uri = str_replace('{'.$param.'}', (string) $generic, $uri); + } + + continue; + } + + // 시드된 완전 샘플 중 같은 모델이 있으면 그 route key 를 우선 사용 + $value = $this->sampleKeyForModel($modelClass, $sampleMap) ?? $this->firstRouteKey($modelClass); + + if ($value !== null) { + $uri = str_replace('{'.$param.'}', (string) $value, $uri); + } + } + + // 목록 GET 은 여러 행을 받아 필드별 non-null 대표 샘플을 확보한다 + // (per_page=1 이면 첫 행이 우연히 비어 "항상 null" 처럼 보임). + if ($route['method'] === 'GET' && ! Str::contains($uri, '{')) { + return $uri.(Str::contains($uri, '?') ? '&' : '?').'per_page=25'; + } + + return $uri; + } + + /** + * route-model binding 없는 제네릭 path 파라미터를 도메인 샘플 모델의 실제 키로 치환합니다. + * + * 코어 다수 라우트는 `{id}`/`{identifier}`/`{key}`/`{slug}` 문자열 파라미터를 쓰고 컨트롤러가 + * 직접 조회한다. 도메인 대표 샘플 모델을 찾아 파라미터명에 대응하는 컬럼값으로 치환한다: + * - `id` → 모델 기본키 + * - `identifier`/`slug`/`key`/`code` → 같은 이름 컬럼이 있으면 그 값, 없으면 route key + * - 그 외 → 치환 안 함(null 반환 → 실측 제외 유지, 오치환 방지) + * + * @param string $param path 파라미터명 + * @param string|null $domain 도메인 그룹명 + * @param array $sampleMap 도메인 샘플 맵 + * @return string|null 치환값 (없으면 null) + */ + private function resolveGenericPathParam(string $param, ?string $domain, array $sampleMap): ?string + { + $modelClass = ($domain !== null && isset($sampleMap[$domain])) + ? $sampleMap[$domain]['model'] + : $this->domainModelHint($domain); + + if (! $modelClass) { + return null; + } + + try { + /** @var Model $model */ + $model = new $modelClass; + $record = $modelClass::query()->orderBy($model->getKeyName())->first(); + + if (! $record) { + return null; + } + + $lower = strtolower($param); + + // id 계열 → 기본키 + if ($lower === 'id' || str_ends_with($lower, '_id')) { + return (string) $record->getKey(); + } + + // identifier/slug/key/code → 동일 컬럼이 있으면 그 값 + if (in_array($lower, ['identifier', 'slug', 'key', 'code'], true)) { + $val = $record->getAttribute($lower); + + return $val !== null ? (string) $val : (string) $record->getRouteKey(); + } + + // 도메인 단수 리소스명 파라미터 → route key + if ($domain !== null && $this->paramMatchesDomain($param, $domain)) { + return (string) $record->getRouteKey(); + } + + return null; + } catch (\Throwable) { + return null; + } + } + + /** + * path 파라미터명이 도메인의 단수 리소스명과 일치하는지 판정합니다. + * + * 도메인 그룹은 복수형(pages)이고 라우트 파라미터는 단수(page)이므로, + * 파라미터명 == 도메인명 또는 파라미터명 == 도메인 단수형일 때만 매칭으로 본다. + * (page ↔ pages). {slug}/{hash}/{versionId} 는 어느 쪽과도 일치하지 않는다. + * + * @param string $param path 파라미터명 + * @param string $domain 도메인 그룹명 (복수형 가능) + * @return bool 도메인 리소스 파라미터 여부 + */ + private function paramMatchesDomain(string $param, string $domain): bool + { + return $param === $domain + || $param === Str::singular($domain) + || Str::plural($param) === $domain; + } + + /** + * 라우트의 주 모델 컬럼 주석 맵을 반환합니다 (응답 필드 설명 기본값). + * + * path 파라미터에 바인딩된 모델을 우선하고, 없으면 도메인 샘플 맵의 대표 + * 모델을 주 모델로 보고 그 테이블 주석을 사용합니다. + * + * @param array $route 라우트 메타데이터 + * @param ColumnCommentResolver $resolver 컬럼 주석 해석기 + * @param array $sampleMap 도메인별 대표 샘플 맵 + * @return array 컬럼명 => 주석 + */ + private function columnComments(array $route, ColumnCommentResolver $resolver, array $sampleMap = []): array + { + $bindings = $route['path_bindings'] ?? []; + + // 마지막 path 바인딩 모델(가장 구체적인 리소스)을 주 모델로 사용 + $modelClass = null; + foreach ($bindings as $class) { + $modelClass = $class; + } + + // path 바인딩이 없으면(목록/단건 me 등) 도메인 샘플 맵 → 정적 힌트 순으로 유추 + if (! $modelClass) { + $domain = $route['domain_group'] ?? null; + $modelClass = ($domain && isset($sampleMap[$domain]) ? $sampleMap[$domain]['model'] : null) + ?? $this->domainModelHint($domain); + } + + return $modelClass ? $resolver->forModel($modelClass) : []; + } + + /** + * 샘플 맵으로 유추되지 않는 도메인의 주 모델을 정적 힌트로 매핑합니다. + * + * me/auth/profile/password 등은 path 바인딩도 없고 sampleMap 키(users)와도 + * 도메인명이 달라 자동 유추가 안 되지만, 모두 User 필드를 반환합니다. + * + * @param string|null $domain 도메인 그룹명 + * @return class-string|null 주 모델 FQCN (없으면 null) + */ + private function domainModelHint(?string $domain): ?string + { + if ($domain === null) { + return null; + } + + $hints = [ + 'me' => User::class, + 'auth' => User::class, + 'profile' => User::class, + 'password' => User::class, + 'modules' => Module::class, + 'plugins' => Plugin::class, + 'templates' => Template::class, + ]; + + return $hints[$domain] ?? null; + } + + /** + * 완전 샘플 맵에서 샘플 사용자의 DB id 를 조회합니다 (토큰 발급 대상). + * + * @param array $sampleMap 대표 샘플 맵 + * @return int|null 샘플 사용자 id (없으면 null → 첫 사용자로 fallback) + */ + private function sampleUserId(array $sampleMap): ?int + { + $users = $sampleMap['users'] ?? null; + + if (! $users) { + return null; + } + + return User::query() + ->where($users['key'], $users['value']) + ->value('id'); + } + + /** + * 시드된 완전 샘플 맵에서 해당 모델의 대표 route key 를 찾습니다. + * + * @param class-string $modelClass 모델 FQCN + * @param array $sampleMap 대표 샘플 맵 + * @return string|null 대표 route key 값 (없으면 null) + */ + private function sampleKeyForModel(string $modelClass, array $sampleMap): ?string + { + foreach ($sampleMap as $sample) { + if ($sample['model'] === $modelClass) { + return $sample['value']; + } + } + + return null; + } + + /** + * 모델의 첫 레코드 route key 값을 반환합니다. + * + * @param class-string $modelClass 모델 FQCN + * @return mixed route key 값 (레코드 없으면 null) + */ + private function firstRouteKey(string $modelClass): mixed + { + try { + /** @var Model $model */ + $model = new $modelClass; + $keyName = $model->getRouteKeyName(); + + $record = $modelClass::query()->orderBy($model->getKeyName())->first(); + + return $record?->getAttribute($keyName); + } catch (\Throwable) { + return null; + } + } + + /** + * 라우트가 저장될 문서 파일 경로를 산출합니다. + * + * @param array $route 라우트 메타데이터 + * @return string 절대 파일 경로 + */ + private function targetFile(array $route): string + { + $owner = $route['owner']; + $domain = $route['domain_group']; + + if ($owner['type'] === 'core') { + return base_path("docs/backend/api/{$domain}.md"); + } + + $base = $owner['type'] === 'module' ? 'modules' : 'plugins'; + + return base_path("{$base}/_bundled/{$owner['id']}/docs/api/{$domain}.md"); + } + + /** + * 번들 확장이 소유한 API 문서 목차(README.md)를 파일 시스템 패턴 스캔으로 수집합니다. + * + * 확장명을 하드코딩하지 않고 `{modules,plugins}/_bundled/{*}/docs/api/README.md` 존재 + * 여부만으로 발견한다(동적 로딩 원칙). 문서 수·엔드포인트 수는 스캐폴더가 방출한 + * 집계 라인(`**문서 수**: N · **엔드포인트 수**: M`)에서 읽는다. + * + * 링크 경로는 코어 README(`docs/backend/api/`) 기준 상대경로로 만든다. + * + * @return array 확장 목록 + */ + private function scanExtensionApiReadmes(): array + { + $results = []; + + foreach (['modules' => 'module', 'plugins' => 'plugin'] as $dir => $type) { + $bundled = base_path("{$dir}/_bundled"); + if (! File::isDirectory($bundled)) { + continue; + } + + foreach (File::directories($bundled) as $extDir) { + $readme = $extDir.DIRECTORY_SEPARATOR.'docs'.DIRECTORY_SEPARATOR.'api'.DIRECTORY_SEPARATOR.'README.md'; + if (! File::exists($readme)) { + continue; + } + + $id = basename($extDir); + $content = File::get($readme); + $docs = 0; + $endpoints = 0; + if (preg_match('/\*\*문서 수\*\*:\s*(\d+)\s*·\s*\*\*엔드포인트 수\*\*:\s*(\d+)/u', $content, $m)) { + $docs = (int) $m[1]; + $endpoints = (int) $m[2]; + } + + $results[] = [ + 'id' => $id, + 'type' => $type, + // docs/backend/api/README.md → 프로젝트 루트까지 3단계 상위 + 'path' => "../../../{$dir}/_bundled/{$id}/docs/api/README.md", + 'docs' => $docs, + 'endpoints' => $endpoints, + ]; + } + } + + return $results; + } + + /** + * 소유 대상의 API 문서 목차(README.md) 경로를 반환합니다. + * + * @param array{type: string, id: string|null} $owner 소유 메타 + * @return string README.md 절대 경로 + */ + private function readmeFile(array $owner): string + { + if ($owner['type'] === 'core') { + return base_path('docs/backend/api/README.md'); + } + + $base = $owner['type'] === 'module' ? 'modules' : 'plugins'; + + return base_path("{$base}/_bundled/{$owner['id']}/docs/api/README.md"); + } + + /** + * 문서 헤더(제목 + TL;DR)를 생성합니다. + * + * @param string $file 문서 파일 경로 + * @param array $firstRoute 대표 라우트 + * @return string 문서 헤더 + */ + private function documentHeader(string $file, array $firstRoute): string + { + $domain = Str::headline(pathinfo($file, PATHINFO_FILENAME)); + $owner = $firstRoute['owner']; + $ownerLabel = $owner['type'] === 'core' ? '코어' : "{$owner['type']} `{$owner['id']}`"; + + return << **소유**: {$ownerLabel} · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + + --- + + ## TL;DR (5초 요약) + + ```text + 1. 이 문서는 실제 API 호출로 실측한 {$domain} 엔드포인트 레퍼런스입니다 + 2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) + 3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 + 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 + 5. 설명(TODO) 칸은 사람이 채웁니다 + ``` + + --- + + + MD; + } +} diff --git a/app/Contracts/ApiDoc/ApiDocSampleSeeder.php b/app/Contracts/ApiDoc/ApiDocSampleSeeder.php new file mode 100644 index 00000000..ffe77aff --- /dev/null +++ b/app/Contracts/ApiDoc/ApiDocSampleSeeder.php @@ -0,0 +1,33 @@ + 실제 값 맵)를 두면, route-model binding + * 도 도메인 폴백도 없는 문자열 path 파라미터(예: 게시판 slug 라우팅 + * `boards/{slug}/posts/{id}`)를 param 명 정확 일치로 치환해 상세 GET 을 + * 실측할 수 있습니다. 미제공 시 route key 기반 자동 치환만 적용됩니다. + * + * @return array}> 도메인 => 대표 레코드 정보 + */ + public function seed(): array; +} diff --git a/app/Extension/Traits/CachesModuleStatus.php b/app/Extension/Traits/CachesModuleStatus.php index 95cd4c49..73a1b88e 100644 --- a/app/Extension/Traits/CachesModuleStatus.php +++ b/app/Extension/Traits/CachesModuleStatus.php @@ -6,6 +6,7 @@ use App\Contracts\Extension\CacheInterface; use App\Enums\ExtensionStatus; use App\Extension\Cache\CoreCacheDriver; use App\Models\Module; +use App\Support\InstallerContext; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; @@ -63,8 +64,6 @@ trait CachesModuleStatus /** * 모듈 상태 캐시를 무효화합니다. * 모듈 상태 변경 시 (install, activate, deactivate, uninstall) 호출해야 합니다. - * - * @return void */ public static function invalidateModuleStatusCache(): void { @@ -79,10 +78,14 @@ trait CachesModuleStatus * 설치 완료 상태(`config('app.installer_completed')`)일 때는 테이블 존재를 * 전제로 하여 `Schema::hasTable()` 호출을 건너뜁니다. 인스톨러 이전 환경이나 * 테스트에서는 기존 체크 경로로 폴백합니다. + * + * 단, 마이그레이션 계열 명령 실행 중에는 `INSTALLER_COMPLETED=true` 라도 테이블이 + * 아직 없을 수 있으므로(빈 DB 새 서버에 .env 복사 후 migrate 전) fast-path 를 + * 신뢰하지 않고 실제 `Schema::hasTable()` 폴백으로 진입합니다. */ private static function isExtensionTableReady(string $table): bool { - if (config('app.installer_completed')) { + if (! InstallerContext::isSchemaMutatingCommand() && config('app.installer_completed')) { return true; } diff --git a/app/Extension/Traits/CachesPluginStatus.php b/app/Extension/Traits/CachesPluginStatus.php index e6c1f172..de1caa82 100644 --- a/app/Extension/Traits/CachesPluginStatus.php +++ b/app/Extension/Traits/CachesPluginStatus.php @@ -6,6 +6,7 @@ use App\Contracts\Extension\CacheInterface; use App\Enums\ExtensionStatus; use App\Extension\Cache\CoreCacheDriver; use App\Models\Plugin; +use App\Support\InstallerContext; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; @@ -63,8 +64,6 @@ trait CachesPluginStatus /** * 플러그인 상태 캐시를 무효화합니다. * 플러그인 상태 변경 시 (install, activate, deactivate, uninstall) 호출해야 합니다. - * - * @return void */ public static function invalidatePluginStatusCache(): void { @@ -76,10 +75,14 @@ trait CachesPluginStatus /** * 설치 완료 상태에서는 `Schema::hasTable()` 호출을 건너뜁니다. * 인스톨러 이전 환경이나 테스트에서는 기존 체크 경로로 폴백합니다. + * + * 단, 마이그레이션 계열 명령 실행 중에는 `INSTALLER_COMPLETED=true` 라도 테이블이 + * 아직 없을 수 있으므로(빈 DB 새 서버에 .env 복사 후 migrate 전) fast-path 를 + * 신뢰하지 않고 실제 `Schema::hasTable()` 폴백으로 진입합니다. */ private static function isPluginTableReady(): bool { - if (config('app.installer_completed')) { + if (! InstallerContext::isSchemaMutatingCommand() && config('app.installer_completed')) { return true; } diff --git a/app/Extension/Traits/CachesTemplateStatus.php b/app/Extension/Traits/CachesTemplateStatus.php index 10294afd..746e06cd 100644 --- a/app/Extension/Traits/CachesTemplateStatus.php +++ b/app/Extension/Traits/CachesTemplateStatus.php @@ -6,6 +6,7 @@ use App\Contracts\Extension\CacheInterface; use App\Enums\ExtensionStatus; use App\Extension\Cache\CoreCacheDriver; use App\Models\Template; +use App\Support\InstallerContext; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; @@ -86,8 +87,6 @@ trait CachesTemplateStatus /** * 템플릿 상태 캐시를 무효화합니다. * 템플릿 상태 변경 시 (install, activate, deactivate, uninstall) 호출해야 합니다. - * - * @return void */ public static function invalidateTemplateStatusCache(): void { @@ -101,10 +100,14 @@ trait CachesTemplateStatus /** * 설치 완료 상태에서는 `Schema::hasTable()` 호출을 건너뜁니다. * 인스톨러 이전 환경이나 테스트에서는 기존 체크 경로로 폴백합니다. + * + * 단, 마이그레이션 계열 명령 실행 중에는 `INSTALLER_COMPLETED=true` 라도 테이블이 + * 아직 없을 수 있으므로(빈 DB 새 서버에 .env 복사 후 migrate 전) fast-path 를 + * 신뢰하지 않고 실제 `Schema::hasTable()` 폴백으로 진입합니다. */ private static function isTemplateTableReady(): bool { - if (config('app.installer_completed')) { + if (! InstallerContext::isSchemaMutatingCommand() && config('app.installer_completed')) { return true; } diff --git a/app/Providers/LanguagePackServiceProvider.php b/app/Providers/LanguagePackServiceProvider.php index fe30659a..3454ef02 100644 --- a/app/Providers/LanguagePackServiceProvider.php +++ b/app/Providers/LanguagePackServiceProvider.php @@ -18,6 +18,7 @@ use App\Services\LanguagePack\LanguagePackBundledRegistrar; use App\Services\LanguagePack\LanguagePackRegistry; use App\Services\LanguagePack\LanguagePackSeedInjector; use App\Services\LanguagePack\LanguagePackTranslator; +use App\Support\InstallerContext; use Illuminate\Contracts\Foundation\Application; use Illuminate\Database\QueryException; use Illuminate\Support\Facades\File; @@ -477,8 +478,7 @@ class LanguagePackServiceProvider extends ServiceProvider return false; } - if ($this->app->runningInConsole() - && in_array($_SERVER['argv'][1] ?? null, ['migrate', 'migrate:fresh', 'migrate:rollback', 'migrate:reset', 'db:wipe'], true)) { + if (InstallerContext::isSchemaMutatingCommand()) { return false; } diff --git a/app/Providers/ModuleRouteServiceProvider.php b/app/Providers/ModuleRouteServiceProvider.php index 658610b2..767ebf09 100644 --- a/app/Providers/ModuleRouteServiceProvider.php +++ b/app/Providers/ModuleRouteServiceProvider.php @@ -6,6 +6,7 @@ use App\Enums\ExtensionStatus; use App\Extension\ExtensionManager; use App\Extension\Testing\ExtensionTestAllowlist; use App\Models\Module; +use App\Support\InstallerContext; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Route; @@ -52,7 +53,12 @@ class ModuleRouteServiceProvider extends ServiceProvider // 설치 완료 상태에서는 Schema introspection 을 건너뜀 (매 요청 쿼리 제거). // 인스톨러 이전 환경에서는 기존 체크 경로로 폴백. - if (! config('app.installer_completed')) { + // + // 단, 마이그레이션 계열 명령 실행 중에는 INSTALLER_COMPLETED=true 라도 테이블이 + // 아직 없을 수 있으므로(빈 DB 새 서버에 .env 복사 후 migrate 전) fast-path 를 + // 무력화하고 hasTable 검증 경로로 진입한다. 빈 DB 면 여기서 early return 하여 + // 아래 무방비 pluck 이 table not found 로 부팅을 깨뜨리는 것을 막는다. + if (! config('app.installer_completed') || InstallerContext::isSchemaMutatingCommand()) { try { if (! Schema::hasTable('modules')) { return; diff --git a/app/Providers/PluginRouteServiceProvider.php b/app/Providers/PluginRouteServiceProvider.php index d29a1838..0798c3a3 100644 --- a/app/Providers/PluginRouteServiceProvider.php +++ b/app/Providers/PluginRouteServiceProvider.php @@ -4,6 +4,7 @@ namespace App\Providers; use App\Extension\ExtensionManager; use App\Extension\Testing\ExtensionTestAllowlist; +use App\Support\InstallerContext; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Route; @@ -48,7 +49,12 @@ class PluginRouteServiceProvider extends ServiceProvider // 설치 완료 상태에서는 Schema introspection 을 건너뜀 (매 요청 쿼리 제거). // 인스톨러 이전 환경에서는 기존 체크 경로로 폴백. - if (! config('app.installer_completed')) { + // + // 단, 마이그레이션 계열 명령 실행 중에는 INSTALLER_COMPLETED=true 라도 테이블이 + // 아직 없을 수 있으므로(빈 DB 새 서버에 .env 복사 후 migrate 전) fast-path 를 + // 무력화하고 hasTable 검증 경로로 진입한다. 빈 DB 면 여기서 early return 하여 + // 이후 플러그인 부팅/상태 조회가 table not found 로 부팅을 깨뜨리는 것을 막는다. + if (! config('app.installer_completed') || InstallerContext::isSchemaMutatingCommand()) { try { if (! Schema::hasTable('plugins')) { return; diff --git a/app/Support/ApiDoc/ApiDocSampleService.php b/app/Support/ApiDoc/ApiDocSampleService.php new file mode 100644 index 00000000..4d871642 --- /dev/null +++ b/app/Support/ApiDoc/ApiDocSampleService.php @@ -0,0 +1,385 @@ + 도메인 => 대표 레코드 정보 + */ + public function seed(): array + { + $map = []; + + // permissions → roles → users 순: user 가 role 을, role 이 permission 을 참조 + $map['permissions'] = $this->seedPermission(); + $map['roles'] = $this->seedRole(); + $map['users'] = $this->seedUser(); + $map['menus'] = $this->seedMenu(); + $map['notification-definitions'] = $this->seedNotificationDefinition(); + $map['schedules'] = $this->seedSchedule(); + $map['activity-logs'] = $this->seedActivityLog(); + $map['language-packs'] = $this->seedLanguagePack(); + $map['notification-logs'] = $this->seedNotificationLog(); + $map['notification-templates'] = $this->seedNotificationTemplate(); + + return array_filter($map); + } + + /** + * 완전한 사용자 샘플을 생성하고 roles 관계를 연결합니다. + * + * @return array{model: class-string, key: string, value: string} 대표 레코드 정보 + */ + private function seedUser(): array + { + $user = User::query()->where('email', self::MARKER.'-user@example.com')->first(); + + if (! $user) { + $user = User::factory()->complete()->create([ + 'name' => 'API 문서 샘플 사용자', + 'email' => self::MARKER.'-user@example.com', + ]); + + $role = Role::query()->where('identifier', self::MARKER.'-role')->first() + ?? Role::query()->where('identifier', 'admin')->first() + ?? Role::query()->first() + ?? Role::factory()->create(['identifier' => self::MARKER.'-role-fallback']); + $user->roles()->syncWithoutDetaching([$role->id]); + } + + return ['model' => User::class, 'key' => $user->getRouteKeyName(), 'value' => (string) $user->getRouteKey()]; + } + + /** + * 완전한 역할 샘플을 생성하고 permissions 관계를 연결합니다. + * + * @return array{model: class-string, key: string, value: string} 대표 레코드 정보 + */ + private function seedRole(): array + { + $role = Role::query()->where('identifier', self::MARKER.'-role')->first(); + + if (! $role) { + $role = Role::factory()->create([ + 'identifier' => self::MARKER.'-role', + 'name' => ['ko' => 'API 문서 샘플 역할', 'en' => 'API Doc Sample Role'], + 'description' => ['ko' => '문서 실측용 역할', 'en' => 'Sample role for API docs'], + 'is_active' => true, + ]); + + $permissionIds = Permission::query()->limit(3)->pluck('id')->all(); + if ($permissionIds !== []) { + $role->permissions()->syncWithoutDetaching($permissionIds); + } + } + + return ['model' => Role::class, 'key' => $role->getRouteKeyName(), 'value' => (string) $role->getRouteKey()]; + } + + /** + * 완전한 권한 샘플(부모-자식 계층)을 생성합니다. + * + * @return array{model: class-string, key: string, value: string} 대표 레코드 정보 + */ + private function seedPermission(): array + { + $permission = Permission::query()->where('identifier', self::MARKER.'.parent')->first(); + + if (! $permission) { + $permission = Permission::factory()->create([ + 'identifier' => self::MARKER.'.parent', + 'name' => ['ko' => 'API 문서 샘플 권한', 'en' => 'API Doc Sample Permission'], + 'description' => ['ko' => '문서 실측용 권한', 'en' => 'Sample permission'], + 'type' => 'admin', + ]); + + Permission::factory()->create([ + 'parent_id' => $permission->id, + 'identifier' => self::MARKER.'.child', + 'name' => ['ko' => '하위 권한', 'en' => 'Child Permission'], + 'type' => 'admin', + ]); + } + + return ['model' => Permission::class, 'key' => $permission->getRouteKeyName(), 'value' => (string) $permission->getRouteKey()]; + } + + /** + * 완전한 메뉴 샘플(부모-자식)을 생성합니다. + * + * @return array{model: class-string, key: string, value: string} 대표 레코드 정보 + */ + private function seedMenu(): array + { + $menu = Menu::query()->where('slug', self::MARKER.'-menu')->first(); + + if (! $menu) { + $creator = User::query()->where('email', self::MARKER.'-user@example.com')->first(); + + $menu = Menu::factory()->create([ + 'name' => ['ko' => 'API 문서 샘플 메뉴', 'en' => 'API Doc Sample Menu'], + 'slug' => self::MARKER.'-menu', + 'url' => '/admin/apidoc-sample', + 'icon' => 'fas fa-book', + 'is_active' => true, + 'created_by' => $creator?->id, + ]); + + Menu::factory()->create([ + 'name' => ['ko' => '하위 메뉴', 'en' => 'Child Menu'], + 'slug' => self::MARKER.'-menu-child', + 'parent_id' => $menu->id, + 'created_by' => $creator?->id, + ]); + } + + return ['model' => Menu::class, 'key' => $menu->getRouteKeyName(), 'value' => (string) $menu->getRouteKey()]; + } + + /** + * 완전한 알림 정의 샘플(템플릿 포함)을 생성합니다. + * + * @return array{model: class-string, key: string, value: string} 대표 레코드 정보 + */ + private function seedNotificationDefinition(): array + { + $definition = NotificationDefinition::query()->where('type', self::MARKER.'.event')->first(); + + if (! $definition) { + $definition = NotificationDefinition::factory()->create([ + 'type' => self::MARKER.'.event', + 'name' => ['ko' => 'API 문서 샘플 알림', 'en' => 'API Doc Sample Notification'], + 'description' => ['ko' => '문서 실측용 알림 정의', 'en' => 'Sample notification'], + 'channels' => ['database', 'mail'], + 'is_active' => true, + ]); + } + + return ['model' => NotificationDefinition::class, 'key' => $definition->getRouteKeyName(), 'value' => (string) $definition->getRouteKey()]; + } + + /** + * 완전한 스케줄 샘플(실행 이력 포함)을 생성합니다. + * + * @return array{model: class-string, key: string, value: string} 대표 레코드 정보 + */ + private function seedSchedule(): array + { + $schedule = Schedule::query()->where('name', 'API 문서 샘플 스케줄')->first(); + + if (! $schedule) { + $creator = $this->sampleUser(); + + $schedule = Schedule::create([ + 'name' => 'API 문서 샘플 스케줄', + 'description' => '문서 실측용 스케줄', + 'type' => 'artisan', + 'command' => 'cache:clear', + 'expression' => '0 3 * * *', + 'frequency' => 'daily', + 'without_overlapping' => true, + 'run_in_maintenance' => false, + 'timeout' => 300, + 'is_active' => true, + 'last_result' => 'success', + 'last_run_at' => now()->subDay(), + 'next_run_at' => now()->addDay(), + 'created_by' => $creator?->id, + ]); + } + + return ['model' => Schedule::class, 'key' => $schedule->getRouteKeyName(), 'value' => (string) $schedule->getRouteKey()]; + } + + /** + * 완전한 활동 로그 샘플(actor + changes 포함)을 생성합니다. + * + * @return array{model: class-string, key: string, value: string} 대표 레코드 정보 + */ + private function seedActivityLog(): array + { + $log = ActivityLog::query()->where('description_key', 'apidoc.sample.action')->first(); + + if (! $log) { + $user = $this->sampleUser(); + + $log = ActivityLog::create([ + 'log_type' => 'admin', + 'loggable_type' => User::class, + 'loggable_id' => $user?->id, + 'user_id' => $user?->id, + 'action' => 'user.update', + 'description_key' => 'apidoc.sample.action', + 'description_params' => ['name' => 'API 문서 샘플'], + 'properties' => ['source' => 'apidoc'], + 'changes' => ['status' => ['old' => 'inactive', 'new' => 'active']], + 'ip_address' => '127.0.0.1', + 'user_agent' => 'ApiDocgen/1.0', + 'created_at' => now(), + ]); + } + + return ['model' => ActivityLog::class, 'key' => $log->getRouteKeyName(), 'value' => (string) $log->getRouteKey()]; + } + + /** + * 완전한 언어팩 샘플(manifest 채움)을 생성합니다. + * + * `scope=core` + `status=active` 조합은 그 locale 을 시스템 전역 지원 로케일로 승격시킨다 + * (LanguagePackRepository::getActiveCoreLocales → refreshSupportedLocales → + * config('app.supported_locales'/'app.translatable_locales')). + * + * 문서용 샘플은 조회 예시를 만들기 위한 것이지 시스템 동작을 바꿀 자격이 없다. 샘플이 그 + * 경로를 타면 이후 시드/저장되는 모든 다국어 데이터에 실재하지 않는 로케일 키가 박히고, + * 샘플을 비활성화한 뒤에는 그 키가 검증(TranslatableField)을 통과하지 못해 저장이 막힌다. + * + * 따라서 승격 경로를 벗어난 조합(module 스코프 + installed 상태)으로 생성하며, locale 도 + * 실재하는 번들 로케일만 사용한다. + * + * @return array{model: class-string, key: string, value: string} 대표 레코드 정보 + */ + private function seedLanguagePack(): array + { + $pack = LanguagePack::query()->where('identifier', 'apidoc-sample-lang')->first(); + + if (! $pack) { + $installer = $this->sampleUser(); + + $pack = LanguagePack::create([ + 'identifier' => 'apidoc-sample-lang', + 'vendor' => 'apidoc', + 'scope' => 'module', + 'target_identifier' => 'apidoc-sample-module', + 'locale' => 'ja', + 'locale_name' => 'Japanese', + 'locale_native_name' => '日本語', + 'text_direction' => 'ltr', + 'version' => '1.0.0', + 'latest_version' => '1.0.0', + 'license' => 'MIT', + 'description' => ['ko' => '문서 실측용 언어팩', 'en' => 'Sample language pack'], + 'status' => 'installed', + 'is_protected' => false, + 'manifest' => [ + 'name' => ['ko' => 'API 문서 샘플 언어팩', 'en' => 'API Doc Sample Pack'], + 'version' => '1.0.0', + 'locale' => 'ja', + ], + 'source_type' => 'bundled', + 'installed_by' => $installer?->id, + 'installed_at' => now()->subDays(3), + 'activated_at' => null, + ]); + } + + return ['model' => LanguagePack::class, 'key' => $pack->getRouteKeyName(), 'value' => (string) $pack->getRouteKey()]; + } + + /** + * 완전한 알림 로그 샘플(수신자/발신자 포함)을 생성합니다. + * + * @return array{model: class-string, key: string, value: string} 대표 레코드 정보 + */ + private function seedNotificationLog(): array + { + $log = NotificationLog::query()->where('notification_type', 'apidoc.sample.event')->first(); + + if (! $log) { + $user = $this->sampleUser(); + + $log = NotificationLog::create([ + 'channel' => 'mail', + 'notification_type' => 'apidoc.sample.event', + 'extension_type' => 'core', + 'extension_identifier' => '', + 'recipient_user_id' => $user?->id, + 'recipient_identifier' => $user?->email, + 'recipient_name' => $user?->name, + 'sender_user_id' => $user?->id, + 'subject' => 'API 문서 샘플 알림', + 'body' => '문서 실측용 알림 본문입니다.', + 'status' => 'sent', + 'error_message' => null, + 'source' => 'apidoc', + 'sent_at' => now()->subHour(), + ]); + } + + return ['model' => NotificationLog::class, 'key' => $log->getRouteKeyName(), 'value' => (string) $log->getRouteKey()]; + } + + /** + * 완전한 알림 템플릿 샘플(정의 연결)을 생성합니다. + * + * @return array{model: class-string, key: string, value: string} 대표 레코드 정보 + */ + private function seedNotificationTemplate(): array + { + $this->seedNotificationDefinition(); + $definitionId = NotificationDefinition::query()->where('type', self::MARKER.'.event')->value('id'); + + $template = NotificationTemplate::query() + ->where('definition_id', $definitionId) + ->where('channel', 'mail') + ->first(); + + if (! $template && $definitionId) { + $updater = $this->sampleUser(); + + $template = NotificationTemplate::create([ + 'definition_id' => $definitionId, + 'channel' => 'mail', + 'subject' => 'API 문서 샘플 템플릿 제목', + 'body' => '안녕하세요 {{name}} 님, 문서 실측용 본문입니다.', + 'click_url' => '/admin/apidoc-sample', + 'recipients' => [['type' => 'role', 'value' => 'admin']], + 'is_active' => true, + 'is_default' => false, + 'updated_by' => $updater?->id, + ]); + } + + if (! $template) { + return []; + } + + return ['model' => NotificationTemplate::class, 'key' => $template->getRouteKeyName(), 'value' => (string) $template->getRouteKey()]; + } + + /** + * 시드된 완전 샘플 사용자를 반환합니다 (연관 엔티티의 소유자/actor 용). + * + * @return User|null 샘플 사용자 (없으면 null) + */ + private function sampleUser(): ?User + { + return User::query()->where('email', self::MARKER.'-user@example.com')->first(); + } +} diff --git a/app/Support/ApiDoc/ApiDocScaffolder.php b/app/Support/ApiDoc/ApiDocScaffolder.php new file mode 100644 index 00000000..000a2d1f --- /dev/null +++ b/app/Support/ApiDoc/ApiDocScaffolder.php @@ -0,0 +1,1361 @@ +'; + + /** + * @var int 응답 예시의 목록(`data.data[]`) 최대 표시 항목 수 + * + * 목록 응답 body 는 클 수 있으므로 대표 항목만 남기고 나머지는 절단 주석으로 대체한다. + */ + private const LIST_EXAMPLE_LIMIT = 2; + + /** + * @var string 요청 예시의 토큰 마스킹 placeholder + * + * 실측 토큰은 임시 발급분이므로 문서에 평문 유출하지 않고 placeholder 로 마스킹한다. + */ + private const TOKEN_PLACEHOLDER = '{YOUR_TOKEN}'; + + /** + * @var string 요청 예시의 공개 Host placeholder + * + * 실측 기준 URL(로컬 개발 호스트 등)을 공개 문서에 노출하지 않고 중립 placeholder 로 마스킹한다. + * RFC 2606 예약 도메인(example.com) 기반이라 어느 환경에도 종속되지 않는다. + */ + private const DOC_HOST = 'api.example.com'; + + /** + * @param ResourceFieldDescriber $fieldDescriber accessor/computed 필드 설명기 + * @param ParameterDescriber $paramDescriber 공통 요청 파라미터 설명기 + */ + public function __construct( + private readonly ResourceFieldDescriber $fieldDescriber = new ResourceFieldDescriber, + private readonly ParameterDescriber $paramDescriber = new ParameterDescriber + ) {} + + /** + * 단일 엔드포인트의 마크다운 섹션을 생성합니다. + * + * @param array $route 라우트 메타데이터 + * @param array $request FormRequest 분석 결과 + * @param array|null $schema 실측 응답 스키마 (null=실측 안 됨) + * @param array $probeMeta 실측 메타 (status, skipped_reason, base_url, resolved_uri, body) + * @param array $commentMap 컬럼명 => 주석 (필드 설명 기본값) + * @return string 마크다운 섹션 + */ + public function endpointSection(array $route, array $request, ?array $schema, array $probeMeta, array $commentMap = []): string + { + $name = $route['name'] ?: '(unnamed)'; + $heading = "### {$route['method']} {$route['uri']}"; + $genKey = $name; + + $lines = []; + $lines[] = $heading; + $lines[] = self::GEN_START.$genKey.' -->'; + $lines[] = "- **라우트명**: `{$name}`"; + if ($route['controller']) { + $lines[] = "- **컨트롤러**: `{$route['controller']}@{$route['controller_method']}`"; + } + $lines[] = '- **인증/권한**: '.$this->authLine($route); + $lines[] = ''; + + $lines[] = '**요청 파라미터**'; + $lines[] = ''; + $lines[] = $this->requestParamTable($route, $request); + if (! empty($request['hook_filters'])) { + $hooks = implode('`, `', $request['hook_filters']); + $lines[] = ''; + $lines[] = "> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`{$hooks}`)."; + } + $lines[] = ''; + + $lines[] = '**요청 예시**'; + $lines[] = ''; + $lines[] = $this->requestExampleBlock($route, $request, $probeMeta); + $lines[] = ''; + + $lines[] = '**응답 필드** (`data` 내부)'; + $lines[] = ''; + $lines[] = $this->responseFieldTable($schema, $probeMeta, $commentMap); + $lines[] = ''; + + $lines[] = '**응답 예시**'; + $lines[] = ''; + $lines[] = $this->responseExampleBlock($schema, $probeMeta); + $lines[] = ''; + + $lines[] = '**에러 응답**'; + $lines[] = ''; + $lines[] = $this->errorTable($route, $request); + $lines[] = ''; + $lines[] = self::GEN_END; + $lines[] = ''; + $lines[] = '**설명** '; + $lines[] = ''; + + return implode("\n", $lines)."\n"; + } + + /** + * @var string 코어 README 의 확장 API 목차 생성 블록 키 + */ + private const GEN_KEY_EXTENSIONS = 'api-readme-extensions'; + + /** + * @var string 코어/확장 README 의 도메인 목차 생성 블록 키 + */ + private const GEN_KEY_INDEX = 'api-readme-index'; + + /** + * @var string 코어 README 의 확장 API 목차 섹션 헤딩 (사람 서술 보존 경계) + */ + private const EXTENSIONS_HEADING = '확장 API 레퍼런스'; + + /** + * 대상(코어/확장)의 API 문서 디렉토리 목차(README.md)를 생성합니다. + * + * 도메인 파일별 링크 + 엔드포인트 수를 표로 나열해, 처음 진입한 개발자/AI 가 이 대상의 + * API 레퍼런스 전모를 한눈에 파악하도록 한다. 표는 `@generated` 블록 안에 두어 재생성 시 + * 갱신되며, 블록 밖 사람 서술(개요·주의사항)은 보존한다. 확장 API 문서의 발견성은 + * 코어 인덱스 생성기가 이 README 를 패턴 스캔(확장명 하드코딩 없이)해 확보한다. + * + * 코어 README 는 프로젝트 최상위 `README.md` 의 "API 레퍼런스" 진입점이므로, + * 도메인 목차 뒤에 확장 API 목차 블록을 함께 싣는다. `$extensions` 가 null 이면 + * 확장 블록을 생성하지 않고 기존 블록(있으면)을 그대로 보존한다 — `--scope` 축소 + * 실행이 확장 목차를 소실시키지 않도록 하기 위함이다. + * + * @param string $ownerLabel 소유 라벨 (예: '코어', '모듈 `sirsoft-ecommerce`') + * @param array $entries 도메인 파일 목록 + * @param string|null $existing 기존 README (사람 서술 보존용, 없으면 null) + * @param array|null $extensions 확장 API 목차 항목 (코어 전용, 미갱신 시 null) + * @return string README 마크다운 + */ + public function readmeIndex( + string $ownerLabel, + array $entries, + ?string $existing = null, + ?array $extensions = null + ): string { + usort($entries, fn ($a, $b) => strcmp($a['domain'], $b['domain'])); + $total = array_sum(array_column($entries, 'count')); + + $rows = ['| 문서 | 도메인 | 엔드포인트 |', '| --- | --- | --- |']; + foreach ($entries as $e) { + $rows[] = "| [{$e['file']}]({$e['file']}) | `{$e['domain']}` | {$e['count']} |"; + } + + $genKey = self::GEN_KEY_INDEX; + $lines = []; + $lines[] = '# API 레퍼런스 문서 목차'; + $lines[] = ''; + $lines[] = "> **소유**: {$ownerLabel} · **생성**: `php artisan api:docgen` (실측 기반)."; + $lines[] = '> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다.'; + $lines[] = ''; + + // 헤더와 목차 표 사이의 사람 개요(공통 규약 등)를 보존/삽입한다. + $overview = $existing !== null ? $this->extractReadmeOverview($existing, $genKey) : null; + if ($overview !== null && $overview !== '') { + $lines[] = $overview; + $lines[] = ''; + } + + $lines[] = self::GEN_START.$genKey.' -->'; + $lines[] = '- **문서 수**: '.count($entries)." · **엔드포인트 수**: {$total}"; + $lines[] = ''; + $lines[] = implode("\n", $rows); + $lines[] = ''; + $lines[] = self::GEN_END; + $lines[] = ''; + + $generated = implode("\n", $lines)."\n"; + + // 목차 블록 밖 사람 서술(주의사항 등)이 있으면 보존한다. + // 확장 목차(`## 확장 API 레퍼런스`) 이후는 별도 블록이므로 삼키지 않는다. + $prose = $existing !== null ? $this->extractReadmeTrailingProse($existing, $genKey) : null; + if ($prose !== null && $prose !== '') { + $generated .= "\n".$prose."\n"; + } + + $extensionBlock = $extensions !== null + ? $this->extensionIndexBlock($extensions) + : ($existing !== null ? $this->extractGeneratedBlock($existing, self::GEN_KEY_EXTENSIONS) : null); + + if ($extensionBlock !== null) { + $generated = rtrim($generated)."\n\n".trim($extensionBlock)."\n"; + } + + return $generated; + } + + /** + * 기존 코어 README 에서 확장 API 목차 블록만 in-place 갱신합니다. + * + * 확장 스코프 실행(`--scope=module:...`)이라 코어 도메인 목차를 재생성하지 않는 경우에도, + * 그 확장의 문서 수·엔드포인트 수 변동을 코어 진입점에 반영하기 위해 사용한다. + * 코어 README 에 확장 블록이 아직 없으면 문서 끝에 새로 덧붙인다. + * + * @param string $existing 기존 코어 README + * @param array $extensions 확장 목록 + * @return string|null 갱신된 README (변경 없으면 null) + */ + public function refreshExtensionIndex(string $existing, array $extensions): ?string + { + $block = $this->extensionIndexBlock($extensions); + $current = $this->extractGeneratedBlock($existing, self::GEN_KEY_EXTENSIONS); + + if ($current === null) { + return rtrim($existing)."\n\n".$block; + } + + if (trim($current) === trim($block)) { + return null; + } + + return str_replace($current, trim($block), $existing); + } + + /** + * 코어 README 의 확장 API 목차 블록을 생성합니다. + * + * 확장명을 하드코딩하지 않고, 커맨드가 `{modules,plugins}/_bundled/*\/docs/api/README.md` + * 를 스캔해 넘긴 목록을 그대로 표로 만든다. + * + * @param array $extensions 확장 목록 + * @return string 확장 목차 마크다운 블록 (@generated 경계 포함) + */ + private function extensionIndexBlock(array $extensions): string + { + usort($extensions, function ($a, $b) { + return [$a['type'], $a['id']] <=> [$b['type'], $b['id']]; + }); + + $lines = []; + $lines[] = '## '.self::EXTENSIONS_HEADING; + $lines[] = ''; + $lines[] = '> 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다.'; + $lines[] = ''; + $lines[] = self::GEN_START.self::GEN_KEY_EXTENSIONS.' -->'; + + if ($extensions === []) { + $lines[] = '_설치된 번들 확장 중 API 문서를 소유한 확장이 없습니다._'; + $lines[] = ''; + $lines[] = self::GEN_END; + + return implode("\n", $lines)."\n"; + } + + $lines[] = '- **확장 수**: '.count($extensions) + .' · **엔드포인트 수**: '.array_sum(array_column($extensions, 'endpoints')); + $lines[] = ''; + $lines[] = '| 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 |'; + $lines[] = '| --- | --- | --- | --- |'; + + foreach ($extensions as $e) { + $type = $e['type'] === 'module' ? '모듈' : '플러그인'; + $lines[] = "| `{$e['id']}` | {$type} | [docs/api/]({$e['path']}) | {$e['docs']} / {$e['endpoints']} |"; + } + + $lines[] = ''; + $lines[] = self::GEN_END; + + return implode("\n", $lines)."\n"; + } + + /** + * 인증/권한 라인을 구성합니다. + * + * @param array $route 라우트 메타데이터 + * @return string 인증/권한 설명 + */ + private function authLine(array $route): string + { + $mw = $route['middleware'] ?? []; + $parts = []; + + // optional.sanctum(회원/비회원 모두 접근 — Bearer 토큰 있으면 인증, 없으면 guest)은 + // auth:sanctum(인증 필수)과 계약이 다르므로 별도 표기한다. 'sanctum' 부분일치가 + // optional.sanctum 까지 auth:sanctum 으로 오표기하던 회귀를 막는다. + if ($this->hasMiddleware($mw, 'optional.sanctum')) { + $parts[] = '`optional.sanctum` (선택적 인증: 회원/비회원 모두 접근)'; + } elseif ($this->hasMiddleware($mw, 'sanctum')) { + $parts[] = '`auth:sanctum`'; + } + if ($this->hasMiddleware($mw, 'AdminMiddleware')) { + $parts[] = '`admin`'; + } + if ($route['permission']) { + $parts[] = "`permission:{$route['permission']}`"; + } + + return $parts === [] ? '공개 (인증 불필요)' : implode(' + ', $parts); + } + + /** + * 미들웨어 목록에 특정 토큰이 포함되는지 확인합니다. + * + * @param array $middleware 미들웨어 목록 + * @param string $needle 검색 토큰 + * @return bool 포함 여부 + */ + private function hasMiddleware(array $middleware, string $needle): bool + { + foreach ($middleware as $mw) { + if (Str::contains($mw, $needle)) { + return true; + } + } + + return false; + } + + /** + * 요청 파라미터 표를 생성합니다. + * + * @param array $route 라우트 메타데이터 + * @param array $request FormRequest 분석 결과 + * @return string 마크다운 표 + */ + private function requestParamTable(array $route, array $request): string + { + $rows = []; + $pathParams = $route['path_params']; + + foreach ($pathParams as $pathParam) { + $desc = $this->paramDescriber->describe($pathParam, 'path', 'string'); + $descCell = $desc !== null ? $this->escapeCell($desc) : ''; + $rows[] = "| {$pathParam} | path | string | 예 | — | {$descCell} |"; + } + + $location = in_array($route['method'], ['GET', 'DELETE'], true) ? 'query' : 'body'; + + foreach ($request['params'] as $p) { + // path 파라미터로 이미 출력한 이름은 FormRequest 규칙에서 중복 출력하지 않는다 + // (라우트 바인딩 세그먼트가 FormRequest rules() 에도 있으면 path 행이 우선). + if (in_array($p['name'], $pathParams, true)) { + continue; + } + + $required = $p['required'] ? '예' : '아니오'; + $allowed = $p['allowed'] !== '' ? $p['allowed'] : '—'; + $desc = $this->paramDescriber->describe($p['name'], $location, $p['type']); + $descCell = $desc !== null ? $this->escapeCell($desc) : ''; + $rows[] = "| {$p['name']} | {$location} | {$p['type']} | {$required} | {$allowed} | {$descCell} |"; + } + + if ($rows === []) { + return '_요청 파라미터 없음._'; + } + + return "| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |\n| --- | --- | --- | --- | --- | --- |\n".implode("\n", $rows); + } + + /** + * 응답 필드 표 블록을 생성합니다 (examples-only 모드의 마커 갱신용 공개 래퍼). + * + * @param array|null $schema 실측 응답 스키마 + * @param array $probeMeta 실측 메타 + * @param array $commentMap 컬럼명 => 주석 (필드 설명 기본값) + * @return string 마크다운 표 또는 실측 제외 사유 + */ + public function responseFieldTableBlock(?array $schema, array $probeMeta, array $commentMap = []): string + { + return $this->responseFieldTable($schema, $probeMeta, $commentMap); + } + + /** + * 응답 필드 표를 생성합니다. + * + * @param array|null $schema 실측 응답 스키마 + * @param array $probeMeta 실측 메타 + * @param array $commentMap 컬럼명 => 주석 (필드 설명 기본값) + * @return string 마크다운 표 또는 실측 제외 사유 + */ + private function responseFieldTable(?array $schema, array $probeMeta, array $commentMap = []): string + { + if ($schema === null) { + $reason = $probeMeta['skipped_reason'] ?? 'not-probed'; + + return ""; + } + + $note = ''; + if ($schema['shape'] === 'collection') { + $note = '_목록 응답: `data.data[]` 배열 항목의 필드'.($schema['pagination'] ? ' + `data.pagination`' : '').'._'; + } elseif ($schema['shape'] === 'object') { + $note = '_단건 응답: `data` 객체의 필드._'; + } + + if ($schema['fields'] === []) { + return $note."\n\n"; + } + + $rows = []; + foreach ($schema['fields'] as $f) { + // 필드 설명 우선순위: + // 1) 리소스 계약 사전 (accessor/computed — status_label, is_owner, *_at 등) + // 2) 컬럼 주석 (한국어 comment — 테이블 실제 컬럼) + // 3) TODO (사람 보강) + // 계약 사전이 앞서는 이유: created_at 은 어느 테이블이든 "생성 일시" 이고, + // status_label 은 컬럼이 아니라 Enum label() 산물이라 주석이 없기 때문. + $desc = $this->fieldDescriber->describe($f['name'], $f['type'] ?? '') + ?? ($commentMap[$f['name']] ?? null); + $descCell = $desc !== null ? $this->escapeCell($desc) : ''; + $rows[] = "| {$f['name']} | {$f['type']} | `{$f['sample']}` | {$descCell} |"; + } + + $table = "| 필드 | 타입 | 실측 예시값 | 용도/설명 |\n| --- | --- | --- | --- |\n".implode("\n", $rows); + + return $note !== '' ? $note."\n\n".$table : $table; + } + + /** + * 에러 응답 표를 생성합니다. + * + * 라우트 메타에서 대표 에러 상태코드와 발생 조건을 자동 추론합니다. + * - 401: 인증 필수(`auth:sanctum`) 미들웨어. `optional.sanctum`(선택 인증)은 제외. + * - 403: `admin` 미들웨어 또는 `permission:` 요구 → 권한 부족 시. + * - 422: FormRequest 검증 규칙 존재 → 검증 실패 시. + * - 404: path 파라미터 존재 → 대상 리소스 미발견 시. + * + * 자동 추론은 대표 상태코드의 초안이며, 도메인 특이 에러(409 충돌·429 제한 등)는 + * `@generated` 블록 밖 사람 서술에서 보강한다. + * + * @param array $route 라우트 메타데이터 + * @param array $request FormRequest 분석 결과 + * @return string 마크다운 표 + */ + private function errorTable(array $route, array $request): string + { + $mw = $route['middleware'] ?? []; + $rows = []; + + // 401: 인증 필수. optional.sanctum(선택 인증)은 미인증도 허용하므로 제외. + if (! $this->hasMiddleware($mw, 'optional.sanctum') && $this->hasMiddleware($mw, 'sanctum')) { + $rows[] = '| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |'; + } + + // 403: admin 게이트 또는 permission 요구 → 권한 부족. + if ($this->hasMiddleware($mw, 'AdminMiddleware') || ! empty($route['permission'])) { + $cond = ! empty($route['permission']) + ? "요구 권한(`{$route['permission']}`)이 없는 경우" + : '관리자 권한이 없는 경우'; + $rows[] = "| 403 | Forbidden | {$cond} |"; + } + + // 422: FormRequest 검증 규칙 존재 → 검증 실패. (훅 주입 규칙 포함 가능) + $hasValidation = ! empty($request['params']) || ! empty($request['hook_filters']); + if ($hasValidation) { + $rows[] = '| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |'; + } + + // 404: path 파라미터 존재 → 대상 리소스 미발견. + if (! empty($route['path_params'])) { + $rows[] = '| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |'; + } + + if ($rows === []) { + return '_대표 에러 없음 (공개 조회). _'; + } + + return "| 상태코드 | 의미 | 발생 조건 |\n| --- | --- | --- |\n".implode("\n", $rows); + } + + /** + * 실제 호출을 재현하는 raw HTTP 요청 예시 블록을 생성합니다. + * + * 요청 라인(`{METHOD} {path} HTTP/1.1`) + 헤더 + 바디로 raw HTTP 요청을 조립합니다 + * (응답 예시의 `HTTP/1.1 {status}` 상태줄과 대칭). curl 은 사용하지 않습니다. + * - 인증 필요(`auth:sanctum`/`optional.sanctum`) 시 `Authorization: Bearer {YOUR_TOKEN}` 마스킹. + * - 바디 있는 메서드(POST/PUT/PATCH)는 요청 파라미터 표의 필수 파라미터 + 타입별 샘플값을 + * 빈 줄 뒤 JSON 바디로 붙입니다. + * + * path 파라미터가 실측 치환값(`resolved_uri`)으로 채워졌으면 그 값을, 아니면 `{param}` placeholder 를 쓴다. + * + * @param array $route 라우트 메타데이터 + * @param array $request FormRequest 분석 결과 + * @param array $probeMeta 실측 메타 (base_url, resolved_uri) + * @return string 마크다운 코드블록 + */ + public function requestExampleBlock(array $route, array $request, array $probeMeta): string + { + $method = strtoupper((string) $route['method']); + $path = (string) ($probeMeta['resolved_uri'] ?? $route['uri']); + + // GET/DELETE 의 query 파라미터는 URL 쿼리스트링으로 반영한다(바디가 아니라 URL). + if (in_array($method, ['GET', 'DELETE'], true)) { + $path = $this->appendQueryString($path, $request); + } + + // 요청 라인 + 헤더로 raw HTTP 요청 예시를 조립한다 (응답 예시의 HTTP/1.1 상태줄과 대칭). + // Host 는 실측 기준 URL(로컬 호스트)을 노출하지 않고 공개 placeholder 로 마스킹한다. + $lines = ["{$method} {$path} HTTP/1.1"]; + $lines[] = 'Host: '.self::DOC_HOST; + $lines[] = 'Accept: application/json'; + + // 인증 필요 시 Bearer 토큰 마스킹 (auth:sanctum 필수 / optional.sanctum 선택). + $mw = $route['middleware'] ?? []; + if ($this->hasMiddleware($mw, 'sanctum')) { + $optional = $this->hasMiddleware($mw, 'optional.sanctum'); + $suffix = $optional ? ' (optional.sanctum: 비회원은 헤더 생략 가능)' : ''; + $lines[] = 'Authorization: Bearer '.self::TOKEN_PLACEHOLDER.$suffix; + } + + // 바디 있는 메서드(POST/PUT/PATCH)는 바디를 붙인다. + if (in_array($method, ['POST', 'PUT', 'PATCH'], true)) { + $bodyParams = $this->bodyParams($request); + + if ($bodyParams !== []) { + // 파일 업로드(image/file 타입) 파라미터가 있으면 multipart/form-data 로 표기한다 + // (application/json 으로는 파일을 전송할 수 없다). + if ($this->hasFileParam($bodyParams)) { + foreach ($this->multipartBodyLines($bodyParams) as $bl) { + $lines[] = $bl; + } + } else { + $lines[] = 'Content-Type: application/json'; + $lines[] = ''; + $body = $this->jsonBody($bodyParams); + $json = json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $lines[] = (string) $json; + } + } + } + + return "```http\n".implode("\n", $lines)."\n```"; + } + + /** + * GET/DELETE 의 query 파라미터를 URL 쿼리스트링으로 붙입니다. + * + * 이미 쿼리(`?per_page=..`)가 있으면 `&` 로 잇고, 각 파라미터는 타입별 대표값으로 채웁니다. + * path 파라미터는 URL 세그먼트라 제외합니다. + * + * @param string $path 경로(치환된 path 파라미터 포함 가능) + * @param array $request FormRequest 분석 결과 + * @return string 쿼리스트링이 붙은 경로 + */ + private function appendQueryString(string $path, array $request): string + { + $params = $request['params'] ?? []; + if ($params === []) { + return $path; + } + + $pathParams = []; + preg_match_all('/\{([^}]+)\}/', $path, $m); + if (! empty($m[1])) { + $pathParams = $m[1]; + } + + $pairs = []; + foreach ($params as $p) { + $name = (string) $p['name']; + if (in_array($name, $pathParams, true)) { + continue; + } + // 허용값 in: 열거가 있으면 그 첫 값을, 없으면 이름·타입 기반 대표값을 쓴다. + $value = $this->exampleValue($name, (string) ($p['type'] ?? 'string'), (string) ($p['allowed'] ?? '')); + if (is_array($value)) { + $value = $value[0] ?? ''; + } + $pairs[] = $name.'='.rawurlencode((string) $value); + } + + if ($pairs === []) { + return $path; + } + + return $path.(str_contains($path, '?') ? '&' : '?').implode('&', $pairs); + } + + /** + * body 위치 파라미터 목록을 반환합니다 (전체 — 필수+선택). + * + * @param array $request FormRequest 분석 결과 + * @return array> body 파라미터 목록 + */ + private function bodyParams(array $request): array + { + return array_values($request['params'] ?? []); + } + + /** + * 파라미터 목록에 파일(image/file) 타입이 있는지 판별합니다. + * + * @param array> $params 파라미터 목록 + * @return bool 파일 파라미터 존재 여부 + */ + private function hasFileParam(array $params): bool + { + foreach ($params as $p) { + if (in_array((string) ($p['type'] ?? ''), ['file', 'image'], true)) { + return true; + } + } + + return false; + } + + /** + * multipart/form-data 요청 예시의 헤더 + 파트 라인들을 생성합니다. + * + * 파일 파라미터는 `filename=` + `Content-Type` 파트로, 그 외는 값 파트로 표현합니다. + * + * @param array> $params body 파라미터 목록 + * @return array multipart 요청 라인들 + */ + private function multipartBodyLines(array $params): array + { + $boundary = '----G7ExampleBoundary'; + $lines = ["Content-Type: multipart/form-data; boundary={$boundary}", '']; + + foreach ($params as $p) { + $name = (string) $p['name']; + $type = (string) ($p['type'] ?? 'string'); + $lines[] = "--{$boundary}"; + + if (in_array($type, ['file', 'image'], true)) { + // 배열 파일(files[]) 은 name 뒤에 [] 를 붙인다. + $isArrayFile = str_ends_with($name, 's') || $name === 'files'; + $field = $isArrayFile ? $name.'[]' : $name; + $fileName = $type === 'image' ? 'example.png' : 'example.pdf'; + $mime = $type === 'image' ? 'image/png' : 'application/octet-stream'; + $lines[] = "Content-Disposition: form-data; name=\"{$field}\"; filename=\"{$fileName}\""; + $lines[] = "Content-Type: {$mime}"; + $lines[] = ''; + $lines[] = '(바이너리 파일 내용)'; + } else { + $lines[] = "Content-Disposition: form-data; name=\"{$name}\""; + $lines[] = ''; + $lines[] = (string) $this->exampleScalar($name, $type); + } + } + + $lines[] = "--{$boundary}--"; + + return $lines; + } + + /** + * JSON 요청 바디 맵을 조립합니다 (전체 파라미터 — 필수+선택). + * + * 값은 이름·타입에 맞는 현실적인 예시값으로 채웁니다(placeholder `"string"` 남발 방지). + * + * @param array> $params body 파라미터 목록 + * @return array 예시 바디 (이름 => 예시값) + */ + private function jsonBody(array $params): array + { + $body = []; + foreach ($params as $p) { + $name = (string) $p['name']; + $type = (string) ($p['type'] ?? 'string'); + $body[$name] = $this->exampleValue($name, $type, (string) ($p['allowed'] ?? '')); + } + + return $body; + } + + /** + * 파라미터의 예시값을 이름·타입·허용값 근거로 생성합니다. + * + * `array` 타입은 빈 배열이 아니라 대표 원소 1개를, boolean/number 는 타입값을, + * 그 외는 이름 기반 현실적 문자열(email/password/url 등)을 반환합니다. + * 허용값에 `in:` 목록(백틱)이 있으면 그 첫 값을 채택합니다. + * + * @param string $name 파라미터명 + * @param string $type 타입 + * @param string $allowed 허용값 설명 + * @return mixed 예시값 + */ + private function exampleValue(string $name, string $type, string $allowed = ''): mixed + { + // 허용값에 in: 열거(백틱으로 감싼 첫 값)가 있으면 그것을 우선 채택한다. + if (preg_match('/`([^`]+)`/', $allowed, $m)) { + return $m[1]; + } + + return match ($type) { + 'array' => [$this->exampleScalar($this->singularName($name), 'string')], + default => $this->exampleScalar($name, $type), + }; + } + + /** + * 스칼라 파라미터의 예시값을 이름·타입 근거로 생성합니다. + * + * @param string $name 파라미터명 + * @param string $type 타입 + * @return mixed 예시 스칼라값 + */ + private function exampleScalar(string $name, string $type): mixed + { + $lower = strtolower($name); + + // 타입 우선 판정 + if ($type === 'boolean') { + return true; + } + if (in_array($type, ['integer', 'number'], true)) { + return match (true) { + str_contains($lower, 'page') => 1, + str_ends_with($lower, '_id'), $lower === 'id' => 1, + default => 1, + }; + } + if ($type === 'email' || str_contains($lower, 'email')) { + return 'user@example.com'; + } + if ($type === 'uuid') { + return '9f8b2c1a-4d3e-4a2b-8c1d-0e1f2a3b4c5d'; + } + if ($type === 'date') { + return '2026-01-01'; + } + + // 이름 기반 현실적 값 + return match (true) { + str_contains($lower, 'password') => 'Password123!', + str_contains($lower, 'url') || str_contains($lower, 'homepage') => 'https://example.com', + str_contains($lower, 'identifier') || str_contains($lower, 'slug') => 'example-key', + str_contains($lower, 'name') => '예시 이름', + str_contains($lower, 'title') => '예시 제목', + str_contains($lower, 'mobile') || str_contains($lower, 'phone') => '010-1234-5678', + str_contains($lower, 'locale') || $lower === 'language' => 'ko', + str_contains($lower, 'country') => 'KR', + str_contains($lower, 'timezone') => 'Asia/Seoul', + str_contains($lower, 'zipcode') || str_contains($lower, 'postal') => '06234', + str_contains($lower, 'address') => '서울특별시 강남구 테헤란로 1', + str_contains($lower, 'content') || str_contains($lower, 'description') || str_contains($lower, 'bio') => '예시 내용입니다.', + str_contains($lower, 'token') => self::TOKEN_PLACEHOLDER, + str_contains($lower, 'color') => '#4F46E5', + default => '예시값', + }; + } + + /** + * 배열 파라미터명의 단수형 근사값을 반환합니다 (예시 원소 이름용). + * + * @param string $name 파라미터명 (복수형 가능) + * @return string 단수형 근사 + */ + private function singularName(string $name): string + { + return Str::singular($name); + } + + /** + * 실측 응답 body 전문(envelope 통짜)을 예시 블록으로 방출합니다. + * + * 실측 body 가 있으면 `{success, data, message, error}` envelope 를 pretty JSON 으로 출력합니다. + * 목록 응답의 `data.data[]` 는 대표 항목으로 절단하고 나머지는 절단 주석으로 대체합니다. + * 실측 제외(쓰기 메서드·바이너리·미치환 path)면 사람 보강 마커를 남깁니다. + * + * @param array|null $schema 실측 응답 스키마 (null=실측 안 됨) + * @param array $probeMeta 실측 메타 (status, body, skipped_reason) + * @return string 마크다운 코드블록 또는 실측 제외 마커 + */ + public function responseExampleBlock(?array $schema, array $probeMeta): string + { + $body = $probeMeta['body'] ?? null; + + if (! is_array($body)) { + $reason = $probeMeta['skipped_reason'] ?? 'not-probed'; + + return ""; + } + + $status = $probeMeta['status'] ?? 200; + $truncated = $this->truncateListBody($body); + // 실측 응답에 섞여 나온 민감값(토큰·비밀번호·시크릿 등)을 마스킹한다. + $sanitized = $this->sanitizeSensitive($truncated); + // 응답 body 내부의 절대 URL(페이지네이터 링크·콜백 URL 등)에 실측 기준 호스트가 + // 그대로 직렬화돼 들어올 수 있으므로 공개 문서용 placeholder 호스트로 마스킹한다. + $masked = $this->maskResponseHost($sanitized, $probeMeta['base_url'] ?? null); + $json = json_encode($masked, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + return "```http\nHTTP/1.1 {$status}\n```\n\n```json\n".(string) $json."\n```"; + } + + /** + * 응답 body 의 절대 URL 에 포함된 실측 기준 호스트를 공개 placeholder 호스트로 마스킹합니다 (재귀). + * + * Laravel 페이지네이터 메타(`first_page_url`/`last_page_url`/`path`/`url` 등)와 콜백/통보 URL 은 + * 실측 시 요청 base URL 의 호스트(로컬 개발 호스트 등)를 그대로 직렬화한다. 요청 예시의 `Host:` + * 헤더는 {@see self::DOC_HOST} 로 마스킹되지만 응답 body 문자열값은 별도 처리가 없으면 유출된다. + * base URL 의 scheme+host 를 `https://{DOC_HOST}` 로 치환해 응답 예시에서도 호스트를 노출하지 않는다. + * + * @param mixed $value 응답 값 (배열/스칼라) + * @param string|null $baseUrl 실측 요청 base URL (없으면 마스킹 생략) + * @return mixed 호스트가 마스킹된 값 + */ + private function maskResponseHost(mixed $value, ?string $baseUrl): mixed + { + if ($baseUrl === null || $baseUrl === '') { + return $value; + } + + $host = parse_url($baseUrl, PHP_URL_HOST); + if (! is_string($host) || $host === '' || $host === self::DOC_HOST) { + return $value; + } + + if (is_string($value)) { + // scheme://host 조합을 placeholder 로 치환 (포트 유무·http/https 모두 대응). + return preg_replace( + '#https?://'.preg_quote($host, '#').'(:\d+)?#i', + 'https://'.self::DOC_HOST, + $value + ); + } + + if (is_array($value)) { + $result = []; + foreach ($value as $k => $v) { + $result[$k] = $this->maskResponseHost($v, $baseUrl); + } + + return $result; + } + + return $value; + } + + /** + * 응답 body 에서 민감한 필드 값을 placeholder 로 마스킹합니다 (재귀). + * + * 실측 응답에 실제 토큰·비밀번호 해시·시크릿·API 키가 섞여 나올 수 있으므로 공개 문서에 + * 방출하기 전에 마스킹한다. 필드명(키)에 민감 키워드가 포함되면 값을 placeholder 로 대체. + * + * @param mixed $value 응답 값 (배열/스칼라) + * @return mixed 마스킹된 값 + */ + private function sanitizeSensitive(mixed $value): mixed + { + if (! is_array($value)) { + return $value; + } + + $sensitiveKeys = [ + 'token', 'access_token', 'refresh_token', 'plaintexttoken', 'plain_text_token', + 'password', 'secret', 'api_key', 'apikey', 'private_key', 'client_secret', + 'authorization', 'remember_token', 'app_key', + ]; + + $result = []; + foreach ($value as $k => $v) { + $keyLower = is_string($k) ? strtolower($k) : ''; + $isSensitive = false; + foreach ($sensitiveKeys as $needle) { + if ($keyLower !== '' && str_contains($keyLower, $needle)) { + $isSensitive = true; + break; + } + } + + if ($isSensitive && ! is_array($v) && $v !== null) { + // 타입 표기용 접미(_type/_expires 등)는 마스킹 대상이 아니므로 스칼라만 마스킹. + $result[$k] = ($keyLower === 'token_type') ? $v : '{MASKED}'; + } else { + $result[$k] = $this->sanitizeSensitive($v); + } + } + + return $result; + } + + /** + * 목록 응답 body 의 `data.data[]` 를 대표 항목으로 절단합니다. + * + * 목록 body 는 클 수 있으므로 최대 LIST_EXAMPLE_LIMIT 항목만 남기고 + * 나머지는 `// ... (총 N건 중 K건 표시)` 절단 주석 항목으로 대체합니다. + * + * @param array $body 실측 응답 body (envelope) + * @return array 절단된 body + */ + private function truncateListBody(array $body): array + { + $data = $body['data'] ?? null; + + if (is_array($data) && isset($data['data']) && is_array($data['data'])) { + $rows = $data['data']; + $total = count($rows); + + if ($total > self::LIST_EXAMPLE_LIMIT) { + $kept = array_slice($rows, 0, self::LIST_EXAMPLE_LIMIT); + $kept[] = "... (총 {$total}건 중 ".self::LIST_EXAMPLE_LIMIT.'건 표시)'; + $body['data']['data'] = $kept; + } + } + + return $body; + } + + /** + * 마크다운 표 셀 안에서 안전하도록 파이프/개행을 이스케이프합니다. + * + * @param string $text 원본 텍스트 + * @return string 이스케이프된 텍스트 + */ + private function escapeCell(string $text): string + { + return str_replace(['|', "\n", "\r"], ['\\|', ' ', ''], $text); + } + + /** + * 기존 문서에 새 생성 블록을 병합합니다. 사람 서술은 보존합니다. + * + * @param string|null $existing 기존 문서 내용 (null=신규) + * @param string $header 문서 헤더 (제목 + TL;DR 등) + * @param array $sections 엔드포인트 섹션 목록 (라우트명 순) + * @param array $sectionKeys 각 섹션의 라우트명 키 + * @return string 병합된 문서 내용 + */ + public function mergeDocument(?string $existing, string $header, array $sections, array $sectionKeys): string + { + if ($existing === null) { + return $header."\n".implode("\n", $sections); + } + + $merged = $header."\n"; + + foreach ($sections as $i => $section) { + $key = $sectionKeys[$i]; + $preserved = $this->extractHumanProse($existing, $key); + $merged .= $this->applyPreservedProse($section, $preserved)."\n"; + } + + return $merged; + } + + /** + * README 헤더(인용 블록)와 첫 생성 블록 사이의 사람 개요를 추출합니다. + * + * 개요(공통 규약: 인증·응답 봉투·에러·페이지네이션)는 목차 표보다 먼저 읽혀야 하므로 + * 생성 블록 앞에 둔다. 재생성 시 이 구간을 원문 그대로 되살려 사람 서술을 보존한다. + * + * @param string $existing 기존 README + * @param string $key 목차 생성 블록 키 + * @return string|null 보존할 개요 (없으면 null) + */ + private function extractReadmeOverview(string $existing, string $key): ?string + { + $startPos = strpos($existing, self::GEN_START.$key.' -->'); + if ($startPos === false) { + return null; + } + + $head = substr($existing, 0, $startPos); + + // 헤더 인용 블록(`> ...`) 의 마지막 줄 이후가 사람 개요다. + if (! preg_match_all('/(?:^|\n)>[^\n]*/', $head, $all, PREG_OFFSET_CAPTURE)) { + return null; + } + + $last = end($all[0]); + $overview = trim(substr($head, $last[1] + strlen($last[0]))); + + return $overview === '' ? null : $overview; + } + + /** + * README 목차 블록 뒤 ~ 확장 목차 헤딩 앞의 사람 서술을 추출합니다. + * + * @param string $existing 기존 README + * @param string $key 목차 생성 블록 키 + * @return string|null 보존할 사람 서술 (없으면 null) + */ + private function extractReadmeTrailingProse(string $existing, string $key): ?string + { + $startPos = strpos($existing, self::GEN_START.$key.' -->'); + if ($startPos === false) { + return null; + } + + $endPos = strpos($existing, self::GEN_END, $startPos); + if ($endPos === false) { + return null; + } + + $afterGen = substr($existing, $endPos + strlen(self::GEN_END)); + + // 확장 목차 섹션(`## 확장 API 레퍼런스`)부터는 생성 블록 소관이므로 제외한다. + // 임의의 `## ` 헤딩에서 자르면 사람이 덧붙인 헤딩 섹션이 재생성 때마다 통째로 소실된다. + $nextSection = preg_match('/\n## '.preg_quote(self::EXTENSIONS_HEADING, '/').'/', $afterGen, $m, PREG_OFFSET_CAPTURE) + ? $m[0][1] + : strlen($afterGen); + + $prose = trim(substr($afterGen, 0, $nextSection)); + + return $prose === '' ? null : $prose; + } + + /** + * 기존 문서에서 지정 키의 생성 블록을 헤딩·인용문 포함해 원문 그대로 추출합니다. + * + * `--scope` 축소 실행 등으로 이번 회차에 갱신 대상이 아닌 블록을 소실 없이 되살린다. + * + * @param string $existing 기존 문서 + * @param string $key 생성 블록 키 + * @return string|null 블록 원문 (없으면 null) + */ + private function extractGeneratedBlock(string $existing, string $key): ?string + { + $startPos = strpos($existing, self::GEN_START.$key.' -->'); + if ($startPos === false) { + return null; + } + + $endPos = strpos($existing, self::GEN_END, $startPos); + if ($endPos === false) { + return null; + } + + // 블록 앞의 섹션 헤딩(`## ...`)부터 함께 되살린다. + $head = substr($existing, 0, $startPos); + $headingPos = preg_match_all('/(?:^|\n)## [^\n]*/', $head, $all, PREG_OFFSET_CAPTURE) + ? end($all[0])[1] + : null; + + $from = $headingPos !== null ? $headingPos : $startPos; + + return trim(substr($existing, $from, ($endPos + strlen(self::GEN_END)) - $from)); + } + + /** + * 기존 문서에서 특정 엔드포인트의 사람 서술(생성 블록 밖)을 추출합니다. + * + * @param string $existing 기존 문서 + * @param string $key 라우트명 키 + * @return string|null 보존할 사람 서술 (없으면 null) + */ + private function extractHumanProse(string $existing, string $key): ?string + { + $startMarker = self::GEN_START.$key.' -->'; + $startPos = strpos($existing, $startMarker); + + if ($startPos === false) { + return null; + } + + $endPos = strpos($existing, self::GEN_END, $startPos); + if ($endPos === false) { + return null; + } + + $afterGen = substr($existing, $endPos + strlen(self::GEN_END)); + // 다음 ### 헤딩 전까지가 이 엔드포인트의 사람 서술 + $nextHeading = preg_match('/\n### /', $afterGen, $m, PREG_OFFSET_CAPTURE) + ? $m[0][1] + : strlen($afterGen); + + $prose = trim(substr($afterGen, 0, $nextHeading)); + + // 기본 TODO 스텁만 있으면 보존할 것 없음 + if ($prose === '' || Str::contains($prose, 'TODO: 이 엔드포인트의 용도')) { + return null; + } + + return $prose; + } + + /** + * 새 섹션의 기본 서술 스텁을 보존된 사람 서술로 치환합니다. + * + * @param string $section 새로 생성된 섹션 + * @param string|null $preserved 보존할 사람 서술 + * @return string 서술이 반영된 섹션 + */ + private function applyPreservedProse(string $section, ?string $preserved): string + { + if ($preserved === null) { + return $section; + } + + $stub = '**설명** '; + + return str_replace($stub, $preserved, $section); + } + + /** + * 기존 문서의 각 엔드포인트 `@generated` 블록에 요청/응답 예시 블록을 in-place 삽입합니다. + * + * 전체 재생성(`endpointSection` + `mergeDocument`)은 `@generated` 블록 내부의 + * 파라미터/응답 필드 표를 통째로 다시 조립하므로, 정적 추출로 재현 불가능한 사람의 + * 도메인 서술 셀이 TODO 로 되돌아갑니다. 이 메서드는 표를 건드리지 않고 예시 2블록만 + * 표 뒤에 삽입/치환하여 그 퇴행 없이 예시를 방출합니다. + * + * 삽입 위치(스캐폴더 `endpointSection` 순서와 동일): + * - `**요청 예시**` → `**응답 필드**` 헤딩 바로 앞 + * - `**응답 예시**` → `**에러 응답**` 헤딩 바로 앞 + * + * 이미 예시 블록이 있으면(멱등) 그 블록만 새 내용으로 치환합니다. + * + * @param string $content 기존 문서 내용 + * @param array $exampleBlocks 라우트명 키 => 예시 블록 + * @return array{0: string, 1: int} [갱신된 문서, 삽입/치환한 예시 블록 수] + */ + public function insertExampleBlocks(string $content, array $exampleBlocks): array + { + $inserted = 0; + + foreach ($exampleBlocks as $key => $blocks) { + $startMarker = self::GEN_START.$key.' -->'; + $startPos = strpos($content, $startMarker); + if ($startPos === false) { + continue; + } + + $endPos = strpos($content, self::GEN_END, $startPos); + if ($endPos === false) { + continue; + } + + $block = substr($content, $startPos, $endPos - $startPos); + + // 이 엔드포인트의 사람 서술 영역(@generated:end 뒤 ~ 다음 ### 전)에 이미 사람이 작성한 + // `**응답 예시**` 가 있으면, 블록 안에 응답 예시 마커를 넣지 않는다(중복 헤딩 회피). + // 요청 예시는 항상 삽입한다. + $proseHasResponseExample = $this->proseHasResponseExample($content, $endPos); + + // 응답 필드 표가 실측 제외 마커(사람이 채운 셀 없음)이고 이번에 실측 표가 확보됐으면 + // 그 마커를 실측 필드 표로 갱신한다(쓰기 메서드 실측 도입분). 이미 필드 표(| 필드 | 타입 |)가 + // 있으면 건드리지 않아 사람 서술 셀을 보존한다. + $updatedBlock = $block; + + // 요청 파라미터 표에서 path 행과 이름이 겹치는 query 행(자동 생성 중복)을 제거한다. + // 라우트 바인딩 세그먼트가 FormRequest rules() 에도 있어 path/query 로 2회 출력되던 결함. + $updatedBlock = $this->dedupeParamRows($updatedBlock, $inserted); + + if (! empty($blocks['response_fields'])) { + $updatedBlock = $this->refreshResponseFieldMarker($updatedBlock, $blocks['response_fields'], $inserted); + } + + $updatedBlock = $this->applyExampleBlock($updatedBlock, '**요청 예시**', '**응답 필드**', $blocks['request'], $inserted); + + if ($proseHasResponseExample) { + // 서술 영역에 수기 응답 예시가 있으면 블록 안 응답 예시는 두지 않는다(중복 헤딩 회피). + // 과거 run 이 삽입한 블록 내 응답 예시가 남아 있으면 제거한다. + $updatedBlock = $this->removeExampleBlock($updatedBlock, '**응답 예시**', '**에러 응답**', $inserted); + } else { + $updatedBlock = $this->applyExampleBlock($updatedBlock, '**응답 예시**', '**에러 응답**', $blocks['response'], $inserted); + } + + if ($updatedBlock !== $block) { + $content = substr($content, 0, $startPos).$updatedBlock.substr($content, $endPos); + } + } + + return [$content, $inserted]; + } + + /** + * 엔드포인트의 사람 서술 영역에 이미 사람이 작성한 `**응답 예시**` 가 있는지 확인합니다. + * + * 서술 영역 = `@generated:end` 뒤부터 다음 `### ` 헤딩(또는 문서 끝) 전까지. 여기에 + * `**응답 예시**` 가 있으면 블록 안에 응답 예시를 삽입하지 않아 중복 헤딩을 방지한다. + * 파일 업로드(바이너리)·미설치 정적 예시처럼 실측 불가라 사람이 미리 채운 응답 예시가 대상. + * + * @param string $content 전체 문서 내용 + * @param int $endPos 이 엔드포인트 `@generated:end` 마커의 시작 위치 + * @return bool 서술 영역에 수기 응답 예시가 있으면 true + */ + private function proseHasResponseExample(string $content, int $endPos): bool + { + $afterGen = substr($content, $endPos + strlen(self::GEN_END)); + + // 다음 ### 헤딩 전까지가 이 엔드포인트의 사람 서술 영역 + $nextHeading = preg_match('/\n### /', $afterGen, $m, PREG_OFFSET_CAPTURE) + ? $m[0][1] + : strlen($afterGen); + + $prose = substr($afterGen, 0, $nextHeading); + + return Str::contains($prose, '**응답 예시**'); + } + + /** + * 요청 파라미터 표에서 path 행과 이름이 겹치는 query 행(자동 생성 중복)을 제거합니다. + * + * 라우트 바인딩 세그먼트가 FormRequest rules() 에도 존재하면 같은 이름이 `| 이름 | path |` 과 + * `| 이름 | query |` 로 2회 출력되던 결함을 in-place 로 정정한다. path 행을 남기고 query 행만 제거. + * + * @param string $block `@generated` 블록 문자열 + * @param int $inserted 정정 카운터 (참조 누적) + * @return string 정정된 블록 + */ + private function dedupeParamRows(string $block, int &$inserted): string + { + $lines = explode("\n", $block); + + // path 행의 파라미터명 수집 (| name | path | ...) + $pathNames = []; + foreach ($lines as $line) { + if (preg_match('/^\|\s*([a-zA-Z_0-9]+)\s*\|\s*path\s*\|/', $line, $m)) { + $pathNames[$m[1]] = true; + } + } + + if ($pathNames === []) { + return $block; + } + + $out = []; + $removed = false; + foreach ($lines as $line) { + // 같은 이름의 query 행이면 제거 (path 행이 우선). + if (preg_match('/^\|\s*([a-zA-Z_0-9]+)\s*\|\s*query\s*\|/', $line, $m) && isset($pathNames[$m[1]])) { + $removed = true; + + continue; + } + $out[] = $line; + } + + if ($removed) { + $inserted++; + } + + return implode("\n", $out); + } + + /** + * 응답 필드 표가 실측 제외 마커일 때만 실측 필드 표로 치환합니다. + * + * `**응답 필드** (\`data\` 내부)` 헤딩 ~ 다음 헤딩(`**응답 예시**`/`**에러 응답**`) 사이 본문이 + * `` 마커면 실측 표로 교체하고, 이미 표(`| 필드 | 타입 |`)가 있으면 + * 사람 서술 셀 보존을 위해 건드리지 않는다. + * + * @param string $block `@generated` 블록 문자열 + * @param string $fieldTable 실측 응답 필드 표 + * @param int $inserted 치환 카운터 (참조 누적) + * @return string 갱신된 블록 + */ + private function refreshResponseFieldMarker(string $block, string $fieldTable, int &$inserted): string + { + $label = '**응답 필드** (`data` 내부)'; + $labelPos = strpos($block, $label); + if ($labelPos === false) { + return $block; + } + + $bodyStart = $labelPos + strlen($label); + // 다음 헤딩(응답 예시 또는 에러 응답) 전까지가 응답 필드 본문 + $nextRespExample = strpos($block, '**응답 예시**', $bodyStart); + $nextError = strpos($block, '**에러 응답**', $bodyStart); + $candidates = array_filter([$nextRespExample, $nextError], fn ($v) => $v !== false); + if ($candidates === []) { + return $block; + } + $bodyEnd = min($candidates); + + $body = substr($block, $bodyStart, $bodyEnd - $bodyStart); + + // 사람이 채운 표(| 필드 | 타입 |)가 이미 있으면 보존 — 마커일 때만 교체. + if (! str_contains($body, '실측 제외')) { + return $block; + } + + $inserted++; + + return substr($block, 0, $bodyStart)."\n\n".$fieldTable."\n\n".substr($block, $bodyEnd); + } + + /** + * `@generated` 블록 문자열에 예시 블록 하나를 삽입하거나 치환합니다. + * + * `$label`(예: `**요청 예시**`) 섹션이 이미 있으면 그 본문을 `$body` 로 치환하고, + * 없으면 `$anchor`(예: `**응답 필드**`) 헤딩 바로 앞에 `label + body` 를 삽입합니다. + * + * @param string $block `@generated` 블록 문자열 + * @param string $label 예시 섹션 헤딩 (`**요청 예시**` / `**응답 예시**`) + * @param string $anchor 삽입 기준 헤딩 (`**응답 필드**` / `**에러 응답**`) + * @param string $body 예시 블록 본문 (코드블록 또는 실측 제외 마커) + * @param int $inserted 삽입/치환 카운터 (참조 누적) + * @return string 갱신된 블록 문자열 + */ + private function applyExampleBlock(string $block, string $label, string $anchor, string $body, int &$inserted): string + { + $section = $label."\n\n".$body."\n\n"; + $labelLine = $label."\n"; + + // 이미 예시 섹션이 있으면 그 label ~ (다음 빈 줄 + 다음 헤딩) 까지를 새 섹션으로 치환한다. + $labelPos = strpos($block, $labelLine); + if ($labelPos !== false) { + $anchorPos = strpos($block, $anchor, $labelPos); + if ($anchorPos === false) { + return $block; + } + + $replaced = substr($block, 0, $labelPos).$section.substr($block, $anchorPos); + if ($replaced !== $block) { + $inserted++; + } + + return $replaced; + } + + // 없으면 anchor 헤딩 바로 앞에 삽입. + $anchorPos = strpos($block, $anchor); + if ($anchorPos === false) { + return $block; + } + + $inserted++; + + return substr($block, 0, $anchorPos).$section.substr($block, $anchorPos); + } + + /** + * `@generated` 블록 문자열에서 예시 섹션 하나를 제거합니다. + * + * 서술 영역에 수기 예시가 있어 블록 안 예시가 중복이 되는 경우, `$label` 섹션 + * (`$label` ~ `$anchor` 직전)을 제거한다. 섹션이 없으면 그대로 반환한다. + * + * @param string $block `@generated` 블록 문자열 + * @param string $label 제거할 예시 섹션 헤딩 (`**응답 예시**`) + * @param string $anchor 섹션 끝 기준 헤딩 (`**에러 응답**`) + * @param int $inserted 변경 카운터 (참조 누적) + * @return string 갱신된 블록 문자열 + */ + private function removeExampleBlock(string $block, string $label, string $anchor, int &$inserted): string + { + $labelLine = $label."\n"; + $labelPos = strpos($block, $labelLine); + if ($labelPos === false) { + return $block; + } + + $anchorPos = strpos($block, $anchor, $labelPos); + if ($anchorPos === false) { + return $block; + } + + $removed = substr($block, 0, $labelPos).substr($block, $anchorPos); + if ($removed !== $block) { + $inserted++; + } + + return $removed; + } +} diff --git a/app/Support/ApiDoc/ApiEndpointProbe.php b/app/Support/ApiDoc/ApiEndpointProbe.php new file mode 100644 index 00000000..a50d8d4e --- /dev/null +++ b/app/Support/ApiDoc/ApiEndpointProbe.php @@ -0,0 +1,216 @@ +baseUrl = rtrim($resolved, '/'); + } + + /** + * 실측 기준 URL 을 반환합니다. + * + * @return string 기준 URL + */ + public function baseUrl(): string + { + return $this->baseUrl; + } + + /** + * 실측용 관리자 토큰을 발급합니다. + * + * @param int|null $userId 토큰 발급 대상 사용자 ID (null 이면 첫 관리자) + * @return bool 발급 성공 여부 + */ + public function authenticate(?int $userId = null): bool + { + $user = $userId + ? User::find($userId) + : User::query()->orderBy('id')->first(); + + if (! $user) { + return false; + } + + $this->user = $user; + $this->cleanupTokens(); + $this->token = $user->createToken($this->tokenName)->plainTextToken; + + return true; + } + + /** + * 엔드포인트를 실호출하여 응답을 관측합니다. + * + * GET/HEAD 는 외부 HTTP 로 read-only 실측하고, 쓰기 메서드(POST/PUT/PATCH/DELETE)는 + * **DB 트랜잭션 안에서 in-process 로 dispatch 한 뒤 롤백**하여(영속 안 함) 응답 shape 만 + * 관측한다. 외부 HTTP 는 별도 프로세스라 롤백이 불가하므로 쓰기는 in-process 경로를 쓴다. + * + * @param string $method HTTP 메서드 + * @param string $uri 라우트 URI (path 파라미터 치환 완료된 실제 경로) + * @param array $body 쓰기 요청 바디 (쓰기 메서드에서만 사용) + * @return array{ok: bool, status: int|null, body: array|null, skipped_reason: string|null} + */ + public function probe(string $method, string $uri, array $body = []): array + { + $method = strtoupper($method); + + // path 파라미터가 남아 있으면(치환 실패) 실호출 불가. + if (Str::contains($uri, '{')) { + return ['ok' => false, 'status' => null, 'body' => null, 'skipped_reason' => 'unresolved-path-param']; + } + + if (! $this->token) { + return ['ok' => false, 'status' => null, 'body' => null, 'skipped_reason' => 'no-token']; + } + + if (in_array($method, ['GET', 'HEAD'], true)) { + return $this->probeRead($method, $uri); + } + + return $this->probeWrite($method, $uri, $body); + } + + /** + * GET/HEAD 를 외부 HTTP 로 read-only 실측합니다. + * + * @param string $method HTTP 메서드 + * @param string $uri 치환된 URI + * @return array{ok: bool, status: int|null, body: array|null, skipped_reason: string|null} + */ + private function probeRead(string $method, string $uri): array + { + try { + $response = Http::withoutVerifying() + ->withToken($this->token) + ->acceptJson() + ->timeout(15) + ->get($this->baseUrl.$uri); + + $json = $response->json(); + + return [ + 'ok' => $response->successful() && is_array($json), + 'status' => $response->status(), + 'body' => is_array($json) ? $json : null, + 'skipped_reason' => null, + ]; + } catch (\Throwable $e) { + return ['ok' => false, 'status' => null, 'body' => null, 'skipped_reason' => 'request-failed: '.$e->getMessage()]; + } + } + + /** + * 쓰기 메서드를 DB 트랜잭션 안에서 in-process dispatch 후 롤백하여 실측합니다. + * + * 응답 shape 만 관측하고 부수효과(레코드 생성/수정/삭제)는 롤백으로 영속시키지 않는다. + * 인증은 샘플 사용자로 로그인(guard)해 수행하며, DELETE 등 바디 없는 메서드도 처리한다. + * + * @param string $method HTTP 메서드 + * @param string $uri 치환된 URI + * @param array $body 요청 바디 + * @return array{ok: bool, status: int|null, body: array|null, skipped_reason: string|null} + */ + private function probeWrite(string $method, string $uri, array $body): array + { + if (! $this->user) { + return ['ok' => false, 'status' => null, 'body' => null, 'skipped_reason' => 'no-token']; + } + + DB::beginTransaction(); + + try { + // in-process dispatch 는 실제 Bearer 토큰 헤더로 인증한다(guard 종류 무관). + // Auth::login() 은 API 기본 guard 가 RequestGuard(토큰 전용)라 사용할 수 없다. + $request = Request::create($uri, $method, $body); + $request->headers->set('Accept', 'application/json'); + $request->headers->set('Authorization', 'Bearer '.$this->token); + + $response = app()->handle($request); + $content = $response->getContent(); + $json = json_decode(is_string($content) ? $content : '', true); + $status = $response->getStatusCode(); + + return [ + 'ok' => $status >= 200 && $status < 300 && is_array($json), + 'status' => $status, + 'body' => is_array($json) ? $json : null, + 'skipped_reason' => (is_array($json) && $status >= 200 && $status < 300) ? null : 'http-'.$status, + ]; + } catch (\Throwable $e) { + return ['ok' => false, 'status' => null, 'body' => null, 'skipped_reason' => 'write-probe-failed: '.$e->getMessage()]; + } finally { + // 쓰기 부수효과를 영속시키지 않는다 (문서 생성 목적). + DB::rollBack(); + // 다음 실측에 인증 컨텍스트가 새지 않도록 해석된 guard 를 초기화한다 + // (API guard 는 RequestGuard 라 logout() 이 없으므로 forgetGuards 사용). + Auth::forgetGuards(); + } + } + + /** + * 발급한 임시 토큰을 정리합니다. + */ + public function cleanup(): void + { + $this->cleanupTokens(); + $this->token = null; + } + + /** + * 실측용 토큰 레코드를 모두 삭제합니다. + */ + private function cleanupTokens(): void + { + PersonalAccessToken::query() + ->where('name', $this->tokenName) + ->delete(); + } +} diff --git a/app/Support/ApiDoc/ApiRouteInventory.php b/app/Support/ApiDoc/ApiRouteInventory.php new file mode 100644 index 00000000..001b3173 --- /dev/null +++ b/app/Support/ApiDoc/ApiRouteInventory.php @@ -0,0 +1,442 @@ +> 정규화된 라우트 메타데이터 목록 + */ + public function collect(?string $scope = null): array + { + $routes = []; + + foreach (RouteFacade::getRoutes() as $route) { + /** @var Route $route */ + $uri = $route->uri(); + + if (! Str::startsWith($uri, 'api/')) { + continue; + } + + $name = $route->getName() ?? ''; + $owner = $this->resolveOwner($name, $uri); + + if ($scope !== null && ! $this->matchesScope($owner, $scope)) { + continue; + } + + foreach ($this->normalizeRoute($route, $uri, $name, $owner) as $entry) { + $routes[] = $entry; + } + } + + // route:list 는 활성 확장만 노출한다. 비활성/미설치 확장을 명시 범위로 지정하면 + // (module:{id} / plugin:{id}) 등록된 라우트가 0건이 되므로, 그 확장의 번들 라우트 + // 파일을 프로바이더와 동일한 prefix/name 규약으로 임시 라우터에 로드해 폴백 수집한다. + if ($routes === [] && $scope !== null && $scope !== 'core' && $scope !== 'all') { + $routes = $this->collectFromBundledFiles($scope); + } + + usort($routes, fn ($a, $b) => [$a['owner']['key'], $a['uri'], $a['method']] <=> [$b['owner']['key'], $b['uri'], $b['method']]); + + return $routes; + } + + /** + * 하나의 라우트를 HTTP 메서드별 정규화 엔트리 배열로 변환합니다. + * + * @param Route $route 라우트 인스턴스 + * @param string $uri 라우트 URI ('api/' prefix 포함) + * @param string $name 라우트명 + * @param array{type: string, id: string|null, key: string} $owner 소유 주체 + * @return array> 정규화된 엔트리 목록 + */ + private function normalizeRoute(Route $route, string $uri, string $name, array $owner): array + { + $entries = []; + + $middleware = $this->gatherMiddleware($route); + + foreach ($this->httpMethods($route) as $method) { + $entries[] = [ + 'method' => $method, + 'uri' => '/'.ltrim($uri, '/'), + 'name' => $name, + 'owner' => $owner, + 'action' => $route->getActionName(), + 'controller' => $this->controllerClass($route), + 'controller_method' => $this->controllerMethod($route), + 'middleware' => $middleware, + 'permission' => $this->resolvePermission($middleware), + 'path_params' => $this->pathParams($uri), + 'path_bindings' => $this->pathBindings($route), + 'domain_group' => $this->domainGroup($name, $uri, $owner), + ]; + } + + return $entries; + } + + /** + * 라우트의 미들웨어 목록을 안전하게 수집합니다. + * + * `gatherMiddleware()` 는 컨트롤러 정의 미들웨어를 읽기 위해 컨트롤러를 인스턴스화한다. + * 비활성/미설치 확장은 서비스 바인딩이 부팅되지 않아 인스턴스화가 실패할 수 있으므로, + * 실패 시 라우트에 직접 부여된 정적 미들웨어(`middleware()`)로 폴백한다. + * + * @param Route $route 라우트 인스턴스 + * @return array 미들웨어 목록 + */ + private function gatherMiddleware(Route $route): array + { + try { + return $route->gatherMiddleware(); + } catch (\Throwable) { + return $route->middleware(); + } + } + + /** + * 비활성/미설치 확장의 번들 라우트 파일을 임시 라우터에 로드해 수집합니다. + * + * 프로바이더(`ModuleRouteServiceProvider`/`PluginRouteServiceProvider`)와 동일한 + * prefix(`api/{modules|plugins}/{id}`)·name(`api.{modules|plugins}.{id}.`)·`api` + * 미들웨어 그룹으로 `src/routes/api.php` 를 라우터에 로드한다. `api/` 로 시작하는 + * 라우트만 대상이므로 web(admin) 라우트는 자동 제외된다(그 라우트는 API 문서 대상이 아니다). + * + * 라우트 파일은 `use Illuminate\Support\Facades\Route` 로 전역 파사드에 등록하므로, + * 등록 전 라우트 이름 스냅샷을 떠서 이번 로드로 추가된 라우트만 골라낸다. 이 커맨드는 + * CLI 단발 프로세스라 전역 라우트 테이블 추가가 웹 요청에 영향을 주지 않는다. + * + * @param string $scope 범위 필터 (`module:{id}` / `plugin:{id}`) + * @return array> 정규화된 라우트 메타데이터 목록 + */ + private function collectFromBundledFiles(string $scope): array + { + if (! preg_match('/^(module|plugin):(.+)$/', $scope, $m)) { + return []; + } + + [$type, $id] = [$m[1], $m[2]]; + $dir = $type === 'module' ? 'modules' : 'plugins'; + $apiRouteFile = base_path("{$dir}/_bundled/{$id}/src/routes/api.php"); + + if (! is_file($apiRouteFile)) { + return []; + } + + $urlPrefix = "api/{$dir}/{$id}"; + $namePrefix = "api.{$dir}.{$id}."; + + try { + RouteFacade::prefix($urlPrefix) + ->name($namePrefix) + ->middleware('api') + ->group($apiRouteFile); + RouteFacade::getRoutes()->refreshNameLookups(); + } catch (\Throwable) { + return []; + } + + // 이 확장 소유로 확정된 라우트를 전역 라우트 테이블에서 prefix(uri/name) 로 직접 골라낸다. + // object-id 스냅샷 방식은 refreshNameLookups() 가 RouteCollection 을 재색인하며 객체를 + // 다시 만들어 dedup 이 어긋나므로(활성 확장 로드 순서·중복 로드에 취약) 쓰지 않는다. + // 대상 확장은 uri 가 `api/{dir}/{id}/`, name 이 `api.{dir}.{id}.` 로 시작해 결정적으로 식별된다. + $seen = []; + $routes = []; + + foreach (RouteFacade::getRoutes() as $route) { + /** @var Route $route */ + $uri = $route->uri(); + + if (! Str::startsWith($uri, 'api/')) { + continue; + } + + $name = $route->getName() ?? ''; + $owner = $this->resolveOwner($name, $uri); + + // 폴백 대상 확장 소유로 확정되지 않으면(코어·타 확장) 제외 + if ($owner['key'] !== $scope) { + continue; + } + + foreach ($this->normalizeRoute($route, $uri, $name, $owner) as $entry) { + // 파일이 이미 한 번 로드돼 있으면 같은 (method, uri) 가 중복될 수 있어 dedup 한다. + $dedupKey = $entry['method'].' '.$entry['uri']; + if (isset($seen[$dedupKey])) { + continue; + } + $seen[$dedupKey] = true; + $routes[] = $entry; + } + } + + return $routes; + } + + /** + * 라우트명/URI 로 소유 주체(코어/모듈/플러그인)를 판별합니다. + * + * @param string $name 라우트명 + * @param string $uri 라우트 URI + * @return array{type: string, id: string|null, key: string} 소유 주체 정보 + */ + private function resolveOwner(string $name, string $uri): array + { + if (preg_match('/^api\.modules\.([^.]+)\./', $name, $m) && $this->isRealExtensionId($m[1])) { + return ['type' => 'module', 'id' => $m[1], 'key' => 'module:'.$m[1]]; + } + + if (preg_match('/^api\.plugins\.([^.]+)\./', $name, $m) && $this->isRealExtensionId($m[1])) { + return ['type' => 'plugin', 'id' => $m[1], 'key' => 'plugin:'.$m[1]]; + } + + // 라우트명이 비어도 URI prefix 로 보조 판별 + if (preg_match('#^api/modules/([^/{]+)/#', $uri, $m) && $this->isRealExtensionId($m[1])) { + return ['type' => 'module', 'id' => $m[1], 'key' => 'module:'.$m[1]]; + } + + if (preg_match('#^api/plugins/([^/{]+)/#', $uri, $m) && $this->isRealExtensionId($m[1])) { + return ['type' => 'plugin', 'id' => $m[1], 'key' => 'plugin:'.$m[1]]; + } + + // 코어가 제공하는 확장 메타/에셋 라우트(/api/modules/{identifier}/license, + // /api/modules/bundle.js 등)는 실제 확장 소유가 아니라 코어 소유다. + return ['type' => 'core', 'id' => null, 'key' => 'core']; + } + + /** + * 세그먼트가 실제 확장 식별자인지 판별합니다 (플레이스홀더/에셋 라우트 제외). + * + * @param string $segment URI/라우트명 세그먼트 + * @return bool 실제 확장 식별자 여부 + */ + private function isRealExtensionId(string $segment): bool + { + // {identifier} 같은 라우트 파라미터, bundle/assets 같은 코어 에셋 라우트 제외. + // 실제 확장 식별자는 vendor-name 형태로 하이픈을 포함한다. + if (Str::startsWith($segment, '{') || in_array($segment, ['assets', 'bundle', 'bundle.js', 'bundle.css'], true)) { + return false; + } + + return Str::contains($segment, '-'); + } + + /** + * 소유 주체가 지정된 범위 필터에 매칭되는지 확인합니다. + * + * @param array{type: string, id: string|null, key: string} $owner 소유 주체 + * @param string $scope 범위 필터 + * @return bool 매칭 여부 + */ + private function matchesScope(array $owner, string $scope): bool + { + if ($scope === 'all') { + return true; + } + + if ($scope === 'core') { + return $owner['type'] === 'core'; + } + + return $owner['key'] === $scope; + } + + /** + * 라우트의 HTTP 메서드 목록을 반환합니다 (HEAD 제외). + * + * @param Route $route 라우트 인스턴스 + * @return array HTTP 메서드 목록 + */ + private function httpMethods(Route $route): array + { + return array_values(array_filter( + $route->methods(), + fn ($m) => $m !== 'HEAD' + )); + } + + /** + * 컨트롤러 클래스명을 반환합니다. + * + * @param Route $route 라우트 인스턴스 + * @return string|null 컨트롤러 FQCN (클로저면 null) + */ + private function controllerClass(Route $route): ?string + { + $action = $route->getActionName(); + + if ($action === 'Closure' || ! Str::contains($action, '@')) { + return null; + } + + return Str::before($action, '@'); + } + + /** + * 컨트롤러 메서드명을 반환합니다. + * + * @param Route $route 라우트 인스턴스 + * @return string|null 메서드명 (클로저면 null) + */ + private function controllerMethod(Route $route): ?string + { + $action = $route->getActionName(); + + if ($action === 'Closure' || ! Str::contains($action, '@')) { + return null; + } + + return Str::after($action, '@'); + } + + /** + * 미들웨어 목록에서 permission 식별자를 추출합니다. + * + * @param array $middleware 미들웨어 목록 + * @return string|null permission 식별자 (예: core.users.read) + */ + private function resolvePermission(array $middleware): ?string + { + foreach ($middleware as $mw) { + if (! Str::contains($mw, 'PermissionMiddleware') && ! Str::startsWith($mw, 'permission:')) { + continue; + } + + $args = Str::after($mw, ':'); + // permission:admin,core.users.read,except:self:user → core.users.read + foreach (explode(',', $args) as $part) { + $part = trim($part); + if (Str::contains($part, '.') && ! Str::startsWith($part, 'except')) { + return $part; + } + } + } + + return null; + } + + /** + * URI 의 path 파라미터 목록을 추출합니다. + * + * @param string $uri 라우트 URI + * @return array path 파라미터명 목록 + */ + private function pathParams(string $uri): array + { + preg_match_all('/\{([^}?]+)\??\}/', $uri, $m); + + return $m[1] ?? []; + } + + /** + * path 파라미터명 → 바인딩된 Eloquent 모델 클래스 맵을 추출합니다. + * + * @param Route $route 라우트 인스턴스 + * @return array 파라미터명 => 모델 FQCN + */ + private function pathBindings(Route $route): array + { + $bindings = []; + + try { + foreach ($route->signatureParameters() as $param) { + $type = $param->getType(); + + if ($type instanceof \ReflectionNamedType && ! $type->isBuiltin()) { + $class = $type->getName(); + + if (is_subclass_of($class, Model::class)) { + $bindings[$param->getName()] = $class; + } + } + } + } catch (\Throwable) { + // signatureParameters 실패 시 빈 맵 + } + + return $bindings; + } + + /** + * 문서 파일 그룹핑용 도메인 키를 산출합니다. + * + * @param string $name 라우트명 + * @param string $uri 라우트 URI + * @param array{type: string, id: string|null, key: string} $owner 소유 주체 + * @return string 도메인 키 (예: users, products) + */ + private function domainGroup(string $name, string $uri, array $owner): string + { + // 라우트명 구조: api.{context}.{resource}.{action} 또는 + // api.modules.{ext-id}.{context}.{resource}.{action} + // 도메인 = 앞쪽 연속된 context/소유 세그먼트를 걷어낸 뒤의 첫 세그먼트(리소스). + // 위치 기반이므로 'auth' 같은 리소스가 skip 리스트에 걸려 소실되지 않는다. + // 'modules'/'plugins' 는 확장 소유 prefix(api.modules.{id}.*)로도, 코어 확장관리 + // 리소스(api.admin.modules.*)로도 쓰인다. 소유 prefix 는 owner['id'] 스킵으로 이미 + // 걷히므로, leading 에는 순수 컨텍스트(api/admin/user/me/public)만 둔다. + $leading = ['api', 'admin', 'user', 'me', 'public']; + $segments = array_values(array_filter(explode('.', $name), fn ($s) => $s !== '')); + + $i = 0; + while ($i < count($segments)) { + $seg = $segments[$i]; + // 확장 소유 prefix 세그먼트(modules/plugins + ext-id)만 스킵 + $isOwnerPrefix = in_array($seg, ['modules', 'plugins'], true) && isset($segments[$i + 1]) && $segments[$i + 1] === $owner['id']; + $isLeading = in_array($seg, $leading, true) || $isOwnerPrefix || ($owner['id'] !== null && $seg === $owner['id']); + + if (! $isLeading) { + break; + } + $i++; + } + + // context(me 등) 직후가 REST 액션명이면 리소스가 아니라 그 컨텍스트가 도메인이다 + // (api.me.show → 'me', api.me.destroy → 'me'). 아니면 그 세그먼트가 리소스. + $restActions = ['show', 'index', 'store', 'update', 'destroy', 'edit', 'create']; + if (isset($segments[$i])) { + if (in_array($segments[$i], $restActions, true) && $i > 0 && in_array($segments[$i - 1], $leading, true)) { + return Str::slug($segments[$i - 1]) ?: 'misc'; + } + + return Str::slug($segments[$i]) ?: 'misc'; + } + + // 리소스 세그먼트가 없으면(context 직속) 마지막 컨텍스트를 도메인으로. + if ($i > 0 && isset($segments[$i - 1]) && in_array($segments[$i - 1], $leading, true)) { + return Str::slug($segments[$i - 1]) ?: 'misc'; + } + + // 라우트명이 비었으면 URI 에서 소유/컨텍스트 prefix 를 걷어낸 첫 리소스 세그먼트 + $parts = array_values(array_filter(explode('/', $uri), fn ($p) => $p !== '' && ! Str::startsWith($p, '{'))); + $uriLeading = ['api', 'admin', 'user', 'me', 'public']; + foreach ($parts as $p) { + if (in_array($p, $uriLeading, true) || ($owner['id'] !== null && $p === $owner['id'])) { + continue; + } + if (in_array($p, ['modules', 'plugins'], true) && ($owner['id'] !== null)) { + continue; + } + + return Str::slug($p) ?: 'misc'; + } + + return 'misc'; + } +} diff --git a/app/Support/ApiDoc/ColumnCommentResolver.php b/app/Support/ApiDoc/ColumnCommentResolver.php new file mode 100644 index 00000000..58d95edf --- /dev/null +++ b/app/Support/ApiDoc/ColumnCommentResolver.php @@ -0,0 +1,71 @@ +> 테이블별 컬럼 주석 캐시 + */ + private array $cache = []; + + /** + * 모델 테이블의 컬럼명 => 주석 맵을 반환합니다. + * + * @param class-string|null $modelClass 모델 FQCN + * @return array 컬럼명 => 주석 + */ + public function forModel(?string $modelClass): array + { + if (! $modelClass || ! class_exists($modelClass) || ! is_subclass_of($modelClass, Model::class)) { + return []; + } + + try { + $table = (new $modelClass)->getTable(); + } catch (\Throwable) { + return []; + } + + return $this->forTable($table); + } + + /** + * 테이블의 컬럼명 => 주석 맵을 반환합니다 (캐시). + * + * @param string $table 테이블명 + * @return array 컬럼명 => 주석 + */ + public function forTable(string $table): array + { + if (isset($this->cache[$table])) { + return $this->cache[$table]; + } + + $map = []; + + try { + foreach (Schema::getColumns($table) as $column) { + $comment = $column['comment'] ?? null; + + if (is_string($comment) && $comment !== '') { + $map[$column['name']] = $comment; + } + } + } catch (\Throwable) { + // 테이블 부재/드라이버 미지원 시 빈 맵 + } + + return $this->cache[$table] = $map; + } +} diff --git a/app/Support/ApiDoc/FormRequestIntrospector.php b/app/Support/ApiDoc/FormRequestIntrospector.php new file mode 100644 index 00000000..5c52cfc4 --- /dev/null +++ b/app/Support/ApiDoc/FormRequestIntrospector.php @@ -0,0 +1,200 @@ +>, hook_filters: array} + */ + public function introspect(?string $controller, ?string $method): array + { + $empty = ['request_class' => null, 'params' => [], 'hook_filters' => []]; + + if (! $controller || ! $method || ! class_exists($controller)) { + return $empty; + } + + try { + $ref = new ReflectionMethod($controller, $method); + } catch (\ReflectionException) { + return $empty; + } + + $requestClass = $this->findFormRequestParam($ref); + + if (! $requestClass) { + return $empty; + } + + $rules = $this->extractRules($requestClass); + + return [ + 'request_class' => $requestClass, + 'params' => $this->rulesToParams($rules), + 'hook_filters' => $this->extractHookFilters($requestClass), + ]; + } + + /** + * 메서드 파라미터 중 FormRequest 하위 클래스를 찾습니다. + * + * @param ReflectionMethod $ref 메서드 리플렉션 + * @return class-string|null FormRequest FQCN + */ + private function findFormRequestParam(ReflectionMethod $ref): ?string + { + foreach ($ref->getParameters() as $param) { + $type = $param->getType(); + + if (! $type instanceof ReflectionNamedType || $type->isBuiltin()) { + continue; + } + + $class = $type->getName(); + + if (is_subclass_of($class, FormRequest::class)) { + return $class; + } + } + + return null; + } + + /** + * FormRequest 인스턴스를 만들어 rules() 를 호출합니다. + * + * @param class-string $requestClass FormRequest FQCN + * @return array 검증 규칙 배열 + */ + private function extractRules(string $requestClass): array + { + try { + $instance = new $requestClass; + + if (! method_exists($instance, 'rules')) { + return []; + } + + $rules = $instance->rules(); + + return is_array($rules) ? $rules : []; + } catch (\Throwable) { + // rules() 가 컨테이너/route 의존이면 정적 호출 실패 — 파라미터 표는 비운다. + return []; + } + } + + /** + * 검증 규칙 배열을 문서용 파라미터 메타데이터로 변환합니다. + * + * @param array $rules 검증 규칙 배열 + * @return array> 파라미터 메타데이터 목록 + */ + private function rulesToParams(array $rules): array + { + $params = []; + + foreach ($rules as $field => $rule) { + // 중첩 필드(items.*.id)는 상위만 대표로 노출 + if (str_contains((string) $field, '.')) { + continue; + } + + $tokens = is_array($rule) ? $rule : explode('|', (string) $rule); + $tokens = array_map(fn ($t) => is_string($t) ? $t : '', $tokens); + + $params[] = [ + 'name' => $field, + 'type' => $this->inferType($tokens), + 'required' => in_array('required', $tokens, true), + 'allowed' => $this->inferAllowed($tokens), + ]; + } + + return $params; + } + + /** + * 규칙 토큰에서 파라미터 타입을 유추합니다. + * + * @param array $tokens 규칙 토큰 목록 + * @return string 유추된 타입 + */ + private function inferType(array $tokens): string + { + foreach (['integer', 'numeric', 'boolean', 'array', 'string', 'date', 'email', 'file', 'image', 'uuid'] as $type) { + foreach ($tokens as $t) { + if ($t === $type || str_starts_with($t, $type)) { + return $type === 'numeric' ? 'number' : $type; + } + } + } + + return 'string'; + } + + /** + * 규칙 토큰에서 허용값(in:, max:, min: 등)을 유추합니다. + * + * @param array $tokens 규칙 토큰 목록 + * @return string 허용값 설명 (없으면 빈 문자열) + */ + private function inferAllowed(array $tokens): string + { + $parts = []; + + foreach ($tokens as $t) { + if (str_starts_with($t, 'in:')) { + $parts[] = '`'.str_replace(',', '`, `', substr($t, 3)).'`'; + } elseif (str_starts_with($t, 'max:')) { + $parts[] = 'max '.substr($t, 4); + } elseif (str_starts_with($t, 'min:')) { + $parts[] = 'min '.substr($t, 4); + } elseif (str_starts_with($t, 'between:')) { + $parts[] = 'between '.substr($t, 8); + } + } + + return implode(', ', $parts); + } + + /** + * FormRequest 소스에서 HookManager::applyFilters 훅 이름을 추출합니다. + * + * @param class-string $requestClass FormRequest FQCN + * @return array 훅 필터 이름 목록 + */ + private function extractHookFilters(string $requestClass): array + { + try { + $file = (new ReflectionClass($requestClass))->getFileName(); + + if (! $file || ! is_readable($file)) { + return []; + } + + $source = file_get_contents($file); + preg_match_all('/applyFilters\(\s*[\'"]([a-z0-9_.-]+)[\'"]/i', $source, $m); + + return array_values(array_unique($m[1] ?? [])); + } catch (\Throwable) { + return []; + } + } +} diff --git a/app/Support/ApiDoc/ParameterDescriber.php b/app/Support/ApiDoc/ParameterDescriber.php new file mode 100644 index 00000000..cf96712d --- /dev/null +++ b/app/Support/ApiDoc/ParameterDescriber.php @@ -0,0 +1,394 @@ + 정확 이름 => 설명 (위치 무관 공통 파라미터) + */ + private const EXACT = [ + // 페이지네이션 + 'page' => '조회할 페이지 번호 (1부터 시작)', + 'per_page' => '페이지당 항목 수', + 'limit' => '반환할 최대 항목 수', + 'offset' => '건너뛸 항목 수 (오프셋 페이지네이션)', + 'cursor' => '커서 기반 페이지네이션의 다음 페이지 커서', + + // 정렬 + 'sort' => '정렬 기준 (필드명, `-` 접두 시 내림차순)', + 'sort_by' => '정렬 기준 필드명', + 'sort_field' => '정렬 기준 필드명', + 'sort_direction' => '정렬 방향 (asc / desc)', + 'order_by' => '정렬 기준 필드명', + 'direction' => '정렬 방향 (asc / desc)', + // sort_order / order 는 문맥에 따라 정렬 방향(문자열 asc/desc)과 + // 표시 순서 값(정수)으로 갈리므로 EXACT 에 두지 않고 describe() 에서 + // 타입으로 분기한다. + + // 검색/필터 + 'search' => '검색어 (지정한 검색 대상 필드에서 부분 일치)', + 'q' => '검색어 (부분 일치)', + 'keyword' => '검색 키워드 (부분 일치)', + 'search_keyword' => '검색 키워드 (부분 일치)', + 'search_field' => '검색 대상 필드명 (검색어를 적용할 컬럼)', + 'search_type' => '검색 유형 (검색 대상/방식 구분)', + 'filters' => '추가 필터 조건 맵 (필드별 조건)', + 'filter' => '필터 조건', + 'start_date' => '조회 기간 시작일 (이 날짜 이후 데이터)', + 'end_date' => '조회 기간 종료일 (이 날짜 이전 데이터)', + 'date_from' => '조회 기간 시작일', + 'date_to' => '조회 기간 종료일', + 'from' => '조회 시작 값 (기간/범위 하한)', + 'to' => '조회 종료 값 (기간/범위 상한)', + 'scope' => '조회 범위 한정 키', + + // 상태 토글/플래그 + 'is_active' => '활성 여부 (true 활성 / false 비활성)', + 'is_default' => '기본값 지정 여부', + 'active' => '활성 여부', + 'published' => '발행 여부 (발행된 항목만 필터)', + 'enabled' => '사용 여부', + 'force' => '강제 실행 여부 (안전 확인/선행 검사 우회)', + 'with_trashed' => '소프트 삭제된 항목 포함 여부', + 'only_trashed' => '소프트 삭제된 항목만 조회 여부', + + // 대량 처리 + 'ids' => '대상 리소스 식별자 배열 (대량 작업 대상)', + 'items' => '처리 대상 항목 배열', + + // 국제화 + 'locale' => '로케일 코드 (표시 언어/지역)', + 'language' => '언어 코드', + 'country_code' => '국가 코드 (ISO 3166-1 alpha-2)', + 'timezone' => '타임존 식별자', + + // 인증/보안 공통 + 'password' => '비밀번호', + 'current_password' => '현재 비밀번호 (변경 전 확인용)', + 'password_confirmation' => '비밀번호 확인 (password 와 일치해야 함)', + 'token' => '인증/검증 토큰', + 'email' => '이메일 주소', + + // 주소 공통 + 'zipcode' => '우편번호', + 'address' => '기본 주소', + 'address_detail' => '상세 주소', + 'recipient_name' => '수령인 이름', + 'recipient_phone' => '수령인 연락처', + 'address_line_1' => '주소 1행 (기본 주소)', + 'address_line_2' => '주소 2행 (상세 주소)', + 'intl_city' => '도시 (국제 주소)', + 'intl_state' => '주/도 (국제 주소)', + 'intl_postal_code' => '우편번호 (국제 주소)', + 'region' => '지역/권역', + + // SEO 메타 공통 (근거: Seo\* / Page\* / Product\* FormRequest 의 + // meta_title/meta_description — 검색엔진 노출용 메타 태그 값. 도메인 무관.) + 'meta_title' => 'SEO 메타 제목 (검색엔진/소셜 공유 표시 제목)', + 'meta_description' => 'SEO 메타 설명 (검색엔진/소셜 공유 표시 요약)', + 'alt_text' => '이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구)', + + // 프로필/콘텐츠 공통 필드 (User/프로필/일반 리소스에서 의미 고정) + // 근거: User\{Create,Update}UserRequest, UpdateProfileRequest, Auth\RegisterRequest, + // Layout\* / Menu\* / Notification* / Schedule\* FormRequest + 'name' => '대상의 이름/명칭', + 'nickname' => '닉네임', + 'description' => '설명', + 'content' => '본문 내용', + 'body' => '본문', + 'subject' => '제목', + 'title' => '제목', + 'slug' => 'URL 친화 식별자 (slug)', + 'label' => '표시용 라벨', + 'phone' => '전화번호', + 'mobile' => '휴대전화 번호', + 'homepage' => '홈페이지 URL', + 'bio' => '자기소개', + 'signature' => '서명', + 'country' => '국가 코드 (ISO 3166-1 alpha-2)', + 'url' => 'URL', + 'file' => '업로드 파일', + 'files' => '업로드 파일 배열', + 'collection' => '첨부 컬렉션 그룹명 (첨부를 용도별로 묶는 키, 미지정 시 default)', + 'avatar' => '아바타 이미지', + 'icon' => '아이콘', + 'value' => '값', + 'values' => '값 배열', + 'username' => '사용자명 (로그인/인증 아이디)', + 'path' => '경로', + 'data' => '데이터 페이로드', + + // 확장/버전 공통 (module/plugin/template/language-pack 설치·업데이트 계약) + // 근거: Module/Plugin/Template/LanguagePack Install·Update·Activate FormRequest, + // Extension\ChangelogRequest, Menu/Schedule/Notification* Request + 'extension_type' => '확장 유형 (core/module/plugin/template)', + 'extension_identifier' => '확장 식별자', + 'from_version' => '시작 버전 (범위 하한)', + 'to_version' => '대상 버전 (범위 상한)', + 'github_url' => 'GitHub 저장소 URL', + 'vendor' => '벤더명 (확장 제작자 식별자)', + 'vendor_mode' => '벤더 설치 모드 (auto/composer/bundled)', + 'checksum' => '무결성 검증 체크섬 (SHA-256)', + 'target_identifier' => '대상 확장 식별자', + 'source_identifier' => '출처 식별자', + 'auto_activate' => '설치 후 자동 활성화 여부', + 'cascade' => '연쇄 처리 여부 (의존 항목 함께 처리)', + 'exclude_protected' => '보호 항목 제외 여부', + + // 스케줄/작업 공통 (근거: Schedule\{Create,Update}ScheduleRequest, ScheduleListRequest) + 'command' => '실행할 아티즌 커맨드', + 'frequency' => '실행 주기', + 'priority' => '우선순위 (작을수록 우선)', + 'timeout' => '타임아웃 (초)', + 'run_in_maintenance' => '점검 모드 중 실행 여부', + 'without_overlapping' => '중복 실행 방지 여부', + 'expected_lock_version' => '낙관적 잠금 버전 (동시 편집 충돌 감지)', + + // 메일/드라이버 설정 (근거: Settings\SaveSettingsRequest, + // Settings\TestMailRequest, Settings\TestDriverConnectionRequest) + 'mailer' => '메일 발송 드라이버 (smtp/mailgun/ses)', + 'from_address' => '발신자 주소', + 'from_name' => '발신자 이름', + 'to_email' => '테스트 수신 주소', + 'host' => '호스트 주소', + 'port' => '포트 번호', + 'encryption' => '전송 암호화 방식 (tls/ssl)', + 'storage_driver' => '스토리지 드라이버 (local/s3)', + 'cache_driver' => '캐시 드라이버 (file/redis/memcached)', + 'session_driver' => '세션 드라이버 (file/database/redis)', + 'queue_driver' => '큐 드라이버 (sync/database/redis)', + 'redis_host' => 'Redis 호스트 주소', + 'redis_port' => 'Redis 포트 번호', + 'redis_password' => 'Redis 비밀번호', + 'redis_database' => 'Redis 데이터베이스 번호', + 'memcached_host' => 'Memcached 호스트 주소', + 'memcached_port' => 'Memcached 포트 번호', + 's3_bucket' => 'S3 버킷명', + 's3_region' => 'S3 리전', + 's3_access_key' => 'S3 액세스 키', + 's3_secret_key' => 'S3 시크릿 키', + 's3_url' => 'S3 엔드포인트 URL', + 'ses_key' => 'SES 액세스 키', + 'ses_secret' => 'SES 시크릿 키', + 'ses_region' => 'SES 리전', + 'mailgun_domain' => 'Mailgun 도메인', + 'mailgun_secret' => 'Mailgun 시크릿 키', + 'mailgun_endpoint' => 'Mailgun 엔드포인트', + 'websocket_enabled' => 'WebSocket 사용 여부', + 'websocket_host' => 'WebSocket 호스트 주소', + 'websocket_port' => 'WebSocket 포트 번호', + 'websocket_scheme' => 'WebSocket 스킴 (http/https)', + 'websocket_app_key' => 'WebSocket 앱 키', + ]; + + /** + * 파라미터 설명을 반환합니다. 없으면 null (호출자가 TODO 로 폴백). + * + * @param string $name 파라미터명 + * @param string $location 위치 (path/query/body) + * @param string $type 타입 (integer/string/boolean/array...) + * @return string|null 설명 (없으면 null) + */ + public function describe(string $name, string $location = '', string $type = ''): ?string + { + // sort_order / order 는 타입에 따라 의미가 갈린다: + // - 문자열: 정렬 방향(asc/desc) + // - 정수: 표시 정렬 순서 값(작을수록 우선 — 컬럼 값) + if (in_array($name, ['sort_order', 'order'], true)) { + return match ($type) { + 'integer', 'number' => '표시 정렬 순서 값 (작을수록 우선)', + 'string' => '정렬 방향 (asc 오름차순 / desc 내림차순)', + default => null, + }; + } + + // status / type / category 는 query(목록 조회)에서만 필터 의미가 고정된다. + // body(생성/수정)에서는 설정할 도메인 값이므로 의미가 도메인마다 달라 + // 사람 서술(TODO)로 남긴다. + if (in_array($name, ['status', 'type', 'category'], true)) { + if ($location !== 'query') { + return null; + } + $label = ['status' => '상태', 'type' => '유형', 'category' => '분류'][$name]; + + return "{$label} 필터 (해당 {$label}의 항목만 조회)"; + } + + if (isset(self::EXACT[$name])) { + return self::EXACT[$name]; + } + + // path 파라미터는 대부분 리소스 식별자 — 위치를 근거로 유추. + if ($location === 'path') { + return $this->describePathParam($name); + } + + return $this->byPattern($name, $type); + } + + /** + * path 파라미터(리소스 식별자)의 설명을 유추합니다. + * + * @param string $name path 파라미터명 + * @return string|null 설명 (미매칭 시 null) + */ + private function describePathParam(string $name): ?string + { + // 순수 id / *_id / *Id: 대상 리소스의 식별자 + if ($name === 'id') { + return '대상 리소스의 식별자'; + } + if (Str::endsWith($name, '_id')) { + $base = $this->humanize(Str::beforeLast($name, '_id')); + + return "대상 {$base}의 식별자"; + } + if (Str::endsWith($name, 'Id') && $name !== 'Id') { + $base = $this->humanizeCamel(Str::beforeLast($name, 'Id')); + + return "대상 {$base}의 식별자"; + } + + // slug / identifier / hash / uuid: 리소스 지시 키 + if (in_array($name, ['slug', 'identifier', 'hash', 'uuid', 'code'], true)) { + $labels = [ + 'slug' => '대상 리소스의 slug (URL 친화 식별자)', + 'identifier' => '대상 리소스의 식별자', + 'hash' => '대상 리소스의 해시 식별자', + 'uuid' => '대상 리소스의 UUID', + 'code' => '대상 리소스의 코드', + ]; + + return $labels[$name]; + } + + // *Name (templateName, pluginName, moduleName): 확장/리소스 이름 식별자 + if (Str::endsWith($name, 'Name') && $name !== 'Name') { + $base = $this->humanizeCamel(Str::beforeLast($name, 'Name')); + + return "대상 {$base}의 이름 (식별자)"; + } + + // *Identifier (templateIdentifier 등): 확장/리소스 식별자 + if (Str::endsWith($name, 'Identifier') && $name !== 'Identifier') { + $base = $this->humanizeCamel(Str::beforeLast($name, 'Identifier')); + + return "대상 {$base}의 식별자"; + } + + // bare 리소스명 path 파라미터: Laravel route-model binding 은 + // `/{user}`, `/{role}`, `/{definition}` 처럼 대상 모델의 단수형(또는 + // camelCase)을 그대로 세그먼트로 쓴다. 접미 패턴(_id/slug/*Id 등)에 + // 걸리지 않은 path 파라미터는 이 바인딩 대상 리소스의 식별자로 본다. + // 예외: key/version 은 리소스가 아니라 설정 키/버전 값이므로 EXACT 폴백. + $bareExact = [ + 'key' => '대상 설정/항목의 키', + 'version' => '대상 버전 (버전 문자열)', + ]; + if (isset($bareExact[$name])) { + return $bareExact[$name]; + } + $base = str_contains($name, '_') + ? $this->humanize($name) + : $this->humanizeCamel($name); + + return "대상 {$base}의 식별자"; + } + + /** + * query/body 파라미터의 일관된 명명 규칙으로 설명을 유추합니다. + * + * @param string $name 파라미터명 + * @param string $type 타입 + * @return string|null 설명 (미매칭 시 null) + */ + private function byPattern(string $name, string $type): ?string + { + // identifier: 확장/리소스 지시 식별자 (query/body). + // path 위치는 describePathParam 이 먼저 처리하므로 여기 도달하지 않는다. + if ($name === 'identifier') { + return '대상 확장/리소스의 식별자'; + } + + // *_name: 확장/리소스 이름 식별자 (template_name/plugin_name/module_name/layout_name 등). + // EXACT 의 recipient_name/from_name 은 여기 도달 전에 이미 처리된다. + if (Str::endsWith($name, '_name')) { + $base = $this->humanize(Str::beforeLast($name, '_name')); + + return "{$base} 이름 (식별자)"; + } + + // *_id: 연관 리소스 식별자 참조 + if (Str::endsWith($name, '_id')) { + $base = $this->humanize(Str::beforeLast($name, '_id')); + + return "{$base} 식별자"; + } + + // *_ids: 연관 리소스 식별자 배열 + if (Str::endsWith($name, '_ids')) { + $base = $this->humanize(Str::beforeLast($name, '_ids')); + + return "{$base} 식별자 배열"; + } + + // is_*/has_*: 불리언 토글 + if ((Str::startsWith($name, 'is_') || Str::startsWith($name, 'has_')) && $type === 'boolean') { + $base = $this->humanize(Str::after($name, '_')); + + return "{$base} 여부"; + } + + // *_date: 날짜 값 + if (Str::endsWith($name, '_date')) { + $base = $this->humanize(Str::beforeLast($name, '_date')); + + return "{$base} 날짜"; + } + + // *_at: 일시 값 + if (Str::endsWith($name, '_at')) { + $base = $this->humanize(Str::beforeLast($name, '_at')); + + return "{$base} 일시"; + } + + return null; + } + + /** + * snake_case 를 사람이 읽는 문구로 변환합니다. + * + * @param string $token snake_case 토큰 + * @return string 공백 구분 문구 + */ + private function humanize(string $token): string + { + return str_replace('_', ' ', $token); + } + + /** + * camelCase 를 사람이 읽는 문구로 변환합니다. + * + * @param string $token camelCase 토큰 + * @return string 공백 구분 소문자 문구 + */ + private function humanizeCamel(string $token): string + { + return Str::lower(trim(preg_replace('/([A-Z])/', ' $1', $token))); + } +} diff --git a/app/Support/ApiDoc/ResourceFieldDescriber.php b/app/Support/ApiDoc/ResourceFieldDescriber.php new file mode 100644 index 00000000..c11963d3 --- /dev/null +++ b/app/Support/ApiDoc/ResourceFieldDescriber.php @@ -0,0 +1,297 @@ + 정확 필드명 => 설명 (BaseApiResource/공통 Resource 계약) + */ + private const EXACT = [ + // BaseApiResource 표준 메타 (resourceMeta) + 'is_owner' => '현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타)', + 'abilities' => '현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반)', + + // 공통 식별/타임스탬프 + 'id' => '기본 키 (내부 식별자)', + 'uuid' => '외부 노출용 UUID (URL/API 식별자, 내부 id 비노출)', + 'created_at' => '생성 일시', + 'updated_at' => '최종 수정 일시', + 'deleted_at' => '소프트 삭제 일시 (미삭제 시 null)', + + // 공통 콘텐츠/표시 필드 (도메인 무관하게 역할이 고정 — 요청 파라미터 + // ParameterDescriber 의 name/title/content/description 대응물). + // 근거: 실측 시 board 게시글·category·brand·notification 등 전 도메인에서 + // 동일 역할(명칭/제목/본문/설명)로 관측. 다국어(object)/문자열 무관. + 'name' => '대상의 이름/명칭 (다국어 필드는 로케일별 값 객체)', + 'title' => '제목', + 'content' => '본문 내용', + 'description' => '설명 (다국어 필드는 로케일별 값 객체)', + 'slug' => 'URL 친화 식별자 (slug)', + 'label' => '표시용 라벨', + 'icon' => '아이콘 식별자 (아이콘 클래스/이름)', + 'thumbnail' => '썸네일 이미지 URL/경로', + 'ip_address' => '요청/행위가 발생한 IP 주소', + + // User 계약 파생 필드 (UserResource) + 'status_label' => '상태의 사람이 읽는 라벨 (상태 Enum label() 산물)', + 'status_variant' => '상태 표시 색상/스타일 변형 키 (상태 Enum variant() 산물 — UI 배지용)', + 'language_label' => '언어 코드의 현지화 라벨 (user.language.{code} 번역)', + 'country_flag' => '국가 코드의 국기 이모지 (country 값에서 파생)', + 'country_name' => '국가 코드의 현지화 국가명 (country 값에서 파생)', + 'is_admin' => '관리자 역할 보유 여부 (User::isAdmin() — 역할 관계 기반 파생)', + + // 확장 상태 계약 필드 (ModuleResource/PluginResource/TemplateResource) + 'is_bundled' => '코어에 선탑재된 번들 확장인지 여부', + 'is_pending' => '_pending 대기소에 있어 설치 대기 중인지 여부', + 'update_available' => '최신 버전 대비 업데이트 가능 여부', + 'update_source' => '업데이트 감지 출처 (github, bundled 등)', + 'latest_version' => '감지된 최신 배포 버전', + 'file_version' => '설치된 파일의 manifest 버전', + 'incompatible_required_version' => '요구 코어 버전 미충족 시 필요한 버전 (호환되면 null)', + 'status_variant_label' => '상태 표시용 라벨/변형', + 'dependencies' => '의존하는 확장 맵 (manifest 파생 — {modules, plugins})', + 'assets' => '프론트엔드 에셋 매니페스트 (manifest 파생 — js/css 진입점·로딩 전략)', + + // 관계/연관 객체 (여러 Resource 에서 동일 계약) + 'creator' => '생성자 정보 객체 (uuid/name/email — creator 관계 파생)', + 'children' => '하위 항목 배열 (계층 트리 — children 관계 파생)', + 'parent' => '상위 항목 객체 (parent 관계 파생)', + 'permissions' => '연결된 권한 목록 (id/identifier/name — 역할 경유 권한 관계 파생)', + 'recipient' => '수신자 사용자 객체 (uuid/name/email — recipientUser 관계 파생)', + 'sender' => '발신자 사용자 객체 (uuid/name/email — senderUser 관계 파생)', + 'author' => '작성자 사용자 객체 (uuid/name — author 관계 파생)', + 'actor_name' => '행위를 수행한 주체(사용자/시스템)의 이름', + + // 시스템/집계 공통 (DashboardService/UserRepository/SettingsService) + 'total' => '전체 개수 (집계)', + 'total_users' => '전체 사용자 수 (통계 객체는 count/추이 포함)', + 'time' => '상대 시각 표시 (예: "24초 전" — diffForHumans() 산물)', + 'server_time' => '서버 현재 시각 (Y-m-d H:i:s)', + 'number' => '목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생)', + + // 로케일 (TemplateResource/LocaleController) + 'locales' => '활성 로케일 코드 배열', + 'locale_names' => '로케일 코드별 표시명 맵 (config app.locale_names)', + + // 확장/버전 공통 (Module/Plugin/Template/LanguagePack Resource, 확장 Manager) + 'extension_type' => '이 리소스를 소유한 확장의 타입 (core/module/plugin/template)', + 'extension_identifier' => '이 리소스를 소유한 확장의 식별자', + 'extension_name' => '이 리소스를 소유한 확장의 표시 이름 (manifest name)', + 'github_url' => 'GitHub 저장소 URL (manifest 파생)', + 'github_changelog_url' => 'GitHub 변경 이력(CHANGELOG) URL (manifest 파생)', + 'changelog' => '변경 이력 텍스트 (원격/파일 CHANGELOG 본문)', + 'current_core_version' => '현재 설치된 코어 버전', + 'installed_modules' => '설치된 모듈 집계 객체 (total/active)', + 'installed_templates' => '설치된 템플릿 집계 객체 (total/active)', + 'active_plugins' => '활성 플러그인 집계 객체 (total/active)', + 'bundled_identifier' => '대응하는 번들 확장 식별자 (번들 원본 매칭용)', + 'origin' => '출처 (설치/등록 원천 구분 값)', + 'target_name' => '대상 확장의 표시 이름 (scope+target_identifier 로 해석)', + 'install_blocked_reason' => '설치가 차단된 사유 (차단 없으면 null)', + + // 감사/관계 공통 (도메인 무관하게 역할 고정 — 쓰기 응답에서 흔히 등장) + 'updated_by' => '최종 수정한 사용자 정보 (uuid/name — updated_by 관계 파생, 없으면 null)', + 'creator' => '생성자 정보 객체 (uuid/name/email — creator 관계 파생)', + 'user' => '대상 사용자 정보 객체 (uuid/name/email 등 — user 관계 파생)', + 'roles' => '보유 역할 목록 (각 원소 id/name/permissions — roles 관계 파생)', + 'permissions' => '권한 목록 (각 원소 identifier/name — permissions 관계 파생)', + 'avatar' => '아바타 이미지 URL (미등록 시 null)', + + // 인증/토큰 (로그인/토큰 발급 응답) + 'token' => '발급된 API 접근 토큰 평문 (Bearer 토큰으로 사용, 발급 시 1회만 노출)', + 'token_type' => '토큰 타입 (일반적으로 Bearer)', + 'trigger_type' => '동작을 유발한 방식/주체 구분 값', + + // 스케줄/실행 이력 (Schedule 실행 결과) + 'last_duration' => '마지막 실행 소요 시간 (초/밀리초 — 실행 이력 파생)', + 'duration' => '실행 소요 시간 (초/밀리초)', + 'exit_code' => '실행 종료 코드 (0=성공, 그 외=실패)', + 'output' => '실행 표준 출력 내용', + 'error_output' => '실행 표준 에러 출력 내용 (없으면 빈 문자열/null)', + 'memory_usage' => '실행 중 최대 메모리 사용량 (바이트)', + + // 캐시/파일/집계 (일회성 동작 응답) + 'ttl' => '캐시 유효 시간 (Time To Live, 초)', + 'size_bytes' => '크기 (바이트)', + 'older_than_days' => '기준 경과 일수 (이 일수보다 오래된 대상 필터/집계)', + 'entries' => '항목 목록 (각 원소는 대상 도메인 레코드)', + 'changelog_entries' => '변경 이력 항목 목록 (버전별 변경 내용)', + 'templates' => '템플릿 목록 (각 원소 identifier/name 등 — 템플릿 관계 파생)', + 'spec' => '스펙 정의 객체 (편집기/컴포넌트 선언 스키마 등)', + 'source_meta' => '원천 메타데이터 객체 (출처·경로·해석 정보)', + 'validation_summary' => '검증 결과 요약 객체 (통과/실패 건수 등)', + ]; + + /** + * 필드명에 대한 설명을 반환합니다. 없으면 null (호출자가 컬럼 주석/TODO 로 폴백). + * + * @param string $field 필드명 + * @param string $type 실측 타입 (boolean/integer/string/object/array...) + * @return string|null 설명 (없으면 null) + */ + public function describe(string $field, string $type = ''): ?string + { + // sort_order 는 응답에서 표시 정렬 순서 값(정수 컬럼)으로 고정된다. + // 문자열이면 정렬 방향일 수 있어 도메인 특이 → TODO 유지. + // (ParameterDescriber 의 sort_order 타입 분기와 동일 계약) + if ($field === 'sort_order') { + return in_array($type, ['integer', 'number'], true) + ? '표시 정렬 순서 값 (작을수록 우선)' + : null; + } + + // status 는 도메인마다 허용값 집합이 다르나(active/blocked, success/failed 등) 역할은 + // 공통이다. 문자열 상태값이면 일반 설명을 주고, 구체 허용값은 status_label/변형 필드와 + // 각 도메인 문서에서 보강한다. (동반 필드 status_label/status_variant 는 EXACT 로 설명됨) + if ($field === 'status' && in_array($type, ['string', 'integer'], true)) { + return '상태 값 (도메인별 상태 집합 — 사람이 읽는 라벨은 status_label, UI 변형은 status_variant 참조)'; + } + + if (isset(self::EXACT[$field])) { + return self::EXACT[$field]; + } + + return $this->byPattern($field, $type); + } + + /** + * 일관된 파생 규칙(접미/접두 패턴)으로 설명을 유추합니다. + * + * @param string $field 필드명 + * @param string $type 실측 타입 + * @return string|null 설명 (패턴 미매칭 시 null) + */ + private function byPattern(string $field, string $type): ?string + { + // *_at: 타임스탬프 (UI 포맷 문자열 또는 ISO) + if (Str::endsWith($field, '_at')) { + $base = $this->humanize(Str::beforeLast($field, '_at')); + + return "{$base} 일시"; + } + + // *_formatted: 원본 값을 사람이 읽는 문자열로 포맷한 표시용 파생 필드 + // (근거: size_formatted accessor, formatCurrencyPrice/formatFileSize/ + // formatCreatedAtFormat — 통화·용량·일시 등을 로케일/단위 포맷). 도메인 + // 무관하게 "`base` 값의 표시용 포맷 문자열" 로 의미가 고정된다. + if (Str::endsWith($field, '_formatted')) { + $base = Str::beforeLast($field, '_formatted'); + + return "`{$base}` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷)"; + } + + // *_label: 원본 값의 현지화 라벨 (Enum label() 또는 번역) + if (Str::endsWith($field, '_label')) { + $base = Str::beforeLast($field, '_label'); + + return "`{$base}` 값의 사람이 읽는 라벨 (현지화/Enum 파생)"; + } + + // *_variant: UI 배지 색상/스타일 변형 키 + if (Str::endsWith($field, '_variant')) { + $base = Str::beforeLast($field, '_variant'); + + return "`{$base}` 값의 표시 변형 키 (UI 배지 색상/스타일)"; + } + + // can_*: abilities 맵 내부 능력 불리언 + if (Str::startsWith($field, 'can_')) { + $action = $this->humanize(Str::after($field, 'can_')); + + return "{$action} 수행 가능 여부 (권한 기반)"; + } + + // is_*/has_*: 불리언 상태 플래그 + if ((Str::startsWith($field, 'is_') || Str::startsWith($field, 'has_')) && $type === 'boolean') { + $base = $this->humanize(Str::after($field, '_')); + + return "{$base} 여부"; + } + + // *_count: 집계 개수 + if (Str::endsWith($field, '_count') && in_array($type, ['integer', 'number'], true)) { + $base = $this->humanize(Str::beforeLast($field, '_count')); + + return "{$base} 개수 (집계)"; + } + + // *_raw: 다국어/현지화 이전 원본 값 (getValue 로 로케일 미해석 원본 반환) + // 예: name_raw = name 의 원본, description_raw = description 의 원본 + if (Str::endsWith($field, '_raw')) { + $base = Str::beforeLast($field, '_raw'); + + return "`{$base}` 의 원본 값 (현재 로케일 미해석 원본 JSON/문자열)"; + } + + // localized_* / *_localized: 다국어 필드를 현재 로케일로 해석한 표시용 값 + // (근거: getLocalizedName() / getLocalizedOptionName() 등 — 다국어 JSON 을 + // 현재 로케일 문자열로 해석). 도메인 무관 파생 규칙. + if (Str::startsWith($field, 'localized_')) { + $base = $this->humanize(Str::after($field, 'localized_')); + + return "`{$base}` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석)"; + } + if (Str::endsWith($field, '_localized')) { + $base = $this->humanize(Str::beforeLast($field, '_localized')); + + return "`{$base}` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석)"; + } + + // *_url: 리소스에 접근하는 URL (썸네일/다운로드/이미지 등) + // (근거: thumbnail_url => download_url / getThumbnailUrl()). 도메인 무관. + if (Str::endsWith($field, '_url')) { + $base = $this->humanize(Str::beforeLast($field, '_url')); + + return "{$base} URL"; + } + + // *_id: 연관 리소스를 참조하는 정수/UUID 식별자 (parent_id/user_id/loggable_id 등). + // *_ids: 연관 리소스 식별자 배열. + // 예외: login_id 는 참조 식별자가 아니라 로그인 계정 아이디(문자열)이므로 제외 + // (도메인 특이 → TODO 유지). + if (Str::endsWith($field, '_ids')) { + $base = $this->humanize(Str::beforeLast($field, '_ids')); + + return "{$base} 식별자 배열 (연관 리소스 참조)"; + } + if (Str::endsWith($field, '_id') && ! Str::endsWith($field, 'login_id')) { + $base = $this->humanize(Str::beforeLast($field, '_id')); + + return "{$base} 식별자 (연관 리소스 참조)"; + } + + // depth: 계층 트리에서의 깊이 (0 = 최상위). children/parent 트리 파생 필드로 + // 도메인 무관하게 의미가 고정된다 (근거: CommentResource/CategoryResource depth). + if ($field === 'depth' && in_array($type, ['integer', 'number'], true)) { + return '계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가)'; + } + + return null; + } + + /** + * snake_case 를 사람이 읽는 문구로 변환합니다. + * + * @param string $token snake_case 토큰 + * @return string 공백 구분 문구 + */ + private function humanize(string $token): string + { + return str_replace('_', ' ', $token); + } +} diff --git a/app/Support/ApiDoc/ResponseSchemaInferrer.php b/app/Support/ApiDoc/ResponseSchemaInferrer.php new file mode 100644 index 00000000..adb406aa --- /dev/null +++ b/app/Support/ApiDoc/ResponseSchemaInferrer.php @@ -0,0 +1,220 @@ + $body 실측 응답 body (envelope) + * @return array{envelope: array, shape: string, fields: array>, pagination: bool} + */ + public function infer(array $body): array + { + $envelope = array_keys($body); + $data = $body['data'] ?? null; + + // 목록 응답: data.data 가 배열 (BaseApiCollection) + if (is_array($data) && isset($data['data']) && is_array($data['data'])) { + $rows = array_values(array_filter($data['data'], 'is_array')); + + return [ + 'envelope' => $envelope, + 'shape' => 'collection', + 'fields' => $this->fieldsFromRows($rows), + 'pagination' => isset($data['pagination']), + ]; + } + + // 단건 응답: data 가 연관 배열 + if (is_array($data) && $this->isAssoc($data)) { + return [ + 'envelope' => $envelope, + 'shape' => 'object', + 'fields' => $this->fieldsFromRow($data), + 'pagination' => false, + ]; + } + + // data 가 순수 배열(목록만) + if (is_array($data) && ! $this->isAssoc($data) && isset($data[0]) && is_array($data[0])) { + $rows = array_values(array_filter($data, 'is_array')); + + return [ + 'envelope' => $envelope, + 'shape' => 'array', + 'fields' => $this->fieldsFromRows($rows), + 'pagination' => false, + ]; + } + + return [ + 'envelope' => $envelope, + 'shape' => 'scalar', + 'fields' => [], + 'pagination' => false, + ]; + } + + /** + * 한 행(row)에서 필드별 타입·샘플값을 추출합니다. + * + * @param array $row 응답 데이터 한 행 + * @return array> 필드 메타데이터 목록 + */ + private function fieldsFromRow(array $row): array + { + $fields = []; + + foreach ($row as $key => $value) { + $fields[] = [ + 'name' => (string) $key, + 'type' => $this->typeOf($value), + 'sample' => $this->sampleOf($value), + ]; + } + + return $fields; + } + + /** + * 여러 행을 병합해 필드별로 non-null 대표 샘플과 실제 타입을 선택합니다. + * + * 첫 행이 우연히 비어있어(null) "항상 null" 처럼 보이는 문제를 방지하기 위해, + * 각 필드에서 값이 채워진 행을 우선 채택합니다. + * + * @param array> $rows 응답 데이터 행 목록 + * @return array> 필드 메타데이터 목록 + */ + private function fieldsFromRows(array $rows): array + { + if ($rows === []) { + return []; + } + + // 첫 행의 키 순서를 기준으로 필드 목록 확정 + $keys = array_keys($rows[0]); + $fields = []; + + foreach ($keys as $key) { + $chosenValue = null; + $chosenType = 'null'; + + foreach ($rows as $row) { + if (! array_key_exists($key, $row)) { + continue; + } + + $value = $row[$key]; + $type = $this->typeOf($value); + + // 값이 채워진(non-null) 첫 행을 대표로 채택하고 탐색 종료 + if ($type !== 'null') { + $chosenValue = $value; + $chosenType = $type; + break; + } + + // 아직 non-null 을 못 찾았으면 null 이라도 후보로 유지 + $chosenValue = $value; + } + + $fields[] = [ + 'name' => (string) $key, + 'type' => $chosenType, + 'sample' => $this->sampleOf($chosenValue), + ]; + } + + return $fields; + } + + /** + * 값의 JSON 타입을 판별합니다. + * + * @param mixed $value 값 + * @return string 타입 문자열 + */ + private function typeOf(mixed $value): string + { + return match (true) { + is_bool($value) => 'boolean', + is_int($value) => 'integer', + is_float($value) => 'number', + is_string($value) => 'string', + is_null($value) => 'null', + is_array($value) && $this->isAssoc($value) => 'object', + is_array($value) => 'array', + default => 'mixed', + }; + } + + /** + * 값의 샘플 표현을 반환합니다 (문서 표에 표시할 축약형). + * + * @param mixed $value 값 + * @return string 샘플 표현 (마크다운 표 셀 안전) + */ + private function sampleOf(mixed $value): string + { + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if (is_null($value)) { + return 'null'; + } + + if (is_array($value)) { + $encoded = json_encode($value, JSON_UNESCAPED_UNICODE); + $encoded = (string) $encoded; + + if (mb_strlen($encoded) > 60) { + $encoded = mb_substr($encoded, 0, 57).'…'; + } + + return $this->escapeCell($encoded); + } + + $str = (string) $value; + + if (mb_strlen($str) > 40) { + $str = mb_substr($str, 0, 37).'…'; + } + + return $this->escapeCell($str); + } + + /** + * 마크다운 표 셀 안에서 안전하도록 파이프/개행을 이스케이프합니다. + * + * @param string $text 원본 텍스트 + * @return string 이스케이프된 텍스트 + */ + private function escapeCell(string $text): string + { + return str_replace(['|', "\n", "\r"], ['\\|', ' ', ''], $text); + } + + /** + * 배열이 연관 배열(맵)인지 판별합니다. + * + * @param array $arr 배열 + * @return bool 연관 배열 여부 + */ + private function isAssoc(array $arr): bool + { + if ($arr === []) { + return false; + } + + return array_keys($arr) !== range(0, count($arr) - 1); + } +} diff --git a/app/Support/InstallerContext.php b/app/Support/InstallerContext.php new file mode 100644 index 00000000..b3c569e2 --- /dev/null +++ b/app/Support/InstallerContext.php @@ -0,0 +1,40 @@ +runningInConsole()) { + return false; + } + + return in_array( + $_SERVER['argv'][1] ?? null, + ['migrate', 'migrate:fresh', 'migrate:refresh', 'migrate:rollback', 'migrate:reset', 'db:wipe'], + true + ); + } +} diff --git a/config/app.php b/config/app.php index a7d6823b..4885bc61 100644 --- a/config/app.php +++ b/config/app.php @@ -231,7 +231,7 @@ return [ | */ - 'version' => env('APP_VERSION', '7.0.2'), + 'version' => env('APP_VERSION', '7.0.3'), /* |-------------------------------------------------------------------------- diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index a763a0de..e6ce38e6 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -3,12 +3,13 @@ namespace Database\Factories; use App\Enums\UserStatus; +use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; /** - * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User> + * @extends Factory */ class UserFactory extends Factory { @@ -49,4 +50,37 @@ class UserFactory extends Factory 'email_verified_at' => null, ]); } + + /** + * 모든 프로필 필드가 채워진 완전한 상태를 만듭니다. + * + * API 문서 실측 시 응답 필드의 예시값이 null 이 되지 않도록, 모델 로직상 + * 유효한 값으로 nullable 프로필 컬럼을 전수 채웁니다. + * (language=지원 로케일, country=ISO alpha-2, status=UserStatus enum 등) + */ + public function complete(): static + { + return $this->state(fn (array $attributes) => [ + 'nickname' => fake()->userName(), + 'language' => 'ko', + 'timezone' => 'Asia/Seoul', + 'country' => 'KR', + 'homepage' => 'https://example.com', + 'mobile' => '010-'.fake()->numerify('####-####'), + 'phone' => '02-'.fake()->numerify('###-####'), + 'zipcode' => fake()->numerify('#####'), + 'address' => fake()->address(), + 'address_detail' => fake()->numerify('##동 ###호'), + 'signature' => fake()->sentence(), + 'bio' => fake()->paragraph(), + // avatar 는 users 테이블 컬럼이 아니라 avatarAttachment 관계에서 파생되는 + // accessor(getAvatarUrl) 이므로 factory 에서 직접 세팅하지 않는다. + 'admin_memo' => fake()->sentence(), + 'ip_address' => fake()->ipv4(), + 'last_login_at' => now()->subDays(1), + 'identity_verified_at' => now()->subDays(5), + 'mobile_verified_at' => now()->subDays(5), + 'failed_login_attempts' => 0, + ]); + } } diff --git a/docs/README.md b/docs/README.md index cda43fab..28674e30 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,7 +9,7 @@ | 카테고리 | 문서 수 | 링크 상태 | |----------|---------|----------| -| [백엔드](backend/) | 32개 | 정상 | +| [백엔드](backend/) | 33개 | 정상 | | [프론트엔드](frontend/) | 51개 | 정상 | | [확장 시스템](extension/) | 31개 | 정상 | | 공통 | 20개 | 정상 | @@ -19,6 +19,18 @@ --- +## API 레퍼런스 + +G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답 필드·요청/응답 예시입니다. +템플릿의 `data_sources` 를 작성하거나 외부에서 G7 을 연동할 때 참고하세요. + +| 문서 | 설명 | +|------|------| +| [backend/api/README.md](backend/api/README.md) | **API 레퍼런스 진입점** — 공통 규약(인증·응답 봉투·페이지네이션·에러) + 코어/확장 전체 목차 | +| [backend/api-documentation.md](backend/api-documentation.md) | API 문서 작성·갱신 규정 (기여자용) | + +--- + ## 작업 유형별 필수 문서 ### 레이아웃 JSON 작성 @@ -113,13 +125,14 @@ ## 카테고리별 전체 문서 목록 -### 백엔드 (32개) +### 백엔드 (33개) | 문서 | 제목 | |------|------| | [activity-log-hooks.md](backend/activity-log-hooks.md) | 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference) | | [activity-log.md](backend/activity-log.md) | 활동 로그 시스템 (Activity Log System) | | [admin-settings-access.md](backend/admin-settings-access.md) | Admin 환경설정 값 접근 (`g7_core_settings` vs `config()`) | +| [api-documentation.md](backend/api-documentation.md) | API 레퍼런스 문서 규정 (API Documentation) | | [api-resources.md](backend/api-resources.md) | API 리소스 | | [authentication.md](backend/authentication.md) | 인증 및 세션 처리 | | [broadcasting.md](backend/broadcasting.md) | Broadcasting (실시간 이벤트) | diff --git a/docs/backend/README.md b/docs/backend/README.md index 88a60b81..f9b5a83a 100644 --- a/docs/backend/README.md +++ b/docs/backend/README.md @@ -15,6 +15,16 @@ --- +## API 레퍼런스 + +엔드포인트별 요청 파라미터·응답 필드·요청/응답 예시는 [api/README.md](api/README.md) 에 있습니다. +공통 규약(Bearer 토큰 인증, 응답 봉투, 페이지네이션, 401/403/422/428 에러)도 그 문서 상단에 정리되어 있습니다. +확장(모듈·플러그인)이 소유한 API 문서 목록도 같은 문서에서 찾을 수 있습니다. + +문서 작성·갱신 규정은 [api-documentation.md](api-documentation.md) 를 참고하세요. + +--- + ## 문서 목록 @@ -23,6 +33,7 @@ | [activity-log-hooks.md](activity-log-hooks.md) | 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference) | 코어 66훅 + 이커머스 92훅 + 게시판 32훅 + 페이지 8훅 = 총 198훅 | | [activity-log.md](activity-log.md) | 활동 로그 시스템 (Activity Log System) | Monolog 기반: Service 훅 → Listener → Log::channel... | | [admin-settings-access.md](admin-settings-access.md) | Admin 환경설정 값 접근 (`g7_core_settings` vs `config()`) | 동기화 SSoT: storage/app/settings/*.json → Setting... | +| [api-documentation.md](api-documentation.md) | API 레퍼런스 문서 규정 (API Documentation) | 모든 API 엔드포인트는 레퍼런스 문서 필수 — 메서드/URI/파라미터/응답 필드 +... | | [api-resources.md](api-resources.md) | API 리소스 | Resource: BaseApiResource 상속 필수 / Collection: B... | | [authentication.md](authentication.md) | 인증 및 세션 처리 | Laravel Sanctum 토큰 전용 인증 (Bearer 토큰만 사용) | | [broadcasting.md](broadcasting.md) | Broadcasting (실시간 이벤트) | Laravel Reverb 사용 (WebSocket) | diff --git a/docs/backend/api-documentation.md b/docs/backend/api-documentation.md new file mode 100644 index 00000000..bf3752f5 --- /dev/null +++ b/docs/backend/api-documentation.md @@ -0,0 +1,370 @@ +# API 레퍼런스 문서 규정 (API Documentation) + +> **관련 문서**: [routing.md](routing.md) | [api-resources.md](api-resources.md) | [response-helper.md](response-helper.md) | [validation.md](validation.md) + +--- + +## TL;DR (5초 요약) + +```text +1. 모든 API 엔드포인트는 레퍼런스 문서 필수 — 메서드/URI/파라미터/응답 필드 + 요청·응답 예시 전수 기재 +2. 위치: 코어 = docs/backend/api/, 확장 = {modules|plugins}/_bundled/{id}/docs/api/ +3. 생성: php artisan api:docgen — 코드에서 추출한 스캐폴딩 + 사람이 서술 보강 (순수 수기 금지) +4. 추출 불가분(훅 주입 파라미터·동적 응답)은 마커 남기고 사람이 채움 +5. Swagger/OpenAPI 도구 미사용 — 마크다운 레퍼런스 전용 +``` + +--- + +## 목차 + +1. [왜 이 규정이 필요한가](#왜-이-규정이-필요한가) +2. [문서 위치 규칙](#문서-위치-규칙) +3. [표준 문서 포맷](#표준-문서-포맷) +4. [생성 커맨드 api:docgen](#생성-커맨드-apidocgen) +5. [문서 갱신 의무](#문서-갱신-의무) +6. [체크리스트](#체크리스트) + +--- + +## 왜 이 규정이 필요한가 + +G7 의 REST API 는 라우트 `->name()` 규약은 있으나 엔드포인트별 공개 레퍼런스가 부재했다. 프론트엔드 +(레이아웃 JSON `data_sources`)와 외부 통합 개발자가 소비하는 요청/응답 계약이 코드에만 존재해, 변경 시 +소비처가 침묵 속에서 깨진다(이슈 #64 의 `data_source` `auth_required` 계약 변화 사고가 계기). + +문서는 **코드에서 추출한 스캐폴딩 + 사람이 채운 서술의 하이브리드**로 유지한다. 674개 규모에서 완전 수기 +문서는 반드시 drift 하고, 완전 자동 추출은 훅 주입 파라미터·동적 응답을 못 잡으므로 둘 다 단독으로는 +불충분하다. + +--- + +## 문서 위치 규칙 + +| 대상 | 문서 위치 | 예시 | +|------|----------|------| +| 코어 | `docs/backend/api/{도메인}.md` | `docs/backend/api/users.md` | +| 모듈 | `modules/_bundled/{id}/docs/api/{도메인}.md` | `modules/_bundled/sirsoft-ecommerce/docs/api/products.md` | +| 플러그인 | `plugins/_bundled/{id}/docs/api/{도메인}.md` | `plugins/_bundled/sirsoft-gdpr/docs/api/consents.md` | + +확장 API 문서는 **확장이 소유**한다(코어에 모으지 않음). 확장을 배포/삭제하면 그 API 문서도 함께 이동한다. + +도메인 그룹핑은 URI/라우트명 prefix 기준(`api.admin.users.*` → `users.md`)으로 커맨드가 자동 분류한다. + +### 문서 목차와 발견성 (README.md 규약) + +각 대상(코어·확장)의 API 문서 디렉토리에는 `README.md` 목차가 있어야 한다. `api:docgen` 이 도메인 파일 +목록·엔드포인트 수를 담아 자동 생성한다(`@generated` 블록, 재생성 멱등, 블록 밖 사람 서술은 보존). + +- 코어: `docs/backend/api/README.md` +- 확장: `{modules|plugins}/_bundled/{id}/docs/api/README.md` + +코어 README 는 프로젝트 최상위 `README.md` 의 "API 레퍼런스" 진입점이다. 따라서 확장 목차와 달리 세 +부분으로 구성된다. 이 문서(작성 규정)와 혼동되지 않도록 최상위 `README.md` 는 둘을 "API 레퍼런스" 와 +"API 문서 작성 규정" 으로 분리해 링크한다. + +| 구성 | 위치 | 소유 | +| --- | --- | --- | +| 공통 규약 개요 (인증·응답 봉투·페이지네이션·에러) | 헤더 인용 블록 뒤 ~ 첫 `@generated` 앞 | 사람 (재생성 시 원문 보존) | +| 코어 도메인 목차 | `@generated:start:api-readme-index` | `api:docgen` | +| 확장 API 목차 | `@generated:start:api-readme-extensions` | `api:docgen` (코어 README 전용) | + +개요를 첫 생성 블록 앞에 두는 이유는 목차 표보다 먼저 읽혀야 하기 때문이다. 확장 README 에는 확장 목차 +블록을 넣지 않는다. `--scope` 를 좁혀 실행해도 이번 회차에 갱신하지 않는 블록과 사람 서술은 원문 그대로 +보존된다. + +확장 API 문서의 발견성은 이 목차를 통해 확보한다. `api:docgen` 과 코어 인덱스 생성기 +(`generate-docs-index.cjs`) 는 확장명을 하드코딩하지 않고 +`{modules,plugins}/_bundled/*/docs/api/README.md` 를 **패턴 스캔**해, 코어 README 의 "확장 API 레퍼런스" +표와 CLAUDE.md·AGENTS.md·docs-index 에 자동 편입한다(동적 로딩 원칙 — 코어는 규약과 스캔 패턴만, 확장 +이름은 파일 시스템에서 발견). 문서 수·엔드포인트 수는 각 README 의 집계 라인 +(`**문서 수**: N · **엔드포인트 수**: M`)에서 읽으므로, 이 라인 형식을 바꾸면 두 스캐너를 함께 갱신한다. + +처음 진입하는 개발자/AI 의 도달 경로: + +```text +README.md "API 레퍼런스" 또는 CLAUDE.md "API 레퍼런스 진입점" 표 + → docs/backend/api/README.md (공통 규약 + 코어 목차 + 확장 목차) + → {도메인}.md 또는 {확장}/docs/api/README.md + → 엔드포인트별 파라미터·응답·예시 +``` + +--- + +## 표준 문서 포맷 + +엔드포인트 1개당 아래 6개 구성(헤더 · 요청 파라미터 · 요청 예시 · 응답 필드 · 응답 예시 · 에러 응답)을 +따른다. `` ~ `` 사이는 `api:docgen` 이 재생성하는 추출 +블록이며, 그 바깥의 사람 서술(`**설명**`)은 재생성 시 보존된다. + +에러 응답 표는 라우트 메타에서 대표 상태코드를 자동 추론한다: 인증 필수(`auth:sanctum`)→401, +`admin`/`permission:` 요구→403, FormRequest 검증 규칙 존재→422, path 파라미터 존재→404. +`optional.sanctum`(선택 인증)은 401 을 유발하지 않는다. 도메인 특이 에러(409·429 등)는 사람이 보강한다. + +**요청 예시**(raw HTTP 요청)와 **응답 예시**(envelope 전문 JSON)는 실제 호출을 재현할 수 있도록 실측 기반으로 +방출된다(단계 6). 요청 예시는 curl 이 아니라 raw HTTP 요청(요청 라인 + 헤더)으로 표기해 응답 예시의 +`HTTP/1.1 {status}` 상태줄과 대칭을 이룬다. 세부 규칙: + +- **요청 예시**: 요청 라인(`{METHOD} {path} HTTP/1.1`) + `Host:` + `Accept: application/json`, 인증 필요 시 + `Authorization: Bearer {YOUR_TOKEN}`(실측 토큰 평문 유출 방지 마스킹). `Host` 는 실측 기준 URL(로컬 개발 + 호스트 등)을 노출하지 않고 공개 placeholder(`api.example.com`, RFC 2606 예약 도메인)로 마스킹한다. + - **바디 메서드(POST/PUT/PATCH)**: `Content-Type: application/json` + 빈 줄 뒤 JSON 바디. 바디는 필수만이 + 아니라 **전체 파라미터**를 담고 값은 이름·타입 기반 현실적 예시값으로 채운다(`"string"` placeholder 남발 + 금지). 허용값 `in:` 열거가 있으면 그 첫 값을 채택한다. + - **파일 업로드**(요청 파라미터 타입이 `image`/`file`): `application/json` 이 아니라 + `Content-Type: multipart/form-data; boundary=...` 로 표기하고 각 파일 파트를 `filename=`+`Content-Type` + 으로 나타낸다(JSON 으로는 파일 전송 불가). + - **GET/DELETE**: query 파라미터는 URL 쿼리스트링(`?a=..&b=..`)으로 반영한다(바디 아님). + - `optional.sanctum`(선택 인증) 엔드포인트는 Authorization 헤더에 "비회원은 생략 가능" 주석을 붙인다. +- **응답 예시**: `HTTP/1.1 {status}` 상태줄 + 실측 응답 body 전문(`{success, data, message, error}` envelope). + 목록 응답의 `data.data[]` 는 대표 **2항목**으로 절단하고 나머지는 `... (총 N건 중 2건 표시)` 항목으로 대체한다. + 응답에 섞여 나온 **민감값(토큰·비밀번호·시크릿·API 키)은 `{MASKED}` 로 마스킹**해 방출한다. +- **쓰기 메서드 실측**: GET/HEAD 는 외부 HTTP 로 read-only 실측하고, 쓰기(POST/PUT/PATCH/DELETE)는 **DB 트랜잭션 + 안에서 in-process dispatch 후 롤백**하여 응답 shape 만 관측한다(부수효과 미영속). 단, 부수효과가 롤백으로 + 되돌릴 수 없는 쓰기(확장 install/activate/update, 언어팩 설치, 코어 업데이트, 파일 업로드, 캐시/워밍업/생성 + 등 파일시스템·프로세스·외부 네트워크 접촉)는 실측에서 제외(`side-effectful-write`)하고 요청 예시만 정적 방출한다. +- **실측 제외**(부수효과 쓰기·바이너리·미치환 path 파라미터 등) 엔드포인트: 요청 예시는 파라미터 표 기반으로 정적 + 조립(raw HTTP 요청 골격), 응답 예시는 `` 마커로 남긴다. +- 두 예시 블록은 전량 `@generated` 블록 **내부**에 있다. 다만 이미 파라미터/응답 필드 표의 사람 서술이 + 채워진 문서에 예시를 추가할 때는 전체 재생성(`api:docgen`)을 쓰지 않는다 — 전체 재생성은 표를 통째로 + 다시 조립해 정적 추출로 재현 불가능한 도메인 서술 셀을 TODO 로 되돌린다. 대신 `api:docgen --examples-only` + 로 표·서술을 건드리지 않고 예시 2블록만 in-place 삽입/치환한다(측정은 재수행하되 표는 불가침). 백필 + 커맨드(`backfill-params`/`backfill-fields`)도 예시 블록을 건드리지 않는다. +- **`--examples-only` 모드**: 기존 문서의 각 엔드포인트 `@generated` 블록에 요청 예시(응답 필드 표 앞)와 + 응답 예시(에러 응답 표 앞)만 삽입한다. 이미 예시가 있으면 그 블록만 새 실측으로 치환(멱등, 실측 상대시간 + 필드는 재측정으로 값이 달라질 수 있음). 표·필드 설명·엔드포인트 서술은 전량 보존된다. 최초 스캐폴딩이 + 없는 신규 문서는 먼저 `api:docgen` 으로 생성한 뒤 서술을 채우고, 이후 예시는 `--examples-only` 로 유지한다. + +```markdown +### GET /api/admin/users + +- **라우트명**: `api.admin.users.index` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\UserController@index` +- **인증/권한**: `auth:sanctum` + `admin` + `permission:admin,core.users.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +|------|------|------|------|--------|------| +| keyword | query | string | 아니오 | — | | +| status | query | string | 아니오 | `active`, `dormant`, `withdrawn` | | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있음 (`core.user.search_validation_rules`). + +**요청 예시** + +​```http +GET /api/admin/users HTTP/1.1 +Host: example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +​``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +|------|------|-------------|-----------| +| id | integer | `1` | | +| uuid | string | `a231747f-...` | | + +**응답 예시** + +​```http +HTTP/1.1 200 +​``` + +​```json +{ + "success": true, + "data": { + "data": [ + { "id": 1, "uuid": "a231747f-..." }, + { "id": 2, "uuid": "b0f2..." }, + "... (총 42건 중 2건 표시)" + ], + "pagination": { "current_page": 1, "total": 42 } + }, + "message": null, + "error": null +} +​``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`admin,core.users.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + +**설명** +``` + +### 응답 envelope 표준 + +모든 응답은 `ResponseHelper` 로 `{success, data, message, error}` 로 래핑된다(response-helper.md). +문서의 "응답 필드" 표는 이 envelope 의 `data` 내부 필드를 기재한다. + +- 목록 응답 pagination: `BaseApiCollection::paginationMeta()` → + `{current_page, last_page, per_page, total, from, to, has_more_pages}` +- 권한 메타: `BaseApiResource::resourceMeta()` → `is_owner` + `abilities.can_*` + +### 파라미터 위치 판정 + +| 위치 | 판정 근거 | +|------|----------| +| `path` | URI 의 `{param}` 세그먼트 | +| `query` | GET/DELETE 요청의 FormRequest rule | +| `body` | POST/PUT/PATCH 요청의 FormRequest rule | + +허용값은 FormRequest rule 의 `in:`, `max:`, `min:`, `Rule::in(...)`, `boolean`, `date` 등에서 유추한다. + +--- + +## 생성 커맨드 api:docgen + +```bash +# 코어 스캐폴딩 생성 (docs/backend/api/*.md) +php artisan api:docgen --scope=core + +# 특정 확장 스캐폴딩 생성 +php artisan api:docgen --scope=module:sirsoft-ecommerce +php artisan api:docgen --scope=plugin:sirsoft-gdpr + +# 전체 +php artisan api:docgen --scope=all + +# 생성 없이 누락/drift 만 리포트 (하네스가 소비) +php artisan api:docgen --check + +# 이미 서술이 채워진 문서에 요청/응답 예시 블록만 in-place 삽입 (표·서술 불가침, 재생성 금지) +php artisan api:docgen --scope=module:sirsoft-board --seed --base-url=https://example.dev --examples-only + +# 생성될 대상만 미리보기 +php artisan api:docgen --scope=core --dry-run +``` + +동작 (실측 기반): + +1. `route:list --json` 으로 API 라우트 전수 수집 (method·uri·name·middleware·action). +2. name prefix 로 소유 확장 판별 (`api.modules.{id}.*` / `api.plugins.{id}.*` / 그 외 코어) → 출력 파일 라우팅. +3. 컨트롤러 메서드의 FormRequest 타입힌트 → `rules()` 리플렉션 → 요청 파라미터 표 (타입·필수·허용값). +4. **실측**: 임시 Sanctum 토큰 발급 → 실제 요청 파라미터로 엔드포인트 호출 → **실제 응답 JSON** 관측. + - GET/HEAD: 실호출(read-only). 목록이 비면 최소 시드 데이터 자동 생성 후 재호출. + - 쓰기(POST/PUT/PATCH/DELETE): DB 트랜잭션 내 실행 후 롤백(응답 shape 만 관측, 영속 안 함). + - 외부 부수효과(결제 PG·외부 인증 콜백·메일)가 있는 라우트: allowlist 로 실호출 제외 → 정적+예시 대체. +5. 실제 응답 JSON 의 키·타입·샘플값 → 응답 필드 표 + 응답 예시. `@generated` 블록만 갱신, 사람 서술 보존. +6. 실측 후 임시 토큰·시드 데이터 정리. + +한계 / 보강: + +- FormRequest 가 `HookManager::applyFilters` 로 규칙을 주입하는 경우(163개) 정적 리플렉션은 훅 주입분을 + 못 읽는다 → 커맨드가 훅 필터 존재 시 주석을 남기고 사람이 보강. 단 **응답 필드는 실측이므로 훅으로 + 병합된 응답 필드까지 실제로 포착**된다. +- `route:list`(=`RouteFacade::getRoutes()`)는 활성 확장만 노출한다. 명시 범위(`module:{id}`/`plugin:{id}`)로 + 지정한 확장이 비활성/미설치여서 등록 라우트가 0건이면, 인벤토리가 그 확장의 번들 라우트 파일 + (`{modules|plugins}/_bundled/{id}/src/routes/api.php`)을 프로바이더와 동일한 prefix + (`api/{modules|plugins}/{id}`)·name(`api.{modules|plugins}.{id}.`)·`api` 미들웨어 규약으로 로드해 + **정적 폴백 수집**한다. 이때 실측(HTTP 호출)은 불가하므로 응답 필드는 `` + 정적 + 추정으로 대체되며, 설치 후 `--seed` 실측으로 채운다. 폴백은 `api/` 로 시작하는 라우트만 대상이므로 + web(admin) 라우트는 자동 제외된다. +- 실측이 불가한 라우트(외부 의존·allowlist 제외)는 `` 마커 + 정적 추정으로 대체. + +### 확장 실측 샘플 시더 (`--seed`) + +`--seed` 는 상세 GET 실측 시 응답 필드가 null 로 관측되는 것을 줄이기 위해, 도메인 대표 엔티티에 +완전한 샘플 레코드를 멱등 시드한다. 코어 도메인은 `App\Support\ApiDoc\ApiDocSampleService` 가 담당한다. + +확장은 자신의 도메인 샘플을 **확장이 소유**한다. `App\Contracts\ApiDoc\ApiDocSampleSeeder` 를 구현한 +클래스를 규약 위치 `{확장 네임스페이스}\Support\ApiDoc\ApiDocSampleService` +(예: `Modules\Sirsoft\Page\Support\ApiDoc\ApiDocSampleService`, 파일은 `src/Support/ApiDoc/`)에 두면, +`api:docgen --scope=module:{id} --seed` 실행 시 커맨드가 자동으로 발견해 코어 시드 뒤에 병합한다. + +- `seed()` 반환 맵의 키는 라우트 도메인 그룹명(`pages` 등), 값은 `{model, key, value}` + (모델 FQCN·route key 이름·route key 값)이다. +- 이 맵은 상세 GET 의 path 파라미터 치환에 쓰인다. 라우트-모델 바인딩이 없는 확장 패턴 + (`show(int $id)`)도, 파라미터명이 도메인의 단수 리소스명과 일치하면(`pages/{page}`) 이 맵으로 실측된다. + `{slug}`·`{hash}`·`{versionId}` 처럼 route key 가 다른 문자열/보조 파라미터는 폴백하지 않고 실측 제외된다. +- 확장에 새 PHP 클래스를 추가했으므로 `_bundled` 작업 후 `{type}:update {id} --force` 로 활성 디렉토리에 + 반영해야 오토로드된다. + +#### 샘플은 시스템 동작을 바꾸지 않는다 + +`--seed` 로 만든 샘플 레코드는 개발 DB 에 영구 잔존한다. 조회 예시를 만들기 위한 데이터일 뿐이므로, +전역 설정을 읽는 쿼리에 걸리는 조합으로 생성해서는 안 된다. + +| ❌ 금지 | ✅ 올바른 사용 | +| --- | --- | +| `LanguagePack::create(['scope' => 'core', 'status' => 'active', ...])` | `['scope' => 'module', 'target_identifier' => '...', 'status' => 'installed']` | +| 실재하지 않는 로케일/통화/국가 코드를 샘플 값으로 사용 | 실재하는 번들 값만 사용 (`ja` 등) | + +`scope=core` + `status=active` 조합은 `LanguagePackRepository::getActiveCoreLocales()` 의 승격 쿼리와 +일치한다. 걸리면 그 locale 이 `config('app.supported_locales')` / `config('app.translatable_locales')` 로 +올라가 시스템 전역이 바뀐다. 그 아래에서 시드·저장되는 모든 다국어 데이터에 실재하지 않는 로케일 키가 +박히고, 샘플을 비활성화한 뒤에는 그 키가 `TranslatableField` 검증을 통과하지 못해 해당 엔티티의 저장이 +막힌다. 오염 시점과 증상 발현 시점이 떨어져 있어 원인 추적이 어렵다. + +같은 원리로 `is_default` / `status=active` 같은 승격·활성 플래그를 샘플에 붙이지 않는다. 샘플 레코드를 +만들기 전에 "이 조합이 전역 설정을 읽는 쿼리에 걸리는가" 를 확인한다. + +자동 차단: audit 룰 `apidoc-sample-no-global-active-language-pack` (severity: error). + +### 설명 백필 커맨드 (`api:docgen-backfill-*`) + +파라미터/응답 필드 표의 `` 셀 중 **도메인 무관 공통 필드**(페이지네이션·정렬·검색· +식별자·기간·토글·공통 파생 필드 등)는 전체 재생성 없이 in-place 로 자동 서술한다. 전체 재생성 +(`api:docgen`)은 `@generated` 블록 표를 통째로 재조립해 실측 예시값·사람 서술 셀을 되돌리므로, TODO +셀만 치환하는 별도 백필 커맨드를 쓴다(멱등 — 채워진 셀·실측 예시값 불가침). + +```bash +# 요청 파라미터 표 TODO 셀 백필 (SSoT: App\Support\ApiDoc\ParameterDescriber) +php artisan api:docgen-backfill-params + +# 응답 필드 표 TODO 셀 백필 (SSoT: App\Support\ApiDoc\ResourceFieldDescriber) +php artisan api:docgen-backfill-fields +``` + +- 도메인 종속 필드(`status`/`type`/`code`/`category`·훅 주입·조건부 필드)는 값 의미가 도메인마다 달라 + 자동 채우지 않고 TODO 로 남겨 사람이 컨트롤러/FormRequest/Resource 근거로 서술한다. +- 새 공통 필드 규칙을 Describer 사전에 추가한 뒤 백필을 재실행하면 그 필드가 전 문서에서 자동 채워진다. + +--- + +## 문서 갱신 의무 + +컨트롤러/라우트/FormRequest/Resource 를 추가·변경하면 대응 API 문서를 같은 변경 단위에서 갱신한다. + +- 트리거: `app/Http/Controllers/**`, `routes/api.php`, `app/Http/Requests/**`, `app/Http/Resources/**` + (+ 확장 대응 경로) 편집. +- 절차: 코드 변경 → `api:docgen --scope=...` 재실행 → `@generated` 블록 갱신 → 신규 TODO 서술 채움. +- 검증: `api:docgen --check` 로 drift 0 확인. audit 룰 `api-doc-coverage` 가 변경셋에 대응 문서 + 동반 여부를 검사한다. severity 는 **대상별**로 부여된다 — 문서가 완비된 대상은 `error`(문서 + 미동반 변경 차단), 진행 중 대상은 `warn`. 코어(`docs/backend/api/`)는 2026-07-08 완료로 + `error` 승격됨. 즉 코어 API 표면(`routes/api.php`·`app/Http/{Controllers,Requests,Resources}/**`)을 + 변경하면서 코어 API 문서를 함께 갱신하지 않으면 세션 종료 시 차단된다. 나머지 확장은 문서 완비 + 시 순차 승격된다(룰의 `ENFORCED_TARGETS`). + +--- + +## 체크리스트 + +```text +□ 엔드포인트가 대응 위치(코어 docs/backend/api/ 또는 확장 docs/api/)에 문서화되었는가? +□ 요청 파라미터 표에 위치/타입/필수/허용값/용도가 모두 기재되었는가? +□ 요청 예시(raw HTTP 요청) 블록이 방출되었는가? (curl 금지, 인증 필요 시 Bearer {YOUR_TOKEN} 마스킹) +□ 응답 필드 표가 envelope 의 data 내부 기준으로 작성되었는가? +□ 응답 예시(envelope 전문 JSON) 블록이 방출되었는가? (목록은 2항목 절단) +□ 훅 주입 파라미터가 있으면 주석 + 사람 보강이 되었는가? +□ TODO 마커가 모두 채워졌는가? +□ api:docgen --check 가 drift 0 인가? +``` + +--- + +## 관련 문서 + +- [routing.md](routing.md) - 라우트 네이밍/URL 규칙 (확장 URL 스킴은 `/api/modules/{module}/...`) +- [api-resources.md](api-resources.md) - 응답 필드/pagination/abilities 형태 +- [response-helper.md](response-helper.md) - 응답 envelope 표준 +- [validation.md](validation.md) - FormRequest rule → 파라미터 허용값 유추 근거 diff --git a/docs/backend/api/README.md b/docs/backend/api/README.md new file mode 100644 index 00000000..43188b71 --- /dev/null +++ b/docs/backend/api/README.md @@ -0,0 +1,172 @@ +# API 레퍼런스 문서 목차 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). +> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. + +G7 의 REST API 레퍼런스입니다. 도메인별 문서에는 엔드포인트마다 메서드·URI·인증/권한, 요청 파라미터 표, +응답 필드 표, 실제 호출로 관측한 요청·응답 예시가 실려 있습니다. 아래 공통 규약은 모든 엔드포인트에 +동일하게 적용되므로 개별 문서에서 반복하지 않습니다. + +문서 작성·갱신 규정은 [api-documentation.md](../api-documentation.md) 를 참고하세요. + +## 공통 규약 + +### 인증 + +Laravel Sanctum 의 **Bearer 토큰 전용**입니다(세션 쿠키 인증 미사용). +`POST /api/auth/login` 또는 `POST /api/auth/admin/login` 으로 토큰을 발급받아 모든 후속 요청에 실어 보냅니다. + +```http +GET /api/me HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +각 문서의 **인증/권한** 줄은 다음 네 가지 중 하나입니다. + +| 표기 | 의미 | +| --- | --- | +| `공개 (인증 불필요)` | 토큰 없이 호출 가능 | +| `optional.sanctum` | 토큰이 있으면 회원, 없으면 비회원으로 처리 (둘 다 접근 가능) | +| `auth:sanctum` | 유효한 토큰 필수 (없거나 만료 시 401) | +| `auth:sanctum + admin + permission:{키}` | 토큰 + 관리자 + 해당 권한 필요 (권한 부족 시 403) | + +### 로케일 + +응답 메시지의 언어는 ① 로그인 사용자의 `users.language` → ② `Accept-Language` 헤더 → +③ 시스템 기본값(`config('app.locale')`) 순으로 결정됩니다. 지원 언어는 `config('app.supported_locales')` 를 따릅니다. + +### 응답 봉투 + +성공·실패 모두 동일한 최상위 구조를 씁니다. 실제 페이로드는 항상 `data` 안에 들어갑니다. + +```json +{ + "success": true, + "message": "요청이 성공했습니다.", + "data": { } +} +``` + +실패 시 `success` 는 `false` 이고, 검증 오류는 `errors` 에 필드별 메시지 배열로 담깁니다. + +```json +{ + "success": false, + "message": "입력값이 올바르지 않습니다.", + "errors": { + "email": ["이메일 형식이 올바르지 않습니다."] + } +} +``` + +### 페이지네이션 + +목록 엔드포인트는 `data.data[]` 에 항목 배열을, `data.pagination` 에 페이지 정보를 담습니다. +요청은 `page` 와 `per_page` 쿼리 파라미터로 제어합니다. + +```json +{ + "pagination": { + "current_page": 1, + "last_page": 4, + "per_page": 25, + "total": 87, + "from": 1, + "to": 25, + "has_more_pages": true + } +} +``` + +일부 목록은 `data.abilities` 에 컬렉션 레벨 권한(`can_create`, `can_delete` 등)을 함께 반환합니다. +화면의 버튼 노출 여부를 이 값으로 판정하세요. + +### 공통 에러 상태코드 + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 엔드포인트가 요구하는 권한이 없는 경우 | +| 404 | Not Found | 대상 리소스가 없거나 접근 범위를 벗어난 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`errors` 에 필드별 메시지) | +| 428 | Precondition Required | 본인인증(IDV)이 선행되어야 하는 경우 | + +428 응답은 `error_code: "identity_verification_required"` 와 함께 `verification` 객체를 반환합니다. +클라이언트는 이 값으로 본인인증 화면을 띄운 뒤 원래 요청을 재시도합니다. + +```json +{ + "success": false, + "error_code": "identity_verification_required", + "message": "본인인증이 필요합니다.", + "verification": { } +} +``` + +## 코어 API 레퍼런스 + + +- **문서 수**: 35 · **엔드포인트 수**: 291 + +| 문서 | 도메인 | 엔드포인트 | +| --- | --- | --- | +| [activity-logs.md](activity-logs.md) | `activity-logs` | 3 | +| [attachment.md](attachment.md) | `attachment` | 1 | +| [attachments.md](attachments.md) | `attachments` | 4 | +| [auth.md](auth.md) | `auth` | 15 | +| [avatar.md](avatar.md) | `avatar` | 2 | +| [broadcasting.md](broadcasting.md) | `broadcasting` | 1 | +| [changelog.md](changelog.md) | `changelog` | 1 | +| [core-update.md](core-update.md) | `core-update` | 2 | +| [dashboard.md](dashboard.md) | `dashboard` | 5 | +| [extensions.md](extensions.md) | `extensions` | 3 | +| [identity.md](identity.md) | `identity` | 27 | +| [language-packs.md](language-packs.md) | `language-packs` | 15 | +| [layouts.md](layouts.md) | `layouts` | 2 | +| [license.md](license.md) | `license` | 1 | +| [locales.md](locales.md) | `locales` | 1 | +| [me.md](me.md) | `me` | 3 | +| [menus.md](menus.md) | `menus` | 10 | +| [modules.md](modules.md) | `modules` | 25 | +| [notification-channels.md](notification-channels.md) | `notification-channels` | 1 | +| [notification-definitions.md](notification-definitions.md) | `notification-definitions` | 5 | +| [notification-logs.md](notification-logs.md) | `notification-logs` | 3 | +| [notification-templates.md](notification-templates.md) | `notification-templates` | 4 | +| [notifications.md](notifications.md) | `notifications` | 14 | +| [password.md](password.md) | `password` | 1 | +| [permissions.md](permissions.md) | `permissions` | 1 | +| [plugins.md](plugins.md) | `plugins` | 27 | +| [profile.md](profile.md) | `profile` | 4 | +| [roles.md](roles.md) | `roles` | 7 | +| [schedules.md](schedules.md) | `schedules` | 12 | +| [search.md](search.md) | `search` | 1 | +| [seo.md](seo.md) | `seo` | 5 | +| [settings.md](settings.md) | `settings` | 15 | +| [templates.md](templates.md) | `templates` | 57 | +| [users.md](users.md) | `users` | 12 | +| [verify-password.md](verify-password.md) | `verify-password` | 1 | + + + +## 확장 API 레퍼런스 + +> 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다. + + +- **확장 수**: 9 · **엔드포인트 수**: 372 + +| 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 | +| --- | --- | --- | --- | +| `gnuboard7-hello_module` | 모듈 | [docs/api/](../../../modules/_bundled/gnuboard7-hello_module/docs/api/README.md) | 1 / 2 | +| `sirsoft-board` | 모듈 | [docs/api/](../../../modules/_bundled/sirsoft-board/docs/api/README.md) | 10 / 80 | +| `sirsoft-ecommerce` | 모듈 | [docs/api/](../../../modules/_bundled/sirsoft-ecommerce/docs/api/README.md) | 33 / 231 | +| `sirsoft-page` | 모듈 | [docs/api/](../../../modules/_bundled/sirsoft-page/docs/api/README.md) | 2 / 17 | +| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 | +| `sirsoft-gdpr` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 | +| `sirsoft-marketing` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 | +| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 22 | +| `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 1 / 1 | + + diff --git a/docs/backend/api/activity-logs.md b/docs/backend/api/activity-logs.md new file mode 100644 index 00000000..4e66ebdd --- /dev/null +++ b/docs/backend/api/activity-logs.md @@ -0,0 +1,291 @@ +# Activity Logs API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Activity Logs 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/admin/activity-logs + +- **라우트명**: `api.admin.activity-logs.index` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ActivityLogController@index` +- **인증/권한**: `auth:sanctum` + `permission:core.activities.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| log_type | query | array | 아니오 | — | 로그 유형 필터 (원소별 값: admin 관리자, user 사용자, system 시스템 — ActivityLogType Enum). 배열로 다중 유형 동시 조회 가능 | +| action | query | string | 아니오 | max 100 | 액션 유형 필터 (예: created, updated, deleted, login — action 필드 부분/일치 검색 대상) | +| user_id | query | integer | 아니오 | — | user 식별자 | +| loggable_type | query | string | 아니오 | max 255 | 연관 리소스 모델 클래스명 필터 (예: App\Models\User — 특정 엔티티 유형의 로그만 조회) | +| search | query | string | 아니오 | max 255 | 검색어 (지정한 검색 대상 필드에서 부분 일치) | +| search_type | query | string | 아니오 | — | 검색 유형 (검색 대상/방식 구분) | +| created_by | query | string | 아니오 | max 36 | 로그를 생성한 행위 주체 식별자 필터 (행위자 기준 조회) | +| date_from | query | date | 아니오 | — | 조회 기간 시작일 | +| date_to | query | date | 아니오 | — | 조회 기간 종료일 | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | +| sort_by | query | string | 아니오 | — | 정렬 기준 필드명 | +| sort_order | query | string | 아니오 | — | 정렬 방향 (asc 오름차순 / desc 내림차순) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.activity_log.index_validation_rules`). + +**요청 예시** + +```http +GET /api/admin/activity-logs?log_type=%EC%98%88%EC%8B%9C%EA%B0%92&action=%EC%98%88%EC%8B%9C%EA%B0%92&user_id=1&loggable_type=%EC%98%88%EC%8B%9C%EA%B0%92&search=%EC%98%88%EC%8B%9C%EA%B0%92&search_type=%EC%98%88%EC%8B%9C%EA%B0%92&created_by=%EC%98%88%EC%8B%9C%EA%B0%92&date_from=2026-01-01&date_to=2026-01-01&per_page=1&sort_by=%EC%98%88%EC%8B%9C%EA%B0%92&sort_order=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| number | integer | `87675` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| id | integer | `210842` | 기본 키 (내부 식별자) | +| log_type | string | `user` | 로그 유형 (admin: 관리자, user: 사용자, system: 시스템) | +| log_type_label | string | `사용자` | `log_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| loggable_type | string | `Modules\Sirsoft\Board\Models\Attachment` | 로그가 연관된 대상 리소스의 모델 클래스 FQCN (loggable 다형성 관계 타입) | +| loggable_type_display | string | `Attachment` | `loggable_type` 의 표시용 짧은 이름 (네임스페이스 제외 클래스명 파생) | +| loggable_id | integer | `155` | loggable 식별자 (연관 리소스 참조) | +| action | string | `attachment.download` | 액션 유형 (created, updated, deleted, login, export 등) | +| action_label | string | `다운로드` | `action` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| localized_description | string | `첨부파일 다운로드 (게시물: 237)` | `description` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| description_key | string | `sirsoft-board::activity_log.descripti…` | 다국어 번역 키 (예: activity_log.description.user_create) | +| properties | object | `{"original_filename":"apidoc-sample.png","post_id":237,"c…` | 변경 상세 데이터 (old/new 값) | +| changes | object | `{"status":{"old":"inactive","new":"active","label":""}}` | 구조화된 변경 이력 (필드별 label_key, old, new, type) | +| bulk_changes | null | `null` | 일괄 수정 로그의 모델별 변경 이력 배열 (원소: model_id + changes[]). 단일 수정 로그이면 null이고 대신 changes 필드가 채워짐 | +| has_changes | boolean | `false` | changes 여부 | +| actor_name | string | `API 문서 샘플 사용자` | 행위를 수행한 주체(사용자/시스템)의 이름 | +| user | object | `{"uuid":"a231747f-e82e-4cf2-9ae1-a261849dce40","name":"AP…` | 행위를 수행한 사용자 정보 (uuid/name/email). 시스템 발생 로그로 사용자가 없으면 name 에 "시스템" 라벨만 담김 | +| ip_address | string | `127.0.0.1` | IP 주소 (IPv6 대응) | +| created_at | string | `2026-07-07 10:00:47` | 생성 일시 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "활동 로그 정보를 성공적으로 가져왔습니다.", + "data": { + "data": [ + { + "number": 22, + "id": 22, + "log_type": "admin", + "log_type_label": "관리자", + "loggable_type": null, + "loggable_type_display": null, + "loggable_id": null, + "action": "user.index", + "action_label": "목록 조회", + "localized_description": "사용자 목록 조회", + "description_key": "activity_log.description.user_index", + "properties": { + "result_count": 2 + }, + "changes": null, + "bulk_changes": null, + "has_changes": false, + "actor_name": "API 문서 샘플 사용자", + "user": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com" + }, + "ip_address": "127.0.0.1", + "created_at": "2026-07-08 12:12:12", + "is_owner": true, + "abilities": { + "can_read": true, + "can_delete": true + } + }, + { + "number": 21, + "id": 21, + "log_type": "admin", + "log_type_label": "관리자", + "loggable_type": null, + "loggable_type_display": null, + "loggable_id": null, + "action": "user.index", + "action_label": "목록 조회", + "localized_description": "사용자 목록 조회", + "description_key": "activity_log.description.user_index", + "properties": { + "result_count": 2 + }, + "changes": null, + "bulk_changes": null, + "has_changes": false, + "actor_name": "API 문서 샘플 사용자", + "user": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com" + }, + "ip_address": "127.0.0.1", + "created_at": "2026-07-08 12:08:26", + "is_owner": true, + "abilities": { + "can_read": true, + "can_delete": true + } + }, + "... (총 22건 중 2건 표시)" + ], + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 22, + "from": 1, + "to": 22, + "has_more_pages": false + }, + "abilities": { + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.activities.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 시스템 활동 로그를 페이지네이션 목록으로 조회합니다. `log_type`(admin/user/system), `action`, `user_id`, `loggable_type`, 기간(`date_from`/`date_to`), 키워드(`search`) 등으로 필터링하고 `sort_by`/`sort_order`로 정렬합니다. null 값 필터는 자동으로 제외됩니다. `core.activities.read` 권한이 필요하며, 각 항목에는 현지화된 액션 라벨·변경 이력(changes)·소유자/권한 메타가 포함됩니다. 확장은 `core.activity_log.index_validation_rules` 훅으로 필터 파라미터를 추가할 수 있습니다. + + +### POST /api/admin/activity-logs/bulk-delete + +- **라우트명**: `api.admin.activity-logs.bulk-destroy` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ActivityLogController@bulkDestroy` +- **인증/권한**: `auth:sanctum` + `permission:core.activities.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| ids | body | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | + +**요청 예시** + +```http +POST /api/admin/activity-logs/bulk-delete HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "ids": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.activities.delete`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 지정한 활동 로그들을 일괄 삭제합니다. `ids` 배열에 삭제할 로그 ID를 담아 요청하며, 서비스가 각 항목을 삭제하고 실제 삭제된 건수(`deleted_count`)를 반환합니다. `core.activities.delete` 권한이 필요합니다. 로그 목록에서 여러 항목을 선택해 한 번에 정리하는 시나리오에 사용합니다. + + +### DELETE /api/admin/activity-logs/{activityLog} + +- **라우트명**: `api.admin.activity-logs.destroy` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ActivityLogController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:core.activities.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| activityLog | path | string | 예 | — | 대상 activity log의 식별자 | + +**요청 예시** + +```http +DELETE /api/admin/activity-logs/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "활동 로그가 삭제되었습니다.", + "data": null +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.activities.delete`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 단일 활동 로그를 삭제합니다. 경로의 `{activityLog}`는 라우트 모델 바인딩으로 로그 ID를 받아 해당 레코드를 삭제합니다. `core.activities.delete` 권한이 필요하며, 삭제 실패 시 오류가 로그로 기록되고 500이 반환됩니다. + + diff --git a/docs/backend/api/attachment.md b/docs/backend/api/attachment.md new file mode 100644 index 00000000..ba6a2986 --- /dev/null +++ b/docs/backend/api/attachment.md @@ -0,0 +1,58 @@ +# Attachment API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Attachment 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/attachment/{hash} + +- **라우트명**: `api.attachment.download` +- **컨트롤러**: `App\Http\Controllers\Api\Public\PublicAttachmentController@download` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| hash | path | string | 예 | — | 대상 리소스의 해시 식별자 | + +**요청 예시** + +```http +GET /api/attachment/{hash} HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 해시(12자)로 식별되는 첨부파일을 다운로드합니다. 이미지 파일은 캐싱 헤더와 함께 인라인으로 표시하고 그 외 파일은 다운로드 방식으로 제공합니다. 인증이 필요 없는 공개 라우트이지만 접근 권한은 AttachmentService가 로그인/비로그인 사용자 모두를 대상으로 하이브리드 방식으로 검사하며, 파일이 없으면 404, 권한이 없으면 403을 반환합니다. 게시글 첨부·상품 이미지 등 공개 리소스를 URL로 직접 내려받는 시나리오에 사용합니다. + + diff --git a/docs/backend/api/attachments.md b/docs/backend/api/attachments.md new file mode 100644 index 00000000..279ef002 --- /dev/null +++ b/docs/backend/api/attachments.md @@ -0,0 +1,250 @@ +# Attachments API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Attachments 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### POST /api/admin/attachments + +- **라우트명**: `api.admin.attachments.upload` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\AttachmentController@upload` +- **인증/권한**: `auth:sanctum` + `permission:core.attachments.create` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| file | body | file | 예 | max 10240 | 업로드 파일 | +| attachmentable_type | body | string | 아니오 | max 255 | 첨부를 연결할 대상 모델의 다형성 타입 (attachmentable morph type, 예 User·Post 등 모델 클래스명). attachmentable_id와 짝을 이뤄 대상을 지정하며 미지정 시 미연결 상태로 저장 | +| attachmentable_id | body | integer | 아니오 | min 1 | attachmentable 식별자 | +| collection | body | string | 아니오 | max 100 | 첨부 컬렉션 그룹명 (첨부를 용도별로 묶는 키, 미지정 시 default) | +| source_type | body | string | 아니오 | — | 첨부 생성 출처 구분 (AttachmentSourceType Enum — core: 코어 시스템, module: 모듈, plugin: 플러그인). 미지정 시 core로 기본 설정 | +| source_identifier | body | string | 아니오 | max 255 | 출처 식별자 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.attachment.upload_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/attachments HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: multipart/form-data; boundary=----G7ExampleBoundary + +------G7ExampleBoundary +Content-Disposition: form-data; name="file"; filename="example.pdf" +Content-Type: application/octet-stream + +(바이너리 파일 내용) +------G7ExampleBoundary +Content-Disposition: form-data; name="attachmentable_type" + +예시값 +------G7ExampleBoundary +Content-Disposition: form-data; name="attachmentable_id" + +1 +------G7ExampleBoundary +Content-Disposition: form-data; name="collection" + +예시값 +------G7ExampleBoundary +Content-Disposition: form-data; name="source_type" + +예시값 +------G7ExampleBoundary +Content-Disposition: form-data; name="source_identifier" + +example-key +------G7ExampleBoundary-- +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.attachments.create`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 단일 파일을 업로드해 첨부파일(Attachment) 레코드로 등록합니다. `attachmentable_type`/`attachmentable_id`로 대상 모델과의 다형성 연결을, `collection`으로 그룹을 지정하며 미지정 시 각각 미연결·`default` 컬렉션으로 저장됩니다. `core.attachments.create` 권한이 필요하며, 성공 시 201과 함께 생성된 첨부파일 리소스를 반환합니다. 확장은 `core.attachment.upload_validation_rules` 훅으로 검증 규칙을 추가할 수 있고, `source_type`/`source_identifier`로 업로드 출처(코어/확장)를 식별합니다. + + +### POST /api/admin/attachments/batch + +- **라우트명**: `api.admin.attachments.upload_batch` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\AttachmentController@uploadBatch` +- **인증/권한**: `auth:sanctum` + `permission:core.attachments.create` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| files | body | array | 예 | min 1 | 업로드 파일 배열 | +| attachmentable_type | body | string | 아니오 | max 255 | 첨부를 연결할 대상 모델의 다형성 타입 (attachmentable morph type, 예 User·Post 등 모델 클래스명). attachmentable_id와 짝을 이뤄 대상을 지정하며 미지정 시 미연결 상태로 저장 | +| attachmentable_id | body | integer | 아니오 | min 1 | attachmentable 식별자 | +| collection | body | string | 아니오 | max 100 | 첨부 컬렉션 그룹명 (첨부를 용도별로 묶는 키, 미지정 시 default) | +| source_type | body | string | 아니오 | — | 첨부 생성 출처 구분 (AttachmentSourceType Enum — core: 코어 시스템, module: 모듈, plugin: 플러그인). 미지정 시 core로 기본 설정 | +| source_identifier | body | string | 아니오 | max 255 | 출처 식별자 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.attachment.upload_batch_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/attachments/batch HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "files": [ + "예시값" + ], + "attachmentable_type": "예시값", + "attachmentable_id": 1, + "collection": "예시값", + "source_type": "예시값", + "source_identifier": "example-key" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.attachments.create`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 여러 파일을 한 번의 요청으로 일괄 업로드합니다. `files` 배열의 각 파일이 개별 첨부파일 레코드로 등록되며, `attachmentable_type`/`attachmentable_id`/`collection` 등의 옵션은 배치 전체에 공통 적용됩니다. `core.attachments.create` 권한이 필요하고, 성공 시 201과 함께 생성된 첨부파일 리소스 컬렉션을 반환합니다. 갤러리·다중 이미지 첨부처럼 한 대상에 여러 파일을 붙이는 시나리오에 사용합니다. + + +### PATCH /api/admin/attachments/reorder + +- **라우트명**: `api.admin.attachments.reorder` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\AttachmentController@reorder` +- **인증/권한**: `auth:sanctum` + `permission:core.attachments.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| order | body | array | 예 | min 1 | 재정렬 대상 목록. 각 원소는 `id`(기존 첨부파일 식별자)와 `order`(새 정렬 순서값, 0 이상 정수)를 가진 객체이며, 이 매핑대로 각 첨부의 정렬 값이 갱신됨 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.attachment.reorder_validation_rules`). + +**요청 예시** + +```http +PATCH /api/admin/attachments/reorder HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "order": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.attachments.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 첨부파일의 표시 순서를 재정렬합니다. `order` 배열에 담긴 순서대로 각 첨부파일의 정렬 값이 갱신됩니다. `core.attachments.update` 권한이 필요합니다. 갤러리에서 드래그 앤 드롭으로 이미지 순서를 바꾸는 등 이미 등록된 첨부파일의 나열 순서만 변경할 때 사용합니다. + + +### DELETE /api/admin/attachments/{attachment} + +- **라우트명**: `api.admin.attachments.destroy` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\AttachmentController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:core.attachments.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| attachment | path | string | 예 | — | 대상 attachment의 식별자 | + +**요청 예시** + +```http +DELETE /api/admin/attachments/{attachment} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.attachments.delete`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 지정한 첨부파일을 삭제합니다. 경로의 `{attachment}`는 라우트 모델 바인딩으로 첨부파일 ID를 받으며, 서비스가 DB 레코드와 실제 저장 파일을 함께 제거합니다. `core.attachments.delete` 권한이 필요합니다. 존재하지 않는 ID면 404가 반환됩니다. + + diff --git a/docs/backend/api/auth.md b/docs/backend/api/auth.md new file mode 100644 index 00000000..8c2880f2 --- /dev/null +++ b/docs/backend/api/auth.md @@ -0,0 +1,3001 @@ +# Auth API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Auth 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### POST /api/admin/auth/logout + +- **라우트명**: `api.admin.auth.logout` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\AuthController@logout` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/admin/auth/logout HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "로그아웃이 성공했습니다.", + "data": null +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** + +현재 관리자의 Sanctum 토큰을 폐기해 로그아웃한다. `AuthService::logout()` 이 3단계(토큰 삭제 → 세션 무효화 → `Auth::logout()`)를 수행하며, `data` 는 없고 `message` 만 `auth.logout_success` 로 내려온다. 프론트는 응답 후 저장된 Bearer 토큰을 폐기하고 로그인 화면으로 전환한다. + + +### POST /api/admin/auth/refresh + +- **라우트명**: `api.admin.auth.refresh` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\AuthController@refresh` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/admin/auth/refresh HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| user | object | `{"uuid":"a234c2b1-cde8-437f-b28b-23323be2b98d","name":"AP…` | 대상 사용자 정보 객체 (uuid/name/email 등 — user 관계 파생) | +| token | string | `75\|WgPUplvLGTv8YIj4507uIR6dEOHTXyNUed…` | 발급된 API 접근 토큰 평문 (Bearer 토큰으로 사용, 발급 시 1회만 노출) | +| token_type | string | `Bearer` | 토큰 타입 (일반적으로 Bearer) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "user": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "nickname": "song.hyunji", + "email": "apidoc-sample-user@example.com", + "avatar": null, + "language": "ko", + "language_label": "한국어", + "country": "KR", + "status": "active", + "status_label": "활성", + "status_variant": "success", + "is_admin": true, + "homepage": "https://example.com", + "mobile": "010-9595-2897", + "phone": "02-637-5618", + "zipcode": "16505", + "address": "경기도 안양시 봉은사로 2918", + "address_detail": "48동 718호", + "signature": "Fugit consequuntur repellendus sed.", + "bio": "Ut magni et sunt ducimus error adipisci. Pariatur corporis voluptatem ratione quo non saepe. Illo atque praesentium possimus dolores qui est fugit. Sint fugiat numquam voluptates.", + "last_login_at": "2026-07-07 10:41:24", + "email_verified_at": "2026-07-08 10:41:24", + "timezone": "Asia/Seoul", + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 10:41:24", + "is_owner": true, + "abilities": { + "can_read": true, + "can_create": true, + "can_update": true, + "can_delete": true, + "can_assign_roles": true + } + }, + "token": "{MASKED}", + "token_type": "Bearer" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** + +현재 관리자 토큰을 새 Sanctum 토큰으로 교체한다. `AuthService::refreshToken()` 이 기존 토큰을 폐기하고 새 토큰을 발급하며, `data` 에는 새 `token` 과 `user`(UserResource) 가 담긴다. 만료 임박 토큰을 재발급하는 용도로, 세션 만료로 재인증이 필요한 경우(토큰 무효)에는 `401 auth.unauthenticated` 를 반환한다. + + +### GET /api/admin/auth/user + +- **라우트명**: `api.admin.auth.user` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\AuthController@user` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/auth/user HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| uuid | string | `a231747f-e82e-4cf2-9ae1-a261849dce40` | 외부 노출용 UUID (URL/API 식별자, 내부 id 비노출) | +| name | string | `API 문서 샘플 사용자` | 사용자 이름 | +| nickname | string | `gunwoo.oh` | 닉네임 | +| email | string | `apidoc-sample-user@example.com` | 이메일 주소 | +| avatar | null | `null` | 아바타 이미지 URL (User::getAvatarUrl() — 아바타 미설정 시 null) | +| language | string | `ko` | 사용자 언어 설정 (ko: 한국어, en: 영어) | +| language_label | string | `한국어` | 언어 코드의 현지화 라벨 (user.language.{code} 번역) | +| country | string | `KR` | 국가 코드 (ISO 3166-1 alpha-2) | +| status | string | `active` | 계정 상태 (active: 활성, inactive: 비활성, blocked: 차단, withdrawn: 탈퇴) | +| status_label | string | `활성` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| status_variant | string | `success` | 상태 표시 색상/스타일 변형 키 (상태 Enum variant() 산물 — UI 배지용) | +| is_admin | boolean | `true` | 관리자 역할 보유 여부 (User::isAdmin() — 역할 관계 기반 파생) | +| homepage | string | `https://example.com` | 홈페이지 URL | +| mobile | string | `010-9070-5662` | 휴대폰 번호 | +| phone | string | `02-805-4759` | 전화번호 | +| zipcode | string | `93153` | 우편번호 | +| address | string | `대구광역시 북구 백제고분로 720` | 기본 주소 | +| address_detail | string | `40동 835호` | 상세 주소 | +| signature | string | `Ipsam rem amet expedita est.` | 서명 | +| bio | string | `Tenetur omnis et amet omnis veniam to…` | 자기소개 | +| last_login_at | string | `2026-07-05 19:15:16` | last login 일시 | +| email_verified_at | string | `2026-07-06 19:15:16` | email verified 일시 | +| timezone | string | `Asia/Seoul` | 사용자 시간대 (예: Asia/Seoul, UTC) | +| roles | array | `[{"id":1,"identifier":"admin","name":"관리자"}]` | 사용자에게 부여된 역할 목록 (원소 id/identifier/name — roles 관계 파생, name 은 현지화 라벨) | +| permissions | array | `[{"id":3,"identifier":"core.users.read","name":"사용자 조회"},…` | 연결된 권한 목록 (id/identifier/name — 역할 경유 권한 관계 파생) | +| created_at | string | `2026-07-06 19:15:16` | 생성 일시 | +| updated_at | string | `2026-07-06 19:15:16` | 최종 수정 일시 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_create":true,"can_update":true,"can…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "nickname": "song.hyunji", + "email": "apidoc-sample-user@example.com", + "avatar": null, + "language": "ko", + "language_label": "한국어", + "country": "KR", + "status": "active", + "status_label": "활성", + "status_variant": "success", + "is_admin": true, + "homepage": "https://example.com", + "mobile": "010-9595-2897", + "phone": "02-637-5618", + "zipcode": "16505", + "address": "경기도 안양시 봉은사로 2918", + "address_detail": "48동 718호", + "signature": "Fugit consequuntur repellendus sed.", + "bio": "Ut magni et sunt ducimus error adipisci. Pariatur corporis voluptatem ratione quo non saepe. Illo atque praesentium possimus dolores qui est fugit. Sint fugiat numquam voluptates.", + "last_login_at": "2026-07-07 10:41:24", + "email_verified_at": "2026-07-08 10:41:24", + "timezone": "Asia/Seoul", + "roles": [ + { + "id": 1, + "identifier": "admin", + "name": "관리자" + }, + { + "id": 4, + "identifier": "apidoc-sample-role", + "name": "API 문서 샘플 역할" + } + ], + "permissions": [ + { + "id": 5, + "identifier": "sirsoft-board.apidoc-sample-board.admin.posts.read", + "name": "게시글 조회 (관리자)" + }, + { + "id": 6, + "identifier": "sirsoft-board.apidoc-sample-board.admin.posts.write", + "name": "게시글 작성/수정/삭제 (관리자)" + }, + { + "id": 7, + "identifier": "sirsoft-board.apidoc-sample-board.admin.posts.read-secret", + "name": "비밀글 조회 (관리자)" + }, + { + "id": 8, + "identifier": "sirsoft-board.apidoc-sample-board.admin.comments.read", + "name": "댓글 조회 (관리자)" + }, + { + "id": 9, + "identifier": "sirsoft-board.apidoc-sample-board.admin.comments.write", + "name": "댓글 작성/수정/삭제 (관리자)" + }, + { + "id": 10, + "identifier": "sirsoft-board.apidoc-sample-board.admin.attachments.upload", + "name": "파일 업로드/삭제 (관리자)" + }, + { + "id": 11, + "identifier": "sirsoft-board.apidoc-sample-board.admin.attachments.download", + "name": "파일 다운로드 (관리자)" + }, + { + "id": 12, + "identifier": "sirsoft-board.apidoc-sample-board.admin.manage", + "name": "게시판 관리 (관리자)" + }, + { + "id": 13, + "identifier": "sirsoft-board.apidoc-sample-board.posts.read", + "name": "게시글 조회" + }, + { + "id": 14, + "identifier": "sirsoft-board.apidoc-sample-board.posts.write", + "name": "게시글 작성/수정/삭제" + }, + { + "id": 15, + "identifier": "sirsoft-board.apidoc-sample-board.posts.read-secret", + "name": "비밀글 조회" + }, + { + "id": 16, + "identifier": "sirsoft-board.apidoc-sample-board.comments.read", + "name": "댓글 조회" + }, + { + "id": 17, + "identifier": "sirsoft-board.apidoc-sample-board.comments.write", + "name": "댓글 작성/수정/삭제" + }, + { + "id": 18, + "identifier": "sirsoft-board.apidoc-sample-board.attachments.upload", + "name": "파일 업로드/삭제" + }, + { + "id": 19, + "identifier": "sirsoft-board.apidoc-sample-board.attachments.download", + "name": "파일 다운로드" + }, + { + "id": 20, + "identifier": "sirsoft-board.apidoc-sample-board.manager", + "name": "게시판 관리 (사용자)" + }, + { + "id": 81, + "identifier": "sirsoft-ecommerce.user-products.read", + "name": "상품 조회" + }, + { + "id": 83, + "identifier": "sirsoft-ecommerce.user-orders.create", + "name": "주문하기" + }, + { + "id": 84, + "identifier": "sirsoft-ecommerce.user-orders.cancel", + "name": "주문 취소" + }, + { + "id": 85, + "identifier": "sirsoft-ecommerce.user-orders.confirm", + "name": "구매확정" + }, + { + "id": 87, + "identifier": "sirsoft-ecommerce.user-reviews.write", + "name": "리뷰 작성" + }, + { + "id": 23, + "identifier": "sirsoft-ecommerce.products.read", + "name": "상품 조회" + }, + { + "id": 24, + "identifier": "sirsoft-ecommerce.products.create", + "name": "상품 생성" + }, + { + "id": 25, + "identifier": "sirsoft-ecommerce.products.update", + "name": "상품 수정" + }, + { + "id": 26, + "identifier": "sirsoft-ecommerce.products.delete", + "name": "상품 삭제" + }, + { + "id": 28, + "identifier": "sirsoft-ecommerce.orders.read", + "name": "주문 조회" + }, + { + "id": 29, + "identifier": "sirsoft-ecommerce.orders.update", + "name": "주문 수정" + }, + { + "id": 31, + "identifier": "sirsoft-ecommerce.categories.read", + "name": "카테고리 조회" + }, + { + "id": 32, + "identifier": "sirsoft-ecommerce.categories.create", + "name": "카테고리 생성" + }, + { + "id": 33, + "identifier": "sirsoft-ecommerce.categories.update", + "name": "카테고리 수정" + }, + { + "id": 34, + "identifier": "sirsoft-ecommerce.categories.delete", + "name": "카테고리 삭제" + }, + { + "id": 36, + "identifier": "sirsoft-ecommerce.brands.read", + "name": "브랜드 조회" + }, + { + "id": 37, + "identifier": "sirsoft-ecommerce.brands.create", + "name": "브랜드 생성" + }, + { + "id": 38, + "identifier": "sirsoft-ecommerce.brands.update", + "name": "브랜드 수정" + }, + { + "id": 39, + "identifier": "sirsoft-ecommerce.brands.delete", + "name": "브랜드 삭제" + }, + { + "id": 41, + "identifier": "sirsoft-ecommerce.product-notice-templates.read", + "name": "조회" + }, + { + "id": 42, + "identifier": "sirsoft-ecommerce.product-notice-templates.create", + "name": "생성" + }, + { + "id": 43, + "identifier": "sirsoft-ecommerce.product-notice-templates.update", + "name": "수정" + }, + { + "id": 44, + "identifier": "sirsoft-ecommerce.product-notice-templates.delete", + "name": "삭제" + }, + { + "id": 46, + "identifier": "sirsoft-ecommerce.product-common-infos.read", + "name": "조회" + }, + { + "id": 47, + "identifier": "sirsoft-ecommerce.product-common-infos.create", + "name": "생성" + }, + { + "id": 48, + "identifier": "sirsoft-ecommerce.product-common-infos.update", + "name": "수정" + }, + { + "id": 49, + "identifier": "sirsoft-ecommerce.product-common-infos.delete", + "name": "삭제" + }, + { + "id": 51, + "identifier": "sirsoft-ecommerce.settings.read", + "name": "환경설정 조회" + }, + { + "id": 52, + "identifier": "sirsoft-ecommerce.settings.update", + "name": "환경설정 수정" + }, + { + "id": 54, + "identifier": "sirsoft-ecommerce.promotion-coupon.read", + "name": "쿠폰 조회" + }, + { + "id": 55, + "identifier": "sirsoft-ecommerce.promotion-coupon.create", + "name": "쿠폰 생성" + }, + { + "id": 56, + "identifier": "sirsoft-ecommerce.promotion-coupon.update", + "name": "쿠폰 수정" + }, + { + "id": 57, + "identifier": "sirsoft-ecommerce.promotion-coupon.delete", + "name": "쿠폰 삭제" + }, + { + "id": 59, + "identifier": "sirsoft-ecommerce.shipping-policies.read", + "name": "배송정책 조회" + }, + { + "id": 60, + "identifier": "sirsoft-ecommerce.shipping-policies.create", + "name": "배송정책 생성" + }, + { + "id": 61, + "identifier": "sirsoft-ecommerce.shipping-policies.update", + "name": "배송정책 수정" + }, + { + "id": 62, + "identifier": "sirsoft-ecommerce.shipping-policies.delete", + "name": "배송정책 삭제" + }, + { + "id": 64, + "identifier": "sirsoft-ecommerce.product-labels.read", + "name": "상품 라벨 조회" + }, + { + "id": 65, + "identifier": "sirsoft-ecommerce.product-labels.create", + "name": "상품 라벨 생성" + }, + { + "id": 66, + "identifier": "sirsoft-ecommerce.product-labels.update", + "name": "상품 라벨 수정" + }, + { + "id": 67, + "identifier": "sirsoft-ecommerce.product-labels.delete", + "name": "상품 라벨 삭제" + }, + { + "id": 69, + "identifier": "sirsoft-ecommerce.identity.policies.read", + "name": "본인인증 정책 조회" + }, + { + "id": 70, + "identifier": "sirsoft-ecommerce.identity.policies.update", + "name": "본인인증 정책 수정" + }, + { + "id": 72, + "identifier": "sirsoft-ecommerce.reviews.read", + "name": "리뷰 조회" + }, + { + "id": 73, + "identifier": "sirsoft-ecommerce.reviews.update", + "name": "리뷰 처리" + }, + { + "id": 74, + "identifier": "sirsoft-ecommerce.reviews.delete", + "name": "리뷰 삭제" + }, + { + "id": 76, + "identifier": "sirsoft-ecommerce.inquiries.update", + "name": "문의 처리" + }, + { + "id": 77, + "identifier": "sirsoft-ecommerce.inquiries.delete", + "name": "문의 삭제" + }, + { + "id": 79, + "identifier": "sirsoft-ecommerce.dashboard.view", + "name": "대시보드 조회" + }, + { + "id": 89, + "identifier": "sirsoft-ecommerce.mileage.read", + "name": "마일리지 내역 조회" + }, + { + "id": 90, + "identifier": "sirsoft-ecommerce.mileage.manage", + "name": "마일리지 수동 처리" + }, + { + "id": 92, + "identifier": "sirsoft-ecommerce.user-currency.manage", + "name": "회원 결제 통화 변경" + }, + { + "id": 94, + "identifier": "sirsoft-ecommerce.user-shipping-country.manage", + "name": "회원 배송국가 변경" + }, + { + "id": 96, + "identifier": "sirsoft-board.boards.read", + "name": "게시판 조회" + }, + { + "id": 97, + "identifier": "sirsoft-board.boards.create", + "name": "게시판 생성" + }, + { + "id": 98, + "identifier": "sirsoft-board.boards.update", + "name": "게시판 수정" + }, + { + "id": 99, + "identifier": "sirsoft-board.boards.delete", + "name": "게시판 삭제" + }, + { + "id": 101, + "identifier": "sirsoft-board.settings.read", + "name": "환경설정 조회" + }, + { + "id": 102, + "identifier": "sirsoft-board.settings.update", + "name": "환경설정 수정" + }, + { + "id": 104, + "identifier": "sirsoft-board.identity.policies.read", + "name": "본인인증 정책 조회" + }, + { + "id": 105, + "identifier": "sirsoft-board.identity.policies.update", + "name": "본인인증 정책 수정" + }, + { + "id": 107, + "identifier": "sirsoft-board.reports.view", + "name": "신고 조회" + }, + { + "id": 108, + "identifier": "sirsoft-board.reports.manage", + "name": "신고 처리" + }, + { + "id": 111, + "identifier": "sirsoft-page.pages.read", + "name": "페이지 조회" + }, + { + "id": 112, + "identifier": "sirsoft-page.pages.create", + "name": "페이지 생성" + }, + { + "id": 113, + "identifier": "sirsoft-page.pages.update", + "name": "페이지 수정" + }, + { + "id": 114, + "identifier": "sirsoft-page.pages.delete", + "name": "페이지 삭제" + }, + { + "id": 117, + "identifier": "core.users.read", + "name": "사용자 조회" + }, + { + "id": 118, + "identifier": "core.users.create", + "name": "사용자 생성" + }, + { + "id": 119, + "identifier": "core.users.update", + "name": "사용자 수정" + }, + { + "id": 120, + "identifier": "core.users.delete", + "name": "사용자 삭제" + }, + { + "id": 122, + "identifier": "core.menus.read", + "name": "메뉴 조회" + }, + { + "id": 123, + "identifier": "core.menus.create", + "name": "메뉴 생성" + }, + { + "id": 124, + "identifier": "core.menus.update", + "name": "메뉴 수정" + }, + { + "id": 125, + "identifier": "core.menus.delete", + "name": "메뉴 삭제" + }, + { + "id": 127, + "identifier": "core.modules.read", + "name": "모듈 조회" + }, + { + "id": 128, + "identifier": "core.modules.install", + "name": "모듈 설치" + }, + { + "id": 129, + "identifier": "core.modules.activate", + "name": "모듈 활성화" + }, + { + "id": 130, + "identifier": "core.modules.uninstall", + "name": "모듈 삭제" + }, + { + "id": 132, + "identifier": "core.plugins.read", + "name": "플러그인 조회" + }, + { + "id": 133, + "identifier": "core.plugins.install", + "name": "플러그인 설치" + }, + { + "id": 134, + "identifier": "core.plugins.activate", + "name": "플러그인 활성화" + }, + { + "id": 135, + "identifier": "core.plugins.update", + "name": "플러그인 설정" + }, + { + "id": 136, + "identifier": "core.plugins.uninstall", + "name": "플러그인 삭제" + }, + { + "id": 138, + "identifier": "core.templates.read", + "name": "템플릿 조회" + }, + { + "id": 139, + "identifier": "core.templates.install", + "name": "템플릿 설치" + }, + { + "id": 140, + "identifier": "core.templates.activate", + "name": "템플릿 활성화" + }, + { + "id": 141, + "identifier": "core.templates.uninstall", + "name": "템플릿 삭제" + }, + { + "id": 142, + "identifier": "core.templates.layouts.edit", + "name": "레이아웃 편집" + }, + { + "id": 144, + "identifier": "core.permissions.read", + "name": "권한 조회" + }, + { + "id": 145, + "identifier": "core.permissions.create", + "name": "역할 생성" + }, + { + "id": 146, + "identifier": "core.permissions.update", + "name": "역할 수정" + }, + { + "id": 147, + "identifier": "core.permissions.delete", + "name": "역할 삭제" + }, + { + "id": 149, + "identifier": "core.notification-logs.read", + "name": "발송 이력 조회" + }, + { + "id": 150, + "identifier": "core.notification-logs.delete", + "name": "발송 이력 삭제" + }, + { + "id": 152, + "identifier": "core.notifications.read", + "name": "알림 조회" + }, + { + "id": 153, + "identifier": "core.notifications.update", + "name": "알림 읽음 처리" + }, + { + "id": 154, + "identifier": "core.notifications.delete", + "name": "알림 삭제" + }, + { + "id": 156, + "identifier": "core.user-notifications.read", + "name": "알림 조회" + }, + { + "id": 157, + "identifier": "core.user-notifications.update", + "name": "알림 읽음 처리" + }, + { + "id": 158, + "identifier": "core.user-notifications.delete", + "name": "알림 삭제" + }, + { + "id": 160, + "identifier": "core.identity.request", + "name": "IDV 요청" + }, + { + "id": 161, + "identifier": "core.identity.verify", + "name": "IDV 검증" + }, + { + "id": 162, + "identifier": "core.identity.cancel", + "name": "IDV 취소" + }, + { + "id": 164, + "identifier": "core.admin.identity.providers.read", + "name": "프로바이더 설정 조회" + }, + { + "id": 165, + "identifier": "core.admin.identity.providers.update", + "name": "프로바이더 설정 수정" + }, + { + "id": 166, + "identifier": "core.admin.identity.policies.read", + "name": "정책 조회" + }, + { + "id": 167, + "identifier": "core.admin.identity.policies.update", + "name": "정책 수정" + }, + { + "id": 168, + "identifier": "core.admin.identity.logs.read", + "name": "로그 열람" + }, + { + "id": 169, + "identifier": "core.admin.identity.logs.purge", + "name": "로그 파기" + }, + { + "id": 170, + "identifier": "core.admin.identity.messages.read", + "name": "메시지 템플릿 조회" + }, + { + "id": 171, + "identifier": "core.admin.identity.messages.update", + "name": "메시지 템플릿 수정" + }, + { + "id": 173, + "identifier": "core.settings.read", + "name": "설정 조회" + }, + { + "id": 174, + "identifier": "core.settings.update", + "name": "설정 수정" + }, + { + "id": 176, + "identifier": "core.dashboard.read", + "name": "대시보드 조회" + }, + { + "id": 177, + "identifier": "core.dashboard.system-status", + "name": "시스템 상태" + }, + { + "id": 178, + "identifier": "core.dashboard.resources", + "name": "시스템 리소스" + }, + { + "id": 179, + "identifier": "core.dashboard.activities", + "name": "최근 활동" + }, + { + "id": 180, + "identifier": "core.dashboard.alerts", + "name": "시스템 알림" + }, + { + "id": 182, + "identifier": "core.activities.read", + "name": "활동 로그 조회" + }, + { + "id": 183, + "identifier": "core.activities.delete", + "name": "활동 로그 삭제" + }, + { + "id": 185, + "identifier": "core.attachments.create", + "name": "첨부파일 업로드" + }, + { + "id": 186, + "identifier": "core.attachments.update", + "name": "첨부파일 수정" + }, + { + "id": 187, + "identifier": "core.attachments.delete", + "name": "첨부파일 삭제" + }, + { + "id": 189, + "identifier": "core.schedules.read", + "name": "스케줄 조회" + }, + { + "id": 190, + "identifier": "core.schedules.create", + "name": "스케줄 생성" + }, + { + "id": 191, + "identifier": "core.schedules.update", + "name": "스케줄 수정" + }, + { + "id": 192, + "identifier": "core.schedules.delete", + "name": "스케줄 삭제" + }, + { + "id": 193, + "identifier": "core.schedules.run", + "name": "스케줄 실행" + }, + { + "id": 195, + "identifier": "core.language_packs.read", + "name": "언어팩 조회" + }, + { + "id": 196, + "identifier": "core.language_packs.install", + "name": "언어팩 설치" + }, + { + "id": 197, + "identifier": "core.language_packs.manage", + "name": "언어팩 관리" + }, + { + "id": 198, + "identifier": "core.language_packs.update", + "name": "언어팩 업데이트" + }, + { + "id": 1, + "identifier": "apidoc-sample.parent", + "name": "API 문서 샘플 권한" + }, + { + "id": 2, + "identifier": "apidoc-sample.child", + "name": "하위 권한" + }, + { + "id": 3, + "identifier": "sirsoft-board", + "name": "게시판" + }, + { + "id": 21, + "identifier": "sirsoft-ecommerce", + "name": "이커머스" + }, + { + "id": 109, + "identifier": "sirsoft-page", + "name": "페이지" + }, + { + "id": 4, + "identifier": "sirsoft-board.apidoc-sample-board", + "name": "API 문서 샘플 게시판 게시판" + }, + { + "id": 95, + "identifier": "sirsoft-board.boards", + "name": "게시판 관리" + }, + { + "id": 100, + "identifier": "sirsoft-board.settings", + "name": "환경설정" + }, + { + "id": 103, + "identifier": "sirsoft-board.identity.policies", + "name": "게시판 본인인증 정책" + }, + { + "id": 106, + "identifier": "sirsoft-board.reports", + "name": "게시판 신고 관리" + }, + { + "id": 22, + "identifier": "sirsoft-ecommerce.products", + "name": "상품 관리" + }, + { + "id": 27, + "identifier": "sirsoft-ecommerce.orders", + "name": "주문 관리" + }, + { + "id": 30, + "identifier": "sirsoft-ecommerce.categories", + "name": "카테고리 관리" + }, + { + "id": 35, + "identifier": "sirsoft-ecommerce.brands", + "name": "브랜드 관리" + }, + { + "id": 40, + "identifier": "sirsoft-ecommerce.product-notice-templates", + "name": "상품정보제공고시 관리" + }, + { + "id": 45, + "identifier": "sirsoft-ecommerce.product-common-infos", + "name": "공통정보 관리" + }, + { + "id": 50, + "identifier": "sirsoft-ecommerce.settings", + "name": "환경설정" + }, + { + "id": 53, + "identifier": "sirsoft-ecommerce.promotion-coupon", + "name": "쿠폰 관리" + }, + { + "id": 58, + "identifier": "sirsoft-ecommerce.shipping-policies", + "name": "배송정책 관리" + }, + { + "id": 63, + "identifier": "sirsoft-ecommerce.product-labels", + "name": "상품 라벨 관리" + }, + { + "id": 68, + "identifier": "sirsoft-ecommerce.identity.policies", + "name": "이커머스 본인인증 정책" + }, + { + "id": 71, + "identifier": "sirsoft-ecommerce.reviews", + "name": "리뷰 관리" + }, + { + "id": 75, + "identifier": "sirsoft-ecommerce.inquiries", + "name": "문의 관리" + }, + { + "id": 78, + "identifier": "sirsoft-ecommerce.dashboard", + "name": "대시보드" + }, + { + "id": 80, + "identifier": "sirsoft-ecommerce.user-products", + "name": "사용자 상품" + }, + { + "id": 82, + "identifier": "sirsoft-ecommerce.user-orders", + "name": "사용자 주문" + }, + { + "id": 86, + "identifier": "sirsoft-ecommerce.user-reviews", + "name": "사용자 리뷰" + }, + { + "id": 88, + "identifier": "sirsoft-ecommerce.mileage", + "name": "마일리지 관리" + }, + { + "id": 91, + "identifier": "sirsoft-ecommerce.user-currency", + "name": "회원 결제 통화 관리" + }, + { + "id": 93, + "identifier": "sirsoft-ecommerce.user-shipping-country", + "name": "회원 배송국가 관리" + }, + { + "id": 110, + "identifier": "sirsoft-page.pages", + "name": "페이지 관리" + }, + { + "id": 115, + "identifier": "core", + "name": "코어" + }, + { + "id": 116, + "identifier": "core.users", + "name": "사용자 관리" + }, + { + "id": 121, + "identifier": "core.menus", + "name": "메뉴 관리" + }, + { + "id": 126, + "identifier": "core.modules", + "name": "모듈 관리" + }, + { + "id": 131, + "identifier": "core.plugins", + "name": "플러그인 관리" + }, + { + "id": 137, + "identifier": "core.templates", + "name": "템플릿 관리" + }, + { + "id": 143, + "identifier": "core.permissions", + "name": "권한 관리" + }, + { + "id": 148, + "identifier": "core.notification-logs", + "name": "알림 발송 이력" + }, + { + "id": 151, + "identifier": "core.notifications", + "name": "알림 (관리자)" + }, + { + "id": 155, + "identifier": "core.user-notifications", + "name": "알림 (사용자)" + }, + { + "id": 159, + "identifier": "core.identity", + "name": "본인인증 (사용자)" + }, + { + "id": 163, + "identifier": "core.admin.identity", + "name": "본인인증 관리 (관리자)" + }, + { + "id": 172, + "identifier": "core.settings", + "name": "환경설정" + }, + { + "id": 175, + "identifier": "core.dashboard", + "name": "대시보드" + }, + { + "id": 181, + "identifier": "core.activities", + "name": "활동 로그" + }, + { + "id": 184, + "identifier": "core.attachments", + "name": "첨부파일 관리" + }, + { + "id": 188, + "identifier": "core.schedules", + "name": "스케줄 관리" + }, + { + "id": 194, + "identifier": "core.language_packs", + "name": "언어팩 관리" + } + ], + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 10:41:24", + "is_owner": true, + "abilities": { + "can_read": true, + "can_create": true, + "can_update": true, + "can_delete": true, + "can_assign_roles": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** + +관리자 레이아웃 전역 부트스트랩 엔드포인트. `_admin_base.json` 의 `data_source`(`current_user`)가 모든 관리자 페이지 진입 시 자동 호출해, 헤더/권한 게이트/is_admin 분기의 기준 사용자 정보를 채운다. 응답에는 `roles.permissions` 가 eager load 되어 `permissions` 배열이 함께 내려온다. + +**인증 계약**: `auth:sanctum` 필요 — Bearer 토큰이 없거나 만료되면 `401` 을 반환한다(프론트 `data_source` 의 `auth_required: true` 에 대응). 이 계약이 프론트 소비의 SSoT 이므로 미들웨어 체인 변경 시 반드시 프론트 `auth_required`/`auth_mode` 와 함께 검토한다(이슈 #64). + + +### POST /api/auth/admin/login + +- **라우트명**: `api.auth.admin.login` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\AuthController@login` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| email | body | email | 예 | — | 이메일 주소 | +| password | body | string | 예 | min 6 | 비밀번호 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.login_validation_rules`). + +**요청 예시** + +```http +POST /api/auth/admin/login HTTP/1.1 +Host: api.example.com +Accept: application/json +Content-Type: application/json + +{ + "email": "user@example.com", + "password": "Password123!" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +관리자 로그인. `email`/`password` 검증 후 `AuthService::login()` 이 인증하고, 인증 사용자가 `isAdmin()` 이 아니면 `403 auth.admin_required` 로 거부한다. 성공 시 `data.token`(Sanctum Bearer) 과 `data.user`(UserResource) 를 반환한다. 계정 잠금 시 `AccountLockedException`, 자격 불일치 시 `422` 검증 오류를 반환한다. 이후 모든 관리자 API 호출은 이 토큰을 `Authorization: Bearer` 헤더로 실어야 한다. + + +### POST /api/auth/forgot-password + +- **라우트명**: `api.auth.forgot-password` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\AuthController@forgotPassword` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| email | body | email | 예 | — | 이메일 주소 | +| redirect_prefix | body | string | 아니오 | `admin` | 재설정 링크가 향할 화면 구분값 — `admin` 전달 시 관리자 재설정 화면, 미지정 시 사용자 화면 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.forgot_password_validation_rules`). + +**요청 예시** + +```http +POST /api/auth/forgot-password HTTP/1.1 +Host: api.example.com +Accept: application/json +Content-Type: application/json + +{ + "email": "user@example.com", + "redirect_prefix": "admin" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +비밀번호 재설정 메일 발송을 요청한다(공개). `email` 로 계정을 찾아 재설정 링크 메일을 보내고 `message: auth.password_reset_email_sent` 를 반환한다. `redirect_prefix` 는 재설정 링크가 향할 화면을 구분하는 값으로 관리자 흐름(`admin_forgot_password.json`)에서는 `admin` 을 전달해 링크가 관리자 재설정 화면을 가리키게 한다(미지정 시 사용자 화면). 계정 열거 방지를 위해 이메일 존재 여부와 무관하게 동일 응답을 주는 것이 원칙이다. + + +### POST /api/auth/login + +- **라우트명**: `api.auth.login` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\AuthController@login` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| email | body | email | 예 | — | 이메일 주소 | +| password | body | string | 예 | min 6 | 비밀번호 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.login_validation_rules`). + +**요청 예시** + +```http +POST /api/auth/login HTTP/1.1 +Host: api.example.com +Accept: application/json +Content-Type: application/json + +{ + "email": "user@example.com", + "password": "Password123!" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +일반 사용자 로그인(공개). 관리자 로그인과 달리 `isAdmin()` 검사가 없다. 성공 시 `data.token`(Sanctum Bearer) 과 `data.user` 를 반환하며 `message: auth.login_success`. 계정 잠금 시 `423 auth.account_locked` 를 잠금 해제까지 남은 정보와 함께 반환한다. 프론트 로그인 폼(`partials/auth/_register_form.json` 인접)에서 소비한다. + + +### POST /api/auth/logout + +- **라우트명**: `api.auth.logout` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\AuthController@logout` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/auth/logout HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "로그아웃이 성공했습니다.", + "data": null +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** + +현재 사용자 토큰을 폐기해 로그아웃한다(`message: auth.logout_success`). 현재 요청에 사용된 토큰만 폐기하며, 모든 기기에서 로그아웃하려면 `/api/user/auth/logout-all-devices` 를 사용한다. + + +### POST /api/auth/register + +- **라우트명**: `api.auth.register` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\AuthController@register` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| name | body | string | 예 | max 255 | 대상의 이름/명칭 | +| nickname | body | string | 아니오 | max 50 | 닉네임 | +| email | body | string | 예 | max 255 | 이메일 주소 | +| password | body | string | 예 | min 8 | 비밀번호 | +| language | body | string | 아니오 | `ko`, `en`, `fr`, `ja` | 언어 코드 | +| agree_terms | body | string | 아니오 | — | 이용약관 동의 (코어 필수 동의 — accepted 규칙, 미동의 시 가입 거부) | +| agree_privacy | body | string | 아니오 | — | 개인정보 처리방침 동의 (코어 필수 동의 — accepted 규칙, 미동의 시 가입 거부) | +| agree_email_subscription | body | boolean | 아니오 | — | 광고성 이메일 수신 동의 (marketing 플러그인 주입, 선택 항목) | +| agree_marketing_consent | body | boolean | 아니오 | — | 마케팅 정보 수신 전체 동의 (marketing 플러그인 주입, 선택 항목) | +| agree_third_party_consent | body | boolean | 아니오 | — | 제3자 정보 제공 동의 (marketing 플러그인 주입, 선택 항목) | +| agree_info_disclosure | body | boolean | 아니오 | — | 개인정보 이용 안내 동의 (marketing 플러그인 주입, 선택 항목) | +| preferred_currency | body | string | 아니오 | — | 선호 결제 통화 (ecommerce 모듈 주입, 가입 시 계정 기본 통화로 저장) | +| preferred_shipping_country | body | string | 아니오 | — | 선호 배송 국가 코드 (ecommerce 모듈 주입, 가입 시 계정 기본 배송 국가로 저장) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.register_validation_rules`). + +**요청 예시** + +```http +POST /api/auth/register HTTP/1.1 +Host: api.example.com +Accept: application/json +Content-Type: application/json + +{ + "name": "예시 이름", + "nickname": "예시 이름", + "email": "user@example.com", + "password": "Password123!", + "language": "ko", + "agree_terms": "예시값", + "agree_privacy": "예시값", + "preferred_currency": "예시값", + "preferred_shipping_country": "KR" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +회원가입(공개). 성공 시 `201 auth.register_success` 와 `data.token`/`data.user` 를 반환해 가입 직후 로그인 상태로 이어진다. `agree_*` 동의 파라미터(약관/개인정보/이메일수신/마케팅/제3자제공/정보공개)는 가입 시점의 동의 이력으로 기록된다 — 그중 `agree_email_subscription`/`agree_marketing_consent`/`agree_third_party_consent`/`agree_info_disclosure` 및 `preferred_currency`/`preferred_shipping_country` 는 marketing·ecommerce 확장이 훅(`core.auth.register_validation_rules`)으로 주입하는 파라미터로, 해당 확장 비활성 시 무시된다. 검증 실패 시 `422 auth.register_failed`. + + +### POST /api/auth/reset-password + +- **라우트명**: `api.auth.reset-password` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\AuthController@resetPassword` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| token | body | string | 예 | — | 인증/검증 토큰 | +| email | body | email | 예 | — | 이메일 주소 | +| password | body | string | 예 | min 8 | 비밀번호 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.reset_password_validation_rules`). + +**요청 예시** + +```http +POST /api/auth/reset-password HTTP/1.1 +Host: api.example.com +Accept: application/json +Content-Type: application/json + +{ + "token": "{YOUR_TOKEN}", + "email": "user@example.com", + "password": "Password123!" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +비밀번호 재설정을 실제 반영한다(공개). 재설정 메일의 `token` 과 `email`, 새 `password` 를 받아 비밀번호를 갱신하고 `message: auth.password_reset_success`. 토큰 만료/불일치 등 검증 실패 시 `422 auth.password_reset_failed`. 반영 전 토큰 유효성만 먼저 확인하려면 `/api/auth/validate-reset-token` 을 사용한다. + + +### GET /api/auth/user + +- **라우트명**: `api.auth.user` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\AuthController@user` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/auth/user HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| uuid | string | `a231747f-e82e-4cf2-9ae1-a261849dce40` | 외부 노출용 UUID (URL/API 식별자, 내부 id 비노출) | +| name | string | `API 문서 샘플 사용자` | 사용자 이름 | +| nickname | string | `gunwoo.oh` | 닉네임 | +| email | string | `apidoc-sample-user@example.com` | 이메일 주소 | +| avatar | null | `null` | 아바타 이미지 URL (User::getAvatarUrl() — 아바타 미설정 시 null) | +| language | string | `ko` | 사용자 언어 설정 (ko: 한국어, en: 영어) | +| language_label | string | `한국어` | 언어 코드의 현지화 라벨 (user.language.{code} 번역) | +| country | string | `KR` | 국가 코드 (ISO 3166-1 alpha-2) | +| status | string | `active` | 계정 상태 (active: 활성, inactive: 비활성, blocked: 차단, withdrawn: 탈퇴) | +| status_label | string | `활성` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| status_variant | string | `success` | 상태 표시 색상/스타일 변형 키 (상태 Enum variant() 산물 — UI 배지용) | +| is_admin | boolean | `true` | 관리자 역할 보유 여부 (User::isAdmin() — 역할 관계 기반 파생) | +| homepage | string | `https://example.com` | 홈페이지 URL | +| mobile | string | `010-9070-5662` | 휴대폰 번호 | +| phone | string | `02-805-4759` | 전화번호 | +| zipcode | string | `93153` | 우편번호 | +| address | string | `대구광역시 북구 백제고분로 720` | 기본 주소 | +| address_detail | string | `40동 835호` | 상세 주소 | +| signature | string | `Ipsam rem amet expedita est.` | 서명 | +| bio | string | `Tenetur omnis et amet omnis veniam to…` | 자기소개 | +| last_login_at | string | `2026-07-05 19:15:16` | last login 일시 | +| email_verified_at | string | `2026-07-06 19:15:16` | email verified 일시 | +| timezone | string | `Asia/Seoul` | 사용자 시간대 (예: Asia/Seoul, UTC) | +| modules_count | array | `[]` | 접근 가능 모듈 수 (modules_count 속성이 로드된 경우에만 포함 — whenLoaded 성격의 조건부 필드) | +| plugins_count | array | `[]` | 접근 가능 플러그인 수 (plugins_count 속성이 로드된 경우에만 포함) | +| menus_count | array | `[]` | 접근 가능 메뉴 수 (menus_count 속성이 로드된 경우에만 포함) | +| modules | array | `[]` | 접근 가능 모듈 목록 (원소 id/name/slug/is_active — modules 관계 로드 시에만 포함) | +| plugins | array | `[]` | 접근 가능 플러그인 목록 (원소 id/name/slug/is_active — plugins 관계 로드 시에만 포함) | +| menus | array | `[]` | 접근 가능 메뉴 목록 (원소 id/title/url/is_active — menus 관계 로드 시에만 포함) | +| roles | array | `[{"id":1,"identifier":"admin","name":"관리자"}]` | 사용자에게 부여된 역할 목록 (원소 id/identifier/name — roles 관계 파생, name 은 현지화 라벨) | +| permissions | array | `[{"id":3,"identifier":"core.users.read","name":"사용자 조회"},…` | 연결된 권한 목록 (id/identifier/name — 역할 경유 권한 관계 파생) | +| consents | array | `[]` | 전체 약관 동의 이력 (원소 consent_type/agreed_at/revoked_at — consents 관계 로드 시 포함, 플러그인 참조용) | +| terms_consent | array | `[]` | 이용약관 동의 정보 (agreed_at — ConsentType::Terms 동의 이력에서 파생, 미동의 시 null) | +| privacy_consent | array | `[]` | 개인정보 처리방침 동의 정보 (agreed_at — ConsentType::Privacy 동의 이력에서 파생, 미동의 시 null) | +| created_at | string | `2026-07-06 19:15:16` | 생성 일시 | +| updated_at | string | `2026-07-06 19:15:16` | 최종 수정 일시 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_create":true,"can_update":true,"can…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | +| notify_post_complete | boolean | `false` | 게시판 새 글 작성 완료 알림 수신 설정 (marketing 플러그인 주입) | +| notify_post_reply | boolean | `false` | 내 게시글에 답글 달림 알림 수신 설정 (marketing 플러그인 주입) | +| notify_comment | boolean | `false` | 내 게시글에 댓글 달림 알림 수신 설정 (marketing 플러그인 주입) | +| notify_reply_comment | boolean | `false` | 내 댓글에 답글 달림 알림 수신 설정 (marketing 플러그인 주입) | +| email_subscription | boolean | `false` | 광고성 이메일 수신 동의 여부 (marketing 플러그인 주입) | +| email_subscription_at | null | `null` | email subscription 일시 (광고성 이메일 수신 동의 시각, 미동의 시 null) | +| marketing_consent | boolean | `false` | 마케팅 정보 수신 전체 동의 마스터 키 (marketing 플러그인 주입) | +| marketing_consent_at | null | `null` | marketing consent 일시 (마케팅 정보 수신 동의 시각, 미동의 시 null) | +| third_party_consent | boolean | `false` | 제3자 정보 제공 동의 여부 (법적 항목 — marketing 플러그인 주입) | +| third_party_consent_at | null | `null` | third party consent 일시 (제3자 정보 제공 동의 시각, 미동의 시 null) | +| info_disclosure | boolean | `false` | 개인정보 이용 안내 동의 여부 (법적 항목 — marketing 플러그인 주입) | +| info_disclosure_at | null | `null` | info disclosure 일시 (개인정보 이용 안내 동의 시각, 미동의 시 null) | +| marketing_consent_enabled | boolean | `true` | 마케팅 정보 수신 동의 항목 UI 노출 여부 (관리자 활성화 플래그) | +| marketing_consent_terms_slug | string | `marketing-terms` | 마케팅 정보 수신 동의에 연결된 약관 slug (미설정 시 null) | +| marketing_consent_terms_slug_set | boolean | `true` | 마케팅 정보 수신 약관 연결 존재 여부 (프론트 링크 표시 판정용) | +| third_party_consent_enabled | boolean | `true` | 제3자 정보 제공 동의 항목 UI 노출 여부 (관리자 활성화 플래그) | +| third_party_consent_terms_slug | null | `null` | 제3자 정보 제공 동의에 연결된 약관 slug (미설정 시 null) | +| third_party_consent_terms_slug_set | boolean | `false` | 제3자 정보 제공 약관 연결 존재 여부 (프론트 링크 표시 판정용) | +| info_disclosure_enabled | boolean | `true` | 개인정보 이용 안내 동의 항목 UI 노출 여부 (관리자 활성화 플래그) | +| info_disclosure_terms_slug | null | `null` | 개인정보 이용 안내 동의에 연결된 약관 slug (미설정 시 null) | +| info_disclosure_terms_slug_set | boolean | `false` | 개인정보 이용 안내 약관 연결 존재 여부 (프론트 링크 표시 판정용) | +| email_subscription_enabled | boolean | `true` | 광고성 이메일 수신 동의 항목 UI 노출 여부 (관리자 활성화 플래그) | +| email_subscription_terms_slug | null | `null` | 광고성 이메일 수신 동의에 연결된 약관 slug (미설정 시 null) | +| email_subscription_terms_slug_set | boolean | `false` | 광고성 이메일 수신 약관 연결 존재 여부 (프론트 링크 표시 판정용) | +| channels | array | `[{"key":"email_subscription","label":"광고성 이메일 수신","enable…` | 관리자 정의 전체 마케팅 채널 목록 (원소 key/label/enabled/terms_slug — marketing 플러그인 주입) | +| consent_histories | array | `[]` | 동의 변경 이력 (원소 channel_key/action/source/created_at — marketing 플러그인 주입) | +| ecommerce_mileage | object | `{"enabled":false}` | 마일리지 정보 (enabled/잔액 — ecommerce 모듈 주입, 모듈 비활성 시 enabled=false) | +| ecommerce_preferred_currency | null | `null` | 선호 결제 통화 (ecommerce 모듈 주입, 미설정 시 null) | +| ecommerce_preferred_shipping_country | null | `null` | 선호 배송 국가 코드 (ecommerce 모듈 주입, 미설정 시 null) | +| ecommerce_preferred_shipping_country_name | null | `null` | 선호 배송 국가 이름 (국가 코드에서 현지화 파생 — ecommerce 모듈 주입, 미설정 시 null) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "nickname": "song.hyunji", + "email": "apidoc-sample-user@example.com", + "avatar": null, + "language": "ko", + "language_label": "한국어", + "country": "KR", + "status": "active", + "status_label": "활성", + "status_variant": "success", + "is_admin": true, + "homepage": "https://example.com", + "mobile": "010-9595-2897", + "phone": "02-637-5618", + "zipcode": "16505", + "address": "경기도 안양시 봉은사로 2918", + "address_detail": "48동 718호", + "signature": "Fugit consequuntur repellendus sed.", + "bio": "Ut magni et sunt ducimus error adipisci. Pariatur corporis voluptatem ratione quo non saepe. Illo atque praesentium possimus dolores qui est fugit. Sint fugiat numquam voluptates.", + "last_login_at": "2026-07-07 10:41:24", + "email_verified_at": "2026-07-08 10:41:24", + "timezone": "Asia/Seoul", + "modules_count": [], + "plugins_count": [], + "menus_count": [], + "modules": [], + "plugins": [], + "menus": [], + "roles": [ + { + "id": 1, + "identifier": "admin", + "name": "관리자" + }, + { + "id": 4, + "identifier": "apidoc-sample-role", + "name": "API 문서 샘플 역할" + } + ], + "permissions": [ + { + "id": 5, + "identifier": "sirsoft-board.apidoc-sample-board.admin.posts.read", + "name": "게시글 조회 (관리자)" + }, + { + "id": 6, + "identifier": "sirsoft-board.apidoc-sample-board.admin.posts.write", + "name": "게시글 작성/수정/삭제 (관리자)" + }, + { + "id": 7, + "identifier": "sirsoft-board.apidoc-sample-board.admin.posts.read-secret", + "name": "비밀글 조회 (관리자)" + }, + { + "id": 8, + "identifier": "sirsoft-board.apidoc-sample-board.admin.comments.read", + "name": "댓글 조회 (관리자)" + }, + { + "id": 9, + "identifier": "sirsoft-board.apidoc-sample-board.admin.comments.write", + "name": "댓글 작성/수정/삭제 (관리자)" + }, + { + "id": 10, + "identifier": "sirsoft-board.apidoc-sample-board.admin.attachments.upload", + "name": "파일 업로드/삭제 (관리자)" + }, + { + "id": 11, + "identifier": "sirsoft-board.apidoc-sample-board.admin.attachments.download", + "name": "파일 다운로드 (관리자)" + }, + { + "id": 12, + "identifier": "sirsoft-board.apidoc-sample-board.admin.manage", + "name": "게시판 관리 (관리자)" + }, + { + "id": 13, + "identifier": "sirsoft-board.apidoc-sample-board.posts.read", + "name": "게시글 조회" + }, + { + "id": 14, + "identifier": "sirsoft-board.apidoc-sample-board.posts.write", + "name": "게시글 작성/수정/삭제" + }, + { + "id": 15, + "identifier": "sirsoft-board.apidoc-sample-board.posts.read-secret", + "name": "비밀글 조회" + }, + { + "id": 16, + "identifier": "sirsoft-board.apidoc-sample-board.comments.read", + "name": "댓글 조회" + }, + { + "id": 17, + "identifier": "sirsoft-board.apidoc-sample-board.comments.write", + "name": "댓글 작성/수정/삭제" + }, + { + "id": 18, + "identifier": "sirsoft-board.apidoc-sample-board.attachments.upload", + "name": "파일 업로드/삭제" + }, + { + "id": 19, + "identifier": "sirsoft-board.apidoc-sample-board.attachments.download", + "name": "파일 다운로드" + }, + { + "id": 20, + "identifier": "sirsoft-board.apidoc-sample-board.manager", + "name": "게시판 관리 (사용자)" + }, + { + "id": 81, + "identifier": "sirsoft-ecommerce.user-products.read", + "name": "상품 조회" + }, + { + "id": 83, + "identifier": "sirsoft-ecommerce.user-orders.create", + "name": "주문하기" + }, + { + "id": 84, + "identifier": "sirsoft-ecommerce.user-orders.cancel", + "name": "주문 취소" + }, + { + "id": 85, + "identifier": "sirsoft-ecommerce.user-orders.confirm", + "name": "구매확정" + }, + { + "id": 87, + "identifier": "sirsoft-ecommerce.user-reviews.write", + "name": "리뷰 작성" + }, + { + "id": 23, + "identifier": "sirsoft-ecommerce.products.read", + "name": "상품 조회" + }, + { + "id": 24, + "identifier": "sirsoft-ecommerce.products.create", + "name": "상품 생성" + }, + { + "id": 25, + "identifier": "sirsoft-ecommerce.products.update", + "name": "상품 수정" + }, + { + "id": 26, + "identifier": "sirsoft-ecommerce.products.delete", + "name": "상품 삭제" + }, + { + "id": 28, + "identifier": "sirsoft-ecommerce.orders.read", + "name": "주문 조회" + }, + { + "id": 29, + "identifier": "sirsoft-ecommerce.orders.update", + "name": "주문 수정" + }, + { + "id": 31, + "identifier": "sirsoft-ecommerce.categories.read", + "name": "카테고리 조회" + }, + { + "id": 32, + "identifier": "sirsoft-ecommerce.categories.create", + "name": "카테고리 생성" + }, + { + "id": 33, + "identifier": "sirsoft-ecommerce.categories.update", + "name": "카테고리 수정" + }, + { + "id": 34, + "identifier": "sirsoft-ecommerce.categories.delete", + "name": "카테고리 삭제" + }, + { + "id": 36, + "identifier": "sirsoft-ecommerce.brands.read", + "name": "브랜드 조회" + }, + { + "id": 37, + "identifier": "sirsoft-ecommerce.brands.create", + "name": "브랜드 생성" + }, + { + "id": 38, + "identifier": "sirsoft-ecommerce.brands.update", + "name": "브랜드 수정" + }, + { + "id": 39, + "identifier": "sirsoft-ecommerce.brands.delete", + "name": "브랜드 삭제" + }, + { + "id": 41, + "identifier": "sirsoft-ecommerce.product-notice-templates.read", + "name": "조회" + }, + { + "id": 42, + "identifier": "sirsoft-ecommerce.product-notice-templates.create", + "name": "생성" + }, + { + "id": 43, + "identifier": "sirsoft-ecommerce.product-notice-templates.update", + "name": "수정" + }, + { + "id": 44, + "identifier": "sirsoft-ecommerce.product-notice-templates.delete", + "name": "삭제" + }, + { + "id": 46, + "identifier": "sirsoft-ecommerce.product-common-infos.read", + "name": "조회" + }, + { + "id": 47, + "identifier": "sirsoft-ecommerce.product-common-infos.create", + "name": "생성" + }, + { + "id": 48, + "identifier": "sirsoft-ecommerce.product-common-infos.update", + "name": "수정" + }, + { + "id": 49, + "identifier": "sirsoft-ecommerce.product-common-infos.delete", + "name": "삭제" + }, + { + "id": 51, + "identifier": "sirsoft-ecommerce.settings.read", + "name": "환경설정 조회" + }, + { + "id": 52, + "identifier": "sirsoft-ecommerce.settings.update", + "name": "환경설정 수정" + }, + { + "id": 54, + "identifier": "sirsoft-ecommerce.promotion-coupon.read", + "name": "쿠폰 조회" + }, + { + "id": 55, + "identifier": "sirsoft-ecommerce.promotion-coupon.create", + "name": "쿠폰 생성" + }, + { + "id": 56, + "identifier": "sirsoft-ecommerce.promotion-coupon.update", + "name": "쿠폰 수정" + }, + { + "id": 57, + "identifier": "sirsoft-ecommerce.promotion-coupon.delete", + "name": "쿠폰 삭제" + }, + { + "id": 59, + "identifier": "sirsoft-ecommerce.shipping-policies.read", + "name": "배송정책 조회" + }, + { + "id": 60, + "identifier": "sirsoft-ecommerce.shipping-policies.create", + "name": "배송정책 생성" + }, + { + "id": 61, + "identifier": "sirsoft-ecommerce.shipping-policies.update", + "name": "배송정책 수정" + }, + { + "id": 62, + "identifier": "sirsoft-ecommerce.shipping-policies.delete", + "name": "배송정책 삭제" + }, + { + "id": 64, + "identifier": "sirsoft-ecommerce.product-labels.read", + "name": "상품 라벨 조회" + }, + { + "id": 65, + "identifier": "sirsoft-ecommerce.product-labels.create", + "name": "상품 라벨 생성" + }, + { + "id": 66, + "identifier": "sirsoft-ecommerce.product-labels.update", + "name": "상품 라벨 수정" + }, + { + "id": 67, + "identifier": "sirsoft-ecommerce.product-labels.delete", + "name": "상품 라벨 삭제" + }, + { + "id": 69, + "identifier": "sirsoft-ecommerce.identity.policies.read", + "name": "본인인증 정책 조회" + }, + { + "id": 70, + "identifier": "sirsoft-ecommerce.identity.policies.update", + "name": "본인인증 정책 수정" + }, + { + "id": 72, + "identifier": "sirsoft-ecommerce.reviews.read", + "name": "리뷰 조회" + }, + { + "id": 73, + "identifier": "sirsoft-ecommerce.reviews.update", + "name": "리뷰 처리" + }, + { + "id": 74, + "identifier": "sirsoft-ecommerce.reviews.delete", + "name": "리뷰 삭제" + }, + { + "id": 76, + "identifier": "sirsoft-ecommerce.inquiries.update", + "name": "문의 처리" + }, + { + "id": 77, + "identifier": "sirsoft-ecommerce.inquiries.delete", + "name": "문의 삭제" + }, + { + "id": 79, + "identifier": "sirsoft-ecommerce.dashboard.view", + "name": "대시보드 조회" + }, + { + "id": 89, + "identifier": "sirsoft-ecommerce.mileage.read", + "name": "마일리지 내역 조회" + }, + { + "id": 90, + "identifier": "sirsoft-ecommerce.mileage.manage", + "name": "마일리지 수동 처리" + }, + { + "id": 92, + "identifier": "sirsoft-ecommerce.user-currency.manage", + "name": "회원 결제 통화 변경" + }, + { + "id": 94, + "identifier": "sirsoft-ecommerce.user-shipping-country.manage", + "name": "회원 배송국가 변경" + }, + { + "id": 96, + "identifier": "sirsoft-board.boards.read", + "name": "게시판 조회" + }, + { + "id": 97, + "identifier": "sirsoft-board.boards.create", + "name": "게시판 생성" + }, + { + "id": 98, + "identifier": "sirsoft-board.boards.update", + "name": "게시판 수정" + }, + { + "id": 99, + "identifier": "sirsoft-board.boards.delete", + "name": "게시판 삭제" + }, + { + "id": 101, + "identifier": "sirsoft-board.settings.read", + "name": "환경설정 조회" + }, + { + "id": 102, + "identifier": "sirsoft-board.settings.update", + "name": "환경설정 수정" + }, + { + "id": 104, + "identifier": "sirsoft-board.identity.policies.read", + "name": "본인인증 정책 조회" + }, + { + "id": 105, + "identifier": "sirsoft-board.identity.policies.update", + "name": "본인인증 정책 수정" + }, + { + "id": 107, + "identifier": "sirsoft-board.reports.view", + "name": "신고 조회" + }, + { + "id": 108, + "identifier": "sirsoft-board.reports.manage", + "name": "신고 처리" + }, + { + "id": 111, + "identifier": "sirsoft-page.pages.read", + "name": "페이지 조회" + }, + { + "id": 112, + "identifier": "sirsoft-page.pages.create", + "name": "페이지 생성" + }, + { + "id": 113, + "identifier": "sirsoft-page.pages.update", + "name": "페이지 수정" + }, + { + "id": 114, + "identifier": "sirsoft-page.pages.delete", + "name": "페이지 삭제" + }, + { + "id": 117, + "identifier": "core.users.read", + "name": "사용자 조회" + }, + { + "id": 118, + "identifier": "core.users.create", + "name": "사용자 생성" + }, + { + "id": 119, + "identifier": "core.users.update", + "name": "사용자 수정" + }, + { + "id": 120, + "identifier": "core.users.delete", + "name": "사용자 삭제" + }, + { + "id": 122, + "identifier": "core.menus.read", + "name": "메뉴 조회" + }, + { + "id": 123, + "identifier": "core.menus.create", + "name": "메뉴 생성" + }, + { + "id": 124, + "identifier": "core.menus.update", + "name": "메뉴 수정" + }, + { + "id": 125, + "identifier": "core.menus.delete", + "name": "메뉴 삭제" + }, + { + "id": 127, + "identifier": "core.modules.read", + "name": "모듈 조회" + }, + { + "id": 128, + "identifier": "core.modules.install", + "name": "모듈 설치" + }, + { + "id": 129, + "identifier": "core.modules.activate", + "name": "모듈 활성화" + }, + { + "id": 130, + "identifier": "core.modules.uninstall", + "name": "모듈 삭제" + }, + { + "id": 132, + "identifier": "core.plugins.read", + "name": "플러그인 조회" + }, + { + "id": 133, + "identifier": "core.plugins.install", + "name": "플러그인 설치" + }, + { + "id": 134, + "identifier": "core.plugins.activate", + "name": "플러그인 활성화" + }, + { + "id": 135, + "identifier": "core.plugins.update", + "name": "플러그인 설정" + }, + { + "id": 136, + "identifier": "core.plugins.uninstall", + "name": "플러그인 삭제" + }, + { + "id": 138, + "identifier": "core.templates.read", + "name": "템플릿 조회" + }, + { + "id": 139, + "identifier": "core.templates.install", + "name": "템플릿 설치" + }, + { + "id": 140, + "identifier": "core.templates.activate", + "name": "템플릿 활성화" + }, + { + "id": 141, + "identifier": "core.templates.uninstall", + "name": "템플릿 삭제" + }, + { + "id": 142, + "identifier": "core.templates.layouts.edit", + "name": "레이아웃 편집" + }, + { + "id": 144, + "identifier": "core.permissions.read", + "name": "권한 조회" + }, + { + "id": 145, + "identifier": "core.permissions.create", + "name": "역할 생성" + }, + { + "id": 146, + "identifier": "core.permissions.update", + "name": "역할 수정" + }, + { + "id": 147, + "identifier": "core.permissions.delete", + "name": "역할 삭제" + }, + { + "id": 149, + "identifier": "core.notification-logs.read", + "name": "발송 이력 조회" + }, + { + "id": 150, + "identifier": "core.notification-logs.delete", + "name": "발송 이력 삭제" + }, + { + "id": 152, + "identifier": "core.notifications.read", + "name": "알림 조회" + }, + { + "id": 153, + "identifier": "core.notifications.update", + "name": "알림 읽음 처리" + }, + { + "id": 154, + "identifier": "core.notifications.delete", + "name": "알림 삭제" + }, + { + "id": 156, + "identifier": "core.user-notifications.read", + "name": "알림 조회" + }, + { + "id": 157, + "identifier": "core.user-notifications.update", + "name": "알림 읽음 처리" + }, + { + "id": 158, + "identifier": "core.user-notifications.delete", + "name": "알림 삭제" + }, + { + "id": 160, + "identifier": "core.identity.request", + "name": "IDV 요청" + }, + { + "id": 161, + "identifier": "core.identity.verify", + "name": "IDV 검증" + }, + { + "id": 162, + "identifier": "core.identity.cancel", + "name": "IDV 취소" + }, + { + "id": 164, + "identifier": "core.admin.identity.providers.read", + "name": "프로바이더 설정 조회" + }, + { + "id": 165, + "identifier": "core.admin.identity.providers.update", + "name": "프로바이더 설정 수정" + }, + { + "id": 166, + "identifier": "core.admin.identity.policies.read", + "name": "정책 조회" + }, + { + "id": 167, + "identifier": "core.admin.identity.policies.update", + "name": "정책 수정" + }, + { + "id": 168, + "identifier": "core.admin.identity.logs.read", + "name": "로그 열람" + }, + { + "id": 169, + "identifier": "core.admin.identity.logs.purge", + "name": "로그 파기" + }, + { + "id": 170, + "identifier": "core.admin.identity.messages.read", + "name": "메시지 템플릿 조회" + }, + { + "id": 171, + "identifier": "core.admin.identity.messages.update", + "name": "메시지 템플릿 수정" + }, + { + "id": 173, + "identifier": "core.settings.read", + "name": "설정 조회" + }, + { + "id": 174, + "identifier": "core.settings.update", + "name": "설정 수정" + }, + { + "id": 176, + "identifier": "core.dashboard.read", + "name": "대시보드 조회" + }, + { + "id": 177, + "identifier": "core.dashboard.system-status", + "name": "시스템 상태" + }, + { + "id": 178, + "identifier": "core.dashboard.resources", + "name": "시스템 리소스" + }, + { + "id": 179, + "identifier": "core.dashboard.activities", + "name": "최근 활동" + }, + { + "id": 180, + "identifier": "core.dashboard.alerts", + "name": "시스템 알림" + }, + { + "id": 182, + "identifier": "core.activities.read", + "name": "활동 로그 조회" + }, + { + "id": 183, + "identifier": "core.activities.delete", + "name": "활동 로그 삭제" + }, + { + "id": 185, + "identifier": "core.attachments.create", + "name": "첨부파일 업로드" + }, + { + "id": 186, + "identifier": "core.attachments.update", + "name": "첨부파일 수정" + }, + { + "id": 187, + "identifier": "core.attachments.delete", + "name": "첨부파일 삭제" + }, + { + "id": 189, + "identifier": "core.schedules.read", + "name": "스케줄 조회" + }, + { + "id": 190, + "identifier": "core.schedules.create", + "name": "스케줄 생성" + }, + { + "id": 191, + "identifier": "core.schedules.update", + "name": "스케줄 수정" + }, + { + "id": 192, + "identifier": "core.schedules.delete", + "name": "스케줄 삭제" + }, + { + "id": 193, + "identifier": "core.schedules.run", + "name": "스케줄 실행" + }, + { + "id": 195, + "identifier": "core.language_packs.read", + "name": "언어팩 조회" + }, + { + "id": 196, + "identifier": "core.language_packs.install", + "name": "언어팩 설치" + }, + { + "id": 197, + "identifier": "core.language_packs.manage", + "name": "언어팩 관리" + }, + { + "id": 198, + "identifier": "core.language_packs.update", + "name": "언어팩 업데이트" + }, + { + "id": 1, + "identifier": "apidoc-sample.parent", + "name": "API 문서 샘플 권한" + }, + { + "id": 2, + "identifier": "apidoc-sample.child", + "name": "하위 권한" + }, + { + "id": 3, + "identifier": "sirsoft-board", + "name": "게시판" + }, + { + "id": 21, + "identifier": "sirsoft-ecommerce", + "name": "이커머스" + }, + { + "id": 109, + "identifier": "sirsoft-page", + "name": "페이지" + }, + { + "id": 4, + "identifier": "sirsoft-board.apidoc-sample-board", + "name": "API 문서 샘플 게시판 게시판" + }, + { + "id": 95, + "identifier": "sirsoft-board.boards", + "name": "게시판 관리" + }, + { + "id": 100, + "identifier": "sirsoft-board.settings", + "name": "환경설정" + }, + { + "id": 103, + "identifier": "sirsoft-board.identity.policies", + "name": "게시판 본인인증 정책" + }, + { + "id": 106, + "identifier": "sirsoft-board.reports", + "name": "게시판 신고 관리" + }, + { + "id": 22, + "identifier": "sirsoft-ecommerce.products", + "name": "상품 관리" + }, + { + "id": 27, + "identifier": "sirsoft-ecommerce.orders", + "name": "주문 관리" + }, + { + "id": 30, + "identifier": "sirsoft-ecommerce.categories", + "name": "카테고리 관리" + }, + { + "id": 35, + "identifier": "sirsoft-ecommerce.brands", + "name": "브랜드 관리" + }, + { + "id": 40, + "identifier": "sirsoft-ecommerce.product-notice-templates", + "name": "상품정보제공고시 관리" + }, + { + "id": 45, + "identifier": "sirsoft-ecommerce.product-common-infos", + "name": "공통정보 관리" + }, + { + "id": 50, + "identifier": "sirsoft-ecommerce.settings", + "name": "환경설정" + }, + { + "id": 53, + "identifier": "sirsoft-ecommerce.promotion-coupon", + "name": "쿠폰 관리" + }, + { + "id": 58, + "identifier": "sirsoft-ecommerce.shipping-policies", + "name": "배송정책 관리" + }, + { + "id": 63, + "identifier": "sirsoft-ecommerce.product-labels", + "name": "상품 라벨 관리" + }, + { + "id": 68, + "identifier": "sirsoft-ecommerce.identity.policies", + "name": "이커머스 본인인증 정책" + }, + { + "id": 71, + "identifier": "sirsoft-ecommerce.reviews", + "name": "리뷰 관리" + }, + { + "id": 75, + "identifier": "sirsoft-ecommerce.inquiries", + "name": "문의 관리" + }, + { + "id": 78, + "identifier": "sirsoft-ecommerce.dashboard", + "name": "대시보드" + }, + { + "id": 80, + "identifier": "sirsoft-ecommerce.user-products", + "name": "사용자 상품" + }, + { + "id": 82, + "identifier": "sirsoft-ecommerce.user-orders", + "name": "사용자 주문" + }, + { + "id": 86, + "identifier": "sirsoft-ecommerce.user-reviews", + "name": "사용자 리뷰" + }, + { + "id": 88, + "identifier": "sirsoft-ecommerce.mileage", + "name": "마일리지 관리" + }, + { + "id": 91, + "identifier": "sirsoft-ecommerce.user-currency", + "name": "회원 결제 통화 관리" + }, + { + "id": 93, + "identifier": "sirsoft-ecommerce.user-shipping-country", + "name": "회원 배송국가 관리" + }, + { + "id": 110, + "identifier": "sirsoft-page.pages", + "name": "페이지 관리" + }, + { + "id": 115, + "identifier": "core", + "name": "코어" + }, + { + "id": 116, + "identifier": "core.users", + "name": "사용자 관리" + }, + { + "id": 121, + "identifier": "core.menus", + "name": "메뉴 관리" + }, + { + "id": 126, + "identifier": "core.modules", + "name": "모듈 관리" + }, + { + "id": 131, + "identifier": "core.plugins", + "name": "플러그인 관리" + }, + { + "id": 137, + "identifier": "core.templates", + "name": "템플릿 관리" + }, + { + "id": 143, + "identifier": "core.permissions", + "name": "권한 관리" + }, + { + "id": 148, + "identifier": "core.notification-logs", + "name": "알림 발송 이력" + }, + { + "id": 151, + "identifier": "core.notifications", + "name": "알림 (관리자)" + }, + { + "id": 155, + "identifier": "core.user-notifications", + "name": "알림 (사용자)" + }, + { + "id": 159, + "identifier": "core.identity", + "name": "본인인증 (사용자)" + }, + { + "id": 163, + "identifier": "core.admin.identity", + "name": "본인인증 관리 (관리자)" + }, + { + "id": 172, + "identifier": "core.settings", + "name": "환경설정" + }, + { + "id": 175, + "identifier": "core.dashboard", + "name": "대시보드" + }, + { + "id": 181, + "identifier": "core.activities", + "name": "활동 로그" + }, + { + "id": 184, + "identifier": "core.attachments", + "name": "첨부파일 관리" + }, + { + "id": 188, + "identifier": "core.schedules", + "name": "스케줄 관리" + }, + { + "id": 194, + "identifier": "core.language_packs", + "name": "언어팩 관리" + } + ], + "consents": [], + "terms_consent": [], + "privacy_consent": [], + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 10:41:24", + "is_owner": true, + "abilities": { + "can_read": true, + "can_create": true, + "can_update": true, + "can_delete": true, + "can_assign_roles": true + }, + "notify_post_complete": false, + "notify_post_reply": false, + "notify_comment": false, + "notify_reply_comment": false, + "ecommerce_mileage": { + "enabled": false + }, + "ecommerce_preferred_currency": null, + "ecommerce_preferred_shipping_country": null, + "ecommerce_preferred_shipping_country_name": null + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** + +프론트(사용자) 레이아웃 전역 부트스트랩 엔드포인트. `_user_base.json` 의 `current_user` data_source 가 모든 페이지 진입 시 호출한다. 관리자 `user` 와 달리 응답을 `UserResource::toAuthArray()` 로 만들어 `core.user.filter_resource_data` 필터를 적용하므로, marketing 플러그인·ecommerce 모듈이 훅으로 병합한 필드(`notify_*`, `marketing_consent*`, `channels`, `ecommerce_*` 등)가 함께 내려온다. 이 필드들은 확장 소유이므로 상세 설명은 각 확장 문서를 따른다. 로그인 시 이 응답이 계정 영속 통화를 덮어쓰는 계약(D-LOGIN-CUR)의 출처다. + +**인증 계약**: 이 경로(`api.auth.user`)는 `auth:sanctum` 으로 인증이 필수다. 인증 여부와 무관하게 게스트 컨텍스트가 필요한 화면은 `optional.sanctum` 이 걸린 `/api/user/auth/user`(`api.user.auth.user`)를 사용해야 한다 — 프론트 `data_source` 의 `auth_mode: "optional"` 이 이 경로에 대응한다. 두 경로의 미들웨어 차이가 곧 `auth_required`/`auth_mode` 계약이므로 변경 시 프론트와 함께 검토한다(이슈 #64). + + +### POST /api/auth/validate-reset-token + +- **라우트명**: `api.auth.validate-reset-token` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\AuthController@validateResetToken` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| token | body | string | 예 | — | 인증/검증 토큰 | +| email | body | email | 예 | — | 이메일 주소 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.validate_reset_token_rules`, `core.auth.validate_reset_token_messages`). + +**요청 예시** + +```http +POST /api/auth/validate-reset-token HTTP/1.1 +Host: api.example.com +Accept: application/json +Content-Type: application/json + +{ + "token": "{YOUR_TOKEN}", + "email": "user@example.com" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +비밀번호 재설정 토큰의 유효성만 사전 확인한다(공개, 비밀번호 미변경). 재설정 화면(`admin_reset_password.json`/`auth/reset_password.json`) 진입 시 토큰/이메일이 유효한지 먼저 검사해, 만료·위조 링크면 즉시 오류 화면을 보이고 유효하면 새 비밀번호 입력 폼을 노출하는 용도다. 실제 반영은 `/api/auth/reset-password` 가 담당한다. + + +### POST /api/user/auth/logout + +- **라우트명**: `api.user.auth.logout` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\AuthController@logout` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.auth.logout` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/user/auth/logout HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`core.auth.logout`)이 없는 경우 | + + + +**설명** + +`/user` prefix 그룹의 사용자 로그아웃. 공용 경로 `/api/auth/logout` 과 동일하게 현재 토큰을 폐기하되, `permission:core.auth.logout` 권한 게이트를 추가로 통과해야 한다. 세션 시작(`start.api.session`)이 걸린 공용 경로와 달리 권한 기반 접근 제어가 필요한 흐름에서 사용한다. + + +### POST /api/user/auth/logout-all-devices + +- **라우트명**: `api.user.auth.logout-all-devices` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\AuthController@logoutFromAllDevices` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.auth.logout` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/user/auth/logout-all-devices HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`core.auth.logout`)이 없는 경우 | + + + +**설명** + +현재 사용자의 **모든** Sanctum 토큰을 폐기해 전 기기에서 로그아웃한다(`message: auth.logout_all_devices_success`). 비밀번호 변경 후 기존 세션 무효화, 계정 도용 대응 등에 사용한다. + + +### POST /api/user/auth/refresh + +- **라우트명**: `api.user.auth.refresh` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\AuthController@refresh` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.auth.refresh` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/user/auth/refresh HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`core.auth.refresh`)이 없는 경우 | + + + +**설명** + +`/user` prefix 그룹의 토큰 갱신. 공용 `refresh` 와 동작은 같으나 `permission:core.auth.refresh` 권한 게이트를 추가로 통과해야 한다. 이 그룹(`routes/api.php:271`)은 `optional.sanctum` + `RefreshTokenExpiration` 미들웨어 아래 있어 토큰 만료 정책 갱신과 함께 동작한다. + + +### GET /api/user/auth/user + +- **라우트명**: `api.user.auth.user` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\AuthController@user` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.auth.user` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/user/auth/user HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`core.auth.user`)이 없는 경우 | + + + +**설명** + +`/user` prefix 그룹(`routes/api.php:271`)의 현재 사용자 정보. 이 그룹은 `optional.sanctum` 미들웨어 아래 있어 **비인증(게스트) 요청도 통과**하며, 게스트 컨텍스트가 필요한 프론트 화면의 `data_source`(`auth_mode: "optional"`)가 이 경로를 소비한다. 응답 필드는 인증된 경우 공용 `/api/auth/user` 와 동일 형태(`toAuthArray` 병합 포함)이며, 이 경로에는 추가로 `permission:core.auth.user` 권한 게이트가 걸린다. 실측이 `403` 으로 제외된 것은 샘플 사용자에 해당 권한이 없었기 때문으로, 응답 shape 은 공용 `user` 경로를 참조한다. + +> **인증 계약 요약(이슈 #64)**: `api.auth.user`(`auth:sanctum`, 필수) ↔ `api.user.auth.user`(`optional.sanctum`, 선택). 프론트 `data_source` 의 `auth_required: true` 는 전자에, `auth_mode: "optional"` 은 후자에 대응한다. 어느 한쪽 미들웨어를 바꾸면 프론트 소비 계약이 침묵 속에서 깨지므로 반드시 양쪽 문서를 함께 갱신한다. + + diff --git a/docs/backend/api/avatar.md b/docs/backend/api/avatar.md new file mode 100644 index 00000000..fa1fe9cf --- /dev/null +++ b/docs/backend/api/avatar.md @@ -0,0 +1,108 @@ +# Avatar API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Avatar 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### DELETE /api/me/avatar + +- **라우트명**: `api.me.avatar.delete` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\ProfileController@deleteAvatar` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +DELETE /api/me/avatar HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** 현재 인증 사용자의 아바타를 삭제합니다. 사용자에 연결된 아바타 첨부파일(Attachment) 레코드와 실제 파일을 함께 제거하며, 삭제 활동이 로그로 기록됩니다. 아바타가 없으면 404(`user.avatar_not_found`)를 반환합니다. `auth:sanctum` 인증만 필요하고 별도 권한은 없으며, 사용자가 자신의 프로필 사진을 기본값으로 되돌리는 시나리오에 사용합니다. + + +### POST /api/me/avatar + +- **라우트명**: `api.me.avatar.upload` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\ProfileController@uploadAvatar` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| avatar | body | image | 예 | max 2048 | 아바타 이미지 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.user.upload_avatar_rules`). + +**요청 예시** + +```http +POST /api/me/avatar HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: multipart/form-data; boundary=----G7ExampleBoundary + +------G7ExampleBoundary +Content-Disposition: form-data; name="avatar"; filename="example.png" +Content-Type: image/png + +(바이너리 파일 내용) +------G7ExampleBoundary-- +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 현재 인증 사용자의 아바타 이미지를 업로드합니다. 기존 아바타가 있으면 먼저 삭제한 뒤 새 이미지를 `avatar` 컬렉션의 다형성 첨부파일로 등록하고, 업로드 활동을 로그로 기록합니다. `auth:sanctum` 인증만 필요하고 별도 권한은 없으며, 이미지는 최대 2048KB로 제한됩니다. 확장은 `core.user.upload_avatar_rules` 훅으로 검증 규칙을 추가할 수 있습니다. + + diff --git a/docs/backend/api/broadcasting.md b/docs/backend/api/broadcasting.md new file mode 100644 index 00000000..a2ee94ce --- /dev/null +++ b/docs/backend/api/broadcasting.md @@ -0,0 +1,56 @@ +# Broadcasting API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Broadcasting 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### POST /api/broadcasting/auth + +- **라우트명**: `api.broadcasting.auth` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/broadcasting/auth HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** WebSocket 프라이빗/프레즌스 채널 구독 시 Laravel Broadcast 채널 인증을 수행하는 엔드포인트입니다. `auth:sanctum` 토큰으로 인증하며, 웹소켓 사용이 OFF(`broadcasting.default === 'null'`)이면 채널 인증을 거부해 403을 반환합니다(reverb.key 무력화를 우회한 직접 연결 시도까지 차단). 컨트롤러 없이 라우트 클로저가 토글 가드를 적용한 뒤 `Broadcast::auth`에 위임하며, 실시간 이벤트 구독을 위해 클라이언트 브로드캐스팅 라이브러리가 자동 호출합니다. + + diff --git a/docs/backend/api/changelog.md b/docs/backend/api/changelog.md new file mode 100644 index 00000000..d903423a --- /dev/null +++ b/docs/backend/api/changelog.md @@ -0,0 +1,73 @@ +# Changelog API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Changelog 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/admin/changelog + +- **라우트명**: `api.admin.changelog` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LicenseController@changelog` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/changelog HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| content | string | `# Changelog 이 프로젝트의 모든 주요 변경사항을 기록합니…` | 본문 내용 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "content": "# Changelog\n\n이 프로젝트의 모든 주요 변경사항을 기록합니다.\n형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,\n[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.\n\n## [7.0.2] - 2026-07-05\n\n### Added\n\n- 설치된 모듈·플러그인이 늘어날수록 화면마다 개별로 내려받던 확장 스크립트·스타일 요청을 종류별로 하나로 묶어 내려받도록 개선해, 확장이 많은 사이트의 초기 로딩 요청 수를 크게 줄였습니다. 확장을 설치·활성화·비활성화하면 묶음이 자동으로 다시 만들어져 항상 현재 활성 상태와 일치합니다.\n\n### Changed\n\n- 화면·데이터를 내려받을 때의 전송 용량을 줄여 초기 로딩과 페이지 이동 속도를 개선했습니다.\n - 모듈·플러그인의 묶음 스크립트·스타일이 지금까지 압축 없이 전송되던 것을 압축 전송하도록 바꿔, 확장이 많은 사이트에서 첫 화면에 받는 용량을 크게 줄였습니다.\n - 한글 등 다국어 텍스트가 응답에 불필요하게 부풀려 담기던 것을 원래 형태 그대로 담도록 바꿔, 특히 다국어 번역 데이터의 전송 용량을 줄였습니다.\n - 화면을 그리는 데 쓰이지 않는 개발용 설명 주석을 화면 정보 응답에서 빼도록 해 응답 용량을 추가로 줄였습니다.\n\n### Fixed\n\n- 코어 업데이트 시 레이아웃 편집기·개발자 진단도구 번들의 빌드 설정 파일이 갱신 대상에서 누락되던 문제를 수정했습니다. 이제 코어를 업데이트하면 이 파일들도 최신 내용으로 반영됩니다.\n- 설치 마법사에서 Vendor 설치 방식을 \"Composer 실행\"으로 선택해 설치할 때, 일부 Windows 환경에서 Composer 설치 로그의 문자 인코딩이 어긋나면 진행 상태 응답이 비어 진행 상황이 표시되지 않고 오류가 반복될 수 있던 문제를 보완했습니다. 어떤 문자가 섞이더라도 진행 로그가 항상 정상적으로 전달되도록 처리했습니다. (#62 @kitrio 님께서 제보해주셨습니다.)\n- 설치 마법사의 \"설치 환경 확인\" 단계에서, storage 폴더 하위에 파일이 많이 쌓인 환경일수록 요구사항 검사가 오래 걸리던 문제를 개선했습니다. 폴더 쓰기 권한 검사가 불필요하게 깊은 하위까지 훑던 것을 필요한 범위로 제한해 이 단계가 즉시 완료되도록 했습니다.\n- 설치 시 \"읽기 전용 데이터베이스(Read DB)를 사용하지 않음\"으로 선택했는데도, 읽기 전용 DB 접속 정보를 확인하려다 설치 마지막 단계에서 실패하던 문제를 수정했습니다. 이제 읽기 전용 DB를 사용하지 않으면 쓰기 DB 정보만으로 설치가 정상 완료됩니다. 아울러 설치 이후에도 읽기 전용 DB 접속 정보를 비워 두면 조회 연결이 자동으로 쓰기 DB로 대체되도록 하여, 별도의 읽기 전용 DB 없이도 안정적으로 동작합니다. (#63 @kitrio 님께서 제보해주셨습니다.)\n\n## [7.0.1] - 2026-07-03\n\n### Changed\n\n- 첫 화면 접속 시 내려받는 코어 스크립트 용량을 절반 이하로 줄여 초기 로딩을 개선했습니다. 레이아웃 편집기와 개발자 진단도구를 코어에서 분리해, 편집기는 편집 화면에 들어갈 때만, 진단도구는 디버그 모드를 켰을 때만 내려받도록 했습니다. 일반 사용자는 평소 쓰지 않는 편집기·진단도구 코드를 더 이상 받지 않습니다.\n- 설치가 끝난 사이트에서 매 요청마다 반복되던 불필요한 데이터베이스 조회를 줄여 페이지 응답 속도를 개선했습니다. 알림·본인인증 설정을 준비하는 과정에서 이미 존재가 보장된 테이블을 매번 다시 확인하던 동작을 설치 완료 상태에서는 건너뛰도록 했습니다.\n- 화면을 그릴 때마다 서버가 매번 확장 기능의 연결 정보를 처음부터 다시 계산하던 부담을 줄여 페이지 응답 속도를 개선했습니다. 이 연결 정보는 확장을 설치·수정하거나 코어를 업데이트할 때만 바뀌므로 그 시점에 한 번 저장해 두고, 이후 요청에서는 저장된 값을 재사용하도록 했습니다. 확장이나 코어를 변경하면 저장 값이 자동으로 갱신됩니다.\n- 설치된 모듈·플러그인의 코드를 찾을 때마다 서버가 파일을 하나하나 탐색하던 부담을 줄여, 특히 공유 호스팅 등 파일 접근이 느린 환경에서 페이지 응답 속도를 개선했습니다. 확장 코드의 위치를 설치·수정 시점에 미리 정리해 두고 이후 요청에서는 그 목록을 바로 참조하도록 했습니다. 확장을 설치·수정·삭제하거나 코어를 업데이트하면 목록이 자동으로 갱신됩니다.\n- 코어 업데이트 중 데이터 변환 단계가 현재 환경에서 자동으로 끝까지 실행되지 못하고 중단될 때, 남은 단계를 이어서 실행하는 안내를 실행 권한에 맞게 개선했습니다. 특히 관리자 권한(sudo)으로 업데이트한 경우, 남은 단계를 관리자 권한 그대로 실행하면 그 과정에서 만들어지는 파일이 관리자 소유로 남아 이후 웹사이트가 그 파일에 쓰지 못하는 문제가 생길 수 있습니다. 이제 이런 경우 웹 서버 실행 계정(예: www-data)으로 실행하도록 정확한 명령을 함께 안내하며, 웹 서버 계정을 자동으로 확인하지 못하면 어떤 계정으로 실행해야 하는지 확인 방법을 안내합니다.\n\n### Fixed\n\n- Windows 환경에서 코어 업데이트가 `public/storage` 링크를 복사하는 단계에서 \"디렉토리는 복사할 수 없습니다\" 오류로 중단되던 문제를 수정했습니다. Windows의 저장소 링크(정션)가 이제 링크 그대로 안전하게 보존되어 업데이트가 정상 완료됩니다.\n- 별도 데이터 변환 작업이 필요 없는 버전으로 코어를 업데이트할 때, \"실행할 업그레이드 단계가 없다\"는 정상 상황을 실패로 표시하고 중단하던 문제를 수정했습니다. 이제 변환 작업이 없는 업데이트는 매끄럽게 완료되며, 이어서 설치된 확장의 최신 번들 반영 단계까지 정상적으로 진행됩니다.\n- 일부 호스팅 환경에서 관리자 환경설정의 보안 화면에 들어갈 때 시스템 정보 조회가 전체 실패(오류 응답)하던 문제를 수정했습니다. 이제 CPU·메모리·디스크 등 특정 항목을 서버 정책상 읽을 수 없더라도 그 항목만 \"알 수 없음\"으로 표시하고 나머지 시스템 정보는 정상적으로 보여줍니다. 읽지 못한 항목은 로그로 원인을 확인할 수 있습니다. (#59 @glitter-gim 님께서 제보해주셨습니다.)\n\n## [7.0.0] - 2026-07-01\n\n### Added\n\n- 모듈·플러그인이 기존 화면의 초기화 동작에 자신의 초기화 단계를 덧붙일 수 있도록 레이아웃 확장 기능을 확대했습니다. 화면이 특정 모듈을 직접 알지 않아도, 모듈이 설치되어 있을 때만 그 모듈의 초기화(예: 표시 통화 복원)가 함께 실행됩니다.\n\n- 관리자 대시보드 통계에 설치된 템플릿 수(활성/전체)와 활성 언어팩 수(활성/전체)를 추가했습니다.\n- 관리자 대시보드에 최근 발송된 알림 이력을 보여주는 \"최근 알림\" 위젯용 조회 기능을 추가했습니다. 발송 채널·수신자·제목·상태(성공/실패/건너뜀)·발송 시각을 최신순으로 확인할 수 있습니다.\n- 본인인증 challenge 취소(cancel) 와 verification 토큰 소비(consume_token) 시점에 외부 본인인증 플러그인이 자기 데이터 정리 로직을 등록할 수 있는 훅 4건 추가.\n- 플러그인 개발용 표준 베이스 클래스를 추가하여, 모듈 개발과 동일한 방식으로 Repository · 캐시 · 스토리지 의존성을 선언만으로 자동 주입받을 수 있도록 개선. 플러그인이 코어와 같은 표준 위에서 작성되어 일관된 캐시·파일 격리 동작을 제공합니다.\n- 모듈·플러그인이 페이지에 주입하는 레이아웃 확장을 관리자 화면에서 직접 편집할 수 있도록 개선. 편집 내역은 버전으로 기록되어 이전 버전으로 복구할 수 있으며, 저장 전 미리보기를 제공합니다.\n\n#### 위지윅 레이아웃 편집기 (신규 도입)\n\n화면을 직접 보며 편집하는 위지윅 방식의 레이아웃 편집기를 이번 버전에서 새로 도입했습니다. 아래는 도입된 기능 영역별 상세입니다.\n\n- 좌측 트리 — 편집 화면 좌측 목록을 트리 구조로 개편하여 일반 레이아웃과 확장 주입 레이아웃을 구분 표시. 확장 주입 레이아웃은 해당 템플릿에 실제 적용되는 항목만 표시됩니다.\n- 편집 진입과 캔버스 — 템플릿 관리 화면에 코드 편집과 레이아웃 편집 진입을 분리하고, 운영 화면 좌측 하단에 편집 권한 보유자 전용 진입 버튼을 추가했습니다. 편집 캔버스는 실제 페이지와 동일한 렌더링 결과를 보여주며, 데스크탑·태블릿·모바일 폭을 즉시 전환해 반응형 결과를 확인할 수 있습니다. 데이터 소스가 정의된 화면은 샘플 데이터로 자동 채워져 실제 API 없이도 화면을 검토할 수 있고, 본문 영역에 사용된 위지윅 에디터 등 동적 자원도 캔버스 안에서 그대로 동작합니다. 좌측 트리에서 화면을 선택하면 주소창이 즉시 반영되어 새로고침·공유·뒤로가기 동작이 자연스럽고, 같은 주소로 직접 진입하면 해당 화면이 자동 선택됩니다. 편집 중 로그인이 풀리면 안내 후 자동으로 로그인 화면으로 이동하며 재로그인 시 편집 화면으로 복귀합니다. 권한 부족·서버 오류·네트워크 끊김 등은 상황별 안내 카드(아이콘·설명·다음 행동 버튼)로 표시합니다.\n- 요소 추가·재배치 — 컴포넌트를 클릭해 선택하면 외곽에 + 버튼이 나타나고, + 버튼을 누르면 화면 중앙에 모달 팔레트가 열려 좌측 카테고리 사이드바와 우측 그리드 카드(아이콘 + 라벨 + 유형 뱃지)에서 추가할 컴포넌트를 고를 수 있습니다. 부모 컨테이너에 들어갈 수 있는 컴포넌트만 자동 필터링되며, 검색으로 컴포넌트를 빠르게 찾을 수 있습니다. 추가한 컴포넌트는 템플릿이 정의한 기본 모양·기본 텍스트로 캔버스에 바로 보이며, 추가 직후 해당 위치로 화면이 부드럽게 스크롤됩니다. 컴포넌트 우상단 ⓘ 아이콘으로 속성 설정·복사·삭제 메뉴를 열고, 호버/선택 시 외곽선·8방향 리사이즈 핸들 자리·잠금/네비게이션 어포던스가 표시됩니다. 변경 사항은 Ctrl+Z / Ctrl+Shift+Z 또는 툴바 ↶/↷ 로 되돌리거나 다시 실행할 수 있습니다. 편집 모드에서 컴포넌트를 클릭해도 페이지 이동이 일어나지 않고 선택만 되며, 내부 라우트로 가리키는 요소는 \"→ 이 화면 편집\" 어포던스로 목적지 화면 편집 모드로 즉시 전환됩니다.\n- 저장·충돌 방지 — 낙관적 잠금으로 저장 결과는 성공·검증 실패·동시 저장 충돌·네트워크 오류 등 상황별 안내(성공 토스트 / 오류 배너 / 충돌 모달)로 즉시 표시됩니다. 같은 레이아웃을 다른 관리자가 먼저 저장했으면 충돌 안내 모달에서 최신 버전 불러오기를 선택할 수 있고, 신규 추가한 컴포넌트가 그 사이 비활성화된 모듈·플러그인 소속이면 저장이 자동 차단됩니다. 저장 직후에는 되돌리기 이력이 정리되어 저장 시점이 새 기준이 되며, 변경 사항이 있는 상태에서 다른 화면으로 이동하려 하면 저장하지 않은 내용이 있다는 안내가 먼저 표시됩니다. 로그인·회원가입 등 비로그인 전용 화면을 편집할 때는 로그인 상태 미리보기가 적용되지 않아 안내 토스트가 잘못 뜨지 않습니다.\n- 속성 편집 — 요소를 선택하면 정렬·글자 크기·굵기·색상·여백·크기 등을 친화적인 컨트롤(분절 버튼·슬라이더·색상 선택·태그 입력)로 조정할 수 있고, 요소 모서리·변을 직접 드래그해 가로·세로 크기를 바꿀 수 있습니다(드래그 크기 조정도 되돌리기/다시 실행에 반영). 반복 영역(목록처럼 같은 틀이 여러 번 펼쳐지는 자리)의 속성을 편집하면 모든 항목에 함께 적용됩니다. 요소를 편집하는 동안에는 선택한 요소만 밝게 유지되고 나머지 화면은 어둡게 잠겨, 다른 요소를 실수로 건드리지 않습니다. 속성 편집 팝업은 헤더를 잡아 옮길 수 있으며 선택한 요소를 가리지 않도록 빈 공간으로 자동 배치되고, 큰 요소에서도 요소 추가·메뉴 버튼이 크기 조절 핸들과 겹치지 않도록 여백을 둡니다. 고급 설정에서 요소를 볼 수 있는 권한을 권한 목록에서 골라 지정할 수 있습니다. 목록 있음·없음, 회원·비회원, 검증 실패·저장 중 같은 화면 상태를 미리보기로 전환해 데이터 의존 영역을 실제와 가깝게 검토할 수 있고, 같은 데이터 이름을 여러 확장이 쓰더라도 각 화면이 자기 출처의 샘플로 올바르게 표시됩니다. 라우트를 전환해도 저장하지 않은 편집 내용이 유지되며, 미저장 상태로 화면을 떠나거나 새로고침하면 경고가 표시됩니다. 마지막 저장 상태로 한 번에 되돌리는 초기화 버튼도 제공합니다.\n- 이미지 관리 — 배경 이미지 등 레이아웃 첨부 이미지를 업로드·관리할 수 있으며, 상단 툴바와 배경 이미지 컨트롤에서 썸네일 갤러리를 열어 업로드·삭제·배경 적용을 할 수 있으며, 지정한 배경 이미지가 편집 캔버스에 즉시 반영되고(채움·맞춤·타일 표시 모드 포함) 모달을 다시 열어도 현재 설정이 그대로 복원됩니다. 발행된 배경 이미지는 일반 방문자 화면에서도 정상적으로 표시됩니다.\n- 동작·표시조건·정렬 편집 — 요소를 선택하고 속성 편집 팝업에서 (1) 클릭·마우스 오버 시 동작을 \"다른 페이지로 이동\"·\"안내 메시지 보여주기\"·\"화면 상태 바꾸기\"·\"목록 새로고침\"·\"서버에 보내고 결과 처리\"·\"결제 진입\" 같은 친화 명칭으로 조립하고(서버 호출은 성공·실패 후속 동작까지 단계로 구성), (2) 로그인 회원·관리자·데이터 있음/없음·수정/새 화면·입력 오류 등 친화 조건을 \"그리고/또는\"으로 결합해 요소를 보여줄 조건을 정하며, (3) 가로·세로 컨테이너의 자식 배치(방향·정렬·줄바꿈·간격)와 정렬 박스 안 요소의 늘어남·정렬·순서를 시각 컨트롤로 편집할 수 있습니다. 핸들러·코드 용어 없이 친화 입력만 노출되며, 이동할 페이지·데이터 이름은 목록에서 선택합니다. \"결제 진입\" 동작은 특정 결제사에 고정하지 않고, 서버 응답이 알려주는 결제 수단(결제 핸들러·결제 데이터)을 데이터 검색에서 골라 연결합니다 — 결제 수단을 바꾸거나 추가해도 화면 편집 없이 동작합니다.\n- 색 모드·디바이스별 스타일 — 속성 편집에 라이트/다크 색 모드와 디바이스별 편집을 더해, 스타일·표시조건 탭 상단의 서브탭에서 라이트/다크 색 모드와 기본값·PC·태블릿·모바일(+ 직접 지정하는 커스텀 화면 폭)을 선택해, 각 범위에 스타일·정렬·표시 조건을 따로 지정할 수 있습니다. 글자색·배경색은 패널의 프리셋 색을 고르면 라이트와 다크 모두에 적용되어 다크 화면용 색을 비개발자도 지정할 수 있고(컬러 피커로 직접 고른 자유 색은 라이트 화면 전용), 어느 범위에 기본값과 다른 설정이 있으면 탭에 표시점이 뜨며 \"기본값으로 초기화\"로 그 범위만 되돌릴 수 있습니다. 편집기 상단 라이트/다크 토글로 미리보기 색상 테마를 전환하면, 관리자 화면이 다크 테마여도 미리보기가 라이트/다크를 정확히 구분해 보여줍니다.\n- 화면 문구 다국어 편집 — 상단 툴바의 콘텐츠 언어 선택으로 미리보기에 표시되는 화면 문구의 언어를 전환할 수 있고, 캔버스의 텍스트를 더블클릭하면 그 자리에서 바로 고칠 수 있습니다(Enter 로 확정, Esc 로 취소). 문구를 고치면 그 문구가 다국어 키로 자동 등록되어, 입력한 내용이 즉시 화면에 반영됩니다. 편집 중 떠 있는 서식 막대로 굵게·기울임·밑줄을 켜고 끄거나, 글자 크기·정렬·글자색을 목록에서 골라 적용할 수 있으며(색은 색상 미리보기와 함께 선택), 바뀐 서식은 편집 중에도 즉시 보입니다. 편집 안내 배지를 누르면 속성 편집의 번역 탭이 열려 모든 언어의 값을 한 번에 입력할 수 있어, 한 언어만 고치고 다른 언어 번역을 놓치는 일을 줄입니다. (데이터에 연결된 문구·반복 영역 문구는 직접 편집 대상에서 제외됩니다.)\n- 모든 컴포넌트로 속성 편집 확대 — 어떤 요소를 선택해도 속성 탭에 \"요소 ID\" 칸이 항상 제공되어 화면 내 요소에 고유 식별자를 부여할 수 있으며(화면 안전 문자만 입력되고 한글·공백 등은 자동으로 걸러집니다), 컴포넌트별 편집 항목(링크 주소·이미지 주소·입력칸 종류·선택지·정렬 옵션 등)도 친화 컨트롤로 편집할 수 있습니다. 아이콘은 자유 입력 대신 검색 가능한 그리드에서 골라 지정할 수 있어(템플릿이 제공하는 아이콘 목록을 검색으로 빠르게 찾기), 아이콘 이름을 외우지 않아도 됩니다. 입력칸의 선택지처럼 여러 항목으로 된 값은 항목 추가·삭제·순서 변경으로 편집하며, 이미 데이터에 연결된 값은 실수로 덮어쓰지 않도록 보호됩니다.\n- 모달 시각 편집 — 좌측 트리에 각 화면이 사용하는 모달 목록이 \"이 화면의 모달\" 그룹으로 표시되고(공통 레이아웃의 모달, 모듈·플러그인 화면의 모달, 별도 파일로 분리된 모달 포함), 각 모달은 \"주문 취소\"·\"비회원 조회 비밀번호 재설정\"처럼 친화 제목으로 표시됩니다. 모달을 선택하면 실제 화면 위에 모달이 열린 상태로 문구 수정·요소 추가·저장을 할 수 있습니다. 모달 바깥 영역은 어둡게 잠겨 클릭·드래그·편집이 차단되어 실수로 호스트 화면을 건드리지 않습니다.\n- 버전 배지·복원 — 좌측 트리에 각 화면과 확장 주입 조각의 현재 저장 버전 배지가 표시되고, 버전 기록 모달에서 이전 버전과의 비교(diff)와 복원을 확장 주입 조각에서도 동일하게 사용할 수 있으며, 저장·복원 시 배지가 새로고침 없이 즉시 갱신되고 복원 결과가 캔버스에 바로 반영됩니다.\n- 공통 레이아웃 편집 — 각 화면의 콘텐츠가 채워질 슬롯 자리가 점선 박스와 라벨로 표시되고(슬롯 자체는 편집 대상이 아니라 잠금), 헤더·푸터를 클릭해 바로 선택·편집할 수 있습니다. 모달 편집 중에도 편집 캔버스의 스크롤이 그대로 유지됩니다.\n- 화면 상태 미리보기 — 모달·공통 레이아웃까지 확대해, 배송지 직접 입력, 모바일 메뉴 펼침, 쿠키 배너 표시, 본인인증 진행 모달처럼 특정 조작에서만 나타나는 영역을 상태 전환만으로 미리보며 편집할 수 있고, 모달 안에 확장이 주입한 영역도 시각 편집할 수 있습니다.\n- 일부 템플릿(사용자 템플릿 등)의 화면을 미리볼 때 헤더가 \"데이터를 표시할 수 없습니다\"로 표시되지 않고, 미리보기용 샘플 데이터가 모든 템플릿에서 안전하게 렌더됩니다.\n\n- 회원이 아닌 사용자(비회원)에게도 이메일 알림을 보낼 수 있도록 알림 시스템을 확장했습니다. 회원에게 알림을 보내던 것과 동일한 방식으로, 입력한 이메일을 수신자로 알림이 발송되며 발송 내역에도 함께 기록됩니다. 알림 종류별로 비회원 발송을 허용할 수단을 선택할 수 있어, 이메일은 비회원에게 보내되 사이트 안 알림처럼 로그인이 필요한 수단은 보내지 않도록 구분됩니다(새 알림 수단이 추가될 때 비회원 허용 여부를 별도로 정하지 않으면 기본적으로 보내지 않습니다). 비회원에게 가는 알림은 그 사용자가 보던 화면 언어로 발송됩니다.\n\n### Changed\n\n- 모든 번들 플러그인이 위 표준 베이스 클래스로 이관되어 캐시·스토리지 도메인이 코어 및 다른 확장과 명확히 격리되도록 개선. 한 플러그인의 캐시 키가 코어 캐시 영역을 침범하던 잠재 결함을 차단하며, 관리자 화면의 변경사항이 의도대로 즉시 반영됩니다.\n- 템플릿이 정의하는 에러 페이지(404 / 403 / 500 / 503 / 401 / 점검) 의 식별자를 다른 모든 페이지와 동일한 디렉토리 접두사 포함 형식(`errors/{코드}`) 으로 통일. 템플릿 설치 시 에러 페이지가 일관된 식별자로 시드되며, 일부 환경에서 에러 페이지가 \"찾을 수 없음\" 응답으로 떨어지던 회귀를 차단합니다. 번들 템플릿(`sirsoft-basic`, `sirsoft-admin_basic`) 의 에러 페이지 설정도 함께 정합화됩니다.\n- 템플릿 외부 리소스 위임 — Font Awesome·웹폰트 등 외부 CDN 리소스를 페이지 코드에서 하드코딩하지 않고 템플릿의 `externals` 선언으로 위임. 외부 스타일시트·웹폰트·스크립트와 preconnect·preload 등 리소스 힌트를 한 곳에서 관리하며, 관리자 템플릿과 사용자 템플릿에 동일하게 적용\n- 템플릿 컴포넌트 에셋 캐시 버스팅 — `?v=time()` → `?v={{ $extensionCacheVersion }}`로 변경하여 매 요청 무효화 → 확장 변경 시점에만 무효화\n- 모듈·플러그인 업데이트 시 관리자가 편집한 확장 레이아웃을 보존하거나 덮어쓰는 전략을 선택할 수 있도록 개선.\n- 코어 업그레이드 스텝 명령에 보조 단계(권한 정상화·마이그레이션·캐시 정리 등)를 건너뛰고 스텝만 실행하는 옵션 추가.\n- 코어 영역의 프론트엔드 다국어 자원(에러 메시지·시스템 토스트 등) 위치를 정리하여 어떤 템플릿이 부팅되어도 자동 노출되도록 개선. 이전에 코어 에러 화면에서 `core.errors.*` 같은 키가 raw 로 노출되던 결함이 해소됩니다.\n\n### Fixed\n\n#### 본인인증(IDV)\n\n- 정책 관리에서 코어·모듈·플러그인이 제공한 정책의 \"인증 목적\"·\"적용 대상\"·\"우선순위\"를 바꿔 저장해도 반영되지 않던 문제를 수정했습니다 — 이제 정책 키·강제 시점·강제 위치(정책이 걸리는 지점)만 고정되고, 그 외 모든 항목을 운영자가 자유롭게 변경할 수 있으며, 변경한 값은 확장 업데이트 후에도 유지됩니다.\n- 한 화면에 본인인증 정책이 여러 개 걸리고 우선순위가 같을 때, 어느 정책이 먼저 적용될지 정해지지 않아 의도한 인증(예: 성인 인증)이 무시되고 다른 인증이 적용될 수 있던 결함을 수정했습니다. 이제 우선순위가 같아도 항상 동일한 정책이 적용되도록 적용 순서를 고정하고, 정책 추가·수정 화면에 우선순위 입력칸을 제공하며 같은 위치에 같은 우선순위의 활성 정책을 저장하려 하면 저장을 막고 안내합니다. 정책 목록에도 우선순위 열을 추가해 한눈에 확인할 수 있습니다.\n- 성인 인증 같은 추가 조건을 함께 요구할 때, 그 조건을 충족하지 못해 인증이 실패하면 실패 사유 안내와 \"본인 확인이 필요합니다\" 안내가 동시에 뜨던 문제를 수정했습니다. 이제 추가 조건 미충족으로 실패하면 그 사유 안내 하나만 표시되며, 일반 본인인증 실패의 안내는 종전대로 표시됩니다.\n- 환경설정의 기본 프로바이더 / 목적별 프로바이더 / 코드 유효시간 / 최대 시도 횟수 4개 항목이 admin UI 저장값을 무시하던 결함 수정. 변경한 환경설정값이 회원가입·비밀번호 재설정 등 모든 본인인증 흐름에 즉시 반영됩니다.\n- 환경설정 > 본인인증 > 목적별 프로바이더에서 일부 본인인증 목적(예: 성인인증)의 프로바이더를 지정해 저장하면 \"설정 저장에 실패했습니다\" 오류로 전혀 저장되지 않던 결함 수정. 이제 모든 본인인증 목적의 프로바이더 매핑이 정상 저장됩니다. 아울러 저장 검증에 실패하면 문제가 된 목적별 프로바이더 입력칸이 빨갛게 강조되고 그 아래에 사유가 표시되어, 어느 항목을 고쳐야 하는지 바로 확인할 수 있습니다.\n- 정책에 프로바이더가 지정되지 않은 경우 환경설정의 기본 프로바이더가 자동 적용되도록 보강. 운영자가 정책마다 프로바이더를 일일이 지정하지 않아도 환경설정 기본값으로 일관되게 동작합니다.\n- 코드 입력 모달의 \"남은 시도 횟수\" 표시가 환경설정값과 무관하게 항상 5회로 굳어 있던 결함 수정. 운영자가 환경설정에서 지정한 한도가 사용자 화면에 정확히 표시됩니다.\n- 인증 코드를 최대 횟수까지 잘못 입력해 시도가 소진되었을 때, 화면에 \"최대 시도 초과 — 다시 요청해주세요\" 안내 대신 일반 오류 문구(\"인증 코드가 올바르지 않습니다\")가 표시되던 결함 수정. 시도가 소진되면 재요청을 안내하는 메시지가 정확히 표시됩니다.\n- 본인인증을 사용하는 모듈·플러그인을 비활성화해도 해당 확장이 설정한 본인인증 요구가 계속 적용되던 결함 수정. 이제 확장을 비활성화하면 그 확장이 선언한 본인인증 정책도 함께 적용 해제되고, 다시 활성화하면 운영자가 조정한 설정을 유지한 채 정상 복구됩니다.\n\n#### 확장 동기화·캐시·레이아웃 주입\n\n- 확장 메뉴 동기화 시점에 운영자가 수정하지 않은 필드 (아이콘·이름·정렬·URL) 가 잘못 \"사용자 수정\" 으로 표시되어 이후 모듈/플러그인 정의값으로의 자동 동기화가 영구히 차단되던 결함 수정. 확장 재설치·업데이트 시 메뉴 정의 변경이 정상 반영됩니다.\n- 확장 역할 동기화 시점에 운영자가 수정하지 않은 필드 (역할 이름·설명) 가 잘못 \"사용자 수정\" 으로 표시되어 이후 모듈/플러그인 정의값으로의 자동 동기화가 영구히 차단되던 결함 수정. 메뉴 동기화와 동일 맥락으로, 확장 재설치·업데이트 시 역할 정의 변경이 정상 반영됩니다.\n- 모듈·플러그인의 레이아웃 확장이 대상이 아닌 템플릿(관리자 확장 ↔ 사용자 템플릿)에도 등록되던 결함 수정. 업그레이드 시 잘못 등록된 항목을 자동 정리하며, 이후로는 대상 레이아웃이 존재하는 템플릿에만 등록됩니다.\n- 모듈·플러그인이 공통 레이아웃(헤더·푸터 등 모든 화면이 공유하는 틀)에 주입한 UI·초기화 동작이 실제 화면에는 전혀 나타나지 않던 결함 수정. 개별 화면이 공통 레이아웃을 물려받을 때 그 \"물려받은 출처\" 정보가 사라져, 공통 레이아웃을 대상으로 한 확장 주입이 어느 화면에도 적용되지 않았습니다. 이제 화면이 물려받은 공통 레이아웃을 대상으로 한 확장도 정상 적용되어, 모듈이 설치되어 있으면 해당 UI(예: 헤더 통화 선택기)와 초기화 동작이 모든 관련 화면에 표시됩니다.\n- 확장(모듈·플러그인) 변경 후 라우트 트리·메뉴·다국어 응답이 옛 값으로 남아 있던 결함 수정. 이전에는 클라이언트가 캐시 버전 신호를 동반하지 않아 백엔드 캐시가 갱신 시점을 놓쳤습니다. 확장 설치/활성화/업데이트가 일어나면 다음 페이지 로드부터 새 값이 즉시 보입니다.\n- 확장을 업데이트하거나 레이아웃을 저장해도 변경 내용이 화면에 반영되지 않고 옛 내용이 계속 보이던 결함 수정. 캐시 갱신 신호가 기록되는 저장소와 읽히는 저장소가 환경설정에 따라 어긋날 수 있어, 올린 갱신 신호가 화면 쪽에서 보이지 않던 문제였습니다. 이제 갱신 신호가 항상 같은 저장소로 일관되게 기록·조회되어 확장 업데이트·레이아웃 변경이 다음 화면 로드부터 즉시 반영됩니다.\n- 코어 업데이트 시 사용자가 모듈·플러그인·템플릿·언어팩의 `_bundled` 폴더 아래에 직접 만들어 둔 커스텀 폴더·파일이 함께 삭제되던 결함 수정. 이제 코어 업데이트는 코어가 제공하는 번들 확장만 갱신하고, 사용자가 추가한 항목은 그대로 보존합니다. (#43 @bigmsg 님께서 제보해주셨습니다.)\n- 관리자 권한(`sudo`)으로 코어를 업데이트한 뒤, 모듈·플러그인·템플릿·언어팩을 업데이트하려 하면 \"백업 생성 중\" 단계에서 `mkdir(): Permission denied` 오류로 실패하던 결함 수정. 관리자 권한 업데이트가 백업 폴더와 업그레이드 기록 파일을 관리자 소유로 남겨, 이후 웹서버 계정이 같은 위치에 쓰지 못하던 문제였습니다. 이제 이들 폴더·파일의 소유권과 그룹 쓰기 권한이 정상화되고, 코어와 확장의 업그레이드 기록을 별도 파일로 분리해 두 작업이 서로의 기록 파일을 두고 충돌하지 않습니다.\n\n#### 알림\n\n- 회원에게 보내는 알림(이메일·사이트 알림)이 보내는 사람(관리자 등)의 화면 언어가 아니라 받는 회원 본인의 언어로 발송되도록 수정. 관리자가 한국어 화면에서 다른 언어를 쓰는 회원의 주문 상태를 바꿔도, 알림은 그 회원의 언어로 전달됩니다.\n- 신규 주문 접수 시 관리자에게 발송되는 알림 메일의 결제금액이 0원으로 표시되던 결함 수정. 결제 예정 금액이 정확히 표시됩니다.\n- 알림 템플릿을 기본값으로 복원할 때 제목·본문만 복원되고 수신자·클릭 주소는 관리자가 수정한 값이 그대로 남던 결함 수정. 이제 복원 시 수신자·클릭 주소까지 기본값으로 정확히 되돌아갑니다.\n\n#### 설치·인스톨러\n\n- 설치 마법사에서 데이터베이스 테이블 접두사를 지나치게 길게 입력하면 일부 확장 설치 시 테이블 인덱스 이름이 데이터베이스 한도를 넘겨 설치가 실패할 수 있던 문제를 예방합니다 — 접두사 입력 길이를 6자로 제한하고, 한도를 넘기면 설치 전 단계에서 안내합니다.\n- 인스톨러에서 \"기존 테이블 삭제 후 설치\"를 선택할 때, 입력한 테이블 접두사와 무관한 다른 테이블까지 모두 삭제되던 결함 수정. 이제 입력한 접두사로 시작하는 테이블만 삭제되어, 같은 데이터베이스를 다른 프로그램과 함께 쓰거나 다른 접두사로 재설치할 때 무관한 테이블이 보존됩니다.\n\n#### 관리자 화면·검색·시스템·보안\n\n- 관리자 대시보드의 최근 활동·모듈/플러그인/템플릿 상태·게시판/이커머스 위젯 등 같은 틀이 여러 번 펼쳐지는 목록에서, 항목마다 고유 식별자를 붙이도록 작성된 화면이 모든 행에 같은 식별자를 그대로 내보내 일부 보조 기능·접근성 도구가 항목을 구분하지 못하고 일부 항목이 화면에서 누락되어 보이던 결함을 수정했습니다 — 이제 반복 목록의 각 행이 고유하게 식별되어 모든 항목이 정상 표시됩니다.\n- 한 화면 안에서 탭이나 필터를 클릭으로 전환할 때, 화면에 따라 표시 조건에 묶인 데이터 목록이 비어 보이던 결함 수정. 새로고침해야 보이던 목록이 이제 탭·필터 전환 즉시 정상 표시됩니다. (표시 조건에 따라 로딩되는 데이터 목록이 전환 시점에 다시 평가되지 않던 문제였습니다.)\n- 환경설정 화면 등에서 별도 표시 텍스트 없이 켜고 끄는 토글·체크박스를 켜서 저장하면 \"참/거짓 값이어야 합니다\" 오류로 저장이 거부되던 결함 수정. 토글의 켜짐/꺼짐 상태가 문자열이 아닌 참/거짓 값으로 정확히 전달되어 정상적으로 저장됩니다.\n- 관리자 목록 화면에서 필터(발급상태·사용여부 등)를 선택하고 검색하면 일부 필터가 검색 직후 \"전체\"로 풀려 보이던 결함 수정. 새로고침해야 선택한 필터가 다시 표시되던 불편을 없애고, 검색 직후에도 선택한 필터가 그대로 유지됩니다.\n- 검색창에 `<`, `>`, `(`, `+` 등 특수문자를 입력하면 오류가 발생하던 결함 수정. 특수문자는 일반 검색어로 처리되어, 어떤 문자를 입력해도 오류 없이 검색 결과(또는 빈 결과)가 표시됩니다.\n- IME(한글·일본어·중국어) 입력 도중 Enter 로 글자를 조합 확정할 때 마지막 글자가 누락되거나 검색·제출이 두 번 실행되던 결함 수정. 이제 조합이 끝난 뒤의 Enter 만 동작합니다. (#54 @devrhee16 님께서 제보해주셨습니다.)\n- 관리자 대시보드의 시스템 리소스(CPU·메모리·디스크) 수집이 일부 호스팅 환경(open_basedir 제한, 일부 시스템 함수 비활성)에서 실패할 때 대시보드 리소스 조회 전체가 오류로 응답하던 결함 수정. 이제 수집할 수 없는 항목만 \"알 수 없음\"으로 표시되고 나머지 화면은 정상 동작합니다. (#40 @glitter-gim 님께서 제보해주셨습니다.)\n- 시스템 정보 화면의 디스크 사용량 조회도 동일한 환경에서 안전하게 \"알 수 없음\"으로 표시되도록 보강했습니다.\n- 웹소켓 사용을 끈 상태에서도 실시간 연결이 일부 동작할 수 있던 문제 수정. 이제 웹소켓 사용을 끄면 실시간 연결·채널 인증이 완전히 비활성화됩니다. (#50 @bigmsg 님께서 제보해주셨습니다.)\n- 사이트 이름을 언어별로 다르게 저장한 환경에서 브라우저 탭 제목과 검색 메타데이터(JSON-LD)가 올바르게 표시되지 않거나 페이지가 깨질 수 있던 문제 수정. 이제 모든 메타 경로가 현재 언어의 사이트 이름을 일관되게 사용합니다. (#49 @glitter-gim 님께서 제보해주셨습니다.)\n- 존재하지 않는 페이지에 접근할 때 정상 페이지처럼 200 응답을 반환하던 문제 수정. 이제 미등록 경로는 올바른 404 응답을 반환해 검색엔진이 없는 페이지를 색인하지 않습니다. (#47 @glitter-gim 님께서 제보해주셨습니다.)\n- 인증이 필요한 관리자 API에 로그인 없이 접근할 때, 브라우저 주소창 직접 입력 등 일부 요청에서 서버 오류로 응답하던 결함 수정. 이제 어떤 방식으로 접근해도 일관되게 \"인증 필요\" 응답이 반환됩니다. (#39 @glitter-gim 님께서 제보해주셨습니다.)\n\n### Security\n\n- 사용되지 않는 파일 서빙 엔드포인트(`/storage` 직접 업로드·다운로드 경로) 노출을 제거했습니다. 실제 파일 업로드·다운로드는 별도의 인증된 경로로 처리되며 영향이 없습니다. (#52 @glitter-gim 님께서 제보해주셨습니다.)\n- 설치가 완료된 시스템에서 설치 상태 관리 엔드포인트(`/install/api/state-management.php`)가 다른 설치 API 와 동일하게 차단되지 않아, 설치 완료 후에도 설치 상태 조회·초기화·중단 요청이 가능하던 문제를 수정했습니다. 설치가 완전히 끝난 환경에서는 해당 요청이 모두 HTTP 410 으로 차단됩니다. 설치 진행 중 완료 화면으로 정상 전환되는 동작은 영향받지 않습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1056)\n\n### Removed\n\n- 기본 제공 결제 수단을 KG이니시스로 단일화했습니다. 그 외 결제 연동(나이스페이먼츠·NHN KCP·토스페이먼츠)은 기본 번들에서 제외되며, 필요한 경우 별도 플러그인으로 설치해 사용할 수 있습니다. 이미 해당 결제 연동을 설치해 사용 중인 사이트는 영향받지 않습니다.\n\n## [7.0.0-beta.7] - 2026-05-15\n\n### Fixed\n\n- 인스톨러 설치 완료 후 `.env` 에 DB 자격증명과 `APP_KEY` 가 기록되지 않고 `storage/installer/runtime.php` 에 평문으로 잔존하던 결함을 수정. 운영 동작은 정상이었으나 자격증명이 정상적인 위치로 옮겨지지 않고 임시 파일에 보존되는 상태였습니다. (#37 @glitter-gim 님께서 제보해주셨습니다.)\n- beta.4~beta.6 출시본에서 위 결함으로 인해 자격증명이 잔존한 환경을 beta.7 업그레이드 시 자동으로 감지하여 `.env` 로 머지하고 임시 파일을 정리하도록 업그레이드 스텝을 추가했습니다. `.env` 의 `INSTALLER_COMPLETED` 마커가 이미 설정된 정합 이상 케이스는 자동 정리 대상에서 제외하고 운영자 수동 검토를 안내합니다.\n- 위 업그레이드 스텝이 `.env` 를 머지할 때 권한·소유자·그룹을 웹 서버 기준으로 정렬하도록 보강. 기존 `.env` 가 있던 환경에서는 운영자가 설정한 권한을 그대로 보존하고, 새로 생성되는 환경에서는 웹 서버가 읽을 수 있는 권한과 그룹을 자동으로 적용합니다. 명령줄 업그레이드 실행 사용자와 웹 서버 사용자가 다른 자체 구축 서버 환경에서 머지 직후 웹 서버가 `.env` 를 읽지 못해 사이트가 다운되는 회귀를 차단합니다.\n- 인스톨러 설치 완료 화면의 `.env` 권한 강화 안내(`chmod`/`chgrp` 명령 예시) 가 Windows 환경에서 비노출되도록 OS 가드 추가. POSIX 권한 모델이 없는 Windows 환경에서 무의미한 Linux/macOS 전용 명령이 노출되어 운영자 혼란을 유발하던 문제를 수정합니다.\n- beta.4~6 출시본의 인스톨러 결함으로 자격증명이 `storage/installer/runtime.php` 에 잔존한 환경에서 beta.7 업그레이드 시 번들 확장 일괄 업데이트 단계가 `Access denied for user 'root'@'localhost'` 로 실패하던 회귀 수정. 업그레이드 스텝이 `.env` 머지 직후 부모 프로세스의 환경 변수도 함께 갱신하여 후속 자식 프로세스가 정상 자격증명을 상속받도록 보강합니다. 정상 설치 환경 (beta.3 등) 에서는 트리거되지 않습니다.\n- 위 동일 회귀의 추가 차단 — 코어 업데이트가 별도 프로세스로 업그레이드 스텝을 실행하는 경로에서는 자식 종료 후 부모의 환경 변수가 갱신되지 않아 동일 증상이 재발하던 결함 수정. 자식 프로세스가 부팅 시점에 디스크의 `.env` 와 자신의 환경 변수를 대조하여 손상이 감지되면 디스크 값으로 보정하도록 보강하여, 다음 단계의 확장 일괄 업데이트가 정상 자격증명으로 진행되도록 합니다. 정상 설치 환경에서는 자동 비활성화됩니다.\n\n### Changed\n\n- `INSTALL.md` 에 \"업그레이드\" 섹션을 신설하여 자동 업데이트의 표준 절차, `sudo` 권장 사유 (`.env` · `bootstrap/cache` 의 권한 모델), 사용자군별 단계적 업그레이드 분기, 권한·캐시 트러블슈팅을 한곳에서 안내하도록 보완. 관리자 환경설정 > 정보 > 업데이트 확인 모달이 본 섹션으로 외부 링크를 노출합니다. (#30 @yks118 님께서 건의해주셨습니다.)\n- 관리자 환경설정 > 정보 > 업데이트 확인 모달의 자동 업데이트 명령어 안내를 `sudo php artisan core:update` 권장 형태로 변경. 권한이 일치하지 않는 환경(`.env` 가 웹 서버 사용자 소유) 에서의 실행 실패를 사용자가 사전에 회피할 수 있도록 모달 하단에 \"전체 업그레이드 가이드 보기\" 외부 링크를 추가해 INSTALL.md 의 업그레이드 섹션으로 연결합니다. (#30 @yks118 님께서 제보해주셨습니다.)\n\n### Notes — `.env` 권한 권장안\n\n위 업그레이드 스텝은 `.env` 가 이미 존재하는 환경에서는 권한을 임의로 변경하지 않고 기존 상태를 그대로 보존합니다. 보안 위생 강화를 원하시면 다음 권장안을 참고하세요.\n\n- 가장 안전한 권한은 `0600` (소유자만 읽기·쓰기) 입니다. `.env` 는 DB 비밀번호와 `APP_KEY` 등 평문 자격증명을 포함하므로 외부 접근을 최소화하는 것이 권장됩니다.\n- 단, `0600` 적용 전에 **`.env` 의 소유자가 웹 서버 실행 사용자와 동일해야** 합니다 (예: 단일 사용자 환경의 `chown www-data:www-data .env && chmod 600 .env`). 소유자가 웹 서버 사용자와 다른데 `0600` 으로 변경하면 웹 서버가 `.env` 를 읽지 못해 사이트가 응답하지 않습니다.\n- 명령줄 사용자와 웹 서버 사용자가 다른 환경 (자체 구축 서버 등) 에서는 `0640` 권한 + 그룹을 웹 서버 그룹으로 설정 (`chgrp www-data .env && chmod 640 .env`) 하는 것이 안전한 절충안입니다. 웹 서버는 그룹 멤버로 읽고, 명령줄 사용자는 직접 수정할 수 있습니다.\n\n## [7.0.0-beta.6] - 2026-05-14\n\n### Fixed\n\n- 코어 업데이트 자동 롤백 시 신 버전이 추가한 신규 파일이 활성 디렉토리에 잔존하여 `BindingResolutionException` 등 부정합 부팅 실패가 발생하던 결함 수정. 백업 디렉토리에 신규 파일 manifest 를 기록하고 롤백 시 정확히 그 목록만 정리합니다. 사용자가 코어 영역에 직접 추가한 파일과 모든 symlink (`public/storage` 등) 는 보존됩니다. (#34 @bigmsg 님께서 제보해주셨습니다.)\n- 자동 롤백 후 `bootstrap/cache` 잔존 PHP 캐시로 인한 추가 부팅 실패 차단 — 롤백 직후 캐시를 자동으로 정리합니다.\n- beta.5 이전 자동 롤백으로 인해 활성 디렉토리에 잔존했을 수 있는 ServiceProvider 후보를 beta.6 업그레이드 스텝이 진단 로그로 식별하여 운영자 수동 검토를 안내합니다. 자동 삭제하지 않으므로 사용자가 직접 추가한 ServiceProvider 는 보존됩니다.\n- 업그레이드 스텝 단독 실행 명령(`core:execute-upgrade-steps`) 이 마이그레이션·코어 재동기화·버전 갱신·캐시 정리·번들 확장 일괄 업데이트를 자동으로 함께 수행하도록 보강. 자동 업데이트 중단 후 안내된 수동 명령을 그대로 실행하면 별도 보조 작업 없이 업그레이드가 완료됩니다.\n- 인스톨러 Step 3 (PHP CLI / Composer / 자동 감지 / 코어 _pending 경로 검증) 의 프론트엔드 catch 분기에서 사용하는 다국어 키가 언어 파일에 정의되어 있지 않아, 서버 응답 실패 시 화면에 다국어 키 문자열이 그대로 노출되던 결함 수정.\n- 인스톨러 다국어 파일에 중복 정의된 키 4건 정리. POST 전용 API 의 메서드 거부 메시지가 일반 문구로 덮여 표시되던 문제, 데이터베이스 연결 실패 로그에서 placeholder 가 치환되지 않던 문제, HTTPS 카드 라벨이 안내 문장으로 표시되던 문제가 함께 해결됩니다.\n- 인스톨러 Step 3 의 PHP CLI 및 Composer 절대경로 검증이 공유 호스팅 환경(시놀로지 DSM 등 `open_basedir` 가 시스템 binary 영역을 차단하는 환경)과 Windows 환경에서 정상 경로임에도 거부되던 결함 수정. 셸 인자 escape 와 메타문자 차단은 유지하고, 파일 시스템 권한에 의존하던 사전 검사를 실제 실행 결과로 판정하도록 변경합니다. Windows 의 백슬래시 경로 구분자도 정상 입력으로 인식됩니다 (단, `C:\\Program Files\\...` 같은 공백 포함 디렉토리는 공백 없는 경로 또는 8.3 short path 사용 권장). (#33 @glitter-gim 님께서 제보해주셨습니다.)\n- 코어 운영 단계의 composer 바이너리 자동 인식이 같은 `open_basedir` 환경에서 실패하여 모듈/플러그인 설치 시 vendor-bundle 모드만 사용 가능하던 결함 수정. composer 가 정상 설치되어 PATH 에 존재하는 환경에서도 인식되도록 검출 로직을 개선했습니다.\n- 멀티 PHP 버전 환경(시놀로지 DSM Web Station, cPanel/Plesk multi-PHP 등) 에서 Composer 를 특정 PHP 인터프리터로 실행하려는 \"PHP 절대경로 + Composer 절대경로\" 공백 분리 입력 형식이 거부되던 결함 수정. 입력을 두 토큰으로 분리한 뒤 각 토큰을 개별 escape 처리하여 안전을 유지하면서 멀티 PHP 운영 시나리오를 지원합니다.\n- 인스톨러 세션 쿠키가 일부 환경(비표준 포트 + dynamic DNS 도메인 + 브라우저 추적 보호 등) 에서 브라우저에 의해 차단되어 첫 화면 \"설치하기\" 클릭이 동작하지 않고 새로고침처럼 보이던 결함을 보완. 세션 쿠키 SameSite 정책을 Strict → Lax 로 완화하여 차단 케이스 자체를 줄이고, Step 0 진입 시 쿠키 round-trip 사전 진단을 수행하여 차단이 감지되면 사용자에게 회피 방법(다른 브라우저 / 쿠키 차단 해제 / IP·localhost 접속) 을 명시 안내합니다.\n\n### Added\n\n- `hotfix:rollback-stale-files` Artisan 커맨드 추가 — 자동 롤백 후 활성 디렉토리에 잔존할 수 있는 신 파일을 운영자가 명시 실행하여 진단/정리할 수 있는 단발성 회복 도구. 기본은 진단 모드 (실제 삭제 없음), `--prune` 옵션 시 확인 프롬프트 후 정리. symlink 와 보호 경로(`storage/`, `vendor/`, `.env*` 등) 는 자동 제외.\n- 본인인증 메시지 정의 목록 API 응답에 `can_create` 권한 키 추가 — 운영자의 수정 권한 보유 여부에 따라 관리자 화면이 \"정의 추가\" 버튼을 정확히 활성화/비활성화할 수 있도록 보강.\n\n## [7.0.0-beta.5] - 2026-05-12\n\n### Upgrade Notice\n\nbeta.4 → beta.5 업그레이드 시 beta.4 의 결함으로 인해 활성 확장 디렉토리 일부가 손실됩니다. (#34 @bigmsg 님께서 제보해주셨습니다.)\n\n- 손실 항목 1: 운영자가 활성 디렉토리(`modules/{id}`, `plugins/{id}`, `templates/{id}`, `lang-packs/{id}`)에서 직접 수정한 코드\n- 손실 항목 2: 외부 install 한 확장 (GitHub 등 `_bundled` 에 포함되지 않은 모듈/플러그인/템플릿/언어팩)\n\nbeta.5 의 자동 복구 스텝이 `_bundled` 기반으로 번들 확장은 복원하지만, 위 두 항목은 `_bundled` 에 없어 자동 복구가 불가능합니다.\n\n업그레이드 전에 다음 디렉토리 전체를 별도 위치(외장 디스크, 별도 git 브랜치, tar.gz 파일)로 백업해두실 것을 권고합니다.\n\n- `modules/`\n- `plugins/`\n- `templates/`\n- `lang-packs/`\n\n업그레이드 후 자동 복구된 활성 디렉토리에 본인의 수정 사항/외부 확장이 누락되어 있다면, 사전 백업에서 복원하거나 외부 확장은 원본 소스(GitHub 등)에서 재설치하세요. 실패한 외부 확장 식별자는 업그레이드 로그(`storage/logs/core_update_success_*.log`)에 명시됩니다.\n\n#### 3가지 사용자군별 업그레이드 절차\n\n- **사용자군 B — 정상 beta.4 사용자 (대다수)**: `php artisan core:update` 표준 절차. beta.5 의 자동 복구 스텝이 활성 확장 디렉토리 + lang-packs 정합성을 보장합니다.\n\n- **사용자군 A — beta.3 → beta.4 업그레이드 도중 `Call to undefined method` 류 fatal 후 stuck 한 사용자**: `.env` `APP_VERSION` 을 변경하지 마시고 그대로 `php artisan core:update --force` 를 재실행하면 됩니다. 디스크는 이미 beta.4 이므로 새 자식 프로세스가 beta.5 메모리로 부팅되어 남은 업그레이드 스텝이 멱등 적용됩니다. 단, 활성 디렉토리에서 직접 수정한 코드 또는 `_bundled` 에 없는 외부 확장은 자동 복구 대상이 아니므로 업그레이드 전 위 디렉토리 백업이 동일하게 권장됩니다.\n\n- **사용자군 C — beta.1 또는 beta.2 사용자**: `php artisan core:update` 1회 호출로 beta.5 까지 직접 업그레이드는 권장하지 않습니다. 중간 버전의 업그레이드 스텝이 이전 버전 메모리에 없는 신규 코드에 의존하여 fatal 위험이 있습니다. 다음 단계적 업그레이드를 권장합니다:\n\n ```text\n beta.1 사용자: beta.1 → beta.2 → beta.3 → beta.4 → beta.5 (4 회)\n beta.2 사용자: beta.2 → beta.3 → beta.4 → beta.5 (3 회)\n ```\n\n 각 단계마다 `.env` `APP_VERSION` 이 자동 갱신되므로 다음 호출은 그대로 `php artisan core:update` 실행하면 됩니다. 가까운 릴리즈 zip 으로 `--source=` 또는 `--zip=` 옵션을 사용해 단계별 진행 가능합니다.\n\n#### 자동 롤백 후 `public/storage` 회복\n\n업그레이드 도중 자동 롤백 후 화면에서 업로드 파일이 보이지 않거나 미디어 경로가 깨졌다면 `public/storage` symlink 가 일반 디렉토리로 변질된 상태일 수 있습니다.\n\n**우선 시도 — beta.5 의 자동 복구**: beta.4 → beta.5 업그레이드 자체는 부모 beta.4 메모리 결함으로 symlink 가 깨질 수 있으나, beta.5 의 업그레이드 스텝이 다음 패턴을 자동 감지하여 복구합니다:\n\n- 감지 조건: `public/storage` 가 일반 디렉토리 + `storage/app/public` 이 존재 (Laravel 표준 구성)\n- 복구 동작: 손상된 디렉토리를 `public/storage.broken.YYYYMMDDHHMMSS` 로 보존 후 symlink 재생성\n- 운영자 조치: 자동 복구 후 `.broken` 디렉토리의 콘텐츠를 검증한 뒤 수동 삭제 (false positive 방어를 위해 자동 삭제하지 않음)\n\n**자동 복구가 작동하지 않은 경우 매뉴얼 회복**:\n\n```bash\nrm -rf public/storage\nphp artisan storage:link\n```\n\nbeta.5 부터 백업/복원 과정에서도 symlink 가 보존되므로 beta.5 → beta.6 이후로는 본 결함이 자동 차단됩니다. Windows 환경에서는 PHP `symlink()` 가 `SeCreateSymbolicLink` 권한을 요구하므로 자동 복구가 silent skip 될 수 있어 매뉴얼 회복 절차가 여전히 필요합니다.\n\n#### spawn 자식 프로세스 실패 시 동작 모드\n\nbeta.5 부터 코어 업데이트의 자식 프로세스 spawn 이 실패하면 즉시 abort 가 기본 동작입니다 (`G7_UPDATE_SPAWN_FAILURE_MODE=abort`). 다음 4가지 상황에서 부모 프로세스 메모리의 stale 클래스로 인한 fatal 위험을 차단합니다:\n\n1. `proc_open` 비활성 (일부 공유 호스팅)\n2. `proc_open` 자원 생성 실패\n3. 자식 비정상 종료 (uncaught exception)\n4. 자식 exit=0 이지만 업그레이드 스텝 0건 실행 (silent skip)\n\nabort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute-upgrade-steps --from=... --to=...`) 을 안내합니다. `proc_open` 비활성 환경 등 호환성이 필요한 경우 `.env` 에 `G7_UPDATE_SPAWN_FAILURE_MODE=fallback` 을 설정하여 in-process fallback 으로 진행할 수 있으나, 부모 메모리 stale 로 인한 fatal 위험이 잔존합니다.\n\n### Added\n\n- 본인인증 관리자 권한을 조회/수정으로 분리 — \"프로바이더 설정 조회\" / \"정책 조회\" 권한 신설로 단순 조회 권한만 부여받은 운영자도 환경설정 본인인증 화면에 접근 가능 (기존에는 수정 권한이 없으면 조회 화면도 403)\n- 환경설정의 본인인증 정책/메시지 탭에 권한 기반 버튼 비활성화 적용 — 수정 권한이 없는 운영자에게는 \"정책 추가\" / \"수정\" / \"삭제\" / \"활성 토글\" 버튼이 자동으로 비활성화 (이커머스/게시판 본인인증 탭 동일)\n- 코어 업데이트의 spawn 자식 프로세스 실패 시 abort/fallback 동작 모드 선택 (`G7_UPDATE_SPAWN_FAILURE_MODE`, 기본 `abort`) — 부모 메모리 stale 로 인한 upgrade step fatal 위험을 fail-fast 로 차단 (#28 @bigmsg 님께서 제보해주셨습니다.)\n- 업그레이드 스텝에 \"버전별 데이터 스냅샷\" 규약 도입 — 멀티 버전 점프 시에도 사용자가 단계별로 업그레이드한 것과 동등한 결과를 보장하도록 각 스텝의 시드 카탈로그·적용 로직·단발성 핫픽스를 해당 버전 디렉토리로 격리. 외부 확장 작성자가 beta.5+ 신규 업그레이드 스텝 작성 시 적용 (상세: 업그레이드 스텝 가이드) (#29 @bigmsg 님께서 건의해주셨습니다.)\n- 언어팩 일괄 활성화 검증 규칙에 동적 확장 훅 추가 (`core.language_packs.bulk_activate_validation_rules`) — 모듈/플러그인이 호스트 확장 재활성화 cascade 흐름에서 추가 검증 필드를 동적으로 등록 가능\n\n### Changed\n\n#### Breaking\n\n- 본인인증 관리자 권한 키 정리 — 기존 `core.admin.identity.manage` / `core.admin.identity.policies.manage` 가 각각 `core.admin.identity.providers.update` / `core.admin.identity.policies.update` 로 변경. beta.4 환경에서 기존 권한을 부여받은 운영자 역할은 업그레이드 시 자동으로 새 권한 키로 마이그레이션 (부여 시점/부여자/스코프 메타데이터 모두 보존). 외부 확장 또는 운영 도구가 옛 키 문자열을 하드코딩한 경우 새 키로 갱신 필요\n\n### Fixed\n\n- 코어 업데이트 시 활성 모듈/플러그인/템플릿/언어팩 디렉토리가 삭제되던 문제 수정 — beta.4 → beta.5 업그레이드 시 번들 원본에서 활성 디렉토리를 자동 복원하고, 외부 설치 확장은 업데이트 로그로 재설치 안내\n- 코어 업데이트의 단계 전환 신호를 비정상 입력으로부터 차단하도록 검증 강화\n- 본인인증 정책 응답의 생성/수정 일시가 사용자 타임존이 아닌 UTC ISO 문자열로 노출되던 문제 수정\n- 백업/롤백 시 `public/storage` 등 symlink 가 target 디렉토리 내용으로 추적 복사되어 symlink 가 일반 디렉토리로 변질되던 문제 수정 (Linux 환경 한정 — Windows 는 권한 한계로 일반 디렉토리 폴백 + 수동 회복 안내 유지)\n- root/super user 환경에서 Composer 검증·설치가 비대화형 컨텍스트에서 실패하던 문제 수정 (코어 업데이트, 확장 의존성 설치, 인스톨러 포함) (#31 @glitter-gim 님께서 제보해주셨습니다.)\n- 본인인증 활동 로그의 \"액션\" 열에 `identity.verify` / `identity.verify_failed` 가 번역되지 않은 키 그대로 노출되던 문제 수정 — 코어 액션 라벨 매핑이 누락되어 있던 부분을 추가\n- 본인인증 단계에서 운영자가 지정한 본인인증 수단이 무시되고 항상 기본 수단(메일)으로 발행되던 문제 수정 — 가입 정책에 설정한 본인인증 수단과 API 요청에서 명시한 수단이 실제 인증 발행에 반영되도록 변경\n\n## [7.0.0-beta.4] - 2026-05-11\n\n### Changed\n\n#### Breaking\n\n- 레이아웃 `navigate` 액션의 기본 스크롤 동작이 페이지 이동 후 최상단 이동으로 변경됨 — 일반 하이퍼링크 이동 UX 와 일치. 이전처럼 스크롤 위치를 유지하려면 `scroll: \"preserve\"` 명시 필요. 검색 필터/페이지네이션 등 위치 유지가 필요한 화면에서 회귀가 의심되면 해당 옵션 추가 (engine-v1.45.0)\n\n#### 언어팩 매니페스트 정합화\n\n- 언어팩 매니페스트(`language-pack.json`) 를 모듈/플러그인/템플릿 매니페스트와 동일한 필드 구조로 정렬 — 외부 작성자가 다른 확장과 동일한 표준으로 언어팩을 만들 수 있도록 개선\n- 언어팩 매니페스트에 GitHub 저장소 필드 추가로 GitHub 기반 업데이트 경로 지원 (모듈/플러그인 매니페스트와 동일한 동작)\n- 관리자 환경설정의 언어팩 카드와 상세 모달이 모듈/플러그인/템플릿 카드와 동일한 형식으로 다국어 이름 · 설명 · GitHub 링크를 노출하도록 변경\n\n### Added\n\n#### Generator 메타 태그\n\n- 관리자 환경설정 → SEO 탭에 Generator 메타 태그 카드 추가 — 토글로 노출 여부를 제어하고 내용 입력으로 W3Techs 등 CMS 시장 점유율 측정 도구가 인식하는 `` 태그를 SEO 봇 페이지·SPA·관리자 셸 모두에 출력. 내용 미입력 시 \"GnuBoard7 {버전}\" 자동 적용, 운영자가 버전 노출을 원치 않으면 \"GnuBoard7\" 만 입력 가능 (#26 @Lastorder-DC 님께서 건의해주셨습니다.)\n\n#### 웹 인스톨러 — 번들 언어팩 동반 선택 · 설치\n\n- 인스톨러 4단계 확장 선택 화면에 \"언어팩\" 카드 신설 — 번들 언어팩을 locale 별 서브헤딩으로 노출하고, 사용자가 모듈/플러그인/템플릿을 선택하면 그 확장에 종속된 번들 언어팩 카드가 즉시 활성화. 종속 확장을 해제하면 그 언어팩 카드는 자동으로 비활성화 + 선택 해제\n- 5단계 설치 진행 시 모든 확장의 install/activate 가 끝난 뒤 선택된 번들 언어팩을 일괄 설치 — 1건 실패는 best-effort 처리로 전체 설치를 중단하지 않음\n- 코어/확장당 다수 locale(일본어 + 중국어 등)이 동시에 번들된 시나리오 대응 — 각 locale 은 독립된 서브헤딩 + 카드로 노출\n\n#### 인스톨러 SSE 호환성 자동 감지\n\n- 인스톨러 시작 시 SSE 호환성을 사전 점검하여 환경에 맞는 모드(SSE 또는 폴링)로 단방향 진입 — 워커 동시 실행 race(테이블 / unique 키 중복 에러) 차단\n- SSE 비호환 환경 사용자에게 폴링 모드 진행 여부를 명시적 다이얼로그로 확인 후 시작\n\n#### ActivityLog 다국어 영역 분리\n\n- 활동 로그의 액션 라벨이 모듈/플러그인 자체 다국어 파일에서 우선 해석되도록 변경 — 그동안 코어에 일괄 등록되어야 했던 모듈 origin 라벨(이커머스 주문·상품·마일리지, 게시판 게시물·댓글·신고, 페이지 등) 이 각 영역으로 이전되어 모듈/플러그인이 자기 도메인 라벨을 자기 영역에서 자기설명. 미정의 시 코어 라벨로 자동 fallback 하여 회귀 없음\n- 모듈/플러그인이 발화하는 활동 로그가 호출자/대상 모델의 영역을 자동으로 인식하여 자기 다국어 파일로 라우팅 — 새 활동 로그를 추가할 때 별도 분기 코드 없이 자기 lang 만 채우면 정합\n\n#### 다국어 시더 인프라\n\n- 확장 entity 시더가 활성 언어팩의 다국어 데이터를 자동 머지하도록 다국어 시더 인터페이스 도입 — 신규 시더 작성 시 회귀를 자동 차단\n- 번들 일본어 언어팩 빌드 스크립트가 시더 메타데이터를 일관된 경로로 조회하도록 단순화\n\n#### 다국어 라벨 helper + Provider/Registry 페이로드 정합화\n\n- 다국어 라벨 표시 시 활성 언어팩의 lang key fallback 을 자동으로 처리하는 다국어 라벨 보강 helper 추가 — Provider/Registry 등록 페이로드(알림 채널·결제 PG 등) 와 settings JSON 다국어 데이터를 단일 시그니처로 처리\n- 알림 채널(`config/notification.php` 의 `default_channels`) 의 라벨이 활성 언어팩(일본어 등) 으로 자동 보강되도록 변경 — 일본어 활성 시 한국어 fallback 으로 노출되던 회귀 차단\n- 토스페이먼츠 PG 프로바이더 등록 페이로드가 lang key 기반으로 라벨을 선언하도록 변경 — 활성 언어팩으로 자동 보강\n- Provider/Registry 등록 페이로드가 다국어 JSON(`['ko' => ..., 'en' => ...]`) 을 직접 보유하지 않도록 audit 룰 신설 (회귀 자동 차단)\n- settings JSON 다국어 entry 의 식별 키(code/id/key) 보유 검증 audit 룰 신설 (lang pack fallback 키 조립 가능성 보장)\n- 프론트엔드 `$localized()` 표현식이 두 번째 인수로 lang key fallback 을 받도록 확장 — 활성 로케일 라벨 부재 시 모듈/플러그인 언어팩의 키로 자동 보강\n- 이커머스 환경설정 카탈로그(배송 가능 국가 / 통화 / 결제수단) 표시 시 활성 언어팩의 라벨이 자동 적용되도록 변경 — 일본어 활성 시 카탈로그 라벨이 한국어로 노출되던 회귀 차단 (다음 일본어 언어팩 빌드 시 자동 적용)\n- audit 룰이 `getDefault*Channels/Providers/Methods` 등 registry 기본값 반환 메서드의 다국어 JSON 직접 보유도 감지하도록 강화\n\n#### Settings 카탈로그 다국어 자동 보강\n\n- 환경설정 카탈로그(결제수단·통화·배송 가능 국가 등) 의 다국어 라벨이 카탈로그 빌드 시점에 활성 언어팩으로 자동 보강되도록 변경 — 사용자 체크아웃 + 관리자 환경설정 양쪽 모두 일본어 등 활성 시 한국어 fallback 으로 노출되던 회귀 차단\n- 모듈/플러그인 개발자가 카탈로그를 추가할 때 누락하지 않도록 audit 룰이 helper 호출 누락 검출\n- [docs/backend/settings-multilingual-enrichment.md](docs/backend/settings-multilingual-enrichment.md) 에 단순 helper 사용 패턴 문서화\n\n#### 확장 시스템\n\n- manifest `hidden: true` 플래그 추가 — 관리자 UI 목록에서 학습용/내부용 확장을 숨김 (CLI 는 정상 노출). 관리자 UI 에 슈퍼관리자 전용 \"숨김 포함\" 토글, artisan `module:list` / `plugin:list` / `template:list` 에 `--hidden` 플래그, 관리자 API `/api/admin/{modules,plugins,templates}` 에 `include_hidden` 쿼리 파라미터 지원\n- 학습용 최소 샘플 확장 4종 번들 추가\n - 모듈: `gnuboard7-hello_module` (Memo CRUD + 훅 발행 시연)\n - 플러그인: `gnuboard7-hello_plugin` (Action/Filter 훅 구독 시연)\n - Admin 템플릿: `gnuboard7-hello_admin_template` (Basic 컴포넌트 최소 셋)\n - User 템플릿: `gnuboard7-hello_user_template` (홈 + Memo 리스트 연동)\n- 모듈/플러그인/템플릿 정보 모달에 \"지원 언어\" 섹션 추가 — 코어/번들/사용자설치 출처 배지로 한눈에 확인\n- 모듈/플러그인/템플릿 인스톨러에 의존 확장 + 동반 번들 언어팩 동반선택 UI 추가 — 미선택 의존성에 종속된 언어팩은 자동 비활성화\n- 인스톨러 요구사항 검증 단계에 언어팩 디렉토리 쓰기 권한 점검 + 권한 부여 안내 추가\n- 사용자 수동 비활성화와 코어 버전 호환성으로 인한 자동 비활성화를 DB 수준에서 구분 — 자동 비활성화된 확장만 재호환 감지/원클릭 복구 대상이 되도록 분리 (#18 @laelbe 님께서 제보해주셨습니다.)\n- 코어 업그레이드 후 자동 비활성화 확장이 다시 호환되면 관리자 대시보드에 \"다시 활성화\" 알림 표시 + 원클릭 복구 버튼 제공 (자동 재활성화는 하지 않음 — 운영자가 명시적으로 복구)\n- 모듈/플러그인/템플릿 목록 화면 상단에 자동 비활성화 확장 안내 배너 추가 (코어 업그레이드 가이드 링크 동반)\n- 업데이트 모달에 코어 버전 호환성 안내 + \"위험을 이해하고 강제로 진행\" 체크박스 추가 — 운영자가 위험을 인지하면 비호환 확장도 강제 설치 가능\n- 관리자 대시보드 \"시스템 알림\" 카드 노출 — 코어 호환성 자동 비활성화/재호환 알림이 분기 렌더되며 개별 dismiss 지원\n\n#### 코어 업데이트 가시성\n\n- 코어 업데이트 마무리 단계에서 sudo 환경 결함(파일시스템 ACL · immutable 비트 · NFS 권한 거부 등) 으로 일부 경로의 소유권/그룹 쓰기 권한을 정상화하지 못한 경우 즉시 콘솔에 실패 경로 + 운영자 수동 복구 명령(`sudo chown -R …` / `sudo chmod -R g+w …`) 안내 — 이전에는 silent fail 로 묻혀 운영자가 후속 권한 거부 발생 후에야 인지하던 문제 해소\n\n#### 알림 시스템\n\n- 권한 기반 수신자 타입 추가 — `permission` 타입으로 특정 권한을 가진 모든 사용자에게 알림 발송 가능 (예: 게시판 신고 알림은 신고 관리 권한자에게 자동 발송)\n- 모듈 환경설정에서 알림이 비활성화된 경우 발송 자체를 사전 차단하는 정책 게이트 도입 — 이전에는 수신자 해석 단계가 정책을 우회하여 발송되던 문제 해소\n- 게시판 환경설정 → 신고 정책 탭에 신고 알림 채널 선택 UI 추가 (이메일 / 사이트 알림 다중 선택)\n- 모듈/플러그인이 자기가 발송하는 알림 정의를 manifest 에서 직접 선언하도록 통일 — 활성화/업데이트 시 운영자 편집값을 보존하면서 자동 등록되고, 제거 시 함께 정리됨. 권한·메뉴·본인인증과 동일한 declarative getter 패턴\n- 코어 기본 알림(회원가입 환영·비밀번호 재설정·비밀번호 변경) 의 다국어 제목/본문/변수/채널 정의를 `config/core.php` 로 통합 — 운영자가 코드 수정 없이도 향후 표면 변경을 추적할 수 있는 단일 소스 확보\n\n#### 본인인증\n\n- 본인인증(IdentityVerification) 인프라 도입 — 코어에 범용 IDV 프로바이더 계약을 마련하고, 기본 메일 프로바이더를 내장. 플러그인이 KCP·이니시스·SMS 등 다른 경로를 동일 계약으로 붙일 수 있도록 확장점 제공\n- 외부 본인인증 provider (KCP·PortOne·토스인증·Stripe Identity 등) 가 G7 표준 Extension Point 패턴으로 자기 SDK UI 를 주입할 수 있는 슬롯 도입\n- 비동기 검증 흐름을 위한 백엔드 폴링/콜백 엔드포인트 추가 — `GET /api/identity/challenges/{id}` (상태 폴링), `POST /api/identity/callback/{providerId}` (외부 redirect 콜백 수신)\n- 본인인증 정책 시스템 신설 — 회원가입·비밀번호 재설정·민감 작업에 적용되는 모든 본인인증 시점을 라우트/훅 단위로 선언형 정책으로 통합 관리. 관리자 화면에서 정책 활성/유예 시간/프로바이더/실패 모드/단계(가입 제출 전 vs 가입 후 활성화 전)·적용 대상(self/admin/both)을 조정할 수 있으며, 운영자 수정값은 업데이트 재시딩 시 보존됨\n- 본인인증 정책의 enable 토글이 라우트 코드 수정 없이 즉시 적용 — 모든 API 라우트가 정책 DB 와 자동 매칭되어 운영자가 admin UI 에서 정책을 켜는 즉시 본인인증이 강제됨. 코어/모듈/플러그인 어떤 라우트의 응답 처리 패턴에서도 본인인증 흐름이 일관되게 모달까지 도달하도록 처리\n- 회원가입 단계 정책 2종 시드 — 가입 제출 전 동기 검증, 가입 후 활성화 전 비동기 challenge (기본값 비활성, 운영자 opt-in)\n- 본인인증 정책이 활성화된 모든 강제 지점(회원가입·비밀번호 재설정·민감 작업·게시판/이커머스 정책 등)에서 사용자/관리자 화면에 동일한 모달 UX 가 자동으로 표시되도록 코어 인터셉터와 공통 모달 인프라 도입\n- 전역 프론트엔드 인터셉트 — 서버가 HTTP 428 본인인증 요구 응답을 반환하면 자동으로 인증 모달을 띄우고 사용자가 인증에 성공하면 원 요청을 자동 재실행\n- 본인인증 가드가 모듈/플러그인이 선언한 훅에도 자동 적용되도록 동적 구독 도입 — 결제 직전·민감 액션 직전 등 도메인 특화 가드를 운영자 토글 한 번으로 활성화\n- 관리자 화면에 본인인증 정책 관리 페이지 추가 (정책 목록 DataGrid + 편집)\n- 관리자 화면에 본인인증 이력 페이지 추가 — 알림 발송 이력과 동일한 수준의 UI 제공: 인증 수단(Provider) 탭(활성화된 프로바이더에 따라 자동 갱신), 통합 검색(자동 감지/사용자 ID/대상 식별자/IP/정책 키), 상태·인증 목적·채널·발생 유형 다중선택 OR 필터, 날짜 범위 + 고급 검색(발생 유형·출처·정책 키) 토글 영역, 정렬·페이지 사이즈 선택, 행 펼침으로 인라인 상세 표시, 모바일 반응형, 사용자 타임존 기준 시각, 보관주기(180일) 일괄 파기, 인증 수단/발생 위치 다국어 라벨 표시\n- 본인인증 목적(purpose) 의 출처(source) 추적 — 코어/모듈/플러그인이 선언한 목적을 구분해 환경설정 화면에서 분리 표시하며, 코어 정책 화면 상단에 \"코어가 제공하는 본인인증 목적\" 칩 섹션(목적 코드/라벨 + 설명·허용 채널 툴팁) 추가\n- 모듈 환경설정의 본인인증 정책 탭을 코어 정책 관리 화면과 동일한 UI/UX 로 통일 — 검색·필터(강제 시점/출처)·페이지네이션·정책 추가/편집 모달 직접 호출(코어 화면으로 이동 X)·이 모듈이 등록한 본인인증 목적 칩 섹션(없으면 안내 메시지). 정책 목록의 \"인증 목적\" 컬럼이 코드명 대신 사람이 읽을 수 있는 라벨로 표시\n- 모듈/플러그인이 자기 컨텍스트의 본인인증 정책·목적·메시지 정의를 manifest 에서 직접 선언하도록 통일 — `module.php::getIdentityPolicies()` / `getIdentityPurposes()` / `getIdentityMessages()` 선언만으로 코어 정책 관리에 자동 연동되며, 활성화/업데이트 시 운영자 편집값을 보존하면서 자동 등록·정리됨. 권한·메뉴·알림과 동일한 declarative getter 패턴\n- 본인인증 정책/이력 목록 API 가 source 컨텍스트별 필터를 받아 모듈 환경설정 탭이 자기 정책/이력만 조회 가능하며, 운영자 정의 정책을 특정 모듈/플러그인 컨텍스트에 귀속시켜 추가할 수 있도록 source_identifier 입력 허용 (`admin` / `module:{id}` / `plugin:{id}`)\n- 본인인증 메일 메시지 템플릿 시스템 도입 — 프로바이더와 (목적/정책)별로 다국어 제목/본문을 개별 정의 가능하며, 메시지 발송 시 정책 → 목적 → 프로바이더 기본값 순서로 fallback 해석. 회원가입/계정 변경/중요 작업/비밀번호 재설정 + 프로바이더 기본값 등 5종 메일 템플릿이 한국어/영어로 시드되어 즉시 발송 가능\n- 본인인증 challenge 발급/검증/취소 라우트에 권한 미들웨어 + scope=self 가드 적용 — 게스트는 `core.identity.{request,verify,cancel}` 권한으로 비로그인 가입(Mode B) 흐름 진입, 로그인 사용자는 본인 challenge 만 다룰 수 있으며 관리자는 임의 challenge 도 처리 가능. 모달 취소 시 서버 cancel API 를 호출해 challenge 가 audit log 에 cancelled 상태로 즉시 기록되도록 정합화\n- 환경설정 → 본인인증 탭에 \"메시지 템플릿\" 서브탭 신설 — 알림 템플릿 관리와 동일한 UX (채널 서브탭·페이지당 항목 수 셀렉터·카드 펼침 본문 미리보기·활성/기본 배지·페이지네이션) 로 정의 목록·활성 토글·다국어 편집(변수 가이드 + 기본값 복원) 제공. 운영자가 추가한 정책에 전용 메일 메시지 정의를 화면에서 직접 등록·삭제 가능 (시드 기본값 보호 + 정책 키 매칭 검증). 외부 본인인증 프로바이더 플러그인이 자기 메시지 기본값을 코어 복원 로직에 기여할 수 있는 필터 훅 노출\n- 알림 발송 이력 / 본인인증 이력 샘플 시더를 코어/모듈별로 분리 — 코어 시더는 코어 정의·정책만, 각 모듈 시더는 자기 영역만 채우도록 영역 격리. 모듈은 `module:seed {id} --sample` 으로 자기 영역 이력만 독립 생성 가능. 샘플 데이터는 실제 등록된 사용자·정의·정책·프로바이더 기반으로 생성되어 운영 데이터와 동일한 스키마/분포 유지\n- 모듈/플러그인 제거 시 코어 공유 테이블에 적재된 해당 확장의 데이터(권한·관리자 메뉴·알림 정의·본인인증 정책·본인인증 메시지 정의·본인인증 목적) 가 함께 정리되며, 제거 모달의 \"삭제될 데이터\" 에도 항목별 건수가 표시되도록 개선\n- IDV 도메인 분류 데이터(목적·채널·트리거 출처·정책 범위·정책 실패 모드·정책 적용 대상·정책 출처·메시지 스코프) 8종을 PHP Backed Enum 으로 정의하여 정책 등록/수정 시 일관된 검증 적용\n\n#### 인스톨러\n\n- 인스톨러 설치 완료 화면에 설치된 코어 버전 표시 — 사용자가 어떤 버전이 설치되었는지 즉시 확인할 수 있도록 개선\n\n#### 언어팩 시스템\n\n- 새 언어(일본어/중국어 등)를 코어 수정 없이 추가할 수 있는 언어팩(Language Pack) 시스템 도입\n- ZIP 업로드 또는 GitHub URL로 언어팩 설치/제거/활성화 지원\n- 동일 언어 슬롯에 여러 벤더의 언어팩 공존 가능 — 라디오 전환으로 즉시 활성 변경\n- 코어/모듈/플러그인/템플릿 별도 적용 — 모듈 언어팩은 해당 코어 언어팩이 활성일 때만 활성화 가능\n- 관리자 메뉴: 환경설정 > 언어팩 관리(통합) + 모듈/플러그인/템플릿별 진입점 추가\n- 사용자가 직접 수정한 다국어 키는 언어팩이 덮어쓰지 않도록 보존 정책 적용\n- 보안: 언어 번역 외의 PHP 실행 코드 포함 시 설치 차단\n- 언어팩 관리 화면을 모듈 관리와 동일 수준의 운영 도구로 보강 — 검색(식별자/벤더/언어), 업데이트 확인, 캐시 갱신, 다중 선택 일괄 제거 지원\n- 활성/비활성 상태를 토글 스위치로 일원화 (보호된 팩은 비활성화된 토글로 표시)\n- 업데이트 가능 항목에 \"업데이트 가능\" 배지와 행 단위 업데이트 실행 버튼 노출\n- 정보 모달을 모듈 정보 모달과 동일한 4섹션 구조(기본정보 / 호환성 / 소스 정보 / 변경로그)로 제공하며, CHANGELOG.md 를 자동 파싱하여 버전별 카테고리로 표시\n- 설치 모달에서 ZIP 업로드 전에 manifest 와 검증 결과를 사전 확인할 수 있는 미리보기 제공\n- 모듈/플러그인/템플릿별 언어팩 페이지 진입 시 대상 확장 안내 배너와 환경설정 탭으로 회귀하는 링크 노출\n- 언어팩 업데이트 권한을 별도 권한 키로 분리하여 설치/관리 권한과 독립적으로 부여 가능\n- 언어팩 운영 풀 패리티 — 모듈/플러그인/템플릿 관리와 동일한 안전 장치/확장성 적용\n- 공식 일본어(ja) 번들 언어팩 12종 추가 — 코어, 주요 모듈(전자상거래/게시판/페이지), 주요 플러그인(CKEditor5/마케팅/토스페이먼츠), 기본 템플릿(admin/user)을 일본어로 즉시 사용 가능\n- 언어팩 관리 화면에서 번들 언어팩을 모듈/플러그인 관리와 동일하게 \"미설치\" 상태로 노출 — 행별 \"설치\" 버튼으로 즉시 설치 가능\n- 언어팩 목록 필터에 \"미설치 (번들)\" 상태 옵션 추가\n- 본인인증 메일 메시지 정의의 다국어 키를 언어팩으로 주입할 수 있도록 확장 — 코어 수정 없이 언어팩 ZIP 만으로 IDV 메일 본문/제목을 다국어화 가능\n- 모듈/플러그인이 선언한 알림/본인인증 메시지 정의의 다국어 키도 언어팩으로 주입되도록 정합 — 이전에는 hook 발화 누락으로 코어 정의에만 적용되던 동작 정상화\n - 업데이트 시 자동 백업 + 실패 시 직전 버전으로 자동 복구\n - 동시성 가드 — 진행 중인 작업 동안 활성/비활성/제거/재업데이트 진입 차단\n - 번들 디렉토리(`lang-packs/_bundled/{identifier}`) 에서 외부 다운로드 없이 (재)설치하는 경로 추가 — 코어 언어팩 복구/재배포 단순화\n - 라이프사이클 훅 명명을 모듈/플러그인/템플릿과 통일 — `core.language_packs.{installed|updated|uninstalled|activated|deactivated}` 발행 (확장 가능성 확대)\n - Artisan 커맨드 신규 — `language-pack:list`, `language-pack:install`, `language-pack:update`, `language-pack:uninstall`\n- 코어와 번들 확장(모듈/플러그인/템플릿)에 내장된 한국어/영어를 가상 보호 언어팩으로 자동 노출 — 별도 설치 없이 언어팩 관리자에서 항상 활성/보호 상태로 확인 가능, 수정/제거 차단\n- 호스트 확장 비활성화 시 그에 종속된 언어팩이 함께 비활성화되며, 재활성화 시 \"다음 언어팩도 활성화하시겠습니까\" 모달로 사용자 의사 확인 후 일괄 활성화\n- 여러 언어팩을 한 번에 활성화하는 `POST /api/admin/language-packs/bulk-activate` API 추가 (의존성/버전 호환성 자동 검사)\n- 언어팩 활성화 시 의존성 + 호스트 확장 버전 호환성 검사 자동 수행 — 호스트 확장이 비활성/미설치이거나 버전 미달이면 활성화 차단\n- 언어팩 업데이트 우선순위를 모듈/플러그인 패턴으로 정합화 — GitHub 1순위 + bundled 폴백, 강제 업데이트 시 bundled 우선\n- 언어팩 상세 모달에서 코어/번들 확장 내장 항목 클릭 시 \"별도 언어팩이 아닌 내장 번역\" 안내 배너 노출\n- 모듈/플러그인/템플릿 상세 모달의 닫기 버튼이 콘텐츠 길이와 무관하게 모달 하단에 항상 보이도록 sticky 처리\n\n#### SEO\n\n- 관리자 환경설정 > SEO 탭의 Sitemap 카드에서 sitemap 을 즉시 재생성하고 마지막 생성 시각을 확인할 수 있는 \"지금 생성\" 버튼 추가 — 큐 드라이버와 무관하게 동기 실행되며 결과를 즉시 표시\n\n#### SEO 봇 감지\n\n- 봇 감지 엔진을 `jaybizzle/crawler-detect` 라이브러리로 교체. 기본 약 1,000종의 봇(검색엔진·링크 미리보기·AI 검색 등)이 자동 감지됨 — 링크를 슬랙·페이스북·LinkedIn·트위터·디스코드·텔레그램 등에 붙여넣으면 제목·설명·이미지 미리보기가 즉시 동작\n- 라이브러리가 놓치는 봇 3종(`kakaotalk-scrap`·`Meta-ExternalAgent`·`ChatGPT-User`) 을 G7 보강 패턴으로 기본 포함\n- 봇 감지 확장 훅 `core.seo.resolve_is_bot` 신설 — 플러그인이 IP 범위 검증·역방향 DNS·Cloudflare 봇 점수 등을 주입할 수 있는 슬롯\n- \"봇 라이브러리 사용\" 관리자 토글 추가 (기본 on, 비활성 시 운영자 커스텀 목록만 사용하는 레거시 모드)\n\n#### SEO OG / Twitter 카드 / 도메인 ownership\n\n- 슬랙·페이스북 링크 미리보기에 이미지·카드가 표시되도록 OG 보강 태그를 자동 출력하도록 개선 — og:site_name, og:image:width, og:image:height, og:image:secure_url, og:image:type, og:image:alt, og:locale 추가\n- Twitter 카드 메타태그(twitter:card / twitter:site / twitter:title / twitter:image 등) 출력 신설 — 슬랙 unfurl 폴백 경로 정상화\n- 운영자 환경설정 SEO 탭에 \"OG / Twitter 카드 기본값\" 카드 추가 — 사이트 이름·이미지 기본 가로/세로·Twitter 카드 타입·Twitter 사이트 핸들 5개 입력\n- 모듈·플러그인이 자기 도메인의 OG/Twitter/JSON-LD 를 직접 선언하도록 SEO declaration API 신설 — 이커머스 상품(Product/Offer/AggregateRating)·게시판 게시글(Article) 등 도메인 스키마가 레이아웃에서 확장 코드로 owned 되어 데이터에 따라 정확한 부속 태그 생성\n- SEO 메타 확장 훅을 분기별·통합 모두 제공 — OG·Twitter·구조화 데이터 각각의 hook 슬롯과 통합 hook 모두 지원하여 확장이 원하는 단계에서 선택 변경 가능\n- 모듈 설정 타이틀 템플릿에서 변수가 비어있을 때 인접 구분자(- – · |) 가 자동 정리되도록 개선 — 옵셔널 그룹 `[ ... ]` 표기도 지원하여 페이지별 구성 명시 가능\n\n### Changed\n\n- 콘솔 confirm 입력 처리 통일 — yes/y, no/n 외 입력 시 안내 메시지 출력 후 재질문, empty 입력 시 default 사용. 코어 업데이트·매니저 커맨드(module/plugin/template install·update·uninstall)·설정 마이그레이션의 모든 yes/no 프롬프트에 동일 규칙 적용 (#15 @laelbe 님께서 건의해주셨습니다.)\n- 비밀번호 재설정 정책 기본값을 비활성으로 변경 — 본인인증 인프라가 미구성된 사이트에서도 기본 동작이 영향받지 않도록 운영자 opt-in 으로 전환\n- 본인인증 정책의 인증 조건(`conditions`) 운영자 편집 허용 — 회원가입 단계 등 정책 조건을 코드 수정 없이 관리자 화면에서 조정 가능. 모듈 업데이트 시 운영자 수정값 보존\n- 토큰 만료 등으로 권한 없는 레이아웃 진입 시 \"페이지 로딩 실패\" 에러 화면 대신 로그인 페이지로 자동 이동 — 로그인 화면에서 \"세션이 만료되었습니다. 다시 로그인해 주세요.\" 토스트로 사용자에게 안내. 템플릿이 자체 로그인 경로를 사용하는 경우 부트스트랩에서 인증 설정을 커스터마이즈할 수 있는 공개 API 도 함께 제공 (#19 @abc101 님께서 건의해주셨습니다.)\n- 템플릿 다국어 데이터 로딩 시 활성 언어팩의 다국어가 가장 높은 우선순위로 병합되도록 변경\n- 권한·역할·메뉴·알림 등 코어 기본 데이터와 배송유형·클레임 사유·게시판 유형 등 모듈 기본 데이터에 활성 언어팩의 다국어가 자동 반영되도록 개선\n- 사용자 수정 보존(user_overrides) 정책을 다국어 JSON 컬럼은 sub-key 단위(`name.ko` 등) 로 기록하도록 개선 — 운영자가 한 언어 라벨만 수정해도 그 언어만 보존되며, 신규 활성 언어팩(예: 일본어 추가) 의 라벨은 자동 동기화됨. 기존 컬럼 단위(`name`) 기록은 업그레이드 시 활성 locale dot-path 로 자동 변환\n- 언어팩 활성/비활성 시점에 영향받는 모듈/플러그인의 entity 시더가 자동 재실행되어 신규 언어 라벨이 즉시 DB 에 반영되도록 라이프사이클 통합 — scope 별 라우팅 (코어 언어팩 → 모든 활성 확장, 모듈 언어팩 → 해당 모듈만, 플러그인 언어팩 → 해당 플러그인만)\n- 환경설정 → SEO → \"추가 봇 패턴\" 필드의 역할 변경 — 기존에는 유일한 봇 매칭 소스(기본 5종)였으나, 이제는 라이브러리가 놓치는 조직별 커스텀 봇만 추가하는 보강 레이어로 동작. 기존 설치의 운영자 커스텀 값은 모두 보존되며, 신규 설치 기본값은 jaybizzle 미커버 3종으로 변경\n\n### Security\n\n- 회원가입·비밀번호 재설정 라우트에 본인인증 정책 강제 미들웨어 부착 — 정책이 활성화된 경우 미인증 요청을 라우트 단계에서 차단 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)\n- 플러그인이 정책 해석 필터 훅에서 잘못된 타입을 반환해도 원본 정책이 유지되도록 우회 차단 강화 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)\n- 웹 인스톨러의 Composer/PHP 바이너리 경로 검증에서 사용자 입력이 그대로 shell 명령으로 실행될 수 있던 문제 수정 — 입력은 실행 가능한 단일 파일 경로로만 허용하고 모든 분기에서 인자 escape 강제. 설치 워커가 동일한 입력을 사용하던 내부 헬퍼도 같은 정책으로 정렬 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)\n- 설치 단계 4 의 확장 기능 선택 API 가 사용자가 보낸 모듈/플러그인/템플릿/언어팩 식별자에 셸 메타문자 검증을 적용하도록 강화 — 부적절한 식별자는 400 응답으로 거부되어 이후 설치 명령에 도달하지 않음 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)\n- 인스톨러의 코어 업데이트 _pending 경로 검증이 `..` 등 부모 디렉토리 우회 시도를 거부하고 응답 메시지를 단일화하여 임의 디렉토리 enumeration 신호 차단 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)\n- 설치 시 `.env` 작성 헬퍼가 입력값에 포함된 개행 문자를 제거하도록 변경 — DB 비밀번호 등 사용자 입력으로 새로운 환경 변수 라인이 주입되는 시나리오 차단 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)\n- 데이터베이스 연결 정보의 host/port/database 값에 DSN 키-밸류 구분자(`;`, `=`) 또는 NUL/CRLF 가 포함되면 연결을 거부하도록 추가 검증 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)\n- 설치가 완료된 시스템에서 `public/install/` 하위 모든 엔드포인트가 비즈니스 로직 진입 전 HTTP 410 으로 차단되도록 공통 가드 도입 — 운영 환경에서 인스톨러 노출형 결함의 공격 표면 제거. 운영자가 인스톨러를 다시 사용해야 하는 경우 설치 완료 마커(`storage/app/g7_installed`) 와 `.env` 의 `INSTALLER_COMPLETED` 를 모두 제거 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)\n\n### Fixed\n\n- 코어 업데이트 후 일부 환경에서 모듈/플러그인이 저장한 사용자 데이터(상품 이미지·첨부파일 등)에 PHP 가 접근하지 못해 \"찾을 수 없음\" 오류가 발생하던 문제 수정 — 업데이트 종료 시점 권한 복원 범위를 PHP 쓰기 영역(storage/logs·storage/framework·bootstrap/cache·storage/app/core_pending)으로 한정하고 항목별 정확 복원으로 정합화. 이전 버전에서 본 릴리즈로 업그레이드한 환경에서는 업그레이드 시점에 storage/app 디렉토리/파일 권한을 PHP 가 접근 가능한 형태로 자동 정상화함. 향후 업데이트에서는 사용자 데이터 디렉토리에 자동 생성되는 보존 마커로 권한이 영구 보호됨\n- PHP 8.5 환경에서 모든 페이지 응답이 손상되어 Firefox 에서는 \"Content Encoding Error\", Edge/Chromium 에서는 빈 화면으로 표시되던 문제 수정\n- `php artisan serve` 환경에서 인스톨러 진행 중 .env 파일이 변경되면 개발 서버가 워커를 재시작하면서 설치 단계가 중도에 끊기던 문제 수정 — 설치 진행 중에는 .env 를 건드리지 않고 완료 화면 노출 후 한 번에 반영하도록 변경\n- 관리자 환경설정 \"시스템 사양\" 카드의 메모리 항목이 서버 물리 메모리가 아닌 현재 PHP 프로세스가 사용 중인 메모리(수 MB 단위)로 표시되던 문제 수정 — 디스크 사용량과 동일한 형식(사용량/전체/백분율)으로 실제 서버 RAM 을 표시하도록 개선\n- 관리자 환경설정 \"시스템 사양\" 카드의 CPU 항목이 Windows 11 / Windows Server 2025 에서 \"operable program or batch file.\" 로 표시되던 문제 수정 — 해당 OS 에서 제거된 wmic 의존을 걷어내고 PowerShell 기반 조회로 전환, 구형 Windows 환경에서는 기존 방식으로 자동 폴백\n- 관리자 환경설정 \"정보\" 탭을 한 번 조회한 뒤 다른 탭으로 전환할 때마다 수 초 지연이 반복되던 문제 수정 — 시스템 정보 조회 결과를 1시간 캐싱하여 탭 전환 시 대기 시간 제거 (시스템 캐시 초기화 시 함께 무효화)\n- 큐 워커가 훅 페이로드의 enum 값(주문 상태 등)을 복원하지 못해 활동 로그·알림 등 일부 후속 처리가 실패하던 문제 수정\n- 항목 삭제 후 큐 워커가 처리하는 후속 훅에서 대상 데이터를 복원하지 못해 처리가 중단되던 문제 수정 — 소프트 삭제 페이지·첨부파일, 하드 삭제 주문 옵션 등 포함\n- 페이지 키워드 검색 결과의 전체 건수가 발행된 모든 페이지 수로 부풀려지던 문제 수정\n- 사용자가 댓글을 단 게시글 활동 조회 시 500 에러가 발생하던 문제 수정\n- 사용자 활동 통계에 삭제된 게시글의 댓글 수가 포함되던 문제 수정\n- 일부 주문에서 옵션 정보 직렬화 시 500 에러가 발생하던 문제 수정\n- 상품 옵션 삭제 검증 시 런타임 에러가 발생하던 문제 수정\n- 설치 직후 또는 활성 모듈 디렉토리 부재 시 이커머스 환경설정 기본값이 비어있던 문제 수정\n- 검색 가능 드롭다운(SearchableDropdown)에서 빠른 모달 전환 시 race condition 가능성 차단\n- 반응형 설정과 반복 렌더링이 결합된 레이아웃에서 무한 재귀가 발생할 수 있던 엔진 결함 수정 (engine-v1.43.1)\n- 슬롯 치환 시 텍스트 노드를 컴포넌트로 가정하여 발생할 수 있던 레이아웃 렌더링 오류 차단\n- 인스톨러 실행 시 보안 키 생성 단계에서 개발용 패키지 ServiceProvider 를 찾지 못해 설치가 중단되던 문제 수정 — 이전 환경에서 남은 컴파일 캐시를 vendor 교체 직후와 보안 키 생성 직전에 자동 정리하도록 개선\n- 폴링 모드 인스톨러가 큰 확장(레이아웃·테스트 수천 파일) 설치 도중 멈추던 문제 수정 — 명령 출력을 파일 기반으로 처리하여 OS 파이프 버퍼 한도와 무관하게 동작\n- PHP 8.5 + Apache + mod_fcgid 환경에서 폴링 모드 진행 상황이 실시간 반영되지 않고 설치 완료 시점에 일괄 표시되던 문제 수정 — 어느 모드로도 진행 상황이 즉시 표시되도록 보정. Apache 환경별 권장 설정은 INSTALL.md 와 시스템 요구사항 문서에 명시\n- 설치 완료 후 임시 파일이 자동 정리되지 않던 문제 수정\n- 알림 템플릿 편집 모달의 입력 필드에서 글자를 입력할 때마다 화면이 심하게 버벅이던 문제 수정\n- 슈퍼관리자가 다른 관리자 계정을 삭제할 수 없던 문제 수정 — 관리자 계정 삭제 가능 여부를 역할/권한/스코프 설정에 따라 판단하도록 개선 (슈퍼관리자 본인 보호는 유지)\n- `seo:generate-sitemap` 커맨드가 큐 드라이버 설정과 무관하게 항상 \"큐에 디스패치\" 안내를 출력하던 문제 수정 — 동기 드라이버에서는 즉시 생성으로 동작하고 그에 맞는 안내를 표시하도록 변경\n- 관리자 템플릿을 활성화해도 즉시 반영되지 않아 사용자가 직접 새로고침해야 하던 문제 수정 — 관리자 템플릿 활성화 시 자동으로 페이지를 갱신하여 새 템플릿이 즉시 적용되도록 개선\n- 보안 환경설정의 \"최대 로그인 시도 횟수 / 차단 시간\" 설정이 실제로 적용되지 않아 무제한 로그인 시도가 가능하던 문제 수정 — 임계 도달 시 계정 잠금(HTTP 423), 잠금 해제 시각 안내 토스트, per-IP 백업 throttle, 활동 로그 기록까지 통합 구현\n- 게시판 글쓰기 화면을 URL 로 직접 진입하거나 강제 새로고침했을 때 업로드한 첨부파일이 게시글에 연결되지 않던 문제 수정 (engine-v1.49.2) (#24 @minyho 님께서 제보해주셨습니다.)\n- 코어 업그레이드 후 새 버전에서 추가된 권한·메뉴·알림 정의가 등록되지 않아 관리자 화면에서 \"해당 권한이 없습니다\" 가 반복 표시되거나 신규 메일 템플릿이 비어있던 문제 수정 — 업그레이드 시 새 버전 설정 파일을 정확히 인식하도록 보정. 본 릴리즈로 업그레이드하면 누락분이 자동 등록됨\n- PHP 8.5 환경에서 관리자 로그인 시 `PDO::MYSQL_ATTR_SSL_CA` 등 PDO 드라이버 상수가 deprecation 경고를 발생시키던 문제 수정 — `Pdo\\Mysql::ATTR_SSL_CA` 형태의 신규 상수로 전환하고, PHP 8.5 미만 환경에서는 기존 상수를 그대로 사용하도록 분기 처리 (#23 @yks118 님께서 제보해주셨습니다.)\n- 회원가입 시 일부 환경에서 동의 항목 메타데이터가 누락되어 활동 로그 처리에서 예외가 발생, 후속 훅 체인이 중단되며 신규 회원에게 `user` 역할이 자동 부여되지 않던 문제 수정 — 동의 메타데이터를 nullable 로 받아 누락 케이스에서도 후속 권한 부여가 정상 동작하도록 개선 (#25 @comtylove-netizen 님께서 제보해주셨습니다.)\n\n## [7.0.0-beta.3] - 2026-04-23\n\n### Fixed\n\n- CKEditor5 플러그인 활성 상태에서 게시판 글쓰기 저장 시 \"제목은 필수입니다\" 422 오류가 발생하던 호환성 문제 수정 — 제목·내용 입력 순서와 무관하게 정상 저장되도록 개선 (#17 @laelbe 님께서 제보해주셨습니다.)\n- 코어 업데이트가 sudo 로 실행된 환경에서 캐시·세션·확장 디렉토리의 그룹 쓰기 권한이 일부 손실되어 업데이트 직후 \"Permission denied\" 또는 플러그인 제거 검증 실패가 발생하던 문제 수정 — 업데이트 종료 시점에 그룹 쓰기 권한을 자동 정상화하며 기존 손실 분은 1회성 복구 스텝으로 회수\n- 업데이트 완료 후 Laravel 런타임이 새로 만드는 캐시·세션 하위 디렉토리가 기본 umask(022) 때문에 다시 그룹 쓰기 권한을 잃어 재차 \"Permission denied\" 가 발생하던 문제 수정 — 업그레이드 스텝이 업데이트 진행 프로세스의 umask 를 그룹 쓰기 친화적으로 전환하고, 이후 부팅 시점에도 `storage/` 의 현재 그룹 쓰기 설정을 감지해 프로세스 umask 를 자동 동조 (운영자가 그룹 공유를 비활성화한 환경은 그대로 보존)\n- 코어 업데이트 마지막 단계의 진행 표시줄이 끝난 뒤 줄바꿈 없이 다음 셸 프롬프트가 같은 줄에 붙어 표시되던 출력 문제 수정\n\n### Notes\n\n- 7.0.0-beta.1 에서 7.0.0-beta.3 로 직접 업그레이드하는 경우, 환경(opcache CLI 활성 등)에 따라 권한 복구 스텝이 자동 실행되지 않고 건너뛰어질 수 있습니다. 업데이트 후 `storage/framework/cache` 등에서 Permission denied 가 발생하면 아래 명령을 수동 실행해 주세요 — 7.0.0-beta.2 에서 올라오는 경로에서는 해당 없음\n - `php artisan core:execute-upgrade-steps --from=7.0.0-beta.2 --to=7.0.0-beta.3 --force`\n- 시스템 레벨에서도 일관된 그룹 공유 권한을 원하면 php-fpm pool 설정에 `umask = 002` (또는 systemd unit 의 `UMask=0002`) 추가를 권장합니다. 코드 레벨 동조와 병행하면 외부 프로세스(cron, composer 등) 도 동일 권한으로 파일을 생성합니다\n\n## [7.0.0-beta.2] - 2026-04-20\n\n### Added\n\n#### 알림 시스템 재설계\n\n- 알림 시스템을 Definition(정의) × Template(채널별 템플릿) × Recipients(수신자) 3계층으로 재설계\n- 채널별 독립 수신자 설정 — trigger_user, related_user, role, specific_users 4종 타입 지원\n- 채널 Readiness 검증 — 미설정 채널(SMTP 미구성 등)의 발송을 사전 차단\n- 알림 발송 공통 디스패처 — 채널별 독립 발송, 발송 전후 훅 자동 실행, 모든 채널 자동 로깅\n- 알림 클릭 URL 지원 — database 채널 알림에 클릭 시 이동할 URL 패턴 설정 가능\n- 사용자/관리자 알림 전체 삭제 API 추가\n- 비밀번호 변경 시 `password_changed` 알림 자동 발송\n- 코어 권한에 사용자 알림 전용 카테고리 분리 (관리자/사용자 권한 의미 분리)\n- 알림 정의 일괄 초기화 기능 — 커스터마이징된 알림의 모든 채널 템플릿을 기본값으로 복원 (확인 모달 + 스피너 UX 포함)\n- 모듈/플러그인이 자신의 기본 알림 정의를 코어 리셋 로직에 제공할 수 있는 필터 훅 제공\n- 템플릿 편집 시 편집 상태가 자동 추적되어 리셋 버튼이 자동 노출\n- 알림 정의/템플릿 응답에 편집/삭제 권한 정보(abilities) 포함으로 UI가 권한에 따라 자동 조정\n\n#### Vendor 번들 시스템\n\n- Composer 실행 불가 환경(공유 호스팅 등)을 위한 vendor 번들 시스템 추가\n- vendor-bundle Artisan 커맨드 — 빌드, 검증, 일괄 처리\n- 모듈/플러그인 설치·업데이트에 `--vendor-mode=auto|composer|bundled` 옵션 추가\n- 인스톨러 Step 3에 Vendor 설치 방식 선택 UI 추가 (환경 자동 감지)\n\n#### GeoIP 및 타임존\n\n- MaxMind GeoLite2 자동 다운로드 스케줄러 + 관리자 환경설정 UI 추가\n- IANA 타임존 전체(약 425개) 지원 — 기존 7개 하드코딩 화이트리스트 폐기 (#8 @abc101 님께서 건의해주셨습니다.)\n- Select 컴포넌트에 `searchable` prop 추가 — 타임존 등 대량 옵션에서 검색 가능\n\n#### 인스톨러 개선\n\n- SSE/폴링 듀얼 모드 지원 — Nginx 프록시 + Apache 환경에서 SSE 문제 시 폴링 모드로 전환 가능\n- 확장 의존성 자동 해결 — 템플릿 선택 시 필요한 모듈/플러그인을 즉시 자동 선택, 전이적 의존성까지 해결 (#10 @glitter-gim 님께서 건의해주셨습니다.)\n- 자동 선택된 항목을 시각적으로 구분하고 요구한 확장 이름을 함께 표시\n- 다른 확장이 의존하는 항목은 선택 해제 차단 (의존 관계 안내 메시지 포함)\n- 의존성 버전 제약 사전 검증 — 버전 불일치 시 설치 진행 전 경고 (semver 비교: `>=`, `^`, `~` 등 지원) (#3 @laelbe 님께서 제보해주셨습니다.)\n- 기존 DB 테이블 감지 및 안전한 재설치 지원 — 백업 안내 + 명시적 동의 후 진행 (#5 @laelbe 님께서 건의해주셨습니다.)\n- 권한 안내 단순화 — `chmod -R 755` 단일 명령어로 통합 (업계 표준 정렬)\n- 소유자 불일치(`ownership_mismatch`) 감지 — 전통적 Apache 환경에서 3가지 해결 옵션 제시\n- Step 5에 \"설치 시작\" 버튼 도입 — 모드 선택 후 사용자 클릭으로 설치 시작\n\n#### 공통 캐시 시스템\n\n- 코어/모듈/플러그인 격리 캐시 시스템 도입 (접두사 자동 부여)\n- 모델 변경 시 태그 기반 자동 캐시 무효화 트레이트 추가\n- 환경설정에서 캐시 TTL 중앙 관리 (7개 설정 키)\n\n#### 어드민 UI\n\n- 페이지 진입/탭 전환 시 로딩 spinner 표시 — 데이터 fetch 완료까지 유지 (#6 @laelbe 님께서 제보해주셨습니다.)\n- 목록 페이지네이션 시 DataGrid body 영역 한정 spinner (pagination 버튼 가림 방지)\n- DataGrid 컴포넌트에 `id` prop 추가\n\n#### 기타\n\n- HookManager 공통 큐 디스패치 — 환경설정 하나로 모든 훅 리스너 자동 비동기 실행, 큐 워커에서도 Auth/Request/Locale 컨텍스트 자동 복원\n- WebSocket 클라이언트/서버 endpoint 분리 설정 지원 — 리버스 프록시 환경 대응, SSL 검증 옵션 추가\n- SEO 변수 시스템 — 모듈/플러그인이 페이지별 SEO 변수를 제공할 수 있도록 개선\n- 코어 드라이버 확장 시스템 — 플러그인이 필터 훅으로 스토리지/캐시/세션/큐 등 드라이버를 등록 가능\n- `HasUserOverrides` 트레이트 — 시더 데이터와 사용자 수정을 자동으로 분리 추적\n- 템플릿 엔진 engine-v1.42.0 — `render: false` 선택적 리렌더 제어\n- 템플릿 엔진 engine-v1.41.0 — setLocal/dispatch debounce API + stale 값 오염 방지\n- 템플릿 엔진 engine-v1.40.0 — navigate fallback 옵션 (교차 템플릿 경로 대응)\n- 템플릿 엔진 engine-v1.30~38 — sortable wrapper 요소 지정, React.memo 자동 래핑, resolvedProps 참조 안정화, transition_overlay spinner 시스템, reloadExtensions 통합 핸들러\n\n#### 확장 업데이트\n\n- 코어 업데이트 완료 후 `_bundled` 에 새 버전이 있는 확장을 감지하고 일괄 업데이트 여부를 묻는 인터랙티브 프롬프트 제공\n- 일괄 업데이트 시 전역 레이아웃 전략(overwrite / keep) 1회 질의 후 확장별 예외 지정 가능\n- 모듈/플러그인 업데이트에도 `--layout-strategy=overwrite|keep` CLI 옵션 및 관리자 UI 모달 선택 지원 (기존 템플릿 전용 → 확장 전체 일관)\n- 관리자 모듈/플러그인 업데이트 모달에 사용자가 수정한 레이아웃 감지 및 목록 표시 기능 추가\n- 모듈/플러그인 업데이트 시 각 upgrade step 버전을 콘솔에 실시간 출력 (기존에는 파일 로그에만 기록)\n- 확장 업데이트 메서드 파라미터 순서를 3종(템플릿·모듈·플러그인) 공통 prefix(id, force, onProgress) 로 정렬해 일관성 확보\n- 모듈/플러그인이 런타임에 동적으로 생성한 권한·역할·메뉴(예: 게시판별 권한 세트)도 업데이트 시 보존되도록 개선\n- 모듈/플러그인/템플릿 업데이트 커맨드에 업데이트 소스 선택 옵션(`--source=auto|bundled|github`) 추가 — GitHub 원격 장애·태그 롤백 등 상황에서 번들 소스로 강제 설치 가능\n- GitHub 원격에 존재하지 않는 태그로 업데이트 시 \"아카이브 추출 실패\" 로 불명확한 오류가 나던 문제 개선 — 태그 존재 여부를 먼저 검증해 \"업데이트 소스 없음\" 안내로 명확화\n- 관리자 모듈/플러그인 업데이트 모달의 _global 상태키를 `hasModifiedTemplateLayouts`, `hasModifiedModuleLayouts`, `hasModifiedPluginLayouts` 로 통일해 네이밍 충돌 방지\n- `module:install` / `plugin:install` / `template:install` 에 `--force` 옵션 추가 — 이미 설치된 확장도 `_bundled`/`_pending` 원본으로 활성 디렉토리를 덮어써서 재설치 (불완전 설치 복구용)\n- 확장 활성 디렉토리 무결성 검사 추가 — `module.php`/`module.json` (모듈), `plugin.php`/`plugin.json` (플러그인), `template.json` (템플릿) 중 하나라도 누락된 활성 디렉토리를 로드 시 경고 로그 + `install --force` 복구 힌트 제공\n- 코어 업데이트 커맨드에 외부 ZIP 파일 직접 지정 옵션(`--zip=/path/to/g7.zip`) 추가 — GitHub 다운로드 대신 지정 ZIP 을 사용해 오프라인/수동 배포 가능\n- 모듈/플러그인/템플릿 업데이트 커맨드에 외부 ZIP 파일 직접 지정 옵션(`--zip=/path/to/ext.zip`) 추가 — manifest 식별자와 대상 확장이 일치하는지 검증 후 manifest 의 버전으로 업데이트\n- 외부 ZIP 은 GitHub 릴리스 zipball 의 래퍼 디렉토리와 평탄 루트 구조를 모두 자동 감지해 지원\n\n### Changed\n\n#### 알림 시스템\n\n- MailTemplate 시스템을 NotificationDefinition + NotificationTemplate 체계로 전면 전환\n- 코어 알림 3종의 직접 발송을 훅 기반 발송으로 전환\n- 알림 수신자를 definition 레벨에서 template 레벨로 이동 (채널별 독립 수신자)\n- 알림 broadcast 채널을 정수 User ID에서 UUID 기반으로 변경 (보안 강화)\n- 알림 시각 표시를 ISO 8601에서 사용자 타임존 기준으로 변경\n\n#### 캐시 시스템\n\n- 24개 서비스의 캐시 호출을 `Cache::` 파사드에서 `CacheInterface` DI로 전환\n- 13개 서비스의 TTL을 환경설정 중앙 관리로 통일 (하드코딩 제거)\n- 캐시 키 접두사를 `g7:core:`, `g7:module.{id}:`, `g7:plugin.{id}:` 체계로 통일\n- `CacheService` 클래스 삭제 — `CacheInterface`로 완전 대체\n\n#### 인스톨러\n\n- 권한 검증을 비트 값 비교에서 실제 읽기/쓰기 가능 여부 기반으로 단순화\n- `.env` 생성 안내를 소유자 일치 여부에 따라 1-step으로 통합\n\n#### 기타\n\n- 확장 정보 모달에 의존성 정보 노출 — 코어 요구 버전(`g7_version`), 의존 모듈/플러그인 목록과 각각의 요구 버전·설치 버전·충족 상태 뱃지를 모듈/플러그인/템플릿 정보 모달에서 확인 가능\n- `locale_names` 설정 추가 — 언어 표시명을 중앙에서 관리\n- 매 요청 `Schema::hasTable()` 호출 제거 — 설치 완료 환경에서 10~14회의 불필요한 스키마 쿼리 제거로 응답 시간 단축\n- SeoRenderer에 모듈/플러그인 SEO 변수 자동 해석 기능 추가\n- ResponseHelper — 디버그 모드에서 예외 상세 자동 포함, 프로덕션에서 내부 메시지 노출 차단\n- 환경설정 드라이버 변경 시 큐 워커 자동 재시작\n- `allow_url_fopen=Off` 환경 지원 — GitHub 연동 HTTP 호출을 Laravel Http 파사드로 전면 교체\n- 코어 업데이트 완료 후 안내 메시지를 \"수동 업데이트 명령 나열\"에서 \"일괄 업데이트 인터랙티브 프롬프트\"로 변경\n- 확장 삭제 시 권한·메뉴·역할은 \"데이터도 함께 삭제\" 옵션을 체크했을 때만 삭제되도록 변경 — 기본 삭제에서는 보존되어 재설치 시 사용자 역할 할당이 복원\n\n### Fixed\n\n- WebSocket 데이터소스가 progressive 목록에 포함되어 영구 블러되던 문제 수정\n- WebSocket 채널/이벤트 표현식 미평가로 인증 실패하던 문제 수정\n- WebSocket broadcast HTTP API가 클라이언트 host로 POST되어 실패하던 문제 수정\n- WebSocket 활성화 시 필수 필드 미검증 문제 수정\n- WebSocket 비활성화 시에도 Reverb 연결을 시도하던 문제 수정\n- `--force` 업데이트 시 번들 없는 외부 확장이 업데이트 소스를 찾지 못하던 문제 수정\n- 확장 의존성 데이터 구조 불일치 수정 — manifest JSON 기반으로 통일\n- 모듈/플러그인/템플릿 install/activate/deactivate/uninstall 직후 라우트 미반영 수정\n- 다국어 번역 캐시 경합으로 raw key 노출되던 문제 수정 (engine-v1.38.1)\n- 조건부 `$t:` 표현식에서 번역 실패하던 문제 수정 (engine-v1.38.2)\n- 테스트 환경과 개발 환경이 동일한 Redis 캐시를 공유하여 상호 오염되던 문제 수정\n- 대시보드 브로드캐스트 스케줄이 무한 락에 걸리던 문제 수정\n- 환경설정 저장 후 `config:cache` 상태에서 변경값 미반영 수정\n- Windows 환경에서 파일 잠금 감지 시 30초+ hang 수정 — 0.5초로 단축\n- 개발 의존성 없이 설치된 프로덕션 환경에서 샘플 시더와 팩토리가 실행되지 않던 문제 수정 — 한국어 샘플 데이터 생성기가 자동으로 대체 동작하도록 개선\n- `sudo php artisan core:update` 실행 시 vendor 디렉토리 전체가 root 소유로 오염되던 문제 수정\n- 코어 업데이트 후 신규 권한·메뉴가 DB에 반영되지 않아 관리자 환경설정 페이지 접근이 거부되던 문제 수정\n- beta.1에서 업그레이드 시 제거된 메일 템플릿 모델 참조로 Fatal 오류가 발생하던 문제 수정\n- 업그레이드 후 `bootstrap/cache` 등 일부 디렉토리가 root 소유로 남아 확장 설치·업데이트가 거부되던 문제 수정 — 소유권 복원 범위를 설치 안내 경로 전체로 확장\n- 코어 업데이트 진행률 표시와 upgrade step 실행 로그가 같은 줄에 뒤섞이던 문제 수정 — 각 step 안내가 별도 줄로 깔끔하게 출력됨\n- 템플릿/레이아웃 공개 API가 확장 활성화 전·설치 중에 받은 \"찾을 수 없음\" 에러 응답을 영구 캐시하여 복구 후에도 같은 오류를 반환하던 문제 수정 — 에러 응답은 캐시에서 제외\n- `_pending`/`_bundled` 에 업데이트 중 남은 임시 디렉토리(`{id}_YYYYMMDD_HHMMSS`, `{id}_updating_*` 등) 의 원본 manifest 때문에 `install` 이 존재하지 않는 표준 경로로 접근해 실패하던 문제 수정 — 디렉토리명과 identifier 불일치 시 스캔에서 제외\n- 업그레이드 후 일부 코어 권한이 DB에 반영되지 않아 사용자 역할의 알림 기능 권한이 비어있던 문제 수정 — 별도 프로세스 실행으로 최신 로직 기반 재동기화 보장\n- 코어·확장 설정에서 제거된 메뉴·권한·역할·알림 정의·알림 템플릿·게시판 유형·클레임 사유가 업데이트 후에도 DB에 잔존하던 문제 수정 — 고아 레코드 자동 정리\n- 관리자 UI에서 메뉴·역할·알림·게시판 유형·클레임 사유·배송 유형 등을 수정해도 다음 업데이트 시 기본값으로 덮어써지던 문제 수정 — 사용자가 수정한 필드는 모든 저장 경로에서 자동 추적되어 업데이트 후에도 보존\n- 알림 정의·템플릿도 업데이트 기준으로 동기화 — 새 버전에서 제거된 알림은 DB에서도 삭제\n- 관리자 모듈/플러그인 목록의 \"작성자\" 표기가 manifest 의 `vendor` 필드 대신 식별자 prefix 를 사용하던 문제 수정 — `vendor` 가 정의된 확장은 정확한 작성자명으로 표시 (#9 @glitter-gim 님께서 제보해주셨습니다.)\n\n### Removed\n\n- MailTemplate 시스템 일괄 제거 — 모델, 컨트롤러, 서비스, 리스너, 시더, 팩토리, 다국어 파일 등. NotificationDefinition + NotificationTemplate 체계로 완전 대체\n\n> 참고: beta.1에서 업그레이드하는 운영 환경의 데이터 이관은 `Upgrade_7_0_0_beta_2`가 자동 처리합니다.\n\n## [7.0.0-beta.1] - 2026-04-01\n\n### Changed\n\n- 오픈 베타 릴리즈\n\n## [7.0.0-alpha.21] - 2026-03-30\n\n### Added\n\n- 인스톨러 PHP CLI/Composer 필수 검증 기능 — 기본 `php` 미감지 시 CLI 설정 필수 전환, Composer 실행 확인 및 미설치 시 설치 안내, 둘 다 검증 완료 전 다음 단계 진행 차단\n- 템플릿 레이아웃 수정 감지 시스템 — `original_content_hash`/`original_content_size` 컬럼 추가, SHA-256 해시 기반 수정 감지로 `updated_by` 방식 대체 (TemplateManager, LayoutRepository, 마이그레이션)\n- 관리자 로그인 화면 개선 — 비밀번호 찾기/재설정 플로우 추가, 테마 Segmented Control, 언어 셀렉트 확대 (PasswordResetController, PasswordResetNotification, ForgotPasswordRequest, ResetPasswordRequest)\n- `{{raw:expression}}` 바인딩 번역 면제 마커 시스템 — `$t:` 자동 번역 대상에서 특정 바인딩을 제외하는 마커 도입 (rawMarkers.ts)\n- 레이아웃 프리뷰 모드 — 관리자 레이아웃 편집기에서 실시간 미리보기 지원 (LayoutPreviewController, LayoutPreviewService, 마이그레이션)\n- 활동 로그 이력 조회 메뉴 신설 — ActivityLogController, ActivityLogResource, ActivityLogService, ActivityLogRepository, config/core.php 메뉴/권한 등록, 관리자 레이아웃 추가\n\n### Changed\n\n- `window.G7Config` 설정 노출 최소화 Phase 1 — 프론트엔드 미참조 설정의 브라우저 소스 노출 차단\n - SettingsService: 필드 레벨 `expose: false` 지원 추가 (formatCategorySettings)\n - FiltersFrontendSchema: `fields: {}` (빈 객체) 시 전체 차단 안전 기본값 적용\n - View Composers: TemplateComposer/UserTemplateComposer에 ModuleSettingsService 주입 — frontend_schema 기반 필터링 적용 (기존 config() 직접 참조 우회 수정)\n - 코어 defaults.json: general 4개 필드, security 전체, seo 전체, advanced 9개 필드(debug_mode 제외), upload 2개 필드, drivers 전체를 `expose: false` 처리\n- 캐시 무효화 로직 정비 — 016862cb 이전의 단순한 방식으로 복귀 (버전 증가 + TTL 자연 만료), `extension_cache_previous_versions` 추적 및 `getCacheVersionsToInvalidate()`/`getCacheVersionsForLayoutInvalidation()` 제거, Cache Tags 드라이버 분기 제거로 캐시 드라이버 비의존성 확보\n\n### Fixed\n\n- MariaDB를 `DB_CONNECTION=mysql` 드라이버로 연결 시 `WITH PARSER ngram` 에러로 인스톨러 설치 실패 수정 — `isMariaDb()` 메서드 추가하여 서버 버전 문자열 기반 실제 DBMS 감지 (DatabaseFulltextEngine)\n- 캐시 무효화 버전 키 누락 수정 — `warmTemplateCache()`가 버전 포함 키(`.v{version}`)로 캐시를 생성하나 `clearTemplateCache()`가 레거시 키만 삭제하던 문제, routes/language 캐시에 버전 포함 키 삭제 추가\n- 캐시 버전 0 무효화 누락 수정 — `array_filter()`가 버전 `0`을 falsy로 제거하여 `.v0` 캐시 키가 삭제 대상에서 누락되던 버그 (ClearsTemplateCaches, InvalidatesLayoutCache)\n- 테스트 활성 디렉토리 보호 강화 — `ProtectsExtensionDirectories`에 `moveDirectory` spy 추가, `copyToActive()` 원자적 교체가 활성 디렉토리를 rename으로 파괴하는 것 방지\n- 활동 로그 하위 호환 — `ActivityLogResource`에서 기존 DB 레코드(`type: 'text'`)의 enum 필드를 모델의 현재 `$activityLogFields` 기준으로 동적 번역 + 일괄 변경 `:count` 미치환 보정\n- 활동 로그 enum 미번역 전수 수정 — 14개 모델 필드의 `$activityLogFields` `type: 'enum'` 전환 + 14개 Enum 클래스에 `labelKey()` 메서드 추가 (User, Order, OrderOption, Product, Coupon, Post, Comment, Board)\n- 활동 로그 삭제/일괄삭제 `description_key` 및 샘플 시더 누락 추가 — 코어 시더 `activity_log_descriptions` 테이블에 delete/bulk_delete 키 보충 + 다국어 파일 동기화\n- 관리자 다크모드 품질 전수 조사 및 수정 — 레이아웃 JSON ~546건, TSX ~37건, CSS 5건의 누락된 `dark:` variant 추가 (이커머스 101파일, 게시판 19파일, 페이지 2파일, 마케팅 1파일, 템플릿 컴포넌트 ~20파일)\n- 레이아웃 편집 캐시 무효화 및 버전 히스토리 수정 — LayoutService의 저장/복원 시 PublicLayoutController 서빙 캐시 무효화 + `extension_cache_version` 증가 누락 수정\n- 사용자 관리에서 관리자/슈퍼관리자 삭제 시 토스트 메시지 및 어빌리티 처리 — CannotDeleteAdminException 추가, UserResource에 `can_delete` 어빌리티 반영\n- ActivityLog 핸들러 `$result` 타입 불일치 수정 — CoreActivityLogListener에서 `bool` → `array` 반환 타입 정합성 보정\n- 환경설정 사이트 로고 저장 시 Attachment 객체 정수 검증 오류 수정 — SaveSettingsRequest에서 Attachment JSON 객체를 정수로 검증하던 문제 수정\n- 관리자 SPA 네비게이션 로고 깜빡임 수정 — extends base 컴포넌트 불필요 remount 방지 (`_fromBase` 마킹 기반 stable key 패턴)\n- 인스톨러 다크모드 적색 배경 가독성 개선 — `alert-title`, `alert-message`, `permission-badge`, `test-result` 다크모드 색상 override 추가\n- 인스톨러 `BASE_PATH` 심볼릭 링크 미해석 — `realpath()` 적용 + 절대경로/상대경로 병기로 호스팅 환경 대응\n- 인스톨러 403 에러 페이지 다크모드 미지원 — `prefers-color-scheme: dark` 미디어쿼리 추가\n- 템플릿 업데이트 수정 감지 실패 — `LayoutRepository::update()`가 `updated_by`를 설정하지 않아 `hasModifiedLayouts()`가 항상 \"수정 없음\" 반환하던 문제 수정 (hash 비교 방식으로 전환)\n- 템플릿 업데이트 \"수정 유지\" 전략 미작동 — `layoutStrategy === 'keep'` 시 `refreshTemplateLayouts()` 미호출 → 양쪽 전략 모두 호출하되 `preserveModified` 플래그로 분기\n- 템플릿 업데이트 API 응답 키 불일치 — 백엔드 `has_modified` → 프론트엔드 기대 `has_modified_layouts`/`modified_count` 키 정렬\n- 템플릿/모듈/플러그인 설치 시 `incrementExtensionCacheVersion()` 누락 수정 — TemplateManager(install/activate/deactivate/uninstall) + ModuleManager(install) + PluginManager(install) 총 7곳 추가\n- 캐시 버전 변경 시 프론트엔드 다국어 미갱신 수정 — TemplateApp.ts에서 캐시 버전 변경 감지 시 TranslationEngine 재로드 추가\n- `warmTemplateCache()` 다국어 캐시 워밍 시 `$partial` 디렉티브 미해석 수정 — `json_decode`만 수행하던 코드를 `TemplateService::getLanguageDataWithModules()` 호출로 교체하여 fragment 해석 및 모듈/플러그인 다국어 병합 정상화\n- `ActivateTemplateCommand` 의존성 미충족 시 성공으로 보고하는 버그 수정 — `if ($result)` → `if ($result['success'])` 변경 및 의존성 경고 메시지 출력, `--force` 옵션 추가\n- `ActivateModuleCommand`/`ActivatePluginCommand` 의존성 미충족 시 경고 메시지 미출력 수정 — 의존성 경고 표시 및 `--force` 옵션 전달 추가\n\n## [7.0.0-alpha.20] - 2026-03-30\n\n### Added\n\n- Laravel Scout 통합 및 MySQL FULLTEXT(ngram) 검색엔진 드라이버 확장 시스템 도입 — 커스텀 `DatabaseFulltextEngine`으로 `MATCH...AGAINST IN BOOLEAN MODE` 지원, `core.search.engine_drivers` 필터 훅으로 Meilisearch/Elasticsearch 등 외부 엔진 플러그인 등록 가능\n- `DatabaseFulltextEngine` 다중 DBMS 호환 — FULLTEXT 미지원 DBMS(PostgreSQL, SQLite)에서 LIKE fallback 자동 전환, `whereFulltext()` 정적 헬퍼(관계 검색용), `addFulltextIndex()` 마이그레이션 헬퍼(DBMS별 조건부 DDL)\n- `config/scout.php` 설정 파일 추가 — 기본 드라이버 `mysql-fulltext`, `SCOUT_DRIVER` 환경변수로 전환 가능\n- `FulltextSearchable` 인터페이스 — FULLTEXT 검색 대상 컬럼 및 가중치 정의 계약\n- `AsUnicodeJson` 커스텀 캐스트 — JSON 컬럼에 한글을 `\\uXXXX` 이스케이프 없이 실제 UTF-8로 저장하여 FULLTEXT ngram 토크나이저 정상 동작 보장\n- 환경설정 > 드라이버 탭에 검색엔진 설정 카드 추가 — 기본 MySQL FULLTEXT(ngram) 드라이버 표시, 플러그인 설치 시 추가 드라이버 자동 표시\n- `SaveSettingsRequest`에 검색엔진 드라이버 동적 validation 추가 — `core.search.engine_drivers` 필터 훅 기반 허용 목록\n\n## [7.0.0-alpha.19] - 2026-03-29\n\n### Added\n\n- 검색/필터/정렬 성능 향상을 위한 누락 인덱스 일괄 추가 — activity_logs(description_key), users(created_at), mail_send_logs(status), template_layouts(template_id), schedules(created_at)\n\n## [7.0.0-alpha.18] - 2026-03-26\n\n### Added\n\n- SEO ExpressionEvaluator 산술 연산자 확장 — `*`, `/`, `%` 추가 (기존 `+`, `-`만 지원)\n- SEO PipeRegistry 파이프 함수 엔진 구현 — 프론트엔드 PipeRegistry.ts 빌트인 파이프 15종 PHP 미러링 (date, datetime, relativeTime, number, truncate, uppercase, lowercase, stripHtml, default, fallback, first, last, join, length, filterBy, keys, values, json, localized)\n- ActivityLog `description_params` ID→이름 변환 필터 훅 (`core.activity_log.filter_description_params`) — `ActivityLog::getLocalizedDescriptionAttribute()`에서 실행\n- 코어 모델 `$activityLogFields` 정의 — `User`, `Role`, `Menu`, `Schedule`, `MailTemplate` (5개 모델)\n- ActivityLog ChangeDetector 필드 라벨 다국어 키 추가 (`lang/ko/activity_log.php`, `lang/en/activity_log.php` — `fields` 섹션)\n- `module_helpers.php` — `getModuleSetting()` 헬퍼 함수 추가\n- CoreActivityLogListener: bulk_update per-User/per-Schedule 전환 + bulk_delete per-Schedule 전환 (건별 loggable_id 기록)\n- ActivityLogHandler: 삭제된 엔티티용 loggable_type/loggable_id 직접 지정 fallback 지원\n- 메뉴 관리 크로스 depth 이동 지원 — `UpdateMenuOrderRequest`에 `moved_items` 검증 추가, `MenuRepository`에 크로스 depth reorder 로직 구현\n- `NotCircularParent` 검증 규칙 추가 — 메뉴 순환 참조 방지\n\n### Changed\n\n- ActivityLog 규정 문서 대폭 보강 (`docs/backend/activity-log.md`) — `description_params` 저장 정책, `ActivityLogDescriptionResolver` 패턴, Bulk Update ChangeDetector 패턴, 개발자 체크리스트 추가\n\n## [7.0.0-alpha.17] - 2026-03-26\n\n### Added\n\n- ActivityLog `description_params` ID→이름 변환 필터 훅 (`core.activity_log.filter_description_params`) — `ActivityLog::getLocalizedDescriptionAttribute()`에서 실행\n- 코어 모델 `$activityLogFields` 정의 — `User`, `Role`, `Menu`, `Schedule`, `MailTemplate` (5개 모델)\n- ActivityLog ChangeDetector 필드 라벨 다국어 키 추가 (`lang/ko/activity_log.php`, `lang/en/activity_log.php` — `fields` 섹션)\n- `module_helpers.php` — `getModuleSetting()` 헬퍼 함수 추가\n- CoreActivityLogListener: bulk_update per-User/per-Schedule 전환 + bulk_delete per-Schedule 전환 (건별 loggable_id 기록)\n- ActivityLogHandler: 삭제된 엔티티용 loggable_type/loggable_id 직접 지정 fallback 지원\n\n### Changed\n\n- ActivityLog 규정 문서 대폭 보강 (`docs/backend/activity-log.md`) — `description_params` 저장 정책, `ActivityLogDescriptionResolver` 패턴, Bulk Update ChangeDetector 패턴, 개발자 체크리스트 추가\n\n## [7.0.0-alpha.17] - 2026-03-26\n\n### Added\n\n- Monolog 기반 ActivityLog 아키텍처: `Log::channel('activity')` → `ActivityLogHandler` → DB (3단계)\n- `ActivityLogChannel` (커스텀 Monolog 채널), `ActivityLogHandler`, `ActivityLogProcessor` 신규\n- ActivityLog i18n 지원: `description_key` + `description_params` 기반 실시간 다국어 번역\n- ActivityLog 구조화된 변경 이력: `changes` JSON 컬럼 (필드별 `label_key`, `old`/`new`, `type` 포함)\n- `ChangeDetector` 유틸리티 (모델 스냅샷 비교 → 구조화된 변경 이력 생성)\n- `CoreActivityLogListener` 전면 확장: 모든 코어 Service 훅 구독 (User/Role/Menu/Settings/Schedule/Auth/Module/Plugin/Template/Layout/MailTemplate/Attachment — 66개 훅)\n- 활동 로그 다국어 키 105개 정의 (`lang/ko/activity_log.php`, `lang/en/activity_log.php`)\n- `config/logging.php`에 `activity` 채널 추가\n- `config/activity_log.php` 전용 설정 파일 신규\n- `activity_logs` 테이블 복합 인덱스 추가 (`loggable_type`+`loggable_id`+`created_at`, `log_type`+`action`+`created_at`)\n\n### Fixed\n\n- `resolveLogType()` 사용자 역할 기반 → 요청 경로 기반으로 변경: 관리자가 사용자 화면에서 수행한 액션이 `admin`으로 기록되던 문제 수정 (ResolvesActivityLogType)\n\n### Changed\n\n- `ActivityLog` 모델: `description` 컬럼 삭제 → `description_key`/`description_params` 기반 다국어 전환\n- `ActivityLogService`: 기록 메서드 전면 제거 → 조회 전용으로 축소\n- `ActivityLogResource`: `description` → `localized_description` (실시간 번역)\n- 모든 Controller에서 `logAdminActivity()` 호출 전면 제거 → Listener 경로로 전환\n\n### Removed\n\n- `activity_logs.description` 컬럼 (DB 삭제)\n- `ActivityLogManager`, `ActivityLogDriverInterface`, `DatabaseActivityLogDriver`, `NullActivityLogDriver`\n- `ActivityLogListener` (이중 훅 계층 — Monolog Handler로 대체)\n- `ActivityLogService.log`/`logAdmin`/`logUser`/`logSystem` (Monolog 채널로 대체)\n- `AdminBaseController.logAdminActivity()`, `generateActivityDescription()`, `flattenDataForTranslation()`\n\n### Fixed\n\n- 마이페이지 프로필 저장 시 국가·언어 미선택 상태에서 오류가 발생하던 문제 수정 — 해당 필드를 선택 사항으로 변경\n- 인스톨러 Step 2 .env 복사 명령어 안내 수정 — `.env.example.production` → `.env.example` (functions.php 2곳, installer.js 4곳)\n\n## [7.0.0-alpha.16] - 2026-03-23\n\n### Fixed\n\n- 코어 업데이트 롤백 시 vendor 디렉토리 복원 불가 수정 — 백업 targets에 vendor 포함, excludes에서 vendor 제거\n- 코어 백업 복원 시 개별 target 실패가 전체 복원을 중단하는 문제 수정 — 개별 try-catch로 나머지 target 복원 계속 진행\n- 코어 업데이트 롤백 실패 시 수동 복구 안내 미출력 수정 — composer install 등 복구 단계 안내 추가\n- 코어 업데이트 완전 복원 성공 시 유지보수 모드 자동 해제 추가\n- 코어 업데이트 시 vendor 디렉토리 이중 처리로 인한 마이그레이션 실패 수정 — `backup_only` 설정 분리 (applyUpdate 제외, 백업/복원 전용)\n\n## [7.0.0-alpha.15] - 2026-03-23\n\n### Added\n\n- Users UUID 전환 — 외부 노출 ID를 UUID v7으로 전환, 정수 `id`는 API 응답에서 숨김\n- UniqueIdService 코어 서비스 추가 (UUID v7 + NanoID 생성)\n- 코어 업그레이드 스텝 추가 (Upgrade_7_0_0_beta_15)\n\n### Changed\n\n- User 모델: `getRouteKeyName()` → 'uuid', `$hidden`에 'id' 추가\n- 공개 프로필 API: Route Model Binding 전환 (`{userId}` → `{user}`)\n- 사용자 벌크 상태변경: 정수 ID → UUID 기반\n- API Resource: user.id → user.uuid 전환 (UserResource, UserCollection 외 7개)\n- Activity Log 메타데이터: user_id → uuid 전환\n- FormRequest: 정수 검증 → UUID 검증 전환\n\n### Fixed\n\n- `_global.currentUser?.id` → `?.uuid` 전환 (sirsoft-basic 템플릿 12개 파일)\n- 글쓰기 버튼 abilities 비활성화 누락 수정\n- 코어 업그레이드 스텝 raw SQL 테이블 프리픽스 미적용 수정 — `UpgradeContext::table()` 헬퍼 추가 (Upgrade_7_0_0_beta_15)\n\n## [7.0.0-alpha.14] - 2026-03-20\n\n### Fixed\n\n- 확장 업데이트 시 임시 디렉토리(`_updating_*`, `_old_*`) 오토로드 오염 방지 — 임시 디렉토리를 `_pending/` 하위에 생성하여 IDE 잠금 등으로 잔존 시에도 Fatal Error 방지 (ExtensionPendingHelper)\n\n### Added\n\n- Windows 파일잠금 감지/해제 기능 — 확장 업데이트 시 IDE 등이 파일 핸들을 보유한 경우 자동 감지 및 해제 시도 (FileHandleHelper, ExtensionPendingHelper)\n\n- SEO 렌더러 훅 시스템: `core.seo.filter_context`, `core.seo.filter_meta`, `core.seo.filter_view_data` — 확장이 SEO 렌더링 파이프라인에 런타임 데이터 변환으로 개입 가능\n- `seo.blade.php` 확장 슬롯: `extraHeadTags` (`` 직전), `extraBodyEnd` (`` 직전) — `filter_view_data` 훅을 통해 커스텀 스크립트/스타일 주입\n- `ComponentHtmlMapper` pagination 렌더 모드 — Pagination 컴포넌트에서 SEO용 페이지 링크 자동 생성 (currentPage/totalPages props 기반)\n- `ComponentHtmlMapper` text_format dot notation 지원 — `{author.nickname}` 형태로 객체 prop의 중첩 필드 접근\n- `ExpressionEvaluator::evaluateRaw()` — 표현식 결과를 원본 타입(배열 등)으로 반환하는 메서드\n- `SeoRenderer` SEO 컨텍스트에 `_global`/`_local` 빈 객체 추가 — 프론트엔드 전용 상태 참조 시 null 대신 빈 객체 제공\n- `ComponentHtmlMapper` fields 렌더 모드 — 컴포지트 컴포넌트(ProductCard 등)의 객체 prop에서 SEO용 HTML 필드 자동 생성 (조건부/반복/속성 기반)\n- `SeoRenderer` seoVars 주입 — `meta.seo.vars` 선언을 해석하여 ComponentHtmlMapper format 모드에서 `{key}` 플레이스홀더 치환\n- `ExpressionEvaluator` 리터럴 값 감지 — 숫자, 문자열, boolean, null/undefined 리터럴을 경로 해석 없이 직접 반환\n- `ExpressionEvaluator` $t: 파라미터 `{{}}` 표현식 해석 — 번역 키 파라미터 값에 포함된 바인딩 표현식을 컨텍스트에서 평가\n- `TemplateManager` seo-config 검증에 `fields` 타입 추가\n- `ExpressionEvaluator` seo_overrides — seo-config.json에서 `_local`/`_global` 상태 오버라이드 선언 (와일드카드 매칭으로 접혀있는 콘텐츠 SEO 강제 펼침)\n- `TemplateManager` seo-config 검증에 `pagination` 타입 및 `seo_overrides` 검증 추가\n- `SeoConfigMerger` — 모듈/플러그인/템플릿의 seo-config.json을 수집·병합하는 동적 확장 시스템 (우선순위: 모듈 → 플러그인 → 템플릿, 24시간 TTL 캐싱)\n- `SeoRenderer` `_global` 컨텍스트 주입 — SettingsService/PluginSettingsService 프론트엔드 설정을 `_global`에 주입 + `initGlobal` 매핑으로 데이터소스 응답을 `_global` 경로에 바인딩\n- `AbstractModule`/`AbstractPlugin` SEO 기여 메서드 — `getSeoConfig()`, `getSeoDataSources()` 인터페이스 추가\n- `ModuleManager`/`PluginManager`/`TemplateManager` install/activate/update 시 훅 발행 추가 — Artisan 커맨드에서도 SEO 캐시 자동 무효화 보장\n- SEO Artisan 커맨드 다국어 파일 추가 (`lang/ko/seo.php`, `lang/en/seo.php`)\n- `ComponentHtmlMapper` fields 모드 `$all_props` source — 모든 props 표현식을 해석하여 데이터 객체로 사용 (Header/Footer 등 다수 props 컴포넌트용)\n- `ComponentHtmlMapper` fields 모드 `$t:` 번역 키 지원 — content 패턴에서 `$t:key` → 다국어 텍스트 렌더링\n- `ComponentHtmlMapper` fields iterate `item_attrs` — 아이템별 동적 HTML 속성 (예: `{ \"href\": \"/board/{slug}\" }`)\n- `ExpressionEvaluator` `evaluateRaw()` `??` null coalescing 지원 — 원본 타입(배열/객체) 유지하면서 null coalescing 수행\n- 인스톨러 Windows 환경 명령어 대응 — `chmod`/`chown` 스킵, 미존재 디렉토리 안내 메시지 추가\n- `ExpressionEvaluator` 삼항 연산자 (`a ? b : c`) — JS 우선순위 준수, `?.`/`??` 자동 구분, 중첩 우측 결합\n- `ExpressionEvaluator` `$t()` 함수 호출 구문 — 삼항 내부에서 `$t('key')` 형태로 번역 키 사용 가능 (기존 `$t:key` 방식 확장)\n- `ExpressionEvaluator` `$localized()` 전역 함수 — 다국어 객체에서 현재 로케일 값 추출 (`{ko: \"상품\", en: \"Product\"}` → `\"상품\"`)\n- `ExpressionEvaluator` 객체 리터럴 파서 — `{key: value, ...obj, [dynamicKey]: value}` 구문 지원\n- `ExpressionEvaluator` 스프레드 연산자 — 배열 `[...arr, item]` 및 객체 `{...obj, key: value}` 스프레드 지원\n- `SeoRenderer` computed 속성 해석 — 레이아웃 `computed` 섹션을 `_computed`/`$computed`에 저장 (문자열 표현식 + `$switch` 형식)\n- `ComponentHtmlMapper` classMap 지원 — `base`/`variants`/`key`/`default`로 조건부 CSS 클래스 선언적 적용\n\n### Fixed\n\n- Redis 캐시 DB가 환경설정 값(`REDIS_CACHE_DB`)을 따르도록 수정 — `config/database.php` cache 연결 DB 반영\n\n- `ExpressionEvaluator` 배열 리터럴 파싱 지원 — `['gallery','card'].includes(...)` 표현식에서 배열 리터럴을 PHP 배열로 변환하여 `includes` 등 배열 메서드 정상 동작 (SEO 게시판 타입 분기 조건 중복 렌더링 수정)\n- `ExpressionEvaluator` null 비교 JavaScript 시맨틱 적용 — `null !== value` → `true`, `null === null` → `true` (SEO 컨텍스트에서 `_global` 미존재 경로 비교 시 빈 문자열 대신 올바른 boolean 반환)\n- `ExpressionEvaluator` $t: 번역에서 `{{param}}` 형식 파라미터 치환 지원 — 템플릿 번역 파일의 `{{param}}` + Laravel 표준 `:param` 형식 모두 처리\n- `ExpressionEvaluator` 비교 연산 좌측 optional chaining 경로 타입 보존 — `?.` 포함 경로가 `evaluateExpression`으로 불필요 라우팅되어 boolean 타입이 문자열로 변환되던 문제 수정\n\n## [7.0.0-alpha.13] - 2026-03-18\n\n### Added\n\n- `SeoCacheRegenerator` — 단건 URL 캐시 즉시 재생성 서비스 (다국어 로케일별 렌더링 + 캐시 저장)\n- `SeoSettingsCacheListener` — 코어 SEO 설정 변경 시 전체 SEO 캐시 + 사이트맵 삭제\n\n### Fixed\n\n- `SeoMiddleware`에서 `put()` 대신 `putWithLayout()` 사용 — `invalidateByLayout()`이 레이아웃명 미저장으로 항상 0건 매칭되던 근본 버그 수정\n- `SeoRenderer`에서 레이아웃명을 request attribute로 저장 — `putWithLayout()` 연동\n\n## [7.0.0-alpha.12] - 2026-03-17\n\n### Changed\n\n- API 인증을 토큰 전용으로 전환 — 세션 기반 인증 의존 제거, Bearer 토큰 단일 방식으로 통일\n- README 업데이트 — 표기 통일, 섹션 정비, 문서 링크 연결\n\n## [7.0.0-alpha.11] - 2026-03-16\n\n### Added\n\n- 역할(Role) 상태 토글 API 추가 — `PATCH /api/admin/roles/{role}/toggle-status` 엔드포인트, 훅 지원 (`core.role.before_toggle_status`, `core.role.after_toggle_status`)\n- RoleResource에 `can_toggle_status` ability 추가 — 역할별 토글 권한 제어\n- RoleResource에 `extension_name` 필드 추가 — 확장 출처별 로케일 이름 표시\n- 역할 생성 시 `identifier` 직접 입력 기능 추가 — 미입력 시 name 기반 자동 생성 유지\n- 역할 관련 다국어 키 추가 (ko/en) — identifier 검증 메시지\n- `replaceUrl` 핸들러 추가 — refetch 없이 URL만 변경 (페이지네이션 등 브라우저 히스토리 관리용)\n- 사용자 검색 API에 `id` 파라미터 지원 추가 — 특정 사용자 ID로 직접 조회 가능\n- `TimezoneHelper` 유틸리티 클래스 추가 — 사용자/서버 타임존 간 변환 헬퍼\n- `HasSampleSeeders` 트레이트 추가 — 모듈/플러그인 시더에서 `--sample` 옵션으로 샘플 데이터만 분리 실행\n- `ComponentRegistry.getComponentNames()` 메서드 추가 — 등록된 컴포넌트 이름 목록 조회\n\n### Fixed\n\n- 템플릿 에러 페이지에서 `:identifier`가 리터럴로 표시되는 버그 수정 — Blade `@` 이스케이프 처리\n- ActionDispatcher onChange raw value fallback 제거 — 마운트/리렌더 시 의도치 않은 setState 실행으로 상품 폼 회귀 유발\n- Form 자동 바인딩 setState 경합 수정 — 자동 바인딩과 수동 setState가 동시 실행 시 stale 값으로 덮어쓰이는 문제 해결\n- Form 자동 바인딩 bindingType 메타데이터 기반 boolean 바인딩 수정 — Toggle/Checkbox 등 boolean 컴포넌트에서 문자열 변환 대신 boolean 값 유지\n- SPA 네비게이션 시 `_global._local`에 이전 페이지 상태가 잔존하는 버그 수정 — 페이지 전환 시 `_local` 초기화 처리\n- DynamicRenderer `_computed` 참조 prop에서 캐시된 stale 값이 사용되는 버그 수정 — `_computed`/`$computed` 참조 시 `skipCache: true` 적용\n\n### Changed\n\n- 마이그레이션 통합 — 증분 마이그레이션을 테이블당 1개 create 마이그레이션으로 정리\n- 시더 디렉토리 분리 — 설치 시더와 샘플 시더를 `Sample/` 하위로 분리, `--sample` 옵션 추가\n- 라이선스 프로그램 명칭 정비\n- 일정 관리 메뉴 기본 비활성화 (`config/core.php`)\n- Composer 의존성 업데이트 — Laravel Framework v12.54.1, Reverb v1.8.0, Symfony v7.4.6~7 등\n\n## [7.0.0-alpha.9] - 2026-03-13\n\n### Added\n\n- 그누보드7 커스텀 에러 페이지 도입 — Laravel 기본 에러 페이지(401, 403, 404, 500, 503)를 그누보드7 스타일로 교체, 다크 모드 지원, 접근 경로 기반 홈 링크 분기 (admin → `/admin`, 기타 → `/`)\n- 환경설정 > 고급 디버그 모드 활성화 시 개발 대시보드(`/dev`) 바로가기 버튼 추가\n- 루트 LICENSE 파일 생성 (MIT 라이선스, 한국어 번역 + 영문 원문)\n- 코어 라이선스/Changelog API 엔드포인트 추가 (`GET /api/admin/license`, `GET /api/admin/changelog`)\n- 확장 라이선스 API 엔드포인트 추가 (`GET /api/admin/modules/{id}/license`, `GET /api/admin/plugins/{id}/license`, `GET /api/admin/templates/{id}/license`)\n- Admin 푸터 copyright 클릭 → 코어 라이선스 모달, 버전 클릭 → Changelog 모달 표시 기능\n- 확장 상세 모달에서 라이선스 클릭 시 전문 모달 표시 기능 (모듈/플러그인/템플릿)\n- 각 번들 확장에 LICENSE 파일 및 manifest `license` 필드 추가\n\n### Changed\n\n- 설치 화면 라이선스를 루트 LICENSE 파일로 통합 (`public/install/lang/license-ko.txt`, `license-en.txt` 삭제)\n- `/dev` 라우트 뷰 이름을 `dev-dashboard`로 변경\n\n## [7.0.0-alpha.8] - 2026-03-13\n\n### Changed\n\n- `.env.example.develop`과 `.env.example.production`을 `.env.example`로 통합 — 설치형 솔루션에 환경별 분리 불필요, Laravel/Vite 표준 준수\n- 인스톨러에서 `.env.production` 백업 파일 생성 로직 제거 — Vite mode 기반 로딩 충돌 방지\n\n### Improved\n\n- 코어/확장 업데이트 시 composer install 스킵 최적화 — composer.json/composer.lock 미변경 시 composer install 및 vendor 디렉토리 교체를 건너뛰어 업데이트 시간 단축\n\n### Fixed\n\n- 확장 수동 설치 모달에서 설치 실패 시 상세 에러 사유(`errors.error`) 미표시 문제 수정 — 3개 모달에 상세 에러 P 요소 추가\n- `checkDependencies()` 복수 의존성 에러 수집 — 첫 번째 미충족 의존성에서 즉시 throw 대신 전체 수집 후 줄바꿈 연결 (ModuleManager, PluginManager, TemplateManager)\n- ModuleController/PluginController 에러 반환 형식을 TemplateController와 통일 — `['error' => $e->getMessage()]` 형태\n- 확장 설치 실패 시 `_pending/{identifier}` 디렉토리 자동 정리 — ModuleService, PluginService, TemplateService에 try-catch 추가\n\n## [7.0.0-alpha.7] - 2026-03-13\n\n### Added\n\n- 템플릿 엔진 `multipart/form-data` 지원 — apiCall 핸들러 및 DataSourceManager에서 `contentType: \"multipart/form-data\"` 설정 시 params를 FormData로 자동 변환\n - ActionDispatcher: `fetchWithOptions()`에 FormData 변환 로직 추가, Content-Type 헤더 자동 생략 (브라우저 boundary 설정)\n - DataSourceManager: `toFormData()` 메서드 추가, 인증/비인증 경로 모두 multipart 지원\n - File/Blob 원본 유지, null/undefined 제외, 객체/배열 JSON.stringify 변환\n- `deepMergeWithState()` non-plain 객체(File/Blob/Date) 보호 — spread 복사로 인한 내부 데이터 소실 방지\n- `resolveParams()` non-plain 객체 재귀 해석 스킵 — File 객체가 빈 객체로 변환되는 문제 방지\n- 컴포넌트 onChange raw value fallback — FileInput, Toggle 등 Event가 아닌 값을 전달하는 컴포넌트 지원\n\n### Fixed\n\n- DataSourceManager `isMultipart` 변수 TDZ(Temporal Dead Zone) 버그 수정 — 선언 전 참조로 인한 ReferenceError 해결\n\n## [7.0.0-alpha.6] - 2026-03-13\n\n### Fixed\n\n- 플러그인 환경설정 페이지 진입 시 404 오류 수정 — `registerPluginLayouts()` admin/user 분기 도입 후 루트 `settings.json`이 스킵되던 문제\n - 플러그인 설정 레이아웃을 `resources/layouts/settings.json` → `resources/layouts/admin/plugin_settings.json`으로 이동하여 모듈과 동일한 구조로 통일\n - `AbstractPlugin::getSettingsLayout()` 경로 변경\n - `PluginSettingsService` 오버라이드 경로 및 주석 수정\n - 영향받는 플러그인: sirsoft-daum_postcode, sirsoft-marketing, sirsoft-tosspayments\n\n### Changed\n\n- 플러그인 설정 레이아웃 규정 문서(`plugin-development.md`) 경로/설명 업데이트\n\n## [7.0.0-alpha.5] - 2026-03-12\n\n### Added\n\n- 확장(모듈/플러그인/템플릿) changelog GitHub 원격 소스 지원 — `source=github` 시 GitHub에서 CHANGELOG.md 조회, 실패 시 bundled 폴백\n- `ChangelogParser::parseFromString()`, `getVersionRangeFromString()` 문자열 기반 파싱 메서드 추가\n- `ChangelogRequest` validation 에러 다국어 메시지 추가 (source, version format, required_with)\n- 플러그인 GitHub/ZIP 설치 기능 추가 — 모듈/템플릿에는 있지만 플러그인에 누락되어 있던 기능 신규 구현\n - `PluginService::installFromGithub()`, `installFromZipFile()`, `findPluginJson()` 메서드 추가\n - `PluginController::installFromFile()`, `installFromGithub()` 엔드포인트 추가\n - `InstallPluginFromGithubRequest`, `InstallPluginFromFileRequest` FormRequest 추가\n - `install-from-file`, `install-from-github` API 라우트 추가\n- `ExtensionManager::hasComposerDependenciesAt(string $path)` 메서드 추가 — 임의 경로의 Composer 의존성 확인\n- 모듈/플러그인 설치 시 `_pending` Composer 선행 설치 로직 추가 — 활성 디렉토리 이관 전 의존성 설치\n\n### Changed\n\n- 확장 수동 설치 모달 3개(모듈/플러그인/템플릿) UI 통일 — TabNavigation underline, 에러 배너, 필드별 적색 테두리\n- 확장 GitHub/ZIP 설치 공통 로직을 `GithubHelper`, `ZipInstallHelper`로 추출하여 3개 Service 중복 제거\n- `CoreUpdateService` GitHub 관련 protected 메서드를 `GithubHelper` 위임으로 리팩토링\n- 확장 목록 PageHeader에서 새로고침 버튼 제거, 업데이트 확인 버튼으로 통일\n- 코어 업데이트 `core_pending` 고정 경로 → `core_{Ymd_His}` 타임스탬프 기반 격리 디렉토리로 변경\n- 확장(모듈/플러그인/템플릿) 업데이트에 `_pending/{identifier}_{timestamp}/` 스테이징 패턴 도입\n- 확장 업데이트 시 스테이징 내에서 composer install 실행 (활성 디렉토리 무영향)\n- 확장 GitHub 다운로드 코드를 코어와 동일한 패턴으로 통합 (인증 헤더, 폴백 체인, 타임아웃)\n- GitHub 다운로드 공용 로직을 `ExtensionManager`로 추출하여 3개 Manager 중복 제거\n- `config/app.php`에서 `preserves` 설정 키 제거 (타임스탬프 격리로 불필요)\n- 모듈/플러그인 GitHub/ZIP 설치 흐름을 `temp → _pending → composer install → 활성 디렉토리` 패턴으로 통일\n\n### Fixed\n\n- 코어 업데이트 결과 모달에서 from/to 버전 파라미터가 전달되지 않는 버그 수정 — `params.params` → `params.query`\n- 플러그인에 GitHub/ZIP 설치 기능이 누락되어 있던 결함 수정\n- 파일 복사 시 퍼미션/소유자/소유그룹 미보존 문제 수정 — `File::copy()` → `FilePermissionHelper::copyFile()` 교체 (6개 위치)\n- Windows 환경에서 확장/코어 업데이트 후 `_pending` 하위에 빈 디렉토리가 잔존하는 문제 수정 — `cleanupStaging()`에 3단계 retry 로직 추가\n- 코어 업데이트 후 vendor 교체 시 stale `packages.php`/`services.php`로 인한 500 오류 수정 — `clearAllCaches()`에 컴파일 캐시 삭제 + `package:discover` + `extension:update-autoload` 추가\n- 코어 업데이트 시 `bootstrap/cache` 디렉토리가 소스에서 덮어씌워지는 문제 수정 — excludes에 `bootstrap/cache` 추가\n\n## [7.0.0-alpha.4] - 2026-03-12\n\n### Added\n\n- 코어 업그레이드 스텝 검증용 샘플 마이그레이션 및 업그레이드 스텝 추가\n- 코어 업그레이드 스텝 경로를 `database/upgrades/` → `upgrades/`로 변경\n- 업그레이드 스텝 실행을 프로그레스바 별도 단계로 분리 및 터미널 피드백 추가\n\n### Changed\n\n- `--source` 모드에서 원본 소스 디렉토리를 `_pending`으로 복제 후 작업 (원본 보호)\n- Step 8: 운영 디렉토리 `composer install` 재실행 → `_pending/vendor/` 복사로 변경 (효율화)\n\n### Fixed\n\n- `--source` 모드에서 소스 버전 감지 시 현재 `env()` 대신 `config/app.php` default 값 파싱\n- 코어 업데이트 targets에 `upgrades` 디렉토리 누락 수정\n\n## [7.0.0-alpha.3] - 2026-03-12\n\n### Fixed\n\n- `.gitattributes`의 `CHANGELOG.md export-ignore`가 모든 CHANGELOG 파일을 릴리스 아카이브에서 제외하던 문제 수정\n- PharData(tar ustar) 100바이트 경로 제한으로 324개 파일이 누락되어 orphan 삭제가 발생하던 버그 수정\n\n### Removed\n\n- PharData 아카이브 추출 전략 제거 — tar ustar 형식의 100바이트 경로 제한은 근본적 해결 불가\n\n### Added\n\n- `core:update --source=` 옵션 추가 — ZipArchive/unzip 불가 환경에서 수동 업데이트 지원\n - 상대경로, 절대경로, Windows 경로 모두 지원\n - 소스 디렉토리의 그누보드7 프로젝트 유효성 검증 (`config/app.php` + `version` 키)\n- 업데이트 안내 모달에 수동 업데이트 가이드 섹션 추가\n- 시스템 요구사항 미충족 시 `--source` 옵션 안내 메시지 추가\n\n## [7.0.0-alpha.2] - 2026-03-12\n\n### Fixed\n- 코어 업데이트 확인 시 업데이트할 버전이 없으면 피드백 없던 문제 수정\n - `openModal` 호출 형식 수정 (`params.id` → `target`)\n - 모달 데이터 바인딩 경로 수정 (`_local` → `$parent._local`)\n- 코어 업데이트 명령어 에러 미출력 수정\n- `.env` 예제 파일에서 `G7_UPDATE_TARGETS` 하드코딩 제거\n\n### Added\n- 코어 업데이트 targets 확장 및 orphan 삭제 로직 추가\n- 코어 업데이트 `--local` 옵션 및 설정 기반 제외/보존 추가\n- 환경설정 고급 탭 코어 업데이트 설정 섹션 추가\n\n## [7.0.0-alpha.1] - 2026-03-07\n\n### Added\n\n#### 코어 아키텍처\n\n- Laravel 12 기반 CMS 플랫폼 초기 구조 설계 및 구현\n- Service-Repository 패턴 기반 계층 분리 아키텍처 구축\n- CoreServiceProvider를 통한 인터페이스-구현체 바인딩 시스템\n- ResponseHelper를 통한 통일된 API 응답 형식 (success/error/paginated)\n- AdminBaseController / AuthBaseController / PublicBaseController 컨트롤러 계층 구조\n- FormRequest + Custom Rule 기반 검증 시스템\n- BaseApiResource 상속 기반 API 리소스 패턴\n- PHP 8.2+ Backed Enum 기반 상태/타입/분류 관리\n\n#### 확장 시스템 (Extension System)\n\n- 모듈(Module) 시스템: 디렉토리 스캔 기반 자동 발견, 설치/활성화/비활성화/삭제 관리\n- 플러그인(Plugin) 시스템: 모듈 의존 기반 기능 확장, 설정 UI(settings.json) 지원\n- 템플릿(Template) 시스템: Admin/User 타입 분리, JSON 기반 레이아웃, 컴포넌트 레지스트리\n- ExtensionManager: 모듈/플러그인/템플릿 통합 관리 (설치, 업데이트, 삭제)\n- HookManager: Action/Filter 훅 시스템 (doAction, applyFilters, HookListenerInterface)\n- 확장 업데이트 시스템: _bundled/_pending 디렉토리 구조, GitHub/로컬 업데이트 감지 및 적용\n- 확장 백업/복원 시스템 (ExtensionBackupHelper)\n- 확장 상태 가드 (ExtensionStatusGuard): Installing/Updating/Uninstalled 상태 관리\n- 확장 권한 동기화 (ExtensionRoleSyncHelper): 설치/삭제 시 역할-권한 자동 동기화\n- 확장 메뉴 동기화 (ExtensionMenuSyncHelper): 모듈 메뉴 자동 등록/해제\n- 확장 오토로드 시스템: 런타임 Composer 오토로드 (composer.json 수정 불필요)\n- 확장 Composer 의존성 관리 (extension:composer-install 커맨드)\n- 확장 업그레이드 스텝 시스템 (UpgradeStepInterface, UpgradeContext)\n- 확장 설정 시스템 (SettingsMigrator): 모듈/플러그인별 독립 설정 관리\n- 확장 소유권 시스템: extension_type/extension_identifier 기반 리소스 귀속\n- 확장 에셋 시스템: module.json 매니페스트 기반 JS/CSS 자동 로딩\n- 확장 Changelog 시스템: ChangelogParser 헬퍼 + API 엔드포인트 + 관리 화면 인라인 표시\n- 확장 빌드 시스템: Artisan 커맨드 기반 (module:build, template:build, plugin:build)\n- 확장 캐시 관리: 확장별 독립 캐시 + 일괄 클리어 커맨드\n\n#### 템플릿 엔진 (Template Engine)\n\n- DynamicRenderer: JSON 레이아웃 기반 React 컴포넌트 동적 렌더링\n- DataBindingEngine: `{{expression}}` 문법, Optional Chaining(`?.`), Nullish Coalescing(`??`) 지원\n- ActionDispatcher: 20+ 내장 핸들러 (navigate, apiCall, setState, openModal, closeModal, sequence, condition, replaceUrl, scrollTo, copyToClipboard, downloadFile, debounce, emit, showToast, confirm, validate, submit, reset, filter, sort 등)\n- ComponentRegistry: 컴포넌트 등록/검색/해석 시스템 (기본/집합/레이아웃 타입)\n- TranslationEngine: `$t:key` 즉시 평가 / `$t:defer:key` 지연 평가 다국어 바인딩\n- LayoutLoader: JSON 레이아웃 로딩, 캐싱, ETag 기반 조건부 요청\n- Router: SPA 라우팅, 동적 경로 파라미터(`{{route.id}}`), 쿼리스트링 관리\n- 레이아웃 상속 시스템: `extends` 기반 베이스 상속 + `type: \"slot\"` 위치에 컨텐츠 삽입\n- Partial 시스템: 레이아웃 모듈화, 컴포넌트 치환 (data_sources/computed/modals/state 미지원)\n- 조건부 렌더링: `if` 속성 기반 표현식 평가 (type: \"conditional\" 미지원)\n- 반복 렌더링: `iteration` 설정 (source, item_var, index_var, key)\n- 반응형 레이아웃: `responsive` 속성 기반 breakpoint 오버라이드 (portable/compact/wide)\n- 다크 모드: Tailwind `dark:` variant 기반 자동 전환\n- classMap: 조건부 CSS 클래스 매핑 (key → variants)\n- computed: 계산된 속성 시스템 (의존성 추적, 자동 재계산)\n- 모달 시스템: `modals` 섹션 + openModal/closeModal 핸들러 (`_global.modalStack` 기반)\n- init_actions: 레이아웃 초기화 시 자동 실행 액션 (루트 레이아웃 레벨)\n- Named Actions: 액션 재사용 시스템 (DRY 패턴)\n- errorHandling: 전역/데이터소스별 에러 핸들링 설정\n- scripts: 레이아웃 레벨 커스텀 스크립트 로딩\n- globalHeaders: API 호출 공통 헤더 설정 (pattern 기반 매칭)\n- blur_until_loaded: 데이터 로딩 전 블러 처리\n- lifecycle: 컴포넌트 생명주기 훅 (onMount, onUnmount)\n- slots: 컴포넌트 슬롯 시스템\n- layout_extensions: 모듈/플러그인의 동적 UI 주입 포인트\n- isolatedState: 컴포넌트 상태 격리\n- 데이터소스 조건부 로딩: `if` 표현식 기반 활성화/비활성화\n\n#### 컴포넌트 시스템\n\n- Basic 컴포넌트 (27+): Div, Button, Input, Select, Form, A, H1~H6, Span, P, Img, Label, Textarea, Table, Thead, Tbody, Tr, Th, Td, Ul, Ol, Li, Hr, Strong, Em, Small, Pre, Code, Blockquote, Nav, Header, Footer, Main, Section, Article, Aside, Figure, FigCaption\n- Composite 컴포넌트: DataGrid, CardGrid, Pagination, Modal, SearchBar, Tabs, TabPanel, Accordion, Badge, Breadcrumb, Card, Checkbox, CheckboxGroup, DatePicker, Dropdown, FileUpload, Icon, Notification, Radio, RadioGroup, RangeSlider, Rating, Select (enhanced), Sidebar, Stepper, Switch, Tag, Timeline, Toast, Tooltip, PasswordInput, DynamicFieldList, SortableList, ColorPicker, NumberInput, TreeView, DateRangePicker\n- Layout 컴포넌트: FlexLayout, GridLayout, ScrollLayout, StickyLayout, Spacer, Container, AspectRatio\n- HtmlEditor: TinyMCE 기반 HTML 에디터 컴포넌트\n- Icon 컴포넌트: Font Awesome 6.4.x Free 아이콘 지원 (Solid 1,390개 / Regular 163개 / Brands 472개)\n- Alert, EmptyState, LoadingSpinner, Skeleton, StatusBadge, CopyButton 유틸리티 컴포넌트\n\n#### 상태 관리\n\n- 전역 상태 (`_global`): 앱 전체 공유, 페이지 이동 시 유지\n- 로컬 상태 (`_local`): 레이아웃 단위 격리\n- 계산된 상태 (`_computed`): 의존성 기반 자동 재계산\n- 폼 자동 바인딩: Form 컴포넌트 `stateKey` 기반 자동 상태 연동\n- setState 핸들러: target(global/local), 함수형 업데이트, 배열 조작 (push/filter/map)\n- 상태 구독: `G7Core.state.subscribe` 기반 반응형 업데이트\n- initGlobal: 전역 상태 초기값 선언\n- 모달 스코프 상태: `$parent._local` 스냅샷 기반 데이터 전달\n\n#### 인증 시스템\n\n- Laravel Sanctum 하이브리드 인증 (세션 + Bearer 토큰)\n- AuthManager: 싱글톤 인증 상태 관리\n- 자동 토큰 갱신: 401 응답 시 자동 리프레시 후 재시도\n- OptionalSanctumMiddleware: 선택적 인증 지원 (비인증 사용자 허용)\n- 로그인/로그아웃 3단계 프로토콜 (토큰 삭제 → 세션 무효화 → Auth::logout)\n- 비밀번호 재설정: 이메일 인증 기반 토큰 발급 및 검증\n- 회원가입: 약관 동의, 이메일 인증 지원\n\n#### 권한 시스템\n\n- Role 기반 권한 관리 (User → Role → Permission 3계층)\n- permission 미들웨어 체인 기반 접근 제어 (FormRequest authorize() 사용 금지)\n- 확장별 권한 자동 등록/해제\n- 역할별 메뉴 접근 제어 (role_menus 피벗 테이블)\n- 슈퍼 관리자 (superadmin) 전체 권한 자동 부여\n\n#### 메뉴 시스템\n\n- 계층형 메뉴 구조 (parent_id 기반)\n- 역할별 메뉴 가시성 제어\n- 모듈 메뉴 자동 등록 (getAdminMenus 인터페이스)\n- 메뉴 순서 관리 (드래그 앤 드롭 SortableList)\n- 다국어 메뉴명 지원 (JSON 배열 형식)\n\n#### 데이터 소스 (Data Sources)\n\n- API 엔드포인트 선언적 정의 (id, endpoint, method, params)\n- loading_strategy: immediate/lazy/manual 3가지 로딩 전략\n- 데이터소스 의존성: depends_on 기반 연쇄 로딩\n- 폴링: poll_interval 기반 주기적 갱신\n- 조건부 로딩: `if` 표현식 기반 활성화\n- transform: 응답 데이터 변환 함수\n- cache_duration: 응답 캐싱\n\n#### 관리자 기능 (Admin)\n\n- 대시보드: 시스템 정보, 통계 위젯, 최근 활동\n- 사용자 관리: CRUD, 역할 할당, 상태 관리 (활성/비활성/차단)\n- 역할 관리: CRUD, 권한 할당, 다국어 역할명\n- 권한 관리: 카테고리별 권한 목록, 역할별 권한 할당\n- 메뉴 관리: 계층형 메뉴 편집, 순서 변경, 역할별 가시성 설정\n- 모듈 관리: 설치/활성화/비활성화/삭제, 업데이트 확인, 상세 정보 모달, changelog 표시\n- 플러그인 관리: 설치/활성화/비활성화/삭제, 설정 UI, 업데이트 확인, changelog 표시\n- 템플릿 관리: 설치/활성화/비활성화/삭제, 업데이트 확인, changelog 표시\n- 환경설정: 사이트 기본 정보, SEO 설정, 메일 설정, 보안 설정, 탭 레이아웃\n- 일정 관리: 스케줄 CRUD, 캘린더 뷰, 카테고리 분류\n- 메일 템플릿 관리: DB 기반 메일 템플릿 CRUD, 변수 치환, 미리보기 기능\n- 메일 발송 로그: 발송 이력 조회, 상태 추적, 상세 정보 모달\n- 시스템 정보: PHP/Laravel/DB 버전, 디스크 사용량, 확장 현황 표시\n- 코어 업데이트: 버전 확인, 업데이트 가이드, changelog 인라인 표시, 백업 생성\n\n#### 사용자 기능 (User/Public)\n\n- 로그인/회원가입/비밀번호 재설정 페이지\n- 마이페이지: 프로필 수정, 비밀번호 변경\n- 통합 검색 기능\n- 게시판 뷰: 목록/상세/작성/수정 (board 모듈 연동)\n- 에러 페이지: 403, 404, 500, 503 커스텀 에러 페이지\n- 점검 모드(maintenance) 페이지\n\n#### 설치 프로그램 (Installer)\n\n- 다단계 웹 설치 마법사 (환경 체크 → DB 설정 → 관리자 생성 → 완료)\n- SSE(Server-Sent Events) 기반 실시간 설치 진행 상태 표시\n- 설치 롤백 기능: 실패 시 자동 복원 (마이그레이션/시더 롤백)\n- 언어 선택 지원 (한국어/영어)\n- 다크 모드 지원\n- 환경 요구사항 자동 검증 (PHP 버전, 확장, 디렉토리 권한)\n\n#### 모듈: sirsoft-board (게시판)\n\n- 게시판 관리: CRUD, 카테고리, 스킨 설정, 권한 설정\n- 게시글 관리: CRUD, 검색, 정렬, 페이지네이션\n- 댓글 시스템: CRUD, 대댓글 (계층형), 답글 알림\n- 신고 시스템: 게시글/댓글 신고, 관리자 처리 (승인/거절)\n- 첨부파일: 이미지/파일 업로드, 다운로드, 인라인 표시\n- 비밀글: 작성자/관리자만 열람 가능\n- 블라인드/복원: 관리자 블라인드 처리 및 복원 기능\n- 카드/갤러리 레이아웃: 다양한 목록 표시 형태 지원\n- 게시판 권한: 읽기/쓰기/댓글/관리 권한 분리\n- 인기글/최신글 위젯\n- SEO 메타 태그 자동 생성\n\n#### 모듈: sirsoft-ecommerce (이커머스)\n\n- 상품 관리: CRUD, 옵션(사이즈/색상), 라벨, SEO 메타, 이미지 갤러리\n- 상품 카테고리: 계층형 카테고리, 순서 관리, TreeView 편집\n- 브랜드 관리: CRUD, 로고, 설명\n- 주문 관리: 주문 목록, 상태 변경, 상세 정보, 주문 타임라인\n- 쿠폰 시스템: 정액/정률 할인, 사용 조건 (최소 금액, 특정 상품), 유효기간, 사용 횟수 제한\n- 배송 정책: 무료/유료/조건부 배송, 지역별 요금 설정\n- 공통 정보 관리: 배송/교환/환불 안내, 판매자 정보\n- 장바구니: 추가/수량 변경/삭제, 옵션별 관리, 품절 상품 알림\n- 체크아웃: 주소 입력 (다음 우편번호 연동), 배송지 저장 체크박스, 결제수단 선택, 쿠폰 적용\n- 주문 완료: 주문 번호, 결제 정보 요약, 장바구니 자동 비우기\n- 상품 상세 페이지: 이미지 갤러리, 옵션 선택, 수량 입력, 장바구니 담기\n- 상품 목록: 필터링 (카테고리/브랜드/가격), 정렬, 페이지네이션, 카드 그리드\n- 위시리스트: 찜하기/해제 기능\n- 상품 검색: 키워드 검색, 카테고리 필터 연동\n\n#### 모듈: sirsoft-page (페이지 관리)\n\n- CMS 페이지: CRUD, 슬러그 기반 URL 매핑\n- 페이지 버전 관리: 버전 이력 조회, 이전 버전 복원\n- 첨부파일: 이미지/파일 업로드 지원\n- 검색 통합: 페이지 내용 통합 검색 지원\n- SEO: 메타 태그, Open Graph 설정\n\n#### 플러그인: sirsoft-daum_postcode (다음 우편번호)\n\n- 다음 우편번호 검색 API 연동\n- 주소 선택 후 폼 자동 입력\n- 체크아웃 배송지 입력 연동\n\n#### 플러그인: sirsoft-tosspayments (토스페이먼츠)\n\n- 토스페이먼츠 결제 API 연동\n- 카드/계좌이체/가상계좌/무통장입금 결제 지원\n- 결제 확인 (confirm) 프로세스\n- 결제 성공/실패 콜백 처리\n- 관리자 설정 UI (API 키 관리)\n\n#### 플러그인: sirsoft-marketing (마케팅 동의)\n\n- 마케팅 동의 관리: 이메일 구독, 마케팅 동의, 제3자 제공 동의\n- MarketingConsent / MarketingConsentHistory 모델 및 서비스\n- MarketingConsentListener 훅 리스너 (회원가입/프로필 연동)\n- 사용자 관리 화면 레이아웃 확장 (마케팅 동의 상세/폼/프로필)\n- 회원가입 폼 마케팅 동의 항목 확장\n- 플러그인 설정 UI\n- 역할 기반 접근 제어 (마케팅 관리자)\n- 다국어 지원 (ko, en)\n\n#### 플러그인: sirsoft-verification (본인인증)\n\n- 휴대폰 인증, 아이핀 인증 등 본인인증 기능 제공\n- 사용자 관리 화면 레이아웃 확장 (본인인증 상세/폼)\n- 역할 기반 접근 제어 (본인인증 관리자)\n- 다국어 지원 (ko, en)\n\n#### 템플릿: sirsoft-admin_basic (관리자)\n\n- 관리자 대시보드 레이아웃\n- 사이드바 네비게이션: 접기/펼치기, 계층형 메뉴, 활성 상태 표시\n- 상단바: 사용자 정보, 알림 벨, 다크 모드 전환 토글\n- 반응형 레이아웃 (데스크톱/태블릿/모바일)\n- 관리자 전용 컴포넌트: DataGrid, CardGrid, SearchBar, 필터 패널 등\n- CRUD 화면 표준 레이아웃 (목록/생성/수정/상세)\n- 모달 기반 상세 보기/수정 기능\n- 토스트 알림 시스템\n- 확인 다이얼로그 (삭제 확인 등)\n- 환경설정 탭 레이아웃\n- 모듈/플러그인/템플릿 관리 화면 (설치/업데이트/상세 모달)\n- 사용자/역할/권한 관리 화면\n- 메뉴 관리 화면 (드래그 앤 드롭 순서 변경)\n- 일정 관리 캘린더 화면\n- 메일 템플릿 관리 화면\n- 메일 발송 로그 화면\n- 시스템 정보 화면\n- 코어 업데이트 가이드/결과 모달\n\n#### 템플릿: sirsoft-basic (사용자)\n\n- 사용자 메인 페이지 레이아웃\n- 헤더/푸터 공통 레이아웃 (반응형)\n- 로그인/회원가입/비밀번호 재설정 화면\n- 마이페이지 레이아웃\n- 게시판 목록/상세/작성/수정 화면\n- 상품 목록/상세 화면\n- 장바구니/체크아웃/주문완료 화면\n- 검색 결과 화면\n- CMS 페이지 표시 화면\n- 에러 페이지 (403, 404, 500, 503)\n- 점검 모드(maintenance) 전용 페이지\n- 반응형 레이아웃 (데스크톱/태블릿/모바일)\n\n#### 다국어 시스템 (i18n)\n\n- 백엔드 다국어: `__()` 함수 기반, `lang/{locale}/*.php` 파일 구조\n- 프론트엔드 다국어: `$t:key` 즉시 평가 바인딩, `$t:defer:key` 지연 평가 바인딩\n- 컴포넌트 다국어: `G7Core.t()` API 제공\n- 모듈 다국어: 모듈별 독립 언어 파일, 네임스페이스 분리 (`__('vendor-module::key')`)\n- 템플릿 다국어: partial 언어 파일 분할 (admin.json, errors.json 등)\n- DB 다국어 필드: JSON 배열 형식 (`{\"ko\": \"...\", \"en\": \"...\"}`)\n- 지원 로케일: 한국어(ko), 영어(en)\n- TranslatableField 트레이트: 모델 다국어 필드 자동 해석\n\n#### 보안 (Security)\n\n- ValidLayoutStructure: JSON 레이아웃 구조 검증 Custom Rule\n- WhitelistedEndpoint: API 엔드포인트 화이트리스트 검증 Custom Rule\n- NoExternalUrls: 외부 URL 차단 검증 Custom Rule\n- ComponentExists: 컴포넌트 존재 여부 검증 Custom Rule\n- CSRF 보호: Laravel 기본 CSRF 토큰 검증\n- XSS 방지: 출력 이스케이프, HTML sanitize 처리\n- SQL Injection 방지: Eloquent ORM, 파라미터 바인딩 사용\n- Rate Limiting: API 요청 제한 미들웨어\n\n#### 메일 시스템\n\n- DB 기반 메일 템플릿: 제목/본문 DB 관리, 변수 치환 (Blade 문법)\n- Notification + Mailable 통합 패턴: BaseNotification 상속으로 보일러플레이트 제거\n- 메일 발송 로그: 발송 이력 DB 기록, 수신자/상태/발송 시간 추적\n- 메일 템플릿 사용자 오버라이드 추적 (user_overrides 필드)\n\n#### 알림 시스템 (Notification)\n\n- BaseNotification: 모든 알림의 베이스 클래스 (via() 보일러플레이트 제거)\n- 알림 채널: 메일, 데이터베이스, 브로드캐스트 지원\n- 실시간 알림: Laravel Reverb (WebSocket) 기반 Broadcasting\n\n#### 스토리지 시스템\n\n- StorageInterface: 모든 파일 저장의 추상화 인터페이스 (Storage::disk() 직접 호출 금지)\n- CoreStorageDriver: 코어 스토리지 구현체\n- 확장별 독립 스토리지 공간 할당\n\n#### 코어 업데이트 시스템\n\n- CoreUpdateService: GitHub API 기반 코어 버전 확인 및 업데이트 감지\n- CoreUpdateController: 업데이트 상태 확인, 가이드 표시 API 엔드포인트\n- CoreBackupHelper: 업데이트 전 코어 파일 백업 생성\n- FilePermissionHelper: 파일/디렉토리 쓰기 권한 사전 검증\n- MaintenanceModePage 미들웨어: 점검 모드 시 전용 페이지 표시\n- 업데이트 가이드 모달: changelog 인라인 표시, 단계별 안내\n\n#### 그누보드7 DevTools\n\n- MCP 서버: 20+ 디버깅 도구 제공\n - 기본 도구: g7-state, g7-actions, g7-cache, g7-diagnose, g7-lifecycle, g7-network, g7-form, g7-expressions, g7-logs\n - 고급 분석: g7-datasources, g7-handlers, g7-events, g7-performance, g7-conditionals, g7-websocket\n - 상태 계층/스타일: g7-renders, g7-state-hierarchy, g7-context-flow, g7-styles, g7-auth, g7-tailwind, g7-layout\n - Phase 8 심화: g7-computed, g7-nested-context, g7-modal-state, g7-sequence, g7-stale-closure, g7-change-detection\n- 브라우저 상태 덤프: 런타임 상태 캡처 및 MCP 서버 전송\n- UI 패널: 브라우저 내 DevTools 패널 (실시간 상태/액션/로그 확인)\n- 페이지네이션: offset/limit 기반 대용량 데이터 조회 지원\n\n#### WYSIWYG 에디터\n\n- 비주얼 레이아웃 에디터: 드래그 앤 드롭 기반 레이아웃 편집\n- PropertyPanel: 컴포넌트 속성 편집 UI\n- 컴포넌트 팔레트: 사용 가능한 컴포넌트 드래그 목록\n- 실시간 미리보기: 편집 즉시 렌더링 결과 확인\n\n#### 성능 최적화\n\n- 레이아웃 캐싱: 파싱된 레이아웃 JSON + 상속 병합 결과 캐시\n- 확장 캐싱: 모듈/플러그인/템플릿 목록 및 메타데이터 캐시\n- 번역 캐싱: 언어 파일 파싱 결과 캐시\n- ETag 지원: API 응답 조건부 캐시 검증 (304 Not Modified)\n- Gzip 압축: API 응답 자동 압축\n- Debounce 액션: 연속 이벤트 디바운스 처리 핸들러\n- Lazy 로딩: 데이터소스 지연 로딩 (스크롤/이벤트 트리거)\n- 순환 참조 방지 메커니즘: 레이아웃 상속 무한 루프 감지\n\n#### 데이터베이스\n\n- 코어 테이블: users, roles, permissions, role_has_permissions, role_menus, menus, settings, mail_templates, mail_send_logs, schedules, modules, plugins, templates, template_layouts, template_layout_versions\n- 모든 컬럼 한국어 comment 필수 규칙 적용\n- down() 메서드 완전 롤백 구현 필수 규칙 적용\n- 다국어 필드 JSON 배열 형식 표준\n- MariaDB 호환성 지원\n- boolean/enum 컬럼 값 설명 comment 포함 규칙\n\n#### 테스트 인프라\n\n- PHPUnit 11.x: 백엔드 단위 테스트 (Unit) 및 기능 테스트 (Feature)\n- Vitest: 프론트엔드 컴포넌트/템플릿 엔진 테스트\n- createLayoutTest(): 레이아웃 JSON 렌더링 테스트 유틸리티 (Vitest + jsdom)\n- mockApi(): API 응답 모킹 유틸리티 (fetch 자동 모킹)\n- 트러블슈팅 회귀 테스트: 해결된 사례별 자동 검증\n- 테스트 커버리지: 모델, 서비스, 컨트롤러, 컴포넌트, 레이아웃 렌더링\n\n#### 빌드 시스템\n\n- Vite 기반 프론트엔드 빌드 (코드 분할, 트리 쉐이킹)\n- Artisan 커맨드 기반 빌드 관리:\n - `core:build`: 코어 템플릿 엔진 빌드 (--full, --watch 옵션)\n - `module:build`: 모듈 프론트엔드 빌드 (--all, --watch, --active 옵션)\n - `template:build`: 템플릿 빌드 (--all, --watch, --active 옵션)\n - `plugin:build`: 플러그인 빌드 (--all, --watch, --active 옵션)\n- _bundled 디렉토리 기본 빌드, --active 옵션으로 활성 디렉토리 빌드\n- --watch 모드: 파일 감시 기반 실시간 빌드 (활성 디렉토리 자동 사용)\n\n#### Artisan 커맨드\n\n- 모듈 관리: module:list, module:install, module:activate, module:deactivate, module:uninstall, module:composer-install, module:cache-clear, module:seed, module:check-updates, module:update, module:build\n- 플러그인 관리: plugin:list, plugin:install, plugin:activate, plugin:deactivate, plugin:uninstall, plugin:composer-install, plugin:cache-clear, plugin:seed, plugin:check-updates, plugin:update, plugin:build\n- 템플릿 관리: template:list, template:install, template:activate, template:deactivate, template:uninstall, template:cache-clear, template:check-updates, template:update, template:build\n- 확장 공통: extension:composer-install, extension:update-autoload\n- 코어: core:build\n\n#### 개발 도구 및 문서\n\n- AI 에이전트 개발 가이드 (AGENTS.md): 핵심 원칙, 코딩 규칙, 디버깅 프로토콜\n- 규정 문서 체계 (docs/): 백엔드 14개, 프론트엔드 59개, 확장 23개, 공통 4개 (총 100개)\n- AI 에이전트 자동화 도구: 검증/분석/구현 스킬 30+개, 인덱스 자동 생성 스크립트\n- 트러블슈팅 가이드: 상태 관리, 캐시, 컴포넌트, 백엔드 문제 해결 사례집\n- 코드 스타일: Laravel Pint (PSR-12) 자동 적용\n" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** 코어의 `CHANGELOG.md` 파일 원문 텍스트를 `content`로 반환합니다. `auth:sanctum` 인증이 필요하며, 파일이 없으면 404(`common.not_found`)를 반환합니다. 관리자 화면에서 코어의 전체 변경 이력을 마크다운 원문 그대로 표시하는 용도로 사용합니다. + + diff --git a/docs/backend/api/core-update.md b/docs/backend/api/core-update.md new file mode 100644 index 00000000..fda80651 --- /dev/null +++ b/docs/backend/api/core-update.md @@ -0,0 +1,1639 @@ +# Core Update API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Core Update 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/admin/core-update/changelog + +- **라우트명**: `api.admin.core-update.changelog` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\CoreUpdateController@changelog` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| source | query | string | 아니오 | `active`, `bundled`, `github` | 어느 위치의 CHANGELOG를 조회할지 지정 (active: 활성 설치본, bundled: 번들 원본, github: 원격 릴리스). 미지정 시 기본 조회 경로를 사용 | +| from_version | query | string | 아니오 | — | 시작 버전 (범위 하한) | +| to_version | query | string | 아니오 | — | 대상 버전 (범위 상한) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_rules`). + +**요청 예시** + +```http +GET /api/admin/core-update/changelog?source=active&from_version=%EC%98%88%EC%8B%9C%EA%B0%92&to_version=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| changelog | array | `[{"version":"7.0.2","date":"2026-07-05","categories":[{"n…` | 변경 이력 텍스트 (원격/파일 CHANGELOG 본문) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "설정을 성공적으로 가져왔습니다.", + "data": { + "changelog": [ + { + "version": "7.0.2", + "date": "2026-07-05", + "categories": [ + { + "name": "Added", + "items": [ + "설치된 모듈·플러그인이 늘어날수록 화면마다 개별로 내려받던 확장 스크립트·스타일 요청을 종류별로 하나로 묶어 내려받도록 개선해, 확장이 많은 사이트의 초기 로딩 요청 수를 크게 줄였습니다. 확장을 설치·활성화·비활성화하면 묶음이 자동으로 다시 만들어져 항상 현재 활성 상태와 일치합니다." + ] + }, + { + "name": "Changed", + "items": [ + "화면·데이터를 내려받을 때의 전송 용량을 줄여 초기 로딩과 페이지 이동 속도를 개선했습니다.", + "모듈·플러그인의 묶음 스크립트·스타일이 지금까지 압축 없이 전송되던 것을 압축 전송하도록 바꿔, 확장이 많은 사이트에서 첫 화면에 받는 용량을 크게 줄였습니다.", + "한글 등 다국어 텍스트가 응답에 불필요하게 부풀려 담기던 것을 원래 형태 그대로 담도록 바꿔, 특히 다국어 번역 데이터의 전송 용량을 줄였습니다.", + "화면을 그리는 데 쓰이지 않는 개발용 설명 주석을 화면 정보 응답에서 빼도록 해 응답 용량을 추가로 줄였습니다." + ] + }, + { + "name": "Fixed", + "items": [ + "코어 업데이트 시 레이아웃 편집기·개발자 진단도구 번들의 빌드 설정 파일이 갱신 대상에서 누락되던 문제를 수정했습니다. 이제 코어를 업데이트하면 이 파일들도 최신 내용으로 반영됩니다.", + "설치 마법사에서 Vendor 설치 방식을 \"Composer 실행\"으로 선택해 설치할 때, 일부 Windows 환경에서 Composer 설치 로그의 문자 인코딩이 어긋나면 진행 상태 응답이 비어 진행 상황이 표시되지 않고 오류가 반복될 수 있던 문제를 보완했습니다. 어떤 문자가 섞이더라도 진행 로그가 항상 정상적으로 전달되도록 처리했습니다. (#62 @kitrio 님께서 제보해주셨습니다.)", + "설치 마법사의 \"설치 환경 확인\" 단계에서, storage 폴더 하위에 파일이 많이 쌓인 환경일수록 요구사항 검사가 오래 걸리던 문제를 개선했습니다. 폴더 쓰기 권한 검사가 불필요하게 깊은 하위까지 훑던 것을 필요한 범위로 제한해 이 단계가 즉시 완료되도록 했습니다.", + "설치 시 \"읽기 전용 데이터베이스(Read DB)를 사용하지 않음\"으로 선택했는데도, 읽기 전용 DB 접속 정보를 확인하려다 설치 마지막 단계에서 실패하던 문제를 수정했습니다. 이제 읽기 전용 DB를 사용하지 않으면 쓰기 DB 정보만으로 설치가 정상 완료됩니다. 아울러 설치 이후에도 읽기 전용 DB 접속 정보를 비워 두면 조회 연결이 자동으로 쓰기 DB로 대체되도록 하여, 별도의 읽기 전용 DB 없이도 안정적으로 동작합니다. (#63 @kitrio 님께서 제보해주셨습니다.)" + ] + } + ] + }, + { + "version": "7.0.1", + "date": "2026-07-03", + "categories": [ + { + "name": "Changed", + "items": [ + "첫 화면 접속 시 내려받는 코어 스크립트 용량을 절반 이하로 줄여 초기 로딩을 개선했습니다. 레이아웃 편집기와 개발자 진단도구를 코어에서 분리해, 편집기는 편집 화면에 들어갈 때만, 진단도구는 디버그 모드를 켰을 때만 내려받도록 했습니다. 일반 사용자는 평소 쓰지 않는 편집기·진단도구 코드를 더 이상 받지 않습니다.", + "설치가 끝난 사이트에서 매 요청마다 반복되던 불필요한 데이터베이스 조회를 줄여 페이지 응답 속도를 개선했습니다. 알림·본인인증 설정을 준비하는 과정에서 이미 존재가 보장된 테이블을 매번 다시 확인하던 동작을 설치 완료 상태에서는 건너뛰도록 했습니다.", + "화면을 그릴 때마다 서버가 매번 확장 기능의 연결 정보를 처음부터 다시 계산하던 부담을 줄여 페이지 응답 속도를 개선했습니다. 이 연결 정보는 확장을 설치·수정하거나 코어를 업데이트할 때만 바뀌므로 그 시점에 한 번 저장해 두고, 이후 요청에서는 저장된 값을 재사용하도록 했습니다. 확장이나 코어를 변경하면 저장 값이 자동으로 갱신됩니다.", + "설치된 모듈·플러그인의 코드를 찾을 때마다 서버가 파일을 하나하나 탐색하던 부담을 줄여, 특히 공유 호스팅 등 파일 접근이 느린 환경에서 페이지 응답 속도를 개선했습니다. 확장 코드의 위치를 설치·수정 시점에 미리 정리해 두고 이후 요청에서는 그 목록을 바로 참조하도록 했습니다. 확장을 설치·수정·삭제하거나 코어를 업데이트하면 목록이 자동으로 갱신됩니다.", + "코어 업데이트 중 데이터 변환 단계가 현재 환경에서 자동으로 끝까지 실행되지 못하고 중단될 때, 남은 단계를 이어서 실행하는 안내를 실행 권한에 맞게 개선했습니다. 특히 관리자 권한(sudo)으로 업데이트한 경우, 남은 단계를 관리자 권한 그대로 실행하면 그 과정에서 만들어지는 파일이 관리자 소유로 남아 이후 웹사이트가 그 파일에 쓰지 못하는 문제가 생길 수 있습니다. 이제 이런 경우 웹 서버 실행 계정(예: www-data)으로 실행하도록 정확한 명령을 함께 안내하며, 웹 서버 계정을 자동으로 확인하지 못하면 어떤 계정으로 실행해야 하는지 확인 방법을 안내합니다." + ] + }, + { + "name": "Fixed", + "items": [ + "Windows 환경에서 코어 업데이트가 `public/storage` 링크를 복사하는 단계에서 \"디렉토리는 복사할 수 없습니다\" 오류로 중단되던 문제를 수정했습니다. Windows의 저장소 링크(정션)가 이제 링크 그대로 안전하게 보존되어 업데이트가 정상 완료됩니다.", + "별도 데이터 변환 작업이 필요 없는 버전으로 코어를 업데이트할 때, \"실행할 업그레이드 단계가 없다\"는 정상 상황을 실패로 표시하고 중단하던 문제를 수정했습니다. 이제 변환 작업이 없는 업데이트는 매끄럽게 완료되며, 이어서 설치된 확장의 최신 번들 반영 단계까지 정상적으로 진행됩니다.", + "일부 호스팅 환경에서 관리자 환경설정의 보안 화면에 들어갈 때 시스템 정보 조회가 전체 실패(오류 응답)하던 문제를 수정했습니다. 이제 CPU·메모리·디스크 등 특정 항목을 서버 정책상 읽을 수 없더라도 그 항목만 \"알 수 없음\"으로 표시하고 나머지 시스템 정보는 정상적으로 보여줍니다. 읽지 못한 항목은 로그로 원인을 확인할 수 있습니다. (#59 @glitter-gim 님께서 제보해주셨습니다.)" + ] + } + ] + }, + { + "version": "7.0.0", + "date": "2026-07-01", + "categories": [ + { + "name": "Added", + "items": [ + "모듈·플러그인이 기존 화면의 초기화 동작에 자신의 초기화 단계를 덧붙일 수 있도록 레이아웃 확장 기능을 확대했습니다. 화면이 특정 모듈을 직접 알지 않아도, 모듈이 설치되어 있을 때만 그 모듈의 초기화(예: 표시 통화 복원)가 함께 실행됩니다.", + "관리자 대시보드 통계에 설치된 템플릿 수(활성/전체)와 활성 언어팩 수(활성/전체)를 추가했습니다.", + "관리자 대시보드에 최근 발송된 알림 이력을 보여주는 \"최근 알림\" 위젯용 조회 기능을 추가했습니다. 발송 채널·수신자·제목·상태(성공/실패/건너뜀)·발송 시각을 최신순으로 확인할 수 있습니다.", + "본인인증 challenge 취소(cancel) 와 verification 토큰 소비(consume_token) 시점에 외부 본인인증 플러그인이 자기 데이터 정리 로직을 등록할 수 있는 훅 4건 추가.", + "플러그인 개발용 표준 베이스 클래스를 추가하여, 모듈 개발과 동일한 방식으로 Repository · 캐시 · 스토리지 의존성을 선언만으로 자동 주입받을 수 있도록 개선. 플러그인이 코어와 같은 표준 위에서 작성되어 일관된 캐시·파일 격리 동작을 제공합니다.", + "모듈·플러그인이 페이지에 주입하는 레이아웃 확장을 관리자 화면에서 직접 편집할 수 있도록 개선. 편집 내역은 버전으로 기록되어 이전 버전으로 복구할 수 있으며, 저장 전 미리보기를 제공합니다.", + "좌측 트리 — 편집 화면 좌측 목록을 트리 구조로 개편하여 일반 레이아웃과 확장 주입 레이아웃을 구분 표시. 확장 주입 레이아웃은 해당 템플릿에 실제 적용되는 항목만 표시됩니다.", + "편집 진입과 캔버스 — 템플릿 관리 화면에 코드 편집과 레이아웃 편집 진입을 분리하고, 운영 화면 좌측 하단에 편집 권한 보유자 전용 진입 버튼을 추가했습니다. 편집 캔버스는 실제 페이지와 동일한 렌더링 결과를 보여주며, 데스크탑·태블릿·모바일 폭을 즉시 전환해 반응형 결과를 확인할 수 있습니다. 데이터 소스가 정의된 화면은 샘플 데이터로 자동 채워져 실제 API 없이도 화면을 검토할 수 있고, 본문 영역에 사용된 위지윅 에디터 등 동적 자원도 캔버스 안에서 그대로 동작합니다. 좌측 트리에서 화면을 선택하면 주소창이 즉시 반영되어 새로고침·공유·뒤로가기 동작이 자연스럽고, 같은 주소로 직접 진입하면 해당 화면이 자동 선택됩니다. 편집 중 로그인이 풀리면 안내 후 자동으로 로그인 화면으로 이동하며 재로그인 시 편집 화면으로 복귀합니다. 권한 부족·서버 오류·네트워크 끊김 등은 상황별 안내 카드(아이콘·설명·다음 행동 버튼)로 표시합니다.", + "요소 추가·재배치 — 컴포넌트를 클릭해 선택하면 외곽에 + 버튼이 나타나고, + 버튼을 누르면 화면 중앙에 모달 팔레트가 열려 좌측 카테고리 사이드바와 우측 그리드 카드(아이콘 + 라벨 + 유형 뱃지)에서 추가할 컴포넌트를 고를 수 있습니다. 부모 컨테이너에 들어갈 수 있는 컴포넌트만 자동 필터링되며, 검색으로 컴포넌트를 빠르게 찾을 수 있습니다. 추가한 컴포넌트는 템플릿이 정의한 기본 모양·기본 텍스트로 캔버스에 바로 보이며, 추가 직후 해당 위치로 화면이 부드럽게 스크롤됩니다. 컴포넌트 우상단 ⓘ 아이콘으로 속성 설정·복사·삭제 메뉴를 열고, 호버/선택 시 외곽선·8방향 리사이즈 핸들 자리·잠금/네비게이션 어포던스가 표시됩니다. 변경 사항은 Ctrl+Z / Ctrl+Shift+Z 또는 툴바 ↶/↷ 로 되돌리거나 다시 실행할 수 있습니다. 편집 모드에서 컴포넌트를 클릭해도 페이지 이동이 일어나지 않고 선택만 되며, 내부 라우트로 가리키는 요소는 \"→ 이 화면 편집\" 어포던스로 목적지 화면 편집 모드로 즉시 전환됩니다.", + "저장·충돌 방지 — 낙관적 잠금으로 저장 결과는 성공·검증 실패·동시 저장 충돌·네트워크 오류 등 상황별 안내(성공 토스트 / 오류 배너 / 충돌 모달)로 즉시 표시됩니다. 같은 레이아웃을 다른 관리자가 먼저 저장했으면 충돌 안내 모달에서 최신 버전 불러오기를 선택할 수 있고, 신규 추가한 컴포넌트가 그 사이 비활성화된 모듈·플러그인 소속이면 저장이 자동 차단됩니다. 저장 직후에는 되돌리기 이력이 정리되어 저장 시점이 새 기준이 되며, 변경 사항이 있는 상태에서 다른 화면으로 이동하려 하면 저장하지 않은 내용이 있다는 안내가 먼저 표시됩니다. 로그인·회원가입 등 비로그인 전용 화면을 편집할 때는 로그인 상태 미리보기가 적용되지 않아 안내 토스트가 잘못 뜨지 않습니다.", + "속성 편집 — 요소를 선택하면 정렬·글자 크기·굵기·색상·여백·크기 등을 친화적인 컨트롤(분절 버튼·슬라이더·색상 선택·태그 입력)로 조정할 수 있고, 요소 모서리·변을 직접 드래그해 가로·세로 크기를 바꿀 수 있습니다(드래그 크기 조정도 되돌리기/다시 실행에 반영). 반복 영역(목록처럼 같은 틀이 여러 번 펼쳐지는 자리)의 속성을 편집하면 모든 항목에 함께 적용됩니다. 요소를 편집하는 동안에는 선택한 요소만 밝게 유지되고 나머지 화면은 어둡게 잠겨, 다른 요소를 실수로 건드리지 않습니다. 속성 편집 팝업은 헤더를 잡아 옮길 수 있으며 선택한 요소를 가리지 않도록 빈 공간으로 자동 배치되고, 큰 요소에서도 요소 추가·메뉴 버튼이 크기 조절 핸들과 겹치지 않도록 여백을 둡니다. 고급 설정에서 요소를 볼 수 있는 권한을 권한 목록에서 골라 지정할 수 있습니다. 목록 있음·없음, 회원·비회원, 검증 실패·저장 중 같은 화면 상태를 미리보기로 전환해 데이터 의존 영역을 실제와 가깝게 검토할 수 있고, 같은 데이터 이름을 여러 확장이 쓰더라도 각 화면이 자기 출처의 샘플로 올바르게 표시됩니다. 라우트를 전환해도 저장하지 않은 편집 내용이 유지되며, 미저장 상태로 화면을 떠나거나 새로고침하면 경고가 표시됩니다. 마지막 저장 상태로 한 번에 되돌리는 초기화 버튼도 제공합니다.", + "이미지 관리 — 배경 이미지 등 레이아웃 첨부 이미지를 업로드·관리할 수 있으며, 상단 툴바와 배경 이미지 컨트롤에서 썸네일 갤러리를 열어 업로드·삭제·배경 적용을 할 수 있으며, 지정한 배경 이미지가 편집 캔버스에 즉시 반영되고(채움·맞춤·타일 표시 모드 포함) 모달을 다시 열어도 현재 설정이 그대로 복원됩니다. 발행된 배경 이미지는 일반 방문자 화면에서도 정상적으로 표시됩니다.", + "동작·표시조건·정렬 편집 — 요소를 선택하고 속성 편집 팝업에서 (1) 클릭·마우스 오버 시 동작을 \"다른 페이지로 이동\"·\"안내 메시지 보여주기\"·\"화면 상태 바꾸기\"·\"목록 새로고침\"·\"서버에 보내고 결과 처리\"·\"결제 진입\" 같은 친화 명칭으로 조립하고(서버 호출은 성공·실패 후속 동작까지 단계로 구성), (2) 로그인 회원·관리자·데이터 있음/없음·수정/새 화면·입력 오류 등 친화 조건을 \"그리고/또는\"으로 결합해 요소를 보여줄 조건을 정하며, (3) 가로·세로 컨테이너의 자식 배치(방향·정렬·줄바꿈·간격)와 정렬 박스 안 요소의 늘어남·정렬·순서를 시각 컨트롤로 편집할 수 있습니다. 핸들러·코드 용어 없이 친화 입력만 노출되며, 이동할 페이지·데이터 이름은 목록에서 선택합니다. \"결제 진입\" 동작은 특정 결제사에 고정하지 않고, 서버 응답이 알려주는 결제 수단(결제 핸들러·결제 데이터)을 데이터 검색에서 골라 연결합니다 — 결제 수단을 바꾸거나 추가해도 화면 편집 없이 동작합니다.", + "색 모드·디바이스별 스타일 — 속성 편집에 라이트/다크 색 모드와 디바이스별 편집을 더해, 스타일·표시조건 탭 상단의 서브탭에서 라이트/다크 색 모드와 기본값·PC·태블릿·모바일(+ 직접 지정하는 커스텀 화면 폭)을 선택해, 각 범위에 스타일·정렬·표시 조건을 따로 지정할 수 있습니다. 글자색·배경색은 패널의 프리셋 색을 고르면 라이트와 다크 모두에 적용되어 다크 화면용 색을 비개발자도 지정할 수 있고(컬러 피커로 직접 고른 자유 색은 라이트 화면 전용), 어느 범위에 기본값과 다른 설정이 있으면 탭에 표시점이 뜨며 \"기본값으로 초기화\"로 그 범위만 되돌릴 수 있습니다. 편집기 상단 라이트/다크 토글로 미리보기 색상 테마를 전환하면, 관리자 화면이 다크 테마여도 미리보기가 라이트/다크를 정확히 구분해 보여줍니다.", + "화면 문구 다국어 편집 — 상단 툴바의 콘텐츠 언어 선택으로 미리보기에 표시되는 화면 문구의 언어를 전환할 수 있고, 캔버스의 텍스트를 더블클릭하면 그 자리에서 바로 고칠 수 있습니다(Enter 로 확정, Esc 로 취소). 문구를 고치면 그 문구가 다국어 키로 자동 등록되어, 입력한 내용이 즉시 화면에 반영됩니다. 편집 중 떠 있는 서식 막대로 굵게·기울임·밑줄을 켜고 끄거나, 글자 크기·정렬·글자색을 목록에서 골라 적용할 수 있으며(색은 색상 미리보기와 함께 선택), 바뀐 서식은 편집 중에도 즉시 보입니다. 편집 안내 배지를 누르면 속성 편집의 번역 탭이 열려 모든 언어의 값을 한 번에 입력할 수 있어, 한 언어만 고치고 다른 언어 번역을 놓치는 일을 줄입니다. (데이터에 연결된 문구·반복 영역 문구는 직접 편집 대상에서 제외됩니다.)", + "모든 컴포넌트로 속성 편집 확대 — 어떤 요소를 선택해도 속성 탭에 \"요소 ID\" 칸이 항상 제공되어 화면 내 요소에 고유 식별자를 부여할 수 있으며(화면 안전 문자만 입력되고 한글·공백 등은 자동으로 걸러집니다), 컴포넌트별 편집 항목(링크 주소·이미지 주소·입력칸 종류·선택지·정렬 옵션 등)도 친화 컨트롤로 편집할 수 있습니다. 아이콘은 자유 입력 대신 검색 가능한 그리드에서 골라 지정할 수 있어(템플릿이 제공하는 아이콘 목록을 검색으로 빠르게 찾기), 아이콘 이름을 외우지 않아도 됩니다. 입력칸의 선택지처럼 여러 항목으로 된 값은 항목 추가·삭제·순서 변경으로 편집하며, 이미 데이터에 연결된 값은 실수로 덮어쓰지 않도록 보호됩니다.", + "모달 시각 편집 — 좌측 트리에 각 화면이 사용하는 모달 목록이 \"이 화면의 모달\" 그룹으로 표시되고(공통 레이아웃의 모달, 모듈·플러그인 화면의 모달, 별도 파일로 분리된 모달 포함), 각 모달은 \"주문 취소\"·\"비회원 조회 비밀번호 재설정\"처럼 친화 제목으로 표시됩니다. 모달을 선택하면 실제 화면 위에 모달이 열린 상태로 문구 수정·요소 추가·저장을 할 수 있습니다. 모달 바깥 영역은 어둡게 잠겨 클릭·드래그·편집이 차단되어 실수로 호스트 화면을 건드리지 않습니다.", + "버전 배지·복원 — 좌측 트리에 각 화면과 확장 주입 조각의 현재 저장 버전 배지가 표시되고, 버전 기록 모달에서 이전 버전과의 비교(diff)와 복원을 확장 주입 조각에서도 동일하게 사용할 수 있으며, 저장·복원 시 배지가 새로고침 없이 즉시 갱신되고 복원 결과가 캔버스에 바로 반영됩니다.", + "공통 레이아웃 편집 — 각 화면의 콘텐츠가 채워질 슬롯 자리가 점선 박스와 라벨로 표시되고(슬롯 자체는 편집 대상이 아니라 잠금), 헤더·푸터를 클릭해 바로 선택·편집할 수 있습니다. 모달 편집 중에도 편집 캔버스의 스크롤이 그대로 유지됩니다.", + "화면 상태 미리보기 — 모달·공통 레이아웃까지 확대해, 배송지 직접 입력, 모바일 메뉴 펼침, 쿠키 배너 표시, 본인인증 진행 모달처럼 특정 조작에서만 나타나는 영역을 상태 전환만으로 미리보며 편집할 수 있고, 모달 안에 확장이 주입한 영역도 시각 편집할 수 있습니다.", + "일부 템플릿(사용자 템플릿 등)의 화면을 미리볼 때 헤더가 \"데이터를 표시할 수 없습니다\"로 표시되지 않고, 미리보기용 샘플 데이터가 모든 템플릿에서 안전하게 렌더됩니다.", + "회원이 아닌 사용자(비회원)에게도 이메일 알림을 보낼 수 있도록 알림 시스템을 확장했습니다. 회원에게 알림을 보내던 것과 동일한 방식으로, 입력한 이메일을 수신자로 알림이 발송되며 발송 내역에도 함께 기록됩니다. 알림 종류별로 비회원 발송을 허용할 수단을 선택할 수 있어, 이메일은 비회원에게 보내되 사이트 안 알림처럼 로그인이 필요한 수단은 보내지 않도록 구분됩니다(새 알림 수단이 추가될 때 비회원 허용 여부를 별도로 정하지 않으면 기본적으로 보내지 않습니다). 비회원에게 가는 알림은 그 사용자가 보던 화면 언어로 발송됩니다." + ] + }, + { + "name": "Changed", + "items": [ + "모든 번들 플러그인이 위 표준 베이스 클래스로 이관되어 캐시·스토리지 도메인이 코어 및 다른 확장과 명확히 격리되도록 개선. 한 플러그인의 캐시 키가 코어 캐시 영역을 침범하던 잠재 결함을 차단하며, 관리자 화면의 변경사항이 의도대로 즉시 반영됩니다.", + "템플릿이 정의하는 에러 페이지(404 / 403 / 500 / 503 / 401 / 점검) 의 식별자를 다른 모든 페이지와 동일한 디렉토리 접두사 포함 형식(`errors/{코드}`) 으로 통일. 템플릿 설치 시 에러 페이지가 일관된 식별자로 시드되며, 일부 환경에서 에러 페이지가 \"찾을 수 없음\" 응답으로 떨어지던 회귀를 차단합니다. 번들 템플릿(`sirsoft-basic`, `sirsoft-admin_basic`) 의 에러 페이지 설정도 함께 정합화됩니다.", + "템플릿 외부 리소스 위임 — Font Awesome·웹폰트 등 외부 CDN 리소스를 페이지 코드에서 하드코딩하지 않고 템플릿의 `externals` 선언으로 위임. 외부 스타일시트·웹폰트·스크립트와 preconnect·preload 등 리소스 힌트를 한 곳에서 관리하며, 관리자 템플릿과 사용자 템플릿에 동일하게 적용", + "템플릿 컴포넌트 에셋 캐시 버스팅 — `?v=time()` → `?v={{ $extensionCacheVersion }}`로 변경하여 매 요청 무효화 → 확장 변경 시점에만 무효화", + "모듈·플러그인 업데이트 시 관리자가 편집한 확장 레이아웃을 보존하거나 덮어쓰는 전략을 선택할 수 있도록 개선.", + "코어 업그레이드 스텝 명령에 보조 단계(권한 정상화·마이그레이션·캐시 정리 등)를 건너뛰고 스텝만 실행하는 옵션 추가.", + "코어 영역의 프론트엔드 다국어 자원(에러 메시지·시스템 토스트 등) 위치를 정리하여 어떤 템플릿이 부팅되어도 자동 노출되도록 개선. 이전에 코어 에러 화면에서 `core.errors.*` 같은 키가 raw 로 노출되던 결함이 해소됩니다." + ] + }, + { + "name": "Fixed", + "items": [ + "정책 관리에서 코어·모듈·플러그인이 제공한 정책의 \"인증 목적\"·\"적용 대상\"·\"우선순위\"를 바꿔 저장해도 반영되지 않던 문제를 수정했습니다 — 이제 정책 키·강제 시점·강제 위치(정책이 걸리는 지점)만 고정되고, 그 외 모든 항목을 운영자가 자유롭게 변경할 수 있으며, 변경한 값은 확장 업데이트 후에도 유지됩니다.", + "한 화면에 본인인증 정책이 여러 개 걸리고 우선순위가 같을 때, 어느 정책이 먼저 적용될지 정해지지 않아 의도한 인증(예: 성인 인증)이 무시되고 다른 인증이 적용될 수 있던 결함을 수정했습니다. 이제 우선순위가 같아도 항상 동일한 정책이 적용되도록 적용 순서를 고정하고, 정책 추가·수정 화면에 우선순위 입력칸을 제공하며 같은 위치에 같은 우선순위의 활성 정책을 저장하려 하면 저장을 막고 안내합니다. 정책 목록에도 우선순위 열을 추가해 한눈에 확인할 수 있습니다.", + "성인 인증 같은 추가 조건을 함께 요구할 때, 그 조건을 충족하지 못해 인증이 실패하면 실패 사유 안내와 \"본인 확인이 필요합니다\" 안내가 동시에 뜨던 문제를 수정했습니다. 이제 추가 조건 미충족으로 실패하면 그 사유 안내 하나만 표시되며, 일반 본인인증 실패의 안내는 종전대로 표시됩니다.", + "환경설정의 기본 프로바이더 / 목적별 프로바이더 / 코드 유효시간 / 최대 시도 횟수 4개 항목이 admin UI 저장값을 무시하던 결함 수정. 변경한 환경설정값이 회원가입·비밀번호 재설정 등 모든 본인인증 흐름에 즉시 반영됩니다.", + "환경설정 > 본인인증 > 목적별 프로바이더에서 일부 본인인증 목적(예: 성인인증)의 프로바이더를 지정해 저장하면 \"설정 저장에 실패했습니다\" 오류로 전혀 저장되지 않던 결함 수정. 이제 모든 본인인증 목적의 프로바이더 매핑이 정상 저장됩니다. 아울러 저장 검증에 실패하면 문제가 된 목적별 프로바이더 입력칸이 빨갛게 강조되고 그 아래에 사유가 표시되어, 어느 항목을 고쳐야 하는지 바로 확인할 수 있습니다.", + "정책에 프로바이더가 지정되지 않은 경우 환경설정의 기본 프로바이더가 자동 적용되도록 보강. 운영자가 정책마다 프로바이더를 일일이 지정하지 않아도 환경설정 기본값으로 일관되게 동작합니다.", + "코드 입력 모달의 \"남은 시도 횟수\" 표시가 환경설정값과 무관하게 항상 5회로 굳어 있던 결함 수정. 운영자가 환경설정에서 지정한 한도가 사용자 화면에 정확히 표시됩니다.", + "인증 코드를 최대 횟수까지 잘못 입력해 시도가 소진되었을 때, 화면에 \"최대 시도 초과 — 다시 요청해주세요\" 안내 대신 일반 오류 문구(\"인증 코드가 올바르지 않습니다\")가 표시되던 결함 수정. 시도가 소진되면 재요청을 안내하는 메시지가 정확히 표시됩니다.", + "본인인증을 사용하는 모듈·플러그인을 비활성화해도 해당 확장이 설정한 본인인증 요구가 계속 적용되던 결함 수정. 이제 확장을 비활성화하면 그 확장이 선언한 본인인증 정책도 함께 적용 해제되고, 다시 활성화하면 운영자가 조정한 설정을 유지한 채 정상 복구됩니다.", + "확장 메뉴 동기화 시점에 운영자가 수정하지 않은 필드 (아이콘·이름·정렬·URL) 가 잘못 \"사용자 수정\" 으로 표시되어 이후 모듈/플러그인 정의값으로의 자동 동기화가 영구히 차단되던 결함 수정. 확장 재설치·업데이트 시 메뉴 정의 변경이 정상 반영됩니다.", + "확장 역할 동기화 시점에 운영자가 수정하지 않은 필드 (역할 이름·설명) 가 잘못 \"사용자 수정\" 으로 표시되어 이후 모듈/플러그인 정의값으로의 자동 동기화가 영구히 차단되던 결함 수정. 메뉴 동기화와 동일 맥락으로, 확장 재설치·업데이트 시 역할 정의 변경이 정상 반영됩니다.", + "모듈·플러그인의 레이아웃 확장이 대상이 아닌 템플릿(관리자 확장 ↔ 사용자 템플릿)에도 등록되던 결함 수정. 업그레이드 시 잘못 등록된 항목을 자동 정리하며, 이후로는 대상 레이아웃이 존재하는 템플릿에만 등록됩니다.", + "모듈·플러그인이 공통 레이아웃(헤더·푸터 등 모든 화면이 공유하는 틀)에 주입한 UI·초기화 동작이 실제 화면에는 전혀 나타나지 않던 결함 수정. 개별 화면이 공통 레이아웃을 물려받을 때 그 \"물려받은 출처\" 정보가 사라져, 공통 레이아웃을 대상으로 한 확장 주입이 어느 화면에도 적용되지 않았습니다. 이제 화면이 물려받은 공통 레이아웃을 대상으로 한 확장도 정상 적용되어, 모듈이 설치되어 있으면 해당 UI(예: 헤더 통화 선택기)와 초기화 동작이 모든 관련 화면에 표시됩니다.", + "확장(모듈·플러그인) 변경 후 라우트 트리·메뉴·다국어 응답이 옛 값으로 남아 있던 결함 수정. 이전에는 클라이언트가 캐시 버전 신호를 동반하지 않아 백엔드 캐시가 갱신 시점을 놓쳤습니다. 확장 설치/활성화/업데이트가 일어나면 다음 페이지 로드부터 새 값이 즉시 보입니다.", + "확장을 업데이트하거나 레이아웃을 저장해도 변경 내용이 화면에 반영되지 않고 옛 내용이 계속 보이던 결함 수정. 캐시 갱신 신호가 기록되는 저장소와 읽히는 저장소가 환경설정에 따라 어긋날 수 있어, 올린 갱신 신호가 화면 쪽에서 보이지 않던 문제였습니다. 이제 갱신 신호가 항상 같은 저장소로 일관되게 기록·조회되어 확장 업데이트·레이아웃 변경이 다음 화면 로드부터 즉시 반영됩니다.", + "코어 업데이트 시 사용자가 모듈·플러그인·템플릿·언어팩의 `_bundled` 폴더 아래에 직접 만들어 둔 커스텀 폴더·파일이 함께 삭제되던 결함 수정. 이제 코어 업데이트는 코어가 제공하는 번들 확장만 갱신하고, 사용자가 추가한 항목은 그대로 보존합니다. (#43 @bigmsg 님께서 제보해주셨습니다.)", + "관리자 권한(`sudo`)으로 코어를 업데이트한 뒤, 모듈·플러그인·템플릿·언어팩을 업데이트하려 하면 \"백업 생성 중\" 단계에서 `mkdir(): Permission denied` 오류로 실패하던 결함 수정. 관리자 권한 업데이트가 백업 폴더와 업그레이드 기록 파일을 관리자 소유로 남겨, 이후 웹서버 계정이 같은 위치에 쓰지 못하던 문제였습니다. 이제 이들 폴더·파일의 소유권과 그룹 쓰기 권한이 정상화되고, 코어와 확장의 업그레이드 기록을 별도 파일로 분리해 두 작업이 서로의 기록 파일을 두고 충돌하지 않습니다.", + "회원에게 보내는 알림(이메일·사이트 알림)이 보내는 사람(관리자 등)의 화면 언어가 아니라 받는 회원 본인의 언어로 발송되도록 수정. 관리자가 한국어 화면에서 다른 언어를 쓰는 회원의 주문 상태를 바꿔도, 알림은 그 회원의 언어로 전달됩니다.", + "신규 주문 접수 시 관리자에게 발송되는 알림 메일의 결제금액이 0원으로 표시되던 결함 수정. 결제 예정 금액이 정확히 표시됩니다.", + "알림 템플릿을 기본값으로 복원할 때 제목·본문만 복원되고 수신자·클릭 주소는 관리자가 수정한 값이 그대로 남던 결함 수정. 이제 복원 시 수신자·클릭 주소까지 기본값으로 정확히 되돌아갑니다.", + "설치 마법사에서 데이터베이스 테이블 접두사를 지나치게 길게 입력하면 일부 확장 설치 시 테이블 인덱스 이름이 데이터베이스 한도를 넘겨 설치가 실패할 수 있던 문제를 예방합니다 — 접두사 입력 길이를 6자로 제한하고, 한도를 넘기면 설치 전 단계에서 안내합니다.", + "인스톨러에서 \"기존 테이블 삭제 후 설치\"를 선택할 때, 입력한 테이블 접두사와 무관한 다른 테이블까지 모두 삭제되던 결함 수정. 이제 입력한 접두사로 시작하는 테이블만 삭제되어, 같은 데이터베이스를 다른 프로그램과 함께 쓰거나 다른 접두사로 재설치할 때 무관한 테이블이 보존됩니다.", + "관리자 대시보드의 최근 활동·모듈/플러그인/템플릿 상태·게시판/이커머스 위젯 등 같은 틀이 여러 번 펼쳐지는 목록에서, 항목마다 고유 식별자를 붙이도록 작성된 화면이 모든 행에 같은 식별자를 그대로 내보내 일부 보조 기능·접근성 도구가 항목을 구분하지 못하고 일부 항목이 화면에서 누락되어 보이던 결함을 수정했습니다 — 이제 반복 목록의 각 행이 고유하게 식별되어 모든 항목이 정상 표시됩니다.", + "한 화면 안에서 탭이나 필터를 클릭으로 전환할 때, 화면에 따라 표시 조건에 묶인 데이터 목록이 비어 보이던 결함 수정. 새로고침해야 보이던 목록이 이제 탭·필터 전환 즉시 정상 표시됩니다. (표시 조건에 따라 로딩되는 데이터 목록이 전환 시점에 다시 평가되지 않던 문제였습니다.)", + "환경설정 화면 등에서 별도 표시 텍스트 없이 켜고 끄는 토글·체크박스를 켜서 저장하면 \"참/거짓 값이어야 합니다\" 오류로 저장이 거부되던 결함 수정. 토글의 켜짐/꺼짐 상태가 문자열이 아닌 참/거짓 값으로 정확히 전달되어 정상적으로 저장됩니다.", + "관리자 목록 화면에서 필터(발급상태·사용여부 등)를 선택하고 검색하면 일부 필터가 검색 직후 \"전체\"로 풀려 보이던 결함 수정. 새로고침해야 선택한 필터가 다시 표시되던 불편을 없애고, 검색 직후에도 선택한 필터가 그대로 유지됩니다.", + "검색창에 `<`, `>`, `(`, `+` 등 특수문자를 입력하면 오류가 발생하던 결함 수정. 특수문자는 일반 검색어로 처리되어, 어떤 문자를 입력해도 오류 없이 검색 결과(또는 빈 결과)가 표시됩니다.", + "IME(한글·일본어·중국어) 입력 도중 Enter 로 글자를 조합 확정할 때 마지막 글자가 누락되거나 검색·제출이 두 번 실행되던 결함 수정. 이제 조합이 끝난 뒤의 Enter 만 동작합니다. (#54 @devrhee16 님께서 제보해주셨습니다.)", + "관리자 대시보드의 시스템 리소스(CPU·메모리·디스크) 수집이 일부 호스팅 환경(open_basedir 제한, 일부 시스템 함수 비활성)에서 실패할 때 대시보드 리소스 조회 전체가 오류로 응답하던 결함 수정. 이제 수집할 수 없는 항목만 \"알 수 없음\"으로 표시되고 나머지 화면은 정상 동작합니다. (#40 @glitter-gim 님께서 제보해주셨습니다.)", + "시스템 정보 화면의 디스크 사용량 조회도 동일한 환경에서 안전하게 \"알 수 없음\"으로 표시되도록 보강했습니다.", + "웹소켓 사용을 끈 상태에서도 실시간 연결이 일부 동작할 수 있던 문제 수정. 이제 웹소켓 사용을 끄면 실시간 연결·채널 인증이 완전히 비활성화됩니다. (#50 @bigmsg 님께서 제보해주셨습니다.)", + "사이트 이름을 언어별로 다르게 저장한 환경에서 브라우저 탭 제목과 검색 메타데이터(JSON-LD)가 올바르게 표시되지 않거나 페이지가 깨질 수 있던 문제 수정. 이제 모든 메타 경로가 현재 언어의 사이트 이름을 일관되게 사용합니다. (#49 @glitter-gim 님께서 제보해주셨습니다.)", + "존재하지 않는 페이지에 접근할 때 정상 페이지처럼 200 응답을 반환하던 문제 수정. 이제 미등록 경로는 올바른 404 응답을 반환해 검색엔진이 없는 페이지를 색인하지 않습니다. (#47 @glitter-gim 님께서 제보해주셨습니다.)", + "인증이 필요한 관리자 API에 로그인 없이 접근할 때, 브라우저 주소창 직접 입력 등 일부 요청에서 서버 오류로 응답하던 결함 수정. 이제 어떤 방식으로 접근해도 일관되게 \"인증 필요\" 응답이 반환됩니다. (#39 @glitter-gim 님께서 제보해주셨습니다.)" + ] + }, + { + "name": "Security", + "items": [ + "사용되지 않는 파일 서빙 엔드포인트(`/storage` 직접 업로드·다운로드 경로) 노출을 제거했습니다. 실제 파일 업로드·다운로드는 별도의 인증된 경로로 처리되며 영향이 없습니다. (#52 @glitter-gim 님께서 제보해주셨습니다.)", + "설치가 완료된 시스템에서 설치 상태 관리 엔드포인트(`/install/api/state-management.php`)가 다른 설치 API 와 동일하게 차단되지 않아, 설치 완료 후에도 설치 상태 조회·초기화·중단 요청이 가능하던 문제를 수정했습니다. 설치가 완전히 끝난 환경에서는 해당 요청이 모두 HTTP 410 으로 차단됩니다. 설치 진행 중 완료 화면으로 정상 전환되는 동작은 영향받지 않습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1056)" + ] + }, + { + "name": "Removed", + "items": [ + "기본 제공 결제 수단을 KG이니시스로 단일화했습니다. 그 외 결제 연동(나이스페이먼츠·NHN KCP·토스페이먼츠)은 기본 번들에서 제외되며, 필요한 경우 별도 플러그인으로 설치해 사용할 수 있습니다. 이미 해당 결제 연동을 설치해 사용 중인 사이트는 영향받지 않습니다." + ] + } + ] + }, + { + "version": "7.0.0-beta.7", + "date": "2026-05-15", + "categories": [ + { + "name": "Fixed", + "items": [ + "인스톨러 설치 완료 후 `.env` 에 DB 자격증명과 `APP_KEY` 가 기록되지 않고 `storage/installer/runtime.php` 에 평문으로 잔존하던 결함을 수정. 운영 동작은 정상이었으나 자격증명이 정상적인 위치로 옮겨지지 않고 임시 파일에 보존되는 상태였습니다. (#37 @glitter-gim 님께서 제보해주셨습니다.)", + "beta.4~beta.6 출시본에서 위 결함으로 인해 자격증명이 잔존한 환경을 beta.7 업그레이드 시 자동으로 감지하여 `.env` 로 머지하고 임시 파일을 정리하도록 업그레이드 스텝을 추가했습니다. `.env` 의 `INSTALLER_COMPLETED` 마커가 이미 설정된 정합 이상 케이스는 자동 정리 대상에서 제외하고 운영자 수동 검토를 안내합니다.", + "위 업그레이드 스텝이 `.env` 를 머지할 때 권한·소유자·그룹을 웹 서버 기준으로 정렬하도록 보강. 기존 `.env` 가 있던 환경에서는 운영자가 설정한 권한을 그대로 보존하고, 새로 생성되는 환경에서는 웹 서버가 읽을 수 있는 권한과 그룹을 자동으로 적용합니다. 명령줄 업그레이드 실행 사용자와 웹 서버 사용자가 다른 자체 구축 서버 환경에서 머지 직후 웹 서버가 `.env` 를 읽지 못해 사이트가 다운되는 회귀를 차단합니다.", + "인스톨러 설치 완료 화면의 `.env` 권한 강화 안내(`chmod`/`chgrp` 명령 예시) 가 Windows 환경에서 비노출되도록 OS 가드 추가. POSIX 권한 모델이 없는 Windows 환경에서 무의미한 Linux/macOS 전용 명령이 노출되어 운영자 혼란을 유발하던 문제를 수정합니다.", + "beta.4~6 출시본의 인스톨러 결함으로 자격증명이 `storage/installer/runtime.php` 에 잔존한 환경에서 beta.7 업그레이드 시 번들 확장 일괄 업데이트 단계가 `Access denied for user 'root'@'localhost'` 로 실패하던 회귀 수정. 업그레이드 스텝이 `.env` 머지 직후 부모 프로세스의 환경 변수도 함께 갱신하여 후속 자식 프로세스가 정상 자격증명을 상속받도록 보강합니다. 정상 설치 환경 (beta.3 등) 에서는 트리거되지 않습니다.", + "위 동일 회귀의 추가 차단 — 코어 업데이트가 별도 프로세스로 업그레이드 스텝을 실행하는 경로에서는 자식 종료 후 부모의 환경 변수가 갱신되지 않아 동일 증상이 재발하던 결함 수정. 자식 프로세스가 부팅 시점에 디스크의 `.env` 와 자신의 환경 변수를 대조하여 손상이 감지되면 디스크 값으로 보정하도록 보강하여, 다음 단계의 확장 일괄 업데이트가 정상 자격증명으로 진행되도록 합니다. 정상 설치 환경에서는 자동 비활성화됩니다." + ] + }, + { + "name": "Changed", + "items": [ + "`INSTALL.md` 에 \"업그레이드\" 섹션을 신설하여 자동 업데이트의 표준 절차, `sudo` 권장 사유 (`.env` · `bootstrap/cache` 의 권한 모델), 사용자군별 단계적 업그레이드 분기, 권한·캐시 트러블슈팅을 한곳에서 안내하도록 보완. 관리자 환경설정 > 정보 > 업데이트 확인 모달이 본 섹션으로 외부 링크를 노출합니다. (#30 @yks118 님께서 건의해주셨습니다.)", + "관리자 환경설정 > 정보 > 업데이트 확인 모달의 자동 업데이트 명령어 안내를 `sudo php artisan core:update` 권장 형태로 변경. 권한이 일치하지 않는 환경(`.env` 가 웹 서버 사용자 소유) 에서의 실행 실패를 사용자가 사전에 회피할 수 있도록 모달 하단에 \"전체 업그레이드 가이드 보기\" 외부 링크를 추가해 INSTALL.md 의 업그레이드 섹션으로 연결합니다. (#30 @yks118 님께서 제보해주셨습니다.)" + ] + }, + { + "name": "Notes — `.env` 권한 권장안", + "items": [ + "가장 안전한 권한은 `0600` (소유자만 읽기·쓰기) 입니다. `.env` 는 DB 비밀번호와 `APP_KEY` 등 평문 자격증명을 포함하므로 외부 접근을 최소화하는 것이 권장됩니다.", + "단, `0600` 적용 전에 **`.env` 의 소유자가 웹 서버 실행 사용자와 동일해야** 합니다 (예: 단일 사용자 환경의 `chown www-data:www-data .env && chmod 600 .env`). 소유자가 웹 서버 사용자와 다른데 `0600` 으로 변경하면 웹 서버가 `.env` 를 읽지 못해 사이트가 응답하지 않습니다.", + "명령줄 사용자와 웹 서버 사용자가 다른 환경 (자체 구축 서버 등) 에서는 `0640` 권한 + 그룹을 웹 서버 그룹으로 설정 (`chgrp www-data .env && chmod 640 .env`) 하는 것이 안전한 절충안입니다. 웹 서버는 그룹 멤버로 읽고, 명령줄 사용자는 직접 수정할 수 있습니다." + ] + } + ] + }, + { + "version": "7.0.0-beta.6", + "date": "2026-05-14", + "categories": [ + { + "name": "Fixed", + "items": [ + "코어 업데이트 자동 롤백 시 신 버전이 추가한 신규 파일이 활성 디렉토리에 잔존하여 `BindingResolutionException` 등 부정합 부팅 실패가 발생하던 결함 수정. 백업 디렉토리에 신규 파일 manifest 를 기록하고 롤백 시 정확히 그 목록만 정리합니다. 사용자가 코어 영역에 직접 추가한 파일과 모든 symlink (`public/storage` 등) 는 보존됩니다. (#34 @bigmsg 님께서 제보해주셨습니다.)", + "자동 롤백 후 `bootstrap/cache` 잔존 PHP 캐시로 인한 추가 부팅 실패 차단 — 롤백 직후 캐시를 자동으로 정리합니다.", + "beta.5 이전 자동 롤백으로 인해 활성 디렉토리에 잔존했을 수 있는 ServiceProvider 후보를 beta.6 업그레이드 스텝이 진단 로그로 식별하여 운영자 수동 검토를 안내합니다. 자동 삭제하지 않으므로 사용자가 직접 추가한 ServiceProvider 는 보존됩니다.", + "업그레이드 스텝 단독 실행 명령(`core:execute-upgrade-steps`) 이 마이그레이션·코어 재동기화·버전 갱신·캐시 정리·번들 확장 일괄 업데이트를 자동으로 함께 수행하도록 보강. 자동 업데이트 중단 후 안내된 수동 명령을 그대로 실행하면 별도 보조 작업 없이 업그레이드가 완료됩니다.", + "인스톨러 Step 3 (PHP CLI / Composer / 자동 감지 / 코어 _pending 경로 검증) 의 프론트엔드 catch 분기에서 사용하는 다국어 키가 언어 파일에 정의되어 있지 않아, 서버 응답 실패 시 화면에 다국어 키 문자열이 그대로 노출되던 결함 수정.", + "인스톨러 다국어 파일에 중복 정의된 키 4건 정리. POST 전용 API 의 메서드 거부 메시지가 일반 문구로 덮여 표시되던 문제, 데이터베이스 연결 실패 로그에서 placeholder 가 치환되지 않던 문제, HTTPS 카드 라벨이 안내 문장으로 표시되던 문제가 함께 해결됩니다.", + "인스톨러 Step 3 의 PHP CLI 및 Composer 절대경로 검증이 공유 호스팅 환경(시놀로지 DSM 등 `open_basedir` 가 시스템 binary 영역을 차단하는 환경)과 Windows 환경에서 정상 경로임에도 거부되던 결함 수정. 셸 인자 escape 와 메타문자 차단은 유지하고, 파일 시스템 권한에 의존하던 사전 검사를 실제 실행 결과로 판정하도록 변경합니다. Windows 의 백슬래시 경로 구분자도 정상 입력으로 인식됩니다 (단, `C:\\Program Files\\...` 같은 공백 포함 디렉토리는 공백 없는 경로 또는 8.3 short path 사용 권장). (#33 @glitter-gim 님께서 제보해주셨습니다.)", + "코어 운영 단계의 composer 바이너리 자동 인식이 같은 `open_basedir` 환경에서 실패하여 모듈/플러그인 설치 시 vendor-bundle 모드만 사용 가능하던 결함 수정. composer 가 정상 설치되어 PATH 에 존재하는 환경에서도 인식되도록 검출 로직을 개선했습니다.", + "멀티 PHP 버전 환경(시놀로지 DSM Web Station, cPanel/Plesk multi-PHP 등) 에서 Composer 를 특정 PHP 인터프리터로 실행하려는 \"PHP 절대경로 + Composer 절대경로\" 공백 분리 입력 형식이 거부되던 결함 수정. 입력을 두 토큰으로 분리한 뒤 각 토큰을 개별 escape 처리하여 안전을 유지하면서 멀티 PHP 운영 시나리오를 지원합니다.", + "인스톨러 세션 쿠키가 일부 환경(비표준 포트 + dynamic DNS 도메인 + 브라우저 추적 보호 등) 에서 브라우저에 의해 차단되어 첫 화면 \"설치하기\" 클릭이 동작하지 않고 새로고침처럼 보이던 결함을 보완. 세션 쿠키 SameSite 정책을 Strict → Lax 로 완화하여 차단 케이스 자체를 줄이고, Step 0 진입 시 쿠키 round-trip 사전 진단을 수행하여 차단이 감지되면 사용자에게 회피 방법(다른 브라우저 / 쿠키 차단 해제 / IP·localhost 접속) 을 명시 안내합니다." + ] + }, + { + "name": "Added", + "items": [ + "`hotfix:rollback-stale-files` Artisan 커맨드 추가 — 자동 롤백 후 활성 디렉토리에 잔존할 수 있는 신 파일을 운영자가 명시 실행하여 진단/정리할 수 있는 단발성 회복 도구. 기본은 진단 모드 (실제 삭제 없음), `--prune` 옵션 시 확인 프롬프트 후 정리. symlink 와 보호 경로(`storage/`, `vendor/`, `.env*` 등) 는 자동 제외.", + "본인인증 메시지 정의 목록 API 응답에 `can_create` 권한 키 추가 — 운영자의 수정 권한 보유 여부에 따라 관리자 화면이 \"정의 추가\" 버튼을 정확히 활성화/비활성화할 수 있도록 보강." + ] + } + ] + }, + { + "version": "7.0.0-beta.5", + "date": "2026-05-12", + "categories": [ + { + "name": "Upgrade Notice", + "items": [ + "손실 항목 1: 운영자가 활성 디렉토리(`modules/{id}`, `plugins/{id}`, `templates/{id}`, `lang-packs/{id}`)에서 직접 수정한 코드", + "손실 항목 2: 외부 install 한 확장 (GitHub 등 `_bundled` 에 포함되지 않은 모듈/플러그인/템플릿/언어팩)", + "`modules/`", + "`plugins/`", + "`templates/`", + "`lang-packs/`", + "**사용자군 B — 정상 beta.4 사용자 (대다수)**: `php artisan core:update` 표준 절차. beta.5 의 자동 복구 스텝이 활성 확장 디렉토리 + lang-packs 정합성을 보장합니다.", + "**사용자군 A — beta.3 → beta.4 업그레이드 도중 `Call to undefined method` 류 fatal 후 stuck 한 사용자**: `.env` `APP_VERSION` 을 변경하지 마시고 그대로 `php artisan core:update --force` 를 재실행하면 됩니다. 디스크는 이미 beta.4 이므로 새 자식 프로세스가 beta.5 메모리로 부팅되어 남은 업그레이드 스텝이 멱등 적용됩니다. 단, 활성 디렉토리에서 직접 수정한 코드 또는 `_bundled` 에 없는 외부 확장은 자동 복구 대상이 아니므로 업그레이드 전 위 디렉토리 백업이 동일하게 권장됩니다.", + "**사용자군 C — beta.1 또는 beta.2 사용자**: `php artisan core:update` 1회 호출로 beta.5 까지 직접 업그레이드는 권장하지 않습니다. 중간 버전의 업그레이드 스텝이 이전 버전 메모리에 없는 신규 코드에 의존하여 fatal 위험이 있습니다. 다음 단계적 업그레이드를 권장합니다:", + "감지 조건: `public/storage` 가 일반 디렉토리 + `storage/app/public` 이 존재 (Laravel 표준 구성)", + "복구 동작: 손상된 디렉토리를 `public/storage.broken.YYYYMMDDHHMMSS` 로 보존 후 symlink 재생성", + "운영자 조치: 자동 복구 후 `.broken` 디렉토리의 콘텐츠를 검증한 뒤 수동 삭제 (false positive 방어를 위해 자동 삭제하지 않음)" + ] + }, + { + "name": "Added", + "items": [ + "본인인증 관리자 권한을 조회/수정으로 분리 — \"프로바이더 설정 조회\" / \"정책 조회\" 권한 신설로 단순 조회 권한만 부여받은 운영자도 환경설정 본인인증 화면에 접근 가능 (기존에는 수정 권한이 없으면 조회 화면도 403)", + "환경설정의 본인인증 정책/메시지 탭에 권한 기반 버튼 비활성화 적용 — 수정 권한이 없는 운영자에게는 \"정책 추가\" / \"수정\" / \"삭제\" / \"활성 토글\" 버튼이 자동으로 비활성화 (이커머스/게시판 본인인증 탭 동일)", + "코어 업데이트의 spawn 자식 프로세스 실패 시 abort/fallback 동작 모드 선택 (`G7_UPDATE_SPAWN_FAILURE_MODE`, 기본 `abort`) — 부모 메모리 stale 로 인한 upgrade step fatal 위험을 fail-fast 로 차단 (#28 @bigmsg 님께서 제보해주셨습니다.)", + "업그레이드 스텝에 \"버전별 데이터 스냅샷\" 규약 도입 — 멀티 버전 점프 시에도 사용자가 단계별로 업그레이드한 것과 동등한 결과를 보장하도록 각 스텝의 시드 카탈로그·적용 로직·단발성 핫픽스를 해당 버전 디렉토리로 격리. 외부 확장 작성자가 beta.5+ 신규 업그레이드 스텝 작성 시 적용 (상세: 업그레이드 스텝 가이드) (#29 @bigmsg 님께서 건의해주셨습니다.)", + "언어팩 일괄 활성화 검증 규칙에 동적 확장 훅 추가 (`core.language_packs.bulk_activate_validation_rules`) — 모듈/플러그인이 호스트 확장 재활성화 cascade 흐름에서 추가 검증 필드를 동적으로 등록 가능" + ] + }, + { + "name": "Changed", + "items": [ + "본인인증 관리자 권한 키 정리 — 기존 `core.admin.identity.manage` / `core.admin.identity.policies.manage` 가 각각 `core.admin.identity.providers.update` / `core.admin.identity.policies.update` 로 변경. beta.4 환경에서 기존 권한을 부여받은 운영자 역할은 업그레이드 시 자동으로 새 권한 키로 마이그레이션 (부여 시점/부여자/스코프 메타데이터 모두 보존). 외부 확장 또는 운영 도구가 옛 키 문자열을 하드코딩한 경우 새 키로 갱신 필요" + ] + }, + { + "name": "Fixed", + "items": [ + "코어 업데이트 시 활성 모듈/플러그인/템플릿/언어팩 디렉토리가 삭제되던 문제 수정 — beta.4 → beta.5 업그레이드 시 번들 원본에서 활성 디렉토리를 자동 복원하고, 외부 설치 확장은 업데이트 로그로 재설치 안내", + "코어 업데이트의 단계 전환 신호를 비정상 입력으로부터 차단하도록 검증 강화", + "본인인증 정책 응답의 생성/수정 일시가 사용자 타임존이 아닌 UTC ISO 문자열로 노출되던 문제 수정", + "백업/롤백 시 `public/storage` 등 symlink 가 target 디렉토리 내용으로 추적 복사되어 symlink 가 일반 디렉토리로 변질되던 문제 수정 (Linux 환경 한정 — Windows 는 권한 한계로 일반 디렉토리 폴백 + 수동 회복 안내 유지)", + "root/super user 환경에서 Composer 검증·설치가 비대화형 컨텍스트에서 실패하던 문제 수정 (코어 업데이트, 확장 의존성 설치, 인스톨러 포함) (#31 @glitter-gim 님께서 제보해주셨습니다.)", + "본인인증 활동 로그의 \"액션\" 열에 `identity.verify` / `identity.verify_failed` 가 번역되지 않은 키 그대로 노출되던 문제 수정 — 코어 액션 라벨 매핑이 누락되어 있던 부분을 추가", + "본인인증 단계에서 운영자가 지정한 본인인증 수단이 무시되고 항상 기본 수단(메일)으로 발행되던 문제 수정 — 가입 정책에 설정한 본인인증 수단과 API 요청에서 명시한 수단이 실제 인증 발행에 반영되도록 변경" + ] + } + ] + }, + { + "version": "7.0.0-beta.4", + "date": "2026-05-11", + "categories": [ + { + "name": "Changed", + "items": [ + "레이아웃 `navigate` 액션의 기본 스크롤 동작이 페이지 이동 후 최상단 이동으로 변경됨 — 일반 하이퍼링크 이동 UX 와 일치. 이전처럼 스크롤 위치를 유지하려면 `scroll: \"preserve\"` 명시 필요. 검색 필터/페이지네이션 등 위치 유지가 필요한 화면에서 회귀가 의심되면 해당 옵션 추가 (engine-v1.45.0)", + "언어팩 매니페스트(`language-pack.json`) 를 모듈/플러그인/템플릿 매니페스트와 동일한 필드 구조로 정렬 — 외부 작성자가 다른 확장과 동일한 표준으로 언어팩을 만들 수 있도록 개선", + "언어팩 매니페스트에 GitHub 저장소 필드 추가로 GitHub 기반 업데이트 경로 지원 (모듈/플러그인 매니페스트와 동일한 동작)", + "관리자 환경설정의 언어팩 카드와 상세 모달이 모듈/플러그인/템플릿 카드와 동일한 형식으로 다국어 이름 · 설명 · GitHub 링크를 노출하도록 변경" + ] + }, + { + "name": "Added", + "items": [ + "관리자 환경설정 → SEO 탭에 Generator 메타 태그 카드 추가 — 토글로 노출 여부를 제어하고 내용 입력으로 W3Techs 등 CMS 시장 점유율 측정 도구가 인식하는 `` 태그를 SEO 봇 페이지·SPA·관리자 셸 모두에 출력. 내용 미입력 시 \"GnuBoard7 {버전}\" 자동 적용, 운영자가 버전 노출을 원치 않으면 \"GnuBoard7\" 만 입력 가능 (#26 @Lastorder-DC 님께서 건의해주셨습니다.)", + "인스톨러 4단계 확장 선택 화면에 \"언어팩\" 카드 신설 — 번들 언어팩을 locale 별 서브헤딩으로 노출하고, 사용자가 모듈/플러그인/템플릿을 선택하면 그 확장에 종속된 번들 언어팩 카드가 즉시 활성화. 종속 확장을 해제하면 그 언어팩 카드는 자동으로 비활성화 + 선택 해제", + "5단계 설치 진행 시 모든 확장의 install/activate 가 끝난 뒤 선택된 번들 언어팩을 일괄 설치 — 1건 실패는 best-effort 처리로 전체 설치를 중단하지 않음", + "코어/확장당 다수 locale(일본어 + 중국어 등)이 동시에 번들된 시나리오 대응 — 각 locale 은 독립된 서브헤딩 + 카드로 노출", + "인스톨러 시작 시 SSE 호환성을 사전 점검하여 환경에 맞는 모드(SSE 또는 폴링)로 단방향 진입 — 워커 동시 실행 race(테이블 / unique 키 중복 에러) 차단", + "SSE 비호환 환경 사용자에게 폴링 모드 진행 여부를 명시적 다이얼로그로 확인 후 시작", + "활동 로그의 액션 라벨이 모듈/플러그인 자체 다국어 파일에서 우선 해석되도록 변경 — 그동안 코어에 일괄 등록되어야 했던 모듈 origin 라벨(이커머스 주문·상품·마일리지, 게시판 게시물·댓글·신고, 페이지 등) 이 각 영역으로 이전되어 모듈/플러그인이 자기 도메인 라벨을 자기 영역에서 자기설명. 미정의 시 코어 라벨로 자동 fallback 하여 회귀 없음", + "모듈/플러그인이 발화하는 활동 로그가 호출자/대상 모델의 영역을 자동으로 인식하여 자기 다국어 파일로 라우팅 — 새 활동 로그를 추가할 때 별도 분기 코드 없이 자기 lang 만 채우면 정합", + "확장 entity 시더가 활성 언어팩의 다국어 데이터를 자동 머지하도록 다국어 시더 인터페이스 도입 — 신규 시더 작성 시 회귀를 자동 차단", + "번들 일본어 언어팩 빌드 스크립트가 시더 메타데이터를 일관된 경로로 조회하도록 단순화", + "다국어 라벨 표시 시 활성 언어팩의 lang key fallback 을 자동으로 처리하는 다국어 라벨 보강 helper 추가 — Provider/Registry 등록 페이로드(알림 채널·결제 PG 등) 와 settings JSON 다국어 데이터를 단일 시그니처로 처리", + "알림 채널(`config/notification.php` 의 `default_channels`) 의 라벨이 활성 언어팩(일본어 등) 으로 자동 보강되도록 변경 — 일본어 활성 시 한국어 fallback 으로 노출되던 회귀 차단", + "토스페이먼츠 PG 프로바이더 등록 페이로드가 lang key 기반으로 라벨을 선언하도록 변경 — 활성 언어팩으로 자동 보강", + "Provider/Registry 등록 페이로드가 다국어 JSON(`['ko' => ..., 'en' => ...]`) 을 직접 보유하지 않도록 audit 룰 신설 (회귀 자동 차단)", + "settings JSON 다국어 entry 의 식별 키(code/id/key) 보유 검증 audit 룰 신설 (lang pack fallback 키 조립 가능성 보장)", + "프론트엔드 `$localized()` 표현식이 두 번째 인수로 lang key fallback 을 받도록 확장 — 활성 로케일 라벨 부재 시 모듈/플러그인 언어팩의 키로 자동 보강", + "이커머스 환경설정 카탈로그(배송 가능 국가 / 통화 / 결제수단) 표시 시 활성 언어팩의 라벨이 자동 적용되도록 변경 — 일본어 활성 시 카탈로그 라벨이 한국어로 노출되던 회귀 차단 (다음 일본어 언어팩 빌드 시 자동 적용)", + "audit 룰이 `getDefault*Channels/Providers/Methods` 등 registry 기본값 반환 메서드의 다국어 JSON 직접 보유도 감지하도록 강화", + "환경설정 카탈로그(결제수단·통화·배송 가능 국가 등) 의 다국어 라벨이 카탈로그 빌드 시점에 활성 언어팩으로 자동 보강되도록 변경 — 사용자 체크아웃 + 관리자 환경설정 양쪽 모두 일본어 등 활성 시 한국어 fallback 으로 노출되던 회귀 차단", + "모듈/플러그인 개발자가 카탈로그를 추가할 때 누락하지 않도록 audit 룰이 helper 호출 누락 검출", + "[docs/backend/settings-multilingual-enrichment.md](docs/backend/settings-multilingual-enrichment.md) 에 단순 helper 사용 패턴 문서화", + "manifest `hidden: true` 플래그 추가 — 관리자 UI 목록에서 학습용/내부용 확장을 숨김 (CLI 는 정상 노출). 관리자 UI 에 슈퍼관리자 전용 \"숨김 포함\" 토글, artisan `module:list` / `plugin:list` / `template:list` 에 `--hidden` 플래그, 관리자 API `/api/admin/{modules,plugins,templates}` 에 `include_hidden` 쿼리 파라미터 지원", + "학습용 최소 샘플 확장 4종 번들 추가", + "모듈: `gnuboard7-hello_module` (Memo CRUD + 훅 발행 시연)", + "플러그인: `gnuboard7-hello_plugin` (Action/Filter 훅 구독 시연)", + "Admin 템플릿: `gnuboard7-hello_admin_template` (Basic 컴포넌트 최소 셋)", + "User 템플릿: `gnuboard7-hello_user_template` (홈 + Memo 리스트 연동)", + "모듈/플러그인/템플릿 정보 모달에 \"지원 언어\" 섹션 추가 — 코어/번들/사용자설치 출처 배지로 한눈에 확인", + "모듈/플러그인/템플릿 인스톨러에 의존 확장 + 동반 번들 언어팩 동반선택 UI 추가 — 미선택 의존성에 종속된 언어팩은 자동 비활성화", + "인스톨러 요구사항 검증 단계에 언어팩 디렉토리 쓰기 권한 점검 + 권한 부여 안내 추가", + "사용자 수동 비활성화와 코어 버전 호환성으로 인한 자동 비활성화를 DB 수준에서 구분 — 자동 비활성화된 확장만 재호환 감지/원클릭 복구 대상이 되도록 분리 (#18 @laelbe 님께서 제보해주셨습니다.)", + "코어 업그레이드 후 자동 비활성화 확장이 다시 호환되면 관리자 대시보드에 \"다시 활성화\" 알림 표시 + 원클릭 복구 버튼 제공 (자동 재활성화는 하지 않음 — 운영자가 명시적으로 복구)", + "모듈/플러그인/템플릿 목록 화면 상단에 자동 비활성화 확장 안내 배너 추가 (코어 업그레이드 가이드 링크 동반)", + "업데이트 모달에 코어 버전 호환성 안내 + \"위험을 이해하고 강제로 진행\" 체크박스 추가 — 운영자가 위험을 인지하면 비호환 확장도 강제 설치 가능", + "관리자 대시보드 \"시스템 알림\" 카드 노출 — 코어 호환성 자동 비활성화/재호환 알림이 분기 렌더되며 개별 dismiss 지원", + "코어 업데이트 마무리 단계에서 sudo 환경 결함(파일시스템 ACL · immutable 비트 · NFS 권한 거부 등) 으로 일부 경로의 소유권/그룹 쓰기 권한을 정상화하지 못한 경우 즉시 콘솔에 실패 경로 + 운영자 수동 복구 명령(`sudo chown -R …` / `sudo chmod -R g+w …`) 안내 — 이전에는 silent fail 로 묻혀 운영자가 후속 권한 거부 발생 후에야 인지하던 문제 해소", + "권한 기반 수신자 타입 추가 — `permission` 타입으로 특정 권한을 가진 모든 사용자에게 알림 발송 가능 (예: 게시판 신고 알림은 신고 관리 권한자에게 자동 발송)", + "모듈 환경설정에서 알림이 비활성화된 경우 발송 자체를 사전 차단하는 정책 게이트 도입 — 이전에는 수신자 해석 단계가 정책을 우회하여 발송되던 문제 해소", + "게시판 환경설정 → 신고 정책 탭에 신고 알림 채널 선택 UI 추가 (이메일 / 사이트 알림 다중 선택)", + "모듈/플러그인이 자기가 발송하는 알림 정의를 manifest 에서 직접 선언하도록 통일 — 활성화/업데이트 시 운영자 편집값을 보존하면서 자동 등록되고, 제거 시 함께 정리됨. 권한·메뉴·본인인증과 동일한 declarative getter 패턴", + "코어 기본 알림(회원가입 환영·비밀번호 재설정·비밀번호 변경) 의 다국어 제목/본문/변수/채널 정의를 `config/core.php` 로 통합 — 운영자가 코드 수정 없이도 향후 표면 변경을 추적할 수 있는 단일 소스 확보", + "본인인증(IdentityVerification) 인프라 도입 — 코어에 범용 IDV 프로바이더 계약을 마련하고, 기본 메일 프로바이더를 내장. 플러그인이 KCP·이니시스·SMS 등 다른 경로를 동일 계약으로 붙일 수 있도록 확장점 제공", + "외부 본인인증 provider (KCP·PortOne·토스인증·Stripe Identity 등) 가 G7 표준 Extension Point 패턴으로 자기 SDK UI 를 주입할 수 있는 슬롯 도입", + "비동기 검증 흐름을 위한 백엔드 폴링/콜백 엔드포인트 추가 — `GET /api/identity/challenges/{id}` (상태 폴링), `POST /api/identity/callback/{providerId}` (외부 redirect 콜백 수신)", + "본인인증 정책 시스템 신설 — 회원가입·비밀번호 재설정·민감 작업에 적용되는 모든 본인인증 시점을 라우트/훅 단위로 선언형 정책으로 통합 관리. 관리자 화면에서 정책 활성/유예 시간/프로바이더/실패 모드/단계(가입 제출 전 vs 가입 후 활성화 전)·적용 대상(self/admin/both)을 조정할 수 있으며, 운영자 수정값은 업데이트 재시딩 시 보존됨", + "본인인증 정책의 enable 토글이 라우트 코드 수정 없이 즉시 적용 — 모든 API 라우트가 정책 DB 와 자동 매칭되어 운영자가 admin UI 에서 정책을 켜는 즉시 본인인증이 강제됨. 코어/모듈/플러그인 어떤 라우트의 응답 처리 패턴에서도 본인인증 흐름이 일관되게 모달까지 도달하도록 처리", + "회원가입 단계 정책 2종 시드 — 가입 제출 전 동기 검증, 가입 후 활성화 전 비동기 challenge (기본값 비활성, 운영자 opt-in)", + "본인인증 정책이 활성화된 모든 강제 지점(회원가입·비밀번호 재설정·민감 작업·게시판/이커머스 정책 등)에서 사용자/관리자 화면에 동일한 모달 UX 가 자동으로 표시되도록 코어 인터셉터와 공통 모달 인프라 도입", + "전역 프론트엔드 인터셉트 — 서버가 HTTP 428 본인인증 요구 응답을 반환하면 자동으로 인증 모달을 띄우고 사용자가 인증에 성공하면 원 요청을 자동 재실행", + "본인인증 가드가 모듈/플러그인이 선언한 훅에도 자동 적용되도록 동적 구독 도입 — 결제 직전·민감 액션 직전 등 도메인 특화 가드를 운영자 토글 한 번으로 활성화", + "관리자 화면에 본인인증 정책 관리 페이지 추가 (정책 목록 DataGrid + 편집)", + "관리자 화면에 본인인증 이력 페이지 추가 — 알림 발송 이력과 동일한 수준의 UI 제공: 인증 수단(Provider) 탭(활성화된 프로바이더에 따라 자동 갱신), 통합 검색(자동 감지/사용자 ID/대상 식별자/IP/정책 키), 상태·인증 목적·채널·발생 유형 다중선택 OR 필터, 날짜 범위 + 고급 검색(발생 유형·출처·정책 키) 토글 영역, 정렬·페이지 사이즈 선택, 행 펼침으로 인라인 상세 표시, 모바일 반응형, 사용자 타임존 기준 시각, 보관주기(180일) 일괄 파기, 인증 수단/발생 위치 다국어 라벨 표시", + "본인인증 목적(purpose) 의 출처(source) 추적 — 코어/모듈/플러그인이 선언한 목적을 구분해 환경설정 화면에서 분리 표시하며, 코어 정책 화면 상단에 \"코어가 제공하는 본인인증 목적\" 칩 섹션(목적 코드/라벨 + 설명·허용 채널 툴팁) 추가", + "모듈 환경설정의 본인인증 정책 탭을 코어 정책 관리 화면과 동일한 UI/UX 로 통일 — 검색·필터(강제 시점/출처)·페이지네이션·정책 추가/편집 모달 직접 호출(코어 화면으로 이동 X)·이 모듈이 등록한 본인인증 목적 칩 섹션(없으면 안내 메시지). 정책 목록의 \"인증 목적\" 컬럼이 코드명 대신 사람이 읽을 수 있는 라벨로 표시", + "모듈/플러그인이 자기 컨텍스트의 본인인증 정책·목적·메시지 정의를 manifest 에서 직접 선언하도록 통일 — `module.php::getIdentityPolicies()` / `getIdentityPurposes()` / `getIdentityMessages()` 선언만으로 코어 정책 관리에 자동 연동되며, 활성화/업데이트 시 운영자 편집값을 보존하면서 자동 등록·정리됨. 권한·메뉴·알림과 동일한 declarative getter 패턴", + "본인인증 정책/이력 목록 API 가 source 컨텍스트별 필터를 받아 모듈 환경설정 탭이 자기 정책/이력만 조회 가능하며, 운영자 정의 정책을 특정 모듈/플러그인 컨텍스트에 귀속시켜 추가할 수 있도록 source_identifier 입력 허용 (`admin` / `module:{id}` / `plugin:{id}`)", + "본인인증 메일 메시지 템플릿 시스템 도입 — 프로바이더와 (목적/정책)별로 다국어 제목/본문을 개별 정의 가능하며, 메시지 발송 시 정책 → 목적 → 프로바이더 기본값 순서로 fallback 해석. 회원가입/계정 변경/중요 작업/비밀번호 재설정 + 프로바이더 기본값 등 5종 메일 템플릿이 한국어/영어로 시드되어 즉시 발송 가능", + "본인인증 challenge 발급/검증/취소 라우트에 권한 미들웨어 + scope=self 가드 적용 — 게스트는 `core.identity.{request,verify,cancel}` 권한으로 비로그인 가입(Mode B) 흐름 진입, 로그인 사용자는 본인 challenge 만 다룰 수 있으며 관리자는 임의 challenge 도 처리 가능. 모달 취소 시 서버 cancel API 를 호출해 challenge 가 audit log 에 cancelled 상태로 즉시 기록되도록 정합화", + "환경설정 → 본인인증 탭에 \"메시지 템플릿\" 서브탭 신설 — 알림 템플릿 관리와 동일한 UX (채널 서브탭·페이지당 항목 수 셀렉터·카드 펼침 본문 미리보기·활성/기본 배지·페이지네이션) 로 정의 목록·활성 토글·다국어 편집(변수 가이드 + 기본값 복원) 제공. 운영자가 추가한 정책에 전용 메일 메시지 정의를 화면에서 직접 등록·삭제 가능 (시드 기본값 보호 + 정책 키 매칭 검증). 외부 본인인증 프로바이더 플러그인이 자기 메시지 기본값을 코어 복원 로직에 기여할 수 있는 필터 훅 노출", + "알림 발송 이력 / 본인인증 이력 샘플 시더를 코어/모듈별로 분리 — 코어 시더는 코어 정의·정책만, 각 모듈 시더는 자기 영역만 채우도록 영역 격리. 모듈은 `module:seed {id} --sample` 으로 자기 영역 이력만 독립 생성 가능. 샘플 데이터는 실제 등록된 사용자·정의·정책·프로바이더 기반으로 생성되어 운영 데이터와 동일한 스키마/분포 유지", + "모듈/플러그인 제거 시 코어 공유 테이블에 적재된 해당 확장의 데이터(권한·관리자 메뉴·알림 정의·본인인증 정책·본인인증 메시지 정의·본인인증 목적) 가 함께 정리되며, 제거 모달의 \"삭제될 데이터\" 에도 항목별 건수가 표시되도록 개선", + "IDV 도메인 분류 데이터(목적·채널·트리거 출처·정책 범위·정책 실패 모드·정책 적용 대상·정책 출처·메시지 스코프) 8종을 PHP Backed Enum 으로 정의하여 정책 등록/수정 시 일관된 검증 적용", + "인스톨러 설치 완료 화면에 설치된 코어 버전 표시 — 사용자가 어떤 버전이 설치되었는지 즉시 확인할 수 있도록 개선", + "새 언어(일본어/중국어 등)를 코어 수정 없이 추가할 수 있는 언어팩(Language Pack) 시스템 도입", + "ZIP 업로드 또는 GitHub URL로 언어팩 설치/제거/활성화 지원", + "동일 언어 슬롯에 여러 벤더의 언어팩 공존 가능 — 라디오 전환으로 즉시 활성 변경", + "코어/모듈/플러그인/템플릿 별도 적용 — 모듈 언어팩은 해당 코어 언어팩이 활성일 때만 활성화 가능", + "관리자 메뉴: 환경설정 > 언어팩 관리(통합) + 모듈/플러그인/템플릿별 진입점 추가", + "사용자가 직접 수정한 다국어 키는 언어팩이 덮어쓰지 않도록 보존 정책 적용", + "보안: 언어 번역 외의 PHP 실행 코드 포함 시 설치 차단", + "언어팩 관리 화면을 모듈 관리와 동일 수준의 운영 도구로 보강 — 검색(식별자/벤더/언어), 업데이트 확인, 캐시 갱신, 다중 선택 일괄 제거 지원", + "활성/비활성 상태를 토글 스위치로 일원화 (보호된 팩은 비활성화된 토글로 표시)", + "업데이트 가능 항목에 \"업데이트 가능\" 배지와 행 단위 업데이트 실행 버튼 노출", + "정보 모달을 모듈 정보 모달과 동일한 4섹션 구조(기본정보 / 호환성 / 소스 정보 / 변경로그)로 제공하며, CHANGELOG.md 를 자동 파싱하여 버전별 카테고리로 표시", + "설치 모달에서 ZIP 업로드 전에 manifest 와 검증 결과를 사전 확인할 수 있는 미리보기 제공", + "모듈/플러그인/템플릿별 언어팩 페이지 진입 시 대상 확장 안내 배너와 환경설정 탭으로 회귀하는 링크 노출", + "언어팩 업데이트 권한을 별도 권한 키로 분리하여 설치/관리 권한과 독립적으로 부여 가능", + "언어팩 운영 풀 패리티 — 모듈/플러그인/템플릿 관리와 동일한 안전 장치/확장성 적용", + "공식 일본어(ja) 번들 언어팩 12종 추가 — 코어, 주요 모듈(전자상거래/게시판/페이지), 주요 플러그인(CKEditor5/마케팅/토스페이먼츠), 기본 템플릿(admin/user)을 일본어로 즉시 사용 가능", + "언어팩 관리 화면에서 번들 언어팩을 모듈/플러그인 관리와 동일하게 \"미설치\" 상태로 노출 — 행별 \"설치\" 버튼으로 즉시 설치 가능", + "언어팩 목록 필터에 \"미설치 (번들)\" 상태 옵션 추가", + "본인인증 메일 메시지 정의의 다국어 키를 언어팩으로 주입할 수 있도록 확장 — 코어 수정 없이 언어팩 ZIP 만으로 IDV 메일 본문/제목을 다국어화 가능", + "모듈/플러그인이 선언한 알림/본인인증 메시지 정의의 다국어 키도 언어팩으로 주입되도록 정합 — 이전에는 hook 발화 누락으로 코어 정의에만 적용되던 동작 정상화", + "업데이트 시 자동 백업 + 실패 시 직전 버전으로 자동 복구", + "동시성 가드 — 진행 중인 작업 동안 활성/비활성/제거/재업데이트 진입 차단", + "번들 디렉토리(`lang-packs/_bundled/{identifier}`) 에서 외부 다운로드 없이 (재)설치하는 경로 추가 — 코어 언어팩 복구/재배포 단순화", + "라이프사이클 훅 명명을 모듈/플러그인/템플릿과 통일 — `core.language_packs.{installed|updated|uninstalled|activated|deactivated}` 발행 (확장 가능성 확대)", + "Artisan 커맨드 신규 — `language-pack:list`, `language-pack:install`, `language-pack:update`, `language-pack:uninstall`", + "코어와 번들 확장(모듈/플러그인/템플릿)에 내장된 한국어/영어를 가상 보호 언어팩으로 자동 노출 — 별도 설치 없이 언어팩 관리자에서 항상 활성/보호 상태로 확인 가능, 수정/제거 차단", + "호스트 확장 비활성화 시 그에 종속된 언어팩이 함께 비활성화되며, 재활성화 시 \"다음 언어팩도 활성화하시겠습니까\" 모달로 사용자 의사 확인 후 일괄 활성화", + "여러 언어팩을 한 번에 활성화하는 `POST /api/admin/language-packs/bulk-activate` API 추가 (의존성/버전 호환성 자동 검사)", + "언어팩 활성화 시 의존성 + 호스트 확장 버전 호환성 검사 자동 수행 — 호스트 확장이 비활성/미설치이거나 버전 미달이면 활성화 차단", + "언어팩 업데이트 우선순위를 모듈/플러그인 패턴으로 정합화 — GitHub 1순위 + bundled 폴백, 강제 업데이트 시 bundled 우선", + "언어팩 상세 모달에서 코어/번들 확장 내장 항목 클릭 시 \"별도 언어팩이 아닌 내장 번역\" 안내 배너 노출", + "모듈/플러그인/템플릿 상세 모달의 닫기 버튼이 콘텐츠 길이와 무관하게 모달 하단에 항상 보이도록 sticky 처리", + "관리자 환경설정 > SEO 탭의 Sitemap 카드에서 sitemap 을 즉시 재생성하고 마지막 생성 시각을 확인할 수 있는 \"지금 생성\" 버튼 추가 — 큐 드라이버와 무관하게 동기 실행되며 결과를 즉시 표시", + "봇 감지 엔진을 `jaybizzle/crawler-detect` 라이브러리로 교체. 기본 약 1,000종의 봇(검색엔진·링크 미리보기·AI 검색 등)이 자동 감지됨 — 링크를 슬랙·페이스북·LinkedIn·트위터·디스코드·텔레그램 등에 붙여넣으면 제목·설명·이미지 미리보기가 즉시 동작", + "라이브러리가 놓치는 봇 3종(`kakaotalk-scrap`·`Meta-ExternalAgent`·`ChatGPT-User`) 을 G7 보강 패턴으로 기본 포함", + "봇 감지 확장 훅 `core.seo.resolve_is_bot` 신설 — 플러그인이 IP 범위 검증·역방향 DNS·Cloudflare 봇 점수 등을 주입할 수 있는 슬롯", + "\"봇 라이브러리 사용\" 관리자 토글 추가 (기본 on, 비활성 시 운영자 커스텀 목록만 사용하는 레거시 모드)", + "슬랙·페이스북 링크 미리보기에 이미지·카드가 표시되도록 OG 보강 태그를 자동 출력하도록 개선 — og:site_name, og:image:width, og:image:height, og:image:secure_url, og:image:type, og:image:alt, og:locale 추가", + "Twitter 카드 메타태그(twitter:card / twitter:site / twitter:title / twitter:image 등) 출력 신설 — 슬랙 unfurl 폴백 경로 정상화", + "운영자 환경설정 SEO 탭에 \"OG / Twitter 카드 기본값\" 카드 추가 — 사이트 이름·이미지 기본 가로/세로·Twitter 카드 타입·Twitter 사이트 핸들 5개 입력", + "모듈·플러그인이 자기 도메인의 OG/Twitter/JSON-LD 를 직접 선언하도록 SEO declaration API 신설 — 이커머스 상품(Product/Offer/AggregateRating)·게시판 게시글(Article) 등 도메인 스키마가 레이아웃에서 확장 코드로 owned 되어 데이터에 따라 정확한 부속 태그 생성", + "SEO 메타 확장 훅을 분기별·통합 모두 제공 — OG·Twitter·구조화 데이터 각각의 hook 슬롯과 통합 hook 모두 지원하여 확장이 원하는 단계에서 선택 변경 가능", + "모듈 설정 타이틀 템플릿에서 변수가 비어있을 때 인접 구분자(- – · |) 가 자동 정리되도록 개선 — 옵셔널 그룹 `[ ... ]` 표기도 지원하여 페이지별 구성 명시 가능" + ] + }, + { + "name": "Changed", + "items": [ + "콘솔 confirm 입력 처리 통일 — yes/y, no/n 외 입력 시 안내 메시지 출력 후 재질문, empty 입력 시 default 사용. 코어 업데이트·매니저 커맨드(module/plugin/template install·update·uninstall)·설정 마이그레이션의 모든 yes/no 프롬프트에 동일 규칙 적용 (#15 @laelbe 님께서 건의해주셨습니다.)", + "비밀번호 재설정 정책 기본값을 비활성으로 변경 — 본인인증 인프라가 미구성된 사이트에서도 기본 동작이 영향받지 않도록 운영자 opt-in 으로 전환", + "본인인증 정책의 인증 조건(`conditions`) 운영자 편집 허용 — 회원가입 단계 등 정책 조건을 코드 수정 없이 관리자 화면에서 조정 가능. 모듈 업데이트 시 운영자 수정값 보존", + "토큰 만료 등으로 권한 없는 레이아웃 진입 시 \"페이지 로딩 실패\" 에러 화면 대신 로그인 페이지로 자동 이동 — 로그인 화면에서 \"세션이 만료되었습니다. 다시 로그인해 주세요.\" 토스트로 사용자에게 안내. 템플릿이 자체 로그인 경로를 사용하는 경우 부트스트랩에서 인증 설정을 커스터마이즈할 수 있는 공개 API 도 함께 제공 (#19 @abc101 님께서 건의해주셨습니다.)", + "템플릿 다국어 데이터 로딩 시 활성 언어팩의 다국어가 가장 높은 우선순위로 병합되도록 변경", + "권한·역할·메뉴·알림 등 코어 기본 데이터와 배송유형·클레임 사유·게시판 유형 등 모듈 기본 데이터에 활성 언어팩의 다국어가 자동 반영되도록 개선", + "사용자 수정 보존(user_overrides) 정책을 다국어 JSON 컬럼은 sub-key 단위(`name.ko` 등) 로 기록하도록 개선 — 운영자가 한 언어 라벨만 수정해도 그 언어만 보존되며, 신규 활성 언어팩(예: 일본어 추가) 의 라벨은 자동 동기화됨. 기존 컬럼 단위(`name`) 기록은 업그레이드 시 활성 locale dot-path 로 자동 변환", + "언어팩 활성/비활성 시점에 영향받는 모듈/플러그인의 entity 시더가 자동 재실행되어 신규 언어 라벨이 즉시 DB 에 반영되도록 라이프사이클 통합 — scope 별 라우팅 (코어 언어팩 → 모든 활성 확장, 모듈 언어팩 → 해당 모듈만, 플러그인 언어팩 → 해당 플러그인만)", + "환경설정 → SEO → \"추가 봇 패턴\" 필드의 역할 변경 — 기존에는 유일한 봇 매칭 소스(기본 5종)였으나, 이제는 라이브러리가 놓치는 조직별 커스텀 봇만 추가하는 보강 레이어로 동작. 기존 설치의 운영자 커스텀 값은 모두 보존되며, 신규 설치 기본값은 jaybizzle 미커버 3종으로 변경" + ] + }, + { + "name": "Security", + "items": [ + "회원가입·비밀번호 재설정 라우트에 본인인증 정책 강제 미들웨어 부착 — 정책이 활성화된 경우 미인증 요청을 라우트 단계에서 차단 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)", + "플러그인이 정책 해석 필터 훅에서 잘못된 타입을 반환해도 원본 정책이 유지되도록 우회 차단 강화 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)", + "웹 인스톨러의 Composer/PHP 바이너리 경로 검증에서 사용자 입력이 그대로 shell 명령으로 실행될 수 있던 문제 수정 — 입력은 실행 가능한 단일 파일 경로로만 허용하고 모든 분기에서 인자 escape 강제. 설치 워커가 동일한 입력을 사용하던 내부 헬퍼도 같은 정책으로 정렬 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)", + "설치 단계 4 의 확장 기능 선택 API 가 사용자가 보낸 모듈/플러그인/템플릿/언어팩 식별자에 셸 메타문자 검증을 적용하도록 강화 — 부적절한 식별자는 400 응답으로 거부되어 이후 설치 명령에 도달하지 않음 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)", + "인스톨러의 코어 업데이트 _pending 경로 검증이 `..` 등 부모 디렉토리 우회 시도를 거부하고 응답 메시지를 단일화하여 임의 디렉토리 enumeration 신호 차단 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)", + "설치 시 `.env` 작성 헬퍼가 입력값에 포함된 개행 문자를 제거하도록 변경 — DB 비밀번호 등 사용자 입력으로 새로운 환경 변수 라인이 주입되는 시나리오 차단 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)", + "데이터베이스 연결 정보의 host/port/database 값에 DSN 키-밸류 구분자(`;`, `=`) 또는 NUL/CRLF 가 포함되면 연결을 거부하도록 추가 검증 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)", + "설치가 완료된 시스템에서 `public/install/` 하위 모든 엔드포인트가 비즈니스 로직 진입 전 HTTP 410 으로 차단되도록 공통 가드 도입 — 운영 환경에서 인스톨러 노출형 결함의 공격 표면 제거. 운영자가 인스톨러를 다시 사용해야 하는 경우 설치 완료 마커(`storage/app/g7_installed`) 와 `.env` 의 `INSTALLER_COMPLETED` 를 모두 제거 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)" + ] + }, + { + "name": "Fixed", + "items": [ + "코어 업데이트 후 일부 환경에서 모듈/플러그인이 저장한 사용자 데이터(상품 이미지·첨부파일 등)에 PHP 가 접근하지 못해 \"찾을 수 없음\" 오류가 발생하던 문제 수정 — 업데이트 종료 시점 권한 복원 범위를 PHP 쓰기 영역(storage/logs·storage/framework·bootstrap/cache·storage/app/core_pending)으로 한정하고 항목별 정확 복원으로 정합화. 이전 버전에서 본 릴리즈로 업그레이드한 환경에서는 업그레이드 시점에 storage/app 디렉토리/파일 권한을 PHP 가 접근 가능한 형태로 자동 정상화함. 향후 업데이트에서는 사용자 데이터 디렉토리에 자동 생성되는 보존 마커로 권한이 영구 보호됨", + "PHP 8.5 환경에서 모든 페이지 응답이 손상되어 Firefox 에서는 \"Content Encoding Error\", Edge/Chromium 에서는 빈 화면으로 표시되던 문제 수정", + "`php artisan serve` 환경에서 인스톨러 진행 중 .env 파일이 변경되면 개발 서버가 워커를 재시작하면서 설치 단계가 중도에 끊기던 문제 수정 — 설치 진행 중에는 .env 를 건드리지 않고 완료 화면 노출 후 한 번에 반영하도록 변경", + "관리자 환경설정 \"시스템 사양\" 카드의 메모리 항목이 서버 물리 메모리가 아닌 현재 PHP 프로세스가 사용 중인 메모리(수 MB 단위)로 표시되던 문제 수정 — 디스크 사용량과 동일한 형식(사용량/전체/백분율)으로 실제 서버 RAM 을 표시하도록 개선", + "관리자 환경설정 \"시스템 사양\" 카드의 CPU 항목이 Windows 11 / Windows Server 2025 에서 \"operable program or batch file.\" 로 표시되던 문제 수정 — 해당 OS 에서 제거된 wmic 의존을 걷어내고 PowerShell 기반 조회로 전환, 구형 Windows 환경에서는 기존 방식으로 자동 폴백", + "관리자 환경설정 \"정보\" 탭을 한 번 조회한 뒤 다른 탭으로 전환할 때마다 수 초 지연이 반복되던 문제 수정 — 시스템 정보 조회 결과를 1시간 캐싱하여 탭 전환 시 대기 시간 제거 (시스템 캐시 초기화 시 함께 무효화)", + "큐 워커가 훅 페이로드의 enum 값(주문 상태 등)을 복원하지 못해 활동 로그·알림 등 일부 후속 처리가 실패하던 문제 수정", + "항목 삭제 후 큐 워커가 처리하는 후속 훅에서 대상 데이터를 복원하지 못해 처리가 중단되던 문제 수정 — 소프트 삭제 페이지·첨부파일, 하드 삭제 주문 옵션 등 포함", + "페이지 키워드 검색 결과의 전체 건수가 발행된 모든 페이지 수로 부풀려지던 문제 수정", + "사용자가 댓글을 단 게시글 활동 조회 시 500 에러가 발생하던 문제 수정", + "사용자 활동 통계에 삭제된 게시글의 댓글 수가 포함되던 문제 수정", + "일부 주문에서 옵션 정보 직렬화 시 500 에러가 발생하던 문제 수정", + "상품 옵션 삭제 검증 시 런타임 에러가 발생하던 문제 수정", + "설치 직후 또는 활성 모듈 디렉토리 부재 시 이커머스 환경설정 기본값이 비어있던 문제 수정", + "검색 가능 드롭다운(SearchableDropdown)에서 빠른 모달 전환 시 race condition 가능성 차단", + "반응형 설정과 반복 렌더링이 결합된 레이아웃에서 무한 재귀가 발생할 수 있던 엔진 결함 수정 (engine-v1.43.1)", + "슬롯 치환 시 텍스트 노드를 컴포넌트로 가정하여 발생할 수 있던 레이아웃 렌더링 오류 차단", + "인스톨러 실행 시 보안 키 생성 단계에서 개발용 패키지 ServiceProvider 를 찾지 못해 설치가 중단되던 문제 수정 — 이전 환경에서 남은 컴파일 캐시를 vendor 교체 직후와 보안 키 생성 직전에 자동 정리하도록 개선", + "폴링 모드 인스톨러가 큰 확장(레이아웃·테스트 수천 파일) 설치 도중 멈추던 문제 수정 — 명령 출력을 파일 기반으로 처리하여 OS 파이프 버퍼 한도와 무관하게 동작", + "PHP 8.5 + Apache + mod_fcgid 환경에서 폴링 모드 진행 상황이 실시간 반영되지 않고 설치 완료 시점에 일괄 표시되던 문제 수정 — 어느 모드로도 진행 상황이 즉시 표시되도록 보정. Apache 환경별 권장 설정은 INSTALL.md 와 시스템 요구사항 문서에 명시", + "설치 완료 후 임시 파일이 자동 정리되지 않던 문제 수정", + "알림 템플릿 편집 모달의 입력 필드에서 글자를 입력할 때마다 화면이 심하게 버벅이던 문제 수정", + "슈퍼관리자가 다른 관리자 계정을 삭제할 수 없던 문제 수정 — 관리자 계정 삭제 가능 여부를 역할/권한/스코프 설정에 따라 판단하도록 개선 (슈퍼관리자 본인 보호는 유지)", + "`seo:generate-sitemap` 커맨드가 큐 드라이버 설정과 무관하게 항상 \"큐에 디스패치\" 안내를 출력하던 문제 수정 — 동기 드라이버에서는 즉시 생성으로 동작하고 그에 맞는 안내를 표시하도록 변경", + "관리자 템플릿을 활성화해도 즉시 반영되지 않아 사용자가 직접 새로고침해야 하던 문제 수정 — 관리자 템플릿 활성화 시 자동으로 페이지를 갱신하여 새 템플릿이 즉시 적용되도록 개선", + "보안 환경설정의 \"최대 로그인 시도 횟수 / 차단 시간\" 설정이 실제로 적용되지 않아 무제한 로그인 시도가 가능하던 문제 수정 — 임계 도달 시 계정 잠금(HTTP 423), 잠금 해제 시각 안내 토스트, per-IP 백업 throttle, 활동 로그 기록까지 통합 구현", + "게시판 글쓰기 화면을 URL 로 직접 진입하거나 강제 새로고침했을 때 업로드한 첨부파일이 게시글에 연결되지 않던 문제 수정 (engine-v1.49.2) (#24 @minyho 님께서 제보해주셨습니다.)", + "코어 업그레이드 후 새 버전에서 추가된 권한·메뉴·알림 정의가 등록되지 않아 관리자 화면에서 \"해당 권한이 없습니다\" 가 반복 표시되거나 신규 메일 템플릿이 비어있던 문제 수정 — 업그레이드 시 새 버전 설정 파일을 정확히 인식하도록 보정. 본 릴리즈로 업그레이드하면 누락분이 자동 등록됨", + "PHP 8.5 환경에서 관리자 로그인 시 `PDO::MYSQL_ATTR_SSL_CA` 등 PDO 드라이버 상수가 deprecation 경고를 발생시키던 문제 수정 — `Pdo\\Mysql::ATTR_SSL_CA` 형태의 신규 상수로 전환하고, PHP 8.5 미만 환경에서는 기존 상수를 그대로 사용하도록 분기 처리 (#23 @yks118 님께서 제보해주셨습니다.)", + "회원가입 시 일부 환경에서 동의 항목 메타데이터가 누락되어 활동 로그 처리에서 예외가 발생, 후속 훅 체인이 중단되며 신규 회원에게 `user` 역할이 자동 부여되지 않던 문제 수정 — 동의 메타데이터를 nullable 로 받아 누락 케이스에서도 후속 권한 부여가 정상 동작하도록 개선 (#25 @comtylove-netizen 님께서 제보해주셨습니다.)" + ] + } + ] + }, + { + "version": "7.0.0-beta.3", + "date": "2026-04-23", + "categories": [ + { + "name": "Fixed", + "items": [ + "CKEditor5 플러그인 활성 상태에서 게시판 글쓰기 저장 시 \"제목은 필수입니다\" 422 오류가 발생하던 호환성 문제 수정 — 제목·내용 입력 순서와 무관하게 정상 저장되도록 개선 (#17 @laelbe 님께서 제보해주셨습니다.)", + "코어 업데이트가 sudo 로 실행된 환경에서 캐시·세션·확장 디렉토리의 그룹 쓰기 권한이 일부 손실되어 업데이트 직후 \"Permission denied\" 또는 플러그인 제거 검증 실패가 발생하던 문제 수정 — 업데이트 종료 시점에 그룹 쓰기 권한을 자동 정상화하며 기존 손실 분은 1회성 복구 스텝으로 회수", + "업데이트 완료 후 Laravel 런타임이 새로 만드는 캐시·세션 하위 디렉토리가 기본 umask(022) 때문에 다시 그룹 쓰기 권한을 잃어 재차 \"Permission denied\" 가 발생하던 문제 수정 — 업그레이드 스텝이 업데이트 진행 프로세스의 umask 를 그룹 쓰기 친화적으로 전환하고, 이후 부팅 시점에도 `storage/` 의 현재 그룹 쓰기 설정을 감지해 프로세스 umask 를 자동 동조 (운영자가 그룹 공유를 비활성화한 환경은 그대로 보존)", + "코어 업데이트 마지막 단계의 진행 표시줄이 끝난 뒤 줄바꿈 없이 다음 셸 프롬프트가 같은 줄에 붙어 표시되던 출력 문제 수정" + ] + }, + { + "name": "Notes", + "items": [ + "7.0.0-beta.1 에서 7.0.0-beta.3 로 직접 업그레이드하는 경우, 환경(opcache CLI 활성 등)에 따라 권한 복구 스텝이 자동 실행되지 않고 건너뛰어질 수 있습니다. 업데이트 후 `storage/framework/cache` 등에서 Permission denied 가 발생하면 아래 명령을 수동 실행해 주세요 — 7.0.0-beta.2 에서 올라오는 경로에서는 해당 없음", + "`php artisan core:execute-upgrade-steps --from=7.0.0-beta.2 --to=7.0.0-beta.3 --force`", + "시스템 레벨에서도 일관된 그룹 공유 권한을 원하면 php-fpm pool 설정에 `umask = 002` (또는 systemd unit 의 `UMask=0002`) 추가를 권장합니다. 코드 레벨 동조와 병행하면 외부 프로세스(cron, composer 등) 도 동일 권한으로 파일을 생성합니다" + ] + } + ] + }, + { + "version": "7.0.0-beta.2", + "date": "2026-04-20", + "categories": [ + { + "name": "Added", + "items": [ + "알림 시스템을 Definition(정의) × Template(채널별 템플릿) × Recipients(수신자) 3계층으로 재설계", + "채널별 독립 수신자 설정 — trigger_user, related_user, role, specific_users 4종 타입 지원", + "채널 Readiness 검증 — 미설정 채널(SMTP 미구성 등)의 발송을 사전 차단", + "알림 발송 공통 디스패처 — 채널별 독립 발송, 발송 전후 훅 자동 실행, 모든 채널 자동 로깅", + "알림 클릭 URL 지원 — database 채널 알림에 클릭 시 이동할 URL 패턴 설정 가능", + "사용자/관리자 알림 전체 삭제 API 추가", + "비밀번호 변경 시 `password_changed` 알림 자동 발송", + "코어 권한에 사용자 알림 전용 카테고리 분리 (관리자/사용자 권한 의미 분리)", + "알림 정의 일괄 초기화 기능 — 커스터마이징된 알림의 모든 채널 템플릿을 기본값으로 복원 (확인 모달 + 스피너 UX 포함)", + "모듈/플러그인이 자신의 기본 알림 정의를 코어 리셋 로직에 제공할 수 있는 필터 훅 제공", + "템플릿 편집 시 편집 상태가 자동 추적되어 리셋 버튼이 자동 노출", + "알림 정의/템플릿 응답에 편집/삭제 권한 정보(abilities) 포함으로 UI가 권한에 따라 자동 조정", + "Composer 실행 불가 환경(공유 호스팅 등)을 위한 vendor 번들 시스템 추가", + "vendor-bundle Artisan 커맨드 — 빌드, 검증, 일괄 처리", + "모듈/플러그인 설치·업데이트에 `--vendor-mode=auto|composer|bundled` 옵션 추가", + "인스톨러 Step 3에 Vendor 설치 방식 선택 UI 추가 (환경 자동 감지)", + "MaxMind GeoLite2 자동 다운로드 스케줄러 + 관리자 환경설정 UI 추가", + "IANA 타임존 전체(약 425개) 지원 — 기존 7개 하드코딩 화이트리스트 폐기 (#8 @abc101 님께서 건의해주셨습니다.)", + "Select 컴포넌트에 `searchable` prop 추가 — 타임존 등 대량 옵션에서 검색 가능", + "SSE/폴링 듀얼 모드 지원 — Nginx 프록시 + Apache 환경에서 SSE 문제 시 폴링 모드로 전환 가능", + "확장 의존성 자동 해결 — 템플릿 선택 시 필요한 모듈/플러그인을 즉시 자동 선택, 전이적 의존성까지 해결 (#10 @glitter-gim 님께서 건의해주셨습니다.)", + "자동 선택된 항목을 시각적으로 구분하고 요구한 확장 이름을 함께 표시", + "다른 확장이 의존하는 항목은 선택 해제 차단 (의존 관계 안내 메시지 포함)", + "의존성 버전 제약 사전 검증 — 버전 불일치 시 설치 진행 전 경고 (semver 비교: `>=`, `^`, `~` 등 지원) (#3 @laelbe 님께서 제보해주셨습니다.)", + "기존 DB 테이블 감지 및 안전한 재설치 지원 — 백업 안내 + 명시적 동의 후 진행 (#5 @laelbe 님께서 건의해주셨습니다.)", + "권한 안내 단순화 — `chmod -R 755` 단일 명령어로 통합 (업계 표준 정렬)", + "소유자 불일치(`ownership_mismatch`) 감지 — 전통적 Apache 환경에서 3가지 해결 옵션 제시", + "Step 5에 \"설치 시작\" 버튼 도입 — 모드 선택 후 사용자 클릭으로 설치 시작", + "코어/모듈/플러그인 격리 캐시 시스템 도입 (접두사 자동 부여)", + "모델 변경 시 태그 기반 자동 캐시 무효화 트레이트 추가", + "환경설정에서 캐시 TTL 중앙 관리 (7개 설정 키)", + "페이지 진입/탭 전환 시 로딩 spinner 표시 — 데이터 fetch 완료까지 유지 (#6 @laelbe 님께서 제보해주셨습니다.)", + "목록 페이지네이션 시 DataGrid body 영역 한정 spinner (pagination 버튼 가림 방지)", + "DataGrid 컴포넌트에 `id` prop 추가", + "HookManager 공통 큐 디스패치 — 환경설정 하나로 모든 훅 리스너 자동 비동기 실행, 큐 워커에서도 Auth/Request/Locale 컨텍스트 자동 복원", + "WebSocket 클라이언트/서버 endpoint 분리 설정 지원 — 리버스 프록시 환경 대응, SSL 검증 옵션 추가", + "SEO 변수 시스템 — 모듈/플러그인이 페이지별 SEO 변수를 제공할 수 있도록 개선", + "코어 드라이버 확장 시스템 — 플러그인이 필터 훅으로 스토리지/캐시/세션/큐 등 드라이버를 등록 가능", + "`HasUserOverrides` 트레이트 — 시더 데이터와 사용자 수정을 자동으로 분리 추적", + "템플릿 엔진 engine-v1.42.0 — `render: false` 선택적 리렌더 제어", + "템플릿 엔진 engine-v1.41.0 — setLocal/dispatch debounce API + stale 값 오염 방지", + "템플릿 엔진 engine-v1.40.0 — navigate fallback 옵션 (교차 템플릿 경로 대응)", + "템플릿 엔진 engine-v1.30~38 — sortable wrapper 요소 지정, React.memo 자동 래핑, resolvedProps 참조 안정화, transition_overlay spinner 시스템, reloadExtensions 통합 핸들러", + "코어 업데이트 완료 후 `_bundled` 에 새 버전이 있는 확장을 감지하고 일괄 업데이트 여부를 묻는 인터랙티브 프롬프트 제공", + "일괄 업데이트 시 전역 레이아웃 전략(overwrite / keep) 1회 질의 후 확장별 예외 지정 가능", + "모듈/플러그인 업데이트에도 `--layout-strategy=overwrite|keep` CLI 옵션 및 관리자 UI 모달 선택 지원 (기존 템플릿 전용 → 확장 전체 일관)", + "관리자 모듈/플러그인 업데이트 모달에 사용자가 수정한 레이아웃 감지 및 목록 표시 기능 추가", + "모듈/플러그인 업데이트 시 각 upgrade step 버전을 콘솔에 실시간 출력 (기존에는 파일 로그에만 기록)", + "확장 업데이트 메서드 파라미터 순서를 3종(템플릿·모듈·플러그인) 공통 prefix(id, force, onProgress) 로 정렬해 일관성 확보", + "모듈/플러그인이 런타임에 동적으로 생성한 권한·역할·메뉴(예: 게시판별 권한 세트)도 업데이트 시 보존되도록 개선", + "모듈/플러그인/템플릿 업데이트 커맨드에 업데이트 소스 선택 옵션(`--source=auto|bundled|github`) 추가 — GitHub 원격 장애·태그 롤백 등 상황에서 번들 소스로 강제 설치 가능", + "GitHub 원격에 존재하지 않는 태그로 업데이트 시 \"아카이브 추출 실패\" 로 불명확한 오류가 나던 문제 개선 — 태그 존재 여부를 먼저 검증해 \"업데이트 소스 없음\" 안내로 명확화", + "관리자 모듈/플러그인 업데이트 모달의 _global 상태키를 `hasModifiedTemplateLayouts`, `hasModifiedModuleLayouts`, `hasModifiedPluginLayouts` 로 통일해 네이밍 충돌 방지", + "`module:install` / `plugin:install` / `template:install` 에 `--force` 옵션 추가 — 이미 설치된 확장도 `_bundled`/`_pending` 원본으로 활성 디렉토리를 덮어써서 재설치 (불완전 설치 복구용)", + "확장 활성 디렉토리 무결성 검사 추가 — `module.php`/`module.json` (모듈), `plugin.php`/`plugin.json` (플러그인), `template.json` (템플릿) 중 하나라도 누락된 활성 디렉토리를 로드 시 경고 로그 + `install --force` 복구 힌트 제공", + "코어 업데이트 커맨드에 외부 ZIP 파일 직접 지정 옵션(`--zip=/path/to/g7.zip`) 추가 — GitHub 다운로드 대신 지정 ZIP 을 사용해 오프라인/수동 배포 가능", + "모듈/플러그인/템플릿 업데이트 커맨드에 외부 ZIP 파일 직접 지정 옵션(`--zip=/path/to/ext.zip`) 추가 — manifest 식별자와 대상 확장이 일치하는지 검증 후 manifest 의 버전으로 업데이트", + "외부 ZIP 은 GitHub 릴리스 zipball 의 래퍼 디렉토리와 평탄 루트 구조를 모두 자동 감지해 지원" + ] + }, + { + "name": "Changed", + "items": [ + "MailTemplate 시스템을 NotificationDefinition + NotificationTemplate 체계로 전면 전환", + "코어 알림 3종의 직접 발송을 훅 기반 발송으로 전환", + "알림 수신자를 definition 레벨에서 template 레벨로 이동 (채널별 독립 수신자)", + "알림 broadcast 채널을 정수 User ID에서 UUID 기반으로 변경 (보안 강화)", + "알림 시각 표시를 ISO 8601에서 사용자 타임존 기준으로 변경", + "24개 서비스의 캐시 호출을 `Cache::` 파사드에서 `CacheInterface` DI로 전환", + "13개 서비스의 TTL을 환경설정 중앙 관리로 통일 (하드코딩 제거)", + "캐시 키 접두사를 `g7:core:`, `g7:module.{id}:`, `g7:plugin.{id}:` 체계로 통일", + "`CacheService` 클래스 삭제 — `CacheInterface`로 완전 대체", + "권한 검증을 비트 값 비교에서 실제 읽기/쓰기 가능 여부 기반으로 단순화", + "`.env` 생성 안내를 소유자 일치 여부에 따라 1-step으로 통합", + "확장 정보 모달에 의존성 정보 노출 — 코어 요구 버전(`g7_version`), 의존 모듈/플러그인 목록과 각각의 요구 버전·설치 버전·충족 상태 뱃지를 모듈/플러그인/템플릿 정보 모달에서 확인 가능", + "`locale_names` 설정 추가 — 언어 표시명을 중앙에서 관리", + "매 요청 `Schema::hasTable()` 호출 제거 — 설치 완료 환경에서 10~14회의 불필요한 스키마 쿼리 제거로 응답 시간 단축", + "SeoRenderer에 모듈/플러그인 SEO 변수 자동 해석 기능 추가", + "ResponseHelper — 디버그 모드에서 예외 상세 자동 포함, 프로덕션에서 내부 메시지 노출 차단", + "환경설정 드라이버 변경 시 큐 워커 자동 재시작", + "`allow_url_fopen=Off` 환경 지원 — GitHub 연동 HTTP 호출을 Laravel Http 파사드로 전면 교체", + "코어 업데이트 완료 후 안내 메시지를 \"수동 업데이트 명령 나열\"에서 \"일괄 업데이트 인터랙티브 프롬프트\"로 변경", + "확장 삭제 시 권한·메뉴·역할은 \"데이터도 함께 삭제\" 옵션을 체크했을 때만 삭제되도록 변경 — 기본 삭제에서는 보존되어 재설치 시 사용자 역할 할당이 복원" + ] + }, + { + "name": "Fixed", + "items": [ + "WebSocket 데이터소스가 progressive 목록에 포함되어 영구 블러되던 문제 수정", + "WebSocket 채널/이벤트 표현식 미평가로 인증 실패하던 문제 수정", + "WebSocket broadcast HTTP API가 클라이언트 host로 POST되어 실패하던 문제 수정", + "WebSocket 활성화 시 필수 필드 미검증 문제 수정", + "WebSocket 비활성화 시에도 Reverb 연결을 시도하던 문제 수정", + "`--force` 업데이트 시 번들 없는 외부 확장이 업데이트 소스를 찾지 못하던 문제 수정", + "확장 의존성 데이터 구조 불일치 수정 — manifest JSON 기반으로 통일", + "모듈/플러그인/템플릿 install/activate/deactivate/uninstall 직후 라우트 미반영 수정", + "다국어 번역 캐시 경합으로 raw key 노출되던 문제 수정 (engine-v1.38.1)", + "조건부 `$t:` 표현식에서 번역 실패하던 문제 수정 (engine-v1.38.2)", + "테스트 환경과 개발 환경이 동일한 Redis 캐시를 공유하여 상호 오염되던 문제 수정", + "대시보드 브로드캐스트 스케줄이 무한 락에 걸리던 문제 수정", + "환경설정 저장 후 `config:cache` 상태에서 변경값 미반영 수정", + "Windows 환경에서 파일 잠금 감지 시 30초+ hang 수정 — 0.5초로 단축", + "개발 의존성 없이 설치된 프로덕션 환경에서 샘플 시더와 팩토리가 실행되지 않던 문제 수정 — 한국어 샘플 데이터 생성기가 자동으로 대체 동작하도록 개선", + "`sudo php artisan core:update` 실행 시 vendor 디렉토리 전체가 root 소유로 오염되던 문제 수정", + "코어 업데이트 후 신규 권한·메뉴가 DB에 반영되지 않아 관리자 환경설정 페이지 접근이 거부되던 문제 수정", + "beta.1에서 업그레이드 시 제거된 메일 템플릿 모델 참조로 Fatal 오류가 발생하던 문제 수정", + "업그레이드 후 `bootstrap/cache` 등 일부 디렉토리가 root 소유로 남아 확장 설치·업데이트가 거부되던 문제 수정 — 소유권 복원 범위를 설치 안내 경로 전체로 확장", + "코어 업데이트 진행률 표시와 upgrade step 실행 로그가 같은 줄에 뒤섞이던 문제 수정 — 각 step 안내가 별도 줄로 깔끔하게 출력됨", + "템플릿/레이아웃 공개 API가 확장 활성화 전·설치 중에 받은 \"찾을 수 없음\" 에러 응답을 영구 캐시하여 복구 후에도 같은 오류를 반환하던 문제 수정 — 에러 응답은 캐시에서 제외", + "`_pending`/`_bundled` 에 업데이트 중 남은 임시 디렉토리(`{id}_YYYYMMDD_HHMMSS`, `{id}_updating_*` 등) 의 원본 manifest 때문에 `install` 이 존재하지 않는 표준 경로로 접근해 실패하던 문제 수정 — 디렉토리명과 identifier 불일치 시 스캔에서 제외", + "업그레이드 후 일부 코어 권한이 DB에 반영되지 않아 사용자 역할의 알림 기능 권한이 비어있던 문제 수정 — 별도 프로세스 실행으로 최신 로직 기반 재동기화 보장", + "코어·확장 설정에서 제거된 메뉴·권한·역할·알림 정의·알림 템플릿·게시판 유형·클레임 사유가 업데이트 후에도 DB에 잔존하던 문제 수정 — 고아 레코드 자동 정리", + "관리자 UI에서 메뉴·역할·알림·게시판 유형·클레임 사유·배송 유형 등을 수정해도 다음 업데이트 시 기본값으로 덮어써지던 문제 수정 — 사용자가 수정한 필드는 모든 저장 경로에서 자동 추적되어 업데이트 후에도 보존", + "알림 정의·템플릿도 업데이트 기준으로 동기화 — 새 버전에서 제거된 알림은 DB에서도 삭제", + "관리자 모듈/플러그인 목록의 \"작성자\" 표기가 manifest 의 `vendor` 필드 대신 식별자 prefix 를 사용하던 문제 수정 — `vendor` 가 정의된 확장은 정확한 작성자명으로 표시 (#9 @glitter-gim 님께서 제보해주셨습니다.)" + ] + }, + { + "name": "Removed", + "items": [ + "MailTemplate 시스템 일괄 제거 — 모델, 컨트롤러, 서비스, 리스너, 시더, 팩토리, 다국어 파일 등. NotificationDefinition + NotificationTemplate 체계로 완전 대체" + ] + } + ] + }, + { + "version": "7.0.0-beta.1", + "date": "2026-04-01", + "categories": [ + { + "name": "Changed", + "items": [ + "오픈 베타 릴리즈" + ] + } + ] + }, + { + "version": "7.0.0-alpha.21", + "date": "2026-03-30", + "categories": [ + { + "name": "Added", + "items": [ + "인스톨러 PHP CLI/Composer 필수 검증 기능 — 기본 `php` 미감지 시 CLI 설정 필수 전환, Composer 실행 확인 및 미설치 시 설치 안내, 둘 다 검증 완료 전 다음 단계 진행 차단", + "템플릿 레이아웃 수정 감지 시스템 — `original_content_hash`/`original_content_size` 컬럼 추가, SHA-256 해시 기반 수정 감지로 `updated_by` 방식 대체 (TemplateManager, LayoutRepository, 마이그레이션)", + "관리자 로그인 화면 개선 — 비밀번호 찾기/재설정 플로우 추가, 테마 Segmented Control, 언어 셀렉트 확대 (PasswordResetController, PasswordResetNotification, ForgotPasswordRequest, ResetPasswordRequest)", + "`{{raw:expression}}` 바인딩 번역 면제 마커 시스템 — `$t:` 자동 번역 대상에서 특정 바인딩을 제외하는 마커 도입 (rawMarkers.ts)", + "레이아웃 프리뷰 모드 — 관리자 레이아웃 편집기에서 실시간 미리보기 지원 (LayoutPreviewController, LayoutPreviewService, 마이그레이션)", + "활동 로그 이력 조회 메뉴 신설 — ActivityLogController, ActivityLogResource, ActivityLogService, ActivityLogRepository, config/core.php 메뉴/권한 등록, 관리자 레이아웃 추가" + ] + }, + { + "name": "Changed", + "items": [ + "`window.G7Config` 설정 노출 최소화 Phase 1 — 프론트엔드 미참조 설정의 브라우저 소스 노출 차단", + "SettingsService: 필드 레벨 `expose: false` 지원 추가 (formatCategorySettings)", + "FiltersFrontendSchema: `fields: {}` (빈 객체) 시 전체 차단 안전 기본값 적용", + "View Composers: TemplateComposer/UserTemplateComposer에 ModuleSettingsService 주입 — frontend_schema 기반 필터링 적용 (기존 config() 직접 참조 우회 수정)", + "코어 defaults.json: general 4개 필드, security 전체, seo 전체, advanced 9개 필드(debug_mode 제외), upload 2개 필드, drivers 전체를 `expose: false` 처리", + "캐시 무효화 로직 정비 — 016862cb 이전의 단순한 방식으로 복귀 (버전 증가 + TTL 자연 만료), `extension_cache_previous_versions` 추적 및 `getCacheVersionsToInvalidate()`/`getCacheVersionsForLayoutInvalidation()` 제거, Cache Tags 드라이버 분기 제거로 캐시 드라이버 비의존성 확보" + ] + }, + { + "name": "Fixed", + "items": [ + "MariaDB를 `DB_CONNECTION=mysql` 드라이버로 연결 시 `WITH PARSER ngram` 에러로 인스톨러 설치 실패 수정 — `isMariaDb()` 메서드 추가하여 서버 버전 문자열 기반 실제 DBMS 감지 (DatabaseFulltextEngine)", + "캐시 무효화 버전 키 누락 수정 — `warmTemplateCache()`가 버전 포함 키(`.v{version}`)로 캐시를 생성하나 `clearTemplateCache()`가 레거시 키만 삭제하던 문제, routes/language 캐시에 버전 포함 키 삭제 추가", + "캐시 버전 0 무효화 누락 수정 — `array_filter()`가 버전 `0`을 falsy로 제거하여 `.v0` 캐시 키가 삭제 대상에서 누락되던 버그 (ClearsTemplateCaches, InvalidatesLayoutCache)", + "테스트 활성 디렉토리 보호 강화 — `ProtectsExtensionDirectories`에 `moveDirectory` spy 추가, `copyToActive()` 원자적 교체가 활성 디렉토리를 rename으로 파괴하는 것 방지", + "활동 로그 하위 호환 — `ActivityLogResource`에서 기존 DB 레코드(`type: 'text'`)의 enum 필드를 모델의 현재 `$activityLogFields` 기준으로 동적 번역 + 일괄 변경 `:count` 미치환 보정", + "활동 로그 enum 미번역 전수 수정 — 14개 모델 필드의 `$activityLogFields` `type: 'enum'` 전환 + 14개 Enum 클래스에 `labelKey()` 메서드 추가 (User, Order, OrderOption, Product, Coupon, Post, Comment, Board)", + "활동 로그 삭제/일괄삭제 `description_key` 및 샘플 시더 누락 추가 — 코어 시더 `activity_log_descriptions` 테이블에 delete/bulk_delete 키 보충 + 다국어 파일 동기화", + "관리자 다크모드 품질 전수 조사 및 수정 — 레이아웃 JSON ~546건, TSX ~37건, CSS 5건의 누락된 `dark:` variant 추가 (이커머스 101파일, 게시판 19파일, 페이지 2파일, 마케팅 1파일, 템플릿 컴포넌트 ~20파일)", + "레이아웃 편집 캐시 무효화 및 버전 히스토리 수정 — LayoutService의 저장/복원 시 PublicLayoutController 서빙 캐시 무효화 + `extension_cache_version` 증가 누락 수정", + "사용자 관리에서 관리자/슈퍼관리자 삭제 시 토스트 메시지 및 어빌리티 처리 — CannotDeleteAdminException 추가, UserResource에 `can_delete` 어빌리티 반영", + "ActivityLog 핸들러 `$result` 타입 불일치 수정 — CoreActivityLogListener에서 `bool` → `array` 반환 타입 정합성 보정", + "환경설정 사이트 로고 저장 시 Attachment 객체 정수 검증 오류 수정 — SaveSettingsRequest에서 Attachment JSON 객체를 정수로 검증하던 문제 수정", + "관리자 SPA 네비게이션 로고 깜빡임 수정 — extends base 컴포넌트 불필요 remount 방지 (`_fromBase` 마킹 기반 stable key 패턴)", + "인스톨러 다크모드 적색 배경 가독성 개선 — `alert-title`, `alert-message`, `permission-badge`, `test-result` 다크모드 색상 override 추가", + "인스톨러 `BASE_PATH` 심볼릭 링크 미해석 — `realpath()` 적용 + 절대경로/상대경로 병기로 호스팅 환경 대응", + "인스톨러 403 에러 페이지 다크모드 미지원 — `prefers-color-scheme: dark` 미디어쿼리 추가", + "템플릿 업데이트 수정 감지 실패 — `LayoutRepository::update()`가 `updated_by`를 설정하지 않아 `hasModifiedLayouts()`가 항상 \"수정 없음\" 반환하던 문제 수정 (hash 비교 방식으로 전환)", + "템플릿 업데이트 \"수정 유지\" 전략 미작동 — `layoutStrategy === 'keep'` 시 `refreshTemplateLayouts()` 미호출 → 양쪽 전략 모두 호출하되 `preserveModified` 플래그로 분기", + "템플릿 업데이트 API 응답 키 불일치 — 백엔드 `has_modified` → 프론트엔드 기대 `has_modified_layouts`/`modified_count` 키 정렬", + "템플릿/모듈/플러그인 설치 시 `incrementExtensionCacheVersion()` 누락 수정 — TemplateManager(install/activate/deactivate/uninstall) + ModuleManager(install) + PluginManager(install) 총 7곳 추가", + "캐시 버전 변경 시 프론트엔드 다국어 미갱신 수정 — TemplateApp.ts에서 캐시 버전 변경 감지 시 TranslationEngine 재로드 추가", + "`warmTemplateCache()` 다국어 캐시 워밍 시 `$partial` 디렉티브 미해석 수정 — `json_decode`만 수행하던 코드를 `TemplateService::getLanguageDataWithModules()` 호출로 교체하여 fragment 해석 및 모듈/플러그인 다국어 병합 정상화", + "`ActivateTemplateCommand` 의존성 미충족 시 성공으로 보고하는 버그 수정 — `if ($result)` → `if ($result['success'])` 변경 및 의존성 경고 메시지 출력, `--force` 옵션 추가", + "`ActivateModuleCommand`/`ActivatePluginCommand` 의존성 미충족 시 경고 메시지 미출력 수정 — 의존성 경고 표시 및 `--force` 옵션 전달 추가" + ] + } + ] + }, + { + "version": "7.0.0-alpha.20", + "date": "2026-03-30", + "categories": [ + { + "name": "Added", + "items": [ + "Laravel Scout 통합 및 MySQL FULLTEXT(ngram) 검색엔진 드라이버 확장 시스템 도입 — 커스텀 `DatabaseFulltextEngine`으로 `MATCH...AGAINST IN BOOLEAN MODE` 지원, `core.search.engine_drivers` 필터 훅으로 Meilisearch/Elasticsearch 등 외부 엔진 플러그인 등록 가능", + "`DatabaseFulltextEngine` 다중 DBMS 호환 — FULLTEXT 미지원 DBMS(PostgreSQL, SQLite)에서 LIKE fallback 자동 전환, `whereFulltext()` 정적 헬퍼(관계 검색용), `addFulltextIndex()` 마이그레이션 헬퍼(DBMS별 조건부 DDL)", + "`config/scout.php` 설정 파일 추가 — 기본 드라이버 `mysql-fulltext`, `SCOUT_DRIVER` 환경변수로 전환 가능", + "`FulltextSearchable` 인터페이스 — FULLTEXT 검색 대상 컬럼 및 가중치 정의 계약", + "`AsUnicodeJson` 커스텀 캐스트 — JSON 컬럼에 한글을 `\\uXXXX` 이스케이프 없이 실제 UTF-8로 저장하여 FULLTEXT ngram 토크나이저 정상 동작 보장", + "환경설정 > 드라이버 탭에 검색엔진 설정 카드 추가 — 기본 MySQL FULLTEXT(ngram) 드라이버 표시, 플러그인 설치 시 추가 드라이버 자동 표시", + "`SaveSettingsRequest`에 검색엔진 드라이버 동적 validation 추가 — `core.search.engine_drivers` 필터 훅 기반 허용 목록" + ] + } + ] + }, + { + "version": "7.0.0-alpha.19", + "date": "2026-03-29", + "categories": [ + { + "name": "Added", + "items": [ + "검색/필터/정렬 성능 향상을 위한 누락 인덱스 일괄 추가 — activity_logs(description_key), users(created_at), mail_send_logs(status), template_layouts(template_id), schedules(created_at)" + ] + } + ] + }, + { + "version": "7.0.0-alpha.18", + "date": "2026-03-26", + "categories": [ + { + "name": "Added", + "items": [ + "SEO ExpressionEvaluator 산술 연산자 확장 — `*`, `/`, `%` 추가 (기존 `+`, `-`만 지원)", + "SEO PipeRegistry 파이프 함수 엔진 구현 — 프론트엔드 PipeRegistry.ts 빌트인 파이프 15종 PHP 미러링 (date, datetime, relativeTime, number, truncate, uppercase, lowercase, stripHtml, default, fallback, first, last, join, length, filterBy, keys, values, json, localized)", + "ActivityLog `description_params` ID→이름 변환 필터 훅 (`core.activity_log.filter_description_params`) — `ActivityLog::getLocalizedDescriptionAttribute()`에서 실행", + "코어 모델 `$activityLogFields` 정의 — `User`, `Role`, `Menu`, `Schedule`, `MailTemplate` (5개 모델)", + "ActivityLog ChangeDetector 필드 라벨 다국어 키 추가 (`lang/ko/activity_log.php`, `lang/en/activity_log.php` — `fields` 섹션)", + "`module_helpers.php` — `getModuleSetting()` 헬퍼 함수 추가", + "CoreActivityLogListener: bulk_update per-User/per-Schedule 전환 + bulk_delete per-Schedule 전환 (건별 loggable_id 기록)", + "ActivityLogHandler: 삭제된 엔티티용 loggable_type/loggable_id 직접 지정 fallback 지원", + "메뉴 관리 크로스 depth 이동 지원 — `UpdateMenuOrderRequest`에 `moved_items` 검증 추가, `MenuRepository`에 크로스 depth reorder 로직 구현", + "`NotCircularParent` 검증 규칙 추가 — 메뉴 순환 참조 방지" + ] + }, + { + "name": "Changed", + "items": [ + "ActivityLog 규정 문서 대폭 보강 (`docs/backend/activity-log.md`) — `description_params` 저장 정책, `ActivityLogDescriptionResolver` 패턴, Bulk Update ChangeDetector 패턴, 개발자 체크리스트 추가" + ] + } + ] + }, + { + "version": "7.0.0-alpha.17", + "date": "2026-03-26", + "categories": [ + { + "name": "Added", + "items": [ + "ActivityLog `description_params` ID→이름 변환 필터 훅 (`core.activity_log.filter_description_params`) — `ActivityLog::getLocalizedDescriptionAttribute()`에서 실행", + "코어 모델 `$activityLogFields` 정의 — `User`, `Role`, `Menu`, `Schedule`, `MailTemplate` (5개 모델)", + "ActivityLog ChangeDetector 필드 라벨 다국어 키 추가 (`lang/ko/activity_log.php`, `lang/en/activity_log.php` — `fields` 섹션)", + "`module_helpers.php` — `getModuleSetting()` 헬퍼 함수 추가", + "CoreActivityLogListener: bulk_update per-User/per-Schedule 전환 + bulk_delete per-Schedule 전환 (건별 loggable_id 기록)", + "ActivityLogHandler: 삭제된 엔티티용 loggable_type/loggable_id 직접 지정 fallback 지원" + ] + }, + { + "name": "Changed", + "items": [ + "ActivityLog 규정 문서 대폭 보강 (`docs/backend/activity-log.md`) — `description_params` 저장 정책, `ActivityLogDescriptionResolver` 패턴, Bulk Update ChangeDetector 패턴, 개발자 체크리스트 추가" + ] + } + ] + }, + { + "version": "7.0.0-alpha.17", + "date": "2026-03-26", + "categories": [ + { + "name": "Added", + "items": [ + "Monolog 기반 ActivityLog 아키텍처: `Log::channel('activity')` → `ActivityLogHandler` → DB (3단계)", + "`ActivityLogChannel` (커스텀 Monolog 채널), `ActivityLogHandler`, `ActivityLogProcessor` 신규", + "ActivityLog i18n 지원: `description_key` + `description_params` 기반 실시간 다국어 번역", + "ActivityLog 구조화된 변경 이력: `changes` JSON 컬럼 (필드별 `label_key`, `old`/`new`, `type` 포함)", + "`ChangeDetector` 유틸리티 (모델 스냅샷 비교 → 구조화된 변경 이력 생성)", + "`CoreActivityLogListener` 전면 확장: 모든 코어 Service 훅 구독 (User/Role/Menu/Settings/Schedule/Auth/Module/Plugin/Template/Layout/MailTemplate/Attachment — 66개 훅)", + "활동 로그 다국어 키 105개 정의 (`lang/ko/activity_log.php`, `lang/en/activity_log.php`)", + "`config/logging.php`에 `activity` 채널 추가", + "`config/activity_log.php` 전용 설정 파일 신규", + "`activity_logs` 테이블 복합 인덱스 추가 (`loggable_type`+`loggable_id`+`created_at`, `log_type`+`action`+`created_at`)" + ] + }, + { + "name": "Fixed", + "items": [ + "`resolveLogType()` 사용자 역할 기반 → 요청 경로 기반으로 변경: 관리자가 사용자 화면에서 수행한 액션이 `admin`으로 기록되던 문제 수정 (ResolvesActivityLogType)" + ] + }, + { + "name": "Changed", + "items": [ + "`ActivityLog` 모델: `description` 컬럼 삭제 → `description_key`/`description_params` 기반 다국어 전환", + "`ActivityLogService`: 기록 메서드 전면 제거 → 조회 전용으로 축소", + "`ActivityLogResource`: `description` → `localized_description` (실시간 번역)", + "모든 Controller에서 `logAdminActivity()` 호출 전면 제거 → Listener 경로로 전환" + ] + }, + { + "name": "Removed", + "items": [ + "`activity_logs.description` 컬럼 (DB 삭제)", + "`ActivityLogManager`, `ActivityLogDriverInterface`, `DatabaseActivityLogDriver`, `NullActivityLogDriver`", + "`ActivityLogListener` (이중 훅 계층 — Monolog Handler로 대체)", + "`ActivityLogService.log`/`logAdmin`/`logUser`/`logSystem` (Monolog 채널로 대체)", + "`AdminBaseController.logAdminActivity()`, `generateActivityDescription()`, `flattenDataForTranslation()`" + ] + }, + { + "name": "Fixed", + "items": [ + "마이페이지 프로필 저장 시 국가·언어 미선택 상태에서 오류가 발생하던 문제 수정 — 해당 필드를 선택 사항으로 변경", + "인스톨러 Step 2 .env 복사 명령어 안내 수정 — `.env.example.production` → `.env.example` (functions.php 2곳, installer.js 4곳)" + ] + } + ] + }, + { + "version": "7.0.0-alpha.16", + "date": "2026-03-23", + "categories": [ + { + "name": "Fixed", + "items": [ + "코어 업데이트 롤백 시 vendor 디렉토리 복원 불가 수정 — 백업 targets에 vendor 포함, excludes에서 vendor 제거", + "코어 백업 복원 시 개별 target 실패가 전체 복원을 중단하는 문제 수정 — 개별 try-catch로 나머지 target 복원 계속 진행", + "코어 업데이트 롤백 실패 시 수동 복구 안내 미출력 수정 — composer install 등 복구 단계 안내 추가", + "코어 업데이트 완전 복원 성공 시 유지보수 모드 자동 해제 추가", + "코어 업데이트 시 vendor 디렉토리 이중 처리로 인한 마이그레이션 실패 수정 — `backup_only` 설정 분리 (applyUpdate 제외, 백업/복원 전용)" + ] + } + ] + }, + { + "version": "7.0.0-alpha.15", + "date": "2026-03-23", + "categories": [ + { + "name": "Added", + "items": [ + "Users UUID 전환 — 외부 노출 ID를 UUID v7으로 전환, 정수 `id`는 API 응답에서 숨김", + "UniqueIdService 코어 서비스 추가 (UUID v7 + NanoID 생성)", + "코어 업그레이드 스텝 추가 (Upgrade_7_0_0_beta_15)" + ] + }, + { + "name": "Changed", + "items": [ + "User 모델: `getRouteKeyName()` → 'uuid', `$hidden`에 'id' 추가", + "공개 프로필 API: Route Model Binding 전환 (`{userId}` → `{user}`)", + "사용자 벌크 상태변경: 정수 ID → UUID 기반", + "API Resource: user.id → user.uuid 전환 (UserResource, UserCollection 외 7개)", + "Activity Log 메타데이터: user_id → uuid 전환", + "FormRequest: 정수 검증 → UUID 검증 전환" + ] + }, + { + "name": "Fixed", + "items": [ + "`_global.currentUser?.id` → `?.uuid` 전환 (sirsoft-basic 템플릿 12개 파일)", + "글쓰기 버튼 abilities 비활성화 누락 수정", + "코어 업그레이드 스텝 raw SQL 테이블 프리픽스 미적용 수정 — `UpgradeContext::table()` 헬퍼 추가 (Upgrade_7_0_0_beta_15)" + ] + } + ] + }, + { + "version": "7.0.0-alpha.14", + "date": "2026-03-20", + "categories": [ + { + "name": "Fixed", + "items": [ + "확장 업데이트 시 임시 디렉토리(`_updating_*`, `_old_*`) 오토로드 오염 방지 — 임시 디렉토리를 `_pending/` 하위에 생성하여 IDE 잠금 등으로 잔존 시에도 Fatal Error 방지 (ExtensionPendingHelper)" + ] + }, + { + "name": "Added", + "items": [ + "Windows 파일잠금 감지/해제 기능 — 확장 업데이트 시 IDE 등이 파일 핸들을 보유한 경우 자동 감지 및 해제 시도 (FileHandleHelper, ExtensionPendingHelper)", + "SEO 렌더러 훅 시스템: `core.seo.filter_context`, `core.seo.filter_meta`, `core.seo.filter_view_data` — 확장이 SEO 렌더링 파이프라인에 런타임 데이터 변환으로 개입 가능", + "`seo.blade.php` 확장 슬롯: `extraHeadTags` (`` 직전), `extraBodyEnd` (`` 직전) — `filter_view_data` 훅을 통해 커스텀 스크립트/스타일 주입", + "`ComponentHtmlMapper` pagination 렌더 모드 — Pagination 컴포넌트에서 SEO용 페이지 링크 자동 생성 (currentPage/totalPages props 기반)", + "`ComponentHtmlMapper` text_format dot notation 지원 — `{author.nickname}` 형태로 객체 prop의 중첩 필드 접근", + "`ExpressionEvaluator::evaluateRaw()` — 표현식 결과를 원본 타입(배열 등)으로 반환하는 메서드", + "`SeoRenderer` SEO 컨텍스트에 `_global`/`_local` 빈 객체 추가 — 프론트엔드 전용 상태 참조 시 null 대신 빈 객체 제공", + "`ComponentHtmlMapper` fields 렌더 모드 — 컴포지트 컴포넌트(ProductCard 등)의 객체 prop에서 SEO용 HTML 필드 자동 생성 (조건부/반복/속성 기반)", + "`SeoRenderer` seoVars 주입 — `meta.seo.vars` 선언을 해석하여 ComponentHtmlMapper format 모드에서 `{key}` 플레이스홀더 치환", + "`ExpressionEvaluator` 리터럴 값 감지 — 숫자, 문자열, boolean, null/undefined 리터럴을 경로 해석 없이 직접 반환", + "`ExpressionEvaluator` $t: 파라미터 `{{}}` 표현식 해석 — 번역 키 파라미터 값에 포함된 바인딩 표현식을 컨텍스트에서 평가", + "`TemplateManager` seo-config 검증에 `fields` 타입 추가", + "`ExpressionEvaluator` seo_overrides — seo-config.json에서 `_local`/`_global` 상태 오버라이드 선언 (와일드카드 매칭으로 접혀있는 콘텐츠 SEO 강제 펼침)", + "`TemplateManager` seo-config 검증에 `pagination` 타입 및 `seo_overrides` 검증 추가", + "`SeoConfigMerger` — 모듈/플러그인/템플릿의 seo-config.json을 수집·병합하는 동적 확장 시스템 (우선순위: 모듈 → 플러그인 → 템플릿, 24시간 TTL 캐싱)", + "`SeoRenderer` `_global` 컨텍스트 주입 — SettingsService/PluginSettingsService 프론트엔드 설정을 `_global`에 주입 + `initGlobal` 매핑으로 데이터소스 응답을 `_global` 경로에 바인딩", + "`AbstractModule`/`AbstractPlugin` SEO 기여 메서드 — `getSeoConfig()`, `getSeoDataSources()` 인터페이스 추가", + "`ModuleManager`/`PluginManager`/`TemplateManager` install/activate/update 시 훅 발행 추가 — Artisan 커맨드에서도 SEO 캐시 자동 무효화 보장", + "SEO Artisan 커맨드 다국어 파일 추가 (`lang/ko/seo.php`, `lang/en/seo.php`)", + "`ComponentHtmlMapper` fields 모드 `$all_props` source — 모든 props 표현식을 해석하여 데이터 객체로 사용 (Header/Footer 등 다수 props 컴포넌트용)", + "`ComponentHtmlMapper` fields 모드 `$t:` 번역 키 지원 — content 패턴에서 `$t:key` → 다국어 텍스트 렌더링", + "`ComponentHtmlMapper` fields iterate `item_attrs` — 아이템별 동적 HTML 속성 (예: `{ \"href\": \"/board/{slug}\" }`)", + "`ExpressionEvaluator` `evaluateRaw()` `??` null coalescing 지원 — 원본 타입(배열/객체) 유지하면서 null coalescing 수행", + "인스톨러 Windows 환경 명령어 대응 — `chmod`/`chown` 스킵, 미존재 디렉토리 안내 메시지 추가", + "`ExpressionEvaluator` 삼항 연산자 (`a ? b : c`) — JS 우선순위 준수, `?.`/`??` 자동 구분, 중첩 우측 결합", + "`ExpressionEvaluator` `$t()` 함수 호출 구문 — 삼항 내부에서 `$t('key')` 형태로 번역 키 사용 가능 (기존 `$t:key` 방식 확장)", + "`ExpressionEvaluator` `$localized()` 전역 함수 — 다국어 객체에서 현재 로케일 값 추출 (`{ko: \"상품\", en: \"Product\"}` → `\"상품\"`)", + "`ExpressionEvaluator` 객체 리터럴 파서 — `{key: value, ...obj, [dynamicKey]: value}` 구문 지원", + "`ExpressionEvaluator` 스프레드 연산자 — 배열 `[...arr, item]` 및 객체 `{...obj, key: value}` 스프레드 지원", + "`SeoRenderer` computed 속성 해석 — 레이아웃 `computed` 섹션을 `_computed`/`$computed`에 저장 (문자열 표현식 + `$switch` 형식)", + "`ComponentHtmlMapper` classMap 지원 — `base`/`variants`/`key`/`default`로 조건부 CSS 클래스 선언적 적용" + ] + }, + { + "name": "Fixed", + "items": [ + "Redis 캐시 DB가 환경설정 값(`REDIS_CACHE_DB`)을 따르도록 수정 — `config/database.php` cache 연결 DB 반영", + "`ExpressionEvaluator` 배열 리터럴 파싱 지원 — `['gallery','card'].includes(...)` 표현식에서 배열 리터럴을 PHP 배열로 변환하여 `includes` 등 배열 메서드 정상 동작 (SEO 게시판 타입 분기 조건 중복 렌더링 수정)", + "`ExpressionEvaluator` null 비교 JavaScript 시맨틱 적용 — `null !== value` → `true`, `null === null` → `true` (SEO 컨텍스트에서 `_global` 미존재 경로 비교 시 빈 문자열 대신 올바른 boolean 반환)", + "`ExpressionEvaluator` $t: 번역에서 `{{param}}` 형식 파라미터 치환 지원 — 템플릿 번역 파일의 `{{param}}` + Laravel 표준 `:param` 형식 모두 처리", + "`ExpressionEvaluator` 비교 연산 좌측 optional chaining 경로 타입 보존 — `?.` 포함 경로가 `evaluateExpression`으로 불필요 라우팅되어 boolean 타입이 문자열로 변환되던 문제 수정" + ] + } + ] + }, + { + "version": "7.0.0-alpha.13", + "date": "2026-03-18", + "categories": [ + { + "name": "Added", + "items": [ + "`SeoCacheRegenerator` — 단건 URL 캐시 즉시 재생성 서비스 (다국어 로케일별 렌더링 + 캐시 저장)", + "`SeoSettingsCacheListener` — 코어 SEO 설정 변경 시 전체 SEO 캐시 + 사이트맵 삭제" + ] + }, + { + "name": "Fixed", + "items": [ + "`SeoMiddleware`에서 `put()` 대신 `putWithLayout()` 사용 — `invalidateByLayout()`이 레이아웃명 미저장으로 항상 0건 매칭되던 근본 버그 수정", + "`SeoRenderer`에서 레이아웃명을 request attribute로 저장 — `putWithLayout()` 연동" + ] + } + ] + }, + { + "version": "7.0.0-alpha.12", + "date": "2026-03-17", + "categories": [ + { + "name": "Changed", + "items": [ + "API 인증을 토큰 전용으로 전환 — 세션 기반 인증 의존 제거, Bearer 토큰 단일 방식으로 통일", + "README 업데이트 — 표기 통일, 섹션 정비, 문서 링크 연결" + ] + } + ] + }, + { + "version": "7.0.0-alpha.11", + "date": "2026-03-16", + "categories": [ + { + "name": "Added", + "items": [ + "역할(Role) 상태 토글 API 추가 — `PATCH /api/admin/roles/{role}/toggle-status` 엔드포인트, 훅 지원 (`core.role.before_toggle_status`, `core.role.after_toggle_status`)", + "RoleResource에 `can_toggle_status` ability 추가 — 역할별 토글 권한 제어", + "RoleResource에 `extension_name` 필드 추가 — 확장 출처별 로케일 이름 표시", + "역할 생성 시 `identifier` 직접 입력 기능 추가 — 미입력 시 name 기반 자동 생성 유지", + "역할 관련 다국어 키 추가 (ko/en) — identifier 검증 메시지", + "`replaceUrl` 핸들러 추가 — refetch 없이 URL만 변경 (페이지네이션 등 브라우저 히스토리 관리용)", + "사용자 검색 API에 `id` 파라미터 지원 추가 — 특정 사용자 ID로 직접 조회 가능", + "`TimezoneHelper` 유틸리티 클래스 추가 — 사용자/서버 타임존 간 변환 헬퍼", + "`HasSampleSeeders` 트레이트 추가 — 모듈/플러그인 시더에서 `--sample` 옵션으로 샘플 데이터만 분리 실행", + "`ComponentRegistry.getComponentNames()` 메서드 추가 — 등록된 컴포넌트 이름 목록 조회" + ] + }, + { + "name": "Fixed", + "items": [ + "템플릿 에러 페이지에서 `:identifier`가 리터럴로 표시되는 버그 수정 — Blade `@` 이스케이프 처리", + "ActionDispatcher onChange raw value fallback 제거 — 마운트/리렌더 시 의도치 않은 setState 실행으로 상품 폼 회귀 유발", + "Form 자동 바인딩 setState 경합 수정 — 자동 바인딩과 수동 setState가 동시 실행 시 stale 값으로 덮어쓰이는 문제 해결", + "Form 자동 바인딩 bindingType 메타데이터 기반 boolean 바인딩 수정 — Toggle/Checkbox 등 boolean 컴포넌트에서 문자열 변환 대신 boolean 값 유지", + "SPA 네비게이션 시 `_global._local`에 이전 페이지 상태가 잔존하는 버그 수정 — 페이지 전환 시 `_local` 초기화 처리", + "DynamicRenderer `_computed` 참조 prop에서 캐시된 stale 값이 사용되는 버그 수정 — `_computed`/`$computed` 참조 시 `skipCache: true` 적용" + ] + }, + { + "name": "Changed", + "items": [ + "마이그레이션 통합 — 증분 마이그레이션을 테이블당 1개 create 마이그레이션으로 정리", + "시더 디렉토리 분리 — 설치 시더와 샘플 시더를 `Sample/` 하위로 분리, `--sample` 옵션 추가", + "라이선스 프로그램 명칭 정비", + "일정 관리 메뉴 기본 비활성화 (`config/core.php`)", + "Composer 의존성 업데이트 — Laravel Framework v12.54.1, Reverb v1.8.0, Symfony v7.4.6~7 등" + ] + } + ] + }, + { + "version": "7.0.0-alpha.9", + "date": "2026-03-13", + "categories": [ + { + "name": "Added", + "items": [ + "그누보드7 커스텀 에러 페이지 도입 — Laravel 기본 에러 페이지(401, 403, 404, 500, 503)를 그누보드7 스타일로 교체, 다크 모드 지원, 접근 경로 기반 홈 링크 분기 (admin → `/admin`, 기타 → `/`)", + "환경설정 > 고급 디버그 모드 활성화 시 개발 대시보드(`/dev`) 바로가기 버튼 추가", + "루트 LICENSE 파일 생성 (MIT 라이선스, 한국어 번역 + 영문 원문)", + "코어 라이선스/Changelog API 엔드포인트 추가 (`GET /api/admin/license`, `GET /api/admin/changelog`)", + "확장 라이선스 API 엔드포인트 추가 (`GET /api/admin/modules/{id}/license`, `GET /api/admin/plugins/{id}/license`, `GET /api/admin/templates/{id}/license`)", + "Admin 푸터 copyright 클릭 → 코어 라이선스 모달, 버전 클릭 → Changelog 모달 표시 기능", + "확장 상세 모달에서 라이선스 클릭 시 전문 모달 표시 기능 (모듈/플러그인/템플릿)", + "각 번들 확장에 LICENSE 파일 및 manifest `license` 필드 추가" + ] + }, + { + "name": "Changed", + "items": [ + "설치 화면 라이선스를 루트 LICENSE 파일로 통합 (`public/install/lang/license-ko.txt`, `license-en.txt` 삭제)", + "`/dev` 라우트 뷰 이름을 `dev-dashboard`로 변경" + ] + } + ] + }, + { + "version": "7.0.0-alpha.8", + "date": "2026-03-13", + "categories": [ + { + "name": "Changed", + "items": [ + "`.env.example.develop`과 `.env.example.production`을 `.env.example`로 통합 — 설치형 솔루션에 환경별 분리 불필요, Laravel/Vite 표준 준수", + "인스톨러에서 `.env.production` 백업 파일 생성 로직 제거 — Vite mode 기반 로딩 충돌 방지" + ] + }, + { + "name": "Improved", + "items": [ + "코어/확장 업데이트 시 composer install 스킵 최적화 — composer.json/composer.lock 미변경 시 composer install 및 vendor 디렉토리 교체를 건너뛰어 업데이트 시간 단축" + ] + }, + { + "name": "Fixed", + "items": [ + "확장 수동 설치 모달에서 설치 실패 시 상세 에러 사유(`errors.error`) 미표시 문제 수정 — 3개 모달에 상세 에러 P 요소 추가", + "`checkDependencies()` 복수 의존성 에러 수집 — 첫 번째 미충족 의존성에서 즉시 throw 대신 전체 수집 후 줄바꿈 연결 (ModuleManager, PluginManager, TemplateManager)", + "ModuleController/PluginController 에러 반환 형식을 TemplateController와 통일 — `['error' => $e->getMessage()]` 형태", + "확장 설치 실패 시 `_pending/{identifier}` 디렉토리 자동 정리 — ModuleService, PluginService, TemplateService에 try-catch 추가" + ] + } + ] + }, + { + "version": "7.0.0-alpha.7", + "date": "2026-03-13", + "categories": [ + { + "name": "Added", + "items": [ + "템플릿 엔진 `multipart/form-data` 지원 — apiCall 핸들러 및 DataSourceManager에서 `contentType: \"multipart/form-data\"` 설정 시 params를 FormData로 자동 변환", + "ActionDispatcher: `fetchWithOptions()`에 FormData 변환 로직 추가, Content-Type 헤더 자동 생략 (브라우저 boundary 설정)", + "DataSourceManager: `toFormData()` 메서드 추가, 인증/비인증 경로 모두 multipart 지원", + "File/Blob 원본 유지, null/undefined 제외, 객체/배열 JSON.stringify 변환", + "`deepMergeWithState()` non-plain 객체(File/Blob/Date) 보호 — spread 복사로 인한 내부 데이터 소실 방지", + "`resolveParams()` non-plain 객체 재귀 해석 스킵 — File 객체가 빈 객체로 변환되는 문제 방지", + "컴포넌트 onChange raw value fallback — FileInput, Toggle 등 Event가 아닌 값을 전달하는 컴포넌트 지원" + ] + }, + { + "name": "Fixed", + "items": [ + "DataSourceManager `isMultipart` 변수 TDZ(Temporal Dead Zone) 버그 수정 — 선언 전 참조로 인한 ReferenceError 해결" + ] + } + ] + }, + { + "version": "7.0.0-alpha.6", + "date": "2026-03-13", + "categories": [ + { + "name": "Fixed", + "items": [ + "플러그인 환경설정 페이지 진입 시 404 오류 수정 — `registerPluginLayouts()` admin/user 분기 도입 후 루트 `settings.json`이 스킵되던 문제", + "플러그인 설정 레이아웃을 `resources/layouts/settings.json` → `resources/layouts/admin/plugin_settings.json`으로 이동하여 모듈과 동일한 구조로 통일", + "`AbstractPlugin::getSettingsLayout()` 경로 변경", + "`PluginSettingsService` 오버라이드 경로 및 주석 수정", + "영향받는 플러그인: sirsoft-daum_postcode, sirsoft-marketing, sirsoft-tosspayments" + ] + }, + { + "name": "Changed", + "items": [ + "플러그인 설정 레이아웃 규정 문서(`plugin-development.md`) 경로/설명 업데이트" + ] + } + ] + }, + { + "version": "7.0.0-alpha.5", + "date": "2026-03-12", + "categories": [ + { + "name": "Added", + "items": [ + "확장(모듈/플러그인/템플릿) changelog GitHub 원격 소스 지원 — `source=github` 시 GitHub에서 CHANGELOG.md 조회, 실패 시 bundled 폴백", + "`ChangelogParser::parseFromString()`, `getVersionRangeFromString()` 문자열 기반 파싱 메서드 추가", + "`ChangelogRequest` validation 에러 다국어 메시지 추가 (source, version format, required_with)", + "플러그인 GitHub/ZIP 설치 기능 추가 — 모듈/템플릿에는 있지만 플러그인에 누락되어 있던 기능 신규 구현", + "`PluginService::installFromGithub()`, `installFromZipFile()`, `findPluginJson()` 메서드 추가", + "`PluginController::installFromFile()`, `installFromGithub()` 엔드포인트 추가", + "`InstallPluginFromGithubRequest`, `InstallPluginFromFileRequest` FormRequest 추가", + "`install-from-file`, `install-from-github` API 라우트 추가", + "`ExtensionManager::hasComposerDependenciesAt(string $path)` 메서드 추가 — 임의 경로의 Composer 의존성 확인", + "모듈/플러그인 설치 시 `_pending` Composer 선행 설치 로직 추가 — 활성 디렉토리 이관 전 의존성 설치" + ] + }, + { + "name": "Changed", + "items": [ + "확장 수동 설치 모달 3개(모듈/플러그인/템플릿) UI 통일 — TabNavigation underline, 에러 배너, 필드별 적색 테두리", + "확장 GitHub/ZIP 설치 공통 로직을 `GithubHelper`, `ZipInstallHelper`로 추출하여 3개 Service 중복 제거", + "`CoreUpdateService` GitHub 관련 protected 메서드를 `GithubHelper` 위임으로 리팩토링", + "확장 목록 PageHeader에서 새로고침 버튼 제거, 업데이트 확인 버튼으로 통일", + "코어 업데이트 `core_pending` 고정 경로 → `core_{Ymd_His}` 타임스탬프 기반 격리 디렉토리로 변경", + "확장(모듈/플러그인/템플릿) 업데이트에 `_pending/{identifier}_{timestamp}/` 스테이징 패턴 도입", + "확장 업데이트 시 스테이징 내에서 composer install 실행 (활성 디렉토리 무영향)", + "확장 GitHub 다운로드 코드를 코어와 동일한 패턴으로 통합 (인증 헤더, 폴백 체인, 타임아웃)", + "GitHub 다운로드 공용 로직을 `ExtensionManager`로 추출하여 3개 Manager 중복 제거", + "`config/app.php`에서 `preserves` 설정 키 제거 (타임스탬프 격리로 불필요)", + "모듈/플러그인 GitHub/ZIP 설치 흐름을 `temp → _pending → composer install → 활성 디렉토리` 패턴으로 통일" + ] + }, + { + "name": "Fixed", + "items": [ + "코어 업데이트 결과 모달에서 from/to 버전 파라미터가 전달되지 않는 버그 수정 — `params.params` → `params.query`", + "플러그인에 GitHub/ZIP 설치 기능이 누락되어 있던 결함 수정", + "파일 복사 시 퍼미션/소유자/소유그룹 미보존 문제 수정 — `File::copy()` → `FilePermissionHelper::copyFile()` 교체 (6개 위치)", + "Windows 환경에서 확장/코어 업데이트 후 `_pending` 하위에 빈 디렉토리가 잔존하는 문제 수정 — `cleanupStaging()`에 3단계 retry 로직 추가", + "코어 업데이트 후 vendor 교체 시 stale `packages.php`/`services.php`로 인한 500 오류 수정 — `clearAllCaches()`에 컴파일 캐시 삭제 + `package:discover` + `extension:update-autoload` 추가", + "코어 업데이트 시 `bootstrap/cache` 디렉토리가 소스에서 덮어씌워지는 문제 수정 — excludes에 `bootstrap/cache` 추가" + ] + } + ] + }, + { + "version": "7.0.0-alpha.4", + "date": "2026-03-12", + "categories": [ + { + "name": "Added", + "items": [ + "코어 업그레이드 스텝 검증용 샘플 마이그레이션 및 업그레이드 스텝 추가", + "코어 업그레이드 스텝 경로를 `database/upgrades/` → `upgrades/`로 변경", + "업그레이드 스텝 실행을 프로그레스바 별도 단계로 분리 및 터미널 피드백 추가" + ] + }, + { + "name": "Changed", + "items": [ + "`--source` 모드에서 원본 소스 디렉토리를 `_pending`으로 복제 후 작업 (원본 보호)", + "Step 8: 운영 디렉토리 `composer install` 재실행 → `_pending/vendor/` 복사로 변경 (효율화)" + ] + }, + { + "name": "Fixed", + "items": [ + "`--source` 모드에서 소스 버전 감지 시 현재 `env()` 대신 `config/app.php` default 값 파싱", + "코어 업데이트 targets에 `upgrades` 디렉토리 누락 수정" + ] + } + ] + }, + { + "version": "7.0.0-alpha.3", + "date": "2026-03-12", + "categories": [ + { + "name": "Fixed", + "items": [ + "`.gitattributes`의 `CHANGELOG.md export-ignore`가 모든 CHANGELOG 파일을 릴리스 아카이브에서 제외하던 문제 수정", + "PharData(tar ustar) 100바이트 경로 제한으로 324개 파일이 누락되어 orphan 삭제가 발생하던 버그 수정" + ] + }, + { + "name": "Removed", + "items": [ + "PharData 아카이브 추출 전략 제거 — tar ustar 형식의 100바이트 경로 제한은 근본적 해결 불가" + ] + }, + { + "name": "Added", + "items": [ + "`core:update --source=` 옵션 추가 — ZipArchive/unzip 불가 환경에서 수동 업데이트 지원", + "상대경로, 절대경로, Windows 경로 모두 지원", + "소스 디렉토리의 그누보드7 프로젝트 유효성 검증 (`config/app.php` + `version` 키)", + "업데이트 안내 모달에 수동 업데이트 가이드 섹션 추가", + "시스템 요구사항 미충족 시 `--source` 옵션 안내 메시지 추가" + ] + } + ] + }, + { + "version": "7.0.0-alpha.2", + "date": "2026-03-12", + "categories": [ + { + "name": "Fixed", + "items": [ + "코어 업데이트 확인 시 업데이트할 버전이 없으면 피드백 없던 문제 수정", + "`openModal` 호출 형식 수정 (`params.id` → `target`)", + "모달 데이터 바인딩 경로 수정 (`_local` → `$parent._local`)", + "코어 업데이트 명령어 에러 미출력 수정", + "`.env` 예제 파일에서 `G7_UPDATE_TARGETS` 하드코딩 제거" + ] + }, + { + "name": "Added", + "items": [ + "코어 업데이트 targets 확장 및 orphan 삭제 로직 추가", + "코어 업데이트 `--local` 옵션 및 설정 기반 제외/보존 추가", + "환경설정 고급 탭 코어 업데이트 설정 섹션 추가" + ] + } + ] + }, + { + "version": "7.0.0-alpha.1", + "date": "2026-03-07", + "categories": [ + { + "name": "Added", + "items": [ + "Laravel 12 기반 CMS 플랫폼 초기 구조 설계 및 구현", + "Service-Repository 패턴 기반 계층 분리 아키텍처 구축", + "CoreServiceProvider를 통한 인터페이스-구현체 바인딩 시스템", + "ResponseHelper를 통한 통일된 API 응답 형식 (success/error/paginated)", + "AdminBaseController / AuthBaseController / PublicBaseController 컨트롤러 계층 구조", + "FormRequest + Custom Rule 기반 검증 시스템", + "BaseApiResource 상속 기반 API 리소스 패턴", + "PHP 8.2+ Backed Enum 기반 상태/타입/분류 관리", + "모듈(Module) 시스템: 디렉토리 스캔 기반 자동 발견, 설치/활성화/비활성화/삭제 관리", + "플러그인(Plugin) 시스템: 모듈 의존 기반 기능 확장, 설정 UI(settings.json) 지원", + "템플릿(Template) 시스템: Admin/User 타입 분리, JSON 기반 레이아웃, 컴포넌트 레지스트리", + "ExtensionManager: 모듈/플러그인/템플릿 통합 관리 (설치, 업데이트, 삭제)", + "HookManager: Action/Filter 훅 시스템 (doAction, applyFilters, HookListenerInterface)", + "확장 업데이트 시스템: _bundled/_pending 디렉토리 구조, GitHub/로컬 업데이트 감지 및 적용", + "확장 백업/복원 시스템 (ExtensionBackupHelper)", + "확장 상태 가드 (ExtensionStatusGuard): Installing/Updating/Uninstalled 상태 관리", + "확장 권한 동기화 (ExtensionRoleSyncHelper): 설치/삭제 시 역할-권한 자동 동기화", + "확장 메뉴 동기화 (ExtensionMenuSyncHelper): 모듈 메뉴 자동 등록/해제", + "확장 오토로드 시스템: 런타임 Composer 오토로드 (composer.json 수정 불필요)", + "확장 Composer 의존성 관리 (extension:composer-install 커맨드)", + "확장 업그레이드 스텝 시스템 (UpgradeStepInterface, UpgradeContext)", + "확장 설정 시스템 (SettingsMigrator): 모듈/플러그인별 독립 설정 관리", + "확장 소유권 시스템: extension_type/extension_identifier 기반 리소스 귀속", + "확장 에셋 시스템: module.json 매니페스트 기반 JS/CSS 자동 로딩", + "확장 Changelog 시스템: ChangelogParser 헬퍼 + API 엔드포인트 + 관리 화면 인라인 표시", + "확장 빌드 시스템: Artisan 커맨드 기반 (module:build, template:build, plugin:build)", + "확장 캐시 관리: 확장별 독립 캐시 + 일괄 클리어 커맨드", + "DynamicRenderer: JSON 레이아웃 기반 React 컴포넌트 동적 렌더링", + "DataBindingEngine: `{{expression}}` 문법, Optional Chaining(`?.`), Nullish Coalescing(`??`) 지원", + "ActionDispatcher: 20+ 내장 핸들러 (navigate, apiCall, setState, openModal, closeModal, sequence, condition, replaceUrl, scrollTo, copyToClipboard, downloadFile, debounce, emit, showToast, confirm, validate, submit, reset, filter, sort 등)", + "ComponentRegistry: 컴포넌트 등록/검색/해석 시스템 (기본/집합/레이아웃 타입)", + "TranslationEngine: `$t:key` 즉시 평가 / `$t:defer:key` 지연 평가 다국어 바인딩", + "LayoutLoader: JSON 레이아웃 로딩, 캐싱, ETag 기반 조건부 요청", + "Router: SPA 라우팅, 동적 경로 파라미터(`{{route.id}}`), 쿼리스트링 관리", + "레이아웃 상속 시스템: `extends` 기반 베이스 상속 + `type: \"slot\"` 위치에 컨텐츠 삽입", + "Partial 시스템: 레이아웃 모듈화, 컴포넌트 치환 (data_sources/computed/modals/state 미지원)", + "조건부 렌더링: `if` 속성 기반 표현식 평가 (type: \"conditional\" 미지원)", + "반복 렌더링: `iteration` 설정 (source, item_var, index_var, key)", + "반응형 레이아웃: `responsive` 속성 기반 breakpoint 오버라이드 (portable/compact/wide)", + "다크 모드: Tailwind `dark:` variant 기반 자동 전환", + "classMap: 조건부 CSS 클래스 매핑 (key → variants)", + "computed: 계산된 속성 시스템 (의존성 추적, 자동 재계산)", + "모달 시스템: `modals` 섹션 + openModal/closeModal 핸들러 (`_global.modalStack` 기반)", + "init_actions: 레이아웃 초기화 시 자동 실행 액션 (루트 레이아웃 레벨)", + "Named Actions: 액션 재사용 시스템 (DRY 패턴)", + "errorHandling: 전역/데이터소스별 에러 핸들링 설정", + "scripts: 레이아웃 레벨 커스텀 스크립트 로딩", + "globalHeaders: API 호출 공통 헤더 설정 (pattern 기반 매칭)", + "blur_until_loaded: 데이터 로딩 전 블러 처리", + "lifecycle: 컴포넌트 생명주기 훅 (onMount, onUnmount)", + "slots: 컴포넌트 슬롯 시스템", + "layout_extensions: 모듈/플러그인의 동적 UI 주입 포인트", + "isolatedState: 컴포넌트 상태 격리", + "데이터소스 조건부 로딩: `if` 표현식 기반 활성화/비활성화", + "Basic 컴포넌트 (27+): Div, Button, Input, Select, Form, A, H1~H6, Span, P, Img, Label, Textarea, Table, Thead, Tbody, Tr, Th, Td, Ul, Ol, Li, Hr, Strong, Em, Small, Pre, Code, Blockquote, Nav, Header, Footer, Main, Section, Article, Aside, Figure, FigCaption", + "Composite 컴포넌트: DataGrid, CardGrid, Pagination, Modal, SearchBar, Tabs, TabPanel, Accordion, Badge, Breadcrumb, Card, Checkbox, CheckboxGroup, DatePicker, Dropdown, FileUpload, Icon, Notification, Radio, RadioGroup, RangeSlider, Rating, Select (enhanced), Sidebar, Stepper, Switch, Tag, Timeline, Toast, Tooltip, PasswordInput, DynamicFieldList, SortableList, ColorPicker, NumberInput, TreeView, DateRangePicker", + "Layout 컴포넌트: FlexLayout, GridLayout, ScrollLayout, StickyLayout, Spacer, Container, AspectRatio", + "HtmlEditor: TinyMCE 기반 HTML 에디터 컴포넌트", + "Icon 컴포넌트: Font Awesome 6.4.x Free 아이콘 지원 (Solid 1,390개 / Regular 163개 / Brands 472개)", + "Alert, EmptyState, LoadingSpinner, Skeleton, StatusBadge, CopyButton 유틸리티 컴포넌트", + "전역 상태 (`_global`): 앱 전체 공유, 페이지 이동 시 유지", + "로컬 상태 (`_local`): 레이아웃 단위 격리", + "계산된 상태 (`_computed`): 의존성 기반 자동 재계산", + "폼 자동 바인딩: Form 컴포넌트 `stateKey` 기반 자동 상태 연동", + "setState 핸들러: target(global/local), 함수형 업데이트, 배열 조작 (push/filter/map)", + "상태 구독: `G7Core.state.subscribe` 기반 반응형 업데이트", + "initGlobal: 전역 상태 초기값 선언", + "모달 스코프 상태: `$parent._local` 스냅샷 기반 데이터 전달", + "Laravel Sanctum 하이브리드 인증 (세션 + Bearer 토큰)", + "AuthManager: 싱글톤 인증 상태 관리", + "자동 토큰 갱신: 401 응답 시 자동 리프레시 후 재시도", + "OptionalSanctumMiddleware: 선택적 인증 지원 (비인증 사용자 허용)", + "로그인/로그아웃 3단계 프로토콜 (토큰 삭제 → 세션 무효화 → Auth::logout)", + "비밀번호 재설정: 이메일 인증 기반 토큰 발급 및 검증", + "회원가입: 약관 동의, 이메일 인증 지원", + "Role 기반 권한 관리 (User → Role → Permission 3계층)", + "permission 미들웨어 체인 기반 접근 제어 (FormRequest authorize() 사용 금지)", + "확장별 권한 자동 등록/해제", + "역할별 메뉴 접근 제어 (role_menus 피벗 테이블)", + "슈퍼 관리자 (superadmin) 전체 권한 자동 부여", + "계층형 메뉴 구조 (parent_id 기반)", + "역할별 메뉴 가시성 제어", + "모듈 메뉴 자동 등록 (getAdminMenus 인터페이스)", + "메뉴 순서 관리 (드래그 앤 드롭 SortableList)", + "다국어 메뉴명 지원 (JSON 배열 형식)", + "API 엔드포인트 선언적 정의 (id, endpoint, method, params)", + "loading_strategy: immediate/lazy/manual 3가지 로딩 전략", + "데이터소스 의존성: depends_on 기반 연쇄 로딩", + "폴링: poll_interval 기반 주기적 갱신", + "조건부 로딩: `if` 표현식 기반 활성화", + "transform: 응답 데이터 변환 함수", + "cache_duration: 응답 캐싱", + "대시보드: 시스템 정보, 통계 위젯, 최근 활동", + "사용자 관리: CRUD, 역할 할당, 상태 관리 (활성/비활성/차단)", + "역할 관리: CRUD, 권한 할당, 다국어 역할명", + "권한 관리: 카테고리별 권한 목록, 역할별 권한 할당", + "메뉴 관리: 계층형 메뉴 편집, 순서 변경, 역할별 가시성 설정", + "모듈 관리: 설치/활성화/비활성화/삭제, 업데이트 확인, 상세 정보 모달, changelog 표시", + "플러그인 관리: 설치/활성화/비활성화/삭제, 설정 UI, 업데이트 확인, changelog 표시", + "템플릿 관리: 설치/활성화/비활성화/삭제, 업데이트 확인, changelog 표시", + "환경설정: 사이트 기본 정보, SEO 설정, 메일 설정, 보안 설정, 탭 레이아웃", + "일정 관리: 스케줄 CRUD, 캘린더 뷰, 카테고리 분류", + "메일 템플릿 관리: DB 기반 메일 템플릿 CRUD, 변수 치환, 미리보기 기능", + "메일 발송 로그: 발송 이력 조회, 상태 추적, 상세 정보 모달", + "시스템 정보: PHP/Laravel/DB 버전, 디스크 사용량, 확장 현황 표시", + "코어 업데이트: 버전 확인, 업데이트 가이드, changelog 인라인 표시, 백업 생성", + "로그인/회원가입/비밀번호 재설정 페이지", + "마이페이지: 프로필 수정, 비밀번호 변경", + "통합 검색 기능", + "게시판 뷰: 목록/상세/작성/수정 (board 모듈 연동)", + "에러 페이지: 403, 404, 500, 503 커스텀 에러 페이지", + "점검 모드(maintenance) 페이지", + "다단계 웹 설치 마법사 (환경 체크 → DB 설정 → 관리자 생성 → 완료)", + "SSE(Server-Sent Events) 기반 실시간 설치 진행 상태 표시", + "설치 롤백 기능: 실패 시 자동 복원 (마이그레이션/시더 롤백)", + "언어 선택 지원 (한국어/영어)", + "다크 모드 지원", + "환경 요구사항 자동 검증 (PHP 버전, 확장, 디렉토리 권한)", + "게시판 관리: CRUD, 카테고리, 스킨 설정, 권한 설정", + "게시글 관리: CRUD, 검색, 정렬, 페이지네이션", + "댓글 시스템: CRUD, 대댓글 (계층형), 답글 알림", + "신고 시스템: 게시글/댓글 신고, 관리자 처리 (승인/거절)", + "첨부파일: 이미지/파일 업로드, 다운로드, 인라인 표시", + "비밀글: 작성자/관리자만 열람 가능", + "블라인드/복원: 관리자 블라인드 처리 및 복원 기능", + "카드/갤러리 레이아웃: 다양한 목록 표시 형태 지원", + "게시판 권한: 읽기/쓰기/댓글/관리 권한 분리", + "인기글/최신글 위젯", + "SEO 메타 태그 자동 생성", + "상품 관리: CRUD, 옵션(사이즈/색상), 라벨, SEO 메타, 이미지 갤러리", + "상품 카테고리: 계층형 카테고리, 순서 관리, TreeView 편집", + "브랜드 관리: CRUD, 로고, 설명", + "주문 관리: 주문 목록, 상태 변경, 상세 정보, 주문 타임라인", + "쿠폰 시스템: 정액/정률 할인, 사용 조건 (최소 금액, 특정 상품), 유효기간, 사용 횟수 제한", + "배송 정책: 무료/유료/조건부 배송, 지역별 요금 설정", + "공통 정보 관리: 배송/교환/환불 안내, 판매자 정보", + "장바구니: 추가/수량 변경/삭제, 옵션별 관리, 품절 상품 알림", + "체크아웃: 주소 입력 (다음 우편번호 연동), 배송지 저장 체크박스, 결제수단 선택, 쿠폰 적용", + "주문 완료: 주문 번호, 결제 정보 요약, 장바구니 자동 비우기", + "상품 상세 페이지: 이미지 갤러리, 옵션 선택, 수량 입력, 장바구니 담기", + "상품 목록: 필터링 (카테고리/브랜드/가격), 정렬, 페이지네이션, 카드 그리드", + "위시리스트: 찜하기/해제 기능", + "상품 검색: 키워드 검색, 카테고리 필터 연동", + "CMS 페이지: CRUD, 슬러그 기반 URL 매핑", + "페이지 버전 관리: 버전 이력 조회, 이전 버전 복원", + "첨부파일: 이미지/파일 업로드 지원", + "검색 통합: 페이지 내용 통합 검색 지원", + "SEO: 메타 태그, Open Graph 설정", + "다음 우편번호 검색 API 연동", + "주소 선택 후 폼 자동 입력", + "체크아웃 배송지 입력 연동", + "토스페이먼츠 결제 API 연동", + "카드/계좌이체/가상계좌/무통장입금 결제 지원", + "결제 확인 (confirm) 프로세스", + "결제 성공/실패 콜백 처리", + "관리자 설정 UI (API 키 관리)", + "마케팅 동의 관리: 이메일 구독, 마케팅 동의, 제3자 제공 동의", + "MarketingConsent / MarketingConsentHistory 모델 및 서비스", + "MarketingConsentListener 훅 리스너 (회원가입/프로필 연동)", + "사용자 관리 화면 레이아웃 확장 (마케팅 동의 상세/폼/프로필)", + "회원가입 폼 마케팅 동의 항목 확장", + "플러그인 설정 UI", + "역할 기반 접근 제어 (마케팅 관리자)", + "다국어 지원 (ko, en)", + "휴대폰 인증, 아이핀 인증 등 본인인증 기능 제공", + "사용자 관리 화면 레이아웃 확장 (본인인증 상세/폼)", + "역할 기반 접근 제어 (본인인증 관리자)", + "다국어 지원 (ko, en)", + "관리자 대시보드 레이아웃", + "사이드바 네비게이션: 접기/펼치기, 계층형 메뉴, 활성 상태 표시", + "상단바: 사용자 정보, 알림 벨, 다크 모드 전환 토글", + "반응형 레이아웃 (데스크톱/태블릿/모바일)", + "관리자 전용 컴포넌트: DataGrid, CardGrid, SearchBar, 필터 패널 등", + "CRUD 화면 표준 레이아웃 (목록/생성/수정/상세)", + "모달 기반 상세 보기/수정 기능", + "토스트 알림 시스템", + "확인 다이얼로그 (삭제 확인 등)", + "환경설정 탭 레이아웃", + "모듈/플러그인/템플릿 관리 화면 (설치/업데이트/상세 모달)", + "사용자/역할/권한 관리 화면", + "메뉴 관리 화면 (드래그 앤 드롭 순서 변경)", + "일정 관리 캘린더 화면", + "메일 템플릿 관리 화면", + "메일 발송 로그 화면", + "시스템 정보 화면", + "코어 업데이트 가이드/결과 모달", + "사용자 메인 페이지 레이아웃", + "헤더/푸터 공통 레이아웃 (반응형)", + "로그인/회원가입/비밀번호 재설정 화면", + "마이페이지 레이아웃", + "게시판 목록/상세/작성/수정 화면", + "상품 목록/상세 화면", + "장바구니/체크아웃/주문완료 화면", + "검색 결과 화면", + "CMS 페이지 표시 화면", + "에러 페이지 (403, 404, 500, 503)", + "점검 모드(maintenance) 전용 페이지", + "반응형 레이아웃 (데스크톱/태블릿/모바일)", + "백엔드 다국어: `__()` 함수 기반, `lang/{locale}/*.php` 파일 구조", + "프론트엔드 다국어: `$t:key` 즉시 평가 바인딩, `$t:defer:key` 지연 평가 바인딩", + "컴포넌트 다국어: `G7Core.t()` API 제공", + "모듈 다국어: 모듈별 독립 언어 파일, 네임스페이스 분리 (`__('vendor-module::key')`)", + "템플릿 다국어: partial 언어 파일 분할 (admin.json, errors.json 등)", + "DB 다국어 필드: JSON 배열 형식 (`{\"ko\": \"...\", \"en\": \"...\"}`)", + "지원 로케일: 한국어(ko), 영어(en)", + "TranslatableField 트레이트: 모델 다국어 필드 자동 해석", + "ValidLayoutStructure: JSON 레이아웃 구조 검증 Custom Rule", + "WhitelistedEndpoint: API 엔드포인트 화이트리스트 검증 Custom Rule", + "NoExternalUrls: 외부 URL 차단 검증 Custom Rule", + "ComponentExists: 컴포넌트 존재 여부 검증 Custom Rule", + "CSRF 보호: Laravel 기본 CSRF 토큰 검증", + "XSS 방지: 출력 이스케이프, HTML sanitize 처리", + "SQL Injection 방지: Eloquent ORM, 파라미터 바인딩 사용", + "Rate Limiting: API 요청 제한 미들웨어", + "DB 기반 메일 템플릿: 제목/본문 DB 관리, 변수 치환 (Blade 문법)", + "Notification + Mailable 통합 패턴: BaseNotification 상속으로 보일러플레이트 제거", + "메일 발송 로그: 발송 이력 DB 기록, 수신자/상태/발송 시간 추적", + "메일 템플릿 사용자 오버라이드 추적 (user_overrides 필드)", + "BaseNotification: 모든 알림의 베이스 클래스 (via() 보일러플레이트 제거)", + "알림 채널: 메일, 데이터베이스, 브로드캐스트 지원", + "실시간 알림: Laravel Reverb (WebSocket) 기반 Broadcasting", + "StorageInterface: 모든 파일 저장의 추상화 인터페이스 (Storage::disk() 직접 호출 금지)", + "CoreStorageDriver: 코어 스토리지 구현체", + "확장별 독립 스토리지 공간 할당", + "CoreUpdateService: GitHub API 기반 코어 버전 확인 및 업데이트 감지", + "CoreUpdateController: 업데이트 상태 확인, 가이드 표시 API 엔드포인트", + "CoreBackupHelper: 업데이트 전 코어 파일 백업 생성", + "FilePermissionHelper: 파일/디렉토리 쓰기 권한 사전 검증", + "MaintenanceModePage 미들웨어: 점검 모드 시 전용 페이지 표시", + "업데이트 가이드 모달: changelog 인라인 표시, 단계별 안내", + "MCP 서버: 20+ 디버깅 도구 제공", + "기본 도구: g7-state, g7-actions, g7-cache, g7-diagnose, g7-lifecycle, g7-network, g7-form, g7-expressions, g7-logs", + "고급 분석: g7-datasources, g7-handlers, g7-events, g7-performance, g7-conditionals, g7-websocket", + "상태 계층/스타일: g7-renders, g7-state-hierarchy, g7-context-flow, g7-styles, g7-auth, g7-tailwind, g7-layout", + "Phase 8 심화: g7-computed, g7-nested-context, g7-modal-state, g7-sequence, g7-stale-closure, g7-change-detection", + "브라우저 상태 덤프: 런타임 상태 캡처 및 MCP 서버 전송", + "UI 패널: 브라우저 내 DevTools 패널 (실시간 상태/액션/로그 확인)", + "페이지네이션: offset/limit 기반 대용량 데이터 조회 지원", + "비주얼 레이아웃 에디터: 드래그 앤 드롭 기반 레이아웃 편집", + "PropertyPanel: 컴포넌트 속성 편집 UI", + "컴포넌트 팔레트: 사용 가능한 컴포넌트 드래그 목록", + "실시간 미리보기: 편집 즉시 렌더링 결과 확인", + "레이아웃 캐싱: 파싱된 레이아웃 JSON + 상속 병합 결과 캐시", + "확장 캐싱: 모듈/플러그인/템플릿 목록 및 메타데이터 캐시", + "번역 캐싱: 언어 파일 파싱 결과 캐시", + "ETag 지원: API 응답 조건부 캐시 검증 (304 Not Modified)", + "Gzip 압축: API 응답 자동 압축", + "Debounce 액션: 연속 이벤트 디바운스 처리 핸들러", + "Lazy 로딩: 데이터소스 지연 로딩 (스크롤/이벤트 트리거)", + "순환 참조 방지 메커니즘: 레이아웃 상속 무한 루프 감지", + "코어 테이블: users, roles, permissions, role_has_permissions, role_menus, menus, settings, mail_templates, mail_send_logs, schedules, modules, plugins, templates, template_layouts, template_layout_versions", + "모든 컬럼 한국어 comment 필수 규칙 적용", + "down() 메서드 완전 롤백 구현 필수 규칙 적용", + "다국어 필드 JSON 배열 형식 표준", + "MariaDB 호환성 지원", + "boolean/enum 컬럼 값 설명 comment 포함 규칙", + "PHPUnit 11.x: 백엔드 단위 테스트 (Unit) 및 기능 테스트 (Feature)", + "Vitest: 프론트엔드 컴포넌트/템플릿 엔진 테스트", + "createLayoutTest(): 레이아웃 JSON 렌더링 테스트 유틸리티 (Vitest + jsdom)", + "mockApi(): API 응답 모킹 유틸리티 (fetch 자동 모킹)", + "트러블슈팅 회귀 테스트: 해결된 사례별 자동 검증", + "테스트 커버리지: 모델, 서비스, 컨트롤러, 컴포넌트, 레이아웃 렌더링", + "Vite 기반 프론트엔드 빌드 (코드 분할, 트리 쉐이킹)", + "Artisan 커맨드 기반 빌드 관리:", + "`core:build`: 코어 템플릿 엔진 빌드 (--full, --watch 옵션)", + "`module:build`: 모듈 프론트엔드 빌드 (--all, --watch, --active 옵션)", + "`template:build`: 템플릿 빌드 (--all, --watch, --active 옵션)", + "`plugin:build`: 플러그인 빌드 (--all, --watch, --active 옵션)", + "_bundled 디렉토리 기본 빌드, --active 옵션으로 활성 디렉토리 빌드", + "--watch 모드: 파일 감시 기반 실시간 빌드 (활성 디렉토리 자동 사용)", + "모듈 관리: module:list, module:install, module:activate, module:deactivate, module:uninstall, module:composer-install, module:cache-clear, module:seed, module:check-updates, module:update, module:build", + "플러그인 관리: plugin:list, plugin:install, plugin:activate, plugin:deactivate, plugin:uninstall, plugin:composer-install, plugin:cache-clear, plugin:seed, plugin:check-updates, plugin:update, plugin:build", + "템플릿 관리: template:list, template:install, template:activate, template:deactivate, template:uninstall, template:cache-clear, template:check-updates, template:update, template:build", + "확장 공통: extension:composer-install, extension:update-autoload", + "코어: core:build", + "AI 에이전트 개발 가이드 (AGENTS.md): 핵심 원칙, 코딩 규칙, 디버깅 프로토콜", + "규정 문서 체계 (docs/): 백엔드 14개, 프론트엔드 59개, 확장 23개, 공통 4개 (총 100개)", + "AI 에이전트 자동화 도구: 검증/분석/구현 스킬 30+개, 인덱스 자동 생성 스크립트", + "트러블슈팅 가이드: 상태 관리, 캐시, 컴포넌트, 백엔드 문제 해결 사례집", + "코드 스타일: Laravel Pint (PSR-12) 자동 적용" + ] + } + ] + } + ] + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 코어의 버전별 변경사항(CHANGELOG)을 구조화된 배열로 조회합니다. `source`(active/bundled/github)로 어느 위치의 CHANGELOG를 읽을지, `from_version`/`to_version`으로 조회 범위를 지정합니다. `core.settings.read` 권한이 필요하며, 업데이트 안내 화면에서 새 버전에 무엇이 바뀌는지 보여줄 때 사용합니다. 확장은 `core.extension.changelog_rules` 훅으로 파라미터를 확장할 수 있습니다. + + +### POST /api/admin/core-update/check + +- **라우트명**: `api.admin.core-update.check` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\CoreUpdateController@checkForUpdates` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.update` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/admin/core-update/check HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 용도/설명 | +| --- | --- | --- | +| update_available | boolean | 업데이트 가능 여부 (최신 버전이 현재 버전보다 높으면 true) | +| current_version | string | 현재 설치된 코어 버전 | +| latest_version | string | GitHub 릴리스에서 확인한 최신 버전 (조회 실패 시 현재 버전으로 대체) | +| github_url | string | 버전 확인 대상 GitHub 저장소 URL (`config('app.update.github_url')`) | + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 | + + + +**설명** GitHub 릴리스를 기준으로 코어 업데이트 가능 여부를 확인합니다. 현재 버전과 최신 버전을 비교한 결과를 반환하며, 조회에 실패하면 실패 사유·현재 버전·github_url과 함께 422를 반환합니다. `core.settings.update` 권한이 필요하고, 관리자가 업데이트 확인 버튼을 눌러 새 버전 유무를 점검하는 시나리오에 사용합니다. + + diff --git a/docs/backend/api/dashboard.md b/docs/backend/api/dashboard.md new file mode 100644 index 00000000..37fd1a3e --- /dev/null +++ b/docs/backend/api/dashboard.md @@ -0,0 +1,400 @@ +# Dashboard API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Dashboard 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/admin/dashboard/activities + +- **라우트명**: `api.admin.dashboard.activities` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\DashboardController@activities` +- **인증/권한**: `auth:sanctum` + `permission:core.dashboard.activities` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/dashboard/activities HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| type | string | `user` | 활동 분류 (log_type Enum 값 — admin: 관리자, user: 사용자, system: 시스템) | +| icon | string | `circle-info` | 아이콘 식별자 (아이콘 클래스/이름) | +| icon_color | string | `green` | 분류별 색상 (log_type Enum variant() 파생 — admin: blue, user: green, system: gray) | +| title | string | `첨부파일 다운로드 (게시물: 237)` | 제목 | +| description | string | `API 문서 샘플 사용자` | 설명 (다국어 필드는 로케일별 값 객체) | +| time | string | `4시간 전` | 상대 시각 표시 (예: "24초 전" — diffForHumans() 산물) | +| timestamp | string | `2026-07-07T10:00:47+09:00` | 활동 발생 절대 시각 (created_at 을 사용자 타임존으로 변환한 ISO 8601) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "최근 활동을 성공적으로 조회했습니다.", + "data": [ + { + "type": "admin", + "icon": "circle-info", + "icon_color": "blue", + "title": "사용자 목록 조회", + "description": "API 문서 샘플 사용자", + "time": "2분 전", + "timestamp": "2026-07-08T12:12:12+09:00" + }, + { + "type": "admin", + "icon": "circle-info", + "icon_color": "blue", + "title": "사용자 목록 조회", + "description": "API 문서 샘플 사용자", + "time": "6분 전", + "timestamp": "2026-07-08T12:08:26+09:00" + }, + { + "type": "admin", + "icon": "circle-info", + "icon_color": "blue", + "title": "사용자 목록 조회", + "description": "API 문서 샘플 사용자", + "time": "9분 전", + "timestamp": "2026-07-08T12:04:38+09:00" + }, + { + "type": "admin", + "icon": "circle-info", + "icon_color": "blue", + "title": "사용자 목록 조회", + "description": "API 문서 샘플 사용자", + "time": "43분 전", + "timestamp": "2026-07-08T11:31:03+09:00" + }, + { + "type": "admin", + "icon": "circle-info", + "icon_color": "blue", + "title": "사용자 목록 조회", + "description": "API 문서 샘플 사용자", + "time": "45분 전", + "timestamp": "2026-07-08T11:29:30+09:00" + } + ] +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.dashboard.activities`)이 없는 경우 | + + + +**설명** 관리자 대시보드에 표시할 최근 활동 내역(사용자 등록, 모듈 활성화 등)을 조회합니다. 인증(`auth:sanctum`)과 `core.dashboard.activities` 권한이 필요합니다. 각 항목은 유형·아이콘·제목·설명과 상대 시간(`time`)·절대 시각(`timestamp`)을 포함하며, 대시보드 최근 활동 카드를 렌더링할 때 사용합니다. + + +### GET /api/admin/dashboard/alerts + +- **라우트명**: `api.admin.dashboard.alerts` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\DashboardController@alerts` +- **인증/권한**: `auth:sanctum` + `permission:core.dashboard.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/dashboard/alerts HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "시스템 알림을 성공적으로 조회했습니다.", + "data": [] +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.dashboard.read`)이 없는 경우 | + + + +**설명** 시스템 업데이트·경고 등 관리자에게 알릴 시스템 알림 목록을 조회합니다. 인증(`auth:sanctum`)과 `core.dashboard.read` 권한이 필요합니다. 알릴 항목이 없으면 빈 목록을 반환하며(위 실측이 빈 상태였던 이유), 대시보드 상단 시스템 알림 영역을 렌더링할 때 사용합니다. + + +### GET /api/admin/dashboard/recent-notifications + +- **라우트명**: `api.admin.dashboard.recent-notifications` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\DashboardController@recentNotifications` +- **인증/권한**: `auth:sanctum` + `permission:core.notification-logs.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/dashboard/recent-notifications HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `677` | 기본 키 (내부 식별자) | +| type | string | `apidoc.sample.event` | 알림 유형 식별자 (notification_type — 발송을 유발한 알림 정의 키) | +| channel | string | `mail` | 발송 채널 (mail: 이메일, database: 인앱, sms 등 알림이 전달된 매체) | +| recipient | string | `API 문서 샘플 사용자` | 수신자 표시명 (recipientUser 관계의 name → recipient_name → recipient_identifier 순 폴백) | +| subject | string | `API 문서 샘플 알림` | 알림 제목 (subject 를 50자로 절삭한 값) | +| status | string | `sent` | 발송 상태 (status Enum 값 — sent: 발송 성공, failed: 발송 실패, skipped: 발송 건너뜀) | +| time | string | `19시간 전` | 상대 시각 표시 (예: "24초 전" — diffForHumans() 산물) | +| timestamp | string | `2026-07-06T18:20:23+09:00` | 발송 절대 시각 (sent_at, 없으면 created_at 을 사용자 타임존으로 변환한 ISO 8601) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "최근 알림을 성공적으로 조회했습니다.", + "data": [ + { + "id": 1, + "type": "apidoc.sample.event", + "channel": "mail", + "recipient": "API 문서 샘플 사용자", + "subject": "API 문서 샘플 알림", + "status": "sent", + "time": "2시간 전", + "timestamp": "2026-07-08T09:41:24+09:00" + } + ] +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.notification-logs.read`)이 없는 경우 | + + + +**설명** 대시보드 "최근 알림" 카드에 표시할 최근 알림 발송 이력을 조회합니다. 인증(`auth:sanctum`)과 `core.notification-logs.read` 권한이 필요합니다. 각 항목은 알림 타입·채널·수신자·제목·상태와 상대 시간(`time`)·절대 시각(`timestamp`)을 포함하며, 전체 이력 목록(notification-logs)의 요약 뷰를 대시보드에 노출할 때 사용합니다. + + +### GET /api/admin/dashboard/resources + +- **라우트명**: `api.admin.dashboard.resources` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\DashboardController@resources` +- **인증/권한**: `auth:sanctum` + `permission:core.dashboard.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/dashboard/resources HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| cpu | object | `{"percentage":6,"color":"green"}` | CPU 사용률 정보 (percentage: 0~100 사용률, color: 임계 색상 — green<50, blue 50~69, yellow 70~89, red≥90) | +| memory | object | `{"percentage":96,"used":"30.1 GB","total":"31.5 GB","colo…` | 메모리 사용량 정보 (percentage 사용률, used/total: 사용량·총량 형식화 문자열, color: 임계 색상). 수집 불가 시 percentage 0·"알 수 없음"·color gray 폴백 | +| disk | object | `{"percentage":76,"used":"360.2 GB","total":"474.7 GB","co…` | 디스크 사용량 정보 (percentage 사용률, used/total: 사용량·총량 형식화 문자열, color: 임계 색상). 수집 불가 시 percentage 0·"알 수 없음"·color gray 폴백 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "시스템 리소스 정보를 성공적으로 조회했습니다.", + "data": { + "cpu": { + "percentage": 52, + "color": "blue" + }, + "memory": { + "percentage": 86, + "used": "27 GB", + "total": "31.5 GB", + "color": "yellow" + }, + "disk": { + "percentage": 76, + "used": "362.6 GB", + "total": "474.7 GB", + "color": "yellow" + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.dashboard.read`)이 없는 경우 | + + + +**설명** 서버의 CPU·메모리·디스크 사용량을 조회합니다. 인증(`auth:sanctum`)과 `core.dashboard.read` 권한이 필요합니다. 각 항목은 사용률(`percentage`)과 상태 색상(`color`), 메모리·디스크의 경우 사용량/총량 문자열을 포함하며, 대시보드 시스템 리소스 게이지를 렌더링할 때 사용합니다. + + +### GET /api/admin/dashboard/stats + +- **라우트명**: `api.admin.dashboard.stats` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\DashboardController@stats` +- **인증/권한**: `auth:sanctum` + `permission:core.dashboard.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/dashboard/stats HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| total_users | object | `{"count":156,"change_percent":15500,"change_display":"+15…` | 전체 사용자 수 (통계 객체는 count/추이 포함) | +| installed_modules | object | `{"total":3,"active":3}` | 설치된 모듈 집계 객체 (total/active) | +| active_plugins | object | `{"total":9,"active":9}` | 활성 플러그인 집계 객체 (total/active) | +| installed_templates | object | `{"total":2,"active":2}` | 설치된 템플릿 집계 객체 (total/active) | +| language_packs | object | `{"total":20,"active":16}` | 언어팩 집계 객체 (active: 현재 활성 언어팩 수, total: 활성 + 미설치 번들 팩 수) | +| system_status | object | `{"status":"normal","label":"정상","all_services_running":true}` | 시스템 상태 객체 (status: normal 정상 / warning 경고, label: 상태 다국어 라벨, all_services_running: 전체 서비스 정상 동작 여부) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "대시보드 통계를 성공적으로 조회했습니다.", + "data": { + "total_users": { + "count": 2, + "change_percent": 0, + "change_display": "+2", + "trend": "up" + }, + "installed_modules": { + "total": 3, + "active": 3 + }, + "active_plugins": { + "total": 9, + "active": 0 + }, + "installed_templates": { + "total": 2, + "active": 0 + }, + "language_packs": { + "total": 17, + "active": 1 + }, + "system_status": { + "status": "normal", + "label": "정상", + "all_services_running": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.dashboard.read`)이 없는 경우 | + + + +**설명** 대시보드 상단 통계 카드에 표시할 집계 데이터를 조회합니다. 인증(`auth:sanctum`)과 `core.dashboard.read` 권한이 필요합니다. 총 사용자 수(증감률 포함), 설치/활성 모듈·플러그인·템플릿·언어팩 수, 시스템 상태를 객체 형태로 반환하며, 대시보드 진입 시 요약 지표를 렌더링할 때 사용합니다. + + diff --git a/docs/backend/api/extensions.md b/docs/backend/api/extensions.md new file mode 100644 index 00000000..8843fc97 --- /dev/null +++ b/docs/backend/api/extensions.md @@ -0,0 +1,166 @@ +# Extensions API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Extensions 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/admin/extensions/auto-deactivated + +- **라우트명**: `api.admin.extensions.auto-deactivated` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ExtensionRecoveryController@autoDeactivated` +- **인증/권한**: `auth:sanctum` + `permission:core.plugins.activate` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/extensions/auto-deactivated HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| items | object | `{"plugins":[],"modules":[],"templates":[]}` | 코어 비호환으로 자동 비활성화된 확장을 타입별(`plugins`/`modules`/`templates`)로 묶은 목록. 각 원소는 식별자(`identifier`), 비호환 요구 버전(`incompatible_required_version`), 비활성화 시각(`deactivated_at`)을 가지며, 사용자가 dismiss했거나 hidden(학습용 샘플) 확장은 제외됨 | +| current_core_version | string | `7.0.1` | 현재 설치된 코어 버전 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "자동 비활성화된 확장 목록입니다.", + "data": { + "items": { + "plugins": [], + "modules": [], + "templates": [] + }, + "current_core_version": "7.0.1" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.plugins.activate`)이 없는 경우 | + + + +**설명** 코어 버전 비호환으로 자동 비활성화된 확장 목록을 타입별(`plugins`/`modules`/`templates`)로 반환합니다. 각 항목에는 식별자, 비호환 요구 버전, 비활성화 시각과 함께 현재 코어 버전이 담깁니다. 사용자가 dismiss한 알림과 hidden(학습용 샘플) 확장은 결과에서 제외됩니다. `core.plugins.activate` 권한이 필요하며, 상단 배너·대시보드 카드의 데이터 소스로 사용됩니다. + + +### POST /api/admin/extensions/{type}/{identifier}/dismiss + +- **라우트명**: `api.admin.extensions.dismiss` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ExtensionRecoveryController@dismiss` +- **인증/권한**: `auth:sanctum` + `permission:core.plugins.activate` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| type | path | string | 예 | module, plugin, template | 대상 확장의 타입 (module: 모듈, plugin: 플러그인, template: 템플릿). 타입에 맞는 Repository/Manager를 해석하는 데 사용되며 그 외 값은 422 | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +POST /api/admin/extensions/{type}/{identifier}/dismiss HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.plugins.activate`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 특정 확장의 호환성 알림을 현재 사용자 기준으로 dismiss(닫기) 처리합니다. 경로의 `{type}`(module|plugin|template)과 `{identifier}`로 대상을 지정하며, 해당 확장의 자동 비활성화 알림과 재호환 알림을 함께 dismiss합니다. `core.plugins.activate` 권한이 필요합니다. dismiss는 사용자별로 저장되므로, 캐시 만료나 감지 갱신 시 재호환 상태가 바뀌면 다시 노출될 수 있습니다. + + +### POST /api/admin/extensions/{type}/{identifier}/recover + +- **라우트명**: `api.admin.extensions.recover` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ExtensionRecoveryController@recover` +- **인증/권한**: `auth:sanctum` + `permission:core.plugins.activate` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| type | path | string | 예 | module, plugin, template | 대상 확장의 타입 (module: 모듈, plugin: 플러그인, template: 템플릿). 타입에 맞는 Repository/Manager를 해석하는 데 사용되며 그 외 값은 422 | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +POST /api/admin/extensions/{type}/{identifier}/recover HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.plugins.activate`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 코어와 재호환된 확장을 원클릭으로 복구(재활성화)합니다. 경로의 `{type}`/`{identifier}`로 대상을 지정하며, 대상이 `IncompatibleCore` 사유로 자동 비활성화된 상태인지 검증한 뒤 코어 버전 재검증을 거쳐 활성화합니다. 잘못된 타입은 422, 미존재 확장은 404, hidden 확장이나 자동 비활성화가 아닌 경우는 error_code와 함께 422를 반환하고, 재검증 실패 시 글로벌 핸들러가 core_version_mismatch로 변환합니다. `core.plugins.activate` 권한이 필요합니다. + + diff --git a/docs/backend/api/identity.md b/docs/backend/api/identity.md new file mode 100644 index 00000000..adfc9fbd --- /dev/null +++ b/docs/backend/api/identity.md @@ -0,0 +1,2222 @@ +# Identity API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Identity 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/admin/identity/logs + +- **라우트명**: `api.admin.identity.logs.index` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\Identity\AdminIdentityLogController@index` +- **인증/권한**: `auth:sanctum` + `permission:core.admin.identity.logs.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| provider_id | query | string | 아니오 | max 64 | provider 식별자 | +| purpose | query | string | 아니오 | max 64 | 인증 목적 필터 (signup/password_reset/self_update/sensitive_action 또는 모듈 정의 목적) | +| status | query | string | 아니오 | — | 상태 필터 (해당 상태의 항목만 조회) | +| channel | query | string | 아니오 | max 16 | 전송 채널 필터 (email 등 — 해당 채널로 시도한 이력만) | +| origin_type | query | string | 아니오 | — | 인증 트리거 출처 유형 필터 (route/hook/policy/middleware/api/custom/system — IdentityOriginType) | +| source_type | query | string | 아니오 | — | 정책 출처 필터 (core/module/plugin/admin — 어느 확장이 인증을 요구했는지, IdentityPolicySourceType) | +| source_identifier | query | string | 아니오 | max 100 | 출처 식별자 | +| provider_ids | query | array | 아니오 | — | provider 식별자 배열 | +| purposes | query | array | 아니오 | — | 인증 목적 다중선택 필터 (여러 목적 중 하나라도 일치) | +| statuses | query | array | 아니오 | — | 상태 다중선택 필터 (여러 상태 중 하나라도 일치) | +| channels | query | array | 아니오 | — | 전송 채널 다중선택 필터 (여러 채널 중 하나라도 일치) | +| origin_types | query | array | 아니오 | — | 출처 유형 다중선택 필터 (여러 origin_type 중 하나라도 일치) | +| user_id | query | integer | 아니오 | min 1 | user 식별자 | +| target_hash | query | string | 아니오 | — | 인증 대상 해시 필터 (SHA256(email\|phone), PII 원본 대신 해시로 추적) | +| search | query | string | 아니오 | max 64 | 검색어 (지정한 검색 대상 필드에서 부분 일치) | +| search_type | query | string | 아니오 | `auto`, `user_id`, `target_hash`, `ip_address`, `policy_key` | 검색 유형 (검색 대상/방식 구분) | +| sort_by | query | string | 아니오 | `created_at`, `attempts` | 정렬 기준 필드명 | +| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) | +| date_from | query | date | 아니오 | — | 조회 기간 시작일 | +| date_to | query | date | 아니오 | — | 조회 기간 종료일 | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | + +**요청 예시** + +```http +GET /api/admin/identity/logs?provider_id=%EC%98%88%EC%8B%9C%EA%B0%92&purpose=%EC%98%88%EC%8B%9C%EA%B0%92&status=%EC%98%88%EC%8B%9C%EA%B0%92&channel=%EC%98%88%EC%8B%9C%EA%B0%92&origin_type=%EC%98%88%EC%8B%9C%EA%B0%92&source_type=%EC%98%88%EC%8B%9C%EA%B0%92&source_identifier=example-key&provider_ids=%EC%98%88%EC%8B%9C%EA%B0%92&purposes=%EC%98%88%EC%8B%9C%EA%B0%92&statuses=%EC%98%88%EC%8B%9C%EA%B0%92&channels=%EC%98%88%EC%8B%9C%EA%B0%92&origin_types=%EC%98%88%EC%8B%9C%EA%B0%92&user_id=1&target_hash=%EC%98%88%EC%8B%9C%EA%B0%92&search=%EC%98%88%EC%8B%9C%EA%B0%92&search_type=auto&sort_by=created_at&sort_order=asc&date_from=2026-01-01&date_to=2026-01-01&per_page=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | string | `e6ab6cd6-cdff-46cd-b4c2-b89dfda8745b` | 기본 키 (내부 식별자) | +| provider_id | string | `inicis` | provider 식별자 (연관 리소스 참조) | +| purpose | string | `sensitive_action` | 인증 목적 (signup/password_reset/self_update/sensitive_action 또는 모듈 정의 목적) | +| channel | string | `ipin` | 인증에 사용된 전송 채널 (email 등 코어 채널 또는 모듈 provider 자체 식별자) | +| user_id | integer | `130` | user 식별자 (연관 리소스 참조) | +| target_hash | string | `d88d36166bbeffc41eb6994e390f4715280c1…` | 인증 대상 해시 (SHA256(email\|phone) — PII 원본 저장 회피) | +| status | string | `cancelled` | 인증 시도 결과 상태 (requested/sent/processing/verified/failed/expired/cancelled/policy_violation_logged) | +| attempts | integer | `0` | 현재까지 누적된 검증 시도 횟수 | +| max_attempts | integer | `0` | 허용되는 최대 검증 시도 횟수 (초과 시 실패 처리) | +| ip_address | string | `127.0.0.1` | 요청/행위가 발생한 IP 주소 | +| user_agent | string | `Mozilla/5.0 (Windows NT 10.0; Win64; …` | 요청 클라이언트의 User-Agent 문자열 | +| origin_type | string | `api` | 인증 트리거 출처 유형 (route/hook/policy/middleware/api/custom/system — IdentityOriginType) | +| origin_identifier | string | `/api/identity/challenges` | 실제 트리거 경로/훅명 (예: PUT /api/me/password, core.user.before_update) | +| origin_policy_key | null | `null` | 정책이 인증을 강제한 경우 해당 identity_policies.key (정책 외 트리거는 null) | +| properties | null | `null` | 요청 페이로드 요약 (감사용 부가 정보, 없으면 null) | +| metadata | object | `{"mid":"INIiasTest","reqSvcCd":"03","mtxid_hash":"981546e…` | 프로바이더 내부 데이터 (코드 해시·외부 인증 식별자 등, PII 원본 미포함) | +| created_at | string | `2026-06-27 19:06:17` | 생성 일시 | +| verified_at | string | `2026-06-27 18:58:30` | verified 일시 | +| expires_at | string | `2026-06-27 19:21:17` | expires 일시 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "data": [], + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 0, + "from": null, + "to": null, + "has_more_pages": false + }, + "abilities": { + "can_purge": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 본인인증 시도 이력(성공/실패/취소/정책위반)을 관리자 화면에서 필터·검색·정렬하여 페이지네이션 조회합니다. `auth:sanctum` + `core.admin.identity.logs.read` 관리자 권한이 필요합니다. `IdentityLogService::search` 로 프로바이더·목적·상태·채널·기간 등 다중 필터를 적용하며, 응답의 `abilities.can_purge` 로 파기 권한 보유 여부를 함께 내려 UI 버튼 노출을 제어합니다. 관리자 IDV 이력 대시보드에서 특정 사용자(`user_id`)나 대상 해시(`target_hash`)로 감사 추적할 때 사용합니다. + + +### POST /api/admin/identity/logs/purge + +- **라우트명**: `api.admin.identity.logs.purge` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\Identity\AdminIdentityLogController@purge` +- **인증/권한**: `auth:sanctum` + `permission:core.admin.identity.logs.purge` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| older_than_days | body | integer | 아니오 | min 1, max 3650 | 파기 기준 보관일수 (지정 일수보다 오래된 이력만 삭제, 미지정 시 기본 180일) | + +**요청 예시** + +```http +POST /api/admin/identity/logs/purge HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "older_than_days": 1 +} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| purged_count | integer | `0` | purged 개수 (집계) | +| older_than_days | integer | `1` | 기준 경과 일수 (이 일수보다 오래된 대상 필터/집계) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "purged_count": 0, + "older_than_days": 1 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.purge`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 지정한 보관주기(`older_than_days`, 기본 180일) 를 경과한 본인인증 이력을 일괄 삭제하고 삭제된 행 수를 반환합니다. `auth:sanctum` + `core.admin.identity.logs.purge` 관리자 권한이 필요합니다. `IdentityLogService::purge` 가 실제 삭제를 수행하며 되돌릴 수 없으므로, 개인정보 보관기간 정책 준수를 위해 오래된 인증 시도 로그를 정리할 때 사용합니다. + + +### GET /api/admin/identity/messages/definitions + +- **라우트명**: `api.admin.identity.messages.definitions.index` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\Identity\AdminIdentityMessageDefinitionController@index` +- **인증/권한**: `auth:sanctum` + `permission:core.admin.identity.messages.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| search | query | string | 아니오 | max 255 | 검색어 (지정한 검색 대상 필드에서 부분 일치) | +| provider_id | query | string | 아니오 | max 64 | provider 식별자 | +| scope_type | query | string | 아니오 | — | 메시지 정의 스코프 필터 (provider_default/purpose/policy — 어느 계층 템플릿인지, IdentityMessageScopeType) | +| scope_value | query | string | 아니오 | max 120 | 스코프 값 필터 (provider_default 빈값 / purpose 키 / policy 키) | +| extension_type | query | string | 아니오 | — | 확장 유형 (core/module/plugin/template) | +| extension_identifier | query | string | 아니오 | max 100 | 확장 식별자 | +| channel | query | string | 아니오 | max 20 | 메시지 채널 필터 (mail 등 — 해당 채널 템플릿을 가진 정의만) | +| is_active | query | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | +| sort_by | query | string | 아니오 | — | 정렬 기준 필드명 | +| sort_order | query | string | 아니오 | — | 정렬 방향 (asc 오름차순 / desc 내림차순) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.identity.message_definition.filter_index_rules`). + +**요청 예시** + +```http +GET /api/admin/identity/messages/definitions?search=%EC%98%88%EC%8B%9C%EA%B0%92&provider_id=%EC%98%88%EC%8B%9C%EA%B0%92&scope_type=%EC%98%88%EC%8B%9C%EA%B0%92&scope_value=%EC%98%88%EC%8B%9C%EA%B0%92&extension_type=%EC%98%88%EC%8B%9C%EA%B0%92&extension_identifier=example-key&channel=%EC%98%88%EC%8B%9C%EA%B0%92&is_active=1&per_page=1&sort_by=%EC%98%88%EC%8B%9C%EA%B0%92&sort_order=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| number | integer | `1` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| id | integer | `1` | 기본 키 (내부 식별자) | +| provider_id | string | `g7:core.mail` | provider 식별자 (연관 리소스 참조) | +| scope_type | string | `provider_default` | 메시지 정의 스코프 (provider_default: 프로바이더 기본 / purpose: 목적별 / policy: 정책별 — IdentityMessageScopeType) | +| scope_value | string | `` | 스코프 값 (provider_default 빈 문자열 / purpose 목적 키 / policy 정책 키) | +| name | object | `{"ko":"메일 본인 확인 (기본)","en":"Mail Verification (default)",…` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| description | object | `{"ko":"특정 목적이 매칭되지 않을 때 사용되는 기본 메일 템플릿","en":"Fallback ma…` | 설명 (다국어 필드는 로케일별 값 객체) | +| channels | array | `["mail"]` | 이 정의가 지원하는 활성 채널 목록 (현재 mail, 향후 sms 등 확장) | +| variables | array | `[{"key":"code","description":"인증 코드 (text_code 흐름)"},{"ke…` | 템플릿에서 치환 가능한 변수 메타데이터 목록 (원소 key/description) | +| extension_type | string | `core` | 이 리소스를 소유한 확장의 타입 (core/module/plugin/template) | +| extension_identifier | string | `core` | 이 리소스를 소유한 확장의 식별자 | +| is_active | boolean | `true` | active 여부 | +| is_default | boolean | `true` | default 여부 | +| user_overrides | array | `["name.ja"]` | 운영자가 시드 기본값에서 수정한 필드 경로 목록 (예: name.ja — 시더 재실행 시 보존 대상) | +| templates | array | `[{"id":1,"definition_id":1,"channel":"mail","subject":{"k…` | 이 정의에 속한 채널별 하위 메시지 템플릿 목록 (원소 id/channel/subject/body 등, eager load 시에만 포함) | +| created_at | string | `2026-05-27 15:20:18` | 생성 일시 | +| updated_at | string | `2026-06-30 13:33:16` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":false}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "IDV 메시지 정의 목록을 조회했습니다.", + "data": { + "data": [ + { + "number": 1, + "id": 1, + "provider_id": "g7:core.mail", + "scope_type": "purpose", + "scope_value": "checkout_verification", + "name": { + "ko": "결제 시 본인 확인", + "en": "Checkout Verification" + }, + "description": { + "ko": "결제 진행 전 본인/성인 확인 인증 코드 메일", + "en": "Identity/adult verification code mail before checkout" + }, + "channels": [ + "mail" + ], + "variables": [ + { + "key": "code", + "description": "인증 코드 (text_code 흐름)" + }, + { + "key": "expire_minutes", + "description": "만료까지 남은 분" + }, + { + "key": "purpose_label", + "description": "인증 목적 라벨" + }, + { + "key": "app_name", + "description": "사이트명" + }, + { + "key": "site_url", + "description": "사이트 URL" + }, + { + "key": "recipient_email", + "description": "수신자 이메일" + } + ], + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "is_active": true, + "is_default": true, + "user_overrides": null, + "templates": [ + { + "id": 1, + "definition_id": 1, + "channel": "mail", + "subject": { + "ko": "[{app_name}] 결제 본인 확인 인증 코드", + "en": "[{app_name}] Checkout Verification Code" + }, + "body": { + "ko": "

결제 본인 확인

결제를 진행하기 위해 본인 확인이 필요합니다. 아래 인증 코드를 입력해 주세요.

{code}

이 코드는 {expire_minutes}분 후 만료됩니다.

본인이 결제를 진행하지 않았다면 이 메일을 무시하고 즉시 비밀번호를 변경해 주세요.

감사합니다,
{app_name}

", + "en": "

Checkout Verification

Please enter the code below to proceed with payment.

{code}

This code will expire in {expire_minutes} minutes.

If you did not initiate this payment, please ignore this email and change your password immediately.

Thank you,
{app_name}

" + }, + "is_active": true, + "is_default": true, + "user_overrides": null, + "updated_by": null, + "created_at": "2026-07-08 10:43:32", + "updated_at": "2026-07-08 10:43:32", + "abilities": { + "can_update": true, + "can_delete": true + } + } + ], + "created_at": "2026-07-08 10:43:32", + "updated_at": "2026-07-08 10:43:32", + "abilities": { + "can_update": true, + "can_delete": false + } + } + ], + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 1, + "from": 1, + "to": 1, + "has_more_pages": false + }, + "abilities": { + "can_create": true, + "can_update": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 본인인증 알림 메시지 정의(프로바이더별·목적별·정책별 메일/SMS 템플릿 묶음) 목록을 필터·검색하여 페이지네이션 조회합니다. `auth:sanctum` + `core.admin.identity.messages.read` 관리자 권한이 필요합니다. 확장이 `core.identity.message_definition.filter_index_rules` 필터 훅으로 추가 검색 파라미터를 등록할 수 있습니다. 응답 각 항목의 `user_overrides` 로 운영자가 시드 기본값에서 수정한 필드를, `templates` 로 채널별 하위 템플릿을 함께 내려 관리자 메시지 설정 화면을 구성할 때 사용합니다. + + +### POST /api/admin/identity/messages/definitions + +- **라우트명**: `api.admin.identity.messages.definitions.store` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\Identity\AdminIdentityMessageDefinitionController@store` +- **인증/권한**: `auth:sanctum` + `permission:core.admin.identity.messages.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| provider_id | body | string | 예 | max 64 | provider 식별자 | +| scope_type | body | string | 예 | — | 메시지 정의 스코프 (관리자 생성은 policy 만 허용 — provider_default/purpose 는 시드 영역) | +| scope_value | body | string | 예 | max 120 | 스코프 값 (source_type='admin' 인 IdentityPolicy.key 와 일치해야 함) | +| name | body | array | 예 | — | 대상의 이름/명칭 | +| description | body | array | 아니오 | — | 설명 | +| channels | body | array | 예 | min 1 | 지원 채널 목록 (최소 1개, 현재 mail 만 허용) | +| variables | body | array | 아니오 | — | 템플릿 치환 변수 메타데이터 목록 (원소 key/description, key 는 영문 식별자) | +| templates | body | array | 예 | min 1 | 채널별 하위 템플릿 배열 (최소 1개, 원소 channel/subject/body 다국어 배열) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.identity.message_definition.filter_store_rules`). + +**요청 예시** + +```http +POST /api/admin/identity/messages/definitions HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "provider_id": "예시값", + "scope_type": "예시값", + "scope_value": "예시값", + "name": [ + "예시 이름" + ], + "description": [ + "예시 내용입니다." + ], + "channels": [ + "예시값" + ], + "variables": [ + "예시값" + ], + "templates": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 운영자가 정책(policy) 매핑용 메시지 정의를 신규 생성합니다. `auth:sanctum` + `core.admin.identity.messages.update` 관리자 권한이 필요합니다. `IdentityMessageDefinitionService::createAdminDefinition` 이 처리하며 `scope_type='policy'` + `scope_value` 가 admin policy.key 와 매칭되는 경우만 허용됩니다(FormRequest 검증). `channels`·`templates` 를 최소 1개 이상 포함해야 하며, 확장은 `core.identity.message_definition.filter_store_rules` 필터 훅으로 검증 규칙을 확장할 수 있습니다. 특정 인증 정책에 전용 메일/SMS 문구를 붙이고자 할 때 사용하며 성공 시 201 로 응답합니다. + + +### DELETE /api/admin/identity/messages/definitions/{definition} + +- **라우트명**: `api.admin.identity.messages.definitions.destroy` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\Identity\AdminIdentityMessageDefinitionController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:core.admin.identity.messages.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| definition | path | string | 예 | — | 대상 definition의 식별자 | + +**요청 예시** + +```http +DELETE /api/admin/identity/messages/definitions/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 운영자가 추가한 메시지 정의를 삭제합니다. `auth:sanctum` + `core.admin.identity.messages.update` 관리자 권한이 필요합니다. 시드로 제공되는 `is_default=true` 정의는 삭제가 거부되어 403 을 반환하며(선언형 보호), 삭제 시 FK cascade 로 자식 템플릿이 함께 제거됩니다. 잘못 만들었거나 더 이상 쓰지 않는 정책 전용 메시지 정의를 정리할 때 사용합니다. + + +### GET /api/admin/identity/messages/definitions/{definition} + +- **라우트명**: `api.admin.identity.messages.definitions.show` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\Identity\AdminIdentityMessageDefinitionController@show` +- **인증/권한**: `auth:sanctum` + `permission:core.admin.identity.messages.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| definition | path | string | 예 | — | 대상 definition의 식별자 | + +**요청 예시** + +```http +GET /api/admin/identity/messages/definitions/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| provider_id | string | `g7:core.mail` | IDV 프로바이더 ID (예: g7:core.mail, kcp, portone) | +| scope_type | string | `provider_default` | 메시지 정의 스코프 (provider_default\|purpose\|policy) — App\Enums\IdentityMessageScopeType enum | +| scope_value | string | `` | 범위 값: provider_default 빈 문자열 / purpose 키 / policy 키 | +| name | object | `{"ko":"메일 본인 확인 (기본)","en":"Mail Verification (default)",…` | 다국어 표시명 ({"ko":"...", "en":"..."}) | +| description | object | `{"ko":"특정 목적이 매칭되지 않을 때 사용되는 기본 메일 템플릿","en":"Fallback ma…` | 다국어 설명 | +| channels | array | `["mail"]` | 활성 채널 (현재 ["mail"], 향후 sms 등 확장) | +| variables | array | `[{"key":"code","description":"인증 코드 (text_code 흐름)"},{"ke…` | 사용 가능 변수 메타데이터 ([{key, description}]) | +| extension_type | string | `core` | 확장 타입: core, module, plugin | +| extension_identifier | string | `core` | 확장 식별자 | +| is_active | boolean | `true` | active 여부 | +| is_default | boolean | `true` | default 여부 | +| user_overrides | array | `["name.ja"]` | 운영자가 수정한 필드명 목록 (예: ["name","is_active"]) | +| templates | array | `[{"id":1,"definition_id":1,"channel":"mail","subject":{"k…` | 이 정의에 속한 채널별 하위 메시지 템플릿 목록 (원소 id/channel/subject/body 등) | +| created_at | string | `2026-05-27 15:20:18` | 생성 일시 | +| updated_at | string | `2026-06-30 13:33:16` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":false}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "IDV 메시지 정의 상세를 조회했습니다.", + "data": { + "id": 1, + "provider_id": "g7:core.mail", + "scope_type": "purpose", + "scope_value": "checkout_verification", + "name": { + "ko": "결제 시 본인 확인", + "en": "Checkout Verification" + }, + "description": { + "ko": "결제 진행 전 본인/성인 확인 인증 코드 메일", + "en": "Identity/adult verification code mail before checkout" + }, + "channels": [ + "mail" + ], + "variables": [ + { + "key": "code", + "description": "인증 코드 (text_code 흐름)" + }, + { + "key": "expire_minutes", + "description": "만료까지 남은 분" + }, + { + "key": "purpose_label", + "description": "인증 목적 라벨" + }, + { + "key": "app_name", + "description": "사이트명" + }, + { + "key": "site_url", + "description": "사이트 URL" + }, + { + "key": "recipient_email", + "description": "수신자 이메일" + } + ], + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "is_active": true, + "is_default": true, + "user_overrides": null, + "templates": [ + { + "id": 1, + "definition_id": 1, + "channel": "mail", + "subject": { + "ko": "[{app_name}] 결제 본인 확인 인증 코드", + "en": "[{app_name}] Checkout Verification Code" + }, + "body": { + "ko": "

결제 본인 확인

결제를 진행하기 위해 본인 확인이 필요합니다. 아래 인증 코드를 입력해 주세요.

{code}

이 코드는 {expire_minutes}분 후 만료됩니다.

본인이 결제를 진행하지 않았다면 이 메일을 무시하고 즉시 비밀번호를 변경해 주세요.

감사합니다,
{app_name}

", + "en": "

Checkout Verification

Please enter the code below to proceed with payment.

{code}

This code will expire in {expire_minutes} minutes.

If you did not initiate this payment, please ignore this email and change your password immediately.

Thank you,
{app_name}

" + }, + "is_active": true, + "is_default": true, + "user_overrides": null, + "updated_by": null, + "created_at": "2026-07-08 10:43:32", + "updated_at": "2026-07-08 10:43:32", + "abilities": { + "can_update": true, + "can_delete": true + } + } + ], + "created_at": "2026-07-08 10:43:32", + "updated_at": "2026-07-08 10:43:32", + "abilities": { + "can_update": true, + "can_delete": false + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 단일 메시지 정의의 상세 정보를 조회합니다(하위 채널 템플릿 eager load 포함). `auth:sanctum` + `core.admin.identity.messages.read` 관리자 권한이 필요합니다. 관리자 편집 모달을 열 때 해당 정의의 다국어 이름/설명·활성 채널·사용 가능 변수·`user_overrides`·`templates` 전체를 로드하기 위해 사용합니다. + + +### PATCH /api/admin/identity/messages/definitions/{definition} + +- **라우트명**: `api.admin.identity.messages.definitions.update` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\Identity\AdminIdentityMessageDefinitionController@update` +- **인증/권한**: `auth:sanctum` + `permission:core.admin.identity.messages.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| definition | path | string | 예 | — | 대상 definition의 식별자 | +| name | body | array | 아니오 | — | 대상의 이름/명칭 | +| description | body | array | 아니오 | — | 설명 | +| channels | body | array | 아니오 | min 1 | 지원 채널 목록 (최소 1개 — 이 정의가 발송할 채널 조정) | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.identity.message_definition.filter_update_rules`). + +**요청 예시** + +```http +PATCH /api/admin/identity/messages/definitions/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": [ + "예시 이름" + ], + "description": [ + "예시 내용입니다." + ], + "channels": [ + "예시값" + ], + "is_active": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 메시지 정의의 편집 가능 속성(`name`, `description`, `channels`, `is_active`) 을 수정합니다. `auth:sanctum` + `core.admin.identity.messages.update` 관리자 권한이 필요합니다. `IdentityMessageDefinitionService::updateDefinition` 이 처리하며 수정된 필드는 `user_overrides` 에 기록되어 시더 재실행 시에도 보존됩니다. 확장은 `core.identity.message_definition.filter_update_rules` 필터 훅으로 검증 규칙을 확장할 수 있습니다. 시드 정의의 표시명이나 활성 채널을 운영 상황에 맞게 조정할 때 사용합니다. + + +### POST /api/admin/identity/messages/definitions/{definition}/reset + +- **라우트명**: `api.admin.identity.messages.definitions.reset` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\Identity\AdminIdentityMessageDefinitionController@reset` +- **인증/권한**: `auth:sanctum` + `permission:core.admin.identity.messages.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| definition | path | string | 예 | — | 대상 definition의 식별자 | + +**요청 예시** + +```http +POST /api/admin/identity/messages/definitions/1/reset HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| provider_id | string | `g7:core.mail` | provider 식별자 (연관 리소스 참조) | +| scope_type | string | `purpose` | 메시지 정의 스코프 (provider_default\|purpose\|policy) — App\Enums\IdentityMessageScopeType enum | +| scope_value | string | `checkout_verification` | 범위 값: provider_default 빈 문자열 / purpose 키 / policy 키 | +| name | object | `{"ko":"결제 시 본인 확인","en":"Checkout Verification"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| description | object | `{"ko":"결제 진행 전 본인\/성인 확인 인증 코드 메일","en":"Identity\/adult …` | 설명 (다국어 필드는 로케일별 값 객체) | +| channels | array | `["mail"]` | 활성 채널 (현재 ["mail"], 향후 sms 등 확장) | +| variables | array | `[{"key":"code","description":"인증 코드 (text_code 흐름)"},{"ke…` | 사용 가능 변수 메타데이터 ([{key, description}]) | +| extension_type | string | `module` | 이 리소스를 소유한 확장의 타입 (core/module/plugin/template) | +| extension_identifier | string | `sirsoft-ecommerce` | 이 리소스를 소유한 확장의 식별자 | +| is_active | boolean | `true` | active 여부 | +| is_default | boolean | `true` | default 여부 | +| user_overrides | null | `null` | 운영자가 수정한 필드명 목록 (예: ["name","is_active"]) | +| templates | array | `[{"id":1,"definition_id":1,"channel":"mail","subject":{"k…` | 템플릿 목록 (각 원소 identifier/name 등 — 템플릿 관계 파생) | +| created_at | string | `2026-07-08 10:43:32` | 생성 일시 | +| updated_at | string | `2026-07-08 10:43:32` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":false}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "IDV 메시지 정의가 기본값으로 복원되었습니다.", + "data": { + "id": 1, + "provider_id": "g7:core.mail", + "scope_type": "purpose", + "scope_value": "checkout_verification", + "name": { + "ko": "결제 시 본인 확인", + "en": "Checkout Verification" + }, + "description": { + "ko": "결제 진행 전 본인/성인 확인 인증 코드 메일", + "en": "Identity/adult verification code mail before checkout" + }, + "channels": [ + "mail" + ], + "variables": [ + { + "key": "code", + "description": "인증 코드 (text_code 흐름)" + }, + { + "key": "expire_minutes", + "description": "만료까지 남은 분" + }, + { + "key": "purpose_label", + "description": "인증 목적 라벨" + }, + { + "key": "app_name", + "description": "사이트명" + }, + { + "key": "site_url", + "description": "사이트 URL" + }, + { + "key": "recipient_email", + "description": "수신자 이메일" + } + ], + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "is_active": true, + "is_default": true, + "user_overrides": null, + "templates": [ + { + "id": 1, + "definition_id": 1, + "channel": "mail", + "subject": { + "ko": "[{app_name}] 결제 본인 확인 인증 코드", + "en": "[{app_name}] Checkout Verification Code" + }, + "body": { + "ko": "

결제 본인 확인

결제를 진행하기 위해 본인 확인이 필요합니다. 아래 인증 코드를 입력해 주세요.

{code}

이 코드는 {expire_minutes}분 후 만료됩니다.

본인이 결제를 진행하지 않았다면 이 메일을 무시하고 즉시 비밀번호를 변경해 주세요.

감사합니다,
{app_name}

", + "en": "

Checkout Verification

Please enter the code below to proceed with payment.

{code}

This code will expire in {expire_minutes} minutes.

If you did not initiate this payment, please ignore this email and change your password immediately.

Thank you,
{app_name}

" + }, + "is_active": true, + "is_default": true, + "user_overrides": null, + "updated_by": null, + "created_at": "2026-07-08 10:43:32", + "updated_at": "2026-07-08 10:43:32", + "abilities": { + "can_update": true, + "can_delete": true + } + } + ], + "created_at": "2026-07-08 10:43:32", + "updated_at": "2026-07-08 10:43:32", + "abilities": { + "can_update": true, + "can_delete": false + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 메시지 정의에 속한 모든 채널 템플릿을 시더 기본값으로 일괄 복원하고 정의를 default 상태로 되돌립니다. `auth:sanctum` + `core.admin.identity.messages.update` 관리자 권한이 필요합니다. 각 하위 템플릿에 대해 `IdentityMessageTemplateService::resetToDefault` 를 호출한 뒤 `markAsDefault` 로 정의를 표시하므로, 운영자가 수정한 문구를 한 번에 원상복구할 때 사용합니다. + + +### PATCH /api/admin/identity/messages/definitions/{definition}/toggle-active + +- **라우트명**: `api.admin.identity.messages.definitions.toggle-active` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\Identity\AdminIdentityMessageDefinitionController@toggleActive` +- **인증/권한**: `auth:sanctum` + `permission:core.admin.identity.messages.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| definition | path | string | 예 | — | 대상 definition의 식별자 | + +**요청 예시** + +```http +PATCH /api/admin/identity/messages/definitions/1/toggle-active HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| provider_id | string | `g7:core.mail` | provider 식별자 (연관 리소스 참조) | +| scope_type | string | `purpose` | 메시지 정의 스코프 (provider_default\|purpose\|policy) — App\Enums\IdentityMessageScopeType enum | +| scope_value | string | `checkout_verification` | 범위 값: provider_default 빈 문자열 / purpose 키 / policy 키 | +| name | object | `{"ko":"결제 시 본인 확인","en":"Checkout Verification"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| description | object | `{"ko":"결제 진행 전 본인\/성인 확인 인증 코드 메일","en":"Identity\/adult …` | 설명 (다국어 필드는 로케일별 값 객체) | +| channels | array | `["mail"]` | 활성 채널 (현재 ["mail"], 향후 sms 등 확장) | +| variables | array | `[{"key":"code","description":"인증 코드 (text_code 흐름)"},{"ke…` | 사용 가능 변수 메타데이터 ([{key, description}]) | +| extension_type | string | `module` | 이 리소스를 소유한 확장의 타입 (core/module/plugin/template) | +| extension_identifier | string | `sirsoft-ecommerce` | 이 리소스를 소유한 확장의 식별자 | +| is_active | boolean | `false` | active 여부 | +| is_default | boolean | `true` | default 여부 | +| user_overrides | array | `["is_active"]` | 운영자가 수정한 필드명 목록 (예: ["name","is_active"]) | +| templates | null | `null` | 템플릿 목록 (각 원소 identifier/name 등 — 템플릿 관계 파생) | +| created_at | string | `2026-07-08 10:43:32` | 생성 일시 | +| updated_at | string | `2026-07-08 12:14:36` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":false}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "IDV 메시지 정의 활성 상태가 변경되었습니다.", + "data": { + "id": 1, + "provider_id": "g7:core.mail", + "scope_type": "purpose", + "scope_value": "checkout_verification", + "name": { + "ko": "결제 시 본인 확인", + "en": "Checkout Verification" + }, + "description": { + "ko": "결제 진행 전 본인/성인 확인 인증 코드 메일", + "en": "Identity/adult verification code mail before checkout" + }, + "channels": [ + "mail" + ], + "variables": [ + { + "key": "code", + "description": "인증 코드 (text_code 흐름)" + }, + { + "key": "expire_minutes", + "description": "만료까지 남은 분" + }, + { + "key": "purpose_label", + "description": "인증 목적 라벨" + }, + { + "key": "app_name", + "description": "사이트명" + }, + { + "key": "site_url", + "description": "사이트 URL" + }, + { + "key": "recipient_email", + "description": "수신자 이메일" + } + ], + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "is_active": false, + "is_default": true, + "user_overrides": [ + "is_active" + ], + "templates": null, + "created_at": "2026-07-08 10:43:32", + "updated_at": "2026-07-08 12:14:36", + "abilities": { + "can_update": true, + "can_delete": false + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 메시지 정의의 활성/비활성 상태를 토글합니다. `auth:sanctum` + `core.admin.identity.messages.update` 관리자 권한이 필요합니다. `IdentityMessageDefinitionService::toggleActive` 가 현재 `is_active` 값을 반전시키며, 특정 목적/정책용 인증 메시지 발송을 임시로 끄거나 다시 켤 때 사용합니다. + + +### POST /api/admin/identity/messages/templates/preview + +- **라우트명**: `api.admin.identity.messages.templates.preview` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\Identity\AdminIdentityMessageTemplateController@preview` +- **인증/권한**: `auth:sanctum` + `permission:core.admin.identity.messages.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| template_id | body | integer | 예 | — | template 식별자 | +| data | body | array | 아니오 | — | 데이터 페이로드 | +| locale | body | string | 아니오 | max 10 | 로케일 코드 (표시 언어/지역) | + +**요청 예시** + +```http +POST /api/admin/identity/messages/templates/preview HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "template_id": 1, + "data": [ + "예시값" + ], + "locale": "ko" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 지정한 템플릿(`template_id`) 의 제목/본문에 변수(`data`) 를 치환한 결과를 지정 로케일(`locale`) 로 렌더링해 미리보기를 반환합니다. `auth:sanctum` + `core.admin.identity.messages.read` 관리자 권한이 필요합니다. `IdentityMessageTemplateService::getPreview` 가 실제 발송 없이 렌더 결과만 생성하므로, 운영자가 편집한 문구가 실제 메일/SMS 에서 어떻게 보일지 저장 전에 확인할 때 사용합니다. + + +### PATCH /api/admin/identity/messages/templates/{template} + +- **라우트명**: `api.admin.identity.messages.templates.update` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\Identity\AdminIdentityMessageTemplateController@update` +- **인증/권한**: `auth:sanctum` + `permission:core.admin.identity.messages.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| template | path | string | 예 | — | 대상 template의 식별자 | +| subject | body | array | 아니오 | — | 제목 | +| body | body | array | 예 | — | 본문 | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.identity.message_template.filter_update_rules`). + +**요청 예시** + +```http +PATCH /api/admin/identity/messages/templates/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "subject": [ + "예시값" + ], + "body": [ + "예시값" + ], + "is_active": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 개별 채널 메시지 템플릿의 제목(`subject`)·본문(`body`)·활성 여부(`is_active`) 를 수정합니다. `auth:sanctum` + `core.admin.identity.messages.update` 관리자 권한이 필요합니다. `body` 는 필수이며 다국어 배열로 전달합니다. `IdentityMessageTemplateService::updateTemplate` 이 처리하고 확장은 `core.identity.message_template.filter_update_rules` 필터 훅으로 검증을 확장할 수 있습니다. 인증 메일/SMS 의 실제 발송 문구를 편집할 때 사용합니다. + + +### POST /api/admin/identity/messages/templates/{template}/reset + +- **라우트명**: `api.admin.identity.messages.templates.reset` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\Identity\AdminIdentityMessageTemplateController@reset` +- **인증/권한**: `auth:sanctum` + `permission:core.admin.identity.messages.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| template | path | string | 예 | — | 대상 template의 식별자 | + +**요청 예시** + +```http +POST /api/admin/identity/messages/templates/1/reset HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| definition_id | integer | `1` | definition 식별자 (연관 리소스 참조) | +| channel | string | `mail` | 메시지 템플릿 채널 (mail 현재 / sms 등 향후) — IdentityVerificationChannel 과는 별개의 도메인 분류 | +| subject | object | `{"ko":"[{app_name}] 결제 본인 확인 인증 코드","en":"[{app_name}] Ch…` | 다국어 제목 ({"ko":"...", "en":"..."}) — mail 채널에서만 의미 | +| body | object | `{"ko":"

결제 본인 확인<\/h1>

결제를 진행하기 위해 본인 확인이 필요합니다. 아래 …` | 다국어 본문 ({"ko":"...", "en":"..."}) | +| is_active | boolean | `true` | active 여부 | +| is_default | boolean | `true` | default 여부 | +| user_overrides | null | `null` | 운영자가 수정한 필드명 목록 (예: ["subject","body","is_active"]) | +| updated_by | null | `null` | 최종 수정한 사용자 정보 (uuid/name — updated_by 관계 파생, 없으면 null) | +| created_at | string | `2026-07-08 10:43:32` | 생성 일시 | +| updated_at | string | `2026-07-08 10:43:32` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "IDV 메시지 템플릿이 기본값으로 복원되었습니다.", + "data": { + "id": 1, + "definition_id": 1, + "channel": "mail", + "subject": { + "ko": "[{app_name}] 결제 본인 확인 인증 코드", + "en": "[{app_name}] Checkout Verification Code" + }, + "body": { + "ko": "

결제 본인 확인

결제를 진행하기 위해 본인 확인이 필요합니다. 아래 인증 코드를 입력해 주세요.

{code}

이 코드는 {expire_minutes}분 후 만료됩니다.

본인이 결제를 진행하지 않았다면 이 메일을 무시하고 즉시 비밀번호를 변경해 주세요.

감사합니다,
{app_name}

", + "en": "

Checkout Verification

Please enter the code below to proceed with payment.

{code}

This code will expire in {expire_minutes} minutes.

If you did not initiate this payment, please ignore this email and change your password immediately.

Thank you,
{app_name}

" + }, + "is_active": true, + "is_default": true, + "user_overrides": null, + "updated_by": null, + "created_at": "2026-07-08 10:43:32", + "updated_at": "2026-07-08 10:43:32", + "abilities": { + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 개별 메시지 템플릿을 시더 기본값으로 복원합니다. `auth:sanctum` + `core.admin.identity.messages.update` 관리자 권한이 필요합니다. `IdentityMessageTemplateService::resetToDefault` 가 운영자 수정 내용을 폐기하고 최초 제공 문구로 되돌리므로, 특정 채널 템플릿 하나만 원상복구할 때 사용합니다(정의 전체 복원은 definition reset 사용). + + +### PATCH /api/admin/identity/messages/templates/{template}/toggle-active + +- **라우트명**: `api.admin.identity.messages.templates.toggle-active` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\Identity\AdminIdentityMessageTemplateController@toggleActive` +- **인증/권한**: `auth:sanctum` + `permission:core.admin.identity.messages.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| template | path | string | 예 | — | 대상 template의 식별자 | + +**요청 예시** + +```http +PATCH /api/admin/identity/messages/templates/1/toggle-active HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| definition_id | integer | `1` | definition 식별자 (연관 리소스 참조) | +| channel | string | `mail` | 메시지 템플릿 채널 (mail 현재 / sms 등 향후) — IdentityVerificationChannel 과는 별개의 도메인 분류 | +| subject | object | `{"ko":"[{app_name}] 결제 본인 확인 인증 코드","en":"[{app_name}] Ch…` | 다국어 제목 ({"ko":"...", "en":"..."}) — mail 채널에서만 의미 | +| body | object | `{"ko":"

결제 본인 확인<\/h1>

결제를 진행하기 위해 본인 확인이 필요합니다. 아래 …` | 다국어 본문 ({"ko":"...", "en":"..."}) | +| is_active | boolean | `false` | active 여부 | +| is_default | boolean | `true` | default 여부 | +| user_overrides | array | `["is_active"]` | 운영자가 수정한 필드명 목록 (예: ["subject","body","is_active"]) | +| updated_by | null | `null` | 최종 수정한 사용자 정보 (uuid/name — updated_by 관계 파생, 없으면 null) | +| created_at | string | `2026-07-08 10:43:32` | 생성 일시 | +| updated_at | string | `2026-07-08 12:14:36` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "IDV 메시지 템플릿 활성 상태가 변경되었습니다.", + "data": { + "id": 1, + "definition_id": 1, + "channel": "mail", + "subject": { + "ko": "[{app_name}] 결제 본인 확인 인증 코드", + "en": "[{app_name}] Checkout Verification Code" + }, + "body": { + "ko": "

결제 본인 확인

결제를 진행하기 위해 본인 확인이 필요합니다. 아래 인증 코드를 입력해 주세요.

{code}

이 코드는 {expire_minutes}분 후 만료됩니다.

본인이 결제를 진행하지 않았다면 이 메일을 무시하고 즉시 비밀번호를 변경해 주세요.

감사합니다,
{app_name}

", + "en": "

Checkout Verification

Please enter the code below to proceed with payment.

{code}

This code will expire in {expire_minutes} minutes.

If you did not initiate this payment, please ignore this email and change your password immediately.

Thank you,
{app_name}

" + }, + "is_active": false, + "is_default": true, + "user_overrides": [ + "is_active" + ], + "updated_by": null, + "created_at": "2026-07-08 10:43:32", + "updated_at": "2026-07-08 12:14:36", + "abilities": { + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 개별 메시지 템플릿의 활성/비활성 상태를 토글합니다. `auth:sanctum` + `core.admin.identity.messages.update` 관리자 권한이 필요합니다. `IdentityMessageTemplateService::toggleActive` 가 현재 `is_active` 값을 반전시키며, 특정 채널(예: 메일) 발송만 임시로 중단하거나 재개할 때 사용합니다. + + +### GET /api/admin/identity/policies + +- **라우트명**: `api.admin.identity.policies.index` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\Identity\AdminIdentityPolicyController@index` +- **인증/권한**: `auth:sanctum` + `permission:core.admin.identity.policies.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| scope | query | string | 아니오 | — | 조회 범위 한정 키 | +| purpose | query | string | 아니오 | max 64 | 인증 목적 필터 (해당 목적을 요구하는 정책만 조회) | +| source_type | query | string | 아니오 | — | 정책 출처 필터 (core/module/plugin/admin — 선언형 vs 운영자 정책 구분, IdentityPolicySourceType) | +| source_identifier | query | string | 아니오 | max 100 | 출처 식별자 | +| applies_to | query | string | 아니오 | — | 적용 대상 사용자 필터 (self: 일반 사용자 / admin: 관리자 / both: 모두, IdentityPolicyAppliesTo) | +| fail_mode | query | string | 아니오 | — | 실패 시 동작 필터 (block: 428 차단 / log_only: 감사 로그만, IdentityPolicyFailMode) | +| enabled | query | boolean | 아니오 | — | 사용 여부 | +| search | query | string | 아니오 | max 255 | 검색어 (지정한 검색 대상 필드에서 부분 일치) | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | + +**요청 예시** + +```http +GET /api/admin/identity/policies?scope=%EC%98%88%EC%8B%9C%EA%B0%92&purpose=%EC%98%88%EC%8B%9C%EA%B0%92&source_type=%EC%98%88%EC%8B%9C%EA%B0%92&source_identifier=example-key&applies_to=%EC%98%88%EC%8B%9C%EA%B0%92&fail_mode=%EC%98%88%EC%8B%9C%EA%B0%92&enabled=1&search=%EC%98%88%EC%8B%9C%EA%B0%92&per_page=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `25` | 기본 키 (내부 식별자) | +| key | string | `test` | 정책 식별자 (고유, 예: core.profile.password_change) | +| scope | string | `route` | 정책 적용 범위 (route: 라우트 패턴 / hook: Service 훅 / custom: 모듈 커스텀 키, IdentityPolicyScope) | +| target | string | `sdfsfsf` | 매칭 대상 (scope 에 따라 라우트명/URI 패턴, 훅 이름, 또는 custom key) | +| purpose | string | `inicis.adult_verification` | 이 정책이 요구하는 인증 목적 | +| provider_id | string | `inicis` | provider 식별자 (연관 리소스 참조) | +| grace_minutes | integer | `0` | 재인증 유예 시간(분) — 최근 N분 이내 동일 목적 인증 성공 시 재인증 생략 (0=매번 요구) | +| enabled | boolean | `true` | 정책 사용 여부 (false 시 인증 미강제) | +| priority | integer | `100` | 정책 우선순위 (같은 대상에 여러 정책 매칭 시 작을수록 우선) | +| conditions | array | `[]` | 추가 매칭 조건 (역할/HTTP 메서드/파라미터 매칭 조건 JSON, 없으면 빈 배열) | +| source_type | string | `admin` | 정책 출처 (core/module/plugin: 선언형 / admin: 운영자 직접 등록, IdentityPolicySourceType) | +| source_identifier | string | `sirsoft-board` | 출처 식별자 (선언형 정책의 소유 확장 identifier) | +| applies_to | string | `both` | 적용 대상 사용자 (self: 일반 사용자 / admin: 관리자 / both: 모두, IdentityPolicyAppliesTo) | +| fail_mode | string | `block` | 실패 시 동작 (block: HTTP 428 차단 / log_only: 감사 로그만 남기고 통과, IdentityPolicyFailMode) | +| user_overrides | array | `[]` | 운영자가 선언 기본값에서 재정의한 필드 목록 (선언형 정책만 의미, 시더 재실행 시 보존) | +| created_at | string | `2026-06-26 16:33:04` | 생성 일시 | +| updated_at | string | `2026-06-26 16:33:04` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "data": [ + { + "id": 12, + "key": "sirsoft-board.post.user_create", + "scope": "hook", + "target": "sirsoft-board.post.before_create", + "purpose": "sensitive_action", + "provider_id": null, + "grace_minutes": 0, + "enabled": false, + "priority": 100, + "conditions": null, + "source_type": "module", + "source_identifier": "sirsoft-board", + "applies_to": "self", + "fail_mode": "block", + "user_overrides": [], + "created_at": "2026-07-08 10:44:35", + "updated_at": "2026-07-08 10:44:35", + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "id": 11, + "key": "sirsoft-board.report.create", + "scope": "hook", + "target": "sirsoft-board.report.before_create", + "purpose": "sensitive_action", + "provider_id": null, + "grace_minutes": 30, + "enabled": false, + "priority": 100, + "conditions": null, + "source_type": "module", + "source_identifier": "sirsoft-board", + "applies_to": "self", + "fail_mode": "block", + "user_overrides": [], + "created_at": "2026-07-08 10:44:35", + "updated_at": "2026-07-08 10:44:35", + "abilities": { + "can_update": true, + "can_delete": true + } + }, + "... (총 12건 중 2건 표시)" + ], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + }, + "meta": { + "current_page": 1, + "per_page": 25, + "total": 12, + "last_page": 1 + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.admin.identity.policies.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 본인인증 정책(어느 시점/위치에서 어떤 목적의 인증을 요구할지) 목록을 필터·검색하여 페이지네이션 조회합니다. `auth:sanctum` + `core.admin.identity.policies.read` 관리자 권한이 필요합니다. `IdentityPolicyService::search` 로 scope·purpose·source_type·enabled 등을 필터링하며, 응답의 `source_type` 으로 선언형(core/module/plugin) 정책과 운영자 정책(admin) 을 구분하고 `user_overrides` 로 운영자가 재정의한 필드를 표시합니다. 관리자 IDV 정책 DataGrid 를 구성할 때 사용합니다. + + +### POST /api/admin/identity/policies + +- **라우트명**: `api.admin.identity.policies.store` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\Identity\AdminIdentityPolicyController@store` +- **인증/권한**: `auth:sanctum` + `permission:core.admin.identity.policies.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| key | body | string | 예 | max 120 | 정책 식별자 (고유, 예: core.profile.password_change) | +| scope | body | string | 예 | — | 조회 범위 한정 키 | +| target | body | string | 예 | max 255 | 매칭 대상 (scope 에 따라 라우트명/URI 패턴, 훅 이름, 또는 custom key) | +| purpose | body | string | 예 | max 64 | 이 정책이 요구하는 인증 목적 | +| provider_id | body | string | 아니오 | max 64 | provider 식별자 | +| grace_minutes | body | integer | 예 | min 0, max 43200 | 재인증 유예 시간(분) — 최근 N분 이내 동일 목적 인증 성공 시 재인증 생략 (0=매번 요구) | +| enabled | body | boolean | 아니오 | — | 사용 여부 | +| priority | body | integer | 아니오 | min 0, max 65535 | 우선순위 (작을수록 우선) | +| conditions | body | array | 아니오 | — | 추가 매칭 조건 (역할/HTTP 메서드/파라미터 매칭 조건 JSON) | +| applies_to | body | string | 예 | — | 적용 대상 사용자 (self: 일반 사용자 / admin: 관리자 / both: 모두, IdentityPolicyAppliesTo) | +| fail_mode | body | string | 예 | — | 실패 시 동작 (block: HTTP 428 차단 / log_only: 감사 로그만 남기고 통과, IdentityPolicyFailMode) | +| source_identifier | body | string | 아니오 | max 100 | 출처 식별자 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.identity_policy.store_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/identity/policies HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "key": "예시값", + "scope": "예시값", + "target": "예시값", + "purpose": "예시값", + "provider_id": "예시값", + "grace_minutes": 1, + "enabled": true, + "priority": 1, + "conditions": [ + "예시값" + ], + "applies_to": "예시값", + "fail_mode": "예시값", + "source_identifier": "example-key" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.admin.identity.policies.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 운영자가 새 본인인증 정책을 생성합니다(`source_type='admin'` 고정). `auth:sanctum` + `core.admin.identity.policies.update` 관리자 권한이 필요합니다. `IdentityPolicyService::createAdminPolicy` 가 처리하며 `key`·`scope`·`target`·`purpose`·`grace_minutes`·`applies_to`·`fail_mode` 등을 지정합니다. 확장은 `core.identity_policy.store_validation_rules` 필터 훅으로 검증을 확장할 수 있습니다. 특정 라우트/훅 지점에 코어가 선언하지 않은 인증 요구를 관리자가 직접 추가할 때 사용하며 성공 시 201 로 응답합니다. + + +### DELETE /api/admin/identity/policies/{id} + +- **라우트명**: `api.admin.identity.policies.destroy` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\Identity\AdminIdentityPolicyController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:core.admin.identity.policies.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.identity_policy.destroy_validation_rules`). + +**요청 예시** + +```http +DELETE /api/admin/identity/policies/{id} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.admin.identity.policies.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 본인인증 정책을 삭제합니다. `auth:sanctum` + `core.admin.identity.policies.update` 관리자 권한이 필요합니다. `source_type='admin'` 인 운영자 정책만 삭제할 수 있고, 선언형 정책(core/module/plugin) 은 403 을 반환하므로 삭제 대신 비활성화(update 로 `enabled=false`) 로 대체해야 합니다. 확장은 `core.identity_policy.destroy_validation_rules` 필터 훅으로 검증을 확장할 수 있습니다. 운영자가 잘못 만든 정책을 제거할 때 사용합니다. + + +### PUT /api/admin/identity/policies/{id} + +- **라우트명**: `api.admin.identity.policies.update` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\Identity\AdminIdentityPolicyController@update` +- **인증/권한**: `auth:sanctum` + `permission:core.admin.identity.policies.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| enabled | body | boolean | 아니오 | — | 사용 여부 | +| grace_minutes | body | integer | 아니오 | min 0, max 43200 | 재인증 유예 시간(분) — 최근 N분 이내 동일 목적 인증 성공 시 재인증 생략 (0=매번 요구) | +| provider_id | body | string | 아니오 | max 64 | provider 식별자 | +| fail_mode | body | string | 아니오 | — | 실패 시 동작 (block: HTTP 428 차단 / log_only: 감사 로그만 남기고 통과, IdentityPolicyFailMode) | +| key | body | string | 아니오 | max 120 | 정책 식별자 (admin 정책만 변경 가능, 선언형 정책은 확장 지점 식별자라 변경 차단) | +| scope | body | string | 아니오 | — | 조회 범위 한정 키 | +| target | body | string | 아니오 | max 255 | 매칭 대상 (admin 정책만 변경 가능, 선언형 정책은 변경 차단) | +| purpose | body | string | 아니오 | max 64 | 이 정책이 요구하는 인증 목적 | +| priority | body | integer | 아니오 | min 0, max 65535 | 우선순위 (작을수록 우선) | +| conditions | body | array | 아니오 | — | 추가 매칭 조건 (역할/HTTP 메서드/파라미터 매칭 조건 JSON) | +| applies_to | body | string | 아니오 | — | 적용 대상 사용자 (self: 일반 사용자 / admin: 관리자 / both: 모두, IdentityPolicyAppliesTo) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.identity_policy.update_validation_rules`). + +**요청 예시** + +```http +PUT /api/admin/identity/policies/{id} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "enabled": true, + "grace_minutes": 1, + "provider_id": "예시값", + "fail_mode": "예시값", + "key": "예시값", + "scope": "예시값", + "target": "예시값", + "purpose": "예시값", + "priority": 1, + "conditions": [ + "예시값" + ], + "applies_to": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.admin.identity.policies.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 본인인증 정책을 수정합니다. `auth:sanctum` + `core.admin.identity.policies.update` 관리자 권한이 필요합니다. `source_type='admin'` 정책은 모든 필드를 편집할 수 있으나, 선언형 정책(core/module/plugin) 은 `enabled`·`grace_minutes`·`provider_id`·`fail_mode`·`conditions`·`purpose`·`applies_to`·`priority` 화이트리스트("어떻게 인증할지") 만 허용되고 `key`/`scope`/`target`("어디서 인증할지") 은 확장 지점 식별자라 변경이 차단됩니다. 편집한 필드는 `user_overrides` 에 append 되어 시더 재실행 시 보존됩니다. 허용 필드가 하나도 없으면 422(nothing_to_update) 를 반환합니다. 확장은 `core.identity_policy.update_validation_rules` 필터 훅으로 검증을 확장할 수 있습니다. + + +### POST /api/admin/identity/policies/{id}/reset-field + +- **라우트명**: `api.admin.identity.policies.reset-field` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\Identity\AdminIdentityPolicyController@resetField` +- **인증/권한**: `auth:sanctum` + `permission:core.admin.identity.policies.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| field | body | string | 예 | `enabled`, `grace_minutes`, `provider_id`, `fail_mode`, `conditions`, `purpose`, `applies_to`, `priority` | 기본값으로 되돌릴 대상 필드명 (해당 필드의 운영자 재정의 해제 후 선언 기본값 복원) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.identity_policy.reset_field_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/identity/policies/{id}/reset-field HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "field": "enabled" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.admin.identity.policies.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 선언형 정책의 특정 필드에 대한 운영자 재정의(`user_overrides`) 를 해제하고 선언 기본값으로 즉시 복원합니다. `auth:sanctum` + `core.admin.identity.policies.update` 관리자 권한이 필요합니다. `field` 는 재정의 가능 필드(`enabled`, `grace_minutes`, `provider_id`, `fail_mode`, `conditions`, `purpose`, `applies_to`, `priority`) 중 하나여야 합니다. `source_type='admin'` 정책은 선언 기본값이 없어 403 을 반환하며 선언형 정책(core/module/plugin) 에만 의미가 있습니다. 관리자 편집 화면의 "↺ 기본값으로 되돌리기" 버튼이 호출하는 엔드포인트입니다. + + +### GET /api/admin/identity/providers + +- **라우트명**: `api.admin.identity.providers.index` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\Identity\AdminIdentityProviderController@index` +- **인증/권한**: `auth:sanctum` + `permission:core.admin.identity.providers.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/identity/providers HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | string | `g7:core.mail` | 기본 키 (내부 식별자) | +| label | string | `이메일` | 표시용 라벨 | +| channels | array | `["email"]` | 이 프로바이더가 지원하는 전송 채널 식별자 목록 | +| channel_labels | object | `{"email":"이메일"}` | 채널 식별자 → 사람이 읽는 표시 라벨 맵 (다국어 처리, UI 표시용) | +| render_hint | string | `text_code` | 프론트 challenge 렌더 방식 힌트 (text_code: 코드 입력 UI / link: 링크 클릭 유도 / external_redirect: 외부 인증 페이지 이동) | +| is_available | boolean | `true` | available 여부 | +| abilities | object | `{"can_update":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | +| settings_schema | object | `{"code_length":{"label":"인증 코드 길이","type":"integer","defa…` | 관리자 설정 UI 반복 렌더용 설정 스키마 (필드별 label/type/default/options/help — 코드 길이·만료 시간 등) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": [ + { + "id": "g7:core.mail", + "label": "이메일", + "channels": [ + "email" + ], + "channel_labels": { + "email": "이메일" + }, + "render_hint": "text_code", + "is_available": true, + "abilities": { + "can_update": true + }, + "settings_schema": { + "code_length": { + "label": "인증 코드 길이", + "type": "integer", + "default": 6, + "help": "발송되는 숫자 코드의 자릿수 (기본 6, 최소 4, 최대 10)." + }, + "from_address": { + "label": "발신자 주소", + "type": "string", + "default": null, + "help": "비어 있으면 시스템 기본 발신자를 사용합니다." + } + } + } + ] +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.admin.identity.providers.read`)이 없는 경우 | + + + +**설명** 등록된 IDV 프로바이더 목록을 각 프로바이더의 설정 스키마(`settings_schema`) 와 함께 반환합니다. `auth:sanctum` + `core.admin.identity.providers.read` 관리자 권한이 필요합니다. 각 프로바이더의 `getSettingsSchema()` 결과를 `core.identity.settings_schema` 필터 훅으로 확장 가능하게 통과시키므로, 관리자 프로바이더 설정 카드(코드 길이·만료 시간 등)를 스키마 기반으로 반복 렌더링할 때 사용합니다. 설정 스키마가 없는 공개용 목록은 `GET /api/identity/providers` 를 사용합니다. + + +### POST /api/identity/callback/{providerId} + +- **라우트명**: `api.identity.callback` +- **컨트롤러**: `\App\Http\Controllers\Api\Identity\IdentityVerificationController@callback` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| providerId | path | string | 예 | — | 대상 provider의 식별자 | +| challenge_id | body | string | 예 | max 64 | challenge 식별자 | +| code | body | string | 아니오 | max 512 | 외부 프로바이더가 콜백으로 전달한 인가 코드/인증 코드 | +| token | body | string | 아니오 | max 1024 | 인증/검증 토큰 | +| state | body | string | 아니오 | max 512 | 콜백 위변조 방지용 state 값 (요청 시 발급한 값과 대조) | +| redirect_url | body | string | 아니오 | max 2048 | 인증 완료 후 되돌아갈 URL (open redirect 방지 위해 same-origin 만 허용) | + +**요청 예시** + +```http +POST /api/identity/callback/{providerId} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +Content-Type: application/json + +{ + "challenge_id": "예시값", + "code": "예시값", + "token": "{YOUR_TOKEN}", + "state": "예시값", + "redirect_url": "https://example.com" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 외부 IDV 프로바이더(외부 인증 SDK/OAuth-style provider) 가 사용자 브라우저를 앱으로 되돌려 보내는 redirect 콜백 진입점입니다. `auth:sanctum` 인증이 필요합니다. body/query 의 `challenge_id` 를 추출해 `IdentityVerificationService::handleProviderCallback` 에 위임하며, 클라이언트가 stash 한 `return` 쿼리 유무와 성공 여부에 따라 응답이 갈립니다 — 성공+return: 302 로 `{return}?verification_token=...&challenge_id=...`, 성공+return 없음: 200 JSON `{ verification_token }`, 실패+return: 302 로 `{return}?identity_error={failure_code}`, 실패+return 없음: 422 JSON. `return` URL 은 open redirect 방지를 위해 same-origin(또는 `/` 상대경로) 만 허용하고 protocol-relative(`//`) 는 차단합니다. + + +### POST /api/identity/challenges + +- **라우트명**: `api.identity.challenges.request` +- **컨트롤러**: `\App\Http\Controllers\Api\Identity\IdentityVerificationController@request` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.identity.request` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| purpose | body | string | 예 | max 64 | 인증 목적 (signup/password_reset/self_update/sensitive_action 또는 모듈 정의 목적) | +| target | body | array | 아니오 | — | 비로그인 게스트의 인증 대상 (target.email 또는 target.phone — 로그인 사용자는 본인으로 자동 설정) | +| provider_id | body | string | 아니오 | max 64 | provider 식별자 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.identity.request_validation_rules`). + +**요청 예시** + +```http +POST /api/identity/challenges HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +Content-Type: application/json + +{ + "purpose": "예시값", + "target": [ + "예시값" + ], + "provider_id": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`core.identity.request`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 본인인증 challenge(인증 시도) 를 시작합니다. `auth:sanctum` + `core.identity.request` 권한이 필요합니다. 로그인 사용자는 인증 대상이 자동으로 본인이 되며, 비로그인 게스트(Mode B 가입 흐름) 는 `target.email` 또는 `target.phone` 을 반드시 제공해야 하고 없으면 422(missing_target) 를 반환합니다. `provider_id` 미지정 시 목적에 매핑된 기본 프로바이더가 선택됩니다. `IdentityVerificationService::start` 가 IP·User-Agent 등 컨텍스트와 함께 challenge 를 생성하고 성공 시 201 로 challenge 리소스(render_hint 등 포함) 를 반환합니다. 확장은 `core.identity.request_validation_rules` 필터 훅으로 파라미터를 추가할 수 있습니다. + + +### GET /api/identity/challenges/{challenge} + +- **라우트명**: `api.identity.challenges.show` +- **컨트롤러**: `\App\Http\Controllers\Api\Identity\IdentityVerificationController@show` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| challenge | path | string | 예 | — | 대상 challenge의 식별자 | + +**요청 예시** + +```http +GET /api/identity/challenges/{challenge} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | string | `00484973-8cd3-4a1d-85f2-78361feb6f0d` | 기본 키 (내부 식별자) | +| status | string | `verified` | requested\|sent\|processing\|verified\|failed\|expired\|cancelled\|policy_violation_logged | +| provider_id | string | `inicis` | 프로바이더 식별자 (예: g7:core.mail, kcp) | +| purpose | string | `sensitive_action` | 인증 목적 (signup\|password_reset\|self_update\|sensitive_action\|*module-defined*) — 코어 4종은 App\Enums\IdentityVerificationPurpose enum, 모듈/플러그인은 declaredPurposes 레지스트리 | +| render_hint | string | `text_code` | 프론트 렌더 힌트 (text_code\|link\|external_redirect) | +| expires_at | string | `2026-05-12T18:14:19+00:00` | expires 일시 | +| attempts | integer | `3` | 시도 횟수 | +| max_attempts | integer | `5` | 허용 최대 시도 횟수 | +| public_payload | array | `[]` | 프론트 렌더에 필요한 공개 안전 페이로드 (민감 metadata 제외, 프로바이더별 UI 힌트 — 없으면 빈 배열) | + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** challenge 의 공개 상태를 폴링 조회합니다(engine-v1.46.0+). `auth:sanctum` 인증이 필요합니다. Stripe Identity/토스인증 push/외부 redirect 콜백 대기처럼 verify 즉시 응답을 받지 못하는 비동기 검증 흐름에서 클라이언트가 상태(verified/failed/expired 등) 를 추적하기 위한 엔드포인트입니다. `IdentityVerificationService::getStatus` 가 공개 안전 항목만 노출하며(시도 횟수 상세·코드 본체·metadata 미노출), challenge 를 찾지 못하면 404 를 반환합니다. + + +### POST /api/identity/challenges/{challenge}/cancel + +- **라우트명**: `api.identity.challenges.cancel` +- **컨트롤러**: `\App\Http\Controllers\Api\Identity\IdentityVerificationController@cancel` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.identity.cancel` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| challenge | path | string | 예 | — | 대상 challenge의 식별자 | + +**요청 예시** + +```http +POST /api/identity/challenges/{challenge}/cancel HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`core.identity.cancel`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 진행 중인 challenge 를 취소합니다. `auth:sanctum` + `core.identity.cancel` 권한이 필요합니다. 라우트 모델 바인딩 + PermissionMiddleware 의 scope=self 가드로 로그인 사용자는 본인 challenge 만 취소할 수 있으며, 비로그인 게스트는 guest 역할 권한으로 진입합니다(모달 취소 시 audit trail 정합용). `IdentityVerificationService::cancel` 이 처리하고 대상 challenge 가 없으면 404 를 반환합니다. 사용자가 인증 모달을 닫을 때 서버 상태를 cancelled 로 남겨 이력 정합성을 맞추는 데 사용합니다. + + +### POST /api/identity/challenges/{challenge}/verify + +- **라우트명**: `api.identity.challenges.verify` +- **컨트롤러**: `\App\Http\Controllers\Api\Identity\IdentityVerificationController@verify` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.identity.verify` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| challenge | path | string | 예 | — | 대상 challenge의 식별자 | +| code | body | string | 아니오 | max 16 | 사용자가 입력한 인증 코드 (text_code 흐름 — 메일/SMS 로 받은 숫자 코드) | +| token | body | string | 아니오 | max 256 | 인증/검증 토큰 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.identity.verify_validation_rules`). + +**요청 예시** + +```http +POST /api/identity/challenges/{challenge}/verify HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +Content-Type: application/json + +{ + "code": "예시값", + "token": "{YOUR_TOKEN}" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`core.identity.verify`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** challenge 를 검증(인증 완료) 합니다. `auth:sanctum` + `core.identity.verify` 권한이 필요합니다. 라우트 모델 바인딩 + PermissionMiddleware 의 scope=self 가드로 로그인 사용자는 본인 challenge 만 검증하며, 비로그인 게스트는 guest 역할 권한으로 진입합니다(Mode B 가입 흐름). `code`(text_code 흐름) 또는 `token`(link/redirect 흐름) 을 전달하고 `IdentityVerificationService::verify` 가 처리합니다. 실패 시 422 로 `failure_code` 와 서버 기준 `attempts`/`max_attempts` 를 함께 내려 클라이언트의 "남은 시도 횟수" UI 를 서버와 동기화하며, 성공 시 후속 민감 작업에 제출할 `verification_token` 을 반환합니다. 확장은 `core.identity.verify_validation_rules` 필터 훅으로 파라미터를 추가할 수 있습니다. + + +### GET /api/identity/policies/resolve + +- **라우트명**: `api.identity.policies.resolve` +- **컨트롤러**: `App\Http\Controllers\Api\Identity\IdentityVerificationController@resolvePolicy` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| scope | query | string | 예 | max 32 | 조회 범위 한정 키 | +| target | query | string | 예 | max 255 | 정책 매칭 대상 (scope 와 함께 해석 — 라우트명/URI 패턴, 훅 이름, 또는 custom key) | + +**요청 예시** + +```http +GET /api/identity/policies/resolve?scope=%EC%98%88%EC%8B%9C%EA%B0%92&target=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 지정한 `scope`+`target` 조합에 매칭되는 본인인증 정책 요약을 반환합니다(프론트엔드 프리페치용). `auth:sanctum` 인증이 필요합니다. `IdentityPolicyService::resolve` 로 정책을 찾아 활성(`enabled`) 정책이 없으면 `data: null` 을, 있으면 UI 힌트에 필요한 최소 필드(`policy_key`, `scope`, `target`, `purpose`, `provider_id`, `grace_minutes`, `applies_to`, `fail_mode`) 만 반환하고 민감 필드는 노출하지 않습니다. 레이아웃 마운트 시 "이 페이지에서 IDV 가 요구될 수 있는 API" 를 미리 파악해 버튼 배지("확인 필요") 같은 UI 힌트를 표시할 때 사용합니다. + + +### GET /api/identity/providers + +- **라우트명**: `api.identity.providers.index` +- **컨트롤러**: `App\Http\Controllers\Api\Identity\IdentityVerificationController@providers` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/identity/providers HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | string | `g7:core.mail` | 기본 키 (내부 식별자) | +| label | string | `이메일` | 표시용 라벨 | +| channels | array | `["email"]` | 이 프로바이더가 지원하는 전송 채널 식별자 목록 | +| channel_labels | object | `{"email":"이메일"}` | 채널 식별자 → 사람이 읽는 표시 라벨 맵 (다국어 처리, UI 표시용) | +| render_hint | string | `text_code` | 프론트 challenge 렌더 방식 힌트 (text_code: 코드 입력 UI / link: 링크 클릭 유도 / external_redirect: 외부 인증 페이지 이동) | +| is_available | boolean | `true` | available 여부 | +| abilities | object | `{"can_update":false}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": [ + { + "id": "g7:core.mail", + "label": "이메일", + "channels": [ + "email" + ], + "channel_labels": { + "email": "이메일" + }, + "render_hint": "text_code", + "is_available": true, + "abilities": { + "can_update": false + } + } + ] +} +``` + +**에러 응답** + +_대표 에러 없음 (공개 조회). 인증·권한 미요구 엔드포인트로 도메인 특이 에러를 반환하지 않습니다._ + + + +**설명** 등록된 IDV 프로바이더의 공개 메타데이터 목록을 반환합니다. 공개 엔드포인트로 인증이 필요하지 않으며, 비로그인 가입 흐름에서도 접근합니다. `IdentityVerificationManager::all()` 의 각 프로바이더를 `ProviderResource` 로 직렬화해 id·label·channels·render_hint·is_available 등만 노출하고, 관리자용과 달리 `settings_schema` 는 포함하지 않습니다. 인증 모달이 사용 가능한 프로바이더 선택지를 표시할 때 사용합니다. + + +### GET /api/identity/purposes + +- **라우트명**: `api.identity.purposes.index` +- **컨트롤러**: `App\Http\Controllers\Api\Identity\IdentityVerificationController@purposes` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/identity/purposes HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | string | `signup` | 기본 키 (내부 식별자) | +| label | string | `회원가입 인증` | 표시용 라벨 | +| description | string | `신규 가입자의 이메일/전화번호 소유 확인.` | 설명 (다국어 필드는 로케일별 값 객체) | +| default_provider | string | `g7:core.mail` | 이 목적에 매핑된 기본 프로바이더 ID (요청 시 provider_id 미지정이면 이 값 사용, 미설정 시 null) | +| allowed_channels | array | `["mail","sms"]` | 이 목적에서 사용 가능한 전송 채널 목록 | +| source_type | string | `core` | 목적 출처 (core: 코어 기본 4종 / module / plugin — 어느 확장이 선언했는지) | +| source_identifier | string | `core` | 출처 식별자 (목적을 선언한 확장의 identifier) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": [ + { + "id": "signup", + "label": "회원가입 인증", + "description": "신규 가입자의 이메일/전화번호 소유 확인.", + "default_provider": "g7:core.mail", + "allowed_channels": [ + "mail", + "sms" + ], + "source_type": "core", + "source_identifier": "core" + }, + { + "id": "password_reset", + "label": "비밀번호 재설정", + "description": "비밀번호를 잊은 사용자가 본인 확인 후 재설정.", + "default_provider": "g7:core.mail", + "allowed_channels": [ + "mail", + "sms" + ], + "source_type": "core", + "source_identifier": "core" + }, + { + "id": "self_update", + "label": "자기 정보 변경", + "description": "로그인 사용자가 이메일/전화 등 본인 정보를 변경할 때.", + "default_provider": "g7:core.mail", + "allowed_channels": [ + "mail", + "sms" + ], + "source_type": "core", + "source_identifier": "core" + }, + { + "id": "sensitive_action", + "label": "민감 작업", + "description": "계정 탈퇴·관리자 작업 등 재인증이 필요한 시점.", + "default_provider": "g7:core.mail", + "allowed_channels": [ + "mail", + "sms" + ], + "source_type": "core", + "source_identifier": "core" + }, + { + "id": "checkout_verification", + "label": "결제 시 본인 확인", + "description": "결제 진행 전 성인/본인 확인이 필요한 경우 사용됩니다.", + "default_provider": null, + "allowed_channels": [ + "email", + "sms", + "ipin" + ], + "source_type": "module", + "source_identifier": "sirsoft-ecommerce" + } + ] +} +``` + +**에러 응답** + +_대표 에러 없음 (공개 조회). 인증·권한 미요구 엔드포인트로 도메인 특이 에러를 반환하지 않습니다._ + + + +**설명** 등록된 인증 목적(purpose) 목록을 반환합니다. 공개 엔드포인트로 인증이 필요하지 않습니다. `IdentityVerificationManager::getAllPurposes()` 로 코어 기본 4종(signup/password_reset/self_update/sensitive_action) + 활성 모듈/플러그인의 `getIdentityPurposes()` 선언 + `core.identity.purposes` 필터 훅(서드파티 동적 확장) 을 병합하며, 각 항목의 label/description 은 i18n 키·다국어 배열·평문 세 형태를 현재 로케일 문자열로 정규화해 내려줍니다. 각 목적의 기본 프로바이더·허용 채널·출처(source_type/source_identifier) 를 함께 반환하므로, 인증 UI 가 목적별 선택지와 문구를 구성할 때 사용합니다. + + diff --git a/docs/backend/api/language-packs.md b/docs/backend/api/language-packs.md new file mode 100644 index 00000000..c00a6dfc --- /dev/null +++ b/docs/backend/api/language-packs.md @@ -0,0 +1,979 @@ +# Language Packs API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Language Packs 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/admin/language-packs + +- **라우트명**: `api.admin.language-packs.index` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@index` +- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| scope | query | string | 아니오 | — | 조회 범위 한정 키 | +| target_identifier | query | string | 아니오 | max 150 | 대상 확장 식별자 | +| locale | query | string | 아니오 | max 20 | 로케일 코드 (표시 언어/지역) | +| status | query | string | 아니오 | — | 상태 필터 (해당 상태의 항목만 조회) | +| vendor | query | string | 아니오 | max 100 | 벤더명 (확장 제작자 식별자) | +| search | query | string | 아니오 | max 150 | 검색어 (지정한 검색 대상 필드에서 부분 일치) | +| exclude_protected | query | boolean | 아니오 | — | 보호 항목 제외 여부 | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | + +**요청 예시** + +```http +GET /api/admin/language-packs?scope=%EC%98%88%EC%8B%9C%EA%B0%92&target_identifier=example-key&locale=ko&status=%EC%98%88%EC%8B%9C%EA%B0%92&vendor=%EC%98%88%EC%8B%9C%EA%B0%92&search=%EC%98%88%EC%8B%9C%EA%B0%92&exclude_protected=1&per_page=1&page=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `16` | 기본 키 (내부 식별자) | +| identifier | string | `g7-core-en` | 언어팩 고유 식별자 ({vendor}-{scope}-{target?}-{locale}) | +| vendor | string | `g7` | 언어팩 제작자 식별자 | +| scope | string | `core` | 적용 대상 분류 | +| target_identifier | string | `gnuboard7-hello_module` | 대상 확장 식별자 (scope=core일 때 null) | +| locale | string | `en` | IETF BCP-47 locale 태그 | +| locale_name | string | `EN` | 영문 언어명 | +| locale_native_name | string | `English` | 원어 언어명 | +| text_direction | string | `ltr` | 텍스트 방향 | +| version | string | `7.0.1` | 언어팩 버전 | +| latest_version | string | `1.0.0` | 감지된 최신 배포 버전 | +| target_version_constraint | null | `null` | 대상 확장 버전 제약 (semver) | +| target_version_mismatch | boolean | `false` | 대상 버전 불일치 경고 플래그 | +| name | string | `API 문서 샘플 언어팩` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| license | string | `MIT` | 라이선스 | +| description | string | `문서 실측용 언어팩` | 언어팩 설명 (다국어) | +| status | string | `active` | 언어팩 상태 | +| is_protected | boolean | `true` | protected 여부 | +| source_type | string | `built_in` | 설치 소스 유형 (zip/github/url/bundled/bundled_with_extension) | +| origin | string | `built_in` | 출처 (설치/등록 원천 구분 값) | +| source_url | string | `lang/en` | 설치 소스 URL 또는 경로 | +| github_url | null | `null` | GitHub 저장소 URL (manifest 파생) | +| github_changelog_url | null | `null` | GitHub 변경 이력(CHANGELOG) URL (manifest 파생) | +| bundled_identifier | string | `g7-module-gnuboard7-hello_module-ja` | 대응하는 번들 확장 식별자 (번들 원본 매칭용) | +| install_blocked_reason | string | `target_not_installed` | 설치가 차단된 사유 (차단 없으면 null) | +| target_name | string | `게시판` | 대상 확장의 표시 이름 (scope+target_identifier 로 해석) | +| installed_at | string | `2026-07-03 19:20:23` | installed 일시 | +| activated_at | string | `2026-07-03 19:20:23` | activated 일시 | +| created_at | string | `2026-07-06 19:20:23` | 생성 일시 | +| updated_at | string | `2026-07-06 19:20:23` | 최종 수정 일시 | +| has_update | boolean | `false` | update 여부 | +| abilities | object | `{"can_activate":true,"can_deactivate":true,"can_uninstall…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "언어팩 목록을 조회했습니다.", + "data": { + "data": [ + { + "id": null, + "identifier": "g7-core-en", + "vendor": "g7", + "scope": "core", + "target_identifier": null, + "locale": "en", + "locale_name": "EN", + "locale_native_name": "English", + "text_direction": "ltr", + "version": "7.0.1", + "latest_version": null, + "target_version_constraint": null, + "target_version_mismatch": false, + "name": null, + "license": null, + "description": null, + "status": "active", + "is_protected": true, + "source_type": "built_in", + "origin": "built_in", + "source_url": "lang/en", + "github_url": null, + "github_changelog_url": null, + "bundled_identifier": null, + "install_blocked_reason": null, + "target_name": null, + "installed_at": null, + "activated_at": null, + "created_at": null, + "updated_at": null, + "has_update": false, + "abilities": { + "can_activate": true, + "can_deactivate": true, + "can_uninstall": true + } + }, + { + "id": 1, + "identifier": "apidoc-sample-lang", + "vendor": "apidoc", + "scope": "core", + "target_identifier": null, + "locale": "fr", + "locale_name": "French", + "locale_native_name": "Français", + "text_direction": "ltr", + "version": "1.0.0", + "latest_version": "1.0.0", + "target_version_constraint": null, + "target_version_mismatch": false, + "name": "API 문서 샘플 언어팩", + "license": "MIT", + "description": "문서 실측용 언어팩", + "status": "active", + "is_protected": false, + "source_type": "bundled", + "origin": "bundled", + "source_url": null, + "github_url": null, + "github_changelog_url": null, + "bundled_identifier": null, + "install_blocked_reason": null, + "target_name": null, + "installed_at": "2026-07-05 10:41:24", + "activated_at": "2026-07-05 10:41:24", + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 10:41:24", + "has_update": false, + "abilities": { + "can_activate": true, + "can_deactivate": true, + "can_uninstall": true + } + }, + "... (총 25건 중 2건 표시)" + ], + "meta": { + "total": 43, + "active": 15, + "installed": 0, + "inactive": 0, + "error": 0, + "uninstalled": 10, + "current_page": 1, + "last_page": 2, + "per_page": 25 + }, + "abilities": { + "can_install": true, + "can_activate": true, + "can_deactivate": true, + "can_uninstall": true, + "can_refresh_cache": true, + "can_check_updates": true, + "can_update": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.language_packs.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 설치된 언어팩과 번들 소스로부터 노출되는 언어팩 목록을 페이지네이션으로 조회합니다. `core.language_packs.read` 권한이 필요합니다. `scope`/`locale`/`status`/`vendor`/`search` 등으로 필터링하고 `exclude_protected` 로 보호(protected) 팩을 제외할 수 있습니다. 관리자 언어팩 관리 화면의 목록/필터 표시에 사용합니다. + + +### POST /api/admin/language-packs/bulk-activate + +- **라우트명**: `api.admin.language-packs.bulk-activate` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@bulkActivate` +- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| ids | body | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.language_packs.bulk_activate_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/language-packs/bulk-activate HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "ids": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.language_packs.manage`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 전달된 `ids` 배열의 언어팩을 일괄 활성화합니다. `core.language_packs.manage` 권한이 필요합니다. 성공/실패를 분리한 결과를 반환하므로 일부만 실패해도 전체가 롤백되지 않습니다. 비활성 팩을 한 번에 재활성화하는 reactivate 모달의 "활성화" 동작에 사용합니다. + + +### POST /api/admin/language-packs/check-updates + +- **라우트명**: `api.admin.language-packs.check-updates` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@checkUpdates` +- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.update` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/admin/language-packs/check-updates HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.language_packs.update`)이 없는 경우 | + + + +**설명** GitHub 소스로 설치된 언어팩들의 원격 최신 버전을 조회해 업데이트 가능 여부를 확인합니다. `core.language_packs.update` 권한이 필요합니다. 실제 업데이트를 수행하지 않고 검사 결과(checked, updates, details)만 반환하며, 외부 GitHub 호출을 동반합니다. 언어팩 목록의 업데이트 배지 표시에 사용합니다. + + +### POST /api/admin/language-packs/install-from-bundled + +- **라우트명**: `api.admin.language-packs.install-from-bundled` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@installFromBundled` +- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.install` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | body | string | 예 | max 200 | 대상 확장/리소스의 식별자 | +| auto_activate | body | boolean | 아니오 | — | 설치 후 자동 활성화 여부 | + +**요청 예시** + +```http +POST /api/admin/language-packs/install-from-bundled HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "identifier": "example-key", + "auto_activate": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.language_packs.install`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** `lang-packs/_bundled/{identifier}` 디렉토리의 번들 소스에서 언어팩을 설치(또는 재설치)합니다. `core.language_packs.install` 권한이 필요합니다. `auto_activate` 가 true면 설치 후 곧바로 활성화합니다. 코어/확장에 선탑재된 번들 언어팩을 DB에 등록할 때 사용합니다. + + +### POST /api/admin/language-packs/install-from-file + +- **라우트명**: `api.admin.language-packs.install-from-file` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@installFromFile` +- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.install` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| file | body | file | 예 | max 10240 | 업로드 파일 | +| auto_activate | body | boolean | 아니오 | — | 설치 후 자동 활성화 여부 | + +**요청 예시** + +```http +POST /api/admin/language-packs/install-from-file HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: multipart/form-data; boundary=----G7ExampleBoundary + +------G7ExampleBoundary +Content-Disposition: form-data; name="file"; filename="example.pdf" +Content-Type: application/octet-stream + +(바이너리 파일 내용) +------G7ExampleBoundary +Content-Disposition: form-data; name="auto_activate" + +1 +------G7ExampleBoundary-- +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.language_packs.install`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 업로드된 ZIP 파일에서 언어팩을 설치합니다. `core.language_packs.install` 권한이 필요합니다. manifest 검증에 실패하면 422로 응답하며, `auto_activate` 가 true면 설치 후 즉시 활성화합니다. 관리자가 로컬 ZIP 파일을 직접 업로드해 언어팩을 추가하는 화면에 사용합니다. + + +### POST /api/admin/language-packs/install-from-github + +- **라우트명**: `api.admin.language-packs.install-from-github` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@installFromGithub` +- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.install` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| github_url | body | string | 예 | — | GitHub 저장소 URL | +| auto_activate | body | boolean | 아니오 | — | 설치 후 자동 활성화 여부 | + +**요청 예시** + +```http +POST /api/admin/language-packs/install-from-github HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "github_url": "https://example.com", + "auto_activate": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.language_packs.install`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** GitHub 저장소 URL에서 언어팩을 다운로드해 설치합니다. `core.language_packs.install` 권한이 필요합니다. 외부 GitHub 호출을 동반하며 manifest 검증 실패 시 422로 응답합니다. `auto_activate` 가 true면 설치 후 즉시 활성화합니다. GitHub로 배포되는 언어팩을 URL 만으로 설치할 때 사용하며, 이후 check-updates/update 로 갱신을 추적할 수 있습니다. + + +### POST /api/admin/language-packs/install-from-url + +- **라우트명**: `api.admin.language-packs.install-from-url` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@installFromUrl` +- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.install` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| url | body | string | 예 | max 500 | URL | +| checksum | body | string | 아니오 | — | 무결성 검증 체크섬 (SHA-256) | +| auto_activate | body | boolean | 아니오 | — | 설치 후 자동 활성화 여부 | + +**요청 예시** + +```http +POST /api/admin/language-packs/install-from-url HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "url": "https://example.com", + "checksum": "예시값", + "auto_activate": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.language_packs.install`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 임의의 URL에서 언어팩 ZIP을 내려받아 설치합니다. `core.language_packs.install` 권한이 필요합니다. `checksum` 을 함께 전달하면 다운로드 무결성을 검증하며, manifest 검증 실패 시 422로 응답합니다. `auto_activate` 가 true면 설치 후 즉시 활성화합니다. GitHub 외 임의 호스팅에 배포된 언어팩을 설치할 때 사용합니다. + + +### POST /api/admin/language-packs/manifest-preview + +- **라우트명**: `api.admin.language-packs.manifest-preview` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@manifestPreview` +- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.install` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| file | body | file | 예 | max 5120 | 업로드 파일 | + +**요청 예시** + +```http +POST /api/admin/language-packs/manifest-preview HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: multipart/form-data; boundary=----G7ExampleBoundary + +------G7ExampleBoundary +Content-Disposition: form-data; name="file"; filename="example.pdf" +Content-Type: application/octet-stream + +(바이너리 파일 내용) +------G7ExampleBoundary-- +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.language_packs.install`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 업로드된 ZIP을 실제로 설치하지 않고 manifest 와 검증 결과만 미리 조회합니다. `core.language_packs.install` 권한이 필요합니다. 부수 효과 없이 읽기만 수행하며 검증 실패 시 422로 응답합니다. 설치 확인 모달에서 대상 언어팩의 메타데이터와 유효성을 미리 보여줄 때 사용합니다. + + +### POST /api/admin/language-packs/refresh-cache + +- **라우트명**: `api.admin.language-packs.refresh-cache` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@refreshCache` +- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.manage` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/admin/language-packs/refresh-cache HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.language_packs.manage`)이 없는 경우 | + + + +**설명** 번역/레지스트리/템플릿 언어 캐시를 무효화합니다. `core.language_packs.manage` 권한이 필요합니다. 언어팩 파일을 직접 수정했거나 활성 상태가 프론트에 반영되지 않을 때 캐시를 강제로 갱신하는 용도로 사용합니다. + + +### DELETE /api/admin/language-packs/{id} + +- **라우트명**: `api.admin.language-packs.uninstall` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@uninstall` +- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| cascade | query | boolean | 아니오 | — | 연쇄 처리 여부 (의존 항목 함께 처리) | + +**요청 예시** + +```http +DELETE /api/admin/language-packs/1?cascade=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.language_packs.manage`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 언어팩을 제거하고 설치된 파일을 삭제합니다. `core.language_packs.manage` 권한이 필요합니다. `cascade` 가 true면 연관 자원까지 함께 제거합니다. 대상이 존재하지 않으면 404로 응답합니다. 관리자 언어팩 관리 화면의 삭제(제거) 동작에 사용합니다. + + +### GET /api/admin/language-packs/{id} + +- **라우트명**: `api.admin.language-packs.show` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@show` +- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/admin/language-packs/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| identifier | string | `apidoc-sample-lang` | 언어팩 고유 식별자 ({vendor}-{scope}-{target?}-{locale}) | +| vendor | string | `apidoc` | 언어팩 제작자 식별자 | +| scope | string | `core` | 적용 대상 분류 | +| target_identifier | null | `null` | 대상 확장 식별자 (scope=core일 때 null) | +| locale | string | `fr` | IETF BCP-47 locale 태그 | +| locale_name | string | `French` | 영문 언어명 | +| locale_native_name | string | `Français` | 원어 언어명 | +| text_direction | string | `ltr` | 텍스트 방향 | +| version | string | `1.0.0` | 언어팩 버전 | +| latest_version | string | `1.0.0` | 감지된 최신 배포 버전 | +| target_version_constraint | null | `null` | 대상 확장 버전 제약 (semver) | +| target_version_mismatch | boolean | `false` | 대상 버전 불일치 경고 플래그 | +| name | string | `API 문서 샘플 언어팩` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| license | string | `MIT` | 라이선스 | +| description | string | `문서 실측용 언어팩` | 설명 (다국어 필드는 로케일별 값 객체) | +| status | string | `active` | 상태 값 (도메인별 상태 집합 — 사람이 읽는 라벨은 status_label, UI 변형은 status_variant 참조) | +| is_protected | boolean | `false` | protected 여부 | +| source_type | string | `bundled` | 설치 소스 유형 (zip/github/url/bundled/bundled_with_extension) | +| origin | string | `bundled` | 출처 (설치/등록 원천 구분 값) | +| source_url | null | `null` | source URL | +| github_url | null | `null` | GitHub 저장소 URL (manifest 파생) | +| github_changelog_url | null | `null` | GitHub 변경 이력(CHANGELOG) URL (manifest 파생) | +| bundled_identifier | null | `null` | 대응하는 번들 확장 식별자 (번들 원본 매칭용) | +| install_blocked_reason | null | `null` | 설치가 차단된 사유 (차단 없으면 null) | +| target_name | null | `null` | 대상 확장의 표시 이름 (scope+target_identifier 로 해석) | +| installed_at | string | `2026-07-05 10:41:24` | installed 일시 | +| activated_at | string | `2026-07-05 10:41:24` | activated 일시 | +| created_at | string | `2026-07-08 10:41:24` | 생성 일시 | +| updated_at | string | `2026-07-08 10:41:24` | 최종 수정 일시 | +| has_update | boolean | `false` | update 여부 | +| abilities | object | `{"can_activate":true,"can_deactivate":true,"can_uninstall…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | +| manifest | object | `{"name":{"en":"API Doc Sample Pack","ko":"API 문서 샘플 언어팩"}…` | language-pack.json 전체 스냅샷 | +| validation_summary | object | `{"target_version_mismatch":false,"depends_on_core_locale"…` | 검증 결과 요약 객체 (통과/실패 건수 등) | +| source_meta | object | `{"type":"bundled","url":null,"installed_by":6,"latest_ver…` | 원천 메타데이터 객체 (출처·경로·해석 정보) | +| changelog_entries | array | `[]` | 변경 이력 항목 목록 (버전별 변경 내용) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "언어팩 목록을 조회했습니다.", + "data": { + "id": 1, + "identifier": "apidoc-sample-lang", + "vendor": "apidoc", + "scope": "core", + "target_identifier": null, + "locale": "fr", + "locale_name": "French", + "locale_native_name": "Français", + "text_direction": "ltr", + "version": "1.0.0", + "latest_version": "1.0.0", + "target_version_constraint": null, + "target_version_mismatch": false, + "name": "API 문서 샘플 언어팩", + "license": "MIT", + "description": "문서 실측용 언어팩", + "status": "active", + "is_protected": false, + "source_type": "bundled", + "origin": "bundled", + "source_url": null, + "github_url": null, + "github_changelog_url": null, + "bundled_identifier": null, + "install_blocked_reason": null, + "target_name": null, + "installed_at": "2026-07-05 10:41:24", + "activated_at": "2026-07-05 10:41:24", + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 10:41:24", + "has_update": false, + "abilities": { + "can_activate": true, + "can_deactivate": true, + "can_uninstall": true + }, + "manifest": { + "name": { + "en": "API Doc Sample Pack", + "ko": "API 문서 샘플 언어팩" + }, + "locale": "fr", + "version": "1.0.0" + }, + "validation_summary": { + "target_version_mismatch": false, + "depends_on_core_locale": null + }, + "source_meta": { + "type": "bundled", + "url": null, + "installed_by": 6, + "latest_version": "1.0.0", + "directory_path": "lang-packs/apidoc-sample-lang" + }, + "changelog_entries": [] + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.language_packs.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 단일 언어팩의 상세 정보를 조회합니다. `core.language_packs.read` 권한이 필요합니다. `{id}` 가 정수면 DB 레코드를, 문자열(번들 식별자)이면 `lang-packs/_bundled/{id}` manifest 로 합성된 가상 행을 반환합니다. 미설치 번들 언어팩까지 상세 모달로 열람할 수 있도록 하며, 없으면 404로 응답합니다. + + +### POST /api/admin/language-packs/{id}/activate + +- **라우트명**: `api.admin.language-packs.activate` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@activate` +- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +POST /api/admin/language-packs/1/activate HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.language_packs.manage`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 언어팩을 활성화합니다(슬롯 스위칭). `core.language_packs.manage` 권한이 필요합니다. 동일 슬롯(scope·target·locale)에 이미 다른 활성 팩이 있으면 409(slot_conflict)로 현재/대상 팩을 함께 반환하며, 프론트는 확인 모달을 띄운 뒤 `force=true` 로 재호출해 교체합니다. 대상이 없으면 404로 응답합니다. + + +### GET /api/admin/language-packs/{id}/changelog + +- **라우트명**: `api.admin.language-packs.changelog` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@changelog` +- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/admin/language-packs/1/changelog HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| identifier | string | `apidoc-sample-lang` | 언어팩 고유 식별자 ({vendor}-{scope}-{target?}-{locale}) | +| entries | array | `[]` | 항목 목록 (각 원소는 대상 도메인 레코드) | +| changelog | string | `` | 변경 이력 텍스트 (원격/파일 CHANGELOG 본문) | +| has_changelog | boolean | `false` | changelog 여부 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "언어팩 목록을 조회했습니다.", + "data": { + "identifier": "apidoc-sample-lang", + "entries": [], + "changelog": "", + "has_changelog": false + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.language_packs.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 언어팩의 CHANGELOG.md 내용을 반환합니다. `core.language_packs.read` 권한이 필요합니다. `{id}` 가 정수면 DB 레코드를, 문자열이면 번들 가상 행을 사용합니다. 파싱된 항목(entries), 원문(changelog), 존재 여부(has_changelog)를 함께 반환하며, CHANGELOG 파일이 없어도 빈 값으로 정상 응답합니다. 상세 모달의 변경 이력 탭에 사용합니다. + + +### POST /api/admin/language-packs/{id}/deactivate + +- **라우트명**: `api.admin.language-packs.deactivate` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@deactivate` +- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +POST /api/admin/language-packs/1/deactivate HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.language_packs.manage`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 활성 언어팩을 비활성화합니다. `core.language_packs.manage` 권한이 필요합니다. 해당 슬롯의 번역 적용이 해제되며, 대상이 없으면 404로 응답합니다. 관리자 언어팩 관리 화면에서 활성 팩을 끄는 동작에 사용합니다. + + +### POST /api/admin/language-packs/{id}/update + +- **라우트명**: `api.admin.language-packs.update` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@performUpdate` +- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +POST /api/admin/language-packs/1/update HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.language_packs.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** GitHub 소스 언어팩을 최신 버전으로 다시 내려받아 적용합니다. `core.language_packs.update` 권한이 필요합니다. 외부 GitHub 재다운로드와 파일 교체를 동반하며, 갱신된 언어팩 정보를 반환합니다. 대상이 없으면 404로 응답합니다. check-updates 로 업데이트가 감지된 팩을 실제로 갱신할 때 사용합니다. + + diff --git a/docs/backend/api/layouts.md b/docs/backend/api/layouts.md new file mode 100644 index 00000000..15de5754 --- /dev/null +++ b/docs/backend/api/layouts.md @@ -0,0 +1,100 @@ +# Layouts API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Layouts 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/layouts/preview/{token}.json + +- **라우트명**: `api.public.layouts.preview.serve` +- **컨트롤러**: `App\Http\Controllers\Api\Public\LayoutPreviewController@serve` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| token | path | string | 예 | — | 인증/검증 토큰 | + +**요청 예시** + +```http +GET /api/layouts/preview/{token}.json HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 편집 중인 레이아웃을 미리보기 토큰(UUID)으로 조회해 JSON으로 서빙합니다. 토큰으로 대상 레이아웃을 찾은 뒤 상속 병합과 확장(extension) 적용까지 마친 결과를 반환하며, 토큰이 유효하지 않으면 404를 반환합니다. 인증 미들웨어가 적용되지만 실질적 보안 메커니즘은 토큰 자체이며, 레이아웃 편집기에서 저장 전 변경분을 실제 렌더링으로 확인하는 용도입니다. + + +### GET /api/layouts/{templateIdentifier}/{layoutName}.json + +- **라우트명**: `api.public.layouts.serve` +- **컨트롤러**: `App\Http\Controllers\Api\Public\PublicLayoutController@serve` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateIdentifier | path | string | 예 | — | 대상 template의 식별자 | +| layoutName | path | string | 예 | — | 대상 layout의 이름 (식별자) | + +**요청 예시** + +```http +GET /api/layouts/{templateIdentifier}/{layoutName}.json HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 활성 템플릿의 병합된 레이아웃 JSON을 프론트엔드에 서빙합니다. 템플릿이 존재하고 활성 상태여야 하며, 상속 병합·확장 적용을 마친 결과를 ETag·Cache-Control 헤더와 함께 반환하고 미변경 시 304로 응답합니다. 레이아웃의 `permissions`에 따라 접근을 제한하고(비회원 401, 권한 부족 403), 컴포넌트 단위 권한 필터링을 사용자별로 적용합니다. 쿼리 `v`(정수 캐시 버전)로 캐시를 구분하며, `with_source_meta=1`은 `core.templates.layouts.edit` 권한이 있어야 노드별 출처 메타(편집기 전용)를 포함해 반환합니다. + + diff --git a/docs/backend/api/license.md b/docs/backend/api/license.md new file mode 100644 index 00000000..6f5f6524 --- /dev/null +++ b/docs/backend/api/license.md @@ -0,0 +1,73 @@ +# License API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 License 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/admin/license + +- **라우트명**: `api.admin.license` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LicenseController@core` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/license HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| content | string | `프로그램 명칭 : 그누보드7 (Gnuboard7) 저작자 : (주…` | 본문 내용 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "content": "프로그램 명칭 : 그누보드7 (Gnuboard7)\n\n저작자 : (주)에스아이알소프트\n\n라이선스 (License)\n\n번역문 아래에 원문이 있습니다.\n\n주의)\n1. 번역문과 원문의 내용상 차이가 있는 경우 원문의 내용을 우선으로 따릅니다.\n2. 이 라이선스 파일 및 내용은 저작자를 제외한 어느 누구도 추가, 수정, 삭제할 수 없습니다.\n\n----- MIT 라이선스 (한국어 번역) --------------------------------------------------------\n\nMIT 라이선스\n\nCopyright (c) 2026 (주)에스아이알소프트\n\n이 소프트웨어와 관련 문서 파일(이하 \"소프트웨어\")의 복사본을 취득하는 모든 사람에게\n소프트웨어를 제한 없이 사용, 복사, 수정, 병합, 출판, 배포, 서브라이선스 허여 및/또는\n판매할 수 있는 권리를 무상으로 부여합니다. 다만, 소프트웨어를 제공받은 사람은 다음\n조건을 따라야 합니다:\n\n위 저작권 고지와 본 허가 고지는 소프트웨어의 모든 복사본 또는 상당 부분에 포함되어야\n합니다.\n\n소프트웨어는 \"있는 그대로\" 제공되며, 명시적이든 묵시적이든 어떠한 종류의 보증도 하지\n않습니다. 여기에는 상품성, 특정 목적에의 적합성 및 비침해에 대한 보증이 포함되나 이에\n국한되지 않습니다. 어떠한 경우에도 저작자 또는 저작권자는 소프트웨어나 소프트웨어의\n사용 또는 기타 거래로 인해 발생하는 계약, 불법행위 또는 기타 청구, 손해 또는 기타\n책임에 대해 책임을 지지 않습니다.\n\n----- MIT License (English Original) --------------------------------------------------------\n\nThe MIT License (MIT)\n\nCopyright (c) 2026 SIRSOFT\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** 코어 라이선스 파일의 원문 텍스트를 `content`로 반환합니다. `auth:sanctum` 인증이 필요하며, 라이선스 파일이 없으면 404(`common.not_found`)를 반환합니다. 관리자 화면에서 코어 저작권·라이선스 고지를 표시하는 용도로 사용합니다. + + diff --git a/docs/backend/api/locales.md b/docs/backend/api/locales.md new file mode 100644 index 00000000..df4bc639 --- /dev/null +++ b/docs/backend/api/locales.md @@ -0,0 +1,80 @@ +# Locales API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Locales 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/locales/active + +- **라우트명**: `api.public.locales.active` +- **컨트롤러**: `App\Http\Controllers\Api\Public\LocaleController@active` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/locales/active HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| locales | array | `["ko","en","fr","ja"]` | 활성 로케일 코드 배열 | +| locale_names | object | `{"ko":"한국어","en":"English","ja":"日本語","fr":"Français"}` | 로케일 코드별 표시명 맵 (config app.locale_names) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "locales.fetched", + "data": { + "locales": [ + "ko", + "en", + "fr" + ], + "locale_names": { + "ko": "한국어", + "en": "English", + "fr": "Français" + } + } +} +``` + +**에러 응답** + +_대표 에러 없음 (공개 조회). 인증·권한 미요구 엔드포인트로 도메인 특이 에러를 반환하지 않습니다._ + + + +**설명** 현재 사이트가 즉시 노출 가능한 활성 로케일 목록과 로케일별 표시명(`locale_names`) 매핑을 반환합니다. 활성 코어 언어팩을 기준으로 산출되며 인증이 필요 없는 공개 엔드포인트입니다. 언어팩 설치·활성화 직후 사용자 언어 셀렉터를 새로고침 없이 갱신하는 시나리오에 사용합니다. + + diff --git a/docs/backend/api/me.md b/docs/backend/api/me.md new file mode 100644 index 00000000..c04f059b --- /dev/null +++ b/docs/backend/api/me.md @@ -0,0 +1,297 @@ +# Me API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Me 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### DELETE /api/me + +- **라우트명**: `api.me.destroy` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\ProfileController@destroy` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +DELETE /api/me HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** + +현재 로그인한 사용자가 자신의 계정을 탈퇴한다. 프론트의 회원 탈퇴 모달(`_modal_withdraw.json`)이 호출한다. 별도 요청 파라미터 없이 인증 토큰의 사용자를 대상으로 하며, `UserService::withdrawUser()` 가 아바타·토큰 삭제와 개인정보 익명화를 수행한다. 되돌릴 수 없는 작업이므로 호출 전 사용자 확인 절차를 두는 것을 권장한다. + + +### GET /api/me + +- **라우트명**: `api.me.show` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\ProfileController@show` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/me HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| uuid | string | `a231747f-e82e-4cf2-9ae1-a261849dce40` | 외부 노출용 UUID (URL/API 식별자, 내부 id 비노출) | +| name | string | `API 문서 샘플 사용자` | 사용자 이름 | +| nickname | string | `gunwoo.oh` | 닉네임 | +| email | string | `apidoc-sample-user@example.com` | 이메일 주소 | +| avatar | null | `null` | 아바타 이미지 URL (User::getAvatarUrl() 산물, 미등록 시 null) | +| language | string | `ko` | 사용자 언어 설정 (ko: 한국어, en: 영어) | +| timezone | string | `Asia/Seoul` | 사용자 시간대 (예: Asia/Seoul, UTC) | +| country | string | `KR` | 국가 코드 (ISO 3166-1 alpha-2) | +| status | string | `active` | 계정 상태 (active: 활성, inactive: 비활성, blocked: 차단, withdrawn: 탈퇴) | +| status_label | string | `활성` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| status_variant | string | `success` | 상태 표시 색상/스타일 변형 키 (상태 Enum variant() 산물 — UI 배지용) | +| homepage | string | `https://example.com` | 홈페이지 URL | +| mobile | string | `010-9070-5662` | 휴대폰 번호 | +| phone | string | `02-805-4759` | 전화번호 | +| zipcode | string | `93153` | 우편번호 | +| address | string | `대구광역시 북구 백제고분로 720` | 기본 주소 | +| address_detail | string | `40동 835호` | 상세 주소 | +| signature | string | `Ipsam rem amet expedita est.` | 서명 | +| bio | string | `Tenetur omnis et amet omnis veniam to…` | 자기소개 | +| is_super | boolean | `false` | super 여부 | +| is_admin | boolean | `true` | 관리자 역할 보유 여부 (User::isAdmin() — 역할 관계 기반 파생) | +| withdrawn_at | null | `null` | withdrawn 일시 | +| last_login_at | string | `2026-07-05 19:15:16` | last login 일시 | +| last_login_human | string | `1일 전` | 마지막 로그인 시각의 상대 표현 (diffForHumans() 산물, 사용자 시간대 기준) | +| created_at | string | `2026-07-06 19:15:16` | 생성 일시 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| notify_post_complete | boolean | `false` | 게시글 작성 완료 알림 수신 설정 (게시판 모듈 주입) | +| notify_post_reply | boolean | `false` | 내 게시글에 대한 답글 알림 수신 설정 (게시판 모듈 주입) | +| notify_comment | boolean | `false` | 내 게시글에 대한 댓글 알림 수신 설정 (게시판 모듈 주입) | +| notify_reply_comment | boolean | `false` | 내 댓글에 대한 대댓글 알림 수신 설정 (게시판 모듈 주입) | +| email_subscription | boolean | `false` | 광고성 이메일 수신 동의 여부 (마케팅 플러그인 주입, 채널) | +| email_subscription_at | null | `null` | email subscription 일시 | +| marketing_consent | boolean | `false` | 마케팅 정보 수신 전체 동의 마스터 키 (마케팅 플러그인 주입) | +| marketing_consent_at | null | `null` | marketing consent 일시 | +| third_party_consent | boolean | `false` | 제3자 정보 제공 동의 여부 (법적 항목, 마케팅 플러그인 주입) | +| third_party_consent_at | null | `null` | third party consent 일시 | +| info_disclosure | boolean | `false` | 개인정보 이용 안내 동의 여부 (법적 항목, 마케팅 플러그인 주입) | +| info_disclosure_at | null | `null` | info disclosure 일시 | +| marketing_consent_enabled | boolean | `true` | 마케팅 동의 항목 UI 노출 여부 (활성화 플래그) | +| marketing_consent_terms_slug | string | `marketing-terms` | 마케팅 동의에 연결된 약관 slug (미설정 시 null) | +| marketing_consent_terms_slug_set | boolean | `true` | 마케팅 동의 약관 연결 존재 여부 (프론트 링크 표시 판정용) | +| third_party_consent_enabled | boolean | `true` | 제3자 제공 동의 항목 UI 노출 여부 (활성화 플래그) | +| third_party_consent_terms_slug | null | `null` | 제3자 제공 동의에 연결된 약관 slug (미설정 시 null) | +| third_party_consent_terms_slug_set | boolean | `false` | 제3자 제공 동의 약관 연결 존재 여부 (프론트 링크 표시 판정용) | +| info_disclosure_enabled | boolean | `true` | 개인정보 이용 안내 동의 항목 UI 노출 여부 (활성화 플래그) | +| info_disclosure_terms_slug | null | `null` | 개인정보 이용 안내 동의에 연결된 약관 slug (미설정 시 null) | +| info_disclosure_terms_slug_set | boolean | `false` | 개인정보 이용 안내 동의 약관 연결 존재 여부 (프론트 링크 표시 판정용) | +| email_subscription_enabled | boolean | `true` | 이메일 수신 동의 항목 UI 노출 여부 (활성화 플래그) | +| email_subscription_terms_slug | null | `null` | 이메일 수신 동의에 연결된 약관 slug (미설정 시 null) | +| email_subscription_terms_slug_set | boolean | `false` | 이메일 수신 동의 약관 연결 존재 여부 (프론트 링크 표시 판정용) | +| channels | array | `[{"key":"email_subscription","label":"광고성 이메일 수신","enable…` | 관리자 정의 전체 마케팅 채널 목록 (원소 key/label/enabled/terms_slug, 마케팅 플러그인 주입) | +| consent_histories | array | `[]` | 동의 변경 이력 (원소 channel_key/action/source/created_at, 마케팅 플러그인 주입) | +| ecommerce_mileage | object | `{"enabled":false}` | 마일리지 정보 (enabled: 기능 활성 여부, 잔액, 이커머스 모듈 주입) | +| ecommerce_preferred_currency | null | `null` | 선호 결제 통화 (이커머스 모듈 주입, 미설정 시 null) | +| ecommerce_preferred_shipping_country | null | `null` | 선호 배송 국가 코드 (이커머스 모듈 주입, 미설정 시 null) | +| ecommerce_preferred_shipping_country_name | null | `null` | 선호 배송 국가 이름 (코드 파생, 이커머스 모듈 주입, 미설정 시 null) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "프로필 정보를 성공적으로 가져왔습니다.", + "data": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "nickname": "song.hyunji", + "email": "apidoc-sample-user@example.com", + "avatar": null, + "language": "ko", + "timezone": "Asia/Seoul", + "country": "KR", + "status": "active", + "status_label": "활성", + "status_variant": "success", + "homepage": "https://example.com", + "mobile": "010-9595-2897", + "phone": "02-637-5618", + "zipcode": "16505", + "address": "경기도 안양시 봉은사로 2918", + "address_detail": "48동 718호", + "signature": "Fugit consequuntur repellendus sed.", + "bio": "Ut magni et sunt ducimus error adipisci. Pariatur corporis voluptatem ratione quo non saepe. Illo atque praesentium possimus dolores qui est fugit. Sint fugiat numquam voluptates.", + "is_super": false, + "is_admin": true, + "withdrawn_at": null, + "last_login_at": "2026-07-07 10:41:24", + "last_login_human": "1일 전", + "created_at": "2026-07-08 10:41:24", + "is_owner": true, + "notify_post_complete": false, + "notify_post_reply": false, + "notify_comment": false, + "notify_reply_comment": false, + "ecommerce_mileage": { + "enabled": false + }, + "ecommerce_preferred_currency": null, + "ecommerce_preferred_shipping_country": null, + "ecommerce_preferred_shipping_country_name": null + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** + +현재 로그인한 사용자의 프로필 정보를 조회한다. 프론트의 마이페이지(`mypage/profile.json`)와 프로필 수정 화면(`mypage/profile-edit.json`)이 소비한다. 응답은 `UserResource::toProfileArray()` 산물로, 비밀번호 등 민감 필드는 제외된다. 표의 `notify_*`(게시판 모듈)·`marketing_consent*`/`email_subscription*`/`third_party_consent*`/`info_disclosure*`/`channels`/`consent_histories`(마케팅 플러그인)·`ecommerce_*`(이커머스 모듈) 필드는 코어가 아니라 각 확장이 `core.user.filter_resource_data` 훅으로 병합하는 확장 소유 필드이며, 해당 확장이 비활성인 환경에서는 응답에 나타나지 않는다. + + +### PUT /api/me + +- **라우트명**: `api.me.update` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\ProfileController@update` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| name | body | string | 예 | max 255 | 대상의 이름/명칭 | +| nickname | body | string | 아니오 | max 50 | 닉네임 | +| email | body | email | 예 | max 255 | 이메일 주소 | +| password | body | string | 아니오 | — | 비밀번호 | +| current_password | body | string | 아니오 | — | 현재 비밀번호 (변경 전 확인용) | +| language | body | string | 아니오 | — | 언어 코드 | +| country | body | string | 아니오 | — | 국가 코드 (ISO 3166-1 alpha-2) | +| timezone | body | string | 아니오 | — | 타임존 식별자 | +| homepage | body | string | 아니오 | max 255 | 홈페이지 URL | +| mobile | body | string | 아니오 | max 20 | 휴대전화 번호 | +| phone | body | string | 아니오 | max 20 | 전화번호 | +| zipcode | body | string | 아니오 | max 10 | 우편번호 | +| address | body | string | 아니오 | max 255 | 기본 주소 | +| address_detail | body | string | 아니오 | max 255 | 상세 주소 | +| signature | body | string | 아니오 | max 1000 | 서명 | +| bio | body | string | 아니오 | max 5000 | 자기소개 | +| notify_post_complete | body | boolean | 아니오 | — | 게시글 작성 완료 알림 수신 설정 (게시판 모듈 추가) | +| notify_post_reply | body | boolean | 아니오 | — | 내 게시글에 대한 답글 알림 수신 설정 (게시판 모듈 추가) | +| notify_comment | body | boolean | 아니오 | — | 내 게시글에 대한 댓글 알림 수신 설정 (게시판 모듈 추가) | +| notify_reply_comment | body | boolean | 아니오 | — | 내 댓글에 대한 대댓글 알림 수신 설정 (게시판 모듈 추가) | +| email_subscription | body | boolean | 아니오 | — | 광고성 이메일 수신 동의 (마케팅 플러그인 추가, 채널) | +| marketing_consent | body | boolean | 아니오 | — | 마케팅 정보 수신 전체 동의 마스터 키 (마케팅 플러그인 추가) | +| third_party_consent | body | boolean | 아니오 | — | 제3자 정보 제공 동의 (법적 항목, 마케팅 플러그인 추가) | +| info_disclosure | body | boolean | 아니오 | — | 개인정보 이용 안내 동의 (법적 항목, 마케팅 플러그인 추가) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.user.update_profile_validation_rules`). + +**요청 예시** + +```http +PUT /api/me HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": "예시 이름", + "nickname": "예시 이름", + "email": "user@example.com", + "password": "Password123!", + "current_password": "Password123!", + "language": "ko", + "country": "KR", + "timezone": "Asia/Seoul", + "homepage": "https://example.com", + "mobile": "010-1234-5678", + "phone": "010-1234-5678", + "zipcode": "06234", + "address": "서울특별시 강남구 테헤란로 1", + "address_detail": "서울특별시 강남구 테헤란로 1", + "signature": "예시값", + "bio": "예시 내용입니다.", + "notify_post_complete": true, + "notify_post_reply": true, + "notify_comment": true, + "notify_reply_comment": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +현재 로그인한 사용자가 자신의 프로필을 수정한다. 프론트의 프로필 수정 화면(`partials/mypage/profile/_edit.json`)이 사용한다. `name`·`email` 은 필수이며 `email` 은 본인을 제외한 중복이 허용되지 않는다. 비밀번호를 함께 변경하려면 `password`(+ `password_confirmation`)와 현재 비밀번호(`current_password`)를 함께 보내야 하며, `password` 가 빈 문자열이면 비밀번호 미변경으로 처리된다. `notify_*`·`marketing_consent`·`email_subscription` 등 확장 소유 파라미터는 게시판 모듈·마케팅 플러그인이 `core.user.update_profile_validation_rules` 훅으로 추가하며, 해당 확장이 비활성인 환경에서는 수용되지 않는다. 성공 시 갱신된 프로필이 `UserResource` 형태로 반환된다. + + diff --git a/docs/backend/api/menus.md b/docs/backend/api/menus.md new file mode 100644 index 00000000..54bdc242 --- /dev/null +++ b/docs/backend/api/menus.md @@ -0,0 +1,1295 @@ +# Menus API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Menus 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/admin/menus + +- **라우트명**: `api.admin.menus.index` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\MenuController@index` +- **인증/권한**: `auth:sanctum` + `permission:core.menus.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| is_active | query | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| filters | query | array | 아니오 | max 10 | 추가 필터 조건 맵 (필드별 조건) | +| sort_by | query | string | 아니오 | `created_at`, `name`, `slug`, `order` | 정렬 기준 필드명 | +| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.menu.list_validation_rules`). + +**요청 예시** + +```http +GET /api/admin/menus?is_active=1&filters=%EC%98%88%EC%8B%9C%EA%B0%92&sort_by=created_at&sort_order=asc HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"대시보드","en":"Dashboard","ja":"ダッシュボード"}` | 메뉴 이름 (다국어 JSON) | +| slug | string | `admin-dashboard` | 메뉴 슬러그 | +| url | string | `/admin/dashboard` | 메뉴 URL | +| icon | string | `fas fa-tachometer-alt` | 메뉴 아이콘 | +| order | integer | `1` | 메뉴 순서 | +| is_active | boolean | `true` | active 여부 | +| parent_id | null | `null` | 상위 메뉴 ID | +| extension_type | string | `core` | 확장 소유 타입: core(코어), module(모듈), plugin(플러그인), NULL(사용자 정의) | +| extension_identifier | string | `core` | 확장 식별자 (예: core, sirsoft-board, sirsoft-payment) | +| children | array | `[]` | 하위 항목 배열 (계층 트리 — children 관계 파생) | +| creator | null | `null` | 생성자 정보 객체 (uuid/name/email — creator 관계 파생) | +| roles | array | `[{"id":1,"name":{"ko":"관리자","en":"Administrator"},"permis…` | 이 메뉴 노출이 허용된 역할 목록 (원소 id/name/permission_type — roles 관계 파생, permission_type 은 pivot 의 노출 권한 유형) | +| created_at | string | `2026-05-27 15:20:18` | 생성 일시 | +| updated_at | string | `2026-05-27 15:21:38` | 최종 수정 일시 | +| is_owner | boolean | `false` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "메뉴를 성공적으로 가져왔습니다.", + "data": { + "data": [ + { + "id": 19, + "name": { + "ko": "페이지 관리", + "en": "Page Management" + }, + "slug": "sirsoft-page", + "url": "/admin/pages", + "icon": "fas fa-file-alt", + "order": 25, + "is_active": true, + "parent_id": null, + "extension_type": "module", + "extension_identifier": "sirsoft-page", + "children": [], + "creator": null, + "roles": [ + { + "id": 1, + "name": { + "ko": "관리자", + "en": "Administrator" + }, + "permission_type": "read" + } + ], + "created_at": "2026-07-08 10:44:43", + "updated_at": "2026-07-08 10:44:43", + "is_owner": false, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + }, + { + "id": 15, + "name": { + "ko": "게시판 관리", + "en": "Board Management" + }, + "slug": "sirsoft-board", + "url": null, + "icon": "fas fa-clipboard-list", + "order": 30, + "is_active": true, + "parent_id": null, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "children": [ + { + "id": 16, + "name": { + "ko": "환경설정", + "en": "Settings" + }, + "slug": "sirsoft-board-settings", + "url": "/admin/boards/settings", + "icon": "fas fa-cog", + "order": 1, + "is_active": true, + "parent_id": 15, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "roles": [ + { + "id": 1, + "name": { + "ko": "관리자", + "en": "Administrator" + }, + "permission_type": "read" + } + ] + }, + { + "id": 17, + "name": { + "ko": "게시판 목록", + "en": "Board List" + }, + "slug": "sirsoft-board-list", + "url": "/admin/boards", + "icon": "fas fa-list", + "order": 2, + "is_active": true, + "parent_id": 15, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "roles": [ + { + "id": 1, + "name": { + "ko": "관리자", + "en": "Administrator" + }, + "permission_type": "read" + } + ] + }, + { + "id": 18, + "name": { + "ko": "게시판 신고현황", + "en": "Board Reports" + }, + "slug": "sirsoft-board-reports", + "url": "/admin/boards/reports", + "icon": "fas fa-flag", + "order": 3, + "is_active": true, + "parent_id": 15, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "roles": [ + { + "id": 1, + "name": { + "ko": "관리자", + "en": "Administrator" + }, + "permission_type": "read" + } + ] + } + ], + "creator": null, + "roles": [ + { + "id": 1, + "name": { + "ko": "관리자", + "en": "Administrator" + }, + "permission_type": "read" + } + ], + "created_at": "2026-07-08 10:44:35", + "updated_at": "2026-07-08 10:44:35", + "is_owner": false, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + }, + "... (총 3건 중 2건 표시)" + ], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.menus.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +관리자 메뉴 관리 화면(`partials/admin_menu_list/*`)의 목록 표시 기준 엔드포인트. `is_active` 또는 `filters` 가 있으면 필터링된 관리용 메뉴를, 없으면 최상위 메뉴 전체를 반환한다. `filters` 는 `field`(name/slug/url/all)·`value`·`operator`(like/eq/starts_with/ends_with) 조합의 배열이며 최대 10개까지 허용된다. `name` 은 다국어 JSON 객체로 내려오고, 각 항목에 `children`/`creator`/`roles` 관계와 `abilities`(현재 사용자의 수정/삭제 가능 여부)가 포함된다. 인증 계약: `auth:sanctum` + `permission:core.menus.read`. + + +### POST /api/admin/menus + +- **라우트명**: `api.admin.menus.store` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\MenuController@store` +- **인증/권한**: `auth:sanctum` + `permission:core.menus.create` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| name | body | string | 예 | — | 대상의 이름/명칭 | +| slug | body | string | 예 | max 255 | URL 친화 식별자 (slug) | +| url | body | string | 아니오 | max 500 | URL | +| icon | body | string | 아니오 | max 100 | 아이콘 | +| parent_id | body | integer | 아니오 | — | parent 식별자 | +| order | body | integer | 아니오 | min 0 | 표시 정렬 순서 값 (작을수록 우선) | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| extension_type | body | string | 아니오 | `core`, `module`, `plugin` | 확장 유형 (core/module/plugin/template) | +| extension_identifier | body | string | 아니오 | max 255 | 확장 식별자 | +| roles | body | array | 아니오 | — | 이 메뉴 노출을 허용할 역할 ID 배열 (각 원소는 존재하는 role id — 지정 시 노출 허용 역할 목록으로 설정/교체) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.menu.create_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/menus HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": "예시 이름", + "slug": "example-key", + "url": "https://example.com", + "icon": "예시값", + "parent_id": 1, + "order": 1, + "is_active": true, + "extension_type": "core", + "extension_identifier": "example-key", + "roles": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `26` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"실측 예시값","en":"실측 예시값","fr":"실측 예시값"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| slug | string | `probe_6a4dc09f5a6bd` | URL 친화 식별자 (slug) | +| url | string | `https://example.com` | 메뉴 URL | +| icon | string | `실측 예시값` | 아이콘 식별자 (아이콘 클래스/이름) | +| order | integer | `1` | 메뉴 순서 | +| is_active | boolean | `true` | active 여부 | +| parent_id | integer | `1` | parent 식별자 (연관 리소스 참조) | +| extension_type | string | `core` | 이 리소스를 소유한 확장의 타입 (core/module/plugin/template) | +| extension_identifier | string | `probe_6a4dc09f5a6c3` | 이 리소스를 소유한 확장의 식별자 | +| parent | object | `{"id":1,"name":{"ko":"API 문서 샘플 메뉴","en":"API Doc Sample …` | 상위 항목 객체 (parent 관계 파생) | +| children | array | `[]` | 하위 항목 배열 (계층 트리 — children 관계 파생) | +| creator | null | `null` | 생성자 정보 객체 (uuid/name/email — creator 관계 파생) | +| roles | array | `[{"id":1,"name":{"ko":"관리자","en":"Administrator"},"permis…` | 보유 역할 목록 (각 원소 id/name/permissions — roles 관계 파생) | +| created_at | string | `2026-07-08 12:14:39` | 생성 일시 | +| updated_at | string | `2026-07-08 12:14:39` | 최종 수정 일시 | +| is_owner | boolean | `false` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "메뉴가 성공적으로 생성되었습니다.", + "data": { + "id": 26, + "name": { + "ko": "실측 예시값", + "en": "실측 예시값", + "fr": "실측 예시값" + }, + "slug": "probe_6a4dc09f5a6bd", + "url": "https://example.com", + "icon": "실측 예시값", + "order": 1, + "is_active": true, + "parent_id": 1, + "extension_type": "core", + "extension_identifier": "probe_6a4dc09f5a6c3", + "parent": { + "id": 1, + "name": { + "ko": "API 문서 샘플 메뉴", + "en": "API Doc Sample Menu" + }, + "url": "/admin/apidoc-sample", + "icon": "fas fa-book" + }, + "children": [], + "creator": null, + "roles": [ + { + "id": 1, + "name": { + "ko": "관리자", + "en": "Administrator" + }, + "permission_type": "read" + }, + { + "id": 4, + "name": { + "ko": "API 문서 샘플 역할", + "en": "API Doc Sample Role" + }, + "permission_type": "read" + } + ], + "created_at": "2026-07-08 12:14:39", + "updated_at": "2026-07-08 12:14:39", + "is_owner": false, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.menus.create`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +새 메뉴를 생성한다. `name` 은 다국어 값(문자열로 보내면 지원 로케일 전체에 동일 값으로 자동 확장)이며 `slug` 는 `menus` 테이블 내 유일해야 한다. `parent_id` 로 하위 메뉴를 만들 수 있고, `roles` 배열로 이 메뉴 노출을 허용할 역할 ID 를 지정한다. 성공 시 `201` 과 함께 생성된 메뉴(관계 eager-load 포함)를 `MenuResource` 로 반환한다. 관리자 메뉴 관리 화면의 메뉴 추가 폼에서 소비된다. 인증 계약: `auth:sanctum` + `permission:core.menus.create`. + + +### GET /api/admin/menus/active + +- **라우트명**: `api.admin.menus.active` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\MenuController@active` +- **인증/권한**: `auth:sanctum` + `permission:core.menus.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/menus/active HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"대시보드","en":"Dashboard","ja":"ダッシュボード"}` | 메뉴 이름 (다국어 JSON) | +| slug | string | `admin-dashboard` | 메뉴 슬러그 | +| url | string | `/admin/dashboard` | 메뉴 URL | +| icon | string | `fas fa-tachometer-alt` | 메뉴 아이콘 | +| order | integer | `1` | 메뉴 순서 | +| is_active | boolean | `true` | active 여부 | +| children | array | `[]` | 하위 항목 배열 (계층 트리 — children 관계 파생) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "메뉴를 성공적으로 가져왔습니다.", + "data": [ + { + "id": 19, + "name": { + "ko": "페이지 관리", + "en": "Page Management" + }, + "slug": "sirsoft-page", + "url": "/admin/pages", + "icon": "fas fa-file-alt", + "order": 25, + "is_active": true, + "children": [] + }, + { + "id": 15, + "name": { + "ko": "게시판 관리", + "en": "Board Management" + }, + "slug": "sirsoft-board", + "url": null, + "icon": "fas fa-clipboard-list", + "order": 30, + "is_active": true, + "children": [ + { + "id": 16, + "name": { + "ko": "환경설정", + "en": "Settings" + }, + "slug": "sirsoft-board-settings", + "url": "/admin/boards/settings", + "icon": "fas fa-cog", + "order": 1, + "is_active": true, + "parent_id": 15, + "extension_type": "module", + "extension_identifier": "sirsoft-board" + }, + { + "id": 17, + "name": { + "ko": "게시판 목록", + "en": "Board List" + }, + "slug": "sirsoft-board-list", + "url": "/admin/boards", + "icon": "fas fa-list", + "order": 2, + "is_active": true, + "parent_id": 15, + "extension_type": "module", + "extension_identifier": "sirsoft-board" + }, + { + "id": 18, + "name": { + "ko": "게시판 신고현황", + "en": "Board Reports" + }, + "slug": "sirsoft-board-reports", + "url": "/admin/boards/reports", + "icon": "fas fa-flag", + "order": 3, + "is_active": true, + "parent_id": 15, + "extension_type": "module", + "extension_identifier": "sirsoft-board" + } + ] + }, + { + "id": 3, + "name": { + "ko": "이커머스", + "en": "Ecommerce" + }, + "slug": "sirsoft-ecommerce", + "url": null, + "icon": "fas fa-shopping-cart", + "order": 40, + "is_active": true, + "children": [ + { + "id": 4, + "name": { + "ko": "환경설정", + "en": "Settings" + }, + "slug": "sirsoft-ecommerce-settings", + "url": "/admin/ecommerce/settings", + "icon": "fas fa-cog", + "order": 1, + "is_active": true, + "parent_id": 3, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce" + }, + { + "id": 5, + "name": { + "ko": "상품 관리", + "en": "Products" + }, + "slug": "sirsoft-ecommerce-products", + "url": "/admin/ecommerce/products", + "icon": "fas fa-box", + "order": 2, + "is_active": true, + "parent_id": 3, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce" + }, + { + "id": 6, + "name": { + "ko": "카테고리 관리", + "en": "Categories" + }, + "slug": "sirsoft-ecommerce-categories", + "url": "/admin/ecommerce/categories", + "icon": "fas fa-folder", + "order": 3, + "is_active": true, + "parent_id": 3, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce" + }, + { + "id": 7, + "name": { + "ko": "브랜드 관리", + "en": "Brands" + }, + "slug": "sirsoft-ecommerce-brands", + "url": "/admin/ecommerce/brands", + "icon": "fas fa-tag", + "order": 4, + "is_active": true, + "parent_id": 3, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce" + }, + { + "id": 8, + "name": { + "ko": "상품정보제공고시", + "en": "Product Notice" + }, + "slug": "sirsoft-ecommerce-product-notices", + "url": "/admin/ecommerce/product-notices", + "icon": "fas fa-file-alt", + "order": 5, + "is_active": true, + "parent_id": 3, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce" + }, + { + "id": 9, + "name": { + "ko": "공통정보 관리", + "en": "Common Info" + }, + "slug": "sirsoft-ecommerce-common-info", + "url": "/admin/ecommerce/common-info", + "icon": "fas fa-info-circle", + "order": 6, + "is_active": true, + "parent_id": 3, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce" + }, + { + "id": 10, + "name": { + "ko": "주문 관리", + "en": "Orders" + }, + "slug": "sirsoft-ecommerce-orders", + "url": "/admin/ecommerce/orders", + "icon": "fas fa-receipt", + "order": 7, + "is_active": true, + "parent_id": 3, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce" + }, + { + "id": 11, + "name": { + "ko": "쿠폰 관리", + "en": "Coupons" + }, + "slug": "sirsoft-ecommerce-promotion-coupons", + "url": "/admin/ecommerce/promotion-coupons", + "icon": "fas fa-ticket-alt", + "order": 8, + "is_active": true, + "parent_id": 3, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce" + }, + { + "id": 12, + "name": { + "ko": "배송정책", + "en": "Shipping Policies" + }, + "slug": "sirsoft-ecommerce-shipping-policies", + "url": "/admin/ecommerce/shipping-policies", + "icon": "fas fa-truck", + "order": 9, + "is_active": true, + "parent_id": 3, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce" + }, + { + "id": 13, + "name": { + "ko": "리뷰 관리", + "en": "Reviews" + }, + "slug": "sirsoft-ecommerce-reviews", + "url": "/admin/ecommerce/reviews", + "icon": "fas fa-star", + "order": 10, + "is_active": true, + "parent_id": 3, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce" + }, + { + "id": 14, + "name": { + "ko": "마일리지 내역", + "en": "Mileage History" + }, + "slug": "sirsoft-ecommerce-mileage-transactions", + "url": "/admin/ecommerce/mileage-transactions", + "icon": "fas fa-coins", + "order": 11, + "is_active": true, + "parent_id": 3, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce" + } + ] + } + ] +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.menus.read`)이 없는 경우 | + + + +**설명** + +관리자 레이아웃 전역 부트스트랩 엔드포인트. `sirsoft-admin_basic/layouts/_admin_base.json` 의 `admin_menu` `data_source`(`/api/admin/menus/active`)가 모든 관리자 페이지 진입 시 자동 호출해, 사이드바 메뉴 트리를 채운다. 인증 사용자의 역할 기준으로 접근 가능한 활성 메뉴만 계층 구조(`children` 중첩)로 반환하며, `data` 는 최상위 메뉴 배열이다. 사용자 정보가 없을 때는 모든 활성 메뉴를 fallback 으로 반환한다. 인증 계약: `auth:sanctum` + `permission:core.menus.read`. + + +### GET /api/admin/menus/extension/{type}/{identifier} + +- **라우트명**: `api.admin.menus.by-extension` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\MenuController@getByExtension` +- **인증/권한**: `auth:sanctum` + `permission:core.menus.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| type | path | string | 예 | — | 확장 소유 타입 (ExtensionOwnerType 으로 파싱 — module: 모듈, plugin: 플러그인. 미유효 값이면 422 menu.invalid_extension_type) | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/admin/menus/extension/{type}/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.menus.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + +특정 확장이 소유한 메뉴 목록을 조회한다. `type` 은 `ExtensionOwnerType`(module, plugin)으로 파싱되며, 유효하지 않은 값이면 `422 menu.invalid_extension_type` 을 반환한다. `identifier` 는 확장 식별자(예: `sirsoft-board`)로, 해당 확장이 등록한 메뉴만 `MenuCollection` 으로 내려준다. 확장 설치/제거 시 그 확장 소유 메뉴를 확인·정리하는 관리 흐름에서 사용된다. 인증 계약: `auth:sanctum` + `permission:core.menus.read`. + + +### GET /api/admin/menus/hierarchy + +- **라우트명**: `api.admin.menus.hierarchy` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\MenuController@hierarchy` +- **인증/권한**: `auth:sanctum` + `permission:core.menus.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/menus/hierarchy HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"대시보드","en":"Dashboard","ja":"ダッシュボード"}` | 메뉴 이름 (다국어 JSON) | +| slug | string | `admin-dashboard` | 메뉴 슬러그 | +| url | string | `/admin/dashboard` | 메뉴 URL | +| icon | string | `fas fa-tachometer-alt` | 메뉴 아이콘 | +| order | integer | `1` | 메뉴 순서 | +| is_active | boolean | `true` | active 여부 | +| children | array | `[]` | 하위 항목 배열 (계층 트리 — children 관계 파생) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "메뉴를 성공적으로 가져왔습니다.", + "data": { + "data": [ + { + "id": 19, + "name": { + "ko": "페이지 관리", + "en": "Page Management" + }, + "slug": "sirsoft-page", + "url": "/admin/pages", + "icon": "fas fa-file-alt", + "order": 25, + "is_active": true, + "children": [] + }, + { + "id": 15, + "name": { + "ko": "게시판 관리", + "en": "Board Management" + }, + "slug": "sirsoft-board", + "url": null, + "icon": "fas fa-clipboard-list", + "order": 30, + "is_active": true, + "children": [ + { + "id": 16, + "name": { + "ko": "환경설정", + "en": "Settings" + }, + "slug": "sirsoft-board-settings", + "url": "/admin/boards/settings", + "icon": "fas fa-cog", + "order": 1, + "is_active": true, + "parent_id": 15, + "extension_type": "module", + "extension_identifier": "sirsoft-board" + }, + { + "id": 17, + "name": { + "ko": "게시판 목록", + "en": "Board List" + }, + "slug": "sirsoft-board-list", + "url": "/admin/boards", + "icon": "fas fa-list", + "order": 2, + "is_active": true, + "parent_id": 15, + "extension_type": "module", + "extension_identifier": "sirsoft-board" + }, + { + "id": 18, + "name": { + "ko": "게시판 신고현황", + "en": "Board Reports" + }, + "slug": "sirsoft-board-reports", + "url": "/admin/boards/reports", + "icon": "fas fa-flag", + "order": 3, + "is_active": true, + "parent_id": 15, + "extension_type": "module", + "extension_identifier": "sirsoft-board" + } + ] + }, + "... (총 4건 중 2건 표시)" + ] + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.menus.read`)이 없는 경우 | + + + +**설명** + +전체 메뉴를 계층 구조로 반환한다. `MenuService::getMenuHierarchy()` 로 조회한 메뉴를 `MenuCollection::toNavigationArray()` 로 변환해 최상위 메뉴 + `children` 중첩 형태로 내려준다. `active` 와 달리 사용자 역할 기반 접근 필터 없이 메뉴 트리 전체를 노출하므로, 관리자 메뉴 관리 화면의 트리 표현·순서 편집 기준 데이터로 사용된다. 인증 계약: `auth:sanctum` + `permission:core.menus.read`. + + +### PUT /api/admin/menus/order + +- **라우트명**: `api.admin.menus.update-order` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\MenuController@updateOrder` +- **인증/권한**: `auth:sanctum` + `permission:core.menus.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| parent_menus | body | array | 예 | min 1 | 최상위 메뉴의 새 순서 목록 (원소 `{id, order}` — id: 대상 메뉴, order: 1 이상 표시 순번) | +| child_menus | body | array | 아니오 | — | 부모별 하위 메뉴의 새 순서 목록 (부모 그룹핑된 2차원 배열, 각 원소 `{id, order}` — id: 하위 메뉴, order: 1 이상 표시 순번) | +| moved_items | body | array | 아니오 | — | 부모가 바뀐 항목 목록 (원소 `{id, new_parent_id}` — new_parent_id 는 새 부모 메뉴 ID 또는 null(최상위 이동), 순환 참조(자기 자신·자손 지정) 시 거부) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.menu.update_order_validation_rules`). + +**요청 예시** + +```http +PUT /api/admin/menus/order HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "parent_menus": [ + "예시값" + ], + "child_menus": [ + "예시값" + ], + "moved_items": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.menus.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +관리자 메뉴 관리 화면의 드래그 앤 드롭 순서 변경을 반영한다. `parent_menus` 는 `{id, order}` 배열로 최상위 메뉴 순서를, `child_menus` 는 부모별 하위 메뉴 순서를, `moved_items` 는 부모가 바뀐 항목(`{id, new_parent_id}`)을 전달한다. `moved_items` 의 `new_parent_id` 에는 순환 참조 방지(`NotCircularParent`) 검증이 적용되어, 자기 자신이나 자손을 부모로 지정하면 거부된다. 각 id/parent_id 는 실제 메뉴로 존재해야 한다. 응답은 성공 메시지만 반환한다. 인증 계약: `auth:sanctum` + `permission:core.menus.update`. + + +### DELETE /api/admin/menus/{menu} + +- **라우트명**: `api.admin.menus.destroy` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\MenuController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:core.menus.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| menu | path | string | 예 | — | 대상 menu의 식별자 | + +**요청 예시** + +```http +DELETE /api/admin/menus/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.menus.delete`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + +지정한 메뉴를 삭제한다. `{menu}` 는 라우트 모델 바인딩으로 해석되며, 삭제는 `MenuService::deleteMenu()` 가 수행한다. 성공 시 `menu.delete_success` 메시지만 반환하고, 삭제 불가 조건은 `422 menu.delete_failed` 로 내려온다. 관리자 메뉴 관리 화면의 메뉴 삭제 동작에서 소비된다. 인증 계약: `auth:sanctum` + `permission:core.menus.delete`. + + +### GET /api/admin/menus/{menu} + +- **라우트명**: `api.admin.menus.show` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\MenuController@show` +- **인증/권한**: `auth:sanctum` + `permission:core.menus.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| menu | path | string | 예 | — | 대상 menu의 식별자 | + +**요청 예시** + +```http +GET /api/admin/menus/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `33` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 메뉴","en":"API Doc Sample Menu"}` | 메뉴 이름 (다국어 JSON) | +| slug | string | `apidoc-sample-menu` | 메뉴 슬러그 | +| url | string | `/admin/apidoc-sample` | 메뉴 URL | +| icon | string | `fas fa-book` | 메뉴 아이콘 | +| order | integer | `28` | 메뉴 순서 | +| is_active | boolean | `true` | active 여부 | +| parent_id | null | `null` | 상위 메뉴 ID | +| extension_type | null | `null` | 확장 소유 타입: core(코어), module(모듈), plugin(플러그인), NULL(사용자 정의) | +| extension_identifier | null | `null` | 확장 식별자 (예: core, sirsoft-board, sirsoft-payment) | +| parent | null | `null` | 상위 항목 객체 (parent 관계 파생) | +| children | array | `[{"id":34,"name":{"ko":"하위 메뉴","en":"Child Menu"},"slug":…` | 하위 항목 배열 (계층 트리 — children 관계 파생) | +| creator | object | `{"uuid":"a231747f-e82e-4cf2-9ae1-a261849dce40","name":"AP…` | 생성자 정보 객체 (uuid/name/email — creator 관계 파생) | +| roles | array | `[]` | 이 메뉴 노출이 허용된 역할 목록 (원소 id/name/permission_type — roles 관계 파생, permission_type 은 pivot 의 노출 권한 유형) | +| created_at | string | `2026-07-06 19:15:16` | 생성 일시 | +| updated_at | string | `2026-07-06 19:15:16` | 최종 수정 일시 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "메뉴를 성공적으로 가져왔습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 메뉴", + "en": "API Doc Sample Menu" + }, + "slug": "apidoc-sample-menu", + "url": "/admin/apidoc-sample", + "icon": "fas fa-book", + "order": 35, + "is_active": true, + "parent_id": null, + "extension_type": null, + "extension_identifier": null, + "parent": null, + "children": [ + { + "id": 2, + "name": { + "ko": "하위 메뉴", + "en": "Child Menu" + }, + "slug": "apidoc-sample-menu-child", + "url": "/admin/nihil-non-eos-doloribus-occaecati-optio", + "icon": "fas fa-chart", + "order": 38, + "is_active": true, + "parent_id": 1, + "extension_type": null, + "extension_identifier": null, + "roles": [] + } + ], + "creator": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com" + }, + "roles": [], + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 10:41:24", + "is_owner": true, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.menus.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + +단일 메뉴의 상세 정보를 조회한다. `{menu}` 는 라우트 모델 바인딩으로 해석되며, `creator`/`parent`/`children`/`roles` 관계를 eager-load 해 함께 반환한다. `children` 에는 하위 메뉴가 order 오름차순으로, 각 하위 메뉴의 `roles` 까지 포함된다. 관리자 메뉴 관리 화면의 메뉴 편집 폼 초기값 로딩에 사용된다. 인증 계약: `auth:sanctum` + `permission:core.menus.read`. + + +### PUT /api/admin/menus/{menu} + +- **라우트명**: `api.admin.menus.update` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\MenuController@update` +- **인증/권한**: `auth:sanctum` + `permission:core.menus.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| menu | path | string | 예 | — | 대상 menu의 식별자 | +| name | body | string | 아니오 | — | 대상의 이름/명칭 | +| slug | body | string | 예 | max 255 | URL 친화 식별자 (slug) | +| url | body | string | 아니오 | max 500 | URL | +| icon | body | string | 아니오 | max 100 | 아이콘 | +| parent_id | body | integer | 아니오 | — | parent 식별자 | +| order | body | integer | 아니오 | min 0 | 표시 정렬 순서 값 (작을수록 우선) | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| extension_type | body | string | 아니오 | `core`, `module`, `plugin` | 확장 유형 (core/module/plugin/template) | +| extension_identifier | body | string | 아니오 | max 255 | 확장 식별자 | +| roles | body | array | 아니오 | — | 이 메뉴 노출을 허용할 역할 ID 배열 (각 원소는 존재하는 role id — 지정 시 노출 허용 역할 목록으로 설정/교체) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.menu.update_validation_rules`). + +**요청 예시** + +```http +PUT /api/admin/menus/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": "예시 이름", + "slug": "example-key", + "url": "https://example.com", + "icon": "예시값", + "parent_id": 1, + "order": 1, + "is_active": true, + "extension_type": "core", + "extension_identifier": "example-key", + "roles": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.menus.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + +기존 메뉴 정보를 수정한다. `{menu}` 는 라우트 모델 바인딩으로 해석되며, 전달된 필드만 갱신한다. `slug` 는 여전히 유일해야 하고, `roles` 배열을 보내면 이 메뉴 노출이 허용된 역할 목록을 교체한다. 성공 시 갱신된 메뉴를 관계 eager-load 포함해 `MenuResource` 로 반환하고, 실패 시 `menu.update_failed`(422 검증 오류 포함)를 반환한다. 관리자 메뉴 관리 화면의 메뉴 편집 폼 저장에서 소비된다. 인증 계약: `auth:sanctum` + `permission:core.menus.update`. + + +### PATCH /api/admin/menus/{menu}/toggle-status + +- **라우트명**: `api.admin.menus.toggle-status` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\MenuController@toggleStatus` +- **인증/권한**: `auth:sanctum` + `permission:core.menus.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| menu | path | string | 예 | — | 대상 menu의 식별자 | + +**요청 예시** + +```http +PATCH /api/admin/menus/1/toggle-status HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 메뉴","en":"API Doc Sample Menu"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| slug | string | `apidoc-sample-menu` | URL 친화 식별자 (slug) | +| url | string | `/admin/apidoc-sample` | 메뉴 URL | +| icon | string | `fas fa-book` | 아이콘 식별자 (아이콘 클래스/이름) | +| order | integer | `35` | 메뉴 순서 | +| is_active | boolean | `false` | active 여부 | +| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | +| extension_type | null | `null` | 이 리소스를 소유한 확장의 타입 (core/module/plugin/template) | +| extension_identifier | null | `null` | 이 리소스를 소유한 확장의 식별자 | +| created_at | string | `2026-07-08 10:41:24` | 생성 일시 | +| updated_at | string | `2026-07-08 12:14:40` | 최종 수정 일시 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "메뉴가 성공적으로 업데이트되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 메뉴", + "en": "API Doc Sample Menu" + }, + "slug": "apidoc-sample-menu", + "url": "/admin/apidoc-sample", + "icon": "fas fa-book", + "order": 35, + "is_active": false, + "parent_id": null, + "extension_type": null, + "extension_identifier": null, + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 12:14:40", + "is_owner": true, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.menus.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + +지정한 메뉴의 활성화 상태(`is_active`)를 반대 값으로 토글한다. `{menu}` 는 라우트 모델 바인딩으로 해석되며, 본문 파라미터 없이 현재 상태를 뒤집는다. 성공 시 갱신된 메뉴를 `MenuResource` 로 반환한다. 관리자 메뉴 관리 화면의 활성/비활성 스위치에서 소비된다. 인증 계약: `auth:sanctum` + `permission:core.menus.update`. + + diff --git a/docs/backend/api/modules.md b/docs/backend/api/modules.md new file mode 100644 index 00000000..fa25de71 --- /dev/null +++ b/docs/backend/api/modules.md @@ -0,0 +1,12246 @@ +# Modules API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Modules 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/admin/modules + +- **라우트명**: `api.admin.modules.index` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ModuleController@index` +- **인증/권한**: `auth:sanctum` + `permission:core.modules.read|core.menus.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| search | query | string | 아니오 | max 255 | 검색어 (지정한 검색 대상 필드에서 부분 일치) | +| filters | query | array | 아니오 | max 10 | 추가 필터 조건 맵 (필드별 조건) | +| status | query | string | 아니오 | `installed`, `not_installed`, `active`, `inactive` | 상태 필터 (해당 상태의 항목만 조회) | +| with | query | array | 아니오 | max 5 | 함께 포함할 추가 데이터 옵션 목록 (허용값 `custom_menus` — 각 모듈의 커스텀 메뉴 데이터 포함) | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | +| include_hidden | query | boolean | 아니오 | — | manifest `hidden=true` 로 표시된 숨김 확장까지 목록에 포함할지 여부 (기본 제외) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.module.index_validation_rules`). + +**요청 예시** + +```http +GET /api/admin/modules?search=%EC%98%88%EC%8B%9C%EA%B0%92&filters=%EC%98%88%EC%8B%9C%EA%B0%92&status=installed&with=%EC%98%88%EC%8B%9C%EA%B0%92&per_page=1&page=1&include_hidden=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| identifier | string | `sirsoft-board` | 모듈 고유 식별자 (vendor-module 형식) | +| vendor | string | `sirsoft` | 벤더/개발자명 | +| name | string | `게시판` | 모듈 이름 (다국어 JSON) | +| version | string | `1.0.0` | 모듈 버전 | +| description | string | `게시판 관리를 위한 모듈` | 모듈 설명 (다국어 JSON) | +| dependencies | array | `[]` | 의존하는 확장 맵 (manifest 파생 — {modules, plugins}) | +| status | string | `active` | 상태 (active: 활성화, inactive: 비활성화, installing: 설치 중, uninstalling: 제거 중, updating: 업데이트 중) | +| assets | object | `{"js":"\/api\/modules\/assets\/sirsoft-ecommerce\/dist\/j…` | 프론트엔드 에셋 매니페스트 (manifest 파생 — js/css 진입점·로딩 전략) | +| update_available | boolean | `false` | 최신 버전 대비 업데이트 가능 여부 | +| update_source | null | `null` | 업데이트 감지 출처 (github, bundled 등) | +| latest_version | string | `1.0.0` | 감지된 최신 배포 버전 | +| file_version | string | `1.0.0` | 설치된 파일의 manifest 버전 | +| github_url | string | `https://github.com/gnuboard/g7-module…` | GitHub 저장소 URL | +| github_changelog_url | string | `https://github.com/gnuboard/g7-module…` | GitHub 변경 내역 URL | +| is_pending | boolean | `false` | _pending 대기소에 있어 설치 대기 중인지 여부 | +| is_bundled | boolean | `false` | 코어에 선탑재된 번들 확장인지 여부 | +| deactivated_reason | null | `null` | 비활성화 사유: manual(사용자 수동) \| incompatible_core(코어 버전 호환성) \| null(active) | +| deactivated_at | null | `null` | deactivated 일시 | +| incompatible_required_version | null | `null` | 요구 코어 버전 미충족 시 필요한 버전 (호환되면 null) | +| abilities | object | `{"can_install":true,"can_activate":true,"can_uninstall":t…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "모듈을 성공적으로 가져왔습니다.", + "data": { + "data": [ + { + "identifier": "sirsoft-board", + "vendor": "sirsoft", + "name": "게시판", + "version": "1.0.0", + "description": "게시판 관리를 위한 모듈", + "dependencies": [], + "status": "active", + "assets": null, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": "1.0.0", + "github_url": "https://github.com/gnuboard/g7-module-sirsoft-board", + "github_changelog_url": "https://github.com/gnuboard/g7-module-sirsoft-board/releases", + "is_pending": false, + "is_bundled": false, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true + } + }, + { + "identifier": "sirsoft-ecommerce", + "vendor": "sirsoft", + "name": "이커머스", + "version": "1.0.1", + "description": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리", + "dependencies": [], + "status": "active", + "assets": { + "js": "/api/modules/assets/sirsoft-ecommerce/dist/js/module.iife.js", + "css": "/api/modules/assets/sirsoft-ecommerce/dist/css/module.css", + "priority": 100 + }, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": "1.0.1", + "github_url": "https://github.com/gnuboard/g7-module-sirsoft-ecommerce", + "github_changelog_url": "https://github.com/gnuboard/g7-module-sirsoft-ecommerce/releases", + "is_pending": false, + "is_bundled": false, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true + } + }, + "... (총 3건 중 2건 표시)" + ], + "pagination": { + "total": 3, + "current_page": 1, + "last_page": 1, + "per_page": 25 + }, + "meta": { + "total_modules": 3, + "active_modules": 0, + "system_modules": 0, + "user_modules": 3, + "total_installs": 0, + "average_rating": null, + "latest_version": "1.0.1", + "categories": [ + null + ], + "dependency_count": 0 + }, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.read|core.menus.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 설치된 모듈과 미설치 모듈을 모두 포함한 전체 모듈 목록을 페이지네이션으로 조회합니다. `search` 는 이름·식별자·설명·벤더에 대한 OR 검색이고 `filters` 는 AND 조건으로 적용되며, `with[]` 에 `custom_menus` 를 지정하면 커스텀 메뉴 데이터를 함께 포함합니다. `core.modules.read` 또는 `core.menus.read` 권한 중 하나가 필요하고, 응답의 `abilities` 는 현재 사용자의 수행 가능 작업 맵을 담습니다. 관리자 모듈 관리 화면의 목록 그리드를 구성하는 기본 엔드포인트입니다. + + +### POST /api/admin/modules/activate + +- **라우트명**: `api.admin.modules.activate` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ModuleController@activate` +- **인증/권한**: `auth:sanctum` + `permission:core.modules.activate` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| module_name | body | string | 예 | max 255 | module 이름 (식별자) | +| force | body | boolean | 아니오 | — | 강제 실행 여부 (안전 확인/선행 검사 우회) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.module.activate_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/modules/activate HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "module_name": "예시 이름", + "force": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.activate`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 설치된 모듈을 활성화합니다. `core.modules.activate` 권한이 필요합니다. `force` 없이 호출했을 때 필요한 의존 확장이 충족되지 않으면 409 응답으로 `missing_modules`·`missing_plugins` 목록과 함께 경고를 반환하므로, 사용자 확인 후 `force: true` 로 재요청해야 합니다. 재활성화 시 cascade 로 함께 비활성화됐던 번들 언어팩 목록이 `pending_language_packs` 로 응답에 포함됩니다. + + +### POST /api/admin/modules/check-updates + +- **라우트명**: `api.admin.modules.check-updates` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ModuleController@checkUpdates` +- **인증/권한**: `auth:sanctum` + `permission:core.modules.install` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/admin/modules/check-updates HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.install`)이 없는 경우 | + + + +**설명** 설치된 모든 모듈에 대해 GitHub·번들 소스를 조회하여 새 버전 배포 여부를 일괄 확인합니다. `core.modules.install` 권한이 필요합니다. 파라미터 없이 호출하며, 각 모듈의 업데이트 가능 여부와 감지된 최신 버전 정보를 반환합니다. 모듈 목록 화면 진입 시 업데이트 뱃지를 갱신하는 용도로 사용됩니다. + + +### POST /api/admin/modules/deactivate + +- **라우트명**: `api.admin.modules.deactivate` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ModuleController@deactivate` +- **인증/권한**: `auth:sanctum` + `permission:core.modules.activate` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| module_name | body | string | 예 | max 255 | module 이름 (식별자) | +| force | body | boolean | 아니오 | — | 강제 실행 여부 (안전 확인/선행 검사 우회) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.module.deactivate_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/modules/deactivate HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "module_name": "예시 이름", + "force": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.activate`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 활성 모듈을 비활성화합니다. `core.modules.activate` 권한이 필요합니다. `force` 없이 호출했을 때 이 모듈에 의존하는 템플릿·모듈·플러그인이 있으면 409 응답으로 `dependent_templates`·`dependent_modules`·`dependent_plugins` 목록과 함께 경고를 반환합니다. 의존 관계 확인 후 `force: true` 로 강제 비활성화할 수 있습니다. + + +### POST /api/admin/modules/install + +- **라우트명**: `api.admin.modules.install` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ModuleController@install` +- **인증/권한**: `auth:sanctum` + `permission:core.modules.install` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| module_name | body | string | 예 | max 255 | module 이름 (식별자) | +| vendor_mode | body | string | 아니오 | `auto`, `composer`, `bundled` | 벤더 설치 모드 (auto/composer/bundled) | +| dependencies | body | array | 아니오 | — | 함께 설치할 의존 확장 목록 (cascade 1단계). 각 원소는 `type`(module\|plugin)·`identifier` 로 구성하며, install-preview 응답에서 사용자가 선택한 항목 | +| language_packs | body | array | 아니오 | — | 함께 설치할 번들 언어팩 식별자 목록 (cascade 2단계, best-effort). 원소는 언어팩 식별자 문자열 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.module.install_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/modules/install HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "module_name": "예시 이름", + "vendor_mode": "auto", + "dependencies": [ + "예시값" + ], + "language_packs": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.install`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** `_pending`·`_bundled` 대기소에 있는 모듈을 활성 디렉토리로 설치합니다. `core.modules.install` 권한이 필요합니다. `vendor_mode` 로 Composer 의존성 설치 방식을(auto/composer/bundled) 지정하며, 요청 본문의 `dependencies` 로 선택한 의존 확장을 먼저 설치(cascade 1단계, 실패 시 전체 중단)한 뒤 `language_packs` 로 지정한 번들 언어팩을 best-effort 로 함께 설치합니다(cascade 2단계). 성공 시 201 상태로 반환하고, 언어팩 설치 실패는 응답의 `language_pack_failures` 에 담깁니다. + + +### POST /api/admin/modules/install-from-file + +- **라우트명**: `api.admin.modules.install-from-file` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ModuleController@installFromFile` +- **인증/권한**: `auth:sanctum` + `permission:core.modules.install` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| file | body | file | 예 | max 51200 | 업로드 파일 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.module.install_from_file_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/modules/install-from-file HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: multipart/form-data; boundary=----G7ExampleBoundary + +------G7ExampleBoundary +Content-Disposition: form-data; name="file"; filename="example.pdf" +Content-Type: application/octet-stream + +(바이너리 파일 내용) +------G7ExampleBoundary-- +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.install`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 업로드된 ZIP 파일에서 모듈을 설치합니다. `core.modules.install` 권한이 필요하며, 파일은 최대 50MB(51200KB)까지 허용됩니다. ZIP 압축 해제 후 module.json 검증을 거쳐 설치하며, 성공 시 201 상태로 설치된 모듈 정보를 반환합니다. 설치 전 manifest 만 미리 확인하려면 `manifest-preview` 를 먼저 호출하는 것이 안전합니다. + + +### POST /api/admin/modules/install-from-github + +- **라우트명**: `api.admin.modules.install-from-github` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ModuleController@installFromGithub` +- **인증/권한**: `auth:sanctum` + `permission:core.modules.install` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| github_url | body | string | 예 | — | GitHub 저장소 URL | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.module.install_from_github_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/modules/install-from-github HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "github_url": "https://example.com" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.install`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** GitHub 저장소 URL 에서 모듈을 내려받아 설치합니다. `core.modules.install` 권한이 필요합니다. `github_url` 로 지정한 공개 저장소의 릴리스/소스를 받아 압축 해제·검증 후 설치하며, 성공 시 201 상태로 설치된 모듈 정보를 반환합니다. + + +### GET /api/admin/modules/installed + +- **라우트명**: `api.admin.modules.installed` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ModuleController@installed` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/modules/installed HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| identifier | string | `sirsoft-board` | 모듈 고유 식별자 (vendor-module 형식) | +| vendor | string | `sirsoft` | 벤더/개발자명 | +| name | string | `게시판` | 모듈 이름 (다국어 JSON) | +| version | string | `1.0.0` | 모듈 버전 | +| description | string | `게시판 관리를 위한 모듈` | 모듈 설명 (다국어 JSON) | +| dependencies | array | `[]` | 의존하는 확장 맵 (manifest 파생 — {modules, plugins}) | +| status | string | `active` | 상태 (active: 활성화, inactive: 비활성화, installing: 설치 중, uninstalling: 제거 중, updating: 업데이트 중) | +| assets | object | `{"js":"\/api\/modules\/assets\/sirsoft-ecommerce\/dist\/j…` | 프론트엔드 에셋 매니페스트 (manifest 파생 — js/css 진입점·로딩 전략) | +| update_available | boolean | `false` | 최신 버전 대비 업데이트 가능 여부 | +| update_source | null | `null` | 업데이트 감지 출처 (github, bundled 등) | +| latest_version | string | `1.0.0` | 감지된 최신 배포 버전 | +| file_version | string | `1.0.0` | 설치된 파일의 manifest 버전 | +| github_url | string | `https://github.com/gnuboard/g7-module…` | GitHub 저장소 URL | +| github_changelog_url | string | `https://github.com/gnuboard/g7-module…` | GitHub 변경 내역 URL | +| is_pending | boolean | `false` | _pending 대기소에 있어 설치 대기 중인지 여부 | +| is_bundled | boolean | `false` | 코어에 선탑재된 번들 확장인지 여부 | +| deactivated_reason | null | `null` | 비활성화 사유: manual(사용자 수동) \| incompatible_core(코어 버전 호환성) \| null(active) | +| deactivated_at | null | `null` | deactivated 일시 | +| incompatible_required_version | null | `null` | 요구 코어 버전 미충족 시 필요한 버전 (호환되면 null) | +| abilities | object | `{"can_install":true,"can_activate":true,"can_uninstall":t…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "모듈을 성공적으로 가져왔습니다.", + "data": { + "data": [ + { + "identifier": "sirsoft-board", + "vendor": "sirsoft", + "name": "게시판", + "version": "1.0.0", + "description": "게시판 관리를 위한 모듈", + "dependencies": [], + "status": "active", + "assets": null, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": "1.0.0", + "github_url": "https://github.com/gnuboard/g7-module-sirsoft-board", + "github_changelog_url": "https://github.com/gnuboard/g7-module-sirsoft-board/releases", + "is_pending": false, + "is_bundled": false, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true + } + }, + { + "identifier": "sirsoft-ecommerce", + "vendor": "sirsoft", + "name": "이커머스", + "version": "1.0.1", + "description": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리", + "dependencies": [], + "status": "active", + "assets": { + "js": "/api/modules/assets/sirsoft-ecommerce/dist/js/module.iife.js", + "css": "/api/modules/assets/sirsoft-ecommerce/dist/css/module.css", + "priority": 100 + }, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": "1.0.1", + "github_url": "https://github.com/gnuboard/g7-module-sirsoft-ecommerce", + "github_changelog_url": "https://github.com/gnuboard/g7-module-sirsoft-ecommerce/releases", + "is_pending": false, + "is_bundled": false, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true + } + }, + "... (총 3건 중 2건 표시)" + ], + "meta": { + "total_modules": 3, + "active_modules": 0, + "system_modules": 0, + "user_modules": 3, + "total_installs": 0, + "average_rating": null, + "latest_version": "1.0.1", + "categories": [ + null + ], + "dependency_count": 0 + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** 현재 설치된 모듈만 조회합니다(미설치 항목 제외). 이 엔드포인트는 세부 권한 미들웨어 없이 `auth:sanctum` 인증만 요구하므로, 다른 화면이 활성/설치된 모듈 목록을 참조할 때 사용하는 경량 조회 API 입니다. 페이지네이션 없이 설치된 항목 배열을 반환합니다. + + +### POST /api/admin/modules/manifest-preview + +- **라우트명**: `api.admin.modules.manifest-preview` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ModuleController@manifestPreview` +- **인증/권한**: `auth:sanctum` + `permission:core.modules.install` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| file | body | file | 예 | max 51200 | 업로드 파일 | + +**요청 예시** + +```http +POST /api/admin/modules/manifest-preview HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: multipart/form-data; boundary=----G7ExampleBoundary + +------G7ExampleBoundary +Content-Disposition: form-data; name="file"; filename="example.pdf" +Content-Type: application/octet-stream + +(바이너리 파일 내용) +------G7ExampleBoundary-- +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.install`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 업로드된 ZIP 파일의 module.json manifest 와 검증 결과만 추출합니다(실제 설치는 수행하지 않음). `core.modules.install` 권한이 필요하며 파일은 최대 50MB 까지 허용됩니다. 설치 모달에서 사용자가 파일 선택 직후 manifest 유효성과 검증 실패 사유를 미리 확인하는 용도이며, 검증 오류 시 422 로 사유를 반환합니다. + + +### POST /api/admin/modules/refresh-layouts + +- **라우트명**: `api.admin.modules.refresh-layouts` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ModuleController@refreshLayouts` +- **인증/권한**: `auth:sanctum` + `permission:core.modules.activate` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| module_name | body | string | 예 | max 255 | module 이름 (식별자) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.module.refresh_layouts_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/modules/refresh-layouts HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "module_name": "예시 이름" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.activate`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 모듈의 레이아웃 파일을 파일에서 다시 읽어 DB 에 동기화합니다. `core.modules.activate` 권한이 필요합니다. 파일에서 변경된 레이아웃은 갱신되고 삭제된 레이아웃은 DB 에서도 제거되며, 갱신된 모듈 정보를 반환합니다. 모듈의 `_bundled` 레이아웃 JSON 을 수정한 뒤 재빌드 없이 반영할 때 사용합니다. + + +### DELETE /api/admin/modules/uninstall + +- **라우트명**: `api.admin.modules.uninstall` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ModuleController@uninstall` +- **인증/권한**: `auth:sanctum` + `permission:core.modules.uninstall` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| module_name | query | string | 예 | max 255 | module 이름 (식별자) | +| delete_data | query | boolean | 아니오 | — | 제거 시 모듈이 생성한 DB 데이터까지 함께 삭제할지 여부 (기본 false — 데이터 보존) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.module.uninstall_validation_rules`). + +**요청 예시** + +```http +DELETE /api/admin/modules/uninstall?module_name=%EC%98%88%EC%8B%9C%20%EC%9D%B4%EB%A6%84&delete_data=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.uninstall`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 모듈을 시스템에서 제거합니다. `core.modules.uninstall` 권한이 필요합니다. 활성 디렉토리만 삭제하고 `_bundled` 원본은 보존합니다. `delete_data: true` 인 경우 모듈이 생성한 DB 데이터까지 함께 삭제하며, 기본값은 데이터 보존입니다. 삭제될 데이터 범위는 사전에 `uninstall-info` 로 확인할 수 있습니다. + + +### GET /api/admin/modules/uninstalled + +- **라우트명**: `api.admin.modules.uninstalled` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ModuleController@uninstalled` +- **인증/권한**: `auth:sanctum` + `permission:core.modules.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/modules/uninstalled HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| identifier | string | `gnuboard7-hello_module` | 모듈 고유 식별자 (vendor-module 형식) | +| vendor | string | `gnuboard7` | 벤더/개발자명 | +| name | string | `Hello 모듈` | 모듈 이름 (다국어 JSON) | +| version | string | `0.1.0` | 모듈 버전 | +| description | string | `학습용 최소 샘플 모듈 (Memo CRUD)` | 모듈 설명 (다국어 JSON) | +| dependencies | array | `[]` | 의존하는 확장 맵 (manifest 파생 — {modules, plugins}) | +| status | string | `uninstalled` | 상태 (active: 활성화, inactive: 비활성화, installing: 설치 중, uninstalling: 제거 중, updating: 업데이트 중) | +| assets | null | `null` | 프론트엔드 에셋 매니페스트 (manifest 파생 — js/css 진입점·로딩 전략) | +| update_available | boolean | `false` | 최신 버전 대비 업데이트 가능 여부 | +| update_source | null | `null` | 업데이트 감지 출처 (github, bundled 등) | +| latest_version | null | `null` | 감지된 최신 배포 버전 | +| file_version | null | `null` | 설치된 파일의 manifest 버전 | +| github_url | null | `null` | GitHub 저장소 URL | +| github_changelog_url | null | `null` | GitHub 변경 내역 URL | +| is_pending | boolean | `false` | _pending 대기소에 있어 설치 대기 중인지 여부 | +| is_bundled | boolean | `true` | 코어에 선탑재된 번들 확장인지 여부 | +| deactivated_reason | null | `null` | 비활성화 사유: manual(사용자 수동) \| incompatible_core(코어 버전 호환성) \| null(active) | +| deactivated_at | null | `null` | deactivated 일시 | +| incompatible_required_version | null | `null` | 요구 코어 버전 미충족 시 필요한 버전 (호환되면 null) | +| abilities | object | `{"can_install":true,"can_activate":true,"can_uninstall":t…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "모듈을 성공적으로 가져왔습니다.", + "data": { + "data": [ + { + "identifier": "gnuboard7-hello_module", + "vendor": "gnuboard7", + "name": "Hello 모듈", + "version": "0.1.0", + "description": "학습용 최소 샘플 모듈 (Memo CRUD)", + "dependencies": [], + "status": "uninstalled", + "assets": null, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": null, + "github_url": null, + "github_changelog_url": null, + "is_pending": false, + "is_bundled": true, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true + } + } + ], + "meta": { + "total_modules": 1, + "active_modules": 0, + "system_modules": 0, + "user_modules": 1, + "total_installs": 0, + "average_rating": null, + "latest_version": "0.1.0", + "categories": [ + null + ], + "dependency_count": 0 + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.read`)이 없는 경우 | + + + +**설명** 아직 설치되지 않은 모듈만 조회합니다(예: 번들로 제공되나 미설치 상태인 샘플 모듈). `core.modules.read` 권한이 필요합니다. 설치 가능한 모듈을 사용자에게 노출하는 화면에서 사용하며, 미설치 항목은 assets·latest_version 등 설치 후에만 채워지는 필드가 null 로 반환됩니다. + + +### GET /api/admin/modules/{identifier}/changelog + +- **라우트명**: `api.admin.modules.changelog` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ModuleController@changelog` +- **인증/권한**: `auth:sanctum` + `permission:core.modules.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | +| source | query | string | 아니오 | `active`, `bundled`, `github` | 변경 내역 조회 출처 (active: 활성 설치본, bundled: 번들 원본, github: 원격 저장소) | +| from_version | query | string | 아니오 | — | 시작 버전 (범위 하한) | +| to_version | query | string | 아니오 | — | 대상 버전 (범위 상한) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_rules`). + +**요청 예시** + +```http +GET /api/admin/modules/sirsoft-ecommerce/changelog?source=active&from_version=%EC%98%88%EC%8B%9C%EA%B0%92&to_version=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| changelog | array | `[{"version":"1.0.1","date":"2026-07-02","categories":[{"n…` | 변경 이력 텍스트 (원격/파일 CHANGELOG 본문) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "모듈을 성공적으로 가져왔습니다.", + "data": { + "changelog": [ + { + "version": "1.0.1", + "date": "2026-07-02", + "categories": [ + { + "name": "Fixed", + "items": [ + "관리자 주문 상세에서 주문상품을 \"구매확정\"으로 변경할 때 구매확정 처리가 고객이 직접 구매확정한 경우와 동일하게 이뤄지지 않던 문제를 수정했습니다 — 이제 구매확정 시점이 정확히 기록되어(부분 수량만 구매확정한 경우 포함), 구매확정 기준으로 지급되는 마일리지 적립이 정상적으로 지급되고, 리뷰 작성 기한도 올바르게 계산됩니다. 기존에는 관리자가 구매확정 처리한 주문상품이 마일리지 적립 대상에서 누락되고 리뷰 작성 기한이 계산되지 않을 수 있었습니다. (#61 @miles44229 님께서 제보해주셨습니다.)", + "주문상품을 여러 번에 나누어 구매확정할 때(예: 2개 중 1개씩 따로 확정) 적립 포인트가 일부만 지급되던 문제를 수정했습니다 — 이제 나누어 확정해도 예정 적립 포인트가 정확한 총액으로 지급됩니다.", + "마일리지 기능을 사용 안 함으로 설정한 뒤에는 지연 적립뿐 아니라 즉시 적립도 지급되지 않도록 통일했습니다 — 기존에는 지급 시점 설정에 따라 어떤 적립은 지급되고 어떤 적립은 지급되지 않을 수 있었습니다." + ] + } + ] + }, + { + "version": "1.0.0", + "date": "2026-07-01", + "categories": [ + { + "name": "Fixed", + "items": [ + "상품의 기본 통화와 다른 통화로 결제한 주문(예: 엔화로 등록된 상품을 원화로 결제)에서 결제·취소 금액의 통화 표기와 실제 환불 금액이 정확해졌습니다 — 주문서·주문 상세·결제 정보·취소 화면에서 금액은 주문 시점 기준 통화(상품 기본 통화)로 표기하고, 고객이 실제 결제한 통화와 그 통화 기준 청구액(예: \"결제 통화 KRW 4,750원 청구\")을 함께 표시합니다. 또한 주문을 취소·환불할 때 환불 금액이 고객이 실제 결제한 통화로 정확히 결제사에 요청되어, 기본 통화와 결제 통화가 다른 주문도 결제한 금액 그대로 환불됩니다(부분취소를 여러 번 나눠 진행해도 누적 환불액이 결제 통화 기준으로 정확히 계산됩니다). 이전에는 결제·취소 금액이 기본 통화 기호로만 고정 표기되고, 환불 요청 금액이 기본 통화 숫자로 전달되어 결제 통화와 단위가 어긋날 수 있었습니다.", + "상품의 기본 통화와 다른 통화로 결제할 때(예: 엔화로 등록된 상품을 원화로 결제) \"결제 금액이 일치하지 않습니다\"라는 오류로 결제가 막히던 문제를 수정했습니다 — 결제창에 청구되는 금액과 주문 승인 시 확인하는 금액의 통화 기준이 어긋나 있었습니다. 이제 결제창 청구액·승인 확인·입금 통보 확인이 모두 고객이 선택한 결제 통화 기준으로 일관되게 검증되어, 기본 통화와 결제 통화가 어떤 조합이든 정상적으로 결제가 진행됩니다.", + "상품에 배송정책을 따로 지정하지 않은 경우(\"기본 배송정책 사용\")에도 기본 배송정책이 그대로 적용됩니다 — 기존에는 배송정책을 지정하지 않은 상품이 해외 배송국가(예: 일본)를 선택하면 기본 배송정책에 그 국가가 설정돼 있어도 \"배송할 수 없는 상품\"으로 안내되고 배송비가 무료로 잡히던 문제가 있었습니다. 이제 상품 상세·장바구니·주문서·주문 처리·배송비 계산 전반에서 기본 배송정책의 국가별 설정과 배송비가 일관되게 반영됩니다. 주문 시점의 기본 배송정책은 주문에 함께 보관되어, 이후 기본 배송정책이 바뀌어도 취소·환불 배송비는 주문 당시 기준으로 계산됩니다.", + "관리자 대시보드의 이커머스 위젯(최신 리뷰·미답변 문의 목록)에서 각 항목이 같은 화면 식별자를 공유해 일부 보조 기능·접근성 도구가 항목을 구분하지 못하던 문제를 수정했습니다 — 이제 목록의 각 행이 고유하게 식별됩니다.", + "헤더의 통화·배송국가 선택기가 데스크톱과 모바일 화면에 동시에 배치되면서 같은 화면 식별자를 공유하던 문제를 수정했습니다 — 두 위치의 선택기가 각각 고유하게 식별됩니다." + ] + }, + { + "name": "Changed", + "items": [ + "상품 등록 화면에서 새로 시작할 때 과세 여부는 \"과세\", 판매상태는 \"판매중\", 전시상태는 \"전시중\"이 기본으로 선택되어 있고, 옵션을 추가하면 재고가 1로 미리 채워집니다 — 가장 흔한 설정이 처음부터 지정되어 매번 직접 고르거나 입력하는 수고를 덜 수 있습니다. 옵션을 일괄 생성하거나 행을 추가·삭제하면 원 상품의 재고가 옵션 재고 합계로 즉시 맞춰집니다(옵션 보유 상품의 재고는 직접 입력하지 않고 옵션 재고 합으로 표시됩니다) — 이전에는 옵션을 만든 직후에는 상품 재고가 0으로 남아 있다가 옵션 재고를 한 번 직접 수정해야 반영되었습니다. 기존 상품을 수정할 때는 저장된 값이 그대로 표시됩니다.", + "상품 등록·수정 화면과 상품 관리 목록에서 옵션별 외화 가격을 직접 입력하던 칸을 없애고, 판매가 아래에 환율로 자동 계산된 외화 가격을 읽기 전용으로 보여주도록 바꿨습니다. 외화 가격은 기본 통화 판매가를 기준으로 환율을 적용해 표시되므로(장바구니·주문서와 동일한 방식), 판매가만 입력하면 설정한 통화들의 환산 금액이 함께 표시됩니다. 표시되는 통화는 쇼핑몰 설정에서 추가한 통화를 그대로 따르며, 통화를 추가하면 자동으로 함께 나타납니다. 상품 관리 목록의 통화별 열과 열 선택 메뉴의 통화 항목, \"다중통화 가격 자동계산\" 켜기/끄기도 더 이상 필요하지 않아 정리했습니다. 판매가 아래에 외화 환산값이 여러 줄로 표시되어도 정가·재고·SKU 등 다른 입력칸이 같은 줄에 가지런히 정렬되도록 행 정렬을 맞췄습니다.", + "쇼핑몰 설정의 마일리지 알림 카드에서 사이트 알림 / 이메일 아이콘이 채널 라벨 위로 올라가 비대칭으로 보이던 회귀를 수정했습니다 — 아이콘이 채널 라벨 옆 가운데로 정렬되어 한 줄로 응집됩니다.", + "쿠폰 등록 / 수정 화면의 직접발급 안내문에서 정보 아이콘이 안내 문구 위로 살짝 떠 보이던 정렬을 바로잡았습니다 — 아이콘이 안내 문구와 세로 가운데로 맞춰집니다.", + "상품 등록 / 수정 화면에서 상단 탭 내비와 본문 콘텐츠가 너무 붙어 보이던 회귀를 수정했습니다 — 탭 내비와 첫 카드 사이에 표준 상단 여백이 자동으로 들어가 다른 관리자 화면과 동일한 결로 통일됩니다.", + "관리자 환경설정·상품·주문·배송정책·쿠폰 화면의 카드 모양과 간격을 다른 관리자 화면과 동일하게 통일했습니다 — 사용자 영역용 카드 자산이 혼용되던 부분을 관리자 표준 카드로 환원하고, 카드 안의 헤더 묶음·본문 묶음을 표준 패턴으로 정리했습니다.", + "관리자 환경설정 페이지의 빈 컨테이너 한 겹을 제거해 다른 관리자 화면과 본문 구조가 동일합니다 — 저장 중 시각 피드백(저장 버튼 안의 회전 아이콘과 \"저장 중...\" 문구)은 그대로 제공됩니다.", + "상품·카테고리의 SEO 제목·SEO 설명을 언어별로 입력할 수 있습니다 — 상품명·상세설명과 동일하게 언어 탭으로 한국어·영어 등 각 언어의 SEO 문구를 따로 작성할 수 있고, 검색엔진이 해당 언어로 접근하면 그 언어의 제목·설명이 노출됩니다. \"상품명과 동일하게 설정\"을 켜면 입력한 언어별 상품명·설명이 그대로 SEO에 반영되고, SEO 문구를 비워 두면 상품명·상세설명으로 자동 대체됩니다. 기존에 입력해 둔 SEO 문구는 기본 언어 값으로 보존됩니다. (이전에는 한 줄 문구만 저장되어 모든 언어에 같은 값이 쓰였고, 입력 중 검색결과 미리보기가 매 글자마다 다시 그려져 타이핑이 느려지던 문제도 함께 해소되었습니다.)", + "배송정책의 통화가 상품 등록과 동일하게 쇼핑몰 기본 통화로 자동 적용됩니다 — 배송정책 등록·수정 화면의 통화 항목이 직접 고르는 칸에서 현재 기본 통화를 보여 주는 읽기 전용 표시로 바뀌었고, 배송비는 항상 기본 통화 기준으로 계산됩니다. 배송비·무료배송 기준금액 입력칸 옆의 통화 표시도 기준 통화 칸과 동일하게 현재 기본 통화로 통일했습니다. 기존에 다른 통화로 저장돼 있던 배송정책도 기본 통화로 통일됩니다. (이전에는 배송정책마다 통화를 따로 지정할 수 있었으나 배송비 계산에는 반영되지 않아 합계가 어긋날 수 있었습니다.)", + "주문서 작성과 장바구니의 배송비가 선택한 배송 국가 기준으로 계산되어 표시됩니다 — 배송정책에 국가별 배송비(예: 국내·미국)를 다르게 설정한 경우, 헤더에서 고른 배송 국가 또는 주문서에서 선택한 배송 국가에 맞는 배송비가 적용됩니다. 주문서에서는 헤더에서 고른 국가가 기본으로 선택되며, 주문서의 배송 국가를 바꾸면 그 즉시 해당 국가 배송비로 다시 계산됩니다. 우편번호를 입력하지 않아도 국가만으로 해당 국가의 기본 배송비가 미리 표시됩니다. (이전에는 미국 등 해외 국가를 선택해도 배송비가 국내 기준으로만 계산되어 표시되던 문제가 있었습니다.)", + "상품 등록·수정 화면의 목록·복사·삭제·저장 버튼을 화면 하단 고정으로 옮기고, 환경설정 화면의 상단 탭과 저장 버튼이 스크롤 중에도 고정되도록 개선했습니다 — 긴 폼을 스크롤하는 동안에도 주요 버튼이 항상 화면에 보입니다. 배송정책·쿠폰·상품·주문 상세 화면에서 페이지 제목이 함께 따라 고정되던 동작은 일반 흐름으로 정리했습니다.", + "카테고리·브랜드 슬러그 수정 모드와 주문 상세의 자동 채움·참조용 입력 칸의 글자를 옅은 회색으로 표시해, 수정할 수 없는 항목임을 한눈에 구분할 수 있습니다.", + "이커머스 관리 전반(상품·주문·결제·배송·환경설정·카테고리·브랜드·쿠폰·마일리지·예치금·리뷰)의 카드·입력 칸·라벨·본문 텍스트·구분선·여백 모양을 다른 관리자 화면과 동일한 결로 통일했습니다 — 화면마다 조금씩 다르게 보이던 카드 모양·간격, 입력 칸 테두리·배경, 글자 톤, 2단 그리드 간격이 한 가지 기준으로 정합되고, 마일리지/예치금 환경설정의 페이지 배경이 누락되어 다르게 보이던 회귀도 함께 해소됩니다.", + "이커머스 관리 화면 곳곳의 역할 없는 빈 컨테이너 중첩을 정리해, 화면 모양은 그대로 둔 채 페이지 구조만 단순해졌습니다.", + "주문 결제 진입이 특정 결제사에 고정되지 않고, 주문을 만들 때 서버 응답이 알려주는 결제 수단으로 진행됩니다 — 결제 플러그인을 바꾸거나 새로 추가해도 주문 화면 수정 없이 해당 결제사의 결제창이 열립니다. 결제 수단을 따로 지정하지 않은 결제(무통장 입금 등)는 기존과 동일하게 동작합니다.", + "코어 최소 요구 버전을 7.0.0-beta.8 로 상향. SEO 자동값 연결 칩 편집 기능이 코어 7.0.0-beta.8 에서 제공하는 편집기 연동 표면을 사용합니다." + ] + }, + { + "name": "Added", + "items": [ + "레이아웃 편집기에서 마일리지 거래내역·쿠폰 목록·쿠폰 생성·주문 목록·주문 상세 화면의 화면 상태를 미리보기로 전환할 수 있습니다 — 목록의 비어 있음, 쿠폰 신규 작성·검증 실패, 주문 상세의 부분 취소 등 특정 상황에서만 나타나는 모양을 편집기에서 골라 미리보고 편집할 수 있습니다.", + "비회원으로 주문하면 주문 접수·입금 확인·배송·취소 등 주요 단계에서 입력한 이메일로 주문 알림이 발송됩니다 — 회원 주문과 동일한 알림을 비회원도 받으며, 메일은 주문할 때 보던 화면 언어로 전달됩니다. 주문 완료 화면에 \"주문 확인 안내를 입력하신 이메일로 보내드렸습니다\"라는 안내가 함께 표시됩니다.", + "회원이 자신의 배송국가를 직접 설정할 수 있습니다 — 해외배송을 켜면 사이트 상단 헤더의 통화 선택기에 배송국가 선택이 함께 표시되고(선택기 버튼에 배송국가 코드와 통화가 나란히 표시), 회원정보 수정·마이페이지·가입 화면에서도 배송국가를 고를 수 있습니다. 로그인한 회원은 선택한 배송국가가 계정에 저장되어 다음 방문에도 유지되고, 비회원은 브라우저에 임시 저장됩니다. 처음 방문 시에는 접속 위치를 추정해 기본 배송국가가 정해지며, 추정이 어려우면 설정한 기본 국가가 사용됩니다. 주문서·주문 상세·주문 내역·배송지 관리에 배송국가가 함께 표시됩니다(국내 포함). 선택한 배송국가로 배송할 수 없는 상품이 장바구니·주문서에 하나라도 있으면 그 상품이 표시되고 주문하기가 막히며, 상품 상세에서도 배송 가능 여부를 미리 확인할 수 있습니다. 해외 주소로 주문하면 도시·우편번호 등 해외 주소가 온전히 저장되고, 주문 알림 메일에도 받는 분·배송국가·배송지가 표시됩니다. 주문을 취소·환불할 때도 그 시점의 배송국가·우편번호가 함께 보관되어, 이후 배송지가 바뀌어도 도서산간·국가별 배송비 환불이 주문 당시 기준으로 정확히 계산됩니다. 해외배송을 끄면 배송국가 선택이 함께 사라집니다.", + "사이트 상단 헤더에 통화 선택기를 추가했습니다 — 이커머스 모듈을 켜면 헤더에 통화 전환 버튼이 나타나고, 관리자가 등록한 통화(환율이 설정된 통화) 중에서 표시 통화를 고를 수 있습니다. 로그인한 회원은 선택한 통화가 계정에 저장되어 다음 방문에도 유지되고, 비회원은 브라우저에 임시 저장됩니다. 관리자 화면 헤더에서도 동일하게 표시 통화를 전환할 수 있습니다. 모듈을 끄면 통화 선택기가 함께 사라집니다. 레이아웃 편집기 미리보기에서도 헤더의 통화·배송국가 선택기가 실제 화면과 동일하게 표시되어, 편집 중 위치와 모양을 미리 확인할 수 있습니다(사용자·관리자 템플릿 공통).", + "기본 통화가 원(KRW)이 아닌 통화(달러·유로 등)일 때도 상품을 등록·수정할 수 있습니다 — 기본 통화가 소수점을 쓰는 통화이면 상품 정가·판매가·옵션 가격과 추가옵션 추가금을 3.30 처럼 소수점까지 입력할 수 있고, 통화별 소수 자릿수(예: 달러 2자리)를 초과하는 입력은 막습니다. 원·엔처럼 소수점이 없는 통화에서는 정수만 허용됩니다. 기존에는 가격이 정수로만 저장되어 기본 통화를 달러 등으로 바꾸면 새 상품 저장이 되지 않았습니다. 아울러 상품·옵션·주문·결제·환불·쿠폰·마일리지·배송비·대시보드 매출 등 금액이 표시되는 화면 전반에서, 소수점이 없는 통화(원·엔 등)인데도 `200.00`처럼 불필요한 소수점이 붙던 부분을 정리해 — 모든 금액이 쇼핑몰 설정의 통화별 소수 자릿수를 그대로 따라 표시됩니다(소수점 없는 통화는 `200`, 달러·유로 등은 `1.70`).", + "기본 통화가 원(KRW)이 아닌 통화(달러 등)일 때 상품·장바구니·주문의 외화 환산 금액이 0 또는 비정상값으로 표시되던 문제를 해결했습니다 — 통화마다 \"환율 입력의 기준 단위\"(예: 원은 1,000원당, 달러·엔 등은 1단위당)를 설정에 두어, 어떤 통화를 기본으로 삼든 외화 가격이 올바르게 환산됩니다. 환율/통화 설정 화면에서 통화마다 \"기준 단위\"를 직접 입력·수정할 수 있고, 환율 입력 안내도 기본 통화의 기준 단위에 맞춰 \"USD 1 =\", \"KRW 1,000 =\"처럼 표시됩니다. 기준 단위·환율을 잘못 입력해 저장하면 해당 입력칸이 빨갛게 강조되고 칸 아래에 사유가 표시됩니다. 이미 접수된 주문의 환불 금액은 주문 당시 환율 그대로 유지됩니다.", + "상품 등록·수정 시 입력 오류 안내에 \"판매가\"·\"옵션 판매가\"처럼 사람이 읽을 수 있는 항목 이름이 표시됩니다 — 기존에는 일부 항목에서 내부 식별자(예: options.0.selling_price)가 그대로 노출됐습니다.", + "쇼핑몰 설정의 언어·통화 화면에서 통화마다 표시 기호를 직접 지정할 수 있습니다 — 통화 추가·수정 시 \"기호\" 칸에 ₩·$·¥·€ 등 원하는 기호를 입력하면, 상품 등록·수정 화면의 기본 통화 표시와 판매가 일괄 변경 창의 금액 단위, 상품 복사 미리보기의 가격이 모두 그 기호로 표시됩니다. 기존 통화(원·달러·엔·위안·유로)에는 표준 기호가 기본으로 채워지며, 통화를 추가·삭제하거나 기본 통화를 바꾸면 상품 관리 화면의 기호 표시가 설정을 그대로 따라갑니다. 위안화(CNY)는 엔화(JPY)와 같은 ¥ 기호를 쓰면 가격을 혼동할 수 있어 元 기호를 기본으로 사용합니다 — 장바구니·상품·주문 화면에서 엔화는 ¥, 위안화는 元 로 구분되어 표시됩니다. 아울러 장바구니·상품·주문서·주문 상세·주문 내역·영수증·배송정책 등 금액이 표시되는 화면 전반에서 통화 기호가 원화로 고정되어 있던 부분을 정리해, 기본 통화를 달러·엔 등으로 바꾸면 그 통화 기호로 표시됩니다(상품 옵션 추가금·과세금액 표기 포함). 이미 접수된 주문의 금액은 주문 시점의 통화로 계속 표시되어, 이후 기본 통화를 바꿔도 과거 주문 표기가 달라지지 않습니다. (이전에는 상품 관리 일부 화면이 통화 설정과 무관하게 원화 기호로 고정 표시되었고, 엔화와 위안화가 모두 ¥ 로 표시되어 구분되지 않았습니다.)", + "관리자 대시보드에 이커머스 영역이 추가되었습니다 — 이커머스 모듈을 켜면 상단에 상품·주문·리뷰·쿠폰·배송 바로가기 버튼이 나타나고, 오늘 주문 현황(결제완료·준비중·배송중·취소 등) 배지, 최근 7일 판매 추세 그래프(판매 수량·매출액과 직전 7일 대비 증감율), 최신 리뷰, 미답변 문의 위젯이 실제 데이터로 표시됩니다. 모듈을 끄면 이 영역과 버튼이 함께 사라집니다.", + "대시보드 판매 현황은 매시간 집계되어 빠르게 표시됩니다 — 판매 수량과 매출은 결제 완료 이후(배송보류·준비중·배송준비·배송중·배송완료·구매확정) 주문상품을 기준으로 집계하며, 취소된 수량은 자동으로 차감되고 주문일 기준으로 날짜에 반영됩니다.", + "마이페이지 취소 주문 상세에서 \"재주문\" 기능 추가 — 과거 주문의 상품 옵션을 현재 장바구니에 일괄 추가, 품절/단종 상품은 사유와 함께 안내", + "비회원 주문 지원 — 로그인 없이 주문/결제하고, 주문번호·휴대폰·조회 비밀번호(8자 이상)로 본인 확인 후 30분간 유효한 토큰으로 주문을 조회할 수 있습니다. 회원/비회원은 주문 단계에서 자동 분기됩니다.", + "비회원 주문 상세에서 주문 취소·환불 예상·구매확정과 배송 전 배송지 변경(직접 입력)을 회원과 동일하게 이용할 수 있으며, 상품 합계·할인·배송비·총액의 다중 통화 금액도 함께 표시됩니다.", + "상품 \"구매 대상 제한\"을 실제 주문 단계에 적용 — 허용 역할에 없는 사용자(비회원 포함)는 제한 상품을 주문할 수 없으며, 주문서 진입과 결제 시점 양쪽에서 안내됩니다. 기존에는 설정만 저장되고 구매를 막지 않았습니다.", + "관리자가 주문 목록에서 회원/비회원을 구분해 조회하고, 비회원 주문의 조회 비밀번호를 재설정할 수 있습니다.", + "관리자 주문 상세의 주문상품 표에서 상품별 재고 차감 여부(\"재고 차감\"/\"재고 미차감\")를 구매수량과 함께 확인할 수 있습니다.", + "주문이 취소된 경우 관리자 주문 상세에 취소 사유가 표시됩니다 — 취소 유형(전체/부분)과 사유, \"기타\" 선택 시 입력한 상세 사유, 취소 일시를 취소 건별로 확인할 수 있습니다. 부분 취소가 여러 번 이뤄진 주문은 각 취소 건이 최근 순으로 모두 나열됩니다.", + "상품·장바구니·주문·찜·문의 등 쇼핑 화면을 샘플 데이터로 미리보고 편집할 수 있습니다 — 재고 있음/품절, 목록 있음/없음, 장바구니 비었음 같은 화면 상태도 미리보기로 전환해 볼 수 있습니다.", + "화면 상태 미리보기를 더 많은 쇼핑 화면으로 넓혔습니다 — 상품의 품절·판매 중지·판매 예정, 상품 목록의 접근 제한, 장바구니 주문 처리 중, 결제 오류, 주문 완료의 결제수단별 안내(카드·가상계좌·무통장), 재주문 진행/일부/실패와 함께, 관리자의 상품·쿠폰·배송정책 폼(신규/수정/검증 실패)과 쇼핑몰 설정의 모든 탭(기본 정보·언어 통화·SEO·주문·취소 반품·배송·리뷰·알림 정의·본인인증)·마일리지/예치금 탭을 전환해 미리볼 수 있습니다.", + "주문서(결제) 화면의 입력 검증 실패 상태를 미리보기에 추가 — 주문자 이름·연락처·이메일, 받는 분 이름·연락처, 우편번호·주소 입력칸에 빨간 오류 안내가 표시된 화면을 전환해 미리볼 수 있습니다.", + "비회원 주문 조회 화면을 회원/비회원 상태로 전환해 미리볼 수 있습니다 — 비회원 상태에서는 주문 조회 입력 폼을, 회원 상태에서는 마이페이지 안내를 각각 편집할 수 있습니다.", + "데이터 소스 목록에서 이 확장이 제공하는 데이터 소스가 친화 명칭으로 표시되고, 어느 확장이 제공했는지 출처가 함께 표시됩니다.", + "[검색엔진] 탭에서 상품·카테고리의 공유 이미지·제목·구조화 데이터 등 이 확장이 제공하는 SEO 자동값이 \"상품 이름\"·\"상품 대표 이미지\" 같은 데이터 연결 칩(출처 표시 포함)으로 보이고, 다른 데이터로 바꿔 지정할 수 있습니다.", + "주문 구매확정 시 결제 금액에 적립률을 적용해 마일리지를 자동 적립하고, 결제 시 보유 마일리지를 사용할 수 있습니다. 적립·사용·소멸 내역이 회원별로 원장에 기록됩니다.", + "마일리지로 결제 금액 전액을 충당하면 별도 결제 절차 없이 주문이 즉시 결제완료 처리됩니다.", + "마일리지 차감 시점을 결제수단별로 \"결제완료 시\" 또는 \"주문접수 시\" 중에서 선택할 수 있습니다. 무통장입금·가상계좌는 입금 전 마일리지가 재사용되지 않도록 주문접수 시 차감이 기본이고, 신용카드(PG)는 결제창을 닫거나 결제에 실패한 주문에서 마일리지가 선차감되어 사라지지 않도록 결제완료 시 차감이 기본입니다. 차감된 뒤 결제가 실패하거나 주문을 취소한 경우에는 사용 마일리지가 자동으로 복원됩니다. 마일리지 사용을 꺼 두면 결제수단별 차감 시점 설정은 비활성화됩니다.", + "마일리지 사용 시 먼저 적립된 분부터 차감(선입선출)되며, 주문 취소·부분 취소 시 사용분 복원과 적립 회수가 정확히 재계산됩니다.", + "무통장입금 주문 접수 시 마일리지 차감 후 실제 입금할 금액과 입금 계좌·기한을 담은 입금 안내가 자동 발송됩니다.", + "통화별 마일리지 규칙(적립률·사용 한도·최소 사용 금액·소멸 기간)을 설정할 수 있으며, 다중 통화 환경에서 통화별로 독립 적용됩니다.", + "적립 마일리지에 유효기간을 두어 만료분이 자동 소멸하며, 소멸 예정 마일리지를 회원에게 사전 안내할 수 있습니다.", + "회원 탈퇴 시 보유 마일리지가 정리되어 잔여가 남지 않습니다.", + "환경설정에 \"마일리지\" 탭이 추가되어 기능 사용 여부·기본 적립률·통화별 규칙·유효기간·소멸 알림을 한 화면에서 관리합니다. 통화 추가가 미확정인 상태로는 저장이 차단되고, 적립률 0%·잘못된 통화 코드 등 잘못된 입력은 저장 전 안내됩니다.", + "관리자 \"마일리지 내역\" 화면에서 회원·유형·통화·기간으로 거래를 조회하고, 특정 회원에게 마일리지를 수동 지급/차감하거나 선택한 적립분의 유효기간을 일괄 연장할 수 있습니다. 다른 관리자 목록 화면(주문/리뷰)과 동일한 필터·페이지네이션·표 구조로 정렬했습니다.", + "각 적립 내역의 \"사유·기간 변경\" 메뉴에서 적립 사유와 만료일을 수정할 수 있습니다. 회원·금액·유형·적립일은 변경할 수 없도록 함께 표시되며, 이미 소멸되었거나 모두 사용된 적립분은 만료일을 바꿀 수 없습니다.", + "마일리지 내역 표에 \"유효기간\" 열이 추가되어 적립분의 만료 예정일, 무기한 지급 여부, 그리고 소멸된 경우 \"전체 소멸\"/\"일부 소멸\" 여부와 소멸된 금액을 한눈에 확인할 수 있습니다.", + "거래 행을 펼치면 표시되는 연결 거래에 관련 주문번호(클릭 시 주문 상세 열람)와 지급한 관리자(또는 시스템 발생) 정보가 함께 표시됩니다.", + "수동 지급 시 적립분의 유효기간을 \"정책 따름\" 또는 \"직접 지정\"(날짜 선택) 중에서 명확한 안내와 함께 선택할 수 있습니다.", + "주문 관련 모든 화면에서 마일리지 사용액·적립 예정액이 일관되게 표시됩니다 — 주문서 결제 요약, 주문완료 화면, 회원/비회원 주문 상세, 마이페이지·관리자 주문 목록에서 사용·적립 마일리지를 확인할 수 있으며, 관리자 주문 상세에는 환불된 마일리지도 함께 표시됩니다.", + "관리자 주문 상세의 주문상품 표에서 상품별 적립 예정액 옆에 실제 적립 여부(\"적립완료\"/\"적립예정\")를 함께 확인할 수 있습니다.", + "장바구니에 담긴 상품이 설정한 보관기간이 지나면 자동으로 정리됩니다. 환경설정의 장바구니 보관기간(일)에 따라 마지막 활동 이후 기간이 지난 항목을 매일 자동 삭제하며, 보관기간을 0(또는 비움)으로 두면 자동 삭제하지 않습니다.", + "배송정책 계산 API의 요청 참고 필드를 시스템이 지원하는 항목(배송정책 ID·국가 코드·주문 항목·그룹 합계 금액·총 수량) 중에서 선택하도록 개선했습니다. 자유 입력으로 인해 지원하지 않는 필드명이 조용히 무시되던 문제를 막고, 선택하지 않으면 모든 항목을 전송합니다.", + "배송정책 계산 API 연동을 외부 API 규격에 맞춰 세부 설정할 수 있도록 확장했습니다.", + "요청 방식을 GET 또는 POST로 선택할 수 있습니다.", + "인증이 필요한 API를 위해 Bearer 토큰 또는 커스텀 헤더(API 키 등)를 지정할 수 있습니다. 저장한 토큰은 안전하게 보관되며 화면에는 가려져 표시됩니다.", + "요청 항목별로 외부 API가 요구하는 필드명을 따로 지정(매핑)할 수 있어, 항목 이름이 다른 API에도 맞출 수 있습니다.", + "응답이 JSON일 때 배송비 값의 위치를 점(.)으로 중첩 경로(예: data.shipping.fee)까지 지정할 수 있고, 텍스트로 응답하는 API는 통화기호·콤마가 섞여 있어도 금액을 인식합니다.", + "\"테스트 호출\" 버튼으로 입력한 설정 그대로 실제 API를 한 번 호출해 요청 내용과 응답·추출된 배송비를 즉시 확인할 수 있습니다.", + "무통장입금 주문의 입금을 관리자가 직접 확인할 수 있는 \"입금확인\" 기능이 추가되었습니다 — 주문 상세 결제정보에서 입금자명과 입금액을 입력해 입금을 기록하며, 입금액이 결제예정금액과 정확히 일치할 때만 처리됩니다. 입금확인 버튼은 무통장입금이면서 입금이 아직 확인되지 않은 결제 건에 표시됩니다. \"주문을 결제완료로 처리\" 체크박스를 함께 제공해, 체크하면 입금 기록과 함께 주문을 결제완료로 변경(마일리지 적립·재고 차감·알림 발송)하고, 체크하지 않으면 입금 내역만 기록합니다. 이미 결제완료된 주문에 추가 금액(교환·반품 배송비 등)을 별도로 입금받아 기록하는 경우에 활용할 수 있습니다.", + "\"배송 완료\" 알림이 추가되었습니다 — 주문이 배송완료 상태로 바뀌면 구매자에게 택배사·운송장번호와 함께 배송완료 안내(메일·알림함)가 발송됩니다.", + "상품 등록·수정 화면의 기타정보에서 새 라벨을 바로 추가할 수 있습니다 — \"라벨 추가\" 버튼으로 이름·색상을 입력해 등록하면 곧바로 해당 상품에 연결됩니다. 같은 모달에서 기존 라벨 수정도 가능합니다.", + "추가옵션 선택지에 \"직접입력 허용\"을 켤 수 있습니다 — 켜면 구매자가 그 선택지를 골랐을 때 각인 문구처럼 원하는 내용을 직접 입력해 주문할 수 있고, 입력한 내용은 장바구니·주문서·주문 상세에 함께 표시되며 주문 시점 그대로 보존됩니다. 필수 추가옵션의 직접입력 선택지는 내용을 입력해야 담기·주문이 진행되고, 필수가 아닌 추가옵션은 비워 두어도 됩니다. 직접입력 내용이 다르면 장바구니에서 별도 항목으로 담깁니다.", + "추가옵션을 선택한 주문이 마이페이지 주문 내역과 관리자 주문 관리 목록에도 대표 옵션과 함께 추가옵션 요약(\"첫 추가옵션 외 N건\")으로 표시됩니다 — 직접입력한 내용도 함께 병기됩니다. 이전에는 목록에서 대표 옵션만 보여 추가옵션 주문 여부를 알 수 없었습니다.", + "추가옵션 그룹·선택지 등록 시 입력 검증 안내가 정확한 우리말 안내로 표시됩니다 — 선택지를 등록하지 않은 빈 그룹을 저장하면 영문 키 대신 \"각 추가옵션 그룹에는 선택지를 1개 이상 등록해주세요.\" 안내가 해당 그룹 영역 강조와 함께 표시됩니다.", + "관리자가 쿠폰 목록에서 회원을 검색·선택해 쿠폰을 즉시 발급할 수 있습니다 — 회원 이름·이메일을 입력하면 실시간으로 검색되며, 발급 한도에 도달한 회원은 자동으로 제외되고 발급 결과를 안내합니다.", + "관리자가 쿠폰 발급 내역에서 발급된 쿠폰의 사용 여부와 사용된 주문(주문번호 바로가기)을 확인할 수 있습니다.", + "관리자가 아직 사용하지 않은 발급 쿠폰을 발급취소할 수 있습니다 — 취소하면 해당 발급분이 회수되어 회원 쿠폰함에서 사라지고 발급 수량이 복원됩니다. 이미 사용했거나 만료·취소된 발급 건은 취소할 수 없습니다.", + "장바구니·주문서 작성 화면에 정가(취소선)·할인율(%)·판매가가 함께 표시되며, 보조 통화를 선택하면 정가도 같이 환산되어 보입니다.", + "회원이 마이페이지에서 결제 통화를 직접 설정할 수 있으며, 관리자도 회원 편집 화면에서 회원별 결제 통화를 지정할 수 있습니다. 로그인하면 헤더에서 임시로 고른 통화 대신 계정에 저장된 통화가 우선 적용됩니다.", + "회원 가입 시 브라우저 언어/지역에 맞는 결제 통화가 자동으로 지정됩니다 — 환경설정의 통화별 사용 언어 매핑을 따르며, 매핑이 겹치거나 없으면 기본 통화가 지정됩니다. 관리자는 환경설정에서 통화별 사용 언어를 여러 개 지정할 수 있습니다.", + "상품 또는 주문이 한 건이라도 등록된 후에는 데이터 정합성을 위해 기본 통화를 변경할 수 없습니다.", + "상품 목록·검색·인기 상품, 상품 상세의 옵션 합계 금액, 상품 상세 쿠폰, 체크아웃의 쿠폰·배송비 쿠폰 금액이 모두 선택한 통화로 환산되어 표시됩니다 — 그동안 이 영역들이 기본 통화(원)로 고정되어 보이던 문제를 바로잡았습니다. 헤더에서 통화를 바꾸면 상품 목록 가격도 즉시 함께 바뀝니다.", + "마일리지 \"통화별 사용 단위\" 설정에서 통화는 언어·통화 설정에 등록된 통화 중에서만 선택할 수 있고, 기본 통화에 대한 사용 단위가 설정되지 않으면 결제 시 마일리지를 사용할 수 없습니다.", + "주문 시 선택한 배송 메모가 내부 코드 대신 안내 문구(라벨)로 저장·표시됩니다 — 마이페이지 주문 상세와 관리자 주문 상세에서 \"경비실에 맡겨주세요\" 같은 문구가 그대로 보이며, 직접 입력한 자유 메모는 입력한 내용이 보존됩니다.", + "주문 상세의 배송 현황에서 운송장 번호를 누르면 택배사 배송조회 페이지가 새 창으로 열립니다 — 운송장 정보가 없으면 기존처럼 번호만 표시됩니다.", + "결제창으로 넘어가는 청구 금액이 결제 통화로 환산되어 전달됩니다 — 결제창에는 주문 시점 환율로 환산한 결제 통화 금액(화면에 표시된 총 결제금액과 동일한 값)이 전달되며, 달러처럼 소수점을 쓰는 통화는 결제대행사 규격에 맞춰 청구됩니다. 결제할 수 없는 통화(환율이 설정되지 않은 통화 등)로는 주문 단계에서 안내와 함께 결제가 차단됩니다. 무통장입금 안내액도 같은 기준으로 결제 통화 금액으로 안내됩니다.", + "환율/통화 설정에서 통화마다 \"사용 언어\"를 사이트가 지원하는 언어 중에서 지정할 수 있습니다 — 방문자가 보는 사이트 언어에 맞춰 표시 통화를 자동으로 정하는 데 쓰입니다. 로그인하지 않은 방문자도 현재 사이트 언어에 해당하는 통화로 가격이 표시되며(예: 영어로 보는 방문자에게는 영어로 지정된 통화), 회원 가입 시에도 같은 기준으로 결제 통화가 자동 지정됩니다. 방문자가 헤더에서 직접 고른 통화가 있으면 그 선택이 우선합니다. 처음 제공되던 통화 중 위안·유로에 지원하지 않는 언어가 들어 있어 통화 설정을 저장하면 \"설정 저장에 실패했습니다\"로 막히던 문제를 해결했습니다 — 기본값을 원은 한국어, 그 외 통화는 영어로 정리하고, 기존에 저장된 설정에서도 지원하지 않는 언어를 자동으로 정리합니다(직접 지정한 지원 언어는 그대로 유지). 지원하지 않는 언어를 선택해 저장하면 해당 입력칸이 빨갛게 강조되고 칸 아래에 \"사용 언어\" 항목명으로 사유가 표시됩니다.", + "쇼핑몰 본인인증 정책 편집 화면에서 \"인증 목적\"과 \"적용 대상\"을 바꿔 저장해도 반영되지 않던 문제를 수정했습니다 — 이제 정책 키·강제 시점·강제 위치를 제외한 모든 항목(인증 목적·적용 대상 등)을 자유롭게 변경할 수 있으며, 변경한 값은 업데이트 후에도 유지됩니다.", + "쇼핑몰 환경설정의 본인인증 정책 탭에서 출처 필터(전체/코어/모듈/플러그인)를 골라도 결과가 항상 비어 보이던 문제를 해소했습니다 — 이 탭은 쇼핑몰 정책만 보여주는 화면이므로, 동작하지 않던 출처 필터를 정리했습니다. 강제 시점 필터와 검색은 그대로 사용할 수 있으며, \"이 모듈이 등록한 본인인증 목적\" 안내는 그대로 표시됩니다.", + "데이터베이스 테이블 접두사를 기본값(`g7_`)보다 길게 설정한 환경에서 쇼핑몰 모듈 설치가 실패하던 문제를 수정했습니다 — 배송정책 관련 일부 테이블의 인덱스 이름이 데이터베이스 한도를 넘겨 설치가 중단되던 결함으로, 접두사 길이와 관계없이 설치가 정상 완료됩니다.", + "할인이 적용되지 않은 주문 항목의 금액이 주문 상세에서 표시되지 않던 문제를 수정했습니다 — 할인이 없는 항목도 소계 금액이 정상적으로 보입니다.", + "주문을 취소한 뒤에도 관리자 주문 상세·주문 목록에 \"적립 예정\" 마일리지가 계속 표시되던 문제를 수정했습니다 — 전체 취소·부분 취소된 주문에는 적립 예정 표시가 더 이상 나타나지 않습니다(실제 적립은 종전에도 발생하지 않았으며 표시만 정리되었습니다).", + "다른 언어를 쓰는 회원에게 보내는 알림(이메일·사이트 알림)이 받는 사람 본인의 언어로 발송됩니다 — 관리자가 한국어 화면에서 영어 회원의 주문 상태를 바꿔도 알림은 영어로 전달됩니다.", + "상품 일괄 적용 확인 창의 옵션 변경 요약에서 \"안전재고\"·\"사용\" 항목이 영어 화면에서도 올바른 언어로 표시됩니다 — 그동안 한국어로 고정 표시되던 문제를 바로잡았습니다.", + "공통정보 관리의 다국어 탭 저장이 정상 동작합니다 — 한국어 탭에 입력하고 저장할 때 \"한국어 필수\" 오류가 뜨거나, 영문 탭으로 옮긴 뒤 저장하면 공통정보명이 비워지던 문제를 해소했습니다.", + "주문 이력이 있는 상품을 삭제하려 하면 \"N건의 주문 이력이 있어 삭제할 수 없습니다\"라고 사유가 표시되고, 삭제 확인 창이 목록 화면과 동일하게 삭제 가능 여부를 먼저 확인해 불가 시 안내와 함께 버튼이 비활성화됩니다. 삭제에 성공한 뒤 잘못된 오류 안내가 뜨던 문제도 함께 해소했습니다.", + "장바구니 선택 삭제·전체 삭제 후 안내 문구에 실제 삭제 개수가 표시됩니다 — 그동안 \"{개수}개 상품이 삭제되었습니다\"처럼 자리표시자가 그대로 노출되던 문제를 바로잡았습니다.", + "통화 선택 메뉴가 관리자가 설정한 통화 목록을 표시하도록 수정했습니다 — 그동안 목록이 비어 통화를 고를 수 없던 문제를 바로잡았고, 환율이 설정되지 않은 통화는 메뉴에서 자동으로 숨겨집니다. 기본 화폐를 바꾸면 사용자 화면의 가격·합계·쿠폰 금액이 선택한 통화로 환산되어 표시됩니다.", + "관리자가 통화 설정을 저장할 때 일부 통화의 환율 정보가 사라지던 문제를 수정했습니다 — 빠진 통화가 기본 카탈로그에서 자동으로 보충됩니다.", + "쿠폰 다운로드 모달에서 정액 할인·최소주문금액이 선택한 통화로 환산되어 표시됩니다 — 그동안 기호만 바뀌고 금액은 환산되지 않던 문제를 바로잡았습니다(정률 할인은 통화와 무관하게 % 그대로 표시).", + "\"언어·통화\" 설정에서 기본 언어 항목을 제거하고, 사이트 언어는 일반 설정에서 일원화하여 관리합니다.", + "결제·결제 통지·부분 환불 금액이 사이트 기본 통화를 기준으로 산정되며, 선택 통화는 주문 시점 환율로 환산된 금액으로 표시·청구됩니다. 환율이 바뀌어도 기존 주문의 환불 금액은 주문 당시 환율로 계산되어 환차손이 없습니다.", + "신용카드(PG) 주문의 관리자 신규주문 알림을 결제 완료 시점에 보내도록 바꿨습니다 — 그동안 결제 전(주문서 생성) 시점에 알림을 보내면서 결제를 마치지 않은 주문도 알림이 발송되고, 알림 발송이 결제하기 응답을 지연시키던 문제가 함께 해소되었습니다. 무통장입금 주문은 기존대로 주문 접수 시점에 입금 안내가 발송됩니다.", + "상품 관리 목록에서 옵션명만 인라인으로 바꾼 뒤 일괄 적용하면 \"일괄 업데이트에 실패했습니다\"로 저장되지 않던 문제 수정 — 한국어 옵션명만 입력하고 영어·일본어를 비워 둔 옵션도 정상 저장됩니다. 또한 일괄 적용이 실패하면 어떤 항목이 왜 거부됐는지 확인 모달과 알림에 구체적인 사유가 표시됩니다.", + "상품 수정 화면에서 이미지를 추가하고 저장하면, 업로드는 됐는데도 저장 직후 새로고침하면 추가한 이미지가 모두 사라지던 문제 수정 — 이미지를 추가하고 저장할 때 그 이미지들이 상품에 정상 반영됩니다. 기존 이미지가 있는 상태에서 추가, 모두 지우고 다시 첨부, 추가 후 순서 바꾸기 모두 저장한 그대로 유지됩니다.", + "상품 관리 목록에서 기본으로 설정한 옵션의 판매가를 바꿔도 상품 판매가가 따라 바뀌지 않던 문제 수정 — 상품 등록·수정 화면과 동일하게, 기본 옵션의 판매가를 바꾸면 상품 판매가가 함께 맞춰지고 나머지 옵션 판매가도 가산액 기준으로 다시 계산됩니다.", + "상품 등록·수정 화면에서 추가옵션의 옵션명을 입력할 때 글자 입력이 버벅이던 문제 수정 — 입력이 끝난 뒤 잠깐 후에 반영되도록 바꿔 타이핑이 매끄러워집니다.", + "상품 등록·수정 화면의 추가옵션 \"필수\" 선택을 체크박스에서 켜기/끄기 토글로 바꿔, 옵션의 사용/노출 토글과 동일한 조작감으로 통일했습니다.", + "쿠폰 정액 할인값에 음수를 입력하면 정률(%) 전용 안내 문구가 잘못 표시되던 문제와, 최소주문금액을 비우고 저장하면 오류가 나던 문제를 수정했습니다 — 정액 할인값은 1원 이상으로 안내되고, 최소주문금액을 비우면 \"제한 없음\"으로 저장됩니다.", + "쿠폰 적용 대상을 \"특정 상품\" 또는 \"특정 카테고리\"로 고른 뒤 대상을 하나도 선택하지 않아도 저장되던 문제를 수정했습니다 — 대상을 1개 이상 선택해야 저장됩니다.", + "쿠폰 등록·수정 화면의 상품 검색이 입력한 검색어를 무시하고 전체 상품을 보여주던 문제를 수정했습니다.", + "\"특정 카테고리\" 쿠폰이 해당 카테고리에 속하지 않은 상품에도 노출·적용되던 문제를 수정했습니다 — 쿠폰의 카테고리와 상품의 카테고리가 일치할 때만 적용됩니다.", + "쿠폰 목록 검색에서 검색 필드(쿠폰명/설명/등록자)를 골라도 항상 전체가 검색되던 문제와, 상세 필터 선택이 주소(URL)로 복원될 때 상세 필터 영역이 펼쳐지지 않아 선택이 보이지 않던 문제를 수정했습니다.", + "상품 상세의 쿠폰 다운로드 모달을 무한 스크롤로 개선했습니다 — 페이지 전환 버튼 없이 목록을 아래로 내리면 다음 쿠폰을 한 묶음씩 이어서 불러오고(불러오는 동안 \"불러오는 중\" 표시), 전체를 다 불러오면 멈춥니다.", + "상품 등록·수정 화면의 \"공통정보\" 선택 목록에 비활성 처리한 공통정보까지 나타나던 문제 수정 — 활성 상태인 공통정보만 선택 대상으로 표시됩니다. 상품정보제공고시 템플릿과 동일하게 동작합니다.", + "신규 주문 접수 시 관리자에게 발송되는 알림 메일에서 결제금액이 0원으로 표시되던 문제 수정 — 결제 예정 금액이 정확히 표시됩니다.", + "관리자 주문 상세의 결제정보에서 \"결제요청일시\"가 비어 있고, 무통장입금의 입금 계좌(은행·계좌번호·예금주) 요약이 표시되지 않던 문제 수정 — 주문 접수 시 결제 요청 시각과 구매자 정보가 기록되며, 무통장입금 주문에 입금 계좌 요약이 표시됩니다.", + "신용카드(PG) 결제가 완료된 뒤에도 주문 상세·마이페이지의 개별 상품 \"주문상태\"가 \"주문대기\"로 남던 문제 수정 — 결제가 완료되면 주문 상품 상태도 함께 결제완료로 표시됩니다. 결제 금액 전액을 마일리지로 충당한 주문도 동일하게 반영됩니다.", + "관리자가 주문 상세에서 주문 상태를 변경할 때 주문 전체 상태만 바뀌고 개별 상품 상태는 이전 상태로 남던 문제 수정 — 상품 상태도 함께 변경됩니다. 이미 취소된 상품은 변경 대상에서 제외되어 그대로 유지됩니다.", + "결제 실패·결제창 닫힘으로 주문이 취소될 때 개별 상품 상태가 \"주문대기\"로 남던 문제 수정 — 주문이 취소되면 상품 상태도 함께 취소로 정리되며, 이미 부분취소된 상품은 그대로 보존됩니다. 그동안 상품 상태가 주문대기에 갇혀 구매확정·리뷰 작성이 막히던 연쇄 문제도 함께 해소됩니다.", + "신용카드 결제가 정상 완료됐는데도 주문 상세에서 주문은 \"결제완료\"이면서 개별 상품만 \"주문취소\"로 표시되던 문제 수정 — 결제 승인과 결제창 닫힘 처리가 겹칠 때 결제가 이미 완료된 주문은 취소 처리하지 않도록 바로잡아, 결제완료 주문의 상품이 임의로 취소 상태가 되지 않습니다.", + "관리자가 주문 상태를 결제완료·배송중·배송완료·구매확정으로 바꿔도(단건/목록 일괄/운송장 일괄 포함) 구매자에게 해당 알림이 발송되지 않던 문제 수정 — 모든 상태변경 경로에서 결제완료·배송중·배송완료·구매확정 알림이 누락 없이 발송됩니다. 이미 같은 상태인 주문을 다시 저장할 때는 중복 발송되지 않습니다.", + "결제 완료 시 발송되는 알림의 제목·본문이 \"주문 확인\"으로 표시되어 결제 완료 시점과 의미가 어긋나던 문구를 \"결제 완료\"로 명확히 정정했습니다.", + "본인인증 정책(결제 시 확인·무통장 입금확인·결제 승인·결제 취소)을 활성화해도 실제 액션 시 본인인증이 요구되지 않던 문제 수정 — 4종 정책 모두 실제 결제·취소·입금확인·주문 진입 시점에 정상 동작합니다.", + "상품 \"구매 대상 제한\" 설정 화면에서 제한으로 전환할 때 이전 역할이 빈 칩으로 남던 문제와, 역할이 많을 때 일부 역할(관리자 등)이 선택 목록에서 누락되던 문제 수정", + "회원 주문 상세에서 \"배송 메모\"·\"배송 현황\" 영역이 표시되지 않던 문제 수정 — 회원/비회원 모두 동일한 배송 정보가 표시됩니다.", + "상품 상세 페이지의 검색엔진용 구조화 데이터(Schema.org Product/Offer)에서 재고 상태가 실제 판매 상태와 무관하게 항상 \"품절(OutOfStock)\"로 출력되던 결함 수정. 판매 중인 상품은 \"재고 있음(InStock)\", 판매 중지·예정 상품은 \"품절\"로 정확히 출력됩니다.", + "레이아웃 편집기 라우트 트리에서 상품정보제공고시 화면이 잘못된 다국어 키 참조로 라벨이 비어 보이던 결함 수정. 정확한 화면명(\"상품정보제공고시 관리\")이 표시됩니다.", + "레이아웃 편집기 라우트 트리에서 상품 등록 단축 경로(`/admin/ecommerce/product-create`) 가 다국어 라벨 없이 경로 그대로 표시되던 문제 수정. \"상품 등록\" 라벨에 \"리다이렉트\" 배지를 함께 표시합니다.", + "쇼핑몰 환경설정의 결제수단·통화·배송 국가 목록 라벨이 일본어 등 비한국어 로케일에서도 한국어로 표시되던 결함 수정. 활성 언어팩의 번역 라벨이 정확히 노출됩니다.", + "주문 취소 시 재고가 이중으로 복구되던 결함 수정. 전체취소·부분취소 모두 취소한 수량만큼만 정확히 한 번 복구됩니다(이전에는 전체취소 시 최대 두 배가 복구되어 오버셀 위험이 있었습니다).", + "환경설정의 \"취소 시 재고 복구\" 끄기 설정이 무시되어, 꺼 두어도 취소 시 재고가 복구되던 문제 수정. 이제 설정대로 켜면 복구, 끄면 복구하지 않습니다.", + "무통장입금 주문이 입금 기한이 지나도 자동으로 취소되지 않던 문제 수정. 가상계좌와 무통장입금 결제대기 주문 모두 설정한 자동취소 기한이 지나면 정상적으로 자동 취소됩니다.", + "무통장입금 주문의 결제 정보에서 입금 은행·계좌 요약이 비어 보이던 문제 수정. 주문 상세에서 입금 은행명과 계좌번호가 정상 표시됩니다.", + "취소했던 주문을 다시 판매 상태(결제완료 등)로 되돌릴 때, 취소로 복구되었던 재고가 다시 차감됩니다. 되돌리려는 시점에 재고가 부족하면 상태 변경이 차단되어 재고가 음수로 빠지거나 오버셀이 발생하지 않습니다. 단건 변경·목록 일괄 변경과 더불어, 주문 상세에서 상품(옵션) 단위로 상태를 되돌리는 경우에도 동일하게 재고가 재차감됩니다.", + "주문 상세에서 상품(옵션) 단위로 상태를 \"주문취소\"로 바꿀 때, 결제(PG) 취소를 함께 하지 않는 경우에는 재고가 복구되지 않던 문제 수정. 이제 결제 취소 동반 여부와 관계없이 취소 시 재고가 설정대로 복구됩니다. 결제를 함께 취소하는 경우에도 재고가 이중으로 복구되지 않고 정확히 한 번만 복구됩니다.", + "단일 상품 주문에서 취소 수량을 줄여 부분취소하려 해도 항상 전체취소로 처리되던 문제 수정. 취소 수량을 상품 수량보다 적게 지정하면 그 수량만 부분취소되고, 모든 상품을 전량 취소할 때만 전체취소로 처리됩니다.", + "쇼핑몰 환경설정의 \"취소 시 재고 복구\" 토글을 켜서 저장하면 \"참/거짓 값이어야 합니다\" 오류로 저장이 거부되던 문제 수정. 이제 토글을 켜고 끄는 설정이 정상적으로 저장됩니다.", + "주문서 작성 시 보유한 마일리지보다 많은 금액을 사용하려 하면, 조용히 보유 잔액으로 줄여 결제하지 않고 \"보유 마일리지를 초과하여 사용할 수 없습니다\"라고 명확히 안내하도록 변경. 의도와 다른 금액으로 결제되던 혼란을 제거했습니다.", + "판매중지·품절·출시예정·전시중지 상태의 상품이 장바구니에 담기거나 결제까지 진행되던 문제 수정. 이제 담기 단계에서 차단되며, 이미 담겨 있던 상품이 판매중지로 바뀌면 합계 금액에서 자동으로 제외되고 구매 불가로 표시됩니다.", + "상품에 설정한 최소·최대 구매 수량이 실제 구매 과정(옵션 상품 포함)에 적용되지 않던 문제 수정. 이제 같은 상품의 옵션 수량을 합산한 총수량 기준으로 최소 미만·최대 초과 구매가 차단됩니다.", + "상품 등록·수정에서 \"구매 대상 제한\"을 선택하고도 허용 역할을 하나도 선택하지 않은 채 저장되던 문제 수정. 이제 제한을 선택하면 허용 역할을 최소 1개 이상 지정해야 저장됩니다.", + "상품 상세에서 \"바로 구매\"를 하면 해당 상품이 장바구니에도 함께 담기던 문제 수정. 이제 바로 구매는 장바구니를 거치지 않으며, 장바구니에 같은 상품이 들어 있어도 그 수량과 합산되지 않고 지금 선택한 수량만으로 주문됩니다(구매 수량 제한도 이번 선택 수량 기준으로 적용). 바로 구매 후 장바구니에는 기존 항목만 그대로 유지됩니다.", + "장바구니 담기·바로 구매가 차단될 때 \"담을 수 없습니다\" 같은 일반 안내만 나오던 문제 수정. 이제 어떤 상품이 왜(판매 중지·품절·구매 권한 없음·최소/최대 구매 수량 초과) 막혔는지 상품명과 함께 구체적으로 안내합니다.", + "쇼핑몰 상품 목록에서 상위 카테고리를 선택하면 그 하위 카테고리에만 속한 상품이 함께 표시되도록 개선. 이전에는 상위 카테고리 버튼을 눌러도 직접 지정된 상품만 나오고 하위 카테고리 상품은 누락되었으며, 카테고리 버튼이 목록을 실제로 필터링하지 않던 문제도 함께 수정했습니다. 선택한 카테고리는 버튼에 표시되어 현재 어떤 분류를 보고 있는지 알 수 있습니다.", + "관리자 상품 목록에서 검색어로 조회하면 결과는 나오는데 \"총 N개\"가 0으로 잘못 표시되던 문제 수정. 이제 검색 결과 수가 실제 목록과 일치하며, 검색어와 판매 상태 등 다른 필터를 함께 적용해도 정확히 집계됩니다. 관리자 쿠폰 목록의 검색 총 개수 오표시도 동일하게 수정했습니다.", + "관리자 상품 목록의 \"카테고리 미등록\"·\"브랜드 미등록\" 필터를 켜면 오류가 발생하며 영문 키가 그대로 노출되던 문제 수정. 이제 해당 필터가 정상 동작하여 분류가 지정되지 않은 상품만 정확히 조회됩니다.", + "관리자 상품·주문 목록에서 검색어를 빠르게 입력한 직후 곧바로 Enter 를 누르면 방금 입력한 검색어가 누락된 채 조회되던 문제 수정. 검색 입력 시 화면 버벅임도 함께 줄였습니다.", + "무통장입금 주문을 취소할 때 결제(PG) 취소 처리가 실패하며 취소 자체가 막히던 문제 수정. 무통장 등 PG를 거치지 않는 결제수단은 취소 시 결제대행 환불 단계를 건너뛰며, 환불금은 운영자가 직접 송금하도록 \"환불 승인(대기)\" 상태로 표시됩니다.", + "입금 전(미입금)이거나 실제 결제금액이 0원인 주문에서 일부 상품을 부분취소하려 하면 항상 \"취소할 수 없습니다\"로 막히던 문제 수정. 실제로 낸 돈이 없는 주문은 환불·추가청구 대상이 아니므로 그대로 부분취소됩니다.", + "여러 상품이 담긴 주문에서 일부 상품만 선택해 취소해도 주문 전체가 취소되던 문제 수정. 선택한 상품만 정확히 취소되고, 남은 상품은 그대로 유지됩니다. 모든 상품을 전량 취소할 때만 주문 전체가 취소됩니다.", + "일부 상품만 취소한 주문도 남은 상품을 이어서 취소할 수 있습니다. 남은 상품을 모두 취소하면 주문 전체가 취소 처리됩니다.", + "일부 상품만 취소한 주문이 그 주문의 실제 진행 단계(결제완료·상품준비중·배송중 등)에서 사라지지 않고 그대로 추적되도록 개선했습니다. 일부가 취소된 주문에는 주문내역·주문 상세에 \"일부취소\" 표시가 함께 붙어, 남은 상품은 정상 배송 흐름을 따르면서 일부 취소 사실도 한눈에 확인할 수 있습니다. (관리자 주문 목록·상세에도 동일하게 표시됩니다.)", + "취소 차단 안내 문구가 \"쿠폰 할인 조건\" 으로 단정하던 표현을 실제 사유(적용 중인 할인 조건 변경으로 결제금액 증가)에 맞게 일반화했습니다.", + "마이페이지 주문내역의 \"상품준비중\" 건수에는 배송준비완료 주문까지 포함되는데, 그 건수를 눌러 필터링하면 배송준비완료 주문이 빠져 표시 건수와 목록 수가 어긋나던 문제 수정. 이제 건수와 목록이 일치합니다.", + "무통장입금 등 결제대행을 거치지 않는 주문의 취소 화면에서 의미 없는 \"결제(PG) 함께 취소\" 선택이 노출되던 문제 수정. 해당 결제수단에서는 자동으로 숨겨집니다. 또한 \"환불 우선순위\"(PG↔마일리지 배분 순서)는 사용한 마일리지가 없는 주문에서 선택 의미가 없으므로, 항목은 보이되 선택할 수 없도록(비활성) 처리하고 사유를 함께 안내합니다.", + "주문 취소 시각이 전체취소에만 기록되고 부분취소에는 남지 않던 문제 수정. 전체·부분취소 모두 취소 시각이 정확히 기록됩니다.", + "관리자 배송정책 목록에서 데이터가 없을 때 빈 표 머리글과 \"검색 결과가 없습니다\" 안내가 동시에 어색하게 겹쳐 보이던 문제 수정. 쿠폰·주문·상품 목록과 동일하게 표 영역에 빈 상태 안내만 깔끔하게 표시되도록 정리했습니다.", + "무통장입금·가상계좌 입금 대기 주문의 자동취소 기한이 환경설정의 자동취소 기한(일)대로 적용됩니다. 이전에는 이 설정이 실제 동작에 반영되지 않았으며, 결제수단별로 나뉘어 있던 입금기한 설정을 하나의 자동취소 기한으로 통일했습니다(기존에 설정해 둔 값은 그대로 보존됩니다).", + "리뷰 작성 화면에 리뷰 이미지 최대 개수·최대 용량 설정이 실제로 반영됩니다. 이전에는 설정과 무관하게 항상 기본값(5장·10MB)이 적용되었으며, 최대 개수를 0으로 두면 이미지 첨부 영역이 숨겨집니다. 용량 초과 시 안내 메시지에도 설정한 용량이 표시됩니다.", + "리뷰 작성 가능 기간(구매확정 후 일수) 설정이 리뷰 작성 버튼 노출과 실제 작성 가능 여부에 동일하게 적용됩니다. 이전에는 두 판정이 미세하게 어긋나 버튼은 보이는데 작성이 막히거나 그 반대가 될 수 있었고, 기간을 0(무제한)으로 두면 버튼이 사라지던 문제도 해소되었습니다.", + "배송정책 목록이 빈 화면일 때 안내 문구가 한 번만 표시되도록 정리했습니다. 상품 등록·수정 폼의 배송정책 선택에는 사용 중인(활성) 정책만 표시되며, 기존 상품에 적용된 정책이 비활성화된 경우에는 그 정책을 그대로 유지하면서 \"비활성화된 배송정책 적용 중\" 안내를 함께 보여 줍니다.", + "배송정책 계산 API 주소를 입력하지 않은 채 저장하면 조용히 기본 배송비로 처리되던 문제를 막아, 계산 API 정책을 선택하면 API 주소 입력을 요구합니다. 또한 구간별 배송비·계산 API 설정에서 음수나 잘못된 값 입력 시 오류 메시지가 내부 코드 경로로 표시되던 문제를 수정해, 이해하기 쉬운 안내 문구로 표시됩니다.", + "상품정보제공고시(및 공통정보) 관리 목록의 무한 스크롤을 정비했습니다. 목록 상단의 개수가 전체 등록 건수를 정확히 표시하고(이전에는 처음 불러온 만큼만 표시), 스크롤하면 다음 항목을 한 묶음씩 불러오다 전체를 다 불러오면 멈춥니다(이전에는 더 불러올 항목이 없어도 추가 요청을 반복). 다음 묶음을 불러오는 동안 \"불러오는 중\" 표시가 화면에 보이도록 했습니다. 같은 방식의 배송정책 \"추가 배송비 템플릿\" 선택 목록과 쿠폰 \"상품 검색\" 목록에도 동일하게 적용했습니다. 목록에서 각 항목의 사용 여부를 바로 켜고 끌 수 있는 토글을 추가했고, 상품 등록·수정 폼의 고시 선택에는 사용 중인 항목만 표시됩니다.", + "상품 옵션을 장바구니에 담거나 수량·옵션을 바꿀 때 품절·재고 부족이면 오류 화면 대신 \"재고가 부족합니다 (요청 N개, 가용 M개)\"처럼 명확한 안내가 표시됩니다. 이미 삭제됐거나 다른 사용자의 장바구니 항목을 조작하려 할 때도 알 수 없는 오류 대신 적절한 안내가 표시됩니다.", + "주문 상태를 진행 순서에 어긋나게(예: 구매확정 → 결제완료) 변경하던 문제 수정 — 관리자 주문 관리의 단건·일괄·상품옵션별 상태 변경 모두에서 허용되지 않는 상태 전이를 차단합니다. 정상 진행 방향(중간 단계 건너뛰기 포함)과 운영상 필요한 일부 되돌림(배송중→상품준비중 등), 취소했던 주문을 다시 판매 상태로 되살리는 복원은 그대로 허용됩니다. 차단될 때에는 \"구매확정 상태에서 결제완료 상태로는 변경할 수 없습니다\"처럼 어떤 전이가 왜 막혔는지 알 수 있는 안내가 표시됩니다(이전에는 주문 일괄·상품옵션 일괄 변경에서 \"Request failed with status code 422\"나 일반 실패 문구만 보였습니다).", + "상품 쿠폰의 \"최소 주문금액\" 조건이 주문 전체 합계가 아니라 그 쿠폰을 적용한 상품 금액 기준으로 검증되도록 수정. 다른 상품을 함께 담아 전체 금액만 채워 최소 주문금액 조건을 우회하던 문제를 막았습니다(주문·배송비 쿠폰은 종전처럼 전체 합계 기준).", + "쿠폰의 \"1인 사용 횟수 제한\"이 주문 단계에서 실제로 적용되도록 수정. 이전에는 다운로드 시점에만 확인되어, 한 주문에 같은 쿠폰을 여러 상품에 중복 적용하거나 이미 사용한 쿠폰을 다시 사용할 수 있었습니다. 이제 과거 사용 횟수와 한 주문 내 중복 적용을 합산해 한도를 초과하면 차단합니다.", + "\"다른 쿠폰과 함께 사용 불가\"로 설정한 쿠폰이 서로 다른 종류의 쿠폰(상품·주문·배송비)과 동시에 적용되던 문제 수정. 이제 어떤 종류의 쿠폰과도 함께 적용되지 않도록 차단합니다.", + "만료되었거나 존재하지 않는 주문서(임시 주문)에 접근할 때 서버 오류(500)로 응답하던 문제 수정. 이제 한국어·영어 환경 모두에서 \"찾을 수 없음\"(404)으로 정확히 응답하며, 주문서 작성 단계에서 적용 불가한 쿠폰(최소금액 미달·사용 한도 초과·중복 불가)이 있으면 해당 쿠폰을 할인에서 제외하고 사유를 안내합니다. 주문 확정 시 적용 불가 쿠폰이 남아 있으면 결제가 차단됩니다.", + "무통장입금 주문에서 1회 사용 제한 쿠폰이 입금 처리 전까지 다시 사용되던 문제 수정. 이제 주문 접수 즉시 쿠폰이 사용 처리되며, 미입금으로 자동취소되거나 주문을 취소하면(부분취소로 적용 조건이 깨지는 경우 포함) 해당 쿠폰이 다시 사용 가능하도록 복원됩니다. (#57 @ChoDongHyeon 님께서 제보해주셨습니다.)", + "상품 등록·수정의 옵션에서 기본 옵션 판매가를 바꾸면 상단 판매정보의 판매가가 함께 갱신되도록 수정 — 기본 옵션 라디오를 다른 옵션으로 바꿀 때도 상단 판매가가 새 기본 옵션 가격으로 맞춰지며, 비기본 옵션은 기존 가산액을 유지한 채 재계산됩니다.", + "추가옵션을 \"사용\"으로 켠 뒤 항목을 추가해도 설정이 \"미사용\"으로 풀리지 않도록 수정 — 추가옵션 항목 유무로 표시 상태가 결정되며, \"미사용\"으로 끌 때 입력한 항목이 있으면 확인 후 비웁니다.", + "상품 이미지를 상한(20장)보다 많이 선택하면 조용히 잘리지 않고 안내가 표시되도록 수정했고, 대표 이미지 안내 문구를 실제 동작(★ 버튼으로 지정)에 맞게 정정했습니다. 서버에서도 상품당 이미지 개수 상한을 검증합니다.", + "SEO 제목·설명을 직접 입력해 저장하면 상품명·설명으로 덮어써지던 문제 수정 — 동기화 사용 여부가 저장되어, 끄면 입력한 값이 그대로 유지되고 켜면 상품명·설명으로 자동 채워집니다. 동기화를 끈 뒤 수정 화면을 다시 열면 끈 상태가 그대로 복원됩니다.", + "판매가·재고 일괄변경에서 상품/옵션 헤더로 전체선택했을 때 \"변경할 항목 없음\"으로 표시되던 문제 수정 — 전체선택에서도 대상이 정확히 집계되어 변경이 적용됩니다. 변경 완료 안내 문구에 실제 변경 건수가 표시됩니다.", + "상품 라벨 적용 기간의 날짜를 직접 입력할 때 화면 오류(\"컴포넌트 로드 실패\")가 발생하던 문제 수정 — 라벨 기간 날짜, 쇼핑몰 연동 설정, 양식 저장 모달 입력이 모두 정상 동작합니다.", + "추가옵션이 등록된 상품을 수정 화면에서 열면 추가옵션이 표시되지 않고 \"미사용\"으로 보여 저장 시 사라질 수 있던 문제 수정 — 저장된 추가옵션이 그대로 불러와집니다.", + "상품 등록·수정의 추가옵션 \"미사용\" 확인 창, 공지 양식 저장 창이 열리지 않던 문제 수정 — 확인 창이 정상적으로 뜨고, 공지 양식 저장 창에서 이름을 입력해 저장할 수 있습니다.", + "상품 관리 일괄변경 확인 창에서 판매·전시 상태가 \"on_sale\", \"visible\" 같은 내부 코드값으로 표시되던 문제 수정 — 드롭다운에서 고른 그대로 \"판매중\", \"전시중\" 등 한글 라벨로 표시됩니다.", + "상품 관리 재고 일괄변경 확인 창의 옵션별 변경 내용이 \"재고: undefined undefined\"로 표시되던 문제 수정 — 입력한 \"+10개\" 같은 변경 값이 그대로 표시됩니다. 가격 일괄변경의 옵션별 표시도 동일하게 정정됩니다.", + "쇼핑몰 환경설정에서 상품 목록·카테고리·상품 상세의 \"SEO 제공 페이지\"를 꺼도 검색엔진 사이트맵(sitemap.xml)에 해당 주소가 그대로 남던 문제 수정 — 끈 페이지 유형의 주소는 사이트맵에서 제외됩니다." + ] + }, + { + "name": "Security", + "items": [ + "결제 거래번호 DB 레벨 멱등성 보장 — PG 결제 콜백이 동일한 거래번호로 중복 수신되어도 DB 가 자동 차단하여 중복 결제완료/포인트 적립 등의 사고를 원천 차단" + ] + } + ] + }, + { + "version": "1.0.0-beta.4", + "date": "2026-05-12", + "categories": [ + { + "name": "Added", + "items": [ + "이커머스 환경설정의 본인인증 정책 탭에 권한 기반 버튼 비활성화 적용 — 수정 권한이 없는 운영자는 \"정책 추가\" / \"수정\" / \"삭제\" / \"활성 토글\" 버튼이 자동 비활성화" + ] + }, + { + "name": "Fixed", + "items": [ + "위시리스트 활동 로그의 \"액션\" 열에 `wishlist.remove` 가 번역되지 않은 키 그대로 노출되던 문제 수정" + ] + } + ] + }, + { + "version": "1.0.0-beta.3", + "date": "2026-05-11", + "categories": [ + { + "name": "Added", + "items": [ + "상품 상세 페이지의 OG 메타태그·Product 구조화 데이터를 이커머스 모듈이 직접 선언하도록 SEO declaration 구현 — 썸네일 가로/세로 크기·가격·통화·재고 상태가 og:product:* 와 Schema.org Product/Offer/AggregateRating 스키마로 자동 출력됨", + "결제 취소·승인·무통장 입금 확인·재고 차감/복원·배송지 관리(CRUD + 기본 설정)·체크아웃 응답 변환 지점에 본인인증(IDV) 정책 등 외부 확장을 붙일 수 있는 확장점 제공", + "IDV 정책 3종(결제 취소·승인·무통장 입금 확인) 을 코어 본인인증 정책에 자동 동기화. 운영자가 관리자 UI 에서 수정한 필드(활성/유예/프로바이더/실패 모드) 는 업데이트 재설치 시 보존 — 모두 기본 비활성", + "결제 직전 성인/본인 확인 목적(`checkout_verification`) 등록 — 별도 본인인증 프로바이더 설치 시 결제 단계 전 검증 가능", + "결제 진행 직전 본인인증(`checkout_verification`) 발생 시 결제 도메인 전용 메일 문구가 발송되도록 모듈에서 메시지 템플릿을 직접 제공 (이전에는 일반 fallback 문구가 발송됨)", + "이커머스 환경설정에 \"본인인증 정책\" 탭 추가 — 결제 취소·승인 등 이커머스 컨텍스트 정책을 코어 정책 관리 화면과 동일한 UI/UX (검색·필터(강제 시점/출처)·페이지네이션·정책 추가/편집 모달 직접 호출·이 모듈이 등록한 본인인증 목적(checkout_verification) 메타 카드 상단 노출·데스크탑 테이블/모바일 카드 자동 분기 + enabled 인라인 토글) 로 한 곳에서 관리. 정책 목록 \"인증 목적\" 컬럼이 코드명 대신 사람이 읽을 수 있는 라벨로 표시", + "결제 직전 본인 확인 정책 신설 (`checkout_verification` 목적) — 운영자가 활성화하면 결제 진행 전 자동 본인인증 진행", + "이커머스 모듈 자체 권한 신설: `sirsoft-ecommerce.identity.policies.{read,update}` (정책 관리 최소 권한)", + "이커머스 알림 발송 이력 / 본인인증 이력 샘플 시더 추가 — `module:seed sirsoft-ecommerce --sample` 으로 결제·문의·주문 등 이커머스 영역 더미 이력만 독립 생성 (코어/타 모듈 영역 침범 없음)", + "이커머스 알림 정의 7종(주문 확인·배송 시작·구매 확정·주문 취소·신규 주문 관리자·상품 문의 접수·문의 답변)을 모듈 manifest 에서 직접 선언하도록 통일 — 활성화/업데이트 시 운영자 편집값을 보존하면서 자동 등록·정리되며, 권한·메뉴·본인인증과 동일한 declarative 패턴으로 일관화", + "활동 로그의 이커머스 액션 라벨(주문·상품·카테고리·쿠폰·배송 정책 등)을 이커머스 모듈 다국어 파일에서 관리하도록 정리 — 모듈 라벨이 자기 영역에서 자기설명되며, 일본어 등 추가 언어팩이 키 누락 없이 동기화" + ] + }, + { + "name": "Changed", + "items": [ + "코어 최소 요구 버전을 7.0.0-beta.4 로 상향 — 본인인증 정책 인프라 의존", + "결제 본인 확인 인증 목적 라벨/설명을 모듈 다국어 파일에서 관리하도록 정리 (운영자/번역자가 lang 파일만 편집해도 라벨 변경 가능)", + "클레임 사유·배송 유형·배송사의 다국어 라벨이 활성 언어팩 추가 시 자동으로 새 언어 키를 포함하도록 정합화 — 운영자가 한국어 라벨만 직접 수정한 row 도 한국어는 보존하면서 신규 언어(예: 일본어) 라벨은 자동 동기화", + "배송사 다국어 라벨이 활성 언어팩의 데이터를 자동으로 머지하도록 시더 정합화 (다른 entity 시더와 일관)" + ] + }, + { + "name": "Fixed", + "items": [ + "모듈 시더 실행 시 배송 유형 시더가 자동 호출되지 않던 회귀 수정 — 언어팩 활성/비활성 토글 시 즉시 다국어 라벨 반영", + "일부 주문에서 옵션 정보 직렬화 시 500 에러가 발생하던 문제 수정", + "상품 옵션 삭제 검증 시 런타임 에러가 발생하던 문제 수정", + "설치 직후 또는 활성 모듈 디렉토리 부재 시 환경설정 기본값이 비어있던 문제 수정 — 활성 디렉토리 부재 시 번들 디렉토리를 fallback 으로 사용", + "알림 템플릿 편집 모달의 입력 필드에서 글자를 입력할 때마다 화면이 심하게 버벅이던 문제 수정", + "이커머스 환경설정 알림 탭의 채널 라벨이 식별자(mail / database)로 노출되던 문제 수정 — 활성 언어 기준의 사람이 읽을 수 있는 라벨로 표시" + ] + } + ] + }, + { + "version": "1.0.0-beta.2", + "date": "2026-04-20", + "categories": [ + { + "name": "Added", + "items": [ + "**알림 시스템 강화**", + "7종 알림(주문확인/배송시작/구매확정/주문취소/신규주문/문의접수/문의답변) 실제 발송 구현", + "알림 목록에 채널별 수신자 뱃지 표시", + "알림 편집 모달에 수신자 설정 섹션 추가 — 타입 선택 및 규칙 관리", + "알림 클릭 시 이동 URL 기본값 설정", + "알림 정의 일괄 초기화 기능 — 확인 모달 + 진행 스피너 UX 포함", + "이커머스 알림 정의를 코어 리셋 로직에 자동 기여하여 기본값 복원 가능", + "알림 탭은 코어 알림 권한 기준으로 편집 가능 여부를 판단하도록 개선 (모듈 설정 권한과 분리)", + "**SEO 환경설정 강화**", + "페이지 타입별 SEO 변수 메타데이터 선언 (상품/카테고리/검색/쇼핑몰 메인)", + "쇼핑몰 메인 페이지 메타태그 설정 추가", + "캐시 삭제 확인 모달 및 캐시 용량 동적 표시", + "**배송유형 관리 기능**", + "환경설정 > 배송설정에서 배송유형 추가/수정/삭제/활성화 가능", + "기본 11종(국내 7종 + 해외 2종 + 기타 2종) 시딩", + "배송유형/배송사 테이블 드래그앤드롭 순서 변경", + "배송방법 \"직접입력\" 선택 시 다국어 배송방법명 입력 지원", + "클레임 사유(ClaimReason) 사용자 수정 보존 지원", + "주소 중복 등록 시 전용 도메인 예외 처리" + ] + }, + { + "name": "Changed", + "items": [ + "코어 최소 요구 버전을 7.0.0-beta.2 로 상향", + "알림 정의 시더를 인라인 방식으로 전환 — 사용자 수정 자동 보존 패턴 적용", + "레거시 메일 템플릿 테이블을 통합 알림 시스템으로 이관", + "알림 수신자를 채널별 독립 관리 방식으로 변경", + "이전 업그레이드 스텝에 클래스 부재 시 안전 스킵 처리 추가", + "상품 설명/공통정보 에디터를 확장 포인트(extension_point)로 변환", + "SEO 캐시 무효화를 코어 공통 캐시 인터페이스로 전환", + "상품정보제공고시, 공통정보, 알림 템플릿 편집의 다국어 탭을 동적 생성으로 전환", + "배송유형을 Enum에서 DB 테이블로 변경하여 관리자 화면에서 동적 관리 가능하도록 변경", + "주문 필터, 배송정책 폼의 하드코딩 옵션을 DB 기반 동적 렌더링으로 변경", + "주문/배송정책에서 직접입력 배송방법명 해석 지원 (스냅샷 기반)" + ] + }, + { + "name": "Removed", + "items": [ + "레거시 메일 템플릿 시더 및 관련 다국어 키 제거", + "ShippingTypeEnum, ShippingMethodEnum 제거 — DB 관리 방식으로 이관", + "환경설정의 도서산간 배송비 기본설정 제거 — 배송정책별 설정으로 완전 이관" + ] + }, + { + "name": "Fixed", + "items": [ + "마이페이지 배송지 관리에서 수정 버튼에 권한 체크가 누락되어 있던 문제 수정", + "사용자 화면에서 상품 설명 HTML이 텍스트로 노출되던 문제 수정", + "상품 설명/공통정보 에디터에 HTML 모드 전환 옵션 누락 수정", + "공통정보 패널 폼 초기화 및 모달 전환 관련 버그 수정", + "주문목록 검색필터 배송방법 선택 시 검증 에러 수정", + "환경설정/상품옵션 테이블의 Select 드롭다운이 가려지는 문제 수정", + "재설치·재시드 시 주문/상품 시퀀스 counter 가 0으로 리셋되어 주문번호 중복 위험이 있던 문제 수정 — 기존 counter 보존", + "재설치·재시드 시 관리자가 수정·추가한 택배사 정보가 기본 목록으로 초기화되던 문제 수정 — 사용자 수정분 자동 보존" + ] + } + ] + }, + { + "version": "1.0.0-beta.1", + "date": "2026-04-01", + "categories": [ + { + "name": "Changed", + "items": [ + "오픈 베타 릴리즈" + ] + } + ] + }, + { + "version": "0.20.0", + "date": "2026-03-31", + "categories": [ + { + "name": "Added", + "items": [ + "기본 클레임 사유(환불/취소) 자동 등록 — 모듈 설치 시 7건 기본 사유 시딩 (고객 귀책 4건 + 판매자 귀책 3건)", + "`ClaimReasonSeeder` 시더 추가 (`getSeeders()` 및 `DatabaseSeeder` 등록)", + "업그레이드 스텝 `Upgrade_0_20_0` — 기존 설치 환경에 기본 클레임 사유 삽입" + ] + }, + { + "name": "Fixed", + "items": [ + "장바구니 API 서버 에러 수정 — 삭제된 배송정책을 참조하는 상품의 배송비 계산 시 null TypeError 방지, 배송비 0원 처리 (OrderCalculationService)", + "사용자 주문 취소 완료 후 모달이 닫히지 않는 문제 수정 — `showCancelModal: false` 상태값 방식을 `G7Core.modal.close('modal_cancel_order')` 호출 방식으로 교체 (userCancelOrderHandlers)", + "사용자 주문 취소 모달 취소 사유 selectbox 다크모드 미적용 수정 — 다크모드 색상 클래스 추가 (_modal_cancel.json)", + "사용자 주문 취소 모달 취소 사유 selectbox 하단 여백 누락 수정 — `mb-4` 추가 (_modal_cancel.json)" + ] + } + ] + }, + { + "version": "0.19.6", + "date": "2026-03-30", + "categories": [ + { + "name": "Fixed", + "items": [ + "상품옵션/고시정보 다국어 입력 stale closure 수정 — productOptionHandlers/productNoticeHandlers에서 디바운스 병합 시 이전 키 변경 유실 방지 (`_partial_product_options.json`, `_partial_product_notices.json`)", + "주문상세 배송방법 `$t:` 번역 토큰 원문 노출 수정 — 배송방법 표시 바인딩에 `raw:` 마커 적용 (_partial_order_info.json)", + "FileUploader onRemove 시 form 데이터 동기화 누락 수정 — remove 핸들러에 setState 추가하여 폼 데이터에서 삭제된 파일 즉시 반영 (_partial_image_upload.json)" + ] + } + ] + }, + { + "version": "0.19.5", + "date": "2026-03-30", + "categories": [ + { + "name": "Fixed", + "items": [ + "주문 일괄 변경 활동 로그 `:count` 플레이스홀더 미치환 수정 — bulk 핸들러 4개(bulkUpdate, bulkStatusUpdate, bulkShippingUpdate, bulkOptionStatusChange)의 `description_params`에 `count` 키 추가", + "주문 상태 변경 활동 로그에서 enum 값(payment_complete, shipping 등)이 번역되지 않고 원시값 노출 수정 — Order/OrderOption 모델 `$activityLogFields`의 status 필드를 `type: 'enum'`으로 변경 + `OrderStatusEnum::labelKey()` 메서드 추가" + ] + } + ] + }, + { + "version": "0.19.4", + "date": "2026-03-30", + "categories": [ + { + "name": "Changed", + "items": [ + "`window.G7Config` 설정 노출 최소화 — frontend_schema 필드별 `expose: false` 처리 (basic_info: route_path, no_route만 노출, language_currency: default_currency, currencies만 노출, order_settings/shipping/seo/review_settings 전체 미노출)" + ] + } + ] + }, + { + "version": "0.19.3", + "date": "2026-03-30", + "categories": [ + { + "name": "Fixed", + "items": [ + "관리자 다크모드 레이아웃 전수 수정 — 101개 파일에서 누락된 `dark:` variant 약 415건 추가 (text-gray, bg-white/gray, focus:ring, text-red/blue/green/yellow, border-gray, hover 등)", + "상품 일괄 변경 모달 라벨 미정의 CSS 클래스 수정 — `className: \"label\"` → `\"form-label\"` (다크모드 텍스트 가독성 복원)", + "상품 목록 판매가/재고 일괄 변경 버튼 다크모드 텍스트 색상 누락 수정 — `text-gray-700 dark:text-gray-300` 추가", + "레이아웃 JSON 내 Select 컴포넌트 다크모드 배경색 수정 — `dark:bg-gray-800` → `dark:bg-gray-700` (카드 배경과 구분)" + ] + } + ] + }, + { + "version": "0.19.2", + "date": "2026-03-30", + "categories": [ + { + "name": "Added", + "items": [ + "검색/필터/정렬 성능 향상을 위한 일반 인덱스 추가 — ecommerce_products(selling_price, list_price, stock_quantity, shipping_policy_id, barcode, tax_status, updated_at), ecommerce_orders(total_amount, order_device, confirmed_at, [user_id+ordered_at]), ecommerce_order_addresses(orderer_phone, recipient_phone)" + ] + }, + { + "name": "Changed", + "items": [ + "Repository 7개의 직접 `MATCH...AGAINST` 호출을 Laravel Scout `Model::search()` 파이프라인으로 전환 — DBMS별 검색 분기를 엔진 내부에서 처리 (ProductRepository, CategoryRepository, BrandRepository, CouponRepository, ProductCommonInfoRepository)", + "ReportRepository의 관계 검색을 `DatabaseFulltextEngine::whereFulltext()` 정적 헬퍼로 전환 — 다중 DBMS 호환", + "Category, Brand, PromotionCoupon, ProductCommonInfo 모델에 `FulltextSearchable` 인터페이스 + `Searchable` 트레이트 추가", + "`searchIndexShouldBeUpdated()` 훅 기반 전환 — `HookManager::applyFilters()` 사용으로 검색 플러그인이 모델별 인덱스 업데이트 제어 가능", + "마이그레이션 FULLTEXT 인덱스 생성을 `DatabaseFulltextEngine::addFulltextIndex()` 정적 헬퍼로 전환 — DBMS별 조건부 처리" + ] + }, + { + "name": "Fixed", + "items": [ + "인덱스명 약어 수정 — `idx_ecom_*` → `idx_ecommerce_*`, `stock_qty` → `stock_quantity` (네이밍 규칙 준수)" + ] + } + ] + }, + { + "version": "0.19.1", + "date": "2026-03-30", + "categories": [ + { + "name": "Added", + "items": [ + "상품 옵션 재생성 시 기존 옵션 초기화 확인 모달 연결 — 기존 옵션이 존재할 때 generateOptions 호출 시 확인 모달 표시, 확인 후 skipConfirm 플래그로 실제 생성 진행 (optionHandlers, _modal_confirm_regenerate.json)" + ] + } + ] + }, + { + "version": "0.19.0", + "date": "2026-03-30", + "categories": [ + { + "name": "Added", + "items": [ + "FULLTEXT 인덱스(ngram) 추가 — ecommerce_products(name, description), ecommerce_categories(name, description), ecommerce_brands(name), ecommerce_promotion_coupons(name, description), ecommerce_product_common_infos(name, content) 총 9개", + "Product 모델에 Laravel Scout `Searchable` 트레이트 + `FulltextSearchable` 인터페이스 적용" + ] + }, + { + "name": "Changed", + "items": [ + "상품/카테고리/브랜드/쿠폰/공통정보 관리자 검색 쿼리를 `LIKE '%keyword%'`에서 `MATCH...AGAINST IN BOOLEAN MODE`로 전환 — JSON TEXT 컬럼 검색 성능 향상", + "공개 상품 검색(통합검색) 쿼리를 로케일 루프 `JSON_EXTRACT + LIKE`에서 `MATCH...AGAINST`로 전환", + "관리자 상품 목록 검색 필드에 '상품설명', 'SKU' 옵션 추가 — description FULLTEXT 검색 지원" + ] + } + ] + }, + { + "version": "0.18.4", + "date": "2026-03-30", + "categories": [ + { + "name": "Fixed", + "items": [ + "상품 수정 시 이미지 순서 유실 수정 — syncImages에서 배열 인덱스를 sort_order SSoT로 사용, stale sort_order 값 무시 (ProductService)", + "상품 수정 시 대표이미지 hash 기반 설정 — syncImages에 thumbnail_hash 파라미터 추가, hash 기준 is_thumbnail 갱신 (ProductService)", + "상품 복사 시 원본 이미지 파일 복사 — copyFromSource() 메서드 추가, hash 기반 원본 조회 및 스토리지 파일 복사 (ProductImageService)", + "상품 복사 시 createImages에서 hash 기반 이미지 복사 분기 — hash 존재 시 copyFromSource 호출, 미존재 시 기존 직접 생성 (ProductService)", + "상품 이미지 임시 파일 연결 시 sort_order 재배치 — linkTempImages에서 기존 이미지 최대 sort_order 뒤에 배치, is_thumbnail 해제 (ProductImageService)", + "상품 수정 폼 이미지 메타데이터 확장 — download_url, size, size_formatted, mime_type, is_image, width, height, thumbnail_hash 추가 (ProductService)", + "상품 이미지 업로드 레이아웃 전면 수정 — event 기반 콜백, hash 기반 대표이미지, 편집/복사 모드별 API 엔드포인트 분기, onReorder/onRemove 핸들러 추가 (_partial_image_upload.json)", + "ProductResource에 thumbnail_hash 필드 추가 — 대표이미지 hash 반환 (ProductResource)", + "StoreProductRequest에 thumbnail_hash 검증 추가, description maxLength 65535 적용 (StoreProductRequest)", + "ProductController 에러 응답에 상세 에러 정보 추가 — ValidationException errors 전달, debug 모드 예외 메시지 포함 (ProductController)" + ] + } + ] + }, + { + "version": "0.18.3", + "date": "2026-03-30", + "categories": [ + { + "name": "Fixed", + "items": [ + "카테고리 삭제 모달 영문 다국어 ICU MessageFormat 오류 수정 — `{count, plural, ...}` 형식을 단순 `{count}` 치환 형식으로 변경 (category.json)" + ] + } + ] + }, + { + "version": "0.18.2", + "date": "2026-03-29", + "categories": [ + { + "name": "Fixed", + "items": [ + "주문 일괄 배송중 변경 시 운송장/택배사 정보가 저장되지 않는 버그 수정 — `bulkUpdateShipping()`에서 shipping 레코드 미존재 시 옵션별 신규 생성 로직 추가 (OrderRepository)" + ] + }, + { + "name": "Changed", + "items": [ + "order_shippings 테이블에서 `carrier_name` 컬럼 삭제 — 택배사명은 carrier_id 관계를 통해 사용자 로케일에 맞게 동적 조회로 전환 (OrderShipping, OrderOptionResource, OrderListResource, OrderShippingResource)" + ] + } + ] + }, + { + "version": "0.18.1", + "date": "2026-03-29", + "categories": [ + { + "name": "Fixed", + "items": [ + "배송완료 상태 변경 시 운송장 입력 강제 해제 — `shippingInfoRequiredStatuses()`에서 DELIVERED 제거 (OrderStatusEnum)", + "주문 목록 일괄 배송완료 시 상태가 \"배송중\"으로 표시되는 버그 수정 — `bulkUpdateShipping()`에서 order_status를 SHIPPING으로 덮어쓰는 코드 삭제 (OrderRepository)", + "주문상세 일괄변경 시 carrier vs carrier_id 키 불일치로 422 에러 발생 수정 — FE `body.carrier` → `body.carrier_id`, BE `$validated['carrier']` → `$validated['carrier_id']` (orderDetailHandlers, OrderController)", + "주문상세 옵션 일괄 상태 변경 후 부모 주문 상태 미동기화 수정 — `syncParentOrderStatus()` 메서드 추가, 모든 활성 옵션 동일 상태면 주문도 해당 상태로, 혼합 시 최저 진행 단계로 설정 (OrderOptionService)", + "ActivityLog 일괄 상태 변경 로그 미기록 수정 — Service results 키를 `order_option_id`로 변경하여 Listener pluck와 일치, 분할/병합 케이스별 loggable 대상 정확히 지정 (OrderOptionService, OrderActivityLogListener)", + "프론트엔드 주문 목록 배송완료 선택 시 운송장 필수 경고/비활성화 해제 — shippingStatuses 배열 및 modal 조건에서 delivered 제거 (orderHandlers, _modal_bulk_confirm.json)" + ] + }, + { + "name": "Changed", + "items": [ + "OrderOption 일괄 상태 변경 results 구조 확장 — `option_id` → `order_option_id`, `split_order_option_id`, `merged_into_order_option_id`, `is_full_quantity` 추가 (OrderOptionService)" + ] + } + ] + }, + { + "version": "0.18.0", + "date": "2026-03-29", + "categories": [ + { + "name": "Added", + "items": [ + "검색/필터/정렬 성능 향상을 위한 누락 인덱스 일괄 추가 — ecommerce_products 7개(selling_price, list_price, stock_quantity, shipping_policy_id, barcode, tax_status, updated_at), ecommerce_orders 4개(total_amount, order_device, confirmed_at, [user_id+ordered_at] 복합), ecommerce_order_addresses 2개(orderer_phone, recipient_phone)", + "상품 복사 모드 이미지 파일 복사 기능 — copyFromSource() 메서드로 원본 이미지 파일과 메타데이터를 새 상품에 복제 (ProductImageService)", + "상품 등록 시 에러 상세 로깅 — ValidationException/Exception 분리 처리 및 debug 모드 시 exception 메시지 포함 응답 (ProductController)" + ] + }, + { + "name": "Fixed", + "items": [ + "상품 등록 시 상세설명 255자 제한 버그 수정 — TranslatableField maxLength 65535 명시 (StoreProductRequest)", + "상품 복사/수정 화면에서 FileUploader 렌더링 에러 수정 — getDetailForForm() 이미지 매핑에 누락 필드(mime_type, is_image, download_url, size, order 등) 추가 (ProductService)", + "상품 복사 컨트롤러에서 중복 download_url 추가 로직 제거 — getDetailForForm()에서 이미 포함 (ProductController)", + "상품 복사 등록 시 500 에러 수정 — createImages()에서 hash 기반 복사 모드 이미지를 copyFromSource()로 파일 복사 처리 (ProductService)", + "인덱스 마이그레이션 날짜 순서 수정 — 테이블 생성 마이그레이션 이후로 재배치 + 인덱스 중복 방어 코드 추가", + "상품 수정/복사 화면에서 대표이미지 표시 안됨 수정 — id 기반 → hash 기반 대표이미지 식별로 전환, 복사 모드 이미지 삭제 시 원본 보호 (ProductResource, ProductService, _partial_image_upload.json)", + "상품 복사 등록 시 임시 이미지 정렬/썸네일 버그 수정 — linkTempImages()에서 sort_order 미설정(기존 이미지와 충돌) 및 is_thumbnail 미해제(이중 썸네일) 문제 해결 (ProductImageService)", + "상품 이미지 filesChange 이벤트가 form.images를 pendingFiles로 덮어쓰는 버그 수정 — 복사 이미지 데이터 유실 방지, uploadComplete도 form.images 미수정으로 createImages/linkTempImages 이중 처리 해소 (_partial_image_upload.json)" + ] + } + ] + }, + { + "version": "0.17.2", + "date": "2026-03-29", + "categories": [ + { + "name": "Fixed", + "items": [ + "주문상세 일괄변경 시 toast에 번역 키(`no_items_selected`)가 원문 그대로 표시되는 버그 수정 — 누락된 다국어 키 추가 (ko/en)", + "주문상세 일괄변경에서 \"선택 없음\"과 \"취소 가능 상품 없음\" 메시지를 동일 키로 사용하던 것을 `no_items_selected` / `no_cancellable_items`로 분리 (orderDetailHandlers)" + ] + } + ] + }, + { + "version": "0.17.1", + "date": "2026-03-28", + "categories": [ + { + "name": "Fixed", + "items": [ + "공통정보 추가 시 \"ko 언어의 값은 필수입니다\" 검증 오류 수정 — MultilingualInput의 setState에서 `$event` 전체 대신 `$event.target.value`로 다국어 값 객체만 저장", + "공통정보 뷰 패널 \"기본값으로 설정\" 버튼 미동작 수정 — 모달 ID 불일치(`set_default_confirm_modal` → `modal_set_default_confirm`) 및 확인 모달에 기본값 설정 API 호출 추가" + ] + } + ] + }, + { + "version": "0.17.0", + "date": "2026-03-30", + "categories": [ + { + "name": "Added", + "items": [ + "상품 상세 페이지에 1:1 문의 탭 추가", + "문의 게시판이 설정된 경우에만 탭 노출", + "비회원 포함 누구나 문의 목록 조회 가능, 로그인 사용자가 문의 작성/수정/삭제 가능", + "비밀글 토글 필터 (전체 / 비밀글 제외) 제공", + "관리자 권한 보유자가 문의에 답변 등록/수정/삭제 가능", + "작성자명 마스킹, 비밀글 잠금 처리", + "마이페이지에 내 문의내역 조회 API 추가 (답변 내용 포함)", + "이커머스 환경설정에 1:1 문의게시판 설정 섹션 추가", + "게시판 모듈과 연동하여 사용할 게시판을 지정할 수 있음", + "게시판 모듈 미설치 시에도 이커머스 단독 운영 가능", + "문의 접수/답변 완료 메일 템플릿 2종 추가 (`inquiry_received`, `inquiry_replied`)", + "문의 작성 시 상품명 다국어 스냅샷 저장 (상품 삭제 후에도 문의 내역에서 상품명 표시 유지)", + "기존 설치 환경을 위한 업그레이드 스텝 추가 (`Upgrade_0_9_0`)" + ] + }, + { + "name": "Changed", + "items": [ + "상품 복사 기능을 POST API 방식에서 navigate 패턴으로 전환 — 복사 옵션 선택 → 상품 등록 페이지로 이동하여 사전 입력 (역할 복사와 동일 UX)", + "복사 옵션 11개로 확장: 이미지, 옵션, 카테고리, 판매정보, 상품설명, 상품정보제공고시, 공통정보, 기타정보, 배송정책, SEO, 식별코드", + "수정/목록 페이지 복사/삭제 버튼을 setState 방식에서 openModal/closeModal 패턴으로 변경" + ] + }, + { + "name": "Fixed", + "items": [ + "문의 삭제 시 Action 훅이 트랜잭션 내부에서 실행되던 문제 수정 (롤백 시 부작용 방지)", + "훅명 표준화: 엔티티 부분의 하이픈(`-`)을 언더스코어(`_`)로 변경", + "마이페이지 문의 목록 조회 시 타입 불일치로 발생할 수 있는 오류 수정", + "문의 가능 여부(`inquiry_available`) 판단 기준을 게시판 slug 존재 여부로 통일", + "목록 페이지 복사 모달 제출 시 \"POST method not supported\" 에러 수정 — GET 라우트에 POST 요청 불일치 해결", + "수정 페이지 복사/삭제 버튼 미동작 수정 — modals 섹션에서 setState 플래그 대신 openModal 핸들러 사용", + "배송정책 목록 페이지에서 데이터 0건일 때 DataGrid와 Empty State가 동시 표시되는 버그 수정 — DataGrid, bulk_actions, table_header_bar, pagination에 조건부 렌더링 추가", + "배송정책 복사 시 원본 데이터가 폼에 로드되지 않는 버그 수정 — copy_source data_source 추가 및 initShippingPolicyForm 핸들러에 isCopy 모드 처리", + "배송정책 신규 등록/수정 시 is_default=true 설정 시 기존 기본 정책이 해제되지 않는 버그 수정 — create()/update()에 clearDefault() 호출 추가", + "배송정책 목록 DataGrid subRow에서 `$t:` 번역 토큰이 원문 그대로 표시되는 버그 수정 — `{{}}` 표현식 내 문자열 리터럴에서 `$t()` 함수 호출로 변경", + "배송정책 복사 모달이 실제 국가별 설정을 반영하지 않는 버그 수정 — 플랫 필드(배송방법, 운송사, 부과정책)에서 country_settings 기반 반복 렌더링으로 재설계" + ] + } + ] + }, + { + "version": "0.16.2", + "date": "2026-03-27", + "categories": [ + { + "name": "Fixed", + "items": [ + "카테고리 크로스 depth 이동 시 parent_id가 null로 변경되지 않는 버그 수정 — `isset()`이 null에 대해 false를 반환하여 `array_key_exists()`로 교체 (CategoryService)", + "이커머스 부모 메뉴 URL을 null로 변경 — 대응 라우트 없는 `/admin/ecommerce` URL이 404 발생 (module.php)" + ] + } + ] + }, + { + "version": "0.16.1", + "date": "2026-03-26", + "categories": [ + { + "name": "Fixed", + "items": [ + "상품 하위 라우트(form, copy, can-delete, logs) `whereNumber` 제약으로 product_code(영숫자) 404 발생 수정 — `where('product', '[0-9a-zA-Z]+')` 패턴으로 변경하여 ID/코드 둘 다 대응" + ] + } + ] + }, + { + "version": "0.16.0", + "date": "2026-03-26", + "categories": [ + { + "name": "Added", + "items": [ + "ProductLog → ActivityLog 통합: 상품 처리로그를 표준 ActivityLog 시스템으로 전환", + "ProductActivityLogListener: ActivityLog 표준 패턴 적용 (description_key, ChangeDetector)", + "ActivityLogDescriptionResolver: product_id → 상품명 실시간 해석", + "ProductController::logs(): 상품별 처리로그 API (OrderController::logs 패턴 동일)", + "Bulk Update ChangeDetector 지원: 일괄 변경 시 필드별 old/new 변경 내역 기록", + "OrderService: bulkUpdate, bulkUpdateStatus, bulkUpdateShipping에 스냅샷 캡처 + ChangeDetector 적용", + "OrderOptionService: bulkChangeStatusWithQuantity에 스냅샷 캡처 + ChangeDetector 적용", + "ProductOptionService: bulkUpdatePriceByMixedIds, bulkUpdateStockByMixedIds, bulkUpdate(통합)에 스냅샷 캡처 + ChangeDetector 적용", + "EcommerceAdminActivityLogListener: `option.after_bulk_update` 훅 구독 추가 (통합 일괄 수정 로깅)", + "ProductOptionRepositoryInterface: `findByIds()`, `getIdsByProductIds()` 메서드 추가", + "Listener 레벨 Bulk 스냅샷/ChangeDetector 전수 적용 (13건)", + "ProductActivityLogListener: bulk_update/bulk_price_update/bulk_stock_update before 스냅샷 + after ChangeDetector (6훅 추가)", + "CouponActivityLogListener: before_bulk_status 스냅샷 캡처 + after ChangeDetector 연동 (1훅 추가)", + "EcommerceAdminActivityLogListener: ExtraFeeTemplate bulk delete/toggle/create, ShippingPolicy bulk delete/toggle, ProductReview bulk delete 스냅샷 + ChangeDetector (9훅 추가)", + "삭제 작업 시 deleted_items 스냅샷 보존 (ExtraFeeTemplate, ShippingPolicy, ProductReview)", + "21개 모델에 $activityLogFields 정의 (코어 5개, 이커머스 11개, 게시판 4개, 페이지 1개)", + "Upgrade_0_16_0: ecommerce_product_logs 테이블 DROP + 레이아웃 캐시 클리어", + "활동 로그 Per-Item 로깅 전환: 모든 bulk 핸들러가 변경된 건별로 loggable_id 기록", + "OrderActivityLogListener: bulk_update/bulk_status_update/bulk_shipping_update per-Order, bulk_option_status_change per-OrderOption 전환", + "ProductActivityLogListener: bulk_update/bulk_price_update/bulk_stock_update per-Product 전환", + "CouponActivityLogListener: bulk_status per-Coupon 전환", + "EcommerceAdminActivityLogListener: ExtraFee/ShippingPolicy bulk toggle per-item, bulk_delete per-item (loggable_type/loggable_id 직접 지정) 전환", + "ShippingPolicyActivityLogListener: 중복 bulk 훅 구독 2개 삭제 (EcommerceAdminActivityLogListener에 위임)", + "EcommerceUserActivityLogListener: 사용자 주문생성(order.after_create) + 구매확인(order-option.after_confirm) 로그 추가", + "ActivityLogHandler: 삭제된 엔티티용 loggable_type/loggable_id 직접 지정 지원" + ] + }, + { + "name": "Removed", + "items": [ + "ecommerce_product_logs 테이블 (ActivityLog로 대체)", + "ProductLogService, ProductLogRepository, ProductLogController, ProductLog 모델 등 관련 코드 전량 삭제" + ] + }, + { + "name": "Changed", + "items": [ + "처리로그 레이아웃: log.description → log.localized_description (ActivityLogResource 기준)", + "Bulk Update 훅 시그니처: after_bulk_* 훅에 $allChanges 파라미터 추가 (하위 호환 유지)" + ] + } + ] + }, + { + "version": "0.15.0", + "date": "2026-03-26", + "categories": [ + { + "name": "Added", + "items": [ + "구매확정 기능 — 마이페이지 주문상세에서 옵션별 구매확정 API 및 UI", + "`confirmed_at` 컬럼 추가 (ecommerce_order_options 테이블)", + "OrderOption 모델: `confirmed_at` fillable/casts, `review()` HasOne 관계", + "ConfirmOrderOptionRequest: 소유권, 상태, 중복 확정 검증", + "OrderService.confirmOption(): 옵션 확정 + 전체 옵션 확정 시 주문도 CONFIRMED 전환", + "User OrderController.confirmOption(): 구매확정 API 엔드포인트", + "`sirsoft-ecommerce.order-option.before_confirm` / `after_confirm` 훅", + "OrderConfirmPointListener: 구매확정 시 포인트 적립 더미 리스너", + "리뷰 작성 기능 — 구매확정 후 리뷰 작성 모달 UI 및 이미지 순차 업로드", + "userConfirmOrderHandlers.ts: 구매확정 커스텀 핸들러", + "userReviewHandlers.ts: 리뷰 작성 + 이미지 순차 업로드 커스텀 핸들러", + "구매확정/리뷰 작성 모달 레이아웃 (_modal_confirm_purchase.json, _modal_write_review.json)", + "_items.json에 구매확정/리뷰작성/리뷰완료 버튼 조건부 렌더링 (PC + 모바일 동일)", + "OrderOptionResource에 `can_confirm`, `can_write_review`, `has_review` 필드 추가", + "환경설정: `confirmable_statuses` (주문설정), `review_settings` (리뷰설정) 추가", + "사용자 권한: `user-orders.confirm`, `user-reviews.write` 등록 (module.php + Upgrade_0_15_0)", + "리뷰 라우트에 `permission:user,sirsoft-ecommerce.user-reviews.write` 미들웨어 추가", + "다국어 키 추가 (ko/en): 구매확정/리뷰 관련 메시지, 예외, 프론트엔드 번역" + ] + }, + { + "name": "Changed", + "items": [ + "show.json: initLocal에 구매확정/리뷰 상태 변수 추가, modals에 구매확정/리뷰 모달 partial 등록", + "handlers/index.ts: confirmOrderOption, submitReview 핸들러 등록" + ] + }, + { + "name": "Fixed", + "items": [ + "상품폼 처리로그 설명 미표시 — ProductLogResource는 `description` 필드를 반환하나 레이아웃이 `localized_description`을 참조하여 빈 값 표시 (v0.13.0 변경 시 오류)" + ] + } + ] + }, + { + "version": "0.14.0", + "date": "2026-03-25", + "categories": [ + { + "name": "Added", + "items": [ + "클래임 사유 관리 시스템 — DB 기반 환불 사유 관리 (ClaimReason 모델, Repository, Service, Controller, FormRequest, Resource)", + "`ecommerce_claim_reasons` 테이블: type, code, name(다국어), fault_type, is_user_selectable, is_active, sort_order", + "Admin API 7개: 목록/생성/상세/수정/삭제/상태토글/활성목록 (`/admin/claim-reasons`)", + "User API 1개: 사용자 선택 가능 사유 목록 (`/user/claim-reasons`)", + "ClaimReasonFaultTypeEnum (customer/seller/carrier), ClaimReasonTypeEnum (refund)", + "쇼핑몰 환경설정 > 클래임 탭 — 환불 사유 인라인 편집 UI (배송사 관리 패턴)", + "테이블 뷰(PC) + 카드 뷰(모바일) 반응형 레이아웃", + "EcommerceSettingsController에 syncReasons() 연동", + "업그레이드 스크립트 (Upgrade_0_14_0) — 기존 CancelReasonTypeEnum 7개 값을 DB 시드 데이터로 마이그레이션" + ] + }, + { + "name": "Changed", + "items": [ + "주문 취소 사유를 하드코딩 Enum에서 DB 기반으로 전환", + "CancelReasonTypeEnum 삭제 → ClaimReason DB 조회로 대체", + "CancelOrderRequest (Admin/User): `Rule::in(enum)` → `Rule::exists(ecommerce_claim_reasons, code)`", + "OrderCancel 모델: Enum cast 제거, getRefundReasonLabel() 헬퍼 추가", + "OrderCancellationService: Enum 참조 제거, 단순 string 저장", + "관리자/사용자 취소 모달 — 하드코딩 Option에서 동적 iteration으로 변경", + "Admin 모달: refundReasons 데이터소스 + iteration 기반 동적 렌더링", + "User 모달: 동일 패턴 적용 (user/claim-reasons API 사용)" + ] + }, + { + "name": "Fixed", + "items": [ + "활동 로그 description 표시 시 번역 키가 원문 그대로 노출되는 문제 수정", + "ActivityLogDescriptionResolver 추가 — 표시 시점에 ID를 엔티티 이름으로 해석 (HookManager 필터 훅)", + "src/lang/{ko,en}/activity_log.php 번역 파일 동기화 (TranslationServiceProvider 로딩 경로 일치)", + "OrderActivityLogListener: handleOrderAfterSendEmail/handleOrderOptionAfterStatusChange description_params 불일치 수정", + "OrderOption의 product_option_name, option_name, option_value 미저장 및 다국어 미지원 수정 (OrderProcessingService, OrderOption)", + "createOrderOptions()에서 product_option_name/option_value 누락 → ProductOption 다국어 원본 직접 저장", + "3개 컬럼 varchar(255) → JSON 마이그레이션 + 모델 array cast 추가", + "Resource 3개(OrderOptionResource, OrderListResource, UserOrderListResource)에서 로케일 변환 추가", + "기존 문자열 데이터 하위호환 (is_array 체크)", + "StoreEcommerceSettingsRequest: claim/review_settings 탭 저장 실패 수정 — `_tab` validation 및 `validatedSettings()` 누락 보완", + "FormRequest 로케일 하드코딩 수정 — `name.ko` 직접 검증을 `LocaleRequiredTranslatable` Rule로 교체 (StoreEcommerceSettingsRequest, StoreProductRequest)" + ] + } + ] + }, + { + "version": "0.13.8", + "date": "2026-03-25", + "categories": [ + { + "name": "Fixed", + "items": [ + "옵션 레벨 promotions_applied_snapshot이 camelCase로 저장되는 버그 수정 — DTO `toArray()` 호출 누락으로 PHP 프로퍼티명이 그대로 직렬화됨 (OrderProcessingService)" + ] + } + ] + }, + { + "version": "0.13.7", + "date": "2026-03-25", + "categories": [ + { + "name": "Fixed", + "items": [ + "취소 모달 debounce 타이머에서 __g7ActionContext 미복원으로 모달 상태 업데이트 실패 수정 (cancelOrderHandlers, userCancelOrderHandlers)", + "debounce 콜백 실행 시 ActionDispatcher의 try/finally가 이미 __g7ActionContext를 복원하여 모달의 actionContext 사라짐", + "캡처/복원 패턴 적용으로 setLocal()이 모달의 actionContext.setState()를 정상 호출" + ] + }, + { + "name": "Changed", + "items": [ + "OrderActivityLogListener에서 명시적 log_type 지정 제거 — 코어 resolveLogType() 요청 경로 기반 판별로 대체 (6개 핸들러)" + ] + } + ] + }, + { + "version": "0.13.6", + "date": "2026-03-25", + "categories": [ + { + "name": "Fixed", + "items": [ + "toPreviewArray() refund_total 배송비 이중 계산: refundAmount에 이미 배송비 차이가 내포되어 있는데 shippingDifference를 별도 가산하여 환불 예정액 과다 표시 (AdjustmentResult)" + ] + }, + { + "name": "Added", + "items": [ + "refund_total 정합성 검증 테스트 4건: 부분취소 무료→유료 전환, 전체취소 배송비 포함, 쿠폰+배송비 복합 시나리오 (OrderAdjustmentServiceTest G-1~G-4)" + ] + } + ] + }, + { + "version": "0.13.5", + "date": "2026-03-25", + "categories": [ + { + "name": "Fixed", + "items": [ + "상품금액 정액 쿠폰 수량 미반영 수정: 정액 할인이 수량과 무관하게 1회만 적용되던 결함 → 적용 대상 옵션의 수량만큼 할인 적용 (OrderCalculationService.calculateCouponDiscount)", + "OrderSeeder 정액 상품쿠폰 수량 미반영 수정: 시더에서도 target_scope별 적용 대상 수량 계산하여 할인 적용 (OrderSeeder.getTargetQuantity)" + ] + }, + { + "name": "Added", + "items": [ + "부분취소 시 쿠폰 조건 미달로 결제금액 증가 시 취소 차단: validateRefundNotNegative 검증 + cancel_blocked 플래그 (OrderCancellationService, AdjustmentResult)", + "Admin/User 취소 모달에 취소 불가 경고 배너 + 취소 버튼 비활성화 (_modal_cancel_order.json, _modal_cancel.json)", + "수량별 정액 할인 테스트 16개 + cancel_blocked 테스트 4개 (OrderCalculationServiceTest, OrderAdjustmentServiceTest)" + ] + } + ] + }, + { + "version": "0.13.4", + "date": "2026-03-25", + "categories": [ + { + "name": "Fixed", + "items": [ + "쿠폰 수정 화면 적용 상품 테이블 데이터 미표시: CouponResource의 included_products/excluded_products에 product_code, name_localized, selling_price_formatted 필드 추가 (CouponResource.php)" + ] + } + ] + }, + { + "version": "0.13.3", + "date": "2026-03-25", + "categories": [ + { + "name": "Fixed", + "items": [ + "관리자 취소 모달 수량 변경 시 비교 테이블 미갱신 결함 수정: updateCancelQuantity 핸들러에 value 파라미터 누락 추가 (_modal_cancel_order.json)", + "관리자 취소 모달 onMount setState로 인한 모달/페이지 scope 단절 해소: onMount 제거 + 초기화 핸들러에서 상태 설정 및 estimateRefundAmount 호출 패턴으로 전환 (orderDetailHandlers.ts, _modal_cancel_order.json)" + ] + } + ] + }, + { + "version": "0.13.2", + "date": "2026-03-25", + "categories": [ + { + "name": "Fixed", + "items": [ + "상품수량(item_count) 스냅샷이 고유 상품 수(distinct)가 아닌 총 수량 합계로 계산되도록 수정 (captureOrderSnapshot)", + "상품쿠폰 최대할인금액 초과 결함 수정: 아이템별 할인 산출 후 글로벌 cap 비례 안분 적용 (applyProductCoupons 3단계 리팩토링)", + "CouponApplication 기록 시 존재하지 않는 필드($coupon->max_discount_amount) 대신 실제 DB 컬럼($coupon->discount_max_amount) 사용하도록 수정 (4곳)", + "buildCouponSnapshotsFromOrder()에 max_discount_amount 필드 추가하여 취소 재계산 시 쿠폰 최대할인 정보 보존" + ] + } + ] + }, + { + "version": "0.13.1", + "date": "2026-03-25", + "categories": [ + { + "name": "Added", + "items": [ + "취소 모달 주문금액 비교 테이블: 취소 전/후 금액 비교 표시 (상품수량, 총 정가금액, 상품/주문/배송비쿠폰 할인, 할인코드 할인, 기본/추가배송비, 포인트 사용, 과세금액, 부가세, 실결제금액, 적립예정포인트)", + "쿠폰 상세 표시: 상품쿠폰/주문쿠폰/배송비쿠폰별 적용 쿠폰명 + 할인금액 표시 (original_coupons, recalculated_coupons)", + "다통화 금액 표시: 총 정가금액/실결제금액 셀 하단에 다통화 금액 인라인 표시 (mc_original_snapshot, mc_recalculated_snapshot)", + "스냅샷 필드 확장: captureOrderSnapshot/captureRecalcSnapshot에 5개 필드 추가 (total_product_coupon_discount_amount, total_order_coupon_discount_amount, total_vat_amount, total_earned_points_amount, item_count)", + "AdjustmentResult DTO: mcOriginalSnapshot, mcRecalculatedSnapshot, originalCoupons, recalculatedCoupons 프로퍼티 추가", + "상품 소계 표시: 단가 × 수량 = 소계 형태로 변경 (Admin/User 모달 공통)", + "Admin 취소 모달 크기 1.5배 확대 (750px → 1125px), User 모달 크기 조정 (lg → 750px)", + "다국어 키 18개 추가 (ko/en, Admin ecommerce + User sirsoft-basic)" + ] + }, + { + "name": "Changed", + "items": [ + "취소 모달 \"환불 예정금액\" 섹션을 \"주문금액 비교\" 테이블로 전면 교체 (Admin/User 공통)", + "변경된 금액에 적색 하이라이트 적용 (text-red-600 dark:text-red-400 font-semibold)" + ] + } + ] + }, + { + "version": "0.13.0", + "date": "2026-03-25", + "categories": [ + { + "name": "Added", + "items": [ + "ActivityLog 리스너 전면 구현:", + "OrderActivityLogListener (주문 CUD + 상태변경 + 일괄처리 + 배송 + 이메일 + 취소/부분취소/환불)", + "CouponActivityLogListener (쿠폰 CUD + 일괄 상태변경)", + "ShippingPolicyActivityLogListener (배송정책 CUD + 토글 + 일괄처리)", + "CategoryActivityLogListener (카테고리 CUD + 순서변경 + 상태전환)", + "EcommerceAdminActivityLogListener (브랜드/라벨/공통정보/고시정보/추가비용/배송업체/상품옵션/상품이미지/리뷰 통합)", + "EcommerceUserActivityLogListener (장바구니/위시리스트/쿠폰사용/마일리지 — ActivityLogType::User)", + "활동 로그 다국어 키 100개 정의 (resources/lang/ko/activity_log.php, en/activity_log.php)", + "$activityLogFields 메타데이터: Product, Order, Coupon, ShippingPolicy 모델", + "ActivityLog 샘플 시더 (database/seeders/ActivityLogSampleSeeder.php)" + ] + }, + { + "name": "Changed", + "items": [ + "ProductActivityLogListener: ActivityLogService 호출 → Log::channel('activity') 직접 호출로 전환", + "주문상세 로그 탭 레이아웃: log.description → log.localized_description", + "상품폼 로그 섹션 레이아웃: log.description → log.localized_description" + ] + } + ] + }, + { + "version": "0.12.6", + "date": "2026-03-25", + "categories": [ + { + "name": "Added", + "items": [ + "OrderSeeder: 쿠폰 적용 주문 생성 — CouponIssue 테이블 조회 기반, 상품금액/주문금액 쿠폰 배분, promotions_applied_snapshot 스냅샷, 약 40% 주문에 쿠폰 적용", + "OrderSeeder: 주문 삭제 시 쿠폰 발급 상태 자동 복원 (used → available)", + "OrderSeeder: 배송비 쿠폰(shipping_fee) 적용 — 배송 레코드에 shipping_discount_amount 배분, 주문 합계에 배송 할인 반영", + "OrderSeeder: 결제완료 이상 상태 쿠폰 적용 비율 80%로 상향, 쿠폰 우선 배정 상태를 먼저 생성하여 쿠폰 재고 확보", + "OrderSeeder: payment_complete 비율 15→20%로 상향, 쿠폰 우선 상태의 비회원 비율 절반으로 축소" + ] + }, + { + "name": "Fixed", + "items": [ + "OrderSeeder: 주문 합계 재계산 시 쿠폰별 할인 필드(product_coupon, order_coupon, coupon, code) 정확히 합산하도록 개선", + "OrderSeeder: 주문 합계의 base_shipping_amount, shipping_discount_amount가 배송 레코드 실값을 반영하도록 수정", + "OrderSeeder: orders.promotions_applied_snapshot 저장 누락 수정 — 부분취소 시 쿠폰 재적용 불가 문제 해결 (coupon_issue_ids, product_promotions, order_promotions 구조)", + "OrderSeeder: 쿠폰 복원을 주문 삭제 전으로 이동 — order_id FK ON DELETE SET NULL로 인한 고아 쿠폰(status=used, order_id=null) 방지", + "OrderAdjustmentService: 복원 쿠폰 정보에서 다국어 쿠폰명(JSON 배열)을 문자열로 변환하지 않아 React 렌더링 오류 발생 — getLocalizedName() 사용으로 수정" + ] + } + ] + }, + { + "version": "0.12.5", + "date": "2026-03-25", + "categories": [ + { + "name": "Fixed", + "items": [ + "시더/팩토리 유령 할인 제거 — 출처 없는 랜덤 할인이 환불 재계산 시 잘못된 discount_difference 유발 (OrderSeeder, OrderOptionFactory, OrderFactory)", + "OrderSeeder: 결제 완료 주문의 mc_total_paid_amount가 항상 0이던 다중통화 불일치 수정" + ] + } + ] + }, + { + "version": "0.12.4", + "date": "2026-03-25", + "categories": [ + { + "name": "Added", + "items": [ + "사용자 주문 상품 선택 핸들러 추가 — toggleItemSelection, toggleSelectAllItems (userCancelOrderHandlers)", + "initUserCancelItems: 선택된 상품(_local.selectedItemIds) 기반으로 취소 대상 필터링" + ] + }, + { + "name": "Fixed", + "items": [ + "사용자 주문 취소 모달이 열리지 않던 버그 수정 — modals 섹션 모달에 id 추가 및 openModal/closeModal 패턴으로 전환 (userCancelOrderHandlers, _modal_cancel.json)", + "Upgrade_0_7_0: app(ModuleSettingsService::class) 직접 호출을 module_setting() 헬퍼로 교체" + ] + } + ] + }, + { + "version": "0.12.3", + "date": "2026-03-24", + "categories": [ + { + "name": "Fixed", + "items": [ + "OrderResource(주문상세 API)에 can_cancel ability 누락 — 사용자 주문상세 화면에서 취소 버튼이 절대 표시되지 않던 버그 수정", + "OrderResource에 resolveAbilities() override 추가 (상태 + 환경설정 + 권한 복합 조건)", + "Resource/Request에서 app(EcommerceSettingsService::class) 직접 호출을 module_setting() 헬퍼로 교체 (4개 파일)" + ] + }, + { + "name": "Added", + "items": [ + "주문상세 API abilities 테스트 3건 추가 (can_cancel true/false/권한 없음)" + ] + } + ] + }, + { + "version": "0.12.2", + "date": "2026-03-24", + "categories": [ + { + "name": "Fixed", + "items": [ + "주문 취소 시 환경설정(cancellable_statuses)을 참조하지 않고 하드코딩된 기본값만 사용하던 버그 수정", + "Admin/User CancelOrderRequest: EcommerceSettingsService에서 취소 가능 상태 조회", + "UserOrderListResource: can_cancel 판정에 환경설정 반영", + "OrderCancellationService: 상세 에러 메시지(현재 상태 + 취소 가능 상태 목록) 반환", + "프론트엔드 취소 핸들러에서 API 상세 에러 메시지를 토스트에 표시하도록 개선", + "주문 취소 시 취소 사유(reason) 미선택으로 검증 우회되던 버그 수정 (nullable → required)", + "취소 모달에서 422 validation 에러가 PG 에러 영역에 잘못 표시되던 버그 수정", + "422 → 필드별 에러 표시, 기타 에러 → PG 에러 영역으로 분리", + "CancelOrderRequest failedValidation에 필드별 에러(errors) 미포함 수정", + "취소 사유 필드 에러 메시지에 원시 필드명(reason) 노출 → 한국어 표시명(취소 사유) 적용" + ] + }, + { + "name": "Added", + "items": [ + "Order 모델에 getCancelDeniedReason(), getCancellableStatuses() 헬퍼 메서드 추가", + "취소 불가 상세 에러 다국어 키 추가 (order_not_cancellable_detail, order_already_cancelled)", + "환경설정 기반 취소 가능 상태 검증 테스트 4건 추가", + "취소 사유 필수 검증 테스트 추가", + "취소 모달 validation_error_title 다국어 키 추가 (ko/en)" + ] + }, + { + "name": "Changed", + "items": [ + "취소 모달 환불 우선순위 라디오: 마일리지 사용 주문 조건 제거 → 항상 표시" + ] + } + ] + }, + { + "version": "0.12.1", + "date": "2026-03-24", + "categories": [ + { + "name": "Fixed", + "items": [ + "취소 모달 배송비 정책명이 번역 키 그대로 노출되던 버그 수정 (OrderAdjustmentService)", + "OrderShipping에 shippingPolicy 관계 추가 (누락)", + "buildFullCancelResult() / buildShippingDetails()에서 ShippingPolicy::getLocalizedName() 사용", + "order.json에 default_shipping 번역 키 추가 (폴백용)" + ] + } + ] + }, + { + "version": "0.12.0", + "date": "2026-03-24", + "categories": [ + { + "name": "Added", + "items": [ + "배송비 계산 확장 훅: `sirsoft-ecommerce.shipping.calculate_fee` — 외부 확장이 배송비 계산을 오버라이드 가능", + "프로모션 스냅샷 저장 훅: `sirsoft-ecommerce.calculation.filter_promotions_snapshot` — 주문 생성 시 스냅샷에 확장 데이터 주입 가능", + "프로모션 스냅샷 복원 훅: `sirsoft-ecommerce.adjustment.filter_restore_promotions` — 환불 재계산 시 스냅샷에서 확장 데이터 해석/복원 가능", + "AppliedShippingPolicy DTO: `hookOverridden` 플래그 — 훅 오버라이드 배송비 식별용", + "스냅샷 기반 훅 오버라이드 배송비 보존: 환불 시 훅이 계산한 배송비를 스냅샷에서 복원 (훅 비활성 대응)", + "PluginExtensibilityTest: 16개 테스트 (배송정책 6, 할인정책 7, 복합 시나리오 3)", + "관리자/사용자 취소 모달 환불 예상금액 UI 확장: 배송비 정책별 상세, 잔여 PG/마일리지 잔액, 복원 쿠폰 목록, 총 환불액(PG+마일리지+배송비)", + "`changeRefundPriority` / `changeUserRefundPriority` 커스텀 핸들러: 환불 우선순위 변경 → debounce 재계산 연동", + "`refund_priority` 파라미터를 환불 예상금액 API에 전달 (관리자/사용자 공통)", + "mc_* 다중통화 환불 테스트 5건: 기본 변환, null 스냅샷, 마일리지 변환, 배송비 변환, previewArray 포함 확인", + "관리자 취소 모달 취소사유 라디오 7개 옵션 (CancelReasonTypeEnum 기반): order_mistake, changed_mind, reorder_other, delayed_delivery, product_info_different, admin_cancel, etc", + "i18n 취소사유 키 중첩 구조 변경: `cancel_reason_type_label` + `reason.{type}` 형태 (ko/en)", + "스냅샷 기반 환불 재계산 테스트 84건 추가 (B-1~B-7):", + "OrderAdjustmentServiceTest +33건 (쿠폰 복원 감지, 환불 우선순위, 배송비 상세, mc_* 변환, 복합 시나리오)", + "OrderCancellationServiceTest +17건 (쿠폰 복원 실행, 환불 처리, mc_* 레코드, 스냅샷 갱신)", + "OrderCalculationServiceTest +11건 (스냅샷 모드 검증)", + "OrderCalculationServiceMultiCurrencyTest +5건 (스냅샷 환율 변환)", + "Admin/User Feature 테스트 +12건 (환불 예상 API, 쿠폰 복원 DB)", + "프론트엔드 레이아웃 렌더링 테스트 +42건 (취소 모달 구조, 라디오, 조건부 렌더링)" + ] + }, + { + "name": "Fixed", + "items": [ + "`buildFullCancelResult`에 mc_* 다통화 변환, 배송비 상세, 복원 쿠폰 정보, 잔여 잔액, 환불 우선순위 누락 수정", + "`buildFullCancelResult`에서 OrderShipping 컬럼명 오류 수정 (`base_shipping_fee` → `base_shipping_amount`, `extra_shipping_fee` → `extra_shipping_amount`)" + ] + } + ] + }, + { + "version": "0.11.1", + "date": "2026-03-24", + "categories": [ + { + "name": "Added", + "items": [ + "OrderShippingRepository: 배송 모델 Repository 인터페이스 + 구현체 신규 생성", + "CouponIssueRepository: `update()`, `findByIds()`, `findByIdsWithRelations()` 메서드 추가", + "SnapshotProduct, SnapshotProductOption DTO: 스냅샷 기반 환불 재계산용", + "RefundPriorityEnum: 환불 우선순위 정의 (SHIPPING_DISCOUNT, ORDER_DISCOUNT, MILEAGE 등)", + "CouponRestoreListener: 주문 취소 시 쿠폰 사용 상태 자동 복원", + "OrderRefund 다통화 환불 컬럼 마이그레이션 (mc_refund_columns)", + "스냅샷 기반 환불 재계산 로직 (OrderCalculationService snapshot_mode, OrderAdjustmentService)", + "취소/환불 관련 예외 다국어 키 추가 (cancel_option_not_found, pg_refund_failed 등)", + "취소/환불 관련 Enum 다국어 라벨 추가 (CancelStatusEnum, RefundStatusEnum 등)", + "스냅샷 재계산 테스트 (SnapshotRecalculationTest), 쿠폰 복원 리스너 테스트 (CouponRestoreListenerTest)" + ] + }, + { + "name": "Fixed", + "items": [ + "Repository 패턴 위반 수정: 4개 서비스 + 1개 리스너의 직접 Eloquent 쿼리 → Repository 인터페이스 주입", + "CurrencyConversionService 예외 메시지 하드코딩 → `__()` 다국어 처리" + ] + }, + { + "name": "Changed", + "items": [ + "OrderAdjustmentService: CouponIssue 직접 접근 → CouponIssueRepositoryInterface 주입", + "OrderCancellationService: OrderShipping 직접 접근 → OrderShippingRepositoryInterface 주입", + "OrderOptionService: OrderShipping 직접 접근 → OrderShippingRepositoryInterface 주입", + "ShippingCarrierService: OrderShipping 직접 접근 → OrderShippingRepositoryInterface 주입", + "CouponRestoreListener: CouponIssue 직접 접근 → CouponIssueRepositoryInterface 주입", + "EcommerceServiceProvider: OrderShippingRepository 바인딩 등록" + ] + } + ] + }, + { + "version": "0.11.0", + "date": "2026-03-24", + "categories": [ + { + "name": "Added", + "items": [ + "주문 취소/환불 시스템: 관리자 전체취소·부분취소, 사용자 취소 요청, 환불 금액 산출", + "주문 취소 관련 모델 추가 (OrderCancel, OrderCancelOption, OrderRefund, OrderRefundOption)", + "주문 취소 Enum 추가 (CancelStatusEnum, CancelTypeEnum, CancelReasonTypeEnum, RefundStatusEnum, RefundMethodEnum 등)", + "주문 취소 서비스 추가 (OrderCancellationService, OrderAdjustmentService)", + "주문 취소 DTO 추가 (CancellationResult, CancellationAdjustment, OrderAdjustment, AdjustmentResult)", + "관리자 주문 취소 API 엔드포인트 (estimate-refund, cancel)", + "사용자 주문 취소 API 엔드포인트 (estimate-refund, cancel)", + "관리자 주문상세 취소 모달 레이아웃 (_modal_cancel_order.json)", + "사용자 마이페이지 주문 취소 모달 확장 (부분취소 지원)", + "주문 취소 프론트엔드 핸들러 (cancelOrderHandlers, userCancelOrderHandlers)", + "토스페이먼츠 환불 리스너 (PaymentRefundListener)", + "관리자 환경설정 주문취소 가능 상태 다중 선택 UI (_tab_order_settings.json)", + "관리자 주문상세 결제정보에 취소/환불 금액 표시 (_partial_payment_info.json)", + "주문관리 목록 주문자(회원) 검색 필터: SearchableDropdown 기반 UUID 검색", + "주문관리 DataGrid 주문자 클릭 시 회원/비회원 분기 검색 (회원: orderer_uuid, 비회원: search_keyword)", + "관리자 주문상세 일괄변경에서 주문취소 선택 시 PG 결제 취소 체크박스 인라인 표시" + ] + }, + { + "name": "Fixed", + "items": [ + "주문 취소 모달: 환불 예상금액 미표시 수정 (isolated 모달 스코프 상태 불일치 해결)", + "주문 취소 모달: 현재상태 컬럼에 enum 원시값 대신 다국어 라벨 표시", + "주문 취소 모달: PG 체크박스/라벨 클릭 연동 (htmlFor 추가)" + ] + }, + { + "name": "Changed", + "items": [ + "취소 상태 Enum 재설계: PENDING→REQUESTED, FAILED 제거 (2단계: 신청/완료)", + "환불 상태 Enum 재설계: 6단계 (신청/승인/처리중/보류/완료/반려), FAILED 제거", + "Option 상태를 부모 상태와 동일하게 동기화", + "OrderCancel 모델: isFailed() 메서드 제거", + "OrderRefund 모델: isFailed()→isRejected() 변경", + "PG 환불 실패 시 FAILED 상태 설정 대신 REQUESTED 유지 (에러 정보만 기록)", + "OrderStatusEnum: 주문취소(CANCELLED) 상태 추가", + "SequenceType: 취소(CANCEL), 환불(REFUND) 시퀀스 타입 추가", + "Order 모델: 취소 관련 관계 및 메서드 추가 (cancels, refunds, isCancellable 등)", + "OrderOption 모델: cancelled_quantity 컬럼 추가", + "OrderProcessingService: 취소 처리 로직 연동", + "관리자 OrderController: 취소/환불 산출 액션 추가", + "사용자 OrderController: 취소 요청 액션 추가" + ] + } + ] + }, + { + "version": "0.10.6", + "date": "2026-03-24", + "categories": [ + { + "name": "Changed", + "items": [ + "상품목록 API: 비활성 옵션도 포함하여 로드 (관리자 화면에서 비활성 옵션 표시)", + "상품상세 API: `includeInactive` 파라미터 추가 (관리자 수정 화면에서 비활성 옵션 포함)", + "옵션 일괄 변경 `option_name` 검증 규칙: `string` → `array` (다국어 배열 지원)", + "옵션 일괄 변경 `list_price` 검증 규칙 추가", + "일괄 변경 확인 모달: 처리중 상태 표시 (spinner + 버튼 비활성화)", + "상품 인라인 수정 필드 추적 확장 (`modifiedProductFields` 추가)", + "상품목록 DataGrid 선택 개수 표시 비활성화", + "주문목록 배송국가: 국가 코드(KR) → 다국어 국가명(한국) 표시 (OrderListResource, DataGrid)" + ] + }, + { + "name": "Fixed", + "items": [ + "시더(OrderAddressFactory)에서 `recipient_country_code` 기본값 누락으로 주문목록 배송국가 미표시 문제 수정", + "관리자 주문 수정 시 배송국가(`recipient_country_code`) 변경 불가 문제 수정 (OrderService, UpdateOrderRequest)", + "상품목록 리소스: `options` relation 로드 시 `option_stock_sum`, `options_count`가 빈 값으로 표시되는 문제 수정 (`relationLoaded` 분기 추가)", + "상품옵션 일괄 변경 후 상품 재고(stock_quantity)가 동기화되지 않는 문제 수정 (`ProductOptionService::bulkUpdate`에 `syncStockFromOptions` 호출 추가)", + "옵션 재고 합산 시 비활성 옵션이 포함되는 문제 수정 (프론트엔드 `updateOptionField`, `calculateTotalOptionStock` 핸들러에 `is_active` 필터 추가)", + "일괄 변경 확인 모달: 인라인 수정 시 `{method}` 플레이스홀더가 리터럴로 표시되는 문제 수정 (인라인 전용 다국어 키 분리)" + ] + } + ] + }, + { + "version": "0.10.5", + "date": "2026-03-23", + "categories": [ + { + "name": "Changed", + "items": [ + "상품수정 화면: 일부 미구현 탭 숨김 처리" + ] + }, + { + "name": "Fixed", + "items": [ + "UserAddressSeeder: name 필드에 다국어 배열 대입으로 인한 \"Array to string conversion\" 오류 수정 (단순 문자열로 변경)" + ] + } + ] + }, + { + "version": "0.10.4", + "date": "2026-03-23", + "categories": [ + { + "name": "Changed", + "items": [ + "주문상세 활동 로그: Badge → 아바타 원형+이름 스타일로 변경, 처리자 클릭 시 ActionMenu(회원정보 보기) 추가 (PC+모바일)", + "상품수정 활동 로그: \"작업\" 컬럼 제거, 처리자 클릭 시 ActionMenu(회원정보 보기) 추가 (PC+모바일)", + "주문상세/상품수정 활동 로그 UI 일관성 통일" + ] + }, + { + "name": "Fixed", + "items": [ + "상품수정 활동 로그: Select $event → $event.target.value, refreshDataSource → refetchDataSource, Pagination type:pageChange → event:onPageChange, 빈 상태 조건 경로 수정, 데이터소스 sort_order/per_page 파라미터 추가", + "주문상세 활동 로그: 동일 버그 6건 수정 (주문상세와 상품수정 패턴 통일)" + ] + } + ] + }, + { + "version": "0.10.3", + "date": "2026-03-23", + "categories": [ + { + "name": "Added", + "items": [ + "주문목록 주문상품 컬럼: 대표상품 썸네일, \"외 X건\" 표시, 상품수정 링크(새 창)", + "OrderListResource: first_option에 product_code 필드 추가" + ] + } + ] + }, + { + "version": "0.10.2", + "date": "2026-03-23", + "categories": [ + { + "name": "Added", + "items": [ + "주문상세 상품 이미지/상품명 클릭 시 상품수정 페이지로 이동 (새 창)" + ] + } + ] + }, + { + "version": "0.10.1", + "date": "2026-03-23", + "categories": [ + { + "name": "Changed", + "items": [ + "API Resource: user.id → user.uuid 전환 (OrderResource, OrderListResource, CouponResource, CouponIssueResource, ProductReviewResource, ProductLogResource, UserAddressResource, ExtraFeeTemplateResource)", + "user_id FK → user.uuid 전환 (CouponIssueResource, ProductReviewResource, UserAddressResource)", + "ExtraFeeTemplate: created_by → creator.uuid 전환 (creator 관계 활용)", + "쿠폰 관리 등록자 컬럼: eager-load에 uuid 컬럼 추가 (CouponRepository, ProductLogRepository)", + "관리자 레이아웃: user.id → user.uuid 참조 전환 (4개 파일)", + "orderHandlers.ts: localStorage 키에 user.uuid 사용", + "FormRequest: 정수 검증 → UUID 검증 전환 (CouponListRequest, CouponIssuesListRequest)", + "MergeCartOnLoginListener: Activity Log user_id → uuid 전환" + ] + } + ] + }, + { + "version": "0.10.0", + "date": "2026-03-22", + "categories": [ + { + "name": "Added", + "items": [ + "주문상세 이메일 발송 기능 — `POST /orders/{order}/send-email` API + 모달 UI 연동", + "SendOrderEmailRequest FormRequest — 이메일/메시지 검증", + "OrderService.sendEmail() — `Mail::raw()` + `HookManager::doAction()` 패턴 (코어 `sendTestMail()` 참조)", + "이메일 발송 다국어 메시지 (ko/en messages + validation + 프론트엔드 toast)" + ] + }, + { + "name": "Fixed", + "items": [ + "이메일 모달 보내기 버튼 무반응 — `actions` 배열 추가 (sequence: setState → apiCall → toast/closeModal)", + "이메일 모달 Textarea 높이 미적용 — `min-h-[100px]` (빌드 CSS 미존재) → `min-h-32` (safelist 존재)", + "이메일 모달 API 호출 시 `/undefined` 요청 — `params.endpoint` → `action.target` 수정", + "이메일 발송 실패 시 에러 원인 미표시 — catch 블록에 `$e->getMessage()` 상세 포함" + ] + }, + { + "name": "Changed", + "items": [ + "이메일 모달 `_global.isSending` → `_local.isSending` (모달 스코프 내 상태 관리)" + ] + } + ] + }, + { + "version": "0.9.3", + "date": "2026-03-22", + "categories": [ + { + "name": "Added", + "items": [ + "비회원 주문 Factory/Seeder 추가 — `OrderFactory::guest()` 상태, `OrderSeeder`에 비회원 주문 생성 로직", + "OrderOptionResource에 `list_price`, `list_price_formatted`, `final_amount`, `final_amount_formatted` 필드 추가", + "다국어 키 추가 — `phone_or_tel_hint` (ko/en)" + ] + }, + { + "name": "Fixed", + "items": [ + "주문상세 저장 버튼 미동작 — apiCall `params.endpoint` → `target` 속성, `auth_mode: \"required\"` 추가, onSuccess/onError 콜백 위치 수정", + "주문상세 수취인 정보 미표시 — `orderDetailHandlers.ts` 데이터 접근 경로 수정 (`_context.data.data` 우선 접근)", + "주문상세 수취인 정보 수정 값 미반영 — 폼 자동 바인딩 (`dataKey: \"form\"` + child `name`) 패턴 적용", + "Validation error 적색 테두리 미표시 — `.input` 클래스에 border 없음, `border border-red-500 dark:border-red-500` 으로 수정" + ] + }, + { + "name": "Changed", + "items": [ + "OrderService.update() 수취인 필드 저장 처리 추가 — shippingAddress 관계 업데이트", + "주문상세 폼 데이터 바인딩을 `initLocal` 패턴으로 변경 — 커스텀 핸들러(`initOrderDetailForm`) → 데이터소스 `initLocal`", + "UpdateOrderRequest validation 강화 — `recipient_name`, `recipient_zipcode`, `recipient_detail_address` 필수, `recipient_phone`/`recipient_tel` 택1 필수 (`required_without`)", + "CreateOrderRequest validation 통일 — `recipient_phone` required → `required_without:recipient_tel`, `address_detail` nullable → required", + "배송메시지 Textarea `textarea-sm` (80px), 관리자메모 Textarea `textarea-md` (128px) 크기 적용", + "OrderOptionResource `mc_subtotal_discount_amount` 제거 — 존재하지 않는 DB 필드 참조" + ] + } + ] + }, + { + "version": "0.9.2", + "date": "2026-03-22", + "categories": [ + { + "name": "Added", + "items": [ + "OrderResource에 `total_quantity`, `total_list_price`, `total_list_price_formatted` 필드 추가 — 합계행 백엔드 필드 기반 표시", + "주문상세 합계행(footerCells) 및 개별행에 다통화(보조 통화) 표시 — iteration 패턴으로 판매가/소계 하단에 표시" + ] + }, + { + "name": "Fixed", + "items": [ + "주문상세 합계행 판매가/수량/할인/적립 합계가 실제 데이터와 불일치 — computed 전면 제거 후 백엔드 필드로 대체", + "할인 0원에 마이너스 부호 표시(-0원) — 백엔드 `total_discount_amount > 0` 조건으로 수정", + "개별행 실구매가격이 할인 전 금액 표시 — `subtotal_price_formatted` → `final_amount_formatted`(할인 후)로 변경", + "OrderOptionResource의 `mc_subtotal_discount_amount` 버그 — 잘못된 필드(`mc_coupon_discount_amount`) 참조 수정" + ] + }, + { + "name": "Changed", + "items": [ + "관리자 주문상세 라우트를 ID 기준에서 order_number 기준으로 변경 — URL이 `/admin/ecommerce/orders/ORD-xxx` 형태로 표시 (상품 수정의 product_code 패턴과 동일)", + "Order 모델에 `resolveRouteBinding()` 추가 — 숫자는 ID, 문자열은 order_number로 자동 판별 (하위 호환)", + "API 라우트 `whereNumber('order')` 제약 제거 — order_number(문자열) 수용", + "주문 목록/상세/리뷰 레이아웃 및 커스텀 핸들러에서 navigate 경로를 order_number 기반으로 변경" + ] + } + ] + }, + { + "version": "0.9.1", + "date": "2026-03-21", + "categories": [ + { + "name": "Fixed", + "items": [ + "상품리뷰 정렬 기준 validation 수정 — sort 파라미터 `latest,oldest` → `created_at_desc,created_at_asc`로 통일 (PublicReviewListRequest)" + ] + } + ] + }, + { + "version": "0.9.0", + "date": "2026-03-21", + "categories": [ + { + "name": "Added", + "items": [ + "주문상세 수량 단위 상태 변경 기능 — 주문상품옵션의 일부 수량만 선택하여 상태 변경 (예: 3개 중 2개만 배송중으로 변경)", + "주문상품옵션 병합(Merge) 로직 — 분할 후 남은 수량을 같은 상태로 변경 시 자동 병합 (cascade 삭제 안전 처리 포함)", + "일괄변경 확인 모달에 상품 목록 표시 — 썸네일, 상품명/옵션/SKU, 판매가, 수량 편집 Input, 현재상태 Badge", + "updateChangeQuantityHandler 핸들러 추가 — 모달 내 수량 편집 시 1~최대수량 클램핑", + "다국어 키 5종 추가 — product_col, price_col, change_quantity_col, current_status_col, quantity_note (ko/en)", + "백엔드 테스트 15건 추가 — 금액 분할 정확성, 병합 로직, cascade 안전성, 스냅샷 보존, 순차 분할", + "프론트엔드 핸들러 테스트 12건 추가 — bulkConfirmItems 구성, 수량 클램핑, changeQuantities 반영" + ] + }, + { + "name": "Fixed", + "items": [ + "금액 분할 계산 2배 오류 수정 — replicate() 후 원본+복제본 금액이 동일하여 ratio 적용 시 2배로 계산되던 버그 (원본 금액 사전 캡처 방식으로 수정)" + ] + }, + { + "name": "Changed", + "items": [ + "buildOrderDetailBulkConfirmDataHandler 개선 — 선택 상품의 상세 정보(bulkConfirmItems)와 수량 맵(changeQuantities)을 _local에 저장 후 모달 열기", + "일괄변경 확인 모달 크기 lg로 확대 및 상품 목록 테이블 추가" + ] + } + ] + }, + { + "version": "0.8.3", + "date": "2026-03-21", + "categories": [ + { + "name": "Fixed", + "items": [ + "주문목록/주문상세 모달 9개 규정 미준수 전면 수정 — btn 클래스 → 명시적 Tailwind 클래스, 푸터 gap/mt 규정 준수, Icon name 규정 준수 (pen-to-square, trash)", + "모달 onError 핸들러 배열 형식 수정 — 객체 → 배열, error.message → $error.message (preset_save, preset_edit, preset_delete_confirm)", + "모달 액션 버튼 스피너/비활성화 처리 추가 — _global.isProcessing/isSaving/isDeleting/isDownloading/isSending 상태 기반 로딩 UI", + "Excel 다운로드 모달 meta.is_partial 누락 추가, 불필요한 props.id/closeOnOutsideClick/p-6 래퍼 제거" + ] + } + ] + }, + { + "version": "0.8.2", + "date": "2026-03-20", + "categories": [ + { + "name": "Changed", + "items": [ + "주문 일괄변경 드롭다운에서 pending_order(주문대기) 상태 제외 — 주문목록/주문상세 레이아웃 + 백엔드 FormRequest 검증 (BulkUpdateOrdersRequest)", + "주문상세 일괄변경 드롭다운에 누락된 상태 추가 — pending_payment, shipping_hold, confirmed, cancelled (pending_order 제외 전체 상태 표시)", + "주문 일괄변경 드롭다운 너비 확대(w-36 → w-44) — 주문목록/주문상세 공통, 한글 상태명 잘림 방지" + ] + }, + { + "name": "Fixed", + "items": [ + "주문상세 일괄변경 드롭다운 pending_payment 다국어 키 오류 수정 — order_status_options.pending_payment → order_status_options.payment_pending (enum 값과 다국어 키 불일치)" + ] + } + ] + }, + { + "version": "0.8.1", + "date": "2026-03-20", + "categories": [ + { + "name": "Added", + "items": [ + "OrderStatusEnum 배송 정보 필수 메서드 추가 — requiresShippingInfo(), shippingInfoRequiredStatuses(), shippingInfoRequiredValues()", + "주문 일괄변경 배송 상태(shipping_ready/shipping/delivered) 선택 시 택배사/송장번호 필수 검증 — 프론트엔드 핸들러(toast 경고), 레이아웃 모달(버튼 비활성화 + 경고 메시지), 백엔드 FormRequest 3계층 검증", + "BulkUpdateOrdersRequest carrier_id 존재 검증 추가 — Rule::exists(ShippingCarrier::class, 'id')", + "주문상세/주문관리 일괄변경 확인 모달에 배송정보 누락 경고 메시지 추가 (적색 텍스트)", + "다국어 키 추가 — carrier_required (ko/en), tracking_number_required (ko/en)", + "OrderOptionBulkStatusTest 배송정보 필수 검증 테스트 3건 추가" + ] + }, + { + "name": "Fixed", + "items": [ + "processOrderDetailBulkChangeHandler/saveAdminMemoHandler API 호출 수정 — G7Core.api.call() → G7Core.api.patch() (존재하지 않는 메서드 호출 오류)", + "processOrderDetailBulkChangeHandler 모달/디스패치 호출 수정 — G7Core.modal?.close?.() / G7Core.dispatch?.() 패턴 적용", + "processOrderDetailBulkChangeHandler 상태 조회 방식 변경 — getLocal() → action.params 직접 전달 (stale 상태 방지)" + ] + }, + { + "name": "Changed", + "items": [ + "BulkUpdateOrdersRequest/BulkChangeOrderOptionStatusRequest 배송 상태 검증을 하드코딩 → OrderStatusEnum::shippingInfoRequiredValues() 메서드로 리팩토링" + ] + } + ] + }, + { + "version": "0.8.0", + "date": "2026-03-20", + "categories": [ + { + "name": "Changed", + "items": [ + "OrderOptionStatusEnum 제거 및 OrderStatusEnum으로 통일 — 주문옵션 상태를 주문 상태와 동일한 Enum으로 관리", + "주문 일괄변경(bulkUpdate) 시 주문상품옵션 상태도 동일하게 일괄 변경 — 기존에는 orders 테이블만 업데이트" + ] + }, + { + "name": "Removed", + "items": [ + "OrderOptionStatusEnum 삭제 — OrderStatusEnum으로 완전 대체 (클레임 상태는 별도 모듈에서 지원 예정)", + "다국어 order_option_status 섹션 제거 — order_status 키 공용 사용" + ] + }, + { + "name": "Added", + "items": [ + "OrderRepository::bulkUpdateOptionStatus() 메서드 추가 — 주문 ID 배열 기준 옵션 상태 일괄 변경", + "OrderStatusEnum::isShipped() / shippedStatuses() 메서드 추가 — 발송 이후 상태 판별", + "OrderOption::shipped_quantity 접근자 추가 — 자신 및 분할 자식 옵션의 발송 수량 합산", + "업그레이드 스크립트 Upgrade_0_8_0 — option_status DB 값 변환 (pending→pending_order, shipped→shipping, 클레임→cancelled)" + ] + } + ] + }, + { + "version": "0.7.6", + "date": "2026-03-20", + "categories": [ + { + "name": "Changed", + "items": [ + "주문상세 주문상품 레이아웃을 CSS Grid → DataGrid 컴포넌트로 전환 — Tailwind purge 미적용 문제 해결, selectable/footerCells/footerCardChildren 활용", + "주문상세 합계 행을 DataGrid footerCells로 통합 — 기존 수동 CSS Grid 합계 영역 제거", + "주문상세 일괄변경 영역 스타일을 주문관리 목록과 동일하게 통일 — composite Select → basic Select, 배경/테두리/버튼 스타일 일치", + "주문상세 일괄변경 버튼을 buildOrderDetailBulkConfirmData 핸들러로 변경 — 검증(상태 미선택/상품 미선택) 후 모달 오픈 패턴 적용", + "주문상세 DataGrid selectedCountText를 빈 문자열로 설정 — 기본 \"N개 선택됨\" 텍스트 비활성화" + ] + }, + { + "name": "Fixed", + "items": [ + "주문상세 0원 항목 표시 — 할인/과세/부가세/쿠폰/할인코드/마일리지/예치금 금액이 0인 항목에 `condition` 속성 추가하여 숨김 처리", + "주문상세 합계 computed 문자열 연결 버그 — API 금액이 문자열(`\"4000.00\"`)로 반환되어 `reduce` 합산 시 문자열 연결 발생 → `Number()` 래핑으로 수정", + "주문상세 0원 금액 마이너스 표기 방지 — 금액 0원 항목이 `-0원`으로 표시되던 문제, condition 기반 숨김으로 해결", + "주문상세 일괄변경 버튼 클릭 무응답 — openModal 직접 호출 → buildOrderDetailBulkConfirmData 검증 핸들러로 교체", + "주문상세 일괄변경 확인 모달 closeModal 패턴 수정 — `params.target` → `target` 직접 지정", + "주문상세 DataGrid 셀 줄바꿈 방지 — 상품정보/헤더 제외 모든 셀과 footer에 whitespace-nowrap 적용" + ] + }, + { + "name": "Added", + "items": [ + "다국어 키 추가 — `no_products` (주문상품 없음) ko/en", + "buildOrderDetailBulkConfirmDataHandler 핸들러 추가 — 상태/상품 미선택 시 경고 토스트, 검증 통과 시 확인 모달 오픈", + "운송사/송장번호 입력 필드 조건부 표시 — 배송 관련 상태(shipping_ready/shipping/delivered) 선택 시에만 표시" + ] + } + ] + }, + { + "version": "0.7.5", + "date": "2026-03-20", + "categories": [ + { + "name": "Fixed", + "items": [ + "주문상세 헤더 \"상태:\" 텍스트 누락 — Badge 앞에 상태 라벨 Span 추가", + "주문상세 결제정보 금액 경로 오류 — `payment.*` 주문레벨 금액을 `order.data.*` 경로로 수정", + "주문상세 처리로그 더미 데이터 표시 — `_local.dummyLogs` → `order_logs` API 데이터소스 연동", + "주문상세 일괄변경 핸들러 변수명 불일치 — `batchCarrier` → `batchCarrierId`로 통일", + "OrderPaymentResource 필드명 5종 DB 컬럼 불일치 수정 (pg_tid→transaction_id, paid_amount→paid_amount_local, vbank_num_masked→vbank_number, vbank_due_date→vbank_due_at, cash_receipt_number→cash_receipt_identifier)" + ] + }, + { + "name": "Added", + "items": [ + "주문 처리 로그 API 엔드포인트 추가 (`GET /admin/orders/{id}/logs`) — ActivityLogService 연동, 페이지네이션/정렬 지원", + "Order 모델 payments HasMany 관계 추가 (기존 payment HasOne 유지)", + "OrderResource 플래튼 필드 추가 — 주문자(orderer_name/phone/email), 수취인(recipient_name/phone/zipcode/address), user_login_id, payments(복수), 세금(total_vat_amount)", + "OrderPaymentResource 추가 필드 — payment_type_label, payment_number, account_info, requested_at_formatted, due_date_formatted, vat_amount_formatted, card_approval_number, is_interest_free", + "OrderOptionResource option_name alias 추가", + "OrderService 활동 로그 기록 — update/bulkUpdate 시 ActivityLogService 연동", + "total_vat_amount 컬럼 마이그레이션 추가", + "처리로그 정렬/페이지당 옵션 변경 시 서버사이드 refetch 지원", + "다국어 키 추가 — 처리로그 정렬(date_asc), 페이지당 옵션(per_page_option), 로그 메시지 5종 (ko/en)", + "백엔드 테스트 3종 추가 (logs 엔드포인트 조회/페이지네이션/인증)", + "프론트엔드 레이아웃 테스트 갱신 — order_logs/active_carriers 데이터소스 검증, 금액 경로 검증, batchCarrierId 반영" + ] + } + ] + }, + { + "version": "0.7.4", + "date": "2026-03-19", + "categories": [ + { + "name": "Fixed", + "items": [ + "주문 목록 필터 5종 미작동 수정 — Repository 필터 키를 FormRequest 키와 정렬 (shipping_type, min_amount, max_amount, country_codes, order_device)", + "주문 목록 페이지네이션/총건수 미작동 — `orders?.data?.current_page` → `orders?.data?.pagination?.current_page` 경로 수정", + "배송국가 국기 미표시 — `row.country_code` → `row.address?.recipient_country_code` 경로 수정", + "OrderRepositoryTest 가격 필터 테스트 OLD 키 수정 (min_price → min_amount, max_price → max_amount)" + ] + }, + { + "name": "Added", + "items": [ + "구매환경(디바이스) 컬럼 추가 — OrderListResource에 `order_device`, `order_device_label` 필드 추가 + DataGrid 컬럼", + "첫구매 여부 표시 — OrderListResource에 `is_first_order` 필드 추가 + orderer 컬럼에 첫구매 뱃지", + "상품카테고리 4단계 계층 필터 — categories API 데이터소스 + computed 옵션 바인딩", + "배송국가 필터 동적화 — 하드코딩 6개국 → ecommerce_settings API 기반 iteration 렌더링", + "결제수단 필터 동적화 — 하드코딩 6종 → ecommerce_settings API 기반 iteration 렌더링", + "다국어 키 추가 — `column.device` (구매환경), `column.first_order` (첫구매) ko/en", + "백엔드 테스트 7종 추가 — 금액/국가/디바이스/배송방법 필터 + 페이지네이션 구조 + 신규 필드 확인", + "프론트엔드 레이아웃 테스트 29종 — 페이지네이션 경로, 필터 키, 컬럼, ActionMenu, 날짜 포맷, DataGrid 설정, 동적 필터, 클레임 제거 검증" + ] + }, + { + "name": "Changed", + "items": [ + "페이지 헤더 아이콘 제거 (디자인 통일)", + "기간 버튼 CSS `btn btn-xs` → `btn-date` 패턴 통일 (상품관리와 동일)", + "NO 컬럼 기본 숨김 (visibleColumns에서 제거)", + "주문자명 클릭 시 직접 navigate 제거 — 셀 레벨 ActionMenu로 변경 (쿠폰관리와 동일 패턴)", + "주문일시 날짜 표기 ISO → formatted 형식으로 수정 (`ordered_at` → `ordered_at_formatted`)", + "DataGrid 컬럼선택기 활성화 (`showColumnSelector: true`) + `responsiveBreakpoint: 768` 모바일 대응 추가" + ] + }, + { + "name": "Removed", + "items": [ + "클레임상태 필터 전체 제거 (state, data_sources params, init_actions, filter UI)" + ] + } + ] + }, + { + "version": "0.7.3", + "date": "2026-03-18", + "categories": [ + { + "name": "Added", + "items": [ + "`WishlistResource` + `WishlistCollection` 신규 생성 — 위시리스트 API에 표준 ResourceCollection 패턴 적용", + "`EcommerceSettingsService::getPublicPaymentSettings()` 메서드 추가 — 은행명 매핑 로직을 Service로 이동" + ] + }, + { + "name": "Changed", + "items": [ + "Public 컨트롤러 4개 `ResponseHelper` 패턴 일관성 수정 — `moduleSuccess`/`moduleError` 통일", + "`WishlistController`: 이중 번역 버그(`__()` + `success()`) 7곳 수정", + "`CartController`: `success('sirsoft-ecommerce::...')` → `moduleSuccess()` 10곳 수정", + "`CheckoutController`: success/error 패턴 혼재 6곳 수정", + "`WishlistController::index()` 인라인 `through()` 변환 → `WishlistCollection` 적용", + "`EcommerceSettingsController::payment()` 은행명 매핑 로직 → `EcommerceSettingsService`로 이동", + "`CartController`: `issueCartKey()`, `count()`에 `logApiUsage` 추가", + "`EcommerceSettingsController`: 3개 메서드에 `logApiUsage` 추가", + "`CheckoutController`: PHPDoc `@param` 타입 3곳 실제 FormRequest 타입으로 수정", + "`CategoryImageController::download()` 리턴 타입 선언 추가" + ] + } + ] + }, + { + "version": "0.7.2", + "date": "2026-03-18", + "categories": [ + { + "name": "Fixed", + "items": [ + "Public 상품 목록 API에 `ProductCollection` 패턴 적용 — 페이지네이션/abilities 포함 응답 구조 정상화", + "`ProductCollection` 콜백에서 `toArray()` → `resolve()` 변경 — 중첩 Resource(MissingValue) 안전 해석" + ] + }, + { + "name": "Changed", + "items": [ + "`TestingSeeder`에 `guest` 역할 및 사용자 타입 권한 추가 — Public API 테스트에서 PermissionMiddleware 통과 보장" + ] + } + ] + }, + { + "version": "0.7.1", + "date": "2026-03-18", + "categories": [ + { + "name": "Added", + "items": [ + "SEO 설정 변경 리스너 (`SeoSettingsCacheListener`) — 이커머스 모듈 SEO 설정 변경 시 영향받는 레이아웃 캐시를 선별적으로 무효화" + ] + }, + { + "name": "Changed", + "items": [ + "`SeoProductCacheListener` 보강 — 상품 수정 시 `home`/`search/index` 캐시 무효화 추가, 생성/수정 시 상세 페이지 캐시 즉시 재생성", + "`SeoCategoryCacheListener` 보강 — `search/index` 캐시 무효화 추가", + "배송지 `name` 필드를 다국어 JSON에서 단순 문자열로 변환 — 마이그레이션, 모델, 팩토리, 리포지토리, 서비스, FormRequest, 테스트 일괄 수정" + ] + } + ] + }, + { + "version": "0.7.0", + "date": "2026-03-17", + "categories": [ + { + "name": "Changed", + "items": [ + "SEO 설정 중 코어 범위 항목을 코어 환경설정으로 이관 (`seo_user_agents` → 코어 `seo.bot_user_agents`)", + "이커머스 SEO 탭에서 메인화면 관련 설정 제거 (`meta_main_title`, `meta_main_description`, `seo_site_main`)", + "이커머스 SEO 탭에서 User-Agent 관리 섹션 제거 (코어로 이관)" + ] + }, + { + "name": "Added", + "items": [ + "업그레이드 스텝 (`Upgrade_0_7_0`) — 기존 사용자 정의 User-Agent를 코어로 자동 이관" + ] + }, + { + "name": "Removed", + "items": [ + "`defaults.json`에서 `seo_user_agents`, `seo_site_main`, `meta_main_title`, `meta_main_description` 필드 제거", + "`StoreEcommerceSettingsRequest`에서 제거된 필드의 검증 규칙 삭제", + "SEO 탭 레이아웃에서 메인화면 메타 아코디언, 사이트 메인 체크박스, User-Agent 관리 섹션 제거" + ] + } + ] + }, + { + "version": "0.5.5", + "date": "2026-03-18", + "categories": [ + { + "name": "Added", + "items": [ + "상품 리뷰 기능 구현", + "DB: 리뷰 테이블, 리뷰 이미지 테이블 생성. 답변 수정일 컬럼 추가", + "모델/서비스: 리뷰 작성 가능 여부 확인, 리뷰 생성·삭제, 상태 변경, 판매자 답변 등록·수정·삭제, 이미��� 업로드·삭제", + "관리자 API: 리뷰 목록 조회(기간·별점·상태·포토리뷰·답변 여부 필터), 상태 변경, 삭제, 일괄 처리, 판매자 답변 CRUD", + "유저 API: 리뷰 작성 가능 여부 확인, 리뷰 작성·삭제, 이미지 업로드·삭제 (최대 5장)", + "공개 API: 상품별 리뷰 목록 (별점 통계, 옵션 필터 포함)", + "관리자 레이아웃: 리뷰 목록 화면 (검색 필터, 답변 모달, 이미지 미리보기 모달, 일괄 처리)", + "관리자 설정: 리뷰 작성 기한, 포토리뷰 최대 장수 설정 탭 추가", + "권한 및 메뉴: 관리자 사이드바 \"상품 리뷰 관리\" 메뉴 등록", + "다국어: 리뷰 관련 다국어 파일 추가 (한국어/영어)", + "샘플 시더: 별점 분포·답변·포토리뷰·답변 수정일 랜덤 생성", + "테스트: 관리자·유저·이미지 Feature 테스트 57개, 레이아웃 렌더링 테스트 94개", + "상품 목록 API에 평균 별점, 리뷰수 필드 추가" + ] + }, + { + "name": "Fixed", + "items": [ + "리뷰 작성 가능 여부 확인 시 구매확정 상태 비교가 항상 실패하던 버그 수정", + "리뷰 저장 시 옵션 스냅샷 배열 직렬화 오류 수정", + "리뷰 이미지 업로드 검증 실패 시 응답 코드가 400으로 반환되던 버그 수정 (422으로 수정)", + "리뷰 이미지 API 응답에 리뷰 ID 누락 수정" + ] + } + ] + }, + { + "version": "0.5.4", + "date": "2026-03-17", + "categories": [ + { + "name": "Fixed", + "items": [ + "주문 상세 SMS/이메일 발송 버튼 openModal 핸들러 포맷 수정 (`params.target` → `target`)" + ] + } + ] + }, + { + "version": "0.5.3", + "date": "2026-03-16", + "categories": [ + { + "name": "Fixed", + "items": [ + "쿠폰 리스트 DataGrid `cellChildren` 액션에서 등록자 필터 검색 시 SearchableDropdown에 선택값이 표시되지 않는 버그 수정 — `setState(target: \"local\")`가 globalStateUpdater 경로로 실행되어 Form stale 값에 덮어쓰이는 문제 (템플릿 DataGrid componentContext 수정으로 해결)", + "CouponResource에 카테고리 로케일 브레드크럼 경로 표시 추가", + "쿠폰 1인당 발급 제한 `per_user_limit` null 오류 수정 — nullable 처리 및 검증 로직 보완", + "쿠폰 할인율 검증 수정 — 정률 할인 시 100% 초과 방지, 정액 할인 시 할인율 검증 제거", + "쿠폰 유효기간/발급기간 타임존 변환 구현 — `TimezoneHelper`를 통한 사용자 타임존 ↔ UTC 변환", + "쿠폰 발급기간 `datetime-local` 입력 변환 및 `CouponResource` 타임존 출력 수정", + "쿠폰 폼 레이아웃에서 `apiCall` 호출 시 `auth_mode` 누락 수정" + ] + }, + { + "name": "Changed", + "items": [ + "쿠폰 폼 상품 검색을 무한스크롤 방식으로 개선", + "쿠폰 폼 레이아웃에서 사용 조건 Partial 분리", + "마이그레이션 통합 — 증분 마이그레이션을 테이블당 1개 create 마이그레이션으로 정리", + "시더 디렉토리 분리 — 샘플 시더를 `Sample/` 하위로 이동", + "라이선스 프로그램 명칭 정비", + "쿠폰 폼 하단 버튼 영역 제거 — 상단 PageHeader 액션으로 통일" + ] + } + ] + }, + { + "version": "0.5.1", + "date": "2026-03-11", + "categories": [ + { + "name": "Added", + "items": [ + "주문 취소 API 엔드포인트 추가 (`POST /user/orders/{id}/cancel`) + `user-orders.cancel` 권한 미들웨어 적용", + "`CancelOrderRequest` — 주문 취소 요청 검증 (소유자 확인, 취소 가능 상태 검증)", + "`UserOrderListResource` — `abilities` 키 추가 (`can_view`, `can_cancel` — 상태+권한 기반)", + "`UserOrderCollection` — `abilities` 키 추가 (`can_create` — 권한 기반)", + "`UserAddressResource` — `abilities` 키 추가 (`can_update`, `can_delete`, `can_set_default`)", + "`UserAddressCollection` — `abilities` 키 추가 (`can_create`)", + "`EcommerceUserAbilitiesTest` — 주문/배송지 abilities 및 주문 취소 API 테스트 13개", + "manifest에 license 필드 및 LICENSE 파일 추가" + ] + } + ] + }, + { + "version": "0.5.0", + "date": "2026-03-11", + "categories": [ + { + "name": "Added", + "items": [ + "사용자 권한 3개 추가 (블랙컨슈머 차단용)", + "`sirsoft-ecommerce.user-products.read`: 상품 조회 권한", + "`sirsoft-ecommerce.user-orders.create`: 주문하기 권한", + "`sirsoft-ecommerce.user-orders.cancel`: 주문 취소 권한", + "상품 조회 라우트에 `optional.sanctum` + `permission:user` 미들웨어 적용", + "주문 생성 라우트에 `permission:user` 미들웨어 적용", + "주문 배송지 변경 API (`PUT /user/orders/{id}/shipping-address`)", + "주문 상세 배송지 변경 모달 (주문상세 화면)", + "배송지명 중복 덮어쓰기 확인 모달", + "업그레이드 스크립트 `Upgrade_0_5_0.php` (권한 생성 + 기존 역할 할당)", + "업그레이드 스크립트 `Upgrade_0_6_0.php` (배송지명 컬럼 검증 + 캐시 클리어)", + "사용자 권한 미들웨어 테스트 `EcommerceUserPermissionTest` (7개)" + ] + }, + { + "name": "Changed", + "items": [ + "배송지명(`name`) 필드 다국어 JSON → 단순 string 변환 (마이그레이션 포함)" + ] + }, + { + "name": "Fixed", + "items": [ + "마이페이지 배송지 관리 API 엔드포인트 및 필드명 불일치 수정", + "체크아웃 배송지 모달 필드명 불일치 수정", + "무통장입금(dbank) 주문 시 결제수단이 'card'로 잘못 전송되던 버그 수정", + "도서산간 추가배송비 제주도 누락 수정", + "배송지 변경 시 배송비 미재계산 수정", + "배송지 모달 폼 전송 실패 수정", + "주문 완료 후 장바구니 아이템이 삭제되지 않던 버그 수정 (재고 차감 타이밍과 동기화)" + ] + } + ] + }, + { + "version": "0.4.2", + "date": "2026-03-10", + "categories": [ + { + "name": "Changed", + "items": [ + "권한 카테고리에 `resource_route_key`, `owner_key` 스코프 메타데이터 추가 (products, orders, brands, promotion-coupon, shipping-policies)", + "라우트 미들웨어 `except:owner:*` 옵션 제거 (scope_type 데이터 기반 시스템으로 전환)", + "Repository 목록 조회에 `PermissionHelper::applyPermissionScope()` 적용 (Product, Order, Brand, ShippingPolicy, Coupon)", + "`PermissionBypassable` 인터페이스 및 `getBypassUserId()` 제거 (Product, Order, Brand, ShippingPolicy, Coupon 등 모델)" + ] + } + ] + }, + { + "version": "0.4.1", + "date": "2026-03-08", + "categories": [ + { + "name": "Changed", + "items": [ + "API 리소스 권한 플래그 키 `permissions` → `abilities`로 변경 (코어 표준화)", + "`permissionMap()` → `abilityMap()` 메서드명 변경 (11개 리소스)", + "`BrandCollection`, `CategoryCollection`, `ShippingCarrierCollection`에 `HasAbilityCheck` 트레이트 적용", + "컬렉션 레벨 권한 응답 키를 `abilities`로 통일", + "관리자 레이아웃 JSON의 `permissions.can_*` 바인딩을 `abilities.can_*`로 변경" + ] + } + ] + }, + { + "version": "0.4.0", + "date": "2026-03-06", + "categories": [ + { + "name": "Changed", + "items": [ + "메일 템플릿 목록 API에 검색/필터/페이지네이션 지원 추가", + "메일 템플릿 편집 모달 UX 개선 (blur_until_loaded, sticky footer, 함수형 body)", + "`getDefaultTemplateData()`를 Controller에서 Service로 이동 (Service-Repository 패턴 준수)", + "메일 템플릿 탭 UI를 코어 환경설정과 동일한 구조로 변경" + ] + }, + { + "name": "Added", + "items": [ + "메일 템플릿 미리보기(preview) API 엔드포인트 추가", + "메일 템플릿 검색 기능 (제목/본문/전체)", + "페이지당 항목 수 선택 및 페이지네이션", + "메일 템플릿 관련 다국어 키 추가 (검색/필터/빈 상태/편집 모달)" + ] + } + ] + }, + { + "version": "0.3.1", + "date": "2026-03-06", + "categories": [ + { + "name": "Fixed", + "items": [ + "`CategoryService::deleteCategory()` — 카테고리 삭제 시 이미지(`images()`) 명시적 삭제 추가 (DB CASCADE 의존 제거)", + "`OrderService::delete()` — 주문 삭제 시 관계 레코드 5건 명시적 삭제 추가 (`taxInvoices`, `shippings`, `addresses`, `payment`, `options`)" + ] + } + ] + }, + { + "version": "0.3.0", + "date": "2026-03-06", + "categories": [ + { + "name": "Added", + "items": [ + "사용자 쿠폰 다운로드 API (`UserCouponController`) — 다운로드 가능 쿠폰 목록 조회/다운로드", + "상품별 다운로드 가능 쿠폰 공개 API (`PublicCouponController`) — 비로그인 사용자도 조회 가능", + "`UserCouponService` — 쿠폰 다운로드 비즈니스 로직 (중복 다운로드 방지, 수량 검증)", + "`CouponRepository`, `CouponIssueRepository` — 쿠폰 데이터 접근 계층", + "`DownloadCouponRequest` — 쿠폰 다운로드 요청 검증", + "쿠폰 다운로드 관련 다국어 메시지 (ko/en `messages.php`, `validation.php`)", + "사용자 쿠폰 다운로드 Feature 테스트 14건 (UserCouponControllerTest + PublicCouponControllerTest)", + "DB 기반 메일 템플릿 시스템 (5종: 주문 확인, 배송 시작, 구매 확정, 주문 취소, 관리자 신규 주문)", + "`ecommerce_mail_templates` 테이블, EcommerceMailTemplate 모델/서비스/리포지토리/컨트롤러", + "메일 템플릿 관리 API 및 환경설정 UI", + "업그레이드 스텝 (`Upgrade_0_3_0`) — 기존 설치에 메일 템플릿 초기 시딩", + "`getSeeders()`에 `EcommerceMailTemplateSeeder` 추가" + ] + } + ] + }, + { + "version": "0.2.1", + "date": "2026-03-03", + "categories": [ + { + "name": "Added", + "items": [ + "도서산간 추가배송비 템플릿 시더 (`ExtraFeeTemplateSeeder`) — 34건 도서산간 우편번호 데이터", + "도서산간 템플릿 모달 CRUD 기능 (생성/수정/삭제/일괄삭제/검색)", + "템플릿 모달에서 전체 적용/선택 적용 기능", + "백엔드 다국어 메시지 (`extra_fee_template` 섹션) 추가 (ko/en)" + ] + }, + { + "name": "Changed", + "items": [ + "도서산간 템플릿 모달 크기 `md` → `xl` 확대", + "도서산간 템플릿 모달을 API 응답 구조에 맞게 전면 재설계", + "`zipcode` 컬럼 `string(10)` → `string(20)` 확장 (범위 형식 지원)", + "FormRequest zipcode 검증 `max:10` → `max:20` 변경", + "부모 레이아웃에 템플릿 CRUD 상태 및 데이터소스 검색 params 추가" + ] + } + ] + }, + { + "version": "0.2.0", + "date": "2026-03-03", + "categories": [ + { + "name": "Added", + "items": [ + "배송사 관리 기능 신설 (CRUD API + 관리 UI)", + "배송사 마스터 테이블 (`ecommerce_shipping_carriers`) 생성", + "배송 설정 탭에 배송사 관리 인라인 섹션 추가", + "주문 배송 추적 URL 생성 (`OrderShipping.getTrackingUrl()`)", + "해외배송 비활성화 확인 모달 추가", + "배송가능국가 테이블에 국기 아이콘 표시", + "업그레이드 스텝 (`Upgrade_0_2_0`) — 배송사 시딩 + 기존 주문 carrier_id 역매핑" + ] + }, + { + "name": "Changed", + "items": [ + "해외배송 토글 수동 바인딩 전환 (해외배송 OFF 시 국가 추가/토글/삭제 비활성화)", + "주문 레이아웃 배송사 옵션을 하드코딩에서 API 기반 동적 로딩으로 전환", + "모듈 설치 시더에 `ShippingCarrierSeeder` 추가" + ] + }, + { + "name": "Removed", + "items": [ + "`CarrierEnum` 삭제 — 배송사를 하드코딩 Enum에서 DB 기반 동적 관리로 전환", + "배송정책에서 carrier 필드 제거 (`shipping_policy_country_settings.carrier` 컬럼 삭제)", + "배송정책 폼/목록에서 carrier Select 및 필터 UI 제거" + ] + } + ] + }, + { + "version": "0.1.7", + "date": "2026-02-27", + "categories": [ + { + "name": "Fixed", + "items": [ + "배송정책 수정 폼에서 가시성 플래그가 잘못 설정되는 버그 수정 (init_actions if 미지원 대응)", + "구간별 배송비 정책 수정 시 구간 테이블 미표시 및 고정배송비 필드 오표시 문제 해결", + "배송정책명(MultilingualInput) 수정 모드 미표시 문제 해결 (dataKey 패턴 적용)" + ] + }, + { + "name": "Changed", + "items": [ + "배송정책 폼에 dataKey=\"form\" + debounce 패턴 적용 (상품 폼과 동일 구조)", + "단순 setState 핸들러 제거 (shipping_method, currency_code → 자동 바인딩)" + ] + } + ] + }, + { + "version": "0.1.2", + "date": "2026-02-25", + "categories": [ + { + "name": "Changed", + "items": [ + "모듈 라우트 admin/user 분기 서빙 적용 (routes.json → routes/admin.json 이동)" + ] + } + ] + }, + { + "version": "0.1.1", + "date": "2026-02-24", + "categories": [ + { + "name": "Changed", + "items": [ + "버전 체계 조정 (정식 출시 전 0.x 체계로 변경)" + ] + } + ] + }, + { + "version": "0.1.0", + "date": "2026-02-23", + "categories": [ + { + "name": "Added", + "items": [ + "이커머스 모듈 초기 구현", + "상품 관리: CRUD, 옵션(사이즈/색상), 라벨, SEO 메타, 이미지 갤러리", + "상품 카테고리: 계층형 카테고리, 순서 관리, TreeView 편집", + "브랜드 관리: CRUD, 로고, 설명", + "쿠폰 시스템: 정액/정률 할인, 사용 조건 (최소 금액, 특정 상품), 유효기간, 사용 횟수 제한", + "배송 정책: 무료/유료/조건부 배송", + "공통 정보 관리: 배송/교환/환불 안내, 판매자 정보", + "장바구니: 추가/수량 변경/삭제, 옵션별 관리", + "체크아웃: 주소 입력, 결제수단 선택, 쿠폰 적용", + "주문 관리: 주문 목록, 상태 변경, 상세 정보, 주문 타임라인", + "주문 완료: 주문 번호, 결제 정보 요약", + "결제 연동 인터페이스 (PaymentInterface)", + "상품 상세 페이지: 이미지 갤러리, 옵션 선택, 수량 입력, 장바구니 담기", + "상품 목록: 필터링 (카테고리/브랜드/가격), 정렬, 페이지네이션, 카드 그리드", + "위시리스트: 찜하기/해제 기능", + "상품 검색: 키워드 검색, 카테고리 필터 연동", + "관리자 레이아웃 (상품, 주문, 카테고리, 브랜드, 쿠폰, 배송정책 관리)", + "사용자 레이아웃 (상품 목록/상세, 장바구니, 체크아웃, 주문완료)", + "권한 시스템 연동", + "다국어 지원 (ko, en)" + ] + } + ] + } + ] + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 특정 모듈의 변경 내역(CHANGELOG)을 조회합니다. `core.modules.read` 권한이 필요합니다. `source` 로 조회 출처를(active: 활성 설치본, bundled: 번들 원본, github: 원격 저장소) 선택하고, `from_version`·`to_version` 으로 버전 구간을 좁힐 수 있습니다. 업데이트 전 사용자에게 변경 사항을 안내하는 데 사용됩니다. + + +### GET /api/admin/modules/{identifier}/dependent-templates + +- **라우트명**: `api.admin.modules.dependent-templates` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ModuleController@dependentTemplates` +- **인증/권한**: `auth:sanctum` + `permission:core.modules.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/admin/modules/sirsoft-ecommerce/dependent-templates HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "의존 템플릿 정보를 성공적으로 조회했습니다.", + "data": { + "data": [], + "total": 0 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 이 모듈에 의존하는 템플릿 목록을 조회합니다. `core.modules.read` 권한이 필요합니다. 응답으로 의존 템플릿 배열과 총 개수를 반환하며, 모듈 비활성화·제거 전 영향을 받는 템플릿을 사용자에게 미리 알리는 데 사용됩니다. + + +### GET /api/admin/modules/{identifier}/license + +- **라우트명**: `api.admin.modules.license` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ModuleController@license` +- **인증/권한**: `auth:sanctum` + `permission:core.modules.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/admin/modules/sirsoft-ecommerce/license HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| content | string | `프로그램 명칭 : 그누보드7용 이커머스 모듈 (sirsoft-eco…` | 본문 내용 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "모듈을 성공적으로 가져왔습니다.", + "data": { + "content": "프로그램 명칭 : 그누보드7용 이커머스 모듈 (sirsoft-ecommerce)\n\n저작자 : (주)에스아이알소프트\n\n----- MIT 라이선스 (한국어 번역) --------------------------------------------------------\n\nMIT 라이선스\n\nCopyright (c) 2026 (주)에스아이알소프트\n\n이 소프트웨어와 관련 문서 파일(이하 \"소프트웨어\")의 복사본을 취득하는 모든 사람에게\n소프트웨어를 제한 없이 사용, 복사, 수정, 병합, 출판, 배포, 서브라이선스 허여 및/또는\n판매할 수 있는 권리를 무상으로 부여합니다. 다만, 소프트웨어를 제공받은 사람은 다음\n조건을 따라야 합니다:\n\n위 저작권 고지와 본 허가 고지는 소프트웨어의 모든 복사본 또는 상당 부분에 포함되어야\n합니다.\n\n소프트웨어는 \"있는 그대로\" 제공되며, 명시적이든 묵시적이든 어떠한 종류의 보증도 하지\n않습니다. 여기에는 상품성, 특정 목적에의 적합성 및 비침해에 대한 보증이 포함되나 이에\n국한되지 않습니다. 어떠한 경우에도 저작자 또는 저작권자는 소프트웨어나 소프트웨어의\n사용 또는 기타 거래로 인해 발생하는 계약, 불법행위 또는 기타 청구, 손해 또는 기타\n책임에 대해 책임을 지지 않습니다.\n\n----- MIT License (English Original) --------------------------------------------------------\n\nThe MIT License (MIT)\n\nCopyright (c) 2026 SIRSOFT\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 모듈에 포함된 라이선스 파일의 원문 내용을 반환합니다. `core.modules.read` 권한이 필요합니다. `identifier` 는 소문자·숫자·하이픈·언더스코어 형식만 허용되며 형식에 맞지 않거나 라이선스 파일이 없으면 404 를 반환합니다. 라이선스 고지 화면에 전문을 표시하는 용도입니다. + + +### GET /api/admin/modules/{moduleName} + +- **라우트명**: `api.admin.modules.show` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ModuleController@show` +- **인증/권한**: `auth:sanctum` + `permission:core.modules.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| moduleName | path | string | 예 | — | 대상 module의 이름 (식별자) | + +**요청 예시** + +```http +GET /api/admin/modules/{moduleName} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 특정 모듈의 상세 정보를 조회합니다. `core.modules.read` 권한이 필요합니다. 목록보다 자세한 `toDetailArray()` 형태를 반환하며, 이 모듈이 지원하는 번들 언어팩 정보가 함께 주입됩니다. 모듈을 찾을 수 없으면 404 를 반환합니다. + + +### GET /api/admin/modules/{moduleName}/check-modified-layouts + +- **라우트명**: `api.admin.modules.check-modified-layouts` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ModuleController@checkModifiedLayouts` +- **인증/권한**: `auth:sanctum` + `permission:core.modules.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| moduleName | path | string | 예 | — | 대상 module의 이름 (식별자) | + +**요청 예시** + +```http +GET /api/admin/modules/{moduleName}/check-modified-layouts HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 특정 모듈에서 사용자가 수정한 레이아웃이 있는지 확인합니다. `core.modules.read` 권한이 필요합니다. 업데이트 실행 전 이 정보를 조회하여 레이아웃 전략(overwrite: 새 버전으로 교체, keep: 사용자 수정본 유지) 선택을 안내하는 데 사용됩니다. + + +### GET /api/admin/modules/{moduleName}/install-preview + +- **라우트명**: `api.admin.modules.install-preview` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ModuleController@installPreview` +- **인증/권한**: `auth:sanctum` + `permission:core.modules.install` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| moduleName | path | string | 예 | — | 대상 module의 이름 (식별자) | + +**요청 예시** + +```http +GET /api/admin/modules/{moduleName}/install-preview HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.install`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 모듈 설치 시 함께 처리될 cascade 후보(의존 확장 + 동반 가능한 번들 언어팩) 트리를 반환합니다. `core.modules.install` 권한이 필요합니다. 설치 모달 오픈 시 호출되어 사용자가 함께 설치할 항목을 선택하도록 노출하며, ZIP 업로드 기반의 `manifest-preview` 와 달리 이미 알려진 식별자에 대한 GET 조회입니다. + + +### GET /api/admin/modules/{moduleName}/uninstall-info + +- **라우트명**: `api.admin.modules.uninstall-info` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ModuleController@uninstallInfo` +- **인증/권한**: `auth:sanctum` + `permission:core.modules.uninstall` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| moduleName | path | string | 예 | — | 대상 module의 이름 (식별자) | + +**요청 예시** + +```http +GET /api/admin/modules/{moduleName}/uninstall-info HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.uninstall`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 모듈 제거 시 삭제될 데이터 정보를 조회합니다. `core.modules.uninstall` 권한이 필요합니다. 제거 확인 모달에서 사용자에게 어떤 데이터가 사라지는지 미리 보여주는 용도이며, 모듈을 찾을 수 없으면 404 를 반환합니다. + + +### POST /api/admin/modules/{moduleName}/update + +- **라우트명**: `api.admin.modules.update` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ModuleController@performUpdate` +- **인증/권한**: `auth:sanctum` + `permission:core.modules.install` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| moduleName | path | string | 예 | — | 대상 module의 이름 (식별자) | +| layout_strategy | body | string | 아니오 | `overwrite`, `keep` | 업데이트 시 레이아웃 처리 전략 (overwrite: 새 버전으로 교체, keep: 사용자 수정본 유지) | +| vendor_mode | body | string | 아니오 | `auto`, `composer`, `bundled` | 벤더 설치 모드 (auto/composer/bundled) | +| force | body | boolean | 아니오 | — | 강제 실행 여부 (안전 확인/선행 검사 우회) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.module.perform_update_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/modules/{moduleName}/update HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "layout_strategy": "overwrite", + "vendor_mode": "auto", + "force": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.modules.install`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 특정 모듈을 최신 버전으로 업데이트합니다. `core.modules.install` 권한이 필요합니다. `layout_strategy` 로 레이아웃 처리 방식을(overwrite: 새 버전으로 교체, keep: 사용자 수정본 유지) 지정하며, `vendor_mode` 로 Composer 의존성 처리 방식을 선택합니다. 버전 제약·호환성 문제로 막힐 경우 `force: true` 로 강제 진행할 수 있습니다. + + +### GET /api/modules/assets/{identifier}/{path} + +- **라우트명**: `api.public.modules.assets` +- **컨트롤러**: `App\Http\Controllers\Api\Public\PublicModuleController@serveAsset` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | +| path | path | string | 예 | — | 경로 | + +**요청 예시** + +```http +GET /api/modules/assets/sirsoft-ecommerce/{path}?identifier=example-key HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 모듈의 개별 프론트엔드 에셋 파일(JS/CSS/이미지 등)을 서빙하는 공개 엔드포인트입니다. 인증이 필요하지 않으며, 경로·확장자 보안 검증은 FormRequest 에서 완료됩니다. 모듈 미존재·파일 미존재·허용되지 않은 파일 유형은 각각 404/404/403 으로 응답하고, 정상 파일은 ETag 와 1년 캐시 헤더를 붙여 반환합니다. 소스맵 등 개별 에셋을 직접 참조할 때 사용되며, 통합 로딩은 `bundle.js`/`bundle.css` 를 사용합니다. + + +### GET /api/modules/bundle.css + +- **라우트명**: `api.public.modules.bundle.css` +- **컨트롤러**: `App\Http\Controllers\Api\Public\PublicModuleController@serveBundleCss` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/bundle.css HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +_대표 에러 없음 (공개 조회). 활성 모듈 에셋이 없어도 빈 200 응답을 반환하므로 404 를 내지 않습니다._ + + + +**설명** 활성 모듈들의 프론트엔드 CSS 를 서버에서 하나로 병합한 번들을 서빙하는 공개 엔드포인트입니다. 인증이 필요하지 않습니다. 활성 global 모듈 에셋이 없으면 빈 200(text/css) 응답을 반환하고, 있으면 병합 파일을 ETag·환경별 Cache-Control 과 함께 서빙합니다. 페이지가 모듈 스타일을 요청 1건으로 로드하도록 합니다. + + +### GET /api/modules/bundle.js + +- **라우트명**: `api.public.modules.bundle.js` +- **컨트롤러**: `App\Http\Controllers\Api\Public\PublicModuleController@serveBundleJs` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/bundle.js HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +_대표 에러 없음 (공개 조회). 활성 모듈 에셋이 없어도 빈 200 응답을 반환하므로 404 를 내지 않습니다._ + + + +**설명** 활성 모듈들의 프론트엔드 IIFE JS 를 서버에서 하나로 병합한 번들을 서빙하는 공개 엔드포인트입니다. 인증이 필요하지 않습니다. 활성 global 모듈 에셋이 없으면 빈 200(text/javascript) 응답을 반환하고(프론트는 빈 스크립트 로드로 무해), 있으면 병합 파일을 ETag·환경별 Cache-Control 과 함께 서빙합니다. 프론트는 `G7Config.bundleUrls` 를 읽어 이 번들을 로드합니다. + + +### GET /api/modules/{identifier}/components.json + +- **라우트명**: `api.public.modules.components` +- **컨트롤러**: `App\Http\Controllers\Api\Public\PublicModuleController@serveComponents` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/components.json HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + + + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "identifier": "sirsoft-ecommerce", + "version": "1.0.0-beta.5", + "components": { + "basic": [], + "composite": [], + "layout": [] + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 모듈의 컴포넌트 정의 파일(components.json)을 서빙하는 공개 엔드포인트입니다. 인증이 필요하지 않습니다. 편집 모드 부팅 시 ComponentRegistry 가 활성 확장 매니페스트를 네임스페이스 병합하기 위해 fetch 하며, 구버전 모듈처럼 파일이 없으면 빈 components 로 폴백합니다. 응답은 1시간 캐시됩니다. 모듈 미존재 시 404. + + +### GET /api/modules/{identifier}/editor-spec + +- **라우트명**: `api.public.modules.editor_spec` +- **컨트롤러**: `App\Http\Controllers\Api\Public\PublicModuleController@serveEditorSpec` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/editor-spec HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| identifier | string | `sirsoft-ecommerce` | 모듈 고유 식별자 (vendor-module 형식) | +| spec | object | `{"$schema":"https:\/\/json-schema.org\/draft\/2020-12\/sc…` | 스펙 정의 객체 (편집기/컴포넌트 선언 스키마 등) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "편집기 스펙을 조회했습니다.", + "data": { + "identifier": "sirsoft-ecommerce", + "spec": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "moduleId": "sirsoft-ecommerce", + "version": "1.0.0", + "description": "레이아웃 편집기 스펙 — 이커머스 모듈 도메인 sampleData/sampleGlobal/states. admin 레이아웃 data_source ID 전수 스캔 기반 도메인 ID 28종(상품·주문·브랜드·쿠폰·배송정책·정산·설정 등) byDataSourceId + 사용자 페이지(템플릿 렌더) byEndpointPattern. 공용 인프라(roles/availableChannels/identityProviders/ecommerceIdentity*/ecommerceNotificationDefinitions)는 admin 템플릿 스펙·코어 프리셋 폴백이 커버.", + "actionRecipes": { + "comment": "이커머스 모듈 소유 친화 액션 레시피. 코어 시드 위에 module 단계로 병합(__source:{kind:module,id:sirsoft-ecommerce}, 편집기 〔이커머스〕 배지). 라벨은 모듈 격리 네임스페이스($t:sirsoft-ecommerce.editor.action.*) — 편집기 t()가 모듈 lang 통짜 ko.json/en.json 의 editor.action 을 sirsoft-ecommerce.editor.action 으로 해석한다(코어/템플릿 editor 와 격리). 결제(PG) 진입은 커머스 도메인이라 코어가 아닌 모듈이 소유한다(provider-agnostic — 핸들러명을 백엔드 응답값으로 받음).", + "requestPgPayment": { + "comment": "결제(PG) 진입 — provider-agnostic. 백엔드 응답이 호출할 결제 진입 핸들러 풀네임(pg_payment_handler)을 데이터 칩으로 연결하면 그 핸들러를 dispatch(특정 PG 하드코딩 없음). build.handler 가 {{paymentHandler}} placeholder 라 matchAction 이 placeholder-aware 로 역매칭한다. onSuccess 컨텍스트(chipContext=response)에서 결제 응답 칩 노출.", + "label": "$t:sirsoft-ecommerce.editor.action.request_pg_payment.label", + "params": [ + { + "key": "paymentHandler", + "label": "$t:sirsoft-ecommerce.editor.action.request_pg_payment.param_handler", + "widget": "data-chip", + "required": true + }, + { + "key": "pgPaymentData", + "label": "$t:sirsoft-ecommerce.editor.action.request_pg_payment.param_data", + "widget": "data-chip", + "required": true + } + ], + "build": { + "handler": "{{paymentHandler}}", + "params": { + "pgPaymentData": "{{pgPaymentData}}" + } + } + } + }, + "actionChipCandidates": { + "comment": "이커머스 도메인 응답 칩 후보 — 코어 기본 응답 칩(response.data/message) 뒤에 병합. 주문 생성 응답이 내려주는 PG 결제 진입 필드(pg_payment_handler/pg_payment_data)를 결제 진입 레시피의 데이터 칩으로 연결할 수 있게 노출한다. 코어는 PG 도메인을 모르고, 본 모듈이 자기 응답 필드를 선언한다(라벨은 모듈 격리 네임스페이스).", + "response": [ + { + "path": "data.pg_payment_handler", + "labelKey": "sirsoft-ecommerce.editor.action_chip.response_pg_handler", + "shape": "scalar" + }, + { + "path": "data.pg_payment_data", + "labelKey": "sirsoft-ecommerce.editor.action_chip.response_pg_data", + "shape": "object" + } + ] + }, + "sampleGlobal": { + "comment": "이커머스 도메인 _global keyspace. 코어 keyspace(currentUser/settings)는 시드하지 않음(충돌 시 코어 우선). 헤더 통화/배송국가 셀렉터(header_currency 슬롯 주입)는 실사이트에서 init_actions 가 채우는 파생 상태(availableCurrencies/availableShippingCountries)와 modules['sirsoft-ecommerce'] 설정을 표시 조건으로 읽는다. 편집기 캔버스는 init_actions 를 실행하지 않으므로 이 키들을 직접 시드해 편집기에서도 셀렉터가 보이게 한다(양 템플릿 공용 — 헤더는 모듈 무지 유지).", + "cart": { + "items": [], + "count": 0, + "total": 0 + }, + "wishlist": { + "items": [], + "count": 0 + }, + "recent_products": [], + "modules": { + "sirsoft-ecommerce": { + "comment": "헤더 통화/배송국가 셀렉터 표시 조건 소스. 실사이트 G7Config(window.__G7_MODULE_CONFIG) 미러 — 편집기 캔버스 전용 시드.", + "language_currency": { + "default_currency": "KRW", + "currencies": [ + { + "code": "KRW", + "symbol": "₩", + "flag": "🇰🇷", + "exchange_rate": 1, + "is_default": true + }, + { + "code": "USD", + "symbol": "$", + "flag": "🇺🇸", + "exchange_rate": 0.00073, + "is_default": false + }, + { + "code": "JPY", + "symbol": "¥", + "flag": "🇯🇵", + "exchange_rate": 0.11, + "is_default": false + } + ] + }, + "shipping": { + "international_shipping_enabled": true, + "default_country": "KR", + "available_countries": [ + { + "code": "KR", + "name": { + "ko": "대한민국", + "en": "South Korea", + "ja": "韓国" + }, + "is_active": true + }, + { + "code": "US", + "name": { + "ko": "미국", + "en": "United States", + "ja": "アメリカ" + }, + "is_active": true + }, + { + "code": "JP", + "name": { + "ko": "일본", + "en": "Japan", + "ja": "日本" + }, + "is_active": true + } + ] + } + } + }, + "defaultCurrency": "KRW", + "preferredCurrency": "KRW", + "preferredShippingCountry": "KR", + "availableCurrencies": [ + { + "code": "KRW", + "symbol": "₩", + "flag": "🇰🇷", + "exchange_rate": 1, + "is_default": true + }, + { + "code": "USD", + "symbol": "$", + "flag": "🇺🇸", + "exchange_rate": 0.00073, + "is_default": false + }, + { + "code": "JPY", + "symbol": "¥", + "flag": "🇯🇵", + "exchange_rate": 0.11, + "is_default": false + } + ], + "availableShippingCountries": [ + { + "code": "KR", + "name": "대한민국" + }, + { + "code": "US", + "name": "미국" + }, + { + "code": "JP", + "name": "일본" + } + ], + "mileageManual": [], + "mileageEdit": [], + "mileageExtend": [] + }, + "sampleData": { + "comment": "실제 모듈 레이아웃 data_source ID(admin: products/categories/orders/order/reviews 등) + 사용자 페이지(템플릿이 렌더, /api/modules/sirsoft-ecommerce/* 호출) byEndpointPattern. 계획서 추정과 실제 ID 차이 반영(데이터소스가 SSoT).", + "byDataSourceId": { + "transactions": { + "data": { + "data": [ + { + "id": 2001, + "user_id": 100, + "currency": "KRW", + "type": "earn", + "type_label": "적립", + "admin_badge_group": null, + "user_display_category": "수동 지급", + "amount": 10000, + "amount_formatted": "10,000", + "remaining_amount": 10000, + "remaining_amount_formatted": "10,000", + "balance_after": 65000, + "order_id": null, + "order_option_id": null, + "order_cancel_id": null, + "source_transaction_id": null, + "granted_by": 1, + "granted_by_name": "관리자", + "granted_by_uuid": "admin-uuid-1", + "user_name": "홍길동", + "user_uuid": "u-1001", + "order_number": null, + "description": "Admin earn", + "memo": "이벤트 보상", + "expires_at": "2027-06-21T00:00:00+09:00", + "expires_at_formatted": "Jun 21, 2027 12:00 AM", + "expires_at_date": "2027-06-21", + "expired_at": null, + "expired_at_formatted": null, + "created_at": "2026-06-20T10:00:00+09:00", + "created_at_formatted": "Jun 20, 2026 10:00 AM", + "created_at_date": "2026-06-20", + "is_earning": true, + "can_edit_expiry": true, + "expired_amount": 0, + "expired_amount_formatted": "0", + "expiry_state": "active", + "abilities": { + "can_manage": true + } + }, + { + "id": 2002, + "user_id": 101, + "currency": "KRW", + "type": "deduct", + "type_label": "차감", + "admin_badge_group": null, + "user_display_category": "수동 차감", + "amount": -5000, + "amount_formatted": "-5,000", + "remaining_amount": 0, + "remaining_amount_formatted": "0", + "balance_after": 45000, + "order_id": null, + "order_option_id": null, + "order_cancel_id": null, + "source_transaction_id": null, + "granted_by": 1, + "granted_by_name": "관리자", + "granted_by_uuid": "admin-uuid-1", + "user_name": "김영희", + "user_uuid": "u-1002", + "order_number": null, + "description": null, + "memo": "잘못된 적립 차감", + "expires_at": null, + "expires_at_formatted": null, + "expires_at_date": null, + "expired_at": null, + "expired_at_formatted": null, + "created_at": "2026-06-19T15:30:00+09:00", + "created_at_formatted": "Jun 19, 2026 3:30 PM", + "created_at_date": "2026-06-19", + "is_earning": false, + "can_edit_expiry": false, + "expired_amount": 0, + "expired_amount_formatted": "0", + "expiry_state": "active", + "abilities": { + "can_manage": true + } + } + ], + "abilities": { + "can_manage": true + }, + "currencies": [ + "KRW" + ], + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 20, + "total": 2, + "from": 1, + "to": 2, + "has_more_pages": false + } + } + }, + "products": { + "data": { + "abilities": { + "can_create": true, + "can_update": true + }, + "data": [ + { + "id": 101, + "name": { + "ko": "베이직 오버핏 코튼 티셔츠" + }, + "name_localized": "베이직 오버핏 코튼 티셔츠", + "product_code": "TS-1001", + "sku": "TS-1001", + "thumbnail_url": "/images/placeholder.png", + "list_price": 29000, + "list_price_formatted": "29,000원", + "selling_price": 23200, + "selling_price_formatted": "23,200원", + "discount_rate": 20, + "multi_currency_list_price": { + "KRW": { + "price": 29000, + "formatted": "29,000원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 22.31, + "formatted": "$22.31", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 23200, + "formatted": "23,200원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 17.85, + "formatted": "$17.85", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "stock_quantity": 320, + "safe_stock_quantity": 10, + "is_below_safe_stock": false, + "option_stock_sum": 320, + "sales_status": "on_sale", + "sales_status_label": "판매중", + "sales_status_variant": "success", + "display_status": "visible", + "display_status_label": "노출", + "display_status_variant": "success", + "categories": [ + { + "id": 11, + "name": "상의", + "is_primary": true + } + ], + "primary_category": "상의", + "categories_with_path": [ + { + "id": 11, + "path": [ + { + "id": 1, + "name": "패션의류" + }, + { + "id": 11, + "name": "상의" + } + ], + "path_string": "패션의류 > 상의", + "is_primary": true + } + ], + "brand_name": "코지홈", + "shipping_policy_id": 1, + "shipping_policy_name": "기본 배송정책", + "min_purchase_qty": 1, + "max_purchase_qty": 0, + "has_options": true, + "options_count": 6, + "options": [], + "labels": [ + { + "name": "BEST", + "color": "#ef4444" + } + ], + "review_count": 128, + "rating_avg": 4.7, + "created_at": "2026-05-20 14:30:00", + "updated_at": "2026-05-28 09:15:00", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "id": 102, + "name": { + "ko": "데일리 와이드 슬랙스" + }, + "name_localized": "데일리 와이드 슬랙스", + "product_code": "PT-2042", + "sku": "PT-2042", + "thumbnail_url": "/images/placeholder.png", + "list_price": 39000, + "list_price_formatted": "39,000원", + "selling_price": 39000, + "selling_price_formatted": "39,000원", + "discount_rate": 0, + "multi_currency_list_price": { + "KRW": { + "price": 39000, + "formatted": "39,000원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 30, + "formatted": "$30.00", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 39000, + "formatted": "39,000원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 30, + "formatted": "$30.00", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "stock_quantity": 85, + "safe_stock_quantity": 10, + "is_below_safe_stock": false, + "option_stock_sum": 85, + "sales_status": "on_sale", + "sales_status_label": "판매중", + "sales_status_variant": "success", + "display_status": "visible", + "display_status_label": "노출", + "display_status_variant": "success", + "categories": [ + { + "id": 11, + "name": "상의", + "is_primary": true + } + ], + "primary_category": "상의", + "categories_with_path": [ + { + "id": 11, + "path": [ + { + "id": 1, + "name": "패션의류" + }, + { + "id": 11, + "name": "상의" + } + ], + "path_string": "패션의류 > 상의", + "is_primary": true + } + ], + "brand_name": "어반핏", + "shipping_policy_id": 1, + "shipping_policy_name": "기본 배송정책", + "min_purchase_qty": 1, + "max_purchase_qty": 0, + "has_options": false, + "options_count": 0, + "options": [], + "labels": [], + "review_count": 42, + "rating_avg": 4.3, + "created_at": "2026-05-20 14:30:00", + "updated_at": "2026-05-28 09:15:00", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "id": 103, + "name": { + "ko": "구스다운 경량 패딩 점퍼" + }, + "name_localized": "구스다운 경량 패딩 점퍼", + "product_code": "OW-3310", + "sku": "OW-3310", + "thumbnail_url": "/images/placeholder.png", + "list_price": 189000, + "list_price_formatted": "189,000원", + "selling_price": 132300, + "selling_price_formatted": "132,300원", + "discount_rate": 30, + "multi_currency_list_price": { + "KRW": { + "price": 189000, + "formatted": "189,000원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 145.38, + "formatted": "$145.38", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 132300, + "formatted": "132,300원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 101.77, + "formatted": "$101.77", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "stock_quantity": 0, + "safe_stock_quantity": 10, + "is_below_safe_stock": true, + "option_stock_sum": 0, + "sales_status": "sold_out", + "sales_status_label": "품절", + "sales_status_variant": "danger", + "display_status": "hidden", + "display_status_label": "미노출", + "display_status_variant": "gray", + "categories": [ + { + "id": 11, + "name": "상의", + "is_primary": true + } + ], + "primary_category": "상의", + "categories_with_path": [ + { + "id": 11, + "path": [ + { + "id": 1, + "name": "패션의류" + }, + { + "id": 11, + "name": "상의" + } + ], + "path_string": "패션의류 > 상의", + "is_primary": true + } + ], + "brand_name": "노스벨리", + "shipping_policy_id": 1, + "shipping_policy_name": "기본 배송정책", + "min_purchase_qty": 1, + "max_purchase_qty": 0, + "has_options": true, + "options_count": 9, + "options": [], + "labels": [ + { + "name": "BEST", + "color": "#ef4444" + } + ], + "review_count": 271, + "rating_avg": 4.9, + "created_at": "2026-05-20 14:30:00", + "updated_at": "2026-05-28 09:15:00", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + } + ], + "pagination": { + "current_page": 1, + "last_page": 4, + "per_page": 20, + "total": 73 + } + } + }, + "product": { + "data": { + "id": 101, + "name": { + "ko": "베이직 오버핏 코튼 티셔츠", + "en": "Basic Overfit Cotton T-Shirt" + }, + "product_code": "TS-1001", + "sales_product_code": "SP-TS-1001", + "abilities": { + "can_update": true, + "can_delete": true + }, + "images": [ + { + "id": 5001, + "hash": "img5001", + "original_filename": "tshirt_main.jpg", + "download_url": "/images/placeholder.png", + "alt_text": "정면", + "is_thumbnail": true, + "sort_order": 0, + "order": 0, + "width": 1000, + "height": 1000, + "file_size": 184320, + "size": 184320, + "size_formatted": "180 KB", + "mime_type": "image/jpeg", + "is_image": true + }, + { + "id": 5002, + "hash": "img5002", + "original_filename": "tshirt_back.jpg", + "download_url": "/images/placeholder.png", + "alt_text": "후면", + "is_thumbnail": false, + "sort_order": 1, + "order": 1, + "width": 1000, + "height": 1000, + "file_size": 176128, + "size": 176128, + "size_formatted": "172 KB", + "mime_type": "image/jpeg", + "is_image": true + }, + { + "id": 5003, + "hash": "img5003", + "original_filename": "tshirt_detail.jpg", + "download_url": "/images/placeholder.png", + "alt_text": "디테일", + "is_thumbnail": false, + "sort_order": 2, + "order": 2, + "width": 1000, + "height": 1000, + "file_size": 158720, + "size": 158720, + "size_formatted": "155 KB", + "mime_type": "image/jpeg", + "is_image": true + } + ], + "created_at": "2026-05-20 14:30:00", + "updated_at": "2026-05-28 09:15:00" + } + }, + "categories": { + "data": { + "abilities": { + "can_create": true, + "can_update": true + }, + "data": [ + { + "id": 1, + "name": { + "ko": "패션의류" + }, + "localized_name": "패션의류", + "description": null, + "parent_id": null, + "path": "1", + "depth": 0, + "sort_order": 1, + "is_active": true, + "slug": "fashion", + "url": "fashion", + "icon": "folder", + "meta_title": null, + "meta_description": null, + "created_at": "2026-04-01 10:00:00", + "updated_at": "2026-05-01 10:00:00", + "children": [ + { + "id": 11, + "name": { + "ko": "상의" + }, + "localized_name": "상의", + "description": null, + "parent_id": 1, + "path": "1/11", + "depth": 1, + "sort_order": 11, + "is_active": true, + "slug": "tops", + "url": "tops", + "icon": "folder", + "meta_title": null, + "meta_description": null, + "created_at": "2026-04-01 10:00:00", + "updated_at": "2026-05-01 10:00:00", + "children": [ + { + "id": 111, + "name": { + "ko": "티셔츠" + }, + "localized_name": "티셔츠", + "description": null, + "parent_id": 11, + "path": "11/111", + "depth": 2, + "sort_order": 111, + "is_active": true, + "slug": "tshirts", + "url": "tshirts", + "icon": "folder", + "meta_title": null, + "meta_description": null, + "created_at": "2026-04-01 10:00:00", + "updated_at": "2026-05-01 10:00:00", + "children": [] + }, + { + "id": 112, + "name": { + "ko": "셔츠/블라우스" + }, + "localized_name": "셔츠/블라우스", + "description": null, + "parent_id": 11, + "path": "11/112", + "depth": 2, + "sort_order": 112, + "is_active": true, + "slug": "shirts", + "url": "shirts", + "icon": "folder", + "meta_title": null, + "meta_description": null, + "created_at": "2026-04-01 10:00:00", + "updated_at": "2026-05-01 10:00:00", + "children": [] + } + ] + }, + { + "id": 12, + "name": { + "ko": "하의" + }, + "localized_name": "하의", + "description": null, + "parent_id": 1, + "path": "1/12", + "depth": 1, + "sort_order": 12, + "is_active": true, + "slug": "bottoms", + "url": "bottoms", + "icon": "folder", + "meta_title": null, + "meta_description": null, + "created_at": "2026-04-01 10:00:00", + "updated_at": "2026-05-01 10:00:00", + "children": [ + { + "id": 121, + "name": { + "ko": "슬랙스" + }, + "localized_name": "슬랙스", + "description": null, + "parent_id": 12, + "path": "12/121", + "depth": 2, + "sort_order": 121, + "is_active": true, + "slug": "slacks", + "url": "slacks", + "icon": "folder", + "meta_title": null, + "meta_description": null, + "created_at": "2026-04-01 10:00:00", + "updated_at": "2026-05-01 10:00:00", + "children": [] + } + ] + } + ] + }, + { + "id": 2, + "name": { + "ko": "가방/잡화" + }, + "localized_name": "가방/잡화", + "description": null, + "parent_id": null, + "path": "2", + "depth": 0, + "sort_order": 2, + "is_active": true, + "slug": "bags", + "url": "bags", + "icon": "folder", + "meta_title": null, + "meta_description": null, + "created_at": "2026-04-01 10:00:00", + "updated_at": "2026-05-01 10:00:00", + "children": [ + { + "id": 21, + "name": { + "ko": "백팩" + }, + "localized_name": "백팩", + "description": null, + "parent_id": 2, + "path": "2/21", + "depth": 1, + "sort_order": 21, + "is_active": true, + "slug": "backpack", + "url": "backpack", + "icon": "folder", + "meta_title": null, + "meta_description": null, + "created_at": "2026-04-01 10:00:00", + "updated_at": "2026-05-01 10:00:00", + "children": [] + } + ] + }, + { + "id": 3, + "name": { + "ko": "아우터" + }, + "localized_name": "아우터", + "description": null, + "parent_id": null, + "path": "3", + "depth": 0, + "sort_order": 3, + "is_active": true, + "slug": "outer", + "url": "outer", + "icon": "folder", + "meta_title": null, + "meta_description": null, + "created_at": "2026-04-01 10:00:00", + "updated_at": "2026-05-01 10:00:00", + "children": [] + } + ] + } + }, + "orders": { + "data": { + "data": [ + { + "id": 5101, + "no": 1, + "order_number": "20260601-0042", + "order_status": "preparing", + "order_status_label": "배송준비중", + "order_status_variant": "info", + "total_amount": 69500, + "total_amount_formatted": "69,500원", + "total_shipping_amount": 3000, + "total_shipping_amount_formatted": "3,000원", + "total_paid_amount": 69500, + "total_paid_amount_formatted": "69,500원", + "total_unpaid_amount": 0, + "total_unpaid_amount_formatted": "0원", + "ordered_at_formatted": "2026-06-01 19:30", + "order_device_label": "PC", + "is_first_order": false, + "options_count": 2, + "user": { + "uuid": "u-1001", + "name": "홍길동", + "email": "gildong@example.com" + }, + "first_option": { + "product_name": "프리미엄 무선 이어버드 프로", + "product_option_name": "미드나잇 블랙 / 기본", + "product_code": "EARBUDS-PRO", + "quantity": 2, + "thumbnail_url": null + }, + "address": { + "orderer_name": "홍길동", + "recipient_name": "홍길동", + "recipient_country_code": "KR", + "recipient_country_name": "대한민국" + }, + "payment": { + "payment_method": "card", + "payment_method_label": "신용카드" + }, + "shipping": { + "shipping_type": "parcel", + "shipping_type_label": "택배", + "shipping_method_label": "일반배송", + "carrier_name": "CJ대한통운", + "tracking_number": null + } + }, + { + "id": 5102, + "no": 2, + "order_number": "20260530-0118", + "order_status": "shipping", + "order_status_label": "배송중", + "order_status_variant": "primary", + "total_amount": 42000, + "total_amount_formatted": "42,000원", + "total_shipping_amount": 0, + "total_shipping_amount_formatted": "0원", + "total_paid_amount": 42000, + "total_paid_amount_formatted": "42,000원", + "total_unpaid_amount": 0, + "total_unpaid_amount_formatted": "0원", + "ordered_at_formatted": "2026-05-30 13:05", + "order_device_label": "PC", + "is_first_order": false, + "options_count": 1, + "user": { + "uuid": "u-1002", + "name": "김영희", + "email": "younghee@example.com" + }, + "first_option": { + "product_name": "휴대용 블루투스 스피커", + "product_option_name": "기본", + "product_code": "SPEAKER-MINI", + "quantity": 1, + "thumbnail_url": null + }, + "address": { + "orderer_name": "김영희", + "recipient_name": "김영희", + "recipient_country_code": "KR", + "recipient_country_name": "대한민국" + }, + "payment": { + "payment_method": "vbank", + "payment_method_label": "가상계좌" + }, + "shipping": { + "shipping_type": "parcel", + "shipping_type_label": "택배", + "shipping_method_label": "일반배송", + "carrier_name": "CJ대한통운", + "tracking_number": "6123456789020" + } + }, + { + "id": 5103, + "no": 3, + "order_number": "20260528-0096", + "order_status": "payment_pending", + "order_status_label": "입금대기", + "order_status_variant": "warning", + "total_amount": 128000, + "total_amount_formatted": "128,000원", + "total_shipping_amount": 3000, + "total_shipping_amount_formatted": "3,000원", + "total_paid_amount": 0, + "total_paid_amount_formatted": "0원", + "total_unpaid_amount": 128000, + "total_unpaid_amount_formatted": "128,000원", + "ordered_at_formatted": "2026-05-28 21:40", + "order_device_label": "PC", + "is_first_order": true, + "options_count": 1, + "user": { + "uuid": "u-1003", + "name": "John Smith", + "email": "john@example.com" + }, + "first_option": { + "product_name": "스마트 워치 밴드", + "product_option_name": "브라운 / M", + "product_code": "WATCH-BAND", + "quantity": 1, + "thumbnail_url": null + }, + "address": { + "orderer_name": "John Smith", + "recipient_name": "John Smith", + "recipient_country_code": "US", + "recipient_country_name": "United States" + }, + "payment": { + "payment_method": "dbank", + "payment_method_label": "무통장입금" + }, + "shipping": { + "shipping_type": "parcel", + "shipping_type_label": "택배", + "shipping_method_label": "일반배송", + "carrier_name": "CJ대한통운", + "tracking_number": null + } + }, + { + "id": 5104, + "no": 4, + "order_number": "20260525-0071", + "order_status": "cancelled", + "order_status_label": "주문취소", + "order_status_variant": "danger", + "total_amount": 24000, + "total_amount_formatted": "24,000원", + "total_shipping_amount": 3000, + "total_shipping_amount_formatted": "3,000원", + "total_paid_amount": 0, + "total_paid_amount_formatted": "0원", + "total_unpaid_amount": 0, + "total_unpaid_amount_formatted": "0원", + "ordered_at_formatted": "2026-05-25 10:12", + "order_device_label": "PC", + "is_first_order": false, + "options_count": 1, + "user": { + "uuid": "u-1004", + "name": "이철수", + "email": "chulsoo@example.com" + }, + "first_option": { + "product_name": "USB-C 멀티 어댑터", + "product_option_name": "실버", + "product_code": "ADAPTER-USBC", + "quantity": 1, + "thumbnail_url": null + }, + "address": { + "orderer_name": "이철수", + "recipient_name": "이철수", + "recipient_country_code": "KR", + "recipient_country_name": "대한민국" + }, + "payment": { + "payment_method": "card", + "payment_method_label": "신용카드" + }, + "shipping": { + "shipping_type": "parcel", + "shipping_type_label": "택배", + "shipping_method_label": "일반배송", + "carrier_name": "CJ대한통운", + "tracking_number": null + } + } + ], + "statistics": { + "pending_payment": 1, + "payment_complete": 0, + "preparing": 1, + "shipping": 1, + "delivered": 0, + "confirmed": 0, + "cancelled": 1 + }, + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 20, + "total": 4, + "from": 1, + "to": 4, + "has_more_pages": false + }, + "abilities": { + "can_update": true + } + } + }, + "order": { + "data": { + "id": 5101, + "order_number": "20260601-0042", + "order_status": "preparing", + "order_status_label": "배송준비중", + "order_status_variant": "info", + "order_device": "pc", + "order_device_label": "PC", + "is_first_order": false, + "abilities": { + "can_read": true, + "can_update": true, + "can_cancel": true + }, + "subtotal_amount": 76000, + "subtotal_amount_formatted": "76,000원", + "total_discount_amount": 9500, + "total_discount_amount_formatted": "9,500원", + "total_shipping_amount": 3000, + "total_shipping_amount_formatted": "3,000원", + "total_amount": 69500, + "total_amount_formatted": "69,500원", + "total_paid_amount": 69500, + "total_paid_amount_formatted": "69,500원", + "total_product_coupon_discount_amount": 5000, + "total_product_coupon_discount_amount_formatted": "5,000원", + "total_order_coupon_discount_amount": 2500, + "total_order_coupon_discount_amount_formatted": "2,500원", + "total_coupon_discount_amount": 7500, + "total_coupon_discount_amount_formatted": "7,500원", + "total_code_discount_amount": 2000, + "total_code_discount_amount_formatted": "2,000원", + "total_points_used_amount": 1000, + "total_points_used_amount_formatted": "1,000원", + "total_deposit_used_amount": 0, + "total_deposit_used_amount_formatted": "0원", + "total_earned_points_amount": 710, + "total_earned_points_amount_formatted": "710원", + "total_tax_amount": 6318, + "total_tax_amount_formatted": "6,318원", + "total_vat_amount": 6318, + "total_vat_amount_formatted": "6,318원", + "total_list_price": 82000, + "total_list_price_formatted": "82,000원", + "total_quantity": 3, + "item_count": 2, + "mc_subtotal_amount": { + "KRW": { + "price": 76000, + "formatted": "76,000원", + "is_default": true + }, + "USD": { + "price": 55.7, + "formatted": "$55.70", + "is_default": false + } + }, + "mc_total_discount_amount": { + "KRW": { + "price": 9500, + "formatted": "9,500원", + "is_default": true + }, + "USD": { + "price": 6.9, + "formatted": "$6.90", + "is_default": false + } + }, + "mc_total_shipping_amount": { + "KRW": { + "price": 3000, + "formatted": "3,000원", + "is_default": true + }, + "USD": { + "price": 2.2, + "formatted": "$2.20", + "is_default": false + } + }, + "mc_total_amount": { + "KRW": { + "price": 69500, + "formatted": "69,500원", + "is_default": true + }, + "USD": { + "price": 50.7, + "formatted": "$50.70", + "is_default": false + } + }, + "ordered_at_formatted": "2026-06-01 19:30", + "paid_at_formatted": "2026-06-01 19:31", + "delivered_at": null, + "user_id": "u-1001", + "user_login_id": "gildong", + "orderer_name": "홍길동", + "orderer_phone": "010-1234-5678", + "orderer_tel": "02-555-1234", + "orderer_email": "gildong@example.com", + "recipient_name": "홍길동", + "recipient_phone": "010-1234-5678", + "recipient_zipcode": "06234", + "recipient_address": "서울특별시 강남구 테헤란로 123", + "recipient_detail_address": "4층 401호", + "delivery_memo": "부재 시 경비실에 맡겨주세요", + "shipping_address": { + "recipient_name": "홍길동", + "recipient_phone": "010-1234-5678", + "zipcode": "06234", + "address": "서울특별시 강남구 테헤란로 123", + "address_detail": "4층 401호", + "full_address": "서울특별시 강남구 테헤란로 123 4층 401호", + "delivery_memo": "부재 시 경비실에 맡겨주세요" + }, + "options": [ + { + "id": 9101, + "option_status": "preparing", + "option_status_label": "배송준비중", + "option_status_variant": "info", + "product_id": 1, + "product_option_id": 101, + "sku": "EARBUDS-PRO-BLK", + "product_name": "프리미엄 무선 이어버드 프로", + "product_option_name": "미드나잇 블랙 / 기본", + "option_name": "미드나잇 블랙 / 기본", + "quantity": 2, + "shipped_quantity": 0, + "unit_price": 29000, + "unit_price_formatted": "29,000원", + "subtotal_price": 58000, + "subtotal_price_formatted": "58,000원", + "subtotal_discount_amount": 7500, + "subtotal_discount_amount_formatted": "7,500원", + "list_price": 32000, + "list_price_formatted": "32,000원", + "final_amount": 50500, + "final_amount_formatted": "50,500원", + "product_coupon_discount_amount": 5000, + "product_coupon_discount_amount_formatted": "5,000원", + "order_coupon_discount_amount": 2500, + "order_coupon_discount_amount_formatted": "2,500원", + "coupon_discount_amount": 7500, + "coupon_discount_amount_formatted": "7,500원", + "code_discount_amount": 0, + "code_discount_amount_formatted": "0원", + "referral_discount_amount": 0, + "referral_discount_amount_formatted": "0원", + "subtotal_points_used_amount": 500, + "subtotal_points_used_amount_formatted": "500원", + "subtotal_deposit_used_amount": 0, + "subtotal_deposit_used_amount_formatted": "0원", + "subtotal_earned_points_amount": 530, + "subtotal_earned_points_amount_formatted": "530원", + "subtotal_tax_amount": 4818, + "subtotal_tax_amount_formatted": "4,818원", + "subtotal_vat_amount": 4818, + "subtotal_vat_amount_formatted": "4,818원", + "shipping_policy_name": "기본 배송 정책", + "shipping_type_label": "택배", + "shipping_amount": 3000, + "shipping_amount_formatted": "3,000원", + "carrier_name": "CJ대한통운", + "tracking_number": "6123456789012", + "thumbnail_url": null, + "product_snapshot": { + "product_code": "EARBUDS-PRO" + }, + "mc_unit_price": { + "KRW": { + "price": 29000, + "formatted": "29,000원", + "is_default": true + }, + "USD": { + "price": 21.3, + "formatted": "$21.30", + "is_default": false + } + }, + "mc_subtotal_price": { + "KRW": { + "price": 58000, + "formatted": "58,000원", + "is_default": true + }, + "USD": { + "price": 42.6, + "formatted": "$42.60", + "is_default": false + } + }, + "confirmed_at": null, + "promotions_applied_snapshot": { + "coupons": [ + { + "name": "이어버드 단독 10% 할인", + "total_discount_formatted": "5,000원" + } + ], + "discount_codes": [ + { + "code": "WELCOME2000", + "name": "신규가입 2,000원 할인코드", + "total_discount_formatted": "2,000원" + } + ], + "events": [] + } + }, + { + "id": 9102, + "option_status": "preparing", + "option_status_label": "배송준비중", + "option_status_variant": "info", + "product_id": 2, + "product_option_id": 102, + "sku": "CABLE-15M-WHT", + "product_name": "고속 충전 케이블 1.5m", + "product_option_name": "1.5m", + "option_name": "1.5m", + "quantity": 1, + "shipped_quantity": 0, + "unit_price": 18000, + "unit_price_formatted": "18,000원", + "subtotal_price": 18000, + "subtotal_price_formatted": "18,000원", + "subtotal_discount_amount": 0, + "subtotal_discount_amount_formatted": "0원", + "list_price": 18000, + "list_price_formatted": "18,000원", + "final_amount": 18000, + "final_amount_formatted": "18,000원", + "product_coupon_discount_amount": 0, + "product_coupon_discount_amount_formatted": "0원", + "order_coupon_discount_amount": 0, + "order_coupon_discount_amount_formatted": "0원", + "coupon_discount_amount": 0, + "coupon_discount_amount_formatted": "0원", + "code_discount_amount": 0, + "code_discount_amount_formatted": "0원", + "referral_discount_amount": 0, + "referral_discount_amount_formatted": "0원", + "subtotal_points_used_amount": 0, + "subtotal_points_used_amount_formatted": "0원", + "subtotal_deposit_used_amount": 0, + "subtotal_deposit_used_amount_formatted": "0원", + "subtotal_earned_points_amount": 180, + "subtotal_earned_points_amount_formatted": "180원", + "subtotal_tax_amount": 1500, + "subtotal_tax_amount_formatted": "1,500원", + "subtotal_vat_amount": 1500, + "subtotal_vat_amount_formatted": "1,500원", + "shipping_policy_name": "무료 배송 정책", + "shipping_type_label": "택배", + "shipping_amount": 0, + "shipping_amount_formatted": "0원", + "carrier_name": "CJ대한통운", + "tracking_number": "6123456789013", + "thumbnail_url": null, + "product_snapshot": { + "product_code": "CABLE-15M" + }, + "mc_unit_price": { + "KRW": { + "price": 18000, + "formatted": "18,000원", + "is_default": true + }, + "USD": { + "price": 13.2, + "formatted": "$13.20", + "is_default": false + } + }, + "mc_subtotal_price": { + "KRW": { + "price": 18000, + "formatted": "18,000원", + "is_default": true + }, + "USD": { + "price": 13.2, + "formatted": "$13.20", + "is_default": false + } + }, + "confirmed_at": null, + "promotions_applied_snapshot": { + "coupons": [], + "discount_codes": [], + "events": [] + } + } + ], + "payment": { + "payment_method_label": "신용카드" + }, + "payments": [ + { + "id": 8101, + "payment_status": "paid", + "payment_status_label": "결제완료", + "payment_status_variant": "success", + "payment_method": "card", + "payment_method_label": "신용카드", + "payment_type_label": "일반결제", + "payment_number": "PAY-20260601-0042", + "paid_amount_formatted": "69,500원", + "account_info": null, + "requested_at_formatted": "2026-06-01 19:30", + "paid_at_formatted": "2026-06-01 19:31", + "due_date_formatted": null, + "cancelled_amount": 0, + "cancelled_amount_formatted": "0원", + "refunded_amount": 0, + "refunded_amount_formatted": "0원", + "refund_status_label": null, + "refund_status_variant": null, + "refund_points_amount": 0, + "refund_points_amount_formatted": "0원", + "event_discount_amount": 0, + "event_discount_amount_formatted": "0원", + "referral_discount_amount": 0, + "referral_discount_amount_formatted": "0원" + } + ], + "shippings": [], + "promotions_applied_snapshot": { + "product_promotions": { + "coupons": [ + { + "coupon_id": 5001, + "name": "이어버드 단독 10% 할인", + "target_type": "product_amount", + "total_discount": 5000, + "total_discount_formatted": "5,000원" + } + ], + "discount_codes": [ + { + "code_id": 7001, + "code": "WELCOME2000", + "name": "신규가입 2,000원 할인코드", + "total_discount": 2000, + "total_discount_formatted": "2,000원" + } + ], + "events": [] + }, + "order_promotions": { + "coupons": [ + { + "coupon_id": 6001, + "name": "5만원 이상 5,000원 할인", + "target_type": "order_amount", + "total_discount": 5000, + "total_discount_formatted": "5,000원" + } + ], + "discount_codes": [], + "events": [] + } + }, + "shipping_policy_applied_snapshot": { + "policy_name": "기본 배송 정책", + "shipping_amount": 3000 + } + } + }, + "reviews": { + "data": { + "abilities": { + "can_update": true, + "can_delete": true + }, + "data": [ + { + "id": 9101, + "product_id": 101, + "user": { + "uuid": "u-aaa", + "name": "김민지", + "email": "minji@example.com" + }, + "product": { + "id": 101, + "name": "베이직 오버핏 코튼 티셔츠", + "thumbnail_url": "/images/placeholder.png" + }, + "option_snapshot_label": "블랙 / M", + "rating": 5, + "content": "핏이 정말 예쁘고 원단도 도톰해서 만족스러워요.", + "content_mode": "text", + "status": "visible", + "status_label": "노출", + "status_badge_color": "green", + "image_count": 2, + "has_reply": true, + "has_reply_label": "답변완료", + "has_reply_badge_color": "green", + "reply_content": "소중한 후기 감사합니다!", + "replied_at": "2026-05-27 09:10:00", + "created_at": "2026-05-26 11:20:00", + "updated_at": "2026-05-26 11:20:00", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "id": 9102, + "product_id": 101, + "user": { + "uuid": "u-bbb", + "name": "이서준", + "email": "seojun@example.com" + }, + "product": { + "id": 101, + "name": "베이직 오버핏 코튼 티셔츠", + "thumbnail_url": "/images/placeholder.png" + }, + "option_snapshot_label": "화이트 / L", + "rating": 4, + "content": "색감 예쁘고 좋아요.", + "content_mode": "text", + "status": "visible", + "status_label": "노출", + "status_badge_color": "green", + "image_count": 0, + "has_reply": false, + "has_reply_label": "미답변", + "has_reply_badge_color": "gray", + "reply_content": null, + "replied_at": null, + "created_at": "2026-05-24 16:42:00", + "updated_at": "2026-05-24 16:42:00", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "id": 9103, + "product_id": 102, + "user": { + "uuid": "u-ccc", + "name": "박도윤", + "email": "doyun@example.com" + }, + "product": { + "id": 102, + "name": "데일리 와이드 슬랙스", + "thumbnail_url": "/images/placeholder.png" + }, + "option_snapshot_label": "", + "rating": 2, + "content": "기대보다 별로였어요.", + "content_mode": "text", + "status": "hidden", + "status_label": "숨김", + "status_badge_color": "gray", + "image_count": 0, + "has_reply": false, + "has_reply_label": "미답변", + "has_reply_badge_color": "gray", + "reply_content": null, + "replied_at": null, + "created_at": "2026-05-22 10:05:00", + "updated_at": "2026-05-22 10:05:00", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + } + ], + "meta": { + "current_page": 1, + "last_page": 2, + "per_page": 15, + "total": 21 + } + } + }, + "brands": { + "data": { + "abilities": { + "can_create": true, + "can_update": true + }, + "data": [ + { + "id": 1, + "name": { + "ko": "코지홈" + }, + "localized_name": "코지홈", + "slug": "cozyhome", + "url": "cozyhome", + "website": "https://cozyhome.example.com", + "sort_order": 1, + "is_active": true, + "icon": "tag", + "description": { + "ko": "데일리 베이직 의류 브랜드" + }, + "products_count": 42, + "created_at": "2026-03-01 10:00:00", + "updated_at": "2026-05-01 10:00:00", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "id": 2, + "name": { + "ko": "어반핏" + }, + "localized_name": "어반핏", + "slug": "urbanfit", + "url": "urbanfit", + "website": null, + "sort_order": 2, + "is_active": true, + "icon": "tag", + "description": { + "ko": "모던 시티 캐주얼" + }, + "products_count": 18, + "created_at": "2026-03-05 10:00:00", + "updated_at": "2026-05-02 10:00:00", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "id": 3, + "name": { + "ko": "노스벨리" + }, + "localized_name": "노스벨리", + "slug": "northvalley", + "url": "northvalley", + "website": "https://northvalley.example.com", + "sort_order": 3, + "is_active": false, + "icon": "tag", + "description": null, + "products_count": 7, + "created_at": "2026-03-10 10:00:00", + "updated_at": "2026-05-03 10:00:00", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + } + ] + } + }, + "carriers": { + "data": [ + { + "id": 1, + "name": "샘플 택배", + "code": "sample", + "enabled": true + }, + { + "id": 2, + "name": "샘플 퀵", + "code": "quick", + "enabled": false + } + ] + }, + "active_carriers": { + "data": [ + { + "id": 1, + "name": "샘플 택배", + "code": "sample" + } + ] + }, + "coupons": { + "data": { + "abilities": { + "can_create": true, + "can_update": true + }, + "data": [ + { + "id": 3001, + "name": { + "ko": "신규회원 10% 할인" + }, + "localized_name": "신규회원 10% 할인", + "code": "WELCOME10", + "target_type": "order_amount", + "target_type_label": "주문금액", + "discount_type": "rate", + "discount_value": 10, + "benefit_formatted": "10% 할인", + "min_order_amount": 20000, + "max_discount_amount": 5000, + "is_active": true, + "issued_count": 1240, + "used_count": 318, + "valid_from": "2026-05-01 00:00:00", + "valid_to": "2026-06-30 23:59:59", + "created_at": "2026-04-25 10:00:00", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "id": 3002, + "name": { + "ko": "5,000원 즉시할인" + }, + "localized_name": "5,000원 즉시할인", + "code": "SAVE5000", + "target_type": "order_amount", + "target_type_label": "주문금액", + "discount_type": "fixed", + "discount_value": 5000, + "benefit_formatted": "5,000원 할인", + "min_order_amount": 30000, + "max_discount_amount": null, + "is_active": true, + "issued_count": 800, + "used_count": 145, + "valid_from": "2026-05-10 00:00:00", + "valid_to": "2026-06-15 23:59:59", + "created_at": "2026-05-08 10:00:00", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "id": 3003, + "name": { + "ko": "무료배송 쿠폰" + }, + "localized_name": "무료배송 쿠폰", + "code": "FREESHIP", + "target_type": "shipping_fee", + "target_type_label": "배송비", + "discount_type": "fixed", + "discount_value": 3000, + "benefit_formatted": "배송비 3,000원 할인", + "min_order_amount": 0, + "max_discount_amount": 3000, + "is_active": false, + "issued_count": 0, + "used_count": 0, + "valid_from": "2026-06-01 00:00:00", + "valid_to": "2026-07-31 23:59:59", + "created_at": "2026-05-20 10:00:00", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + } + ], + "pagination": { + "current_page": 1, + "last_page": 3, + "per_page": 15, + "total": 38 + } + } + }, + "coupon": { + "data": { + "id": 1, + "name": { + "ko": "신규회원 10% 할인", + "en": "New Member 10% Off" + }, + "localized_name": "신규회원 10% 할인", + "description": { + "ko": "신규 가입 회원 대상 주문금액 10% 할인 쿠폰", + "en": "10% off for new members" + }, + "localized_description": "신규 가입 회원 대상 주문금액 10% 할인 쿠폰", + "target_type": "order_amount", + "target_type_label": "주문금액", + "target_type_badge_color": "blue", + "discount_type": "rate", + "discount_type_label": "정률", + "discount_value": 10, + "discount_max_amount": 20000, + "min_order_amount": 30000, + "benefit_formatted": "10% 할인 (최대 20,000원)", + "issue_method": "download", + "issue_method_label": "다운로드", + "issue_method_badge_color": "green", + "issue_condition": "manual", + "issue_condition_label": "수동발급", + "issue_condition_badge_color": "gray", + "issue_status": "active", + "issue_status_label": "발급중", + "issue_status_badge_color": "green", + "total_quantity": 1000, + "issued_count": 342, + "per_user_limit": 1, + "issue_count_formatted": "342 / 1,000", + "valid_type": "days", + "valid_days": 30, + "valid_from": null, + "valid_to": null, + "valid_period_formatted": "발급일로부터 30일", + "issue_from": "2026-05-01T00:00", + "issue_to": "2026-06-30T23:59", + "issue_period_formatted": "2026-05-01 ~ 2026-06-30", + "is_combinable": false, + "target_scope": "all", + "target_scope_label": "제한 없음", + "is_issuable": true, + "created_at": "2026-04-25 10:00", + "updated_at": "2026-05-08 14:20", + "created_by_name": "관리자", + "included_products": [], + "excluded_products": [], + "included_categories": [], + "excluded_categories": [], + "issues_count": 342, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } + }, + "couponIssues": { + "data": { + "data": [ + { + "id": 9001, + "coupon_id": 1, + "user_id": "u-1001", + "user_name": "홍길동", + "coupon_code": "WELCOME-A1B2", + "status": "used", + "status_label": "사용완료", + "status_badge_color": "gray", + "issued_at": "2026-05-02 10:15", + "expired_at": "2026-06-01 23:59", + "used_at": "2026-05-10 14:20", + "order_id": 5101, + "discount_amount": 7000, + "is_expired": false, + "is_usable": false + }, + { + "id": 9002, + "coupon_id": 1, + "user_id": "u-1002", + "user_name": "김영희", + "coupon_code": "WELCOME-C3D4", + "status": "issued", + "status_label": "발급완료", + "status_badge_color": "green", + "issued_at": "2026-05-15 09:40", + "expired_at": "2026-06-14 23:59", + "used_at": null, + "order_id": null, + "discount_amount": 0, + "is_expired": false, + "is_usable": true + }, + { + "id": 9003, + "coupon_id": 1, + "user_id": "u-1003", + "user_name": "이철수", + "coupon_code": "WELCOME-E5F6", + "status": "expired", + "status_label": "기간만료", + "status_badge_color": "red", + "issued_at": "2026-04-01 11:00", + "expired_at": "2026-05-01 23:59", + "used_at": null, + "order_id": null, + "discount_amount": 0, + "is_expired": true, + "is_usable": false + } + ], + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 10, + "total": 3 + } + } + }, + "policy": { + "data": { + "id": 1, + "name": { + "ko": "기본 배송정책", + "en": "Standard Shipping" + }, + "name_localized": "기본 배송정책", + "is_active": true, + "is_default": true, + "sort_order": 0, + "created_at": "2026-05-01 09:00", + "updated_at": "2026-05-20 11:30", + "fee_summary": "3,000원 (50,000원 이상 무료배송)", + "countries_display": [ + { + "country_code": "KR", + "country_name": "대한민국", + "flag": "🇰🇷" + }, + { + "country_code": "US", + "country_name": "미국", + "flag": "🇺🇸" + } + ], + "country_settings": [ + { + "id": 11, + "country_code": "KR", + "shipping_method": "parcel", + "shipping_method_label": "택배", + "custom_shipping_name": null, + "currency_code": "KRW", + "charge_policy": "conditional_free", + "charge_policy_label": "조건부 무료", + "base_fee": 3000, + "free_threshold": 50000, + "ranges": null, + "api_endpoint": null, + "api_request_fields": null, + "api_response_fee_field": null, + "extra_fee_enabled": true, + "extra_fee_settings": [ + { + "region": "제주특별자치도", + "zipcode": "63000-63644", + "fee": 3000 + }, + { + "region": "도서산간", + "zipcode": "", + "fee": 4000 + } + ], + "extra_fee_multiply": false, + "is_active": true + }, + { + "id": 12, + "country_code": "US", + "shipping_method": "express", + "shipping_method_label": "특송", + "custom_shipping_name": null, + "currency_code": "USD", + "charge_policy": "range_amount", + "charge_policy_label": "구간별(금액)", + "base_fee": 0, + "free_threshold": null, + "ranges": [ + { + "min": 0, + "max": 100000, + "fee": 25000 + }, + { + "min": 100000, + "max": null, + "fee": 15000 + } + ], + "api_endpoint": null, + "api_request_fields": null, + "api_response_fee_field": null, + "extra_fee_enabled": false, + "extra_fee_settings": null, + "extra_fee_multiply": false, + "is_active": true + } + ], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } + }, + "shipping_policies": { + "data": { + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + }, + "data": [ + { + "id": 1, + "name": { + "ko": "기본 배송정책" + }, + "name_localized": "기본 배송정책", + "is_active": true, + "is_default": true, + "sort_order": 1, + "fee_summary": "3,000원 (50,000원 이상 무료배송)", + "countries_display": "🇰🇷 대한민국", + "country_settings": [ + { + "id": 1, + "country_code": "KR", + "country_name": "대한민국", + "shipping_method": "parcel", + "charge_policy": "conditional_free", + "base_fee": 3000, + "base_fee_formatted": "3,000원", + "currency_code": "KRW", + "free_threshold": 50000, + "free_threshold_formatted": "50,000원", + "carrier_label": "CJ대한통운" + } + ], + "created_at": "2026-03-01 10:00:00", + "updated_at": "2026-05-01 10:00:00", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "id": 2, + "name": { + "ko": "해외 배송정책" + }, + "name_localized": "해외 배송정책", + "is_active": true, + "is_default": false, + "sort_order": 2, + "fee_summary": "국가별 차등 (25,000원~)", + "countries_display": "🇰🇷 🇺🇸 외 3개국", + "country_settings": [ + { + "id": 1, + "country_code": "KR", + "country_name": "대한민국", + "shipping_method": "parcel", + "charge_policy": "conditional_free", + "base_fee": 3000, + "base_fee_formatted": "3,000원", + "currency_code": "KRW", + "free_threshold": 50000, + "free_threshold_formatted": "50,000원", + "carrier_label": "CJ대한통운" + }, + { + "id": 2, + "country_code": "US", + "country_name": "미국", + "shipping_method": "express", + "charge_policy": "paid", + "base_fee": 25000, + "base_fee_formatted": "25,000원", + "currency_code": "KRW", + "free_threshold": null, + "free_threshold_formatted": null, + "carrier_label": "EMS" + } + ], + "created_at": "2026-03-02 10:00:00", + "updated_at": "2026-05-02 10:00:00", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "id": 3, + "name": { + "ko": "무료배송 정책" + }, + "name_localized": "무료배송 정책", + "is_active": false, + "is_default": false, + "sort_order": 3, + "fee_summary": "전 상품 무료배송", + "countries_display": "🇰🇷 대한민국", + "country_settings": [ + { + "id": 1, + "country_code": "KR", + "country_name": "대한민국", + "shipping_method": "parcel", + "charge_policy": "free", + "base_fee": 0, + "base_fee_formatted": "0원", + "currency_code": "KRW", + "free_threshold": 0, + "free_threshold_formatted": "0원", + "carrier_label": "CJ대한통운" + } + ], + "created_at": "2026-03-03 10:00:00", + "updated_at": "2026-05-03 10:00:00", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + } + ], + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 20, + "total": 3 + } + } + }, + "extra_fee_templates": { + "data": { + "data": [ + { + "id": 1, + "description": "제주 도서산간 추가배송비", + "region": "제주특별자치도", + "zipcode": "63000-63644", + "fee": 3000, + "fee_formatted": "3,000원", + "is_active": true + }, + { + "id": 2, + "description": "울릉도/독도 추가배송비", + "region": "경상북도 울릉군", + "zipcode": "40200-40240", + "fee": 5000, + "fee_formatted": "5,000원", + "is_active": true + }, + { + "id": 3, + "description": "기타 도서산간 추가배송비", + "region": "전국 도서산간", + "zipcode": "", + "fee": 4000, + "fee_formatted": "4,000원", + "is_active": false + } + ] + } + }, + "presets": { + "data": [ + { + "id": 1, + "name": "기본 프리셋", + "config": [] + } + ] + }, + "product_labels": { + "data": { + "abilities": { + "can_create": true, + "can_update": true + }, + "data": [ + { + "id": 1, + "name": { + "ko": "BEST" + }, + "color": "#ef4444", + "is_active": true, + "sort_order": 1, + "assignments_count": 12, + "created_at": "2026-03-01 10:00:00", + "updated_at": "2026-05-01 10:00:00", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "id": 2, + "name": { + "ko": "NEW" + }, + "color": "#3b82f6", + "is_active": true, + "sort_order": 2, + "assignments_count": 8, + "created_at": "2026-03-02 10:00:00", + "updated_at": "2026-05-02 10:00:00", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "id": 3, + "name": { + "ko": "한정특가" + }, + "color": "#f59e0b", + "is_active": false, + "sort_order": 3, + "assignments_count": 0, + "created_at": "2026-03-03 10:00:00", + "updated_at": "2026-05-03 10:00:00", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + } + ] + } + }, + "notice_templates": { + "data": { + "abilities": { + "can_create": true, + "can_update": true + }, + "data": [ + { + "id": 1, + "name": { + "ko": "의류 상품정보제공고시" + }, + "localized_name": "의류 상품정보제공고시", + "category": "clothing", + "fields": [ + { + "key": "material", + "label": { + "ko": "제품 소재" + } + }, + { + "key": "size", + "label": { + "ko": "치수" + } + }, + { + "key": "manufacturer", + "label": { + "ko": "제조자" + } + } + ], + "fields_count": 3, + "is_active": true, + "sort_order": 1, + "icon": "file-alt", + "created_at": "2026-03-01 10:00:00", + "updated_at": "2026-05-01 10:00:00", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "id": 2, + "name": { + "ko": "가공식품 상품정보제공고시" + }, + "localized_name": "가공식품 상품정보제공고시", + "category": "food", + "fields": [ + { + "key": "name", + "label": { + "ko": "제품명" + } + }, + { + "key": "expiry", + "label": { + "ko": "소비기한" + } + } + ], + "fields_count": 2, + "is_active": true, + "sort_order": 2, + "icon": "file-alt", + "created_at": "2026-03-02 10:00:00", + "updated_at": "2026-05-02 10:00:00", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "id": 3, + "name": { + "ko": "전자제품 상품정보제공고시" + }, + "localized_name": "전자제품 상품정보제공고시", + "category": "electronics", + "fields": [ + { + "key": "model", + "label": { + "ko": "모델명" + } + } + ], + "fields_count": 1, + "is_active": false, + "sort_order": 3, + "icon": "file-alt", + "created_at": "2026-03-03 10:00:00", + "updated_at": "2026-05-03 10:00:00", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + } + ] + } + }, + "templates": { + "data": { + "data": [ + { + "id": 1, + "name": "기본 안내문", + "body_html": "

샘플 상품 안내 템플릿

" + } + ], + "meta": { + "total": 1, + "per_page": 20, + "current_page": 1 + }, + "abilities": { + "can_create": true + } + } + }, + "common_infos": { + "data": { + "abilities": { + "can_create": true, + "can_update": true + }, + "data": [ + { + "id": 1, + "name": { + "ko": "기본 배송/교환/환불 안내" + }, + "localized_name": "기본 배송/교환/환불 안내", + "content": { + "ko": "

주문 후 영업일 기준 1~2일 내 출고됩니다. 단순 변심 교환·반품은 수령 후 7일 이내 가능합니다.

" + }, + "localized_content": "

주문 후 영업일 기준 1~2일 내 출고됩니다. 단순 변심 교환·반품은 수령 후 7일 이내 가능합니다.

", + "content_mode": "html", + "is_default": true, + "is_active": true, + "sort_order": 1, + "icon": "info-circle", + "products_count": 30, + "language_count": 1, + "created_at": "2026-03-01 10:00:00", + "updated_at": "2026-05-01 10:00:00", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "id": 2, + "name": { + "ko": "식품 보관 안내" + }, + "localized_name": "식품 보관 안내", + "content": { + "ko": "직사광선을 피해 서늘한 곳에 보관하세요." + }, + "localized_content": "직사광선을 피해 서늘한 곳에 보관하세요.", + "content_mode": "text", + "is_default": false, + "is_active": true, + "sort_order": 2, + "icon": "info-circle", + "products_count": 5, + "language_count": 1, + "created_at": "2026-03-02 10:00:00", + "updated_at": "2026-05-02 10:00:00", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "id": 3, + "name": { + "ko": "대형가전 설치 안내" + }, + "localized_name": "대형가전 설치 안내", + "content": { + "ko": "

설치 기사 방문 일정은 별도 안내됩니다.

" + }, + "localized_content": "

설치 기사 방문 일정은 별도 안내됩니다.

", + "content_mode": "html", + "is_default": false, + "is_active": true, + "sort_order": 3, + "icon": "info-circle", + "products_count": 2, + "language_count": 1, + "created_at": "2026-03-03 10:00:00", + "updated_at": "2026-05-03 10:00:00", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + } + ] + } + }, + "commonInfos": { + "data": { + "abilities": { + "can_create": true, + "can_update": true + }, + "data": [ + { + "id": 1, + "name": { + "ko": "기본 배송/교환/환불 안내" + }, + "localized_name": "기본 배송/교환/환불 안내", + "content": { + "ko": "

주문 후 영업일 기준 1~2일 내 출고됩니다. 단순 변심 교환·반품은 수령 후 7일 이내 가능합니다.

" + }, + "localized_content": "

주문 후 영업일 기준 1~2일 내 출고됩니다. 단순 변심 교환·반품은 수령 후 7일 이내 가능합니다.

", + "content_mode": "html", + "is_default": true, + "is_active": true, + "sort_order": 1, + "icon": "info-circle", + "products_count": 30, + "language_count": 1, + "created_at": "2026-03-01 10:00:00", + "updated_at": "2026-05-01 10:00:00", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "id": 2, + "name": { + "ko": "식품 보관 안내" + }, + "localized_name": "식품 보관 안내", + "content": { + "ko": "직사광선을 피해 서늘한 곳에 보관하세요." + }, + "localized_content": "직사광선을 피해 서늘한 곳에 보관하세요.", + "content_mode": "text", + "is_default": false, + "is_active": true, + "sort_order": 2, + "icon": "info-circle", + "products_count": 5, + "language_count": 1, + "created_at": "2026-03-02 10:00:00", + "updated_at": "2026-05-02 10:00:00", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "id": 3, + "name": { + "ko": "대형가전 설치 안내" + }, + "localized_name": "대형가전 설치 안내", + "content": { + "ko": "

설치 기사 방문 일정은 별도 안내됩니다.

" + }, + "localized_content": "

설치 기사 방문 일정은 별도 안내됩니다.

", + "content_mode": "html", + "is_default": false, + "is_active": true, + "sort_order": 3, + "icon": "info-circle", + "products_count": 2, + "language_count": 1, + "created_at": "2026-03-03 10:00:00", + "updated_at": "2026-05-03 10:00:00", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + } + ] + } + }, + "order_logs": { + "data": { + "data": [ + { + "id": 3001, + "created_at": "2026-06-03 09:12", + "localized_description": "주문 상태를 결제완료에서 배송준비중으로 변경했습니다", + "user": { + "uuid": "admin-1", + "name": "관리자" + }, + "changes": [ + { + "field": "order_status", + "label": "주문상태", + "old": "payment_complete", + "old_label": "결제완료", + "new": "preparing", + "new_label": "배송준비중" + } + ], + "bulk_changes": [] + }, + { + "id": 3002, + "created_at": "2026-06-01 19:31", + "localized_description": "결제가 완료되었습니다", + "user": null, + "changes": [ + { + "field": "payment_status", + "label": "결제상태", + "old": "pending", + "old_label": "결제대기", + "new": "paid", + "new_label": "결제완료" + } + ], + "bulk_changes": [] + }, + { + "id": 3003, + "created_at": "2026-06-01 19:30", + "localized_description": "관리자 메모를 수정했습니다", + "user": { + "uuid": "admin-1", + "name": "관리자" + }, + "changes": [ + { + "field": "admin_memo", + "label": "관리자 메모", + "old": "", + "old_label": "(없음)", + "new": "VIP 고객 — 빠른 배송 요청", + "new_label": "VIP 고객 — 빠른 배송 요청" + }, + { + "field": "delivery_memo", + "label": "배송 메모", + "old": "", + "old_label": "(없음)", + "new": "부재 시 경비실", + "new_label": "부재 시 경비실" + } + ], + "bulk_changes": [] + } + ], + "meta": { + "current_page": 1, + "last_page": 1, + "per_page": 20, + "total": 3 + } + } + }, + "activity_logs": { + "data": { + "data": [ + { + "id": 1, + "log_type": "admin", + "log_type_label": "관리자", + "loggable_type": "Product", + "loggable_type_display": "상품", + "loggable_id": 101, + "action": "product.updated", + "action_label": "상품 수정", + "localized_description": "'베이직 오버핏 코튼 티셔츠' 상품을 수정했습니다.", + "description_key": "product.updated", + "properties": null, + "changes": [ + { + "field": "selling_price", + "label": "판매가", + "old": 29000, + "new": 23200, + "old_label": "29,000원", + "new_label": "23,200원" + }, + { + "field": "sales_status", + "label": "판매상태", + "old": "sold_out", + "new": "on_sale", + "old_label": "품절", + "new_label": "판매중" + } + ], + "bulk_changes": null, + "has_changes": true, + "actor_name": "관리자", + "user": { + "uuid": "admin-uuid-1", + "name": "관리자", + "email": "admin@example.com" + }, + "ip_address": "127.0.0.1", + "created_at": "2026-05-28 09:15:00", + "is_owner": false + }, + { + "id": 2, + "log_type": "admin", + "log_type_label": "관리자", + "loggable_type": "Product", + "loggable_type_display": "상품", + "loggable_id": 101, + "action": "product.created", + "action_label": "상품 등록", + "localized_description": "'베이직 오버핏 코튼 티셔츠' 상품을 등록했습니다.", + "description_key": "product.created", + "properties": null, + "changes": null, + "bulk_changes": null, + "has_changes": false, + "actor_name": "운영자", + "user": { + "uuid": "admin-uuid-2", + "name": "운영자", + "email": "manager@example.com" + }, + "ip_address": "127.0.0.1", + "created_at": "2026-05-20 14:30:00", + "is_owner": false + }, + { + "id": 3, + "log_type": "admin", + "log_type_label": "관리자", + "loggable_type": "Product", + "loggable_type_display": "상품", + "loggable_id": 101, + "action": "product.stock_adjusted", + "action_label": "재고 조정", + "localized_description": "재고를 조정했습니다.", + "description_key": "product.stock_adjusted", + "properties": null, + "changes": [ + { + "field": "stock_quantity", + "label": "재고수량", + "old": 200, + "new": 320, + "old_label": "200", + "new_label": "320" + } + ], + "bulk_changes": null, + "has_changes": true, + "actor_name": "시스템", + "user": { + "name": "시스템" + }, + "ip_address": null, + "created_at": "2026-05-25 03:00:00", + "is_owner": false + } + ], + "meta": { + "current_page": 1, + "last_page": 1, + "per_page": 10, + "total": 3 + } + } + }, + "settings": { + "data": { + "general": { + "shop_name": "샘플 쇼핑몰", + "currency": "KRW" + }, + "shipping": { + "default_fee": 3000, + "free_threshold": 50000 + }, + "inquiry": { + "board_slug": "product-inquiry" + }, + "abilities": { + "can_update": true + } + } + }, + "ecommerce_settings": { + "data": { + "basic_info": { + "shop_name": "", + "route_path": "shop", + "no_route": false, + "company_name": "", + "business_number": "", + "ceo_name": "", + "business_type": "", + "business_category": "", + "zipcode": "", + "base_address": "", + "detail_address": "", + "phone": "", + "fax": "", + "email": "", + "privacy_officer": "", + "privacy_officer_email": "", + "mail_order_number": "", + "telecom_number": "" + }, + "language_currency": { + "default_language": "ko", + "default_currency": "KRW", + "currencies": [ + { + "code": "KRW", + "name": { + "ko": "KRW (원)", + "en": "KRW (Won)", + "ja": "KRW (ウォン)" + }, + "exchange_rate": null, + "rounding_unit": "1", + "rounding_method": "floor", + "decimal_places": 0, + "is_default": true + }, + { + "code": "USD", + "name": { + "ko": "USD (달러)", + "en": "USD (Dollar)", + "ja": "USD (ドル)" + }, + "exchange_rate": 0.85, + "rounding_unit": "0.01", + "rounding_method": "round", + "decimal_places": 2, + "is_default": false + }, + { + "code": "JPY", + "name": { + "ko": "JPY (엔)", + "en": "JPY (Yen)", + "ja": "JPY (円)" + }, + "exchange_rate": 115, + "rounding_unit": "1", + "rounding_method": "floor", + "decimal_places": 0, + "is_default": false + }, + { + "code": "CNY", + "name": { + "ko": "CNY (위안)", + "en": "CNY (Yuan)", + "ja": "CNY (人民元)" + }, + "exchange_rate": 5.8, + "rounding_unit": "0.01", + "rounding_method": "round", + "decimal_places": 2, + "is_default": false + }, + { + "code": "EUR", + "name": { + "ko": "EUR (유로)", + "en": "EUR (Euro)", + "ja": "EUR (ユーロ)" + }, + "exchange_rate": 0.78, + "rounding_unit": "0.01", + "rounding_method": "round", + "decimal_places": 2, + "is_default": false + } + ] + }, + "order_settings": { + "default_pg_provider": null, + "payment_methods": [ + { + "id": "card", + "pg_provider": null, + "sort_order": 1, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "신용카드", + "en": "Credit Card", + "ja": "クレジットカード" + }, + "_cached_description": { + "ko": "신용카드로 안전하게 결제", + "en": "Pay securely with credit card", + "ja": "クレジットカードで安全に決済" + }, + "_cached_icon": "credit-card", + "_cached_source": "builtin" + }, + { + "id": "vbank", + "pg_provider": null, + "sort_order": 2, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "가상계좌", + "en": "Virtual Account", + "ja": "バーチャル口座" + }, + "_cached_description": { + "ko": "가상계좌로 입금", + "en": "Pay via virtual account", + "ja": "バーチャル口座に入金" + }, + "_cached_icon": "money-check", + "_cached_source": "builtin" + }, + { + "id": "dbank", + "pg_provider": null, + "sort_order": 3, + "is_active": true, + "min_order_amount": 0, + "stock_deduction_timing": "order_placed", + "_cached_name": { + "ko": "무통장입금", + "en": "Bank Transfer", + "ja": "銀行振込" + }, + "_cached_description": { + "ko": "지정 계좌로 직접 입금", + "en": "Direct bank transfer", + "ja": "指定口座に直接入金" + }, + "_cached_icon": "building-columns", + "_cached_source": "builtin" + }, + { + "id": "bank", + "pg_provider": null, + "sort_order": 4, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "계좌이체", + "en": "Account Transfer", + "ja": "口座振替" + }, + "_cached_description": { + "ko": "실시간 계좌이체", + "en": "Real-time bank transfer", + "ja": "リアルタイム口座振替" + }, + "_cached_icon": "building-columns", + "_cached_source": "builtin" + }, + { + "id": "phone", + "pg_provider": null, + "sort_order": 5, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "휴대폰결제", + "en": "Mobile Payment", + "ja": "携帯電話決済" + }, + "_cached_description": { + "ko": "휴대폰 소액결제", + "en": "Mobile phone payment", + "ja": "携帯電話小額決済" + }, + "_cached_icon": "mobile-screen-button", + "_cached_source": "builtin" + }, + { + "id": "kginicis_samsung_pay", + "pg_provider": null, + "sort_order": 6, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "삼성페이 (KG이니시스)", + "en": "Samsung Pay (KG Inicis)" + }, + "_cached_description": { + "ko": "삼성페이로 결제 — KG 이니시스를 통해 처리", + "en": "Pay with Samsung Pay via KG Inicis" + }, + "_cached_icon": "mobile-screen-button", + "_cached_source": "plugin:sirsoft-pay_kginicis" + }, + { + "id": "kginicis_naverpay", + "pg_provider": null, + "sort_order": 7, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "네이버페이 (KG이니시스)", + "en": "Naver Pay (KG Inicis)" + }, + "_cached_description": { + "ko": "네이버페이로 결제 — KG 이니시스를 통해 처리", + "en": "Pay with Naver Pay via KG Inicis" + }, + "_cached_icon": "wallet", + "_cached_source": "plugin:sirsoft-pay_kginicis" + }, + { + "id": "kginicis_lpay", + "pg_provider": null, + "sort_order": 8, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "L.pay (KG이니시스)", + "en": "L.pay (KG Inicis)" + }, + "_cached_description": { + "ko": "L.pay 로 결제 — KG 이니시스를 통해 처리", + "en": "Pay with L.pay via KG Inicis" + }, + "_cached_icon": "mobile-screen-button", + "_cached_source": "plugin:sirsoft-pay_kginicis" + }, + { + "id": "kginicis_kakaopay", + "pg_provider": null, + "sort_order": 9, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "카카오페이 (KG이니시스)", + "en": "Kakao Pay (KG Inicis)" + }, + "_cached_description": { + "ko": "카카오페이로 결제 — KG 이니시스를 통해 처리", + "en": "Pay with Kakao Pay via KG Inicis" + }, + "_cached_icon": "mobile-screen-button", + "_cached_source": "plugin:sirsoft-pay_kginicis" + }, + { + "id": "kginicis_japan_paypay", + "pg_provider": null, + "sort_order": 10, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "PayPay (일본 KG이니시스)", + "en": "PayPay (KG Inicis Japan)" + }, + "_cached_description": { + "ko": "일본 엔(JPY) 주문을 KG 이니시스 CBT PayPay로 결제", + "en": "Pay JPY orders with KG Inicis CBT PayPay" + }, + "_cached_icon": "wallet", + "_cached_source": "plugin:sirsoft-pay_kginicis" + }, + { + "id": "point", + "pg_provider": null, + "sort_order": 10, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "order_placed", + "_cached_name": { + "ko": "포인트결제", + "en": "Points", + "ja": "ポイント決済" + }, + "_cached_description": { + "ko": "적립 포인트로 결제", + "en": "Pay with points", + "ja": "積立ポイントで決済" + }, + "_cached_icon": "coins", + "_cached_source": "builtin" + }, + { + "id": "kginicis_japan_cvs", + "pg_provider": null, + "sort_order": 11, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "일본 편의점결제 (KG이니시스)", + "en": "Japan Convenience Store (KG Inicis)" + }, + "_cached_description": { + "ko": "일본 엔(JPY) 주문을 KG 이니시스 CBT 편의점 결제로 접수", + "en": "Accept JPY orders with KG Inicis CBT convenience store payment" + }, + "_cached_icon": "store", + "_cached_source": "plugin:sirsoft-pay_kginicis" + }, + { + "id": "deposit", + "pg_provider": null, + "sort_order": 11, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "order_placed", + "_cached_name": { + "ko": "예치금결제", + "en": "Store Credit", + "ja": "預金決済" + }, + "_cached_description": { + "ko": "예치금으로 결제", + "en": "Pay with store credit", + "ja": "預金で決済" + }, + "_cached_icon": "wallet", + "_cached_source": "builtin" + }, + { + "id": "free", + "pg_provider": null, + "sort_order": 12, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "order_placed", + "_cached_name": { + "ko": "무료", + "en": "Free", + "ja": "無料" + }, + "_cached_description": { + "ko": "결제 없이 주문 완료", + "en": "Order without payment", + "ja": "決済なしで注文完了" + }, + "_cached_icon": "gift", + "_cached_source": "builtin" + } + ], + "banks": [ + { + "code": "004", + "name": { + "ko": "국민은행", + "en": "Kookmin Bank" + } + }, + { + "code": "088", + "name": { + "ko": "신한은행", + "en": "Shinhan Bank" + } + }, + { + "code": "020", + "name": { + "ko": "우리은행", + "en": "Woori Bank" + } + }, + { + "code": "081", + "name": { + "ko": "하나은행", + "en": "Hana Bank" + } + }, + { + "code": "003", + "name": { + "ko": "IBK기업은행", + "en": "IBK Industrial Bank" + } + }, + { + "code": "011", + "name": { + "ko": "NH농협은행", + "en": "NH Nonghyup Bank" + } + }, + { + "code": "071", + "name": { + "ko": "우체국", + "en": "Korea Post" + } + }, + { + "code": "031", + "name": { + "ko": "DGB대구은행", + "en": "DGB Daegu Bank" + } + }, + { + "code": "032", + "name": { + "ko": "BNK부산은행", + "en": "BNK Busan Bank" + } + }, + { + "code": "034", + "name": { + "ko": "광주은행", + "en": "Kwangju Bank" + } + }, + { + "code": "035", + "name": { + "ko": "제주은행", + "en": "Jeju Bank" + } + }, + { + "code": "037", + "name": { + "ko": "전북은행", + "en": "Jeonbuk Bank" + } + }, + { + "code": "039", + "name": { + "ko": "BNK경남은행", + "en": "BNK Kyongnam Bank" + } + }, + { + "code": "045", + "name": { + "ko": "새마을금고", + "en": "MG Community Credit Cooperatives" + } + }, + { + "code": "048", + "name": { + "ko": "신협", + "en": "KFCC" + } + }, + { + "code": "090", + "name": { + "ko": "카카오뱅크", + "en": "Kakao Bank" + } + }, + { + "code": "092", + "name": { + "ko": "토스뱅크", + "en": "Toss Bank" + } + } + ], + "bank_accounts": [ + { + "bank_code": "004", + "account_number": "", + "account_holder": "", + "is_active": false, + "is_default": false + } + ], + "auto_cancel_expired": true, + "auto_cancel_days": 3, + "vbank_due_days": 3, + "dbank_due_days": 7, + "cart_expiry_days": 30, + "stock_restore_on_cancel": true, + "cancellable_statuses": [ + "payment_complete" + ], + "confirmable_statuses": [ + "shipping", + "delivered" + ] + }, + "shipping": { + "default_country": "KR", + "available_countries": [ + { + "code": "KR", + "name": { + "ko": "대한민국", + "en": "South Korea", + "ja": "大韓民国" + }, + "is_active": true + }, + { + "code": "US", + "name": { + "ko": "미국", + "en": "United States", + "ja": "アメリカ" + }, + "is_active": false + }, + { + "code": "JP", + "name": { + "ko": "일본", + "en": "Japan", + "ja": "日本" + }, + "is_active": false + }, + { + "code": "CN", + "name": { + "ko": "중국", + "en": "China", + "ja": "中国" + }, + "is_active": false + }, + { + "code": "SG", + "name": { + "ko": "싱가포르", + "en": "Singapore", + "ja": "シンガポール" + }, + "is_active": false + }, + { + "code": "HK", + "name": { + "ko": "홍콩", + "en": "Hong Kong", + "ja": "香港" + }, + "is_active": false + }, + { + "code": "TW", + "name": { + "ko": "대만", + "en": "Taiwan", + "ja": "台湾" + }, + "is_active": false + }, + { + "code": "VN", + "name": { + "ko": "베트남", + "en": "Vietnam", + "ja": "ベトナム" + }, + "is_active": false + }, + { + "code": "TH", + "name": { + "ko": "태국", + "en": "Thailand", + "ja": "タイ" + }, + "is_active": false + }, + { + "code": "MY", + "name": { + "ko": "말레이시아", + "en": "Malaysia", + "ja": "マレーシア" + }, + "is_active": false + } + ], + "international_shipping_enabled": false, + "free_shipping_threshold": 50000, + "free_shipping_enabled": true, + "address_validation_enabled": false, + "address_api_provider": "kakao", + "carriers": [ + { + "id": 1, + "code": "cj", + "name": { + "ko": "CJ대한통운", + "en": "CJ Logistics", + "ja": "CJ大韓通運" + }, + "type": "domestic", + "tracking_url": "https://trace.cjlogistics.com/next/tracking.html?wblNo={tracking_number}", + "is_active": true, + "sort_order": 1 + }, + { + "id": 2, + "code": "hanjin", + "name": { + "ko": "한진택배", + "en": "Hanjin Express", + "ja": "韓進택배" + }, + "type": "domestic", + "tracking_url": "https://www.hanjin.com/kor/CMS/DeliveryMgr/WaybillResult.do?wblnb={tracking_number}", + "is_active": true, + "sort_order": 2 + }, + { + "id": 3, + "code": "lotte", + "name": { + "ko": "롯데택배", + "en": "Lotte Global Logistics", + "ja": "ロッテ택배" + }, + "type": "domestic", + "tracking_url": "https://www.lotteglogis.com/home/reservation/tracking/link498?InvNo={tracking_number}", + "is_active": true, + "sort_order": 3 + }, + { + "id": 4, + "code": "logen", + "name": { + "ko": "로젠택배", + "en": "Logen Logistics", + "ja": "ロジェン택배" + }, + "type": "domestic", + "tracking_url": "https://www.ilogen.com/web/personal/trace/{tracking_number}", + "is_active": true, + "sort_order": 4 + }, + { + "id": 5, + "code": "ups", + "name": { + "ko": "UPS", + "en": "UPS", + "ja": "UPS" + }, + "type": "international", + "tracking_url": "https://www.ups.com/track?tracknum={tracking_number}", + "is_active": true, + "sort_order": 5 + }, + { + "id": 6, + "code": "ems", + "name": { + "ko": "EMS", + "en": "EMS", + "ja": "EMS" + }, + "type": "international", + "tracking_url": "https://service.epost.go.kr/trace.RetrieveEmsRi498.postal?POST_CODE={tracking_number}", + "is_active": true, + "sort_order": 6 + }, + { + "id": 7, + "code": "dhl", + "name": { + "ko": "DHL", + "en": "DHL", + "ja": "DHL" + }, + "type": "international", + "tracking_url": "https://www.dhl.com/kr-ko/home/tracking/tracking-express.html?submit=1&tracking-id={tracking_number}", + "is_active": true, + "sort_order": 7 + }, + { + "id": 8, + "code": "fedex", + "name": { + "ko": "FedEx", + "en": "FedEx", + "ja": "FedEx" + }, + "type": "international", + "tracking_url": "https://www.fedex.com/fedextrack/?tracknumbers={tracking_number}", + "is_active": true, + "sort_order": 8 + }, + { + "id": 9, + "code": "sf", + "name": { + "ko": "SF Express", + "en": "SF Express", + "ja": "SF Express" + }, + "type": "international", + "tracking_url": null, + "is_active": true, + "sort_order": 9 + }, + { + "id": 10, + "code": "yamato", + "name": { + "ko": "야마토운수", + "en": "Yamato Transport", + "ja": "ヤマト運輸" + }, + "type": "international", + "tracking_url": null, + "is_active": true, + "sort_order": 10 + }, + { + "id": 11, + "code": "sagawa", + "name": { + "ko": "사가와익스프레스", + "en": "Sagawa Express", + "ja": "佐川急便" + }, + "type": "international", + "tracking_url": null, + "is_active": true, + "sort_order": 11 + }, + { + "id": 12, + "code": "other", + "name": { + "ko": "기타", + "en": "Other", + "ja": "その他" + }, + "type": "domestic", + "tracking_url": null, + "is_active": true, + "sort_order": 99 + } + ], + "types": [ + { + "id": 1, + "code": "parcel", + "name": { + "ko": "택배", + "en": "Parcel", + "ja": "宅配便" + }, + "category": "domestic", + "is_active": true, + "sort_order": 1 + }, + { + "id": 2, + "code": "direct", + "name": { + "ko": "직접배송", + "en": "Direct Delivery", + "ja": "直送" + }, + "category": "domestic", + "is_active": true, + "sort_order": 2 + }, + { + "id": 3, + "code": "quick", + "name": { + "ko": "퀵서비스", + "en": "Quick Service", + "ja": "クイックサービス" + }, + "category": "domestic", + "is_active": true, + "sort_order": 3 + }, + { + "id": 4, + "code": "freight", + "name": { + "ko": "화물배송", + "en": "Freight", + "ja": "貨物配送" + }, + "category": "domestic", + "is_active": true, + "sort_order": 4 + }, + { + "id": 5, + "code": "pickup", + "name": { + "ko": "매장수령", + "en": "Store Pickup", + "ja": "店舗受取" + }, + "category": "domestic", + "is_active": true, + "sort_order": 5 + }, + { + "id": 6, + "code": "express", + "name": { + "ko": "국내특급", + "en": "Express", + "ja": "国内特急" + }, + "category": "domestic", + "is_active": false, + "sort_order": 6 + }, + { + "id": 7, + "code": "international_ems", + "name": { + "ko": "국제EMS", + "en": "International EMS", + "ja": "国際EMS" + }, + "category": "international", + "is_active": false, + "sort_order": 7 + }, + { + "id": 8, + "code": "international_standard", + "name": { + "ko": "국제일반", + "en": "International Standard", + "ja": "国際通常" + }, + "category": "international", + "is_active": false, + "sort_order": 8 + }, + { + "id": 9, + "code": "cvs", + "name": { + "ko": "편의점택배", + "en": "Convenience Store", + "ja": "コンビニ宅配便" + }, + "category": "other", + "is_active": false, + "sort_order": 9 + }, + { + "id": 10, + "code": "digital", + "name": { + "ko": "디지털상품", + "en": "Digital", + "ja": "デジタル商品" + }, + "category": "other", + "is_active": false, + "sort_order": 10 + }, + { + "id": 11, + "code": "custom", + "name": { + "ko": "직접입력", + "en": "Custom", + "ja": "直接入力" + }, + "category": "domestic", + "is_active": true, + "sort_order": 99 + } + ] + }, + "seo": { + "meta_category_title": "{commerce_name} - {category_name}", + "meta_category_description": "", + "meta_search_title": "{commerce_name} - {keyword_name}", + "meta_search_description": "", + "meta_product_title": "{commerce_name} - {product_name}", + "meta_product_description": "", + "meta_shop_index_title": "{commerce_name}", + "meta_shop_index_description": "", + "seo_category": true, + "seo_search_result": true, + "seo_product_detail": true, + "seo_shop_index": true + }, + "review_settings": { + "write_deadline_days": 90, + "max_images": 5, + "max_image_size_mb": 10 + }, + "inquiry": { + "board_slug": null + }, + "notifications": { + "channels": [ + { + "id": "mail", + "is_active": true, + "sort_order": 1 + }, + { + "id": "database", + "is_active": true, + "sort_order": 2 + } + ] + }, + "claim": { + "refund_reasons": [ + { + "id": 1, + "type": "refund", + "code": "order_mistake", + "name": { + "ko": "주문 실수", + "en": "Order Mistake", + "ja": "注文ミス" + }, + "localized_name": "주문 실수", + "fault_type": "customer", + "is_user_selectable": true, + "is_active": true, + "sort_order": 0 + }, + { + "id": 2, + "type": "refund", + "code": "changed_mind", + "name": { + "ko": "단순 변심", + "en": "Changed Mind", + "ja": "単純な心変わり" + }, + "localized_name": "단순 변심", + "fault_type": "customer", + "is_user_selectable": true, + "is_active": true, + "sort_order": 1 + }, + { + "id": 3, + "type": "refund", + "code": "reorder_other", + "name": { + "ko": "다른 상품으로 재주문", + "en": "Reorder with Different Product", + "ja": "別の商品で再注文" + }, + "localized_name": "다른 상품으로 재주문", + "fault_type": "customer", + "is_user_selectable": true, + "is_active": true, + "sort_order": 2 + }, + { + "id": 4, + "type": "refund", + "code": "delayed_delivery", + "name": { + "ko": "배송 지연", + "en": "Delayed Delivery", + "ja": "配送遅延" + }, + "localized_name": "배송 지연", + "fault_type": "seller", + "is_user_selectable": true, + "is_active": true, + "sort_order": 3 + }, + { + "id": 5, + "type": "refund", + "code": "product_info_different", + "name": { + "ko": "상품 정보 상이", + "en": "Product Info Different", + "ja": "商品情報の相違" + }, + "localized_name": "상품 정보 상이", + "fault_type": "seller", + "is_user_selectable": true, + "is_active": true, + "sort_order": 4 + }, + { + "id": 6, + "type": "refund", + "code": "admin_cancel", + "name": { + "ko": "관리자 취소", + "en": "Admin Cancel", + "ja": "管理者キャンセル" + }, + "localized_name": "관리자 취소", + "fault_type": "seller", + "is_user_selectable": false, + "is_active": true, + "sort_order": 5 + }, + { + "id": 7, + "type": "refund", + "code": "etc", + "name": { + "ko": "기타", + "en": "Etc", + "ja": "その他" + }, + "localized_name": "기타", + "fault_type": "customer", + "is_user_selectable": true, + "is_active": true, + "sort_order": 6 + } + ] + }, + "available_pg_providers": [ + { + "id": "kginicis", + "name": "KG 이니시스", + "icon": "credit-card", + "supported_methods": [ + "card", + "bank_transfer", + "virtual_account", + "mobile" + ] + } + ], + "abilities": { + "can_update": true + } + } + }, + "seoStats": { + "data": { + "count": 0, + "size_bytes": 0, + "size_formatted": "0 B" + } + }, + "copy_source": { + "data": { + "id": 102, + "name": { + "ko": "데일리 와이드 슬랙스", + "en": "Daily Wide Slacks" + }, + "product_code": "PT-2042", + "selling_price": 39000, + "thumbnail_url": "/images/placeholder.png", + "images": [ + { + "id": 5101, + "hash": "img5101", + "original_filename": "slacks_main.jpg", + "download_url": "/images/placeholder.png", + "alt_text": "정면", + "is_thumbnail": true, + "sort_order": 0, + "order": 0, + "width": 1000, + "height": 1000, + "file_size": 172000, + "size": 172000, + "size_formatted": "168 KB", + "mime_type": "image/jpeg", + "is_image": true + }, + { + "id": 5102, + "hash": "img5102", + "original_filename": "slacks_detail.jpg", + "download_url": "/images/placeholder.png", + "alt_text": "디테일", + "is_thumbnail": false, + "sort_order": 1, + "order": 1, + "width": 1000, + "height": 1000, + "file_size": 165000, + "size": 165000, + "size_formatted": "161 KB", + "mime_type": "image/jpeg", + "is_image": true + } + ] + } + }, + "refundReasons": { + "data": [ + { + "id": 1, + "reason": "단순 변심", + "code": "change_mind" + }, + { + "id": 2, + "reason": "상품 불량", + "code": "defective" + } + ] + }, + "availableChannels": { + "data": { + "channels": [ + { + "id": "mail", + "name": "이메일", + "icon": "fas fa-envelope", + "source": "core", + "source_label": "코어" + }, + { + "id": "sms", + "name": "SMS", + "icon": "fas fa-comment-sms", + "source": "core", + "source_label": "코어" + }, + { + "id": "push", + "name": "푸시", + "icon": "fas fa-bell", + "source": "core", + "source_label": "코어" + } + ] + } + }, + "identityProviders": { + "data": [ + { + "id": "kginicis", + "label": "KG이니시스" + }, + { + "id": "nice", + "label": "나이스" + } + ] + }, + "ecommerceIdentityPurposes": { + "data": [ + { + "id": "signup", + "label": "회원가입", + "source_identifier": "sirsoft-ecommerce", + "description": "회원가입 시 본인인증", + "allowed_channels": [ + "mobile" + ] + }, + { + "id": "sensitive_action", + "label": "민감 작업", + "source_identifier": "core", + "description": "민감 작업 시 본인인증", + "allowed_channels": [ + "mobile", + "ipin" + ] + } + ] + }, + "ecommerceIdentityPolicies": { + "data": { + "data": [ + { + "id": 1, + "key": "shop.signup", + "scope": "route", + "target": "/shop/signup", + "purpose": "signup", + "provider_id": "kginicis", + "grace_minutes": 0, + "enabled": true, + "source_type": "admin", + "abilities": { + "can_update": true, + "can_delete": true + } + } + ], + "meta": { + "total": 1, + "current_page": 1, + "last_page": 1, + "per_page": 20 + }, + "abilities": { + "can_create": true + } + } + }, + "ecommerceNotificationDefinitions": { + "data": { + "data": [ + { + "id": 1, + "type": "order.paid", + "is_active": true, + "is_default": true, + "variables": [ + { + "key": "order_no" + } + ], + "templates": [ + { + "id": 11, + "channel": "mail", + "is_active": true, + "is_default": true, + "subject": { + "ko": "결제 완료 안내", + "en": "Payment completed" + }, + "body": { + "ko": "

결제가 완료되었습니다.

", + "en": "

Payment completed.

" + }, + "recipients": [ + { + "type": "trigger_user" + } + ] + } + ] + } + ], + "pagination": { + "total": 1, + "current_page": 1, + "last_page": 1 + }, + "abilities": { + "can_update": true + } + } + }, + "roles": { + "data": { + "data": [ + { + "id": 1, + "identifier": "super_admin", + "name": "최고 관리자", + "display_name": "최고 관리자", + "description": "전체 권한", + "is_system": true + }, + { + "id": 2, + "identifier": "manager", + "name": "운영자", + "display_name": "운영자", + "description": "운영 권한", + "is_system": false + }, + { + "id": 3, + "identifier": "member", + "name": "일반 회원", + "display_name": "일반 회원", + "description": "기본 회원", + "is_system": true + } + ] + } + }, + "category": { + "data": { + "breadcrumb": [ + "샘플" + ], + "children": [ + "샘플" + ], + "description": "

샘플 설명입니다.

", + "description_localized": "샘플 description_localized", + "images": [ + "샘플" + ], + "name": "샘플 name", + "name_localized": "샘플 name_localized" + } + }, + "qna": { + "data": { + "items": [ + "샘플" + ], + "meta": { + "abilities": { + "can_delete": true, + "can_update": true + }, + "board_settings": "샘플", + "current_page": 3, + "last_page": 3, + "total": 10000 + } + } + }, + "productDownloadableCoupons": { + "data": { + "data": "샘플" + } + }, + "cartItems": { + "data": { + "calculation": { + "items": [ + "샘플" + ], + "summary": { + "base_shipping_total": 10000, + "code_discount": 10000, + "discount_formatted": "10,000원", + "extra_shipping_total": 10000, + "final_amount_formatted": "10,000원", + "mileage_formatted": "10,000원", + "multi_currency": "샘플", + "order_coupon_discount": 10000, + "payment_amount_formatted": "10,000원", + "points_earning": 10000, + "product_coupon_discount": 10000, + "shipping_discount": 10000, + "shipping_fee_formatted": "10,000원", + "subtotal_formatted": "10,000원", + "tax_free_amount": 10000, + "taxable_amount": 10000, + "total_discount": 10000, + "total_shipping": 10000 + } + }, + "item_count": 3, + "items": [ + "샘플" + ] + } + }, + "checkoutData": { + "data": { + "available_coupons": [ + "샘플" + ], + "calculation": { + "items": [ + "샘플" + ], + "promotions": { + "order_promotions": { + "coupons": [ + "샘플" + ] + }, + "product_promotions": { + "coupons": [ + "샘플" + ] + } + }, + "summary": { + "base_shipping_total": 10000, + "code_discount": 10000, + "code_discount_formatted": "10,000원", + "extra_shipping_total": 10000, + "final_amount": 10000, + "final_amount_formatted": "10,000원", + "mileage_formatted": "10,000원", + "multi_currency": "샘플", + "order_coupon_discount": 10000, + "order_coupon_discount_formatted": "10,000원", + "payment_amount_formatted": "10,000원", + "points_earning": 10000, + "points_used": 10000, + "points_used_formatted": "10,000원", + "product_coupon_discount": 10000, + "shipping_discount": 10000, + "shipping_discount_formatted": "10,000원", + "subtotal_formatted": "10,000원", + "tax_free_amount": 10000, + "taxable_amount": 10000, + "total_discount": 10000, + "total_discount_formatted": "10,000원", + "total_shipping": 10000, + "total_shipping_formatted": "10,000원" + } + }, + "items": [ + "샘플" + ], + "mileage": { + "balance": 10000, + "balance_formatted": "10,000원", + "max_usable": "샘플", + "max_usable_formatted": "10,000원" + }, + "temp_order_id": 1 + } + }, + "userAddresses": { + "data": { + "addresses": { + "data": "샘플" + } + } + }, + "paymentSettings": { + "data": { + "order_settings": { + "bank_accounts": [ + "샘플" + ], + "dbank_due_days": 3, + "payment_methods": "샘플", + "vbank_due_days": 3 + } + } + }, + "orderData": { + "data": { + "mc_subtotal_amount": 10000, + "mc_total_amount": 10000, + "mc_total_discount_amount": 10000, + "mc_total_shipping_amount": 10000, + "options": [ + "샘플" + ], + "order_number": 3, + "payment": { + "dbank_account": "샘플", + "dbank_holder": "샘플 dbank_holder", + "dbank_name": "샘플 dbank_name", + "deposit_due_at_formatted": "10,000원", + "depositor_name": "샘플 depositor_name", + "payment_method": "card", + "vbank_due_at": "2026-05-24 09:32:38", + "vbank_holder": "샘플 vbank_holder", + "vbank_name": "샘플 vbank_name", + "vbank_number": 3 + }, + "shipping_address": { + "address": "샘플 address", + "address_detail": "샘플 address_detail", + "city": "샘플", + "recipient_country_code": "KR", + "recipient_name": "샘플 recipient_name", + "recipient_phone": "010-1234-5678", + "state": "sample", + "zipcode": "sample" + }, + "subtotal_amount_formatted": "10,000원", + "total_amount_formatted": "10,000원", + "total_discount_amount": 10000, + "total_discount_amount_formatted": "10,000원", + "total_shipping_amount": 10000, + "total_shipping_amount_formatted": "10,000원" + } + }, + "wishlist": { + "data": { + "data": [ + "샘플" + ], + "pagination": { + "current_page": 3, + "last_page": 3, + "total": 10000 + } + } + }, + "addresses": { + "data": { + "addresses": { + "data": "샘플" + } + } + }, + "myInquiries": { + "data": { + "items": [ + "샘플" + ], + "meta": { + "board_settings": "샘플", + "current_page": 3, + "last_page": 3, + "total": 10000 + } + } + }, + "board_list_for_inquiry": { + "data": { + "data": [ + { + "id": 1, + "name": "공지사항", + "slug": "notice", + "is_active": true, + "type": "basic", + "description": "공지사항 게시판입니다.", + "per_page": 20, + "per_page_mobile": 15, + "order_by": "created_at", + "order_direction": "DESC", + "categories": [], + "show_view_count": true, + "secret_mode": "{MASKED}", + "use_comment": true, + "use_reply": true, + "max_reply_depth": 5, + "use_report": false, + "comment_order": "ASC", + "max_comment_depth": 10, + "min_title_length": 2, + "max_title_length": 200, + "min_content_length": 2, + "max_content_length": 10000, + "min_comment_length": 2, + "max_comment_length": 1000, + "blocked_keywords": [], + "use_file_upload": false, + "max_file_size": 10, + "max_file_count": 5, + "allowed_extensions": [ + "jpg", + "jpeg", + "png", + "gif", + "webp", + "pdf", + "doc", + "docx", + "xls", + "xlsx", + "ppt", + "pptx", + "hwp", + "txt", + "zip" + ], + "new_display_hours": 24, + "board_managers": [ + { + "uuid": "00000000-0000-4000-8000-000000000001", + "name": "관리자", + "email": "admin@example.com" + } + ], + "board_steps": [], + "board_manager_ids": [ + "00000000-0000-4000-8000-000000000001" + ], + "board_step_ids": [], + "notify_author": true, + "notify_admin_on_post": true, + "created_at": "2026-05-26 02:41:23", + "updated_at": "2026-05-26 02:41:23", + "permissions": null, + "category_post_counts": null, + "posts_count": 0, + "user_abilities": null, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } + ], + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 20, + "total": 1, + "from": 1, + "to": 1, + "has_more_pages": false + }, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } + }, + "notifications": { + "channels": [ + { + "key": "email", + "label": "이메일", + "enabled": true + }, + { + "key": "push", + "label": "푸시", + "enabled": true + } + ], + "data": { + "data": [ + { + "id": 1, + "type": "comment", + "title": "샘플 알림", + "message": "샘플 알림 내용입니다.", + "is_read": false, + "created_at": "2026-05-24 09:32:38", + "link": "/" + } + ], + "pagination": { + "current_page": 1, + "last_page": 1, + "total": 1, + "has_more_pages": false + }, + "unread_count": 2 + } + }, + "mileage_balance": { + "data": { + "mileage": { + "available": 50000, + "pending": 10000, + "total_earned": 150000, + "expiring_soon": 5000, + "enabled": true + } + } + }, + "mileage_history": { + "data": { + "data": [ + { + "id": 1001, + "user_id": 100, + "currency": "KRW", + "type": "earn", + "type_label": "적립", + "admin_badge_group": null, + "user_display_category": "주문 완료", + "amount": 5000, + "amount_formatted": "5,000", + "remaining_amount": 5000, + "remaining_amount_formatted": "5,000", + "balance_after": 55000, + "order_id": null, + "order_option_id": null, + "order_cancel_id": null, + "source_transaction_id": null, + "granted_by": null, + "granted_by_name": null, + "granted_by_uuid": null, + "user_name": "홍길동", + "user_uuid": "u-1001", + "order_number": "20260601-0042", + "description": "Order reward", + "memo": null, + "expires_at": "2027-06-01T00:00:00+09:00", + "expires_at_formatted": "Jun 1, 2027 12:00 AM", + "expires_at_date": "2027-06-01", + "expired_at": null, + "expired_at_formatted": null, + "created_at": "2026-06-01T14:30:00+09:00", + "created_at_formatted": "Jun 1, 2026 2:30 PM", + "created_at_date": "2026-06-01", + "is_earning": true, + "can_edit_expiry": true, + "expired_amount": 0, + "expired_amount_formatted": "0", + "expiry_state": "active", + "abilities": { + "can_manage": false + } + }, + { + "id": 1002, + "user_id": 100, + "currency": "KRW", + "type": "use", + "type_label": "사용", + "admin_badge_group": null, + "user_display_category": "주문", + "amount": -3000, + "amount_formatted": "-3,000", + "remaining_amount": 0, + "remaining_amount_formatted": "0", + "balance_after": 52000, + "order_id": null, + "order_option_id": null, + "order_cancel_id": null, + "source_transaction_id": null, + "granted_by": null, + "granted_by_name": null, + "granted_by_uuid": null, + "user_name": "홍길동", + "user_uuid": "u-1001", + "order_number": "20260530-0118", + "description": null, + "memo": null, + "expires_at": null, + "expires_at_formatted": null, + "expires_at_date": null, + "expired_at": null, + "expired_at_formatted": null, + "created_at": "2026-05-30T13:05:00+09:00", + "created_at_formatted": "May 30, 2026 1:05 PM", + "created_at_date": "2026-05-30", + "is_earning": false, + "can_edit_expiry": false, + "expired_amount": 0, + "expired_amount_formatted": "0", + "expiry_state": "active", + "abilities": { + "can_manage": false + } + } + ], + "abilities": { + "can_manage": false + }, + "currencies": [ + "KRW" + ], + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 20, + "total": 2, + "from": 1, + "to": 2, + "has_more_pages": false + } + } + }, + "reviewSettings": { + "data": { + "review_settings": { + "max_images": 5, + "max_image_size_mb": 10, + "write_deadline_days": 30 + } + } + } + }, + "byEndpointPattern": { + "/api/modules/sirsoft-ecommerce/products*": { + "data": { + "data": [ + { + "id": 1, + "name": "샘플 상품 1", + "price": 29000, + "sale_price": 24000, + "thumbnail": null, + "stock_status": "in_stock", + "rating": 4.5 + }, + { + "id": 2, + "name": "샘플 상품 2", + "price": 15000, + "sale_price": null, + "thumbnail": null, + "stock_status": "in_stock", + "rating": 4 + }, + { + "id": 3, + "name": "샘플 상품 3 (품절)", + "price": 42000, + "sale_price": null, + "thumbnail": null, + "stock_status": "out_of_stock", + "rating": 3.5 + } + ], + "meta": { + "total": 3, + "per_page": 20, + "current_page": 1 + } + } + }, + "/api/modules/sirsoft-ecommerce/cart*": { + "data": { + "items": [ + { + "product_id": 1, + "name": "샘플 상품 1", + "thumbnail": null, + "price": 24000, + "quantity": 2, + "options": "블랙" + }, + { + "product_id": 2, + "name": "샘플 상품 2", + "thumbnail": null, + "price": 15000, + "quantity": 1, + "options": null + } + ], + "total": 63000, + "discount": 0, + "shipping_fee": 3000 + } + }, + "/api/modules/sirsoft-ecommerce/checkout*": { + "data": { + "items": [ + { + "product_id": 1, + "name": "샘플 상품 1", + "price": 24000, + "quantity": 2 + } + ], + "addresses": [ + { + "id": 1, + "recipient": "샘플 수령인", + "address": "서울시 샘플구 샘플로 1", + "is_default": true + } + ], + "payment_methods": [ + { + "code": "card", + "name": "신용카드" + }, + { + "code": "vbank", + "name": "무통장입금" + } + ], + "coupons": [], + "points": 1250 + } + }, + "/api/modules/sirsoft-ecommerce/wishlist*": { + "data": { + "data": [ + { + "id": 1, + "name": "샘플 찜 상품 1", + "price": 29000, + "thumbnail": null, + "stock_status": "in_stock" + }, + { + "id": 2, + "name": "샘플 찜 상품 2", + "price": 15000, + "thumbnail": null, + "stock_status": "in_stock" + } + ], + "meta": { + "total": 2, + "per_page": 20, + "current_page": 1 + } + } + }, + "/api/modules/sirsoft-ecommerce/user/orders*": { + "data": { + "data": [ + { + "id": 1, + "order_no": "20260529-0001", + "created_at": "2026-05-29", + "status": "paid", + "total": 48000, + "first_item_name": "샘플 상품 1", + "item_count": 2 + } + ], + "meta": { + "total": 1, + "per_page": 20, + "current_page": 1 + } + } + }, + "/api/modules/sirsoft-ecommerce/user/addresses*": { + "data": [ + { + "id": 1, + "recipient": "샘플 수령인", + "phone": "010-0000-0000", + "zip": "00000", + "address": "서울시 샘플구 샘플로 1", + "detail": "101동 101호", + "is_default": true, + "label": "집" + } + ] + }, + "/api/modules/sirsoft-ecommerce/user/inquiries*": { + "data": { + "data": [ + { + "id": 1, + "product": { + "id": 1, + "name": "샘플 상품 1" + }, + "title": "배송 문의", + "body": "언제 배송되나요?", + "answer": "내일 발송 예정입니다.", + "created_at": "2026-05-25", + "status": "answered" + } + ], + "meta": { + "total": 1, + "per_page": 20, + "current_page": 1 + } + } + } + } + }, + "states": { + "comment": "이커머스 도메인 변종. 사용자 shop/* 라우트(템플릿 sirsoft-basic 소속이나 코어 concat 으로 일원화)와 관리자 라우트(*/admin/ecommerce/*) 변종을 모두 포함한다. shop/* scope.match 는 편집기가 동적 path 를 'shop' 으로 평가한 selectedRoute.path 와 일치한다(Chrome MCP 실측).", + "groups": [ + { + "comment": "shop/show — product.data.sales_status 값(on_sale/sold_out/suspended/coming_soon)으로 판매 상태 배지·버튼을 if 분기한다(본문 line 388/406). 재고는 stock 으로 분기.", + "scope": { + "kind": "route", + "match": "/shop/products/:id" + }, + "items": [ + { + "id": "in_stock", + "label": "$t:editor.state.in_stock", + "default": true + }, + { + "id": "out_of_stock", + "label": "$t:editor.state.out_of_stock", + "sampleDataOverrides": { + "byEndpointPattern": { + "/api/modules/sirsoft-ecommerce/products/*": { + "data": { + "id": 101, + "name": { + "ko": "베이직 오버핏 코튼 티셔츠", + "en": "Basic Overfit Cotton T-Shirt" + }, + "name_localized": "베이직 오버핏 코튼 티셔츠", + "product_code": "TS-1001", + "sku": "TS-1001-BLK", + "categories": [ + { + "id": 1, + "name": { + "ko": "패션의류", + "en": "Fashion" + }, + "name_localized": "패션의류", + "breadcrumb": "패션의류", + "path": "1", + "is_primary": false + }, + { + "id": 11, + "name": { + "ko": "상의", + "en": "Tops" + }, + "name_localized": "상의", + "breadcrumb": "패션의류 > 상의", + "path": "1/11", + "is_primary": true + } + ], + "category_name": "상의", + "list_price": 29000, + "list_price_formatted": "29,000원", + "selling_price": 23200, + "selling_price_formatted": "23,200원", + "discount_rate": 20, + "multi_currency_list_price": { + "KRW": { + "price": 29000, + "formatted": "29,000원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 22.31, + "formatted": "$22.31", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 23200, + "formatted": "23,200원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 17.85, + "formatted": "$17.85", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "stock_quantity": 0, + "min_purchase_qty": 1, + "max_purchase_qty": 10, + "sales_status": "on_sale", + "sales_status_label": "판매중", + "brand_name": "코지홈", + "labels": [ + { + "name": "BEST", + "color": "#ef4444" + }, + { + "name": "한정특가", + "color": "#f59e0b" + } + ], + "additional_options": [ + { + "id": 9001, + "name": "이니셜 자수 추가", + "is_required": false + }, + { + "id": 9002, + "name": "선물 포장", + "is_required": false + } + ], + "shipping_policy_id": 1, + "free_shipping": false, + "shipping_fee_formatted": "3,000원 (50,000원 이상 무료)", + "shipping_policy": { + "name": "기본 배송정책", + "charge_policy": "conditional_free", + "charge_policy_label": "조건부 무료", + "base_fee": 3000, + "base_fee_formatted": "3,000원", + "free_threshold": 50000, + "free_threshold_formatted": "50,000원", + "fee_summary": "3,000원 (50,000원 이상 무료배송)" + }, + "short_description": "사계절 입기 좋은 오버핏 기본 티셔츠", + "short_description_localized": "사계절 입기 좋은 오버핏 기본 티셔츠", + "description": { + "ko": "

부드러운 30수 코튼 소재로 제작된 데일리 오버핏 티셔츠입니다.

" + }, + "description_localized": "

제품 특징

부드러운 30수 코튼 100% 소재로 제작되어 사계절 내내 편안하게 착용할 수 있습니다. 오버핏 실루엣으로 체형 커버에 탁월하며, 어떤 하의와도 자연스럽게 매치됩니다.

  • 소재: 코튼 100%
  • 핏: 오버핏
  • 제조국: 대한민국

\"상세

", + "description_mode": "html", + "images": [ + { + "id": 5001, + "hash": "img5001", + "original_filename": "tshirt_main.jpg", + "download_url": "/images/placeholder.png", + "alt_text": "정면", + "alt_text_current": "베이직 오버핏 티셔츠 정면", + "is_thumbnail": true, + "sort_order": 0, + "order": 0, + "width": 1000, + "height": 1000, + "file_size": 184320, + "size": 184320, + "size_formatted": "180 KB", + "mime_type": "image/jpeg", + "is_image": true + }, + { + "id": 5002, + "hash": "img5002", + "original_filename": "tshirt_back.jpg", + "download_url": "/images/placeholder.png", + "alt_text": "후면", + "alt_text_current": "베이직 오버핏 티셔츠 후면", + "is_thumbnail": false, + "sort_order": 1, + "order": 1, + "width": 1000, + "height": 1000, + "file_size": 176128, + "size": 176128, + "size_formatted": "172 KB", + "mime_type": "image/jpeg", + "is_image": true + }, + { + "id": 5003, + "hash": "img5003", + "original_filename": "tshirt_detail.jpg", + "download_url": "/images/placeholder.png", + "alt_text": "디테일", + "alt_text_current": "소매 디테일 컷", + "is_thumbnail": false, + "sort_order": 2, + "order": 2, + "width": 1000, + "height": 1000, + "file_size": 158720, + "size": 158720, + "size_formatted": "155 KB", + "mime_type": "image/jpeg", + "is_image": true + } + ], + "thumbnail_url": "/images/placeholder.png", + "meta_title": "베이직 오버핏 코튼 티셔츠 | 코지홈", + "meta_description": "사계절 입기 좋은 오버핏 기본 티셔츠", + "meta_keywords": [ + "티셔츠", + "오버핏", + "코튼" + ], + "has_options": true, + "option_groups": [ + { + "name": { + "ko": "색상", + "en": "Color" + }, + "name_localized": "색상", + "values": [ + { + "ko": "블랙", + "en": "Black" + }, + { + "ko": "화이트", + "en": "White" + }, + { + "ko": "네이비", + "en": "Navy" + } + ], + "values_localized": [ + "블랙", + "화이트", + "네이비" + ] + }, + { + "name": { + "ko": "사이즈", + "en": "Size" + }, + "name_localized": "사이즈", + "values": [ + { + "ko": "S", + "en": "S" + }, + { + "ko": "M", + "en": "M" + }, + { + "ko": "L", + "en": "L" + } + ], + "values_localized": [ + "S", + "M", + "L" + ] + } + ], + "options": [ + { + "id": 7001, + "option_code": "OPT-BLK-S", + "option_values": [ + { + "key": { + "ko": "색상" + }, + "value": { + "ko": "블랙" + } + }, + { + "key": { + "ko": "사이즈" + }, + "value": { + "ko": "S" + } + } + ], + "option_values_localized": [ + "블랙", + "S" + ], + "option_name": { + "ko": "블랙 / S" + }, + "option_name_localized": "블랙 / S", + "price_adjustment": 0, + "price_adjustment_formatted": "+0원", + "price_adjustment_type": "increase", + "list_price": 29000, + "list_price_formatted": "29,000원", + "selling_price": 23200, + "selling_price_formatted": "23,200원", + "final_price": 23200, + "final_price_formatted": "23,200원", + "multi_currency_list_price": { + "KRW": { + "price": 29000, + "formatted": "29,000원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 22.31, + "formatted": "$22.31", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 23200, + "formatted": "23,200원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 17.85, + "formatted": "$17.85", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "stock_quantity": 0, + "safe_stock_quantity": 10, + "is_below_safe_stock": true, + "is_default": true, + "is_active": true, + "sku": "TS-1001-BLK-S", + "sort_order": 0 + }, + { + "id": 7002, + "option_code": "OPT-BLK-M", + "option_values": [ + { + "key": { + "ko": "색상" + }, + "value": { + "ko": "블랙" + } + }, + { + "key": { + "ko": "사이즈" + }, + "value": { + "ko": "M" + } + } + ], + "option_values_localized": [ + "블랙", + "M" + ], + "option_name": { + "ko": "블랙 / M" + }, + "option_name_localized": "블랙 / M", + "price_adjustment": 0, + "price_adjustment_formatted": "+0원", + "price_adjustment_type": "increase", + "list_price": 29000, + "list_price_formatted": "29,000원", + "selling_price": 23200, + "selling_price_formatted": "23,200원", + "final_price": 23200, + "final_price_formatted": "23,200원", + "multi_currency_list_price": { + "KRW": { + "price": 29000, + "formatted": "29,000원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 22.31, + "formatted": "$22.31", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 23200, + "formatted": "23,200원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 17.85, + "formatted": "$17.85", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "stock_quantity": 0, + "safe_stock_quantity": 10, + "is_below_safe_stock": true, + "is_default": false, + "is_active": true, + "sku": "TS-1001-BLK-M", + "sort_order": 1 + }, + { + "id": 7003, + "option_code": "OPT-WHT-L", + "option_values": [ + { + "key": { + "ko": "색상" + }, + "value": { + "ko": "화이트" + } + }, + { + "key": { + "ko": "사이즈" + }, + "value": { + "ko": "L" + } + } + ], + "option_values_localized": [ + "화이트", + "L" + ], + "option_name": { + "ko": "화이트 / L" + }, + "option_name_localized": "화이트 / L", + "price_adjustment": 2000, + "price_adjustment_formatted": "+2,000원", + "price_adjustment_type": "increase", + "list_price": 31000, + "list_price_formatted": "31,000원", + "selling_price": 25200, + "selling_price_formatted": "25,200원", + "final_price": 25200, + "final_price_formatted": "25,200원", + "multi_currency_list_price": { + "KRW": { + "price": 31000, + "formatted": "31,000원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 23.85, + "formatted": "$23.85", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 25200, + "formatted": "25,200원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 19.38, + "formatted": "$19.38", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "stock_quantity": 0, + "safe_stock_quantity": 10, + "is_below_safe_stock": true, + "is_default": false, + "is_active": true, + "sku": "TS-1001-WHT-L", + "sort_order": 2 + } + ], + "notice": { + "template_name": "의류 상품정보제공고시", + "values": [ + { + "label": "제품 소재", + "value": "면 100%" + }, + { + "label": "색상", + "value": "블랙, 화이트, 네이비" + }, + { + "label": "치수", + "value": "S, M, L (상세 사이즈표 참고)" + }, + { + "label": "제조자", + "value": "코지홈" + }, + { + "label": "제조국", + "value": "대한민국" + }, + { + "label": "취급 시 주의사항", + "value": "찬물 단독 손세탁 권장" + }, + { + "label": "품질보증기준", + "value": "관련 법 및 소비자분쟁해결기준에 따름" + }, + { + "label": "A/S 책임자와 전화번호", + "value": "코지홈 고객센터 1588-0000" + } + ] + }, + "common_info": { + "name": "배송/교환/환불 안내", + "content": "

주문 후 영업일 기준 1~2일 내 출고됩니다. 단순 변심 교환·반품은 수령 후 7일 이내 가능하며, 왕복 배송비가 부과됩니다.

", + "content_mode": "html" + }, + "is_wishlisted": false + } + } + } + } + }, + { + "id": "sold_out", + "label": "$t:editor.state.sold_out", + "sampleDataOverrides": { + "byEndpointPattern": { + "/api/modules/sirsoft-ecommerce/products/*": { + "data": { + "id": 101, + "name": { + "ko": "베이직 오버핏 코튼 티셔츠", + "en": "Basic Overfit Cotton T-Shirt" + }, + "name_localized": "베이직 오버핏 코튼 티셔츠", + "product_code": "TS-1001", + "sku": "TS-1001-BLK", + "categories": [ + { + "id": 1, + "name": { + "ko": "패션의류", + "en": "Fashion" + }, + "name_localized": "패션의류", + "breadcrumb": "패션의류", + "path": "1", + "is_primary": false + }, + { + "id": 11, + "name": { + "ko": "상의", + "en": "Tops" + }, + "name_localized": "상의", + "breadcrumb": "패션의류 > 상의", + "path": "1/11", + "is_primary": true + } + ], + "category_name": "상의", + "list_price": 29000, + "list_price_formatted": "29,000원", + "selling_price": 23200, + "selling_price_formatted": "23,200원", + "discount_rate": 20, + "multi_currency_list_price": { + "KRW": { + "price": 29000, + "formatted": "29,000원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 22.31, + "formatted": "$22.31", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 23200, + "formatted": "23,200원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 17.85, + "formatted": "$17.85", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "stock_quantity": 0, + "min_purchase_qty": 1, + "max_purchase_qty": 10, + "sales_status": "sold_out", + "sales_status_label": "품절", + "brand_name": "코지홈", + "labels": [ + { + "name": "BEST", + "color": "#ef4444" + }, + { + "name": "한정특가", + "color": "#f59e0b" + } + ], + "additional_options": [ + { + "id": 9001, + "name": "이니셜 자수 추가", + "is_required": false + }, + { + "id": 9002, + "name": "선물 포장", + "is_required": false + } + ], + "shipping_policy_id": 1, + "free_shipping": false, + "shipping_fee_formatted": "3,000원 (50,000원 이상 무료)", + "shipping_policy": { + "name": "기본 배송정책", + "charge_policy": "conditional_free", + "charge_policy_label": "조건부 무료", + "base_fee": 3000, + "base_fee_formatted": "3,000원", + "free_threshold": 50000, + "free_threshold_formatted": "50,000원", + "fee_summary": "3,000원 (50,000원 이상 무료배송)" + }, + "short_description": "사계절 입기 좋은 오버핏 기본 티셔츠", + "short_description_localized": "사계절 입기 좋은 오버핏 기본 티셔츠", + "description": { + "ko": "

부드러운 30수 코튼 소재로 제작된 데일리 오버핏 티셔츠입니다.

" + }, + "description_localized": "

제품 특징

부드러운 30수 코튼 100% 소재로 제작되어 사계절 내내 편안하게 착용할 수 있습니다. 오버핏 실루엣으로 체형 커버에 탁월하며, 어떤 하의와도 자연스럽게 매치됩니다.

  • 소재: 코튼 100%
  • 핏: 오버핏
  • 제조국: 대한민국

\"상세

", + "description_mode": "html", + "images": [ + { + "id": 5001, + "hash": "img5001", + "original_filename": "tshirt_main.jpg", + "download_url": "/images/placeholder.png", + "alt_text": "정면", + "alt_text_current": "베이직 오버핏 티셔츠 정면", + "is_thumbnail": true, + "sort_order": 0, + "order": 0, + "width": 1000, + "height": 1000, + "file_size": 184320, + "size": 184320, + "size_formatted": "180 KB", + "mime_type": "image/jpeg", + "is_image": true + }, + { + "id": 5002, + "hash": "img5002", + "original_filename": "tshirt_back.jpg", + "download_url": "/images/placeholder.png", + "alt_text": "후면", + "alt_text_current": "베이직 오버핏 티셔츠 후면", + "is_thumbnail": false, + "sort_order": 1, + "order": 1, + "width": 1000, + "height": 1000, + "file_size": 176128, + "size": 176128, + "size_formatted": "172 KB", + "mime_type": "image/jpeg", + "is_image": true + }, + { + "id": 5003, + "hash": "img5003", + "original_filename": "tshirt_detail.jpg", + "download_url": "/images/placeholder.png", + "alt_text": "디테일", + "alt_text_current": "소매 디테일 컷", + "is_thumbnail": false, + "sort_order": 2, + "order": 2, + "width": 1000, + "height": 1000, + "file_size": 158720, + "size": 158720, + "size_formatted": "155 KB", + "mime_type": "image/jpeg", + "is_image": true + } + ], + "thumbnail_url": "/images/placeholder.png", + "meta_title": "베이직 오버핏 코튼 티셔츠 | 코지홈", + "meta_description": "사계절 입기 좋은 오버핏 기본 티셔츠", + "meta_keywords": [ + "티셔츠", + "오버핏", + "코튼" + ], + "has_options": true, + "option_groups": [ + { + "name": { + "ko": "색상", + "en": "Color" + }, + "name_localized": "색상", + "values": [ + { + "ko": "블랙", + "en": "Black" + }, + { + "ko": "화이트", + "en": "White" + }, + { + "ko": "네이비", + "en": "Navy" + } + ], + "values_localized": [ + "블랙", + "화이트", + "네이비" + ] + }, + { + "name": { + "ko": "사이즈", + "en": "Size" + }, + "name_localized": "사이즈", + "values": [ + { + "ko": "S", + "en": "S" + }, + { + "ko": "M", + "en": "M" + }, + { + "ko": "L", + "en": "L" + } + ], + "values_localized": [ + "S", + "M", + "L" + ] + } + ], + "options": [ + { + "id": 7001, + "option_code": "OPT-BLK-S", + "option_values": [ + { + "key": { + "ko": "색상" + }, + "value": { + "ko": "블랙" + } + }, + { + "key": { + "ko": "사이즈" + }, + "value": { + "ko": "S" + } + } + ], + "option_values_localized": [ + "블랙", + "S" + ], + "option_name": { + "ko": "블랙 / S" + }, + "option_name_localized": "블랙 / S", + "price_adjustment": 0, + "price_adjustment_formatted": "+0원", + "price_adjustment_type": "increase", + "list_price": 29000, + "list_price_formatted": "29,000원", + "selling_price": 23200, + "selling_price_formatted": "23,200원", + "final_price": 23200, + "final_price_formatted": "23,200원", + "multi_currency_list_price": { + "KRW": { + "price": 29000, + "formatted": "29,000원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 22.31, + "formatted": "$22.31", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 23200, + "formatted": "23,200원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 17.85, + "formatted": "$17.85", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "stock_quantity": 0, + "safe_stock_quantity": 10, + "is_below_safe_stock": true, + "is_default": true, + "is_active": true, + "sku": "TS-1001-BLK-S", + "sort_order": 0 + }, + { + "id": 7002, + "option_code": "OPT-BLK-M", + "option_values": [ + { + "key": { + "ko": "색상" + }, + "value": { + "ko": "블랙" + } + }, + { + "key": { + "ko": "사이즈" + }, + "value": { + "ko": "M" + } + } + ], + "option_values_localized": [ + "블랙", + "M" + ], + "option_name": { + "ko": "블랙 / M" + }, + "option_name_localized": "블랙 / M", + "price_adjustment": 0, + "price_adjustment_formatted": "+0원", + "price_adjustment_type": "increase", + "list_price": 29000, + "list_price_formatted": "29,000원", + "selling_price": 23200, + "selling_price_formatted": "23,200원", + "final_price": 23200, + "final_price_formatted": "23,200원", + "multi_currency_list_price": { + "KRW": { + "price": 29000, + "formatted": "29,000원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 22.31, + "formatted": "$22.31", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 23200, + "formatted": "23,200원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 17.85, + "formatted": "$17.85", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "stock_quantity": 0, + "safe_stock_quantity": 10, + "is_below_safe_stock": true, + "is_default": false, + "is_active": true, + "sku": "TS-1001-BLK-M", + "sort_order": 1 + }, + { + "id": 7003, + "option_code": "OPT-WHT-L", + "option_values": [ + { + "key": { + "ko": "색상" + }, + "value": { + "ko": "화이트" + } + }, + { + "key": { + "ko": "사이즈" + }, + "value": { + "ko": "L" + } + } + ], + "option_values_localized": [ + "화이트", + "L" + ], + "option_name": { + "ko": "화이트 / L" + }, + "option_name_localized": "화이트 / L", + "price_adjustment": 2000, + "price_adjustment_formatted": "+2,000원", + "price_adjustment_type": "increase", + "list_price": 31000, + "list_price_formatted": "31,000원", + "selling_price": 25200, + "selling_price_formatted": "25,200원", + "final_price": 25200, + "final_price_formatted": "25,200원", + "multi_currency_list_price": { + "KRW": { + "price": 31000, + "formatted": "31,000원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 23.85, + "formatted": "$23.85", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 25200, + "formatted": "25,200원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 19.38, + "formatted": "$19.38", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "stock_quantity": 0, + "safe_stock_quantity": 10, + "is_below_safe_stock": true, + "is_default": false, + "is_active": true, + "sku": "TS-1001-WHT-L", + "sort_order": 2 + } + ], + "notice": { + "template_name": "의류 상품정보제공고시", + "values": [ + { + "label": "제품 소재", + "value": "면 100%" + }, + { + "label": "색상", + "value": "블랙, 화이트, 네이비" + }, + { + "label": "치수", + "value": "S, M, L (상세 사이즈표 참고)" + }, + { + "label": "제조자", + "value": "코지홈" + }, + { + "label": "제조국", + "value": "대한민국" + }, + { + "label": "취급 시 주의사항", + "value": "찬물 단독 손세탁 권장" + }, + { + "label": "품질보증기준", + "value": "관련 법 및 소비자분쟁해결기준에 따름" + }, + { + "label": "A/S 책임자와 전화번호", + "value": "코지홈 고객센터 1588-0000" + } + ] + }, + "common_info": { + "name": "배송/교환/환불 안내", + "content": "

주문 후 영업일 기준 1~2일 내 출고됩니다. 단순 변심 교환·반품은 수령 후 7일 이내 가능하며, 왕복 배송비가 부과됩니다.

", + "content_mode": "html" + }, + "is_wishlisted": false + } + } + } + } + }, + { + "id": "suspended", + "label": "$t:editor.state.suspended", + "sampleDataOverrides": { + "byEndpointPattern": { + "/api/modules/sirsoft-ecommerce/products/*": { + "data": { + "id": 101, + "name": { + "ko": "베이직 오버핏 코튼 티셔츠", + "en": "Basic Overfit Cotton T-Shirt" + }, + "name_localized": "베이직 오버핏 코튼 티셔츠", + "product_code": "TS-1001", + "sku": "TS-1001-BLK", + "categories": [ + { + "id": 1, + "name": { + "ko": "패션의류", + "en": "Fashion" + }, + "name_localized": "패션의류", + "breadcrumb": "패션의류", + "path": "1", + "is_primary": false + }, + { + "id": 11, + "name": { + "ko": "상의", + "en": "Tops" + }, + "name_localized": "상의", + "breadcrumb": "패션의류 > 상의", + "path": "1/11", + "is_primary": true + } + ], + "category_name": "상의", + "list_price": 29000, + "list_price_formatted": "29,000원", + "selling_price": 23200, + "selling_price_formatted": "23,200원", + "discount_rate": 20, + "multi_currency_list_price": { + "KRW": { + "price": 29000, + "formatted": "29,000원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 22.31, + "formatted": "$22.31", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 23200, + "formatted": "23,200원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 17.85, + "formatted": "$17.85", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "stock_quantity": 320, + "min_purchase_qty": 1, + "max_purchase_qty": 10, + "sales_status": "suspended", + "sales_status_label": "판매중지", + "brand_name": "코지홈", + "labels": [ + { + "name": "BEST", + "color": "#ef4444" + }, + { + "name": "한정특가", + "color": "#f59e0b" + } + ], + "additional_options": [ + { + "id": 9001, + "name": "이니셜 자수 추가", + "is_required": false + }, + { + "id": 9002, + "name": "선물 포장", + "is_required": false + } + ], + "shipping_policy_id": 1, + "free_shipping": false, + "shipping_fee_formatted": "3,000원 (50,000원 이상 무료)", + "shipping_policy": { + "name": "기본 배송정책", + "charge_policy": "conditional_free", + "charge_policy_label": "조건부 무료", + "base_fee": 3000, + "base_fee_formatted": "3,000원", + "free_threshold": 50000, + "free_threshold_formatted": "50,000원", + "fee_summary": "3,000원 (50,000원 이상 무료배송)" + }, + "short_description": "사계절 입기 좋은 오버핏 기본 티셔츠", + "short_description_localized": "사계절 입기 좋은 오버핏 기본 티셔츠", + "description": { + "ko": "

부드러운 30수 코튼 소재로 제작된 데일리 오버핏 티셔츠입니다.

" + }, + "description_localized": "

제품 특징

부드러운 30수 코튼 100% 소재로 제작되어 사계절 내내 편안하게 착용할 수 있습니다. 오버핏 실루엣으로 체형 커버에 탁월하며, 어떤 하의와도 자연스럽게 매치됩니다.

  • 소재: 코튼 100%
  • 핏: 오버핏
  • 제조국: 대한민국

\"상세

", + "description_mode": "html", + "images": [ + { + "id": 5001, + "hash": "img5001", + "original_filename": "tshirt_main.jpg", + "download_url": "/images/placeholder.png", + "alt_text": "정면", + "alt_text_current": "베이직 오버핏 티셔츠 정면", + "is_thumbnail": true, + "sort_order": 0, + "order": 0, + "width": 1000, + "height": 1000, + "file_size": 184320, + "size": 184320, + "size_formatted": "180 KB", + "mime_type": "image/jpeg", + "is_image": true + }, + { + "id": 5002, + "hash": "img5002", + "original_filename": "tshirt_back.jpg", + "download_url": "/images/placeholder.png", + "alt_text": "후면", + "alt_text_current": "베이직 오버핏 티셔츠 후면", + "is_thumbnail": false, + "sort_order": 1, + "order": 1, + "width": 1000, + "height": 1000, + "file_size": 176128, + "size": 176128, + "size_formatted": "172 KB", + "mime_type": "image/jpeg", + "is_image": true + }, + { + "id": 5003, + "hash": "img5003", + "original_filename": "tshirt_detail.jpg", + "download_url": "/images/placeholder.png", + "alt_text": "디테일", + "alt_text_current": "소매 디테일 컷", + "is_thumbnail": false, + "sort_order": 2, + "order": 2, + "width": 1000, + "height": 1000, + "file_size": 158720, + "size": 158720, + "size_formatted": "155 KB", + "mime_type": "image/jpeg", + "is_image": true + } + ], + "thumbnail_url": "/images/placeholder.png", + "meta_title": "베이직 오버핏 코튼 티셔츠 | 코지홈", + "meta_description": "사계절 입기 좋은 오버핏 기본 티셔츠", + "meta_keywords": [ + "티셔츠", + "오버핏", + "코튼" + ], + "has_options": true, + "option_groups": [ + { + "name": { + "ko": "색상", + "en": "Color" + }, + "name_localized": "색상", + "values": [ + { + "ko": "블랙", + "en": "Black" + }, + { + "ko": "화이트", + "en": "White" + }, + { + "ko": "네이비", + "en": "Navy" + } + ], + "values_localized": [ + "블랙", + "화이트", + "네이비" + ] + }, + { + "name": { + "ko": "사이즈", + "en": "Size" + }, + "name_localized": "사이즈", + "values": [ + { + "ko": "S", + "en": "S" + }, + { + "ko": "M", + "en": "M" + }, + { + "ko": "L", + "en": "L" + } + ], + "values_localized": [ + "S", + "M", + "L" + ] + } + ], + "options": [ + { + "id": 7001, + "option_code": "OPT-BLK-S", + "option_values": [ + { + "key": { + "ko": "색상" + }, + "value": { + "ko": "블랙" + } + }, + { + "key": { + "ko": "사이즈" + }, + "value": { + "ko": "S" + } + } + ], + "option_values_localized": [ + "블랙", + "S" + ], + "option_name": { + "ko": "블랙 / S" + }, + "option_name_localized": "블랙 / S", + "price_adjustment": 0, + "price_adjustment_formatted": "+0원", + "price_adjustment_type": "increase", + "list_price": 29000, + "list_price_formatted": "29,000원", + "selling_price": 23200, + "selling_price_formatted": "23,200원", + "final_price": 23200, + "final_price_formatted": "23,200원", + "multi_currency_list_price": { + "KRW": { + "price": 29000, + "formatted": "29,000원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 22.31, + "formatted": "$22.31", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 23200, + "formatted": "23,200원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 17.85, + "formatted": "$17.85", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "stock_quantity": 80, + "safe_stock_quantity": 10, + "is_below_safe_stock": false, + "is_default": true, + "is_active": true, + "sku": "TS-1001-BLK-S", + "sort_order": 0 + }, + { + "id": 7002, + "option_code": "OPT-BLK-M", + "option_values": [ + { + "key": { + "ko": "색상" + }, + "value": { + "ko": "블랙" + } + }, + { + "key": { + "ko": "사이즈" + }, + "value": { + "ko": "M" + } + } + ], + "option_values_localized": [ + "블랙", + "M" + ], + "option_name": { + "ko": "블랙 / M" + }, + "option_name_localized": "블랙 / M", + "price_adjustment": 0, + "price_adjustment_formatted": "+0원", + "price_adjustment_type": "increase", + "list_price": 29000, + "list_price_formatted": "29,000원", + "selling_price": 23200, + "selling_price_formatted": "23,200원", + "final_price": 23200, + "final_price_formatted": "23,200원", + "multi_currency_list_price": { + "KRW": { + "price": 29000, + "formatted": "29,000원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 22.31, + "formatted": "$22.31", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 23200, + "formatted": "23,200원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 17.85, + "formatted": "$17.85", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "stock_quantity": 120, + "safe_stock_quantity": 10, + "is_below_safe_stock": false, + "is_default": false, + "is_active": true, + "sku": "TS-1001-BLK-M", + "sort_order": 1 + }, + { + "id": 7003, + "option_code": "OPT-WHT-L", + "option_values": [ + { + "key": { + "ko": "색상" + }, + "value": { + "ko": "화이트" + } + }, + { + "key": { + "ko": "사이즈" + }, + "value": { + "ko": "L" + } + } + ], + "option_values_localized": [ + "화이트", + "L" + ], + "option_name": { + "ko": "화이트 / L" + }, + "option_name_localized": "화이트 / L", + "price_adjustment": 2000, + "price_adjustment_formatted": "+2,000원", + "price_adjustment_type": "increase", + "list_price": 31000, + "list_price_formatted": "31,000원", + "selling_price": 25200, + "selling_price_formatted": "25,200원", + "final_price": 25200, + "final_price_formatted": "25,200원", + "multi_currency_list_price": { + "KRW": { + "price": 31000, + "formatted": "31,000원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 23.85, + "formatted": "$23.85", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 25200, + "formatted": "25,200원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 19.38, + "formatted": "$19.38", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "stock_quantity": 5, + "safe_stock_quantity": 10, + "is_below_safe_stock": true, + "is_default": false, + "is_active": true, + "sku": "TS-1001-WHT-L", + "sort_order": 2 + } + ], + "notice": { + "template_name": "의류 상품정보제공고시", + "values": [ + { + "label": "제품 소재", + "value": "면 100%" + }, + { + "label": "색상", + "value": "블랙, 화이트, 네이비" + }, + { + "label": "치수", + "value": "S, M, L (상세 사이즈표 참고)" + }, + { + "label": "제조자", + "value": "코지홈" + }, + { + "label": "제조국", + "value": "대한민국" + }, + { + "label": "취급 시 주의사항", + "value": "찬물 단독 손세탁 권장" + }, + { + "label": "품질보증기준", + "value": "관련 법 및 소비자분쟁해결기준에 따름" + }, + { + "label": "A/S 책임자와 전화번호", + "value": "코지홈 고객센터 1588-0000" + } + ] + }, + "common_info": { + "name": "배송/교환/환불 안내", + "content": "

주문 후 영업일 기준 1~2일 내 출고됩니다. 단순 변심 교환·반품은 수령 후 7일 이내 가능하며, 왕복 배송비가 부과됩니다.

", + "content_mode": "html" + }, + "is_wishlisted": false + } + } + } + } + }, + { + "id": "coming_soon", + "label": "$t:editor.state.coming_soon", + "sampleDataOverrides": { + "byEndpointPattern": { + "/api/modules/sirsoft-ecommerce/products/*": { + "data": { + "id": 101, + "name": { + "ko": "베이직 오버핏 코튼 티셔츠", + "en": "Basic Overfit Cotton T-Shirt" + }, + "name_localized": "베이직 오버핏 코튼 티셔츠", + "product_code": "TS-1001", + "sku": "TS-1001-BLK", + "categories": [ + { + "id": 1, + "name": { + "ko": "패션의류", + "en": "Fashion" + }, + "name_localized": "패션의류", + "breadcrumb": "패션의류", + "path": "1", + "is_primary": false + }, + { + "id": 11, + "name": { + "ko": "상의", + "en": "Tops" + }, + "name_localized": "상의", + "breadcrumb": "패션의류 > 상의", + "path": "1/11", + "is_primary": true + } + ], + "category_name": "상의", + "list_price": 29000, + "list_price_formatted": "29,000원", + "selling_price": 23200, + "selling_price_formatted": "23,200원", + "discount_rate": 20, + "multi_currency_list_price": { + "KRW": { + "price": 29000, + "formatted": "29,000원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 22.31, + "formatted": "$22.31", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 23200, + "formatted": "23,200원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 17.85, + "formatted": "$17.85", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "stock_quantity": 320, + "min_purchase_qty": 1, + "max_purchase_qty": 10, + "sales_status": "coming_soon", + "sales_status_label": "판매예정", + "brand_name": "코지홈", + "labels": [ + { + "name": "BEST", + "color": "#ef4444" + }, + { + "name": "한정특가", + "color": "#f59e0b" + } + ], + "additional_options": [ + { + "id": 9001, + "name": "이니셜 자수 추가", + "is_required": false + }, + { + "id": 9002, + "name": "선물 포장", + "is_required": false + } + ], + "shipping_policy_id": 1, + "free_shipping": false, + "shipping_fee_formatted": "3,000원 (50,000원 이상 무료)", + "shipping_policy": { + "name": "기본 배송정책", + "charge_policy": "conditional_free", + "charge_policy_label": "조건부 무료", + "base_fee": 3000, + "base_fee_formatted": "3,000원", + "free_threshold": 50000, + "free_threshold_formatted": "50,000원", + "fee_summary": "3,000원 (50,000원 이상 무료배송)" + }, + "short_description": "사계절 입기 좋은 오버핏 기본 티셔츠", + "short_description_localized": "사계절 입기 좋은 오버핏 기본 티셔츠", + "description": { + "ko": "

부드러운 30수 코튼 소재로 제작된 데일리 오버핏 티셔츠입니다.

" + }, + "description_localized": "

제품 특징

부드러운 30수 코튼 100% 소재로 제작되어 사계절 내내 편안하게 착용할 수 있습니다. 오버핏 실루엣으로 체형 커버에 탁월하며, 어떤 하의와도 자연스럽게 매치됩니다.

  • 소재: 코튼 100%
  • 핏: 오버핏
  • 제조국: 대한민국

\"상세

", + "description_mode": "html", + "images": [ + { + "id": 5001, + "hash": "img5001", + "original_filename": "tshirt_main.jpg", + "download_url": "/images/placeholder.png", + "alt_text": "정면", + "alt_text_current": "베이직 오버핏 티셔츠 정면", + "is_thumbnail": true, + "sort_order": 0, + "order": 0, + "width": 1000, + "height": 1000, + "file_size": 184320, + "size": 184320, + "size_formatted": "180 KB", + "mime_type": "image/jpeg", + "is_image": true + }, + { + "id": 5002, + "hash": "img5002", + "original_filename": "tshirt_back.jpg", + "download_url": "/images/placeholder.png", + "alt_text": "후면", + "alt_text_current": "베이직 오버핏 티셔츠 후면", + "is_thumbnail": false, + "sort_order": 1, + "order": 1, + "width": 1000, + "height": 1000, + "file_size": 176128, + "size": 176128, + "size_formatted": "172 KB", + "mime_type": "image/jpeg", + "is_image": true + }, + { + "id": 5003, + "hash": "img5003", + "original_filename": "tshirt_detail.jpg", + "download_url": "/images/placeholder.png", + "alt_text": "디테일", + "alt_text_current": "소매 디테일 컷", + "is_thumbnail": false, + "sort_order": 2, + "order": 2, + "width": 1000, + "height": 1000, + "file_size": 158720, + "size": 158720, + "size_formatted": "155 KB", + "mime_type": "image/jpeg", + "is_image": true + } + ], + "thumbnail_url": "/images/placeholder.png", + "meta_title": "베이직 오버핏 코튼 티셔츠 | 코지홈", + "meta_description": "사계절 입기 좋은 오버핏 기본 티셔츠", + "meta_keywords": [ + "티셔츠", + "오버핏", + "코튼" + ], + "has_options": true, + "option_groups": [ + { + "name": { + "ko": "색상", + "en": "Color" + }, + "name_localized": "색상", + "values": [ + { + "ko": "블랙", + "en": "Black" + }, + { + "ko": "화이트", + "en": "White" + }, + { + "ko": "네이비", + "en": "Navy" + } + ], + "values_localized": [ + "블랙", + "화이트", + "네이비" + ] + }, + { + "name": { + "ko": "사이즈", + "en": "Size" + }, + "name_localized": "사이즈", + "values": [ + { + "ko": "S", + "en": "S" + }, + { + "ko": "M", + "en": "M" + }, + { + "ko": "L", + "en": "L" + } + ], + "values_localized": [ + "S", + "M", + "L" + ] + } + ], + "options": [ + { + "id": 7001, + "option_code": "OPT-BLK-S", + "option_values": [ + { + "key": { + "ko": "색상" + }, + "value": { + "ko": "블랙" + } + }, + { + "key": { + "ko": "사이즈" + }, + "value": { + "ko": "S" + } + } + ], + "option_values_localized": [ + "블랙", + "S" + ], + "option_name": { + "ko": "블랙 / S" + }, + "option_name_localized": "블랙 / S", + "price_adjustment": 0, + "price_adjustment_formatted": "+0원", + "price_adjustment_type": "increase", + "list_price": 29000, + "list_price_formatted": "29,000원", + "selling_price": 23200, + "selling_price_formatted": "23,200원", + "final_price": 23200, + "final_price_formatted": "23,200원", + "multi_currency_list_price": { + "KRW": { + "price": 29000, + "formatted": "29,000원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 22.31, + "formatted": "$22.31", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 23200, + "formatted": "23,200원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 17.85, + "formatted": "$17.85", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "stock_quantity": 80, + "safe_stock_quantity": 10, + "is_below_safe_stock": false, + "is_default": true, + "is_active": true, + "sku": "TS-1001-BLK-S", + "sort_order": 0 + }, + { + "id": 7002, + "option_code": "OPT-BLK-M", + "option_values": [ + { + "key": { + "ko": "색상" + }, + "value": { + "ko": "블랙" + } + }, + { + "key": { + "ko": "사이즈" + }, + "value": { + "ko": "M" + } + } + ], + "option_values_localized": [ + "블랙", + "M" + ], + "option_name": { + "ko": "블랙 / M" + }, + "option_name_localized": "블랙 / M", + "price_adjustment": 0, + "price_adjustment_formatted": "+0원", + "price_adjustment_type": "increase", + "list_price": 29000, + "list_price_formatted": "29,000원", + "selling_price": 23200, + "selling_price_formatted": "23,200원", + "final_price": 23200, + "final_price_formatted": "23,200원", + "multi_currency_list_price": { + "KRW": { + "price": 29000, + "formatted": "29,000원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 22.31, + "formatted": "$22.31", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 23200, + "formatted": "23,200원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 17.85, + "formatted": "$17.85", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "stock_quantity": 120, + "safe_stock_quantity": 10, + "is_below_safe_stock": false, + "is_default": false, + "is_active": true, + "sku": "TS-1001-BLK-M", + "sort_order": 1 + }, + { + "id": 7003, + "option_code": "OPT-WHT-L", + "option_values": [ + { + "key": { + "ko": "색상" + }, + "value": { + "ko": "화이트" + } + }, + { + "key": { + "ko": "사이즈" + }, + "value": { + "ko": "L" + } + } + ], + "option_values_localized": [ + "화이트", + "L" + ], + "option_name": { + "ko": "화이트 / L" + }, + "option_name_localized": "화이트 / L", + "price_adjustment": 2000, + "price_adjustment_formatted": "+2,000원", + "price_adjustment_type": "increase", + "list_price": 31000, + "list_price_formatted": "31,000원", + "selling_price": 25200, + "selling_price_formatted": "25,200원", + "final_price": 25200, + "final_price_formatted": "25,200원", + "multi_currency_list_price": { + "KRW": { + "price": 31000, + "formatted": "31,000원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 23.85, + "formatted": "$23.85", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 25200, + "formatted": "25,200원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 19.38, + "formatted": "$19.38", + "is_default": false, + "editable": false, + "exchange_rate": 1300 + } + }, + "stock_quantity": 5, + "safe_stock_quantity": 10, + "is_below_safe_stock": true, + "is_default": false, + "is_active": true, + "sku": "TS-1001-WHT-L", + "sort_order": 2 + } + ], + "notice": { + "template_name": "의류 상품정보제공고시", + "values": [ + { + "label": "제품 소재", + "value": "면 100%" + }, + { + "label": "색상", + "value": "블랙, 화이트, 네이비" + }, + { + "label": "치수", + "value": "S, M, L (상세 사이즈표 참고)" + }, + { + "label": "제조자", + "value": "코지홈" + }, + { + "label": "제조국", + "value": "대한민국" + }, + { + "label": "취급 시 주의사항", + "value": "찬물 단독 손세탁 권장" + }, + { + "label": "품질보증기준", + "value": "관련 법 및 소비자분쟁해결기준에 따름" + }, + { + "label": "A/S 책임자와 전화번호", + "value": "코지홈 고객센터 1588-0000" + } + ] + }, + "common_info": { + "name": "배송/교환/환불 안내", + "content": "

주문 후 영업일 기준 1~2일 내 출고됩니다. 단순 변심 교환·반품은 수령 후 7일 이내 가능하며, 왕복 배송비가 부과됩니다.

", + "content_mode": "html" + }, + "is_wishlisted": false + } + } + } + } + }, + { + "id": "reviews_tab", + "label": "$t:editor.state.product_reviews_tab", + "comment": "상품상세 리뷰 탭은 _local.activeTab === 'reviews' 게이트 뒤라 기본('info') 상태 캔버스에 미렌더. activeTab 시드 + reviews 를 유저 탭 shape(reviews.data.reviews.data + rating_stats + option_filters + total_count)로 override. base reviews 샘플은 admin 리뷰관리 shape(reviews.data.data[])라 SSoT 분리(copy_source 패턴).", + "initialState": { + "local": { + "activeTab": "reviews" + } + }, + "sampleDataOverrides": { + "byDataSourceId": { + "reviews": { + "data": { + "total_count": 37, + "rating_stats": { + "1": { + "count": 1, + "percent": 3 + }, + "2": { + "count": 2, + "percent": 5 + }, + "3": { + "count": 4, + "percent": 11 + }, + "4": { + "count": 8, + "percent": 22 + }, + "5": { + "count": 22, + "percent": 59 + }, + "avg": 4.3 + }, + "option_filters": [ + { + "key": "색상", + "values": [ + { + "value": "블랙", + "count": 18 + }, + { + "value": "화이트", + "count": 12 + }, + { + "value": "네이비", + "count": 7 + } + ] + }, + { + "key": "사이즈", + "values": [ + { + "value": "S", + "count": 6 + }, + { + "value": "M", + "count": 19 + }, + { + "value": "L", + "count": 12 + } + ] + } + ], + "reviews": { + "data": [ + { + "id": 9101, + "user": { + "name": "김민지", + "avatar": null + }, + "rating": 5, + "content": "핏이 정말 예쁘고 원단도 도톰해서 가을에 입기 딱 좋아요. 세탁 후에도 변형이 없어서 만족합니다.", + "option_snapshot": { + "option_values": [ + { + "key": { + "ko": "색상" + }, + "value": { + "ko": "블랙" + } + }, + { + "key": { + "ko": "사이즈" + }, + "value": { + "ko": "M" + } + } + ] + }, + "image_count": 2, + "images": [ + { + "download_url": "/images/placeholder.png" + }, + { + "download_url": "/images/placeholder.png" + } + ], + "has_reply": true, + "reply_content": "소중한 후기 감사합니다! 앞으로도 좋은 상품으로 보답하겠습니다.", + "replied_at": "2026-05-27 09:10", + "created_at": "2026-05-26 11:20" + }, + { + "id": 9102, + "user": { + "name": "이서준", + "avatar": null + }, + "rating": 4, + "content": "색감 예쁘고 무난하게 입기 좋습니다. 다만 생각보다 살짝 얇은 느낌이에요.", + "option_snapshot": { + "option_values": [ + { + "key": { + "ko": "색상" + }, + "value": { + "ko": "화이트" + } + }, + { + "key": { + "ko": "사이즈" + }, + "value": { + "ko": "L" + } + } + ] + }, + "image_count": 0, + "images": [], + "has_reply": false, + "reply_content": null, + "replied_at": null, + "created_at": "2026-05-24 16:42" + }, + { + "id": 9103, + "user": { + "name": "박도윤", + "avatar": null + }, + "rating": 5, + "content": "재구매했어요. 데일리로 입기 좋고 색상별로 사고 싶네요. 배송도 빨랐습니다.", + "option_snapshot": { + "option_values": [ + { + "key": { + "ko": "색상" + }, + "value": { + "ko": "네이비" + } + }, + { + "key": { + "ko": "사이즈" + }, + "value": { + "ko": "S" + } + } + ] + }, + "image_count": 5, + "images": [ + { + "download_url": "/images/placeholder.png" + }, + { + "download_url": "/images/placeholder.png" + }, + { + "download_url": "/images/placeholder.png" + }, + { + "download_url": "/images/placeholder.png" + }, + { + "download_url": "/images/placeholder.png" + } + ], + "has_reply": true, + "reply_content": "재구매 감사합니다! 네이비 색상도 곧 재입고 예정입니다.", + "replied_at": "2026-05-21 10:00", + "created_at": "2026-05-20 19:05" + }, + { + "id": 9104, + "user": { + "name": "최예린", + "avatar": null + }, + "rating": 3, + "content": "무난해요. 기본템으로는 괜찮은데 특별하진 않네요.", + "option_snapshot": { + "option_values": [ + { + "key": { + "ko": "색상" + }, + "value": { + "ko": "블랙" + } + }, + { + "key": { + "ko": "사이즈" + }, + "value": { + "ko": "M" + } + } + ] + }, + "image_count": 0, + "images": [], + "has_reply": false, + "reply_content": null, + "replied_at": null, + "created_at": "2026-05-18 08:33" + } + ], + "meta": { + "current_page": 1, + "last_page": 4, + "per_page": 10, + "total": 37 + } + } + } + } + } + } + }, + { + "id": "qna_tab", + "label": "$t:editor.state.product_qna_tab", + "comment": "상품상세 문의 탭은 _local.activeTab === 'qna' && _global.modules['sirsoft-ecommerce'].inquiry.board_slug 게이트 + 탭 hiddenTabIds 가 board_slug 없으면 qna 탭 숨김. activeTab + global modules.inquiry.board_slug 시드 + qna 를 유저 shape(items[] + meta)로 override(기존 stub items:['샘플'] 해소).", + "initialState": { + "local": { + "activeTab": "qna" + }, + "global": { + "modules": { + "sirsoft-ecommerce": { + "inquiry": { + "board_slug": "product-qna" + } + } + } + } + } + } + ] + }, + { + "comment": "shop/index — 목록 유무 + 접근 제한. _local.productAccessDenied=true 시 접근 제한 화면을 if 분기한다(본문 line 192).", + "scope": { + "kind": "route", + "match": "/shop/products" + }, + "items": [ + { + "id": "with_data", + "label": "$t:editor.state.with_data", + "default": true + }, + { + "id": "empty_data", + "label": "$t:editor.state.empty_data", + "sampleDataOverrides": { + "byEndpointPattern": { + "/api/modules/sirsoft-ecommerce/products*": { + "data": { + "data": [], + "meta": { + "total": 0, + "per_page": 20, + "current_page": 1 + } + } + } + } + } + }, + { + "id": "access_denied", + "label": "$t:editor.state.shop_access_denied", + "initialState": { + "local": { + "productAccessDenied": true + } + } + } + ] + }, + { + "comment": "shop/cart — 담긴 상품 유무 + 주문 처리 중(_local.isOrdering 시 오버레이 잠금, 본문 line 115).", + "scope": { + "kind": "route", + "match": "/shop/cart" + }, + "items": [ + { + "id": "with_items", + "label": "$t:editor.state.cart_with_items", + "default": true + }, + { + "id": "empty_cart", + "label": "$t:editor.state.cart_empty", + "sampleDataOverrides": { + "byDataSourceId": { + "cartItems": { + "data": { + "items": [], + "item_ids": [], + "item_count": 0, + "calculation": { + "items": [], + "promotions": { + "product_promotions": { + "coupons": [], + "discount_codes": [], + "events": [] + }, + "order_promotions": { + "coupons": [], + "discount_codes": [], + "events": [] + } + }, + "validation_errors": [], + "summary": { + "subtotal": 0, + "subtotal_formatted": "0원", + "total_discount": 0, + "discount_formatted": "0원", + "total_shipping": 0, + "shipping_fee_formatted": "0원", + "final_amount": 0, + "final_amount_formatted": "0원", + "payment_amount_formatted": "0원", + "points_earning": 0, + "multi_currency": [] + } + } + } + } + } + } + }, + { + "id": "ordering", + "label": "$t:editor.state.cart_ordering", + "initialState": { + "local": { + "isOrdering": true + } + } + }, + { + "id": "summary_tax_expanded", + "label": "$t:editor.state.cart_summary_details", + "comment": "장바구니 요약의 세금 상세는 (1) 금액 영역이 _local.selectedItems.length>0 게이트(_cart_summary.json line 75), (2) 세금 상세가 _local.showTaxDetails 토글 게이트(line 128)로 이중 중첩. 둘 다 시드(selectedItems=[1,2,3] + showTaxDetails:true)해 과세/면세 금액(multi_currency) breakdown 을 노출. 편집기는 정적 시뮬레이션이라 체크박스 클릭·토글 클릭 불가.", + "initialState": { + "local": { + "showTaxDetails": true, + "selectedItems": [ + 1, + 2, + 3 + ] + } + } + } + ] + }, + { + "comment": "shop/checkout — 정상/결제오류(_local.orderError·query.error, 본문 line 45/194)/구매불가(_local.unavailableModal, line 102)/입력검증실패 분기. 검증 실패는 주문자/배송지 입력칸별 인라인 에러를 _local.errors 의 점 포함 flat 키(예 'orderer.name')로 읽는다(_checkout_orderer.json line 99/101, _checkout_shipping.json) — formErrors 키를 대괄호 표기로 지정한다.", + "scope": { + "kind": "route", + "match": "/shop/checkout" + }, + "items": [ + { + "id": "normal", + "label": "$t:editor.state.checkout_normal", + "default": true, + "comment": "빈 local 패치 — sampleGlobal._local baseline(받는분/주소/배송메모 시드)을 캔버스에 force-inject 하는 트리거. 패치가 없으면 편집기가 _localInit 을 주입하지 않아 레이아웃 자체 initLocal 이 baseline 을 덮어쓴다. 시드 데이터는 sampleGlobal._local 1곳에만 존재(상태별 중복 없음).", + "initialState": { + "local": [] + } + }, + { + "id": "payment_error", + "label": "$t:editor.state.checkout_payment_error", + "initialState": { + "local": { + "orderError": true, + "orderErrorMessage": "결제 처리 중 오류가 발생했습니다" + }, + "query": { + "error": "confirm_failed" + } + } + }, + { + "id": "validation_failed", + "label": "$t:editor.state.checkout_validation_failed", + "formErrors": { + "_local.errors['orderer.name']": [ + "주문자 이름을 입력해 주세요" + ], + "_local.errors['orderer.phone']": [ + "연락처를 입력해 주세요" + ], + "_local.errors['orderer.email']": [ + "올바른 이메일 형식이 아닙니다" + ], + "_local.errors['shipping.recipient_name']": [ + "받는 분 이름을 입력해 주세요" + ], + "_local.errors['shipping.recipient_phone']": [ + "받는 분 연락처를 입력해 주세요" + ], + "_local.errors['shipping.zipcode']": [ + "우편번호를 입력해 주세요" + ], + "_local.errors['shipping.address']": [ + "주소를 입력해 주세요" + ] + } + }, + { + "id": "summary_details_expanded", + "label": "$t:editor.state.checkout_summary_details", + "comment": "결제금액 요약의 세금/할인 상세는 _local.showTaxDetails / showDiscountDetails 토글 게이트 뒤(_checkout_summary.json line 93/161)라 기본 상태 미렌더. 두 토글 ON 으로 과세/면세 금액·상품/주문/배송 쿠폰 할인 breakdown(multi_currency 전 경로) 노출. base checkoutData 가 풍부하므로 override 불요.", + "initialState": { + "local": { + "showTaxDetails": true, + "showDiscountDetails": true + } + } + } + ] + }, + { + "comment": "shop/order_complete — orderData.data.payment.payment_method 값(card/vbank/dbank)으로 결제수단별 안내를 if 분기한다(본문 line 52/95/138/229). card 는 base sampleData(=card)가 흐르므로 오버라이드 없음, vbank/dbank 는 byDataSourceId.orderData 통째 교체.", + "scope": { + "kind": "route", + "match": "/shop/orders/:id/complete" + }, + "items": [ + { + "id": "card", + "label": "$t:editor.state.payment_card", + "default": true + }, + { + "id": "vbank", + "label": "$t:editor.state.payment_vbank", + "sampleDataOverrides": { + "byDataSourceId": { + "orderData": { + "data": { + "id": 5001, + "order_number": "20260603-0001", + "order_status": "payment_pending", + "order_status_label": "입금대기", + "order_status_variant": "warning", + "options": [ + { + "id": 9001, + "option_status": "preparing", + "option_status_label": "배송준비중", + "option_status_variant": "info", + "product_id": 1, + "product_option_id": 101, + "sku": "EARBUDS-PRO-BLK", + "product_name": "프리미엄 무선 이어버드 프로", + "product_option_name": "미드나잇 블랙 / 기본", + "option_name": "미드나잇 블랙 / 기본", + "quantity": 2, + "shipped_quantity": 0, + "unit_price": 29000, + "unit_price_formatted": "29,000원", + "subtotal_price": 58000, + "subtotal_price_formatted": "58,000원", + "subtotal_discount_amount": 5000, + "subtotal_discount_amount_formatted": "5,000원", + "list_price": 32000, + "list_price_formatted": "32,000원", + "final_amount": 53000, + "final_amount_formatted": "53,000원", + "thumbnail_url": null, + "mc_unit_price": { + "KRW": { + "price": 29000, + "formatted": "29,000원", + "is_default": true + }, + "USD": { + "price": 21.3, + "formatted": "$21.30", + "is_default": false + } + }, + "mc_subtotal_price": { + "KRW": { + "price": 58000, + "formatted": "58,000원", + "is_default": true + }, + "USD": { + "price": 42.6, + "formatted": "$42.60", + "is_default": false + } + }, + "confirmed_at": null, + "can_confirm": false, + "can_write_review": false, + "has_review": false + }, + { + "id": 9002, + "option_status": "preparing", + "option_status_label": "배송준비중", + "option_status_variant": "info", + "product_id": 2, + "product_option_id": 102, + "sku": "CABLE-15M-WHT", + "product_name": "고속 충전 케이블 1.5m", + "product_option_name": "1.5m", + "option_name": "1.5m", + "quantity": 1, + "shipped_quantity": 0, + "unit_price": 18000, + "unit_price_formatted": "18,000원", + "subtotal_price": 18000, + "subtotal_price_formatted": "18,000원", + "subtotal_discount_amount": 0, + "subtotal_discount_amount_formatted": "0원", + "list_price": 18000, + "list_price_formatted": "18,000원", + "final_amount": 18000, + "final_amount_formatted": "18,000원", + "thumbnail_url": null, + "mc_unit_price": { + "KRW": { + "price": 18000, + "formatted": "18,000원", + "is_default": true + }, + "USD": { + "price": 13.2, + "formatted": "$13.20", + "is_default": false + } + }, + "mc_subtotal_price": { + "KRW": { + "price": 18000, + "formatted": "18,000원", + "is_default": true + }, + "USD": { + "price": 13.2, + "formatted": "$13.20", + "is_default": false + } + }, + "confirmed_at": null, + "can_confirm": false, + "can_write_review": false, + "has_review": false + } + ], + "payment": { + "id": 8001, + "payment_status": "pending", + "payment_status_label": "입금대기", + "payment_status_variant": "warning", + "payment_method": "vbank", + "payment_method_label": "가상계좌", + "payment_type_label": "일반결제", + "pg_provider": "kginicis", + "payment_number": "PAY-20260603-0002", + "paid_amount_formatted": "102,000원", + "card_name": null, + "card_number_masked": null, + "card_installment_months": 0, + "is_interest_free": false, + "paid_at_formatted": null, + "dbank_name": null, + "dbank_account": null, + "dbank_holder": null, + "depositor_name": null, + "deposit_due_at_formatted": null, + "vbank_name": "국민은행", + "vbank_number": "901234-56-789012", + "vbank_holder": "(주)지세븐커머스", + "vbank_due_at": "2026-06-05T23:59:59+09:00" + }, + "shipping_address": { + "id": 7001, + "address_type": "shipping", + "recipient_name": "홍길동", + "recipient_phone": "010-1234-5678", + "recipient_country_code": "KR", + "zipcode": "06234", + "address": "서울특별시 강남구 테헤란로 123", + "address_detail": "4층 401호", + "city": "", + "state": "", + "full_address": "서울특별시 강남구 테헤란로 123 4층 401호", + "delivery_memo": "부재 시 경비실에 맡겨주세요" + }, + "subtotal_amount": 76000, + "subtotal_amount_formatted": "76,000원", + "total_discount_amount": 5000, + "total_discount_amount_formatted": "5,000원", + "total_shipping_amount": 0, + "total_shipping_amount_formatted": "0원", + "total_amount": 102000, + "total_amount_formatted": "102,000원", + "mc_subtotal_amount": { + "KRW": { + "price": 76000, + "formatted": "76,000원", + "is_default": true + }, + "USD": { + "price": 55.7, + "formatted": "$55.70", + "is_default": false + } + }, + "mc_total_discount_amount": { + "KRW": { + "price": 5000, + "formatted": "5,000원", + "is_default": true + }, + "USD": { + "price": 3.65, + "formatted": "$3.65", + "is_default": false + } + }, + "mc_total_shipping_amount": { + "KRW": { + "price": 0, + "formatted": "0원", + "is_default": true + }, + "USD": { + "price": 0, + "formatted": "$0.00", + "is_default": false + } + }, + "mc_total_amount": { + "KRW": { + "price": 102000, + "formatted": "102,000원", + "is_default": true + }, + "USD": { + "price": 74.5, + "formatted": "$74.50", + "is_default": false + } + } + } + } + } + } + }, + { + "id": "dbank", + "label": "$t:editor.state.payment_dbank", + "sampleDataOverrides": { + "byDataSourceId": { + "orderData": { + "data": { + "id": 5001, + "order_number": "20260603-0001", + "order_status": "payment_pending", + "order_status_label": "입금대기", + "order_status_variant": "warning", + "options": [ + { + "id": 9001, + "option_status": "preparing", + "option_status_label": "배송준비중", + "option_status_variant": "info", + "product_id": 1, + "product_option_id": 101, + "sku": "EARBUDS-PRO-BLK", + "product_name": "프리미엄 무선 이어버드 프로", + "product_option_name": "미드나잇 블랙 / 기본", + "option_name": "미드나잇 블랙 / 기본", + "quantity": 2, + "shipped_quantity": 0, + "unit_price": 29000, + "unit_price_formatted": "29,000원", + "subtotal_price": 58000, + "subtotal_price_formatted": "58,000원", + "subtotal_discount_amount": 5000, + "subtotal_discount_amount_formatted": "5,000원", + "list_price": 32000, + "list_price_formatted": "32,000원", + "final_amount": 53000, + "final_amount_formatted": "53,000원", + "thumbnail_url": null, + "mc_unit_price": { + "KRW": { + "price": 29000, + "formatted": "29,000원", + "is_default": true + }, + "USD": { + "price": 21.3, + "formatted": "$21.30", + "is_default": false + } + }, + "mc_subtotal_price": { + "KRW": { + "price": 58000, + "formatted": "58,000원", + "is_default": true + }, + "USD": { + "price": 42.6, + "formatted": "$42.60", + "is_default": false + } + }, + "confirmed_at": null, + "can_confirm": false, + "can_write_review": false, + "has_review": false + }, + { + "id": 9002, + "option_status": "preparing", + "option_status_label": "배송준비중", + "option_status_variant": "info", + "product_id": 2, + "product_option_id": 102, + "sku": "CABLE-15M-WHT", + "product_name": "고속 충전 케이블 1.5m", + "product_option_name": "1.5m", + "option_name": "1.5m", + "quantity": 1, + "shipped_quantity": 0, + "unit_price": 18000, + "unit_price_formatted": "18,000원", + "subtotal_price": 18000, + "subtotal_price_formatted": "18,000원", + "subtotal_discount_amount": 0, + "subtotal_discount_amount_formatted": "0원", + "list_price": 18000, + "list_price_formatted": "18,000원", + "final_amount": 18000, + "final_amount_formatted": "18,000원", + "thumbnail_url": null, + "mc_unit_price": { + "KRW": { + "price": 18000, + "formatted": "18,000원", + "is_default": true + }, + "USD": { + "price": 13.2, + "formatted": "$13.20", + "is_default": false + } + }, + "mc_subtotal_price": { + "KRW": { + "price": 18000, + "formatted": "18,000원", + "is_default": true + }, + "USD": { + "price": 13.2, + "formatted": "$13.20", + "is_default": false + } + }, + "confirmed_at": null, + "can_confirm": false, + "can_write_review": false, + "has_review": false + } + ], + "payment": { + "id": 8001, + "payment_status": "pending", + "payment_status_label": "입금대기", + "payment_status_variant": "warning", + "payment_method": "dbank", + "payment_method_label": "무통장입금", + "payment_type_label": "일반결제", + "pg_provider": "kginicis", + "payment_number": "PAY-20260603-0003", + "paid_amount_formatted": "102,000원", + "card_name": null, + "card_number_masked": null, + "card_installment_months": 0, + "is_interest_free": false, + "paid_at_formatted": null, + "dbank_name": "신한은행", + "dbank_account": "110-123-456789", + "dbank_holder": "(주)지세븐커머스", + "depositor_name": "홍길동", + "deposit_due_at_formatted": "2026-06-05 23:59", + "vbank_name": null, + "vbank_number": null, + "vbank_holder": null, + "vbank_due_at": null + }, + "shipping_address": { + "id": 7001, + "address_type": "shipping", + "recipient_name": "홍길동", + "recipient_phone": "010-1234-5678", + "recipient_country_code": "KR", + "zipcode": "06234", + "address": "서울특별시 강남구 테헤란로 123", + "address_detail": "4층 401호", + "city": "", + "state": "", + "full_address": "서울특별시 강남구 테헤란로 123 4층 401호", + "delivery_memo": "부재 시 경비실에 맡겨주세요" + }, + "subtotal_amount": 76000, + "subtotal_amount_formatted": "76,000원", + "total_discount_amount": 5000, + "total_discount_amount_formatted": "5,000원", + "total_shipping_amount": 0, + "total_shipping_amount_formatted": "0원", + "total_amount": 102000, + "total_amount_formatted": "102,000원", + "mc_subtotal_amount": { + "KRW": { + "price": 76000, + "formatted": "76,000원", + "is_default": true + }, + "USD": { + "price": 55.7, + "formatted": "$55.70", + "is_default": false + } + }, + "mc_total_discount_amount": { + "KRW": { + "price": 5000, + "formatted": "5,000원", + "is_default": true + }, + "USD": { + "price": 3.65, + "formatted": "$3.65", + "is_default": false + } + }, + "mc_total_shipping_amount": { + "KRW": { + "price": 0, + "formatted": "0원", + "is_default": true + }, + "USD": { + "price": 0, + "formatted": "$0.00", + "is_default": false + } + }, + "mc_total_amount": { + "KRW": { + "price": 102000, + "formatted": "102,000원", + "is_default": true + }, + "USD": { + "price": 74.5, + "formatted": "$74.50", + "is_default": false + } + } + } + } + } + } + } + ] + }, + { + "comment": "shop/reorder — _local.status 값(pending/success/error)으로 재주문 진행 화면을 if 분기한다(본문 line 72/99/225).", + "scope": { + "kind": "route", + "match": "/shop/reorder/:id" + }, + "items": [ + { + "id": "pending", + "label": "$t:editor.state.reorder_pending", + "default": true, + "initialState": { + "local": { + "status": "pending" + } + } + }, + { + "id": "success_partial", + "label": "$t:editor.state.reorder_success_partial", + "initialState": { + "local": { + "status": "success", + "skipped": [ + { + "name": "품절 상품" + } + ] + } + } + }, + { + "id": "error", + "label": "$t:editor.state.reorder_error", + "initialState": { + "local": { + "status": "error" + } + } + } + ] + }, + { + "comment": "admin 상품 폼 — route.itemCode 유무로 수정/신규, query.copy_id 로 복사 생성을 분기한다(본문 line 9/32/75). 검증 오류는 _local.errors.{field} 경로.", + "scope": { + "kind": "route", + "match": "*/admin/ecommerce/products/:itemCode/edit" + }, + "items": [ + { + "id": "edit_mode", + "label": "$t:editor.state.edit_existing", + "default": true + }, + { + "id": "create_mode", + "label": "$t:editor.state.create_mode", + "initialState": { + "route": { + "itemCode": null + } + } + }, + { + "id": "validation_failed", + "label": "$t:editor.state.validation_failed", + "formErrors": { + "_local.errors.name": [ + "필수 입력 항목입니다" + ] + } + } + ] + }, + { + "comment": "admin 쿠폰 폼 — route.id 유무로 수정/신규 분기(본문 line 9/74). 검증 오류는 _local.errors.", + "scope": { + "kind": "route", + "match": "*/admin/ecommerce/promotion-coupons/:id/edit" + }, + "items": [ + { + "id": "edit_mode", + "label": "$t:editor.state.edit_existing", + "default": true, + "initialState": { + "local": { + "form": { + "target_type": "order_amount", + "name": { + "ko": "신규회원 10% 할인", + "en": "New Member 10% Off" + }, + "description": { + "ko": "신규 가입 회원 대상 주문금액 10% 할인 쿠폰", + "en": "10% off for new members" + }, + "issue_method": "download", + "issue_condition": "manual", + "discount_type": "rate", + "discount_value": 10, + "discount_max_amount": 20000, + "min_order_amount": 30000, + "valid_type": "days", + "valid_days": 30, + "valid_from": null, + "valid_to": null, + "total_quantity": 1000, + "issue_from": "2026-05-01T00:00", + "issue_to": "2026-06-30T23:59", + "per_user_limit": 1, + "is_combinable": false, + "target_scope": "all" + } + } + } + }, + { + "id": "create_mode", + "label": "$t:editor.state.create_mode", + "initialState": { + "route": { + "id": null + } + } + }, + { + "id": "validation_failed", + "label": "$t:editor.state.validation_failed", + "formErrors": { + "_local.errors.name": [ + "필수 입력 항목입니다" + ] + } + } + ] + }, + { + "comment": "admin 배송 정책 폼 — route.id 유무로 수정/신규, query.copy_id 로 복사 분기(본문 line 9/60/98). 검증 오류는 _local.errors.", + "scope": { + "kind": "route", + "match": "*/admin/ecommerce/shipping-policies/:id/edit" + }, + "items": [ + { + "id": "edit_mode", + "label": "부과정책: 조건부 무료", + "default": true, + "initialState": { + "local": { + "form": { + "name": { + "ko": "기본 배송정책", + "en": "Standard Shipping" + }, + "is_active": true, + "is_default": true, + "country_settings": [ + { + "country_code": "KR", + "shipping_method": "parcel", + "custom_shipping_name": null, + "carrier": null, + "currency_code": "KRW", + "charge_policy": "conditional_free", + "base_fee": 3000, + "free_threshold": 50000, + "ranges": null, + "api_endpoint": null, + "api_request_fields": null, + "api_response_fee_field": null, + "extra_fee_enabled": true, + "extra_fee_settings": [ + { + "region": "제주특별자치도", + "zipcode": "63000-63644", + "fee": 3000 + }, + { + "region": "도서산간", + "zipcode": "", + "fee": 4000 + } + ], + "extra_fee_multiply": false, + "is_active": true + }, + { + "country_code": "US", + "shipping_method": "parcel", + "custom_shipping_name": null, + "carrier": null, + "currency_code": "USD", + "charge_policy": "range_amount", + "base_fee": 0, + "free_threshold": null, + "ranges": { + "type": "amount", + "tiers": [ + { + "min": 0, + "max": 50000, + "fee": 3000 + }, + { + "min": 50000, + "max": 100000, + "fee": 1500 + }, + { + "min": 100000, + "max": null, + "fee": 0 + } + ] + }, + "api_endpoint": null, + "api_request_fields": null, + "api_response_fee_field": null, + "extra_fee_enabled": false, + "extra_fee_settings": [], + "extra_fee_multiply": false, + "is_active": true + } + ] + }, + "activeCountryTab": 0, + "rangeErrors": [], + "showBaseFee": true, + "showFreeThreshold": true, + "showRanges": false, + "showApiSettings": false, + "showUnitValue": false + } + } + }, + { + "id": "charge_fixed", + "label": "부과정책: 고정", + "default": false, + "initialState": { + "local": { + "form": { + "name": { + "ko": "기본 배송정책", + "en": "Standard Shipping" + }, + "is_active": true, + "is_default": false, + "country_settings": [ + { + "country_code": "KR", + "shipping_method": "parcel", + "custom_shipping_name": null, + "carrier": null, + "currency_code": "KRW", + "charge_policy": "fixed", + "base_fee": 3000, + "free_threshold": null, + "ranges": null, + "api_endpoint": null, + "api_request_fields": null, + "api_response_fee_field": null, + "extra_fee_enabled": false, + "extra_fee_settings": [], + "extra_fee_multiply": false, + "is_active": true + } + ] + }, + "activeCountryTab": 0, + "rangeErrors": [], + "showBaseFee": true, + "showFreeThreshold": false, + "showRanges": false, + "showApiSettings": false, + "showUnitValue": false + } + } + }, + { + "id": "charge_range", + "label": "부과정책: 구간별(금액)", + "default": false, + "initialState": { + "local": { + "form": { + "name": { + "ko": "기본 배송정책", + "en": "Standard Shipping" + }, + "is_active": true, + "is_default": false, + "country_settings": [ + { + "country_code": "KR", + "shipping_method": "parcel", + "custom_shipping_name": null, + "carrier": null, + "currency_code": "KRW", + "charge_policy": "range_amount", + "base_fee": 0, + "free_threshold": null, + "ranges": { + "type": "amount", + "tiers": [ + { + "min": 0, + "max": 50000, + "fee": 3000 + }, + { + "min": 50000, + "max": 100000, + "fee": 1500 + }, + { + "min": 100000, + "max": null, + "fee": 0 + } + ] + }, + "api_endpoint": null, + "api_request_fields": null, + "api_response_fee_field": null, + "extra_fee_enabled": false, + "extra_fee_settings": [], + "extra_fee_multiply": false, + "is_active": true + } + ] + }, + "activeCountryTab": 0, + "rangeErrors": [], + "showBaseFee": false, + "showFreeThreshold": false, + "showRanges": true, + "showApiSettings": false, + "showUnitValue": false + } + } + }, + { + "id": "charge_per_weight", + "label": "부과정책: 무게당", + "default": false, + "initialState": { + "local": { + "form": { + "name": { + "ko": "기본 배송정책", + "en": "Standard Shipping" + }, + "is_active": true, + "is_default": false, + "country_settings": [ + { + "country_code": "KR", + "shipping_method": "parcel", + "custom_shipping_name": null, + "carrier": null, + "currency_code": "KRW", + "charge_policy": "per_weight", + "base_fee": 2500, + "free_threshold": null, + "ranges": { + "type": "weight", + "unit_value": 5 + }, + "api_endpoint": null, + "api_request_fields": null, + "api_response_fee_field": null, + "extra_fee_enabled": false, + "extra_fee_settings": [], + "extra_fee_multiply": false, + "is_active": true + } + ] + }, + "activeCountryTab": 0, + "rangeErrors": [], + "showBaseFee": true, + "showFreeThreshold": false, + "showRanges": false, + "showApiSettings": false, + "showUnitValue": true + } + } + }, + { + "id": "charge_free", + "label": "부과정책: 무료", + "default": false, + "initialState": { + "local": { + "form": { + "name": { + "ko": "기본 배송정책", + "en": "Standard Shipping" + }, + "is_active": true, + "is_default": false, + "country_settings": [ + { + "country_code": "KR", + "shipping_method": "parcel", + "custom_shipping_name": null, + "carrier": null, + "currency_code": "KRW", + "charge_policy": "free", + "base_fee": 0, + "free_threshold": null, + "ranges": null, + "api_endpoint": null, + "api_request_fields": null, + "api_response_fee_field": null, + "extra_fee_enabled": false, + "extra_fee_settings": [], + "extra_fee_multiply": false, + "is_active": true + } + ] + }, + "activeCountryTab": 0, + "rangeErrors": [], + "showBaseFee": false, + "showFreeThreshold": false, + "showRanges": false, + "showApiSettings": false, + "showUnitValue": false + } + } + }, + { + "id": "charge_api", + "label": "부과정책: 계산 API", + "default": false, + "initialState": { + "local": { + "form": { + "name": { + "ko": "기본 배송정책", + "en": "Standard Shipping" + }, + "is_active": true, + "is_default": false, + "country_settings": [ + { + "country_code": "KR", + "shipping_method": "parcel", + "custom_shipping_name": null, + "carrier": null, + "currency_code": "KRW", + "charge_policy": "api", + "base_fee": 0, + "free_threshold": null, + "ranges": null, + "api_endpoint": "https://shipping.example.com/api/quote", + "api_request_fields": [ + "zipcode", + "weight" + ], + "api_response_fee_field": "fee", + "extra_fee_enabled": false, + "extra_fee_settings": [], + "extra_fee_multiply": false, + "is_active": true + } + ] + }, + "activeCountryTab": 0, + "rangeErrors": [], + "showBaseFee": false, + "showFreeThreshold": false, + "showRanges": false, + "showApiSettings": true, + "showUnitValue": false + } + } + }, + { + "id": "create_mode", + "label": "$t:editor.state.create_mode", + "initialState": { + "route": { + "id": null + } + } + }, + { + "id": "validation_failed", + "label": "$t:editor.state.validation_failed", + "formErrors": { + "_local.errors.name": [ + "필수 입력 항목입니다" + ] + } + } + ] + }, + { + "comment": "admin 이커머스 설정 — {{_global.activeEcommerceSettingsTab || query.tab || 'basic_info'}} 으로 9개 탭 화면을 if 분기한다(본문 line 303/344~). query.tab + 우선 키 함께 주입.", + "scope": { + "kind": "route", + "match": "*/admin/ecommerce/settings" + }, + "items": [ + { + "id": "tab_basic_info", + "label": "$t:editor.state.tab.basic_info", + "initialState": { + "query": { + "tab": "basic_info" + }, + "global": { + "activeEcommerceSettingsTab": "basic_info" + } + }, + "default": true + }, + { + "id": "tab_language_currency", + "label": "$t:editor.state.tab.language_currency", + "initialState": { + "query": { + "tab": "language_currency" + }, + "global": { + "activeEcommerceSettingsTab": "language_currency" + } + } + }, + { + "id": "tab_seo", + "label": "$t:editor.state.tab.seo", + "initialState": { + "query": { + "tab": "seo" + }, + "global": { + "activeEcommerceSettingsTab": "seo" + } + } + }, + { + "id": "tab_order_settings", + "label": "$t:editor.state.tab.order_settings", + "initialState": { + "query": { + "tab": "order_settings" + }, + "global": { + "activeEcommerceSettingsTab": "order_settings" + } + } + }, + { + "id": "tab_claim", + "label": "$t:editor.state.tab.claim", + "initialState": { + "query": { + "tab": "claim" + }, + "global": { + "activeEcommerceSettingsTab": "claim" + } + } + }, + { + "id": "tab_shipping", + "label": "$t:editor.state.tab.shipping", + "initialState": { + "query": { + "tab": "shipping" + }, + "global": { + "activeEcommerceSettingsTab": "shipping" + } + } + }, + { + "id": "tab_review_settings", + "label": "$t:editor.state.tab.review_settings", + "initialState": { + "query": { + "tab": "review_settings" + }, + "global": { + "activeEcommerceSettingsTab": "review_settings" + } + } + }, + { + "id": "tab_notification_definitions", + "label": "$t:editor.state.tab.notification_definitions", + "initialState": { + "query": { + "tab": "notification_definitions" + }, + "global": { + "activeEcommerceSettingsTab": "notification_definitions" + } + } + }, + { + "id": "tab_identity_policies", + "label": "$t:editor.state.tab.identity_policies", + "initialState": { + "query": { + "tab": "identity_policies" + }, + "global": { + "activeEcommerceSettingsTab": "identity_policies" + } + } + } + ] + }, + { + "comment": "admin 마일리지/예치금 설정 — _local.activeTab(mileage/deposit)으로 탭을 분기한다(본문 line 130/165).", + "scope": { + "kind": "route", + "match": "*/admin/ecommerce/mileage-deposit-settings" + }, + "items": [ + { + "id": "tab_mileage", + "label": "$t:editor.state.tab.mileage", + "default": true, + "initialState": { + "local": { + "activeTab": "mileage" + } + } + }, + { + "id": "tab_deposit", + "label": "$t:editor.state.tab.deposit", + "initialState": { + "local": { + "activeTab": "deposit" + } + } + } + ] + }, + { + "comment": "shop/guest_order_form — 비회원 주문 조회 폼. 본문이 _global.currentUser.uuid 유무로 양분된다(slots.content[0] 회원→마이페이지 리다이렉트 안내 / content[1] 비회원→조회 폼). 편집기 sampleGlobal 은 로그인 상태(currentUser 시드)라 비회원 조회 폼이 기본 미표시이므로, currentUser 를 null 로 패치한 비회원 상태를 기본으로 노출해 폼을 편집 가능하게 한다.", + "scope": { + "kind": "route", + "match": "/shop/guest/orders" + }, + "items": [ + { + "id": "guest", + "label": "$t:editor.state.guest_order_form_guest", + "default": true, + "comment": "비회원 상태 — currentUser 를 null 로 덮어 content[1] 조회 폼을 캔버스에 노출한다.", + "initialState": { + "global": { + "currentUser": null + } + } + }, + { + "id": "member", + "label": "$t:editor.state.guest_order_form_member", + "comment": "회원 로그인 상태 — currentUser.uuid 를 명시 시드해 content[0] 마이페이지 리다이렉트 안내 분기를 활성화한다. sampleGlobal 은 코어 keyspace(currentUser)를 시드하지 않으므로(코어 우선) 빈 패치로는 guest(null) 와 다른 결과를 만들지 못한다 — 로그인 상태를 명시 패치해야 양분 분기가 회원 쪽으로 전환되고 캔버스가 실제로 달라진다.", + "initialState": { + "global": { + "currentUser": { + "uuid": "editor-sample-member-uuid" + } + } + } + } + ] + }, + { + "comment": "admin 마일리지 거래내역 목록 — transactions 데이터소스 목록 유무. 마일리지 시스템.", + "scope": { + "kind": "route", + "match": "*/admin/ecommerce/mileage-transactions" + }, + "items": [ + { + "id": "with_data", + "label": "$t:editor.state.with_data", + "default": true + }, + { + "id": "empty_data", + "label": "$t:editor.state.empty_data", + "sampleDataOverrides": { + "byEndpointPattern": { + "/api/modules/sirsoft-ecommerce/admin/mileage-transactions*": { + "data": { + "data": [], + "meta": { + "total": 0, + "per_page": 20, + "current_page": 1 + } + } + } + } + } + } + ] + }, + { + "comment": "admin 쿠폰 목록 — coupons 데이터소스 목록 유무. 쿠폰 관리.", + "scope": { + "kind": "route", + "match": "*/admin/ecommerce/promotion-coupons" + }, + "items": [ + { + "id": "with_data", + "label": "$t:editor.state.with_data", + "default": true + }, + { + "id": "empty_data", + "label": "$t:editor.state.empty_data", + "sampleDataOverrides": { + "byEndpointPattern": { + "/api/modules/sirsoft-ecommerce/admin/promotion-coupons*": { + "data": { + "data": [], + "meta": { + "total": 0, + "per_page": 20, + "current_page": 1 + } + } + } + } + } + } + ] + }, + { + "comment": "admin 쿠폰 생성 — promotion-coupon-create 라우트(route.id 없음 = 신규 작성 폼). 쿠폰 관리. 검증 오류는 _local.errors.", + "scope": { + "kind": "route", + "match": "*/admin/ecommerce/promotion-coupon-create" + }, + "items": [ + { + "id": "create_mode", + "label": "$t:editor.state.create_mode", + "default": true, + "initialState": { + "route": { + "id": null + } + } + }, + { + "id": "validation_failed", + "label": "$t:editor.state.validation_failed", + "formErrors": { + "_local.errors.name": [ + "필수 입력 항목입니다" + ] + } + } + ] + }, + { + "comment": "admin 주문 목록 — orders 데이터소스 목록 유무.", + "scope": { + "kind": "route", + "match": "*/admin/ecommerce/orders" + }, + "items": [ + { + "id": "with_data", + "label": "$t:editor.state.with_data", + "default": true + }, + { + "id": "empty_data", + "label": "$t:editor.state.empty_data", + "sampleDataOverrides": { + "byEndpointPattern": { + "/api/modules/sirsoft-ecommerce/admin/orders*": { + "data": { + "data": [], + "meta": { + "total": 0, + "per_page": 20, + "current_page": 1 + } + } + } + } + } + } + ] + }, + { + "comment": "admin 주문 상세 — order 데이터소스 + 부분취소 분기(본문 if order.data.is_partially_cancelled). 주문 항목상태 전이.", + "scope": { + "kind": "route", + "match": "*/admin/ecommerce/orders/:orderNumber" + }, + "items": [ + { + "id": "default", + "label": "$t:editor.state.order_detail_default", + "default": true + }, + { + "id": "partially_cancelled", + "label": "$t:editor.state.order_detail_partially_cancelled", + "comment": "부분취소 상태 — order.data.is_partially_cancelled=true 로 취소 표시 분기를 캔버스에 노출.", + "sampleDataOverrides": { + "byEndpointPattern": { + "/api/modules/sirsoft-ecommerce/admin/orders/*": { + "data": { + "is_partially_cancelled": true + } + } + } + } + } + ] + } + ] + } + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 모듈의 레이아웃 편집기 스펙(editor-spec.json)을 서빙하는 공개 엔드포인트입니다. 인증이 필요하지 않습니다. 활성 모듈만 대상으로 하며 활성 디렉토리 → `_bundled` 폴백 순으로 읽어 `data.spec` 형태로 반환합니다. 비활성·미존재 모듈은 404 이고, 편집기 스펙 파일을 작성하지 않은 경우 spec=null 로 정상 응답합니다. + + diff --git a/docs/backend/api/notification-channels.md b/docs/backend/api/notification-channels.md new file mode 100644 index 00000000..0ca154fb --- /dev/null +++ b/docs/backend/api/notification-channels.md @@ -0,0 +1,107 @@ +# Notification Channels API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Notification Channels 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/admin/notification-channels + +- **라우트명**: `api.admin.notification-channels.index` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationChannelController@index` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/notification-channels HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| channels | array | `[{"id":"mail","name_key":"notification.channels.mail.name…` | 사용 가능한 알림 채널 메타데이터 목록. 각 원소는 `id`(채널 식별자: mail, database 등), `name`/`name_key`·`description`/`description_key`(활성 locale 기준 해석된 라벨/설명과 원본 다국어 키), `icon`(Font Awesome 클래스), `source`(제공 주체: core/module/plugin)·`source_label`(출처 표시 라벨), `allow_guest`(비회원 발송 허용 여부), `readiness`(컨트롤러가 `ChannelReadinessCheckerInterface`로 붙인 채널 설정 완료 여부 정보)로 구성됩니다. config 기본 채널(mail, database)에 `core.notification.filter_available_channels` 훅으로 추가된 확장 채널이 병합됩니다. | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "알림 채널 목록을 조회했습니다.", + "data": { + "channels": [ + { + "id": "mail", + "name_key": "notification.channels.mail.name", + "icon": "fas fa-envelope", + "description_key": "notification.channels.mail.description", + "source": "core", + "source_label_key": "notification.channels.core_default", + "allow_guest": true, + "name": "메일", + "description": "이메일로 알림 발송", + "source_label": "코어 기본 채널", + "readiness": { + "ready": false, + "reason": "notification.readiness.mail_smtp_host_empty" + } + }, + { + "id": "database", + "name_key": "notification.channels.database.name", + "icon": "fas fa-bell", + "description_key": "notification.channels.database.description", + "source": "core", + "source_label_key": "notification.channels.core_default", + "allow_guest": false, + "name": "사이트내 알림", + "description": "사이트내 알림 센터에 표시", + "source_label": "코어 기본 채널", + "readiness": { + "ready": true, + "reason": null + } + } + ] + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 | + + + +**설명** 시스템에서 사용 가능한 알림 채널 목록을 반환하며, 각 채널에 설정 완료 여부(`readiness`) 정보를 붙여 제공합니다. 인증(`auth:sanctum`)과 `core.settings.read` 권한이 필요합니다. 플러그인이 Filter 훅으로 채널을 확장할 수 있으므로 목록은 설치된 확장에 따라 달라집니다. 알림 정의·템플릿 편집 화면에서 채널 선택 옵션을 채우고 미설정 채널을 안내할 때 사용합니다. + + diff --git a/docs/backend/api/notification-definitions.md b/docs/backend/api/notification-definitions.md new file mode 100644 index 00000000..ef7bb9b1 --- /dev/null +++ b/docs/backend/api/notification-definitions.md @@ -0,0 +1,713 @@ +# Notification Definitions API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Notification Definitions 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/admin/notification-definitions + +- **라우트명**: `api.admin.notification-definitions.index` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationDefinitionController@index` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| search | query | string | 아니오 | max 255 | 검색어 (지정한 검색 대상 필드에서 부분 일치) | +| extension_type | query | string | 아니오 | `core`, `module`, `plugin` | 확장 유형 (core/module/plugin/template) | +| extension_identifier | query | string | 아니오 | max 100 | 확장 식별자 | +| channel | query | string | 아니오 | max 50 | 채널 필터 — 활성 채널(`channels`) 배열에 이 채널을 포함하는 정의만 조회 (mail, database 등) | +| is_active | query | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | +| sort_by | query | string | 아니오 | `id`, `type`, `extension_type`, `is_active`, `created_at`, `updated_at` | 정렬 기준 필드명 | +| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification_definition.filter_index_rules`). + +**요청 예시** + +```http +GET /api/admin/notification-definitions?search=%EC%98%88%EC%8B%9C%EA%B0%92&extension_type=core&extension_identifier=example-key&channel=%EC%98%88%EC%8B%9C%EA%B0%92&is_active=1&per_page=1&sort_by=id&sort_order=asc HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| number | integer | `1` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| id | integer | `1` | 기본 키 (내부 식별자) | +| type | string | `welcome` | 알림 타입 (welcome, order_confirmed 등) | +| hook_prefix | string | `core.auth` | 훅 접두사 (core.auth, sirsoft-ecommerce 등) | +| extension_type | string | `core` | 확장 타입: core, module, plugin | +| extension_identifier | string | `core` | 확장 식별자: core, sirsoft-board 등 | +| name | object | `{"ko":"회원가입 환영","en":"Welcome","ja":"会員登録 ウェルカム"}` | 다국어 이름 ({"ko": "회원가입 환영", "en": "Welcome"}) | +| description | object | `{"ko":"회원가입 완료 시 발송되는 환영 알림","en":"Welcome notification s…` | 다국어 설명 | +| variables | array | `[{"key":"name","description":"수신자 이름"},{"key":"app_name",…` | 사용 가능 변수 메타데이터 ([{key, description}]) | +| channels | array | `["mail","database"]` | 활성 채널 (["mail", "database"]) | +| hooks | array | `["core.auth.after_register"]` | 트리거 훅 목록 (["core.auth.after_register"]) | +| is_active | boolean | `true` | active 여부 | +| is_default | boolean | `true` | default 여부 | +| templates | array | `[{"id":1,"definition_id":1,"channel":"mail","subject":{"k…` | 채널별 알림 템플릿 목록 (templates 관계 로드 시 NotificationTemplateResource 배열, 미로드 시 null) | +| created_at | string | `2026-05-27 15:20:18` | 생성 일시 | +| updated_at | string | `2026-06-30 13:33:16` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "알림 정의 목록을 조회했습니다.", + "data": { + "data": [ + { + "number": 1, + "id": 1, + "type": "apidoc-sample.event", + "hook_prefix": "core", + "extension_type": "core", + "extension_identifier": "", + "name": { + "ko": "API 문서 샘플 알림", + "en": "API Doc Sample Notification" + }, + "description": { + "ko": "문서 실측용 알림 정의", + "en": "Sample notification" + }, + "variables": [], + "channels": [ + "database", + "mail" + ], + "hooks": [], + "is_active": true, + "is_default": false, + "templates": [ + { + "id": 1, + "definition_id": 1, + "channel": "mail", + "subject": "API 문서 샘플 템플릿 제목", + "body": "안녕하세요 {{name}} 님, 문서 실측용 본문입니다.", + "click_url": "/admin/apidoc-sample", + "recipients": [ + { + "type": "role", + "value": "admin", + "display_name": "관리자" + } + ], + "is_active": true, + "is_default": false, + "user_overrides": null, + "updated_by": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 10:41:24", + "abilities": { + "can_update": true, + "can_delete": true + } + } + ], + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 10:41:24", + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "number": 2, + "id": 2, + "type": "order_confirmed", + "hook_prefix": "sirsoft-ecommerce", + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "name": { + "ko": "결제 완료", + "en": "Payment Completed" + }, + "description": { + "ko": "결제 완료 시 고객에게 발송", + "en": "Sent to customer when payment is completed" + }, + "variables": [ + { + "key": "name", + "description": "수신자 이름" + }, + { + "key": "app_name", + "description": "사이트 이름" + }, + { + "key": "order_number", + "description": "주문번호" + }, + { + "key": "total_amount", + "description": "결제 금액" + }, + { + "key": "order_url", + "description": "주문 상세 URL" + }, + { + "key": "site_url", + "description": "사이트 URL" + }, + { + "key": "shipping_recipient_name", + "description": "수취인 이름" + }, + { + "key": "shipping_country_name", + "description": "배송국가명" + }, + { + "key": "shipping_address", + "description": "배송지 주소" + } + ], + "channels": [ + "mail", + "database" + ], + "hooks": [ + "sirsoft-ecommerce.order.after_confirm" + ], + "is_active": true, + "is_default": true, + "templates": [ + { + "id": 2, + "definition_id": 2, + "channel": "mail", + "subject": { + "ko": "[{app_name}] 결제가 완료되었습니다 (주문번호: {order_number})", + "en": "[{app_name}] Your payment has been completed (Order #{order_number})" + }, + "body": { + "ko": "

결제 완료

{name}님, 결제가 완료되었습니다. 주문해 주셔서 감사합니다.

주문번호: {order_number}

결제금액: {total_amount}

받는분: {shipping_recipient_name}

배송국가: {shipping_country_name}

배송지: {shipping_address}

주문 상세 내용은 아래 버튼을 클릭하여 확인하실 수 있습니다.


본 메일은 {app_name}에서 발송되었습니다.

", + "en": "

Payment Completed

Dear {name}, your payment has been completed. Thank you for your order.

Order Number: {order_number}

Total Amount: {total_amount}

Recipient: {shipping_recipient_name}

Shipping Country: {shipping_country_name}

Shipping Address: {shipping_address}

Click the button below to view your order details.


This email was sent from {app_name}.

" + }, + "click_url": null, + "recipients": [ + { + "type": "trigger_user" + } + ], + "is_active": true, + "is_default": true, + "user_overrides": null, + "updated_by": null, + "created_at": "2026-07-08 10:43:32", + "updated_at": "2026-07-08 10:43:32", + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "id": 3, + "definition_id": 2, + "channel": "database", + "subject": { + "ko": "결제가 완료되었습니다", + "en": "Your payment has been completed" + }, + "body": { + "ko": "{name}님, 주문번호 {order_number}의 결제가 완료되었습니다.", + "en": "{name}, payment for your order {order_number} has been completed." + }, + "click_url": "{order_url}", + "recipients": [ + { + "type": "trigger_user" + } + ], + "is_active": true, + "is_default": true, + "user_overrides": null, + "updated_by": null, + "created_at": "2026-07-08 10:43:32", + "updated_at": "2026-07-08 10:43:32", + "abilities": { + "can_update": true, + "can_delete": true + } + } + ], + "created_at": "2026-07-08 10:43:32", + "updated_at": "2026-07-08 10:43:32", + "abilities": { + "can_update": true, + "can_delete": true + } + }, + "... (총 18건 중 2건 표시)" + ], + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 18, + "from": 1, + "to": 18, + "has_more_pages": false + }, + "abilities": { + "can_update": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 등록된 알림 정의 목록을 페이지네이션으로 조회합니다. 인증(`auth:sanctum`)과 `core.settings.read` 권한이 필요합니다. `search`, `extension_type`, `extension_identifier`, `channel`, `is_active` 로 필터링하고 `sort_by`/`sort_order` 로 정렬하며, 확장이 `core.notification_definition.filter_index_rules` 훅으로 필터를 추가할 수 있습니다. 관리자 알림 정의 관리 목록 화면을 렌더링할 때 사용합니다. + + +### GET /api/admin/notification-definitions/{definition} + +- **라우트명**: `api.admin.notification-definitions.show` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationDefinitionController@show` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| definition | path | string | 예 | — | 대상 definition의 식별자 | + +**요청 예시** + +```http +GET /api/admin/notification-definitions/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `21` | 기본 키 (내부 식별자) | +| type | string | `apidoc-sample.event` | 알림 타입 (welcome, order_confirmed 등) | +| hook_prefix | string | `core` | 훅 접두사 (core.auth, sirsoft-ecommerce 등) | +| extension_type | string | `core` | 확장 타입: core, module, plugin | +| extension_identifier | string | `` | 확장 식별자: core, sirsoft-board 등 | +| name | object | `{"ko":"API 문서 샘플 알림","en":"API Doc Sample Notification"}` | 다국어 이름 ({"ko": "회원가입 환영", "en": "Welcome"}) | +| description | object | `{"ko":"문서 실측용 알림 정의","en":"Sample notification"}` | 다국어 설명 | +| variables | array | `[]` | 사용 가능 변수 메타데이터 ([{key, description}]) | +| channels | array | `["database","mail"]` | 활성 채널 (["mail", "database"]) | +| hooks | array | `[]` | 트리거 훅 목록 (["core.auth.after_register"]) | +| is_active | boolean | `true` | active 여부 | +| is_default | boolean | `false` | default 여부 | +| templates | array | `[{"id":41,"definition_id":21,"channel":"mail","subject":"…` | 채널별 알림 템플릿 목록 (templates 관계 로드 시 NotificationTemplateResource 배열, 미로드 시 null) | +| created_at | string | `2026-07-06 19:15:16` | 생성 일시 | +| updated_at | string | `2026-07-06 19:15:16` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "알림 정의를 조회했습니다.", + "data": { + "id": 1, + "type": "apidoc-sample.event", + "hook_prefix": "core", + "extension_type": "core", + "extension_identifier": "", + "name": { + "ko": "API 문서 샘플 알림", + "en": "API Doc Sample Notification" + }, + "description": { + "ko": "문서 실측용 알림 정의", + "en": "Sample notification" + }, + "variables": [], + "channels": [ + "database", + "mail" + ], + "hooks": [], + "is_active": true, + "is_default": false, + "templates": [ + { + "id": 1, + "definition_id": 1, + "channel": "mail", + "subject": "API 문서 샘플 템플릿 제목", + "body": "안녕하세요 {{name}} 님, 문서 실측용 본문입니다.", + "click_url": "/admin/apidoc-sample", + "recipients": [ + { + "type": "role", + "value": "admin", + "display_name": "관리자" + } + ], + "is_active": true, + "is_default": false, + "user_overrides": null, + "updated_by": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 10:41:24", + "abilities": { + "can_update": true, + "can_delete": true + } + } + ], + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 10:41:24", + "abilities": { + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 단일 알림 정의의 상세 정보를 조회하며, 응답에 소속 템플릿(`templates`)을 함께 로드합니다. 인증(`auth:sanctum`)과 `core.settings.read` 권한이 필요합니다. `definition` 경로 파라미터로 대상을 지정하며, 정의 편집 화면 진입 시 채널별 템플릿을 포함한 전체 구성을 불러올 때 사용합니다. + + +### PUT /api/admin/notification-definitions/{definition} + +- **라우트명**: `api.admin.notification-definitions.update` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationDefinitionController@update` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| definition | path | string | 예 | — | 대상 definition의 식별자 | +| channels | body | array | 아니오 | min 1 | 활성 채널 목록 — 이 정의가 발송에 사용할 채널 배열 (각 원소 최대 50자, mail·database 등). 지정 시 최소 1개 필요 | +| hooks | body | array | 아니오 | — | 트리거 훅 목록 — 이 알림을 발송시키는 훅 이름 배열 (각 원소 최대 255자, core.auth.after_register 등) | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification_definition.filter_update_rules`). + +**요청 예시** + +```http +PUT /api/admin/notification-definitions/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "channels": [ + "예시값" + ], + "hooks": [ + "예시값" + ], + "is_active": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 알림 정의의 활성 채널(`channels`), 트리거 훅(`hooks`), 활성 상태(`is_active`)를 수정합니다. 인증(`auth:sanctum`)과 `core.settings.update` 권한이 필요합니다. Service 계층에서 수정 후 템플릿을 다시 로드해 반환하며, 확장이 `core.notification_definition.filter_update_rules` 훅으로 추가 파라미터를 검증에 넣을 수 있습니다. 발송 채널 구성이나 훅 연결을 변경할 때 사용합니다. + + +### POST /api/admin/notification-definitions/{definition}/reset + +- **라우트명**: `api.admin.notification-definitions.reset` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationDefinitionController@reset` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| definition | path | string | 예 | — | 대상 definition의 식별자 | + +**요청 예시** + +```http +POST /api/admin/notification-definitions/1/reset HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| type | string | `apidoc-sample.event` | 알림 타입 (welcome, order_confirmed 등) | +| hook_prefix | string | `core` | 훅 접두사 (core.auth, sirsoft-ecommerce 등) | +| extension_type | string | `core` | 이 리소스를 소유한 확장의 타입 (core/module/plugin/template) | +| extension_identifier | string | `` | 이 리소스를 소유한 확장의 식별자 | +| name | object | `{"ko":"API 문서 샘플 알림","en":"API Doc Sample Notification"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| description | object | `{"ko":"문서 실측용 알림 정의","en":"Sample notification"}` | 설명 (다국어 필드는 로케일별 값 객체) | +| variables | array | `[]` | 사용 가능 변수 메타데이터 ([{key, description}]) | +| channels | array | `["database","mail"]` | 활성 채널 (["mail", "database"]) | +| hooks | array | `[]` | 트리거 훅 목록 (["core.auth.after_register"]) | +| is_active | boolean | `true` | active 여부 | +| is_default | boolean | `true` | default 여부 | +| templates | array | `[{"id":1,"definition_id":1,"channel":"mail","subject":"AP…` | 템플릿 목록 (각 원소 identifier/name 등 — 템플릿 관계 파생) | +| created_at | string | `2026-07-08 10:41:24` | 생성 일시 | +| updated_at | string | `2026-07-08 12:14:43` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "알림 정의의 모든 템플릿이 기본값으로 복원되었습니다.", + "data": { + "id": 1, + "type": "apidoc-sample.event", + "hook_prefix": "core", + "extension_type": "core", + "extension_identifier": "", + "name": { + "ko": "API 문서 샘플 알림", + "en": "API Doc Sample Notification" + }, + "description": { + "ko": "문서 실측용 알림 정의", + "en": "Sample notification" + }, + "variables": [], + "channels": [ + "database", + "mail" + ], + "hooks": [], + "is_active": true, + "is_default": true, + "templates": [ + { + "id": 1, + "definition_id": 1, + "channel": "mail", + "subject": "API 문서 샘플 템플릿 제목", + "body": "안녕하세요 {{name}} 님, 문서 실측용 본문입니다.", + "click_url": "/admin/apidoc-sample", + "recipients": [ + { + "type": "role", + "value": "admin", + "display_name": "관리자" + } + ], + "is_active": true, + "is_default": false, + "user_overrides": null, + "updated_by": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 10:41:24", + "abilities": { + "can_update": true, + "can_delete": true + } + } + ], + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 12:14:43", + "abilities": { + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 알림 정의에 속한 모든 채널 템플릿을 기본값(default) 데이터로 일괄 복원하고, 정의 자체를 default 상태로 표시합니다. 인증(`auth:sanctum`)과 `core.settings.update` 권한이 필요합니다. 각 템플릿의 제목·본문을 기본값으로 덮어쓰는 파괴적 작업이므로 사용자 편집분이 사라집니다. 관리자가 커스터마이징한 알림 문구를 초기 상태로 되돌릴 때 사용합니다. + + +### PATCH /api/admin/notification-definitions/{definition}/toggle-active + +- **라우트명**: `api.admin.notification-definitions.toggle-active` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationDefinitionController@toggleActive` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| definition | path | string | 예 | — | 대상 definition의 식별자 | + +**요청 예시** + +```http +PATCH /api/admin/notification-definitions/1/toggle-active HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| type | string | `apidoc-sample.event` | 알림 타입 (welcome, order_confirmed 등) | +| hook_prefix | string | `core` | 훅 접두사 (core.auth, sirsoft-ecommerce 등) | +| extension_type | string | `core` | 이 리소스를 소유한 확장의 타입 (core/module/plugin/template) | +| extension_identifier | string | `` | 이 리소스를 소유한 확장의 식별자 | +| name | object | `{"ko":"API 문서 샘플 알림","en":"API Doc Sample Notification"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| description | object | `{"ko":"문서 실측용 알림 정의","en":"Sample notification"}` | 설명 (다국어 필드는 로케일별 값 객체) | +| variables | array | `[]` | 사용 가능 변수 메타데이터 ([{key, description}]) | +| channels | array | `["database","mail"]` | 활성 채널 (["mail", "database"]) | +| hooks | array | `[]` | 트리거 훅 목록 (["core.auth.after_register"]) | +| is_active | boolean | `false` | active 여부 | +| is_default | boolean | `false` | default 여부 | +| templates | null | `null` | 템플릿 목록 (각 원소 identifier/name 등 — 템플릿 관계 파생) | +| created_at | string | `2026-07-08 10:41:24` | 생성 일시 | +| updated_at | string | `2026-07-08 12:14:43` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "알림 정의 활성 상태가 변경되었습니다.", + "data": { + "id": 1, + "type": "apidoc-sample.event", + "hook_prefix": "core", + "extension_type": "core", + "extension_identifier": "", + "name": { + "ko": "API 문서 샘플 알림", + "en": "API Doc Sample Notification" + }, + "description": { + "ko": "문서 실측용 알림 정의", + "en": "Sample notification" + }, + "variables": [], + "channels": [ + "database", + "mail" + ], + "hooks": [], + "is_active": false, + "is_default": false, + "templates": null, + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 12:14:43", + "abilities": { + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 알림 정의의 활성 상태(`is_active`)를 현재 값의 반대로 토글합니다. 인증(`auth:sanctum`)과 `core.settings.update` 권한이 필요합니다. 비활성 정의는 해당 알림 발송이 중단되므로, 관리자가 목록에서 특정 알림을 켜거나 끄는 스위치 조작에 사용합니다. + + diff --git a/docs/backend/api/notification-logs.md b/docs/backend/api/notification-logs.md new file mode 100644 index 00000000..30e2374f --- /dev/null +++ b/docs/backend/api/notification-logs.md @@ -0,0 +1,257 @@ +# Notification Logs API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Notification Logs 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/admin/notification-logs + +- **라우트명**: `api.admin.notification-logs.index` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationLogController@index` +- **인증/권한**: `auth:sanctum` + `permission:core.notification-logs.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| sender_user_id | query | integer | 아니오 | — | sender user 식별자 | +| recipient_user_id | query | integer | 아니오 | — | recipient user 식별자 | +| search | query | string | 아니오 | max 255 | 검색어 (지정한 검색 대상 필드에서 부분 일치) | +| channel | query | string | 아니오 | max 50 | 발송 채널 필터 (해당 채널로 발송된 이력만 조회 — mail, database, fcm 등) | +| notification_type | query | string | 아니오 | max 100 | 알림 타입 필터 (해당 타입의 이력만 조회 — welcome, order_confirmed 등) | +| extension_type | query | string | 아니오 | `core`, `module`, `plugin` | 확장 유형 (core/module/plugin/template) | +| status | query | string | 아니오 | — | 상태 필터 (해당 상태의 항목만 조회) | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | +| sort_by | query | string | 아니오 | `id`, `channel`, `notification_type`, `status`, `sent_at`, `created_at` | 정렬 기준 필드명 | +| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification_log.filter_index_rules`). + +**요청 예시** + +```http +GET /api/admin/notification-logs?sender_user_id=1&recipient_user_id=1&search=%EC%98%88%EC%8B%9C%EA%B0%92&channel=%EC%98%88%EC%8B%9C%EA%B0%92¬ification_type=%EC%98%88%EC%8B%9C%EA%B0%92&extension_type=core&status=%EC%98%88%EC%8B%9C%EA%B0%92&per_page=1&sort_by=id&sort_order=asc HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| number | integer | `677` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| id | integer | `677` | 기본 키 (내부 식별자) | +| channel | string | `mail` | 채널: mail, database, fcm 등 | +| notification_type | string | `apidoc.sample.event` | 알림 타입: welcome, order_confirmed 등 | +| extension_type | string | `core` | 확장 타입: core, module, plugin | +| extension_identifier | string | `` | 확장 식별자 | +| recipient_user_id | integer | `166` | recipient user 식별자 (연관 리소스 참조) | +| recipient_identifier | string | `apidoc-sample-user@example.com` | 수신자 식별자 (채널별: 이메일, 디바이스토큰, user_id 등) | +| recipient_name | string | `API 문서 샘플 사용자` | 수신자 표시명 (발송 시점 스냅샷) | +| sender_user_id | integer | `166` | sender user 식별자 (연관 리소스 참조) | +| sender | object | `{"uuid":"a231747f-e82e-4cf2-9ae1-a261849dce40","name":"AP…` | 발신자 사용자 객체 (uuid/name/email — senderUser 관계 파생) | +| recipient | object | `{"uuid":"a231747f-e82e-4cf2-9ae1-a261849dce40","name":"AP…` | 수신자 사용자 객체 (uuid/name/email — recipientUser 관계 파생) | +| subject | string | `API 문서 샘플 알림` | 렌더링된 제목 | +| body | string | `문서 실측용 알림 본문입니다.` | 렌더링된 본문 | +| status | string | `sent` | 상태: sent, failed, skipped | +| error_message | string | `해당 채널은 비회원 발송을 허용하지 않아 발송을 건너뛰었습니다.` | 에러 메시지 | +| source | string | `apidoc` | 발송 출처: notification, test_mail 등 | +| sent_at | string | `2026-07-06 18:20:23` | sent 일시 | +| created_at | string | `2026-07-06 19:20:23` | 생성 일시 | +| updated_at | string | `2026-07-06 19:20:23` | 최종 수정 일시 | +| abilities | object | `{"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "알림 발송 이력을 조회했습니다.", + "data": { + "data": [ + { + "number": 1, + "id": 1, + "channel": "mail", + "notification_type": "apidoc.sample.event", + "extension_type": "core", + "extension_identifier": "", + "recipient_user_id": 6, + "recipient_identifier": "apidoc-sample-user@example.com", + "recipient_name": "API 문서 샘플 사용자", + "sender_user_id": 6, + "sender": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com" + }, + "recipient": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com" + }, + "subject": "API 문서 샘플 알림", + "body": "문서 실측용 알림 본문입니다.", + "status": "sent", + "error_message": null, + "source": "apidoc", + "sent_at": "2026-07-08 09:41:24", + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 10:41:24", + "abilities": { + "can_delete": true + } + } + ], + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 1, + "from": 1, + "to": 1, + "has_more_pages": false + }, + "abilities": { + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.notification-logs.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 알림 발송 이력을 페이지네이션으로 조회합니다. 인증(`auth:sanctum`)과 `core.notification-logs.read` 권한이 필요합니다. 발송자/수신자 ID, `search`, `channel`, `notification_type`, `extension_type`, `status` 로 필터링하고 `sort_by`/`sort_order` 로 정렬하며, 확장이 `core.notification_log.filter_index_rules` 훅으로 필터를 추가할 수 있습니다. 요청 사용자(`request->user()`)를 Service 에 전달해 열람 범위를 결정하며, 관리자 알림 발송 이력 화면을 렌더링할 때 사용합니다. + + +### POST /api/admin/notification-logs/bulk-delete + +- **라우트명**: `api.admin.notification-logs.bulk-destroy` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationLogController@bulkDestroy` +- **인증/권한**: `auth:sanctum` + `permission:core.notification-logs.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| ids | body | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | + +**요청 예시** + +```http +POST /api/admin/notification-logs/bulk-delete HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "ids": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.notification-logs.delete`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 알림 발송 이력을 ID 배열(`ids`)로 다건 삭제하고 삭제 건수(`deleted_count`)를 반환합니다. 인증(`auth:sanctum`)과 `core.notification-logs.delete` 권한이 필요합니다. 복구 불가능한 삭제이므로 주의가 필요하며, 관리자가 목록에서 여러 이력을 선택해 일괄 정리할 때 사용합니다. + + +### DELETE /api/admin/notification-logs/{notificationLog} + +- **라우트명**: `api.admin.notification-logs.destroy` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationLogController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:core.notification-logs.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| notificationLog | path | string | 예 | — | 대상 notification log의 식별자 | + +**요청 예시** + +```http +DELETE /api/admin/notification-logs/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "알림 발송 이력이 삭제되었습니다.", + "data": null +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.notification-logs.delete`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 단일 알림 발송 이력을 삭제합니다. 인증(`auth:sanctum`)과 `core.notification-logs.delete` 권한이 필요합니다. `notificationLog` 경로 파라미터로 대상을 지정하며, 복구 불가능한 삭제입니다. 관리자가 개별 발송 이력 한 건을 제거할 때 사용합니다. + + diff --git a/docs/backend/api/notification-templates.md b/docs/backend/api/notification-templates.md new file mode 100644 index 00000000..ae3cc060 --- /dev/null +++ b/docs/backend/api/notification-templates.md @@ -0,0 +1,278 @@ +# Notification Templates API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Notification Templates 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### POST /api/admin/notification-templates/preview + +- **라우트명**: `api.admin.notification-templates.preview` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationTemplateController@preview` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| definition_id | body | integer | 예 | — | definition 식별자 | +| subject | body | array | 예 | — | 제목 | +| body | body | array | 예 | — | 본문 | +| locale | body | string | 아니오 | max 10 | 로케일 코드 (표시 언어/지역) | + +**요청 예시** + +```http +POST /api/admin/notification-templates/preview HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "definition_id": 1, + "subject": [ + "예시값" + ], + "body": [ + "예시값" + ], + "locale": "ko" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 저장 전 알림 템플릿의 렌더링 결과를 미리 확인합니다. `definition_id` 와 다국어 `subject`/`body`, 선택적 `locale` 을 받아 샘플 변수로 치환된 제목·본문을 반환합니다. 인증(`auth:sanctum`)과 `core.settings.read` 권한이 필요하며, 실제 발송이나 저장은 일어나지 않습니다. 템플릿 편집 화면에서 변수 치환 결과를 실시간으로 확인할 때 사용합니다. + + +### PUT /api/admin/notification-templates/{template} + +- **라우트명**: `api.admin.notification-templates.update` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationTemplateController@update` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| template | path | string | 예 | — | 대상 template의 식별자 | +| subject | body | array | 예 | — | 제목 | +| body | body | array | 예 | — | 본문 | +| click_url | body | string | 아니오 | max 500 | 알림 클릭 시 이동할 대상 URL (미설정 시 이동 없음) | +| recipients | body | array | 아니오 | — | 수신자 규칙 목록. 각 원소는 type(trigger_user: 이벤트 유발 사용자, related_user: 연관 사용자, role: 역할 대상, specific_users: 지정 사용자), value(대상 식별값), relation(연관 사용자 관계명), exclude_trigger_user(유발 사용자 제외 여부)로 구성 | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification_template.filter_update_rules`). + +**요청 예시** + +```http +PUT /api/admin/notification-templates/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "subject": [ + "예시값" + ], + "body": [ + "예시값" + ], + "click_url": "https://example.com", + "recipients": [ + "예시값" + ], + "is_active": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 단일 채널 알림 템플릿의 다국어 제목(`subject`)·본문(`body`)과 클릭 URL, 수신자(`recipients`), 활성 상태를 수정합니다. 인증(`auth:sanctum`)과 `core.settings.update` 권한이 필요합니다. `template` 경로 파라미터로 대상을 지정하며, 확장이 `core.notification_template.filter_update_rules` 훅으로 추가 파라미터를 검증에 넣을 수 있습니다. 관리자가 특정 채널의 알림 문구를 편집해 저장할 때 사용합니다. + + +### POST /api/admin/notification-templates/{template}/reset + +- **라우트명**: `api.admin.notification-templates.reset` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationTemplateController@reset` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| template | path | string | 예 | — | 대상 template의 식별자 | + +**요청 예시** + +```http +POST /api/admin/notification-templates/1/reset HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 단일 채널 템플릿을 소속 정의의 기본값 데이터로 복원합니다. 인증(`auth:sanctum`)과 `core.settings.update` 권한이 필요합니다. 소속 정의가 없으면 404, 해당 채널의 기본 데이터가 없으면 404 를 반환합니다. 편집한 문구를 버리고 기본값 하나만 되돌릴 때 사용하며, 정의 전체를 복원하는 정의 reset 과 달리 대상 템플릿에만 적용됩니다. + + +### PATCH /api/admin/notification-templates/{template}/toggle-active + +- **라우트명**: `api.admin.notification-templates.toggle-active` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationTemplateController@toggleActive` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| template | path | string | 예 | — | 대상 template의 식별자 | + +**요청 예시** + +```http +PATCH /api/admin/notification-templates/1/toggle-active HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| definition_id | integer | `1` | definition 식별자 (연관 리소스 참조) | +| channel | string | `mail` | 채널: mail, database, fcm | +| subject | string | `API 문서 샘플 템플릿 제목` | 다국어 제목 ({"ko": "...", "en": "..."}) | +| body | string | `안녕하세요 {{name}} 님, 문서 실측용 본문입니다.` | 다국어 본문 ({"ko": "...", "en": "..."}) | +| click_url | string | `/admin/apidoc-sample` | click URL | +| recipients | array | `[{"type":"role","value":"admin","display_name":"관리자"}]` | 수신자 규칙 JSON ([{type, value, relation, exclude_trigger_user}]) | +| is_active | boolean | `false` | active 여부 | +| is_default | boolean | `false` | default 여부 | +| user_overrides | array | `["is_active"]` | 사용자가 수정한 필드명 목록 | +| updated_by | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | 최종 수정한 사용자 정보 (uuid/name — updated_by 관계 파생, 없으면 null) | +| created_at | string | `2026-07-08 10:41:24` | 생성 일시 | +| updated_at | string | `2026-07-08 12:14:43` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "알림 템플릿 활성 상태가 변경되었습니다.", + "data": { + "id": 1, + "definition_id": 1, + "channel": "mail", + "subject": "API 문서 샘플 템플릿 제목", + "body": "안녕하세요 {{name}} 님, 문서 실측용 본문입니다.", + "click_url": "/admin/apidoc-sample", + "recipients": [ + { + "type": "role", + "value": "admin", + "display_name": "관리자" + } + ], + "is_active": false, + "is_default": false, + "user_overrides": [ + "is_active" + ], + "updated_by": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 12:14:43", + "abilities": { + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 단일 채널 알림 템플릿의 활성 상태(`is_active`)를 현재 값의 반대로 토글합니다. 인증(`auth:sanctum`)과 `core.settings.update` 권한이 필요합니다. 비활성 템플릿은 해당 채널로의 발송이 중단되므로, 정의는 유지한 채 특정 채널만 켜거나 끌 때 사용합니다. + + diff --git a/docs/backend/api/notifications.md b/docs/backend/api/notifications.md new file mode 100644 index 00000000..5d178abd --- /dev/null +++ b/docs/backend/api/notifications.md @@ -0,0 +1,780 @@ +# Notifications API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Notifications 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/admin/notifications + +- **라우트명**: `api.admin.notifications.index` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationController@index` +- **인증/권한**: `auth:sanctum` + `permission:core.notifications.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| read | query | string | 아니오 | `unread`, `read`, `all` | 읽음 상태 필터 (unread: 미읽음(`read_at` null)만, read: 읽음(`read_at` not null)만, all: 전체). 미지정 시 전체 | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | +| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification.filter_index_rules`). + +**요청 예시** + +```http +GET /api/admin/notifications?read=unread&per_page=1&page=1&sort_order=asc HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "알림 목록을 조회했습니다.", + "data": { + "data": [], + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 0, + "from": null, + "to": null, + "has_more_pages": false + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +인증된 관리자 본인의 사이트내 알림 목록을 최신순(`created_at desc`)으로 페이지네이션해 반환합니다. `read` 파라미터로 미읽음(`unread`)·읽음(`read`)·전체(`all`, 기본)를 필터링하고, `per_page` 미지정 시 15건 단위로 반환합니다. 조회 대상은 항상 요청 사용자 본인의 알림으로 한정되며, 다른 사용자의 알림은 조회되지 않습니다. `_admin_base.json` 헤더의 알림 벨이 이 엔드포인트를 auto_fetch 로 소비하며 WebSocket 알림 수신 시 갱신됩니다. 항목 필드는 `id`, `type`, `type_label`, `subject`, `body`, `url`, `read_at`, `created_at` 로 구성됩니다. + + +### DELETE /api/admin/notifications/all + +- **라우트명**: `api.admin.notifications.destroy-all` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationController@destroyAll` +- **인증/권한**: `auth:sanctum` + `permission:core.notifications.delete` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +DELETE /api/admin/notifications/all HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted_count | integer | `0` | deleted 개수 (집계) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "모든 알림이 삭제되었습니다.", + "data": { + "deleted_count": 0 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.notifications.delete`)이 없는 경우 | + + + +**설명** + +인증된 관리자 본인의 모든 사이트내 알림(읽음·미읽음 무관)을 삭제합니다. 삭제 대상은 요청 사용자 본인의 알림으로만 한정되며, 응답 `data.deleted_count` 에 삭제된 건수를 반환합니다. 되돌릴 수 없는 작업이므로 UI 에서 확인 절차를 거친 뒤 호출합니다. + + +### POST /api/admin/notifications/read-all + +- **라우트명**: `api.admin.notifications.read-all` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationController@markAllAsRead` +- **인증/권한**: `auth:sanctum` + `permission:core.notifications.update` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/admin/notifications/read-all HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| marked_count | integer | `0` | marked 개수 (집계) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "모든 알림을 읽음 처리했습니다.", + "data": { + "marked_count": 0 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.notifications.update`)이 없는 경우 | + + + +**설명** + +인증된 관리자 본인의 미읽음 알림을 모두 읽음 처리합니다. 처리 대상 `read_at` 을 현재 시각으로 갱신하며, 응답 `data.marked_count` 에 읽음 처리된 건수를 반환합니다. 이미 읽음 상태인 알림은 대상에서 제외됩니다. + + +### POST /api/admin/notifications/read-batch + +- **라우트명**: `api.admin.notifications.read-batch` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationController@markBatchAsRead` +- **인증/권한**: `auth:sanctum` + `permission:core.notifications.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| ids | body | array | 예 | min 1, max 100 | 대상 리소스 식별자 배열 (대량 작업 대상) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification.filter_batch_read_rules`). + +**요청 예시** + +```http +POST /api/admin/notifications/read-batch HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "ids": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.notifications.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +`ids` 배열로 지정한 알림들을 일괄 읽음 처리합니다. 처리 대상은 요청 사용자 본인의 미읽음 알림 중 지정된 ID 에 해당하는 것으로 한정되며, 이미 읽음 상태이거나 본인 소유가 아닌 ID 는 무시됩니다. 응답 `data.marked_count` 에 실제 읽음 처리된 건수를 반환합니다. `ids` 는 최소 1개, 최대 100개까지 전달할 수 있습니다. + + +### GET /api/admin/notifications/unread-count + +- **라우트명**: `api.admin.notifications.unread-count` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationController@unreadCount` +- **인증/권한**: `auth:sanctum` + `permission:core.notifications.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/notifications/unread-count HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| unread_count | integer | `0` | unread 개수 (집계) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "미읽음 알림 수를 조회했습니다.", + "data": { + "unread_count": 0 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 | + + + +**설명** + +인증된 관리자 본인의 미읽음 알림 개수를 집계해 `data.unread_count` 로 반환합니다. `_admin_base.json` 헤더 알림 벨의 미읽음 배지에 사용되며, WebSocket 으로 새 알림이 수신되면 이 값을 재조회해 갱신합니다. + + +### DELETE /api/admin/notifications/{notification} + +- **라우트명**: `api.admin.notifications.destroy` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:core.notifications.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| notification | path | string | 예 | — | 대상 notification의 식별자 | + +**요청 예시** + +```http +DELETE /api/admin/notifications/{notification} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.notifications.delete`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + +경로의 `{notification}` ID 에 해당하는 알림 1건을 삭제합니다. 대상은 요청 사용자 본인의 알림으로 한정되며, 해당 ID 가 본인 소유로 존재하지 않으면 404(`notification.user.not_found`)를 반환합니다. + + +### PATCH /api/admin/notifications/{notification}/read + +- **라우트명**: `api.admin.notifications.read` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationController@markAsRead` +- **인증/권한**: `auth:sanctum` + `permission:core.notifications.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| notification | path | string | 예 | — | 대상 notification의 식별자 | + +**요청 예시** + +```http +PATCH /api/admin/notifications/{notification}/read HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.notifications.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + +경로의 `{notification}` ID 에 해당하는 알림 1건을 읽음 처리하고, 갱신된 알림 리소스를 `data` 로 반환합니다. 대상은 요청 사용자(관리자) 본인의 알림으로 한정되며, 본인 소유로 존재하지 않으면 404(`notification.user.not_found`)를 반환합니다. 반환 리소스에는 `id`, `type`, `type_label`, `subject`, `body`, `url`, `read_at`, `created_at` 가 포함됩니다. + + +### GET /api/user/notifications + +- **라우트명**: `api.user.notifications.index` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\NotificationController@index` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.user-notifications.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| read | query | string | 아니오 | `unread`, `read`, `all` | 읽음 상태 필터 (unread: 미읽음(`read_at` null)만, read: 읽음(`read_at` not null)만, all: 전체). 미지정 시 전체 | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | +| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification.filter_index_rules`). + +**요청 예시** + +```http +GET /api/user/notifications?read=unread&per_page=1&page=1&sort_order=asc HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "알림 목록을 조회했습니다.", + "data": { + "data": [], + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 0, + "from": null, + "to": null, + "has_more_pages": false + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`core.user-notifications.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +인증된 사용자 본인의 사이트내 알림 목록을 최신순(`created_at desc`)으로 페이지네이션해 반환합니다. `read` 파라미터로 미읽음(`unread`)·읽음(`read`)·전체(`all`, 기본)를 필터링하고, `per_page` 미지정 시 20건 단위로 반환합니다. 조회 대상은 항상 요청 사용자 본인의 알림으로 한정됩니다. `_user_base.json` 이 이 엔드포인트를 소비하며 WebSocket 알림 수신 시 갱신됩니다. 관리자 스코프(`/api/admin/notifications`)와 동일한 서비스·리소스를 사용하되 권한(`core.user-notifications.*`)과 기본 페이지 크기(20건)가 다릅니다. + + +### DELETE /api/user/notifications/all + +- **라우트명**: `api.user.notifications.destroy-all` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\NotificationController@destroyAll` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.user-notifications.delete` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +DELETE /api/user/notifications/all HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted_count | integer | `0` | deleted 개수 (집계) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "모든 알림이 삭제되었습니다.", + "data": { + "deleted_count": 0 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`core.user-notifications.delete`)이 없는 경우 | + + + +**설명** + +인증된 사용자 본인의 모든 사이트내 알림(읽음·미읽음 무관)을 삭제합니다. 삭제 대상은 요청 사용자 본인의 알림으로만 한정되며, 응답 `data.deleted_count` 에 삭제된 건수를 반환합니다. 되돌릴 수 없는 작업입니다. + + +### POST /api/user/notifications/read-all + +- **라우트명**: `api.user.notifications.read-all` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\NotificationController@markAllAsRead` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.user-notifications.update` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/user/notifications/read-all HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| marked_count | integer | `0` | marked 개수 (집계) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "모든 알림을 읽음 처리했습니다.", + "data": { + "marked_count": 0 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`core.user-notifications.update`)이 없는 경우 | + + + +**설명** + +인증된 사용자 본인의 미읽음 알림을 모두 읽음 처리합니다. 처리 대상 `read_at` 을 현재 시각으로 갱신하며, 응답 `data.marked_count` 에 읽음 처리된 건수를 반환합니다. 이미 읽음 상태인 알림은 대상에서 제외됩니다. + + +### POST /api/user/notifications/read-batch + +- **라우트명**: `api.user.notifications.read-batch` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\NotificationController@markBatchAsRead` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.user-notifications.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| ids | body | array | 예 | min 1, max 100 | 대상 리소스 식별자 배열 (대량 작업 대상) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification.filter_batch_read_rules`). + +**요청 예시** + +```http +POST /api/user/notifications/read-batch HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +Content-Type: application/json + +{ + "ids": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`core.user-notifications.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +`ids` 배열로 지정한 알림들을 일괄 읽음 처리합니다. 처리 대상은 요청 사용자 본인의 미읽음 알림 중 지정된 ID 에 해당하는 것으로 한정되며, 이미 읽음 상태이거나 본인 소유가 아닌 ID 는 무시됩니다. 응답 `data.marked_count` 에 실제 읽음 처리된 건수를 반환합니다. `ids` 는 최소 1개, 최대 100개까지 전달할 수 있습니다. + + +### GET /api/user/notifications/unread-count + +- **라우트명**: `api.user.notifications.unread-count` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\NotificationController@unreadCount` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.user-notifications.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/user/notifications/unread-count HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| unread_count | integer | `0` | unread 개수 (집계) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "미읽음 알림 수를 조회했습니다.", + "data": { + "unread_count": 0 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`core.user-notifications.read`)이 없는 경우 | + + + +**설명** + +인증된 사용자 본인의 미읽음 알림 개수를 집계해 `data.unread_count` 로 반환합니다. `_user_base.json` 의 알림 미읽음 배지에 사용되며, WebSocket 으로 새 알림이 수신되면 이 값을 재조회해 갱신합니다. + + +### DELETE /api/user/notifications/{notification} + +- **라우트명**: `api.user.notifications.destroy` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\NotificationController@destroy` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.user-notifications.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| notification | path | string | 예 | — | 대상 notification의 식별자 | + +**요청 예시** + +```http +DELETE /api/user/notifications/{notification} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`core.user-notifications.delete`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + +경로의 `{notification}` ID 에 해당하는 알림 1건을 삭제합니다. 대상은 요청 사용자 본인의 알림으로 한정되며, 해당 ID 가 본인 소유로 존재하지 않으면 404(`notification.user.not_found`)를 반환합니다. + + +### PATCH /api/user/notifications/{notification}/read + +- **라우트명**: `api.user.notifications.read` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\NotificationController@markAsRead` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.user-notifications.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| notification | path | string | 예 | — | 대상 notification의 식별자 | + +**요청 예시** + +```http +PATCH /api/user/notifications/{notification}/read HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`core.user-notifications.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + +경로의 `{notification}` ID 에 해당하는 알림 1건을 읽음 처리하고, 갱신된 알림 리소스를 `data` 로 반환합니다. 대상은 요청 사용자 본인의 알림으로 한정되며, 본인 소유로 존재하지 않으면 404(`notification.user.not_found`)를 반환합니다. 반환 리소스에는 `id`, `type`, `type_label`, `subject`, `body`, `url`, `read_at`, `created_at` 가 포함됩니다. + + diff --git a/docs/backend/api/password.md b/docs/backend/api/password.md new file mode 100644 index 00000000..ef99e7f7 --- /dev/null +++ b/docs/backend/api/password.md @@ -0,0 +1,73 @@ +# Password API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Password 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### PUT /api/me/password + +- **라우트명**: `api.me.password` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\ProfileController@changePassword` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| current_password | body | string | 예 | — | 현재 비밀번호 (변경 전 확인용) | +| password | body | string | 예 | — | 비밀번호 | +| password_confirmation | body | string | 예 | — | 비밀번호 확인 (password 와 일치해야 함) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.user.change_password_validation_rules`). + +**요청 예시** + +```http +PUT /api/me/password HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "current_password": "Password123!", + "password": "Password123!", + "password_confirmation": "Password123!" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +현재 로그인한 사용자가 자신의 비밀번호를 변경한다. 프론트의 비밀번호 변경 화면(`mypage/change-password.json`)이 사용한다. `current_password` 는 `current_password:sanctum` 규칙으로 검증되어 현재 비밀번호가 틀리면 실패하며, 새 비밀번호는 8자 이상이면서 `password_confirmation` 과 일치해야 한다. 해싱은 `UserService::updateUser()` 가 담당한다. 비밀번호 변경이 본인인증(IDV) 대상으로 설정된 경우 확장이 428 을 유발할 수 있으며, 이는 글로벌 예외 핸들러가 처리한다. + + diff --git a/docs/backend/api/permissions.md b/docs/backend/api/permissions.md new file mode 100644 index 00000000..48a23900 --- /dev/null +++ b/docs/backend/api/permissions.md @@ -0,0 +1,4593 @@ +# Permissions API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Permissions 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/admin/permissions + +- **라우트명**: `api.admin.permissions.index` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\PermissionController@index` +- **인증/권한**: `auth:sanctum` + `permission:core.permissions.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/permissions HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| admin | object | `{"label":"관리자 권한","icon":"cog","permissions":[{"id":1,"id…` | 관리자(admin) 타입 권한 그룹. `label`·`icon`(PermissionType::Admin 의 label()/icon() 산물)과 admin 타입으로 필터링된 권한 트리(`permissions`)를 담는다. | +| user | object | `{"label":"사용자 권한","icon":"user","permissions":[{"id":1,"i…` | 사용자(user) 타입 권한 그룹. `label`·`icon`(PermissionType::User 의 label()/icon() 산물)과 user 타입으로 필터링된 권한 트리(`permissions`)를 담는다. | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "권한 정보를 성공적으로 가져왔습니다.", + "data": { + "data": { + "permissions": { + "admin": { + "label": "관리자 권한", + "icon": "cog", + "permissions": [ + { + "id": 115, + "identifier": "core", + "name": "코어", + "name_raw": { + "ko": "코어", + "en": "Core" + }, + "description": "코어 시스템 권한", + "description_raw": { + "ko": "코어 시스템 권한", + "en": "Core system permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 116, + "identifier": "core.users", + "name": "사용자 관리", + "name_raw": { + "ko": "사용자 관리", + "en": "User Management" + }, + "description": "사용자 관리 권한", + "description_raw": { + "ko": "사용자 관리 권한", + "en": "User management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 117, + "identifier": "core.users.read", + "name": "사용자 조회", + "name_raw": { + "ko": "사용자 조회", + "en": "View Users" + }, + "description": "사용자 목록 및 상세 정보를 조회할 수 있습니다.", + "description_raw": { + "ko": "사용자 목록 및 상세 정보를 조회할 수 있습니다.", + "en": "Can view user list and details." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": "user", + "owner_key": "id", + "children": [], + "leaf_count": 1 + }, + { + "id": 118, + "identifier": "core.users.create", + "name": "사용자 생성", + "name_raw": { + "ko": "사용자 생성", + "en": "Create Users" + }, + "description": "새로운 사용자를 생성할 수 있습니다.", + "description_raw": { + "ko": "새로운 사용자를 생성할 수 있습니다.", + "en": "Can create new users." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 119, + "identifier": "core.users.update", + "name": "사용자 수정", + "name_raw": { + "ko": "사용자 수정", + "en": "Update Users" + }, + "description": "사용자 정보를 수정할 수 있습니다.", + "description_raw": { + "ko": "사용자 정보를 수정할 수 있습니다.", + "en": "Can update user information." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": "user", + "owner_key": "id", + "children": [], + "leaf_count": 1 + }, + { + "id": 120, + "identifier": "core.users.delete", + "name": "사용자 삭제", + "name_raw": { + "ko": "사용자 삭제", + "en": "Delete Users" + }, + "description": "사용자를 삭제할 수 있습니다.", + "description_raw": { + "ko": "사용자를 삭제할 수 있습니다.", + "en": "Can delete users." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": "user", + "owner_key": "id", + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 4 + }, + { + "id": 121, + "identifier": "core.menus", + "name": "메뉴 관리", + "name_raw": { + "ko": "메뉴 관리", + "en": "Menu Management" + }, + "description": "메뉴 관리 권한", + "description_raw": { + "ko": "메뉴 관리 권한", + "en": "Menu management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 122, + "identifier": "core.menus.read", + "name": "메뉴 조회", + "name_raw": { + "ko": "메뉴 조회", + "en": "View Menus" + }, + "description": "메뉴 목록을 조회할 수 있습니다.", + "description_raw": { + "ko": "메뉴 목록을 조회할 수 있습니다.", + "en": "Can view menu list." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": "menu", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + }, + { + "id": 123, + "identifier": "core.menus.create", + "name": "메뉴 생성", + "name_raw": { + "ko": "메뉴 생성", + "en": "Create Menus" + }, + "description": "새로운 메뉴를 생성할 수 있습니다.", + "description_raw": { + "ko": "새로운 메뉴를 생성할 수 있습니다.", + "en": "Can create new menus." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 124, + "identifier": "core.menus.update", + "name": "메뉴 수정", + "name_raw": { + "ko": "메뉴 수정", + "en": "Update Menus" + }, + "description": "메뉴 정보를 수정할 수 있습니다.", + "description_raw": { + "ko": "메뉴 정보를 수정할 수 있습니다.", + "en": "Can update menu information." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": "menu", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + }, + { + "id": 125, + "identifier": "core.menus.delete", + "name": "메뉴 삭제", + "name_raw": { + "ko": "메뉴 삭제", + "en": "Delete Menus" + }, + "description": "메뉴를 삭제할 수 있습니다.", + "description_raw": { + "ko": "메뉴를 삭제할 수 있습니다.", + "en": "Can delete menus." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": "menu", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 4 + }, + { + "id": 126, + "identifier": "core.modules", + "name": "모듈 관리", + "name_raw": { + "ko": "모듈 관리", + "en": "Module Management" + }, + "description": "모듈 관리 권한", + "description_raw": { + "ko": "모듈 관리 권한", + "en": "Module management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 127, + "identifier": "core.modules.read", + "name": "모듈 조회", + "name_raw": { + "ko": "모듈 조회", + "en": "View Modules" + }, + "description": "모듈 목록을 조회할 수 있습니다.", + "description_raw": { + "ko": "모듈 목록을 조회할 수 있습니다.", + "en": "Can view module list." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 128, + "identifier": "core.modules.install", + "name": "모듈 설치", + "name_raw": { + "ko": "모듈 설치", + "en": "Install Modules" + }, + "description": "새로운 모듈을 설치할 수 있습니다.", + "description_raw": { + "ko": "새로운 모듈을 설치할 수 있습니다.", + "en": "Can install new modules." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 129, + "identifier": "core.modules.activate", + "name": "모듈 활성화", + "name_raw": { + "ko": "모듈 활성화", + "en": "Activate Modules" + }, + "description": "모듈을 활성화/비활성화할 수 있습니다.", + "description_raw": { + "ko": "모듈을 활성화/비활성화할 수 있습니다.", + "en": "Can activate/deactivate modules." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 130, + "identifier": "core.modules.uninstall", + "name": "모듈 삭제", + "name_raw": { + "ko": "모듈 삭제", + "en": "Uninstall Modules" + }, + "description": "모듈을 삭제할 수 있습니다.", + "description_raw": { + "ko": "모듈을 삭제할 수 있습니다.", + "en": "Can uninstall modules." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 4 + }, + { + "id": 131, + "identifier": "core.plugins", + "name": "플러그인 관리", + "name_raw": { + "ko": "플러그인 관리", + "en": "Plugin Management" + }, + "description": "플러그인 관리 권한", + "description_raw": { + "ko": "플러그인 관리 권한", + "en": "Plugin management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 132, + "identifier": "core.plugins.read", + "name": "플러그인 조회", + "name_raw": { + "ko": "플러그인 조회", + "en": "View Plugins" + }, + "description": "플러그인 목록을 조회할 수 있습니다.", + "description_raw": { + "ko": "플러그인 목록을 조회할 수 있습니다.", + "en": "Can view plugin list." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 133, + "identifier": "core.plugins.install", + "name": "플러그인 설치", + "name_raw": { + "ko": "플러그인 설치", + "en": "Install Plugins" + }, + "description": "새로운 플러그인을 설치할 수 있습니다.", + "description_raw": { + "ko": "새로운 플러그인을 설치할 수 있습니다.", + "en": "Can install new plugins." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 134, + "identifier": "core.plugins.activate", + "name": "플러그인 활성화", + "name_raw": { + "ko": "플러그인 활성화", + "en": "Activate Plugins" + }, + "description": "플러그인을 활성화/비활성화할 수 있습니다.", + "description_raw": { + "ko": "플러그인을 활성화/비활성화할 수 있습니다.", + "en": "Can activate/deactivate plugins." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 135, + "identifier": "core.plugins.update", + "name": "플러그인 설정", + "name_raw": { + "ko": "플러그인 설정", + "en": "Configure Plugins" + }, + "description": "플러그인 환경설정을 수정할 수 있습니다.", + "description_raw": { + "ko": "플러그인 환경설정을 수정할 수 있습니다.", + "en": "Can update plugin settings." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 136, + "identifier": "core.plugins.uninstall", + "name": "플러그인 삭제", + "name_raw": { + "ko": "플러그인 삭제", + "en": "Uninstall Plugins" + }, + "description": "플러그인을 삭제할 수 있습니다.", + "description_raw": { + "ko": "플러그인을 삭제할 수 있습니다.", + "en": "Can uninstall plugins." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 5 + }, + { + "id": 137, + "identifier": "core.templates", + "name": "템플릿 관리", + "name_raw": { + "ko": "템플릿 관리", + "en": "Template Management" + }, + "description": "템플릿 관리 권한", + "description_raw": { + "ko": "템플릿 관리 권한", + "en": "Template management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 138, + "identifier": "core.templates.read", + "name": "템플릿 조회", + "name_raw": { + "ko": "템플릿 조회", + "en": "View Templates" + }, + "description": "템플릿 목록을 조회할 수 있습니다.", + "description_raw": { + "ko": "템플릿 목록을 조회할 수 있습니다.", + "en": "Can view template list." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 139, + "identifier": "core.templates.install", + "name": "템플릿 설치", + "name_raw": { + "ko": "템플릿 설치", + "en": "Install Templates" + }, + "description": "새로운 템플릿을 설치할 수 있습니다.", + "description_raw": { + "ko": "새로운 템플릿을 설치할 수 있습니다.", + "en": "Can install new templates." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 140, + "identifier": "core.templates.activate", + "name": "템플릿 활성화", + "name_raw": { + "ko": "템플릿 활성화", + "en": "Activate Templates" + }, + "description": "템플릿을 활성화/비활성화할 수 있습니다.", + "description_raw": { + "ko": "템플릿을 활성화/비활성화할 수 있습니다.", + "en": "Can activate/deactivate templates." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 141, + "identifier": "core.templates.uninstall", + "name": "템플릿 삭제", + "name_raw": { + "ko": "템플릿 삭제", + "en": "Uninstall Templates" + }, + "description": "템플릿을 삭제할 수 있습니다.", + "description_raw": { + "ko": "템플릿을 삭제할 수 있습니다.", + "en": "Can uninstall templates." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 142, + "identifier": "core.templates.layouts.edit", + "name": "레이아웃 편집", + "name_raw": { + "ko": "레이아웃 편집", + "en": "Edit Layouts" + }, + "description": "템플릿 레이아웃을 편집할 수 있습니다.", + "description_raw": { + "ko": "템플릿 레이아웃을 편집할 수 있습니다.", + "en": "Can edit template layouts." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 5 + }, + { + "id": 143, + "identifier": "core.permissions", + "name": "권한 관리", + "name_raw": { + "ko": "권한 관리", + "en": "Permission Management" + }, + "description": "역할 및 권한 관리 권한", + "description_raw": { + "ko": "역할 및 권한 관리 권한", + "en": "Role and permission management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 144, + "identifier": "core.permissions.read", + "name": "권한 조회", + "name_raw": { + "ko": "권한 조회", + "en": "View Permissions" + }, + "description": "역할 및 권한 목록을 조회할 수 있습니다.", + "description_raw": { + "ko": "역할 및 권한 목록을 조회할 수 있습니다.", + "en": "Can view roles and permissions." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 145, + "identifier": "core.permissions.create", + "name": "역할 생성", + "name_raw": { + "ko": "역할 생성", + "en": "Create Roles" + }, + "description": "새로운 역할을 생성할 수 있습니다.", + "description_raw": { + "ko": "새로운 역할을 생성할 수 있습니다.", + "en": "Can create new roles." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 146, + "identifier": "core.permissions.update", + "name": "역할 수정", + "name_raw": { + "ko": "역할 수정", + "en": "Update Roles" + }, + "description": "역할 정보와 권한을 수정할 수 있습니다.", + "description_raw": { + "ko": "역할 정보와 권한을 수정할 수 있습니다.", + "en": "Can update role information and permissions." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 147, + "identifier": "core.permissions.delete", + "name": "역할 삭제", + "name_raw": { + "ko": "역할 삭제", + "en": "Delete Roles" + }, + "description": "역할을 삭제할 수 있습니다.", + "description_raw": { + "ko": "역할을 삭제할 수 있습니다.", + "en": "Can delete roles." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 4 + }, + { + "id": 148, + "identifier": "core.notification-logs", + "name": "알림 발송 이력", + "name_raw": { + "ko": "알림 발송 이력", + "en": "Notification Logs" + }, + "description": "알림 발송 이력 관리 권한", + "description_raw": { + "ko": "알림 발송 이력 관리 권한", + "en": "Notification log management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 149, + "identifier": "core.notification-logs.read", + "name": "발송 이력 조회", + "name_raw": { + "ko": "발송 이력 조회", + "en": "View Notification Logs" + }, + "description": "알림 발송 이력을 조회할 수 있습니다.", + "description_raw": { + "ko": "알림 발송 이력을 조회할 수 있습니다.", + "en": "Can view notification logs." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 150, + "identifier": "core.notification-logs.delete", + "name": "발송 이력 삭제", + "name_raw": { + "ko": "발송 이력 삭제", + "en": "Delete Notification Logs" + }, + "description": "알림 발송 이력을 삭제할 수 있습니다.", + "description_raw": { + "ko": "알림 발송 이력을 삭제할 수 있습니다.", + "en": "Can delete notification logs." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 2 + }, + { + "id": 151, + "identifier": "core.notifications", + "name": "알림 (관리자)", + "name_raw": { + "ko": "알림 (관리자)", + "en": "Notifications (Admin)" + }, + "description": "관리자용 알림 관리 권한 (관리자 화면에서 사용)", + "description_raw": { + "ko": "관리자용 알림 관리 권한 (관리자 화면에서 사용)", + "en": "Admin notification management permissions (used in admin UI)" + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 152, + "identifier": "core.notifications.read", + "name": "알림 조회", + "name_raw": { + "ko": "알림 조회", + "en": "View Notifications" + }, + "description": "알림 목록 및 읽지 않은 수를 조회할 수 있습니다.", + "description_raw": { + "ko": "알림 목록 및 읽지 않은 수를 조회할 수 있습니다.", + "en": "Can view notification list and unread count." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 153, + "identifier": "core.notifications.update", + "name": "알림 읽음 처리", + "name_raw": { + "ko": "알림 읽음 처리", + "en": "Mark Notifications Read" + }, + "description": "알림을 읽음 처리할 수 있습니다.", + "description_raw": { + "ko": "알림을 읽음 처리할 수 있습니다.", + "en": "Can mark notifications as read." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 154, + "identifier": "core.notifications.delete", + "name": "알림 삭제", + "name_raw": { + "ko": "알림 삭제", + "en": "Delete Notifications" + }, + "description": "알림을 삭제할 수 있습니다.", + "description_raw": { + "ko": "알림을 삭제할 수 있습니다.", + "en": "Can delete notifications." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 3 + }, + { + "id": 163, + "identifier": "core.admin.identity", + "name": "본인인증 관리 (관리자)", + "name_raw": { + "ko": "본인인증 관리 (관리자)", + "en": "Identity Verification Management (Admin)" + }, + "description": "관리자용 IDV 프로바이더/정책/로그 관리 권한", + "description_raw": { + "ko": "관리자용 IDV 프로바이더/정책/로그 관리 권한", + "en": "Admin permissions for IDV providers, policies, and logs" + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 164, + "identifier": "core.admin.identity.providers.read", + "name": "프로바이더 설정 조회", + "name_raw": { + "ko": "프로바이더 설정 조회", + "en": "View IDV Providers" + }, + "description": "본인인증 프로바이더 설정을 조회할 수 있습니다.", + "description_raw": { + "ko": "본인인증 프로바이더 설정을 조회할 수 있습니다.", + "en": "Can view identity verification providers." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 165, + "identifier": "core.admin.identity.providers.update", + "name": "프로바이더 설정 수정", + "name_raw": { + "ko": "프로바이더 설정 수정", + "en": "Update IDV Providers" + }, + "description": "본인인증 프로바이더 설정을 수정할 수 있습니다.", + "description_raw": { + "ko": "본인인증 프로바이더 설정을 수정할 수 있습니다.", + "en": "Can update identity verification providers." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 166, + "identifier": "core.admin.identity.policies.read", + "name": "정책 조회", + "name_raw": { + "ko": "정책 조회", + "en": "View IDV Policies" + }, + "description": "본인인증 정책(라우트/훅별)을 조회할 수 있습니다.", + "description_raw": { + "ko": "본인인증 정책(라우트/훅별)을 조회할 수 있습니다.", + "en": "Can view identity verification policies by route/hook." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 167, + "identifier": "core.admin.identity.policies.update", + "name": "정책 수정", + "name_raw": { + "ko": "정책 수정", + "en": "Update IDV Policies" + }, + "description": "본인인증 정책(라우트/훅별)을 CRUD 할 수 있습니다.", + "description_raw": { + "ko": "본인인증 정책(라우트/훅별)을 CRUD 할 수 있습니다.", + "en": "Can CRUD identity verification policies by route/hook." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 168, + "identifier": "core.admin.identity.logs.read", + "name": "로그 열람", + "name_raw": { + "ko": "로그 열람", + "en": "View IDV Logs" + }, + "description": "본인인증 이력을 조회할 수 있습니다.", + "description_raw": { + "ko": "본인인증 이력을 조회할 수 있습니다.", + "en": "Can view identity verification logs." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 169, + "identifier": "core.admin.identity.logs.purge", + "name": "로그 파기", + "name_raw": { + "ko": "로그 파기", + "en": "Purge IDV Logs" + }, + "description": "본인인증 이력을 파기(보관주기 외)할 수 있습니다.", + "description_raw": { + "ko": "본인인증 이력을 파기(보관주기 외)할 수 있습니다.", + "en": "Can purge identity verification logs (retention-based)." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 170, + "identifier": "core.admin.identity.messages.read", + "name": "메시지 템플릿 조회", + "name_raw": { + "ko": "메시지 템플릿 조회", + "en": "View IDV Message Templates" + }, + "description": "본인인증 메시지 정의/템플릿을 조회할 수 있습니다.", + "description_raw": { + "ko": "본인인증 메시지 정의/템플릿을 조회할 수 있습니다.", + "en": "Can view identity verification message definitions and templates." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 171, + "identifier": "core.admin.identity.messages.update", + "name": "메시지 템플릿 수정", + "name_raw": { + "ko": "메시지 템플릿 수정", + "en": "Update IDV Message Templates" + }, + "description": "본인인증 메시지 정의/템플릿을 수정할 수 있습니다.", + "description_raw": { + "ko": "본인인증 메시지 정의/템플릿을 수정할 수 있습니다.", + "en": "Can update identity verification message definitions and templates." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 8 + }, + { + "id": 172, + "identifier": "core.settings", + "name": "환경설정", + "name_raw": { + "ko": "환경설정", + "en": "Settings" + }, + "description": "시스템 환경설정 권한", + "description_raw": { + "ko": "시스템 환경설정 권한", + "en": "System settings permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 173, + "identifier": "core.settings.read", + "name": "설정 조회", + "name_raw": { + "ko": "설정 조회", + "en": "View Settings" + }, + "description": "시스템 설정을 조회할 수 있습니다.", + "description_raw": { + "ko": "시스템 설정을 조회할 수 있습니다.", + "en": "Can view system settings." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 174, + "identifier": "core.settings.update", + "name": "설정 수정", + "name_raw": { + "ko": "설정 수정", + "en": "Update Settings" + }, + "description": "시스템 설정을 수정할 수 있습니다.", + "description_raw": { + "ko": "시스템 설정을 수정할 수 있습니다.", + "en": "Can update system settings." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 2 + }, + { + "id": 175, + "identifier": "core.dashboard", + "name": "대시보드", + "name_raw": { + "ko": "대시보드", + "en": "Dashboard" + }, + "description": "대시보드 접근 권한", + "description_raw": { + "ko": "대시보드 접근 권한", + "en": "Dashboard access permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 176, + "identifier": "core.dashboard.read", + "name": "대시보드 조회", + "name_raw": { + "ko": "대시보드 조회", + "en": "View Dashboard" + }, + "description": "대시보드 통계 및 정보를 조회할 수 있습니다.", + "description_raw": { + "ko": "대시보드 통계 및 정보를 조회할 수 있습니다.", + "en": "Can view dashboard statistics and information." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 177, + "identifier": "core.dashboard.system-status", + "name": "시스템 상태", + "name_raw": { + "ko": "시스템 상태", + "en": "System Status" + }, + "description": "시스템 상태 정보를 조회할 수 있습니다.", + "description_raw": { + "ko": "시스템 상태 정보를 조회할 수 있습니다.", + "en": "Can view system status information." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 178, + "identifier": "core.dashboard.resources", + "name": "시스템 리소스", + "name_raw": { + "ko": "시스템 리소스", + "en": "System Resources" + }, + "description": "CPU, 메모리, 디스크 사용량을 조회할 수 있습니다.", + "description_raw": { + "ko": "CPU, 메모리, 디스크 사용량을 조회할 수 있습니다.", + "en": "Can view CPU, memory, and disk usage." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 179, + "identifier": "core.dashboard.activities", + "name": "최근 활동", + "name_raw": { + "ko": "최근 활동", + "en": "Recent Activities" + }, + "description": "최근 활동 이력을 조회할 수 있습니다.", + "description_raw": { + "ko": "최근 활동 이력을 조회할 수 있습니다.", + "en": "Can view recent activity history." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": "activityLog", + "owner_key": "user_id", + "children": [], + "leaf_count": 1 + }, + { + "id": 180, + "identifier": "core.dashboard.alerts", + "name": "시스템 알림", + "name_raw": { + "ko": "시스템 알림", + "en": "System Alerts" + }, + "description": "시스템 알림을 조회할 수 있습니다.", + "description_raw": { + "ko": "시스템 알림을 조회할 수 있습니다.", + "en": "Can view system alerts." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 5 + }, + { + "id": 181, + "identifier": "core.activities", + "name": "활동 로그", + "name_raw": { + "ko": "활동 로그", + "en": "Activity Logs" + }, + "description": "활동 로그 조회 권한", + "description_raw": { + "ko": "활동 로그 조회 권한", + "en": "Activity log access permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 182, + "identifier": "core.activities.read", + "name": "활동 로그 조회", + "name_raw": { + "ko": "활동 로그 조회", + "en": "View Activity Logs" + }, + "description": "활동 로그를 조회할 수 있습니다.", + "description_raw": { + "ko": "활동 로그를 조회할 수 있습니다.", + "en": "Can view activity logs." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": "activityLog", + "owner_key": "user_id", + "children": [], + "leaf_count": 1 + }, + { + "id": 183, + "identifier": "core.activities.delete", + "name": "활동 로그 삭제", + "name_raw": { + "ko": "활동 로그 삭제", + "en": "Delete Activity Logs" + }, + "description": "활동 로그를 삭제할 수 있습니다.", + "description_raw": { + "ko": "활동 로그를 삭제할 수 있습니다.", + "en": "Can delete activity logs." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 2 + }, + { + "id": 184, + "identifier": "core.attachments", + "name": "첨부파일 관리", + "name_raw": { + "ko": "첨부파일 관리", + "en": "Attachment Management" + }, + "description": "첨부파일 관리 권한", + "description_raw": { + "ko": "첨부파일 관리 권한", + "en": "Attachment management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 185, + "identifier": "core.attachments.create", + "name": "첨부파일 업로드", + "name_raw": { + "ko": "첨부파일 업로드", + "en": "Upload Attachments" + }, + "description": "첨부파일을 업로드할 수 있습니다.", + "description_raw": { + "ko": "첨부파일을 업로드할 수 있습니다.", + "en": "Can upload attachments." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 186, + "identifier": "core.attachments.update", + "name": "첨부파일 수정", + "name_raw": { + "ko": "첨부파일 수정", + "en": "Update Attachments" + }, + "description": "첨부파일 정보 및 순서를 수정할 수 있습니다.", + "description_raw": { + "ko": "첨부파일 정보 및 순서를 수정할 수 있습니다.", + "en": "Can update attachment information and order." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": "attachment", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + }, + { + "id": 187, + "identifier": "core.attachments.delete", + "name": "첨부파일 삭제", + "name_raw": { + "ko": "첨부파일 삭제", + "en": "Delete Attachments" + }, + "description": "첨부파일을 삭제할 수 있습니다.", + "description_raw": { + "ko": "첨부파일을 삭제할 수 있습니다.", + "en": "Can delete attachments." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": "attachment", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 3 + }, + { + "id": 188, + "identifier": "core.schedules", + "name": "스케줄 관리", + "name_raw": { + "ko": "스케줄 관리", + "en": "Schedule Management" + }, + "description": "스케줄 작업 관리 권한", + "description_raw": { + "ko": "스케줄 작업 관리 권한", + "en": "Schedule task management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 189, + "identifier": "core.schedules.read", + "name": "스케줄 조회", + "name_raw": { + "ko": "스케줄 조회", + "en": "View Schedules" + }, + "description": "스케줄 목록 및 상세 정보를 조회할 수 있습니다.", + "description_raw": { + "ko": "스케줄 목록 및 상세 정보를 조회할 수 있습니다.", + "en": "Can view schedule list and details." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 190, + "identifier": "core.schedules.create", + "name": "스케줄 생성", + "name_raw": { + "ko": "스케줄 생성", + "en": "Create Schedules" + }, + "description": "새로운 스케줄을 생성할 수 있습니다.", + "description_raw": { + "ko": "새로운 스케줄을 생성할 수 있습니다.", + "en": "Can create new schedules." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 191, + "identifier": "core.schedules.update", + "name": "스케줄 수정", + "name_raw": { + "ko": "스케줄 수정", + "en": "Update Schedules" + }, + "description": "스케줄 정보를 수정할 수 있습니다.", + "description_raw": { + "ko": "스케줄 정보를 수정할 수 있습니다.", + "en": "Can update schedule information." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 192, + "identifier": "core.schedules.delete", + "name": "스케줄 삭제", + "name_raw": { + "ko": "스케줄 삭제", + "en": "Delete Schedules" + }, + "description": "스케줄을 삭제할 수 있습니다.", + "description_raw": { + "ko": "스케줄을 삭제할 수 있습니다.", + "en": "Can delete schedules." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 193, + "identifier": "core.schedules.run", + "name": "스케줄 실행", + "name_raw": { + "ko": "스케줄 실행", + "en": "Run Schedules" + }, + "description": "스케줄을 수동으로 실행할 수 있습니다.", + "description_raw": { + "ko": "스케줄을 수동으로 실행할 수 있습니다.", + "en": "Can manually run schedules." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 5 + }, + { + "id": 194, + "identifier": "core.language_packs", + "name": "언어팩 관리", + "name_raw": { + "ko": "언어팩 관리", + "en": "Language Pack Management" + }, + "description": "언어팩 설치/제거/활성화 권한", + "description_raw": { + "ko": "언어팩 설치/제거/활성화 권한", + "en": "Language pack install/uninstall/activation permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 195, + "identifier": "core.language_packs.read", + "name": "언어팩 조회", + "name_raw": { + "ko": "언어팩 조회", + "en": "View Language Packs" + }, + "description": "설치된 언어팩 목록 및 상세 정보를 조회할 수 있습니다.", + "description_raw": { + "ko": "설치된 언어팩 목록 및 상세 정보를 조회할 수 있습니다.", + "en": "Can view installed language pack list and details." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 196, + "identifier": "core.language_packs.install", + "name": "언어팩 설치", + "name_raw": { + "ko": "언어팩 설치", + "en": "Install Language Packs" + }, + "description": "ZIP/GitHub/URL 로 언어팩을 설치할 수 있습니다.", + "description_raw": { + "ko": "ZIP/GitHub/URL 로 언어팩을 설치할 수 있습니다.", + "en": "Can install language packs from ZIP/GitHub/URL." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 197, + "identifier": "core.language_packs.manage", + "name": "언어팩 관리", + "name_raw": { + "ko": "언어팩 관리", + "en": "Manage Language Packs" + }, + "description": "언어팩을 활성화/비활성화/제거할 수 있습니다.", + "description_raw": { + "ko": "언어팩을 활성화/비활성화/제거할 수 있습니다.", + "en": "Can activate/deactivate/uninstall language packs." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 198, + "identifier": "core.language_packs.update", + "name": "언어팩 업데이트", + "name_raw": { + "ko": "언어팩 업데이트", + "en": "Update Language Packs" + }, + "description": "설치된 언어팩의 업데이트를 확인하고 실행할 수 있습니다.", + "description_raw": { + "ko": "설치된 언어팩의 업데이트를 확인하고 실행할 수 있습니다.", + "en": "Can check and apply updates for installed language packs." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 4 + } + ], + "leaf_count": 60 + }, + { + "id": 1, + "identifier": "apidoc-sample.parent", + "name": "API 문서 샘플 권한", + "name_raw": { + "ko": "API 문서 샘플 권한", + "en": "API Doc Sample Permission" + }, + "description": "문서 실측용 권한", + "description_raw": { + "ko": "문서 실측용 권한", + "en": "Sample permission" + }, + "extension_type": null, + "extension_identifier": null, + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 2, + "identifier": "apidoc-sample.child", + "name": "하위 권한", + "name_raw": { + "ko": "하위 권한", + "en": "Child Permission" + }, + "description": "Quod consequatur sunt necessitatibus quam quia voluptas quod aperiam.", + "description_raw": { + "ko": "Quod consequatur sunt necessitatibus quam quia voluptas quod aperiam.", + "en": "Mollitia incidunt dolor rem maxime." + }, + "extension_type": null, + "extension_identifier": null, + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 1 + }, + { + "id": 3, + "identifier": "sirsoft-board", + "name": "게시판", + "name_raw": { + "ko": "게시판", + "en": "Board" + }, + "description": "게시판 모듈 권한", + "description_raw": { + "ko": "게시판 모듈 권한", + "en": "Board module permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 4, + "identifier": "sirsoft-board.apidoc-sample-board", + "name": "API 문서 샘플 게시판 게시판", + "name_raw": { + "ko": "API 문서 샘플 게시판 게시판", + "en": "API Doc Sample Board board" + }, + "description": "API 문서 샘플 게시판 게시판 권한", + "description_raw": { + "ko": "API 문서 샘플 게시판 게시판 권한", + "en": "API Doc Sample Board board permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 5, + "identifier": "sirsoft-board.apidoc-sample-board.admin.posts.read", + "name": "게시글 조회 (관리자)", + "name_raw": { + "ko": "게시글 조회 (관리자)", + "en": "View Posts (Admin)" + }, + "description": "관리자 페이지에서 게시글 조회", + "description_raw": { + "ko": "관리자 페이지에서 게시글 조회", + "en": "View posts in admin page" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "admin", + "is_assignable": true, + "resource_route_key": "post", + "owner_key": "user_id", + "children": [], + "leaf_count": 1 + }, + { + "id": 6, + "identifier": "sirsoft-board.apidoc-sample-board.admin.posts.write", + "name": "게시글 작성/수정/삭제 (관리자)", + "name_raw": { + "ko": "게시글 작성/수정/삭제 (관리자)", + "en": "Create/Edit/Delete Posts (Admin)" + }, + "description": "관리자 페이지에서 게시글 작성 및 수정/삭제", + "description_raw": { + "ko": "관리자 페이지에서 게시글 작성 및 수정/삭제", + "en": "Create and edit/delete posts in admin page" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 7, + "identifier": "sirsoft-board.apidoc-sample-board.admin.posts.read-secret", + "name": "비밀글 조회 (관리자)", + "name_raw": { + "ko": "비밀글 조회 (관리자)", + "en": "View Secret Posts (Admin)" + }, + "description": "관리자 페이지에서 비밀글 조회", + "description_raw": { + "ko": "관리자 페이지에서 비밀글 조회", + "en": "View secret posts in admin page" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "admin", + "is_assignable": true, + "resource_route_key": "post", + "owner_key": "user_id", + "children": [], + "leaf_count": 1 + }, + { + "id": 8, + "identifier": "sirsoft-board.apidoc-sample-board.admin.comments.read", + "name": "댓글 조회 (관리자)", + "name_raw": { + "ko": "댓글 조회 (관리자)", + "en": "View Comments (Admin)" + }, + "description": "관리자 페이지에서 댓글 조회", + "description_raw": { + "ko": "관리자 페이지에서 댓글 조회", + "en": "View comments in admin page" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "admin", + "is_assignable": true, + "resource_route_key": "comment", + "owner_key": "user_id", + "children": [], + "leaf_count": 1 + }, + { + "id": 9, + "identifier": "sirsoft-board.apidoc-sample-board.admin.comments.write", + "name": "댓글 작성/수정/삭제 (관리자)", + "name_raw": { + "ko": "댓글 작성/수정/삭제 (관리자)", + "en": "Create/Edit/Delete Comments (Admin)" + }, + "description": "관리자 페이지에서 댓글 작성 및 수정/삭제", + "description_raw": { + "ko": "관리자 페이지에서 댓글 작성 및 수정/삭제", + "en": "Create and edit/delete comments in admin page" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 10, + "identifier": "sirsoft-board.apidoc-sample-board.admin.attachments.upload", + "name": "파일 업로드/삭제 (관리자)", + "name_raw": { + "ko": "파일 업로드/삭제 (관리자)", + "en": "Upload/Delete Files (Admin)" + }, + "description": "관리자 페이지에서 파일 업로드 및 삭제", + "description_raw": { + "ko": "관리자 페이지에서 파일 업로드 및 삭제", + "en": "Upload and delete files in admin page" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "admin", + "is_assignable": true, + "resource_route_key": "attachment", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + }, + { + "id": 11, + "identifier": "sirsoft-board.apidoc-sample-board.admin.attachments.download", + "name": "파일 다운로드 (관리자)", + "name_raw": { + "ko": "파일 다운로드 (관리자)", + "en": "Download Files (Admin)" + }, + "description": "관리자 페이지에서 파일 다운로드", + "description_raw": { + "ko": "관리자 페이지에서 파일 다운로드", + "en": "Download files in admin page" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "admin", + "is_assignable": true, + "resource_route_key": "attachment", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + }, + { + "id": 12, + "identifier": "sirsoft-board.apidoc-sample-board.admin.manage", + "name": "게시판 관리 (관리자)", + "name_raw": { + "ko": "게시판 관리 (관리자)", + "en": "Manage Board (Admin)" + }, + "description": "타인 게시글/댓글 관리, 블라인드, 복원, 공지 설정", + "description_raw": { + "ko": "타인 게시글/댓글 관리, 블라인드, 복원, 공지 설정", + "en": "Manage others posts/comments, blind, restore, set notice" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "admin", + "is_assignable": true, + "resource_route_key": "board", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 8 + }, + { + "id": 95, + "identifier": "sirsoft-board.boards", + "name": "게시판 관리", + "name_raw": { + "ko": "게시판 관리", + "en": "Board Management" + }, + "description": "게시판 관리 권한", + "description_raw": { + "ko": "게시판 관리 권한", + "en": "Board management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 96, + "identifier": "sirsoft-board.boards.read", + "name": "게시판 조회", + "name_raw": { + "ko": "게시판 조회", + "en": "View Boards" + }, + "description": "게시판 목록 및 상세 조회", + "description_raw": { + "ko": "게시판 목록 및 상세 조회", + "en": "View board list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "admin", + "is_assignable": true, + "resource_route_key": "board", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + }, + { + "id": 97, + "identifier": "sirsoft-board.boards.create", + "name": "게시판 생성", + "name_raw": { + "ko": "게시판 생성", + "en": "Create Board" + }, + "description": "새 게시판 생성", + "description_raw": { + "ko": "새 게시판 생성", + "en": "Create new board" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 98, + "identifier": "sirsoft-board.boards.update", + "name": "게시판 수정", + "name_raw": { + "ko": "게시판 수정", + "en": "Update Board" + }, + "description": "게시판 설정 수정", + "description_raw": { + "ko": "게시판 설정 수정", + "en": "Update board settings" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "admin", + "is_assignable": true, + "resource_route_key": "board", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + }, + { + "id": 99, + "identifier": "sirsoft-board.boards.delete", + "name": "게시판 삭제", + "name_raw": { + "ko": "게시판 삭제", + "en": "Delete Board" + }, + "description": "게시판 삭제", + "description_raw": { + "ko": "게시판 삭제", + "en": "Delete board" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "admin", + "is_assignable": true, + "resource_route_key": "board", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 4 + }, + { + "id": 100, + "identifier": "sirsoft-board.settings", + "name": "환경설정", + "name_raw": { + "ko": "환경설정", + "en": "Settings" + }, + "description": "게시판 환경설정 권한", + "description_raw": { + "ko": "게시판 환경설정 권한", + "en": "Board settings permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 101, + "identifier": "sirsoft-board.settings.read", + "name": "환경설정 조회", + "name_raw": { + "ko": "환경설정 조회", + "en": "View Settings" + }, + "description": "게시판 환경설정 조회", + "description_raw": { + "ko": "게시판 환경설정 조회", + "en": "View board settings" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 102, + "identifier": "sirsoft-board.settings.update", + "name": "환경설정 수정", + "name_raw": { + "ko": "환경설정 수정", + "en": "Update Settings" + }, + "description": "게시판 환경설정 수정", + "description_raw": { + "ko": "게시판 환경설정 수정", + "en": "Update board settings" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 2 + }, + { + "id": 103, + "identifier": "sirsoft-board.identity.policies", + "name": "게시판 본인인증 정책", + "name_raw": { + "ko": "게시판 본인인증 정책", + "en": "Board Identity Policies" + }, + "description": "게시판 컨텍스트의 본인인증 정책 관리 권한", + "description_raw": { + "ko": "게시판 컨텍스트의 본인인증 정책 관리 권한", + "en": "Manage identity verification policies in board context" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 104, + "identifier": "sirsoft-board.identity.policies.read", + "name": "본인인증 정책 조회", + "name_raw": { + "ko": "본인인증 정책 조회", + "en": "View Identity Policies" + }, + "description": "게시판 본인인증 정책 조회", + "description_raw": { + "ko": "게시판 본인인증 정책 조회", + "en": "View board identity policies" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 105, + "identifier": "sirsoft-board.identity.policies.update", + "name": "본인인증 정책 수정", + "name_raw": { + "ko": "본인인증 정책 수정", + "en": "Update Identity Policies" + }, + "description": "게시판 본인인증 정책 수정/추가/삭제", + "description_raw": { + "ko": "게시판 본인인증 정책 수정/추가/삭제", + "en": "Update, add, delete board identity policies" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 2 + }, + { + "id": 106, + "identifier": "sirsoft-board.reports", + "name": "게시판 신고 관리", + "name_raw": { + "ko": "게시판 신고 관리", + "en": "Report Management" + }, + "description": "게시판 신고 관리 권한", + "description_raw": { + "ko": "게시판 신고 관리 권한", + "en": "Board report management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 107, + "identifier": "sirsoft-board.reports.view", + "name": "신고 조회", + "name_raw": { + "ko": "신고 조회", + "en": "View Reports" + }, + "description": "신고 목록 및 상세 조회", + "description_raw": { + "ko": "신고 목록 및 상세 조회", + "en": "View report list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "admin", + "is_assignable": true, + "resource_route_key": "report", + "owner_key": "reporter_id", + "children": [], + "leaf_count": 1 + }, + { + "id": 108, + "identifier": "sirsoft-board.reports.manage", + "name": "신고 처리", + "name_raw": { + "ko": "신고 처리", + "en": "Manage Reports" + }, + "description": "신고 상태 변경 및 처리", + "description_raw": { + "ko": "신고 상태 변경 및 처리", + "en": "Update report status and process" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "admin", + "is_assignable": true, + "resource_route_key": "report", + "owner_key": "reporter_id", + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 2 + } + ], + "leaf_count": 18 + }, + { + "id": 21, + "identifier": "sirsoft-ecommerce", + "name": "이커머스", + "name_raw": { + "ko": "이커머스", + "en": "Ecommerce" + }, + "description": "이커머스 모듈 권한", + "description_raw": { + "ko": "이커머스 모듈 권한", + "en": "Ecommerce module permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 22, + "identifier": "sirsoft-ecommerce.products", + "name": "상품 관리", + "name_raw": { + "ko": "상품 관리", + "en": "Product Management" + }, + "description": "상품 관리 권한", + "description_raw": { + "ko": "상품 관리 권한", + "en": "Product management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 23, + "identifier": "sirsoft-ecommerce.products.read", + "name": "상품 조회", + "name_raw": { + "ko": "상품 조회", + "en": "Read Products" + }, + "description": "상품 목록 및 상세 조회", + "description_raw": { + "ko": "상품 목록 및 상세 조회", + "en": "Read product list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": "product", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + }, + { + "id": 24, + "identifier": "sirsoft-ecommerce.products.create", + "name": "상품 생성", + "name_raw": { + "ko": "상품 생성", + "en": "Create Product" + }, + "description": "새 상품 등록", + "description_raw": { + "ko": "새 상품 등록", + "en": "Create new product" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 25, + "identifier": "sirsoft-ecommerce.products.update", + "name": "상품 수정", + "name_raw": { + "ko": "상품 수정", + "en": "Update Product" + }, + "description": "상품 정보 수정", + "description_raw": { + "ko": "상품 정보 수정", + "en": "Update product information" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": "product", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + }, + { + "id": 26, + "identifier": "sirsoft-ecommerce.products.delete", + "name": "상품 삭제", + "name_raw": { + "ko": "상품 삭제", + "en": "Delete Product" + }, + "description": "상품 삭제", + "description_raw": { + "ko": "상품 삭제", + "en": "Delete product" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": "product", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 4 + }, + { + "id": 27, + "identifier": "sirsoft-ecommerce.orders", + "name": "주문 관리", + "name_raw": { + "ko": "주문 관리", + "en": "Order Management" + }, + "description": "주문 관리 권한", + "description_raw": { + "ko": "주문 관리 권한", + "en": "Order management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 28, + "identifier": "sirsoft-ecommerce.orders.read", + "name": "주문 조회", + "name_raw": { + "ko": "주문 조회", + "en": "Read Orders" + }, + "description": "주문 목록 및 상세 조회", + "description_raw": { + "ko": "주문 목록 및 상세 조회", + "en": "Read order list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": "order", + "owner_key": "user_id", + "children": [], + "leaf_count": 1 + }, + { + "id": 29, + "identifier": "sirsoft-ecommerce.orders.update", + "name": "주문 수정", + "name_raw": { + "ko": "주문 수정", + "en": "Update Order" + }, + "description": "주문 상태 변경", + "description_raw": { + "ko": "주문 상태 변경", + "en": "Update order status" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": "order", + "owner_key": "user_id", + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 2 + }, + { + "id": 30, + "identifier": "sirsoft-ecommerce.categories", + "name": "카테고리 관리", + "name_raw": { + "ko": "카테고리 관리", + "en": "Category Management" + }, + "description": "카테고리 관리 권한", + "description_raw": { + "ko": "카테고리 관리 권한", + "en": "Category management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 31, + "identifier": "sirsoft-ecommerce.categories.read", + "name": "카테고리 조회", + "name_raw": { + "ko": "카테고리 조회", + "en": "Read Categories" + }, + "description": "카테고리 목록 및 상세 조회", + "description_raw": { + "ko": "카테고리 목록 및 상세 조회", + "en": "Read category list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 32, + "identifier": "sirsoft-ecommerce.categories.create", + "name": "카테고리 생성", + "name_raw": { + "ko": "카테고리 생성", + "en": "Create Category" + }, + "description": "카테고리 생성", + "description_raw": { + "ko": "카테고리 생성", + "en": "Create category" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 33, + "identifier": "sirsoft-ecommerce.categories.update", + "name": "카테고리 수정", + "name_raw": { + "ko": "카테고리 수정", + "en": "Update Category" + }, + "description": "카테고리 수정", + "description_raw": { + "ko": "카테고리 수정", + "en": "Update category" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 34, + "identifier": "sirsoft-ecommerce.categories.delete", + "name": "카테고리 삭제", + "name_raw": { + "ko": "카테고리 삭제", + "en": "Delete Category" + }, + "description": "카테고리 삭제", + "description_raw": { + "ko": "카테고리 삭제", + "en": "Delete category" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 4 + }, + { + "id": 35, + "identifier": "sirsoft-ecommerce.brands", + "name": "브랜드 관리", + "name_raw": { + "ko": "브랜드 관리", + "en": "Brand Management" + }, + "description": "브랜드 관리 권한", + "description_raw": { + "ko": "브랜드 관리 권한", + "en": "Brand management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 36, + "identifier": "sirsoft-ecommerce.brands.read", + "name": "브랜드 조회", + "name_raw": { + "ko": "브랜드 조회", + "en": "Read Brands" + }, + "description": "브랜드 목록 및 상세 조회", + "description_raw": { + "ko": "브랜드 목록 및 상세 조회", + "en": "Read brand list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": "brand", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + }, + { + "id": 37, + "identifier": "sirsoft-ecommerce.brands.create", + "name": "브랜드 생성", + "name_raw": { + "ko": "브랜드 생성", + "en": "Create Brand" + }, + "description": "브랜드 생성", + "description_raw": { + "ko": "브랜드 생성", + "en": "Create brand" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 38, + "identifier": "sirsoft-ecommerce.brands.update", + "name": "브랜드 수정", + "name_raw": { + "ko": "브랜드 수정", + "en": "Update Brand" + }, + "description": "브랜드 수정", + "description_raw": { + "ko": "브랜드 수정", + "en": "Update brand" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": "brand", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + }, + { + "id": 39, + "identifier": "sirsoft-ecommerce.brands.delete", + "name": "브랜드 삭제", + "name_raw": { + "ko": "브랜드 삭제", + "en": "Delete Brand" + }, + "description": "브랜드 삭제", + "description_raw": { + "ko": "브랜드 삭제", + "en": "Delete brand" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": "brand", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 4 + }, + { + "id": 40, + "identifier": "sirsoft-ecommerce.product-notice-templates", + "name": "상품정보제공고시 관리", + "name_raw": { + "ko": "상품정보제공고시 관리", + "en": "Product Notice Template Management" + }, + "description": "상품정보제공고시 템플릿 관리 권한", + "description_raw": { + "ko": "상품정보제공고시 템플릿 관리 권한", + "en": "Product notice template management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 41, + "identifier": "sirsoft-ecommerce.product-notice-templates.read", + "name": "조회", + "name_raw": { + "ko": "조회", + "en": "Read" + }, + "description": "상품정보제공고시 조회", + "description_raw": { + "ko": "상품정보제공고시 조회", + "en": "Read product notice templates" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 42, + "identifier": "sirsoft-ecommerce.product-notice-templates.create", + "name": "생성", + "name_raw": { + "ko": "생성", + "en": "Create" + }, + "description": "상품정보제공고시 생성", + "description_raw": { + "ko": "상품정보제공고시 생성", + "en": "Create product notice template" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 43, + "identifier": "sirsoft-ecommerce.product-notice-templates.update", + "name": "수정", + "name_raw": { + "ko": "수정", + "en": "Update" + }, + "description": "상품정보제공고시 수정", + "description_raw": { + "ko": "상품정보제공고시 수정", + "en": "Update product notice template" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 44, + "identifier": "sirsoft-ecommerce.product-notice-templates.delete", + "name": "삭제", + "name_raw": { + "ko": "삭제", + "en": "Delete" + }, + "description": "상품정보제공고시 삭제", + "description_raw": { + "ko": "상품정보제공고시 삭제", + "en": "Delete product notice template" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 4 + }, + { + "id": 45, + "identifier": "sirsoft-ecommerce.product-common-infos", + "name": "공통정보 관리", + "name_raw": { + "ko": "공통정보 관리", + "en": "Product Common Info Management" + }, + "description": "상품 공통정보 관리 권한", + "description_raw": { + "ko": "상품 공통정보 관리 권한", + "en": "Product common info management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 46, + "identifier": "sirsoft-ecommerce.product-common-infos.read", + "name": "조회", + "name_raw": { + "ko": "조회", + "en": "Read" + }, + "description": "공통정보 조회", + "description_raw": { + "ko": "공통정보 조회", + "en": "Read product common info" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 47, + "identifier": "sirsoft-ecommerce.product-common-infos.create", + "name": "생성", + "name_raw": { + "ko": "생성", + "en": "Create" + }, + "description": "공통정보 생성", + "description_raw": { + "ko": "공통정보 생성", + "en": "Create product common info" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 48, + "identifier": "sirsoft-ecommerce.product-common-infos.update", + "name": "수정", + "name_raw": { + "ko": "수정", + "en": "Update" + }, + "description": "공통정보 수정", + "description_raw": { + "ko": "공통정보 수정", + "en": "Update product common info" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 49, + "identifier": "sirsoft-ecommerce.product-common-infos.delete", + "name": "삭제", + "name_raw": { + "ko": "삭제", + "en": "Delete" + }, + "description": "공통정보 삭제", + "description_raw": { + "ko": "공통정보 삭제", + "en": "Delete product common info" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 4 + }, + { + "id": 50, + "identifier": "sirsoft-ecommerce.settings", + "name": "환경설정", + "name_raw": { + "ko": "환경설정", + "en": "Settings" + }, + "description": "이커머스 환경설정 권한", + "description_raw": { + "ko": "이커머스 환경설정 권한", + "en": "Ecommerce settings permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 51, + "identifier": "sirsoft-ecommerce.settings.read", + "name": "환경설정 조회", + "name_raw": { + "ko": "환경설정 조회", + "en": "View Settings" + }, + "description": "이커머스 환경설정 조회", + "description_raw": { + "ko": "이커머스 환경설정 조회", + "en": "View ecommerce settings" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 52, + "identifier": "sirsoft-ecommerce.settings.update", + "name": "환경설정 수정", + "name_raw": { + "ko": "환경설정 수정", + "en": "Update Settings" + }, + "description": "이커머스 환경설정 수정", + "description_raw": { + "ko": "이커머스 환경설정 수정", + "en": "Update ecommerce settings" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 2 + }, + { + "id": 53, + "identifier": "sirsoft-ecommerce.promotion-coupon", + "name": "쿠폰 관리", + "name_raw": { + "ko": "쿠폰 관리", + "en": "Coupon Management" + }, + "description": "쿠폰 관리 권한", + "description_raw": { + "ko": "쿠폰 관리 권한", + "en": "Coupon management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 54, + "identifier": "sirsoft-ecommerce.promotion-coupon.read", + "name": "쿠폰 조회", + "name_raw": { + "ko": "쿠폰 조회", + "en": "Read Coupons" + }, + "description": "쿠폰 목록 및 상세 조회", + "description_raw": { + "ko": "쿠폰 목록 및 상세 조회", + "en": "Read coupon list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": "coupon", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + }, + { + "id": 55, + "identifier": "sirsoft-ecommerce.promotion-coupon.create", + "name": "쿠폰 생성", + "name_raw": { + "ko": "쿠폰 생성", + "en": "Create Coupon" + }, + "description": "새 쿠폰 등록", + "description_raw": { + "ko": "새 쿠폰 등록", + "en": "Create new coupon" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 56, + "identifier": "sirsoft-ecommerce.promotion-coupon.update", + "name": "쿠폰 수정", + "name_raw": { + "ko": "쿠폰 수정", + "en": "Update Coupon" + }, + "description": "쿠폰 정보 수정", + "description_raw": { + "ko": "쿠폰 정보 수정", + "en": "Update coupon information" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": "coupon", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + }, + { + "id": 57, + "identifier": "sirsoft-ecommerce.promotion-coupon.delete", + "name": "쿠폰 삭제", + "name_raw": { + "ko": "쿠폰 삭제", + "en": "Delete Coupon" + }, + "description": "쿠폰 삭제", + "description_raw": { + "ko": "쿠폰 삭제", + "en": "Delete coupon" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": "coupon", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 4 + }, + { + "id": 58, + "identifier": "sirsoft-ecommerce.shipping-policies", + "name": "배송정책 관리", + "name_raw": { + "ko": "배송정책 관리", + "en": "Shipping Policy Management" + }, + "description": "배송정책 관리 권한", + "description_raw": { + "ko": "배송정책 관리 권한", + "en": "Shipping policy management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 59, + "identifier": "sirsoft-ecommerce.shipping-policies.read", + "name": "배송정책 조회", + "name_raw": { + "ko": "배송정책 조회", + "en": "Read Shipping Policies" + }, + "description": "배송정책 목록 및 상세 조회", + "description_raw": { + "ko": "배송정책 목록 및 상세 조회", + "en": "Read shipping policy list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": "shippingPolicy", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + }, + { + "id": 60, + "identifier": "sirsoft-ecommerce.shipping-policies.create", + "name": "배송정책 생성", + "name_raw": { + "ko": "배송정책 생성", + "en": "Create Shipping Policy" + }, + "description": "새 배송정책 등록", + "description_raw": { + "ko": "새 배송정책 등록", + "en": "Create new shipping policy" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 61, + "identifier": "sirsoft-ecommerce.shipping-policies.update", + "name": "배송정책 수정", + "name_raw": { + "ko": "배송정책 수정", + "en": "Update Shipping Policy" + }, + "description": "배송정책 정보 수정", + "description_raw": { + "ko": "배송정책 정보 수정", + "en": "Update shipping policy information" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": "shippingPolicy", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + }, + { + "id": 62, + "identifier": "sirsoft-ecommerce.shipping-policies.delete", + "name": "배송정책 삭제", + "name_raw": { + "ko": "배송정책 삭제", + "en": "Delete Shipping Policy" + }, + "description": "배송정책 삭제", + "description_raw": { + "ko": "배송정책 삭제", + "en": "Delete shipping policy" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": "shippingPolicy", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 4 + }, + { + "id": 63, + "identifier": "sirsoft-ecommerce.product-labels", + "name": "상품 라벨 관리", + "name_raw": { + "ko": "상품 라벨 관리", + "en": "Product Label Management" + }, + "description": "상품 라벨 관리 권한", + "description_raw": { + "ko": "상품 라벨 관리 권한", + "en": "Product label management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 64, + "identifier": "sirsoft-ecommerce.product-labels.read", + "name": "상품 라벨 조회", + "name_raw": { + "ko": "상품 라벨 조회", + "en": "Read Product Labels" + }, + "description": "상품 라벨 목록 및 상세 조회", + "description_raw": { + "ko": "상품 라벨 목록 및 상세 조회", + "en": "Read product label list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 65, + "identifier": "sirsoft-ecommerce.product-labels.create", + "name": "상품 라벨 생성", + "name_raw": { + "ko": "상품 라벨 생성", + "en": "Create Product Label" + }, + "description": "새 상품 라벨 등록", + "description_raw": { + "ko": "새 상품 라벨 등록", + "en": "Create new product label" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 66, + "identifier": "sirsoft-ecommerce.product-labels.update", + "name": "상품 라벨 수정", + "name_raw": { + "ko": "상품 라벨 수정", + "en": "Update Product Label" + }, + "description": "상품 라벨 정보 수정", + "description_raw": { + "ko": "상품 라벨 정보 수정", + "en": "Update product label information" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 67, + "identifier": "sirsoft-ecommerce.product-labels.delete", + "name": "상품 라벨 삭제", + "name_raw": { + "ko": "상품 라벨 삭제", + "en": "Delete Product Label" + }, + "description": "상품 라벨 삭제", + "description_raw": { + "ko": "상품 라벨 삭제", + "en": "Delete product label" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 4 + }, + { + "id": 68, + "identifier": "sirsoft-ecommerce.identity.policies", + "name": "이커머스 본인인증 정책", + "name_raw": { + "ko": "이커머스 본인인증 정책", + "en": "Ecommerce Identity Policies" + }, + "description": "이커머스 컨텍스트의 본인인증 정책 관리 권한", + "description_raw": { + "ko": "이커머스 컨텍스트의 본인인증 정책 관리 권한", + "en": "Manage identity verification policies in ecommerce context" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 69, + "identifier": "sirsoft-ecommerce.identity.policies.read", + "name": "본인인증 정책 조회", + "name_raw": { + "ko": "본인인증 정책 조회", + "en": "View Identity Policies" + }, + "description": "이커머스 본인인증 정책 조회", + "description_raw": { + "ko": "이커머스 본인인증 정책 조회", + "en": "View ecommerce identity policies" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 70, + "identifier": "sirsoft-ecommerce.identity.policies.update", + "name": "본인인증 정책 수정", + "name_raw": { + "ko": "본인인증 정책 수정", + "en": "Update Identity Policies" + }, + "description": "이커머스 본인인증 정책 수정/추가/삭제", + "description_raw": { + "ko": "이커머스 본인인증 정책 수정/추가/삭제", + "en": "Update, add, delete ecommerce identity policies" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 2 + }, + { + "id": 71, + "identifier": "sirsoft-ecommerce.reviews", + "name": "리뷰 관리", + "name_raw": { + "ko": "리뷰 관리", + "en": "Review Management" + }, + "description": "상품 리뷰 관리 권한", + "description_raw": { + "ko": "상품 리뷰 관리 권한", + "en": "Product review management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 72, + "identifier": "sirsoft-ecommerce.reviews.read", + "name": "리뷰 조회", + "name_raw": { + "ko": "리뷰 조회", + "en": "Read Reviews" + }, + "description": "리뷰 목록 및 상세 조회", + "description_raw": { + "ko": "리뷰 목록 및 상세 조회", + "en": "Read review list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": "review", + "owner_key": "user_id", + "children": [], + "leaf_count": 1 + }, + { + "id": 73, + "identifier": "sirsoft-ecommerce.reviews.update", + "name": "리뷰 처리", + "name_raw": { + "ko": "리뷰 처리", + "en": "Manage Review" + }, + "description": "리뷰 상태 변경, 답변 등록/수정/삭제, 일괄 처리", + "description_raw": { + "ko": "리뷰 상태 변경, 답변 등록/수정/삭제, 일괄 처리", + "en": "Update review status, manage replies, bulk actions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": "review", + "owner_key": "user_id", + "children": [], + "leaf_count": 1 + }, + { + "id": 74, + "identifier": "sirsoft-ecommerce.reviews.delete", + "name": "리뷰 삭제", + "name_raw": { + "ko": "리뷰 삭제", + "en": "Delete Review" + }, + "description": "리뷰 삭제", + "description_raw": { + "ko": "리뷰 삭제", + "en": "Delete review" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": "review", + "owner_key": "user_id", + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 3 + }, + { + "id": 75, + "identifier": "sirsoft-ecommerce.inquiries", + "name": "문의 관리", + "name_raw": { + "ko": "문의 관리", + "en": "Inquiry Management" + }, + "description": "상품 1:1 문의 관리 권한", + "description_raw": { + "ko": "상품 1:1 문의 관리 권한", + "en": "Product inquiry management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 76, + "identifier": "sirsoft-ecommerce.inquiries.update", + "name": "문의 처리", + "name_raw": { + "ko": "문의 처리", + "en": "Manage Inquiry" + }, + "description": "답변 등록/수정/삭제, 비밀글 내용 열람", + "description_raw": { + "ko": "답변 등록/수정/삭제, 비밀글 내용 열람", + "en": "Create, update, and delete inquiry replies; view secret inquiry contents" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 77, + "identifier": "sirsoft-ecommerce.inquiries.delete", + "name": "문의 삭제", + "name_raw": { + "ko": "문의 삭제", + "en": "Delete Inquiry" + }, + "description": "고객이 작성한 문의 자체를 삭제", + "description_raw": { + "ko": "고객이 작성한 문의 자체를 삭제", + "en": "Delete inquiries submitted by customers" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 2 + }, + { + "id": 78, + "identifier": "sirsoft-ecommerce.dashboard", + "name": "대시보드", + "name_raw": { + "ko": "대시보드", + "en": "Dashboard" + }, + "description": "관리자 대시보드 이커머스 영역 조회 권한", + "description_raw": { + "ko": "관리자 대시보드 이커머스 영역 조회 권한", + "en": "Admin dashboard commerce area view permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 79, + "identifier": "sirsoft-ecommerce.dashboard.view", + "name": "대시보드 조회", + "name_raw": { + "ko": "대시보드 조회", + "en": "View Dashboard" + }, + "description": "관리자 대시보드의 이커머스 판매 현황/리뷰/문의 위젯 조회", + "description_raw": { + "ko": "관리자 대시보드의 이커머스 판매 현황/리뷰/문의 위젯 조회", + "en": "View commerce sales/review/inquiry widgets on the admin dashboard" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 1 + }, + { + "id": 88, + "identifier": "sirsoft-ecommerce.mileage", + "name": "마일리지 관리", + "name_raw": { + "ko": "마일리지 관리", + "en": "Mileage Management" + }, + "description": "마일리지 내역 조회 및 수동 지급/차감 권한", + "description_raw": { + "ko": "마일리지 내역 조회 및 수동 지급/차감 권한", + "en": "Mileage history and manual grant/deduct permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 89, + "identifier": "sirsoft-ecommerce.mileage.read", + "name": "마일리지 내역 조회", + "name_raw": { + "ko": "마일리지 내역 조회", + "en": "Read Mileage" + }, + "description": "마일리지 내역 목록 및 상세 조회", + "description_raw": { + "ko": "마일리지 내역 목록 및 상세 조회", + "en": "Read mileage history list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": "mileage-transaction", + "owner_key": "user_id", + "children": [], + "leaf_count": 1 + }, + { + "id": 90, + "identifier": "sirsoft-ecommerce.mileage.manage", + "name": "마일리지 수동 처리", + "name_raw": { + "ko": "마일리지 수동 처리", + "en": "Manage Mileage" + }, + "description": "마일리지 수동 지급/차감 및 일괄 유효기간 연장", + "description_raw": { + "ko": "마일리지 수동 지급/차감 및 일괄 유효기간 연장", + "en": "Manual grant/deduct and bulk expiry extension" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": "mileage-transaction", + "owner_key": "user_id", + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 2 + }, + { + "id": 91, + "identifier": "sirsoft-ecommerce.user-currency", + "name": "회원 결제 통화 관리", + "name_raw": { + "ko": "회원 결제 통화 관리", + "en": "User Currency Management" + }, + "description": "관리자가 회원별 결제 통화를 변경하는 권한", + "description_raw": { + "ko": "관리자가 회원별 결제 통화를 변경하는 권한", + "en": "Permission for admins to change a user's payment currency" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 92, + "identifier": "sirsoft-ecommerce.user-currency.manage", + "name": "회원 결제 통화 변경", + "name_raw": { + "ko": "회원 결제 통화 변경", + "en": "Manage User Currency" + }, + "description": "회원별 결제 통화 변경", + "description_raw": { + "ko": "회원별 결제 통화 변경", + "en": "Change a user's payment currency" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 1 + }, + { + "id": 93, + "identifier": "sirsoft-ecommerce.user-shipping-country", + "name": "회원 배송국가 관리", + "name_raw": { + "ko": "회원 배송국가 관리", + "en": "User Shipping Country Management" + }, + "description": "관리자가 회원별 배송국가를 변경하는 권한", + "description_raw": { + "ko": "관리자가 회원별 배송국가를 변경하는 권한", + "en": "Permission for admins to change a user's shipping country" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 94, + "identifier": "sirsoft-ecommerce.user-shipping-country.manage", + "name": "회원 배송국가 변경", + "name_raw": { + "ko": "회원 배송국가 변경", + "en": "Manage User Shipping Country" + }, + "description": "회원별 배송국가 변경", + "description_raw": { + "ko": "회원별 배송국가 변경", + "en": "Change a user's shipping country" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 1 + } + ], + "leaf_count": 48 + }, + { + "id": 109, + "identifier": "sirsoft-page", + "name": "페이지", + "name_raw": { + "ko": "페이지", + "en": "Page" + }, + "description": "페이지 모듈 권한", + "description_raw": { + "ko": "페이지 모듈 권한", + "en": "Page module permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-page", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 110, + "identifier": "sirsoft-page.pages", + "name": "페이지 관리", + "name_raw": { + "ko": "페이지 관리", + "en": "Page Management" + }, + "description": "페이지 관리 권한", + "description_raw": { + "ko": "페이지 관리 권한", + "en": "Page management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-page", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 111, + "identifier": "sirsoft-page.pages.read", + "name": "페이지 조회", + "name_raw": { + "ko": "페이지 조회", + "en": "View Pages" + }, + "description": "페이지 목록 및 상세 조회", + "description_raw": { + "ko": "페이지 목록 및 상세 조회", + "en": "View page list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-page", + "type": "admin", + "is_assignable": true, + "resource_route_key": "page", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + }, + { + "id": 112, + "identifier": "sirsoft-page.pages.create", + "name": "페이지 생성", + "name_raw": { + "ko": "페이지 생성", + "en": "Create Page" + }, + "description": "새 페이지 생성", + "description_raw": { + "ko": "새 페이지 생성", + "en": "Create new page" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-page", + "type": "admin", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 113, + "identifier": "sirsoft-page.pages.update", + "name": "페이지 수정", + "name_raw": { + "ko": "페이지 수정", + "en": "Update Page" + }, + "description": "페이지 내용 수정", + "description_raw": { + "ko": "페이지 내용 수정", + "en": "Update page content" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-page", + "type": "admin", + "is_assignable": true, + "resource_route_key": "page", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + }, + { + "id": 114, + "identifier": "sirsoft-page.pages.delete", + "name": "페이지 삭제", + "name_raw": { + "ko": "페이지 삭제", + "en": "Delete Page" + }, + "description": "페이지 삭제", + "description_raw": { + "ko": "페이지 삭제", + "en": "Delete page" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-page", + "type": "admin", + "is_assignable": true, + "resource_route_key": "page", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 4 + } + ], + "leaf_count": 4 + } + ] + }, + "user": { + "label": "사용자 권한", + "icon": "user", + "permissions": [ + { + "id": 115, + "identifier": "core", + "name": "코어", + "name_raw": { + "ko": "코어", + "en": "Core" + }, + "description": "코어 시스템 권한", + "description_raw": { + "ko": "코어 시스템 권한", + "en": "Core system permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 155, + "identifier": "core.user-notifications", + "name": "알림 (사용자)", + "name_raw": { + "ko": "알림 (사용자)", + "en": "Notifications (User)" + }, + "description": "사용자용 알림 권한 (사용자 화면에서 본인 알림 관리)", + "description_raw": { + "ko": "사용자용 알림 권한 (사용자 화면에서 본인 알림 관리)", + "en": "User notification permissions (managing own notifications in user UI)" + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "user", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 156, + "identifier": "core.user-notifications.read", + "name": "알림 조회", + "name_raw": { + "ko": "알림 조회", + "en": "View Notifications" + }, + "description": "본인의 알림 목록 및 읽지 않은 수를 조회할 수 있습니다.", + "description_raw": { + "ko": "본인의 알림 목록 및 읽지 않은 수를 조회할 수 있습니다.", + "en": "Can view own notification list and unread count." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "user", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 157, + "identifier": "core.user-notifications.update", + "name": "알림 읽음 처리", + "name_raw": { + "ko": "알림 읽음 처리", + "en": "Mark Notifications Read" + }, + "description": "본인의 알림을 읽음 처리할 수 있습니다.", + "description_raw": { + "ko": "본인의 알림을 읽음 처리할 수 있습니다.", + "en": "Can mark own notifications as read." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "user", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 158, + "identifier": "core.user-notifications.delete", + "name": "알림 삭제", + "name_raw": { + "ko": "알림 삭제", + "en": "Delete Notifications" + }, + "description": "본인의 알림을 삭제할 수 있습니다.", + "description_raw": { + "ko": "본인의 알림을 삭제할 수 있습니다.", + "en": "Can delete own notifications." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "user", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 3 + }, + { + "id": 159, + "identifier": "core.identity", + "name": "본인인증 (사용자)", + "name_raw": { + "ko": "본인인증 (사용자)", + "en": "Identity Verification (User)" + }, + "description": "로그인 사용자의 본인인증 challenge 요청/검증/취소 권한", + "description_raw": { + "ko": "로그인 사용자의 본인인증 challenge 요청/검증/취소 권한", + "en": "Permissions for authenticated users to request/verify/cancel IDV challenges" + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "user", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 160, + "identifier": "core.identity.request", + "name": "IDV 요청", + "name_raw": { + "ko": "IDV 요청", + "en": "Request IDV Challenge" + }, + "description": "본인인증 challenge 를 요청할 수 있습니다.", + "description_raw": { + "ko": "본인인증 challenge 를 요청할 수 있습니다.", + "en": "Can request identity verification challenges." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "user", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 161, + "identifier": "core.identity.verify", + "name": "IDV 검증", + "name_raw": { + "ko": "IDV 검증", + "en": "Verify IDV Challenge" + }, + "description": "본인의 본인인증 challenge 를 검증할 수 있습니다.", + "description_raw": { + "ko": "본인의 본인인증 challenge 를 검증할 수 있습니다.", + "en": "Can verify own identity verification challenges." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "user", + "is_assignable": true, + "resource_route_key": "challenge", + "owner_key": "user_id", + "children": [], + "leaf_count": 1 + }, + { + "id": 162, + "identifier": "core.identity.cancel", + "name": "IDV 취소", + "name_raw": { + "ko": "IDV 취소", + "en": "Cancel IDV Challenge" + }, + "description": "본인의 본인인증 challenge 를 취소할 수 있습니다.", + "description_raw": { + "ko": "본인의 본인인증 challenge 를 취소할 수 있습니다.", + "en": "Can cancel own identity verification challenges." + }, + "extension_type": "core", + "extension_identifier": "core", + "type": "user", + "is_assignable": true, + "resource_route_key": "challenge", + "owner_key": "user_id", + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 3 + } + ], + "leaf_count": 6 + }, + { + "id": 3, + "identifier": "sirsoft-board", + "name": "게시판", + "name_raw": { + "ko": "게시판", + "en": "Board" + }, + "description": "게시판 모듈 권한", + "description_raw": { + "ko": "게시판 모듈 권한", + "en": "Board module permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 4, + "identifier": "sirsoft-board.apidoc-sample-board", + "name": "API 문서 샘플 게시판 게시판", + "name_raw": { + "ko": "API 문서 샘플 게시판 게시판", + "en": "API Doc Sample Board board" + }, + "description": "API 문서 샘플 게시판 게시판 권한", + "description_raw": { + "ko": "API 문서 샘플 게시판 게시판 권한", + "en": "API Doc Sample Board board permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 13, + "identifier": "sirsoft-board.apidoc-sample-board.posts.read", + "name": "게시글 조회", + "name_raw": { + "ko": "게시글 조회", + "en": "View Posts" + }, + "description": "게시글 목록 및 상세 조회", + "description_raw": { + "ko": "게시글 목록 및 상세 조회", + "en": "View post list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "user", + "is_assignable": true, + "resource_route_key": "post", + "owner_key": "user_id", + "children": [], + "leaf_count": 1 + }, + { + "id": 14, + "identifier": "sirsoft-board.apidoc-sample-board.posts.write", + "name": "게시글 작성/수정/삭제", + "name_raw": { + "ko": "게시글 작성/수정/삭제", + "en": "Create/Edit/Delete Posts" + }, + "description": "게시글 작성 및 본인 글 수정/삭제", + "description_raw": { + "ko": "게시글 작성 및 본인 글 수정/삭제", + "en": "Create posts and edit/delete own posts" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "user", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 15, + "identifier": "sirsoft-board.apidoc-sample-board.posts.read-secret", + "name": "비밀글 조회", + "name_raw": { + "ko": "비밀글 조회", + "en": "View Secret Posts" + }, + "description": "비밀글 조회", + "description_raw": { + "ko": "비밀글 조회", + "en": "View secret posts" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "user", + "is_assignable": true, + "resource_route_key": "post", + "owner_key": "user_id", + "children": [], + "leaf_count": 1 + }, + { + "id": 16, + "identifier": "sirsoft-board.apidoc-sample-board.comments.read", + "name": "댓글 조회", + "name_raw": { + "ko": "댓글 조회", + "en": "View Comments" + }, + "description": "댓글 목록 조회", + "description_raw": { + "ko": "댓글 목록 조회", + "en": "View comment list" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "user", + "is_assignable": true, + "resource_route_key": "comment", + "owner_key": "user_id", + "children": [], + "leaf_count": 1 + }, + { + "id": 17, + "identifier": "sirsoft-board.apidoc-sample-board.comments.write", + "name": "댓글 작성/수정/삭제", + "name_raw": { + "ko": "댓글 작성/수정/삭제", + "en": "Create/Edit/Delete Comments" + }, + "description": "댓글 작성 및 본인 댓글 수정/삭제", + "description_raw": { + "ko": "댓글 작성 및 본인 댓글 수정/삭제", + "en": "Create comments and edit/delete own comments" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "user", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 18, + "identifier": "sirsoft-board.apidoc-sample-board.attachments.upload", + "name": "파일 업로드/삭제", + "name_raw": { + "ko": "파일 업로드/삭제", + "en": "Upload/Delete Files" + }, + "description": "게시글에 파일 첨부 및 삭제", + "description_raw": { + "ko": "게시글에 파일 첨부 및 삭제", + "en": "Attach and delete files to posts" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "user", + "is_assignable": true, + "resource_route_key": "attachment", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + }, + { + "id": 19, + "identifier": "sirsoft-board.apidoc-sample-board.attachments.download", + "name": "파일 다운로드", + "name_raw": { + "ko": "파일 다운로드", + "en": "Download Files" + }, + "description": "첨부파일 다운로드", + "description_raw": { + "ko": "첨부파일 다운로드", + "en": "Download attached files" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "user", + "is_assignable": true, + "resource_route_key": "attachment", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + }, + { + "id": 20, + "identifier": "sirsoft-board.apidoc-sample-board.manager", + "name": "게시판 관리 (사용자)", + "name_raw": { + "ko": "게시판 관리 (사용자)", + "en": "Manage Board (User)" + }, + "description": "사용자 페이지에서 게시판 관리", + "description_raw": { + "ko": "사용자 페이지에서 게시판 관리", + "en": "Manage board in user page" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "type": "user", + "is_assignable": true, + "resource_route_key": "board", + "owner_key": "created_by", + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 8 + } + ], + "leaf_count": 8 + }, + { + "id": 21, + "identifier": "sirsoft-ecommerce", + "name": "이커머스", + "name_raw": { + "ko": "이커머스", + "en": "Ecommerce" + }, + "description": "이커머스 모듈 권한", + "description_raw": { + "ko": "이커머스 모듈 권한", + "en": "Ecommerce module permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "admin", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 80, + "identifier": "sirsoft-ecommerce.user-products", + "name": "사용자 상품", + "name_raw": { + "ko": "사용자 상품", + "en": "User Products" + }, + "description": "사용자 상품 접근 권한 (블랙컨슈머 차단용)", + "description_raw": { + "ko": "사용자 상품 접근 권한 (블랙컨슈머 차단용)", + "en": "User product access permissions (for blocking malicious consumers)" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "user", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 81, + "identifier": "sirsoft-ecommerce.user-products.read", + "name": "상품 조회", + "name_raw": { + "ko": "상품 조회", + "en": "View Products" + }, + "description": "상품 목록 및 상세 조회", + "description_raw": { + "ko": "상품 목록 및 상세 조회", + "en": "View product list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "user", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 1 + }, + { + "id": 82, + "identifier": "sirsoft-ecommerce.user-orders", + "name": "사용자 주문", + "name_raw": { + "ko": "사용자 주문", + "en": "User Orders" + }, + "description": "사용자 주문 관련 권한 (블랙컨슈머 차단용)", + "description_raw": { + "ko": "사용자 주문 관련 권한 (블랙컨슈머 차단용)", + "en": "User order permissions (for blocking malicious consumers)" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "user", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 83, + "identifier": "sirsoft-ecommerce.user-orders.create", + "name": "주문하기", + "name_raw": { + "ko": "주문하기", + "en": "Create Order" + }, + "description": "주문 생성", + "description_raw": { + "ko": "주문 생성", + "en": "Create a new order" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "user", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 84, + "identifier": "sirsoft-ecommerce.user-orders.cancel", + "name": "주문 취소", + "name_raw": { + "ko": "주문 취소", + "en": "Cancel Order" + }, + "description": "주문 취소 요청", + "description_raw": { + "ko": "주문 취소 요청", + "en": "Request order cancellation" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "user", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + }, + { + "id": 85, + "identifier": "sirsoft-ecommerce.user-orders.confirm", + "name": "구매확정", + "name_raw": { + "ko": "구매확정", + "en": "Confirm Purchase" + }, + "description": "주문 상품 구매확정", + "description_raw": { + "ko": "주문 상품 구매확정", + "en": "Confirm purchase of order item" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "user", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 3 + }, + { + "id": 86, + "identifier": "sirsoft-ecommerce.user-reviews", + "name": "사용자 리뷰", + "name_raw": { + "ko": "사용자 리뷰", + "en": "User Reviews" + }, + "description": "사용자 리뷰 관련 권한 (블랙컨슈머 차단용)", + "description_raw": { + "ko": "사용자 리뷰 관련 권한 (블랙컨슈머 차단용)", + "en": "User review permissions (for blocking malicious consumers)" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "user", + "is_assignable": false, + "resource_route_key": null, + "owner_key": null, + "children": [ + { + "id": 87, + "identifier": "sirsoft-ecommerce.user-reviews.write", + "name": "리뷰 작성", + "name_raw": { + "ko": "리뷰 작성", + "en": "Write Review" + }, + "description": "상품 리뷰 작성", + "description_raw": { + "ko": "상품 리뷰 작성", + "en": "Write product review" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "type": "user", + "is_assignable": true, + "resource_route_key": null, + "owner_key": null, + "children": [], + "leaf_count": 1 + } + ], + "leaf_count": 1 + } + ], + "leaf_count": 5 + } + ] + } + }, + "types": [ + "admin", + "user" + ], + "0": "... (총 4건 중 2건 표시)" + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.permissions.read`)이 없는 경우 | + + + +**설명** + +시스템의 전체 권한을 계층형 트리로 조회한다. 역할 생성·편집 화면(`admin_role_form.json`)의 권한 선택 트리를 채우는 데 사용된다. 권한은 모듈 → 카테고리 → 개별 권한 순으로 중첩되며(각 노드의 `children`), 코어 권한이 먼저 오도록 정렬된다. 응답의 `permissions` 는 권한 타입별(admin/user)로 그룹화되고, 각 그룹은 `label`·`icon` 메타와 필터링된 권한 트리를 담는다. 함께 반환되는 `types`(권한 타입 목록), `default_type`(기본 탭), `scope_options`(scope_type 선택지: 전체/역할/본인)는 편집 UI 구성에 쓰인다. 리프 노드만 실제 부여 가능한 권한(`is_assignable`)이다. `core.permissions.read` 권한이 필요하다. + + diff --git a/docs/backend/api/plugins.md b/docs/backend/api/plugins.md new file mode 100644 index 00000000..745557a3 --- /dev/null +++ b/docs/backend/api/plugins.md @@ -0,0 +1,1413 @@ +# Plugins API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Plugins 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/admin/plugins + +- **라우트명**: `api.admin.plugins.index` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\PluginController@index` +- **인증/권한**: `auth:sanctum` + `permission:core.plugins.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| search | query | string | 아니오 | max 255 | 검색어 (지정한 검색 대상 필드에서 부분 일치) | +| filters | query | array | 아니오 | max 10 | 추가 필터 조건 맵 (필드별 조건) | +| status | query | string | 아니오 | `installed`, `uninstalled`, `active`, `inactive` | 상태 필터 (해당 상태의 항목만 조회) | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | +| include_hidden | query | boolean | 아니오 | — | 숨김 확장 포함 여부 (manifest `hidden=true` 로 목록에서 감춰진 플러그인까지 조회) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.plugin.index_validation_rules`). + +**요청 예시** + +```http +GET /api/admin/plugins?search=%EC%98%88%EC%8B%9C%EA%B0%92&filters=%EC%98%88%EC%8B%9C%EA%B0%92&status=installed&per_page=1&page=1&include_hidden=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | null | `null` | 기본 키 (내부 식별자) | +| identifier | string | `sirsoft-ckeditor5` | 플러그인 고유 식별자 (vendor-plugin 형식) | +| vendor | string | `sirsoft` | 벤더/개발자명 | +| name | string | `CKEditor 5 WYSIWYG 에디터` | 플러그인 이름 (다국어 JSON) | +| version | string | `1.0.0` | 플러그인 버전 | +| description | string | `CKEditor 5를 이용한 WYSIWYG 에디터 플러그인입니다. …` | 플러그인 설명 (다국어 JSON) | +| dependencies | array | `[]` | 의존하는 확장 맵 (manifest 파생 — {modules, plugins}) | +| permissions | array | `[]` | 연결된 권한 목록 (id/identifier/name — 역할 경유 권한 관계 파생) | +| roles | array | `[]` | 플러그인이 정의한 역할 목록 (manifest 파생 — 설치 시 시드되는 역할) | +| config | array | `[]` | 플러그인 설정 값 (manifest config 정의 기반 현재 설정 맵) | +| hooks | array | `[]` | 훅 설정 정보 | +| status | string | `active` | 상태 (active: 활성화, inactive: 비활성화, installing: 설치 중, uninstalling: 제거 중, updating: 업데이트 중) | +| is_installed | boolean | `false` | installed 여부 | +| has_settings | boolean | `true` | settings 여부 | +| settings_route | string | `/admin/plugins/sirsoft-ckeditor5/sett…` | 설정 페이지 경로 (설정 UI 진입 라우트, 설정 미제공 시 null) | +| assets | object | `{"js":"\/api\/plugins\/assets\/sirsoft-ckeditor5\/dist\/j…` | 프론트엔드 에셋 매니페스트 (manifest 파생 — js/css 진입점·로딩 전략) | +| update_available | boolean | `false` | 최신 버전 대비 업데이트 가능 여부 | +| update_source | null | `null` | 업데이트 감지 출처 (github, bundled 등) | +| latest_version | string | `1.0.0` | 감지된 최신 배포 버전 | +| file_version | string | `1.0.0` | 설치된 파일의 manifest 버전 | +| github_url | string | `https://github.com/gnuboard/g7-plugin…` | GitHub 저장소 URL | +| github_changelog_url | string | `https://github.com/gnuboard/g7-plugin…` | GitHub 변경 내역 URL | +| is_pending | boolean | `false` | _pending 대기소에 있어 설치 대기 중인지 여부 | +| is_bundled | boolean | `false` | 코어에 선탑재된 번들 확장인지 여부 | +| deactivated_reason | null | `null` | 비활성화 사유: manual(사용자 수동) \| incompatible_core(코어 버전 호환성) \| null(active) | +| deactivated_at | null | `null` | deactivated 일시 | +| incompatible_required_version | null | `null` | 요구 코어 버전 미충족 시 필요한 버전 (호환되면 null) | +| abilities | object | `{"can_install":true,"can_activate":true,"can_uninstall":t…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "플러그인 목록을 성공적으로 가져왔습니다.", + "data": { + "data": [ + { + "id": null, + "identifier": "sirsoft-ckeditor5", + "vendor": "sirsoft", + "name": "CKEditor 5 WYSIWYG 에디터", + "version": "1.0.0", + "description": "CKEditor 5를 이용한 WYSIWYG 에디터 플러그인입니다. 플러그인 설치만으로 기존 HtmlEditor가 교체됩니다.", + "dependencies": [], + "permissions": [], + "roles": [], + "config": [], + "hooks": [], + "status": "uninstalled", + "is_installed": false, + "has_settings": true, + "settings_route": "/admin/plugins/sirsoft-ckeditor5/settings", + "assets": { + "js": "/api/plugins/assets/sirsoft-ckeditor5/dist/js/plugin.iife.js", + "css": null, + "priority": 100 + }, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": null, + "github_url": null, + "github_changelog_url": null, + "is_pending": false, + "is_bundled": false, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true + } + }, + { + "id": null, + "identifier": "sirsoft-daum_postcode", + "vendor": "sirsoft", + "name": "Daum 우편번호", + "version": "1.0.0", + "description": "Daum 우편번호 서비스를 통한 주소 검색 기능을 제공하는 플러그인입니다. API 키 없이 무료로 사용할 수 있습니다.", + "dependencies": [], + "permissions": [], + "roles": [], + "config": [], + "hooks": [], + "status": "uninstalled", + "is_installed": false, + "has_settings": true, + "settings_route": "/admin/plugins/sirsoft-daum_postcode/settings", + "assets": { + "js": "/api/plugins/assets/sirsoft-daum_postcode/dist/js/plugin.iife.js", + "css": null, + "priority": 100 + }, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": null, + "github_url": null, + "github_changelog_url": null, + "is_pending": false, + "is_bundled": false, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true + } + }, + "... (총 9건 중 2건 표시)" + ], + "pagination": { + "total": 9, + "current_page": 1, + "last_page": 1, + "per_page": 25 + }, + "meta": { + "total_plugins": 9, + "active_plugins": 0, + "inactive_plugins": 0, + "installed_plugins": 0, + "uninstalled_plugins": 9 + }, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 설치된 플러그인과 미설치 플러그인을 모두 포함한 전체 플러그인 목록을 페이지네이션으로 조회합니다. `search` 는 이름·식별자·설명·벤더에 대한 OR 검색이고 `filters` 는 AND 조건으로 적용됩니다. `core.plugins.read` 권한이 필요하며, 응답의 `abilities` 는 현재 사용자의 install/activate/uninstall 권한 보유 여부를 담습니다. 관리자 플러그인 관리 화면의 목록 그리드를 구성하는 기본 엔드포인트입니다. + + +### POST /api/admin/plugins/activate + +- **라우트명**: `api.admin.plugins.activate` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\PluginController@activate` +- **인증/권한**: `auth:sanctum` + `permission:core.plugins.activate` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| plugin_name | body | string | 예 | max 255 | plugin 이름 (식별자) | +| force | body | boolean | 아니오 | — | 강제 실행 여부 (안전 확인/선행 검사 우회) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.plugin.activate_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/plugins/activate HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "plugin_name": "예시 이름", + "force": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.plugins.activate`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 설치된 플러그인을 활성화합니다. `core.plugins.activate` 권한이 필요합니다. `force` 없이 호출했을 때 필요한 의존 확장이 충족되지 않으면 409 응답으로 `missing_modules`·`missing_plugins` 목록과 함께 경고를 반환하므로, 사용자 확인 후 `force: true` 로 재요청해야 합니다. 재활성화 시 cascade 로 함께 비활성화됐던 번들 언어팩 목록이 `pending_language_packs` 로 응답에 포함됩니다. + + +### POST /api/admin/plugins/check-updates + +- **라우트명**: `api.admin.plugins.check-updates` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\PluginController@checkUpdates` +- **인증/권한**: `auth:sanctum` + `permission:core.plugins.install` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/admin/plugins/check-updates HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.plugins.install`)이 없는 경우 | + + + +**설명** 설치된 모든 플러그인에 대해 GitHub·번들 소스를 조회하여 새 버전 배포 여부를 일괄 확인합니다. `core.plugins.install` 권한이 필요합니다. 파라미터 없이 호출하며, 각 플러그인의 업데이트 가능 여부와 감지된 최신 버전 정보를 반환합니다. 플러그인 목록 화면 진입 시 업데이트 뱃지를 갱신하는 용도로 사용됩니다. + + +### POST /api/admin/plugins/deactivate + +- **라우트명**: `api.admin.plugins.deactivate` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\PluginController@deactivate` +- **인증/권한**: `auth:sanctum` + `permission:core.plugins.activate` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| plugin_name | body | string | 예 | max 255 | plugin 이름 (식별자) | +| force | body | boolean | 아니오 | — | 강제 실행 여부 (안전 확인/선행 검사 우회) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.plugin.deactivate_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/plugins/deactivate HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "plugin_name": "예시 이름", + "force": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.plugins.activate`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 활성 플러그인을 비활성화합니다. `core.plugins.activate` 권한이 필요합니다. `force` 없이 호출했을 때 이 플러그인에 의존하는 템플릿·모듈·플러그인이 있으면 409 응답으로 `dependent_templates`·`dependent_modules`·`dependent_plugins` 목록과 함께 경고를 반환합니다. 의존 관계 확인 후 `force: true` 로 강제 비활성화할 수 있습니다. + + +### POST /api/admin/plugins/install + +- **라우트명**: `api.admin.plugins.install` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\PluginController@install` +- **인증/권한**: `auth:sanctum` + `permission:core.plugins.install` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| plugin_name | body | string | 예 | max 255 | plugin 이름 (식별자) | +| vendor_mode | body | string | 아니오 | `auto`, `composer`, `bundled` | 벤더 설치 모드 (auto/composer/bundled) | +| dependencies | body | array | 아니오 | — | 함께 설치할 의존 확장 목록 (install-preview 응답 기반 사용자 선택 — 원소 type: module\|plugin, identifier) | +| language_packs | body | array | 아니오 | — | 함께 설치할 번들 언어팩 식별자 목록 (best-effort cascade 2단계) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.plugin.install_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/plugins/install HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "plugin_name": "예시 이름", + "vendor_mode": "auto", + "dependencies": [ + "예시값" + ], + "language_packs": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.plugins.install`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** `_pending`·`_bundled` 대기소에 있는 플러그인을 활성 디렉토리로 설치합니다. `core.plugins.install` 권한이 필요합니다. `vendor_mode` 로 Composer 의존성 설치 방식을(auto/composer/bundled) 지정하며, 요청 본문의 `dependencies` 로 선택한 의존 확장을 먼저 설치(cascade 1단계, 실패 시 전체 중단)한 뒤 `language_packs` 로 지정한 번들 언어팩을 best-effort 로 함께 설치합니다(cascade 2단계). 언어팩 설치 실패는 응답의 `language_pack_failures` 에 담겨 반환됩니다. + + +### POST /api/admin/plugins/install-from-file + +- **라우트명**: `api.admin.plugins.install-from-file` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\PluginController@installFromFile` +- **인증/권한**: `auth:sanctum` + `permission:core.plugins.install` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| file | body | file | 예 | max 51200 | 업로드 파일 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.plugin.install_from_file_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/plugins/install-from-file HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: multipart/form-data; boundary=----G7ExampleBoundary + +------G7ExampleBoundary +Content-Disposition: form-data; name="file"; filename="example.pdf" +Content-Type: application/octet-stream + +(바이너리 파일 내용) +------G7ExampleBoundary-- +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.plugins.install`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 업로드된 ZIP 파일에서 플러그인을 설치합니다. `core.plugins.install` 권한이 필요하며, 파일은 최대 50MB(51200KB)까지 허용됩니다. ZIP 압축 해제 후 plugin.json 검증을 거쳐 설치하며, 성공 시 201 상태로 설치된 플러그인 정보를 반환합니다. 설치 전 manifest 만 미리 확인하려면 `manifest-preview` 를 먼저 호출하는 것이 안전합니다. + + +### POST /api/admin/plugins/install-from-github + +- **라우트명**: `api.admin.plugins.install-from-github` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\PluginController@installFromGithub` +- **인증/권한**: `auth:sanctum` + `permission:core.plugins.install` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| github_url | body | string | 예 | — | GitHub 저장소 URL | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.plugin.install_from_github_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/plugins/install-from-github HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "github_url": "https://example.com" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.plugins.install`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** GitHub 저장소 URL 에서 플러그인을 내려받아 설치합니다. `core.plugins.install` 권한이 필요합니다. `github_url` 로 지정한 공개 저장소의 릴리스/소스를 받아 압축 해제·검증 후 설치하며, 성공 시 201 상태로 설치된 플러그인 정보를 반환합니다. + + +### GET /api/admin/plugins/installed + +- **라우트명**: `api.admin.plugins.installed` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\PluginController@installed` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/plugins/installed HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | null | `null` | 기본 키 (내부 식별자) | +| identifier | string | `sirsoft-ckeditor5` | 플러그인 고유 식별자 (vendor-plugin 형식) | +| vendor | string | `sirsoft` | 벤더/개발자명 | +| name | string | `CKEditor 5 WYSIWYG 에디터` | 플러그인 이름 (다국어 JSON) | +| version | string | `1.0.0` | 플러그인 버전 | +| description | string | `CKEditor 5를 이용한 WYSIWYG 에디터 플러그인입니다. …` | 플러그인 설명 (다국어 JSON) | +| dependencies | array | `[]` | 의존하는 확장 맵 (manifest 파생 — {modules, plugins}) | +| permissions | array | `[]` | 연결된 권한 목록 (id/identifier/name — 역할 경유 권한 관계 파생) | +| roles | array | `[]` | 플러그인이 정의한 역할 목록 (manifest 파생 — 설치 시 시드되는 역할) | +| config | array | `[]` | 플러그인 설정 값 (manifest config 정의 기반 현재 설정 맵) | +| hooks | array | `[]` | 훅 설정 정보 | +| status | string | `active` | 상태 (active: 활성화, inactive: 비활성화, installing: 설치 중, uninstalling: 제거 중, updating: 업데이트 중) | +| is_installed | boolean | `false` | installed 여부 | +| has_settings | boolean | `true` | settings 여부 | +| settings_route | string | `/admin/plugins/sirsoft-ckeditor5/sett…` | 설정 페이지 경로 (설정 UI 진입 라우트, 설정 미제공 시 null) | +| assets | object | `{"js":"\/api\/plugins\/assets\/sirsoft-ckeditor5\/dist\/j…` | 프론트엔드 에셋 매니페스트 (manifest 파생 — js/css 진입점·로딩 전략) | +| update_available | boolean | `false` | 최신 버전 대비 업데이트 가능 여부 | +| update_source | null | `null` | 업데이트 감지 출처 (github, bundled 등) | +| latest_version | string | `1.0.0` | 감지된 최신 배포 버전 | +| file_version | string | `1.0.0` | 설치된 파일의 manifest 버전 | +| github_url | string | `https://github.com/gnuboard/g7-plugin…` | GitHub 저장소 URL | +| github_changelog_url | string | `https://github.com/gnuboard/g7-plugin…` | GitHub 변경 내역 URL | +| is_pending | boolean | `false` | _pending 대기소에 있어 설치 대기 중인지 여부 | +| is_bundled | boolean | `false` | 코어에 선탑재된 번들 확장인지 여부 | +| deactivated_reason | null | `null` | 비활성화 사유: manual(사용자 수동) \| incompatible_core(코어 버전 호환성) \| null(active) | +| deactivated_at | null | `null` | deactivated 일시 | +| incompatible_required_version | null | `null` | 요구 코어 버전 미충족 시 필요한 버전 (호환되면 null) | +| abilities | object | `{"can_install":true,"can_activate":true,"can_uninstall":t…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "플러그인 목록을 성공적으로 가져왔습니다.", + "data": { + "data": [], + "meta": { + "total_plugins": 0, + "active_plugins": 0, + "inactive_plugins": 0, + "installed_plugins": 0, + "uninstalled_plugins": 0 + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** 현재 설치된 플러그인만 조회합니다(미설치 항목 제외). 이 엔드포인트는 세부 권한 미들웨어 없이 `auth:sanctum` 인증만 요구하므로, 다른 화면이 활성/설치된 플러그인 목록을 참조할 때 사용하는 경량 조회 API 입니다. 페이지네이션 없이 설치된 항목 배열을 반환합니다. + + +### POST /api/admin/plugins/manifest-preview + +- **라우트명**: `api.admin.plugins.manifest-preview` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\PluginController@manifestPreview` +- **인증/권한**: `auth:sanctum` + `permission:core.plugins.install` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| file | body | file | 예 | max 51200 | 업로드 파일 | + +**요청 예시** + +```http +POST /api/admin/plugins/manifest-preview HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: multipart/form-data; boundary=----G7ExampleBoundary + +------G7ExampleBoundary +Content-Disposition: form-data; name="file"; filename="example.pdf" +Content-Type: application/octet-stream + +(바이너리 파일 내용) +------G7ExampleBoundary-- +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.plugins.install`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 업로드된 ZIP 파일의 plugin.json manifest 와 검증 결과만 추출합니다(실제 설치는 수행하지 않음). `core.plugins.install` 권한이 필요하며 파일은 최대 50MB 까지 허용됩니다. 설치 모달에서 사용자가 파일 선택 직후 manifest 유효성과 검증 실패 사유를 미리 확인하는 용도입니다. 검증 오류 시 422 로 사유를 반환합니다. + + +### POST /api/admin/plugins/refresh-layouts + +- **라우트명**: `api.admin.plugins.refresh-layouts` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\PluginController@refreshLayouts` +- **인증/권한**: `auth:sanctum` + `permission:core.plugins.activate` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| plugin_name | body | string | 예 | max 255 | plugin 이름 (식별자) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.plugin.refresh_layouts_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/plugins/refresh-layouts HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "plugin_name": "예시 이름" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.plugins.activate`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 플러그인의 레이아웃 파일을 다시 읽어 DB 에 동기화합니다. `core.plugins.activate` 권한이 필요합니다. 파일에서 변경된 레이아웃은 갱신되고 삭제된 레이아웃은 DB 에서도 제거되며, 응답으로 created/updated/deleted/unchanged 건수를 반환합니다. 플러그인의 `_bundled` 레이아웃 JSON 을 수정한 뒤 재빌드 없이 반영할 때 사용합니다. + + +### DELETE /api/admin/plugins/uninstall + +- **라우트명**: `api.admin.plugins.uninstall` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\PluginController@uninstall` +- **인증/권한**: `auth:sanctum` + `permission:core.plugins.uninstall` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| plugin_name | query | string | 예 | max 255 | plugin 이름 (식별자) | +| delete_data | query | boolean | 아니오 | — | 데이터 삭제 여부 (true 시 플러그인이 생성한 DB 데이터까지 함께 삭제, 미지정 시 데이터 보존) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.plugin.uninstall_validation_rules`). + +**요청 예시** + +```http +DELETE /api/admin/plugins/uninstall?plugin_name=%EC%98%88%EC%8B%9C%20%EC%9D%B4%EB%A6%84&delete_data=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.plugins.uninstall`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 플러그인을 시스템에서 제거합니다. `core.plugins.uninstall` 권한이 필요합니다. 활성 디렉토리만 삭제하고 `_bundled` 원본은 보존합니다. `delete_data: true` 인 경우 플러그인이 생성한 DB 데이터까지 함께 삭제하며, 기본값은 데이터 보존입니다. 삭제될 데이터 범위는 사전에 `uninstall-info` 로 확인할 수 있습니다. + + +### GET /api/admin/plugins/{identifier}/changelog + +- **라우트명**: `api.admin.plugins.changelog` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\PluginController@changelog` +- **인증/권한**: `auth:sanctum` + `permission:core.plugins.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | +| source | query | string | 아니오 | `active`, `bundled`, `github` | 변경 내역 조회 출처 (active: 활성 설치본, bundled: 번들 원본, github: 원격 저장소) | +| from_version | query | string | 아니오 | — | 시작 버전 (범위 하한) | +| to_version | query | string | 아니오 | — | 대상 버전 (범위 상한) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_rules`). + +**요청 예시** + +```http +GET /api/admin/plugins/{identifier}/changelog?source=active&from_version=%EC%98%88%EC%8B%9C%EA%B0%92&to_version=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 특정 플러그인의 변경 내역(CHANGELOG)을 조회합니다. `core.plugins.read` 권한이 필요합니다. `source` 로 조회 출처를(active: 활성 설치본, bundled: 번들 원본, github: 원격 저장소) 선택하고, `from_version`·`to_version` 으로 버전 구간을 좁힐 수 있습니다. 업데이트 전 사용자에게 변경 사항을 안내하는 데 사용됩니다. + + +### GET /api/admin/plugins/{identifier}/dependent-templates + +- **라우트명**: `api.admin.plugins.dependent-templates` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\PluginController@dependentTemplates` +- **인증/권한**: `auth:sanctum` + `permission:core.plugins.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/admin/plugins/{identifier}/dependent-templates HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 이 플러그인에 의존하는 템플릿 목록을 조회합니다. `core.plugins.read` 권한이 필요합니다. 응답으로 의존 템플릿 배열과 총 개수를 반환하며, 플러그인 비활성화·제거 전 영향을 받는 템플릿을 사용자에게 미리 알리는 데 사용됩니다. + + +### GET /api/admin/plugins/{identifier}/license + +- **라우트명**: `api.admin.plugins.license` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\PluginController@license` +- **인증/권한**: `auth:sanctum` + `permission:core.plugins.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/admin/plugins/{identifier}/license HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 플러그인에 포함된 라이선스 파일의 원문 내용을 반환합니다. `core.plugins.read` 권한이 필요합니다. `identifier` 는 소문자·숫자·하이픈·언더스코어 형식만 허용되며 형식에 맞지 않거나 라이선스 파일이 없으면 404 를 반환합니다. 라이선스 고지 화면에 전문을 표시하는 용도입니다. + + +### GET /api/admin/plugins/{identifier}/settings + +- **라우트명**: `api.admin.plugins.settings.show` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\PluginSettingsController@show` +- **인증/권한**: `auth:sanctum` + `permission:core.plugins.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/admin/plugins/{identifier}/settings HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 플러그인의 현재 설정 값을 조회합니다. `core.plugins.read` 권한이 필요합니다. 저장된 설정이 없거나 플러그인을 찾을 수 없으면 404 를 반환합니다. 설정 페이지 진입 시 폼의 현재 값을 채우는 용도이며, 폼 스키마/UI 구성은 별도의 `settings/layout` 엔드포인트에서 조회합니다. + + +### PUT /api/admin/plugins/{identifier}/settings + +- **라우트명**: `api.admin.plugins.settings.update` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\PluginSettingsController@update` +- **인증/권한**: `auth:sanctum` + `permission:core.plugins.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.plugin_settings.update_rules`). + +**요청 예시** + +```http +PUT /api/admin/plugins/{identifier}/settings HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.plugins.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 플러그인의 설정 값을 저장합니다. `core.plugins.update` 권한이 필요합니다. 검증된 값을 우선 사용하되, PluginManager 에 등록되지 않아 검증 규칙이 없는 플러그인의 경우 요청 본문 전체(`all()`)를 저장합니다. 저장 실패 시 500 을 반환하고, 성공 시 갱신된 설정 값을 함께 반환합니다. + + +### GET /api/admin/plugins/{identifier}/settings/layout + +- **라우트명**: `api.admin.plugins.settings.layout` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\PluginSettingsController@layout` +- **인증/권한**: `auth:sanctum` + `permission:core.plugins.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/admin/plugins/{identifier}/settings/layout HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 플러그인 설정 페이지의 UI 구성과 설정 스키마(레이아웃)를 조회합니다. `core.plugins.read` 권한이 필요합니다. 레이아웃이 정의되지 않았거나 플러그인을 찾을 수 없으면 404 를 반환합니다. 설정 값 조회(`settings`)와 짝을 이루어 설정 화면을 렌더링하는 데 사용됩니다. + + +### GET /api/admin/plugins/{pluginName} + +- **라우트명**: `api.admin.plugins.show` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\PluginController@show` +- **인증/권한**: `auth:sanctum` + `permission:core.plugins.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| pluginName | path | string | 예 | — | 대상 plugin의 이름 (식별자) | + +**요청 예시** + +```http +GET /api/admin/plugins/{pluginName} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 특정 플러그인의 상세 정보를 조회합니다. `core.plugins.read` 권한이 필요합니다. 목록보다 자세한 `toDetailArray()` 형태를 반환하며, 이 플러그인이 지원하는 번들 언어팩 정보가 함께 주입됩니다. 플러그인을 찾을 수 없으면 404 를 반환합니다. + + +### GET /api/admin/plugins/{pluginName}/check-modified-layouts + +- **라우트명**: `api.admin.plugins.check-modified-layouts` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\PluginController@checkModifiedLayouts` +- **인증/권한**: `auth:sanctum` + `permission:core.plugins.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| pluginName | path | string | 예 | — | 대상 plugin의 이름 (식별자) | + +**요청 예시** + +```http +GET /api/admin/plugins/{pluginName}/check-modified-layouts HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 특정 플러그인에서 사용자가 수정한 레이아웃이 있는지 확인합니다. `core.plugins.read` 권한이 필요합니다. 업데이트 실행 전 이 정보를 조회하여 레이아웃 전략(overwrite: 새 버전으로 교체, keep: 사용자 수정본 유지) 선택을 안내하는 데 사용됩니다. + + +### GET /api/admin/plugins/{pluginName}/install-preview + +- **라우트명**: `api.admin.plugins.install-preview` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\PluginController@installPreview` +- **인증/권한**: `auth:sanctum` + `permission:core.plugins.install` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| pluginName | path | string | 예 | — | 대상 plugin의 이름 (식별자) | + +**요청 예시** + +```http +GET /api/admin/plugins/{pluginName}/install-preview HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.plugins.install`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 플러그인 설치 시 함께 처리될 cascade 후보(의존 확장 + 동반 가능한 번들 언어팩) 트리를 반환합니다. `core.plugins.install` 권한이 필요합니다. 설치 모달 오픈 시 호출되어 사용자가 함께 설치할 항목을 선택하도록 노출하며, ZIP 업로드 기반의 `manifest-preview` 와 달리 이미 알려진 식별자에 대한 GET 조회입니다. + + +### GET /api/admin/plugins/{pluginName}/uninstall-info + +- **라우트명**: `api.admin.plugins.uninstall-info` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\PluginController@uninstallInfo` +- **인증/권한**: `auth:sanctum` + `permission:core.plugins.uninstall` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| pluginName | path | string | 예 | — | 대상 plugin의 이름 (식별자) | + +**요청 예시** + +```http +GET /api/admin/plugins/{pluginName}/uninstall-info HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.plugins.uninstall`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 플러그인 제거 시 삭제될 데이터 정보를 조회합니다. `core.plugins.uninstall` 권한이 필요합니다. 제거 확인 모달에서 사용자에게 어떤 데이터가 사라지는지 미리 보여주는 용도이며, 플러그인을 찾을 수 없으면 404 를 반환합니다. + + +### POST /api/admin/plugins/{pluginName}/update + +- **라우트명**: `api.admin.plugins.update` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\PluginController@performUpdate` +- **인증/권한**: `auth:sanctum` + `permission:core.plugins.install` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| pluginName | path | string | 예 | — | 대상 plugin의 이름 (식별자) | +| layout_strategy | body | string | 아니오 | `overwrite`, `keep` | 레이아웃 처리 전략 (overwrite: 새 버전으로 교체, keep: 사용자 수정본 유지) | +| vendor_mode | body | string | 아니오 | `auto`, `composer`, `bundled` | 벤더 설치 모드 (auto/composer/bundled) | +| force | body | boolean | 아니오 | — | 강제 실행 여부 (안전 확인/선행 검사 우회) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.plugin.perform_update_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/plugins/{pluginName}/update HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "layout_strategy": "overwrite", + "vendor_mode": "auto", + "force": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.plugins.install`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 특정 플러그인을 최신 버전으로 업데이트합니다. `core.plugins.install` 권한이 필요합니다. `layout_strategy` 로 레이아웃 처리 방식을(overwrite: 새 버전으로 교체, keep: 사용자 수정본 유지) 지정하며, `vendor_mode` 로 Composer 의존성 처리 방식을 선택합니다. 버전 제약·호환성 문제로 막힐 경우 `force: true` 로 강제 진행할 수 있습니다. + + +### GET /api/plugins/assets/{identifier}/{path} + +- **라우트명**: `api.public.plugins.assets` +- **컨트롤러**: `App\Http\Controllers\Api\Public\PublicPluginController@serveAsset` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | +| path | path | string | 예 | — | 경로 | + +**요청 예시** + +```http +GET /api/plugins/assets/{identifier}/{path} HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 플러그인의 개별 프론트엔드 에셋 파일(JS/CSS/이미지 등)을 서빙하는 공개 엔드포인트입니다. 인증이 필요하지 않으며, 경로·확장자 보안 검증은 FormRequest 에서 완료됩니다. 플러그인 미존재·파일 미존재·허용되지 않은 파일 유형은 각각 404/404/403 으로 응답하고, 정상 파일은 ETag 와 1년 캐시 헤더를 붙여 반환합니다. 소스맵 등 개별 에셋을 직접 참조할 때 사용되며, 통합 로딩은 `bundle.js`/`bundle.css` 를 사용합니다. + + +### GET /api/plugins/bundle.css + +- **라우트명**: `api.public.plugins.bundle.css` +- **컨트롤러**: `App\Http\Controllers\Api\Public\PublicPluginController@serveBundleCss` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/bundle.css HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +_대표 에러 없음 (공개 조회 — 활성 에셋이 없으면 빈 200 응답)._ + + + +**설명** 활성 플러그인들의 프론트엔드 CSS 를 서버에서 하나로 병합한 번들을 서빙하는 공개 엔드포인트입니다. 인증이 필요하지 않습니다. 활성 global 플러그인 에셋이 없으면 빈 200(text/css) 응답을 반환하고, 있으면 병합 파일을 ETag·환경별 Cache-Control 과 함께 서빙합니다. 페이지가 플러그인 스타일을 요청 1건으로 로드하도록 합니다. + + +### GET /api/plugins/bundle.js + +- **라우트명**: `api.public.plugins.bundle.js` +- **컨트롤러**: `App\Http\Controllers\Api\Public\PublicPluginController@serveBundleJs` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/bundle.js HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +_대표 에러 없음 (공개 조회 — 활성 에셋이 없으면 빈 200 응답)._ + + + +**설명** 활성 플러그인들의 프론트엔드 IIFE JS 를 서버에서 하나로 병합한 번들을 서빙하는 공개 엔드포인트입니다. 인증이 필요하지 않습니다. 활성 global 플러그인 에셋이 없으면 빈 200(text/javascript) 응답을 반환하고, 있으면 병합 파일을 ETag·환경별 Cache-Control 과 함께 서빙합니다. 프론트는 `G7Config.bundleUrls` 를 읽어 이 번들을 로드합니다. + + +### GET /api/plugins/{identifier}/components.json + +- **라우트명**: `api.public.plugins.components` +- **컨트롤러**: `App\Http\Controllers\Api\Public\PublicPluginController@serveComponents` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/plugins/{identifier}/components.json HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 플러그인의 컴포넌트 정의 파일(components.json)을 서빙하는 공개 엔드포인트입니다. 인증이 필요하지 않습니다. 편집 모드 부팅 시 ComponentRegistry 가 활성 확장 매니페스트를 네임스페이스 병합하기 위해 fetch 하며, 구버전 플러그인처럼 파일이 없으면 빈 components 로 폴백합니다. 응답은 1시간 캐시됩니다. 플러그인 미존재 시 404. + + +### GET /api/plugins/{identifier}/editor-spec + +- **라우트명**: `api.public.plugins.editor_spec` +- **컨트롤러**: `App\Http\Controllers\Api\Public\PublicPluginController@serveEditorSpec` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/plugins/{identifier}/editor-spec HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 플러그인의 레이아웃 편집기 스펙(editor-spec.json)을 서빙하는 공개 엔드포인트입니다. 인증이 필요하지 않습니다. 활성 플러그인만 대상으로 하며 활성 디렉토리 → `_bundled` 폴백 순으로 읽어 `data.spec` 형태로 반환합니다. 비활성·미존재 플러그인은 404 이고, 편집기 스펙 파일을 작성하지 않은 경우 spec=null 로 정상 응답합니다. + + diff --git a/docs/backend/api/profile.md b/docs/backend/api/profile.md new file mode 100644 index 00000000..90cbbda1 --- /dev/null +++ b/docs/backend/api/profile.md @@ -0,0 +1,231 @@ +# Profile API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Profile 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/user/profile + +- **라우트명**: `api.user.profile.show` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\ProfileController@show` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.profile.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/user/profile HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`core.profile.read`)이 없는 경우 | + + + +**설명** + +`GET /api/me` 와 동일하게 `ProfileController@show` 를 호출해 현재 사용자의 프로필을 조회하지만, `permission:core.profile.read` 권한 미들웨어가 추가된 경로다. 응답 형태는 `UserResource::toProfileArray()` 산물로 `GET /api/me` 와 같으며, 필드별 소유(확장 병합) 규칙도 동일하다. 실측 예시가 비어 있는 것은 문서 생성 시 샘플 사용자가 해당 권한을 갖지 못해 403 이 반환되었기 때문이며, 응답 필드는 `GET /api/me` 문서를 참조한다. + + +### PUT /api/user/profile + +- **라우트명**: `api.user.profile.update` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\ProfileController@update` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.profile.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| name | body | string | 예 | max 255 | 대상의 이름/명칭 | +| nickname | body | string | 아니오 | max 50 | 닉네임 | +| email | body | email | 예 | max 255 | 이메일 주소 | +| password | body | string | 아니오 | — | 비밀번호 | +| current_password | body | string | 아니오 | — | 현재 비밀번호 (변경 전 확인용) | +| language | body | string | 아니오 | — | 언어 코드 | +| country | body | string | 아니오 | — | 국가 코드 (ISO 3166-1 alpha-2) | +| timezone | body | string | 아니오 | — | 타임존 식별자 | +| homepage | body | string | 아니오 | max 255 | 홈페이지 URL | +| mobile | body | string | 아니오 | max 20 | 휴대전화 번호 | +| phone | body | string | 아니오 | max 20 | 전화번호 | +| zipcode | body | string | 아니오 | max 10 | 우편번호 | +| address | body | string | 아니오 | max 255 | 기본 주소 | +| address_detail | body | string | 아니오 | max 255 | 상세 주소 | +| signature | body | string | 아니오 | max 1000 | 서명 | +| bio | body | string | 아니오 | max 5000 | 자기소개 | +| notify_post_complete | body | boolean | 아니오 | — | 내 글에 답변/처리 완료 시 알림 수신 여부 (게시판 모듈 알림 설정) | +| notify_post_reply | body | boolean | 아니오 | — | 내 글에 답글이 달렸을 때 알림 수신 여부 (게시판 모듈 알림 설정) | +| notify_comment | body | boolean | 아니오 | — | 내 글에 댓글이 달렸을 때 알림 수신 여부 (게시판 모듈 알림 설정) | +| notify_reply_comment | body | boolean | 아니오 | — | 내 댓글에 대댓글이 달렸을 때 알림 수신 여부 (게시판 모듈 알림 설정) | +| email_subscription | body | boolean | 아니오 | — | 광고성 이메일 수신 동의 여부 (sirsoft-marketing 채널, 훅 주입 파라미터) | +| marketing_consent | body | boolean | 아니오 | — | 마케팅 정보 수신 전체 동의 마스터 키 — 마케팅 채널 전체 동의/철회 제어 (sirsoft-marketing 훅 주입 파라미터) | +| third_party_consent | body | boolean | 아니오 | — | 제3자 정보 제공 동의 여부 (법적 필수 항목, sirsoft-marketing 훅 주입 파라미터) | +| info_disclosure | body | boolean | 아니오 | — | 개인정보 이용 안내 동의 여부 (법적 필수 항목, sirsoft-marketing 훅 주입 파라미터) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.user.update_profile_validation_rules`). + +**요청 예시** + +```http +PUT /api/user/profile HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +Content-Type: application/json + +{ + "name": "예시 이름", + "nickname": "예시 이름", + "email": "user@example.com", + "password": "Password123!", + "current_password": "Password123!", + "language": "ko", + "country": "KR", + "timezone": "Asia/Seoul", + "homepage": "https://example.com", + "mobile": "010-1234-5678", + "phone": "010-1234-5678", + "zipcode": "06234", + "address": "서울특별시 강남구 테헤란로 1", + "address_detail": "서울특별시 강남구 테헤란로 1", + "signature": "예시값", + "bio": "예시 내용입니다.", + "notify_post_complete": true, + "notify_post_reply": true, + "notify_comment": true, + "notify_reply_comment": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`core.profile.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +`PUT /api/me` 와 동일하게 `ProfileController@update` 를 호출해 프로필을 수정하되, `permission:core.profile.update` 권한 미들웨어가 추가된 경로다. 요청 파라미터와 검증 규칙(`UpdateProfileRequest`), 확장 소유 파라미터 병합(`core.user.update_profile_validation_rules`)은 `PUT /api/me` 와 동일하다. 성공 시 갱신된 프로필이 `UserResource` 형태로 반환된다. + + +### GET /api/user/profile/activity-log + +- **라우트명**: `api.user.profile.activity-log` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\ProfileController@activityLog` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.profile.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/user/profile/activity-log HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`core.profile.read`)이 없는 경우 | + + + +**설명** + +현재 사용자의 최근 활동 로그를 조회한다(`permission:core.profile.read` 필요). `data.activities` 에 최대 50건의 로그가 최신순으로 담기며, 각 항목은 `id`·`action`·`action_label`·`description`(로케일 반영)·`ip_address`·`created_at`(ISO 8601) 필드를 가진다. 실측 예시가 비어 있는 것은 문서 생성 시 샘플 사용자가 권한을 갖지 못해 403 이 반환되었기 때문이다. + + +### POST /api/user/profile/update-language + +- **라우트명**: `api.user.profile.update-language` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\ProfileController@updateLanguage` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/user/profile/update-language HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 400 | Bad Request | `language` 값이 `config('app.supported_locales')`(기본 `['ko','en']`)에 포함되지 않는 미지원 로케일인 경우 | + + + +**설명** + +현재 사용자의 언어 설정만 변경한다. 요청 본문의 `language` 값을 받아 `config('app.supported_locales')`(기본 `['ko','en']`)에 포함되는지 검사하며, 허용되지 않는 값이면 400 을 반환한다. 프로필 전체 수정 없이 언어만 즉시 전환할 때 사용하며, 성공 시 갱신된 사용자 정보가 `UserResource` 형태로 반환된다. `language` 는 FormRequest 가 아닌 컨트롤러에서 직접 읽어 검증하므로 문서 상단 파라미터 표에는 자동 수집되지 않는다. + + diff --git a/docs/backend/api/roles.md b/docs/backend/api/roles.md new file mode 100644 index 00000000..1c03b361 --- /dev/null +++ b/docs/backend/api/roles.md @@ -0,0 +1,9479 @@ +# Roles API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Roles 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/admin/roles + +- **라우트명**: `api.admin.roles.index` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\RoleController@index` +- **인증/권한**: `auth:sanctum` + `permission:core.permissions.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | +| search | query | string | 아니오 | max 255 | 검색어 (지정한 검색 대상 필드에서 부분 일치) | +| is_active | query | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | + +**요청 예시** + +```http +GET /api/admin/roles?page=1&per_page=1&search=%EC%98%88%EC%8B%9C%EA%B0%92&is_active=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `23` | 기본 키 (내부 식별자) | +| identifier | string | `sirsoft-board.archive.manager` | 역할명 (예: admin, user, manager) | +| name | string | `아카이브 게시판 관리자` | 역할 이름 (다국어 JSON) | +| name_raw | object | `{"ko":"아카이브 게시판 관리자","en":"Archive Board Manager"}` | `name` 의 원본 값 (현재 로케일 미해석 원본 JSON/문자열) | +| description | string | `아카이브 게시판의 관리자 역할` | 역할 설명 (다국어 JSON) | +| description_raw | object | `{"ko":"아카이브 게시판의 관리자 역할","en":"Manager role for Archive b…` | `description` 의 원본 값 (현재 로케일 미해석 원본 JSON/문자열) | +| extension_type | string | `module` | 확장 소유 타입: core(코어), module(모듈), plugin(플러그인), NULL(사용자 정의) | +| extension_identifier | string | `sirsoft-board` | 확장 식별자 (예: core, sirsoft-board, sirsoft-payment) | +| extension_name | string | `게시판` | 이 리소스를 소유한 확장의 표시 이름 (manifest name) | +| is_deletable | boolean | `false` | deletable 여부 | +| is_active | boolean | `true` | active 여부 | +| users_count | integer | `1` | users 개수 (집계) | +| permission_ids | array | `[312,313,314,315,316,317,318,319,320,321,322,323,324,325,…` | permission 식별자 배열 (연관 리소스 참조) | +| permission_values | array | `[{"id":312,"scope_type":null},{"id":313,"scope_type":null…` | 할당된 각 권한의 id와 적용 범위만 담은 경량 목록 (원소 id/scope_type — 역할-권한 pivot 파생). scope_type: null=전체, role=역할 범위, self=본인 범위 | +| permissions | array | `[{"id":85,"parent_id":null,"identifier":"sirsoft-board","…` | 연결된 권한 목록 (id/identifier/name — 역할 경유 권한 관계 파생) | +| created_at | string | `2026-06-04 09:35:35` | 생성 일시 | +| updated_at | string | `2026-06-04 09:35:35` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true,"c…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "역할 정보를 성공적으로 가져왔습니다.", + "data": { + "data": [ + { + "id": 6, + "identifier": "manager", + "name": "매니저", + "name_raw": { + "ko": "매니저", + "en": "Manager" + }, + "description": "콘텐츠 및 사용자 관리 권한을 가진 관리자입니다.", + "description_raw": { + "ko": "콘텐츠 및 사용자 관리 권한을 가진 관리자입니다.", + "en": "Manager with content and user management permissions." + }, + "extension_type": "core", + "extension_identifier": "core", + "extension_name": null, + "is_deletable": false, + "is_active": true, + "users_count": 0, + "permission_ids": [ + 117, + 118, + 119, + 122, + 123, + 124, + 149, + 176, + 177, + 179, + 182, + 185, + 186, + 187 + ], + "permission_values": [ + { + "id": 117, + "scope_type": "self" + }, + { + "id": 118, + "scope_type": null + }, + { + "id": 119, + "scope_type": "self" + }, + { + "id": 122, + "scope_type": null + }, + { + "id": 123, + "scope_type": null + }, + { + "id": 124, + "scope_type": null + }, + { + "id": 149, + "scope_type": "self" + }, + { + "id": 176, + "scope_type": null + }, + { + "id": 177, + "scope_type": null + }, + { + "id": 179, + "scope_type": null + }, + { + "id": 182, + "scope_type": "self" + }, + { + "id": 185, + "scope_type": null + }, + { + "id": 186, + "scope_type": "self" + }, + { + "id": 187, + "scope_type": "self" + } + ], + "permissions": [ + { + "id": 115, + "parent_id": null, + "identifier": "core", + "name": "코어", + "name_raw": { + "ko": "코어", + "en": "Core" + }, + "description": "코어 시스템 권한", + "description_raw": { + "ko": "코어 시스템 권한", + "en": "Core system permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": false, + "is_assignable": false, + "children": [ + { + "id": 116, + "parent_id": 115, + "identifier": "core.users", + "name": "사용자 관리", + "name_raw": { + "ko": "사용자 관리", + "en": "User Management" + }, + "description": "사용자 관리 권한", + "description_raw": { + "ko": "사용자 관리 권한", + "en": "User management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": false, + "is_assignable": false, + "children": [ + { + "id": 117, + "parent_id": 116, + "identifier": "core.users.read", + "name": "사용자 조회", + "name_raw": { + "ko": "사용자 조회", + "en": "View Users" + }, + "description": "사용자 목록 및 상세 정보를 조회할 수 있습니다.", + "description_raw": { + "ko": "사용자 목록 및 상세 정보를 조회할 수 있습니다.", + "en": "Can view user list and details." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": "user", + "owner_key": "id", + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": "self" + }, + { + "id": 118, + "parent_id": 116, + "identifier": "core.users.create", + "name": "사용자 생성", + "name_raw": { + "ko": "사용자 생성", + "en": "Create Users" + }, + "description": "새로운 사용자를 생성할 수 있습니다.", + "description_raw": { + "ko": "새로운 사용자를 생성할 수 있습니다.", + "en": "Can create new users." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 119, + "parent_id": 116, + "identifier": "core.users.update", + "name": "사용자 수정", + "name_raw": { + "ko": "사용자 수정", + "en": "Update Users" + }, + "description": "사용자 정보를 수정할 수 있습니다.", + "description_raw": { + "ko": "사용자 정보를 수정할 수 있습니다.", + "en": "Can update user information." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": "user", + "owner_key": "id", + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": "self" + } + ] + }, + { + "id": 121, + "parent_id": 115, + "identifier": "core.menus", + "name": "메뉴 관리", + "name_raw": { + "ko": "메뉴 관리", + "en": "Menu Management" + }, + "description": "메뉴 관리 권한", + "description_raw": { + "ko": "메뉴 관리 권한", + "en": "Menu management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": false, + "is_assignable": false, + "children": [ + { + "id": 122, + "parent_id": 121, + "identifier": "core.menus.read", + "name": "메뉴 조회", + "name_raw": { + "ko": "메뉴 조회", + "en": "View Menus" + }, + "description": "메뉴 목록을 조회할 수 있습니다.", + "description_raw": { + "ko": "메뉴 목록을 조회할 수 있습니다.", + "en": "Can view menu list." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": "menu", + "owner_key": "created_by", + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 123, + "parent_id": 121, + "identifier": "core.menus.create", + "name": "메뉴 생성", + "name_raw": { + "ko": "메뉴 생성", + "en": "Create Menus" + }, + "description": "새로운 메뉴를 생성할 수 있습니다.", + "description_raw": { + "ko": "새로운 메뉴를 생성할 수 있습니다.", + "en": "Can create new menus." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 124, + "parent_id": 121, + "identifier": "core.menus.update", + "name": "메뉴 수정", + "name_raw": { + "ko": "메뉴 수정", + "en": "Update Menus" + }, + "description": "메뉴 정보를 수정할 수 있습니다.", + "description_raw": { + "ko": "메뉴 정보를 수정할 수 있습니다.", + "en": "Can update menu information." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": "menu", + "owner_key": "created_by", + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 148, + "parent_id": 115, + "identifier": "core.notification-logs", + "name": "알림 발송 이력", + "name_raw": { + "ko": "알림 발송 이력", + "en": "Notification Logs" + }, + "description": "알림 발송 이력 관리 권한", + "description_raw": { + "ko": "알림 발송 이력 관리 권한", + "en": "Notification log management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 7, + "is_assigned": false, + "is_assignable": false, + "children": [ + { + "id": 149, + "parent_id": 148, + "identifier": "core.notification-logs.read", + "name": "발송 이력 조회", + "name_raw": { + "ko": "발송 이력 조회", + "en": "View Notification Logs" + }, + "description": "알림 발송 이력을 조회할 수 있습니다.", + "description_raw": { + "ko": "알림 발송 이력을 조회할 수 있습니다.", + "en": "Can view notification logs." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": "self" + } + ] + }, + { + "id": 175, + "parent_id": 115, + "identifier": "core.dashboard", + "name": "대시보드", + "name_raw": { + "ko": "대시보드", + "en": "Dashboard" + }, + "description": "대시보드 접근 권한", + "description_raw": { + "ko": "대시보드 접근 권한", + "en": "Dashboard access permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 9, + "is_assigned": false, + "is_assignable": false, + "children": [ + { + "id": 176, + "parent_id": 175, + "identifier": "core.dashboard.read", + "name": "대시보드 조회", + "name_raw": { + "ko": "대시보드 조회", + "en": "View Dashboard" + }, + "description": "대시보드 통계 및 정보를 조회할 수 있습니다.", + "description_raw": { + "ko": "대시보드 통계 및 정보를 조회할 수 있습니다.", + "en": "Can view dashboard statistics and information." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 177, + "parent_id": 175, + "identifier": "core.dashboard.system-status", + "name": "시스템 상태", + "name_raw": { + "ko": "시스템 상태", + "en": "System Status" + }, + "description": "시스템 상태 정보를 조회할 수 있습니다.", + "description_raw": { + "ko": "시스템 상태 정보를 조회할 수 있습니다.", + "en": "Can view system status information." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 179, + "parent_id": 175, + "identifier": "core.dashboard.activities", + "name": "최근 활동", + "name_raw": { + "ko": "최근 활동", + "en": "Recent Activities" + }, + "description": "최근 활동 이력을 조회할 수 있습니다.", + "description_raw": { + "ko": "최근 활동 이력을 조회할 수 있습니다.", + "en": "Can view recent activity history." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": "activityLog", + "owner_key": "user_id", + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 181, + "parent_id": 115, + "identifier": "core.activities", + "name": "활동 로그", + "name_raw": { + "ko": "활동 로그", + "en": "Activity Logs" + }, + "description": "활동 로그 조회 권한", + "description_raw": { + "ko": "활동 로그 조회 권한", + "en": "Activity log access permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 10, + "is_assigned": false, + "is_assignable": false, + "children": [ + { + "id": 182, + "parent_id": 181, + "identifier": "core.activities.read", + "name": "활동 로그 조회", + "name_raw": { + "ko": "활동 로그 조회", + "en": "View Activity Logs" + }, + "description": "활동 로그를 조회할 수 있습니다.", + "description_raw": { + "ko": "활동 로그를 조회할 수 있습니다.", + "en": "Can view activity logs." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": "activityLog", + "owner_key": "user_id", + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": "self" + } + ] + }, + { + "id": 184, + "parent_id": 115, + "identifier": "core.attachments", + "name": "첨부파일 관리", + "name_raw": { + "ko": "첨부파일 관리", + "en": "Attachment Management" + }, + "description": "첨부파일 관리 권한", + "description_raw": { + "ko": "첨부파일 관리 권한", + "en": "Attachment management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 11, + "is_assigned": false, + "is_assignable": false, + "children": [ + { + "id": 185, + "parent_id": 184, + "identifier": "core.attachments.create", + "name": "첨부파일 업로드", + "name_raw": { + "ko": "첨부파일 업로드", + "en": "Upload Attachments" + }, + "description": "첨부파일을 업로드할 수 있습니다.", + "description_raw": { + "ko": "첨부파일을 업로드할 수 있습니다.", + "en": "Can upload attachments." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 186, + "parent_id": 184, + "identifier": "core.attachments.update", + "name": "첨부파일 수정", + "name_raw": { + "ko": "첨부파일 수정", + "en": "Update Attachments" + }, + "description": "첨부파일 정보 및 순서를 수정할 수 있습니다.", + "description_raw": { + "ko": "첨부파일 정보 및 순서를 수정할 수 있습니다.", + "en": "Can update attachment information and order." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": "attachment", + "owner_key": "created_by", + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": "self" + }, + { + "id": 187, + "parent_id": 184, + "identifier": "core.attachments.delete", + "name": "첨부파일 삭제", + "name_raw": { + "ko": "첨부파일 삭제", + "en": "Delete Attachments" + }, + "description": "첨부파일을 삭제할 수 있습니다.", + "description_raw": { + "ko": "첨부파일을 삭제할 수 있습니다.", + "en": "Can delete attachments." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": "attachment", + "owner_key": "created_by", + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": "self" + } + ] + } + ] + } + ], + "created_at": "2026-07-08 10:47:51", + "updated_at": "2026-07-08 10:47:51", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true, + "can_toggle_status": true + } + }, + { + "id": 5, + "identifier": "sirsoft-ecommerce.manager", + "name": "이커머스 관리자", + "name_raw": { + "ko": "이커머스 관리자", + "en": "Ecommerce Manager" + }, + "description": "이커머스 관리 권한을 가진 역할", + "description_raw": { + "ko": "이커머스 관리 권한을 가진 역할", + "en": "Role with ecommerce management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "extension_name": "이커머스", + "is_deletable": false, + "is_active": true, + "users_count": 0, + "permission_ids": [ + 23, + 24, + 25, + 26, + 28, + 29, + 31, + 32, + 33, + 34, + 36, + 37, + 38, + 39, + 41, + 42, + 43, + 44, + 46, + 47, + 48, + 49, + 51, + 52, + 54, + 55, + 56, + 57, + 59, + 60, + 61, + 62, + 64, + 65, + 66, + 67, + 72, + 73, + 74, + 76, + 77, + 79, + 81, + 83, + 84, + 85, + 87, + 89, + 90, + 92, + 94 + ], + "permission_values": [ + { + "id": 23, + "scope_type": null + }, + { + "id": 24, + "scope_type": null + }, + { + "id": 25, + "scope_type": null + }, + { + "id": 26, + "scope_type": null + }, + { + "id": 28, + "scope_type": null + }, + { + "id": 29, + "scope_type": null + }, + { + "id": 31, + "scope_type": null + }, + { + "id": 32, + "scope_type": null + }, + { + "id": 33, + "scope_type": null + }, + { + "id": 34, + "scope_type": null + }, + { + "id": 36, + "scope_type": null + }, + { + "id": 37, + "scope_type": null + }, + { + "id": 38, + "scope_type": null + }, + { + "id": 39, + "scope_type": null + }, + { + "id": 41, + "scope_type": null + }, + { + "id": 42, + "scope_type": null + }, + { + "id": 43, + "scope_type": null + }, + { + "id": 44, + "scope_type": null + }, + { + "id": 46, + "scope_type": null + }, + { + "id": 47, + "scope_type": null + }, + { + "id": 48, + "scope_type": null + }, + { + "id": 49, + "scope_type": null + }, + { + "id": 51, + "scope_type": null + }, + { + "id": 52, + "scope_type": null + }, + { + "id": 54, + "scope_type": null + }, + { + "id": 55, + "scope_type": null + }, + { + "id": 56, + "scope_type": null + }, + { + "id": 57, + "scope_type": null + }, + { + "id": 59, + "scope_type": null + }, + { + "id": 60, + "scope_type": null + }, + { + "id": 61, + "scope_type": null + }, + { + "id": 62, + "scope_type": null + }, + { + "id": 64, + "scope_type": null + }, + { + "id": 65, + "scope_type": null + }, + { + "id": 66, + "scope_type": null + }, + { + "id": 67, + "scope_type": null + }, + { + "id": 72, + "scope_type": null + }, + { + "id": 73, + "scope_type": null + }, + { + "id": 74, + "scope_type": null + }, + { + "id": 76, + "scope_type": null + }, + { + "id": 77, + "scope_type": null + }, + { + "id": 79, + "scope_type": null + }, + { + "id": 81, + "scope_type": null + }, + { + "id": 83, + "scope_type": null + }, + { + "id": 84, + "scope_type": null + }, + { + "id": 85, + "scope_type": null + }, + { + "id": 87, + "scope_type": null + }, + { + "id": 89, + "scope_type": null + }, + { + "id": 90, + "scope_type": null + }, + { + "id": 92, + "scope_type": null + }, + { + "id": 94, + "scope_type": null + } + ], + "permissions": [ + { + "id": 21, + "parent_id": null, + "identifier": "sirsoft-ecommerce", + "name": "이커머스", + "name_raw": { + "ko": "이커머스", + "en": "Ecommerce" + }, + "description": "이커머스 모듈 권한", + "description_raw": { + "ko": "이커머스 모듈 권한", + "en": "Ecommerce module permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 100, + "is_assigned": false, + "is_assignable": false, + "children": [ + { + "id": 22, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.products", + "name": "상품 관리", + "name_raw": { + "ko": "상품 관리", + "en": "Product Management" + }, + "description": "상품 관리 권한", + "description_raw": { + "ko": "상품 관리 권한", + "en": "Product management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": false, + "is_assignable": false, + "children": [ + { + "id": 23, + "parent_id": 22, + "identifier": "sirsoft-ecommerce.products.read", + "name": "상품 조회", + "name_raw": { + "ko": "상품 조회", + "en": "Read Products" + }, + "description": "상품 목록 및 상세 조회", + "description_raw": { + "ko": "상품 목록 및 상세 조회", + "en": "Read product list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "product", + "owner_key": "created_by", + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 24, + "parent_id": 22, + "identifier": "sirsoft-ecommerce.products.create", + "name": "상품 생성", + "name_raw": { + "ko": "상품 생성", + "en": "Create Product" + }, + "description": "새 상품 등록", + "description_raw": { + "ko": "새 상품 등록", + "en": "Create new product" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 25, + "parent_id": 22, + "identifier": "sirsoft-ecommerce.products.update", + "name": "상품 수정", + "name_raw": { + "ko": "상품 수정", + "en": "Update Product" + }, + "description": "상품 정보 수정", + "description_raw": { + "ko": "상품 정보 수정", + "en": "Update product information" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "product", + "owner_key": "created_by", + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 26, + "parent_id": 22, + "identifier": "sirsoft-ecommerce.products.delete", + "name": "상품 삭제", + "name_raw": { + "ko": "상품 삭제", + "en": "Delete Product" + }, + "description": "상품 삭제", + "description_raw": { + "ko": "상품 삭제", + "en": "Delete product" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "product", + "owner_key": "created_by", + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 27, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.orders", + "name": "주문 관리", + "name_raw": { + "ko": "주문 관리", + "en": "Order Management" + }, + "description": "주문 관리 권한", + "description_raw": { + "ko": "주문 관리 권한", + "en": "Order management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": false, + "is_assignable": false, + "children": [ + { + "id": 28, + "parent_id": 27, + "identifier": "sirsoft-ecommerce.orders.read", + "name": "주문 조회", + "name_raw": { + "ko": "주문 조회", + "en": "Read Orders" + }, + "description": "주문 목록 및 상세 조회", + "description_raw": { + "ko": "주문 목록 및 상세 조회", + "en": "Read order list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "order", + "owner_key": "user_id", + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 29, + "parent_id": 27, + "identifier": "sirsoft-ecommerce.orders.update", + "name": "주문 수정", + "name_raw": { + "ko": "주문 수정", + "en": "Update Order" + }, + "description": "주문 상태 변경", + "description_raw": { + "ko": "주문 상태 변경", + "en": "Update order status" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "order", + "owner_key": "user_id", + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 30, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.categories", + "name": "카테고리 관리", + "name_raw": { + "ko": "카테고리 관리", + "en": "Category Management" + }, + "description": "카테고리 관리 권한", + "description_raw": { + "ko": "카테고리 관리 권한", + "en": "Category management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 3, + "is_assigned": false, + "is_assignable": false, + "children": [ + { + "id": 31, + "parent_id": 30, + "identifier": "sirsoft-ecommerce.categories.read", + "name": "카테고리 조회", + "name_raw": { + "ko": "카테고리 조회", + "en": "Read Categories" + }, + "description": "카테고리 목록 및 상세 조회", + "description_raw": { + "ko": "카테고리 목록 및 상세 조회", + "en": "Read category list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 32, + "parent_id": 30, + "identifier": "sirsoft-ecommerce.categories.create", + "name": "카테고리 생성", + "name_raw": { + "ko": "카테고리 생성", + "en": "Create Category" + }, + "description": "카테고리 생성", + "description_raw": { + "ko": "카테고리 생성", + "en": "Create category" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 33, + "parent_id": 30, + "identifier": "sirsoft-ecommerce.categories.update", + "name": "카테고리 수정", + "name_raw": { + "ko": "카테고리 수정", + "en": "Update Category" + }, + "description": "카테고리 수정", + "description_raw": { + "ko": "카테고리 수정", + "en": "Update category" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 34, + "parent_id": 30, + "identifier": "sirsoft-ecommerce.categories.delete", + "name": "카테고리 삭제", + "name_raw": { + "ko": "카테고리 삭제", + "en": "Delete Category" + }, + "description": "카테고리 삭제", + "description_raw": { + "ko": "카테고리 삭제", + "en": "Delete category" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 35, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.brands", + "name": "브랜드 관리", + "name_raw": { + "ko": "브랜드 관리", + "en": "Brand Management" + }, + "description": "브랜드 관리 권한", + "description_raw": { + "ko": "브랜드 관리 권한", + "en": "Brand management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 4, + "is_assigned": false, + "is_assignable": false, + "children": [ + { + "id": 36, + "parent_id": 35, + "identifier": "sirsoft-ecommerce.brands.read", + "name": "브랜드 조회", + "name_raw": { + "ko": "브랜드 조회", + "en": "Read Brands" + }, + "description": "브랜드 목록 및 상세 조회", + "description_raw": { + "ko": "브랜드 목록 및 상세 조회", + "en": "Read brand list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "brand", + "owner_key": "created_by", + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 37, + "parent_id": 35, + "identifier": "sirsoft-ecommerce.brands.create", + "name": "브랜드 생성", + "name_raw": { + "ko": "브랜드 생성", + "en": "Create Brand" + }, + "description": "브랜드 생성", + "description_raw": { + "ko": "브랜드 생성", + "en": "Create brand" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 38, + "parent_id": 35, + "identifier": "sirsoft-ecommerce.brands.update", + "name": "브랜드 수정", + "name_raw": { + "ko": "브랜드 수정", + "en": "Update Brand" + }, + "description": "브랜드 수정", + "description_raw": { + "ko": "브랜드 수정", + "en": "Update brand" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "brand", + "owner_key": "created_by", + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 39, + "parent_id": 35, + "identifier": "sirsoft-ecommerce.brands.delete", + "name": "브랜드 삭제", + "name_raw": { + "ko": "브랜드 삭제", + "en": "Delete Brand" + }, + "description": "브랜드 삭제", + "description_raw": { + "ko": "브랜드 삭제", + "en": "Delete brand" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "brand", + "owner_key": "created_by", + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 40, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.product-notice-templates", + "name": "상품정보제공고시 관리", + "name_raw": { + "ko": "상품정보제공고시 관리", + "en": "Product Notice Template Management" + }, + "description": "상품정보제공고시 템플릿 관리 권한", + "description_raw": { + "ko": "상품정보제공고시 템플릿 관리 권한", + "en": "Product notice template management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 5, + "is_assigned": false, + "is_assignable": false, + "children": [ + { + "id": 41, + "parent_id": 40, + "identifier": "sirsoft-ecommerce.product-notice-templates.read", + "name": "조회", + "name_raw": { + "ko": "조회", + "en": "Read" + }, + "description": "상품정보제공고시 조회", + "description_raw": { + "ko": "상품정보제공고시 조회", + "en": "Read product notice templates" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 42, + "parent_id": 40, + "identifier": "sirsoft-ecommerce.product-notice-templates.create", + "name": "생성", + "name_raw": { + "ko": "생성", + "en": "Create" + }, + "description": "상품정보제공고시 생성", + "description_raw": { + "ko": "상품정보제공고시 생성", + "en": "Create product notice template" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 43, + "parent_id": 40, + "identifier": "sirsoft-ecommerce.product-notice-templates.update", + "name": "수정", + "name_raw": { + "ko": "수정", + "en": "Update" + }, + "description": "상품정보제공고시 수정", + "description_raw": { + "ko": "상품정보제공고시 수정", + "en": "Update product notice template" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 44, + "parent_id": 40, + "identifier": "sirsoft-ecommerce.product-notice-templates.delete", + "name": "삭제", + "name_raw": { + "ko": "삭제", + "en": "Delete" + }, + "description": "상품정보제공고시 삭제", + "description_raw": { + "ko": "상품정보제공고시 삭제", + "en": "Delete product notice template" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 45, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.product-common-infos", + "name": "공통정보 관리", + "name_raw": { + "ko": "공통정보 관리", + "en": "Product Common Info Management" + }, + "description": "상품 공통정보 관리 권한", + "description_raw": { + "ko": "상품 공통정보 관리 권한", + "en": "Product common info management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 6, + "is_assigned": false, + "is_assignable": false, + "children": [ + { + "id": 46, + "parent_id": 45, + "identifier": "sirsoft-ecommerce.product-common-infos.read", + "name": "조회", + "name_raw": { + "ko": "조회", + "en": "Read" + }, + "description": "공통정보 조회", + "description_raw": { + "ko": "공통정보 조회", + "en": "Read product common info" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 47, + "parent_id": 45, + "identifier": "sirsoft-ecommerce.product-common-infos.create", + "name": "생성", + "name_raw": { + "ko": "생성", + "en": "Create" + }, + "description": "공통정보 생성", + "description_raw": { + "ko": "공통정보 생성", + "en": "Create product common info" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 48, + "parent_id": 45, + "identifier": "sirsoft-ecommerce.product-common-infos.update", + "name": "수정", + "name_raw": { + "ko": "수정", + "en": "Update" + }, + "description": "공통정보 수정", + "description_raw": { + "ko": "공통정보 수정", + "en": "Update product common info" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 49, + "parent_id": 45, + "identifier": "sirsoft-ecommerce.product-common-infos.delete", + "name": "삭제", + "name_raw": { + "ko": "삭제", + "en": "Delete" + }, + "description": "공통정보 삭제", + "description_raw": { + "ko": "공통정보 삭제", + "en": "Delete product common info" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 50, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.settings", + "name": "환경설정", + "name_raw": { + "ko": "환경설정", + "en": "Settings" + }, + "description": "이커머스 환경설정 권한", + "description_raw": { + "ko": "이커머스 환경설정 권한", + "en": "Ecommerce settings permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 7, + "is_assigned": false, + "is_assignable": false, + "children": [ + { + "id": 51, + "parent_id": 50, + "identifier": "sirsoft-ecommerce.settings.read", + "name": "환경설정 조회", + "name_raw": { + "ko": "환경설정 조회", + "en": "View Settings" + }, + "description": "이커머스 환경설정 조회", + "description_raw": { + "ko": "이커머스 환경설정 조회", + "en": "View ecommerce settings" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 52, + "parent_id": 50, + "identifier": "sirsoft-ecommerce.settings.update", + "name": "환경설정 수정", + "name_raw": { + "ko": "환경설정 수정", + "en": "Update Settings" + }, + "description": "이커머스 환경설정 수정", + "description_raw": { + "ko": "이커머스 환경설정 수정", + "en": "Update ecommerce settings" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 53, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.promotion-coupon", + "name": "쿠폰 관리", + "name_raw": { + "ko": "쿠폰 관리", + "en": "Coupon Management" + }, + "description": "쿠폰 관리 권한", + "description_raw": { + "ko": "쿠폰 관리 권한", + "en": "Coupon management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 8, + "is_assigned": false, + "is_assignable": false, + "children": [ + { + "id": 54, + "parent_id": 53, + "identifier": "sirsoft-ecommerce.promotion-coupon.read", + "name": "쿠폰 조회", + "name_raw": { + "ko": "쿠폰 조회", + "en": "Read Coupons" + }, + "description": "쿠폰 목록 및 상세 조회", + "description_raw": { + "ko": "쿠폰 목록 및 상세 조회", + "en": "Read coupon list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "coupon", + "owner_key": "created_by", + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 55, + "parent_id": 53, + "identifier": "sirsoft-ecommerce.promotion-coupon.create", + "name": "쿠폰 생성", + "name_raw": { + "ko": "쿠폰 생성", + "en": "Create Coupon" + }, + "description": "새 쿠폰 등록", + "description_raw": { + "ko": "새 쿠폰 등록", + "en": "Create new coupon" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 56, + "parent_id": 53, + "identifier": "sirsoft-ecommerce.promotion-coupon.update", + "name": "쿠폰 수정", + "name_raw": { + "ko": "쿠폰 수정", + "en": "Update Coupon" + }, + "description": "쿠폰 정보 수정", + "description_raw": { + "ko": "쿠폰 정보 수정", + "en": "Update coupon information" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "coupon", + "owner_key": "created_by", + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 57, + "parent_id": 53, + "identifier": "sirsoft-ecommerce.promotion-coupon.delete", + "name": "쿠폰 삭제", + "name_raw": { + "ko": "쿠폰 삭제", + "en": "Delete Coupon" + }, + "description": "쿠폰 삭제", + "description_raw": { + "ko": "쿠폰 삭제", + "en": "Delete coupon" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "coupon", + "owner_key": "created_by", + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 58, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.shipping-policies", + "name": "배송정책 관리", + "name_raw": { + "ko": "배송정책 관리", + "en": "Shipping Policy Management" + }, + "description": "배송정책 관리 권한", + "description_raw": { + "ko": "배송정책 관리 권한", + "en": "Shipping policy management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 9, + "is_assigned": false, + "is_assignable": false, + "children": [ + { + "id": 59, + "parent_id": 58, + "identifier": "sirsoft-ecommerce.shipping-policies.read", + "name": "배송정책 조회", + "name_raw": { + "ko": "배송정책 조회", + "en": "Read Shipping Policies" + }, + "description": "배송정책 목록 및 상세 조회", + "description_raw": { + "ko": "배송정책 목록 및 상세 조회", + "en": "Read shipping policy list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "shippingPolicy", + "owner_key": "created_by", + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 60, + "parent_id": 58, + "identifier": "sirsoft-ecommerce.shipping-policies.create", + "name": "배송정책 생성", + "name_raw": { + "ko": "배송정책 생성", + "en": "Create Shipping Policy" + }, + "description": "새 배송정책 등록", + "description_raw": { + "ko": "새 배송정책 등록", + "en": "Create new shipping policy" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 61, + "parent_id": 58, + "identifier": "sirsoft-ecommerce.shipping-policies.update", + "name": "배송정책 수정", + "name_raw": { + "ko": "배송정책 수정", + "en": "Update Shipping Policy" + }, + "description": "배송정책 정보 수정", + "description_raw": { + "ko": "배송정책 정보 수정", + "en": "Update shipping policy information" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "shippingPolicy", + "owner_key": "created_by", + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 62, + "parent_id": 58, + "identifier": "sirsoft-ecommerce.shipping-policies.delete", + "name": "배송정책 삭제", + "name_raw": { + "ko": "배송정책 삭제", + "en": "Delete Shipping Policy" + }, + "description": "배송정책 삭제", + "description_raw": { + "ko": "배송정책 삭제", + "en": "Delete shipping policy" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "shippingPolicy", + "owner_key": "created_by", + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 63, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.product-labels", + "name": "상품 라벨 관리", + "name_raw": { + "ko": "상품 라벨 관리", + "en": "Product Label Management" + }, + "description": "상품 라벨 관리 권한", + "description_raw": { + "ko": "상품 라벨 관리 권한", + "en": "Product label management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 10, + "is_assigned": false, + "is_assignable": false, + "children": [ + { + "id": 64, + "parent_id": 63, + "identifier": "sirsoft-ecommerce.product-labels.read", + "name": "상품 라벨 조회", + "name_raw": { + "ko": "상품 라벨 조회", + "en": "Read Product Labels" + }, + "description": "상품 라벨 목록 및 상세 조회", + "description_raw": { + "ko": "상품 라벨 목록 및 상세 조회", + "en": "Read product label list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 65, + "parent_id": 63, + "identifier": "sirsoft-ecommerce.product-labels.create", + "name": "상품 라벨 생성", + "name_raw": { + "ko": "상품 라벨 생성", + "en": "Create Product Label" + }, + "description": "새 상품 라벨 등록", + "description_raw": { + "ko": "새 상품 라벨 등록", + "en": "Create new product label" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 66, + "parent_id": 63, + "identifier": "sirsoft-ecommerce.product-labels.update", + "name": "상품 라벨 수정", + "name_raw": { + "ko": "상품 라벨 수정", + "en": "Update Product Label" + }, + "description": "상품 라벨 정보 수정", + "description_raw": { + "ko": "상품 라벨 정보 수정", + "en": "Update product label information" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 67, + "parent_id": 63, + "identifier": "sirsoft-ecommerce.product-labels.delete", + "name": "상품 라벨 삭제", + "name_raw": { + "ko": "상품 라벨 삭제", + "en": "Delete Product Label" + }, + "description": "상품 라벨 삭제", + "description_raw": { + "ko": "상품 라벨 삭제", + "en": "Delete product label" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 71, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.reviews", + "name": "리뷰 관리", + "name_raw": { + "ko": "리뷰 관리", + "en": "Review Management" + }, + "description": "상품 리뷰 관리 권한", + "description_raw": { + "ko": "상품 리뷰 관리 권한", + "en": "Product review management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 12, + "is_assigned": false, + "is_assignable": false, + "children": [ + { + "id": 72, + "parent_id": 71, + "identifier": "sirsoft-ecommerce.reviews.read", + "name": "리뷰 조회", + "name_raw": { + "ko": "리뷰 조회", + "en": "Read Reviews" + }, + "description": "리뷰 목록 및 상세 조회", + "description_raw": { + "ko": "리뷰 목록 및 상세 조회", + "en": "Read review list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "review", + "owner_key": "user_id", + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 73, + "parent_id": 71, + "identifier": "sirsoft-ecommerce.reviews.update", + "name": "리뷰 처리", + "name_raw": { + "ko": "리뷰 처리", + "en": "Manage Review" + }, + "description": "리뷰 상태 변경, 답변 등록/수정/삭제, 일괄 처리", + "description_raw": { + "ko": "리뷰 상태 변경, 답변 등록/수정/삭제, 일괄 처리", + "en": "Update review status, manage replies, bulk actions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "review", + "owner_key": "user_id", + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 74, + "parent_id": 71, + "identifier": "sirsoft-ecommerce.reviews.delete", + "name": "리뷰 삭제", + "name_raw": { + "ko": "리뷰 삭제", + "en": "Delete Review" + }, + "description": "리뷰 삭제", + "description_raw": { + "ko": "리뷰 삭제", + "en": "Delete review" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "review", + "owner_key": "user_id", + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 75, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.inquiries", + "name": "문의 관리", + "name_raw": { + "ko": "문의 관리", + "en": "Inquiry Management" + }, + "description": "상품 1:1 문의 관리 권한", + "description_raw": { + "ko": "상품 1:1 문의 관리 권한", + "en": "Product inquiry management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 13, + "is_assigned": false, + "is_assignable": false, + "children": [ + { + "id": 76, + "parent_id": 75, + "identifier": "sirsoft-ecommerce.inquiries.update", + "name": "문의 처리", + "name_raw": { + "ko": "문의 처리", + "en": "Manage Inquiry" + }, + "description": "답변 등록/수정/삭제, 비밀글 내용 열람", + "description_raw": { + "ko": "답변 등록/수정/삭제, 비밀글 내용 열람", + "en": "Create, update, and delete inquiry replies; view secret inquiry contents" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 77, + "parent_id": 75, + "identifier": "sirsoft-ecommerce.inquiries.delete", + "name": "문의 삭제", + "name_raw": { + "ko": "문의 삭제", + "en": "Delete Inquiry" + }, + "description": "고객이 작성한 문의 자체를 삭제", + "description_raw": { + "ko": "고객이 작성한 문의 자체를 삭제", + "en": "Delete inquiries submitted by customers" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 78, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.dashboard", + "name": "대시보드", + "name_raw": { + "ko": "대시보드", + "en": "Dashboard" + }, + "description": "관리자 대시보드 이커머스 영역 조회 권한", + "description_raw": { + "ko": "관리자 대시보드 이커머스 영역 조회 권한", + "en": "Admin dashboard commerce area view permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 14, + "is_assigned": false, + "is_assignable": false, + "children": [ + { + "id": 79, + "parent_id": 78, + "identifier": "sirsoft-ecommerce.dashboard.view", + "name": "대시보드 조회", + "name_raw": { + "ko": "대시보드 조회", + "en": "View Dashboard" + }, + "description": "관리자 대시보드의 이커머스 판매 현황/리뷰/문의 위젯 조회", + "description_raw": { + "ko": "관리자 대시보드의 이커머스 판매 현황/리뷰/문의 위젯 조회", + "en": "View commerce sales/review/inquiry widgets on the admin dashboard" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 80, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.user-products", + "name": "사용자 상품", + "name_raw": { + "ko": "사용자 상품", + "en": "User Products" + }, + "description": "사용자 상품 접근 권한 (블랙컨슈머 차단용)", + "description_raw": { + "ko": "사용자 상품 접근 권한 (블랙컨슈머 차단용)", + "en": "User product access permissions (for blocking malicious consumers)" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 15, + "is_assigned": false, + "is_assignable": false, + "children": [ + { + "id": 81, + "parent_id": 80, + "identifier": "sirsoft-ecommerce.user-products.read", + "name": "상품 조회", + "name_raw": { + "ko": "상품 조회", + "en": "View Products" + }, + "description": "상품 목록 및 상세 조회", + "description_raw": { + "ko": "상품 목록 및 상세 조회", + "en": "View product list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 82, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.user-orders", + "name": "사용자 주문", + "name_raw": { + "ko": "사용자 주문", + "en": "User Orders" + }, + "description": "사용자 주문 관련 권한 (블랙컨슈머 차단용)", + "description_raw": { + "ko": "사용자 주문 관련 권한 (블랙컨슈머 차단용)", + "en": "User order permissions (for blocking malicious consumers)" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 16, + "is_assigned": false, + "is_assignable": false, + "children": [ + { + "id": 83, + "parent_id": 82, + "identifier": "sirsoft-ecommerce.user-orders.create", + "name": "주문하기", + "name_raw": { + "ko": "주문하기", + "en": "Create Order" + }, + "description": "주문 생성", + "description_raw": { + "ko": "주문 생성", + "en": "Create a new order" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 84, + "parent_id": 82, + "identifier": "sirsoft-ecommerce.user-orders.cancel", + "name": "주문 취소", + "name_raw": { + "ko": "주문 취소", + "en": "Cancel Order" + }, + "description": "주문 취소 요청", + "description_raw": { + "ko": "주문 취소 요청", + "en": "Request order cancellation" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 85, + "parent_id": 82, + "identifier": "sirsoft-ecommerce.user-orders.confirm", + "name": "구매확정", + "name_raw": { + "ko": "구매확정", + "en": "Confirm Purchase" + }, + "description": "주문 상품 구매확정", + "description_raw": { + "ko": "주문 상품 구매확정", + "en": "Confirm purchase of order item" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 86, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.user-reviews", + "name": "사용자 리뷰", + "name_raw": { + "ko": "사용자 리뷰", + "en": "User Reviews" + }, + "description": "사용자 리뷰 관련 권한 (블랙컨슈머 차단용)", + "description_raw": { + "ko": "사용자 리뷰 관련 권한 (블랙컨슈머 차단용)", + "en": "User review permissions (for blocking malicious consumers)" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 17, + "is_assigned": false, + "is_assignable": false, + "children": [ + { + "id": 87, + "parent_id": 86, + "identifier": "sirsoft-ecommerce.user-reviews.write", + "name": "리뷰 작성", + "name_raw": { + "ko": "리뷰 작성", + "en": "Write Review" + }, + "description": "상품 리뷰 작성", + "description_raw": { + "ko": "상품 리뷰 작성", + "en": "Write product review" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 88, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.mileage", + "name": "마일리지 관리", + "name_raw": { + "ko": "마일리지 관리", + "en": "Mileage Management" + }, + "description": "마일리지 내역 조회 및 수동 지급/차감 권한", + "description_raw": { + "ko": "마일리지 내역 조회 및 수동 지급/차감 권한", + "en": "Mileage history and manual grant/deduct permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 18, + "is_assigned": false, + "is_assignable": false, + "children": [ + { + "id": 89, + "parent_id": 88, + "identifier": "sirsoft-ecommerce.mileage.read", + "name": "마일리지 내역 조회", + "name_raw": { + "ko": "마일리지 내역 조회", + "en": "Read Mileage" + }, + "description": "마일리지 내역 목록 및 상세 조회", + "description_raw": { + "ko": "마일리지 내역 목록 및 상세 조회", + "en": "Read mileage history list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "mileage-transaction", + "owner_key": "user_id", + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 90, + "parent_id": 88, + "identifier": "sirsoft-ecommerce.mileage.manage", + "name": "마일리지 수동 처리", + "name_raw": { + "ko": "마일리지 수동 처리", + "en": "Manage Mileage" + }, + "description": "마일리지 수동 지급/차감 및 일괄 유효기간 연장", + "description_raw": { + "ko": "마일리지 수동 지급/차감 및 일괄 유효기간 연장", + "en": "Manual grant/deduct and bulk expiry extension" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "mileage-transaction", + "owner_key": "user_id", + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 91, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.user-currency", + "name": "회원 결제 통화 관리", + "name_raw": { + "ko": "회원 결제 통화 관리", + "en": "User Currency Management" + }, + "description": "관리자가 회원별 결제 통화를 변경하는 권한", + "description_raw": { + "ko": "관리자가 회원별 결제 통화를 변경하는 권한", + "en": "Permission for admins to change a user's payment currency" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 19, + "is_assigned": false, + "is_assignable": false, + "children": [ + { + "id": 92, + "parent_id": 91, + "identifier": "sirsoft-ecommerce.user-currency.manage", + "name": "회원 결제 통화 변경", + "name_raw": { + "ko": "회원 결제 통화 변경", + "en": "Manage User Currency" + }, + "description": "회원별 결제 통화 변경", + "description_raw": { + "ko": "회원별 결제 통화 변경", + "en": "Change a user's payment currency" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 93, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.user-shipping-country", + "name": "회원 배송국가 관리", + "name_raw": { + "ko": "회원 배송국가 관리", + "en": "User Shipping Country Management" + }, + "description": "관리자가 회원별 배송국가를 변경하는 권한", + "description_raw": { + "ko": "관리자가 회원별 배송국가를 변경하는 권한", + "en": "Permission for admins to change a user's shipping country" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 20, + "is_assigned": false, + "is_assignable": false, + "children": [ + { + "id": 94, + "parent_id": 93, + "identifier": "sirsoft-ecommerce.user-shipping-country.manage", + "name": "회원 배송국가 변경", + "name_raw": { + "ko": "회원 배송국가 변경", + "en": "Manage User Shipping Country" + }, + "description": "회원별 배송국가 변경", + "description_raw": { + "ko": "회원별 배송국가 변경", + "en": "Change a user's shipping country" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + } + ] + } + ], + "created_at": "2026-07-08 10:43:31", + "updated_at": "2026-07-08 10:43:31", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true, + "can_toggle_status": true + } + }, + "... (총 6건 중 2건 표시)" + ], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + }, + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 6, + "from": 1, + "to": 6, + "has_more_pages": false + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.permissions.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +역할 관리 화면(`admin_role_list.json`)의 목록 데이터를 제공하는 페이지네이션 조회 엔드포인트다. `search`(identifier/name 텍스트 검색)와 `is_active`(활성 여부)로 필터링하며 `per_page` 로 페이지 크기를 조절한다. 응답에는 각 역할의 할당 권한(permission_ids/permission_values/permissions), 소유 확장 정보, 사용자 수, 현재 사용자의 조작 가능 여부(abilities)가 포함된다. `core.permissions.read` 권한이 필요하다. + + +### POST /api/admin/roles + +- **라우트명**: `api.admin.roles.store` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\RoleController@store` +- **인증/권한**: `auth:sanctum` + `permission:core.permissions.create` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | body | string | 예 | max 100 | 대상 확장/리소스의 식별자 | +| name | body | string | 예 | — | 대상의 이름/명칭 | +| description | body | string | 아니오 | — | 설명 | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| permissions | body | array | 아니오 | — | 역할에 부여할 권한 목록. 각 원소는 `{id, scope_type}` (id=권한 식별자, scope_type=적용 범위: null 전체 / role 역할 범위 / self 본인 범위). 전달된 목록 기준으로 역할의 권한 집합이 재설정됨 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.role.store_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/roles HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "identifier": "example-key", + "name": "예시 이름", + "description": "예시 내용입니다.", + "is_active": true, + "permissions": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `14` | 기본 키 (내부 식별자) | +| identifier | string | `probe_6a4dc0a720350` | 역할명 (예: admin, user, manager) | +| name | string | `실측 예시값` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name_raw | object | `{"ko":"실측 예시값","en":"실측 예시값","fr":"실측 예시값"}` | `name` 의 원본 값 (현재 로케일 미해석 원본 JSON/문자열) | +| description | string | `실측 예시값` | 설명 (다국어 필드는 로케일별 값 객체) | +| description_raw | object | `{"ko":"실측 예시값","en":"실측 예시값","fr":"실측 예시값"}` | `description` 의 원본 값 (현재 로케일 미해석 원본 JSON/문자열) | +| extension_type | null | `null` | 이 리소스를 소유한 확장의 타입 (core/module/plugin/template) | +| extension_identifier | null | `null` | 이 리소스를 소유한 확장의 식별자 | +| extension_name | null | `null` | 이 리소스를 소유한 확장의 표시 이름 (manifest name) | +| is_deletable | boolean | `true` | deletable 여부 | +| is_active | boolean | `true` | active 여부 | +| created_at | string | `2026-07-08 12:14:47` | 생성 일시 | +| updated_at | string | `2026-07-08 12:14:47` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true,"c…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "역할이 성공적으로 생성되었습니다.", + "data": { + "id": 14, + "identifier": "probe_6a4dc0a720350", + "name": "실측 예시값", + "name_raw": { + "ko": "실측 예시값", + "en": "실측 예시값", + "fr": "실측 예시값" + }, + "description": "실측 예시값", + "description_raw": { + "ko": "실측 예시값", + "en": "실측 예시값", + "fr": "실측 예시값" + }, + "extension_type": null, + "extension_identifier": null, + "extension_name": null, + "is_deletable": true, + "is_active": true, + "created_at": "2026-07-08 12:14:47", + "updated_at": "2026-07-08 12:14:47", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true, + "can_toggle_status": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.permissions.create`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +새 역할을 생성한다. `identifier` 는 소문자로 시작하는 영숫자·언더스코어 형식(`^[a-z][a-z0-9_]*$`)이어야 하고 전역 고유해야 한다. `name`·`description` 은 다국어 필드로, 문자열로 보내면 설정된 로케일 전체에 동일 값이 채워지고 객체(`{"ko":..., "en":...}`)로도 보낼 수 있다. `permissions` 는 `[{id, scope_type}]` 형식으로 부여할 권한과 각 권한의 적용 범위(scope_type: null=전체, role, self)를 지정한다. 검증 규칙은 `core.role.store_validation_rules` 필터 훅으로 확장이 확장할 수 있다. `core.permissions.create` 권한이 필요하며 성공 시 201 로 생성된 역할을 반환한다. + + +### GET /api/admin/roles/active + +- **라우트명**: `api.admin.roles.active` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\RoleController@active` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/roles/active HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| identifier | string | `admin` | 역할명 (예: admin, user, manager) | +| name | string | `관리자` | 역할 이름 (다국어 JSON) | +| name_raw | object | `{"ko":"관리자","en":"Administrator"}` | `name` 의 원본 값 (현재 로케일 미해석 원본 JSON/문자열) | +| description | string | `시스템의 모든 기능에 접근할 수 있는 최고 관리자입니다.` | 역할 설명 (다국어 JSON) | +| description_raw | object | `{"ko":"시스템의 모든 기능에 접근할 수 있는 최고 관리자입니다.","en":"Super admin…` | `description` 의 원본 값 (현재 로케일 미해석 원본 JSON/문자열) | +| extension_type | string | `core` | 확장 소유 타입: core(코어), module(모듈), plugin(플러그인), NULL(사용자 정의) | +| extension_identifier | string | `core` | 확장 식별자 (예: core, sirsoft-board, sirsoft-payment) | +| extension_name | string | `이커머스` | 이 리소스를 소유한 확장의 표시 이름 (manifest name) | +| is_deletable | boolean | `false` | deletable 여부 | +| is_active | boolean | `true` | active 여부 | +| created_at | string | `2026-05-27 15:20:18` | 생성 일시 | +| updated_at | string | `2026-06-30 13:41:48` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true,"c…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "역할 정보를 성공적으로 가져왔습니다.", + "data": { + "data": [ + { + "id": 1, + "identifier": "admin", + "name": "관리자", + "name_raw": { + "ko": "관리자", + "en": "Administrator" + }, + "description": "시스템의 모든 기능에 접근할 수 있는 최고 관리자입니다.", + "description_raw": { + "ko": "시스템의 모든 기능에 접근할 수 있는 최고 관리자입니다.", + "en": "Super administrator with access to all system features." + }, + "extension_type": "core", + "extension_identifier": "core", + "extension_name": null, + "is_deletable": false, + "is_active": true, + "created_at": "2026-07-07 16:38:39", + "updated_at": "2026-07-08 10:47:51", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true, + "can_toggle_status": true + } + }, + { + "id": 2, + "identifier": "user", + "name": "일반 사용자", + "name_raw": { + "ko": "일반 사용자", + "en": "User" + }, + "description": "기본 사용자 역할입니다.", + "description_raw": { + "ko": "기본 사용자 역할입니다.", + "en": "Default user role." + }, + "extension_type": "core", + "extension_identifier": "core", + "extension_name": null, + "is_deletable": false, + "is_active": true, + "created_at": "2026-07-07 16:38:39", + "updated_at": "2026-07-08 10:47:51", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true, + "can_toggle_status": true + } + }, + "... (총 6건 중 2건 표시)" + ], + "abilities": { + "can_assign_roles": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** + +셀렉트 UI(사용자 폼·메뉴 편집의 역할 선택 등)에 채울 활성 역할 목록을 제공한다. 별도 권한 미들웨어가 없어 인증만 되면 호출 가능하지만, 내부에서 권한에 따라 범위가 갈린다. `core.permissions.read` 권한 보유자는 전체 활성 역할을 받고(사용자에게 역할을 부여하는 관리 용도), 미보유자는 자신에게 부여된 활성 역할만 받는다(자기 정보 폼 표시 용도). 응답의 `abilities.can_assign_roles` 는 `core.permissions.update` 권한 보유 여부를 나타낸다. + + +### DELETE /api/admin/roles/{role} + +- **라우트명**: `api.admin.roles.destroy` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\RoleController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:core.permissions.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| role | path | string | 예 | — | 대상 role의 식별자 | + +**요청 예시** + +```http +DELETE /api/admin/roles/4 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "역할이 성공적으로 삭제되었습니다.", + "data": null +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.permissions.delete`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + +역할을 삭제한다. 코어 소유 역할(admin/user 등)은 403(`role.system_role_delete_error`)으로, 모듈·플러그인이 소유한 확장 역할은 403(`role.extension_owned_role_delete_error`)으로 거부된다. 삭제 가능한(사용자 정의) 역할만 제거되며, CASCADE 에 의존하지 않고 권한·메뉴·사용자 매핑을 명시적으로 해제한 뒤 역할을 삭제한다. `core.permissions.delete` 권한이 필요하다. + + +### GET /api/admin/roles/{role} + +- **라우트명**: `api.admin.roles.show` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\RoleController@show` +- **인증/권한**: `auth:sanctum` + `permission:core.permissions.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| role | path | string | 예 | — | 대상 role의 식별자 | + +**요청 예시** + +```http +GET /api/admin/roles/4 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `66` | 기본 키 (내부 식별자) | +| identifier | string | `apidoc-sample-role` | 역할명 (예: admin, user, manager) | +| name | string | `API 문서 샘플 역할` | 역할 이름 (다국어 JSON) | +| name_raw | object | `{"ko":"API 문서 샘플 역할","en":"API Doc Sample Role"}` | `name` 의 원본 값 (현재 로케일 미해석 원본 JSON/문자열) | +| description | string | `문서 실측용 역할` | 역할 설명 (다국어 JSON) | +| description_raw | object | `{"ko":"문서 실측용 역할","en":"Sample role for API docs"}` | `description` 의 원본 값 (현재 로케일 미해석 원본 JSON/문자열) | +| extension_type | null | `null` | 확장 소유 타입: core(코어), module(모듈), plugin(플러그인), NULL(사용자 정의) | +| extension_identifier | null | `null` | 확장 식별자 (예: core, sirsoft-board, sirsoft-payment) | +| extension_name | null | `null` | 이 리소스를 소유한 확장의 표시 이름 (manifest name) | +| is_deletable | boolean | `true` | deletable 여부 | +| is_active | boolean | `true` | active 여부 | +| users_count | integer | `0` | users 개수 (집계) | +| permission_ids | array | `[1,85,100]` | permission 식별자 배열 (연관 리소스 참조) | +| permission_values | array | `[{"id":1,"scope_type":null},{"id":85,"scope_type":null},{…` | 할당된 각 권한의 id와 적용 범위만 담은 경량 목록 (원소 id/scope_type — 역할-권한 pivot 파생). scope_type: null=전체, role=역할 범위, self=본인 범위 | +| permissions | array | `[{"id":1,"parent_id":null,"identifier":"core","name":"코어"…` | 연결된 권한 목록 (id/identifier/name — 역할 경유 권한 관계 파생) | +| created_at | string | `2026-07-06 19:15:16` | 생성 일시 | +| updated_at | string | `2026-07-06 19:15:16` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true,"c…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "역할 정보를 성공적으로 가져왔습니다.", + "data": { + "id": 4, + "identifier": "apidoc-sample-role", + "name": "API 문서 샘플 역할", + "name_raw": { + "ko": "API 문서 샘플 역할", + "en": "API Doc Sample Role" + }, + "description": "문서 실측용 역할", + "description_raw": { + "ko": "문서 실측용 역할", + "en": "Sample role for API docs" + }, + "extension_type": null, + "extension_identifier": null, + "extension_name": null, + "is_deletable": true, + "is_active": true, + "users_count": 1, + "permission_ids": [ + 1, + 2, + 3, + 4, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 109, + 110, + 111, + 112, + 113, + 114, + 115, + 116, + 117, + 118, + 119, + 120, + 121, + 122, + 123, + 124, + 125, + 126, + 127, + 128, + 129, + 130, + 131, + 132, + 133, + 134, + 135, + 136, + 137, + 138, + 139, + 140, + 141, + 142, + 143, + 144, + 145, + 146, + 147, + 148, + 149, + 150, + 151, + 152, + 153, + 154, + 155, + 156, + 157, + 158, + 159, + 160, + 161, + 162, + 163, + 164, + 165, + 166, + 167, + 168, + 169, + 170, + 171, + 172, + 173, + 174, + 175, + 176, + 177, + 178, + 179, + 180, + 181, + 182, + 183, + 184, + 185, + 186, + 187, + 188, + 189, + 190, + 191, + 192, + 193, + 194, + 195, + 196, + 197, + 198 + ], + "permission_values": [ + { + "id": 1, + "scope_type": null + }, + { + "id": 2, + "scope_type": null + }, + { + "id": 3, + "scope_type": null + }, + { + "id": 4, + "scope_type": null + }, + { + "id": 21, + "scope_type": null + }, + { + "id": 22, + "scope_type": null + }, + { + "id": 23, + "scope_type": null + }, + { + "id": 24, + "scope_type": null + }, + { + "id": 25, + "scope_type": null + }, + { + "id": 26, + "scope_type": null + }, + { + "id": 27, + "scope_type": null + }, + { + "id": 28, + "scope_type": null + }, + { + "id": 29, + "scope_type": null + }, + { + "id": 30, + "scope_type": null + }, + { + "id": 31, + "scope_type": null + }, + { + "id": 32, + "scope_type": null + }, + { + "id": 33, + "scope_type": null + }, + { + "id": 34, + "scope_type": null + }, + { + "id": 35, + "scope_type": null + }, + { + "id": 36, + "scope_type": null + }, + { + "id": 37, + "scope_type": null + }, + { + "id": 38, + "scope_type": null + }, + { + "id": 39, + "scope_type": null + }, + { + "id": 40, + "scope_type": null + }, + { + "id": 41, + "scope_type": null + }, + { + "id": 42, + "scope_type": null + }, + { + "id": 43, + "scope_type": null + }, + { + "id": 44, + "scope_type": null + }, + { + "id": 45, + "scope_type": null + }, + { + "id": 46, + "scope_type": null + }, + { + "id": 47, + "scope_type": null + }, + { + "id": 48, + "scope_type": null + }, + { + "id": 49, + "scope_type": null + }, + { + "id": 50, + "scope_type": null + }, + { + "id": 51, + "scope_type": null + }, + { + "id": 52, + "scope_type": null + }, + { + "id": 53, + "scope_type": null + }, + { + "id": 54, + "scope_type": null + }, + { + "id": 55, + "scope_type": null + }, + { + "id": 56, + "scope_type": null + }, + { + "id": 57, + "scope_type": null + }, + { + "id": 58, + "scope_type": null + }, + { + "id": 59, + "scope_type": null + }, + { + "id": 60, + "scope_type": null + }, + { + "id": 61, + "scope_type": null + }, + { + "id": 62, + "scope_type": null + }, + { + "id": 63, + "scope_type": null + }, + { + "id": 64, + "scope_type": null + }, + { + "id": 65, + "scope_type": null + }, + { + "id": 66, + "scope_type": null + }, + { + "id": 67, + "scope_type": null + }, + { + "id": 68, + "scope_type": null + }, + { + "id": 69, + "scope_type": null + }, + { + "id": 70, + "scope_type": null + }, + { + "id": 71, + "scope_type": null + }, + { + "id": 72, + "scope_type": null + }, + { + "id": 73, + "scope_type": null + }, + { + "id": 74, + "scope_type": null + }, + { + "id": 75, + "scope_type": null + }, + { + "id": 76, + "scope_type": null + }, + { + "id": 77, + "scope_type": null + }, + { + "id": 78, + "scope_type": null + }, + { + "id": 79, + "scope_type": null + }, + { + "id": 80, + "scope_type": null + }, + { + "id": 81, + "scope_type": null + }, + { + "id": 82, + "scope_type": null + }, + { + "id": 83, + "scope_type": null + }, + { + "id": 84, + "scope_type": null + }, + { + "id": 85, + "scope_type": null + }, + { + "id": 86, + "scope_type": null + }, + { + "id": 87, + "scope_type": null + }, + { + "id": 88, + "scope_type": null + }, + { + "id": 89, + "scope_type": null + }, + { + "id": 90, + "scope_type": null + }, + { + "id": 91, + "scope_type": null + }, + { + "id": 92, + "scope_type": null + }, + { + "id": 93, + "scope_type": null + }, + { + "id": 94, + "scope_type": null + }, + { + "id": 95, + "scope_type": null + }, + { + "id": 96, + "scope_type": null + }, + { + "id": 97, + "scope_type": null + }, + { + "id": 98, + "scope_type": null + }, + { + "id": 99, + "scope_type": null + }, + { + "id": 100, + "scope_type": null + }, + { + "id": 101, + "scope_type": null + }, + { + "id": 102, + "scope_type": null + }, + { + "id": 103, + "scope_type": null + }, + { + "id": 104, + "scope_type": null + }, + { + "id": 105, + "scope_type": null + }, + { + "id": 106, + "scope_type": null + }, + { + "id": 107, + "scope_type": null + }, + { + "id": 108, + "scope_type": null + }, + { + "id": 109, + "scope_type": null + }, + { + "id": 110, + "scope_type": null + }, + { + "id": 111, + "scope_type": null + }, + { + "id": 112, + "scope_type": null + }, + { + "id": 113, + "scope_type": null + }, + { + "id": 114, + "scope_type": null + }, + { + "id": 115, + "scope_type": null + }, + { + "id": 116, + "scope_type": null + }, + { + "id": 117, + "scope_type": null + }, + { + "id": 118, + "scope_type": null + }, + { + "id": 119, + "scope_type": null + }, + { + "id": 120, + "scope_type": null + }, + { + "id": 121, + "scope_type": null + }, + { + "id": 122, + "scope_type": null + }, + { + "id": 123, + "scope_type": null + }, + { + "id": 124, + "scope_type": null + }, + { + "id": 125, + "scope_type": null + }, + { + "id": 126, + "scope_type": null + }, + { + "id": 127, + "scope_type": null + }, + { + "id": 128, + "scope_type": null + }, + { + "id": 129, + "scope_type": null + }, + { + "id": 130, + "scope_type": null + }, + { + "id": 131, + "scope_type": null + }, + { + "id": 132, + "scope_type": null + }, + { + "id": 133, + "scope_type": null + }, + { + "id": 134, + "scope_type": null + }, + { + "id": 135, + "scope_type": null + }, + { + "id": 136, + "scope_type": null + }, + { + "id": 137, + "scope_type": null + }, + { + "id": 138, + "scope_type": null + }, + { + "id": 139, + "scope_type": null + }, + { + "id": 140, + "scope_type": null + }, + { + "id": 141, + "scope_type": null + }, + { + "id": 142, + "scope_type": null + }, + { + "id": 143, + "scope_type": null + }, + { + "id": 144, + "scope_type": null + }, + { + "id": 145, + "scope_type": null + }, + { + "id": 146, + "scope_type": null + }, + { + "id": 147, + "scope_type": null + }, + { + "id": 148, + "scope_type": null + }, + { + "id": 149, + "scope_type": null + }, + { + "id": 150, + "scope_type": null + }, + { + "id": 151, + "scope_type": null + }, + { + "id": 152, + "scope_type": null + }, + { + "id": 153, + "scope_type": null + }, + { + "id": 154, + "scope_type": null + }, + { + "id": 155, + "scope_type": null + }, + { + "id": 156, + "scope_type": null + }, + { + "id": 157, + "scope_type": null + }, + { + "id": 158, + "scope_type": null + }, + { + "id": 159, + "scope_type": null + }, + { + "id": 160, + "scope_type": null + }, + { + "id": 161, + "scope_type": null + }, + { + "id": 162, + "scope_type": null + }, + { + "id": 163, + "scope_type": null + }, + { + "id": 164, + "scope_type": null + }, + { + "id": 165, + "scope_type": null + }, + { + "id": 166, + "scope_type": null + }, + { + "id": 167, + "scope_type": null + }, + { + "id": 168, + "scope_type": null + }, + { + "id": 169, + "scope_type": null + }, + { + "id": 170, + "scope_type": null + }, + { + "id": 171, + "scope_type": null + }, + { + "id": 172, + "scope_type": null + }, + { + "id": 173, + "scope_type": null + }, + { + "id": 174, + "scope_type": null + }, + { + "id": 175, + "scope_type": null + }, + { + "id": 176, + "scope_type": null + }, + { + "id": 177, + "scope_type": null + }, + { + "id": 178, + "scope_type": null + }, + { + "id": 179, + "scope_type": null + }, + { + "id": 180, + "scope_type": null + }, + { + "id": 181, + "scope_type": null + }, + { + "id": 182, + "scope_type": null + }, + { + "id": 183, + "scope_type": null + }, + { + "id": 184, + "scope_type": null + }, + { + "id": 185, + "scope_type": null + }, + { + "id": 186, + "scope_type": null + }, + { + "id": 187, + "scope_type": null + }, + { + "id": 188, + "scope_type": null + }, + { + "id": 189, + "scope_type": null + }, + { + "id": 190, + "scope_type": null + }, + { + "id": 191, + "scope_type": null + }, + { + "id": 192, + "scope_type": null + }, + { + "id": 193, + "scope_type": null + }, + { + "id": 194, + "scope_type": null + }, + { + "id": 195, + "scope_type": null + }, + { + "id": 196, + "scope_type": null + }, + { + "id": 197, + "scope_type": null + }, + { + "id": 198, + "scope_type": null + } + ], + "permissions": [ + { + "id": 3, + "parent_id": null, + "identifier": "sirsoft-board", + "name": "게시판", + "name_raw": { + "ko": "게시판", + "en": "Board" + }, + "description": "게시판 모듈 권한", + "description_raw": { + "ko": "게시판 모듈 권한", + "en": "Board module permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "resource_route_key": null, + "owner_key": null, + "order": 100, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 95, + "parent_id": 3, + "identifier": "sirsoft-board.boards", + "name": "게시판 관리", + "name_raw": { + "ko": "게시판 관리", + "en": "Board Management" + }, + "description": "게시판 관리 권한", + "description_raw": { + "ko": "게시판 관리 권한", + "en": "Board management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 96, + "parent_id": 95, + "identifier": "sirsoft-board.boards.read", + "name": "게시판 조회", + "name_raw": { + "ko": "게시판 조회", + "en": "View Boards" + }, + "description": "게시판 목록 및 상세 조회", + "description_raw": { + "ko": "게시판 목록 및 상세 조회", + "en": "View board list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "resource_route_key": "board", + "owner_key": "created_by", + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 97, + "parent_id": 95, + "identifier": "sirsoft-board.boards.create", + "name": "게시판 생성", + "name_raw": { + "ko": "게시판 생성", + "en": "Create Board" + }, + "description": "새 게시판 생성", + "description_raw": { + "ko": "새 게시판 생성", + "en": "Create new board" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 98, + "parent_id": 95, + "identifier": "sirsoft-board.boards.update", + "name": "게시판 수정", + "name_raw": { + "ko": "게시판 수정", + "en": "Update Board" + }, + "description": "게시판 설정 수정", + "description_raw": { + "ko": "게시판 설정 수정", + "en": "Update board settings" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "resource_route_key": "board", + "owner_key": "created_by", + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 99, + "parent_id": 95, + "identifier": "sirsoft-board.boards.delete", + "name": "게시판 삭제", + "name_raw": { + "ko": "게시판 삭제", + "en": "Delete Board" + }, + "description": "게시판 삭제", + "description_raw": { + "ko": "게시판 삭제", + "en": "Delete board" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "resource_route_key": "board", + "owner_key": "created_by", + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 100, + "parent_id": 3, + "identifier": "sirsoft-board.settings", + "name": "환경설정", + "name_raw": { + "ko": "환경설정", + "en": "Settings" + }, + "description": "게시판 환경설정 권한", + "description_raw": { + "ko": "게시판 환경설정 권한", + "en": "Board settings permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 101, + "parent_id": 100, + "identifier": "sirsoft-board.settings.read", + "name": "환경설정 조회", + "name_raw": { + "ko": "환경설정 조회", + "en": "View Settings" + }, + "description": "게시판 환경설정 조회", + "description_raw": { + "ko": "게시판 환경설정 조회", + "en": "View board settings" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 102, + "parent_id": 100, + "identifier": "sirsoft-board.settings.update", + "name": "환경설정 수정", + "name_raw": { + "ko": "환경설정 수정", + "en": "Update Settings" + }, + "description": "게시판 환경설정 수정", + "description_raw": { + "ko": "게시판 환경설정 수정", + "en": "Update board settings" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 103, + "parent_id": 3, + "identifier": "sirsoft-board.identity.policies", + "name": "게시판 본인인증 정책", + "name_raw": { + "ko": "게시판 본인인증 정책", + "en": "Board Identity Policies" + }, + "description": "게시판 컨텍스트의 본인인증 정책 관리 권한", + "description_raw": { + "ko": "게시판 컨텍스트의 본인인증 정책 관리 권한", + "en": "Manage identity verification policies in board context" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "resource_route_key": null, + "owner_key": null, + "order": 3, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 104, + "parent_id": 103, + "identifier": "sirsoft-board.identity.policies.read", + "name": "본인인증 정책 조회", + "name_raw": { + "ko": "본인인증 정책 조회", + "en": "View Identity Policies" + }, + "description": "게시판 본인인증 정책 조회", + "description_raw": { + "ko": "게시판 본인인증 정책 조회", + "en": "View board identity policies" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 105, + "parent_id": 103, + "identifier": "sirsoft-board.identity.policies.update", + "name": "본인인증 정책 수정", + "name_raw": { + "ko": "본인인증 정책 수정", + "en": "Update Identity Policies" + }, + "description": "게시판 본인인증 정책 수정/추가/삭제", + "description_raw": { + "ko": "게시판 본인인증 정책 수정/추가/삭제", + "en": "Update, add, delete board identity policies" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 106, + "parent_id": 3, + "identifier": "sirsoft-board.reports", + "name": "게시판 신고 관리", + "name_raw": { + "ko": "게시판 신고 관리", + "en": "Report Management" + }, + "description": "게시판 신고 관리 권한", + "description_raw": { + "ko": "게시판 신고 관리 권한", + "en": "Board report management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "resource_route_key": null, + "owner_key": null, + "order": 4, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 107, + "parent_id": 106, + "identifier": "sirsoft-board.reports.view", + "name": "신고 조회", + "name_raw": { + "ko": "신고 조회", + "en": "View Reports" + }, + "description": "신고 목록 및 상세 조회", + "description_raw": { + "ko": "신고 목록 및 상세 조회", + "en": "View report list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "resource_route_key": "report", + "owner_key": "reporter_id", + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 108, + "parent_id": 106, + "identifier": "sirsoft-board.reports.manage", + "name": "신고 처리", + "name_raw": { + "ko": "신고 처리", + "en": "Manage Reports" + }, + "description": "신고 상태 변경 및 처리", + "description_raw": { + "ko": "신고 상태 변경 및 처리", + "en": "Update report status and process" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "resource_route_key": "report", + "owner_key": "reporter_id", + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + } + ] + }, + { + "id": 21, + "parent_id": null, + "identifier": "sirsoft-ecommerce", + "name": "이커머스", + "name_raw": { + "ko": "이커머스", + "en": "Ecommerce" + }, + "description": "이커머스 모듈 권한", + "description_raw": { + "ko": "이커머스 모듈 권한", + "en": "Ecommerce module permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 100, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 22, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.products", + "name": "상품 관리", + "name_raw": { + "ko": "상품 관리", + "en": "Product Management" + }, + "description": "상품 관리 권한", + "description_raw": { + "ko": "상품 관리 권한", + "en": "Product management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 23, + "parent_id": 22, + "identifier": "sirsoft-ecommerce.products.read", + "name": "상품 조회", + "name_raw": { + "ko": "상품 조회", + "en": "Read Products" + }, + "description": "상품 목록 및 상세 조회", + "description_raw": { + "ko": "상품 목록 및 상세 조회", + "en": "Read product list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "product", + "owner_key": "created_by", + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 24, + "parent_id": 22, + "identifier": "sirsoft-ecommerce.products.create", + "name": "상품 생성", + "name_raw": { + "ko": "상품 생성", + "en": "Create Product" + }, + "description": "새 상품 등록", + "description_raw": { + "ko": "새 상품 등록", + "en": "Create new product" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 25, + "parent_id": 22, + "identifier": "sirsoft-ecommerce.products.update", + "name": "상품 수정", + "name_raw": { + "ko": "상품 수정", + "en": "Update Product" + }, + "description": "상품 정보 수정", + "description_raw": { + "ko": "상품 정보 수정", + "en": "Update product information" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "product", + "owner_key": "created_by", + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 26, + "parent_id": 22, + "identifier": "sirsoft-ecommerce.products.delete", + "name": "상품 삭제", + "name_raw": { + "ko": "상품 삭제", + "en": "Delete Product" + }, + "description": "상품 삭제", + "description_raw": { + "ko": "상품 삭제", + "en": "Delete product" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "product", + "owner_key": "created_by", + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 27, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.orders", + "name": "주문 관리", + "name_raw": { + "ko": "주문 관리", + "en": "Order Management" + }, + "description": "주문 관리 권한", + "description_raw": { + "ko": "주문 관리 권한", + "en": "Order management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 28, + "parent_id": 27, + "identifier": "sirsoft-ecommerce.orders.read", + "name": "주문 조회", + "name_raw": { + "ko": "주문 조회", + "en": "Read Orders" + }, + "description": "주문 목록 및 상세 조회", + "description_raw": { + "ko": "주문 목록 및 상세 조회", + "en": "Read order list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "order", + "owner_key": "user_id", + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 29, + "parent_id": 27, + "identifier": "sirsoft-ecommerce.orders.update", + "name": "주문 수정", + "name_raw": { + "ko": "주문 수정", + "en": "Update Order" + }, + "description": "주문 상태 변경", + "description_raw": { + "ko": "주문 상태 변경", + "en": "Update order status" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "order", + "owner_key": "user_id", + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 30, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.categories", + "name": "카테고리 관리", + "name_raw": { + "ko": "카테고리 관리", + "en": "Category Management" + }, + "description": "카테고리 관리 권한", + "description_raw": { + "ko": "카테고리 관리 권한", + "en": "Category management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 3, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 31, + "parent_id": 30, + "identifier": "sirsoft-ecommerce.categories.read", + "name": "카테고리 조회", + "name_raw": { + "ko": "카테고리 조회", + "en": "Read Categories" + }, + "description": "카테고리 목록 및 상세 조회", + "description_raw": { + "ko": "카테고리 목록 및 상세 조회", + "en": "Read category list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 32, + "parent_id": 30, + "identifier": "sirsoft-ecommerce.categories.create", + "name": "카테고리 생성", + "name_raw": { + "ko": "카테고리 생성", + "en": "Create Category" + }, + "description": "카테고리 생성", + "description_raw": { + "ko": "카테고리 생성", + "en": "Create category" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 33, + "parent_id": 30, + "identifier": "sirsoft-ecommerce.categories.update", + "name": "카테고리 수정", + "name_raw": { + "ko": "카테고리 수정", + "en": "Update Category" + }, + "description": "카테고리 수정", + "description_raw": { + "ko": "카테고리 수정", + "en": "Update category" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 34, + "parent_id": 30, + "identifier": "sirsoft-ecommerce.categories.delete", + "name": "카테고리 삭제", + "name_raw": { + "ko": "카테고리 삭제", + "en": "Delete Category" + }, + "description": "카테고리 삭제", + "description_raw": { + "ko": "카테고리 삭제", + "en": "Delete category" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 35, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.brands", + "name": "브랜드 관리", + "name_raw": { + "ko": "브랜드 관리", + "en": "Brand Management" + }, + "description": "브랜드 관리 권한", + "description_raw": { + "ko": "브랜드 관리 권한", + "en": "Brand management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 4, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 36, + "parent_id": 35, + "identifier": "sirsoft-ecommerce.brands.read", + "name": "브랜드 조회", + "name_raw": { + "ko": "브랜드 조회", + "en": "Read Brands" + }, + "description": "브랜드 목록 및 상세 조회", + "description_raw": { + "ko": "브랜드 목록 및 상세 조회", + "en": "Read brand list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "brand", + "owner_key": "created_by", + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 37, + "parent_id": 35, + "identifier": "sirsoft-ecommerce.brands.create", + "name": "브랜드 생성", + "name_raw": { + "ko": "브랜드 생성", + "en": "Create Brand" + }, + "description": "브랜드 생성", + "description_raw": { + "ko": "브랜드 생성", + "en": "Create brand" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 38, + "parent_id": 35, + "identifier": "sirsoft-ecommerce.brands.update", + "name": "브랜드 수정", + "name_raw": { + "ko": "브랜드 수정", + "en": "Update Brand" + }, + "description": "브랜드 수정", + "description_raw": { + "ko": "브랜드 수정", + "en": "Update brand" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "brand", + "owner_key": "created_by", + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 39, + "parent_id": 35, + "identifier": "sirsoft-ecommerce.brands.delete", + "name": "브랜드 삭제", + "name_raw": { + "ko": "브랜드 삭제", + "en": "Delete Brand" + }, + "description": "브랜드 삭제", + "description_raw": { + "ko": "브랜드 삭제", + "en": "Delete brand" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "brand", + "owner_key": "created_by", + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 40, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.product-notice-templates", + "name": "상품정보제공고시 관리", + "name_raw": { + "ko": "상품정보제공고시 관리", + "en": "Product Notice Template Management" + }, + "description": "상품정보제공고시 템플릿 관리 권한", + "description_raw": { + "ko": "상품정보제공고시 템플릿 관리 권한", + "en": "Product notice template management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 5, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 41, + "parent_id": 40, + "identifier": "sirsoft-ecommerce.product-notice-templates.read", + "name": "조회", + "name_raw": { + "ko": "조회", + "en": "Read" + }, + "description": "상품정보제공고시 조회", + "description_raw": { + "ko": "상품정보제공고시 조회", + "en": "Read product notice templates" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 42, + "parent_id": 40, + "identifier": "sirsoft-ecommerce.product-notice-templates.create", + "name": "생성", + "name_raw": { + "ko": "생성", + "en": "Create" + }, + "description": "상품정보제공고시 생성", + "description_raw": { + "ko": "상품정보제공고시 생성", + "en": "Create product notice template" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 43, + "parent_id": 40, + "identifier": "sirsoft-ecommerce.product-notice-templates.update", + "name": "수정", + "name_raw": { + "ko": "수정", + "en": "Update" + }, + "description": "상품정보제공고시 수정", + "description_raw": { + "ko": "상품정보제공고시 수정", + "en": "Update product notice template" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 44, + "parent_id": 40, + "identifier": "sirsoft-ecommerce.product-notice-templates.delete", + "name": "삭제", + "name_raw": { + "ko": "삭제", + "en": "Delete" + }, + "description": "상품정보제공고시 삭제", + "description_raw": { + "ko": "상품정보제공고시 삭제", + "en": "Delete product notice template" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 45, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.product-common-infos", + "name": "공통정보 관리", + "name_raw": { + "ko": "공통정보 관리", + "en": "Product Common Info Management" + }, + "description": "상품 공통정보 관리 권한", + "description_raw": { + "ko": "상품 공통정보 관리 권한", + "en": "Product common info management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 6, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 46, + "parent_id": 45, + "identifier": "sirsoft-ecommerce.product-common-infos.read", + "name": "조회", + "name_raw": { + "ko": "조회", + "en": "Read" + }, + "description": "공통정보 조회", + "description_raw": { + "ko": "공통정보 조회", + "en": "Read product common info" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 47, + "parent_id": 45, + "identifier": "sirsoft-ecommerce.product-common-infos.create", + "name": "생성", + "name_raw": { + "ko": "생성", + "en": "Create" + }, + "description": "공통정보 생성", + "description_raw": { + "ko": "공통정보 생성", + "en": "Create product common info" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 48, + "parent_id": 45, + "identifier": "sirsoft-ecommerce.product-common-infos.update", + "name": "수정", + "name_raw": { + "ko": "수정", + "en": "Update" + }, + "description": "공통정보 수정", + "description_raw": { + "ko": "공통정보 수정", + "en": "Update product common info" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 49, + "parent_id": 45, + "identifier": "sirsoft-ecommerce.product-common-infos.delete", + "name": "삭제", + "name_raw": { + "ko": "삭제", + "en": "Delete" + }, + "description": "공통정보 삭제", + "description_raw": { + "ko": "공통정보 삭제", + "en": "Delete product common info" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 50, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.settings", + "name": "환경설정", + "name_raw": { + "ko": "환경설정", + "en": "Settings" + }, + "description": "이커머스 환경설정 권한", + "description_raw": { + "ko": "이커머스 환경설정 권한", + "en": "Ecommerce settings permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 7, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 51, + "parent_id": 50, + "identifier": "sirsoft-ecommerce.settings.read", + "name": "환경설정 조회", + "name_raw": { + "ko": "환경설정 조회", + "en": "View Settings" + }, + "description": "이커머스 환경설정 조회", + "description_raw": { + "ko": "이커머스 환경설정 조회", + "en": "View ecommerce settings" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 52, + "parent_id": 50, + "identifier": "sirsoft-ecommerce.settings.update", + "name": "환경설정 수정", + "name_raw": { + "ko": "환경설정 수정", + "en": "Update Settings" + }, + "description": "이커머스 환경설정 수정", + "description_raw": { + "ko": "이커머스 환경설정 수정", + "en": "Update ecommerce settings" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 53, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.promotion-coupon", + "name": "쿠폰 관리", + "name_raw": { + "ko": "쿠폰 관리", + "en": "Coupon Management" + }, + "description": "쿠폰 관리 권한", + "description_raw": { + "ko": "쿠폰 관리 권한", + "en": "Coupon management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 8, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 54, + "parent_id": 53, + "identifier": "sirsoft-ecommerce.promotion-coupon.read", + "name": "쿠폰 조회", + "name_raw": { + "ko": "쿠폰 조회", + "en": "Read Coupons" + }, + "description": "쿠폰 목록 및 상세 조회", + "description_raw": { + "ko": "쿠폰 목록 및 상세 조회", + "en": "Read coupon list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "coupon", + "owner_key": "created_by", + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 55, + "parent_id": 53, + "identifier": "sirsoft-ecommerce.promotion-coupon.create", + "name": "쿠폰 생성", + "name_raw": { + "ko": "쿠폰 생성", + "en": "Create Coupon" + }, + "description": "새 쿠폰 등록", + "description_raw": { + "ko": "새 쿠폰 등록", + "en": "Create new coupon" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 56, + "parent_id": 53, + "identifier": "sirsoft-ecommerce.promotion-coupon.update", + "name": "쿠폰 수정", + "name_raw": { + "ko": "쿠폰 수정", + "en": "Update Coupon" + }, + "description": "쿠폰 정보 수정", + "description_raw": { + "ko": "쿠폰 정보 수정", + "en": "Update coupon information" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "coupon", + "owner_key": "created_by", + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 57, + "parent_id": 53, + "identifier": "sirsoft-ecommerce.promotion-coupon.delete", + "name": "쿠폰 삭제", + "name_raw": { + "ko": "쿠폰 삭제", + "en": "Delete Coupon" + }, + "description": "쿠폰 삭제", + "description_raw": { + "ko": "쿠폰 삭제", + "en": "Delete coupon" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "coupon", + "owner_key": "created_by", + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 58, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.shipping-policies", + "name": "배송정책 관리", + "name_raw": { + "ko": "배송정책 관리", + "en": "Shipping Policy Management" + }, + "description": "배송정책 관리 권한", + "description_raw": { + "ko": "배송정책 관리 권한", + "en": "Shipping policy management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 9, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 59, + "parent_id": 58, + "identifier": "sirsoft-ecommerce.shipping-policies.read", + "name": "배송정책 조회", + "name_raw": { + "ko": "배송정책 조회", + "en": "Read Shipping Policies" + }, + "description": "배송정책 목록 및 상세 조회", + "description_raw": { + "ko": "배송정책 목록 및 상세 조회", + "en": "Read shipping policy list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "shippingPolicy", + "owner_key": "created_by", + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 60, + "parent_id": 58, + "identifier": "sirsoft-ecommerce.shipping-policies.create", + "name": "배송정책 생성", + "name_raw": { + "ko": "배송정책 생성", + "en": "Create Shipping Policy" + }, + "description": "새 배송정책 등록", + "description_raw": { + "ko": "새 배송정책 등록", + "en": "Create new shipping policy" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 61, + "parent_id": 58, + "identifier": "sirsoft-ecommerce.shipping-policies.update", + "name": "배송정책 수정", + "name_raw": { + "ko": "배송정책 수정", + "en": "Update Shipping Policy" + }, + "description": "배송정책 정보 수정", + "description_raw": { + "ko": "배송정책 정보 수정", + "en": "Update shipping policy information" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "shippingPolicy", + "owner_key": "created_by", + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 62, + "parent_id": 58, + "identifier": "sirsoft-ecommerce.shipping-policies.delete", + "name": "배송정책 삭제", + "name_raw": { + "ko": "배송정책 삭제", + "en": "Delete Shipping Policy" + }, + "description": "배송정책 삭제", + "description_raw": { + "ko": "배송정책 삭제", + "en": "Delete shipping policy" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "shippingPolicy", + "owner_key": "created_by", + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 63, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.product-labels", + "name": "상품 라벨 관리", + "name_raw": { + "ko": "상품 라벨 관리", + "en": "Product Label Management" + }, + "description": "상품 라벨 관리 권한", + "description_raw": { + "ko": "상품 라벨 관리 권한", + "en": "Product label management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 10, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 64, + "parent_id": 63, + "identifier": "sirsoft-ecommerce.product-labels.read", + "name": "상품 라벨 조회", + "name_raw": { + "ko": "상품 라벨 조회", + "en": "Read Product Labels" + }, + "description": "상품 라벨 목록 및 상세 조회", + "description_raw": { + "ko": "상품 라벨 목록 및 상세 조회", + "en": "Read product label list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 65, + "parent_id": 63, + "identifier": "sirsoft-ecommerce.product-labels.create", + "name": "상품 라벨 생성", + "name_raw": { + "ko": "상품 라벨 생성", + "en": "Create Product Label" + }, + "description": "새 상품 라벨 등록", + "description_raw": { + "ko": "새 상품 라벨 등록", + "en": "Create new product label" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 66, + "parent_id": 63, + "identifier": "sirsoft-ecommerce.product-labels.update", + "name": "상품 라벨 수정", + "name_raw": { + "ko": "상품 라벨 수정", + "en": "Update Product Label" + }, + "description": "상품 라벨 정보 수정", + "description_raw": { + "ko": "상품 라벨 정보 수정", + "en": "Update product label information" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 67, + "parent_id": 63, + "identifier": "sirsoft-ecommerce.product-labels.delete", + "name": "상품 라벨 삭제", + "name_raw": { + "ko": "상품 라벨 삭제", + "en": "Delete Product Label" + }, + "description": "상품 라벨 삭제", + "description_raw": { + "ko": "상품 라벨 삭제", + "en": "Delete product label" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 68, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.identity.policies", + "name": "이커머스 본인인증 정책", + "name_raw": { + "ko": "이커머스 본인인증 정책", + "en": "Ecommerce Identity Policies" + }, + "description": "이커머스 컨텍스트의 본인인증 정책 관리 권한", + "description_raw": { + "ko": "이커머스 컨텍스트의 본인인증 정책 관리 권한", + "en": "Manage identity verification policies in ecommerce context" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 11, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 69, + "parent_id": 68, + "identifier": "sirsoft-ecommerce.identity.policies.read", + "name": "본인인증 정책 조회", + "name_raw": { + "ko": "본인인증 정책 조회", + "en": "View Identity Policies" + }, + "description": "이커머스 본인인증 정책 조회", + "description_raw": { + "ko": "이커머스 본인인증 정책 조회", + "en": "View ecommerce identity policies" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 70, + "parent_id": 68, + "identifier": "sirsoft-ecommerce.identity.policies.update", + "name": "본인인증 정책 수정", + "name_raw": { + "ko": "본인인증 정책 수정", + "en": "Update Identity Policies" + }, + "description": "이커머스 본인인증 정책 수정/추가/삭제", + "description_raw": { + "ko": "이커머스 본인인증 정책 수정/추가/삭제", + "en": "Update, add, delete ecommerce identity policies" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 71, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.reviews", + "name": "리뷰 관리", + "name_raw": { + "ko": "리뷰 관리", + "en": "Review Management" + }, + "description": "상품 리뷰 관리 권한", + "description_raw": { + "ko": "상품 리뷰 관리 권한", + "en": "Product review management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 12, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 72, + "parent_id": 71, + "identifier": "sirsoft-ecommerce.reviews.read", + "name": "리뷰 조회", + "name_raw": { + "ko": "리뷰 조회", + "en": "Read Reviews" + }, + "description": "리뷰 목록 및 상세 조회", + "description_raw": { + "ko": "리뷰 목록 및 상세 조회", + "en": "Read review list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "review", + "owner_key": "user_id", + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 73, + "parent_id": 71, + "identifier": "sirsoft-ecommerce.reviews.update", + "name": "리뷰 처리", + "name_raw": { + "ko": "리뷰 처리", + "en": "Manage Review" + }, + "description": "리뷰 상태 변경, 답변 등록/수정/삭제, 일괄 처리", + "description_raw": { + "ko": "리뷰 상태 변경, 답변 등록/수정/삭제, 일괄 처리", + "en": "Update review status, manage replies, bulk actions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "review", + "owner_key": "user_id", + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 74, + "parent_id": 71, + "identifier": "sirsoft-ecommerce.reviews.delete", + "name": "리뷰 삭제", + "name_raw": { + "ko": "리뷰 삭제", + "en": "Delete Review" + }, + "description": "리뷰 삭제", + "description_raw": { + "ko": "리뷰 삭제", + "en": "Delete review" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "review", + "owner_key": "user_id", + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 75, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.inquiries", + "name": "문의 관리", + "name_raw": { + "ko": "문의 관리", + "en": "Inquiry Management" + }, + "description": "상품 1:1 문의 관리 권한", + "description_raw": { + "ko": "상품 1:1 문의 관리 권한", + "en": "Product inquiry management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 13, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 76, + "parent_id": 75, + "identifier": "sirsoft-ecommerce.inquiries.update", + "name": "문의 처리", + "name_raw": { + "ko": "문의 처리", + "en": "Manage Inquiry" + }, + "description": "답변 등록/수정/삭제, 비밀글 내용 열람", + "description_raw": { + "ko": "답변 등록/수정/삭제, 비밀글 내용 열람", + "en": "Create, update, and delete inquiry replies; view secret inquiry contents" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 77, + "parent_id": 75, + "identifier": "sirsoft-ecommerce.inquiries.delete", + "name": "문의 삭제", + "name_raw": { + "ko": "문의 삭제", + "en": "Delete Inquiry" + }, + "description": "고객이 작성한 문의 자체를 삭제", + "description_raw": { + "ko": "고객이 작성한 문의 자체를 삭제", + "en": "Delete inquiries submitted by customers" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 78, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.dashboard", + "name": "대시보드", + "name_raw": { + "ko": "대시보드", + "en": "Dashboard" + }, + "description": "관리자 대시보드 이커머스 영역 조회 권한", + "description_raw": { + "ko": "관리자 대시보드 이커머스 영역 조회 권한", + "en": "Admin dashboard commerce area view permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 14, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 79, + "parent_id": 78, + "identifier": "sirsoft-ecommerce.dashboard.view", + "name": "대시보드 조회", + "name_raw": { + "ko": "대시보드 조회", + "en": "View Dashboard" + }, + "description": "관리자 대시보드의 이커머스 판매 현황/리뷰/문의 위젯 조회", + "description_raw": { + "ko": "관리자 대시보드의 이커머스 판매 현황/리뷰/문의 위젯 조회", + "en": "View commerce sales/review/inquiry widgets on the admin dashboard" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 80, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.user-products", + "name": "사용자 상품", + "name_raw": { + "ko": "사용자 상품", + "en": "User Products" + }, + "description": "사용자 상품 접근 권한 (블랙컨슈머 차단용)", + "description_raw": { + "ko": "사용자 상품 접근 권한 (블랙컨슈머 차단용)", + "en": "User product access permissions (for blocking malicious consumers)" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 15, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 81, + "parent_id": 80, + "identifier": "sirsoft-ecommerce.user-products.read", + "name": "상품 조회", + "name_raw": { + "ko": "상품 조회", + "en": "View Products" + }, + "description": "상품 목록 및 상세 조회", + "description_raw": { + "ko": "상품 목록 및 상세 조회", + "en": "View product list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 82, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.user-orders", + "name": "사용자 주문", + "name_raw": { + "ko": "사용자 주문", + "en": "User Orders" + }, + "description": "사용자 주문 관련 권한 (블랙컨슈머 차단용)", + "description_raw": { + "ko": "사용자 주문 관련 권한 (블랙컨슈머 차단용)", + "en": "User order permissions (for blocking malicious consumers)" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 16, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 83, + "parent_id": 82, + "identifier": "sirsoft-ecommerce.user-orders.create", + "name": "주문하기", + "name_raw": { + "ko": "주문하기", + "en": "Create Order" + }, + "description": "주문 생성", + "description_raw": { + "ko": "주문 생성", + "en": "Create a new order" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 84, + "parent_id": 82, + "identifier": "sirsoft-ecommerce.user-orders.cancel", + "name": "주문 취소", + "name_raw": { + "ko": "주문 취소", + "en": "Cancel Order" + }, + "description": "주문 취소 요청", + "description_raw": { + "ko": "주문 취소 요청", + "en": "Request order cancellation" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 85, + "parent_id": 82, + "identifier": "sirsoft-ecommerce.user-orders.confirm", + "name": "구매확정", + "name_raw": { + "ko": "구매확정", + "en": "Confirm Purchase" + }, + "description": "주문 상품 구매확정", + "description_raw": { + "ko": "주문 상품 구매확정", + "en": "Confirm purchase of order item" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 86, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.user-reviews", + "name": "사용자 리뷰", + "name_raw": { + "ko": "사용자 리뷰", + "en": "User Reviews" + }, + "description": "사용자 리뷰 관련 권한 (블랙컨슈머 차단용)", + "description_raw": { + "ko": "사용자 리뷰 관련 권한 (블랙컨슈머 차단용)", + "en": "User review permissions (for blocking malicious consumers)" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 17, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 87, + "parent_id": 86, + "identifier": "sirsoft-ecommerce.user-reviews.write", + "name": "리뷰 작성", + "name_raw": { + "ko": "리뷰 작성", + "en": "Write Review" + }, + "description": "상품 리뷰 작성", + "description_raw": { + "ko": "상품 리뷰 작성", + "en": "Write product review" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 88, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.mileage", + "name": "마일리지 관리", + "name_raw": { + "ko": "마일리지 관리", + "en": "Mileage Management" + }, + "description": "마일리지 내역 조회 및 수동 지급/차감 권한", + "description_raw": { + "ko": "마일리지 내역 조회 및 수동 지급/차감 권한", + "en": "Mileage history and manual grant/deduct permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 18, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 89, + "parent_id": 88, + "identifier": "sirsoft-ecommerce.mileage.read", + "name": "마일리지 내역 조회", + "name_raw": { + "ko": "마일리지 내역 조회", + "en": "Read Mileage" + }, + "description": "마일리지 내역 목록 및 상세 조회", + "description_raw": { + "ko": "마일리지 내역 목록 및 상세 조회", + "en": "Read mileage history list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "mileage-transaction", + "owner_key": "user_id", + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 90, + "parent_id": 88, + "identifier": "sirsoft-ecommerce.mileage.manage", + "name": "마일리지 수동 처리", + "name_raw": { + "ko": "마일리지 수동 처리", + "en": "Manage Mileage" + }, + "description": "마일리지 수동 지급/차감 및 일괄 유효기간 연장", + "description_raw": { + "ko": "마일리지 수동 지급/차감 및 일괄 유효기간 연장", + "en": "Manual grant/deduct and bulk expiry extension" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": "mileage-transaction", + "owner_key": "user_id", + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 91, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.user-currency", + "name": "회원 결제 통화 관리", + "name_raw": { + "ko": "회원 결제 통화 관리", + "en": "User Currency Management" + }, + "description": "관리자가 회원별 결제 통화를 변경하는 권한", + "description_raw": { + "ko": "관리자가 회원별 결제 통화를 변경하는 권한", + "en": "Permission for admins to change a user's payment currency" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 19, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 92, + "parent_id": 91, + "identifier": "sirsoft-ecommerce.user-currency.manage", + "name": "회원 결제 통화 변경", + "name_raw": { + "ko": "회원 결제 통화 변경", + "en": "Manage User Currency" + }, + "description": "회원별 결제 통화 변경", + "description_raw": { + "ko": "회원별 결제 통화 변경", + "en": "Change a user's payment currency" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 93, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.user-shipping-country", + "name": "회원 배송국가 관리", + "name_raw": { + "ko": "회원 배송국가 관리", + "en": "User Shipping Country Management" + }, + "description": "관리자가 회원별 배송국가를 변경하는 권한", + "description_raw": { + "ko": "관리자가 회원별 배송국가를 변경하는 권한", + "en": "Permission for admins to change a user's shipping country" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 20, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 94, + "parent_id": 93, + "identifier": "sirsoft-ecommerce.user-shipping-country.manage", + "name": "회원 배송국가 변경", + "name_raw": { + "ko": "회원 배송국가 변경", + "en": "Manage User Shipping Country" + }, + "description": "회원별 배송국가 변경", + "description_raw": { + "ko": "회원별 배송국가 변경", + "en": "Change a user's shipping country" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + } + ] + }, + { + "id": 109, + "parent_id": null, + "identifier": "sirsoft-page", + "name": "페이지", + "name_raw": { + "ko": "페이지", + "en": "Page" + }, + "description": "페이지 모듈 권한", + "description_raw": { + "ko": "페이지 모듈 권한", + "en": "Page module permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-page", + "resource_route_key": null, + "owner_key": null, + "order": 100, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 110, + "parent_id": 109, + "identifier": "sirsoft-page.pages", + "name": "페이지 관리", + "name_raw": { + "ko": "페이지 관리", + "en": "Page Management" + }, + "description": "페이지 관리 권한", + "description_raw": { + "ko": "페이지 관리 권한", + "en": "Page management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-page", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 111, + "parent_id": 110, + "identifier": "sirsoft-page.pages.read", + "name": "페이지 조회", + "name_raw": { + "ko": "페이지 조회", + "en": "View Pages" + }, + "description": "페이지 목록 및 상세 조회", + "description_raw": { + "ko": "페이지 목록 및 상세 조회", + "en": "View page list and details" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-page", + "resource_route_key": "page", + "owner_key": "created_by", + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 112, + "parent_id": 110, + "identifier": "sirsoft-page.pages.create", + "name": "페이지 생성", + "name_raw": { + "ko": "페이지 생성", + "en": "Create Page" + }, + "description": "새 페이지 생성", + "description_raw": { + "ko": "새 페이지 생성", + "en": "Create new page" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-page", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 113, + "parent_id": 110, + "identifier": "sirsoft-page.pages.update", + "name": "페이지 수정", + "name_raw": { + "ko": "페이지 수정", + "en": "Update Page" + }, + "description": "페이지 내용 수정", + "description_raw": { + "ko": "페이지 내용 수정", + "en": "Update page content" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-page", + "resource_route_key": "page", + "owner_key": "created_by", + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 114, + "parent_id": 110, + "identifier": "sirsoft-page.pages.delete", + "name": "페이지 삭제", + "name_raw": { + "ko": "페이지 삭제", + "en": "Delete Page" + }, + "description": "페이지 삭제", + "description_raw": { + "ko": "페이지 삭제", + "en": "Delete page" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-page", + "resource_route_key": "page", + "owner_key": "created_by", + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + } + ] + }, + { + "id": 115, + "parent_id": null, + "identifier": "core", + "name": "코어", + "name_raw": { + "ko": "코어", + "en": "Core" + }, + "description": "코어 시스템 권한", + "description_raw": { + "ko": "코어 시스템 권한", + "en": "Core system permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 116, + "parent_id": 115, + "identifier": "core.users", + "name": "사용자 관리", + "name_raw": { + "ko": "사용자 관리", + "en": "User Management" + }, + "description": "사용자 관리 권한", + "description_raw": { + "ko": "사용자 관리 권한", + "en": "User management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 117, + "parent_id": 116, + "identifier": "core.users.read", + "name": "사용자 조회", + "name_raw": { + "ko": "사용자 조회", + "en": "View Users" + }, + "description": "사용자 목록 및 상세 정보를 조회할 수 있습니다.", + "description_raw": { + "ko": "사용자 목록 및 상세 정보를 조회할 수 있습니다.", + "en": "Can view user list and details." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": "user", + "owner_key": "id", + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 118, + "parent_id": 116, + "identifier": "core.users.create", + "name": "사용자 생성", + "name_raw": { + "ko": "사용자 생성", + "en": "Create Users" + }, + "description": "새로운 사용자를 생성할 수 있습니다.", + "description_raw": { + "ko": "새로운 사용자를 생성할 수 있습니다.", + "en": "Can create new users." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 119, + "parent_id": 116, + "identifier": "core.users.update", + "name": "사용자 수정", + "name_raw": { + "ko": "사용자 수정", + "en": "Update Users" + }, + "description": "사용자 정보를 수정할 수 있습니다.", + "description_raw": { + "ko": "사용자 정보를 수정할 수 있습니다.", + "en": "Can update user information." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": "user", + "owner_key": "id", + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 120, + "parent_id": 116, + "identifier": "core.users.delete", + "name": "사용자 삭제", + "name_raw": { + "ko": "사용자 삭제", + "en": "Delete Users" + }, + "description": "사용자를 삭제할 수 있습니다.", + "description_raw": { + "ko": "사용자를 삭제할 수 있습니다.", + "en": "Can delete users." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": "user", + "owner_key": "id", + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 121, + "parent_id": 115, + "identifier": "core.menus", + "name": "메뉴 관리", + "name_raw": { + "ko": "메뉴 관리", + "en": "Menu Management" + }, + "description": "메뉴 관리 권한", + "description_raw": { + "ko": "메뉴 관리 권한", + "en": "Menu management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 122, + "parent_id": 121, + "identifier": "core.menus.read", + "name": "메뉴 조회", + "name_raw": { + "ko": "메뉴 조회", + "en": "View Menus" + }, + "description": "메뉴 목록을 조회할 수 있습니다.", + "description_raw": { + "ko": "메뉴 목록을 조회할 수 있습니다.", + "en": "Can view menu list." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": "menu", + "owner_key": "created_by", + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 123, + "parent_id": 121, + "identifier": "core.menus.create", + "name": "메뉴 생성", + "name_raw": { + "ko": "메뉴 생성", + "en": "Create Menus" + }, + "description": "새로운 메뉴를 생성할 수 있습니다.", + "description_raw": { + "ko": "새로운 메뉴를 생성할 수 있습니다.", + "en": "Can create new menus." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 124, + "parent_id": 121, + "identifier": "core.menus.update", + "name": "메뉴 수정", + "name_raw": { + "ko": "메뉴 수정", + "en": "Update Menus" + }, + "description": "메뉴 정보를 수정할 수 있습니다.", + "description_raw": { + "ko": "메뉴 정보를 수정할 수 있습니다.", + "en": "Can update menu information." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": "menu", + "owner_key": "created_by", + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 125, + "parent_id": 121, + "identifier": "core.menus.delete", + "name": "메뉴 삭제", + "name_raw": { + "ko": "메뉴 삭제", + "en": "Delete Menus" + }, + "description": "메뉴를 삭제할 수 있습니다.", + "description_raw": { + "ko": "메뉴를 삭제할 수 있습니다.", + "en": "Can delete menus." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": "menu", + "owner_key": "created_by", + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 126, + "parent_id": 115, + "identifier": "core.modules", + "name": "모듈 관리", + "name_raw": { + "ko": "모듈 관리", + "en": "Module Management" + }, + "description": "모듈 관리 권한", + "description_raw": { + "ko": "모듈 관리 권한", + "en": "Module management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 3, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 127, + "parent_id": 126, + "identifier": "core.modules.read", + "name": "모듈 조회", + "name_raw": { + "ko": "모듈 조회", + "en": "View Modules" + }, + "description": "모듈 목록을 조회할 수 있습니다.", + "description_raw": { + "ko": "모듈 목록을 조회할 수 있습니다.", + "en": "Can view module list." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 128, + "parent_id": 126, + "identifier": "core.modules.install", + "name": "모듈 설치", + "name_raw": { + "ko": "모듈 설치", + "en": "Install Modules" + }, + "description": "새로운 모듈을 설치할 수 있습니다.", + "description_raw": { + "ko": "새로운 모듈을 설치할 수 있습니다.", + "en": "Can install new modules." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 129, + "parent_id": 126, + "identifier": "core.modules.activate", + "name": "모듈 활성화", + "name_raw": { + "ko": "모듈 활성화", + "en": "Activate Modules" + }, + "description": "모듈을 활성화/비활성화할 수 있습니다.", + "description_raw": { + "ko": "모듈을 활성화/비활성화할 수 있습니다.", + "en": "Can activate/deactivate modules." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 130, + "parent_id": 126, + "identifier": "core.modules.uninstall", + "name": "모듈 삭제", + "name_raw": { + "ko": "모듈 삭제", + "en": "Uninstall Modules" + }, + "description": "모듈을 삭제할 수 있습니다.", + "description_raw": { + "ko": "모듈을 삭제할 수 있습니다.", + "en": "Can uninstall modules." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 131, + "parent_id": 115, + "identifier": "core.plugins", + "name": "플러그인 관리", + "name_raw": { + "ko": "플러그인 관리", + "en": "Plugin Management" + }, + "description": "플러그인 관리 권한", + "description_raw": { + "ko": "플러그인 관리 권한", + "en": "Plugin management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 4, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 132, + "parent_id": 131, + "identifier": "core.plugins.read", + "name": "플러그인 조회", + "name_raw": { + "ko": "플러그인 조회", + "en": "View Plugins" + }, + "description": "플러그인 목록을 조회할 수 있습니다.", + "description_raw": { + "ko": "플러그인 목록을 조회할 수 있습니다.", + "en": "Can view plugin list." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 133, + "parent_id": 131, + "identifier": "core.plugins.install", + "name": "플러그인 설치", + "name_raw": { + "ko": "플러그인 설치", + "en": "Install Plugins" + }, + "description": "새로운 플러그인을 설치할 수 있습니다.", + "description_raw": { + "ko": "새로운 플러그인을 설치할 수 있습니다.", + "en": "Can install new plugins." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 134, + "parent_id": 131, + "identifier": "core.plugins.activate", + "name": "플러그인 활성화", + "name_raw": { + "ko": "플러그인 활성화", + "en": "Activate Plugins" + }, + "description": "플러그인을 활성화/비활성화할 수 있습니다.", + "description_raw": { + "ko": "플러그인을 활성화/비활성화할 수 있습니다.", + "en": "Can activate/deactivate plugins." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 135, + "parent_id": 131, + "identifier": "core.plugins.update", + "name": "플러그인 설정", + "name_raw": { + "ko": "플러그인 설정", + "en": "Configure Plugins" + }, + "description": "플러그인 환경설정을 수정할 수 있습니다.", + "description_raw": { + "ko": "플러그인 환경설정을 수정할 수 있습니다.", + "en": "Can update plugin settings." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 136, + "parent_id": 131, + "identifier": "core.plugins.uninstall", + "name": "플러그인 삭제", + "name_raw": { + "ko": "플러그인 삭제", + "en": "Uninstall Plugins" + }, + "description": "플러그인을 삭제할 수 있습니다.", + "description_raw": { + "ko": "플러그인을 삭제할 수 있습니다.", + "en": "Can uninstall plugins." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 5, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 137, + "parent_id": 115, + "identifier": "core.templates", + "name": "템플릿 관리", + "name_raw": { + "ko": "템플릿 관리", + "en": "Template Management" + }, + "description": "템플릿 관리 권한", + "description_raw": { + "ko": "템플릿 관리 권한", + "en": "Template management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 5, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 138, + "parent_id": 137, + "identifier": "core.templates.read", + "name": "템플릿 조회", + "name_raw": { + "ko": "템플릿 조회", + "en": "View Templates" + }, + "description": "템플릿 목록을 조회할 수 있습니다.", + "description_raw": { + "ko": "템플릿 목록을 조회할 수 있습니다.", + "en": "Can view template list." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 139, + "parent_id": 137, + "identifier": "core.templates.install", + "name": "템플릿 설치", + "name_raw": { + "ko": "템플릿 설치", + "en": "Install Templates" + }, + "description": "새로운 템플릿을 설치할 수 있습니다.", + "description_raw": { + "ko": "새로운 템플릿을 설치할 수 있습니다.", + "en": "Can install new templates." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 140, + "parent_id": 137, + "identifier": "core.templates.activate", + "name": "템플릿 활성화", + "name_raw": { + "ko": "템플릿 활성화", + "en": "Activate Templates" + }, + "description": "템플릿을 활성화/비활성화할 수 있습니다.", + "description_raw": { + "ko": "템플릿을 활성화/비활성화할 수 있습니다.", + "en": "Can activate/deactivate templates." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 141, + "parent_id": 137, + "identifier": "core.templates.uninstall", + "name": "템플릿 삭제", + "name_raw": { + "ko": "템플릿 삭제", + "en": "Uninstall Templates" + }, + "description": "템플릿을 삭제할 수 있습니다.", + "description_raw": { + "ko": "템플릿을 삭제할 수 있습니다.", + "en": "Can uninstall templates." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 142, + "parent_id": 137, + "identifier": "core.templates.layouts.edit", + "name": "레이아웃 편집", + "name_raw": { + "ko": "레이아웃 편집", + "en": "Edit Layouts" + }, + "description": "템플릿 레이아웃을 편집할 수 있습니다.", + "description_raw": { + "ko": "템플릿 레이아웃을 편집할 수 있습니다.", + "en": "Can edit template layouts." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 5, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 143, + "parent_id": 115, + "identifier": "core.permissions", + "name": "권한 관리", + "name_raw": { + "ko": "권한 관리", + "en": "Permission Management" + }, + "description": "역할 및 권한 관리 권한", + "description_raw": { + "ko": "역할 및 권한 관리 권한", + "en": "Role and permission management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 6, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 144, + "parent_id": 143, + "identifier": "core.permissions.read", + "name": "권한 조회", + "name_raw": { + "ko": "권한 조회", + "en": "View Permissions" + }, + "description": "역할 및 권한 목록을 조회할 수 있습니다.", + "description_raw": { + "ko": "역할 및 권한 목록을 조회할 수 있습니다.", + "en": "Can view roles and permissions." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 145, + "parent_id": 143, + "identifier": "core.permissions.create", + "name": "역할 생성", + "name_raw": { + "ko": "역할 생성", + "en": "Create Roles" + }, + "description": "새로운 역할을 생성할 수 있습니다.", + "description_raw": { + "ko": "새로운 역할을 생성할 수 있습니다.", + "en": "Can create new roles." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 146, + "parent_id": 143, + "identifier": "core.permissions.update", + "name": "역할 수정", + "name_raw": { + "ko": "역할 수정", + "en": "Update Roles" + }, + "description": "역할 정보와 권한을 수정할 수 있습니다.", + "description_raw": { + "ko": "역할 정보와 권한을 수정할 수 있습니다.", + "en": "Can update role information and permissions." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 147, + "parent_id": 143, + "identifier": "core.permissions.delete", + "name": "역할 삭제", + "name_raw": { + "ko": "역할 삭제", + "en": "Delete Roles" + }, + "description": "역할을 삭제할 수 있습니다.", + "description_raw": { + "ko": "역할을 삭제할 수 있습니다.", + "en": "Can delete roles." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 148, + "parent_id": 115, + "identifier": "core.notification-logs", + "name": "알림 발송 이력", + "name_raw": { + "ko": "알림 발송 이력", + "en": "Notification Logs" + }, + "description": "알림 발송 이력 관리 권한", + "description_raw": { + "ko": "알림 발송 이력 관리 권한", + "en": "Notification log management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 7, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 149, + "parent_id": 148, + "identifier": "core.notification-logs.read", + "name": "발송 이력 조회", + "name_raw": { + "ko": "발송 이력 조회", + "en": "View Notification Logs" + }, + "description": "알림 발송 이력을 조회할 수 있습니다.", + "description_raw": { + "ko": "알림 발송 이력을 조회할 수 있습니다.", + "en": "Can view notification logs." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 150, + "parent_id": 148, + "identifier": "core.notification-logs.delete", + "name": "발송 이력 삭제", + "name_raw": { + "ko": "발송 이력 삭제", + "en": "Delete Notification Logs" + }, + "description": "알림 발송 이력을 삭제할 수 있습니다.", + "description_raw": { + "ko": "알림 발송 이력을 삭제할 수 있습니다.", + "en": "Can delete notification logs." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 151, + "parent_id": 115, + "identifier": "core.notifications", + "name": "알림 (관리자)", + "name_raw": { + "ko": "알림 (관리자)", + "en": "Notifications (Admin)" + }, + "description": "관리자용 알림 관리 권한 (관리자 화면에서 사용)", + "description_raw": { + "ko": "관리자용 알림 관리 권한 (관리자 화면에서 사용)", + "en": "Admin notification management permissions (used in admin UI)" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 8, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 152, + "parent_id": 151, + "identifier": "core.notifications.read", + "name": "알림 조회", + "name_raw": { + "ko": "알림 조회", + "en": "View Notifications" + }, + "description": "알림 목록 및 읽지 않은 수를 조회할 수 있습니다.", + "description_raw": { + "ko": "알림 목록 및 읽지 않은 수를 조회할 수 있습니다.", + "en": "Can view notification list and unread count." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 153, + "parent_id": 151, + "identifier": "core.notifications.update", + "name": "알림 읽음 처리", + "name_raw": { + "ko": "알림 읽음 처리", + "en": "Mark Notifications Read" + }, + "description": "알림을 읽음 처리할 수 있습니다.", + "description_raw": { + "ko": "알림을 읽음 처리할 수 있습니다.", + "en": "Can mark notifications as read." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 154, + "parent_id": 151, + "identifier": "core.notifications.delete", + "name": "알림 삭제", + "name_raw": { + "ko": "알림 삭제", + "en": "Delete Notifications" + }, + "description": "알림을 삭제할 수 있습니다.", + "description_raw": { + "ko": "알림을 삭제할 수 있습니다.", + "en": "Can delete notifications." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 155, + "parent_id": 115, + "identifier": "core.user-notifications", + "name": "알림 (사용자)", + "name_raw": { + "ko": "알림 (사용자)", + "en": "Notifications (User)" + }, + "description": "사용자용 알림 권한 (사용자 화면에서 본인 알림 관리)", + "description_raw": { + "ko": "사용자용 알림 권한 (사용자 화면에서 본인 알림 관리)", + "en": "User notification permissions (managing own notifications in user UI)" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 8, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 156, + "parent_id": 155, + "identifier": "core.user-notifications.read", + "name": "알림 조회", + "name_raw": { + "ko": "알림 조회", + "en": "View Notifications" + }, + "description": "본인의 알림 목록 및 읽지 않은 수를 조회할 수 있습니다.", + "description_raw": { + "ko": "본인의 알림 목록 및 읽지 않은 수를 조회할 수 있습니다.", + "en": "Can view own notification list and unread count." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 157, + "parent_id": 155, + "identifier": "core.user-notifications.update", + "name": "알림 읽음 처리", + "name_raw": { + "ko": "알림 읽음 처리", + "en": "Mark Notifications Read" + }, + "description": "본인의 알림을 읽음 처리할 수 있습니다.", + "description_raw": { + "ko": "본인의 알림을 읽음 처리할 수 있습니다.", + "en": "Can mark own notifications as read." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 158, + "parent_id": 155, + "identifier": "core.user-notifications.delete", + "name": "알림 삭제", + "name_raw": { + "ko": "알림 삭제", + "en": "Delete Notifications" + }, + "description": "본인의 알림을 삭제할 수 있습니다.", + "description_raw": { + "ko": "본인의 알림을 삭제할 수 있습니다.", + "en": "Can delete own notifications." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 159, + "parent_id": 115, + "identifier": "core.identity", + "name": "본인인증 (사용자)", + "name_raw": { + "ko": "본인인증 (사용자)", + "en": "Identity Verification (User)" + }, + "description": "로그인 사용자의 본인인증 challenge 요청/검증/취소 권한", + "description_raw": { + "ko": "로그인 사용자의 본인인증 challenge 요청/검증/취소 권한", + "en": "Permissions for authenticated users to request/verify/cancel IDV challenges" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 8, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 160, + "parent_id": 159, + "identifier": "core.identity.request", + "name": "IDV 요청", + "name_raw": { + "ko": "IDV 요청", + "en": "Request IDV Challenge" + }, + "description": "본인인증 challenge 를 요청할 수 있습니다.", + "description_raw": { + "ko": "본인인증 challenge 를 요청할 수 있습니다.", + "en": "Can request identity verification challenges." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 161, + "parent_id": 159, + "identifier": "core.identity.verify", + "name": "IDV 검증", + "name_raw": { + "ko": "IDV 검증", + "en": "Verify IDV Challenge" + }, + "description": "본인의 본인인증 challenge 를 검증할 수 있습니다.", + "description_raw": { + "ko": "본인의 본인인증 challenge 를 검증할 수 있습니다.", + "en": "Can verify own identity verification challenges." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": "challenge", + "owner_key": "user_id", + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 162, + "parent_id": 159, + "identifier": "core.identity.cancel", + "name": "IDV 취소", + "name_raw": { + "ko": "IDV 취소", + "en": "Cancel IDV Challenge" + }, + "description": "본인의 본인인증 challenge 를 취소할 수 있습니다.", + "description_raw": { + "ko": "본인의 본인인증 challenge 를 취소할 수 있습니다.", + "en": "Can cancel own identity verification challenges." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": "challenge", + "owner_key": "user_id", + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 163, + "parent_id": 115, + "identifier": "core.admin.identity", + "name": "본인인증 관리 (관리자)", + "name_raw": { + "ko": "본인인증 관리 (관리자)", + "en": "Identity Verification Management (Admin)" + }, + "description": "관리자용 IDV 프로바이더/정책/로그 관리 권한", + "description_raw": { + "ko": "관리자용 IDV 프로바이더/정책/로그 관리 권한", + "en": "Admin permissions for IDV providers, policies, and logs" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 8, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 164, + "parent_id": 163, + "identifier": "core.admin.identity.providers.read", + "name": "프로바이더 설정 조회", + "name_raw": { + "ko": "프로바이더 설정 조회", + "en": "View IDV Providers" + }, + "description": "본인인증 프로바이더 설정을 조회할 수 있습니다.", + "description_raw": { + "ko": "본인인증 프로바이더 설정을 조회할 수 있습니다.", + "en": "Can view identity verification providers." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 165, + "parent_id": 163, + "identifier": "core.admin.identity.providers.update", + "name": "프로바이더 설정 수정", + "name_raw": { + "ko": "프로바이더 설정 수정", + "en": "Update IDV Providers" + }, + "description": "본인인증 프로바이더 설정을 수정할 수 있습니다.", + "description_raw": { + "ko": "본인인증 프로바이더 설정을 수정할 수 있습니다.", + "en": "Can update identity verification providers." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 166, + "parent_id": 163, + "identifier": "core.admin.identity.policies.read", + "name": "정책 조회", + "name_raw": { + "ko": "정책 조회", + "en": "View IDV Policies" + }, + "description": "본인인증 정책(라우트/훅별)을 조회할 수 있습니다.", + "description_raw": { + "ko": "본인인증 정책(라우트/훅별)을 조회할 수 있습니다.", + "en": "Can view identity verification policies by route/hook." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 167, + "parent_id": 163, + "identifier": "core.admin.identity.policies.update", + "name": "정책 수정", + "name_raw": { + "ko": "정책 수정", + "en": "Update IDV Policies" + }, + "description": "본인인증 정책(라우트/훅별)을 CRUD 할 수 있습니다.", + "description_raw": { + "ko": "본인인증 정책(라우트/훅별)을 CRUD 할 수 있습니다.", + "en": "Can CRUD identity verification policies by route/hook." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 168, + "parent_id": 163, + "identifier": "core.admin.identity.logs.read", + "name": "로그 열람", + "name_raw": { + "ko": "로그 열람", + "en": "View IDV Logs" + }, + "description": "본인인증 이력을 조회할 수 있습니다.", + "description_raw": { + "ko": "본인인증 이력을 조회할 수 있습니다.", + "en": "Can view identity verification logs." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 5, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 169, + "parent_id": 163, + "identifier": "core.admin.identity.logs.purge", + "name": "로그 파기", + "name_raw": { + "ko": "로그 파기", + "en": "Purge IDV Logs" + }, + "description": "본인인증 이력을 파기(보관주기 외)할 수 있습니다.", + "description_raw": { + "ko": "본인인증 이력을 파기(보관주기 외)할 수 있습니다.", + "en": "Can purge identity verification logs (retention-based)." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 6, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 170, + "parent_id": 163, + "identifier": "core.admin.identity.messages.read", + "name": "메시지 템플릿 조회", + "name_raw": { + "ko": "메시지 템플릿 조회", + "en": "View IDV Message Templates" + }, + "description": "본인인증 메시지 정의/템플릿을 조회할 수 있습니다.", + "description_raw": { + "ko": "본인인증 메시지 정의/템플릿을 조회할 수 있습니다.", + "en": "Can view identity verification message definitions and templates." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 7, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 171, + "parent_id": 163, + "identifier": "core.admin.identity.messages.update", + "name": "메시지 템플릿 수정", + "name_raw": { + "ko": "메시지 템플릿 수정", + "en": "Update IDV Message Templates" + }, + "description": "본인인증 메시지 정의/템플릿을 수정할 수 있습니다.", + "description_raw": { + "ko": "본인인증 메시지 정의/템플릿을 수정할 수 있습니다.", + "en": "Can update identity verification message definitions and templates." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 8, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 172, + "parent_id": 115, + "identifier": "core.settings", + "name": "환경설정", + "name_raw": { + "ko": "환경설정", + "en": "Settings" + }, + "description": "시스템 환경설정 권한", + "description_raw": { + "ko": "시스템 환경설정 권한", + "en": "System settings permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 8, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 173, + "parent_id": 172, + "identifier": "core.settings.read", + "name": "설정 조회", + "name_raw": { + "ko": "설정 조회", + "en": "View Settings" + }, + "description": "시스템 설정을 조회할 수 있습니다.", + "description_raw": { + "ko": "시스템 설정을 조회할 수 있습니다.", + "en": "Can view system settings." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 174, + "parent_id": 172, + "identifier": "core.settings.update", + "name": "설정 수정", + "name_raw": { + "ko": "설정 수정", + "en": "Update Settings" + }, + "description": "시스템 설정을 수정할 수 있습니다.", + "description_raw": { + "ko": "시스템 설정을 수정할 수 있습니다.", + "en": "Can update system settings." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 175, + "parent_id": 115, + "identifier": "core.dashboard", + "name": "대시보드", + "name_raw": { + "ko": "대시보드", + "en": "Dashboard" + }, + "description": "대시보드 접근 권한", + "description_raw": { + "ko": "대시보드 접근 권한", + "en": "Dashboard access permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 9, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 176, + "parent_id": 175, + "identifier": "core.dashboard.read", + "name": "대시보드 조회", + "name_raw": { + "ko": "대시보드 조회", + "en": "View Dashboard" + }, + "description": "대시보드 통계 및 정보를 조회할 수 있습니다.", + "description_raw": { + "ko": "대시보드 통계 및 정보를 조회할 수 있습니다.", + "en": "Can view dashboard statistics and information." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 177, + "parent_id": 175, + "identifier": "core.dashboard.system-status", + "name": "시스템 상태", + "name_raw": { + "ko": "시스템 상태", + "en": "System Status" + }, + "description": "시스템 상태 정보를 조회할 수 있습니다.", + "description_raw": { + "ko": "시스템 상태 정보를 조회할 수 있습니다.", + "en": "Can view system status information." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 178, + "parent_id": 175, + "identifier": "core.dashboard.resources", + "name": "시스템 리소스", + "name_raw": { + "ko": "시스템 리소스", + "en": "System Resources" + }, + "description": "CPU, 메모리, 디스크 사용량을 조회할 수 있습니다.", + "description_raw": { + "ko": "CPU, 메모리, 디스크 사용량을 조회할 수 있습니다.", + "en": "Can view CPU, memory, and disk usage." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 179, + "parent_id": 175, + "identifier": "core.dashboard.activities", + "name": "최근 활동", + "name_raw": { + "ko": "최근 활동", + "en": "Recent Activities" + }, + "description": "최근 활동 이력을 조회할 수 있습니다.", + "description_raw": { + "ko": "최근 활동 이력을 조회할 수 있습니다.", + "en": "Can view recent activity history." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": "activityLog", + "owner_key": "user_id", + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 180, + "parent_id": 175, + "identifier": "core.dashboard.alerts", + "name": "시스템 알림", + "name_raw": { + "ko": "시스템 알림", + "en": "System Alerts" + }, + "description": "시스템 알림을 조회할 수 있습니다.", + "description_raw": { + "ko": "시스템 알림을 조회할 수 있습니다.", + "en": "Can view system alerts." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 5, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 181, + "parent_id": 115, + "identifier": "core.activities", + "name": "활동 로그", + "name_raw": { + "ko": "활동 로그", + "en": "Activity Logs" + }, + "description": "활동 로그 조회 권한", + "description_raw": { + "ko": "활동 로그 조회 권한", + "en": "Activity log access permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 10, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 182, + "parent_id": 181, + "identifier": "core.activities.read", + "name": "활동 로그 조회", + "name_raw": { + "ko": "활동 로그 조회", + "en": "View Activity Logs" + }, + "description": "활동 로그를 조회할 수 있습니다.", + "description_raw": { + "ko": "활동 로그를 조회할 수 있습니다.", + "en": "Can view activity logs." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": "activityLog", + "owner_key": "user_id", + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 183, + "parent_id": 181, + "identifier": "core.activities.delete", + "name": "활동 로그 삭제", + "name_raw": { + "ko": "활동 로그 삭제", + "en": "Delete Activity Logs" + }, + "description": "활동 로그를 삭제할 수 있습니다.", + "description_raw": { + "ko": "활동 로그를 삭제할 수 있습니다.", + "en": "Can delete activity logs." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 184, + "parent_id": 115, + "identifier": "core.attachments", + "name": "첨부파일 관리", + "name_raw": { + "ko": "첨부파일 관리", + "en": "Attachment Management" + }, + "description": "첨부파일 관리 권한", + "description_raw": { + "ko": "첨부파일 관리 권한", + "en": "Attachment management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 11, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 185, + "parent_id": 184, + "identifier": "core.attachments.create", + "name": "첨부파일 업로드", + "name_raw": { + "ko": "첨부파일 업로드", + "en": "Upload Attachments" + }, + "description": "첨부파일을 업로드할 수 있습니다.", + "description_raw": { + "ko": "첨부파일을 업로드할 수 있습니다.", + "en": "Can upload attachments." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 186, + "parent_id": 184, + "identifier": "core.attachments.update", + "name": "첨부파일 수정", + "name_raw": { + "ko": "첨부파일 수정", + "en": "Update Attachments" + }, + "description": "첨부파일 정보 및 순서를 수정할 수 있습니다.", + "description_raw": { + "ko": "첨부파일 정보 및 순서를 수정할 수 있습니다.", + "en": "Can update attachment information and order." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": "attachment", + "owner_key": "created_by", + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 187, + "parent_id": 184, + "identifier": "core.attachments.delete", + "name": "첨부파일 삭제", + "name_raw": { + "ko": "첨부파일 삭제", + "en": "Delete Attachments" + }, + "description": "첨부파일을 삭제할 수 있습니다.", + "description_raw": { + "ko": "첨부파일을 삭제할 수 있습니다.", + "en": "Can delete attachments." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": "attachment", + "owner_key": "created_by", + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 188, + "parent_id": 115, + "identifier": "core.schedules", + "name": "스케줄 관리", + "name_raw": { + "ko": "스케줄 관리", + "en": "Schedule Management" + }, + "description": "스케줄 작업 관리 권한", + "description_raw": { + "ko": "스케줄 작업 관리 권한", + "en": "Schedule task management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 12, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 189, + "parent_id": 188, + "identifier": "core.schedules.read", + "name": "스케줄 조회", + "name_raw": { + "ko": "스케줄 조회", + "en": "View Schedules" + }, + "description": "스케줄 목록 및 상세 정보를 조회할 수 있습니다.", + "description_raw": { + "ko": "스케줄 목록 및 상세 정보를 조회할 수 있습니다.", + "en": "Can view schedule list and details." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 190, + "parent_id": 188, + "identifier": "core.schedules.create", + "name": "스케줄 생성", + "name_raw": { + "ko": "스케줄 생성", + "en": "Create Schedules" + }, + "description": "새로운 스케줄을 생성할 수 있습니다.", + "description_raw": { + "ko": "새로운 스케줄을 생성할 수 있습니다.", + "en": "Can create new schedules." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 191, + "parent_id": 188, + "identifier": "core.schedules.update", + "name": "스케줄 수정", + "name_raw": { + "ko": "스케줄 수정", + "en": "Update Schedules" + }, + "description": "스케줄 정보를 수정할 수 있습니다.", + "description_raw": { + "ko": "스케줄 정보를 수정할 수 있습니다.", + "en": "Can update schedule information." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 192, + "parent_id": 188, + "identifier": "core.schedules.delete", + "name": "스케줄 삭제", + "name_raw": { + "ko": "스케줄 삭제", + "en": "Delete Schedules" + }, + "description": "스케줄을 삭제할 수 있습니다.", + "description_raw": { + "ko": "스케줄을 삭제할 수 있습니다.", + "en": "Can delete schedules." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 193, + "parent_id": 188, + "identifier": "core.schedules.run", + "name": "스케줄 실행", + "name_raw": { + "ko": "스케줄 실행", + "en": "Run Schedules" + }, + "description": "스케줄을 수동으로 실행할 수 있습니다.", + "description_raw": { + "ko": "스케줄을 수동으로 실행할 수 있습니다.", + "en": "Can manually run schedules." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 5, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 194, + "parent_id": 115, + "identifier": "core.language_packs", + "name": "언어팩 관리", + "name_raw": { + "ko": "언어팩 관리", + "en": "Language Pack Management" + }, + "description": "언어팩 설치/제거/활성화 권한", + "description_raw": { + "ko": "언어팩 설치/제거/활성화 권한", + "en": "Language pack install/uninstall/activation permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 13, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 195, + "parent_id": 194, + "identifier": "core.language_packs.read", + "name": "언어팩 조회", + "name_raw": { + "ko": "언어팩 조회", + "en": "View Language Packs" + }, + "description": "설치된 언어팩 목록 및 상세 정보를 조회할 수 있습니다.", + "description_raw": { + "ko": "설치된 언어팩 목록 및 상세 정보를 조회할 수 있습니다.", + "en": "Can view installed language pack list and details." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 196, + "parent_id": 194, + "identifier": "core.language_packs.install", + "name": "언어팩 설치", + "name_raw": { + "ko": "언어팩 설치", + "en": "Install Language Packs" + }, + "description": "ZIP/GitHub/URL 로 언어팩을 설치할 수 있습니다.", + "description_raw": { + "ko": "ZIP/GitHub/URL 로 언어팩을 설치할 수 있습니다.", + "en": "Can install language packs from ZIP/GitHub/URL." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 197, + "parent_id": 194, + "identifier": "core.language_packs.manage", + "name": "언어팩 관리", + "name_raw": { + "ko": "언어팩 관리", + "en": "Manage Language Packs" + }, + "description": "언어팩을 활성화/비활성화/제거할 수 있습니다.", + "description_raw": { + "ko": "언어팩을 활성화/비활성화/제거할 수 있습니다.", + "en": "Can activate/deactivate/uninstall language packs." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 3, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + }, + { + "id": 198, + "parent_id": 194, + "identifier": "core.language_packs.update", + "name": "언어팩 업데이트", + "name_raw": { + "ko": "언어팩 업데이트", + "en": "Update Language Packs" + }, + "description": "설치된 언어팩의 업데이트를 확인하고 실행할 수 있습니다.", + "description_raw": { + "ko": "설치된 언어팩의 업데이트를 확인하고 실행할 수 있습니다.", + "en": "Can check and apply updates for installed language packs." + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 4, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + } + ] + }, + { + "id": 1, + "parent_id": null, + "identifier": "apidoc-sample.parent", + "name": "API 문서 샘플 권한", + "name_raw": { + "ko": "API 문서 샘플 권한", + "en": "API Doc Sample Permission" + }, + "description": "문서 실측용 권한", + "description_raw": { + "ko": "문서 실측용 권한", + "en": "Sample permission" + }, + "extension_type": null, + "extension_identifier": null, + "resource_route_key": null, + "owner_key": null, + "order": 0, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 2, + "parent_id": 1, + "identifier": "apidoc-sample.child", + "name": "하위 권한", + "name_raw": { + "ko": "하위 권한", + "en": "Child Permission" + }, + "description": "Quod consequatur sunt necessitatibus quam quia voluptas quod aperiam.", + "description_raw": { + "ko": "Quod consequatur sunt necessitatibus quam quia voluptas quod aperiam.", + "en": "Mollitia incidunt dolor rem maxime." + }, + "extension_type": null, + "extension_identifier": null, + "resource_route_key": null, + "owner_key": null, + "order": 0, + "is_assigned": true, + "is_assignable": true, + "scope_type": null + } + ] + }, + { + "id": 3, + "parent_id": null, + "identifier": "sirsoft-board", + "name": "게시판", + "name_raw": { + "ko": "게시판", + "en": "Board" + }, + "description": "게시판 모듈 권한", + "description_raw": { + "ko": "게시판 모듈 권한", + "en": "Board module permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "resource_route_key": null, + "owner_key": null, + "order": 100, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 4, + "parent_id": 3, + "identifier": "sirsoft-board.apidoc-sample-board", + "name": "API 문서 샘플 게시판 게시판", + "name_raw": { + "ko": "API 문서 샘플 게시판 게시판", + "en": "API Doc Sample Board board" + }, + "description": "API 문서 샘플 게시판 게시판 권한", + "description_raw": { + "ko": "API 문서 샘플 게시판 게시판 권한", + "en": "API Doc Sample Board board permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "resource_route_key": null, + "owner_key": null, + "order": 0, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 95, + "parent_id": 3, + "identifier": "sirsoft-board.boards", + "name": "게시판 관리", + "name_raw": { + "ko": "게시판 관리", + "en": "Board Management" + }, + "description": "게시판 관리 권한", + "description_raw": { + "ko": "게시판 관리 권한", + "en": "Board management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 100, + "parent_id": 3, + "identifier": "sirsoft-board.settings", + "name": "환경설정", + "name_raw": { + "ko": "환경설정", + "en": "Settings" + }, + "description": "게시판 환경설정 권한", + "description_raw": { + "ko": "게시판 환경설정 권한", + "en": "Board settings permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 103, + "parent_id": 3, + "identifier": "sirsoft-board.identity.policies", + "name": "게시판 본인인증 정책", + "name_raw": { + "ko": "게시판 본인인증 정책", + "en": "Board Identity Policies" + }, + "description": "게시판 컨텍스트의 본인인증 정책 관리 권한", + "description_raw": { + "ko": "게시판 컨텍스트의 본인인증 정책 관리 권한", + "en": "Manage identity verification policies in board context" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "resource_route_key": null, + "owner_key": null, + "order": 3, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 106, + "parent_id": 3, + "identifier": "sirsoft-board.reports", + "name": "게시판 신고 관리", + "name_raw": { + "ko": "게시판 신고 관리", + "en": "Report Management" + }, + "description": "게시판 신고 관리 권한", + "description_raw": { + "ko": "게시판 신고 관리 권한", + "en": "Board report management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "resource_route_key": null, + "owner_key": null, + "order": 4, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + } + ] + }, + { + "id": 21, + "parent_id": null, + "identifier": "sirsoft-ecommerce", + "name": "이커머스", + "name_raw": { + "ko": "이커머스", + "en": "Ecommerce" + }, + "description": "이커머스 모듈 권한", + "description_raw": { + "ko": "이커머스 모듈 권한", + "en": "Ecommerce module permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 100, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 22, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.products", + "name": "상품 관리", + "name_raw": { + "ko": "상품 관리", + "en": "Product Management" + }, + "description": "상품 관리 권한", + "description_raw": { + "ko": "상품 관리 권한", + "en": "Product management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 27, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.orders", + "name": "주문 관리", + "name_raw": { + "ko": "주문 관리", + "en": "Order Management" + }, + "description": "주문 관리 권한", + "description_raw": { + "ko": "주문 관리 권한", + "en": "Order management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 30, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.categories", + "name": "카테고리 관리", + "name_raw": { + "ko": "카테고리 관리", + "en": "Category Management" + }, + "description": "카테고리 관리 권한", + "description_raw": { + "ko": "카테고리 관리 권한", + "en": "Category management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 3, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 35, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.brands", + "name": "브랜드 관리", + "name_raw": { + "ko": "브랜드 관리", + "en": "Brand Management" + }, + "description": "브랜드 관리 권한", + "description_raw": { + "ko": "브랜드 관리 권한", + "en": "Brand management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 4, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 40, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.product-notice-templates", + "name": "상품정보제공고시 관리", + "name_raw": { + "ko": "상품정보제공고시 관리", + "en": "Product Notice Template Management" + }, + "description": "상품정보제공고시 템플릿 관리 권한", + "description_raw": { + "ko": "상품정보제공고시 템플릿 관리 권한", + "en": "Product notice template management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 5, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 45, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.product-common-infos", + "name": "공통정보 관리", + "name_raw": { + "ko": "공통정보 관리", + "en": "Product Common Info Management" + }, + "description": "상품 공통정보 관리 권한", + "description_raw": { + "ko": "상품 공통정보 관리 권한", + "en": "Product common info management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 6, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 50, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.settings", + "name": "환경설정", + "name_raw": { + "ko": "환경설정", + "en": "Settings" + }, + "description": "이커머스 환경설정 권한", + "description_raw": { + "ko": "이커머스 환경설정 권한", + "en": "Ecommerce settings permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 7, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 53, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.promotion-coupon", + "name": "쿠폰 관리", + "name_raw": { + "ko": "쿠폰 관리", + "en": "Coupon Management" + }, + "description": "쿠폰 관리 권한", + "description_raw": { + "ko": "쿠폰 관리 권한", + "en": "Coupon management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 8, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 58, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.shipping-policies", + "name": "배송정책 관리", + "name_raw": { + "ko": "배송정책 관리", + "en": "Shipping Policy Management" + }, + "description": "배송정책 관리 권한", + "description_raw": { + "ko": "배송정책 관리 권한", + "en": "Shipping policy management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 9, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 63, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.product-labels", + "name": "상품 라벨 관리", + "name_raw": { + "ko": "상품 라벨 관리", + "en": "Product Label Management" + }, + "description": "상품 라벨 관리 권한", + "description_raw": { + "ko": "상품 라벨 관리 권한", + "en": "Product label management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 10, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 68, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.identity.policies", + "name": "이커머스 본인인증 정책", + "name_raw": { + "ko": "이커머스 본인인증 정책", + "en": "Ecommerce Identity Policies" + }, + "description": "이커머스 컨텍스트의 본인인증 정책 관리 권한", + "description_raw": { + "ko": "이커머스 컨텍스트의 본인인증 정책 관리 권한", + "en": "Manage identity verification policies in ecommerce context" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 11, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 71, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.reviews", + "name": "리뷰 관리", + "name_raw": { + "ko": "리뷰 관리", + "en": "Review Management" + }, + "description": "상품 리뷰 관리 권한", + "description_raw": { + "ko": "상품 리뷰 관리 권한", + "en": "Product review management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 12, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 75, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.inquiries", + "name": "문의 관리", + "name_raw": { + "ko": "문의 관리", + "en": "Inquiry Management" + }, + "description": "상품 1:1 문의 관리 권한", + "description_raw": { + "ko": "상품 1:1 문의 관리 권한", + "en": "Product inquiry management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 13, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 78, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.dashboard", + "name": "대시보드", + "name_raw": { + "ko": "대시보드", + "en": "Dashboard" + }, + "description": "관리자 대시보드 이커머스 영역 조회 권한", + "description_raw": { + "ko": "관리자 대시보드 이커머스 영역 조회 권한", + "en": "Admin dashboard commerce area view permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 14, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 80, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.user-products", + "name": "사용자 상품", + "name_raw": { + "ko": "사용자 상품", + "en": "User Products" + }, + "description": "사용자 상품 접근 권한 (블랙컨슈머 차단용)", + "description_raw": { + "ko": "사용자 상품 접근 권한 (블랙컨슈머 차단용)", + "en": "User product access permissions (for blocking malicious consumers)" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 15, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 82, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.user-orders", + "name": "사용자 주문", + "name_raw": { + "ko": "사용자 주문", + "en": "User Orders" + }, + "description": "사용자 주문 관련 권한 (블랙컨슈머 차단용)", + "description_raw": { + "ko": "사용자 주문 관련 권한 (블랙컨슈머 차단용)", + "en": "User order permissions (for blocking malicious consumers)" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 16, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 86, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.user-reviews", + "name": "사용자 리뷰", + "name_raw": { + "ko": "사용자 리뷰", + "en": "User Reviews" + }, + "description": "사용자 리뷰 관련 권한 (블랙컨슈머 차단용)", + "description_raw": { + "ko": "사용자 리뷰 관련 권한 (블랙컨슈머 차단용)", + "en": "User review permissions (for blocking malicious consumers)" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 17, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 88, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.mileage", + "name": "마일리지 관리", + "name_raw": { + "ko": "마일리지 관리", + "en": "Mileage Management" + }, + "description": "마일리지 내역 조회 및 수동 지급/차감 권한", + "description_raw": { + "ko": "마일리지 내역 조회 및 수동 지급/차감 권한", + "en": "Mileage history and manual grant/deduct permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 18, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 91, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.user-currency", + "name": "회원 결제 통화 관리", + "name_raw": { + "ko": "회원 결제 통화 관리", + "en": "User Currency Management" + }, + "description": "관리자가 회원별 결제 통화를 변경하는 권한", + "description_raw": { + "ko": "관리자가 회원별 결제 통화를 변경하는 권한", + "en": "Permission for admins to change a user's payment currency" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 19, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 93, + "parent_id": 21, + "identifier": "sirsoft-ecommerce.user-shipping-country", + "name": "회원 배송국가 관리", + "name_raw": { + "ko": "회원 배송국가 관리", + "en": "User Shipping Country Management" + }, + "description": "관리자가 회원별 배송국가를 변경하는 권한", + "description_raw": { + "ko": "관리자가 회원별 배송국가를 변경하는 권한", + "en": "Permission for admins to change a user's shipping country" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-ecommerce", + "resource_route_key": null, + "owner_key": null, + "order": 20, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + } + ] + }, + { + "id": 109, + "parent_id": null, + "identifier": "sirsoft-page", + "name": "페이지", + "name_raw": { + "ko": "페이지", + "en": "Page" + }, + "description": "페이지 모듈 권한", + "description_raw": { + "ko": "페이지 모듈 권한", + "en": "Page module permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-page", + "resource_route_key": null, + "owner_key": null, + "order": 100, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 110, + "parent_id": 109, + "identifier": "sirsoft-page.pages", + "name": "페이지 관리", + "name_raw": { + "ko": "페이지 관리", + "en": "Page Management" + }, + "description": "페이지 관리 권한", + "description_raw": { + "ko": "페이지 관리 권한", + "en": "Page management permissions" + }, + "extension_type": "module", + "extension_identifier": "sirsoft-page", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + } + ] + }, + { + "id": 115, + "parent_id": null, + "identifier": "core", + "name": "코어", + "name_raw": { + "ko": "코어", + "en": "Core" + }, + "description": "코어 시스템 권한", + "description_raw": { + "ko": "코어 시스템 권한", + "en": "Core system permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": false, + "children": [ + { + "id": 116, + "parent_id": 115, + "identifier": "core.users", + "name": "사용자 관리", + "name_raw": { + "ko": "사용자 관리", + "en": "User Management" + }, + "description": "사용자 관리 권한", + "description_raw": { + "ko": "사용자 관리 권한", + "en": "User management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 1, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 121, + "parent_id": 115, + "identifier": "core.menus", + "name": "메뉴 관리", + "name_raw": { + "ko": "메뉴 관리", + "en": "Menu Management" + }, + "description": "메뉴 관리 권한", + "description_raw": { + "ko": "메뉴 관리 권한", + "en": "Menu management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 2, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 126, + "parent_id": 115, + "identifier": "core.modules", + "name": "모듈 관리", + "name_raw": { + "ko": "모듈 관리", + "en": "Module Management" + }, + "description": "모듈 관리 권한", + "description_raw": { + "ko": "모듈 관리 권한", + "en": "Module management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 3, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 131, + "parent_id": 115, + "identifier": "core.plugins", + "name": "플러그인 관리", + "name_raw": { + "ko": "플러그인 관리", + "en": "Plugin Management" + }, + "description": "플러그인 관리 권한", + "description_raw": { + "ko": "플러그인 관리 권한", + "en": "Plugin management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 4, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 137, + "parent_id": 115, + "identifier": "core.templates", + "name": "템플릿 관리", + "name_raw": { + "ko": "템플릿 관리", + "en": "Template Management" + }, + "description": "템플릿 관리 권한", + "description_raw": { + "ko": "템플릿 관리 권한", + "en": "Template management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 5, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 143, + "parent_id": 115, + "identifier": "core.permissions", + "name": "권한 관리", + "name_raw": { + "ko": "권한 관리", + "en": "Permission Management" + }, + "description": "역할 및 권한 관리 권한", + "description_raw": { + "ko": "역할 및 권한 관리 권한", + "en": "Role and permission management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 6, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 148, + "parent_id": 115, + "identifier": "core.notification-logs", + "name": "알림 발송 이력", + "name_raw": { + "ko": "알림 발송 이력", + "en": "Notification Logs" + }, + "description": "알림 발송 이력 관리 권한", + "description_raw": { + "ko": "알림 발송 이력 관리 권한", + "en": "Notification log management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 7, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 151, + "parent_id": 115, + "identifier": "core.notifications", + "name": "알림 (관리자)", + "name_raw": { + "ko": "알림 (관리자)", + "en": "Notifications (Admin)" + }, + "description": "관리자용 알림 관리 권한 (관리자 화면에서 사용)", + "description_raw": { + "ko": "관리자용 알림 관리 권한 (관리자 화면에서 사용)", + "en": "Admin notification management permissions (used in admin UI)" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 8, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 155, + "parent_id": 115, + "identifier": "core.user-notifications", + "name": "알림 (사용자)", + "name_raw": { + "ko": "알림 (사용자)", + "en": "Notifications (User)" + }, + "description": "사용자용 알림 권한 (사용자 화면에서 본인 알림 관리)", + "description_raw": { + "ko": "사용자용 알림 권한 (사용자 화면에서 본인 알림 관리)", + "en": "User notification permissions (managing own notifications in user UI)" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 8, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 159, + "parent_id": 115, + "identifier": "core.identity", + "name": "본인인증 (사용자)", + "name_raw": { + "ko": "본인인증 (사용자)", + "en": "Identity Verification (User)" + }, + "description": "로그인 사용자의 본인인증 challenge 요청/검증/취소 권한", + "description_raw": { + "ko": "로그인 사용자의 본인인증 challenge 요청/검증/취소 권한", + "en": "Permissions for authenticated users to request/verify/cancel IDV challenges" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 8, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 163, + "parent_id": 115, + "identifier": "core.admin.identity", + "name": "본인인증 관리 (관리자)", + "name_raw": { + "ko": "본인인증 관리 (관리자)", + "en": "Identity Verification Management (Admin)" + }, + "description": "관리자용 IDV 프로바이더/정책/로그 관리 권한", + "description_raw": { + "ko": "관리자용 IDV 프로바이더/정책/로그 관리 권한", + "en": "Admin permissions for IDV providers, policies, and logs" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 8, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 172, + "parent_id": 115, + "identifier": "core.settings", + "name": "환경설정", + "name_raw": { + "ko": "환경설정", + "en": "Settings" + }, + "description": "시스템 환경설정 권한", + "description_raw": { + "ko": "시스템 환경설정 권한", + "en": "System settings permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 8, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 175, + "parent_id": 115, + "identifier": "core.dashboard", + "name": "대시보드", + "name_raw": { + "ko": "대시보드", + "en": "Dashboard" + }, + "description": "대시보드 접근 권한", + "description_raw": { + "ko": "대시보드 접근 권한", + "en": "Dashboard access permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 9, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 181, + "parent_id": 115, + "identifier": "core.activities", + "name": "활동 로그", + "name_raw": { + "ko": "활동 로그", + "en": "Activity Logs" + }, + "description": "활동 로그 조회 권한", + "description_raw": { + "ko": "활동 로그 조회 권한", + "en": "Activity log access permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 10, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 184, + "parent_id": 115, + "identifier": "core.attachments", + "name": "첨부파일 관리", + "name_raw": { + "ko": "첨부파일 관리", + "en": "Attachment Management" + }, + "description": "첨부파일 관리 권한", + "description_raw": { + "ko": "첨부파일 관리 권한", + "en": "Attachment management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 11, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 188, + "parent_id": 115, + "identifier": "core.schedules", + "name": "스케줄 관리", + "name_raw": { + "ko": "스케줄 관리", + "en": "Schedule Management" + }, + "description": "스케줄 작업 관리 권한", + "description_raw": { + "ko": "스케줄 작업 관리 권한", + "en": "Schedule task management permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 12, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + }, + { + "id": 194, + "parent_id": 115, + "identifier": "core.language_packs", + "name": "언어팩 관리", + "name_raw": { + "ko": "언어팩 관리", + "en": "Language Pack Management" + }, + "description": "언어팩 설치/제거/활성화 권한", + "description_raw": { + "ko": "언어팩 설치/제거/활성화 권한", + "en": "Language pack install/uninstall/activation permissions" + }, + "extension_type": "core", + "extension_identifier": "core", + "resource_route_key": null, + "owner_key": null, + "order": 13, + "is_assigned": true, + "is_assignable": false, + "scope_type": null + } + ] + } + ], + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 10:41:24", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true, + "can_toggle_status": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.permissions.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + +단일 역할의 상세 정보를 조회한다. 역할 편집 화면과 복제(clone_from) 시 원본 값을 채우는 데 사용된다. 목록 응답과 달리 permissions 관계를 pivot(scope_type)과 함께 로드하므로 `permission_ids`·`permission_values`·`permissions`(계층 트리)와 `users_count` 가 항상 포함된다. `core.permissions.read` 권한이 필요하다. + + +### PUT /api/admin/roles/{role} + +- **라우트명**: `api.admin.roles.update` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\RoleController@update` +- **인증/권한**: `auth:sanctum` + `permission:core.permissions.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| role | path | string | 예 | — | 대상 role의 식별자 | +| name | body | string | 예 | — | 대상의 이름/명칭 | +| description | body | string | 아니오 | — | 설명 | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| permissions | body | array | 아니오 | — | 역할에 부여할 권한 목록. 각 원소는 `{id, scope_type}` (id=권한 식별자, scope_type=적용 범위: null 전체 / role 역할 범위 / self 본인 범위). 전달된 목록 기준으로 역할의 권한 집합이 재설정됨 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.role.update_validation_rules`). + +**요청 예시** + +```http +PUT /api/admin/roles/4 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": "예시 이름", + "description": "예시 내용입니다.", + "is_active": true, + "permissions": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `4` | 기본 키 (내부 식별자) | +| identifier | string | `apidoc-sample-role` | 역할명 (예: admin, user, manager) | +| name | string | `실측 예시값` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name_raw | object | `{"ko":"실측 예시값","en":"실측 예시값","fr":"실측 예시값"}` | `name` 의 원본 값 (현재 로케일 미해석 원본 JSON/문자열) | +| description | string | `실측 예시값` | 설명 (다국어 필드는 로케일별 값 객체) | +| description_raw | object | `{"ko":"실측 예시값","en":"실측 예시값","fr":"실측 예시값"}` | `description` 의 원본 값 (현재 로케일 미해석 원본 JSON/문자열) | +| extension_type | null | `null` | 이 리소스를 소유한 확장의 타입 (core/module/plugin/template) | +| extension_identifier | null | `null` | 이 리소스를 소유한 확장의 식별자 | +| extension_name | null | `null` | 이 리소스를 소유한 확장의 표시 이름 (manifest name) | +| is_deletable | boolean | `true` | deletable 여부 | +| is_active | boolean | `true` | active 여부 | +| created_at | string | `2026-07-08 10:41:24` | 생성 일시 | +| updated_at | string | `2026-07-08 12:14:48` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true,"c…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "역할이 성공적으로 수정되었습니다.", + "data": { + "id": 4, + "identifier": "apidoc-sample-role", + "name": "실측 예시값", + "name_raw": { + "ko": "실측 예시값", + "en": "실측 예시값", + "fr": "실측 예시값" + }, + "description": "실측 예시값", + "description_raw": { + "ko": "실측 예시값", + "en": "실측 예시값", + "fr": "실측 예시값" + }, + "extension_type": null, + "extension_identifier": null, + "extension_name": null, + "is_deletable": true, + "is_active": true, + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 12:14:48", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true, + "can_toggle_status": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.permissions.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + +기존 역할의 `name`·`description`·`is_active`·`permissions` 를 수정한다. 생성과 달리 `identifier` 는 변경 대상이 아니며, 각 필드는 `sometimes` 규칙이라 전달된 항목만 갱신된다. `permissions` 를 보내면 `[{id, scope_type}]` 형식으로 역할의 권한 집합 전체가 동기화된다(전달된 목록 기준으로 재설정). `name`·`description` 은 문자열/다국어 객체 양쪽을 받는다. 검증 규칙은 `core.role.update_validation_rules` 필터 훅으로 확장할 수 있다. `core.permissions.update` 권한이 필요하다. + + +### PATCH /api/admin/roles/{role}/toggle-status + +- **라우트명**: `api.admin.roles.toggle-status` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\RoleController@toggleStatus` +- **인증/권한**: `auth:sanctum` + `permission:core.permissions.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| role | path | string | 예 | — | 대상 role의 식별자 | + +**요청 예시** + +```http +PATCH /api/admin/roles/4/toggle-status HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `4` | 기본 키 (내부 식별자) | +| identifier | string | `apidoc-sample-role` | 역할명 (예: admin, user, manager) | +| name | string | `API 문서 샘플 역할` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name_raw | object | `{"ko":"API 문서 샘플 역할","en":"API Doc Sample Role"}` | `name` 의 원본 값 (현재 로케일 미해석 원본 JSON/문자열) | +| description | string | `문서 실측용 역할` | 설명 (다국어 필드는 로케일별 값 객체) | +| description_raw | object | `{"ko":"문서 실측용 역할","en":"Sample role for API docs"}` | `description` 의 원본 값 (현재 로케일 미해석 원본 JSON/문자열) | +| extension_type | null | `null` | 이 리소스를 소유한 확장의 타입 (core/module/plugin/template) | +| extension_identifier | null | `null` | 이 리소스를 소유한 확장의 식별자 | +| extension_name | null | `null` | 이 리소스를 소유한 확장의 표시 이름 (manifest name) | +| is_deletable | boolean | `true` | deletable 여부 | +| is_active | boolean | `false` | active 여부 | +| users_count | integer | `1` | users 개수 (집계) | +| created_at | string | `2026-07-08 10:41:24` | 생성 일시 | +| updated_at | string | `2026-07-08 12:14:48` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true,"c…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "역할이 성공적으로 수정되었습니다.", + "data": { + "id": 4, + "identifier": "apidoc-sample-role", + "name": "API 문서 샘플 역할", + "name_raw": { + "ko": "API 문서 샘플 역할", + "en": "API Doc Sample Role" + }, + "description": "문서 실측용 역할", + "description_raw": { + "ko": "문서 실측용 역할", + "en": "Sample role for API docs" + }, + "extension_type": null, + "extension_identifier": null, + "extension_name": null, + "is_deletable": true, + "is_active": false, + "users_count": 1, + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 12:14:48", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true, + "can_toggle_status": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.permissions.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + +역할의 `is_active` 상태를 반대로 토글한다(활성↔비활성). 목록 화면의 상태 스위치에서 호출되며, 별도 본문 없이 대상 역할만 지정하면 된다. 성공 시 사용자 수를 다시 집계한 갱신된 역할 리소스를 반환한다. `core.permissions.update` 권한이 필요하다. + + diff --git a/docs/backend/api/schedules.md b/docs/backend/api/schedules.md new file mode 100644 index 00000000..b3ae70cb --- /dev/null +++ b/docs/backend/api/schedules.md @@ -0,0 +1,1078 @@ +# Schedules API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Schedules 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/admin/schedules + +- **라우트명**: `api.admin.schedules.index` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ScheduleController@index` +- **인증/권한**: `auth:sanctum` + `permission:core.schedules.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | +| filters | query | array | 아니오 | max 10 | 추가 필터 조건 맵 (필드별 조건) | +| type | query | string | 아니오 | `artisan`, `shell`, `url` | 유형 필터 (해당 유형의 항목만 조회) | +| frequency | query | string | 아니오 | `everyMinute`, `hourly`, `daily`, `weekly`, `monthly`, `custom` | 실행 주기 | +| status | query | string | 아니오 | `active`, `inactive` | 상태 필터 (해당 상태의 항목만 조회) | +| last_result | query | string | 아니오 | `success`, `failed`, `running`, `never` | 마지막 실행 결과 필터: success(성공), failed(실패), running(실행중), never(미실행) 중 해당 결과인 스케줄만 조회 | +| without_overlapping | query | string | 아니오 | `0`, `1` | 중복 실행 방지 여부 | +| run_in_maintenance | query | string | 아니오 | `0`, `1` | 점검 모드 중 실행 여부 | +| extension_type | query | string | 아니오 | `core`, `module`, `plugin` | 확장 유형 (core/module/plugin/template) | +| extension_identifier | query | string | 아니오 | max 255 | 확장 식별자 | +| created_from | query | date | 아니오 | — | 생성일 범위의 시작일 (이 날짜 이후 생성된 스케줄만 조회) | +| created_to | query | date | 아니오 | — | 생성일 범위의 종료일 (이 날짜 이전 생성된 스케줄만 조회, created_from 이후여야 함) | +| sort_by | query | string | 아니오 | `created_at`, `name`, `next_run_at`, `last_run_at`, `is_active`, `last_result` | 정렬 기준 필드명 | +| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.schedule.list_validation_rules`). + +**요청 예시** + +```http +GET /api/admin/schedules?page=1&per_page=1&filters=%EC%98%88%EC%8B%9C%EA%B0%92&type=artisan&frequency=everyMinute&status=active&last_result=success&without_overlapping=0&run_in_maintenance=0&extension_type=core&extension_identifier=example-key&created_from=2026-01-01&created_to=2026-01-01&sort_by=created_at&sort_order=asc HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | string | `API 문서 샘플 스케줄` | 작업명 | +| type | string | `artisan` | 작업 유형: artisan(Artisan 커맨드), shell(쉘 명령), url(URL 호출) | +| type_label | string | `Artisan 커맨드` | `type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| command | string | `cache:clear` | 명령어 또는 URL | +| expression | string | `0 3 * * *` | Cron 표현식 | +| frequency | string | `daily` | 실행 주기: everyMinute(매분), hourly(매시간), daily(매일), weekly(매주), monthly(매월), custom(사용자 정의) | +| frequency_label | string | `매일` | `frequency` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| without_overlapping | boolean | `true` | 중복 실행 방지 여부: 0(허용), 1(방지) | +| run_in_maintenance | boolean | `false` | 점검 모드 실행 여부: 0(비실행), 1(실행) | +| is_active | boolean | `true` | active 여부 | +| last_result | string | `success` | 마지막 실행 결과: success(성공), failed(실패), running(실행중), never(미실행) | +| last_result_label | string | `성공` | `last_result` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| last_run_at | string | `2026-07-05T19:20:23+09:00` | last run 일시 | +| last_duration | null | `null` | 마지막 실행의 소요 시간을 사람이 읽는 문자열로 포맷한 값 (예: "45초", "2분 3초" — 마지막 실행 이력의 duration 파생, 실행 이력이 없으면 null) | +| next_run_at | string | `2026-07-07T12:00:00+09:00` | next run 일시 | +| creator | object | `{"uuid":"a231747f-e82e-4cf2-9ae1-a261849dce40","name":"AP…` | 생성자 정보 객체 (uuid/name/email — creator 관계 파생) | +| created_at | string | `2026-07-06` | 생성 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true,"c…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "스케줄 목록을 조회했습니다.", + "data": { + "data": [ + { + "id": 1, + "name": "API 문서 샘플 스케줄", + "type": "artisan", + "type_label": "Artisan 커맨드", + "command": "cache:clear", + "expression": "0 3 * * *", + "frequency": "daily", + "frequency_label": "매일", + "without_overlapping": true, + "run_in_maintenance": false, + "is_active": true, + "last_result": "success", + "last_result_label": "성공", + "last_run_at": "2026-07-07T10:41:24+09:00", + "last_duration": null, + "next_run_at": "2026-07-08T12:00:00+09:00", + "creator": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자" + }, + "created_at": "2026-07-08", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true, + "can_run": true + } + } + ], + "statistics": { + "total": 1, + "active": 1, + "inactive": 0, + "success": 1, + "failed": 0, + "running": 0, + "never_run": 0 + }, + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 1, + "from": 1, + "to": 1, + "has_more_pages": false + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.schedules.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 예약 작업(스케줄) 목록을 페이지네이션과 함께 조회하며, 응답에는 상태별 통계도 포함됩니다. `core.schedules.read` 권한이 필요합니다. `type`/`frequency`/`status`/`last_result`/`extension_type` 등으로 필터링하고 `sort_by`/`sort_order` 로 정렬할 수 있습니다. 관리자 스케줄 관리 화면의 목록·필터·요약 카드 표시에 사용합니다. + + +### POST /api/admin/schedules + +- **라우트명**: `api.admin.schedules.store` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ScheduleController@store` +- **인증/권한**: `auth:sanctum` + `permission:core.schedules.create` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| name | body | string | 예 | max 255 | 대상의 이름/명칭 | +| description | body | string | 아니오 | max 1000 | 설명 | +| type | body | string | 예 | `artisan`, `shell`, `url` | 작업 유형: artisan(Artisan 커맨드 실행), shell(쉘 명령 실행), url(URL 호출) | +| command | body | string | 예 | max 2000 | 실행할 아티즌 커맨드 | +| expression | body | string | 예 | max 100 | 실행 시각을 정의하는 Cron 표현식 (예: `0 3 * * *`, 다음 실행 시각 next_run_at 계산의 기준) | +| frequency | body | string | 예 | `everyMinute`, `hourly`, `daily`, `weekly`, `monthly`, `custom` | 실행 주기 | +| without_overlapping | body | boolean | 아니오 | — | 중복 실행 방지 여부 | +| run_in_maintenance | body | boolean | 아니오 | — | 점검 모드 중 실행 여부 | +| timeout | body | integer | 아니오 | min 1, max 86400 | 타임아웃 (초) | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| extension_type | body | string | 아니오 | `core`, `module`, `plugin` | 확장 유형 (core/module/plugin/template) | +| extension_identifier | body | string | 아니오 | max 255 | 확장 식별자 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.schedule.create_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/schedules HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": "예시 이름", + "description": "예시 내용입니다.", + "type": "artisan", + "command": "예시값", + "expression": "예시값", + "frequency": "everyMinute", + "without_overlapping": true, + "run_in_maintenance": true, + "timeout": 1, + "is_active": true, + "extension_type": "core", + "extension_identifier": "example-key" +} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `14` | 기본 키 (내부 식별자) | +| name | string | `실측 예시값` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| description | string | `실측 예시값` | 설명 (다국어 필드는 로케일별 값 객체) | +| type | string | `artisan` | 작업 유형: artisan(Artisan 커맨드), shell(쉘 명령), url(URL 호출) | +| type_label | string | `Artisan 커맨드` | `type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| command | string | `실측 예시값` | 명령어 또는 URL | +| expression | string | `실측 예시값` | Cron 표현식 | +| frequency | string | `everyMinute` | 실행 주기: everyMinute(매분), hourly(매시간), daily(매일), weekly(매주), monthly(매월), custom(사용자 정의) | +| frequency_label | string | `매분` | `frequency` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| without_overlapping | boolean | `true` | 중복 실행 방지 여부: 0(허용), 1(방지) | +| run_in_maintenance | boolean | `true` | 점검 모드 실행 여부: 0(비실행), 1(실행) | +| timeout | integer | `1` | 실행 제한 시간 (초) | +| is_active | boolean | `true` | active 여부 | +| last_result | string | `never` | 마지막 실행 결과: success(성공), failed(실패), running(실행중), never(미실행) | +| last_result_label | string | `미실행` | `last_result` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| last_duration | null | `null` | 마지막 실행 소요 시간 (초/밀리초 — 실행 이력 파생) | +| extension_type | string | `core` | 이 리소스를 소유한 확장의 타입 (core/module/plugin/template) | +| extension_identifier | string | `probe_6a4dc0a862b69` | 이 리소스를 소유한 확장의 식별자 | +| creator | object | `{"uuid":"a234c2b1-cde8-437f-b28b-23323be2b98d","name":"AP…` | 생성자 정보 객체 (uuid/name/email — creator 관계 파생) | +| created_at | string | `2026-07-08 12:14:48` | 생성 일시 | +| updated_at | string | `2026-07-08 12:14:48` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true,"c…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "스케줄이 생성되었습니다.", + "data": { + "id": 14, + "name": "실측 예시값", + "description": "실측 예시값", + "type": "artisan", + "type_label": "Artisan 커맨드", + "command": "실측 예시값", + "expression": "실측 예시값", + "frequency": "everyMinute", + "frequency_label": "매분", + "without_overlapping": true, + "run_in_maintenance": true, + "timeout": 1, + "is_active": true, + "last_result": "never", + "last_result_label": "미실행", + "last_duration": null, + "extension_type": "core", + "extension_identifier": "probe_6a4dc0a862b69", + "creator": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자" + }, + "created_at": "2026-07-08 12:14:48", + "updated_at": "2026-07-08 12:14:48", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true, + "can_run": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.schedules.create`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 새 예약 작업을 생성합니다. `core.schedules.create` 권한이 필요합니다. `type`(artisan/shell/url), `command`, `expression`, `frequency` 를 지정하며 생성자(creator)는 현재 사용자로 자동 기록됩니다. 검증 실패 시 422로 응답하고, 성공 시 201과 생성된 스케줄 리소스를 반환합니다. 관리자 스케줄 등록 폼에 사용합니다. + + +### DELETE /api/admin/schedules/bulk + +- **라우트명**: `api.admin.schedules.bulk-delete` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ScheduleController@bulkDelete` +- **인증/권한**: `auth:sanctum` + `permission:core.schedules.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| ids | query | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.schedule.bulk_delete_validation_rules`). + +**요청 예시** + +```http +DELETE /api/admin/schedules/bulk?ids=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.schedules.delete`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 전달된 `ids` 배열의 스케줄을 일괄 삭제합니다. `core.schedules.delete` 권한이 필요합니다. 삭제 처리 결과 요약을 반환하며, 검증 실패 시 422로 응답합니다. 목록에서 여러 스케줄을 선택해 한 번에 제거하는 동작에 사용합니다. + + +### PATCH /api/admin/schedules/bulk-status + +- **라우트명**: `api.admin.schedules.bulk-status` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ScheduleController@bulkUpdateStatus` +- **인증/권한**: `auth:sanctum` + `permission:core.schedules.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| ids | body | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | +| is_active | body | boolean | 예 | — | 활성 여부 (true 활성 / false 비활성) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.schedule.bulk_update_status_validation_rules`). + +**요청 예시** + +```http +PATCH /api/admin/schedules/bulk-status HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "ids": [ + "예시값" + ], + "is_active": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.schedules.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 전달된 `ids` 배열의 스케줄 활성 상태(`is_active`)를 일괄 변경합니다. `core.schedules.update` 권한이 필요합니다. 처리 결과 요약을 반환하며, 검증 실패 시 422로 응답합니다. 목록에서 여러 스케줄을 선택해 한 번에 활성화/비활성화하는 동작에 사용합니다. + + +### DELETE /api/admin/schedules/history/{historyId} + +- **라우트명**: `api.admin.schedules.delete-history` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ScheduleController@deleteHistory` +- **인증/권한**: `auth:sanctum` + `permission:core.schedules.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| historyId | path | string | 예 | — | 대상 history의 식별자 | + +**요청 예시** + +```http +DELETE /api/admin/schedules/history/{historyId} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.schedules.delete`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 특정 실행 이력 레코드를 삭제합니다. `core.schedules.delete` 권한이 필요합니다. 대상 이력이 없으면 404로 응답합니다. 스케줄 상세의 실행 이력 목록에서 개별 이력을 제거할 때 사용합니다. + + +### GET /api/admin/schedules/statistics + +- **라우트명**: `api.admin.schedules.statistics` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ScheduleController@statistics` +- **인증/권한**: `auth:sanctum` + `permission:core.schedules.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/schedules/statistics HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| total | integer | `1` | 전체 개수 (집계) | +| active | integer | `1` | 활성(is_active=true) 스케줄 수 | +| inactive | integer | `0` | 비활성(is_active=false) 스케줄 수 | +| success | integer | `1` | 마지막 실행 결과가 성공(success)인 스케줄 수 | +| failed | integer | `0` | 마지막 실행 결과가 실패(failed)인 스케줄 수 | +| running | integer | `0` | 마지막 실행 결과가 실행중(running)인 스케줄 수 | +| never_run | integer | `0` | 아직 한 번도 실행되지 않은(last_result=never) 스케줄 수 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "스케줄 통계를 조회했습니다.", + "data": { + "total": 1, + "active": 1, + "inactive": 0, + "success": 1, + "failed": 0, + "running": 0, + "never_run": 0 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.schedules.read`)이 없는 경우 | + + + +**설명** 스케줄 전체의 집계 통계를 조회합니다. `core.schedules.read` 권한이 필요합니다. 전체/활성/비활성 수와 마지막 실행 결과별(성공/실패/실행중/미실행) 건수를 반환합니다. 관리자 스케줄 대시보드의 요약 카드 표시에 사용합니다. + + +### DELETE /api/admin/schedules/{schedule} + +- **라우트명**: `api.admin.schedules.destroy` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ScheduleController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:core.schedules.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| schedule | path | string | 예 | — | 대상 schedule의 식별자 | + +**요청 예시** + +```http +DELETE /api/admin/schedules/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "스케줄이 삭제되었습니다.", + "data": null +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.schedules.delete`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 단일 스케줄을 삭제합니다. `core.schedules.delete` 권한이 필요합니다. 경로의 `schedule` 은 라우트 모델 바인딩으로 해석되어 존재하지 않으면 404가 됩니다. 관리자 스케줄 관리 화면의 개별 삭제 동작에 사용합니다. + + +### GET /api/admin/schedules/{schedule} + +- **라우트명**: `api.admin.schedules.show` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ScheduleController@show` +- **인증/권한**: `auth:sanctum` + `permission:core.schedules.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| schedule | path | string | 예 | — | 대상 schedule의 식별자 | + +**요청 예시** + +```http +GET /api/admin/schedules/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | string | `API 문서 샘플 스케줄` | 작업명 | +| description | string | `문서 실측용 스케줄` | 설명 | +| type | string | `artisan` | 작업 유형: artisan(Artisan 커맨드), shell(쉘 명령), url(URL 호출) | +| type_label | string | `Artisan 커맨드` | `type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| command | string | `cache:clear` | 명령어 또는 URL | +| expression | string | `0 3 * * *` | Cron 표현식 | +| frequency | string | `daily` | 실행 주기: everyMinute(매분), hourly(매시간), daily(매일), weekly(매주), monthly(매월), custom(사용자 정의) | +| frequency_label | string | `매일` | `frequency` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| without_overlapping | boolean | `true` | 중복 실행 방지 여부: 0(허용), 1(방지) | +| run_in_maintenance | boolean | `false` | 점검 모드 실행 여부: 0(비실행), 1(실행) | +| timeout | integer | `300` | 실행 제한 시간 (초) | +| is_active | boolean | `true` | active 여부 | +| last_result | string | `success` | 마지막 실행 결과: success(성공), failed(실패), running(실행중), never(미실행) | +| last_result_label | string | `성공` | `last_result` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| last_run_at | string | `2026-07-05T19:20:23+09:00` | last run 일시 | +| last_duration | null | `null` | 마지막 실행의 소요 시간을 사람이 읽는 문자열로 포맷한 값 (예: "45초", "2분 3초" — 마지막 실행 이력의 duration 파생, 실행 이력이 없으면 null) | +| next_run_at | string | `2026-07-07T12:00:00+09:00` | next run 일시 | +| extension_type | null | `null` | 확장 소유 타입: core(코어), module(모듈), plugin(플러그인), NULL(사용자 정의) | +| extension_identifier | null | `null` | 확장 식별자 (예: core, sirsoft-board, sirsoft-payment) | +| creator | object | `{"uuid":"a231747f-e82e-4cf2-9ae1-a261849dce40","name":"AP…` | 생성자 정보 객체 (uuid/name/email — creator 관계 파생) | +| created_at | string | `2026-07-06 19:20:23` | 생성 일시 | +| updated_at | string | `2026-07-06 19:20:23` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true,"c…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "스케줄 목록을 조회했습니다.", + "data": { + "id": 1, + "name": "API 문서 샘플 스케줄", + "description": "문서 실측용 스케줄", + "type": "artisan", + "type_label": "Artisan 커맨드", + "command": "cache:clear", + "expression": "0 3 * * *", + "frequency": "daily", + "frequency_label": "매일", + "without_overlapping": true, + "run_in_maintenance": false, + "timeout": 300, + "is_active": true, + "last_result": "success", + "last_result_label": "성공", + "last_run_at": "2026-07-07T10:41:24+09:00", + "last_duration": null, + "next_run_at": "2026-07-08T12:00:00+09:00", + "extension_type": null, + "extension_identifier": null, + "creator": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자" + }, + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 10:41:24", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true, + "can_run": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.schedules.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 단일 스케줄의 상세 정보를 조회하며 생성자(creator) 정보를 함께 로드합니다. `core.schedules.read` 권한이 필요합니다. 경로의 `schedule` 은 라우트 모델 바인딩으로 해석되어 없으면 404가 됩니다. 관리자 스케줄 상세/수정 화면의 초기 데이터 로딩에 사용합니다. + + +### PUT /api/admin/schedules/{schedule} + +- **라우트명**: `api.admin.schedules.update` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ScheduleController@update` +- **인증/권한**: `auth:sanctum` + `permission:core.schedules.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| schedule | path | string | 예 | — | 대상 schedule의 식별자 | +| name | body | string | 예 | max 255 | 대상의 이름/명칭 | +| description | body | string | 아니오 | max 1000 | 설명 | +| type | body | string | 예 | `artisan`, `shell`, `url` | 작업 유형: artisan(Artisan 커맨드 실행), shell(쉘 명령 실행), url(URL 호출) | +| command | body | string | 예 | max 2000 | 실행할 아티즌 커맨드 | +| expression | body | string | 예 | max 100 | 실행 시각을 정의하는 Cron 표현식 (예: `0 3 * * *`, 다음 실행 시각 next_run_at 계산의 기준) | +| frequency | body | string | 예 | `everyMinute`, `hourly`, `daily`, `weekly`, `monthly`, `custom` | 실행 주기 | +| without_overlapping | body | boolean | 아니오 | — | 중복 실행 방지 여부 | +| run_in_maintenance | body | boolean | 아니오 | — | 점검 모드 중 실행 여부 | +| timeout | body | integer | 아니오 | min 1, max 86400 | 타임아웃 (초) | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| extension_type | body | string | 아니오 | `core`, `module`, `plugin` | 확장 유형 (core/module/plugin/template) | +| extension_identifier | body | string | 아니오 | max 255 | 확장 식별자 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.schedule.update_validation_rules`). + +**요청 예시** + +```http +PUT /api/admin/schedules/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": "예시 이름", + "description": "예시 내용입니다.", + "type": "artisan", + "command": "예시값", + "expression": "예시값", + "frequency": "everyMinute", + "without_overlapping": true, + "run_in_maintenance": true, + "timeout": 1, + "is_active": true, + "extension_type": "core", + "extension_identifier": "example-key" +} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | string | `실측 예시값` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| description | string | `실측 예시값` | 설명 (다국어 필드는 로케일별 값 객체) | +| type | string | `artisan` | 작업 유형: artisan(Artisan 커맨드), shell(쉘 명령), url(URL 호출) | +| type_label | string | `Artisan 커맨드` | `type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| command | string | `실측 예시값` | 명령어 또는 URL | +| expression | string | `실측 예시값` | Cron 표현식 | +| frequency | string | `everyMinute` | 실행 주기: everyMinute(매분), hourly(매시간), daily(매일), weekly(매주), monthly(매월), custom(사용자 정의) | +| frequency_label | string | `매분` | `frequency` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| without_overlapping | boolean | `true` | 중복 실행 방지 여부: 0(허용), 1(방지) | +| run_in_maintenance | boolean | `true` | 점검 모드 실행 여부: 0(비실행), 1(실행) | +| timeout | integer | `1` | 실행 제한 시간 (초) | +| is_active | boolean | `true` | active 여부 | +| last_result | string | `success` | 마지막 실행 결과: success(성공), failed(실패), running(실행중), never(미실행) | +| last_result_label | string | `성공` | `last_result` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| last_run_at | string | `2026-07-07T10:41:24+09:00` | last run 일시 | +| last_duration | null | `null` | 마지막 실행 소요 시간 (초/밀리초 — 실행 이력 파생) | +| extension_type | string | `core` | 이 리소스를 소유한 확장의 타입 (core/module/plugin/template) | +| extension_identifier | string | `probe_6a4dc0a8e44c0` | 이 리소스를 소유한 확장의 식별자 | +| creator | object | `{"uuid":"a234c2b1-cde8-437f-b28b-23323be2b98d","name":"AP…` | 생성자 정보 객체 (uuid/name/email — creator 관계 파생) | +| created_at | string | `2026-07-08 10:41:24` | 생성 일시 | +| updated_at | string | `2026-07-08 12:14:48` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true,"c…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "스케줄이 수정되었습니다.", + "data": { + "id": 1, + "name": "실측 예시값", + "description": "실측 예시값", + "type": "artisan", + "type_label": "Artisan 커맨드", + "command": "실측 예시값", + "expression": "실측 예시값", + "frequency": "everyMinute", + "frequency_label": "매분", + "without_overlapping": true, + "run_in_maintenance": true, + "timeout": 1, + "is_active": true, + "last_result": "success", + "last_result_label": "성공", + "last_run_at": "2026-07-07T10:41:24+09:00", + "last_duration": null, + "extension_type": "core", + "extension_identifier": "probe_6a4dc0a8e44c0", + "creator": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자" + }, + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 12:14:48", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true, + "can_run": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.schedules.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 기존 스케줄 정보를 수정합니다. `core.schedules.update` 권한이 필요합니다. store 와 동일한 필드(`type`/`command`/`expression`/`frequency` 등)를 받으며 검증 실패 시 422로 응답합니다. 경로의 `schedule` 은 라우트 모델 바인딩으로 해석되며, 수정된 스케줄 리소스를 반환합니다. 관리자 스케줄 수정 폼에 사용합니다. + + +### POST /api/admin/schedules/{schedule}/duplicate + +- **라우트명**: `api.admin.schedules.duplicate` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ScheduleController@duplicate` +- **인증/권한**: `auth:sanctum` + `permission:core.schedules.create` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| schedule | path | string | 예 | — | 대상 schedule의 식별자 | + +**요청 예시** + +```http +POST /api/admin/schedules/1/duplicate HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `15` | 기본 키 (내부 식별자) | +| name | string | `API 문서 샘플 스케줄 (복사본)` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| description | string | `문서 실측용 스케줄` | 설명 (다국어 필드는 로케일별 값 객체) | +| type | string | `artisan` | 작업 유형: artisan(Artisan 커맨드), shell(쉘 명령), url(URL 호출) | +| type_label | string | `Artisan 커맨드` | `type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| command | string | `cache:clear` | 명령어 또는 URL | +| expression | string | `0 3 * * *` | Cron 표현식 | +| frequency | string | `daily` | 실행 주기: everyMinute(매분), hourly(매시간), daily(매일), weekly(매주), monthly(매월), custom(사용자 정의) | +| frequency_label | string | `매일` | `frequency` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| without_overlapping | boolean | `true` | 중복 실행 방지 여부: 0(허용), 1(방지) | +| run_in_maintenance | boolean | `false` | 점검 모드 실행 여부: 0(비실행), 1(실행) | +| timeout | integer | `300` | 실행 제한 시간 (초) | +| is_active | boolean | `false` | active 여부 | +| last_result | string | `never` | 마지막 실행 결과: success(성공), failed(실패), running(실행중), never(미실행) | +| last_result_label | string | `미실행` | `last_result` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| last_duration | null | `null` | 마지막 실행 소요 시간 (초/밀리초 — 실행 이력 파생) | +| extension_type | null | `null` | 이 리소스를 소유한 확장의 타입 (core/module/plugin/template) | +| extension_identifier | null | `null` | 이 리소스를 소유한 확장의 식별자 | +| created_at | string | `2026-07-08 12:14:48` | 생성 일시 | +| updated_at | string | `2026-07-08 12:14:48` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true,"c…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "스케줄이 복제되었습니다.", + "data": { + "id": 15, + "name": "API 문서 샘플 스케줄 (복사본)", + "description": "문서 실측용 스케줄", + "type": "artisan", + "type_label": "Artisan 커맨드", + "command": "cache:clear", + "expression": "0 3 * * *", + "frequency": "daily", + "frequency_label": "매일", + "without_overlapping": true, + "run_in_maintenance": false, + "timeout": 300, + "is_active": false, + "last_result": "never", + "last_result_label": "미실행", + "last_duration": null, + "extension_type": null, + "extension_identifier": null, + "created_at": "2026-07-08 12:14:48", + "updated_at": "2026-07-08 12:14:48", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true, + "can_run": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.schedules.create`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 기존 스케줄을 복제해 새 스케줄을 만듭니다. `core.schedules.create` 권한이 필요합니다. 원본을 바탕으로 새 레코드를 생성하며, 성공 시 201과 복제된 스케줄 리소스를 반환합니다. 유사한 설정의 스케줄을 빠르게 추가하는 "복제" 동작에 사용합니다. + + +### GET /api/admin/schedules/{schedule}/history + +- **라우트명**: `api.admin.schedules.history` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ScheduleController@history` +- **인증/권한**: `auth:sanctum` + `permission:core.schedules.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| schedule | path | string | 예 | — | 대상 schedule의 식별자 | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | +| status | query | string | 아니오 | `success`, `failed`, `running` | 상태 필터 (해당 상태의 항목만 조회) | +| trigger_type | query | string | 아니오 | `scheduled`, `manual` | 실행 방식 필터: scheduled(예약 시각 자동 실행), manual(관리자의 즉시 실행) 중 해당 이력만 조회 | +| started_from | query | date | 아니오 | — | 실행 시작일 범위의 시작일 (이 날짜 이후 시작된 이력만 조회) | +| started_to | query | date | 아니오 | — | 실행 시작일 범위의 종료일 (이 날짜 이전 시작된 이력만 조회, started_from 이후여야 함) | +| sort_by | query | string | 아니오 | `started_at`, `ended_at`, `duration`, `status` | 정렬 기준 필드명 | +| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.schedule.history_list_validation_rules`). + +**요청 예시** + +```http +GET /api/admin/schedules/1/history?page=1&per_page=1&status=success&trigger_type=scheduled&started_from=2026-01-01&started_to=2026-01-01&sort_by=started_at&sort_order=asc HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "실행 이력을 조회했습니다.", + "data": { + "data": [], + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 0, + "from": null, + "to": null, + "has_more_pages": false + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.schedules.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 특정 스케줄의 실행 이력을 페이지네이션으로 조회합니다. `core.schedules.read` 권한이 필요합니다. `status`(success/failed/running), `trigger_type`(scheduled/manual), 기간(`started_from`/`started_to`)으로 필터링하고 `sort_by`/`sort_order` 로 정렬할 수 있습니다. 스케줄 상세의 실행 이력 탭 표시에 사용합니다. + + +### POST /api/admin/schedules/{schedule}/run + +- **라우트명**: `api.admin.schedules.run` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\ScheduleController@run` +- **인증/권한**: `auth:sanctum` + `permission:core.schedules.run` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| schedule | path | string | 예 | — | 대상 schedule의 식별자 | + +**요청 예시** + +```http +POST /api/admin/schedules/1/run HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `7` | 기본 키 (내부 식별자) | +| schedule_id | integer | `1` | schedule 식별자 (연관 리소스 참조) | +| started_at | string | `2026-07-08T12:14:49+09:00` | started 일시 | +| ended_at | string | `2026-07-08T12:14:49+09:00` | ended 일시 | +| duration | integer | `0` | 실행 소요 시간 (초/밀리초) | +| duration_formatted | null | `null` | `duration` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| status | string | `success` | 상태 값 (도메인별 상태 집합 — 사람이 읽는 라벨은 status_label, UI 변형은 status_variant 참조) | +| status_label | string | `성공` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| exit_code | integer | `0` | 실행 종료 코드 (0=성공, 그 외=실패) | +| memory_usage | integer | `427352` | 실행 중 최대 메모리 사용량 (바이트) | +| memory_usage_formatted | string | `417.34 KB` | `memory_usage` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| output | string | ` INFO Application cache cleared s…` | 실행 표준 출력 내용 | +| error_output | null | `null` | 실행 표준 에러 출력 내용 (없으면 빈 문자열/null) | +| trigger_type | string | `manual` | 동작을 유발한 방식/주체 구분 값 | +| trigger_type_label | string | `수동 실행` | `trigger_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| created_at | string | `2026-07-08 12:14:49` | 생성 일시 | +| updated_at | string | `2026-07-08 12:14:49` | 최종 수정 일시 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "스케줄이 실행되었습니다.", + "data": { + "id": 7, + "schedule_id": 1, + "started_at": "2026-07-08T12:14:49+09:00", + "ended_at": "2026-07-08T12:14:49+09:00", + "duration": 0, + "duration_formatted": null, + "status": "success", + "status_label": "성공", + "exit_code": 0, + "memory_usage": 427352, + "memory_usage_formatted": "417.34 KB", + "output": "\n INFO Application cache cleared successfully. \n\r\n", + "error_output": null, + "trigger_type": "manual", + "trigger_type_label": "수동 실행", + "created_at": "2026-07-08 12:14:49", + "updated_at": "2026-07-08 12:14:49" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.schedules.run`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 스케줄을 예약 시각과 무관하게 즉시 실행합니다. `core.schedules.run` 권한이 필요합니다. 실제 명령이 수행되고 실행 이력 레코드가 생성되며, 그 이력(trigger_type=manual)을 반환합니다. 관리자가 대상 작업을 수동으로 즉시 돌려 결과를 확인하는 "지금 실행" 동작에 사용합니다. + + diff --git a/docs/backend/api/search.md b/docs/backend/api/search.md new file mode 100644 index 00000000..b1ee332b --- /dev/null +++ b/docs/backend/api/search.md @@ -0,0 +1,86 @@ +# Search API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Search 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/search + +- **라우트명**: `api.search` +- **컨트롤러**: `App\Http\Controllers\Api\Public\PublicSearchController@search` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| q | query | string | 아니오 | min 2, max 200 | 검색어 (부분 일치) | +| type | query | string | 아니오 | — | 유형 필터 (해당 유형의 항목만 조회) | +| sort | query | string | 아니오 | `relevance`, `latest`, `oldest`, `views`, `popular`, `price_asc`, `price_desc` | 정렬 기준 (필드명, `-` 접두 시 내림차순) | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | +| board_slug | query | string | 아니오 | max 100 | 검색 범위를 특정 게시판으로 한정 (게시판 모듈이 `core.search.validation_rules` 훅으로 추가하는 파라미터, 해당 slug의 게시판 글만 검색) | +| category_id | query | integer | 아니오 | — | category 식별자 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.search.validation_rules`). + +**요청 예시** + +```http +GET /api/search?q=%EC%98%88%EC%8B%9C%EA%B0%92&type=%EC%98%88%EC%8B%9C%EA%B0%92&sort=relevance&page=1&per_page=1&board_slug=example-key&category_id=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| q | string | `` | 실제 검색에 사용된 검색어 (요청 `q` 를 trim 하여 에코, 검색어가 비어 있으면 빈 문자열) | +| total | integer | `0` | 전체 개수 (집계) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "검색어를 입력해주세요.", + "data": { + "q": "", + "total": 0 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +프론트엔드 통합 검색(`search/index.json`)이 호출하는 공개 엔드포인트입니다. 인증이 필요 없으며 게스트도 사용할 수 있습니다. 코어 컨트롤러는 검색 결과를 직접 생성하지 않고, 검증된 파라미터로 검색 컨텍스트(q/type/sort/page/per_page 및 요청 객체)를 구성한 뒤 `core.search.results` Filter 훅을 실행합니다. 게시판·상품 등 각 검색 대상 모듈이 이 훅에 리스너를 등록해 자신의 카테고리 결과를 추가하고, `core.search.build_response` 훅으로 응답 구조를 완성합니다. 따라서 활성 검색 모듈이 없으면 항상 빈 결과(`total: 0`)가 반환됩니다. 검색 엔진 자체는 Scout + `DatabaseFulltextEngine`(MySQL FULLTEXT) 기반이며, 상세는 `docs/backend/search-system.md`를 참고하세요. + + diff --git a/docs/backend/api/seo.md b/docs/backend/api/seo.md new file mode 100644 index 00000000..1d2185ff --- /dev/null +++ b/docs/backend/api/seo.md @@ -0,0 +1,298 @@ +# Seo API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Seo 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/admin/seo/cached-urls + +- **라우트명**: `api.admin.seo.cached-urls` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\SeoCacheController@cachedUrls` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/seo/cached-urls HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| urls | array | `[]` | 현재 사전 렌더 캐시에 남아 있는 봇 대상 페이지 URL 목록 (SeoCacheManager 인덱스에서 조회). | +| count | integer | `0` | 캐시된 URL 개수 (`urls` 배열 길이). | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "urls": [], + "count": 0 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 | + + + +**설명** + +SeoCacheManager 인덱스에서 현재 캐시된 SEO 페이지 URL 목록과 개수를 조회합니다. 어떤 봇 대상 페이지가 사전 렌더 캐시로 남아 있는지 확인하는 진단 용도로 사용합니다. + + +### POST /api/admin/seo/clear-cache + +- **라우트명**: `api.admin.seo.clear-cache` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\SeoCacheController@clearCache` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| layout | body | string | 아니오 | — | 무효화 대상 레이아웃명. 지정 시 해당 레이아웃의 SEO 캐시만 삭제하고 무효화된 항목 수를 반환하며, 미지정 시 전체 SEO 캐시를 삭제한다. | +| module | body | string | 아니오 | — | 모듈 식별자 필터. 검증 규칙에는 정의되어 있으나 현재 컨트롤러 로직에서는 사용되지 않는다(향후 모듈 단위 캐시 무효화 확장 예약 필드). | + +**요청 예시** + +```http +POST /api/admin/seo/clear-cache HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "layout": "예시값", + "module": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +SEO 사전 렌더 캐시를 삭제합니다. `layout` 을 지정하면 해당 레이아웃 캐시만 무효화하고, 지정하지 않으면 전체 SEO 캐시를 삭제합니다. 응답의 `data.cleared` 는 `layout` 지정 시 무효화된 항목 수(정수), 미지정 시 문자열 `"all"` 입니다. 설정이나 콘텐츠 변경 후 오래된 봇 응답이 캐시로 남는 것을 방지할 때 사용합니다. + + +### POST /api/admin/seo/sitemap/regenerate + +- **라우트명**: `api.admin.seo.sitemap.regenerate` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\SeoCacheController@regenerateSitemap` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.update` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/admin/seo/sitemap/regenerate HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| last_updated_at | string | `2026-07-08T03:14:49+00:00` | last updated 일시 | +| size_bytes | integer | `25489` | 크기 (바이트) | +| ttl | integer | `86400` | 캐시 유효 시간 (Time To Live, 초) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "Sitemap 재생성이 완료되었습니다.", + "data": { + "last_updated_at": "2026-07-08T03:14:49+00:00", + "size_bytes": 25489, + "ttl": 86400 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 | + + + +**설명** + +sitemap.xml 을 즉시 재생성합니다. SitemapManager 에 위임하며 큐 드라이버와 무관하게 동기(즉시) 실행되고, 완료 후 마지막 생성 시각을 갱신합니다. SEO 설정에서 sitemap 기능이 비활성인 경우 400(`seo.sitemap_disabled`), 생성 실패 시 500 을 반환합니다. 관리자가 콘텐츠 변경 후 검색엔진에 노출할 sitemap 을 스케줄 대기 없이 즉시 갱신할 때 사용합니다. + + +### GET /api/admin/seo/stats + +- **라우트명**: `api.admin.seo.stats` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\SeoCacheController@stats` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/seo/stats HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| overall | object | `{"total_entries":0,"hits":0,"misses":0,"hit_rate":0,"avg_…` | 최근 7일 전체 캐시 통계 집계. `total_entries`(기록 총 건수), `hits`(적중 건수), `misses`(미적중 건수), `hit_rate`(적중률 %, hits/total×100), `avg_response_time_ms`(미적중 시 평균 렌더링 소요 시간 ms, 데이터 없으면 null). | +| by_layout | array | `[]` | 레이아웃별 통계 목록 (`layout_name` 으로 그룹핑). 각 원소는 `layout_name`·`total`·`hits`·`misses`·`hit_rate`·`avg_response_time_ms` 를 가지며, 레이아웃 단위로 캐시 효율을 비교하는 용도. | +| by_module | array | `[]` | 모듈별 통계 목록 (`module_identifier` 로 그룹핑). 각 원소는 `module_identifier`·`total`·`hits`·`misses`·`hit_rate`·`avg_response_time_ms` 를 가지며, 어느 확장 모듈의 SEO 페이지가 캐시로 재사용되는지 파악하는 용도. | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "overall": { + "total_entries": 0, + "hits": 0, + "misses": 0, + "hit_rate": 0, + "avg_response_time_ms": null + }, + "by_layout": [], + "by_module": [] + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 | + + + +**설명** + +SEO 캐시 적중 현황을 최근 7일 기준으로 전체·레이아웃별·모듈별로 조회합니다. 봇 대상 사전 렌더 캐시가 얼마나 효과적으로 재사용되는지(적중률, 렌더 비용 절감)를 모니터링하는 관리자 대시보드용 통계입니다. + + +### POST /api/admin/seo/warmup + +- **라우트명**: `api.admin.seo.warmup` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\SeoCacheController@warmup` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.update` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/admin/seo/warmup HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 | + + + +**설명** + +SEO 캐시 워밍업(모든 SEO 레이아웃 사전 렌더)을 위한 엔드포인트입니다. 현재 컨트롤러는 실제 워밍업 로직 없이 `status: dispatched` 와 안내 메시지만 반환합니다(실 렌더링은 후속 구현 예정). 응답 성공은 요청 접수만을 의미하며 이 시점에 캐시가 채워지지는 않습니다. + + diff --git a/docs/backend/api/settings.md b/docs/backend/api/settings.md new file mode 100644 index 00000000..cd71a7ec --- /dev/null +++ b/docs/backend/api/settings.md @@ -0,0 +1,1306 @@ +# Settings API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Settings 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/admin/settings + +- **라우트명**: `api.admin.settings.index` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\SettingsController@index` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/settings HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| general | object | `{"site_name":"Test Site","site_url":"https:\/\/test.examp…` | 일반 탭 설정 그룹 (사이트명·사이트 URL·설명·관리자 이메일·타임존·기본 언어·통화·점검 모드·사이트 로고 첨부). site_logo 는 SettingsService 가 별도 주입한 첨부 정보 | +| security | object | `{"force_https":true,"login_attempt_enabled":true,"auth_to…` | 보안 탭 설정 그룹 (HTTPS 강제·로그인 시도 제한 사용·인증 토큰 유지시간(분, 0=무한)·최대 로그인 시도 횟수·잠금 시간) | +| mail | object | `{"mailer":"smtp","host":"","port":587,"username":"","pass…` | 메일 탭 설정 그룹 (메일러 종류(smtp/mailgun/ses)·SMTP 호스트/포트/인증 정보·암호화 방식·발신자 주소/이름·Mailgun/SES 자격 정보) | +| upload | object | `{"max_file_size":10,"allowed_extensions":["jpg","jpeg","p…` | 업로드 탭 설정 그룹 (최대 파일 크기(MB)·허용 확장자 목록·이미지 최대 가로/세로·이미지 품질) | +| seo | object | `{"meta_title_suffix":"","meta_description":"","meta_keywo…` | SEO 탭 설정 그룹 (메타 타이틀 접미사·메타 설명/키워드·검색엔진 인증 코드·봇 감지·OG/Twitter 기본값·SEO 캐시·사이트맵·생성기 설정) | +| advanced | object | `{"cache_enabled":true,"cache_default_ttl":86400,"layout_c…` | 고급 탭 설정 그룹 (캐시·디버그·코어 업데이트·GeoIP 설정을 한 탭으로 합친 병합 뷰). cache/debug 카테고리 값이 함께 노출됨 | +| cache | object | `{"cache_enabled":true,"cache_default_ttl":86400,"layout_c…` | 캐시 원본 카테고리 (전역 캐시 사용·기본 TTL·레이아웃/통계/SEO 캐시 사용 및 TTL). advanced 탭에 병합되면서 개별 접근용으로 별도 노출된 파생 뷰 | +| debug | object | `{"debug_mode":true,"sql_query_log":false,"log_level":"err…` | 디버그 원본 카테고리 (디버그 모드·SQL 쿼리 로그·로그 레벨). advanced 탭에 병합되면서 개별 접근용으로 별도 노출된 파생 뷰 | +| drivers | object | `{"storage_driver":"local","s3_bucket":null,"s3_region":"a…` | 드라이버 탭 설정 그룹 (스토리지/캐시/세션/큐/로그 드라이버 선택 + S3·Redis·Memcached·WebSocket·검색엔진 접속 파라미터) | +| core_update | object | `{"core_update_github_url":"https:\/\/github.com\/custom\/…` | 코어 업데이트 원본 카테고리 (코어 업데이트를 받아올 GitHub 저장소 URL·비공개 저장소 접근용 토큰). advanced 탭에 병합된 파생 뷰 | +| geoip | object | `{"geoip_enabled":false,"geoip_license_key":null,"geoip_au…` | GeoIP 원본 카테고리 (GeoIP 사용 여부·MaxMind 라이선스 키·DB 자동 갱신 사용). advanced 탭에 병합된 파생 뷰 | +| notifications | object | `{"channels":[{"id":"mail","is_active":true,"sort_order":1…` | 알림 탭 설정 그룹. channels 는 알림 채널 목록으로 각 원소가 id(채널 식별자)·is_active(활성 여부)·sort_order(표시 순서)를 가짐 | +| identity | object | `{"default_provider":"g7:core.mail","purpose_providers":{"…` | 본인인증(IDV) 탭 설정 그룹 (기본 provider·목적별 provider 매핑(purpose_providers)·챌린지 유효시간(분)·최대 시도 횟수) | +| available_drivers | object | `{"storage":[{"id":"local","label":{"ko":"로컬","en":"Local"…` | 드라이버 선택지 카탈로그 (DriverRegistryService 산물). 종류별(storage/cache/session/queue 등) 선택 가능한 드라이버 목록을 id/다국어 label 형태로 제공 | +| abilities | object | `{"can_update":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "설정을 성공적으로 가져왔습니다.", + "data": { + "general": { + "site_name": "Test Site", + "site_url": "https://test.example.com", + "site_description": "", + "admin_email": "admin@example.com", + "timezone": "Asia/Seoul", + "language": "ko", + "currency": "KRW", + "maintenance_mode": false, + "site_logo": [] + }, + "security": { + "force_https": true, + "login_attempt_enabled": true, + "auth_token_lifetime": "{MASKED}", + "max_login_attempts": 5, + "login_lockout_time": 30, + "two_factor_auth": false, + "password_min_length": "{MASKED}", + "require_password_special_char": "{MASKED}" + }, + "mail": { + "mailer": "smtp", + "host": "", + "port": 587, + "username": "", + "password": "{MASKED}", + "encryption": "tls", + "mailgun_domain": "", + "mailgun_secret": "{MASKED}", + "mailgun_endpoint": "api.mailgun.net", + "ses_key": "", + "ses_secret": "{MASKED}", + "ses_region": "ap-northeast-2", + "from_address": "heuristing@gmail.com", + "from_name": "그누보드7" + }, + "upload": { + "max_file_size": 10, + "allowed_extensions": [ + "jpg", + "jpeg", + "png", + "gif", + "webp", + "pdf", + "doc", + "docx", + "xls", + "xlsx", + "zip" + ], + "image_max_width": 2000, + "image_max_height": 2000, + "image_quality": 85 + }, + "seo": { + "meta_title_suffix": "", + "meta_description": "", + "meta_keywords": "", + "google_analytics_id": "", + "google_site_verification": "", + "naver_site_verification": "", + "bot_user_agents": [ + "kakaotalk-scrap", + "Meta-ExternalAgent", + "ChatGPT-User" + ], + "bot_detection_enabled": true, + "bot_detection_library_enabled": true, + "og_default_site_name": "", + "og_image_default_width": 1200, + "og_image_default_height": 630, + "twitter_default_card": "summary_large_image", + "twitter_default_site": "", + "cache_enabled": true, + "cache_ttl": 7200, + "sitemap_enabled": true, + "sitemap_cache_ttl": 86400, + "sitemap_schedule": "daily", + "sitemap_schedule_time": "02:00", + "sitemap_last_updated_at": "2026-07-08T03:14:49+00:00", + "generator_enabled": true, + "generator_content": "" + }, + "advanced": { + "cache_enabled": true, + "cache_default_ttl": 86400, + "layout_cache_enabled": true, + "layout_cache_ttl": 3600, + "seo_cache_enabled": true, + "seo_cache_ttl": 7200, + "seo_sitemap_cache_ttl": 86400, + "stats_cache_enabled": true, + "stats_cache_ttl": 1800, + "notification_cache_ttl": 3600, + "extension_status_cache_ttl": 86400, + "geoip_cache_enabled": true, + "geoip_cache_ttl": 86400, + "version_check_cache_ttl": 3600, + "debug_mode": true, + "sql_query_log": false, + "log_level": "error", + "core_update_github_url": "https://github.com/custom/repo", + "core_update_github_token": "{MASKED}", + "geoip_enabled": false, + "geoip_license_key": null, + "geoip_auto_update_enabled": true, + "geoip_last_updated_at": "" + }, + "cache": { + "cache_enabled": true, + "cache_default_ttl": 86400, + "layout_cache_enabled": true, + "layout_cache_ttl": 3600, + "seo_cache_enabled": true, + "seo_cache_ttl": 7200, + "seo_sitemap_cache_ttl": 86400, + "stats_cache_enabled": true, + "stats_cache_ttl": 1800, + "notification_cache_ttl": 3600, + "extension_status_cache_ttl": 86400, + "geoip_cache_enabled": true, + "geoip_cache_ttl": 86400, + "version_check_cache_ttl": 3600 + }, + "debug": { + "debug_mode": true, + "sql_query_log": false, + "log_level": "error" + }, + "drivers": { + "storage_driver": "local", + "s3_bucket": null, + "s3_region": "ap-northeast-2", + "s3_access_key": null, + "s3_secret_key": null, + "s3_url": null, + "cache_driver": "file", + "redis_host": "127.0.0.1", + "redis_port": 6379, + "redis_password": null, + "redis_database": "1", + "memcached_host": "127.0.0.1", + "memcached_port": 11211, + "session_driver": "file", + "session_lifetime": 120, + "queue_driver": "sync", + "log_driver": "daily", + "log_level": "error", + "log_days": 14, + "websocket_enabled": true, + "websocket_app_id": "test-app-id", + "websocket_app_key": "{MASKED}", + "websocket_app_secret": "{MASKED}", + "websocket_host": "localhost", + "websocket_port": 8080, + "websocket_scheme": "https", + "websocket_verify_ssl": true, + "websocket_server_host": "127.0.0.1", + "websocket_server_port": 8080, + "websocket_server_scheme": "http", + "search_engine_driver": "mysql-fulltext" + }, + "core_update": { + "core_update_github_url": "https://github.com/custom/repo", + "core_update_github_token": "{MASKED}" + }, + "geoip": { + "geoip_enabled": false, + "geoip_license_key": null, + "geoip_auto_update_enabled": true, + "geoip_last_updated_at": "" + }, + "notifications": { + "channels": [ + { + "id": "mail", + "is_active": true, + "sort_order": 1 + }, + { + "id": "database", + "is_active": true, + "sort_order": 2 + } + ] + }, + "identity": { + "default_provider": "g7:core.mail", + "purpose_providers": { + "signup": null, + "password_reset": null, + "inicis": { + "adult_verification": "inicis" + } + }, + "challenge_ttl_minutes": 15, + "max_attempts": 5 + }, + "available_drivers": { + "storage": [ + { + "id": "local", + "label": { + "ko": "로컬", + "en": "Local", + "fr": "로컬" + } + }, + { + "id": "s3", + "label": { + "ko": "Amazon S3", + "en": "Amazon S3", + "fr": "Amazon S3" + } + } + ], + "cache": [ + { + "id": "file", + "label": { + "ko": "파일", + "en": "File", + "fr": "파일" + } + }, + { + "id": "redis", + "label": { + "ko": "Redis", + "en": "Redis", + "fr": "Redis" + } + } + ], + "session": [ + { + "id": "file", + "label": { + "ko": "파일", + "en": "File", + "fr": "파일" + } + }, + { + "id": "database", + "label": { + "ko": "데이터베이스", + "en": "Database", + "fr": "데이터베이스" + } + }, + { + "id": "redis", + "label": { + "ko": "Redis", + "en": "Redis", + "fr": "Redis" + } + } + ], + "queue": [ + { + "id": "sync", + "label": { + "ko": "동기", + "en": "Sync", + "fr": "동기" + } + }, + { + "id": "database", + "label": { + "ko": "데이터베이스", + "en": "Database", + "fr": "데이터베이스" + } + }, + { + "id": "redis", + "label": { + "ko": "Redis", + "en": "Redis", + "fr": "Redis" + } + } + ], + "log": [ + { + "id": "single", + "label": { + "ko": "단일 파일", + "en": "Single File", + "fr": "단일 파일" + } + }, + { + "id": "daily", + "label": { + "ko": "일별 파일", + "en": "Daily File", + "fr": "일별 파일" + } + } + ], + "websocket": [ + { + "id": "reverb", + "label": { + "ko": "Laravel Reverb", + "en": "Laravel Reverb", + "fr": "Laravel Reverb" + } + } + ], + "mail": [ + { + "id": "smtp", + "label": { + "ko": "SMTP", + "en": "SMTP", + "fr": "SMTP" + } + }, + { + "id": "mailgun", + "label": { + "ko": "Mailgun", + "en": "Mailgun", + "fr": "Mailgun" + } + }, + { + "id": "ses", + "label": { + "ko": "SES (Amazon)", + "en": "SES (Amazon)", + "fr": "SES (Amazon)" + } + } + ] + }, + "abilities": { + "can_update": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 | + + + +**설명** + +관리자 통합 환경설정 화면(`admin_settings.json`)이 사용하는 전체 설정 조회 엔드포인트입니다. 각 탭에 해당하는 설정 그룹(general/security/mail/upload/seo/advanced/drivers/geoip/notifications/identity 등)과 드라이버 선택지 카탈로그(`available_drivers`)를 한 번에 반환합니다. 응답은 Eloquent 모델이 아니라 SettingsService 가 여러 설정 소스를 병합해 만든 집계 배열이며, 일부 그룹(cache/debug 등)은 원본 카테고리 값을 별도 키로 함께 노출한 파생 뷰입니다. + + +### POST /api/admin/settings + +- **라우트명**: `api.admin.settings.store` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\SettingsController@store` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| _tab | body | string | 아니오 | — | 활성 탭 식별자 (general/mail/upload/seo/security/drivers/advanced/notifications/identity). 지정 시 해당 탭 필드만 필수 검증되고 나머지 탭은 nullable 처리되어 탭 단위 부분 저장을 가능케 함 | +| general | body | array | 아니오 | — | 일반 탭 설정 묶음 (사이트명·URL·설명·관리자 이메일·타임존·기본 언어·통화·점검 모드·사이트 로고) | +| mail | body | array | 아니오 | — | 메일 탭 설정 묶음 (메일러 종류·SMTP 호스트/포트/인증·암호화·발신자 정보·Mailgun/SES 자격 정보) | +| upload | body | array | 아니오 | — | 업로드 탭 설정 묶음 (최대 파일 크기·허용 확장자·이미지 최대 크기 및 품질) | +| seo | body | array | 아니오 | — | SEO 탭 설정 묶음 (메타 태그·검색엔진 인증·봇 감지·OG/Twitter 기본값·SEO 캐시·사이트맵·생성기) | +| security | body | array | 아니오 | — | 보안 탭 설정 묶음 (HTTPS 강제·로그인 시도 제한·인증 토큰 유지시간·최대 시도 횟수·잠금 시간) | +| drivers | body | array | 아니오 | — | 드라이버 탭 설정 묶음 (스토리지/캐시/세션/큐/로그 드라이버 및 S3·Redis·Memcached·WebSocket·검색엔진 접속 정보) | +| advanced | body | array | 아니오 | — | 고급 탭 설정 묶음 (캐시·디버그·코어 업데이트·GeoIP 설정) | +| notifications | body | array | 아니오 | — | 알림 탭 설정 묶음. channels 배열로 각 알림 채널의 id·is_active(활성 여부)·sort_order(표시 순서)를 저장 | +| identity | body | array | 아니오 | — | 본인인증(IDV) 탭 설정 묶음 (기본 provider·목적별 provider 매핑·챌린지 유효시간·최대 시도 횟수) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.settings.save_validation_rules`, `core.search.engine_drivers`). + +**요청 예시** + +```http +POST /api/admin/settings HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "_tab": "예시값", + "general": [ + "예시값" + ], + "mail": [ + "예시값" + ], + "upload": [ + "예시값" + ], + "seo": [ + "예시값" + ], + "security": [ + "예시값" + ], + "drivers": [ + "예시값" + ], + "advanced": [ + "예시값" + ], + "notifications": [ + "예시값" + ], + "identity": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +통합 환경설정 화면에서 한 탭의 설정을 일괄 저장합니다. `_tab` 으로 활성 탭을 지정하면 해당 탭의 필드만 필수 검증되고 다른 탭 필드는 nullable 로 처리되므로, 탭 단위로 부분 저장할 수 있습니다. 저장 성공 시 응답 `data.settings` 에 갱신된 전체 설정과 `available_drivers` 를 함께 반환하여, 프론트엔드가 새로고침 없이 전역 상태를 갱신할 수 있습니다. 검증 실패 시 422, 그 외 오류 시 500 을 반환합니다. + + +### GET /api/admin/settings/app-key + +- **라우트명**: `api.admin.settings.app-key` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\SettingsController@getAppKey` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/settings/app-key HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| app_key | string | `base64:97gZH*************************…` | 현재 애플리케이션 키(`APP_KEY`)를 마스킹한 문자열. 앞부분 일부만 노출하고 나머지는 별표로 가려 전체 원문은 반환하지 않음 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "app_key": "{MASKED}" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 | + + + +**설명** + +현재 애플리케이션 키(`APP_KEY`)를 마스킹된 형태로 조회합니다. 관리자 화면에서 앱 키 존재/일부만 표시하는 용도이며, 전체 키 원문은 반환하지 않습니다. + + +### POST /api/admin/settings/backup + +- **라우트명**: `api.admin.settings.backup` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\SettingsController@backup` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.update` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/admin/settings/backup HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 | + + + +**설명** + +현재 설정을 백업 파일로 저장합니다. 응답 `data.backup_path` 에 생성된 백업 경로를 반환하며, 이 경로는 이후 `POST /restore` 의 `backup_path` 로 사용할 수 있습니다. 설정 변경 전 스냅샷을 남길 때 사용합니다. + + +### POST /api/admin/settings/backup-database + +- **라우트명**: `api.admin.settings.backup-database` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\SettingsController@backupDatabase` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.update` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/admin/settings/backup-database HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 | + + + +**설명** + +데이터베이스를 백업합니다. SettingsService 에 위임하며, 성공/실패를 메시지로 반환합니다. 설정 백업(`POST /backup`)이 설정 파일만 다루는 것과 달리, 이 엔드포인트는 DB 데이터를 백업 대상으로 합니다. + + +### POST /api/admin/settings/clear-cache + +- **라우트명**: `api.admin.settings.clear-cache` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\SettingsController@clearCache` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.update` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/admin/settings/clear-cache HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 | + + + +**설명** + +시스템 캐시를 정리합니다. 시스템 정보 캐시를 지원 로케일별로 비운 뒤 `cache:clear`, `route:clear`, `view:clear` 를 실행하고, config 캐시는 비운 직후 즉시 재생성합니다(비워 두면 이후 모든 요청이 config 를 재파싱하므로). 설정/코드 변경 후 오래된 캐시를 초기화할 때 사용합니다. + + +### POST /api/admin/settings/geoip/update + +- **라우트명**: `api.admin.settings.geoip.update` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\GeoIpController@update` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.update` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/admin/settings/geoip/update HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 | + + + +**설명** + +MaxMind GeoLite2-City DB 를 즉시 재다운로드합니다. GeoIpDatabaseService 에 위임하며 동기(즉시) 실행되므로 웹서버/PHP-FPM 타임아웃(90초 이상)이 필요합니다. 라이선스 키 미설정 시 400, 키가 잘못된 경우 401, 연결 실패/기타 오류 시 500 을 반환합니다. 정기 갱신은 스케줄(`geoip:update`)이 담당하고, 이 엔드포인트는 수동 갱신 트리거입니다. + + +### POST /api/admin/settings/optimize-system + +- **라우트명**: `api.admin.settings.optimize-system` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\SettingsController@optimizeSystem` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.update` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/admin/settings/optimize-system HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "시스템이 성공적으로 최적화되었습니다.", + "data": null +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 | + + + +**설명** + +시스템을 최적화합니다. `config:cache`, `route:cache`, `view:cache` 를 실행해 설정·라우트·뷰 캐시를 생성함으로써 이후 요청의 부팅 비용을 줄입니다. 캐시를 비우는 `clear-cache` 와 반대로, 캐시를 사전 생성하는 프로덕션 성능용 작업입니다. + + +### POST /api/admin/settings/regenerate-app-key + +- **라우트명**: `api.admin.settings.regenerate-app-key` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\SettingsController@regenerateAppKey` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| password | body | string | 예 | — | 비밀번호 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.settings.regenerate_app_key_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/settings/regenerate-app-key HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "password": "Password123!" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +애플리케이션 키(`APP_KEY`)를 재생성합니다. FormRequest 단계에서 `super_admin` 역할만 허용하고, Service 단계에서 요청자 본인의 비밀번호가 일치하는지 다시 확인합니다(불일치 시 401). 성공 시 새 키를 `.env` 의 `APP_KEY` 에 기록하고 config 캐시를 재생성하며, 응답 `data.app_key` 에 새 키를 반환합니다. 앱 키 변경은 기존 암호화 값/서명 무효화를 동반하므로 주의가 필요합니다. + + +### POST /api/admin/settings/restore + +- **라우트명**: `api.admin.settings.restore` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\SettingsController@restore` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| backup_path | body | string | 예 | — | 복원할 백업 파일 경로. `POST /api/admin/settings/backup` 응답의 `backup_path` 로 받은 값을 그대로 지정 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.settings.restore_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/settings/restore HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "backup_path": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +이전에 만든 설정 백업에서 설정을 복원합니다. `backup_path` 로 `POST /backup` 이 반환한 백업 경로를 지정합니다. 복원 성공 시 시스템 설정 캐시를 무효화합니다. 잘못된 설정을 되돌릴 때 사용합니다. + + +### GET /api/admin/settings/system-info + +- **라우트명**: `api.admin.settings.system-info` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\SettingsController@systemInfo` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/settings/system-info HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| os_info | string | `Windows NT 10.0` | 운영체제 종류와 버전 (`php_uname` 산물). probe 차단 시 "알 수 없음" 폴백 | +| web_server | string | `Apache/2.4.62 (Win64) OpenSSL/3.0.16 …` | 웹서버 소프트웨어 식별 문자열 (`$_SERVER['SERVER_SOFTWARE']`) | +| php_version | string | `8.3.26` | 실행 중인 PHP 버전 (`PHP_VERSION`) | +| mysql_version | string | `Mysql 8.4.3` | 연결된 데이터베이스 서버 종류와 버전 (DB 조회 산물). probe 실패 시 "알 수 없음" 폴백 | +| g7_version | string | `7.0.1` | G7 코어 버전 (`config('app.version')`) | +| g7_release_year | string | `2026` | G7 릴리즈 연도 (`config('app.release_year')`, 저작권 표기 등에 사용) | +| laravel_version | string | `12.54.1` | 프레임워크 Laravel 버전 (`app()->version()`) | +| environment | string | `local` | 현재 실행 환경 (`app()->environment()` — local/production/testing 등) | +| cpu_info | string | `Intel(R) Core(TM) Ultra 5 225H` | CPU 모델명 (OS별 시스템 probe 산물). 수집 실패 시 "알 수 없음" 폴백 | +| memory_usage | object | `{"total":"31.49 GB","used":"29.63 GB","free":"1.86 GB","p…` | 물리 메모리 사용량. total/used/free 는 사람이 읽기 쉬운 단위 문자열, percentage 는 사용률(%) | +| disk_usage | object | `{"total":"474.72 GB","used":"360.2 GB","free":"114.51 GB"…` | 설치 볼륨 디스크 사용량. total/used/free 단위 문자열 + percentage 사용률(%) | +| php_memory_limit | string | `512M` | PHP `memory_limit` ini 값 | +| max_execution_time | string | `36000초` | PHP `max_execution_time` ini 값 (초 단위 접미사 부착) | +| upload_max_filesize | string | `2G` | PHP `upload_max_filesize` ini 값 | +| install_path | string | `C:\Users\HeuJung\htdocs\g7_2` | 애플리케이션 설치 루트 경로 (`base_path()`) | +| config_path | string | `C:\Users\HeuJung\htdocs\g7_2\storage\…` | 설정 파일 저장 경로 (`storage/app/settings`) | +| log_path | string | `C:\Users\HeuJung\htdocs\g7_2\storage\…` | 로그 파일 저장 경로 (`storage/logs`) | +| upload_path | string | `C:\Users\HeuJung\htdocs\g7_2\storage\…` | 공개 업로드 파일 저장 경로 (`storage/app/public`) | +| php_extensions | object | `{"required":{"openssl":true,"pdo":true,"mbstring":true,"t…` | PHP 확장 로드 상태. required(필수)·optional(선택) 두 그룹으로 나뉘며 각 확장명→로드 여부(bool) 매핑 | +| database_config | object | `{"has_read_write_split":false,"write":{"host":"localhost"…` | DB 연결 구성 요약. has_read_write_split(읽기/쓰기 분리 여부)·write(쓰기 연결 정보)·read(읽기 replica 목록, write 와 동일하면 제외) | +| timezone | string | `UTC` | 애플리케이션 기본 타임존 (`config('app.timezone')`) | +| server_time | string | `2026-07-07 05:08:58` | 서버 현재 시각 (Y-m-d H:i:s) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "os_info": "Windows NT 10.0", + "web_server": "Apache/2.4.62 (Win64) OpenSSL/3.0.16 PHP/8.3.26", + "php_version": "8.3.26", + "mysql_version": "Mysql 8.4.3", + "g7_version": "7.0.1", + "g7_release_year": "2026", + "laravel_version": "12.54.1", + "environment": "local", + "cpu_info": "Intel(R) Core(TM) Ultra 5 225H", + "memory_usage": { + "total": "31.49 GB", + "used": "26.92 GB", + "free": "4.57 GB", + "percentage": 85.49 + }, + "disk_usage": { + "total": "474.72 GB", + "used": "362.59 GB", + "free": "112.12 GB", + "percentage": 76.38 + }, + "php_memory_limit": "512M", + "max_execution_time": "36000초", + "upload_max_filesize": "2G", + "install_path": "C:\\Users\\HeuJung\\htdocs\\g7_2", + "config_path": "C:\\Users\\HeuJung\\htdocs\\g7_2\\storage\\app/settings", + "log_path": "C:\\Users\\HeuJung\\htdocs\\g7_2\\storage\\logs", + "upload_path": "C:\\Users\\HeuJung\\htdocs\\g7_2\\storage\\app/public", + "php_extensions": { + "required": { + "openssl": true, + "pdo": true, + "mbstring": true, + "tokenizer": "{MASKED}", + "xml": true, + "curl": true, + "json": true, + "zip": true, + "fileinfo": true, + "bcmath": true + }, + "optional": { + "gd": true, + "imagick": false, + "redis": true, + "memcached": false, + "sodium": true, + "exif": true, + "intl": true, + "ldap": false, + "zlib": true + } + }, + "database_config": { + "has_read_write_split": false, + "write": { + "host": "localhost", + "port": 3306, + "database": "g7_2", + "username": "g7_2" + }, + "read": [] + }, + "timezone": "UTC", + "server_time": "2026-07-08 03:14:54" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 | + + + +**설명** + +서버 실행 환경 정보를 한 번에 조회합니다. OS/웹서버/PHP/DB/Laravel/코어 버전, CPU·메모리·디스크 사용량, PHP 주요 설정값(memory_limit·max_execution_time·upload_max_filesize), 주요 경로, PHP 확장 로드 상태, DB 연결 구성 요약 등을 포함합니다. 관리자 시스템 정보 화면과 요구사항 점검용 진단 데이터로 사용됩니다. + + +### POST /api/admin/settings/test-driver + +- **라우트명**: `api.admin.settings.test-driver` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\SettingsController@testDriverConnection` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| storage_driver | body | string | 아니오 | — | 스토리지 드라이버 (local/s3) | +| cache_driver | body | string | 아니오 | — | 캐시 드라이버 (file/redis/memcached) | +| session_driver | body | string | 아니오 | — | 세션 드라이버 (file/database/redis) | +| queue_driver | body | string | 아니오 | — | 큐 드라이버 (sync/database/redis) | +| websocket_enabled | body | boolean | 아니오 | — | WebSocket 사용 여부 | +| s3_bucket | body | string | 아니오 | max 255 | S3 버킷명 | +| s3_region | body | string | 아니오 | — | S3 리전 | +| s3_access_key | body | string | 아니오 | max 255 | S3 액세스 키 | +| s3_secret_key | body | string | 아니오 | max 255 | S3 시크릿 키 | +| s3_url | body | string | 아니오 | max 500 | S3 엔드포인트 URL | +| redis_host | body | string | 아니오 | max 255 | Redis 호스트 주소 | +| redis_port | body | integer | 아니오 | min 1, max 65535 | Redis 포트 번호 | +| redis_password | body | string | 아니오 | max 255 | Redis 비밀번호 | +| redis_database | body | integer | 아니오 | min 0, max 15 | Redis 데이터베이스 번호 | +| memcached_host | body | string | 아니오 | max 255 | Memcached 호스트 주소 | +| memcached_port | body | integer | 아니오 | min 1, max 65535 | Memcached 포트 번호 | +| websocket_app_key | body | string | 아니오 | max 255 | WebSocket 앱 키 | +| websocket_host | body | string | 아니오 | max 255 | WebSocket 호스트 주소 | +| websocket_port | body | integer | 아니오 | min 1, max 65535 | WebSocket 포트 번호 | +| websocket_scheme | body | string | 아니오 | — | WebSocket 스킴 (http/https) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.settings.test_driver_connection_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/settings/test-driver HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "storage_driver": "예시값", + "cache_driver": "예시값", + "session_driver": "예시값", + "queue_driver": "예시값", + "websocket_enabled": true, + "s3_bucket": "예시값", + "s3_region": "예시값", + "s3_access_key": "예시값", + "s3_secret_key": "예시값", + "s3_url": "https://example.com", + "redis_host": "예시값", + "redis_port": 1, + "redis_password": "Password123!", + "redis_database": 1, + "memcached_host": "예시값", + "memcached_port": 1, + "websocket_app_key": "예시값", + "websocket_host": "예시값", + "websocket_port": 1, + "websocket_scheme": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +폼에 입력한 드라이버 접속 정보(S3·Redis·Memcached·Websocket 등)로 실제 연결을 시도해 결과를 반환합니다. 설정을 저장하기 전에 접속 정보가 유효한지 확인하는 용도입니다. 모든 테스트 통과 시 성공 메시지, 일부 실패 시에도 HTTP 성공 응답으로 항목별 결과(`all_passed=false` 포함)를 함께 반환합니다. + + +### POST /api/admin/settings/test-mail + +- **라우트명**: `api.admin.settings.test-mail` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\SettingsController@testMail` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| to_email | body | email | 예 | max 255 | 테스트 수신 주소 | +| mailer | body | string | 아니오 | `smtp`, `mailgun`, `ses` | 메일 발송 드라이버 (smtp/mailgun/ses) | +| from_address | body | email | 예 | max 255 | 발신자 주소 | +| from_name | body | string | 예 | max 255 | 발신자 이름 | +| host | body | string | 예 | max 255 | 호스트 주소 | +| port | body | integer | 예 | min 1, max 65535 | 포트 번호 | +| username | body | string | 아니오 | max 255 | 사용자명 (로그인/인증 아이디) | +| password | body | string | 아니오 | max 255 | 비밀번호 | +| encryption | body | string | 아니오 | `tls`, `ssl`, `null` | 전송 암호화 방식 (tls/ssl) | +| mailgun_domain | body | string | 아니오 | max 255 | Mailgun 도메인 | +| mailgun_secret | body | string | 아니오 | max 255 | Mailgun 시크릿 키 | +| mailgun_endpoint | body | string | 아니오 | max 255 | Mailgun 엔드포인트 | +| ses_key | body | string | 아니오 | max 255 | SES 액세스 키 | +| ses_secret | body | string | 아니오 | max 255 | SES 시크릿 키 | +| ses_region | body | string | 아니오 | max 255 | SES 리전 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.settings.test_mail_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/settings/test-mail HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "to_email": "user@example.com", + "mailer": "smtp", + "from_address": "user@example.com", + "from_name": "예시 이름", + "host": "예시값", + "port": 1, + "username": "예시 이름", + "password": "Password123!", + "encryption": "tls", + "mailgun_domain": "예시값", + "mailgun_secret": "예시값", + "mailgun_endpoint": "예시값", + "ses_key": "예시값", + "ses_secret": "예시값", + "ses_region": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +폼에 입력한 메일 설정으로 지정한 주소에 테스트 메일을 발송합니다. 요청에서 전달한 값(호스트·포트·인증 정보 등)을 저장된 메일 설정 위에 임시로 덮어써 그 값으로만 발송을 시도하므로, 설정을 저장하기 전에 실제 발송 가능 여부를 검증할 수 있습니다. 성공 시 발송한 제목/본문을 응답에 포함하고, 실패 시 오류 사유와 함께 500 을 반환합니다. + + +### GET /api/admin/settings/{key} + +- **라우트명**: `api.admin.settings.show` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\SettingsController@show` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| key | path | string | 예 | — | 대상 설정/항목의 키 | + +**요청 예시** + +```http +GET /api/admin/settings/{key} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + +단일 설정 키의 값을 조회합니다. 응답의 `data.key` 는 요청한 키, `data.value` 는 해당 설정 값입니다. 통합 조회(`GET /api/admin/settings`)와 달리 특정 키 하나만 필요할 때 사용합니다. + + +### PUT /api/admin/settings/{key} + +- **라우트명**: `api.admin.settings.update` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\SettingsController@update` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| key | path | string | 예 | — | 대상 설정/항목의 키 | +| value | body | string | 예 | max 1000 | 값 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.settings.update_validation_rules`). + +**요청 예시** + +```http +PUT /api/admin/settings/{key} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "value": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + +단일 설정 키의 값을 업데이트합니다. 경로의 `key` 로 대상 설정을, 본문의 `value` 로 새 값을 지정합니다. 탭 단위 일괄 저장(`POST /api/admin/settings`)과 달리 개별 키 하나만 변경할 때 사용합니다. 검증 실패 시 422, 그 외 오류 시 500 을 반환합니다. + + diff --git a/docs/backend/api/templates.md b/docs/backend/api/templates.md new file mode 100644 index 00000000..669e3407 --- /dev/null +++ b/docs/backend/api/templates.md @@ -0,0 +1,2803 @@ +# Templates API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Templates 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/admin/templates + +- **라우트명**: `api.admin.templates.index` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\TemplateController@index` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| type | query | string | 아니오 | `user`, `admin` | 유형 필터 (해당 유형의 항목만 조회) | +| search | query | string | 아니오 | max 255 | 검색어 (지정한 검색 대상 필드에서 부분 일치) | +| filters | query | array | 아니오 | max 10 | 추가 필터 조건 맵 (필드별 조건) | +| status | query | string | 아니오 | `installed`, `not_installed`, `active`, `inactive` | 상태 필터 (해당 상태의 항목만 조회) | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | +| include_hidden | query | boolean | 아니오 | — | manifest `hidden=true`로 숨김 처리된 확장까지 목록에 포함할지 여부 (기본 미포함) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.template.index_validation_rules`). + +**요청 예시** + +```http +GET /api/admin/templates?type=user&search=%EC%98%88%EC%8B%9C%EA%B0%92&filters=%EC%98%88%EC%8B%9C%EA%B0%92&status=installed&per_page=1&page=1&include_hidden=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| identifier | string | `sirsoft-admin_basic` | 템플릿 고유 식별자 (vendor-name 형식, 예: sirsoft-admin_basic) | +| vendor | string | `sirsoft` | 벤더/개발자명 (예: sirsoft) | +| name | string | `Admin Basic` | 템플릿 이름 (다국어 JSON) | +| version | string | `1.0.0` | 템플릿 버전 (예: 1.0.0) | +| type | string | `admin` | 템플릿 타입 (admin: 관리자용, user: 사용자용) | +| status | string | `active` | 상태 (active: 활성화, inactive: 비활성화, installing: 설치 중, uninstalling: 제거 중, updating: 업데이트 중) | +| description | string | `그누보드7 기본 관리자 템플릿` | 템플릿 설명 (다국어 JSON) | +| dependencies | object | `{"modules":[],"plugins":[]}` | 의존하는 확장 맵 (manifest 파생 — {modules, plugins}) | +| dependencies_met | boolean | `true` | 의존하는 모듈/플러그인이 모두 설치·활성 상태로 충족되었는지 여부 (activate 선행 검사 파생) | +| update_available | boolean | `false` | 최신 버전 대비 업데이트 가능 여부 | +| update_source | null | `null` | 업데이트 감지 출처 (github, bundled 등) | +| latest_version | string | `1.0.0` | 감지된 최신 배포 버전 | +| file_version | string | `1.0.0` | 설치된 파일의 manifest 버전 | +| github_url | string | `https://github.com/gnuboard/g7-templa…` | GitHub 저장소 URL | +| github_changelog_url | string | `https://github.com/gnuboard/g7-templa…` | GitHub 변경 내역 URL | +| is_pending | boolean | `false` | _pending 대기소에 있어 설치 대기 중인지 여부 | +| is_bundled | boolean | `false` | 코어에 선탑재된 번들 확장인지 여부 | +| deactivated_reason | null | `null` | 비활성화 사유: manual(사용자 수동) \| incompatible_core(코어 버전 호환성) \| null(active) | +| deactivated_at | null | `null` | deactivated 일시 | +| incompatible_required_version | null | `null` | 요구 코어 버전 미충족 시 필요한 버전 (호환되면 null) | +| abilities | object | `{"can_install":true,"can_activate":true,"can_uninstall":t…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "템플릿 정보를 성공적으로 가져왔습니다.", + "data": { + "data": [ + { + "identifier": "sirsoft-admin_basic", + "vendor": "sirsoft", + "name": "Admin Basic", + "version": "1.0.0", + "type": "admin", + "status": "uninstalled", + "description": "그누보드7 기본 관리자 템플릿", + "dependencies": { + "modules": [], + "plugins": [] + }, + "dependencies_met": true, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": null, + "github_url": null, + "github_changelog_url": null, + "is_pending": false, + "is_bundled": false, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true, + "can_edit_layouts": true + } + }, + { + "identifier": "sirsoft-basic", + "vendor": "sirsoft", + "name": "Basic", + "version": "1.0.0", + "type": "user", + "status": "uninstalled", + "description": "그누보드7 기본 사용자 템플릿", + "dependencies": { + "modules": [ + { + "identifier": "sirsoft-board", + "name": "게시판", + "type": "module" + }, + { + "identifier": "sirsoft-ecommerce", + "name": "이커머스", + "type": "module" + }, + { + "identifier": "sirsoft-page", + "name": "페이지", + "type": "module" + } + ], + "plugins": [ + { + "identifier": "sirsoft-daum_postcode", + "name": "sirsoft-daum_postcode", + "type": "plugin" + } + ] + }, + "dependencies_met": false, + "update_available": false, + "update_source": null, + "latest_version": null, + "file_version": null, + "github_url": null, + "github_changelog_url": null, + "is_pending": false, + "is_bundled": false, + "deactivated_reason": null, + "deactivated_at": null, + "incompatible_required_version": null, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true, + "can_edit_layouts": true + } + } + ], + "pagination": { + "total": 2, + "current_page": 1, + "last_page": 1, + "per_page": 25 + }, + "meta": { + "total_templates": 2, + "active_templates": 0, + "admin_templates": 1, + "user_templates": 1, + "installed_templates": 2 + }, + "abilities": { + "can_install": true, + "can_activate": true, + "can_uninstall": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 설치/미설치 템플릿을 모두 포함해 목록을 조회합니다(TemplateService::getPaginatedTemplates). `search`(이름·식별자·설명·벤더 OR 검색), `filters`(AND 조건), `status`, `type`, `include_hidden` 필터와 페이지네이션을 지원하며, 응답에는 현재 사용자의 install/activate/uninstall 수행 가능 여부(`abilities`)가 함께 담깁니다. `core.templates.read` 권한이 필요합니다. + + +### POST /api/admin/templates/activate + +- **라우트명**: `api.admin.templates.activate` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\TemplateController@activate` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.activate` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| template_name | body | string | 예 | max 255 | template 이름 (식별자) | +| force | body | boolean | 아니오 | — | 강제 실행 여부 (안전 확인/선행 검사 우회) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.template.activate_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/templates/activate HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "template_name": "예시 이름", + "force": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.activate`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 설치된 템플릿을 활성화합니다. 필요한 의존 모듈/플러그인이 충족되지 않으면 409(warning)로 누락 목록을 반환하며, `force=true`로 강제 활성화할 수 있습니다. 활성화 성공 시 재활성화로 되살아나는 번들 언어팩 목록(`pending_language_packs`)을 함께 반환합니다. `core.templates.activate` 권한이 필요합니다. + + +### POST /api/admin/templates/check-updates + +- **라우트명**: `api.admin.templates.check-updates` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\TemplateController@checkUpdates` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.install` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/admin/templates/check-updates HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.install`)이 없는 경우 | + + + +**설명** 설치된 모든 템플릿의 배포 최신 버전을 조회해 업데이트 가능 여부를 확인합니다(TemplateService::checkForUpdates). 별도 요청 파라미터는 없으며, 목록 화면의 업데이트 배지 갱신에 사용됩니다. `core.templates.install` 권한이 필요합니다. + + +### POST /api/admin/templates/deactivate + +- **라우트명**: `api.admin.templates.deactivate` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\TemplateController@deactivate` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.activate` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| template_name | body | string | 예 | max 255 | template 이름 (식별자) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.template.deactivate_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/templates/deactivate HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "template_name": "예시 이름" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.activate`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 활성 상태의 템플릿을 비활성화합니다(TemplateService::deactivateTemplate). `core.templates.activate` 권한이 필요합니다. + + +### POST /api/admin/templates/install + +- **라우트명**: `api.admin.templates.install` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\TemplateController@install` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.install` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| template_name | body | string | 예 | max 255 | template 이름 (식별자) | +| dependencies | body | array | 아니오 | — | 함께 설치할 의존 확장 목록 (install-preview 응답 기반 사용자 선택분, 각 원소 type: module\|plugin, identifier) | +| language_packs | body | array | 아니오 | — | 함께 설치할 동반 번들 언어팩 식별자 배열 (설치 성공 후 best-effort 설치) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.template.install_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/templates/install HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "template_name": "예시 이름", + "dependencies": [ + "예시값" + ], + "language_packs": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.install`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** `_bundled`/`_pending` 대기소의 템플릿을 활성 디렉토리로 설치합니다. 요청 시 함께 선택한 의존 확장을 먼저 설치(cascade 1단계)하고, 실패 시 중단합니다. 설치 성공 후 동반 번들 언어팩을 best-effort로 설치하며 실패 목록을 `language_pack_failures`로 반환합니다(cascade 2단계). `core.templates.install` 권한이 필요합니다. + + +### POST /api/admin/templates/install-from-file + +- **라우트명**: `api.admin.templates.install-from-file` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\TemplateController@installFromFile` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.install` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| file | body | file | 예 | max 51200 | 업로드 파일 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.template.install_from_file_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/templates/install-from-file HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: multipart/form-data; boundary=----G7ExampleBoundary + +------G7ExampleBoundary +Content-Disposition: form-data; name="file"; filename="example.pdf" +Content-Type: application/octet-stream + +(바이너리 파일 내용) +------G7ExampleBoundary-- +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.install`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 업로드된 ZIP 파일에서 템플릿을 설치합니다(TemplateService::installFromZipFile). 최대 50MB(51200KB)까지 허용합니다. `core.templates.install` 권한이 필요합니다. + + +### POST /api/admin/templates/install-from-github + +- **라우트명**: `api.admin.templates.install-from-github` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\TemplateController@installFromGithub` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.install` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| github_url | body | string | 예 | — | GitHub 저장소 URL | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.template.install_from_github_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/templates/install-from-github HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "github_url": "https://example.com" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.install`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 지정한 GitHub 저장소 URL에서 템플릿을 내려받아 설치합니다(TemplateService::installFromGithub). `core.templates.install` 권한이 필요합니다. + + +### DELETE /api/admin/templates/layout-attachments/{attachment} + +- **라우트명**: `api.admin.templates.layout-attachments.destroy` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\AdminTemplateLayoutAttachmentController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.layouts.edit` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| attachment | path | string | 예 | — | 대상 attachment의 식별자 | + +**요청 예시** + +```http +DELETE /api/admin/templates/layout-attachments/{attachment} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 레이아웃 편집 중 업로드된 첨부(배경 이미지 등)를 삭제합니다. 스토리지의 실제 파일과 DB 행을 함께 삭제합니다(TemplateLayoutAttachmentService::delete). `core.templates.layouts.edit` 권한이 필요합니다. + + +### POST /api/admin/templates/manifest-preview + +- **라우트명**: `api.admin.templates.manifest-preview` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\TemplateController@manifestPreview` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.install` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| file | body | file | 예 | max 51200 | 업로드 파일 | + +**요청 예시** + +```http +POST /api/admin/templates/manifest-preview HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: multipart/form-data; boundary=----G7ExampleBoundary + +------G7ExampleBoundary +Content-Disposition: form-data; name="file"; filename="example.pdf" +Content-Type: application/octet-stream + +(바이너리 파일 내용) +------G7ExampleBoundary-- +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.install`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 업로드된 ZIP을 실제 설치하지 않고 manifest와 검증 결과만 추출해 반환합니다(TemplateService::previewManifest). 설치 전 확인 다이얼로그에서 사용합니다. `core.templates.install` 권한이 필요합니다. + + +### POST /api/admin/templates/refresh-layouts + +- **라우트명**: `api.admin.templates.refresh-layouts` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\TemplateController@refreshLayouts` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.activate` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| template_name | body | string | 예 | max 255 | template 이름 (식별자) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.template.refresh_layouts_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/templates/refresh-layouts HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "template_name": "예시 이름" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.activate`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 템플릿의 레이아웃을 파일에서 다시 읽어 DB에 갱신합니다(TemplateService::refreshTemplateLayouts). 파일로 직접 수정한 레이아웃을 반영할 때 사용합니다. `core.templates.activate` 권한이 필요합니다. + + +### DELETE /api/admin/templates/uninstall + +- **라우트명**: `api.admin.templates.uninstall` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\TemplateController@uninstall` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.uninstall` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| template_name | query | string | 예 | max 255 | template 이름 (식별자) | +| delete_data | query | boolean | 아니오 | — | 제거 시 템플릿 관련 데이터(레이아웃/설정 등)까지 함께 삭제할지 여부 (기본 false = 파일만 제거) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.template.uninstall_validation_rules`). + +**요청 예시** + +```http +DELETE /api/admin/templates/uninstall?template_name=%EC%98%88%EC%8B%9C%20%EC%9D%B4%EB%A6%84&delete_data=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.uninstall`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 활성 디렉토리의 템플릿을 제거합니다(`_bundled` 원본은 보존). `delete_data=true`인 경우 관련 데이터까지 함께 삭제합니다(TemplateService::uninstallTemplate). `core.templates.uninstall` 권한이 필요합니다. + + +### GET /api/admin/templates/{identifier}/changelog + +- **라우트명**: `api.admin.templates.changelog` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\TemplateController@changelog` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | +| source | query | string | 아니오 | `active`, `bundled`, `github` | CHANGELOG 조회 출처 (active: 설치본, bundled: 코어 번들 원본, github: 원격 저장소) | +| from_version | query | string | 아니오 | — | 시작 버전 (범위 하한) | +| to_version | query | string | 아니오 | — | 대상 버전 (범위 상한) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_rules`). + +**요청 예시** + +```http +GET /api/admin/templates/{identifier}/changelog?source=active&from_version=%EC%98%88%EC%8B%9C%EA%B0%92&to_version=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 특정 템플릿의 변경 내역(CHANGELOG)을 조회합니다. `source`(active/bundled/github)로 출처를, `from_version`/`to_version`으로 버전 범위를 지정할 수 있습니다(TemplateService::getTemplateChangelog). `core.templates.read` 권한이 필요합니다. + + +### GET /api/admin/templates/{identifier}/editor-assets + +- **라우트명**: `api.admin.templates.editor-assets` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\AdminTemplateAssetController@getEditorAssets` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.layouts.edit` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/admin/templates/{identifier}/editor-assets HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 레이아웃 편집기 부팅용 자산 매니페스트(컴포넌트 IIFE JS / CSS URL)를 반환합니다. 비활성 템플릿도 편집할 수 있도록 활성 디렉토리 → `_bundled` 폴백으로 빌드 산출물을 탐색하며, 빌드가 없으면 빈 목록으로 폴백합니다. `core.templates.layouts.edit` 권한이 필요합니다. + + +### GET /api/admin/templates/{identifier}/editor/broadcast-catalog.json + +- **라우트명**: `api.admin.templates.editor-broadcast-catalog` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\BroadcastCatalogController@index` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.layouts.edit` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/admin/templates/{identifier}/editor/broadcast-catalog.json HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 데이터소스 websocket 후보용으로 등록된 브로드캐스트 채널/이벤트 카탈로그를 반환합니다(BroadcastCatalogService::collect). 편집기 전용 가드 하에서만 노출되며(admin 전역 broadcast 회피), `identifier`는 라우트 일관성용이고 카탈로그는 설치본 전역 기준입니다. `core.templates.layouts.edit` 권한이 필요합니다. + + +### GET /api/admin/templates/{identifier}/editor/components.css + +- **라우트명**: `api.admin.templates.editor-css` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\AdminTemplateAssetController@serveEditorCss` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.layouts.edit` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/admin/templates/{identifier}/editor/components.css HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 레이아웃 편집기 프리뷰 전용 CSS를 서빙합니다. 편집기 진입 시에만 components.css의 다크 조상 셀렉터를 프리뷰 마커로 치환하고(editor-spec `darkMode.previewIsolation` 규칙), 필요 시 `@layer` 래퍼를 평탄화해 라이트/다크 프리뷰를 격리합니다. 변환 결과는 캐시 버전+파일 mtime 키로 캐시하며, CSS 부재 시 빈 응답으로 폴백합니다. `core.templates.layouts.edit` 권한이 필요합니다. + + +### GET /api/admin/templates/{identifier}/editor/components.json + +- **라우트명**: `api.admin.templates.editor-components` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\AdminTemplateAssetController@serveComponents` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.layouts.edit` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/admin/templates/{identifier}/editor/components.json HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 편집기용 components.json(컴포넌트 정의)을 서빙합니다. 비활성 템플릿도 편집 가능하도록 활성 디렉토리 → `_bundled` 폴백으로 파일을 읽어 반환합니다. `core.templates.layouts.edit` 권한이 필요합니다. + + +### GET /api/admin/templates/{identifier}/editor/editor-spec.json + +- **라우트명**: `api.admin.templates.editor-spec` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\AdminTemplateAssetController@serveEditorSpec` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.layouts.edit` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/admin/templates/{identifier}/editor/editor-spec.json HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 편집기 스펙(editor-spec.json)을 서빙합니다. 분할 스펙은 manifest + `$include` 블록을 합본한 단일 spec으로 반환하며(활성 디렉토리 기준), sampleData/sampleGlobal/states 등 전 블록이 포함됩니다. 파일 미존재 시 `spec=null`로 폴백합니다. `core.templates.layouts.edit` 권한이 필요합니다. + + +### GET /api/admin/templates/{identifier}/editor/lang/{locale}.json + +- **라우트명**: `api.admin.templates.editor-lang` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\AdminTemplateAssetController@serveLanguage` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.layouts.edit` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | +| locale | path | string | 예 | — | 로케일 코드 (표시 언어/지역) | + +**요청 예시** + +```http +GET /api/admin/templates/{identifier}/editor/lang/{locale}.json HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 편집기용 다국어 데이터(lang/{locale}.json)를 서빙합니다. 활성 상태 검증 없이 활성 디렉토리 → `_bundled` 폴백으로 읽으며, 파일 부재 시 빈 객체로 폴백합니다. `core.templates.layouts.edit` 권한이 필요합니다. + + +### GET /api/admin/templates/{identifier}/editor/permission-candidates.json + +- **라우트명**: `api.admin.templates.editor-permission-candidates` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\AdminTemplateAssetController@servePermissionCandidates` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.layouts.edit` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/admin/templates/{identifier}/editor/permission-candidates.json HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 레이아웃 편집기의 표시 권한 지정용으로 코어+활성 확장의 전체 권한을 `{key, name}` 목록으로 반환합니다(PermissionService::getPermissionCandidates). 편집기 진입 가드 하에서만 노출되어, 권한 카탈로그가 모든 admin 페이지에 상시 노출되던 방식보다 범위가 편집기로 한정됩니다. `core.templates.layouts.edit` 권한이 필요합니다. + + +### GET /api/admin/templates/{identifier}/editor/routes.json + +- **라우트명**: `api.admin.templates.editor-routes` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\AdminTemplateAssetController@serveRoutes` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.layouts.edit` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/admin/templates/{identifier}/editor/routes.json HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 편집기 라우트 트리용 routes.json을 서빙합니다. 각 라우트에 `source`(kind/identifier) 태깅과 모듈/플러그인 라우트 병합을 적용해(TemplateService::getEditorRoutesDataWithModules) 클라이언트가 출처별로 그룹핑할 수 있게 하며, 활성/비활성 무관 + `_bundled` 폴백으로 동작합니다. `core.templates.layouts.edit` 권한이 필요합니다. + + +### POST /api/admin/templates/{identifier}/editor/seo-bot-preview + +- **라우트명**: `api.admin.templates.editor-seo-bot-preview` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\SeoBotPreviewController@show` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.layouts.edit` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | +| layout | body | array | 예 | — | 편집 중인(dirty) 레이아웃 JSON 전체 (봇 HTML 렌더 대상) | +| route_params | body | array | 아니오 | — | 렌더 시 주입할 라우트 path 파라미터 맵 (예: 게시글 id 등 URL 동적 세그먼트) | +| url | body | string | 아니오 | — | 미리보기 기준 URL | +| locale | body | string | 아니오 | — | 로케일 코드 (표시 언어/지역) | +| module_id | body | string | 아니오 | — | module 식별자 | +| plugin_id | body | string | 아니오 | — | plugin 식별자 | +| seed_context | body | array | 아니오 | — | 렌더 컨텍스트 시드 데이터 (편집기 샘플 데이터 — 데이터소스/전역/로컬 값 대체) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.seo_bot_preview.show_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/templates/{identifier}/editor/seo-bot-preview HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "layout": [ + "예시값" + ], + "route_params": [ + "예시값" + ], + "url": "https://example.com", + "locale": "ko", + "module_id": "예시값", + "plugin_id": "예시값", + "seed_context": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 편집기 [검색엔진] 탭의 봇 HTML 실시간 미리보기를 반환합니다. dirty 레이아웃 + 편집기 샘플 데이터로 운영과 동일한 렌더 경로를 거쳐(SEO 캐시 우회) 완성 HTML을 만들며, `meta.seo.enabled=false`이거나 미렌더 시 `enabled=false`로 미노출을 안내합니다. `core.templates.layouts.edit` 권한이 필요합니다. + + +### GET /api/admin/templates/{identifier}/editor/seo-candidates.json + +- **라우트명**: `api.admin.templates.editor-seo-candidates` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\SeoCandidateController@index` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.layouts.edit` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | +| extensions | query | string | 아니오 | — | 편집 중 레이아웃 문맥의 확장 목록 (JSON `[{type, id}]` 문자열 또는 배열 — SEO 후보 범위 결정용) | +| page_type | query | string | 아니오 | — | 편집 중 레이아웃의 페이지 유형 (page_type별 SEO 후보/토글 설정 필터) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.seo_candidate.index_validation_rules`). + +**요청 예시** + +```http +GET /api/admin/templates/{identifier}/editor/seo-candidates.json?extensions=%EC%98%88%EC%8B%9C%EA%B0%92&page_type=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 편집기 [검색엔진] 탭의 SEO 후보(page_type/toggle_setting/유효 vars)를 한 번에 공급합니다(SeoCandidateService::collect). `extensions`(JSON `[{type,id}]`)와 `page_type` query로 편집 중 레이아웃 문맥을 전달하며, 후보 미존재 시 빈 목록 → 자유 텍스트 폴백입니다. `core.templates.layouts.edit` 권한이 필요합니다. + + +### POST /api/admin/templates/{identifier}/editor/seo-og-preview + +- **라우트명**: `api.admin.templates.editor-seo-og-preview` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\SeoOgPreviewController@show` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.layouts.edit` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | +| seo | body | array | 예 | — | 편집 중인(dirty) meta.seo 설정 전체 (base 병합본 — og/twitter cascade 계산 대상) | +| own_seo | body | array | 아니오 | — | 이 레이아웃이 직접 선언한 meta.seo (base 병합 전) — 병합본에만 있는 키를 base 상속으로 판정하는 근거 | +| seed_context | body | array | 아니오 | — | 렌더 컨텍스트 시드 데이터 (편집기 샘플 데이터 — 미리보기 값 계산용) | +| route_params | body | array | 아니오 | — | 렌더 시 주입할 라우트 path 파라미터 맵 (URL 동적 세그먼트) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.seo_og_preview.show_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/templates/{identifier}/editor/seo-og-preview HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "seo": [ + "예시값" + ], + "own_seo": [ + "예시값" + ], + "seed_context": [ + "예시값" + ], + "route_params": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 편집기 [검색엔진] 탭의 OG/Twitter/구조화 데이터 미리보기를 반환합니다. dirty `meta.seo` + 샘플로 og/twitter cascade를 실제 계산하고 필터 전/후 diff로 각 키의 출처·잠김을 산출합니다(SeoOgPreviewService::preview). `core.templates.layouts.edit` 권한이 필요합니다. + + +### GET /api/admin/templates/{identifier}/layout-attachments + +- **라우트명**: `api.admin.templates.layout-attachments.index` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\AdminTemplateLayoutAttachmentController@index` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.layouts.edit` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | +| layout_name | query | string | 아니오 | max 150 | layout 이름 (식별자) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.template_layout_attachment.list_validation_rules`). + +**요청 예시** + +```http +GET /api/admin/templates/{identifier}/layout-attachments?layout_name=%EC%98%88%EC%8B%9C%20%EC%9D%B4%EB%A6%84 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 해당 템플릿의 레이아웃 첨부(배경 이미지 등) 목록을 조회합니다(ImagePickerControl의 이미지 재선택용). `layout_name` query로 특정 레이아웃의 첨부만 필터할 수 있습니다(TemplateLayoutAttachmentService::list). `core.templates.layouts.edit` 권한이 필요합니다. + + +### POST /api/admin/templates/{identifier}/layout-attachments + +- **라우트명**: `api.admin.templates.layout-attachments.store` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\AdminTemplateLayoutAttachmentController@store` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.layouts.edit` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | +| file | body | file | 예 | max 10240 | 업로드 파일 | +| layout_name | body | string | 아니오 | max 150 | layout 이름 (식별자) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.template_layout_attachment.upload_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/templates/{identifier}/layout-attachments HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: multipart/form-data; boundary=----G7ExampleBoundary + +------G7ExampleBoundary +Content-Disposition: form-data; name="file"; filename="example.pdf" +Content-Type: application/octet-stream + +(바이너리 파일 내용) +------G7ExampleBoundary +Content-Disposition: form-data; name="layout_name" + +예시 이름 +------G7ExampleBoundary-- +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 레이아웃 편집 중 첨부 파일을 업로드합니다. 스토리지 저장 후 DB 행을 생성하고 접근 URL을 반환합니다(TemplateLayoutAttachmentService::upload). 최대 10MB(10240KB)까지 허용합니다. `core.templates.layouts.edit` 권한이 필요합니다. + + +### GET /api/admin/templates/{identifier}/license + +- **라우트명**: `api.admin.templates.license` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\TemplateController@license` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/admin/templates/{identifier}/license HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 템플릿의 라이선스 파일(LICENSE) 내용을 반환합니다(LicenseService::getExtensionLicense). 식별자는 소문자/숫자/`_`/`-` 형식만 허용하며, 파일이 없으면 404를 반환합니다. `core.templates.read` 권한이 필요합니다. + + +### GET /api/admin/templates/{templateName} + +- **라우트명**: `api.admin.templates.show` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\TemplateController@show` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateName | path | string | 예 | — | 대상 template의 이름 (식별자) | + +**요청 예시** + +```http +GET /api/admin/templates/{templateName} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 특정 템플릿의 상세 정보를 조회합니다(TemplateService::getTemplateInfo). 목록보다 상세한 필드(toDetailArray)와 함께 지원 언어팩 정보를 주입해 반환하며, 템플릿이 없으면 404를 반환합니다. `core.templates.read` 권한이 필요합니다. + + +### GET /api/admin/templates/{templateName}/check-modified-layouts + +- **라우트명**: `api.admin.templates.check-modified-layouts` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\TemplateController@checkModifiedLayouts` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateName | path | string | 예 | — | 대상 template의 이름 (식별자) | + +**요청 예시** + +```http +GET /api/admin/templates/{templateName}/check-modified-layouts HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 업데이트 전 사용자가 수정한 레이아웃이 있는지 확인합니다(TemplateService::checkModifiedLayouts). 업데이트 시 레이아웃 전략(overwrite/keep) 선택의 참고 자료로 사용합니다. `core.templates.read` 권한이 필요합니다. + + +### DELETE /api/admin/templates/{templateName}/custom-translations + +- **라우트명**: `api.admin.templates.custom-translations.bulk-destroy` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\TemplateCustomTranslationController@bulkDestroy` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.layouts.edit` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateName | path | string | 예 | — | 대상 template의 이름 (식별자) | +| ids | query | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.custom_translation.bulk_destroy_validation_rules`). + +**요청 예시** + +```http +DELETE /api/admin/templates/{templateName}/custom-translations?ids=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리 모달의 '선택 삭제'/'미사용 전체 삭제'로 커스텀 다국어 키를 일괄 삭제합니다. 요청 `ids` 중 해당 템플릿 소속 키만 추려 삭제해(교차 템플릿 삭제 차단) 삭제 건수를 반환합니다. `core.templates.layouts.edit` 권한이 필요합니다. + + +### GET /api/admin/templates/{templateName}/custom-translations + +- **라우트명**: `api.admin.templates.custom-translations.index` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\TemplateCustomTranslationController@index` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.layouts.edit` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateName | path | string | 예 | — | 대상 template의 이름 (식별자) | +| layout_name | query | string | 아니오 | max 150 | layout 이름 (식별자) | +| status | query | string | 아니오 | `active`, `orphaned` | 상태 필터 (해당 상태의 항목만 조회) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.custom_translation.index_validation_rules`). + +**요청 예시** + +```http +GET /api/admin/templates/{templateName}/custom-translations?layout_name=%EC%98%88%EC%8B%9C%20%EC%9D%B4%EB%A6%84&status=active HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 레이아웃 편집기의 인라인 편집/번역 탭에서 사용하는 커스텀 다국어 키 목록을 조회합니다. `layout_name`으로 특정 레이아웃을, `status`(active/orphaned)로 사용 여부를 필터할 수 있습니다(TemplateCustomTranslationService::getList). `core.templates.layouts.edit` 권한이 필요합니다. + + +### POST /api/admin/templates/{templateName}/custom-translations + +- **라우트명**: `api.admin.templates.custom-translations.store` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\TemplateCustomTranslationController@store` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.layouts.edit` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateName | path | string | 예 | — | 대상 template의 이름 (식별자) | +| layout_name | body | string | 예 | max 150 | layout 이름 (식별자) | +| locale | body | string | 예 | max 35 | 로케일 코드 (표시 언어/지역) | +| value | body | string | 예 | — | 값 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.custom_translation.store_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/templates/{templateName}/custom-translations HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "layout_name": "예시 이름", + "locale": "ko", + "value": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 인라인 편집 확정 시 커스텀 다국어 키를 생성합니다. `layout_name`/`locale`/`value`를 받아 트랜잭션으로 키를 만들고 생성자를 기록합니다(TemplateCustomTranslationService::createKey). `core.templates.layouts.edit` 권한이 필요합니다. + + +### DELETE /api/admin/templates/{templateName}/custom-translations/{id} + +- **라우트명**: `api.admin.templates.custom-translations.destroy` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\TemplateCustomTranslationController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.layouts.edit` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateName | path | string | 예 | — | 대상 template의 이름 (식별자) | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +DELETE /api/admin/templates/{templateName}/custom-translations/{id} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 커스텀 다국어 키 1건을 삭제합니다. 대상 키가 경로의 템플릿 소속인지 교차검증한 뒤 삭제합니다(TemplateCustomTranslationService::deleteKey). `core.templates.layouts.edit` 권한이 필요합니다. + + +### PUT /api/admin/templates/{templateName}/custom-translations/{id} + +- **라우트명**: `api.admin.templates.custom-translations.update` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\TemplateCustomTranslationController@update` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.layouts.edit` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateName | path | string | 예 | — | 대상 template의 이름 (식별자) | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| values | body | array | 예 | — | 값 배열 | +| expected_lock_version | body | integer | 예 | min 0 | 낙관적 잠금 버전 (동시 편집 충돌 감지) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.custom_translation.update_validation_rules`). + +**요청 예시** + +```http +PUT /api/admin/templates/{templateName}/custom-translations/{id} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "values": [ + "예시값" + ], + "expected_lock_version": 1 +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 번역 탭의 일괄 편집으로 커스텀 다국어 키의 로케일별 값을 수정합니다. `expected_lock_version` 기반 낙관적 잠금을 적용해 동시 수정 충돌 시 409(current/your version)를 반환합니다(TemplateCustomTranslationService::updateValues). `core.templates.layouts.edit` 권한이 필요합니다. + + +### GET /api/admin/templates/{templateName}/install-preview + +- **라우트명**: `api.admin.templates.install-preview` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\TemplateController@installPreview` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.install` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateName | path | string | 예 | — | 대상 template의 이름 (식별자) | + +**요청 예시** + +```http +GET /api/admin/templates/{templateName}/install-preview HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.install`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 템플릿 설치 cascade 프리뷰를 반환합니다. 의존 확장 목록과 동반 가능한 번들 언어팩을 미리 계산해(ExtensionInstallPreviewBuilder::build) 설치 확인 화면에서 사용합니다. `core.templates.install` 권한이 필요합니다. + + +### GET /api/admin/templates/{templateName}/layout-extensions + +- **라우트명**: `api.admin.templates.layout-extensions.index` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LayoutExtensionController@index` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateName | path | string | 예 | — | 대상 template의 이름 (식별자) | + +**요청 예시** + +```http +GET /api/admin/templates/{templateName}/layout-extensions HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 특정 템플릿에 모듈/플러그인이 주입한 레이아웃 확장 목록을 출처별로 그룹핑해 조회합니다. 각 확장에는 호스트 레이아웃 목록(`host_layouts`)이 부착되어, 캔버스 로드 없이 화면별 연결 확장을 정적 구성할 수 있습니다(LayoutExtensionService::getExtensionsByTemplateId). `core.templates.read` 권한이 필요합니다. + + +### GET /api/admin/templates/{templateName}/layout-extensions/{extensionId} + +- **라우트명**: `api.admin.templates.layout-extensions.show` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LayoutExtensionController@show` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateName | path | string | 예 | — | 대상 template의 이름 (식별자) | +| extensionId | path | string | 예 | — | 대상 extension의 식별자 | + +**요청 예시** + +```http +GET /api/admin/templates/{templateName}/layout-extensions/{extensionId} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 특정 레이아웃 확장 1건의 상세를 조회합니다. 확장이 경로의 템플릿 소속인지 교차검증하며, 편집 모드 캔버스가 호스트 병합 렌더에 쓰는 호스트 레이아웃 후보(`host_layouts`)를 함께 반환합니다. `core.templates.read` 권한이 필요합니다. + + +### PUT /api/admin/templates/{templateName}/layout-extensions/{extensionId} + +- **라우트명**: `api.admin.templates.layout-extensions.update` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LayoutExtensionController@update` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.layouts.edit` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateName | path | string | 예 | — | 대상 template의 이름 (식별자) | +| extensionId | path | string | 예 | — | 대상 extension의 식별자 | +| expected_lock_version | body | integer | 예 | min 0 | 낙관적 잠금 버전 (동시 편집 충돌 감지) | +| content | body | array | 예 | — | 본문 내용 | +| priority | body | integer | 아니오 | min 0, max 9999 | 우선순위 (작을수록 우선) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.layout_extension.update_content_validation_rules`). + +**요청 예시** + +```http +PUT /api/admin/templates/{templateName}/layout-extensions/{extensionId} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "expected_lock_version": 1, + "content": [ + "예시 내용입니다." + ], + "priority": 1 +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 레이아웃 확장의 content를 수정합니다. `expected_lock_version` 기반 낙관적 잠금으로 동시 수정 시 409를 반환하며, `content`는 최상위 키(extension_point/components 등) 누락을 막기 위해 검증된 하위 규칙이 아닌 원본 배열을 그대로 저장합니다. `priority`도 함께 갱신할 수 있습니다. `core.templates.layouts.edit` 권한이 필요합니다. + + +### POST /api/admin/templates/{templateName}/layout-extensions/{extensionId}/preview + +- **라우트명**: `api.admin.templates.layout-extensions.preview.store` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LayoutExtensionController@storePreview` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateName | path | string | 예 | — | 대상 template의 이름 (식별자) | +| extensionId | path | string | 예 | — | 대상 extension의 식별자 | +| content | body | array | 예 | — | 본문 내용 | +| preview_layout | body | string | 아니오 | max 255 | 미리보기에 사용할 대표 레이아웃명 (extension_point 타입 시 필수, overlay 타입은 target_layout 자체가 대표라 생략 가능) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.layout_extension.store_preview_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/templates/{templateName}/layout-extensions/{extensionId}/preview HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "content": [ + "예시 내용입니다." + ], + "preview_layout": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 편집 중인 레이아웃 확장 content를 임시 저장하고 대표 레이아웃에 적용한 미리보기 URL/토큰을 반환합니다. overlay 타입은 target_name이, extension_point 타입은 `preview_layout` 파라미터가 대표 레이아웃이 되며, 미지정 시 422를 반환합니다(LayoutPreviewService::createExtensionPreview). `core.templates.read` 권한이 필요합니다. + + +### GET /api/admin/templates/{templateName}/layout-extensions/{extensionId}/versions + +- **라우트명**: `api.admin.templates.layout-extensions.versions.index` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LayoutExtensionController@versions` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateName | path | string | 예 | — | 대상 template의 이름 (식별자) | +| extensionId | path | string | 예 | — | 대상 extension의 식별자 | + +**요청 예시** + +```http +GET /api/admin/templates/{templateName}/layout-extensions/{extensionId}/versions HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 레이아웃 확장의 모든 버전 이력을 조회합니다(LayoutExtensionService::getExtensionVersions). `core.templates.read` 권한이 필요합니다. + + +### POST /api/admin/templates/{templateName}/layout-extensions/{extensionId}/versions/{versionId}/restore + +- **라우트명**: `api.admin.templates.layout-extensions.versions.restore` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LayoutExtensionController@restoreVersion` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.layouts.edit` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateName | path | string | 예 | — | 대상 template의 이름 (식별자) | +| extensionId | path | string | 예 | — | 대상 extension의 식별자 | +| versionId | path | string | 예 | — | 대상 version의 식별자 | + +**요청 예시** + +```http +POST /api/admin/templates/{templateName}/layout-extensions/{extensionId}/versions/{versionId}/restore HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 레이아웃 확장을 지정한 버전으로 복원합니다. 복원은 새 버전으로 기록되며 복원 후 새 버전 정보를 반환합니다(LayoutExtensionService::restoreExtensionVersion). `core.templates.layouts.edit` 권한이 필요합니다. + + +### GET /api/admin/templates/{templateName}/layout-extensions/{extensionId}/versions/{version} + +- **라우트명**: `api.admin.templates.layout-extensions.versions.show` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LayoutExtensionController@showVersion` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateName | path | string | 예 | — | 대상 template의 이름 (식별자) | +| extensionId | path | string | 예 | — | 대상 extension의 식별자 | +| version | path | string | 예 | — | 대상 버전 (버전 문자열) | + +**요청 예시** + +```http +GET /api/admin/templates/{templateName}/layout-extensions/{extensionId}/versions/{version} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 레이아웃 확장의 특정 버전 content를 조회합니다(버전 비교/diff용, LayoutExtensionService::getExtensionVersion). `core.templates.read` 권한이 필요합니다. + + +### GET /api/admin/templates/{templateName}/layouts + +- **라우트명**: `api.admin.templates.layouts.index` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LayoutController@index` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateName | path | string | 예 | — | 대상 template의 이름 (식별자) | + +**요청 예시** + +```http +GET /api/admin/templates/{templateName}/layouts HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 특정 템플릿의 모든 레이아웃 목록을 조회합니다. 각 레이아웃에 이름 → 라우트 path 매핑을 부착해, 코드 편집기가 파일 선택 시 `?route=` URL 동기화나 위지윅에서 넘어온 라우트로 파일을 복원할 수 있게 합니다(LayoutService::getLayoutsByTemplateId). `core.templates.read` 권한이 필요합니다. + + +### GET /api/admin/templates/{templateName}/layouts/{name} + +- **라우트명**: `api.admin.templates.layouts.show` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LayoutController@show` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateName | path | string | 예 | — | 대상 template의 이름 (식별자) | +| name | path | string | 예 | — | 대상의 이름/명칭 | + +**요청 예시** + +```http +GET /api/admin/templates/{templateName}/layouts/{name} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 특정 레이아웃 1건의 상세(content 포함)를 조회합니다(LayoutService::getLayoutByName). 레이아웃이 없으면 404를 반환합니다. `core.templates.read` 권한이 필요합니다. + + +### PUT /api/admin/templates/{templateName}/layouts/{name} + +- **라우트명**: `api.admin.templates.layouts.update` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LayoutController@update` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.layouts.edit` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateName | path | string | 예 | — | 대상 template의 이름 (식별자) | +| name | path | string | 예 | — | 대상의 이름/명칭 | +| expected_lock_version | body | integer | 예 | min 0 | 낙관적 잠금 버전 (동시 편집 충돌 감지) | +| content | body | array | 예 | — | 본문 내용 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.layout.update_content_validation_rules`). + +**요청 예시** + +```http +PUT /api/admin/templates/{templateName}/layouts/{name} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "expected_lock_version": 1, + "content": [ + "예시 내용입니다." + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 레이아웃 content를 수정합니다. `expected_lock_version` 기반 낙관적 잠금을 적용해 동시 수정 충돌 시 409(current/your version)를 반환합니다(LayoutService::updateLayout). `core.templates.layouts.edit` 권한이 필요합니다. + + +### POST /api/admin/templates/{templateName}/layouts/{name}/preview + +- **라우트명**: `api.admin.templates.layouts.preview.store` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LayoutController@storePreview` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateName | path | string | 예 | — | 대상 template의 이름 (식별자) | +| name | path | string | 예 | — | 대상의 이름/명칭 | +| content | body | array | 예 | — | 본문 내용 | + +**요청 예시** + +```http +POST /api/admin/templates/{templateName}/layouts/{name}/preview HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "content": [ + "예시 내용입니다." + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 편집 중인 레이아웃 content를 임시 저장하고 미리보기 URL/토큰을 반환합니다(LayoutPreviewService::createPreview). 토큰은 만료 시각을 함께 반환합니다. `core.templates.read` 권한이 필요합니다. + + +### GET /api/admin/templates/{templateName}/layouts/{name}/versions + +- **라우트명**: `api.admin.templates.layouts.versions.index` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LayoutController@versions` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateName | path | string | 예 | — | 대상 template의 이름 (식별자) | +| name | path | string | 예 | — | 대상의 이름/명칭 | + +**요청 예시** + +```http +GET /api/admin/templates/{templateName}/layouts/{name}/versions HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 레이아웃의 모든 버전 이력을 조회합니다(LayoutService::getLayoutVersions). `core.templates.read` 권한이 필요합니다. + + +### POST /api/admin/templates/{templateName}/layouts/{name}/versions/{versionId}/restore + +- **라우트명**: `api.admin.templates.layouts.versions.restore` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LayoutController@restoreVersion` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.layouts.edit` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateName | path | string | 예 | — | 대상 template의 이름 (식별자) | +| name | path | string | 예 | — | 대상의 이름/명칭 | +| versionId | path | string | 예 | — | 대상 version의 식별자 | + +**요청 예시** + +```http +POST /api/admin/templates/{templateName}/layouts/{name}/versions/{versionId}/restore HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 레이아웃을 지정한 버전으로 복원합니다. 복원 결과는 새 버전으로 기록되어 반환됩니다(LayoutService::restoreVersion). `core.templates.layouts.edit` 권한이 필요합니다. + + +### GET /api/admin/templates/{templateName}/layouts/{name}/versions/{version} + +- **라우트명**: `api.admin.templates.layouts.versions.show` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\LayoutController@showVersion` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateName | path | string | 예 | — | 대상 template의 이름 (식별자) | +| name | path | string | 예 | — | 대상의 이름/명칭 | +| version | path | string | 예 | — | 대상 버전 (버전 문자열) | + +**요청 예시** + +```http +GET /api/admin/templates/{templateName}/layouts/{name}/versions/{version} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 레이아웃의 특정 버전 content를 조회합니다. 버전 비교 diff용으로 slots/extends 등을 포함한 content 원본 전체를 노출합니다(LayoutService::getLayoutVersion). `core.templates.read` 권한이 필요합니다. + + +### GET /api/admin/templates/{templateName}/uninstall-info + +- **라우트명**: `api.admin.templates.uninstall-info` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\TemplateController@uninstallInfo` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.uninstall` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateName | path | string | 예 | — | 대상 template의 이름 (식별자) | + +**요청 예시** + +```http +GET /api/admin/templates/{templateName}/uninstall-info HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.uninstall`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 템플릿 제거 시 함께 삭제될 데이터 정보를 조회합니다(TemplateService::getTemplateUninstallInfo). 제거 확인 다이얼로그에서 사용하며, 템플릿이 없으면 404를 반환합니다. `core.templates.uninstall` 권한이 필요합니다. + + +### POST /api/admin/templates/{templateName}/update + +- **라우트명**: `api.admin.templates.update` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\TemplateController@performUpdate` +- **인증/권한**: `auth:sanctum` + `permission:core.templates.install` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateName | path | string | 예 | — | 대상 template의 이름 (식별자) | +| layout_strategy | body | string | 아니오 | `overwrite`, `keep` | 업데이트 시 레이아웃 처리 전략 (overwrite: 새 파일로 전면 교체, keep: 사용자 수정 레이아웃 유지) | +| force | body | boolean | 아니오 | — | 강제 실행 여부 (안전 확인/선행 검사 우회) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.template.perform_update_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/templates/{templateName}/update HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "layout_strategy": "overwrite", + "force": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.templates.install`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 설치된 템플릿을 최신 버전으로 업데이트합니다. `layout_strategy`(overwrite: 레이아웃 전면 교체 / keep: 사용자 수정 레이아웃 유지)로 레이아웃 처리 방식을 결정하며, `force`로 강제 진행할 수 있습니다(TemplateService::performVersionUpdate). `core.templates.install` 권한이 필요합니다. + + +### GET /api/templates/assets/{identifier}/{path} + +- **라우트명**: `api.public.templates.assets` +- **컨트롤러**: `App\Http\Controllers\Api\Public\PublicTemplateController@serveAsset` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | +| path | path | string | 예 | — | 경로 | + +**요청 예시** + +```http +GET /api/templates/assets/{identifier}/{path} HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 템플릿의 정적 자산 파일(JS/CSS/이미지 등)을 서빙하는 공개 엔드포인트입니다. FormRequest에서 경로 보안 검증을 마친 뒤 파일을 조회하며(TemplateService::getAssetFilePath), ETag 및 장기 캐싱 헤더와 함께 반환합니다. 허용되지 않은 파일 유형은 403, 파일 부재 시 404입니다. 인증이 필요 없습니다. + + +### GET /api/templates/{identifier}/components.json + +- **라우트명**: `api.public.templates.components` +- **컨트롤러**: `App\Http\Controllers\Api\Public\PublicTemplateController@serveComponents` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/templates/{identifier}/components.json HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 템플릿의 컴포넌트 정의(components.json)를 서빙하는 공개 엔드포인트입니다(TemplateService::getComponentsFilePath). 프론트엔드 렌더 엔진 부팅에 사용하며 1시간 캐시됩니다. 인증이 필요 없습니다. + + +### GET /api/templates/{identifier}/config.json + +- **라우트명**: `api.public.templates.config` +- **컨트롤러**: `App\Http\Controllers\Api\Public\PublicTemplateController@serveConfig` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/templates/{identifier}/config.json HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 활성 템플릿의 설정 파일(template.json, error_config 등 메타데이터)을 서빙하는 공개 엔드포인트입니다. 응답에 확장 캐시 버전(`cache_version`)을 포함해 프론트엔드가 후속 API 호출에 사용하게 하며, 비활성/미존재 템플릿은 404입니다. 1시간 캐시됩니다. 인증이 필요 없습니다. + + +### GET /api/templates/{identifier}/editor-spec + +- **라우트명**: `api.public.templates.editor_spec` +- **컨트롤러**: `App\Http\Controllers\Api\Public\PublicTemplateController@serveEditorSpec` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/templates/{identifier}/editor-spec HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 템플릿의 편집기 스펙(editor-spec.json)을 서빙하는 공개 엔드포인트입니다. 분할 스펙은 manifest + `$include` 블록을 합본한 단일 spec으로(활성 디렉토리 기준) 반환하며, 파일 미존재 시 `spec=null`로 폴백합니다. 인증이 필요 없습니다. + + +### GET /api/templates/{identifier}/lang/{locale}.json + +- **라우트명**: `api.public.templates.language` +- **컨트롤러**: `App\Http\Controllers\Api\Public\PublicTemplateController@serveLanguage` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | +| locale | path | string | 예 | — | 로케일 코드 (표시 언어/지역) | + +**요청 예시** + +```http +GET /api/templates/{identifier}/lang/{locale}.json HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 활성 템플릿의 다국어 파일(lang/{locale}.json)을 활성화된 모듈의 다국어 데이터와 병합해 서빙하는 공개 엔드포인트입니다(TemplateService::getLanguageDataWithModules). 지원하지 않는 로케일/파일 부재 시 404이며 1시간 캐시됩니다. 인증이 필요 없습니다. + + +### GET /api/templates/{identifier}/layout-attachments/{attachment}/file + +- **라우트명**: `api.public.templates.layout-attachment-file` +- **컨트롤러**: `App\Http\Controllers\Api\Public\PublicTemplateController@serveFile` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | +| attachment | path | string | 예 | — | 대상 attachment의 식별자 | + +**요청 예시** + +```http +GET /api/templates/{identifier}/layout-attachments/{attachment}/file HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 레이아웃 첨부 이미지 파일을 서빙하는 공개 엔드포인트입니다. 발행된 배경 이미지가 일반 방문자에게도 로드되어야 하므로 인증 없이 노출하며, 비공개 attachments 디스크의 파일을 캐싱 헤더와 함께 인라인 스트림합니다. 첨부가 경로의 템플릿 소속이 아니거나 파일이 없으면 404입니다. 인증이 필요 없습니다. + + +### GET /api/templates/{identifier}/routes.json + +- **라우트명**: `api.public.templates.routes` +- **컨트롤러**: `App\Http\Controllers\Api\Public\PublicTemplateController@getRoutes` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/templates/{identifier}/routes.json HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 템플릿의 라우트 정의(routes.json)를 활성화된 모듈의 라우트와 병합해 서빙하는 공개 엔드포인트입니다(TemplateService::getRoutesDataWithModules). 프론트엔드 라우팅 부팅에 사용하며 `v` query로 캐시를 무효화하고 1시간 캐시됩니다. 인증이 필요 없습니다. + + diff --git a/docs/backend/api/users.md b/docs/backend/api/users.md new file mode 100644 index 00000000..0a9afe28 --- /dev/null +++ b/docs/backend/api/users.md @@ -0,0 +1,1243 @@ +# Users API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Users 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/admin/users + +- **라우트명**: `api.admin.users.index` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\UserController@index` +- **인증/권한**: `auth:sanctum` + `permission:core.users.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | +| filters | query | array | 아니오 | max 10 | 추가 필터 조건 맵 (필드별 조건) | +| start_date | query | date | 아니오 | — | 조회 기간 시작일 (이 날짜 이후 데이터) | +| end_date | query | date | 아니오 | — | 조회 기간 종료일 (이 날짜 이전 데이터) | +| date_filter | query | string | 아니오 | `all`, `week`, `month`, `custom` | 가입 기간 프리셋 (all: 전체, week: 최근 1주, month: 최근 1개월, custom: start_date/end_date 로 직접 지정) | +| sort_by | query | string | 아니오 | `created_at`, `name`, `email`, `last_login_at` | 정렬 기준 필드명 | +| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.user.list_validation_rules`). + +**요청 예시** + +```http +GET /api/admin/users?page=1&per_page=1&filters=%EC%98%88%EC%8B%9C%EA%B0%92&start_date=2026-01-01&end_date=2026-01-01&date_filter=all&sort_by=created_at&sort_order=asc HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| number | integer | `156` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| uuid | string | `a231747f-e82e-4cf2-9ae1-a261849dce40` | 외부 노출용 UUID (URL/API 식별자, 내부 id 비노출) | +| name | string | `API 문서 샘플 사용자` | 사용자 이름 | +| nickname | string | `gunwoo.oh` | 닉네임 | +| email | string | `apidoc-sample-user@example.com` | 이메일 주소 | +| language | string | `ko` | 사용자 언어 설정 (ko: 한국어, en: 영어) | +| language_label | string | `한국어` | 언어 코드의 현지화 라벨 (user.language.{code} 번역) | +| country | string | `KR` | 국가 코드 (ISO 3166-1 alpha-2) | +| country_flag | string | `🇰🇷` | 국가 코드의 국기 이모지 (country 값에서 파생) | +| country_name | string | `한국` | 국가 코드의 현지화 국가명 (country 값에서 파생) | +| status | string | `active` | 계정 상태 (active: 활성, inactive: 비활성, blocked: 차단, withdrawn: 탈퇴) | +| status_label | string | `활성` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| status_variant | string | `success` | 상태 표시 색상/스타일 변형 키 (상태 Enum variant() 산물 — UI 배지용) | +| mobile | string | `010-9070-5662` | 휴대폰 번호 | +| roles | array | `[{"id":1,"identifier":"admin","name":"관리자"}]` | 사용자에게 부여된 역할 목록 (원소: id/identifier/name — 역할 관계 파생) | +| email_verified_at | string | `2026-07-06 19:15:16` | email verified 일시 | +| last_login_at | string | `2026-07-05 19:15:16` | last login 일시 | +| created_at | string | `2026-07-06` | 생성 일시 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_create":true,"can_update":true,"can…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "사용자 정보를 성공적으로 가져왔습니다.", + "data": { + "data": [ + { + "number": 2, + "uuid": "a234c3ea-a4f9-496d-a5e7-b44f0d53fc2f", + "name": "선지영", + "nickname": null, + "email": "jihee74@example.net", + "language": "ko", + "language_label": "한국어", + "country": null, + "country_flag": null, + "country_name": null, + "status": "active", + "status_label": "활성", + "status_variant": "success", + "mobile": null, + "roles": [], + "email_verified_at": "2026-07-08 10:44:49", + "last_login_at": null, + "created_at": "2026-07-08", + "is_owner": false, + "abilities": { + "can_read": true, + "can_create": true, + "can_update": true, + "can_delete": true, + "can_assign_roles": true + } + }, + { + "number": 1, + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "nickname": "song.hyunji", + "email": "apidoc-sample-user@example.com", + "language": "ko", + "language_label": "한국어", + "country": "KR", + "country_flag": "🇰🇷", + "country_name": "한국", + "status": "active", + "status_label": "활성", + "status_variant": "success", + "mobile": "010-9595-2897", + "roles": [ + { + "id": 1, + "identifier": "admin", + "name": "관리자" + }, + { + "id": 4, + "identifier": "apidoc-sample-role", + "name": "API 문서 샘플 역할" + } + ], + "email_verified_at": "2026-07-08 10:41:24", + "last_login_at": "2026-07-07 10:41:24", + "created_at": "2026-07-08", + "is_owner": true, + "abilities": { + "can_read": true, + "can_create": true, + "can_update": true, + "can_delete": true, + "can_assign_roles": true + } + } + ], + "statistics": { + "total_users": 2, + "users_this_week": 2, + "users_this_month": 2, + "users_today": 2, + "active_users_this_week": 1 + }, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true, + "can_assign_roles": true + }, + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 2, + "from": 1, + "to": 2, + "has_more_pages": false + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.users.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +관리자 사용자 관리 화면(`admin_user_list.json`)의 목록을 제공합니다. `filters` 로 이름/이메일 다중 검색(operator: like, eq, starts_with, ends_with), `date_filter`(all/week/month/custom)와 `start_date`/`end_date` 로 가입 기간 필터, `sort_by`/`sort_order` 로 정렬한다. 응답은 `data.data[]`(항목별 순번 `number` + 요약 필드)와 `data.pagination`(페이지 정보)에 더해 `data.statistics`(통계)와 `data.abilities`(컬렉션 레벨 권한)를 함께 반환한다. 기본값은 `per_page=15`, `page=1`, `sort_by=created_at`, `sort_order=desc`, `date_filter=all` 이다. + + +### POST /api/admin/users + +- **라우트명**: `api.admin.users.store` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\UserController@store` +- **인증/권한**: `auth:sanctum` + `permission:core.users.create` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| name | body | string | 예 | max 255 | 대상의 이름/명칭 | +| nickname | body | string | 아니오 | max 50 | 닉네임 | +| email | body | email | 예 | max 255 | 이메일 주소 | +| password | body | string | 예 | — | 비밀번호 | +| language | body | string | 아니오 | `ko`, `en`, `fr`, `ja` | 언어 코드 | +| country | body | string | 아니오 | — | 국가 코드 (ISO 3166-1 alpha-2) | +| timezone | body | string | 아니오 | — | 타임존 식별자 | +| status | body | string | 아니오 | `active`, `inactive`, `blocked`, `withdrawn` | 계정 상태 (미지정 시 active) | +| homepage | body | string | 아니오 | max 255 | 홈페이지 URL | +| mobile | body | string | 아니오 | max 20 | 휴대전화 번호 | +| phone | body | string | 아니오 | max 20 | 전화번호 | +| zipcode | body | string | 아니오 | max 10 | 우편번호 | +| address | body | string | 아니오 | max 255 | 기본 주소 | +| address_detail | body | string | 아니오 | max 255 | 상세 주소 | +| signature | body | string | 아니오 | max 1000 | 서명 | +| bio | body | string | 아니오 | max 5000 | 자기소개 | +| admin_memo | body | string | 아니오 | max 5000 | 관리자 전용 메모 (해당 사용자에 대한 내부 기록, 사용자에게 노출 안 됨) | +| roles | body | array | 아니오 | min 1 | 부여할 역할 객체 배열 `[{id}]` (role_ids 와 병용 시 role_ids 우선) | +| role_ids | body | array | 아니오 | min 1 | role 식별자 배열 | +| notify_post_complete | body | boolean | 아니오 | — | 게시글 작성 완료 알림 수신 여부 (게시판 모듈 알림 설정) | +| notify_post_reply | body | boolean | 아니오 | — | 내 게시글에 답글이 달릴 때 알림 수신 여부 | +| notify_comment | body | boolean | 아니오 | — | 내 게시글에 댓글이 달릴 때 알림 수신 여부 | +| notify_reply_comment | body | boolean | 아니오 | — | 내 댓글에 대댓글이 달릴 때 알림 수신 여부 | +| email_subscription | body | boolean | 아니오 | — | 광고성 이메일 수신 동의 여부 (marketing 플러그인 채널 동의, marketing_consents 테이블에 저장) | +| marketing_consent | body | boolean | 아니오 | — | 마케팅 정보 수신 전체 동의 마스터 키 (marketing 플러그인이 검증 규칙 주입, 상세는 user-consent-injection.md) | +| third_party_consent | body | boolean | 아니오 | — | 제3자 정보 제공 동의 여부 (marketing 플러그인 법적 동의 항목) | +| info_disclosure | body | boolean | 아니오 | — | 개인정보 이용 안내 동의 여부 (marketing 플러그인 법적 동의 항목) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.user.create_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/users HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": "예시 이름", + "nickname": "예시 이름", + "email": "user@example.com", + "password": "Password123!", + "language": "ko", + "country": "KR", + "timezone": "Asia/Seoul", + "status": "active", + "homepage": "https://example.com", + "mobile": "010-1234-5678", + "phone": "010-1234-5678", + "zipcode": "06234", + "address": "서울특별시 강남구 테헤란로 1", + "address_detail": "서울특별시 강남구 테헤란로 1", + "signature": "예시값", + "bio": "예시 내용입니다.", + "admin_memo": "예시값", + "roles": [ + "예시값" + ], + "role_ids": [ + "예시값" + ], + "notify_post_complete": true, + "notify_post_reply": true, + "notify_comment": true, + "notify_reply_comment": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.users.create`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +관리자가 새 사용자를 생성합니다. `name`, `email`, `password`(8자 이상, `password_confirmation` 확인 필수), 그리고 역할이 필수이다 — 역할은 `roles`(객체 배열 `[{id}]`) 또는 `role_ids`(id 배열) 중 하나로 지정하며 둘 다 보내면 `role_ids` 가 우선한다. `language`(미지정 시 `ko`), `status`(미지정 시 `active`), 연락처/주소/자기소개 등은 선택 항목이다. 성공 시 201 과 함께 생성된 사용자를 `UserResource` 형태로 반환한다. `notify_*`/`marketing_consent`/`third_party_consent`/`info_disclosure`/`email_subscription` 파라미터는 확장(sirsoft-marketing)이 검증 규칙을 주입한 필드로, 상세는 해당 확장 문서를 참조한다. + + +### PATCH /api/admin/users/bulk-status + +- **라우트명**: `api.admin.users.bulk-status` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\UserController@bulkUpdateStatus` +- **인증/권한**: `auth:sanctum` + `permission:core.users.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| ids | body | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | +| status | body | string | 예 | — | 일괄 적용할 계정 상태 (UserStatus Enum 값: active/inactive/blocked/withdrawn/pending_verification) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.user.bulk_update_status_validation_rules`). + +**요청 예시** + +```http +PATCH /api/admin/users/bulk-status HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "ids": [ + "예시값" + ], + "status": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.users.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +여러 사용자의 계정 상태를 한 번에 변경합니다. `ids` 는 대상 사용자 UUID 배열이며, `status` 는 `active`/`inactive`/`blocked`/`withdrawn`/`pending_verification`(UserStatus Enum 값) 중 하나이다. `ExcludeCurrentUser` 규칙으로 요청자 본인은 대상에서 제외된다. 목록 화면의 다중 선택 후 일괄 상태 변경에 사용한다. + + +### POST /api/admin/users/check-email + +- **라우트명**: `api.admin.users.check-email` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\UserController@checkEmail` +- **인증/권한**: `auth:sanctum` + `permission:core.users.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| email | body | email | 예 | max 255 | 이메일 주소 | +| exclude_user_id | body | uuid | 아니오 | — | exclude user 식별자 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.user.check_email_validation_rules`). + +**요청 예시** + +```http +POST /api/admin/users/check-email HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "email": "user@example.com", + "exclude_user_id": "9f8b2c1a-4d3e-4a2b-8c1d-0e1f2a3b4c5d" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.users.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +이메일 주소의 사용 가능 여부(중복 아님)를 확인합니다. `email` 은 필수, `exclude_user_id`(UUID)를 주면 해당 사용자를 중복 검사에서 제외한다 — 사용자 수정 화면에서 자기 자신의 이메일을 유지할 때 사용한다. 응답 `data.available` 이 true 면 사용 가능, false 면 이미 사용 중이다. 사용자 생성/수정 폼의 이메일 실시간 중복 확인에 쓰인다. + + +### PATCH /api/admin/users/me/language + +- **라우트명**: `api.admin.users.me.language` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\UserController@updateMyLanguage` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| language | body | string | 예 | `ko`, `en`, `fr`, `ja` | 언어 코드 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.user.update_language_validation_rules`). + +**요청 예시** + +```http +PATCH /api/admin/users/me/language HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "language": "ko" +} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| uuid | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | 외부 노출용 UUID (URL/API 식별자, 내부 id 비노출) | +| name | string | `API 문서 샘플 사용자` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| nickname | string | `song.hyunji` | 닉네임 | +| email | string | `apidoc-sample-user@example.com` | 이메일 주소 | +| avatar | null | `null` | 아바타 이미지 URL (미등록 시 null) | +| language | string | `ko` | 사용자 언어 설정 (ko: 한국어, en: 영어) | +| language_label | string | `한국어` | 언어 코드의 현지화 라벨 (user.language.{code} 번역) | +| country | string | `KR` | 국가 코드 (ISO 3166-1 alpha-2) | +| status | string | `active` | 상태 값 (도메인별 상태 집합 — 사람이 읽는 라벨은 status_label, UI 변형은 status_variant 참조) | +| status_label | string | `활성` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| status_variant | string | `success` | 상태 표시 색상/스타일 변형 키 (상태 Enum variant() 산물 — UI 배지용) | +| is_admin | boolean | `true` | 관리자 역할 보유 여부 (User::isAdmin() — 역할 관계 기반 파생) | +| homepage | string | `https://example.com` | 홈페이지 URL | +| mobile | string | `010-9595-2897` | 휴대폰 번호 | +| phone | string | `02-637-5618` | 전화번호 | +| zipcode | string | `16505` | 우편번호 | +| address | string | `경기도 안양시 봉은사로 2918` | 기본 주소 | +| address_detail | string | `48동 718호` | 상세 주소 | +| signature | string | `Fugit consequuntur repellendus sed.` | 서명 | +| bio | string | `Ut magni et sunt ducimus error adipis…` | 자기소개 | +| last_login_at | string | `2026-07-07 10:41:24` | last login 일시 | +| email_verified_at | string | `2026-07-08 10:41:24` | email verified 일시 | +| timezone | string | `Asia/Seoul` | 사용자 시간대 (예: Asia/Seoul, UTC) | +| created_at | string | `2026-07-08 10:41:24` | 생성 일시 | +| updated_at | string | `2026-07-08 10:41:24` | 최종 수정 일시 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_create":true,"can_update":true,"can…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "언어 설정이 성공적으로 변경되었습니다.", + "data": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "nickname": "song.hyunji", + "email": "apidoc-sample-user@example.com", + "avatar": null, + "language": "ko", + "language_label": "한국어", + "country": "KR", + "status": "active", + "status_label": "활성", + "status_variant": "success", + "is_admin": true, + "homepage": "https://example.com", + "mobile": "010-9595-2897", + "phone": "02-637-5618", + "zipcode": "16505", + "address": "경기도 안양시 봉은사로 2918", + "address_detail": "48동 718호", + "signature": "Fugit consequuntur repellendus sed.", + "bio": "Ut magni et sunt ducimus error adipisci. Pariatur corporis voluptatem ratione quo non saepe. Illo atque praesentium possimus dolores qui est fugit. Sint fugiat numquam voluptates.", + "last_login_at": "2026-07-07 10:41:24", + "email_verified_at": "2026-07-08 10:41:24", + "timezone": "Asia/Seoul", + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 10:41:24", + "is_owner": true, + "abilities": { + "can_read": true, + "can_create": true, + "can_update": true, + "can_delete": true, + "can_assign_roles": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +현재 로그인한 사용자 본인의 언어 설정을 변경합니다. 별도 권한 없이 `auth:sanctum` 인증만 요구하며(다른 사용자를 대상으로 하지 않음), `language` 는 `config('app.supported_locales')`(예: ko, en, fr, ja) 중 하나여야 한다. 성공 시 갱신된 사용자를 `UserResource` 로 반환하며, 관리자 UI 의 언어 전환에 사용한다. + + +### GET /api/admin/users/recent + +- **라우트명**: `api.admin.users.recent` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\UserController@recent` +- **인증/권한**: `auth:sanctum` + `permission:core.users.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/users/recent HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| uuid | string | `a231747f-e82e-4cf2-9ae1-a261849dce40` | 외부 노출용 UUID (URL/API 식별자, 내부 id 비노출) | +| name | string | `API 문서 샘플 사용자` | 사용자 이름 | +| nickname | string | `gunwoo.oh` | 닉네임 | +| email | string | `apidoc-sample-user@example.com` | 이메일 주소 | +| avatar | null | `null` | 프로필 아바타 이미지 URL (미설정 시 null) | +| language | string | `ko` | 사용자 언어 설정 (ko: 한국어, en: 영어) | +| language_label | string | `한국어` | 언어 코드의 현지화 라벨 (user.language.{code} 번역) | +| country | string | `KR` | 국가 코드 (ISO 3166-1 alpha-2) | +| status | string | `active` | 계정 상태 (active: 활성, inactive: 비활성, blocked: 차단, withdrawn: 탈퇴) | +| status_label | string | `활성` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| status_variant | string | `success` | 상태 표시 색상/스타일 변형 키 (상태 Enum variant() 산물 — UI 배지용) | +| is_admin | boolean | `true` | 관리자 역할 보유 여부 (User::isAdmin() — 역할 관계 기반 파생) | +| homepage | string | `https://example.com` | 홈페이지 URL | +| mobile | string | `010-9070-5662` | 휴대폰 번호 | +| phone | string | `02-805-4759` | 전화번호 | +| zipcode | string | `93153` | 우편번호 | +| address | string | `대구광역시 북구 백제고분로 720` | 기본 주소 | +| address_detail | string | `40동 835호` | 상세 주소 | +| signature | string | `Ipsam rem amet expedita est.` | 서명 | +| bio | string | `Tenetur omnis et amet omnis veniam to…` | 자기소개 | +| last_login_at | string | `2026-07-05 19:15:16` | last login 일시 | +| email_verified_at | string | `2026-07-06 19:15:16` | email verified 일시 | +| timezone | string | `Asia/Seoul` | 사용자 시간대 (예: Asia/Seoul, UTC) | +| created_at | string | `2026-07-06 19:15:16` | 생성 일시 | +| updated_at | string | `2026-07-06 19:15:16` | 최종 수정 일시 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_create":true,"can_update":true,"can…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "사용자 정보를 성공적으로 가져왔습니다.", + "data": [ + { + "uuid": "a234c3ea-a4f9-496d-a5e7-b44f0d53fc2f", + "name": "선지영", + "nickname": null, + "email": "jihee74@example.net", + "avatar": null, + "language": "ko", + "language_label": "한국어", + "country": null, + "status": "active", + "status_label": "활성", + "status_variant": "success", + "is_admin": false, + "homepage": null, + "mobile": null, + "phone": null, + "zipcode": null, + "address": null, + "address_detail": null, + "signature": null, + "bio": null, + "last_login_at": null, + "email_verified_at": "2026-07-08 10:44:49", + "timezone": "Asia/Seoul", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "is_owner": false, + "abilities": { + "can_read": true, + "can_create": true, + "can_update": true, + "can_delete": true, + "can_assign_roles": true + } + }, + { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "nickname": "song.hyunji", + "email": "apidoc-sample-user@example.com", + "avatar": null, + "language": "ko", + "language_label": "한국어", + "country": "KR", + "status": "active", + "status_label": "활성", + "status_variant": "success", + "is_admin": true, + "homepage": "https://example.com", + "mobile": "010-9595-2897", + "phone": "02-637-5618", + "zipcode": "16505", + "address": "경기도 안양시 봉은사로 2918", + "address_detail": "48동 718호", + "signature": "Fugit consequuntur repellendus sed.", + "bio": "Ut magni et sunt ducimus error adipisci. Pariatur corporis voluptatem ratione quo non saepe. Illo atque praesentium possimus dolores qui est fugit. Sint fugiat numquam voluptates.", + "last_login_at": "2026-07-07 10:41:24", + "email_verified_at": "2026-07-08 10:41:24", + "timezone": "Asia/Seoul", + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 10:41:24", + "is_owner": true, + "abilities": { + "can_read": true, + "can_create": true, + "can_update": true, + "can_delete": true, + "can_assign_roles": true + } + } + ] +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.users.read`)이 없는 경우 | + + + +**설명** + +최근 가입한 사용자 10명을 최신순으로 반환합니다. 파라미터는 없으며, `UserResource` 전체 필드(관계형 데이터는 미로드)를 담은 컬렉션을 반환한다. 관리자 대시보드의 최근 가입자 위젯이 소비한다. + + +### GET /api/admin/users/search + +- **라우트명**: `api.admin.users.search` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\UserController@search` +- **인증/권한**: `auth:sanctum` + `permission:core.users.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| keyword | query | string | 아니오 | max 255 | 검색 키워드 (부분 일치) | +| uuid | query | uuid | 아니오 | — | 특정 사용자 UUID 로 단건 조회 (지정 시 keyword 무시하고 해당 UUID 사용자만 반환) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.user.search_validation_rules`). + +**요청 예시** + +```http +GET /api/admin/users/search?keyword=%EC%98%88%EC%8B%9C%EA%B0%92&uuid=9f8b2c1a-4d3e-4a2b-8c1d-0e1f2a3b4c5d HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.users.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +사용자를 검색해 `UserResource` 컬렉션으로 반환합니다. `uuid` 를 주면 해당 UUID 의 단일 사용자(존재 시 1건, 없으면 빈 배열)를, 없으면 `keyword` 로 이름·닉네임·이메일을 부분 일치 검색한다(`keyword` 와 `uuid` 중 하나는 필수). 알림 템플릿 수신자 지정이나 활동 로그 필터의 사용자 선택 UI 에서 사용한다. + + +### GET /api/admin/users/statistics + +- **라우트명**: `api.admin.users.statistics` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\UserController@statistics` +- **인증/권한**: `auth:sanctum` + `permission:core.users.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/users/statistics HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| total_users | integer | `156` | 전체 사용자 수 (통계 객체는 count/추이 포함) | +| users_this_week | integer | `1` | 이번 주 신규 가입자 수 | +| users_this_month | integer | `155` | 이번 달 신규 가입자 수 | +| users_today | integer | `0` | 오늘 신규 가입자 수 | +| active_users_this_week | integer | `2` | 이번 주 활동(로그인) 사용자 수 | +| language_distribution | object | `{"ko":92,"en":64}` | 언어별 사용자 분포 (언어 코드 => 사용자 수) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "사용자 통계를 성공적으로 가져왔습니다.", + "data": { + "total_users": 2, + "users_this_week": 2, + "users_this_month": 2, + "users_today": 2, + "active_users_this_week": 1, + "language_distribution": { + "ko": 2 + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.users.read`)이 없는 경우 | + + + +**설명** 관리자 대시보드의 사용자 통계 위젯이 소비하는 집계 API. 캐시 없이 실시간 집계. + + +### DELETE /api/admin/users/{user} + +- **라우트명**: `api.admin.users.destroy` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\UserController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:core.users.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| user | path | string | 예 | — | 대상 user의 식별자 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.user.delete_validation_rules`). + +**요청 예시** + +```http +DELETE /api/admin/users/a234c2b1-cde8-437f-b28b-23323be2b98d HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "사용자가 성공적으로 삭제되었습니다.", + "data": null +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.users.delete`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + +지정한 사용자(경로 파라미터는 UUID 로 바인딩)를 삭제합니다. 슈퍼관리자 계정은 삭제할 수 없으며 시도 시 422(`exceptions.cannot_delete_super_admin`)를 반환한다. 그 외 삭제 실패 시에는 실패 상세 사유가 담긴 422 를, 나머지 오류는 500 을 반환한다. 삭제는 Service 계층에서 관련 데이터 정리와 훅을 거쳐 처리된다. + + +### GET /api/admin/users/{user} + +- **라우트명**: `api.admin.users.show` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\UserController@show` +- **인증/권한**: `auth:sanctum` + `permission:core.users.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| user | path | string | 예 | — | 대상 user의 식별자 | + +**요청 예시** + +```http +GET /api/admin/users/a234c2b1-cde8-437f-b28b-23323be2b98d HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| uuid | string | `a231747f-e82e-4cf2-9ae1-a261849dce40` | 외부 노출용 UUID (URL/API 식별자, 내부 id 비노출) | +| name | string | `API 문서 샘플 사용자` | 사용자 이름 | +| nickname | string | `gunwoo.oh` | 닉네임 | +| email | string | `apidoc-sample-user@example.com` | 이메일 주소 | +| avatar | null | `null` | 프로필 아바타 이미지 URL (미설정 시 null) | +| language | string | `ko` | 사용자 언어 설정 (ko: 한국어, en: 영어) | +| language_label | string | `한국어` | 언어 코드의 현지화 라벨 (user.language.{code} 번역) | +| country | string | `KR` | 국가 코드 (ISO 3166-1 alpha-2) | +| status | string | `active` | 계정 상태 (active: 활성, inactive: 비활성, blocked: 차단, withdrawn: 탈퇴) | +| status_label | string | `활성` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| status_variant | string | `success` | 상태 표시 색상/스타일 변형 키 (상태 Enum variant() 산물 — UI 배지용) | +| is_admin | boolean | `true` | 관리자 역할 보유 여부 (User::isAdmin() — 역할 관계 기반 파생) | +| homepage | string | `https://example.com` | 홈페이지 URL | +| mobile | string | `010-9070-5662` | 휴대폰 번호 | +| phone | string | `02-805-4759` | 전화번호 | +| zipcode | string | `93153` | 우편번호 | +| address | string | `대구광역시 북구 백제고분로 720` | 기본 주소 | +| address_detail | string | `40동 835호` | 상세 주소 | +| signature | string | `Ipsam rem amet expedita est.` | 서명 | +| bio | string | `Tenetur omnis et amet omnis veniam to…` | 자기소개 | +| last_login_at | string | `2026-07-05 19:15:16` | last login 일시 | +| email_verified_at | string | `2026-07-06 19:15:16` | email verified 일시 | +| timezone | string | `Asia/Seoul` | 사용자 시간대 (예: Asia/Seoul, UTC) | +| modules_count | integer | `0` | modules 개수 (집계) | +| plugins_count | integer | `0` | plugins 개수 (집계) | +| menus_count | integer | `2` | menus 개수 (집계) | +| modules | array | `[]` | 이 사용자가 접근 권한을 가진 모듈 목록 (역할 경유 권한 관계 파생) | +| plugins | array | `[]` | 이 사용자가 접근 권한을 가진 플러그인 목록 (역할 경유 권한 관계 파생) | +| menus | array | `[{"id":33,"title":"API 문서 샘플 메뉴","url":"\/admin\/apidoc-s…` | 이 사용자가 접근 가능한 관리자 메뉴 목록 (원소: id/title/url — 역할 경유 메뉴 관계 파생) | +| roles | array | `[{"id":1,"identifier":"admin","name":"관리자"}]` | 사용자에게 부여된 역할 목록 (원소: id/identifier/name — 역할 관계 파생) | +| permissions | array | `[]` | 연결된 권한 목록 (id/identifier/name — 역할 경유 권한 관계 파생) | +| consents | array | `[]` | 사용자가 동의한 약관 동의 레코드 목록 (약관 관계 파생) | +| terms_consent | null | `null` | 이용약관 동의 정보 (동의 시각 등, 미동의 시 null) | +| privacy_consent | null | `null` | 개인정보 처리방침 동의 정보 (동의 시각 등, 미동의 시 null) | +| created_at | string | `2026-07-06 19:15:16` | 생성 일시 | +| updated_at | string | `2026-07-06 19:15:16` | 최종 수정 일시 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_create":true,"can_update":true,"can…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | +| admin_memo | string | `Perferendis ut suscipit quia unde sed.` | 관리자 메모 | +| ip_address | string | `77.105.222.87` | 마지막 접속 IP 주소 | +| withdrawn_at | null | `null` | withdrawn 일시 | +| blocked_at | null | `null` | blocked 일시 | +| notify_post_complete | boolean | `false` | 게시글 작성 완료 알림 수신 여부 (게시판 모듈 알림 설정) | +| notify_post_reply | boolean | `false` | 내 게시글 답글 알림 수신 여부 | +| notify_comment | boolean | `false` | 내 게시글 댓글 알림 수신 여부 | +| notify_reply_comment | boolean | `false` | 내 댓글 대댓글 알림 수신 여부 | +| email_subscription | boolean | `false` | 광고성 이메일 수신 동의 여부 (marketing 플러그인 채널 동의) | +| email_subscription_at | null | `null` | email subscription 일시 | +| marketing_consent | boolean | `false` | 마케팅 정보 수신 전체 동의 여부 (marketing 플러그인 마스터 키, 미동의 시 false) | +| marketing_consent_at | null | `null` | marketing consent 일시 | +| third_party_consent | boolean | `false` | 제3자 정보 제공 동의 여부 (marketing 플러그인 법적 동의 항목) | +| third_party_consent_at | null | `null` | third party consent 일시 | +| info_disclosure | boolean | `false` | 개인정보 이용 안내 동의 여부 (marketing 플러그인 법적 동의 항목) | +| info_disclosure_at | null | `null` | info disclosure 일시 | +| marketing_consent_enabled | boolean | `true` | 마케팅 동의 UI 노출 여부 (marketing 플러그인 활성화 플래그, 기본 true) | +| marketing_consent_terms_slug | string | `marketing-terms` | 마케팅 약관 slug (연결된 약관 페이지 식별자, 미설정 시 null) | +| marketing_consent_terms_slug_set | boolean | `true` | 마케팅 약관 연결 존재 여부 (프론트 약관 링크 표시 판정용) | +| third_party_consent_enabled | boolean | `true` | 제3자 제공 동의 항목 노출 여부 (marketing 플러그인 활성화 플래그) | +| third_party_consent_terms_slug | null | `null` | 제3자 제공 약관 slug (미설정 시 null) | +| third_party_consent_terms_slug_set | boolean | `false` | 제3자 제공 약관 연결 존재 여부 | +| info_disclosure_enabled | boolean | `true` | 정보 이용 안내 동의 항목 노출 여부 (marketing 플러그인 활성화 플래그) | +| info_disclosure_terms_slug | null | `null` | 정보 이용 안내 약관 slug (미설정 시 null) | +| info_disclosure_terms_slug_set | boolean | `false` | 정보 이용 안내 약관 연결 존재 여부 | +| email_subscription_enabled | boolean | `true` | 이메일 수신 채널 노출 여부 (marketing 플러그인 활성화 플래그) | +| email_subscription_terms_slug | null | `null` | 이메일 수신 약관 slug (미설정 시 null) | +| email_subscription_terms_slug_set | boolean | `false` | 이메일 수신 약관 연결 존재 여부 | +| channels | array | `[{"key":"email_subscription","label":"광고성 이메일 수신","enable…` | 관리자 정의 전체 마케팅 채널 목록 (원소: key/label/enabled/terms_slug — marketing 플러그인 주입, iteration 렌더링용) | +| consent_histories | array | `[]` | 사용자 동의 변경 이력 (원소: channel_key/action/source/created_at — marketing 플러그인 주입) | +| ecommerce_mileage | object | `{"enabled":false}` | 이커머스 마일리지 정보 (enabled 및 잔액 등 — sirsoft-ecommerce 모듈 주입) | +| ecommerce_preferred_currency | null | `null` | 선호 결제 통화 (sirsoft-ecommerce 모듈 주입, 미설정 시 null) | +| ecommerce_preferred_shipping_country | null | `null` | 선호 배송 국가 코드 (sirsoft-ecommerce 모듈 주입, 미설정 시 null) | +| ecommerce_preferred_shipping_country_name | null | `null` | 선호 배송 국가명 (배송 국가 코드에서 파생, sirsoft-ecommerce 모듈 주입) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "사용자 정보를 성공적으로 가져왔습니다.", + "data": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "nickname": "song.hyunji", + "email": "apidoc-sample-user@example.com", + "avatar": null, + "language": "ko", + "language_label": "한국어", + "country": "KR", + "status": "active", + "status_label": "활성", + "status_variant": "success", + "is_admin": true, + "homepage": "https://example.com", + "mobile": "010-9595-2897", + "phone": "02-637-5618", + "zipcode": "16505", + "address": "경기도 안양시 봉은사로 2918", + "address_detail": "48동 718호", + "signature": "Fugit consequuntur repellendus sed.", + "bio": "Ut magni et sunt ducimus error adipisci. Pariatur corporis voluptatem ratione quo non saepe. Illo atque praesentium possimus dolores qui est fugit. Sint fugiat numquam voluptates.", + "last_login_at": "2026-07-07 10:41:24", + "email_verified_at": "2026-07-08 10:41:24", + "timezone": "Asia/Seoul", + "modules_count": 0, + "plugins_count": 0, + "menus_count": 2, + "modules": [], + "plugins": [], + "menus": [ + { + "id": 1, + "title": "API 문서 샘플 메뉴", + "url": "/admin/apidoc-sample", + "is_active": true + }, + { + "id": 2, + "title": "하위 메뉴", + "url": "/admin/nihil-non-eos-doloribus-occaecati-optio", + "is_active": true + } + ], + "roles": [ + { + "id": 1, + "identifier": "admin", + "name": "관리자" + }, + { + "id": 4, + "identifier": "apidoc-sample-role", + "name": "API 문서 샘플 역할" + } + ], + "permissions": [], + "consents": [], + "terms_consent": null, + "privacy_consent": null, + "created_at": "2026-07-08 10:41:24", + "updated_at": "2026-07-08 10:41:24", + "is_owner": true, + "abilities": { + "can_read": true, + "can_create": true, + "can_update": true, + "can_delete": true, + "can_assign_roles": true + }, + "admin_memo": "In officiis nemo quaerat debitis quia.", + "ip_address": "166.217.51.216", + "withdrawn_at": null, + "blocked_at": null, + "notify_post_complete": false, + "notify_post_reply": false, + "notify_comment": false, + "notify_reply_comment": false, + "ecommerce_mileage": { + "enabled": false + }, + "ecommerce_preferred_currency": null, + "ecommerce_preferred_shipping_country": null, + "ecommerce_preferred_shipping_country_name": null + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.users.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + +특정 사용자의 상세 정보를 조회합니다(경로 파라미터는 UUID 로 바인딩). `withAdminInfo()` 를 통해 기본 필드에 더해 관리자 전용 필드(admin_memo, ip_address, withdrawn_at, blocked_at)와 관계형 데이터(modules, plugins, menus, roles, permissions, consents 및 개수 필드)를 함께 반환한다. `core.user.filter_resource_data` 필터로 확장이 자신의 필드(sirsoft-marketing 의 알림/동의 설정, sirsoft-ecommerce 의 마일리지/선호 통화·배송국 등)를 병합한다. 관리자 사용자 상세/수정 화면(`admin_user_detail.json`/`admin_user_form.json`)이 소비한다. + + +### PUT /api/admin/users/{user} + +- **라우트명**: `api.admin.users.update` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\UserController@update` +- **인증/권한**: `auth:sanctum` + `permission:core.users.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| user | path | string | 예 | — | 대상 user의 식별자 | +| name | body | string | 예 | max 255 | 대상의 이름/명칭 | +| nickname | body | string | 아니오 | max 50 | 닉네임 | +| email | body | email | 예 | max 255 | 이메일 주소 | +| password | body | string | 아니오 | — | 비밀번호 | +| language | body | string | 아니오 | `ko`, `en`, `fr`, `ja` | 언어 코드 | +| country | body | string | 아니오 | — | 국가 코드 (ISO 3166-1 alpha-2) | +| timezone | body | string | 아니오 | — | 타임존 식별자 | +| status | body | string | 아니오 | `active`, `inactive`, `blocked`, `withdrawn` | 계정 상태 (미지정 시 active) | +| homepage | body | string | 아니오 | max 255 | 홈페이지 URL | +| mobile | body | string | 아니오 | max 20 | 휴대전화 번호 | +| phone | body | string | 아니오 | max 20 | 전화번호 | +| zipcode | body | string | 아니오 | max 10 | 우편번호 | +| address | body | string | 아니오 | max 255 | 기본 주소 | +| address_detail | body | string | 아니오 | max 255 | 상세 주소 | +| signature | body | string | 아니오 | max 1000 | 서명 | +| bio | body | string | 아니오 | max 5000 | 자기소개 | +| admin_memo | body | string | 아니오 | max 5000 | 관리자 전용 메모 (해당 사용자에 대한 내부 기록, 사용자에게 노출 안 됨) | +| roles | body | array | 아니오 | min 1 | 부여할 역할 객체 배열 `[{id}]` (role_ids 와 병용 시 role_ids 우선) | +| role_ids | body | array | 아니오 | min 1 | role 식별자 배열 | +| notify_post_complete | body | boolean | 아니오 | — | 게시글 작성 완료 알림 수신 여부 (게시판 모듈 알림 설정) | +| notify_post_reply | body | boolean | 아니오 | — | 내 게시글에 답글이 달릴 때 알림 수신 여부 | +| notify_comment | body | boolean | 아니오 | — | 내 게시글에 댓글이 달릴 때 알림 수신 여부 | +| notify_reply_comment | body | boolean | 아니오 | — | 내 댓글에 대댓글이 달릴 때 알림 수신 여부 | +| email_subscription | body | boolean | 아니오 | — | 광고성 이메일 수신 동의 여부 (marketing 플러그인 채널 동의, marketing_consents 테이블에 저장) | +| marketing_consent | body | boolean | 아니오 | — | 마케팅 정보 수신 전체 동의 마스터 키 (marketing 플러그인이 검증 규칙 주입, 상세는 user-consent-injection.md) | +| third_party_consent | body | boolean | 아니오 | — | 제3자 정보 제공 동의 여부 (marketing 플러그인 법적 동의 항목) | +| info_disclosure | body | boolean | 아니오 | — | 개인정보 이용 안내 동의 여부 (marketing 플러그인 법적 동의 항목) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.user.update_validation_rules`). + +**요청 예시** + +```http +PUT /api/admin/users/a234c2b1-cde8-437f-b28b-23323be2b98d HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": "예시 이름", + "nickname": "예시 이름", + "email": "user@example.com", + "password": "Password123!", + "language": "ko", + "country": "KR", + "timezone": "Asia/Seoul", + "status": "active", + "homepage": "https://example.com", + "mobile": "010-1234-5678", + "phone": "010-1234-5678", + "zipcode": "06234", + "address": "서울특별시 강남구 테헤란로 1", + "address_detail": "서울특별시 강남구 테헤란로 1", + "signature": "예시값", + "bio": "예시 내용입니다.", + "admin_memo": "예시값", + "roles": [ + "예시값" + ], + "role_ids": [ + "예시값" + ], + "notify_post_complete": true, + "notify_post_reply": true, + "notify_comment": true, + "notify_reply_comment": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.users.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + +기존 사용자 정보를 수정합니다(경로 파라미터는 UUID 로 바인딩). `name`, `email` 은 필수이며 `email` 은 해당 사용자를 제외한 고유성 검사를 거친다. `password` 는 선택이며 값을 주면 8자 이상·`password_confirmation` 확인을 요구한다(미전송 시 기존 비밀번호 유지). 역할은 `roles` 또는 `role_ids` 중 하나로 지정하고 둘 다 오면 `role_ids` 가 우선한다. 성공 시 갱신된 사용자를 `UserResource` 로 반환한다. `notify_*`/`marketing_consent`/`third_party_consent`/`info_disclosure`/`email_subscription` 파라미터는 확장(sirsoft-marketing)이 검증 규칙을 주입한 필드로, 상세는 해당 확장 문서를 참조한다. + + +### GET /api/users/{user}/profile + +- **라우트명**: `api.public.users.profile` +- **컨트롤러**: `App\Http\Controllers\Api\Public\PublicProfileController@show` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| user | path | string | 예 | — | 대상 user의 식별자 | + +**요청 예시** + +```http +GET /api/users/a234c2b1-cde8-437f-b28b-23323be2b98d/profile HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| uuid | string | `a231747f-e82e-4cf2-9ae1-a261849dce40` | 외부 노출용 UUID (URL/API 식별자, 내부 id 비노출) | +| name | string | `API 문서 샘플 사용자` | 사용자 이름 | +| status | string | `active` | 계정 상태 (active: 활성, inactive: 비활성, blocked: 차단, withdrawn: 탈퇴) | +| status_label | string | `활성` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| avatar | null | `null` | 프로필 아바타 이미지 URL (미설정 시 null) | +| bio | string | `Tenetur omnis et amet omnis veniam to…` | 자기소개 | +| created_at | string | `2026-07-06` | 생성 일시 | +| is_withdrawn | boolean | `false` | withdrawn 여부 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "프로필 정보를 성공적으로 가져왔습니다.", + "data": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "status": "active", + "status_label": "활성", + "avatar": null, + "bio": "Ut magni et sunt ducimus error adipisci. Pariatur corporis voluptatem ratione quo non saepe. Illo atque praesentium possimus dolores qui est fugit. Sint fugiat numquam voluptates.", + "created_at": "2026-07-08", + "is_withdrawn": false + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + +타인의 공개 프로필을 인증 없이 조회합니다(경로 파라미터는 UUID 로 바인딩, `users/show.json` 이 소비). 사용자 상태에 따라 노출 필드가 달라진다 — active 는 name/avatar/bio/created_at 전체, inactive 는 bio 를 제외, blocked 는 avatar/bio/created_at 를 모두 제외한다. withdrawn 사용자는 이름을 익명 표기로 대체하고 `is_withdrawn=true` 로 반환하며, 미존재 사용자는 404(`user.not_found`)를 반환한다. 게시글 통계는 게시판 모듈 API 로 별도 조회한다. + + diff --git a/docs/backend/api/verify-password.md b/docs/backend/api/verify-password.md new file mode 100644 index 00000000..44d418dd --- /dev/null +++ b/docs/backend/api/verify-password.md @@ -0,0 +1,69 @@ +# Verify Password API 레퍼런스 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Verify Password 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### POST /api/me/verify-password + +- **라우트명**: `api.me.verify-password` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\ProfileController@verifyPassword` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| password | body | string | 예 | — | 비밀번호 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.verify_password_rules`). + +**요청 예시** + +```http +POST /api/me/verify-password HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "password": "Password123!" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +현재 로그인한 사용자의 비밀번호를 재확인한다. 민감한 작업 직전 본인 확인 게이트로 사용되며, 프론트의 `_password_verify_section.json` 이 호출한다. 요청의 `password` 를 `Hash::check` 로 저장된 해시와 대조해, 일치하면 성공 응답(`user.password_verified`)을, 틀리면 401(`user.password_incorrect`)을 반환한다. 비밀번호를 변경하지 않고 신원만 확인하므로 사용자 데이터는 바뀌지 않는다. + + diff --git a/docs/backend/routing.md b/docs/backend/routing.md index b1274e8c..b54825ac 100644 --- a/docs/backend/routing.md +++ b/docs/backend/routing.md @@ -68,7 +68,8 @@ | 코어 | `/admin/[기능명]` | `/admin/users` | | 모듈 | `/admin/[vendor-module]/[기능명]` | `/admin/sirsoft-ecommerce/products` | | 플러그인 | `/admin/[vendor-plugin]/[기능명]` | `/admin/sirsoft-payment/settings` | -| 공개 API | `/api/[vendor-module]/[기능명]` | `/api/sirsoft-ecommerce/products` | +| 모듈 공개 API | `/api/modules/[vendor-module]/[기능명]` | `/api/modules/sirsoft-ecommerce/products` | +| 플러그인 공개 API | `/api/plugins/[vendor-plugin]/[기능명]` | `/api/plugins/sirsoft-gdpr/consent` | ### 리소스 URL 규칙 @@ -149,32 +150,35 @@ sirsoft-ecommerce.products.delete ### 모듈 라우트 파일 ```php -// modules/sirsoft-ecommerce/src/routes/api.php +// modules/_bundled/sirsoft-ecommerce/src/routes/api.php use Illuminate\Support\Facades\Route; use Modules\Sirsoft\Ecommerce\Controllers\Api\Admin\ProductController; -Route::prefix('admin/sirsoft-ecommerce')->middleware(['auth:sanctum', 'admin'])->group(function () { - // 상품 관리 (권한 체크 포함) +// ModuleRouteServiceProvider 가 URL prefix('api/modules/sirsoft-ecommerce')와 +// name prefix('api.modules.sirsoft-ecommerce.')를 자동 적용한다. +// 라우트 파일 내부 group 에는 관리자 세그먼트('admin')만 두고, 접두는 중복 입력하지 않는다. +Route::prefix('admin')->middleware(['auth:sanctum', 'admin'])->group(function () { + // 상품 관리 (권한 체크 포함) → 최종 URL: /api/modules/sirsoft-ecommerce/admin/products Route::get('/products', [ProductController::class, 'index']) ->middleware('permission:sirsoft-ecommerce.products.view') - ->name('api.sirsoft-ecommerce.products.index'); + ->name('products.index'); // 최종 name: api.modules.sirsoft-ecommerce.products.index Route::post('/products', [ProductController::class, 'store']) ->middleware('permission:sirsoft-ecommerce.products.create') - ->name('api.sirsoft-ecommerce.products.store'); + ->name('products.store'); Route::get('/products/{id}', [ProductController::class, 'show']) ->middleware('permission:sirsoft-ecommerce.products.view') - ->name('api.sirsoft-ecommerce.products.show'); + ->name('products.show'); Route::put('/products/{id}', [ProductController::class, 'update']) ->middleware('permission:sirsoft-ecommerce.products.edit') - ->name('api.sirsoft-ecommerce.products.update'); + ->name('products.update'); Route::delete('/products/{id}', [ProductController::class, 'destroy']) ->middleware('permission:sirsoft-ecommerce.products.delete') - ->name('api.sirsoft-ecommerce.products.destroy'); + ->name('products.destroy'); }); ``` @@ -253,17 +257,20 @@ Route::middleware('auth:sanctum')->prefix('user')->group(function () { ### 공개 API 라우트 ```php -// modules/sirsoft-ecommerce/src/routes/api.php +// modules/_bundled/sirsoft-ecommerce/src/routes/api.php use Modules\Sirsoft\Ecommerce\Controllers\Api\Public\ProductController; -Route::prefix('api/sirsoft-ecommerce')->group(function () { - // 공개 상품 API (인증 불필요) - Route::get('/products', [ProductController::class, 'index']) - ->name('api.sirsoft-ecommerce.public.products.index'); +// ModuleRouteServiceProvider 가 URL prefix('api/modules/sirsoft-ecommerce')와 +// name prefix('api.modules.sirsoft-ecommerce.')를 자동 적용한다. +// 라우트 파일에서 prefix/name 접두를 중복 입력하지 않는다. +Route::prefix('products')->group(function () { + // 공개 상품 API (인증 불필요) → 최종 URL: /api/modules/sirsoft-ecommerce/products + Route::get('/', [ProductController::class, 'index']) + ->name('public.products.index'); // 최종 name: api.modules.sirsoft-ecommerce.public.products.index - Route::get('/products/{id}', [ProductController::class, 'show']) - ->name('api.sirsoft-ecommerce.public.products.show'); + Route::get('/{id}', [ProductController::class, 'show']) + ->name('public.products.show'); }); ``` diff --git a/docs/backend/service-provider.md b/docs/backend/service-provider.md index aefd901d..57f63a36 100644 --- a/docs/backend/service-provider.md +++ b/docs/backend/service-provider.md @@ -334,11 +334,14 @@ protected function loadModuleRoutes(): void ### 동작 표 -| 환경 | `installer_completed` | 동작 | -|------|----------------------|------| -| 프로덕션 (설치 완료) | `true` | 가드 통과 → `hasTable` 스킵 (쿼리 0건) | -| 인스톨러 실행 중 / 마이그레이션 전 | `false` (기본값) | 기존 `hasTable` 폴백 경로 (원본 동작 보존) | -| 테스트 (`.env.testing` 에 플래그 없음) | `false` | 기존 `hasTable` 경로 | +| 환경 | `installer_completed` | 마이그레이션 명령 | 동작 | +|------|----------------------|------------------|------| +| 프로덕션 (설치 완료, 웹 요청) | `true` | 아님 | 가드 통과 → `hasTable` 스킵 (쿼리 0건) | +| 설치 완료 `.env` 복사 + 빈 DB (마이그레이션 전) | `true` | **실행 중** | fast-path 무력화 → `hasTable` 폴백 (table not found 부팅 실패 방지) | +| 인스톨러 실행 중 / 마이그레이션 전 | `false` (기본값) | — | 기존 `hasTable` 폴백 경로 (원본 동작 보존) | +| 테스트 (`.env.testing` 에 플래그 없음) | `false` | — | 기존 `hasTable` 경로 | + +두 번째 행이 이 가드의 핵심 케이스다. `INSTALLER_COMPLETED=true` 가 적힌 `.env` 를 빈 DB 새 서버에 복사한 뒤 `php artisan migrate` 로 테이블을 만들기 **전** 에 앱이 부팅되면, fast-path 가 테이블 존재를 잘못 전제하여 뒤따르는 쿼리가 "table not found" 로 부팅을 깨뜨린다. 이 컨텍스트에서는 fast-path 를 신뢰하지 않고 실제 `hasTable` 검증 경로로 폴백해야 한다 (아래 "마이그레이션 컨텍스트 가드" 참조). ### 적용 가이드 @@ -346,6 +349,66 @@ protected function loadModuleRoutes(): void - **`hasTable` 폴백 경로는 반드시 유지** — `installer_completed=false` 환경에서도 안전하게 부팅되어야 함 - **try/catch 도 그대로 유지** — DB 연결 실패 시 안전한 스킵 계약 준수 - 가드는 hasTable 체크 **전체 블록을 `if (! config('app.installer_completed'))` 로 래핑** 하는 형태 +- **fast-path 이후 쿼리가 try/catch 로 보호되지 않는 지점은 마이그레이션 컨텍스트 가드를 함께 적용** (아래 참조). 예외로 보호되는 지점은 빈 DB 에서도 안전하므로 불필요 + +### 마이그레이션 컨텍스트 가드 + +`installer_completed` fast-path 는 테이블 존재를 전제한다. 그러나 `INSTALLER_COMPLETED=true` 가 적힌 `.env` 를 빈 DB 새 서버에 복사한 뒤 `php artisan migrate` 로 테이블을 만들기 **전** 에 앱이 부팅되면, fast-path 가 `true` 를 반환하고 곧이어 실제 쿼리(`Model::where()->pluck()` 등)가 실행되어 "table not found" 로 부팅이 실패한다. 마이그레이션을 실행하려는 부팅에서 그 명령이 오히려 실패하는 닭-달걀 상태다. + +이를 막기 위해, 스키마를 파괴/재생성하는 마이그레이션 계열 콘솔 명령 판정을 `App\Support\InstallerContext::isSchemaMutatingCommand()` 로 단일 SSoT 화하고, fast-path 지점에 가드로 결합한다. 명령 리스트: `migrate`, `migrate:fresh`, `migrate:refresh`, `migrate:rollback`, `migrate:reset`, `db:wipe`. + +```php +// App\Support\InstallerContext +public static function isSchemaMutatingCommand(): bool +{ + if (! app()->runningInConsole()) { + return false; + } + + return in_array( + $_SERVER['argv'][1] ?? null, + ['migrate', 'migrate:fresh', 'migrate:refresh', 'migrate:rollback', 'migrate:reset', 'db:wipe'], + true + ); +} +``` + +가드 결합 방식은 지점의 **논리 극성** 에 따라 다르다: + +- **Trait (fast-path 통과 조건)** — `&&` 로 결합. 마이그레이션 중이면 fast-path 를 건너뛰고 hasTable 폴백으로 진입. + + ```php + if (! InstallerContext::isSchemaMutatingCommand() && config('app.installer_completed')) { + return true; + } + ``` + +- **RouteServiceProvider (hasTable 검증 블록 진입 조건)** — `||` 로 결합. 마이그레이션 중이면 hasTable 검증 경로로 진입해 빈 DB 시 early return → 무방비 `pluck` 도달 차단. + + ```php + if (! config('app.installer_completed') || InstallerContext::isSchemaMutatingCommand()) { + try { + if (! Schema::hasTable('modules')) { + return; + } + // ... + } catch (\Exception) { + return; + } + } + ``` + +`isRegistryReady()` 처럼 fast-path 앞에서 조기 반환하는 지점은 `if (InstallerContext::isSchemaMutatingCommand()) { return false; }` 로 단순 게이트한다. + +#### 부류 A (가드 필수) vs 부류 B (불필요) + +| 부류 | 조건 | 예 | 조치 | +|------|------|-----|------| +| A | fast-path 이후 쿼리가 try/catch 로 **미보호** → 빈 DB 에서 부팅 실패 | `Caches{Module,Plugin,Template}Status`, `Module/PluginRouteServiceProvider` | 마이그레이션 컨텍스트 가드 적용 | +| B | fast-path 이후 쿼리가 try/catch(\Throwable) 로 **보호** → 예외를 삼켜 안전한 기본값 반환 | `NotificationHookListener`, `IdentityPolicyRepository::{listHookTargets,activeExtensionIdentifiers}` | 가드 불필요 (예외 흡수로 안전) | + +### config 캐시 재생성 주의 + - `config:cache` 는 config 소스를 변경하는 라이프사이클에서 `App\Support\ConfigCacheHelper::rebuild()` 로 자동 재생성된다 (아래 "config 캐시 자동 재생성" 참조). `.env` 를 직접 편집한 경우처럼 헬퍼를 거치지 않는 변경은 여전히 `php artisan config:clear && php artisan config:cache` 를 수동 실행한다 ### 확장 Trait 패턴 diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index 621254f3..905f9d48 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -253,6 +253,22 @@ php artisan seo:generate-sitemap # Sitemap 생성 (큐 디스패치) php artisan seo:generate-sitemap --sync # Sitemap 동기 생성 ``` +### API 문서 Artisan 커맨드 + +```bash +# API 레퍼런스 문서 생성/갱신 (실측 기반 스캐폴딩). 상세: docs/backend/api-documentation.md +php artisan api:docgen --scope=core # 코어 문서 생성 (범위: core|module:{id}|plugin:{id}|all) +php artisan api:docgen --scope=core --seed # 실측용 완전 샘플 시드 후 생성 (개발 환경 전용) +php artisan api:docgen --scope=core --base-url=https://example.com # 실측 기준 URL 지정 +php artisan api:docgen --scope=core --examples-only # 표·서술 불가침, 요청/응답 예시 블록만 in-place 삽입 +php artisan api:docgen --scope=core --check # 생성 없이 누락/drift 만 리포트 (하네스 소비) +php artisan api:docgen --scope=core --dry-run # 생성 대상 파일/엔드포인트 목록만 출력 + +# 파라미터/응답 필드 설명 TODO 셀 in-place 백필 (재생성 없이 공통 필드 자동 서술, 멱등) +php artisan api:docgen-backfill-params # 요청 파라미터 표 TODO 셀 백필 +php artisan api:docgen-backfill-fields # 응답 필드 표 TODO 셀 백필 +``` + --- ## 확장 업데이트 (CLI + API) diff --git a/docs/extension/module-routing.md b/docs/extension/module-routing.md index 3a6bfd08..fe763e57 100644 --- a/docs/extension/module-routing.md +++ b/docs/extension/module-routing.md @@ -7,8 +7,8 @@ ## TL;DR (5초 요약) ```text -1. URL prefix 자동: /api/admin/[vendor-module]/... -2. name prefix 자동: api.[vendor-module].... +1. URL prefix 자동: /api/modules/[vendor-module]/... +2. name prefix 자동: api.modules.[vendor-module].... 3. 활성화된 모듈만 라우트 등록됨 4. 프론트엔드: routes/admin.json, routes/user.json으로 admin/user 분기 5. 레거시 routes.json은 admin으로 폴백 (경고 로그 출력) diff --git a/docs/frontend/state-management.md b/docs/frontend/state-management.md index 43a66aa7..81fde685 100644 --- a/docs/frontend/state-management.md +++ b/docs/frontend/state-management.md @@ -185,6 +185,40 @@ G7이 자동으로 주입하는 `_global` 속성입니다. 레이아웃에서 - 상세 설명: [`docs/extension/plugin-development.md`](../extension/plugin-development.md) "폼 상태 정합성" 섹션 - 구현 참고: [`DynamicRenderer.tsx`](../../resources/js/core/template-engine/DynamicRenderer.tsx) `performStateUpdate` 상단 주석 (~50줄) +### `_localInit` 은 단일 슬롯이 아니다 (engine-v1.52.2+) + +데이터소스의 `initLocal` 이 만든 초기화 payload 는 `dataContext._localInit` 한 키로 전달된다. +생산부는 `updateTemplateData`(3개 write site), 소비부는 `DynamicRenderer` 의 `_localInit` useEffect 다. + +소비는 React commit **이후**에 일어난다. progressive 데이터소스는 응답이 오는 대로 각자 독립적으로 +`updateTemplateData({ _localInit })` 를 호출하므로, `initLocal` 을 가진 progressive 소스가 둘 이상이면 +두 호출이 같은 commit 사이에 들어올 수 있다. 이때 슬롯을 교체하면 먼저 도착한 payload 가 +한 번도 관측되지 않고 사라진다. + +`_localInit` 슬롯 병합과 `__g7LocalInitTracking` 레지스트리의 단일 소유자는 +[`localInitSlot.ts`](../../resources/js/core/template-engine/localInitSlot.ts) 다. + +```text +❌ updateTemplateData 에서 _localInit 을 얕은 스프레드(...data)로 교체 +❌ 소비 여부와 무관하게 _localInit 을 무조건 누적 병합 + (소비가 끝난 payload 가 refetchDataSource 시 재적용되어 사용자 폼 편집을 되돌린다) +❌ 소비 여부 판정을 위해 해시 계산식을 생산부에 복제 + (생산·소비 양쪽의 판정 기준이 어긋난다 — 슬롯 참조 비교를 쓴다) +❌ __g7LocalInitTracking 을 레이아웃 전환 시 리셋하지 않음 + (_local 은 비웠는데 추적 해시가 남아 새 레이아웃의 동일 payload 가 "이미 적용됨" 으로 건너뛰어진다) +``` + +```text +✅ 아직 관측되지 않은(unconsumed) 슬롯만 누적 병합 — mergeLocalInitSlot() +✅ 관측 시 슬롯 참조 기록 — markLocalInitConsumed(). 적용/건너뜀 여부와 무관하게 호출 +✅ 레이아웃 전환으로 _local 을 리셋할 때 추적 레지스트리도 함께 초기화 — resetLocalInitTracking() +✅ _forceLocalInit 은 한쪽에만 있으면 보존, 양쪽에 있으면 최신 타임스탬프 (refetchOnMount 강제 초기화 유실 방지) +✅ 소비부는 A(setLocalDynamicState)/B(_globalSetState) 양쪽을 갱신 — 이 대칭을 깨지 않는다 +``` + +> 이 결함은 정상 CPU 에서 간헐적이라 놓치기 쉽다. CPU 스로틀링(6배 이상)으로 commit 을 지연시켜야 +> 경합 창이 결정적으로 열린다. + ### 라이프사이클 (SPA 네비게이션) SPA navigate 시 `_local` 상태는 **레이아웃 이름 기준으로 선택적 초기화**됩니다: diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md index a007235b..00bebad6 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.2] - 2026-07-10 + +### Removed + +- 배송설정의 배송가능 국가 추가 화면이 설치된 언어 전체에 대해 국가명 입력칸을 자동으로 만들도록 바뀌면서, 더 이상 쓰이지 않게 된 "한국어명"·"영문명" 라벨과 입력 예시 문구를 제거했습니다. + ## [1.0.1] - 2026-07-08 ### Added diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json index 747448fe..29a74807 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json @@ -280,10 +280,6 @@ "country_code": "国コード", "country_code_placeholder": "例:KR", "country_name": "国名", - "country_name_ko": "日本語名", - "name_ko_placeholder": "例:日本", - "country_name_en": "英文名", - "name_en_placeholder": "例:Japan", "is_active": "使用", "actions": "管理", "delete": "削除", diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json index bb1e5257..814f617c 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)", "ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)" }, - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "scope": "module", "target_identifier": "sirsoft-ecommerce", diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/CHANGELOG.md index b3ce82a0..25ed116e 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [Unreleased] + +### Added + +- 주문 설정 화면의 KG 이니시스 테스트모드 경고(`kginicis_test_mode_status`, `test_mode_settings_warning_*`) 일본어 번역 추가 — 테스트결제 상태 안내와 실결제 설정 이동 버튼이 일본어 로케일에서 자연스럽게 표시됩니다. + ## [1.0.0] - 2026-07-01 ### Added diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/frontend/ja.json b/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/frontend/ja.json index 50ceec99..d04eaa3d 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/frontend/ja.json +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/frontend/ja.json @@ -4,6 +4,7 @@ "kginicis_cbt_reconciliation": "海外決済精算", "kginicis_escrow_delivery": "エスクロー配送", "kginicis_status": "決済ステータス", + "kginicis_test_mode_status": "テストモード状態", "kginicis_test_map": "テスト決済マップ", "settings": "決済設定", "vbank_info": "仮想口座情報" @@ -74,6 +75,10 @@ "test_mode_badge": "テスト決済", "test_mode_warning_badge": "テスト決済注意事項", "test_mode_order_notice": "この注文はテスト決済として処理されました。実際のカード承認・出金通知が発生する可能性がありますが、KGイニシスのテスト取引は毎日23:00~23:50の間に自動キャンセルされる可能性があります。絶対に配送しないでください。", + "test_mode_settings_warning_plugin": "KGイニシス", + "test_mode_settings_warning_title": "(注意)KGイニシス決済設定が現在テスト決済になっています。", + "test_mode_settings_warning_body": "テスト決済では実際の決済は発生しません。ショップ運営中の場合は必ず実決済に設定して運営してください。", + "test_mode_settings_warning_action": "実決済に設定", "order_verify_title": "KGイニシス決済検証", "order_verify_description": "KGイニシスサーバーからリアルタイムで決済ステータスを照会します。", "order_verify_button": "決済照会", diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/CHANGELOG.md new file mode 100644 index 00000000..5d8e4a68 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +이 언어팩의 모든 주요 변경사항을 기록합니다. +형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, +[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. + +## [1.0.1] + +### Added + +- 환불 진행 중 안내(`refund.in_progress`)와 KCP CLI 바이너리 누락·실행 권한 부족 안내(`errors.cli_binary_missing`, `errors.cli_binary_not_executable`) 일본어 번역 추가 — 환불 중복 요청·CLI 실행 환경 오류 안내가 일본어 로케일에서 자연스럽게 표시됩니다. +- 주문 설정 화면의 NHN KCP 테스트모드 경고(`nhnkcp_test_mode_status`, `test_mode_settings_warning_*`) 일본어 번역 추가 — 테스트결제 상태 안내와 실결제 설정 이동 버튼이 일본어 로케일에서 자연스럽게 표시됩니다. diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/activity_log.php b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/activity_log.php new file mode 100644 index 00000000..eec5866c --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/activity_log.php @@ -0,0 +1,10 @@ + [ + 'cancel' => 'PG決済キャンセル', + ], + 'description' => [ + 'payment_cancel' => 'NHN KCP決済キャンセルが確認されました(注文 :order_number、返金金額 :refund_amount円)。', + ], +]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/messages.php new file mode 100644 index 00000000..e520bb91 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/messages.php @@ -0,0 +1,16 @@ + [ + 'missing_tid' => '取引番号(tno)がないため返金を進めることができません。', + 'default_reason' => '購入者返金リクエスト', + 'in_progress' => 'NHN KCP 返金はすでに処理中です。', + ], + 'errors' => [ + 'wsdl_missing' => 'KCP WSDL ファイルがありません: :file', + 'approval_key_error' => 'KCP 承認キーエラー [:code]: :message', + 'soap_error' => 'KCP SOAP 連携エラー: :message', + 'cli_binary_missing' => 'KCP CLIバイナリ不足: :path', + 'cli_binary_not_executable' => 'KCP CLIバイナリ実行権限不足 — 管理者の対応が必要です (sudo chmod 755 :path)', + ], +]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/payment_methods.php b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/payment_methods.php new file mode 100644 index 00000000..694f9498 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/payment_methods.php @@ -0,0 +1,24 @@ + [ + 'name' => 'PAYCO', + 'description' => 'PAYCOで決済(NHN KCP)', + ], + 'naverpay' => [ + 'name' => 'ネイバーペイ(カード)', + 'description' => 'ネイバーペイクレジットカードで決済(NHN KCP)', + ], + 'naverpay_point' => [ + 'name' => 'ネイバーペイ(ポイント)', + 'description' => 'ネイバーペイマネー/ポイントで決済(NHN KCP)', + ], + 'kakaopay' => [ + 'name' => 'カカオペイ', + 'description' => 'カカオペイで決済(NHN KCP)', + ], + 'applepay' => [ + 'name' => 'アップルペイ', + 'description' => 'アップルペイで決済(NHN KCP)·アップルペイはiOSデバイスのモバイル決済のみ利用可能です。', + ], +]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/provider.php b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/provider.php new file mode 100644 index 00000000..29ebc927 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/provider.php @@ -0,0 +1,6 @@ + 'NHN KCP', + 'description' => 'NHN KCP決済ゲートウェイ(Standard Pay連動)— カード·口座振替·仮想口座·携帯電話決済対応', +]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/frontend/ja.json b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/frontend/ja.json new file mode 100644 index 00000000..52e22b53 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/frontend/ja.json @@ -0,0 +1,141 @@ +{ + "editor": { + "data_source": { + "health": "連携状態", + "kcp_escrow_delivery": "エスクロー配送", + "kcp_status": "決済状態", + "nhnkcp_easy_pay_config": "簡便決済設定", + "nhnkcp_test_mode_status": "テストモード状態", + "nhnkcp_test_map": "テスト決済マップ", + "nicepay_test_map": "テスト決済マップ", + "settings": "決済設定", + "vbank_info": "仮想口座情報" + } + }, + "messages": { + "refund": { + "missing_tid": "決済取引番号(tno)がありません。", + "default_reason": "管理者返金" + }, + "payment": { + "auth_failed": "KCP決済認証に失敗しました。", + "order_not_found": "注文が見つかりません。", + "amount_mismatch": "決済金額が一致しません。", + "confirm_failed": "KCP取引照会に失敗しました。" + } + }, + "payment_error": { + "title": "決済エラー", + "message": "決済処理中にエラーが発生しました。" + }, + "user": { + "test_mode_badge": "テスト決済", + "payment_type_label": "決済方法", + "receipt_label": "領収書", + "receipt_view": "領収書照会", + "cash_receipt_view": "現金領収書照会", + "vbank_title": "仮想口座入金案内", + "vbank_bank_name": "銀行", + "vbank_account": "口座番号", + "vbank_holder": "預金者名", + "vbank_due": "入金期限", + "vbank_notice": "入金期限内に入金いただかない場合、注文は自動キャンセルされます。", + "tax_breakdown_title": "税金詳細", + "supply_amount": "供給価額", + "vat_amount": "付加価値税", + "tax_free_amount": "非課税金額", + "escrow_badge": "エスクロー" + }, + "admin": { + "test_mode_badge": "テスト決済", + "test_mode_order_notice": "この注文はテスト決済として処理されました。", + "test_mode_settings_warning_plugin": "NHN KCP", + "test_mode_settings_warning_title": "(注意)NHN KCP決済設定が現在テスト決済になっています。", + "test_mode_settings_warning_body": "テスト決済では実際の決済は発生しません。ショップ運営中の場合は必ず実決済に設定して運営してください。", + "test_mode_settings_warning_action": "実決済に設定", + "order_info_title": "NHN KCP決済情報", + "order_info_description": "DBに保存されたKCP決済情報を表示します。", + "pay_status_approved": "承認", + "pay_status_cancelled": "キャンセル", + "pay_status_unknown": "未確認", + "result_tno": "取引番号 (TNO)", + "result_app_no": "承認番号", + "result_app_time": "承認日時", + "result_pay_method": "決済方法", + "result_res_cd": "応答コード", + "escrow_badge": "エスクロー", + "escrow_delivery_title": "KCPエスクロー配送登録", + "escrow_delivery_description": "商品発送後、KCPに送り状番号を登録してください。", + "escrow_delivery_invoice_label": "送り状番号", + "escrow_delivery_invoice_placeholder": "送り状番号入力", + "escrow_delivery_courier_label": "配送業者", + "escrow_delivery_courier_placeholder": "配送業者選択", + "escrow_delivery_already_registered": "配送登録完了", + "escrow_delivery_registered_at": "登録日時", + "escrow_delivery_invoice_info": "送り状番号", + "escrow_delivery_courier_info": "配送業者", + "escrow_delivery_register_button": "配送登録", + "escrow_delivery_change_button": "配送情報編集", + "escrow_delivery_registering": "登録中...", + "escrow_delivery_success": "配送情報がKCPに登録されました。", + "escrow_delivery_error": "配送登録に失敗しました。" + }, + "settings": { + "title": "NHN KCP設定", + "description": "NHN KCP決済プラグイン設定を管理します。", + "test_mode": "テストモード", + "test_mode_hint": "テストモードでは実際の決済は発生しません。", + "live_mode_warning_title": "ライブモード有効化", + "live_mode_warning_body": "実際の決済が発生します。ライブサイトコードおよびキーを正確に設定したことを確認してください。", + "section_test_keys": "テスト認証情報", + "test_site_cd": "テストサイトコード (site_cd)", + "test_site_cd_hint": "KCPから発行されたテストサイトコード (デフォルト値: T0000)", + "test_site_key": "テストサイトキー (site_key)", + "test_site_key_hint": "KCPから発行されたテストサイトキー", + "section_live_keys": "ライブ認証情報", + "live_site_cd": "ライブサイトコード (site_cd)", + "live_site_cd_hint": "KCPから発行されたライブサイトコード (SRプレフィックスを除外して入力)", + "live_site_cd_example": "例: xxxxxx → 実際に使用するsite_cdはSRxxxxxx", + "live_site_key": "ライブサイトキー (site_key)", + "live_site_key_hint": "外部に暴露されないよう注意してください。", + "section_easy_pay": "簡便決済設定", + "section_easy_pay_hint": "使用する簡便決済サービスを選択してください。KCP加盟店契約が完了したサービスのみ有効化してください。", + "easy_pay_allow_with_other_pg": "他のPGで使用可能です", + "easy_pay_allow_with_other_pg_hint": "有効化すると、他のPG事業者が基本決済方法として設定されている場合でも、チェックアウトページにNHN KCP簡便決済ボタンが表示されます。", + "easy_pay_payco": "PAYCO (ペイコ)", + "easy_pay_naverpay": "ネイバーペイ", + "easy_pay_naverpay_hint": "テストモードでは決済不可", + "easy_pay_naverpay_point": "ネイバーペイポイント決済", + "easy_pay_naverpay_point_hint": "KCP加盟店ではネイバーペイポイント決済の契約が必要です。[DR02]エラーが発生した場合は無効化してKCPにお問い合わせください。", + "easy_pay_kakaopay": "カカオペイ", + "easy_pay_kakaopay_hint": "テストモードでは決済不可", + "easy_pay_applepay": "アップルペイ", + "easy_pay_applepay_hint": "iOS Safariでのみ使用可能です", + "section_redirect": "リダイレクトURL", + "redirect_success_url": "決済成功リダイレクトURL", + "redirect_success_url_hint": "相対パス(/shop/...)または完全URLの両方が可能です。{orderId}は注文番号に自動置換されます。", + "redirect_fail_url": "決済失敗リダイレクトURL", + "redirect_fail_url_hint": "相対パスまたは完全URLの両方が可能です。エラー情報はクエリパラメータとして自動追加されます。", + "section_vbank": "仮想口座設定", + "section_vbank_hint": "仮想口座決済に関する設定です。", + "vbank_expire_days": "入金有効期限", + "vbank_expire_days_hint": "仮想口座発行後、入金可能な期限(1~7日)。", + "vbank_expire_days_unit": "日", + "section_vbank_notify": "仮想口座入金通知URL", + "vbank_notify_hint": "NHN KCP加盟店管理画面でこのURLを入金通知URLとして登録する必要があり、仮想口座決済が自動的に処理されます。", + "vbank_notify_copied": "URLがクリップボードにコピーされました。", + "copy": "コピー", + "section_escrow": "エスクロー設定", + "section_escrow_hint": "エスクロー決済を使用すると、購入者が購入確認後、販売者に決済金額が支払われます。", + "use_escrow": "エスクロー決済を使用", + "use_escrow_hint": "有効化するとすべての決済にエスクローが適用されます。", + "escrow_test_site_cd": "エスクロー テストサイトコード", + "escrow_test_site_cd_hint": "KCPエスクロー テスト用site_cd(デフォルト:T0007)", + "section_escrow_notify": "エスクロー共通通知URL", + "escrow_common_notify_hint": "NHN KCP加盟店管理画面でこのURLを共通通知URLとして登録する必要があり、購入確認·キャンセルが自動的に処理されます。", + "escrow_common_notify_copied": "URLがクリップボードにコピーされました。" + }, + "easy_pay": { + "section_title": "簡便決済" + } +} diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/language-pack.json b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/language-pack.json new file mode 100644 index 00000000..4285e495 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/language-pack.json @@ -0,0 +1,30 @@ +{ + "identifier": "g7-plugin-sirsoft-pay_nhnkcp-ja", + "namespace": "g7", + "vendor": "sirsoft", + "name": { + "ko": "G7 플러그인 (sirsoft-pay_nhnkcp) 일본어 언어팩", + "en": "G7 plugin (sirsoft-pay_nhnkcp) Japanese language pack", + "ja": "G7 プラグイン (sirsoft-pay_nhnkcp) 日本語 言語パック" + }, + "description": { + "ko": "G7 플러그인 (sirsoft-pay_nhnkcp) 일본어 언어팩 (번들)", + "en": "G7 plugin (sirsoft-pay_nhnkcp) Japanese language pack (bundled)", + "ja": "G7 プラグイン (sirsoft-pay_nhnkcp) 日本語 言語パック(バンドル)" + }, + "version": "1.0.1", + "license": "MIT", + "scope": "plugin", + "target_identifier": "sirsoft-pay_nhnkcp", + "locale": "ja", + "locale_name": "Japanese", + "locale_native_name": "日本語", + "text_direction": "ltr", + "g7_version": ">=7.0.0-beta.4", + "requires": { + "target_version": null, + "depends_on_core_locale": true + }, + "github_url": "", + "github_changelog_url": "" +} diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/seed/manifest.json b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/seed/manifest.json new file mode 100644 index 00000000..d3a31272 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/seed/manifest.json @@ -0,0 +1,4 @@ +{ + "name": "NHN KCP", + "description": "NHN KCP 決済ゲートウェイ (Standard Pay 連携)" +} diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/CHANGELOG.md new file mode 100644 index 00000000..a59ecfb2 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +이 언어팩의 모든 주요 변경사항을 기록합니다. +형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, +[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. + +## [1.0.1] + +### Added + +- 가상계좌 환불 검증 안내(`errors.vbank_refund_*` — 환불 대상 주문 없음·환불 불가 결제·환불 금액 불일치·환불 처리 진행 중) 일본어 번역 추가 — 관리자 가상계좌 환불 요청 검증 실패 안내가 일본어 로케일에서 자연스럽게 표시됩니다. +- 주문 설정 화면의 나이스페이먼츠 테스트모드 경고(`nicepay_test_mode_status`, `test_mode_settings_warning_*`) 일본어 번역 추가 — 테스트결제 상태 안내와 실결제 설정 이동 버튼이 일본어 로케일에서 자연스럽게 표시됩니다. diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/activity_log.php b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/activity_log.php new file mode 100644 index 00000000..20112d44 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/activity_log.php @@ -0,0 +1,10 @@ + [ + 'cancel' => 'PG決済キャンセル', + ], + 'description' => [ + 'payment_cancel' => 'NICEペイメンツ決済キャンセルが確認されました (注文 :order_number、返金金額 :refund_amount円)。', + ], +]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/messages.php new file mode 100644 index 00000000..46f959b1 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/messages.php @@ -0,0 +1,24 @@ + [ + 'tid_required' => 'TIDを入力してください。', + 'order_not_found' => '注文が見つかりません。', + 'invalid_request' => '無効なリクエストです。', + 'invalid_amount' => 'リクエスト金額が有効ではありません。', + 'vbank_refund_required_fields' => 'TID、注文番号、返金金額、返金口座情報(口座番号·銀行コード·口座名義人)をすべて入力してください。', + 'vbank_completed_requires_bank_info' => '仮想口座入金完了の場合は返金口座情報が必要です。管理者APIを通じて返金を進めてください。', + 'invalid_refund_amount' => '返金金額が有効ではありません。(リクエスト::amount円)', + 'vbank_refund_order_not_found' => '返金する注文が見つかりません。', + 'vbank_refund_invalid_payment' => '返金可能なNICEペイメンツ仮想口座入金完了決済ではありません。', + 'vbank_refund_amount_mismatch' => '要求された返金金額がDBのキャンセル可能金額と一致していません。', + 'vbank_refund_already_processing' => '既に仮想口座返金処理が進行中です。', + ], + 'refund' => [ + 'missing_tid' => '取引ID(TID)がないため返金を進めることができません。', + 'default_reason' => '購入者返金リクエスト', + ], + 'defaults' => [ + 'vbank_refund_msg' => '仮想口座返金', + ], +]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/payment_methods.php b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/payment_methods.php new file mode 100644 index 00000000..daa59f28 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/payment_methods.php @@ -0,0 +1,36 @@ + [ + 'name' => 'Naver Pay (ナイスペイメンツ)', + 'description' => 'Naver Payで決済 (ナイスペイメンツ)', + ], + 'kakaopay' => [ + 'name' => 'KakaoPay (ナイスペイメンツ)', + 'description' => 'KakaoPayで決済 (ナイスペイメンツ)', + ], + 'samsungpay' => [ + 'name' => 'Samsung Pay (ナイスペイメンツ)', + 'description' => 'Samsung Payで決済 (ナイスペイメンツ)', + ], + 'applepay' => [ + 'name' => 'Apple Pay (ナイスペイメンツ)', + 'description' => 'Apple Payで決済 (ナイスペイメンツ)', + ], + 'payco' => [ + 'name' => 'PAYCO (ナイスペイメンツ)', + 'description' => 'PAYCOで決済 (ナイスペイメンツ)', + ], + 'skpay' => [ + 'name' => '11pay (ナイスペイメンツ)', + 'description' => '11payで決済 (ナイスペイメンツ)', + ], + 'ssgpay' => [ + 'name' => 'SSG Pay (ナイスペイメンツ)', + 'description' => 'SSG Payで決済 (ナイスペイメンツ)', + ], + 'lpay' => [ + 'name' => 'L.pay (ナイスペイメンツ)', + 'description' => 'L.payで決済 (ナイスペイメンツ)', + ], +]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/provider.php b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/provider.php new file mode 100644 index 00000000..c260ef55 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/provider.php @@ -0,0 +1,6 @@ + 'ナイスペイメンツ', + 'description' => '国内PG企業ナイスペイメンツ決済連携(カード/口座振替/仮想口座/携帯電話 + 7種簡便決済)', +]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/frontend/ja.json b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/frontend/ja.json new file mode 100644 index 00000000..50cb2574 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/frontend/ja.json @@ -0,0 +1,154 @@ +{ + "editor": { + "data_source": { + "nicepay_easy_pay_config": "簡易決済設定", + "nicepay_escrow": "エスクロー", + "nicepay_status": "決済ステータス", + "nicepay_test_mode_status": "テストモード状態", + "nicepay_test_map": "テスト決済マップ", + "settings": "決済設定", + "vbank_info": "仮想口座情報" + } + }, + "settings": { + "title": "NicePayments設定", + "description": "NicePayments PG連動設定", + "test_mode": "テストモード", + "test_mode_hint": "テストモードでも実際のカード承認・出金通知が発生する場合があり、テストアカウント決済は当日23時30分頃一括自動キャンセルされます。", + "section_test_keys": "テストキー設定", + "test_mid": "テスト加盟店ID(MID)", + "test_mid_hint": "NicePayments公用テストMIDです。テスト専用であり、変更する必要はありません。", + "test_merchant_key": "テスト加盟店キー", + "test_merchant_key_hint": "NicePayments公用テストキーです。テスト専用であり、変更する必要はありません。", + "section_live_keys": "ライブキー設定", + "live_mid": "ライブ加盟店ID(MID)", + "live_mid_hint": "NicePayments店舗管理者 > 加盟店情報 > KEY管理から確認。「SR」を除いた残りだけを入力してください。", + "live_mid_example": "例)店舗IDがSRpaytestmの場合、paytestmのみを入力 → 完成:SRpaytestm", + "live_merchant_key": "ライブ加盟店キー", + "live_merchant_key_hint": "外部に公開されないよう注意してください。", + "live_mode_warning_title": "ライブモード有効化", + "live_mode_warning_body": "現在、実際の決済が処理されています。ライブMIDと加盟店キーが正しく設定されていることを確認してください。", + "section_vbank_notify": "仮想口座入金通知URL", + "vbank_notify_hint": "仮想口座使用時は、以下のURLをNicePayments加盟店管理者 → 加盟店情報設定に登録してください。入金が完了するとこのアドレスに自動通知されます。", + "vbank_notify_copied": "URLがクリップボードにコピーされました。", + "copy": "コピー", + "section_redirect": "リダイレクトURL", + "redirect_success_url": "決済成功リダイレクトURL", + "redirect_success_url_hint": "相対パス(/shop/...)または完全URL(https://...)の両方が可能です。{orderId}は注文番号に自動置換されます。", + "redirect_fail_url": "決済失敗リダイレクトURL", + "redirect_fail_url_hint": "相対パスまたは完全URL両方が可能です。エラー情報はクエリパラメーターとして自動追加されます。", + "use_escrow": "エスクロー決済使用", + "use_escrow_hint": "実物商品販売時に購入者保護のためのエスクロー決済を有効化します。エスクロー決済後の配送登録が必要です。", + "section_easy_pay": "簡易決済設定", + "section_easy_pay_hint": "使用する簡易決済サービスを選択してください。NicePayments加盟店契約が完了したサービスのみ有効化してください。", + "easy_pay_naverpay": "NaverPay", + "easy_pay_naverpay_hint": "テストモードでも使用可能", + "easy_pay_kakaopay": "KakaoPay", + "easy_pay_samsungpay": "Samsung Pay", + "easy_pay_applepay": "Apple Pay", + "easy_pay_applepay_hint": "SafariおよびAppleデバイスでのみ使用可能", + "easy_pay_payco": "PAYCO", + "easy_pay_skpay": "11pay(SKペイ)", + "easy_pay_ssgpay": "SSGペイ", + "easy_pay_lpay": "L.pay", + "easy_pay_allow_with_other_pg": "他のPGと使用可能", + "easy_pay_allow_with_other_pg_hint": "有効化すると、他のPG企業が基本決済手段として設定されている場合でも、チェックアウトページにNicePay簡易決済ボタンが表示されます。" + }, + "admin": { + "transaction_query_title": "TID取引照会", + "transaction_query_description": "NicePayments取引ID(TID)で決済ステータスを直接照会します。", + "transaction_tid_label": "取引ID(TID)", + "transaction_query_button": "照会", + "transaction_query_error": "照会失敗", + "transaction_result_title": "照会結果", + "result_tid": "取引ID(TID)", + "result_order_id": "注文番号(Moid)", + "result_amount": "決済金額", + "result_status": "決済ステータス", + "result_method": "決済手段", + "result_auth_code": "承認番号", + "result_auth_date": "承認日時", + "result_result_code": "結果コード", + "order_verify_title": "NicePayments決済検証", + "order_verify_description": "NicePaymentsサーバーからリアルタイムで決済ステータスを照会します。", + "order_verify_button": "決済照会", + "order_verify_error": "照会失敗", + "pay_status_approved": "承認", + "pay_status_cancelled": "キャンセル", + "pay_status_unknown": "未確認", + "vbank_noti_title": "仮想口座入金通知履歴", + "vbank_noti_description": "NicePay入金通知受信記録(自動蓄積)", + "vbank_noti_count": "通知回数", + "vbank_noti_first_at": "初回通知受信", + "vbank_noti_last_at": "最終通知受信", + "vbank_noti_deposited_at": "入金完了通知時刻", + "vbank_noti_cancelled_at": "入金キャンセル通知時刻", + "vbank_noti_depositor": "入金者名(最終)", + "vbank_noti_history": "通知履歴(時間順)", + "vbank_noti_type_deposited": "入金完了", + "vbank_noti_type_cancelled": "入金キャンセル", + "vbank_noti_type_other": "その他の通知", + "vbank_noti_query_button": "通知履歴照会", + "vbank_noti_loading": "閲覧中...", + "vbank_noti_not_loaded": "閲覧ボタンをクリックすると、NicePay入金通知履歴を確認できます。", + "vbank_noti_empty": "受信された入金通知がありません。", + "vbank_noti_load_error": "入金通知履歴閲覧中にエラーが発生しました。", + "escrow_badge": "エスクロー", + "result_escrow": "エスクロー有無", + "escrow_yes": "エスクロー決済", + "escrow_no": "通常決済", + "escrow_payment_notice": "エスクロー決済です。配送登録後、購入者の購入確定が必要です。", + "escrow_delivery_title": "エスクロー配送登録", + "escrow_delivery_company": "配送業者名", + "escrow_delivery_company_placeholder": "例:CJ大韓通運", + "escrow_tracking_number": "送り状番号", + "escrow_tracking_number_placeholder": "送り状番号を入力してください", + "escrow_buyer_address": "配送先住所", + "escrow_register_name": "登録者名", + "escrow_register_button": "配送登録", + "escrow_register_success": "エスクロー配送が登録されました。", + "escrow_register_error": "エスクロー配送登録に失敗しました。", + "test_mode_badge": "テスト決済", + "test_mode_order_notice": "テスト決済として処理された注文です。実際のカード承認・出金通知が発生する可能性がありますが、NicePaymentsテストアカウント決済は当日23時30分頃一括自動キャンセルされます。配送前に運営決済の有無をご確認ください。", + "test_mode_settings_warning_plugin": "NicePayments", + "test_mode_settings_warning_title": "(注意)NicePayments決済設定が現在テスト決済になっています。", + "test_mode_settings_warning_body": "テスト決済では実際の決済は発生しません。ショップ運営中の場合は必ず実決済に設定して運営してください。", + "test_mode_settings_warning_action": "実決済に設定", + "test_mode_orders_in_list": "テスト決済注文を含む" + }, + "easy_pay": { + "section_title": "簡便決済" + }, + "user": { + "receipt_label": "領収書", + "view_receipt": "領収書を見る", + "view_cash_receipt": "現金領収書照会", + "easy_pay_label": "簡便決済サービス", + "test_mode_badge": "テスト決済", + "tax_breakdown_title": "税金詳細", + "supply_amount": "供給価額", + "vat_amount": "付加価値税", + "tax_free_amount": "非課税金額", + "vbank_deposit_info": "仮想口座入金ご案内", + "vbank_bank": "銀行", + "vbank_account": "口座番号", + "vbank_holder": "口座名義人", + "vbank_amount": "入金金額", + "vbank_due": "入金期限", + "vbank_notice": "入金期限内に入金が完了しない場合、注文は自動キャンセルされます。" + }, + "messages": { + "refund": { + "missing_tid": "決済取引番号(TID)がありません。", + "default_reason": "管理者返金" + }, + "payment": { + "auth_failed": "決済認証に失敗しました。", + "mid_mismatch": "加盟店IDが一致しません。", + "signature_mismatch": "決済署名検証に失敗しました。", + "order_not_found": "注文が見つかりません。", + "amount_mismatch": "決済金額が一致しません。", + "authorize_failed": "決済承認に失敗しました。" + } + } +} diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/language-pack.json b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/language-pack.json new file mode 100644 index 00000000..bbe7abca --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/language-pack.json @@ -0,0 +1,30 @@ +{ + "identifier": "g7-plugin-sirsoft-pay_nicepayments-ja", + "namespace": "g7", + "vendor": "sirsoft", + "name": { + "ko": "G7 플러그인 (sirsoft-pay_nicepayments) 일본어 언어팩", + "en": "G7 plugin (sirsoft-pay_nicepayments) Japanese language pack", + "ja": "G7 プラグイン (sirsoft-pay_nicepayments) 日本語 言語パック" + }, + "description": { + "ko": "G7 플러그인 (sirsoft-pay_nicepayments) 일본어 언어팩 (번들)", + "en": "G7 plugin (sirsoft-pay_nicepayments) Japanese language pack (bundled)", + "ja": "G7 プラグイン (sirsoft-pay_nicepayments) 日本語 言語パック(バンドル)" + }, + "version": "1.0.1", + "license": "MIT", + "scope": "plugin", + "target_identifier": "sirsoft-pay_nicepayments", + "locale": "ja", + "locale_name": "Japanese", + "locale_native_name": "日本語", + "text_direction": "ltr", + "g7_version": ">=7.0.0-beta.4", + "requires": { + "target_version": null, + "depends_on_core_locale": true + }, + "github_url": "", + "github_changelog_url": "" +} diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/seed/manifest.json b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/seed/manifest.json new file mode 100644 index 00000000..0113a5f9 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/seed/manifest.json @@ -0,0 +1,4 @@ +{ + "name": "NICE Payments", + "description": "NICE Payments 決済ゲートウェイ (統合決済ウィンドウ連携)" +} diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md index 32eb5df4..36adde52 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.2] - 2026-07-10 + +### Added + +- 레이아웃 편집기의 페이지 상태 선택 항목(기본 / 모바일 메뉴 펼침) 일본어 번역 추가 — 관리자 베이스 레이아웃을 편집할 때 모바일 메뉴가 펼쳐진 상태를 미리 보며 편집할 수 있고, 그 선택지가 일본어 로케일에서도 올바르게 표시됩니다. + ## [1.0.1] - 2026-07-08 ### Added diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/editor.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/editor.json index 968f8074..68e0fe21 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/editor.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/editor.json @@ -1393,7 +1393,9 @@ "page_detail_default": "基本", "page_detail_attachments_open": "添付を展開", "order_detail_default": "通常注文", - "order_detail_partially_cancelled": "部分キャンセル" + "order_detail_partially_cancelled": "部分キャンセル", + "admin_base_default": "基本", + "sidebar_open": "モバイルメニュー展開" }, "cell_border": { "side_all": "すべて", diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json index fd8cc968..20e88edb 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 template (sirsoft-admin_basic) Japanese language pack (bundled)", "ja": "G7 テンプレート (sirsoft-admin_basic) 日本語 言語パック(バンドル)" }, - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "scope": "template", "target_identifier": "sirsoft-admin_basic", diff --git a/modules/_bundled/gnuboard7-hello_module/docs/api/README.md b/modules/_bundled/gnuboard7-hello_module/docs/api/README.md new file mode 100644 index 00000000..2bf8b841 --- /dev/null +++ b/modules/_bundled/gnuboard7-hello_module/docs/api/README.md @@ -0,0 +1,14 @@ +# API 레퍼런스 문서 목차 + +> **소유**: 모듈 `gnuboard7-hello_module` · **생성**: `php artisan api:docgen` (실측 기반). +> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. + + +- **문서 수**: 1 · **엔드포인트 수**: 2 + +| 문서 | 도메인 | 엔드포인트 | +| --- | --- | --- | +| [memos.md](memos.md) | `memos` | 2 | + + + diff --git a/modules/_bundled/gnuboard7-hello_module/docs/api/memos.md b/modules/_bundled/gnuboard7-hello_module/docs/api/memos.md new file mode 100644 index 00000000..286c241d --- /dev/null +++ b/modules/_bundled/gnuboard7-hello_module/docs/api/memos.md @@ -0,0 +1,135 @@ +# Memos API 레퍼런스 + +> **소유**: module `gnuboard7-hello_module` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Memos 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/gnuboard7-hello_module/memos + +- **라우트명**: `api.modules.gnuboard7-hello_module.memos.index` +- **컨트롤러**: `Modules\Gnuboard7\HelloModule\Http\Controllers\Api\MemoController@index` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/gnuboard7-hello_module/memos HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**에러 응답** + +_대표 에러 없음 (공개 조회). _ + + + +**설명** + +메모 목록을 페이지네이션으로 조회하는 공개 엔드포인트다. 라우트는 `optional.sanctum` 미들웨어를 쓰므로 **비로그인 사용자도 조회할 수 있다**(생성기 표기 `auth:sanctum` 은 실제와 다르며, 실제로는 토큰이 있으면 인증 컨텍스트를 붙이되 없어도 통과한다). 이 확장은 학습용 샘플로, 공개 읽기 API 의 표준 패턴(`PublicBaseController` + `throttle:600,1`)을 보여준다. + +- **요청 파라미터**: query `per_page`(정수, 기본 10)로 페이지 크기를 조절한다. FormRequest 를 쓰지 않고 컨트롤러가 `$request->query('per_page', 10)` 로 직접 읽는다. +- **응답**: `data` 는 `MemoCollection`(`BaseApiCollection`) 산물로, `data.data` 에 `MemoResource` 배열, `data.meta.pagination` 에 페이지 메타(`current_page`/`last_page`/`per_page`/`total`/`from`/`to`/`has_more_pages`)를 담는다. 각 메모 항목 필드는 아래 상세 조회와 동일하다. +- **미설치 주의**: 이 문서는 확장이 미설치인 상태에서 라우트 파일 정적 분석으로 생성되어 실측 응답이 없다(`http-404`). 설치 후 `api:docgen --scope=module:gnuboard7-hello_module --seed` 로 실측하면 응답 예시가 채워진다. + +**응답 예시** (정적 — MemoResource 구조 기준) + +```json +{ + "success": true, + "data": { + "data": [ + { "id": 1, "uuid": "…", "title": "샘플 메모", "content": "본문", "created_at": "…", "updated_at": "…", "is_owner": false, "abilities": { "can_create": false, "can_update": false, "can_delete": false } } + ], + "meta": { "pagination": { "current_page": 1, "last_page": 1, "per_page": 10, "total": 1, "from": 1, "to": 1, "has_more_pages": false } } + }, + "message": "메모를 조회했습니다.", + "error": null +} +``` + + +### GET /api/modules/gnuboard7-hello_module/memos/{id} + +- **라우트명**: `api.modules.gnuboard7-hello_module.memos.show` +- **컨트롤러**: `Modules\Gnuboard7\HelloModule\Http\Controllers\Api\MemoController@show` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/modules/gnuboard7-hello_module/memos/{id} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + +단일 메모를 조회하는 공개 엔드포인트다. 목록과 마찬가지로 `optional.sanctum` 이라 비로그인 조회가 허용된다(생성기 표기 `auth:sanctum` 은 실제와 다름). + +- **path 파라미터** `{id}`: 메모의 정수 PK. 라우트 제약 `whereNumber('id')` 로 숫자만 매칭된다. 라우트-모델 바인딩 없이 컨트롤러가 `MemoService::getMemo($id)` 로 직접 조회하므로, 존재하지 않으면 `ModelNotFoundException` → `messages.memo.not_found`(404). +- **응답**: `data` 는 단건 `MemoResource` 다. 필드는 `id`(integer), `uuid`(string), `title`(string), `content`(string), 타임스탬프(`created_at`/`updated_at`), 그리고 `BaseApiResource` 공통 메타 `is_owner`(boolean) + `abilities`(`can_create`/`can_update`/`can_delete` — 각 권한 보유 여부). 권한 능력은 `gnuboard7-hello_module.memos.{create,update,delete}` 권한 매핑에서 파생된다. +- **미설치 주의**: 이 문서는 미설치 상태 정적 분석으로 생성되어 실측 응답이 없다(`unresolved-path-param` — 실측할 실제 메모 레코드가 없음). 설치 후 `--seed` 실측 시 실제 값으로 채워진다. + +**응답 예시** (정적 — MemoResource 구조 기준) + +```json +{ + "success": true, + "data": { + "id": 1, + "uuid": "…", + "title": "샘플 메모", + "content": "본문", + "created_at": "…", + "updated_at": "…", + "is_owner": false, + "abilities": { "can_create": false, "can_update": false, "can_delete": false } + }, + "message": "메모를 조회했습니다.", + "error": null +} +``` + + diff --git a/modules/_bundled/sirsoft-board/docs/api/README.md b/modules/_bundled/sirsoft-board/docs/api/README.md new file mode 100644 index 00000000..bdead860 --- /dev/null +++ b/modules/_bundled/sirsoft-board/docs/api/README.md @@ -0,0 +1,23 @@ +# API 레퍼런스 문서 목차 + +> **소유**: 모듈 `sirsoft-board` · **생성**: `php artisan api:docgen` (실측 기반). +> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. + + +- **문서 수**: 10 · **엔드포인트 수**: 80 + +| 문서 | 도메인 | 엔드포인트 | +| --- | --- | --- | +| [activity-stats.md](activity-stats.md) | `activity-stats` | 1 | +| [board.md](board.md) | `board` | 18 | +| [board-activities.md](board-activities.md) | `board-activities` | 1 | +| [board-types.md](board-types.md) | `board-types` | 4 | +| [boards.md](boards.md) | `boards` | 37 | +| [dashboard.md](dashboard.md) | `dashboard` | 4 | +| [my-comments.md](my-comments.md) | `my-comments` | 1 | +| [reports.md](reports.md) | `reports` | 7 | +| [settings.md](settings.md) | `settings` | 5 | +| [users.md](users.md) | `users` | 2 | + + + diff --git a/modules/_bundled/sirsoft-board/docs/api/activity-stats.md b/modules/_bundled/sirsoft-board/docs/api/activity-stats.md new file mode 100644 index 00000000..4a44abae --- /dev/null +++ b/modules/_bundled/sirsoft-board/docs/api/activity-stats.md @@ -0,0 +1,77 @@ +# Activity Stats API 레퍼런스 + +> **소유**: module `sirsoft-board` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Activity Stats 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-board/me/activity-stats + +- **라우트명**: `api.modules.sirsoft-board.me.activity-stats` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\UserActivityController@stats` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/me/activity-stats HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| total_posts | integer | `1` | 회원 본인이 작성한 게시글 총수입니다. 비활성 게시판의 글은 제외하며 소프트 삭제된 글도 집계에서 빠집니다. | +| total_comments | integer | `0` | 회원 본인이 작성한 게시글들의 댓글 수 합계(SUM of comments_count)입니다. 비활성 게시판 글은 제외됩니다. | +| total_views | integer | `42` | 회원 본인이 작성한 게시글들의 누적 조회수 합계(SUM of view_count)입니다. 비활성 게시판 글은 제외됩니다. | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "활동 통계를 조회했습니다.", + "data": { + "total_posts": 1, + "total_comments": 0, + "total_views": 44 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** 로그인한 회원 본인의 게시판 활동 통계(작성 글 수, 작성 댓글 수, 누적 조회수)를 마이페이지 요약 카드에 표시하기 위해 반환합니다. `auth:sanctum` 인증이 필요한 회원 전용 엔드포인트로, 대상은 항상 인증된 본인(`Auth::id()`)입니다. + + diff --git a/modules/_bundled/sirsoft-board/docs/api/board-activities.md b/modules/_bundled/sirsoft-board/docs/api/board-activities.md new file mode 100644 index 00000000..0cd00961 --- /dev/null +++ b/modules/_bundled/sirsoft-board/docs/api/board-activities.md @@ -0,0 +1,138 @@ +# Board Activities API 레퍼런스 + +> **소유**: module `sirsoft-board` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Board Activities 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-board/me/board-activities + +- **라우트명**: `api.modules.sirsoft-board.me.board-activities.index` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\UserActivityController@index` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/me/board-activities HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `237` | 기본 키 (내부 식별자) | +| board_slug | string | `apidoc-sample-board` | 게시글이 속한 게시판의 슬러그(URL 식별자)입니다. 게시판 상세 링크 구성에 사용합니다. | +| board_name | string | `API 문서 샘플 게시판` | 게시글이 속한 게시판의 표시 이름입니다. 현재 로케일에 맞는 다국어 이름(`getLocalizedName()`)이 적용됩니다. | +| activity_type | string | `authored` | 활동 유형입니다. `authored`(본인이 작성한 글) 또는 `commented`(본인이 댓글을 단 글)로, 요청의 `activity_type` 필터(기본 authored)에 대응합니다. | +| activity_count | integer | `0` | activity 개수 (집계) | +| title | string | `API 문서 샘플 게시글` | 제목 | +| is_secret | boolean | `false` | secret 여부 | +| status | string | `published` | 게시글 상태입니다. `published`(공개), `blinded`(블라인드 처리), `deleted`(삭제) 등 PostStatus 값이며, UI에서 블라인드/삭제 배지 표시에 사용합니다. | +| view_count | integer | `43` | view 개수 (집계) | +| comment_count | integer | `0` | comment 개수 (집계) | +| created_at | string | `2026-07-07 09:34:50` | 생성 일시 | +| created_at_formatted | string | `4시간 전` | `created_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| content_plain | string | `API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.` | 게시글 본문의 순수 텍스트입니다. HTML 모드 글은 태그를 제거한 평문으로 변환되며, 목록 미리보기용으로 사용합니다. | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "내 활동 게시글을 조회했습니다.", + "data": { + "current_page": 1, + "data": [ + { + "id": 1, + "board_slug": "apidoc-sample-board", + "board_name": "API 문서 샘플 게시판", + "activity_type": "authored", + "activity_count": 0, + "title": "API 문서 샘플 게시글", + "is_secret": "{MASKED}", + "status": "published", + "view_count": 44, + "comment_count": 0, + "created_at": "2026-07-08 10:41:34", + "created_at_formatted": "4시간 전", + "content_plain": "API 레퍼런스 실측용 완전 샘플 게시글 본문입니다." + } + ], + "first_page_url": "https://api.example.com/api/modules/sirsoft-board/me/board-activities?page=1", + "from": 1, + "last_page": 1, + "last_page_url": "https://api.example.com/api/modules/sirsoft-board/me/board-activities?page=1", + "links": [ + { + "url": null, + "label": "pagination.previous", + "page": null, + "active": false + }, + { + "url": "https://api.example.com/api/modules/sirsoft-board/me/board-activities?page=1", + "label": "1", + "page": 1, + "active": true + }, + { + "url": null, + "label": "pagination.next", + "page": null, + "active": false + } + ], + "next_page_url": null, + "path": "https://api.example.com/api/modules/sirsoft-board/me/board-activities", + "per_page": 25, + "prev_page_url": null, + "to": 1, + "total": 1, + "query": { + "board_slug": "", + "search": "", + "sort": "latest", + "activity_type": "" + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** 로그인한 회원 본인의 게시글 활동을 마이페이지에 표시하기 위해 반환합니다. `auth:sanctum` 인증이 필요한 회원 전용 엔드포인트로, 대상 사용자는 항상 인증된 본인(`Auth::id()`)입니다. `board_slug`·`search`·`activity_type`·`sort`(latest/oldest/views) 필터와 `per_page`(기본 20) 페이지네이션을 지원하며, 응답에는 적용된 필터가 `query` 로 함께 담깁니다. + + diff --git a/modules/_bundled/sirsoft-board/docs/api/board-types.md b/modules/_bundled/sirsoft-board/docs/api/board-types.md new file mode 100644 index 00000000..8245bf2c --- /dev/null +++ b/modules/_bundled/sirsoft-board/docs/api/board-types.md @@ -0,0 +1,237 @@ +# Board Types API 레퍼런스 + +> **소유**: module `sirsoft-board` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Board Types 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-board/admin/board-types + +- **라우트명**: `api.modules.sirsoft-board.admin.board-types.index` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\BoardTypeController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.boards.create` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/admin/board-types HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| slug | string | `basic` | 유형 식별자 (basic, card, gallery 등) | +| name | object | `{"ko":"기본형","en":"Basic List","ja":"基本形"}` | 유형명 (다국어: {"ko": "기본형", "en": "Basic List"}) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "게시판 유형 목록을 조회했습니다.", + "data": [ + { + "id": 2, + "slug": "basic", + "name": { + "ko": "기본형", + "en": "Basic List" + } + }, + { + "id": 3, + "slug": "gallery", + "name": { + "ko": "갤러리형", + "en": "Gallery" + } + }, + { + "id": 4, + "slug": "card", + "name": { + "ko": "카드형", + "en": "Card" + } + } + ] +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.boards.create`)이 없는 경우 | + + + +**설명** 관리자가 게시판 생성/편집 화면에서 선택할 수 있는 게시판 유형(basic, card, gallery 등) 목록을 반환합니다. `auth:sanctum` 인증과 `sirsoft-board.boards.create` 권한이 필요하며, 게시판 생성 권한을 재사용해 접근을 통제합니다. `name` 은 다국어 객체로 반환되므로 표시 시 현재 로케일 키를 선택해야 합니다. + + +### POST /api/modules/sirsoft-board/admin/board-types + +- **라우트명**: `api.modules.sirsoft-board.admin.board-types.store` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\BoardTypeController@store` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.boards.create` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | body | string | 예 | max 50 | URL 친화 식별자 (slug) | +| name | body | string | 예 | — | 대상의 이름/명칭 | + +**요청 예시** + +```http +POST /api/modules/sirsoft-board/admin/board-types HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "slug": "example-key", + "name": "예시 이름" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.boards.create`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 새 게시판 유형을 생성합니다. `slug` 는 유형을 식별하는 고유 문자열(최대 50자)이고 `name` 은 유형명입니다. `auth:sanctum` 인증과 `sirsoft-board.boards.create` 권한이 필요하며, 성공 시 생성된 유형 리소스와 함께 201 을 반환합니다. + + +### DELETE /api/modules/sirsoft-board/admin/board-types/{id} + +- **라우트명**: `api.modules.sirsoft-board.admin.board-types.destroy` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\BoardTypeController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.boards.create` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-board/admin/board-types/2 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.boards.create`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 지정한 `id` 의 게시판 유형을 삭제합니다. `auth:sanctum` 인증과 `sirsoft-board.boards.create` 권한이 필요합니다. 존재하지 않는 id 는 404, 해당 유형을 사용 중인 게시판이 있는 등 삭제할 수 없는 경우 422 를 반환합니다. + + +### PUT /api/modules/sirsoft-board/admin/board-types/{id} + +- **라우트명**: `api.modules.sirsoft-board.admin.board-types.update` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\BoardTypeController@update` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.boards.create` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| name | body | string | 예 | — | 대상의 이름/명칭 | + +**요청 예시** + +```http +PUT /api/modules/sirsoft-board/admin/board-types/2 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": "예시 이름" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.boards.create`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 지정한 `id` 의 게시판 유형명을 수정합니다. `slug` 는 변경되지 않으며 `name` 만 갱신합니다. `auth:sanctum` 인증과 `sirsoft-board.boards.create` 권한이 필요하고, 존재하지 않는 id 는 404 를 반환합니다. + + diff --git a/modules/_bundled/sirsoft-board/docs/api/board.md b/modules/_bundled/sirsoft-board/docs/api/board.md new file mode 100644 index 00000000..bf41d182 --- /dev/null +++ b/modules/_bundled/sirsoft-board/docs/api/board.md @@ -0,0 +1,1749 @@ +# Board API 레퍼런스 + +> **소유**: module `sirsoft-board` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Board 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### POST /api/modules/sirsoft-board/admin/board/{slug}/attachments + +- **라우트명**: `api.modules.sirsoft-board.admin.board.attachments.upload` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\AttachmentController@upload` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.{slug}.admin.attachments.upload` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| file | body | file | 예 | — | 업로드 파일 | +| post_id | body | integer | 아니오 | min 1 | post 식별자 | +| collection | body | string | 아니오 | max 100 | 첨부 컬렉션 그룹명 (첨부를 용도별로 묶는 키, 미지정 시 default) | +| temp_key | body | string | 아니오 | max 64 | 게시글 작성 전 임시 업로드 세션 키. `post_id`가 없을 때 이 키로 첨부를 임시 보관했다가 게시글 저장 시점에 연결합니다 (쿼리스트링으로 보내면 body로 병합). | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-board.attachment.upload_validation_rules`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-board/admin/board/apidoc-sample-board/attachments HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: multipart/form-data; boundary=----G7ExampleBoundary + +------G7ExampleBoundary +Content-Disposition: form-data; name="file"; filename="example.pdf" +Content-Type: application/octet-stream + +(바이너리 파일 내용) +------G7ExampleBoundary +Content-Disposition: form-data; name="post_id" + +1 +------G7ExampleBoundary +Content-Disposition: form-data; name="collection" + +예시값 +------G7ExampleBoundary +Content-Disposition: form-data; name="temp_key" + +예시값 +------G7ExampleBoundary-- +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.attachments.upload`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시판 관리자가 게시글 첨부파일 1건을 업로드합니다. `auth:sanctum` + admin + 게시판별 `attachments.upload` 권한이 필요하며, `AttachmentService::upload()`가 게시판별 동적 첨부 테이블에 저장합니다. `post_id`가 있으면 해당 게시글에 즉시 귀속되고, 없으면 `temp_key`로 임시 업로드되어 게시글 작성/수정 저장 시점에 연결됩니다. 응답은 FileUploader 컴포넌트 호환을 위해 `data.data`로 한 번 더 감싸 파일 메타(hash·url·order 등)를 반환합니다. + + +### GET /api/modules/sirsoft-board/admin/board/{slug}/attachments/download/{hash} + +- **라우트명**: `api.modules.sirsoft-board.admin.board.attachments.download` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\AttachmentController@download` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.{slug}.admin.attachments.download` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| hash | path | string | 예 | — | 대상 리소스의 해시 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/admin/board/apidoc-sample-board/attachments/download/apidocsmpl1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.attachments.download`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시판 관리자가 첨부파일을 해시로 조회해 다운로드합니다. `auth:sanctum` + admin + 게시판별 `attachments.download` 권한이 필요하며, `AttachmentService::getByHash()`로 대상을 찾은 뒤 `download()`가 파일 스트림 응답을 생성합니다. 해시에 해당하는 첨부가 없거나 실제 파일이 없으면 404를 반환하고, JSON이 아닌 `StreamedResponse`로 파일 본문을 직접 전송합니다. + + +### PATCH /api/modules/sirsoft-board/admin/board/{slug}/attachments/reorder + +- **라우트명**: `api.modules.sirsoft-board.admin.board.attachments.reorder` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\AttachmentController@reorder` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.{slug}.admin.attachments.upload` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| order | body | array | 예 | min 1 | 첨부파일 순서 배열. FileUploader가 보내는 `[{id, order}]` 형태로, 각 원소의 `id`(첨부 ID)와 `order`(0 이상 정수)를 담아 표시 순서를 지정합니다. | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-board.attachment.reorder_validation_rules`). + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-board/admin/board/apidoc-sample-board/attachments/reorder HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "order": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.attachments.upload`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시판 관리자가 게시글 첨부파일들의 표시 순서를 일괄 변경합니다. `auth:sanctum` + admin + 게시판별 `attachments.upload` 권한이 필요하며, FileUploader가 보낸 `[{id, order}]` 배열을 `[ID => order]` 매핑으로 변환해 `AttachmentService::reorder()`가 게시판별 첨부 테이블의 order 값을 갱신합니다. + + +### DELETE /api/modules/sirsoft-board/admin/board/{slug}/attachments/{id} + +- **라우트명**: `api.modules.sirsoft-board.admin.board.attachments.destroy` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\AttachmentController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.{slug}.admin.attachments.upload` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-board/admin/board/apidoc-sample-board/attachments/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "파일이 삭제되었습니다.", + "data": null +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.attachments.upload`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시판 관리자가 첨부파일 1건을 삭제합니다. `auth:sanctum` + admin + 게시판별 `attachments.upload` 권한이 필요하며, `AttachmentService::getById()`로 대상 존재를 확인한 뒤 `delete()`가 게시판별 첨부 테이블 레코드와 실제 파일을 함께 제거합니다. 첨부가 없으면 404, 삭제 실패 시 500을 반환합니다. + + +### GET /api/modules/sirsoft-board/admin/board/{slug}/posts + +- **라우트명**: `api.modules.sirsoft-board.admin.board.posts.index` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\PostController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.{slug}.admin.posts.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/admin/board/apidoc-sample-board/posts HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `237` | 기본 키 (내부 식별자) | +| category | null | `null` | 게시글 분류(카테고리) 문자열. 게시판이 카테고리를 쓰지 않거나 미지정 시 null (최대 50자). | +| author | object | `{"uuid":"a231747f-e82e-4cf2-9ae1-a261849dce40","name":"AP…` | 작성자 사용자 객체 (uuid/name — author 관계 파생) | +| is_notice | boolean | `false` | notice 여부 | +| is_secret | boolean | `false` | secret 여부 | +| content_mode | string | `html` | 본문 편집 모드. `html`(위지윅/HTML) 또는 `text`(평문)이며, 요약·썸네일 추출과 렌더링 방식을 결정합니다. 미지정 시 `text`. | +| is_new | boolean | `true` | new 여부 | +| status | string | `published` | 게시글 상태 코드. `published`(게시됨) / `blinded`(블라인드) / `deleted`(삭제됨) 중 하나이며, `status_label`이 사람이 읽는 라벨입니다. | +| status_label | string | `게시됨` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| view_count | integer | `43` | view 개수 (집계) | +| comment_count | integer | `0` | comment 개수 (집계) | +| reply_count | integer | `0` | reply 개수 (집계) | +| attachment_count | integer | `0` | attachment 개수 (집계) | +| has_attachment | boolean | `false` | attachment 여부 | +| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 | +| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | +| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | +| is_reply | boolean | `false` | reply 여부 | +| created_at | string | `2026-07-07 09:34:50` | 생성 일시 | +| created_at_formatted | string | `4시간 전` | `created_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| is_author | boolean | `true` | author 여부 | +| is_guest_post | boolean | `false` | guest post 여부 | +| slug | string | `apidoc-sample-board` | 게시판 슬러그 (URL/테이블명) | +| title | string | `API 문서 샘플 게시글` | 제목 | +| deleted_at | null | `null` | 소프트 삭제 일시 (미삭제 시 null) | +| content_preview | string | `API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.` | 목록용 본문 요약(태그 제거 후 앞 150자). 블라인드·비밀글은 원문 유출 방지를 위해 권한과 무관하게 빈 문자열을 반환합니다. | +| row_type | string | `normal` | 목록 행 유형. `notice`(공지) / `reply`(답변글) / `normal`(일반) 중 하나로, 목록 렌더링 시 행 스타일과 순번 표시를 분기합니다. | +| number | integer | `1` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| show_category | boolean | `false` | 목록에 카테고리 열을 노출할지 여부. 게시판 설정(`show_category`)에서 파생되어 각 행에 부여됩니다. | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "게시글 목록을 조회했습니다.", + "data": { + "data": [ + { + "id": 1, + "category": null, + "author": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "avatar": null, + "status": "active", + "status_label": "활성", + "is_guest": false + }, + "is_notice": false, + "is_secret": "{MASKED}", + "content_mode": "html", + "is_new": true, + "status": "published", + "status_label": "게시됨", + "view_count": 43, + "comment_count": 0, + "reply_count": 0, + "attachment_count": 0, + "has_attachment": false, + "thumbnail": "/api/modules/sirsoft-board/boards/apidoc-sample-board/attachment/apidocsmpl1/preview", + "parent_id": null, + "depth": 0, + "is_reply": false, + "created_at": "2026-07-08 10:41:34", + "created_at_formatted": "4시간 전", + "is_author": true, + "is_guest_post": false, + "slug": "apidoc-sample-board", + "title": "API 문서 샘플 게시글", + "deleted_at": null, + "content_preview": "API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.", + "row_type": "normal", + "number": 1, + "show_category": false + } + ], + "pagination": { + "total": 1, + "all_total": 1, + "count": 1, + "per_page": 25, + "current_page": 1, + "last_page": 1, + "from": 1, + "to": 1, + "has_more_pages": false + }, + "board": { + "slug": "apidoc-sample-board", + "name": "API 문서 샘플 게시판", + "type": "basic", + "categories": [], + "show_category": false, + "settings": { + "use_file_upload": true, + "use_comment": true, + "use_reply": true, + "use_report": true, + "secret_mode": "{MASKED}", + "per_page": 20, + "per_page_mobile": 15, + "order_by": "created_at", + "order_direction": "DESC" + } + }, + "abilities": { + "can_read": true, + "can_write": true, + "can_read_secret": "{MASKED}", + "can_read_comments": true, + "can_write_comments": true, + "can_upload": true, + "can_download": true, + "can_manage": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.posts.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시판 관리자가 특정 게시판의 게시글 목록을 조회합니다. `auth:sanctum` + admin + 게시판별 `posts.read` 권한이 필요하며, 요청 파라미터로 검색·상태·정렬·페이지네이션이 적용됩니다(`PostService::buildListParams`). 추가로 `admin.manage` 권한이 있으면 소프트 삭제된 게시글까지 포함해 조회하며, 응답에는 공지 고정 처리 후의 일반 게시글 총 건수(캐시 기반)와 관리자용 게시판 정보(`boardInfo`)가 함께 담깁니다. + + +### POST /api/modules/sirsoft-board/admin/board/{slug}/posts + +- **라우트명**: `api.modules.sirsoft-board.admin.board.posts.store` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\PostController@store` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.{slug}.admin.posts.write` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-board.post.store_validation_rules`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-board/admin/board/apidoc-sample-board/posts HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.posts.write`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시판 관리자가 새 게시글을 작성합니다. `auth:sanctum` + admin + 게시판별 `posts.write` 권한이 필요하며, `StorePostRequest` 검증을 거친 값에 작성자(`Auth::id()`)와 요청 IP가 자동으로 채워집니다. 업로드 파일과 첨부파일 ID 배열은 본문에서 분리되어 `PostService::createPost()`로 전달되고, 성공 시 생성된 게시글 리소스를 201로 반환합니다. + + +### GET /api/modules/sirsoft-board/admin/board/{slug}/posts/form-data + +- **라우트명**: `api.modules.sirsoft-board.admin.board.posts.form-data` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\PostController@getFormData` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.{slug}.admin.posts.write` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/admin/board/apidoc-sample-board/posts/form-data HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| title | string | `` | 제목 | +| content | string | `` | 본문 내용 | +| content_mode | string | `text` | 본문 편집 모드. `html`(위지윅/HTML) 또는 `text`(평문)이며, 폼 초기값은 `text`입니다. | +| category | null | `null` | 게시글 분류(카테고리) 문자열. 게시판이 카테고리를 쓰지 않거나 미지정 시 null (최대 50자). | +| is_notice | boolean | `false` | notice 여부 | +| is_secret | boolean | `false` | secret 여부 | +| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "게시글 폼 데이터를 조회했습니다.", + "data": { + "title": "", + "content": "", + "content_mode": "text", + "category": null, + "is_notice": false, + "is_secret": "{MASKED}", + "parent_id": null + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.posts.write`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시글 작성/수정/답변글 폼에 미리 채울 입력 데이터를 반환합니다. `auth:sanctum` + admin + 게시판별 `posts.write` 권한이 필요하며, 쿼리 파라미터에 따라 분기합니다. `post_id`가 있으면 기존 게시글 데이터(수정 모드), `parent_id`가 있으면 제목에 `Re:`를 붙이고 원글 카테고리·비밀글 여부를 물려받은 답변글 기본값(답글 허용 게시판만, 아니면 404), 둘 다 없으면 빈 폼(게시판 `secret_mode`가 `always`면 비밀글 기본값)을 돌려줍니다. + + +### GET /api/modules/sirsoft-board/admin/board/{slug}/posts/form-meta + +- **라우트명**: `api.modules.sirsoft-board.admin.board.posts.form-meta` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\PostController@getFormMeta` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.{slug}.admin.posts.write` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/admin/board/apidoc-sample-board/posts/form-meta HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| board | object | `{"id":12,"name":"API 문서 샘플 게시판","slug":"apidoc-sample-boa…` | 폼 화면 표시에 필요한 게시판 정보 객체(이름·슬러그·댓글/답글/비밀글 설정 등). 사용자 권한(abilities)과 함께 항상 포함됩니다. | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "게시글 폼 메타 데이터를 조회했습니다.", + "data": { + "board": { + "id": 1, + "name": "API 문서 샘플 게시판", + "slug": "apidoc-sample-board", + "is_active": true, + "type": "basic", + "description": "", + "per_page": 20, + "per_page_mobile": 15, + "order_by": "created_at", + "order_direction": "DESC", + "categories": [], + "show_view_count": true, + "secret_mode": "{MASKED}", + "use_comment": true, + "use_reply": true, + "max_reply_depth": 5, + "use_report": true, + "comment_order": "ASC", + "max_comment_depth": 10, + "min_title_length": 2, + "max_title_length": 200, + "min_content_length": 10, + "max_content_length": 10000, + "min_comment_length": 2, + "max_comment_length": 1000, + "blocked_keywords": [], + "use_file_upload": true, + "max_file_size": 10, + "max_file_count": 5, + "allowed_extensions": [ + "jpg", + "jpeg", + "png", + "gif", + "pdf", + "zip" + ], + "add_to_menu": null, + "new_display_hours": 24, + "board_managers": [], + "board_steps": [], + "board_manager_ids": [], + "board_step_ids": [], + "notify_author": true, + "notify_admin_on_post": true, + "created_at": "2026-07-08 10:41:34", + "updated_at": "2026-07-08 10:41:34", + "permissions": null, + "category_post_counts": null, + "posts_count": 0, + "user_abilities": { + "can_read": true, + "can_write": true, + "can_read_secret": "{MASKED}", + "can_read_comments": true, + "can_write_comments": true, + "can_upload": true, + "can_download": true, + "can_manage": true + }, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.posts.write`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시글 폼 화면 표시용 메타 데이터(읽기 전용)를 반환합니다. `auth:sanctum` + admin + 게시판별 `posts.write` 권한이 필요하며, 게시판 정보와 사용자 권한(abilities)을 항상 포함합니다. `post_id`가 있으면 작성자·작성일·첨부파일과 원글 정보를 덧붙이고(수정 모드), `parent_id`가 있으면 원글 정보를 포함하되 블라인드/삭제된 원글에는 답글 작성이 차단됩니다(각각 403). + + +### DELETE /api/modules/sirsoft-board/admin/board/{slug}/posts/{id} + +- **라우트명**: `api.modules.sirsoft-board.admin.board.posts.destroy` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\PostController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.{slug}.admin.posts.write|sirsoft-board.{slug}.admin.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-board/admin/board/apidoc-sample-board/posts/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| category | null | `null` | 게시글 분류(카테고리) 문자열. 게시판이 카테고리를 쓰지 않거나 미지정 시 null (최대 50자). | +| author | object | `{"uuid":"a234c2b1-cde8-437f-b28b-23323be2b98d","name":"AP…` | 작성자 사용자 객체 (uuid/name — author 관계 파생) | +| is_notice | boolean | `false` | notice 여부 | +| is_secret | boolean | `false` | secret 여부 | +| content_mode | string | `html` | 본문 편집 모드. `html`(위지윅/HTML) 또는 `text`(평문)이며, 요약·썸네일 추출과 렌더링 방식을 결정합니다. 미지정 시 `text`. | +| is_new | boolean | `true` | new 여부 | +| status | string | `deleted` | 상태 값 (도메인별 상태 집합 — 사람이 읽는 라벨은 status_label, UI 변형은 status_variant 참조) | +| status_label | string | `삭제됨` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| view_count | integer | `43` | view 개수 (집계) | +| comment_count | integer | `0` | comment 개수 (집계) | +| reply_count | integer | `0` | reply 개수 (집계) | +| attachment_count | integer | `0` | attachment 개수 (집계) | +| has_attachment | boolean | `false` | attachment 여부 | +| thumbnail | null | `null` | 썸네일 이미지 URL/경로 | +| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | +| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | +| is_reply | boolean | `false` | reply 여부 | +| created_at | string | `2026-07-08 10:41:34` | 생성 일시 | +| created_at_formatted | string | `4시간 전` | `created_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| is_author | boolean | `true` | author 여부 | +| is_guest_post | boolean | `false` | guest post 여부 | +| title | string | `API 문서 샘플 게시글` | 제목 | +| content | string | `

API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.

` | 본문 내용 | +| user_id | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | user 식별자 (연관 리소스 참조) | +| trigger_type | string | `admin` | 동작을 유발한 방식/주체 구분 값 | +| updated_at | string | `2026-07-08 15:01:43` | 최종 수정 일시 | +| deleted_at | string | `2026-07-08 15:01:43` | 소프트 삭제 일시 (미삭제 시 null) | +| ip_address | string | `127.0.0.1` | 요청/행위가 발생한 IP 주소 | +| action_logs | array | `[{"action":"delete","reason":null,"admin_name":"API 문서 샘플…` | 블라인드/복원/삭제 등 처리 이력 목록(항목별 action·reason·admin_name·created_at). `admin.manage` 권한 보유자에게만 노출되며, 민감 필드(admin_id·ip_address)는 제외됩니다. 비권한자에게는 null. | +| board | null | `null` | 소속 게시판 정보 객체(슬러그·이름·유형·댓글/답글/신고 사용 여부·조회수 표시·최대 답글/댓글 깊이·신고 사유 목록). board 관계가 로드된 경우에만 채워지며, 아니면 null. | +| navigation | null | `null` | 이전/다음 게시글 이동 정보(`prev`·`next`). 상세 로드 시 계산되며, 쓰기 응답처럼 인접 게시글을 계산하지 않은 경우 null. | +| parent | null | `null` | 상위 항목 객체 (parent 관계 파생) | +| comments | null | `null` | 게시글에 달린 댓글 목록(CommentResource 컬렉션). comments 관계가 로드된 경우에만 채워지며, 아니면 null. | +| attachments | null | `null` | 게시글 첨부파일 목록(AttachmentResource 컬렉션). attachments 관계가 로드된 경우에만 채워지며, 아니면 null(비밀글·삭제글은 권한에 따라 빈 배열 또는 연쇄 삭제분만 노출). | +| replies | null | `null` | 이 게시글에 달린 답변글 목록(PostResource 컬렉션, 재귀). replies 관계가 로드된 경우에만 채워지며, 아니면 null. | +| is_already_reported | boolean | `false` | already reported 여부 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_write":true,"can_read_secret":true,…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "게시글이 삭제되었습니다.", + "data": { + "id": 1, + "category": null, + "author": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "avatar": null, + "status": "active", + "status_label": "활성", + "is_guest": false + }, + "is_notice": false, + "is_secret": "{MASKED}", + "content_mode": "html", + "is_new": true, + "status": "deleted", + "status_label": "삭제됨", + "view_count": 43, + "comment_count": 0, + "reply_count": 0, + "attachment_count": 0, + "has_attachment": false, + "thumbnail": null, + "parent_id": null, + "depth": 0, + "is_reply": false, + "created_at": "2026-07-08 10:41:34", + "created_at_formatted": "4시간 전", + "is_author": true, + "is_guest_post": false, + "title": "API 문서 샘플 게시글", + "content": "

API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.

", + "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "trigger_type": "admin", + "updated_at": "2026-07-08 15:01:43", + "deleted_at": "2026-07-08 15:01:43", + "ip_address": "127.0.0.1", + "action_logs": [ + { + "action": "delete", + "reason": null, + "admin_name": "API 문서 샘플 사용자", + "created_at": "2026-07-08 06:01:43" + } + ], + "board": null, + "navigation": null, + "parent": null, + "comments": null, + "attachments": null, + "replies": null, + "is_already_reported": false, + "is_owner": true, + "abilities": { + "can_read": true, + "can_write": true, + "can_read_secret": "{MASKED}", + "can_read_comments": true, + "can_write_comments": true, + "can_upload": true, + "can_download": true, + "can_manage": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.posts.write|sirsoft-board.{slug}.admin.manage`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시판 관리자가 게시글 1건을 소프트 삭제합니다. `auth:sanctum` + admin 인증이 필요하며, 라우트 권한은 `posts.write` 또는 `manage`입니다. 컨트롤러가 대상 게시글을 조회한 뒤 세분화된 권한 분기를 적용합니다: `admin.manage`는 모든 글(비회원 글 포함)을, `admin.posts.write`는 본인 글만 삭제할 수 있으며 이미 삭제된 글의 재처리는 `admin.manage`가 필요합니다. `PostService::deletePost()`가 'admin' 컨텍스트로 소프트 삭제를 수행합니다. + + +### GET /api/modules/sirsoft-board/admin/board/{slug}/posts/{id} + +- **라우트명**: `api.modules.sirsoft-board.admin.board.posts.show` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\PostController@show` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.{slug}.admin.posts.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/admin/board/apidoc-sample-board/posts/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `237` | 기본 키 (내부 식별자) | +| category | null | `null` | 게시글 분류(카테고리) 문자열. 게시판이 카테고리를 쓰지 않거나 미지정 시 null (최대 50자). | +| author | object | `{"uuid":"a231747f-e82e-4cf2-9ae1-a261849dce40","name":"AP…` | 작성자 사용자 객체 (uuid/name — author 관계 파생) | +| is_notice | boolean | `false` | notice 여부 | +| is_secret | boolean | `false` | secret 여부 | +| content_mode | string | `html` | 본문 편집 모드. `html`(위지윅/HTML) 또는 `text`(평문)이며, 요약·썸네일 추출과 렌더링 방식을 결정합니다. 미지정 시 `text`. | +| is_new | boolean | `true` | new 여부 | +| status | string | `published` | 게시글 상태 코드. `published`(게시됨) / `blinded`(블라인드) / `deleted`(삭제됨) 중 하나이며, `status_label`이 사람이 읽는 라벨입니다. | +| status_label | string | `게시됨` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| view_count | integer | `43` | view 개수 (집계) | +| comment_count | integer | `0` | comment 개수 (집계) | +| reply_count | integer | `0` | reply 개수 (집계) | +| attachment_count | integer | `0` | attachment 개수 (집계) | +| has_attachment | boolean | `false` | attachment 여부 | +| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 | +| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | +| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | +| is_reply | boolean | `false` | reply 여부 | +| created_at | string | `2026-07-07 09:34:50` | 생성 일시 | +| created_at_formatted | string | `4시간 전` | `created_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| is_author | boolean | `true` | author 여부 | +| is_guest_post | boolean | `false` | guest post 여부 | +| title | string | `API 문서 샘플 게시글` | 제목 | +| content | string | `

API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.

` | 본문 내용 | +| user_id | string | `a231747f-e82e-4cf2-9ae1-a261849dce40` | user 식별자 (연관 리소스 참조) | +| trigger_type | string | `user` | 상태 변경(삭제/블라인드 등)을 유발한 주체. `report`(신고) / `admin`(관리자 직권) / `system`(시스템) / `auto_hide`(신고 누적 자동 블라인드) / `user`(사용자 직접) / `cascade`(상위 삭제 연쇄) 중 하나입니다. | +| updated_at | string | `2026-07-07 09:39:03` | 최종 수정 일시 | +| deleted_at | null | `null` | 소프트 삭제 일시 (미삭제 시 null) | +| ip_address | string | `127.0.0.1` | 요청/행위가 발생한 IP 주소 | +| action_logs | array | `[]` | 블라인드/복원/삭제 등 처리 이력 목록(항목별 action·reason·admin_name·created_at). `admin.manage` 권한 보유자에게만 노출되며, 민감 필드(admin_id·ip_address)는 제외됩니다. 비권한자에게는 null. | +| board | object | `{"slug":"apidoc-sample-board","name":"API 문서 샘플 게시판","typ…` | 소속 게시판 정보 객체(슬러그·이름·유형·댓글/답글/신고 사용 여부·조회수 표시·최대 답글/댓글 깊이·신고 사유 목록). board 관계가 로드된 경우에만 채워지며, 아니면 null. | +| navigation | object | `{"prev":null,"next":null}` | 이전/다음 게시글 이동 정보. `prev`·`next` 키에 인접 게시글 요약(없으면 null)이 담기며, 상세 로드 시 함께 계산됩니다. | +| parent | null | `null` | 상위 항목 객체 (parent 관계 파생) | +| comments | array | `[{"id":760,"post_id":237,"parent_id":null,"content":"API …` | 게시글에 달린 댓글 목록(CommentResource 컬렉션). comments 관계가 로드된 경우에만 채워지며, 각 항목에 신고 여부가 사전 로드되어 담깁니다. | +| attachments | array | `[{"id":155,"hash":"apidocsmpl1","original_filename":"apid…` | 게시글 첨부파일 목록(AttachmentResource 컬렉션). 비밀글은 열람 권한이 없으면 빈 배열, 삭제된 게시글은 관리 권한이 없으면 연쇄 삭제된 첨부만 노출됩니다. | +| replies | array | `[]` | 이 게시글에 달린 답변글 목록(PostResource 컬렉션, 재귀). replies 관계가 로드된 경우에만 채워지며, 아니면 null. | +| is_already_reported | boolean | `false` | already reported 여부 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_write":true,"can_read_secret":true,…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "게시글 목록을 조회했습니다.", + "data": { + "id": 1, + "category": null, + "author": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "avatar": null, + "status": "active", + "status_label": "활성", + "is_guest": false + }, + "is_notice": false, + "is_secret": "{MASKED}", + "content_mode": "html", + "is_new": true, + "status": "published", + "status_label": "게시됨", + "view_count": 44, + "comment_count": 0, + "reply_count": 0, + "attachment_count": 0, + "has_attachment": false, + "thumbnail": "/api/modules/sirsoft-board/boards/apidoc-sample-board/attachment/apidocsmpl1/preview", + "parent_id": null, + "depth": 0, + "is_reply": false, + "created_at": "2026-07-08 10:41:34", + "created_at_formatted": "4시간 전", + "is_author": true, + "is_guest_post": false, + "title": "API 문서 샘플 게시글", + "content": "

API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.

", + "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "trigger_type": "user", + "updated_at": "2026-07-08 15:01:44", + "deleted_at": null, + "ip_address": "127.0.0.1", + "action_logs": [], + "board": { + "slug": "apidoc-sample-board", + "name": "API 문서 샘플 게시판", + "type": "basic", + "use_comment": true, + "use_reply": true, + "use_report": true, + "show_view_count": true, + "max_reply_depth": 5, + "max_comment_depth": 10, + "report_types": [ + { + "value": "abuse", + "label": "욕설/비방" + }, + { + "value": "hate_speech", + "label": "혐오 발언" + }, + { + "value": "spam", + "label": "스팸/광고" + }, + { + "value": "copyright", + "label": "저작권 침해" + }, + { + "value": "privacy", + "label": "개인정보 노출" + }, + { + "value": "misinformation", + "label": "허위정보" + }, + { + "value": "sexual", + "label": "성적인 콘텐츠" + }, + { + "value": "violence", + "label": "폭력적인 콘텐츠" + }, + { + "value": "other", + "label": "기타" + } + ] + }, + "navigation": { + "prev": null, + "next": null + }, + "parent": null, + "comments": [ + { + "id": 1, + "post_id": 1, + "parent_id": null, + "content": "API 문서 샘플 댓글입니다.", + "author": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "avatar": null, + "status": "active", + "status_label": "활성", + "is_guest": false + }, + "is_secret": "{MASKED}", + "status": "published", + "status_label": "게시됨", + "depth": 0, + "replies_count": 0, + "created_at": "2026-07-08 10:41:34", + "created_at_formatted": "4시간 전", + "updated_at": "2026-07-08 10:41:34", + "deleted_at": null, + "is_cascade_deleted": false, + "ip_address": null, + "action_logs": [], + "is_author": true, + "is_guest_comment": false, + "is_already_reported": false, + "is_owner": true, + "abilities": { + "can_read": true, + "can_write": true, + "can_manage": true + } + } + ], + "attachments": [ + { + "id": 1, + "hash": "apidocsmpl1", + "original_filename": "apidoc-sample.png", + "mime_type": "image/png", + "size": 2048, + "size_formatted": "2 KB", + "collection": "default", + "order": 0, + "download_url": "/api/modules/sirsoft-board/boards/apidoc-sample-board/attachment/apidocsmpl1", + "preview_url": "/api/modules/sirsoft-board/boards/apidoc-sample-board/attachment/apidocsmpl1/preview", + "is_image": true, + "meta": null + } + ], + "replies": [], + "is_already_reported": false, + "is_owner": true, + "abilities": { + "can_read": true, + "can_write": true, + "can_read_secret": "{MASKED}", + "can_read_comments": true, + "can_write_comments": true, + "can_upload": true, + "can_download": true, + "can_manage": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.posts.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시판 관리자가 게시글 상세를 조회합니다. `auth:sanctum` + admin + 게시판별 `posts.read` 권한이 필요하며, 삭제된 게시글은 `admin.manage` 권한이 있어야 열람할 수 있습니다(없으면 403). `PostService::loadPostDetail()`이 조회수 증가·댓글·이전/다음 게시글까지 로드하며, 응답에는 댓글별 신고 여부를 N+1 없이 일괄 사전 로드해 담습니다. + + +### PUT /api/modules/sirsoft-board/admin/board/{slug}/posts/{id} + +- **라우트명**: `api.modules.sirsoft-board.admin.board.posts.update` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\PostController@update` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.{slug}.admin.posts.write` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-board.post.update_validation_rules`). + +**요청 예시** + +```http +PUT /api/modules/sirsoft-board/admin/board/apidoc-sample-board/posts/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| category | null | `null` | 게시글 분류(카테고리) 문자열. 게시판이 카테고리를 쓰지 않거나 미지정 시 null (최대 50자). | +| author | object | `{"uuid":"a234c2b1-cde8-437f-b28b-23323be2b98d","name":"AP…` | 작성자 사용자 객체 (uuid/name — author 관계 파생) | +| is_notice | boolean | `false` | notice 여부 | +| is_secret | boolean | `false` | secret 여부 | +| content_mode | string | `html` | 본문 편집 모드. `html`(위지윅/HTML) 또는 `text`(평문)이며, 요약·썸네일 추출과 렌더링 방식을 결정합니다. 미지정 시 `text`. | +| is_new | boolean | `true` | new 여부 | +| status | string | `published` | 상태 값 (도메인별 상태 집합 — 사람이 읽는 라벨은 status_label, UI 변형은 status_variant 참조) | +| status_label | string | `게시됨` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| view_count | integer | `44` | view 개수 (집계) | +| comment_count | integer | `0` | comment 개수 (집계) | +| reply_count | integer | `0` | reply 개수 (집계) | +| attachment_count | integer | `0` | attachment 개수 (집계) | +| has_attachment | boolean | `false` | attachment 여부 | +| thumbnail | null | `null` | 썸네일 이미지 URL/경로 | +| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | +| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | +| is_reply | boolean | `false` | reply 여부 | +| created_at | string | `2026-07-08 10:41:34` | 생성 일시 | +| created_at_formatted | string | `4시간 전` | `created_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| is_author | boolean | `true` | author 여부 | +| is_guest_post | boolean | `false` | guest post 여부 | +| title | string | `API 문서 샘플 게시글` | 제목 | +| content | string | `

API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.

` | 본문 내용 | +| user_id | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | user 식별자 (연관 리소스 참조) | +| trigger_type | string | `user` | 동작을 유발한 방식/주체 구분 값 | +| updated_at | string | `2026-07-08 15:01:44` | 최종 수정 일시 | +| deleted_at | null | `null` | 소프트 삭제 일시 (미삭제 시 null) | +| ip_address | string | `127.0.0.1` | 요청/행위가 발생한 IP 주소 | +| action_logs | array | `[]` | 블라인드/복원/삭제 등 처리 이력 목록(항목별 action·reason·admin_name·created_at). `admin.manage` 권한 보유자에게만 노출되며, 민감 필드(admin_id·ip_address)는 제외됩니다. 비권한자에게는 null. | +| board | null | `null` | 소속 게시판 정보 객체(슬러그·이름·유형·댓글/답글/신고 사용 여부·조회수 표시·최대 답글/댓글 깊이·신고 사유 목록). board 관계가 로드된 경우에만 채워지며, 아니면 null. | +| navigation | null | `null` | 이전/다음 게시글 이동 정보(`prev`·`next`). 상세 로드 시 계산되며, 쓰기 응답처럼 인접 게시글을 계산하지 않은 경우 null. | +| parent | null | `null` | 상위 항목 객체 (parent 관계 파생) | +| comments | null | `null` | 게시글에 달린 댓글 목록(CommentResource 컬렉션). comments 관계가 로드된 경우에만 채워지며, 아니면 null. | +| attachments | null | `null` | 게시글 첨부파일 목록(AttachmentResource 컬렉션). attachments 관계가 로드된 경우에만 채워지며, 아니면 null(비밀글·삭제글은 권한에 따라 빈 배열 또는 연쇄 삭제분만 노출). | +| replies | null | `null` | 이 게시글에 달린 답변글 목록(PostResource 컬렉션, 재귀). replies 관계가 로드된 경우에만 채워지며, 아니면 null. | +| is_already_reported | boolean | `false` | already reported 여부 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_write":true,"can_read_secret":true,…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "게시글이 수정되었습니다.", + "data": { + "id": 1, + "category": null, + "author": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "avatar": null, + "status": "active", + "status_label": "활성", + "is_guest": false + }, + "is_notice": false, + "is_secret": "{MASKED}", + "content_mode": "html", + "is_new": true, + "status": "published", + "status_label": "게시됨", + "view_count": 44, + "comment_count": 0, + "reply_count": 0, + "attachment_count": 0, + "has_attachment": false, + "thumbnail": null, + "parent_id": null, + "depth": 0, + "is_reply": false, + "created_at": "2026-07-08 10:41:34", + "created_at_formatted": "4시간 전", + "is_author": true, + "is_guest_post": false, + "title": "API 문서 샘플 게시글", + "content": "

API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.

", + "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "trigger_type": "user", + "updated_at": "2026-07-08 15:01:44", + "deleted_at": null, + "ip_address": "127.0.0.1", + "action_logs": [], + "board": null, + "navigation": null, + "parent": null, + "comments": null, + "attachments": null, + "replies": null, + "is_already_reported": false, + "is_owner": true, + "abilities": { + "can_read": true, + "can_write": true, + "can_read_secret": "{MASKED}", + "can_read_comments": true, + "can_write_comments": true, + "can_upload": true, + "can_download": true, + "can_manage": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.posts.write`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시판 관리자가 게시글을 수정합니다. `auth:sanctum` + admin + 게시판별 `posts.write` 권한이 필요하며, 컨트롤러가 대상 게시글을 조회한 뒤 세분화된 권한을 적용합니다: 일반 글은 `admin.manage`(타인 글) 또는 `admin.write`(본인 글), 이미 삭제된 글은 `admin.manage`가 필요합니다. `UpdatePostRequest` 검증 값에서 첨부파일 ID 배열을 분리해 `PostService::updatePost()`로 전달하고, 갱신된 게시글 리소스를 반환합니다. + + +### PATCH /api/modules/sirsoft-board/admin/board/{slug}/posts/{id}/blind + +- **라우트명**: `api.modules.sirsoft-board.admin.board.posts.blind` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\PostController@blind` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.{slug}.admin.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| reason | body | string | 아니오 | max 1000 | 블라인드 처리 사유(최대 1000자). 처리 이력(action_logs)에 기록되며, 미지정 시 빈 문자열로 저장됩니다. | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-board/admin/board/apidoc-sample-board/posts/1/blind HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "reason": "예시값" +} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| category | null | `null` | 게시글 분류(카테고리) 문자열. 게시판이 카테고리를 쓰지 않거나 미지정 시 null (최대 50자). | +| author | object | `{"uuid":"a234c2b1-cde8-437f-b28b-23323be2b98d","name":"AP…` | 작성자 사용자 객체 (uuid/name — author 관계 파생) | +| is_notice | boolean | `false` | notice 여부 | +| is_secret | boolean | `false` | secret 여부 | +| content_mode | string | `html` | 본문 편집 모드. `html`(위지윅/HTML) 또는 `text`(평문)이며, 요약·썸네일 추출과 렌더링 방식을 결정합니다. 미지정 시 `text`. | +| is_new | boolean | `true` | new 여부 | +| status | string | `blinded` | 상태 값 (도메인별 상태 집합 — 사람이 읽는 라벨은 status_label, UI 변형은 status_variant 참조) | +| status_label | string | `블라인드` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| view_count | integer | `44` | view 개수 (집계) | +| comment_count | integer | `0` | comment 개수 (집계) | +| reply_count | integer | `0` | reply 개수 (집계) | +| attachment_count | integer | `0` | attachment 개수 (집계) | +| has_attachment | boolean | `false` | attachment 여부 | +| thumbnail | null | `null` | 썸네일 이미지 URL/경로 | +| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | +| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | +| is_reply | boolean | `false` | reply 여부 | +| created_at | string | `2026-07-08 10:41:34` | 생성 일시 | +| created_at_formatted | string | `4시간 전` | `created_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| is_author | boolean | `true` | author 여부 | +| is_guest_post | boolean | `false` | guest post 여부 | +| title | string | `API 문서 샘플 게시글` | 제목 | +| content | string | `

API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.

` | 본문 내용 | +| user_id | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | user 식별자 (연관 리소스 참조) | +| trigger_type | string | `user` | 동작을 유발한 방식/주체 구분 값 | +| updated_at | string | `2026-07-08 15:01:44` | 최종 수정 일시 | +| deleted_at | null | `null` | 소프트 삭제 일시 (미삭제 시 null) | +| ip_address | string | `127.0.0.1` | 요청/행위가 발생한 IP 주소 | +| action_logs | array | `[{"action":"blind","reason":"실측 예시값","admin_name":"API 문서…` | 블라인드/복원/삭제 등 처리 이력 목록(항목별 action·reason·admin_name·created_at). `admin.manage` 권한 보유자에게만 노출되며, 민감 필드(admin_id·ip_address)는 제외됩니다. 비권한자에게는 null. | +| board | null | `null` | 소속 게시판 정보 객체(슬러그·이름·유형·댓글/답글/신고 사용 여부·조회수 표시·최대 답글/댓글 깊이·신고 사유 목록). board 관계가 로드된 경우에만 채워지며, 아니면 null. | +| navigation | null | `null` | 이전/다음 게시글 이동 정보(`prev`·`next`). 상세 로드 시 계산되며, 쓰기 응답처럼 인접 게시글을 계산하지 않은 경우 null. | +| parent | null | `null` | 상위 항목 객체 (parent 관계 파생) | +| comments | null | `null` | 게시글에 달린 댓글 목록(CommentResource 컬렉션). comments 관계가 로드된 경우에만 채워지며, 아니면 null. | +| attachments | null | `null` | 게시글 첨부파일 목록(AttachmentResource 컬렉션). attachments 관계가 로드된 경우에만 채워지며, 아니면 null(비밀글·삭제글은 권한에 따라 빈 배열 또는 연쇄 삭제분만 노출). | +| replies | null | `null` | 이 게시글에 달린 답변글 목록(PostResource 컬렉션, 재귀). replies 관계가 로드된 경우에만 채워지며, 아니면 null. | +| is_already_reported | boolean | `false` | already reported 여부 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_write":true,"can_read_secret":true,…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "게시글이 블라인드 처리되었습니다.", + "data": { + "id": 1, + "category": null, + "author": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "avatar": null, + "status": "active", + "status_label": "활성", + "is_guest": false + }, + "is_notice": false, + "is_secret": "{MASKED}", + "content_mode": "html", + "is_new": true, + "status": "blinded", + "status_label": "블라인드", + "view_count": 44, + "comment_count": 0, + "reply_count": 0, + "attachment_count": 0, + "has_attachment": false, + "thumbnail": null, + "parent_id": null, + "depth": 0, + "is_reply": false, + "created_at": "2026-07-08 10:41:34", + "created_at_formatted": "4시간 전", + "is_author": true, + "is_guest_post": false, + "title": "API 문서 샘플 게시글", + "content": "

API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.

", + "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "trigger_type": "user", + "updated_at": "2026-07-08 15:01:44", + "deleted_at": null, + "ip_address": "127.0.0.1", + "action_logs": [ + { + "action": "blind", + "reason": "실측 예시값", + "admin_name": "API 문서 샘플 사용자", + "created_at": "2026-07-08 06:01:44" + } + ], + "board": null, + "navigation": null, + "parent": null, + "comments": null, + "attachments": null, + "replies": null, + "is_already_reported": false, + "is_owner": true, + "abilities": { + "can_read": true, + "can_write": true, + "can_read_secret": "{MASKED}", + "can_read_comments": true, + "can_write_comments": true, + "can_upload": true, + "can_download": true, + "can_manage": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.manage`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시판 관리자가 게시글을 블라인드 처리합니다. `auth:sanctum` + admin + 게시판별 `admin.manage` 권한이 필요하며, 선택적 `reason`(최대 1000자)을 사유로 받아 `PostService::blindPost()`가 게시글 상태를 블라인드로 전환합니다. 소프트 삭제와 달리 게시글을 숨기되 관리 목적으로 보존하는 처리이며, 복원(restore)으로 되돌릴 수 있습니다. + + +### PATCH /api/modules/sirsoft-board/admin/board/{slug}/posts/{id}/restore + +- **라우트명**: `api.modules.sirsoft-board.admin.board.posts.restore` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\PostController@restore` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.{slug}.admin.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| reason | body | string | 아니오 | max 1000 | 블라인드 복원 사유(최대 1000자). 처리 이력(action_logs)에 기록되며, 미지정 시 null로 전달됩니다. | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-board/admin/board/apidoc-sample-board/posts/1/restore HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "reason": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.manage`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시판 관리자가 블라인드 처리된 게시글을 복원합니다. `auth:sanctum` + admin + 게시판별 `admin.manage` 권한이 필요하며, 선택적 `reason`(최대 1000자)을 사유로 받아 `PostService::restorePost()`가 블라인드 상태를 해제해 게시글을 다시 노출합니다. + + +### POST /api/modules/sirsoft-board/admin/board/{slug}/posts/{postId}/comments + +- **라우트명**: `api.modules.sirsoft-board.admin.board.posts.comments.store` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\CommentController@store` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.{slug}.admin.comments.write` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| postId | path | string | 예 | — | 대상 post의 식별자 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-board.comment.store_validation_rules`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-board/admin/board/apidoc-sample-board/posts/1/comments HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.comments.write`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시판 관리자가 특정 게시글에 댓글을 작성합니다. `auth:sanctum` + admin + 게시판별 `comments.write` 권한이 필요하며, 게시판의 `use_comment`가 꺼져 있으면 403으로 차단됩니다. 검증된 값에 게시글 ID·작성자(`Auth::id()`)·요청 IP가 자동으로 채워져 `CommentService::createComment()`로 전달되고, 성공 시 생성된 댓글 리소스를 201로 반환합니다. + + +### DELETE /api/modules/sirsoft-board/admin/board/{slug}/posts/{postId}/comments/{id} + +- **라우트명**: `api.modules.sirsoft-board.admin.board.posts.comments.destroy` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\CommentController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.{slug}.admin.comments.write|sirsoft-board.{slug}.admin.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| postId | path | string | 예 | — | 대상 post의 식별자 | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-board/admin/board/apidoc-sample-board/posts/1/comments/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "댓글이 삭제되었습니다.", + "data": null +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.comments.write|sirsoft-board.{slug}.admin.manage`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시판 관리자가 댓글 1건을 삭제합니다. `auth:sanctum` + admin 인증이 필요하며, 라우트 권한은 `comments.write` 또는 `manage`입니다. 컨트롤러가 댓글을 조회한 뒤 권한을 적용합니다: `admin.manage`는 모든 댓글(비회원 댓글 포함), `admin.write`는 본인 댓글만 삭제할 수 있습니다. 게시판의 `use_comment`가 꺼져 있으면 403이며, `CommentService::deleteComment()`가 'admin' 컨텍스트로 삭제를 수행합니다. + + +### PUT /api/modules/sirsoft-board/admin/board/{slug}/posts/{postId}/comments/{id} + +- **라우트명**: `api.modules.sirsoft-board.admin.board.posts.comments.update` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\CommentController@update` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.{slug}.admin.comments.write` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| postId | path | string | 예 | — | 대상 post의 식별자 | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-board.comment.update_validation_rules`). + +**요청 예시** + +```http +PUT /api/modules/sirsoft-board/admin/board/apidoc-sample-board/posts/1/comments/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.comments.write`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시판 관리자가 댓글 1건을 수정합니다. `auth:sanctum` + admin + 게시판별 `comments.write` 권한이 필요하며, 컨트롤러가 댓글을 조회한 뒤 권한을 적용합니다: `admin.manage`는 모든 댓글, `admin.write`는 본인 댓글만 수정할 수 있습니다. 게시판의 `use_comment`가 꺼져 있으면 403이며, `UpdateCommentRequest` 검증 값으로 `CommentService::updateComment()`가 갱신을 수행합니다. + + +### PATCH /api/modules/sirsoft-board/admin/board/{slug}/posts/{postId}/comments/{id}/blind + +- **라우트명**: `api.modules.sirsoft-board.admin.board.posts.comments.blind` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\CommentController@blind` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.{slug}.admin.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| postId | path | string | 예 | — | 대상 post의 식별자 | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| reason | body | string | 아니오 | max 1000 | 댓글 블라인드 처리 사유(최대 1000자). 처리 이력에 기록되며, 미지정 시 빈 문자열로 저장됩니다. | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-board/admin/board/apidoc-sample-board/posts/1/comments/1/blind HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "reason": "예시값" +} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| post_id | integer | `1` | post 식별자 (연관 리소스 참조) | +| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | +| content | string | `API 문서 샘플 댓글입니다.` | 본문 내용 | +| author | object | `{"uuid":"a234c2b1-cde8-437f-b28b-23323be2b98d","name":"AP…` | 작성자 사용자 객체 (uuid/name — author 관계 파생) | +| is_secret | boolean | `false` | secret 여부 | +| status | string | `blinded` | 상태 값 (도메인별 상태 집합 — 사람이 읽는 라벨은 status_label, UI 변형은 status_variant 참조) | +| status_label | string | `블라인드` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | +| replies_count | integer | `0` | replies 개수 (집계) | +| created_at | string | `2026-07-08 10:41:34` | 생성 일시 | +| created_at_formatted | string | `4시간 전` | `created_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| updated_at | string | `2026-07-08 15:01:44` | 최종 수정 일시 | +| deleted_at | null | `null` | 소프트 삭제 일시 (미삭제 시 null) | +| is_cascade_deleted | boolean | `false` | cascade deleted 여부 | +| ip_address | null | `null` | 요청/행위가 발생한 IP 주소 | +| action_logs | array | `[{"action":"blind","reason":"실측 예시값","admin_name":"API 문서…` | 블라인드/복원/삭제 등 처리 이력 목록(항목별 action·reason·admin_name·created_at). `admin.manage` 권한 보유자에게만 노출되며, 민감 필드(admin_id·ip_address)는 제외됩니다. 비권한자에게는 null. | +| is_author | boolean | `true` | author 여부 | +| is_guest_comment | boolean | `false` | guest comment 여부 | +| is_already_reported | boolean | `false` | already reported 여부 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_write":true,"can_manage":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "댓글이 블라인드 처리되었습니다.", + "data": { + "id": 1, + "post_id": 1, + "parent_id": null, + "content": "API 문서 샘플 댓글입니다.", + "author": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "avatar": null, + "status": "active", + "status_label": "활성", + "is_guest": false + }, + "is_secret": "{MASKED}", + "status": "blinded", + "status_label": "블라인드", + "depth": 0, + "replies_count": 0, + "created_at": "2026-07-08 10:41:34", + "created_at_formatted": "4시간 전", + "updated_at": "2026-07-08 15:01:44", + "deleted_at": null, + "is_cascade_deleted": false, + "ip_address": null, + "action_logs": [ + { + "action": "blind", + "reason": "실측 예시값", + "admin_name": "API 문서 샘플 사용자", + "created_at": "2026-07-08 06:01:44" + } + ], + "is_author": true, + "is_guest_comment": false, + "is_already_reported": false, + "is_owner": true, + "abilities": { + "can_read": true, + "can_write": true, + "can_manage": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.manage`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시판 관리자가 댓글을 블라인드 처리합니다. `auth:sanctum` + admin + 게시판별 `admin.manage` 권한이 필요하며, 게시판의 `use_comment`가 꺼져 있으면 403으로 차단됩니다. 선택적 `reason`(최대 1000자)을 사유로 받아 `CommentService::blindComment()`가 댓글을 숨김 처리하되 관리 목적으로 보존하며, 복원(restore)으로 되돌릴 수 있습니다. + + +### PATCH /api/modules/sirsoft-board/admin/board/{slug}/posts/{postId}/comments/{id}/restore + +- **라우트명**: `api.modules.sirsoft-board.admin.board.posts.comments.restore` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\CommentController@restore` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.{slug}.admin.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| postId | path | string | 예 | — | 대상 post의 식별자 | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-board/admin/board/apidoc-sample-board/posts/1/comments/1/restore HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.manage`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시판 관리자가 블라인드 처리된 댓글을 복원합니다. `auth:sanctum` + admin + 게시판별 `admin.manage` 권한이 필요하며, 게시판의 `use_comment`가 꺼져 있으면 403으로 차단됩니다. 요청 본문의 선택적 `reason`을 사유로 받아 `CommentService::restoreComment()`가 블라인드 상태를 해제해 댓글을 다시 노출합니다. + + diff --git a/modules/_bundled/sirsoft-board/docs/api/boards.md b/modules/_bundled/sirsoft-board/docs/api/boards.md new file mode 100644 index 00000000..b9b01405 --- /dev/null +++ b/modules/_bundled/sirsoft-board/docs/api/boards.md @@ -0,0 +1,3805 @@ +# Boards API 레퍼런스 + +> **소유**: module `sirsoft-board` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Boards 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-board/admin/boards + +- **라우트명**: `api.modules.sirsoft-board.admin.boards.index` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\BoardController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.boards.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/admin/boards HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `12` | 기본 키 (내부 식별자) | +| name | string | `API 문서 샘플 게시판` | 게시판명 (다국어 JSON) | +| slug | string | `apidoc-sample-board` | 게시판 슬러그 (URL/테이블명) | +| is_active | boolean | `true` | active 여부 | +| type | string | `basic` | 게시판 타입 (basic, gallery, card 등) | +| description | string | `` | 게시판 설명 (다국어 JSON) | +| per_page | integer | `20` | 페이지당 게시글 수 (PC) | +| per_page_mobile | integer | `15` | 페이지당 게시글 수 (Mobile) | +| order_by | string | `created_at` | 정렬 기준 (created_at, view_count, title, author) | +| order_direction | string | `DESC` | 정렬 방향 (ASC, DESC) | +| categories | array | `[]` | 분류 목록 (배열) | +| show_view_count | boolean | `true` | 조회수 노출 | +| secret_mode | string | `disabled` | 비밀글 설정 (disabled: 사용안함, enabled: 사용함, always: 고정) | +| use_comment | boolean | `true` | 댓글 기능 사용 | +| use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) | +| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~5) | +| use_report | boolean | `true` | 게시글/댓글 신고 기능 사용 | +| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) | +| max_comment_depth | integer | `10` | 대댓글 최대 깊이 (1~10) | +| min_title_length | integer | `2` | 최소 제목 글자 수 | +| max_title_length | integer | `200` | 최대 제목 글자 수 | +| min_content_length | integer | `10` | 최소 게시글 글자 수 | +| max_content_length | integer | `10000` | 최대 게시글 글자 수 | +| min_comment_length | integer | `2` | 최소 댓글 글자 수 | +| max_comment_length | integer | `1000` | 최대 댓글 글자 수 | +| blocked_keywords | array | `[]` | 금지어 목록 (배열) | +| use_file_upload | boolean | `true` | 파일 업로드 사용 | +| max_file_size | integer | `10` | 최대 파일 크기 (MB) | +| max_file_count | integer | `5` | max file 개수 (집계) | +| allowed_extensions | array | `["jpg","jpeg","png","gif","pdf","zip"]` | 허용 확장자 배열 | +| add_to_menu | null | `null` | 관리자 메뉴 등록 여부 (폼 요청에서만 채워지는 토글 초기값 — 조회 응답에서는 항상 null) | +| new_display_hours | integer | `24` | 신규 게시글 표시 기간 (시간 단위) | +| board_managers | array | `[]` | 게시판 관리자로 지정된 사용자 목록 (manager 역할 사용자의 uuid/name/email — 역할 기반 파생) | +| board_steps | array | `[]` | 게시판 승인/처리 담당자(스텝)로 지정된 사용자 목록 (step 역할 사용자의 uuid/name/email — 역할 기반 파생) | +| board_manager_ids | array | `[]` | board manager 식별자 배열 (연관 리소스 참조) | +| board_step_ids | array | `[]` | board step 식별자 배열 (연관 리소스 참조) | +| notify_author | boolean | `true` | 작성자 이메일 알림 (댓글, 대댓글, 답변글, 관리자 처리 시) | +| notify_admin_on_post | boolean | `true` | 관리자 이메일 알림 (게시글 등록 시) | +| created_at | string | `2026-07-07 09:34:50` | 생성 일시 | +| updated_at | string | `2026-07-07 09:34:50` | 최종 수정 일시 | +| permissions | null | `null` | 연결된 권한 목록 (id/identifier/name — 역할 경유 권한 관계 파생) | +| category_post_counts | null | `null` | 분류별 게시글 개수 맵 (요청 시 조건부로만 채워지며, 미포함 시 null) | +| posts_count | integer | `0` | posts 개수 (집계) | +| user_abilities | null | `null` | 현재 사용자의 게시판별 세부 권한 맵 (can_read/can_write/can_read_secret/can_manage 등 — include_user_abilities 요청 시에만 채워지며 미포함 시 null) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "게시판 정보를 조회했습니다.", + "data": { + "data": [ + { + "id": 1, + "name": "API 문서 샘플 게시판", + "slug": "apidoc-sample-board", + "is_active": true, + "type": "basic", + "description": "", + "per_page": 20, + "per_page_mobile": 15, + "order_by": "created_at", + "order_direction": "DESC", + "categories": [], + "show_view_count": true, + "secret_mode": "{MASKED}", + "use_comment": true, + "use_reply": true, + "max_reply_depth": 5, + "use_report": true, + "comment_order": "ASC", + "max_comment_depth": 10, + "min_title_length": 2, + "max_title_length": 200, + "min_content_length": 10, + "max_content_length": 10000, + "min_comment_length": 2, + "max_comment_length": 1000, + "blocked_keywords": [], + "use_file_upload": true, + "max_file_size": 10, + "max_file_count": 5, + "allowed_extensions": [ + "jpg", + "jpeg", + "png", + "gif", + "pdf", + "zip" + ], + "add_to_menu": null, + "new_display_hours": 24, + "board_managers": [], + "board_steps": [], + "board_manager_ids": [], + "board_step_ids": [], + "notify_author": true, + "notify_admin_on_post": true, + "created_at": "2026-07-08 10:41:34", + "updated_at": "2026-07-08 10:41:34", + "permissions": null, + "category_post_counts": null, + "posts_count": 0, + "user_abilities": null, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } + ], + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 1, + "from": 1, + "to": 1, + "has_more_pages": false + }, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.boards.read`)이 없는 경우 | + + + +**설명** 관리자 화면의 게시판 관리 목록을 조회합니다. `auth:sanctum` + `sirsoft-board.boards.read` 권한이 필요하며, 요청의 필터/페이징 파라미터를 그대로 서비스에 넘겨 전체 게시판을 페이지네이션합니다. 각 항목은 `BoardCollection::withPermissions()` 로 감싸져 현재 관리자의 생성/수정/삭제 가능 여부(`abilities`)를 함께 반환합니다. + + +### POST /api/modules/sirsoft-board/admin/boards + +- **라우트명**: `api.modules.sirsoft-board.admin.boards.store` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\BoardController@store` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.boards.create` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| name | body | array | 예 | — | 대상의 이름/명칭 | +| slug | body | string | 예 | max 50 | URL 친화 식별자 (slug) | +| description | body | array | 아니오 | — | 설명 | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| type | body | string | 예 | max 50 | 게시판 타입 슬러그 (basic, gallery, card 등 — 등록된 board_types 값 중 하나) | +| add_to_menu | body | boolean | 아니오 | — | 저장 시 이 게시판을 관리자 메뉴에 자동 등록할지 여부 (DB 컬럼 아님 — 메뉴 등록 처리에만 사용) | +| per_page | body | integer | 예 | min 5, max 100 | 페이지당 항목 수 | +| per_page_mobile | body | integer | 예 | min 5, max 100 | 모바일 화면에서 페이지당 표시할 게시글 수 | +| order_by | body | string | 예 | `created_at`, `view_count`, `title`, `author` | 정렬 기준 필드명 | +| order_direction | body | string | 예 | `ASC`, `DESC` | 게시글 목록 정렬 방향 (ASC 오름차순 / DESC 내림차순) | +| categories | body | array | 아니오 | max 50 | 게시글 분류(카테고리) 이름 목록 (최대 50개, 빈/공백 이름 불가) | +| show_view_count | body | boolean | 예 | — | 게시글 목록/상세에 조회수를 노출할지 여부 | +| secret_mode | body | string | 예 | `disabled`, `enabled`, `always` | 비밀글 설정 (disabled 사용 안 함 / enabled 작성자 선택 가능 / always 전 글 비밀글 강제) | +| use_comment | body | boolean | 예 | — | 댓글 기능 사용 여부 | +| use_reply | body | boolean | 예 | — | 게시글 답변(원글에 대한 답글) 기능 사용 여부 | +| use_report | body | boolean | 예 | — | 게시글/댓글 신고 기능 사용 여부 | +| comment_order | body | string | 예 | `ASC`, `DESC` | 댓글 정렬 순서 (ASC 오름차순 / DESC 내림차순) | +| new_display_hours | body | integer | 아니오 | min 1, max 720 | 신규(NEW) 표시를 유지할 기간 (시간 단위, 최대 720시간=30일) | +| min_title_length | body | integer | 아니오 | min 0, max 200 | 게시글 제목 최소 글자 수 | +| max_title_length | body | integer | 아니오 | min 1, max 1000 | 게시글 제목 최대 글자 수 | +| min_content_length | body | integer | 아니오 | min 0, max 10000 | 게시글 본문 최소 글자 수 | +| max_content_length | body | integer | 아니오 | min 1, max 100000 | 게시글 본문 최대 글자 수 | +| min_comment_length | body | integer | 아니오 | min 0, max 1000 | 댓글 최소 글자 수 | +| max_comment_length | body | integer | 아니오 | min 1, max 10000 | 댓글 최대 글자 수 | +| use_file_upload | body | boolean | 예 | — | 파일 첨부 기능 사용 여부 (true일 때 allowed_extensions 최소 1개 필수) | +| max_file_size | body | integer | 아니오 | min 1, max 200 | 첨부파일 1개당 최대 크기 (MB 단위) | +| max_file_count | body | integer | 아니오 | min 1, max 20 | 게시글 1건당 첨부할 수 있는 최대 파일 개수 | +| allowed_extensions | body | array | 예 | min 1 | 업로드 허용 확장자 목록 (예: jpg, png, pdf — 첨부 사용 시 최소 1개 필수) | +| board_manager_ids | body | array | 예 | min 1 | board manager 식별자 배열 | +| board_step_ids | body | array | 아니오 | — | board step 식별자 배열 | +| permissions | body | array | 아니오 | — | 게시판별 세부 권한 매트릭스 (권한 키별 mode/roles — 미지정 시 Service가 Manager/Step 역할을 주입) | +| max_reply_depth | body | integer | 아니오 | min 1, max 10 | 답변글 최대 중첩 깊이 | +| max_comment_depth | body | integer | 아니오 | min 0, max 10 | 대댓글 최대 중첩 깊이 | +| notify_admin_on_post | body | boolean | 예 | — | 게시글 등록 시 관리자에게 이메일 알림 발송 여부 | +| notify_author | body | boolean | 예 | — | 댓글·대댓글·답변글·관리자 처리 발생 시 작성자에게 이메일 알림 발송 여부 | +| blocked_keywords | body | array | 아니오 | — | 게시글/댓글 작성 시 차단할 금지어 목록 (각 항목 최대 100자, 쉼표 구분 문자열도 허용) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-board.board.store_validation_rules`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-board/admin/boards HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": [ + "예시 이름" + ], + "slug": "example-key", + "description": [ + "예시 내용입니다." + ], + "is_active": true, + "type": "예시값", + "add_to_menu": true, + "per_page": 1, + "per_page_mobile": 1, + "order_by": "created_at", + "order_direction": "ASC", + "categories": [ + "예시값" + ], + "show_view_count": true, + "secret_mode": "disabled", + "use_comment": true, + "use_reply": true, + "use_report": true, + "comment_order": "ASC", + "new_display_hours": 1, + "min_title_length": 1, + "max_title_length": 1, + "min_content_length": 1, + "max_content_length": 1, + "min_comment_length": 1, + "max_comment_length": 1, + "use_file_upload": true, + "max_file_size": 1, + "max_file_count": 1, + "allowed_extensions": [ + "예시값" + ], + "board_manager_ids": [ + "예시값" + ], + "board_step_ids": [ + "예시값" + ], + "permissions": [ + "예시값" + ], + "max_reply_depth": 1, + "max_comment_depth": 1, + "notify_admin_on_post": true, + "notify_author": true, + "blocked_keywords": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.boards.create`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 새 게시판을 생성합니다. `auth:sanctum` + `sirsoft-board.boards.create` 권한이 필요하며, `StoreBoardRequest` 로 검증된 이름/슬러그/타입/권한/제한값 등을 받아 게시판과 전용 데이터를 초기화합니다. 슬러그는 게시글 테이블명·권한 키·URL의 기준이 되므로 생성 후 변경할 수 없다는 점에 주의하고, 성공 시 생성된 게시판 리소스를 201로 반환합니다. + + +### GET /api/modules/sirsoft-board/admin/boards/form-data + +- **라우트명**: `api.modules.sirsoft-board.admin.boards.form-data` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\BoardController@getFormData` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.boards.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/admin/boards/form-data HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| type | string | `basic` | 게시판 타입 (basic, gallery, card 등) | +| per_page | integer | `20` | 페이지당 게시글 수 (PC) | +| per_page_mobile | integer | `15` | 페이지당 게시글 수 (Mobile) | +| order_by | string | `created_at` | 정렬 기준 (created_at, view_count, title, author) | +| order_direction | string | `DESC` | 정렬 방향 (ASC, DESC) | +| secret_mode | string | `disabled` | 비밀글 설정 (disabled: 사용안함, enabled: 사용함, always: 고정) | +| use_comment | boolean | `true` | 댓글 기능 사용 | +| use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) | +| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~5) | +| max_comment_depth | integer | `10` | 대댓글 최대 깊이 (1~10) | +| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) | +| show_view_count | boolean | `true` | 조회수 노출 | +| use_report | boolean | `false` | 게시글/댓글 신고 기능 사용 | +| min_title_length | integer | `2` | 최소 제목 글자 수 | +| max_title_length | integer | `200` | 최대 제목 글자 수 | +| min_content_length | integer | `2` | 최소 게시글 글자 수 | +| max_content_length | integer | `10000` | 최대 게시글 글자 수 | +| min_comment_length | integer | `2` | 최소 댓글 글자 수 | +| max_comment_length | integer | `1000` | 최대 댓글 글자 수 | +| use_file_upload | boolean | `false` | 파일 업로드 사용 | +| max_file_size | integer | `10` | 최대 파일 크기 (MB) | +| max_file_count | integer | `5` | max file 개수 (집계) | +| notify_admin_on_post | boolean | `true` | 관리자 이메일 알림 (게시글 등록 시) | +| notify_author | boolean | `true` | 작성자 이메일 알림 (댓글, 대댓글, 답변글, 관리자 처리 시) | +| new_display_hours | integer | `24` | 신규 게시글 표시 기간 (시간 단위) | +| id | null | `null` | 기본 키 (내부 식별자) | +| slug | null | `null` | 게시판 슬러그 (URL/테이블명) | +| is_active | boolean | `true` | active 여부 | +| add_to_menu | boolean | `false` | 관리자 메뉴 등록 여부 폼 토글 초기값 (수정 모드에서 해당 게시판이 이미 관리자 메뉴에 등록돼 있으면 true) | +| board_managers | array | `[{"uuid":"a231747f-e82e-4cf2-9ae1-a261849dce40","name":"A…` | 게시판 관리자로 지정된 사용자 목록 (생성 모드에서는 현재 로그인 관리자가 기본값으로 채워짐 — uuid/name/email) | +| board_steps | array | `[]` | 게시판 승인/처리 담당자(스텝)로 지정된 사용자 목록 (step 역할 사용자의 uuid/name/email — 역할 기반 파생) | +| board_manager_ids | array | `["a231747f-e82e-4cf2-9ae1-a261849dce40"]` | board manager 식별자 배열 (연관 리소스 참조) | +| board_step_ids | array | `[]` | board step 식별자 배열 (연관 리소스 참조) | +| created_at | null | `null` | 생성 일시 | +| updated_at | null | `null` | 최종 수정 일시 | +| name | object | `{"ko":""}` | 게시판명 (다국어 JSON) | +| description | object | `{"ko":""}` | 게시판 설명 (다국어 JSON) | +| categories | array | `[]` | 분류 목록 (배열) | +| blocked_keywords | array | `[]` | 금지어 목록 (배열) | +| allowed_extensions | array | `["jpg","jpeg","png","gif","webp","pdf","doc","docx","xls"…` | 허용 확장자 배열 | +| permissions | object | `{"admin_posts_read":{"_key":"admin_posts_read","name":"ad…` | 연결된 권한 목록 (id/identifier/name — 역할 경유 권한 관계 파생) | +| board_types | array | `[{"id":1,"slug":"basic","name":{"ko":"기본형","en":"Basic Li…` | 선택 가능한 게시판 타입 목록 (타입 선택 UI 렌더링용 — id/slug/name 등) | +| _meta | object | `{"limits":{"per_page_min":5,"per_page_max":100,"min_title…` | 폼 입력 한계값 메타 (config('sirsoft-board.limits') — 페이지당 수·제목/본문/댓글 길이 등 min/max 범위) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "게시판 폼 데이터를 조회했습니다.", + "data": { + "type": "basic", + "per_page": 20, + "per_page_mobile": 15, + "order_by": "created_at", + "order_direction": "DESC", + "secret_mode": "{MASKED}", + "use_comment": true, + "use_reply": true, + "max_reply_depth": 5, + "max_comment_depth": 10, + "comment_order": "ASC", + "show_view_count": true, + "use_report": false, + "min_title_length": 2, + "max_title_length": 200, + "min_content_length": 2, + "max_content_length": 10000, + "min_comment_length": 2, + "max_comment_length": 1000, + "use_file_upload": false, + "max_file_size": 10, + "max_file_count": 5, + "notify_admin_on_post": true, + "notify_author": true, + "new_display_hours": 24, + "id": null, + "slug": null, + "is_active": true, + "add_to_menu": false, + "board_managers": [ + { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com" + } + ], + "board_steps": [], + "board_manager_ids": [ + "a234c2b1-cde8-437f-b28b-23323be2b98d" + ], + "board_step_ids": [], + "created_at": null, + "updated_at": null, + "name": { + "ko": "" + }, + "description": { + "ko": "" + }, + "categories": [], + "blocked_keywords": [], + "allowed_extensions": [ + "jpg", + "jpeg", + "png", + "gif", + "webp", + "pdf", + "doc", + "docx", + "xls", + "xlsx", + "ppt", + "pptx", + "hwp", + "txt", + "zip" + ], + "permissions": { + "admin_posts_read": { + "_key": "admin_posts_read", + "name": "admin.posts.read", + "mode": "roles", + "roles": [ + "admin" + ] + }, + "admin_posts_write": { + "_key": "admin_posts_write", + "name": "admin.posts.write", + "mode": "roles", + "roles": [ + "admin" + ] + }, + "admin_posts_read-secret": { + "_key": "admin_posts_read-secret", + "name": "admin.posts.read-secret", + "mode": "roles", + "roles": [ + "admin" + ] + }, + "admin_comments_read": { + "_key": "admin_comments_read", + "name": "admin.comments.read", + "mode": "roles", + "roles": [ + "admin" + ] + }, + "admin_comments_write": { + "_key": "admin_comments_write", + "name": "admin.comments.write", + "mode": "roles", + "roles": [ + "admin" + ] + }, + "admin_attachments_upload": { + "_key": "admin_attachments_upload", + "name": "admin.attachments.upload", + "mode": "roles", + "roles": [ + "admin" + ] + }, + "admin_attachments_download": { + "_key": "admin_attachments_download", + "name": "admin.attachments.download", + "mode": "roles", + "roles": [ + "admin" + ] + }, + "admin_manage": { + "_key": "admin_manage", + "name": "admin.manage", + "mode": "roles", + "roles": [ + "admin" + ] + }, + "posts_read": { + "_key": "posts_read", + "name": "posts.read", + "mode": "roles", + "roles": [ + "admin", + "user", + "guest" + ] + }, + "posts_write": { + "_key": "posts_write", + "name": "posts.write", + "mode": "roles", + "roles": [ + "admin", + "user" + ] + }, + "posts_read-secret": { + "_key": "posts_read-secret", + "name": "posts.read-secret", + "mode": "roles", + "roles": [ + "admin" + ] + }, + "comments_read": { + "_key": "comments_read", + "name": "comments.read", + "mode": "roles", + "roles": [ + "admin", + "user", + "guest" + ] + }, + "comments_write": { + "_key": "comments_write", + "name": "comments.write", + "mode": "roles", + "roles": [ + "admin", + "user" + ] + }, + "attachments_upload": { + "_key": "attachments_upload", + "name": "attachments.upload", + "mode": "roles", + "roles": [ + "admin", + "user" + ] + }, + "attachments_download": { + "_key": "attachments_download", + "name": "attachments.download", + "mode": "roles", + "roles": [ + "admin", + "user", + "guest" + ] + }, + "manager": { + "_key": "manager", + "name": "manager", + "mode": "roles", + "roles": [ + "admin" + ] + } + }, + "board_types": [ + { + "id": 2, + "slug": "basic", + "name": { + "ko": "기본형", + "en": "Basic List" + } + }, + { + "id": 3, + "slug": "gallery", + "name": { + "ko": "갤러리형", + "en": "Gallery" + } + }, + { + "id": 4, + "slug": "card", + "name": { + "ko": "카드형", + "en": "Card" + } + } + ], + "_meta": { + "limits": { + "per_page_min": 5, + "per_page_max": 100, + "min_title_length_min": 0, + "min_title_length_max": 200, + "max_title_length_min": 1, + "max_title_length_max": 1000, + "min_content_length_min": 0, + "min_content_length_max": 10000, + "max_content_length_min": 1, + "max_content_length_max": 100000, + "min_comment_length_min": 0, + "min_comment_length_max": 1000, + "max_comment_length_min": 1, + "max_comment_length_max": 10000, + "max_file_size_min": 1, + "max_file_size_max": 200, + "max_file_count_min": 1, + "max_file_count_max": 20, + "category_max": 50, + "max_reply_depth_min": 1, + "max_reply_depth_max": 10, + "max_comment_depth_min": 0, + "max_comment_depth_max": 10 + } + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.boards.read`)이 없는 경우 | + + + +**설명** 게시판 생성/수정 폼을 렌더링하는 데 필요한 초기 데이터를 반환합니다. `auth:sanctum` + `sirsoft-board.boards.read` 권한이 필요하며, 쿼리 파라미터로 모드가 분기됩니다: `board_id` 가 있으면 기존 게시판 값(수정 모드), `copy_id` 가 있으면 복사 원본 값(복사 모드), 둘 다 없으면 모듈 기본 설정 기반 기본값(생성 모드)을 채웁니다. 생성 모드에서는 로그인한 관리자가 게시판 관리자 기본값으로 지정되며, 응답에는 선택 가능한 게시판 타입 목록(`board_types`)과 입력 한계값(`_meta.limits`)이 함께 포함됩니다. + + +### GET /api/modules/sirsoft-board/admin/boards/slug/{slug} + +- **라우트명**: `api.modules.sirsoft-board.admin.boards.show-by-slug` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\BoardController@showBySlug` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.boards.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/admin/boards/slug/apidoc-sample-board HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `12` | 기본 키 (내부 식별자) | +| name | string | `API 문서 샘플 게시판` | 게시판명 (다국어 JSON) | +| slug | string | `apidoc-sample-board` | 게시판 슬러그 (URL/테이블명) | +| is_active | boolean | `true` | active 여부 | +| type | string | `basic` | 게시판 타입 (basic, gallery, card 등) | +| description | string | `` | 게시판 설명 (다국어 JSON) | +| per_page | integer | `20` | 페이지당 게시글 수 (PC) | +| per_page_mobile | integer | `15` | 페이지당 게시글 수 (Mobile) | +| order_by | string | `created_at` | 정렬 기준 (created_at, view_count, title, author) | +| order_direction | string | `DESC` | 정렬 방향 (ASC, DESC) | +| categories | array | `[]` | 분류 목록 (배열) | +| show_view_count | boolean | `true` | 조회수 노출 | +| secret_mode | string | `disabled` | 비밀글 설정 (disabled: 사용안함, enabled: 사용함, always: 고정) | +| use_comment | boolean | `true` | 댓글 기능 사용 | +| use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) | +| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~5) | +| use_report | boolean | `true` | 게시글/댓글 신고 기능 사용 | +| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) | +| max_comment_depth | integer | `10` | 대댓글 최대 깊이 (1~10) | +| min_title_length | integer | `2` | 최소 제목 글자 수 | +| max_title_length | integer | `200` | 최대 제목 글자 수 | +| min_content_length | integer | `10` | 최소 게시글 글자 수 | +| max_content_length | integer | `10000` | 최대 게시글 글자 수 | +| min_comment_length | integer | `2` | 최소 댓글 글자 수 | +| max_comment_length | integer | `1000` | 최대 댓글 글자 수 | +| blocked_keywords | array | `[]` | 금지어 목록 (배열) | +| use_file_upload | boolean | `true` | 파일 업로드 사용 | +| max_file_size | integer | `10` | 최대 파일 크기 (MB) | +| max_file_count | integer | `5` | max file 개수 (집계) | +| allowed_extensions | array | `["jpg","jpeg","png","gif","pdf","zip"]` | 허용 확장자 배열 | +| add_to_menu | null | `null` | 관리자 메뉴 등록 여부 (폼 요청에서만 채워지는 토글 초기값 — 조회 응답에서는 항상 null) | +| new_display_hours | integer | `24` | 신규 게시글 표시 기간 (시간 단위) | +| board_managers | array | `[]` | 게시판 관리자로 지정된 사용자 목록 (manager 역할 사용자의 uuid/name/email — 역할 기반 파생) | +| board_steps | array | `[]` | 게시판 승인/처리 담당자(스텝)로 지정된 사용자 목록 (step 역할 사용자의 uuid/name/email — 역할 기반 파생) | +| board_manager_ids | array | `[]` | board manager 식별자 배열 (연관 리소스 참조) | +| board_step_ids | array | `[]` | board step 식별자 배열 (연관 리소스 참조) | +| notify_author | boolean | `true` | 작성자 이메일 알림 (댓글, 대댓글, 답변글, 관리자 처리 시) | +| notify_admin_on_post | boolean | `true` | 관리자 이메일 알림 (게시글 등록 시) | +| created_at | string | `2026-07-07 09:34:50` | 생성 일시 | +| updated_at | string | `2026-07-07 09:34:50` | 최종 수정 일시 | +| permissions | null | `null` | 연결된 권한 목록 (id/identifier/name — 역할 경유 권한 관계 파생) | +| category_post_counts | null | `null` | 분류별 게시글 개수 맵 (요청 시 조건부로만 채워지며, 미포함 시 null) | +| posts_count | integer | `0` | posts 개수 (집계) | +| user_abilities | object | `{"can_read":true,"can_write":true,"can_read_secret":true,…` | 현재 사용자의 게시판별 세부 권한 맵 (can_read/can_write/can_read_secret/can_read_comments/can_write_comments/can_upload/can_download/can_manage — 관리자 라우트에서는 항상 포함) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "게시판 정보를 조회했습니다.", + "data": { + "id": 1, + "name": "API 문서 샘플 게시판", + "slug": "apidoc-sample-board", + "is_active": true, + "type": "basic", + "description": "", + "per_page": 20, + "per_page_mobile": 15, + "order_by": "created_at", + "order_direction": "DESC", + "categories": [], + "show_view_count": true, + "secret_mode": "{MASKED}", + "use_comment": true, + "use_reply": true, + "max_reply_depth": 5, + "use_report": true, + "comment_order": "ASC", + "max_comment_depth": 10, + "min_title_length": 2, + "max_title_length": 200, + "min_content_length": 10, + "max_content_length": 10000, + "min_comment_length": 2, + "max_comment_length": 1000, + "blocked_keywords": [], + "use_file_upload": true, + "max_file_size": 10, + "max_file_count": 5, + "allowed_extensions": [ + "jpg", + "jpeg", + "png", + "gif", + "pdf", + "zip" + ], + "add_to_menu": null, + "new_display_hours": 24, + "board_managers": [], + "board_steps": [], + "board_manager_ids": [], + "board_step_ids": [], + "notify_author": true, + "notify_admin_on_post": true, + "created_at": "2026-07-08 10:41:34", + "updated_at": "2026-07-08 10:41:34", + "permissions": null, + "category_post_counts": null, + "posts_count": 0, + "user_abilities": { + "can_read": true, + "can_write": true, + "can_read_secret": "{MASKED}", + "can_read_comments": true, + "can_write_comments": true, + "can_upload": true, + "can_download": true, + "can_manage": true + }, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.boards.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 슬러그로 게시판 상세를 조회합니다(관리자용). `auth:sanctum` + `sirsoft-board.boards.read` 권한이 필요하며, ID 대신 슬러그로 접근하는 화면(게시글 관리·글쓰기 진입 등)에서 사용합니다. 관리자 라우트이므로 항상 사용자 권한 맵(`user_abilities`)을 포함하며, `parent_id` 쿼리 파라미터가 있으면 답변글 작성을 위해 원글 정보와 기본 제목(`RE: ...`)을 `parent_post` 로 함께 반환합니다. + + +### DELETE /api/modules/sirsoft-board/admin/boards/{board} + +- **라우트명**: `api.modules.sirsoft-board.admin.boards.destroy` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\BoardController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.boards.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| board | path | string | 예 | — | 대상 board의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-board/admin/boards/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "게시판이 삭제되었습니다.", + "data": null +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.boards.delete`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시판을 삭제합니다. `auth:sanctum` + `sirsoft-board.boards.delete` 권한이 필요하며, 요청의 `force_delete` 불리언 플래그로 삭제 방식을 결정합니다. 게시판 삭제는 소속 게시글·댓글·첨부·권한까지 연쇄 정리를 수반하므로 되돌릴 수 없다는 점에 주의해야 합니다. + + +### GET /api/modules/sirsoft-board/admin/boards/{board} + +- **라우트명**: `api.modules.sirsoft-board.admin.boards.show` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\BoardController@show` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.boards.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| board | path | string | 예 | — | 대상 board의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/admin/boards/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `12` | 기본 키 (내부 식별자) | +| name | string | `API 문서 샘플 게시판` | 게시판명 (다국어 JSON) | +| slug | string | `apidoc-sample-board` | 게시판 슬러그 (URL/테이블명) | +| is_active | boolean | `true` | active 여부 | +| type | string | `basic` | 게시판 타입 (basic, gallery, card 등) | +| description | string | `` | 게시판 설명 (다국어 JSON) | +| per_page | integer | `20` | 페이지당 게시글 수 (PC) | +| per_page_mobile | integer | `15` | 페이지당 게시글 수 (Mobile) | +| order_by | string | `created_at` | 정렬 기준 (created_at, view_count, title, author) | +| order_direction | string | `DESC` | 정렬 방향 (ASC, DESC) | +| categories | array | `[]` | 분류 목록 (배열) | +| show_view_count | boolean | `true` | 조회수 노출 | +| secret_mode | string | `disabled` | 비밀글 설정 (disabled: 사용안함, enabled: 사용함, always: 고정) | +| use_comment | boolean | `true` | 댓글 기능 사용 | +| use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) | +| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~5) | +| use_report | boolean | `true` | 게시글/댓글 신고 기능 사용 | +| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) | +| max_comment_depth | integer | `10` | 대댓글 최대 깊이 (1~10) | +| min_title_length | integer | `2` | 최소 제목 글자 수 | +| max_title_length | integer | `200` | 최대 제목 글자 수 | +| min_content_length | integer | `10` | 최소 게시글 글자 수 | +| max_content_length | integer | `10000` | 최대 게시글 글자 수 | +| min_comment_length | integer | `2` | 최소 댓글 글자 수 | +| max_comment_length | integer | `1000` | 최대 댓글 글자 수 | +| blocked_keywords | array | `[]` | 금지어 목록 (배열) | +| use_file_upload | boolean | `true` | 파일 업로드 사용 | +| max_file_size | integer | `10` | 최대 파일 크기 (MB) | +| max_file_count | integer | `5` | max file 개수 (집계) | +| allowed_extensions | array | `["jpg","jpeg","png","gif","pdf","zip"]` | 허용 확장자 배열 | +| add_to_menu | null | `null` | 관리자 메뉴 등록 여부 (폼 요청에서만 채워지는 토글 초기값 — 조회 응답에서는 항상 null) | +| new_display_hours | integer | `24` | 신규 게시글 표시 기간 (시간 단위) | +| board_managers | array | `[]` | 게시판 관리자로 지정된 사용자 목록 (manager 역할 사용자의 uuid/name/email — 역할 기반 파생) | +| board_steps | array | `[]` | 게시판 승인/처리 담당자(스텝)로 지정된 사용자 목록 (step 역할 사용자의 uuid/name/email — 역할 기반 파생) | +| board_manager_ids | array | `[]` | board manager 식별자 배열 (연관 리소스 참조) | +| board_step_ids | array | `[]` | board step 식별자 배열 (연관 리소스 참조) | +| notify_author | boolean | `true` | 작성자 이메일 알림 (댓글, 대댓글, 답변글, 관리자 처리 시) | +| notify_admin_on_post | boolean | `true` | 관리자 이메일 알림 (게시글 등록 시) | +| created_at | string | `2026-07-07 09:34:50` | 생성 일시 | +| updated_at | string | `2026-07-07 09:34:50` | 최종 수정 일시 | +| permissions | object | `{"admin_posts_read":{"_key":"admin_posts_read","name":"ad…` | 연결된 권한 목록 (id/identifier/name — 역할 경유 권한 관계 파생) | +| category_post_counts | null | `null` | 분류별 게시글 개수 맵 (요청 시 조건부로만 채워지며, 미포함 시 null) | +| posts_count | integer | `0` | posts 개수 (집계) | +| user_abilities | null | `null` | 현재 사용자의 게시판별 세부 권한 맵 (can_read/can_write/can_read_secret/can_manage 등 — include_user_abilities 요청 시에만 채워지며 미포함 시 null) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "게시판 정보를 조회했습니다.", + "data": { + "id": 1, + "name": "API 문서 샘플 게시판", + "slug": "apidoc-sample-board", + "is_active": true, + "type": "basic", + "description": "", + "per_page": 20, + "per_page_mobile": 15, + "order_by": "created_at", + "order_direction": "DESC", + "categories": [], + "show_view_count": true, + "secret_mode": "{MASKED}", + "use_comment": true, + "use_reply": true, + "max_reply_depth": 5, + "use_report": true, + "comment_order": "ASC", + "max_comment_depth": 10, + "min_title_length": 2, + "max_title_length": 200, + "min_content_length": 10, + "max_content_length": 10000, + "min_comment_length": 2, + "max_comment_length": 1000, + "blocked_keywords": [], + "use_file_upload": true, + "max_file_size": 10, + "max_file_count": 5, + "allowed_extensions": [ + "jpg", + "jpeg", + "png", + "gif", + "pdf", + "zip" + ], + "add_to_menu": null, + "new_display_hours": 24, + "board_managers": [], + "board_steps": [], + "board_manager_ids": [], + "board_step_ids": [], + "notify_author": true, + "notify_admin_on_post": true, + "created_at": "2026-07-08 10:41:34", + "updated_at": "2026-07-08 10:41:34", + "permissions": { + "admin_posts_read": { + "_key": "admin_posts_read", + "name": "admin.posts.read", + "mode": "roles", + "roles": [ + "admin" + ] + }, + "admin_posts_write": { + "_key": "admin_posts_write", + "name": "admin.posts.write", + "mode": "roles", + "roles": [ + "admin" + ] + }, + "admin_posts_read-secret": { + "_key": "admin_posts_read-secret", + "name": "admin.posts.read-secret", + "mode": "roles", + "roles": [ + "admin" + ] + }, + "admin_comments_read": { + "_key": "admin_comments_read", + "name": "admin.comments.read", + "mode": "roles", + "roles": [ + "admin" + ] + }, + "admin_comments_write": { + "_key": "admin_comments_write", + "name": "admin.comments.write", + "mode": "roles", + "roles": [ + "admin" + ] + }, + "admin_attachments_upload": { + "_key": "admin_attachments_upload", + "name": "admin.attachments.upload", + "mode": "roles", + "roles": [ + "admin" + ] + }, + "admin_attachments_download": { + "_key": "admin_attachments_download", + "name": "admin.attachments.download", + "mode": "roles", + "roles": [ + "admin" + ] + }, + "admin_manage": { + "_key": "admin_manage", + "name": "admin.manage", + "mode": "roles", + "roles": [ + "admin" + ] + }, + "posts_read": { + "_key": "posts_read", + "name": "posts.read", + "mode": "roles", + "roles": [ + "admin", + "user", + "guest" + ] + }, + "posts_write": { + "_key": "posts_write", + "name": "posts.write", + "mode": "roles", + "roles": [ + "admin", + "user" + ] + }, + "posts_read-secret": { + "_key": "posts_read-secret", + "name": "posts.read-secret", + "mode": "roles", + "roles": [ + "admin" + ] + }, + "comments_read": { + "_key": "comments_read", + "name": "comments.read", + "mode": "roles", + "roles": [ + "admin", + "user", + "guest" + ] + }, + "comments_write": { + "_key": "comments_write", + "name": "comments.write", + "mode": "roles", + "roles": [ + "admin", + "user" + ] + }, + "attachments_upload": { + "_key": "attachments_upload", + "name": "attachments.upload", + "mode": "roles", + "roles": [ + "admin", + "user" + ] + }, + "attachments_download": { + "_key": "attachments_download", + "name": "attachments.download", + "mode": "roles", + "roles": [ + "admin", + "user", + "guest" + ] + }, + "manager": { + "_key": "manager", + "name": "manager", + "mode": "roles", + "roles": [ + "admin" + ] + } + }, + "category_post_counts": null, + "posts_count": 0, + "user_abilities": null, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.boards.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** ID로 게시판 상세를 조회합니다(관리자용). `auth:sanctum` + `sirsoft-board.boards.read` 권한이 필요하며, 게시판 설정 편집·상세 확인 화면에서 사용합니다. 게시판 권한 매트릭스(`permissions`)를 포함한 전체 설정 필드를 반환합니다. + + +### PUT /api/modules/sirsoft-board/admin/boards/{board} + +- **라우트명**: `api.modules.sirsoft-board.admin.boards.update` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\BoardController@update` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.boards.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| board | path | string | 예 | — | 대상 board의 식별자 | +| name | body | array | 예 | — | 대상의 이름/명칭 | +| description | body | array | 아니오 | — | 설명 | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| type | body | string | 예 | max 50 | 게시판 타입 슬러그 (basic, gallery, card 등 — 등록된 board_types 값 중 하나) | +| add_to_menu | body | boolean | 아니오 | — | 저장 시 이 게시판을 관리자 메뉴에 자동 등록할지 여부 (DB 컬럼 아님 — 메뉴 등록 처리에만 사용) | +| per_page | body | integer | 예 | min 5, max 100 | 페이지당 항목 수 | +| per_page_mobile | body | integer | 예 | min 5, max 100 | 모바일 화면에서 페이지당 표시할 게시글 수 | +| order_by | body | string | 예 | `created_at`, `view_count`, `title`, `author` | 정렬 기준 필드명 | +| order_direction | body | string | 예 | `ASC`, `DESC` | 게시글 목록 정렬 방향 (ASC 오름차순 / DESC 내림차순) | +| categories | body | array | 아니오 | max 50 | 게시글 분류(카테고리) 이름 목록 (최대 50개, 빈/공백 이름 불가) | +| show_view_count | body | boolean | 예 | — | 게시글 목록/상세에 조회수를 노출할지 여부 | +| secret_mode | body | string | 예 | `disabled`, `enabled`, `always` | 비밀글 설정 (disabled 사용 안 함 / enabled 작성자 선택 가능 / always 전 글 비밀글 강제) | +| use_comment | body | boolean | 예 | — | 댓글 기능 사용 여부 | +| use_reply | body | boolean | 예 | — | 게시글 답변(원글에 대한 답글) 기능 사용 여부 | +| use_report | body | boolean | 예 | — | 게시글/댓글 신고 기능 사용 여부 | +| comment_order | body | string | 예 | `ASC`, `DESC` | 댓글 정렬 순서 (ASC 오름차순 / DESC 내림차순) | +| new_display_hours | body | integer | 아니오 | min 1, max 720 | 신규(NEW) 표시를 유지할 기간 (시간 단위, 최대 720시간=30일) | +| min_title_length | body | integer | 아니오 | min 0, max 200 | 게시글 제목 최소 글자 수 | +| max_title_length | body | integer | 아니오 | min 1, max 1000 | 게시글 제목 최대 글자 수 | +| min_content_length | body | integer | 아니오 | min 0, max 10000 | 게시글 본문 최소 글자 수 | +| max_content_length | body | integer | 아니오 | min 1, max 100000 | 게시글 본문 최대 글자 수 | +| min_comment_length | body | integer | 아니오 | min 0, max 1000 | 댓글 최소 글자 수 | +| max_comment_length | body | integer | 아니오 | min 1, max 10000 | 댓글 최대 글자 수 | +| use_file_upload | body | boolean | 예 | — | 파일 첨부 기능 사용 여부 (true일 때 allowed_extensions 최소 1개 필수) | +| max_file_size | body | integer | 아니오 | min 1, max 200 | 첨부파일 1개당 최대 크기 (MB 단위) | +| max_file_count | body | integer | 아니오 | min 1, max 20 | 게시글 1건당 첨부할 수 있는 최대 파일 개수 | +| allowed_extensions | body | array | 아니오 | min 1 | 업로드 허용 확장자 목록 (예: jpg, png, pdf — 첨부 사용 시 최소 1개 필수) | +| board_manager_ids | body | array | 예 | min 1 | board manager 식별자 배열 | +| board_step_ids | body | array | 아니오 | — | board step 식별자 배열 | +| permissions | body | array | 예 | — | 게시판별 세부 권한 매트릭스 (권한 키별 mode/roles — 각 권한을 all 또는 특정 역할에 부여) | +| max_reply_depth | body | integer | 아니오 | min 1, max 10 | 답변글 최대 중첩 깊이 | +| max_comment_depth | body | integer | 아니오 | min 0, max 10 | 대댓글 최대 중첩 깊이 | +| notify_admin_on_post | body | boolean | 예 | — | 게시글 등록 시 관리자에게 이메일 알림 발송 여부 | +| notify_author | body | boolean | 예 | — | 댓글·대댓글·답변글·관리자 처리 발생 시 작성자에게 이메일 알림 발송 여부 | +| blocked_keywords | body | array | 아니오 | — | 게시글/댓글 작성 시 차단할 금지어 목록 (각 항목 최대 100자, 쉼표 구분 문자열도 허용) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-board.board.update_validation_rules`). + +**요청 예시** + +```http +PUT /api/modules/sirsoft-board/admin/boards/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": [ + "예시 이름" + ], + "description": [ + "예시 내용입니다." + ], + "is_active": true, + "type": "예시값", + "add_to_menu": true, + "per_page": 1, + "per_page_mobile": 1, + "order_by": "created_at", + "order_direction": "ASC", + "categories": [ + "예시값" + ], + "show_view_count": true, + "secret_mode": "disabled", + "use_comment": true, + "use_reply": true, + "use_report": true, + "comment_order": "ASC", + "new_display_hours": 1, + "min_title_length": 1, + "max_title_length": 1, + "min_content_length": 1, + "max_content_length": 1, + "min_comment_length": 1, + "max_comment_length": 1, + "use_file_upload": true, + "max_file_size": 1, + "max_file_count": 1, + "allowed_extensions": [ + "예시값" + ], + "board_manager_ids": [ + "예시값" + ], + "board_step_ids": [ + "예시값" + ], + "permissions": [ + "예시값" + ], + "max_reply_depth": 1, + "max_comment_depth": 1, + "notify_admin_on_post": true, + "notify_author": true, + "blocked_keywords": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.boards.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시판 설정을 수정합니다. `auth:sanctum` + `sirsoft-board.boards.update` 권한이 필요하며, `UpdateBoardRequest` 로 검증된 값으로 이름/설명/제한값/권한 매트릭스 등을 갱신합니다. 슬러그와 타입은 생성 시 고정되므로 이 요청으로는 변경 대상이 아니며, 성공 시 갱신된 게시판 리소스를 반환합니다. + + +### POST /api/modules/sirsoft-board/admin/boards/{board}/add-to-menu + +- **라우트명**: `api.modules.sirsoft-board.admin.boards.add-to-menu` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\BoardController@addToAdminMenu` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.boards.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| board | path | string | 예 | — | 대상 board의 식별자 | + +**요청 예시** + +```http +POST /api/modules/sirsoft-board/admin/boards/1/add-to-menu HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| menu | object | `{"name":{"ko":"API 문서 샘플 게시판 게시판","en":"API Doc Sample Bo…` | 이 게시판용으로 새로 생성된 관리자 사이드바 메뉴 항목 (name/slug/url/icon/parent_id/order 등 Menu 레코드 전체) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "관리자 메뉴에 추가되었습니다.", + "data": { + "menu": { + "name": { + "ko": "API 문서 샘플 게시판 게시판", + "en": "API Doc Sample Board Board" + }, + "slug": "board-apidoc-sample-board", + "url": "/admin/board/apidoc-sample-board", + "icon": "fas fa-clipboard-list", + "parent_id": 15, + "is_active": true, + "extension_type": "module", + "extension_identifier": "sirsoft-board", + "order": 4, + "updated_at": "2026-07-08T06:01:45.000000Z", + "created_at": "2026-07-08T06:01:45.000000Z", + "id": 27 + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.boards.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 해당 게시판을 관리자 메뉴에 등록합니다. `auth:sanctum` + `sirsoft-board.boards.update` 권한이 필요하며, 관리자 사이드바에서 게시판별 관리 메뉴를 바로 진입할 수 있도록 메뉴 항목을 생성합니다. 이미 등록된 게시판이면 `MenuAlreadyExistsException` 이 발생해 중복 등록을 막습니다. + + +### GET /api/modules/sirsoft-board/boards + +- **라우트명**: `api.modules.sirsoft-board.boards.index` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\BoardController@index` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/boards HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `12` | 기본 키 (내부 식별자) | +| name | string | `API 문서 샘플 게시판` | 게시판명 (다국어 JSON) | +| slug | string | `apidoc-sample-board` | 게시판 슬러그 (URL/테이블명) | +| description | string | `` | 게시판 설명 (다국어 JSON) | +| posts_count | integer | `0` | posts 개수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": [ + { + "id": 1, + "name": "API 문서 샘플 게시판", + "slug": "apidoc-sample-board", + "description": "", + "posts_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } + ] +} +``` + +**에러 응답** + +_대표 에러 없음 (공개 조회). _ + + + +**설명** 사용자 화면용 활성 게시판 목록을 경량으로 조회합니다. 전체 게시판 목록 페이지에서 사용하며, `id`/`name`/`slug`/`description`/`posts_count` 만 반환합니다. `limit` 파라미터(0~10, 기본 0)를 주면 게시판별 최신글도 함께 담아주며(답변글·삭제·블라인드 제외), 활성화된 게시판만 노출됩니다. + + +### GET /api/modules/sirsoft-board/boards/board-menu + +- **라우트명**: `api.modules.sirsoft-board.boards.board-menu` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\BoardController@boardMenu` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/boards/board-menu HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | string | `테스트 게시판` | 게시판명 (다국어 JSON) | +| slug | string | `test` | 게시판 슬러그 (URL/테이블명) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": [ + { + "id": 1, + "name": "API 문서 샘플 게시판", + "slug": "apidoc-sample-board" + } + ] +} +``` + +**에러 응답** + +_대표 에러 없음 (공개 조회). _ + + + +**설명** 헤더/네비게이션 메뉴에 노출할 게시판 목록을 최소 필드(`id`/`name`/`slug`)로 반환합니다. 활성 게시판만 오래된 순(created_at ASC)으로 정렬해 캐시에서 제공하는 경량 조회 API입니다. + + +### GET /api/modules/sirsoft-board/boards/popular + +- **라우트명**: `api.modules.sirsoft-board.boards.popular` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\BoardController@popular` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/boards/popular HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": [ + { + "id": 1, + "board_slug": "apidoc-sample-board", + "board_name": "API 문서 샘플 게시판", + "title": "API 문서 샘플 게시글", + "excerpt": "API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.", + "author": { + "id": 6, + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "is_guest": false, + "avatar": null, + "status": "active", + "status_label": "활성" + }, + "view_count": 44, + "comment_count": 0, + "created_at": "2026-07-08 10:41:34", + "created_at_formatted": "4시간 전" + } + ] +} +``` + +**에러 응답** + +_대표 에러 없음 (공개 조회). _ + + + +**설명** 조회수가 많은 인기 게시글 목록을 반환합니다. 홈/사이드바 위젯용 경량 조회로, `period`(today·week·month·year, 기본 week; `all` 은 하위 호환으로 year 로 매핑)로 기간을, `limit`(기본 20, 최대 50)으로 개수를 조절합니다. 결과는 캐시에서 제공됩니다. + + +### GET /api/modules/sirsoft-board/boards/popular-boards + +- **라우트명**: `api.modules.sirsoft-board.boards.popular-boards` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\BoardController@popularBoards` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/boards/popular-boards HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `6` | 기본 키 (내부 식별자) | +| name | string | `Q&A` | 게시판명 (다국어 JSON) | +| slug | string | `qna` | 게시판 슬러그 (URL/테이블명) | +| description | string | `궁금한 점을 질문하고 답변을 받으세요` | 게시판 설명 (다국어 JSON) | +| posts_count | integer | `40` | posts 개수 (집계) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": [ + { + "id": 1, + "name": "API 문서 샘플 게시판", + "slug": "apidoc-sample-board", + "description": "", + "posts_count": 0 + } + ] +} +``` + +**에러 응답** + +_대표 에러 없음 (공개 조회). _ + + + +**설명** 게시글 수가 많은 인기 게시판 목록을 반환합니다. 홈 화면 등에서 활발한 게시판을 노출하는 데 사용하며, `limit`(기본 4, 최대 20)으로 개수를 조절합니다. 결과는 캐시에서 제공됩니다. + + +### GET /api/modules/sirsoft-board/boards/posts/recent + +- **라우트명**: `api.modules.sirsoft-board.boards.posts.recent` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\BoardController@recentPosts` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/boards/posts/recent HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `237` | 기본 키 (내부 식별자) | +| board_slug | string | `apidoc-sample-board` | 게시글이 속한 게시판의 슬러그 (게시글 URL 구성용) | +| board_name | string | `API 문서 샘플 게시판` | 게시글이 속한 게시판의 다국어 이름 | +| title | string | `API 문서 샘플 게시글` | 제목 | +| author_name | string | `API 문서 샘플 사용자` | 작성자 표시 이름 (회원은 회원명, 비회원은 입력한 이름) | +| created_at | string | `2026-07-07 09:34:50` | 생성 일시 | +| created_at_formatted | string | `방금 전` | `created_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| view_count | integer | `42` | view 개수 (집계) | +| comment_count | integer | `0` | comment 개수 (집계) | +| is_secret | boolean | `false` | secret 여부 | +| is_new | boolean | `true` | new 여부 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": [ + { + "id": 1, + "board_slug": "apidoc-sample-board", + "board_name": "API 문서 샘플 게시판", + "title": "API 문서 샘플 게시글", + "author_name": "API 문서 샘플 사용자", + "created_at": "2026-07-08 10:41:34", + "created_at_formatted": "4시간 전", + "view_count": 44, + "comment_count": 0, + "is_secret": "{MASKED}", + "is_new": true + } + ] +} +``` + +**에러 응답** + +_대표 에러 없음 (공개 조회). _ + + + +**설명** 모든 게시판을 통합한 최근 게시글 목록을 반환합니다. 홈 화면의 최신글 위젯 등에 사용하며, `limit`(기본 5, 최대 20)으로 개수를 조절합니다. 각 항목에는 게시판 슬러그/이름과 상대 시간(`created_at_formatted`), 신규 여부(`is_new`)가 함께 담기고, 결과는 캐시에서 제공됩니다. + + +### GET /api/modules/sirsoft-board/boards/stats + +- **라우트명**: `api.modules.sirsoft-board.boards.stats` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\BoardController@stats` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/boards/stats HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| users | integer | `156` | 전체 회원 수 | +| boards | integer | `10` | 활성 게시판 수 | +| posts | integer | `116` | 전체 게시글 수 | +| comments | integer | `321` | 전체 댓글 수 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "users": 2, + "boards": 1, + "posts": 0, + "comments": 0 + } +} +``` + +**에러 응답** + +_대표 에러 없음 (공개 조회). _ + + + +**설명** 게시판 관련 집계 통계를 반환합니다. 홈 화면의 통계 카드 등에 사용하며, 회원 수·활성 게시판 수·전체 게시글 수·전체 댓글 수를 담아 캐시에서 제공하는 경량 조회 API입니다. + + +### GET /api/modules/sirsoft-board/boards/{slug} + +- **라우트명**: `api.modules.sirsoft-board.boards.show` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\BoardController@show` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/boards/apidoc-sample-board HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `12` | 기본 키 (내부 식별자) | +| name | string | `API 문서 샘플 게시판` | 게시판명 (다국어 JSON) | +| slug | string | `apidoc-sample-board` | 게시판 슬러그 (URL/테이블명) | +| is_active | boolean | `true` | active 여부 | +| type | string | `basic` | 게시판 타입 (basic, gallery, card 등) | +| description | string | `` | 게시판 설명 (다국어 JSON) | +| per_page | integer | `20` | 페이지당 게시글 수 (PC) | +| per_page_mobile | integer | `15` | 페이지당 게시글 수 (Mobile) | +| order_by | string | `created_at` | 정렬 기준 (created_at, view_count, title, author) | +| order_direction | string | `DESC` | 정렬 방향 (ASC, DESC) | +| categories | array | `[]` | 분류 목록 (배열) | +| show_view_count | boolean | `true` | 조회수 노출 | +| secret_mode | string | `disabled` | 비밀글 설정 (disabled: 사용안함, enabled: 사용함, always: 고정) | +| use_comment | boolean | `true` | 댓글 기능 사용 | +| use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) | +| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~5) | +| use_report | boolean | `true` | 게시글/댓글 신고 기능 사용 | +| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) | +| max_comment_depth | integer | `10` | 대댓글 최대 깊이 (1~10) | +| min_title_length | integer | `2` | 최소 제목 글자 수 | +| max_title_length | integer | `200` | 최대 제목 글자 수 | +| min_content_length | integer | `10` | 최소 게시글 글자 수 | +| max_content_length | integer | `10000` | 최대 게시글 글자 수 | +| min_comment_length | integer | `2` | 최소 댓글 글자 수 | +| max_comment_length | integer | `1000` | 최대 댓글 글자 수 | +| blocked_keywords | array | `[]` | 금지어 목록 (배열) | +| use_file_upload | boolean | `true` | 파일 업로드 사용 | +| max_file_size | integer | `10` | 최대 파일 크기 (MB) | +| max_file_count | integer | `5` | max file 개수 (집계) | +| allowed_extensions | array | `["jpg","jpeg","png","gif","pdf","zip"]` | 허용 확장자 배열 | +| add_to_menu | null | `null` | 관리자 메뉴 등록 여부 (폼 요청에서만 채워지는 토글 초기값 — 조회 응답에서는 항상 null) | +| new_display_hours | integer | `24` | 신규 게시글 표시 기간 (시간 단위) | +| board_managers | array | `[]` | 게시판 관리자로 지정된 사용자 목록 (manager 역할 사용자의 uuid/name/email — 역할 기반 파생) | +| board_steps | array | `[]` | 게시판 승인/처리 담당자(스텝)로 지정된 사용자 목록 (step 역할 사용자의 uuid/name/email — 역할 기반 파생) | +| board_manager_ids | array | `[]` | board manager 식별자 배열 (연관 리소스 참조) | +| board_step_ids | array | `[]` | board step 식별자 배열 (연관 리소스 참조) | +| notify_author | boolean | `true` | 작성자 이메일 알림 (댓글, 대댓글, 답변글, 관리자 처리 시) | +| notify_admin_on_post | boolean | `true` | 관리자 이메일 알림 (게시글 등록 시) | +| created_at | string | `2026-07-07 09:34:50` | 생성 일시 | +| updated_at | string | `2026-07-07 09:34:50` | 최종 수정 일시 | +| permissions | object | `{"admin_posts_read":{"_key":"admin_posts_read","name":"ad…` | 연결된 권한 목록 (id/identifier/name — 역할 경유 권한 관계 파생) | +| category_post_counts | null | `null` | 분류별 게시글 개수 맵 (요청 시 조건부로만 채워지며, 미포함 시 null) | +| posts_count | integer | `0` | posts 개수 (집계) | +| user_abilities | null | `null` | 현재 사용자의 게시판별 세부 권한 맵 (can_read/can_write/can_read_secret/can_manage 등 — include_user_abilities 요청 시에만 채워지며 미포함 시 null) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "id": 1, + "name": "API 문서 샘플 게시판", + "slug": "apidoc-sample-board", + "is_active": true, + "type": "basic", + "description": "", + "per_page": 20, + "per_page_mobile": 15, + "order_by": "created_at", + "order_direction": "DESC", + "categories": [], + "show_view_count": true, + "secret_mode": "{MASKED}", + "use_comment": true, + "use_reply": true, + "max_reply_depth": 5, + "use_report": true, + "comment_order": "ASC", + "max_comment_depth": 10, + "min_title_length": 2, + "max_title_length": 200, + "min_content_length": 10, + "max_content_length": 10000, + "min_comment_length": 2, + "max_comment_length": 1000, + "blocked_keywords": [], + "use_file_upload": true, + "max_file_size": 10, + "max_file_count": 5, + "allowed_extensions": [ + "jpg", + "jpeg", + "png", + "gif", + "pdf", + "zip" + ], + "add_to_menu": null, + "new_display_hours": 24, + "board_managers": [], + "board_steps": [], + "board_manager_ids": [], + "board_step_ids": [], + "notify_author": true, + "notify_admin_on_post": true, + "created_at": "2026-07-08 10:41:34", + "updated_at": "2026-07-08 10:41:34", + "permissions": { + "admin_posts_read": { + "_key": "admin_posts_read", + "name": "admin.posts.read", + "mode": "roles", + "roles": [ + "admin" + ] + }, + "admin_posts_write": { + "_key": "admin_posts_write", + "name": "admin.posts.write", + "mode": "roles", + "roles": [ + "admin" + ] + }, + "admin_posts_read-secret": { + "_key": "admin_posts_read-secret", + "name": "admin.posts.read-secret", + "mode": "roles", + "roles": [ + "admin" + ] + }, + "admin_comments_read": { + "_key": "admin_comments_read", + "name": "admin.comments.read", + "mode": "roles", + "roles": [ + "admin" + ] + }, + "admin_comments_write": { + "_key": "admin_comments_write", + "name": "admin.comments.write", + "mode": "roles", + "roles": [ + "admin" + ] + }, + "admin_attachments_upload": { + "_key": "admin_attachments_upload", + "name": "admin.attachments.upload", + "mode": "roles", + "roles": [ + "admin" + ] + }, + "admin_attachments_download": { + "_key": "admin_attachments_download", + "name": "admin.attachments.download", + "mode": "roles", + "roles": [ + "admin" + ] + }, + "admin_manage": { + "_key": "admin_manage", + "name": "admin.manage", + "mode": "roles", + "roles": [ + "admin" + ] + }, + "posts_read": { + "_key": "posts_read", + "name": "posts.read", + "mode": "roles", + "roles": [ + "admin", + "user", + "guest" + ] + }, + "posts_write": { + "_key": "posts_write", + "name": "posts.write", + "mode": "roles", + "roles": [ + "admin", + "user" + ] + }, + "posts_read-secret": { + "_key": "posts_read-secret", + "name": "posts.read-secret", + "mode": "roles", + "roles": [ + "admin" + ] + }, + "comments_read": { + "_key": "comments_read", + "name": "comments.read", + "mode": "roles", + "roles": [ + "admin", + "user", + "guest" + ] + }, + "comments_write": { + "_key": "comments_write", + "name": "comments.write", + "mode": "roles", + "roles": [ + "admin", + "user" + ] + }, + "attachments_upload": { + "_key": "attachments_upload", + "name": "attachments.upload", + "mode": "roles", + "roles": [ + "admin", + "user" + ] + }, + "attachments_download": { + "_key": "attachments_download", + "name": "attachments.download", + "mode": "roles", + "roles": [ + "admin", + "user", + "guest" + ] + }, + "manager": { + "_key": "manager", + "name": "manager", + "mode": "roles", + "roles": [ + "admin" + ] + } + }, + "category_post_counts": null, + "posts_count": 0, + "user_abilities": null, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 사용자 화면에서 특정 게시판의 상세 설정을 조회합니다. 게시판 목록/글쓰기 진입 시 게시판 헤더와 규칙(제목/본문 길이, 파일 업로드, 댓글 설정 등)을 렌더링하는 데 사용합니다. 스코프 권한 검사 없이 조회하되, 게시판이 없거나 비활성 상태면 존재 여부를 숨기기 위해 404를 반환합니다. + + +### GET /api/modules/sirsoft-board/boards/{slug}/attachment/{hash} + +- **라우트명**: `api.modules.sirsoft-board.boards.attachment.download` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\AttachmentController@download` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-board.{slug}.attachments.download` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| hash | path | string | 예 | — | 대상 리소스의 해시 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/boards/apidoc-sample-board/attachment/apidocsmpl1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.attachments.download`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 해시로 지정한 첨부파일을 다운로드로 제공합니다(`Content-Disposition: attachment`). `auth:sanctum` + `sirsoft-board.{slug}.attachments.download` 권한이 필요하며, 이미지 포함 모든 파일을 다운로드 방식으로 스트리밍합니다. 이미지의 인라인 미리보기는 별도의 preview 엔드포인트를 사용하고, 삭제글 첨부 등 접근 차단 시 403을 반환합니다. + + +### GET /api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview + +- **라우트명**: `api.modules.sirsoft-board.boards.attachment.preview` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\AttachmentController@preview` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| hash | path | string | 예 | — | 대상 리소스의 해시 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/boards/apidoc-sample-board/attachment/apidocsmpl1/preview HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 해시로 지정한 이미지 첨부파일을 인라인 미리보기로 제공합니다. 게시글 본문 내 이미지 표시용이라 비회원도 접근할 수 있으며(권한 체크 없음), 이미지가 아닌 파일 요청 시 400을 반환합니다. 응답에는 레이아웃 캐시 TTL(기본 24시간) 기반 캐싱 헤더가 포함되고, 삭제글 첨부 등 차단 대상은 403을 반환합니다. + + +### POST /api/modules/sirsoft-board/boards/{slug}/attachments + +- **라우트명**: `api.modules.sirsoft-board.boards.attachments.upload` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\AttachmentController@upload` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-board.{slug}.attachments.upload` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| file | body | file | 예 | — | 업로드 파일 | +| post_id | body | integer | 아니오 | min 1 | post 식별자 | +| collection | body | string | 아니오 | max 100 | 첨부 컬렉션 그룹명 (첨부를 용도별로 묶는 키, 미지정 시 default) | +| temp_key | body | string | 아니오 | max 64 | 게시글 저장 전 임시 업로드를 묶는 키 (post_id가 없을 때 첨부를 임시로 그룹핑, 저장 시 게시글에 연결) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-board.attachment.upload_validation_rules`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-board/boards/apidoc-sample-board/attachments HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +Content-Type: multipart/form-data; boundary=----G7ExampleBoundary + +------G7ExampleBoundary +Content-Disposition: form-data; name="file"; filename="example.pdf" +Content-Type: application/octet-stream + +(바이너리 파일 내용) +------G7ExampleBoundary +Content-Disposition: form-data; name="post_id" + +1 +------G7ExampleBoundary +Content-Disposition: form-data; name="collection" + +예시값 +------G7ExampleBoundary +Content-Disposition: form-data; name="temp_key" + +예시값 +------G7ExampleBoundary-- +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.attachments.upload`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시글 작성/수정 폼에서 단일 파일을 업로드합니다. `auth:sanctum` + `sirsoft-board.{slug}.attachments.upload` 권한이 필요하며, 게시판의 파일 업로드 설정이 꺼져 있으면 403을 반환합니다. 게시글 저장 전 임시 업로드를 위해 `temp_key` 로 묶어두거나 `post_id` 로 기존 글에 바로 연결할 수 있고, 성공 시 FileUploader 컴포넌트가 기대하는 `data.data` 형식으로 첨부 메타(해시·URL·순서 등)를 201로 반환합니다. + + +### PATCH /api/modules/sirsoft-board/boards/{slug}/attachments/reorder + +- **라우트명**: `api.modules.sirsoft-board.boards.attachments.reorder` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\AttachmentController@reorder` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-board.{slug}.attachments.upload` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| order | body | array | 예 | min 1 | 첨부 순서 항목 배열 (각 항목은 {id, order} — 첨부 ID별 새 순서 값) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-board.attachment.reorder_validation_rules`). + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-board/boards/apidoc-sample-board/attachments/reorder HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +Content-Type: application/json + +{ + "order": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.attachments.upload`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시글에 첨부된 파일들의 표시 순서를 재정렬합니다. `auth:sanctum` + `sirsoft-board.{slug}.attachments.upload` 권한이 필요하며, FileUploader 가 보낸 `[{id, order}]` 배열을 받아 각 첨부의 순서 값을 갱신합니다. + + +### DELETE /api/modules/sirsoft-board/boards/{slug}/attachments/{id} + +- **라우트명**: `api.modules.sirsoft-board.boards.attachments.destroy` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\AttachmentController@destroy` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-board.{slug}.attachments.upload` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-board/boards/apidoc-sample-board/attachments/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "파일이 삭제되었습니다.", + "data": null +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.attachments.upload`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 첨부파일을 삭제합니다. `auth:sanctum` + `sirsoft-board.{slug}.attachments.upload` 권한이 필요하며, 서비스의 `canDelete` 로 현재 사용자가 해당 첨부의 소유자(작성자)인지 확인한 뒤 삭제합니다. 권한이 없으면 403을 반환합니다. + + +### POST /api/modules/sirsoft-board/boards/{slug}/comments/{commentId}/reports + +- **라우트명**: `api.modules.sirsoft-board.boards.comments.reports.store` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\ReportController@storeCommentReport` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| commentId | path | string | 예 | — | 대상 comment의 식별자 | +| reason_type | body | string | 예 | — | 신고 사유 유형 (ReportReasonType Enum 값 중 하나 — 스팸/욕설 등) | +| reason_detail | body | string | 예 | min 1, max 1000 | 신고 사유 상세 설명 (1~1000자) | + +**요청 예시** + +```http +POST /api/modules/sirsoft-board/boards/apidoc-sample-board/comments/1/reports HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "reason_type": "예시값", + "reason_detail": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 특정 댓글을 신고합니다. 신고는 회원 전용이라 컨트롤러가 `AuthBaseController` 를 상속해 로그인 사용자만 호출할 수 있으며, 게시판의 신고 기능이 꺼져 있으면 403을 반환합니다. 본인 댓글 신고, 블라인드/삭제된 대상 신고는 차단되고, 이미 신고한 대상이면 409(중복)로 응답합니다. 신고 사유 유형(`reason_type`)과 상세(`reason_detail`)를 받아 신고를 접수하며 사용자 활동 로그를 남깁니다. + + +### POST /api/modules/sirsoft-board/boards/{slug}/comments/{commentId}/verify-password + +- **라우트명**: `api.modules.sirsoft-board.boards.comments.verify-password` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\CommentController@verifyPassword` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-board.{slug}.comments.write` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| commentId | path | string | 예 | — | 대상 comment의 식별자 | + +**요청 예시** + +```http +POST /api/modules/sirsoft-board/boards/apidoc-sample-board/comments/1/verify-password HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.comments.write`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 비회원이 작성한 댓글의 수정/삭제 권한을 확인하기 위해 비밀번호를 검증합니다. `auth:sanctum` + `sirsoft-board.{slug}.comments.write` 권한이 필요하며, 대상이 비회원 댓글이 아니면 400, 비밀번호가 틀리면 401을 반환합니다. 검증 성공 시 1시간 유효한 임시 토큰(`verification_token`)을 발급해 이후 수정/삭제 요청에서 비밀번호 재입력 없이 사용하도록 합니다. + + +### GET /api/modules/sirsoft-board/boards/{slug}/posts + +- **라우트명**: `api.modules.sirsoft-board.boards.posts.index` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\PostController@index` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-board.{slug}.posts.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/boards/apidoc-sample-board/posts HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `237` | 기본 키 (내부 식별자) | +| category | null | `null` | 게시글 분류(카테고리) 이름 (분류 미사용 게시판이거나 미지정 시 null) | +| author | object | `{"uuid":"a231747f-e82e-4cf2-9ae1-a261849dce40","name":"AP…` | 작성자 사용자 객체 (uuid/name — author 관계 파생) | +| is_notice | boolean | `false` | notice 여부 | +| is_secret | boolean | `false` | secret 여부 | +| content_mode | string | `html` | 본문 편집 모드 (html: WYSIWYG/HTML, text: 일반 텍스트) | +| is_new | boolean | `true` | new 여부 | +| status | string | `published` | 게시 상태 (published 게시됨 / blinded 블라인드 처리 / deleted 삭제됨 — PostStatus Enum 값) | +| status_label | string | `게시됨` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| view_count | integer | `43` | view 개수 (집계) | +| comment_count | integer | `0` | comment 개수 (집계) | +| reply_count | integer | `0` | reply 개수 (집계) | +| attachment_count | integer | `0` | attachment 개수 (집계) | +| has_attachment | boolean | `false` | attachment 여부 | +| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 | +| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | +| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | +| is_reply | boolean | `false` | reply 여부 | +| created_at | string | `2026-07-07 09:34:50` | 생성 일시 | +| created_at_formatted | string | `4시간 전` | `created_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| is_author | boolean | `true` | author 여부 | +| is_guest_post | boolean | `false` | guest post 여부 | +| slug | string | `apidoc-sample-board` | 게시판 슬러그 (URL/테이블명) | +| title | string | `API 문서 샘플 게시글` | 제목 | +| deleted_at | null | `null` | 소프트 삭제 일시 (미삭제 시 null) | +| content_preview | string | `API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.` | 목록용 본문 요약 (평문 앞 150자, 블라인드/비밀글은 원문 유출 방지를 위해 빈 문자열) | +| row_type | string | `normal` | 목록 행 유형 (notice 공지 / reply 답변글 / normal 일반글 — 표시 스타일·순번 처리 구분) | +| number | integer | `1` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| show_category | boolean | `false` | 게시판에 분류가 설정돼 있어 목록에 분류 컬럼을 노출할지 여부 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "게시글 목록을 조회했습니다.", + "data": { + "data": [ + { + "id": 1, + "category": null, + "author": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "avatar": null, + "status": "active", + "status_label": "활성", + "is_guest": false + }, + "is_notice": false, + "is_secret": "{MASKED}", + "content_mode": "html", + "is_new": true, + "status": "published", + "status_label": "게시됨", + "view_count": 44, + "comment_count": 0, + "reply_count": 0, + "attachment_count": 0, + "has_attachment": false, + "thumbnail": "/api/modules/sirsoft-board/boards/apidoc-sample-board/attachment/apidocsmpl1/preview", + "parent_id": null, + "depth": 0, + "is_reply": false, + "created_at": "2026-07-08 10:41:34", + "created_at_formatted": "4시간 전", + "is_author": true, + "is_guest_post": false, + "slug": "apidoc-sample-board", + "title": "API 문서 샘플 게시글", + "deleted_at": null, + "content_preview": "API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.", + "row_type": "normal", + "number": 1, + "show_category": false + } + ], + "pagination": { + "total": 1, + "all_total": 1, + "count": 1, + "per_page": 25, + "current_page": 1, + "last_page": 1, + "from": 1, + "to": 1, + "has_more_pages": false + }, + "board": { + "slug": "apidoc-sample-board", + "name": "API 문서 샘플 게시판", + "description": "", + "type": "basic", + "categories": [], + "show_category": false, + "settings": { + "use_file_upload": true, + "use_comment": true, + "use_reply": true, + "use_report": true, + "secret_mode": "{MASKED}", + "show_view_count": true, + "per_page": 20, + "posts_per_page": 20, + "posts_per_page_mobile": 15, + "new_display_hours": 24, + "order_by": "created_at", + "order_direction": "DESC" + } + }, + "abilities": { + "can_read": true, + "can_write": true, + "can_read_secret": "{MASKED}", + "can_read_comments": true, + "can_write_comments": true, + "can_upload": true, + "can_download": true, + "can_manage": true, + "can_view_deleted": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.posts.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시판의 게시글 목록을 조회합니다. `auth:sanctum` + `sirsoft-board.{slug}.posts.read` 권한이 필요하며(공개 게시판은 게스트에게도 read 권한이 부여될 수 있음), 검색/카테고리/정렬 등 필터와 페이지네이션을 지원합니다. 성능을 위해 simplePaginate 로 조회하되 일반 게시글 총 건수는 캐시에서 별도로 채우며, manager 권한 보유자가 `del=1` 을 주면 삭제된 게시글까지 포함합니다. + + +### POST /api/modules/sirsoft-board/boards/{slug}/posts + +- **라우트명**: `api.modules.sirsoft-board.boards.posts.store` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\PostController@store` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-board.{slug}.posts.write` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-board.user_post.store_validation_rules`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-board/boards/apidoc-sample-board/posts HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.posts.write`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시글을 작성합니다. `auth:sanctum` + `sirsoft-board.{slug}.posts.write` 권한이 필요하며, `StorePostRequest` 로 검증된 제목/본문/카테고리 등을 받아 현재 로그인 사용자를 작성자로 저장합니다. 파일이 첨부되면 게시판의 업로드 허용 여부와 첨부 업로드 권한을 함께 확인하고, 게시판 `secret_mode` 에 따라 비밀글 설정을 강제(always)하거나 거부(disabled)합니다. 스팸 방지 쿨다운이 설정되어 있으면 작성 성공 후 쿨다운을 기록합니다. + + +### GET /api/modules/sirsoft-board/boards/{slug}/posts/form-data + +- **라우트명**: `api.modules.sirsoft-board.boards.posts.form-data` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\PostController@getFormData` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-board.{slug}.posts.write` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/boards/apidoc-sample-board/posts/form-data HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| title | string | `` | 제목 | +| content | string | `` | 본문 내용 | +| content_mode | string | `text` | 본문 편집 모드 (html: WYSIWYG/HTML, text: 일반 텍스트) | +| category | null | `null` | 게시글 분류(카테고리) 이름 (분류 미사용 게시판이거나 미지정 시 null) | +| is_notice | boolean | `false` | notice 여부 | +| is_secret | boolean | `false` | secret 여부 | +| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "게시글 폼 데이터를 조회했습니다.", + "data": { + "title": "", + "content": "", + "content_mode": "text", + "category": null, + "is_notice": false, + "is_secret": "{MASKED}", + "parent_id": null + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.posts.write`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시글 작성/수정 폼의 초기 입력값을 반환합니다. `auth:sanctum` + `sirsoft-board.{slug}.posts.write` 권한이 필요하며, 쿼리 파라미터로 모드가 분기됩니다: `post_id` 가 있으면 기존 글 값(수정 모드), `parent_id` 가 있으면 원글 제목을 기반으로 한 답변글 초기값(답변 모드), 둘 다 없으면 빈 기본값(생성 모드)입니다. 수정/답변 모드에서는 본인·관리자 여부와 비회원 글의 비밀번호/토큰 검증을 확인하며, 답변 대상이 블라인드/삭제 상태면 진입을 차단합니다. + + +### GET /api/modules/sirsoft-board/boards/{slug}/posts/form-meta + +- **라우트명**: `api.modules.sirsoft-board.boards.posts.form-meta` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\PostController@getFormMeta` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-board.{slug}.posts.write` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/boards/apidoc-sample-board/posts/form-meta HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| board | object | `{"id":12,"name":"API 문서 샘플 게시판","slug":"apidoc-sample-boa…` | 글쓰기/수정 폼 렌더링용 게시판 메타 (id/name/slug/type + 게시판 설정과 현재 사용자 권한(user_abilities) 등) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "게시글 폼 메타 데이터를 조회했습니다.", + "data": { + "board": { + "id": 1, + "name": "API 문서 샘플 게시판", + "slug": "apidoc-sample-board", + "is_active": true, + "type": "basic", + "description": "", + "per_page": 20, + "per_page_mobile": 15, + "order_by": "created_at", + "order_direction": "DESC", + "categories": [], + "show_view_count": true, + "secret_mode": "{MASKED}", + "use_comment": true, + "use_reply": true, + "max_reply_depth": 5, + "use_report": true, + "comment_order": "ASC", + "max_comment_depth": 10, + "min_title_length": 2, + "max_title_length": 200, + "min_content_length": 10, + "max_content_length": 10000, + "min_comment_length": 2, + "max_comment_length": 1000, + "blocked_keywords": [], + "use_file_upload": true, + "max_file_size": 10, + "max_file_count": 5, + "allowed_extensions": [ + "jpg", + "jpeg", + "png", + "gif", + "pdf", + "zip" + ], + "add_to_menu": null, + "new_display_hours": 24, + "board_managers": [], + "board_steps": [], + "board_manager_ids": [], + "board_step_ids": [], + "notify_author": true, + "notify_admin_on_post": true, + "created_at": "2026-07-08 10:41:34", + "updated_at": "2026-07-08 10:41:34", + "permissions": null, + "category_post_counts": null, + "posts_count": 0, + "user_abilities": { + "can_read": true, + "can_write": true, + "can_read_secret": "{MASKED}", + "can_read_comments": true, + "can_write_comments": true, + "can_upload": true, + "can_download": true, + "can_manage": true + }, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.posts.write`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시글 작성/수정 폼 렌더링에 필요한 게시판 메타 정보를 반환합니다. `auth:sanctum` + `sirsoft-board.{slug}.posts.write` 권한이 필요하며, 게시판 설정과 사용자 권한(`user_abilities`)을 담습니다. `post_id` 가 있으면 수정 모드로 작성자/작성일/첨부 목록을 포함하되 회원 글은 본인 또는 관리자만, 비회원 글은 비밀번호/검증 토큰 확인을 요구합니다. `parent_id` 가 있으면 답변 모드로 원글 정보를 포함하며, 답변 기능이 꺼져 있거나 원글이 블라인드/삭제 상태면 차단합니다. + + +### DELETE /api/modules/sirsoft-board/boards/{slug}/posts/{id} + +- **라우트명**: `api.modules.sirsoft-board.boards.posts.destroy` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\PostController@destroy` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-board.{slug}.posts.write|sirsoft-board.{slug}.manager` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-board/boards/apidoc-sample-board/posts/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "게시글이 삭제되었습니다.", + "data": null +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.posts.write|sirsoft-board.{slug}.manager`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시글을 삭제(소프트 삭제)합니다. `auth:sanctum` + `sirsoft-board.{slug}.posts.write` 또는 게시판 manager 권한이 필요합니다. 작성자 본인, 게시판 관리자(admin.manage/manager), 또는 비회원 글의 경우 검증 토큰·비밀번호 확인 중 하나를 만족해야 삭제할 수 있으며, 조건 미충족 시 403을 반환합니다. + + +### GET /api/modules/sirsoft-board/boards/{slug}/posts/{id} + +- **라우트명**: `api.modules.sirsoft-board.boards.posts.show` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\PostController@show` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-board.{slug}.posts.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/boards/apidoc-sample-board/posts/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `237` | 기본 키 (내부 식별자) | +| category | null | `null` | 게시글 분류(카테고리) 이름 (분류 미사용 게시판이거나 미지정 시 null) | +| author | object | `{"uuid":"a231747f-e82e-4cf2-9ae1-a261849dce40","name":"AP…` | 작성자 사용자 객체 (uuid/name — author 관계 파생) | +| is_notice | boolean | `false` | notice 여부 | +| is_secret | boolean | `false` | secret 여부 | +| content_mode | string | `html` | 본문 편집 모드 (html: WYSIWYG/HTML, text: 일반 텍스트) | +| is_new | boolean | `true` | new 여부 | +| status | string | `published` | 게시 상태 (published 게시됨 / blinded 블라인드 처리 / deleted 삭제됨 — PostStatus Enum 값) | +| status_label | string | `게시됨` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| view_count | integer | `43` | view 개수 (집계) | +| comment_count | integer | `0` | comment 개수 (집계) | +| reply_count | integer | `0` | reply 개수 (집계) | +| attachment_count | integer | `0` | attachment 개수 (집계) | +| has_attachment | boolean | `false` | attachment 여부 | +| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 | +| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | +| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | +| is_reply | boolean | `false` | reply 여부 | +| created_at | string | `2026-07-07 09:34:50` | 생성 일시 | +| created_at_formatted | string | `4시간 전` | `created_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| is_author | boolean | `true` | author 여부 | +| is_guest_post | boolean | `false` | guest post 여부 | +| title | string | `API 문서 샘플 게시글` | 제목 | +| content | string | `

API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.

` | 본문 내용 | +| user_id | string | `a231747f-e82e-4cf2-9ae1-a261849dce40` | user 식별자 (연관 리소스 참조) | +| trigger_type | string | `user` | 상태 변경/삭제를 유발한 주체 (user 사용자 / admin 관리자 / report 신고 / system 시스템 / auto_hide 자동 블라인드 / cascade 연쇄 삭제 — TriggerType Enum) | +| updated_at | string | `2026-07-07 09:39:03` | 최종 수정 일시 | +| deleted_at | null | `null` | 소프트 삭제 일시 (미삭제 시 null) | +| ip_address | string | `127.0.0.1` | 요청/행위가 발생한 IP 주소 | +| action_logs | array | `[]` | 블라인드/복원/삭제 등 운영 처리 이력 (admin.manage 권한 보유자에게만 노출, action/reason/admin_name/created_at — 비권한자는 빈 배열/null) | +| board | object | `{"slug":"apidoc-sample-board","name":"API 문서 샘플 게시판","typ…` | 게시글이 속한 게시판 정보 (slug/name/type + 댓글·답변·신고·조회수 노출·깊이 등 렌더링에 필요한 설정과 신고 유형 목록) | +| navigation | null | `null` | 이전/다음 글 정보 (상세 API에서는 기본 null — 별도 navigation 엔드포인트로 비동기 로딩) | +| parent | null | `null` | 상위 항목 객체 (parent 관계 파생) | +| comments | array | `[{"id":760,"post_id":237,"parent_id":null,"content":"API …` | 이 게시글의 댓글 목록 (CommentResource 배열 — 대댓글 계층 포함) | +| attachments | array | `[{"id":155,"hash":"apidocsmpl1","original_filename":"apid…` | 이 게시글의 첨부파일 목록 (비밀글/삭제글은 권한에 따라 빈 배열 또는 cascade 항목만 노출) | +| replies | array | `[]` | 이 게시글에 달린 답변글 목록 (PostResource 배열 — use_reply 게시판에서만 로드) | +| is_already_reported | boolean | `false` | already reported 여부 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_write":true,"can_read_secret":true,…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "게시글 목록을 조회했습니다.", + "data": { + "id": 1, + "category": null, + "author": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "avatar": null, + "status": "active", + "status_label": "활성", + "is_guest": false + }, + "is_notice": false, + "is_secret": "{MASKED}", + "content_mode": "html", + "is_new": true, + "status": "published", + "status_label": "게시됨", + "view_count": 44, + "comment_count": 0, + "reply_count": 0, + "attachment_count": 0, + "has_attachment": false, + "thumbnail": "/api/modules/sirsoft-board/boards/apidoc-sample-board/attachment/apidocsmpl1/preview", + "parent_id": null, + "depth": 0, + "is_reply": false, + "created_at": "2026-07-08 10:41:34", + "created_at_formatted": "4시간 전", + "is_author": true, + "is_guest_post": false, + "title": "API 문서 샘플 게시글", + "content": "

API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.

", + "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "trigger_type": "user", + "updated_at": "2026-07-08 15:01:44", + "deleted_at": null, + "ip_address": "127.0.0.1", + "action_logs": [], + "board": { + "slug": "apidoc-sample-board", + "name": "API 문서 샘플 게시판", + "type": "basic", + "use_comment": true, + "use_reply": true, + "use_report": true, + "show_view_count": true, + "max_reply_depth": 5, + "max_comment_depth": 10, + "report_types": [ + { + "value": "abuse", + "label": "욕설/비방" + }, + { + "value": "hate_speech", + "label": "혐오 발언" + }, + { + "value": "spam", + "label": "스팸/광고" + }, + { + "value": "copyright", + "label": "저작권 침해" + }, + { + "value": "privacy", + "label": "개인정보 노출" + }, + { + "value": "misinformation", + "label": "허위정보" + }, + { + "value": "sexual", + "label": "성적인 콘텐츠" + }, + { + "value": "violence", + "label": "폭력적인 콘텐츠" + }, + { + "value": "other", + "label": "기타" + } + ] + }, + "navigation": null, + "parent": null, + "comments": [ + { + "id": 1, + "post_id": 1, + "parent_id": null, + "content": "API 문서 샘플 댓글입니다.", + "author": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "avatar": null, + "status": "active", + "status_label": "활성", + "is_guest": false + }, + "is_secret": "{MASKED}", + "status": "published", + "status_label": "게시됨", + "depth": 0, + "replies_count": 0, + "created_at": "2026-07-08 10:41:34", + "created_at_formatted": "4시간 전", + "updated_at": "2026-07-08 10:41:34", + "deleted_at": null, + "is_cascade_deleted": false, + "ip_address": null, + "action_logs": [], + "is_author": true, + "is_guest_comment": false, + "is_already_reported": false, + "is_owner": true, + "abilities": { + "can_write": true + } + } + ], + "attachments": [ + { + "id": 1, + "hash": "apidocsmpl1", + "original_filename": "apidoc-sample.png", + "mime_type": "image/png", + "size": 2048, + "size_formatted": "2 KB", + "collection": "default", + "order": 0, + "download_url": "/api/modules/sirsoft-board/boards/apidoc-sample-board/attachment/apidocsmpl1", + "preview_url": "/api/modules/sirsoft-board/boards/apidoc-sample-board/attachment/apidocsmpl1/preview", + "is_image": true, + "meta": null + } + ], + "replies": [], + "is_already_reported": false, + "is_owner": true, + "abilities": { + "can_read": true, + "can_write": true, + "can_read_secret": "{MASKED}", + "can_read_comments": true, + "can_write_comments": true, + "can_upload": true, + "can_download": true, + "can_manage": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.posts.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시글 상세를 조회합니다. `auth:sanctum` + `sirsoft-board.{slug}.posts.read` 권한이 필요하며, 댓글/첨부/답글을 함께 로드하고 조회수를 (중복 방지 캐시로) 1회 증가시킵니다. 삭제된 게시글은 manager 권한이 있어야 열람 가능하고, 비밀글의 본문 노출 여부와 신고 이력 표시는 로그인 사용자 기준으로 결정됩니다(비로그인은 신고 불가로 모두 false). manager 가 `del_cmt=1` 을 주면 삭제된 댓글도 포함합니다. + + +### PUT /api/modules/sirsoft-board/boards/{slug}/posts/{id} + +- **라우트명**: `api.modules.sirsoft-board.boards.posts.update` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\PostController@update` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-board.{slug}.posts.write|sirsoft-board.{slug}.manager` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-board.user_post.update_validation_rules`). + +**요청 예시** + +```http +PUT /api/modules/sirsoft-board/boards/apidoc-sample-board/posts/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| category | null | `null` | 게시글이 속한 분류(카테고리) 이름 (분류 미지정 시 null) | +| author | object | `{"uuid":"a234c2b1-cde8-437f-b28b-23323be2b98d","name":"AP…` | 작성자 사용자 객체 (uuid/name — author 관계 파생) | +| is_notice | boolean | `false` | notice 여부 | +| is_secret | boolean | `false` | secret 여부 | +| content_mode | string | `html` | 본문 편집 모드 (html: HTML 에디터 / text: 일반 텍스트 — 미지정 시 text) | +| is_new | boolean | `true` | new 여부 | +| status | string | `published` | 상태 값 (도메인별 상태 집합 — 사람이 읽는 라벨은 status_label, UI 변형은 status_variant 참조) | +| status_label | string | `게시됨` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| view_count | integer | `44` | view 개수 (집계) | +| comment_count | integer | `0` | comment 개수 (집계) | +| reply_count | integer | `0` | reply 개수 (집계) | +| attachment_count | integer | `0` | attachment 개수 (집계) | +| has_attachment | boolean | `false` | attachment 여부 | +| thumbnail | null | `null` | 썸네일 이미지 URL/경로 | +| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | +| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | +| is_reply | boolean | `false` | reply 여부 | +| created_at | string | `2026-07-08 10:41:34` | 생성 일시 | +| created_at_formatted | string | `4시간 전` | `created_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| is_author | boolean | `true` | author 여부 | +| is_guest_post | boolean | `false` | guest post 여부 | +| title | string | `API 문서 샘플 게시글` | 제목 | +| content | string | `

API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.

` | 본문 내용 | +| user_id | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | user 식별자 (연관 리소스 참조) | +| trigger_type | string | `user` | 동작을 유발한 방식/주체 구분 값 | +| updated_at | string | `2026-07-08 15:01:44` | 최종 수정 일시 | +| deleted_at | null | `null` | 소프트 삭제 일시 (미삭제 시 null) | +| ip_address | string | `127.0.0.1` | 요청/행위가 발생한 IP 주소 | +| action_logs | array | `[]` | 게시글 블라인드/복원/삭제 등 운영 처리 이력 (action/reason/admin_name/created_at — admin.manage 권한 보유자에게만 노출, 비권한자는 null) | +| board | object | `{"slug":"apidoc-sample-board","name":"API 문서 샘플 게시판","typ…` | 소속 게시판 요약 정보 (slug/name/type/댓글·답변·신고 사용 여부·최대 깊이·신고 유형 목록 등 — board 관계 로드 시에만 채워지며 미로드 시 null) | +| navigation | null | `null` | 이전/다음 글 이동 정보 (별도 navigation 조회로 채워지며, 이 응답에는 기본적으로 미포함되어 null) | +| parent | null | `null` | 상위 항목 객체 (parent 관계 파생) | +| comments | null | `null` | 게시글 댓글 목록 (comments 관계 로드 시 CommentResource 컬렉션으로 채워지며, 미로드 시 null) | +| attachments | null | `null` | 게시글 첨부파일 목록 (attachments 관계 로드 시 권한·비밀글·삭제 정책에 따라 필터링된 AttachmentResource 컬렉션, 미로드 시 null) | +| replies | null | `null` | 이 게시글에 달린 답변글 목록 (replies 관계 로드 시 동일 PostResource 컬렉션으로 채워지며, 미로드 시 null) | +| is_already_reported | boolean | `false` | already reported 여부 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_write":true,"can_read_secret":true,…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "게시글이 수정되었습니다.", + "data": { + "id": 1, + "category": null, + "author": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "avatar": null, + "status": "active", + "status_label": "활성", + "is_guest": false + }, + "is_notice": false, + "is_secret": "{MASKED}", + "content_mode": "html", + "is_new": true, + "status": "published", + "status_label": "게시됨", + "view_count": 44, + "comment_count": 0, + "reply_count": 0, + "attachment_count": 0, + "has_attachment": false, + "thumbnail": null, + "parent_id": null, + "depth": 0, + "is_reply": false, + "created_at": "2026-07-08 10:41:34", + "created_at_formatted": "4시간 전", + "is_author": true, + "is_guest_post": false, + "title": "API 문서 샘플 게시글", + "content": "

API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.

", + "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "trigger_type": "user", + "updated_at": "2026-07-08 15:01:44", + "deleted_at": null, + "ip_address": "127.0.0.1", + "action_logs": [], + "board": { + "slug": "apidoc-sample-board", + "name": "API 문서 샘플 게시판", + "type": "basic", + "use_comment": true, + "use_reply": true, + "use_report": true, + "show_view_count": true, + "max_reply_depth": 5, + "max_comment_depth": 10, + "report_types": [ + { + "value": "abuse", + "label": "욕설/비방" + }, + { + "value": "hate_speech", + "label": "혐오 발언" + }, + { + "value": "spam", + "label": "스팸/광고" + }, + { + "value": "copyright", + "label": "저작권 침해" + }, + { + "value": "privacy", + "label": "개인정보 노출" + }, + { + "value": "misinformation", + "label": "허위정보" + }, + { + "value": "sexual", + "label": "성적인 콘텐츠" + }, + { + "value": "violence", + "label": "폭력적인 콘텐츠" + }, + { + "value": "other", + "label": "기타" + } + ] + }, + "navigation": null, + "parent": null, + "comments": null, + "attachments": null, + "replies": null, + "is_already_reported": false, + "is_owner": true, + "abilities": { + "can_read": true, + "can_write": true, + "can_read_secret": "{MASKED}", + "can_read_comments": true, + "can_write_comments": true, + "can_upload": true, + "can_download": true, + "can_manage": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.posts.write|sirsoft-board.{slug}.manager`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시글을 수정합니다. `auth:sanctum` + `sirsoft-board.{slug}.posts.write` 또는 게시판 manager 권한이 필요하며, `UpdatePostRequest` 로 검증된 값으로 갱신합니다. 작성자 본인, 게시판 관리자, 또는 비회원 글의 검증 토큰·비밀번호 확인 중 하나를 만족해야 수정할 수 있고, 조건 미충족 시 403을 반환합니다. + + +### GET /api/modules/sirsoft-board/boards/{slug}/posts/{id}/navigation + +- **라우트명**: `api.modules.sirsoft-board.boards.posts.navigation` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\PostController@navigation` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-board.{slug}.posts.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/boards/apidoc-sample-board/posts/1/navigation HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| prev | null | `null` | 정렬 기준상 이전 글 정보 (없거나 공지·답글·조회 실패 시 null) | +| next | null | `null` | 정렬 기준상 다음 글 정보 (없거나 공지·답글·조회 실패 시 null) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "게시글 목록을 조회했습니다.", + "data": { + "prev": null, + "next": null + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.posts.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시글 상세 화면의 이전/다음 글 정보를 조회합니다(상세 API에서 분리한 비동기 로딩용). `auth:sanctum` + `sirsoft-board.{slug}.posts.read` 권한이 필요하며, 게시판 정렬 설정과 현재 글의 카테고리를 기준으로 인접 글을 계산합니다. 공지글·답글·존재하지 않는 글이거나 내부 조회 실패 시에도 500 대신 `{prev: null, next: null}` 로 안전하게 응답하며, manager 가 `del=1` 을 주면 삭제된 글까지 후보에 포함합니다. + + +### POST /api/modules/sirsoft-board/boards/{slug}/posts/{id}/verify-password + +- **라우트명**: `api.modules.sirsoft-board.boards.posts.verify-password` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\PostController@verifyPassword` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-board.{slug}.posts.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| password | body | string | 예 | — | 비밀번호 | + +**요청 예시** + +```http +POST /api/modules/sirsoft-board/boards/apidoc-sample-board/posts/1/verify-password HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +Content-Type: application/json + +{ + "password": "Password123!" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.posts.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 비밀글 열람을 위해 비밀번호를 검증합니다. `auth:sanctum` + `sirsoft-board.{slug}.posts.read` 권한이 필요하며, 주로 비회원 비밀글의 내용을 확인할 때 사용합니다. 검증에 성공하면 `password_verified` 플래그가 설정되어 게시글 본문과 첨부파일을 포함한 상세를 반환하고, 실패 시 서비스가 지정한 에러 키/코드로 응답합니다. + + +### POST /api/modules/sirsoft-board/boards/{slug}/posts/{id}/verify-password-for-modify + +- **라우트명**: `api.modules.sirsoft-board.boards.posts.verify-password-for-modify` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\PostController@verifyPasswordForModify` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-board.{slug}.posts.write` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| password | body | string | 예 | — | 비밀번호 | + +**요청 예시** + +```http +POST /api/modules/sirsoft-board/boards/apidoc-sample-board/posts/1/verify-password-for-modify HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +Content-Type: application/json + +{ + "password": "Password123!" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.posts.write`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시글 수정/삭제 전 권한 확인을 위해 비밀번호를 검증합니다. `auth:sanctum` + `sirsoft-board.{slug}.posts.write` 권한이 필요하며, 주로 비회원 글의 소유 확인에 사용합니다. 검증 성공 시 32자 임시 토큰(`verification_token`)과 만료 시각을 발급하여, 이후 수정/삭제 요청에서 비밀번호를 다시 보내지 않고 토큰으로 권한을 증명하게 합니다. + + +### GET /api/modules/sirsoft-board/boards/{slug}/posts/{postId}/comments + +- **라우트명**: `api.modules.sirsoft-board.boards.posts.comments.index` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\CommentController@index` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-board.{slug}.comments.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| postId | path | string | 예 | — | 대상 post의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/boards/apidoc-sample-board/posts/1/comments HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `760` | 기본 키 (내부 식별자) | +| post_id | integer | `237` | post 식별자 (연관 리소스 참조) | +| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | +| content | string | `API 문서 샘플 댓글입니다.` | 본문 내용 | +| author | object | `{"uuid":"a231747f-e82e-4cf2-9ae1-a261849dce40","name":"AP…` | 작성자 사용자 객체 (uuid/name — author 관계 파생) | +| is_secret | boolean | `false` | secret 여부 | +| status | string | `published` | 게시 상태 (published 게시됨 / blinded 블라인드 처리 / deleted 삭제됨 — PostStatus Enum 값) | +| status_label | string | `게시됨` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | +| replies_count | integer | `0` | replies 개수 (집계) | +| created_at | string | `2026-07-07 09:34:50` | 생성 일시 | +| created_at_formatted | string | `4시간 전` | `created_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| updated_at | string | `2026-07-07 09:34:50` | 최종 수정 일시 | +| deleted_at | null | `null` | 소프트 삭제 일시 (미삭제 시 null) | +| is_cascade_deleted | boolean | `false` | cascade deleted 여부 | +| ip_address | null | `null` | 요청/행위가 발생한 IP 주소 | +| action_logs | array | `[]` | 블라인드/복원/삭제 등 운영 처리 이력 (admin.manage 권한 보유자에게만 노출, action/reason/admin_name/created_at — 비권한자는 빈 배열/null) | +| is_author | boolean | `true` | author 여부 | +| is_guest_comment | boolean | `false` | guest comment 여부 | +| is_already_reported | boolean | `false` | already reported 여부 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_write":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-board::messages.comments.index_success", + "data": [ + { + "id": 1, + "post_id": 1, + "parent_id": null, + "content": "API 문서 샘플 댓글입니다.", + "author": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "avatar": null, + "status": "active", + "status_label": "활성", + "is_guest": false + }, + "is_secret": "{MASKED}", + "status": "published", + "status_label": "게시됨", + "depth": 0, + "replies_count": 0, + "created_at": "2026-07-08 10:41:34", + "created_at_formatted": "4시간 전", + "updated_at": "2026-07-08 10:41:34", + "deleted_at": null, + "is_cascade_deleted": false, + "ip_address": null, + "action_logs": [], + "is_author": true, + "is_guest_comment": false, + "is_already_reported": false, + "is_owner": true, + "abilities": { + "can_write": true + } + } + ] +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.comments.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시글의 댓글 목록을 조회합니다. `auth:sanctum` + `sirsoft-board.{slug}.comments.read` 권한이 필요하며, 게시판의 댓글 기능이 꺼져 있으면 403을 반환합니다. 대댓글 계층(`depth`, `replies_count`)을 포함하며, 각 항목에 현재 사용자의 작성자 여부·소유 여부·신고 이력 등이 메타로 담깁니다. + + +### POST /api/modules/sirsoft-board/boards/{slug}/posts/{postId}/comments + +- **라우트명**: `api.modules.sirsoft-board.boards.posts.comments.store` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\CommentController@store` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-board.{slug}.comments.write` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| postId | path | string | 예 | — | 대상 post의 식별자 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-board.comment.store_validation_rules`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-board/boards/apidoc-sample-board/posts/1/comments HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.comments.write`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 게시글에 댓글(또는 대댓글)을 작성합니다. `auth:sanctum` + `sirsoft-board.{slug}.comments.write` 권한이 필요하며, 게시판의 댓글 기능이 꺼져 있으면 403을 반환합니다. 현재 로그인 사용자를 작성자로 저장하고 요청 IP를 기록하며, 스팸 방지 쿨다운이 설정되어 있으면 작성 성공 후 쿨다운을 기록합니다. + + +### DELETE /api/modules/sirsoft-board/boards/{slug}/posts/{postId}/comments/{commentId} + +- **라우트명**: `api.modules.sirsoft-board.boards.posts.comments.destroy` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\CommentController@destroy` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-board.{slug}.comments.write|sirsoft-board.{slug}.manager` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| postId | path | string | 예 | — | 대상 post의 식별자 | +| commentId | path | string | 예 | — | 대상 comment의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-board/boards/apidoc-sample-board/posts/1/comments/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "댓글이 삭제되었습니다.", + "data": null +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.comments.write|sirsoft-board.{slug}.manager`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 댓글을 삭제합니다. `auth:sanctum` + `sirsoft-board.{slug}.comments.write` 또는 게시판 manager 권한이 필요하며, 게시판의 댓글 기능이 꺼져 있으면 403을 반환합니다. 서비스의 `canDelete` 가 작성자 본인·게시판 관리자·비회원 댓글의 비밀번호 확인 여부를 판정하며, 조건 미충족 시 403을 반환합니다. + + +### PUT /api/modules/sirsoft-board/boards/{slug}/posts/{postId}/comments/{commentId} + +- **라우트명**: `api.modules.sirsoft-board.boards.posts.comments.update` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\CommentController@update` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-board.{slug}.comments.write|sirsoft-board.{slug}.manager` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| postId | path | string | 예 | — | 대상 post의 식별자 | +| commentId | path | string | 예 | — | 대상 comment의 식별자 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-board.comment.update_validation_rules`). + +**요청 예시** + +```http +PUT /api/modules/sirsoft-board/boards/apidoc-sample-board/posts/1/comments/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.comments.write|sirsoft-board.{slug}.manager`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 댓글을 수정합니다. `auth:sanctum` + `sirsoft-board.{slug}.comments.write` 또는 게시판 manager 권한이 필요하며, 게시판의 댓글 기능이 꺼져 있으면 403을 반환합니다. 서비스의 `canUpdate` 가 작성자 본인·게시판 관리자·비회원 댓글의 비밀번호 확인 여부를 판정하며, 검증용 `password` 는 저장에서 제외됩니다. + + +### POST /api/modules/sirsoft-board/boards/{slug}/posts/{postId}/reports + +- **라우트명**: `api.modules.sirsoft-board.boards.posts.reports.store` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\ReportController@storePostReport` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| postId | path | string | 예 | — | 대상 post의 식별자 | +| reason_type | body | string | 예 | — | 신고 사유 유형 (ReportReasonType Enum 값 중 하나 — 스팸/욕설 등) | +| reason_detail | body | string | 예 | min 1, max 1000 | 신고 사유 상세 설명 (1~1000자) | + +**요청 예시** + +```http +POST /api/modules/sirsoft-board/boards/apidoc-sample-board/posts/1/reports HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "reason_type": "예시값", + "reason_detail": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 특정 게시글을 신고합니다. 신고는 회원 전용이라 컨트롤러가 `AuthBaseController` 를 상속해 로그인 사용자만 호출할 수 있으며, 게시판의 신고 기능이 꺼져 있으면 403을 반환합니다. 본인 글 신고, 블라인드/삭제된 대상 신고는 차단되고, 이미 신고한 대상이면 409(중복)로 응답합니다. 신고 사유 유형(`reason_type`)과 상세(`reason_detail`)를 받아 신고를 접수하며 사용자 활동 로그를 남깁니다. + + diff --git a/modules/_bundled/sirsoft-board/docs/api/dashboard.md b/modules/_bundled/sirsoft-board/docs/api/dashboard.md new file mode 100644 index 00000000..2f3813e8 --- /dev/null +++ b/modules/_bundled/sirsoft-board/docs/api/dashboard.md @@ -0,0 +1,320 @@ +# Dashboard API 레퍼런스 + +> **소유**: module `sirsoft-board` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Dashboard 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-board/admin/dashboard/overview + +- **라우트명**: `api.modules.sirsoft-board.admin.dashboard.overview` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\DashboardController@overview` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/admin/dashboard/overview HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| today_posts | integer | `0` | 오늘 등록된 새 게시글 수입니다. board_stats 집계 테이블의 오늘 행에서 읽으며, 행이 없으면 0 을 반환합니다(최대 1시간 지연). | +| today_comments | integer | `0` | 오늘 등록된 새 댓글 수입니다. board_stats 집계 테이블의 오늘 행에서 읽으며, 행이 없으면 0 을 반환합니다(최대 1시간 지연). | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-board::messages.dashboard.fetch_success", + "data": { + "today_posts": 0, + "today_comments": 0 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** 관리자 대시보드 위젯에 표시할 오늘의 게시판 현황을 반환합니다. `auth:sanctum` 인증이 필요하며(대시보드 진입은 코어 `core.dashboard.read` 가드로 보호), 오늘 등록된 새 글 수(today_posts)와 새 댓글 수(today_comments)를 집계하여 반환합니다. + + +### GET /api/modules/sirsoft-board/admin/dashboard/pending-reports + +- **라우트명**: `api.modules.sirsoft-board.admin.dashboard.pending-reports` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\DashboardController@pendingReports` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| limit | query | integer | 아니오 | min 1, max 50 | 반환할 최대 항목 수 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/admin/dashboard/pending-reports?limit=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| items | array | `[{"id":7,"board_slug":"qna","board_name":"Q&A","target_ty…` | 전체 게시판을 가로질러 조회한 미처리 신고 항목 목록입니다. 각 항목은 신고 대상 게시판(board_slug/board_name), 대상 종류(target_type), 대상 제목/발췌(target_title/target_excerpt), 상태, 신고 시각을 포함합니다. `limit` 개까지 반환됩니다. | +| total | integer | `16` | 전체 개수 (집계) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-board::messages.dashboard.fetch_success", + "data": { + "items": [ + { + "id": 1, + "board_slug": "apidoc-sample-board", + "board_name": "API 문서 샘플 게시판", + "target_type": "post", + "target_type_label": "게시글", + "target_post_id": 1, + "target_title": "API 문서 샘플 게시글", + "target_excerpt": null, + "status": "pending", + "status_label": "접수", + "author_name": "API 문서 샘플 사용자", + "last_reported_at": "4시간 전" + } + ], + "total": 1 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 전체 게시판의 미처리 신고 목록과 총 건수를 대시보드 위젯에 표시하기 위해 반환합니다. `auth:sanctum` 인증이 필요하며(대시보드 진입은 코어 `core.dashboard.read` 가드로 보호), `limit`(1~50, 기본 5)으로 표시 건수를 제어합니다. 응답은 미처리 신고 항목(items)과 전체 미처리 건수(total)를 포함합니다. + + +### GET /api/modules/sirsoft-board/admin/dashboard/post-graph + +- **라우트명**: `api.modules.sirsoft-board.admin.dashboard.post-graph` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\DashboardController@postGraph` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/admin/dashboard/post-graph HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| days | array | `[{"date":"2026-07-01","post_count":0,"comment_count":0},{…` | 최근 7일간 일자별 집계 막대 배열입니다. 각 원소는 날짜(date)와 해당 일의 게시글 수(post_count)/댓글 수(comment_count)를 담으며, 집계 행이 없는 날은 0 으로 채워집니다. | +| total_posts | integer | `0` | 이번 7일 기간의 게시글 합계입니다. days 의 post_count 를 합산한 값입니다. | +| total_comments | integer | `0` | 이번 7일 기간의 댓글 합계입니다. days 의 comment_count 를 합산한 값입니다. | +| posts_change | null | `null` | 직전 동일 7일 기간 대비 게시글 증감율(%)입니다. 소수점 첫째 자리까지 계산하며, 직전 기간 합이 0 이면 비교 기준이 없으므로 null 을 반환합니다(화면에서 '—' 폴백). | +| comments_change | null | `null` | 직전 동일 7일 기간 대비 댓글 증감율(%)입니다. 소수점 첫째 자리까지 계산하며, 직전 기간 합이 0 이면 비교 기준이 없으므로 null 을 반환합니다(화면에서 '—' 폴백). | +| updated_at | null | `null` | 최종 수정 일시 | +| updated_at_display | string | `` | 집계 행의 최종 갱신 시각(updated_at)을 모듈 날짜 표시 형식(display.date_display_format 설정)으로 포맷한 표시용 문자열입니다. 집계 행이 없으면 빈 문자열입니다. | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-board::messages.dashboard.fetch_success", + "data": { + "days": [ + { + "date": "2026-07-02", + "post_count": 0, + "comment_count": 0 + }, + { + "date": "2026-07-03", + "post_count": 0, + "comment_count": 0 + }, + { + "date": "2026-07-04", + "post_count": 0, + "comment_count": 0 + }, + { + "date": "2026-07-05", + "post_count": 0, + "comment_count": 0 + }, + { + "date": "2026-07-06", + "post_count": 0, + "comment_count": 0 + }, + { + "date": "2026-07-07", + "post_count": 0, + "comment_count": 0 + }, + { + "date": "2026-07-08", + "post_count": 0, + "comment_count": 0 + } + ], + "total_posts": 0, + "total_comments": 0, + "posts_change": null, + "comments_change": null, + "updated_at": null, + "updated_at_display": "" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** 대시보드 위젯의 추세 그래프에 표시할 최근 7일간 게시글/댓글 추이를 반환합니다. `auth:sanctum` 인증이 필요하며(대시보드 진입은 코어 `core.dashboard.read` 가드로 보호), 일자별 막대(days), 기간 합계(total_posts/total_comments), 이전 기간 대비 변화율(posts_change/comments_change), 갱신 시각(updated_at)을 포함합니다. + + +### GET /api/modules/sirsoft-board/admin/dashboard/recent-posts + +- **라우트명**: `api.modules.sirsoft-board.admin.dashboard.recent-posts` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\DashboardController@recentPosts` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| limit | query | integer | 아니오 | min 1, max 50 | 반환할 최대 항목 수 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/admin/dashboard/recent-posts?limit=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `237` | 기본 키 (내부 식별자) | +| board_slug | string | `apidoc-sample-board` | 게시글이 속한 게시판의 슬러그입니다. 대시보드에서 해당 게시판으로 이동할 때 식별자로 사용됩니다. | +| board_name | string | `API 문서 샘플 게시판` | 게시글이 속한 게시판의 현재 로케일 표시명입니다(getLocalizedName). | +| title | string | `API 문서 샘플 게시글` | 제목 | +| author_name | string | `API 문서 샘플 사용자` | 작성자 이름입니다. 회원 게시글은 연결된 사용자 이름을, 비회원 게시글은 작성 시 입력한 이름(author_name)을 사용합니다. | +| comments_count | integer | `0` | comments 개수 (집계) | +| created_at | string | `4시간 전` | 생성 일시 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-board::messages.dashboard.fetch_success", + "data": [ + { + "id": 1, + "board_slug": "apidoc-sample-board", + "board_name": "API 문서 샘플 게시판", + "title": "API 문서 샘플 게시글", + "author_name": "API 문서 샘플 사용자", + "comments_count": 0, + "created_at": "4시간 전" + } + ] +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 전체 게시판에서 최신 게시글을 대시보드 위젯에 표시하기 위해 반환합니다. `auth:sanctum` 인증이 필요하며(대시보드 진입은 코어 `core.dashboard.read` 가드로 보호), `limit`(1~50, 기본 5)으로 표시 건수를 제어합니다. 각 항목은 게시판(board_slug/board_name), 제목, 작성자, 댓글 수, 작성 시각(상대 시간 표기)을 포함합니다. + + diff --git a/modules/_bundled/sirsoft-board/docs/api/my-comments.md b/modules/_bundled/sirsoft-board/docs/api/my-comments.md new file mode 100644 index 00000000..3da32a33 --- /dev/null +++ b/modules/_bundled/sirsoft-board/docs/api/my-comments.md @@ -0,0 +1,127 @@ +# My Comments API 레퍼런스 + +> **소유**: module `sirsoft-board` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 My Comments 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-board/me/my-comments + +- **라우트명**: `api.modules.sirsoft-board.me.my-comments.index` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\UserActivityController@myComments` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/me/my-comments HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `760` | 기본 키 (내부 식별자) | +| board_slug | string | `apidoc-sample-board` | 댓글이 달린 게시글이 속한 게시판의 슬러그(URL 식별자)입니다. 게시판 링크 구성에 사용합니다. | +| board_name | string | `API 문서 샘플 게시판` | 댓글이 달린 게시글이 속한 게시판의 표시 이름입니다. 현재 로케일에 맞는 다국어 이름(`getLocalizedName()`)이 적용됩니다. | +| post_title | string | `API 문서 샘플 게시글` | 댓글이 달린 원 게시글의 제목입니다. 목록에서 어느 글에 남긴 댓글인지 식별하는 데 사용합니다. | +| post_id_val | integer | `237` | 댓글이 달린 원 게시글의 ID입니다. 원 게시글 상세로 이동하는 링크 구성에 사용합니다. | +| content | string | `API 문서 샘플 댓글입니다.` | 본문 내용 | +| created_at | string | `2026-07-07 09:34:50` | 생성 일시 | +| created_at_formatted | string | `4시간 전` | `created_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "내 활동 게시글을 조회했습니다.", + "data": { + "current_page": 1, + "data": [ + { + "id": 1, + "board_slug": "apidoc-sample-board", + "board_name": "API 문서 샘플 게시판", + "post_title": "API 문서 샘플 게시글", + "post_id_val": 1, + "content": "API 문서 샘플 댓글입니다.", + "created_at": "2026-07-08 10:41:34", + "created_at_formatted": "4시간 전" + } + ], + "first_page_url": "https://api.example.com/api/modules/sirsoft-board/me/my-comments?page=1", + "from": 1, + "last_page": 1, + "last_page_url": "https://api.example.com/api/modules/sirsoft-board/me/my-comments?page=1", + "links": [ + { + "url": null, + "label": "pagination.previous", + "page": null, + "active": false + }, + { + "url": "https://api.example.com/api/modules/sirsoft-board/me/my-comments?page=1", + "label": "1", + "page": 1, + "active": true + }, + { + "url": null, + "label": "pagination.next", + "page": null, + "active": false + } + ], + "next_page_url": null, + "path": "https://api.example.com/api/modules/sirsoft-board/me/my-comments", + "per_page": 25, + "prev_page_url": null, + "to": 1, + "total": 1, + "query": { + "board_slug": "", + "search": "", + "sort": "latest" + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** 로그인한 회원 본인이 작성한 댓글 목록을 마이페이지에 표시하기 위해 반환하며, 각 항목에 댓글 내용과 함께 원 게시글 제목·게시판 정보를 포함합니다. `auth:sanctum` 인증이 필요한 회원 전용 엔드포인트로, 대상은 항상 인증된 본인(`Auth::id()`)입니다. `board_slug`·`search`·`sort`(기본 latest) 필터와 `per_page`(1~100, 기본 20) 페이지네이션을 지원합니다. + + diff --git a/modules/_bundled/sirsoft-board/docs/api/reports.md b/modules/_bundled/sirsoft-board/docs/api/reports.md new file mode 100644 index 00000000..ab51e2b3 --- /dev/null +++ b/modules/_bundled/sirsoft-board/docs/api/reports.md @@ -0,0 +1,597 @@ +# Reports API 레퍼런스 + +> **소유**: module `sirsoft-board` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Reports 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-board/admin/reports + +- **라우트명**: `api.modules.sirsoft-board.admin.reports.index` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\ReportController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.reports.view` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| filters | query | array | 아니오 | — | 추가 필터 조건 맵 (필드별 조건) | +| status | query | string | 아니오 | — | 상태 필터 (해당 상태의 항목만 조회) | +| target_type | query | string | 아니오 | — | 신고 대상 타입 필터 (`post`/`comment`). 단일 문자열 또는 배열로 여러 타입을 전달할 수 있습니다. | +| target_status | query | string | 아니오 | — | 신고 대상 콘텐츠의 현재 상태 필터 (게시글/댓글의 status 기준). 단일 문자열 또는 배열로 전달하며 `all`은 무시됩니다. | +| board_id | query | integer | 아니오 | — | board 식별자 | +| reported_at_from | query | string | 아니오 | — | 신고 접수 기간 시작일 (`YYYY-MM-DD`). 마지막 신고일시(`last_reported_at`) 기준으로 이 날짜 00:00:00 이후 건만 조회합니다. | +| reported_at_to | query | string | 아니오 | — | 신고 접수 기간 종료일 (`YYYY-MM-DD`). 마지막 신고일시(`last_reported_at`) 기준으로 이 날짜 23:59:59 이전 건만 조회합니다. | +| sort_by | query | string | 아니오 | — | 정렬 기준 필드명 | +| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) | +| per_page | query | integer | 아니오 | — | 페이지당 항목 수 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/admin/reports?filters=%EC%98%88%EC%8B%9C%EA%B0%92&status=%EC%98%88%EC%8B%9C%EA%B0%92&target_type=%EC%98%88%EC%8B%9C%EA%B0%92&target_status=%EC%98%88%EC%8B%9C%EA%B0%92&board_id=1&reported_at_from=%EC%98%88%EC%8B%9C%EA%B0%92&reported_at_to=%EC%98%88%EC%8B%9C%EA%B0%92&sort_by=%EC%98%88%EC%8B%9C%EA%B0%92&sort_order=asc&per_page=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `7` | 기본 키 (내부 식별자) | +| board_id | integer | `6` | 게시판 ID (게시판 삭제 시 NULL) | +| board | object | `{"id":6,"name":"Q&A","slug":"qna","title":"파일 업로드 오류","cu…` | 신고 대상이 속한 게시판 정보 (id/name/slug/대상 제목/현재 상태). 게시판 삭제 시 관계 대신 첫 신고 로그의 스냅샷 값으로 폴백합니다. | +| target_type | string | `comment` | 신고 대상 타입 (post, comment) | +| target_type_label | string | `댓글` | `target_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| target_id | integer | `282` | 신고 대상 ID (동적 테이블의 ID) | +| post_id | integer | `99` | post 식별자 (연관 리소스 참조) | +| content | null | `null` | 본문 내용 | +| content_mode | string | `text` | 대상 본문의 형식 모드 (`text`/`html` 등). 목록에서는 미리보기 표시 방식을 결정하며 스냅샷이 없으면 `text`로 기본 설정됩니다. | +| content_preview | string | `저도 비슷한 경험이 있어요.` | 대상 본문의 미리보기 (앞 100자로 잘린 발췌). 목록 응답에서만 채워지고 상세 응답에서는 null 입니다. | +| author | object | `{"uuid":"a1e0a91a-fba6-491c-a53e-7285a5686857","name":"관리…` | 작성자 사용자 객체 (uuid/name — author 관계 파생) | +| reporter | object | `{"uuid":"a1e0a91a-fba6-491c-a53e-7285a5686857","name":"관리…` | 대표 신고자 정보 (uuid/name/email/is_guest). 첫 번째 신고 로그에서 추출하며, 비회원 신고 시 게스트로 표시됩니다. | +| reason_type | string | `abuse` | 대표 신고 사유 코드 (첫 번째 신고 로그의 사유 Enum 값 — 예: abuse, spam). | +| reason_type_label | string | `욕설/비방` | `reason_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| status | string | `review` | 신고 상태 (pending, review, rejected, suspended) | +| status_label | string | `검토` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| status_variant | string | `info` | 상태 표시 색상/스타일 변형 키 (상태 Enum variant() 산물 — UI 배지용) | +| processor | object | `{"uuid":"a1e0a91a-fba6-491c-a53e-7285a5686857","name":"관리자"}` | 신고를 처리한 관리자 정보 (uuid/name). 처리자가 지정되지 않은 경우 null 입니다. | +| processed_at | string | `2026-06-01 09:35:42` | processed 일시 | +| metadata | null | `null` | 메타데이터 (IP, User Agent 등) | +| report_count | integer | `1` | report 개수 (집계) | +| last_reported_at | string | `2026-06-02 09:35:42` | last reported 일시 | +| is_reactivated | boolean | `false` | reactivated 여부 | +| target_status | string | `published` | 신고 대상 콘텐츠(게시글/댓글)의 현재 상태. 대상 테이블의 status 컬럼을 서브쿼리로 조인한 값입니다 (예: published, blinded). | +| target_trigger_type | string | `admin` | 신고 대상의 현재 상태를 유발한 트리거 유형. 대상 테이블의 trigger_type 값으로, 블라인드 처리가 자동(auto_hide)/관리자 수동(admin) 중 무엇에 의한 것인지 구분합니다. | +| target_status_label | string | `게시중` | `target_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| created_at | string | `2026-06-04 09:35:42` | 생성 일시 | +| updated_at | string | `2026-06-04 09:35:42` | 최종 수정 일시 | +| is_owner | boolean | `false` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_view":true,"can_manage":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "신고 목록을 조회했습니다.", + "data": { + "data": [ + { + "id": 1, + "board_id": 1, + "board": { + "id": 1, + "name": "API 문서 샘플 게시판", + "slug": "apidoc-sample-board", + "title": null, + "current_status": null, + "deleted_at": null + }, + "target_type": "post", + "target_type_label": "게시글", + "target_id": 1, + "post_id": 1, + "content": null, + "content_mode": "text", + "content_preview": "", + "author": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "is_guest": false + }, + "reporter": null, + "reason_type": null, + "reason_type_label": null, + "status": "pending", + "status_label": "접수", + "status_variant": "warning", + "processor": null, + "processed_at": null, + "metadata": null, + "report_count": 0, + "last_reported_at": "2026-07-08 10:41:34", + "is_reactivated": false, + "target_status": "published", + "target_trigger_type": "user", + "target_status_label": "게시중", + "created_at": "2026-07-08 10:41:34", + "updated_at": "2026-07-08 10:41:34", + "is_owner": false, + "abilities": { + "can_view": true, + "can_manage": true + } + } + ], + "statistics": { + "by_status": { + "pending": 1 + }, + "by_type": { + "post": 1 + }, + "total": 1 + }, + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 20, + "total": 1, + "from": 1, + "to": 1, + "has_more_pages": false + }, + "abilities": { + "can_view": true, + "can_manage": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.reports.view`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 신고 관리 화면의 목록을 조회합니다. `auth:sanctum` 인증과 `sirsoft-board.reports.view` 권한이 필요합니다. 동일 대상(게시글/댓글)에 대한 여러 신고는 그룹화되어 최초 신고 1건만 목록에 노출되며, `filters`/`status`/`target_type`/`target_status`/`board_id`/기간(`reported_at_from`~`reported_at_to`)으로 필터링하고 `sort_by`/`sort_order`로 정렬합니다. `per_page`는 10~20 범위로 강제 제한되며, 응답에는 상태별 통계와 사용자 권한 정보가 함께 포함됩니다. + + +### PATCH /api/modules/sirsoft-board/admin/reports/bulk-status + +- **라우트명**: `api.modules.sirsoft-board.admin.reports.bulk-status` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\ReportController@bulkUpdateStatus` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.reports.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| ids | body | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | +| status | body | string | 예 | — | 일괄 전환할 신고 상태 (`ReportStatus` 허용값: pending/review/rejected/suspended 등). 지정한 모든 신고를 이 상태로 변경합니다. | +| process_note | body | string | 아니오 | max 1000 | 처리 메모 (최대 1000자). 상태 변경 사유나 조치 내용을 처리 이력에 함께 기록합니다. | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-board/admin/reports/bulk-status HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "ids": [ + "예시값" + ], + "status": "예시값", + "process_note": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.reports.manage`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 여러 신고의 상태를 한 번에 변경합니다. `auth:sanctum` 인증과 `sirsoft-board.reports.manage` 권한이 필요합니다. `ids`로 지정한 신고들만 대상으로 하며(그룹 확장 없음) `status`로 지정한 상태로 일괄 전환하고, 선택적으로 `process_note`(최대 1000자)를 처리 메모로 남깁니다. 응답은 실제 변경된 건수(`affected_count`), 대상 콘텐츠 복구 건수(`restored_count`), 수동 블라인드 복구 건수(`manual_blind_restored`)와 안내 메시지를 반환합니다. + + +### POST /api/modules/sirsoft-board/admin/reports/status-counts + +- **라우트명**: `api.modules.sirsoft-board.admin.reports.status-counts` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\ReportController@getStatusCounts` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.reports.view` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| ids | body | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | +| target_status | body | string | 아니오 | — | 집계 기준이 되는 전환 대상 상태 (`ReportStatus` 허용값). 지정 시 해당 상태로의 일괄 전환을 가정한 상태별 건수 요약을 계산합니다. | + +**요청 예시** + +```http +POST /api/modules/sirsoft-board/admin/reports/status-counts HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "ids": [ + "예시값" + ], + "target_status": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.reports.view`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 선택된 신고들의 상태별 건수를 집계하여 반환합니다. `auth:sanctum` 인증과 `sirsoft-board.reports.view` 권한이 필요합니다. 대량 상태 변경을 실행하기 전에 사용자에게 선택한 신고들의 상태 분포를 미리 보여주기 위한 조회용 API로, `ids` 배열(최소 1개)과 선택적 `target_status`를 받아 상태별 건수와 요약 정보를 계산합니다. + + +### DELETE /api/modules/sirsoft-board/admin/reports/{report} + +- **라우트명**: `api.modules.sirsoft-board.admin.reports.destroy` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\ReportController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.reports.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| report | path | string | 예 | — | 대상 report의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-board/admin/reports/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "신고가 삭제되었습니다.", + "data": null +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.reports.manage`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 신고를 삭제합니다(소프트 삭제). `auth:sanctum` 인증과 `sirsoft-board.reports.manage` 권한이 필요합니다. 경로의 `report`(신고 ID)에 해당하는 신고 케이스를 소프트 삭제하며, 존재하지 않으면 404, 스코프 권한 위반 시 403을 반환합니다. + + +### GET /api/modules/sirsoft-board/admin/reports/{report} + +- **라우트명**: `api.modules.sirsoft-board.admin.reports.show` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\ReportController@show` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.reports.view` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| report | path | string | 예 | — | 대상 report의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/admin/reports/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| board_id | integer | `4` | 게시판 ID (게시판 삭제 시 NULL) | +| board | object | `{"id":4,"name":"갤러리","slug":"gallery"}` | 신고 대상이 속한 게시판 정보 (id/name/slug). 게시판이 삭제된 경우 관계 대신 첫 신고 로그 스냅샷의 게시판명으로 폴백합니다. | +| target_type | string | `post` | 신고 대상 타입 (post, comment) | +| target_id | integer | `55` | 신고 대상 ID (동적 테이블의 ID) | +| post | object | `{"id":55,"title":"작업물 공유합니다","content":"최근에 작업한 결과물입니다.피드…` | 신고 대상 게시글 상세 (id/title/content/작성일시/작성자). 대상이 댓글이면 해당 댓글의 상위 게시글 정보가 담기며, 조회 불가 시 null 입니다. | +| comment | null | `null` | 신고 대상 댓글 상세 (id/content/작성일시/작성자). 대상 타입이 comment 일 때만 채워지고 게시글 신고에서는 null 입니다. | +| target_status | string | `published` | 신고 대상 콘텐츠의 현재 상태 (reportable의 current_status — 예: published, blinded). | +| target_status_label | string | `게시중` | `target_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| blind_trigger_type | string | `admin` | 대상 콘텐츠의 블라인드/상태 변경을 유발한 트리거 유형 (reportable의 trigger_type — 예: 자동/관리자 수동). | +| blind_trigger_type_label | string | `관리자 수동` | `blind_trigger_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| status | string | `pending` | 신고 상태 (pending, review, rejected, suspended) | +| available_actions | array | `["review","rejected","suspended","deleted"]` | 현재 상태에서 전환 가능한 다음 신고 상태 목록 (상태 Enum의 getAvailableTransitions() 산물). 상태 변경 UI의 선택지로 사용됩니다. | +| abilities | object | `{"can_view":true,"can_manage":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | +| reporters | array | `[{"id":1,"reporter":{"uuid":"a1e0a91a-fba6-491c-a53e-7285…` | 이 케이스에 접수된 개별 신고 로그 목록. 각 항목은 신고자(reporter)·사유(reason_type/reason_detail)·신고 시점 스냅샷(snapshot)·신고 일시를 포함합니다. | +| report_count | integer | `1` | report 개수 (집계) | +| reason_summary | string | `욕설/비방 1건` | 신고 사유 요약 문자열. 상위 2개 사유를 "사유 N건" 형식으로 나열하고 나머지는 "외 N건"으로 합산해 표시합니다. | +| first_reported_at | string | `2026-06-04 09:35:42` | first reported 일시 | +| last_reported_at | string | `2026-05-13 09:35:42` | last reported 일시 | +| histories | array | `[{"id":1,"type":"reported","action_label":"신고 접수","proces…` | 신고 처리 이력 타임라인 (process_histories JSON 기반, 최신순). 각 항목은 이벤트 유형·라벨·처리자·사유·신고자 수·발생 일시를 포함하며, 접수 이벤트에는 처리자가 없습니다. | +| metadata | object | `{"ip":"127.0.0.1","user_agent":"Mozilla\/5.0 (Windows NT …` | 메타데이터 (IP, User Agent 등) | +| created_at | string | `2026-06-04 09:35:42` | 생성 일시 | +| updated_at | string | `2026-06-04 09:35:42` | 최종 수정 일시 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "신고 목록을 조회했습니다.", + "data": { + "id": 1, + "board_id": 1, + "board": { + "id": 1, + "name": "API 문서 샘플 게시판", + "slug": "apidoc-sample-board" + }, + "target_type": "post", + "target_id": 1, + "post": { + "id": 1, + "title": "API 문서 샘플 게시글", + "content": "

API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.

", + "content_mode": "html", + "created_at": "2026-07-08 10:41:34", + "author": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com", + "is_guest": false + } + }, + "comment": null, + "target_status": "published", + "target_status_label": "게시중", + "blind_trigger_type": "user", + "blind_trigger_type_label": "사용자 직접 삭제", + "status": "pending", + "available_actions": [ + "review", + "rejected", + "suspended", + "deleted" + ], + "abilities": { + "can_view": true, + "can_manage": true + }, + "reporters": [], + "report_count": 0, + "reason_summary": "-", + "first_reported_at": "2026-07-08 10:41:34", + "last_reported_at": "2026-07-08 10:41:34", + "histories": [], + "metadata": { + "reason": "spam" + }, + "created_at": "2026-07-08 10:41:34", + "updated_at": "2026-07-08 10:41:34" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.reports.view`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 신고 케이스 1건의 상세 정보를 조회합니다. `auth:sanctum` 인증과 `sirsoft-board.reports.view` 권한이 필요합니다. 경로의 `report`(신고 ID)를 기준으로 동일 대상에 대한 모든 신고를 그룹화한 상세 정보와 신고 대상 콘텐츠(reportable) 데이터를 함께 반환하며, 처리 이력(histories), 신고자 목록(reporters), 사유 요약(reason_summary), 전환 가능한 상태(available_actions) 등을 포함합니다. 대상이 없으면 404, 스코프 권한 위반 시 403을 반환합니다. + + +### GET /api/modules/sirsoft-board/admin/reports/{report}/reporters + +- **라우트명**: `api.modules.sirsoft-board.admin.reports.reporters` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\ReportController@reporters` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.reports.view` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| report | path | string | 예 | — | 대상 report의 식별자 | +| per_page | query | integer | 아니오 | min 1 | 페이지당 항목 수 | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/admin/reports/1/reporters?per_page=1&page=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| reporter | object | `{"uuid":"a1e0a91a-fba6-491c-a53e-7285a5686857","name":"관리…` | 개별 신고자 정보 (uuid/name/email). 비회원(게스트) 신고 로그인 경우 null 입니다. | +| reason_type | string | `abuse` | 신고 사유 코드 (사유 Enum 값 — 예: abuse, spam). | +| reason_type_label | string | `욕설/비방` | `reason_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| reason_detail | string | `욕설과 비방이 포함된 게시글입니다. 다른 사용자를 모욕하는 내용이 …` | 신고자가 직접 입력한 상세 사유 텍스트. 입력하지 않은 경우 null 입니다. | +| snapshot | object | `{"board_name":"갤러리","title":"작업물 공유합니다","content":"최근에 작업…` | 신고 접수 시점의 대상 콘텐츠 스냅샷 (게시판명/제목/본문/작성자 등). 이후 대상이 수정·삭제되어도 신고 당시 내용을 보존합니다. | +| reported_at | string | `2026-06-04 09:35:42` | reported 일시 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "신고 목록을 조회했습니다.", + "data": { + "data": [], + "pagination": { + "total": 0, + "from": 0, + "to": 0, + "per_page": 25, + "current_page": 1, + "last_page": 1 + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.reports.view`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 신고 케이스에 접수된 개별 신고자 목록을 페이지네이션으로 반환합니다. `auth:sanctum` 인증과 `sirsoft-board.reports.view` 권한이 필요합니다. 경로의 `report`(신고 케이스 ID)에 대해 각 신고자의 신고 사유(reason_type/reason_detail)와 신고 시점 스냅샷(snapshot)을 포함한 항목을 반환하며, `per_page`(최대 50)와 `page`로 페이지를 제어합니다. 대상이 없으면 404를 반환합니다. + + +### PATCH /api/modules/sirsoft-board/admin/reports/{report}/status + +- **라우트명**: `api.modules.sirsoft-board.admin.reports.update-status` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\ReportController@updateStatus` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.reports.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| report | path | string | 예 | — | 대상 report의 식별자 | +| status | body | string | 예 | — | 전환할 신고 상태 (`ReportStatus` 허용값). 현재 상태에서 전환 불가한 값(영구삭제 등)은 검증에서 422로 차단됩니다. | +| process_note | body | string | 아니오 | max 1000 | 처리 메모 (최대 1000자). 상태 변경 사유나 조치 내용을 처리 이력에 함께 기록합니다. | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-board/admin/reports/1/status HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "status": "예시값", + "process_note": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.reports.manage`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 단건 신고의 상태를 변경합니다. `auth:sanctum` 인증과 `sirsoft-board.reports.manage` 권한이 필요합니다. 경로의 `report`(신고 ID)에 대해 `status`로 지정한 상태로 전환하고 선택적으로 `process_note`(최대 1000자)를 처리 메모로 남깁니다. 영구삭제(deleted) 등 전환 불가 상태는 FormRequest 검증에서 422로 선차단되고 서비스의 deleted 가드가 2차 방어선으로 동작하며, 대상이 없으면 404를 반환합니다. + + diff --git a/modules/_bundled/sirsoft-board/docs/api/settings.md b/modules/_bundled/sirsoft-board/docs/api/settings.md new file mode 100644 index 00000000..87c93cc2 --- /dev/null +++ b/modules/_bundled/sirsoft-board/docs/api/settings.md @@ -0,0 +1,821 @@ +# Settings API 레퍼런스 + +> **소유**: module `sirsoft-board` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Settings 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-board/admin/settings + +- **라우트명**: `api.modules.sirsoft-board.admin.settings.index` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\BoardSettingsController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.settings.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/admin/settings HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| basic_defaults | object | `{"type":"basic","per_page":20,"per_page_mobile":15,"order…` | 게시판 생성 시 적용되는 기본 설정 카테고리. 게시판 타입, 페이지당 글 수(PC/모바일), 정렬 기준/방향, 댓글·답글 사용 여부와 깊이, 제목·내용·댓글 길이 제한, 파일 업로드 허용/용량/개수/확장자, 새 글 표시 시간, 게시판 기본 권한(default_board_permissions) 등을 포함합니다. | +| report_policy | object | `{"auto_hide_threshold":5,"auto_hide_target":"both","daily…` | 신고 정책 카테고리. 자동 숨김 임계치(auto_hide_threshold)와 대상(auto_hide_target: post/comment/both), 사용자별 일일 신고 한도, 신고 거부 누적 제한(횟수/기간), 관리자·작성자 신고 알림 발송 여부와 채널을 포함합니다. | +| spam_security | object | `{"post_cooldown_seconds":0,"comment_cooldown_seconds":0,"…` | 스팸·보안 카테고리. 글·댓글·신고 작성 사이의 도배 방지 쿨다운 시간(초)과 조회수 캐시 TTL을 포함합니다. | +| display | object | `{"date_display_format":"standard"}` | 표시 설정 카테고리. 날짜 표시 형식(date_display_format: standard 절대 표기 / relative 상대 표기)을 포함합니다. | +| seo | object | `{"meta_boards_title":"{site_name}","meta_boards_descripti…` | SEO 메타 태그 설정 카테고리. 게시판 목록/개별 게시판/글 상세 페이지의 메타 제목·설명 템플릿과 각 페이지의 SEO 생성 활성화 여부(seo_boards, seo_board, seo_post_detail)를 포함합니다. | +| notifications | object | `{"channels":[{"id":"mail","is_active":true,"sort_order":0…` | 알림 채널 설정 카테고리. 각 채널(mail, database 등)의 식별자, 활성화 여부(is_active), 정렬 순서(sort_order)를 담은 channels 배열을 포함합니다. | +| report_permissions | object | `{"view_roles":["admin","manager"],"manage_roles":["admin"]}` | 신고 관리 권한 역할. 신고 내역을 조회할 수 있는 역할(view_roles)과 신고를 처리·관리할 수 있는 역할(manage_roles)의 식별자 배열이며, 설정값이 아닌 DB 권한 데이터로 관리됩니다. | +| abilities | object | `{"can_update":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | +| _meta | object | `{"limits":{"per_page_min":5,"per_page_max":100,"min_title…` | 편집 UI 보조 메타데이터. `limits`에 `config('sirsoft-board.limits')` 기반 입력 제한값(페이지당 글 수·답글/댓글 깊이의 최소·최대 등)이 담겨 프론트 입력 검증 범위로 사용됩니다. | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-board::messages.settings.fetch_success", + "data": { + "basic_defaults": { + "type": "basic", + "per_page": 20, + "per_page_mobile": 15, + "order_by": "created_at", + "order_direction": "DESC", + "secret_mode": "{MASKED}", + "use_comment": true, + "use_reply": true, + "max_reply_depth": 5, + "max_comment_depth": 10, + "comment_order": "ASC", + "show_view_count": true, + "use_report": false, + "min_title_length": 2, + "max_title_length": 200, + "min_content_length": 2, + "max_content_length": 10000, + "min_comment_length": 2, + "max_comment_length": 1000, + "use_file_upload": false, + "max_file_size": 10, + "max_file_count": 5, + "allowed_extensions": [ + "jpg", + "jpeg", + "png", + "gif", + "webp", + "pdf", + "doc", + "docx", + "xls", + "xlsx", + "ppt", + "pptx", + "hwp", + "txt", + "zip" + ], + "notify_admin_on_post": true, + "notify_author": true, + "new_display_hours": 24, + "blocked_keywords": [ + "시발", + "씨발", + "ㅅㅂ", + "ㅆㅂ", + "좆", + "ㅈㄴ", + "존나", + "씹", + "지랄", + "ㅈㄹ", + "개새끼", + "새끼", + "ㅅㄲ", + "개자식", + "병신", + "ㅂㅅ", + "미친놈", + "미친년", + "느금마", + "니애미", + "느금", + "엠창", + "엠뒤", + "빡대가리", + "개같은", + "개년", + "개놈", + "개돼지", + "개씹", + "개쓰레기", + "닥쳐", + "꺼져", + "죽어", + "자살해", + "뒤져", + "저능아", + "정신병자", + "썅", + "쌍놈", + "쌍년", + "쉬발", + "쒸발", + "시팔", + "씨팔", + "한남", + "한녀", + "김치녀", + "된장녀", + "맘충", + "틀딱", + "느개비", + "장애인놈", + "병신새끼", + "흑형", + "짱깨", + "쪽바리", + "왜놈", + "튀기", + "똥남아", + "야동", + "포르노", + "섹스", + "야사", + "음란", + "성인사이트", + "조건만남", + "원나잇", + "섹파", + "오피", + "키스방", + "매춘", + "성매매", + "몸파는" + ], + "default_board_permissions": { + "admin.posts.read": [ + "admin" + ], + "admin.posts.write": [ + "admin" + ], + "admin.posts.read-secret": [ + "admin" + ], + "admin.comments.read": [ + "admin" + ], + "admin.comments.write": [ + "admin" + ], + "admin.attachments.upload": [ + "admin" + ], + "admin.attachments.download": [ + "admin" + ], + "admin.manage": [ + "admin" + ], + "posts.read": [ + "admin", + "user", + "guest" + ], + "posts.write": [ + "admin", + "user" + ], + "posts.read-secret": [ + "admin" + ], + "comments.read": [ + "admin", + "user", + "guest" + ], + "comments.write": [ + "admin", + "user" + ], + "attachments.upload": [ + "admin", + "user" + ], + "attachments.download": [ + "admin", + "user", + "guest" + ], + "manager": [ + "admin" + ] + } + }, + "report_policy": { + "auto_hide_threshold": 5, + "auto_hide_target": "both", + "daily_report_limit": 10, + "rejection_limit_count": 5, + "rejection_limit_days": 30, + "notify_admin_on_report": true, + "notify_admin_on_report_scope": "per_case", + "notify_admin_on_report_channels": [ + "mail", + "database" + ], + "notify_author_on_report_action": true, + "notify_author_on_report_action_channels": [ + "mail", + "database" + ] + }, + "spam_security": { + "post_cooldown_seconds": 0, + "comment_cooldown_seconds": 0, + "report_cooldown_seconds": 60, + "view_count_cache_ttl": 86400 + }, + "display": { + "date_display_format": "standard" + }, + "seo": { + "meta_boards_title": "{site_name}", + "meta_boards_description": "", + "meta_board_title": "{board_name}", + "meta_board_description": "{board_description}", + "meta_post_title": "{board_name} - {post_title}", + "meta_post_description": "", + "seo_boards": true, + "seo_board": true, + "seo_post_detail": true + }, + "notifications": { + "channels": [ + { + "id": "mail", + "is_active": true, + "sort_order": 0 + }, + { + "id": "database", + "is_active": true, + "sort_order": 1 + } + ] + }, + "report_permissions": { + "view_roles": [ + "admin", + "apidoc-sample-role" + ], + "manage_roles": [ + "admin", + "apidoc-sample-role" + ] + }, + "abilities": { + "can_update": true + }, + "_meta": { + "limits": { + "per_page_min": 5, + "per_page_max": 100, + "min_title_length_min": 0, + "min_title_length_max": 200, + "max_title_length_min": 1, + "max_title_length_max": 1000, + "min_content_length_min": 0, + "min_content_length_max": 10000, + "max_content_length_min": 1, + "max_content_length_max": 100000, + "min_comment_length_min": 0, + "min_comment_length_max": 1000, + "max_comment_length_min": 1, + "max_comment_length_max": 10000, + "max_file_size_min": 1, + "max_file_size_max": 200, + "max_file_count_min": 1, + "max_file_count_max": 20, + "category_max": 50, + "max_reply_depth_min": 1, + "max_reply_depth_max": 10, + "max_comment_depth_min": 0, + "max_comment_depth_max": 10 + } + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.settings.read`)이 없는 경우 | + + + +**설명** 게시판 모듈의 전체 환경설정을 조회합니다. `auth:sanctum` 인증과 `sirsoft-board.settings.read` 권한이 필요합니다. 기본값(basic_defaults)/신고정책(report_policy)/스팸·보안(spam_security)/표시(display)/SEO/알림(notifications) 등 모든 카테고리 설정과 신고 권한 역할(report_permissions)을 함께 반환하며, 현재 사용자의 수정 가능 여부(abilities)와 입력 제한값(`_meta.limits`, `config('sirsoft-board.limits')`)을 포함합니다. + + +### PUT /api/modules/sirsoft-board/admin/settings + +- **라우트명**: `api.modules.sirsoft-board.admin.settings.store` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\BoardSettingsController@store` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| _tab | body | string | 아니오 | `basic_defaults`, `report_policy`, `spam_security`, `general`, `seo`, `notifications`, `notification_definitions` | 현재 편집 중인 탭을 나타내는 메타 값. 탭 단위 부분 저장의 컨텍스트를 식별하는 용도이며 설정값으로는 저장되지 않습니다. | +| notifications | body | array | 아니오 | — | 알림 채널 설정. `channels` 배열의 각 항목에 채널 식별자(id), 활성화 여부(is_active), 정렬 순서(sort_order)를 담아 저장합니다. | +| basic_defaults | body | array | 아니오 | — | 기본 설정 카테고리 값. 게시판 타입·페이지당 글 수·정렬·댓글/답글·길이 제한·파일 업로드·기본 권한 등 basic_defaults 하위 키를 저장합니다. | +| report_policy | body | array | 아니오 | — | 신고 정책 카테고리 값. 자동 숨김 임계치/대상, 일일 신고 한도, 거부 누적 제한, 관리자·작성자 신고 알림 설정을 저장합니다. | +| report_permissions | body | array | 아니오 | — | 신고 관리 권한 역할. `view_roles`(조회 역할)와 `manage_roles`(관리 역할)의 역할 식별자 배열이며, 포함 시 설정 저장과 별개로 DB 권한 역할이 동기화됩니다. | +| display | body | array | 아니오 | — | 표시 설정 카테고리 값. 날짜 표시 형식(date_display_format: standard/relative) 등을 저장합니다. | +| spam_security | body | array | 아니오 | — | 스팸·보안 카테고리 값. 글·댓글·신고 작성 쿨다운 시간(초)과 조회수 캐시 TTL을 저장합니다. | +| seo | body | array | 아니오 | — | SEO 설정 카테고리 값. 게시판 목록/개별/글 상세 페이지의 메타 제목·설명 템플릿과 각 페이지 SEO 활성화 여부를 저장합니다. | + +**요청 예시** + +```http +PUT /api/modules/sirsoft-board/admin/settings HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "_tab": "basic_defaults", + "notifications": [ + "예시값" + ], + "basic_defaults": [ + "예시값" + ], + "report_policy": [ + "예시값" + ], + "report_permissions": [ + "예시값" + ], + "display": [ + "예시값" + ], + "spam_security": [ + "예시값" + ], + "seo": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| basic_defaults | object | `{"type":"basic","per_page":20,"per_page_mobile":15,"order…` | 저장 후 갱신된 기본 설정 카테고리. 게시판 타입, 페이지당 글 수(PC/모바일), 정렬 기준/방향, 댓글·답글 사용 여부와 깊이, 제목·내용·댓글 길이 제한, 파일 업로드 허용/용량/개수/확장자, 새 글 표시 시간, 게시판 기본 권한(default_board_permissions) 등을 포함합니다. | +| report_policy | object | `{"auto_hide_threshold":5,"auto_hide_target":"both","daily…` | 저장 후 갱신된 신고 정책 카테고리. 자동 숨김 임계치(auto_hide_threshold)와 대상(auto_hide_target: post/comment/both), 사용자별 일일 신고 한도, 신고 거부 누적 제한(횟수/기간), 관리자·작성자 신고 알림 발송 여부와 채널을 포함합니다. | +| spam_security | object | `{"post_cooldown_seconds":0,"comment_cooldown_seconds":0,"…` | 저장 후 갱신된 스팸·보안 카테고리. 글·댓글·신고 작성 사이의 도배 방지 쿨다운 시간(초)과 조회수 캐시 TTL을 포함합니다. | +| display | object | `{"date_display_format":"standard"}` | 저장 후 갱신된 표시 설정 카테고리. 날짜 표시 형식(date_display_format: standard 절대 표기 / relative 상대 표기)을 포함합니다. | +| seo | object | `{"meta_boards_title":"{site_name}","meta_boards_descripti…` | 저장 후 갱신된 SEO 메타 태그 설정 카테고리. 게시판 목록/개별 게시판/글 상세 페이지의 메타 제목·설명 템플릿과 각 페이지의 SEO 생성 활성화 여부(seo_boards, seo_board, seo_post_detail)를 포함합니다. | +| notifications | object | `{"channels":[{"id":"mail","is_active":true,"sort_order":0…` | 저장 후 갱신된 알림 채널 설정 카테고리. 각 채널(mail, database 등)의 식별자, 활성화 여부(is_active), 정렬 순서(sort_order)를 담은 channels 배열을 포함합니다. | +| report_permissions | object | `{"view_roles":[],"manage_roles":[]}` | 저장 후 동기화된 신고 관리 권한 역할. 신고 내역을 조회할 수 있는 역할(view_roles)과 신고를 처리·관리할 수 있는 역할(manage_roles)의 식별자 배열이며, 요청에 포함된 경우 DB 권한 역할로 동기화되어 반환됩니다. | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-board::messages.settings.save_success", + "data": { + "basic_defaults": { + "type": "basic", + "per_page": 20, + "per_page_mobile": 15, + "order_by": "created_at", + "order_direction": "DESC", + "secret_mode": "{MASKED}", + "use_comment": true, + "use_reply": true, + "max_reply_depth": 5, + "max_comment_depth": 10, + "comment_order": "ASC", + "show_view_count": true, + "use_report": false, + "min_title_length": 2, + "max_title_length": 200, + "min_content_length": 2, + "max_content_length": 10000, + "min_comment_length": 2, + "max_comment_length": 1000, + "use_file_upload": false, + "max_file_size": 10, + "max_file_count": 5, + "allowed_extensions": [ + "jpg", + "jpeg", + "png", + "gif", + "webp", + "pdf", + "doc", + "docx", + "xls", + "xlsx", + "ppt", + "pptx", + "hwp", + "txt", + "zip" + ], + "notify_admin_on_post": true, + "notify_author": true, + "new_display_hours": 24, + "blocked_keywords": [ + "시발", + "씨발", + "ㅅㅂ", + "ㅆㅂ", + "좆", + "ㅈㄴ", + "존나", + "씹", + "지랄", + "ㅈㄹ", + "개새끼", + "새끼", + "ㅅㄲ", + "개자식", + "병신", + "ㅂㅅ", + "미친놈", + "미친년", + "느금마", + "니애미", + "느금", + "엠창", + "엠뒤", + "빡대가리", + "개같은", + "개년", + "개놈", + "개돼지", + "개씹", + "개쓰레기", + "닥쳐", + "꺼져", + "죽어", + "자살해", + "뒤져", + "저능아", + "정신병자", + "썅", + "쌍놈", + "쌍년", + "쉬발", + "쒸발", + "시팔", + "씨팔", + "한남", + "한녀", + "김치녀", + "된장녀", + "맘충", + "틀딱", + "느개비", + "장애인놈", + "병신새끼", + "흑형", + "짱깨", + "쪽바리", + "왜놈", + "튀기", + "똥남아", + "야동", + "포르노", + "섹스", + "야사", + "음란", + "성인사이트", + "조건만남", + "원나잇", + "섹파", + "오피", + "키스방", + "매춘", + "성매매", + "몸파는" + ], + "default_board_permissions": { + "admin.posts.read": [ + "admin" + ], + "admin.posts.write": [ + "admin" + ], + "admin.posts.read-secret": [ + "admin" + ], + "admin.comments.read": [ + "admin" + ], + "admin.comments.write": [ + "admin" + ], + "admin.attachments.upload": [ + "admin" + ], + "admin.attachments.download": [ + "admin" + ], + "admin.manage": [ + "admin" + ], + "posts.read": [ + "admin", + "user", + "guest" + ], + "posts.write": [ + "admin", + "user" + ], + "posts.read-secret": [ + "admin" + ], + "comments.read": [ + "admin", + "user", + "guest" + ], + "comments.write": [ + "admin", + "user" + ], + "attachments.upload": [ + "admin", + "user" + ], + "attachments.download": [ + "admin", + "user", + "guest" + ], + "manager": [ + "admin" + ] + } + }, + "report_policy": { + "auto_hide_threshold": 5, + "auto_hide_target": "both", + "daily_report_limit": 10, + "rejection_limit_count": 5, + "rejection_limit_days": 30, + "notify_admin_on_report": true, + "notify_admin_on_report_scope": "per_case", + "notify_admin_on_report_channels": [ + "mail", + "database" + ], + "notify_author_on_report_action": true, + "notify_author_on_report_action_channels": [ + "mail", + "database" + ] + }, + "spam_security": { + "post_cooldown_seconds": 0, + "comment_cooldown_seconds": 0, + "report_cooldown_seconds": 60, + "view_count_cache_ttl": 86400 + }, + "display": { + "date_display_format": "standard" + }, + "seo": { + "meta_boards_title": "{site_name}", + "meta_boards_description": "", + "meta_board_title": "{board_name}", + "meta_board_description": "{board_description}", + "meta_post_title": "{board_name} - {post_title}", + "meta_post_description": "", + "seo_boards": true, + "seo_board": true, + "seo_post_detail": true + }, + "notifications": { + "channels": [ + { + "id": "mail", + "is_active": true, + "sort_order": 0 + }, + { + "id": "database", + "is_active": true, + "sort_order": 1 + } + ] + }, + "report_permissions": { + "view_roles": [], + "manage_roles": [] + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.settings.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 게시판 모듈의 환경설정을 저장합니다. `auth:sanctum` 인증과 `sirsoft-board.settings.update` 권한이 필요합니다. `_tab`으로 지정한 탭 단위로 검증된 설정을 저장하며, `report_permissions`가 포함된 경우 신고 권한 역할도 함께 동기화합니다. 저장 성공 시 갱신된 전체 설정과 신고 권한 역할을 반환합니다. + + +### POST /api/modules/sirsoft-board/admin/settings/bulk-apply + +- **라우트명**: `api.modules.sirsoft-board.admin.settings.bulk-apply` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\BoardSettingsController@bulkApply` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| fields | body | array | 예 | min 1 | 대상 게시판에 일괄 적용할 필드 목록(최소 1개). boards 테이블 컬럼(type, per_page, use_comment, allowed_extensions 등)이나 권한 필드(default_board_permissions, manager), 점(.)을 포함한 개별 권한 키(예: `posts.read`)를 허용합니다. | +| apply_all | body | boolean | 예 | — | 전체 게시판 적용 여부. true면 모든 게시판에 적용하고, false면 `board_ids`로 지정한 게시판에만 적용합니다(false 시 board_ids 필수). | +| board_ids | body | array | 아니오 | — | board 식별자 배열 | +| override_values | body | array | 아니오 | — | 환경설정 기본값 대신 사용할 재정의 값 맵. 지정한 필드에 대해 기본값이 아닌 임의의 값으로 일괄 적용할 때 사용합니다. | + +**요청 예시** + +```http +POST /api/modules/sirsoft-board/admin/settings/bulk-apply HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "fields": [ + "예시값" + ], + "apply_all": true, + "board_ids": [ + "예시값" + ], + "override_values": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.settings.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 환경설정의 기본값을 기존 게시판들에 일괄 적용합니다. `auth:sanctum` 인증과 `sirsoft-board.settings.update` 권한이 필요합니다. `fields`(적용할 필드, 최소 1개)와 `apply_all`(전체 적용 여부)을 받으며, `apply_all`이 false이면 `board_ids`로 대상을 지정하고 `override_values`로 값을 재정의할 수 있습니다. 적용 도중 실패하면 전체가 롤백되며, 이 경우에도 HTTP 200으로 `rolled_back: true`와 실패 지점 정보를 반환하여 프론트에서 안내 처리합니다. + + +### POST /api/modules/sirsoft-board/admin/settings/clear-cache + +- **라우트명**: `api.modules.sirsoft-board.admin.settings.clear-cache` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\BoardSettingsController@clearCache` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.settings.update` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/modules/sirsoft-board/admin/settings/clear-cache HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.settings.update`)이 없는 경우 | + + + +**설명** 게시판 모듈 설정 캐시를 초기화합니다. `auth:sanctum` 인증과 `sirsoft-board.settings.update` 권한이 필요합니다. ModuleSettings 캐시와 게시판 캐시를 모두 초기화하며, 응답으로 `cleared: true`를 반환합니다. + + +### GET /api/modules/sirsoft-board/admin/settings/{category} + +- **라우트명**: `api.modules.sirsoft-board.admin.settings.show` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\BoardSettingsController@show` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-board.settings.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| category | path | string | 예 | — | 분류 필터 (해당 분류의 항목만 조회) | + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/admin/settings/{category} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-board.settings.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 특정 카테고리의 설정만 조회합니다. `auth:sanctum` 인증과 `sirsoft-board.settings.read` 권한이 필요합니다. 경로의 `category`에 해당하는 설정만 반환하며, 응답에는 카테고리명(category), 설정값(settings), 현재 사용자의 수정 가능 여부(abilities)가 포함됩니다. + + diff --git a/modules/_bundled/sirsoft-board/docs/api/users.md b/modules/_bundled/sirsoft-board/docs/api/users.md new file mode 100644 index 00000000..dd2b6953 --- /dev/null +++ b/modules/_bundled/sirsoft-board/docs/api/users.md @@ -0,0 +1,192 @@ +# Users API 레퍼런스 + +> **소유**: module `sirsoft-board` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Users 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-board/users/{user}/posts + +- **라우트명**: `api.modules.sirsoft-board.users.posts.index` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\PostController@userPosts` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| user | path | string | 예 | — | 대상 user의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/users/a234c2b1-cde8-437f-b28b-23323be2b98d/posts HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `237` | 기본 키 (내부 식별자) | +| board_slug | string | `apidoc-sample-board` | 게시글이 속한 게시판의 슬러그(URL 식별자)입니다. 게시판 상세 링크 구성에 사용합니다. | +| board_name | string | `API 문서 샘플 게시판` | 게시글이 속한 게시판의 표시 이름입니다. 현재 로케일에 맞는 다국어 이름(`getLocalizedName()`)이 적용됩니다. | +| activity_type | string | `authored` | 활동 유형입니다. 공개 프로필의 게시글 목록은 작성글만 반환하므로 항상 `authored`(본인이 작성한 글)입니다. | +| activity_count | integer | `0` | activity 개수 (집계) | +| title | string | `API 문서 샘플 게시글` | 제목 | +| is_secret | boolean | `false` | secret 여부 | +| status | string | `published` | 계정 상태 (active: 활성, inactive: 비활성, blocked: 차단, withdrawn: 탈퇴) | +| view_count | integer | `43` | view 개수 (집계) | +| comment_count | integer | `0` | comment 개수 (집계) | +| created_at | string | `2026-07-07 09:34:50` | 생성 일시 | +| created_at_formatted | string | `4시간 전` | `created_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| content_plain | string | `API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.` | 게시글 본문의 순수 텍스트입니다. HTML 모드 글은 태그를 제거한 평문으로 변환되며, 목록 미리보기용으로 사용합니다. | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "게시글 목록을 조회했습니다.", + "data": { + "current_page": 1, + "data": [ + { + "id": 1, + "board_slug": "apidoc-sample-board", + "board_name": "API 문서 샘플 게시판", + "activity_type": "authored", + "activity_count": 0, + "title": "API 문서 샘플 게시글", + "is_secret": "{MASKED}", + "status": "published", + "view_count": 44, + "comment_count": 0, + "created_at": "2026-07-08 10:41:34", + "created_at_formatted": "4시간 전", + "content_plain": "API 레퍼런스 실측용 완전 샘플 게시글 본문입니다." + } + ], + "first_page_url": "https://api.example.com/api/modules/sirsoft-board/users/a234c2b1-cde8-437f-b28b-23323be2b98d/posts?page=1", + "from": 1, + "last_page": 1, + "last_page_url": "https://api.example.com/api/modules/sirsoft-board/users/a234c2b1-cde8-437f-b28b-23323be2b98d/posts?page=1", + "links": [ + { + "url": null, + "label": "pagination.previous", + "page": null, + "active": false + }, + { + "url": "https://api.example.com/api/modules/sirsoft-board/users/a234c2b1-cde8-437f-b28b-23323be2b98d/posts?page=1", + "label": "1", + "page": 1, + "active": true + }, + { + "url": null, + "label": "pagination.next", + "page": null, + "active": false + } + ], + "next_page_url": null, + "path": "https://api.example.com/api/modules/sirsoft-board/users/a234c2b1-cde8-437f-b28b-23323be2b98d/posts", + "per_page": 25, + "prev_page_url": null, + "to": 1, + "total": 1 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 특정 사용자(`{user}` 는 회원 uuid 로 라우트 바인딩)의 공개 프로필 페이지에 표시할 게시글 목록을 모든 게시판에 걸쳐 반환합니다. 비밀글은 제외되며, `optional.sanctum` 이 적용되어 비로그인 상태에서도 조회할 수 있습니다. `per_page`(1~100, 기본 20)와 `sort`(latest 등) 쿼리 파라미터로 페이지네이션·정렬을 제어합니다. + + +### GET /api/modules/sirsoft-board/users/{user}/posts/stats + +- **라우트명**: `api.modules.sirsoft-board.users.posts.stats` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\PostController@userPostsStats` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| user | path | string | 예 | — | 대상 user의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/users/a234c2b1-cde8-437f-b28b-23323be2b98d/posts/stats HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| posts_count | integer | `1` | posts 개수 (집계) | +| comments_count | integer | `1` | comments 개수 (집계) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "posts_count": 1, + "comments_count": 1 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 공개 프로필 페이지 상단에 표시할 특정 사용자(`{user}` 는 회원 uuid)의 게시글·댓글 수 요약을 반환합니다. `status=published` 인 항목만 집계하며, `optional.sanctum` 이 적용되어 비로그인 상태에서도 조회할 수 있습니다. + + diff --git a/modules/_bundled/sirsoft-board/src/Support/ApiDoc/ApiDocSampleService.php b/modules/_bundled/sirsoft-board/src/Support/ApiDoc/ApiDocSampleService.php new file mode 100644 index 00000000..9466e2fd --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Support/ApiDoc/ApiDocSampleService.php @@ -0,0 +1,290 @@ +}> 도메인 => 대표 레코드 정보 + */ + public function seed(): array + { + $board = $this->seedBoard(); + $post = $this->seedPost($board); + $comment = $this->seedComment($board, $post); + $attachment = $this->seedAttachment($board, $post); + + // 게시판 slug 라우팅(boards/{slug}/posts/{id}...)을 실측하기 위한 path 파라미터 맵. + // route-model binding 이 없는 문자열 param 을 실제 값으로 정확 일치 치환한다. + $boardParams = [ + 'slug' => (string) $board->slug, + 'board' => (string) $board->getKey(), + 'id' => (string) $post->getKey(), + 'postId' => (string) $post->getKey(), + 'commentId' => (string) $comment->getKey(), + 'hash' => (string) $attachment->hash, + ]; + + $map = []; + + // boards: 공개(User) 게시판/게시글/댓글/첨부 라우트 + $map['boards'] = [ + 'model' => Board::class, + 'key' => $board->getRouteKeyName(), + 'value' => (string) $board->getRouteKey(), + 'path_params' => $boardParams, + ]; + + // board: 관리자 게시글/첨부/댓글 라우트(admin/board/{slug}/posts/{id}...) + $map['board'] = [ + 'model' => Board::class, + 'key' => $board->getRouteKeyName(), + 'value' => (string) $board->getRouteKey(), + 'path_params' => $boardParams, + ]; + + // reports: 신고 상세(admin/reports/{report}) 실측용 대표 신고 + if ($report = $this->representativeReport($board, $post)) { + $map['reports'] = [ + 'model' => Report::class, + 'key' => $report->getRouteKeyName(), + 'value' => (string) $report->getRouteKey(), + 'path_params' => ['report' => (string) $report->getKey()], + ]; + } + + // board-types: 게시판 유형(GET 상세는 없으나 대표 키 노출) + if ($boardType = $this->representativeBoardType()) { + $map['board-types'] = [ + 'model' => BoardType::class, + 'key' => $boardType->getRouteKeyName(), + 'value' => (string) $boardType->getRouteKey(), + 'path_params' => ['id' => (string) $boardType->getKey()], + ]; + } + + return $map; + } + + /** + * 완전 샘플 게시판을 멱등 생성합니다(신고/파일 업로드 활성). + * + * @return Board 대표 게시판 레코드 + */ + private function seedBoard(): Board + { + $board = Board::query()->where('slug', self::SAMPLE_SLUG)->first() + ?? Board::factory()->create([ + 'slug' => self::SAMPLE_SLUG, + 'name' => ['ko' => 'API 문서 샘플 게시판', 'en' => 'API Doc Sample Board'], + 'is_active' => true, + 'use_report' => true, + 'use_file_upload' => true, + 'show_view_count' => true, + ]); + + // factory 직접 생성은 BoardService::create 의 권한 등록 훅을 우회하므로, + // 게시판별 권한(sirsoft-board.{slug}.{action})을 멱등 등록한다. 기본 role + // 매핑에 admin 이 포함(posts.read/comments.read 등)되어 실측 사용자(admin)가 + // slug 라우트(boards/{slug}/posts...)를 403 없이 실측할 수 있게 한다. + (new BoardPermissionService)->ensureBoardPermissions($board); + + return $board; + } + + /** + * 완전 샘플 공개 게시글을 멱등 생성합니다. + * + * @param Board $board 대표 게시판 + * @return Post 대표 게시글 레코드 + */ + private function seedPost(Board $board): Post + { + $post = Post::query() + ->where('board_id', $board->id) + ->where('title', 'API 문서 샘플 게시글') + ->first(); + + if ($post) { + return $post; + } + + $actor = $this->sampleActor(); + + return Post::query()->create([ + 'board_id' => $board->id, + 'title' => 'API 문서 샘플 게시글', + 'content' => '

API 레퍼런스 실측용 완전 샘플 게시글 본문입니다.

', + 'content_mode' => 'html', + 'user_id' => $actor?->id, + 'author_name' => $actor?->name ?? '관리자', + 'ip_address' => '127.0.0.1', + 'is_notice' => false, + 'is_secret' => false, + 'status' => PostStatus::Published, + 'trigger_type' => TriggerType::User, + 'view_count' => 42, + ]); + } + + /** + * 완전 샘플 댓글을 멱등 생성합니다. + * + * @param Board $board 대표 게시판 + * @param Post $post 대표 게시글 + * @return Comment 대표 댓글 레코드 + */ + private function seedComment(Board $board, Post $post): Comment + { + $comment = Comment::query() + ->where('post_id', $post->id) + ->where('content', 'API 문서 샘플 댓글입니다.') + ->first(); + + if ($comment) { + return $comment; + } + + $actor = $this->sampleActor(); + + return Comment::query()->create([ + 'board_id' => $board->id, + 'post_id' => $post->id, + 'user_id' => $actor?->id, + 'author_name' => $actor?->name ?? '관리자', + 'content' => 'API 문서 샘플 댓글입니다.', + 'is_secret' => false, + 'status' => PostStatus::Published, + 'trigger_type' => TriggerType::User, + 'depth' => 0, + ]); + } + + /** + * 완전 샘플 첨부파일 레코드를 멱등 생성합니다(실파일 없이 메타만). + * + * @param Board $board 대표 게시판 + * @param Post $post 대표 게시글 + * @return Attachment 대표 첨부 레코드 + */ + private function seedAttachment(Board $board, Post $post): Attachment + { + $attachment = Attachment::query() + ->where('post_id', $post->id) + ->where('original_filename', 'apidoc-sample.png') + ->first(); + + if ($attachment) { + return $attachment; + } + + $actor = $this->sampleActor(); + + return Attachment::query()->create([ + 'board_id' => $board->id, + 'post_id' => $post->id, + 'hash' => 'apidocsmpl1', + 'original_filename' => 'apidoc-sample.png', + 'stored_filename' => 'apidoc-sample.png', + 'disk' => 'public', + 'path' => 'board/apidoc-sample.png', + 'mime_type' => 'image/png', + 'size' => 2048, + 'collection' => 'default', + 'order' => 0, + 'created_by' => $actor?->id, + 'trigger_type' => TriggerType::User, + ]); + } + + /** + * 신고 상세 실측용 대표 신고를 반환합니다(기존 우선, 없으면 멱등 생성). + * + * @param Board $board 대표 게시판 + * @param Post $post 대표 게시글 + * @return Report|null 대표 신고 (생성 실패 시 null) + */ + private function representativeReport(Board $board, Post $post): ?Report + { + if ($report = Report::query()->orderBy('id')->first()) { + return $report; + } + + $actor = $this->sampleActor(); + + return Report::query()->create([ + 'board_id' => $board->id, + 'target_type' => ReportType::Post, + 'target_id' => $post->id, + 'author_id' => $actor?->id, + 'status' => ReportStatus::Pending, + 'process_histories' => [], + 'metadata' => ['reason' => ReportReasonType::Spam->value], + 'last_reported_at' => now(), + ]); + } + + /** + * 대표 게시판 유형을 반환합니다(기존 우선, 없으면 멱등 생성). + * + * @return BoardType|null 대표 게시판 유형 (없으면 null) + */ + private function representativeBoardType(): ?BoardType + { + if ($boardType = BoardType::query()->orderBy('id')->first()) { + return $boardType; + } + + return BoardType::query()->create([ + 'slug' => 'apidoc-sample-type', + 'name' => ['ko' => 'API 문서 샘플 유형', 'en' => 'API Doc Sample Type'], + ]); + } + + /** + * 샘플 작성자로 쓸 사용자를 반환합니다. + * + * 코어 완전 샘플 사용자(먼저 시드됨)를 우선하고, 없으면 첫 사용자로 폴백합니다. + * + * @return User|null 샘플 사용자 (없으면 null) + */ + private function sampleActor(): ?User + { + return User::query()->where('email', 'apidoc-sample-user@example.com')->first() + ?? User::query()->orderBy('id')->first(); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Support/ApiDocSampleServiceTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Support/ApiDocSampleServiceTest.php new file mode 100644 index 00000000..12aefbae --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/Support/ApiDocSampleServiceTest.php @@ -0,0 +1,112 @@ +assertInstanceOf(ApiDocSampleSeeder::class, new ApiDocSampleService); + } + + #[Test] + public function 게시판_도메인_대표_샘플_맵을_반환한다(): void + { + $map = (new ApiDocSampleService)->seed(); + + // 공개(boards)/관리자(board) 두 도메인 키가 모두 존재 + $this->assertArrayHasKey('boards', $map); + $this->assertArrayHasKey('board', $map); + $this->assertSame(Board::class, $map['boards']['model']); + $this->assertNotEmpty($map['boards']['value']); + } + + #[Test] + public function slug_라우팅_실측용_path_params_맵을_제공한다(): void + { + $map = (new ApiDocSampleService)->seed(); + + // boards 도메인은 slug/board/id/postId/commentId/hash 를 실제 값으로 제공 + $params = $map['boards']['path_params']; + + $this->assertSame(self::SAMPLE_SLUG, $params['slug']); + $this->assertArrayHasKey('id', $params); + $this->assertArrayHasKey('postId', $params); + $this->assertArrayHasKey('commentId', $params); + $this->assertArrayHasKey('hash', $params); + + // 각 값은 실제 시드된 레코드의 키와 일치 + $post = Post::query()->where('title', 'API 문서 샘플 게시글')->firstOrFail(); + $comment = Comment::query()->where('content', 'API 문서 샘플 댓글입니다.')->firstOrFail(); + $attachment = Attachment::query()->where('original_filename', 'apidoc-sample.png')->firstOrFail(); + + $this->assertSame((string) $post->getKey(), $params['id']); + $this->assertSame((string) $post->getKey(), $params['postId']); + $this->assertSame((string) $comment->getKey(), $params['commentId']); + $this->assertSame((string) $attachment->hash, $params['hash']); + } + + #[Test] + public function 대표_샘플_게시판은_공개_게시글과_댓글과_첨부를_갖는다(): void + { + (new ApiDocSampleService)->seed(); + + $board = Board::query()->where('slug', self::SAMPLE_SLUG)->firstOrFail(); + $post = Post::query()->where('board_id', $board->id)->where('title', 'API 문서 샘플 게시글')->firstOrFail(); + + $this->assertTrue((bool) $board->is_active); + $this->assertFalse((bool) $post->is_secret); + $this->assertSame('published', $post->status->value); + $this->assertSame(1, Comment::query()->where('post_id', $post->id)->count()); + $this->assertSame(1, Attachment::query()->where('post_id', $post->id)->count()); + } + + #[Test] + public function 게시판별_권한이_admin_역할에_등록되어_실측_사용자가_접근할_수_있다(): void + { + (new ApiDocSampleService)->seed(); + + // ensureBoardPermissions 로 게시판별 권한이 생성됨 (posts.read 등) + $this->assertDatabaseHas('permissions', [ + 'identifier' => 'sirsoft-board.'.self::SAMPLE_SLUG.'.posts.read', + ]); + } + + #[Test] + public function 재실행_시_샘플이_중복_생성되지_않는다(): void + { + $service = new ApiDocSampleService; + + $service->seed(); + $service->seed(); + + $this->assertSame(1, Board::query()->where('slug', self::SAMPLE_SLUG)->count()); + $this->assertSame(1, Post::query()->where('title', 'API 문서 샘플 게시글')->count()); + $this->assertSame(1, Comment::query()->where('content', 'API 문서 샘플 댓글입니다.')->count()); + $this->assertSame(1, Attachment::query()->where('original_filename', 'apidoc-sample.png')->count()); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 9e4a26a6..287f7454 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -4,6 +4,24 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.2] - 2026-07-10 + +### Changed + +- 쇼핑몰 환경설정 > 배송설정에서 배송가능 국가를 추가할 때, 사이트에 설치된 모든 언어에 대해 국가명을 입력할 수 있습니다. 기존에는 한국어·영어 두 칸만 있어 그 외 언어의 국가명을 넣을 방법이 없었습니다. 언어를 새로 설치하면 입력칸도 함께 늘어납니다. 입력하지 않은 언어는 저장되지 않으며, 기본 제공 국가는 언어팩이 자동으로 채웁니다. +- 휴대폰·태블릿 화면에서 통화·배송국가 선택이 메뉴 안의 접었다 펼치는 항목으로 바뀌었습니다. 접혀 있을 때도 현재 선택된 통화와 배송국가가 함께 표시되어 펼치지 않아도 확인할 수 있고, 펼치면 가로 버튼으로 나열됩니다. 좁은 화면의 메뉴 안에서 드롭다운이 잘려 보이던 문제가 함께 해소됩니다. 데스크톱 화면의 드롭다운은 그대로입니다. 관리자 화면도 동일하게 적용됩니다. +- 휴대폰 화면에서 주문 취소·주문상태 일괄변경 팝업의 상품 목록이 표 대신 상품별 카드 형태로 표시됩니다. + +### Fixed + +- 배송국가 이름이 어떤 언어로 화면을 보든 항상 한국어로만 표시되던 문제를 수정했습니다. 이제 사용자·관리자 헤더의 배송국가 선택, 회원가입의 배송국가 선택, 관리자 회원정보와 주문상세의 배송국가, 배송설정의 기본 배송국가가 모두 현재 보고 있는 언어로 표시됩니다. 일본어처럼 나중에 추가한 언어에서도 언어팩이 제공하는 국가명이 그대로 반영됩니다. +- 쇼핑몰 환경설정의 은행 관리에서 은행코드를 입력해도 입력한 값이 보이지 않던 문제를 수정했습니다 — 좁은 화면에서 입력칸이 글자가 들어갈 수 없을 만큼 줄어들고 있었습니다. +- 휴대폰 화면에서 주문 취소·주문상태 일괄변경 팝업의 금액이 "249,000원" 대신 "249,000" / "원" 처럼 숫자 중간에서 잘려 보이던 문제를 수정했습니다. 금액은 앞으로도 숫자 중간에서 줄바꿈되지 않으며, "결제완료" 같은 상태 이름도 "결제완" / "료" 로 쪼개지지 않습니다. +- 휴대폰 화면에서 쇼핑몰 환경설정 화면이 좌우로 넘쳐 가로 스크롤이 생기던 문제를 수정했습니다 (이메일 입력 줄, 쇼핑몰 이름·종목 입력칸, 탭 영역). +- 휴대폰 화면에서 쿠폰 발급기간 입력칸과 엑셀 다운로드 기간 검색 입력칸이 화면 밖으로 넘치던 문제를 수정했습니다. +- 은행 관리·도서산간 배송비 팝업의 표가 좁은 화면에서 가로로 스크롤되도록 명시해, 앞으로 표가 잘려 보이지 않도록 했습니다. +- 관리자 주문상세의 상품 목록에서 구매수량·적립예정만 가운데로 놓여 다른 금액 열과 세로줄이 어긋나 보이던 문제를 수정했습니다. 이제 여섯 개 숫자 열이 모두 오른쪽 끝에 맞춰 정렬됩니다. 휴대폰 화면의 카드 형태에서도 동일하게 적용됩니다. + ## [1.0.1] - 2026-07-08 ### Added diff --git a/modules/_bundled/sirsoft-ecommerce/composer.json b/modules/_bundled/sirsoft-ecommerce/composer.json index 3ccab90e..d7af07c0 100644 --- a/modules/_bundled/sirsoft-ecommerce/composer.json +++ b/modules/_bundled/sirsoft-ecommerce/composer.json @@ -2,7 +2,7 @@ "name": "modules/sirsoft-ecommerce", "description": "Ecommerce module for Gnuboard7", "type": "library", - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "autoload": { "psr-4": { diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/README.md b/modules/_bundled/sirsoft-ecommerce/docs/api/README.md new file mode 100644 index 00000000..593b69e1 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/README.md @@ -0,0 +1,46 @@ +# API 레퍼런스 문서 목차 + +> **소유**: 모듈 `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). +> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. + + +- **문서 수**: 33 · **엔드포인트 수**: 231 + +| 문서 | 도메인 | 엔드포인트 | +| --- | --- | --- | +| [addresses.md](addresses.md) | `addresses` | 6 | +| [brands.md](brands.md) | `brands` | 6 | +| [cart.md](cart.md) | `cart` | 11 | +| [categories.md](categories.md) | `categories` | 14 | +| [category-image.md](category-image.md) | `category-image` | 1 | +| [checkout.md](checkout.md) | `checkout` | 5 | +| [claim-reasons.md](claim-reasons.md) | `claim-reasons` | 8 | +| [coupons.md](coupons.md) | `coupons` | 4 | +| [currency.md](currency.md) | `currency` | 2 | +| [dashboard.md](dashboard.md) | `dashboard` | 4 | +| [extra-fee-templates.md](extra-fee-templates.md) | `extra-fee-templates` | 10 | +| [guest.md](guest.md) | `guest` | 5 | +| [inquiries.md](inquiries.md) | `inquiries` | 10 | +| [mileage.md](mileage.md) | `mileage` | 3 | +| [mileage-transactions.md](mileage-transactions.md) | `mileage-transactions` | 5 | +| [options.md](options.md) | `options` | 3 | +| [orders.md](orders.md) | `orders` | 22 | +| [payments.md](payments.md) | `payments` | 1 | +| [presets.md](presets.md) | `presets` | 4 | +| [product-common-infos.md](product-common-infos.md) | `product-common-infos` | 6 | +| [product-image.md](product-image.md) | `product-image` | 1 | +| [product-labels.md](product-labels.md) | `product-labels` | 6 | +| [product-notice-templates.md](product-notice-templates.md) | `product-notice-templates` | 7 | +| [products.md](products.md) | `products` | 30 | +| [promotion-coupons.md](promotion-coupons.md) | `promotion-coupons` | 9 | +| [review-image.md](review-image.md) | `review-image` | 1 | +| [reviews.md](reviews.md) | `reviews` | 12 | +| [settings.md](settings.md) | `settings` | 10 | +| [shipping-carriers.md](shipping-carriers.md) | `shipping-carriers` | 7 | +| [shipping-country.md](shipping-country.md) | `shipping-country` | 2 | +| [shipping-policies.md](shipping-policies.md) | `shipping-policies` | 11 | +| [users.md](users.md) | `users` | 2 | +| [wishlist.md](wishlist.md) | `wishlist` | 3 | + + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/addresses.md b/modules/_bundled/sirsoft-ecommerce/docs/api/addresses.md new file mode 100644 index 00000000..dd125472 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/addresses.md @@ -0,0 +1,555 @@ +# Addresses API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Addresses 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-ecommerce/user/addresses + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.addresses.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\UserAddressController@index` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/user/addresses HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| addresses | object | `{"data":[{"id":263,"user_id":"a1e0a91a-fba6-491c-a53e-728…` | 회원 본인 소유 배송지 컬렉션 (`data[]` 배송지 항목 배열 + `abilities.can_create` — UserAddressCollection 파생) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "배송지 목록을 조회했습니다.", + "data": { + "addresses": { + "data": [ + { + "id": 1, + "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 배송지", + "recipient_name": "원진수", + "recipient_phone": "010-4988-7051", + "country_code": "KR", + "country_name": { + "ko": "한국", + "en": "South Korea" + }, + "zipcode": "27058", + "address": "충청남도 남양주시 테헤란로 6114", + "address_detail": "Quidem natus iste.", + "address_line_1": null, + "address_line_2": null, + "city": null, + "state": null, + "postal_code": null, + "is_default": false, + "is_domestic": true, + "is_international": false, + "full_address": "충청남도 남양주시 테헤란로 6114 Quidem natus iste.", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_update": true, + "can_delete": true, + "can_set_default": true + } + } + ], + "abilities": { + "can_create": true + } + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** 로그인한 회원 본인의 배송지 목록을 조회합니다. `auth:sanctum` 인증이 필요하며, `UserAddressService::getUserAddresses()`가 현재 사용자(`Auth::id()`) 소유의 배송지를 조회해 `UserAddressCollection`으로 반환합니다. 마이페이지 배송지 관리 화면이나 주문 시 배송지 선택 목록을 채우는 용도이며, 다른 회원의 배송지는 노출되지 않습니다. + + +### POST /api/modules/sirsoft-ecommerce/user/addresses + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.addresses.store` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\UserAddressController@store` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| name | body | string | 예 | max 100 | 대상의 이름/명칭 | +| recipient_name | body | string | 예 | max 50 | 수령인 이름 | +| recipient_phone | body | string | 예 | max 20 | 수령인 연락처 | +| country_code | body | string | 아니오 | — | 국가 코드 (ISO 3166-1 alpha-2) | +| zipcode | body | string | 아니오 | max 10 | 우편번호 | +| province_code | body | string | 아니오 | max 10 | 광역 시·도 코드 (국내 주소 지역 구분) | +| city | body | string | 아니오 | max 100 | 시·군·구 등 도시명 | +| address | body | string | 아니오 | max 255 | 기본 주소 | +| address_detail | body | string | 아니오 | max 255 | 상세 주소 | +| address_type_code | body | string | 아니오 | `R`, `J` | 국내 주소 표기 방식 (`R` 도로명 / `J` 지번) | +| address_line_1 | body | string | 아니오 | max 255 | 주소 1행 (기본 주소) | +| address_line_2 | body | string | 아니오 | max 255 | 주소 2행 (상세 주소) | +| intl_city | body | string | 아니오 | max 100 | 도시 (국제 주소) | +| intl_state | body | string | 아니오 | max 100 | 주/도 (국제 주소) | +| intl_postal_code | body | string | 아니오 | max 20 | 우편번호 (국제 주소) | +| is_default | body | boolean | 아니오 | — | 기본값 지정 여부 | +| force_overwrite | body | boolean | 아니오 | — | 동일 배송지명 존재 시 기존 항목 덮어쓰기 허용 (미지정 시 중복이면 409) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.user_address.store_validation_rules`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/user/addresses HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": "예시 이름", + "recipient_name": "예시 이름", + "recipient_phone": "010-1234-5678", + "country_code": "KR", + "zipcode": "06234", + "province_code": "예시값", + "city": "예시값", + "address": "서울특별시 강남구 테헤란로 1", + "address_detail": "서울특별시 강남구 테헤란로 1", + "address_type_code": "R", + "address_line_1": "서울특별시 강남구 테헤란로 1", + "address_line_2": "서울특별시 강남구 테헤란로 1", + "intl_city": "예시값", + "intl_state": "예시값", + "intl_postal_code": "06234", + "is_default": true, + "force_overwrite": true +} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| address | object | `{"id":9,"user_id":"a234c2b1-cde8-437f-b28b-23323be2b98d",…` | 기본주소 (국내) | + +**응답 예시** + +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "배송지가 추가되었습니다.", + "data": { + "address": { + "id": 9, + "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "실측 예시값", + "recipient_name": "실측 예시값", + "recipient_phone": "실측 예시값", + "country_code": "KR", + "country_name": { + "ko": "한국", + "en": "South Korea" + }, + "zipcode": "실측 예시값", + "address": "실측 예시값", + "address_detail": "실측 예시값", + "address_line_1": null, + "address_line_2": null, + "city": "실측 예시값", + "state": null, + "postal_code": null, + "is_default": true, + "is_domestic": true, + "is_international": false, + "full_address": "실측 예시값 실측 예시값", + "created_at": "2026-07-08 15:00:36", + "updated_at": "2026-07-08 15:00:36", + "abilities": { + "can_update": true, + "can_delete": false, + "can_set_default": false + } + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 로그인한 회원 본인의 새 배송지를 등록합니다. `auth:sanctum` 인증이 필요하며, `UserAddressService::createAddress()`가 검증된 요청에 현재 사용자 ID를 결합해 배송지를 생성하고 성공 시 `201`로 반환합니다. 국내(우편번호/도로명·지번)·해외(intl_* 필드) 주소를 모두 지원하고 `is_default`로 기본 배송지 지정이 가능합니다. 같은 이름의 배송지가 있으면 `409`(중복 ID 포함)를, `force_overwrite`로 덮어쓰기를 허용할 수 있으며, 최대 배송지 개수를 초과하면 `422`를 반환합니다. + + +### DELETE /api/modules/sirsoft-ecommerce/user/addresses/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.addresses.destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\UserAddressController@destroy` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/user/addresses/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "배송지가 삭제되었습니다.", + "data": null +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 로그인한 회원 본인의 배송지 1건을 삭제합니다. `auth:sanctum` 인증이 필요하며, `UserAddressService::deleteAddress()`가 현재 사용자 소유 여부를 확인한 뒤 path의 `{id}` 배송지를 삭제합니다. 본인 소유가 아니거나 존재하지 않는 배송지이면 `404`를 반환합니다. 마이페이지 배송지 관리에서 더 이상 사용하지 않는 배송지를 제거하는 용도입니다. + + +### GET /api/modules/sirsoft-ecommerce/user/addresses/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.addresses.show` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\UserAddressController@show` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/user/addresses/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| address | object | `{"id":1,"user_id":"a234c2b1-cde8-437f-b28b-23323be2b98d",…` | 기본주소 (국내) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "배송지 정보를 조회했습니다.", + "data": { + "address": { + "id": 1, + "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 배송지", + "recipient_name": "원진수", + "recipient_phone": "010-4988-7051", + "country_code": "KR", + "country_name": { + "ko": "한국", + "en": "South Korea" + }, + "zipcode": "27058", + "address": "충청남도 남양주시 테헤란로 6114", + "address_detail": "Quidem natus iste.", + "address_line_1": null, + "address_line_2": null, + "city": null, + "state": null, + "postal_code": null, + "is_default": false, + "is_domestic": true, + "is_international": false, + "full_address": "충청남도 남양주시 테헤란로 6114 Quidem natus iste.", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_update": true, + "can_delete": true, + "can_set_default": true + } + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 로그인한 회원 본인의 배송지 1건 상세를 조회합니다. `auth:sanctum` 인증이 필요하며, `UserAddressService::getAddress()`가 현재 사용자 소유의 path `{id}` 배송지를 조회해 `UserAddressResource`로 반환합니다. 본인 소유가 아니거나 존재하지 않는 배송지이면 `404`를 반환합니다. 배송지 수정 화면 진입 시 기존 값을 불러오는 용도입니다. + + +### PUT /api/modules/sirsoft-ecommerce/user/addresses/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.addresses.update` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\UserAddressController@update` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| name | body | string | 아니오 | max 100 | 대상의 이름/명칭 | +| recipient_name | body | string | 아니오 | max 50 | 수령인 이름 | +| recipient_phone | body | string | 아니오 | max 20 | 수령인 연락처 | +| country_code | body | string | 아니오 | — | 국가 코드 (ISO 3166-1 alpha-2) | +| zipcode | body | string | 아니오 | max 10 | 우편번호 | +| province_code | body | string | 아니오 | max 10 | 광역 시·도 코드 (국내 주소 지역 구분) | +| city | body | string | 아니오 | max 100 | 시·군·구 등 도시명 | +| address | body | string | 아니오 | max 255 | 기본 주소 | +| address_detail | body | string | 아니오 | max 255 | 상세 주소 | +| address_type_code | body | string | 아니오 | `R`, `J` | 국내 주소 표기 방식 (`R` 도로명 / `J` 지번) | +| address_line_1 | body | string | 아니오 | max 255 | 주소 1행 (기본 주소) | +| address_line_2 | body | string | 아니오 | max 255 | 주소 2행 (상세 주소) | +| intl_city | body | string | 아니오 | max 100 | 도시 (국제 주소) | +| intl_state | body | string | 아니오 | max 100 | 주/도 (국제 주소) | +| intl_postal_code | body | string | 아니오 | max 20 | 우편번호 (국제 주소) | +| is_default | body | boolean | 아니오 | — | 기본값 지정 여부 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.user_address.update_validation_rules`). + +**요청 예시** + +```http +PUT /api/modules/sirsoft-ecommerce/user/addresses/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": "예시 이름", + "recipient_name": "예시 이름", + "recipient_phone": "010-1234-5678", + "country_code": "KR", + "zipcode": "06234", + "province_code": "예시값", + "city": "예시값", + "address": "서울특별시 강남구 테헤란로 1", + "address_detail": "서울특별시 강남구 테헤란로 1", + "address_type_code": "R", + "address_line_1": "서울특별시 강남구 테헤란로 1", + "address_line_2": "서울특별시 강남구 테헤란로 1", + "intl_city": "예시값", + "intl_state": "예시값", + "intl_postal_code": "06234", + "is_default": true +} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| address | object | `{"id":1,"user_id":"a234c2b1-cde8-437f-b28b-23323be2b98d",…` | 기본주소 (국내) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "배송지가 수정되었습니다.", + "data": { + "address": { + "id": 1, + "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "실측 예시값", + "recipient_name": "실측 예시값", + "recipient_phone": "실측 예시값", + "country_code": "KR", + "country_name": { + "ko": "한국", + "en": "South Korea" + }, + "zipcode": "실측 예시값", + "address": "실측 예시값", + "address_detail": "실측 예시값", + "address_line_1": null, + "address_line_2": null, + "city": "실측 예시값", + "state": null, + "postal_code": null, + "is_default": true, + "is_domestic": true, + "is_international": false, + "full_address": "실측 예시값 실측 예시값", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:00:36", + "abilities": { + "can_update": true, + "can_delete": false, + "can_set_default": false + } + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 로그인한 회원 본인의 배송지 1건을 수정합니다. `auth:sanctum` 인증이 필요하며, `UserAddressService::updateAddress()`가 현재 사용자 소유의 path `{id}` 배송지를 검증된 값으로 갱신하고 `UserAddressResource`로 반환합니다. 모든 본문 필드는 선택이며 전달된 필드만 갱신되고, 국내·해외 주소 필드와 `is_default`(기본 배송지 지정)를 모두 지원합니다. 본인 소유가 아니거나 존재하지 않는 배송지이면 `404`를 반환합니다. + + +### PATCH /api/modules/sirsoft-ecommerce/user/addresses/{id}/default + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.addresses.set-default` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\UserAddressController@setDefault` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/user/addresses/1/default HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "기본 배송지로 설정되었습니다.", + "data": null +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 로그인한 회원 본인의 배송지 1건을 기본 배송지로 지정합니다. `auth:sanctum` 인증이 필요하며, `UserAddressService::setDefaultAddress()`가 현재 사용자 소유의 path `{id}` 배송지를 기본으로 설정하고 기존 기본 배송지는 자동 해제됩니다. 본인 소유가 아니거나 존재하지 않는 배송지이면 `404`를 반환합니다. 마이페이지 배송지 목록에서 기본 배송지를 전환하는 용도입니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/brands.md b/modules/_bundled/sirsoft-ecommerce/docs/api/brands.md new file mode 100644 index 00000000..d26992e3 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/brands.md @@ -0,0 +1,486 @@ +# Brands API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Brands 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-ecommerce/admin/brands + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.brands.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\BrandController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.brands.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| is_active | query | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| search | query | string | 아니오 | max 100 | 검색어 (지정한 검색 대상 필드에서 부분 일치) | +| sort | query | string | 아니오 | `name_asc`, `name_desc`, `created_asc`, `created_desc` | 정렬 기준 (필드명, `-` 접두 시 내림차순) | +| sort_by | query | string | 아니오 | `name`, `sort_order` | 정렬 기준 필드명 | +| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) | +| locale | query | string | 아니오 | `ko`, `en`, `fr`, `ja` | 로케일 코드 (표시 언어/지역) | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/brands?is_active=1&search=%EC%98%88%EC%8B%9C%EA%B0%92&sort=name_asc&sort_by=name&sort_order=asc&locale=ko HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| number | integer | `43` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| id | integer | `127` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 브랜드","en":"API Doc Sample Brand"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 브랜드` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| slug | string | `apidoc-sample-brand` | URL 친화 식별자 (slug) | +| url | string | `apidoc-sample-brand` | SortableMenuItem 표시용 URL (slug 값을 그대로 노출) | +| website | string | `https://www.asus.com` | 브랜드 공식 웹사이트 URL | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| is_active | boolean | `true` | active 여부 | +| icon | string | `tag` | 아이콘 식별자 (아이콘 클래스/이름) | +| created_at | string | `2026-07-07 14:47:31` | 생성 일시 | +| updated_at | string | `2026-07-07 14:47:31` | 최종 수정 일시 | +| creator | array | `[]` | 생성자 정보 객체 (uuid/name/email — creator 관계 파생) | +| updater | array | `[]` | 수정자 정보 객체 (id/name — updater 관계 파생, 로드 시에만 포함) | +| products_count | integer | `0` | products 개수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "브랜드 목록을 조회했습니다.", + "data": { + "data": [ + { + "number": 1, + "id": 1, + "name": { + "ko": "API 문서 샘플 브랜드", + "en": "API Doc Sample Brand" + }, + "localized_name": "API 문서 샘플 브랜드", + "slug": "apidoc-sample-brand", + "url": "apidoc-sample-brand", + "website": null, + "sort_order": 0, + "is_active": true, + "icon": "tag", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "creator": [], + "updater": [], + "products_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } + ], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.brands.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자용 브랜드 목록을 조회합니다. `auth:sanctum` + `sirsoft-ecommerce.brands.read` 권한이 필요하며, `BrandService::getAllBrands()`가 검증된 필터를 받아 조회합니다. `is_active`·`search`로 필터링하고 `sort`(name_asc/desc, created_asc/desc) 또는 `sort_by`+`sort_order` 조합으로 정렬하며, `locale`로 표시 언어를 지정할 수 있습니다. 각 항목에는 `products_count` 집계와 현재 사용자의 `abilities` 맵이 포함됩니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/brands + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.brands.store` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\BrandController@store` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.brands.create` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| name | body | array | 예 | — | 대상의 이름/명칭 | +| slug | body | string | 예 | max 200 | URL 친화 식별자 (slug) | +| website | body | string | 아니오 | max 500 | 브랜드 공식 웹사이트 URL | +| sort_order | body | integer | 아니오 | min 0 | 표시 정렬 순서 값 (작을수록 우선) | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.brand.create_validation_rules`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/brands HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": [ + "예시 이름" + ], + "slug": "example-key", + "website": "예시값", + "sort_order": 1, + "is_active": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.brands.create`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 새 브랜드를 생성합니다. `auth:sanctum` + `sirsoft-ecommerce.brands.create` 권한이 필요하며, `BrandService::createBrand()`가 트랜잭션 내에서 저장하고 생성자/수정자(`created_by`/`updated_by`)를 현재 사용자로 기록한 뒤 `BrandResource`를 201로 반환합니다. `name`(다국어 배열)과 `slug`가 필수이며 `website`·`sort_order`·`is_active`는 선택입니다. `sirsoft-ecommerce.brand.create_validation_rules` 필터로 확장이 검증 규칙을 추가할 수 있습니다. + + +### DELETE /api/modules/sirsoft-ecommerce/admin/brands/{brand} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.brands.destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\BrandController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.brands.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| brand | path | string | 예 | — | 대상 brand의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/admin/brands/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| brand_id | integer | `1` | brand 식별자 (연관 리소스 참조) | +| products_count | integer | `0` | products 개수 (집계) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "브랜드가 삭제되었습니다.", + "data": { + "brand_id": 1, + "products_count": 0 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.brands.delete`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 브랜드 1건을 삭제합니다. `auth:sanctum` + `sirsoft-ecommerce.brands.delete` 권한이 필요하며, `BrandService::deleteBrand()`가 삭제 전 연결된 상품 수를 확인합니다. 연결된 상품이 1개 이상이면 예외가 발생해 삭제가 차단되고 400 오류가 반환되므로, 해당 브랜드의 상품을 먼저 정리하거나 다른 브랜드로 이전해야 삭제할 수 있습니다. 대상이 없으면 404를 반환합니다. + + +### PUT /api/modules/sirsoft-ecommerce/admin/brands/{brand} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.brands.update` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\BrandController@update` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.brands.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| brand | path | string | 예 | — | 대상 brand의 식별자 | +| name | body | array | 예 | — | 대상의 이름/명칭 | +| slug | body | string | 아니오 | — | URL 친화 식별자 (slug) | +| website | body | string | 아니오 | max 500 | 브랜드 공식 웹사이트 URL | +| sort_order | body | integer | 아니오 | min 0 | 표시 정렬 순서 값 (작을수록 우선) | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.brand.update_validation_rules`). + +**요청 예시** + +```http +PUT /api/modules/sirsoft-ecommerce/admin/brands/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": [ + "예시 이름" + ], + "slug": "example-key", + "website": "예시값", + "sort_order": 1, + "is_active": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.brands.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 기존 브랜드(path의 `brand`)를 수정합니다. `auth:sanctum` + `sirsoft-ecommerce.brands.update` 권한이 필요하며, `BrandService::updateBrand()`가 검증된 데이터로 갱신한 뒤 `BrandResource`를 반환합니다. `name`은 필수이고 `slug`·`website`·`sort_order`·`is_active`는 선택입니다. 대상이 없거나 처리 중 예외가 발생하면 404 또는 400을 반환하며, `sirsoft-ecommerce.brand.update_validation_rules` 필터로 확장이 검증 규칙을 추가할 수 있습니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/brands/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.brands.show` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\BrandController@show` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.brands.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/brands/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 브랜드","en":"API Doc Sample Brand"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 브랜드` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| slug | string | `apidoc-sample-brand` | URL 친화 식별자 (slug) | +| url | string | `apidoc-sample-brand` | SortableMenuItem 표시용 URL (slug 값을 그대로 노출) | +| website | null | `null` | 브랜드 웹사이트 URL | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| is_active | boolean | `true` | active 여부 | +| icon | string | `tag` | 아이콘 식별자 (아이콘 클래스/이름) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | +| products_count | integer | `0` | products 개수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "브랜드 정보를 조회했습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 브랜드", + "en": "API Doc Sample Brand" + }, + "localized_name": "API 문서 샘플 브랜드", + "slug": "apidoc-sample-brand", + "url": "apidoc-sample-brand", + "website": null, + "sort_order": 0, + "is_active": true, + "icon": "tag", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "products_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.brands.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 브랜드 1건의 상세 정보를 조회합니다. `auth:sanctum` + `sirsoft-ecommerce.brands.read` 권한이 필요하며, path의 `id`로 `BrandService::getBrand()`를 호출해 단건을 `BrandResource`로 반환합니다. 응답에는 다국어 이름, 로컬라이즈된 이름(`localized_name`), `website`, `products_count` 집계, 현재 사용자의 `abilities` 맵이 포함됩니다. 대상 브랜드가 없으면 404를 반환합니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/brands/{id}/toggle-status + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.brands.toggle-status` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\BrandController@toggleStatus` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.brands.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/brands/1/toggle-status HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 브랜드","en":"API Doc Sample Brand"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 브랜드` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| slug | string | `apidoc-sample-brand` | URL 친화 식별자 (slug) | +| url | string | `apidoc-sample-brand` | SortableMenuItem 표시용 URL (slug 값을 그대로 노출) | +| website | null | `null` | 브랜드 웹사이트 URL | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| is_active | boolean | `false` | active 여부 | +| icon | string | `tag` | 아이콘 식별자 (아이콘 클래스/이름) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 15:00:16` | 최종 수정 일시 | +| products_count | integer | `0` | products 개수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.brands.status_changed", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 브랜드", + "en": "API Doc Sample Brand" + }, + "localized_name": "API 문서 샘플 브랜드", + "slug": "apidoc-sample-brand", + "url": "apidoc-sample-brand", + "website": null, + "sort_order": 0, + "is_active": false, + "icon": "tag", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:00:16", + "products_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.brands.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 브랜드의 활성 상태를 토글합니다. `auth:sanctum` + `sirsoft-ecommerce.brands.update` 권한이 필요하며, path의 `id`로 `BrandService::toggleStatus()`를 호출해 트랜잭션 내에서 현재 `is_active` 값을 반전시킨 뒤 갱신된 `BrandResource`를 반환합니다. 관리자 목록에서 브랜드 노출/비노출을 빠르게 전환할 때 사용하며, 대상이 없거나 처리 중 예외가 발생하면 404 또는 400을 반환합니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/cart.md b/modules/_bundled/sirsoft-ecommerce/docs/api/cart.md new file mode 100644 index 00000000..bbfd7d86 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/cart.md @@ -0,0 +1,705 @@ +# Cart API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Cart 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### DELETE /api/modules/sirsoft-ecommerce/cart + +- **라우트명**: `api.modules.sirsoft-ecommerce.cart.destroy-multiple` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CartController@destroyMultiple` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| ids | query | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.cart.delete_items_validation_rules`). + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/cart?ids=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 장바구니에서 선택한 여러 아이템(`ids`)을 한 번에 삭제합니다. `optional.sanctum`으로 회원은 `Auth::id()`, 비회원은 cart_key(`X-Cart-Key`)로 소유를 식별하며, `CartController@destroyMultiple`이 `CartService::deleteItems()`를 호출해 삭제된 건수(`deleted_count`)를 반환합니다. 장바구니 화면에서 체크박스로 선택한 항목들을 "선택 삭제"할 때 사용합니다. + + +### GET /api/modules/sirsoft-ecommerce/cart + +- **라우트명**: `api.modules.sirsoft-ecommerce.cart.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CartController@index` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| selected_ids | query | array | 아니오 | — | selected 식별자 배열 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.cart.get_validation_rules`). + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/cart?selected_ids=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| items | array | `[{"id":962,"product_id":201,"product_option_id":1086,"qua…` | 장바구니 라인 아이템 목록 (상품·옵션·수량 등 — CartItemResource 파생) | +| item_ids | array | `[962]` | item 식별자 배열 (연관 리소스 참조) | +| item_count | integer | `1` | item 개수 (집계) | +| calculation | object | `{"items":[{"product_id":201,"product_option_id":1086,"pro…` | 선택 아이템 기준 금액 계산 결과 (상품 소계·할인·배송비 등 — OrderCalculationResult 파생) | +| has_unshippable_items | boolean | `false` | unshippable items 여부 | +| selected_shipping_country | string | `KR` | 배송비 계산에 적용된 배송 국가 코드 (ResolveShippingCountry 해석 결과) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "장바구니 목록을 조회했습니다.", + "data": { + "items": [], + "item_ids": [], + "item_count": 0, + "calculation": { + "items": [], + "summary": { + "subtotal": 0, + "subtotal_formatted": "0원", + "product_coupon_discount": 0, + "product_coupon_discount_formatted": "0원", + "code_discount": 0, + "code_discount_formatted": "0원", + "order_coupon_discount": 0, + "order_coupon_discount_formatted": "0원", + "total_coupon_discount": 0, + "total_coupon_discount_formatted": "0원", + "total_discount": 0, + "discount_formatted": "0원", + "base_shipping_total": 0, + "extra_shipping_total": 0, + "total_shipping": 0, + "total_shipping_fee": 0, + "shipping_fee_formatted": "0원", + "shipping_discount": 0, + "shipping_discount_formatted": "0원", + "taxable_amount": 0, + "tax_free_amount": 0, + "points_earning": 0, + "total_mileage": 0, + "mileage_formatted": "0P", + "points_used": 0, + "points_used_formatted": "0원", + "payment_amount": 0, + "payment_amount_formatted": "0원", + "final_amount": 0, + "final_amount_formatted": "0원", + "coupon_discount": 0, + "coupon_discount_formatted": "0원", + "order_discount": 0, + "order_discount_formatted": "0원" + }, + "promotions": { + "coupon_issue_ids": [], + "item_coupons": [], + "discount_code": null, + "product_promotions": { + "coupons": [], + "discount_codes": [], + "events": [] + }, + "order_promotions": { + "coupons": [], + "discount_codes": [], + "events": [] + } + }, + "validation_errors": [] + }, + "has_unshippable_items": false, + "selected_shipping_country": "KR" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 장바구니 목록과 함께 가격 정보(소계·할인·배송비 등 `calculation`)를 계산해 반환합니다. `optional.sanctum`으로 회원은 `Auth::id()`, 비회원은 cart_key(`X-Cart-Key`)로 장바구니를 식별하며, `CartController@index`가 `CartService::getCartWithCalculation()`을 호출합니다. `selected_ids`를 전달하면 해당 아이템만 계산에 포함되고(미전달=전체, 빈 배열=계산 생략), 선택된 배송 국가로 배송 불가한 상품이 있으면 `has_unshippable_items`가 true가 됩니다. 비회원인데 cart_key가 없거나 형식(`ck_`+32자)이 틀리면 400을 반환합니다. + + +### POST /api/modules/sirsoft-ecommerce/cart + +- **라우트명**: `api.modules.sirsoft-ecommerce.cart.store` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CartController@store` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| product_id | body | integer | 예 | — | product 식별자 | +| items | body | array | 예 | min 1 | 처리 대상 항목 배열 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.cart.bulk_add_validation_rules`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/cart HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +Content-Type: application/json + +{ + "product_id": 1, + "items": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 하나의 상품에 대해 단일 또는 여러 옵션 조합을 `items[]` 배열로 한 번에 장바구니에 담습니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CartController@store`가 `CartService::bulkAddToCart()`를 호출한 뒤 추가된 아이템 목록과 총 담긴 수량(`cart_count`)을 201로 반환합니다. 재고 부족·판매 중지·구매 대상 제한·구매 수량 한도 위반은 사유별 422(cart_unavailable/purchase_not_allowed), 항목/권한/옵션 문제는 404/403/422로 매핑됩니다. 비회원은 cart_key(`X-Cart-Key`) 검증을 통과해야 합니다. + + +### DELETE /api/modules/sirsoft-ecommerce/cart/all + +- **라우트명**: `api.modules.sirsoft-ecommerce.cart.destroy-all` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CartController@destroyAll` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/cart/all HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted_count | integer | `0` | deleted 개수 (집계) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "장바구니가 비워졌습니다. 0개 상품이 삭제되었습니다.", + "data": { + "deleted_count": 0 + } +} +``` + +**에러 응답** + +_대표 에러 없음 (공개 조회). _ + + + +**설명** 현재 회원/비회원 장바구니의 모든 아이템을 삭제해 장바구니를 비웁니다. `optional.sanctum`으로 회원은 `Auth::id()`, 비회원은 cart_key로 대상을 식별하며, `CartController@destroyAll`이 `CartService::deleteAll()`을 호출해 삭제된 건수(`deleted_count`)를 반환합니다. 장바구니 화면의 "전체 비우기" 동작에 사용합니다. + + +### GET /api/modules/sirsoft-ecommerce/cart/count + +- **라우트명**: `api.modules.sirsoft-ecommerce.cart.count` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CartController@count` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| selected_ids | query | array | 아니오 | — | selected 식별자 배열 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.cart.get_validation_rules`). + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/cart/count?selected_ids=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| count | integer | `1` | 장바구니에 담긴 아이템 개수 (`selected_ids` 지정 시 해당 항목만 집계) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "장바구니 수량을 조회했습니다.", + "data": { + "count": 0 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 장바구니에 담긴 아이템 개수(`count`)만 가볍게 조회합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CartController@count`가 `CartService::getItemCount()`를 호출합니다. 전체 목록·계산 결과가 필요 없는 헤더의 장바구니 배지 카운트 갱신 등에 사용하며, `selected_ids`로 특정 아이템만 집계할 수도 있습니다. + + +### POST /api/modules/sirsoft-ecommerce/cart/key + +- **라우트명**: `api.modules.sirsoft-ecommerce.cart.key` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CartController@issueCartKey` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/cart/key HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| cart_key | string | `ck_QBQT80EINHHXLO8HYHntMpmUZrPujPka` | 비회원 장바구니 식별 키 (`ck_` + 32자 영숫자. 이후 요청의 `X-Cart-Key` 헤더에 실어 자신의 장바구니를 식별) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "장바구니 키가 발급되었습니다.", + "data": { + "cart_key": "ck_QBQT80EINHHXLO8HYHntMpmUZrPujPka" + } +} +``` + +**에러 응답** + +_대표 에러 없음 (공개 조회). _ + + + +**설명** 비회원 장바구니를 식별하기 위한 cart_key(`ck_`+32자 영숫자)를 발급합니다. 인증이 필요 없으며(`optional.sanctum`), `CartController@issueCartKey`가 `CartService::issueCartKey()`로 키를 생성해 반환합니다. 비회원은 이 키를 `X-Cart-Key` 헤더에 실어 이후 장바구니 담기·조회·수정 요청에서 자신의 장바구니를 식별합니다. 비회원 쇼핑 시작 시점(첫 장바구니 담기 전)에 한 번 호출해 클라이언트에 저장해 둡니다. + + +### POST /api/modules/sirsoft-ecommerce/cart/merge + +- **라우트명**: `api.modules.sirsoft-ecommerce.cart.merge` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CartController@merge` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/cart/merge HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +_대표 에러 없음 (공개 조회). _ + + + +**설명** 비회원 상태에서 담아 둔 장바구니를 로그인한 회원 계정으로 병합합니다. 로그인 직후 클라이언트가 보유한 cart_key(`X-Cart-Key`)를 실어 호출하면, `CartController@merge`가 `CartService::mergeGuestCartToUser()`로 해당 cart_key의 비회원 아이템을 회원 장바구니로 옮기고 병합된 건수(`merged_count`)를 반환합니다. 비회원으로 담던 상품이 로그인 후 사라지지 않도록 인증 성공 시점에 1회 호출합니다. + + +### POST /api/modules/sirsoft-ecommerce/cart/query + +- **라우트명**: `api.modules.sirsoft-ecommerce.cart.query` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CartController@index` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| selected_ids | body | array | 아니오 | — | selected 식별자 배열 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.cart.get_validation_rules`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/cart/query HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +Content-Type: application/json + +{ + "selected_ids": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| items | array | `[]` | 장바구니 라인 아이템 목록 (상품·옵션·수량 등 — CartItemResource 파생) | +| item_ids | array | `[]` | item 식별자 배열 (연관 리소스 참조) | +| item_count | integer | `0` | item 개수 (집계) | +| calculation | object | `{"items":[],"summary":{"subtotal":0,"subtotal_formatted":…` | 선택 아이템 기준 금액 계산 결과 (상품 소계·할인·배송비 등 — OrderCalculationResult 파생) | +| has_unshippable_items | boolean | `false` | unshippable items 여부 | +| selected_shipping_country | string | `KR` | 배송비 계산에 적용된 배송 국가 코드 (ResolveShippingCountry 해석 결과) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "장바구니 목록을 조회했습니다.", + "data": { + "items": [], + "item_ids": [], + "item_count": 0, + "calculation": { + "items": [], + "summary": { + "subtotal": 0, + "subtotal_formatted": "0원", + "product_coupon_discount": 0, + "product_coupon_discount_formatted": "0원", + "code_discount": 0, + "code_discount_formatted": "0원", + "order_coupon_discount": 0, + "order_coupon_discount_formatted": "0원", + "total_coupon_discount": 0, + "total_coupon_discount_formatted": "0원", + "total_discount": 0, + "discount_formatted": "0원", + "base_shipping_total": 0, + "extra_shipping_total": 0, + "total_shipping": 0, + "total_shipping_fee": 0, + "shipping_fee_formatted": "0원", + "shipping_discount": 0, + "shipping_discount_formatted": "0원", + "taxable_amount": 0, + "tax_free_amount": 0, + "points_earning": 0, + "total_mileage": 0, + "mileage_formatted": "0P", + "points_used": 0, + "points_used_formatted": "0원", + "payment_amount": 0, + "payment_amount_formatted": "0원", + "final_amount": 0, + "final_amount_formatted": "0원", + "coupon_discount": 0, + "coupon_discount_formatted": "0원", + "order_discount": 0, + "order_discount_formatted": "0원" + }, + "promotions": { + "coupon_issue_ids": [], + "item_coupons": [], + "discount_code": null, + "product_promotions": { + "coupons": [], + "discount_codes": [], + "events": [] + }, + "order_promotions": { + "coupons": [], + "discount_codes": [], + "events": [] + } + }, + "validation_errors": [] + }, + "has_unshippable_items": false, + "selected_shipping_country": "KR" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** GET `/cart`와 동일하게 장바구니 목록과 가격 계산 결과를 반환하되, `selected_ids`를 GET 쿼리 대신 POST 본문으로 전달하는 변형 엔드포인트입니다(같은 `CartController@index` 처리). 선택 아이템 배열이 커서 URL 길이 제한이 우려되는 경우에 사용합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, 응답 구조(`items`·`calculation`·`has_unshippable_items` 등)는 GET 조회와 동일합니다. + + +### DELETE /api/modules/sirsoft-ecommerce/cart/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.cart.destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CartController@destroy` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/cart/{id} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 장바구니에서 단일 아이템(`id`)을 삭제합니다. `optional.sanctum`으로 회원은 `Auth::id()`, 비회원은 cart_key(`X-Cart-Key`)로 소유를 식별하며, `CartController@destroy`가 `CartService::deleteItem()`을 호출합니다. 존재하지 않는 항목은 404, 타인 소유 항목 삭제 시도는 403(사유별 `CartOperationException` 매핑)으로 반환합니다. 장바구니 각 행의 개별 삭제 버튼에 사용합니다. + + +### PUT /api/modules/sirsoft-ecommerce/cart/{id}/option + +- **라우트명**: `api.modules.sirsoft-ecommerce.cart.change-option` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CartController@changeOption` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| product_option_id | body | integer | 예 | — | product option 식별자 | +| quantity | body | integer | 예 | min 1, max 9999 | 변경할 구매 수량 (1~9999) | +| additional_option_selections | body | array | 아니오 | — | 추가 옵션 재선택 목록 (항목별 additional_option_id/value_id, 직접입력 custom_text — 미전달 시 기존 선택 유지) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.cart.change_option_validation_rules`). + +**요청 예시** + +```http +PUT /api/modules/sirsoft-ecommerce/cart/{id}/option HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +Content-Type: application/json + +{ + "product_option_id": 1, + "quantity": 1, + "additional_option_selections": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 장바구니 아이템(`id`)의 선택 옵션과 수량을 변경합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CartController@changeOption`이 `CartService::changeOption()`으로 `product_option_id`·`quantity`(및 추가 옵션 선택)를 반영한 뒤 수정된 아이템을 반환합니다. 다른 상품의 옵션으로 바꾸려 하거나 옵션이 없는 경우, 재고/판매상태/구매수량 한도 위반은 사유별 422/404/403으로 매핑됩니다. 장바구니에서 옵션(예: 색상/사이즈)을 바꿀 때 사용합니다. + + +### PATCH /api/modules/sirsoft-ecommerce/cart/{id}/quantity + +- **라우트명**: `api.modules.sirsoft-ecommerce.cart.update-quantity` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CartController@updateQuantity` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| quantity | body | integer | 예 | min 1, max 9999 | 변경할 구매 수량 (1~9999) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.cart.update_quantity_validation_rules`). + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/cart/{id}/quantity HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +Content-Type: application/json + +{ + "quantity": 1 +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 장바구니 아이템(`id`)의 수량만 변경합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CartController@updateQuantity`가 `CartService::updateQuantity()`로 수량을 반영한 뒤 프론트가 별도 refetch 없이 화면을 갱신할 수 있도록 `index`와 동일한 전체 목록·계산 결과(`items`·`calculation`)를 함께 반환합니다. 수량은 1~9999 범위이며, 재고 부족·판매 중지·구매수량 한도 위반은 사유별 422, 항목/권한 문제는 404/403으로 매핑됩니다. 장바구니의 수량 증감(+/-) 컨트롤에 사용합니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/categories.md b/modules/_bundled/sirsoft-ecommerce/docs/api/categories.md new file mode 100644 index 00000000..098a31c3 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/categories.md @@ -0,0 +1,1091 @@ +# Categories API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Categories 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-ecommerce/admin/categories + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.categories.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CategoryController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.categories.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| parent_id | query | string | 아니오 | — | parent 식별자 | +| is_active | query | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| search | query | string | 아니오 | max 100 | 검색어 (지정한 검색 대상 필드에서 부분 일치) | +| hierarchical | query | boolean | 아니오 | — | true 면 자식을 중첩한 트리 구조로 반환 | +| flat | query | boolean | 아니오 | — | true 면 깊이 들여쓰기를 포함한 평면 리스트로 반환 (TagInput 등에 사용) | +| max_depth | query | integer | 아니오 | min 1, max 10 | 조회할 최대 계층 깊이 제한 (1~10) | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/categories?parent_id=%EC%98%88%EC%8B%9C%EA%B0%92&is_active=1&search=%EC%98%88%EC%8B%9C%EA%B0%92&hierarchical=1&flat=1&max_depth=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `87` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"의류","en":"Clothing"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| description | object | `{"ko":"다양한 스타일의 의류 제품","en":"Various styles of clothing p…` | 설명 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `의류` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | +| path | string | `87` | 조상부터 자기 자신까지의 ID를 `/`로 이은 materialized path (조상 조회·하위 일괄 선택에 사용) | +| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| is_active | boolean | `true` | active 여부 | +| slug | string | `clothing` | URL 친화 식별자 (slug) | +| url | string | `clothing` | SortableMenuItem 표시용 URL (slug 값을 그대로 사용) | +| icon | string | `folder` | 아이콘 식별자 (아이콘 클래스/이름) | +| meta_title | null | `null` | SEO 메타 제목 (검색엔진/소셜 공유 표시 제목, 미설정 시 null) | +| meta_description | null | `null` | SEO 메타 설명 (검색엔진/소셜 공유 표시 요약, 미설정 시 null) | +| created_at | string | `2026-06-15 02:24:00` | 생성 일시 | +| updated_at | string | `2026-06-15 02:24:00` | 최종 수정 일시 | +| images | array | `[]` | 카테고리 이미지 배열 (images 관계 로드 시 — id/hash/download_url/alt_text 등) | +| products_count | integer | `22` | products 개수 (집계) | +| children_count | integer | `0` | children 개수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "카테고리 목록을 조회했습니다.", + "data": { + "data": [ + { + "id": 1, + "name": { + "ko": "API 문서 샘플 카테고리", + "en": "API Doc Sample Category" + }, + "description": null, + "localized_name": "API 문서 샘플 카테고리", + "parent_id": null, + "path": "0", + "depth": 0, + "sort_order": 0, + "is_active": true, + "slug": "apidoc-sample-category", + "url": "apidoc-sample-category", + "icon": "folder", + "meta_title": null, + "meta_description": null, + "created_at": "2026-07-08 01:44:49", + "updated_at": "2026-07-08 01:44:49", + "images": [], + "products_count": 0, + "children_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } + ], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자용 카테고리 목록을 조회합니다. `auth:sanctum` + `sirsoft-ecommerce.categories.read` 권한이 필요하며, `CategoryService::getHierarchicalCategories()`가 검증된 필터(`parent_id`/`is_active`/`search`/`max_depth`)를 받아 조회합니다. `hierarchical=true`면 자식을 중첩한 트리, `flat=true`면 평면 리스트(TagInput 등에 사용), 둘 다 없으면 기본 계층 구조를 반환합니다. 각 항목에는 `products_count`·`children_count` 집계와 현재 사용자의 `abilities` 맵이 포함됩니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/categories + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.categories.store` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CategoryController@store` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.categories.create` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| name | body | array | 예 | — | 대상의 이름/명칭 | +| description | body | array | 아니오 | — | 설명 | +| parent_id | body | string | 아니오 | — | parent 식별자 | +| slug | body | string | 예 | max 200 | URL 친화 식별자 (slug) | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| meta_title | body | string | 아니오 | max 200 | SEO 메타 제목 (검색엔진/소셜 공유 표시 제목) | +| meta_description | body | string | 아니오 | — | SEO 메타 설명 (검색엔진/소셜 공유 표시 요약) | +| temp_key | body | string | 아니오 | max 64 | 저장 전 임시 업로드한 이미지를 이 카테고리에 연결하기 위한 FileUploader temp_key | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.category.create_validation_rules`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/categories HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": [ + "예시 이름" + ], + "description": [ + "예시 내용입니다." + ], + "parent_id": "예시값", + "slug": "example-key", + "is_active": true, + "meta_title": "예시 제목", + "meta_description": "예시 내용입니다.", + "temp_key": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.create`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 새 카테고리를 생성합니다. `auth:sanctum` + `sirsoft-ecommerce.categories.create` 권한이 필요하며, `CategoryService::createCategory()`가 검증된 데이터로 저장한 뒤 `CategoryResource`를 201로 반환합니다. `name`(다국어 배열)과 `slug`는 필수이고, `parent_id`를 지정하면 해당 카테고리의 하위로 배치되어 path/depth가 계산됩니다. `temp_key`로 사전 업로드해 둔 임시 이미지를 이 시점에 카테고리에 연결할 수 있으며, `sirsoft-ecommerce.category.create_validation_rules` 필터로 확장이 추가 파라미터를 검증에 주입할 수 있습니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/categories/images + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.categories.images.upload-temp` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CategoryController@uploadImage` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.categories.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| file | body | file | 예 | max 10240 | 업로드 파일 | +| temp_key | body | string | 아니오 | max 64 | 사전 업로드한 임시 이미지를 이 카테고리에 연결하기 위한 FileUploader temp_key | +| collection | body | string | 아니오 | max 100 | 첨부 컬렉션 그룹명 (첨부를 용도별로 묶는 키, 미지정 시 default) | +| alt_text | body | array | 아니오 | — | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.category-image.filter_upload_validation_rules`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/categories/images HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: multipart/form-data; boundary=----G7ExampleBoundary + +------G7ExampleBoundary +Content-Disposition: form-data; name="file"; filename="example.pdf" +Content-Type: application/octet-stream + +(바이너리 파일 내용) +------G7ExampleBoundary +Content-Disposition: form-data; name="temp_key" + +예시값 +------G7ExampleBoundary +Content-Disposition: form-data; name="collection" + +예시값 +------G7ExampleBoundary +Content-Disposition: form-data; name="alt_text" + +예시값 +------G7ExampleBoundary-- +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 카테고리에 아직 귀속되지 않은 이미지를 임시로 업로드합니다. `auth:sanctum` + `sirsoft-ecommerce.categories.update` 권한이 필요하며, path에 `categoryId`가 없으므로 `CategoryImageService::upload()`가 `temp_key` 기준의 임시 이미지로 저장합니다. 카테고리 생성/수정 폼에서 저장 전에 이미지를 먼저 올릴 때 사용하며, 이후 store/update 요청에 같은 `temp_key`를 전달하면 해당 카테고리에 연결됩니다. 응답은 FileUploader 컴포넌트가 기대하는 `data.data` 형식으로 업로드 이미지의 id/hash/download_url 등을 201로 반환합니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/categories/images/reorder + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.categories.images.reorder` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CategoryController@reorderImages` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.categories.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| order | body | array | 예 | min 1 | 이미지 순서 배열 (각 항목 `{id, order}` — 이미지 id별 새 정렬 순서) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.category-image.filter_reorder_validation_rules`). + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/categories/images/reorder HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "order": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 카테고리 이미지들의 표시 순서를 일괄 변경합니다. `auth:sanctum` + `sirsoft-ecommerce.categories.update` 권한이 필요하며, `order` 배열(각 항목 `{id, order}`)을 받아 컨트롤러가 `id => order` 맵으로 변환한 뒤 `CategoryImageService::reorder()`에 전달합니다. 여러 이미지를 등록한 카테고리에서 드래그로 순서를 재배열할 때 사용하며, `sirsoft-ecommerce.category-image.filter_reorder_validation_rules` 필터로 확장이 검증 규칙을 추가할 수 있습니다. + + +### DELETE /api/modules/sirsoft-ecommerce/admin/categories/images/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.categories.images.delete` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CategoryController@deleteImage` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.categories.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/admin/categories/images/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 카테고리 이미지 1건을 삭제합니다. `auth:sanctum` + `sirsoft-ecommerce.categories.update` 권한이 필요하며, path의 이미지 `id`로 `CategoryImageService::delete()`를 호출해 레코드와 저장 파일을 제거합니다. 대상 이미지가 존재하지 않으면 404를 반환합니다. 카테고리 편집 화면에서 등록된 이미지나 임시 업로드 이미지를 개별 제거할 때 사용합니다. + + +### PUT /api/modules/sirsoft-ecommerce/admin/categories/order + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.categories.reorder` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CategoryController@reorder` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.categories.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| parent_menus | body | array | 아니오 | — | 최상위 카테고리 순서 배열 (SortableMenuList — 각 항목 `{id, order}`, child_menus 없으면 필수) | +| child_menus | body | array | 아니오 | — | 부모 ID별 자식 카테고리 순서 맵 (`{부모id: [{id, order}, ...]}`, parent_menus 없으면 필수) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.category.reorder_validation_rules`). + +**요청 예시** + +```http +PUT /api/modules/sirsoft-ecommerce/admin/categories/order HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "parent_menus": [ + "예시값" + ], + "child_menus": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 카테고리 트리 전체의 배치(부모-자식 관계와 정렬 순서)를 일괄 갱신합니다. `auth:sanctum` + `sirsoft-ecommerce.categories.update` 권한이 필요하며, SortableMenuList 컴포넌트가 보내는 `parent_menus`/`child_menus` 형식을 컨트롤러가 `{id, parent_id, sort_order}` 목록으로 변환해 `CategoryService::reorder()`(트랜잭션)에 전달합니다. `parent_id`가 바뀐 항목은 depth와 materialized path가 함께 재계산됩니다. 관리자 카테고리 관리 화면에서 드래그 앤 드롭으로 계층 구조를 재정렬할 때 사용합니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/categories/tree + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.categories.tree` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CategoryController@tree` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.categories.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/categories/tree HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `87` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"의류","en":"Clothing"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| description | object | `{"ko":"다양한 스타일의 의류 제품","en":"Various styles of clothing p…` | 설명 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `의류` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | +| path | string | `87` | 조상부터 자기 자신까지의 ID를 `/`로 이은 materialized path (조상 조회·하위 일괄 선택에 사용) | +| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| is_active | boolean | `true` | active 여부 | +| slug | string | `clothing` | URL 친화 식별자 (slug) | +| url | string | `clothing` | SortableMenuItem 표시용 URL (slug 값을 그대로 사용) | +| icon | string | `folder` | 아이콘 식별자 (아이콘 클래스/이름) | +| meta_title | null | `null` | SEO 메타 제목 (검색엔진/소셜 공유 표시 제목, 미설정 시 null) | +| meta_description | null | `null` | SEO 메타 설명 (검색엔진/소셜 공유 표시 요약, 미설정 시 null) | +| created_at | string | `2026-06-15 02:24:00` | 생성 일시 | +| updated_at | string | `2026-06-15 02:24:00` | 최종 수정 일시 | +| parent | null | `null` | 상위 항목 객체 (parent 관계 파생) | +| children | array | `[{"id":88,"name":{"ko":"남성","en":"Men"},"description":{"k…` | 하위 항목 배열 (계층 트리 — children 관계 파생) | +| images | array | `[]` | 카테고리 이미지 배열 (images 관계 로드 시 — id/hash/download_url/alt_text 등) | +| products_count | integer | `22` | products 개수 (집계) | +| children_count | integer | `2` | children 개수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "카테고리 목록을 조회했습니다.", + "data": { + "data": [ + { + "id": 1, + "name": { + "ko": "API 문서 샘플 카테고리", + "en": "API Doc Sample Category" + }, + "description": null, + "localized_name": "API 문서 샘플 카테고리", + "parent_id": null, + "path": "0", + "depth": 0, + "sort_order": 0, + "is_active": true, + "slug": "apidoc-sample-category", + "url": "apidoc-sample-category", + "icon": "folder", + "meta_title": null, + "meta_description": null, + "created_at": "2026-07-08 01:44:49", + "updated_at": "2026-07-08 01:44:49", + "parent": null, + "children": [], + "images": [], + "products_count": 0, + "children_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } + ], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.read`)이 없는 경우 | + + + +**설명** 상품 등록 폼용 카테고리 트리를 조회합니다. `auth:sanctum` + `sirsoft-ecommerce.categories.read` 권한이 필요하며, 별도 파라미터 없이 `CategoryService::getHierarchicalCategories(['hierarchical' => true, 'is_active' => true])`를 호출해 활성 카테고리만 자식을 중첩한 트리로 반환합니다. index 엔드포인트와 달리 필터를 받지 않고 항상 활성 트리를 반환하므로, 상품 작성/수정 시 카테고리 선택 UI를 채우는 용도로 사용됩니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/categories/{categoryId}/images + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.categories.images.upload` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CategoryController@uploadImage` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.categories.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| categoryId | path | string | 예 | — | 대상 category의 식별자 | +| file | body | file | 예 | max 10240 | 업로드 파일 | +| temp_key | body | string | 아니오 | max 64 | 사전 업로드한 임시 이미지를 이 카테고리에 연결하기 위한 FileUploader temp_key | +| collection | body | string | 아니오 | max 100 | 첨부 컬렉션 그룹명 (첨부를 용도별로 묶는 키, 미지정 시 default) | +| alt_text | body | array | 아니오 | — | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.category-image.filter_upload_validation_rules`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/categories/{categoryId}/images HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: multipart/form-data; boundary=----G7ExampleBoundary + +------G7ExampleBoundary +Content-Disposition: form-data; name="file"; filename="example.pdf" +Content-Type: application/octet-stream + +(바이너리 파일 내용) +------G7ExampleBoundary +Content-Disposition: form-data; name="temp_key" + +예시값 +------G7ExampleBoundary +Content-Disposition: form-data; name="collection" + +예시값 +------G7ExampleBoundary +Content-Disposition: form-data; name="alt_text" + +예시값 +------G7ExampleBoundary-- +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 특정 카테고리(path의 `categoryId`)에 이미지 1건을 업로드해 즉시 귀속시킵니다. `auth:sanctum` + `sirsoft-ecommerce.categories.update` 권한이 필요하며, `CategoryImageService::upload()`가 `categoryId`와 함께 파일을 저장하므로 임시 업로드와 달리 해당 카테고리에 바로 연결됩니다. 대상 카테고리가 없으면 404를 반환하고, 응답은 FileUploader가 기대하는 `data.data` 형식으로 업로드 이미지 정보를 201로 반환합니다. 이미 존재하는 카테고리를 편집하며 이미지를 추가할 때 사용합니다. + + +### DELETE /api/modules/sirsoft-ecommerce/admin/categories/{category} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.categories.destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CategoryController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.categories.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| category | path | string | 예 | — | 분류 필터 (해당 분류의 항목만 조회) | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/admin/categories/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| category_id | integer | `1` | category 식별자 (연관 리소스 참조) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "카테고리가 삭제되었습니다.", + "data": { + "category_id": 1 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.delete`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 카테고리 1건을 삭제합니다. `auth:sanctum` + `sirsoft-ecommerce.categories.delete` 권한이 필요하며, `CategoryService::deleteCategory()`가 삭제 전 안전 검사를 수행합니다. 하위 카테고리가 있거나 연결된 상품이 존재하면 예외가 발생해 삭제가 차단되고 400 오류가 반환되므로, 자식과 상품을 먼저 정리해야 삭제할 수 있습니다. 대상이 없으면 404를 반환합니다. + + +### PUT /api/modules/sirsoft-ecommerce/admin/categories/{category} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.categories.update` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CategoryController@update` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.categories.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| category | path | string | 예 | — | 분류 필터 (해당 분류의 항목만 조회) | +| name | body | array | 예 | — | 대상의 이름/명칭 | +| description | body | array | 아니오 | — | 설명 | +| parent_id | body | string | 아니오 | — | parent 식별자 | +| slug | body | string | 예 | max 200 | URL 친화 식별자 (slug) | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| meta_title | body | string | 아니오 | max 200 | SEO 메타 제목 (검색엔진/소셜 공유 표시 제목) | +| meta_description | body | string | 아니오 | — | SEO 메타 설명 (검색엔진/소셜 공유 표시 요약) | +| temp_key | body | string | 아니오 | max 64 | 저장 전 임시 업로드한 이미지를 이 카테고리에 연결하기 위한 FileUploader temp_key | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.category.update_validation_rules`). + +**요청 예시** + +```http +PUT /api/modules/sirsoft-ecommerce/admin/categories/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": [ + "예시 이름" + ], + "description": [ + "예시 내용입니다." + ], + "parent_id": "예시값", + "slug": "example-key", + "is_active": true, + "meta_title": "예시 제목", + "meta_description": "예시 내용입니다.", + "temp_key": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 기존 카테고리(path의 `category`)를 수정합니다. `auth:sanctum` + `sirsoft-ecommerce.categories.update` 권한이 필요하며, `CategoryService::updateCategory()`가 검증된 데이터로 갱신한 뒤 `CategoryResource`를 반환합니다. `name`과 `slug`는 필수이고, `parent_id`를 변경하면 계층 위치(path/depth)가 재계산됩니다. `temp_key`로 임시 업로드한 이미지를 이 시점에 연결할 수 있으며, 대상이 없거나 처리 중 예외가 발생하면 404 또는 400을 반환합니다. `sirsoft-ecommerce.category.update_validation_rules` 필터로 확장이 검증 규칙을 추가할 수 있습니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/categories/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.categories.show` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CategoryController@show` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.categories.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/categories/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 카테고리","en":"API Doc Sample Category"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| description | null | `null` | 설명 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 카테고리` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | +| path | string | `0` | Materialized Path: 1/5/23 | +| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| is_active | boolean | `true` | active 여부 | +| slug | string | `apidoc-sample-category` | URL 친화 식별자 (slug) | +| url | string | `apidoc-sample-category` | SortableMenuItem 표시용 URL (slug 값을 그대로 사용) | +| icon | string | `folder` | 아이콘 식별자 (아이콘 클래스/이름) | +| meta_title | null | `null` | SEO 제목 (다국어 JSON) | +| meta_description | null | `null` | SEO 설명 (다국어 JSON) | +| created_at | string | `2026-07-08 01:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 01:44:49` | 최종 수정 일시 | +| parent | null | `null` | 상위 항목 객체 (parent 관계 파생) | +| children | array | `[]` | 하위 항목 배열 (계층 트리 — children 관계 파생) | +| images | array | `[]` | 카테고리 이미지 배열 (images 관계 로드 시 — id/hash/download_url/alt_text 등) | +| products_count | integer | `0` | products 개수 (집계) | +| children_count | integer | `0` | children 개수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "카테고리 정보를 조회했습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 카테고리", + "en": "API Doc Sample Category" + }, + "description": null, + "localized_name": "API 문서 샘플 카테고리", + "parent_id": null, + "path": "0", + "depth": 0, + "sort_order": 0, + "is_active": true, + "slug": "apidoc-sample-category", + "url": "apidoc-sample-category", + "icon": "folder", + "meta_title": null, + "meta_description": null, + "created_at": "2026-07-08 01:44:49", + "updated_at": "2026-07-08 01:44:49", + "parent": null, + "children": [], + "images": [], + "products_count": 0, + "children_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 카테고리 1건의 상세 정보를 조회합니다. `auth:sanctum` + `sirsoft-ecommerce.categories.read` 권한이 필요하며, path의 `id`로 `CategoryService::getCategory()`를 호출해 단건을 `CategoryResource`로 반환합니다. 응답에는 부모(`parent`)·자식(`children`)·이미지(`images`) 관계와 `products_count`·`children_count` 집계, 현재 사용자의 `abilities` 맵이 포함됩니다. 대상 카테고리가 없으면 404를 반환합니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/categories/{id}/toggle-status + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.categories.toggle-status` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CategoryController@toggleStatus` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.categories.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/categories/1/toggle-status HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 카테고리","en":"API Doc Sample Category"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| description | null | `null` | 설명 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 카테고리` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | +| path | string | `0` | Materialized Path: 1/5/23 | +| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| is_active | boolean | `false` | active 여부 | +| slug | string | `apidoc-sample-category` | URL 친화 식별자 (slug) | +| url | string | `apidoc-sample-category` | SortableMenuItem 표시용 URL (slug 값을 그대로 사용) | +| icon | string | `folder` | 아이콘 식별자 (아이콘 클래스/이름) | +| meta_title | null | `null` | SEO 제목 (다국어 JSON) | +| meta_description | null | `null` | SEO 설명 (다국어 JSON) | +| created_at | string | `2026-07-08 01:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 06:00:17` | 최종 수정 일시 | +| images | array | `[]` | 카테고리 이미지 배열 (images 관계 로드 시 — id/hash/download_url/alt_text 등) | +| products_count | integer | `0` | products 개수 (집계) | +| children_count | integer | `0` | children 개수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.categories.status_changed", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 카테고리", + "en": "API Doc Sample Category" + }, + "description": null, + "localized_name": "API 문서 샘플 카테고리", + "parent_id": null, + "path": "0", + "depth": 0, + "sort_order": 0, + "is_active": false, + "slug": "apidoc-sample-category", + "url": "apidoc-sample-category", + "icon": "folder", + "meta_title": null, + "meta_description": null, + "created_at": "2026-07-08 01:44:49", + "updated_at": "2026-07-08 06:00:17", + "images": [], + "products_count": 0, + "children_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 카테고리의 활성 상태를 토글합니다. `auth:sanctum` + `sirsoft-ecommerce.categories.update` 권한이 필요하며, path의 `id`로 `CategoryService::toggleStatus()`를 호출해 현재 `is_active` 값을 반전시킨 뒤 갱신된 `CategoryResource`를 반환합니다. 관리자 목록에서 노출/비노출을 빠르게 전환할 때 사용하며, 대상이 없거나 처리 중 예외가 발생하면 404 또는 400을 반환합니다. + + +### GET /api/modules/sirsoft-ecommerce/categories + +- **라우트명**: `api.modules.sirsoft-ecommerce.categories.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CategoryController@index` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/categories HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `87` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"의류","en":"Clothing"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name_localized | string | `의류` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| slug | string | `clothing` | URL 친화 식별자 (slug) | +| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | +| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | +| products_count | integer | `22` | products 개수 (집계) | +| children | array | `[{"id":88,"name":{"ko":"남성","en":"Men"},"name_localized":…` | 하위 항목 배열 (계층 트리 — children 관계 파생) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "카테고리 목록을 조회했습니다.", + "data": [ + { + "id": 1, + "name": { + "ko": "API 문서 샘플 카테고리", + "en": "API Doc Sample Category" + }, + "name_localized": "API 문서 샘플 카테고리", + "slug": "apidoc-sample-category", + "depth": 0, + "parent_id": null, + "products_count": 0, + "children": [] + } + ] +} +``` + +**에러 응답** + +_대표 에러 없음 (공개 조회). _ + + + +**설명** 공개 카테고리 트리를 조회합니다. 인증이 필요 없는 공개 엔드포인트이며, `Public\CategoryController@index`가 `CategoryService::getPublicCategoryTree()`를 호출해 활성 카테고리만 자식을 중첩한 트리로 반환합니다. 각 항목에는 로컬라이즈된 이름(`name_localized`)과 공개 상품 수(`products_count`)가 포함됩니다. 스토어프론트의 카테고리 내비게이션/메뉴를 렌더링하는 데 사용하며, 조회 실패 시 500을 반환합니다. + + +### GET /api/modules/sirsoft-ecommerce/categories/{slug} + +- **라우트명**: `api.modules.sirsoft-ecommerce.categories.show` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CategoryController@show` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/categories/apidoc-sample-category HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 카테고리","en":"API Doc Sample Category"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name_localized | string | `API 문서 샘플 카테고리` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| description | null | `null` | 설명 (다국어 필드는 로케일별 값 객체) | +| description_localized | null | `null` | `description` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| slug | string | `apidoc-sample-category` | URL 친화 식별자 (slug) | +| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | +| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | +| products_count | integer | `0` | products 개수 (집계) | +| breadcrumb | array | `[{"id":1,"name":"API 문서 샘플 카테고리","slug":"apidoc-sample-ca…` | 최상위부터 현재 카테고리까지의 상위 경로 배열 (각 항목에 id·현지화 name·slug — 스토어프론트 breadcrumb 표시용) | +| images | array | `[]` | 카테고리 이미지 배열 (images 관계 로드 시 — id/hash/download_url/alt_text 등) | +| children | array | `[]` | 하위 항목 배열 (계층 트리 — children 관계 파생) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.categories.fetch_success", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 카테고리", + "en": "API Doc Sample Category" + }, + "name_localized": "API 문서 샘플 카테고리", + "description": null, + "description_localized": null, + "slug": "apidoc-sample-category", + "depth": 0, + "parent_id": null, + "products_count": 0, + "breadcrumb": [ + { + "id": 1, + "name": "API 문서 샘플 카테고리", + "slug": "apidoc-sample-category" + } + ], + "images": [], + "children": [] + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** slug로 단일 공개 카테고리와 직계 자식을 조회합니다. 인증이 필요 없는 공개 엔드포인트이며, `Public\CategoryController@show`가 `CategoryService::getPublicCategoryBySlug()`를 호출해 활성 자식(`activeChildren`)과 이미지를 함께 로드합니다. 조회된 카테고리가 비활성(`is_active=false`)이면 없는 것으로 간주해 404를 반환하며, 응답에는 상위 경로를 나타내는 `breadcrumb` 배열과 `products_count` 집계가 포함됩니다. 스토어프론트 카테고리 상세/목록 페이지 진입 시 사용합니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/category-image.md b/modules/_bundled/sirsoft-ecommerce/docs/api/category-image.md new file mode 100644 index 00000000..32468e36 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/category-image.md @@ -0,0 +1,58 @@ +# Category Image API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Category Image 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-ecommerce/category-image/{hash} + +- **라우트명**: `api.modules.sirsoft-ecommerce.category-image.download` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CategoryImageController@download` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| hash | path | string | 예 | — | 대상 리소스의 해시 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/category-image/{hash} HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 공개 API로, 해시(`hash`)로 식별되는 카테고리 이미지 원본 파일을 스트리밍 서빙합니다. 인증이 필요 없으며(`PublicBaseController`), `CategoryImageController@download`가 `CategoryImageService::download()`를 호출해 리포지토리에서 해시로 이미지를 찾고 `StorageInterface::response()`로 `StreamedResponse`를 반환합니다. 응답에는 저장된 `mime_type`과 `Cache-Control: public, max-age=31536000`(1년) 헤더가 부여되어 브라우저/CDN 캐싱에 최적화됩니다. 해시에 해당하는 레코드가 없거나 스토리지에 실제 파일이 없으면 404를, 그 외 처리 오류 시 400 에러 응답을 반환합니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md b/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md new file mode 100644 index 00000000..93186507 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md @@ -0,0 +1,255 @@ +# Checkout API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Checkout 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### DELETE /api/modules/sirsoft-ecommerce/checkout + +- **라우트명**: `api.modules.sirsoft-ecommerce.checkout.destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CheckoutController@destroy` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/checkout HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +_대표 에러 없음 (공개 조회). _ + + + +**설명** 주문서 페이지를 이탈할 때 현재 회원/비회원의 임시 주문(temp order)을 삭제합니다. `optional.sanctum`으로 회원은 `Auth::id()`, 비회원은 cart_key(`X-Cart-Key`)로 대상을 식별하며, `CheckoutController@destroy`가 `TempOrderService::deleteTempOrder()`를 호출합니다. 삭제할 임시 주문이 없으면 404를 반환합니다. 주문 확정 없이 주문서에서 뒤로가기·페이지 이탈 시 미완료 임시 데이터를 정리하는 용도입니다. + + +### GET /api/modules/sirsoft-ecommerce/checkout + +- **라우트명**: `api.modules.sirsoft-ecommerce.checkout.show` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CheckoutController@show` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| country_code | query | string | 아니오 | — | 국가 코드 (ISO 3166-1 alpha-2) | +| zipcode | query | string | 아니오 | max 20 | 우편번호 | +| region | query | string | 아니오 | max 100 | 지역/권역 | +| city | query | string | 아니오 | max 100 | 도시명 (배송비 미리보기 산출용 배송 주소) | +| address | query | string | 아니오 | max 255 | 기본 주소 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/checkout?country_code=KR&zipcode=06234®ion=%EC%98%88%EC%8B%9C%EA%B0%92&city=%EC%98%88%EC%8B%9C%EA%B0%92&address=%EC%84%9C%EC%9A%B8%ED%8A%B9%EB%B3%84%EC%8B%9C%20%EA%B0%95%EB%82%A8%EA%B5%AC%20%ED%85%8C%ED%97%A4%EB%9E%80%EB%A1%9C%201 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 현재 유효한 임시 주문을 조회하면서 최신 가격으로 실시간 재계산해 주문서 페이지 데이터를 반환합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CheckoutController@show`가 `TempOrderService::getTempOrderWithCalculation()`으로 재계산하고 `CheckoutDataService::buildResponseData()`가 쿠폰·마일리지·상품·구매불가 상품 정보를 포함해 응답을 구성합니다. 쿼리로 `country_code`/`zipcode`/`region` 등 배송 주소를 전달하면 해당 주소 기준 배송비가 계산되며, 우편번호 없이 배송국가만으로도 미리보기 배송비를 산출합니다. 임시 주문이 만료·미존재면 404를 반환합니다. + + +### POST /api/modules/sirsoft-ecommerce/checkout + +- **라우트명**: `api.modules.sirsoft-ecommerce.checkout.store` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CheckoutController@store` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| item_ids | body | array | 아니오 | min 1 | item 식별자 배열 | +| direct_items | body | array | 아니오 | min 1 | 바로 구매 항목 배열 (장바구니 미경유 — 항목별 product_id/option_values/quantity, item_ids와 택일) | +| coupon_issue_ids | body | array | 아니오 | — | coupon issue 식별자 배열 | +| use_points | body | integer | 아니오 | min 0 | 사용할 마일리지(적립금) 포인트 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.checkout.validation_rules`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/checkout HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +Content-Type: application/json + +{ + "item_ids": [ + "예시값" + ], + "direct_items": [ + "예시값" + ], + "coupon_issue_ids": [ + "예시값" + ], + "use_points": 1 +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 장바구니에서 선택한 아이템으로 임시 주문을 생성해 주문서 작성 단계로 진입합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CheckoutController@store`가 `direct_items`가 있으면 `TempOrderService::createTempOrderFromDirectItems()`(바로 구매, 장바구니 미경유), 없으면 `item_ids`로 `createTempOrderFromSelectedItems()`(장바구니 경유)를 호출합니다. 응답에는 임시 주문 ID·계산 결과·만료 시각(`expires_at`)이 포함됩니다. 재고 부족·판매 중지·구매 제한 상품이 있으면 400(cart_unavailable), 보유 잔액을 넘는 마일리지 사용은 422, 빈 장바구니는 400을 반환합니다. + + +### PUT /api/modules/sirsoft-ecommerce/checkout + +- **라우트명**: `api.modules.sirsoft-ecommerce.checkout.update` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CheckoutController@update` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| item_coupons | body | array | 아니오 | — | 상품별 적용 쿠폰 맵 (상품 옵션 ID를 키로, 발급 쿠폰 ID 배열을 값으로 — 상품당 최대 2개) | +| order_coupon_issue_id | body | integer | 아니오 | — | order coupon issue 식별자 | +| shipping_coupon_issue_id | body | integer | 아니오 | — | shipping coupon issue 식별자 | +| use_points | body | integer | 아니오 | min 0 | 사용할 마일리지(적립금) 포인트 | +| zipcode | body | string | 아니오 | max 10 | 우편번호 | +| country_code | body | string | 아니오 | — | 국가 코드 (ISO 3166-1 alpha-2) | +| payment_method | body | string | 아니오 | max 50 | 결제 수단 코드 (결제수단별 할인/수수료 계산 확장용) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.checkout.update_validation_rules`). + +**요청 예시** + +```http +PUT /api/modules/sirsoft-ecommerce/checkout HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +Content-Type: application/json + +{ + "item_coupons": [ + "예시값" + ], + "order_coupon_issue_id": 1, + "shipping_coupon_issue_id": 1, + "use_points": 1, + "zipcode": "06234", + "country_code": "KR", + "payment_method": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 주문서 작성 중 쿠폰·마일리지·배송 주소가 변경될 때 임시 주문 금액을 재계산합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CheckoutController@update`가 전송된 프로모션 필드(`item_coupons`·`order_coupon_issue_id`·`shipping_coupon_issue_id`)와 `use_points`만 반영하고 미전송 필드는 `TempOrderService::updateTempOrder()`에서 기존 값을 유지합니다. `zipcode`/`country_code`로 배송 주소를 함께 넘기면 배송비가 다시 계산됩니다. 임시 주문이 만료·미존재면 404, 보유 잔액 초과 마일리지는 422를 반환합니다. + + +### POST /api/modules/sirsoft-ecommerce/checkout/extend + +- **라우트명**: `api.modules.sirsoft-ecommerce.checkout.extend` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CheckoutController@extend` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/checkout/extend HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +_대표 에러 없음 (공개 조회). _ + + + +**설명** 임시 주문의 만료 시각을 연장합니다. `optional.sanctum`으로 회원은 `Auth::id()`, 비회원은 cart_key로 대상을 식별하며, `CheckoutController@extend`가 `TempOrderService::extendExpiration()`을 호출해 갱신된 `expires_at`을 반환합니다. 주문서 작성이 길어져 임시 주문이 만료되기 전에 세션을 연장하는 용도이며, 연장할 임시 주문이 이미 만료·미존재면 404를 반환합니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/claim-reasons.md b/modules/_bundled/sirsoft-ecommerce/docs/api/claim-reasons.md new file mode 100644 index 00000000..c6568004 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/claim-reasons.md @@ -0,0 +1,759 @@ +# Claim Reasons API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Claim Reasons 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-ecommerce/admin/claim-reasons + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.claim-reasons.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ClaimReasonController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/claim-reasons HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| number | integer | `8` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| id | integer | `1` | 기본 키 (내부 식별자) | +| type | string | `refund` | 클래임 사유 유형 (ClaimReasonTypeEnum — `refund`(환불/취소)) | +| code | string | `order_mistake` | 사유 식별 코드 (같은 type 내 고유, 영문 소문자/숫자/`_`) | +| name | object | `{"ko":"주문 실수","en":"Order Mistake","ja":"注文ミス"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `주문 실수` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| fault_type | string | `customer` | 귀책 구분 (ClaimReasonFaultTypeEnum — `customer`(고객)/`seller`(판매자)/`carrier`(배송사)) | +| fault_type_label | string | `고객 귀책` | `fault_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| is_user_selectable | boolean | `true` | user selectable 여부 | +| is_active | boolean | `true` | active 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-05-27 15:20:43` | 생성 일시 | +| updated_at | string | `2026-06-27 00:49:51` | 최종 수정 일시 | +| creator | array | `[]` | 생성자 정보 객체 (uuid/name/email — creator 관계 파생) | +| updater | array | `[]` | 최종 수정자 정보 객체 (id/name — updater 관계 로드 시) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "클래임 사유 목록을 조회했습니다.", + "data": { + "data": [ + { + "number": 8, + "id": 1, + "type": "refund", + "code": "order_mistake", + "name": { + "ko": "주문 실수", + "en": "Order Mistake" + }, + "localized_name": "주문 실수", + "fault_type": "customer", + "fault_type_label": "고객 귀책", + "is_user_selectable": true, + "is_active": true, + "sort_order": 0, + "created_at": "2026-07-08 10:43:32", + "updated_at": "2026-07-08 10:43:32", + "creator": [], + "updater": [], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + }, + { + "number": 7, + "id": 8, + "type": "refund", + "code": "apidoc_sample", + "name": { + "ko": "API 문서 샘플 사유", + "en": "API Doc Sample Reason" + }, + "localized_name": "API 문서 샘플 사유", + "fault_type": "customer", + "fault_type_label": "고객 귀책", + "is_user_selectable": true, + "is_active": true, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "creator": [], + "updater": [], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + }, + "... (총 8건 중 2건 표시)" + ], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.read`)이 없는 경우 | + + + +**설명** 관리자가 클래임(반품/교환/환불) 사유 마스터 목록을 조회합니다. `permission:sirsoft-ecommerce.settings.read` 권한이 필요하며, `type`(기본 refund)·`is_active`·`fault_type`·`search` 쿼리로 필터링할 수 있습니다. `ClaimReasonService::getAllReasons()`가 조회하고 `ClaimReasonCollection`으로 반환하며, 각 항목은 다국어 사유명(`name`)과 귀책 구분(`fault_type`: customer·seller·carrier), 사용자 노출 여부(`is_user_selectable`)를 포함합니다. 환경설정의 클래임 사유 관리 화면에서 사용됩니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/claim-reasons + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.claim-reasons.store` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ClaimReasonController@store` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| type | body | string | 예 | — | 클래임 사유 유형 (ClaimReasonTypeEnum — 현재 `refund`(환불/취소)) | +| code | body | string | 예 | max 50 | 사유 식별 코드 (영문 소문자/숫자/`_`, 같은 type 내에서 고유) | +| name | body | array | 예 | — | 대상의 이름/명칭 | +| fault_type | body | string | 예 | — | 귀책 구분 (ClaimReasonFaultTypeEnum — `customer`(고객)/`seller`(판매자)/`carrier`(배송사)) | +| is_user_selectable | body | boolean | 아니오 | — | user selectable 여부 | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| sort_order | body | integer | 아니오 | min 0 | 표시 정렬 순서 값 (작을수록 우선) | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/claim-reasons HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "type": "예시값", + "code": "예시값", + "name": [ + "예시 이름" + ], + "fault_type": "예시값", + "is_user_selectable": true, + "is_active": true, + "sort_order": 1 +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 새 클래임 사유를 생성합니다. `permission:sirsoft-ecommerce.settings.update` 권한이 필요하며, 유형(`type`)·고유 코드(`code`)·다국어 사유명(`name`)·귀책 구분(`fault_type`)을 필수로 받고 사용자 노출 여부·활성 여부·정렬 순서를 선택 입력합니다. `ClaimReasonService::createReason()`이 저장하고 생성된 사유를 201로 반환합니다. `code` 는 유형 내에서 고유해야 하며 회원 취소/반품 화면의 사유 선택지로 활용됩니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/claim-reasons/active + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.claim-reasons.active` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ClaimReasonController@active` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/claim-reasons/active HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| number | integer | `8` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| id | integer | `1` | 기본 키 (내부 식별자) | +| type | string | `refund` | 클래임 사유 유형 (ClaimReasonTypeEnum — `refund`(환불/취소)) | +| code | string | `order_mistake` | 사유 식별 코드 (같은 type 내 고유, 영문 소문자/숫자/`_`) | +| name | object | `{"ko":"주문 실수","en":"Order Mistake","ja":"注文ミス"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `주문 실수` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| fault_type | string | `customer` | 귀책 구분 (ClaimReasonFaultTypeEnum — `customer`(고객)/`seller`(판매자)/`carrier`(배송사)) | +| fault_type_label | string | `고객 귀책` | `fault_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| is_user_selectable | boolean | `true` | user selectable 여부 | +| is_active | boolean | `true` | active 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-05-27 15:20:43` | 생성 일시 | +| updated_at | string | `2026-06-27 00:49:51` | 최종 수정 일시 | +| creator | array | `[]` | 생성자 정보 객체 (uuid/name/email — creator 관계 파생) | +| updater | array | `[]` | 최종 수정자 정보 객체 (id/name — updater 관계 로드 시) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "클래임 사유 목록을 조회했습니다.", + "data": { + "data": [ + { + "number": 8, + "id": 1, + "type": "refund", + "code": "order_mistake", + "name": { + "ko": "주문 실수", + "en": "Order Mistake" + }, + "localized_name": "주문 실수", + "fault_type": "customer", + "fault_type_label": "고객 귀책", + "is_user_selectable": true, + "is_active": true, + "sort_order": 0, + "created_at": "2026-07-08 10:43:32", + "updated_at": "2026-07-08 10:43:32", + "creator": [], + "updater": [], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + }, + { + "number": 7, + "id": 8, + "type": "refund", + "code": "apidoc_sample", + "name": { + "ko": "API 문서 샘플 사유", + "en": "API Doc Sample Reason" + }, + "localized_name": "API 문서 샘플 사유", + "fault_type": "customer", + "fault_type_label": "고객 귀책", + "is_user_selectable": true, + "is_active": true, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "creator": [], + "updater": [], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + }, + "... (총 8건 중 2건 표시)" + ], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.read`)이 없는 경우 | + + + +**설명** 활성화된 클래임 사유만 추려 Select 옵션용으로 조회합니다. `permission:sirsoft-ecommerce.settings.read` 권한이 필요하며, `type` 쿼리(기본 refund)로 유형을 지정하면 `ClaimReasonService::getActiveReasons()`가 `is_active=true` 인 사유만 반환합니다. 관리자 화면에서 환불/취소 처리 시 사유 드롭다운을 채우는 용도로, 목록(index)과 달리 필터 없이 활성 사유만 내려주는 점이 다릅니다. + + +### DELETE /api/modules/sirsoft-ecommerce/admin/claim-reasons/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.claim-reasons.destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ClaimReasonController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/admin/claim-reasons/8 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| reason_id | integer | `8` | reason 식별자 (연관 리소스 참조) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "클래임 사유가 삭제되었습니다.", + "data": { + "reason_id": 8 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 클래임 사유 1건을 삭제합니다. `permission:sirsoft-ecommerce.settings.update` 권한이 필요하며, 대상이 없으면 404 를 반환하고 존재하면 `ClaimReasonService::deleteReason()`이 삭제합니다. 이미 사용 중인 사유 등 삭제 불가 상황에서는 서비스가 던진 예외 메시지를 그대로 사용해 400 으로 응답하므로, 관리자에게 삭제 실패 사유가 노출됩니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/claim-reasons/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.claim-reasons.show` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ClaimReasonController@show` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/claim-reasons/8 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `8` | 기본 키 (내부 식별자) | +| type | string | `refund` | 사유 유형 (refund, exchange, return 등) | +| code | string | `apidoc_sample` | 고유 코드 (order_mistake 등) | +| name | object | `{"ko":"API 문서 샘플 사유","en":"API Doc Sample Reason"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 사유` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| fault_type | string | `customer` | 귀책 구분 (customer, seller, carrier) | +| fault_type_label | string | `고객 귀책` | `fault_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| is_user_selectable | boolean | `true` | user selectable 여부 | +| is_active | boolean | `true` | active 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "클래임 사유 정보를 조회했습니다.", + "data": { + "id": 8, + "type": "refund", + "code": "apidoc_sample", + "name": { + "ko": "API 문서 샘플 사유", + "en": "API Doc Sample Reason" + }, + "localized_name": "API 문서 샘플 사유", + "fault_type": "customer", + "fault_type_label": "고객 귀책", + "is_user_selectable": true, + "is_active": true, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 클래임 사유 1건의 상세 정보를 조회합니다. `permission:sirsoft-ecommerce.settings.read` 권한이 필요하며, `ClaimReasonService::getReason()`이 대상을 조회해 `ClaimReasonResource`로 반환합니다. 사유 편집 폼을 열 때 기존 값(다국어 사유명·코드·귀책 구분·활성/노출 설정 등)을 채우는 용도로 사용되며, 해당 사유가 없으면 404 를 반환합니다. + + +### PUT /api/modules/sirsoft-ecommerce/admin/claim-reasons/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.claim-reasons.update` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ClaimReasonController@update` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| type | body | string | 예 | — | 클래임 사유 유형 (ClaimReasonTypeEnum — 현재 `refund`(환불/취소)) | +| code | body | string | 예 | max 50 | 사유 식별 코드 (영문 소문자/숫자/`_`, 같은 type 내에서 고유) | +| name | body | array | 예 | — | 대상의 이름/명칭 | +| fault_type | body | string | 예 | — | 귀책 구분 (ClaimReasonFaultTypeEnum — `customer`(고객)/`seller`(판매자)/`carrier`(배송사)) | +| is_user_selectable | body | boolean | 아니오 | — | user selectable 여부 | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| sort_order | body | integer | 아니오 | min 0 | 표시 정렬 순서 값 (작을수록 우선) | + +**요청 예시** + +```http +PUT /api/modules/sirsoft-ecommerce/admin/claim-reasons/8 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "type": "예시값", + "code": "예시값", + "name": [ + "예시 이름" + ], + "fault_type": "예시값", + "is_user_selectable": true, + "is_active": true, + "sort_order": 1 +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 기존 클래임 사유를 수정합니다. `permission:sirsoft-ecommerce.settings.update` 권한이 필요하고 대상은 경로 `id` 로 지정하며, 생성과 동일한 필드(유형·코드·다국어 사유명·귀책 구분·노출/활성/정렬)를 받아 `ClaimReasonService::updateReason()`이 갱신하고 갱신된 사유를 반환합니다. 대상이 없거나 갱신 실패 시 400 오류로 응답합니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/claim-reasons/{id}/toggle-status + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.claim-reasons.toggle-status` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ClaimReasonController@toggleStatus` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/claim-reasons/8/toggle-status HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `8` | 기본 키 (내부 식별자) | +| type | string | `refund` | 사유 유형 (refund, exchange, return 등) | +| code | string | `apidoc_sample` | 고유 코드 (order_mistake 등) | +| name | object | `{"ko":"API 문서 샘플 사유","en":"API Doc Sample Reason"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 사유` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| fault_type | string | `customer` | 귀책 구분 (customer, seller, carrier) | +| fault_type_label | string | `고객 귀책` | `fault_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| is_user_selectable | boolean | `true` | user selectable 여부 | +| is_active | boolean | `false` | active 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 15:00:18` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "클래임 사유 상태가 변경되었습니다.", + "data": { + "id": 8, + "type": "refund", + "code": "apidoc_sample", + "name": { + "ko": "API 문서 샘플 사유", + "en": "API Doc Sample Reason" + }, + "localized_name": "API 문서 샘플 사유", + "fault_type": "customer", + "fault_type_label": "고객 귀책", + "is_user_selectable": true, + "is_active": false, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:00:18", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 클래임 사유의 활성 상태를 켜고 끄는 토글을 수행합니다. `permission:sirsoft-ecommerce.settings.update` 권한이 필요하며, `ClaimReasonService::toggleStatus()`가 대상 사유의 `is_active` 값을 반전시켜 저장하고 갱신된 사유를 반환합니다. 사유를 삭제하지 않고 일시적으로 회원 선택지에서 감추거나 다시 노출할 때 사용합니다. + + +### GET /api/modules/sirsoft-ecommerce/user/claim-reasons + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.claim-reasons.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ClaimReasonController@userSelectableReasons` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-ecommerce.user-orders.cancel` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/user/claim-reasons HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| number | integer | `7` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| id | integer | `1` | 기본 키 (내부 식별자) | +| type | string | `refund` | 클래임 사유 유형 (ClaimReasonTypeEnum — `refund`(환불/취소)) | +| code | string | `order_mistake` | 사유 식별 코드 (같은 type 내 고유, 영문 소문자/숫자/`_`) | +| name | object | `{"ko":"주문 실수","en":"Order Mistake","ja":"注文ミス"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `주문 실수` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| fault_type | string | `customer` | 귀책 구분 (ClaimReasonFaultTypeEnum — `customer`(고객)/`seller`(판매자)/`carrier`(배송사)) | +| fault_type_label | string | `고객 귀책` | `fault_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| is_user_selectable | boolean | `true` | user selectable 여부 | +| is_active | boolean | `true` | active 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-05-27 15:20:43` | 생성 일시 | +| updated_at | string | `2026-06-27 00:49:51` | 최종 수정 일시 | +| creator | array | `[]` | 생성자 정보 객체 (uuid/name/email — creator 관계 파생) | +| updater | array | `[]` | 최종 수정자 정보 객체 (id/name — updater 관계 로드 시) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "클래임 사유 목록을 조회했습니다.", + "data": { + "data": [ + { + "number": 7, + "id": 1, + "type": "refund", + "code": "order_mistake", + "name": { + "ko": "주문 실수", + "en": "Order Mistake" + }, + "localized_name": "주문 실수", + "fault_type": "customer", + "fault_type_label": "고객 귀책", + "is_user_selectable": true, + "is_active": true, + "sort_order": 0, + "created_at": "2026-07-08 10:43:32", + "updated_at": "2026-07-08 10:43:32", + "creator": [], + "updater": [], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + }, + { + "number": 6, + "id": 8, + "type": "refund", + "code": "apidoc_sample", + "name": { + "ko": "API 문서 샘플 사유", + "en": "API Doc Sample Reason" + }, + "localized_name": "API 문서 샘플 사유", + "fault_type": "customer", + "fault_type_label": "고객 귀책", + "is_user_selectable": true, + "is_active": true, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "creator": [], + "updater": [], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + }, + "... (총 7건 중 2건 표시)" + ], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-orders.cancel`)이 없는 경우 | + + + +**설명** 회원(및 선택적으로 비회원)이 주문 취소/반품 신청 화면에서 선택할 수 있는 클래임 사유 목록을 조회합니다. `optional.sanctum`(회원/비회원 모두 접근)과 `permission:sirsoft-ecommerce.user-orders.cancel` 권한이 적용되며, `ClaimReasonService::getUserSelectableReasons()`가 활성이면서 `is_user_selectable=true` 인 사유만 반환합니다. 관리 전용 목록과 달리 사용자에게 공개 가능한 사유만 내려주는 사용자향 엔드포인트입니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/coupons.md b/modules/_bundled/sirsoft-ecommerce/docs/api/coupons.md new file mode 100644 index 00000000..e1abac71 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/coupons.md @@ -0,0 +1,370 @@ +# Coupons API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Coupons 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-ecommerce/user/coupons + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.coupons.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\UserCouponController@index` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| status | query | string | 아니오 | `available`, `used`, `expired` | 상태 필터 (해당 상태의 항목만 조회) | +| per_page | query | integer | 아니오 | min 1, max 50 | 페이지당 항목 수 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.coupon.user_list_validation_rules`). + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/user/coupons?status=available&per_page=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| coupons | object | `{"data":[{"id":8611,"coupon_id":156,"user_id":"a1e0a91a-f…` | 회원이 발급받은 쿠폰(발급 내역) 페이지네이션 객체 (`data[]` 발급 건 + `pagination` — CouponIssueCollection 직렬화, 쿠폰 정의가 아닌 회원별 발급 건) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "쿠폰 목록을 조회했습니다.", + "data": { + "coupons": { + "data": [], + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 0, + "from": null, + "to": null, + "has_more_pages": false + } + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 로그인한 회원이 마이페이지 쿠폰함에서 자신이 발급받은 쿠폰(발급 내역)을 페이지네이션으로 조회합니다. `auth:sanctum` 인증이 필요하며, `status` 필터(available·used·expired)로 사용 가능/사용 완료/만료 쿠폰을 구분합니다. `UserCouponService::getUserCoupons()`가 조회하고 `CouponIssueCollection`으로 직렬화되므로, 여기의 항목은 쿠폰 정의(마스터)가 아니라 회원별 발급 건입니다. + + +### GET /api/modules/sirsoft-ecommerce/user/coupons/available + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.coupons.available` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\UserCouponController@available` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| product_ids | query | array | 아니오 | — | product 식별자 배열 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.coupon.user_available_validation_rules`). + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/user/coupons/available?product_ids=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| coupons | array | `[{"id":4702,"coupon_id":106,"user_id":1,"coupon_code":nul…` | 현재 장바구니 상품에 적용 가능한 보유 쿠폰(발급 건) 배열 (상품/카테고리 범위·최소 주문금액·유효기간을 만족해 주문에 곧바로 선택 가능한 후보만) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "사용 가능한 쿠폰 목록을 조회했습니다.", + "data": { + "coupons": [] + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 체크아웃 화면에서 회원이 현재 장바구니 상품에 실제로 적용할 수 있는 보유 쿠폰만 추려서 반환합니다. `auth:sanctum` 인증이 필요하고 `product_ids` 로 대상 상품을 전달하면, `UserCouponService::getAvailableCoupons()`가 보유 쿠폰 중 상품/카테고리 적용 범위·최소 주문금액·유효기간 등을 만족하는 것만 필터링해 내려줍니다. 쿠폰함 목록(index)과 달리 주문에 곧바로 선택 가능한 후보만 반환하는 점이 다릅니다. + + +### GET /api/modules/sirsoft-ecommerce/user/coupons/downloadable + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.coupons.downloadable` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\UserCouponController@downloadable` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| per_page | query | integer | 아니오 | min 1, max 50 | 페이지당 항목 수 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.coupon.user_downloadable_validation_rules`). + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/user/coupons/downloadable?per_page=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `157` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 쿠폰","en":"API Doc Sample Coupon"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| description | object | `{"ko":"특정 카테고리 배송비 할인","en":"Shipping discount on categor…` | 설명 (다국어 필드는 로케일별 값 객체) | +| target_type | string | `order_amount` | 할인 적용 대상 (product_amount=상품금액, order_amount=주문금액, shipping_fee=배송비) | +| discount_type | string | `fixed` | 혜택 유형 (fixed=정액 할인, rate=정률(%) 할인) | +| discount_value | string | `1000.00` | 혜택값 (정액이면 할인 금액, 정률이면 할인율 %) | +| discount_max_amount | string | `3000.00` | 정률 할인 시 최대 할인 금액 상한 (없으면 null) | +| min_order_amount | string | `0.00` | 쿠폰 적용 최소 주문금액 (미만 주문에는 사용 불가) | +| issue_method | string | `download` | 발급 방법 (direct=직접발급, download=다운로드, auto=자동발급) | +| issue_condition | string | `manual` | 발급 조건 (manual=수동, signup=회원가입, first_purchase=첫구매, birthday=생일) | +| issue_status | string | `issuing` | 발급 상태 (issuing=발급중, stopped=발급중단) | +| total_quantity | integer | `300` | 총 발급 수량 상한 (null=무제한) | +| issued_count | integer | `0` | issued 개수 (집계) | +| per_user_limit | integer | `1` | 회원 1인당 발급 제한 수량 | +| valid_type | string | `period` | 유효기간 유형 (period=기간지정, days_from_issue=발급일로부터 N일) | +| valid_days | integer | `14` | 발급일로부터 유효한 일수 (valid_type=days_from_issue 인 경우) | +| valid_from | string | `2026-06-08T02:24:18.000000Z` | 유효기간 시작일 (쿠폰 사용 가능 시작 시각) | +| valid_to | string | `2026-08-07T02:24:18.000000Z` | 유효기간 종료일 (쿠폰 사용 가능 종료 시각) | +| issue_from | string | `2026-06-08T02:24:18.000000Z` | 발급기간 시작일 (다운로드 가능 시작 시각) | +| issue_to | string | `2026-07-15T02:24:18.000000Z` | 발급기간 종료일 (다운로드 가능 종료 시각) | +| is_combinable | boolean | `false` | combinable 여부 | +| target_scope | string | `all` | 적용 범위 (all=전체 상품, products=특정 상품, categories=특정 카테고리) | +| created_by | integer | `1` | 쿠폰 등록자(관리자) 식별자 (users 참조, 삭제 시 null) | +| created_at | string | `2026-07-07T05:47:31.000000Z` | 생성 일시 | +| updated_at | string | `2026-07-07T05:47:31.000000Z` | 최종 수정 일시 | +| deleted_at | null | `null` | 소프트 삭제 일시 (미삭제 시 null) | +| is_downloaded | boolean | `false` | downloaded 여부 | +| user_issued_count | integer | `0` | user issued 개수 (집계) | +| coupon_id | integer | `157` | coupon 식별자 (연관 리소스 참조) | +| localized_name | string | `API 문서 샘플 쿠폰` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| target_type_short_label | string | `주문` | `target_type_short` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| valid_period_formatted | string | `-` | `valid_period` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| min_order_amount_formatted | string | `0원` | `min_order_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| remaining_quantity | integer | `300` | 잔여 발급 가능 수량 (total_quantity − issued_count, 무제한이면 null) | +| benefit_formatted | string | `1,000원 할인` | `benefit` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| included_products | array | `[]` | 적용 대상 포함 상품 목록 (target_scope=products 시 이 상품에만 적용) | +| excluded_products | array | `[]` | 적용 제외 상품 목록 (해당 상품은 쿠폰 적용에서 제외) | +| included_categories | array | `[]` | 적용 대상 포함 카테고리 목록 (target_scope=categories 시 이 카테고리에만 적용) | +| excluded_categories | array | `[]` | 적용 제외 카테고리 목록 (해당 카테고리는 쿠폰 적용에서 제외) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "다운로드 가능한 쿠폰 목록을 불러왔습니다.", + "data": { + "current_page": 1, + "data": [ + { + "id": 1, + "name": { + "ko": "API 문서 샘플 쿠폰", + "en": "API Doc Sample Coupon" + }, + "description": null, + "target_type": "order_amount", + "discount_type": "fixed", + "discount_value": "1000.00", + "discount_max_amount": null, + "min_order_amount": "0.00", + "issue_method": "download", + "issue_condition": "manual", + "issue_status": "issuing", + "total_quantity": null, + "issued_count": 0, + "per_user_limit": 1, + "valid_type": "period", + "valid_days": null, + "valid_from": null, + "valid_to": null, + "issue_from": null, + "issue_to": null, + "is_combinable": false, + "target_scope": "all", + "created_by": null, + "created_at": "2026-07-08T01:44:49.000000Z", + "updated_at": "2026-07-08T01:44:49.000000Z", + "deleted_at": null, + "is_downloaded": false, + "user_issued_count": 0, + "coupon_id": 1, + "localized_name": "API 문서 샘플 쿠폰", + "target_type_short_label": "주문", + "valid_period_formatted": "-", + "min_order_amount_formatted": "0원", + "remaining_quantity": null, + "benefit_formatted": "1,000원 할인", + "included_products": [], + "excluded_products": [], + "included_categories": [], + "excluded_categories": [] + } + ], + "first_page_url": "https://api.example.com/api/modules/sirsoft-ecommerce/user/coupons/downloadable?page=1", + "from": 1, + "last_page": 1, + "last_page_url": "https://api.example.com/api/modules/sirsoft-ecommerce/user/coupons/downloadable?page=1", + "links": [ + { + "url": null, + "label": "pagination.previous", + "page": null, + "active": false + }, + { + "url": "https://api.example.com/api/modules/sirsoft-ecommerce/user/coupons/downloadable?page=1", + "label": "1", + "page": 1, + "active": true + }, + { + "url": null, + "label": "pagination.next", + "page": null, + "active": false + } + ], + "next_page_url": null, + "path": "https://api.example.com/api/modules/sirsoft-ecommerce/user/coupons/downloadable", + "per_page": 25, + "prev_page_url": null, + "to": 1, + "total": 1 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 로그인한 회원이 지금 다운로드해 발급받을 수 있는 쿠폰(다운로드형) 목록을 페이지네이션으로 조회합니다. `auth:sanctum` 인증이 필요하며, `UserCouponService::getDownloadableCoupons()`가 발급기간·수량·회원당 한도를 만족하는 다운로드형 쿠폰을 반환하고 각 항목에 `is_downloaded`·`user_issued_count`로 이미 받았는지 여부를 표시합니다. 여기 항목은 아직 발급 전이므로 쿠폰 정의(마스터) 기준이며, 실제 발급은 download 엔드포인트로 수행합니다. + + +### POST /api/modules/sirsoft-ecommerce/user/coupons/{couponId}/download + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.coupons.download` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\UserCouponController@download` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| couponId | path | string | 예 | — | 대상 coupon의 식별자 | +| coupon_id | body | integer | 예 | — | coupon 식별자 | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/user/coupons/{couponId}/download HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "coupon_id": 1 +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 로그인한 회원이 다운로드형 쿠폰 1건을 실제로 발급받습니다. `auth:sanctum` 인증이 필요하고 대상 쿠폰은 `couponId`(경로)로 지정하며, `UserCouponService::downloadCoupon()`이 발급기간·수량·회원당 한도·중복 발급 여부를 검증한 뒤 발급 내역을 생성해 201로 반환합니다. 한도 초과·발급기간 종료 등 발급 불가 사유는 서비스 예외의 메시지와 코드가 그대로 오류 응답에 전달됩니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/currency.md b/modules/_bundled/sirsoft-ecommerce/docs/api/currency.md new file mode 100644 index 00000000..c471ab7c --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/currency.md @@ -0,0 +1,119 @@ +# Currency API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Currency 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-ecommerce/user/currency + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.currency.show` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\UserCurrencyController@show` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/user/currency HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| preferred_currency | null | `null` | 회원이 저장한 선호 결제 통화 코드 (미설정 시 `null`) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "결제 통화를 조회했습니다.", + "data": { + "preferred_currency": null + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** 로그인한 회원 본인의 선호 결제 통화를 조회합니다. `auth:sanctum` 인증이 필요하며, `UserCurrencyService::getPreferredCurrency()`가 현재 사용자의 저장된 통화 코드를 반환합니다. 아직 통화를 설정하지 않은 회원은 `preferred_currency`가 `null`로 반환됩니다. 마이페이지 통화 설정 화면이나 회원정보 수정 화면에서 현재 값을 표시하는 용도입니다. + + +### PUT /api/modules/sirsoft-ecommerce/user/currency + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.currency.update` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\UserCurrencyController@update` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| currency | body | string | 예 | — | 저장할 선호 결제 통화 코드 (등록 통화: is_default 또는 exchange_rate>0 인 통화만 허용) | + +**요청 예시** + +```http +PUT /api/modules/sirsoft-ecommerce/user/currency HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "currency": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 로그인한 회원 본인의 선호 결제 통화를 저장합니다. `auth:sanctum` 인증이 필요하며, `UserCurrencyService::setPreferredCurrency()`가 검증된 `currency`(등록된 통화만 허용)를 현재 사용자에 영속화하고 저장된 통화 코드를 반환합니다. 마이페이지 통화 설정이나 회원정보 수정에서 회원이 직접 결제 통화를 변경할 때 호출하는 용도입니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/dashboard.md b/modules/_bundled/sirsoft-ecommerce/docs/api/dashboard.md new file mode 100644 index 00000000..c9a97e48 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/dashboard.md @@ -0,0 +1,328 @@ +# Dashboard API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Dashboard 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-ecommerce/admin/dashboard/overview + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.dashboard.overview` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\DashboardController@overview` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.dashboard.view` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/dashboard/overview HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| pending_payment | integer | `0` | 오늘 결제대기 상태 주문상품 수량 | +| payment_complete | integer | `0` | 오늘 결제완료 상태 주문상품 수량 | +| preparing | integer | `0` | 오늘 상품준비중 상태 주문상품 수량 | +| shipping_ready | integer | `0` | 오늘 배송준비 상태 주문상품 수량 | +| shipping | integer | `0` | 오늘 배송중 상태 주문상품 수량 | +| cancellations | integer | `0` | 오늘 취소 상태 주문상품 수량 (전체취소 기준, 부분취소 포함) | +| returns | integer | `0` | 오늘 반품 상태 주문상품 수량 (환불 도메인 미반영으로 현재 항상 0) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "대시보드 데이터를 조회했습니다.", + "data": { + "pending_payment": 0, + "payment_complete": 0, + "preparing": 0, + "shipping_ready": 0, + "shipping": 0, + "cancellations": 0, + "returns": 0 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.dashboard.view`)이 없는 경우 | + + + +**설명** 관리자 대시보드 상단의 "오늘 주문 현황" 배지를 채우는 조회 엔드포인트입니다. `auth:sanctum` + admin + `sirsoft-ecommerce.dashboard.view` 권한이 필요하며, `EcommerceDashboardService::getOverview()`가 오늘자 주문을 상태별(결제대기/결제완료/상품준비중/배송준비/배송중/취소/반품)로 집계해 각 건수를 반환합니다. 모든 값은 오늘 하루 범위의 집계이며, 관리자가 처리해야 할 주문 흐름을 한눈에 파악하는 용도입니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/dashboard/pending-inquiries + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.dashboard.pending-inquiries` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\DashboardController@pendingInquiries` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.dashboard.view` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| limit | query | integer | 아니오 | min 1, max 50 | 반환할 최대 항목 수 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/dashboard/pending-inquiries?limit=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| items | array | `[{"id":1,"product_id":320,"inquirable_id":79762,"product_…` | 미답변 상품문의 목록 (최신순, PendingInquiryResource — 문의 id/상품/작성자/게시판 글 id 등) | +| total | integer | `1` | 전체 개수 (집계) | +| board_slug | null | `null` | 문의가 저장된 연동 게시판의 slug (관리자 문의 상세 링크용, 미연동 시 null) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "대시보드 데이터를 조회했습니다.", + "data": { + "items": [ + { + "id": 1, + "product_id": 1, + "inquirable_id": 59404, + "product_name": "iste et inventore", + "author_name": "API 문서 샘플 사용자", + "created_at": "2026-07-08 10:44:49" + } + ], + "total": 1, + "board_slug": null + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.dashboard.view`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 전체 상품에 달린 미답변 상품문의 목록과 총 건수를 조회합니다. `auth:sanctum` + admin + `sirsoft-ecommerce.dashboard.view` 권한이 필요하며, `EcommerceDashboardService::getPendingInquiries()`가 답변 대기 문의를 최신순으로 조회해 `items`(문의 목록)·`total`(전체 미답변 건수)·`board_slug`(연동 게시판 slug, 미연동 시 null)를 반환합니다. `limit` 쿼리로 표시 건수를 1~50 사이에서 조정할 수 있고, 미지정 시 모듈 설정 `dashboard.recent_limit`(기본 5) 값이 적용됩니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/dashboard/recent-reviews + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.dashboard.recent-reviews` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\DashboardController@recentReviews` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.dashboard.view` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| limit | query | integer | 아니오 | min 1, max 50 | 반환할 최대 항목 수 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/dashboard/recent-reviews?limit=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `99` | 기본 키 (내부 식별자) | +| product_id | integer | `320` | product 식별자 (연관 리소스 참조) | +| product_name | string | `API 문서 샘플 상품` | 리뷰 대상 상품의 현재 로케일 상품명 (product 관계 파생) | +| rating | integer | `5` | 리뷰 평점 (별점 정수) | +| author_name | string | `API 문서 샘플 사용자` | 리뷰 작성자 이름 (user 관계 파생) | +| created_at | string | `2026-07-07 14:47:31` | 생성 일시 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "대시보드 데이터를 조회했습니다.", + "data": [ + { + "id": 1, + "product_id": 1, + "product_name": "API 문서 샘플 상품", + "rating": 5, + "author_name": "API 문서 샘플 사용자", + "created_at": "2026-07-08 10:44:49" + } + ] +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.dashboard.view`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 전체 상품에 등록된 최신 노출 리뷰를 조회해 대시보드 "최신 리뷰" 카드를 채웁니다. `auth:sanctum` + admin + `sirsoft-ecommerce.dashboard.view` 권한이 필요하며, `EcommerceDashboardService::getRecentReviews()`가 노출 상태의 리뷰를 최신순으로 가져와 `RecentReviewResource`로 상품명·평점·작성자명·작성일시 등을 반환합니다. `limit` 쿼리로 1~50 건 범위에서 표시 개수를 지정할 수 있으며, 미지정 시 모듈 설정 `dashboard.recent_limit`(기본 5)이 적용됩니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/dashboard/sales-graph + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.dashboard.sales-graph` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\DashboardController@salesGraph` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.dashboard.view` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/dashboard/sales-graph HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| days | array | `[{"date":"2026-07-01","sales_quantity":0,"sales_amount":0…` | 일자별 판매 집계 배열 (각 항목 `{date, sales_quantity, sales_amount}` — 그래프 막대 데이터) | +| total_quantity | integer | `0` | 표시 기간 판매 수량 합계 | +| total_sales | integer | `0` | 표시 기간 순매출 합계 (기본 통화 자릿수로 라운딩) | +| quantity_change | null | `null` | 직전 동일 기간 대비 판매 수량 증감율(%) (직전 합계 0 이면 null) | +| sales_change | null | `null` | 직전 동일 기간 대비 순매출 증감율(%) (직전 합계 0 이면 null) | +| updated_at | null | `null` | 최종 수정 일시 | +| updated_at_display | string | `` | 집계 마지막 갱신 시각의 사용자 타임존 HH:mm 캡션 (갱신 이력 없으면 빈 문자열) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "대시보드 데이터를 조회했습니다.", + "data": { + "days": [ + { + "date": "2026-07-02", + "sales_quantity": 0, + "sales_amount": 0 + }, + { + "date": "2026-07-03", + "sales_quantity": 0, + "sales_amount": 0 + }, + { + "date": "2026-07-04", + "sales_quantity": 0, + "sales_amount": 0 + }, + { + "date": "2026-07-05", + "sales_quantity": 0, + "sales_amount": 0 + }, + { + "date": "2026-07-06", + "sales_quantity": 0, + "sales_amount": 0 + }, + { + "date": "2026-07-07", + "sales_quantity": 0, + "sales_amount": 0 + }, + { + "date": "2026-07-08", + "sales_quantity": 0, + "sales_amount": 0 + } + ], + "total_quantity": 0, + "total_sales": 0, + "quantity_change": null, + "sales_change": null, + "updated_at": null, + "updated_at_display": "" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.dashboard.view`)이 없는 경우 | + + + +**설명** 최근 N일간의 판매 추세 막대 그래프 데이터를 조회합니다. `auth:sanctum` + admin + `sirsoft-ecommerce.dashboard.view` 권한이 필요하며, `EcommerceDashboardService::getSalesGraph()`가 일자별 판매 수량·금액(`days`)과 기간 합계(`total_quantity`, `total_sales`), 직전 기간 대비 변화율(`quantity_change`, `sales_change`)을 반환합니다. 그래프 표시 일수는 모듈 설정 `dashboard.graph_days`(기본 7일)로 결정되며 별도 파라미터는 받지 않습니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/extra-fee-templates.md b/modules/_bundled/sirsoft-ecommerce/docs/api/extra-fee-templates.md new file mode 100644 index 00000000..82062633 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/extra-fee-templates.md @@ -0,0 +1,670 @@ +# Extra Fee Templates API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Extra Fee Templates 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-ecommerce/admin/extra-fee-templates + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.extra-fee-templates.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ExtraFeeTemplateController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.shipping-policies.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| search | query | string | 아니오 | max 200 | 검색어 (지정한 검색 대상 필드에서 부분 일치) | +| region | query | string | 아니오 | max 100 | 지역/권역 | +| is_active | query | string | 아니오 | ``, `true`, `false` | 활성 여부 (true 활성 / false 비활성) | +| sort_by | query | string | 아니오 | `id`, `zipcode`, `fee`, `region`, `is_active`, `created_at`, `updated_at` | 정렬 기준 필드명 | +| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) | +| per_page | query | integer | 아니오 | min 10, max 100 | 페이지당 항목 수 | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/extra-fee-templates?search=%EC%98%88%EC%8B%9C%EA%B0%92®ion=%EC%98%88%EC%8B%9C%EA%B0%92&is_active=%2C%20&sort_by=id&sort_order=asc&per_page=1&page=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| number | integer | `37` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| id | integer | `109` | 기본 키 (내부 식별자) | +| zipcode | string | `00000` | 추가배송비를 적용할 우편번호 (단일 또는 `-` 로 이은 범위) | +| fee | integer | `3000` | 해당 우편번호에 부과할 추가 배송비 (상점 기본 통화 기준 반올림) | +| fee_formatted | string | `3,000원` | `fee` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| region | string | `경기 안산 풍도동` | 지역명 (도서산간 등 관리자 참고용 표시 라벨) | +| description | string | `도서산간 지역` | 설명 (다국어 필드는 로케일별 값 객체) | +| is_active | boolean | `true` | active 여부 | +| created_by | null | `null` | 등록자 UUID (creator 관계 파생, 없으면 null) | +| updated_by | null | `null` | 최종 수정자 UUID (updater 관계 파생, 없으면 null) | +| created_at | string | `2026-07-07 14:47:31` | 생성 일시 | +| updated_at | string | `2026-07-07 14:47:31` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "추가배송비 템플릿 목록을 조회했습니다.", + "data": { + "data": [ + { + "number": 1, + "id": 1, + "zipcode": "00000", + "fee": 3000, + "fee_formatted": "3,000원", + "region": null, + "description": null, + "is_active": true, + "created_by": null, + "updated_by": null, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } + ], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + }, + "statistics": { + "total": 1, + "active": 1, + "inactive": 0, + "by_region": [] + }, + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 1, + "from": 1, + "to": 1, + "has_more_pages": false + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 추가배송비(도서산간 등 우편번호별 할증) 템플릿 목록을 검색·필터·정렬·페이지네이션으로 조회합니다. `permission:sirsoft-ecommerce.shipping-policies.read` 권한이 필요하며, `search`·`region`·`is_active` 필터와 다양한 정렬 기준을 지원합니다. `ExtraFeeTemplateService::getList()`가 조회하고 통계(`getStatistics()`)를 함께 담아 `ExtraFeeTemplateCollection`으로 반환합니다. 각 항목은 우편번호·추가 배송비·지역명을 포함하며 배송정책의 지역별 할증 관리 화면에 사용됩니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/extra-fee-templates + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.extra-fee-templates.store` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ExtraFeeTemplateController@store` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.shipping-policies.create` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| zipcode | body | string | 예 | max 20 | 우편번호 | +| fee | body | number | 예 | min 0, max 9999999999.99 | 해당 우편번호에 부과할 추가 배송비 (0 이상) | +| region | body | string | 아니오 | max 100 | 지역/권역 | +| description | body | string | 아니오 | max 1000 | 설명 | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/extra-fee-templates HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "zipcode": "06234", + "fee": 1, + "region": "예시값", + "description": "예시 내용입니다.", + "is_active": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.create`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 추가배송비 템플릿 1건을 생성합니다. `permission:sirsoft-ecommerce.shipping-policies.create` 권한이 필요하며, 우편번호(`zipcode`, 단일 또는 범위)와 추가 배송비(`fee`)를 필수로 받고 지역명·설명·활성 여부를 선택 입력합니다. `ExtraFeeTemplateService::create()`가 저장하고 생성된 템플릿을 201로 반환합니다. 활성 템플릿은 배송비 계산 시 해당 우편번호에 할증으로 반영됩니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/active-settings + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.extra-fee-templates.active-settings` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ExtraFeeTemplateController@activeSettings` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.shipping-policies.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/active-settings HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| zipcode | string | `00000` | 추가배송비 적용 우편번호 (배송정책 설정용 축약 필드) | +| fee | integer | `3000` | 해당 우편번호의 추가 배송비 (float 변환값) | +| region | string | `` | 지역명 (없으면 빈 문자열) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "활성 추가배송비 템플릿 설정을 조회했습니다.", + "data": [ + { + "zipcode": "00000", + "fee": 3000, + "region": "" + } + ] +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.read`)이 없는 경우 | + + + +**설명** 활성화된 추가배송비 템플릿을 배송정책에서 바로 사용할 수 있는 축약 JSON 배열(우편번호·배송비·지역명)로 반환합니다. `permission:sirsoft-ecommerce.shipping-policies.read` 권한이 필요하며, `ExtraFeeTemplateService::getAllAsExtraFeeSettings()`가 `is_active=true` 인 템플릿만 배송정책 설정 형식으로 변환합니다. 배송정책 편집 화면에서 지역별 할증 규칙을 채우거나 계산 로직에 주입하는 용도로, 관리 목록(index)의 상세 필드 대신 계산에 필요한 최소 필드만 내려줍니다. + + +### DELETE /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/bulk + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.extra-fee-templates.bulk-destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ExtraFeeTemplateController@bulkDestroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.shipping-policies.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| ids | query | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/bulk?ids=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.delete`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 여러 추가배송비 템플릿을 한 번에 삭제합니다. `permission:sirsoft-ecommerce.shipping-policies.delete` 권한이 필요하고 대상 ID 배열(`ids`)을 쿼리로 전달하며, `ExtraFeeTemplateService::bulkDelete()`가 일괄 삭제 후 삭제 건수(`deleted_count`)를 반환합니다. 목록에서 여러 지역 할증 규칙을 선택해 한꺼번에 정리할 때 사용합니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/bulk + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.extra-fee-templates.bulk-store` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ExtraFeeTemplateController@bulkStore` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.shipping-policies.create` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| items | body | array | 예 | min 1, max 1000 | 처리 대상 항목 배열 | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/bulk HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "items": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.create`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 CSV/엑셀 업로드로 추가배송비 템플릿을 한 번에 대량 등록합니다. `permission:sirsoft-ecommerce.shipping-policies.create` 권한이 필요하고 최대 1000건까지 `items` 배열로 전달하며, `ExtraFeeTemplateService::bulkCreate()`가 일괄 생성 후 등록 건수(`created_count`)를 201로 반환합니다. 도서산간 우편번호 목록처럼 다수의 지역 할증을 수기 입력 없이 파일로 업로드할 때 사용합니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/bulk-toggle-active + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.extra-fee-templates.bulk-toggle-active` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ExtraFeeTemplateController@bulkToggleActive` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.shipping-policies.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| ids | body | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | +| is_active | body | boolean | 예 | — | 활성 여부 (true 활성 / false 비활성) | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/bulk-toggle-active HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "ids": [ + "예시값" + ], + "is_active": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 여러 추가배송비 템플릿의 활성 상태를 한 번에 지정한 값(`is_active`)으로 변경합니다. `permission:sirsoft-ecommerce.shipping-policies.update` 권한이 필요하고 대상 ID 배열(`ids`)과 적용할 활성 여부를 전달하며, `ExtraFeeTemplateService::bulkToggleActive()`가 일괄 갱신 후 변경 건수(`updated_count`)를 반환합니다. 단건 토글과 달리 여러 지역 할증을 한꺼번에 켜거나 끌 때 사용합니다. + + +### DELETE /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.extra-fee-templates.destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ExtraFeeTemplateController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.shipping-policies.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "추가배송비 템플릿이 삭제되었습니다.", + "data": null +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.delete`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 추가배송비 템플릿 1건을 삭제합니다. `permission:sirsoft-ecommerce.shipping-policies.delete` 권한이 필요하며, 대상이 없으면 404 를 반환하고 존재하면 `ExtraFeeTemplateService::delete()`가 삭제합니다. 특정 지역의 할증 규칙을 개별적으로 제거할 때 사용합니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.extra-fee-templates.show` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ExtraFeeTemplateController@show` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.shipping-policies.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| zipcode | string | `00000` | 우편번호 (단일 또는 범위) | +| fee | integer | `3000` | 추가 배송비 | +| fee_formatted | string | `3,000원` | `fee` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| region | null | `null` | 지역명 (예: 제주도, 울릉도) | +| description | null | `null` | 설명 (다국어 필드는 로케일별 값 객체) | +| is_active | boolean | `true` | active 여부 | +| created_by | null | `null` | 생성자 ID | +| updated_by | null | `null` | 최종 수정한 사용자 정보 (uuid/name — updated_by 관계 파생, 없으면 null) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "추가배송비 템플릿 정보를 조회했습니다.", + "data": { + "id": 1, + "zipcode": "00000", + "fee": 3000, + "fee_formatted": "3,000원", + "region": null, + "description": null, + "is_active": true, + "created_by": null, + "updated_by": null, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 추가배송비 템플릿 1건의 상세 정보를 조회합니다. `permission:sirsoft-ecommerce.shipping-policies.read` 권한이 필요하며, `ExtraFeeTemplateService::getDetail()`이 대상을 조회해 `ExtraFeeTemplateResource`로 반환합니다. 템플릿 편집 폼에 기존 값(우편번호·배송비·지역명·설명·활성 여부)을 채우는 용도로 사용되며, 대상이 없으면 404 를 반환합니다. + + +### PUT /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.extra-fee-templates.update` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ExtraFeeTemplateController@update` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.shipping-policies.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| zipcode | body | string | 예 | max 20 | 우편번호 | +| fee | body | number | 예 | min 0, max 9999999999.99 | 해당 우편번호에 부과할 추가 배송비 (0 이상) | +| region | body | string | 아니오 | max 100 | 지역/권역 | +| description | body | string | 아니오 | max 1000 | 설명 | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | + +**요청 예시** + +```http +PUT /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "zipcode": "06234", + "fee": 1, + "region": "예시값", + "description": "예시 내용입니다.", + "is_active": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 추가배송비 템플릿 1건을 전체 수정합니다. `permission:sirsoft-ecommerce.shipping-policies.update` 권한이 필요하며, path 의 `id` 로 대상을 조회해 없으면 404(`messages.extra_fee_template.not_found`)를 반환합니다. 존재하면 `ExtraFeeTemplateService::update()`가 우편번호·추가 배송비·지역명·설명·활성 여부를 갱신합니다. 우편번호와 배송비는 필수이며, 수정된 템플릿이 활성 상태이면 이후 배송비 계산에 즉시 반영됩니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/{id}/toggle-active + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.extra-fee-templates.toggle-active` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ExtraFeeTemplateController@toggleActive` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.shipping-policies.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/1/toggle-active HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| zipcode | string | `00000` | 우편번호 (단일 또는 범위) | +| fee | integer | `3000` | 추가 배송비 | +| fee_formatted | string | `3,000원` | `fee` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| region | null | `null` | 지역명 (예: 제주도, 울릉도) | +| description | null | `null` | 설명 (다국어 필드는 로케일별 값 객체) | +| is_active | boolean | `false` | active 여부 | +| created_by | null | `null` | 생성자 ID | +| updated_by | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | 최종 수정한 사용자 정보 (uuid/name — updated_by 관계 파생, 없으면 null) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 15:00:20` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "추가배송비 템플릿 사용여부가 변경되었습니다.", + "data": { + "id": 1, + "zipcode": "00000", + "fee": 3000, + "fee_formatted": "3,000원", + "region": null, + "description": null, + "is_active": false, + "created_by": null, + "updated_by": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:00:20", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 추가배송비 템플릿 1건의 활성 상태를 반전(active↔inactive)합니다. `permission:sirsoft-ecommerce.shipping-policies.update` 권한이 필요하며, path 의 `id` 로 대상을 조회해 없으면 404(`messages.extra_fee_template.not_found`)를 반환합니다. 존재하면 `ExtraFeeTemplateService::toggleActive()`가 현재 값을 뒤집어 저장하고 갱신된 템플릿을 반환합니다. 목록 화면에서 특정 지역 할증 규칙을 개별적으로 켜거나 끌 때 사용하며, 여러 건을 동일 값으로 일괄 설정하려면 bulk-toggle-active 엔드포인트를 사용합니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/guest.md b/modules/_bundled/sirsoft-ecommerce/docs/api/guest.md new file mode 100644 index 00000000..545eb3d0 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/guest.md @@ -0,0 +1,281 @@ +# Guest API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Guest 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### POST /api/modules/sirsoft-ecommerce/guest/orders/verify + +- **라우트명**: `api.modules.sirsoft-ecommerce.guest.orders.verify` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\OrderController@verify` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| order_number | body | string | 예 | max 50 | 조회할 주문번호 (본인 확인 키 ①) | +| orderer_phone | body | string | 예 | max 20 | 주문자 전화번호 (본인 확인 키 ②) | +| guest_lookup_password | body | string | 예 | max 255 | 비회원 주문 조회 비밀번호 (본인 확인 키 ③) | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/guest/orders/verify HTTP/1.1 +Host: api.example.com +Accept: application/json +Content-Type: application/json + +{ + "order_number": "예시값", + "orderer_phone": "010-1234-5678", + "guest_lookup_password": "Password123!" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 비회원이 주문번호·주문자 전화번호·조회 비밀번호로 본인 확인을 수행하고, 성공 시 30분 유효한 비회원 주문 조회 토큰을 발급받는 공개 엔드포인트입니다. 인증이 필요 없으며, `OrderController@verify`가 `GuestOrderAuthService::authenticate()`로 검증한 뒤 토큰과 최소 주문 요약(`order_number`, `order_status`)만 반환합니다. 주문 없음·회원 주문·전화번호 불일치·비밀번호 오류·잠금 등 모든 실패는 정보 노출 방지를 위해 동일한 404("주문을 찾을 수 없습니다")로 처리됩니다. 발급된 토큰은 이후 비회원 주문 상세/취소/환불 예상 등 후속 호출의 `X-Guest-Order-Token` 헤더로 사용합니다. + + +### POST /api/modules/sirsoft-ecommerce/guest/orders/{orderNumber}/cancel + +- **라우트명**: `api.modules.sirsoft-ecommerce.guest.orders.cancel` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\OrderController@cancel` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| orderNumber | path | string | 예 | — | 대상 order number의 식별자 | +| reason | body | string | 예 | — | 취소 사유 코드 (ClaimReason 의 refund 타입·활성·사용자 선택 가능 코드) | +| reason_detail | body | string | 아니오 | max 500 | 사용자 입력 취소 사유 상세 | +| items | body | array | 아니오 | min 1 | 처리 대상 항목 배열 (전달 시 부분취소, 미전달 시 전체취소) | +| refund_priority | body | string | 아니오 | `pg_first`, `points_first` | 환불 배분 우선순위 (PG 우선 / 포인트 우선, 미전달 시 pg_first) | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/guest/orders/{orderNumber}/cancel HTTP/1.1 +Host: api.example.com +Accept: application/json +Content-Type: application/json + +{ + "reason": "예시값", + "reason_detail": "예시값", + "items": [ + "예시값" + ], + "refund_priority": "pg_first" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 비회원이 조회 토큰으로 인증된 상태에서 자신의 주문을 취소합니다. 주문 소유권은 `VerifyGuestOrderToken` 미들웨어가 `X-Guest-Order-Token` 헤더로 검증하며, `OrderController@cancel`이 회원 취소와 동일한 `OrderCancellationService`를 재사용하되 취소자(`cancelledBy`)는 null로 둡니다. `items`를 전달하면 부분취소, 없으면 전체취소로 처리하고, `refund_priority`로 PG 환불과 포인트 환불 중 우선순위를 지정할 수 있습니다. 취소 후 갱신된 주문 상세를 `GuestOrderResource`로 반환합니다. + + +### POST /api/modules/sirsoft-ecommerce/guest/orders/{orderNumber}/estimate-refund + +- **라우트명**: `api.modules.sirsoft-ecommerce.guest.orders.estimate-refund` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\OrderController@estimateRefund` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| orderNumber | path | string | 예 | — | 대상 order number의 식별자 | +| items | body | array | 예 | min 1 | 처리 대상 항목 배열 | +| refund_priority | body | string | 아니오 | `pg_first`, `points_first` | 환불 배분 우선순위 (PG 우선 / 포인트 우선, 미전달 시 pg_first) | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/guest/orders/{orderNumber}/estimate-refund HTTP/1.1 +Host: api.example.com +Accept: application/json +Content-Type: application/json + +{ + "items": [ + "예시값" + ], + "refund_priority": "pg_first" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 비회원이 취소를 확정하기 전에 특정 옵션(`items`) 취소 시 예상 환불 금액을 미리 계산해 보여줍니다. 조회 토큰(`X-Guest-Order-Token`)으로 주문 소유권이 검증되며, `OrderController@estimateRefund`가 `OrderCancellationService::previewRefund()`로 실제 취소를 수행하지 않고 환불 예상값만 반환합니다. `refund_priority`에 따라 PG 우선/포인트 우선 환불 배분 결과가 달라집니다. 비회원 주문 취소 화면에서 "환불 예정 금액"을 미리 안내하는 용도입니다. + + +### POST /api/modules/sirsoft-ecommerce/guest/orders/{orderNumber}/options/{optionId}/confirm + +- **라우트명**: `api.modules.sirsoft-ecommerce.guest.orders.confirm-option` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\OrderController@confirmOption` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| orderNumber | path | string | 예 | — | 대상 order number의 식별자 | +| optionId | path | string | 예 | — | 대상 option의 식별자 | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/guest/orders/{orderNumber}/options/{optionId}/confirm HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 비회원이 배송 완료된 주문의 개별 옵션(`optionId`)을 구매확정합니다. 조회 토큰(`X-Guest-Order-Token`)으로 주문 소유권이 검증되며, `OrderController@confirmOption`이 토큰으로 검증된 주문에 실제 속한 옵션인지 다시 확인한 뒤 `OrderService::confirmOption()`을 호출합니다. 주문에 속하지 않은 옵션 ID면 404, 확정 불가 상태(배송 미완료 등)면 422를 반환합니다. 구매확정 시 적립 포인트 확정 등 후속 처리가 서비스 계층에서 이어집니다. + + +### PUT /api/modules/sirsoft-ecommerce/guest/orders/{orderNumber}/shipping-address + +- **라우트명**: `api.modules.sirsoft-ecommerce.guest.orders.update-shipping-address` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\OrderController@updateShippingAddress` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| orderNumber | path | string | 예 | — | 대상 order number의 식별자 | +| recipient_name | body | string | 예 | max 50 | 수령인 이름 | +| recipient_phone | body | string | 예 | max 20 | 수령인 연락처 | +| recipient_tel | body | string | 아니오 | max 20 | 수령인 일반전화 (선택 연락처) | +| country_code | body | string | 아니오 | — | 국가 코드 (ISO 3166-1 alpha-2) | +| zipcode | body | string | 아니오 | max 10 | 우편번호 | +| address | body | string | 아니오 | max 255 | 기본 주소 | +| address_detail | body | string | 아니오 | max 255 | 상세 주소 | +| address_line_1 | body | string | 아니오 | max 255 | 주소 1행 (기본 주소) | +| address_line_2 | body | string | 아니오 | max 255 | 주소 2행 (상세 주소) | +| intl_city | body | string | 아니오 | max 100 | 도시 (국제 주소) | +| intl_state | body | string | 아니오 | max 100 | 주/도 (국제 주소) | +| intl_postal_code | body | string | 아니오 | max 20 | 우편번호 (국제 주소) | +| delivery_memo | body | string | 아니오 | max 255 | 배송 메모 (배송 시 요청사항) | + +**요청 예시** + +```http +PUT /api/modules/sirsoft-ecommerce/guest/orders/{orderNumber}/shipping-address HTTP/1.1 +Host: api.example.com +Accept: application/json +Content-Type: application/json + +{ + "recipient_name": "예시 이름", + "recipient_phone": "010-1234-5678", + "recipient_tel": "예시값", + "country_code": "KR", + "zipcode": "06234", + "address": "서울특별시 강남구 테헤란로 1", + "address_detail": "서울특별시 강남구 테헤란로 1", + "address_line_1": "서울특별시 강남구 테헤란로 1", + "address_line_2": "서울특별시 강남구 테헤란로 1", + "intl_city": "예시값", + "intl_state": "예시값", + "intl_postal_code": "06234", + "delivery_memo": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 비회원이 배송 전 상태의 주문 배송지를 수정합니다. 조회 토큰(`X-Guest-Order-Token`)으로 주문 소유권이 검증되며, 비회원은 저장된 회원 주소(`address_id`)를 쓸 수 없으므로 수취인·연락처·주소 필드를 직접 입력받아 `OrderController@updateShippingAddress`가 회원과 동일한 `OrderService::updateShippingAddress()`로 처리합니다. 국내(`zipcode`/`address`)와 해외(`address_line_1`·`intl_city` 등) 주소 필드를 함께 지원합니다. 이미 배송이 시작된 주문 등 수정 불가 상태면 422를 반환합니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/inquiries.md b/modules/_bundled/sirsoft-ecommerce/docs/api/inquiries.md new file mode 100644 index 00000000..0e5d4a7f --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/inquiries.md @@ -0,0 +1,537 @@ +# Inquiries API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Inquiries 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### DELETE /api/modules/sirsoft-ecommerce/admin/inquiries/{inquiryId} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.inquiries.destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductInquiryController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.inquiries.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| inquiryId | path | string | 예 | — | 대상 inquiry의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/admin/inquiries/{inquiryId} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.inquiries.delete`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 상품 1:1 문의 1건을 삭제합니다. `sirsoft-ecommerce.inquiries.delete` 권한이 필요하며, `ProductInquiryService::deleteInquiry()` 가 해당 문의를 제거하고 `{deleted: true}` 를 반환합니다. 문의가 존재하지 않는 등 삭제 불가 상황에서는 서비스가 `RuntimeException` 을 던져 422 로 응답합니다. 관리자 문의 관리 화면에서 부적절하거나 중복된 문의를 정리할 때 사용합니다. + + +### DELETE /api/modules/sirsoft-ecommerce/admin/inquiries/{inquiryId}/reply + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.inquiries.reply.destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductInquiryController@destroyReply` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.inquiries.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| inquiryId | path | string | 예 | — | 대상 inquiry의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/admin/inquiries/{inquiryId}/reply HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.inquiries.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 문의에 등록된 답변을 삭제합니다. `sirsoft-ecommerce.inquiries.update` 권한이 필요하며, `ProductInquiryService::deleteReply()` 가 답변을 제거하고 문의를 미답변 상태로 되돌린 뒤 `{deleted: true}` 를 반환합니다. 문의 자체는 유지되며, 잘못 등록한 답변을 회수할 때 사용합니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/inquiries/{inquiryId}/reply + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.inquiries.reply` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductInquiryController@reply` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.inquiries.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| inquiryId | path | string | 예 | — | 대상 inquiry의 식별자 | +| content | body | string | 예 | min 1, max 5000 | 본문 내용 | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/inquiries/{inquiryId}/reply HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "content": "예시 내용입니다." +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.inquiries.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 상품 문의에 답변을 등록합니다. `sirsoft-ecommerce.inquiries.update` 권한이 필요하며, `ProductInquiryService::createReply()` 가 `content`(1~5000자)로 답변을 저장하고 문의를 답변완료 상태로 전환한 뒤 `{id, is_answered}` 를 201 로 반환합니다. 이미 답변이 있는 등 등록 불가 상황에서는 `RuntimeException` 이 던져져 422 로 응답합니다. 관리자가 고객 문의에 응대할 때 사용합니다. + + +### PUT /api/modules/sirsoft-ecommerce/admin/inquiries/{inquiryId}/reply + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.inquiries.reply.update` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductInquiryController@updateReply` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.inquiries.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| inquiryId | path | string | 예 | — | 대상 inquiry의 식별자 | +| content | body | string | 예 | min 1, max 5000 | 본문 내용 | + +**요청 예시** + +```http +PUT /api/modules/sirsoft-ecommerce/admin/inquiries/{inquiryId}/reply HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "content": "예시 내용입니다." +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.inquiries.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 문의에 이미 등록된 답변 내용을 수정합니다. `sirsoft-ecommerce.inquiries.update` 권한이 필요하며, `ProductInquiryService::updateReply()` 가 `content`(1~5000자)로 기존 답변을 갱신하고 `{id}` 를 반환합니다. 답변이 없는 문의 등 수정 불가 상황에서는 `RuntimeException` 이 던져져 422 로 응답합니다. 오탈자 정정 등 답변 내용을 고칠 때 사용합니다. + + +### GET /api/modules/sirsoft-ecommerce/user/inquiries + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.inquiries.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\ProductInquiryController@index` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/user/inquiries HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| items | array | `[]` | 내 문의 항목 배열 (각 항목: id, product 요약, product_name, is_answered, 게시판 연동 시 title/category/content/is_secret/reply/attachments) | +| meta | object | `{"current_page":1,"per_page":25,"total":0,"last_page":1,"…` | 페이지네이션 메타 (current_page/per_page/total/last_page/from/to, 문의 게시판 연동 여부 inquiry_available, abilities 답변·삭제 권한, board_settings) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "문의 목록을 조회했습니다.", + "data": { + "items": [ + { + "id": 1, + "product_id": 1, + "product": { + "id": 1, + "name": "API 문서 샘플 상품", + "thumbnail_url": null, + "url": "/shop/products/1" + }, + "product_name": "iste et inventore", + "is_answered": false, + "answered_at": null, + "created_at": "2026-07-08T01:44:49+00:00", + "updated_at": "2026-07-08T01:44:49+00:00", + "title": null, + "category": null, + "content": null, + "is_secret": "{MASKED}", + "reply": null, + "attachments": [] + } + ], + "meta": { + "current_page": 1, + "per_page": 25, + "total": 1, + "last_page": 1, + "from": 1, + "to": 1, + "inquiry_available": false, + "abilities": { + "can_update": true, + "can_delete": true + }, + "board_settings": { + "secret_mode": "{MASKED}", + "categories": [], + "use_file_upload": false, + "max_file_count": 5, + "max_file_size": 10485760, + "allowed_extensions": [], + "min_title_length": 2, + "max_title_length": 200, + "min_content_length": 10, + "max_content_length": 10000 + } + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** 로그인 회원이 마이페이지에서 자신이 작성한 상품 문의 목록을 조회합니다. `auth:sanctum` 인증만 요구하며, `ProductInquiryService::getUserInquiries()` 가 로그인 사용자(`Auth::id()`)의 문의를 `search`(검색어)·`is_answered`(답변 여부) 필터와 `per_page`(기본 10)로 페이지네이션해 `items`(문의 배열)와 `meta`(페이지 정보)로 반환합니다. 마이페이지 문의 내역 화면을 채우는 데 사용합니다. + + +### DELETE /api/modules/sirsoft-ecommerce/user/inquiries/{inquiryId} + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.inquiries.destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\ProductInquiryController@destroy` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| inquiryId | path | string | 예 | — | 대상 inquiry의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/user/inquiries/{inquiryId} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 로그인 회원이 자신이 작성한 문의를 삭제합니다. `auth:sanctum` 인증이 필요하며, 컨트롤러가 문의를 조회해 없으면 404, `inquiry->user_id` 가 로그인 사용자와 다르면 403 을 반환한 뒤 `ProductInquiryService::deleteInquiry()` 로 삭제하고 `{deleted: true}` 를 반환합니다. 마이페이지에서 본인 문의를 취소/삭제할 때 사용합니다. + + +### PUT /api/modules/sirsoft-ecommerce/user/inquiries/{inquiryId} + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.inquiries.update` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\ProductInquiryController@update` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| inquiryId | path | string | 예 | — | 대상 inquiry의 식별자 | +| title | body | string | 아니오 | — | 제목 | +| category | body | string | 아니오 | — | 문의 분류 (게시판 설정 기반 유형 슬러그, 연동 게시판 Post 로 저장) | +| content | body | string | 예 | — | 본문 내용 | +| is_secret | body | boolean | 아니오 | — | secret 여부 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.inquiry.update_validation_rules`). + +**요청 예시** + +```http +PUT /api/modules/sirsoft-ecommerce/user/inquiries/{inquiryId} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "title": "예시 제목", + "category": "예시값", + "content": "예시 내용입니다.", + "is_secret": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 로그인 회원이 자신이 작성한 문의 내용을 수정합니다. `auth:sanctum` 인증이 필요하며, 컨트롤러가 문의를 조회해 없으면 404, `inquiry->user_id` 가 로그인 사용자와 다르면 403 을 반환한 뒤 `ProductInquiryService::updateInquiry()` 로 제목·분류·본문·비밀글 여부를 갱신하고 `{id}` 를 반환합니다. `content` 는 필수이며, 마이페이지에서 아직 답변되지 않은 본인 문의를 고칠 때 사용합니다. + + +### DELETE /api/modules/sirsoft-ecommerce/user/inquiries/{inquiryId}/reply + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.inquiries.reply.destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\ProductInquiryController@destroyReply` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| inquiryId | path | string | 예 | — | 대상 inquiry의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/user/inquiries/{inquiryId}/reply HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 사용자 표면에서 문의 답변 권한을 가진 회원이 문의 답변을 삭제합니다. `auth:sanctum` 인증에 더해 컨트롤러가 `PermissionHelper::check('sirsoft-ecommerce.inquiries.update')` 로 답변 권한을 확인(없으면 403)한 뒤 `ProductInquiryService::deleteReply()` 로 답변을 제거하고 `{deleted: true}` 를 반환합니다. 답변 권한을 위임받은 사용자(예: 상담원 역할)가 사용자 화면에서 답변을 회수할 때 사용합니다. + + +### POST /api/modules/sirsoft-ecommerce/user/inquiries/{inquiryId}/reply + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.inquiries.reply` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\ProductInquiryController@reply` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| inquiryId | path | string | 예 | — | 대상 inquiry의 식별자 | +| content | body | string | 예 | min 1, max 5000 | 본문 내용 | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/user/inquiries/{inquiryId}/reply HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "content": "예시 내용입니다." +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 사용자 표면에서 문의 답변 권한을 가진 회원이 문의에 답변을 등록합니다. `auth:sanctum` 인증에 더해 컨트롤러가 `PermissionHelper::check('sirsoft-ecommerce.inquiries.update')` 로 답변 권한을 확인(없으면 403)한 뒤 `ProductInquiryService::createReply()` 가 `content`(1~5000자)로 답변을 저장하고 문의를 답변완료로 전환해 `{id, is_answered}` 를 201 로 반환합니다. 관리자 화면이 아닌 사용자 프론트에서 답변을 처리하는 상담원 역할에 사용합니다. + + +### PUT /api/modules/sirsoft-ecommerce/user/inquiries/{inquiryId}/reply + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.inquiries.reply.update` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\ProductInquiryController@updateReply` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| inquiryId | path | string | 예 | — | 대상 inquiry의 식별자 | +| content | body | string | 예 | min 1, max 5000 | 본문 내용 | + +**요청 예시** + +```http +PUT /api/modules/sirsoft-ecommerce/user/inquiries/{inquiryId}/reply HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "content": "예시 내용입니다." +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 사용자 표면에서 문의 답변 권한을 가진 회원이 기존 문의 답변을 수정합니다. `auth:sanctum` 인증에 더해 컨트롤러가 `PermissionHelper::check('sirsoft-ecommerce.inquiries.update')` 로 답변 권한을 확인(없으면 403)한 뒤 `ProductInquiryService::updateReply()` 가 `content`(1~5000자)로 답변을 갱신하고 `{id}` 를 반환합니다. 사용자 프론트에서 답변을 처리하는 상담원 역할이 답변 내용을 정정할 때 사용합니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/mileage-transactions.md b/modules/_bundled/sirsoft-ecommerce/docs/api/mileage-transactions.md new file mode 100644 index 00000000..c177968a --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/mileage-transactions.md @@ -0,0 +1,408 @@ +# Mileage Transactions API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Mileage Transactions 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-ecommerce/admin/mileage-transactions + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.mileage-transactions.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\MileageTransactionController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.mileage.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | +| sort | query | string | 아니오 | `created_at_desc`, `created_at_asc`, `amount_desc`, `amount_asc` | 정렬 기준 (필드명, `-` 접두 시 내림차순) | +| search_field | query | string | 아니오 | `member`, `member_id`, `email`, `order` | 검색 대상 필드명 (검색어를 적용할 컬럼) | +| search_keyword | query | string | 아니오 | max 100 | 검색 키워드 (부분 일치) | +| type | query | string | 아니오 | `earn`, `use`, `expire`, `adjust` | 유형 필터 (해당 유형의 항목만 조회) | +| currency | query | string | 아니오 | max 10 | 통화 코드 필터 (해당 통화로 기록된 거래만 조회) | +| start_date | query | date | 아니오 | — | 조회 기간 시작일 (이 날짜 이후 데이터) | +| end_date | query | date | 아니오 | — | 조회 기간 종료일 (이 날짜 이전 데이터) | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/mileage-transactions?page=1&per_page=1&sort=created_at_desc&search_field=member&search_keyword=%EC%98%88%EC%8B%9C%EA%B0%92&type=earn¤cy=%EC%98%88%EC%8B%9C%EA%B0%92&start_date=2026-01-01&end_date=2026-01-01 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| number | integer | `68` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| id | integer | `528` | 기본 키 (내부 식별자) | +| user_id | integer | `166` | user 식별자 (연관 리소스 참조) | +| currency | string | `KRW` | 거래 기록 통화 코드 (주문 기준통화 스냅샷, 금액 표기·잔액 집계 단위) | +| type | string | `admin_earn` | 거래 유형 (MileageTransactionTypeEnum 8종: purchase_earn·admin_earn·order_use·admin_deduct·expired·refund_restore·order_cancel_restore·earn_cancel) | +| type_label | string | `관리자 지급` | `type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| admin_badge_group | string | `amber` | 관리자 내역 화면 배지 색상 그룹 (적립계=green·사용계=blue·소멸=gray·복원계=teal·수동/회수계=amber) | +| user_display_category | string | `adjust` | 회원 마이페이지 표시용 4분류 (earn·use·expire·adjust — 복원·수동·회수는 adjust 로 통합) | +| amount | integer | `1000` | 거래 금액 (양수=적립, 음수=차감) | +| amount_formatted | string | `1,000원` | `amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| remaining_amount | integer | `0` | 잔여 금액 (적립건만 양수, FIFO 차감으로 소진 — 미만료 잔여 합이 잔액 SSoT) | +| remaining_amount_formatted | string | `0원` | `remaining_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| balance_after | integer | `1000` | 거래 직후 잔액 (감사용 스냅샷, 베스트에포트) | +| order_id | integer | `436` | order 식별자 (연관 리소스 참조) | +| order_option_id | integer | `824` | order option 식별자 (연관 리소스 참조) | +| order_cancel_id | integer | `18` | order cancel 식별자 (연관 리소스 참조) | +| source_transaction_id | integer | `523` | source transaction 식별자 (연관 리소스 참조) | +| granted_by | integer | `1` | 부여 주체 식별자 (NULL=시스템 자동, user ID=관리자 수동 부여) | +| granted_by_name | string | `관리자` | 부여 관리자 이름 (grantedByUser 관계 eager load 시에만 노출) | +| granted_by_uuid | string | `a1e0a91a-fba6-491c-a53e-7285a5686857` | 부여 관리자 UUID (grantedByUser 관계 eager load 시에만 노출) | +| user_name | string | `API 문서 샘플 사용자` | 거래 대상 회원 이름 (user 관계 eager load 시에만 노출) | +| user_uuid | string | `a231747f-e82e-4cf2-9ae1-a261849dce40` | 거래 대상 회원 UUID (user 관계 eager load 시에만 노출) | +| order_number | string | `20260619-1425382147` | 연관 주문번호 (order 관계 eager load 시에만 노출) | +| description | string | `마일리지 적립 (660원)` | 설명 (다국어 필드는 로케일별 값 객체) | +| memo | string | `111` | 관리자 메모 (수동 지급/차감·적립건 편집 시 입력) | +| expires_at | string | `2027-07-02T15:29:30+00:00` | expires 일시 | +| expires_at_formatted | string | `2027-07-03 00:29:30` | `expires_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| expires_at_date | string | `2027-07-03` | `expires_at` 의 사이트 타임존 기준 날짜 부분 (시각 제외) | +| expired_at | null | `null` | expired 일시 | +| expired_at_formatted | null | `null` | `expired_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| created_at | string | `2026-07-07T05:47:31+00:00` | 생성 일시 | +| created_at_formatted | string | `2026-07-07 14:47:31` | `created_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| created_at_date | string | `2026-07-07` | `created_at` 의 사이트 타임존 기준 날짜 부분 (시각 제외) | +| is_earning | boolean | `true` | earning 여부 | +| can_edit_expiry | boolean | `false` | edit expiry 수행 가능 여부 (권한 기반) | +| expired_amount | integer | `0` | 이 적립 lot 을 source 로 소멸(expired)된 금액 합계 (목록 조회 시 eager 집계, 단건 조회 시 0 폴백) | +| expired_amount_formatted | string | `0원` | `expired_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| expiry_state | string | `active` | 적립건 소멸 상태 (active=미소멸, partial_expired=일부 소멸, fully_expired=전액 소멸 — 적립계만 의미) | +| abilities | object | `{"can_manage":true,"can_edit":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "마일리지 내역을 조회했습니다.", + "data": { + "data": [ + { + "number": 1, + "id": 1, + "user_id": 6, + "currency": "KRW", + "type": "admin_earn", + "type_label": "관리자 지급", + "admin_badge_group": "amber", + "user_display_category": "adjust", + "amount": 1000, + "amount_formatted": "1,000원", + "remaining_amount": 0, + "remaining_amount_formatted": "0원", + "balance_after": 1000, + "order_id": null, + "order_option_id": null, + "order_cancel_id": null, + "source_transaction_id": null, + "granted_by": null, + "granted_by_name": null, + "granted_by_uuid": null, + "user_name": "API 문서 샘플 사용자", + "user_uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "order_number": null, + "description": null, + "memo": null, + "expires_at": null, + "expires_at_formatted": null, + "expires_at_date": null, + "expired_at": null, + "expired_at_formatted": null, + "created_at": "2026-07-08T01:44:49+00:00", + "created_at_formatted": "2026-07-08 10:44:49", + "created_at_date": "2026-07-08", + "is_earning": true, + "can_edit_expiry": false, + "expired_amount": 0, + "expired_amount_formatted": "0원", + "expiry_state": "active", + "abilities": { + "can_manage": true, + "can_edit": true + } + } + ], + "abilities": { + "can_manage": true + }, + "currencies": [ + "KRW" + ], + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 1, + "from": 1, + "to": 1, + "has_more_pages": false + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.mileage.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 전체 회원의 마일리지 거래 원장을 검색·필터·페이지네이션으로 조회합니다. `permission:sirsoft-ecommerce.mileage.read` 권한이 필요하며, 회원(member·member_id·email)·주문번호 검색, type(earn·use·expire·adjust)·통화·기간 필터, 금액/생성일 정렬을 지원합니다. `UserMileageService::paginateAdminHistory()`가 조회하고 `MileageTransactionCollection`이 통화 필터 후보(`withCurrencies`)와 함께 직렬화합니다. 각 행은 원장 스냅샷(balance_after, remaining_amount 등)을 그대로 노출하며 원장은 불변입니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/mileage-transactions + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.mileage-transactions.store` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\MileageTransactionController@store` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.mileage.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| user_id | body | uuid | 예 | — | user 식별자 | +| action | body | string | 예 | `earn`, `deduct` | 처리 동작 (earn=수동 적립 → adminEarn, deduct=수동 차감 → adminDeduct FIFO 소진) | +| amount | body | integer | 예 | min 1 | 지급/차감할 마일리지 금액 (양수) | +| currency | body | string | 예 | max 10 | 대상 통화 코드 (해당 통화 잔액에 적용) | +| memo | body | string | 아니오 | max 1000 | 관리자 메모 (거래에 기록) | +| description | body | string | 아니오 | max 500 | 설명 | +| expires_at | body | date | 아니오 | — | 만료일 직접 지정 (지급 시 `use_default_expiry`=false 인 경우 적용) | +| use_default_expiry | body | boolean | 아니오 | — | 지급 시 정책 기본 만료일 적용 여부 (기본 true, false 면 `expires_at` 사용) | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/mileage-transactions HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "user_id": "9f8b2c1a-4d3e-4a2b-8c1d-0e1f2a3b4c5d", + "action": "earn", + "amount": 1, + "currency": "예시값", + "memo": "예시값", + "description": "예시 내용입니다.", + "expires_at": "2026-01-01", + "use_default_expiry": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.mileage.manage`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 특정 회원에게 마일리지를 수동으로 지급하거나 차감합니다. `permission:sirsoft-ecommerce.mileage.manage` 권한이 필요하고 회원은 `user_id`(uuid)로 지정하며, `action`(earn/deduct)에 따라 `UserMileageService::adminEarn()` 또는 `adminDeduct()`를 호출합니다. 지급 시 `use_default_expiry`(기본 true)면 정책 기본 만료일을, 아니면 `expires_at`을 적용하고, 차감은 FIFO로 적립건에서 소진합니다. 잔액 부족 등 도메인 규칙 위반은 `MileageValidationException`으로 422를 반환합니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/mileage-transactions/extend-expiry + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.mileage-transactions.extend-expiry` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\MileageTransactionController@extendExpiry` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.mileage.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| user_id | body | uuid | 예 | — | user 식별자 | +| lot_ids | body | array | 예 | min 1 | lot 식별자 배열 | +| days | body | integer | 예 | min 1, max 3650 | 각 lot 만료일을 연장할 일수 (최대 3650일) | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/mileage-transactions/extend-expiry HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "user_id": "9f8b2c1a-4d3e-4a2b-8c1d-0e1f2a3b4c5d", + "lot_ids": [ + "예시값" + ], + "days": 1 +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.mileage.manage`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 특정 회원의 여러 적립 lot 의 유효기간을 한 번에 연장합니다. `permission:sirsoft-ecommerce.mileage.manage` 권한이 필요하며, 회원(`user_id` uuid)과 대상 적립건 배열(`lot_ids`), 연장 일수(`days`, 최대 3650)를 받아 `UserMileageService::extendLotExpiry()`가 각 lot 의 만료일을 연장하고 실제로 연장된 건수(`extended_count`)를 반환합니다. 이미 소멸/사용된 lot 등 대상 외 건은 서비스가 걸러내므로 반환 건수가 요청한 `lot_ids` 수보다 작을 수 있습니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/mileage-transactions/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.mileage-transactions.update` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\MileageTransactionController@update` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.mileage.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| memo | body | string | 아니오 | max 1000 | 관리자 메모 보정값 (요청에 포함된 경우만 갱신, 빈 값으로 비우기 허용) | +| expires_at | body | date | 아니오 | — | expires 일시 | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/mileage-transactions/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "memo": "예시값", + "expires_at": "2026-01-01" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.mileage.manage`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 기존 적립 거래의 부가 필드(관리자 메모 `memo`, 만료일 `expires_at`)만 보정합니다. 마일리지 원장은 불변이므로 금액·유형 등 핵심 값은 수정할 수 없고, 요청에 실제로 포함된 키만 갱신합니다(`memo` 만 보내면 만료일은 유지). `UserMileageService::updateAdminTransaction()`가 적립계 거래인지·소멸/사용된 lot 이 아닌지 검증하며, 적립계 외 거래 등 규칙 위반은 `MileageValidationException`(422)으로 거부합니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/mileage-transactions/{id}/linked + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.mileage-transactions.linked` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\MileageTransactionController@linked` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.mileage.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/mileage-transactions/1/linked HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "연결 거래를 조회했습니다.", + "data": { + "data": [], + "abilities": { + "can_manage": true + }, + "currencies": [] + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.mileage.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 마일리지 내역 한 행을 펼쳤을 때 그와 연결된 거래들을 조회합니다. `permission:sirsoft-ecommerce.mileage.read` 권한이 필요하며, `UserMileageService::getLinkedTransactions()`가 적립건이면 그 적립을 FIFO 로 소비한 차감 거래들을, 차감/복원건이면 원본 적립·취소 연결 거래를 찾아 `MileageTransactionCollection`으로 반환합니다. 해당 거래가 없으면 404 를 반환하며, 연결 거래가 없으면 빈 목록이 내려옵니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/mileage.md b/modules/_bundled/sirsoft-ecommerce/docs/api/mileage.md new file mode 100644 index 00000000..ec3f163a --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/mileage.md @@ -0,0 +1,244 @@ +# Mileage API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Mileage 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-ecommerce/user/mileage + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.mileage.balance` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\UserMileageController@balance` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/user/mileage HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| mileage | object | `{"enabled":false,"available":12910,"pending":14000,"expir…` | 마일리지 잔액 요약 객체 (enabled 기능 활성화 여부, available 사용 가능, pending 적립 대기, expiring_soon/expiring_date 소멸 예정, total_earned/total_used 누적 적립·사용, by_currency 통화별 잔액) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "마일리지 잔액을 조회했습니다.", + "data": { + "mileage": { + "enabled": false, + "available": 0, + "pending": 0, + "expiring_soon": 0, + "expiring_date": null, + "total_earned": 0, + "total_used": 0, + "by_currency": [] + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** 로그인한 회원이 마이페이지에서 자신의 마일리지 잔액 요약을 조회합니다. `auth:sanctum` 인증만 필요하며, `UserMileageService::getBalance()`가 마일리지 기능 활성화 여부, 사용 가능(available)·적립 대기(pending)·소멸 예정 금액을 계산해 `mileage` 객체로 반환합니다. 마일리지 기능이 꺼져 있으면 `enabled: false` 와 0값이 내려오므로 화면에서 잔액 위젯 노출 여부를 이 플래그로 판단할 수 있습니다. + + +### GET /api/modules/sirsoft-ecommerce/user/mileage/history + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.mileage.history` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\UserMileageController@history` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| category | query | string | 아니오 | `earn`, `use`, `expire`, `adjust` | 분류 필터 (해당 분류의 항목만 조회) | +| currency | query | string | 아니오 | max 10 | 통화 코드 필터 (해당 통화의 마일리지 거래만 조회) | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/user/mileage/history?category=earn¤cy=%EC%98%88%EC%8B%9C%EA%B0%92&per_page=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| transactions | object | `{"data":[{"number":6,"id":527,"user_id":1,"currency":"KRW…` | 마일리지 거래 내역 페이지네이션 객체 (`data` 거래 항목 배열 + 페이지 메타, `MileageTransactionCollection` 으로 직렬화) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "마일리지 내역을 조회했습니다.", + "data": { + "transactions": { + "data": [ + { + "number": 1, + "id": 1, + "user_id": 6, + "currency": "KRW", + "type": "admin_earn", + "type_label": "관리자 지급", + "admin_badge_group": "amber", + "user_display_category": "adjust", + "amount": 1000, + "amount_formatted": "1,000원", + "remaining_amount": 0, + "remaining_amount_formatted": "0원", + "balance_after": 1000, + "order_id": null, + "order_option_id": null, + "order_cancel_id": null, + "source_transaction_id": null, + "granted_by": null, + "granted_by_name": [], + "granted_by_uuid": [], + "user_name": [], + "user_uuid": [], + "order_number": [], + "description": null, + "memo": null, + "expires_at": null, + "expires_at_formatted": null, + "expires_at_date": null, + "expired_at": null, + "expired_at_formatted": null, + "created_at": "2026-07-08T01:44:49+00:00", + "created_at_formatted": "2026-07-08 10:44:49", + "created_at_date": "2026-07-08", + "is_earning": true, + "can_edit_expiry": false, + "expired_amount": 0, + "expired_amount_formatted": "0원", + "expiry_state": "active", + "abilities": { + "can_manage": true, + "can_edit": true + } + } + ], + "abilities": { + "can_manage": true + }, + "currencies": [], + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 1, + "from": 1, + "to": 1, + "has_more_pages": false + } + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 로그인한 회원이 마이페이지에서 자신의 마일리지 적립/사용 내역을 페이지네이션으로 조회합니다. `category`(earn·use·expire·adjust) 4분류 필터와 `currency`·`per_page`(최대 100)를 지원하며, `UserMileageService::paginateUserHistory()`가 필터를 적용해 조회한 뒤 `MileageTransactionCollection`으로 직렬화해 `transactions` 에 담습니다. `category` 는 원장의 원시 type 이 아니라 사용자 표시용 4분류로 매핑된 값입니다. + + +### GET /api/modules/sirsoft-ecommerce/user/mileage/max-usable + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.mileage.max-usable` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\UserMileageController@maxUsable` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| order_amount | query | integer | 예 | min 0 | 사용 가능 상한 계산 기준 주문금액 (마일리지 사용액은 이 금액을 넘을 수 없음) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.mileage.max_usable_validation_rules`). + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/user/mileage/max-usable?order_amount=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 체크아웃 화면에서 특정 주문금액(`order_amount`)에 실제로 사용 가능한 최대 마일리지를 계산해 반환합니다. `auth:sanctum` 인증이 필요하고 `order_amount` 는 필수이며, `UserMileageService::getMaxUsable()`가 보유 잔액·최소 사용 정책·주문금액 상한을 종합해 사용 가능 상한을 산출하고 현재 잔액(available)도 함께 내려줍니다. 확장은 `sirsoft-ecommerce.mileage.max_usable_validation_rules` 필터로 검증 파라미터를 추가할 수 있습니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/options.md b/modules/_bundled/sirsoft-ecommerce/docs/api/options.md new file mode 100644 index 00000000..a9748ed7 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/options.md @@ -0,0 +1,198 @@ +# Options API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Options 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### PATCH /api/modules/sirsoft-ecommerce/admin/options/bulk-price + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.options.bulk-price` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductOptionController@bulkUpdatePrice` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.products.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| product_ids | body | array | 아니오 | — | product 식별자 배열 | +| option_ids | body | array | 아니오 | — | option 식별자 배열 | +| method | body | string | 예 | `increase`, `decrease`, `fixed` | 가격 변경 방식 (increase 인상 / decrease 인하 / fixed 고정가로 설정) | +| value | body | number | 예 | min 0 | 값 | +| unit | body | string | 예 | `won`, `percent` | 변경 단위 (won 금액 기준 / percent 비율 기준) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product_option.bulk_price_validation_rules`). + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/options/bulk-price HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "product_ids": [ + "예시값" + ], + "option_ids": [ + "예시값" + ], + "method": "increase", + "value": 1, + "unit": "won" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 선택한 상품/옵션의 판매가를 일괄 변경합니다. `auth:sanctum` + `sirsoft-ecommerce.products.update` 권한이 필요하며, `ProductOptionService::bulkUpdatePriceByMixedIds()`가 처리합니다. `product_ids`는 해당 상품의 모든 옵션을, `option_ids`는 "productId-optionId" 형식으로 개별 선택된 옵션을 대상으로 합니다. `method`(increase/decrease/fixed)와 `unit`(won/percent) 조합으로 인상·인하·고정가를 적용하며, 검증 실패 시 422, 그 외 오류 시 500을 반환합니다. `sirsoft-ecommerce.product_option.bulk_price_validation_rules` 필터로 확장이 검증 규칙을 추가할 수 있습니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/options/bulk-stock + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.options.bulk-stock` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductOptionController@bulkUpdateStock` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.products.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| product_ids | body | array | 아니오 | — | product 식별자 배열 | +| option_ids | body | array | 아니오 | — | option 식별자 배열 | +| method | body | string | 예 | `increase`, `decrease`, `set` | 재고 변경 방식 (increase 증가 / decrease 감소 / set 특정 수량으로 설정) | +| value | body | integer | 예 | min 0 | 값 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product_option.bulk_stock_validation_rules`). + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/options/bulk-stock HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "product_ids": [ + "예시값" + ], + "option_ids": [ + "예시값" + ], + "method": "increase", + "value": 1 +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 선택한 상품/옵션의 재고를 일괄 변경합니다. `auth:sanctum` + `sirsoft-ecommerce.products.update` 권한이 필요하며, `ProductOptionService::bulkUpdateStockByMixedIds()`가 처리합니다. `product_ids`는 해당 상품의 모든 옵션을, `option_ids`는 "productId-optionId" 형식으로 개별 선택된 옵션을 대상으로 합니다. `method`(increase/decrease/set)와 정수 `value`로 재고를 가감하거나 특정 수량으로 설정하며, 검증 실패 시 422, 그 외 오류 시 500을 반환합니다. `sirsoft-ecommerce.product_option.bulk_stock_validation_rules` 필터로 확장이 검증 규칙을 추가할 수 있습니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/options/bulk-update + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.options.bulk-update` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductOptionController@bulkUpdate` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.products.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| ids | body | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | +| bulk_changes | body | array | 아니오 | — | 옵션 일괄 변경 조건 (`price_adjustment`/`stock_quantity` 각각 method+value, 설정된 필드가 개별 수정보다 우선 적용) | +| items | body | array | 아니오 | — | 처리 대상 항목 배열 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.option.bulk_update_validation_rules`). + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/options/bulk-update HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "ids": [ + "예시값" + ], + "bulk_changes": [ + "예시값" + ], + "items": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 옵션들을 통합 일괄 업데이트합니다. `auth:sanctum` + `sirsoft-ecommerce.products.update` 권한이 필요하며, 상품은 미선택하고 옵션만 선택된 경우에 사용됩니다. `ids`(대상 옵션, 최소 1개)와 함께 `bulk_changes`(일괄 변경 조건)와 `items`(개별 인라인 수정)를 받아 `ProductOptionService::bulkUpdate()`가 처리하며, 일괄 변경 조건이 설정된 필드가 우선 적용되고 나머지는 개별 수정이 반영됩니다. 검증 실패 시 422, 그 외 오류 시 500을 반환하고, `sirsoft-ecommerce.option.bulk_update_validation_rules` 필터로 확장이 검증 규칙을 추가할 수 있습니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md new file mode 100644 index 00000000..bb6a447b --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md @@ -0,0 +1,2339 @@ +# Orders API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Orders 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-ecommerce/admin/orders + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.orders.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\OrderController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| search_field | query | string | 아니오 | `all`, `order_number`, `orderer_name`, `recipient_name`, `orderer_phone`, `recipient_phone`, `product_name`, `sku` | 검색 대상 필드명 (검색어를 적용할 컬럼) | +| search_keyword | query | string | 아니오 | max 200 | 검색 키워드 (부분 일치) | +| date_type | query | string | 아니오 | — | 기간 필터 기준 일자 종류 (ordered_at 주문일 / paid_at 결제일 / confirmed_at 구매확정일 / delivered_at 배송완료일 / cancelled_at 취소일) | +| start_date | query | date | 아니오 | — | 조회 기간 시작일 (이 날짜 이후 데이터) | +| end_date | query | date | 아니오 | — | 조회 기간 종료일 (이 날짜 이전 데이터) | +| order_status | query | array | 아니오 | — | 주문상태 다중 선택 필터 (OrderStatusEnum 값 배열, 해당 상태의 주문만 조회) | +| option_status | query | array | 아니오 | — | 주문옵션 상태 다중 선택 필터 (OrderStatusEnum 값 배열, 해당 옵션 상태를 가진 주문만 조회) | +| shipping_type | query | array | 아니오 | — | 배송유형 다중 선택 필터 (ShippingType 코드 배열) | +| payment_method | query | array | 아니오 | — | 결제수단 다중 선택 필터 (PaymentMethodEnum 값 배열) | +| category_id | query | integer | 아니오 | — | category 식별자 | +| min_amount | query | integer | 아니오 | min 0 | 주문금액 범위 필터 하한 (이 금액 이상 주문만 조회) | +| max_amount | query | integer | 아니오 | min 0 | 주문금액 범위 필터 상한 (이 금액 이하 주문만 조회) | +| country_codes | query | array | 아니오 | — | 배송국가 코드 다중 선택 필터 (ISO 3166-1 alpha-2 2자리 코드 배열) | +| order_device | query | array | 아니오 | — | 주문 디바이스 다중 선택 필터 (DeviceTypeEnum 값 배열 — pc/mobile/app 등) | +| min_shipping_amount | query | integer | 아니오 | min 0 | 배송비 범위 필터 하한 (이 배송비 이상 주문만 조회) | +| max_shipping_amount | query | integer | 아니오 | min 0 | 배송비 범위 필터 상한 (이 배송비 이하 주문만 조회) | +| shipping_policy_id | query | integer | 아니오 | — | shipping policy 식별자 | +| user_id | query | integer | 아니오 | — | user 식별자 | +| orderer_uuid | query | uuid | 아니오 | — | 특정 회원의 주문만 조회하는 주문자 UUID 필터 (회원 검색 연동용) | +| member_type | query | string | 아니오 | `member`, `guest` | 회원 구분 필터 (member 회원 주문 / guest 비회원 주문) | +| sort_by | query | string | 아니오 | `ordered_at`, `paid_at`, `total_amount` | 정렬 기준 필드명 | +| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) | +| per_page | query | integer | 아니오 | min 10, max 100 | 페이지당 항목 수 | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.order.list_validation_rules`, `sirsoft-ecommerce.order.list_validation_messages`). + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/orders?search_field=all&search_keyword=%EC%98%88%EC%8B%9C%EA%B0%92&date_type=%EC%98%88%EC%8B%9C%EA%B0%92&start_date=2026-01-01&end_date=2026-01-01&order_status=%EC%98%88%EC%8B%9C%EA%B0%92&option_status=%EC%98%88%EC%8B%9C%EA%B0%92&shipping_type=%EC%98%88%EC%8B%9C%EA%B0%92&payment_method=%EC%98%88%EC%8B%9C%EA%B0%92&category_id=1&min_amount=1&max_amount=1&country_codes=KR&order_device=%EC%98%88%EC%8B%9C%EA%B0%92&min_shipping_amount=1&max_shipping_amount=1&shipping_policy_id=1&user_id=1&orderer_uuid=9f8b2c1a-4d3e-4a2b-8c1d-0e1f2a3b4c5d&member_type=member&sort_by=ordered_at&sort_order=asc&per_page=1&page=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| number | integer | `128` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| id | integer | `455` | 기본 키 (내부 식별자) | +| order_number | string | `ORD-20260707-000002` | 주문번호 (사용자 노출용 고유 식별 코드) | +| order_status | string | `pending_payment` | 주문상태 (OrderStatusEnum 값 — 결제대기/결제완료/배송중 등) | +| order_status_label | string | `결제대기` | `order_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| order_status_variant | string | `warning` | `order_status` 값의 표시 변형 키 (UI 배지 색상/스타일) | +| base_currency | string | `KRW` | 금액 표기 기준 통화 (모든 *_formatted 필드의 통화, 주문 시점 base_currency 고정) | +| payment_currency | string | `KRW` | 결제 통화 (유저가 선택·결제한 통화, base_currency 와 다르면 병기 표시) | +| is_cross_currency | boolean | `false` | cross currency 여부 | +| is_partially_cancelled | boolean | `false` | partially cancelled 여부 | +| total_amount | integer | `193397` | 최종 주문금액 (상품합계 − 할인 + 배송비) | +| total_amount_formatted | string | `193,397원` | `total_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_shipping_amount | integer | `0` | 총 배송비 | +| total_shipping_amount_formatted | string | `0원` | `total_shipping_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_paid_amount | integer | `0` | 총 실제 결제금액 (PG 결제된 금액) | +| total_paid_amount_formatted | string | `0원` | `total_paid_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_unpaid_amount | integer | `193397` | 미결제 잔액 (최종 주문금액 − 실제 결제금액) | +| total_unpaid_amount_formatted | string | `193,397원` | `total_unpaid_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_cancelled_amount | integer | `0` | 총 취소금액 | +| total_refunded_amount | integer | `0` | 총 환불금액 | +| total_points_used_amount | integer | `0` | 총 포인트(마일리지) 사용액 | +| total_points_used_amount_formatted | string | `0원` | `total_points_used_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_earned_points_amount | integer | `1934` | 총 적립 예정 포인트 | +| total_earned_points_amount_formatted | string | `1,934원` | `total_earned_points_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| ordered_at | string | `2026-07-07T05:47:30+00:00` | ordered 일시 | +| ordered_at_formatted | string | `2026-07-07 14:47:30` | `ordered_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| order_device | string | `pc` | 주문 디바이스 (DeviceTypeEnum 값 — pc/mobile/app) | +| order_device_label | string | `PC` | `order_device` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| is_first_order | boolean | `true` | first order 여부 | +| user | object | `{"uuid":"a23317ba-05bc-4de3-8272-2b73c091a266","name":"남상준"}` | 회원 주문의 주문자 요약 (uuid·name, 비회원 주문이면 미포함) | +| first_option | object | `{"product_name":"quisquam et quia","product_option_name":…` | 대표 표시용 첫 번째 주문 옵션 요약 (상품명·옵션명·수량·썸네일·추가옵션 요약) | +| options_count | integer | `1` | options 개수 (집계) | +| address | object | `{"orderer_name":"관리자","recipient_name":"구태호","recipient_c…` | 배송지 요약 (주문자명·수령인명·배송국가 코드/현지화명) | +| payment | object | `{"payment_method":"dbank","payment_method_label":"무통장입금"}` | 결제 요약 (결제수단 값·현지화 라벨) | +| shipping | object | `{"shipping_type":null,"shipping_type_label":null,"shippin…` | 배송 요약 (배송유형·배송방법 라벨·택배사명·송장번호, 첫 번째 배송 기준) | +| is_owner | boolean | `false` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_update":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "주문 정보를 조회했습니다.", + "data": { + "data": [ + { + "number": 2, + "id": 5, + "order_number": "ORD-20260708-000002", + "order_status": "pending_payment", + "order_status_label": "결제대기", + "order_status_variant": "warning", + "base_currency": "KRW", + "payment_currency": "KRW", + "is_cross_currency": false, + "is_partially_cancelled": false, + "total_amount": 163969, + "total_amount_formatted": "163,969원", + "total_shipping_amount": 2500, + "total_shipping_amount_formatted": "2,500원", + "total_paid_amount": 0, + "total_paid_amount_formatted": "0원", + "total_unpaid_amount": 163969, + "total_unpaid_amount_formatted": "163,969원", + "total_cancelled_amount": 0, + "total_refunded_amount": 0, + "total_points_used_amount": 0, + "total_points_used_amount_formatted": "0원", + "total_earned_points_amount": 1640, + "total_earned_points_amount_formatted": "1,640원", + "ordered_at": "2026-07-08T01:44:49+00:00", + "ordered_at_formatted": "2026-07-08 10:44:49", + "order_device": "pc", + "order_device_label": "PC", + "is_first_order": true, + "user": { + "uuid": "a234c3ea-a4f9-496d-a5e7-b44f0d53fc2f", + "name": "선지영" + }, + "first_option": { + "product_name": "assumenda doloremque ipsam", + "product_option_name": "ea", + "product_code": null, + "quantity": 2, + "thumbnail_url": null, + "additional_options_summary": null + }, + "options_count": 1, + "address": null, + "payment": null, + "shipping": { + "shipping_type": null, + "shipping_type_label": null, + "shipping_method_label": null, + "carrier_name": null, + "tracking_number": null + }, + "is_owner": false, + "abilities": { + "can_read": true, + "can_update": true + } + }, + { + "number": 1, + "id": 4, + "order_number": "APIDOC-20260708-000001", + "order_status": "pending_payment", + "order_status_label": "결제대기", + "order_status_variant": "warning", + "base_currency": "KRW", + "payment_currency": "KRW", + "is_cross_currency": false, + "is_partially_cancelled": false, + "total_amount": 327327, + "total_amount_formatted": "327,327원", + "total_shipping_amount": 3000, + "total_shipping_amount_formatted": "3,000원", + "total_paid_amount": 0, + "total_paid_amount_formatted": "0원", + "total_unpaid_amount": 327327, + "total_unpaid_amount_formatted": "327,327원", + "total_cancelled_amount": 0, + "total_refunded_amount": 0, + "total_points_used_amount": 0, + "total_points_used_amount_formatted": "0원", + "total_earned_points_amount": 3273, + "total_earned_points_amount_formatted": "3,273원", + "ordered_at": "2026-07-08T01:44:49+00:00", + "ordered_at_formatted": "2026-07-08 10:44:49", + "order_device": "pc", + "order_device_label": "PC", + "is_first_order": false, + "user": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자" + }, + "first_option": { + "product_name": "", + "product_option_name": "", + "product_code": null, + "quantity": null, + "thumbnail_url": null, + "additional_options_summary": null + }, + "options_count": 0, + "address": null, + "payment": null, + "shipping": { + "shipping_type": null, + "shipping_type_label": null, + "shipping_method_label": null, + "carrier_name": null, + "tracking_number": null + }, + "is_owner": true, + "abilities": { + "can_read": true, + "can_update": true + } + } + ], + "abilities": { + "can_update": true + }, + "statistics": { + "total": 2, + "status_counts": { + "pending_payment": 2 + }, + "today_count": 2, + "today_revenue": "0.00", + "monthly_revenue": "0.00" + }, + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 2, + "from": 1, + "to": 2, + "has_more_pages": false + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 주문을 다양한 필터·검색·정렬 조건으로 페이지네이션 조회합니다. `auth:sanctum` + `sirsoft-ecommerce.orders.read` 권한이 필요하며, `Admin\OrderController@index`가 `OrderService::getList()`로 목록을, `getStatistics()`로 상태별 통계를 함께 가져와 `OrderCollection`에 담아 반환합니다. 검색 필드(주문번호/주문자명/상품명/SKU 등)·기간·주문상태·결제수단·금액대·회원/비회원 구분 등 폭넓은 필터를 지원합니다. 관리자 주문 목록 화면의 기본 데이터 소스입니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/orders/bulk + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.orders.bulk` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\OrderController@bulkUpdate` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| ids | body | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | +| order_status | body | string | 아니오 | — | 일괄 전환할 주문상태 (OrderStatusEnum 값, pending_order 제외 · 전이 규칙 검증) | +| carrier_id | body | integer | 아니오 | — | carrier 식별자 | +| tracking_number | body | string | 아니오 | max 50 | 송장(운송장)번호 (배송 관련 상태로 전환 시 carrier_id 와 함께 필수) | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/orders/bulk HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "ids": [ + "예시값" + ], + "order_status": "예시값", + "carrier_id": 1, + "tracking_number": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 여러 주문(`ids`)의 주문상태나 배송 정보(택배사·송장번호)를 일괄 변경합니다. `auth:sanctum` + `sirsoft-ecommerce.orders.update` 권한이 필요하며, `Admin\OrderController@bulkUpdate`가 `OrderService::bulkUpdate()`로 처리합니다. 주문 목록에서 여러 건을 선택해 "배송 처리"·"상태 일괄 변경" 등을 수행할 때 사용합니다. + + +### DELETE /api/modules/sirsoft-ecommerce/admin/orders/{order} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.orders.destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\OrderController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| order | path | string | 예 | — | 정렬 방향 (asc 오름차순 / desc 내림차순) | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/admin/orders/4 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.delete`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 특정 주문(`order`)을 소프트 삭제합니다. `auth:sanctum` + `sirsoft-ecommerce.orders.delete` 권한이 필요하며, `Admin\OrderController@destroy`가 `OrderService::delete()`를 호출합니다. 물리 삭제가 아닌 소프트 삭제(deleted_at 표시)이므로 데이터는 보존되며, 주문 목록/상세에서 제외됩니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/orders/{order} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.orders.show` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\OrderController@show` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| order | path | string | 예 | — | 정렬 방향 (asc 오름차순 / desc 내림차순) | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/orders/4 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `322` | 기본 키 (내부 식별자) | +| order_number | string | `20260617-0207256237` | 주문번호 | +| base_currency | string | `KRW` | 금액 표기 기준 통화 (모든 *_formatted 필드의 통화, 주문 시점 base_currency 고정) | +| payment_currency | string | `KRW` | 결제 통화 (유저가 선택·결제한 통화, base_currency 와 다르면 병기 표시) | +| is_cross_currency | boolean | `false` | cross currency 여부 | +| order_status | string | `payment_complete` | 주문상태 (OrderStatusEnum) | +| order_status_label | string | `결제완료` | `order_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| order_status_variant | string | `info` | `order_status` 값의 표시 변형 키 (UI 배지 색상/스타일) | +| is_partially_cancelled | boolean | `false` | partially cancelled 여부 | +| order_device | string | `pc` | 주문 디바이스 (pc/mobile/app) | +| order_device_label | string | `PC` | `order_device` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| is_first_order | boolean | `true` | first order 여부 | +| subtotal_amount | integer | `140000` | 상품 합계 (할인 전, 상품가×수량 합계) | +| subtotal_amount_formatted | string | `140,000원` | `subtotal_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_discount_amount | integer | `25000` | 총 할인금액 (모든 할인 합계) | +| total_discount_amount_formatted | string | `25,000원` | `total_discount_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_shipping_amount | integer | `0` | 총 배송비 | +| total_shipping_amount_formatted | string | `0원` | `total_shipping_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_amount | integer | `115000` | 최종 주문금액 (subtotal - discount + shipping) | +| total_amount_formatted | string | `115,000원` | `total_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_paid_amount | integer | `115000` | 총 실제 결제금액 (PG 결제액) | +| total_paid_amount_formatted | string | `115,000원` | `total_paid_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_due_amount | integer | `0` | 총 결제예정금액 (무통장 등) | +| total_due_amount_formatted | string | `0원` | `total_due_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| depositor_name | null | `null` | 무통장 입금자명 (입금확인 모달 기본값, payment 관계 로드 시에만 노출) | +| total_cancelled_amount | integer | `0` | 총 취소금액 | +| total_cancelled_amount_formatted | string | `0원` | `total_cancelled_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_refunded_amount | integer | `0` | 총 환불금액 | +| total_refunded_amount_formatted | string | `0원` | `total_refunded_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_refunded_points_amount | integer | `0` | 총 환불 포인트 | +| total_refunded_points_amount_formatted | string | `0원` | `total_refunded_points_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_product_coupon_discount_amount | integer | `0` | 상품 쿠폰 할인 합계 | +| total_product_coupon_discount_amount_formatted | string | `0원` | `total_product_coupon_discount_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_order_coupon_discount_amount | integer | `25000` | 주문 쿠폰 할인 합계 | +| total_order_coupon_discount_amount_formatted | string | `25,000원` | `total_order_coupon_discount_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_coupon_discount_amount | integer | `25000` | 총 쿠폰 할인금액 | +| total_coupon_discount_amount_formatted | string | `25,000원` | `total_coupon_discount_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_code_discount_amount | integer | `0` | 총 할인코드 할인금액 | +| total_code_discount_amount_formatted | string | `0원` | `total_code_discount_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_points_used_amount | integer | `0` | 총 포인트 사용액 | +| total_points_used_amount_formatted | string | `0원` | `total_points_used_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_deposit_used_amount | integer | `0` | 총 예치금 사용액 | +| total_deposit_used_amount_formatted | string | `0원` | `total_deposit_used_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_earned_points_amount | integer | `1150` | 총 적립 예정 포인트 | +| total_earned_points_amount_formatted | string | `1,150원` | `total_earned_points_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| mc_subtotal_amount | object | `{"KRW":{"amount":140000,"formatted":"140,000원"},"USD":{"a…` | 상품합계 다중 통화 | +| mc_total_discount_amount | object | `{"KRW":{"amount":25000,"formatted":"25,000원"},"USD":{"amo…` | 총 할인 다중 통화 | +| mc_total_shipping_amount | object | `{"KRW":{"amount":0,"formatted":"0원"},"USD":{"amount":0,"f…` | 총 배송비 다중 통화 | +| mc_total_amount | object | `{"KRW":{"amount":115000,"formatted":"115,000원"},"USD":{"a…` | 최종금액 다중 통화 (payment_amount) | +| mc_total_product_coupon_discount_amount | object | `{"KRW":{"amount":0,"formatted":"0원"},"USD":{"amount":0,"f…` | 상품 쿠폰 할인 다중 통화 | +| mc_total_order_coupon_discount_amount | object | `{"KRW":{"amount":25000,"formatted":"25,000원"},"USD":{"amo…` | 주문 쿠폰 할인 다중 통화 | +| mc_total_coupon_discount_amount | object | `{"KRW":{"amount":25000,"formatted":"25,000원"},"USD":{"amo…` | 쿠폰 할인 합계 다중 통화 | +| mc_total_code_discount_amount | object | `{"KRW":{"amount":0,"formatted":"0원"},"USD":{"amount":0,"f…` | 할인코드 할인 다중 통화 | +| mc_total_points_used_amount | object | `{"KRW":{"amount":0,"formatted":"0원"},"USD":{"amount":0,"f…` | 포인트 사용 다중 통화 | +| mc_total_deposit_used_amount | object | `{"KRW":{"amount":0,"formatted":"0원"},"USD":{"amount":0,"f…` | 예치금 사용 다중 통화 | +| item_count | integer | `2` | item 개수 (집계) | +| total_quantity | integer | `5` | 주문 옵션 수량 합계 (options 로드 시) | +| total_list_price | integer | `177000` | 정가 합계 (옵션 스냅샷 정가 × 수량 합계) | +| total_list_price_formatted | string | `177,000원` | `total_list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| ordered_at | string | `2026-06-15T02:07:25+00:00` | ordered 일시 | +| ordered_at_formatted | string | `2026-06-15 11:07:25` | `ordered_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| paid_at | string | `2026-06-17T02:07:25+00:00` | paid 일시 | +| paid_at_formatted | string | `2026-06-17 11:07:25` | `paid_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| confirmed_at | null | `null` | confirmed 일시 | +| confirmed_at_formatted | null | `null` | `confirmed_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| cancelled_at | null | `null` | cancelled 일시 | +| cancelled_at_formatted | null | `null` | `cancelled_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| delivered_at | null | `null` | delivered 일시 | +| total_tax_amount | integer | `10455` | 총 과세금액 | +| total_tax_amount_formatted | string | `10,455원` | `total_tax_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_vat_amount | integer | `0` | 총 부가세금액 | +| total_vat_amount_formatted | string | `0원` | `total_vat_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_taxable_supply_amount | integer | `10455` | 과세 공급가액 (총 과세금액 − 부가세, 영수증 과세금액 표시 SSoT) | +| total_taxable_supply_amount_formatted | string | `10,455원` | `total_taxable_supply_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_tax_free_amount | integer | `0` | 총 면세금액 | +| total_tax_free_amount_formatted | string | `0원` | `total_tax_free_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| user | object | `{"uuid":"a20683c6-14f8-4061-baa9-157c45e9de5a","name":"Jo…` | 회원 주문의 주문자 정보 (uuid·name·email, user 관계 로드 시 · 비회원이면 미포함) | +| user_id | string | `a20683c6-14f8-4061-baa9-157c45e9de5a` | user 식별자 (연관 리소스 참조) | +| user_login_id | null | `null` | 회원 로그인 아이디 (login_id, 비회원 주문이면 null) | +| orderer_name | string | `설창용` | 주문자 이름 (배송지에서 플래튼) | +| orderer_phone | string | `010-0650-9192` | 주문자 휴대전화 (배송지에서 플래튼) | +| orderer_tel | null | `null` | 주문자 일반전화 (배송지에서 플래튼, 미입력 시 null) | +| orderer_email | string | `moonchang.shim@gmail.com` | 주문자 이메일 (배송지에서 플래튼, 비회원 알림 수신 통로) | +| recipient_name | string | `길준` | 수령인 이름 (배송지에서 플래튼) | +| recipient_phone | string | `010-1612-1979` | 수령인 휴대전화 (배송지에서 플래튼) | +| recipient_tel | null | `null` | 수령인 일반전화 (배송지에서 플래튼, 미입력 시 null) | +| recipient_zipcode | string | `19882` | 수령인 우편번호 (배송지에서 플래튼) | +| recipient_address | string | `부산광역시 도봉구 역삼로 201` | 수령인 기본 주소 (배송지에서 플래튼) | +| recipient_detail_address | null | `null` | 수령인 상세 주소 (배송지에서 플래튼, 미입력 시 null) | +| delivery_memo | null | `null` | 배송 메모 (배송지에서 플래튼, 미입력 시 null) | +| delivery_memo_label | null | `null` | `delivery_memo` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| options | array | `[{"id":612,"option_status":"payment_complete","option_sta…` | 주문 옵션(품목) 목록 (OrderOptionResource — 상품·옵션·수량·옵션상태·금액) | +| shipping_address | object | `{"id":320,"address_type":"shipping","orderer_name":"설창용",…` | 배송지 상세 (OrderAddressResource — 주문자/수령인/국내·해외 주소) | +| billing_address | null | `null` | 청구지 상세 (OrderAddressResource, 미분리 시 null) | +| payment | object | `{"id":284,"payment_status":"paid","payment_status_label":…` | 대표 결제 정보 (OrderPaymentResource — 결제수단·결제상태·금액) | +| payments | array | `[{"id":284,"payment_status":"paid","payment_status_label"…` | 결제 이력 목록 (OrderPaymentResource 배열 — 다회 결제/부분결제 포함) | +| shippings | array | `[]` | 배송 이력 목록 (OrderShippingResource 배열 — 배송유형·택배사·송장번호) | +| cancels | array | `[]` | 취소 이력 목록 (OrderCancelResource 배열 — 취소 사유·상세·취소일시, 최근순) | +| promotions_applied_snapshot | object | `{"coupon_issue_ids":[7330],"item_coupons":[],"discount_co…` | 적용된 프로모션 스냅샷 (재계산용) | +| shipping_policy_applied_snapshot | null | `null` | 적용된 배송정책 스냅샷 (재계산용) | +| admin_memo | null | `null` | 관리자 메모 (내부 관리용) | +| customer_memo | null | `null` | 고객 메모 (주문 시 고객이 남긴 메모) | +| created_at | string | `2026-06-17T02:07:25+00:00` | 생성 일시 | +| updated_at | string | `2026-06-17T02:07:25+00:00` | 최종 수정 일시 | +| is_owner | boolean | `false` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_update":true,"can_cancel":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "주문 정보를 조회했습니다.", + "data": { + "id": 4, + "order_number": "APIDOC-20260708-000001", + "base_currency": "KRW", + "payment_currency": "KRW", + "is_cross_currency": false, + "order_status": "pending_payment", + "order_status_label": "결제대기", + "order_status_variant": "warning", + "is_partially_cancelled": false, + "order_device": "pc", + "order_device_label": "PC", + "is_first_order": false, + "subtotal_amount": 324327, + "subtotal_amount_formatted": "324,327원", + "total_discount_amount": 0, + "total_discount_amount_formatted": "0원", + "total_shipping_amount": 3000, + "total_shipping_amount_formatted": "3,000원", + "total_amount": 327327, + "total_amount_formatted": "327,327원", + "total_paid_amount": 0, + "total_paid_amount_formatted": "0원", + "total_due_amount": 327327, + "total_due_amount_formatted": "327,327원", + "depositor_name": null, + "total_cancelled_amount": 0, + "total_cancelled_amount_formatted": "0원", + "total_refunded_amount": 0, + "total_refunded_amount_formatted": "0원", + "total_refunded_points_amount": 0, + "total_refunded_points_amount_formatted": "0원", + "total_product_coupon_discount_amount": 0, + "total_product_coupon_discount_amount_formatted": "0원", + "total_order_coupon_discount_amount": 0, + "total_order_coupon_discount_amount_formatted": "0원", + "total_coupon_discount_amount": 0, + "total_coupon_discount_amount_formatted": "0원", + "total_code_discount_amount": 0, + "total_code_discount_amount_formatted": "0원", + "total_points_used_amount": 0, + "total_points_used_amount_formatted": "0원", + "total_deposit_used_amount": 0, + "total_deposit_used_amount_formatted": "0원", + "total_earned_points_amount": 3273, + "total_earned_points_amount_formatted": "3,273원", + "mc_subtotal_amount": [], + "mc_total_discount_amount": [], + "mc_total_shipping_amount": [], + "mc_total_amount": [], + "mc_total_product_coupon_discount_amount": [], + "mc_total_order_coupon_discount_amount": [], + "mc_total_coupon_discount_amount": [], + "mc_total_code_discount_amount": [], + "mc_total_points_used_amount": [], + "mc_total_deposit_used_amount": [], + "item_count": 4, + "total_quantity": 0, + "total_list_price": 0, + "total_list_price_formatted": "0원", + "ordered_at": "2026-07-08T01:44:49+00:00", + "ordered_at_formatted": "2026-07-08 10:44:49", + "paid_at": null, + "paid_at_formatted": null, + "confirmed_at": null, + "confirmed_at_formatted": null, + "cancelled_at": null, + "cancelled_at_formatted": null, + "delivered_at": null, + "total_tax_amount": 29757, + "total_tax_amount_formatted": "29,757원", + "total_vat_amount": 0, + "total_vat_amount_formatted": "0원", + "total_taxable_supply_amount": 29757, + "total_taxable_supply_amount_formatted": "29,757원", + "total_tax_free_amount": 0, + "total_tax_free_amount_formatted": "0원", + "user": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com" + }, + "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "user_login_id": null, + "orderer_name": null, + "orderer_phone": null, + "orderer_tel": null, + "orderer_email": null, + "recipient_name": null, + "recipient_phone": null, + "recipient_tel": null, + "recipient_zipcode": null, + "recipient_address": null, + "recipient_detail_address": null, + "delivery_memo": null, + "delivery_memo_label": null, + "options": [], + "shipping_address": null, + "billing_address": null, + "payment": null, + "payments": [], + "shippings": [], + "cancels": [], + "promotions_applied_snapshot": null, + "shipping_policy_applied_snapshot": null, + "admin_memo": null, + "customer_memo": null, + "created_at": "2026-07-08T01:44:49+00:00", + "updated_at": "2026-07-08T01:44:49+00:00", + "is_owner": true, + "abilities": { + "can_read": true, + "can_update": true, + "can_cancel": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 특정 주문(`order`)의 전체 상세를 조회합니다. `auth:sanctum` + `sirsoft-ecommerce.orders.read` 권한이 필요하며, `Admin\OrderController@show`가 `OrderService::getDetail()`로 옵션·배송·결제·취소 이력·금액 내역(과세/면세/다중통화 포함)까지 풀로드해 `OrderResource`로 반환합니다. 관리자 주문 상세 화면의 데이터 소스이며, 주문이 없으면 404를 반환합니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/orders/{order} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.orders.update` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\OrderController@update` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| order | path | string | 예 | — | 정렬 방향 (asc 오름차순 / desc 내림차순) | +| order_status | body | string | 아니오 | — | 변경할 주문상태 (OrderStatusEnum 값, 현재 상태에서 전이 가능한 값만 허용) | +| admin_memo | body | string | 아니오 | max 2000 | 관리자 메모 (내부 관리용, 고객 비노출) | +| recipient_name | body | string | 예 | max 50 | 수령인 이름 | +| recipient_phone | body | string | 아니오 | max 20 | 수령인 연락처 | +| recipient_tel | body | string | 아니오 | max 20 | 수령인 일반전화 (recipient_phone 없을 때 필수) | +| recipient_zipcode | body | string | 아니오 | max 10 | 수령인 우편번호 (국내 주소, 해외 주소 없을 때 필수) | +| recipient_address | body | string | 아니오 | max 255 | 수령인 기본 주소 (국내 주소, 해외 주소 없을 때 필수) | +| recipient_detail_address | body | string | 아니오 | max 255 | 수령인 상세 주소 (recipient_address 입력 시 필수) | +| address_line_1 | body | string | 아니오 | max 255 | 주소 1행 (기본 주소) | +| address_line_2 | body | string | 아니오 | max 255 | 주소 2행 (상세 주소) | +| intl_city | body | string | 아니오 | max 100 | 도시 (국제 주소) | +| intl_state | body | string | 아니오 | max 100 | 주/도 (국제 주소) | +| intl_postal_code | body | string | 아니오 | max 20 | 우편번호 (국제 주소) | +| delivery_memo | body | string | 아니오 | max 500 | 배송 메모 (배송 시 요청사항) | +| recipient_country_code | body | string | 아니오 | — | 수령인 배송국가 코드 (ISO 3166-1 alpha-2 2자리) | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/orders/4 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "order_status": "예시값", + "admin_memo": "예시값", + "recipient_name": "예시 이름", + "recipient_phone": "010-1234-5678", + "recipient_tel": "예시값", + "recipient_zipcode": "06234", + "recipient_address": "서울특별시 강남구 테헤란로 1", + "recipient_detail_address": "서울특별시 강남구 테헤란로 1", + "address_line_1": "서울특별시 강남구 테헤란로 1", + "address_line_2": "서울특별시 강남구 테헤란로 1", + "intl_city": "예시값", + "intl_state": "예시값", + "intl_postal_code": "06234", + "delivery_memo": "예시값", + "recipient_country_code": "KR" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 특정 주문(`order`)의 주문상태·관리자 메모·수취인 배송지(국내/해외 주소 포함)를 수정합니다. `auth:sanctum` + `sirsoft-ecommerce.orders.update` 권한이 필요하며, `Admin\OrderController@update`가 `OrderService::update()`로 처리한 뒤 수정된 주문을 `OrderResource`로 반환합니다. 관리자 주문 상세에서 배송지 정정·메모 기록·상태 변경 등에 사용합니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/orders/{order}/cancel + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.orders.cancel` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\OrderController@cancelOrder` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| order | path | string | 예 | — | 정렬 방향 (asc 오름차순 / desc 내림차순) | +| type | body | string | 예 | `full`, `partial` | 취소 유형 (full 전체취소 / partial 부분취소 — partial 이면 items 필수) | +| reason | body | string | 예 | — | 취소 사유 코드 (ClaimReason 의 refund·활성 코드) | +| reason_detail | body | string | 아니오 | max 500 | 취소 사유 상세 (관리자 입력 자유 텍스트) | +| items | body | array | 아니오 | min 1 | 처리 대상 항목 배열 | +| cancel_pg | body | boolean | 아니오 | — | PG 결제 취소 동반 여부 (미지정 시 기본 true — 실제 PG 취소 수행) | +| refund_priority | body | string | 아니오 | `pg_first`, `points_first` | 환불 배분 우선순위 (pg_first PG 우선 / points_first 포인트 우선, 기본 pg_first) | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/orders/4/cancel HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "type": "예시값", + "reason": "예시값", + "reason_detail": "예시값", + "items": [ + "예시값" + ], + "cancel_pg": true, + "refund_priority": "pg_first" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 특정 주문(`order`)을 전체취소 또는 부분취소합니다. `auth:sanctum` + `sirsoft-ecommerce.orders.update` 권한이 필요하며, `Admin\OrderController@cancelOrder`가 `items` 유무에 따라 `OrderCancellationService`의 `cancelOrder()`(전체) 또는 `cancelOrderOptions()`(부분)를 호출합니다. 취소자(`cancelledBy`)로 관리자 ID가 기록되고, `cancel_pg`로 PG 결제 취소 동반 여부를, `refund_priority`로 PG/포인트 환불 우선순위를 지정합니다. 취소 후 갱신된 주문을 `OrderResource`로 반환하며, 취소 불가 상태 등 실패 시 422를 반환합니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/orders/{order}/confirm-deposit + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.orders.confirm-deposit` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\OrderController@confirmDeposit` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| order | path | string | 예 | — | 정렬 방향 (asc 오름차순 / desc 내림차순) | +| amount | body | number | 예 | min 0 | 확인된 입금액 (결제예정금액과 정확히 일치해야 함, 불일치 시 422) | +| depositor_name | body | string | 아니오 | max 100 | depositor 이름 (식별자) | +| mark_order_complete | body | boolean | 아니오 | — | 입금확인과 동시에 주문완료 처리 여부 (미지정 시 기본 false — 결제완료 전이만) | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/orders/4/confirm-deposit HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "amount": 1, + "depositor_name": "예시 이름", + "mark_order_complete": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 무통장(dbank) 미결제 주문(`order`)의 입금을 확인해 결제완료로 전이합니다. `auth:sanctum` + `sirsoft-ecommerce.orders.update` 권한이 필요하며, `Admin\OrderController@confirmDeposit`가 `OrderProcessingService::confirmManualDeposit()`으로 입금자명·입금액을 기록하고 결제완료 처리합니다. 입금액(`amount`)이 결제예정금액과 정확히 일치하지 않으면 422(deposit_amount_mismatch)를 반환하며, `mark_order_complete`로 결제완료와 동시에 주문완료 처리 여부를 지정할 수 있습니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/orders/{order}/estimate-refund + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.orders.estimate-refund` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\OrderController@estimateRefund` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| order | path | string | 예 | — | 정렬 방향 (asc 오름차순 / desc 내림차순) | +| items | body | array | 예 | min 1 | 처리 대상 항목 배열 | +| refund_priority | body | string | 아니오 | `pg_first`, `points_first` | 환불 배분 우선순위 (pg_first PG 우선 / points_first 포인트 우선, 기본 pg_first) | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/orders/4/estimate-refund HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "items": [ + "예시값" + ], + "refund_priority": "pg_first" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 특정 주문(`order`)의 선택 옵션(`items`) 취소 시 예상 환불 금액을 실제 취소 없이 미리 계산합니다. `auth:sanctum` + `sirsoft-ecommerce.orders.update` 권한이 필요하며, `Admin\OrderController@estimateRefund`가 `OrderCancellationService::previewRefund()`로 환불 예상값을 반환합니다. `refund_priority`에 따라 PG 우선/포인트 우선 환불 배분 결과가 달라집니다. 취소 화면에서 "환불 예정 금액"을 관리자에게 미리 보여주는 용도입니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/orders/{order}/logs + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.orders.logs` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\OrderController@logs` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| order | path | string | 예 | — | 정렬 방향 (asc 오름차순 / desc 내림차순) | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | +| sort_order | query | string | 아니오 | — | 정렬 방향 (asc 오름차순 / desc 내림차순) | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/orders/4/logs?per_page=1&sort_order=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "주문 처리 이력을 조회했습니다.", + "data": { + "data": [], + "links": { + "first": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/orders/4/logs?page=1", + "last": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/orders/4/logs?page=1", + "prev": null, + "next": null + }, + "meta": { + "current_page": 1, + "from": null, + "last_page": 1, + "links": [ + { + "url": null, + "label": "pagination.previous", + "page": null, + "active": false + }, + { + "url": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/orders/4/logs?page=1", + "label": "1", + "page": 1, + "active": true + }, + { + "url": null, + "label": "pagination.next", + "page": null, + "active": false + } + ], + "path": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/orders/4/logs", + "per_page": 25, + "to": null, + "total": 0 + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 특정 주문(`order`)의 활동 로그(주문·주문옵션·배송지 변경 이력 합산)를 페이지네이션 조회합니다. `auth:sanctum` + `sirsoft-ecommerce.orders.read` 권한이 필요하며, `Admin\OrderController@logs`가 `OrderService::getActivityLogs()`로 조회해 `ActivityLogResource`로 반환합니다. `sort_order`로 시간 정렬 방향을 지정할 수 있습니다. 관리자 주문 상세의 "처리 이력" 탭에서 누가 언제 무엇을 변경했는지 추적하는 용도입니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/orders/{order}/options/bulk-status + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.orders.options.bulk-status` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\OrderController@bulkChangeOptionStatus` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| order | path | string | 예 | — | 정렬 방향 (asc 오름차순 / desc 내림차순) | +| items | body | array | 예 | min 1 | 처리 대상 항목 배열 | +| status | body | string | 예 | — | 일괄 전환할 옵션 상태 (OrderStatusEnum 값, 옵션별 전이 규칙 검증) | +| carrier_id | body | integer | 아니오 | — | carrier 식별자 | +| tracking_number | body | string | 아니오 | max 50 | 송장(운송장)번호 (배송 관련 상태로 전환 시 carrier_id 와 함께 필수) | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/orders/4/options/bulk-status HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "items": [ + "예시값" + ], + "status": "예시값", + "carrier_id": 1, + "tracking_number": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 특정 주문(`order`)의 여러 주문 옵션 상태를 수량 분할까지 지원해 일괄 변경합니다. `auth:sanctum` + `sirsoft-ecommerce.orders.update` 권한이 필요하며, `Admin\OrderController@bulkChangeOptionStatus`가 `status`를 `OrderStatusEnum`으로 변환한 뒤 `OrderOptionService::bulkChangeStatusWithQuantity()`로 처리합니다. 배송중으로 전환 시 `carrier_id`·`tracking_number`(택배사·송장번호)를 함께 넘길 수 있습니다. 한 옵션의 일부 수량만 상태 전환(부분 배송 등)하는 시나리오를 지원합니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/orders/{order}/reset-guest-lookup-password + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.orders.reset-guest-lookup-password` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\OrderController@resetGuestLookupPassword` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| order | path | string | 예 | — | 정렬 방향 (asc 오름차순 / desc 내림차순) | +| guest_lookup_password | body | string | 예 | min 8, max 255 | 재설정할 비회원 주문 조회 비밀번호 (8자 이상, 해시로 저장 · 회원가입 정책과 동일) | +| guest_lookup_password_confirmation | body | string | 예 | — | 조회 비밀번호 확인 (guest_lookup_password 와 일치해야 함) | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/orders/4/reset-guest-lookup-password HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "guest_lookup_password": "Password123!", + "guest_lookup_password_confirmation": "Password123!" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 비회원 주문(`order`)의 조회 비밀번호를 재설정합니다. `auth:sanctum` + `sirsoft-ecommerce.orders.update` 권한이 필요하며, 비회원 주문(`user_id IS NULL`)만 허용하고 회원 주문에는 422를 반환합니다. `Admin\OrderController@resetGuestLookupPassword`가 `OrderService::resetGuestLookupPassword()`로 새 비밀번호를 해시로 저장하며, 평문은 응답/로그에 노출하지 않습니다. 비회원이 조회 비밀번호를 분실했을 때 관리자가 대신 재설정해 주는 용도입니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/orders/{order}/send-email + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.orders.send-email` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\OrderController@sendEmail` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| order | path | string | 예 | — | 정렬 방향 (asc 오름차순 / desc 내림차순) | +| email | body | email | 예 | max 255 | 이메일 주소 | +| message | body | string | 예 | max 5000 | 관리자가 작성한 안내 메일 본문 | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/orders/4/send-email HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "email": "user@example.com", + "message": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 특정 주문(`order`)에 대해 주문 관련 안내 이메일을 지정 주소(`email`)로 발송합니다. `auth:sanctum` + `sirsoft-ecommerce.orders.update` 권한이 필요하며, `Admin\OrderController@sendEmail`이 `OrderService::sendEmail()`로 관리자가 작성한 메시지(`message`)를 전송합니다. 주문 관련 개별 안내가 필요할 때 관리자가 상세 화면에서 수동으로 메일을 보내는 용도입니다. + + +### POST /api/modules/sirsoft-ecommerce/orders/{orderNumber}/cancel-payment + +- **라우트명**: `api.modules.sirsoft-ecommerce.orders.cancel-payment` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\OrderController@cancelPayment` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| orderNumber | path | string | 예 | — | 대상 order number의 식별자 | +| cancel_code | body | string | 아니오 | max 100 | PG사 취소 코드 (예: USER_CANCEL, order_payments 취소 이력에 기록) | +| cancel_message | body | string | 아니오 | max 500 | PG사 취소 메시지 (order_payments 취소 이력에 기록) | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/orders/APIDOC-20260708-000001/cancel-payment HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +Content-Type: application/json + +{ + "cancel_code": "예시값", + "cancel_message": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 회원/비회원이 PG 결제창을 닫았을 때 결제 취소 이력만 기록합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `Public\OrderController@cancelPayment`가 `OrderProcessingService::recordPaymentCancellation()`으로 주문 상태는 변경하지 않고 `order_payments`에 취소창 닫힘 이력(`cancel_code`·`cancel_message`)만 남깁니다. 결제 SDK가 사용자 취소 콜백을 받았을 때 프론트가 호출해 결제 시도 이력을 추적하는 용도입니다. + + +### GET /api/modules/sirsoft-ecommerce/user/orders + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.orders.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\OrderController@index` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | +| per_page | query | integer | 아니오 | min 1, max 50 | 페이지당 항목 수 | +| status | query | string | 아니오 | — | 상태 필터 (해당 상태의 항목만 조회) | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/user/orders?page=1&per_page=1&status=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `452` | 기본 키 (내부 식별자) | +| order_number | string | `20260625-1144420949` | 주문번호 (사용자 노출용 고유 식별 코드) | +| status | string | `shipping` | 주문상태 값 (OrderStatusEnum value — 마이페이지용 status 별칭) | +| status_label | string | `배송중` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| status_variant | string | `primary` | 상태 표시 색상/스타일 변형 키 (상태 Enum variant() 산물 — UI 배지용) | +| is_partially_cancelled | boolean | `false` | partially cancelled 여부 | +| recipient_country_code | string | `KR` | 배송국가 코드 (ISO 3166-1 alpha-2, shippingAddress 로드 시) | +| recipient_country_name | object | `{"ko":"한국","en":"South Korea"}` | 배송국가 현지화명 (로케일별 국가명 맵) | +| ordered_at | string | `2026-06-25T11:44:42+00:00` | ordered 일시 | +| ordered_at_formatted | string | `2026-06-25 20:44:42` | `ordered_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_amount | integer | `31000` | 최종 주문금액 (상품합계 − 할인 + 배송비) | +| total_amount_formatted | string | `31,000원` | `total_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| mc_total_amount | object | `{"KRW":{"amount":31000,"formatted":"31,000원"},"USD":{"amo…` | 최종 주문금액 다중 통화 (주문 시점 스냅샷, 통화별 amount·formatted) | +| total_shipping_amount | integer | `0` | 총 배송비 | +| total_shipping_amount_formatted | string | `0원` | `total_shipping_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| mc_total_shipping_amount | object | `{"KRW":{"amount":0,"formatted":"0원"},"USD":{"amount":0,"f…` | 총 배송비 다중 통화 (주문 시점 스냅샷, 통화별 amount·formatted) | +| total_points_used_amount | integer | `0` | 총 포인트(마일리지) 사용액 | +| total_points_used_amount_formatted | string | `0원` | `total_points_used_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_earned_points_amount | integer | `310` | 총 적립 예정 포인트 | +| total_earned_points_amount_formatted | string | `310원` | `total_earned_points_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| items | array | `[{"product_name":"프리미엄 샤인머스캣 2kg #94","product_option_nam…` | 주문 품목 목록 (상품명·옵션명·썸네일·수량·단가/소계·추가옵션 요약) | +| item_count | integer | `1` | item 개수 (집계) | +| abilities | object | `{"can_view":true,"can_cancel":false}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "주문 정보를 조회했습니다.", + "data": { + "data": [ + { + "id": 4, + "order_number": "APIDOC-20260708-000001", + "status": "pending_payment", + "status_label": "결제대기", + "status_variant": "warning", + "is_partially_cancelled": false, + "recipient_country_code": null, + "recipient_country_name": null, + "ordered_at": "2026-07-08T01:44:49+00:00", + "ordered_at_formatted": "2026-07-08 10:44:49", + "total_amount": 327327, + "total_amount_formatted": "327,327원", + "mc_total_amount": [], + "total_shipping_amount": 3000, + "total_shipping_amount_formatted": "3,000원", + "mc_total_shipping_amount": [], + "total_points_used_amount": 0, + "total_points_used_amount_formatted": "0원", + "total_earned_points_amount": 3273, + "total_earned_points_amount_formatted": "3,273원", + "items": [], + "item_count": 0, + "abilities": { + "can_view": true, + "can_cancel": true + } + } + ], + "statistics": { + "pending_payment": 1, + "payment_complete": 0, + "preparing": 0, + "shipping": 0, + "delivered": 0, + "confirmed": 0 + }, + "abilities": { + "can_create": true + }, + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 1 + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 회원이 마이페이지 주문내역에서 본인 주문 목록을 상태별 통계와 함께 페이지네이션 조회합니다. `auth:sanctum` 인증이 필요하며, `User\OrderController@index`가 `user_id`를 본인으로 고정한 뒤 `OrderService::getList()`와 `getUserStatistics()`를 호출해 `UserOrderCollection`으로 반환합니다. `status`로 특정 주문상태만 필터링할 수 있습니다. 관리자 목록과 달리 항상 본인 주문으로만 한정됩니다. + + +### POST /api/modules/sirsoft-ecommerce/user/orders + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.orders.store` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\OrderController@store` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-ecommerce.user-orders.create` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| payment_method | body | string | 예 | — | 결제수단 (PaymentMethodEnum 값 — card/vbank/dbank 등) | +| expected_total_amount | body | number | 예 | min 0 | 프론트가 계산한 예상 결제금액 (서버 재계산값과 대조해 금액 위변조 검증) | +| shipping_memo | body | string | 아니오 | max 500 | 배송 요청사항 메모 | +| depositor_name | body | string | 아니오 | max 50 | depositor 이름 (식별자) | +| save_shipping_address | body | boolean | 아니오 | — | 회원 주소록에 이번 배송지 저장 여부 (회원 주문 한정) | +| guest_lookup_password | body | string | 예 | min 8, max 255 | 비회원 주문 조회 비밀번호 (비회원만 필수, 8자 이상 · 해시로 저장) | +| guest_lookup_password_confirmation | body | string | 예 | — | 조회 비밀번호 확인 (guest_lookup_password 와 일치해야 함) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.order.create_validation_rules`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/user/orders HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +Content-Type: application/json + +{ + "payment_method": "예시값", + "expected_total_amount": 1, + "shipping_memo": "예시값", + "depositor_name": "예시 이름", + "save_shipping_address": true, + "guest_lookup_password": "Password123!", + "guest_lookup_password_confirmation": "Password123!" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-orders.create`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 주문서 작성을 마치고 실제 주문을 생성(결제하기)하는 회원/비회원 공용 엔드포인트입니다. PG 플러그인의 fetch 인터셉터가 이 한 경로만 매칭하므로 회원/비회원이 동일 URL로 진입하고, `Public\OrderController@store`가 `Auth::id()`로 분기합니다(회원은 `OrderResource`, 비회원은 민감 필드를 가린 `GuestOrderResource`). `optional.sanctum` + `sirsoft-ecommerce.user-orders.create` 권한이 필요하며, `expected_total_amount`로 금액 위변조를 검증하고 비회원은 `guest_lookup_password`로 이후 조회 비밀번호를 설정합니다. 회원이 `save_shipping_address`를 켜면 배송지가 자동 저장(PG 결제는 결제완료 시점) 됩니다. + + +### GET /api/modules/sirsoft-ecommerce/user/orders/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.orders.show-by-id` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\OrderController@show` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/user/orders/4 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `4` | 기본 키 (내부 식별자) | +| order_number | string | `APIDOC-20260708-000001` | 주문번호 | +| base_currency | string | `KRW` | 금액 표기 기준 통화 (모든 *_formatted 필드의 통화, 주문 시점 base_currency 고정) | +| payment_currency | string | `KRW` | 결제 통화 (유저가 선택·결제한 통화, base_currency 와 다르면 병기 표시) | +| is_cross_currency | boolean | `false` | cross currency 여부 | +| order_status | string | `pending_payment` | 주문상태 (OrderStatusEnum) | +| order_status_label | string | `결제대기` | `order_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| order_status_variant | string | `warning` | `order_status` 값의 표시 변형 키 (UI 배지 색상/스타일) | +| is_partially_cancelled | boolean | `false` | partially cancelled 여부 | +| order_device | string | `pc` | 주문 디바이스 (pc/mobile/app) | +| order_device_label | string | `PC` | `order_device` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| is_first_order | boolean | `false` | first order 여부 | +| subtotal_amount | integer | `324327` | 상품 합계 (할인 전, 상품가×수량 합계) | +| subtotal_amount_formatted | string | `324,327원` | `subtotal_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_discount_amount | integer | `0` | 총 할인금액 (모든 할인 합계) | +| total_discount_amount_formatted | string | `0원` | `total_discount_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_shipping_amount | integer | `3000` | 총 배송비 | +| total_shipping_amount_formatted | string | `3,000원` | `total_shipping_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_amount | integer | `327327` | 최종 주문금액 (subtotal - discount + shipping) | +| total_amount_formatted | string | `327,327원` | `total_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_paid_amount | integer | `0` | 총 실제 결제금액 (PG 결제액) | +| total_paid_amount_formatted | string | `0원` | `total_paid_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_due_amount | integer | `327327` | 총 결제예정금액 (무통장 등) | +| total_due_amount_formatted | string | `327,327원` | `total_due_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| depositor_name | null | `null` | 무통장 입금자명 (입금확인 모달 기본값, payment 관계 로드 시에만 노출) | +| total_cancelled_amount | integer | `0` | 총 취소금액 | +| total_cancelled_amount_formatted | string | `0원` | `total_cancelled_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_refunded_amount | integer | `0` | 총 환불금액 | +| total_refunded_amount_formatted | string | `0원` | `total_refunded_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_refunded_points_amount | integer | `0` | 총 환불 포인트 | +| total_refunded_points_amount_formatted | string | `0원` | `total_refunded_points_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_product_coupon_discount_amount | integer | `0` | 상품 쿠폰 할인 합계 | +| total_product_coupon_discount_amount_formatted | string | `0원` | `total_product_coupon_discount_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_order_coupon_discount_amount | integer | `0` | 주문 쿠폰 할인 합계 | +| total_order_coupon_discount_amount_formatted | string | `0원` | `total_order_coupon_discount_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_coupon_discount_amount | integer | `0` | 총 쿠폰 할인금액 | +| total_coupon_discount_amount_formatted | string | `0원` | `total_coupon_discount_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_code_discount_amount | integer | `0` | 총 할인코드 할인금액 | +| total_code_discount_amount_formatted | string | `0원` | `total_code_discount_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_points_used_amount | integer | `0` | 총 포인트 사용액 | +| total_points_used_amount_formatted | string | `0원` | `total_points_used_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_deposit_used_amount | integer | `0` | 총 예치금 사용액 | +| total_deposit_used_amount_formatted | string | `0원` | `total_deposit_used_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_earned_points_amount | integer | `3273` | 총 적립 예정 포인트 | +| total_earned_points_amount_formatted | string | `3,273원` | `total_earned_points_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| mc_subtotal_amount | array | `[]` | 상품합계 다중 통화 | +| mc_total_discount_amount | array | `[]` | 총 할인 다중 통화 | +| mc_total_shipping_amount | array | `[]` | 총 배송비 다중 통화 | +| mc_total_amount | array | `[]` | 최종금액 다중 통화 (payment_amount) | +| mc_total_product_coupon_discount_amount | array | `[]` | 상품 쿠폰 할인 다중 통화 | +| mc_total_order_coupon_discount_amount | array | `[]` | 주문 쿠폰 할인 다중 통화 | +| mc_total_coupon_discount_amount | array | `[]` | 쿠폰 할인 합계 다중 통화 | +| mc_total_code_discount_amount | array | `[]` | 할인코드 할인 다중 통화 | +| mc_total_points_used_amount | array | `[]` | 포인트 사용 다중 통화 | +| mc_total_deposit_used_amount | array | `[]` | 예치금 사용 다중 통화 | +| item_count | integer | `4` | item 개수 (집계) | +| total_quantity | integer | `0` | 주문 옵션 수량 합계 (options 로드 시) | +| total_list_price | integer | `0` | 정가 합계 (옵션 스냅샷 정가 × 수량 합계) | +| total_list_price_formatted | string | `0원` | `total_list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| ordered_at | string | `2026-07-08T01:44:49+00:00` | ordered 일시 | +| ordered_at_formatted | string | `2026-07-08 10:44:49` | `ordered_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| paid_at | null | `null` | paid 일시 | +| paid_at_formatted | null | `null` | `paid_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| confirmed_at | null | `null` | confirmed 일시 | +| confirmed_at_formatted | null | `null` | `confirmed_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| cancelled_at | null | `null` | cancelled 일시 | +| cancelled_at_formatted | null | `null` | `cancelled_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| delivered_at | null | `null` | delivered 일시 | +| total_tax_amount | integer | `29757` | 총 과세금액 | +| total_tax_amount_formatted | string | `29,757원` | `total_tax_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_vat_amount | integer | `0` | 총 부가세금액 | +| total_vat_amount_formatted | string | `0원` | `total_vat_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_taxable_supply_amount | integer | `29757` | 과세 공급가액 (총 과세금액 − 부가세, 영수증 과세금액 표시 SSoT) | +| total_taxable_supply_amount_formatted | string | `29,757원` | `total_taxable_supply_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_tax_free_amount | integer | `0` | 총 면세금액 | +| total_tax_free_amount_formatted | string | `0원` | `total_tax_free_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| user | object | `{"uuid":"a234c2b1-cde8-437f-b28b-23323be2b98d","name":"AP…` | 대상 사용자 정보 객체 (uuid/name/email 등 — user 관계 파생) | +| user_id | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | user 식별자 (연관 리소스 참조) | +| user_login_id | null | `null` | 회원 로그인 아이디 (login_id, 비회원 주문이면 null) | +| orderer_name | null | `null` | 주문자 이름 (배송지에서 플래튼) | +| orderer_phone | null | `null` | 주문자 휴대전화 (배송지에서 플래튼) | +| orderer_tel | null | `null` | 주문자 일반전화 (배송지에서 플래튼, 미입력 시 null) | +| orderer_email | null | `null` | 주문자 이메일 (배송지에서 플래튼, 비회원 알림 수신 통로) | +| recipient_name | null | `null` | 수령인 이름 (배송지에서 플래튼) | +| recipient_phone | null | `null` | 수령인 휴대전화 (배송지에서 플래튼) | +| recipient_tel | null | `null` | 수령인 일반전화 (배송지에서 플래튼, 미입력 시 null) | +| recipient_zipcode | null | `null` | 수령인 우편번호 (배송지에서 플래튼) | +| recipient_address | null | `null` | 수령인 기본 주소 (배송지에서 플래튼) | +| recipient_detail_address | null | `null` | 수령인 상세 주소 (배송지에서 플래튼, 미입력 시 null) | +| delivery_memo | null | `null` | 배송 메모 (배송지에서 플래튼, 미입력 시 null) | +| delivery_memo_label | null | `null` | `delivery_memo` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| options | array | `[]` | 주문 옵션(품목) 목록 (OrderOptionResource — 상품·옵션·수량·옵션상태·금액) | +| shipping_address | null | `null` | 배송지 상세 (OrderAddressResource — 주문자/수령인/국내·해외 주소) | +| billing_address | null | `null` | 청구지 상세 (OrderAddressResource, 미분리 시 null) | +| payment | null | `null` | 대표 결제 정보 (OrderPaymentResource — 결제수단·결제상태·금액) | +| payments | array | `[]` | 결제 이력 목록 (OrderPaymentResource 배열 — 다회 결제/부분결제 포함) | +| shippings | array | `[]` | 배송 이력 목록 (OrderShippingResource 배열 — 배송유형·택배사·송장번호) | +| cancels | array | `[]` | 취소 이력 목록 (OrderCancelResource 배열 — 취소 사유·상세·취소일시, 최근순) | +| promotions_applied_snapshot | null | `null` | 적용된 프로모션 스냅샷 (재계산용) | +| shipping_policy_applied_snapshot | null | `null` | 적용된 배송정책 스냅샷 (재계산용) | +| admin_memo | null | `null` | 관리자 메모 (내부 관리용) | +| customer_memo | null | `null` | 고객 메모 (주문 시 고객이 남긴 메모) | +| created_at | string | `2026-07-08T01:44:49+00:00` | 생성 일시 | +| updated_at | string | `2026-07-08T01:44:49+00:00` | 최종 수정 일시 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_update":true,"can_cancel":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "주문 정보를 조회했습니다.", + "data": { + "id": 4, + "order_number": "APIDOC-20260708-000001", + "base_currency": "KRW", + "payment_currency": "KRW", + "is_cross_currency": false, + "order_status": "pending_payment", + "order_status_label": "결제대기", + "order_status_variant": "warning", + "is_partially_cancelled": false, + "order_device": "pc", + "order_device_label": "PC", + "is_first_order": false, + "subtotal_amount": 324327, + "subtotal_amount_formatted": "324,327원", + "total_discount_amount": 0, + "total_discount_amount_formatted": "0원", + "total_shipping_amount": 3000, + "total_shipping_amount_formatted": "3,000원", + "total_amount": 327327, + "total_amount_formatted": "327,327원", + "total_paid_amount": 0, + "total_paid_amount_formatted": "0원", + "total_due_amount": 327327, + "total_due_amount_formatted": "327,327원", + "depositor_name": null, + "total_cancelled_amount": 0, + "total_cancelled_amount_formatted": "0원", + "total_refunded_amount": 0, + "total_refunded_amount_formatted": "0원", + "total_refunded_points_amount": 0, + "total_refunded_points_amount_formatted": "0원", + "total_product_coupon_discount_amount": 0, + "total_product_coupon_discount_amount_formatted": "0원", + "total_order_coupon_discount_amount": 0, + "total_order_coupon_discount_amount_formatted": "0원", + "total_coupon_discount_amount": 0, + "total_coupon_discount_amount_formatted": "0원", + "total_code_discount_amount": 0, + "total_code_discount_amount_formatted": "0원", + "total_points_used_amount": 0, + "total_points_used_amount_formatted": "0원", + "total_deposit_used_amount": 0, + "total_deposit_used_amount_formatted": "0원", + "total_earned_points_amount": 3273, + "total_earned_points_amount_formatted": "3,273원", + "mc_subtotal_amount": [], + "mc_total_discount_amount": [], + "mc_total_shipping_amount": [], + "mc_total_amount": [], + "mc_total_product_coupon_discount_amount": [], + "mc_total_order_coupon_discount_amount": [], + "mc_total_coupon_discount_amount": [], + "mc_total_code_discount_amount": [], + "mc_total_points_used_amount": [], + "mc_total_deposit_used_amount": [], + "item_count": 4, + "total_quantity": 0, + "total_list_price": 0, + "total_list_price_formatted": "0원", + "ordered_at": "2026-07-08T01:44:49+00:00", + "ordered_at_formatted": "2026-07-08 10:44:49", + "paid_at": null, + "paid_at_formatted": null, + "confirmed_at": null, + "confirmed_at_formatted": null, + "cancelled_at": null, + "cancelled_at_formatted": null, + "delivered_at": null, + "total_tax_amount": 29757, + "total_tax_amount_formatted": "29,757원", + "total_vat_amount": 0, + "total_vat_amount_formatted": "0원", + "total_taxable_supply_amount": 29757, + "total_taxable_supply_amount_formatted": "29,757원", + "total_tax_free_amount": 0, + "total_tax_free_amount_formatted": "0원", + "user": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com" + }, + "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "user_login_id": null, + "orderer_name": null, + "orderer_phone": null, + "orderer_tel": null, + "orderer_email": null, + "recipient_name": null, + "recipient_phone": null, + "recipient_tel": null, + "recipient_zipcode": null, + "recipient_address": null, + "recipient_detail_address": null, + "delivery_memo": null, + "delivery_memo_label": null, + "options": [], + "shipping_address": null, + "billing_address": null, + "payment": null, + "payments": [], + "shippings": [], + "cancels": [], + "promotions_applied_snapshot": null, + "shipping_policy_applied_snapshot": null, + "admin_memo": null, + "customer_memo": null, + "created_at": "2026-07-08T01:44:49+00:00", + "updated_at": "2026-07-08T01:44:49+00:00", + "is_owner": true, + "abilities": { + "can_read": true, + "can_update": true, + "can_cancel": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 회원이 마이페이지 주문 상세에서 주문 ID(`id`)로 본인 주문의 전체 상세를 조회합니다. `auth:sanctum` 인증이 필요하며, `User\OrderController@show`가 `OrderService::getDetail()`로 로드한 뒤 소유자 검증(`user_id === Auth::id()`)을 거쳐 `OrderResource`로 반환합니다. 본인 주문이 아니거나 존재하지 않으면 정보 노출 방지를 위해 404를 반환합니다. 주문번호로 조회하는 `showByOrderNumber`와 달리 내부 주문 ID를 사용합니다. + + +### POST /api/modules/sirsoft-ecommerce/user/orders/{id}/cancel + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.orders.cancel` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\OrderController@cancel` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.user-orders.cancel` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| reason | body | string | 예 | — | 취소 사유 코드 (ClaimReason 의 refund·활성·사용자 선택 가능 코드) | +| reason_detail | body | string | 아니오 | max 500 | 취소 사유 상세 (회원 입력 자유 텍스트) | +| items | body | array | 아니오 | min 1 | 처리 대상 항목 배열 | +| refund_priority | body | string | 아니오 | `pg_first`, `points_first` | 환불 배분 우선순위 (pg_first PG 우선 / points_first 포인트 우선, 기본 pg_first) | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/user/orders/4/cancel HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "reason": "예시값", + "reason_detail": "예시값", + "items": [ + "예시값" + ], + "refund_priority": "pg_first" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-orders.cancel`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 회원이 마이페이지에서 본인 주문(`id`)을 취소합니다. `auth:sanctum` + `sirsoft-ecommerce.user-orders.cancel` 권한이 필요하며, `User\OrderController@cancel`이 `items` 유무에 따라 `OrderCancellationService`의 `cancelOrderOptions()`(부분) 또는 `cancelOrder()`(전체)를 호출합니다. 취소자(`cancelledBy`)로 회원 본인 ID가 기록되고, `refund_priority`로 PG/포인트 환불 우선순위를 지정합니다. 취소 가능 상태의 주문만 취소되며, 취소 후 갱신된 주문을 `OrderResource`로 반환합니다. + + +### POST /api/modules/sirsoft-ecommerce/user/orders/{id}/estimate-refund + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.orders.estimate-refund` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\OrderController@estimateRefund` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.user-orders.cancel` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| items | body | array | 예 | min 1 | 처리 대상 항목 배열 | +| refund_priority | body | string | 아니오 | `pg_first`, `points_first` | 환불 배분 우선순위 (pg_first PG 우선 / points_first 포인트 우선, 기본 pg_first) | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/user/orders/4/estimate-refund HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "items": [ + "예시값" + ], + "refund_priority": "pg_first" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-orders.cancel`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 회원이 마이페이지에서 본인 주문(`id`)의 선택 옵션(`items`) 취소 시 예상 환불 금액을 실제 취소 없이 미리 계산합니다. `auth:sanctum` + `sirsoft-ecommerce.user-orders.cancel` 권한이 필요하며, `User\OrderController@estimateRefund`가 `OrderCancellationService::previewRefund()`로 환불 예상값을 반환합니다. `refund_priority`에 따라 PG 우선/포인트 우선 환불 배분 결과가 달라집니다. 취소 확정 전 "환불 예정 금액"을 회원에게 안내하는 용도입니다. + + +### POST /api/modules/sirsoft-ecommerce/user/orders/{id}/options/{optionId}/confirm + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.orders.confirm-option` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\OrderController@confirmOption` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.user-orders.confirm` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| optionId | path | string | 예 | — | 대상 option의 식별자 | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/user/orders/4/options/{optionId}/confirm HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-orders.confirm`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 회원이 마이페이지에서 본인 주문(`id`)의 개별 옵션(`optionId`)을 구매확정합니다. `auth:sanctum` + `sirsoft-ecommerce.user-orders.confirm` 권한이 필요하며, `User\OrderController@confirmOption`이 `OrderService::confirmOption()`을 호출합니다. 구매확정 시 적립 포인트 확정 등 후속 처리가 이어지며, 확정 불가 상태(배송 미완료 등)면 422를 반환합니다. 배송 완료된 상품을 회원이 직접 "구매확정" 할 때 사용합니다. + + +### POST /api/modules/sirsoft-ecommerce/user/orders/{id}/reorder + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.orders.reorder` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\OrderController@reorder` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/user/orders/4/reorder HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| added_count | integer | `0` | added 개수 (집계) | +| skipped | array | `[]` | 장바구니에 담지 못한 항목 목록 (품절·단종 등으로 재주문에서 건너뛴 옵션) | +| cart_count | integer | `0` | cart 개수 (집계) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "과거 주문의 상품을 장바구니에 추가했습니다.", + "data": { + "added_count": 0, + "skipped": [], + "cart_count": 0 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 회원이 과거 주문(`id`)의 옵션들을 현재 장바구니에 다시 담는 재주문 기능입니다. `auth:sanctum` 인증이 필요하며, `User\OrderController@reorder`가 `CartService::reorderFromOrder()`로 처리해 담긴 수량(`added_count`), 담지 못한 항목(`skipped[]`), 현재 장바구니 총 개수(`cart_count`)를 반환합니다. 취소된 주문도 재주문 대상이 되며, 품절·단종 등으로 추가 불가한 항목은 건너뛰어 `skipped` 배열로 안내합니다. 마이페이지 주문내역의 "재주문" 버튼에 사용합니다. + + +### PUT /api/modules/sirsoft-ecommerce/user/orders/{id}/shipping-address + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.orders.update-shipping-address` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\OrderController@updateShippingAddress` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| address_id | body | integer | 아니오 | — | address 식별자 | +| recipient_name | body | string | 아니오 | max 50 | 수령인 이름 | +| recipient_phone | body | string | 아니오 | max 20 | 수령인 연락처 | +| country_code | body | string | 아니오 | — | 국가 코드 (ISO 3166-1 alpha-2) | +| zipcode | body | string | 아니오 | max 10 | 우편번호 | +| address | body | string | 아니오 | max 255 | 기본 주소 | +| address_detail | body | string | 아니오 | max 255 | 상세 주소 | +| address_line_1 | body | string | 아니오 | max 255 | 주소 1행 (기본 주소) | +| address_line_2 | body | string | 아니오 | max 255 | 주소 2행 (상세 주소) | +| intl_city | body | string | 아니오 | max 100 | 도시 (국제 주소) | +| intl_state | body | string | 아니오 | max 100 | 주/도 (국제 주소) | +| intl_postal_code | body | string | 아니오 | max 20 | 우편번호 (국제 주소) | +| delivery_memo | body | string | 아니오 | max 255 | 배송 메모 (배송 시 요청사항) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.order.shipping_address_validation_rules`). + +**요청 예시** + +```http +PUT /api/modules/sirsoft-ecommerce/user/orders/4/shipping-address HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "address_id": 1, + "recipient_name": "예시 이름", + "recipient_phone": "010-1234-5678", + "country_code": "KR", + "zipcode": "06234", + "address": "서울특별시 강남구 테헤란로 1", + "address_detail": "서울특별시 강남구 테헤란로 1", + "address_line_1": "서울특별시 강남구 테헤란로 1", + "address_line_2": "서울특별시 강남구 테헤란로 1", + "intl_city": "예시값", + "intl_state": "예시값", + "intl_postal_code": "06234", + "delivery_memo": "예시값" +} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| order | object | `{"id":4,"order_number":"APIDOC-20260708-000001","base_cur…` | 배송지 변경이 반영된 주문 상세 (OrderResource — 변경 후 최신 주문 전체) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "배송지가 변경되었습니다.", + "data": { + "order": { + "id": 4, + "order_number": "APIDOC-20260708-000001", + "base_currency": "KRW", + "payment_currency": "KRW", + "is_cross_currency": false, + "order_status": "pending_payment", + "order_status_label": "결제대기", + "order_status_variant": "warning", + "is_partially_cancelled": false, + "order_device": "pc", + "order_device_label": "PC", + "is_first_order": false, + "subtotal_amount": 324327, + "subtotal_amount_formatted": "324,327원", + "total_discount_amount": 0, + "total_discount_amount_formatted": "0원", + "total_shipping_amount": 3000, + "total_shipping_amount_formatted": "3,000원", + "total_amount": 327327, + "total_amount_formatted": "327,327원", + "total_paid_amount": 0, + "total_paid_amount_formatted": "0원", + "total_due_amount": 327327, + "total_due_amount_formatted": "327,327원", + "total_cancelled_amount": 0, + "total_cancelled_amount_formatted": "0원", + "total_refunded_amount": 0, + "total_refunded_amount_formatted": "0원", + "total_refunded_points_amount": 0, + "total_refunded_points_amount_formatted": "0원", + "total_product_coupon_discount_amount": 0, + "total_product_coupon_discount_amount_formatted": "0원", + "total_order_coupon_discount_amount": 0, + "total_order_coupon_discount_amount_formatted": "0원", + "total_coupon_discount_amount": 0, + "total_coupon_discount_amount_formatted": "0원", + "total_code_discount_amount": 0, + "total_code_discount_amount_formatted": "0원", + "total_points_used_amount": 0, + "total_points_used_amount_formatted": "0원", + "total_deposit_used_amount": 0, + "total_deposit_used_amount_formatted": "0원", + "total_earned_points_amount": 3273, + "total_earned_points_amount_formatted": "3,273원", + "mc_subtotal_amount": [], + "mc_total_discount_amount": [], + "mc_total_shipping_amount": [], + "mc_total_amount": [], + "mc_total_product_coupon_discount_amount": [], + "mc_total_order_coupon_discount_amount": [], + "mc_total_coupon_discount_amount": [], + "mc_total_code_discount_amount": [], + "mc_total_points_used_amount": [], + "mc_total_deposit_used_amount": [], + "item_count": 4, + "total_quantity": 0, + "total_list_price": 0, + "total_list_price_formatted": "0원", + "ordered_at": "2026-07-08T01:44:49+00:00", + "ordered_at_formatted": "2026-07-08 10:44:49", + "paid_at": null, + "paid_at_formatted": null, + "confirmed_at": null, + "confirmed_at_formatted": null, + "cancelled_at": null, + "cancelled_at_formatted": null, + "delivered_at": null, + "total_tax_amount": 29757, + "total_tax_amount_formatted": "29,757원", + "total_vat_amount": 0, + "total_vat_amount_formatted": "0원", + "total_taxable_supply_amount": 29757, + "total_taxable_supply_amount_formatted": "29,757원", + "total_tax_free_amount": 0, + "total_tax_free_amount_formatted": "0원", + "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "user_login_id": null, + "orderer_name": null, + "orderer_phone": null, + "orderer_tel": null, + "orderer_email": null, + "recipient_name": null, + "recipient_phone": null, + "recipient_tel": null, + "recipient_zipcode": null, + "recipient_address": null, + "recipient_detail_address": null, + "delivery_memo": null, + "delivery_memo_label": null, + "options": [], + "shipping_address": null, + "promotions_applied_snapshot": null, + "shipping_policy_applied_snapshot": null, + "admin_memo": null, + "customer_memo": null, + "created_at": "2026-07-08T01:44:49+00:00", + "updated_at": "2026-07-08T01:44:49+00:00", + "is_owner": true, + "abilities": { + "can_read": true, + "can_update": true, + "can_cancel": true + } + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 회원이 배송 전 상태의 본인 주문(`id`) 배송지를 변경합니다. `auth:sanctum` 인증이 필요하며, `User\OrderController@updateShippingAddress`가 소유자 검증 후 `OrderService::updateShippingAddress()`로 처리합니다. 저장된 회원 주소(`address_id`)를 선택하거나 수취인·연락처·주소 필드를 직접 입력할 수 있고, 국내(`zipcode`/`address`)와 해외(`address_line_1`·`intl_city` 등) 주소를 모두 지원합니다. 이미 배송이 시작된 주문 등 변경 불가 상태면 422를 반환합니다. + + +### GET /api/modules/sirsoft-ecommerce/user/orders/{orderNumber} + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.orders.show` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\OrderController@showByOrderNumber` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| orderNumber | path | string | 예 | — | 대상 order number의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/user/orders/APIDOC-20260708-000001 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `4` | 기본 키 (내부 식별자) | +| order_number | string | `APIDOC-20260708-000001` | 주문번호 | +| base_currency | string | `KRW` | 금액 표기 기준 통화 (모든 *_formatted 필드의 통화, 주문 시점 base_currency 고정) | +| payment_currency | string | `KRW` | 결제 통화 (유저가 선택·결제한 통화, base_currency 와 다르면 병기 표시) | +| is_cross_currency | boolean | `false` | cross currency 여부 | +| order_status | string | `pending_payment` | 주문상태 (OrderStatusEnum) | +| order_status_label | string | `결제대기` | `order_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| order_status_variant | string | `warning` | `order_status` 값의 표시 변형 키 (UI 배지 색상/스타일) | +| is_partially_cancelled | boolean | `false` | partially cancelled 여부 | +| order_device | string | `pc` | 주문 디바이스 (pc/mobile/app) | +| order_device_label | string | `PC` | `order_device` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| is_first_order | boolean | `false` | first order 여부 | +| subtotal_amount | integer | `324327` | 상품 합계 (할인 전, 상품가×수량 합계) | +| subtotal_amount_formatted | string | `324,327원` | `subtotal_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_discount_amount | integer | `0` | 총 할인금액 (모든 할인 합계) | +| total_discount_amount_formatted | string | `0원` | `total_discount_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_shipping_amount | integer | `3000` | 총 배송비 | +| total_shipping_amount_formatted | string | `3,000원` | `total_shipping_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_amount | integer | `327327` | 최종 주문금액 (subtotal - discount + shipping) | +| total_amount_formatted | string | `327,327원` | `total_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_paid_amount | integer | `0` | 총 실제 결제금액 (PG 결제액) | +| total_paid_amount_formatted | string | `0원` | `total_paid_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_due_amount | integer | `327327` | 총 결제예정금액 (무통장 등) | +| total_due_amount_formatted | string | `327,327원` | `total_due_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| depositor_name | null | `null` | 무통장 입금자명 (입금확인 모달 기본값, payment 관계 로드 시에만 노출) | +| total_cancelled_amount | integer | `0` | 총 취소금액 | +| total_cancelled_amount_formatted | string | `0원` | `total_cancelled_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_refunded_amount | integer | `0` | 총 환불금액 | +| total_refunded_amount_formatted | string | `0원` | `total_refunded_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_refunded_points_amount | integer | `0` | 총 환불 포인트 | +| total_refunded_points_amount_formatted | string | `0원` | `total_refunded_points_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_product_coupon_discount_amount | integer | `0` | 상품 쿠폰 할인 합계 | +| total_product_coupon_discount_amount_formatted | string | `0원` | `total_product_coupon_discount_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_order_coupon_discount_amount | integer | `0` | 주문 쿠폰 할인 합계 | +| total_order_coupon_discount_amount_formatted | string | `0원` | `total_order_coupon_discount_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_coupon_discount_amount | integer | `0` | 총 쿠폰 할인금액 | +| total_coupon_discount_amount_formatted | string | `0원` | `total_coupon_discount_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_code_discount_amount | integer | `0` | 총 할인코드 할인금액 | +| total_code_discount_amount_formatted | string | `0원` | `total_code_discount_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_points_used_amount | integer | `0` | 총 포인트 사용액 | +| total_points_used_amount_formatted | string | `0원` | `total_points_used_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_deposit_used_amount | integer | `0` | 총 예치금 사용액 | +| total_deposit_used_amount_formatted | string | `0원` | `total_deposit_used_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_earned_points_amount | integer | `3273` | 총 적립 예정 포인트 | +| total_earned_points_amount_formatted | string | `3,273원` | `total_earned_points_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| mc_subtotal_amount | array | `[]` | 상품합계 다중 통화 | +| mc_total_discount_amount | array | `[]` | 총 할인 다중 통화 | +| mc_total_shipping_amount | array | `[]` | 총 배송비 다중 통화 | +| mc_total_amount | array | `[]` | 최종금액 다중 통화 (payment_amount) | +| mc_total_product_coupon_discount_amount | array | `[]` | 상품 쿠폰 할인 다중 통화 | +| mc_total_order_coupon_discount_amount | array | `[]` | 주문 쿠폰 할인 다중 통화 | +| mc_total_coupon_discount_amount | array | `[]` | 쿠폰 할인 합계 다중 통화 | +| mc_total_code_discount_amount | array | `[]` | 할인코드 할인 다중 통화 | +| mc_total_points_used_amount | array | `[]` | 포인트 사용 다중 통화 | +| mc_total_deposit_used_amount | array | `[]` | 예치금 사용 다중 통화 | +| item_count | integer | `4` | item 개수 (집계) | +| total_quantity | integer | `0` | 주문 옵션 수량 합계 (options 로드 시) | +| total_list_price | integer | `0` | 정가 합계 (옵션 스냅샷 정가 × 수량 합계) | +| total_list_price_formatted | string | `0원` | `total_list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| ordered_at | string | `2026-07-08T01:44:49+00:00` | ordered 일시 | +| ordered_at_formatted | string | `2026-07-08 10:44:49` | `ordered_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| paid_at | null | `null` | paid 일시 | +| paid_at_formatted | null | `null` | `paid_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| confirmed_at | null | `null` | confirmed 일시 | +| confirmed_at_formatted | null | `null` | `confirmed_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| cancelled_at | null | `null` | cancelled 일시 | +| cancelled_at_formatted | null | `null` | `cancelled_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| delivered_at | null | `null` | delivered 일시 | +| total_tax_amount | integer | `29757` | 총 과세금액 | +| total_tax_amount_formatted | string | `29,757원` | `total_tax_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_vat_amount | integer | `0` | 총 부가세금액 | +| total_vat_amount_formatted | string | `0원` | `total_vat_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_taxable_supply_amount | integer | `29757` | 과세 공급가액 (총 과세금액 − 부가세, 영수증 과세금액 표시 SSoT) | +| total_taxable_supply_amount_formatted | string | `29,757원` | `total_taxable_supply_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_tax_free_amount | integer | `0` | 총 면세금액 | +| total_tax_free_amount_formatted | string | `0원` | `total_tax_free_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| user | object | `{"uuid":"a234c2b1-cde8-437f-b28b-23323be2b98d","name":"AP…` | 대상 사용자 정보 객체 (uuid/name/email 등 — user 관계 파생) | +| user_id | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | user 식별자 (연관 리소스 참조) | +| user_login_id | null | `null` | 회원 로그인 아이디 (login_id, 비회원 주문이면 null) | +| orderer_name | null | `null` | 주문자 이름 (배송지에서 플래튼) | +| orderer_phone | null | `null` | 주문자 휴대전화 (배송지에서 플래튼) | +| orderer_tel | null | `null` | 주문자 일반전화 (배송지에서 플래튼, 미입력 시 null) | +| orderer_email | null | `null` | 주문자 이메일 (배송지에서 플래튼, 비회원 알림 수신 통로) | +| recipient_name | null | `null` | 수령인 이름 (배송지에서 플래튼) | +| recipient_phone | null | `null` | 수령인 휴대전화 (배송지에서 플래튼) | +| recipient_tel | null | `null` | 수령인 일반전화 (배송지에서 플래튼, 미입력 시 null) | +| recipient_zipcode | null | `null` | 수령인 우편번호 (배송지에서 플래튼) | +| recipient_address | null | `null` | 수령인 기본 주소 (배송지에서 플래튼) | +| recipient_detail_address | null | `null` | 수령인 상세 주소 (배송지에서 플래튼, 미입력 시 null) | +| delivery_memo | null | `null` | 배송 메모 (배송지에서 플래튼, 미입력 시 null) | +| delivery_memo_label | null | `null` | `delivery_memo` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| options | array | `[]` | 주문 옵션(품목) 목록 (OrderOptionResource — 상품·옵션·수량·옵션상태·금액) | +| shipping_address | null | `null` | 배송지 상세 (OrderAddressResource — 주문자/수령인/국내·해외 주소) | +| billing_address | null | `null` | 청구지 상세 (OrderAddressResource, 미분리 시 null) | +| payment | null | `null` | 대표 결제 정보 (OrderPaymentResource — 결제수단·결제상태·금액) | +| payments | array | `[]` | 결제 이력 목록 (OrderPaymentResource 배열 — 다회 결제/부분결제 포함) | +| shippings | array | `[]` | 배송 이력 목록 (OrderShippingResource 배열 — 배송유형·택배사·송장번호) | +| cancels | array | `[]` | 취소 이력 목록 (OrderCancelResource 배열 — 취소 사유·상세·취소일시, 최근순) | +| promotions_applied_snapshot | null | `null` | 적용된 프로모션 스냅샷 (재계산용) | +| shipping_policy_applied_snapshot | null | `null` | 적용된 배송정책 스냅샷 (재계산용) | +| admin_memo | null | `null` | 관리자 메모 (내부 관리용) | +| customer_memo | null | `null` | 고객 메모 (주문 시 고객이 남긴 메모) | +| created_at | string | `2026-07-08T01:44:49+00:00` | 생성 일시 | +| updated_at | string | `2026-07-08T01:44:49+00:00` | 최종 수정 일시 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_update":true,"can_cancel":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "주문 정보를 조회했습니다.", + "data": { + "id": 4, + "order_number": "APIDOC-20260708-000001", + "base_currency": "KRW", + "payment_currency": "KRW", + "is_cross_currency": false, + "order_status": "pending_payment", + "order_status_label": "결제대기", + "order_status_variant": "warning", + "is_partially_cancelled": false, + "order_device": "pc", + "order_device_label": "PC", + "is_first_order": false, + "subtotal_amount": 324327, + "subtotal_amount_formatted": "324,327원", + "total_discount_amount": 0, + "total_discount_amount_formatted": "0원", + "total_shipping_amount": 3000, + "total_shipping_amount_formatted": "3,000원", + "total_amount": 327327, + "total_amount_formatted": "327,327원", + "total_paid_amount": 0, + "total_paid_amount_formatted": "0원", + "total_due_amount": 327327, + "total_due_amount_formatted": "327,327원", + "depositor_name": null, + "total_cancelled_amount": 0, + "total_cancelled_amount_formatted": "0원", + "total_refunded_amount": 0, + "total_refunded_amount_formatted": "0원", + "total_refunded_points_amount": 0, + "total_refunded_points_amount_formatted": "0원", + "total_product_coupon_discount_amount": 0, + "total_product_coupon_discount_amount_formatted": "0원", + "total_order_coupon_discount_amount": 0, + "total_order_coupon_discount_amount_formatted": "0원", + "total_coupon_discount_amount": 0, + "total_coupon_discount_amount_formatted": "0원", + "total_code_discount_amount": 0, + "total_code_discount_amount_formatted": "0원", + "total_points_used_amount": 0, + "total_points_used_amount_formatted": "0원", + "total_deposit_used_amount": 0, + "total_deposit_used_amount_formatted": "0원", + "total_earned_points_amount": 3273, + "total_earned_points_amount_formatted": "3,273원", + "mc_subtotal_amount": [], + "mc_total_discount_amount": [], + "mc_total_shipping_amount": [], + "mc_total_amount": [], + "mc_total_product_coupon_discount_amount": [], + "mc_total_order_coupon_discount_amount": [], + "mc_total_coupon_discount_amount": [], + "mc_total_code_discount_amount": [], + "mc_total_points_used_amount": [], + "mc_total_deposit_used_amount": [], + "item_count": 4, + "total_quantity": 0, + "total_list_price": 0, + "total_list_price_formatted": "0원", + "ordered_at": "2026-07-08T01:44:49+00:00", + "ordered_at_formatted": "2026-07-08 10:44:49", + "paid_at": null, + "paid_at_formatted": null, + "confirmed_at": null, + "confirmed_at_formatted": null, + "cancelled_at": null, + "cancelled_at_formatted": null, + "delivered_at": null, + "total_tax_amount": 29757, + "total_tax_amount_formatted": "29,757원", + "total_vat_amount": 0, + "total_vat_amount_formatted": "0원", + "total_taxable_supply_amount": 29757, + "total_taxable_supply_amount_formatted": "29,757원", + "total_tax_free_amount": 0, + "total_tax_free_amount_formatted": "0원", + "user": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com" + }, + "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "user_login_id": null, + "orderer_name": null, + "orderer_phone": null, + "orderer_tel": null, + "orderer_email": null, + "recipient_name": null, + "recipient_phone": null, + "recipient_tel": null, + "recipient_zipcode": null, + "recipient_address": null, + "recipient_detail_address": null, + "delivery_memo": null, + "delivery_memo_label": null, + "options": [], + "shipping_address": null, + "billing_address": null, + "payment": null, + "payments": [], + "shippings": [], + "cancels": [], + "promotions_applied_snapshot": null, + "shipping_policy_applied_snapshot": null, + "admin_memo": null, + "customer_memo": null, + "created_at": "2026-07-08T01:44:49+00:00", + "updated_at": "2026-07-08T01:44:49+00:00", + "is_owner": true, + "abilities": { + "can_read": true, + "can_update": true, + "can_cancel": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 주문번호(`orderNumber`)로 주문 상세를 조회하는 회원/비회원 공용 엔드포인트입니다. `optional.sanctum`으로 로그인 여부에 따라 분기하는데, 로그인 상태면 본인 회원 주문만 `OrderResource`로 반환하고 아니면 404(마이페이지 주문 목록으로 안내), 비로그인이면 `X-Guest-Order-Token`으로 비회원 주문을 매칭해 `GuestOrderResource`로 반환하고 실패 시 404(비회원 조회 폼으로 안내)합니다. 회원이 비회원 토큰을 들고 와도 회원 분기가 우선하며, 실패 사유는 모두 동일한 404로 처리해 정보 노출을 차단합니다. 결제 완료 후 주문번호 기반 주문 완료/상세 페이지에서 사용합니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/payments.md b/modules/_bundled/sirsoft-ecommerce/docs/api/payments.md new file mode 100644 index 00000000..97a7316d --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/payments.md @@ -0,0 +1,58 @@ +# Payments API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Payments 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-ecommerce/payments/client-config/{provider} + +- **라우트명**: `api.modules.sirsoft-ecommerce.payments.client-config` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Shop\PaymentConfigController@clientConfig` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| provider | path | string | 예 | — | 대상 provider의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/payments/client-config/{provider} HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** PG 제공자(`provider`, 예: `tosspayments`)의 프론트엔드 결제 SDK 초기화에 필요한 클라이언트 설정을 반환하는 공개 엔드포인트입니다. 인증이 필요 없으며, 결제 페이지가 결제창을 띄우기 직전에 호출합니다. 실제 설정값은 `PaymentConfigController@clientConfig`가 `sirsoft-ecommerce.payment.get_client_config` 필터 훅을 실행해 각 PG 플러그인이 등록한 `client_key`·`sdk_url` 등을 수집한 결과이며, 코어는 어떤 PG도 하드코딩하지 않습니다. 해당 provider에 등록된 설정이 없으면(플러그인 미설치·미활성) 404를 반환합니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/presets.md b/modules/_bundled/sirsoft-ecommerce/docs/api/presets.md new file mode 100644 index 00000000..5cb25e37 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/presets.md @@ -0,0 +1,225 @@ +# Presets API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Presets 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-ecommerce/admin/presets + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.presets.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\SearchPresetController@index` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| target_screen | query | string | 아니오 | `products`, `orders`, `coupons` | 대상 검색 화면 (해당 화면의 프리셋만 조회, 미지정 시 `products`) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.search_preset.list_validation_rules`). + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/presets?target_screen=products HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.presets.fetch_success", + "data": [] +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자 검색 화면(상품/주문/쿠폰 등)에서 저장해 둔 검색 조건 프리셋 목록을 조회합니다. `auth:sanctum` 인증이 필요하며, `SearchPresetService::getPresets()`가 `target_screen`에 해당하는 프리셋을 반환합니다(미지정 시 `products` 화면 기준). 관리자가 자주 쓰는 필터 조합을 프리셋으로 관리해 검색 화면에서 빠르게 적용하기 위한 용도이며, 확장이 `list_validation_rules` 훅으로 조회 파라미터를 추가할 수 있습니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/presets + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.presets.store` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\SearchPresetController@store` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| target_screen | body | string | 아니오 | — | 프리셋이 속할 검색 화면 (`products`/`orders`/`coupons`, 미지정 시 `products`) | +| name | body | string | 예 | max 100 | 대상의 이름/명칭 | +| conditions | body | array | 예 | — | 저장할 검색 조건 배열 (필터 필드/값 조합, 적용 시 그대로 복원됨) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.preset.store_validation_rules`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/presets HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "target_screen": "예시값", + "name": "예시 이름", + "conditions": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자 검색 화면의 새 검색 조건 프리셋을 생성합니다. `auth:sanctum` 인증이 필요하며, `SearchPresetService::create()`가 `target_screen`(미지정 시 `products`)·`name`(최대 100자)·`conditions`(검색 조건 배열)을 받아 프리셋을 저장하고 성공 시 `201`로 생성된 프리셋을 반환합니다. 자주 쓰는 필터 조합을 이름 붙여 저장해 재사용하기 위한 용도이며, 확장이 `preset.store_validation_rules` 훅으로 파라미터를 추가할 수 있습니다. + + +### DELETE /api/modules/sirsoft-ecommerce/admin/presets/{preset} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.presets.destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\SearchPresetController@destroy` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| preset | path | string | 예 | — | 대상 preset의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/admin/presets/{preset} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 지정한 검색 조건 프리셋 1건을 삭제합니다. `auth:sanctum` 인증이 필요하며, path의 `{preset}`은 라우트 모델 바인딩으로 `SearchPreset` 모델이 주입되고 `SearchPresetService::delete()`가 삭제를 수행합니다. 삭제 성공 시 `{ "deleted": true }`를 반환하며, 존재하지 않는 프리셋이면 `404`를 반환합니다. 더 이상 사용하지 않는 저장 검색 조합을 정리하는 용도입니다. + + +### PUT /api/modules/sirsoft-ecommerce/admin/presets/{preset} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.presets.update` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\SearchPresetController@update` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| preset | path | string | 예 | — | 대상 preset의 식별자 | +| name | body | string | 예 | max 100 | 대상의 이름/명칭 | +| conditions | body | array | 예 | — | 저장할 검색 조건 배열 (필터 필드/값 조합, 적용 시 그대로 복원됨) | +| sort_order | body | integer | 아니오 | min 0 | 표시 정렬 순서 값 (작을수록 우선) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.preset.update_validation_rules`). + +**요청 예시** + +```http +PUT /api/modules/sirsoft-ecommerce/admin/presets/{preset} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": "예시 이름", + "conditions": [ + "예시값" + ], + "sort_order": 1 +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 기존 검색 조건 프리셋 1건을 수정합니다. `auth:sanctum` 인증이 필요하며, path의 `{preset}`은 라우트 모델 바인딩으로 주입되고 `SearchPresetService::update()`가 `name`(최대 100자)·`conditions`(검색 조건 배열)·선택 `sort_order`(정렬 순서, 0 이상)를 반영해 저장 후 갱신된 프리셋을 반환합니다. 저장해 둔 필터 조합의 이름·조건·노출 순서를 변경하는 용도이며, 확장이 `preset.update_validation_rules` 훅으로 파라미터를 추가할 수 있습니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/product-common-infos.md b/modules/_bundled/sirsoft-ecommerce/docs/api/product-common-infos.md new file mode 100644 index 00000000..09bb1d2e --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/product-common-infos.md @@ -0,0 +1,501 @@ +# Product Common Infos API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Product Common Infos 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-ecommerce/admin/product-common-infos + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.product-common-infos.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductCommonInfoController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.product-common-infos.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/product-common-infos HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `207` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"일반 배송 안내","en":"Standard Shipping"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `일반 배송 안내` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| content | object | `{"ko":"• 배송 기간: 결제 완료 후 1~3일 이내 출고 (영업일 기준)\n• 배송 업체: CJ대…` | 본문 내용 | +| localized_content | string | `• 배송 기간: 결제 완료 후 1~3일 이내 출고 (영업일 기준) …` | `content` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| content_mode | string | `text` | 내용 표시 모드 (`text` 일반 텍스트 / `html` HTML, 기본값 `text`) | +| is_default | boolean | `true` | default 여부 | +| is_active | boolean | `true` | active 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| icon | string | `info-circle` | 아이콘 식별자 (아이콘 클래스/이름) | +| created_at | string | `2026-06-15 11:24:00` | 생성 일시 | +| updated_at | string | `2026-06-15 11:24:00` | 최종 수정 일시 | +| products_count | integer | `81` | products 개수 (집계) | +| language_count | integer | `2` | language 개수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "공통정보 목록을 불러왔습니다.", + "data": { + "data": [ + { + "id": 1, + "name": { + "ko": "API 문서 샘플 공통정보", + "en": "API Doc Sample Common Info" + }, + "localized_name": "API 문서 샘플 공통정보", + "content": [], + "localized_content": "", + "content_mode": "text", + "is_default": false, + "is_active": true, + "sort_order": 0, + "icon": "info-circle", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "products_count": 0, + "language_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } + ], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + }, + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 1, + "from": 1, + "to": 1, + "has_more_pages": false + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-common-infos.read`)이 없는 경우 | + + + +**설명** 관리자가 상품 공통정보(배송·교환·반품 안내 등 여러 상품에 재사용되는 안내문) 목록을 조회합니다. `auth:sanctum` + admin + `sirsoft-ecommerce.product-common-infos.read` 권한이 필요하며, `ProductCommonInfoController@index`가 `search`·`active_only`·`default_only` 필터를 조립합니다. `per_page`가 0 이하이거나 `all`이면 `ProductCommonInfoService::getAllCommonInfos()`로 전체를 조회하고, 그 외에는 `getPaginatedCommonInfos()`로 페이지네이션 조회(`data.pagination` 포함)합니다. `localized_name`·`localized_content`는 현재 로케일로 해석된 값, `products_count`는 이 공통정보를 사용하는 상품 수입니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/product-common-infos + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.product-common-infos.store` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductCommonInfoController@store` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.product-common-infos.create` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| name | body | array | 예 | — | 대상의 이름/명칭 | +| content | body | array | 아니오 | — | 본문 내용 | +| content_mode | body | string | 아니오 | `text`, `html` | 내용 표시 모드 (`text` 일반 텍스트 / `html` HTML, 미지정 시 `text`) | +| is_default | body | boolean | 아니오 | — | 기본값 지정 여부 | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| sort_order | body | integer | 아니오 | min 0 | 표시 정렬 순서 값 (작을수록 우선) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product-common-info.create_validation_rules`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/product-common-infos HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": [ + "예시 이름" + ], + "content": [ + "예시 내용입니다." + ], + "content_mode": "text", + "is_default": true, + "is_active": true, + "sort_order": 1 +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-common-infos.create`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 새 상품 공통정보를 생성합니다. `auth:sanctum` + admin + `sirsoft-ecommerce.product-common-infos.create` 권한이 필요하며, `ProductCommonInfoController@store`가 `ProductCommonInfoService::createCommonInfo()`에 검증된 데이터를 전달해 저장합니다. `name`은 다국어 배열(필수), `content`는 다국어 안내 내용, `content_mode`는 `text`/`html` 중 하나이며, `is_default`·`is_active`·`sort_order`로 기본 사용 여부·활성 여부·정렬을 지정합니다. 확장이 `sirsoft-ecommerce.product-common-info.create_validation_rules` 필터로 추가 파라미터를 붙일 수 있고, 성공 시 HTTP 201을, 처리 실패 시 400 에러 응답을 반환합니다. + + +### DELETE /api/modules/sirsoft-ecommerce/admin/product-common-infos/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.product-common-infos.destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductCommonInfoController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.product-common-infos.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/admin/product-common-infos/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| common_info_id | integer | `1` | common info 식별자 (연관 리소스 참조) | +| products_count | integer | `0` | products 개수 (집계) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "공통정보가 삭제되었습니다.", + "data": { + "common_info_id": 1, + "products_count": 0 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-common-infos.delete`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 상품 공통정보 1건을 삭제합니다. `auth:sanctum` + admin + `sirsoft-ecommerce.product-common-infos.delete` 권한이 필요하며, `ProductCommonInfoController@destroy`가 `ProductCommonInfoService::deleteCommonInfo()`를 호출해 삭제합니다. path의 `id`에 해당하는 공통정보가 없거나 삭제 처리 중 오류가 발생하면 각각 404/400 에러 응답을 반환합니다. 여러 상품에서 참조 중인 공통정보를 삭제할 경우 노출에 영향을 줄 수 있으므로 `products_count`를 먼저 확인하는 것이 좋습니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/product-common-infos/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.product-common-infos.show` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductCommonInfoController@show` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.product-common-infos.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/product-common-infos/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 공통정보","en":"API Doc Sample Common Info"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 공통정보` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| content | array | `[]` | 본문 내용 | +| localized_content | string | `` | `content` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| content_mode | string | `text` | 내용 모드 (text: 텍스트, html: HTML) | +| is_default | boolean | `false` | default 여부 | +| is_active | boolean | `true` | active 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| icon | string | `info-circle` | 아이콘 식별자 (아이콘 클래스/이름) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | +| products_count | integer | `0` | products 개수 (집계) | +| language_count | integer | `0` | language 개수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "공통정보 목록을 불러왔습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 공통정보", + "en": "API Doc Sample Common Info" + }, + "localized_name": "API 문서 샘플 공통정보", + "content": [], + "localized_content": "", + "content_mode": "text", + "is_default": false, + "is_active": true, + "sort_order": 0, + "icon": "info-circle", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "products_count": 0, + "language_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-common-infos.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 상품 공통정보 1건의 상세를 조회합니다. `auth:sanctum` + admin + `sirsoft-ecommerce.product-common-infos.read` 권한이 필요하며, `ProductCommonInfoController@show`가 `ProductCommonInfoService::getCommonInfo()`로 단건을 조회합니다. 다국어 원본(`name`, `content`)과 현재 로케일 해석값(`localized_name`, `localized_content`), `content_mode`, 기본/활성 여부를 함께 반환하며, 해당 `id`의 공통정보가 없으면 404를 반환합니다. 주로 수정 화면 진입 시 기존 값을 불러오는 데 사용됩니다. + + +### PUT /api/modules/sirsoft-ecommerce/admin/product-common-infos/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.product-common-infos.update` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductCommonInfoController@update` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.product-common-infos.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| name | body | array | 예 | — | 대상의 이름/명칭 | +| content | body | array | 아니오 | — | 본문 내용 | +| content_mode | body | string | 아니오 | `text`, `html` | 내용 표시 모드 (`text` 일반 텍스트 / `html` HTML, 미지정 시 `text`) | +| is_default | body | boolean | 아니오 | — | 기본값 지정 여부 | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| sort_order | body | integer | 아니오 | min 0 | 표시 정렬 순서 값 (작을수록 우선) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product-common-info.update_validation_rules`). + +**요청 예시** + +```http +PUT /api/modules/sirsoft-ecommerce/admin/product-common-infos/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": [ + "예시 이름" + ], + "content": [ + "예시 내용입니다." + ], + "content_mode": "text", + "is_default": true, + "is_active": true, + "sort_order": 1 +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-common-infos.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 상품 공통정보 1건을 수정합니다. `auth:sanctum` + admin + `sirsoft-ecommerce.product-common-infos.update` 권한이 필요하며, `ProductCommonInfoController@update`가 `ProductCommonInfoService::updateCommonInfo()`에 검증된 데이터를 전달해 갱신합니다. `name`(다국어 배열)은 필수이고 `content`·`content_mode`·`is_default`·`is_active`·`sort_order`를 함께 변경할 수 있습니다. 확장이 `sirsoft-ecommerce.product-common-info.update_validation_rules` 필터로 파라미터를 추가할 수 있으며, 대상이 없거나 처리 실패 시 각각 404/400 에러 응답을 반환합니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/product-common-infos/{id}/toggle-active + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.product-common-infos.toggle-active` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductCommonInfoController@toggleActive` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.product-common-infos.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/product-common-infos/1/toggle-active HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 공통정보","en":"API Doc Sample Common Info"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 공통정보` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| content | array | `[]` | 본문 내용 | +| localized_content | string | `` | `content` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| content_mode | string | `text` | 내용 모드 (text: 텍스트, html: HTML) | +| is_default | boolean | `false` | default 여부 | +| is_active | boolean | `false` | active 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| icon | string | `info-circle` | 아이콘 식별자 (아이콘 클래스/이름) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 15:00:26` | 최종 수정 일시 | +| products_count | integer | `0` | products 개수 (집계) | +| language_count | integer | `0` | language 개수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "공통정보가 비활성화되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 공통정보", + "en": "API Doc Sample Common Info" + }, + "localized_name": "API 문서 샘플 공통정보", + "content": [], + "localized_content": "", + "content_mode": "text", + "is_default": false, + "is_active": false, + "sort_order": 0, + "icon": "info-circle", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:00:26", + "products_count": 0, + "language_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-common-infos.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 상품 공통정보의 활성/비활성 상태를 한 번의 요청으로 토글합니다. `auth:sanctum` + admin + `sirsoft-ecommerce.product-common-infos.update` 권한이 필요하며, `ProductCommonInfoController@toggleActive`가 `ProductCommonInfoService::toggleActive()`를 호출해 현재 `is_active` 값을 반전시킵니다. 반전 결과에 따라 활성화/비활성화 메시지를 구분해 응답하므로 목록 화면의 스위치 조작에 적합합니다. 대상이 없거나 처리 실패 시 각각 404/400 에러 응답을 반환합니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/product-image.md b/modules/_bundled/sirsoft-ecommerce/docs/api/product-image.md new file mode 100644 index 00000000..8c499c56 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/product-image.md @@ -0,0 +1,58 @@ +# Product Image API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Product Image 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-ecommerce/product-image/{hash} + +- **라우트명**: `api.modules.sirsoft-ecommerce.product-image.download` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\ProductImageController@download` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| hash | path | string | 예 | — | 대상 리소스의 해시 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/product-image/{hash} HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 공개 API로, 해시(`hash`, 12자)로 식별되는 상품 이미지 원본 파일을 스트리밍 서빙합니다. 인증이 필요 없으며(`PublicBaseController`), `ProductImageController@download`가 먼저 `ProductImageService::findByHash()`로 이미지 레코드 존재를 확인한 뒤 `ProductImageService::download()`로 `StorageInterface::response()` 기반 `StreamedResponse`를 반환합니다. 응답에는 저장된 `mime_type`과 `Cache-Control: public, max-age=31536000`(1년) 헤더가 부여되어 브라우저/CDN 캐싱에 최적화됩니다. 해시에 해당하는 레코드가 없거나 스토리지에 실제 파일이 없으면 404 에러 응답을 반환합니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/product-labels.md b/modules/_bundled/sirsoft-ecommerce/docs/api/product-labels.md new file mode 100644 index 00000000..ac029b50 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/product-labels.md @@ -0,0 +1,447 @@ +# Product Labels API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Product Labels 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-ecommerce/admin/product-labels + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.product-labels.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductLabelController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.product-labels.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| is_active | query | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| active_only | query | boolean | 아니오 | — | 활성 라벨만 필터 (true 시 내부적으로 `is_active=true` 로 변환 — 기존 호환용) | +| search | query | string | 아니오 | max 100 | 검색어 (지정한 검색 대상 필드에서 부분 일치) | +| sort | query | string | 아니오 | `name_asc`, `name_desc`, `created_asc`, `created_desc` | 정렬 기준 (필드명, `-` 접두 시 내림차순) | +| locale | query | string | 아니오 | `ko`, `en`, `fr`, `ja` | 로케일 코드 (표시 언어/지역) | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/product-labels?is_active=1&active_only=1&search=%EC%98%88%EC%8B%9C%EA%B0%92&sort=name_asc&locale=ko HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `37` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 라벨","en":"API Doc Sample Label"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| color | string | `#6B7280` | 라벨 색상 코드 (`#RRGGBB` 6자리 HEX, 뱃지 배경/글자색 등 표시에 사용) | +| is_active | boolean | `true` | active 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-07 14:47:31` | 생성 일시 | +| updated_at | string | `2026-07-07 14:47:31` | 최종 수정 일시 | +| assignments_count | integer | `0` | assignments 개수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "라벨 목록을 조회했습니다.", + "data": { + "data": [ + { + "id": 1, + "name": { + "ko": "API 문서 샘플 라벨", + "en": "API Doc Sample Label" + }, + "color": null, + "is_active": true, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "assignments_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } + ], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-labels.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 상품 라벨(예: "신상품", "베스트") 목록을 조회합니다. `auth:sanctum` + admin + `sirsoft-ecommerce.product-labels.read` 권한이 필요하며, `ProductLabelController@index`가 `ProductLabelService::getAllLabels()`에 검증된 필터를 전달해 조회합니다. `is_active` 또는 `active_only` 로 활성 라벨만 필터링하고, `search`(라벨명 검색), `sort`(이름/생성일 정렬), `locale`(다국어 정렬 기준)을 지원합니다. `active_only=true` 는 내부적으로 `is_active=true` 로 변환되어 기존 호환성을 유지하며, 각 항목의 `assignments_count` 로 라벨이 몇 개 상품에 부여됐는지 확인할 수 있습니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/product-labels + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.product-labels.store` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductLabelController@store` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.product-labels.create` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| name | body | array | 예 | — | 대상의 이름/명칭 | +| color | body | string | 예 | max 20 | 라벨 색상 코드 (필수, `#RRGGBB` 6자리 HEX 형식만 허용) | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| sort_order | body | integer | 아니오 | min 0 | 표시 정렬 순서 값 (작을수록 우선) | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/product-labels HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": [ + "예시 이름" + ], + "color": "#4F46E5", + "is_active": true, + "sort_order": 1 +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-labels.create`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 새 상품 라벨을 생성합니다. `auth:sanctum` + admin + `sirsoft-ecommerce.product-labels.create` 권한이 필요하며, `ProductLabelController@store`가 `ProductLabelService::createLabel()`에 검증된 데이터를 넘겨 저장합니다. `name`은 다국어 배열({ko, en, ...}), `color`는 라벨 색상 코드(최대 20자)이고, `is_active`·`sort_order`로 활성 여부와 정렬 순서를 지정합니다. 성공 시 HTTP 201과 함께 생성된 라벨 리소스를 반환합니다. + + +### DELETE /api/modules/sirsoft-ecommerce/admin/product-labels/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.product-labels.destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductLabelController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.product-labels.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/admin/product-labels/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| label_id | integer | `1` | label 식별자 (연관 리소스 참조) | +| products_count | integer | `0` | products 개수 (집계) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "라벨이 삭제되었습니다.", + "data": { + "label_id": 1, + "products_count": 0 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-labels.delete`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 상품 라벨 1건을 삭제합니다. `auth:sanctum` + admin + `sirsoft-ecommerce.product-labels.delete` 권한이 필요하며, `ProductLabelController@destroy`가 `ProductLabelService::deleteLabel()`을 호출해 삭제합니다. path의 `id`에 해당하는 라벨이 없으면 404를 반환하고, 삭제 중 오류가 발생하면 400 에러 응답을 반환합니다. 삭제 시 해당 라벨과 상품 간의 부여(assignment) 관계도 함께 정리됩니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/product-labels/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.product-labels.show` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductLabelController@show` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.product-labels.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/product-labels/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 라벨","en":"API Doc Sample Label"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| color | null | `null` | 색상 코드 (예: #FF5733) | +| is_active | boolean | `true` | active 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | +| assignments_count | integer | `0` | assignments 개수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "라벨 정보를 조회했습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 라벨", + "en": "API Doc Sample Label" + }, + "color": null, + "is_active": true, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "assignments_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-labels.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 상품 라벨 1건의 상세 정보를 조회합니다. `auth:sanctum` + admin + `sirsoft-ecommerce.product-labels.read` 권한이 필요하며, `ProductLabelController@show`가 `ProductLabelService::getLabel()`로 단건을 조회합니다. 다국어 라벨명(`name`), 색상, 활성 여부, 정렬 순서와 함께 `assignments_count`를 반환하며, 해당 `id`의 라벨이 없으면 404를 반환합니다. 주로 라벨 수정 화면 진입 시 기존 값을 불러오는 데 사용됩니다. + + +### PUT /api/modules/sirsoft-ecommerce/admin/product-labels/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.product-labels.update` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductLabelController@update` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.product-labels.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| name | body | array | 예 | — | 대상의 이름/명칭 | +| color | body | string | 예 | max 20 | 라벨 색상 코드 (필수, `#RRGGBB` 6자리 HEX 형식만 허용) | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| sort_order | body | integer | 아니오 | min 0 | 표시 정렬 순서 값 (작을수록 우선) | + +**요청 예시** + +```http +PUT /api/modules/sirsoft-ecommerce/admin/product-labels/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": [ + "예시 이름" + ], + "color": "#4F46E5", + "is_active": true, + "sort_order": 1 +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-labels.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 기존 상품 라벨 1건을 수정합니다. `auth:sanctum` + admin + `sirsoft-ecommerce.product-labels.update` 권한이 필요하며, `ProductLabelController@update`가 `ProductLabelService::updateLabel()`에 검증된 데이터를 전달해 갱신합니다. `name`(다국어 배열)과 `color`는 필수이며, `is_active`·`sort_order`도 함께 변경할 수 있습니다. 대상 라벨이 없으면 404, 갱신 처리 중 오류가 발생하면 400 에러 응답을 반환합니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/product-labels/{id}/toggle-status + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.product-labels.toggle-status` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductLabelController@toggleStatus` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.product-labels.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/product-labels/1/toggle-status HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 라벨","en":"API Doc Sample Label"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| color | null | `null` | 색상 코드 (예: #FF5733) | +| is_active | boolean | `false` | active 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 15:00:27` | 최종 수정 일시 | +| assignments_count | integer | `0` | assignments 개수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "라벨 상태가 변경되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 라벨", + "en": "API Doc Sample Label" + }, + "color": null, + "is_active": false, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:00:27", + "assignments_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-labels.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 상품 라벨의 활성/비활성 상태를 한 번의 요청으로 토글합니다. `auth:sanctum` + admin + `sirsoft-ecommerce.product-labels.update` 권한이 필요하며, `ProductLabelController@toggleStatus`가 `ProductLabelService::toggleStatus()`를 호출해 현재 `is_active` 값을 반전시킵니다. 별도의 본문 없이 path의 `id`만으로 동작하므로 목록 화면에서 스위치 조작으로 즉시 노출 여부를 바꾸는 데 적합합니다. 대상 라벨이 없으면 404, 처리 중 오류가 발생하면 400 에러 응답을 반환합니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/product-notice-templates.md b/modules/_bundled/sirsoft-ecommerce/docs/api/product-notice-templates.md new file mode 100644 index 00000000..3f578d88 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/product-notice-templates.md @@ -0,0 +1,590 @@ +# Product Notice Templates API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Product Notice Templates 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-ecommerce/admin/product-notice-templates + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.product-notice-templates.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductNoticeTemplateController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.product-notice-templates.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| search | query | string | 아니오 | max 200 | 검색어 (지정한 검색 대상 필드에서 부분 일치) | +| active_only | query | boolean | 아니오 | — | true 시 활성(is_active) 템플릿만 조회 | +| per_page | query | string | 아니오 | — | 페이지당 항목 수 | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/product-notice-templates?search=%EC%98%88%EC%8B%9C%EA%B0%92&active_only=1&per_page=%EC%98%88%EC%8B%9C%EA%B0%92&page=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `172` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 고시템플릿","en":"API Doc Sample Notice Templ…` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 고시템플릿` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| category | string | `clothing` | 이 템플릿이 적용되는 품목 카테고리 식별자 | +| fields | array | `[{"label":"품명","value":"샘플"}]` | 고시 항목 정의 배열 (항목별 name/content 다국어 — 상품 등록 시 고시 항목 자동 채움에 사용) | +| fields_count | integer | `1` | fields 개수 (집계) | +| is_active | boolean | `true` | active 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| icon | string | `file-alt` | 아이콘 식별자 (아이콘 클래스/이름) | +| created_at | string | `2026-07-07 14:47:31` | 생성 일시 | +| updated_at | string | `2026-07-07 14:47:31` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품정보제공고시 목록을 불러왔습니다.", + "data": { + "data": [ + { + "id": 1, + "name": { + "ko": "API 문서 샘플 고시템플릿", + "en": "API Doc Sample Notice Template" + }, + "localized_name": "API 문서 샘플 고시템플릿", + "category": null, + "fields": [ + { + "label": "품명", + "value": "샘플" + } + ], + "fields_count": 1, + "is_active": true, + "sort_order": 0, + "icon": "file-alt", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } + ], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + }, + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 1, + "from": 1, + "to": 1, + "has_more_pages": false + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-notice-templates.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 상품정보제공고시 템플릿(전자상거래법상 품목별 필수 고지 항목 세트) 목록을 조회합니다. `auth:sanctum` + admin + `sirsoft-ecommerce.product-notice-templates.read` 권한이 필요하며, `ProductNoticeTemplateController@index`가 `search`·`active_only` 필터를 조립합니다. `per_page`가 0 이하이거나 `all`이면 `ProductNoticeTemplateService::getAllTemplates()`로 전체를, 그 외에는 `getPaginatedTemplates()`로 페이지네이션 조회(`data.pagination` 포함)합니다. 각 항목은 다국어 템플릿명, 품목 `category`, 고시 항목 배열 `fields`(및 `fields_count`)를 포함합니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/product-notice-templates + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.product-notice-templates.store` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductNoticeTemplateController@store` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.product-notice-templates.create` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| name | body | array | 예 | — | 대상의 이름/명칭 | +| category | body | string | 아니오 | max 100 | 이 템플릿이 적용되는 품목 카테고리 식별자 | +| fields | body | array | 예 | min 1 | 고시 항목 정의 배열 (항목별 name/content 다국어, 최소 1개) | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| sort_order | body | integer | 아니오 | min 0 | 표시 정렬 순서 값 (작을수록 우선) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product-notice-template.create_validation_rules`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/product-notice-templates HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": [ + "예시 이름" + ], + "category": "예시값", + "fields": [ + "예시값" + ], + "is_active": true, + "sort_order": 1 +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-notice-templates.create`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 새 상품정보제공고시 템플릿을 생성합니다. `auth:sanctum` + admin + `sirsoft-ecommerce.product-notice-templates.create` 권한이 필요하며, `ProductNoticeTemplateController@store`가 `ProductNoticeTemplateService::createTemplate()`에 검증된 데이터를 전달해 저장합니다. `name`(다국어 배열)과 `fields`(고시 항목 정의, 최소 1개)는 필수이고, `category`(품목 카테고리), `is_active`, `sort_order`는 선택입니다. 확장이 `sirsoft-ecommerce.product-notice-template.create_validation_rules` 필터로 파라미터를 추가할 수 있으며, 성공 시 HTTP 201을, 처리 실패 시 400 에러 응답을 반환합니다. + + +### DELETE /api/modules/sirsoft-ecommerce/admin/product-notice-templates/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.product-notice-templates.destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductNoticeTemplateController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.product-notice-templates.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/admin/product-notice-templates/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| template_id | integer | `1` | template 식별자 (연관 리소스 참조) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품정보제공고시가 삭제되었습니다.", + "data": { + "template_id": 1 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-notice-templates.delete`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 상품정보제공고시 템플릿 1건을 삭제합니다. `auth:sanctum` + admin + `sirsoft-ecommerce.product-notice-templates.delete` 권한이 필요하며, `ProductNoticeTemplateController@destroy`가 `ProductNoticeTemplateService::deleteTemplate()`를 호출해 삭제합니다. path의 `id`에 해당하는 템플릿이 없거나 삭제 처리 중 오류가 발생하면 각각 404/400 에러 응답을 반환합니다. 삭제된 템플릿은 이후 신규 상품의 고시 항목 자동 채움에 더 이상 사용되지 않습니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/product-notice-templates/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.product-notice-templates.show` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductNoticeTemplateController@show` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.product-notice-templates.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/product-notice-templates/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 고시템플릿","en":"API Doc Sample Notice Templ…` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 고시템플릿` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| category | null | `null` | 품목 카테고리 | +| fields | array | `[{"label":"품명","value":"샘플"}]` | 필드 정의 JSON | +| fields_count | integer | `1` | fields 개수 (집계) | +| is_active | boolean | `true` | active 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| icon | string | `file-alt` | 아이콘 식별자 (아이콘 클래스/이름) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품정보제공고시 목록을 불러왔습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 고시템플릿", + "en": "API Doc Sample Notice Template" + }, + "localized_name": "API 문서 샘플 고시템플릿", + "category": null, + "fields": [ + { + "label": "품명", + "value": "샘플" + } + ], + "fields_count": 1, + "is_active": true, + "sort_order": 0, + "icon": "file-alt", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-notice-templates.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 상품정보제공고시 템플릿 1건의 상세를 조회합니다. `auth:sanctum` + admin + `sirsoft-ecommerce.product-notice-templates.read` 권한이 필요하며, `ProductNoticeTemplateController@show`가 `ProductNoticeTemplateService::getTemplate()`로 단건을 조회합니다. 다국어 템플릿명(`name`, `localized_name`), 품목 `category`, 고시 항목 배열 `fields`, 활성 여부·정렬 순서를 반환하며, 해당 `id`의 템플릿이 없으면 404를 반환합니다. 주로 템플릿 수정 화면 진입 시 기존 고시 항목을 불러오는 데 사용됩니다. + + +### PUT /api/modules/sirsoft-ecommerce/admin/product-notice-templates/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.product-notice-templates.update` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductNoticeTemplateController@update` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.product-notice-templates.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| name | body | array | 예 | — | 대상의 이름/명칭 | +| category | body | string | 아니오 | max 100 | 이 템플릿이 적용되는 품목 카테고리 식별자 | +| fields | body | array | 예 | min 1 | 고시 항목 정의 배열 (항목별 name/content 다국어, 최소 1개) | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| sort_order | body | integer | 아니오 | min 0 | 표시 정렬 순서 값 (작을수록 우선) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product-notice-template.update_validation_rules`). + +**요청 예시** + +```http +PUT /api/modules/sirsoft-ecommerce/admin/product-notice-templates/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": [ + "예시 이름" + ], + "category": "예시값", + "fields": [ + "예시값" + ], + "is_active": true, + "sort_order": 1 +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-notice-templates.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 상품정보제공고시 템플릿 1건을 수정합니다. `auth:sanctum` + admin + `sirsoft-ecommerce.product-notice-templates.update` 권한이 필요하며, `ProductNoticeTemplateController@update`가 `ProductNoticeTemplateService::updateTemplate()`에 검증된 데이터를 전달해 갱신합니다. `name`(다국어 배열)과 `fields`(최소 1개)는 필수이고, `category`·`is_active`·`sort_order`도 함께 변경할 수 있습니다. 확장이 `sirsoft-ecommerce.product-notice-template.update_validation_rules` 필터로 파라미터를 추가할 수 있으며, 대상이 없거나 처리 실패 시 각각 404/400 에러 응답을 반환합니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/product-notice-templates/{id}/copy + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.product-notice-templates.copy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductNoticeTemplateController@copy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.product-notice-templates.create` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/product-notice-templates/1/copy HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `2` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 고시템플릿 (복사)","en":"API Doc Sample Notice …` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 고시템플릿 (복사)` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| category | null | `null` | 품목 카테고리 | +| fields | array | `[{"label":"품명","value":"샘플"}]` | 필드 정의 JSON | +| fields_count | integer | `1` | fields 개수 (집계) | +| is_active | boolean | `true` | active 여부 | +| sort_order | integer | `1` | 표시 정렬 순서 값 (작을수록 우선) | +| icon | string | `file-alt` | 아이콘 식별자 (아이콘 클래스/이름) | +| created_at | string | `2026-07-08 15:00:27` | 생성 일시 | +| updated_at | string | `2026-07-08 15:00:27` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "상품정보제공고시가 복사되었습니다.", + "data": { + "id": 2, + "name": { + "ko": "API 문서 샘플 고시템플릿 (복사)", + "en": "API Doc Sample Notice Template (Copy)" + }, + "localized_name": "API 문서 샘플 고시템플릿 (복사)", + "category": null, + "fields": [ + { + "label": "품명", + "value": "샘플" + } + ], + "fields_count": 1, + "is_active": true, + "sort_order": 1, + "icon": "file-alt", + "created_at": "2026-07-08 15:00:27", + "updated_at": "2026-07-08 15:00:27", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-notice-templates.create`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 기존 상품정보제공고시 템플릿을 원본 삼아 복제본을 생성합니다. `auth:sanctum` + admin + `sirsoft-ecommerce.product-notice-templates.create` 권한이 필요하며(생성 계열이므로 create 권한 사용), `ProductNoticeTemplateController@copy`가 `ProductNoticeTemplateService::copyTemplate()`를 호출해 path의 `id` 템플릿을 복사합니다. 별도 본문 없이 원본 `id`만으로 동작하며, 복제된 새 템플릿을 HTTP 201로 반환합니다. 유사한 고시 항목 세트를 반복 작성하지 않고 빠르게 파생 템플릿을 만들 때 사용하며, 원본이 없거나 처리 실패 시 각각 404/400 에러 응답을 반환합니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/product-notice-templates/{id}/toggle-active + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.product-notice-templates.toggle-active` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductNoticeTemplateController@toggleActive` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.product-notice-templates.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/product-notice-templates/1/toggle-active HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 고시템플릿","en":"API Doc Sample Notice Templ…` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 고시템플릿` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| category | null | `null` | 품목 카테고리 | +| fields | array | `[{"label":"품명","value":"샘플"}]` | 필드 정의 JSON | +| fields_count | integer | `1` | fields 개수 (집계) | +| is_active | boolean | `false` | active 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| icon | string | `file-alt` | 아이콘 식별자 (아이콘 클래스/이름) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 15:00:27` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품정보제공고시가 비활성화되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 고시템플릿", + "en": "API Doc Sample Notice Template" + }, + "localized_name": "API 문서 샘플 고시템플릿", + "category": null, + "fields": [ + { + "label": "품명", + "value": "샘플" + } + ], + "fields_count": 1, + "is_active": false, + "sort_order": 0, + "icon": "file-alt", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:00:27", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-notice-templates.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 상품정보제공고시 템플릿의 활성/비활성 상태를 한 번의 요청으로 토글합니다. `auth:sanctum` + admin + `sirsoft-ecommerce.product-notice-templates.update` 권한이 필요하며, `ProductNoticeTemplateController@toggleActive`가 `ProductNoticeTemplateService::toggleActive()`를 호출해 현재 `is_active` 값을 반전시킵니다. 반전 결과에 따라 활성화/비활성화 메시지를 구분해 응답하므로 목록 화면의 스위치 조작에 적합합니다. 대상이 없거나 처리 실패 시 각각 404/400 에러 응답을 반환합니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/products.md b/modules/_bundled/sirsoft-ecommerce/docs/api/products.md new file mode 100644 index 00000000..8464fbfa --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/products.md @@ -0,0 +1,4266 @@ +# Products API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Products 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-ecommerce/admin/products + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.products.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.products.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| search_field | query | string | 아니오 | `all`, `name`, `product_code`, `sku`, `barcode` | 검색 대상 필드명 (검색어를 적용할 컬럼) | +| search_keyword | query | string | 아니오 | max 200 | 검색 키워드 (부분 일치) | +| category_id | query | integer | 아니오 | — | category 식별자 | +| no_category | query | boolean | 아니오 | — | 카테고리 미지정 상품만 필터 (true 시 어떤 카테고리에도 속하지 않은 상품 조회) | +| date_type | query | string | 아니오 | — | 기간 필터 기준 날짜 컬럼 (created_at 등록일 / updated_at 수정일) | +| start_date | query | date | 아니오 | — | 조회 기간 시작일 (이 날짜 이후 데이터) | +| end_date | query | date | 아니오 | — | 조회 기간 종료일 (이 날짜 이전 데이터) | +| sales_status | query | array | 아니오 | — | 판매상태 다중 필터 (on_sale/suspended/sold_out/coming_soon 값 배열, 해당 상태만 조회) | +| display_status | query | string | 아니오 | — | 전시상태 필터 (visible 전시 / hidden 숨김) | +| brand_id | query | integer | 아니오 | — | brand 식별자 | +| no_brand | query | boolean | 아니오 | — | 브랜드 미지정 상품만 필터 (true 시 브랜드가 없는 상품 조회) | +| tax_status | query | string | 아니오 | — | 과세여부 필터 (taxable 과세 / tax_free 면세) | +| price_type | query | string | 아니오 | — | 가격 범위 필터의 기준 가격 종류 (selling_price 판매가 / supply_price 공급가 / list_price 정가) | +| min_price | query | integer | 아니오 | min 0 | 가격 범위 필터 하한 (price_type 기준 이 값 이상) | +| max_price | query | integer | 아니오 | min 0 | 가격 범위 필터 상한 (price_type 기준 이 값 이하) | +| min_stock | query | integer | 아니오 | — | 재고 범위 필터 하한 (재고 수량이 이 값 이상) | +| max_stock | query | integer | 아니오 | — | 재고 범위 필터 상한 (재고 수량이 이 값 이하) | +| shipping_policy_id | query | integer | 아니오 | — | shipping policy 식별자 | +| sort_by | query | string | 아니오 | `created_at`, `updated_at`, `selling_price`, `stock_quantity`, `name` | 정렬 기준 필드명 | +| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) | +| per_page | query | integer | 아니오 | min 10, max 100 | 페이지당 항목 수 | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product.list_validation_rules`, `sirsoft-ecommerce.product.list_validation_messages`). + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/products?search_field=all&search_keyword=%EC%98%88%EC%8B%9C%EA%B0%92&category_id=1&no_category=1&date_type=%EC%98%88%EC%8B%9C%EA%B0%92&start_date=2026-01-01&end_date=2026-01-01&sales_status=%EC%98%88%EC%8B%9C%EA%B0%92&display_status=%EC%98%88%EC%8B%9C%EA%B0%92&brand_id=1&no_brand=1&tax_status=%EC%98%88%EC%8B%9C%EA%B0%92&price_type=%EC%98%88%EC%8B%9C%EA%B0%92&min_price=1&max_price=1&min_stock=1&max_stock=1&shipping_policy_id=1&sort_by=created_at&sort_order=asc&per_page=1&page=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| number | integer | `115` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| id | integer | `322` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"eum et quia","en":"tenetur id quae"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name_localized | string | `eum et quia` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| product_code | string | `PROD-GJUX-1484` | 상품코드 (상품 고유 관리 식별자) | +| sku | string | `SKU-MRAD-9306` | 재고관리코드(SKU) | +| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL | +| list_price | integer | `112594` | 정가 (기본통화 자릿수로 정규화된 값) | +| list_price_formatted | string | `112,594원` | `list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| selling_price | integer | `88949` | 판매가 (기본통화 자릿수로 정규화된 값) | +| selling_price_formatted | string | `88,949원` | `selling_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| discount_rate | integer | `21` | 할인율(%) (정가 대비 판매가 할인 비율, (1 - 판매가/정가) × 100) | +| multi_currency_list_price | object | `{"KRW":{"price":112594,"formatted":"112,594원","is_default…` | 통화별 정가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 정가) | +| multi_currency_selling_price | object | `{"KRW":{"price":88949,"formatted":"88,949원","is_default":…` | 통화별 판매가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 판매가) | +| stock_quantity | integer | `22` | 재고 수량 (옵션 사용 시 옵션 재고 합계) | +| safe_stock_quantity | integer | `12` | 안전재고 수량 (이 값 미만이면 재고 부족으로 표시) | +| is_below_safe_stock | boolean | `false` | below safe stock 여부 | +| option_stock_sum | integer | `51` | 활성 옵션의 재고 합계 (is_active 옵션들의 stock_quantity 총합) | +| sales_status | string | `on_sale` | 판매상태 값 (on_sale 판매중 / suspended 판매중지 / sold_out 품절 / coming_soon 출시예정) | +| sales_status_label | string | `판매중` | `sales_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| sales_status_variant | string | `success` | `sales_status` 값의 표시 변형 키 (UI 배지 색상/스타일) | +| display_status | string | `visible` | 전시상태 값 (visible 전시 / hidden 숨김) | +| display_status_label | string | `전시` | `display_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| display_status_variant | string | `success` | `display_status` 값의 표시 변형 키 (UI 배지 색상/스타일) | +| categories | array | `[]` | 소속 카테고리 목록 (각 항목: id·현지화 이름·대표 여부. categories 관계 eager load 시에만 채워짐) | +| primary_category | string | `바지` | 대표 카테고리명 (is_primary 카테고리의 현지화 이름) | +| categories_with_path | array | `[]` | 소속 카테고리 목록 + 경로 (각 항목: id·breadcrumb path·대표 여부) | +| brand_name | string | `ASUS` | 브랜드명 (연관 브랜드의 현지화 이름) | +| shipping_policy_id | integer | `31` | shipping policy 식별자 (연관 리소스 참조) | +| shipping_policy_name | string | `국내 무료배송` | 배송정책명 (연관 배송정책의 현지화 이름) | +| min_purchase_qty | integer | `1` | 최소 구매 수량 (1회 주문 시 이 수량 이상 구매) | +| max_purchase_qty | integer | `0` | 최대 구매 수량 (0=무제한) | +| has_options | boolean | `false` | options 여부 | +| options_count | integer | `1` | options 개수 (집계) | +| options | array | `[{"id":1597,"option_code":"OPT-JLNF-2511","option_values"…` | 활성 옵션(SKU) 목록 (각 옵션의 코드·옵션값·가격·재고 등, ProductOptionResource) | +| review_count | integer | `0` | review 개수 (집계) | +| rating_avg | integer | `0` | 평균 별점 (공개 리뷰 별점 평균, 소수 1자리 반올림) | +| created_at | string | `2026-07-07 14:47:31` | 생성 일시 | +| updated_at | string | `2026-07-07 14:47:31` | 최종 수정 일시 | +| is_owner | boolean | `false` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품 정보를 조회했습니다.", + "data": { + "data": [ + { + "number": 3, + "id": 3, + "name": { + "ko": "nisi et corrupti", + "en": "suscipit esse id" + }, + "name_localized": "nisi et corrupti", + "product_code": "PROD-YNAZ-3669", + "sku": "SKU-EOQY-1167", + "thumbnail_url": null, + "list_price": 22605, + "list_price_formatted": "22,605원", + "selling_price": 20118, + "selling_price_formatted": "20,118원", + "discount_rate": 11, + "multi_currency_list_price": { + "KRW": { + "price": 22605, + "formatted": "22,605원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 19.21, + "formatted": "$19.21", + "is_default": false, + "editable": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 2599, + "formatted": "¥2,599", + "is_default": false, + "editable": false, + "exchange_rate": 115 + }, + "CNY": { + "price": 131.11, + "formatted": "元131.11", + "is_default": false, + "editable": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 17.63, + "formatted": "€17.63", + "is_default": false, + "editable": false, + "exchange_rate": 0.78 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 20118, + "formatted": "20,118원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 17.1, + "formatted": "$17.10", + "is_default": false, + "editable": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 2313, + "formatted": "¥2,313", + "is_default": false, + "editable": false, + "exchange_rate": 115 + }, + "CNY": { + "price": 116.68, + "formatted": "元116.68", + "is_default": false, + "editable": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 15.69, + "formatted": "€15.69", + "is_default": false, + "editable": false, + "exchange_rate": 0.78 + } + }, + "stock_quantity": 164, + "safe_stock_quantity": 8, + "is_below_safe_stock": false, + "option_stock_sum": 24, + "sales_status": "on_sale", + "sales_status_label": "판매중", + "sales_status_variant": "success", + "display_status": "visible", + "display_status_label": "전시", + "display_status_variant": "success", + "categories": [], + "primary_category": null, + "categories_with_path": [], + "brand_name": null, + "shipping_policy_id": null, + "shipping_policy_name": null, + "min_purchase_qty": 1, + "max_purchase_qty": 0, + "has_options": false, + "options_count": 1, + "options": [ + { + "id": 1, + "option_code": "OPT-IXWA-1855", + "option_values": [ + { + "key": { + "ko": "색상", + "en": "Color" + }, + "value": { + "ko": "흰색", + "en": "Blue" + } + }, + { + "key": { + "ko": "사이즈", + "en": "Size" + }, + "value": { + "ko": "M", + "en": "M" + } + } + ], + "option_values_localized": { + "색상": "흰색", + "사이즈": "M" + }, + "option_name": { + "ko": "흰색/M", + "en": "Blue/M" + }, + "option_name_localized": "흰색/M", + "price_adjustment": 1000, + "price_adjustment_formatted": "+1,000원", + "price_adjustment_type": "increase", + "list_price": 23605, + "list_price_formatted": "23,605원", + "selling_price": 21118, + "selling_price_formatted": "21,118원", + "final_price": 21118, + "final_price_formatted": "21,118원", + "multi_currency_list_price": { + "KRW": { + "price": 23605, + "formatted": "23,605원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 20.06, + "formatted": "$20.06", + "is_default": false, + "editable": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 2714, + "formatted": "¥2,714", + "is_default": false, + "editable": false, + "exchange_rate": 115 + }, + "CNY": { + "price": 136.91, + "formatted": "元136.91", + "is_default": false, + "editable": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 18.41, + "formatted": "€18.41", + "is_default": false, + "editable": false, + "exchange_rate": 0.78 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 21118, + "formatted": "21,118원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 17.95, + "formatted": "$17.95", + "is_default": false, + "editable": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 2428, + "formatted": "¥2,428", + "is_default": false, + "editable": false, + "exchange_rate": 115 + }, + "CNY": { + "price": 122.48, + "formatted": "元122.48", + "is_default": false, + "editable": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 16.47, + "formatted": "€16.47", + "is_default": false, + "editable": false, + "exchange_rate": 0.78 + } + }, + "stock_quantity": 24, + "safe_stock_quantity": 11, + "is_below_safe_stock": false, + "is_sold_out": false, + "is_default": false, + "is_active": true, + "sku": "SKU-OPT-MMYU-1269", + "sort_order": 75 + } + ], + "review_count": 0, + "rating_avg": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "number": 2, + "id": 2, + "name": { + "ko": "repudiandae et et", + "en": "velit alias distinctio" + }, + "name_localized": "repudiandae et et", + "product_code": "PROD-XSMH-5438", + "sku": "SKU-XXSC-9588", + "thumbnail_url": null, + "list_price": 654947, + "list_price_formatted": "654,947원", + "selling_price": 465012, + "selling_price_formatted": "465,012원", + "discount_rate": 29, + "multi_currency_list_price": { + "KRW": { + "price": 654947, + "formatted": "654,947원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 556.7, + "formatted": "$556.70", + "is_default": false, + "editable": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 75318, + "formatted": "¥75,318", + "is_default": false, + "editable": false, + "exchange_rate": 115 + }, + "CNY": { + "price": 3798.69, + "formatted": "元3,798.69", + "is_default": false, + "editable": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 510.86, + "formatted": "€510.86", + "is_default": false, + "editable": false, + "exchange_rate": 0.78 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 465012, + "formatted": "465,012원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 395.26, + "formatted": "$395.26", + "is_default": false, + "editable": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 53476, + "formatted": "¥53,476", + "is_default": false, + "editable": false, + "exchange_rate": 115 + }, + "CNY": { + "price": 2697.07, + "formatted": "元2,697.07", + "is_default": false, + "editable": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 362.71, + "formatted": "€362.71", + "is_default": false, + "editable": false, + "exchange_rate": 0.78 + } + }, + "stock_quantity": 161, + "safe_stock_quantity": 49, + "is_below_safe_stock": false, + "option_stock_sum": 0, + "sales_status": "on_sale", + "sales_status_label": "판매중", + "sales_status_variant": "success", + "display_status": "visible", + "display_status_label": "전시", + "display_status_variant": "success", + "categories": [], + "primary_category": null, + "categories_with_path": [], + "brand_name": null, + "shipping_policy_id": null, + "shipping_policy_name": null, + "min_purchase_qty": 1, + "max_purchase_qty": 0, + "has_options": false, + "options_count": 0, + "options": [], + "review_count": 0, + "rating_avg": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + }, + "... (총 3건 중 2건 표시)" + ], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + }, + "statistics": { + "total": 3, + "sales_status": { + "on_sale": 3 + }, + "display_status": { + "visible": 3 + }, + "low_stock_count": 0, + "out_of_stock_count": 0 + }, + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 3, + "from": 1, + "to": 3, + "has_more_pages": false + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자 상품 목록을 페이지네이션으로 조회합니다. `auth:sanctum` + `sirsoft-ecommerce.products.read` 권한이 필요하며, `ProductService::getList()`가 검색어/카테고리/판매·전시상태/가격·재고 범위 등 다양한 필터를 적용해 목록을 반환하고 `getStatistics()`로 집계 통계를 함께 제공합니다. 응답은 `ProductCollection`으로 감싸져 `withStatistics()`로 통계가 병합됩니다. 확장은 `sirsoft-ecommerce.product.list_validation_rules` 훅으로 추가 필터 파라미터를 주입할 수 있습니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/products + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.products.store` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductController@store` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.products.create` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| name | body | array | 예 | — | 대상의 이름/명칭 | +| product_code | body | string | 예 | max 50 | 상품코드 (상품 고유 관리 식별자, 상품 간 중복 불가) | +| sales_product_code | body | string | 아니오 | max 50 | 판매자 상품코드 (판매자가 직접 입력하는 관리용 코드) | +| sku | body | string | 아니오 | max 100 | 재고관리코드(SKU) | +| category_ids | body | array | 예 | min 1, max 5 | category 식별자 배열 | +| primary_category_id | body | integer | 아니오 | — | primary category 식별자 | +| brand_id | body | integer | 아니오 | — | brand 식별자 | +| list_price | body | integer | 예 | min 0.01 | 정가 (기본통화 기준, 소수 통화는 소수 입력 허용) | +| selling_price | body | integer | 예 | min 0.01 | 판매가 (기본통화 기준, 정가 이하여야 함) | +| stock_quantity | body | integer | 예 | min 0 | 재고 수량 (옵션 사용 시 옵션 재고 합계로 관리) | +| safe_stock_quantity | body | integer | 아니오 | min 0 | 안전재고 수량 (이 값 미만이면 재고 부족 표시) | +| sales_status | body | string | 예 | — | 판매상태 (on_sale 판매중 / suspended 판매중지 / sold_out 품절 / coming_soon 출시예정) | +| display_status | body | string | 예 | — | 전시상태 (visible 전시 / hidden 숨김) | +| tax_status | body | string | 예 | — | 과세여부 (taxable 과세 / tax_free 면세) | +| tax_rate | body | number | 아니오 | min 0, max 100 | 세율(%) (과세 상품의 부가세 계산 비율) | +| shipping_policy_id | body | integer | 아니오 | — | shipping policy 식별자 | +| common_info_id | body | integer | 아니오 | — | common info 식별자 | +| description | body | array | 아니오 | — | 설명 | +| description_mode | body | string | 아니오 | `text`, `html` | 상세 설명 편집 모드 (text 일반 텍스트 / html HTML 에디터) | +| thumbnail_hash | body | string | 아니오 | max 64 | 대표 이미지로 지정할 이미지 해시 (업로드된 이미지 중 썸네일 선택) | +| image_temp_key | body | string | 아니오 | max 64 | 임시 업로드 세션 키 (사전 업로드한 이미지를 이 상품에 연결) | +| images | body | array | 아니오 | max 20 | 상품 이미지 목록 (각 항목: id/hash/url/alt_text/is_thumbnail/sort_order) | +| meta_title | body | array | 아니오 | — | SEO 메타 제목 (검색엔진/소셜 공유 표시 제목) | +| meta_description | body | array | 아니오 | — | SEO 메타 설명 (검색엔진/소셜 공유 표시 요약) | +| meta_keywords | body | array | 아니오 | — | SEO 메타 키워드 배열 (검색엔진 색인용 키워드 목록) | +| seo_sync_title | body | boolean | 아니오 | — | SEO 제목 자동 동기화 여부 (true 시 상품명으로 메타 제목 자동 채움) | +| seo_sync_description | body | boolean | 아니오 | — | SEO 설명 자동 동기화 여부 (true 시 상품 설명으로 메타 설명 자동 채움) | +| use_main_image_for_og | body | boolean | 아니오 | — | 대표 이미지를 OG(소셜 공유) 이미지로 사용할지 여부 | +| has_options | body | boolean | 아니오 | — | options 여부 | +| option_groups | body | array | 아니오 | — | 옵션 그룹 정의 (예: 색상/사이즈 등 옵션 축과 각 축의 선택값 목록) | +| options | body | array | 예 | min 1 | 옵션(SKU) 목록 (각 항목: 옵션코드·옵션명·옵션값·정가·판매가·재고 등, 최소 1건 필수) | +| additional_options | body | array | 아니오 | max 5 | 추가옵션 그룹 배열 (각 그룹당 선택지 1~20개, 필수 여부·추가금·직접입력 허용 등 설정) | +| notice_items | body | array | 아니오 | max 50 | 상품정보제공고시 항목 배열 (각 항목: 항목명·내용 다국어) | +| label_assignments | body | array | 아니오 | — | 라벨 할당 배열 (label_id + 노출 시작/종료일로 상품에 라벨 부착) | +| min_purchase_qty | body | integer | 아니오 | min 1 | 최소 구매 수량 (1회 주문 시 이 수량 이상 구매) | +| max_purchase_qty | body | integer | 아니오 | min 0 | 최대 구매 수량 (0=무제한) | +| purchase_restriction | body | string | 아니오 | `none`, `restricted` | 구매 대상 제한 (none 제한 없음 / restricted 특정 역할만 구매 허용) | +| allowed_roles | body | array | 아니오 | — | 구매 허용 역할 ID 배열 (purchase_restriction=restricted 시 필수) | +| barcode | body | string | 아니오 | max 50 | 바코드 | +| hs_code | body | string | 아니오 | max 20 | HS 코드 (수출입 관세 분류 코드) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product.store_validation_rules`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/products HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": [ + "예시 이름" + ], + "product_code": "예시값", + "sales_product_code": "예시값", + "sku": "예시값", + "category_ids": [ + "예시값" + ], + "primary_category_id": 1, + "brand_id": 1, + "list_price": 1, + "selling_price": 1, + "stock_quantity": 1, + "safe_stock_quantity": 1, + "sales_status": "예시값", + "display_status": "예시값", + "tax_status": "예시값", + "tax_rate": 1, + "shipping_policy_id": 1, + "common_info_id": 1, + "description": [ + "예시 내용입니다." + ], + "description_mode": "text", + "thumbnail_hash": "예시값", + "image_temp_key": "예시값", + "images": [ + "예시값" + ], + "meta_title": [ + "예시 제목" + ], + "meta_description": [ + "예시 내용입니다." + ], + "meta_keywords": [ + "예시값" + ], + "seo_sync_title": true, + "seo_sync_description": true, + "use_main_image_for_og": true, + "has_options": true, + "option_groups": [ + "예시값" + ], + "options": [ + "예시값" + ], + "additional_options": [ + "예시값" + ], + "notice_items": [ + "예시값" + ], + "label_assignments": [ + "예시값" + ], + "min_purchase_qty": 1, + "max_purchase_qty": 1, + "purchase_restriction": "none", + "allowed_roles": [ + "예시값" + ], + "barcode": "예시값", + "hs_code": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.create`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 새 상품을 생성합니다. `auth:sanctum` + `sirsoft-ecommerce.products.create` 권한이 필요하며, `StoreProductRequest`로 검증된 데이터를 `ProductService::create()`에 넘겨 상품·옵션·카테고리·이미지·SEO 메타를 함께 저장하고 성공 시 201과 `ProductResource`를 반환합니다. 이미지는 사전에 `POST .../products/images`로 임시 업로드한 뒤 `image_temp_key`(또는 `images`/`thumbnail_hash`)로 연결하며, `options`는 최소 1건 필수입니다. 검증 실패는 422, 그 외 오류는 500으로 응답합니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/products/bulk-price + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.products.bulk-price` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductController@bulkUpdatePrice` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.products.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| ids | body | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | +| method | body | string | 예 | `increase`, `decrease`, `set` | 변경 방식 (increase 증가 / decrease 감소 / set 지정값으로 설정) | +| value | body | number | 예 | min 0 | 값 | +| unit | body | string | 예 | `won`, `percent` | 변경 단위 (won 금액 단위 / percent 판매가 대비 비율) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product.bulk_price_validation_rules`). + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/products/bulk-price HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "ids": [ + "예시값" + ], + "method": "increase", + "value": 1, + "unit": "won" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 선택한 여러 상품의 판매가를 일괄 변경합니다. `auth:sanctum` + `sirsoft-ecommerce.products.update` 권한이 필요하며, `ProductService::bulkUpdatePrice()`가 `ids` 목록에 대해 `method`(increase/decrease/set)와 `unit`(won/percent) 조합으로 가격을 재계산해 저장합니다. 응답의 `updated_count`가 실제 반영 건수로 메타에 담기며, 대량 변경은 상품별 개별 활동 로그로 기록됩니다. 확장은 `sirsoft-ecommerce.product.bulk_price_validation_rules` 훅으로 검증 규칙을 확장할 수 있습니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/products/bulk-status + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.products.bulk-status` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductController@bulkUpdateStatus` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.products.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| ids | body | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | +| field | body | string | 예 | `sales_status`, `display_status` | 일괄 변경할 상태 필드 (sales_status 판매상태 / display_status 전시상태) | +| value | body | string | 예 | — | 값 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product.bulk_status_validation_rules`). + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/products/bulk-status HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "ids": [ + "예시값" + ], + "field": "sales_status", + "value": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 선택한 여러 상품의 상태를 일괄 변경합니다. `auth:sanctum` + `sirsoft-ecommerce.products.update` 권한이 필요하며, `ProductService::bulkUpdateStatus()`가 `field`(sales_status 또는 display_status)를 지정한 `value`로 `ids` 대상에 일괄 적용합니다. 예를 들어 판매중지된 상품을 한 번에 판매중으로 전환하거나 노출/숨김을 일괄 조정할 때 사용하며, 반영 건수는 `updated_count`로 반환됩니다. 확장은 `sirsoft-ecommerce.product.bulk_status_validation_rules` 훅으로 검증 규칙을 확장할 수 있습니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/products/bulk-stock + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.products.bulk-stock` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductController@bulkUpdateStock` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.products.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| ids | body | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | +| method | body | string | 예 | `increase`, `decrease`, `set` | 변경 방식 (increase 증가 / decrease 감소 / set 지정값으로 설정) | +| value | body | integer | 예 | min 0 | 값 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product.bulk_stock_validation_rules`). + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/products/bulk-stock HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "ids": [ + "예시값" + ], + "method": "increase", + "value": 1 +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 선택한 여러 상품의 재고를 일괄 변경합니다. `auth:sanctum` + `sirsoft-ecommerce.products.update` 권한이 필요하며, `ProductService::bulkUpdateStock()`가 `ids` 목록에 대해 `method`(increase/decrease/set)와 정수 `value`를 적용해 재고 수량을 조정합니다. 입고/재고 실사 반영 등 다건 재고 보정에 사용하며, 반영 건수는 `updated_count`로 반환됩니다. 확장은 `sirsoft-ecommerce.product.bulk_stock_validation_rules` 훅으로 검증 규칙을 확장할 수 있습니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/products/bulk-update + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.products.bulk-update` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductController@bulkUpdate` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.products.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| ids | body | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | +| bulk_changes | body | array | 아니오 | — | 상품 조건 기반 일괄 변경값 (지정 시 ids 전체에 sales_status/display_status 일괄 적용) | +| items | body | array | 아니오 | — | 처리 대상 항목 배열 | +| option_bulk_changes | body | array | 아니오 | — | 옵션 조건 기반 일괄 변경값 (price_adjustment/stock_quantity 를 method+value 로 일괄 조정) | +| option_items | body | array | 아니오 | — | 옵션 개별 인라인 수정 배열 (각 항목: product_id·option_id + 수정할 옵션 필드) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product.bulk_update_validation_rules`). + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/products/bulk-update HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "ids": [ + "예시값" + ], + "bulk_changes": [ + "예시값" + ], + "items": [ + "예시값" + ], + "option_bulk_changes": [ + "예시값" + ], + "option_items": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 상품과 옵션을 통합 일괄 수정합니다. `auth:sanctum` + `sirsoft-ecommerce.products.update` 권한이 필요하며, `ProductService::bulkUpdate()`가 조건 기반 일괄 변경(`bulk_changes`/`option_bulk_changes`)과 행별 인라인 수정(`items`/`option_items`)을 함께 처리합니다. 일괄 변경 조건이 지정된 필드는 우선 적용되고 나머지는 개별 수정값이 반영되며, 응답 메타의 `count`는 상품 반영 건수와 옵션 반영 건수를 합산한 값입니다. 관리자 목록 화면의 인라인 편집·일괄 편집을 한 요청으로 저장하는 데 사용됩니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/products/by-code/{code} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.products.show-by-code` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductController@showByCode` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.products.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| code | path | string | 예 | — | 대상 리소스의 코드 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/products/by-code/APIDOCSAMPLE01 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 상품","en":"API Doc Sample Product"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name_localized | string | `API 문서 샘플 상품` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| product_code | string | `APIDOCSAMPLE01` | 상품코드 | +| sku | string | `SKU-DKOK-1319` | SKU | +| categories | array | `[]` | 소속 카테고리 목록 (각 항목: id·현지화 이름·대표 여부. categories 관계 eager load 시에만 채워짐) | +| category_ids | array | `[]` | category 식별자 배열 (연관 리소스 참조) | +| primary_category_id | null | `null` | primary category 식별자 (연관 리소스 참조) | +| brand_id | null | `null` | brand 식별자 (연관 리소스 참조) | +| list_price | integer | `703155` | 정가 (기본통화 기준) | +| selling_price | integer | `597682` | 판매가 (기본통화 기준) | +| discount_rate | integer | `15` | 할인율(%) (정가 대비 판매가 할인 비율, (1 - 판매가/정가) × 100) | +| stock_quantity | integer | `509` | 재고 수량 (옵션 있으면 옵션 합계) | +| safe_stock_quantity | integer | `38` | 안전재고 수량 | +| is_below_safe_stock | boolean | `false` | below safe stock 여부 | +| is_stock_consistent | boolean | `true` | stock consistent 여부 | +| sales_status | string | `on_sale` | 판매상태: on_sale(판매중), suspended(판매중지), sold_out(품절), coming_soon(출시예정) | +| sales_status_label | string | `판매중` | `sales_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| display_status | string | `visible` | 전시상태: visible(전시), hidden(숨김) | +| display_status_label | string | `전시` | `display_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| tax_status | string | `taxable` | 과세여부: taxable(과세), tax_free(면세) | +| tax_status_label | string | `과세` | `tax_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| tax_rate | string | `10.00` | 세율 (%) | +| shipping_policy_id | null | `null` | shipping policy 식별자 (연관 리소스 참조) | +| common_info_id | null | `null` | common info 식별자 (연관 리소스 참조) | +| description | object | `{"ko":"Tempore et non fugit eos et aperiam dolore. Aut es…` | 설명 (다국어 필드는 로케일별 값 객체) | +| description_localized | string | `Tempore et non fugit eos et aperiam d…` | `description` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| description_mode | string | `text` | 설명 모드: text(텍스트), html(HTML) | +| min_purchase_qty | integer | `1` | 최소 구매 수량 | +| max_purchase_qty | integer | `0` | 최대 구매 수량 (0=무제한) | +| purchase_restriction | string | `none` | 구매 제한: none(없음), restricted(제한) | +| allowed_roles | null | `null` | 구매 허용 역할 ID 배열 | +| barcode | null | `null` | 바코드 | +| hs_code | null | `null` | HS 코드 (관세 분류) | +| images | array | `[]` | 상품 이미지 목록 (각 항목: hash·url·alt_text·is_thumbnail·sort_order 등, images 관계 로드 시) | +| thumbnail_hash | null | `null` | 대표 이미지 해시 (썸네일로 지정된 이미지의 hash) | +| thumbnail_url | null | `null` | thumbnail URL | +| meta_title | null | `null` | SEO 제목 (다국어 JSON) | +| meta_description | null | `null` | SEO 설명 (다국어 JSON) | +| meta_keywords | null | `null` | SEO 키워드 (배열) | +| seo_sync_title | boolean | `true` | SEO 제목 동기화 여부 (1: 상품명으로 자동 채움, 0: 직접 입력 보존) | +| seo_sync_description | boolean | `true` | SEO 설명 동기화 여부 (1: 상품 설명으로 자동 채움, 0: 직접 입력 보존) | +| has_options | boolean | `false` | options 여부 | +| option_groups | array | `[]` | 옵션 그룹 정의: [{name: "색상", values: ["빨강", "파랑"]}] | +| options | array | `[]` | 옵션(SKU) 목록 (수정 폼 바인딩용, 각 옵션의 id·코드·옵션값·가격·재고 등) | +| additional_options | array | `[]` | 추가옵션 그룹 목록 (수정 폼 바인딩용, 그룹명·선택지·추가금 등) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품 정보를 조회했습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 상품", + "en": "API Doc Sample Product" + }, + "name_localized": "API 문서 샘플 상품", + "product_code": "APIDOCSAMPLE01", + "sku": "SKU-DKOK-1319", + "categories": [], + "category_ids": [], + "primary_category_id": null, + "brand_id": null, + "list_price": 703155, + "selling_price": 597682, + "discount_rate": 15, + "stock_quantity": 509, + "safe_stock_quantity": 38, + "is_below_safe_stock": false, + "is_stock_consistent": true, + "sales_status": "on_sale", + "sales_status_label": "판매중", + "display_status": "visible", + "display_status_label": "전시", + "tax_status": "taxable", + "tax_status_label": "과세", + "tax_rate": "10.00", + "shipping_policy_id": null, + "common_info_id": null, + "description": { + "ko": "Tempore et non fugit eos et aperiam dolore. Aut esse fugiat adipisci iure itaque nisi. Earum ab dignissimos ipsa harum dolor sunt voluptatem.\n\nEst voluptatibus in sed distinctio. Delectus occaecati et earum est rerum ex. Beatae ex aut autem impedit facere totam. Et tempore minus veniam doloribus maiores eveniet dolorem ut. Culpa sed inventore ipsam.\n\nFuga praesentium distinctio quia ducimus harum assumenda. Ipsa dolorum odio reiciendis quis id. Tempore facere molestiae et reprehenderit reiciendis dicta quos. Quae labore deserunt fuga quam beatae. Praesentium aut fuga ratione aliquid.", + "en": "Soluta et ut repellendus rem. Id ea quibusdam in. Doloribus saepe rem praesentium et quis et molestiae.\n\nDolor voluptas aut qui unde sed quia. Corrupti qui enim laborum maxime possimus quis. Odit ducimus doloremque qui officiis fugit rerum. Magni ea possimus quia commodi optio saepe. Recusandae itaque in voluptatum et.\n\nRerum enim velit est inventore. Ipsa laboriosam provident ullam." + }, + "description_localized": "Tempore et non fugit eos et aperiam dolore. Aut esse fugiat adipisci iure itaque nisi. Earum ab dignissimos ipsa harum dolor sunt voluptatem.\n\nEst voluptatibus in sed distinctio. Delectus occaecati et earum est rerum ex. Beatae ex aut autem impedit facere totam. Et tempore minus veniam doloribus maiores eveniet dolorem ut. Culpa sed inventore ipsam.\n\nFuga praesentium distinctio quia ducimus harum assumenda. Ipsa dolorum odio reiciendis quis id. Tempore facere molestiae et reprehenderit reiciendis dicta quos. Quae labore deserunt fuga quam beatae. Praesentium aut fuga ratione aliquid.", + "description_mode": "text", + "min_purchase_qty": 1, + "max_purchase_qty": 0, + "purchase_restriction": "none", + "allowed_roles": null, + "barcode": null, + "hs_code": null, + "images": [], + "thumbnail_hash": null, + "thumbnail_url": null, + "meta_title": null, + "meta_description": null, + "meta_keywords": null, + "seo_sync_title": true, + "seo_sync_description": true, + "has_options": false, + "option_groups": [], + "options": [], + "additional_options": [], + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 상품코드로 단일 상품의 상세 정보를 조회합니다. `auth:sanctum` + `sirsoft-ecommerce.products.read` 권한이 필요하며, `ProductService::findByCode()`로 `code`에 해당하는 상품을 찾아 `ProductResource`로 반환합니다. ID가 아닌 판매/관리용 상품코드로 상세를 열람할 때 사용하며, 일치하는 상품이 없으면 404를 반환합니다. + + +### PUT /api/modules/sirsoft-ecommerce/admin/products/by-code/{code} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.products.update-by-code` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductController@updateByCode` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.products.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| code | path | string | 예 | — | 대상 리소스의 코드 | +| name | body | array | 아니오 | — | 대상의 이름/명칭 | +| product_code | body | string | 예 | max 50 | 상품코드 (상품 고유 관리 식별자, 상품 간 중복 불가) | +| sales_product_code | body | string | 아니오 | max 50 | 판매자 상품코드 (판매자가 직접 입력하는 관리용 코드) | +| sku | body | string | 아니오 | max 100 | 재고관리코드(SKU) | +| category_ids | body | array | 아니오 | min 1, max 5 | category 식별자 배열 | +| primary_category_id | body | integer | 아니오 | — | primary category 식별자 | +| brand_id | body | integer | 아니오 | — | brand 식별자 | +| list_price | body | integer | 아니오 | min 0.01 | 정가 (기본통화 기준, 소수 통화는 소수 입력 허용) | +| selling_price | body | integer | 아니오 | min 0.01 | 판매가 (기본통화 기준, 정가 이하여야 함) | +| stock_quantity | body | integer | 아니오 | min 0 | 재고 수량 (옵션 사용 시 옵션 재고 합계로 관리) | +| safe_stock_quantity | body | integer | 아니오 | min 0 | 안전재고 수량 (이 값 미만이면 재고 부족 표시) | +| sales_status | body | string | 아니오 | — | 판매상태 (on_sale 판매중 / suspended 판매중지 / sold_out 품절 / coming_soon 출시예정) | +| display_status | body | string | 아니오 | — | 전시상태 (visible 전시 / hidden 숨김) | +| tax_status | body | string | 아니오 | — | 과세여부 (taxable 과세 / tax_free 면세) | +| tax_rate | body | number | 아니오 | min 0, max 100 | 세율(%) (과세 상품의 부가세 계산 비율) | +| shipping_policy_id | body | integer | 아니오 | — | shipping policy 식별자 | +| common_info_id | body | integer | 아니오 | — | common info 식별자 | +| description | body | array | 아니오 | — | 설명 | +| description_mode | body | string | 아니오 | `text`, `html` | 상세 설명 편집 모드 (text 일반 텍스트 / html HTML 에디터) | +| thumbnail_hash | body | string | 아니오 | max 64 | 대표 이미지로 지정할 이미지 해시 (업로드된 이미지 중 썸네일 선택) | +| image_temp_key | body | string | 아니오 | max 64 | 임시 업로드 세션 키 (사전 업로드한 이미지를 이 상품에 연결) | +| images | body | array | 아니오 | max 20 | 상품 이미지 목록 (각 항목: id/hash/url/alt_text/is_thumbnail/sort_order) | +| meta_title | body | array | 아니오 | — | SEO 메타 제목 (검색엔진/소셜 공유 표시 제목) | +| meta_description | body | array | 아니오 | — | SEO 메타 설명 (검색엔진/소셜 공유 표시 요약) | +| meta_keywords | body | array | 아니오 | — | SEO 메타 키워드 배열 (검색엔진 색인용 키워드 목록) | +| seo_sync_title | body | boolean | 아니오 | — | SEO 제목 자동 동기화 여부 (true 시 상품명으로 메타 제목 자동 채움) | +| seo_sync_description | body | boolean | 아니오 | — | SEO 설명 자동 동기화 여부 (true 시 상품 설명으로 메타 설명 자동 채움) | +| use_main_image_for_og | body | boolean | 아니오 | — | 대표 이미지를 OG(소셜 공유) 이미지로 사용할지 여부 | +| has_options | body | boolean | 아니오 | — | options 여부 | +| option_groups | body | array | 아니오 | — | 옵션 그룹 정의 (예: 색상/사이즈 등 옵션 축과 각 축의 선택값 목록) | +| options | body | array | 아니오 | min 1 | 옵션(SKU) 목록 (각 항목: 옵션코드·옵션명·옵션값·정가·판매가·재고 등) | +| additional_options | body | array | 아니오 | max 5 | 추가옵션 그룹 배열 (각 그룹당 선택지 1~20개, 필수 여부·추가금·직접입력 허용 등 설정) | +| notice_items | body | array | 아니오 | max 50 | 상품정보제공고시 항목 배열 (각 항목: 항목명·내용 다국어) | +| label_assignments | body | array | 아니오 | — | 라벨 할당 배열 (label_id + 노출 시작/종료일로 상품에 라벨 부착) | +| min_purchase_qty | body | integer | 아니오 | min 1 | 최소 구매 수량 (1회 주문 시 이 수량 이상 구매) | +| max_purchase_qty | body | integer | 아니오 | min 0 | 최대 구매 수량 (0=무제한) | +| purchase_restriction | body | string | 아니오 | `none`, `restricted` | 구매 대상 제한 (none 제한 없음 / restricted 특정 역할만 구매 허용) | +| allowed_roles | body | array | 아니오 | — | 구매 허용 역할 ID 배열 (purchase_restriction=restricted 시 필수) | +| barcode | body | string | 아니오 | max 50 | 바코드 | +| hs_code | body | string | 아니오 | max 20 | HS 코드 (수출입 관세 분류 코드) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product.update_validation_rules`). + +**요청 예시** + +```http +PUT /api/modules/sirsoft-ecommerce/admin/products/by-code/APIDOCSAMPLE01 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": [ + "예시 이름" + ], + "product_code": "예시값", + "sales_product_code": "예시값", + "sku": "예시값", + "category_ids": [ + "예시값" + ], + "primary_category_id": 1, + "brand_id": 1, + "list_price": 1, + "selling_price": 1, + "stock_quantity": 1, + "safe_stock_quantity": 1, + "sales_status": "예시값", + "display_status": "예시값", + "tax_status": "예시값", + "tax_rate": 1, + "shipping_policy_id": 1, + "common_info_id": 1, + "description": [ + "예시 내용입니다." + ], + "description_mode": "text", + "thumbnail_hash": "예시값", + "image_temp_key": "예시값", + "images": [ + "예시값" + ], + "meta_title": [ + "예시 제목" + ], + "meta_description": [ + "예시 내용입니다." + ], + "meta_keywords": [ + "예시값" + ], + "seo_sync_title": true, + "seo_sync_description": true, + "use_main_image_for_og": true, + "has_options": true, + "option_groups": [ + "예시값" + ], + "options": [ + "예시값" + ], + "additional_options": [ + "예시값" + ], + "notice_items": [ + "예시값" + ], + "label_assignments": [ + "예시값" + ], + "min_purchase_qty": 1, + "max_purchase_qty": 1, + "purchase_restriction": "none", + "allowed_roles": [ + "예시값" + ], + "barcode": "예시값", + "hs_code": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 상품코드로 기존 상품을 수정합니다. `auth:sanctum` + `sirsoft-ecommerce.products.update` 권한이 필요하며, `ProductService::findByCode()`로 `code` 대상 상품을 찾은 뒤 `UpdateProductRequest`로 검증된 값을 `ProductService::update()`에 넘겨 상품·옵션·이미지·SEO 등을 갱신하고 `ProductResource`를 반환합니다. `{product}` ID 경로 대신 상품코드 기반으로 수정할 때 사용하며, 대상 상품이 없으면 404, 검증 실패는 422로 응답합니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/products/generate-code + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.products.generate-code` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductController@generateCode` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.products.create` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/products/generate-code HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.create`)이 없는 경우 | + + + +**설명** 중복되지 않는 신규 상품코드를 생성해 반환합니다. `auth:sanctum` + `sirsoft-ecommerce.products.create` 권한이 필요하며, `ProductService::generateUniqueCode()`가 기존 상품과 충돌하지 않는 코드를 발급해 `product_code` 필드로 응답합니다. 상품 등록 폼에서 코드 자동 채움 버튼을 눌렀을 때 사용하며, 요청 본문은 없습니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/products/images + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.products.images.upload-temp` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductController@uploadImage` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.products.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| file | body | file | 예 | max 10240 | 업로드 파일 | +| temp_key | body | string | 아니오 | max 64 | 임시 업로드 세션 키 (같은 상품의 여러 이미지를 한 세션으로 묶음, 생략 시 서버가 UUID 자동 발급) | +| collection | body | string | 아니오 | — | 첨부 컬렉션 그룹명 (첨부를 용도별로 묶는 키, 미지정 시 default) | +| alt_text | body | array | 아니오 | — | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product-image.filter_upload_validation_rules`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/products/images HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: multipart/form-data; boundary=----G7ExampleBoundary + +------G7ExampleBoundary +Content-Disposition: form-data; name="file"; filename="example.pdf" +Content-Type: application/octet-stream + +(바이너리 파일 내용) +------G7ExampleBoundary +Content-Disposition: form-data; name="temp_key" + +예시값 +------G7ExampleBoundary +Content-Disposition: form-data; name="collection" + +예시값 +------G7ExampleBoundary +Content-Disposition: form-data; name="alt_text" + +예시값 +------G7ExampleBoundary-- +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 상품 등록 전 이미지를 임시로 업로드합니다. `auth:sanctum` + `sirsoft-ecommerce.products.update` 권한이 필요하며, `productId` 경로가 없어 `ProductImageService::upload()`가 상품에 귀속되지 않은 임시 이미지로 저장합니다. `temp_key`를 넘기면 같은 업로드 세션으로 묶이고 생략 시 서버가 UUID를 자동 발급해 응답에 포함하므로, 이후 상품 생성/수정 요청의 `image_temp_key`로 전달해 실제 상품에 연결합니다. 컬렉션 내 첫 이미지는 자동으로 대표 이미지(`is_thumbnail`)로 지정되며, 개수 상한 초과 시 422를 반환합니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/products/images/reorder + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.products.images.reorder` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductController@reorderImages` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.products.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| order | body | array | 예 | min 1 | 이미지 순서 배열 (각 항목: id + 부여할 order 값, 이미지별 노출 순서 갱신) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product-image.filter_reorder_validation_rules`). + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/products/images/reorder HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "order": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 상품 이미지의 노출 순서를 일괄 변경합니다. `auth:sanctum` + `sirsoft-ecommerce.products.update` 권한이 필요하며, 컨트롤러가 `order` 배열을 `id => order` 맵으로 변환해 `ProductImageService::reorder()`에 넘겨 각 이미지의 `sort_order`를 갱신합니다. 이미지 갤러리에서 드래그로 순서를 재배치한 결과를 저장할 때 사용합니다. 확장은 `sirsoft-ecommerce.product-image.filter_reorder_validation_rules` 훅으로 검증 규칙을 확장할 수 있습니다. + + +### DELETE /api/modules/sirsoft-ecommerce/admin/products/images/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.products.images.delete` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductController@deleteImage` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.products.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/admin/products/images/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 상품 이미지 1건을 삭제합니다. `auth:sanctum` + `sirsoft-ecommerce.products.update` 권한이 필요하며, `ProductImageService::delete()`가 `id`에 해당하는 이미지 레코드와 저장 파일을 제거합니다. 임시 업로드 이미지와 상품에 귀속된 이미지 모두 삭제할 수 있으며, 해당 이미지가 없으면 404를 반환합니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/products/{identifier} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.products.show` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductController@show` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.products.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| identifier | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/products/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 상품","en":"API Doc Sample Product"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name_localized | string | `API 문서 샘플 상품` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| product_code | string | `APIDOCSAMPLE01` | 상품코드 | +| sku | string | `SKU-DKOK-1319` | SKU | +| categories | array | `[]` | 소속 카테고리 목록 (각 항목: id·현지화 이름·대표 여부. categories 관계 eager load 시에만 채워짐) | +| category_ids | array | `[]` | category 식별자 배열 (연관 리소스 참조) | +| primary_category_id | null | `null` | primary category 식별자 (연관 리소스 참조) | +| brand_id | null | `null` | brand 식별자 (연관 리소스 참조) | +| list_price | integer | `703155` | 정가 (기본통화 기준) | +| selling_price | integer | `597682` | 판매가 (기본통화 기준) | +| discount_rate | integer | `15` | 할인율(%) (정가 대비 판매가 할인 비율, (1 - 판매가/정가) × 100) | +| stock_quantity | integer | `509` | 재고 수량 (옵션 있으면 옵션 합계) | +| safe_stock_quantity | integer | `38` | 안전재고 수량 | +| is_below_safe_stock | boolean | `false` | below safe stock 여부 | +| is_stock_consistent | boolean | `true` | stock consistent 여부 | +| sales_status | string | `on_sale` | 판매상태: on_sale(판매중), suspended(판매중지), sold_out(품절), coming_soon(출시예정) | +| sales_status_label | string | `판매중` | `sales_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| display_status | string | `visible` | 전시상태: visible(전시), hidden(숨김) | +| display_status_label | string | `전시` | `display_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| tax_status | string | `taxable` | 과세여부: taxable(과세), tax_free(면세) | +| tax_status_label | string | `과세` | `tax_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| tax_rate | string | `10.00` | 세율 (%) | +| shipping_policy_id | null | `null` | shipping policy 식별자 (연관 리소스 참조) | +| common_info_id | null | `null` | common info 식별자 (연관 리소스 참조) | +| description | object | `{"ko":"Tempore et non fugit eos et aperiam dolore. Aut es…` | 설명 (다국어 필드는 로케일별 값 객체) | +| description_localized | string | `Tempore et non fugit eos et aperiam d…` | `description` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| description_mode | string | `text` | 설명 모드: text(텍스트), html(HTML) | +| min_purchase_qty | integer | `1` | 최소 구매 수량 | +| max_purchase_qty | integer | `0` | 최대 구매 수량 (0=무제한) | +| purchase_restriction | string | `none` | 구매 제한: none(없음), restricted(제한) | +| allowed_roles | null | `null` | 구매 허용 역할 ID 배열 | +| barcode | null | `null` | 바코드 | +| hs_code | null | `null` | HS 코드 (관세 분류) | +| label_assignments | array | `[]` | 라벨 할당 목록 (각 항목: label_id + 노출 시작/종료일) | +| notice_items | null | `null` | 상품정보제공고시 항목 목록 (각 항목: 항목명·내용 다국어) | +| images | array | `[]` | 상품 이미지 목록 (각 항목: hash·url·alt_text·is_thumbnail·sort_order 등, images 관계 로드 시) | +| thumbnail_hash | null | `null` | 대표 이미지 해시 (썸네일로 지정된 이미지의 hash) | +| thumbnail_url | null | `null` | thumbnail URL | +| meta_title | null | `null` | SEO 제목 (다국어 JSON) | +| meta_description | null | `null` | SEO 설명 (다국어 JSON) | +| meta_keywords | null | `null` | SEO 키워드 (배열) | +| seo_sync_title | boolean | `true` | SEO 제목 동기화 여부 (1: 상품명으로 자동 채움, 0: 직접 입력 보존) | +| seo_sync_description | boolean | `true` | SEO 설명 동기화 여부 (1: 상품 설명으로 자동 채움, 0: 직접 입력 보존) | +| has_options | boolean | `false` | options 여부 | +| option_groups | array | `[]` | 옵션 그룹 정의: [{name: "색상", values: ["빨강", "파랑"]}] | +| options | array | `[]` | 옵션(SKU) 목록 (수정 폼 바인딩용, 각 옵션의 id·코드·옵션값·가격·재고 등) | +| additional_options | array | `[]` | 추가옵션 그룹 목록 (수정 폼 바인딩용, 그룹명·선택지·추가금 등) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품 정보를 조회했습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 상품", + "en": "API Doc Sample Product" + }, + "name_localized": "API 문서 샘플 상품", + "product_code": "APIDOCSAMPLE01", + "sku": "SKU-DKOK-1319", + "categories": [], + "category_ids": [], + "primary_category_id": null, + "brand_id": null, + "list_price": 703155, + "selling_price": 597682, + "discount_rate": 15, + "stock_quantity": 509, + "safe_stock_quantity": 38, + "is_below_safe_stock": false, + "is_stock_consistent": true, + "sales_status": "on_sale", + "sales_status_label": "판매중", + "display_status": "visible", + "display_status_label": "전시", + "tax_status": "taxable", + "tax_status_label": "과세", + "tax_rate": "10.00", + "shipping_policy_id": null, + "common_info_id": null, + "description": { + "ko": "Tempore et non fugit eos et aperiam dolore. Aut esse fugiat adipisci iure itaque nisi. Earum ab dignissimos ipsa harum dolor sunt voluptatem.\n\nEst voluptatibus in sed distinctio. Delectus occaecati et earum est rerum ex. Beatae ex aut autem impedit facere totam. Et tempore minus veniam doloribus maiores eveniet dolorem ut. Culpa sed inventore ipsam.\n\nFuga praesentium distinctio quia ducimus harum assumenda. Ipsa dolorum odio reiciendis quis id. Tempore facere molestiae et reprehenderit reiciendis dicta quos. Quae labore deserunt fuga quam beatae. Praesentium aut fuga ratione aliquid.", + "en": "Soluta et ut repellendus rem. Id ea quibusdam in. Doloribus saepe rem praesentium et quis et molestiae.\n\nDolor voluptas aut qui unde sed quia. Corrupti qui enim laborum maxime possimus quis. Odit ducimus doloremque qui officiis fugit rerum. Magni ea possimus quia commodi optio saepe. Recusandae itaque in voluptatum et.\n\nRerum enim velit est inventore. Ipsa laboriosam provident ullam." + }, + "description_localized": "Tempore et non fugit eos et aperiam dolore. Aut esse fugiat adipisci iure itaque nisi. Earum ab dignissimos ipsa harum dolor sunt voluptatem.\n\nEst voluptatibus in sed distinctio. Delectus occaecati et earum est rerum ex. Beatae ex aut autem impedit facere totam. Et tempore minus veniam doloribus maiores eveniet dolorem ut. Culpa sed inventore ipsam.\n\nFuga praesentium distinctio quia ducimus harum assumenda. Ipsa dolorum odio reiciendis quis id. Tempore facere molestiae et reprehenderit reiciendis dicta quos. Quae labore deserunt fuga quam beatae. Praesentium aut fuga ratione aliquid.", + "description_mode": "text", + "min_purchase_qty": 1, + "max_purchase_qty": 0, + "purchase_restriction": "none", + "allowed_roles": null, + "barcode": null, + "hs_code": null, + "label_assignments": [], + "notice_items": null, + "images": [], + "thumbnail_hash": null, + "thumbnail_url": null, + "meta_title": null, + "meta_description": null, + "meta_keywords": null, + "seo_sync_title": true, + "seo_sync_description": true, + "has_options": false, + "option_groups": [], + "options": [], + "additional_options": [], + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자 화면용 단일 상품 상세를 조회합니다. `auth:sanctum` + `sirsoft-ecommerce.products.read` 권한이 필요하며, `ProductService::findByIdOrCode()`로 숫자 ID 우선, 없으면 상품코드로 상품을 찾은 뒤 `getDetail($id, includeInactive: true)`로 비활성(숨김/판매중지) 상품까지 포함해 상세를 로드하고 `ProductResource`로 반환합니다. 공개 상세와 달리 전시상태에 관계없이 조회되므로 관리자 편집/열람에 사용하며, 대상이 없으면 404를 반환합니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/products/{productId}/images + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.products.images.upload` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductController@uploadImage` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.products.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| productId | path | string | 예 | — | 대상 product의 식별자 | +| file | body | file | 예 | max 10240 | 업로드 파일 | +| temp_key | body | string | 아니오 | max 64 | 임시 업로드 세션 키 (같은 상품의 여러 이미지를 한 세션으로 묶음, 생략 시 서버가 UUID 자동 발급) | +| collection | body | string | 아니오 | — | 첨부 컬렉션 그룹명 (첨부를 용도별로 묶는 키, 미지정 시 default) | +| alt_text | body | array | 아니오 | — | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product-image.filter_upload_validation_rules`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/products/1/images HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: multipart/form-data; boundary=----G7ExampleBoundary + +------G7ExampleBoundary +Content-Disposition: form-data; name="file"; filename="example.pdf" +Content-Type: application/octet-stream + +(바이너리 파일 내용) +------G7ExampleBoundary +Content-Disposition: form-data; name="temp_key" + +예시값 +------G7ExampleBoundary +Content-Disposition: form-data; name="collection" + +예시값 +------G7ExampleBoundary +Content-Disposition: form-data; name="alt_text" + +예시값 +------G7ExampleBoundary-- +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 기존 상품에 이미지 1건을 업로드해 즉시 귀속시킵니다. `auth:sanctum` + `sirsoft-ecommerce.products.update` 권한이 필요하며, 경로의 `productId`가 있어 `ProductImageService::upload()`가 임시가 아닌 해당 상품 소유 이미지로 저장하고 `collection`별 마지막 순서에 추가합니다. 상품 편집 화면에서 이미지를 추가할 때 사용하며, 컬렉션의 첫 이미지는 자동으로 대표 이미지로 지정됩니다. 개수 상한 초과 시 422, 상품이 없으면 404를 반환합니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/products/{productId}/images/{imageId}/thumbnail + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.products.images.set-thumbnail` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductController@setThumbnail` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.products.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| productId | path | string | 예 | — | 대상 product의 식별자 | +| imageId | path | string | 예 | — | 대상 image의 식별자 | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/products/1/images/{imageId}/thumbnail HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 상품의 대표(썸네일) 이미지를 지정합니다. `auth:sanctum` + `sirsoft-ecommerce.products.update` 권한이 필요하며, `ProductImageService::setThumbnail()`가 같은 상품의 기존 대표 이미지의 `is_thumbnail`을 해제하고 `imageId` 이미지에 대표 플래그를 부여합니다. 목록·상세에서 노출될 기본 이미지를 교체할 때 사용하며, 지정 대상 상품/이미지가 없으면 404를 반환합니다. + + +### DELETE /api/modules/sirsoft-ecommerce/admin/products/{product} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.products.destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.products.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| product | path | string | 예 | — | 대상 product의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/admin/products/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted | boolean | `true` | 삭제 성공 여부 (상품이 정상 삭제되면 true) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품이 삭제되었습니다.", + "data": { + "deleted": true + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.delete`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 상품 1건을 삭제합니다. `auth:sanctum` + `sirsoft-ecommerce.products.delete` 권한이 필요하며, 먼저 `ProductService::checkCanDelete()`로 주문 이력을 선행 검사해 이력이 있으면 관련 주문 수(`count`)와 함께 409 Conflict로 차단하고, 통과 시 `ProductService::delete()`가 상품과 하위 데이터(옵션/이미지 등)를 명시적으로 제거합니다. 서비스 계층의 도메인 가드가 경합/우회 상황에서도 `ProductHasOrderHistoryException`으로 재차 409를 반환하며, 대상이 없으면 404를 반환합니다. + + +### PUT /api/modules/sirsoft-ecommerce/admin/products/{product} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.products.update` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductController@update` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.products.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| product | path | string | 예 | — | 대상 product의 식별자 | +| name | body | array | 아니오 | — | 대상의 이름/명칭 | +| product_code | body | string | 예 | max 50 | 상품코드 (상품 고유 관리 식별자, 상품 간 중복 불가) | +| sales_product_code | body | string | 아니오 | max 50 | 판매자 상품코드 (판매자가 직접 입력하는 관리용 코드) | +| sku | body | string | 아니오 | max 100 | 재고관리코드(SKU) | +| category_ids | body | array | 아니오 | min 1, max 5 | category 식별자 배열 | +| primary_category_id | body | integer | 아니오 | — | primary category 식별자 | +| brand_id | body | integer | 아니오 | — | brand 식별자 | +| list_price | body | integer | 아니오 | min 0.01 | 정가 (기본통화 기준, 소수 통화는 소수 입력 허용) | +| selling_price | body | integer | 아니오 | min 0.01 | 판매가 (기본통화 기준, 정가 이하여야 함) | +| stock_quantity | body | integer | 아니오 | min 0 | 재고 수량 (옵션 사용 시 옵션 재고 합계로 관리) | +| safe_stock_quantity | body | integer | 아니오 | min 0 | 안전재고 수량 (이 값 미만이면 재고 부족 표시) | +| sales_status | body | string | 아니오 | — | 판매상태 (on_sale 판매중 / suspended 판매중지 / sold_out 품절 / coming_soon 출시예정) | +| display_status | body | string | 아니오 | — | 전시상태 (visible 전시 / hidden 숨김) | +| tax_status | body | string | 아니오 | — | 과세여부 (taxable 과세 / tax_free 면세) | +| tax_rate | body | number | 아니오 | min 0, max 100 | 세율(%) (과세 상품의 부가세 계산 비율) | +| shipping_policy_id | body | integer | 아니오 | — | shipping policy 식별자 | +| common_info_id | body | integer | 아니오 | — | common info 식별자 | +| description | body | array | 아니오 | — | 설명 | +| description_mode | body | string | 아니오 | `text`, `html` | 상세 설명 편집 모드 (text 일반 텍스트 / html HTML 에디터) | +| thumbnail_hash | body | string | 아니오 | max 64 | 대표 이미지로 지정할 이미지 해시 (업로드된 이미지 중 썸네일 선택) | +| image_temp_key | body | string | 아니오 | max 64 | 임시 업로드 세션 키 (사전 업로드한 이미지를 이 상품에 연결) | +| images | body | array | 아니오 | max 20 | 상품 이미지 목록 (각 항목: id/hash/url/alt_text/is_thumbnail/sort_order) | +| meta_title | body | array | 아니오 | — | SEO 메타 제목 (검색엔진/소셜 공유 표시 제목) | +| meta_description | body | array | 아니오 | — | SEO 메타 설명 (검색엔진/소셜 공유 표시 요약) | +| meta_keywords | body | array | 아니오 | — | SEO 메타 키워드 배열 (검색엔진 색인용 키워드 목록) | +| seo_sync_title | body | boolean | 아니오 | — | SEO 제목 자동 동기화 여부 (true 시 상품명으로 메타 제목 자동 채움) | +| seo_sync_description | body | boolean | 아니오 | — | SEO 설명 자동 동기화 여부 (true 시 상품 설명으로 메타 설명 자동 채움) | +| use_main_image_for_og | body | boolean | 아니오 | — | 대표 이미지를 OG(소셜 공유) 이미지로 사용할지 여부 | +| has_options | body | boolean | 아니오 | — | options 여부 | +| option_groups | body | array | 아니오 | — | 옵션 그룹 정의 (예: 색상/사이즈 등 옵션 축과 각 축의 선택값 목록) | +| options | body | array | 아니오 | min 1 | 옵션(SKU) 목록 (각 항목: 옵션코드·옵션명·옵션값·정가·판매가·재고 등) | +| additional_options | body | array | 아니오 | max 5 | 추가옵션 그룹 배열 (각 그룹당 선택지 1~20개, 필수 여부·추가금·직접입력 허용 등 설정) | +| notice_items | body | array | 아니오 | max 50 | 상품정보제공고시 항목 배열 (각 항목: 항목명·내용 다국어) | +| label_assignments | body | array | 아니오 | — | 라벨 할당 배열 (label_id + 노출 시작/종료일로 상품에 라벨 부착) | +| min_purchase_qty | body | integer | 아니오 | min 1 | 최소 구매 수량 (1회 주문 시 이 수량 이상 구매) | +| max_purchase_qty | body | integer | 아니오 | min 0 | 최대 구매 수량 (0=무제한) | +| purchase_restriction | body | string | 아니오 | `none`, `restricted` | 구매 대상 제한 (none 제한 없음 / restricted 특정 역할만 구매 허용) | +| allowed_roles | body | array | 아니오 | — | 구매 허용 역할 ID 배열 (purchase_restriction=restricted 시 필수) | +| barcode | body | string | 아니오 | max 50 | 바코드 | +| hs_code | body | string | 아니오 | max 20 | HS 코드 (수출입 관세 분류 코드) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product.update_validation_rules`). + +**요청 예시** + +```http +PUT /api/modules/sirsoft-ecommerce/admin/products/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": [ + "예시 이름" + ], + "product_code": "예시값", + "sales_product_code": "예시값", + "sku": "예시값", + "category_ids": [ + "예시값" + ], + "primary_category_id": 1, + "brand_id": 1, + "list_price": 1, + "selling_price": 1, + "stock_quantity": 1, + "safe_stock_quantity": 1, + "sales_status": "예시값", + "display_status": "예시값", + "tax_status": "예시값", + "tax_rate": 1, + "shipping_policy_id": 1, + "common_info_id": 1, + "description": [ + "예시 내용입니다." + ], + "description_mode": "text", + "thumbnail_hash": "예시값", + "image_temp_key": "예시값", + "images": [ + "예시값" + ], + "meta_title": [ + "예시 제목" + ], + "meta_description": [ + "예시 내용입니다." + ], + "meta_keywords": [ + "예시값" + ], + "seo_sync_title": true, + "seo_sync_description": true, + "use_main_image_for_og": true, + "has_options": true, + "option_groups": [ + "예시값" + ], + "options": [ + "예시값" + ], + "additional_options": [ + "예시값" + ], + "notice_items": [ + "예시값" + ], + "label_assignments": [ + "예시값" + ], + "min_purchase_qty": 1, + "max_purchase_qty": 1, + "purchase_restriction": "none", + "allowed_roles": [ + "예시값" + ], + "barcode": "예시값", + "hs_code": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** ID 경로로 지정한 상품을 수정합니다. `auth:sanctum` + `sirsoft-ecommerce.products.update` 권한이 필요하며, 라우트 모델 바인딩된 `Product`에 `UpdateProductRequest`로 검증된 값을 `ProductService::update()`로 반영해 상품 기본정보·옵션·이미지·SEO 메타를 갱신하고 `ProductResource`를 반환합니다. `by-code` 변형과 동일 서비스 메서드를 쓰지만 상품코드 조회 단계 없이 바로 대상 모델을 받습니다. 검증 실패는 422, 대상이 없으면 404로 응답합니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/products/{product}/can-delete + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.products.can-delete` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductController@canDelete` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.products.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| product | path | string | 예 | — | 대상 product의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/products/1/can-delete HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| canDelete | boolean | `false` | 삭제 가능 여부 (true 삭제 가능 / false 주문 이력 등으로 삭제 불가) | +| reason | string | `이 상품은 5건의 주문 이력이 있어 삭제할 수 없습니다.` | 삭제 불가 사유 (canDelete=false 일 때 안내 문구) | +| relatedData | object | `{"orders":5,"images":4,"options":3,"additionalOptions":0,…` | 연관 데이터 건수 (orders/images/options 등 상품에 연결된 하위 데이터 수) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "삭제 가능 여부를 확인했습니다.", + "data": { + "canDelete": true, + "reason": null, + "relatedData": { + "orders": 0, + "images": 0, + "options": 0, + "additionalOptions": 0, + "labelAssignments": 0 + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.delete`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 상품 삭제 가능 여부를 사전 확인합니다. `auth:sanctum` + `sirsoft-ecommerce.products.delete` 권한이 필요하며, `ProductService::checkCanDelete()`가 주문 이력 등 연관 데이터를 검사해 `canDelete` 불리언과 차단 `reason`, 그리고 `relatedData`(orders/images/options 등 연관 건수)를 반환합니다. 삭제 버튼을 누르기 전 확인 다이얼로그에서 삭제 가능 여부와 연관 데이터를 안내하는 데 사용하며, 실제 삭제는 DELETE 엔드포인트가 수행합니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/products/{product}/copy + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.products.show-for-copy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductController@showForCopy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.products.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| product | path | string | 예 | — | 대상 product의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/products/1/copy HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| name | object | `{"ko":"면 손수건 3매입 #1","en":"Cotton Handkerchief 3pcs #1"}` | 상품명 (다국어 JSON: {ko: "...", en: "..."}) | +| product_code | string | `BP747N3QSSSNEPII` | 상품코드 | +| sales_product_code | null | `null` | 판매자 상품코드 (사용자 입력용) | +| sku | string | `HK-0001` | SKU | +| brand_id | integer | `91` | 브랜드 ID | +| category_ids | array | `[114,116]` | category 식별자 배열 (연관 리소스 참조) | +| primary_category_id | integer | `116` | primary category 식별자 (연관 리소스 참조) | +| list_price | string | `4000.00` | 정가 (기본통화 기준) | +| selling_price | string | `2000.00` | 판매가 (기본통화 기준) | +| stock_quantity | integer | `59` | 재고 수량 (옵션 있으면 옵션 합계) | +| safe_stock_quantity | integer | `15` | 안전재고 수량 | +| tax_status | string | `taxable` | 과세여부: taxable(과세), tax_free(면세) | +| sales_status | string | `on_sale` | 판매상태: on_sale(판매중), suspended(판매중지), sold_out(품절), coming_soon(출시예정) | +| display_status | string | `visible` | 전시상태: visible(전시), hidden(숨김) | +| options | array | `[{"option_code":"AS2HM7CEDFHEGS43-001","option_name":{"ko…` | 복사 대상 옵션(SKU) 목록 (신규 등록 폼에 채울 옵션 정의, 다중통화 가격 포함) | +| additional_options | array | `[]` | 복사 대상 추가옵션 그룹 목록 (그룹명·선택지·추가금 등) | +| images | array | `[{"hash":"7858be3cf217","url":null,"original_filename":"p…` | 복사 대상 이미지 목록 (각 항목: hash·원본파일명 등, copy_images 선택 시 포함) | +| thumbnail_hash | string | `7858be3cf217` | 대표 이미지 해시 (썸네일로 지정된 이미지의 hash) | +| description | object | `{"ko":"

부드러운 면 100% 손수건 3매 세트입니다.<\/p>","en":"

A set …` | 상세 설명 (다국어 JSON, HTML 포함) | +| description_mode | string | `text` | 설명 모드: text(텍스트), html(HTML) | +| notice_items | array | `[{"name":{"ko":"제품 소재 (충전재 포함)","en":"Material (Including…` | 상품정보제공고시 항목 목록 (각 항목: 항목명·내용 다국어) | +| shipping_policy_id | integer | `31` | 배송정책 ID | +| shipping_policy | object | `{"id":31,"name":{"ko":"국내 무료배송","en":"Domestic Free Shipp…` | 현재 부여된 배송정책 객체 (비활성 포함 — 수정폼 활성 목록에 없을 때 union 표시용) | +| common_info_id | integer | `207` | 공통정보 템플릿 ID | +| label_assignments | array | `[{"label_id":26,"start_date":null,"end_date":null}]` | 라벨 할당 목록 (각 항목: label_id + 노출 시작/종료일) | +| min_purchase_qty | integer | `1` | 최소 구매 수량 | +| max_purchase_qty | integer | `0` | 최대 구매 수량 (0=무제한) | +| purchase_restriction | string | `none` | 구매 제한: none(없음), restricted(제한) | +| allowed_roles | array | `[]` | 구매 허용 역할 ID 배열 | +| meta_title | null | `null` | SEO 제목 (다국어 JSON) | +| meta_description | null | `null` | SEO 설명 (다국어 JSON) | +| seo_tags | array | `[]` | SEO 태그 목록 (메타 키워드 등 검색엔진 노출용 태그) | +| seo_sync_title | boolean | `true` | SEO 제목 동기화 여부 (1: 상품명으로 자동 채움, 0: 직접 입력 보존) | +| seo_sync_description | boolean | `true` | SEO 설명 동기화 여부 (1: 상품 설명으로 자동 채움, 0: 직접 입력 보존) | +| barcode | null | `null` | 바코드 | +| hs_code | null | `null` | HS 코드 (관세 분류) | +| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL | +| categories | array | `[{"id":114,"name":{"ko":"스포츠","en":"Sports"},"name_locali…` | 소속 카테고리 목록 (breadcrumb 포함 — 복사 폼의 카테고리 표시용) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품 정보를 조회했습니다.", + "data": { + "name": { + "ko": "API 문서 샘플 상품", + "en": "API Doc Sample Product" + }, + "product_code": "WLA9NKCCGO7026T5", + "sales_product_code": null, + "sku": "SKU-DKOK-1319", + "brand_id": null, + "category_ids": [], + "primary_category_id": null, + "list_price": "703155.00", + "selling_price": "597682.00", + "stock_quantity": 509, + "safe_stock_quantity": 38, + "tax_status": "taxable", + "sales_status": "on_sale", + "display_status": "visible", + "options": [], + "additional_options": [], + "images": [], + "thumbnail_hash": null, + "description": { + "ko": "Tempore et non fugit eos et aperiam dolore. Aut esse fugiat adipisci iure itaque nisi. Earum ab dignissimos ipsa harum dolor sunt voluptatem.\n\nEst voluptatibus in sed distinctio. Delectus occaecati et earum est rerum ex. Beatae ex aut autem impedit facere totam. Et tempore minus veniam doloribus maiores eveniet dolorem ut. Culpa sed inventore ipsam.\n\nFuga praesentium distinctio quia ducimus harum assumenda. Ipsa dolorum odio reiciendis quis id. Tempore facere molestiae et reprehenderit reiciendis dicta quos. Quae labore deserunt fuga quam beatae. Praesentium aut fuga ratione aliquid.", + "en": "Soluta et ut repellendus rem. Id ea quibusdam in. Doloribus saepe rem praesentium et quis et molestiae.\n\nDolor voluptas aut qui unde sed quia. Corrupti qui enim laborum maxime possimus quis. Odit ducimus doloremque qui officiis fugit rerum. Magni ea possimus quia commodi optio saepe. Recusandae itaque in voluptatum et.\n\nRerum enim velit est inventore. Ipsa laboriosam provident ullam." + }, + "description_mode": "text", + "notice_items": null, + "shipping_policy_id": null, + "shipping_policy": null, + "common_info_id": null, + "label_assignments": [], + "min_purchase_qty": 1, + "max_purchase_qty": 0, + "purchase_restriction": "none", + "allowed_roles": [], + "meta_title": null, + "meta_description": null, + "seo_tags": [], + "seo_sync_title": true, + "seo_sync_description": true, + "barcode": null, + "hs_code": null, + "thumbnail_url": null + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 상품 복사(복제) 등록 폼을 채우기 위한 원본 데이터를 조회합니다. `auth:sanctum` + `sirsoft-ecommerce.products.read` 권한이 필요하며, `copy_images`/`copy_options`/`copy_categories`/`copy_seo` 등 쿼리 불리언으로 복사 항목을 선택하면 `ProductService::getDetailForCopy()`가 해당 항목만 담아 반환합니다. 컨트롤러가 대표 이미지의 `thumbnail_url`, 카테고리 breadcrumb, 옵션별 다중통화 가격(`ProductOptionResource`)을 추가로 보강하며, SEO는 기본적으로 복사 제외(false)입니다. 대상 상품이 없으면 404를 반환합니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/products/{product}/form + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.products.show-for-form` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductController@showForForm` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.products.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| product | path | string | 예 | — | 대상 product의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/products/1/form HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `201` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"면 손수건 3매입 #1","en":"Cotton Handkerchief 3pcs #1"}` | 상품명 (다국어 JSON: {ko: "...", en: "..."}) | +| product_code | string | `AS2HM7CEDFHEGS43` | 상품코드 | +| sales_product_code | null | `null` | 판매자 상품코드 (사용자 입력용) | +| sku | string | `HK-0001` | SKU | +| brand_id | integer | `91` | 브랜드 ID | +| category_ids | array | `[114,116]` | category 식별자 배열 (연관 리소스 참조) | +| primary_category_id | integer | `116` | primary category 식별자 (연관 리소스 참조) | +| created_at | string | `2026-06-15 02:24:15` | 생성 일시 | +| updated_at | string | `2026-06-15 02:24:15` | 최종 수정 일시 | +| list_price | string | `4000.00` | 정가 (기본통화 기준) | +| selling_price | string | `2000.00` | 판매가 (기본통화 기준) | +| stock_quantity | integer | `59` | 재고 수량 (옵션 있으면 옵션 합계) | +| safe_stock_quantity | integer | `15` | 안전재고 수량 | +| tax_status | string | `taxable` | 과세여부: taxable(과세), tax_free(면세) | +| sales_status | string | `on_sale` | 판매상태: on_sale(판매중), suspended(판매중지), sold_out(품절), coming_soon(출시예정) | +| display_status | string | `visible` | 전시상태: visible(전시), hidden(숨김) | +| options | array | `[{"id":1086,"option_code":"AS2HM7CEDFHEGS43-001","option_…` | 옵션(SKU) 목록 (수정 폼 바인딩용, 각 옵션의 id·코드·옵션값·가격·재고 등) | +| additional_options | array | `[]` | 추가옵션 그룹 목록 (수정 폼 바인딩용, 그룹명·선택지·추가금 등) | +| images | array | `[{"id":801,"hash":"7858be3cf217","url":null,"original_fil…` | 이미지 목록 (각 항목: id·hash·원본파일명 등) | +| thumbnail_hash | string | `7858be3cf217` | 대표 이미지 해시 (썸네일로 지정된 이미지의 hash) | +| description | object | `{"ko":"

부드러운 면 100% 손수건 3매 세트입니다.<\/p>","en":"

A set …` | 상세 설명 (다국어 JSON, HTML 포함) | +| description_mode | string | `text` | 설명 모드: text(텍스트), html(HTML) | +| notice_items | array | `[{"name":{"ko":"제품 소재 (충전재 포함)","en":"Material (Including…` | 상품정보제공고시 항목 목록 (각 항목: 항목명·내용 다국어) | +| shipping_policy_id | integer | `31` | 배송정책 ID | +| shipping_policy | object | `{"id":31,"name":{"ko":"국내 무료배송","en":"Domestic Free Shipp…` | 현재 부여된 배송정책 객체 (비활성 포함 — 수정폼 활성 목록에 없을 때 union 표시용) | +| common_info_id | integer | `207` | 공통정보 템플릿 ID | +| label_assignments | array | `[{"label_id":26,"start_date":null,"end_date":null}]` | 라벨 할당 목록 (각 항목: label_id + 노출 시작/종료일) | +| min_purchase_qty | integer | `1` | 최소 구매 수량 | +| max_purchase_qty | integer | `0` | 최대 구매 수량 (0=무제한) | +| purchase_restriction | string | `none` | 구매 제한: none(없음), restricted(제한) | +| allowed_roles | array | `[]` | 구매 허용 역할 ID 배열 | +| meta_title | null | `null` | SEO 제목 (다국어 JSON) | +| meta_description | null | `null` | SEO 설명 (다국어 JSON) | +| seo_tags | array | `[]` | SEO 태그 목록 (메타 키워드 등 검색엔진 노출용 태그) | +| seo_sync_title | boolean | `true` | SEO 제목 동기화 여부 (1: 상품명으로 자동 채움, 0: 직접 입력 보존) | +| seo_sync_description | boolean | `true` | SEO 설명 동기화 여부 (1: 상품 설명으로 자동 채움, 0: 직접 입력 보존) | +| barcode | null | `null` | 바코드 | +| hs_code | null | `null` | HS 코드 (관세 분류) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품 정보를 조회했습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 상품", + "en": "API Doc Sample Product" + }, + "product_code": "APIDOCSAMPLE01", + "sales_product_code": null, + "sku": "SKU-DKOK-1319", + "brand_id": null, + "category_ids": [], + "primary_category_id": null, + "created_at": "2026-07-08 01:44:49", + "updated_at": "2026-07-08 01:44:49", + "list_price": "703155.00", + "selling_price": "597682.00", + "stock_quantity": 509, + "safe_stock_quantity": 38, + "tax_status": "taxable", + "sales_status": "on_sale", + "display_status": "visible", + "options": [], + "additional_options": [], + "images": [], + "thumbnail_hash": null, + "description": { + "ko": "Tempore et non fugit eos et aperiam dolore. Aut esse fugiat adipisci iure itaque nisi. Earum ab dignissimos ipsa harum dolor sunt voluptatem.\n\nEst voluptatibus in sed distinctio. Delectus occaecati et earum est rerum ex. Beatae ex aut autem impedit facere totam. Et tempore minus veniam doloribus maiores eveniet dolorem ut. Culpa sed inventore ipsam.\n\nFuga praesentium distinctio quia ducimus harum assumenda. Ipsa dolorum odio reiciendis quis id. Tempore facere molestiae et reprehenderit reiciendis dicta quos. Quae labore deserunt fuga quam beatae. Praesentium aut fuga ratione aliquid.", + "en": "Soluta et ut repellendus rem. Id ea quibusdam in. Doloribus saepe rem praesentium et quis et molestiae.\n\nDolor voluptas aut qui unde sed quia. Corrupti qui enim laborum maxime possimus quis. Odit ducimus doloremque qui officiis fugit rerum. Magni ea possimus quia commodi optio saepe. Recusandae itaque in voluptatum et.\n\nRerum enim velit est inventore. Ipsa laboriosam provident ullam." + }, + "description_mode": "text", + "notice_items": null, + "shipping_policy_id": null, + "shipping_policy": null, + "common_info_id": null, + "label_assignments": [], + "min_purchase_qty": 1, + "max_purchase_qty": 0, + "purchase_restriction": "none", + "allowed_roles": [], + "meta_title": null, + "meta_description": null, + "seo_tags": [], + "seo_sync_title": true, + "seo_sync_description": true, + "barcode": null, + "hs_code": null + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 상품 수정 폼을 채우기 위한 상세 데이터를 조회합니다. `auth:sanctum` + `sirsoft-ecommerce.products.read` 권한이 필요하며, `ProductService::getDetailForForm()`가 폼 입력 필드에 맞춘 형태(카테고리 ID 배열, 옵션/추가옵션, 이미지, SEO 태그 등)로 데이터를 반환합니다. 관리자 상품 편집 화면 진입 시 폼 초기값을 로드하는 데 사용하며, 대상 상품이 없으면 404를 반환합니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/products/{product}/logs + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.products.logs` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductController@logs` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.products.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| product | path | string | 예 | — | 대상 product의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/products/1/logs HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `151066` | 기본 키 (내부 식별자) | +| log_type | string | `admin` | 로그 구분 값 (admin 관리자 작업 / user 사용자 작업 등 활동 로그 채널) | +| log_type_label | string | `관리자` | `log_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| loggable_type | string | `Modules\Sirsoft\Ecommerce\Models\Product` | 로그 대상 모델의 전체 클래스명 (상품 또는 상품옵션 모델) | +| loggable_type_display | string | `Product` | 로그 대상 모델의 표시용 짧은 이름 (클래스 basename) | +| loggable_id | integer | `201` | loggable 식별자 (연관 리소스 참조) | +| action | string | `product.create` | 활동 액션 키 (product.create/update 등 수행된 작업 식별자) | +| action_label | string | `생성` | `action` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| localized_description | string | `상품 생성 (면 손수건 3매입 #1)` | `description` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| description_key | string | `sirsoft-ecommerce::activity_log.descr…` | 설명 번역 키 (localized_description 을 생성하는 다국어 키) | +| properties | null | `null` | 로그 부가 속성 (액션에 첨부된 임의 메타데이터, 없으면 null) | +| changes | array | `[{"field":"sku","label_key":"sirsoft-ecommerce::activity_…` | 단일 수정 변경 내역 (각 항목: field·label·old·new, 일괄 수정 로그면 null) | +| bulk_changes | null | `null` | 일괄 수정 변경 내역 (각 항목: model_id·changes 배열, 단일 수정 로그면 null) | +| has_changes | boolean | `false` | changes 여부 | +| actor_name | string | `관리자` | 행위를 수행한 주체(사용자/시스템)의 이름 | +| user | object | `{"uuid":"a1e0a91a-fba6-491c-a53e-7285a5686857","name":"관리…` | 행위 수행 사용자 정보 (uuid·name·email, 시스템 작업이면 name 만 '시스템') | +| ip_address | string | `192.168.1.10` | 요청/행위가 발생한 IP 주소 | +| created_at | string | `2026-06-14 08:28:44` | 생성 일시 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품 처리 이력을 조회했습니다.", + "data": { + "data": [], + "links": { + "first": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/products/1/logs?page=1", + "last": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/products/1/logs?page=1", + "prev": null, + "next": null + }, + "meta": { + "current_page": 1, + "from": null, + "last_page": 1, + "links": [ + { + "url": null, + "label": "pagination.previous", + "page": null, + "active": false + }, + { + "url": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/products/1/logs?page=1", + "label": "1", + "page": 1, + "active": true + }, + { + "url": null, + "label": "pagination.next", + "page": null, + "active": false + } + ], + "path": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/products/1/logs", + "per_page": 25, + "to": null, + "total": 0 + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 상품의 처리로그(활동 로그) 목록을 조회합니다. `auth:sanctum` + `sirsoft-ecommerce.products.read` 권한이 필요하며, 컨트롤러가 해당 상품과 그 하위 옵션(`ProductOption`)의 `ActivityLog` 레코드를 `loggable_type`/`loggable_id` 기준으로 합쳐 `created_at` 정렬(기본 desc)로 페이지네이션합니다. `per_page`/`sort_order` 쿼리로 조회 범위를 조정하며, 상품 상세의 처리 이력 탭에서 생성/수정/재고 변경 등 감사 로그를 표시하는 데 사용합니다. 대상 상품이 없으면 404를 반환합니다. + + +### GET /api/modules/sirsoft-ecommerce/products + +- **라우트명**: `api.modules.sirsoft-ecommerce.products.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\ProductController@index` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-ecommerce.user-products.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| category_id | query | integer | 아니오 | — | category 식별자 | +| category_slug | query | string | 아니오 | max 100 | 카테고리 slug 필터 (URL 친화 식별자로 카테고리 지정, category_id 대체 가능) | +| brand_id | query | integer | 아니오 | — | brand 식별자 | +| search | query | string | 아니오 | max 200 | 검색어 (지정한 검색 대상 필드에서 부분 일치) | +| sort | query | string | 아니오 | `latest`, `sales`, `price_asc`, `price_desc` | 정렬 기준 (필드명, `-` 접두 시 내림차순) | +| min_price | query | integer | 아니오 | min 0 | 판매가 범위 필터 하한 (판매가가 이 값 이상인 상품) | +| max_price | query | integer | 아니오 | min 0 | 판매가 범위 필터 상한 (판매가가 이 값 이하인 상품) | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product.public_list_validation_rules`, `sirsoft-ecommerce.product.public_list_validation_messages`). + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/products?category_id=1&category_slug=example-key&brand_id=1&search=%EC%98%88%EC%8B%9C%EA%B0%92&sort=latest&min_price=1&max_price=1&per_page=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| number | integer | `109` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| id | integer | `322` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"eum et quia","en":"tenetur id quae"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name_localized | string | `eum et quia` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| product_code | string | `PROD-GJUX-1484` | 상품코드 (상품 고유 관리 식별자) | +| sku | string | `SKU-MRAD-9306` | 재고관리코드(SKU) | +| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL | +| list_price | integer | `112594` | 정가 (기본통화 자릿수로 정규화된 값) | +| list_price_formatted | string | `112,594원` | `list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| selling_price | integer | `88949` | 판매가 (기본통화 자릿수로 정규화된 값) | +| selling_price_formatted | string | `88,949원` | `selling_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| discount_rate | integer | `21` | 할인율(%) (정가 대비 판매가 할인 비율, (1 - 판매가/정가) × 100) | +| multi_currency_list_price | object | `{"KRW":{"price":112594,"formatted":"112,594원","is_default…` | 통화별 정가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 정가) | +| multi_currency_selling_price | object | `{"KRW":{"price":88949,"formatted":"88,949원","is_default":…` | 통화별 판매가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 판매가) | +| stock_quantity | integer | `22` | 재고 수량 (옵션 사용 시 옵션 재고 합계) | +| safe_stock_quantity | integer | `12` | 안전재고 수량 (이 값 미만이면 재고 부족으로 표시) | +| is_below_safe_stock | boolean | `false` | below safe stock 여부 | +| sales_status | string | `on_sale` | 판매상태 값 (on_sale 판매중 / suspended 판매중지 / sold_out 품절 / coming_soon 출시예정) | +| sales_status_label | string | `판매중` | `sales_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| sales_status_variant | string | `success` | `sales_status` 값의 표시 변형 키 (UI 배지 색상/스타일) | +| display_status | string | `visible` | 전시상태 값 (visible 전시 / hidden 숨김) | +| display_status_label | string | `전시` | `display_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| display_status_variant | string | `success` | `display_status` 값의 표시 변형 키 (UI 배지 색상/스타일) | +| categories | array | `[]` | 소속 카테고리 목록 (각 항목: id·현지화 이름·대표 여부. categories 관계 eager load 시에만 채워짐) | +| primary_category | string | `스마트폰` | 대표 카테고리명 (is_primary 카테고리의 현지화 이름) | +| categories_with_path | array | `[]` | 소속 카테고리 목록 + 경로 (각 항목: id·breadcrumb path·대표 여부) | +| brand_name | string | `CJ제일제당` | 브랜드명 (연관 브랜드의 현지화 이름) | +| shipping_policy_id | integer | `31` | shipping policy 식별자 (연관 리소스 참조) | +| min_purchase_qty | integer | `1` | 최소 구매 수량 (1회 주문 시 이 수량 이상 구매) | +| max_purchase_qty | integer | `0` | 최대 구매 수량 (0=무제한) | +| has_options | boolean | `false` | options 여부 | +| labels | array | `[]` | 노출 중인 상품 라벨 목록 (각 항목: 라벨명·색상, 활성 라벨을 sort_order 순으로 정렬) | +| review_count | integer | `0` | review 개수 (집계) | +| rating_avg | integer | `0` | 평균 별점 (공개 리뷰 별점 평균, 소수 1자리 반올림) | +| created_at | string | `2026-07-07 14:47:31` | 생성 일시 | +| updated_at | string | `2026-07-07 14:47:31` | 최종 수정 일시 | +| is_owner | boolean | `false` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품 정보를 조회했습니다.", + "data": { + "data": [ + { + "number": 3, + "id": 3, + "name": { + "ko": "nisi et corrupti", + "en": "suscipit esse id" + }, + "name_localized": "nisi et corrupti", + "product_code": "PROD-YNAZ-3669", + "sku": "SKU-EOQY-1167", + "thumbnail_url": null, + "list_price": 22605, + "list_price_formatted": "22,605원", + "selling_price": 20118, + "selling_price_formatted": "20,118원", + "discount_rate": 11, + "multi_currency_list_price": { + "KRW": { + "price": 22605, + "formatted": "22,605원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 19.21, + "formatted": "$19.21", + "is_default": false, + "editable": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 2599, + "formatted": "¥2,599", + "is_default": false, + "editable": false, + "exchange_rate": 115 + }, + "CNY": { + "price": 131.11, + "formatted": "元131.11", + "is_default": false, + "editable": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 17.63, + "formatted": "€17.63", + "is_default": false, + "editable": false, + "exchange_rate": 0.78 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 20118, + "formatted": "20,118원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 17.1, + "formatted": "$17.10", + "is_default": false, + "editable": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 2313, + "formatted": "¥2,313", + "is_default": false, + "editable": false, + "exchange_rate": 115 + }, + "CNY": { + "price": 116.68, + "formatted": "元116.68", + "is_default": false, + "editable": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 15.69, + "formatted": "€15.69", + "is_default": false, + "editable": false, + "exchange_rate": 0.78 + } + }, + "stock_quantity": 164, + "safe_stock_quantity": 8, + "is_below_safe_stock": false, + "sales_status": "on_sale", + "sales_status_label": "판매중", + "sales_status_variant": "success", + "display_status": "visible", + "display_status_label": "전시", + "display_status_variant": "success", + "categories": [], + "primary_category": null, + "categories_with_path": [], + "brand_name": null, + "shipping_policy_id": null, + "min_purchase_qty": 1, + "max_purchase_qty": 0, + "has_options": false, + "labels": [], + "review_count": 0, + "rating_avg": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "number": 2, + "id": 2, + "name": { + "ko": "repudiandae et et", + "en": "velit alias distinctio" + }, + "name_localized": "repudiandae et et", + "product_code": "PROD-XSMH-5438", + "sku": "SKU-XXSC-9588", + "thumbnail_url": null, + "list_price": 654947, + "list_price_formatted": "654,947원", + "selling_price": 465012, + "selling_price_formatted": "465,012원", + "discount_rate": 29, + "multi_currency_list_price": { + "KRW": { + "price": 654947, + "formatted": "654,947원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 556.7, + "formatted": "$556.70", + "is_default": false, + "editable": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 75318, + "formatted": "¥75,318", + "is_default": false, + "editable": false, + "exchange_rate": 115 + }, + "CNY": { + "price": 3798.69, + "formatted": "元3,798.69", + "is_default": false, + "editable": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 510.86, + "formatted": "€510.86", + "is_default": false, + "editable": false, + "exchange_rate": 0.78 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 465012, + "formatted": "465,012원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 395.26, + "formatted": "$395.26", + "is_default": false, + "editable": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 53476, + "formatted": "¥53,476", + "is_default": false, + "editable": false, + "exchange_rate": 115 + }, + "CNY": { + "price": 2697.07, + "formatted": "元2,697.07", + "is_default": false, + "editable": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 362.71, + "formatted": "€362.71", + "is_default": false, + "editable": false, + "exchange_rate": 0.78 + } + }, + "stock_quantity": 161, + "safe_stock_quantity": 49, + "is_below_safe_stock": false, + "sales_status": "on_sale", + "sales_status_label": "판매중", + "sales_status_variant": "success", + "display_status": "visible", + "display_status_label": "전시", + "display_status_variant": "success", + "categories": [], + "primary_category": null, + "categories_with_path": [], + "brand_name": null, + "shipping_policy_id": null, + "min_purchase_qty": 1, + "max_purchase_qty": 0, + "has_options": false, + "labels": [], + "review_count": 0, + "rating_avg": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + }, + "... (총 3건 중 2건 표시)" + ], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + }, + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 3, + "from": 1, + "to": 3, + "has_more_pages": false + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-products.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 쇼핑몰 프런트용 공개 상품 목록을 조회합니다. `optional.sanctum`(회원/비회원 모두 접근) + `sirsoft-ecommerce.user-products.read` 권한이 적용되며, `ProductService::getPublicList()`가 전시상태 visible이고 판매상태가 on_sale 또는 coming_soon인 상품만 반환합니다. 카테고리/브랜드/검색어/가격 범위 필터와 `sort`(latest/sales/price_asc/price_desc) 정렬을 지원하고 결과는 `ProductCollection`으로 페이지네이션됩니다. 확장은 `sirsoft-ecommerce.product.public_list_validation_rules` 훅으로 필터를 추가할 수 있습니다. + + +### GET /api/modules/sirsoft-ecommerce/products/new + +- **라우트명**: `api.modules.sirsoft-ecommerce.products.new` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\ProductController@new` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-ecommerce.user-products.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| limit | query | integer | 아니오 | min 1, max 50 | 반환할 최대 항목 수 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product.public_new_validation_rules`). + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/products/new?limit=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `322` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"eum et quia","en":"tenetur id quae"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name_localized | string | `eum et quia` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| product_code | string | `PROD-GJUX-1484` | 상품코드 (상품 고유 관리 식별자) | +| sku | string | `SKU-MRAD-9306` | 재고관리코드(SKU) | +| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL | +| list_price | integer | `112594` | 정가 (기본통화 자릿수로 정규화된 값) | +| list_price_formatted | string | `112,594원` | `list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| selling_price | integer | `88949` | 판매가 (기본통화 자릿수로 정규화된 값) | +| selling_price_formatted | string | `88,949원` | `selling_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| discount_rate | integer | `21` | 할인율(%) (정가 대비 판매가 할인 비율, (1 - 판매가/정가) × 100) | +| multi_currency_list_price | object | `{"KRW":{"price":112594,"formatted":"112,594원","is_default…` | 통화별 정가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 정가) | +| multi_currency_selling_price | object | `{"KRW":{"price":88949,"formatted":"88,949원","is_default":…` | 통화별 판매가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 판매가) | +| stock_quantity | integer | `22` | 재고 수량 (옵션 사용 시 옵션 재고 합계) | +| safe_stock_quantity | integer | `12` | 안전재고 수량 (이 값 미만이면 재고 부족으로 표시) | +| is_below_safe_stock | boolean | `false` | below safe stock 여부 | +| sales_status | string | `on_sale` | 판매상태 값 (on_sale 판매중 / suspended 판매중지 / sold_out 품절 / coming_soon 출시예정) | +| sales_status_label | string | `판매중` | `sales_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| sales_status_variant | string | `success` | `sales_status` 값의 표시 변형 키 (UI 배지 색상/스타일) | +| display_status | string | `visible` | 전시상태 값 (visible 전시 / hidden 숨김) | +| display_status_label | string | `전시` | `display_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| display_status_variant | string | `success` | `display_status` 값의 표시 변형 키 (UI 배지 색상/스타일) | +| categories | array | `[]` | 소속 카테고리 목록 (각 항목: id·현지화 이름·대표 여부. categories 관계 eager load 시에만 채워짐) | +| primary_category | string | `스마트폰` | 대표 카테고리명 (is_primary 카테고리의 현지화 이름) | +| categories_with_path | array | `[]` | 소속 카테고리 목록 + 경로 (각 항목: id·breadcrumb path·대표 여부) | +| shipping_policy_id | integer | `31` | shipping policy 식별자 (연관 리소스 참조) | +| min_purchase_qty | integer | `1` | 최소 구매 수량 (1회 주문 시 이 수량 이상 구매) | +| max_purchase_qty | integer | `0` | 최대 구매 수량 (0=무제한) | +| has_options | boolean | `false` | options 여부 | +| labels | array | `[]` | 노출 중인 상품 라벨 목록 (각 항목: 라벨명·색상, 활성 라벨을 sort_order 순으로 정렬) | +| review_count | integer | `0` | review 개수 (집계) | +| rating_avg | integer | `0` | 평균 별점 (공개 리뷰 별점 평균, 소수 1자리 반올림) | +| created_at | string | `2026-07-07 14:47:31` | 생성 일시 | +| updated_at | string | `2026-07-07 14:47:31` | 최종 수정 일시 | +| is_owner | boolean | `false` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품 정보를 조회했습니다.", + "data": [ + { + "id": 3, + "name": { + "ko": "nisi et corrupti", + "en": "suscipit esse id" + }, + "name_localized": "nisi et corrupti", + "product_code": "PROD-YNAZ-3669", + "sku": "SKU-EOQY-1167", + "thumbnail_url": null, + "list_price": 22605, + "list_price_formatted": "22,605원", + "selling_price": 20118, + "selling_price_formatted": "20,118원", + "discount_rate": 11, + "multi_currency_list_price": { + "KRW": { + "price": 22605, + "formatted": "22,605원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 19.21, + "formatted": "$19.21", + "is_default": false, + "editable": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 2599, + "formatted": "¥2,599", + "is_default": false, + "editable": false, + "exchange_rate": 115 + }, + "CNY": { + "price": 131.11, + "formatted": "元131.11", + "is_default": false, + "editable": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 17.63, + "formatted": "€17.63", + "is_default": false, + "editable": false, + "exchange_rate": 0.78 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 20118, + "formatted": "20,118원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 17.1, + "formatted": "$17.10", + "is_default": false, + "editable": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 2313, + "formatted": "¥2,313", + "is_default": false, + "editable": false, + "exchange_rate": 115 + }, + "CNY": { + "price": 116.68, + "formatted": "元116.68", + "is_default": false, + "editable": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 15.69, + "formatted": "€15.69", + "is_default": false, + "editable": false, + "exchange_rate": 0.78 + } + }, + "stock_quantity": 164, + "safe_stock_quantity": 8, + "is_below_safe_stock": false, + "sales_status": "on_sale", + "sales_status_label": "판매중", + "sales_status_variant": "success", + "display_status": "visible", + "display_status_label": "전시", + "display_status_variant": "success", + "categories": [], + "primary_category": null, + "categories_with_path": [], + "shipping_policy_id": null, + "min_purchase_qty": 1, + "max_purchase_qty": 0, + "has_options": false, + "labels": [], + "review_count": 0, + "rating_avg": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "id": 2, + "name": { + "ko": "repudiandae et et", + "en": "velit alias distinctio" + }, + "name_localized": "repudiandae et et", + "product_code": "PROD-XSMH-5438", + "sku": "SKU-XXSC-9588", + "thumbnail_url": null, + "list_price": 654947, + "list_price_formatted": "654,947원", + "selling_price": 465012, + "selling_price_formatted": "465,012원", + "discount_rate": 29, + "multi_currency_list_price": { + "KRW": { + "price": 654947, + "formatted": "654,947원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 556.7, + "formatted": "$556.70", + "is_default": false, + "editable": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 75318, + "formatted": "¥75,318", + "is_default": false, + "editable": false, + "exchange_rate": 115 + }, + "CNY": { + "price": 3798.69, + "formatted": "元3,798.69", + "is_default": false, + "editable": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 510.86, + "formatted": "€510.86", + "is_default": false, + "editable": false, + "exchange_rate": 0.78 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 465012, + "formatted": "465,012원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 395.26, + "formatted": "$395.26", + "is_default": false, + "editable": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 53476, + "formatted": "¥53,476", + "is_default": false, + "editable": false, + "exchange_rate": 115 + }, + "CNY": { + "price": 2697.07, + "formatted": "元2,697.07", + "is_default": false, + "editable": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 362.71, + "formatted": "€362.71", + "is_default": false, + "editable": false, + "exchange_rate": 0.78 + } + }, + "stock_quantity": 161, + "safe_stock_quantity": 49, + "is_below_safe_stock": false, + "sales_status": "on_sale", + "sales_status_label": "판매중", + "sales_status_variant": "success", + "display_status": "visible", + "display_status_label": "전시", + "display_status_variant": "success", + "categories": [], + "primary_category": null, + "categories_with_path": [], + "shipping_policy_id": null, + "min_purchase_qty": 1, + "max_purchase_qty": 0, + "has_options": false, + "labels": [], + "review_count": 0, + "rating_avg": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "id": 1, + "name": { + "ko": "API 문서 샘플 상품", + "en": "API Doc Sample Product" + }, + "name_localized": "API 문서 샘플 상품", + "product_code": "APIDOCSAMPLE01", + "sku": "SKU-DKOK-1319", + "thumbnail_url": null, + "list_price": 703155, + "list_price_formatted": "703,155원", + "selling_price": 597682, + "selling_price_formatted": "597,682원", + "discount_rate": 15, + "multi_currency_list_price": { + "KRW": { + "price": 703155, + "formatted": "703,155원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 597.68, + "formatted": "$597.68", + "is_default": false, + "editable": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 80862, + "formatted": "¥80,862", + "is_default": false, + "editable": false, + "exchange_rate": 115 + }, + "CNY": { + "price": 4078.3, + "formatted": "元4,078.30", + "is_default": false, + "editable": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 548.46, + "formatted": "€548.46", + "is_default": false, + "editable": false, + "exchange_rate": 0.78 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 597682, + "formatted": "597,682원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 508.03, + "formatted": "$508.03", + "is_default": false, + "editable": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 68733, + "formatted": "¥68,733", + "is_default": false, + "editable": false, + "exchange_rate": 115 + }, + "CNY": { + "price": 3466.56, + "formatted": "元3,466.56", + "is_default": false, + "editable": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 466.19, + "formatted": "€466.19", + "is_default": false, + "editable": false, + "exchange_rate": 0.78 + } + }, + "stock_quantity": 509, + "safe_stock_quantity": 38, + "is_below_safe_stock": false, + "sales_status": "on_sale", + "sales_status_label": "판매중", + "sales_status_variant": "success", + "display_status": "visible", + "display_status_label": "전시", + "display_status_variant": "success", + "categories": [], + "primary_category": null, + "categories_with_path": [], + "shipping_policy_id": null, + "min_purchase_qty": 1, + "max_purchase_qty": 0, + "has_options": false, + "labels": [], + "review_count": 1, + "rating_avg": 5, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + } + ] +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-products.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 공개 신상품 목록을 조회합니다. `optional.sanctum`(회원/비회원 모두 접근) + `sirsoft-ecommerce.user-products.read` 권한이 적용되며, `ProductService::getNewProducts()`가 최신 등록순으로 상품을 정렬해 `ProductListResource` 컬렉션으로 반환합니다. `limit`(기본 10, 최대 50) 쿼리로 개수를 제한하며, 메인 페이지의 신상품 섹션 등에 사용됩니다. 확장은 `sirsoft-ecommerce.product.public_new_validation_rules` 훅으로 검증 규칙을 확장할 수 있습니다. + + +### GET /api/modules/sirsoft-ecommerce/products/popular + +- **라우트명**: `api.modules.sirsoft-ecommerce.products.popular` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\ProductController@popular` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-ecommerce.user-products.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| limit | query | integer | 아니오 | min 1, max 50 | 반환할 최대 항목 수 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product.public_popular_validation_rules`). + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/products/popular?limit=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `204` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"베이직 라운드 티셔츠 #4","en":"Basic Round T-Shirt #4"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name_localized | string | `베이직 라운드 티셔츠 #4` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| product_code | string | `2R9AKHR0GH2DR3NG` | 상품코드 (상품 고유 관리 식별자) | +| sku | string | `TS-0004` | 재고관리코드(SKU) | +| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL | +| list_price | integer | `29000` | 정가 (기본통화 자릿수로 정규화된 값) | +| list_price_formatted | string | `29,000원` | `list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| selling_price | integer | `22000` | 판매가 (기본통화 자릿수로 정규화된 값) | +| selling_price_formatted | string | `22,000원` | `selling_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| discount_rate | number | `24.1` | 할인율(%) (정가 대비 판매가 할인 비율, (1 - 판매가/정가) × 100) | +| multi_currency_list_price | object | `{"KRW":{"price":29000,"formatted":"29,000원","is_default":…` | 통화별 정가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 정가) | +| multi_currency_selling_price | object | `{"KRW":{"price":22000,"formatted":"22,000원","is_default":…` | 통화별 판매가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 판매가) | +| stock_quantity | integer | `264` | 재고 수량 (옵션 사용 시 옵션 재고 합계) | +| safe_stock_quantity | integer | `15` | 안전재고 수량 (이 값 미만이면 재고 부족으로 표시) | +| is_below_safe_stock | boolean | `false` | below safe stock 여부 | +| sales_status | string | `on_sale` | 판매상태 값 (on_sale 판매중 / suspended 판매중지 / sold_out 품절 / coming_soon 출시예정) | +| sales_status_label | string | `판매중` | `sales_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| sales_status_variant | string | `success` | `sales_status` 값의 표시 변형 키 (UI 배지 색상/스타일) | +| display_status | string | `visible` | 전시상태 값 (visible 전시 / hidden 숨김) | +| display_status_label | string | `전시` | `display_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| display_status_variant | string | `success` | `display_status` 값의 표시 변형 키 (UI 배지 색상/스타일) | +| categories | array | `[{"id":109,"name":"식품","is_primary":0},{"id":113,"name":"…` | 소속 카테고리 목록 (각 항목: id·현지화 이름·대표 여부) | +| primary_category | string | `해산물` | 대표 카테고리명 (is_primary 카테고리의 현지화 이름) | +| categories_with_path | array | `[{"id":109,"path":[{"id":109,"name":"식품","slug":"food"}],…` | 소속 카테고리 목록 + 경로 (각 항목: id·breadcrumb path·대표 여부) | +| shipping_policy_id | integer | `31` | shipping policy 식별자 (연관 리소스 참조) | +| min_purchase_qty | integer | `1` | 최소 구매 수량 (1회 주문 시 이 수량 이상 구매) | +| max_purchase_qty | integer | `0` | 최대 구매 수량 (0=무제한) | +| has_options | boolean | `true` | options 여부 | +| labels | array | `[]` | 노출 중인 상품 라벨 목록 (각 항목: 라벨명·색상, 활성 라벨을 sort_order 순으로 정렬) | +| review_count | integer | `0` | review 개수 (집계) | +| rating_avg | integer | `0` | 평균 별점 (공개 리뷰 별점 평균, 소수 1자리 반올림) | +| created_at | string | `2026-06-15 11:24:15` | 생성 일시 | +| updated_at | string | `2026-06-15 11:24:15` | 최종 수정 일시 | +| is_owner | boolean | `false` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품 정보를 조회했습니다.", + "data": [ + { + "id": 2, + "name": { + "ko": "repudiandae et et", + "en": "velit alias distinctio" + }, + "name_localized": "repudiandae et et", + "product_code": "PROD-XSMH-5438", + "sku": "SKU-XXSC-9588", + "thumbnail_url": null, + "list_price": 654947, + "list_price_formatted": "654,947원", + "selling_price": 465012, + "selling_price_formatted": "465,012원", + "discount_rate": 29, + "multi_currency_list_price": { + "KRW": { + "price": 654947, + "formatted": "654,947원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 556.7, + "formatted": "$556.70", + "is_default": false, + "editable": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 75318, + "formatted": "¥75,318", + "is_default": false, + "editable": false, + "exchange_rate": 115 + }, + "CNY": { + "price": 3798.69, + "formatted": "元3,798.69", + "is_default": false, + "editable": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 510.86, + "formatted": "€510.86", + "is_default": false, + "editable": false, + "exchange_rate": 0.78 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 465012, + "formatted": "465,012원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 395.26, + "formatted": "$395.26", + "is_default": false, + "editable": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 53476, + "formatted": "¥53,476", + "is_default": false, + "editable": false, + "exchange_rate": 115 + }, + "CNY": { + "price": 2697.07, + "formatted": "元2,697.07", + "is_default": false, + "editable": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 362.71, + "formatted": "€362.71", + "is_default": false, + "editable": false, + "exchange_rate": 0.78 + } + }, + "stock_quantity": 161, + "safe_stock_quantity": 49, + "is_below_safe_stock": false, + "sales_status": "on_sale", + "sales_status_label": "판매중", + "sales_status_variant": "success", + "display_status": "visible", + "display_status_label": "전시", + "display_status_variant": "success", + "categories": [], + "primary_category": null, + "categories_with_path": [], + "shipping_policy_id": null, + "min_purchase_qty": 1, + "max_purchase_qty": 0, + "has_options": false, + "labels": [], + "review_count": 0, + "rating_avg": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "id": 1, + "name": { + "ko": "API 문서 샘플 상품", + "en": "API Doc Sample Product" + }, + "name_localized": "API 문서 샘플 상품", + "product_code": "APIDOCSAMPLE01", + "sku": "SKU-DKOK-1319", + "thumbnail_url": null, + "list_price": 703155, + "list_price_formatted": "703,155원", + "selling_price": 597682, + "selling_price_formatted": "597,682원", + "discount_rate": 15, + "multi_currency_list_price": { + "KRW": { + "price": 703155, + "formatted": "703,155원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 597.68, + "formatted": "$597.68", + "is_default": false, + "editable": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 80862, + "formatted": "¥80,862", + "is_default": false, + "editable": false, + "exchange_rate": 115 + }, + "CNY": { + "price": 4078.3, + "formatted": "元4,078.30", + "is_default": false, + "editable": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 548.46, + "formatted": "€548.46", + "is_default": false, + "editable": false, + "exchange_rate": 0.78 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 597682, + "formatted": "597,682원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 508.03, + "formatted": "$508.03", + "is_default": false, + "editable": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 68733, + "formatted": "¥68,733", + "is_default": false, + "editable": false, + "exchange_rate": 115 + }, + "CNY": { + "price": 3466.56, + "formatted": "元3,466.56", + "is_default": false, + "editable": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 466.19, + "formatted": "€466.19", + "is_default": false, + "editable": false, + "exchange_rate": 0.78 + } + }, + "stock_quantity": 509, + "safe_stock_quantity": 38, + "is_below_safe_stock": false, + "sales_status": "on_sale", + "sales_status_label": "판매중", + "sales_status_variant": "success", + "display_status": "visible", + "display_status_label": "전시", + "display_status_variant": "success", + "categories": [], + "primary_category": null, + "categories_with_path": [], + "shipping_policy_id": null, + "min_purchase_qty": 1, + "max_purchase_qty": 0, + "has_options": false, + "labels": [], + "review_count": 1, + "rating_avg": 5, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + }, + { + "id": 3, + "name": { + "ko": "nisi et corrupti", + "en": "suscipit esse id" + }, + "name_localized": "nisi et corrupti", + "product_code": "PROD-YNAZ-3669", + "sku": "SKU-EOQY-1167", + "thumbnail_url": null, + "list_price": 22605, + "list_price_formatted": "22,605원", + "selling_price": 20118, + "selling_price_formatted": "20,118원", + "discount_rate": 11, + "multi_currency_list_price": { + "KRW": { + "price": 22605, + "formatted": "22,605원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 19.21, + "formatted": "$19.21", + "is_default": false, + "editable": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 2599, + "formatted": "¥2,599", + "is_default": false, + "editable": false, + "exchange_rate": 115 + }, + "CNY": { + "price": 131.11, + "formatted": "元131.11", + "is_default": false, + "editable": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 17.63, + "formatted": "€17.63", + "is_default": false, + "editable": false, + "exchange_rate": 0.78 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 20118, + "formatted": "20,118원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 17.1, + "formatted": "$17.10", + "is_default": false, + "editable": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 2313, + "formatted": "¥2,313", + "is_default": false, + "editable": false, + "exchange_rate": 115 + }, + "CNY": { + "price": 116.68, + "formatted": "元116.68", + "is_default": false, + "editable": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 15.69, + "formatted": "€15.69", + "is_default": false, + "editable": false, + "exchange_rate": 0.78 + } + }, + "stock_quantity": 164, + "safe_stock_quantity": 8, + "is_below_safe_stock": false, + "sales_status": "on_sale", + "sales_status_label": "판매중", + "sales_status_variant": "success", + "display_status": "visible", + "display_status_label": "전시", + "display_status_variant": "success", + "categories": [], + "primary_category": null, + "categories_with_path": [], + "shipping_policy_id": null, + "min_purchase_qty": 1, + "max_purchase_qty": 0, + "has_options": false, + "labels": [], + "review_count": 0, + "rating_avg": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "is_owner": false, + "abilities": { + "can_update": true, + "can_delete": true + } + } + ] +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-products.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 공개 인기 상품 목록을 조회합니다. `optional.sanctum`(회원/비회원 모두 접근) + `sirsoft-ecommerce.user-products.read` 권한이 적용되며, `ProductService::getPopularProducts()`가 최근 30일 판매량 기준으로 정렬한 상품을 `ProductListResource` 컬렉션으로 반환합니다. `limit`(기본 10, 최대 50) 쿼리로 개수를 제한하며, 베스트/인기 상품 위젯에 사용됩니다. 확장은 `sirsoft-ecommerce.product.public_popular_validation_rules` 훅으로 검증 규칙을 확장할 수 있습니다. + + +### GET /api/modules/sirsoft-ecommerce/products/recent + +- **라우트명**: `api.modules.sirsoft-ecommerce.products.recent` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\ProductController@recent` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-ecommerce.user-products.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| ids | query | string | 아니오 | max 500 | 대상 리소스 식별자 배열 (대량 작업 대상) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product.public_recent_validation_rules`). + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/products/recent?ids=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품 정보를 조회했습니다.", + "data": [] +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-products.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 최근 본 상품 목록을 조회합니다. `optional.sanctum`(회원/비회원 모두 접근) + `sirsoft-ecommerce.user-products.read` 권한이 적용되며, 클라이언트가 로컬에 보관한 조회 이력 상품 ID들을 쉼표 구분 문자열(`ids`)로 전달하면 컨트롤러가 정수 배열로 파싱해 `ProductService::getProductsByIds()`로 조회한 뒤 `ProductListResource` 컬렉션을 반환합니다. `ids`가 비어 있으면 빈 배열을 반환하며, 확장은 `sirsoft-ecommerce.product.public_recent_validation_rules` 훅으로 검증 규칙을 확장할 수 있습니다. + + +### GET /api/modules/sirsoft-ecommerce/products/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.products.show` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\ProductController@show` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-ecommerce.user-products.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/products/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 상품","en":"API Doc Sample Product"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name_localized | string | `API 문서 샘플 상품` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| product_code | string | `APIDOCSAMPLE01` | 상품코드 | +| sku | string | `SKU-DKOK-1319` | SKU | +| categories | array | `[]` | 소속 카테고리 목록 (각 항목: id·현지화 이름·대표 여부. categories 관계 eager load 시에만 채워짐) | +| category_name | null | `null` | 대표 카테고리명 (is_primary 카테고리의 현지화 이름) | +| list_price | integer | `703155` | 정가 (기본통화 기준) | +| list_price_formatted | string | `703,155원` | `list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| selling_price | integer | `597682` | 판매가 (기본통화 기준) | +| selling_price_formatted | string | `597,682원` | `selling_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| discount_rate | integer | `15` | 할인율(%) (정가 대비 판매가 할인 비율, (1 - 판매가/정가) × 100) | +| multi_currency_list_price | object | `{"KRW":{"price":703155,"formatted":"703,155원","is_default…` | 통화별 정가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 정가) | +| multi_currency_selling_price | object | `{"KRW":{"price":597682,"formatted":"597,682원","is_default…` | 통화별 판매가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 판매가) | +| stock_quantity | integer | `509` | 재고 수량 (옵션 있으면 옵션 합계) | +| min_purchase_qty | integer | `1` | 최소 구매 수량 | +| max_purchase_qty | integer | `0` | 최대 구매 수량 (0=무제한) | +| sales_status | string | `on_sale` | 판매상태: on_sale(판매중), suspended(판매중지), sold_out(품절), coming_soon(출시예정) | +| sales_status_label | string | `판매중` | `sales_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| brand_name | null | `null` | 브랜드명 (연관 브랜드의 현지화 이름) | +| labels | array | `[]` | 노출 중인 상품 라벨 목록 (각 항목: 라벨명·색상, 활성 라벨을 sort_order 순으로 정렬) | +| additional_options | array | `[]` | 추가옵션 그룹 목록 (각 그룹: 그룹명·필수 여부·선택지 목록, 활성 옵션을 sort_order 순으로 정렬) | +| shipping_policy_id | null | `null` | shipping policy 식별자 (연관 리소스 참조) | +| is_shippable_to_selected_country | boolean | `true` | shippable to selected country 여부 | +| selected_shipping_country | string | `KR` | 배송비 계산에 적용된 배송 국가 코드 (ResolveShippingCountry 해석 결과) | +| free_shipping | boolean | `false` | 무료배송 여부 (적용 배송정책의 청구방식이 무료(FREE)인 경우 true) | +| shipping_fee_formatted | string | `` | `shipping_fee` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| shipping_policy | null | `null` | 적용 배송정책 요약 객체 (정책명·청구방식·기본배송비·무료배송 기준액 등, 상품 정책 없으면 기본 정책으로 폴백) | +| short_description | null | `null` | 짧은 설명 (상품 목록/카드용 요약 설명, 다국어 필드는 로케일별 값 객체) | +| short_description_localized | null | `null` | `short description` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| description | object | `{"ko":"Tempore et non fugit eos et aperiam dolore. Aut es…` | 설명 (다국어 필드는 로케일별 값 객체) | +| description_localized | string | `Tempore et non fugit eos et aperiam d…` | `description` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| description_mode | string | `text` | 설명 모드: text(텍스트), html(HTML) | +| images | array | `[]` | 상품 이미지 목록 (각 항목: hash·url·alt_text·is_thumbnail·sort_order 등, images 관계 로드 시) | +| thumbnail_url | null | `null` | thumbnail URL | +| meta_title | null | `null` | SEO 제목 (다국어 JSON) | +| meta_description | null | `null` | SEO 설명 (다국어 JSON) | +| meta_keywords | null | `null` | SEO 키워드 (배열) | +| has_options | boolean | `false` | options 여부 | +| option_groups | array | `[]` | 옵션 그룹 정의: [{name: "색상", values: ["빨강", "파랑"]}] | +| options | array | `[]` | 활성 옵션(SKU) 목록 (각 옵션의 코드·옵션값·가격·재고 등, ProductOptionResource) | +| notice | null | `null` | 상품정보제공고시 (템플릿명 + 항목별 라벨/내용 목록, notice 관계 로드 시) | +| common_info | null | `null` | 공통정보 (name·content·content_mode, commonInfo 관계 로드 시 — 여러 상품이 공유하는 공통 안내문) | +| is_wishlisted | boolean | `false` | wishlisted 여부 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품 정보를 조회했습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 상품", + "en": "API Doc Sample Product" + }, + "name_localized": "API 문서 샘플 상품", + "product_code": "APIDOCSAMPLE01", + "sku": "SKU-DKOK-1319", + "categories": [], + "category_name": null, + "list_price": 703155, + "list_price_formatted": "703,155원", + "selling_price": 597682, + "selling_price_formatted": "597,682원", + "discount_rate": 15, + "multi_currency_list_price": { + "KRW": { + "price": 703155, + "formatted": "703,155원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 597.68, + "formatted": "$597.68", + "is_default": false, + "editable": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 80862, + "formatted": "¥80,862", + "is_default": false, + "editable": false, + "exchange_rate": 115 + }, + "CNY": { + "price": 4078.3, + "formatted": "元4,078.30", + "is_default": false, + "editable": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 548.46, + "formatted": "€548.46", + "is_default": false, + "editable": false, + "exchange_rate": 0.78 + } + }, + "multi_currency_selling_price": { + "KRW": { + "price": 597682, + "formatted": "597,682원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 508.03, + "formatted": "$508.03", + "is_default": false, + "editable": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 68733, + "formatted": "¥68,733", + "is_default": false, + "editable": false, + "exchange_rate": 115 + }, + "CNY": { + "price": 3466.56, + "formatted": "元3,466.56", + "is_default": false, + "editable": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 466.19, + "formatted": "€466.19", + "is_default": false, + "editable": false, + "exchange_rate": 0.78 + } + }, + "stock_quantity": 509, + "min_purchase_qty": 1, + "max_purchase_qty": 0, + "sales_status": "on_sale", + "sales_status_label": "판매중", + "brand_name": null, + "labels": [], + "additional_options": [], + "shipping_policy_id": null, + "is_shippable_to_selected_country": true, + "selected_shipping_country": "KR", + "free_shipping": false, + "shipping_fee_formatted": "", + "shipping_policy": null, + "short_description": null, + "short_description_localized": null, + "description": { + "ko": "Tempore et non fugit eos et aperiam dolore. Aut esse fugiat adipisci iure itaque nisi. Earum ab dignissimos ipsa harum dolor sunt voluptatem.\n\nEst voluptatibus in sed distinctio. Delectus occaecati et earum est rerum ex. Beatae ex aut autem impedit facere totam. Et tempore minus veniam doloribus maiores eveniet dolorem ut. Culpa sed inventore ipsam.\n\nFuga praesentium distinctio quia ducimus harum assumenda. Ipsa dolorum odio reiciendis quis id. Tempore facere molestiae et reprehenderit reiciendis dicta quos. Quae labore deserunt fuga quam beatae. Praesentium aut fuga ratione aliquid.", + "en": "Soluta et ut repellendus rem. Id ea quibusdam in. Doloribus saepe rem praesentium et quis et molestiae.\n\nDolor voluptas aut qui unde sed quia. Corrupti qui enim laborum maxime possimus quis. Odit ducimus doloremque qui officiis fugit rerum. Magni ea possimus quia commodi optio saepe. Recusandae itaque in voluptatum et.\n\nRerum enim velit est inventore. Ipsa laboriosam provident ullam." + }, + "description_localized": "Tempore et non fugit eos et aperiam dolore. Aut esse fugiat adipisci iure itaque nisi. Earum ab dignissimos ipsa harum dolor sunt voluptatem.\n\nEst voluptatibus in sed distinctio. Delectus occaecati et earum est rerum ex. Beatae ex aut autem impedit facere totam. Et tempore minus veniam doloribus maiores eveniet dolorem ut. Culpa sed inventore ipsam.\n\nFuga praesentium distinctio quia ducimus harum assumenda. Ipsa dolorum odio reiciendis quis id. Tempore facere molestiae et reprehenderit reiciendis dicta quos. Quae labore deserunt fuga quam beatae. Praesentium aut fuga ratione aliquid.", + "description_mode": "text", + "images": [], + "thumbnail_url": null, + "meta_title": null, + "meta_description": null, + "meta_keywords": null, + "has_options": false, + "option_groups": [], + "options": [], + "notice": null, + "common_info": null, + "is_wishlisted": false + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-products.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 쇼핑몰 프런트용 공개 상품 상세를 조회합니다. `optional.sanctum`(회원/비회원 모두 접근) + `sirsoft-ecommerce.user-products.read` 권한이 적용되며, `ProductService::getDetail()`로 상품을 로드하되 전시상태가 visible이 아니면 404를 반환합니다. 상세 페이지에 필요한 배송정책·상품고시·공통정보·브랜드·라벨·추가옵션·현재 사용자 위시리스트 관계를 추가 로드하고 `PublicProductResource`로 반환하며, 응답에는 다중통화 가격·배송비 안내(`shipping_fee_formatted`)·`is_wishlisted` 등 프런트 표시용 파생 필드가 포함됩니다. + + +### GET /api/modules/sirsoft-ecommerce/products/{productId}/downloadable-coupons + +- **라우트명**: `api.modules.sirsoft-ecommerce.products.downloadable-coupons` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\PublicCouponController@downloadableCoupons` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-ecommerce.user-products.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| productId | path | string | 예 | — | 대상 product의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/products/1/downloadable-coupons HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| coupon_id | integer | `1` | coupon 식별자 (연관 리소스 참조) | +| localized_name | string | `API 문서 샘플 쿠폰` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| benefit_formatted | string | `1,000원 할인` | `benefit` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| multi_currency_benefit_formatted | object | `{"KRW":"1,000원 할인","USD":"$0.85 할인","JPY":"¥115 할인","CNY"…` | `multi_currency_benefit` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| target_type | string | `order_amount` | 적용대상(할인 기준): product_amount(상품금액), order_amount(주문금액), shipping_fee(배송비) | +| target_type_short_label | string | `주문` | `target_type_short` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| valid_period_formatted | string | `-` | `valid_period` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| min_order_amount | string | `0.00` | 쿠폰 적용 최소 주문금액 (0=제한 없음) | +| min_order_amount_formatted | string | `0원` | `min_order_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| multi_currency_min_order_amount | null | `null` | 최소 주문금액의 통화별 환산 맵 (0이면 null) | +| total_quantity | null | `null` | 총 발급 수량 (null=무제한) | +| remaining_quantity | null | `null` | 잔여 발급 가능 수량 (total_quantity − issued_count, 무제한이면 null) | +| is_downloaded | boolean | `false` | downloaded 여부 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "다운로드 가능한 쿠폰 목록을 불러왔습니다.", + "data": { + "data": [ + { + "coupon_id": 1, + "localized_name": "API 문서 샘플 쿠폰", + "benefit_formatted": "1,000원 할인", + "multi_currency_benefit_formatted": { + "KRW": "1,000원 할인", + "USD": "$0.85 할인", + "JPY": "¥115 할인", + "CNY": "元5.80 할인", + "EUR": "€0.78 할인" + }, + "target_type": "order_amount", + "target_type_short_label": "주문", + "valid_period_formatted": "-", + "min_order_amount": "0.00", + "min_order_amount_formatted": "0원", + "multi_currency_min_order_amount": null, + "total_quantity": null, + "remaining_quantity": null, + "is_downloaded": false + } + ] + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-products.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 특정 상품에서 다운로드 가능한 쿠폰 목록을 조회합니다. `optional.sanctum`(회원/비회원 모두 접근) + `sirsoft-ecommerce.user-products.read` 권한이 적용되며, `UserCouponService::getProductDownloadableCoupons()`가 해당 상품에 적용 가능한 발급 대기 쿠폰을 반환합니다. 로그인 상태면 사용자 ID를 함께 넘겨 각 쿠폰의 `is_downloaded`(이미 받았는지) 여부를 채워주고, 다중통화 혜택·최소주문금액(`multi_currency_benefit_formatted` 등)이 포함됩니다. 상품 상세의 쿠폰 받기 영역에 사용됩니다. + + +### GET /api/modules/sirsoft-ecommerce/products/{productId}/inquiries + +- **라우트명**: `api.modules.sirsoft-ecommerce.products.inquiries.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\ProductInquiryController@index` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-ecommerce.user-products.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| productId | path | string | 예 | — | 대상 product의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/products/1/inquiries HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| items | array | `[]` | 상품문의 항목 배열 (각 항목: id·작성자·작성일·답변 여부·게시판 연동 시 제목/내용/비밀글 여부/답변/첨부) | +| meta | object | `{"board_settings":{"secret_mode":"disabled","categories":…` | 문의 목록 메타 (board_settings 게시판 설정, inquiry_available 문의 게시판 연동 여부, current_page/per_page/total/last_page 페이지네이션, abilities 답변·삭제 권한) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "문의 목록을 조회했습니다.", + "data": { + "items": [], + "meta": { + "board_settings": { + "secret_mode": "{MASKED}", + "categories": [], + "use_file_upload": false, + "max_file_count": 5, + "max_file_size": 10485760, + "allowed_extensions": [], + "min_title_length": 2, + "max_title_length": 200, + "min_content_length": 10, + "max_content_length": 10000 + }, + "inquiry_available": false, + "total": 0, + "current_page": 1, + "per_page": 25, + "last_page": 1 + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-products.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 상품의 1:1 문의 목록을 조회합니다. `optional.sanctum`(회원/비회원 모두 접근) + `sirsoft-ecommerce.user-products.read` 권한이 적용되며, `ProductInquiryService::getProductInquiries()`가 게시판 모듈과 연동된 문의 글을 페이지네이션해 `items`와 `board_settings`(비밀글 모드·카테고리 등) 메타를 반환합니다. `per_page`/`page`/`exclude_secret` 쿼리로 조회 범위를 조정하며, 비밀 문의는 설정과 열람 권한에 따라 마스킹됩니다. 상품 상세의 문의 탭에 사용됩니다. + + +### POST /api/modules/sirsoft-ecommerce/products/{productId}/inquiries + +- **라우트명**: `api.modules.sirsoft-ecommerce.products.inquiries.store` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\ProductInquiryController@store` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-ecommerce.user-products.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| productId | path | string | 예 | — | 대상 product의 식별자 | +| title | body | string | 아니오 | — | 제목 | +| category | body | string | 아니오 | — | 문의 분류 (게시판 설정에 정의된 카테고리, 미지정 시 기본값) | +| content | body | string | 예 | — | 본문 내용 | +| is_secret | body | boolean | 아니오 | — | secret 여부 | +| temp_key | body | string | 아니오 | — | 첨부파일 임시 업로드 키 (사전 업로드한 첨부를 이 문의에 연결) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.inquiry.store_validation_rules`, `sirsoft-ecommerce.inquiry.store_validation_messages`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/products/1/inquiries HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +Content-Type: application/json + +{ + "title": "예시 제목", + "category": "예시값", + "content": "예시 내용입니다.", + "is_secret": true, + "temp_key": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-products.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 상품에 1:1 문의를 작성합니다. `optional.sanctum` + `sirsoft-ecommerce.user-products.read` 권한이 적용되며(선택적 인증 표면이지만 실제 작성은 인증 사용자를 전제), `ProductInquiryService::createInquiry()`가 게시판 모듈과 연동해 문의 글을 생성하고 성공 시 201과 생성된 `id`를 반환합니다. `content`는 필수, `title`/`category`/`is_secret`은 선택이며, 첨부는 사전 업로드한 `temp_key`로 연결됩니다. 도메인 규칙 위반(비밀글 비허용 등)은 `RuntimeException`으로 422를 반환합니다. + + +### GET /api/modules/sirsoft-ecommerce/products/{productId}/reviews + +- **라우트명**: `api.modules.sirsoft-ecommerce.products.reviews.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\ProductReviewController@index` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-ecommerce.user-products.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| productId | path | string | 예 | — | 대상 product의 식별자 | +| sort | query | string | 아니오 | `created_at_desc`, `created_at_asc`, `rating_desc`, `rating_asc` | 정렬 기준 (필드명, `-` 접두 시 내림차순) | +| photo_only | query | string | 아니오 | `0`, `1`, `true`, `false` | 포토리뷰만 필터 (true 시 사진이 첨부된 리뷰만 조회) | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | +| per_page | query | integer | 아니오 | min 1, max 50 | 페이지당 항목 수 | +| rating | query | integer | 아니오 | `1`, `2`, `3`, `4`, `5` | 별점 필터 (지정한 평점의 리뷰만 조회) | +| option_filters | query | string | 아니오 | — | 옵션 조건 필터 (JSON 문자열로 전달, 서버에서 배열로 파싱해 특정 옵션 구매 리뷰만 조회) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.review.public_list_validation_rules`, `sirsoft-ecommerce.review.public_list_validation_messages`). + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/products/1/reviews?sort=created_at_desc&photo_only=0&page=1&per_page=1&rating=1&option_filters=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| reviews | object | `{"data":[{"id":1,"product_id":1,"order_option_id":1,"user…` | 리뷰 목록 (data 배열 + 페이지네이션 메타, 공개 상태 리뷰만 정렬/필터 적용해 반환) | +| rating_stats | object | `{"5":{"count":1,"percent":100},"4":{"count":0,"percent":0…` | 별점 분포 통계 (별점(1~5)별 count·percent 맵 + avg 평균 별점, 공개 리뷰 기준) | +| option_filters | array | `[]` | 옵션 조건 필터 후보 (구매 옵션 키·값별 리뷰 건수 집계, 옵션별 리뷰 필터 UI용) | +| total_count | integer | `1` | total 개수 (집계) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "리뷰 목록을 조회했습니다.", + "data": { + "reviews": { + "data": [ + { + "id": 1, + "product_id": 1, + "order_option_id": 1, + "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "user": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com" + }, + "option_snapshot": null, + "option_snapshot_label": "", + "rating": 5, + "content": "Alias quas iusto dolorem eum eveniet ad omnis. Id neque consequatur fuga ut. Enim cum mollitia nisi. Adipisci sunt tenetur et tempora tempora eius rerum.", + "content_mode": "text", + "status": "visible", + "status_label": "전시중", + "status_badge_color": "blue", + "images": [], + "image_count": 0, + "has_reply": false, + "has_reply_label": "미답변", + "has_reply_badge_color": "gray", + "reply_content": null, + "reply_content_mode": "text", + "replied_at": null, + "reply_updated_at": null, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_update": true, + "can_delete": true + } + } + ], + "links": { + "first": "https://api.example.com/api/modules/sirsoft-ecommerce/products/1/reviews?page=1", + "last": "https://api.example.com/api/modules/sirsoft-ecommerce/products/1/reviews?page=1", + "prev": null, + "next": null + }, + "meta": { + "current_page": 1, + "from": 1, + "last_page": 1, + "links": [ + { + "url": null, + "label": "pagination.previous", + "page": null, + "active": false + }, + { + "url": "https://api.example.com/api/modules/sirsoft-ecommerce/products/1/reviews?page=1", + "label": "1", + "page": 1, + "active": true + }, + { + "url": null, + "label": "pagination.next", + "page": null, + "active": false + } + ], + "path": "https://api.example.com/api/modules/sirsoft-ecommerce/products/1/reviews", + "per_page": 25, + "to": 1, + "total": 1 + } + }, + "rating_stats": { + "5": { + "count": 1, + "percent": 100 + }, + "4": { + "count": 0, + "percent": 0 + }, + "3": { + "count": 0, + "percent": 0 + }, + "2": { + "count": 0, + "percent": 0 + }, + "1": { + "count": 0, + "percent": 0 + }, + "avg": 5 + }, + "option_filters": [], + "total_count": 1 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-products.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 상품의 공개 리뷰 목록과 별점 통계를 조회합니다. `optional.sanctum`(회원/비회원 모두 접근) + `sirsoft-ecommerce.user-products.read` 권한이 적용되며, `ProductReviewService::getProductReviews()`가 정렬(`sort`)·포토리뷰만(`photo_only`)·별점(`rating`)·옵션(`option_filters`) 필터를 적용해 리뷰를 페이지네이션하고 별점 분포(`rating_stats`)와 선택 가능한 옵션 필터, 총 개수를 함께 반환합니다. `option_filters`는 JSON 문자열로 전달되면 서버에서 배열로 파싱되며, 상품 상세의 리뷰 탭에 사용됩니다. 확장은 `sirsoft-ecommerce.review.public_list_validation_rules` 훅으로 필터를 추가할 수 있습니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/promotion-coupons.md b/modules/_bundled/sirsoft-ecommerce/docs/api/promotion-coupons.md new file mode 100644 index 00000000..fb590202 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/promotion-coupons.md @@ -0,0 +1,935 @@ +# Promotion Coupons API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Promotion Coupons 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-ecommerce/admin/promotion-coupons + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.promotion-coupons.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CouponController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.promotion-coupon.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | +| sort_by | query | string | 아니오 | `created_at`, `name`, `discount_value`, `issued_count` | 정렬 기준 필드명 | +| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) | +| search_field | query | string | 아니오 | `all`, `name`, `description`, `created_by` | 검색 대상 필드명 (검색어를 적용할 컬럼) | +| search_keyword | query | string | 아니오 | max 255 | 검색 키워드 (부분 일치) | +| target_type | query | string | 아니오 | `all`, `product_amount`, `order_amount`, `shipping_fee` | 적용대상(할인 기준) 필터: 상품금액/주문금액/배송비 (`all`=전체) | +| discount_type | query | string | 아니오 | `all`, `fixed`, `rate` | 혜택유형 필터: fixed(정액), rate(정률%) (`all`=전체) | +| issue_status | query | string | 아니오 | `all`, `issuing`, `stopped` | 발급상태 필터: issuing(발급중), stopped(발급중단) (`all`=전체) | +| issue_method | query | string | 아니오 | `all`, `direct`, `download`, `auto` | 발급방법 필터: direct(직접발급), download(다운로드), auto(자동발급) (`all`=전체) | +| issue_condition | query | string | 아니오 | `all`, `manual`, `signup`, `first_purchase`, `birthday` | 발급조건 필터: manual(수동), signup(회원가입), first_purchase(첫구매), birthday(생일) (`all`=전체) | +| min_benefit_amount | query | number | 아니오 | min 0 | 혜택값(할인 금액/율) 하한 필터 | +| max_benefit_amount | query | number | 아니오 | min 0 | 혜택값(할인 금액/율) 상한 필터 | +| min_order_amount | query | number | 아니오 | min 0 | 최소 주문금액 하한 필터 | +| created_start_date | query | date | 아니오 | — | 생성일시 범위 시작 | +| created_end_date | query | date | 아니오 | — | 생성일시 범위 종료 (시작일 이후) | +| valid_start_date | query | date | 아니오 | — | 유효기간 범위 시작 | +| valid_end_date | query | date | 아니오 | — | 유효기간 범위 종료 (시작일 이후) | +| issue_start_date | query | date | 아니오 | — | 발급기간 범위 시작 | +| issue_end_date | query | date | 아니오 | — | 발급기간 범위 종료 (시작일 이후) | +| created_by | query | uuid | 아니오 | — | 등록자(생성한 관리자) UUID 필터 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/promotion-coupons?page=1&per_page=1&sort_by=created_at&sort_order=asc&search_field=all&search_keyword=%EC%98%88%EC%8B%9C%EA%B0%92&target_type=all&discount_type=all&issue_status=all&issue_method=all&issue_condition=all&min_benefit_amount=1&max_benefit_amount=1&min_order_amount=1&created_start_date=2026-01-01&created_end_date=2026-01-01&valid_start_date=2026-01-01&valid_end_date=2026-01-01&issue_start_date=2026-01-01&issue_end_date=2026-01-01&created_by=9f8b2c1a-4d3e-4a2b-8c1d-0e1f2a3b4c5d HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `157` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 쿠폰","en":"API Doc Sample Coupon"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 쿠폰` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| description | object | `{"ko":null,"en":null}` | 설명 (다국어 필드는 로케일별 값 객체) | +| localized_description | string | `설날 특별 무료배송 예정` | `description` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| target_type | string | `order_amount` | 적용대상(할인 기준): product_amount(상품금액), order_amount(주문금액), shipping_fee(배송비) | +| target_type_label | string | `주문금액` | `target_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| target_type_badge_color | string | `blue` | `target_type` 배지 색상 (상품금액=teal, 주문금액=blue, 배송비=orange) | +| discount_type | string | `fixed` | 혜택유형: fixed(정액 금액 할인), rate(정률 % 할인) | +| discount_type_label | string | `정액할인` | `discount_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| discount_value | integer | `1000` | 혜택값 (정액이면 할인 금액, 정률이면 할인율 %). 정액은 기본 통화 자릿수로 정규화 | +| discount_max_amount | integer | `2000` | 최대 할인액 (정률 할인 시 상한 금액, 미설정 시 null) | +| min_order_amount | integer | `0` | 쿠폰 적용 최소 주문금액 (0=제한 없음) | +| benefit_formatted | string | `1,000원 할인` | `benefit` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| multi_currency_discount_value | object | `{"KRW":{"price":1000,"formatted":"1,000원","is_default":tr…` | 정액 할인 금액의 통화별 환산 맵 (정률은 통화 무관이라 null) | +| multi_currency_min_order_amount | object | `{"KRW":{"price":10000,"formatted":"10,000원","is_default":…` | 최소 주문금액의 통화별 환산 맵 (0이면 null) | +| multi_currency_discount_max_amount | object | `{"KRW":{"price":2000,"formatted":"2,000원","is_default":tr…` | 최대 할인액의 통화별 환산 맵 (미설정 시 null) | +| issue_method | string | `download` | 발급방법: direct(직접발급), download(다운로드), auto(자동발급) | +| issue_method_label | string | `다운로드` | `issue_method` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| issue_method_badge_color | string | `teal` | `issue_method` 배지 색상 (직접발급=gray, 다운로드=teal, 자동발급=blue) | +| issue_condition | string | `manual` | 발급조건: manual(수동), signup(회원가입), first_purchase(첫구매), birthday(생일) | +| issue_condition_label | string | `수동발급` | `issue_condition` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| issue_condition_badge_color | string | `orange` | `issue_condition` 배지 색상 (수동=orange, 회원가입=blue, 첫구매=teal, 생일=pink) | +| issue_status | string | `issuing` | 발급상태: issuing(발급중), stopped(발급중단) | +| issue_status_label | string | `발급중` | `issue_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| issue_status_badge_color | string | `blue` | `issue_status` 배지 색상 (발급중=blue, 발급중단=orange) | +| total_quantity | integer | `1` | 총 발급 수량 (null=무제한) | +| issued_count | integer | `0` | issued 개수 (집계) | +| per_user_limit | integer | `1` | 회원 1인당 발급 제한 수량 (0=무제한) | +| issue_count_formatted | string | `0/무제한` | `issue_count` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| valid_type | string | `period` | 유효기간 유형: period(기간 지정), days_from_issue(발급일로부터 N일) | +| valid_days | integer | `1` | 발급일로부터 유효 일수 (valid_type=days_from_issue 일 때) | +| valid_from | string | `2026-06-30` | 유효기간 시작일 (사이트 타임존 기준 날짜 문자열) | +| valid_to | string | `2026-07-30` | 유효기간 종료일 (사이트 타임존 기준 날짜 문자열) | +| valid_period_formatted | string | `-` | `valid_period` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| issue_from | string | `2026-06-30T11:24` | 발급기간 시작 일시 (datetime-local 입력 호환 문자열) | +| issue_to | string | `2026-07-15T11:24` | 발급기간 종료 일시 (datetime-local 입력 호환 문자열) | +| issue_period_formatted | string | `상시발급` | `issue_period` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| is_combinable | boolean | `false` | combinable 여부 | +| target_scope | string | `all` | 적용 범위: all(전체 상품), products(특정 상품), categories(특정 카테고리) | +| target_scope_label | string | `전체상품` | `target_scope` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| is_issuable | boolean | `true` | issuable 여부 | +| created_at | string | `2026-07-07 14:47:31` | 생성 일시 | +| updated_at | string | `2026-07-07 14:47:31` | 최종 수정 일시 | +| created_by | string | `a1e0a91a-fba6-491c-a53e-7285a5686857` | 등록자(생성한 관리자) UUID (creator 관계 로드 시) | +| created_by_name | string | `-` | 등록자 이름 (creator 미로드/미설정 시 `-`) | +| created_by_email | string | `heuristing@gmail.com` | 등록자 이메일 (creator 관계 파생) | +| creator | object | `{"uuid":"a1e0a91a-fba6-491c-a53e-7285a5686857","name":"관리자"}` | 생성자 정보 객체 (uuid/name/email — creator 관계 파생) | +| issues_count | integer | `0` | issues 개수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "쿠폰 목록을 조회했습니다.", + "data": { + "data": [ + { + "id": 1, + "name": { + "ko": "API 문서 샘플 쿠폰", + "en": "API Doc Sample Coupon" + }, + "localized_name": "API 문서 샘플 쿠폰", + "description": null, + "localized_description": null, + "target_type": "order_amount", + "target_type_label": "주문금액", + "target_type_badge_color": "blue", + "discount_type": "fixed", + "discount_type_label": "정액할인", + "discount_value": 1000, + "discount_max_amount": null, + "min_order_amount": 0, + "benefit_formatted": "1,000원 할인", + "multi_currency_discount_value": { + "KRW": { + "price": 1000, + "formatted": "1,000원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 0.85, + "formatted": "$0.85", + "is_default": false, + "editable": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 115, + "formatted": "¥115", + "is_default": false, + "editable": false, + "exchange_rate": 115 + }, + "CNY": { + "price": 5.8, + "formatted": "元5.80", + "is_default": false, + "editable": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 0.78, + "formatted": "€0.78", + "is_default": false, + "editable": false, + "exchange_rate": 0.78 + } + }, + "multi_currency_min_order_amount": null, + "multi_currency_discount_max_amount": null, + "issue_method": "download", + "issue_method_label": "다운로드", + "issue_method_badge_color": "teal", + "issue_condition": "manual", + "issue_condition_label": "수동발급", + "issue_condition_badge_color": "orange", + "issue_status": "issuing", + "issue_status_label": "발급중", + "issue_status_badge_color": "blue", + "total_quantity": null, + "issued_count": 0, + "per_user_limit": 1, + "issue_count_formatted": "0/무제한", + "valid_type": "period", + "valid_days": null, + "valid_from": null, + "valid_to": null, + "valid_period_formatted": "-", + "issue_from": null, + "issue_to": null, + "issue_period_formatted": "상시발급", + "is_combinable": false, + "target_scope": "all", + "target_scope_label": "전체상품", + "is_issuable": true, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "created_by": null, + "created_by_name": "-", + "created_by_email": null, + "creator": null, + "issues_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } + ], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + }, + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 1, + "from": 1, + "to": 1, + "has_more_pages": false + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.promotion-coupon.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 프로모션 쿠폰(쿠폰 정의/마스터) 목록을 검색·필터·정렬·페이지네이션으로 조회합니다. `permission:sirsoft-ecommerce.promotion-coupon.read` 권한이 필요하며, 이름/설명/생성자 검색, 적용대상·혜택유형·발급상태·발급방법·발급조건 필터, 혜택금액·주문금액·생성/유효/발급 기간 범위 필터를 지원합니다. `CouponService::getCoupons()`가 조회하고 `CouponCollection`으로 직렬화하며, 각 항목은 다국어 라벨·배지 색상·다중통화 혜택값 등 관리자 UI 표시용 파생 필드를 포함합니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/promotion-coupons + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.promotion-coupons.store` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CouponController@store` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.promotion-coupon.create` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| name | body | array | 예 | — | 대상의 이름/명칭 | +| description | body | array | 아니오 | — | 설명 | +| target_type | body | string | 예 | `product_amount`, `order_amount`, `shipping_fee` | 적용대상(할인 기준): 상품금액/주문금액/배송비 | +| discount_type | body | string | 예 | `fixed`, `rate` | 혜택유형: fixed(정액 금액), rate(정률 %) | +| discount_value | body | number | 예 | min 1 | 혜택값 (정액이면 할인 금액, 정률이면 1~100 할인율 %) | +| discount_max_amount | body | number | 아니오 | min 0 | 최대 할인액 (정률 할인 시 상한 금액) | +| min_order_amount | body | number | 아니오 | min 0 | 쿠폰 적용 최소 주문금액 (미입력 시 0=제한 없음) | +| issue_method | body | string | 예 | `direct`, `download`, `auto` | 발급방법: direct(직접발급), download(다운로드), auto(자동발급) | +| issue_condition | body | string | 예 | `manual`, `signup`, `first_purchase`, `birthday` | 발급조건: manual(수동), signup(회원가입), first_purchase(첫구매), birthday(생일) | +| issue_status | body | string | 예 | `issuing`, `stopped` | 발급상태: issuing(발급중), stopped(발급중단) | +| total_quantity | body | integer | 아니오 | min 1 | 총 발급 수량 (미입력 시 무제한) | +| per_user_limit | body | integer | 예 | min 0 | 회원 1인당 발급 제한 수량 (0=무제한) | +| valid_type | body | string | 예 | `period`, `days_from_issue` | 유효기간 유형: period(기간 지정, valid_from/valid_to 필수), days_from_issue(발급일로부터 N일, valid_days 필수) | +| valid_days | body | integer | 아니오 | min 1 | 발급일로부터 유효 일수 (valid_type=days_from_issue 시 필수) | +| valid_from | body | date | 아니오 | — | 유효기간 시작일 (valid_type=period 시 필수) | +| valid_to | body | date | 아니오 | — | 유효기간 종료일 (valid_type=period 시 필수, valid_from 이후) | +| issue_from | body | date | 아니오 | — | 발급기간 시작 일시 (미입력 시 상시발급) | +| issue_to | body | date | 아니오 | — | 발급기간 종료 일시 (issue_from 이후) | +| is_combinable | body | boolean | 아니오 | — | combinable 여부 | +| target_scope | body | string | 아니오 | `all`, `products`, `categories` | 적용 범위: all(전체 상품), products(특정 상품), categories(특정 카테고리) | +| products | body | array | 아니오 | — | 적용 상품 목록 (`target_scope=products`), 항목별 `{id, type: include\|exclude}` | +| categories | body | array | 아니오 | — | 적용 카테고리 목록 (`target_scope=categories`), 항목별 `{id, type: include\|exclude}` | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.coupon.create_validation_rules`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/promotion-coupons HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": [ + "예시 이름" + ], + "description": [ + "예시 내용입니다." + ], + "target_type": "product_amount", + "discount_type": "fixed", + "discount_value": 1, + "discount_max_amount": 1, + "min_order_amount": 1, + "issue_method": "direct", + "issue_condition": "manual", + "issue_status": "issuing", + "total_quantity": 1, + "per_user_limit": 1, + "valid_type": "period", + "valid_days": 1, + "valid_from": "2026-01-01", + "valid_to": "2026-01-01", + "issue_from": "2026-01-01", + "issue_to": "2026-01-01", + "is_combinable": true, + "target_scope": "all", + "products": [ + "예시값" + ], + "categories": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.promotion-coupon.create`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 새 프로모션 쿠폰(정의)을 생성합니다. `permission:sirsoft-ecommerce.promotion-coupon.create` 권한이 필요하며, 다국어 쿠폰명(`name`), 적용대상·혜택유형·혜택값, 발급방법/조건/상태, 유효기간·발급기간, 회원당 한도, 적용 범위(`target_scope`: all·products·categories)와 그에 따른 상품/카테고리 배열을 받아 `CouponService::createCoupon()`이 저장하고 생성된 쿠폰을 201로 반환합니다. `target_scope` 가 products/categories 일 때만 각 배열이 의미를 가지며, 확장은 `sirsoft-ecommerce.coupon.create_validation_rules` 필터로 검증 규칙을 추가할 수 있습니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/promotion-coupons/bulk-status + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.promotion-coupons.bulk-status` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CouponController@bulkUpdateStatus` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.promotion-coupon.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| ids | body | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | +| issue_status | body | string | 예 | `issuing`, `stopped` | 일괄 적용할 발급상태: issuing(발급중), stopped(발급중단) | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/promotion-coupons/bulk-status HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "ids": [ + "예시값" + ], + "issue_status": "issuing" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.promotion-coupon.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 여러 쿠폰의 발급상태(`issue_status`: issuing·stopped)를 한 번에 변경합니다. `permission:sirsoft-ecommerce.promotion-coupon.update` 권한이 필요하고 `ids` 로 대상 쿠폰들을 지정하며, `CouponService::bulkUpdateIssueStatus()`가 일괄 갱신 후 변경된 건수(`updated_count`)를 반환합니다. 쿠폰 목록에서 여러 항목을 선택해 발급을 일괄 중단/재개할 때 사용합니다. + + +### DELETE /api/modules/sirsoft-ecommerce/admin/promotion-coupons/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.promotion-coupons.destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CouponController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.promotion-coupon.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/admin/promotion-coupons/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| coupon_id | integer | `1` | coupon 식별자 (연관 리소스 참조) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "쿠폰이 삭제되었습니다.", + "data": { + "coupon_id": 1 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.promotion-coupon.delete`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 프로모션 쿠폰(정의) 1건을 삭제합니다. `permission:sirsoft-ecommerce.promotion-coupon.delete` 권한이 필요하며, `CouponService::deleteCoupon()`이 삭제를 수행합니다(쿠폰 모델은 소프트 삭제 대상). 이미 발급된 내역이 있는 등 도메인 제약으로 삭제가 실패하면 400 오류로 응답합니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/promotion-coupons/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.promotion-coupons.show` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CouponController@show` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.promotion-coupon.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/promotion-coupons/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 쿠폰","en":"API Doc Sample Coupon"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 쿠폰` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| description | null | `null` | 설명 (다국어 필드는 로케일별 값 객체) | +| localized_description | null | `null` | `description` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| target_type | string | `order_amount` | 적용대상: product_amount(상품금액), order_amount(주문금액), shipping_fee(배송비) | +| target_type_label | string | `주문금액` | `target_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| target_type_badge_color | string | `blue` | `target_type` 배지 색상 (상품금액=teal, 주문금액=blue, 배송비=orange) | +| discount_type | string | `fixed` | 혜택유형: fixed(정액), rate(정률) | +| discount_type_label | string | `정액할인` | `discount_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| discount_value | integer | `1000` | 혜택값 (정액: 금액, 정률: %) | +| discount_max_amount | null | `null` | 최대 할인액 (정률 시) | +| min_order_amount | integer | `0` | 최소 주문금액 | +| benefit_formatted | string | `1,000원 할인` | `benefit` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| multi_currency_discount_value | object | `{"KRW":{"price":1000,"formatted":"1,000원","is_default":tr…` | 정액 할인 금액의 통화별 환산 맵 (정률은 통화 무관이라 null) | +| multi_currency_min_order_amount | null | `null` | 최소 주문금액의 통화별 환산 맵 (0이면 null) | +| multi_currency_discount_max_amount | null | `null` | 최대 할인액의 통화별 환산 맵 (미설정 시 null) | +| issue_method | string | `download` | 발급방법: direct(직접발급), download(다운로드), auto(자동발급) | +| issue_method_label | string | `다운로드` | `issue_method` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| issue_method_badge_color | string | `teal` | `issue_method` 배지 색상 (직접발급=gray, 다운로드=teal, 자동발급=blue) | +| issue_condition | string | `manual` | 발급조건: manual(수동), signup(회원가입), first_purchase(첫구매), birthday(생일) | +| issue_condition_label | string | `수동발급` | `issue_condition` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| issue_condition_badge_color | string | `orange` | `issue_condition` 배지 색상 (수동=orange, 회원가입=blue, 첫구매=teal, 생일=pink) | +| issue_status | string | `issuing` | 발급상태: issuing(발급중), stopped(발급중단) | +| issue_status_label | string | `발급중` | `issue_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| issue_status_badge_color | string | `blue` | `issue_status` 배지 색상 (발급중=blue, 발급중단=orange) | +| total_quantity | null | `null` | 총 발급 수량 (NULL=무제한) | +| issued_count | integer | `0` | issued 개수 (집계) | +| per_user_limit | integer | `1` | 회원당 발급 제한 | +| issue_count_formatted | string | `0/무제한` | `issue_count` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| valid_type | string | `period` | 유효기간 유형: period(기간지정), days_from_issue(발급일로부터) | +| valid_days | null | `null` | 발급일로부터 N일 (valid_type=days_from_issue) | +| valid_from | null | `null` | 유효기간 시작 | +| valid_to | null | `null` | 유효기간 종료 | +| valid_period_formatted | string | `-` | `valid_period` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| issue_from | null | `null` | 발급기간 시작 | +| issue_to | null | `null` | 발급기간 종료 | +| issue_period_formatted | string | `상시발급` | `issue_period` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| is_combinable | boolean | `false` | combinable 여부 | +| target_scope | string | `all` | 적용 범위: all(전체), products(특정상품), categories(특정카테고리) | +| target_scope_label | string | `전체상품` | `target_scope` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| is_issuable | boolean | `true` | issuable 여부 | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | +| created_by | null | `null` | 쿠폰 등록자(관리자) 식별자 (users 참조, 삭제 시 null) | +| created_by_name | string | `-` | 등록자 이름 (creator 미로드/미설정 시 `-`) | +| created_by_email | null | `null` | 등록자 이메일 (creator 관계 파생) | +| creator | null | `null` | 생성자 정보 객체 (uuid/name/email — creator 관계 파생) | +| included_products | array | `[]` | 적용 대상 포함 상품 목록 (target_scope=products 시 이 상품에만 적용) | +| excluded_products | array | `[]` | 적용 제외 상품 목록 (해당 상품은 쿠폰 적용에서 제외) | +| included_categories | array | `[]` | 적용 대상 포함 카테고리 목록 (target_scope=categories 시 이 카테고리에만 적용) | +| excluded_categories | array | `[]` | 적용 제외 카테고리 목록 (해당 카테고리는 쿠폰 적용에서 제외) | +| issues_count | integer | `0` | issues 개수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "쿠폰 정보를 조회했습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 쿠폰", + "en": "API Doc Sample Coupon" + }, + "localized_name": "API 문서 샘플 쿠폰", + "description": null, + "localized_description": null, + "target_type": "order_amount", + "target_type_label": "주문금액", + "target_type_badge_color": "blue", + "discount_type": "fixed", + "discount_type_label": "정액할인", + "discount_value": 1000, + "discount_max_amount": null, + "min_order_amount": 0, + "benefit_formatted": "1,000원 할인", + "multi_currency_discount_value": { + "KRW": { + "price": 1000, + "formatted": "1,000원", + "is_default": true, + "editable": true + }, + "USD": { + "price": 0.85, + "formatted": "$0.85", + "is_default": false, + "editable": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 115, + "formatted": "¥115", + "is_default": false, + "editable": false, + "exchange_rate": 115 + }, + "CNY": { + "price": 5.8, + "formatted": "元5.80", + "is_default": false, + "editable": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 0.78, + "formatted": "€0.78", + "is_default": false, + "editable": false, + "exchange_rate": 0.78 + } + }, + "multi_currency_min_order_amount": null, + "multi_currency_discount_max_amount": null, + "issue_method": "download", + "issue_method_label": "다운로드", + "issue_method_badge_color": "teal", + "issue_condition": "manual", + "issue_condition_label": "수동발급", + "issue_condition_badge_color": "orange", + "issue_status": "issuing", + "issue_status_label": "발급중", + "issue_status_badge_color": "blue", + "total_quantity": null, + "issued_count": 0, + "per_user_limit": 1, + "issue_count_formatted": "0/무제한", + "valid_type": "period", + "valid_days": null, + "valid_from": null, + "valid_to": null, + "valid_period_formatted": "-", + "issue_from": null, + "issue_to": null, + "issue_period_formatted": "상시발급", + "is_combinable": false, + "target_scope": "all", + "target_scope_label": "전체상품", + "is_issuable": true, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "created_by": null, + "created_by_name": "-", + "created_by_email": null, + "creator": null, + "included_products": [], + "excluded_products": [], + "included_categories": [], + "excluded_categories": [], + "issues_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.promotion-coupon.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 프로모션 쿠폰 1건의 상세 정보를 조회합니다. `permission:sirsoft-ecommerce.promotion-coupon.read` 권한이 필요하며, `CouponService::getCoupon()`이 쿠폰 정의와 함께 적용 범위(included/excluded products·categories)까지 로드해 `CouponResource`로 반환합니다. 목록(index)보다 상세한 필드(적용 대상 상품/카테고리 목록 등)를 포함하며, 해당 쿠폰이 없으면 404 를 반환합니다. + + +### PUT /api/modules/sirsoft-ecommerce/admin/promotion-coupons/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.promotion-coupons.update` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CouponController@update` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.promotion-coupon.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| name | body | array | 예 | — | 대상의 이름/명칭 | +| description | body | array | 아니오 | — | 설명 | +| target_type | body | string | 예 | `product_amount`, `order_amount`, `shipping_fee` | 적용대상(할인 기준): 상품금액/주문금액/배송비 | +| discount_type | body | string | 예 | `fixed`, `rate` | 혜택유형: fixed(정액 금액), rate(정률 %) | +| discount_value | body | number | 예 | min 1 | 혜택값 (정액이면 할인 금액, 정률이면 1~100 할인율 %) | +| discount_max_amount | body | number | 아니오 | min 0 | 최대 할인액 (정률 할인 시 상한 금액) | +| min_order_amount | body | number | 아니오 | min 0 | 쿠폰 적용 최소 주문금액 (미입력 시 0=제한 없음) | +| issue_method | body | string | 예 | `direct`, `download`, `auto` | 발급방법: direct(직접발급), download(다운로드), auto(자동발급) | +| issue_condition | body | string | 예 | `manual`, `signup`, `first_purchase`, `birthday` | 발급조건: manual(수동), signup(회원가입), first_purchase(첫구매), birthday(생일) | +| issue_status | body | string | 예 | `issuing`, `stopped` | 발급상태: issuing(발급중), stopped(발급중단) | +| total_quantity | body | integer | 아니오 | min 1 | 총 발급 수량 (미입력 시 무제한) | +| per_user_limit | body | integer | 예 | min 0 | 회원 1인당 발급 제한 수량 (0=무제한) | +| valid_type | body | string | 예 | `period`, `days_from_issue` | 유효기간 유형: period(기간 지정, valid_from/valid_to 필수), days_from_issue(발급일로부터 N일, valid_days 필수) | +| valid_days | body | integer | 아니오 | min 1 | 발급일로부터 유효 일수 (valid_type=days_from_issue 시 필수) | +| valid_from | body | date | 아니오 | — | 유효기간 시작일 (valid_type=period 시 필수) | +| valid_to | body | date | 아니오 | — | 유효기간 종료일 (valid_type=period 시 필수, valid_from 이후) | +| issue_from | body | date | 아니오 | — | 발급기간 시작 일시 (미입력 시 상시발급) | +| issue_to | body | date | 아니오 | — | 발급기간 종료 일시 (issue_from 이후) | +| is_combinable | body | boolean | 아니오 | — | combinable 여부 | +| target_scope | body | string | 아니오 | `all`, `products`, `categories` | 적용 범위: all(전체 상품), products(특정 상품), categories(특정 카테고리) | +| products | body | array | 아니오 | — | 적용 상품 목록 (`target_scope=products`), 항목별 `{id, type: include\|exclude}` | +| categories | body | array | 아니오 | — | 적용 카테고리 목록 (`target_scope=categories`), 항목별 `{id, type: include\|exclude}` | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.coupon.update_validation_rules`). + +**요청 예시** + +```http +PUT /api/modules/sirsoft-ecommerce/admin/promotion-coupons/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": [ + "예시 이름" + ], + "description": [ + "예시 내용입니다." + ], + "target_type": "product_amount", + "discount_type": "fixed", + "discount_value": 1, + "discount_max_amount": 1, + "min_order_amount": 1, + "issue_method": "direct", + "issue_condition": "manual", + "issue_status": "issuing", + "total_quantity": 1, + "per_user_limit": 1, + "valid_type": "period", + "valid_days": 1, + "valid_from": "2026-01-01", + "valid_to": "2026-01-01", + "issue_from": "2026-01-01", + "issue_to": "2026-01-01", + "is_combinable": true, + "target_scope": "all", + "products": [ + "예시값" + ], + "categories": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.promotion-coupon.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 기존 프로모션 쿠폰(정의)의 내용을 수정합니다. `permission:sirsoft-ecommerce.promotion-coupon.update` 권한이 필요하며, 생성과 동일한 필드 집합(쿠폰명·혜택·발급 조건·유효/발급 기간·적용 범위 등)을 받아 `CouponService::updateCoupon()`이 전체 갱신합니다. `target_scope` 변경 시 그에 맞는 상품/카테고리 배열을 함께 보내야 하며, 대상 쿠폰이 없으면 404, 갱신 실패 시 400 을 반환합니다. 확장은 `sirsoft-ecommerce.coupon.update_validation_rules` 필터로 검증 규칙을 추가할 수 있습니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/promotion-coupons/{id}/issue-direct + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.promotion-coupons.issue-direct` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CouponController@issueDirect` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.promotion-coupon.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| user_uuids | body | array | 예 | min 1 | 쿠폰을 직접 발급할 대상 회원 UUID 배열 (내부 회원 ID 로 해석 후 발급) | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/promotion-coupons/1/issue-direct HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "user_uuids": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.promotion-coupon.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 지정한 회원들에게 특정 쿠폰을 즉시 직접 발급합니다. `permission:sirsoft-ecommerce.promotion-coupon.update` 권한이 필요하고 대상 회원은 `user_uuids`(uuid 배열)로 지정하며, FormRequest 가 uuid 를 내부 회원 ID 로 해석한 뒤 `CouponService::issueDirectly()`가 발급합니다. 응답에는 실제 발급 건수(`issued`)와 이미 보유/한도 초과 등으로 건너뛴 목록(`skipped`)이 포함되고, 건너뛴 건이 있으면 별도 안내 메시지 키가 사용됩니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/promotion-coupons/{id}/issues + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.promotion-coupons.issues` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CouponController@issues` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.promotion-coupon.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| user_id | query | uuid | 아니오 | — | user 식별자 | +| status | query | string | 아니오 | `available`, `used`, `expired`, `cancelled` | 상태 필터 (해당 상태의 항목만 조회) | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.coupon.issues_list_validation_rules`). + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/promotion-coupons/1/issues?user_id=9f8b2c1a-4d3e-4a2b-8c1d-0e1f2a3b4c5d&status=available&per_page=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "쿠폰 발급 내역을 조회했습니다.", + "data": { + "data": [], + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 0, + "from": null, + "to": null, + "has_more_pages": false + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.promotion-coupon.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 특정 쿠폰의 회원별 발급 내역을 회원(`user_id`)·상태(available·used·expired·cancelled)로 필터링해 페이지네이션으로 조회합니다. `permission:sirsoft-ecommerce.promotion-coupon.read` 권한이 필요하며, `CouponService::getCouponIssues()`가 조회하고 `CouponIssueCollection`으로 직렬화합니다. 어떤 회원이 이 쿠폰을 받아 언제 사용/만료/취소했는지 추적하는 발급 원장 화면에 사용됩니다. + + +### DELETE /api/modules/sirsoft-ecommerce/admin/promotion-coupons/{id}/issues/{issueId} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.promotion-coupons.issues.cancel` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CouponController@cancelIssue` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.promotion-coupon.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| issueId | path | string | 예 | — | 대상 issue의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/admin/promotion-coupons/1/issues/{issueId} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.promotion-coupon.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 특정 쿠폰의 발급 내역 1건을 취소 처리합니다. 대상은 쿠폰 ID(`id`)와 발급 내역 ID(`issueId`) 조합으로 지정하고 `permission:sirsoft-ecommerce.promotion-coupon.update` 권한이 필요하며, `CouponService::cancelIssue()`가 미사용 발급 건만 취소합니다. 이미 사용된 발급 건 등 취소 불가 사유는 예외 메시지(`detail`)로 관리자에게 그대로 노출되어 400 으로 응답합니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/review-image.md b/modules/_bundled/sirsoft-ecommerce/docs/api/review-image.md new file mode 100644 index 00000000..2d66acb9 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/review-image.md @@ -0,0 +1,58 @@ +# Review Image API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Review Image 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-ecommerce/review-image/{hash} + +- **라우트명**: `api.modules.sirsoft-ecommerce.review-image.download` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\ReviewImageController@download` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| hash | path | string | 예 | — | 대상 리소스의 해시 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/review-image/{hash} HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 리뷰에 첨부된 이미지를 해시(12자) 기반으로 공개 서빙합니다. 인증이 필요 없으며, `ReviewImageController@download` 가 `ProductReviewImageService::download()` 로 해시에 해당하는 이미지를 찾아 스트림(`StreamedResponse`)으로 반환합니다. 해시에 해당하는 이미지가 없으면 404 를 반환합니다. `` 등에서 리뷰 이미지 원본을 표시할 때 사용하며, 실제 파일 경로를 노출하지 않고 해시로만 접근하게 합니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/reviews.md b/modules/_bundled/sirsoft-ecommerce/docs/api/reviews.md new file mode 100644 index 00000000..9b9f8036 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/reviews.md @@ -0,0 +1,997 @@ +# Reviews API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Reviews 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-ecommerce/admin/reviews + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.reviews.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductReviewController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.reviews.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| search_field | query | string | 아니오 | `all`, `product_name`, `reviewer`, `content`, `order_number`, `option_name` | 검색 대상 필드명 (검색어를 적용할 컬럼) | +| search_keyword | query | string | 아니오 | max 200 | 검색 키워드 (부분 일치) | +| rating | query | string | 아니오 | `1`, `2`, `3`, `4`, `5`, `` | 별점 필터 (해당 별점의 리뷰만 조회, 빈 값은 전체) | +| reply_status | query | string | 아니오 | `all`, `replied`, `unreplied` | 답변 상태 필터 (답변완료/미답변) | +| photo | query | string | 아니오 | `photo`, `normal`, `` | 포토 리뷰 필터 (이미지 첨부 여부, 빈 값은 전체) | +| has_photo | query | boolean | 아니오 | — | photo 여부 | +| status | query | string | 아니오 | — | 상태 필터 (해당 상태의 항목만 조회) | +| start_date | query | date | 아니오 | — | 조회 기간 시작일 (이 날짜 이후 데이터) | +| end_date | query | date | 아니오 | — | 조회 기간 종료일 (이 날짜 이전 데이터) | +| sort | query | string | 아니오 | `created_at_desc`, `created_at_asc`, `rating_desc`, `rating_asc` | 정렬 기준 (필드명, `-` 접두 시 내림차순) | +| sort_by | query | string | 아니오 | `created_at`, `rating`, `reply_status` | 정렬 기준 필드명 | +| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) | +| per_page | query | integer | 아니오 | min 10, max 100 | 페이지당 항목 수 | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.review.list_validation_rules`, `sirsoft-ecommerce.review.list_validation_messages`). + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/reviews?search_field=all&search_keyword=%EC%98%88%EC%8B%9C%EA%B0%92&rating=1&reply_status=all&photo=photo&has_photo=1&status=%EC%98%88%EC%8B%9C%EA%B0%92&start_date=2026-01-01&end_date=2026-01-01&sort=created_at_desc&sort_by=created_at&sort_order=asc&per_page=1&page=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `99` | 기본 키 (내부 식별자) | +| product_id | integer | `320` | product 식별자 (연관 리소스 참조) | +| order_option_id | integer | `859` | order option 식별자 (연관 리소스 참조) | +| user_id | string | `a231747f-e82e-4cf2-9ae1-a261849dce40` | user 식별자 (연관 리소스 참조) | +| user | object | `{"uuid":"a231747f-e82e-4cf2-9ae1-a261849dce40","name":"AP…` | 작성자 정보 (uuid·name·email, `user` 관계 로드 시) | +| product | object | `{"id":320,"name":"API 문서 샘플 상품","thumbnail_url":null}` | 리뷰 대상 상품 정보 (id·현지화 상품명·썸네일 URL) | +| option_snapshot | null | `null` | 주문 시점 옵션 스냅샷 (옵션명 보존용) | +| option_snapshot_label | string | `` | `option_snapshot` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| rating | integer | `5` | 별점 (1~5) | +| content | string | `Molestiae repellendus accusantium omn…` | 리뷰 내용 | +| content_mode | string | `text` | 콘텐츠 모드: text / html | +| status | string | `visible` | 리뷰 상태: visible(전시중) / hidden(숨김) | +| status_label | string | `전시중` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| status_badge_color | string | `blue` | 상태 뱃지 색상 (visible=blue / hidden=gray) | +| images | array | `[]` | 첨부 이미지 목록 (이미지 리소스 배열, `images` 관계 로드 시) | +| image_count | integer | `0` | image 개수 (집계) | +| orderOption | object | `{"id":859,"order_id":455,"order_number":"ORD-20260707-000…` | 리뷰가 연결된 주문 옵션 정보 (주문 ID·주문번호·수량·주문일) | +| has_reply | boolean | `false` | reply 여부 | +| has_reply_label | string | `미답변` | `has_reply` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| has_reply_badge_color | string | `gray` | 답변 여부 뱃지 색상 (답변완료=green / 미답변=gray) | +| reply_content | null | `null` | 판매자 답변 내용 (없으면 null) | +| reply_content_mode | string | `text` | 답변 콘텐츠 모드: text / html | +| reply_admin_uuid | null | `null` | 답변 작성 관리자 UUID (`replyAdmin` 관계 로드 시) | +| reply_admin | null | `null` | 답변 작성 관리자 정보 (uuid·name·email, `replyAdmin` 관계 로드 시) | +| replied_at | null | `null` | replied 일시 | +| reply_updated_at | null | `null` | reply updated 일시 | +| created_at | string | `2026-07-07 14:47:31` | 생성 일시 | +| updated_at | string | `2026-07-07 14:47:31` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "리뷰 목록을 조회했습니다.", + "data": { + "data": [ + { + "id": 1, + "product_id": 1, + "order_option_id": 1, + "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "user": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com" + }, + "product": { + "id": 1, + "name": "API 문서 샘플 상품", + "thumbnail_url": null + }, + "option_snapshot": null, + "option_snapshot_label": "", + "rating": 5, + "content": "Alias quas iusto dolorem eum eveniet ad omnis. Id neque consequatur fuga ut. Enim cum mollitia nisi. Adipisci sunt tenetur et tempora tempora eius rerum.", + "content_mode": "text", + "status": "visible", + "status_label": "전시중", + "status_badge_color": "blue", + "images": [], + "image_count": 0, + "orderOption": { + "id": 1, + "order_id": 5, + "order_number": "ORD-20260708-000002", + "quantity": 2, + "created_at": "2026-07-08 10:44:49" + }, + "has_reply": false, + "has_reply_label": "미답변", + "has_reply_badge_color": "gray", + "reply_content": null, + "reply_content_mode": "text", + "reply_admin_uuid": null, + "reply_admin": null, + "replied_at": null, + "reply_updated_at": null, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_update": true, + "can_delete": true + } + } + ], + "links": { + "first": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/reviews?page=1", + "last": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/reviews?page=1", + "prev": null, + "next": null + }, + "meta": { + "current_page": 1, + "from": 1, + "last_page": 1, + "links": [ + { + "url": null, + "label": "pagination.previous", + "page": null, + "active": false + }, + { + "url": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/reviews?page=1", + "label": "1", + "page": 1, + "active": true + }, + { + "url": null, + "label": "pagination.next", + "page": null, + "active": false + } + ], + "path": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/reviews", + "per_page": 25, + "to": 1, + "total": 1 + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.reviews.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 전체 상품 리뷰를 페이지네이션으로 조회합니다. `sirsoft-ecommerce.reviews.read` 권한이 필요하며, `ProductReviewService::getAdminList()`가 검색어·별점·답변 여부·포토 여부·상태·기간 등 필터와 정렬을 적용해 목록을 반환합니다. 각 항목에는 작성자·상품·주문옵션·이미지·답변 정보가 함께 로드되고, `abilities` 로 현재 관리자의 수정/삭제 가능 여부가 내려옵니다. 리뷰 관리 화면의 목록 표를 채우는 데 사용합니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/reviews/bulk + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.reviews.bulk` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductReviewController@bulk` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.reviews.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| ids | body | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | +| action | body | string | 예 | `delete`, `change_status` | 일괄 작업 종류 (delete=삭제, change_status=상태 변경) | +| status | body | string | 아니오 | — | 변경할 리뷰 상태 (visible/hidden, `action=change_status` 시 필수) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.review.bulk_validation_rules`, `sirsoft-ecommerce.review.bulk_validation_messages`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/reviews/bulk HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "ids": [ + "예시값" + ], + "action": "delete", + "status": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.reviews.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 선택한 여러 리뷰를 한 번에 일괄 처리합니다. `sirsoft-ecommerce.reviews.update` 권한이 필요하며, `action` 이 `delete` 이면 `ProductReviewService::bulkDelete()` 로 삭제하고 `deleted_count` 를, `change_status` 이면 `bulkUpdateStatus()` 로 `status` 값으로 상태를 변경하고 `updated_count` 를 반환합니다. `change_status` 를 선택했다면 `status` 값이 반드시 필요합니다. 목록 화면에서 체크박스로 다건 선택 후 삭제/전시상태 변경 시 사용합니다. + + +### DELETE /api/modules/sirsoft-ecommerce/admin/reviews/{review} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.reviews.destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductReviewController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.reviews.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| review | path | string | 예 | — | 대상 review의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/admin/reviews/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted | boolean | `true` | 삭제 처리 성공 여부 (true 이면 리뷰와 첨부 이미지가 제거됨) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "리뷰가 삭제되었습니다.", + "data": { + "deleted": true + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.reviews.delete`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 리뷰 1건을 삭제합니다. `sirsoft-ecommerce.reviews.delete` 권한이 필요하며, 삭제 전 `images` 관계를 로드한 뒤 `ProductReviewService::deleteReview()` 가 첨부 이미지 파일까지 함께 정리하며 리뷰를 제거합니다. 라우트 모델 바인딩으로 존재하지 않는 리뷰는 404 를 반환합니다. 부적절한 리뷰를 관리자 화면에서 개별 삭제할 때 사용합니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/reviews/{review} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.reviews.show` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductReviewController@show` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.reviews.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| review | path | string | 예 | — | 대상 review의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/reviews/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `99` | 기본 키 (내부 식별자) | +| product_id | integer | `320` | 상품 ID | +| order_option_id | integer | `859` | 주문 옵션 ID | +| user_id | string | `a231747f-e82e-4cf2-9ae1-a261849dce40` | 작성자 ID | +| user | object | `{"uuid":"a231747f-e82e-4cf2-9ae1-a261849dce40","name":"AP…` | 작성자 정보 (uuid·name·email, `user` 관계 로드 시) | +| product | object | `{"id":320,"name":"API 문서 샘플 상품","thumbnail_url":null}` | 리뷰 대상 상품 정보 (id·현지화 상품명·썸네일 URL) | +| option_snapshot | null | `null` | 주문 시점 옵션 스냅샷 (옵션명 보존용) | +| option_snapshot_label | string | `` | `option_snapshot` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| rating | integer | `5` | 별점 (1~5) | +| content | string | `Molestiae repellendus accusantium omn…` | 리뷰 내용 | +| content_mode | string | `text` | 콘텐츠 모드: text / html | +| status | string | `visible` | 리뷰 상태: visible / hidden | +| status_label | string | `전시중` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| status_badge_color | string | `blue` | 상태 뱃지 색상 (visible=blue / hidden=gray) | +| images | array | `[]` | 첨부 이미지 목록 (이미지 리소스 배열, `images` 관계 로드 시) | +| image_count | integer | `0` | image 개수 (집계) | +| orderOption | object | `{"id":859,"order_id":455,"order_number":"ORD-20260707-000…` | 리뷰가 연결된 주문 옵션 정보 (주문 ID·주문번호·수량·주문일) | +| has_reply | boolean | `false` | reply 여부 | +| has_reply_label | string | `미답변` | `has_reply` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| has_reply_badge_color | string | `gray` | 답변 여부 뱃지 색상 (답변완료=green / 미답변=gray) | +| reply_content | null | `null` | 판매자 답변 내용 | +| reply_content_mode | string | `text` | 답변 콘텐츠 모드: text / html | +| reply_admin_uuid | null | `null` | 답변 작성 관리자 UUID (`replyAdmin` 관계 로드 시) | +| reply_admin | null | `null` | 답변 작성 관리자 정보 (uuid·name·email, `replyAdmin` 관계 로드 시) | +| replied_at | null | `null` | replied 일시 | +| reply_updated_at | null | `null` | reply updated 일시 | +| created_at | string | `2026-07-07 14:47:31` | 생성 일시 | +| updated_at | string | `2026-07-07 14:47:31` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "리뷰 목록을 조회했습니다.", + "data": { + "id": 1, + "product_id": 1, + "order_option_id": 1, + "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "user": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com" + }, + "product": { + "id": 1, + "name": "API 문서 샘플 상품", + "thumbnail_url": null + }, + "option_snapshot": null, + "option_snapshot_label": "", + "rating": 5, + "content": "Alias quas iusto dolorem eum eveniet ad omnis. Id neque consequatur fuga ut. Enim cum mollitia nisi. Adipisci sunt tenetur et tempora tempora eius rerum.", + "content_mode": "text", + "status": "visible", + "status_label": "전시중", + "status_badge_color": "blue", + "images": [], + "image_count": 0, + "orderOption": { + "id": 1, + "order_id": 5, + "order_number": "ORD-20260708-000002", + "quantity": 2, + "created_at": "2026-07-08 10:44:49" + }, + "has_reply": false, + "has_reply_label": "미답변", + "has_reply_badge_color": "gray", + "reply_content": null, + "reply_content_mode": "text", + "reply_admin_uuid": null, + "reply_admin": null, + "replied_at": null, + "reply_updated_at": null, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.reviews.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 리뷰 1건의 상세 정보를 조회합니다. `sirsoft-ecommerce.reviews.read` 권한이 필요하며, 컨트롤러가 `user`·`product`·`images`·`replyAdmin`·`orderOption.order` 관계를 함께 로드해 작성자·상품·이미지·판매자 답변·주문 정보까지 포함한 단건 리소스를 반환합니다. 관리자 리뷰 상세/답변 작성 화면 진입 시 사용합니다. + + +### DELETE /api/modules/sirsoft-ecommerce/admin/reviews/{review}/reply + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.reviews.reply.destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductReviewController@destroyReply` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.reviews.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| review | path | string | 예 | — | 대상 review의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/admin/reviews/1/reply HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| product_id | integer | `1` | product 식별자 (연관 리소스 참조) | +| order_option_id | integer | `1` | order option 식별자 (연관 리소스 참조) | +| user_id | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | user 식별자 (연관 리소스 참조) | +| user | object | `{"uuid":"a234c2b1-cde8-437f-b28b-23323be2b98d","name":"AP…` | 대상 사용자 정보 객체 (uuid/name/email 등 — user 관계 파생) | +| option_snapshot | null | `null` | 주문 시점 옵션 스냅샷 (옵션명 보존용) | +| option_snapshot_label | string | `` | `option_snapshot` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| rating | integer | `5` | 별점 (1~5) | +| content | string | `Alias quas iusto dolorem eum eveniet …` | 본문 내용 | +| content_mode | string | `text` | 콘텐츠 모드: text / html | +| status | string | `visible` | 상태 값 (도메인별 상태 집합 — 사람이 읽는 라벨은 status_label, UI 변형은 status_variant 참조) | +| status_label | string | `전시중` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| status_badge_color | string | `blue` | 상태 뱃지 색상 (visible=blue / hidden=gray) | +| has_reply | boolean | `false` | reply 여부 | +| has_reply_label | string | `미답변` | `has_reply` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| has_reply_badge_color | string | `gray` | 답변 여부 뱃지 색상 (답변완료=green / 미답변=gray) | +| reply_content | null | `null` | 판매자 답변 내용 | +| reply_content_mode | string | `text` | 답변 콘텐츠 모드: text / html | +| replied_at | null | `null` | replied 일시 | +| reply_updated_at | null | `null` | reply updated 일시 | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "답변이 삭제되었습니다.", + "data": { + "id": 1, + "product_id": 1, + "order_option_id": 1, + "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "user": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com" + }, + "option_snapshot": null, + "option_snapshot_label": "", + "rating": 5, + "content": "Alias quas iusto dolorem eum eveniet ad omnis. Id neque consequatur fuga ut. Enim cum mollitia nisi. Adipisci sunt tenetur et tempora tempora eius rerum.", + "content_mode": "text", + "status": "visible", + "status_label": "전시중", + "status_badge_color": "blue", + "has_reply": false, + "has_reply_label": "미답변", + "has_reply_badge_color": "gray", + "reply_content": null, + "reply_content_mode": "text", + "replied_at": null, + "reply_updated_at": null, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.reviews.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 리뷰에 등록한 판매자 답변을 삭제합니다. `sirsoft-ecommerce.reviews.update` 권한이 필요하며, `ProductReviewService::deleteReply()` 가 답변 내용·작성자·작성 일시를 비우고 답변이 제거된 리뷰 리소스를 반환합니다. 잘못 작성한 답변을 회수할 때 사용하며, 리뷰 자체는 유지됩니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/reviews/{review}/reply + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.reviews.reply.store` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductReviewController@storeReply` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.reviews.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| review | path | string | 예 | — | 대상 review의 식별자 | +| reply_content | body | string | 예 | min 1, max 2000 | 판매자 답변 내용 (1~2000자) | +| reply_content_mode | body | string | 아니오 | `text`, `html` | 답변 콘텐츠 모드 (평문/HTML, 미지정 시 text) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.review.store_reply_validation_rules`, `sirsoft-ecommerce.review.store_reply_validation_messages`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/reviews/1/reply HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "reply_content": "예시 내용입니다.", + "reply_content_mode": "text" +} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| product_id | integer | `1` | product 식별자 (연관 리소스 참조) | +| order_option_id | integer | `1` | order option 식별자 (연관 리소스 참조) | +| user_id | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | user 식별자 (연관 리소스 참조) | +| user | object | `{"uuid":"a234c2b1-cde8-437f-b28b-23323be2b98d","name":"AP…` | 대상 사용자 정보 객체 (uuid/name/email 등 — user 관계 파생) | +| option_snapshot | null | `null` | 주문 시점 옵션 스냅샷 (옵션명 보존용) | +| option_snapshot_label | string | `` | `option_snapshot` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| rating | integer | `5` | 별점 (1~5) | +| content | string | `Alias quas iusto dolorem eum eveniet …` | 본문 내용 | +| content_mode | string | `text` | 콘텐츠 모드: text / html | +| status | string | `visible` | 상태 값 (도메인별 상태 집합 — 사람이 읽는 라벨은 status_label, UI 변형은 status_variant 참조) | +| status_label | string | `전시중` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| status_badge_color | string | `blue` | 상태 뱃지 색상 (visible=blue / hidden=gray) | +| has_reply | boolean | `true` | reply 여부 | +| has_reply_label | string | `답변완료` | `has_reply` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| has_reply_badge_color | string | `green` | 답변 여부 뱃지 색상 (답변완료=green / 미답변=gray) | +| reply_content | string | `실측 예시값` | 판매자 답변 내용 | +| reply_content_mode | string | `text` | 답변 콘텐츠 모드: text / html | +| replied_at | string | `2026-07-08 15:00:32` | replied 일시 | +| reply_updated_at | null | `null` | reply updated 일시 | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 15:00:32` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "답변이 저장되었습니다.", + "data": { + "id": 1, + "product_id": 1, + "order_option_id": 1, + "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "user": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com" + }, + "option_snapshot": null, + "option_snapshot_label": "", + "rating": 5, + "content": "Alias quas iusto dolorem eum eveniet ad omnis. Id neque consequatur fuga ut. Enim cum mollitia nisi. Adipisci sunt tenetur et tempora tempora eius rerum.", + "content_mode": "text", + "status": "visible", + "status_label": "전시중", + "status_badge_color": "blue", + "has_reply": true, + "has_reply_label": "답변완료", + "has_reply_badge_color": "green", + "reply_content": "실측 예시값", + "reply_content_mode": "text", + "replied_at": "2026-07-08 15:00:32", + "reply_updated_at": null, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:00:32", + "abilities": { + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.reviews.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 리뷰에 판매자 답변을 등록하거나 기존 답변을 수정합니다. `sirsoft-ecommerce.reviews.update` 권한이 필요하며, `ProductReviewService::saveReply()` 가 로그인 관리자 UUID(`Auth::id()`)를 답변 작성자로 기록하고 `reply_content`(1~2000자)와 `reply_content_mode`(text/html)를 저장합니다. 답변이 이미 있으면 갱신되고 작성 일시가 채워집니다. 고객 리뷰에 판매자가 응대할 때 사용합니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/reviews/{review}/status + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.reviews.update-status` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductReviewController@updateStatus` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.reviews.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| review | path | string | 예 | — | 대상 review의 식별자 | +| status | body | string | 예 | — | 변경할 리뷰 전시 상태 (visible=전시중 / hidden=숨김) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.review.update_status_validation_rules`, `sirsoft-ecommerce.review.update_status_validation_messages`). + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/reviews/1/status HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "status": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.reviews.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 리뷰의 전시 상태를 변경합니다. `sirsoft-ecommerce.reviews.update` 권한이 필요하며, `ProductReviewService::updateStatus()` 가 `status` 값(예: visible/hidden)으로 리뷰를 전시하거나 숨기고 갱신된 리뷰 리소스를 반환합니다. 신고되었거나 부적절한 리뷰를 노출에서 제외하거나 다시 노출할 때 사용합니다. + + +### POST /api/modules/sirsoft-ecommerce/user/reviews + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.reviews.store` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\ProductReviewController@store` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.user-reviews.write` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| product_id | body | integer | 예 | — | product 식별자 | +| order_option_id | body | integer | 예 | — | order option 식별자 | +| rating | body | integer | 예 | min 1, max 5 | 별점 (1~5) | +| content | body | string | 예 | min 10, max 2000 | 리뷰 내용 (10~2000자) | +| content_mode | body | string | 아니오 | `text`, `html` | 콘텐츠 모드 (평문/HTML, 미지정 시 text) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.review.store_validation_rules`, `sirsoft-ecommerce.review.store_validation_messages`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/user/reviews HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "product_id": 1, + "order_option_id": 1, + "rating": 1, + "content": "예시 내용입니다.", + "content_mode": "text" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-reviews.write`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 로그인 회원이 구매한 상품에 리뷰를 작성합니다. `sirsoft-ecommerce.user-reviews.write` 권한이 필요하며, `ProductReviewService::createReview()` 가 로그인 사용자(`Auth::id()`)를 작성자로 하여 `product_id`·`order_option_id`·별점(1~5)·내용(10~2000자)으로 리뷰를 생성하고 201 로 반환합니다. 본인 주문이 아니거나 이미 작성했거나 작성 조건을 만족하지 못하면 서비스가 `RuntimeException` 을 던져 422 로 응답합니다. 마이페이지 리뷰 작성 폼에서 사용합니다. + + +### GET /api/modules/sirsoft-ecommerce/user/reviews/can-write/{orderOptionId} + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.reviews.can-write` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\ProductReviewController@canWrite` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| orderOptionId | path | string | 예 | — | 대상 order option의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/user/reviews/can-write/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| can_write | boolean | `false` | write 수행 가능 여부 (권한 기반) | +| reason | string | `not_own_order` | 리뷰 작성 불가 사유 코드 (`can_write` 가 false 일 때만 값이 있고 가능하면 null. order_option_not_found 주문옵션 없음, not_own_order 본인 주문 아님, not_confirmed 구매확정 전, deadline_passed 작성 기한 초과, already_written 이미 작성함) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "리뷰 작성 가능 여부를 확인했습니다.", + "data": { + "can_write": false, + "reason": "not_own_order" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 로그인 회원이 특정 주문 옵션에 대해 리뷰를 쓸 수 있는지 확인합니다. `auth:sanctum` 인증만 요구하며, `ProductReviewService::canWrite()` 가 본인 주문 여부·구매 완료·중복 작성 여부 등을 판정해 `can_write` 불리언과 불가 시 `reason`(예: `not_own_order`)을 반환합니다. 리뷰 작성 버튼 노출 여부를 결정하기 위해 상품/주문 화면에서 사전 호출합니다. + + +### DELETE /api/modules/sirsoft-ecommerce/user/reviews/{review} + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.reviews.destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\ProductReviewController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.user-reviews.write` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| review | path | string | 예 | — | 대상 review의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/user/reviews/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted | boolean | `true` | 삭제 처리 성공 여부 (true 이면 리뷰와 첨부 이미지가 제거됨) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "리뷰가 삭제되었습니다.", + "data": { + "deleted": true + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-reviews.write`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 로그인 회원이 본인이 작성한 리뷰를 삭제합니다. `sirsoft-ecommerce.user-reviews.write` 권한이 필요하며, 컨트롤러가 `review->user_id` 와 로그인 사용자를 대조해 본인 소유가 아니면 403 을 반환합니다. 본인 리뷰이면 `images` 관계를 로드한 뒤 `ProductReviewService::deleteReview()` 가 첨부 이미지까지 함께 삭제합니다. 마이페이지에서 자신의 리뷰를 지울 때 사용합니다. + + +### POST /api/modules/sirsoft-ecommerce/user/reviews/{review}/images + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.reviews.images.store` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\ReviewImageController@store` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.user-reviews.write` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| review | path | string | 예 | — | 대상 review의 식별자 | +| image | body | file | 예 | max 10240 | 첨부할 이미지 파일 (최대 용량은 리뷰 설정 `review_settings.max_image_size_mb` 기반, 폴백 10MB) | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/user/reviews/1/images HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: multipart/form-data; boundary=----G7ExampleBoundary + +------G7ExampleBoundary +Content-Disposition: form-data; name="image"; filename="example.pdf" +Content-Type: application/octet-stream + +(바이너리 파일 내용) +------G7ExampleBoundary-- +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-reviews.write`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 로그인 회원이 자신의 리뷰에 이미지를 첨부합니다. `sirsoft-ecommerce.user-reviews.write` 권한이 필요하며, 컨트롤러가 `review->user_id` 로 본인 소유를 확인(불일치 시 403)한 뒤 `ProductReviewImageService::upload()` 가 업로드된 이미지(최대 10MB)를 저장하고 201 로 이미지 리소스를 반환합니다. 파일 형식/크기 등 제약 위반 시 서비스가 `RuntimeException` 을 던져 422 로 응답합니다. 포토 리뷰 작성 시 이미지를 추가할 때 사용합니다. + + +### DELETE /api/modules/sirsoft-ecommerce/user/reviews/{review}/images/{image} + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.reviews.images.destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\ReviewImageController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.user-reviews.write` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| review | path | string | 예 | — | 대상 review의 식별자 | +| image | path | string | 예 | — | 대상 image의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/user/reviews/1/images/{image} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-reviews.write`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 로그인 회원이 자신의 리뷰에서 첨부 이미지 1건을 삭제합니다. `sirsoft-ecommerce.user-reviews.write` 권한이 필요하며, 컨트롤러가 `review->user_id` 로 본인 소유를 확인(불일치 시 403)하고 `image->review_id` 가 해당 리뷰에 속하는지 대조(불일치 시 404)한 뒤 `ProductReviewImageService::delete()` 로 파일과 레코드를 함께 제거합니다. 포토 리뷰에서 잘못 올린 이미지를 개별 삭제할 때 사용합니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md b/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md new file mode 100644 index 00000000..2d8668c0 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md @@ -0,0 +1,3572 @@ +# Settings API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Settings 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-ecommerce/admin/settings + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.settings.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\EcommerceSettingsController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/settings HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| basic_info | object | `{"shop_name":"","route_path":"shop","no_route":false,"com…` | 쇼핑몰 기본 정보 (쇼핑몰명·라우트 경로·상호·사업자번호·주소·연락처·이메일 등) | +| language_currency | object | `{"default_currency":"KRW","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙) | +| order_settings | object | `{"default_pg_provider":null,"payment_methods":[{"id":"car…` | 주문/결제 설정 (기본 PG·병합된 결제수단·은행/무통장 계좌·자동취소·장바구니 만료 등) | +| shipping | object | `{"default_country":"KR","available_countries":[{"code":"K…` | 배송 설정 (기본 국가·배송 가능 국가·무료배송·DB 관리 배송사(carriers)·배송유형(types)·계산 API 후보 필드 포함) | +| seo | object | `{"meta_category_title":"{commerce_name} - {category_name}…` | SEO 메타 설정 (카테고리·검색·상품·쇼핑몰 인덱스별 메타 타이틀/설명 및 SEO 활성 토글) | +| review_settings | object | `{"write_deadline_days":90,"max_images":5,"max_image_size_…` | 리뷰 정책 (작성 기한일·이미지 최대 개수·이미지 최대 용량 MB) | +| inquiry | object | `{"board_slug":null}` | 문의 연동 설정 (문의 게시판 slug) | +| notifications | object | `{"channels":[{"id":"mail","is_active":true,"sort_order":1…` | 알림 채널 설정 (채널 ID·활성 여부·정렬 순서) | +| mileage | object | `{"enabled":false,"default_earn_rate":1,"earn_trigger":"co…` | 마일리지 설정 (사용 여부·기본 적립률·적립 트리거·통화별 규칙·소멸/소멸 알림·실제 활성 알림 채널 포함) | +| claim | object | `{"refund_reasons":[{"id":1,"type":"refund","code":"order_…` | 클레임 설정 (DB 관리 대상인 환불 사유 목록: 코드·다국어명·귀책 유형·노출/활성 여부) | +| available_pg_providers | array | `[{"id":"kginicis","name_key":"sirsoft-pay_kginicis::provi…` | 설치된 PG 플러그인이 훅으로 등록한 PG 제공자 목록 (id·name_key·지원 결제수단) | +| abilities | object | `{"can_update":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.settings.fetch_success", + "data": { + "basic_info": { + "shop_name": "", + "route_path": "shop", + "no_route": false, + "company_name": "", + "business_number": "", + "ceo_name": "", + "business_type": "", + "business_category": "", + "zipcode": "", + "base_address": "", + "detail_address": "", + "phone": "", + "fax": "", + "email": "", + "privacy_officer": "", + "privacy_officer_email": "", + "mail_order_number": "", + "telecom_number": "" + }, + "language_currency": { + "default_currency": "KRW", + "currencies": [ + { + "code": "KRW", + "name": { + "ko": "KRW (원)", + "en": "KRW (Won)", + "fr": "KRW (원)" + }, + "symbol": "₩", + "exchange_rate": null, + "base_unit": 1000, + "rounding_unit": "1", + "rounding_method": "floor", + "decimal_places": 0, + "is_default": true, + "locales": [ + "ko" + ], + "flag": "🇰🇷" + }, + { + "code": "USD", + "name": { + "ko": "USD (달러)", + "en": "USD (Dollar)", + "fr": "USD (달러)" + }, + "symbol": "$", + "exchange_rate": 0.85, + "base_unit": 1, + "rounding_unit": "0.01", + "rounding_method": "round", + "decimal_places": 2, + "is_default": false, + "locales": [ + "en" + ], + "flag": "🇺🇸" + }, + { + "code": "JPY", + "name": { + "ko": "JPY (엔)", + "en": "JPY (Yen)", + "fr": "JPY (엔)" + }, + "symbol": "¥", + "exchange_rate": 115, + "base_unit": 100, + "rounding_unit": "1", + "rounding_method": "floor", + "decimal_places": 0, + "is_default": false, + "locales": [ + "en" + ], + "flag": "🇯🇵" + }, + { + "code": "CNY", + "name": { + "ko": "CNY (위안)", + "en": "CNY (Yuan)", + "fr": "CNY (위안)" + }, + "symbol": "元", + "exchange_rate": 5.8, + "base_unit": 1, + "rounding_unit": "0.01", + "rounding_method": "round", + "decimal_places": 2, + "is_default": false, + "locales": [ + "en" + ], + "flag": "🇨🇳" + }, + { + "code": "EUR", + "name": { + "ko": "EUR (유로)", + "en": "EUR (Euro)", + "fr": "EUR (유로)" + }, + "symbol": "€", + "exchange_rate": 0.78, + "base_unit": 1, + "rounding_unit": "0.01", + "rounding_method": "round", + "decimal_places": 2, + "is_default": false, + "locales": [ + "en" + ], + "flag": "🇪🇺" + } + ] + }, + "order_settings": { + "default_pg_provider": null, + "payment_methods": [ + { + "id": "card", + "pg_provider": null, + "sort_order": 1, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "신용카드", + "en": "Credit Card", + "fr": "신용카드" + }, + "_cached_description": { + "ko": "신용카드로 안전하게 결제", + "en": "Pay securely with credit card", + "fr": "신용카드로 안전하게 결제" + }, + "_cached_icon": "credit-card", + "_cached_source": "builtin" + }, + { + "id": "vbank", + "pg_provider": null, + "sort_order": 2, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "mileage_deduction_timing": "order_placed", + "_cached_name": { + "ko": "가상계좌", + "en": "Virtual Account", + "fr": "가상계좌" + }, + "_cached_description": { + "ko": "가상계좌로 입금", + "en": "Pay via virtual account", + "fr": "가상계좌로 입금" + }, + "_cached_icon": "money-check", + "_cached_source": "builtin" + }, + { + "id": "dbank", + "pg_provider": null, + "sort_order": 3, + "is_active": true, + "min_order_amount": 0, + "stock_deduction_timing": "order_placed", + "mileage_deduction_timing": "order_placed", + "_cached_name": { + "ko": "무통장입금", + "en": "Bank Transfer", + "fr": "무통장입금" + }, + "_cached_description": { + "ko": "지정 계좌로 직접 입금", + "en": "Direct bank transfer", + "fr": "지정 계좌로 직접 입금" + }, + "_cached_icon": "building-columns", + "_cached_source": "builtin" + }, + { + "id": "bank", + "pg_provider": null, + "sort_order": 4, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "계좌이체", + "en": "Account Transfer", + "fr": "계좌이체" + }, + "_cached_description": { + "ko": "실시간 계좌이체", + "en": "Real-time bank transfer", + "fr": "실시간 계좌이체" + }, + "_cached_icon": "building-columns", + "_cached_source": "builtin" + }, + { + "id": "phone", + "pg_provider": null, + "sort_order": 5, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "휴대폰결제", + "en": "Mobile Payment", + "fr": "휴대폰결제" + }, + "_cached_description": { + "ko": "휴대폰 소액결제", + "en": "Mobile phone payment", + "fr": "휴대폰 소액결제" + }, + "_cached_icon": "mobile-screen-button", + "_cached_source": "builtin" + }, + { + "id": "point", + "pg_provider": null, + "sort_order": 6, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "order_placed", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "포인트결제", + "en": "Points", + "fr": "포인트결제" + }, + "_cached_description": { + "ko": "적립 포인트로 결제", + "en": "Pay with points", + "fr": "적립 포인트로 결제" + }, + "_cached_icon": "coins", + "_cached_source": "builtin" + }, + { + "id": "deposit", + "pg_provider": null, + "sort_order": 7, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "order_placed", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "예치금결제", + "en": "Store Credit", + "fr": "예치금결제" + }, + "_cached_description": { + "ko": "예치금으로 결제", + "en": "Pay with store credit", + "fr": "예치금으로 결제" + }, + "_cached_icon": "wallet", + "_cached_source": "builtin" + }, + { + "id": "free", + "pg_provider": null, + "sort_order": 8, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "order_placed", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "무료", + "en": "Free", + "fr": "무료" + }, + "_cached_description": { + "ko": "결제 없이 주문 완료", + "en": "Order without payment", + "fr": "결제 없이 주문 완료" + }, + "_cached_icon": "gift", + "_cached_source": "builtin" + } + ], + "banks": [ + { + "code": "004", + "name": { + "ko": "국민은행", + "en": "Kookmin Bank" + } + }, + { + "code": "088", + "name": { + "ko": "신한은행", + "en": "Shinhan Bank" + } + }, + { + "code": "020", + "name": { + "ko": "우리은행", + "en": "Woori Bank" + } + }, + { + "code": "081", + "name": { + "ko": "하나은행", + "en": "Hana Bank" + } + }, + { + "code": "003", + "name": { + "ko": "IBK기업은행", + "en": "IBK Industrial Bank" + } + }, + { + "code": "011", + "name": { + "ko": "NH농협은행", + "en": "NH Nonghyup Bank" + } + }, + { + "code": "071", + "name": { + "ko": "우체국", + "en": "Korea Post" + } + }, + { + "code": "031", + "name": { + "ko": "DGB대구은행", + "en": "DGB Daegu Bank" + } + }, + { + "code": "032", + "name": { + "ko": "BNK부산은행", + "en": "BNK Busan Bank" + } + }, + { + "code": "034", + "name": { + "ko": "광주은행", + "en": "Kwangju Bank" + } + }, + { + "code": "035", + "name": { + "ko": "제주은행", + "en": "Jeju Bank" + } + }, + { + "code": "037", + "name": { + "ko": "전북은행", + "en": "Jeonbuk Bank" + } + }, + { + "code": "039", + "name": { + "ko": "BNK경남은행", + "en": "BNK Kyongnam Bank" + } + }, + { + "code": "045", + "name": { + "ko": "새마을금고", + "en": "MG Community Credit Cooperatives" + } + }, + { + "code": "048", + "name": { + "ko": "신협", + "en": "KFCC" + } + }, + { + "code": "090", + "name": { + "ko": "카카오뱅크", + "en": "Kakao Bank" + } + }, + { + "code": "092", + "name": { + "ko": "토스뱅크", + "en": "Toss Bank" + } + } + ], + "bank_accounts": [ + { + "bank_code": "004", + "account_number": "", + "account_holder": "", + "is_active": false, + "is_default": false + } + ], + "auto_cancel_expired": true, + "auto_cancel_days": 3, + "cart_expiry_days": 30, + "stock_restore_on_cancel": true, + "cancellable_statuses": [ + "payment_complete" + ], + "confirmable_statuses": [ + "shipping", + "delivered" + ] + }, + "shipping": { + "default_country": "KR", + "available_countries": [ + { + "code": "KR", + "name": { + "ko": "대한민국", + "en": "South Korea", + "fr": "대한민국" + }, + "is_active": true + }, + { + "code": "US", + "name": { + "ko": "미국", + "en": "United States", + "fr": "미국" + }, + "is_active": false + }, + { + "code": "JP", + "name": { + "ko": "일본", + "en": "Japan", + "fr": "일본" + }, + "is_active": false + }, + { + "code": "CN", + "name": { + "ko": "중국", + "en": "China", + "fr": "중국" + }, + "is_active": false + }, + { + "code": "SG", + "name": { + "ko": "싱가포르", + "en": "Singapore", + "fr": "싱가포르" + }, + "is_active": false + }, + { + "code": "HK", + "name": { + "ko": "홍콩", + "en": "Hong Kong", + "fr": "홍콩" + }, + "is_active": false + }, + { + "code": "TW", + "name": { + "ko": "대만", + "en": "Taiwan", + "fr": "대만" + }, + "is_active": false + }, + { + "code": "VN", + "name": { + "ko": "베트남", + "en": "Vietnam", + "fr": "베트남" + }, + "is_active": false + }, + { + "code": "TH", + "name": { + "ko": "태국", + "en": "Thailand", + "fr": "태국" + }, + "is_active": false + }, + { + "code": "MY", + "name": { + "ko": "말레이시아", + "en": "Malaysia", + "fr": "말레이시아" + }, + "is_active": false + } + ], + "international_shipping_enabled": false, + "free_shipping_threshold": 50000, + "free_shipping_enabled": true, + "address_validation_enabled": false, + "address_api_provider": "kakao", + "carriers": [ + { + "id": 13, + "code": "apidoc", + "name": { + "ko": "API 문서 샘플 배송사", + "en": "API Doc Sample Carrier" + }, + "type": "domestic", + "tracking_url": null, + "is_active": true, + "sort_order": 0 + }, + { + "id": 1, + "code": "cj", + "name": { + "ko": "CJ대한통운", + "en": "CJ Logistics" + }, + "type": "domestic", + "tracking_url": "https://trace.cjlogistics.com/next/tracking.html?wblNo={tracking_number}", + "is_active": true, + "sort_order": 1 + }, + { + "id": 2, + "code": "hanjin", + "name": { + "ko": "한진택배", + "en": "Hanjin Express" + }, + "type": "domestic", + "tracking_url": "https://www.hanjin.com/kor/CMS/DeliveryMgr/WaybillResult.do?wblnb={tracking_number}", + "is_active": true, + "sort_order": 2 + }, + { + "id": 3, + "code": "lotte", + "name": { + "ko": "롯데택배", + "en": "Lotte Global Logistics" + }, + "type": "domestic", + "tracking_url": "https://www.lotteglogis.com/home/reservation/tracking/link498?InvNo={tracking_number}", + "is_active": true, + "sort_order": 3 + }, + { + "id": 4, + "code": "logen", + "name": { + "ko": "로젠택배", + "en": "Logen Logistics" + }, + "type": "domestic", + "tracking_url": "https://www.ilogen.com/web/personal/trace/{tracking_number}", + "is_active": true, + "sort_order": 4 + }, + { + "id": 5, + "code": "ups", + "name": { + "ko": "UPS", + "en": "UPS" + }, + "type": "international", + "tracking_url": "https://www.ups.com/track?tracknum={tracking_number}", + "is_active": true, + "sort_order": 5 + }, + { + "id": 6, + "code": "ems", + "name": { + "ko": "EMS", + "en": "EMS" + }, + "type": "international", + "tracking_url": "https://service.epost.go.kr/trace.RetrieveEmsRi498.postal?POST_CODE={tracking_number}", + "is_active": true, + "sort_order": 6 + }, + { + "id": 7, + "code": "dhl", + "name": { + "ko": "DHL", + "en": "DHL" + }, + "type": "international", + "tracking_url": "https://www.dhl.com/kr-ko/home/tracking/tracking-express.html?submit=1&tracking-id={tracking_number}", + "is_active": true, + "sort_order": 7 + }, + { + "id": 8, + "code": "fedex", + "name": { + "ko": "FedEx", + "en": "FedEx" + }, + "type": "international", + "tracking_url": "https://www.fedex.com/fedextrack/?tracknumbers={tracking_number}", + "is_active": true, + "sort_order": 8 + }, + { + "id": 9, + "code": "sf", + "name": { + "ko": "SF Express", + "en": "SF Express" + }, + "type": "international", + "tracking_url": null, + "is_active": true, + "sort_order": 9 + }, + { + "id": 10, + "code": "yamato", + "name": { + "ko": "야마토운수", + "en": "Yamato Transport" + }, + "type": "international", + "tracking_url": null, + "is_active": true, + "sort_order": 10 + }, + { + "id": 11, + "code": "sagawa", + "name": { + "ko": "사가와익스프레스", + "en": "Sagawa Express" + }, + "type": "international", + "tracking_url": null, + "is_active": true, + "sort_order": 11 + }, + { + "id": 12, + "code": "other", + "name": { + "ko": "기타", + "en": "Other" + }, + "type": "domestic", + "tracking_url": null, + "is_active": true, + "sort_order": 99 + } + ], + "types": [], + "api_request_fields": [ + { + "value": "policy_id", + "label": "배송정책 ID" + }, + { + "value": "country_code", + "label": "국가 코드" + }, + { + "value": "items", + "label": "주문 항목" + }, + { + "value": "group_total", + "label": "그룹 합계 금액" + }, + { + "value": "total_quantity", + "label": "총 수량" + } + ], + "api_http_methods": [ + { + "value": "GET", + "label": "GET" + }, + { + "value": "POST", + "label": "POST" + } + ], + "api_auth_types": [ + { + "value": "none", + "label": "인증 없음" + }, + { + "value": "bearer", + "label": "Bearer 토큰" + }, + { + "value": "custom_header", + "label": "커스텀 헤더" + } + ], + "api_response_types": [ + { + "value": "json", + "label": "JSON" + }, + { + "value": "text", + "label": "텍스트" + } + ] + }, + "seo": { + "meta_category_title": "{commerce_name} - {category_name}", + "meta_category_description": "", + "meta_search_title": "{commerce_name} - {keyword_name}", + "meta_search_description": "", + "meta_product_title": "{commerce_name} - {product_name}", + "meta_product_description": "", + "meta_shop_index_title": "{commerce_name}", + "meta_shop_index_description": "", + "seo_category": true, + "seo_search_result": true, + "seo_product_detail": true, + "seo_shop_index": true + }, + "review_settings": { + "write_deadline_days": 90, + "max_images": 5, + "max_image_size_mb": 10 + }, + "inquiry": { + "board_slug": null + }, + "notifications": { + "channels": [ + { + "id": "mail", + "is_active": true, + "sort_order": 1 + }, + { + "id": "database", + "is_active": true, + "sort_order": 2 + } + ] + }, + "mileage": { + "enabled": false, + "default_earn_rate": 1, + "earn_trigger": "confirmed", + "earn_delay_days": "0", + "currency_rules": [ + { + "currency_code": "KRW", + "point_value": 1, + "min_use_amount": 1000, + "use_unit": 10, + "max_use_type": "fixed", + "max_use_percent": 30, + "max_use_value": 50000 + } + ], + "expiry_enabled": true, + "expiry_days": 365, + "expiry_notification_enabled": true, + "expiry_notification_days_before": 7, + "notification_channels": [ + "mail", + "database" + ] + }, + "claim": { + "refund_reasons": [ + { + "id": 1, + "type": "refund", + "code": "order_mistake", + "name": { + "ko": "주문 실수", + "en": "Order Mistake" + }, + "localized_name": "주문 실수", + "fault_type": "customer", + "is_user_selectable": true, + "is_active": true, + "sort_order": 0 + }, + { + "id": 8, + "type": "refund", + "code": "apidoc_sample", + "name": { + "ko": "API 문서 샘플 사유", + "en": "API Doc Sample Reason" + }, + "localized_name": "API 문서 샘플 사유", + "fault_type": "customer", + "is_user_selectable": true, + "is_active": true, + "sort_order": 0 + }, + { + "id": 2, + "type": "refund", + "code": "changed_mind", + "name": { + "ko": "단순 변심", + "en": "Changed Mind" + }, + "localized_name": "단순 변심", + "fault_type": "customer", + "is_user_selectable": true, + "is_active": true, + "sort_order": 1 + }, + { + "id": 3, + "type": "refund", + "code": "reorder_other", + "name": { + "ko": "다른 상품으로 재주문", + "en": "Reorder with Different Product" + }, + "localized_name": "다른 상품으로 재주문", + "fault_type": "customer", + "is_user_selectable": true, + "is_active": true, + "sort_order": 2 + }, + { + "id": 4, + "type": "refund", + "code": "delayed_delivery", + "name": { + "ko": "배송 지연", + "en": "Delayed Delivery" + }, + "localized_name": "배송 지연", + "fault_type": "seller", + "is_user_selectable": true, + "is_active": true, + "sort_order": 3 + }, + { + "id": 5, + "type": "refund", + "code": "product_info_different", + "name": { + "ko": "상품 정보 상이", + "en": "Product Info Different" + }, + "localized_name": "상품 정보 상이", + "fault_type": "seller", + "is_user_selectable": true, + "is_active": true, + "sort_order": 4 + }, + { + "id": 6, + "type": "refund", + "code": "admin_cancel", + "name": { + "ko": "관리자 취소", + "en": "Admin Cancel" + }, + "localized_name": "관리자 취소", + "fault_type": "seller", + "is_user_selectable": false, + "is_active": true, + "sort_order": 5 + }, + { + "id": 7, + "type": "refund", + "code": "etc", + "name": { + "ko": "기타", + "en": "Etc" + }, + "localized_name": "기타", + "fault_type": "customer", + "is_user_selectable": true, + "is_active": true, + "sort_order": 6 + } + ] + }, + "available_pg_providers": [], + "abilities": { + "can_update": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.read`)이 없는 경우 | + + + +**설명** 관리자가 이커머스 모듈의 전체 환경설정을 카테고리별로 묶어 한 번에 조회합니다. `permission:sirsoft-ecommerce.settings.read` 권한이 필요하며, `EcommerceSettingsService::getAllSettings()`로 JSON 설정을 읽은 뒤 DB 관리 대상(배송사·배송유형·클레임 사유·마일리지 알림 채널)과 등록된 PG 목록을 병합해 반환합니다. `basic_info`·`shipping`·`order_settings`·`claim`·`mileage` 등 관리자 설정 화면 전 탭의 초기 데이터를 이 한 응답으로 채웁니다. 응답의 `abilities.can_update` 로 수정 권한 보유 여부도 함께 내려갑니다. + + +### PUT /api/modules/sirsoft-ecommerce/admin/settings + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.settings.store` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\EcommerceSettingsController@store` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| _tab | body | string | 아니오 | `basic_info`, `language_currency`, `seo`, `order_settings`, `claim`, `shipping`, `review_settings`, `notification_definitions`, `notifications`, `inquiry`, `mileage` | 저장할 설정 탭(카테고리) 지정 (탭별 부분 저장 식별용) | +| notifications | body | array | 아니오 | — | 알림 채널 설정 배열 (채널 ID·활성 여부·정렬 순서) | +| basic_info | body | array | 아니오 | — | 쇼핑몰 기본 정보 섹션 (쇼핑몰명·라우트 경로·상호·사업자번호·주소·연락처 등) | +| language_currency | body | array | 아니오 | — | 통화 설정 섹션 (기본 통화·통화 목록: 코드·다국어명·환율·반올림 규칙·통화별 로케일) | +| seo | body | array | 아니오 | — | SEO 메타 설정 섹션 (페이지 유형별 메타 타이틀/설명·SEO 활성 토글) | +| inquiry | body | array | 아니오 | — | 문의 연동 설정 섹션 (문의 게시판 slug) | +| order_settings | body | array | 아니오 | — | 주문/결제 설정 섹션 (기본 PG·결제수단·은행/무통장 계좌·자동취소·장바구니 만료 등) | +| claim | body | array | 아니오 | — | 클레임 설정 섹션 (환불 사유 목록, DB 동기화 대상으로 분리 저장) | +| review_settings | body | array | 아니오 | — | 리뷰 정책 섹션 (작성 기한일·이미지 최대 개수·이미지 최대 용량 MB) | +| mileage | body | array | 아니오 | — | 마일리지 설정 섹션 (사용 여부·기본 적립률·적립 트리거·통화별 규칙·소멸/소멸 알림) | +| shipping | body | array | 아니오 | — | 배송 설정 섹션 (기본 국가·배송 가능 국가·무료배송·배송사(carriers)·배송유형(types) — carriers/types는 DB 동기화 대상으로 분리 저장) | + +**요청 예시** + +```http +PUT /api/modules/sirsoft-ecommerce/admin/settings HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "_tab": "basic_info", + "notifications": [ + "예시값" + ], + "basic_info": [ + "예시값" + ], + "language_currency": [ + "예시값" + ], + "seo": [ + "예시값" + ], + "inquiry": [ + "예시값" + ], + "order_settings": [ + "예시값" + ], + "claim": [ + "예시값" + ], + "review_settings": [ + "예시값" + ], + "mileage": [ + "예시값" + ], + "shipping": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| basic_info | object | `{"shop_name":"","route_path":"shop","no_route":false,"com…` | 쇼핑몰 기본 정보 (쇼핑몰명·라우트 경로·상호·사업자번호·주소·연락처·이메일 등) | +| language_currency | object | `{"default_currency":"KRW","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙) | +| order_settings | object | `{"default_pg_provider":null,"payment_methods":[{"id":"car…` | 주문/결제 설정 (기본 PG·병합된 결제수단·은행/무통장 계좌·자동취소·장바구니 만료 등) | +| shipping | object | `{"default_country":"KR","available_countries":[{"code":"K…` | 배송 설정 (기본 국가·배송 가능 국가·무료배송·DB 관리 배송사(carriers)·배송유형(types)·계산 API 후보 필드 포함) | +| seo | object | `{"meta_category_title":"{commerce_name} - {category_name}…` | SEO 메타 설정 (카테고리·검색·상품·쇼핑몰 인덱스별 메타 타이틀/설명 및 SEO 활성 토글) | +| review_settings | object | `{"write_deadline_days":90,"max_images":5,"max_image_size_…` | 리뷰 정책 (작성 기한일·이미지 최대 개수·이미지 최대 용량 MB) | +| inquiry | object | `{"board_slug":null}` | 문의 연동 설정 (문의 게시판 slug) | +| notifications | object | `{"channels":[{"id":"mail","is_active":true,"sort_order":1…` | 알림 채널 설정 (채널 ID·활성 여부·정렬 순서) | +| mileage | object | `{"enabled":false,"default_earn_rate":1,"earn_trigger":"co…` | 마일리지 설정 (사용 여부·기본 적립률·적립 트리거·통화별 규칙·소멸/소멸 알림·실제 활성 알림 채널 포함) | +| claim | object | `{"refund_reasons":[{"id":1,"type":"refund","code":"order_…` | 클레임 설정 (DB 관리 대상인 환불 사유 목록: 코드·다국어명·귀책 유형·노출/활성 여부) | +| available_pg_providers | array | `[]` | 설치된 PG 플러그인이 훅으로 등록한 PG 제공자 목록 (id·name_key·지원 결제수단) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.settings.save_success", + "data": { + "basic_info": { + "shop_name": "", + "route_path": "shop", + "no_route": false, + "company_name": "", + "business_number": "", + "ceo_name": "", + "business_type": "", + "business_category": "", + "zipcode": "", + "base_address": "", + "detail_address": "", + "phone": "", + "fax": "", + "email": "", + "privacy_officer": "", + "privacy_officer_email": "", + "mail_order_number": "", + "telecom_number": "" + }, + "language_currency": { + "default_currency": "KRW", + "currencies": [ + { + "code": "KRW", + "name": { + "ko": "KRW (원)", + "en": "KRW (Won)", + "fr": "KRW (원)" + }, + "symbol": "₩", + "exchange_rate": null, + "base_unit": 1000, + "rounding_unit": "1", + "rounding_method": "floor", + "decimal_places": 0, + "is_default": true, + "locales": [ + "ko" + ], + "flag": "🇰🇷" + }, + { + "code": "USD", + "name": { + "ko": "USD (달러)", + "en": "USD (Dollar)", + "fr": "USD (달러)" + }, + "symbol": "$", + "exchange_rate": 0.85, + "base_unit": 1, + "rounding_unit": "0.01", + "rounding_method": "round", + "decimal_places": 2, + "is_default": false, + "locales": [ + "en" + ], + "flag": "🇺🇸" + }, + { + "code": "JPY", + "name": { + "ko": "JPY (엔)", + "en": "JPY (Yen)", + "fr": "JPY (엔)" + }, + "symbol": "¥", + "exchange_rate": 115, + "base_unit": 100, + "rounding_unit": "1", + "rounding_method": "floor", + "decimal_places": 0, + "is_default": false, + "locales": [ + "en" + ], + "flag": "🇯🇵" + }, + { + "code": "CNY", + "name": { + "ko": "CNY (위안)", + "en": "CNY (Yuan)", + "fr": "CNY (위안)" + }, + "symbol": "元", + "exchange_rate": 5.8, + "base_unit": 1, + "rounding_unit": "0.01", + "rounding_method": "round", + "decimal_places": 2, + "is_default": false, + "locales": [ + "en" + ], + "flag": "🇨🇳" + }, + { + "code": "EUR", + "name": { + "ko": "EUR (유로)", + "en": "EUR (Euro)", + "fr": "EUR (유로)" + }, + "symbol": "€", + "exchange_rate": 0.78, + "base_unit": 1, + "rounding_unit": "0.01", + "rounding_method": "round", + "decimal_places": 2, + "is_default": false, + "locales": [ + "en" + ], + "flag": "🇪🇺" + } + ] + }, + "order_settings": { + "default_pg_provider": null, + "payment_methods": [ + { + "id": "card", + "pg_provider": null, + "sort_order": 1, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "신용카드", + "en": "Credit Card", + "fr": "신용카드" + }, + "_cached_description": { + "ko": "신용카드로 안전하게 결제", + "en": "Pay securely with credit card", + "fr": "신용카드로 안전하게 결제" + }, + "_cached_icon": "credit-card", + "_cached_source": "builtin" + }, + { + "id": "vbank", + "pg_provider": null, + "sort_order": 2, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "mileage_deduction_timing": "order_placed", + "_cached_name": { + "ko": "가상계좌", + "en": "Virtual Account", + "fr": "가상계좌" + }, + "_cached_description": { + "ko": "가상계좌로 입금", + "en": "Pay via virtual account", + "fr": "가상계좌로 입금" + }, + "_cached_icon": "money-check", + "_cached_source": "builtin" + }, + { + "id": "dbank", + "pg_provider": null, + "sort_order": 3, + "is_active": true, + "min_order_amount": 0, + "stock_deduction_timing": "order_placed", + "mileage_deduction_timing": "order_placed", + "_cached_name": { + "ko": "무통장입금", + "en": "Bank Transfer", + "fr": "무통장입금" + }, + "_cached_description": { + "ko": "지정 계좌로 직접 입금", + "en": "Direct bank transfer", + "fr": "지정 계좌로 직접 입금" + }, + "_cached_icon": "building-columns", + "_cached_source": "builtin" + }, + { + "id": "bank", + "pg_provider": null, + "sort_order": 4, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "계좌이체", + "en": "Account Transfer", + "fr": "계좌이체" + }, + "_cached_description": { + "ko": "실시간 계좌이체", + "en": "Real-time bank transfer", + "fr": "실시간 계좌이체" + }, + "_cached_icon": "building-columns", + "_cached_source": "builtin" + }, + { + "id": "phone", + "pg_provider": null, + "sort_order": 5, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "휴대폰결제", + "en": "Mobile Payment", + "fr": "휴대폰결제" + }, + "_cached_description": { + "ko": "휴대폰 소액결제", + "en": "Mobile phone payment", + "fr": "휴대폰 소액결제" + }, + "_cached_icon": "mobile-screen-button", + "_cached_source": "builtin" + }, + { + "id": "point", + "pg_provider": null, + "sort_order": 6, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "order_placed", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "포인트결제", + "en": "Points", + "fr": "포인트결제" + }, + "_cached_description": { + "ko": "적립 포인트로 결제", + "en": "Pay with points", + "fr": "적립 포인트로 결제" + }, + "_cached_icon": "coins", + "_cached_source": "builtin" + }, + { + "id": "deposit", + "pg_provider": null, + "sort_order": 7, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "order_placed", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "예치금결제", + "en": "Store Credit", + "fr": "예치금결제" + }, + "_cached_description": { + "ko": "예치금으로 결제", + "en": "Pay with store credit", + "fr": "예치금으로 결제" + }, + "_cached_icon": "wallet", + "_cached_source": "builtin" + }, + { + "id": "free", + "pg_provider": null, + "sort_order": 8, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "order_placed", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "무료", + "en": "Free", + "fr": "무료" + }, + "_cached_description": { + "ko": "결제 없이 주문 완료", + "en": "Order without payment", + "fr": "결제 없이 주문 완료" + }, + "_cached_icon": "gift", + "_cached_source": "builtin" + } + ], + "banks": [ + { + "code": "004", + "name": { + "ko": "국민은행", + "en": "Kookmin Bank" + } + }, + { + "code": "088", + "name": { + "ko": "신한은행", + "en": "Shinhan Bank" + } + }, + { + "code": "020", + "name": { + "ko": "우리은행", + "en": "Woori Bank" + } + }, + { + "code": "081", + "name": { + "ko": "하나은행", + "en": "Hana Bank" + } + }, + { + "code": "003", + "name": { + "ko": "IBK기업은행", + "en": "IBK Industrial Bank" + } + }, + { + "code": "011", + "name": { + "ko": "NH농협은행", + "en": "NH Nonghyup Bank" + } + }, + { + "code": "071", + "name": { + "ko": "우체국", + "en": "Korea Post" + } + }, + { + "code": "031", + "name": { + "ko": "DGB대구은행", + "en": "DGB Daegu Bank" + } + }, + { + "code": "032", + "name": { + "ko": "BNK부산은행", + "en": "BNK Busan Bank" + } + }, + { + "code": "034", + "name": { + "ko": "광주은행", + "en": "Kwangju Bank" + } + }, + { + "code": "035", + "name": { + "ko": "제주은행", + "en": "Jeju Bank" + } + }, + { + "code": "037", + "name": { + "ko": "전북은행", + "en": "Jeonbuk Bank" + } + }, + { + "code": "039", + "name": { + "ko": "BNK경남은행", + "en": "BNK Kyongnam Bank" + } + }, + { + "code": "045", + "name": { + "ko": "새마을금고", + "en": "MG Community Credit Cooperatives" + } + }, + { + "code": "048", + "name": { + "ko": "신협", + "en": "KFCC" + } + }, + { + "code": "090", + "name": { + "ko": "카카오뱅크", + "en": "Kakao Bank" + } + }, + { + "code": "092", + "name": { + "ko": "토스뱅크", + "en": "Toss Bank" + } + } + ], + "bank_accounts": [ + { + "bank_code": "004", + "account_number": "", + "account_holder": "", + "is_active": false, + "is_default": false + } + ], + "auto_cancel_expired": true, + "auto_cancel_days": 3, + "cart_expiry_days": 30, + "stock_restore_on_cancel": true, + "cancellable_statuses": [ + "payment_complete" + ], + "confirmable_statuses": [ + "shipping", + "delivered" + ] + }, + "shipping": { + "default_country": "KR", + "available_countries": [ + { + "code": "KR", + "name": { + "ko": "대한민국", + "en": "South Korea", + "fr": "대한민국" + }, + "is_active": true + }, + { + "code": "US", + "name": { + "ko": "미국", + "en": "United States", + "fr": "미국" + }, + "is_active": false + }, + { + "code": "JP", + "name": { + "ko": "일본", + "en": "Japan", + "fr": "일본" + }, + "is_active": false + }, + { + "code": "CN", + "name": { + "ko": "중국", + "en": "China", + "fr": "중국" + }, + "is_active": false + }, + { + "code": "SG", + "name": { + "ko": "싱가포르", + "en": "Singapore", + "fr": "싱가포르" + }, + "is_active": false + }, + { + "code": "HK", + "name": { + "ko": "홍콩", + "en": "Hong Kong", + "fr": "홍콩" + }, + "is_active": false + }, + { + "code": "TW", + "name": { + "ko": "대만", + "en": "Taiwan", + "fr": "대만" + }, + "is_active": false + }, + { + "code": "VN", + "name": { + "ko": "베트남", + "en": "Vietnam", + "fr": "베트남" + }, + "is_active": false + }, + { + "code": "TH", + "name": { + "ko": "태국", + "en": "Thailand", + "fr": "태국" + }, + "is_active": false + }, + { + "code": "MY", + "name": { + "ko": "말레이시아", + "en": "Malaysia", + "fr": "말레이시아" + }, + "is_active": false + } + ], + "international_shipping_enabled": false, + "free_shipping_threshold": 50000, + "free_shipping_enabled": true, + "address_validation_enabled": false, + "address_api_provider": "kakao", + "carriers": [ + { + "id": 13, + "code": "apidoc", + "name": { + "ko": "API 문서 샘플 배송사", + "en": "API Doc Sample Carrier" + }, + "type": "domestic", + "tracking_url": null, + "is_active": true, + "sort_order": 0 + }, + { + "id": 1, + "code": "cj", + "name": { + "ko": "CJ대한통운", + "en": "CJ Logistics" + }, + "type": "domestic", + "tracking_url": "https://trace.cjlogistics.com/next/tracking.html?wblNo={tracking_number}", + "is_active": true, + "sort_order": 1 + }, + { + "id": 2, + "code": "hanjin", + "name": { + "ko": "한진택배", + "en": "Hanjin Express" + }, + "type": "domestic", + "tracking_url": "https://www.hanjin.com/kor/CMS/DeliveryMgr/WaybillResult.do?wblnb={tracking_number}", + "is_active": true, + "sort_order": 2 + }, + { + "id": 3, + "code": "lotte", + "name": { + "ko": "롯데택배", + "en": "Lotte Global Logistics" + }, + "type": "domestic", + "tracking_url": "https://www.lotteglogis.com/home/reservation/tracking/link498?InvNo={tracking_number}", + "is_active": true, + "sort_order": 3 + }, + { + "id": 4, + "code": "logen", + "name": { + "ko": "로젠택배", + "en": "Logen Logistics" + }, + "type": "domestic", + "tracking_url": "https://www.ilogen.com/web/personal/trace/{tracking_number}", + "is_active": true, + "sort_order": 4 + }, + { + "id": 5, + "code": "ups", + "name": { + "ko": "UPS", + "en": "UPS" + }, + "type": "international", + "tracking_url": "https://www.ups.com/track?tracknum={tracking_number}", + "is_active": true, + "sort_order": 5 + }, + { + "id": 6, + "code": "ems", + "name": { + "ko": "EMS", + "en": "EMS" + }, + "type": "international", + "tracking_url": "https://service.epost.go.kr/trace.RetrieveEmsRi498.postal?POST_CODE={tracking_number}", + "is_active": true, + "sort_order": 6 + }, + { + "id": 7, + "code": "dhl", + "name": { + "ko": "DHL", + "en": "DHL" + }, + "type": "international", + "tracking_url": "https://www.dhl.com/kr-ko/home/tracking/tracking-express.html?submit=1&tracking-id={tracking_number}", + "is_active": true, + "sort_order": 7 + }, + { + "id": 8, + "code": "fedex", + "name": { + "ko": "FedEx", + "en": "FedEx" + }, + "type": "international", + "tracking_url": "https://www.fedex.com/fedextrack/?tracknumbers={tracking_number}", + "is_active": true, + "sort_order": 8 + }, + { + "id": 9, + "code": "sf", + "name": { + "ko": "SF Express", + "en": "SF Express" + }, + "type": "international", + "tracking_url": null, + "is_active": true, + "sort_order": 9 + }, + { + "id": 10, + "code": "yamato", + "name": { + "ko": "야마토운수", + "en": "Yamato Transport" + }, + "type": "international", + "tracking_url": null, + "is_active": true, + "sort_order": 10 + }, + { + "id": 11, + "code": "sagawa", + "name": { + "ko": "사가와익스프레스", + "en": "Sagawa Express" + }, + "type": "international", + "tracking_url": null, + "is_active": true, + "sort_order": 11 + }, + { + "id": 12, + "code": "other", + "name": { + "ko": "기타", + "en": "Other" + }, + "type": "domestic", + "tracking_url": null, + "is_active": true, + "sort_order": 99 + } + ], + "types": [], + "api_request_fields": [ + { + "value": "policy_id", + "label": "배송정책 ID" + }, + { + "value": "country_code", + "label": "국가 코드" + }, + { + "value": "items", + "label": "주문 항목" + }, + { + "value": "group_total", + "label": "그룹 합계 금액" + }, + { + "value": "total_quantity", + "label": "총 수량" + } + ], + "api_http_methods": [ + { + "value": "GET", + "label": "GET" + }, + { + "value": "POST", + "label": "POST" + } + ], + "api_auth_types": [ + { + "value": "none", + "label": "인증 없음" + }, + { + "value": "bearer", + "label": "Bearer 토큰" + }, + { + "value": "custom_header", + "label": "커스텀 헤더" + } + ], + "api_response_types": [ + { + "value": "json", + "label": "JSON" + }, + { + "value": "text", + "label": "텍스트" + } + ] + }, + "seo": { + "meta_category_title": "{commerce_name} - {category_name}", + "meta_category_description": "", + "meta_search_title": "{commerce_name} - {keyword_name}", + "meta_search_description": "", + "meta_product_title": "{commerce_name} - {product_name}", + "meta_product_description": "", + "meta_shop_index_title": "{commerce_name}", + "meta_shop_index_description": "", + "seo_category": true, + "seo_search_result": true, + "seo_product_detail": true, + "seo_shop_index": true + }, + "review_settings": { + "write_deadline_days": 90, + "max_images": 5, + "max_image_size_mb": 10 + }, + "inquiry": { + "board_slug": null + }, + "notifications": { + "channels": [ + { + "id": "mail", + "is_active": true, + "sort_order": 1 + }, + { + "id": "database", + "is_active": true, + "sort_order": 2 + } + ] + }, + "mileage": { + "enabled": false, + "default_earn_rate": 1, + "earn_trigger": "confirmed", + "earn_delay_days": "0", + "currency_rules": [ + { + "currency_code": "KRW", + "point_value": 1, + "min_use_amount": 1000, + "use_unit": 10, + "max_use_type": "fixed", + "max_use_percent": 30, + "max_use_value": 50000 + } + ], + "expiry_enabled": true, + "expiry_days": 365, + "expiry_notification_enabled": true, + "expiry_notification_days_before": 7, + "notification_channels": [ + "mail", + "database" + ] + }, + "claim": { + "refund_reasons": [ + { + "id": 1, + "type": "refund", + "code": "order_mistake", + "name": { + "ko": "주문 실수", + "en": "Order Mistake" + }, + "localized_name": "주문 실수", + "fault_type": "customer", + "is_user_selectable": true, + "is_active": true, + "sort_order": 0 + }, + { + "id": 8, + "type": "refund", + "code": "apidoc_sample", + "name": { + "ko": "API 문서 샘플 사유", + "en": "API Doc Sample Reason" + }, + "localized_name": "API 문서 샘플 사유", + "fault_type": "customer", + "is_user_selectable": true, + "is_active": true, + "sort_order": 0 + }, + { + "id": 2, + "type": "refund", + "code": "changed_mind", + "name": { + "ko": "단순 변심", + "en": "Changed Mind" + }, + "localized_name": "단순 변심", + "fault_type": "customer", + "is_user_selectable": true, + "is_active": true, + "sort_order": 1 + }, + { + "id": 3, + "type": "refund", + "code": "reorder_other", + "name": { + "ko": "다른 상품으로 재주문", + "en": "Reorder with Different Product" + }, + "localized_name": "다른 상품으로 재주문", + "fault_type": "customer", + "is_user_selectable": true, + "is_active": true, + "sort_order": 2 + }, + { + "id": 4, + "type": "refund", + "code": "delayed_delivery", + "name": { + "ko": "배송 지연", + "en": "Delayed Delivery" + }, + "localized_name": "배송 지연", + "fault_type": "seller", + "is_user_selectable": true, + "is_active": true, + "sort_order": 3 + }, + { + "id": 5, + "type": "refund", + "code": "product_info_different", + "name": { + "ko": "상품 정보 상이", + "en": "Product Info Different" + }, + "localized_name": "상품 정보 상이", + "fault_type": "seller", + "is_user_selectable": true, + "is_active": true, + "sort_order": 4 + }, + { + "id": 6, + "type": "refund", + "code": "admin_cancel", + "name": { + "ko": "관리자 취소", + "en": "Admin Cancel" + }, + "localized_name": "관리자 취소", + "fault_type": "seller", + "is_user_selectable": false, + "is_active": true, + "sort_order": 5 + }, + { + "id": 7, + "type": "refund", + "code": "etc", + "name": { + "ko": "기타", + "en": "Etc" + }, + "localized_name": "기타", + "fault_type": "customer", + "is_user_selectable": true, + "is_active": true, + "sort_order": 6 + } + ] + }, + "available_pg_providers": [] + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 이커머스 환경설정을 저장합니다. `permission:sirsoft-ecommerce.settings.update` 권한이 필요하며, `_tab` 으로 저장할 카테고리를 지정하고 각 섹션(`basic_info`·`shipping`·`claim` 등)을 배열로 전달합니다. `EcommerceSettingsService::saveSettings()`가 JSON 설정을 저장하되, DB 관리 대상인 `shipping.carriers`·`shipping.types`·`claim.refund_reasons` 는 분리해 각 Service 의 sync 메서드로 동기화합니다. 저장 성공 시 `sirsoft-ecommerce.settings.after_save` 훅을 발화하고, 관리자 UI 상태 갱신을 위해 병합된 전체 설정을 다시 반환합니다. + + +### PUT /api/modules/sirsoft-ecommerce/admin/settings/banks + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.settings.store-banks` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\EcommerceSettingsController@storeBanks` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| banks | body | array | 아니오 | — | 무통장입금용 은행 목록 (은행 코드·다국어 은행명) | + +**요청 예시** + +```http +PUT /api/modules/sirsoft-ecommerce/admin/settings/banks HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "banks": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| basic_info | object | `{"shop_name":"","route_path":"shop","no_route":false,"com…` | 쇼핑몰 기본 정보 (쇼핑몰명·라우트 경로·상호·사업자번호·주소·연락처·이메일 등) | +| language_currency | object | `{"default_currency":"KRW","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙) | +| order_settings | object | `{"default_pg_provider":null,"payment_methods":[{"id":"car…` | 주문/결제 설정 (기본 PG·병합된 결제수단·은행/무통장 계좌·자동취소·장바구니 만료 등) | +| shipping | object | `{"default_country":"KR","available_countries":[{"code":"K…` | 배송 설정 (기본 국가·배송 가능 국가·무료배송·DB 관리 배송사(carriers)·배송유형(types)·계산 API 후보 필드 포함) | +| seo | object | `{"meta_category_title":"{commerce_name} - {category_name}…` | SEO 메타 설정 (카테고리·검색·상품·쇼핑몰 인덱스별 메타 타이틀/설명 및 SEO 활성 토글) | +| review_settings | object | `{"write_deadline_days":90,"max_images":5,"max_image_size_…` | 리뷰 정책 (작성 기한일·이미지 최대 개수·이미지 최대 용량 MB) | +| inquiry | object | `{"board_slug":null}` | 문의 연동 설정 (문의 게시판 slug) | +| notifications | object | `{"channels":[{"id":"mail","is_active":true,"sort_order":1…` | 알림 채널 설정 (채널 ID·활성 여부·정렬 순서) | +| mileage | object | `{"enabled":false,"default_earn_rate":1,"earn_trigger":"co…` | 마일리지 설정 (사용 여부·기본 적립률·적립 트리거·통화별 규칙·소멸/소멸 알림·실제 활성 알림 채널 포함) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.settings.save_success", + "data": { + "basic_info": { + "shop_name": "", + "route_path": "shop", + "no_route": false, + "company_name": "", + "business_number": "", + "ceo_name": "", + "business_type": "", + "business_category": "", + "zipcode": "", + "base_address": "", + "detail_address": "", + "phone": "", + "fax": "", + "email": "", + "privacy_officer": "", + "privacy_officer_email": "", + "mail_order_number": "", + "telecom_number": "" + }, + "language_currency": { + "default_currency": "KRW", + "currencies": [ + { + "code": "KRW", + "name": { + "ko": "KRW (원)", + "en": "KRW (Won)", + "fr": "KRW (원)" + }, + "symbol": "₩", + "exchange_rate": null, + "base_unit": 1000, + "rounding_unit": "1", + "rounding_method": "floor", + "decimal_places": 0, + "is_default": true, + "locales": [ + "ko" + ], + "flag": "🇰🇷" + }, + { + "code": "USD", + "name": { + "ko": "USD (달러)", + "en": "USD (Dollar)", + "fr": "USD (달러)" + }, + "symbol": "$", + "exchange_rate": 0.85, + "base_unit": 1, + "rounding_unit": "0.01", + "rounding_method": "round", + "decimal_places": 2, + "is_default": false, + "locales": [ + "en" + ], + "flag": "🇺🇸" + }, + { + "code": "JPY", + "name": { + "ko": "JPY (엔)", + "en": "JPY (Yen)", + "fr": "JPY (엔)" + }, + "symbol": "¥", + "exchange_rate": 115, + "base_unit": 100, + "rounding_unit": "1", + "rounding_method": "floor", + "decimal_places": 0, + "is_default": false, + "locales": [ + "en" + ], + "flag": "🇯🇵" + }, + { + "code": "CNY", + "name": { + "ko": "CNY (위안)", + "en": "CNY (Yuan)", + "fr": "CNY (위안)" + }, + "symbol": "元", + "exchange_rate": 5.8, + "base_unit": 1, + "rounding_unit": "0.01", + "rounding_method": "round", + "decimal_places": 2, + "is_default": false, + "locales": [ + "en" + ], + "flag": "🇨🇳" + }, + { + "code": "EUR", + "name": { + "ko": "EUR (유로)", + "en": "EUR (Euro)", + "fr": "EUR (유로)" + }, + "symbol": "€", + "exchange_rate": 0.78, + "base_unit": 1, + "rounding_unit": "0.01", + "rounding_method": "round", + "decimal_places": 2, + "is_default": false, + "locales": [ + "en" + ], + "flag": "🇪🇺" + } + ] + }, + "order_settings": { + "default_pg_provider": null, + "payment_methods": [ + { + "id": "card", + "pg_provider": null, + "sort_order": 1, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "신용카드", + "en": "Credit Card", + "fr": "신용카드" + }, + "_cached_description": { + "ko": "신용카드로 안전하게 결제", + "en": "Pay securely with credit card", + "fr": "신용카드로 안전하게 결제" + }, + "_cached_icon": "credit-card", + "_cached_source": "builtin" + }, + { + "id": "vbank", + "pg_provider": null, + "sort_order": 2, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "mileage_deduction_timing": "order_placed", + "_cached_name": { + "ko": "가상계좌", + "en": "Virtual Account", + "fr": "가상계좌" + }, + "_cached_description": { + "ko": "가상계좌로 입금", + "en": "Pay via virtual account", + "fr": "가상계좌로 입금" + }, + "_cached_icon": "money-check", + "_cached_source": "builtin" + }, + { + "id": "dbank", + "pg_provider": null, + "sort_order": 3, + "is_active": true, + "min_order_amount": 0, + "stock_deduction_timing": "order_placed", + "mileage_deduction_timing": "order_placed", + "_cached_name": { + "ko": "무통장입금", + "en": "Bank Transfer", + "fr": "무통장입금" + }, + "_cached_description": { + "ko": "지정 계좌로 직접 입금", + "en": "Direct bank transfer", + "fr": "지정 계좌로 직접 입금" + }, + "_cached_icon": "building-columns", + "_cached_source": "builtin" + }, + { + "id": "bank", + "pg_provider": null, + "sort_order": 4, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "계좌이체", + "en": "Account Transfer", + "fr": "계좌이체" + }, + "_cached_description": { + "ko": "실시간 계좌이체", + "en": "Real-time bank transfer", + "fr": "실시간 계좌이체" + }, + "_cached_icon": "building-columns", + "_cached_source": "builtin" + }, + { + "id": "phone", + "pg_provider": null, + "sort_order": 5, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "휴대폰결제", + "en": "Mobile Payment", + "fr": "휴대폰결제" + }, + "_cached_description": { + "ko": "휴대폰 소액결제", + "en": "Mobile phone payment", + "fr": "휴대폰 소액결제" + }, + "_cached_icon": "mobile-screen-button", + "_cached_source": "builtin" + }, + { + "id": "point", + "pg_provider": null, + "sort_order": 6, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "order_placed", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "포인트결제", + "en": "Points", + "fr": "포인트결제" + }, + "_cached_description": { + "ko": "적립 포인트로 결제", + "en": "Pay with points", + "fr": "적립 포인트로 결제" + }, + "_cached_icon": "coins", + "_cached_source": "builtin" + }, + { + "id": "deposit", + "pg_provider": null, + "sort_order": 7, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "order_placed", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "예치금결제", + "en": "Store Credit", + "fr": "예치금결제" + }, + "_cached_description": { + "ko": "예치금으로 결제", + "en": "Pay with store credit", + "fr": "예치금으로 결제" + }, + "_cached_icon": "wallet", + "_cached_source": "builtin" + }, + { + "id": "free", + "pg_provider": null, + "sort_order": 8, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "order_placed", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "무료", + "en": "Free", + "fr": "무료" + }, + "_cached_description": { + "ko": "결제 없이 주문 완료", + "en": "Order without payment", + "fr": "결제 없이 주문 완료" + }, + "_cached_icon": "gift", + "_cached_source": "builtin" + } + ], + "banks": [], + "bank_accounts": [ + { + "bank_code": "004", + "account_number": "", + "account_holder": "", + "is_active": false, + "is_default": false + } + ], + "auto_cancel_expired": true, + "auto_cancel_days": 3, + "cart_expiry_days": 30, + "stock_restore_on_cancel": true, + "cancellable_statuses": [ + "payment_complete" + ], + "confirmable_statuses": [ + "shipping", + "delivered" + ] + }, + "shipping": { + "default_country": "KR", + "available_countries": [ + { + "code": "KR", + "name": { + "ko": "대한민국", + "en": "South Korea", + "fr": "대한민국" + }, + "is_active": true + }, + { + "code": "US", + "name": { + "ko": "미국", + "en": "United States", + "fr": "미국" + }, + "is_active": false + }, + { + "code": "JP", + "name": { + "ko": "일본", + "en": "Japan", + "fr": "일본" + }, + "is_active": false + }, + { + "code": "CN", + "name": { + "ko": "중국", + "en": "China", + "fr": "중국" + }, + "is_active": false + }, + { + "code": "SG", + "name": { + "ko": "싱가포르", + "en": "Singapore", + "fr": "싱가포르" + }, + "is_active": false + }, + { + "code": "HK", + "name": { + "ko": "홍콩", + "en": "Hong Kong", + "fr": "홍콩" + }, + "is_active": false + }, + { + "code": "TW", + "name": { + "ko": "대만", + "en": "Taiwan", + "fr": "대만" + }, + "is_active": false + }, + { + "code": "VN", + "name": { + "ko": "베트남", + "en": "Vietnam", + "fr": "베트남" + }, + "is_active": false + }, + { + "code": "TH", + "name": { + "ko": "태국", + "en": "Thailand", + "fr": "태국" + }, + "is_active": false + }, + { + "code": "MY", + "name": { + "ko": "말레이시아", + "en": "Malaysia", + "fr": "말레이시아" + }, + "is_active": false + } + ], + "international_shipping_enabled": false, + "free_shipping_threshold": 50000, + "free_shipping_enabled": true, + "address_validation_enabled": false, + "address_api_provider": "kakao" + }, + "seo": { + "meta_category_title": "{commerce_name} - {category_name}", + "meta_category_description": "", + "meta_search_title": "{commerce_name} - {keyword_name}", + "meta_search_description": "", + "meta_product_title": "{commerce_name} - {product_name}", + "meta_product_description": "", + "meta_shop_index_title": "{commerce_name}", + "meta_shop_index_description": "", + "seo_category": true, + "seo_search_result": true, + "seo_product_detail": true, + "seo_shop_index": true + }, + "review_settings": { + "write_deadline_days": 90, + "max_images": 5, + "max_image_size_mb": 10 + }, + "inquiry": { + "board_slug": null + }, + "notifications": { + "channels": [ + { + "id": "mail", + "is_active": true, + "sort_order": 1 + }, + { + "id": "database", + "is_active": true, + "sort_order": 2 + } + ] + }, + "mileage": { + "enabled": false, + "default_earn_rate": 1, + "earn_trigger": "confirmed", + "earn_delay_days": "0", + "currency_rules": [ + { + "currency_code": "KRW", + "point_value": 1, + "min_use_amount": 1000, + "use_unit": 10, + "max_use_type": "fixed", + "max_use_percent": 30, + "max_use_value": 50000 + } + ], + "expiry_enabled": true, + "expiry_days": 365, + "expiry_notification_enabled": true, + "expiry_notification_days_before": 7 + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 무통장입금용 은행 목록만 별도로 저장합니다. `permission:sirsoft-ecommerce.settings.update` 권한이 필요하며, `banks` 배열을 받아 `EcommerceSettingsService::saveBanks()`가 저장합니다. 전체 설정 저장(`store`)과 분리된 전용 엔드포인트로, 결제 설정 화면에서 은행 목록만 관리할 때 사용합니다. 저장 성공 시 갱신된 전체 설정을 반환합니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/settings/clear-cache + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.settings.clear-cache` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\EcommerceSettingsController@clearCache` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.update` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/settings/clear-cache HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 | + + + +**설명** 관리자가 이커머스 설정 캐시와 SEO 렌더 캐시를 초기화합니다. `permission:sirsoft-ecommerce.settings.update` 권한이 필요하며, `EcommerceSettingsService::clearCache()`로 설정 캐시를 비우고 `SeoCacheManagerInterface::clearAll()`로 SEO 페이지 캐시까지 전부 삭제합니다. 설정 변경이 화면에 즉시 반영되지 않을 때 캐시를 강제로 비우는 용도로, 성공 시 `{cleared: true}` 를 반환합니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/settings/seo-cache-info + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.settings.seo-cache-info` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\EcommerceSettingsController@seoCacheInfo` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/settings/seo-cache-info HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| count | integer | `0` | 캐시된 SEO 페이지 URL 개수 | +| size_bytes | integer | `0` | 캐시된 SEO 페이지의 지원 로케일별 HTML 총 바이트 | +| size_formatted | string | `0 B` | `size` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.settings.fetch_success", + "data": { + "count": 0, + "size_bytes": 0, + "size_formatted": "0 B" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.read`)이 없는 경우 | + + + +**설명** 관리자가 현재 캐시된 SEO 페이지의 개수와 총 용량을 조회합니다. `permission:sirsoft-ecommerce.settings.read` 권한이 필요하며, `SeoCacheManagerInterface::getCachedUrls()`로 캐시된 URL 을 열거하고 지원 로케일별 HTML 바이트를 합산합니다. 응답은 캐시 페이지 수(`count`)·총 바이트(`size_bytes`)·사람이 읽기 쉬운 크기(`size_formatted`, 예 `1.5 MB`)를 담습니다. 설정 화면에서 SEO 캐시 현황을 표시하고 캐시 초기화 여부를 판단하는 근거로 사용합니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/settings/{category} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.settings.show` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\EcommerceSettingsController@show` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| category | path | string | 예 | — | 분류 필터 (해당 분류의 항목만 조회) | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/settings/basic_info HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| category | string | `basic_info` | 조회한 설정 분류(탭) 식별자 (`basic_info`·`language_currency`·`shipping` 등 요청 경로의 category 값 반영) | +| settings | object | `{"shop_name":"","route_path":"shop","no_route":false,"com…` | 해당 분류의 설정 값 객체 (분류에 따라 구조가 달라지며, 배송·클레임 등은 DB 관리 대상이 병합되어 반환) | +| abilities | object | `{"can_update":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.settings.fetch_success", + "data": { + "category": "basic_info", + "settings": { + "shop_name": "", + "route_path": "shop", + "no_route": false, + "company_name": "", + "business_number": "", + "ceo_name": "", + "business_type": "", + "business_category": "", + "zipcode": "", + "base_address": "", + "detail_address": "", + "phone": "", + "fax": "", + "email": "", + "privacy_officer": "", + "privacy_officer_email": "", + "mail_order_number": "", + "telecom_number": "" + }, + "abilities": { + "can_update": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 단일 설정 카테고리만 골라 조회합니다. `permission:sirsoft-ecommerce.settings.read` 권한이 필요하며, path 의 `category`(예 `basic_info`)로 `EcommerceSettingsService::getSettings()`를 호출해 해당 섹션만 반환합니다. 전체 설정을 내려받는 index 와 달리 특정 탭 데이터만 필요할 때 사용하며, 응답은 `category`·`settings`·`abilities.can_update` 를 포함합니다. + + +### GET /api/modules/sirsoft-ecommerce/settings/checkout + +- **라우트명**: `api.modules.sirsoft-ecommerce.settings.checkout` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\EcommerceSettingsController@checkout` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/settings/checkout HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| shipping | object | `{"default_country":"KR","available_countries":[{"code":"K…` | 체크아웃용 배송 설정 (기본 국가·배송 가능 국가·무료배송·배송유형 등) | +| order_settings | object | `{"default_pg_provider":null,"payment_methods":[{"id":"car…` | 체크아웃용 주문/결제 설정 (기본 PG·활성 결제수단·무통장 계좌 등) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.settings.fetch_success", + "data": { + "shipping": { + "default_country": "KR", + "available_countries": [ + { + "code": "KR", + "name": { + "ko": "대한민국", + "en": "South Korea", + "fr": "대한민국" + }, + "is_active": true + }, + { + "code": "US", + "name": { + "ko": "미국", + "en": "United States", + "fr": "미국" + }, + "is_active": false + }, + { + "code": "JP", + "name": { + "ko": "일본", + "en": "Japan", + "fr": "일본" + }, + "is_active": false + }, + { + "code": "CN", + "name": { + "ko": "중국", + "en": "China", + "fr": "중국" + }, + "is_active": false + }, + { + "code": "SG", + "name": { + "ko": "싱가포르", + "en": "Singapore", + "fr": "싱가포르" + }, + "is_active": false + }, + { + "code": "HK", + "name": { + "ko": "홍콩", + "en": "Hong Kong", + "fr": "홍콩" + }, + "is_active": false + }, + { + "code": "TW", + "name": { + "ko": "대만", + "en": "Taiwan", + "fr": "대만" + }, + "is_active": false + }, + { + "code": "VN", + "name": { + "ko": "베트남", + "en": "Vietnam", + "fr": "베트남" + }, + "is_active": false + }, + { + "code": "TH", + "name": { + "ko": "태국", + "en": "Thailand", + "fr": "태국" + }, + "is_active": false + }, + { + "code": "MY", + "name": { + "ko": "말레이시아", + "en": "Malaysia", + "fr": "말레이시아" + }, + "is_active": false + } + ], + "international_shipping_enabled": false, + "free_shipping_threshold": 50000, + "free_shipping_enabled": true, + "address_validation_enabled": false, + "address_api_provider": "kakao" + }, + "order_settings": { + "default_pg_provider": null, + "payment_methods": [ + { + "id": "card", + "pg_provider": null, + "sort_order": 1, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "신용카드", + "en": "Credit Card", + "fr": "신용카드" + }, + "_cached_description": { + "ko": "신용카드로 안전하게 결제", + "en": "Pay securely with credit card", + "fr": "신용카드로 안전하게 결제" + }, + "_cached_icon": "credit-card", + "_cached_source": "builtin" + }, + { + "id": "vbank", + "pg_provider": null, + "sort_order": 2, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "mileage_deduction_timing": "order_placed", + "_cached_name": { + "ko": "가상계좌", + "en": "Virtual Account", + "fr": "가상계좌" + }, + "_cached_description": { + "ko": "가상계좌로 입금", + "en": "Pay via virtual account", + "fr": "가상계좌로 입금" + }, + "_cached_icon": "money-check", + "_cached_source": "builtin" + }, + { + "id": "dbank", + "pg_provider": null, + "sort_order": 3, + "is_active": true, + "min_order_amount": 0, + "stock_deduction_timing": "order_placed", + "mileage_deduction_timing": "order_placed", + "_cached_name": { + "ko": "무통장입금", + "en": "Bank Transfer", + "fr": "무통장입금" + }, + "_cached_description": { + "ko": "지정 계좌로 직접 입금", + "en": "Direct bank transfer", + "fr": "지정 계좌로 직접 입금" + }, + "_cached_icon": "building-columns", + "_cached_source": "builtin" + }, + { + "id": "bank", + "pg_provider": null, + "sort_order": 4, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "계좌이체", + "en": "Account Transfer", + "fr": "계좌이체" + }, + "_cached_description": { + "ko": "실시간 계좌이체", + "en": "Real-time bank transfer", + "fr": "실시간 계좌이체" + }, + "_cached_icon": "building-columns", + "_cached_source": "builtin" + }, + { + "id": "phone", + "pg_provider": null, + "sort_order": 5, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "휴대폰결제", + "en": "Mobile Payment", + "fr": "휴대폰결제" + }, + "_cached_description": { + "ko": "휴대폰 소액결제", + "en": "Mobile phone payment", + "fr": "휴대폰 소액결제" + }, + "_cached_icon": "mobile-screen-button", + "_cached_source": "builtin" + }, + { + "id": "point", + "pg_provider": null, + "sort_order": 6, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "order_placed", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "포인트결제", + "en": "Points", + "fr": "포인트결제" + }, + "_cached_description": { + "ko": "적립 포인트로 결제", + "en": "Pay with points", + "fr": "적립 포인트로 결제" + }, + "_cached_icon": "coins", + "_cached_source": "builtin" + }, + { + "id": "deposit", + "pg_provider": null, + "sort_order": 7, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "order_placed", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "예치금결제", + "en": "Store Credit", + "fr": "예치금결제" + }, + "_cached_description": { + "ko": "예치금으로 결제", + "en": "Pay with store credit", + "fr": "예치금으로 결제" + }, + "_cached_icon": "wallet", + "_cached_source": "builtin" + }, + { + "id": "free", + "pg_provider": null, + "sort_order": 8, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "order_placed", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "무료", + "en": "Free", + "fr": "무료" + }, + "_cached_description": { + "ko": "결제 없이 주문 완료", + "en": "Order without payment", + "fr": "결제 없이 주문 완료" + }, + "_cached_icon": "gift", + "_cached_source": "builtin" + } + ], + "banks": [], + "bank_accounts": [ + { + "bank_code": "004", + "account_number": "", + "account_holder": "", + "is_active": false, + "is_default": false + } + ], + "auto_cancel_expired": true, + "auto_cancel_days": 3, + "cart_expiry_days": 30, + "stock_restore_on_cancel": true, + "cancellable_statuses": [ + "payment_complete" + ], + "confirmable_statuses": [ + "shipping", + "delivered" + ] + } + } +} +``` + +**에러 응답** + +_대표 에러 없음 (공개 조회). _ + + + +**설명** 인증 없이 접근 가능한 공개 엔드포인트로, 체크아웃 화면이 필요로 하는 배송·결제 설정을 한 번에 반환합니다. `EcommerceSettingsService::getSettings()`로 `shipping` 과 `order_settings` 두 섹션을 함께 조회하며, 개별 shipping/payment 엔드포인트를 두 번 호출하지 않도록 묶어줍니다. 비회원·회원 모두 접근하고, `logApiUsage('settings.checkout')`로 사용 로그를 남깁니다. + + +### GET /api/modules/sirsoft-ecommerce/settings/payment + +- **라우트명**: `api.modules.sirsoft-ecommerce.settings.payment` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\EcommerceSettingsController@payment` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/settings/payment HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| order_settings | object | `{"default_pg_provider":null,"payment_methods":[{"id":"car…` | 공개 가능한 결제 설정 (활성 결제수단·무통장 은행명 매핑 포함, 민감 정보 제외) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.settings.fetch_success", + "data": { + "order_settings": { + "default_pg_provider": null, + "payment_methods": [ + { + "id": "card", + "pg_provider": null, + "sort_order": 1, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "신용카드", + "en": "Credit Card", + "fr": "신용카드" + }, + "_cached_description": { + "ko": "신용카드로 안전하게 결제", + "en": "Pay securely with credit card", + "fr": "신용카드로 안전하게 결제" + }, + "_cached_icon": "credit-card", + "_cached_source": "builtin" + }, + { + "id": "vbank", + "pg_provider": null, + "sort_order": 2, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "mileage_deduction_timing": "order_placed", + "_cached_name": { + "ko": "가상계좌", + "en": "Virtual Account", + "fr": "가상계좌" + }, + "_cached_description": { + "ko": "가상계좌로 입금", + "en": "Pay via virtual account", + "fr": "가상계좌로 입금" + }, + "_cached_icon": "money-check", + "_cached_source": "builtin" + }, + { + "id": "dbank", + "pg_provider": null, + "sort_order": 3, + "is_active": true, + "min_order_amount": 0, + "stock_deduction_timing": "order_placed", + "mileage_deduction_timing": "order_placed", + "_cached_name": { + "ko": "무통장입금", + "en": "Bank Transfer", + "fr": "무통장입금" + }, + "_cached_description": { + "ko": "지정 계좌로 직접 입금", + "en": "Direct bank transfer", + "fr": "지정 계좌로 직접 입금" + }, + "_cached_icon": "building-columns", + "_cached_source": "builtin" + }, + { + "id": "bank", + "pg_provider": null, + "sort_order": 4, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "계좌이체", + "en": "Account Transfer", + "fr": "계좌이체" + }, + "_cached_description": { + "ko": "실시간 계좌이체", + "en": "Real-time bank transfer", + "fr": "실시간 계좌이체" + }, + "_cached_icon": "building-columns", + "_cached_source": "builtin" + }, + { + "id": "phone", + "pg_provider": null, + "sort_order": 5, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "payment_complete", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "휴대폰결제", + "en": "Mobile Payment", + "fr": "휴대폰결제" + }, + "_cached_description": { + "ko": "휴대폰 소액결제", + "en": "Mobile phone payment", + "fr": "휴대폰 소액결제" + }, + "_cached_icon": "mobile-screen-button", + "_cached_source": "builtin" + }, + { + "id": "point", + "pg_provider": null, + "sort_order": 6, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "order_placed", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "포인트결제", + "en": "Points", + "fr": "포인트결제" + }, + "_cached_description": { + "ko": "적립 포인트로 결제", + "en": "Pay with points", + "fr": "적립 포인트로 결제" + }, + "_cached_icon": "coins", + "_cached_source": "builtin" + }, + { + "id": "deposit", + "pg_provider": null, + "sort_order": 7, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "order_placed", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "예치금결제", + "en": "Store Credit", + "fr": "예치금결제" + }, + "_cached_description": { + "ko": "예치금으로 결제", + "en": "Pay with store credit", + "fr": "예치금으로 결제" + }, + "_cached_icon": "wallet", + "_cached_source": "builtin" + }, + { + "id": "free", + "pg_provider": null, + "sort_order": 8, + "is_active": false, + "min_order_amount": 0, + "stock_deduction_timing": "order_placed", + "mileage_deduction_timing": "payment_complete", + "_cached_name": { + "ko": "무료", + "en": "Free", + "fr": "무료" + }, + "_cached_description": { + "ko": "결제 없이 주문 완료", + "en": "Order without payment", + "fr": "결제 없이 주문 완료" + }, + "_cached_icon": "gift", + "_cached_source": "builtin" + } + ], + "banks": [], + "bank_accounts": [ + { + "bank_code": "004", + "account_number": "", + "account_holder": "", + "is_active": false, + "is_default": false, + "bank_name": "004" + } + ], + "auto_cancel_expired": true, + "auto_cancel_days": 3, + "cart_expiry_days": 30, + "stock_restore_on_cancel": true, + "cancellable_statuses": [ + "payment_complete" + ], + "confirmable_statuses": [ + "shipping", + "delivered" + ] + } + } +} +``` + +**에러 응답** + +_대표 에러 없음 (공개 조회). _ + + + +**설명** 인증 없이 접근 가능한 공개 엔드포인트로, 체크아웃에서 필요한 결제 설정을 반환합니다. `EcommerceSettingsService::getPublicPaymentSettings()`가 활성화된 결제 수단과 무통장입금 설정 등 공개 가능한 항목만 추려 `order_settings` 로 내려줍니다. 관리자 전용 민감 정보는 제외되며, 비회원·회원 모두 접근하고 `logApiUsage('settings.payment')`로 사용 로그를 남깁니다. + + +### GET /api/modules/sirsoft-ecommerce/settings/review + +- **라우트명**: `api.modules.sirsoft-ecommerce.settings.review` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\EcommerceSettingsController@review` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/settings/review HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| review_settings | object | `{"write_deadline_days":90,"max_images":5,"max_image_size_…` | 공개 리뷰 정책 (작성 기한일·이미지 최대 개수·이미지 최대 용량 MB) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.settings.fetch_success", + "data": { + "review_settings": { + "write_deadline_days": 90, + "max_images": 5, + "max_image_size_mb": 10 + } + } +} +``` + +**에러 응답** + +_대표 에러 없음 (공개 조회). _ + + + +**설명** 인증 없이 접근 가능한 공개 엔드포인트로, 리뷰 작성 화면이 필요로 하는 리뷰 정책을 반환합니다. `EcommerceSettingsService::getSettings('review_settings')`로 리뷰 이미지 최대 개수(`max_images`)·최대 용량(`max_image_size_mb`)·작성 기한(`write_deadline_days`) 등을 `review_settings` 로 내려줍니다. 프론트가 이미지 업로드 제한과 작성 가능 기간을 판단하는 데 사용하며, `logApiUsage('settings.review')`로 사용 로그를 남깁니다. + + +### GET /api/modules/sirsoft-ecommerce/settings/shipping + +- **라우트명**: `api.modules.sirsoft-ecommerce.settings.shipping` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\EcommerceSettingsController@shipping` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/settings/shipping HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| shipping | object | `{"default_country":"KR","available_countries":[{"code":"K…` | 공개 배송 설정 (기본 국가·배송 가능 국가·국제배송 활성 여부·배송유형·무료배송 설정) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.settings.fetch_success", + "data": { + "shipping": { + "default_country": "KR", + "available_countries": [ + { + "code": "KR", + "name": { + "ko": "대한민국", + "en": "South Korea", + "fr": "대한민국" + }, + "is_active": true + }, + { + "code": "US", + "name": { + "ko": "미국", + "en": "United States", + "fr": "미국" + }, + "is_active": false + }, + { + "code": "JP", + "name": { + "ko": "일본", + "en": "Japan", + "fr": "일본" + }, + "is_active": false + }, + { + "code": "CN", + "name": { + "ko": "중국", + "en": "China", + "fr": "중국" + }, + "is_active": false + }, + { + "code": "SG", + "name": { + "ko": "싱가포르", + "en": "Singapore", + "fr": "싱가포르" + }, + "is_active": false + }, + { + "code": "HK", + "name": { + "ko": "홍콩", + "en": "Hong Kong", + "fr": "홍콩" + }, + "is_active": false + }, + { + "code": "TW", + "name": { + "ko": "대만", + "en": "Taiwan", + "fr": "대만" + }, + "is_active": false + }, + { + "code": "VN", + "name": { + "ko": "베트남", + "en": "Vietnam", + "fr": "베트남" + }, + "is_active": false + }, + { + "code": "TH", + "name": { + "ko": "태국", + "en": "Thailand", + "fr": "태국" + }, + "is_active": false + }, + { + "code": "MY", + "name": { + "ko": "말레이시아", + "en": "Malaysia", + "fr": "말레이시아" + }, + "is_active": false + } + ], + "international_shipping_enabled": false, + "free_shipping_threshold": 50000, + "free_shipping_enabled": true, + "address_validation_enabled": false, + "address_api_provider": "kakao" + } + } +} +``` + +**에러 응답** + +_대표 에러 없음 (공개 조회). _ + + + +**설명** 인증 없이 접근 가능한 공개 엔드포인트로, 체크아웃에서 필요한 배송 설정을 반환합니다. `EcommerceSettingsService::getSettings('shipping')`로 기본 배송 국가·이용 가능한 국가 목록·국제 배송 활성화 여부·배송 타입·무료 배송 설정 등을 `shipping` 으로 내려줍니다. 프론트가 배송지 선택과 배송비 안내를 구성하는 데 사용하며, `logApiUsage('settings.shipping')`로 사용 로그를 남깁니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-carriers.md b/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-carriers.md new file mode 100644 index 00000000..eac3aaa0 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-carriers.md @@ -0,0 +1,646 @@ +# Shipping Carriers API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Shipping Carriers 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-ecommerce/admin/shipping-carriers + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.shipping-carriers.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ShippingCarrierController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/shipping-carriers HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| number | integer | `13` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| id | integer | `1` | 기본 키 (내부 식별자) | +| code | string | `cj` | 배송사 고유 코드 (소문자 시작 영숫자·하이픈/언더스코어, 시스템 식별용) | +| name | object | `{"ko":"CJ대한통운","en":"CJ Logistics","ja":"CJ大韓通運"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `CJ대한통운` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| type | string | `domestic` | 배송사 유형 (`domestic` 국내 / `international` 해외) | +| type_label | string | `국내` | `type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| tracking_url | string | `https://trace.cjlogistics.com/next/tr…` | tracking URL | +| is_active | boolean | `true` | active 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-05-27 15:20:43` | 생성 일시 | +| updated_at | string | `2026-06-27 00:49:51` | 최종 수정 일시 | +| creator | array | `[]` | 생성자 정보 객체 (uuid/name/email — creator 관계 파생) | +| updater | array | `[]` | 최종 수정자 정보 객체 (id/name — updater 관계 로드 시) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "배송사 목록을 조회했습니다.", + "data": { + "data": [ + { + "number": 13, + "id": 13, + "code": "apidoc", + "name": { + "ko": "API 문서 샘플 배송사", + "en": "API Doc Sample Carrier" + }, + "localized_name": "API 문서 샘플 배송사", + "type": "domestic", + "type_label": "국내", + "tracking_url": null, + "is_active": true, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "creator": [], + "updater": [], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + }, + { + "number": 12, + "id": 1, + "code": "cj", + "name": { + "ko": "CJ대한통운", + "en": "CJ Logistics" + }, + "localized_name": "CJ대한통운", + "type": "domestic", + "type_label": "국내", + "tracking_url": "https://trace.cjlogistics.com/next/tracking.html?wblNo={tracking_number}", + "is_active": true, + "sort_order": 1, + "created_at": "2026-07-08 10:43:32", + "updated_at": "2026-07-08 10:43:32", + "creator": [], + "updater": [], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + }, + "... (총 13건 중 2건 표시)" + ], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.read`)이 없는 경우 | + + + +**설명** 관리자가 등록된 배송사 목록을 조회합니다. 배송 설정 영역이므로 `sirsoft-ecommerce.settings.read` 권한이 필요하며, `ShippingCarrierService::getAllCarriers()` 가 요청 파라미터로 목록을 조회해 `ShippingCarrierCollection` 으로 반환합니다. 각 항목에는 코드·다국어 배송사명·유형(국내/해외)·추적 URL·활성여부·정렬순서가 포함됩니다. 배송사 관리 화면의 목록을 채우는 데 사용합니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/shipping-carriers + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.shipping-carriers.store` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ShippingCarrierController@store` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| code | body | string | 예 | max 50 | 배송사 고유 코드 (소문자 시작 영숫자·하이픈/언더스코어, 중복 불가) | +| name | body | array | 예 | — | 대상의 이름/명칭 | +| type | body | string | 예 | — | 배송사 유형 (`domestic` 국내 / `international` 해외) | +| tracking_url | body | string | 아니오 | max 500 | 배송 추적 URL 템플릿 (`{tracking_number}` 치환자를 운송장 번호로 대체) | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| sort_order | body | integer | 아니오 | min 0 | 표시 정렬 순서 값 (작을수록 우선) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.shipping_carrier.create_validation_rules`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/shipping-carriers HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "code": "예시값", + "name": [ + "예시 이름" + ], + "type": "예시값", + "tracking_url": "https://example.com", + "is_active": true, + "sort_order": 1 +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 새 배송사를 등록합니다. `sirsoft-ecommerce.settings.update` 권한이 필요하며, `ShippingCarrierService::createCarrier()` 가 고유 코드(`code`), 다국어 배송사명(`name`), 유형(`type`, 국내/해외), 배송 추적 URL 템플릿, 활성여부, 정렬순서를 저장하고 201 로 생성된 배송사 리소스를 반환합니다. `tracking_url` 에는 `{tracking_number}` 치환자를 넣어 추적 링크를 구성합니다. 새 택배사/특송사를 시스템에 추가할 때 사용합니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/shipping-carriers/active + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.shipping-carriers.active` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ShippingCarrierController@active` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/shipping-carriers/active HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| value | integer | `1` | 배송사 ID (Select 옵션의 value) | +| label | string | `CJ대한통운` | 표시용 라벨 | +| code | string | `cj` | 배송사 고유 코드 (시스템 식별용) | +| type | string | `domestic` | 배송사 유형 (`domestic` 국내 / `international` 해외) | +| tracking_url | string | `https://trace.cjlogistics.com/next/tr…` | tracking URL | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "배송사 목록을 조회했습니다.", + "data": [ + { + "value": 13, + "label": "API 문서 샘플 배송사", + "code": "apidoc", + "type": "domestic", + "tracking_url": null + }, + { + "value": 1, + "label": "CJ대한통운", + "code": "cj", + "type": "domestic", + "tracking_url": "https://trace.cjlogistics.com/next/tracking.html?wblNo={tracking_number}" + }, + { + "value": 2, + "label": "한진택배", + "code": "hanjin", + "type": "domestic", + "tracking_url": "https://www.hanjin.com/kor/CMS/DeliveryMgr/WaybillResult.do?wblnb={tracking_number}" + }, + { + "value": 3, + "label": "롯데택배", + "code": "lotte", + "type": "domestic", + "tracking_url": "https://www.lotteglogis.com/home/reservation/tracking/link498?InvNo={tracking_number}" + }, + { + "value": 4, + "label": "로젠택배", + "code": "logen", + "type": "domestic", + "tracking_url": "https://www.ilogen.com/web/personal/trace/{tracking_number}" + }, + { + "value": 5, + "label": "UPS", + "code": "ups", + "type": "international", + "tracking_url": "https://www.ups.com/track?tracknum={tracking_number}" + }, + { + "value": 6, + "label": "EMS", + "code": "ems", + "type": "international", + "tracking_url": "https://service.epost.go.kr/trace.RetrieveEmsRi498.postal?POST_CODE={tracking_number}" + }, + { + "value": 7, + "label": "DHL", + "code": "dhl", + "type": "international", + "tracking_url": "https://www.dhl.com/kr-ko/home/tracking/tracking-express.html?submit=1&tracking-id={tracking_number}" + }, + { + "value": 8, + "label": "FedEx", + "code": "fedex", + "type": "international", + "tracking_url": "https://www.fedex.com/fedextrack/?tracknumbers={tracking_number}" + }, + { + "value": 9, + "label": "SF Express", + "code": "sf", + "type": "international", + "tracking_url": null + }, + { + "value": 10, + "label": "야마토운수", + "code": "yamato", + "type": "international", + "tracking_url": null + }, + { + "value": 11, + "label": "사가와익스프레스", + "code": "sagawa", + "type": "international", + "tracking_url": null + }, + { + "value": 12, + "label": "기타", + "code": "other", + "type": "domestic", + "tracking_url": null + } + ] +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.read`)이 없는 경우 | + + + +**설명** 활성화된 배송사만 Select 옵션 형태로 조회합니다. `sirsoft-ecommerce.settings.read` 권한이 필요하며, `ShippingCarrierService::getActiveCarriers()` 결과를 `{value, label, code, type, tracking_url}` 로 매핑해 반환합니다. `type` 쿼리(domestic/international)로 국내/해외 배송사를 필터링할 수 있습니다. 송장 등록 등에서 배송사를 선택하는 드롭다운을 채우는 데 사용하며, 비활성 배송사는 노출되지 않습니다. + + +### DELETE /api/modules/sirsoft-ecommerce/admin/shipping-carriers/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.shipping-carriers.destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ShippingCarrierController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/admin/shipping-carriers/13 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| carrier_id | integer | `13` | carrier 식별자 (연관 리소스 참조) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "배송사가 삭제되었습니다.", + "data": { + "carrier_id": 13 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 배송사 1건을 삭제합니다. `sirsoft-ecommerce.settings.update` 권한이 필요하며, 컨트롤러가 `getCarrier()` 로 배송사를 조회해 없으면 404 를 반환한 뒤 `ShippingCarrierService::deleteCarrier()` 로 제거합니다. 주문/송장에서 사용 중이어서 삭제할 수 없는 경우 서비스 예외 메시지와 함께 400 을 반환합니다. 더 이상 쓰지 않는 배송사를 정리할 때 사용합니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/shipping-carriers/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.shipping-carriers.show` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ShippingCarrierController@show` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/shipping-carriers/13 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `13` | 기본 키 (내부 식별자) | +| code | string | `apidoc` | 고유 코드 (cj, fedex 등) | +| name | object | `{"ko":"API 문서 샘플 배송사","en":"API Doc Sample Carrier"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 배송사` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| type | string | `domestic` | 배송사 유형: domestic(국내), international(해외) | +| type_label | string | `국내` | `type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| tracking_url | null | `null` | tracking URL | +| is_active | boolean | `true` | active 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "배송사 정보를 조회했습니다.", + "data": { + "id": 13, + "code": "apidoc", + "name": { + "ko": "API 문서 샘플 배송사", + "en": "API Doc Sample Carrier" + }, + "localized_name": "API 문서 샘플 배송사", + "type": "domestic", + "type_label": "국내", + "tracking_url": null, + "is_active": true, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 배송사 1건의 상세 정보를 조회합니다. `sirsoft-ecommerce.settings.read` 권한이 필요하며, `ShippingCarrierService::getCarrier()` 가 배송사를 조회해 없으면 404 를 반환하고, 있으면 코드·다국어명·유형·추적 URL·활성여부 등을 담은 단건 리소스를 반환합니다. 배송사 수정 화면 진입 시 기존 값을 불러오는 데 사용합니다. + + +### PUT /api/modules/sirsoft-ecommerce/admin/shipping-carriers/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.shipping-carriers.update` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ShippingCarrierController@update` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| code | body | string | 아니오 | max 50 | 배송사 고유 코드 (부분 수정 시 전달, 중복 불가) | +| name | body | array | 아니오 | — | 대상의 이름/명칭 | +| type | body | string | 아니오 | — | 배송사 유형 (`domestic` 국내 / `international` 해외) | +| tracking_url | body | string | 아니오 | max 500 | 배송 추적 URL 템플릿 (`{tracking_number}` 치환자를 운송장 번호로 대체) | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| sort_order | body | integer | 아니오 | min 0 | 표시 정렬 순서 값 (작을수록 우선) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.shipping_carrier.update_validation_rules`). + +**요청 예시** + +```http +PUT /api/modules/sirsoft-ecommerce/admin/shipping-carriers/13 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "code": "예시값", + "name": [ + "예시 이름" + ], + "type": "예시값", + "tracking_url": "https://example.com", + "is_active": true, + "sort_order": 1 +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 기존 배송사 정보를 수정합니다. `sirsoft-ecommerce.settings.update` 권한이 필요하며, `ShippingCarrierService::updateCarrier()` 가 코드·다국어명·유형·추적 URL·활성여부·정렬순서 중 전달된 값을 갱신하고 수정된 리소스를 반환합니다(모든 body 필드는 선택적, 부분 수정 가능). 대상 배송사가 없거나 갱신에 실패하면 각각 404/400 을 반환합니다. 배송사의 추적 URL이나 표시명을 변경할 때 사용합니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/shipping-carriers/{id}/toggle-status + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.shipping-carriers.toggle-status` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ShippingCarrierController@toggleStatus` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/shipping-carriers/13/toggle-status HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `13` | 기본 키 (내부 식별자) | +| code | string | `apidoc` | 고유 코드 (cj, fedex 등) | +| name | object | `{"ko":"API 문서 샘플 배송사","en":"API Doc Sample Carrier"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 배송사` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| type | string | `domestic` | 배송사 유형: domestic(국내), international(해외) | +| type_label | string | `국내` | `type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| tracking_url | null | `null` | tracking URL | +| is_active | boolean | `false` | active 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 15:00:34` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.shipping_carriers.status_changed", + "data": { + "id": 13, + "code": "apidoc", + "name": { + "ko": "API 문서 샘플 배송사", + "en": "API Doc Sample Carrier" + }, + "localized_name": "API 문서 샘플 배송사", + "type": "domestic", + "type_label": "국내", + "tracking_url": null, + "is_active": false, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:00:34", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 배송사 1건의 활성 상태를 토글합니다. `sirsoft-ecommerce.settings.update` 권한이 필요하며, `ShippingCarrierService::toggleStatus()` 가 현재 활성 여부를 반전시키고 갱신된 배송사 리소스를 반환합니다. 대상 배송사가 없거나 처리에 실패하면 각각 404/400 을 반환합니다. 목록 화면에서 배송사 사용여부 스위치를 켜고 끌 때 사용합니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-country.md b/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-country.md new file mode 100644 index 00000000..cf4da12e --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-country.md @@ -0,0 +1,135 @@ +# Shipping Country API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Shipping Country 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-ecommerce/user/shipping-country + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.shipping-country.show` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\UserShippingCountryController@show` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/user/shipping-country HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| preferred_shipping_country | null | `null` | 회원이 저장한 선호 배송국가 코드 (2자리 대문자, 미설정 시 `null`) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "배송국가를 조회했습니다.", + "data": { + "preferred_shipping_country": null + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** 로그인한 회원이 마이페이지에 저장해 둔 선호 배송국가 코드를 조회합니다. `auth:sanctum` 인증이 필요하며, `UserShippingCountryService::getPreferredShippingCountry()`가 인증 사용자 ID로 영속된 값을 반환하고 미설정 시 `preferred_shipping_country: null`을 내려줍니다. 마이페이지 배송국가 설정·회원정보 수정 화면이 초기 선택값을 채우는 데 사용합니다. + + +### PUT /api/modules/sirsoft-ecommerce/user/shipping-country + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.shipping-country.update` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\UserShippingCountryController@update` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| shipping_country | body | string | 예 | — | 저장할 선호 배송국가 2자리 코드 (활성 배송가능 국가만 허용, 대문자로 정규화되어 저장) | + +**요청 예시** + +```http +PUT /api/modules/sirsoft-ecommerce/user/shipping-country HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "shipping_country": "KR" +} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| preferred_shipping_country | string | `KR` | 변경 후 저장된 회원 선호 배송국가 코드 (2자리 대문자 ISO 국가코드) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "배송국가가 변경되었습니다.", + "data": { + "preferred_shipping_country": "KR" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 로그인한 회원이 선호 배송국가를 저장합니다. `auth:sanctum` 인증이 필요하며, `UpdateUserShippingCountryRequest`가 활성 국가 코드만 허용하도록 검증한 뒤 `UserShippingCountryService::setPreferredShippingCountry()`가 인증 사용자에게 영속합니다. 저장된 값은 대문자로 정규화되어 응답되며, 이후 장바구니·주문 계산의 기본 배송국가로 사용됩니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-policies.md b/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-policies.md new file mode 100644 index 00000000..3178aa0f --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-policies.md @@ -0,0 +1,797 @@ +# Shipping Policies API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Shipping Policies 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-ecommerce/admin/shipping-policies + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.shipping-policies.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ShippingPolicyController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.shipping-policies.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| search | query | string | 아니오 | max 200 | 검색어 (지정한 검색 대상 필드에서 부분 일치) | +| shipping_methods | query | array | 아니오 | — | 배송방법 코드로 필터 (ShippingType 코드 배열, 국가별 설정 중 하나라도 매치되는 정책만) | +| charge_policies | query | array | 아니오 | — | 배송비 부과정책으로 필터 (free/fixed/conditional_free/range_*/api/per_* 등, 국가별 설정 매치) | +| countries | query | array | 아니오 | — | 배송 국가 코드로 필터 (ISO 코드 배열, 해당 국가 설정을 가진 정책만) | +| is_active | query | string | 아니오 | ``, `true`, `false` | 활성 여부 (true 활성 / false 비활성) | +| sort_by | query | string | 아니오 | `id`, `name`, `is_active`, `sort_order`, `created_at`, `updated_at` | 정렬 기준 필드명 | +| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) | +| per_page | query | integer | 아니오 | min 10, max 100 | 페이지당 항목 수 | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.shipping_policy.list_validation_rules`, `sirsoft-ecommerce.shipping_policy.list_validation_messages`). + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/shipping-policies?search=%EC%98%88%EC%8B%9C%EA%B0%92&shipping_methods=%EC%98%88%EC%8B%9C%EA%B0%92&charge_policies=%EC%98%88%EC%8B%9C%EA%B0%92&countries=KR&is_active=%2C%20&sort_by=id&sort_order=asc&per_page=1&page=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| number | integer | `17` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| id | integer | `47` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 배송정책","en":"API Doc Sample Shipping Poli…` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name_localized | string | `API 문서 샘플 배송정책` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| country_settings | array | `[]` | 국가별 배송 설정 목록 (countrySettings 관계 로드 시 각 국가의 배송방식·부과정책·배송비 상세) | +| fee_summary | string | `` | 활성 국가별 설정을 종합한 배송비 요약 텍스트 (예: `KR: 3000원 \| US: $20`, 활성 설정 없으면 빈 문자열) | +| countries_display | string | `` | 활성 배송 국가를 국기 이모지로 표시한 문자열 (최대 3개 노출, 초과분은 `+N` 축약) | +| is_active | boolean | `true` | active 여부 | +| is_default | boolean | `false` | default 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-07 14:47:31` | 생성 일시 | +| updated_at | string | `2026-07-07 14:47:31` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "배송정책 목록을 조회했습니다.", + "data": { + "data": [ + { + "number": 1, + "id": 1, + "name": { + "ko": "API 문서 샘플 배송정책", + "en": "API Doc Sample Shipping Policy" + }, + "name_localized": "API 문서 샘플 배송정책", + "country_settings": [], + "fee_summary": "", + "countries_display": "", + "is_active": true, + "is_default": false, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } + ], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + }, + "statistics": { + "total": 1, + "active": 1, + "inactive": 0, + "shipping_method": [], + "charge_policy": [] + }, + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 1, + "from": 1, + "to": 1, + "has_more_pages": false + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 배송정책 목록을 페이지네이션으로 조회합니다. `sirsoft-ecommerce.shipping-policies.read` 권한이 필요하며, `ShippingPolicyService::getList()` 가 검색어·배송방식·부과정책·국가·활성여부 필터와 정렬을 적용하고, 함께 `getStatistics()` 로 집계 통계를 계산해 `ShippingPolicyCollection` 에 담아 반환합니다. 각 항목의 `abilities` 로 생성/수정/삭제 가능 여부가 내려옵니다. 배송정책 관리 목록 화면을 채우는 데 사용합니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/shipping-policies + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.shipping-policies.store` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ShippingPolicyController@store` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.shipping-policies.create` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| name | body | array | 예 | — | 대상의 이름/명칭 | +| is_active | body | boolean | 예 | — | 활성 여부 (true 활성 / false 비활성) | +| is_default | body | boolean | 아니오 | — | 기본값 지정 여부 | +| sort_order | body | integer | 아니오 | min 0 | 표시 정렬 순서 값 (작을수록 우선) | +| country_settings | body | array | 예 | min 1 | 국가별 배송 설정 배열 (최소 1개). 각 항목에 국가코드·배송방식·부과정책(charge_policy)·배송비·구간/API/도서산간 설정을 담음 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.shipping_policy.store_validation_rules`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/shipping-policies HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": [ + "예시 이름" + ], + "is_active": true, + "is_default": true, + "sort_order": 1, + "country_settings": [ + "KR" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.create`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 새 배송정책을 생성합니다. `sirsoft-ecommerce.shipping-policies.create` 권한이 필요하며, `ShippingPolicyService::create()` 가 다국어 정책명(`name`), 활성여부, 기본여부, 정렬순서, 국가별 설정(`country_settings`, 최소 1개)을 저장하고 201 로 생성된 정책 리소스를 반환합니다. 국가별로 배송방식·배송비 부과정책을 담은 배송정책을 새로 등록할 때 사용합니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/shipping-policies/active + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.shipping-policies.active` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ShippingPolicyController@activeList` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.shipping-policies.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/shipping-policies/active HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| value | integer | `46` | 배송정책 ID (Select 옵션의 value) | +| label | string | `sdfsf` | 표시용 라벨 | +| countries_display | string | `🇰🇷` | 활성 배송 국가를 국기 이모지로 표시한 문자열 (최대 3개, 초과분 `+N`) | +| fee_summary | string | `KR: 외부 API 연동 (실시간 계산)` | 국가별 배송비 요약 텍스트 (`country_code: fee` 형태를 ` \| ` 로 결합) | +| is_default | boolean | `false` | default 여부 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.shipping_policy.active_list_retrieved", + "data": [ + { + "value": 1, + "label": "API 문서 샘플 배송정책", + "countries_display": "", + "fee_summary": "", + "is_default": false + } + ] +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.read`)이 없는 경우 | + + + +**설명** 활성화된 배송정책만 Select 옵션 형태로 조회합니다. `sirsoft-ecommerce.shipping-policies.read` 권한이 필요하며, `ShippingPolicyService::getActiveList()` 결과를 `{value, label, countries_display, fee_summary, is_default}` 로 매핑해 반환합니다. 상품 등록/수정 폼 등에서 배송정책을 선택하는 드롭다운을 채우는 데 사용하며, 비활성 정책은 노출되지 않습니다. + + +### DELETE /api/modules/sirsoft-ecommerce/admin/shipping-policies/bulk + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.shipping-policies.bulk-destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ShippingPolicyController@bulkDestroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.shipping-policies.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| ids | query | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.shipping_policy.bulk_delete_validation_rules`). + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/admin/shipping-policies/bulk?ids=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.delete`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 선택한 여러 배송정책을 한 번에 삭제합니다. `sirsoft-ecommerce.shipping-policies.delete` 권한이 필요하며, `ShippingPolicyService::bulkDelete()` 가 `ids`(최소 1개)에 해당하는 정책들을 삭제하고 `deleted_count` 를 반환합니다. 목록 화면에서 체크박스로 다건 선택 후 일괄 삭제할 때 사용합니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/shipping-policies/bulk-toggle-active + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.shipping-policies.bulk-toggle-active` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ShippingPolicyController@bulkToggleActive` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.shipping-policies.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| ids | body | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | +| is_active | body | boolean | 예 | — | 활성 여부 (true 활성 / false 비활성) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.shipping_policy.bulk_toggle_active_validation_rules`). + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/shipping-policies/bulk-toggle-active HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "ids": [ + "예시값" + ], + "is_active": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 선택한 여러 배송정책의 활성 상태를 한 번에 변경합니다. `sirsoft-ecommerce.shipping-policies.update` 권한이 필요하며, `ShippingPolicyService::bulkToggleActive()` 가 `ids`(최소 1개)에 해당하는 정책들을 `is_active` 값으로 일괄 활성/비활성 처리하고 `updated_count` 를 반환합니다. 목록 화면에서 다건 선택 후 사용여부를 한꺼번에 켜거나 끌 때 사용합니다. + + +### POST /api/modules/sirsoft-ecommerce/admin/shipping-policies/test-api-call + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.shipping-policies.test-api-call` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ShippingPolicyController@testApiCall` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.shipping-policies.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| endpoint | body | string | 예 | max 500 | 테스트로 호출할 외부 배송비 계산 API 엔드포인트 URL | +| request_fields | body | array | 아니오 | — | 요청에 실어 보낼 필드명 목록 (후보 SSoT ShippingApiRequestField 5종) | +| config | body | array | 아니오 | — | API 호출 고급 설정 (HTTP 메서드·인증방식·필드 매핑·응답 형식/경로 등) | +| sample | body | array | 아니오 | — | 테스트 계산에 사용할 샘플 주문 데이터 (무게/금액/수량 등) | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/shipping-policies/test-api-call HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "endpoint": "예시값", + "request_fields": [ + "예시값" + ], + "config": [ + "예시값" + ], + "sample": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 배송정책 편집 폼에서 입력 중인 설정으로 외부 배송비 계산 API 를 1회 실호출해 미리 테스트합니다. `sirsoft-ecommerce.shipping-policies.update` 권한이 필요하며, `OrderCalculationService::testApiCall()` 이 `endpoint`·`config`·`request_fields`·`sample` 을 사용해 실제 요청을 보내고 요청 미리보기, 응답, 추출된 배송비를 반환합니다. 타임아웃과 응답 크기 제한이 적용됩니다. 실시간 계산형 배송정책을 저장하기 전에 API 연동이 올바른지 검증할 때 사용합니다. + + +### DELETE /api/modules/sirsoft-ecommerce/admin/shipping-policies/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.shipping-policies.destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ShippingPolicyController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.shipping-policies.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/admin/shipping-policies/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "배송정책이 삭제되었습니다.", + "data": null +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.delete`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 배송정책 1건을 삭제합니다. `sirsoft-ecommerce.shipping-policies.delete` 권한이 필요하며, 컨트롤러가 `getDetail()` 로 정책을 조회해 없으면 404 를 반환한 뒤 `ShippingPolicyService::delete()` 로 제거합니다. 사용 중인 정책이라 삭제할 수 없는 등 실패 시 400 을 반환합니다. 더 이상 쓰지 않는 배송정책을 개별 정리할 때 사용합니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/shipping-policies/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.shipping-policies.show` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ShippingPolicyController@show` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.shipping-policies.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/shipping-policies/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 배송정책","en":"API Doc Sample Shipping Poli…` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name_localized | string | `API 문서 샘플 배송정책` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| country_settings | array | `[]` | 국가별 배송 설정 목록 (countrySettings 관계 로드 시 각 국가의 배송방식·부과정책·배송비 상세) | +| fee_summary | string | `` | 활성 국가별 설정을 종합한 배송비 요약 텍스트 (예: `KR: 3000원 \| US: $20`, 활성 설정 없으면 빈 문자열) | +| countries_display | string | `` | 활성 배송 국가를 국기 이모지로 표시한 문자열 (최대 3개 노출, 초과분은 `+N` 축약) | +| is_active | boolean | `true` | active 여부 | +| is_default | boolean | `false` | default 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "배송정책 정보를 조회했습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 배송정책", + "en": "API Doc Sample Shipping Policy" + }, + "name_localized": "API 문서 샘플 배송정책", + "country_settings": [], + "fee_summary": "", + "countries_display": "", + "is_active": true, + "is_default": false, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 배송정책 1건의 상세 정보를 조회합니다. `sirsoft-ecommerce.shipping-policies.read` 권한이 필요하며, `ShippingPolicyService::getDetail()` 이 정책을 조회해 없으면 404 를 반환하고, 있으면 다국어 정책명·국가별 설정·배송비 요약 등을 담은 단건 리소스를 반환합니다. 배송정책 수정 화면 진입 시 기존 값을 불러오는 데 사용합니다. + + +### PUT /api/modules/sirsoft-ecommerce/admin/shipping-policies/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.shipping-policies.update` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ShippingPolicyController@update` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.shipping-policies.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| name | body | array | 예 | — | 대상의 이름/명칭 | +| is_active | body | boolean | 예 | — | 활성 여부 (true 활성 / false 비활성) | +| is_default | body | boolean | 아니오 | — | 기본값 지정 여부 | +| sort_order | body | integer | 아니오 | min 0 | 표시 정렬 순서 값 (작을수록 우선) | +| country_settings | body | array | 예 | min 1 | 국가별 배송 설정 배열 (최소 1개). 각 항목에 국가코드·배송방식·부과정책(charge_policy)·배송비·구간/API/도서산간 설정을 담음 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.shipping_policy.update_validation_rules`). + +**요청 예시** + +```http +PUT /api/modules/sirsoft-ecommerce/admin/shipping-policies/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "name": [ + "예시 이름" + ], + "is_active": true, + "is_default": true, + "sort_order": 1, + "country_settings": [ + "KR" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 기존 배송정책을 수정합니다. `sirsoft-ecommerce.shipping-policies.update` 권한이 필요하며, 컨트롤러가 `getDetail()` 로 정책을 조회해 없으면 404 를 반환한 뒤 `ShippingPolicyService::update()` 가 다국어 정책명·활성여부·기본여부·정렬순서·국가별 설정(`country_settings`, 최소 1개)을 갱신하고 수정된 리소스를 반환합니다. 배송정책의 국가별 배송비/방식을 변경할 때 사용합니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/shipping-policies/{id}/set-default + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.shipping-policies.set-default` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ShippingPolicyController@setDefault` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.shipping-policies.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/shipping-policies/1/set-default HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 배송정책","en":"API Doc Sample Shipping Poli…` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name_localized | string | `API 문서 샘플 배송정책` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| fee_summary | string | `` | 활성 국가별 설정을 종합한 배송비 요약 텍스트 (예: `KR: 3000원 \| US: $20`, 활성 설정 없으면 빈 문자열) | +| countries_display | string | `` | 활성 배송 국가를 국기 이모지로 표시한 문자열 (최대 3개 노출, 초과분은 `+N` 축약) | +| is_active | boolean | `true` | active 여부 | +| is_default | boolean | `true` | default 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 15:00:35` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "기본 배송정책이 설정되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 배송정책", + "en": "API Doc Sample Shipping Policy" + }, + "name_localized": "API 문서 샘플 배송정책", + "fee_summary": "", + "countries_display": "", + "is_active": true, + "is_default": true, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:00:35", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 지정한 배송정책을 기본 배송정책으로 설정합니다. `sirsoft-ecommerce.shipping-policies.update` 권한이 필요하며, 컨트롤러가 `getDetail()` 로 정책을 조회해 없으면 404 를 반환한 뒤 `ShippingPolicyService::setDefault()` 가 해당 정책을 기본값으로 지정합니다(기존 기본 정책은 해제). 별도 정책이 매칭되지 않을 때 적용되는 기본 배송정책을 바꿀 때 사용합니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/shipping-policies/{id}/toggle-active + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.shipping-policies.toggle-active` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ShippingPolicyController@toggleActive` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.shipping-policies.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/shipping-policies/1/toggle-active HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 배송정책","en":"API Doc Sample Shipping Poli…` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name_localized | string | `API 문서 샘플 배송정책` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| fee_summary | string | `` | 활성 국가별 설정을 종합한 배송비 요약 텍스트 (예: `KR: 3000원 \| US: $20`, 활성 설정 없으면 빈 문자열) | +| countries_display | string | `` | 활성 배송 국가를 국기 이모지로 표시한 문자열 (최대 3개 노출, 초과분은 `+N` 축약) | +| is_active | boolean | `false` | active 여부 | +| is_default | boolean | `false` | default 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 15:00:35` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "배송정책 사용여부가 변경되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 배송정책", + "en": "API Doc Sample Shipping Policy" + }, + "name_localized": "API 문서 샘플 배송정책", + "fee_summary": "", + "countries_display": "", + "is_active": false, + "is_default": false, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:00:35", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 배송정책 1건의 활성 상태를 토글합니다. `sirsoft-ecommerce.shipping-policies.update` 권한이 필요하며, 컨트롤러가 `getDetail()` 로 정책을 조회해 없으면 404 를 반환한 뒤 `ShippingPolicyService::toggleActive()` 가 현재 활성 여부를 반전시키고 갱신된 리소스를 반환합니다. 목록 화면에서 개별 정책의 사용여부 스위치를 켜고 끌 때 사용합니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/users.md b/modules/_bundled/sirsoft-ecommerce/docs/api/users.md new file mode 100644 index 00000000..70877bc8 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/users.md @@ -0,0 +1,133 @@ +# Users API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Users 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### PATCH /api/modules/sirsoft-ecommerce/admin/users/{user}/currency + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.users.currency.update` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\AdminUserCurrencyController@update` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.user-currency.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| user | path | string | 예 | — | 대상 user의 식별자 | +| currency | body | string | 예 | — | 회원에게 지정할 결제 통화 코드 (등록 통화: is_default 또는 exchange_rate>0 인 통화만 허용) | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/users/a234c2b1-cde8-437f-b28b-23323be2b98d/currency HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "currency": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-currency.manage`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 특정 회원의 선호 결제 통화를 변경합니다. `auth:sanctum` + admin + `sirsoft-ecommerce.user-currency.manage` 권한이 필요하며, path의 `{user}`는 UUID 라우트 모델 바인딩(관리자 회원 URL 규약)으로 주입됩니다. `UserCurrencyService::changeUserCurrencyByAdmin()`이 통화 저장과 활동 로그 훅 발화를 한 단위로 처리하며, `currency`는 등록된 통화 코드만 허용됩니다. 관리자 회원 상세 화면에서 회원별 결제 통화를 지정하는 용도입니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/users/{user}/shipping-country + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.users.shipping-country.update` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\AdminUserShippingCountryController@update` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.user-shipping-country.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| user | path | string | 예 | — | 대상 user의 식별자 | +| shipping_country | body | string | 예 | — | 회원에게 지정할 배송국가 2자리 코드 (활성 배송가능 국가만 허용, 대문자로 정규화) | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/users/a234c2b1-cde8-437f-b28b-23323be2b98d/shipping-country HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "shipping_country": "KR" +} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| preferred_shipping_country | string | `KR` | 변경 후 저장된 회원 선호 배송국가 코드 (2자리 대문자 ISO 국가코드) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "배송국가가 변경되었습니다.", + "data": { + "preferred_shipping_country": "KR" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-shipping-country.manage`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 특정 회원의 선호 배송국가를 변경합니다. `auth:sanctum` + admin + `sirsoft-ecommerce.user-shipping-country.manage` 권한이 필요하며, path의 `{user}`는 UUID 라우트 모델 바인딩으로 주입됩니다. `UserShippingCountryService::changeUserShippingCountryByAdmin()`이 배송국가 저장과 활동 로그 훅 발화를 한 단위로 처리하고, `shipping_country`는 활성 국가만 허용되며 대문자로 정규화되어 반환됩니다. 관리자 회원 상세 화면에서 회원별 기본 배송국가를 지정하는 용도입니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/wishlist.md b/modules/_bundled/sirsoft-ecommerce/docs/api/wishlist.md new file mode 100644 index 00000000..b5392d1f --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/wishlist.md @@ -0,0 +1,185 @@ +# Wishlist API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Wishlist 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-ecommerce/wishlist + +- **라우트명**: `api.modules.sirsoft-ecommerce.wishlist.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\WishlistController@index` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/wishlist HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "찜 목록을 불러왔습니다.", + "data": { + "data": [], + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 0, + "from": null, + "to": null, + "has_more_pages": false + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** 로그인한 회원의 찜(위시리스트) 목록을 페이지네이션으로 조회합니다. `auth:sanctum` 인증이 필요하며, `WishlistController@index`가 `ProductWishlistService::getByUser()`로 본인 찜 목록만 가져와 `WishlistCollection`으로 반환합니다. `per_page`는 기본 20건이며 최대 100건으로 제한됩니다. 마이페이지의 찜 목록 화면에서 사용합니다. + + +### POST /api/modules/sirsoft-ecommerce/wishlist/toggle + +- **라우트명**: `api.modules.sirsoft-ecommerce.wishlist.toggle` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\WishlistController@toggle` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| product_id | body | integer | 예 | — | product 식별자 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.wishlist.toggle_validation_rules`). + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/wishlist/toggle HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "product_id": 1 +} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| added | boolean | `true` | 토글 후 찜 상태 (true 이면 찜 목록에 추가됨 / false 이면 제거됨) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품이 찜 목록에 추가되었습니다.", + "data": { + "added": true + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 특정 상품(`product_id`)의 찜 상태를 토글합니다. `auth:sanctum` 인증이 필요하며, `WishlistController@toggle`이 `ProductWishlistService::toggle()`을 호출해 이미 찜한 상품이면 제거하고 아니면 추가한 뒤 `added` 불리언을 반환합니다. 상품 상세/목록의 찜 하트 버튼이 이 하나의 엔드포인트로 추가·제거를 모두 처리합니다. 응답의 `added` 값으로 현재 찜 여부를 즉시 갱신할 수 있습니다. + + +### DELETE /api/modules/sirsoft-ecommerce/wishlist/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.wishlist.destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\WishlistController@destroy` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/wishlist/{id} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 찜 목록에서 특정 찜 항목(`id`)을 삭제합니다. `auth:sanctum` 인증이 필요하며, `WishlistController@destroy`가 `ProductWishlistService::destroy()`로 본인 소유 찜만 삭제합니다. 상품 ID가 아니라 찜 레코드 ID로 삭제하며, 해당 항목이 본인 것이 아니거나 존재하지 않으면 404를 반환합니다. 상품 상세의 하트 토글과 달리 마이페이지 찜 목록에서 특정 항목을 명시적으로 제거할 때 사용합니다. + + diff --git a/modules/_bundled/sirsoft-ecommerce/module.json b/modules/_bundled/sirsoft-ecommerce/module.json index 323944a8..8463b86d 100644 --- a/modules/_bundled/sirsoft-ecommerce/module.json +++ b/modules/_bundled/sirsoft-ecommerce/module.json @@ -5,7 +5,7 @@ "ko": "이커머스", "en": "Ecommerce" }, - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "description": { "ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리", diff --git a/modules/_bundled/sirsoft-ecommerce/package-lock.json b/modules/_bundled/sirsoft-ecommerce/package-lock.json index 608be326..dc9901a7 100644 --- a/modules/_bundled/sirsoft-ecommerce/package-lock.json +++ b/modules/_bundled/sirsoft-ecommerce/package-lock.json @@ -1,12 +1,12 @@ { "name": "@g7/sirsoft-ecommerce", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@g7/sirsoft-ecommerce", - "version": "1.0.1", + "version": "1.0.2", "devDependencies": { "jsdom": "^27.4.0", "typescript": "^5.3.3", diff --git a/modules/_bundled/sirsoft-ecommerce/package.json b/modules/_bundled/sirsoft-ecommerce/package.json index 3066e2a1..08a25b1e 100644 --- a/modules/_bundled/sirsoft-ecommerce/package.json +++ b/modules/_bundled/sirsoft-ecommerce/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-ecommerce", - "version": "1.0.1", + "version": "1.0.2", "description": "그누보드7 이커머스 모듈 프론트엔드 에셋", "private": true, "type": "module", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/extensions/admin-user-shipping-country-field.json b/modules/_bundled/sirsoft-ecommerce/resources/extensions/admin-user-shipping-country-field.json index cc210333..745d6a44 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/extensions/admin-user-shipping-country-field.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/extensions/admin-user-shipping-country-field.json @@ -61,7 +61,7 @@ "props": { "name": "ecommerce_preferred_shipping_country", "value": "{{_local.form?.ecommerce_preferred_shipping_country ?? user?.data?.ecommerce_preferred_shipping_country ?? _global.modules?.['sirsoft-ecommerce']?.shipping?.default_country ?? 'KR'}}", - "options": "{{(_global.modules?.['sirsoft-ecommerce']?.shipping?.available_countries ?? []).filter(c => c.is_active).map(c => ({ value: c.code, label: (c.name?.[_global.locale] ?? c.name?.ko ?? c.code) }))}}", + "options": "{{(_global.modules?.['sirsoft-ecommerce']?.shipping?.available_countries ?? []).filter(c => c.is_active).map(c => ({ value: c.code, label: ($localized(c.name, 'sirsoft-ecommerce::settings.countries.' + (c.code ?? '') + '.name') || c.code) }))}}", "className": "w-full sm:w-64" } } diff --git a/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-admin.json b/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-admin.json index 626c594b..d0282f3c 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-admin.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-admin.json @@ -24,11 +24,11 @@ } }, { - "comment": "헤더 배송국가 셀렉터 목록 파생 주입 (유저 헤더 미러). 활성 국가만 {code, name(현지화)} 형태로. 해외배송 활성 게이트는 셀렉터 if 가 international_shipping_enabled 로 별도 체크(유저 헤더 동형). 셀렉터 if 게이트가 이 파생 상태(availableShippingCountries)를 참조.", + "comment": "헤더 배송국가 셀렉터 목록 파생 주입 (유저 헤더 미러). 활성 국가만 {code, name(현지화)} 형태로. 해외배송 활성 게이트는 셀렉터 if 가 international_shipping_enabled 로 별도 체크(유저 헤더 동형). 셀렉터 if 게이트가 이 파생 상태(availableShippingCountries)를 참조. 국가명 $localized(name, fallbackKey): 활성 로케일 키는 서버(localize_catalog_field)가 settings.countries.{code}.name 으로 미리 보강해 통상 즉시 hit 한다. fallbackKey 와 || c.code 는 보강이 실패한 경우의 방어선.", "handler": "setState", "params": { "target": "global", - "availableShippingCountries": "{{(_global.modules?.['sirsoft-ecommerce']?.shipping?.available_countries ?? []).filter(c => c.is_active).map(c => ({ code: c.code, name: (c.name?.[_global.locale] ?? c.name?.ko ?? c.code) }))}}" + "availableShippingCountries": "{{(_global.modules?.['sirsoft-ecommerce']?.shipping?.available_countries ?? []).filter(c => c.is_active).map(c => ({ code: c.code, name: ($localized(c.name, 'sirsoft-ecommerce::settings.countries.' + (c.code ?? '') + '.name') || c.code) }))}}" } } ], @@ -44,8 +44,15 @@ "name": "Div", "slot": "header_currency", "props": { "className": "relative {{(_global.availableCurrencies ?? []).length > 0 || ((_global.availableShippingCountries ?? []).length > 1 && _global.modules?.['sirsoft-ecommerce']?.shipping?.international_shipping_enabled === true) ? '' : 'hidden'}}" }, + "responsive": { + "portable": { + "comment": "portable 에서는 이 조각이 관리자 템플릿 모바일 드로어(admin_drawer_prefs, 세로 블록) 안에 렌더된다. 펼친 목록이 컨테이너 폭을 채우도록 w-full. hidden 게이트 표현식은 그대로 유지(props 얕은 머지가 아니라 className 통째 교체이므로 표현식을 복제해야 함).", + "props": { "className": "relative w-full {{(_global.availableCurrencies ?? []).length > 0 || ((_global.availableShippingCountries ?? []).length > 1 && _global.modules?.['sirsoft-ecommerce']?.shipping?.international_shipping_enabled === true) ? '' : 'hidden'}}" } + } + }, "children": [ { + "comment": "트리거 버튼 — 데스크톱은 absolute 드롭다운의 토글, portable(드로어 안)에서는 접기/펼치기 아코디언의 트리거. 두 경우 모두 같은 플래그(_local.showCurrencyDropdown)를 토글하므로 기본은 접힘이다. portable 에서는 드로어 폭을 꽉 채우는 행 형태(좌: '통화' 라벨, 우: 현재 통화·배송국가 요약 + chevron)로 바뀐다.", "type": "basic", "name": "Button", "props": { @@ -54,6 +61,55 @@ "aria-expanded": "{{_local.showCurrencyDropdown ?? false}}", "aria-label": "$t:sirsoft-ecommerce.common.currency_label" }, + "responsive": { + "portable": { + "comment": "드로어 안에서는 언어 아코디언 트리거와 동일한 형태 — children 을 통째 교체해 '통화' 라벨 + 현재값 요약 + chevron 으로 구성한다(엔진은 responsive.children 을 완전 교체한다).", + "props": { + "className": "w-full flex items-center justify-between py-1 cursor-pointer", + "aria-haspopup": "listbox", + "aria-expanded": "{{_local.showCurrencyDropdown ?? false}}", + "aria-label": "$t:sirsoft-ecommerce.common.currency_label" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" }, + "text": "$t:sirsoft-ecommerce.common.currency_label" + }, + { + "type": "basic", + "name": "Span", + "props": { "className": "flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300" }, + "children": [ + { + "comment": "접힘 상태 요약 ① 현재 통화 코드", + "type": "basic", + "name": "Span", + "props": { "className": "font-medium" }, + "text": "{{_global.preferredCurrency ?? _global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency ?? 'KRW'}}" + }, + { + "comment": "접힘 상태 요약 ② 현재 배송국가명 (해외배송 활성 + 국가 2개 이상일 때만).", + "type": "basic", + "name": "Span", + "if": "{{(_global.availableShippingCountries ?? []).length > 1 && _global.modules?.['sirsoft-ecommerce']?.shipping?.international_shipping_enabled === true}}", + "props": { "className": "text-gray-500 dark:text-gray-400" }, + "text": "· {{(_global.availableShippingCountries ?? []).find(c => c.code === (_global.preferredShippingCountry ?? _global.modules?.['sirsoft-ecommerce']?.shipping?.default_country ?? 'KR'))?.name ?? (_global.preferredShippingCountry ?? 'KR')}}" + }, + { + "type": "basic", + "name": "Icon", + "props": { + "name": "chevron-down", + "className": "{{_local.showCurrencyDropdown ? 'rotate-180' : ''}} text-xs text-gray-400 dark:text-gray-500 transition-transform duration-200" + } + } + ] + } + ] + } + }, "children": [ { "comment": "트리거 배송국가 코드 (해외배송 활성 시) — 관리자 헤더는 flag 없이 코드 병기", @@ -63,9 +119,9 @@ "props": { "className": "text-xs text-gray-500 dark:text-gray-400" }, "text": "{{_global.preferredShippingCountry ?? _global.modules?.['sirsoft-ecommerce']?.shipping?.default_country ?? 'KR'}}" }, - { "type": "basic", "name": "Icon", "props": { "name": "coins", "className": "w-4 h-4" } }, + { "type": "basic", "name": "Icon", "props": { "name": "coins", "className": "text-base" } }, { "type": "basic", "name": "Span", "props": { "className": "font-medium" }, "text": "{{_global.preferredCurrency ?? _global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency ?? 'KRW'}}" }, - { "type": "basic", "name": "Icon", "props": { "name": "chevron-down", "className": "w-3 h-3" } } + { "type": "basic", "name": "Icon", "props": { "name": "chevron-down", "className": "text-xs" } } ], "actions": [ { @@ -76,7 +132,7 @@ ] }, { - "comment": "드롭다운 메뉴", + "comment": "드롭다운 메뉴 (통화 + 배송국가 2섹션). portable(0~1023px) 에서는 absolute 팝오버가 드로어 스크롤 경계에서 잘리므로 static 인라인 목록으로 전환한다. 표시 여부는 데스크톱과 동일하게 _local.showCurrencyDropdown 이 결정한다(responsive 에 if 를 두지 않으면 base if 를 그대로 상속) → 기본 접힘.", "type": "basic", "name": "Div", "if": "{{_local.showCurrencyDropdown}}", @@ -84,13 +140,34 @@ "className": "absolute right-0 top-full mt-2 w-44 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50 py-1 overflow-hidden", "role": "listbox" }, + "responsive": { + "portable": { + "comment": "드로어 안에서는 패널이 곧 본문이므로 카드 크롬(테두리/배경/좌우 패딩)을 벗긴다. 크롬을 남기면 드로어 패딩 안에 패딩이 중첩되어 칩 가용폭이 199px 로 줄고, 같은 드로어의 언어 칩(224px)과 줄바꿈 위치가 어긋난다.", + "props": { + "className": "static w-full mt-2 bg-transparent border-0 shadow-none py-0", + "role": "listbox" + } + } + }, "children": [ { + "comment": "섹션 라벨 '통화'. portable 에서는 아코디언 트리거 행이 이미 '통화' 라벨을 달고 있어 중복이므로 숨긴다(배송국가 라벨은 두 번째 섹션을 구분해야 하므로 유지).", "type": "basic", "name": "Div", "props": { "className": "px-4 py-1.5 text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wide" }, + "responsive": { "portable": { "if": "{{false}}" } }, "text": "$t:sirsoft-ecommerce.common.currency_label" }, + { + "comment": "옵션 래퍼. portable 에서만 가로 칩 나열(flex-wrap). 데스크톱은 무클래스 블록이라 기존 세로 목록 그대로. iteration 은 아래 Div 자신에 걸려 있어 항목 수만큼 복제되므로, 복제본들이 이 래퍼의 flex item 이 된다.", + "type": "basic", + "name": "Div", + "responsive": { + "portable": { + "props": { "className": "flex flex-wrap gap-2 pb-1" } + } + }, + "children": [ { "type": "basic", "name": "Div", @@ -104,22 +181,34 @@ "role": "option", "aria-selected": "{{_global.preferredCurrency === currency.code}}" }, + "responsive": { + "portable": { + "props": { + "className": "inline-flex items-center gap-1.5 px-3 py-1.5 text-sm border rounded-full cursor-pointer whitespace-nowrap transition-colors {{_global.preferredCurrency === currency.code ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 border-blue-200 dark:border-blue-800' : 'text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'}}", + "role": "option", + "aria-selected": "{{_global.preferredCurrency === currency.code}}" + } + } + }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-base" }, "text": "{{currency.flag}}" }, { "type": "basic", "name": "Span", "props": { "className": "flex-1" }, + "responsive": { "portable": { "props": { "className": "" } } }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "font-medium" }, "text": "{{currency.code}}" }, { "type": "basic", "name": "Span", "props": { "className": "text-gray-500 dark:text-gray-400 ml-1" }, "text": "({{currency.symbol}})" } ] }, { + "comment": "portable(칩 나열)에서는 선택 상태가 배경/테두리 색으로 이미 표현되고 aria-selected 로도 전달되므로, 칩 폭을 아끼려 체크 아이콘을 숨긴다.", "type": "basic", "name": "Icon", "if": "{{_global.preferredCurrency === currency.code}}", - "props": { "name": "check", "className": "w-4 h-4" } + "responsive": { "portable": { "if": "{{false}}" } }, + "props": { "name": "check", "className": "text-base" } } ], "actions": [ @@ -135,6 +224,8 @@ ] } ] + } + ] }, { "comment": "── 구분선 ──", @@ -153,8 +244,19 @@ "type": "basic", "name": "Div", "props": { "className": "px-4 py-1.5 text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wide" }, + "responsive": { "portable": { "props": { "className": "py-1 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" } } }, "text": "$t:sirsoft-ecommerce.common.shipping_country_label" }, + { + "comment": "옵션 래퍼. portable 에서만 가로 칩 나열(flex-wrap). 데스크톱은 무클래스 블록.", + "type": "basic", + "name": "Div", + "responsive": { + "portable": { + "props": { "className": "flex flex-wrap gap-2 pb-1" } + } + }, + "children": [ { "type": "basic", "name": "Div", @@ -168,13 +270,30 @@ "role": "option", "aria-selected": "{{(_global.preferredShippingCountry ?? 'KR') === shipCountry.code}}" }, + "responsive": { + "portable": { + "props": { + "className": "inline-flex items-center gap-1.5 px-3 py-1.5 text-sm border rounded-full cursor-pointer whitespace-nowrap transition-colors {{(_global.preferredShippingCountry ?? 'KR') === shipCountry.code ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 border-blue-200 dark:border-blue-800' : 'text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'}}", + "role": "option", + "aria-selected": "{{(_global.preferredShippingCountry ?? 'KR') === shipCountry.code}}" + } + } + }, "children": [ - { "type": "basic", "name": "Span", "props": { "className": "flex-1 font-medium" }, "text": "{{shipCountry.name ?? shipCountry.code}}" }, { + "type": "basic", + "name": "Span", + "props": { "className": "flex-1 font-medium" }, + "responsive": { "portable": { "props": { "className": "font-medium" } } }, + "text": "{{shipCountry.name ?? shipCountry.code}}" + }, + { + "comment": "portable(칩 나열)에서는 선택 상태를 배경/테두리 색 + aria-selected 로 전달하므로 체크 아이콘을 숨긴다.", "type": "basic", "name": "Icon", "if": "{{(_global.preferredShippingCountry ?? 'KR') === shipCountry.code}}", - "props": { "name": "check", "className": "w-4 h-4" } + "responsive": { "portable": { "if": "{{false}}" } }, + "props": { "name": "check", "className": "text-base" } } ], "actions": [ @@ -191,15 +310,18 @@ } ] } + ] + } ] } ] }, { - "comment": "배경 클릭 시 닫기", + "comment": "배경 클릭 시 닫기 — 데스크톱 드롭다운 전용. portable 은 항상 펼친 칩 목록이라 백드롭이 불필요(있으면 화면 전체를 덮어 클릭을 가로챈다).", "type": "basic", "name": "Div", "if": "{{_local.showCurrencyDropdown}}", + "responsive": { "portable": { "if": "{{false}}" } }, "props": { "className": "fixed inset-0 z-40" }, "actions": [ { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-user.json b/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-user.json index 9f908f94..1e2dbf97 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-user.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-user.json @@ -12,8 +12,15 @@ "name": "Div", "slot": "header_currency", "props": { "className": "relative {{(_global.availableCurrencies ?? []).length > 0 || ((_global.availableShippingCountries ?? []).length > 1 && _global.modules?.['sirsoft-ecommerce']?.shipping?.international_shipping_enabled === true) ? '' : 'hidden'}}" }, + "responsive": { + "portable": { + "comment": "portable 에서는 이 조각이 사용자 템플릿 모바일 드로어(세로 블록) 안에 렌더된다. 펼친 목록이 컨테이너 폭을 채우도록 w-full. hidden 게이트 표현식은 그대로 유지(props 얕은 머지가 아니라 className 통째 교체이므로 표현식을 복제해야 함).", + "props": { "className": "relative w-full {{(_global.availableCurrencies ?? []).length > 0 || ((_global.availableShippingCountries ?? []).length > 1 && _global.modules?.['sirsoft-ecommerce']?.shipping?.international_shipping_enabled === true) ? '' : 'hidden'}}" } + } + }, "children": [ { + "comment": "트리거 버튼 — 데스크톱은 absolute 드롭다운의 토글, portable(드로어 안)에서는 접기/펼치기 아코디언의 트리거. 두 경우 모두 같은 플래그(_local.showCurrencyDropdown)를 토글하므로 기본은 접힘이다. portable 에서는 드로어 폭을 꽉 채우는 행 형태(좌: '통화' 라벨, 우: 현재 통화·배송국가 요약 + chevron)로 바뀐다.", "type": "basic", "name": "Button", "props": { @@ -22,6 +29,55 @@ "aria-expanded": "{{_local.showCurrencyDropdown ?? false}}", "aria-label": "$t:sirsoft-ecommerce.common.currency_label" }, + "responsive": { + "portable": { + "comment": "드로어 안에서는 언어 아코디언 트리거와 동일한 형태 — children 을 통째 교체해 '통화' 라벨 + 현재값 요약 + chevron 으로 구성한다(엔진은 responsive.children 을 완전 교체한다).", + "props": { + "className": "w-full flex items-center justify-between py-1 cursor-pointer", + "aria-haspopup": "listbox", + "aria-expanded": "{{_local.showCurrencyDropdown ?? false}}", + "aria-label": "$t:sirsoft-ecommerce.common.currency_label" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" }, + "text": "$t:sirsoft-ecommerce.common.currency_label" + }, + { + "type": "basic", + "name": "Span", + "props": { "className": "flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300" }, + "children": [ + { + "comment": "접힘 상태 요약 ① 현재 통화 코드", + "type": "basic", + "name": "Span", + "props": { "className": "font-medium" }, + "text": "{{_global.preferredCurrency ?? _global.defaultCurrency ?? 'KRW'}}" + }, + { + "comment": "접힘 상태 요약 ② 현재 배송국가명 (해외배송 활성 + 국가 2개 이상일 때만). 통화와 시각적으로 구분되도록 옅은 색.", + "type": "basic", + "name": "Span", + "if": "{{(_global.availableShippingCountries ?? []).length > 1 && _global.modules?.['sirsoft-ecommerce']?.shipping?.international_shipping_enabled === true}}", + "props": { "className": "text-gray-500 dark:text-gray-400" }, + "text": "· {{(_global.availableShippingCountries ?? []).find(c => c.code === (_global.preferredShippingCountry ?? _global.modules?.['sirsoft-ecommerce']?.shipping?.default_country ?? 'KR'))?.name ?? (_global.preferredShippingCountry ?? 'KR')}}" + }, + { + "type": "basic", + "name": "Icon", + "props": { + "name": "chevron-down", + "className": "{{_local.showCurrencyDropdown ? 'rotate-180' : ''}} text-xs text-gray-400 dark:text-gray-500 transition-transform duration-200" + } + } + ] + } + ] + } + }, "children": [ { "comment": "트리거 라벨: 해외배송 활성이면 선택 배송국가 코드 병기 (관리자 헤더와 동일하게 코드 텍스트로 표기 — flag-icons 의존 제거).", @@ -31,9 +87,9 @@ "props": { "className": "text-xs text-gray-500 dark:text-gray-400" }, "text": "{{_global.preferredShippingCountry ?? _global.modules?.['sirsoft-ecommerce']?.shipping?.default_country ?? 'KR'}}" }, - { "type": "basic", "name": "Icon", "props": { "name": "coins", "className": "w-4 h-4" } }, + { "type": "basic", "name": "Icon", "props": { "name": "coins", "className": "text-base" } }, { "type": "basic", "name": "Span", "props": { "className": "font-medium" }, "text": "{{_global.preferredCurrency ?? _global.defaultCurrency ?? 'KRW'}}" }, - { "type": "basic", "name": "Icon", "props": { "name": "chevron-down", "className": "w-3 h-3" } } + { "type": "basic", "name": "Icon", "props": { "name": "chevron-down", "className": "text-xs" } } ], "actions": [ { @@ -44,7 +100,7 @@ ] }, { - "comment": "드롭다운 메뉴 (통화 + 배송국가 2섹션)", + "comment": "드롭다운 메뉴 (통화 + 배송국가 2섹션). portable(0~1023px) 에서는 absolute 팝오버가 드로어 스크롤 경계에서 잘리므로 static 인라인 목록으로 전환한다. 표시 여부는 데스크톱과 동일하게 _local.showCurrencyDropdown 이 결정한다(responsive 에 if 를 두지 않으면 base if 를 그대로 상속) → 기본 접힘.", "type": "basic", "name": "Div", "if": "{{_local.showCurrencyDropdown}}", @@ -52,6 +108,14 @@ "className": "absolute right-0 top-full mt-2 w-52 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50 py-1 overflow-hidden", "role": "listbox" }, + "responsive": { + "portable": { + "props": { + "className": "static w-full mt-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg py-1", + "role": "listbox" + } + } + }, "children": [ { "comment": "── 통화 섹션 ──", @@ -60,11 +124,23 @@ "if": "{{(_global.availableCurrencies ?? []).length > 0}}", "children": [ { + "comment": "섹션 라벨 '통화'. portable 에서는 아코디언 트리거 행이 이미 '통화' 라벨을 달고 있어 중복이므로 숨긴다(배송국가 라벨은 두 번째 섹션을 구분해야 하므로 유지).", "type": "basic", "name": "Div", "props": { "className": "px-4 py-1.5 text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wide" }, + "responsive": { "portable": { "if": "{{false}}" } }, "text": "$t:sirsoft-ecommerce.common.currency_label" }, + { + "comment": "옵션 래퍼. portable 에서만 가로 칩 나열(flex-wrap). 데스크톱은 무클래스 블록이라 기존 세로 목록 그대로. iteration 은 아래 Div 자신에 걸려 있어 항목 수만큼 복제되므로, 복제본들이 이 래퍼의 flex item 이 된다.", + "type": "basic", + "name": "Div", + "responsive": { + "portable": { + "props": { "className": "flex flex-wrap gap-2 px-3 pb-2" } + } + }, + "children": [ { "type": "basic", "name": "Div", @@ -78,22 +154,34 @@ "role": "option", "aria-selected": "{{_global.preferredCurrency === currency.code}}" }, + "responsive": { + "portable": { + "props": { + "className": "inline-flex items-center gap-1.5 px-3 py-1.5 text-sm border rounded-full cursor-pointer whitespace-nowrap transition-colors {{_global.preferredCurrency === currency.code ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 border-blue-200 dark:border-blue-800' : 'text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'}}", + "role": "option", + "aria-selected": "{{_global.preferredCurrency === currency.code}}" + } + } + }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-base" }, "text": "{{currency.flag}}" }, { "type": "basic", "name": "Span", "props": { "className": "flex-1" }, + "responsive": { "portable": { "props": { "className": "" } } }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "font-medium" }, "text": "{{currency.code}}" }, { "type": "basic", "name": "Span", "props": { "className": "text-gray-500 dark:text-gray-400 ml-1" }, "text": "({{currency.symbol}})" } ] }, { + "comment": "portable(칩 나열)에서는 선택 상태가 배경/테두리 색으로 이미 표현되고 aria-selected 로도 전달되므로, 칩 폭을 아끼려 체크 아이콘을 숨긴다.", "type": "basic", "name": "Icon", "if": "{{_global.preferredCurrency === currency.code}}", - "props": { "name": "check", "className": "w-4 h-4" } + "responsive": { "portable": { "if": "{{false}}" } }, + "props": { "name": "check", "className": "text-base" } } ], "actions": [ @@ -128,6 +216,8 @@ } ] } + ] + } ] }, { @@ -147,8 +237,19 @@ "type": "basic", "name": "Div", "props": { "className": "px-4 py-1.5 text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wide" }, + "responsive": { "portable": { "props": { "className": "px-3 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" } } }, "text": "$t:sirsoft-ecommerce.common.shipping_country_label" }, + { + "comment": "옵션 래퍼. portable 에서만 가로 칩 나열(flex-wrap). 데스크톱은 무클래스 블록.", + "type": "basic", + "name": "Div", + "responsive": { + "portable": { + "props": { "className": "flex flex-wrap gap-2 px-3 pb-2" } + } + }, + "children": [ { "type": "basic", "name": "Div", @@ -162,13 +263,30 @@ "role": "option", "aria-selected": "{{(_global.preferredShippingCountry ?? 'KR') === shipCountry.code}}" }, + "responsive": { + "portable": { + "props": { + "className": "inline-flex items-center gap-1.5 px-3 py-1.5 text-sm border rounded-full cursor-pointer whitespace-nowrap transition-colors {{(_global.preferredShippingCountry ?? 'KR') === shipCountry.code ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 border-blue-200 dark:border-blue-800' : 'text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'}}", + "role": "option", + "aria-selected": "{{(_global.preferredShippingCountry ?? 'KR') === shipCountry.code}}" + } + } + }, "children": [ - { "type": "basic", "name": "Span", "props": { "className": "flex-1 font-medium" }, "text": "{{shipCountry.name}}" }, { + "type": "basic", + "name": "Span", + "props": { "className": "flex-1 font-medium" }, + "responsive": { "portable": { "props": { "className": "font-medium" } } }, + "text": "{{shipCountry.name}}" + }, + { + "comment": "portable(칩 나열)에서는 선택 상태를 배경/테두리 색 + aria-selected 로 전달하므로 체크 아이콘을 숨긴다.", "type": "basic", "name": "Icon", "if": "{{(_global.preferredShippingCountry ?? 'KR') === shipCountry.code}}", - "props": { "name": "check", "className": "w-4 h-4" } + "responsive": { "portable": { "if": "{{false}}" } }, + "props": { "name": "check", "className": "text-base" } } ], "actions": [ @@ -217,15 +335,18 @@ } ] } + ] + } ] } ] }, { - "comment": "배경 클릭 시 닫기", + "comment": "배경 클릭 시 닫기 — 데스크톱 팝오버 전용. portable 에서는 패널이 드로어 안 인라인 아코디언이라 배경 개념이 없다. 여기서 if 를 끄지 않으면 모바일에서 펼치는 순간 fixed inset-0 오버레이가 드로어 전체를 덮어 모든 클릭을 가로챈다.", "type": "basic", "name": "Div", "if": "{{_local.showCurrencyDropdown}}", + "responsive": { "portable": { "if": "{{false}}" } }, "props": { "className": "fixed inset-0 z-40" }, "actions": [ { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/extensions/register-shipping-country-field.json b/modules/_bundled/sirsoft-ecommerce/resources/extensions/register-shipping-country-field.json index 48fc080f..46fbcd53 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/extensions/register-shipping-country-field.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/extensions/register-shipping-country-field.json @@ -26,7 +26,7 @@ "props": { "name": "preferred_shipping_country", "value": "{{_local?.registerForm?.preferred_shipping_country ?? _global.preferredShippingCountry ?? _global.modules?.['sirsoft-ecommerce']?.shipping?.default_country ?? 'KR'}}", - "options": "{{(_global.modules?.['sirsoft-ecommerce']?.shipping?.available_countries ?? []).filter(c => c.is_active).map(c => ({ value: c.code, label: (c.name?.[_global.locale] ?? c.name?.ko ?? c.code) }))}}", + "options": "{{(_global.modules?.['sirsoft-ecommerce']?.shipping?.available_countries ?? []).filter(c => c.is_active).map(c => ({ value: c.code, label: ($localized(c.name, 'sirsoft-ecommerce::settings.countries.' + (c.code ?? '') + '.name') || c.code) }))}}", "className": "w-full px-4 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-blue-500", "disabled": "{{_local?.isRegistering}}" } diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminHeaderCurrencySelectorPortable.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminHeaderCurrencySelectorPortable.test.tsx new file mode 100644 index 00000000..0d971302 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminHeaderCurrencySelectorPortable.test.tsx @@ -0,0 +1,134 @@ +/** + * @file adminHeaderCurrencySelectorPortable.test.tsx + * @description 관리자 헤더 통화/배송국가 주입 조각의 portable(모바일·태블릿) 칩 전환 회귀 테스트 + * + * 배경: 관리자 모바일 헤더 우측 그룹이 420px 를 요구해 폰 뷰포트에서 통화 셀렉터가 잘렸다. + * 템플릿이 통화 슬롯을 모바일 드로어(`admin_drawer_prefs`)로 옮겼고, 이 조각은 드로어 안에서 + * 접기/펼치기 아코디언(트리거 행 + static 가로 칩 목록)으로 렌더된다(사용자 조각 + * `header-currency-selector-user` 와 동일 사상). + * + * 접힘이 기본이며, 표시 여부는 데스크톱과 같은 `_local.showCurrencyDropdown` 이 결정한다 — + * portable 오버라이드에 `if` 를 두지 않으면 엔진이 base `if` 를 상속하기 때문(`override.if ?? baseDef.if`). + * 데스크톱(≥1024px)은 기존 absolute 드롭다운을 그대로 유지해야 한다. + * + * @vitest-environment jsdom + */ + +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +const fragmentPath = path.resolve( + __dirname, + '../../../extensions/header-currency-selector-admin.json', +); + +const fragment = JSON.parse(fs.readFileSync(fragmentPath, 'utf8')); +const root = fragment.injections[0].components[0]; +const [trigger, panel, backdrop] = root.children; + +describe('관리자 통화 주입 조각 — portable 칩 전환', () => { + it('데스크톱 기본 경로는 absolute 드롭다운을 유지한다 (회귀 방지)', () => { + expect(root.props.className).toContain('relative'); + expect(root.props.className).not.toContain('w-full'); + + expect(panel.props.className).toContain('absolute'); + expect(panel.props.className).toContain('z-50'); + expect(panel.props.className).toContain('w-44'); + expect(panel.if).toBe('{{_local.showCurrencyDropdown}}'); + }); + + it('portable 에서 루트가 w-full 이 되고 hidden 게이트 표현식이 보존된다', () => { + const cls = root.responsive.portable.props.className; + expect(cls).toContain('w-full'); + // props 는 얕은 머지가 아니라 className 통째 교체이므로 표현식을 복제해야 한다. + expect(cls).toContain('availableCurrencies'); + expect(cls).toContain('international_shipping_enabled'); + }); + + it('portable 트리거는 아코디언 행이 되고 접힘 상태 요약(통화·배송국가)을 노출한다', () => { + expect(trigger.name).toBe('Button'); + + const t = trigger.responsive.portable; + // 더 이상 숨기지 않는다 — 드로어 폭을 채우는 트리거 행 + expect(t.props.className).not.toBe('hidden'); + expect(t.props.className).toContain('w-full'); + expect(t.props.className).toContain('justify-between'); + // 얕은 머지가 아니라 통째 교체이므로 aria-* 를 오버라이드에도 명시 + expect(t.props['aria-expanded']).toBe('{{_local.showCurrencyDropdown ?? false}}'); + + // 접힘 상태에서 현재 값을 알 수 있어야 한다 + const summary = JSON.stringify(t.children); + expect(summary).toContain('preferredCurrency'); + expect(summary).toContain('preferredShippingCountry'); + expect(summary).toContain('chevron-down'); + expect(summary).toContain("{{_local.showCurrencyDropdown ? 'rotate-180' : ''}}"); + }); + + it('portable 패널도 _local.showCurrencyDropdown 으로 게이트된다 (기본 접힘)', () => { + const p = panel.responsive.portable; + // 핵심: `{{true}}` 로 강제하면 드로어에서 항상 펼쳐져 아코디언이 성립하지 않는다. + // if 를 생략해야 base if 를 상속한다. + expect(p.if).toBeUndefined(); + expect(p.props.className).toContain('static'); + expect(p.props.className).not.toContain('absolute'); + expect(p.props.className).not.toContain('z-50'); + // 얕은 머지라 role 을 재선언하지 않으면 유실된다. + expect(p.props.role).toBe('listbox'); + }); + + it('portable 백드롭은 렌더되지 않는다 (fixed inset-0 이 드로어 전체 클릭을 가로챈다)', () => { + expect(backdrop.props.className).toContain('fixed'); + expect(backdrop.responsive.portable.if).toBe('{{false}}'); + }); + + it('칩 래퍼는 정적이고 iteration 은 그 자식에 걸린다', () => { + // 엔진은 iteration 을 가진 노드를 항목 수만큼 복제한다. flex 컨테이너에 걸면 + // 컨테이너가 복제되어 칩이 세로로 쌓인다. + const wrappers = panel.children.filter( + (c: any) => c.responsive?.portable?.props?.className?.includes('flex-wrap'), + ); + // 통화 섹션 래퍼 1 + 배송국가 섹션 래퍼 1 + const shippingSection = panel.children.find( + (c: any) => c.comment?.includes('배송국가 섹션'), + ); + const shippingWrapper = shippingSection.children.find( + (c: any) => c.responsive?.portable?.props?.className?.includes('flex-wrap'), + ); + + expect(wrappers.length).toBe(1); // 통화 섹션은 panel 직계 + expect(shippingWrapper).toBeTruthy(); + + for (const wrapper of [wrappers[0], shippingWrapper]) { + expect(wrapper.iteration).toBeUndefined(); + const iterating = wrapper.children[0]; + expect(iterating.iteration).toBeTruthy(); + } + }); + + it('portable 옵션은 rounded-full 칩이고 체크 아이콘은 숨는다', () => { + const currencyWrapper = panel.children.find( + (c: any) => c.responsive?.portable?.props?.className?.includes('flex-wrap'), + ); + const button = currencyWrapper.children[0].children[0]; + + const chipCls = button.responsive.portable.props.className; + expect(chipCls).toContain('rounded-full'); + expect(chipCls).toContain('inline-flex'); + expect(chipCls).toContain('whitespace-nowrap'); + // 얕은 머지 — role/aria-selected 재선언 필수 + expect(button.responsive.portable.props.role).toBe('option'); + expect(button.responsive.portable.props['aria-selected']).toBeTruthy(); + + const check = button.children.find((c: any) => c.name === 'Icon'); + expect(check.responsive.portable.if).toBe('{{false}}'); + }); + + it('portable 패널은 드로어 안에서 카드 크롬을 벗는다 (언어 칩과 줄바꿈 정렬)', () => { + const cls = panel.responsive.portable.props.className; + // 크롬을 남기면 드로어 패딩 안에 패딩이 중첩되어 칩 가용폭이 줄고 언어 칩과 어긋난다. + expect(cls).toContain('bg-transparent'); + expect(cls).toContain('border-0'); + expect(cls).toContain('shadow-none'); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminMobileViewportOverflow.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminMobileViewportOverflow.test.tsx new file mode 100644 index 00000000..9d0ff405 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminMobileViewportOverflow.test.tsx @@ -0,0 +1,247 @@ +/** + * @file adminMobileViewportOverflow.test.tsx + * @description 관리자 모바일 뷰포트 넘침/압착 회귀 테스트 (이커머스 소유분) + * + * 각 단언은 브라우저 실측(390px / 320px)으로 확인된 결함에 1:1 대응한다. + * 되돌리면 해당 결함이 그대로 재발하므로, 사유를 각 테스트에 남긴다. + * + * @vitest-environment jsdom + */ + +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +const layoutsDir = path.resolve(__dirname, '../../../layouts/admin'); + +const load = (rel: string): any => + JSON.parse(fs.readFileSync(path.resolve(layoutsDir, rel), 'utf8')); + +/** 트리 전체를 순회하며 술어를 만족하는 노드를 모은다. */ +function collect(node: any, pred: (n: any) => boolean, acc: any[] = []): any[] { + if (!node || typeof node !== 'object') return acc; + if (Array.isArray(node)) { + for (const n of node) collect(n, pred, acc); + return acc; + } + if (pred(node)) acc.push(node); + for (const v of Object.values(node)) { + if (v && typeof v === 'object') collect(v, pred, acc); + } + return acc; +} + +const cls = (n: any): string => { + const c = n?.props?.className; + return typeof c === 'string' ? c : ''; +}; + +describe('B1 — 모달 좌우 여백 (max-w-full 트랩)', () => { + // .modal-container 의 max-w-[calc(100vw-2rem)] 와 .max-w-full 은 명시도가 같고(0,1,0) + // 빌드 CSS 에서 .max-w-full 이 뒤에 정의되므로 승리한다. 레이아웃에서 max-w-full 을 + // 주면 clamp 가 무효화되어 모달이 화면 좌우에 붙는다(여백 0). + it.each([ + 'partials/admin_ecommerce_order_detail/_modal_cancel_order.json', + 'partials/admin_ecommerce_order_detail/_modal_batch_change_confirm.json', + ])('%s 는 max-w-full 을 갖지 않는다', (rel) => { + const raw = fs.readFileSync(path.resolve(layoutsDir, rel), 'utf8'); + expect(raw).not.toContain('max-w-full'); + }); +}); + +describe('B2 — 주문상세 모달 상품표 카드형 전환', () => { + it.each([ + ['_modal_cancel_order.json', 'partials/admin_ecommerce_order_detail/_modal_cancel_order.json'], + ['_modal_batch_change_confirm.json', 'partials/admin_ecommerce_order_detail/_modal_batch_change_confirm.json'], + ])('%s: grid-cols-12 행이 portable 에서 1열로 스택된다', (_n, rel) => { + const layout = load(rel); + const grids = collect(layout, (n) => cls(n).includes('grid-cols-12')); + + // 헤더 행 1 + 본문(iteration) 행 1 + expect(grids.length).toBe(2); + + const header = grids.find((g) => cls(g).includes('bg-gray-50')); + const body = grids.find((g) => !cls(g).includes('bg-gray-50')); + + // 카드형에서는 열 라벨 행이 의미 없다 → 미렌더 + expect(header.responsive?.portable?.if).toBe('{{false}}'); + + // 390px 에서 12열이면 1열 = 16.4px → `249,000원`(56px) 같은 토큰이 숫자 내부에서 쪼개진다. + expect(body.responsive?.portable?.props?.className).toContain('grid-cols-1'); + expect(body.responsive?.portable?.props?.className).not.toContain('grid-cols-12'); + }); + + it.each([ + 'partials/admin_ecommerce_order_detail/_modal_cancel_order.json', + 'partials/admin_ecommerce_order_detail/_modal_batch_change_confirm.json', + ])('%s: 모든 col-span 셀이 portable 에서 span 을 벗는다', (rel) => { + const layout = load(rel); + const spanCells = collect(layout, (n) => /col-span-\d+/.test(cls(n))); + expect(spanCells.length).toBeGreaterThan(0); + + for (const cell of spanCells) { + const portable = cell.responsive?.portable?.props?.className; + expect(portable).toBeDefined(); + // 1열 그리드에 col-span-5 가 남으면 암묵 열이 생겨 셀 폭이 들쭉날쭉해진다. + expect(portable).not.toMatch(/col-span-\d+/); + } + }); + + // 세로 스택으로 셀이 전폭(285px)이 되어 현재 데이터로는 절단이 없다(10억대 금액도 88px). + // 그러나 셀이 다시 좁아지면 즉시 재발하므로, 방어선을 코드에 선언해 둔다. + // 실측(16px 셀로 축소): + // - 금액 순수 토큰 + whitespace-nowrap → 1줄 유지, 숫자 내부 미분리 + // - `소계 249,000원`(한글+금액 혼합) + break-keep → `소계` / `249,000원` 2줄, 숫자 보존 + // (이 문자열에 nowrap 을 주면 셀을 65px 넘겨 더 나쁘다 — 그래서 break-keep) + it('금액(순수 숫자) 노드는 whitespace-nowrap 으로 숫자 내부 줄바꿈을 막는다', () => { + const cancel = load('partials/admin_ecommerce_order_detail/_modal_cancel_order.json'); + const batch = load('partials/admin_ecommerce_order_detail/_modal_batch_change_confirm.json'); + + const unitPrice = collect( + cancel, + (n) => typeof n.text === 'string' && n.text.includes('unit_price') && n.text.includes('원'), + )[0]; + expect(cls(unitPrice)).toContain('whitespace-nowrap'); + + const batchAmounts = collect( + batch, + (n) => typeof n.text === 'string' && /Number\(confirmItem\.(original_price|unit_price)\)/.test(n.text), + ); + expect(batchAmounts.length).toBe(2); + for (const node of batchAmounts) { + expect(cls(node)).toContain('whitespace-nowrap'); + } + }); + + it('한글 라벨 노드는 break-keep 으로 음절 경계 절단을 막는다', () => { + const cancel = load('partials/admin_ecommerce_order_detail/_modal_cancel_order.json'); + const batch = load('partials/admin_ecommerce_order_detail/_modal_batch_change_confirm.json'); + + // `소계 249,000원` — 한글 라벨 + 금액 혼합. nowrap 이 아니라 break-keep 이 정답. + const subtotal = collect( + cancel, + (n) => typeof n.text === 'string' && n.text.includes('product_subtotal'), + )[0]; + expect(cls(subtotal)).toContain('break-keep'); + expect(cls(subtotal)).not.toContain('whitespace-nowrap'); + + // 상태 배지 (`결제완료` → `결제완`/`료` 방지) + const cancelStatus = collect( + cancel, + (n) => typeof n.text === 'string' && n.text.includes('option_status_label'), + )[0]; + expect(cls(cancelStatus)).toContain('break-keep'); + + const batchStatus = collect( + batch, + (n) => typeof n.text === 'string' && n.text.includes('order.status.payment_complete'), + )[0]; + expect(cls(batchStatus)).toContain('break-keep'); + }); +}); + +describe('B3 — 은행 관리 모달', () => { + const rel = 'partials/admin_ecommerce_settings/_bank_management_modal.json'; + + it('은행코드 입력칸에 min-w-[80px] 하한이 있다', () => { + const layout = load(rel); + // 은행코드 input 은 code 오류 키를 참조하는 className 바인딩으로 식별한다. + const input = collect( + layout, + (n) => n.name === 'Input' && cls(n).includes("+ '.code'"), + )[0]; + + expect(input).toBeTruthy(); + // table-layout:auto + td{min-width:0} 이라 좁은 뷰포트에서 input 이 24px(=좌우 padding)로 + // 압착되어 텍스트 영역이 0px 이 된다. width 지정은 auto 레이아웃이 무시하므로 min-width 필요. + expect(cls(input)).toContain('min-w-[80px]'); + // 숫자 스케일 min-w-16 은 safelist 에 없어 CSS 가 생성되지 않는다(무효). + expect(cls(input)).not.toContain('min-w-16'); + }); + + it('표 래퍼가 overflow-x-auto 를 명시한다', () => { + const layout = load(rel); + const wrapper = collect(layout, (n) => cls(n).includes('max-h-96'))[0]; + expect(wrapper).toBeTruthy(); + // overflow-y 만 선언해도 computed 는 auto 가 되지만 암묵적 의존이다. + expect(cls(wrapper)).toContain('overflow-x-auto'); + }); +}); + +describe('C2 — 쿠폰 발급기간 입력 행', () => { + const rel = 'partials/admin_ecommerce_promotion_coupon_form/_partial_issue_settings.json'; + + it('portable 에서 행이 줄바꿈하고 datetime 입력이 전폭이 된다', () => { + const layout = load(rel); + const row = collect(layout, (n) => n.id === 'issue_period_range')[0]; + expect(row).toBeTruthy(); + // flex-center 는 nowrap. w-52(208px) 두 개가 좁은 뷰포트를 넘긴다. + expect(row.responsive.portable.props.className).toContain('flex-wrap'); + + const inputs = collect(row, (n) => n.name === 'Input'); + expect(inputs.length).toBe(2); + for (const i of inputs) { + expect(i.responsive.portable.props.className).toContain('w-full'); + expect(i.responsive.portable.props.className).not.toContain('w-52'); + } + }); +}); + +describe('C3 — 이커머스 환경설정 넘침', () => { + it('탭바는 sticky-tab-nav-responsive 를 쓴다 (단계적 패딩과 일치)', () => { + const layout = load('admin_ecommerce_settings.json'); + const nav = collect(layout, (n) => n.id === 'tab_navigation')[0]; + expect(nav).toBeTruthy(); + // 콘텐츠 래퍼가 p-4 sm:p-6 lg:p-8 이라 고정 24px 를 가정하는 sticky-tab-nav(-mx-6 px-6)는 + // 폰에서 좌우 8px 씩 과다 bleed 된다. + expect(nav.props.className).toBe('sticky-tab-nav-responsive'); + }); + + it('이메일 행이 portable 에서 줄바꿈한다 (rcaExcess 71px 의 진범)', () => { + const layout = load('partials/admin_ecommerce_settings/_tab_basic_info.json'); + const emailGroup = collect(layout, (n) => n.id === 'field_email')[0]; + expect(emailGroup).toBeTruthy(); + const row = collect(emailGroup, (n) => cls(n).startsWith('flex-center'))[0]; + expect(row.responsive.portable.props.className).toContain('flex-wrap'); + }); + + it('w-80 고정폭 입력은 mobile(0~767px) 에서만 전폭이 된다', () => { + const layout = load('partials/admin_ecommerce_settings/_tab_basic_info.json'); + const w80 = collect(layout, (n) => n.name === 'Input' && cls(n).includes('w-80')); + expect(w80.length).toBeGreaterThanOrEqual(2); + + const overridden = w80.filter((n) => n.responsive); + expect(overridden.length).toBe(2); // 쇼핑몰 이름 + 종목 + + for (const n of overridden) { + // 태블릿(768~1023px)은 w-80 이 넘치지 않으므로 portable 로 잡으면 불필요하게 늘어난다. + expect(n.responsive.mobile).toBeDefined(); + expect(n.responsive.portable).toBeUndefined(); + expect(n.responsive.mobile.props.className).toContain('w-full'); + } + }); +}); + +describe('C5 — 엑셀 다운로드 날짜 필터 행', () => { + it('부모가 flex-wrap 이어도 자식 행에 flex-wrap 이 필요하다', () => { + const layout = load('admin_ecommerce_excel_download_index.json'); + const parent = collect(layout, (n) => n.id === 'date_filter_row')[0]; + expect(parent).toBeTruthy(); + expect(cls(parent)).toContain('flex-wrap'); + + // 부모만 wrap 이면 nowrap 인 자식 행(입력 2개 × w-36)이 11px 넘친다. + const child = (parent.children ?? []).find((c: any) => cls(c).startsWith('flex-center')); + expect(child).toBeTruthy(); + expect(cls(child)).toContain('flex-wrap'); + }); +}); + +describe('C6 — 도서산간 템플릿 모달 표', () => { + it('표 컨테이너가 overflow-x-auto 를 명시한다', () => { + const layout = load('partials/admin_ecommerce_shipping_policy_form/_modal_extra_fee_template.json'); + const wrapper = collect(layout, (n) => n.id === 'template_list')[0]; + expect(wrapper).toBeTruthy(); + // 현재는 computed 가 우연히 auto 라 동작하지만, overflow-y-visible 로 바뀌면 즉시 표가 잘린다. + expect(cls(wrapper)).toContain('overflow-x-auto'); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingCountryLayouts.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingCountryLayouts.test.tsx index c66cc957..a860f78b 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingCountryLayouts.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingCountryLayouts.test.tsx @@ -116,6 +116,141 @@ describe('header-currency-selector-user.json — 헤더 공용 셀렉터 2섹션 expect(raw).toContain('g7_preferred_shipping_country'); }); + /** + * 사용자 템플릿(sirsoft-basic)은 390px 뷰포트 오버플로(+11px) 해소를 위해 이 슬롯을 + * 헤더 우측 그룹에서 모바일 드로어(overflow-y-auto + w-80)로 옮겼다. 드로어에서는 + * absolute 드롭다운 패널이 스크롤 경계에서 잘리므로, 주입 노드 자체가 responsive.portable + * (0~1023px) 오버라이드로 static 인라인 목록으로 전환한다. + * + * 표시 여부는 데스크톱과 동일하게 `_local.showCurrencyDropdown` 이 결정한다 — portable 에 + * `if` 를 두지 않으면 엔진이 base `if` 를 그대로 상속하므로(`override.if ?? baseDef.if`) + * 드로어에서도 기본 접힘이 되고, 트리거가 접기/펼치기 아코디언 역할을 한다. + * + * SlotContainer 는 주입 자식에게 variant/context prop 을 전달하지 않으므로 컨테이너별로 + * 다르게 그릴 방법이 이것뿐이다. 데스크톱(≥1024px)은 기존 드롭다운을 그대로 유지한다. + */ + it('트리거 버튼은 portable 에서 아코디언 행이 되고 접힘 상태 요약(통화·배송국가)을 노출한다', () => { + const trigger = walk(root, (n) => n.name === 'Button' && Array.isArray(n.actions) && + n.actions.some((a: any) => a.handler === 'setState' && a.params?.showCurrencyDropdown !== undefined)); + expect(trigger).toBeTruthy(); + + const portable = trigger.responsive?.portable; + expect(portable).toBeTruthy(); + // 더 이상 숨기지 않는다 — 드로어 폭을 채우는 트리거 행 + expect(portable.props.className).not.toBe('hidden'); + expect(portable.props.className).toContain('w-full'); + expect(portable.props.className).toContain('justify-between'); + // 접근성: 얕은 머지가 아니라 통째 교체이므로 aria-* 를 오버라이드에도 명시 + expect(portable.props['aria-expanded']).toBe('{{_local.showCurrencyDropdown ?? false}}'); + + // 접힘 상태에서 현재 값을 알 수 있어야 한다 (요약 = 통화 + 배송국가) + const summary = JSON.stringify(portable.children); + expect(summary).toContain('preferredCurrency'); + expect(summary).toContain('preferredShippingCountry'); + expect(summary).toContain('chevron-down'); + expect(summary).toContain("{{_local.showCurrencyDropdown ? 'rotate-180' : ''}}"); + }); + + it('패널은 portable 에서도 _local.showCurrencyDropdown 으로 게이트된다 (기본 접힘)', () => { + const panel = (root.children ?? []).find( + (n: any) => n.name === 'Div' && n.props?.role === 'listbox' + ); + expect(panel).toBeTruthy(); + + // 데스크톱 기본 경로 — 회귀 방지 + expect(panel.if).toBe('{{_local.showCurrencyDropdown}}'); + expect(panel.props.className).toContain('absolute'); + expect(panel.props.className).toContain('z-50'); + + const portable = panel.responsive?.portable; + expect(portable).toBeTruthy(); + // 핵심: portable 에 if 를 두지 않아야 base if 를 상속해 기본 접힘이 된다. + // `{{true}}` 로 강제하면 드로어에서 항상 펼쳐져 아코디언이 성립하지 않는다. + expect(portable.if).toBeUndefined(); + expect(portable.props.className).toContain('static'); + expect(portable.props.className).not.toContain('absolute'); + expect(portable.props.className).not.toContain('z-50'); + // 접근성 role 은 props 얕은 머지에서 유실되지 않도록 오버라이드에도 명시 + expect(portable.props.role).toBe('listbox'); + }); + + it('배경 클릭 오버레이는 portable 에서 꺼진다 (드로어 전체를 덮어 클릭을 가로채는 회귀 방지)', () => { + const backdrop = (root.children ?? []).find( + (n: any) => n.name === 'Div' && (n.props?.className ?? '').includes('fixed inset-0') + ); + expect(backdrop).toBeTruthy(); + expect(backdrop.if).toBe('{{_local.showCurrencyDropdown}}'); + expect(backdrop.responsive?.portable?.if).toBe('{{false}}'); + }); + + /** + * 모바일(portable)에서는 통화·배송국가를 세로 목록이 아니라 가로 나열 칩으로 그린다. + * 세로 목록은 좁은 드로어에서 세로 공간을 과하게 먹는다. + * + * 엔진의 `iteration` 은 그 속성을 가진 노드 자신을 항목 수만큼 복제한다. 따라서 옵션들을 + * 한 줄에 흘리려면 iteration 노드의 **부모**가 flex 컨테이너여야 한다 → 래퍼 Div 를 두고 + * 그 래퍼에만 responsive.portable 로 `flex flex-wrap` 을 준다(데스크톱은 무클래스 블록). + */ + const findSection = (labelKey: string) => { + const panel = (root.children ?? []).find((c: any) => c.props?.role === 'listbox'); + return (panel.children ?? []).find( + (c: any) => JSON.stringify(c).includes(labelKey) && Array.isArray(c.children) + ); + }; + + for (const [name, labelKey] of [ + ['통화', 'common.currency_label'], + ['배송국가', 'common.shipping_country_label'], + ] as const) { + it(`${name} 섹션: iteration 노드의 부모 래퍼가 portable 에서 flex-wrap 이다 (가로 칩 나열)`, () => { + const section = findSection(labelKey); + expect(section).toBeTruthy(); + + // [0] = 라벨, [1] = 옵션 래퍼 + const wrapper = section.children[1]; + expect(wrapper.name).toBe('Div'); + // 데스크톱은 무클래스(블록 흐름) — 기존 세로 목록 유지 + expect(wrapper.props?.className).toBeUndefined(); + + const portableCls = wrapper.responsive?.portable?.props?.className ?? ''; + expect(portableCls).toContain('flex'); + expect(portableCls).toContain('flex-wrap'); + + // iteration 은 래퍼가 아니라 그 자식에 있어야 한다 (래퍼가 복제되면 칩이 세로로 쌓임) + expect(wrapper.iteration).toBeUndefined(); + expect(wrapper.children[0].iteration).toBeTruthy(); + }); + + it(`${name} 섹션: 옵션 버튼이 portable 에서 칩(rounded-full) 이고 데스크톱은 w-full 행이다`, () => { + const section = findSection(labelKey); + const iterNode = section.children[1].children[0]; + const button = iterNode.children[0]; + expect(button.name).toBe('Button'); + + // 데스크톱 기본 — 전체폭 행 + expect(button.props.className).toContain('w-full'); + expect(button.props.className).not.toContain('rounded-full'); + + // portable — 가로 칩 + const chip = button.responsive?.portable?.props?.className ?? ''; + expect(chip).toContain('inline-flex'); + expect(chip).toContain('rounded-full'); + expect(chip).toContain('whitespace-nowrap'); + expect(chip).not.toContain('w-full'); + // 접근성 속성은 props 얕은 머지에서 유실되지 않도록 오버라이드에도 유지 + expect(button.responsive.portable.props.role).toBe('option'); + expect(button.responsive.portable.props['aria-selected']).toBeTruthy(); + }); + + it(`${name} 섹션: 선택 체크 아이콘은 portable 에서 숨긴다 (칩 폭 절약, 선택은 색+aria-selected 로 전달)`, () => { + const section = findSection(labelKey); + const button = section.children[1].children[0].children[0]; + const check = (button.children ?? []).find((c: any) => c.props?.name === 'check'); + expect(check).toBeTruthy(); + expect(check.responsive?.portable?.if).toBe('{{false}}'); + }); + } + it('트리거 라벨 = 배송국가 코드 텍스트 (관리자 헤더와 동일, flag-icons 비의존)', () => { // 트리거(통화 버튼) 안의 첫 Span 이 배송국가 코드를 텍스트로 표시해야 한다. const trigger = walk(root, (n) => n.name === 'Button' && Array.isArray(n.actions) && @@ -385,6 +520,182 @@ describe('D10 (defect #3) — 마이페이지 주소록 모달 국가 Select + }); }); +/** + * 배송국가명 다국어 표시 회귀 (#459) + * + * 두 가지 회귀를 동시에 차단한다. + * + * ① `_global.locale` 은 존재하지 않는 경로다. 엔진은 로케일을 최상위 `$locale` 로만 노출하므로 + * (template-engine.ts::createGlobalVariables) 이를 인덱스로 쓰면 표현식이 항상 undefined 가 되어 + * `?? name?.ko` 폴백으로 떨어진다 → 어떤 로케일에서도 한국어 국가명이 표시된다. + * + * ② `$localized(value)` 를 fallbackKey 없이 호출하면, 저장본(settings/shipping.json)에 해당 로케일 + * 키가 없을 때 헬퍼가 ko 로 폴백한다. 기본 10개국은 언어팩이 라벨을 갖고 있으므로 fallbackKey + * (`sirsoft-ecommerce::settings.countries.{code}.name`)를 넘겨야 언어팩 보강이 적용된다. + * 이미 마이그레이션된 _shipping_country_table/_cards 와 동일한 계약이다(catalogLangPackFallback.test). + */ +describe('#459 — 배송국가명 다국어 표시 ($localized + fallbackKey)', () => { + /** 국가 lang key 접두사 — 저장본에 로케일 키가 없을 때 언어팩으로 보강되는 경로 */ + const COUNTRY_LANG_PREFIX = 'sirsoft-ecommerce::settings.countries.'; + + /** [설명 라벨, 파일 경로, base] */ + const surfaces: Array<[string, string, string]> = [ + ['사용자 헤더 셀렉터', 'layouts/_user_base.json', templateRoot], + ['관리자 헤더 셀렉터', 'resources/extensions/header-currency-selector-admin.json', moduleRoot], + ['회원가입 배송국가 필드', 'resources/extensions/register-shipping-country-field.json', moduleRoot], + ['관리자 회원 배송국가 필드', 'resources/extensions/admin-user-shipping-country-field.json', moduleRoot], + [ + '관리자 주문상세 배송국가', + 'resources/layouts/admin/partials/admin_ecommerce_order_detail/_partial_order_info.json', + moduleRoot, + ], + ['배송설정 기본국가 Select', 'resources/layouts/admin/partials/admin_ecommerce_settings/_tab_shipping.json', moduleRoot], + ]; + + for (const [label, rel, base] of surfaces) { + it(`${label}: 존재하지 않는 경로 _global.locale 을 인덱스로 쓰지 않는다`, () => { + const raw = serialize(rel, base); + expect(raw).not.toMatch(/\[\s*_global\.locale\s*\]/); + }); + } + + /** + * 국가명을 표시하는 각 표현식이 $localized 를 fallbackKey 와 함께 호출하는지 검증한다. + * `needle` 은 그 표현식을 담은 줄을 특정하기 위한 고유 문자열이다. + */ + const localizedCalls: Array<[string, string, string, string]> = [ + ['사용자 헤더 파생', 'layouts/_user_base.json', templateRoot, '"availableShippingCountries"'], + [ + '관리자 헤더 파생', + 'resources/extensions/header-currency-selector-admin.json', + moduleRoot, + '"availableShippingCountries"', + ], + [ + '회원가입 Select options', + 'resources/extensions/register-shipping-country-field.json', + moduleRoot, + '"options"', + ], + [ + '관리자 회원폼 Select options', + 'resources/extensions/admin-user-shipping-country-field.json', + moduleRoot, + '"options"', + ], + [ + '배송설정 기본국가 Select options', + 'resources/layouts/admin/partials/admin_ecommerce_settings/_tab_shipping.json', + moduleRoot, + '"options"', + ], + ]; + + for (const [label, rel, base, needle] of localizedCalls) { + it(`${label}: $localized 를 국가 lang key fallbackKey 와 함께 호출한다`, () => { + const line = serialize(rel, base) + .split('\n') + .find((l) => l.includes(needle) && l.includes('$localized(')); + expect(line, `${needle} 를 담은 $localized 호출 줄 존재`).toBeTruthy(); + expect(line!).toContain(COUNTRY_LANG_PREFIX); + // 폴백 체인은 헬퍼 + 언어팩이 담당한다 — 레이아웃에서 ko 를 명시 폴백하지 않는다. + expect(line!).not.toContain('name?.ko'); + }); + } + + it('관리자 주문상세 Select options: $localized + fallbackKey', () => { + const line = serialize( + 'resources/layouts/admin/partials/admin_ecommerce_order_detail/_partial_order_info.json' + ) + .split('\n') + .find((l) => l.includes('available_countries') && l.includes('"options"')); + expect(line, '배송국가 Select options 존재').toBeTruthy(); + expect(line!).toContain('$localized('); + expect(line!).toContain(COUNTRY_LANG_PREFIX); + expect(line!).not.toContain('name?.ko'); + }); + + it('관리자 주문상세: 해외배송 OFF 시 표시되는 주문 스냅샷 국가명도 $localized 로 현지화된다', () => { + // 주문 스냅샷(recipient_country_name)은 주문 시점 다국어 JSON — 언어팩 보강 대상이 아니므로 + // fallbackKey 없이 $localized 단독 호출로 충분하다(활성 로케일 → ko → en → 첫 값). + const line = serialize( + 'resources/layouts/admin/partials/admin_ecommerce_order_detail/_partial_order_info.json' + ) + .split('\n') + .find((l) => l.includes('recipient_country_name') && l.includes('"text"')); + expect(line, '스냅샷 국가명 표시 노드 존재').toBeTruthy(); + expect(line!).toContain('$localized('); + expect(line!).not.toContain('name?.ko'); + }); +}); + +/** + * 배송국가 추가 폼 다국어 입력 회귀 (#459) + * + * 운영자가 직접 추가한 국가는 언어팩에 lang key 가 없으므로 보강 대상이 아니다. 따라서 활성 언어팩 + * 전체(`$locales`)에 대해 이름을 입력할 수 있어야 하며, ko/en 두 칸 하드코딩이면 ja 등 나머지 + * 로케일에는 영원히 이름을 넣을 수 없다. 백엔드 검증은 이미 로케일 무제한(`name.*`)이다. + */ +describe('#459 — 배송국가 추가 폼 다국어 입력 ($locales 순회)', () => { + const json = read('resources/layouts/admin/partials/admin_ecommerce_settings/_tab_shipping.json'); + const raw = serialize('resources/layouts/admin/partials/admin_ecommerce_settings/_tab_shipping.json'); + + const addForm = walk(json, (n) => n.id === 'add_country_form'); + + it('국가 추가 폼이 존재한다', () => { + expect(addForm).toBeTruthy(); + }); + + it('국가명 입력칸이 $locales 순회로 생성된다 (ko/en 하드코딩 제거)', () => { + const iterNode = walk(addForm, (n) => typeof n.iteration?.source === 'string' + && n.iteration.source.includes('$locales')); + expect(iterNode, '$locales 를 순회하는 입력칸 노드 존재').toBeTruthy(); + + // 로케일별 입력칸은 newCountry.name[] 로 써야 한다 — 고정 .ko/.en 경로 금지 + const formRaw = JSON.stringify(addForm); + expect(formRaw).not.toContain('newCountry.name.ko'); + expect(formRaw).not.toContain('newCountry.name.en'); + expect(formRaw).not.toContain('newCountry?.name?.ko'); + expect(formRaw).not.toContain('newCountry?.name?.en'); + }); + + it('입력칸 라벨이 로케일 표시명(localeNames)으로 동적 생성된다', () => { + const formRaw = JSON.stringify(addForm); + expect(formRaw).toContain('localeNames'); + // 고정 라벨 키(country_name_ko/en) 잔존 0 + expect(formRaw).not.toContain('countries.country_name_ko'); + expect(formRaw).not.toContain('countries.country_name_en'); + }); + + it('추가 버튼 활성 조건이 특정 로케일(ko)에 고정되지 않는다', () => { + const formRaw = JSON.stringify(addForm); + // 최소 하나의 로케일에 이름이 있으면 추가 가능 — 백엔드 validateCountryNames 와 동일 계약 + expect(formRaw).not.toContain('!_local.newCountry?.name?.ko'); + }); + + it('신규 국가 초기 상태가 $locales 기반으로 만들어진다 (빈 ko/en 리터럴 금지)', () => { + const addButton = walk(json, (n) => n.id === 'add_country_button'); + expect(addButton).toBeTruthy(); + const init = JSON.stringify(addButton.actions); + expect(init).not.toContain('"ko": ""'); + expect(init).not.toContain('"en": ""'); + }); + + it('배송국가 카드/테이블에서 이름 편집이 가능하면 동일하게 $locales 를 순회한다', () => { + // 편집 진입점이 없다면(현재 구조) 이 테스트는 자동 통과 — 추가 시 회귀 차단용 가드 + for (const rel of [ + 'resources/layouts/admin/partials/admin_ecommerce_settings/_shipping_country_table.json', + 'resources/layouts/admin/partials/admin_ecommerce_settings/_shipping_country_cards.json', + ]) { + const r = serialize(rel); + if (r.includes('name.ko') || r.includes('name.en')) { + expect(r, `${rel} 이름 편집이 로케일 하드코딩`).toContain('$locales'); + } + } + expect(raw).toBeTruthy(); + }); +}); + describe('D7 (defect #5) — 관리자 주문상세 국내/해외 분기 폼', () => { const json = read('resources/layouts/admin/partials/admin_ecommerce_order_detail/_partial_order_info.json'); const raw = serialize('resources/layouts/admin/partials/admin_ecommerce_order_detail/_partial_order_info.json'); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingPolicySettingsTab.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingPolicySettingsTab.test.tsx index 645491be..9085713f 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingPolicySettingsTab.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingPolicySettingsTab.test.tsx @@ -210,33 +210,46 @@ describe('배송설정 탭 콘텐츠 (_tab_shipping.json)', () => { expect(addForm.if).toContain('international_shipping_enabled'); }); - it('국가 추가 폼에 코드/한국어명/영문명 Input이 있다', () => { + it('국가 추가 폼에 코드 Input + 설치 언어마다 반복되는 국가명 Input이 있다', () => { + // #459: 이름 입력칸은 ko/en 두 칸 고정이 아니라 $locales 순회로 언어 수만큼 생성된다. + // 따라서 정적 노드 트리에는 name Input 이 "템플릿 1개"로만 존재한다. const addForm = findById(allNodes, 'add_country_form'); const formNodes = flattenAll(addForm); const inputs = formNodes.filter((n: any) => n.name === 'Input'); - // code, name.ko, name.en => 3개 - expect(inputs.length).toBeGreaterThanOrEqual(3); - // code input + const codeInput = inputs.find((i: any) => i.props?.value?.includes('newCountry?.code')); expect(codeInput).toBeDefined(); expect(codeInput.props?.maxLength).toBe(10); - // name.ko input - const koInput = inputs.find((i: any) => i.props?.value?.includes('newCountry?.name?.ko')); - expect(koInput).toBeDefined(); - // name.en input - const enInput = inputs.find((i: any) => i.props?.value?.includes('newCountry?.name?.en')); - expect(enInput).toBeDefined(); + + // 로케일 인덱싱 이름 입력칸 (name?.[loc]) — 특정 로케일 하드코딩이 아니어야 한다 + const nameInput = inputs.find((i: any) => i.props?.value?.includes('newCountry?.name?.[loc]')); + expect(nameInput).toBeDefined(); + + // 그 입력칸을 감싼 Div 가 $locales 를 순회한다 + const iterated = formNodes.find( + (n: any) => n.iteration?.source?.includes('$locales') && n.iteration?.item_var === 'loc' + ); + expect(iterated).toBeDefined(); + + // ko/en 고정 입력칸 잔존 0 (회귀 차단) + expect(inputs.some((i: any) => i.props?.value?.includes('newCountry?.name?.ko'))).toBe(false); + expect(inputs.some((i: any) => i.props?.value?.includes('newCountry?.name?.en'))).toBe(false); }); - it('추가 버튼은 code와 name.ko가 없으면 disabled', () => { + it('추가 버튼은 코드가 없거나 모든 언어의 이름이 비면 disabled', () => { + // #459: 특정 로케일(ko)을 필수로 강제하지 않는다. + // 백엔드 StoreEcommerceSettingsRequest 도 name 을 array 로만 요구한다. const addForm = findById(allNodes, 'add_country_form'); const formNodes = flattenAll(addForm); const buttons = formNodes.filter((n: any) => n.name === 'Button'); - // 추가 버튼 (disabled prop 가진 것) const addButton = buttons.find((b: any) => b.props?.disabled); expect(addButton).toBeDefined(); expect(addButton.props.disabled).toContain('newCountry?.code'); - expect(addButton.props.disabled).toContain('newCountry?.name?.ko'); + // "어느 한 언어라도 채워졌는가" 조건 + expect(addButton.props.disabled).toContain('Object.values'); + expect(addButton.props.disabled).toContain('some'); + // ko 필수 조건 잔존 0 (회귀 차단) + expect(addButton.props.disabled).not.toContain('newCountry?.name?.ko'); }); it('추가 버튼 클릭 시 available_countries에 새 국가가 push된다', () => { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json index 6dca40f7..33d6470d 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json @@ -275,10 +275,6 @@ "country_code": "Country Code", "country_code_placeholder": "e.g., KR", "country_name": "Country Name", - "country_name_ko": "Korean Name", - "name_ko_placeholder": "e.g., South Korea", - "country_name_en": "English Name", - "name_en_placeholder": "e.g., South Korea", "is_active": "Active", "actions": "Actions", "delete": "Delete", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json index 55830f2c..975c5e24 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json @@ -275,10 +275,6 @@ "country_code": "국가코드", "country_code_placeholder": "예: KR", "country_name": "국가명", - "country_name_ko": "한국어명", - "name_ko_placeholder": "예: 한국", - "country_name_en": "영문명", - "name_en_placeholder": "예: South Korea", "is_active": "사용", "actions": "관리", "delete": "삭제", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_excel_download_index.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_excel_download_index.json index 5ead3af5..3a0031f7 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_excel_download_index.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_excel_download_index.json @@ -50,7 +50,7 @@ "name": "Icon", "props": { "name": "file-spreadsheet", - "className": "w-5 h-5 icon-muted" + "className": "text-xl icon-muted" } }, { @@ -157,7 +157,7 @@ "name": "Icon", "props": { "name": "refresh-cw", - "className": "w-4 h-4" + "className": "text-base" } }, { @@ -230,7 +230,7 @@ "name": "Icon", "props": { "name": "check-circle", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -262,7 +262,7 @@ "name": "Icon", "props": { "name": "loader", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -294,7 +294,7 @@ "name": "Icon", "props": { "name": "alert-circle", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -326,7 +326,7 @@ "name": "Icon", "props": { "name": "file-text", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -439,10 +439,11 @@ } }, { + "comment": "부모(date_filter_row)는 flex-wrap 이지만 이 행 자신은 nowrap 이라, 시작일~종료일(각 input w-36 = 144px)이 좁은 뷰포트에서 콘텐츠 영역을 11px 넘긴다. 자식에도 flex-wrap 을 준다.", "type": "basic", "name": "Div", "props": { - "className": "flex-center gap-2" + "className": "flex-center flex-wrap gap-2" }, "children": [ { @@ -457,7 +458,7 @@ "name": "Icon", "props": { "name": "calendar", - "className": "w-4 h-4 icon-muted" + "className": "text-base icon-muted" } }, { @@ -502,7 +503,7 @@ "name": "Icon", "props": { "name": "calendar", - "className": "w-4 h-4 icon-muted" + "className": "text-base icon-muted" } }, { @@ -781,7 +782,7 @@ "name": "Icon", "props": { "name": "check-circle", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -994,7 +995,7 @@ "name": "Icon", "props": { "name": "check-circle", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -1161,7 +1162,7 @@ "name": "Icon", "props": { "name": "loader", - "className": "w-3 h-3 animate-spin" + "className": "text-xs animate-spin" } }, { @@ -1299,7 +1300,7 @@ "name": "Icon", "props": { "name": "alert-circle", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -1437,7 +1438,7 @@ "name": "Icon", "props": { "name": "file-text", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -1581,7 +1582,7 @@ "name": "Icon", "props": { "name": "check-circle", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -1789,7 +1790,7 @@ "name": "Icon", "props": { "name": "loader", - "className": "w-3 h-3 animate-spin" + "className": "text-xs animate-spin" } }, { @@ -1979,7 +1980,7 @@ "name": "Icon", "props": { "name": "alert-circle", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -2169,7 +2170,7 @@ "name": "Icon", "props": { "name": "file-text", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -2379,7 +2380,7 @@ "name": "Icon", "props": { "name": "chevrons-left", - "className": "w-4 h-4" + "className": "text-base" } } ] @@ -2398,7 +2399,7 @@ "name": "Icon", "props": { "name": "chevron-left", - "className": "w-4 h-4" + "className": "text-base" } } ] @@ -2434,7 +2435,7 @@ "name": "Icon", "props": { "name": "chevron-right", - "className": "w-4 h-4" + "className": "text-base" } } ] @@ -2452,7 +2453,7 @@ "name": "Icon", "props": { "name": "chevrons-right", - "className": "w-4 h-4" + "className": "text-base" } } ] diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_settings.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_settings.json index 4ab60a40..3c65cf33 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_settings.json @@ -268,6 +268,7 @@ ] }, { + "comment": "className 은 sticky-tab-nav-responsive 를 쓴다 — 이 화면의 콘텐츠 래퍼가 admin-page-content-responsive(p-4 sm:p-6 lg:p-8) 라 뷰포트마다 좌우 패딩이 다르기 때문. 고정 24px 를 가정하는 sticky-tab-nav(-mx-6 px-6)를 쓰면 폰(p-4=16px)에서 좌우로 8px 씩 과다 bleed 되어 콘텐츠 영역을 넘친다.", "id": "tab_navigation", "type": "composite", "name": "TabNavigation", @@ -316,7 +317,7 @@ ], "activeTabId": "{{_global.activeEcommerceSettingsTab || query.tab || 'basic_info'}}", "variant": "default", - "className": "sticky-tab-nav" + "className": "sticky-tab-nav-responsive" }, "actions": [ { @@ -380,7 +381,7 @@ "name": "Icon", "props": { "name": "triangle-exclamation", - "className": "w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" + "className": "text-xl text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" } }, { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_modal_batch_change_confirm.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_modal_batch_change_confirm.json index 8e778d99..c51773b9 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_modal_batch_change_confirm.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_modal_batch_change_confirm.json @@ -8,8 +8,7 @@ "name": "Modal", "props": { "title": "$t:sirsoft-ecommerce.admin.order.detail.modal.batch_change.title", - "width": "750px", - "className": "max-w-full" + "width": "750px" }, "children": [ { @@ -48,7 +47,14 @@ "props": { "className": "col-span-5" }, - "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.batch_change.product_col" + "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.batch_change.product_col", + "responsive": { + "portable": { + "props": { + "className": "" + } + } + } }, { "type": "basic", @@ -56,7 +62,14 @@ "props": { "className": "col-span-2 text-right" }, - "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.batch_change.price_col" + "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.batch_change.price_col", + "responsive": { + "portable": { + "props": { + "className": "text-left" + } + } + } }, { "type": "basic", @@ -64,7 +77,14 @@ "props": { "className": "col-span-2 text-center" }, - "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.batch_change.change_quantity_col" + "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.batch_change.change_quantity_col", + "responsive": { + "portable": { + "props": { + "className": "text-left" + } + } + } }, { "type": "basic", @@ -72,9 +92,21 @@ "props": { "className": "col-span-3 text-center" }, - "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.batch_change.current_status_col" + "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.batch_change.current_status_col", + "responsive": { + "portable": { + "props": { + "className": "text-left" + } + } + } } - ] + ], + "responsive": { + "portable": { + "if": "{{false}}" + } + } }, { "type": "basic", @@ -165,7 +197,14 @@ } ] } - ] + ], + "responsive": { + "portable": { + "props": { + "className": "flex-center gap-2 min-w-0" + } + } + } }, { "type": "basic", @@ -175,11 +214,12 @@ }, "children": [ { + "comment": "whitespace-nowrap — 금액은 순수 숫자 토큰이라 숫자 내부에서 절대 줄바꿈되지 않아야 한다(`1,249,000` → `1,249` / `,000`). portable 세로 스택으로 셀이 전폭이라 현재는 절단이 없으나, 셀이 다시 좁아지면 즉시 재발하므로 의도를 코드에 고정한다.", "type": "basic", "name": "P", "if": "{{confirmItem.original_price && Number(confirmItem.original_price) !== Number(confirmItem.unit_price)}}", "props": { - "className": "text-hint line-through" + "className": "text-hint line-through whitespace-nowrap" }, "text": "{{Number(confirmItem.original_price).toLocaleString()}}" }, @@ -187,11 +227,18 @@ "type": "basic", "name": "P", "props": { - "className": "text-sm font-medium text-gray-900 dark:text-gray-100" + "className": "text-sm font-medium text-gray-900 dark:text-gray-100 whitespace-nowrap" }, "text": "{{Number(confirmItem.unit_price).toLocaleString()}}" } - ] + ], + "responsive": { + "portable": { + "props": { + "className": "text-left" + } + } + } }, { "type": "basic", @@ -230,7 +277,14 @@ }, "text": "/ {{confirmItem.quantity}}" } - ] + ], + "responsive": { + "portable": { + "props": { + "className": "flex justify-start" + } + } + } }, { "type": "basic", @@ -243,13 +297,27 @@ "type": "basic", "name": "Span", "props": { - "className": "{{({'pending_order':'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300','pending_payment':'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300','payment_complete':'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300','shipping_hold':'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300','preparing':'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300','shipping_ready':'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-300','shipping':'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300','delivered':'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-300','cancelled':'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300','confirmed':'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-300'})[confirmItem.option_status] ?? 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'}} inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" + "className": "{{({'pending_order':'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300','pending_payment':'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300','payment_complete':'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300','shipping_hold':'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300','preparing':'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300','shipping_ready':'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-300','shipping':'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300','delivered':'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-300','cancelled':'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300','confirmed':'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-300'})[confirmItem.option_status] ?? 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'}} inline-flex items-center px-2 py-0.5 rounded text-xs font-medium break-keep" }, "text": "{{({'pending_order':'$t:sirsoft-ecommerce.admin.order.status.waiting','pending_payment':'$t:sirsoft-ecommerce.admin.order.status.payment_pending','payment_complete':'$t:sirsoft-ecommerce.admin.order.status.payment_complete','shipping_hold':'$t:sirsoft-ecommerce.admin.order.status.shipping_hold','preparing':'$t:sirsoft-ecommerce.admin.order.status.preparing','shipping_ready':'$t:sirsoft-ecommerce.admin.order.status.shipping_ready','shipping':'$t:sirsoft-ecommerce.admin.order.status.shipping','delivered':'$t:sirsoft-ecommerce.admin.order.status.delivered','cancelled':'$t:sirsoft-ecommerce.admin.order.status.cancelled','confirmed':'$t:sirsoft-ecommerce.admin.order.status.confirmed'})[confirmItem.option_status] ?? confirmItem.option_status}}" } - ] + ], + "responsive": { + "portable": { + "props": { + "className": "flex justify-start" + } + } + } } - ] + ], + "responsive": { + "portable": { + "props": { + "className": "grid grid-cols-1 gap-2 px-3 py-2 items-center" + } + } + } } ] } diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_modal_cancel_order.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_modal_cancel_order.json index d3c81786..dab3bbe9 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_modal_cancel_order.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_modal_cancel_order.json @@ -8,8 +8,7 @@ "name": "Modal", "props": { "title": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.title", - "width": "1125px", - "className": "max-w-full" + "width": "1125px" }, "children": [ { @@ -154,7 +153,14 @@ "props": { "className": "col-span-5" }, - "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.product_col" + "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.product_col", + "responsive": { + "portable": { + "props": { + "className": "" + } + } + } }, { "type": "basic", @@ -162,7 +168,14 @@ "props": { "className": "col-span-2 text-right" }, - "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.unit_price_col" + "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.unit_price_col", + "responsive": { + "portable": { + "props": { + "className": "text-left" + } + } + } }, { "type": "basic", @@ -170,7 +183,14 @@ "props": { "className": "col-span-3 text-center" }, - "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.cancel_quantity_col" + "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.cancel_quantity_col", + "responsive": { + "portable": { + "props": { + "className": "text-left" + } + } + } }, { "type": "basic", @@ -178,9 +198,21 @@ "props": { "className": "col-span-2 text-center" }, - "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.status_col" + "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.status_col", + "responsive": { + "portable": { + "props": { + "className": "text-left" + } + } + } } - ] + ], + "responsive": { + "portable": { + "if": "{{false}}" + } + } }, { "comment": "취소 항목 반복", @@ -238,7 +270,14 @@ } ] } - ] + ], + "responsive": { + "portable": { + "props": { + "className": "flex-center gap-2" + } + } + } }, { "comment": "단가 + 소계", @@ -249,22 +288,31 @@ }, "children": [ { + "comment": "whitespace-nowrap — 금액 토큰은 숫자 내부에서 절대 줄바꿈되지 않아야 한다(`249,000원` → `249,000` / `원`). portable 세로 스택으로 셀이 전폭이라 현재는 절단이 없으나, 셀이 다시 좁아지면 즉시 재발하므로 의도를 코드에 고정한다.", "type": "basic", "name": "Span", "props": { - "className": "text-body" + "className": "text-body whitespace-nowrap" }, "text": "{{(cancelItem.unit_price ?? 0).toLocaleString()}}원 × {{cancelItem.quantity ?? 1}}" }, { + "comment": "break-keep + whitespace-nowrap 조합 대신 break-keep 만 — `소계`(한글 라벨) + 금액이 한 문자열이라 nowrap 을 주면 좁은 셀에서 넘친다. break-keep 은 어절 경계에서만 줄바꿈해 `소계` / `249,000원` 으로 끊고 숫자 내부는 보존한다.", "type": "basic", "name": "P", "props": { - "className": "text-tertiary" + "className": "text-tertiary break-keep" }, "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.product_subtotal {{((cancelItem.unit_price ?? 0) * (cancelItem.quantity ?? 1)).toLocaleString()}}원" } - ] + ], + "responsive": { + "portable": { + "props": { + "className": "text-left" + } + } + } }, { "comment": "취소수량 입력", @@ -297,7 +345,14 @@ } ] } - ] + ], + "responsive": { + "portable": { + "props": { + "className": "flex justify-start" + } + } + } }, { "comment": "현재상태 Badge", @@ -310,13 +365,31 @@ }, "children": [ { + "comment": "break-keep — 한글 상태명(`결제완료`)이 음절 경계에서 `결제완`/`료` 로 쪼개지지 않게 어절 단위 줄바꿈을 강제한다. 현재 Badge 는 inline-flex 라 콘텐츠 폭으로 자라 줄바꿈이 없지만, 배지 폭이 제한되면 즉시 재발한다.", "type": "basic", "name": "Span", + "props": { + "className": "break-keep" + }, "text": "{{cancelItem.option_status_label ?? cancelItem.option_status ?? ''}}" } - ] + ], + "responsive": { + "portable": { + "props": { + "className": "" + } + } + } } - ] + ], + "responsive": { + "portable": { + "props": { + "className": "grid grid-cols-1 gap-2 px-3 py-2 border-t border-gray-100 dark:border-gray-700 items-center" + } + } + } } ] }, @@ -1675,8 +1748,7 @@ ] } ], - "props": { - } + "props": {} } ] } diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_partial_order_info.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_partial_order_info.json index b6b8232d..dc42ac1e 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_partial_order_info.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_partial_order_info.json @@ -430,7 +430,7 @@ "type": "basic", "name": "Div", "props": { - "className": "flex flex-col items-center gap-1 whitespace-nowrap" + "className": "flex flex-col items-end gap-1 whitespace-nowrap" }, "children": [ { @@ -683,7 +683,7 @@ "type": "basic", "name": "Div", "props": { - "className": "flex flex-col items-center gap-1 whitespace-nowrap" + "className": "flex flex-col items-end gap-1 whitespace-nowrap" }, "children": [ { @@ -1614,7 +1614,7 @@ "props": { "name": "recipient_country_code", "value": "{{_local.form.recipient_country_code ?? order?.data?.shipping_address?.recipient_country_code ?? 'KR'}}", - "options": "{{(_global.modules?.['sirsoft-ecommerce']?.shipping?.available_countries ?? []).filter(c => c.is_active).map(c => ({ value: c.code, label: (c.name?.[_global.locale] ?? c.name?.ko ?? c.code) }))}}", + "options": "{{(_global.modules?.['sirsoft-ecommerce']?.shipping?.available_countries ?? []).filter(c => c.is_active).map(c => ({ value: c.code, label: ($localized(c.name, 'sirsoft-ecommerce::settings.countries.' + (c.code ?? '') + '.name') || c.code) }))}}", "className": "w-full", "disabled": "{{order?.data?.abilities?.can_update !== true}}" }, @@ -1634,7 +1634,7 @@ "name": "Span", "if": "{{_global.modules?.['sirsoft-ecommerce']?.shipping?.international_shipping_enabled !== true}}", "props": { "className": "text-gray-900 dark:text-white" }, - "text": "{{order?.data?.shipping_address?.recipient_country_name?.[_global.locale] ?? order?.data?.shipping_address?.recipient_country_name?.ko ?? order?.data?.shipping_address?.recipient_country_code ?? 'KR'}}" + "text": "{{$localized(order?.data?.shipping_address?.recipient_country_name) || order?.data?.shipping_address?.recipient_country_code || 'KR'}}" } ] }, diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_form/_partial_issue_settings.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_form/_partial_issue_settings.json index bab9f421..7bb7c9ad 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_form/_partial_issue_settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_form/_partial_issue_settings.json @@ -194,7 +194,7 @@ ] }, { - "comment": "기간설정 시: 시작일 ~ 종료일", + "comment": "기간설정 시: 시작일 ~ 종료일. flex-center 는 nowrap 이고 datetime-local 입력이 각 208px(w-52) 이라 좁은 뷰포트에서 행이 콘텐츠 영역을 넘친다 → portable 에서 줄바꿈 + 입력 전폭.", "id": "issue_period_range", "type": "basic", "name": "Div", @@ -202,6 +202,13 @@ "props": { "className": "flex-center gap-2" }, + "responsive": { + "portable": { + "props": { + "className": "flex-center flex-wrap gap-2" + } + } + }, "children": [ { "type": "basic", @@ -210,6 +217,15 @@ "type": "datetime-local", "name": "issue_from", "className": "input w-52" + }, + "responsive": { + "portable": { + "props": { + "type": "datetime-local", + "name": "issue_from", + "className": "input w-full" + } + } } }, { @@ -227,6 +243,15 @@ "type": "datetime-local", "name": "issue_to", "className": "input w-52" + }, + "responsive": { + "portable": { + "props": { + "type": "datetime-local", + "name": "issue_to", + "className": "input w-full" + } + } } } ] diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_bank_management_modal.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_bank_management_modal.json index 3139f488..0b2bd420 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_bank_management_modal.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_bank_management_modal.json @@ -119,11 +119,11 @@ ] }, { - "comment": "은행 목록 테이블 (스크롤 영역)", + "comment": "은행 목록 테이블 (스크롤 영역). overflow-x-auto 명시 필수 — 좁은 뷰포트에서 표(413px)가 컨테이너(356px)를 넘는다. overflow-y 만 선언해도 CSS Overflow 명세상 다른 축이 visible 로 남을 수 없어 computed 값이 auto 가 되지만, 그 의존은 암묵적이다. 누군가 overflow-y-visible 로 바꾸는 순간 표가 잘리므로 의도를 코드에 고정한다.", "type": "basic", "name": "Div", "props": { - "className": "max-h-96 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg" + "className": "max-h-96 overflow-y-auto overflow-x-auto border border-gray-200 dark:border-gray-700 rounded-lg" }, "children": [ { @@ -207,13 +207,14 @@ }, "children": [ { + "comment": "min-w-[80px] 필수 — 표가 table-layout:auto 이고 td 의 min-width 가 0 이라, 좁은 뷰포트에서 은행명 열이 은행코드 열을 56px 로 압착하면 input 이 24px(= 좌우 padding 12+12)로 줄어 텍스트 영역이 0px 이 된다(입력한 값이 보이지 않음). width 지정은 auto 레이아웃이 무시하므로 하한(min-width)을 고정해야 한다. 숫자 스케일 min-w-16 등은 safelist 에 없어 CSS 가 생성되지 않으므로 쓰면 무효다.", "type": "basic", "name": "Input", "props": { "type": "text", "value": "{{bank.code ?? ''}}", "placeholder": "$t:sirsoft-ecommerce.admin.settings.order_settings.bank_management.code_placeholder", - "className": "{{$parent._local.bankModalErrors?.['banks.' + bank._idx + '.code'] ? 'input input-error' : 'input'}}", + "className": "{{$parent._local.bankModalErrors?.['banks.' + bank._idx + '.code'] ? 'input input-error min-w-[80px]' : 'input min-w-[80px]'}}", "disabled": "{{$parent._computed.isReadOnly}}" }, "actions": [ diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_basic_info.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_basic_info.json index ca5ae84c..e2c39841 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_basic_info.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_basic_info.json @@ -74,6 +74,7 @@ ] }, { + "comment": "w-80(320px) 고정폭은 폰(<768px)의 콘텐츠 영역을 넘긴다(320px 뷰포트에서 32px 초과). 태블릿(768~1023px)은 넘치지 않으므로 portable 이 아니라 mobile(0~767px) 로 한정한다 — portable 로 잡으면 태블릿에서 입력칸이 불필요하게 늘어난다. className 오버라이드는 통째 교체라 에러 분기 표현식을 복제한다.", "type": "basic", "name": "Input", "props": { @@ -82,6 +83,13 @@ "placeholder": "$t:sirsoft-ecommerce.admin.settings.basic_info.shop_info.shop_name_placeholder", "className": "{{_local.errors?.['basic_info.shop_name'] ? 'input input-error w-80' : 'input w-80'}}", "disabled": "{{_computed.isReadOnly}}" + }, + "responsive": { + "mobile": { + "props": { + "className": "{{_local.errors?.['basic_info.shop_name'] ? 'input input-error w-full' : 'input w-full'}}" + } + } } }, { @@ -453,6 +461,7 @@ "text": "$t:sirsoft-ecommerce.admin.settings.basic_info.business_info.business_category" }, { + "comment": "w-80(320px) 고정폭은 폰(<768px)의 콘텐츠 영역을 넘긴다(320px 뷰포트에서 32px 초과). 태블릿(768~1023px)은 넘치지 않으므로 portable 이 아니라 mobile(0~767px) 로 한정한다 — portable 로 잡으면 태블릿에서 입력칸이 불필요하게 늘어난다. className 오버라이드는 통째 교체라 에러 분기 표현식을 복제한다.", "type": "basic", "name": "Input", "props": { @@ -461,6 +470,13 @@ "placeholder": "$t:sirsoft-ecommerce.admin.settings.basic_info.business_info.business_category_placeholder", "className": "{{_local.errors?.['basic_info.business_category'] ? 'input input-error w-80' : 'input w-80'}}", "disabled": "{{_computed.isReadOnly}}" + }, + "responsive": { + "mobile": { + "props": { + "className": "{{_local.errors?.['basic_info.business_category'] ? 'input input-error w-full' : 'input w-full'}}" + } + } } }, { @@ -778,11 +794,19 @@ "text": "$t:sirsoft-ecommerce.admin.settings.basic_info.business_info.email" }, { + "comment": "flex-center 는 nowrap 이고 자식이 고정폭(아이디 w-36 + @ + 도메인 입력 + '직접입력' 드롭다운 104px)이라 좁은 뷰포트에서 이 행이 콘텐츠 영역을 71px 넘긴다 → portable 에서 줄바꿈.", "type": "basic", "name": "Div", "props": { "className": "flex-center gap-2" }, + "responsive": { + "portable": { + "props": { + "className": "flex-center flex-wrap gap-2" + } + } + }, "children": [ { "type": "basic", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_shipping.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_shipping.json index 632064ea..8a6cfc3b 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_shipping.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_shipping.json @@ -58,7 +58,7 @@ "props": { "value": "{{_local.form?.shipping?.default_country ?? 'KR'}}", "className": "w-full", - "options": "{{(_local.form?.shipping?.available_countries ?? []).filter(c => c.is_active).map(c => ({value: c.code, label: $localized(c.name)}))}}", + "options": "{{(_local.form?.shipping?.available_countries ?? []).filter(c => c.is_active).map(c => ({value: c.code, label: ($localized(c.name, 'sirsoft-ecommerce::settings.countries.' + (c.code ?? '') + '.name') || c.code)}))}}", "disabled": "{{_computed.isReadOnly}}" }, "actions": [ @@ -216,19 +216,13 @@ }, "actions": [ { + "comment": "신규 국가의 name 은 활성 언어팩($locales) 전체에 대한 빈 문자열 맵으로 시드한다. 언어팩이 추가되면 입력칸도 함께 늘어나야 하므로 로케일을 리터럴로 열거하지 않는다.", "type": "click", "handler": "setState", "params": { "target": "local", "isAddingCountry": true, - "newCountry": { - "code": "", - "name": { - "ko": "", - "en": "" - }, - "is_active": true - } + "newCountry": "{{ { code: '', name: Object.fromEntries(($locales ?? []).map(l => [l, ''])), is_active: true } }}" } } ], @@ -305,24 +299,31 @@ ] }, { + "comment": "국가명 입력칸은 활성 언어팩($locales) 마다 하나씩 생성된다. iteration 은 이 Div 자신을 로케일 수만큼 복제하므로, 부모(flex flex-wrap)가 입력칸들을 가로로 흘린다. 언어팩이 추가되면 입력칸도 자동으로 늘어난다 — 로케일 리터럴 열거 금지.", "type": "basic", "name": "Div", + "iteration": { + "source": "{{$locales ?? []}}", + "item_var": "loc" + }, "children": [ { + "comment": "라벨 = 로케일 표시명(예: 한국어 / English / 日本語). appConfig.localeNames 부재 시 로케일 코드로 폴백.", "type": "basic", "name": "Label", "props": { "className": "text-tertiary block font-medium mb-1" }, - "text": "$t:sirsoft-ecommerce.admin.settings.shipping.countries.country_name_ko" + "text": "{{_global.appConfig?.localeNames?.[loc] ?? loc}}" }, { + "comment": "setState 는 params 키의 표현식을 해석하지 않는다(엔진 제약). 따라서 `newCountry.name.` 같은 동적 키 대신 name 객체를 통째로 재구성해 넣는다.", "type": "basic", "name": "Input", "props": { - "value": "{{_local.newCountry?.name?.ko ?? ''}}", + "value": "{{_local.newCountry?.name?.[loc] ?? ''}}", "className": "w-40", - "placeholder": "$t:sirsoft-ecommerce.admin.settings.shipping.countries.name_ko_placeholder", + "placeholder": "{{$t('sirsoft-ecommerce.admin.settings.shipping.countries.country_name') + ' (' + (_global.appConfig?.localeNames?.[loc] ?? loc) + ')'}}", "disabled": "{{_computed.isReadOnly}}" }, "actions": [ @@ -331,41 +332,7 @@ "handler": "setState", "params": { "target": "local", - "newCountry.name.ko": "{{$event.target.value}}" - } - } - ] - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-tertiary block font-medium mb-1" - }, - "text": "$t:sirsoft-ecommerce.admin.settings.shipping.countries.country_name_en" - }, - { - "type": "basic", - "name": "Input", - "props": { - "value": "{{_local.newCountry?.name?.en ?? ''}}", - "className": "w-40", - "placeholder": "$t:sirsoft-ecommerce.admin.settings.shipping.countries.name_en_placeholder", - "disabled": "{{_computed.isReadOnly}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "local", - "newCountry.name.en": "{{$event.target.value}}" + "newCountry.name": "{{ { ...(_local.newCountry?.name ?? {}), [loc]: $event.target.value } }}" } } ] @@ -380,11 +347,12 @@ }, "children": [ { + "comment": "활성 조건: 국가코드 + 최소 하나의 로케일 이름. StoreEcommerceSettingsRequest 의 available_countries.*.name 규칙이 array 만 요구하고 특정 로케일 키를 강제하지 않으므로, 프론트도 ko 를 필수로 두지 않는다.", "type": "basic", "name": "Button", "props": { "type": "button", - "disabled": "{{_computed.isReadOnly || !_local.newCountry?.code || !_local.newCountry?.name?.ko}}", + "disabled": "{{_computed.isReadOnly || !(_local.newCountry?.code ?? '').trim() || !Object.values(_local.newCountry?.name ?? {}).some(v => (v ?? '').trim() !== '')}}", "className": "px-3 py-1.5 text-sm 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": [ @@ -393,10 +361,11 @@ "handler": "sequence", "actions": [ { + "comment": "입력하지 않은 로케일의 빈 문자열은 저장본에 남기지 않는다 — 부재 로케일은 언어팩(settings.countries.{code}.name)이 보강하거나, 운영자가 나중에 채운다.", "handler": "setState", "params": { "target": "local", - "form.shipping.available_countries": "{{[...(_local.form?.shipping?.available_countries ?? []), { code: _local.newCountry.code, name: _local.newCountry.name, is_active: true }]}}", + "form.shipping.available_countries": "{{[...(_local.form?.shipping?.available_countries ?? []), { code: _local.newCountry.code.trim().toUpperCase(), name: Object.fromEntries(Object.entries(_local.newCountry?.name ?? {}).filter(e => (e[1] ?? '').trim() !== '').map(e => [e[0], e[1].trim()])), is_active: true }]}}", "isAddingCountry": false, "newCountry": null, "hasChanges": true diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_form/_modal_extra_fee_template.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_form/_modal_extra_fee_template.json index 93b3b81b..997d7caf 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_form/_modal_extra_fee_template.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_form/_modal_extra_fee_template.json @@ -394,11 +394,12 @@ }, "children": [ { + "comment": "overflow-x-auto 명시 — 6열 표(615px)가 좁은 뷰포트 컨테이너를 넘는다. overflow-y 만 선언해도 CSS Overflow 명세상 다른 축이 visible 로 남을 수 없어 computed 가 auto 로 계산되지만 그 의존은 암묵적이다. 누군가 overflow-y-visible 로 바꾸면 즉시 표가 잘리므로 의도를 코드에 고정한다. 표는 whitespace-nowrap 이라 절단 없이 가로 스크롤된다(세로 스택 전환은 과잉 대응).", "id": "template_list", "type": "basic", "name": "Div", "props": { - "className": "max-h-96 overflow-y-auto" + "className": "max-h-96 overflow-y-auto overflow-x-auto" }, "actions": [ { diff --git a/modules/_bundled/sirsoft-ecommerce/src/Support/ApiDoc/ApiDocSampleService.php b/modules/_bundled/sirsoft-ecommerce/src/Support/ApiDoc/ApiDocSampleService.php new file mode 100644 index 00000000..4efed001 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Support/ApiDoc/ApiDocSampleService.php @@ -0,0 +1,511 @@ +}> 도메인 => 대표 레코드 정보 + */ + public function seed(): array + { + $actor = $this->sampleActor(); + + $product = $this->seedProduct(); + $category = $this->seedCategory(); + $order = $actor ? $this->seedOrder($actor) : null; + $review = ($actor && $product) ? $this->seedReview($actor, $product) : null; + $inquiry = ($actor && $product) ? $this->seedInquiry($actor, $product) : null; + $address = $actor ? $this->seedAddress($actor) : null; + $mileage = $actor ? $this->seedMileageTransaction($actor) : null; + + $brand = $this->seedBrand(); + $coupon = $this->seedCoupon(); + $shippingPolicy = $this->seedShippingPolicy(); + $carrier = $this->seedShippingCarrier(); + $claimReason = $this->seedClaimReason(); + $label = $this->seedProductLabel(); + $commonInfo = $this->seedProductCommonInfo(); + $noticeTemplate = $this->seedNoticeTemplate(); + $extraFee = $this->seedExtraFeeTemplate(); + + $map = []; + + // products: 공개 상세(products/{id}) 는 숫자 id + VISIBLE, admin 상세는 id/product_code 허용. + if ($product) { + $pid = (string) $product->getKey(); + $map['products'] = [ + 'model' => Product::class, + 'key' => 'id', + 'value' => $pid, + 'path_params' => [ + 'id' => $pid, + 'identifier' => $pid, + 'code' => (string) $product->product_code, + 'productId' => $pid, + 'product' => $pid, + ], + ]; + } + + // categories: 공개는 slug, admin 은 id. + if ($category) { + $cid = (string) $category->getKey(); + $map['categories'] = [ + 'model' => Category::class, + 'key' => 'id', + 'value' => $cid, + 'path_params' => [ + 'id' => $cid, + 'slug' => (string) $category->slug, + 'category' => $cid, + ], + ]; + } + + // orders: 회원 주문 상세(user/orders/{id}) + 주문번호 조회(user/orders/{orderNumber}). + if ($order) { + $oid = (string) $order->getKey(); + $map['orders'] = [ + 'model' => Order::class, + 'key' => 'id', + 'value' => $oid, + 'path_params' => [ + 'id' => $oid, + 'order' => $oid, + 'orderNumber' => (string) $order->order_number, + ], + ]; + } + + // reviews: admin 상세는 route-model binding(id), 공개 상품 리뷰 목록은 productId. + if ($review) { + $rid = (string) $review->getKey(); + $map['reviews'] = [ + 'model' => ProductReview::class, + 'key' => 'id', + 'value' => $rid, + 'path_params' => [ + 'review' => $rid, + 'id' => $rid, + 'orderOptionId' => (string) ($review->order_option_id ?? ''), + ], + ]; + } + + // inquiries: 상품 문의 목록 실측용 대표 문의(products/{productId}/inquiries 는 products 맵 사용). + if ($inquiry) { + $iid = (string) $inquiry->getKey(); + $map['inquiries'] = [ + 'model' => ProductInquiry::class, + 'key' => 'id', + 'value' => $iid, + 'path_params' => ['id' => $iid, 'inquiry' => $iid], + ]; + } + + // addresses: 본인 배송지 상세(user/addresses/{id}). + if ($address) { + $aid = (string) $address->getKey(); + $map['addresses'] = [ + 'model' => UserAddress::class, + 'key' => 'id', + 'value' => $aid, + 'path_params' => ['id' => $aid], + ]; + } + + // mileage-transactions: 연관 거래 조회(admin/mileage-transactions/{id}/linked). + if ($mileage) { + $mid = (string) $mileage->getKey(); + $map['mileage-transactions'] = [ + 'model' => MileageTransaction::class, + 'key' => 'id', + 'value' => $mid, + 'path_params' => ['id' => $mid], + ]; + } + + // 단순 id 상세 도메인(admin/{domain}/{id}). + $this->putSimpleIdDomain($map, 'brands', Brand::class, $brand); + $this->putSimpleIdDomain($map, 'promotion-coupons', Coupon::class, $coupon); + $this->putSimpleIdDomain($map, 'shipping-policies', ShippingPolicy::class, $shippingPolicy); + $this->putSimpleIdDomain($map, 'shipping-carriers', ShippingCarrier::class, $carrier); + $this->putSimpleIdDomain($map, 'claim-reasons', ClaimReason::class, $claimReason); + $this->putSimpleIdDomain($map, 'product-labels', ProductLabel::class, $label); + $this->putSimpleIdDomain($map, 'product-common-infos', ProductCommonInfo::class, $commonInfo); + $this->putSimpleIdDomain($map, 'product-notice-templates', ProductNoticeTemplate::class, $noticeTemplate); + $this->putSimpleIdDomain($map, 'extra-fee-templates', ExtraFeeTemplate::class, $extraFee); + + // settings: 레코드 없이 유효 카테고리 문자열 고정(admin/settings/{category}). + $map['settings'] = [ + 'model' => Product::class, + 'key' => 'id', + 'value' => $product ? (string) $product->getKey() : '1', + 'path_params' => ['category' => 'basic_info'], + ]; + + return $map; + } + + /** + * 단순 id 상세 도메인 항목을 맵에 등록합니다(모델이 null 이면 건너뜀). + * + * @param array $map 대상 맵(참조) + * @param string $domain 도메인 그룹명 + * @param class-string $model 모델 FQCN + * @param Model|null $record 대표 레코드 + */ + private function putSimpleIdDomain(array &$map, string $domain, string $model, $record): void + { + if (! $record) { + return; + } + + $id = (string) $record->getKey(); + $map[$domain] = [ + 'model' => $model, + 'key' => 'id', + 'value' => $id, + 'path_params' => ['id' => $id], + ]; + } + + /** + * 완전 샘플 상품을 멱등 생성합니다(전시 상태로 공개 상세 실측 가능). + * + * @return Product|null 대표 상품 (생성 실패 시 null) + */ + private function seedProduct(): ?Product + { + $product = Product::query()->where('product_code', self::SAMPLE_PRODUCT_CODE)->first(); + if ($product) { + return $product; + } + + return Product::factory()->create([ + 'name' => ['ko' => 'API 문서 샘플 상품', 'en' => 'API Doc Sample Product'], + 'product_code' => self::SAMPLE_PRODUCT_CODE, + 'sales_status' => 'on_sale', + 'display_status' => 'visible', + ]); + } + + /** + * 완전 샘플 카테고리를 멱등 생성합니다(공개 slug 조회 실측 가능). + * + * @return Category|null 대표 카테고리 (생성 실패 시 null) + */ + private function seedCategory(): ?Category + { + $category = Category::query()->where('slug', self::SAMPLE_CATEGORY_SLUG)->first(); + if ($category) { + return $category; + } + + return Category::query()->create([ + 'name' => ['ko' => 'API 문서 샘플 카테고리', 'en' => 'API Doc Sample Category'], + 'slug' => self::SAMPLE_CATEGORY_SLUG, + 'path' => '0', + 'depth' => 0, + 'sort_order' => 0, + 'is_active' => true, + ]); + } + + /** + * 실측 사용자 소유의 완전 샘플 주문을 멱등 생성합니다. + * + * @param User $actor 실측 사용자 + * @return Order|null 대표 주문 (생성 실패 시 null) + */ + private function seedOrder(User $actor): ?Order + { + $order = Order::query()->where('user_id', $actor->id) + ->where('order_number', 'like', 'APIDOC-%') + ->first(); + if ($order) { + return $order; + } + + return Order::factory()->forUser($actor)->create([ + 'order_number' => 'APIDOC-'.now()->format('Ymd').'-000001', + ]); + } + + /** + * 완전 샘플 상품 리뷰를 멱등 생성합니다. + * + * @param User $actor 실측 사용자 + * @param Product $product 대표 상품 + * @return ProductReview|null 대표 리뷰 (생성 실패 시 null) + */ + private function seedReview(User $actor, Product $product): ?ProductReview + { + $review = ProductReview::query()->where('product_id', $product->id) + ->where('user_id', $actor->id)->first(); + if ($review) { + return $review; + } + + return ProductReview::factory()->create([ + 'product_id' => $product->id, + 'user_id' => $actor->id, + ]); + } + + /** + * 완전 샘플 상품 문의를 멱등 생성합니다. + * + * @param User $actor 실측 사용자 + * @param Product $product 대표 상품 + * @return ProductInquiry|null 대표 문의 (생성 실패 시 null) + */ + private function seedInquiry(User $actor, Product $product): ?ProductInquiry + { + $inquiry = ProductInquiry::query()->where('product_id', $product->id) + ->where('user_id', $actor->id)->first(); + if ($inquiry) { + return $inquiry; + } + + return ProductInquiry::factory()->create([ + 'product_id' => $product->id, + 'user_id' => $actor->id, + ]); + } + + /** + * 실측 사용자 소유의 완전 샘플 배송지를 멱등 생성합니다. + * + * @param User $actor 실측 사용자 + * @return UserAddress|null 대표 배송지 (생성 실패 시 null) + */ + private function seedAddress(User $actor): ?UserAddress + { + $address = UserAddress::query()->where('user_id', $actor->id) + ->where('name', 'API 문서 샘플 배송지')->first(); + if ($address) { + return $address; + } + + return UserAddress::factory()->create([ + 'user_id' => $actor->id, + 'name' => 'API 문서 샘플 배송지', + ]); + } + + /** + * 실측 사용자 소유의 완전 샘플 마일리지 거래를 멱등 생성합니다. + * + * @param User $actor 실측 사용자 + * @return MileageTransaction|null 대표 거래 (생성 실패 시 null) + */ + private function seedMileageTransaction(User $actor): ?MileageTransaction + { + $tx = MileageTransaction::query()->where('user_id', $actor->id) + ->where('type', 'admin_earn')->first(); + if ($tx) { + return $tx; + } + + return MileageTransaction::query()->create([ + 'user_id' => $actor->id, + 'type' => 'admin_earn', + 'amount' => 1000, + 'balance_after' => 1000, + ]); + } + + /** + * 완전 샘플 브랜드를 멱등 생성합니다. + * + * @return Brand|null 대표 브랜드 (생성 실패 시 null) + */ + private function seedBrand(): ?Brand + { + return Brand::query()->firstOrCreate( + ['slug' => 'apidoc-sample-brand'], + ['name' => ['ko' => 'API 문서 샘플 브랜드', 'en' => 'API Doc Sample Brand']] + ); + } + + /** + * 완전 샘플 프로모션 쿠폰을 멱등 생성합니다. + * + * @return Coupon|null 대표 쿠폰 (생성 실패 시 null) + */ + private function seedCoupon(): ?Coupon + { + return Coupon::query()->firstOrCreate( + ['name->ko' => 'API 문서 샘플 쿠폰'], + [ + 'name' => ['ko' => 'API 문서 샘플 쿠폰', 'en' => 'API Doc Sample Coupon'], + 'target_type' => 'order_amount', + 'discount_type' => 'fixed', + 'discount_value' => 1000, + 'issue_method' => 'download', + 'issue_condition' => 'manual', + ] + ); + } + + /** + * 완전 샘플 배송정책을 멱등 생성합니다. + * + * @return ShippingPolicy|null 대표 배송정책 (생성 실패 시 null) + */ + private function seedShippingPolicy(): ?ShippingPolicy + { + return ShippingPolicy::query()->firstOrCreate( + ['name->ko' => 'API 문서 샘플 배송정책'], + ['name' => ['ko' => 'API 문서 샘플 배송정책', 'en' => 'API Doc Sample Shipping Policy']] + ); + } + + /** + * 완전 샘플 배송사를 멱등 생성합니다. + * + * @return ShippingCarrier|null 대표 배송사 (생성 실패 시 null) + */ + private function seedShippingCarrier(): ?ShippingCarrier + { + return ShippingCarrier::query()->firstOrCreate( + ['code' => 'apidoc'], + [ + 'name' => ['ko' => 'API 문서 샘플 배송사', 'en' => 'API Doc Sample Carrier'], + 'type' => 'domestic', + ] + ); + } + + /** + * 완전 샘플 클레임 사유를 멱등 생성합니다. + * + * @return ClaimReason|null 대표 클레임 사유 (생성 실패 시 null) + */ + private function seedClaimReason(): ?ClaimReason + { + return ClaimReason::query()->firstOrCreate( + ['type' => 'refund', 'code' => 'apidoc_sample'], + [ + 'name' => ['ko' => 'API 문서 샘플 사유', 'en' => 'API Doc Sample Reason'], + 'fault_type' => 'customer', + ] + ); + } + + /** + * 완전 샘플 상품 라벨을 멱등 생성합니다. + * + * @return ProductLabel|null 대표 라벨 (생성 실패 시 null) + */ + private function seedProductLabel(): ?ProductLabel + { + return ProductLabel::query()->firstOrCreate( + ['name->ko' => 'API 문서 샘플 라벨'], + ['name' => ['ko' => 'API 문서 샘플 라벨', 'en' => 'API Doc Sample Label']] + ); + } + + /** + * 완전 샘플 상품 공통정보를 멱등 생성합니다. + * + * @return ProductCommonInfo|null 대표 공통정보 (생성 실패 시 null) + */ + private function seedProductCommonInfo(): ?ProductCommonInfo + { + return ProductCommonInfo::query()->firstOrCreate( + ['name->ko' => 'API 문서 샘플 공통정보'], + ['name' => ['ko' => 'API 문서 샘플 공통정보', 'en' => 'API Doc Sample Common Info']] + ); + } + + /** + * 완전 샘플 상품정보고시 템플릿을 멱등 생성합니다. + * + * @return ProductNoticeTemplate|null 대표 고시 템플릿 (생성 실패 시 null) + */ + private function seedNoticeTemplate(): ?ProductNoticeTemplate + { + return ProductNoticeTemplate::query()->firstOrCreate( + ['name->ko' => 'API 문서 샘플 고시템플릿'], + [ + 'name' => ['ko' => 'API 문서 샘플 고시템플릿', 'en' => 'API Doc Sample Notice Template'], + 'fields' => [['label' => '품명', 'value' => '샘플']], + ] + ); + } + + /** + * 완전 샘플 추가배송비 템플릿을 멱등 생성합니다. + * + * @return ExtraFeeTemplate|null 대표 추가배송비 템플릿 (생성 실패 시 null) + */ + private function seedExtraFeeTemplate(): ?ExtraFeeTemplate + { + return ExtraFeeTemplate::query()->firstOrCreate( + ['zipcode' => '00000'], + ['fee' => 3000] + ); + } + + /** + * 샘플 작성자로 쓸 사용자를 반환합니다. + * + * 코어 완전 샘플 사용자(먼저 시드됨)를 우선하고, 없으면 첫 사용자로 폴백합니다. + * + * @return User|null 샘플 사용자 (없으면 null) + */ + private function sampleActor(): ?User + { + return User::query()->where('email', 'apidoc-sample-user@example.com')->first() + ?? User::query()->orderBy('id')->first(); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/fixtures/ecommerce-auth.ts b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/fixtures/ecommerce-auth.ts index bbd43629..e77f97cc 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/fixtures/ecommerce-auth.ts +++ b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/fixtures/ecommerce-auth.ts @@ -27,6 +27,12 @@ type EcommerceAuthFixtures = { mileageReadOnlyToken: string; /** 상품 조회 + 수정 권한 보유 토큰 (상품폼 등록/수정 검증용) */ productManageToken: string; + /** 주문 조회(`orders.read`) 권한 보유 토큰 (주문 상세 표시 검증용) */ + ordersReadToken: string; + /** 일반 쇼핑 사용자 토큰 (관리자 권한 없음 — 유저 화면 검증용) */ + userToken: string; + /** 구매 고객 토큰 (장바구니/추가옵션 등 쇼핑 플로우 검증용) */ + customerToken: string; }; export const test = base.extend({ @@ -49,6 +55,18 @@ export const test = base.extend({ productManageToken: async ({}, use) => { await use(issueToken('sirsoft-ecommerce.products.read', 'sirsoft-ecommerce.products.update')); }, + ordersReadToken: async ({}, use) => { + await use(issueToken('sirsoft-ecommerce.orders.read')); + }, + // userToken / customerToken 은 현재 둘 다 "관리자 권한 없는 인증 사용자" 로 동일하다. + // 유저 화면(마이페이지/헤더)과 구매 플로우(장바구니/주문서)는 요구 권한이 갈릴 수 있어 + // 호출부의 의도를 이름으로 남긴다 — 한쪽에 권한이 붙어도 다른 쪽이 영향받지 않는다. + userToken: async ({}, use) => { + await use(issueToken()); + }, + customerToken: async ({}, use) => { + await use(issueToken()); + }, }); export { authenticatePage }; diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/mp06-coupon-and-mileage-timing.spec.ts b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/mp06-coupon-and-mileage-timing.spec.ts index d4e07367..36fe89da 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/mp06-coupon-and-mileage-timing.spec.ts +++ b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/mp06-coupon-and-mileage-timing.spec.ts @@ -13,8 +13,8 @@ * - 무통장 1회 제한 쿠폰은 주문 생성 즉시 USED 차감되어 입금 전까지 재사용 불가(공개#57). * * Select 렌더/disabled 바인딩은 레이아웃 렌더 테스트(adminEcommerceSettingsOrder.test.tsx)가, - * 차감 시점 분기·쿠폰 검증·선차감/반환은 PHPUnit(OrderCalculation*/CouponUse*/CouponRestore*/ - * EcommerceSettingsMileageTiming/MigrateMileageDeductionTiming)가 커버한다. 본 spec 은 브라우저에서 + * 차감 시점 분기·쿠폰 검증·선차감/반환은 PHPUnit(OrderCalculation·CouponUse·CouponRestore· + * EcommerceSettingsMileageTiming·MigrateMileageDeductionTiming)가 커버한다. 본 spec 은 브라우저에서 * 설정 disabled 연동 + 주문서 위반 안내 노출만 왕복 검증한다. * * 활성화 전 사전 작업(data-testid 보강): diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/order-detail-numeric-alignment.spec.ts b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/order-detail-numeric-alignment.spec.ts new file mode 100644 index 00000000..e6631e8e --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/order-detail-numeric-alignment.spec.ts @@ -0,0 +1,132 @@ +/** + * 주문 상세 — 상품 목록 숫자 열 정렬 일치 회귀. + * + * 배경: 상품 목록의 숫자 열 6개 중 `정가/판매가`·`할인가격`·`실 구매가격`·`소계` 는 + * `text-right` 로 우측 정렬인데, `구매수량`·`적립예정` 만 `flex flex-col items-center` + * 로 가운데 정렬이라 값이 서로 어긋나 보였다. 표(≥768px)·카드(<768px) 양쪽에서 발생. + * + * 측정 규율 (이 spec 을 고칠 사람에게): + * - `items-center` 는 `text-align` 이 아니라 `align-items` 로 정렬하므로, + * `textAlign === 'right'` 만 단언하면 두 셀을 영영 못 잡는다. 실제 **콘텐츠의 + * 우측 여백(rightGap)** 이 열끼리 같은지를 본다. + * - 절대 좌표/임계값(`toBeGreaterThan(N)`)은 레이아웃이 바뀌면 거짓 통과한다. + * 같은 행 안에서 **열끼리 서로 같은가**(집합 크기 1)를 단언한다. + * + * @scenario order_detail_numeric_alignment viewport=1280|390 + * @effects numeric_columns_right_aligned_consistently + */ +import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth'; +import type { Page } from '@playwright/test'; + +/** + * 숫자 값을 담는 열의 수 (정가/판매가·구매수량·할인가격·실 구매가격·적립예정·소계). + * + * 헤더 텍스트로 찾지 않는다 — E2E 세션 로케일이 en 이면 `Quantity` 로 렌더돼 + * 한글 문자열 단언이 통째로 새기 때문(실측 확인). 대신 "우측 정렬로 선언된 셀" 을 + * 숫자 열로 식별한다. 회귀(= `items-center` 복귀)가 나면 그 셀이 목록에서 빠지므로 + * 개수 단언이 먼저 깨진다. + */ +const NUMERIC_COLUMN_COUNT = 6; + +/** + * 첫 주문의 상세 URL 을 얻는다 (주문번호 하드코딩 회피). + * + * 목록 화면은 좁은 뷰포트에서 표가 아니라 카드로 렌더되므로, 진입은 항상 데스크톱 폭에서 + * 한다. 상세 진입은 `` 가 아니라 주문번호 `` 클릭(DataGrid onRowClick 계열). + * 상세 URL 은 숫자 id 가 아니라 주문번호(예: `20260709-0144414102`) 다. + */ +async function firstOrderDetailUrl(page: Page): Promise { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto('/admin/ecommerce/orders'); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + + const orderNumber = page.locator('table tbody tr span.cursor-pointer').first(); + await orderNumber.waitFor({ state: 'visible', timeout: 30_000 }); + await orderNumber.click(); + + await page.waitForURL(/\/admin\/ecommerce\/orders\/[\w-]+$/, { timeout: 30_000 }); + return new URL(page.url()).pathname; +} + +/** + * 우측 정렬로 선언된 셀들의 "콘텐츠 우측 여백" 을 잰다. + * + * 셀 경계와 실제 콘텐츠 우측 끝의 거리. 모든 숫자 열이 같은 방식으로 우측 정렬돼 있으면 + * 이 값이 열끼리 같다. `구매수량`·`적립예정` 이 `items-center` 로 돌아가면 그 셀은 + * 우측 정렬 목록에서 빠지므로 개수가 줄고, 설령 잡히더라도 여백이 달라진다. + * + * `text-right`(block) 와 `items-end`(flex) 둘 다 우측 정렬이므로 함께 센다. + */ +async function rightGaps(page: Page): Promise { + return page.evaluate(() => { + const isRightAligned = (el: Element): boolean => { + const cs = getComputedStyle(el); + return cs.textAlign === 'right' || cs.alignItems === 'flex-end'; + }; + const gapOf = (container: Element, inner: Element): number => { + const content = inner.firstElementChild ?? inner; + return Math.round(container.getBoundingClientRect().right - content.getBoundingClientRect().right); + }; + + // 표 뷰(≥768px): thead 가 있는 상품 목록 table + const table = [...document.querySelectorAll('table')].find((t) => t.querySelector('tbody tr td')); + if (table) { + const row = table.querySelector('tbody tr'); + if (!row) return []; + return [...row.querySelectorAll('td')] + .map((td) => { + const inner = td.firstElementChild; + return inner && isRightAligned(inner) ? gapOf(td, inner) : null; + }) + .filter((v): v is number => v !== null); + } + + // 카드 뷰(<768px): DataGrid 가 컬럼을 grid 셀로 편다 + const grid = [...document.querySelectorAll('[class*="grid-cols-"]')].find((g) => g.querySelector('.min-w-0')); + if (!grid) return []; + return [...grid.children] + .map((cell) => { + const label = cell.querySelector('span'); + const value = [...cell.children].find((c) => c !== label); + const inner = value?.firstElementChild; + return inner && isRightAligned(inner) ? gapOf(cell, inner) : null; + }) + .filter((v): v is number => v !== null); + }); +} + +test.describe('@sirsoft-ecommerce 주문 상세 상품 목록 정렬', () => { + // @scenario order_detail_numeric_alignment:desktop_table + // @effects numeric_columns_right_aligned_consistently + test('1280px 표 뷰: 숫자 열이 모두 같은 우측 여백으로 정렬된다', async ({ page, ordersReadToken }) => { + await authenticatePage(page, ordersReadToken); + await firstOrderDetailUrl(page); // 진입만 하면 이미 1280px 표 뷰 + + // 회귀 시 `구매수량`·`적립예정` 이 items-center 로 돌아가 우측 정렬 셀이 4개로 줄어든다. + await expect + .poll(async () => (await rightGaps(page)).length, { timeout: 20_000 }) + .toBe(NUMERIC_COLUMN_COUNT); + + const gaps = await rightGaps(page); + expect(new Set(gaps).size, `열별 우측 여백: ${JSON.stringify(gaps)}`).toBe(1); + }); + + // @scenario order_detail_numeric_alignment:mobile_card + // @effects numeric_columns_right_aligned_consistently + test('390px 카드 뷰: 숫자 열이 모두 같은 우측 여백으로 정렬된다', async ({ page, ordersReadToken }) => { + await authenticatePage(page, ordersReadToken); + // 목록은 데스크톱 폭에서 진입(좁은 폭에선 목록도 카드라 주문번호 span 이 없다) + const detailPath = await firstOrderDetailUrl(page); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(detailPath); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + + await expect + .poll(async () => (await rightGaps(page)).length, { timeout: 20_000 }) + .toBe(NUMERIC_COLUMN_COUNT); + + const gaps = await rightGaps(page); + expect(new Set(gaps).size, `열별 우측 여백: ${JSON.stringify(gaps)}`).toBe(1); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/shipping-country-i18n.spec.ts b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/shipping-country-i18n.spec.ts new file mode 100644 index 00000000..a634be78 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/shipping-country-i18n.spec.ts @@ -0,0 +1,199 @@ +/** + * 배송설정 배송국가 — 다국어 표시/입력 회귀 (#459). + * + * 배경: 배송국가명이 UI 로케일과 무관하게 항상 한국어로 렌더됐다. 원인은 두 겹이었다. + * 1) 레이아웃이 `name[_global.locale]` 로 직접 색인 — 로케일 키 부재 시 폴백 없음. + * 2) 엔진 `$localized()` 가 `context.$locale || 'ko'` 로 로케일을 확정 — `init_actions` + * 같은 ActionDispatcher 경로는 `$locale` 을 넘기지 않으므로 **항상** ko 를 반환했다 + * (engine-v1.52.1 에서 `window.__templateApp.getConfig()` 회수로 수정). + * 또한 국가 추가 폼의 이름 입력칸이 ko/en 2칸으로 하드코딩돼 있어 언어를 추가해도 + * 그 언어의 이름을 넣을 수 없었다 → `$locales` 순회로 전환. + * + * 측정 규율 (이 spec 을 고칠 사람에게): + * - **국가명 문자열을 하드코딩하지 않는다**("South Korea" 등). 시드 데이터가 바뀌면 + * 거짓 실패하고, 무엇보다 "ko 가 아닌 무언가"를 렌더한다는 사실만으로는 회귀를 못 잡는다. + * API 응답에서 그 로케일의 기대값을 **런타임에 도출**해 화면과 대조한다. + * - **로케일 목록을 하드코딩하지 않는다**. 언어는 계속 추가된다(PO 지시). 설치된 언어는 + * `$locales` 에서 읽는다. + * - 회귀(= ko 고정) 판정은 "en 값과 같다" 로는 부족하다. ko 와 en 이 같은 국가(예: 코드 + * 폴백)만 골라 보면 통과해 버린다. **ko 값과 다른 국가가 하나라도 있는지** 먼저 확인한 + * 뒤, 그 국가에서 화면값 === en 값을 단언한다. + * + * @scenario shipping_country_i18n locale=en|ja × surface=settings_list|add_form + * @effects country_names_localized_by_active_locale, + * country_names_not_pinned_to_ko, + * add_form_inputs_follow_installed_locales, + * add_button_enabled_by_any_locale_name + */ +import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth'; +import type { Page } from '@playwright/test'; + +const SHIPPING_SETTINGS_URL = '/admin/ecommerce/settings?tab=shipping'; + +/** 활성 배송국가의 원본 다국어 이름(name 객체)을 앱 상태에서 읽는다. */ +async function activeCountryNames(page: Page): Promise>> { + return page.evaluate(() => { + const core = (window as any).G7Core; + const shipping = core?.state?.getGlobal?.()?.modules?.['sirsoft-ecommerce']?.shipping; + return (shipping?.available_countries ?? []) + .filter((c: any) => c.is_active) + .map((c: any) => (typeof c.name === 'object' && c.name ? c.name : {})); + }); +} + +/** 현재 UI 로케일. */ +async function activeLocale(page: Page): Promise { + return page.evaluate(() => (window as any).__templateApp?.getConfig?.()?.locale ?? ''); +} + +/** 헤더 배송국가 셀렉터가 파생한 표시명 (init_actions/ActionDispatcher 경로 — 회귀 지점). */ +async function derivedHeaderNames(page: Page): Promise { + return page.evaluate(() => + ((window as any).G7Core?.state?.getGlobal?.()?.availableShippingCountries ?? []).map((c: any) => c.name), + ); +} + +/** + * 설치된 언어의 `{ 코드: 표시명 }` 맵. 하드코딩 금지 — 언어는 계속 추가된다(PO 지시). + * 레이아웃의 이름 입력칸 라벨이 쓰는 것과 동일한 출처(`appConfig.localeNames`)를 읽는다. + */ +async function localeDisplayNames(page: Page): Promise> { + return page.evaluate(() => (window as any).G7Core?.state?.getGlobal?.()?.appConfig?.localeNames ?? {}); +} + +/** 국가 표(`_local.form.shipping`)가 시드됐는지. */ +async function formSeeded(page: Page): Promise { + return page.evaluate(() => { + const form = (window as any).G7Core?.state?.getLocal?.()?.form; + return Array.isArray(form?.shipping?.available_countries) && form.shipping.available_countries.length > 0; + }); +} + +/** + * 배송설정 탭이 데이터를 받아 국가 목록을 **화면에** 그릴 때까지 기다린다. + * + * 상태(available_countries)만 기다리면 DOM 이 아직 비어 있어 body 텍스트가 `""` 다. + * + * 재진입 1회 허용 — 사전 결함(#459 검수 중 발견): settings 데이터소스의 `initLocal: "form"` + * 시드가 간헐적으로 유실돼 `_local.form` 이 비고 표가 "No shipping countries" 로 렌더된다. + * 이때 `_global.modules[...]` 와 데이터소스에는 국가가 정상 적재돼 있다(= 로딩 실패가 아님). + * 이 spec 의 대상은 다국어 표시이므로, 시드 유실 시 1회 재진입해 본 검증을 이어간다. + * **이 재진입은 그 결함의 회피가 아니라 격리다** — 결함 자체는 별도 이슈로 추적한다. + */ +async function gotoShippingSettings(page: Page, token: string): Promise { + await authenticatePage(page, token); + await page.setViewportSize({ width: 1280, height: 900 }); + + for (let attempt = 0; attempt < 2; attempt++) { + await page.goto(SHIPPING_SETTINGS_URL); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + await expect.poll(async () => (await activeCountryNames(page)).length, { timeout: 30_000 }).toBeGreaterThan(0); + + const seeded = await expect + .poll(async () => formSeeded(page), { timeout: 15_000 }) + .toBe(true) + .then(() => true) + .catch(() => false); + if (seeded) break; + if (attempt === 1) throw new Error('_local.form 시드 유실이 2회 연속 발생 (사전 결함)'); + } + + const locale = await activeLocale(page); + const expected = (await activeCountryNames(page)).map((n) => n[locale]).filter(Boolean); + if (expected.length > 0) { + await expect + .poll(async () => (await page.locator('body').innerText()).includes(expected[0]), { timeout: 30_000 }) + .toBe(true); + } +} + +test.describe('@sirsoft-ecommerce 배송국가 다국어', () => { + // @scenario shipping_country_i18n:settings_list + // @effects country_names_localized_by_active_locale, country_names_not_pinned_to_ko + test('국가 목록이 활성 로케일 값으로 렌더된다 (ko 고정 회귀 차단)', async ({ page, settingsToken }) => { + await gotoShippingSettings(page, settingsToken); + + const locale = await activeLocale(page); + const names = await activeCountryNames(page); + + // 이 테스트가 의미를 가지려면 "ko 와 다른 활성 로케일 값" 이 최소 1건 있어야 한다. + // (전부 같으면 ko 고정 회귀를 구분할 수 없어 거짓 통과한다.) + const discriminating = names.filter((n) => n[locale] && n.ko && n[locale] !== n.ko); + test.skip( + discriminating.length === 0, + `활성 로케일(${locale}) 값이 ko 와 모두 동일 — 회귀 판별 불가한 시드`, + ); + + const body = await page.locator('body').innerText(); + for (const name of discriminating) { + expect(body, `활성 로케일(${locale}) 이름이 렌더돼야 한다`).toContain(name[locale]); + expect(body, `ko 이름(${name.ko})이 렌더되면 ko 고정 회귀`).not.toContain(name.ko); + } + }); + + // @scenario shipping_country_i18n:header_derivation + // @effects country_names_localized_by_active_locale, country_names_not_pinned_to_ko + test('헤더 파생 목록도 활성 로케일 값을 쓴다 (ActionDispatcher 경로 $localized)', async ({ + page, + settingsToken, + }) => { + await gotoShippingSettings(page, settingsToken); + + const locale = await activeLocale(page); + const names = await activeCountryNames(page); + const discriminating = names.filter((n) => n[locale] && n.ko && n[locale] !== n.ko); + test.skip(discriminating.length === 0, `활성 로케일(${locale}) 값이 ko 와 모두 동일`); + + await expect.poll(async () => (await derivedHeaderNames(page)).length, { timeout: 20_000 }).toBeGreaterThan(0); + const derived = await derivedHeaderNames(page); + + // 파생 목록에 ko 전용 값이 섞이면 회귀. (init_actions 는 $locale 을 넘기지 않는 경로다.) + const koOnly = discriminating.map((n) => n.ko).filter((ko) => derived.includes(ko)); + expect(koOnly, `헤더 파생값: ${JSON.stringify(derived)}`).toEqual([]); + }); + + // @scenario shipping_country_i18n:add_form + // @effects add_form_inputs_follow_installed_locales, add_button_enabled_by_any_locale_name + test('국가 추가 폼의 이름 입력칸이 설치된 언어 수만큼 생기고, 어느 한 언어만 채워도 추가된다', async ({ + page, + settingsToken, + }) => { + await gotoShippingSettings(page, settingsToken); + + const localeNames = await localeDisplayNames(page); + const locales = Object.keys(localeNames); + expect(locales.length, '설치된 언어를 못 읽었다').toBeGreaterThan(0); + + await page.getByRole('button', { name: /Add Country|국가 추가|国追加/ }).click(); + + // 국가 코드 입력칸(placeholder="KR")이 있는 행이 곧 추가 폼이다. + const codeInput = page.locator('input[placeholder="KR"]'); + await expect(codeInput).toBeVisible({ timeout: 10_000 }); + + // 이름 입력칸은 placeholder 가 `... (언어표시명)` 으로 끝난다. + // 언어 목록을 하드코딩하지 않고 localeNames 로 셀렉터를 만든다. + const nameInputs = locales.map((code) => page.locator(`input[placeholder$="(${localeNames[code]})"]`)); + for (const [i, input] of nameInputs.entries()) { + await expect(input, `${locales[i]} 이름 입력칸이 있어야 한다`).toBeVisible({ timeout: 10_000 }); + } + + const addButton = page.getByRole('button', { name: /^(Add|추가|追加)$/ }); + await expect(addButton).toBeDisabled(); + + // 코드만 채워도 여전히 비활성 (이름이 전부 비어 있음) + await codeInput.fill('zz'); + await expect(addButton).toBeDisabled(); + + // 공백만 채워도 비활성 (.trim() 가드) + await nameInputs[0].fill(' '); + await expect(addButton, '공백만 입력하면 추가 불가여야 한다').toBeDisabled(); + await nameInputs[0].fill(''); + + // 마지막 언어 한 칸만 채워도 활성 — ko 고정 조건이면 여기서 실패한다. + await nameInputs[nameInputs.length - 1].fill('Zed Test Country'); + await expect(addButton, '어느 한 언어만 채워도 추가 가능해야 한다').toBeEnabled(); + + // 저장하지 않고 폼을 닫아 부작용을 남기지 않는다. + await page.getByRole('button', { name: /^(Cancel|취소|キャンセル)$/ }).first().click(); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Support/ApiDocSampleServiceTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Support/ApiDocSampleServiceTest.php new file mode 100644 index 00000000..9c12ada8 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Support/ApiDocSampleServiceTest.php @@ -0,0 +1,120 @@ +assertInstanceOf(ApiDocSampleSeeder::class, new ApiDocSampleService); + } + + #[Test] + public function 이커머스_핵심_도메인_대표_샘플_맵을_반환한다(): void + { + // 회원 소유 도메인(orders/reviews/inquiries/addresses/mileage)은 실측 사용자를 + // 전제한다 — 실측 커맨드 컨텍스트(코어 완전 샘플 사용자)를 재현하기 위해 먼저 생성. + User::factory()->create(); + + $map = (new ApiDocSampleService)->seed(); + + // 핵심 상세 GET 대상 도메인 키가 모두 존재 + foreach (['products', 'categories', 'orders', 'reviews', 'settings'] as $domain) { + $this->assertArrayHasKey($domain, $map, "도메인 '{$domain}' 누락"); + } + + $this->assertSame(Product::class, $map['products']['model']); + $this->assertNotEmpty($map['products']['value']); + } + + #[Test] + public function 문자열_path_param_실측용_맵을_제공한다(): void + { + $map = (new ApiDocSampleService)->seed(); + + // products: 공개 show(id)·admin show(identifier/code)·상품 하위(productId) + $product = Product::query()->where('product_code', self::SAMPLE_PRODUCT_CODE)->firstOrFail(); + $params = $map['products']['path_params']; + + $this->assertSame((string) $product->getKey(), $params['id']); + $this->assertSame((string) $product->getKey(), $params['identifier']); + $this->assertSame(self::SAMPLE_PRODUCT_CODE, $params['code']); + $this->assertSame((string) $product->getKey(), $params['productId']); + + // categories: 공개 show 는 slug + $this->assertSame(self::SAMPLE_CATEGORY_SLUG, $map['categories']['path_params']['slug']); + + // settings: 레코드 없이 유효 카테고리 문자열 고정 + $this->assertSame('basic_info', $map['settings']['path_params']['category']); + } + + #[Test] + public function 공개_상세_실측용_상품은_전시_상태다(): void + { + (new ApiDocSampleService)->seed(); + + $product = Product::query()->where('product_code', self::SAMPLE_PRODUCT_CODE)->firstOrFail(); + + // products/{id} 공개 show 는 display_status===visible 필터 → 실측 가능해야 함 + $this->assertSame('visible', $product->display_status->value); + $this->assertSame('on_sale', $product->sales_status->value); + } + + #[Test] + public function 공개_slug_조회용_카테고리는_활성이며_slug를_갖는다(): void + { + (new ApiDocSampleService)->seed(); + + $category = Category::query()->where('slug', self::SAMPLE_CATEGORY_SLUG)->firstOrFail(); + + $this->assertTrue((bool) $category->is_active); + $this->assertSame(self::SAMPLE_CATEGORY_SLUG, $category->slug); + } + + #[Test] + public function 재실행_시_샘플이_중복_생성되지_않는다(): void + { + $service = new ApiDocSampleService; + + $service->seed(); + $service->seed(); + + $this->assertSame( + 1, + Product::query()->where('product_code', self::SAMPLE_PRODUCT_CODE)->count() + ); + $this->assertSame( + 1, + Category::query()->where('slug', self::SAMPLE_CATEGORY_SLUG)->count() + ); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Upgrades/PruneEmptyShippingCountryNameLocalesTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Upgrades/PruneEmptyShippingCountryNameLocalesTest.php new file mode 100644 index 00000000..46f817e7 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Upgrades/PruneEmptyShippingCountryNameLocalesTest.php @@ -0,0 +1,234 @@ +settingsPath = storage_path('framework/testing/modules/sirsoft-ecommerce/settings'); + $this->settingsFile = $this->settingsPath.'/shipping.json'; + } + + protected function tearDown(): void + { + if (File::exists($this->settingsFile)) { + File::delete($this->settingsFile); + } + parent::tearDown(); + } + + private function migration(): object + { + $class = self::MIGRATION_NS; + + return new $class; + } + + private function context(): UpgradeContext + { + return new UpgradeContext( + fromVersion: '1.0.1', + toVersion: '1.0.2', + currentStep: '1.0.2', + ); + } + + private function writeSettings(array $settings): void + { + File::ensureDirectoryExists($this->settingsPath); + File::put($this->settingsFile, json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + } + + private function readCountries(): array + { + $settings = json_decode((string) File::get($this->settingsFile), true); + + return collect($settings['available_countries'])->keyBy('code')->all(); + } + + public function test_empty_string_locale_keys_are_removed(): void + { + $this->writeSettings([ + 'default_country' => 'KR', + 'available_countries' => [ + ['code' => 'KR', 'name' => ['ko' => '대한민국', 'en' => 'South Korea'], 'is_active' => true], + ['code' => 'FR', 'name' => ['ko' => '프랑스', 'en' => ''], 'is_active' => true], + ], + ]); + + ($this->migration())->run($this->context()); + + $byCode = $this->readCountries(); + // 채워진 값은 그대로 + $this->assertSame(['ko' => '대한민국', 'en' => 'South Korea'], $byCode['KR']['name']); + // 빈 문자열 키만 제거 — 부재로 정규화 + $this->assertSame(['ko' => '프랑스'], $byCode['FR']['name']); + } + + public function test_whitespace_only_locale_keys_are_removed(): void + { + // 공백만 입력한 경우도 빈 값으로 취급 + $this->writeSettings([ + 'default_country' => 'KR', + 'available_countries' => [ + ['code' => 'DE', 'name' => ['ko' => '독일', 'en' => ' ', 'ja' => "\t"], 'is_active' => false], + ], + ]); + + ($this->migration())->run($this->context()); + + $this->assertSame(['ko' => '독일'], $this->readCountries()['DE']['name']); + } + + public function test_surviving_values_are_trimmed(): void + { + $this->writeSettings([ + 'default_country' => 'KR', + 'available_countries' => [ + ['code' => 'IT', 'name' => ['ko' => ' 이탈리아 ', 'en' => ' Italy '], 'is_active' => true], + ], + ]); + + ($this->migration())->run($this->context()); + + $this->assertSame(['ko' => '이탈리아', 'en' => 'Italy'], $this->readCountries()['IT']['name']); + } + + public function test_no_locale_names_leaves_empty_object_not_null(): void + { + // 모든 로케일이 빈 값이면 name 은 빈 객체가 된다(키 자체는 유지) — 백엔드 검증이 + // `name` 을 array 로 요구하므로 null 로 만들면 저장이 깨진다. + $this->writeSettings([ + 'default_country' => 'KR', + 'available_countries' => [ + ['code' => 'XX', 'name' => ['ko' => '', 'en' => ''], 'is_active' => false], + ], + ]); + + ($this->migration())->run($this->context()); + + $this->assertSame([], $this->readCountries()['XX']['name']); + } + + public function test_country_names_are_never_filled_in(): void + { + // PO 결정: 이름을 새로 채우지 않는다. 부재 로케일은 언어팩이 읽기 시점에 보강한다. + $this->writeSettings([ + 'default_country' => 'KR', + 'available_countries' => [ + ['code' => 'KR', 'name' => ['ko' => '대한민국'], 'is_active' => true], + ], + ]); + + ($this->migration())->run($this->context()); + + // ja/en 이 추가되지 않아야 한다 + $this->assertSame(['ko' => '대한민국'], $this->readCountries()['KR']['name']); + } + + public function test_other_country_fields_are_preserved(): void + { + $this->writeSettings([ + 'default_country' => 'KR', + 'international_shipping_enabled' => true, + 'available_countries' => [ + ['code' => 'KR', 'name' => ['ko' => '대한민국', 'en' => ''], 'is_active' => true], + ], + ]); + + ($this->migration())->run($this->context()); + + $settings = json_decode((string) File::get($this->settingsFile), true); + $this->assertTrue($settings['international_shipping_enabled']); + $this->assertSame('KR', $settings['default_country']); + $this->assertTrue($settings['available_countries'][0]['is_active']); + $this->assertSame('KR', $settings['available_countries'][0]['code']); + } + + public function test_is_idempotent(): void + { + $this->writeSettings([ + 'default_country' => 'KR', + 'available_countries' => [ + ['code' => 'KR', 'name' => ['ko' => '대한민국'], 'is_active' => true], + ['code' => 'FR', 'name' => ['ko' => '프랑스', 'en' => ''], 'is_active' => true], + ], + ]); + + ($this->migration())->run($this->context()); + $first = $this->readCountries(); + + ($this->migration())->run($this->context()); + $second = $this->readCountries(); + + $this->assertSame($first, $second); + $this->assertSame(['ko' => '프랑스'], $second['FR']['name']); + } + + public function test_missing_file_is_noop(): void + { + if (File::exists($this->settingsFile)) { + File::delete($this->settingsFile); + } + + ($this->migration())->run($this->context()); + + $this->assertFalse(File::exists($this->settingsFile)); + } + + public function test_missing_available_countries_is_noop(): void + { + $this->writeSettings(['default_country' => 'KR']); + + ($this->migration())->run($this->context()); + + $settings = json_decode((string) File::get($this->settingsFile), true); + $this->assertSame('KR', $settings['default_country']); + $this->assertArrayNotHasKey('available_countries', $settings); + } + + public function test_non_array_name_is_left_untouched(): void + { + // 구 스키마 잔재(문자열 name)는 이 마이그레이션의 책임이 아니다 — 건드리지 않는다. + $this->writeSettings([ + 'default_country' => 'KR', + 'available_countries' => [ + ['code' => 'KR', 'name' => '대한민국', 'is_active' => true], + ], + ]); + + ($this->migration())->run($this->context()); + + $this->assertSame('대한민국', $this->readCountries()['KR']['name']); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_0_2.php b/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_0_2.php new file mode 100644 index 00000000..53223c89 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_0_2.php @@ -0,0 +1,21 @@ +settingsFilePath(); + + if (! File::exists($path)) { + $context->logger->info('[ecommerce:1.0.2] shipping.json 미존재 — 국가명 빈 로케일 청소 스킵'); + + return; + } + + $settings = json_decode((string) File::get($path), true); + if (! is_array($settings) || ! isset($settings['available_countries']) || ! is_array($settings['available_countries'])) { + $context->logger->info('[ecommerce:1.0.2] shipping.json 에 배송가능 국가 목록 없음 — 국가명 빈 로케일 청소 스킵'); + + return; + } + + $countries = $settings['available_countries']; + $prunedKeys = 0; + + foreach ($countries as $idx => $country) { + $name = $country['name'] ?? null; + + // 구 스키마 잔재(문자열 name)는 대상 아님 + if (! is_array($name)) { + continue; + } + + $cleaned = $this->pruneEmptyLocales($name); + + if ($cleaned !== $name) { + $prunedKeys += count($name) - count($cleaned); + $countries[$idx]['name'] = $cleaned; + } + } + + if ($countries === $settings['available_countries']) { + $context->logger->info('[ecommerce:1.0.2] 국가명에 빈 로케일 키 없음 — 변경 없음 (idempotent)'); + + return; + } + + $settings['available_countries'] = $countries; + + // 인코딩 플래그는 EcommerceSettingsService 의 저장부와 동일해야 한다. + // 다르면 이 스텝이 국가명과 무관한 값(URL 등)의 이스케이프까지 조용히 바꿔 쓴다. + File::put($path, json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + + $context->logger->info(sprintf( + '[ecommerce:1.0.2] 국가명 빈 로케일 키 %d개 제거 완료 (국가 %d개 검사)', + $prunedKeys, + count($countries), + )); + } + + /** + * 다국어 이름 맵에서 빈 값(공백 포함) 로케일 키를 제거하고 남은 값을 trim 합니다. + * + * 모든 로케일이 비어 있으면 빈 배열을 반환합니다 — null 이 아니라 빈 배열이어야 + * 백엔드 검증(`shipping.available_countries.*.name` => array)이 깨지지 않습니다. + * + * @param array $name 로케일 => 국가명 맵 + * @return array 빈 로케일이 제거되고 trim 된 맵 + */ + private function pruneEmptyLocales(array $name): array + { + $cleaned = []; + + foreach ($name as $locale => $value) { + if (! is_string($value)) { + continue; + } + + $trimmed = trim($value); + if ($trimmed === '') { + continue; + } + + $cleaned[$locale] = $trimmed; + } + + return $cleaned; + } + + /** + * shipping.json 의 저장 경로를 반환합니다. + * + * 테스트 환경에서는 운영 storage 오염을 막기 위해 framework/testing 경로를 사용합니다 + * (EcommerceSettingsService 의 저장 경로 분기와 동일). + * + * @return string 설정 파일 절대 경로 + */ + private function settingsFilePath(): string + { + $base = app()->runningUnitTests() + ? 'framework/testing/modules/' + : 'app/modules/'; + + return storage_path($base.self::MODULE_IDENTIFIER.'/settings/shipping.json'); + } +} diff --git a/modules/_bundled/sirsoft-page/docs/api/README.md b/modules/_bundled/sirsoft-page/docs/api/README.md new file mode 100644 index 00000000..6ad1ecf6 --- /dev/null +++ b/modules/_bundled/sirsoft-page/docs/api/README.md @@ -0,0 +1,15 @@ +# API 레퍼런스 문서 목차 + +> **소유**: 모듈 `sirsoft-page` · **생성**: `php artisan api:docgen` (실측 기반). +> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. + + +- **문서 수**: 2 · **엔드포인트 수**: 17 + +| 문서 | 도메인 | 엔드포인트 | +| --- | --- | --- | +| [attachments.md](attachments.md) | `attachments` | 3 | +| [pages.md](pages.md) | `pages` | 14 | + + + diff --git a/modules/_bundled/sirsoft-page/docs/api/attachments.md b/modules/_bundled/sirsoft-page/docs/api/attachments.md new file mode 100644 index 00000000..982f3db6 --- /dev/null +++ b/modules/_bundled/sirsoft-page/docs/api/attachments.md @@ -0,0 +1,175 @@ +# Attachments API 레퍼런스 + +> **소유**: module `sirsoft-page` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Attachments 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### POST /api/modules/sirsoft-page/admin/attachments + +- **라우트명**: `api.modules.sirsoft-page.admin.attachments.upload` +- **컨트롤러**: `Modules\Sirsoft\Page\Http\Controllers\Admin\PageAttachmentController@upload` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-page.pages.create` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| file | body | file | 예 | max 10240 | 업로드 파일 | +| page_id | body | integer | 아니오 | min 1 | page 식별자 | +| collection | body | string | 아니오 | max 100 | 첨부 컬렉션 그룹명 (첨부를 용도별로 묶는 키, 미지정 시 default) | +| temp_key | body | string | 아니오 | max 64 | 저장 전 임시 귀속 키. 아직 저장되지 않은 페이지의 첨부를 임시로 묶어 두고, 이후 페이지 저장 시 이 키로 확정 귀속합니다 (`page_id` 미지정 시 사용) | + +**요청 예시** + +```http +POST /api/modules/sirsoft-page/admin/attachments HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: multipart/form-data; boundary=----G7ExampleBoundary + +------G7ExampleBoundary +Content-Disposition: form-data; name="file"; filename="example.pdf" +Content-Type: application/octet-stream + +(바이너리 파일 내용) +------G7ExampleBoundary +Content-Disposition: form-data; name="page_id" + +1 +------G7ExampleBoundary +Content-Disposition: form-data; name="collection" + +예시값 +------G7ExampleBoundary +Content-Disposition: form-data; name="temp_key" + +예시값 +------G7ExampleBoundary-- +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-page.pages.create`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 페이지 첨부파일을 업로드합니다(최대 10MB). `file`(필수)과 함께 이미 저장된 페이지에 귀속시키려면 `page_id`, 아직 저장 전이면 `temp_key`(임시 귀속 후 페이지 저장 시 `store`/`update` 의 `temp_key` 로 확정)를 보냅니다. `collection` 으로 첨부 그룹을 구분합니다. 응답은 FileUploader 컴포넌트 규약(`data.data`)에 맞춰 `PageAttachmentResource` 를 201 로 반환합니다. 권한은 `pages.create` 를 요구합니다. + + +### PATCH /api/modules/sirsoft-page/admin/attachments/reorder + +- **라우트명**: `api.modules.sirsoft-page.admin.attachments.reorder` +- **컨트롤러**: `Modules\Sirsoft\Page\Http\Controllers\Admin\PageAttachmentController@reorder` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-page.pages.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| order | body | array | 예 | min 1 | 새 정렬 순서 배열. 각 원소는 `{"id": 첨부ID(정수), "order": 순서값(0 이상 정수)}` 형태이며, 이 순서대로 첨부 표시 순위가 갱신됩니다 | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-page/admin/attachments/reorder HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "order": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-page.pages.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 첨부파일 정렬 순서를 변경합니다. `order` 는 FileUploader 가 보내는 `[{id, order}]` 형태의 배열이며, 컨트롤러가 `[ID => order]` 매핑으로 변환해 `PageAttachmentService::reorder()` 에 전달합니다. 편집 화면에서 첨부 목록을 드래그로 재정렬할 때 사용합니다. 권한은 `pages.update` 를 요구합니다. + + +### DELETE /api/modules/sirsoft-page/admin/attachments/{id} + +- **라우트명**: `api.modules.sirsoft-page.admin.attachments.destroy` +- **컨트롤러**: `Modules\Sirsoft\Page\Http\Controllers\Admin\PageAttachmentController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-page.pages.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-page/admin/attachments/{id} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-page.pages.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 첨부파일을 삭제합니다. `{id}` 는 첨부파일 ID 입니다(라우트-모델 바인딩이 아닌 `int $id`). `PageAttachmentService::deleteAttachment()` 가 DB 레코드와 실제 저장 파일을 함께 정리합니다. 미존재 시 404 를 반환합니다. 권한은 `pages.update` 를 요구합니다. + + diff --git a/modules/_bundled/sirsoft-page/docs/api/pages.md b/modules/_bundled/sirsoft-page/docs/api/pages.md new file mode 100644 index 00000000..2b586642 --- /dev/null +++ b/modules/_bundled/sirsoft-page/docs/api/pages.md @@ -0,0 +1,1001 @@ +# Pages API 레퍼런스 + +> **소유**: module `sirsoft-page` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Pages 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/modules/sirsoft-page/admin/pages + +- **라우트명**: `api.modules.sirsoft-page.admin.pages.index` +- **컨트롤러**: `Modules\Sirsoft\Page\Http\Controllers\Admin\PageController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-page.pages.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| published | query | boolean | 아니오 | — | 발행 여부 (발행된 항목만 필터) | +| search | query | string | 아니오 | max 100 | 검색어 (지정한 검색 대상 필드에서 부분 일치) | +| search_field | query | string | 아니오 | `all`, `title`, `slug` | 검색 대상 필드명 (검색어를 적용할 컬럼) | +| filters | query | array | 아니오 | — | 추가 필터 조건 맵 (필드별 조건) | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | +| sort_by | query | string | 아니오 | `created_at`, `published_at` | 정렬 기준 필드명 | +| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) | + +**요청 예시** + +```http +GET /api/modules/sirsoft-page/admin/pages?published=1&search=%EC%98%88%EC%8B%9C%EA%B0%92&search_field=all&filters=%EC%98%88%EC%8B%9C%EA%B0%92&per_page=1&page=1&sort_by=created_at&sort_order=asc HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `29` | 기본 키 (내부 식별자) | +| slug | string | `apidoc-sample-page` | URL 슬러그 (고유) | +| title | string | `API 문서 샘플 페이지` | 페이지 제목 (다국어 JSON) | +| published | boolean | `true` | 발행 여부 (true: 발행, false: 미발행) | +| published_at | string | `2026-07-06 23:57:18` | published 일시 | +| current_version | integer | `2` | 현재 버전 번호 | +| creator | object | `{"uuid":"a231747f-e82e-4cf2-9ae1-a261849dce40","name":"AP…` | 생성자 정보 객체 (uuid/name/email — creator 관계 파생) | +| created_at | string | `2026-07-06 23:57:18` | 생성 일시 | +| updated_at | string | `2026-07-06 23:57:18` | 최종 수정 일시 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "페이지 정보를 조회했습니다.", + "data": { + "data": [ + { + "id": 10, + "slug": "apidoc-sample-page", + "title": "API 문서 샘플 페이지", + "published": true, + "published_at": "2026-07-08 10:51:59", + "current_version": 2, + "creator": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자" + }, + "created_at": "2026-07-08 10:51:59", + "updated_at": "2026-07-08 10:51:59", + "is_owner": true, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + }, + { + "id": 4, + "slug": "terms", + "title": "이용약관", + "published": true, + "published_at": "2026-07-08 10:44:43", + "current_version": 1, + "creator": null, + "created_at": "2026-07-08 10:44:43", + "updated_at": "2026-07-08 10:44:43", + "is_owner": false, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + }, + "... (총 7건 중 2건 표시)" + ], + "meta": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 7 + }, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-page.pages.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자 페이지 목록을 조회합니다. `published`, `search`/`search_field`(제목·슬러그), `filters` 로 필터링하고 `sort_by`(created_at, published_at)·`sort_order` 로 정렬하며 `per_page`·`page` 로 페이지네이션합니다. `PageService::getPages()` 가 반환하는 `PageCollection` 으로 래핑되어 목록 항목마다 `is_owner`·`abilities` 권한 메타가 함께 내려갑니다. 관리자 페이지 관리 화면(목록 그리드)의 데이터 소스입니다. + + +### POST /api/modules/sirsoft-page/admin/pages + +- **라우트명**: `api.modules.sirsoft-page.admin.pages.store` +- **컨트롤러**: `Modules\Sirsoft\Page\Http\Controllers\Admin\PageController@store` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-page.pages.create` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | body | string | 예 | max 255 | URL 친화 식별자 (slug) | +| title | body | array | 예 | — | 제목 | +| content | body | string | 아니오 | — | 본문 내용 | +| content_mode | body | string | 아니오 | `html`, `text` | 본문 편집 모드. `html` 은 리치 에디터 HTML, `text` 는 평문으로 저장·렌더링됩니다 (미지정 시 `html`) | +| published | body | boolean | 아니오 | — | 발행 여부 (발행된 항목만 필터) | +| seo_meta | body | array | 아니오 | — | SEO 메타 정보 맵. 하위 키 `title`(max 255)·`description`(max 500)·`keywords`(max 500)를 담습니다 | +| temp_key | body | string | 아니오 | max 64 | 저장 전 첨부 업로드 시 발급받은 임시 키. 생성된 페이지에 임시 첨부를 귀속시키는 데 사용합니다 | + +**요청 예시** + +```http +POST /api/modules/sirsoft-page/admin/pages HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "slug": "example-key", + "title": [ + "예시 제목" + ], + "content": "예시 내용입니다.", + "content_mode": "html", + "published": true, + "seo_meta": [ + "예시값" + ], + "temp_key": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-page.pages.create`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 새 페이지를 생성합니다. `slug`(고유 필수)·`title`(다국어 배열 필수)·`content`·`content_mode`(html/text)·`published`·`seo_meta` 를 받아 `PageService::createPage()` 로 저장하고, `creator`/`updater`/`attachments` 를 eager load 한 `PageResource` 를 201 로 반환합니다. `temp_key` 는 페이지 저장 전 임시 업로드한 첨부(첨부 업로드 시 발급받은 키)를 생성된 페이지에 귀속시키는 데 씁니다. + + +### PATCH /api/modules/sirsoft-page/admin/pages/bulk-publish + +- **라우트명**: `api.modules.sirsoft-page.admin.pages.bulk-publish` +- **컨트롤러**: `Modules\Sirsoft\Page\Http\Controllers\Admin\PageController@bulkPublish` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-page.pages.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| ids | body | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | +| published | body | boolean | 예 | — | 발행 여부 (발행된 항목만 필터) | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-page/admin/pages/bulk-publish HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "ids": [ + "예시값" + ], + "published": true +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-page.pages.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 여러 페이지의 발행 상태를 한 번에 변경합니다. `ids`(대상 페이지 ID 배열, 최소 1개)와 `published`(true=발행, false=미발행)를 받아 `PageService::bulkChangePublishStatus()` 로 일괄 적용하고, 실제 변경된 건수를 `count` 로 반환합니다. 목록 화면의 다중 선택 후 일괄 발행/미발행 액션에 사용합니다. + + +### POST /api/modules/sirsoft-page/admin/pages/check-slug + +- **라우트명**: `api.modules.sirsoft-page.admin.pages.check-slug` +- **컨트롤러**: `Modules\Sirsoft\Page\Http\Controllers\Admin\PageController@checkSlug` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-page.pages.create` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | body | string | 예 | max 255 | URL 친화 식별자 (slug) | +| exclude_id | body | integer | 아니오 | min 1 | exclude 식별자 | + +**요청 예시** + +```http +POST /api/modules/sirsoft-page/admin/pages/check-slug HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "slug": "example-key", + "exclude_id": 1 +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-page.pages.create`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 슬러그 중복 여부를 확인합니다. `slug`(필수)와 선택 `exclude_id`(수정 화면에서 자기 자신을 중복 검사에서 제외)를 받아 `{ "exists": true|false }` 를 반환합니다. 페이지 생성/수정 폼에서 슬러그 입력 시 실시간 중복 검증에 사용합니다. 권한은 `pages.create` 를 요구합니다. + + +### DELETE /api/modules/sirsoft-page/admin/pages/{page} + +- **라우트명**: `api.modules.sirsoft-page.admin.pages.destroy` +- **컨트롤러**: `Modules\Sirsoft\Page\Http\Controllers\Admin\PageController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-page.pages.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| page | path | string | 예 | — | 조회할 페이지 번호 (1부터 시작) | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-page/admin/pages/10 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "페이지가 삭제되었습니다.", + "data": null +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-page.pages.delete`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 페이지를 소프트 삭제합니다. `{page}` 는 페이지 ID 입니다(라우트-모델 바인딩이 아닌 `int $id` 로 서비스에서 조회). 존재하지 않으면 404, 스코프 권한 밖이면 403 을 반환합니다. 삭제는 DB CASCADE 가 아니라 `PageService::deletePage()` 를 통해 수행되어 관련 훅·정리 로직이 보장됩니다. 권한은 `pages.delete` 를 요구합니다. + + +### GET /api/modules/sirsoft-page/admin/pages/{page} + +- **라우트명**: `api.modules.sirsoft-page.admin.pages.show` +- **컨트롤러**: `Modules\Sirsoft\Page\Http\Controllers\Admin\PageController@show` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-page.pages.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| page | path | string | 예 | — | 조회할 페이지 번호 (1부터 시작) | + +**요청 예시** + +```http +GET /api/modules/sirsoft-page/admin/pages/10 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `29` | 기본 키 (내부 식별자) | +| slug | string | `apidoc-sample-page` | URL 슬러그 (고유) | +| title | object | `{"ko":"API 문서 샘플 페이지","en":"API Doc Sample Page"}` | 페이지 제목 (다국어 JSON) | +| content | object | `{"ko":"

API 레퍼런스 실측용 완전 샘플 페이지 본문입니다.<\/p>","en":"

Co…` | 페이지 본문 (다국어 JSON) | +| content_mode | string | `html` | 본문 형식 (html, text) | +| published | boolean | `true` | 발행 여부 (true: 발행, false: 미발행) | +| published_at | string | `2026-07-06 23:57:18` | published 일시 | +| seo_meta | object | `{"title":"Et qui sapiente veritatis.","description":"Et m…` | SEO 메타 정보 (title, description, keywords) | +| current_version | integer | `2` | 현재 버전 번호 | +| creator | object | `{"uuid":"a231747f-e82e-4cf2-9ae1-a261849dce40","name":"AP…` | 생성자 정보 객체 (uuid/name/email — creator 관계 파생) | +| updater | object | `{"uuid":"a231747f-e82e-4cf2-9ae1-a261849dce40","name":"AP…` | 최종 수정자 정보 객체 (uuid/name — updater 관계 파생, 로드된 경우에만 포함) | +| attachments | array | `[{"id":9,"hash":"xtfalwomchas","original_filename":"deser…` | 페이지에 귀속된 첨부파일 목록 (PageAttachmentResource 배열 — id/hash/원본파일명/URL 등, 로드된 경우에만 포함) | +| created_at | string | `2026-07-06 23:57:18` | 생성 일시 | +| updated_at | string | `2026-07-06 23:57:18` | 최종 수정 일시 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "페이지 정보를 조회했습니다.", + "data": { + "id": 10, + "slug": "apidoc-sample-page", + "title": { + "ko": "API 문서 샘플 페이지", + "en": "API Doc Sample Page" + }, + "content": { + "ko": "

API 레퍼런스 실측용 완전 샘플 페이지 본문입니다.

", + "en": "

Complete sample page body for API reference probing.

" + }, + "content_mode": "html", + "published": true, + "published_at": "2026-07-08 10:51:59", + "seo_meta": { + "title": "Dolorum et aut officia ipsam doloribus inventore.", + "description": "Sint ipsa impedit enim natus tempore quasi dignissimos praesentium numquam rerum tempore nam.", + "keywords": "illo,pariatur,quis,necessitatibus,aperiam" + }, + "current_version": 2, + "creator": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자" + }, + "updater": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자" + }, + "attachments": [ + { + "id": 4, + "hash": "ninrdtsmhhta", + "original_filename": "nesciunt.jpg", + "mime_type": "image/jpeg", + "size": 7124557, + "collection": "attachments", + "order": 0, + "is_image": true, + "download_url": "/api/modules/sirsoft-page/pages/attachment/ninrdtsmhhta", + "preview_url": "/api/modules/sirsoft-page/pages/attachment/ninrdtsmhhta/preview", + "created_at": "2026-07-08 10:51:59" + } + ], + "created_at": "2026-07-08 10:51:59", + "updated_at": "2026-07-08 10:51:59", + "is_owner": true, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-page.pages.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자용 페이지 상세를 조회합니다. `{page}` 는 페이지 ID 입니다. `creator`·`updater`·`attachments` 관계를 eager load 한 `PageResource` 를 반환하며, 목록과 달리 `content`(다국어 본문)·`seo_meta` 전체를 포함합니다. 페이지 편집 화면 진입 시 폼 초기값 로딩에 사용합니다. 미존재 404, 스코프 밖 403. + + +### PUT /api/modules/sirsoft-page/admin/pages/{page} + +- **라우트명**: `api.modules.sirsoft-page.admin.pages.update` +- **컨트롤러**: `Modules\Sirsoft\Page\Http\Controllers\Admin\PageController@update` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-page.pages.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| page | path | string | 예 | — | 조회할 페이지 번호 (1부터 시작) | +| slug | body | string | 아니오 | max 255 | URL 친화 식별자 (slug) | +| title | body | array | 예 | — | 제목 | +| content | body | string | 아니오 | — | 본문 내용 | +| content_mode | body | string | 아니오 | `html`, `text` | 본문 편집 모드. `html` 은 리치 에디터 HTML, `text` 는 평문으로 저장·렌더링됩니다 (미지정 시 `html`) | +| published | body | boolean | 아니오 | — | 발행 여부 (발행된 항목만 필터) | +| seo_meta | body | array | 아니오 | — | SEO 메타 정보 맵. 하위 키 `title`(max 255)·`description`(max 500)·`keywords`(max 500)를 담습니다 | +| temp_key | body | string | 아니오 | max 64 | 저장 전 첨부 업로드 시 발급받은 임시 키. 새로 업로드한 첨부를 이 페이지에 귀속시키는 데 사용합니다 | + +**요청 예시** + +```http +PUT /api/modules/sirsoft-page/admin/pages/10 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "slug": "example-key", + "title": [ + "예시 제목" + ], + "content": "예시 내용입니다.", + "content_mode": "html", + "published": true, + "seo_meta": [ + "예시값" + ], + "temp_key": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-page.pages.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 페이지를 수정합니다. `{page}` 는 페이지 ID 입니다. `title`(필수)·`slug`·`content`·`content_mode`·`published`·`seo_meta` 를 받아 `PageService::updatePage()` 로 반영하고, 수정 시 이전 상태가 버전 이력으로 적재됩니다(`current_version` 증가). `creator`/`updater`/`attachments` 를 eager load 한 `PageResource` 를 반환합니다. 미존재 404, 스코프 밖 403. + + +### PATCH /api/modules/sirsoft-page/admin/pages/{page}/publish + +- **라우트명**: `api.modules.sirsoft-page.admin.pages.publish` +- **컨트롤러**: `Modules\Sirsoft\Page\Http\Controllers\Admin\PageController@publish` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-page.pages.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| page | path | string | 예 | — | 조회할 페이지 번호 (1부터 시작) | +| published | body | boolean | 예 | — | 발행 여부 (발행된 항목만 필터) | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-page/admin/pages/10/publish HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "published": true +} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `10` | 기본 키 (내부 식별자) | +| slug | string | `apidoc-sample-page` | URL 친화 식별자 (slug) | +| title | object | `{"ko":"API 문서 샘플 페이지","en":"API Doc Sample Page"}` | 제목 | +| content | object | `{"ko":"

API 레퍼런스 실측용 완전 샘플 페이지 본문입니다.<\/p>","en":"

Co…` | 본문 내용 | +| content_mode | string | `html` | 본문 형식 (html, text) | +| published | boolean | `true` | 발행 여부 (true: 발행, false: 미발행) | +| published_at | string | `2026-07-08 15:03:15` | published 일시 | +| seo_meta | object | `{"title":"Dolorum et aut officia ipsam doloribus inventor…` | SEO 메타 정보 (title, description, keywords) | +| current_version | integer | `2` | 현재 버전 번호 | +| created_at | string | `2026-07-08 10:51:59` | 생성 일시 | +| updated_at | string | `2026-07-08 15:03:15` | 최종 수정 일시 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "페이지 발행 상태가 변경되었습니다.", + "data": { + "id": 10, + "slug": "apidoc-sample-page", + "title": { + "ko": "API 문서 샘플 페이지", + "en": "API Doc Sample Page" + }, + "content": { + "ko": "

API 레퍼런스 실측용 완전 샘플 페이지 본문입니다.

", + "en": "

Complete sample page body for API reference probing.

" + }, + "content_mode": "html", + "published": true, + "published_at": "2026-07-08 15:03:15", + "seo_meta": { + "title": "Dolorum et aut officia ipsam doloribus inventore.", + "description": "Sint ipsa impedit enim natus tempore quasi dignissimos praesentium numquam rerum tempore nam.", + "keywords": "illo,pariatur,quis,necessitatibus,aperiam" + }, + "current_version": 2, + "created_at": "2026-07-08 10:51:59", + "updated_at": "2026-07-08 15:03:15", + "is_owner": true, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-page.pages.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 단일 페이지의 발행 상태만 토글합니다. `{page}` 는 페이지 ID, `published`(필수 boolean)로 발행/미발행을 전환합니다. 본문·슬러그 등 다른 필드는 건드리지 않으며 변경된 `PageResource` 를 반환합니다. 목록/상세 화면의 발행 토글 스위치에 사용합니다. 미존재 404, 스코프 밖 403. + + +### GET /api/modules/sirsoft-page/admin/pages/{page}/versions + +- **라우트명**: `api.modules.sirsoft-page.admin.pages.versions.index` +- **컨트롤러**: `Modules\Sirsoft\Page\Http\Controllers\Admin\PageController@versions` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-page.pages.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| page | path | string | 예 | — | 조회할 페이지 번호 (1부터 시작) | + +**요청 예시** + +```http +GET /api/modules/sirsoft-page/admin/pages/10/versions HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `40` | 기본 키 (내부 식별자) | +| page_id | integer | `29` | page 식별자 (연관 리소스 참조) | +| version | integer | `2` | 버전 순번 (1부터 증가, 페이지 수정 시마다 채번) | +| title | object | `{"ko":"API 문서 샘플 페이지 (v2)","en":"API Doc Sample Page (v2)"}` | 페이지 제목 (다국어 JSON) | +| content | object | `{"ko":"

수정 버전 본문.<\/p>","en":"

Revised version body.<…` | 페이지 본문 (다국어 JSON) | +| content_mode | string | `html` | 본문 형식 (html, text) | +| seo_meta | null | `null` | SEO 메타 정보 (title, description, keywords) | +| changes_summary | string | `본문 보강` | 이 버전에서 무엇이 바뀌었는지 요약한 변경 설명 (버전 생성 시 기록, 없으면 null) | +| creator | object | `{"uuid":"a231747f-e82e-4cf2-9ae1-a261849dce40","name":"AP…` | 생성자 정보 객체 (uuid/name/email — creator 관계 파생) | +| created_at | string | `2026-07-06 23:57:18` | 생성 일시 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "페이지 정보를 조회했습니다.", + "data": [ + { + "id": 14, + "page_id": 10, + "version": 2, + "title": { + "ko": "API 문서 샘플 페이지 (v2)", + "en": "API Doc Sample Page (v2)" + }, + "content": { + "ko": "

수정 버전 본문.

", + "en": "

Revised version body.

" + }, + "content_mode": "html", + "seo_meta": null, + "changes_summary": "본문 보강", + "creator": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자" + }, + "created_at": "2026-07-08 10:51:59" + }, + { + "id": 13, + "page_id": 10, + "version": 1, + "title": { + "ko": "API 문서 샘플 페이지 (v1)", + "en": "API Doc Sample Page (v1)" + }, + "content": { + "ko": "

초기 버전 본문.

", + "en": "

Initial version body.

" + }, + "content_mode": "html", + "seo_meta": null, + "changes_summary": "최초 작성", + "creator": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자" + }, + "created_at": "2026-07-08 10:51:59" + } + ] +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-page.pages.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 페이지의 버전 이력 목록을 조회합니다. `{page}` 는 페이지 ID 입니다. 각 항목은 그 시점의 `title`·`content`·`content_mode`·`seo_meta` 스냅샷과 `version` 번호·`changes_summary`(변경 요약)·`creator`(작성자)를 담습니다. 편집 화면의 버전 이력 패널에서 과거 버전 목록을 보여 주는 데이터 소스입니다. 미존재 404, 스코프 밖 403. + + +### GET /api/modules/sirsoft-page/admin/pages/{page}/versions/{versionId} + +- **라우트명**: `api.modules.sirsoft-page.admin.pages.versions.show` +- **컨트롤러**: `Modules\Sirsoft\Page\Http\Controllers\Admin\PageController@showVersion` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-page.pages.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| page | path | string | 예 | — | 조회할 페이지 번호 (1부터 시작) | +| versionId | path | string | 예 | — | 대상 version의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-page/admin/pages/10/versions/{versionId} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-page.pages.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 특정 버전의 상세 스냅샷을 조회합니다. `{page}` 는 페이지 ID, `{versionId}` 는 버전 ID 입니다. 해당 버전의 전체 본문(`content`)과 메타를 담은 `PageVersionResource` 를 반환하며, 복원 전 미리보기·버전 간 비교에 사용합니다. `{versionId}` 는 라우트-모델 바인딩이 아니어서 문서 생성 시 자동 실측에서 제외되며, 응답 shape 은 `versions.index` 항목과 동일합니다. 미존재 404, 스코프 밖 403. + + +### POST /api/modules/sirsoft-page/admin/pages/{page}/versions/{versionId}/restore + +- **라우트명**: `api.modules.sirsoft-page.admin.pages.versions.restore` +- **컨트롤러**: `Modules\Sirsoft\Page\Http\Controllers\Admin\PageController@restoreVersion` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-page.pages.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| page | path | string | 예 | — | 조회할 페이지 번호 (1부터 시작) | +| versionId | path | string | 예 | — | 대상 version의 식별자 | + +**요청 예시** + +```http +POST /api/modules/sirsoft-page/admin/pages/10/versions/{versionId}/restore HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-page.pages.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 페이지를 특정 버전 시점으로 복원합니다. `{page}` 는 페이지 ID, `{versionId}` 는 복원 대상 버전 ID 입니다. `PageService::restoreVersion()` 이 그 버전의 내용을 현재 페이지에 반영하고(복원 자체도 새 버전으로 적재), 복원된 `PageResource` 를 반환합니다. 편집 화면 버전 이력 패널의 "이 버전으로 복원" 액션에 사용합니다. 미존재 404, 스코프 밖 403. + + +### GET /api/modules/sirsoft-page/pages/attachment/{hash} + +- **라우트명**: `api.modules.sirsoft-page.pages.attachment.download` +- **컨트롤러**: `Modules\Sirsoft\Page\Http\Controllers\User\PublicPageAttachmentController@download` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| hash | path | string | 예 | — | 대상 리소스의 해시 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-page/pages/attachment/{hash} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 공개 첨부파일을 다운로드합니다(해시 기반, 12자). 실제 미들웨어는 `optional.sanctum` 으로, 비로그인 사용자도 접근할 수 있습니다(위 표의 `auth:sanctum` 은 생성기가 sanctum 미들웨어를 인식한 표기이며, 실제로는 토큰이 없어도 통과). 발행된 페이지의 첨부는 누구나, 미발행 페이지의 첨부는 `sirsoft-page.pages.read` 관리자만 다운로드할 수 있고, 그 외에는 404 로 존재를 숨깁니다. 브라우저 직접 GET(토큰 미탑재)을 위해 해시 라우트로 단일화되었습니다. 파일 스트리밍 응답이므로 실측 대상이 아닙니다. + + +### GET /api/modules/sirsoft-page/pages/attachment/{hash}/preview + +- **라우트명**: `api.modules.sirsoft-page.pages.attachment.preview` +- **컨트롤러**: `Modules\Sirsoft\Page\Http\Controllers\User\PublicPageAttachmentController@preview` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| hash | path | string | 예 | — | 대상 리소스의 해시 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-page/pages/attachment/{hash}/preview HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 이미지 첨부 썸네일을 인라인으로 미리봅니다(해시 기반, 12자). 썸네일 `` 는 토큰을 실을 수 없으므로 발행/미발행과 무관하게 공개 서빙합니다. 미발행 콘텐츠의 썸네일은 해시를 보유해야만 조회 가능(비추측성)하며, 실제 파일 다운로드는 `download` 의 권한 게이트로 보호됩니다. 이미지 스트리밍 응답이므로 실측 대상이 아닙니다. + + +### GET /api/modules/sirsoft-page/pages/{slug} + +- **라우트명**: `api.modules.sirsoft-page.pages.show` +- **컨트롤러**: `Modules\Sirsoft\Page\Http\Controllers\User\PublicPageController@show` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | + +**요청 예시** + +```http +GET /api/modules/sirsoft-page/pages/terms HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `4` | 기본 키 (내부 식별자) | +| slug | string | `terms` | URL 친화 식별자 (slug) | +| title | string | `이용약관` | 제목 | +| content | string | `

제1조 (목적)

\n

[이용약관의 목적을 입력하세요.]

\n\n

제2조 (정의)

\n

[주요 용어의 정의를 입력하세요.]

\n\n

제3조 (약관의 효력 및 변경)

\n

[약관의 효력 및 변경에 관한 내용을 입력하세요.]

\n\n

제4조 (서비스의 제공 및 변경)

\n

[서비스 제공 및 변경에 관한 내용을 입력하세요.]

\n\n

제5조 (회원의 의무)

\n

[회원의 의무에 관한 내용을 입력하세요.]

", + "content_mode": "html", + "is_preview": false, + "published_at": "2026-07-08 10:44:43", + "seo_meta": null, + "current_version": 1, + "attachments": [], + "created_at": "2026-07-08 10:44:43", + "updated_at": "2026-07-08 10:44:43" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 발행된 페이지를 슬러그로 조회하는 공개 엔드포인트입니다. 실제 미들웨어는 `optional.sanctum` 으로 비로그인 사용자도 발행 페이지를 볼 수 있습니다(위 표의 `auth:sanctum` 은 생성기 표기이며 토큰 없이도 통과). `sirsoft-page.pages.read` 권한을 가진 관리자는 미발행 페이지도 사용자 화면에서 미리볼 수 있으며(이때 응답에 preview 표시가 실림), 비로그인·일반 회원은 미발행 시 404 를 받습니다. `PublicPageResource` 는 관리자용 상세보다 축소된 공개 표면을 노출하고 `attachments` 를 포함합니다. `{slug}` 는 문자열 라우트 파라미터라 문서 생성 시 자동 실측에서 제외됩니다. + + diff --git a/modules/_bundled/sirsoft-page/src/Support/ApiDoc/ApiDocSampleService.php b/modules/_bundled/sirsoft-page/src/Support/ApiDoc/ApiDocSampleService.php new file mode 100644 index 00000000..62eaa3bd --- /dev/null +++ b/modules/_bundled/sirsoft-page/src/Support/ApiDoc/ApiDocSampleService.php @@ -0,0 +1,113 @@ + 도메인 => 대표 레코드 정보 + */ + public function seed(): array + { + $map = []; + + $page = $this->seedPage(); + $map['pages'] = ['model' => Page::class, 'key' => $page->getRouteKeyName(), 'value' => (string) $page->getRouteKey()]; + + return $map; + } + + /** + * 완전한 발행 페이지 샘플(버전 이력 + 첨부 포함)을 생성합니다. + * + * @return Page 대표 페이지 레코드 + */ + private function seedPage(): Page + { + $page = Page::query()->where('slug', self::SAMPLE_SLUG)->first(); + + if ($page) { + return $page; + } + + $actor = $this->sampleActor(); + + $page = Page::factory()->published()->withSeoMeta()->create([ + 'slug' => self::SAMPLE_SLUG, + 'title' => ['ko' => 'API 문서 샘플 페이지', 'en' => 'API Doc Sample Page'], + 'content' => [ + 'ko' => '

API 레퍼런스 실측용 완전 샘플 페이지 본문입니다.

', + 'en' => '

Complete sample page body for API reference probing.

', + ], + 'content_mode' => 'html', + 'current_version' => 2, + 'created_by' => $actor?->id, + 'updated_by' => $actor?->id, + ]); + + // 버전 이력 2건 — versions 조회 실측이 배열 항목 필드를 확보하도록. + PageVersion::factory()->create([ + 'page_id' => $page->id, + 'version' => 1, + 'title' => ['ko' => 'API 문서 샘플 페이지 (v1)', 'en' => 'API Doc Sample Page (v1)'], + 'content' => ['ko' => '

초기 버전 본문.

', 'en' => '

Initial version body.

'], + 'content_mode' => 'html', + 'changes_summary' => '최초 작성', + 'created_by' => $actor?->id, + ]); + + PageVersion::factory()->create([ + 'page_id' => $page->id, + 'version' => 2, + 'title' => ['ko' => 'API 문서 샘플 페이지 (v2)', 'en' => 'API Doc Sample Page (v2)'], + 'content' => ['ko' => '

수정 버전 본문.

', 'en' => '

Revised version body.

'], + 'content_mode' => 'html', + 'changes_summary' => '본문 보강', + 'created_by' => $actor?->id, + ]); + + // 첨부 1건 — 첨부 임베드 응답 필드가 채워지도록. + PageAttachment::factory()->image()->create([ + 'page_id' => $page->id, + 'created_by' => $actor?->id, + ]); + + return $page->refresh(); + } + + /** + * 샘플 소유자/작성자로 쓸 사용자를 반환합니다. + * + * 코어 완전 샘플 사용자(먼저 시드됨)를 우선하고, 없으면 첫 사용자로 폴백합니다. + * + * @return User|null 샘플 사용자 (없으면 null) + */ + private function sampleActor(): ?User + { + return User::query()->where('email', 'apidoc-sample-user@example.com')->first() + ?? User::query()->orderBy('id')->first(); + } +} diff --git a/modules/_bundled/sirsoft-page/tests/Unit/Support/ApiDocSampleServiceTest.php b/modules/_bundled/sirsoft-page/tests/Unit/Support/ApiDocSampleServiceTest.php new file mode 100644 index 00000000..081a0bfd --- /dev/null +++ b/modules/_bundled/sirsoft-page/tests/Unit/Support/ApiDocSampleServiceTest.php @@ -0,0 +1,65 @@ +assertInstanceOf(ApiDocSampleSeeder::class, new ApiDocSampleService); + } + + #[Test] + public function 페이지_도메인_대표_샘플_맵을_반환한다(): void + { + $map = (new ApiDocSampleService)->seed(); + + $this->assertArrayHasKey('pages', $map); + $this->assertSame(Page::class, $map['pages']['model']); + $this->assertArrayHasKey('key', $map['pages']); + $this->assertNotEmpty($map['pages']['value']); + } + + #[Test] + public function 대표_샘플_페이지는_발행_상태로_버전_이력과_첨부를_갖는다(): void + { + (new ApiDocSampleService)->seed(); + + $page = Page::query()->where('slug', 'apidoc-sample-page')->first(); + + $this->assertNotNull($page); + $this->assertTrue($page->published); + $this->assertNotNull($page->published_at); + $this->assertNotNull($page->seo_meta); + $this->assertSame(2, $page->versions()->count()); + $this->assertSame(1, $page->attachments()->count()); + } + + #[Test] + public function 재실행_시_샘플이_중복_생성되지_않는다(): void + { + $service = new ApiDocSampleService; + + $service->seed(); + $countAfterFirst = Page::query()->where('slug', 'apidoc-sample-page')->count(); + + $service->seed(); + $countAfterSecond = Page::query()->where('slug', 'apidoc-sample-page')->count(); + + $this->assertSame(1, $countAfterFirst); + $this->assertSame(1, $countAfterSecond); + } +} diff --git a/plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md b/plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md new file mode 100644 index 00000000..19f7e45f --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md @@ -0,0 +1,15 @@ +# API 레퍼런스 문서 목차 + +> **소유**: 플러그인 `sirsoft-ckeditor5` · **생성**: `php artisan api:docgen` (실측 기반). +> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. + + +- **문서 수**: 2 · **엔드포인트 수**: 2 + +| 문서 | 도메인 | 엔드포인트 | +| --- | --- | --- | +| [images.md](images.md) | `images` | 1 | +| [upload.md](upload.md) | `upload` | 1 | + + + diff --git a/plugins/_bundled/sirsoft-ckeditor5/docs/api/images.md b/plugins/_bundled/sirsoft-ckeditor5/docs/api/images.md new file mode 100644 index 00000000..f6c43dad --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/docs/api/images.md @@ -0,0 +1,66 @@ +# Images API 레퍼런스 + +> **소유**: plugin `sirsoft-ckeditor5` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Images 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/plugins/sirsoft-ckeditor5/images/{hash} + +- **라우트명**: `api.plugins.sirsoft-ckeditor5.api.sirsoft-ckeditor5.images.serve` +- **컨트롤러**: `Plugins\Sirsoft\Ckeditor5\Http\Controllers\ImageServeController@serve` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| hash | path | string | 예 | — | 업로드 시 발급된 12자리 소문자 16진수 이미지 해시(라우트 제약 `[a-f0-9]{12}`). 저장된 `` 의 마지막 경로 세그먼트로, 이 값으로 서빙할 이미지를 조회한다. | + +**요청 예시** + +```http +GET /api/plugins/sirsoft-ckeditor5/images/a1b2c3d4e5f6 HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + +CKEditor5 본문에 삽입된 이미지를 실제로 내려주는 공개 서빙 엔드포인트다. 에디터가 저장한 HTML 의 `` 를 브라우저가 직접 GET 하므로 **인증이 없다**(`PublicBaseController`). 발행된 콘텐츠를 비로그인 독자가 열람하는 시나리오를 지원하기 위한 설계다. + +- `{hash}` 는 라우트 제약 `where('hash', '[a-f0-9]{12}')` 로 12자리 소문자 16진수만 매칭된다. 형식이 어긋나면 라우트 자체가 매칭되지 않아 404 가 된다. 이 값은 이미지 업로드 시 발급된 `download_url` 의 마지막 경로 세그먼트다. +- 성공 응답은 JSON 이 아니라 **이미지 바이너리 스트림**(`StreamedResponse`, `Content-Type` 은 이미지 MIME)이다. `ResponseHelper` envelope 로 감싸지 않는다. +- `ImageServeService::findByHash()` 가 레코드를 찾지 못하거나 스토리지에 실제 파일이 없어 `serve()` 가 null 을 반환하면 `messages.image.not_found`(404) 표준 JSON 을 반환한다. + +**응답 예시** (실패 시에만 JSON — 성공 시에는 이미지 바이너리) + +```json +{ "success": false, "data": null, "message": "이미지를 찾을 수 없습니다.", "error": { "code": "not_found" } } +``` + + diff --git a/plugins/_bundled/sirsoft-ckeditor5/docs/api/upload.md b/plugins/_bundled/sirsoft-ckeditor5/docs/api/upload.md new file mode 100644 index 00000000..5381be58 --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/docs/api/upload.md @@ -0,0 +1,83 @@ +# Upload API 레퍼런스 + +> **소유**: plugin `sirsoft-ckeditor5` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Upload 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### POST /api/plugins/sirsoft-ckeditor5/upload + +- **라우트명**: `api.plugins.sirsoft-ckeditor5.api.sirsoft-ckeditor5.upload` +- **컨트롤러**: `Plugins\Sirsoft\Ckeditor5\Http\Controllers\ImageUploadController@upload` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| upload | body | file | 예 | max 2048 | 에디터에 드롭/붙여넣은 이미지 파일 1개(multipart). 허용 MIME 은 `jpeg,jpg,png,gif,webp`, 최대 크기는 플러그인 설정 `imageMaxSizeMb`(기본 2MB) 로 결정된다. | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-ckeditor5/upload HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: multipart/form-data; boundary=----G7ExampleBoundary + +------G7ExampleBoundary +Content-Disposition: form-data; name="upload"; filename="example.pdf" +Content-Type: application/octet-stream + +(바이너리 파일 내용) +------G7ExampleBoundary-- +``` + +**응답 필드** (`data` 내부) + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +CKEditor5 의 SimpleUploadAdapter 가 에디터에 드롭/붙여넣은 이미지를 업로드하는 관리자 엔드포인트다. 컨트롤러가 `AdminBaseController` 를 상속하므로 실제 인증은 `auth:sanctum` **에 더해 관리자(admin) 미들웨어**가 적용된다(생성기 표기는 `auth:sanctum` 만 노출). + +- **응답 형식이 표준 envelope 가 아니다.** SimpleUploadAdapter 규격상 성공 시 HTTP 201 + 최상위 `{"url": "..."}`, 실패 시 4xx/5xx + `{"error": {"message": "..."}}` 를 반환한다. `ResponseHelper` 를 쓰지 않으므로 `data`/`success` 필드가 없다. +- **요청 파라미터**: multipart body 의 `upload` 필드(이미지 파일 1개). 허용 MIME 은 `jpeg,jpg,png,gif,webp`, 최대 크기는 플러그인 설정 `imageMaxSizeMb`(기본 2MB) 로 동적 결정된다. 검증 실패도 CKEditor 규격(`{"error":{"message":...}}`, HTTP 422)으로 응답한다. +- **선택 권한 게이트**: query 파라미터 `permission` 이 주어지면, 현재 사용자가 해당 권한을 갖지 못한 경우 403 `{"error":{"message":...}}`. 에디터를 임베드하는 화면이 업로드 권한을 세분화할 때 사용한다. +- 업로드 성공 시 반환하는 `url` 은 공개 서빙 엔드포인트(`GET /images/{hash}`)의 절대 URL 이다. + +**응답 예시** (성공 — CKEditor 규격, envelope 아님) + +```json +{ "url": "https://example.com/api/plugins/sirsoft-ckeditor5/images/a1b2c3d4e5f6" } +``` + +**오류 예시** (검증/권한/서버 오류 공통 — HTTP 422/403/500) + +```json +{ "error": { "message": "이미지 파일만 업로드할 수 있습니다." } } +``` + + diff --git a/plugins/_bundled/sirsoft-ckeditor5/src/Support/ApiDoc/ApiDocSampleService.php b/plugins/_bundled/sirsoft-ckeditor5/src/Support/ApiDoc/ApiDocSampleService.php new file mode 100644 index 00000000..bc251f8b --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/src/Support/ApiDoc/ApiDocSampleService.php @@ -0,0 +1,80 @@ +}> 도메인 => 대표 레코드 정보 + */ + public function seed(): array + { + if (! class_exists(Ckeditor5ImageUpload::class)) { + return []; + } + + $image = $this->seedImage(); + + $entry = [ + 'model' => Ckeditor5ImageUpload::class, + 'key' => 'hash', + 'value' => (string) $image->hash, + 'path_params' => [ + 'hash' => (string) $image->hash, + ], + ]; + + return [ + 'images' => $entry, + ]; + } + + /** + * 고정 해시의 이미지 업로드 레코드 1건을 멱등 생성합니다. + * + * @return Ckeditor5ImageUpload 샘플 이미지 업로드 + */ + private function seedImage(): Ckeditor5ImageUpload + { + $actor = User::query()->orderBy('id')->first(); + + return Ckeditor5ImageUpload::query()->firstOrCreate( + ['hash' => self::SAMPLE_HASH], + [ + 'original_name' => 'apidoc-sample.png', + 'file_path' => 'ckeditor5/apidoc-sample.png', + 'storage_disk' => 'public', + 'file_size' => 1024, + 'mime_type' => 'image/png', + 'uploaded_by' => $actor?->getKey(), + ] + ); + } +} diff --git a/plugins/_bundled/sirsoft-ckeditor5/tests/Unit/Support/ApiDocSampleServiceTest.php b/plugins/_bundled/sirsoft-ckeditor5/tests/Unit/Support/ApiDocSampleServiceTest.php new file mode 100644 index 00000000..28b6a80e --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/tests/Unit/Support/ApiDocSampleServiceTest.php @@ -0,0 +1,83 @@ +create([ + 'email' => 'apidoc-sample-user@example.com', + 'name' => 'API 문서 샘플 사용자', + ]); + } + + /** + * 시더가 계약을 구현한다. + */ + public function test_seeder_implements_contract(): void + { + $this->assertInstanceOf(ApiDocSampleSeeder::class, new ApiDocSampleService); + } + + /** + * 시더가 고정 해시(라우트 제약 `[a-f0-9]{12}` 충족)의 이미지 대표 샘플을 생성한다. + */ + public function test_seed_creates_image_with_valid_hash(): void + { + (new ApiDocSampleService)->seed(); + + $image = Ckeditor5ImageUpload::query()->where('hash', self::SAMPLE_HASH)->first(); + + $this->assertNotNull($image); + $this->assertMatchesRegularExpression('/^[a-f0-9]{12}$/', $image->hash); + } + + /** + * 시더가 images 도메인의 path_params 맵을 반환한다. + */ + public function test_seed_returns_images_path_params_map(): void + { + $map = (new ApiDocSampleService)->seed(); + + $this->assertArrayHasKey('images', $map); + $entry = $map['images']; + $this->assertSame(Ckeditor5ImageUpload::class, $entry['model']); + $this->assertSame('hash', $entry['key']); + $this->assertArrayHasKey('hash', $entry['path_params']); + $this->assertSame(self::SAMPLE_HASH, $entry['path_params']['hash']); + } + + /** + * 재실행 시 이미지가 중복 생성되지 않는다 (멱등). + */ + public function test_seed_is_idempotent(): void + { + (new ApiDocSampleService)->seed(); + (new ApiDocSampleService)->seed(); + + $this->assertSame(1, Ckeditor5ImageUpload::query()->where('hash', self::SAMPLE_HASH)->count()); + } +} diff --git a/plugins/_bundled/sirsoft-gdpr/docs/api/README.md b/plugins/_bundled/sirsoft-gdpr/docs/api/README.md new file mode 100644 index 00000000..cf0f8d6c --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/docs/api/README.md @@ -0,0 +1,17 @@ +# API 레퍼런스 문서 목차 + +> **소유**: 플러그인 `sirsoft-gdpr` · **생성**: `php artisan api:docgen` (실측 기반). +> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. + + +- **문서 수**: 4 · **엔드포인트 수**: 15 + +| 문서 | 도메인 | 엔드포인트 | +| --- | --- | --- | +| [consent.md](consent.md) | `consent` | 7 | +| [consent-log.md](consent-log.md) | `consent-log` | 1 | +| [policy-versions.md](policy-versions.md) | `policy-versions` | 4 | +| [settings.md](settings.md) | `settings` | 3 | + + + diff --git a/plugins/_bundled/sirsoft-gdpr/docs/api/consent-log.md b/plugins/_bundled/sirsoft-gdpr/docs/api/consent-log.md new file mode 100644 index 00000000..305824e1 --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/docs/api/consent-log.md @@ -0,0 +1,94 @@ +# Consent Log API 레퍼런스 + +> **소유**: plugin `sirsoft-gdpr` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Consent Log 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/plugins/sirsoft-gdpr/admin/consent-log + +- **라우트명**: `api.plugins.sirsoft-gdpr.admin.consent-log.index` +- **컨트롤러**: `Plugins\Sirsoft\Gdpr\Http\Controllers\Admin\GdprAdminConsentLogController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-gdpr.privacy.view` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-gdpr/admin/consent-log HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `83` | 기본 키 (내부 식별자) | +| user_id | integer | `130` | user 식별자 (연관 리소스 참조) | +| session_id | string | `c50916f6-58ee-42a6-a046-60a0c9ecfad2` | session 식별자 (연관 리소스 참조) | +| consent_key | string | `cookie_marketing` | 변경된 동의 항목 키 (쿠키 카테고리 키). 어떤 동의 항목의 부여/철회가 기록된 행인지 나타냅니다 | +| action | string | `revoked` | 이 이력 행의 변경 유형 (`granted`=동의 부여, `revoked`=철회, `acknowledged`=확인). Art.7(1) 입증 트레일의 행위 구분입니다 | +| source | string | `banner` | 동의 변경이 발생한 경로 (`banner`=쿠키 배너, `preference_center`=환경설정 센터, `mypage`=마이페이지, 그 외 `register`/`order`/`withdraw`) | +| policy_version | string | `10` | 변경 시점의 정책 버전 문자열. 해당 동의가 어느 정책 버전 기준으로 표명되었는지 기록해 정책 갱신 후 재동의 판정과 감사에 사용됩니다 | +| categories | object | `{"cookie_analytics":false,"cookie_marketing":false,"cooki…` | 변경 시점 전체 카테고리별 동의 여부 스냅샷 (키→boolean). MySQL JSON 컬럼이라 키가 알파벳 순으로 정규화 저장됩니다 | +| categories_snapshot | array | `[{"key":"cookie_necessary","label_key":"sirsoft-gdpr.cons…` | `categories` 객체를 관리자 화면 iteration 친화 배열(`{key, label_key, granted}`)로 변환한 것. 필수→분석→마케팅 UX 위계 순으로 재정렬되며 `label_key`는 프론트가 다국어로 해석합니다 | +| ip_address | string | `127.0.0.1` | 요청/행위가 발생한 IP 주소 | +| user_agent | string | `Mozilla/5.0 (Windows NT 10.0; Win64; …` | 동의 변경 요청의 User-Agent 문자열. DPO 감사 시 동의 표명 단말/브라우저를 식별하는 용도이며 회원 삭제 시 NULL로 익명화됩니다 | +| created_at | string | `2026-07-03 00:24:27` | 생성 일시 | +| user | object | `{"id":130,"uuid":"a21d03b4-df0b-4aa6-ab7a-f51143f6375a","…` | 동의 주체 회원 정보(id/uuid/name/email). 관계가 로드된 경우에만 포함되며 게스트 이력이거나 삭제로 익명화된 경우 null입니다 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "data": [], + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 0, + "from": null, + "to": null + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-gdpr.privacy.view`)이 없는 경우 | + + + +**설명** 관리자 감사 화면에서 `gdpr_user_consent_histories` 테이블의 동의 로그를 페이지네이션으로 조회합니다. `auth:sanctum`과 `sirsoft-gdpr.privacy.view` 권한이 필요합니다. `email`, `session_id`, `consent_keys[]`, `actions[]`(granted|revoked), `sources[]`(banner|preference_center|mypage), `per_page`(1~100, 기본 20) 쿼리 필터를 지원합니다. DPO 감사 용도로 IP 주소와 User-Agent까지 노출되는 조회 전용 엔드포인트입니다. + + diff --git a/plugins/_bundled/sirsoft-gdpr/docs/api/consent.md b/plugins/_bundled/sirsoft-gdpr/docs/api/consent.md new file mode 100644 index 00000000..fafef2f0 --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/docs/api/consent.md @@ -0,0 +1,428 @@ +# Consent API 레퍼런스 + +> **소유**: plugin `sirsoft-gdpr` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Consent 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### POST /api/plugins/sirsoft-gdpr/consent/cookie + +- **라우트명**: `api.plugins.sirsoft-gdpr.consent.cookie` +- **컨트롤러**: `Plugins\Sirsoft\Gdpr\Http\Controllers\Public\GdprCookieConsentController@store` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/plugins/sirsoft-gdpr/consent/cookie HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +_대표 에러 없음 (공개 조회). _ + + + +**설명** 공개 쿠키 동의 배너에서 방문자가 선택한 카테고리별 동의를 저장합니다. `optional.sanctum` 라우트로 게스트와 회원 모두 호출할 수 있으며, 회원(sanctum 토큰 보유)이면 user_id 기준으로 status를 upsert하고 history를 남기고, 게스트면 session_id 기준으로 history를 기록합니다. 게스트가 처음 호출해 세션 식별자가 없으면 UUID 기반 `gdpr_session` 쿠키(1년, SameSite=Lax)를 응답에 발급해 첨부합니다. 동의 철회 시 실제 쿠키 파기는 이 엔드포인트가 아니라 클라이언트 정리기와 후속 응답의 CookieConsentMiddleware가 담당합니다. + + +### GET /api/plugins/sirsoft-gdpr/consent/cookie/status + +- **라우트명**: `api.plugins.sirsoft-gdpr.consent.cookie.status` +- **컨트롤러**: `Plugins\Sirsoft\Gdpr\Http\Controllers\Public\GdprCookieConsentController@status` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-gdpr/consent/cookie/status HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| has_consented | boolean | `false` | consented 여부 | +| consents | array | `[]` | 현재 방문자의 활성 쿠키 동의 목록(카테고리별 현재 선택 상태). 배너가 기존 동의를 반영해 토글 초기값을 그릴 때 사용합니다 | +| needs_renewal | boolean | `false` | 과거 동의 이력은 있으나 현재 정책 버전으로는 미동의인 상태(정책 갱신 후 재확인 필요). 동의 이력이 전혀 없는 신규 게스트는 `false`입니다 | +| current_policy_version | string | `10` | 현재 발행된 최신 정책 버전 문자열(정책 버전 서비스 기준). 방문자 동의 버전과 비교해 배너 재노출 여부를 판단합니다 | +| is_member | boolean | `true` | member 여부 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "has_consented": false, + "consents": [], + "needs_renewal": false, + "current_policy_version": "1", + "is_member": true + } +} +``` + +**에러 응답** + +_대표 에러 없음 (공개 조회). _ + + + +**설명** 현재 방문자의 쿠키 동의 상태를 반환해 배너 재노출 여부를 판단하게 합니다. `optional.sanctum` 라우트로 회원이면 user_id 기준, 미인증이면 session_id 기준으로 조회합니다. `has_consented`는 현재 정책 버전으로 동의를 완료했는지, `needs_renewal`은 과거 동의는 있으나 현재 정책 버전으로는 미동의인 상태(정책 갱신 후 재확인 필요)를 나타내며 동의 이력이 전혀 없는 신규 게스트는 `false`입니다. `is_member`는 배너가 회원 전용 분기(기존 동의 유지 버튼 등)를 노출할지 결정하는 데 사용합니다. + + +### POST /api/plugins/sirsoft-gdpr/consent/grant + +- **라우트명**: `api.plugins.sirsoft-gdpr.consent.grant` +- **컨트롤러**: `Plugins\Sirsoft\Gdpr\Http\Controllers\User\GdprConsentController@grant` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/plugins/sirsoft-gdpr/consent/grant HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** 마이페이지 「내 동의 현황」에서 회원이 특정 동의 항목을 부여하거나 다시 동의할 때 호출합니다. `auth:sanctum` 인증이 필요하며, `consent_key` 하나를 대상으로 동의를 `true`로 갱신하고 source를 `mypage`로 기록합니다. GDPR Art.7(3)의 자유 변경권을 구현한 것으로, 철회했던 항목의 재동의와 신규 동의를 동일하게 처리하며 history 행이 남습니다. `consent_key`의 화이트리스트 검사는 FormRequest에서 수행합니다. + + +### GET /api/plugins/sirsoft-gdpr/consent/history + +- **라우트명**: `api.plugins.sirsoft-gdpr.consent.history` +- **컨트롤러**: `Plugins\Sirsoft\Gdpr\Http\Controllers\User\GdprConsentController@history` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-gdpr/consent/history HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| histories | array | `[]` | 회원 본인의 동의 변경 이력 배열. 각 항목은 `consent_key`, `action`(granted/revoked), `source`, `policy_version`, `categories`, `created_at`로 구성되며 부여/철회 기록을 시간순으로 제공합니다 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "histories": [] + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** 회원 본인의 동의 이력을 반환해 마이페이지 감사 목적으로 표시합니다. `auth:sanctum` 인증이 필요하며, `histories` 배열로 동의 부여/철회 기록을 시간순으로 제공합니다. 조회 전용이므로 부수 효과는 없습니다. + + +### GET /api/plugins/sirsoft-gdpr/consent/me + +- **라우트명**: `api.plugins.sirsoft-gdpr.consent.me` +- **컨트롤러**: `Plugins\Sirsoft\Gdpr\Http\Controllers\User\GdprConsentController@me` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-gdpr/consent/me HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| user_id | integer | `166` | user 식별자 (연관 리소스 참조) | +| needs_renewal | boolean | `false` | 회원의 활성 동의 중 옛 정책 버전인 항목이 있어 재동의가 필요한지 여부. 마이페이지가 「전체 다시 동의」 안내를 노출할지 판단합니다 | +| current_policy_version | string | `10` | 현재 발행된 최신 정책 버전 문자열. 각 동의 항목의 `policy_version`과 비교해 항목별 갱신 필요 여부를 계산하는 기준입니다 | +| consents | array | `[{"id":null,"consent_key":"cookie_necessary","consent_lab…` | 카탈로그의 모든 쿠키 카테고리와 회원 status를 합친 동의 매트릭스. 항목마다 다국어 라벨(`consent_label`), 필수 여부(`is_required`), 현재 동의 상태(`is_consented`), 철회/재동의 가능 여부(`can_revoke`/`can_grant`), 항목별 갱신 필요(`needs_renewal_this_item`)를 담아 한 화면에서 철회·재동의·신규 동의를 처리하게 합니다 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "user_id": 6, + "needs_renewal": false, + "current_policy_version": "1", + "consents": [ + { + "id": null, + "consent_key": "cookie_necessary", + "consent_label": "필수 쿠키", + "consent_description": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.", + "consent_category": "necessary", + "is_required": true, + "is_consented": false, + "can_revoke": false, + "can_grant": false, + "needs_renewal_this_item": false, + "consented_at": null, + "revoked_at": null, + "consent_count": 0, + "policy_version": null, + "last_source": null + }, + { + "id": null, + "consent_key": "cookie_functional", + "consent_label": "기능 쿠키", + "consent_description": "사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.", + "consent_category": "functional", + "is_required": false, + "is_consented": false, + "can_revoke": false, + "can_grant": true, + "needs_renewal_this_item": false, + "consented_at": null, + "revoked_at": null, + "consent_count": 0, + "policy_version": null, + "last_source": null + }, + { + "id": null, + "consent_key": "cookie_analytics", + "consent_label": "분석 쿠키", + "consent_description": "방문자가 사이트를 어떻게 이용하는지 익명으로 측정해 더 나은 서비스를 만드는 데 사용됩니다. (예: Google Analytics, Hotjar)", + "consent_category": "analytics", + "is_required": false, + "is_consented": false, + "can_revoke": false, + "can_grant": true, + "needs_renewal_this_item": false, + "consented_at": null, + "revoked_at": null, + "consent_count": 0, + "policy_version": null, + "last_source": null + }, + { + "id": null, + "consent_key": "cookie_marketing", + "consent_label": "마케팅 쿠키", + "consent_description": "관심사에 맞는 광고를 보여주거나, 광고가 얼마나 효과적이었는지 측정하는 데 사용됩니다. SNS 영상 임베드 등도 포함됩니다. (예: Facebook 픽셀, Google 광고, YouTube 영상)", + "consent_category": "marketing", + "is_required": false, + "is_consented": false, + "can_revoke": false, + "can_grant": true, + "needs_renewal_this_item": false, + "consented_at": null, + "revoked_at": null, + "consent_count": 0, + "policy_version": null, + "last_source": null + } + ] + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** 마이페이지 「내 동의 현황」 화면의 데이터 소스로, 회원 본인의 전체 동의 매트릭스를 반환합니다. `auth:sanctum` 인증이 필요하며, 활성 동의만이 아니라 카탈로그의 모든 카테고리와 회원 상태를 합쳐 노출해 철회·재동의·신규 동의를 한 화면에서 처리하도록 합니다(Art.7(3) 대칭성). 함께 반환되는 `needs_renewal`과 `current_policy_version`으로 정책 버전 갱신 이후 재동의가 필요한지 판단합니다. 조회 전용입니다. + + +### POST /api/plugins/sirsoft-gdpr/consent/renew-all + +- **라우트명**: `api.plugins.sirsoft-gdpr.consent.renew_all` +- **컨트롤러**: `Plugins\Sirsoft\Gdpr\Http\Controllers\User\GdprConsentController@renewAll` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/plugins/sirsoft-gdpr/consent/renew-all HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| renewed | integer | `0` | 이번 호출로 현재 정책 버전으로 재동의 처리된 활성 선택형 동의 항목 수. 필수 쿠키와 이미 철회한 항목·이미 현재 버전인 항목은 제외되며, 프론트 toast 메시지의 `{renewed}` 보간값으로 사용됩니다 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "활성 항목 {renewed} 개를 새 정책 버전으로 갱신했습니다.", + "data": { + "renewed": 0 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** 정책 버전이 갱신된 뒤 회원의 활성 선택형 동의를 현재 정책 버전으로 일괄 재동의 처리합니다. `auth:sanctum` 인증이 필요하며, 필수 쿠키와 이미 철회한 항목은 대상에서 제외됩니다. 의사 변경 없이 재동의 의사 표명으로 처리되어 갱신된 각 항목마다 `action=granted` history 행이 누적됩니다(Art.7(1) 입증 트레일). 응답에는 갱신 건수가 포함되어 프론트 toast 메시지에 사용됩니다. + + +### POST /api/plugins/sirsoft-gdpr/consent/revoke + +- **라우트명**: `api.plugins.sirsoft-gdpr.consent.revoke` +- **컨트롤러**: `Plugins\Sirsoft\Gdpr\Http\Controllers\User\GdprConsentController@revoke` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/plugins/sirsoft-gdpr/consent/revoke HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** 마이페이지 「내 동의 현황」에서 회원이 특정 동의 항목을 철회할 때 호출합니다. `auth:sanctum` 인증이 필요하며, `consent_key` 하나를 대상으로 동의를 `false`로 갱신하고 source를 `mypage`로 기록하며 history 행을 남깁니다. `consent_key`의 화이트리스트 검사는 FormRequest에서 수행합니다. + + diff --git a/plugins/_bundled/sirsoft-gdpr/docs/api/policy-versions.md b/plugins/_bundled/sirsoft-gdpr/docs/api/policy-versions.md new file mode 100644 index 00000000..ba69f25f --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/docs/api/policy-versions.md @@ -0,0 +1,294 @@ +# Policy Versions API 레퍼런스 + +> **소유**: plugin `sirsoft-gdpr` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Policy Versions 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/plugins/sirsoft-gdpr/admin/policy-versions + +- **라우트명**: `api.plugins.sirsoft-gdpr.admin.policy-versions.index` +- **컨트롤러**: `Plugins\Sirsoft\Gdpr\Http\Controllers\Admin\GdprAdminPolicyVersionController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-gdpr.privacy.view` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-gdpr/admin/policy-versions HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `10` | 기본 키 (내부 식별자) | +| version | integer | `10` | 정책 버전 번호(단조 증가 정수). 발행 시마다 1씩 증가하며 회원 동의 시점의 버전과 비교해 재동의 필요 여부를 판정하는 기준입니다 | +| change_type | string | `material` | 변경 종류. `material`=카테고리 key/설명·slug 변경 등 모든 회원 재동의를 트리거하는 중대 변경, `non_material`=도메인/라벨/힌트 정정 등 재동의 미유발, `initial`=최초 발행(시드) | +| memo | string | `Chrome MCP 정밀 점검 — 마이페이지 정책 갱신 트리거 (P…` | 발행 사유 메모(최대 500자). 수동 발행 시 운영자가 입력하는 감사 추적용 설명으로 자동 감지 밖의 변경 배경을 남깁니다 | +| created_at | string | `2026-06-18 09:27:39` | 생성 일시 | +| publisher | object | `{"uuid":"a1e0a91a-fba6-491c-a53e-7285a5686857","name":"관리…` | 이 버전을 발행한 운영자 정보(uuid/name/email). raw FK(created_by)는 노출하지 않고 관계가 로드된 경우에만 포함되며, 발행자가 없으면 null입니다 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "data": [ + { + "id": 1, + "version": 1, + "change_type": "initial", + "memo": null, + "created_at": "2026-07-07 16:38:26", + "publisher": null + } + ], + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 25, + "total": 1, + "from": 1, + "to": 1 + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-gdpr.privacy.view`)이 없는 경우 | + + + +**설명** 관리자 화면에서 발행된 개인정보 처리방침 정책 버전 이력을 version 내림차순 페이지네이션으로 조회합니다. `auth:sanctum`과 `sirsoft-gdpr.privacy.view` 권한이 필요합니다. `per_page`(1~100, 기본 20) 쿼리 파라미터로 페이지 크기를 조절할 수 있습니다. 조회 전용입니다. + + +### POST /api/plugins/sirsoft-gdpr/admin/policy-versions + +- **라우트명**: `api.plugins.sirsoft-gdpr.admin.policy-versions.store` +- **컨트롤러**: `Plugins\Sirsoft\Gdpr\Http\Controllers\Admin\GdprAdminPolicyVersionController@store` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-gdpr.privacy.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| memo | body | string | 예 | min 1, max 500 | 발행 사유 메모(1~500자, 필수). 자동 감지 밖의 변경을 인지하고 명시적으로 새 버전을 발행할 때 감사 추적용으로 남기는 설명입니다 | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-gdpr/admin/policy-versions HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "memo": "예시값" +} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| uuid | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | 외부 노출용 UUID (URL/API 식별자, 내부 id 비노출) | +| name | string | `API 문서 샘플 사용자` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| email | string | `apidoc-sample-user@example.com` | 이 버전을 발행한 운영자의 이메일. publisher 관계에서 노출되며 감사 화면에서 발행자를 식별하는 용도입니다 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "새 정책 버전이 발행되었습니다. 모든 회원이 다음 방문 시 재동의를 진행합니다.", + "data": { + "data": { + "id": 6, + "version": 2, + "0": "... (총 6건 중 2건 표시)" + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-gdpr.privacy.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 운영자가 새 개인정보 처리방침 정책 버전을 수동으로 발행합니다. `auth:sanctum`과 `sirsoft-gdpr.privacy.update` 권한이 필요하며, `memo`(1~500자)가 필수입니다. 자동 감지되는 변경(카테고리 key/설명·slug 변경) 밖의 변경(정책 본문 외부 수정, 법인명 변경 후 의도적 재동의 트리거 등)을 발행 시점의 현재 settings 스냅샷과 함께 새 버전으로 기록하며, settings 자체는 변경하지 않습니다. 이 발행이 회원의 `needs_renewal`을 true로 만드는 트리거가 됩니다. + + +### GET /api/plugins/sirsoft-gdpr/admin/policy-versions/current + +- **라우트명**: `api.plugins.sirsoft-gdpr.admin.policy-versions.current` +- **컨트롤러**: `Plugins\Sirsoft\Gdpr\Http\Controllers\Admin\GdprAdminPolicyVersionController@current` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-gdpr.privacy.view` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-gdpr/admin/policy-versions/current HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| uuid | string | `a1e0a91a-fba6-491c-a53e-7285a5686857` | 외부 노출용 UUID (URL/API 식별자, 내부 id 비노출) | +| name | string | `관리자` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| email | string | `heuristing@gmail.com` | 이 버전을 발행한 운영자의 이메일. publisher 관계에서 노출되며 감사 화면에서 발행자를 식별하는 용도입니다 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "data": { + "id": 1, + "version": 1, + "0": "... (총 6건 중 2건 표시)" + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-gdpr.privacy.view`)이 없는 경우 | + + + +**설명** 현재 발행된 최신 정책 버전을 반환합니다. `auth:sanctum`과 `sirsoft-gdpr.privacy.view` 권한이 필요합니다. 발행된 정책 버전 row가 하나도 없으면 `data`가 null입니다. 조회 전용입니다. + + +### GET /api/plugins/sirsoft-gdpr/admin/policy-versions/{version} + +- **라우트명**: `api.plugins.sirsoft-gdpr.admin.policy-versions.show` +- **컨트롤러**: `Plugins\Sirsoft\Gdpr\Http\Controllers\Admin\GdprAdminPolicyVersionController@show` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-gdpr.privacy.view` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| version | path | string | 예 | — | 대상 버전 (버전 문자열) | + +**요청 예시** + +```http +GET /api/plugins/sirsoft-gdpr/admin/policy-versions/1 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| banner_enabled | boolean | `true` | 발행 시점 스냅샷의 쿠키 배너·자동 차단 통합 활성 여부. `true`일 때만 게스트에게 배너가 노출되고 도메인 차단 엔진이 동작합니다 | +| banner_position | string | `bottom_bar` | 발행 시점의 쿠키 배너 노출 위치(bottom_bar / bottom_left_popup / bottom_right_popup / centered_modal) | +| blocked_domains | object | `{"analytics":["google-analytics.com","*.google-analytics.…` | 발행 시점에 설정된 카테고리별 차단 도메인 패턴(키→도메인 배열). 동의 전 로드를 차단하는 대상이며, 분쟁 시 당시 어떤 도메인이 차단 대상이었는지 확인하는 근거입니다 | +| cookie_categories | array | `[{"key":"necessary","label":{"en":"Strictly Necessary","k…` | 발행 시점의 쿠키 카테고리 카탈로그 배열. 각 항목은 `key`, 필수 여부, 다국어 `label`/`description`을 담으며, 회원 분쟁 시 당시 동의 대상 카테고리 본문을 확인하는 근거가 됩니다 | +| legal_entity_name | string | `` | 발행 시점에 표기된 개인정보 처리 법인/사업자명. 미설정 시 빈 문자열입니다 | +| privacy_policy_slug | string | `privacy` | 발행 시점의 개인정보 처리방침 페이지 slug. 배너/마이페이지의 방침 링크 대상이며, 미설정 시 빈 값입니다 | +| data_storage_location | string | `` | 발행 시점에 표기된 데이터 저장 위치 문자열(국가 단위 안내). 미설정 시 빈 문자열입니다 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "data": { + "id": 1, + "version": 1, + "0": "... (총 8건 중 2건 표시)" + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-gdpr.privacy.view`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 특정 정책 버전의 상세(발행 시점 settings 스냅샷 본문 포함)를 반환합니다. `auth:sanctum`과 `sirsoft-gdpr.privacy.view` 권한이 필요합니다. `{version}`은 정수 path 파라미터로, 관리자 동의 이력·정책 버전 이력 화면에서 행 클릭 시 그 시점의 cookie_categories·privacy_policy_slug·blocked_domains를 모달로 표시해 회원 분쟁 시 당시 정책 본문을 확인할 수 있게 합니다(Art.7(1) 입증 책임). 해당 버전이 없으면 404를 반환합니다. + + diff --git a/plugins/_bundled/sirsoft-gdpr/docs/api/settings.md b/plugins/_bundled/sirsoft-gdpr/docs/api/settings.md new file mode 100644 index 00000000..ac265354 --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/docs/api/settings.md @@ -0,0 +1,385 @@ +# Settings API 레퍼런스 + +> **소유**: plugin `sirsoft-gdpr` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Settings 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/plugins/sirsoft-gdpr/admin/settings + +- **라우트명**: `api.plugins.sirsoft-gdpr.admin.settings.show` +- **컨트롤러**: `Plugins\Sirsoft\Gdpr\Http\Controllers\Admin\GdprAdminSettingsController@show` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-gdpr.privacy.view` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-gdpr/admin/settings HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| settings | object | `{"privacy_policy_slug":"privacy","legal_entity_name":"","…` | GDPR 플러그인의 관리자 설정 전체 객체. 정책 메타데이터(slug/법인명/저장 위치), 배너 설정, 쿠키 카테고리 카탈로그, 차단 도메인을 담으며 `cookie_categories` 등 JSON 필드는 디코드되어 객체/배열로 노출됩니다 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "settings": { + "privacy_policy_slug": "privacy", + "legal_entity_name": "", + "data_storage_location": "", + "banner_enabled": true, + "banner_position": "bottom_bar", + "blocked_domains": { + "functional": [ + "*.crisp.chat", + "widget.intercom.io" + ], + "analytics": [ + "google-analytics.com" + ], + "marketing": [ + "facebook.com" + ] + }, + "cookie_categories": [ + { + "key": "necessary", + "required": true, + "label": { + "ko": "필수 쿠키", + "en": "Strictly Necessary" + }, + "description": { + "ko": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.", + "en": "Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference at registration, cookie consent record. Cannot be disabled." + } + }, + { + "key": "functional", + "required": false, + "label": { + "ko": "기능 쿠키", + "en": "Functional" + }, + "description": { + "ko": "사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.", + "en": "Cookies that remember user preferences such as dark mode and display currency. If declined, defaults are used on every visit." + } + }, + { + "key": "analytics", + "required": false, + "label": { + "ko": "분석 쿠키", + "en": "Analytics" + }, + "description": { + "ko": "방문자가 사이트를 어떻게 이용하는지 익명으로 측정해 더 나은 서비스를 만드는 데 사용됩니다. (예: Google Analytics, Hotjar)", + "en": "Used to anonymously measure how visitors use the site so we can improve it. (e.g. Google Analytics, Hotjar)" + } + }, + { + "key": "marketing", + "required": false, + "label": { + "ko": "마케팅 쿠키", + "en": "Marketing" + }, + "description": { + "ko": "관심사에 맞는 광고를 보여주거나, 광고가 얼마나 효과적이었는지 측정하는 데 사용됩니다. SNS 영상 임베드 등도 포함됩니다. (예: Facebook 픽셀, Google 광고, YouTube 영상)", + "en": "Used to show ads relevant to your interests, measure ad performance, and embed social media content. (e.g. Facebook Pixel, Google Ads, YouTube embeds)" + } + } + ] + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-gdpr.privacy.view`)이 없는 경우 | + + + +**설명** GDPR 플러그인의 관리자 설정 전체를 반환해 관리자 설정 화면 폼에 바인딩합니다. `auth:sanctum`과 `sirsoft-gdpr.privacy.view` 권한이 필요합니다. `cookie_categories` 같은 JSON 필드는 디코드하여 객체/배열로 노출합니다. 조회 전용이며 정책 버전 발행 등 부수 효과는 없습니다. + + +### PUT /api/plugins/sirsoft-gdpr/admin/settings + +- **라우트명**: `api.plugins.sirsoft-gdpr.admin.settings.update` +- **컨트롤러**: `Plugins\Sirsoft\Gdpr\Http\Controllers\Admin\GdprAdminSettingsController@update` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-gdpr.privacy.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| privacy_policy_slug | body | string | 아니오 | max 100 | 개인정보 처리방침 페이지 slug(소문자·숫자·하이픈만). 비면 처리방침 링크가 비활성화되며 배너/마이페이지의 방침 링크 대상이 됩니다 | +| legal_entity_name | body | string | 아니오 | max 200 | legal entity 이름 (식별자) | +| data_storage_location | body | string | 아니오 | max 200 | 데이터 저장 위치 표기(GDPR Art.13(1)(f)/PIPA 국가 단위 안내용). IP/CIDR·클라우드 리전 코드 등 보안 민감 식별자는 검증에서 차단됩니다 | +| banner_enabled | body | boolean | 아니오 | — | 쿠키 배너와 자동 차단 엔진의 통합 활성 토글. `true`일 때만 배너 노출과 도메인 기반 차단이 동작합니다 | +| banner_position | body | string | 아니오 | `bottom_bar`, `bottom_left_popup`, `bottom_right_popup`, `centered_modal` | 쿠키 배너 노출 위치(하단 바/좌하단 팝업/우하단 팝업/중앙 모달) | +| cookie_categories | body | string | 아니오 | — | 쿠키 카테고리 카탈로그(JSON 문자열 또는 배열). 각 항목의 `key`는 necessary/functional/analytics/marketing 중 하나이며 `label.ko`/`label.en`이 필수입니다 | +| blocked_domains | body | array | 아니오 | — | 카테고리별 차단 도메인 패턴 배열(necessary 제외). FQDN 및 `*.` 와일드카드 prefix만 허용되며 textarea 줄바꿈 입력도 배열로 정규화됩니다 | + +**요청 예시** + +```http +PUT /api/plugins/sirsoft-gdpr/admin/settings HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "privacy_policy_slug": "example-key", + "legal_entity_name": "예시 이름", + "data_storage_location": "예시값", + "banner_enabled": true, + "banner_position": "bottom_bar", + "cookie_categories": "예시값", + "blocked_domains": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-gdpr.privacy.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 검증된 GDPR 관리자 설정을 저장합니다. `auth:sanctum`과 `sirsoft-gdpr.privacy.update` 권한이 필요합니다. 설정만 저장할 뿐 정책 버전은 자동 발행되지 않으며, 재동의가 필요한 변경이라면 운영자가 「+ 새 버전 발행」을 별도로 눌러야 합니다. 배너 문구·위치, 쿠키 카테고리, 차단 도메인 등을 갱신하는 쓰기 엔드포인트입니다. + + +### GET /api/plugins/sirsoft-gdpr/settings + +- **라우트명**: `api.plugins.sirsoft-gdpr.settings` +- **컨트롤러**: `Plugins\Sirsoft\Gdpr\Http\Controllers\Public\GdprSettingsController@show` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-gdpr/settings HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| cookie_policy_version | string | `10` | 현재 발행된 최신 정책 버전 문자열(`gdpr_policy_versions` 테이블이 SSoT, 최신 row의 version 정수). 게스트/회원 동의 시점 비교 및 배너 재노출 판정에 사용됩니다 | +| privacy_policy_slug | string | `privacy` | 개인정보 처리방침 페이지 slug. 설정이 비어 있으면 null로 반환됩니다 | +| privacy_policy_available | boolean | `true` | 처리방침 slug가 설정되어 링크 노출이 가능한지 여부. slug가 비면 `false`입니다 | +| legal_entity_name | string | `` | 개인정보 처리 법인/사업자명. 미설정 시 빈 문자열입니다 | +| data_storage_location | string | `` | 데이터 저장 위치 표기 문자열(국가 단위 안내). 미설정 시 빈 문자열입니다 | +| banner_enabled | boolean | `true` | 쿠키 배너와 자동 차단의 통합 활성 여부. `true`일 때만 게스트에게 배너가 노출되고 도메인 차단 엔진이 동작합니다 | +| banner_position | string | `bottom_bar` | 쿠키 배너 노출 위치(bottom_bar / bottom_left_popup / bottom_right_popup / centered_modal) | +| cookie_categories | array | `[{"key":"necessary","required":true,"label":{"ko":"필수 쿠키"…` | 배너·마이페이지 렌더링용 쿠키 카테고리 카탈로그 배열. 각 항목은 `key`, `required`(필수 여부), 다국어 `label`/`description`을 담습니다 | +| blocked_domains | object | `{"functional":["*.crisp.chat","widget.intercom.io"],"anal…` | 현재 설정된 카테고리별 차단 도메인 패턴(키→도메인 배열). 게스트도 차단이 동작해야 하므로 공개 응답에 노출되며 본 컨트롤러가 노출 SSoT입니다 | +| default_blocked_domains_preview | object | `{"functional":["*.crisp.chat","client.crisp.chat","*.inte…` | 플러그인 기본 차단 도메인 카탈로그 미리보기(변경 불가 기본값). 관리자가 커스텀 차단 목록을 편집할 때 참고용 기본 패턴을 보여줍니다 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "cookie_policy_version": "1", + "privacy_policy_slug": "privacy", + "privacy_policy_available": true, + "legal_entity_name": "", + "data_storage_location": "", + "banner_enabled": true, + "banner_position": "bottom_bar", + "cookie_categories": [ + { + "key": "necessary", + "required": true, + "label": { + "ko": "필수 쿠키", + "en": "Strictly Necessary" + }, + "description": { + "ko": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.", + "en": "Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference at registration, cookie consent record. Cannot be disabled." + } + }, + { + "key": "functional", + "required": false, + "label": { + "ko": "기능 쿠키", + "en": "Functional" + }, + "description": { + "ko": "사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.", + "en": "Cookies that remember user preferences such as dark mode and display currency. If declined, defaults are used on every visit." + } + }, + { + "key": "analytics", + "required": false, + "label": { + "ko": "분석 쿠키", + "en": "Analytics" + }, + "description": { + "ko": "방문자가 사이트를 어떻게 이용하는지 익명으로 측정해 더 나은 서비스를 만드는 데 사용됩니다. (예: Google Analytics, Hotjar)", + "en": "Used to anonymously measure how visitors use the site so we can improve it. (e.g. Google Analytics, Hotjar)" + } + }, + { + "key": "marketing", + "required": false, + "label": { + "ko": "마케팅 쿠키", + "en": "Marketing" + }, + "description": { + "ko": "관심사에 맞는 광고를 보여주거나, 광고가 얼마나 효과적이었는지 측정하는 데 사용됩니다. SNS 영상 임베드 등도 포함됩니다. (예: Facebook 픽셀, Google 광고, YouTube 영상)", + "en": "Used to show ads relevant to your interests, measure ad performance, and embed social media content. (e.g. Facebook Pixel, Google Ads, YouTube embeds)" + } + } + ], + "blocked_domains": { + "functional": [ + "*.crisp.chat", + "widget.intercom.io" + ], + "analytics": [ + "google-analytics.com" + ], + "marketing": [ + "facebook.com" + ] + }, + "default_blocked_domains_preview": { + "functional": [ + "*.crisp.chat", + "client.crisp.chat", + "*.intercom.io", + "widget.intercom.io", + "*.tawk.to", + "embed.tawk.to", + "cdn.weglot.com", + "*.weglot.com", + "*.usercentrics.eu" + ], + "analytics": [ + "google-analytics.com", + "*.google-analytics.com", + "googletagmanager.com", + "*.googletagmanager.com", + "ssl.google-analytics.com", + "*.hotjar.com", + "static.hotjar.com", + "*.mixpanel.com", + "cdn.mxpnl.com", + "*.amplitude.com", + "cdn.amplitude.com", + "*.segment.io", + "*.segment.com", + "wcs.naver.net", + "wcs.naver.com", + "*.beusable.net" + ], + "marketing": [ + "facebook.net", + "connect.facebook.net", + "facebook.com", + "*.facebook.com", + "doubleclick.net", + "*.doubleclick.net", + "googleadservices.com", + "googlesyndication.com", + "ads.google.com", + "*.criteo.com", + "static.criteo.net", + "*.adnxs.com", + "*.taboola.com", + "cdn.taboola.com", + "*.outbrain.com", + "*.kakao.com", + "analytics.ad.daum.net", + "platform.twitter.com", + "*.twitter.com", + "platform.linkedin.com", + "*.linkedin.com" + ] + } + } +} +``` + +**에러 응답** + +_대표 에러 없음 (공개 조회). _ + + + +**설명** 게스트도 접근할 수 있는 완전 공개 엔드포인트로, 인증 없이 호출됩니다. 공개 쿠키 동의 배너와 마이페이지 카드 렌더링에 필요한 쿠키 카테고리·배너 설정·차단 도메인과 현재 `cookie_policy_version`(정책 버전 서비스 기준)을 반환합니다. 차단 도메인은 게스트도 차단이 동작해야 하므로 공개 응답에 노출되며, 이 컨트롤러가 응답 노출의 기준(SSoT)입니다. 조회 전용입니다. + + diff --git a/plugins/_bundled/sirsoft-gdpr/src/Support/ApiDoc/ApiDocSampleService.php b/plugins/_bundled/sirsoft-gdpr/src/Support/ApiDoc/ApiDocSampleService.php new file mode 100644 index 00000000..bba24208 --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/src/Support/ApiDoc/ApiDocSampleService.php @@ -0,0 +1,79 @@ +}> 도메인 => 대표 레코드 정보 + */ + public function seed(): array + { + if (! class_exists(GdprPolicyVersion::class)) { + return []; + } + + $version = $this->seedPolicyVersion(); + + $entry = [ + 'model' => GdprPolicyVersion::class, + 'key' => 'version', + 'value' => (string) $version->version, + 'path_params' => [ + 'version' => (string) $version->version, + ], + ]; + + return [ + 'policy-versions' => $entry, + ]; + } + + /** + * 발행된 GDPR 정책 버전 1건을 멱등 생성합니다. + * + * @return GdprPolicyVersion 샘플 정책 버전 + */ + private function seedPolicyVersion(): GdprPolicyVersion + { + $actor = User::query()->orderBy('id')->first(); + + return GdprPolicyVersion::query()->firstOrCreate( + ['version' => self::SAMPLE_VERSION], + [ + 'change_type' => GdprPolicyChangeType::Initial, + 'memo' => 'API 문서 실측용 샘플 정책 버전', + 'snapshot' => [ + 'privacy_policy_slug' => 'privacy', + 'banner_enabled' => true, + 'banner_position' => 'bottom_bar', + ], + 'created_by' => $actor?->getKey(), + ] + ); + } +} diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Unit/Support/ApiDocSampleServiceTest.php b/plugins/_bundled/sirsoft-gdpr/tests/Unit/Support/ApiDocSampleServiceTest.php new file mode 100644 index 00000000..0fc7c6f2 --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/tests/Unit/Support/ApiDocSampleServiceTest.php @@ -0,0 +1,83 @@ +create([ + 'email' => 'apidoc-sample-user@example.com', + 'name' => 'API 문서 샘플 사용자', + ]); + } + + /** + * 시더가 계약을 구현한다. + */ + public function test_seeder_implements_contract(): void + { + $this->assertInstanceOf(ApiDocSampleSeeder::class, new ApiDocSampleService); + } + + /** + * 시더가 발행된 정책 버전 대표 샘플을 생성한다. + */ + public function test_seed_creates_policy_version(): void + { + (new ApiDocSampleService)->seed(); + + $version = GdprPolicyVersion::query()->where('version', 1)->first(); + + $this->assertNotNull($version); + $this->assertSame(1, $version->version); + $this->assertNotNull($version->change_type); + } + + /** + * 시더가 policy-versions 도메인의 path_params 맵을 반환한다. + */ + public function test_seed_returns_policy_versions_path_params_map(): void + { + $map = (new ApiDocSampleService)->seed(); + + $this->assertArrayHasKey('policy-versions', $map); + $entry = $map['policy-versions']; + $this->assertSame(GdprPolicyVersion::class, $entry['model']); + $this->assertSame('version', $entry['key']); + $this->assertArrayHasKey('version', $entry['path_params']); + $this->assertSame('1', $entry['path_params']['version']); + } + + /** + * 재실행 시 정책 버전이 중복 생성되지 않는다 (멱등). + */ + public function test_seed_is_idempotent(): void + { + (new ApiDocSampleService)->seed(); + (new ApiDocSampleService)->seed(); + + $this->assertSame(1, GdprPolicyVersion::query()->where('version', 1)->count()); + } +} diff --git a/plugins/_bundled/sirsoft-marketing/docs/api/README.md b/plugins/_bundled/sirsoft-marketing/docs/api/README.md new file mode 100644 index 00000000..73920976 --- /dev/null +++ b/plugins/_bundled/sirsoft-marketing/docs/api/README.md @@ -0,0 +1,15 @@ +# API 레퍼런스 문서 목차 + +> **소유**: 플러그인 `sirsoft-marketing` · **생성**: `php artisan api:docgen` (실측 기반). +> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. + + +- **문서 수**: 2 · **엔드포인트 수**: 2 + +| 문서 | 도메인 | 엔드포인트 | +| --- | --- | --- | +| [channels.md](channels.md) | `channels` | 1 | +| [settings.md](settings.md) | `settings` | 1 | + + + diff --git a/plugins/_bundled/sirsoft-marketing/docs/api/channels.md b/plugins/_bundled/sirsoft-marketing/docs/api/channels.md new file mode 100644 index 00000000..ae949d04 --- /dev/null +++ b/plugins/_bundled/sirsoft-marketing/docs/api/channels.md @@ -0,0 +1,86 @@ +# Channels API 레퍼런스 + +> **소유**: plugin `sirsoft-marketing` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Channels 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### PUT /api/plugins/sirsoft-marketing/admin/channels + +- **라우트명**: `api.plugins.sirsoft-marketing.admin.channels.update` +- **컨트롤러**: `Plugins\Sirsoft\Marketing\Http\Controllers\MarketingAdminController@updateChannels` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +PUT /api/plugins/sirsoft-marketing/admin/channels HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** + +관리자 환경설정 화면에서 마케팅 동의 **채널 목록 전체를 한 번에 저장**하는 엔드포인트다. 컨트롤러가 `AdminBaseController` 를 상속하므로 실제 인증은 `auth:sanctum` **에 더해 관리자(admin) 권한**을 요구한다(생성기 표기는 `auth:sanctum` 만 노출). 제출된 배열이 곧 새 상태가 되며, 개별 채널 추가/수정 엔드포인트는 없다(전량 교체 방식). + +**요청 파라미터**는 생성기가 배열 중첩 규칙(`channels.*`)을 평면화하지 못해 위 표에 "없음"으로 표기되나, 실제 `ChannelUpdateRequest` 는 다음 body 를 요구한다: + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| channels | body | array | 예 | 최대 10개 | 저장할 채널 정의 배열(전체 교체) | +| channels.*.key | body | string | 예 | `^[a-z0-9_]+$` | 채널 식별자(소문자·숫자·밑줄) | +| channels.*.label | body | object | 예 | 로케일 맵, 각 값 최대 50자 | 다국어 라벨(`LocaleRequiredTranslatable`) | +| channels.*.page_slug | body | string | 아니오 | 최대 100자 | 연결 약관 페이지 slug | +| channels.*.enabled | body | boolean | 예 | — | 노출 여부 | +| channels.*.is_system | body | boolean | 예 | — | 시스템 채널 플래그 | + +- **시스템 채널 보호**: `is_system=true` 채널은 제출 목록에서 빠져도 서버가 기본 정의를 다시 앞에 끼워 넣어 항상 유지된다. `is_system` 을 true→false 로 위변조하거나 시스템 채널을 누락시키면 검증 오류(422). +- **동의 이력 보호**: 이미 사용자 동의가 존재하는 채널을 목록에서 제거하려 하면 `MarketingConsentService::countConsentedByKey()` 가 0 이 아니어서 422 로 거부된다(개인정보 이력 보존). +- **key 중복 금지**: 같은 `key` 가 둘 이상이면 422. +- 성공 시 저장된 최종 `channels` 배열(시스템 채널 병합 포함)을 `data.channels` 로 되돌려준다. + +**응답 예시** (성공) + +```json +{ + "success": true, + "data": { + "channels": [ + { "key": "email_subscription", "label": { "ko": "광고성 이메일 수신", "en": "Marketing email" }, "page_slug": null, "enabled": true, "is_system": true } + ] + }, + "message": "채널이 저장되었습니다.", + "error": null +} +``` + + diff --git a/plugins/_bundled/sirsoft-marketing/docs/api/settings.md b/plugins/_bundled/sirsoft-marketing/docs/api/settings.md new file mode 100644 index 00000000..9e45b5eb --- /dev/null +++ b/plugins/_bundled/sirsoft-marketing/docs/api/settings.md @@ -0,0 +1,101 @@ +# Settings API 레퍼런스 + +> **소유**: plugin `sirsoft-marketing` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Settings 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/plugins/sirsoft-marketing/settings + +- **라우트명**: `api.plugins.sirsoft-marketing.settings` +- **컨트롤러**: `Plugins\Sirsoft\Marketing\Http\Controllers\MarketingSettingsController@settings` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-marketing/settings HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| marketing_consent_enabled | boolean | `true` | 광고성 정보 수신 전체 동의 축의 노출 여부. false 면 회원가입/마이페이지에서 해당 동의 UI 를 렌더하지 않는다(미설정 시 기본 `true`). | +| marketing_consent_terms_slug | string | `marketing-terms` | 광고성 정보 수신 약관 페이지 라우팅에 쓰는 slug. 미설정 시 `null`. 프론트는 값 자체를 링크 대상에만 사용한다. | +| marketing_consent_terms_slug_set | boolean | `true` | 광고성 정보 수신 약관 slug 존재 여부. 프론트는 이 플래그로 약관 링크 표시 여부를 판정한다. | +| third_party_consent_enabled | boolean | `true` | 제3자 제공 동의 축의 노출 여부. false 면 해당 동의 UI 를 렌더하지 않는다(미설정 시 기본 `false`). | +| third_party_consent_terms_slug | null | `null` | 제3자 제공 약관 페이지 라우팅에 쓰는 slug. 미설정 시 `null`. | +| third_party_consent_terms_slug_set | boolean | `false` | 제3자 제공 약관 slug 존재 여부. 프론트의 약관 링크 표시 판정에 쓴다. | +| info_disclosure_enabled | boolean | `true` | 정보 이용 안내 동의 축의 노출 여부. false 면 해당 안내 UI 를 렌더하지 않는다(미설정 시 기본 `false`). | +| info_disclosure_terms_slug | null | `null` | 정보 이용 안내 약관 페이지 라우팅에 쓰는 slug. 미설정 시 `null`. | +| info_disclosure_terms_slug_set | boolean | `false` | 정보 이용 안내 약관 slug 존재 여부. 프론트의 약관 링크 표시 판정에 쓴다. | +| channels | array | `[{"key":"email_subscription","label":"광고성 이메일 수신","label_…` | `MarketingConsentService::getRegisteredChannels()` 가 반환하는 활성 채널 목록. 각 원소는 `key`·현재 로케일 해석 `label`·로케일 맵 원본 `label_i18n`·`enabled`·`terms_slug`·`terms_slug_set` 를 갖는다. 폼의 반복 렌더링에 그대로 쓰인다. | + +**에러 응답** + +_대표 에러 없음 (공개 조회). _ + + + +**설명** + +비로그인 상태에서도 조회 가능한 공개 설정 엔드포인트다(`PublicBaseController`). 회원가입 폼과 마이페이지의 마케팅 동의 UI 가, 어떤 동의 항목을 노출할지·약관 링크가 존재하는지·어떤 채널을 반복 렌더링할지 결정하기 위해 소비한다. `plugin_settings` 저장값과 `channels` JSON 을 조합해 반환하며 별도 DB 모델을 두지 않는다. + +- **약관 slug 은 값을 노출하지 않는다.** 각 동의 항목마다 실제 slug(`*_terms_slug`)와 존재 플래그(`*_terms_slug_set`)를 함께 반환하되, slug 미설정 시 값은 `null` 이고 플래그는 `false` 다. 프론트는 링크 표시 여부를 `*_terms_slug_set` 으로 판정하고, slug 값 자체는 약관 페이지 라우팅에만 쓴다. +- **세 가지 법적 동의 축**: `marketing_consent_*`(광고성 정보 수신 전체 동의), `third_party_consent_*`(제3자 제공), `info_disclosure_*`(정보 이용 안내). 각 축의 `*_enabled` 가 false 면 해당 동의 UI 를 렌더하지 않는다. `marketing_consent_enabled` 는 미설정 시 기본 `true`, 나머지 두 축은 기본 `false`. +- **`channels` 배열**은 `MarketingConsentService::getRegisteredChannels()` 가 반환하는 **활성 채널만** 담는다. 각 원소는 `key`, 현재 로케일로 해석된 `label`, 로케일 맵 원본 `label_i18n`, `enabled`, 그리고 slug 값(`terms_slug`)과 존재 플래그(`terms_slug_set`)를 갖는다. 회원가입/마이페이지 폼의 iteration 렌더링에 그대로 쓰인다. +- `label` 은 요청 시점의 `app()->getLocale()` 기준으로 해석되며, 해당 로케일 라벨이 없으면 `fallback_locale`(기본 `ko`) → `key` 순으로 폴백한다. + +**응답 예시** (실측) + +```json +{ + "success": true, + "data": { + "marketing_consent_enabled": true, + "marketing_consent_terms_slug": "marketing-terms", + "marketing_consent_terms_slug_set": true, + "third_party_consent_enabled": true, + "third_party_consent_terms_slug": null, + "third_party_consent_terms_slug_set": false, + "info_disclosure_enabled": true, + "info_disclosure_terms_slug": null, + "info_disclosure_terms_slug_set": false, + "channels": [ + { + "key": "email_subscription", + "label": "광고성 이메일 수신", + "label_i18n": { "ko": "광고성 이메일 수신", "en": "Marketing email" }, + "enabled": true, + "terms_slug": null, + "terms_slug_set": false + } + ] + }, + "message": null, + "error": null +} +``` + + diff --git a/plugins/_bundled/sirsoft-marketing/docs/api/user-consent-injection.md b/plugins/_bundled/sirsoft-marketing/docs/api/user-consent-injection.md new file mode 100644 index 00000000..097548bc --- /dev/null +++ b/plugins/_bundled/sirsoft-marketing/docs/api/user-consent-injection.md @@ -0,0 +1,99 @@ +# 코어 User 응답 마케팅 동의 필드 주입 (Hook Injection) + +> **소유**: plugin `sirsoft-marketing` · 이 문서는 `api:docgen` 생성 대상이 아닌 **훅 주입 계약** 레퍼런스다(플러그인이 코어 응답에 필드를 주입하므로 라우트 단위 문서로 표현되지 않는다). 코어 API 문서(`docs/backend/api/users.md`, `me.md`, `profile.md`)의 응답 필드 표에서 "확장 소유"로 표기된 `*_consent`/`notify_*`/`{channel}_*` 필드의 실제 계약이 여기에 있다. + +--- + +## TL;DR (5초 요약) + +```text +1. sirsoft-marketing 은 core.user.filter_resource_data 훅으로 코어 User 응답에 마케팅 동의 필드를 병합한다 +2. 주입 대상: /me, /auth/user, /admin/users/{user}, 프로필 등 User 리소스를 반환하는 모든 코어 엔드포인트 +3. 주입 필드는 활성 채널·법적 항목·마스터 키에 따라 동적 — 고정 스키마가 아니다 +4. 각 동의 항목마다 상태({key})·시각({key}_at)·활성화 플래그({key}_enabled)·약관 slug 3종을 함께 내린다 +5. 이 필드들이 코어 문서에서 TODO 로 남은 이유: 코어가 아니라 이 플러그인이 소유하기 때문 +``` + +--- + +## 왜 이 문서가 필요한가 + +코어 `UserResource` 등 User 계열 리소스는 `core.user.filter_resource_data` 필터 훅을 발화한다. +`sirsoft-marketing` 플러그인의 `MarketingConsentListener::filterResourceData()` 가 이 훅을 구독해 +**코어 User 응답 `data` 에 마케팅 동의 관련 필드를 병합**한다. 따라서 `docs/backend/api/` 의 코어 User +문서에 나타나는 `marketing_consent`, `third_party_consent_at`, `email_subscription_enabled`, +`channels`, `consent_histories` 같은 필드는 코어 코드에는 없고 이 플러그인이 런타임에 주입한 것이다. + +코어 문서 생성기(`api:docgen`)는 정적 리플렉션으로 코어 Resource `toArray()` 만 읽으므로 이 필드들을 +설명하지 못하고 TODO 로 남긴다. 그 설명의 SSoT 가 이 문서다. + +## 주입 지점 + +`core.user.filter_resource_data` 훅을 발화하는 모든 코어 엔드포인트에서 주입된다. 대표적으로: + +| 엔드포인트 | 문서 | +|---|---| +| `GET /api/me` | `docs/backend/api/me.md` | +| `GET /api/auth/user`, `GET /api/user/auth/user` | `docs/backend/api/auth.md` | +| `GET /api/admin/users/{user}` | `docs/backend/api/users.md` | +| 프로필 조회/수정 응답 | `docs/backend/api/profile.md` | + +## 주입 필드 계약 + +주입 필드 집합은 **활성 채널 목록 + 법적 필수 항목 + 마스터 키**에 따라 동적으로 결정된다. 고정 스키마가 +아니므로 아래는 필드 **패턴**으로 기술한다. + +### 동의 상태 필드 (동의 키별 2개) + +동의 키 집합 = 활성 채널 키(`channels` JSON 의 `enabled` 채널) ∪ 마스터 키 `marketing_consent` +∪ 활성화된 법적 키(`third_party_consent`, `info_disclosure` 중 `*_enabled` 인 것). + +| 필드 | 타입 | 용도/설명 | +|---|---|---| +| `{consent_key}` | boolean | 해당 항목에 동의했는지. 동의 레코드 없으면 `false` | +| `{consent_key}_at` | string\|null | 동의 시각(ISO 8601). 미동의면 `null` | + +- `marketing_consent` (마스터 키): 마케팅 채널 전체 동의/철회를 제어하는 상위 키. +- `third_party_consent`, `info_disclosure`: 법적 필수 동의 항목(고정 키, 활성화 시에만 주입). +- 그 외 키는 관리자가 정의한 동적 채널 키(예: `email_subscription`, `sms_subscription`). + +### 활성화·약관 플래그 필드 (프론트 조건부 렌더링용) + +마스터·법적·채널 각각에 대해 3종 플래그를 내린다. 값 규칙은 공개 `GET /settings` 응답과 동일하다. + +| 필드 패턴 | 타입 | 용도/설명 | +|---|---|---| +| `marketing_consent_enabled` | boolean | 마케팅 전체 동의 UI 노출 여부(기본 `true`) | +| `marketing_consent_terms_slug` | string\|null | 마케팅 약관 slug(미설정 `null`) | +| `marketing_consent_terms_slug_set` | boolean | 마케팅 약관 존재 여부 | +| `third_party_consent_enabled` / `_terms_slug` / `_terms_slug_set` | boolean/string·null/boolean | 제3자 제공 동의 항목 플래그 | +| `info_disclosure_enabled` / `_terms_slug` / `_terms_slug_set` | boolean/string·null/boolean | 정보 이용 안내 동의 항목 플래그 | +| `{channel_key}_enabled` / `_terms_slug` / `_terms_slug_set` | boolean/string·null/boolean | 동적 채널별 노출·약관 플래그(모든 채널에 대해, 활성 여부 무관) | + +> 약관 slug 은 `*_terms_slug`(값)와 `*_terms_slug_set`(존재 플래그)을 함께 내린다. 프론트는 링크 표시를 +> `*_terms_slug_set` 으로 판정한다. 공개 `settings.md` 계약과 동일하다. + +### 컬렉션 필드 + +| 필드 | 타입 | 용도/설명 | +|---|---|---| +| `channels` | array | 관리자 정의 전체 채널 목록(iteration 렌더링용). 원소: `key`, 현재 로케일 `label`, `enabled`, `terms_slug`, `terms_slug_set` | +| `consent_histories` | array | 이 사용자의 동의 변경 이력. 원소: `channel_key`, `action`, `source`(register/admin/profile), `created_at` | + +## 주의사항 + +- **책임 분리**: 이 필드들의 값 규칙·존재 여부는 전적으로 `sirsoft-marketing` 이 결정한다. 플러그인을 + 비활성화하면 코어 User 응답에서 이 필드들이 사라진다. 코어 문서가 이들을 TODO 로 남긴 것은 오류가 + 아니라 책임 분리의 결과다. +- **동적 스키마**: 채널 추가/삭제(`PUT /admin/channels`)나 법적 항목 활성화 설정에 따라 주입 필드 집합이 + 바뀐다. 소비처는 고정 키 목록을 가정하지 말고 `channels` 배열을 순회하는 것을 권장한다. +- **쓰기 경로**: 이 필드들은 조회 시 주입되지만, 저장은 회원가입(`agree_{key}`)·사용자 생성/수정 + (`{key}`) 요청 필드를 같은 리스너가 validation rule 훅으로 추가하고 action 훅에서 별도 저장한다. + User 테이블 컬럼이 아니라 별도 `marketing_consents` EAV 테이블에 보관된다. + +## 관련 + +- [settings.md](settings.md) — 비로그인 공개 설정(동일한 `*_terms_slug_set`·`channels` 계약) +- [channels.md](channels.md) — 채널 목록 저장(동적 채널 키의 출처) +- `docs/backend/api/users.md`, `me.md`, `profile.md`, `auth.md` — 주입 대상 코어 문서 +- `docs/extension/hooks.md` — Filter 훅(`core.user.filter_resource_data`) 계약 diff --git a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md index 5b792641..d66d32e2 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md @@ -30,9 +30,13 @@ - 표준/모바일 결제 서명 API에서 현재 모드의 MID와 서명키가 준비되지 않은 경우 서명을 생성하지 않고 중단하도록 보강했습니다. - PC/모바일/CBT 결제 콜백에서 로컬 결제완료 저장 후 예외가 발생한 경우 PG 취소·환불 보상 호출을 건너뛰도록 보강했습니다. 로컬 주문은 결제완료인데 PG 거래만 취소되는 상태 불일치를 방지합니다. - 일본 편의점결제(CBT CVS) 만료 처리와 통보 이력 저장 시 결제 row를 잠근 뒤 최신 상태를 다시 확인하도록 보강해 입금통보와 관리자 만료 처리의 동시성 충돌 가능성을 줄였습니다. +- 통화 스냅샷의 환율 설정이 비정상인 주문은 표준/모바일/CBT/가상계좌 결제 흐름에서 서버 오류 대신 검증 실패 또는 재통보 가능한 응답으로 처리하도록 보강했습니다. +- PC/모바일/CBT/가상계좌 결제 완료 콜백을 주문 단위로 직렬화해 완전 동시 콜백에서 결제완료 후속 처리 훅이 중복 실행될 가능성을 줄였습니다. ### Fixed +- 이커머스 주문설정 화면에서 KG 이니시스 플러그인이 테스트 모드일 때 운영 전환 경고와 설정 이동 버튼을 표시하도록 보강했습니다. +- KG 이니시스 설정 저장 실패 시 검증 오류가 화면 상단에만 남아 사용자가 원인을 놓치지 않도록 오류 영역으로 자동 이동하고, 일본 결제(CBT) 샘플값 안내를 한 번만 표시하도록 개선했습니다. - 상품의 기본 통화와 다른 통화로 결제한 주문(예: 엔화 상품을 원화로 결제)을 취소·환불할 때 환불 금액이 고객이 실제 결제한 통화로 정확히 요청됩니다 — 이전에는 기본 통화 기준 금액이 그대로 전달되어 결제 통화와 단위가 어긋날 수 있었습니다. 부분취소를 여러 번 나눠 진행해도 누적 환불액이 결제 통화 기준으로 정확히 계산됩니다. - 상품의 기본 통화와 다른 통화로 결제할 때(예: 엔화 상품을 원화로 결제) 결제창에 청구된 금액과 승인·입금통보 확인 금액의 통화 기준이 어긋나 "결제 금액이 일치하지 않습니다"로 막히던 문제를 수정했습니다. 표준결제·가상계좌 입금통보·일본 편의점결제(CVS) 확인이 모두 고객이 선택한 결제 통화 기준으로 일관되게 검증됩니다. - 관리자 주문목록과 주문상세 레이아웃이 리뉴얼되거나 플러그인 업데이트 과정에서 KG 이니시스 테스트결제 배지, 테스트 모드 안내, 결제 조회/결제 정보 패널이 누락될 수 있던 문제를 수정했습니다. @@ -45,11 +49,12 @@ - 활성 관리자 테마에 최신 시맨틱 CSS가 없어도 플러그인 설정 화면의 여백, 카드 경계, 하단 저장 영역이 깨지지 않도록 보강했습니다. - 회원 마이페이지 주문 상세에서 KG 이니시스로 결제한 주문의 영수증 조회 버튼이 표시되지 않던 문제를 수정했습니다. 주문 상세 주소의 주문 ID를 주문번호로 잘못 사용해 영수증 조회가 항상 실패하던 원인을 바로잡았습니다. - 주문 상세 화면에서 다른 결제수단으로 결제한 주문까지 영수증 조회를 짧은 간격으로 수십 회 반복 요청하던 문제를 수정했습니다. 이제 KG 이니시스 결제가 아닌 주문은 영수증 조회를 호출하지 않으며, 영수증이 없다는 확정 응답을 받으면 재시도를 멈춥니다. -- 카드 결제가 정상 완료된 직후 결제창이 닫히면서 결제완료 주문의 상품이 취소 상태로 바뀌던 문제를 수정했습니다. 결제가 이미 완료된 주문은 결제창 닫힘 보고로 취소 처리되지 않습니다. +- 카드 결제가 정상 완료된 직후 또는 승인 콜백 처리 중 결제창 닫힘 보고가 도착하면서 결제완료 주문의 상품이 취소 상태로 바뀌던 문제를 수정했습니다. 결제가 이미 완료됐거나 완료 처리 중인 주문은 결제창 닫힘 보고로 취소 처리되지 않습니다. - 기본 통화가 원(KRW)이 아닌 통화일 때 결제창에 전달되는 청구 금액이 결제 통화로 환산된 금액과 일치하도록 검증 기준을 맞췄습니다 — 그동안 결제 금액 불일치로 결제가 막히던 문제가 해소됩니다. - CBT 설정 저장 검증 실패 메시지가 번역 키 그대로 노출되지 않도록 백엔드 언어팩과 플러그인 번역 로딩 경로를 보강했습니다. - 운영 모드의 국내 표준/모바일 결제 설정이 미완료된 경우 결제창 호출 전에 명확히 중단하도록 클라이언트 설정 상태를 노출했습니다. - 플러그인 설정의 국내 간편결제 개별 토글이 실제 주문서 결제수단 등록과 결제 요청 가능 여부에 반영되도록 수정했습니다. +- 신규 설치 사이트의 이커머스 주문설정 결제수단 목록에 KG 이니시스 네이버페이·삼성페이·L.pay·카카오페이 선택지가 표시되지 않던 문제를 수정했습니다. ### Changed diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md new file mode 100644 index 00000000..067f7063 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md @@ -0,0 +1,18 @@ +# API 레퍼런스 문서 목차 + +> **소유**: 플러그인 `sirsoft-pay_kginicis` · **생성**: `php artisan api:docgen` (실측 기반). +> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. + + +- **문서 수**: 5 · **엔드포인트 수**: 22 + +| 문서 | 도메인 | 엔드포인트 | +| --- | --- | --- | +| [cbt.md](cbt.md) | `cbt` | 2 | +| [orders.md](orders.md) | `orders` | 13 | +| [payment.md](payment.md) | `payment` | 5 | +| [transaction.md](transaction.md) | `transaction` | 1 | +| [vbank.md](vbank.md) | `vbank` | 1 | + + + diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/cbt.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/cbt.md new file mode 100644 index 00000000..7d1e5d9a --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/cbt.md @@ -0,0 +1,162 @@ +# Cbt API 레퍼런스 + +> **소유**: plugin `sirsoft-pay_kginicis` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Cbt 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/plugins/sirsoft-pay_kginicis/admin/cbt-connectivity-check + +- **라우트명**: `api.plugins.sirsoft-pay_kginicis.admin.cbt.connectivity.check` +- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\AdminCbtConnectivityCheckController@check` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-pay_kginicis/admin/cbt-connectivity-check HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| egress_ip | string | `118.235.10.131` | 서버가 외부 통신 시 사용하는 egress IP. KG 이니시스 측에 DEVCBT 접근용 IP 화이트리스트 등록을 요청할 때 알려줄 IP이며, 외부 echo 서비스(ipify 등)를 순차 조회해 얻는다(모두 실패 시 null). | +| server_ip | string | `127.0.0.1` | `$_SERVER['SERVER_ADDR']` 로 읽은 서버 내부 IP. egress IP와 대조해 NAT/프록시 여부를 가늠하는 참고값이다. | +| hosts | array | `[{"name":"devcbt.inicis.com","env":"test","dns_resolved_i…` | 진단 대상 호스트별 결과 배열. 각 항목은 호스트명(`devcbt.inicis.com`), 환경(`test`), DNS 해석 IP(`dns_resolved_ip`), TCP 443 도달 여부(`tcp_443_reachable`)와 에러·응답지연(`tcp_443_error`, `tcp_443_latency_ms`)을 담는다. 운영계(`cbt.inicis.com`)는 화이트리스트 제약이 없어 제외된다. | +| callback | object | `{"app_url":"https:\/\/test.example.com","callback_url":"h…` | 결제 콜백 URL 진단 정보. 앱 URL·콜백 URL과 각각의 HTTPS 여부(`app_url_https`, `callback_url_https`)·공인 호스트 여부(`app_url_public`, `callback_url_public`), 그리고 콜백 호스트가 앱 URL 호스트와 일치하는지(`host_matches_app_url`)를 담아 CBT 콜백 수신 가능 여부를 점검한다. | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-pay_kginicis::messages.cbt_connectivity.checked", + "data": { + "egress_ip": "59.10.38.149", + "server_ip": "127.0.0.1", + "hosts": [ + { + "name": "devcbt.inicis.com", + "env": "test", + "dns_resolved_ip": "183.109.71.154", + "tcp_443_reachable": false, + "tcp_443_error": "A connection attempt failed because the connected party did not properly respond after a period of time, or established connection failed because connected host has failed to respond", + "tcp_443_latency_ms": 3008 + } + ], + "callback": { + "app_url": "https://test.example.com", + "callback_url": "https://api.example.com/plugins/sirsoft-pay_kginicis/payment/cbt/callback", + "app_url_https": true, + "callback_url_https": true, + "app_url_public": true, + "callback_url_public": true, + "host_matches_app_url": false + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.read`)이 없는 경우 | + + + +**설명** + +일본 CBT(테스트 모드, DEVCBT) 결제를 위한 호스트 연결 상태를 관리자 설정 페이지에서 셀프 진단하는 엔드포인트입니다. 서버의 egress IP(외부 통신에 쓰이는 IP, KG 이니시스 IP 화이트리스트 등록 대상)와 서버 내부 IP 를 조회하고, `devcbt.inicis.com` 에 대한 DNS 해석 및 TCP 443 도달성(3초 timeout)을 점검하며, 결제 콜백 URL 이 HTTPS·공인 호스트인지 등 콜백 진단 정보도 함께 반환합니다. 운영계(`cbt.inicis.com`)는 IP 화이트리스트 제약이 없어 진단 대상에서 제외됩니다. 관리자 인증(`auth:sanctum`)과 `sirsoft-ecommerce.settings.read` 권한이 필요하며, 토큰 누락·만료는 401, 권한 부족은 403, 진단 중 예외 발생 시 500 으로 응답합니다. + + +### POST /api/plugins/sirsoft-pay_kginicis/admin/cbt-test-product + +- **라우트명**: `api.plugins.sirsoft-pay_kginicis.admin.cbt.test-product.create` +- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\AdminCbtTestProductController@create` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.products.create` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/plugins/sirsoft-pay_kginicis/admin/cbt-test-product HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| product_id | integer | `4` | product 식별자 (연관 리소스 참조) | +| product_code | string | `CBT-TEST-20260708063238` | 생성된 테스트 상품의 상품코드. `CBT-TEST-` 접두사에 생성 시각(YmdHis)을 붙여 자동 부여되며, SKU는 여기에 `KGINICIS-` 접두사를 더해 만들어진다. | +| admin_url | string | `/admin/ecommerce/products/4/edit` | admin URL | +| shop_url | string | `/shop/products/4?locale=ja` | shop URL | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "product_id": 4, + "product_code": "CBT-TEST-20260708063238", + "admin_url": "/admin/ecommerce/products/4/edit", + "shop_url": "/shop/products/4?locale=ja" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.create`)이 없는 경우 | + + + +**설명** + +일본 CBT(JPPG) 결제 검증을 손쉽게 하기 위해 JPY 가격(100엔)이 설정된 테스트용 상품을 한 번에 자동 생성하는 관리자 엔드포인트입니다. `ProductService::create()` 를 호출해 다국어(ko/en/ja) 이름·설명과 판매 상태, 기본 옵션 1행을 갖춘 상품을 생성하며, 성공 시 `product_id`·`product_code` 와 함께 어드민 편집 URL·일본어 쇼핑몰 URL 을 반환해 운영자가 곧바로 CBT 결제 흐름을 테스트할 수 있게 합니다. 관리자 인증(`auth:sanctum`)과 `sirsoft-ecommerce.products.create` 권한이 필요하며, 토큰 누락·만료는 401, 권한 부족은 403, 상품 생성 중 예외 발생 시 500(에러 상세 포함)으로 응답합니다. + + diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/orders.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/orders.md new file mode 100644 index 00000000..e6bb1bf6 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/orders.md @@ -0,0 +1,970 @@ +# Orders API 레퍼런스 + +> **소유**: plugin `sirsoft-pay_kginicis` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Orders 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/plugins/sirsoft-pay_kginicis/admin/orders/test-mode-map + +- **라우트명**: `api.plugins.sirsoft-pay_kginicis.admin.orders.test-mode-map` +- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\AdminOrderListController@testModeMap` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-pay_kginicis/admin/orders/test-mode-map HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| 20260619-1358556131 | boolean | `true` | 응답 객체의 키가 곧 주문번호이며 값 `true`는 해당 주문이 KG 이니시스 테스트 모드 결제임을 뜻한다. 목록에 존재하는 주문번호만 테스트 결제로 간주하면 된다. | +| 20260619-1425382147 | boolean | `true` | 위와 동일 — 키가 주문번호, 값 `true`는 테스트 모드 결제 주문임을 나타낸다. | +| APIDOC-KGINICIS-000001 | boolean | `true` | 위와 동일 — 키가 주문번호, 값 `true`는 테스트 모드 결제 주문임을 나타낸다. | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "APIDOC-KGINICIS-000001": true + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.read`)이 없는 경우 | + + + +**설명** +`AdminOrderListController@testModeMap` 가 최근 6개월 이내 `pg_provider = kginicis` 결제 주문을 조회해 그중 테스트 모드 결제만 `{ "주문번호": true, ... }` 맵으로 반환한다. 테스트 판별은 `payment_meta.is_test_mode === true` → `pg_raw_response.mid` 가 KG 이니시스 Live MID 접두사(`SIR`)가 아님 → `transaction_id` 에 `Test` 포함 순으로 이루어진다. 어드민 주문 목록에서 결제수단 셀 하단에 "(테스트 결제)" 배지를 붙이는 용도이며, 응답 필드 키가 곧 주문번호이므로 특정 주문이 맵에 존재하면 테스트 결제로 간주하면 된다. `sirsoft-ecommerce.orders.read` 권한이 필요하고, 파라미터 없이 전체 맵을 한 번에 조회한다. + + +### POST /api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/cash-receipt + +- **라우트명**: `api.plugins.sirsoft-pay_kginicis.admin.orders.cash-receipt.issue` +- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\AdminCashReceiptController@issue` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| orderNumber | path | string | 예 | — | 대상 order number의 식별자 | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-pay_kginicis/admin/orders/APIDOC-KGINICIS-000001/cash-receipt HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** +`AdminCashReceiptController@issue` 가 이미 승인 완료된 KG 이니시스 결제 건에 대해 현금영수증을 별도 발행한다. 요청의 `issue_type`(`0`=소득공제, `1`=지출증빙)과 `issue_number`(휴대폰/사업자번호 등 식별번호)를 검증한 뒤, `paid_amount_local` 기준 금액과 부가세(저장값 우선, 없으면 총액의 10/110)를 계산해 `KgInicisApiService::issueCashReceipt` 로 발행 요청을 보낸다. 발행 성공 시 `is_cash_receipt_issued`, `cash_receipt_type`, 마스킹된 식별번호(`cash_receipt_identifier`, 끝 4자리만 노출)를 저장하며, 식별번호 원문은 저장하지 않는다. `sirsoft-ecommerce.orders.update` 권한이 필요하고, 검증 실패 422 / 주문 미존재 404 / 이미 발행됨 409 / PG 발행 실패(resultCode≠`00`) 502 / 예외 500 으로 상태코드가 매핑된다. + + +### GET /api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/cbt-cvs + +- **라우트명**: `api.plugins.sirsoft-pay_kginicis.admin.orders.cbt-cvs.show` +- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\AdminCbtCvsOperationsController@show` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| orderNumber | path | string | 예 | — | 대상 order number의 식별자 | + +**요청 예시** + +```http +GET /api/plugins/sirsoft-pay_kginicis/admin/orders/APIDOC-KGINICIS-000001/cbt-cvs HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| is_cbt_cvs | boolean | `true` | cbt cvs 여부 | +| order_number | string | `APIDOC-KGINICIS-000001` | 주문번호 | +| order_status | string | `pending_payment` | 주문상태 (OrderStatusEnum) | +| payment_status | string | `paid` | 결제 상태값(PaymentStatusEnum). CVS 입금 흐름에서는 `waiting_deposit`(입금대기) → `paid`(입금완료) 또는 `expired`(기한만료) 로 전이한다. | +| tid | string | `INIJPGCARDapidocsmpl0000000001` | KG 이니시스 거래 ID(transaction_id). 결제 승인·통보를 식별하는 이니시스 측 거래번호다. | +| amount | integer | `5000` | 청구 금액. 결제 메타의 `cvs_amount`(CVS 통보 기준 결제 통화 환산액)를 우선 사용하고, 없으면 결제 승인액 또는 주문 총 청구액으로 대체한다. | +| currency | string | `KRW` | 결제 통화 (KRW, USD, EUR 등) | +| cbt_mid | string | `apidocmid1` | CBT(일본결제)에 사용된 KG 이니시스 일본 가맹점 ID. 입금 통보(NOTI) 검증 시 통보의 mid와 대조하는 기준값이다. | +| cbt_sid | string | `apidocsid1` | CBT 결제 세션/상점 식별자(SID). 입금 통보의 sid와 일치하는지 검증하는 데 사용된다. | +| is_test_mode | boolean | `true` | test mode 여부 | +| convenience | string | `seven_eleven` | 구매자가 선택한 일본 편의점 식별값. 어느 편의점에서 입금하는지를 나타낸다. | +| conf_no | string | `1234567890` | 편의점 입금용 확인번호(수납확인번호). 구매자가 편의점 단말에서 입력·제시하는 번호다. | +| receipt_no | string | `0987654321` | 편의점 입금용 접수번호(수납번호). 확인번호와 함께 편의점 결제 접수를 식별한다. | +| payment_term | string | `20260710235959` | 편의점 입금 마감 일시(YmdHis 압축 문자열). 이 기한이 지나면 시간 경과 만료 대상이 된다. | +| payment_term_formatted | string | `2026-07-10 23:59:59` | `payment_term` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| is_expired_by_time | boolean | `false` | expired by time 여부 | +| cvs_status | string | `waiting_deposit` | CVS 입금 상태(`payment_meta.cvs_status`). 입금대기(`waiting_deposit`)·입금완료(`paid`)·만료(`expired`) 등을 나타내며, 메타값이 없고 결제가 입금대기면 `waiting_deposit`로 채운다. | +| last_notify_at | string | `` | last notify 일시 | +| last_notify_result | string | `` | 마지막 입금 통보(NOTI) 처리 결과. `confirmed`(입금확정)·`ignored`(무시)·`failed`(검증실패) 중 하나가 기록된다. | +| last_notify_reason | string | `` | 마지막 통보 처리 결과의 상세 사유(예: `deposit_confirmed`, `tid_mismatch`, `amount_mismatch`, `already_paid` 등). | +| notify_history | array | `[]` | 최근 입금 통보 이력 목록(최대 10건). 각 항목은 수신시각·발신IP·결과·사유와 통보 payload 요약(tid·금액·통화 등)을 담는다. | +| notify_url | string | `https://api.example.com/plugins/sirsoft-pay_…` | notify URL | +| can_simulate_notify | boolean | `false` | simulate notify 수행 가능 여부 (권한 기반) | +| can_mark_expired | boolean | `false` | mark expired 수행 가능 여부 (권한 기반) | +| last_recheck_at | string | `` | last recheck 일시 | +| last_recheck_result | string | `` | 마지막 로컬 상태 재확인 결과. recheck 액션 실행 시 `local_status_checked` 로 기록되며, CBT 편의점 입금은 외부 PG 조회 대상이 아니라 로컬 확인 흔적만 남긴다. | +| expired_at | string | `` | expired 일시 | +| expiry_reason | string | `` | 만료 처리 사유. 입금 기한 경과로 만료된 경우 `payment_term_elapsed` 가 기록된다. | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "is_cbt_cvs": true, + "order_number": "APIDOC-KGINICIS-000001", + "order_status": "pending_payment", + "payment_status": "paid", + "tid": "INIJPGCARDapidocsmpl0000000001", + "amount": 5000, + "currency": "KRW", + "cbt_mid": "apidocmid1", + "cbt_sid": "apidocsid1", + "is_test_mode": true, + "convenience": "seven_eleven", + "conf_no": "1234567890", + "receipt_no": "0987654321", + "payment_term": "20260710235959", + "payment_term_formatted": "2026-07-10 23:59:59", + "is_expired_by_time": false, + "cvs_status": "waiting_deposit", + "last_notify_at": "", + "last_notify_result": "", + "last_notify_reason": "", + "notify_history": [], + "notify_url": "https://api.example.com/plugins/sirsoft-pay_kginicis/payment/cbt/cvs-notify", + "can_simulate_notify": false, + "can_mark_expired": false, + "last_recheck_at": "", + "last_recheck_result": "", + "expired_at": "", + "expiry_reason": "" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** +`AdminCbtCvsOperationsController@show` 가 `CbtCvsOperationsService::summary` 를 호출해 CBT(일본결제) 편의점(CVS) 입금 결제의 운영 요약을 반환한다. 주문번호로 결제·메타(`payment_meta`)를 읽어 입금 상태(`cvs_status`), 편의점 확인번호/접수번호(`conf_no`/`receipt_no`), 입금 마감(`payment_term` 및 포맷된 값), 시간 경과 만료 여부(`is_expired_by_time`), NOTI 통보 이력(`notify_history`), 통보 수신 URL 등을 집계한다. `can_simulate_notify`(테스트 모드 + 입금대기)와 `can_mark_expired`(입금대기 + 기한 경과)는 후속 운영 액션의 버튼 노출을 제어하는 게이트값이다. `sirsoft-ecommerce.orders.read` 권한이 필요하며, 주문이 없으면 플러그인 표준 404(`order_not_found`)를 반환한다. + + +### POST /api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/cbt-cvs/expire + +- **라우트명**: `api.plugins.sirsoft-pay_kginicis.admin.orders.cbt-cvs.expire` +- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\AdminCbtCvsOperationsController@expire` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| orderNumber | path | string | 예 | — | 대상 order number의 식별자 | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-pay_kginicis/admin/orders/APIDOC-KGINICIS-000001/cbt-cvs/expire HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** +`AdminCbtCvsOperationsController@expire` 가 `CbtCvsOperationsService::expireOverdue` 를 호출해 입금 기한이 지난 CBT 편의점 결제를 만료 상태로 전환한다. 결제 row 를 트랜잭션 내에서 잠근 뒤 `canMarkExpired`(CVS 메타 + `waiting_deposit` 상태 + `cvs_payment_term` 경과)를 재판정해 통과할 때만 `payment_status` 를 `expired` 로 바꾸고 메타에 `cvs_expired_at`·`cvs_expiry_reason=payment_term_elapsed` 를 기록한다. 잠금·재판정으로 동시 입금 통보(NOTI)와의 경합을 방지하며, 만료 조건 미충족 시 `not_expirable` 422 를 반환한다. `sirsoft-ecommerce.orders.update` 권한이 필요하고, CVS 결제가 아니면 `not_cvs` 422, 주문 미존재 시 404 로 응답한다. + + +### POST /api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/cbt-cvs/recheck + +- **라우트명**: `api.plugins.sirsoft-pay_kginicis.admin.orders.cbt-cvs.recheck` +- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\AdminCbtCvsOperationsController@recheck` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| orderNumber | path | string | 예 | — | 대상 order number의 식별자 | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-pay_kginicis/admin/orders/APIDOC-KGINICIS-000001/cbt-cvs/recheck HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| is_cbt_cvs | boolean | `true` | cbt cvs 여부 | +| order_number | string | `APIDOC-KGINICIS-000001` | 주문번호 | +| order_status | string | `pending_payment` | 주문상태 (OrderStatusEnum) | +| payment_status | string | `paid` | 결제 상태값(PaymentStatusEnum). CVS 입금 흐름에서는 `waiting_deposit`(입금대기) → `paid`(입금완료) 또는 `expired`(기한만료) 로 전이한다. | +| tid | string | `INIJPGCARDapidocsmpl0000000001` | KG 이니시스 거래 ID(transaction_id). 결제 승인·통보를 식별하는 이니시스 측 거래번호다. | +| amount | integer | `5000` | 청구 금액. 결제 메타의 `cvs_amount`(CVS 통보 기준 결제 통화 환산액)를 우선 사용하고, 없으면 결제 승인액 또는 주문 총 청구액으로 대체한다. | +| currency | string | `KRW` | 결제 통화 (KRW, USD, EUR 등) | +| cbt_mid | string | `apidocmid1` | CBT(일본결제)에 사용된 KG 이니시스 일본 가맹점 ID. 입금 통보(NOTI) 검증 시 통보의 mid와 대조하는 기준값이다. | +| cbt_sid | string | `apidocsid1` | CBT 결제 세션/상점 식별자(SID). 입금 통보의 sid와 일치하는지 검증하는 데 사용된다. | +| is_test_mode | boolean | `true` | test mode 여부 | +| convenience | string | `seven_eleven` | 구매자가 선택한 일본 편의점 식별값. 어느 편의점에서 입금하는지를 나타낸다. | +| conf_no | string | `1234567890` | 편의점 입금용 확인번호(수납확인번호). 구매자가 편의점 단말에서 입력·제시하는 번호다. | +| receipt_no | string | `0987654321` | 편의점 입금용 접수번호(수납번호). 확인번호와 함께 편의점 결제 접수를 식별한다. | +| payment_term | string | `20260710235959` | 편의점 입금 마감 일시(YmdHis 압축 문자열). 이 기한이 지나면 시간 경과 만료 대상이 된다. | +| payment_term_formatted | string | `2026-07-10 23:59:59` | `payment_term` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| is_expired_by_time | boolean | `false` | expired by time 여부 | +| cvs_status | string | `waiting_deposit` | CVS 입금 상태(`payment_meta.cvs_status`). 입금대기(`waiting_deposit`)·입금완료(`paid`)·만료(`expired`) 등을 나타내며, 메타값이 없고 결제가 입금대기면 `waiting_deposit`로 채운다. | +| last_notify_at | string | `` | last notify 일시 | +| last_notify_result | string | `` | 마지막 입금 통보(NOTI) 처리 결과. `confirmed`(입금확정)·`ignored`(무시)·`failed`(검증실패) 중 하나가 기록된다. | +| last_notify_reason | string | `` | 마지막 통보 처리 결과의 상세 사유(예: `deposit_confirmed`, `tid_mismatch`, `amount_mismatch`, `already_paid` 등). | +| notify_history | array | `[]` | 최근 입금 통보 이력 목록(최대 10건). 각 항목은 수신시각·발신IP·결과·사유와 통보 payload 요약(tid·금액·통화 등)을 담는다. | +| notify_url | string | `http://localhost/plugins/sirsoft-pay_…` | notify URL | +| can_simulate_notify | boolean | `false` | simulate notify 수행 가능 여부 (권한 기반) | +| can_mark_expired | boolean | `false` | mark expired 수행 가능 여부 (권한 기반) | +| last_recheck_at | string | `2026-07-08T06:32:39+00:00` | last recheck 일시 | +| last_recheck_result | string | `local_status_checked` | 마지막 로컬 상태 재확인 결과. recheck 액션 실행 시 `local_status_checked` 로 기록되며, CBT 편의점 입금은 외부 PG 조회 대상이 아니라 로컬 확인 흔적만 남긴다. | +| expired_at | string | `` | expired 일시 | +| expiry_reason | string | `` | 만료 처리 사유. 입금 기한 경과로 만료된 경우 `payment_term_elapsed` 가 기록된다. | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-pay_kginicis::messages.cbt_cvs.recheck_success", + "data": { + "is_cbt_cvs": true, + "order_number": "APIDOC-KGINICIS-000001", + "order_status": "pending_payment", + "payment_status": "paid", + "tid": "INIJPGCARDapidocsmpl0000000001", + "amount": 5000, + "currency": "KRW", + "cbt_mid": "apidocmid1", + "cbt_sid": "apidocsid1", + "is_test_mode": true, + "convenience": "seven_eleven", + "conf_no": "1234567890", + "receipt_no": "0987654321", + "payment_term": "20260710235959", + "payment_term_formatted": "2026-07-10 23:59:59", + "is_expired_by_time": false, + "cvs_status": "waiting_deposit", + "last_notify_at": "", + "last_notify_result": "", + "last_notify_reason": "", + "notify_history": [], + "notify_url": "http://localhost/plugins/sirsoft-pay_kginicis/payment/cbt/cvs-notify", + "can_simulate_notify": false, + "can_mark_expired": false, + "last_recheck_at": "2026-07-08T06:32:39+00:00", + "last_recheck_result": "local_status_checked", + "expired_at": "", + "expiry_reason": "" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** +`AdminCbtCvsOperationsController@recheck` 가 `CbtCvsOperationsService::markRechecked` 를 호출해 CBT 편의점 결제의 로컬 상태 확인 시각만 메타에 기록한다(`cvs_last_recheck_at`, `cvs_last_recheck_result=local_status_checked`). CBT 편의점 입금은 한국 INIAPI 거래조회 대상이 아니므로 외부 PG 조회 없이 관리자가 "로컬 상태를 확인했다"는 감사 흔적을 남기는 용도이며, 결제 상태 자체는 변경하지 않는다. 처리 후 갱신된 운영 요약(summary)을 반환한다. `sirsoft-ecommerce.orders.read` 권한이 필요하고, CVS 결제가 아니면 `not_cvs` 422, 주문 미존재 시 404 로 응답한다. + + +### POST /api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/cbt-cvs/simulate-notify + +- **라우트명**: `api.plugins.sirsoft-pay_kginicis.admin.orders.cbt-cvs.simulate-notify` +- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\AdminCbtCvsOperationsController@simulateNotify` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| orderNumber | path | string | 예 | — | 대상 order number의 식별자 | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-pay_kginicis/admin/orders/APIDOC-KGINICIS-000001/cbt-cvs/simulate-notify HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** +`AdminCbtCvsOperationsController@simulateNotify` 가 `CbtCvsOperationsService::simulatePaidNotify` 를 호출해, 테스트 모드에 한해 편의점 입금 완료 NOTI(`status=00`)를 관리자 동작으로 합성·재생한다. 저장된 메타(`cbt_mid`/`cbt_sid`/`cvs_*`)와 결제 통화 환산 금액으로 payload 를 만들어 실제 통보 처리 경로(`handleNotify`)에 흘려보내므로, 정상 처리 시 결제가 입금완료로 전환되고 주문 결제가 확정된다. 가드로 `is_test_mode` 가 아니면 `not_test_mode` 422, 결제가 `waiting_deposit` 이 아니면 `not_waiting_deposit` 422, 합성 통보가 OK 가 아니면 `simulate_failed` 422 를 반환한다. `sirsoft-ecommerce.orders.update` 권한이 필요하며, 운영(Live) 결제에는 사용할 수 없는 테스트 전용 도구다. + + +### GET /api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/cbt-reconciliation + +- **라우트명**: `api.plugins.sirsoft-pay_kginicis.admin.orders.cbt-reconciliation.show` +- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\AdminCbtReconciliationController@show` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| orderNumber | path | string | 예 | — | 대상 order number의 식별자 | + +**요청 예시** + +```http +GET /api/plugins/sirsoft-pay_kginicis/admin/orders/APIDOC-KGINICIS-000001/cbt-reconciliation HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": null +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** +`AdminCbtReconciliationController@show` 가 `CbtReconciliationService::get` 을 호출해 주문에 연결된 CBT 자동환불 조정(reconciliation) 레코드를 반환한다. 이 레코드는 CBT 결제의 자동환불 처리 이력(상태, TID, 금액, 환불 결과/에러, 재시도 횟수)을 담으며, `status` 에서 파생된 `manual_action_required`(수동 환불 필요 여부)와 `can_retry`(수동환불필요 + TID 존재 시 재시도 가능)를 함께 노출한다. 조정 레코드가 없는 주문이면 `data` 가 `null` 로 내려간다(정상 응답). `sirsoft-ecommerce.orders.read` 권한이 필요하며, 관리자 화면에서 자동환불 실패 건의 재시도 버튼 활성화 판단에 사용된다. + + +### POST /api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/cbt-reconciliation/refund-retry + +- **라우트명**: `api.plugins.sirsoft-pay_kginicis.admin.orders.cbt-reconciliation.refund-retry` +- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\AdminCbtReconciliationController@retryRefund` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| orderNumber | path | string | 예 | — | 대상 order number의 식별자 | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-pay_kginicis/admin/orders/APIDOC-KGINICIS-000001/cbt-reconciliation/refund-retry HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** +`AdminCbtReconciliationController@retryRefund` 가 실패한 CBT 자동환불을 관리자 동작으로 재시도한다. 먼저 `claimRefundRetry` 로 레코드를 잠금 획득하는데, `can_retry`(상태가 `manual_refund_required` + TID 존재)가 아니면 획득에 실패해 `not_retryable` 422 를 반환한다. 획득 성공 시 저장된 CBT 자격증명(`is_test_mode`/`cbt_mid`)으로 `KgInicisApiService::refundCbtPayment` 를 호출하고, 성공하면 상태를 `auto_refunded` 로, 예외 발생 시 `manual_refund_required` 로 되돌리며 에러를 기록한다. `sirsoft-ecommerce.orders.update` 권한이 필요하고, 환불 API 실패 시 `retry_failed` 502 로 응답한다. + + +### GET /api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/escrow-delivery + +- **라우트명**: `api.plugins.sirsoft-pay_kginicis.admin.orders.escrow-delivery.form` +- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\AdminEscrowDeliveryController@formData` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| orderNumber | path | string | 예 | — | 대상 order number의 식별자 | + +**요청 예시** + +```http +GET /api/plugins/sirsoft-pay_kginicis/admin/orders/APIDOC-KGINICIS-000001/escrow-delivery HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| has_escrow_payment | boolean | `true` | escrow payment 여부 | +| tid | string | `INIJPGCARDapidocsmpl0000000001` | 배송정보를 등록할 에스크로 결제의 KG 이니시스 거래 ID(transaction_id). INIAPI 배송등록 호출의 대상 거래를 지정한다. | +| price | integer | `170624` | 에스크로 결제 금액(paid_amount_local을 정수 반올림). 배송등록 요청의 price로 전송된다. | +| courier_codes | object | `{"hanjin":"한진택배","cjgls":"CJ대한통운","loge":"롯데택배","epost":"…` | KG 이니시스 공식 택배사 코드표(코드→택배사명). 배송등록 시 `ex_code`는 이 표에 존재하는 코드여야 한다. | +| prefill | object | `{"recvName":"API 문서 샘플 수령인","recvTel":"010-0000-0002","re…` | 배송지 DB에서 채운 수령인 선입력값. 수령인명(`recvName`)·연락처(`recvTel`)·우편번호(`recvPost`)·주소(`recvAddr`)를 담아 등록 폼을 미리 채운다. | +| registered_delivery | null | `null` | 이미 등록된 배송 이력(`payment_meta.escrow_delivery`). 미등록이면 `null`이며, 값이 있으면 운송장·택배사 등 중복 등록 방지 판단에 쓰인다. | +| escrow_confirm | null | `null` | 구매확정 이력(`payment_meta.escrow_confirm`). 구매자 구매확정 처리 전이면 `null`이다. | +| deny_confirmed | boolean | `false` | 판매자 구매거절 확인 이력(`payment_meta.escrow_deny_confirm`) 존재 여부. 이미 거절확인했으면 `true`가 되어 중복 처리를 막는다. | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "has_escrow_payment": true, + "tid": "INIJPGCARDapidocsmpl0000000001", + "price": 460829, + "courier_codes": { + "hanjin": "한진택배", + "cjgls": "CJ대한통운", + "loge": "롯데택배", + "epost": "우체국택배", + "lotte": "롯데글로벌로지스", + "kdexp": "경동택배", + "cvs": "편의점택배", + "ilyang": "일양로지스", + "chunil": "천일택배", + "cvsnet": "CVSnet편의점", + "daesin": "대신택배", + "kunyoung": "건영택배", + "gsilogis": "GSI Express", + "etc": "기타" + }, + "prefill": { + "recvName": "API 문서 샘플 수령인", + "recvTel": "010-0000-0002", + "recvPost": "06134", + "recvAddr": "서울특별시 강남구 테헤란로 001 API 문서 샘플 빌딩 1층" + }, + "registered_delivery": null, + "escrow_confirm": null, + "deny_confirmed": false + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** +`AdminEscrowDeliveryController@formData` 가 에스크로 배송등록 폼에 필요한 초기 데이터를 반환한다. 대상 주문의 KG 이니시스 에스크로 결제(`is_escrow = true`)를 찾아 TID, 결제금액, 공식 택배사 코드표(`courier_codes`), 배송지 기반 수령인 선입력값(`prefill`)을 내려주고, `payment_meta` 에서 이미 등록된 배송 이력(`registered_delivery`)·구매확정(`escrow_confirm`)·구매거절확인(`deny_confirmed`) 여부를 함께 제공한다. 에스크로 결제가 아닌 주문은 `has_escrow_payment` 없이 `data` 가 `null` 로 내려간다. `sirsoft-ecommerce.orders.read` 권한이 필요하며, 실제 배송등록(POST) 전 화면 표시·중복 등록 방지 판단에 사용된다. + + +### POST /api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/escrow-delivery + +- **라우트명**: `api.plugins.sirsoft-pay_kginicis.admin.orders.escrow-delivery.register` +- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\AdminEscrowDeliveryController@register` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| orderNumber | path | string | 예 | — | 대상 order number의 식별자 | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-pay_kginicis/admin/orders/APIDOC-KGINICIS-000001/escrow-delivery HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** +`AdminEscrowDeliveryController@register` 가 KG 이니시스 에스크로 결제의 배송정보를 INIAPI 에 등록한다. 운송장번호(`invoice`)와 택배사코드(`ex_code`, 공식 코드표에 존재해야 함)를 필수 검증하고, 수령인 정보는 요청값 우선·부재 시 배송지 DB 로 채운다. 에스크로 결제는 반드시 에스크로 자격증명(`useEscrowCredentials`)으로 `registerEscrowDelivery` 를 호출하며, 성공 시 정제된 PG 응답과 배송 이력을 `payment_meta.escrow_delivery` 에 저장한다. `sirsoft-ecommerce.orders.update` 권한이 필요하고, 입력 검증 실패 422 / 에스크로 결제 미존재 404 / PG 실패(resultCode≠`00`) 502 / 예외 500 으로 매핑된다. + + +### POST /api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/escrow-deny-confirm + +- **라우트명**: `api.plugins.sirsoft-pay_kginicis.admin.orders.escrow-deny-confirm` +- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\AdminEscrowDenyConfirmController@confirm` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| orderNumber | path | string | 예 | — | 대상 order number의 식별자 | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-pay_kginicis/admin/orders/APIDOC-KGINICIS-000001/escrow-deny-confirm HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** +`AdminEscrowDenyConfirmController@confirm` 이 구매자가 구매거절을 선택한 에스크로 주문에 대해 판매자(관리자) 측 거절 확인을 처리한다(INIAPI v1 `type=Dncf`). 대상 에스크로 결제(`is_escrow = true`)를 찾아 이미 거절확인 이력(`payment_meta.escrow_deny_confirm`)이 있으면 중복 처리 없이 422 로 막고, 에스크로 자격증명으로 `denyConfirmEscrow` 를 호출한다. 성공 시 확인 시각과 담당자명(`dcnf_name`, 기본 "관리자"), 정제된 PG 응답을 메타에 기록한다. `sirsoft-ecommerce.orders.update` 권한이 필요하며, 에스크로 결제 미존재 404 / 이미 확인됨 422 / PG 실패(resultCode≠`00`) 502 / 예외 500 으로 매핑된다. + + +### GET /api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/transaction-status + +- **라우트명**: `api.plugins.sirsoft-pay_kginicis.admin.orders.transaction-status` +- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\AdminTransactionController@queryByOrder` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| orderNumber | path | string | 예 | — | 대상 order number의 식별자 | + +**요청 예시** + +```http +GET /api/plugins/sirsoft-pay_kginicis/admin/orders/APIDOC-KGINICIS-000001/transaction-status HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| resultCode | string | `0000` | 거래조회 결과 코드. `queryTransaction` API 응답의 처리 결과 코드이며, CBT 로컬 확인 경로에서는 저장된 승인 코드가 없으면 `LOCAL_CBT` 로 대체된다. | +| resultMsg | string | `정상처리` | 결과 코드에 대응하는 메시지. CBT 로컬 확인 시에는 "CBT 거래는 로컬 결제 확인 정보로 표시됩니다." 안내 문구가 들어간다. | +| tid | string | `INIJPGCARDapidocsmpl0000000001` | 조회 대상 KG 이니시스 거래 ID(transaction_id). | +| _is_cbt | boolean | `true` | 이 거래가 CBT(일본결제)인지 여부. TID가 `INIJPG`로 시작하거나 통화가 JPY이면 CBT로 판정된다. | +| _is_local_confirmation | boolean | `true` | 응답이 실제 INIAPI 조회가 아니라 로컬 저장 정보로 구성됐는지 여부. CBT는 한국 INIAPI 거래조회 대상이 아니라 이 값이 `true`가 된다. | +| _is_test_mode | boolean | `true` | 결제가 테스트 모드였는지 여부. `payment_meta.is_test_mode` 또는 자격증명 모드에서 파생된다. | +| _local_is_escrow | boolean | `true` | 로컬 결제 레코드의 에스크로 결제 여부(`is_escrow`). | +| _pay_method | string | `CVS` | KG 이니시스 결제수단 코드(대문자 정규화). 예: `CARD`(신용카드)·`VBANK`(가상계좌)·`CVS`(일본 편의점결제) 등. | +| _base_pay_method_label | string | `일본 편의점결제` | `_base_pay_method` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| _embedded_pg_provider | null | `null` | 간편결제 등 결제창에 내장된 실제 PG 제공사 식별값(예: `kakaopay`, `naverpay`). 일반 결제면 `null`이다. | +| _embedded_pg_provider_label | null | `null` | `_embedded_pg_provider` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| _pay_method_label | string | `일본 편의점결제` | `_pay_method` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| _auth_code | null | `null` | 승인번호. CVS 결제는 확인번호/접수번호를 대체로 사용하며, 카드 등에서는 승인번호(applNum)가 채워진다. | +| _auth_date | string | `2026-07-07 07:17:11` | 승인 일시(YYYY-MM-DD HH:MM:SS 포맷). 승인 일시가 없으면 로컬 결제완료 시각(`paid_at`)으로 대체된다. | +| _total_price | string | `5000` | 결제 총액. 조회 응답 또는 로컬 저장 금액에서 취한 값이다. | +| _currency | string | `JPY` | 결제 통화 코드. CBT는 기본 `JPY`, 일반 국내결제는 원화(응답에 없으면 `WON`)로 표기된다. | +| _moid | string | `APIDOC-KGINICIS-000001` | 가맹점 주문번호(MOID). 이 거래에 대응하는 주문의 주문번호다. | +| _buyer_name | string | `API 문서 샘플 구매자` | 구매자 이름. | +| _buyer_email | string | `apidoc-sample-user@example.com` | 구매자 이메일. | +| _buyer_tel | string | `010-0000-0001` | 구매자 전화번호. | +| _status | string | `waiting_deposit` | 거래/결제 상태. CVS는 결제 상태(`waiting_deposit` 등), 일반 결제는 조회 응답의 거래 상태값이 들어간다. | +| _cancel_price | null | `null` | 취소(환불) 금액. 취소 이력이 없으면 `null`이다. | +| _cancel_date | null | `null` | 취소 일시(YYYY-MM-DD HH:MM:SS). 취소 이력이 없으면 `null`이다. | +| _part_cancel_list | array | `[]` | 부분취소 이력 목록. 각 항목은 취소 금액·일시·메시지·취소 TID로 정규화된다. | +| _card_name | string | `KB국민카드` | 카드 결제 시 발급사(카드사) 이름. | +| _card_num | string | `1554-****-****-9102` | 마스킹된 카드번호(중간 자리는 `*` 처리). | +| _card_code | null | `null` | KG 이니시스 카드사 코드. 없으면 `null`이다. | +| _card_quota | string | `일시불` | 할부 개월. `0`이면 `일시불`, 그 외에는 `N개월`로 변환된다. | +| _card_interest | null | `null` | 무이자 할부 여부. CBT 로컬 확인 경로에서는 `null`이다. | +| _vbank_num | null | `null` | 가상계좌 번호. CVS 결제에서는 확인번호/접수번호로 대체되며, 미해당 시 `null`이다. | +| _vbank_bank_code | null | `null` | 가상계좌 은행 코드. CVS 결제에서는 편의점 식별값(`convenience`)이 들어갈 수 있고, 미해당 시 `null`이다. | +| _vbank_bank_name | string | `CVS` | 가상계좌 은행명. 은행명이 없으면 은행 코드로 매핑하며, 편의점(CVS) 결제에서는 `CVS`로 표기된다. | +| _vbank_holder | null | `null` | 가상계좌 예금주명. 미해당 시 `null`이다. | +| _vbank_expire_date | string | `2026-07-10 23:59:59` | 가상계좌/편의점 입금 마감 일시. 로컬 `vbank_due_at`가 있으면 KST로 변환해 사용하고, 없으면 조회 응답의 입금기한을 포맷한다. | +| _vbank_status | string | `waiting_deposit` | 가상계좌/편의점 입금 상태(입금대기 등). | +| _vbank_paid_at | null | `null` | vbank paid 일시 | +| _bank_code | null | `null` | 계좌이체 은행 코드. 계좌이체 결제가 아니면 `null`이다. | +| _bank_name | null | `null` | 계좌이체 은행명. 은행명이 없으면 은행 코드로 매핑하며, 미해당 시 `null`이다. | +| _bank_acnt_num | null | `null` | 계좌이체 계좌번호. 미해당 시 `null`이다. | +| _hpp_num | null | `null` | 휴대폰 결제 시 결제 휴대폰 번호. 미해당 시 `null`이다. | +| _hpp_corp | null | `null` | 휴대폰 결제 시 이동통신사. 미해당 시 `null`이다. | +| _escrow_status | null | `null` | 에스크로 거래 상태. 에스크로 결제가 아니거나 CBT 로컬 확인 경로에서는 `null`이다. | +| _escrow_confirm | null | `null` | 에스크로 구매확정 일시(YYYY-MM-DD HH:MM:SS). 미확정 시 `null`이다. | +| _inquiry_at | string | `2026-07-07 07:24:51` | inquiry 일시 | +| _local_notice | string | `CBT 거래는 한국 INIAPI 거래조회 대상이 아니므로 저장된 승…` | CBT 로컬 확인 경로에서만 채워지는 안내 문구. 이 거래가 실시간 PG 조회가 아니라 저장된 승인/입금 정보로 표시됨을 관리자에게 알린다. | +| _cbt_cvs | object | `{"status":"waiting_deposit","last_notify_at":"","last_not…` | CBT 편의점(CVS) 결제일 때만 채워지는 입금 운영 요약. 입금 상태·최근 통보/재확인 결과·만료 정보·최근 통보 이력(최대 10건)을 담으며, CVS가 아니면 `null`이다. | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "resultCode": "0000", + "resultMsg": "정상처리", + "tid": "INIJPGCARDapidocsmpl0000000001", + "_is_cbt": true, + "_is_local_confirmation": true, + "_is_test_mode": true, + "_local_is_escrow": true, + "_pay_method": "CVS", + "_base_pay_method_label": "일본 편의점결제", + "_embedded_pg_provider": null, + "_embedded_pg_provider_label": null, + "_pay_method_label": "일본 편의점결제", + "_auth_code": null, + "_auth_date": "2026-07-08 06:32:34", + "_total_price": "5000", + "_currency": "JPY", + "_moid": "APIDOC-KGINICIS-000001", + "_buyer_name": "API 문서 샘플 구매자", + "_buyer_email": "apidoc-sample-user@example.com", + "_buyer_tel": "010-0000-0001", + "_status": "waiting_deposit", + "_cancel_price": null, + "_cancel_date": null, + "_part_cancel_list": [], + "_card_name": "신한카드", + "_card_num": "2570-****-****-6458", + "_card_code": null, + "_card_quota": "일시불", + "_card_interest": null, + "_vbank_num": null, + "_vbank_bank_code": null, + "_vbank_bank_name": "CVS", + "_vbank_holder": null, + "_vbank_expire_date": "2026-07-10 23:59:59", + "_vbank_status": "waiting_deposit", + "_vbank_paid_at": null, + "_bank_code": null, + "_bank_name": null, + "_bank_acnt_num": null, + "_hpp_num": null, + "_hpp_corp": null, + "_escrow_status": null, + "_escrow_confirm": null, + "_inquiry_at": "2026-07-08 06:32:40", + "_local_notice": "CBT 거래는 한국 INIAPI 거래조회 대상이 아니므로 저장된 승인/입금 확인 정보로 표시됩니다.", + "_cbt_cvs": { + "status": "waiting_deposit", + "last_notify_at": "", + "last_notify_result": "", + "last_notify_reason": "", + "last_recheck_at": "", + "last_recheck_result": "", + "expired_at": "", + "expiry_reason": "", + "notify_history": [] + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** +`AdminTransactionController@queryByOrder` 가 주문번호로 KG 이니시스 결제의 실시간 거래 상태를 조회한다. 결제 TID 를 찾은 뒤, CBT(일본결제, TID 가 `INIJPG` 로 시작하거나 통화 JPY)면 한국 INIAPI 조회 대상이 아니므로 로컬에 저장된 승인/입금 정보로 응답을 구성하고(`_is_local_confirmation=true`), 그 외에는 결제 시점 MID·모드(`is_test_mode`, MID `SIR` 접두사=Live 추정)에 맞는 자격증명으로 실제 `queryTransaction` API 를 호출한다. 응답은 `_pay_method_label`·카드/가상계좌/은행 상세·부분취소 이력·에스크로 상태 등 화면 표시용 `_` 접두 필드로 보강되며, 결제수단/은행/할부 코드는 한국어 라벨로 매핑된다. `sirsoft-ecommerce.orders.read` 권한이 필요하고, 결제 미존재 시 `data`=`null`, 조회 예외 시 502 로 응답한다. + + +### GET /api/plugins/sirsoft-pay_kginicis/user/orders/{orderNumber}/receipt + +- **라우트명**: `api.plugins.sirsoft-pay_kginicis.user.orders.receipt` +- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\UserReceiptController@show` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| orderNumber | path | string | 예 | — | 대상 order number의 식별자 | + +**요청 예시** + +```http +GET /api/plugins/sirsoft-pay_kginicis/user/orders/APIDOC-KGINICIS-000001/receipt HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + + + + + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "receipt_type": "cbt_confirmation", + "receipt_url": null, + "receipt_label": "결제확인", + "receipt_view_label": "결제확인서 보기", + "receipt_title": "KG 이니시스 CBT 결제확인서", + "receipt_notice": "일본 CBT 결제는 한국 KG 이니시스 매출전표 조회와 별도로 결제 승인 정보를 표시합니다.", + "receipt_fields": [ + { + "label": "주문번호", + "value": "APIDOC-KGINICIS-000001" + }, + { + "label": "결제수단", + "value": "일본 편의점결제" + }, + { + "label": "거래번호", + "value": "INIJPGCARDapidocsmpl0000000001" + }, + { + "label": "결제금액", + "value": "5,000 KRW" + }, + { + "label": "입금 상태", + "value": "입금완료" + }, + { + "label": "편의점 코드", + "value": "seven_eleven" + }, + { + "label": "편의점 확인번호", + "value": "1234567890" + }, + { + "label": "편의점 접수번호", + "value": "0987654321" + }, + { + "label": "입금 마감일시", + "value": "2026-07-10 23:59:59" + } + ], + "is_test_mode": true, + "payment_method_label": "일본 편의점결제", + "payment_method_display_label": "일본 편의점결제", + "cbt_pay_method": "CVS", + "payment_status": "paid", + "selected_payment_method": null, + "embedded_pg_provider": null, + "embedded_pg_provider_label": null +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** +`UserReceiptController@show` 가 회원/비회원 공용 영수증 조회를 제공한다(선택적 인증). 로그인 사용자는 본인 소유 주문만, 비로그인 사용자는 `X-Guest-Order-Token` 헤더로 비회원 주문을 매칭하고, 토큰이 없거나 stale 하면 결제 직후 PG 콜백이 발급한 단기 영수증 쿠키(5분 유효)로 폴백한다. 소유권/토큰/쿠키 검증 실패는 모두 404 로 통일된다. CBT(일본결제) 결제면 KG 이니시스 매출전표가 없으므로 결제확인서용 필드 목록(`receipt_fields`)과 라벨을 직접 구성해 `receipt_type=cbt_confirmation` 으로 내려주고, 일반 결제는 KG 이니시스 영수증 URL(`receipt_type=inicis_receipt`)을 생성해 반환한다. + + diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/payment.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/payment.md new file mode 100644 index 00000000..5f6c0863 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/payment.md @@ -0,0 +1,257 @@ +# Payment API 레퍼런스 + +> **소유**: plugin `sirsoft-pay_kginicis` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Payment 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### POST /api/plugins/sirsoft-pay_kginicis/payment/cbt/checkout-token + +- **라우트명**: `api.plugins.sirsoft-pay_kginicis.payment.cbt.checkout-token` +- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\CbtCheckoutTokenController@issue` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/plugins/sirsoft-pay_kginicis/payment/cbt/checkout-token HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +_대표 에러 없음 (공개 조회). _ + + + +**설명** + +일본 CBT(국경 간) 결제 흐름의 첫 단계로, 프론트엔드 결제창이 후속 해시 생성 요청에 사용할 단기 체크아웃 토큰을 발급합니다. `CbtCheckoutTokenService::issue()` 가 주문번호·금액·구매자(이메일/전화)·요청 IP·User-Agent 를 HMAC-SHA256 으로 봉인한 서명 토큰을 만들어 반환하며, 이 토큰은 hash-data 단계에서 결제 컨텍스트가 위변조되지 않았는지 검증하는 데 쓰입니다. 인증은 필요 없고(결제창에서 직접 호출), 대신 `oid` 기준 IP별 분당 10회 레이트리밋과 일본 결제 활성화·설정 여부, 주문 존재·결제 가능 상태·통화 JPY 여부·구매자 일치·금액 일치를 순차 검증합니다. 필수 파라미터(`oid`, `price`) 누락 시 422, 레이트리밋 초과 시 429, 주문 미존재 시 404, 구매자 검증 실패 시 403, 그 외 결제 불가 조건은 422 로 응답합니다. + + +### POST /api/plugins/sirsoft-pay_kginicis/payment/cbt/hash-data + +- **라우트명**: `api.plugins.sirsoft-pay_kginicis.payment.cbt.hash-data` +- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\CbtHashDataController@generate` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/plugins/sirsoft-pay_kginicis/payment/cbt/hash-data HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +_대표 에러 없음 (공개 조회). _ + + + +**설명** + +일본 CBT 결제창이 실제로 KG 이니시스에 전송할 위변조 방지 해시(P_HASHDATA)를 생성해 반환합니다. `KgInicisApiService::generateCbtHashData()` 가 일본 가맹점 MID·타임스탬프·금액·주문번호로 해시를 만들며, 인증은 불필요하지만 checkout-token 단계에서 발급한 토큰을 `CbtCheckoutTokenService::verify()` 로 재검증해 동일한 결제 컨텍스트(주문·금액·구매자·IP·UA)에서 온 요청임을 보장합니다. 재생 공격을 막기 위해 타임스탬프 신선도(`isTimestampFresh`)를 확인하고, `oid` 기준 IP별 분당 10회 레이트리밋과 일본 결제 활성화·설정·주문 상태·통화 JPY·구매자 일치·금액 일치를 검증합니다. 파라미터(`oid`, `price`, `timestamp`) 누락·타임스탬프 만료·금액 불일치 등은 422, 레이트리밋 초과 429, 주문 미존재 404, 구매자 검증 또는 토큰 검증 실패는 403 으로 응답합니다. + + +### POST /api/plugins/sirsoft-pay_kginicis/payment/close-report + +- **라우트명**: `api.plugins.sirsoft-pay_kginicis.payment.close-report` +- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\PaymentCloseReportController@store` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| oid | body | string | 예 | max 40 | 결제창을 닫은 대상 주문의 주문번호. 서버가 이 값으로 주문을 조회해 결제 실패/취소 이력을 기록한다. | +| price | body | integer | 예 | min 1 | 주문 결제 금액. 저장된 주문 청구액과 일치하는지 검증해 위변조된 닫힘 보고를 차단한다. | +| buyer_email | body | string | 아니오 | max 255 | 구매자 이메일. 제공 시 주문의 구매자 정보와 대조해 본인 요청인지 확인하는 데 사용된다. | +| buyer_phone | body | string | 아니오 | max 30 | 구매자 전화번호. 제공 시 주문의 구매자 정보와 대조해 본인 요청인지 확인하는 데 사용된다. | +| payment_method | body | string | 아니오 | max 50 | 사용자가 결제창에서 선택했던 간편결제 등 결제수단 식별값. 결제 메타에 병합해 어떤 수단에서 창을 닫았는지 남긴다. | +| reason | body | string | 아니오 | max 80 | 결제창 닫힘 사유 문자열. 실패/취소 이력에 참고 정보로 기록된다. | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-pay_kginicis/payment/close-report HTTP/1.1 +Host: api.example.com +Accept: application/json +Content-Type: application/json + +{ + "oid": "예시값", + "price": 1, + "buyer_email": "user@example.com", + "buyer_phone": "010-1234-5678", + "payment_method": "예시값", + "reason": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +PC 표준결제창(KRW)에서 사용자가 결제를 완료하지 않고 창을 닫았을 때 프론트엔드가 이를 서버에 보고하는 엔드포인트로, 해당 주문의 결제 실패/취소 이력을 기록합니다. `OrderProcessingService::failPayment()` 로 주문을 `USER_CANCEL` 사유로 실패 처리하고 `recordPaymentCancellation()` 으로 취소 이력을 남기며, 간편결제 선택 정보가 있으면 결제 메타에 병합합니다. 인증은 불필요하나(결제창 컨텍스트에서 호출) FormRequest 검증과 `oid` 기준 IP별 분당 20회 레이트리밋, 주문 존재·통화 KRW·구매자 일치·금액 일치를 검증하고, 이미 결제 가능 상태가 아니거나(`order_not_payable`) 이미 결제 완료(`payment_already_paid`)면 성공 응답에 `status: ignored` 로 무시 처리해 결제 성공 콜백과의 경쟁 상태를 차단합니다. 검증 규칙 위반 시 422, 레이트리밋 초과 429, 주문 미존재 404, 구매자 검증 실패 403 으로 응답합니다. + + +### POST /api/plugins/sirsoft-pay_kginicis/payment/mobile/signature + +- **라우트명**: `api.plugins.sirsoft-pay_kginicis.payment.mobile.signature` +- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\MobileSignatureController@generate` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| oid | body | string | 예 | max 40 | 결제 대상 주문의 주문번호. 서버가 주문을 조회해 결제 가능 상태·통화·금액을 검증하고 이 값을 해시 생성 입력으로 사용한다. | +| price | body | integer | 예 | min 1 | 모바일 결제 금액. 저장된 주문 청구액과 일치하는지 검증한 뒤 P_CHKFAKE 해시 생성에 반영한다. | +| timestamp | body | string | 예 | max 20 | 결제창이 생성한 요청 타임스탬프. 재생 공격 방지를 위해 신선도(만료 여부)를 확인하고 해시 계산에 함께 사용한다. | +| buyer_email | body | string | 아니오 | max 255 | 구매자 이메일. 제공 시 주문의 구매자 정보와 대조해 본인 결제 요청인지 확인하는 데 사용된다. | +| buyer_phone | body | string | 아니오 | max 30 | 구매자 전화번호. 제공 시 주문의 구매자 정보와 대조해 본인 결제 요청인지 확인하는 데 사용된다. | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-pay_kginicis/payment/mobile/signature HTTP/1.1 +Host: api.example.com +Accept: application/json +Content-Type: application/json + +{ + "oid": "예시값", + "price": 1, + "timestamp": "예시값", + "buyer_email": "user@example.com", + "buyer_phone": "010-1234-5678" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +모바일 KRW 결제창이 요구하는 위변조 방지 해시(P_CHKFAKE)와 모바일 결제 URL 을 생성해 반환합니다. `KgInicisApiService::generateMobileChkfake()` 로 주문번호·금액·타임스탬프 기반 해시를 만들고 `getMobilePaymentUrl()` 로 결제창 진입 URL 을 함께 내려줍니다. 인증은 불필요하지만(결제창에서 직접 호출) FormRequest 검증에 더해 재생 공격 방지를 위한 타임스탬프 신선도 확인, `oid` 기준 IP별 분당 20회 레이트리밋, 그리고 주문 존재·결제 가능 상태·통화 KRW·구매자 일치·금액 일치를 검증하며 모바일 결제 자격증명 설정 여부도 확인합니다. 파라미터 검증 실패·타임스탬프 만료·통화 불일치·금액 불일치·자격증명 미설정은 422, 레이트리밋 초과 429, 주문 미존재 404, 구매자 검증 실패 403 으로 응답합니다. + + +### POST /api/plugins/sirsoft-pay_kginicis/payment/signature + +- **라우트명**: `api.plugins.sirsoft-pay_kginicis.payment.signature` +- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\PaymentSignatureController@generate` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| oid | body | string | 예 | max 40 | 결제 대상 주문의 주문번호. 서버가 주문을 조회해 결제 가능 상태·통화·금액을 검증하고 이 값을 서명 생성 입력으로 사용한다. | +| price | body | integer | 예 | min 100 | PC 표준결제창 결제 금액(최소 100원). 저장된 주문 청구액과 일치하는지 검증한 뒤 signature 생성에 반영한다. | +| timestamp | body | string | 예 | max 20 | 결제창이 생성한 요청 타임스탬프. 재생 공격 방지를 위해 신선도(만료 여부)를 확인하고 서명 계산에 함께 사용한다. | +| buyer_email | body | string | 아니오 | max 255 | 구매자 이메일. 제공 시 주문의 구매자 정보와 대조해 본인 결제 요청인지 확인하는 데 사용된다. | +| buyer_phone | body | string | 아니오 | max 30 | 구매자 전화번호. 제공 시 주문의 구매자 정보와 대조해 본인 결제 요청인지 확인하는 데 사용된다. | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-pay_kginicis/payment/signature HTTP/1.1 +Host: api.example.com +Accept: application/json +Content-Type: application/json + +{ + "oid": "예시값", + "price": 1, + "timestamp": "예시값", + "buyer_email": "user@example.com", + "buyer_phone": "010-1234-5678" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +PC 표준결제창(KRW)이 요구하는 서명(signature)·검증값(verification)·mKey 를 생성해 반환하는, PC 결제 흐름의 시작점입니다. `KgInicisApiService` 의 `generateSignature()`·`generateVerification()`·`getMKey()` 를 호출해 주문번호·금액·타임스탬프로 만든 서명 세트를 내려주며, 프론트엔드는 이 값으로 KG 이니시스 표준결제창을 호출합니다. 인증은 불필요하나(결제창에서 직접 호출) FormRequest 검증, 재생 공격 방지용 타임스탬프 신선도 확인, `oid` 기준 IP별 분당 20회 레이트리밋, 주문 존재·결제 가능 상태·통화 KRW·구매자 일치·금액 일치 검증과 표준결제 자격증명 설정 여부를 확인합니다. 파라미터 검증 실패·타임스탬프 만료·통화/금액 불일치·자격증명 미설정은 422, 레이트리밋 초과 429, 주문 미존재 404, 구매자 검증 실패 403 으로 응답합니다. + + diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/transaction.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/transaction.md new file mode 100644 index 00000000..94b2273e --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/transaction.md @@ -0,0 +1,60 @@ +# Transaction API 레퍼런스 + +> **소유**: plugin `sirsoft-pay_kginicis` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Transaction 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### POST /api/plugins/sirsoft-pay_kginicis/admin/transaction/query + +- **라우트명**: `api.plugins.sirsoft-pay_kginicis.admin.transaction.query` +- **컨트롤러**: `Plugins\Sirsoft\PayKginicis\Controllers\AdminTransactionController@query` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/plugins/sirsoft-pay_kginicis/admin/transaction/query HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.read`)이 없는 경우 | + + + +**설명** + +거래번호(TID)를 직접 입력받아 KG 이니시스 거래 상태를 조회하고 화면 표시용으로 보강해 반환하는 관리자 엔드포인트입니다. 로컬 결제 레코드(`ecommerce_order_payments`)에서 결제 시점 MID·테스트 모드·에스크로 여부를 해석해 해당 자격증명으로 `KgInicisApiService::queryTransaction()` 을 호출하며, 응답을 카드/가상계좌/간편결제/취소 이력 등 상세 필드로 정규화하고 은행 코드→은행명·할부 개월·날짜 포맷을 변환합니다. 일본 CBT 거래(TID `INIJPG` prefix 또는 통화 JPY)는 한국 INIAPI 조회 대상이 아니므로 저장된 로컬 승인/입금 확인 정보로 결과를 구성합니다. 관리자 인증(`auth:sanctum`)과 `sirsoft-ecommerce.orders.read` 권한이 필요하며, `tid` 미입력은 422, 토큰 누락·만료 401, 권한 부족 403, KG 이니시스 조회 실패 시 502 로 응답합니다. + + diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/vbank.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/vbank.md new file mode 100644 index 00000000..4ed9381e --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/vbank.md @@ -0,0 +1,76 @@ +# Vbank API 레퍼런스 + +> **소유**: plugin `sirsoft-pay_kginicis` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Vbank 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/plugins/sirsoft-pay_kginicis/admin/vbank-notify-url + +- **라우트명**: `api.plugins.sirsoft-pay_kginicis.admin.vbank.notify.url` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-pay_kginicis/admin/vbank-notify-url HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| url | string | `https://api.example.com/plugins/sirsoft-pay_…` | PC 웹용 가상계좌 입금통보(NOTI) 수신 콜백 URL(`/plugins/sirsoft-pay_kginicis/payment/vbank-notify`)의 절대 주소. 운영자가 이 값을 KG 이니시스 가맹점 설정에 입금통보 URL로 등록하면, 구매자가 가상계좌에 실제 입금했을 때 KG 이니시스가 이 주소로 통보한다. | +| mobile_url | string | `https://api.example.com/plugins/sirsoft-pay_…` | mobile URL | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "data": { + "url": "https://api.example.com/plugins/sirsoft-pay_kginicis/payment/vbank-notify", + "mobile_url": "https://api.example.com/plugins/sirsoft-pay_kginicis/payment/mobile/vbank-notify" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.read`)이 없는 경우 | + + + +**설명** + +가상계좌 입금통보를 받을 콜백 URL(PC 웹용 `url`, 모바일용 `mobile_url`)을 관리자 설정 페이지에 표시하기 위해 반환하는 엔드포인트입니다. 라우트 파일 내 클로저로 정의되어 있으며, `url()` 헬퍼로 현재 사이트 도메인 기준의 절대 URL(`/plugins/sirsoft-pay_kginicis/payment/vbank-notify` 및 `.../payment/mobile/vbank-notify`)을 조합해 내려줍니다. 운영자는 이 URL 을 KG 이니시스 가맹점 설정에 입금통보 URL 로 등록해, 구매자가 가상계좌에 실제로 입금했을 때 KG 이니시스가 이 주소로 통보를 보내도록 합니다. 관리자 인증(`auth:sanctum`)과 `sirsoft-ecommerce.settings.read` 권한이 필요하며, 토큰 누락·만료는 401, 권한 부족은 403 으로 응답합니다. + + diff --git a/plugins/_bundled/sirsoft-pay_kginicis/lang/en/messages.php b/plugins/_bundled/sirsoft-pay_kginicis/lang/en/messages.php index 04b6f559..3c00cef3 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/lang/en/messages.php @@ -30,7 +30,7 @@ return [ 'japan_contact_email_required' => 'Contact email for the live Japan payment window is required.', 'japan_contact_phone_required' => 'Contact phone for the live Japan payment window is required.', 'japan_contact_opening_hours_required' => 'Contact opening hours for the live Japan payment window are required.', - 'replace_sample_value' => 'Replace sample values with your real contract information in live mode.', + 'replace_sample_value' => 'Replace the sample Japan merchant display information with your real contract information in live mode.', ], 'defaults' => [ 'good_name' => 'Goods', diff --git a/plugins/_bundled/sirsoft-pay_kginicis/lang/ko/messages.php b/plugins/_bundled/sirsoft-pay_kginicis/lang/ko/messages.php index 80dcda6e..91395990 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/lang/ko/messages.php @@ -30,7 +30,7 @@ return [ 'japan_contact_email_required' => '운영 일본 결제창에 표시할 문의 이메일이 필요합니다.', 'japan_contact_phone_required' => '운영 일본 결제창에 표시할 문의 전화번호가 필요합니다.', 'japan_contact_opening_hours_required' => '운영 일본 결제창에 표시할 문의 영업시간이 필요합니다.', - 'replace_sample_value' => '운영 모드에서는 샘플값 대신 실제 계약 정보를 입력하세요.', + 'replace_sample_value' => '운영 모드에서는 일본 가맹점 표시 정보의 샘플값을 실제 계약 정보로 변경하세요.', ], 'defaults' => [ 'good_name' => '상품', diff --git a/plugins/_bundled/sirsoft-pay_kginicis/resources/js/__tests__/layouts/pluginSettingsSticky.test.tsx b/plugins/_bundled/sirsoft-pay_kginicis/resources/js/__tests__/layouts/pluginSettingsSticky.test.tsx index 6f4c7ba8..34150d02 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/resources/js/__tests__/layouts/pluginSettingsSticky.test.tsx +++ b/plugins/_bundled/sirsoft-pay_kginicis/resources/js/__tests__/layouts/pluginSettingsSticky.test.tsx @@ -49,6 +49,21 @@ function collectClassNames(node: unknown, classNames: string[] = []): string[] { return classNames; } +function collectNodes(node: unknown, predicate: (value: Record) => boolean, nodes: Record[] = []): Record[] { + if (!node || typeof node !== 'object') { + return nodes; + } + const value = node as Record; + if (predicate(value)) { + nodes.push(value); + } + for (const child of Object.values(value)) { + collectNodes(child, predicate, nodes); + } + + return nodes; +} + describe('plugin_settings 하단 버튼 sticky 고정', () => { it('footer_buttons 에 sticky bottom 고정 클래스가 존재해야 한다', () => { const footer = findById(pluginSettingsLayout, 'footer_buttons'); @@ -80,4 +95,57 @@ describe('plugin_settings 하단 버튼 sticky 고정', () => { expect(className).not.toContain('row-stack'); } }); + + it('저장 실패 시 apiCall onError 에서 validation errors 를 error 컨텍스트에서 읽어야 한다', () => { + const saveActions = collectNodes(pluginSettingsLayout, (node) => { + return node.handler === 'apiCall' + && typeof node.target === 'string' + && node.target.includes('/api/admin/plugins/') + && node.target.includes('/settings'); + }); + + expect(saveActions).toHaveLength(1); + + const onError = saveActions[0].onError as Record[] | undefined; + expect(onError).toBeDefined(); + expect(onError?.[0]?.handler).toBe('setState'); + + const params = (onError?.[0]?.params ?? {}) as Record; + expect(params.errors).toBe('{{error.errors ?? { _general: error.message }}}'); + }); + + it('저장 실패 시 상단 검증 오류 영역으로 자동 스크롤해야 한다', () => { + const validationError = collectNodes(pluginSettingsLayout, (node) => { + return node.id === 'validation_error'; + })[0]; + + expect(validationError).toBeDefined(); + + const validationErrorProps = (validationError.props ?? {}) as Record; + expect(validationErrorProps.id).toBe('kginicis_settings_validation_error'); + expect(validationErrorProps.role).toBe('alert'); + expect(String(validationErrorProps.className)).toContain('scroll-mt-32'); + + const saveActions = collectNodes(pluginSettingsLayout, (node) => { + return node.handler === 'apiCall' + && typeof node.target === 'string' + && node.target.includes('/api/admin/plugins/') + && node.target.includes('/settings'); + }); + + expect(saveActions).toHaveLength(1); + + const onError = saveActions[0].onError as Record[] | undefined; + const scrollAction = onError?.find((action) => action.handler === 'scrollIntoView'); + + expect(scrollAction).toBeDefined(); + + const params = (scrollAction?.params ?? {}) as Record; + expect(params).toMatchObject({ + selector: '#kginicis_settings_validation_error', + behavior: 'smooth', + block: 'start', + waitForElement: true, + }); + }); }); diff --git a/plugins/_bundled/sirsoft-pay_kginicis/resources/lang/en.json b/plugins/_bundled/sirsoft-pay_kginicis/resources/lang/en.json index 79853876..aba0f1ca 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-pay_kginicis/resources/lang/en.json @@ -4,6 +4,7 @@ "kginicis_cbt_reconciliation": "Cross-border Reconciliation", "kginicis_escrow_delivery": "Escrow Delivery", "kginicis_status": "Payment Status", + "kginicis_test_mode_status": "Test Mode Status", "kginicis_test_map": "Test Payment Map", "settings": "Payment Settings", "vbank_info": "Virtual Account Info" @@ -52,7 +53,7 @@ "japan_contact_email_required": "Contact email for the live Japan payment window is required.", "japan_contact_phone_required": "Contact phone for the live Japan payment window is required.", "japan_contact_opening_hours_required": "Contact opening hours for the live Japan payment window are required.", - "replace_sample_value": "Replace sample values with your real contract information in live mode." + "replace_sample_value": "Replace the sample Japan merchant display information with your real contract information in live mode." } }, "payment_error": { @@ -74,6 +75,10 @@ "test_mode_badge": "Test Payment", "test_mode_warning_badge": "Test Payment WARNING)", "test_mode_order_notice": "This order was processed in test mode. Real card authorization/withdrawal notifications may occur, but KG Inicis test transactions may be automatically cancelled daily between 23:00 and 23:50. DO NOT ship this order.", + "test_mode_settings_warning_plugin": "KG Inicis", + "test_mode_settings_warning_title": "Warning: KG Inicis payments are currently set to test mode.", + "test_mode_settings_warning_body": "Test payments do not create real payments. If the shop is live, switch this plugin to live payment mode before operating.", + "test_mode_settings_warning_action": "Switch to live mode", "order_verify_title": "KG Inicis Payment Verification", "order_verify_description": "Query real-time payment status from KG Inicis servers.", "order_verify_button": "Query Payment", diff --git a/plugins/_bundled/sirsoft-pay_kginicis/resources/lang/ko.json b/plugins/_bundled/sirsoft-pay_kginicis/resources/lang/ko.json index 5f4ba04a..b19d870a 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-pay_kginicis/resources/lang/ko.json @@ -4,6 +4,7 @@ "kginicis_cbt_reconciliation": "해외결제 정산", "kginicis_escrow_delivery": "에스크로 배송", "kginicis_status": "결제 상태", + "kginicis_test_mode_status": "테스트 모드 상태", "kginicis_test_map": "테스트 결제 맵", "settings": "결제 설정", "vbank_info": "가상계좌 정보" @@ -52,7 +53,7 @@ "japan_contact_email_required": "운영 일본 결제창에 표시할 문의 이메일이 필요합니다.", "japan_contact_phone_required": "운영 일본 결제창에 표시할 문의 전화번호가 필요합니다.", "japan_contact_opening_hours_required": "운영 일본 결제창에 표시할 문의 영업시간이 필요합니다.", - "replace_sample_value": "운영 모드에서는 샘플값 대신 실제 계약 정보를 입력하세요." + "replace_sample_value": "운영 모드에서는 일본 가맹점 표시 정보의 샘플값을 실제 계약 정보로 변경하세요." } }, "payment_error": { @@ -74,6 +75,10 @@ "test_mode_badge": "테스트 결제", "test_mode_warning_badge": "테스트 결제 주의)", "test_mode_order_notice": "이 주문은 테스트 결제로 처리되었습니다. 실제 카드 승인/출금 알림이 발생할 수 있으나, KG 이니시스 테스트 거래는 매일 23:00~23:50 사이 자동 취소될 수 있습니다. 절대 배송하지 마세요.", + "test_mode_settings_warning_plugin": "KG 이니시스", + "test_mode_settings_warning_title": "(주의!) KG 이니시스 결제 설정이 현재 테스트결제로 되어 있습니다.", + "test_mode_settings_warning_body": "테스트결제 시 실제 결제가 되지 않으므로, 쇼핑몰 운영 중이면 반드시 실결제로 설정하여 운영하세요.", + "test_mode_settings_warning_action": "실결제로 설정", "order_verify_title": "KG 이니시스 결제 검증", "order_verify_description": "KG 이니시스 서버에서 실시간으로 결제 상태를 조회합니다.", "order_verify_button": "결제 조회", diff --git a/plugins/_bundled/sirsoft-pay_kginicis/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-pay_kginicis/resources/layouts/admin/plugin_settings.json index 17391a71..a4df03c1 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-pay_kginicis/resources/layouts/admin/plugin_settings.json @@ -61,7 +61,11 @@ "name": "Div", "if": "{{_local.errors}}", "props": { - "className": "alert-danger" + "id": "kginicis_settings_validation_error", + "role": "alert", + "aria-live": "assertive", + "tabIndex": -1, + "className": "alert-danger scroll-mt-32" }, "children": [ { @@ -2688,7 +2692,17 @@ "params": { "target": "local", "isSaving": false, - "errors": "{{$error.errors}}" + "errors": "{{error.errors ?? { _general: error.message }}}" + } + }, + { + "handler": "scrollIntoView", + "params": { + "selector": "#kginicis_settings_validation_error", + "behavior": "smooth", + "block": "start", + "waitForElement": true, + "timeout": 2000 } }, { diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Concerns/SerializesPaymentCallbacks.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Concerns/SerializesPaymentCallbacks.php new file mode 100644 index 00000000..6b6f8390 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Concerns/SerializesPaymentCallbacks.php @@ -0,0 +1,57 @@ +callbackLockKey($context, $orderNumber); + $lock = Cache::lock($key, $this->callbackLockTtlSeconds()); + + try { + $lock->block($this->callbackLockWaitSeconds()); + } catch (LockTimeoutException $e) { + Log::warning('KG Inicis: callback lock timeout', [ + 'context' => $context, + 'order_number' => $orderNumber, + 'lock_key' => $key, + ]); + + throw $e; + } + + return $lock; + } + + protected function releaseOrderCallbackLock(mixed $lock): void + { + if (is_object($lock) && method_exists($lock, 'release')) { + $lock->release(); + } + } + + protected function callbackLockKey(string $context, string $orderNumber): string + { + return 'sirsoft-pay_kginicis:callback:' + . $context + . ':' + . sha1(trim($orderNumber)); + } + + protected function callbackLockTtlSeconds(): int + { + return 30; + } + + protected function callbackLockWaitSeconds(): int + { + return 10; + } +} diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Concerns/ValidatesCbtOrderContext.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Concerns/ValidatesCbtOrderContext.php index f89325f7..b8fd6d2d 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Concerns/ValidatesCbtOrderContext.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Concerns/ValidatesCbtOrderContext.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace Plugins\Sirsoft\PayKginicis\Concerns; +use InvalidArgumentException; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Ecommerce\Models\Order; use Modules\Sirsoft\Ecommerce\Models\OrderAddress; use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService; @@ -24,6 +26,33 @@ trait ValidatesCbtOrderContext return $this->expectedPaymentPrice($order); } + protected function resolveExpectedPaymentPriceOrNull(Order $order, string $context, array $logContext = []): ?int + { + try { + return $this->expectedPaymentPrice($order); + } catch (InvalidArgumentException $e) { + $this->logInvalidPaymentCurrency($order, $e, $context, $logContext); + + return null; + } + } + + protected function logInvalidPaymentCurrency( + Order $order, + InvalidArgumentException $e, + string $context, + array $logContext = [], + ): void { + Log::error('KG Inicis: payment currency is not chargeable', array_merge([ + 'context' => $context, + 'order_id' => $order->id, + 'order_number' => $order->order_number, + 'currency' => $order->currency, + 'currency_snapshot' => $order->currency_snapshot, + 'error' => $e->getMessage(), + ], $logContext)); + } + protected function requestMatchesOrderBuyer(Request $request, Order $order): bool { /** @var OrderAddress|null $address */ diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminSettingsStatusController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminSettingsStatusController.php new file mode 100644 index 00000000..a9cd5f4b --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminSettingsStatusController.php @@ -0,0 +1,28 @@ + (bool) $this->settingsService->get(self::PLUGIN_IDENTIFIER, 'is_test_mode', true), + ]); + } +} diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/CbtCallbackController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/CbtCallbackController.php index 7117f914..af5ce159 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/CbtCallbackController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/CbtCallbackController.php @@ -10,11 +10,12 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum; use Modules\Sirsoft\Ecommerce\Models\Order; -use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService; use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService; use Plugins\Sirsoft\PayKginicis\Concerns\IssuesReceiptCookie; use Plugins\Sirsoft\PayKginicis\Concerns\PreventsReplayCallback; +use Plugins\Sirsoft\PayKginicis\Concerns\SerializesPaymentCallbacks; use Plugins\Sirsoft\PayKginicis\Http\Requests\CbtCallbackRequest; +use Plugins\Sirsoft\PayKginicis\Concerns\ValidatesCbtOrderContext; use Plugins\Sirsoft\PayKginicis\Services\CbtReconciliationService; use Plugins\Sirsoft\PayKginicis\Services\KgInicisApiService; @@ -31,6 +32,8 @@ class CbtCallbackController { use IssuesReceiptCookie; use PreventsReplayCallback; + use SerializesPaymentCallbacks; + use ValidatesCbtOrderContext; private const PLUGIN_IDENTIFIER = 'sirsoft-pay_kginicis'; @@ -167,6 +170,7 @@ class CbtCallbackController // Approve 성공 후 후속 처리 실패 시 PG 자동 cancel 추적 변수. $approvedTid = null; $approvedAmount = 0; + $callbackLock = null; try { $order = $this->orderService->findByOrderNumber($oid); @@ -177,6 +181,8 @@ class CbtCallbackController return redirect($this->resolveFailUrl(['error' => 'order_not_found', 'orderId' => $oid])); } + $callbackLock = $this->acquireOrderCallbackLock('cbtAuthCallback', $oid); + $this->assertPayableCbtOrder($order); $pgResponse = $this->apiService->approveCbtPayment($sid); @@ -221,9 +227,6 @@ class CbtCallbackController } $this->assertCbtApproveResponseMatchesOrder($order, $pgResponse, $request, $payMethod); - // PG 승인금액이 누락되어 검증 예외가 발생해도 자동환불 대사 레코드에는 결제 통화 청구액을 남긴다 - // (buildPgPaymentData 와 동일 SSoT — base≠결제 통화에서도 PG 청구 통화와 단위 일치). - $approvedAmount = app(CurrencyConversionService::class)->resolveOrderPaymentChargeAmount($order); $approvedAmount = $this->resolveApprovedAmount($pgResponse, $order); $authResponse = $this->sanitizePgResponse($request->except(['_token']), self::CBT_AUTH_RESPONSE_KEYS); $approveResponse = $this->sanitizePgResponse($pgResponse, self::CBT_APPROVE_RESPONSE_KEYS); @@ -293,6 +296,8 @@ class CbtCallbackController ); return redirect($this->resolveFailUrl(['error' => 'cbt_failed', 'orderId' => $oid])); + } finally { + $this->releaseOrderCallbackLock($callbackLock); } } @@ -396,8 +401,14 @@ class CbtCallbackController // 결제 청구액 SSoT = 결제 통화(order_currency) 환산액 (buildPgPaymentData 와 동일 기준). // CBT 는 order_currency=JPY 강제이나 base 통화는 다를 수 있어(예: base KRW, 결제 JPY) // total_due_amount(base) 직접 비교 시 PG 승인액(JPY)과 단위가 어긋난다. - $expectedAmount = app(CurrencyConversionService::class)->resolveOrderPaymentChargeAmount($order); $pgAmount = $this->firstNonEmptyString($pgResponse, ['amount', 'price', 'bokuApplPrice', 'bokuLocalApplPrice']); + $expectedAmount = $this->resolveExpectedPaymentPriceOrNull($order, 'cbt_approve', [ + 'tid' => $pgResponse['tid'] ?? ($pgResponse['transactionId'] ?? null), + 'received_amount' => $pgAmount, + ]); + if ($expectedAmount === null) { + throw new \RuntimeException('KG Inicis CBT payment currency is not chargeable.'); + } // 일본 CBT 승인 응답은 금액 필드가 비어 온다(매뉴얼상 미사용). 금액이 없으면 // 주문 결제예정액을 승인액으로 신뢰한다(승인 권위 = resultCode OK + tid). diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/MobileCallbackController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/MobileCallbackController.php index d5680808..79ffd303 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/MobileCallbackController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/MobileCallbackController.php @@ -15,6 +15,8 @@ use Plugins\Sirsoft\PayKginicis\Concerns\IssuesReceiptCookie; use Plugins\Sirsoft\PayKginicis\Concerns\PreventsReplayCallback; use Plugins\Sirsoft\PayKginicis\Concerns\ResolvesEasyPaySelection; use Plugins\Sirsoft\PayKginicis\Concerns\SanitizesPgResponse; +use Plugins\Sirsoft\PayKginicis\Concerns\SerializesPaymentCallbacks; +use Plugins\Sirsoft\PayKginicis\Concerns\ValidatesCbtOrderContext; use Plugins\Sirsoft\PayKginicis\Http\Requests\MobileCallbackRequest; use Plugins\Sirsoft\PayKginicis\Services\KgInicisApiService; @@ -33,6 +35,8 @@ class MobileCallbackController use PreventsReplayCallback; use ResolvesEasyPaySelection; use SanitizesPgResponse; + use SerializesPaymentCallbacks; + use ValidatesCbtOrderContext; private const PLUGIN_IDENTIFIER = 'sirsoft-pay_kginicis'; @@ -172,6 +176,7 @@ class MobileCallbackController // cancelPayment 를 호출해 PG 잔존 승인을 해제한다 (사용자 환불 보장). $approvedTid = null; $approvedTotPrice = 0; + $callbackLock = null; try { $order = $this->orderService->findByOrderNumber($moid); @@ -182,6 +187,8 @@ class MobileCallbackController return redirect($this->resolveFailUrl(['error' => 'order_not_found', 'orderId' => $moid])); } + $callbackLock = $this->acquireOrderCallbackLock('mobileAuthCallback', $moid); + // 서버 승인 요청: POST P_REQ_URL with P_MID + P_TID $result = $this->apiService->authorizeMobilePayment($reqUrl, $pTid); @@ -228,6 +235,21 @@ class MobileCallbackController $approvedTotPrice = $totPrice; $embeddedPgProvider = $this->resolveEmbeddedPgProvider($selectedEasyPayMethod); + $expectedAmount = $this->resolveExpectedPaymentPriceOrNull($order, 'mobile_auth_callback', [ + 'tid' => $tid, + 'received_amount' => $totPrice, + ]); + if ($expectedAmount === null) { + $this->autoCancelIfApproved($approvedTid, $moid, $approvedTotPrice, 'invalid_payment_currency'); + + return redirect($this->resolveFailUrl(['error' => 'invalid_payment_currency', 'orderId' => $moid])); + } + if ($expectedAmount > 0 && $totPrice !== $expectedAmount) { + $this->autoCancelIfApproved($approvedTid, $moid, $approvedTotPrice, 'amount_mismatch'); + + return redirect($this->resolveFailUrl(['error' => 'amount_mismatch', 'orderId' => $moid])); + } + Log::info('KG Inicis mobile: completing card payment', [ 'P_OID' => $moid, 'P_TID' => $tid, @@ -298,6 +320,8 @@ class MobileCallbackController 'message' => $e->getMessage(), 'orderId' => $moid, ])); + } finally { + $this->releaseOrderCallbackLock($callbackLock); } } diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/MobileSignatureController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/MobileSignatureController.php index 8b712482..a0f38bb0 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/MobileSignatureController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/MobileSignatureController.php @@ -107,7 +107,17 @@ class MobileSignatureController ], 403); } - if ($price !== $this->expectedPaymentPrice($order)) { + $expectedPrice = $this->resolveExpectedPaymentPriceOrNull($order, 'mobile_signature', [ + 'received_amount' => $price, + ]); + if ($expectedPrice === null) { + return response()->json([ + 'success' => false, + 'message' => 'Payment currency is not chargeable.', + ], 422); + } + + if ($price !== $expectedPrice) { return response()->json([ 'success' => false, 'message' => 'Payment amount does not match the order amount.', diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCallbackController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCallbackController.php index 85ba2800..b359c92c 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCallbackController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCallbackController.php @@ -16,12 +16,13 @@ use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum; use Modules\Sirsoft\Ecommerce\Exceptions\PaymentAmountMismatchException; use Modules\Sirsoft\Ecommerce\Helpers\DeviceDetector; use Modules\Sirsoft\Ecommerce\Models\Order; -use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService; use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService; use Plugins\Sirsoft\PayKginicis\Concerns\IssuesReceiptCookie; use Plugins\Sirsoft\PayKginicis\Concerns\PreventsReplayCallback; use Plugins\Sirsoft\PayKginicis\Concerns\ResolvesEasyPaySelection; use Plugins\Sirsoft\PayKginicis\Concerns\SanitizesPgResponse; +use Plugins\Sirsoft\PayKginicis\Concerns\SerializesPaymentCallbacks; +use Plugins\Sirsoft\PayKginicis\Concerns\ValidatesCbtOrderContext; use Plugins\Sirsoft\PayKginicis\Http\Requests\AuthCallbackRequest; use Plugins\Sirsoft\PayKginicis\Http\Requests\MobileVbankNotifyRequest; use Plugins\Sirsoft\PayKginicis\Http\Requests\VbankNotifyRequest; @@ -40,6 +41,8 @@ class PaymentCallbackController use PreventsReplayCallback; use ResolvesEasyPaySelection; use SanitizesPgResponse; + use SerializesPaymentCallbacks; + use ValidatesCbtOrderContext; private const PLUGIN_IDENTIFIER = 'sirsoft-pay_kginicis'; @@ -223,6 +226,7 @@ class PaymentCallbackController $authUrl = $receivedAuthUrl; $netCancelUrl = $this->apiService->resolveIdcNetCancelUrl($idcName); $tid = ''; + $callbackLock = null; try { $order = $this->orderService->findByOrderNumber($moid); @@ -233,6 +237,8 @@ class PaymentCallbackController return redirect($this->resolveFailUrl(['error' => 'order_not_found', 'orderId' => $moid])); } + $callbackLock = $this->acquireOrderCallbackLock('authCallback', $moid); + HookManager::doAction('sirsoft-pay_kginicis.payment.before_authorize', $order, $validated); $pgResponse = $this->apiService->authorizePayment($authUrl, $authToken); @@ -280,6 +286,21 @@ class PaymentCallbackController $totPrice = (int) ($pgResponse['TotPrice'] ?? $pgResponse['totPrice'] ?? 0); } + $expectedAmount = $this->resolveExpectedPaymentPriceOrNull($order, 'auth_callback', [ + 'tid' => $tid, + 'received_amount' => $totPrice, + ]); + if ($expectedAmount === null) { + $this->apiService->sendNetCancel($netCancelUrl, $authToken); + + return redirect($this->resolveFailUrl(['error' => 'invalid_payment_currency', 'orderId' => $moid])); + } + if ($expectedAmount > 0 && $totPrice !== $expectedAmount) { + $this->apiService->sendNetCancel($netCancelUrl, $authToken); + + return redirect($this->resolveFailUrl(['error' => 'amount_mismatch', 'orderId' => $moid])); + } + $embeddedPgProvider = $this->resolveEmbeddedPgProvider($selectedEasyPayMethod); Log::info('KG Inicis: completing card payment', [ @@ -352,6 +373,8 @@ class PaymentCallbackController 'message' => $e->getMessage(), 'orderId' => $moid, ])); + } finally { + $this->releaseOrderCallbackLock($callbackLock); } } @@ -376,6 +399,8 @@ class PaymentCallbackController 'bank' => $validated['nm_inputbank'] ?? null, ]); + $callbackLock = null; + try { $order = $this->orderService->findByOrderNumber($moid); @@ -385,6 +410,8 @@ class PaymentCallbackController return response('FAIL', 200)->header('Content-Type', 'text/plain'); } + $callbackLock = $this->acquireOrderCallbackLock('vbankNotify', $moid); + if ($this->wasAlreadyPaid($tid)) { $this->logReplayDetected($tid, $moid, 'PC vbankNotify'); @@ -430,6 +457,8 @@ class PaymentCallbackController ]); return response('FAIL', 200)->header('Content-Type', 'text/plain'); + } finally { + $this->releaseOrderCallbackLock($callbackLock); } } @@ -467,6 +496,8 @@ class PaymentCallbackController 'bank' => $validated['P_FN_NM'] ?? null, ]); + $callbackLock = null; + try { $order = $this->orderService->findByOrderNumber($moid); @@ -476,6 +507,8 @@ class PaymentCallbackController return response('FAIL', 200)->header('Content-Type', 'text/plain'); } + $callbackLock = $this->acquireOrderCallbackLock('mobileVbankNotify', $moid); + if ($this->wasAlreadyPaid($tid)) { $this->logReplayDetected($tid, $moid, 'mobile vbankNotify'); @@ -521,6 +554,8 @@ class PaymentCallbackController ]); return response('FAIL', 200)->header('Content-Type', 'text/plain'); + } finally { + $this->releaseOrderCallbackLock($callbackLock); } } @@ -599,7 +634,15 @@ class PaymentCallbackController // 결제 청구액 SSoT = 결제 통화(order_currency) 환산액 (buildPgPaymentData 와 동일 기준). // base≠결제 통화에서 PG 통보 금액(환산 통화)과 단위가 일치하도록 한다. - $expectedAmount = app(CurrencyConversionService::class)->resolveOrderPaymentChargeAmount($order); + $expectedAmount = $this->resolveExpectedPaymentPriceOrNull($order, 'vbank_notify', [ + 'source' => $source, + 'tid' => $notify['tid'], + 'received_amount' => $notify['amount'], + ]); + if ($expectedAmount === null) { + return false; + } + if ($expectedAmount > 0 && $notify['amount'] !== $expectedAmount) { Log::warning('KG Inicis: vbank notify rejected - amount mismatch', [ 'source' => $source, diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCloseReportController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCloseReportController.php index 014e238d..b43391c0 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCloseReportController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCloseReportController.php @@ -5,17 +5,20 @@ declare(strict_types=1); namespace Plugins\Sirsoft\PayKginicis\Controllers; use App\Helpers\ResponseHelper; +use Illuminate\Contracts\Cache\LockTimeoutException; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\RateLimiter; use Modules\Sirsoft\Ecommerce\Models\Order; use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService; use Plugins\Sirsoft\PayKginicis\Concerns\ResolvesEasyPaySelection; +use Plugins\Sirsoft\PayKginicis\Concerns\SerializesPaymentCallbacks; use Plugins\Sirsoft\PayKginicis\Concerns\ValidatesCbtOrderContext; use Plugins\Sirsoft\PayKginicis\Http\Requests\PaymentCloseReportRequest; class PaymentCloseReportController { use ResolvesEasyPaySelection; + use SerializesPaymentCallbacks; use ValidatesCbtOrderContext; private const FAILURE_CODE = 'USER_CANCEL'; @@ -46,73 +49,97 @@ class PaymentCloseReportController } RateLimiter::hit($rateLimitKey, 60); - $order = $this->orderService->findByOrderNumber($oid); - if (! $order) { - return ResponseHelper::error('messages.failed', 404, [ - 'message' => ['Order not found.'], - ]); - } + $callbackLock = null; - if (strtoupper((string) $order->currency) !== 'KRW') { - return ResponseHelper::error('messages.failed', 422, [ - 'message' => ['Standard KG Inicis close report is only available for KRW orders.'], - ]); - } + try { + // PC close-report 는 PC authCallback 과 경쟁할 수 있으므로 같은 주문 락을 공유한다. + // authCallback 이 먼저 결제완료를 확정했다면 아래 최신 주문 조회에서 close-report 를 무시한다. + $callbackLock = $this->acquireOrderCallbackLock('authCallback', $oid); - if (! $this->requestMatchesOrderBuyer($request, $order)) { - return ResponseHelper::error('messages.failed', 403, [ - 'message' => ['Order buyer verification failed.'], - ]); - } + $order = $this->orderService->findByOrderNumber($oid); + if (! $order) { + return ResponseHelper::error('messages.failed', 404, [ + 'message' => ['Order not found.'], + ]); + } - if ($price !== $this->expectedPaymentPrice($order)) { - return ResponseHelper::error('messages.failed', 422, [ - 'message' => ['Payment amount does not match the order amount.'], - ]); - } + if (! $order->order_status->isBeforePayment()) { + return ResponseHelper::success('messages.success', [ + 'status' => 'ignored', + 'reason' => 'order_not_payable', + ]); + } - if (! $order->order_status->isBeforePayment()) { + // 결제 성공 콜백(authorize)과 결제창 닫힘 보고가 경쟁할 때, 카드 주문은 승인 직전까지 + // order_status=PENDING_ORDER 라 위 가드를 통과한다. payment_status 가 이미 PAID 면 결제가 + // 성공한 것이므로 실패 처리하지 않는다(failPayment 가 옵션을 취소로 덮어 주문/옵션 상태가 + // 어긋나는 race 차단). failPayment 자체에도 동일 가드가 있으나, 여기서 차단해 불필요한 + // 마일리지 복원 시도·결제취소 이력 기록까지 미연에 방지한다. + if ($order->payment?->isPaid()) { + return ResponseHelper::success('messages.success', [ + 'status' => 'ignored', + 'reason' => 'payment_already_paid', + ]); + } + + if (strtoupper((string) $order->currency) !== 'KRW') { + return ResponseHelper::error('messages.failed', 422, [ + 'message' => ['Standard KG Inicis close report is only available for KRW orders.'], + ]); + } + + if (! $this->requestMatchesOrderBuyer($request, $order)) { + return ResponseHelper::error('messages.failed', 403, [ + 'message' => ['Order buyer verification failed.'], + ]); + } + + $expectedPrice = $this->resolveExpectedPaymentPriceOrNull($order, 'close_report', [ + 'received_amount' => $price, + ]); + if ($expectedPrice === null) { + return ResponseHelper::error('messages.failed', 422, [ + 'message' => ['Payment currency is not chargeable.'], + ]); + } + + if ($price !== $expectedPrice) { + return ResponseHelper::error('messages.failed', 422, [ + 'message' => ['Payment amount does not match the order amount.'], + ]); + } + + $selectedEasyPayMethod = $this->normalizeEasyPayMethod( + is_string($validated['payment_method'] ?? null) ? $validated['payment_method'] : null, + ); + + $failedOrder = $this->orderService->failPayment( + $order, + self::FAILURE_CODE, + self::FAILURE_MESSAGE, + ); + + $closeReason = trim((string) ($validated['reason'] ?? '')); + + $this->persistEasyPayCloseContext($failedOrder, $selectedEasyPayMethod); + + $this->orderService->recordPaymentCancellation( + $failedOrder, + self::FAILURE_CODE, + $closeReason !== '' ? $closeReason : self::FAILURE_MESSAGE, + ); + + return ResponseHelper::success('messages.success', [ + 'status' => 'recorded', + ]); + } catch (LockTimeoutException) { return ResponseHelper::success('messages.success', [ 'status' => 'ignored', - 'reason' => 'order_not_payable', + 'reason' => 'callback_in_progress', ]); + } finally { + $this->releaseOrderCallbackLock($callbackLock); } - - // 결제 성공 콜백(authorize)과 결제창 닫힘 보고가 경쟁할 때, 카드 주문은 승인 직전까지 - // order_status=PENDING_ORDER 라 위 가드를 통과한다. payment_status 가 이미 PAID 면 결제가 - // 성공한 것이므로 실패 처리하지 않는다(failPayment 가 옵션을 취소로 덮어 주문/옵션 상태가 - // 어긋나는 race 차단). failPayment 자체에도 동일 가드가 있으나, 여기서 차단해 불필요한 - // 마일리지 복원 시도·결제취소 이력 기록까지 미연에 방지한다. - if ($order->payment?->isPaid()) { - return ResponseHelper::success('messages.success', [ - 'status' => 'ignored', - 'reason' => 'payment_already_paid', - ]); - } - - $selectedEasyPayMethod = $this->normalizeEasyPayMethod( - is_string($validated['payment_method'] ?? null) ? $validated['payment_method'] : null, - ); - - $failedOrder = $this->orderService->failPayment( - $order, - self::FAILURE_CODE, - self::FAILURE_MESSAGE, - ); - - $closeReason = trim((string) ($validated['reason'] ?? '')); - - $this->persistEasyPayCloseContext($failedOrder, $selectedEasyPayMethod); - - $this->orderService->recordPaymentCancellation( - $failedOrder, - self::FAILURE_CODE, - $closeReason !== '' ? $closeReason : self::FAILURE_MESSAGE, - ); - - return ResponseHelper::success('messages.success', [ - 'status' => 'recorded', - ]); } private function rateLimitKey(PaymentCloseReportRequest $request, string $oid): string diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentSignatureController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentSignatureController.php index c730183f..e86b22f3 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentSignatureController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentSignatureController.php @@ -108,7 +108,17 @@ class PaymentSignatureController ], 403); } - if ($price !== $this->expectedPaymentPrice($order)) { + $expectedPrice = $this->resolveExpectedPaymentPriceOrNull($order, 'signature', [ + 'received_amount' => $price, + ]); + if ($expectedPrice === null) { + return response()->json([ + 'success' => false, + 'message' => 'Payment currency is not chargeable.', + ], 422); + } + + if ($price !== $expectedPrice) { return response()->json([ 'success' => false, 'message' => 'Payment amount does not match the order amount.', diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Listeners/AdjustEcommercePaymentMethodsLayoutListener.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Listeners/AdjustEcommercePaymentMethodsLayoutListener.php index 50f72e2a..0be14dd7 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Listeners/AdjustEcommercePaymentMethodsLayoutListener.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Listeners/AdjustEcommercePaymentMethodsLayoutListener.php @@ -13,10 +13,21 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa { private const TARGET_LAYOUT = 'admin_ecommerce_settings'; + private const TEST_MODE_DATA_SOURCE_ID = 'kginicis_test_mode_status'; + + private const TEST_MODE_NOTICE_ID = 'payment_test_mode_order_settings_notice'; + + private const TEST_MODE_NOTICE_ROW_ID = 'kginicis_test_mode_order_settings_notice'; + + private const TEST_MODE_CONDITION = 'kginicis_test_mode_status.data?.is_test_mode === true'; + private const CORE_NO_PG_METHODS = "['point','deposit','free','dbank']"; private const KGINICIS_NO_PG_METHODS = "['point','deposit','free','dbank','kginicis_samsung_pay','kginicis_naverpay','kginicis_lpay','kginicis_kakaopay','kginicis_japan_paypay','kginicis_japan_cvs']"; + /** + * @return array> + */ public static function getSubscribedHooks(): array { return [ @@ -37,6 +48,7 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa /** * @param array $layout + * @param int $templateId * @return array */ public function markEasyPayMethodsAsPgNotRequired(array $layout, int $templateId): array @@ -45,7 +57,9 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa return $layout; } - return $this->replaceNoPgMethodExpressions($layout); + $layout = $this->replaceNoPgMethodExpressions($layout); + + return $this->ensureTestModeWarning($layout); } /** @@ -71,4 +85,222 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa return $node; } + + /** + * @param array $layout + * @return array + */ + private function ensureTestModeWarning(array $layout): array + { + $layout = $this->ensureTestModeDataSource($layout); + + return $this->insertTestModeNotice($layout); + } + + /** + * @param array $layout + * @return array + */ + private function ensureTestModeDataSource(array $layout): array + { + $dataSources = is_array($layout['data_sources'] ?? null) ? $layout['data_sources'] : []; + + foreach ($dataSources as $source) { + if (is_array($source) && ($source['id'] ?? null) === self::TEST_MODE_DATA_SOURCE_ID) { + $layout['data_sources'] = $dataSources; + + return $layout; + } + } + + $dataSources[] = [ + 'id' => self::TEST_MODE_DATA_SOURCE_ID, + 'label_key' => '$t:sirsoft-pay_kginicis.editor.data_source.kginicis_test_mode_status', + 'type' => 'api', + 'endpoint' => '/api/plugins/sirsoft-pay_kginicis/admin/settings/test-mode-status', + 'method' => 'GET', + 'auto_fetch' => true, + 'auth_required' => true, + ]; + + $layout['data_sources'] = $dataSources; + + return $layout; + } + + /** + * @param array $node + * @return array + */ + private function insertTestModeNotice(array $node): array + { + if (($node['id'] ?? null) === 'tab_content_order_settings' && is_array($node['children'] ?? null)) { + $noticeIndex = $this->findChildIndexById($node['children'], self::TEST_MODE_NOTICE_ID); + + if ($noticeIndex === null) { + $insertAt = $this->findChildIndexById($node['children'], 'payment_methods_card') ?? count($node['children']); + array_splice($node['children'], $insertAt, 0, [$this->testModeNoticeContainerNode()]); + $noticeIndex = $insertAt; + } + + if (is_array($node['children'][$noticeIndex] ?? null)) { + $notice = $node['children'][$noticeIndex]; + $notice['if'] = $this->mergeTestModeCondition((string) ($notice['if'] ?? '')); + $node['children'][$noticeIndex] = $this->appendTestModeNoticeRow($notice); + } + + return $node; + } + + foreach ($node as $key => $value) { + if (is_array($value)) { + $node[$key] = $this->insertTestModeNotice($value); + } + } + + return $node; + } + + /** + * @param array $children + */ + private function findChildIndexById(array $children, string $id): ?int + { + foreach ($children as $index => $child) { + if (is_array($child) && ($child['id'] ?? null) === $id) { + return $index; + } + } + + return null; + } + + /** + * @param array $children + */ + private function hasChildWithId(array $children, string $id): bool + { + return $this->findChildIndexById($children, $id) !== null; + } + + private function mergeTestModeCondition(string $condition): string + { + if (str_contains($condition, self::TEST_MODE_CONDITION)) { + return $condition !== '' ? $condition : '{{' . self::TEST_MODE_CONDITION . '}}'; + } + + $inner = trim($condition); + if (str_starts_with($inner, '{{') && str_ends_with($inner, '}}')) { + $inner = trim(substr($inner, 2, -2)); + } + + if ($inner === '') { + return '{{' . self::TEST_MODE_CONDITION . '}}'; + } + + return '{{' . $inner . ' || ' . self::TEST_MODE_CONDITION . '}}'; + } + + /** + * @param array $notice + * @return array + */ + private function appendTestModeNoticeRow(array $notice): array + { + $children = is_array($notice['children'] ?? null) ? $notice['children'] : []; + + if (! $this->hasChildWithId($children, self::TEST_MODE_NOTICE_ROW_ID)) { + $children[] = $this->testModeNoticeRowNode(); + } + + $notice['children'] = $children; + + return $notice; + } + + /** + * @return array + */ + private function testModeNoticeContainerNode(): array + { + return [ + 'id' => self::TEST_MODE_NOTICE_ID, + 'type' => 'basic', + 'name' => 'Div', + 'if' => '{{' . self::TEST_MODE_CONDITION . '}}', + 'props' => [ + 'className' => 'mb-4 space-y-3 rounded-lg border border-orange-200 bg-orange-50 p-4 dark:border-orange-700 dark:bg-orange-900/20', + ], + 'children' => [ + $this->testModeNoticeRowNode(), + ], + ]; + } + + /** + * @return array + */ + private function testModeNoticeRowNode(): array + { + return [ + 'id' => self::TEST_MODE_NOTICE_ROW_ID, + 'type' => 'basic', + 'name' => 'Div', + 'if' => '{{' . self::TEST_MODE_CONDITION . '}}', + 'props' => [ + 'className' => 'flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between', + ], + 'children' => [ + [ + 'type' => 'basic', + 'name' => 'Div', + 'props' => [ + 'className' => 'min-w-0', + ], + 'children' => [ + [ + 'type' => 'basic', + 'name' => 'Div', + 'props' => [ + 'className' => 'text-sm font-medium text-orange-950 dark:text-orange-50', + ], + 'text' => '$t:sirsoft-pay_kginicis.admin.test_mode_settings_warning_plugin', + ], + [ + 'type' => 'basic', + 'name' => 'P', + 'props' => [ + 'className' => 'mt-0.5 text-sm leading-6 text-orange-800 dark:text-orange-200', + ], + 'text' => '$t:sirsoft-pay_kginicis.admin.test_mode_settings_warning_body', + ], + ], + ], + [ + 'type' => 'basic', + 'name' => 'Button', + 'props' => [ + 'type' => 'button', + 'className' => 'btn btn-outline btn-sm w-full flex-shrink-0 border-orange-300 text-orange-900 hover:bg-orange-100 sm:w-auto dark:border-orange-600 dark:text-orange-100 dark:hover:bg-orange-900/40', + ], + 'actions' => [ + [ + 'type' => 'click', + 'handler' => 'navigate', + 'params' => [ + 'path' => '/admin/plugins/sirsoft-pay_kginicis/settings', + ], + ], + ], + 'children' => [ + [ + 'type' => 'basic', + 'name' => 'Span', + 'text' => '$t:sirsoft-pay_kginicis.admin.test_mode_settings_warning_action', + ], + ], + ], + ], + ]; + } } diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Listeners/RegisterEasyPayMethodsListener.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Listeners/RegisterEasyPayMethodsListener.php index 6ecde099..4cab5fd9 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Listeners/RegisterEasyPayMethodsListener.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Listeners/RegisterEasyPayMethodsListener.php @@ -65,30 +65,24 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface { $kgInicisMethods = []; - $this->appendIfEnabled($kgInicisMethods, 'kginicis_samsung_pay', - $this->buildEntry( - id: 'kginicis_samsung_pay', - nameKey: 'sirsoft-pay_kginicis::payment_methods.samsung_pay.name', - descriptionKey: 'sirsoft-pay_kginicis::payment_methods.samsung_pay.description', - icon: 'mobile-screen-button', - ) + $kgInicisMethods[] = $this->buildDomesticEntry( + id: 'kginicis_samsung_pay', + nameKey: 'sirsoft-pay_kginicis::payment_methods.samsung_pay.name', + descriptionKey: 'sirsoft-pay_kginicis::payment_methods.samsung_pay.description', + icon: 'mobile-screen-button', ); - $this->appendIfEnabled($kgInicisMethods, 'kginicis_naverpay', $this->buildNaverPayEntry()); - $this->appendIfEnabled($kgInicisMethods, 'kginicis_lpay', - $this->buildEntry( - id: 'kginicis_lpay', - nameKey: 'sirsoft-pay_kginicis::payment_methods.lpay.name', - descriptionKey: 'sirsoft-pay_kginicis::payment_methods.lpay.description', - icon: 'mobile-screen-button', - ) + $kgInicisMethods[] = $this->buildNaverPayEntry(); + $kgInicisMethods[] = $this->buildDomesticEntry( + id: 'kginicis_lpay', + nameKey: 'sirsoft-pay_kginicis::payment_methods.lpay.name', + descriptionKey: 'sirsoft-pay_kginicis::payment_methods.lpay.description', + icon: 'mobile-screen-button', ); - $this->appendIfEnabled($kgInicisMethods, 'kginicis_kakaopay', - $this->buildEntry( - id: 'kginicis_kakaopay', - nameKey: 'sirsoft-pay_kginicis::payment_methods.kakaopay.name', - descriptionKey: 'sirsoft-pay_kginicis::payment_methods.kakaopay.description', - icon: 'mobile-screen-button', - ) + $kgInicisMethods[] = $this->buildDomesticEntry( + id: 'kginicis_kakaopay', + nameKey: 'sirsoft-pay_kginicis::payment_methods.kakaopay.name', + descriptionKey: 'sirsoft-pay_kginicis::payment_methods.kakaopay.description', + icon: 'mobile-screen-button', ); if ($this->settingEnabled('japan_enabled')) { @@ -126,6 +120,21 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface ); } + /** + * 국내 간편결제 entry 는 항상 등록해 이커머스 주문설정에서 켜고 끌 수 있게 한다. + * 기존 플러그인 설정값은 신규 저장값이 없을 때 사용할 기본 활성 상태로만 반영한다. + */ + private function buildDomesticEntry(string $id, string $nameKey, string $descriptionKey, string $icon): array + { + return $this->buildEntry( + id: $id, + nameKey: $nameKey, + descriptionKey: $descriptionKey, + icon: $icon, + isActive: $this->domesticMethodEnabled($id), + ); + } + /** * 브랜드 버튼 옵션은 표시 메타데이터만 바꾸고 결제수단 ID는 유지한다. */ @@ -138,6 +147,7 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface ? 'sirsoft-pay_kginicis::payment_methods.naverpay_brand.description' : 'sirsoft-pay_kginicis::payment_methods.naverpay.description', icon: 'wallet', + isActive: $this->domesticMethodEnabled('kginicis_naverpay'), ); } @@ -152,21 +162,16 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface } /** - * 플러그인 설정에서 활성화된 국내 간편결제 수단만 목록에 추가합니다. + * 기존 플러그인 설정의 국내 간편결제 활성값을 읽습니다. * - * @param array> $methods * @param string $id - * @param array $entry - * @return void + * @return bool */ - private function appendIfEnabled(array &$methods, string $id, array $entry): void + private function domesticMethodEnabled(string $id): bool { $settingKey = self::DOMESTIC_EASY_PAY_SETTINGS[$id] ?? null; - if ($settingKey === null || ! $this->settingEnabled($settingKey)) { - return; - } - $methods[] = $entry; + return $settingKey !== null && $this->settingEnabled($settingKey); } /** @@ -187,7 +192,7 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface /** * 결제수단 entry 1건 빌더 — EcommerceSettingsService 의 getBuiltinPaymentMethods 와 동일 형식. */ - private function buildEntry(string $id, string $nameKey, string $descriptionKey, string $icon): array + private function buildEntry(string $id, string $nameKey, string $descriptionKey, string $icon, bool $isActive = false): array { return [ 'id' => $id, @@ -205,7 +210,7 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface // PG 선택 불필요 — orderResponseInterceptor 가 prefix 'kginicis_' 를 인식해 // 기본 PG 사 설정과 무관하게 KG 이니시스 결제 흐름으로 강제. 'pg_provider' => null, - 'is_active' => false, + 'is_active' => $isActive, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete', ], diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Listeners/ValidateCbtSettingsListener.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Listeners/ValidateCbtSettingsListener.php index c2314a25..67b49b40 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Listeners/ValidateCbtSettingsListener.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Listeners/ValidateCbtSettingsListener.php @@ -36,12 +36,15 @@ class ValidateCbtSettingsListener implements HookListenerInterface 'japan_contact_opening_hours' => '10:00-18:00', ]; + private const SAMPLE_VALUE_ERROR_FIELD = 'japan_contract_info'; + public static function getSubscribedHooks(): array { return [ 'core.plugin_settings.before_save' => [ 'method' => 'validateBeforeSave', 'priority' => 10, + 'sync' => true, ], ]; } @@ -73,10 +76,10 @@ class ValidateCbtSettingsListener implements HookListenerInterface } } - foreach (self::SAMPLE_VALUES as $field => $sampleValue) { - if (trim((string) ($settings[$field] ?? '')) === $sampleValue) { - $errors[$field][] = __('sirsoft-pay_kginicis::messages.settings_validation.replace_sample_value'); - } + if ($this->hasSampleDisplayValue($settings)) { + $errors[self::SAMPLE_VALUE_ERROR_FIELD][] = __( + 'sirsoft-pay_kginicis::messages.settings_validation.replace_sample_value' + ); } } @@ -103,4 +106,15 @@ class ValidateCbtSettingsListener implements HookListenerInterface { return trim((string) $value) === ''; } + + private function hasSampleDisplayValue(array $settings): bool + { + foreach (self::SAMPLE_VALUES as $field => $sampleValue) { + if (trim((string) ($settings[$field] ?? '')) === $sampleValue) { + return true; + } + } + + return false; + } } diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Services/CbtCvsOperationsService.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Services/CbtCvsOperationsService.php index 2bfb7934..81e07227 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Services/CbtCvsOperationsService.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Services/CbtCvsOperationsService.php @@ -10,16 +10,19 @@ use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum; use Modules\Sirsoft\Ecommerce\Models\Order; use Modules\Sirsoft\Ecommerce\Models\OrderPayment; -use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService; use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService; use Plugins\Sirsoft\PayKginicis\Concerns\PreventsReplayCallback; use Plugins\Sirsoft\PayKginicis\Concerns\SanitizesPgResponse; +use Plugins\Sirsoft\PayKginicis\Concerns\SerializesPaymentCallbacks; +use Plugins\Sirsoft\PayKginicis\Concerns\ValidatesCbtOrderContext; use Plugins\Sirsoft\PayKginicis\Repositories\CbtCvsOperationsRepositoryInterface; class CbtCvsOperationsService { use PreventsReplayCallback; use SanitizesPgResponse; + use SerializesPaymentCallbacks; + use ValidatesCbtOrderContext; private const HISTORY_LIMIT = 20; @@ -64,6 +67,7 @@ class CbtCvsOperationsService $amount = (int) ($payload['amount'] ?? 0); $payment = null; $existingMeta = []; + $callbackLock = null; if ($tid === '' || $orderId === '' || $mid === '' || $amount <= 0) { Log::warning('KG Inicis CBT CVS: invalid notify payload', [ @@ -85,6 +89,8 @@ class CbtCvsOperationsService return $this->notifyResult('FAIL', 'failed', 'order_not_found'); } + $callbackLock = $this->acquireOrderCallbackLock('cbtCvsNotify', $orderId); + $payment = $this->repository->firstPaymentForOrder($order); if (! $payment) { Log::warning('KG Inicis CBT CVS: payment row not found', [ @@ -177,6 +183,16 @@ class CbtCvsOperationsService } $expectedAmount = $this->resolveExpectedCvsAmount($order, $existingMeta); + if ($expectedAmount === null) { + $this->storeNotifyHistory($payment, $existingMeta, $payload, 'failed', 'invalid_payment_currency', $source, $remoteIp); + Log::warning('KG Inicis CBT CVS: payment currency is not chargeable', [ + 'order_id' => $orderId, + 'tid' => $tid, + ]); + + return $this->notifyResult('FAIL', 'failed', 'invalid_payment_currency'); + } + if ($expectedAmount > 0 && $amount !== $expectedAmount) { $this->storeNotifyHistory($payment, $existingMeta, $payload, 'failed', 'amount_mismatch', $source, $remoteIp); Log::warning('KG Inicis CBT CVS: amount mismatch', [ @@ -251,6 +267,8 @@ class CbtCvsOperationsService ]); return $this->notifyResult('FAIL', 'failed', 'exception'); + } finally { + $this->releaseOrderCallbackLock($callbackLock); } } @@ -338,6 +356,11 @@ class CbtCvsOperationsService return $this->operationError('messages.cbt_cvs.not_waiting_deposit', 422); } + $expectedAmount = $this->resolveExpectedCvsAmount($order, $meta); + if ($expectedAmount === null) { + return $this->operationError('messages.cbt_cvs.simulate_failed', 422); + } + $now = now(); $payload = [ 'tid' => $payment->transaction_id ?: 'ADMIN_CVS_'.$order->order_number, @@ -353,7 +376,7 @@ class CbtCvsOperationsService 'confNo' => (string) ($meta['cvs_conf_no'] ?? ''), 'receiptNo' => (string) ($meta['cvs_receipt_no'] ?? ''), 'paymentTerm' => (string) ($meta['cvs_payment_term'] ?? ''), - 'amount' => (string) $this->resolveExpectedCvsAmount($order, $meta), + 'amount' => (string) $expectedAmount, 'currencyCd' => 'JPY', ]; @@ -599,7 +622,7 @@ class CbtCvsOperationsService return $paymentTermAt instanceof CarbonImmutable && $paymentTermAt->isPast(); } - private function resolveExpectedCvsAmount(Order $order, array $paymentMeta): int + private function resolveExpectedCvsAmount(Order $order, array $paymentMeta): ?int { $metaAmount = (int) ($paymentMeta['cvs_amount'] ?? 0); if ($metaAmount > 0) { @@ -608,7 +631,9 @@ class CbtCvsOperationsService // 결제 청구액 SSoT = 결제 통화(order_currency) 환산액 (buildPgPaymentData 와 동일 기준). // base≠결제 통화에서 CVS 통보 금액(결제 통화)과 단위가 일치하도록 한다. - return app(CurrencyConversionService::class)->resolveOrderPaymentChargeAmount($order); + return $this->resolveExpectedPaymentPriceOrNull($order, 'cbt_cvs_notify', [ + 'pay_method' => 'CVS', + ]); } private function parseCbtDateTime(?string $value): ?CarbonImmutable diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Support/ApiDoc/ApiDocSampleService.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Support/ApiDoc/ApiDocSampleService.php new file mode 100644 index 00000000..660637fe --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Support/ApiDoc/ApiDocSampleService.php @@ -0,0 +1,222 @@ +}> 도메인 => 대표 레코드 정보 + */ + public function seed(): array + { + // ecommerce 모델이 없으면(모듈 비활성) 실측 폴백 없이 빈 맵 반환. + if (! class_exists(Order::class)) { + return []; + } + + $actor = $this->sampleActor(); + if ($actor === null) { + return []; + } + + $order = $this->seedOrder($actor); + $this->seedPayment($order); + $this->seedShippingAddress($order); + + // orders/{orderNumber} 계열 GET(cbt-cvs·cbt-reconciliation·escrow-delivery· + // transaction-status)과 user/orders/{orderNumber}/receipt 를 실측하기 위한 + // path 파라미터 맵. route-model binding 이 없는 문자열 param 을 실제 주문번호로 + // 정확 일치 치환한다. + $orderParams = [ + 'orderNumber' => (string) $order->order_number, + ]; + + $entry = [ + 'model' => Order::class, + 'key' => 'order_number', + 'value' => (string) $order->order_number, + 'path_params' => $orderParams, + ]; + + // 라우트 도메인 그룹(orders.md · vbank.md · payment.md · cbt.md · transaction.md)이 + // 공유하는 {orderNumber} 를 모두 실측 가능하도록 각 그룹에 동일 맵을 노출한다. + return [ + 'orders' => $entry, + 'vbank' => $entry, + 'transaction' => $entry, + 'cbt' => $entry, + 'payment' => $entry, + ]; + } + + /** + * 실측 사용자 소유의 완전 샘플 kginicis 주문을 멱등 생성합니다. + * + * @param User $actor 실측 사용자 + * @return Order 대표 주문 레코드 + */ + private function seedOrder(User $actor): Order + { + $order = Order::query()->where('order_number', self::SAMPLE_ORDER_NUMBER)->first(); + if ($order) { + return $order; + } + + return Order::factory()->forUser($actor)->create([ + 'order_number' => self::SAMPLE_ORDER_NUMBER, + ]); + } + + /** + * 완전 샘플 kginicis 결제(에스크로 + CBT 편의점 메타)를 멱등 생성합니다. + * + * 채우는 특화 필드: + * - pg_provider = 'kginicis' (모든 조회 컨트롤러의 WHERE 조건) + * - is_escrow = true (escrow-delivery formData 의 findEscrowPayment 조건) + * - payment_meta.is_cbt = true + pay_method = 'CVS' (cbt-cvs summary 의 is_cbt_cvs 판정) + * - transaction_id (모든 조회 컨트롤러가 요구하는 non-null TID) + * + * @param Order $order 대표 주문 + * @return OrderPayment 대표 결제 레코드 + */ + private function seedPayment(Order $order): OrderPayment + { + $payment = OrderPayment::query() + ->where('order_id', $order->id) + ->where('pg_provider', 'kginicis') + ->first(); + + if ($payment) { + return $payment; + } + + return OrderPayment::factory()->forOrder($order)->create([ + 'pg_provider' => 'kginicis', + 'transaction_id' => self::SAMPLE_TID, + 'merchant_order_id' => 'MO-'.self::SAMPLE_ORDER_NUMBER, + 'payment_status' => PaymentStatusEnum::PAID, + 'payment_method' => PaymentMethodEnum::VBANK, + 'is_escrow' => true, + 'buyer_name' => 'API 문서 샘플 구매자', + 'buyer_email' => 'apidoc-sample-user@example.com', + 'buyer_phone' => '010-0000-0001', + 'payment_name' => 'API 문서 샘플 CBT 결제', + 'payment_meta' => [ + 'mid' => 'apidocmid1', + 'is_test_mode' => true, + 'is_cbt' => true, + 'cbt_type' => 'cvs', + 'pay_method' => 'CVS', + 'cbt_mid' => 'apidocmid1', + 'cbt_sid' => 'apidocsid1', + 'cvs_convenience' => 'seven_eleven', + 'cvs_conf_no' => '1234567890', + 'cvs_receipt_no' => '0987654321', + 'cvs_amount' => 5000, + 'cvs_status' => 'waiting_deposit', + 'cvs_payment_term' => '20260710235959', + 'cvs_last_notify_at' => '', + 'cvs_notify_history' => [], + 'pg_raw_response' => [ + 'resultCode' => '0000', + 'resultMsg' => '정상처리', + 'tid' => self::SAMPLE_TID, + 'payMethod' => 'CVS', + 'currency' => 'JPY', + ], + ], + ]); + } + + /** + * escrow-delivery formData 의 배송지 prefill 을 실측하기 위한 배송지를 멱등 생성합니다. + * + * @param Order $order 대표 주문 + */ + private function seedShippingAddress(Order $order): void + { + $exists = DB::table('ecommerce_order_addresses') + ->where('order_id', $order->id) + ->where('address_type', 'shipping') + ->exists(); + + if ($exists) { + return; + } + + DB::table('ecommerce_order_addresses')->insert([ + 'order_id' => $order->id, + 'address_type' => 'shipping', + 'orderer_name' => 'API 문서 샘플 주문자', + 'orderer_phone' => '010-0000-0001', + 'orderer_email' => 'apidoc-sample-user@example.com', + 'recipient_name' => 'API 문서 샘플 수령인', + 'recipient_phone' => '010-0000-0002', + 'zipcode' => '06134', + 'address' => '서울특별시 강남구 테헤란로 001', + 'address_detail' => 'API 문서 샘플 빌딩 1층', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + /** + * 샘플 주문 소유자로 쓸 사용자를 반환합니다. + * + * 코어 완전 샘플 사용자(admin role, docgen 실측 주체)를 우선하고, + * 없으면 첫 사용자로 폴백합니다. + * + * @return User|null 샘플 사용자 (없으면 null) + */ + private function sampleActor(): ?User + { + return User::query()->where('email', 'apidoc-sample-user@example.com')->first() + ?? User::query()->orderBy('id')->first(); + } +} diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/routes/api.php b/plugins/_bundled/sirsoft-pay_kginicis/src/routes/api.php index 5b3a9b6a..8b9cdaaa 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/routes/api.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/routes/api.php @@ -9,6 +9,7 @@ use Plugins\Sirsoft\PayKginicis\Controllers\AdminCbtTestProductController; use Plugins\Sirsoft\PayKginicis\Controllers\AdminEscrowDeliveryController; use Plugins\Sirsoft\PayKginicis\Controllers\AdminEscrowDenyConfirmController; use Plugins\Sirsoft\PayKginicis\Controllers\AdminOrderListController; +use Plugins\Sirsoft\PayKginicis\Controllers\AdminSettingsStatusController; use Plugins\Sirsoft\PayKginicis\Controllers\AdminTransactionController; use Plugins\Sirsoft\PayKginicis\Controllers\CbtCheckoutTokenController; use Plugins\Sirsoft\PayKginicis\Controllers\CbtHashDataController; @@ -66,6 +67,10 @@ Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->g })->middleware('permission:admin,sirsoft-ecommerce.settings.read') ->name('vbank.notify.url'); + Route::get('/settings/test-mode-status', [AdminSettingsStatusController::class, 'testMode']) + ->middleware('permission:admin,sirsoft-ecommerce.settings.read') + ->name('settings.test-mode-status'); + // 어드민 주문 목록의 테스트 결제 배지 표시용 맵 (auto_fetch) Route::get('/orders/test-mode-map', [AdminOrderListController::class, 'testModeMap']) ->middleware('permission:admin,sirsoft-ecommerce.orders.read') diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Concerns/SerializesPaymentCallbacksTest.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Concerns/SerializesPaymentCallbacksTest.php new file mode 100644 index 00000000..c2a8f52a --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Concerns/SerializesPaymentCallbacksTest.php @@ -0,0 +1,74 @@ +traitSubject = new class { + use SerializesPaymentCallbacks { + acquireOrderCallbackLock as public; + releaseOrderCallbackLock as public; + callbackLockKey as public; + } + + protected function callbackLockWaitSeconds(): int + { + return 0; + } + + protected function callbackLockTtlSeconds(): int + { + return 5; + } + }; + } + + public function test_same_context_and_order_are_serialized(): void + { + $orderNumber = 'ORD-KG-LOCK-' . random_int(10000, 99999); + $firstLock = $this->traitSubject->acquireOrderCallbackLock('authCallback', $orderNumber); + + try { + $this->expectException(LockTimeoutException::class); + + $this->traitSubject->acquireOrderCallbackLock('authCallback', $orderNumber); + } finally { + $this->traitSubject->releaseOrderCallbackLock($firstLock); + } + } + + public function test_released_lock_allows_next_callback(): void + { + $orderNumber = 'ORD-KG-LOCK-' . random_int(10000, 99999); + $firstLock = $this->traitSubject->acquireOrderCallbackLock('authCallback', $orderNumber); + $this->traitSubject->releaseOrderCallbackLock($firstLock); + + $secondLock = $this->traitSubject->acquireOrderCallbackLock('authCallback', $orderNumber); + + $this->assertNotNull($secondLock); + $this->traitSubject->releaseOrderCallbackLock($secondLock); + } + + public function test_lock_key_is_scoped_by_context_and_order(): void + { + $this->assertNotSame( + $this->traitSubject->callbackLockKey('authCallback', 'ORD-KG-LOCK-A'), + $this->traitSubject->callbackLockKey('vbankNotify', 'ORD-KG-LOCK-A'), + ); + + $this->assertNotSame( + $this->traitSubject->callbackLockKey('authCallback', 'ORD-KG-LOCK-A'), + $this->traitSubject->callbackLockKey('authCallback', 'ORD-KG-LOCK-B'), + ); + } +} diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/AdminCbtTestProductControllerTest.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/AdminCbtTestProductControllerTest.php index d437e394..22659e74 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/AdminCbtTestProductControllerTest.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/AdminCbtTestProductControllerTest.php @@ -69,7 +69,7 @@ class AdminCbtTestProductControllerTest extends PluginTestCase 'order_number' => 'JP-ORDER-ADMIN-CBT-001', 'order_status' => OrderStatusEnum::PAYMENT_COMPLETE, 'currency' => 'JPY', - 'currency_snapshot' => ['JPY' => 1.0], + 'currency_snapshot' => self::jpyCurrencySnapshot(), 'subtotal_amount' => 100, 'total_amount' => 100, 'total_due_amount' => 100, @@ -86,7 +86,7 @@ class AdminCbtTestProductControllerTest extends PluginTestCase 'paid_amount_local' => 100, 'paid_amount_base' => 100, 'currency' => 'JPY', - 'currency_snapshot' => ['JPY' => 1.0], + 'currency_snapshot' => self::jpyCurrencySnapshot(), 'payment_meta' => [ 'is_cbt' => true, 'cbt_type' => 'JPPG', diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/AdminPluginSettingsValidationTest.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/AdminPluginSettingsValidationTest.php new file mode 100644 index 00000000..95b4ac7e --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/AdminPluginSettingsValidationTest.php @@ -0,0 +1,79 @@ +setLocale('ko'); + + $admin = $this->createAdminUser(['core.plugins.update']); + $originalBeforeSaveHooks = HookManager::getHooks()['core.plugin_settings.before_save'] ?? []; + + HookManager::addAction( + 'core.plugin_settings.before_save', + fn (string $identifier, array $settings) => app(ValidateCbtSettingsListener::class) + ->validateBeforeSave($identifier, $settings), + 1 + ); + + try { + $response = $this->actingAs($admin) + ->withHeaders(['Accept-Language' => 'ko']) + ->putJson('/api/admin/plugins/sirsoft-pay_kginicis/settings', [ + 'is_test_mode' => false, + 'japan_enabled' => true, + 'test_japan_sign_key' => 'test-cbt-hash-key', + 'live_japan_mid' => '', + 'live_japan_sign_key' => '', + 'japan_merchant_name' => 'サンプルストア', + 'japan_merchant_name_kana' => 'サンプルストア', + 'japan_merchant_name_alphabet' => 'Sample Store', + 'japan_merchant_name_short' => 'サンプル', + 'japan_contact_name' => 'サポート窓口', + 'japan_contact_email' => 'support@example.com', + 'japan_contact_phone' => '0120-123-456', + 'japan_contact_opening_hours' => '10:00-18:00', + ]); + + $response->assertStatus(422) + ->assertJsonPath( + 'errors.live_japan_mid.0', + '운영 모드에서 일본 결제(CBT)를 사용하려면 라이브 일본 MID가 필요합니다.' + ) + ->assertJsonPath( + 'errors.japan_contract_info.0', + '운영 모드에서는 일본 가맹점 표시 정보의 샘플값을 실제 계약 정보로 변경하세요.' + ); + + $errors = $response->json('errors'); + $this->assertCount(1, $errors['japan_contract_info'] ?? []); + $this->assertArrayNotHasKey('japan_merchant_name', $errors); + $this->assertArrayNotHasKey('japan_merchant_name_alphabet', $errors); + $this->assertArrayNotHasKey('japan_contact_email', $errors); + } finally { + $this->restoreBeforeSaveHooks($originalBeforeSaveHooks); + } + } + + /** + * @param array> $hooks + */ + private function restoreBeforeSaveHooks(array $hooks): void + { + HookManager::clearAction('core.plugin_settings.before_save'); + + foreach ($hooks as $priority => $callbacks) { + foreach ($callbacks as $callback) { + HookManager::addAction('core.plugin_settings.before_save', $callback, (int) $priority); + } + } + } +} diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/AdminSettingsStatusControllerTest.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/AdminSettingsStatusControllerTest.php new file mode 100644 index 00000000..edc48e32 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/AdminSettingsStatusControllerTest.php @@ -0,0 +1,33 @@ +createMock(PluginSettingsService::class); + $settings->expects($this->once()) + ->method('get') + ->with('sirsoft-pay_kginicis', 'is_test_mode', true) + ->willReturn(true); + $this->app->instance(PluginSettingsService::class, $settings); + + $admin = $this->createAdminUser(['sirsoft-ecommerce.settings.read']); + + $response = $this->actingAs($admin) + ->getJson('/api/plugins/sirsoft-pay_kginicis/admin/settings/test-mode-status'); + + $response->assertOk() + ->assertJsonPath('data.is_test_mode', true); + + $data = $response->json('data'); + $this->assertArrayNotHasKey('test_mid', $data); + $this->assertArrayNotHasKey('live_mid', $data); + } +} diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/CbtPaymentControllerTest.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/CbtPaymentControllerTest.php index a3158143..cb0d6a8e 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/CbtPaymentControllerTest.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/CbtPaymentControllerTest.php @@ -1284,6 +1284,7 @@ class CbtPaymentControllerTest extends PluginTestCase $order->order_status = OrderStatusEnum::PENDING_ORDER; $order->currency = 'JPY'; $order->total_due_amount = $amount; + $order->currency_snapshot = self::jpyCurrencySnapshot(); return $order; } @@ -1294,7 +1295,7 @@ class CbtPaymentControllerTest extends PluginTestCase 'order_number' => $orderNumber, 'order_status' => OrderStatusEnum::PENDING_ORDER, 'currency' => 'JPY', - 'currency_snapshot' => ['JPY' => 1.0], + 'currency_snapshot' => self::jpyCurrencySnapshot(), 'subtotal_amount' => $amount, 'total_discount_amount' => 0, 'total_coupon_discount_amount' => 0, @@ -1328,7 +1329,7 @@ class CbtPaymentControllerTest extends PluginTestCase 'paid_amount_local' => 0, 'paid_amount_base' => 0, 'currency' => 'JPY', - 'currency_snapshot' => ['JPY' => 1.0], + 'currency_snapshot' => self::jpyCurrencySnapshot(), 'paid_at' => null, ]); @@ -1369,6 +1370,7 @@ class CbtPaymentControllerTest extends PluginTestCase 'order_number' => $orderNumber, 'order_status' => OrderStatusEnum::PENDING_ORDER->value, 'currency' => 'JPY', + 'currency_snapshot' => json_encode(self::jpyCurrencySnapshot(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'subtotal_amount' => $amount, 'total_amount' => $amount, 'total_tax_amount' => $amount, diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/PaymentCallbackControllerTest.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/PaymentCallbackControllerTest.php index 44994807..5b4475dc 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/PaymentCallbackControllerTest.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/PaymentCallbackControllerTest.php @@ -48,6 +48,8 @@ class PaymentCallbackControllerTest extends PluginTestCase 'user_id' => $user->id, 'order_number' => 'ORD-TEST-' . random_int(10000, 99999), 'order_status' => OrderStatusEnum::PENDING_ORDER, + 'currency' => 'KRW', + 'currency_snapshot' => self::krwCurrencySnapshot(), 'subtotal_amount' => $totalAmount, 'total_discount_amount' => 0, 'total_coupon_discount_amount' => 0, diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/PaymentCloseReportControllerTest.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/PaymentCloseReportControllerTest.php index 85dee8e1..cac842f7 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/PaymentCloseReportControllerTest.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/PaymentCloseReportControllerTest.php @@ -77,6 +77,30 @@ class PaymentCloseReportControllerTest extends PluginTestCase ->assertJsonPath('errors.message.0', 'Payment amount does not match the order amount.'); } + public function test_close_report_rejects_unchargeable_payment_currency_without_server_error(): void + { + $order = $this->makeOrder('ORD-CLOSE-CURRENCY-001', 10000); + $order->currency_snapshot = self::unchargeableKrwCurrencySnapshot(); + + $orderService = Mockery::mock(OrderProcessingService::class); + $orderService->shouldReceive('findByOrderNumber') + ->once() + ->with('ORD-CLOSE-CURRENCY-001') + ->andReturn($order); + $orderService->shouldNotReceive('failPayment'); + $orderService->shouldNotReceive('recordPaymentCancellation'); + + $this->app->instance(OrderProcessingService::class, $orderService); + + $response = $this->postJson('/api/plugins/sirsoft-pay_kginicis/payment/close-report', [ + 'oid' => 'ORD-CLOSE-CURRENCY-001', + 'price' => 10000, + ]); + + $response->assertStatus(422) + ->assertJsonPath('errors.message.0', 'Payment currency is not chargeable.'); + } + public function test_close_report_rejects_non_krw_order_without_failing_payment(): void { $order = $this->makeOrder('ORD-CLOSE-USD-001', 10000, 'USD'); @@ -157,8 +181,10 @@ class PaymentCloseReportControllerTest extends PluginTestCase public function test_close_report_ignores_order_when_payment_already_paid(): void { - // race 재현: 승인 콜백이 payment 를 먼저 PAID 로 갱신했으나 order_status 는 아직 PENDING_ORDER. - $order = $this->makeOrder('ORD-CLOSE-PAID-001', 10000); + // race 재현: 승인 콜백이 payment 를 PAID 로 갱신했고 결제 예정액은 이미 0원이 됐으나, + // order_status 관계가 아직 PENDING_ORDER 로 관측되는 순간에도 close-report 는 금액 불일치가 + // 아니라 결제 성공으로 판단해 무시해야 한다. + $order = $this->makeOrder('ORD-CLOSE-PAID-001', 0); $order->setRelation('shippingAddress', new OrderAddress([ 'address_type' => 'shipping', 'orderer_email' => 'buyer@example.com', @@ -199,6 +225,7 @@ class PaymentCloseReportControllerTest extends PluginTestCase 'order_number' => 'ORD-CLOSE-EASYPAY-001', 'order_status' => OrderStatusEnum::PENDING_ORDER, 'currency' => 'KRW', + 'currency_snapshot' => self::krwCurrencySnapshot(), 'subtotal_amount' => 10000, 'total_amount' => 10000, 'total_due_amount' => 10000, @@ -246,6 +273,7 @@ class PaymentCloseReportControllerTest extends PluginTestCase $order->order_status = OrderStatusEnum::PENDING_ORDER; $order->currency = $currency; $order->total_due_amount = $amount; + $order->currency_snapshot = self::currencySnapshotFor($currency); return $order; } diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/SignatureControllerTest.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/SignatureControllerTest.php index 38cfe35a..1cfd532b 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/SignatureControllerTest.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/SignatureControllerTest.php @@ -79,6 +79,33 @@ class SignatureControllerTest extends PluginTestCase ->assertJsonPath('message', 'Payment amount does not match the order amount.'); } + public function test_pc_signature_rejects_unchargeable_payment_currency_without_server_error(): void + { + $order = $this->makePendingOrder('ORD-SIGN-CURRENCY-001', 10000, 'KRW', self::unchargeableKrwCurrencySnapshot()); + + $orderService = Mockery::mock(OrderProcessingService::class); + $orderService->shouldReceive('findByOrderNumber') + ->with('ORD-SIGN-CURRENCY-001') + ->andReturn($order); + + $apiService = Mockery::mock(KgInicisApiService::class); + $apiService->shouldNotReceive('hasStandardPaymentCredentials'); + $apiService->shouldNotReceive('generateSignature'); + $apiService->shouldNotReceive('generateVerification'); + + $this->app->instance(OrderProcessingService::class, $orderService); + $this->app->instance(KgInicisApiService::class, $apiService); + + $response = $this->postJson('/api/plugins/sirsoft-pay_kginicis/payment/signature', [ + 'oid' => 'ORD-SIGN-CURRENCY-001', + 'price' => 10000, + 'timestamp' => $this->freshEpochMs(), + ]); + + $response->assertStatus(422) + ->assertJsonPath('message', 'Payment currency is not chargeable.'); + } + public function test_pc_signature_rejects_non_krw_order(): void { $order = $this->makePendingOrder('ORD-SIGN-USD-001', 10000, 'USD'); @@ -216,6 +243,32 @@ class SignatureControllerTest extends PluginTestCase ->assertJsonPath('message', 'KG Inicis mobile payment credentials are not configured.'); } + public function test_mobile_signature_rejects_unchargeable_payment_currency_without_server_error(): void + { + $order = $this->makePendingOrder('ORD-SIGN-CURRENCY-002', 10000, 'KRW', self::unchargeableKrwCurrencySnapshot()); + + $orderService = Mockery::mock(OrderProcessingService::class); + $orderService->shouldReceive('findByOrderNumber') + ->with('ORD-SIGN-CURRENCY-002') + ->andReturn($order); + + $apiService = Mockery::mock(KgInicisApiService::class); + $apiService->shouldNotReceive('hasMobilePaymentCredentials'); + $apiService->shouldNotReceive('generateMobileChkfake'); + + $this->app->instance(OrderProcessingService::class, $orderService); + $this->app->instance(KgInicisApiService::class, $apiService); + + $response = $this->postJson('/api/plugins/sirsoft-pay_kginicis/payment/mobile/signature', [ + 'oid' => 'ORD-SIGN-CURRENCY-002', + 'price' => 10000, + 'timestamp' => $this->freshEpochMs(), + ]); + + $response->assertStatus(422) + ->assertJsonPath('message', 'Payment currency is not chargeable.'); + } + public function test_pc_signature_expects_converted_price_for_non_base_order_currency(): void { // base=USD, 결제통화=KRW. base $6 → KRW 7058 환산이 검증 기준(price)이어야 한다. @@ -266,7 +319,9 @@ class SignatureControllerTest extends PluginTestCase $order->order_status = OrderStatusEnum::PENDING_ORDER; $order->currency = $currency; $order->total_due_amount = $amount; - $order->currency_snapshot = $currencySnapshot; + $order->currency_snapshot = $currencySnapshot !== [] + ? $currencySnapshot + : self::currencySnapshotFor($currency); return $order; } diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/VbankNotifyReplayTest.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/VbankNotifyReplayTest.php index b39e4b60..bdefa05e 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/VbankNotifyReplayTest.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/VbankNotifyReplayTest.php @@ -39,6 +39,8 @@ class VbankNotifyReplayTest extends PluginTestCase 'user_id' => $user->id, 'order_number' => 'ORD-VR-' . random_int(10000, 99999), 'order_status' => OrderStatusEnum::PAYMENT_COMPLETE, + 'currency' => 'KRW', + 'currency_snapshot' => self::krwCurrencySnapshot(), 'subtotal_amount' => $amount, 'total_discount_amount' => 0, 'total_coupon_discount_amount' => 0, @@ -139,6 +141,8 @@ class VbankNotifyReplayTest extends PluginTestCase 'user_id' => $user->id, 'order_number' => 'ORD-FA-' . random_int(10000, 99999), 'order_status' => OrderStatusEnum::PENDING_ORDER, + 'currency' => 'KRW', + 'currency_snapshot' => self::krwCurrencySnapshot(), 'subtotal_amount' => 30000, 'total_discount_amount' => 0, 'total_coupon_discount_amount' => 0, @@ -203,6 +207,8 @@ class VbankNotifyReplayTest extends PluginTestCase 'user_id' => User::factory()->create()->id, 'order_number' => 'ORD-DUP-' . random_int(10000, 99999), 'order_status' => OrderStatusEnum::PAYMENT_COMPLETE, + 'currency' => 'KRW', + 'currency_snapshot' => self::krwCurrencySnapshot(), 'subtotal_amount' => 20000, 'total_discount_amount' => 0, 'total_coupon_discount_amount' => 0, diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/PluginTestCase.php index 19b23cc2..c47c2b2b 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/tests/PluginTestCase.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/PluginTestCase.php @@ -62,6 +62,74 @@ abstract class PluginTestCase extends TestCase $this->registerEcommerceModuleInManager(); } + protected static function krwCurrencySnapshot(): array + { + return [ + 'base_currency' => 'KRW', + 'order_currency' => 'KRW', + 'base_unit' => 1, + 'exchange_rates' => [ + 'KRW' => [ + 'rate' => 1, + 'rounding_unit' => '1', + 'rounding_method' => 'round', + 'decimal_places' => 0, + 'base_unit' => 1, + ], + ], + ]; + } + + protected static function jpyCurrencySnapshot(): array + { + return [ + 'base_currency' => 'JPY', + 'order_currency' => 'JPY', + 'base_unit' => 1, + 'exchange_rates' => [ + 'JPY' => [ + 'rate' => 1, + 'rounding_unit' => '1', + 'rounding_method' => 'round', + 'decimal_places' => 0, + 'base_unit' => 1, + ], + ], + ]; + } + + protected static function unchargeableKrwCurrencySnapshot(): array + { + return [ + 'base_currency' => 'USD', + 'order_currency' => 'KRW', + 'base_unit' => 1, + 'exchange_rates' => [ + 'USD' => [ + 'rate' => 1, + 'rounding_unit' => '0.01', + 'rounding_method' => 'round', + 'decimal_places' => 2, + 'base_unit' => 1, + ], + 'KRW' => [ + 'rate' => 0, + 'rounding_unit' => '1', + 'rounding_method' => 'round', + 'decimal_places' => 0, + 'base_unit' => 1, + ], + ], + ]; + } + + protected static function currencySnapshotFor(string $currency): array + { + return strtoupper($currency) === 'JPY' + ? self::jpyCurrencySnapshot() + : self::krwCurrencySnapshot(); + } + /** * 테스트 환경에서 sirsoft-ecommerce 모듈을 ModuleManager 에 명시 등록. */ diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/Unit/Listeners/AdjustEcommercePaymentMethodsLayoutListenerTest.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/Unit/Listeners/AdjustEcommercePaymentMethodsLayoutListenerTest.php index 05ef775c..e295d388 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/tests/Unit/Listeners/AdjustEcommercePaymentMethodsLayoutListenerTest.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/Unit/Listeners/AdjustEcommercePaymentMethodsLayoutListenerTest.php @@ -58,6 +58,45 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase $this->assertStringNotContainsString("['point','deposit','free','dbank'].includes", $json); } + public function test_adds_test_mode_warning_to_order_settings_tab(): void + { + $listener = new AdjustEcommercePaymentMethodsLayoutListener(); + + $layout = [ + 'layout_name' => 'admin_ecommerce_settings', + 'children' => [ + [ + 'id' => 'tab_content_order_settings', + 'children' => [ + ['id' => 'default_pg_card'], + ['id' => 'payment_methods_card'], + ], + ], + ], + ]; + + $result = $listener->markEasyPayMethodsAsPgNotRequired($layout, 1); + $result = $listener->markEasyPayMethodsAsPgNotRequired($result, 1); + $json = json_encode($result, JSON_UNESCAPED_SLASHES); + + $this->assertIsString($json); + $this->assertStringContainsString('kginicis_test_mode_status', $json); + $this->assertStringContainsString('/api/plugins/sirsoft-pay_kginicis/admin/settings/test-mode-status', $json); + $this->assertStringContainsString('payment_test_mode_order_settings_notice', $json); + $this->assertStringContainsString('kginicis_test_mode_order_settings_notice', $json); + $this->assertStringNotContainsString('sirsoft-pay_kginicis.admin.test_mode_settings_summary_title', $json); + $this->assertStringNotContainsString('sirsoft-pay_kginicis.admin.test_mode_settings_summary_body', $json); + $this->assertStringContainsString('sirsoft-pay_kginicis.admin.test_mode_settings_warning_plugin', $json); + $this->assertStringContainsString('sirsoft-pay_kginicis.admin.test_mode_settings_warning_body', $json); + $this->assertStringContainsString('/admin/plugins/sirsoft-pay_kginicis/settings', $json); + $this->assertSame(1, substr_count($json, 'payment_test_mode_order_settings_notice')); + $this->assertSame(1, substr_count($json, 'kginicis_test_mode_order_settings_notice')); + $this->assertLessThan( + strpos($json, 'payment_methods_card'), + strpos($json, 'payment_test_mode_order_settings_notice') + ); + } + public function test_leaves_other_layouts_unchanged(): void { $listener = new AdjustEcommercePaymentMethodsLayoutListener(); diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/Unit/Listeners/RegisterEasyPayMethodsListenerTest.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/Unit/Listeners/RegisterEasyPayMethodsListenerTest.php index d2503e6f..34750531 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/tests/Unit/Listeners/RegisterEasyPayMethodsListenerTest.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/Unit/Listeners/RegisterEasyPayMethodsListenerTest.php @@ -13,10 +13,10 @@ class RegisterEasyPayMethodsListenerTest extends PluginTestCase public function test_injects_easy_pay_methods_between_phone_and_point(): void { $this->mockSettings([ - 'easy_pay_samsung_pay' => true, - 'easy_pay_naverpay' => true, - 'easy_pay_lpay' => true, - 'easy_pay_kakaopay' => true, + 'easy_pay_samsung_pay' => false, + 'easy_pay_naverpay' => false, + 'easy_pay_lpay' => false, + 'easy_pay_kakaopay' => false, 'japan_enabled' => true, ]); @@ -46,10 +46,10 @@ class RegisterEasyPayMethodsListenerTest extends PluginTestCase public function test_easy_pay_methods_do_not_require_pg_provider_in_saved_defaults(): void { $this->mockSettings([ - 'easy_pay_samsung_pay' => true, - 'easy_pay_naverpay' => true, - 'easy_pay_lpay' => true, - 'easy_pay_kakaopay' => true, + 'easy_pay_samsung_pay' => false, + 'easy_pay_naverpay' => false, + 'easy_pay_lpay' => false, + 'easy_pay_kakaopay' => false, 'japan_enabled' => true, ]); @@ -118,11 +118,11 @@ class RegisterEasyPayMethodsListenerTest extends PluginTestCase $this->assertSame('wallet', $naverpay['icon'] ?? null); } - public function test_disabled_easy_pay_settings_are_not_injected(): void + public function test_disabled_easy_pay_settings_are_injected_as_inactive_for_admin_toggle(): void { $this->mockSettings([ 'easy_pay_samsung_pay' => false, - 'easy_pay_naverpay' => true, + 'easy_pay_naverpay' => false, 'easy_pay_lpay' => false, 'easy_pay_kakaopay' => false, 'japan_enabled' => false, @@ -137,9 +137,41 @@ class RegisterEasyPayMethodsListenerTest extends PluginTestCase $this->assertSame([ 'phone', + 'kginicis_samsung_pay', 'kginicis_naverpay', + 'kginicis_lpay', + 'kginicis_kakaopay', 'point', ], array_column($methods, 'id')); + + foreach ($methods as $method) { + if (str_starts_with((string) ($method['id'] ?? ''), 'kginicis_')) { + $this->assertFalse($method['defaults']['is_active'] ?? true); + } + } + } + + public function test_legacy_easy_pay_settings_are_used_as_default_active_state(): void + { + $this->mockSettings([ + 'easy_pay_samsung_pay' => true, + 'easy_pay_naverpay' => false, + 'easy_pay_lpay' => true, + 'easy_pay_kakaopay' => false, + 'japan_enabled' => false, + ]); + + $listener = new RegisterEasyPayMethodsListener(); + + $methods = collect($listener->injectEasyPayMethods([ + ['id' => 'phone'], + ['id' => 'point'], + ]))->keyBy('id'); + + $this->assertTrue($methods->get('kginicis_samsung_pay')['defaults']['is_active'] ?? false); + $this->assertFalse($methods->get('kginicis_naverpay')['defaults']['is_active'] ?? true); + $this->assertTrue($methods->get('kginicis_lpay')['defaults']['is_active'] ?? false); + $this->assertFalse($methods->get('kginicis_kakaopay')['defaults']['is_active'] ?? true); } private function mockSettings(array $settings): void diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/Unit/Listeners/ValidateCbtSettingsListenerTest.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/Unit/Listeners/ValidateCbtSettingsListenerTest.php index a596ec12..69fd4d4f 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/tests/Unit/Listeners/ValidateCbtSettingsListenerTest.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/Unit/Listeners/ValidateCbtSettingsListenerTest.php @@ -13,6 +13,11 @@ class ValidateCbtSettingsListenerTest extends PluginTestCase public function test_plugin_registers_cbt_settings_validation_listener(): void { $this->assertContains(ValidateCbtSettingsListener::class, (new Plugin())->getHookListeners()); + + $hooks = ValidateCbtSettingsListener::getSubscribedHooks(); + $this->assertSame('validateBeforeSave', $hooks['core.plugin_settings.before_save']['method'] ?? null); + $this->assertSame(10, $hooks['core.plugin_settings.before_save']['priority'] ?? null); + $this->assertTrue($hooks['core.plugin_settings.before_save']['sync'] ?? false); } public function test_plugin_schema_includes_jpy_payment_method_restriction_option(): void @@ -120,16 +125,27 @@ class ValidateCbtSettingsListenerTest extends PluginTestCase public function test_live_japan_payment_rejects_sample_jppg_display_values(): void { $this->mockCurrentSettings([]); + app()->setLocale('ko'); $listener = new ValidateCbtSettingsListener(); try { $listener->validateBeforeSave('sirsoft-pay_kginicis', $this->validLiveSettings([ 'japan_merchant_name' => 'サンプルストア', + 'japan_merchant_name_alphabet' => 'Sample Store', + 'japan_contact_email' => 'support@example.com', ])); $this->fail('ValidationException was not thrown.'); } catch (ValidationException $e) { - $this->assertArrayHasKey('japan_merchant_name', $e->errors()); + $errors = $e->errors(); + + $this->assertArrayHasKey('japan_contract_info', $errors); + $this->assertArrayNotHasKey('japan_merchant_name', $errors); + $this->assertArrayNotHasKey('japan_merchant_name_alphabet', $errors); + $this->assertArrayNotHasKey('japan_contact_email', $errors); + $this->assertSame([ + '운영 모드에서는 일본 가맹점 표시 정보의 샘플값을 실제 계약 정보로 변경하세요.', + ], $errors['japan_contract_info']); } } diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/Unit/Support/ApiDocSampleServiceTest.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/Unit/Support/ApiDocSampleServiceTest.php new file mode 100644 index 00000000..d3c6e823 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/Unit/Support/ApiDocSampleServiceTest.php @@ -0,0 +1,144 @@ +create([ + 'email' => 'apidoc-sample-user@example.com', + 'name' => 'API 문서 샘플 사용자', + ]); + } + + /** + * 시더가 계약을 구현한다. + */ + public function test_seeder_implements_contract(): void + { + $this->assertInstanceOf(ApiDocSampleSeeder::class, new ApiDocSampleService); + } + + /** + * 시더가 kginicis 결제 도메인 대표 샘플을 멱등 생성한다. + */ + public function test_seed_creates_kginicis_escrow_cbt_order(): void + { + (new ApiDocSampleService)->seed(); + + $order = Order::query()->where('order_number', self::SAMPLE_ORDER_NUMBER)->first(); + $this->assertNotNull($order, '샘플 kginicis 주문이 생성되어야 한다'); + + $payment = OrderPayment::query() + ->where('order_id', $order->id) + ->where('pg_provider', 'kginicis') + ->first(); + $this->assertNotNull($payment, 'kginicis 결제 레코드가 생성되어야 한다'); + + // escrow-delivery formData 의 findEscrowPayment 조건 + $this->assertTrue((bool) $payment->is_escrow, '에스크로 결제여야 한다'); + $this->assertNotEmpty($payment->transaction_id, 'TID 가 존재해야 한다'); + + // cbt-cvs summary 의 isCbtCvsMeta 판정 조건 (is_cbt=true + pay_method=CVS) + $meta = is_array($payment->payment_meta) ? $payment->payment_meta : []; + $this->assertTrue(($meta['is_cbt'] ?? false) === true, 'CBT 결제 메타여야 한다'); + $this->assertSame('CVS', strtoupper((string) ($meta['pay_method'] ?? '')), 'CVS 결제수단이어야 한다'); + + // escrow-delivery 배송지 prefill 실측용 shipping 주소 + $hasShipping = DB::table('ecommerce_order_addresses') + ->where('order_id', $order->id) + ->where('address_type', 'shipping') + ->exists(); + $this->assertTrue($hasShipping, '배송지가 생성되어야 한다'); + } + + /** + * 시더가 orders/{orderNumber} 실측용 path_params 맵을 도메인 그룹별로 반환한다. + */ + public function test_seed_returns_path_params_map_for_order_domains(): void + { + $map = (new ApiDocSampleService)->seed(); + + // 5개 라우트 도메인 그룹이 {orderNumber} 를 공유 + foreach (['orders', 'vbank', 'transaction', 'cbt', 'payment'] as $domain) { + $this->assertArrayHasKey($domain, $map, "{$domain} 도메인 맵이 있어야 한다"); + $this->assertSame('order_number', $map[$domain]['key']); + $this->assertSame(self::SAMPLE_ORDER_NUMBER, $map[$domain]['value']); + $this->assertArrayHasKey('path_params', $map[$domain]); + $this->assertSame( + self::SAMPLE_ORDER_NUMBER, + $map[$domain]['path_params']['orderNumber'], + "{$domain} 의 orderNumber 치환값이 실제 주문번호여야 한다", + ); + } + } + + /** + * 시더는 멱등하다 — 재실행 시 중복 레코드를 만들지 않는다. + */ + public function test_seed_is_idempotent(): void + { + $service = new ApiDocSampleService; + $service->seed(); + $service->seed(); + + $orderCount = Order::query()->where('order_number', self::SAMPLE_ORDER_NUMBER)->count(); + $this->assertSame(1, $orderCount, '주문은 1건만 존재해야 한다'); + + $order = Order::query()->where('order_number', self::SAMPLE_ORDER_NUMBER)->first(); + $paymentCount = OrderPayment::query() + ->where('order_id', $order->id) + ->where('pg_provider', 'kginicis') + ->count(); + $this->assertSame(1, $paymentCount, 'kginicis 결제는 1건만 존재해야 한다'); + + $addressCount = DB::table('ecommerce_order_addresses') + ->where('order_id', $order->id) + ->where('address_type', 'shipping') + ->count(); + $this->assertSame(1, $addressCount, '배송지는 1건만 존재해야 한다'); + } + + /** + * 샘플 사용자가 없으면(코어 샘플/기존 사용자 부재) 빈 맵을 반환한다. + */ + public function test_seed_returns_empty_map_when_no_user(): void + { + // RefreshDatabase + 시더로 생성된 사용자를 모두 제거해 폴백 경로를 검증. + User::query()->delete(); + + $map = (new ApiDocSampleService)->seed(); + + $this->assertSame([], $map, '사용자가 없으면 빈 맵이어야 한다'); + } +} diff --git a/plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md b/plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md new file mode 100644 index 00000000..41baa7dd --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md @@ -0,0 +1,14 @@ +# API 레퍼런스 문서 목차 + +> **소유**: 플러그인 `sirsoft-verification_kginicis` · **생성**: `php artisan api:docgen` (실측 기반). +> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. + + +- **문서 수**: 1 · **엔드포인트 수**: 1 + +| 문서 | 도메인 | 엔드포인트 | +| --- | --- | --- | +| [identity.md](identity.md) | `identity` | 1 | + + + diff --git a/plugins/_bundled/sirsoft-verification_kginicis/docs/api/identity.md b/plugins/_bundled/sirsoft-verification_kginicis/docs/api/identity.md new file mode 100644 index 00000000..f8e430e0 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_kginicis/docs/api/identity.md @@ -0,0 +1,107 @@ +# Identity API 레퍼런스 + +> **소유**: plugin `sirsoft-verification_kginicis` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Identity 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 +3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/plugins/sirsoft-verification_kginicis/me/identity/inicis + +- **라우트명**: `api.plugins.sirsoft-verification_kginicis.me.identity.inicis.show` +- **컨트롤러**: `\Plugins\Sirsoft\VerificationKginicis\Http\Controllers\MyInicisIdentityShowController@show` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-verification_kginicis/me/identity/inicis HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | + + + +**설명** + +로그인한 사용자가 마이페이지 본인인증 카드에서 자신의 KG이니시스 본인확인 정보(마스킹)를 조회하는 엔드포인트다. `AuthBaseController` 를 상속하므로 `auth:sanctum` 인증이 필수이며, 라우트에 `check.user_status:active` 미들웨어가 걸려 **활성 상태 사용자만** 호출할 수 있다(정지/탈퇴 대기 사용자는 차단). 조회 전용이며 부수 효과는 없다. + +- **요청 파라미터 없음**: 대상은 항상 인증된 본인(`Auth::id()`)이며, 다른 사용자의 정보를 조회할 수 없다. 컨트롤러가 `InicisIdentityCardService::findForUser(Auth::id())` 로 본인 record 만 조회한다. +- **미인증 사용자(record 없음)**: 아직 본인인증을 하지 않은 사용자는 `data: null` 로 응답한다(코어 표준 null 처리 — HTTP 200, `success: true`). 프론트는 `data` 가 `null` 인 경우 「본인인증 하기」 유도 UI 를, 값이 있으면 본인확인 카드를 렌더한다. +- **PII 마스킹**: 평문 개인정보는 서버에서 마스킹 후 노출한다(PIPC 사용자 본인 PII 열람권 충족). `di`/`ci` 등 식별값은 **일체 노출하지 않는다**. 이름은 첫 글자만(`홍**`), 생년월일은 연도만(`1990-**-**`), 휴대폰은 앞 3자리+뒤 4자리(`010-****-5678`)로 마스킹된다. +- **응답 필드** (`data` 내부, record 존재 시 — `InicisIdentityResource`): + + | 필드 | 타입 | 예시값 | 용도/설명 | + | --- | --- | --- | --- | + | method | string | `"KG이니시스 본인확인"` | 본인확인 수단 표시 라벨(고정 문자열). | + | verified_at | string\|null | `"2026-07-01 14:22:10"` | 최종 본인확인 시각(`Y-m-d H:i:s`). 재인증이 있었으면 `re_verified_at`, 없으면 최초 `verified_at`. | + | name_masked | string | `"홍**"` | 마스킹된 실명(첫 글자 + 나머지 `*`). | + | birthday_masked | string | `"1990-**-**"` | 마스킹된 생년월일(연도만 노출). | + | phone_masked | string | `"010-****-5678"` | 마스킹된 휴대폰 번호(앞 3 + 뒤 4자리). | + | is_adult | boolean | `true` | 성인 여부(연령 게이팅 판정에 사용). | + | is_foreigner | boolean | `false` | 외국인 여부. | + + 이 외에 `BaseApiResource` 공통 메타 `is_owner`(항상 본인이므로 `true`) + `abilities` 가 함께 붙는다. +- **응답 예시 주의**: 실측 시 샘플 사용자에게 본인인증 record 가 없어 `data: null`(record 없음) 응답만 관측된다. 아래 두 응답 예시 중 "record 존재 시" 는 `InicisIdentityResource` 구조 기준 정적 작성이다. 실제 record 가 있는 사용자로 실측하면 마스킹된 본인확인 정보가 채워진다. + +**응답 예시** (record 존재 시 — 정적, `InicisIdentityResource` 구조 기준) + +```json +{ + "success": true, + "data": { + "method": "KG이니시스 본인확인", + "verified_at": "2026-07-01 14:22:10", + "name_masked": "홍**", + "birthday_masked": "1990-**-**", + "phone_masked": "010-****-5678", + "is_adult": true, + "is_foreigner": false, + "is_owner": true, + "abilities": {} + }, + "message": "성공적으로 처리되었습니다.", + "error": null +} +``` + +**응답 예시** (본인인증 이력 없음 — record 없음) + +```json +{ + "success": true, + "data": null, + "message": "성공적으로 처리되었습니다.", + "error": null +} +``` + + diff --git a/public/build/core/layout-editor.min.js b/public/build/core/layout-editor.min.js index 51b92918..898a81ed 100644 --- a/public/build/core/layout-editor.min.js +++ b/public/build/core/layout-editor.min.js @@ -1,14 +1,14 @@ -var Rz=Object.defineProperty;var eM=(ln,i,u)=>i in ln?Rz(ln,i,{enumerable:!0,configurable:!0,writable:!0,value:u}):ln[i]=u;var Ii=(ln,i,u)=>eM(ln,typeof i!="symbol"?i+"":i,u);(function(ln,i,u,Sn){"use strict";function Lt(){const e=typeof window<"u"?window.G7Core?.__runtime:void 0;if(!e)throw new Error("[layout-editor] window.G7Core.__runtime 이 없습니다. 코어 번들(template-engine.min.js)이 편집기 번들보다 먼저 로드되어야 합니다.");return e}const Ai=Lt();Ai.TranslationProvider;const tt=Ai.useTranslation;Ai.TranslationReactContext;function rt(){return typeof window>"u"?null:window.__g7Devtools??null}function wl(e,t){const n=rt();if(!n?.trackEditorState)return;const o={templateIdentifier:e.templateIdentifier,editMode:e.editMode,selectedRoutePath:e.selectedRoute?.path??null,selectedLayoutName:e.selectedRoute?.layoutName??null,routeTreeSize:e.routeTree.length,isRouteTreeCollapsed:e.isRouteTreeCollapsed,locale:e.locale,previewDevice:e.previewDevice,previewZoom:e.previewZoom,lastAction:t,timestamp:Date.now()};try{n.trackEditorState(o)}catch{}}function Bh(){const e=rt();if(e?.clearEditorStateData)try{e.clearEditorStateData()}catch{}}function Wh(e){const t=rt();if(t?.trackEditorSampleMatch)try{t.trackEditorSampleMatch(e)}catch{}}function jh(){const e=rt();if(e?.clearEditorSampleData)try{e.clearEditorSampleData()}catch{}}function Hh(e){const t=rt();if(t?.trackEditorHistoryEntry)try{t.trackEditorHistoryEntry(e)}catch{}}function Fh(){const e=rt();if(e?.clearEditorHistoryData)try{e.clearEditorHistoryData()}catch{}}function kl(e){const t=rt();if(t?.trackEditorSelection)try{t.trackEditorSelection(e)}catch{}}function Kh(){const e=rt();if(e?.clearEditorSelectionData)try{e.clearEditorSelectionData()}catch{}}function or(e){const t=rt();if(t?.trackEditorPropertyPatch)try{t.trackEditorPropertyPatch(e)}catch{}}function Cn(e){const t=rt();if(t?.trackEditorDnd)try{t.trackEditorDnd(e)}catch{}}function Vh(){const e=rt();if(e?.clearEditorDndData)try{e.clearEditorDndData()}catch{}}function st(e){const t=rt();if(t?.trackEditorDocument)try{t.trackEditorDocument(e)}catch{}}function Uh(){const e=rt();if(e?.clearEditorDocumentData)try{e.clearEditorDocumentData()}catch{}}function qh(e){const t=rt();if(t?.trackEditorSpecMerge)try{t.trackEditorSpecMerge(e)}catch{}}function Gh(){const e=rt();if(e?.clearEditorSpecMergeData)try{e.clearEditorSpecMergeData()}catch{}}function Ti(e){let t=5381;for(let n=0;n>>0;return t}function Et(e){const t=rt();if(t?.trackEditorI18n)try{t.trackEditorI18n(e)}catch{}}function Yh(){const e=rt();if(e?.clearEditorI18nData)try{e.clearEditorI18nData()}catch{}}function Sl(e){const t=rt();if(t?.trackPageState)try{t.trackPageState(e)}catch{}}function Xh(){const e=rt();if(e?.clearPageStateData)try{e.clearPageStateData()}catch{}}const Cl="g7le.routeTree.collapsed";function Jh(){if(typeof window>"u")return!1;try{return window.localStorage?.getItem(Cl)==="1"}catch{return!1}}function Zh(e){if(!(typeof window>"u"))try{window.localStorage?.setItem(Cl,e?"1":"0")}catch{}}function Qh(e,t){return{templateIdentifier:e,routeTree:[],routesError:null,selectedRoute:null,isRouteTreeCollapsed:Jh(),editMode:"route",returnRoute:null,returnEditMode:null,locale:t,previewDevice:"desktop",previewZoom:1,previewCustomWidth:1024,previewColorScheme:"light",history:{canUndo:!1,canRedo:!1},isPaletteOpen:!1,availableStates:[],activeStateId:null,layoutVersions:{},extensionVersions:{}}}function Rh(e,t){switch(t.type){case"SET_ROUTE_TREE":return{...e,routeTree:t.tree,routesError:null};case"SET_ROUTES_ERROR":return{...e,routesError:t.error};case"SELECT_ROUTE":return{...e,editMode:"route",selectedRoute:t.route,returnRoute:null,returnEditMode:null};case"TOGGLE_ROUTE_TREE":return{...e,isRouteTreeCollapsed:!e.isRouteTreeCollapsed};case"SET_LOCALE":return{...e,locale:t.locale};case"ENTER_BASE_EDIT":return{...e,editMode:"base",returnRoute:t.returnRoute!==void 0?t.returnRoute:e.selectedRoute,selectedRoute:{path:`__base__/${t.layoutName}`,layoutName:t.layoutName}};case"EXIT_BASE_EDIT":return{...e,editMode:"route",selectedRoute:e.returnRoute,returnRoute:null};case"ENTER_MODAL_EDIT":return{...e,editMode:"modal",returnRoute:t.returnRoute!==void 0?t.returnRoute:e.selectedRoute,selectedRoute:{path:`__modal__/${t.modalId}`,layoutName:t.hostLayout}};case"EXIT_MODAL_EDIT":return{...e,editMode:"route",selectedRoute:e.returnRoute,returnRoute:null};case"ENTER_EXTENSION_EDIT":return{...e,editMode:"extension",returnRoute:t.returnRoute!==void 0?t.returnRoute:e.selectedRoute,selectedRoute:{path:`__extension__/${t.extensionId}`,layoutName:t.extensionHost??null}};case"SET_RETURN_ROUTE":return e.editMode==="route"||e.returnRoute!==null?e:{...e,returnRoute:t.route};case"EXIT_EXTENSION_EDIT":return{...e,editMode:"route",selectedRoute:e.returnRoute,returnRoute:null};case"ENTER_ITERATION_ITEM_EDIT":return{...e,editMode:"iteration_item",returnRoute:t.returnRoute!==void 0?t.returnRoute:e.selectedRoute,returnEditMode:e.editMode,selectedRoute:{path:`__iteration__/${t.sourcePath}`,layoutName:t.hostLayout??e.selectedRoute?.layoutName??null}};case"EXIT_ITERATION_ITEM_EDIT":return{...e,editMode:e.returnEditMode??"route",selectedRoute:e.returnRoute,returnRoute:null,returnEditMode:null};case"SET_PREVIEW_DEVICE":return{...e,previewDevice:t.device};case"SET_PREVIEW_ZOOM":return{...e,previewZoom:t.zoom};case"SET_PREVIEW_CUSTOM_WIDTH":return{...e,previewCustomWidth:t.width};case"SET_PREVIEW_COLOR_SCHEME":return{...e,previewColorScheme:t.scheme};case"TOGGLE_PALETTE":return{...e,isPaletteOpen:!e.isPaletteOpen};case"SET_PALETTE_OPEN":return{...e,isPaletteOpen:t.open};case"SET_AVAILABLE_STATES":return{...e,availableStates:t.states,activeStateId:t.activeStateId};case"SET_ACTIVE_STATE":return{...e,activeStateId:t.activeStateId};case"SET_LAYOUT_VERSIONS":return{...e,layoutVersions:t.versions};case"SET_LAYOUT_VERSION":return{...e,layoutVersions:{...e.layoutVersions,[t.layoutName]:t.version}};case"SET_EXTENSION_VERSIONS":return{...e,extensionVersions:t.versions};case"SET_EXTENSION_VERSION":return{...e,extensionVersions:{...e.extensionVersions,[t.extensionId]:t.version}};default:return e}}function ey(e,t){const n=Rh(e,t);return((n.selectedRoute?.path??null)!==(e.selectedRoute?.path??null)||n.editMode!==e.editMode)&&(n.availableStates.length>0||n.activeStateId!==null)?{...n,availableStates:[],activeStateId:null}:n}const $l=u.createContext(null);function ty(e,t){const n=ey(e,t);return wl(n,t.type),n}function ny({templateIdentifier:e,initialLocale:t,children:n}){const[o,r]=u.useReducer(ty,void 0,()=>{const a=Qh(e,t);return wl(a,void 0),a});u.useEffect(()=>{Zh(o.isRouteTreeCollapsed)},[o.isRouteTreeCollapsed]);const s=u.useMemo(()=>({state:o,dispatch:r}),[o]);return u.createElement($l.Provider,{value:s},n)}function Je(){const e=u.useContext($l);if(!e)throw new Error("useLayoutEditor must be used within LayoutEditorProvider");return e}const El=u.createContext(null);function oy({value:e,children:t}){return u.createElement(El.Provider,{value:e},t)}function Ft(){return u.useContext(El)}const Di=u.createContext(null);let ry=1;function iy({children:e,t}){const[n,o]=u.useState([]),r=u.useRef([]),[s,a]=u.useState({}),l=u.useCallback(b=>{const x=b.id??`editor-modal-${ry++}`,k={id:x,content:b.content,ariaLabel:b.ariaLabel??"",closeOnBackdrop:b.closeOnBackdrop??!0,closeOnEscape:b.closeOnEscape??!0,width:b.width??"auto",height:b.height??"auto",maxHeightRatio:b.maxHeightRatio??.85,draggable:b.draggable??!1,avoidRect:b.avoidRect,onClose:b.onClose};return o(w=>{const S=[...w.filter(E=>E.id!==x),k];return r.current=S,S}),x},[]),d=u.useCallback(b=>{o(x=>{const k=x.find(_=>_.id===b),w=x.filter(_=>_.id!==b);return r.current=w,k?.onClose&&queueMicrotask(()=>k.onClose?.()),w})},[]),c=u.useCallback(()=>{const b=r.current[r.current.length-1];b&&d(b.id)},[d]),p=u.useCallback(()=>{o(b=>{for(let x=b.length-1;x>=0;x--){const k=b[x];k?.onClose&&queueMicrotask(()=>k.onClose?.())}return r.current=[],[]})},[]),f=u.useCallback((b,x)=>{a(k=>{const w=r.current.find(S=>S.id===b),_=x??w?.ariaLabel??"";return{...k,[b]:_}})},[]),y=u.useCallback(b=>{a(x=>{if(!(b in x))return x;const k={...x};return delete k[b],k})},[]),g=u.useCallback(b=>{y(b),d(b)},[y,d]),m=u.useCallback(()=>{a({}),p()},[p]),h=u.useMemo(()=>Object.keys(s),[s]),v=u.useMemo(()=>({open:l,close:g,closeTop:c,closeAll:m,stack:n,minimize:f,restore:y,minimizedStack:h}),[l,g,c,m,n,f,y,h]);return u.createElement(Di.Provider,{value:v},e,u.createElement(ay,{minimized:s,onRestore:y,t,key:"__min_bar"}))}function go(){const e=u.useContext(Di);if(!e)throw new Error("useEditorModal must be used within EditorModalProvider");return e}function sy(){const e=u.useContext(Di);if(!e)return null;const{stack:t,close:n,minimizedStack:o}=e,r=u.useMemo(()=>new Set(o),[o]);return u.useEffect(()=>{if(t.length===0)return;const s=a=>{if(a.key!=="Escape"&&a.key!=="Esc")return;const l=t.filter(c=>!r.has(c.id)),d=l[l.length-1];d?.closeOnEscape&&(a.preventDefault(),a.stopPropagation(),n(d.id))};return window.addEventListener("keydown",s,!0),()=>window.removeEventListener("keydown",s,!0)},[t,n,r]),t.length===0?null:i.jsx(i.Fragment,{children:t.map((s,a)=>i.jsx(dy,{entry:s,depth:a,hidden:r.has(s.id),onClose:()=>n(s.id)},s.id))})}function ay({minimized:e,onRestore:t,t:n}){const o=n??(s=>s),r=Object.keys(e);return r.length===0?null:i.jsx("div",{className:"g7le-minimized-bar","data-testid":"g7le-minimized-bar",style:{position:"fixed",left:16,bottom:16,display:"flex",gap:8,zIndex:9300},children:r.map(s=>i.jsxs("button",{type:"button","data-testid":`g7le-minimized-${s}`,onClick:()=>t(s),style:{display:"flex",alignItems:"center",gap:6,padding:"8px 12px",background:"#1e293b",color:"#fff",border:"none",borderRadius:8,boxShadow:"0 8px 24px rgba(15, 23, 42, 0.3)",cursor:"pointer",fontSize:13},children:[i.jsx("span",{"aria-hidden":"true",children:"🗗"}),i.jsx("span",{children:e[s]||o("layout_editor.modal.minimized_default")}),i.jsx("span",{style:{opacity:.7},children:o("layout_editor.modal.restore")})]},s))})}function ly(e,t,n){if(!e||typeof window>"u")return null;const o=window.innerWidth,r=window.innerHeight,s=16,a=e.left,l=o-(e.left+e.width),d=t+s;let c;if(l>=a&&l>=d)c=e.left+e.width+s;else if(a>=d)c=Math.max(s,e.left-s-t);else return null;c=Math.min(Math.max(s,c),Math.max(s,o-t-s));let p=e.top;return p=Math.min(Math.max(s,p),Math.max(s,r-n-s)),{left:c,top:p}}const Il="clamp(16px, 6vh, 56px)";function dy({entry:e,depth:t,hidden:n,onClose:o}){const r=9100+t*10,s=e.maxHeightRatio>0?`${e.maxHeightRatio*100}vh`:void 0,a=e.draggable?s:`calc(100vh - (${Il}) * 2)`,[l,d]=u.useState(null),c=u.useRef(null),p=u.useRef(null);u.useEffect(()=>{if(!e.draggable)return;const v=c.current;if(!v)return;const b=v.getBoundingClientRect(),x=ly(e.avoidRect,b.width,b.height);x&&d(x)},[e.id,e.draggable]);const f=u.useCallback(v=>{if(!e.draggable)return;const b=v.target;if(!b.closest("[data-modal-drag-handle]")||b.closest("button, a, input, textarea, select"))return;const k=c.current,w=k?.getBoundingClientRect(),_=l?.left??w?.left??0,S=l?.top??w?.top??0;p.current={startX:v.clientX,startY:v.clientY,baseLeft:_,baseTop:S};const E=N=>{const $=p.current;if(!$)return;const A=window.innerWidth,z=window.innerHeight,D=k?.offsetWidth??0,O=k?.offsetHeight??0;let M=$.baseLeft+(N.clientX-$.startX),B=$.baseTop+(N.clientY-$.startY);M=Math.min(Math.max(0,M),Math.max(0,A-D)),B=Math.min(Math.max(0,B),Math.max(0,z-O)),d({left:M,top:B})},C=()=>{p.current=null,window.removeEventListener("pointermove",E),window.removeEventListener("pointerup",C)};window.addEventListener("pointermove",E),window.addEventListener("pointerup",C)},[e.draggable,l]),y=v=>{e.closeOnBackdrop&&v.target===v.currentTarget&&o()},g=e.draggable&&l!==null,m=e.draggable?{position:"fixed",inset:0,background:"transparent",pointerEvents:"none",zIndex:r,...n?{display:"none"}:{}}:{position:"fixed",inset:0,background:"rgba(15, 23, 42, 0.45)",display:n?"none":"flex",alignItems:"flex-start",justifyContent:"center",paddingTop:Il,boxSizing:"border-box",zIndex:r},h={background:"#fff",borderRadius:10,boxShadow:"0 20px 60px rgba(15, 23, 42, 0.25)",width:e.width,height:e.height,maxHeight:a,maxWidth:"92vw",overflow:"hidden",display:"flex",flexDirection:"column",...e.draggable?{position:"absolute",pointerEvents:"auto",left:g?l.left:"50%",top:g?l.top:"50%",transform:g?void 0:"translate(-50%, -50%)"}:{}};return i.jsx("div",{className:"g7le-modal-backdrop",role:"presentation","data-testid":`g7le-modal-backdrop-${e.id}`,"data-draggable":e.draggable?"true":"false",onClick:y,style:m,children:i.jsx("div",{ref:c,className:"g7le-modal",role:"dialog","aria-modal":"true","aria-label":e.ariaLabel||void 0,"data-testid":`g7le-modal-${e.id}`,"data-positioned":g?"true":"false",onClick:v=>v.stopPropagation(),onPointerDown:e.draggable?f:void 0,style:h,children:e.content})})}function wt({baseStyle:e,hoverStyle:t,disabled:n,style:o,children:r,...s}){const[a,l]=u.useState(!1),d={outline:"none",...e,...o,...a&&!n?t:{}};return i.jsx("button",{type:"button",disabled:n,onMouseEnter:()=>l(!0),onMouseLeave:()=>l(!1),style:d,...s,children:r})}function cy({templateIdentifier:e,templateName:t,templateVersion:n,templateList:o,onSwitch:r}){const{t:s}=tt(),[a,l]=u.useState(!1),[d,c]=u.useState(!1),p=u.useRef(null);u.useEffect(()=>{if(!a)return;const v=x=>{p.current&&!p.current.contains(x.target)&&l(!1)},b=x=>{x.key==="Escape"&&l(!1)};return document.addEventListener("mousedown",v),document.addEventListener("keydown",b),()=>{document.removeEventListener("mousedown",v),document.removeEventListener("keydown",b)}},[a]);const f=o??[],y=f.some(v=>v.identifier!==e),g=!!r&&y,m=t||e,h=v=>{if(v===e){l(!1);return}l(!1),r?.(v)};return i.jsxs("div",{ref:p,className:"g7le-toolbar__template-switcher",style:{position:"relative",display:"inline-flex"},children:[i.jsxs("button",{type:"button",className:"g7le-toolbar__template","data-testid":"g7le-toolbar-template-label","data-can-switch":g?"true":"false","aria-haspopup":g?"listbox":void 0,"aria-expanded":g?a:void 0,disabled:!g,onClick:()=>g&&l(v=>!v),onMouseEnter:()=>c(!0),onMouseLeave:()=>c(!1),title:g?s("layout_editor.chrome.toolbar.switch_template"):m,style:{display:"inline-flex",alignItems:"center",gap:6,padding:"4px 8px",fontSize:13,fontWeight:600,color:"#0f172a",letterSpacing:.1,borderRadius:6,borderWidth:1,borderStyle:"solid",borderColor:g&&(d||a)?"#cbd5e1":"transparent",backgroundColor:g&&(d||a)?"#f1f5f9":"transparent",cursor:g?"pointer":"default",outline:"none",transition:"background-color 120ms, border-color 120ms"},children:[i.jsx("span",{className:"g7le-toolbar__template-name","data-testid":"g7le-toolbar-template-name",children:m}),n&&i.jsxs("span",{className:"g7le-toolbar__template-version","data-testid":"g7le-toolbar-template-version",style:{fontSize:11,fontWeight:500,color:"#64748b",padding:"1px 6px",borderRadius:999,background:"#f1f5f9",border:"1px solid #e2e8f0"},children:["v",n]}),g&&i.jsx("span",{className:"g7le-toolbar__template-caret","aria-hidden":"true",style:{fontSize:9,color:"#94a3b8",marginLeft:1},children:"▼"})]}),g&&a&&i.jsx("div",{className:"g7le-toolbar__template-menu","data-testid":"g7le-toolbar-template-menu",role:"listbox",style:{position:"absolute",top:"calc(100% + 4px)",left:0,minWidth:240,maxWidth:360,maxHeight:360,overflowY:"auto",background:"#ffffff",border:"1px solid #e2e8f0",borderRadius:8,boxShadow:"0 8px 24px rgba(15, 23, 42, 0.14)",zIndex:30,padding:4},children:f.map(v=>{const b=v.identifier===e;return i.jsxs("button",{type:"button",role:"option","aria-selected":b,"aria-current":b?"true":void 0,className:"g7le-toolbar__template-menu-item","data-testid":"g7le-toolbar-template-menu-item","data-identifier":v.identifier,"data-current":b?"true":"false",onClick:()=>h(v.identifier),style:{display:"flex",alignItems:"center",justifyContent:"space-between",gap:8,width:"100%",padding:"8px 10px",fontSize:13,color:"#1e293b",background:b?"#eff6ff":"transparent",border:"none",borderRadius:6,cursor:b?"default":"pointer",textAlign:"left",outline:"none"},onMouseEnter:x=>{b||(x.currentTarget.style.background="#f1f5f9")},onMouseLeave:x=>{x.currentTarget.style.background=b?"#eff6ff":"transparent"},children:[i.jsxs("span",{style:{display:"flex",alignItems:"center",gap:8,minWidth:0},children:[i.jsx("span",{"aria-hidden":"true",style:{width:12,flex:"0 0 auto",color:"#2563eb",fontSize:12,textAlign:"center"},children:b?"✓":""}),i.jsxs("span",{style:{display:"flex",flexDirection:"column",gap:2,minWidth:0},children:[i.jsx("span",{style:{fontWeight:600,color:b?"#1d4ed8":"#1e293b",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:v.name||v.identifier}),i.jsx("span",{style:{fontSize:11,color:"#94a3b8",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:v.identifier})]})]}),i.jsxs("span",{style:{display:"inline-flex",alignItems:"center",gap:6,flex:"0 0 auto"},children:[i.jsx("span",{style:{fontSize:10,fontWeight:600,color:v.type==="admin"?"#7c3aed":"#0891b2",textTransform:"uppercase",letterSpacing:.4},children:v.type}),v.version&&i.jsxs("span",{style:{fontSize:11,color:"#64748b"},children:["v",v.version]})]})]},v.identifier)})})]})}function uy(e={}){const{onSave:t,isDirty:n=!1,onExit:o,onEditCode:r,onManageImages:s,onManageTranslations:a,onPageSettings:l,onReset:d,onShowShortcuts:c,onPreview:p,onShowVersions:f,templateName:y,templateVersion:g,templateList:m,onSwitchTemplate:h}=e,[v,b]=u.useState(!1),[x,k]=u.useState(!1),{t:w}=tt(),{state:_,dispatch:S}=Je(),[E,C]=u.useState(null);u.useEffect(()=>{const I=()=>{const F=window.__g7LayoutEditorHistory;C(F?{undo:F.undo,redo:F.redo,canUndo:!!F.canUndo,canRedo:!!F.canRedo}:null)};I();const P=window.setInterval(I,250);return()=>window.clearInterval(P)},[]);const N=()=>{S({type:"TOGGLE_ROUTE_TREE"})},$=()=>_.editMode==="base"?w("layout_editor.chrome.toolbar.mode_badge.base"):_.editMode==="modal"?w("layout_editor.chrome.toolbar.mode_badge.modal"):_.editMode==="extension"?w("layout_editor.chrome.toolbar.mode_badge.extension"):_.editMode==="iteration_item"?w("layout_editor.chrome.toolbar.mode_badge.iteration_item"):null,A=()=>{_.editMode==="base"?S({type:"EXIT_BASE_EDIT"}):_.editMode==="modal"?S({type:"EXIT_MODAL_EDIT"}):_.editMode==="extension"?S({type:"EXIT_EXTENSION_EDIT"}):_.editMode==="iteration_item"&&S({type:"EXIT_ITERATION_ITEM_EDIT"})},z=$(),D=_.editMode!=="route",O={display:"inline-flex",alignItems:"center",gap:6,padding:"6px 12px",fontSize:13,fontWeight:500,borderRadius:6,borderWidth:1,borderStyle:"solid",borderColor:"transparent",backgroundColor:"transparent",color:"#475569",cursor:"pointer",transition:"background-color 120ms, color 120ms, border-color 120ms"},M={...O,backgroundColor:"#2563eb",color:"#ffffff",borderColor:"#2563eb",fontWeight:600},B={...O,borderColor:"#e2e8f0",backgroundColor:"#ffffff",color:"#0f172a"},T={backgroundColor:"#f1f5f9",color:"#0f172a",borderColor:"#cbd5e1"},H={backgroundColor:"#1d4ed8",borderColor:"#1d4ed8"},L={backgroundColor:"#f8fafc",borderColor:"#cbd5e1"};return i.jsxs("div",{className:"g7le-toolbar","data-testid":"g7le-toolbar",role:"toolbar",style:{display:"flex",alignItems:"center",gap:6,padding:"8px 12px",borderBottom:"1px solid #e2e8f0",background:"#ffffff",boxShadow:"0 1px 2px rgba(15, 23, 42, 0.04)"},children:[i.jsx("style",{children:"@keyframes g7le-spin { to { transform: rotate(360deg); } }"}),i.jsx("button",{type:"button",className:"g7le-toolbar__route-tree-toggle","data-testid":"g7le-toolbar-toggle-route-tree","aria-label":w("layout_editor.chrome.toolbar.toggle_route_tree"),title:w("layout_editor.chrome.toolbar.toggle_route_tree"),onClick:N,style:{...O,outline:"none",padding:"6px 10px",fontSize:16,lineHeight:1,color:"#64748b"},children:"☰"}),i.jsx(cy,{templateIdentifier:_.templateIdentifier,templateName:y,templateVersion:g,templateList:m,onSwitch:h}),z&&i.jsx("span",{className:"g7le-toolbar__mode-badge","data-testid":"g7le-toolbar-mode-badge","data-mode":_.editMode,style:{padding:"2px 8px",borderRadius:999,background:"#fef3c7",color:"#92400e",fontSize:11,fontWeight:600,border:"1px solid #fde68a"},children:z}),i.jsx("div",{style:{flex:1}}),i.jsxs(i.Fragment,{children:[i.jsx(wt,{"data-testid":"g7le-toolbar-undo","aria-label":w("layout_editor.chrome.toolbar.undo"),title:w("layout_editor.chrome.toolbar.undo"),disabled:!E?.canUndo,onClick:()=>E?.undo?.(),baseStyle:O,hoverStyle:T,style:{opacity:E?.canUndo?1:.4},children:"↶"}),i.jsx(wt,{"data-testid":"g7le-toolbar-redo","aria-label":w("layout_editor.chrome.toolbar.redo"),title:w("layout_editor.chrome.toolbar.redo"),disabled:!E?.canRedo,onClick:()=>E?.redo?.(),baseStyle:O,hoverStyle:T,style:{opacity:E?.canRedo?1:.4},children:"↷"}),i.jsxs(wt,{"data-testid":"g7le-toolbar-add-element","data-open":_.isPaletteOpen?"true":"false",disabled:!_.selectedRoute,onClick:()=>S({type:"TOGGLE_PALETTE"}),"aria-pressed":_.isPaletteOpen,baseStyle:O,hoverStyle:T,style:_.isPaletteOpen?{backgroundColor:"#eff6ff",color:"#1d4ed8",borderColor:"#93c5fd"}:void 0,children:["+ ",w("layout_editor.chrome.toolbar.add_element")]}),i.jsx(wt,{"data-testid":"g7le-toolbar-preview","data-previewing":x?"true":"false",disabled:!p||!_.selectedRoute?.layoutName||x,onClick:()=>{const I=p?.();I&&typeof I.then=="function"&&(k(!0),I.finally(()=>k(!1)))},baseStyle:O,hoverStyle:T,style:{opacity:x?.7:1},children:x?i.jsxs(i.Fragment,{children:[i.jsx("span",{"data-testid":"g7le-toolbar-preview-spinner","aria-hidden":"true",style:{display:"inline-block",width:12,height:12,border:"2px solid rgba(71,85,105,0.35)",borderTopColor:"#475569",borderRadius:"50%",animation:"g7le-spin 600ms linear infinite"}}),w("layout_editor.chrome.toolbar.previewing")]}):i.jsxs(i.Fragment,{children:["👁 ",w("layout_editor.chrome.toolbar.preview")]})}),i.jsxs(wt,{"data-testid":"g7le-toolbar-versions",disabled:!f||!(_.selectedRoute?.layoutName||_.selectedRoute?.path?.startsWith("__extension__/")),onClick:()=>f?.(),"aria-label":w("layout_editor.chrome.toolbar.version_history"),title:w("layout_editor.chrome.toolbar.version_history"),baseStyle:O,hoverStyle:T,children:["🕘 ",w("layout_editor.chrome.toolbar.version_history")]}),i.jsxs(wt,{"data-testid":"g7le-toolbar-edit-code",disabled:!r,onClick:()=>r?.(),baseStyle:O,hoverStyle:T,children:["⟨/⟩ ",w("layout_editor.chrome.toolbar.edit_code")]}),i.jsxs(wt,{"data-testid":"g7le-toolbar-images",disabled:!s||!_.selectedRoute,onClick:()=>s?.(),baseStyle:O,hoverStyle:T,children:["🖼 ",w("layout_editor.chrome.toolbar.images")]}),i.jsxs(wt,{"data-testid":"g7le-toolbar-translations",disabled:!a||!_.selectedRoute,onClick:()=>a?.(),baseStyle:O,hoverStyle:T,children:["🌐 ",w("layout_editor.chrome.toolbar.translations")]}),i.jsxs(wt,{"data-testid":"g7le-toolbar-page-settings",disabled:!l||!_.selectedRoute||_.editMode==="extension"||_.editMode==="iteration_item",onClick:()=>l?.(),baseStyle:O,hoverStyle:T,children:["⚙ ",w("layout_editor.chrome.toolbar.page_settings")]}),i.jsxs(wt,{"data-testid":"g7le-toolbar-reset",disabled:!d||!n,onClick:()=>d?.(),"aria-label":w("layout_editor.chrome.toolbar.reset"),title:w("layout_editor.chrome.toolbar.reset"),baseStyle:O,hoverStyle:T,style:{opacity:n?1:.4},children:["↺ ",w("layout_editor.chrome.toolbar.reset")]}),i.jsx(wt,{"data-testid":"g7le-toolbar-save","data-dirty":n?"true":"false","data-saving":v?"true":"false",disabled:!t||v,onClick:()=>{const I=t?.();I&&typeof I.then=="function"&&(b(!0),I.finally(()=>b(!1)))},baseStyle:M,hoverStyle:H,style:{opacity:v?.7:n?1:.7},children:v?i.jsxs(i.Fragment,{children:[i.jsx("span",{"data-testid":"g7le-toolbar-save-spinner","aria-hidden":"true",className:"g7le-toolbar__spinner",style:{display:"inline-block",width:12,height:12,border:"2px solid rgba(255,255,255,0.4)",borderTopColor:"#ffffff",borderRadius:"50%",animation:"g7le-spin 600ms linear infinite"}}),w("layout_editor.chrome.toolbar.saving")]}):i.jsxs(i.Fragment,{children:["💾 ",w("layout_editor.chrome.toolbar.save")]})}),i.jsx(wt,{"data-testid":"g7le-toolbar-shortcuts",disabled:!c,onClick:()=>c?.(),"aria-label":w("layout_editor.shortcuts.title"),title:w("layout_editor.shortcuts.title"),baseStyle:O,hoverStyle:T,children:"⌨"}),i.jsxs(wt,{"data-testid":"g7le-toolbar-exit",disabled:!o,onClick:()=>o?.(),baseStyle:B,hoverStyle:L,children:["✕ ",w("layout_editor.chrome.toolbar.exit")]})]}),D&&i.jsxs(wt,{"data-testid":"g7le-toolbar-exit-alt-mode",onClick:A,baseStyle:B,hoverStyle:L,children:["← ",_.editMode==="base"&&w("layout_editor.chrome.toolbar.exit_base_edit"),_.editMode==="modal"&&w("layout_editor.chrome.toolbar.exit_modal_edit"),_.editMode==="extension"&&w("layout_editor.chrome.toolbar.exit_extension_edit"),_.editMode==="iteration_item"&&w("layout_editor.chrome.toolbar.exit_iteration_item_edit")]})]})}function rr(){if(typeof window>"u")return null;try{return window.localStorage?.getItem("auth_token")??null}catch{return null}}function je(e){const t={Accept:"application/json",...e??{}},n=rr();return n&&(t.Authorization=`Bearer ${n}`),t}function Ni(e){return encodeURIComponent(e)}async function Al(e,t){const n=`/api/admin/templates/${Ni(e)}/layout-attachments?layout_name=${encodeURIComponent(t)}`;try{const o=await fetch(n,{method:"GET",headers:je(),credentials:"same-origin"}),r=await o.json().catch(()=>null);return!o.ok||!r?.success?{ok:!1,status:o.status,message:zi(r,o.status)}:{ok:!0,data:Array.isArray(r?.data)?r.data:[]}}catch(o){return{ok:!1,status:0,message:o instanceof Error?o.message:"network error"}}}async function Tl(e,t,n){const o=`/api/admin/templates/${Ni(e)}/layout-attachments`,r=new FormData;r.append("file",n),r.append("layout_name",t);try{const s=await fetch(o,{method:"POST",headers:je(),body:r,credentials:"same-origin"}),a=await s.json().catch(()=>null);return!s.ok||!a?.success||!a?.data?.url?{ok:!1,status:s.status,message:zi(a,s.status)}:{ok:!0,data:a.data}}catch(s){return{ok:!1,status:0,message:s instanceof Error?s.message:"network error"}}}async function Dl(e){const t=`/api/admin/templates/layout-attachments/${Ni(String(e))}`;try{const n=await fetch(t,{method:"DELETE",headers:je(),credentials:"same-origin"}),o=await n.json().catch(()=>null);return!n.ok||o&&o.success===!1?{ok:!1,status:n.status,message:zi(o,n.status)}:{ok:!0,data:!0}}catch(n){return{ok:!1,status:0,message:n instanceof Error?n.message:"network error"}}}function zi(e,t){if(e&&typeof e=="object"&&"message"in e){const n=e.message;if(typeof n=="string"&&n.length>0)return n}return`HTTP ${t}`}function py(e){return e<1024?`${e} B`:e<1024*1024?`${Math.round(e/1024)} KB`:`${(e/(1024*1024)).toFixed(1)} MB`}function fy(e){if(!e)return"";const t=e.match(/^(\d{4})-(\d{2})-(\d{2})/);return t?`${t[2]}-${t[3]}`:""}function Nl({templateIdentifier:e,layoutName:t,t:n,onSelect:o,onChanged:r,onClose:s}){const[a,l]=u.useState([]),[d,c]=u.useState("idle"),[p,f]=u.useState(!1),[y,g]=u.useState(null),m=u.useRef(null),h=u.useCallback(async()=>{c("loading"),g(null);const x=await Al(e,t);x.ok?(l(x.data),c("idle")):(g(x.message),c("error"))},[e,t]);u.useEffect(()=>{h()},[h]);const v=async x=>{const k=x.target.files?.[0];if(!k)return;f(!0),g(null);const w=await Tl(e,t,k);w.ok?(l(_=>[w.data,..._.filter(S=>S.id!==w.data.id)]),r?.()):g(w.message),f(!1),m.current&&(m.current.value="")},b=async x=>{if(!(typeof window<"u"&&typeof window.confirm=="function"?window.confirm(n("layout_editor.attachment_manager.delete_confirm")):!0))return;const w=await Dl(x.id);w.ok?(l(_=>_.filter(S=>S.id!==x.id)),r?.()):g(w.message)};return i.jsxs("div",{className:"g7le-attachment-manager","data-testid":"g7le-attachment-manager",style:gy,children:[i.jsxs("div",{"data-modal-drag-handle":!0,style:hy,children:[i.jsx("span",{style:{fontSize:14,fontWeight:700},"data-testid":"g7le-attachment-manager-title",children:n("layout_editor.attachment_manager.title")}),i.jsx("button",{type:"button","aria-label":"close",onClick:s,style:yy,"data-testid":"g7le-attachment-manager-close",children:"✕"})]}),i.jsx("div",{style:by,children:i.jsxs("label",{style:my,"data-testid":"g7le-attachment-upload",children:[p?"…":`⬆ ${n("layout_editor.attachment_manager.upload")}`,i.jsx("input",{ref:m,type:"file",accept:"image/*",onChange:v,disabled:p,style:{display:"none"},"data-testid":"g7le-attachment-upload-input"})]})}),y&&i.jsx("div",{style:vy,"data-testid":"g7le-attachment-manager-error",children:y}),i.jsx("div",{style:_y,children:d==="loading"?i.jsx("div",{style:zl,"data-testid":"g7le-attachment-manager-loading",children:n("layout_editor.attachment_manager.loading")}):a.length===0&&d!=="error"?i.jsx("div",{style:zl,"data-testid":"g7le-attachment-manager-empty",children:n("layout_editor.attachment_manager.empty")}):i.jsx("div",{style:xy,"data-testid":"g7le-attachment-grid",children:a.map(x=>i.jsxs("div",{style:wy,"data-testid":`g7le-attachment-card-${x.id}`,children:[i.jsx("div",{style:{...ky,backgroundImage:`url(${x.url})`}}),i.jsx("div",{style:Sy,title:x.original_name,children:x.original_name}),i.jsxs("div",{style:Cy,children:[py(x.size),x.created_at?` · ${fy(x.created_at)}`:""]}),i.jsxs("div",{style:$y,children:[o&&i.jsx("button",{type:"button",onClick:()=>o(x.url),style:Ey,"data-testid":`g7le-attachment-use-${x.id}`,children:n("layout_editor.attachment_manager.use_as_bg")}),i.jsx("button",{type:"button",onClick:()=>b(x),style:Iy,"data-testid":`g7le-attachment-delete-${x.id}`,children:"🗑"})]})]},x.id))})}),i.jsx("div",{style:Ay,children:i.jsx("button",{type:"button",onClick:s,style:Ty,"data-testid":"g7le-attachment-manager-footer-close",children:n("layout_editor.attachment_manager.close")})})]})}const gy={display:"flex",flexDirection:"column",minHeight:0,flex:1},hy={display:"flex",alignItems:"center",justifyContent:"space-between",padding:"12px 16px",borderBottom:"1px solid #e2e8f0"},yy={border:"none",background:"transparent",fontSize:16,color:"#94a3b8",cursor:"pointer"},by={padding:"10px 16px",borderBottom:"1px solid #f1f5f9",display:"flex",gap:8},my={padding:"6px 12px",fontSize:13,border:"1px solid #2563eb",borderRadius:6,background:"#fff",color:"#2563eb",cursor:"pointer",display:"inline-flex",alignItems:"center"},vy={padding:"8px 16px",fontSize:12,color:"#b91c1c",background:"#fef2f2"},_y={padding:16,overflow:"auto",flex:1,minHeight:120},zl={fontSize:13,color:"#94a3b8",textAlign:"center",padding:"32px 0"},xy={display:"grid",gridTemplateColumns:"repeat(auto-fill, minmax(140px, 1fr))",gap:12},wy={display:"flex",flexDirection:"column",gap:4,border:"1px solid #e2e8f0",borderRadius:8,padding:8},ky={width:"100%",height:88,borderRadius:6,border:"1px solid #cbd5e1",backgroundSize:"cover",backgroundPosition:"center",backgroundColor:"#f8fafc"},Sy={fontSize:12,fontWeight:600,color:"#0f172a",whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis"},Cy={fontSize:11,color:"#64748b"},$y={display:"flex",gap:6,marginTop:2},Ey={flex:1,padding:"4px 6px",fontSize:11,border:"1px solid #2563eb",borderRadius:6,background:"#2563eb",color:"#fff",cursor:"pointer"},Iy={padding:"4px 8px",fontSize:12,border:"1px solid #fecaca",borderRadius:6,background:"#fff",color:"#dc2626",cursor:"pointer"},Ay={padding:"10px 16px",borderTop:"1px solid #e2e8f0",display:"flex",justifyContent:"flex-end"},Ty={padding:"6px 14px",fontSize:13,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"};function ir(e){return encodeURIComponent(e)}async function Dy(e,t,n){const o=new URLSearchParams;t&&o.set("layout_name",t);const r=o.toString(),s=`/api/admin/templates/${ir(e)}/custom-translations`+(r?`?${r}`:"");try{const a=await fetch(s,{method:"GET",headers:je(),credentials:"same-origin"}),l=await a.json().catch(()=>null);return!a.ok||!l?.success?{ok:!1,status:a.status,message:sr(l,a.status)}:{ok:!0,data:Array.isArray(l?.data)?l.data:[]}}catch(a){return{ok:!1,status:0,message:a instanceof Error?a.message:"network error"}}}async function Ny(e,t,n,o){const r=`/api/admin/templates/${ir(e)}/custom-translations/${t}`;try{const s=await fetch(r,{method:"PUT",headers:je({"Content-Type":"application/json"}),body:JSON.stringify({values:n,expected_lock_version:o}),credentials:"same-origin"}),a=await s.json().catch(()=>null);return!s.ok||!a?.success||!a?.data?{ok:!1,status:s.status,message:sr(a,s.status)}:{ok:!0,data:a.data}}catch(s){return{ok:!1,status:0,message:s instanceof Error?s.message:"network error"}}}async function zy(e,t){const n=`/api/admin/templates/${ir(e)}/custom-translations/${t}`;try{const o=await fetch(n,{method:"DELETE",headers:je(),credentials:"same-origin"}),r=await o.json().catch(()=>null);return!o.ok||r&&r.success===!1?{ok:!1,status:o.status,message:sr(r,o.status)}:{ok:!0,data:!0}}catch(o){return{ok:!1,status:0,message:o instanceof Error?o.message:"network error"}}}async function My(e,t){const n=`/api/admin/templates/${ir(e)}/custom-translations`;try{const o=await fetch(n,{method:"DELETE",headers:je({"Content-Type":"application/json"}),body:JSON.stringify({ids:t}),credentials:"same-origin"}),r=await o.json().catch(()=>null);return!o.ok||!r?.success?{ok:!1,status:o.status,message:sr(r,o.status)}:{ok:!0,data:{deleted:typeof r?.data?.deleted=="number"?r.data.deleted:t.length}}}catch(o){return{ok:!1,status:0,message:o instanceof Error?o.message:"network error"}}}const Ml=/\$t:(custom\.[A-Za-z0-9_\-]+\.\d+)/g;function Ly(e){const t=new Set,n=o=>{if(typeof o=="string"){if(o.includes("$t:custom.")){let r;for(Ml.lastIndex=0;(r=Ml.exec(o))!==null;)t.add(r[1])}return}if(Array.isArray(o)){for(const r of o)n(r);return}if(o&&typeof o=="object")for(const r of Object.values(o))n(r)};return n(e),t}function sr(e,t){if(e&&typeof e=="object"&&"message"in e){const n=e.message;if(typeof n=="string"&&n.length>0)return n}return`HTTP ${t}`}const Pt=Lt().TranslationEngine,ar=Lt().createLogger,Mi=ar("EditorSpecLoader"),Py=["controls","componentCapabilities","actionRecipes","conditionRecipes","pageSettings","initActionRecipes","errorRecipes","computedRecipes","loadingComponents"],Oy=["actionRecipes","initActionRecipes","errorRecipes","computedRecipes","loadingComponents"];function Ll(e){if(typeof window>"u")return[];const n=window.G7Config?.[e];if(!Array.isArray(n))return[];const o=[];for(const r of n){const s=r?.identifier;typeof s=="string"&&s.length>0&&o.push(s)}return o}async function By(e,t,n,o){const s=`${n}/${e==="template"?"templates":e==="module"?"modules":"plugins"}/${encodeURIComponent(t)}/editor-spec`;try{const a=await o(s);if(!a.ok)return a.status!==404&&Mi.warn(`fetchOneSpec: HTTP ${a.status}`,s),null;const l=await a.json();return!l?.success||!l.data?null:l.data.spec??null}catch(a){return Mi.warn("fetchOneSpec: fetch failed",s,a),null}}function Wy(e){return Oy.includes(e)}function jy(e,t){return e==null||typeof e!="object"?e:{...e,__source:t}}function Hy(e,t,n,o){const r=t[n];if(!r||typeof r!="object")return;const s=e[n]??{};if(o&&Wy(n)){const a={};for(const[l,d]of Object.entries(r))a[l]=jy(d,o);e[n]={...s,...a};return}e[n]={...s,...r}}function Fy(e,t){if(!t.nesting)return;const n=e.nesting??{},o=new Set(n.draggable??[]);for(const r of t.nesting.draggable??[])o.add(r);n.draggable=Array.from(o),n.containers={...n.containers??{},...t.nesting.containers??{}},e.nesting=n}function Ky(e,t){if(!t.componentPalette)return;const n=e.componentPalette??{},o=[...n.groups??[],...t.componentPalette.groups??[]];n.groups=o,n.entries={...n.entries??{},...t.componentPalette.entries??{}},e.componentPalette=n}function Vy(e,t){if(!t.states)return;const n=e.states??{},o=[...n.groups??[],...t.states.groups??[]];n.groups=o,e.states=n}function Uy(e,t){if(!Array.isArray(t.stateLabels)||t.stateLabels.length===0)return;const n=[...e.stateLabels??[],...t.stateLabels],o=new Map;for(const r of n)!r||typeof r.key!="string"||typeof r.scope!="string"||o.set(`${r.scope}:${r.key}`,r);e.stateLabels=Array.from(o.values())}function qy(e,t){if(!t.actionChipCandidates)return;const n=e.actionChipCandidates??{};for(const o of["response","error","payload"]){const r=t.actionChipCandidates[o];!Array.isArray(r)||r.length===0||(n[o]=[...n[o]??[],...r])}e.actionChipCandidates=n}function Gy(e,t){if(!t.sampleData)return;const n=e.sampleData??{};n.byDataSourceId={...n.byDataSourceId??{},...t.sampleData.byDataSourceId??{}},n.byEndpointPattern={...n.byEndpointPattern??{},...t.sampleData.byEndpointPattern??{}},e.sampleData=n}function Pl(e,t,n){for(const o of Py)Hy(e,t,o,n);Fy(e,t),Ky(e,t),Vy(e,t),Uy(e,t),qy(e,t),Gy(e,t),t.version&&(e.version=t.version)}async function Yy(e){const t=e.apiBaseUrl??"/api",n=e.fetcher??(typeof fetch<"u"?fetch:void 0);if(!n)return Mi.warn("loadEditorSpecBundle: fetch unavailable, returning null spec"),{spec:null,sampleGlobalSources:[]};const o=e.activeModuleIdentifiers??Ll("activeModules"),r=e.activePluginIdentifiers??Ll("activePlugins"),s=[...o.map(p=>({kind:"module",id:p})),...r.map(p=>({kind:"plugin",id:p})),{kind:"template",id:e.templateIdentifier}],a=await Promise.all(s.map(p=>By(p.kind,p.id,t,n)));let l=null;const d=[],c={};if(e.coreSeed&&(l={},Pl(l,e.coreSeed,{kind:"core"})),s.forEach((p,f)=>{const y=a[f];if(!y)return;y.sampleGlobal&&typeof y.sampleGlobal=="object"&&d.push({id:p.id,kind:p.kind,sampleGlobal:y.sampleGlobal});const g=y.sampleData?.byDataSourceId;if(g&&typeof g=="object"){const m=p.kind==="template"?"template":`${p.kind}:${p.id}`;c[m]={...c[m]??{},...g}}l===null&&(l={}),Pl(l,y,{kind:p.kind,id:p.id})}),l!==null&&Object.keys(c).length>0){const p=l;p.sampleData={...p.sampleData??{},bySource:c}}return{spec:l,sampleGlobalSources:d}}function Li(e,t){return e?.componentCapabilities?.[t]??null}function Ct(e,t){return e?.controls?.[t]??null}function Xy(e){return e?.initActionRecipes??{}}function Jy(e){return e?.computedRecipes??{}}function Zy(e){return e?.errorRecipes??{}}function Qy(e){const t=e?.loadingComponents;return!t||typeof t!="object"?[]:Object.values(t).filter(n=>n!=null&&typeof n=="object"&&typeof n.name=="string")}function Ry(e,t,n){const o=e?.stateLabels;if(!Array.isArray(o))return null;for(const r of o)if(r?.scope===t&&r?.key===n)return typeof r.label_key=="string"?r.label_key:null;return null}function Pi(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)}function eb(e){return e===null?"null":typeof e=="string"?e.length>40?`${e.slice(0,40)}…`:e:typeof e=="number"||typeof e=="boolean"?String(e):""}function tb(e){const t=e.find(n=>Pi(n));if(Pi(t))return Object.keys(t).slice(0,8)}function nb(e){return Object.keys(e).slice(0,8)}function ob(e){const t=Object.entries(e);return t.length===0?!1:t.some(([,n])=>n===null||typeof n!="object")}function Oi(e,t,n,o,r){if(Array.isArray(e)){r(t,"array",`[${e.length}]`,tb(e));return}if(Pi(e)){if(t&&ob(e)){const s=nb(e);r(t,"object",`{${s.length}}`,s)}if(n>=o)return;for(const[s,a]of Object.entries(e)){const l=t?`${t}.${s}`:s;Oi(a,l,n+1,o,r)}return}r(t,"scalar",eb(e))}function rb(e,t,n){const o=[];let r=e;for(const s of t){if(!r||(r=(Array.isArray(r.children)?r.children:[])[s],!r))break;const l=r.iteration;if(!(!l||l.source===void 0)&&(typeof l.index_var=="string"&&l.index_var.length>0&&o.push({name:l.index_var,kind:"index"}),typeof l.item_var=="string"&&l.item_var.length>0)){const d=typeof l.source=="string"?l.source:"",c=d&&n?.get(Ol(d))||[];o.push({name:l.item_var,kind:"item",itemFields:c})}}return o}function Ol(e){return e.replace(/^\{\{|\}\}$/g,"").trim()}function ib(e){const t=new Map;for(const n of e)n.shape==="array"&&n.itemFields&&n.itemFields.length>0&&t.set(Ol(n.expression),n.itemFields);return t}function Bi(e){const t=e.maxDepth??4,n=[];for(const o of e.dataSources??[])!o||typeof o.id!="string"||o.id.length===0||Oi(o.sample,"",0,t,(r,s,a,l)=>{const d=r?`{{${o.id}.${r}}}`:`{{${o.id}}}`;n.push({expression:d,source:"data_source",sourceId:o.id,path:r,shape:s,groupLabelKey:o.labelKey?.startsWith("$t:")?o.labelKey.slice(3):o.labelKey,preview:a,itemFields:l})});for(const o of e.states??[])!o||typeof o.scope!="string"||Oi(o.tree,"",0,t,(r,s,a,l)=>{if(!r)return;const d=Ry(e.spec,o.scope,r)??void 0;n.push({expression:`{{${o.scope}.${r}}}`,source:o.scope,sourceId:o.scope,path:r,shape:s,labelKey:d?.startsWith("$t:")?d.slice(3):d,preview:a,itemFields:l})});for(const o of e.iterationVars??[])if(!(!o||typeof o.name!="string"||o.name.length===0)){if(o.kind==="index"){n.push({expression:`{{${o.name}}}`,source:"iteration",sourceId:o.name,path:"",shape:"scalar",preview:"0"});continue}for(const r of o.itemFields??[])n.push({expression:`{{${o.name}.${r}}}`,source:"iteration",sourceId:o.name,path:r,shape:"scalar",preview:""})}return n}const sb=["$localized"];function ab(e){const t=e.trim(),n=t.indexOf("(");if(n===-1||!t.endsWith(")"))return{fn:null,inner:t};const o=t.slice(0,n).trim();if(!sb.includes(o))return{fn:null,inner:t};const r=t.slice(n+1,-1);let s=0,a=null;for(let l=0;ln.shape===t)}function Hi(e,t){const n=t.trim().toLowerCase();return n===""?e:e.filter(o=>[o.resolvedLabel??"",o.sourceId,o.path,o.preview].join(" ").toLowerCase().includes(n))}function Bl(e,t,n){const o={response:[{path:"data",labelKey:"layout_editor.action_chip.response_data",shape:"object"},{path:"message",labelKey:"layout_editor.action_chip.response_message",shape:"scalar"}],error:[{path:"status",labelKey:"layout_editor.action_chip.error_status",shape:"scalar"},{path:"message",labelKey:"layout_editor.action_chip.error_message",shape:"scalar"},{path:"errors",labelKey:"layout_editor.action_chip.error_errors",shape:"object"},{path:"data",labelKey:"layout_editor.action_chip.error_data",shape:"object"},{path:"statusText",labelKey:"layout_editor.action_chip.error_status_text",shape:"scalar"}],payload:[{path:"data",labelKey:"layout_editor.action_chip.payload_data",shape:"object"}]},r=e==="response"?"response":e==="error"?"error":"message",s=new Set,a=[];for(const d of o[e])s.has(d.path)||(s.add(d.path),a.push(d));const l=n?.[e];if(Array.isArray(l))for(const d of l)!d||typeof d.path!="string"||typeof d.labelKey!="string"||s.has(d.path)||(s.add(d.path),a.push({path:d.path,labelKey:d.labelKey,shape:d.shape??"scalar"}));return a.map(d=>({expression:`{{${r}.${d.path}}}`,source:"_local",sourceId:r,path:d.path,shape:d.shape,labelKey:d.labelKey,preview:t(d.labelKey)}))}const Fi=/\{\{(?:[^}]|\}(?!\}))*\}\}/g;function En(e){const t=[];if(typeof e!="string"||e.length===0)return t;let n=0,o=0;const r=new RegExp(Fi.source,"g");let s;for(;(s=r.exec(e))!==null;){const a=s.index,l=a+s[0].length;a>n&&t.push({kind:"literal",raw:e.slice(n,a),start:n,end:a}),t.push({kind:"binding",raw:s[0],start:a,end:l,parsed:Kt(s[0]),bindingIndex:o++}),n=l}return nt.kind==="binding")}function Ki(e){return typeof e!="string"?!1:new RegExp(Fi.source).test(e)}function db(e){const t=new Set(e.map(o=>o.trim()));let n=0;for(;t.has(`p${n}`);)n+=1;return`p${n}`}function Wl(e,t,n){const o=typeof e=="string"?e:"",r=Math.max(0,Math.min(t,o.length)),s=o.slice(0,r),a=o.slice(r),l=s.length>0&&!/\s$/.test(s)?" ":"",d=a.length>0&&!/^\s/.test(a)?" ":"";return`${s}${l}{${n}}${d}${a}`}function Vi(e,t){const n=typeof e=="string"?e:"";return In(n).includes(t)?n:n.length===0?`{${t}}`:/\s$/.test(n)?`${n}{${t}}`:`${n} {${t}}`}function jl(e){return lr(e).map(t=>({bindingIndex:t.bindingIndex??0,expression:t.raw,parsed:t.parsed??null,isComplex:(t.parsed??null)===null}))}function dr(e){return typeof e!="string"?"":e.replace(new RegExp(Fi.source,"g")," ").replace(/\s+/g," ").trim()}const cb=/\$t:([a-zA-Z0-9._-]+)/g,Hl="custom.";function Ui(e,t){return typeof e!="string"||e.length===0?typeof e=="string"?e:"":t?En(e).map(n=>n.kind!=="literal"?n.raw:n.raw.replace(cb,(o,r)=>{if(r.startsWith(Hl))return o;const s=t(r);return s&&s!==r?s:o})).join(""):e}const Fn="(?:(?!\\|[a-zA-Z0-9_]+=).)*",ub=new RegExp(`^\\s*\\$t:([a-zA-Z0-9._-]+)((?:\\|[a-zA-Z0-9_]+=${Fn})*)`),pb=new RegExp(`\\|([a-zA-Z0-9_]+)=(${Fn})`,"g");function Vt(e){if(typeof e!="string")return"";const t=Kt(e);if(t)return t.path||t.sourceId;const n=e.trim().replace(/^\{\{\s*/,"").replace(/\s*\}\}$/,"");let o=-1;for(let l=0;l0){const l=n.slice(0,o).trim(),d=Kt(`{{${l}}}`);if(d)return d.path||d.sourceId;const c=l.replace(/\?\./g,".").replace(/\s*\?\?.*$/,"").trim();if(/^[A-Za-z_$][\w$.]*$/.test(c)){const p=c.split(".").filter(Boolean);return p.length>1&&p.slice(1).join(".")||c}}const r=n.replace(/\s+/g," ").trim(),s=r.replace(/\s*(\?\?|\|\|)\s*(\[\s*\]|\{\s*\}|'[^']*'|"[^"]*"|\d+|null|undefined)\s*$/,"").trim(),a=/\.([A-Za-z_$][\w$]*)\s*$/.exec(s);return a?a[1]:r}const Fl=/\$(?:core|module|plugin)_settings:[a-zA-Z0-9._:-]+/g;function Kl(e){return typeof e=="string"&&new RegExp(Fl.source).test(e)}function fb(e){return typeof e!="string"?"":e.replace(/^\$(?:core|module|plugin)_settings:/,"").split(/[.:]/).filter(Boolean).pop()||e}function Vl(e){const t=[];for(const n of En(e)){if(n.kind==="binding"){t.push({kind:"chip",label:Vt(n.raw)});continue}let o=0;const r=n.raw,s=new RegExp(Fl.source,"g");let a;for(;(a=s.exec(r))!==null;)a.index>o&&t.push({kind:"text",label:r.slice(o,a.index)}),t.push({kind:"chip",label:fb(a[0])}),o=a.index+a[0].length;o{const m=`p${o.length}`;return o.push(g),r[m]=Vt(g),`{${m}}`};let a=e,l="";const d=ub.exec(e);if(d&&!d[1].startsWith(Hl)){const g=d[1],m=d[2]??"",h=e.slice(d[0].length),v=new Map,b=new RegExp(pb.source,"g");let x;for(;(x=b.exec(m))!==null;)x[2]&&x[2].includes("{{")&&v.set(x[1],x[2]);const k=t?t(g):"";if(k&&k!==g&&!k.startsWith("$t:"))l=k.replace(/\{\{?([a-zA-Z0-9_]+)\}?\}/g,(w,_)=>{const S=v.get(_);return S?s(S):w});else return n;a=h}const c=Ui(a,t),p=En(c).map(g=>g.kind==="binding"?s(g.raw):g.raw).join(""),f=`${l}${p}`;return/\$t:[a-zA-Z0-9._-]+/.test(f)||o.length===0?n:{chipValue:f,bindings:o,paramLabels:r,keyifiable:!0}}function Ul(e,t){return t.bindings.length?e+t.bindings.map((n,o)=>`|p${o}=${n}`).join(""):e}function ql(e,t){const n=lr(t).map(r=>r.raw);if(n.length===0)return e;const o=n.map((r,s)=>`|p${s}=${r}`).join("");return`${e}${o}`}function Gl(e){if(typeof e!="string")return"";const t=En(e);let n=0;return t.map(o=>o.kind==="binding"?`{p${n++}}`:o.raw).join("")}const Yl=new RegExp(`^\\s*\\$t:([a-zA-Z0-9._-]+)((?:\\|[a-zA-Z0-9_]+=${Fn})+)\\s*$`),hb=new RegExp(`\\|([a-zA-Z0-9_]+)=(${Fn})`,"g");function mt(e){if(typeof e!="string")return null;const t=Yl.exec(e);if(!t)return null;const n=t[1],o=t[2],r=[],s=new RegExp(hb.source,"g");let a;for(;(a=s.exec(o))!==null;){const l=a[1],d=a[2];r.push({name:l,expression:d,parsed:Kt(d)})}return{key:n,params:r}}function Xl(e){return typeof e=="string"&&Yl.test(e)}function In(e){if(typeof e!="string")return[];const t=[],n=/\{\{?(p\d+)\}?\}/g;let o;for(;(o=n.exec(e))!==null;)t.push(o[1]);return t.sort()}function yb(e,t,n){if(typeof e!="string")return e;const o=new RegExp(`(\\|${t}=)${Fn}`);return o.test(e)?e.replace(o,`$1${n}`):e}function ho(e,t){if(typeof e!="string")return e;const n=new RegExp(`\\|${t}=${Fn}`);return e.replace(n,"")}function Jl(e,t){return typeof e!="string"?e:e.replace(new RegExp(`\\s*\\{\\{?${t}\\}?\\}\\s*`,"g")," ").replace(/\s+/g," ").trim()}function bb(e,t){const n=s=>lr(typeof s=="string"?s:"").map(a=>a.raw.replace(/\s+/g,"")).sort(),o=n(e),r=n(t);return o.length!==r.length?!1:o.every((s,a)=>s===r[a])}function Zl(e){return e.__source?.kind==="base"}function Ql(e){return e.__source?.kind==="partial"}function mb(e){return e.__source?.kind==="route"}function cr(e,t){return e.__source?.kind!=="extension"?!1:t==null?!0:e.__source.extensionId!==t}function Rl(e){return e.type==="extension_point"}function Gi(e=[]){for(const t of e)if(t.iteration&&t.iteration.source!==void 0)return!0;return!1}function vb(e,t=[]){for(const n of t)if(n.iteration&&n.iteration.source!==void 0)return!0;return e.iteration&&e.iteration.source!==void 0?!0:ed(e)}function ed(e){if(typeof e.text=="string"&&td(e.text)&&!_b(e.text))return!0;if(e.props&&typeof e.props=="object"){for(const t of Object.values(e.props))if(typeof t=="string"&&td(t))return!0}return!1}function td(e){return/\{\{[\s\S]*?\}\}/.test(e)}function _b(e){const t=mt(e);return t!==null&&t.key.startsWith("custom.")}function xb(e,t){return e??t??null}function Yi(e,t,n){if(Rl(e))return!0;switch(t){case"route":return Zl(e)||Ql(e)||cr(e);case"base":return cr(e);case"modal":return cr(e);case"extension":return cr(e,n)?!0:Zl(e)||mb(e)||Ql(e);default:return!1}}function et(e){return typeof e=="object"&&e!==null&&typeof e.responsive=="string"}function ur(e,t){return et(e)?et(t)&&e.responsive===t.responsive:!et(t)&&e===t}function at(e){const t=[];let n=!1;for(const o of e){if(et(o)){t.push("responsive",o.responsive),n=!0;continue}n?t.push("children",String(o)):(t.push(String(o)),n=!0)}return t.join(".")}function Qt(e){return Array.isArray(e.children)?e.children:[]}function pr(e,t){const n=e.responsive?.[t];return n&&Array.isArray(n.children)?n.children:[]}function We(e,t){if(!e)return null;if(t.length===0)return e;let n=e,o=Qt(e);for(const r of t){if(et(r)){o=pr(n,r.responsive);continue}if(r<0||r>=o.length||(n=o[r],!n))return null;o=Qt(n)}return n}function wb(e,t,n){if(!e||t.length===0)return t;let o=e,r=Qt(e),s;for(let a=0;a=2?t[t.length-2]:void 0;if(r!==void 0&&et(r)){const a=We(e,t.slice(0,-2));return a?{owner:a,children:pr(a,r.responsive),branchKey:r.responsive,index:o}:null}const s=We(e,t.slice(0,-1));return s?{owner:s,children:Qt(s),index:o}:null}function Xi(e,t){if(e.branchKey!==void 0){const n={...e.owner.responsive??{}};n[e.branchKey]={...n[e.branchKey]??{},children:t},e.owner.responsive=n}else e.owner.children=t}function Rt(e){const t={...e};t.props&&typeof t.props=="object"&&(t.props={...t.props});const n=Qt(e);if(n.length>0&&(t.children=n.map(Rt)),e.responsive&&typeof e.responsive=="object"){const o={};for(const r of Object.keys(e.responsive)){const s=e.responsive[r];if(!s||typeof s!="object"){o[r]=s;continue}const a={...s};Array.isArray(s.children)&&(a.children=s.children.map(Rt)),a.props&&typeof a.props=="object"&&(a.props={...a.props}),o[r]=a}t.responsive=o}return t}function Ji(e,t,n,o){const r=Rt(e),s=t.length>0?t[t.length-1]:void 0;if(s!==void 0&&et(s)){const p=We(r,t.slice(0,-1));if(!p)return r;const f=pr(p,s.responsive),y=Math.max(0,Math.min(n,f.length)),g=[...f];return g.splice(y,0,o),Xi({owner:p,branchKey:s.responsive},g),r}const a=We(r,t);if(!a)return r;const l=Qt(a),d=Math.max(0,Math.min(n,l.length)),c=[...l];return c.splice(d,0,o),a.children=c,r}function Zi(e,t){if(t.length===0)return e;const n=Rt(e),o=nd(n,t);if(!o||o.index<0||o.index>=o.children.length)return n;const r=[...o.children];return r.splice(o.index,1),Xi(o,r),n}function kb(e,t,n,o){if(t.length===0||!n||et(t[t.length-1]))return e;const r=Rt(e),s=We(r,t);if(!s)return r;const a=s.responsive&&typeof s.responsive=="object"?s.responsive[n]:void 0;if(a&&Array.isArray(a.children))return r;const l=o&&s.responsive&&typeof s.responsive=="object"?s.responsive[o]:void 0,c=(l&&Array.isArray(l.children)?l.children:Qt(s)).map(Rt),p={...s.responsive??{}};return p[n]={...p[n]??{},children:c},s.responsive=p,r}function Sb(e,t,n){if(t.length===0||!n||et(t[t.length-1]))return e;const o=Rt(e),r=We(o,t);if(!r||!r.responsive||typeof r.responsive!="object")return o;const s=r.responsive[n];if(!s||!Array.isArray(s.children))return o;const a={...r.responsive},l={...a[n]};return delete l.children,Object.keys(l).length===0?delete a[n]:a[n]=l,r.responsive=Object.keys(a).length>0?a:void 0,o}function Cb(e,t,n,o){if(t.length===0)return e;const r=We(e,t);if(!r||$b(t,n))return e;const s=Rt(r),a=Zi(e,t),l=od(n,t);let d=o;const c=t.slice(0,-1);if(rd(c,n)){const p=t[t.length-1];(typeof p=="number"?p:0)o){const s=[...e];return s[n]=r-1,s}return e}function rd(e,t){if(e.length!==t.length)return!1;for(let n=0;ne.length||rd(e,t)}function Qi(e){const t={...e};if(t.props&&typeof t.props=="object"&&(t.props={...t.props}),typeof t.id=="string"&&t.id.length>0){const o=typeof t.name=="string"?t.name:"node";t.id=`${o}_${Eb()}`}const n=Qt(e);return n.length>0&&(t.children=n.map(Qi)),t}function Ot(e,t,n){if(t.length===0)return n(e);const o=Rt(e),r=We(o,t);if(!r)return o;const s=n(r),a=nd(o,t);if(!a||a.index<0||a.index>=a.children.length)return o;const l=[...a.children];return l[a.index]=s,Xi(a,l),o}function Eb(){return Math.floor(Math.random()*2821109907456).toString(36).padStart(8,"0")}function Kn(e){if(!Array.isArray(e)||e.length===0)return[];const t=[];for(const n of e){const o=Ib(n);o.length>0&&t.push(...o)}return t}function Ib(e){if(!e||typeof e!="object")return[];const t=e.__source,n=e._fromBase===!0,o=t?.kind;if(o==="extension"||o==="partial")return[];if(o==="base"||n){const s=Array.isArray(e.children)?e.children:[];return Kn(s)}const r={...e};return delete r.__source,delete r._fromBase,delete r.__injectedProps,Array.isArray(r.children)&&(r.children=Kn(r.children)),[r]}function yo(e){if(!e||typeof e!="object")return e;const t=e.__editor,n=t&&typeof t=="object"&&!Array.isArray(t)?t.original:void 0,o=n&&typeof n=="object"&&!Array.isArray(n)?n:void 0,r=Array.isArray(e.components)?e.components:[],s=r.length>0,a=Kn(r),l=o?{...o}:{...e};if(typeof l.extends=="string"&&l.extends.length>0){delete l.components;const c=l.slots&&typeof l.slots=="object"&&!Array.isArray(l.slots)?l.slots:{},p=Object.keys(c);if(!s&&p.length>0){const f={};for(const[y,g]of Object.entries(c))f[y]=Array.isArray(g)?Kn(g):g;l.slots=f}else if(p.length===1)l.slots={[p[0]]:a};else if(p.length===0)l.slots={content:a};else{const f={};for(const[y,g]of Object.entries(c))f[y]=Array.isArray(g)?Kn(g):g;l.slots=f}}else l.components=a,delete l.slots;return delete l.lock_version,delete l.__editor,l}const id=/^\s*\$t:([a-zA-Z0-9._-]+)\s*$/,fr="custom.",Ab=/\{\{.*?\}\}/;function Tb(e){return!!(!e||e.iteration&&typeof e.iteration=="object")}function sd(e,t){const n=e?.text,o=typeof n=="string"?n:"";if(Tb(e)||typeof n!="string")return{sourceState:"plain_text",editable:!1,customKey:null,displayValue:o};const r=mt(o);if(r&&r.key.startsWith(fr)){const a=t?t(r.key):r.key;return{sourceState:"custom_key",editable:!0,customKey:r.key,displayValue:a,isParamKey:!0}}const s=id.exec(o);if(s){const a=s[1];if(a.startsWith(fr)){const d=t?t(a):a;return{sourceState:"custom_key",editable:!0,customKey:a,displayValue:d}}const l=t?t(a):"";return{sourceState:"plain_text",editable:!0,customKey:null,displayValue:l&&l!==a?l:""}}if(Ab.test(o)){const a=dr(o),l=id.exec(a);if(l&&l[1].startsWith(fr)){const f=l[1],y=t?t(f):f;return{sourceState:"custom_key",editable:!0,customKey:f,displayValue:y}}const d=Ui(o,t),c=dr(d),p=/\$t:[a-zA-Z0-9._-]+/.test(c);if(c.length>0){const f=qi(o,t);return{sourceState:"plain_with_binding",editable:!0,customKey:null,displayValue:p?"":c,chipValue:f.keyifiable?f.chipValue:void 0,chipParamLabels:f.keyifiable?f.paramLabels:void 0}}return{sourceState:"binding_expression",editable:!1,customKey:null,displayValue:o}}return{sourceState:"plain_text",editable:!0,customKey:null,displayValue:o}}function Db(){const{state:e}=Je(),t=Ft(),n=e.templateIdentifier,o=e.locale,r=e.selectedRoute?.layoutName??null,s=u.useCallback(p=>{try{const y=Pt.getInstance().translate(p,{templateId:n,locale:o});return y&&y!==p?y:""}catch{return""}},[n,o]),a=u.useCallback(p=>sd(p,s),[s]),l=u.useCallback(p=>{Et({op:"inline_edit_cancel",componentPath:p?at(p):null,timestamp:Date.now()})},[]),d=u.useCallback(async(p,f)=>{if(!t)return{kind:"error",message:"no document context"};const g={children:t.document?.raw?.components??[]},m=We(g,p),h=sd(m,s);if(!h.editable)return Et({op:"inline_edit_blocked_binding",sourceState:h.sourceState,componentPath:at(p),timestamp:Date.now()}),{kind:"blocked"};if(h.customKey){if(f===h.displayValue)return{kind:"noop",translationKey:h.customKey};bo(n,o,h.customKey,f);const E=await gr(n,h.customKey,o,f);return E.kind==="error"?{kind:"error",message:E.message}:(await en(n,o),Et({op:"inline_edit_update_value",sourceState:"custom_key",componentPath:at(p),translationKey:h.customKey,toLocale:o,valueLength:f.length,valueHash:Ti(f),timestamp:Date.now()}),{kind:"updated",translationKey:h.customKey})}const v=typeof m?.text=="string"?m.text:"",b=h.sourceState==="plain_with_binding",x=Ui(v,s),k=b?Gl(x):f,w=E=>b?ql(E,v):E;t.patchLayout(E=>Ot({children:E},p,$=>({...$,text:f})).children??[]);const _=await Vn(n,r,o,k);if(_.kind==="error"||!_.resource)return{kind:"error",message:_.message};const S=_.resource.translation_key;return bo(n,o,S,k),t.patchLayout(E=>Ot({children:E},p,$=>({...$,text:w(`$t:${S}`)})).children??[]),await en(n,o),Et({op:"inline_edit_create_key",sourceState:h.sourceState,componentPath:at(p),translationKey:S,toLocale:o,valueLength:f.length,valueHash:Ti(f),timestamp:Date.now()}),{kind:"created",translationKey:S,id:_.resource.id}},[t,n,o,r,s]),c=u.useCallback(async(p,f)=>{if(!t)return{kind:"error",message:"no document context"};const g={children:t.document?.raw?.components??[]},m=We(g,p),h=typeof m?.text=="string"?m.text:"",v=mt(h);if(v&&v.key.startsWith(fr))return hr(n,v.key,o,f),bo(n,o,v.key,f),t.markDirty?.(),{kind:"updated",translationKey:v.key};const b=qi(h,s),x=dr(f.replace(/\{\{?p\d+\}?\}/g," ")).replace(/\s+/g," ").trim(),k=await Vn(n,r,o,x);if(k.kind==="error"||!k.resource)return{kind:"error",message:k.message};const w=k.resource.translation_key,_=In(f),S=k.resource.values??{},E={[o]:f};for(const C of Object.keys(S)){if(C===o)continue;let N=S[C]??"";for(const $ of _)N=Vi(N,$);E[C]=N}return mo(n,w,E),bo(n,o,w,f),t.patchLayout(C=>Ot({children:C},p,A=>({...A,text:Ul(`$t:${w}`,b)})).children??[]),t.markDirty?.(),await en(n,o),bo(n,o,w,f),Et({op:"inline_edit_create_key",sourceState:"plain_with_binding",componentPath:at(p),translationKey:w,toLocale:o,valueLength:f.length,valueHash:Ti(f),timestamp:Date.now()}),{kind:"created",translationKey:w}},[t,n,o,r]);return{classify:a,commit:d,trackCancel:l,keyifyChipValue:c}}async function Vn(e,t,n,o){const r=`/api/admin/templates/${encodeURIComponent(e)}/custom-translations`;try{const s=await fetch(r,{method:"POST",credentials:"same-origin",headers:je({"Content-Type":"application/json"}),body:JSON.stringify({layout_name:t,locale:n,value:o})}),a=await s.json().catch(()=>null);if(!s.ok)return{kind:"error",message:a?.message??`HTTP ${s.status}`};const l=a?.data;return!l||typeof l.translation_key!="string"?{kind:"error",message:"invalid response"}:{kind:"ok",resource:l}}catch(s){return{kind:"error",message:s instanceof Error?s.message:"network error"}}}async function gr(e,t,n,o){const r=await dn(e,t);if(!r)return{kind:"error",message:"custom key not found"};const s=`/api/admin/templates/${encodeURIComponent(e)}/custom-translations/${r.id}`,a={...r.values??{},[n]:o};try{const l=await fetch(s,{method:"PUT",credentials:"same-origin",headers:je({"Content-Type":"application/json"}),body:JSON.stringify({values:a,expected_lock_version:r.lock_version})}),d=await l.json().catch(()=>null);return l.status===409?{kind:"error",message:"concurrent_modification"}:l.ok?{kind:"ok"}:{kind:"error",message:d?.message??`HTTP ${l.status}`}}catch(l){return{kind:"error",message:l instanceof Error?l.message:"network error"}}}async function dn(e,t){const n=`/api/admin/templates/${encodeURIComponent(e)}/custom-translations`;try{const o=await fetch(n,{method:"GET",credentials:"same-origin",headers:je()});if(!o.ok)return null;const s=(await o.json().catch(()=>null))?.data;return Array.isArray(s)?s.find(a=>a.translation_key===t)??null:null}catch{return null}}function bo(e,t,n,o){try{Pt.getInstance().setTranslationValue(e,t,n,o)}catch{}if(typeof window<"u")try{window.dispatchEvent(new CustomEvent(kt,{detail:{templateIdentifier:e,locale:t}}))}catch{}}const kt="g7le:translations-refreshed";async function en(e,t){if(typeof window>"u")return;const n=t??window.__g7EditorContentLocale;if(n){try{const o=Pt.getInstance();try{const r=await fetch(`/api/templates/${encodeURIComponent(e)}/config.json`,{headers:{Accept:"application/json"},credentials:"same-origin"}).then(a=>a.ok?a.json():null),s=r?.data?.cache_version??r?.cache_version;if(typeof s=="number"&&s>0){o.setCacheVersion(s);const a=window;a.G7Config&&(a.G7Config.cache_version=s)}}catch{}await o.loadTranslations(e,n,"/api",!0)}catch{}try{window.dispatchEvent(new CustomEvent(kt,{detail:{templateIdentifier:e,locale:n}}))}catch{}}}const tn=new Map;function hr(e,t,n,o){const r=tn.get(t)??{};r[n]=o,tn.set(t,r);try{Pt.getInstance().setTranslationValue(e,n,t,o)}catch{}if(typeof window<"u")try{window.dispatchEvent(new CustomEvent(kt,{detail:{templateIdentifier:e,locale:n}}))}catch{}}function mo(e,t,n){for(const[o,r]of Object.entries(n))hr(e,t,o,r)}function It(e,t){return tn.get(e)?.[t]}function Nb(e){const t=tn.get(e);return t?{...t}:void 0}function zb(){return tn.size>0}function ad(e,t){for(const[n,o]of tn.entries())for(const[r,s]of Object.entries(o))try{e.setTranslationValue(t,r,n,s)}catch{}}async function Mb(e){if(tn.size===0)return{ok:0,failed:[]};const t=Array.from(tn.entries());let n=0;const o=[];for(const[r,s]of t)try{const a=await dn(e,r);if(!a){o.push(r);continue}const l={...a.values??{},...s},d=`/api/admin/templates/${encodeURIComponent(e)}/custom-translations/${a.id}`;if(!(await fetch(d,{method:"PUT",credentials:"same-origin",headers:je({"Content-Type":"application/json"}),body:JSON.stringify({values:l,expected_lock_version:a.lock_version})})).ok){o.push(r);continue}n+=1}catch{o.push(r)}for(const[r]of t)o.includes(r)||tn.delete(r);try{await en(e)}catch{}return{ok:n,failed:o}}function Un(e,t){const n=`layout_editor.locale.${e}`,o=t(n);return o&&o!==n?o:e.toUpperCase()}function ld(){if(typeof window>"u")return[];const t=window.G7Config?.appConfig?.supportedLocales;return Array.isArray(t)?t.filter(n=>typeof n=="string"&&n.length>0):[]}function Lb({locales:e}={}){const{t}=tt(),{state:n,dispatch:o}=Je();u.useEffect(()=>{window.__g7EditorContentLocale=n.locale},[n.locale]);const r=e??ld(),s=r.includes(n.locale)?r:[n.locale,...r];if(s.length<=1)return i.jsxs("span",{className:"g7le-locale-switcher g7le-locale-switcher--single","data-testid":"g7le-locale-switcher-single",style:{fontSize:11,color:"#475569",padding:"0 4px"},children:[t("layout_editor.chrome.toolbar.locale_switcher_label"),": ",Un(n.locale,t)]});const a=l=>{if(l===n.locale)return;const d=n.locale;o({type:"SET_LOCALE",locale:l}),Et({op:"locale_switch",fromLocale:d,toLocale:l,timestamp:Date.now()})};return i.jsxs("div",{className:"g7le-locale-switcher","data-testid":"g7le-locale-switcher",role:"group","aria-label":t("layout_editor.chrome.toolbar.locale_switcher_label"),style:{display:"inline-flex",alignItems:"center",gap:4},children:[i.jsx("span",{style:{fontSize:11,color:"#475569"},children:t("layout_editor.chrome.toolbar.locale_switcher_label")}),i.jsx("div",{style:{display:"inline-flex",gap:2},children:s.map(l=>{const d=l===n.locale;return i.jsx("button",{type:"button","data-testid":`g7le-locale-${l}`,"aria-pressed":d,onClick:()=>a(l),style:{padding:"2px 8px",fontSize:12,cursor:"pointer",background:d?"#0f172a":"#f8fafc",color:d?"#fff":"#0f172a",border:"1px solid #cbd5e1",borderRadius:4},children:Un(l,t)},l)})})]})}const Pb=/\{\{?(p\d+)\}?\}/g;function Ob(e){const t=[];if(typeof e!="string"||e.length===0)return t;const n=new RegExp(Pb.source,"g");let o=0,r;for(;(r=n.exec(e))!==null;){const s=r.index,a=s+r[0].length;s>o&&t.push({kind:"text",raw:e.slice(o,s),start:o,end:s}),t.push({kind:"chip",raw:r[0],paramName:r[1],start:s,end:a}),o=a}return o{n.push({kind:"text",text:a}),r=!0};for(const a of t)a.kind==="text"?(o=a.raw,s(o),o=""):(r||s(""),n.push({kind:"chip",paramName:a.paramName}),r=!1);return r||s(""),n}function Wb(e,t,n,o){const r=[];return e.forEach((s,a)=>{if(s.kind==="chip"){if(s.paramName===t)return;r.push(`{${s.paramName}}`)}else{const l=s.text??"";if(a===n){const d=Math.max(0,Math.min(o,l.length));r.push(l.slice(0,d),`{${t}}`,l.slice(d))}else r.push(l)}}),r.join("")}function yr({value:e,onChange:t,t:n,paramLabels:o,onRequestInsert:r,onRemoveChip:s,testIdSuffix:a="pc",disabled:l=!1,hideInsertButton:d=!1,caretRef:c}){const p=u.useMemo(()=>Bb(e),[e]),f=u.useRef(null),[y,g]=u.useState(null),[m,h]=u.useState(null),[v,b]=u.useState(null),x=u.useMemo(()=>p.map(B=>B.kind==="chip"?`c:${B.paramName}`:"t").join("|"),[p]),k=u.useRef(null),w=u.useRef([]),_=u.useRef(!1);u.useLayoutEffect(()=>{const B=_.current;_.current=!1,!(!B&&k.current===x)&&(k.current=x,p.forEach((T,H)=>{if(T.kind!=="text")return;const L=w.current[H];L&&L.textContent!==(T.text??"")&&(L.textContent=T.text??"")}))},[x,p,e]);const S=()=>{const B=f.current;if(!B)return e;const T=[];for(const H of Array.from(B.querySelectorAll("[data-seg]")))H.dataset.seg==="chip"?T.push(`{${H.dataset.param??""}}`):T.push(H.textContent??"");return T.join("")},E=()=>{l||t(S())},C=u.useRef(null),N=u.useRef(!1),$=(B,T)=>{if(!l){B.preventDefault(),B.stopPropagation(),C.current={param:T,pointerId:B.pointerId},N.current=!1,g(T);try{B.currentTarget.setPointerCapture(B.pointerId)}catch{}}},A=B=>{const T=C.current;if(!T||B.pointerId!==T.pointerId)return;N.current=!0;const H=O(B.clientX,B.clientY);H&&(h({slotIdx:H.slotIdx,offset:H.offset}),b(Hb(f.current,H.slotIdx,H.offset)))},z=(B,T)=>{if(B.stopPropagation(),N.current){N.current=!1;return}s?.(T)},D=B=>{const T=C.current;if(!T||B.pointerId!==T.pointerId)return;try{B.currentTarget.releasePointerCapture(B.pointerId)}catch{}C.current=null,N.current&&typeof setTimeout=="function"&&setTimeout(()=>{N.current=!1},0);const H=O(B.clientX,B.clientY);if(g(null),h(null),b(null),!H)return;const L=f.current;if(!L)return;const P=Array.from(L.querySelectorAll("[data-seg]")).map(G=>G.dataset.seg==="chip"?{kind:"chip",paramName:G.dataset.param??""}:{kind:"text",text:G.textContent??""}),F=Wb(P,T.param,H.slotIdx,H.offset);F!==e&&(_.current=!0,t(F))},O=(B,T)=>{const H=f.current;if(!H)return null;const L=Array.from(H.querySelectorAll("[data-seg]")),I=H.ownerDocument,P=I.elementFromPoint(B,T);let F=P?.closest('[data-seg="text"]');if(F)return{slotIdx:L.indexOf(F),offset:jb(I,F,B,T)};const G=P?.closest('[data-seg="chip"]');if(G){const Y=L.indexOf(G),Z=G.getBoundingClientRect();if(B=0){const se=L[Y-1];return{slotIdx:Y-1,offset:(se.textContent??"").length}}if(Y+1({s:Y,i:Z})).filter(Y=>Y.s.dataset.seg==="text").pop();return V?{slotIdx:V.i,offset:(V.s.textContent??"").length}:null},M=()=>{l||!r||r(dd(f.current,e.length))};return u.useEffect(()=>{if(c)return c.current=()=>dd(f.current,e.length),()=>{c.current=null}},[c,e.length]),i.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:4},children:[i.jsxs("div",{ref:f,"data-testid":`g7le-chip-input-${a}`,className:"g7le-chip-input",style:Fb,children:[p.map((B,T)=>B.kind==="chip"?i.jsxs("span",{"data-seg":"chip","data-param":B.paramName,"data-testid":`g7le-chip-${a}-${B.paramName}`,contentEditable:!1,onPointerDown:H=>$(H,B.paramName??""),onPointerMove:A,onPointerUp:D,title:n("layout_editor.translation.placeholder_locked"),style:{...qb,...y===B.paramName?Gb:null,touchAction:"none"},children:[i.jsx("span",{"aria-hidden":"true",style:Yb,children:"⠿"}),i.jsx("span",{"aria-hidden":"true",style:{marginRight:3},children:"🔗"}),i.jsx("span",{style:Xb,children:o?.[B.paramName??""]??B.paramName}),s&&!l&&i.jsx("button",{type:"button","data-testid":`g7le-chip-remove-${a}-${B.paramName}`,"aria-label":n("layout_editor.inline_binding.clear"),title:n("layout_editor.inline_binding.clear"),onPointerDown:H=>{H.stopPropagation(),H.preventDefault()},onClick:H=>z(H,B.paramName??""),style:Jb,children:"✕"})]},`chip-${B.paramName}`):i.jsx("span",{ref:H=>{w.current[T]=H},"data-seg":"text",contentEditable:!l,suppressContentEditableWarning:!0,onInput:E,"data-testid":`g7le-chip-text-${a}-${T}`,style:{...Kb,...y?Vb:null,...m?.slotIdx===T?Ub:null}},`text-slot-${T}`)),v&&i.jsx("span",{"aria-hidden":"true","data-testid":`g7le-chip-caret-${a}`,style:{position:"absolute",left:v.left,top:v.top,width:2,height:v.height,background:"#2563eb",borderRadius:1,pointerEvents:"none",boxShadow:"0 0 0 1px rgba(37,99,235,0.3)"}})]}),i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6},children:[r&&!d&&i.jsxs("button",{type:"button","data-testid":`g7le-chip-insert-${a}`,onClick:M,disabled:l,style:Zb,children:["+ ",n("layout_editor.inline_binding.insert_data")]}),i.jsx("span",{style:Qb,children:n("layout_editor.translation.placeholder_locked")})]})]})}function jb(e,t,n,o){const r=(t.textContent??"").length;try{const a=e;let l=null,d=0;if(typeof a.caretRangeFromPoint=="function"){const c=a.caretRangeFromPoint(n,o);c&&(l=c.startContainer,d=c.startOffset)}else if(typeof a.caretPositionFromPoint=="function"){const c=a.caretPositionFromPoint(n,o);c&&(l=c.offsetNode,d=c.offset)}if(l&&t.contains(l))return Math.max(0,Math.min(d,r))}catch{}const s=t.getBoundingClientRect();return n>s.left+s.width/2?r:0}function Hb(e,t,n){if(!e)return null;const r=Array.from(e.querySelectorAll("[data-seg]"))[t];if(!r)return null;const s=e.getBoundingClientRect();try{const d=r.firstChild,c=e.ownerDocument;if(d&&d.nodeType===Node.TEXT_NODE&&(d.textContent??"").length>0){const p=c.createRange(),f=Math.max(0,Math.min(n,(d.textContent??"").length));p.setStart(d,f),p.setEnd(d,f);const y=p.getClientRects(),g=y.length>0?y[0]:p.getBoundingClientRect();if(g&&(g.height>0||g.width>=0))return{left:g.left-s.left,top:g.top-s.top,height:g.height||r.getBoundingClientRect().height}}}catch{}const a=r.getBoundingClientRect();return{left:(n>0?a.right:a.left)-s.left,top:a.top-s.top,height:a.height||16}}function dd(e,t){if(!e)return t;try{const n=e.ownerDocument.defaultView?.getSelection();if(!n||n.rangeCount===0)return t;const o=n.getRangeAt(0);let r=o.startContainer,s=null;for(;r&&r!==e;){if(r instanceof HTMLElement&&r.dataset.seg==="text"){s=r;break}r=r.parentNode}if(!s||!e.contains(s))return t;const a=Array.from(e.querySelectorAll("[data-seg]"));let l=0;for(const d of a){if(d===s)break;l+=d.dataset.seg==="chip"?`{${d.dataset.param??""}}`.length:(d.textContent??"").length}return l+o.startOffset}catch{return t}}const Fb={position:"relative",display:"flex",flexWrap:"wrap",alignItems:"center",gap:2,minHeight:30,padding:"4px 8px",border:"1px solid #cbd5e1",borderRadius:4,background:"#fff",fontSize:13,lineHeight:1.6},Kb={outline:"none",minWidth:8,minHeight:"1.2em",whiteSpace:"pre-wrap",color:"#0f172a",display:"inline-block"},Vb={boxShadow:"inset 0 -2px 0 #93c5fd",background:"#eff6ff",borderRadius:2},Ub={background:"#dbeafe",boxShadow:"inset 0 -2px 0 #2563eb"},qb={display:"inline-flex",alignItems:"center",gap:1,background:"linear-gradient(180deg,#eff6ff,#dbeafe)",color:"#1d4ed8",border:"1px dashed #60a5fa",borderRadius:9999,padding:"1px 8px 1px 4px",margin:"0 1px",fontSize:12,fontWeight:600,cursor:"grab",userSelect:"none",whiteSpace:"nowrap",boxShadow:"0 1px 2px rgba(37,99,235,0.18)",verticalAlign:"middle"},Gb={opacity:.5,borderStyle:"solid",cursor:"grabbing"},Yb={color:"#2563eb",opacity:.7,fontSize:11,marginRight:1,cursor:"grab"},Xb={textDecoration:"none"},Jb={marginLeft:4,width:14,height:14,lineHeight:"12px",padding:0,border:"none",borderRadius:9999,background:"rgba(29,78,216,0.12)",color:"#1d4ed8",fontSize:9,fontWeight:700,cursor:"pointer",display:"inline-flex",alignItems:"center",justifyContent:"center",flex:"0 0 auto"},Zb={fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#334155",padding:"3px 10px",cursor:"pointer"},Qb={fontSize:10,color:"#94a3b8"};function cd(e){e&&(e.style.height="auto",e.style.height=`${Math.max(e.scrollHeight,28)}px`)}function Rb(e){cd(e)}function em(e){const t=new Set;for(const n of e)for(const o of Object.keys(n.values??{}))t.add(o);return Array.from(t).sort()}function tm({templateIdentifier:e,layoutName:t,t:n,referencedKeys:o,onClose:r}){const[s,a]=u.useState([]),[l,d]=u.useState("idle"),[c,p]=u.useState(null),[f,y]=u.useState("all"),[g,m]=u.useState(new Set),[h,v]=u.useState({}),[b,x]=u.useState(null),[k,w]=u.useState({}),_=u.useCallback(async()=>{d("loading"),p(null);const T=await Dy(e,t);T.ok?(a(T.data),v({}),m(new Set),w({}),d("idle")):(p(T.message),d("error"))},[e,t]);u.useEffect(()=>{_()},[_]);const S=u.useMemo(()=>em(s),[s]),E=u.useMemo(()=>s.filter(T=>f==="all"?!0:T.status===f).slice().sort((T,H)=>{const L=T.updated_at?Date.parse(T.updated_at):0,I=H.updated_at?Date.parse(H.updated_at):0;return I!==L?I-L:H.id-T.id}),[s,f]),C=u.useMemo(()=>s.filter(T=>T.status==="orphaned"),[s]),N=u.useCallback(T=>{const H={...T.values??{}};for(const L of Object.keys(H)){const I=It(T.translation_key,L);I!==void 0&&(H[L]=I)}return H},[]),$=u.useCallback(T=>h[T.id]??N(T),[h,N]),A=u.useCallback((T,H,L)=>{v(I=>{const P=I[T]??s.find(F=>F.id===T)?.values??{};return{...I,[T]:{...P,[H]:L}}})},[s]),z=u.useCallback(async T=>{x(T.id),w(I=>({...I,[T.id]:""}));const H=$(T),L=await Ny(e,T.id,H,T.lock_version);if(L.ok)a(I=>I.map(P=>P.id===T.id?L.data:P)),v(I=>{const P={...I};return delete P[T.id],P});else{const I=L.status===409?n("layout_editor.translation_manager.conflict"):L.message;w(P=>({...P,[T.id]:I}))}x(null)},[e,$,n]),D=u.useCallback(T=>typeof window<"u"&&typeof window.confirm=="function"?window.confirm(n("layout_editor.translation_manager.delete_confirm",{count:T})):!0,[n]),O=u.useCallback(async T=>{if(!D(1))return;const H=await zy(e,T.id);H.ok?(a(L=>L.filter(I=>I.id!==T.id)),m(L=>{const I=new Set(L);return I.delete(T.id),I})):p(H.message)},[e,D]),M=u.useCallback(async T=>{if(T.length===0||!D(T.length))return;const H=await My(e,T);if(H.ok){const L=new Set(T);a(I=>I.filter(P=>!L.has(P.id))),m(new Set)}else p(H.message)},[e,D]),B=u.useCallback(T=>{m(H=>{const L=new Set(H);return L.has(T)?L.delete(T):L.add(T),L})},[]);return i.jsxs("div",{className:"g7le-translation-manager","data-testid":"g7le-translation-manager",style:nm,children:[i.jsxs("div",{"data-modal-drag-handle":!0,style:om,children:[i.jsx("span",{style:{fontSize:14,fontWeight:700},"data-testid":"g7le-translation-manager-title",children:n("layout_editor.translation_manager.title")}),i.jsx("button",{type:"button","aria-label":"close",onClick:r,style:rm,"data-testid":"g7le-translation-manager-close",children:"✕"})]}),i.jsxs("div",{style:im,children:[i.jsx("div",{style:{display:"inline-flex",gap:4},role:"tablist","aria-label":n("layout_editor.translation_manager.filter_label"),children:["all","active","orphaned"].map(T=>i.jsx("button",{type:"button",role:"tab","aria-selected":f===T,"data-active":f===T?"true":"false",onClick:()=>y(T),style:f===T?sm:ud,"data-testid":`g7le-translation-filter-${T}`,children:n(`layout_editor.translation_manager.filter_${T}`)},T))}),i.jsx("div",{style:{flex:1}}),g.size>0&&i.jsxs("button",{type:"button",onClick:()=>void M(Array.from(g)),style:pd,"data-testid":"g7le-translation-bulk-delete",children:["🗑 ",n("layout_editor.translation_manager.delete_selected",{count:g.size})]}),C.length>0&&i.jsxs("button",{type:"button",onClick:()=>void M(C.map(T=>T.id)),style:pd,"data-testid":"g7le-translation-purge-orphaned",children:["🧹 ",n("layout_editor.translation_manager.purge_orphaned",{count:C.length})]})]}),c&&i.jsx("div",{style:am,"data-testid":"g7le-translation-manager-error",children:c}),i.jsx("div",{style:lm,children:l==="loading"?i.jsx("div",{style:fd,"data-testid":"g7le-translation-manager-loading",children:n("layout_editor.translation_manager.loading")}):E.length===0&&l!=="error"?i.jsx("div",{style:fd,"data-testid":"g7le-translation-manager-empty",children:n("layout_editor.translation_manager.empty")}):i.jsx("div",{"data-testid":"g7le-translation-list",style:dm,children:E.map(T=>{const H=$(T),L=T.status==="orphaned",I=o?o.has(T.translation_key):null;return i.jsxs("div",{style:cm,"data-testid":`g7le-translation-row-${T.id}`,children:[i.jsxs("div",{style:um,children:[i.jsx("input",{type:"checkbox",checked:g.has(T.id),onChange:()=>B(T.id),"aria-label":n("layout_editor.translation_manager.select_aria",{key:T.translation_key}),"data-testid":`g7le-translation-select-${T.id}`}),i.jsx("code",{style:pm,children:T.translation_key}),L&&i.jsx("span",{style:fm,"data-testid":`g7le-translation-badge-orphaned-${T.id}`,children:n("layout_editor.translation_manager.badge_orphaned")}),I===!0&&i.jsx("span",{style:gm,"data-testid":`g7le-translation-badge-live-inuse-${T.id}`,children:n("layout_editor.translation_manager.badge_live_in_use")}),I===!1&&i.jsx("span",{style:hm,"data-testid":`g7le-translation-badge-live-unused-${T.id}`,children:n("layout_editor.translation_manager.badge_live_unused")}),i.jsx("div",{style:{flex:1}}),i.jsx("button",{type:"button",onClick:()=>void O(T),style:wm,"data-testid":`g7le-translation-delete-${T.id}`,children:"🗑"})]}),i.jsxs("div",{style:ym,children:[S.map(P=>{const F=H[P]??"",G=In(F).length>0;return i.jsxs("div",{style:bm,"data-testid":`g7le-translation-field-${T.id}-${P}`,children:[i.jsxs("label",{style:mm,title:P,children:[Un(P,n),i.jsxs("span",{style:vm,children:["(",P,")"]})]}),G?i.jsx("div",{style:{flex:1},"data-testid":`g7le-translation-chip-${T.id}-${P}`,children:i.jsx(yr,{value:F,onChange:V=>A(T.id,P,V),t:n,testIdSuffix:`${T.id}-${P}`})}):i.jsx("textarea",{value:F,rows:1,ref:Rb,onChange:V=>{A(T.id,P,V.currentTarget.value),cd(V.currentTarget)},style:_m,"data-testid":`g7le-translation-input-${T.id}-${P}`})]},P)}),i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8},children:[i.jsx("button",{type:"button",disabled:b===T.id,onClick:()=>void z(T),style:xm,"data-testid":`g7le-translation-save-${T.id}`,children:b===T.id?n("layout_editor.translation_manager.saving"):n("layout_editor.translation_manager.save")}),k[T.id]?i.jsx("span",{style:{fontSize:11,color:"#dc2626"},"data-testid":`g7le-translation-row-error-${T.id}`,children:k[T.id]}):null]})]})]},T.id)})})}),i.jsx("div",{style:km,children:i.jsx("button",{type:"button",onClick:r,style:Sm,"data-testid":"g7le-translation-manager-footer-close",children:n("layout_editor.translation_manager.close")})})]})}const nm={display:"flex",flexDirection:"column",minHeight:0,flex:1},om={display:"flex",alignItems:"center",justifyContent:"space-between",padding:"12px 16px",borderBottom:"1px solid #e2e8f0"},rm={border:"none",background:"transparent",fontSize:16,color:"#94a3b8",cursor:"pointer"},im={padding:"10px 16px",borderBottom:"1px solid #f1f5f9",display:"flex",gap:8,alignItems:"center"},ud={padding:"4px 10px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},sm={...ud,borderColor:"#2563eb",background:"#eff6ff",color:"#1d4ed8",fontWeight:600},pd={padding:"4px 10px",fontSize:12,border:"1px solid #fecaca",borderRadius:6,background:"#fff",color:"#dc2626",cursor:"pointer"},am={padding:"8px 16px",fontSize:12,color:"#b91c1c",background:"#fef2f2"},lm={padding:16,overflow:"auto",flex:1,minHeight:120,display:"flex",flexDirection:"column",gap:12},fd={fontSize:13,color:"#94a3b8",textAlign:"center",padding:"32px 0"},dm={display:"flex",flexDirection:"column",gap:20},cm={border:"1px solid #e2e8f0",borderRadius:8,padding:10,display:"flex",flexDirection:"column",gap:6},um={display:"flex",alignItems:"center",gap:8},pm={fontSize:11,background:"#f1f5f9",padding:"2px 6px",borderRadius:4,color:"#0f172a"},fm={fontSize:10,color:"#b45309",background:"#fef3c7",padding:"1px 6px",borderRadius:4,fontWeight:600},gm={fontSize:10,color:"#15803d",background:"#dcfce7",padding:"1px 6px",borderRadius:4,fontWeight:600},hm={fontSize:10,color:"#9f1239",background:"#ffe4e6",padding:"1px 6px",borderRadius:4,fontWeight:600},ym={display:"flex",flexDirection:"column",gap:6},bm={display:"flex",alignItems:"flex-start",gap:8},mm={fontSize:12,color:"#475569",minWidth:88,fontWeight:600,paddingTop:4,display:"flex",flexDirection:"column",lineHeight:1.2},vm={fontSize:10,color:"#94a3b8",fontWeight:400},_m={flex:1,padding:"4px 8px",fontSize:13,border:"1px solid #cbd5e1",borderRadius:4,color:"#0f172a",resize:"vertical",overflow:"hidden",minHeight:28,lineHeight:1.4,fontFamily:"inherit",boxSizing:"border-box"},xm={padding:"5px 12px",fontSize:12,border:"none",borderRadius:6,background:"#2563eb",color:"#fff",cursor:"pointer"},wm={padding:"4px 8px",fontSize:12,border:"1px solid #fecaca",borderRadius:6,background:"#fff",color:"#dc2626",cursor:"pointer"},km={padding:"10px 16px",borderTop:"1px solid #e2e8f0",display:"flex",justifyContent:"flex-end"},Sm={padding:"6px 14px",fontSize:13,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"};function Cm(){const e=Ft(),{state:t}=Je(),n=e?.document?.raw??null,o=t.templateIdentifier??"",r=t.selectedRoute?.layoutName??"",s=u.useCallback((c,p)=>{const f=n?.[c];return f===void 0?p:f},[n]),a=u.useCallback((c,p,...f)=>{e&&(f.length>=1?e.patchDocumentRaw(c,p,f[0]):e.patchDocumentRaw(c,p))},[e]),l=u.useCallback(async(c,p)=>!o||!r?null:Vn(o,r,c,p),[o,r]),d=u.useCallback(async(c,p,f)=>o?gr(o,c,p,f):null,[o]);return{raw:n,getValue:s,patch:a,createI18nKey:l,updateI18nKeyValue:d}}function $m(e){const t=e.__source;return t&&(t.kind==="module"||t.kind==="plugin")&&t.identifier?`${t.kind}:${t.identifier}`:"template"}function gd(e,t){if(!t)return null;const n=$m(e);if(n&&t.bySource){const a=t.bySource[n]?.[e.id];if(a!==void 0)return{value:a,match:{dataSourceId:e.id,resolvedBy:"spec_byId",sourceKey:n}}}const o=t.byDataSourceId?.[e.id];if(o!==void 0)return{value:o,match:{dataSourceId:e.id,resolvedBy:"spec_byId"}};const r=e.endpoint??"";if(t.byEndpointPattern&&typeof r=="string"&&r.length>0){for(const[s,a]of Object.entries(t.byEndpointPattern))if(Em(r,s))return{value:a,match:{dataSourceId:e.id,resolvedBy:"spec_byEndpoint"}}}return null}function Ri(e,t){const n=gd(e,t);if(n)return n;const o=e.fallback?.data;return o!==void 0?{value:{data:Array.isArray(o)?o:[o]},match:{dataSourceId:e.id,resolvedBy:"fallback"}}:{value:{},match:{dataSourceId:e.id,resolvedBy:"inferred"}}}function Em(e,t){const n=t.replace(/[.+?^${}()|[\]\\]/g,"\\$&").replace(/\*/g,".*");return new RegExp(`^${n}$`).test(e)}function Im(e={}){const{spec:t,overrideSpec:n,onMatch:o}=e;return{has(r){return!0},resolve(r){const s=n?gd(r,n):null,{value:a,match:l}=s??Ri(r,t);if(o)try{o(l)}catch{}return a}}}const hd=Lt(),yd=hd.DataBindingEngine,Am=hd.dataBindingEngine,Tm=new Set(["comment"]);function bd(e){if(!e||typeof e!="object")return[];const t=[];for(const[n,o]of Object.entries(e)){if(Tm.has(n)||!o||typeof o!="object")continue;const r=o;typeof r.expr!="string"||r.expr.length===0||t.push({id:n,label:typeof r.label=="string"?r.label:void 0,params:Array.isArray(r.params)?r.params:[],expr:r.expr,group:typeof r.group=="string"?r.group:void 0,source:r.__source})}return t}function Dm(e,t){let n=e.replace(/\{(\w+)\*\}(\s*\?\?\s*)?/g,(o,r,s)=>{const a=t[r],l=a==null?[]:String(a).split(",").map(d=>d.trim()).filter(Boolean);return l.length===0?"":l.join(" ?? ")+(s?" ?? ":"")});return n=n.replace(/\{(\w+)\}/g,(o,r)=>{const s=t[r];return s===void 0?o:s}),n}function br(e){const t=e.trim(),n=/^\{\{([\s\S]*)\}\}$/.exec(t);return n?n[1].trim():t}function md(e){const t=e.trim();return/^\{\{[\s\S]*\}\}$/.test(t)?t:`{{ ${t} }}`}function vd(e,t){const n={};for(const[r,s]of Object.entries(t))n[r]=typeof s=="string"?br(s):s;const o=Dm(e.expr,n);return md(o)}function Nm(e,t){if(typeof e!="string")return null;const n=_d(br(e));for(const o of t){const r=zm(n,o.expr,o.params);if(r)return{recipeId:o.id,params:r}}return null}function _d(e){return e.replace(/\s+/g," ").trim()}function xd(e){return e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}function zm(e,t,n){const o=[],r=new Set;let s="";const a=/\{(\w+)(\*?)\}(\s*\?\?\s*)?/g;let l=0,d;const c=_d(t);for(;(d=a.exec(c))!==null;){const g=c.slice(l,d.index);s+=xd(g).replace(/\\ /g,"\\s*");const m=d[2]==="*",h=!!d[3];m?(r.add(d[1]),s+="(.*?)",h&&(s+="(?:\\s*\\?\\?\\s*)?")):(s+="(.+?)",h&&(s+="\\s*\\?\\?\\s*")),o.push(d[1]),l=a.lastIndex}s+=xd(c.slice(l)).replace(/\\ /g,"\\s*");let p;try{p=new RegExp(`^${s}$`)}catch{return null}const f=p.exec(e);if(!f)return null;const y={};if(o.forEach((g,m)=>{let h=(f[m+1]??"").trim();r.has(g)&&(h=h.split(/\s*\?\?\s*/).map(v=>v.trim()).filter(Boolean).join(", ")),y[g]=h}),n.length>0){const g=new Set(n.map(m=>m.key));for(const m of Object.keys(y))g.has(m)||delete y[m]}return y}function Mm(e){const t=`x.${e.field}`,n=Lm(e.value);switch(e.cmp){case"=":return`${t} === ${n}`;case"!=":return`${t} !== ${n}`;case">":return`${t} > ${n}`;case"<":return`${t} < ${n}`;case">=":return`${t} >= ${n}`;case"<=":return`${t} <= ${n}`;case"includes":return`(${t} ?? []).includes(${n})`;default:return`${t} === ${n}`}}function Lm(e){const t=e.trim();return t==="true"||t==="false"||t!==""&&!Number.isNaN(Number(t))||/^['"].*['"]$/.test(t)||/[.([{]/.test(t)?t:`'${t.replace(/'/g,"\\'")}'`}function es(e){return!e||e.length===0?"true":e.map(Mm).join(" && ")}function Pm(e){const n=`(${(e.source??"").trim()||"[]"} ?? [])`;let o;switch(e.op){case"count":o=`${n}.filter(x => ${es(e.conditions)}).length`;break;case"sum":{const r=(e.sumField??"").trim()||"value";o=`${n}.filter(x => ${es(e.conditions)}).reduce((s, x) => s + (x.${r} ?? 0), 0)`;break}case"filter":o=`${n}.filter(x => ${es(e.conditions)})`;break;case"toOptions":{const r=(e.valueField??"").trim()||"value",s=(e.labelField??"").trim()||"label";o=`${n}.map(x => ({ value: x.${r}, label: x.${s} }))`;break}case"nth":{const r=(e.index??"").trim()||"0",s=(e.prop??"").trim();o=`${n}[${r}]${s?`?.${s}`:""}`;break}case"firstOf":{const r=(e.candidates??[]).map(l=>l.trim()).filter(Boolean),s=r.length>0?r.join(" ?? "):"''",a=(e.fallback??"").trim();o=a?`${s} ?? '${a.replace(/'/g,"\\'")}'`:s;break}case"literal":{const r=e.literalKind??"string",s=(e.literalValue??"").trim();r==="number"?o=s===""?"0":s:r==="boolean"?o=s==="true"?"true":"false":o=`'${s.replace(/'/g,"\\'")}'`;break}default:o="[]"}return md(o)}function Om(e){if(typeof e!="string")return null;const t=br(e).trim();let n=/^\((.+?)\s*\?\?\s*\[\]\)\.filter\(x => (.+)\)\.length$/.exec(t);return n?{key:"",op:"count",source:n[1].trim(),conditions:ts(n[2])}:(n=/^\((.+?)\s*\?\?\s*\[\]\)\.filter\(x => (.+)\)\.reduce\(\(s, x\) => s \+ \(x\.(\w+(?:\.\w+)*) \?\? 0\), 0\)$/.exec(t),n?{key:"",op:"sum",source:n[1].trim(),conditions:ts(n[2]),sumField:n[3]}:(n=/^\((.+?)\s*\?\?\s*\[\]\)\.filter\(x => (.+)\)$/.exec(t),n?{key:"",op:"filter",source:n[1].trim(),conditions:ts(n[2])}:(n=/^\((.+?)\s*\?\?\s*\[\]\)\.map\(x => \(\{ value: x\.(\w+(?:\.\w+)*), label: x\.(\w+(?:\.\w+)*) \}\)\)$/.exec(t),n?{key:"",op:"toOptions",source:n[1].trim(),valueField:n[2],labelField:n[3]}:(n=/^\((.+?)\s*\?\?\s*\[\]\)\[(.+?)\](?:\?\.(\w+(?:\.\w+)*))?$/.exec(t),n?{key:"",op:"nth",source:n[1].trim(),index:n[2].trim(),prop:n[3]}:null))))}function ts(e){const t=e.trim();if(t==="true")return[];const n=[];for(const o of Bm(t)){const r=Wm(o.trim());r&&n.push(r)}return n}function Bm(e){const t=[];let n=0,o=0;for(let r=0;r=|<=|>|<)\s*(.+)$/.exec(e),t){const n={"===":"=","!==":"!=",">":">","<":"<",">=":">=","<=":"<="};return{field:t[1],cmp:n[t[2]]??"=",value:wd(t[3])}}return null}function wd(e){const t=e.trim(),n=/^'(.*)'$/.exec(t)||/^"(.*)"$/.exec(t);return n?n[1]:t}function kd(e,t){const n=Nm(e,t);if(n)return{kind:"preset",recipeId:n.recipeId,params:n.params};const o=Om(e);return o?{kind:"custom",model:o}:{kind:"advanced"}}function jm(e){if(e==null)return"null";if(Array.isArray(e))return"list";switch(typeof e){case"number":return"number";case"boolean":return"boolean";case"string":return"string";default:return"object"}}function Sd(e,t){if(typeof e!="string"||e.trim()==="")return{ok:!1,reason:"empty"};const n=br(e);try{const o=Am.evaluateExpression(n,t??{},{skipCache:!0});return{ok:!0,value:o,type:jm(o)}}catch(o){return{ok:!1,reason:o instanceof Error?o.message:String(o)}}}function Cd(e,t){const n=Array.isArray(e?.data_sources)?e.data_sources:[],o=[];for(const r of n){const s=r?.id;if(typeof s!="string"||s.length===0)continue;const{value:a}=Ri(r,t?.sampleData),l=typeof r.label_key=="string"?r.label_key:void 0;o.push({id:s,sample:a,labelKey:l})}return o}function $d(e,t){const n=[];t?.sampleGlobal&&typeof t.sampleGlobal=="object"&&n.push({scope:"_global",tree:t.sampleGlobal});const o={},r={},s={},a=(l,d)=>{if(!(!d||typeof d!="object"||Array.isArray(d)))for(const[c,p]of Object.entries(d))if(p&&typeof p=="object"&&!Array.isArray(p)){const f=l[c]&&typeof l[c]=="object"&&!Array.isArray(l[c])?l[c]:l[c]={};a(f,p)}else c in l||(l[c]=p)};for(const l of t?.states?.groups??[])for(const d of l.items??[]){const c=d.initialState;c?.local&&a(o,c.local),c?.query&&a(r,c.query),c?.route&&a(s,c.route)}return Object.keys(o).length&&n.push({scope:"_local",tree:o}),Object.keys(r).length&&n.push({scope:"query",tree:r}),Object.keys(s).length&&n.push({scope:"route",tree:s}),e?.computed&&typeof e.computed=="object"&&!Array.isArray(e.computed)&&n.push({scope:"_computed",tree:e.computed}),n}function Ed(e,t){const n={};for(const r of t)r.scope!=="_computed"&&(n[r.scope]=r.tree);const o={};for(const[r,s]of Object.entries(e)){if(typeof s!="string"){o[r]=s;continue}const a=Sd(s,n);o[r]=a.ok?a.value:s}return o}function Hm(e,t){const n=Cd(e,t),o=$d(e,t),r={};for(const s of n)s.sample!==void 0&&(r[s.id]=s.sample);for(const s of o)s.scope==="_computed"?r[s.scope]=Ed(s.tree,o):r[s.scope]=s.tree;return r}function Id(e){const{raw:t,spec:n,evaluateComputed:o}=e;return u.useMemo(()=>{const r=Cd(t,n),s=$d(t,n);if(o){const a=s.findIndex(l=>l.scope==="_computed");if(a>=0){const l=Ed(s[a].tree,s);s[a]={scope:"_computed",tree:l}}}return Bi({dataSources:r,states:s,spec:n??void 0})},[t,n,o])}function Fm(e){const{raw:t,spec:n,pageType:o}=e;return u.useMemo(()=>{const r=Array.isArray(t?.data_sources)?t.data_sources:[],s=[];for(const d of r){const c=d?.id;if(typeof c!="string"||c.length===0)continue;const{value:p}=Ri(d,n?.sampleData),f=typeof d.label_key=="string"?d.label_key:void 0;s.push({id:c,sample:p,labelKey:f})}const a=[];n?.sampleGlobal&&typeof n.sampleGlobal=="object"&&a.push({scope:"_global",tree:n.sampleGlobal}),t?.computed&&typeof t.computed=="object"&&!Array.isArray(t.computed)&&a.push({scope:"_computed",tree:t.computed});const l=Bi({dataSources:s,states:a,spec:n??void 0});if(typeof o=="string"&&o!=="")for(const d of["title","description"])l.push({expression:`{{_seo.${o}.${d}}}`,source:"_seo",sourceId:"_seo",path:`${o}.${d}`,shape:"scalar",preview:""});return l},[t,n,o])}function Ad(e,t){const n=[],o=new Set,r=s=>{for(const a of s??[]){if(a.kind==="route"&&!a.isRedirect&&typeof a.path=="string"&&a.path.startsWith("/")&&!o.has(a.path)){o.add(a.path);const l=a.label.startsWith("$t:")?a.label.slice(3):a.label,d=a.label.startsWith("$t:")?t(l):l,c=d&&d!==l&&!d.startsWith("$t:")?d:"";n.push({value:a.path,label:c?`${c} (${a.path})`:a.path})}r(a.children)}};return r(e),n}function Td(e){const t=Array.isArray(e?.data_sources)?e.data_sources:[],n=[];for(const o of t){const r=o?.id;typeof r=="string"&&r.length>0&&n.push({value:r,label:r})}return n}function ns(e,t){const n=typeof e.id=="string"?e.id:"",o=typeof e.label_key=="string"?e.label_key:"";if(!o)return null;const r=o.startsWith("$t:")?o.slice(3):o,s=t(r);return s&&s!==r&&!s.startsWith("$t:")&&s!==n?s:null}function os(e,t){const n=e.__source;if(!n||typeof n!="object"||Array.isArray(n))return null;const o=n;if(o.kind!=="extension")return null;const r=o.extensionSourceType==="plugin"?t("layout_editor.data_sources.source.plugin"):t("layout_editor.data_sources.source.module"),s=typeof o.extensionIdentifier=="string"?o.extensionIdentifier:"",a=typeof o.extensionName=="string"?o.extensionName:"",l=a&&a!==s?`${a} (${s})`:s;return l?`${r}: ${l}`:r}function Km(e,t,n){const o=Array.isArray(e?.data_sources)?e.data_sources:[],r=[],s=new Set;for(const a of o){const l=a?.id;typeof l!="string"||l.length===0||s.has(l)||(s.add(l),r.push({id:l,friendly:ns(a,t),source:os(a,n)}))}return r}function Dd(e){const t=e?.states?.groups??[],n=new Set;for(const o of t)for(const r of o.items??[]){for(const s of Object.keys(r.initialState?.local??{}))n.add(s);for(const s of Object.keys(r.initialState?.global??{}))n.add(s)}return Array.from(n).map(o=>({value:o,label:o}))}function Vm(e,t){const n=Array.isArray(e?.modals)?e.modals:[],o=[],r=new Set;for(const s of n){const a=s?.id;typeof a!="string"||a.length===0||r.has(a)||(r.add(a),o.push({value:a,label:Um(s,a,t)}))}return o}function Um(e,t,n){const r=e?.meta?.editor_label;if(typeof r=="string"&&r.length>0)return Nd(r,n);const s=e?.title;return typeof s=="string"&&s.length>0?Nd(s,n):t}function Nd(e,t){if(e.startsWith("$t:")){const n=e.slice(3),o=t?t(n):n;return o&&o!==n?o:n}return e}const zd=ar("ActionRecipeEngine"),qm={api:"apiCall",nav:"navigate",showToast:"toast",setLocalState:"setState"},Gm=new Set(["comment"]);function vo(e){if(!e||typeof e!="object")return[];const t=[];for(const[n,o]of Object.entries(e)){if(Gm.has(n)||!o||typeof o!="object")continue;const r=o,s=r.build&&typeof r.build=="object"?r.build:typeof r.handler=="string"?{handler:r.handler}:void 0;!s||typeof s.handler!="string"||s.handler.length===0||t.push({id:n,label:typeof r.label=="string"?r.label:void 0,params:Array.isArray(r.params)?r.params:[],build:s})}return t}function nn(e){const t=/^\{\{\s*([\w.]+)\s*\}\}$/.exec(e);return t?t[1]:null}const mr="...";function Ym(e,t){return e.replace(/\{\{\s*([\w.]+)\s*\}\}/g,(n,o)=>{const r=t[o];return r==null?"":String(r)})}function rs(e,t){if(typeof e=="string"){const n=nn(e);return n!==null?n in t?t[n]:void 0:e.includes("{{")?Ym(e,t):e}if(Array.isArray(e))return e.map(n=>rs(n,t)).filter(n=>n!==void 0);if(e&&typeof e=="object"){const n={};for(const[o,r]of Object.entries(e)){if(o===mr&&typeof r=="string"){const d=nn(r),c=d!==null?t[d]:void 0;if(c&&typeof c=="object"&&!Array.isArray(c))for(const[p,f]of Object.entries(c))f===void 0||p===""||(n[p]=f);continue}const s=rs(r,t);if(s===void 0||Array.isArray(s)&&s.length===0&&(o==="onSuccess"||o==="onError"))continue;const a=nn(o),l=a!==null?a in t?String(t[a]):null:o;l===null||l===""||s&&typeof s=="object"&&!Array.isArray(s)&&Object.keys(s).length===0||(n[l]=s)}return n}return e}function Xm(e){const t=e.handler;if(typeof t!="string")return e;const n=qm[t];if(n)return zd.warn(`action recipe used forbidden handler "${t}" — corrected to "${n}" (core handler rules)`),{...e,handler:n};if(t==="apiCall"||t==="navigate"){const o=e.params;if(o&&typeof o=="object")for(const r of["target","onSuccess","onError"])r in o&&zd.warn(`action recipe build placed "${r}" inside params for handler "${t}" — should be top-level (core handler rules)`)}return e}function vr(e,t){const n=rs(e.build,t);if(typeof n.handler!="string"&&e.build.handler.includes("{{")&&(n.handler=e.build.handler),e.build.handler.includes("{{")){const o=new Set(e.params.filter(r=>r.required).map(r=>r.key));o.size>0&&Md(e.build,n,o)}return Xm(n)}function Md(e,t,n){if(!e||typeof e!="object"||Array.isArray(e)||!t||typeof t!="object"||Array.isArray(t))return;const o=e,r=t;for(const[s,a]of Object.entries(o))if(s!=="handler"){if(typeof a=="string"){const l=nn(a);l!==null&&n.has(l)&&!(s in r)&&(r[s]=a)}else if(a&&typeof a=="object"){if(!(s in r)||typeof r[s]!="object"||r[s]===null)if(JSON.stringify(a).includes("{{"))r[s]={};else continue;Md(a,r[s],n)}}}function Jm(e,t){if(!e||typeof e!="object")return null;const n=e.handler;if(typeof n!="string")return null;let o=null,r=-1;for(const s of t){const a=s.build.handler.includes("{{");if(!a&&s.build.handler!==n||a&&!Zm(s.build,e))continue;const l=Qm(s.build,e);if(l===null)continue;const d=Object.keys(l).length;d>r&&(r=d,o={recipeId:s.id,values:l})}return o}function Zm(e,t){if(typeof t.handler=="string"&&t.handler===e.handler)return!0;const n=e.params;if(!n||typeof n!="object")return!1;const o=t.params;if(!o||typeof o!="object")return!1;const r=o,s=Object.entries(n).filter(([,a])=>typeof a=="string"&&nn(a)!==null);return s.length===0?!1:s.every(([a])=>a in r)}function Qm(e,t){const n={},o=(r,s)=>{if(typeof r=="string"){const a=nn(r);return a!==null?(n[a]=s,!0):r.includes("{{")?typeof s=="string":r===s}if(Array.isArray(r))return Array.isArray(s);if(r&&typeof r=="object"){if(!s||typeof s!="object"||Array.isArray(s))return!1;const a=s,l=r[mr],d=typeof l=="string"?nn(l):null,c=new Set(Object.keys(r).filter(p=>p!==mr));for(const[p,f]of Object.entries(r))if(p!==mr){if(typeof f=="string"&&nn(f)!==null){const y=nn(f);p in a&&(n[y]=a[p]);continue}if(!(p in a)){if(p==="onSuccess"||p==="onError"||p==="params"||p==="identity_target")continue;return!1}if(!o(f,a[p]))return!1}if(d!==null){const p={};for(const[f,y]of Object.entries(a))c.has(f)||(p[f]=y);Object.keys(p).length>0&&(n[d]=p)}return!0}return r===s};return o(e,t)?n:null}function qn(e,t){const n=typeof e?.handler=="string"?e.handler:"",o=Jm(e,t);return o?{kind:"preset",recipeId:o.recipeId,values:o.values,handler:n}:{kind:"advanced",handler:n}}function Ld(e,t,n){const o=e?.label,r=o?n?n(o):o:"",s=[],a=e?.params??[];for(const l of a){if(l.advanced||l.widget==="action-list")continue;const d=t[l.key];if(d==null||d==="")continue;const c=Rm(d);if(c!==""&&(s.push(c),s.length>=2))break}return s.length===0?r:`${r} (${s.join(", ")})`}function Rm(e){return typeof e=="string"?e:typeof e=="number"||typeof e=="boolean"?String(e):Array.isArray(e)?`[${e.length}]`:e&&typeof e=="object"?"{…}":""}function Pd(e,t){const n={...e};return typeof t=="string"&&t.trim().length>0?n.if=t.trim():delete n.if,n}function is(e){const t=e?.if;return typeof t=="string"&&t.trim().length>0?t:null}const ev=/^\s*\$t:(custom\.[a-zA-Z0-9._-]+)\s*$/,tv=/^\s*\$t:([a-zA-Z0-9._-]+)\s*$/,nv=/\{\{.*?\}\}/;function Od(e,t){const n=typeof e=="string"?e:"",o=ev.exec(n);if(o){const a=o[1],l=t?t(a):"";return{customKey:a,binding:!1,paramKey:!1,displayValue:l}}const r=mt(n);if(r&&r.key.startsWith("custom.")){const a=t?t(r.key):"";return{customKey:r.key,binding:!1,paramKey:!0,displayValue:a}}if(nv.test(n))return{customKey:null,binding:!0,paramKey:!1,displayValue:n};const s=tv.exec(n);if(s){const a=s[1],l=t?t(a):"";return{customKey:null,binding:!1,paramKey:!1,displayValue:l&&l!==a?l:""}}return{customKey:null,binding:!1,paramKey:!1,displayValue:n}}function ov(){const{state:e}=Je(),t=e.templateIdentifier,n=e.locale,o=e.selectedRoute?.layoutName??null,r=u.useCallback(l=>{try{const c=Pt.getInstance().translate(l,{templateId:t,locale:n});return c&&c!==l?c:""}catch{return""}},[t,n]),s=u.useCallback(l=>Od(l,r),[r]),a=u.useCallback(async(l,d)=>{const c=Od(l,r);if(c.binding)return{kind:"blocked"};if(c.customKey){if(d===c.displayValue)return{kind:"noop",customKey:c.customKey};const y=await gr(t,c.customKey,n,d);return y.kind==="error"?{kind:"error",message:y.message}:(await en(t,n),{kind:"updated",customKey:c.customKey})}const p=await Vn(t,o,n,d);if(p.kind==="error"||!p.resource)return{kind:"error",message:p.message};const f=p.resource.translation_key;return await en(t,n),{kind:"created",customKey:f,token:`$t:${f}`}},[t,n,o,r]);return{locale:n,templateIdentifier:t,translate:r,classify:s,commitText:a}}async function ss(e,t,n,o,r,s,a,l,d,c){const p=c?qi(o,c):null,f=!!(p&&p.keyifiable),y=d?d(o):o,g=f?p.chipValue:Gl(y),h=`p${f?p.bindings.length:lr(y).length}`,v=Math.max(0,Math.min(r,g.length)),b=Wl(g,v,h),x=dr(b).replace(/\{\{?p\d+\}?\}/g," ").replace(/\s+/g," ").trim(),k=x.length>0?x:b,w=await Vn(e,t,n,k);if(w.kind==="error"||!w.resource)return{kind:"error",message:w.message};const _=w.resource.translation_key,S=In(b),E=w.resource.values??{},C={[n]:b};for(const z of Object.keys(E)){if(z===n)continue;let D=E[z]??"";for(const O of S)D=Vi(D,O);C[z]=D}mo(e,_,C);const N=$n(s,a,l);return{kind:"ok",text:`${f?Ul(`$t:${_}`,p):ql(`$t:${_}`,y)}|${h}=${N}`,key:_}}async function _r(e,t,n,o,r,s,a){let l=mt(n);if(!l&&typeof n=="string"){const h=/^\s*\$t:(custom\.[A-Za-z0-9._-]+)\s*$/.exec(n);h&&(l={key:h[1],params:[]})}if(!l)return{kind:"error",message:"not a param key"};if(!l.key.startsWith("custom."))return{kind:"error",message:"not a custom param key"};const d=db(l.params.map(h=>h.name)),c=$n(r,s,a),p=`${n.trim()}|${d}=${c}`,f=await dn(e,l.key),y=Nb(l.key)??{},g=new Set([...Object.keys(f?.values??{}),...Object.keys(y),t]);if(g.size===0)return{kind:"error",message:"custom key not found"};const m={};for(const h of g){const v=It(l.key,h)??f?.values?.[h]??"";m[h]=h===t?Wl(v,o,d):Vi(v,d)}return mo(e,l.key,m),{kind:"ok",text:p,paramName:d}}async function _o(e,t,n,o){return hr(e,t,n,o),{kind:"ok"}}async function rv(e,t,n){const o=await dn(e,t);if(!o)return{kind:"error",message:"custom key not found"};const r={};for(const[s,a]of Object.entries(o.values??{})){const l=It(t,s)??a;r[s]=n(l)}mo(e,t,r);try{const s=Pt.getInstance();for(const[a,l]of Object.entries(r))s.setTranslationValue(e,a,t,l)}catch{}return{kind:"ok"}}function Bd(e,t,n){return rv(e,t,o=>Jl(o,n))}function Wd(e,t){if(!(typeof window>"u"))try{window.dispatchEvent(new CustomEvent(kt,{detail:{templateIdentifier:e,locale:t}}))}catch{}}async function xr(e,t,n,o){const r=await Bd(e,t,n);return Wd(e,o),r}const iv=/^\s*\$t:(custom\.[a-zA-Z0-9._-]+)\s*$/;function sv(e,t){const n=s=>{const a=[],l=/\{\{?(p\d+)\}?\}/g;let d;for(;(d=l.exec(s))!==null;)a.push(d[1]);return a},o=n(e),r=n(t);return o.length!==r.length?!0:o.some((s,a)=>s!==r[a])}function jd(e){const t=e.text;if(typeof t!="string")return null;const n=iv.exec(t);if(n)return n[1];const o=mt(t);return o&&o.key.startsWith("custom.")?o.key:null}function as(e){const t={},n=mt(typeof e=="string"?e:"");for(const o of n?.params??[])t[o.name]=Vt(o.expression);return t}function av(e){return as(typeof e.text=="string"?e.text:"")}function Hd({customKey:e,templateIdentifier:t,t:n,paramLabels:o,locales:r,onRemoveParam:s}){const a=r??ld(),l=Ft(),d=o??{},[c,p]=u.useState({status:"idle",values:{}}),[f,y]=u.useState(!1),[g,m]=u.useState(!1),[h,v]=u.useState(null),b=u.useRef({});u.useEffect(()=>{let $=!1;if(!e){p({status:"not_a_key",values:{}});return}return p({status:"loading",values:{}}),(async()=>{const A=await dn(t,e);if($)return;if(!A){p({status:"error",values:{}});return}const z={...A.values??{}};for(const D of Object.keys(z)){const O=It(e,D);O!==void 0&&(z[D]=O)}for(const D of a){const O=It(e,D);O!==void 0&&(z[D]=O)}b.current={...z},p({status:"loaded",id:A.id,lockVersion:A.lock_version,values:z})})(),()=>{$=!0}},[e,t]),u.useEffect(()=>{if(typeof window>"u"||!e)return;const $=A=>{const z=A.detail;z?.templateIdentifier&&z.templateIdentifier!==t||p(D=>{if(D.status!=="loaded")return D;let O=!1;const M={...D.values};for(const B of Object.keys(M)){const T=It(e,B);T!==void 0&&T!==M[B]&&(M[B]=T,O=!0)}return O?(b.current={...b.current,...M},{...D,values:M}):D})};return window.addEventListener(kt,$),()=>window.removeEventListener(kt,$)},[e,t]);const x=u.useCallback(($,A)=>{p(z=>{const D=z.values[$]??"",O={...z,values:{...z.values,[$]:A}};return e&&sv(D,A)&&(hr(t,e,$,A),b.current={...b.current,[$]:A},l?.markDirty?.(),Wd(t,$)),O})},[e,t,l]),k=u.useCallback($=>{p(A=>{const z={};for(const[D,O]of Object.entries(A.values))z[D]=Jl(O,$);return b.current={...z},{...A,values:z}}),s?.($)},[s]),w=u.useMemo(()=>[...Object.values(c.values),...Object.values(b.current)].some(A=>In(A).length>0),[c.values]),_=u.useRef(null),S=u.useCallback(()=>{m(!0),_.current&&clearTimeout(_.current),_.current=setTimeout(()=>m(!1),1500)},[]);u.useEffect(()=>()=>{_.current&&clearTimeout(_.current)},[]);const E=u.useCallback(async()=>{if(m(!1),c.status!=="loaded"||c.id===void 0)return;const $=b.current;for(const[z,D]of Object.entries(c.values)){const O=$[z]??"";if(w){const M=In(O);if(M.length===0)continue;const B=In(D);if(!(M.length===B.length&&M.every((H,L)=>H===B[L]))){v(n("layout_editor.translation.placeholder_mismatch"));return}continue}if(Ki(O)&&!bb(O,D)){v(n("layout_editor.translation.token_mismatch"));return}}if(y(!0),v(null),w){mo(t,e??"",c.values),b.current={...c.values},l?.markDirty?.(),Et({op:"translation_field_update",translationKey:e,changedLocales:Object.keys(c.values),timestamp:Date.now()}),await new Promise(z=>setTimeout(z,0)),y(!1),S();return}const A=`/api/admin/templates/${encodeURIComponent(t)}/custom-translations/${c.id}`;try{const z=await fetch(A,{method:"PUT",credentials:"same-origin",headers:je({"Content-Type":"application/json"}),body:JSON.stringify({values:c.values,expected_lock_version:c.lockVersion??0})}),D=await z.json().catch(()=>null);if(z.status===409){v(n("layout_editor.translation.conflict"));return}if(!z.ok){v(D?.message??n("layout_editor.translation.save_failed"));return}const O=typeof D?.data?.lock_version=="number"?D.data.lock_version:(c.lockVersion??0)+1;p(M=>({...M,lockVersion:O})),b.current={...c.values},await en(t),Et({op:"translation_field_update",translationKey:e,changedLocales:Object.keys(c.values),timestamp:Date.now()}),S()}catch(z){v(z instanceof Error?z.message:n("layout_editor.translation.save_failed"))}finally{y(!1)}},[c,t,e,n]);if(c.status==="not_a_key"||!e)return i.jsx("div",{"data-testid":"g7le-translation-not-a-key",style:ls,children:n("layout_editor.translation.not_a_key")});if(c.status==="loading"||c.status==="idle")return i.jsx("div",{"data-testid":"g7le-translation-loading",style:ls,children:n("layout_editor.translation.loading")});if(c.status==="error")return i.jsx("div",{"data-testid":"g7le-translation-error",style:ls,children:n("layout_editor.translation.load_failed")});const C=new Set([...a,...Object.keys(c.values)]),N=Array.from(C);return i.jsxs("div",{"data-testid":"g7le-translation-field",style:{display:"flex",flexDirection:"column",gap:8},children:[i.jsxs("div",{"data-testid":"g7le-translation-key",style:lv,children:[n("layout_editor.translation.key_label"),": ",i.jsx("code",{style:dv,children:e})]}),N.map($=>{const A=c.values[$]??"",z=A.trim().length===0;return i.jsxs("div",{className:"g7le-translation-row","data-testid":`g7le-translation-row-${$}`,style:cv,children:[i.jsxs("label",{htmlFor:`g7le-translation-input-${$}`,style:{fontSize:12,color:"#475569",minWidth:72,display:"inline-flex",alignItems:"center",gap:4},children:[Un($,n),z&&i.jsx("span",{"data-testid":`g7le-translation-missing-${$}`,style:uv,children:n("layout_editor.translation.needs_translation")})]}),w?i.jsx("div",{style:{flex:1},"data-testid":`g7le-translation-chip-${$}`,children:i.jsx(yr,{value:A,onChange:D=>x($,D),t:n,paramLabels:d,testIdSuffix:$,onRemoveChip:s?k:void 0})}):i.jsx("input",{id:`g7le-translation-input-${$}`,type:"text","data-testid":`g7le-translation-input-${$}`,value:A,placeholder:z?n("layout_editor.translation.needs_translation"):"",onChange:D=>x($,D.currentTarget.value),onBlur:E,style:{flex:1,padding:"4px 8px",fontSize:13,border:"1px solid #cbd5e1",borderRadius:4,color:z?"#94a3b8":"#0f172a"}})]},$)}),i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8},children:[i.jsx("button",{type:"button","data-testid":"g7le-translation-save",disabled:f,onClick:E,style:pv,children:n(f?"layout_editor.translation.saving":"layout_editor.translation.save")}),g&&!f&&i.jsxs("span",{"data-testid":"g7le-translation-saved",style:{fontSize:11,color:"#16a34a",display:"inline-flex",alignItems:"center",gap:4},children:["✓ ",n("layout_editor.translation.saved")]}),h&&i.jsx("span",{"data-testid":"g7le-translation-save-error",style:{fontSize:11,color:"#dc2626"},children:h})]})]})}const ls={fontSize:12,color:"#94a3b8",padding:"16px 0",textAlign:"center"},lv={fontSize:12,color:"#475569"},dv={fontSize:11,background:"#f1f5f9",padding:"1px 4px",borderRadius:3,color:"#0f172a"},cv={display:"flex",alignItems:"center",gap:8},uv={fontSize:10,color:"#b45309",background:"#fef3c7",padding:"0 4px",borderRadius:3},pv={padding:"6px 12px",fontSize:12,border:"none",borderRadius:6,background:"#2563eb",color:"#fff",cursor:"pointer"};function Gn({anchorRef:e,open:t,onClose:n,children:o,placement:r="bottom",minWidth:s=240,maxWidth:a=360,margin:l=8,testid:d}){const c=u.useRef(null),[p,f]=u.useState(null),y=u.useCallback(()=>{const m=e.current,h=c.current;if(!m||!h)return;const v=m.getBoundingClientRect(),b=window.innerWidth,x=window.innerHeight,k=h.getBoundingClientRect(),w=Math.min(Math.max(k.width,s),a),_=k.height;let S=v.left;S+w+l>b&&(S=v.right-w),S=Math.min(Math.max(S,l),b-w-l);const E=x-v.bottom-l,C=v.top-l;let N,$;const A=r==="bottom",z=E>=Math.min(_,160),D=C>=Math.min(_,160);A&&z||A&&!D?(N=v.bottom+4,$=E):!A&&D||D?(N=v.top-4-Math.min(_,C),$=C):E>=C?(N=v.bottom+4,$=E):(N=l,$=C),f({left:S,top:N,maxHeight:Math.max($,120)})},[e,r,s,a,l]);if(u.useLayoutEffect(()=>{if(!t){f(null);return}y()},[t,y]),u.useEffect(()=>{if(!t)return;const m=()=>y();window.addEventListener("resize",m),window.addEventListener("scroll",m,!0);const h=b=>{const x=c.current,k=e.current,w=b.target;x?.contains(w)||k?.contains(w)||n()},v=b=>{b.key==="Escape"&&n()};return document.addEventListener("pointerdown",h,!0),document.addEventListener("keydown",v),()=>{window.removeEventListener("resize",m),window.removeEventListener("scroll",m,!0),document.removeEventListener("pointerdown",h,!0),document.removeEventListener("keydown",v)}},[t,y,n,e]),!t)return null;const g={...fv,minWidth:s,maxWidth:`min(${a}px, calc(100vw - ${l*2}px))`,...p?{left:p.left,top:p.top,maxHeight:p.maxHeight,visibility:"visible"}:{left:-9999,top:-9999,visibility:"hidden"}};return i.jsx("div",{ref:c,"data-testid":d,style:g,role:"dialog",children:o})}const fv={position:"fixed",zIndex:1e3,background:"#fff",border:"1px solid #cbd5e1",borderRadius:8,boxShadow:"0 4px 16px rgba(0,0,0,0.14)",padding:6,overflowY:"auto",boxSizing:"border-box"};function Fd(e,t,n){if(!t)return n;const o=e(t);return!o||o===t||o.startsWith("$t:")?n:o}function $t({candidates:e,t,onSelect:n,testIdSuffix:o,defaultOpen:r=!1,floating:s=!0}){const[a,l]=u.useState(r),[d,c]=u.useState(""),p=u.useRef(null),f=u.useMemo(()=>ji(e,"scalar").map(h=>({...h,resolvedLabel:Fd(t,h.labelKey,h.path||h.sourceId)})),[e,t]),y=u.useMemo(()=>Hi(f,d),[f,d]),g=i.jsxs(i.Fragment,{children:[i.jsx("input",{type:"text",autoFocus:!0,"data-testid":`g7le-inline-binding-search-input-${o}`,value:d,placeholder:t("layout_editor.inline_binding.search_placeholder"),onChange:m=>c(m.target.value),style:bv}),i.jsx("div",{style:mv,children:y.length===0?i.jsx("div",{style:vv,children:t("layout_editor.inline_binding.no_results")}):y.map(m=>i.jsxs("button",{type:"button","data-testid":`g7le-inline-binding-candidate-${m.expression}`,onClick:()=>{n(m),l(!1),c("")},style:_v,children:[i.jsxs("span",{style:{display:"flex",flexDirection:"column",alignItems:"flex-start"},children:[i.jsx("span",{style:{fontSize:12},children:m.resolvedLabel}),i.jsxs("span",{style:xv,children:[Fd(t,m.groupLabelKey,m.sourceId)," · ",m.path||m.sourceId]})]}),i.jsx("span",{style:wv,children:m.preview})]},m.expression))})]});return i.jsxs("div",{style:s?yv:{marginTop:4},children:[i.jsx("button",{ref:p,type:"button","data-testid":`g7le-inline-binding-search-toggle-${o}`,onClick:()=>l(m=>!m),title:t("layout_editor.inline_binding.search"),"aria-label":t("layout_editor.inline_binding.search"),"aria-expanded":a,style:gv,children:"🔍"}),s?i.jsx(Gn,{anchorRef:p,open:a,onClose:()=>l(!1),testid:`g7le-inline-binding-picker-${o}`,children:g}):a&&i.jsx("div",{"data-testid":`g7le-inline-binding-picker-${o}`,style:hv,children:g})]})}const gv={fontSize:13,lineHeight:1,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#334155",padding:"4px 8px",cursor:"pointer",textAlign:"center",whiteSpace:"nowrap"},hv={marginTop:4,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",padding:6},yv={display:"inline-block"},bv={width:"100%",padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,boxSizing:"border-box"},mv={marginTop:4,maxHeight:200,overflowY:"auto",display:"flex",flexDirection:"column",gap:2},vv={fontSize:11,color:"#94a3b8",padding:"8px 0",textAlign:"center"},_v={display:"flex",alignItems:"center",justifyContent:"space-between",gap:8,border:"none",background:"transparent",cursor:"pointer",padding:"6px 8px",borderRadius:4,textAlign:"left"},xv={fontSize:10,color:"#94a3b8"},wv={fontSize:10,color:"#64748b",fontFamily:"monospace",whiteSpace:"nowrap"},kv=["$localized"];function Yn(e){const t=e.trim();if(!t.startsWith("{{")||!t.endsWith("}}")||t.length<4)return null;let n=0;for(let o=0;ocn(n).decomposed)}function xo(e,t,n,o){switch(e.kind){case"leaf":Vd(e.text,t,n,o);return;case"conditional":xo(e.then,t,n,o),o.push({kind:"ellipsis",text:"⋯"});return;case"fallback":xo(e.primary,t,n,o);return;case"concat":e.parts.forEach(r=>xo(r,t,n,o));return;case"raw":default:o.push({kind:"text",text:e.source});return}}function Vd(e,t,n,o){const r=e.trim();if(/^\{\{([\s\S]*)\}\}$/.exec(r)&&!r.slice(2,-2).includes("}}")){o.push({kind:"chip",text:n(r)});return}if(r.startsWith("$t:")){const a=r.slice(3).split("|")[0];o.push({kind:"text",text:t(a)||a});return}for(const a of e.split(/(\{\{(?:[^}]|\}(?!\}))*\}\})/g))a!==""&&(/^\{\{[\s\S]*\}\}$/.test(a)?o.push({kind:"chip",text:n(a)}):o.push({kind:"text",text:a}))}function ds(e,t,n){const o=[];if(typeof e!="string"||e==="")return o;if(Yn(e)===null&&/\{\{/.test(e)){for(const a of e.split(/(\{\{(?:[^}]|\}(?!\}))*\}\})/g))if(!(a===void 0||a===""))if(/^\{\{[\s\S]*\}\}$/.test(a)){const l=cn(a);xo(l.node,t,n,o)}else Vd(a,t,n,o);return o}const s=cn(e);return xo(s.node,t,n,o),o}function Sv(e){if(typeof e!="string"||e.trim()==="")return"empty";const t=e.trim();if(Yn(t)===null)return"simple";const n=cn(e);return n.node.kind==="leaf"?"simple":n.decomposed?"expression":"raw"}function cn(e){const t=typeof e=="string"?e:"",n=t.trim();if(n==="")return{node:{kind:"leaf",text:t},decomposed:!1,original:t};const o=Yn(n);if(o===null)return{node:{kind:"leaf",text:t},decomposed:!1,original:t};let r;try{r=zv(o)}catch{r=null}if(r===null)return{node:{kind:"raw",source:o},decomposed:!1,original:t};if(r.kind==="leaf"||r.kind==="raw")return{node:r,decomposed:!1,original:t};if(r.kind==="fallback"&&r.op==="??"&&Cv(r.primary)&&$v(r.fallback))return{node:{kind:"leaf",text:t},decomposed:!1,original:t};const s=Xn(r,!0);return Bv(o,s)?{node:r,decomposed:!0,original:t}:{node:{kind:"raw",source:o},decomposed:!1,original:t}}function Xn(e,t=!1){const n=qd(e);return t?n:e.kind==="leaf"?e.text:`{{${n}}}`}function wo(e){return Xn({kind:"conditional",condition:{kind:"simple",left:"",op:"truthy",right:""},then:{kind:"leaf",text:typeof e=="string"?e:""},else:{kind:"leaf",text:""}},!1)}function cs(e){return Xn({kind:"fallback",op:"??",primary:{kind:"leaf",text:typeof e=="string"?e:""},fallback:{kind:"leaf",text:""}},!1)}function ko(e){switch(e.kind){case"leaf":return e.text;case"conditional":return ko(e.then);case"fallback":return ko(e.primary);case"concat":return e.parts.map(ko).join("");case"raw":default:return e.kind==="raw"?`{{${e.source}}}`:""}}function tM(e){return e}function wr(e){if(typeof e!="string"||e.trim()==="")return"";if(Yn(e)===null&&/\{\{/.test(e)){for(const n of e.split(/(\{\{(?:[^}]|\}(?!\}))*\}\})/g))if(!(n===void 0||n===""))return/^\{\{[\s\S]*\}\}$/.test(n)?ko(cn(n).node):n;return""}return ko(cn(e).node)}function Cv(e){if(e.kind!=="leaf")return!1;const t=/^\{\{([\s\S]*)\}\}$/.exec(e.text.trim());return t?Jn(t[1].trim()):!1}function $v(e){if(e.kind!=="leaf")return!1;const t=e.text.trim();return!(/\{\{/.test(t)||t.startsWith("$t:"))}function kr(e){return`'${e.replace(/\\/g,"\\\\").replace(/'/g,"\\'")}'`}function Ev(e){const t=[];let n=0,o=0;const r=e.length;for(;on&&t.push(kr(e.slice(n,o)));let s=o+2;for(;s=r)break;const a=e.slice(o+2,s).trim();t.push(a),o=s+2,n=o;continue}o++}return nSo(t,"concat")).join(" + ");case"fallback":return`${So(e.primary,"fallback")} ${e.op} ${So(e.fallback,"fallback")}`;case"conditional":return`${Av(e.condition)} ? ${So(e.then,"conditional")} : ${So(e.else,"conditional")}`;default:return""}}function So(e,t){const n=qd(e);return e.kind==="leaf"||e.kind==="raw"?n:e.kind==="conditional"?t==="conditional"?n:`(${n})`:e.kind==="fallback"?t==="concat"?`(${n})`:n:(e.kind==="concat",n)}const Dv=["===","!==","??","||","&&","==","!=",">=","<="];function Gd(e){const t=[];let n=0;const o=e.length;for(;n=o)throw new Error("unterminated-binding-in-string");d+=e.slice(l,c+2),l=c+2;continue}if(e[l]===a){d+=a,l++;break}if(a==="`"&&e[l]==="$"&&e[l+1]==="{")throw new Error("template-literal-interpolation-unsupported");d+=e[l],l++}if(l>o||d[d.length-1]!==a)throw new Error("unterminated-string");t.push({kind:"string",value:d,start:n,end:l}),n=l;continue}if(r==="{"&&e[n+1]==="{"){let a=n+2;for(;a"||r==="<"){t.push({kind:"op",value:r,start:n,end:n+1}),n++;continue}if(r==="!"){t.push({kind:"op",value:"!",start:n,end:n+1}),n++;continue}if(r==="("){t.push({kind:"lparen",value:"(",start:n,end:n+1}),n++;continue}if(r===")"){t.push({kind:"rparen",value:")",start:n,end:n+1}),n++;continue}if(r==="["){t.push({kind:"lbracket",value:"[",start:n,end:n+1}),n++;continue}if(r==="]"){t.push({kind:"rbracket",value:"]",start:n,end:n+1}),n++;continue}if(r==="."){t.push({kind:"dot",value:".",start:n,end:n+1}),n++;continue}if(/[0-9]/.test(r)){let a=n;for(;a=n)return"";const o=this.toks[t].start,r=this.toks[n-1].end;return this.src.slice(o,r)}parseFallback(){let t=this.parseConcat(),n=this.peek();for(;n&&n.kind==="op"&&(n.value==="??"||n.value==="||");){const o=n.value;this.next();const r=this.parseConcat();t={kind:"fallback",op:o,primary:t,fallback:r},n=this.peek()}return t}parseConcat(){const t=this.parseUnaryOrPrimary(),n=[t];let o=this.peek();for(;o&&o.kind==="op"&&o.value==="+";)this.next(),n.push(this.parseUnaryOrPrimary()),o=this.peek();return n.length===1?t:{kind:"concat",parts:n}}parseUnaryOrPrimary(){return this.parsePrimary()}parsePrimary(){const t=this.peek();if(!t)throw new Error("unexpected-end");if(t.kind==="lparen"){this.next();const n=this.parseTernary(),o=this.next();if(!o||o.kind!=="rparen")throw new Error("expected-rparen");return n}if(t.kind==="string")return this.next(),{kind:"leaf",text:Xd(t.value)};if(t.kind==="tkey")return this.next(),{kind:"leaf",text:t.value};if(t.kind==="binding")return this.next(),{kind:"leaf",text:t.value};if(t.kind==="ident"&&kv.includes(t.value)&&this.toks[this.pos+1]?.kind==="lparen"){const n=this.tryParsePathWrapperCall(t.value);if(n!==null)return n;throw new Error("path-wrapper-non-simple-arg")}if(t.kind==="ident")return{kind:"leaf",text:`{{${this.parsePath()}}}`};if(t.kind==="number")return this.next(),{kind:"leaf",text:t.value};throw new Error(`unparseable-primary:${t.kind}`)}parsePath(){let t="";const n=this.next();if(!n||n.kind!=="ident")throw new Error("expected-ident");for(t+=n.value;;){const o=this.peek();if(!o)break;if(o.kind==="op"&&o.value==="?"&&this.toks[this.pos+1]?.kind==="dot"){this.next(),this.next();const r=this.next();if(!r||r.kind!=="ident")throw new Error("expected-ident-after-optional");t+="?."+r.value;continue}if(o.kind==="dot"){this.next();const r=this.next();if(!r||r.kind!=="ident")throw new Error("expected-ident-after-dot");t+="."+r.value;continue}if(o.kind==="lbracket"){this.next();const r=this.next(),s=this.next();if(!s||s.kind!=="rbracket")throw new Error("expected-rbracket");if(r&&(r.kind==="number"||r.kind==="string")){t+=`[${r.value}]`;continue}throw new Error("non-literal-index")}if(o.kind==="lparen")throw new Error("call-expression");break}return t}tryParsePathWrapperCall(t){const n=this.pos;this.next();const o=this.next();if(!o||o.kind!=="lparen")return this.pos=n,null;let r;try{if(this.peek()?.kind!=="ident")return this.pos=n,null;r=this.parsePath()}catch{return this.pos=n,null}const s=this.next();return!s||s.kind!=="rparen"?(this.pos=n,null):{kind:"leaf",text:`{{${t}(${r})}}`}}}function zv(e){return new Nv(e).parse()}const Mv=["===","!==","==","!=",">=","<=",">","<"];function Lv(e,t){const n=e.trim();if(n==="")return{kind:"raw",source:n};if(n===Ud)return{kind:"simple",left:"",op:"truthy",right:""};if(n.startsWith("!")){const s=n.slice(1).trim(),a=Yd(s);return Jn(a)?{kind:"simple",left:a,op:"falsy",right:""}:{kind:"raw",source:n}}const o=Pv(n);if(o){const{left:s,op:a,right:l}=o,d=s.trim(),c=l.trim();return Jn(d)&&(c==="''"||c==='""')?{kind:"simple",left:d,op:a,right:""}:Jn(d)&&Ov(c)?{kind:"simple",left:d,op:a,right:c}:{kind:"raw",source:n}}const r=Yd(n);return Jn(r)?{kind:"simple",left:r,op:"truthy",right:""}:{kind:"raw",source:n}}function Pv(e){let t=0,n=null;for(let o=0;o=o||!/[A-Za-z_$]/.test(t[n]))return!1;for(n++;n=o||!/[A-Za-z_$]/.test(t[n]))return!1;for(n++;n=2&&(t[0]==="'"||t[0]==='"'||t[0]==="`")?t.slice(1,-1).replace(/\\(['"`\\])/g,"$1"):t}function Bv(e,t){try{return Sr(e)===Sr(t)}catch{return!1}}function Sr(e){const t=Yn(e);return t!==null?Sr(t):Gd(e).map(o=>{if(o.kind==="string")return`S:${Xd(o.value)}`;if(o.kind==="binding"){const r=/^\{\{([\s\S]*)\}\}$/.exec(o.value);return`B:${r?Sr(r[1].trim()):o.value.replace(/\s+/g,"")}`}return o.kind==="tkey"?`S:${o.value}`:o.kind==="op"?`O:${o.value}`:o.kind==="ident"||o.kind==="number"?`I:${o.value}`:o.kind==="dot"?".":o.kind==="lbracket"?"[":o.kind==="rbracket"?"]":o.kind==="lparen"||o.kind==="rparen"?"":`X:${o.value}`}).join("")}function Zn({active:e,testid:t}){return i.jsx("div",{"data-testid":t,"data-active":e?"true":"false","aria-hidden":"true",style:e?jv:Wv})}const Wv={height:0,margin:0,transition:"height 80ms"},jv={height:3,margin:"1px 0",borderRadius:2,background:"#2563eb",boxShadow:"0 0 0 1px #93c5fd"};function us(e){const{length:t,onMove:n,canDrag:o,canDropAt:r}=e,[s,a]=u.useState(null),[l,d]=u.useState(null),c=u.useCallback(m=>{o&&!o(m)||(a(m),d(m))},[o]),p=u.useCallback(()=>{a(null),d(null)},[]),f=u.useCallback((m,h)=>{if(s===null)return;const v=h==="before"?m:m+1;if(r&&!r(s,v)){d(null);return}d(v)},[s,r]),y=u.useCallback(()=>{if(s!==null&&l!==null){const m=l>s?l-1:l;m!==s&&(!r||r(s,l))&&n(s,m)}a(null),d(null)},[s,l,n,r]),g=u.useCallback(m=>s!==null&&l===m&&m<=t,[s,l,t]);return{dragIndex:s,dropIndex:l,dragging:s!==null,onDragStart:c,onDragEnd:p,onDragOverItem:f,onDrop:y,isDropTarget:g}}const Hv={text:"",expression:"{{false ? '' : ''}}",fallback:"{{'' ?? ''}}",data:""};function Jd(e,t){const n=En(e);let o=t;const r=n.map(s=>({id:o++,kind:s.kind==="literal"?"text":"expression",value:s.raw}));return r.length===0&&r.push({id:o++,kind:"text",value:""}),{segs:r,nextSeq:o}}function ps({value:e,onChange:t,t:n,candidates:o,testidPrefix:r="g7le-seg-value",renderLeafInput:s}){const a=u.useRef(0),l=u.useRef(e),d=u.useMemo(()=>{const k=Jd(e,a.current);return a.current=k.nextSeq,k.segs},[]),[c,p]=u.useState(d),[f,y]=u.useState(!1);if(e!==l.current){l.current=e;const k=c.map(w=>w.value).join("");if(e!==k){const w=Jd(e,a.current);a.current=w.nextSeq,p(w.segs)}}const g=u.useCallback(k=>{p(k);const w=k.map(_=>_.value).join("");l.current=w,t(w)},[t]),m=u.useCallback((k,w)=>{g(c.map(_=>_.id===k?{..._,value:w??""}:_))},[c,g]),h=u.useCallback(k=>{const w=c.filter(_=>_.id!==k);g(w.length>0?w:[{id:a.current++,kind:"text",value:""}])},[c,g]),v=u.useCallback(k=>{g([...c,{id:a.current++,kind:k,value:Hv[k]}])},[c,g]),b=u.useCallback((k,w)=>{if(k===w||k<0||w<0||k>=c.length||w>=c.length)return;const _=c.slice(),[S]=_.splice(k,1);_.splice(w,0,S),g(_)},[c,g]),x=us({length:c.length,onMove:b});return i.jsxs("div",{"data-testid":r,style:Vv,children:[i.jsx("div",{style:Uv,children:n("layout_editor.value_tree.segmented_hint")}),c.map((k,w)=>i.jsxs(u.Fragment,{children:[i.jsx(Zn,{active:x.isDropTarget(w),testid:`${r}-dropline-${w}`}),i.jsx(Kv,{seg:k,index:w,t:n,candidates:o,testidPrefix:r,renderLeafInput:s,onValueChange:_=>m(k.id,_),onRemove:()=>h(k.id),dragging:x.dragIndex===w,onDragStartCard:()=>x.onDragStart(w),onDragEndCard:x.onDragEnd,onDragOverCard:_=>x.onDragOverItem(w,_),onDropCard:x.onDrop})]},k.id)),i.jsx(Zn,{active:x.isDropTarget(c.length),testid:`${r}-dropline-end`}),i.jsxs("div",{style:Zv,"data-testid":`${r}-add-row`,children:[i.jsxs("button",{type:"button","data-testid":`${r}-add-text`,onClick:()=>v("text"),style:Cr,children:["+ ",n("layout_editor.value_tree.segment.text")]}),i.jsxs("button",{type:"button","data-testid":`${r}-add-expression`,onClick:()=>v("expression"),style:Cr,children:["+ ",n("layout_editor.value_tree.segment.expression")]}),i.jsxs("button",{type:"button","data-testid":`${r}-add-fallback`,onClick:()=>v("fallback"),style:Cr,children:["+ ",n("layout_editor.value_tree.segment.fallback")]}),i.jsxs("button",{type:"button","data-testid":`${r}-add-data`,onClick:()=>v("data"),style:Cr,children:["+ ",n("layout_editor.value_tree.segment.data")]})]}),i.jsxs("div",{style:e_,children:[i.jsxs("button",{type:"button","data-testid":`${r}-source-toggle`,"aria-expanded":f,onClick:()=>y(k=>!k),style:t_,children:[""," ",n("layout_editor.value_tree.show_source")," ",f?"▴":"▾"]}),f&&i.jsx("code",{"data-testid":`${r}-source-code`,style:n_,children:c.map(k=>k.value).join("")})]})]})}function Fv({t:e,candidates:t,onPick:n,testidPrefix:o}){return!t||t.length===0?i.jsx("div",{"data-testid":`${o}-data-empty`,style:Rv,children:e("layout_editor.value_tree.segment.data_no_candidates")}):i.jsxs("div",{"data-testid":`${o}-data-picker`,style:{minWidth:0},children:[i.jsx("div",{style:Qv,children:e("layout_editor.value_tree.segment.data_pick_hint")}),i.jsx($t,{candidates:t,t:e,onSelect:r=>{n($n(r.sourceId,r.path,"scalar"))},testIdSuffix:`${o}-data`,defaultOpen:!0,floating:!0})]})}function Kv({seg:e,index:t,t:n,candidates:o,testidPrefix:r,renderLeafInput:s,onValueChange:a,onRemove:l,dragging:d,onDragStartCard:c,onDragEndCard:p,onDragOverCard:f,onDropCard:y}){const g=e.kind==="text"?"layout_editor.value_tree.segment.text":e.kind==="data"?"layout_editor.value_tree.segment.data":e.kind==="fallback"?"layout_editor.value_tree.segment.fallback":"layout_editor.value_tree.segment.expression";return i.jsxs("div",{"data-testid":`${r}-card-${t}`,"data-seg-kind":e.kind,style:d?{...Zd,opacity:.5}:Zd,onDragOver:m=>{m.preventDefault();const h=m.currentTarget.getBoundingClientRect();f(m.clientY{m.preventDefault(),y()},children:[i.jsxs("div",{style:qv,children:[i.jsx("span",{"data-testid":`${r}-drag-${t}`,style:Xv,draggable:!0,onDragStart:c,onDragEnd:p,title:n("layout_editor.value_tree.drag_reorder"),"aria-label":n("layout_editor.value_tree.drag_reorder"),role:"button",children:"⠿"}),i.jsx("span",{style:Gv,children:n(g)}),i.jsx("button",{type:"button","data-testid":`${r}-remove-${t}`,onClick:l,title:n("layout_editor.value_tree.segment.remove"),"aria-label":n("layout_editor.value_tree.segment.remove"),style:Jv,children:"✕"})]}),i.jsx("div",{style:Yv,children:e.kind==="data"&&e.value.trim()===""?i.jsx(Fv,{t:n,candidates:o,onPick:a,testidPrefix:`${r}-field-${t}`}):s?s({value:e.value,onChange:a,t:n,candidates:o,testidPrefix:`${r}-field-${t}`}):i.jsx(vt,{value:e.value,onChange:a,t:n,candidates:o,testidPrefix:`${r}-field-${t}`,enableExpressionTree:!0,expressionSourceToggle:!1})})]})}const Vv={display:"flex",flexDirection:"column",gap:6,width:"100%",minWidth:0},Uv={fontSize:11,color:"#64748b"},Zd={display:"flex",flexDirection:"column",gap:4,padding:"6px 8px",border:"1px solid #e2e8f0",borderLeft:"3px solid #6366f1",borderRadius:6,background:"#f8fafc",minWidth:0},qv={display:"flex",alignItems:"center",gap:6,minWidth:0},Gv={fontSize:10,fontWeight:600,color:"#6366f1",flex:1,minWidth:0},Yv={minWidth:0},Xv={color:"#94a3b8",cursor:"grab",fontSize:14,userSelect:"none"},Jv={border:"none",background:"transparent",color:"#94a3b8",cursor:"pointer",fontSize:12,padding:2,flexShrink:0},Zv={display:"flex",flexWrap:"wrap",gap:6,marginTop:2},Qv={fontSize:11,color:"#64748b",marginBottom:2},Rv={fontSize:11,color:"#94a3b8",padding:"4px 0"},Cr={padding:"4px 10px",fontSize:11,border:"1px dashed #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",whiteSpace:"nowrap"},e_={display:"flex",flexDirection:"column",gap:4,marginTop:2},t_={alignSelf:"flex-start",padding:"3px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer",fontFamily:"monospace"},n_={fontSize:11,background:"#0f172a",color:"#e2e8f0",padding:"6px 8px",borderRadius:4,wordBreak:"break-all",fontFamily:"monospace"},o_=[{value:"truthy",labelKey:"layout_editor.value_tree.cond.op_truthy"},{value:"falsy",labelKey:"layout_editor.value_tree.cond.op_falsy"},{value:"===",labelKey:"layout_editor.value_tree.cond.op_eq"},{value:"!==",labelKey:"layout_editor.value_tree.cond.op_neq"},{value:">",labelKey:"layout_editor.value_tree.cond.op_gt"},{value:"<",labelKey:"layout_editor.value_tree.cond.op_lt"},{value:">=",labelKey:"layout_editor.value_tree.cond.op_gte"},{value:"<=",labelKey:"layout_editor.value_tree.cond.op_lte"}];function Qd({node:e,onChange:t,t:n,candidates:o,testidPrefix:r="g7le-value-tree",depth:s=0,showSourceToggle:a=!0,renderLeafInput:l}){const[d,c]=u.useState(!1),p=s===0,f=i.jsx(Qn,{node:e,onChange:t,t:n,candidates:o,testidPrefix:r,depth:s,renderLeafInput:l});return p?a?i.jsxs("div",{"data-testid":r,style:ec,children:[f,i.jsxs("div",{style:k_,children:[i.jsxs("button",{type:"button","data-testid":`${r}-source-toggle`,"aria-expanded":d,onClick:()=>c(y=>!y),style:S_,children:[""," ",n("layout_editor.value_tree.show_source")," ",d?"▴":"▾"]}),d&&i.jsx("code",{"data-testid":`${r}-source-code`,style:C_,children:Xn(e,!1)})]})]}):i.jsx("div",{"data-testid":r,style:ec,children:f}):f}function Qn({node:e,onChange:t,t:n,candidates:o,testidPrefix:r,depth:s,renderLeafInput:a}){switch(e.kind){case"leaf":return i.jsx(ps,{value:e.text,onChange:l=>t({kind:"leaf",text:l}),t:n,candidates:o,testidPrefix:`${r}-leaf`,renderLeafInput:a});case"conditional":return i.jsx(r_,{node:e,onChange:t,t:n,candidates:o,testidPrefix:r,depth:s,renderLeafInput:a});case"fallback":return i.jsx(i_,{node:e,onChange:t,t:n,candidates:o,testidPrefix:r,depth:s,renderLeafInput:a});case"concat":return i.jsx(a_,{node:e,onChange:t,t:n,candidates:o,testidPrefix:r,depth:s,renderLeafInput:a});case"raw":default:return i.jsxs("div",{"data-testid":`${r}-raw`,style:$_,children:[i.jsx("code",{style:oc,children:e.source??""}),i.jsxs("span",{style:rc,children:["🔒 ",n("layout_editor.value_tree.raw_code_only")]})]})}}function r_({node:e,onChange:t,t:n,candidates:o,testidPrefix:r,depth:s,renderLeafInput:a}){const l=u.useCallback(p=>t({...e,condition:p}),[e,t]),d=u.useCallback(p=>t({...e,then:p}),[e,t]),c=u.useCallback(p=>t({...e,else:p}),[e,t]);return i.jsxs("div",{"data-testid":`${r}-conditional`,style:fs(s),children:[i.jsx(l_,{condition:e.condition,onChange:l,t:n,candidates:o,testidPrefix:r}),i.jsx($r,{label:n("layout_editor.value_tree.cond.when_true"),testid:`${r}-then`,children:i.jsx(Qn,{node:e.then,onChange:d,t:n,candidates:o,testidPrefix:`${r}-then`,depth:s+1,renderLeafInput:a})}),i.jsx($r,{label:n("layout_editor.value_tree.cond.when_false"),testid:`${r}-else`,children:i.jsx(Qn,{node:e.else,onChange:c,t:n,candidates:o,testidPrefix:`${r}-else`,depth:s+1,renderLeafInput:a})})]})}function i_({node:e,onChange:t,t:n,candidates:o,testidPrefix:r,depth:s,renderLeafInput:a}){const l=u.useCallback(c=>t({...e,primary:c}),[e,t]),d=u.useCallback(c=>t({...e,fallback:c}),[e,t]);return i.jsxs("div",{"data-testid":`${r}-fallback`,style:fs(s),children:[i.jsx($r,{label:n("layout_editor.value_tree.fallback.primary"),testid:`${r}-primary`,children:i.jsx(Qn,{node:e.primary,onChange:l,t:n,candidates:o,testidPrefix:`${r}-primary`,depth:s+1,renderLeafInput:a})}),i.jsx($r,{label:n(e.op==="??"?"layout_editor.value_tree.fallback.when_empty":"layout_editor.value_tree.fallback.when_falsy"),testid:`${r}-fallback-branch`,children:i.jsx(Qn,{node:e.fallback,onChange:d,t:n,candidates:o,testidPrefix:`${r}-fallbackb`,depth:s+1,renderLeafInput:a})})]})}const s_={text:()=>({kind:"leaf",text:""}),data:()=>({kind:"leaf",text:""}),expression:()=>({kind:"conditional",condition:{kind:"simple",left:"",op:"truthy",right:""},then:{kind:"leaf",text:""},else:{kind:"leaf",text:""}}),fallback:()=>({kind:"fallback",op:"??",primary:{kind:"leaf",text:""},fallback:{kind:"leaf",text:""}})};function a_({node:e,onChange:t,t:n,candidates:o,testidPrefix:r,depth:s,renderLeafInput:a}){const l=u.useCallback((g,m)=>{const h=e.parts.slice();h[g]=m,t({...e,parts:h})},[e,t]),d=u.useCallback(g=>{g.length===0?t({kind:"leaf",text:""}):g.length===1?t(g[0]):t({...e,parts:g})},[e,t]),c=u.useCallback(g=>d(e.parts.filter((m,h)=>h!==g)),[e.parts,d]),p=u.useCallback(g=>d([...e.parts,s_[g]()]),[e.parts,d]),f=u.useCallback((g,m)=>{if(g===m||g<0||m<0||g>=e.parts.length||m>=e.parts.length)return;const h=e.parts.slice(),[v]=h.splice(g,1);h.splice(m,0,v),d(h)},[e.parts,d]),y=us({length:e.parts.length,onMove:f});return i.jsxs("div",{"data-testid":`${r}-concat`,style:fs(s),children:[i.jsx("div",{style:b_,children:n("layout_editor.value_tree.concat.hint")}),e.parts.map((g,m)=>i.jsxs(u.Fragment,{children:[i.jsx(Zn,{active:y.isDropTarget(m),testid:`${r}-part-dropline-${m}`}),i.jsxs("div",{"data-testid":`${r}-part-card-${m}`,style:y.dragIndex===m?{...nc,opacity:.5}:nc,onDragOver:h=>{h.preventDefault();const v=h.currentTarget.getBoundingClientRect();y.onDragOverItem(m,h.clientY{h.preventDefault(),y.onDrop()},children:[i.jsxs("div",{style:m_,children:[i.jsx("span",{"data-testid":`${r}-part-handle-${m}`,style:x_,draggable:!0,onDragStart:()=>y.onDragStart(m),onDragEnd:y.onDragEnd,title:n("layout_editor.value_tree.drag_reorder"),"aria-label":n("layout_editor.value_tree.drag_reorder"),role:"button",children:"⠿"}),i.jsx("span",{style:v_,children:n("layout_editor.value_tree.concat.part",{n:m+1})}),i.jsx("button",{type:"button","data-testid":`${r}-part-remove-${m}`,onClick:()=>c(m),title:n("layout_editor.value_tree.segment.remove"),"aria-label":n("layout_editor.value_tree.segment.remove"),style:w_,children:"✕"})]}),i.jsx("div",{style:{minWidth:0},children:i.jsx(Qn,{node:g,onChange:h=>l(m,h),t:n,candidates:o,testidPrefix:`${r}-part-${m}`,depth:s+1,renderLeafInput:a})})]})]},m)),i.jsx(Zn,{active:y.isDropTarget(e.parts.length),testid:`${r}-part-dropline-end`}),i.jsxs("div",{style:__,"data-testid":`${r}-part-add-row`,children:[i.jsxs("button",{type:"button","data-testid":`${r}-part-add-text`,onClick:()=>p("text"),style:Er,children:["+ ",n("layout_editor.value_tree.segment.text")]}),i.jsxs("button",{type:"button","data-testid":`${r}-part-add-expression`,onClick:()=>p("expression"),style:Er,children:["+ ",n("layout_editor.value_tree.segment.expression")]}),i.jsxs("button",{type:"button","data-testid":`${r}-part-add-fallback`,onClick:()=>p("fallback"),style:Er,children:["+ ",n("layout_editor.value_tree.segment.fallback")]}),i.jsxs("button",{type:"button","data-testid":`${r}-part-add-data`,onClick:()=>p("data"),style:Er,children:["+ ",n("layout_editor.value_tree.segment.data")]})]})]})}function l_({condition:e,onChange:t,t:n,candidates:o,testidPrefix:r}){if(e.kind==="raw")return i.jsxs("div",{"data-testid":`${r}-cond-raw`,style:f_,children:[i.jsx("span",{style:tc,children:n("layout_editor.value_tree.cond.label")}),i.jsx("code",{style:oc,children:e.source}),i.jsxs("span",{style:rc,children:["🔒 ",n("layout_editor.value_tree.cond.code_only")]})]});const s=e,a=s.op!=="truthy"&&s.op!=="falsy";return i.jsxs("div",{"data-testid":`${r}-cond`,style:p_,children:[i.jsx("span",{style:tc,children:n("layout_editor.value_tree.cond.label")}),i.jsx(Rd,{testid:`${r}-cond-left`,value:s.left,onCommit:l=>t({...s,left:l}),placeholder:n("layout_editor.value_tree.cond.field_placeholder"),candidates:o,t:n}),i.jsx("select",{"data-testid":`${r}-cond-op`,value:s.op,onChange:l=>t({...s,op:l.target.value}),style:y_,children:o_.map(l=>i.jsx("option",{value:l.value,children:n(l.labelKey)},l.value))}),a&&i.jsx(Rd,{testid:`${r}-cond-right`,value:s.right,onCommit:l=>t({...s,right:l}),placeholder:n("layout_editor.value_tree.cond.value_placeholder"),candidates:o,t:n,quoteDataPick:!0})]})}function Rd({testid:e,value:t,onCommit:n,placeholder:o,candidates:r,t:s,quoteDataPick:a=!1}){const[l,d]=u.useState(null),c=l!==null?l:t,p=()=>{l!==null&&(l!==t&&n(l),d(null))},f=(y,g)=>{const m=$n(y,g,"scalar"),h=/^\{\{\s*([\s\S]*?)\s*\}\}$/.exec(m.trim());let v=h?h[1]:m;v=v.replace(/\s*\?\?\s*''$/,"").trim(),n(a?`'${v}'`:v),d(null)};return i.jsxs("span",{style:g_,children:[i.jsx("input",{type:"text","data-testid":e,value:c,onChange:y=>d(y.target.value),onBlur:p,onKeyDown:y=>{y.key==="Enter"&&(y.preventDefault(),p())},placeholder:o,style:h_}),r&&r.length>0&&s&&i.jsx($t,{candidates:r,t:s,onSelect:y=>f(y.sourceId,y.path),testIdSuffix:`${e}-data`,floating:!0})]})}function $r({label:e,testid:t,children:n}){return i.jsxs("div",{"data-testid":t,style:d_,children:[i.jsx("span",{style:c_,children:e}),i.jsx("div",{style:u_,children:n})]})}const ec={display:"flex",flexDirection:"column",gap:6,width:"100%",minWidth:0},fs=e=>({display:"flex",flexDirection:"column",gap:6,padding:"8px",border:"1px solid #e2e8f0",borderLeft:`3px solid ${e%2===0?"#6366f1":"#0ea5e9"}`,borderRadius:6,background:e%2===0?"#f8fafc":"#fefefe",minWidth:0}),d_={display:"flex",flexDirection:"column",gap:3,minWidth:0},c_={fontSize:11,fontWeight:600,color:"#475569"},u_={paddingLeft:8,minWidth:0},p_={display:"flex",flexWrap:"wrap",gap:4,alignItems:"center",minWidth:0},f_={display:"flex",flexWrap:"wrap",gap:4,alignItems:"center",minWidth:0},tc={fontSize:11,fontWeight:600,color:"#0f172a",whiteSpace:"nowrap"},g_={display:"inline-flex",alignItems:"flex-start",gap:2,flex:"1 1 80px",minWidth:60},h_={flex:"1 1 80px",minWidth:60,padding:"4px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6},y_={padding:"4px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff"},b_={fontSize:11,color:"#94a3b8"},nc={display:"flex",flexDirection:"column",gap:4,padding:"6px 8px",border:"1px solid #e2e8f0",borderLeft:"3px solid #6366f1",borderRadius:6,background:"#fff",minWidth:0},m_={display:"flex",alignItems:"center",gap:6,minWidth:0},v_={fontSize:10,fontWeight:600,color:"#6366f1",flex:1,minWidth:0},__={display:"flex",flexWrap:"wrap",gap:6,marginTop:2},x_={color:"#94a3b8",cursor:"grab",fontSize:14,userSelect:"none"},w_={border:"none",background:"transparent",color:"#94a3b8",cursor:"pointer",fontSize:12,padding:2,flexShrink:0},Er={padding:"4px 10px",fontSize:11,border:"1px dashed #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",whiteSpace:"nowrap"},k_={display:"flex",flexDirection:"column",gap:4,marginTop:2},S_={alignSelf:"flex-start",padding:"3px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer",fontFamily:"monospace"},C_={fontSize:11,background:"#0f172a",color:"#e2e8f0",padding:"6px 8px",borderRadius:4,wordBreak:"break-all",fontFamily:"monospace"},$_={display:"flex",flexDirection:"column",gap:2,minWidth:0},oc={fontSize:11,background:"#f1f5f9",padding:"3px 6px",borderRadius:4,color:"#0f172a",wordBreak:"break-all",fontFamily:"monospace"},rc={fontSize:10,color:"#64748b"};function vt({value:e,onChange:t,t:n,placeholder:o,testidPrefix:r="g7le-i18n-text-field",locales:s,candidates:a,enableExpressionTree:l=!1,expressionTreeCollapsible:d=!1,expressionSourceToggle:c=!0}){const{locale:p,templateIdentifier:f,commitText:y,classify:g,translate:m}=ov(),[h,v]=u.useState(null),[b,x]=u.useState(!1),[k,w]=u.useState(!1),[_,S]=u.useState(!1),[E,C]=u.useState(!1),[N,$]=u.useState(!1),[,A]=u.useState(0);u.useEffect(()=>{if(typeof window>"u")return;const T=H=>{const L=H.detail;L?.templateIdentifier&&L.templateIdentifier!==f||A(I=>I+1)};return window.addEventListener(kt,T),()=>window.removeEventListener(kt,T)},[f]);const z=g(e);u.useEffect(()=>{v(null),$(!1)},[p,e]);const D=h!==null?h:z.displayValue,O=u.useCallback(async()=>{if(h!==null){if(h===z.displayValue){v(null);return}x(!0);try{const T=await y(e,h);T.kind==="created"&&T.token?(t(T.token),Et({op:"prop_i18n_create_key",sourceState:"plain_text",translationKey:T.customKey,toLocale:p,valueLength:h.length,timestamp:Date.now()})):T.kind==="updated"&&Et({op:"prop_i18n_update_value",sourceState:"custom_key",translationKey:T.customKey,toLocale:p,valueLength:h.length,timestamp:Date.now()})}finally{x(!1),v(null)}}},[h,z.displayValue,y,e,t,p]),M=u.useCallback(T=>{typeof e=="string"&&(t(ho(e,T)),z.customKey&&xr(f,z.customKey,T,p))},[e,t,z.customKey,f,p]);if(z.binding){if(l&&typeof e=="string"){const T=Kd(e),H=cn(e),L=!T&&Kt(e)!==null,I=(T||H.decomposed)&&!L;if(I&&d&&!_)return i.jsx(A_,{value:e,t:n,translate:m,onEdit:()=>S(!0),testidPrefix:`${r}-preview`});const F=I&&(d||T)?i.jsx(ps,{value:e,onChange:G=>t(G),t:n,candidates:a,testidPrefix:`${r}-seg`}):H.decomposed?i.jsx(Qd,{node:H.node,onChange:G=>t(Xn(G,!1)),t:n,candidates:a,testidPrefix:`${r}-tree`,showSourceToggle:c}):null;if(F){if(!d)return F;const G=wr(e),V=G?ds(G,Y=>{const Z=m(Y);return Z&&Z!==Y&&!Z.startsWith("$t:")?Z:""},Y=>Vt(Y)):[];return i.jsxs("div",{"data-testid":`${r}-builder`,style:{display:"flex",flexDirection:"column",gap:6,width:"100%",minWidth:0},children:[i.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",gap:6},children:[i.jsxs("button",{type:"button","data-testid":`${r}-to-plain`,onClick:()=>C(!0),style:P_,title:n("layout_editor.value_tree.to_plain"),"aria-label":n("layout_editor.value_tree.to_plain"),children:["↩ ",n("layout_editor.value_tree.to_plain")]}),i.jsxs("button",{type:"button","data-testid":`${r}-builder-collapse`,onClick:()=>S(!1),style:ac,title:n("layout_editor.value_tree.collapse"),"aria-label":n("layout_editor.value_tree.collapse"),children:["▴ ",n("layout_editor.value_tree.collapse")]})]}),E&&i.jsxs("div",{"data-testid":`${r}-to-plain-confirm`,style:O_,children:[i.jsx("p",{style:B_,children:n("layout_editor.value_tree.to_plain_lead")}),i.jsx("div",{style:W_,children:V.length===0?i.jsx("span",{style:{color:"#94a3b8"},children:n("layout_editor.prop_i18n.placeholder")}):V.map((Y,Z)=>Y.kind==="chip"?i.jsxs("span",{style:ic,children:["🔗 ",Y.text]},Z):i.jsx("span",{style:Y.kind==="ellipsis"?sc:void 0,children:Y.text},Z))}),i.jsxs("p",{style:j_,children:["⚠ ",n("layout_editor.value_tree.to_plain_warn")]}),i.jsxs("div",{style:{display:"flex",justifyContent:"flex-end",gap:6},children:[i.jsx("button",{type:"button","data-testid":`${r}-to-plain-confirm-cancel`,onClick:()=>C(!1),style:ac,children:n("layout_editor.value_tree.to_plain_cancel")}),i.jsx("button",{type:"button","data-testid":`${r}-to-plain-confirm-ok`,onClick:()=>{C(!1),S(!1),t(wr(e)||void 0)},style:H_,children:n("layout_editor.value_tree.to_plain_ok")})]})]}),F]})}}return l&&typeof e=="string"&&a&&a.length>0&&Kt(e)!==null?i.jsx(T_,{value:e,onChange:t,t:n,candidates:a,testidPrefix:`${r}-data`}):i.jsxs("div",{"data-testid":`${r}-binding`,style:G_,children:[i.jsx("code",{style:Y_,children:typeof e=="string"?e:""}),i.jsxs("span",{style:X_,children:["🔗 ",n("layout_editor.prop_i18n.bound_code_only")]})]})}if(!N&&typeof e=="string"&&Kl(e)){const T=Vl(e);return i.jsxs("div",{"data-testid":`${r}-settings-ref`,style:V_,children:[i.jsx("div",{"data-testid":`${r}-settings-ref-chips`,style:U_,children:T.map((H,L)=>H.kind==="chip"?i.jsxs("span",{style:q_,children:["🔗 ",H.label]},L):i.jsx("span",{children:H.label},L))}),i.jsxs("button",{type:"button","data-testid":`${r}-settings-ref-edit`,onClick:()=>$(!0),style:lc,title:n("layout_editor.value_tree.edit"),"aria-label":n("layout_editor.value_tree.edit"),children:["✎ ",n("layout_editor.value_tree.edit")]})]})}const B=i.jsxs("button",{type:"button","data-testid":`${r}-toggle`,title:`${n("layout_editor.prop_i18n.edit_languages")} (${Un(p,n)})`,"aria-label":`${n("layout_editor.prop_i18n.edit_languages")} (${Un(p,n)})`,"aria-expanded":k,onClick:()=>w(T=>!T),style:lc,children:["🌐 ",z.customKey?k?"⌃":"⌄":"+"]});return i.jsxs("div",{"data-testid":r,style:{display:"flex",flexDirection:"column",gap:4,width:"100%"},children:[i.jsx("div",{style:F_,children:z.paramKey&&z.customKey?i.jsx(E_,{customKey:z.customKey,value:typeof e=="string"?e:"",onChange:t,t:n,candidates:a,testidPrefix:r,showExprButton:l&&d,onToExpression:()=>{S(!0),t(wo(typeof e=="string"?e:""))},langToggle:B}):i.jsxs(i.Fragment,{children:[i.jsx("input",{type:"text","data-testid":`${r}-preview`,value:D,placeholder:o??n("layout_editor.prop_i18n.placeholder"),onChange:T=>v(T.target.value),onBlur:()=>{O(),N&&$(!1)},disabled:b,style:K_}),a&&a.length>0&&i.jsx(I_,{value:typeof e=="string"?e:"",customKey:z.customKey,onChange:t,t:n,candidates:a,testidPrefix:r}),l&&d&&i.jsx("button",{type:"button","data-testid":`${r}-to-expr`,onClick:()=>{S(!0),t(wo(typeof e=="string"?e:""))},title:n("layout_editor.value_tree.to_expression"),"aria-label":n("layout_editor.value_tree.to_expression"),style:Co,children:"ƒx"}),B]})}),k&&(z.customKey?i.jsx(Hd,{customKey:z.customKey,templateIdentifier:f,t:n,paramLabels:as(e),locales:s,onRemoveParam:M},z.customKey):i.jsx("div",{"data-testid":`${r}-expand-hint`,style:J_,children:n("layout_editor.prop_i18n.create_first")}))]})}function E_({customKey:e,value:t,onChange:n,t:o,candidates:r,testidPrefix:s,showExprButton:a=!1,onToExpression:l,langToggle:d}){const{state:c}=Je(),p=c.templateIdentifier,f=c.locale,y=Ft(),[g,m]=u.useState(null),h=u.useRef(""),v=u.useRef(null),b=u.useRef(null),x=u.useRef(null),[k,w]=u.useState(null),[_,S]=u.useState(!1),E=as(t),C=u.useCallback(async()=>{const D=It(e,f);if(D!==void 0){h.current=D,m(D);return}const M=(await dn(p,e))?.values?.[f]??"";h.current=M,m(M)},[e,p,f]);u.useEffect(()=>{let D=!1;return(async()=>{const O=It(e,f);if(O!==void 0){D||(h.current=O,m(O));return}const M=await dn(p,e);if(D)return;const B=M?.values?.[f]??"";h.current=B,m(B)})(),()=>{D=!0}},[e,p,f]),u.useEffect(()=>{if(typeof window>"u")return;const D=O=>{const M=O.detail;if(M?.templateIdentifier&&M.templateIdentifier!==p)return;const B=It(e,f);B!==void 0&&B!==g&&(h.current=B,m(B))};return window.addEventListener(kt,D),()=>window.removeEventListener(kt,D)},[e,p,f,g]);const N=u.useCallback(D=>{m(D),_o(p,e,f,D).then(()=>{y?.markDirty?.()})},[p,e,f,y]),$=u.useCallback(D=>{n(ho(t,D)),xr(p,e,D,f).then(()=>{C(),y?.markDirty?.()})},[t,n,p,e,f,C,y]),A=u.useCallback(async D=>{if(!(k===null||_)){S(!0);try{g!==null&&g!==h.current&&(await _o(p,e,f,g),h.current=g);const O=await _r(p,f,t,k,D.sourceId,D.path,"scalar");O.kind==="ok"&&(n(O.text),await C(),y?.markDirty?.())}finally{S(!1),w(null)}}},[k,_,g,t,p,e,f,n,C,y]),z=!!r&&r.length>0;return i.jsxs("div",{"data-testid":`${s}-chipfield-wrap`,style:{flex:1,minWidth:0,display:"flex",alignItems:"center",gap:4},children:[i.jsx("div",{ref:v,"data-testid":`${s}-chipfield`,style:{flex:1,minWidth:0},children:g===null?i.jsx("div",{"data-testid":`${s}-chipfield-loading`,style:{fontSize:11,color:"#94a3b8",padding:"4px 0"},children:o("layout_editor.translation.loading")}):i.jsx(yr,{value:g,onChange:N,t:o,paramLabels:E,testIdSuffix:`${s}-field`,onRequestInsert:z?D=>w(D):void 0,onRemoveChip:$,hideInsertButton:!0,caretRef:x})}),z&&i.jsx("button",{ref:b,type:"button","data-testid":`${s}-plus-data-btn`,onClick:()=>w(x.current?x.current():g?.length??0),title:o("layout_editor.inline_binding.insert_data"),"aria-label":o("layout_editor.inline_binding.insert_data"),style:Co,children:"+"}),a&&l&&i.jsx("button",{type:"button","data-testid":`${s}-to-expr`,onClick:l,title:o("layout_editor.value_tree.to_expression"),"aria-label":o("layout_editor.value_tree.to_expression"),style:Co,children:"ƒx"}),d,z&&i.jsx(Gn,{anchorRef:b,open:k!==null,onClose:()=>w(null),children:i.jsx("div",{"data-testid":`${s}-chipfield-picker`,children:i.jsx($t,{candidates:r,t:o,onSelect:D=>void A(D),testIdSuffix:`${s}-field-insert`,defaultOpen:!0,floating:!1})})})]})}function I_({value:e,customKey:t,onChange:n,t:o,candidates:r,testidPrefix:s}){const{state:a}=Je(),l=a.templateIdentifier,d=a.selectedRoute?.layoutName??null,c=a.locale,p=Ft(),[f,y]=u.useState(!1),[g,m]=u.useState(!1),h=u.useRef(null),v=u.useCallback(k=>{const w=o(k);return w&&w!==k&&!w.startsWith("$t:")?w:""},[o]),b=u.useCallback(k=>k.replace(/\$t:[a-zA-Z0-9._-]+/g,w=>v(w.slice(3))).replace(/\s+/g," ").trim(),[v]),x=u.useCallback(async k=>{if(!g){m(!0);try{if(t){const w=await _r(l,c,e,Number.MAX_SAFE_INTEGER,k.sourceId,k.path,"scalar");w.kind==="ok"&&(n(w.text),p?.markDirty?.())}else{const w=await ss(l,d,c,e,e.length,k.sourceId,k.path,"scalar",b,_=>o(_));w.kind==="ok"&&(n(w.text),p?.markDirty?.())}}finally{m(!1),y(!1)}}},[g,t,l,d,c,e,n,p,o,b]);return i.jsxs("div",{"data-testid":`${s}-plus-data`,style:{display:"inline-block"},children:[i.jsx("button",{ref:h,type:"button","data-testid":`${s}-plus-data-btn`,disabled:g,onClick:()=>y(k=>!k),title:o("layout_editor.inline_binding.insert_data"),"aria-label":o("layout_editor.inline_binding.insert_data"),style:Co,children:"+"}),i.jsx(Gn,{anchorRef:h,open:f,onClose:()=>y(!1),children:i.jsx($t,{candidates:r,t:o,onSelect:k=>void x(k),testIdSuffix:`${s}-plus-insert`,defaultOpen:!0,floating:!1})})]})}function A_({value:e,t,translate:n,onEdit:o,testidPrefix:r}){const s=ds(e,a=>{const l=n(a);return l&&l!==a&&!l.startsWith("$t:")?l:""},a=>Vt(a));return i.jsxs("div",{"data-testid":r,style:z_,children:[i.jsx("div",{"data-testid":`${r}-text`,style:M_,children:s.length===0?i.jsx("span",{style:{color:"#94a3b8"},children:t("layout_editor.prop_i18n.placeholder")}):s.map((a,l)=>a.kind==="chip"?i.jsxs("span",{style:ic,children:["🔗 ",a.text]},l):i.jsx("span",{style:a.kind==="ellipsis"?sc:void 0,children:a.text},l))}),i.jsxs("button",{type:"button","data-testid":`${r}-edit`,onClick:o,style:L_,title:t("layout_editor.value_tree.edit"),"aria-label":t("layout_editor.value_tree.edit"),children:["✎ ",t("layout_editor.value_tree.edit")]})]})}function T_({value:e,onChange:t,t:n,candidates:o,testidPrefix:r}){const[s,a]=u.useState(!1),l=u.useRef(null),d=Vt(e);return i.jsxs("div",{"data-testid":r,style:{display:"flex",flexDirection:"column",gap:4,width:"100%",minWidth:0},children:[i.jsxs("div",{style:D_,children:[i.jsxs("span",{"data-testid":`${r}-chip`,style:N_,children:["🔗 ",d]}),i.jsx("button",{ref:l,type:"button","data-testid":`${r}-change`,onClick:()=>a(c=>!c),style:Co,title:n("layout_editor.value_tree.change_data"),"aria-label":n("layout_editor.value_tree.change_data"),children:n("layout_editor.value_tree.change_data")})]}),i.jsx(Gn,{anchorRef:l,open:s,onClose:()=>a(!1),children:i.jsx($t,{candidates:o,t:n,onSelect:c=>{const p=c.path?c.path.split(".").filter(Boolean):[],f=[c.sourceId,...p].join("?.");t(`{{${f}}}`),a(!1)},testIdSuffix:`${r}-pick`,defaultOpen:!0,floating:!1})})]})}const D_={display:"flex",alignItems:"center",gap:6,width:"100%",minWidth:0,flexWrap:"wrap"},N_={fontSize:11,background:"#eef2ff",color:"#4338ca",padding:"3px 8px",borderRadius:12,wordBreak:"break-all"},z_={display:"flex",alignItems:"center",gap:8,width:"100%",minWidth:0},M_={flex:1,minWidth:0,display:"flex",flexWrap:"wrap",alignItems:"center",gap:4,padding:"5px 8px",fontSize:12,color:"#334155",border:"1px solid #e2e8f0",borderRadius:6,background:"#f8fafc",overflow:"hidden"},ic={fontSize:11,background:"#eef2ff",color:"#4338ca",padding:"1px 6px",borderRadius:10,whiteSpace:"nowrap"},sc={color:"#94a3b8"},L_={padding:"4px 10px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#2563eb",cursor:"pointer",whiteSpace:"nowrap",flexShrink:0},ac={padding:"3px 10px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer",whiteSpace:"nowrap"},P_={padding:"3px 10px",fontSize:11,border:"1px solid #fca5a5",borderRadius:6,background:"#fef2f2",color:"#b91c1c",cursor:"pointer",whiteSpace:"nowrap"},O_={display:"flex",flexDirection:"column",gap:6,padding:10,border:"1px solid #fca5a5",borderRadius:8,background:"#fffbeb"},B_={margin:0,fontSize:12,color:"#334155"},W_={padding:"5px 8px",fontSize:12,color:"#0f172a",background:"#fff",border:"1px solid #e2e8f0",borderRadius:6,wordBreak:"break-all"},j_={margin:0,fontSize:11,color:"#b45309"},H_={padding:"3px 12px",fontSize:11,border:"1px solid #dc2626",borderRadius:6,background:"#dc2626",color:"#fff",cursor:"pointer",whiteSpace:"nowrap"},F_={display:"flex",gap:4,alignItems:"center",width:"100%",minWidth:0},K_={flex:1,padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,minWidth:0,width:"100%"},lc={padding:"4px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer",whiteSpace:"nowrap"},Co={padding:"4px 8px",fontSize:13,lineHeight:1,border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer",whiteSpace:"nowrap"},V_={display:"flex",alignItems:"center",gap:6,width:"100%",minWidth:0,flexWrap:"wrap"},U_={flex:1,minWidth:0,display:"flex",flexWrap:"wrap",alignItems:"center",gap:4,fontSize:12,color:"#334155"},q_={fontSize:11,background:"#eef2ff",color:"#4338ca",padding:"2px 8px",borderRadius:12,whiteSpace:"nowrap"},G_={display:"flex",flexDirection:"column",gap:2,width:"100%"},Y_={fontSize:11,background:"#f1f5f9",padding:"3px 6px",borderRadius:4,color:"#0f172a",wordBreak:"break-all"},X_={fontSize:10,color:"#64748b"},J_={fontSize:11,color:"#94a3b8",padding:"6px 8px",border:"1px dashed #cbd5e1",borderRadius:6,background:"#f8fafc"};function gs({value:e,onChange:t,candidates:n,t:o}){const r=Array.isArray(e)?e:[],s=n??[],[a,l]=u.useState(!1),d=g=>s.find(m=>m.value===g)?.label??g,c=g=>d(g)!==g,p=g=>{const m=r.filter(h=>h!==g);t(m.length===0?void 0:m)},f=g=>{r.includes(g)||(t([...r,g]),l(!1))},y=s.filter(g=>!r.includes(g.value));return i.jsxs("div",{className:"g7le-widget g7le-widget--tag-input","data-testid":"g7le-widget-tag-input",style:Z_,children:[i.jsxs("div",{style:Q_,children:[r.map(g=>i.jsxs("span",{"data-testid":`g7le-tag-chip-${g}`,style:R_,children:[i.jsx("span",{style:ex,children:d(g)}),c(g)&&i.jsx("code",{"data-testid":`g7le-tag-chip-id-${g}`,style:tx,children:g}),i.jsx("button",{type:"button","aria-label":`remove ${g}`,"data-testid":`g7le-tag-remove-${g}`,onClick:()=>p(g),style:nx,children:"✕"})]},g)),i.jsx("button",{type:"button","data-testid":"g7le-tag-add",onClick:()=>l(g=>!g),disabled:y.length===0,style:ox,children:o("layout_editor.control.tag_add")})]}),a&&y.length>0&&i.jsx("div",{"data-testid":"g7le-tag-candidates",style:rx,children:y.map(g=>{const m=g.value!==g.label;return i.jsxs("button",{type:"button","data-testid":`g7le-tag-candidate-${g.value}`,onClick:()=>f(g.value),style:ix,children:[i.jsx("span",{style:sx,children:g.label}),m&&i.jsx("code",{"data-testid":`g7le-tag-candidate-id-${g.value}`,style:ax,children:g.value})]},g.value)})})]})}const Z_={display:"flex",flexDirection:"column",gap:6},Q_={display:"flex",flexWrap:"wrap",gap:4,alignItems:"center"},R_={display:"inline-flex",alignItems:"center",gap:4,padding:"2px 6px",fontSize:12,background:"#e0e7ff",color:"#1d4ed8",borderRadius:12},ex={fontSize:12},tx={fontSize:10,color:"#4f46e5",background:"#c7d2fe",borderRadius:4,padding:"0 4px",fontFamily:"monospace"},nx={border:"none",background:"transparent",color:"#1d4ed8",cursor:"pointer",fontSize:10,padding:0,lineHeight:1},ox={padding:"2px 8px",fontSize:12,border:"1px dashed #94a3b8",borderRadius:12,background:"#fff",cursor:"pointer",color:"#475569"},rx={display:"flex",flexDirection:"column",maxHeight:180,overflowY:"auto",border:"1px solid #cbd5e1",borderRadius:6,background:"#fff"},ix={display:"flex",flexDirection:"column",gap:1,textAlign:"left",padding:"6px 10px",fontSize:12,border:"none",borderBottom:"1px solid #f1f5f9",background:"transparent",cursor:"pointer",color:"#0f172a"},sx={fontSize:12,color:"#0f172a"},ax={fontSize:10,color:"#64748b",fontFamily:"monospace"},dc=new Map;function Xe(e,t){dc.set(e,t)}function An(e){return e?dc.get(e)??null:null}function Ir(e,t){const o=e("meta",{})?.[t];return typeof o=="string"?o:""}function lx({getValue:e,patch:t,t:n,fieldT:o,spec:r,permissionCandidates:s,bindingCandidates:a}){const l=o??n,d=Ir(e,"title"),c=Ir(e,"description"),p=Ir(e,"icon"),f=Ir(e,"editor_label"),y=e("permissions",[]),g=u.useCallback((b,x)=>{const k={...e("meta",{})??{}};x===void 0||x===""?delete k[b]:k[b]=x,t("meta",k)},[e,t]),m=u.useCallback(b=>{const x=Array.isArray(b)?b:[];x.length===0?t("permissions",void 0):t("permissions",x)},[t]),h=An("icon-picker"),v=Ct(r,"icon-picker")??Ct(r,"icon")??(r?.controls?Object.values(r.controls).find(b=>b&&typeof b=="object"&&b.widget==="icon-picker")??null:null);return i.jsxs("div",{className:"g7le-meta-form","data-testid":"g7le-meta-form",style:cx,children:[i.jsx($o,{label:n("layout_editor.page_settings.meta.title"),children:i.jsx(vt,{value:d,onChange:b=>g("title",b),t:l,testidPrefix:"g7le-meta-title",candidates:a,enableExpressionTree:!0,expressionTreeCollapsible:!0})}),i.jsx($o,{label:n("layout_editor.page_settings.meta.description"),children:i.jsx(vt,{value:c,onChange:b=>g("description",b),t:l,testidPrefix:"g7le-meta-description",candidates:a,enableExpressionTree:!0,expressionTreeCollapsible:!0})}),i.jsx($o,{label:n("layout_editor.page_settings.meta.icon"),children:h?i.jsx(dx,{icon:p,t:n,onChange:b=>g("icon",typeof b=="string"?b:void 0),IconWidget:h,iconControl:v??{widget:"icon-picker"}}):i.jsx("input",{type:"text","data-testid":"g7le-meta-icon",value:p,placeholder:n("layout_editor.page_settings.meta.icon_placeholder"),onChange:b=>g("icon",b.target.value),style:gx})}),i.jsx($o,{label:n("layout_editor.page_settings.meta.editor_label"),hint:n("layout_editor.page_settings.meta.editor_label_hint"),children:i.jsx(vt,{value:f,onChange:b=>g("editor_label",b),t:l,testidPrefix:"g7le-meta-editor-label",candidates:a,enableExpressionTree:!0,expressionTreeCollapsible:!0})}),i.jsx($o,{label:n("layout_editor.page_settings.meta.permissions"),hint:n("layout_editor.page_settings.meta.permissions_hint"),children:i.jsx("div",{"data-testid":"g7le-meta-permissions",children:i.jsx(gs,{control:{widget:"tag-input"},value:y,onChange:m,t:n,candidates:s})})})]})}function $o({label:e,hint:t,children:n}){return i.jsxs("div",{className:"g7le-meta-field",style:ux,children:[i.jsx("label",{style:px,children:e}),n,t?i.jsxs("p",{style:fx,children:["ⓘ ",t]}):null]})}function dx({icon:e,onChange:t,t:n,IconWidget:o,iconControl:r}){const[s,a]=u.useState(!1),l=e?/\bfa-/.test(e)?i.jsx("i",{className:e,"aria-hidden":"true"}):e:"📄";return i.jsxs("div",{"data-testid":"g7le-meta-icon",style:{display:"flex",flexDirection:"column",gap:6,minWidth:0},children:[i.jsxs("button",{type:"button","data-testid":"g7le-meta-icon-toggle","aria-expanded":s,onClick:()=>a(d=>!d),style:{display:"flex",alignItems:"center",gap:8,padding:"5px 8px",border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",cursor:"pointer",fontSize:12,color:"#475569",width:"fit-content"},children:[i.jsx("span",{style:{fontSize:16},"aria-hidden":"true",children:l}),i.jsx("span",{children:e||n("layout_editor.page_settings.meta.icon_placeholder")}),i.jsx("span",{"aria-hidden":"true",children:s?"▴":"▾"})]}),s?i.jsx(o,{control:r,value:e,onChange:d=>{t(d),a(!1)},t:n}):null]})}const cx={display:"flex",flexDirection:"column",gap:16,minWidth:0},ux={display:"flex",flexDirection:"column",gap:6,minWidth:0},px={fontSize:13,fontWeight:600,color:"#0f172a"},fx={margin:0,fontSize:11,color:"#94a3b8"},gx={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,minWidth:0,width:"100%"};function Ar({checked:e,onChange:t,label:n,testid:o,ariaLabel:r,disabled:s=!1}){return i.jsxs("label",{style:{...vx,...s?{cursor:"not-allowed"}:{}},children:[i.jsx("button",{type:"button","data-testid":o,role:"switch","aria-checked":e,"aria-label":r,disabled:s,onClick:()=>t(!e),style:_x(e,s),children:i.jsx("span",{style:xx(e)})}),n!=null?i.jsx("span",{style:wx(s),children:n}):null]})}function Tr({disabled:e,children:t,testid:n,style:o}){return i.jsx("div",{"data-testid":n,"data-disabled":e?"true":"false","aria-disabled":e,style:{...o,...e?{opacity:.5,pointerEvents:"none",userSelect:"none"}:{}},children:t})}function hx({filled:e,sourceLabel:t,onRevert:n,revertLabel:o,hint:r,children:s,testid:a}){const l=e&&typeof n=="function",d=!e&&t!=null&&t!=="";return i.jsxs("div",{"data-testid":a,style:yx,children:[s,l?i.jsxs("button",{type:"button","data-testid":a?`${a}-revert`:void 0,onClick:n,title:r??o,"aria-label":o,style:mx,children:["↩ ",o]}):d?i.jsx("span",{"data-testid":a?`${a}-source`:void 0,title:r,style:bx,children:t}):null]})}const yx={position:"relative",minWidth:0,width:"100%"},bx={position:"absolute",right:8,top:"50%",transform:"translateY(-50%)",fontSize:10,color:"#94a3b8",background:"#f1f5f9",borderRadius:4,padding:"1px 6px",pointerEvents:"none",maxWidth:"45%",overflow:"hidden",whiteSpace:"nowrap",textOverflow:"ellipsis"},mx={position:"absolute",right:6,top:"50%",transform:"translateY(-50%)",fontSize:10,color:"#0369a1",background:"#e0f2fe",border:"1px solid #bae6fd",borderRadius:4,padding:"1px 6px",cursor:"pointer",whiteSpace:"nowrap"},vx={display:"inline-flex",alignItems:"center",gap:8,cursor:"pointer"},_x=(e,t)=>({position:"relative",width:38,height:22,flexShrink:0,borderRadius:999,border:"none",padding:0,background:t?"#e2e8f0":e?"#2563eb":"#cbd5e1",cursor:t?"not-allowed":"pointer",transition:"background 0.15s ease"}),xx=e=>({position:"absolute",top:2,left:e?18:2,width:18,height:18,borderRadius:"50%",background:"#fff",boxShadow:"0 1px 2px rgba(0,0,0,0.2)",transition:"left 0.15s ease"}),wx=e=>({fontSize:13,fontWeight:600,color:e?"#94a3b8":"#0f172a"}),kx=/\$(?:core|module|plugin)_settings:[a-zA-Z0-9._:-]+/;function Sx(e){const t=[];let n=!1;const o=s=>{t.push({kind:"text",text:s}),n=!0},r=(s,a,l)=>{n||o(""),t.push({kind:"chip",raw:s,text:a,removable:l}),n=!1};for(const s of Cx(e))s.kind==="text"?o(s.raw):r(s.raw,s.label,s.removable);return n||o(""),t}function Cx(e){const t=[];if(typeof e!="string"||e.length===0)return t;const n=e.length;let o=0,r=0;const s=a=>{a>r&&t.push({kind:"text",raw:e.slice(r,a),label:"",removable:!1})};for(;o=n){o++;continue}s(o);const l=e.slice(o,a+2);t.push({kind:"chip",raw:l,label:Vt(l),removable:!0}),o=a+2,r=o;continue}if(e[o]==="$"){const a=kx.exec(e.slice(o));if(a&&a.index===0){s(o);const l=a[0],d=l.split(":").pop()||l;t.push({kind:"chip",raw:l,label:d,removable:!1}),o+=l.length,r=o;continue}}o++}return s(n),t}function $x(e,t,n,o){const r=[];let s=!1;return e.forEach((a,l)=>{if(a.kind==="chip"){if(!s&&a.raw===t){s=!0;return}r.push(a.raw??"")}else{const d=a.text??"";if(l===n){const c=Math.max(0,Math.min(o,d.length));r.push(d.slice(0,c),t,d.slice(c))}else r.push(d)}}),r.join("")}function Ex(e,t,n){const o=Math.max(0,Math.min(t,e.length));return e.slice(0,o)+n+e.slice(o)}function Ix(e,t){const n=e.indexOf(t);return n<0?e:e.slice(0,n)+e.slice(n+t.length)}function hs({value:e,onChange:t,t:n,candidates:o,onDone:r,testidPrefix:s="g7le-binding-chip-text",disabled:a=!1}){const l=u.useMemo(()=>Sx(e),[e]),d=u.useRef(null),c=!!o&&o.length>0,[p,f]=u.useState(null),[y,g]=u.useState(null),[m,h]=u.useState(null),[v,b]=u.useState(!1),x=u.useRef(0),k=u.useRef(null),w=u.useMemo(()=>l.map(L=>L.kind==="chip"?`c:${L.raw}`:"t").join("|"),[l]),_=u.useRef(null),S=u.useRef([]),E=u.useRef(!1);u.useLayoutEffect(()=>{const L=E.current;E.current=!1,!(!L&&_.current===w)&&(_.current=w,l.forEach((I,P)=>{if(I.kind!=="text")return;const F=S.current[P];F&&F.textContent!==(I.text??"")&&(F.textContent=I.text??"")}))},[w,l,e]);const C=u.useCallback(()=>{const L=d.current;if(!L)return e;const I=[];for(const P of Array.from(L.querySelectorAll("[data-seg]")))P.dataset.seg==="chip"?I.push(P.dataset.raw??""):I.push(P.textContent??"");return I.join("")},[e]),N=u.useCallback(()=>{a||t(C())},[a,t,C]),$=u.useRef(null),A=u.useRef(!1),z=(L,I)=>{if(!a){L.preventDefault(),L.stopPropagation(),$.current={raw:I,pointerId:L.pointerId},A.current=!1,f(I);try{L.currentTarget.setPointerCapture(L.pointerId)}catch{}}},D=L=>{const I=$.current;if(!I||L.pointerId!==I.pointerId)return;A.current=!0;const P=B(L.clientX,L.clientY);P&&(g({slotIdx:P.slotIdx,offset:P.offset}),h(Tx(d.current,P.slotIdx,P.offset)))},O=(L,I)=>{if(L.stopPropagation(),A.current){A.current=!1;return}E.current=!0,t(Ix(e,I))},M=L=>{const I=$.current;if(!I||L.pointerId!==I.pointerId)return;try{L.currentTarget.releasePointerCapture(L.pointerId)}catch{}$.current=null,A.current&&typeof setTimeout=="function"&&setTimeout(()=>{A.current=!1},0);const P=B(L.clientX,L.clientY);if(f(null),g(null),h(null),!P)return;const F=d.current;if(!F)return;const V=Array.from(F.querySelectorAll("[data-seg]")).map(Z=>Z.dataset.seg==="chip"?{kind:"chip",raw:Z.dataset.raw??""}:{kind:"text",text:Z.textContent??""}),Y=$x(V,I.raw,P.slotIdx,P.offset);Y!==e&&(E.current=!0,t(Y))},B=(L,I)=>{const P=d.current;if(!P)return null;const F=Array.from(P.querySelectorAll("[data-seg]")),G=P.ownerDocument,V=G.elementFromPoint(L,I),Y=V?.closest('[data-seg="text"]');if(Y)return{slotIdx:F.indexOf(Y),offset:Ax(G,Y,L,I)};const Z=V?.closest('[data-seg="chip"]');if(Z){const re=F.indexOf(Z),U=Z.getBoundingClientRect();if(L=0){const ee=F[re-1];return{slotIdx:re-1,offset:(ee.textContent??"").length}}if(re+1({s:re,i:U})).filter(re=>re.s.dataset.seg==="text").pop();return se?{slotIdx:se.i,offset:(se.s.textContent??"").length}:null},T=()=>{a||!c||(x.current=Dx(d.current,e.length),b(L=>!L))},H=L=>{const I=L.path?L.path.split(".").filter(Boolean):[],P=[L.sourceId,...I].join("?.");E.current=!0,t(Ex(e,x.current,`{{${P}}}`)),b(!1)};return i.jsxs("div",{"data-testid":s,style:{display:"flex",flexDirection:"column",gap:4},children:[i.jsxs("div",{ref:d,"data-testid":`${s}-box`,className:"g7le-binding-chip-input",style:Nx,children:[l.map((L,I)=>L.kind==="chip"?i.jsxs("span",{"data-seg":"chip","data-raw":L.raw,"data-testid":`${s}-chip-${I}`,contentEditable:!1,onPointerDown:P=>z(P,L.raw??""),onPointerMove:D,onPointerUp:M,title:L.removable?n("layout_editor.value_tree.change_data"):n("layout_editor.prop_i18n.bound_code_only"),style:{...Px,...p===L.raw?Ox:null,touchAction:"none"},children:[i.jsx("span",{"aria-hidden":"true",style:Bx,children:"⠿"}),i.jsx("span",{"aria-hidden":"true",style:{marginRight:3},children:"🔗"}),i.jsx("span",{children:L.text}),L.removable&&!a&&i.jsx("button",{type:"button","data-testid":`${s}-chip-remove-${I}`,"aria-label":n("layout_editor.inline_binding.clear"),title:n("layout_editor.inline_binding.clear"),onPointerDown:P=>{P.stopPropagation(),P.preventDefault()},onClick:P=>O(P,L.raw??""),style:Wx,children:"✕"})]},`chip-${L.raw}-${I}`):i.jsx("span",{ref:P=>{S.current[I]=P},"data-seg":"text",contentEditable:!a,suppressContentEditableWarning:!0,onInput:N,"data-testid":`${s}-text-${I}`,style:{...zx,...p?Mx:null,...y?.slotIdx===I?Lx:null}},`text-slot-${I}`)),m&&i.jsx("span",{"aria-hidden":"true","data-testid":`${s}-caret`,style:{position:"absolute",left:m.left,top:m.top,width:2,height:m.height,background:"#2563eb",borderRadius:1,pointerEvents:"none",boxShadow:"0 0 0 1px rgba(37,99,235,0.3)"}})]}),i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6,flexWrap:"wrap"},children:[r&&i.jsxs("button",{type:"button","data-testid":`${s}-done`,onClick:r,style:cc,title:n("layout_editor.value_tree.done"),"aria-label":n("layout_editor.value_tree.done"),children:["✓ ",n("layout_editor.value_tree.done")]}),c&&i.jsxs("div",{style:{position:"relative"},children:[i.jsxs("button",{ref:k,type:"button","data-testid":`${s}-insert`,onClick:T,disabled:a,style:cc,title:n("layout_editor.inline_binding.insert_data"),"aria-label":n("layout_editor.inline_binding.insert_data"),children:["+ ",n("layout_editor.inline_binding.insert_data")]}),i.jsx(Gn,{anchorRef:k,open:v,onClose:()=>b(!1),children:i.jsx($t,{candidates:o,t:n,onSelect:H,testIdSuffix:`${s}-insert`,defaultOpen:!0,floating:!1})})]}),i.jsx("span",{style:jx,children:n("layout_editor.translation.placeholder_locked")})]})]})}function Ax(e,t,n,o){const r=(t.textContent??"").length;try{const a=e;let l=null,d=0;if(typeof a.caretRangeFromPoint=="function"){const c=a.caretRangeFromPoint(n,o);c&&(l=c.startContainer,d=c.startOffset)}else if(typeof a.caretPositionFromPoint=="function"){const c=a.caretPositionFromPoint(n,o);c&&(l=c.offsetNode,d=c.offset)}if(l&&t.contains(l))return Math.max(0,Math.min(d,r))}catch{}const s=t.getBoundingClientRect();return n>s.left+s.width/2?r:0}function Tx(e,t,n){if(!e)return null;const r=Array.from(e.querySelectorAll("[data-seg]"))[t];if(!r)return null;const s=e.getBoundingClientRect();try{const d=r.firstChild,c=e.ownerDocument;if(d&&d.nodeType===Node.TEXT_NODE&&(d.textContent??"").length>0){const p=c.createRange(),f=Math.max(0,Math.min(n,(d.textContent??"").length));p.setStart(d,f),p.setEnd(d,f);const y=p.getClientRects(),g=y.length>0?y[0]:p.getBoundingClientRect();if(g&&(g.height>0||g.width>=0))return{left:g.left-s.left,top:g.top-s.top,height:g.height||r.getBoundingClientRect().height}}}catch{}const a=r.getBoundingClientRect();return{left:(n>0?a.right:a.left)-s.left,top:a.top-s.top,height:a.height||16}}function Dx(e,t){if(!e)return t;try{const n=e.ownerDocument.defaultView?.getSelection();if(!n||n.rangeCount===0)return t;const o=n.getRangeAt(0);let r=o.startContainer,s=null;for(;r&&r!==e;){if(r instanceof HTMLElement&&r.dataset.seg==="text"){s=r;break}r=r.parentNode}if(!s||!e.contains(s))return t;const a=Array.from(e.querySelectorAll("[data-seg]"));let l=0;for(const d of a){if(d===s)break;l+=d.dataset.seg==="chip"?(d.dataset.raw??"").length:(d.textContent??"").length}return l+o.startOffset}catch{return t}}const Nx={position:"relative",display:"flex",flexWrap:"wrap",alignItems:"center",gap:2,minHeight:30,padding:"4px 8px",border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",fontSize:12,lineHeight:1.6,minWidth:0,width:"100%",boxSizing:"border-box"},zx={outline:"none",minWidth:8,minHeight:"1.2em",whiteSpace:"pre-wrap",color:"#0f172a",display:"inline-block",wordBreak:"break-all"},Mx={boxShadow:"inset 0 -2px 0 #93c5fd",background:"#eff6ff",borderRadius:2},Lx={background:"#dbeafe",boxShadow:"inset 0 -2px 0 #2563eb"},Px={display:"inline-flex",alignItems:"center",gap:1,background:"linear-gradient(180deg,#eff6ff,#dbeafe)",color:"#1d4ed8",border:"1px dashed #60a5fa",borderRadius:9999,padding:"1px 8px 1px 4px",margin:"0 1px",fontSize:11,fontWeight:600,cursor:"grab",userSelect:"none",whiteSpace:"nowrap",boxShadow:"0 1px 2px rgba(37,99,235,0.18)",verticalAlign:"middle"},Ox={opacity:.5,borderStyle:"solid",cursor:"grabbing"},Bx={color:"#2563eb",opacity:.7,fontSize:11,marginRight:1,cursor:"grab"},Wx={marginLeft:4,width:14,height:14,lineHeight:"12px",padding:0,border:"none",borderRadius:9999,background:"rgba(29,78,216,0.12)",color:"#1d4ed8",fontSize:9,fontWeight:700,cursor:"pointer",display:"inline-flex",alignItems:"center",justifyContent:"center",flex:"0 0 auto"},cc={padding:"4px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer",whiteSpace:"nowrap"},jx={fontSize:10,color:"#94a3b8"},Hx=Vl;function ys(e){return gb(e)}function Ve({value:e,onChange:t,t:n,candidates:o,placeholder:r,testidPrefix:s="g7le-data-chip-value",readOnly:a=!1,enableExpressionTree:l=!0}){const d=typeof e=="string"?e:"",c=!!o&&o.length>0,[p,f]=u.useState(!1),[y,g]=u.useState(!1),[m,h]=u.useState(!1),v=u.useCallback(E=>i.jsx(Ve,{value:E.value,onChange:C=>E.onChange(C),t:E.t,candidates:E.candidates,testidPrefix:E.testidPrefix,enableExpressionTree:!0}),[]),b=()=>i.jsx("div",{style:{display:"flex",gap:4,alignItems:"center",width:"100%",minWidth:0},children:i.jsx("input",{type:"text","data-testid":`${s}-input`,value:d,placeholder:r??n("layout_editor.page_settings.seo.value_placeholder"),readOnly:a,onChange:E=>t(E.target.value),onFocus:()=>f(!0),onBlur:()=>f(!1),style:{flex:1,padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,minWidth:0,width:"100%",background:a?"#f1f5f9":"#fff",color:a?"#64748b":"#0f172a"}})}),x=u.useCallback(E=>{const C=d?`${d} ${E.expression}`:E.expression;t(C)},[d,t]);if(!l||a)return i.jsxs("div",{"data-testid":s,style:un,children:[b(),!a&&c&&i.jsx($t,{candidates:o,t:n,onSelect:x,testIdSuffix:`${s}-pick`})]});const k=Sv(d),w=Kd(d),_=Kt(d),S=E=>{const C=wr(d),N=C?ds(C,()=>"",$=>Vt($)):[];return i.jsxs(i.Fragment,{children:[i.jsx("div",{style:{display:"flex",justifyContent:"flex-start"},children:i.jsxs("button",{type:"button","data-testid":`${E}-to-plain`,onClick:()=>h(!0),style:Ux,title:n("layout_editor.value_tree.to_plain"),"aria-label":n("layout_editor.value_tree.to_plain"),children:["↩ ",n("layout_editor.value_tree.to_plain")]})}),m&&i.jsxs("div",{"data-testid":`${E}-to-plain-confirm`,style:qx,children:[i.jsx("p",{style:Gx,children:n("layout_editor.value_tree.to_plain_lead")}),i.jsx("div",{style:Yx,children:N.length===0?i.jsx("span",{style:{color:"#94a3b8"},children:n("layout_editor.page_settings.seo.value_placeholder")}):N.map(($,A)=>$.kind==="chip"?i.jsxs("span",{style:bs,children:["🔗 ",$.text]},A):i.jsx("span",{style:$.kind==="ellipsis"?{color:"#94a3b8"}:void 0,children:$.text},A))}),i.jsxs("p",{style:Xx,children:["⚠ ",n("layout_editor.value_tree.to_plain_warn")]}),i.jsxs("div",{style:{display:"flex",justifyContent:"flex-end",gap:6},children:[i.jsx("button",{type:"button","data-testid":`${E}-to-plain-confirm-cancel`,onClick:()=>h(!1),style:Ut,children:n("layout_editor.value_tree.to_plain_cancel")}),i.jsx("button",{type:"button","data-testid":`${E}-to-plain-confirm-ok`,onClick:()=>{h(!1),t(wr(d)||"")},style:Jx,children:n("layout_editor.value_tree.to_plain_ok")})]})]})]})};if(!p&&(k==="expression"||w)){if(w)return i.jsxs("div",{"data-testid":s,style:un,children:[!a&&S(`${s}-seg`),i.jsx(ps,{value:d,onChange:C=>t(C),t:n,candidates:o,testidPrefix:`${s}-seg`,renderLeafInput:v})]});const E=cn(d);return i.jsxs("div",{"data-testid":s,style:un,children:[!a&&S(`${s}-tree`),i.jsx(Qd,{node:E.node,onChange:C=>t(Xn(C,!1)),t:n,candidates:o,testidPrefix:`${s}-tree`,renderLeafInput:v})]})}if(_&&!p&&!y)return i.jsx(Fx,{value:d,onChange:t,t:n,candidates:o,testidPrefix:s,onEditRaw:()=>g(!0)});if(!p&&!y&&ys(d)){const E=Hx(d);return i.jsx("div",{"data-testid":s,style:un,children:i.jsxs("div",{style:uc,children:[i.jsx("div",{"data-testid":`${s}-chips`,style:Kx,children:E.map((C,N)=>C.kind==="chip"?i.jsxs("span",{style:bs,children:["🔗 ",C.label]},N):i.jsx("span",{children:C.label},N))}),i.jsxs("button",{type:"button","data-testid":`${s}-edit-raw`,onClick:()=>g(!0),style:Ut,title:n("layout_editor.value_tree.edit"),"aria-label":n("layout_editor.value_tree.edit"),children:["✎ ",n("layout_editor.value_tree.edit")]})]})})}return y&&ys(d)?i.jsxs("div",{"data-testid":s,style:un,children:[i.jsx(hs,{value:d,onChange:t,t:n,candidates:o,disabled:a,onDone:()=>g(!1),testidPrefix:`${s}-chipedit`}),!a&&k!=="raw"&&i.jsxs("div",{style:{display:"flex",gap:4,flexWrap:"wrap"},children:[i.jsxs("button",{type:"button","data-testid":`${s}-to-expr`,onClick:()=>t(wo(d)),style:Ut,title:n("layout_editor.value_tree.to_expression"),"aria-label":n("layout_editor.value_tree.to_expression"),children:["ƒx ",n("layout_editor.value_tree.to_expression")]}),i.jsxs("button",{type:"button","data-testid":`${s}-to-fallback`,onClick:()=>t(cs(d)),style:Ut,title:n("layout_editor.value_tree.segment.fallback"),"aria-label":n("layout_editor.value_tree.segment.fallback"),children:["?? ",n("layout_editor.value_tree.segment.fallback")]})]}),k==="raw"&&i.jsxs("p",{"data-testid":`${s}-raw-hint`,style:ms,children:["ⓘ ",n("layout_editor.value_tree.raw_expression_hint")]})]}):!p&&ys(d)?i.jsxs("div",{"data-testid":s,style:un,children:[i.jsx(hs,{value:d,onChange:t,t:n,candidates:o,disabled:a,onDone:()=>{g(!1),f(!1)},testidPrefix:`${s}-chipedit`}),!a&&k!=="raw"&&i.jsxs("div",{style:{display:"flex",gap:4,flexWrap:"wrap"},children:[i.jsxs("button",{type:"button","data-testid":`${s}-to-expr`,onClick:()=>t(wo(d)),style:Ut,title:n("layout_editor.value_tree.to_expression"),"aria-label":n("layout_editor.value_tree.to_expression"),children:["ƒx ",n("layout_editor.value_tree.to_expression")]}),i.jsxs("button",{type:"button","data-testid":`${s}-to-fallback`,onClick:()=>t(cs(d)),style:Ut,title:n("layout_editor.value_tree.segment.fallback"),"aria-label":n("layout_editor.value_tree.segment.fallback"),children:["?? ",n("layout_editor.value_tree.segment.fallback")]})]}),k==="raw"&&i.jsxs("p",{"data-testid":`${s}-raw-hint`,style:ms,children:["ⓘ ",n("layout_editor.value_tree.raw_expression_hint")]})]}):i.jsxs("div",{"data-testid":s,style:un,children:[b(),i.jsxs("div",{style:Vx,children:[c&&i.jsx($t,{candidates:o,t:n,onSelect:x,testIdSuffix:`${s}-pick`}),k!=="raw"&&i.jsxs("div",{style:{display:"flex",gap:4},children:[i.jsxs("button",{type:"button","data-testid":`${s}-to-expr`,onClick:()=>t(wo(d)),style:Ut,title:n("layout_editor.value_tree.to_expression"),"aria-label":n("layout_editor.value_tree.to_expression"),children:["ƒx ",n("layout_editor.value_tree.to_expression")]}),i.jsxs("button",{type:"button","data-testid":`${s}-to-fallback`,onClick:()=>t(cs(d)),style:Ut,title:n("layout_editor.value_tree.segment.fallback"),"aria-label":n("layout_editor.value_tree.segment.fallback"),children:["?? ",n("layout_editor.value_tree.segment.fallback")]})]})]}),k==="raw"&&!p&&i.jsxs("p",{"data-testid":`${s}-raw-hint`,style:ms,children:["ⓘ ",n("layout_editor.value_tree.raw_expression_hint")]})]})}function Fx({value:e,onChange:t,t:n,candidates:o,testidPrefix:r,onEditRaw:s}){const[a,l]=u.useState(!1),d=u.useRef(null),c=Vt(e),p=!!o&&o.length>0,f=Kt(e)?.localeFn;return i.jsxs("div",{"data-testid":r,style:un,children:[i.jsxs("div",{style:uc,children:[i.jsxs("span",{"data-testid":`${r}-chip`,style:bs,children:["🔗 ",c]}),p&&i.jsx("button",{ref:d,type:"button","data-testid":`${r}-change`,onClick:()=>l(y=>!y),style:Ut,title:n("layout_editor.value_tree.change_data"),"aria-label":n("layout_editor.value_tree.change_data"),children:n("layout_editor.value_tree.change_data")}),i.jsx("button",{type:"button","data-testid":`${r}-edit-raw`,onClick:s,style:Ut,title:n("layout_editor.value_tree.edit"),"aria-label":n("layout_editor.value_tree.edit"),children:"✎"})]}),p&&i.jsx(Gn,{anchorRef:d,open:a,onClose:()=>l(!1),children:i.jsx($t,{candidates:o,t:n,onSelect:y=>{if(f){const h=[y.sourceId,...y.path?y.path.split(".").filter(Boolean):[]].join(".");t(`{{${f}(${h})}}`),l(!1);return}const g=y.path?y.path.split(".").filter(Boolean):[],m=[y.sourceId,...g].join("?.");t(`{{${m}}}`),l(!1)},testIdSuffix:`${r}-pick`,defaultOpen:!0,floating:!1})})]})}const un={display:"flex",flexDirection:"column",gap:4,width:"100%",minWidth:0},uc={display:"flex",alignItems:"center",gap:6,width:"100%",minWidth:0,flexWrap:"wrap"},Kx={flex:1,minWidth:0,display:"flex",flexWrap:"wrap",alignItems:"center",gap:4,padding:"5px 8px",fontSize:12,color:"#334155",border:"1px solid #e2e8f0",borderRadius:6,background:"#f8fafc"},bs={fontSize:11,background:"#eef2ff",color:"#4338ca",padding:"2px 8px",borderRadius:12,whiteSpace:"nowrap"},Ut={padding:"4px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer",whiteSpace:"nowrap"},Vx={display:"flex",flexWrap:"wrap",alignItems:"center",gap:6},ms={margin:"2px 0 0",fontSize:11,color:"#94a3b8",lineHeight:1.4},Ux={padding:"3px 10px",fontSize:11,border:"1px solid #fca5a5",borderRadius:6,background:"#fef2f2",color:"#b91c1c",cursor:"pointer",whiteSpace:"nowrap"},qx={display:"flex",flexDirection:"column",gap:6,padding:10,border:"1px solid #fca5a5",borderRadius:8,background:"#fffbeb"},Gx={margin:0,fontSize:12,color:"#334155"},Yx={padding:"5px 8px",fontSize:12,color:"#0f172a",background:"#fff",border:"1px solid #e2e8f0",borderRadius:6,wordBreak:"break-all",display:"flex",flexWrap:"wrap",gap:4,alignItems:"center"},Xx={margin:0,fontSize:11,color:"#b45309"},Jx={padding:"3px 12px",fontSize:11,border:"1px solid #dc2626",borderRadius:6,background:"#dc2626",color:"#fff",cursor:"pointer",whiteSpace:"nowrap"};let pc=0;function Dr({value:e,onChange:t,keyField:n,valueField:o="content",t:r,candidates:s,testidPrefix:a="g7le-kv-chip-editor",keyPlaceholder:l,valuePlaceholder:d,addLabel:c,renderKeyInput:p}){const f=u.useMemo(()=>(Array.isArray(e)?e:[]).map(k=>({id:pc++,k:typeof k[n]=="string"?k[n]:"",v:typeof k[o]=="string"?k[o]:""})),[]),[y,g]=u.useState(f),m=u.useCallback(k=>{g(k);const w=k.filter(_=>_.k.trim()!=="").map(_=>({[n]:_.k,[o]:_.v}));t(w)},[n,o,t]),h=u.useCallback(()=>{m([...y,{id:pc++,k:"",v:""}])},[y,m]),v=u.useCallback(k=>{m(y.filter(w=>w.id!==k))},[y,m]),b=u.useCallback((k,w)=>{m(y.map(_=>_.id===k?{..._,k:w}:_))},[y,m]),x=u.useCallback((k,w)=>{m(y.map(_=>_.id===k?{..._,v:w}:_))},[y,m]);return i.jsxs("div",{"data-testid":a,style:{display:"flex",flexDirection:"column",gap:6},children:[y.map(k=>i.jsxs("div",{"data-testid":`${a}-row-${k.id}`,style:{display:"flex",gap:6,alignItems:"flex-start",border:"1px solid #e2e8f0",borderRadius:8,padding:8},children:[i.jsxs("div",{style:{flex:1,minWidth:0,display:"flex",flexDirection:"column",gap:6},children:[i.jsx("div",{style:{display:"flex",flexDirection:"column",gap:2,minWidth:0},children:p?i.jsx("div",{style:{minWidth:0},children:p({value:k.k,onChange:w=>b(k.id,w),testid:`${a}-key-${k.id}`,placeholder:l??r(`layout_editor.page_settings.seo.extra_${n}`)})}):i.jsx("input",{type:"text","data-testid":`${a}-key-${k.id}`,value:k.k,placeholder:l??r(`layout_editor.page_settings.seo.extra_${n}`),onChange:w=>b(k.id,w.target.value),style:{width:"100%",padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,minWidth:0,boxSizing:"border-box"}})}),i.jsx("div",{style:{minWidth:0},children:i.jsx(Ve,{value:k.v,onChange:w=>x(k.id,w),t:r,candidates:s,placeholder:d??r("layout_editor.page_settings.seo.extra_content"),testidPrefix:`${a}-value-${k.id}`})})]}),i.jsx("button",{type:"button","data-testid":`${a}-remove-${k.id}`,onClick:()=>v(k.id),title:r("layout_editor.page_settings.seo.remove"),"aria-label":r("layout_editor.page_settings.seo.remove"),style:{flex:"0 0 auto",padding:"4px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#64748b",cursor:"pointer"},children:"✕"})]},k.id)),i.jsxs("button",{type:"button","data-testid":`${a}-add`,onClick:h,style:{alignSelf:"flex-start",padding:"5px 10px",fontSize:12,border:"1px dashed #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer"},children:["+ ",c??r("layout_editor.page_settings.seo.add_extra")]})]})}const Zx=["website","article","product","profile"],Qx=["summary","summary_large_image","app","player"];function Rx(e){switch(e){case"core":return"layout_editor.page_settings.seo.og_source_core";case"inherited":return"layout_editor.page_settings.seo.og_source_inherited";case"filter":return"layout_editor.page_settings.seo.og_source_filter";case"layout":return null;default:return e?"layout_editor.page_settings.seo.og_source_module":null}}function fc(e){const t={};for(const n of e??[])t[n.key]=n;return t}function gc(e,t){return!!e&&t&&typeof e.sourceExpr=="string"&&e.sourceExpr!==""&&!e.overriddenByLayout&&!e.lockedByFilter}function hc({label:e,sourceExpr:t,onReplace:n,t:o,testid:r}){return i.jsxs("div",{"data-testid":r,style:d0,children:[i.jsxs("span",{"data-testid":`${r}-chip`,style:c0,title:t,children:["🔗 ",e||t]}),i.jsx("span",{"data-testid":`${r}-badge`,style:u0,children:o("layout_editor.page_settings.seo.auto_chip_source")}),i.jsx("button",{type:"button","data-testid":`${r}-replace`,onClick:n,style:p0,children:o("layout_editor.page_settings.seo.auto_chip_replace")})]})}function e0({og:e,twitter:t,onChangeOg:n,onChangeTwitter:o,ogPreview:r,twitterPreview:s,defaultsAvailable:a=!1,candidates:l,t:d,testidPrefix:c="g7le-seo-og"}){const p=e&&typeof e=="object"?e:{},f=t&&typeof t=="object"?t:{},[y,g]=u.useState(p.image_height!=null||p.image_type!=null||p.image_secure_url!=null),m=fc(r),h=fc(s),v=u.useCallback((N,$)=>{const A={...p};$===void 0||$===""||$===null?delete A[N]:A[N]=$,n(A)},[p,n]),b=u.useCallback((N,$)=>{const A={...f};$===void 0||$===""||$===null?delete A[N]:A[N]=$,o(A)},[f,o]),x=(N,$,A)=>!A||!a||!A.lockedByFilter?null:i.jsx("div",{style:{display:"flex",gap:6,alignItems:"center",marginTop:2},children:i.jsxs("span",{"data-testid":`${N}-locked-${$}`,style:s0,children:["🔒 ",d("layout_editor.page_settings.seo.og_locked")]})}),k=N=>{if(!N||!a||N.lockedByFilter||N.overriddenByLayout)return;const $=Rx(N.source);return $?d($):void 0},w=(N,$)=>N&&a?N.overriddenByLayout===!0:$!=="",_=(N,$,A,z,D,O)=>{const M=O[$],B=!!M?.lockedByFilter,T=typeof z[$]=="string"?z[$]:"";return i.jsxs("div",{"data-testid":`${N}-${$}`,style:Tn,children:[i.jsx("label",{style:Rn,children:d(A)}),B?i.jsx("div",{"data-testid":`${N}-locked-value-${$}`,style:i0,children:String(M?.effectiveValue??"")}):gc(M,a)?i.jsx(hc,{label:M.label??"",sourceExpr:M.sourceExpr,onReplace:()=>D($,M.sourceExpr),t:d,testid:`${N}-${$}-auto`}):i.jsx(vt,{value:T,onChange:H=>D($,H??""),t:d,candidates:l,placeholder:a&&M?.effectiveValue!=null?String(M.effectiveValue):void 0,testidPrefix:`${N}-${$}-field`,enableExpressionTree:!0,expressionTreeCollapsible:!0}),x(N,$,M)]})},S=(N,$,A,z,D,O)=>{const M=O[$],B=!!M?.lockedByFilter,T=typeof z[$]=="string"?z[$]:"";return i.jsxs("div",{"data-testid":`${N}-${$}`,style:Tn,children:[i.jsx("label",{style:Rn,children:d(A)}),gc(M,a)?i.jsx(hc,{label:M.label??"",sourceExpr:M.sourceExpr,onReplace:()=>D($,M.sourceExpr),t:d,testid:`${N}-${$}-auto`}):i.jsx(Ve,{value:T,onChange:H=>D($,H),t:d,candidates:l,readOnly:B,placeholder:a&&M?.effectiveValue!=null?String(M.effectiveValue):void 0,testidPrefix:`${N}-${$}-field`}),x(N,$,M)]})},E=(N,$,A,z,D,O)=>{const M=O[$],B=!!M?.lockedByFilter,T=typeof z[$]=="string"?z[$]:"",H=w(M,T),L=H?T:"";return i.jsxs("div",{"data-testid":`${N}-${$}`,style:Tn,children:[i.jsx("label",{style:Rn,children:d(A)}),i.jsx(hx,{filled:H,sourceLabel:k(M),onRevert:B?void 0:()=>D($,void 0),revertLabel:d("layout_editor.page_settings.seo.revert_default"),testid:`${N}-${$}-overlay`,children:i.jsx("div",{"data-testid":`${N}-${$}-input`,style:{minWidth:0},children:i.jsx(Ve,{value:L,onChange:I=>D($,I),t:d,candidates:l,readOnly:B,placeholder:a&&M?.effectiveValue!=null?String(M.effectiveValue):void 0,testidPrefix:`${N}-${$}-chip`})})}),x(N,$,M)]})},C=(N,$,A,z,D,O,M)=>{const B=M[$],T=typeof D[$]=="string"?D[$]:"",H=w(B,T),L=k(B),I=H?T:"";return i.jsxs("div",{"data-testid":`${N}-${$}`,style:Tn,children:[i.jsxs("div",{style:n0,children:[i.jsx("label",{style:Rn,children:d(A)}),L?i.jsxs("span",{"data-testid":`${N}-${$}-source-inline`,style:o0,children:["· ",L]}):null]}),i.jsxs("select",{"data-testid":`${N}-${$}-select`,value:I,onChange:P=>O($,P.target.value),style:r0,children:[i.jsx("option",{value:"",children:a&&B?.effectiveValue!=null&&B.effectiveValue!==""?`${d("layout_editor.page_settings.seo.use_default")} (${String(B.effectiveValue)})`:d("layout_editor.page_settings.seo.use_default")}),z.map(P=>i.jsx("option",{value:P,children:P},P))]}),x(N,$,B)]})};return i.jsxs("div",{"data-testid":c,style:{display:"flex",flexDirection:"column",gap:12},children:[i.jsxs("div",{"data-testid":`${c}-og-section`,style:yc,children:[i.jsx("div",{style:bc,children:d("layout_editor.page_settings.seo.og_section")}),i.jsxs("p",{"data-testid":`${c}-intro`,style:t0,children:["ⓘ ",d("layout_editor.page_settings.seo.og_intro")]}),C(c,"type","layout_editor.page_settings.seo.og_type",Zx,p,v,m),_(c,"title","layout_editor.page_settings.seo.og_title",p,v,m),_(c,"description","layout_editor.page_settings.seo.og_description",p,v,m),S(c,"image","layout_editor.page_settings.seo.og_image",p,v,m),_(c,"image_alt","layout_editor.page_settings.seo.og_image_alt",p,v,m),i.jsxs("div",{style:Tn,children:[i.jsxs("button",{type:"button","data-testid":`${c}-image-adv-toggle`,"aria-expanded":y,onClick:()=>g(N=>!N),style:a0,children:[y?"▾":"▸"," ",d("layout_editor.page_settings.seo.og_image_adv")]}),y?i.jsxs("div",{"data-testid":`${c}-image-adv`,style:l0,children:[E(c,"image_height","layout_editor.page_settings.seo.og_image_height",p,v,m),E(c,"image_type","layout_editor.page_settings.seo.og_image_type",p,v,m),E(c,"image_secure_url","layout_editor.page_settings.seo.og_image_secure_url",p,v,m)]}):null]}),E(c,"site_name","layout_editor.page_settings.seo.og_site_name",p,v,m),E(c,"locale","layout_editor.page_settings.seo.og_locale",p,v,m),i.jsxs("div",{"data-testid":`${c}-extra`,style:Tn,children:[i.jsx("label",{style:Rn,children:d("layout_editor.page_settings.seo.og_extra")}),i.jsx(Dr,{value:Array.isArray(p.extra)?p.extra:[],onChange:N=>v("extra",N.length>0?N:void 0),keyField:"property",t:d,candidates:l,testidPrefix:`${c}-extra-editor`})]})]}),i.jsxs("div",{"data-testid":`${c}-tw-section`,style:yc,children:[i.jsx("div",{style:bc,children:d("layout_editor.page_settings.seo.tw_section")}),i.jsx("p",{style:{margin:"0 0 6px",fontSize:11,color:"#94a3b8"},children:d("layout_editor.page_settings.seo.tw_fallback_note")}),C("g7le-seo-tw","card","layout_editor.page_settings.seo.tw_card",Qx,f,b,h),E("g7le-seo-tw","site","layout_editor.page_settings.seo.tw_site",f,b,h),E("g7le-seo-tw","creator","layout_editor.page_settings.seo.tw_creator",f,b,h),_("g7le-seo-tw","title","layout_editor.page_settings.seo.tw_title",f,b,h),_("g7le-seo-tw","description","layout_editor.page_settings.seo.tw_description",f,b,h),S("g7le-seo-tw","image","layout_editor.page_settings.seo.tw_image",f,b,h),_("g7le-seo-tw","image_alt","layout_editor.page_settings.seo.tw_image_alt",f,b,h),i.jsxs("div",{"data-testid":"g7le-seo-tw-extra",style:Tn,children:[i.jsx("label",{style:Rn,children:d("layout_editor.page_settings.seo.tw_extra")}),i.jsx(Dr,{value:Array.isArray(f.extra)?f.extra:[],onChange:N=>b("extra",N.length>0?N:void 0),keyField:"name",t:d,candidates:l,testidPrefix:"g7le-seo-tw-extra-editor"})]})]})]})}const yc={border:"1px solid #e2e8f0",borderRadius:8,padding:12,display:"flex",flexDirection:"column",gap:10},bc={fontSize:13,fontWeight:600,color:"#0f172a"},t0={margin:0,fontSize:11,color:"#64748b",background:"#f8fafc",borderRadius:6,padding:"6px 8px"},Tn={display:"flex",flexDirection:"column",gap:4},Rn={fontSize:12,color:"#475569"},n0={display:"flex",alignItems:"center",gap:6},o0={fontSize:11,color:"#94a3b8"},r0={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,width:"100%",minWidth:0,boxSizing:"border-box"},i0={padding:"5px 8px",fontSize:12,border:"1px solid #e2e8f0",borderRadius:6,background:"#f1f5f9",color:"#64748b"},s0={fontSize:11,color:"#7c3aed"},a0={alignSelf:"flex-start",padding:"2px 0",fontSize:12,fontWeight:600,color:"#475569",background:"transparent",border:"none",cursor:"pointer"},l0={display:"flex",flexDirection:"column",gap:8,paddingLeft:14,marginTop:4,minWidth:0},d0={display:"flex",alignItems:"center",gap:8,flexWrap:"wrap",minWidth:0},c0={display:"inline-flex",alignItems:"center",maxWidth:"100%",padding:"3px 8px",fontSize:12,color:"#1d4ed8",background:"#eff6ff",border:"1px solid #bfdbfe",borderRadius:6,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},u0={fontSize:11,color:"#94a3b8"},p0={padding:"2px 8px",fontSize:11,color:"#475569",background:"#fff",border:"1px solid #cbd5e1",borderRadius:6,cursor:"pointer"},f0=["WebPage","WebSite","ItemList","BreadcrumbList","Organization","Person","CollectionPage","AboutPage","ContactPage","SearchAction"];function Nr(e){const t=[],n=(o,r)=>{for(const[s,a]of Object.entries(o)){if(s==="@type"||s==="@context")continue;const l=r?`${r}.${s}`:s;a!==null&&typeof a=="object"&&!Array.isArray(a)?n(a,l):t.push({key:l,value:a==null?"":String(a)})}};return n(e,""),t}function mc(e,t){const n={"@type":e};for(const{key:o,value:r}of t){if(o.trim()==="")continue;const s=o.split(".");let a=n;for(let l=0;lp?Nr(e).map(D=>({id:vs++,...D})):[]),x=u.useCallback((D,O)=>{h(D),b(O),t(mc(D,O.map(M=>({key:M.key,value:M.value}))))},[t]),k=u.useCallback(()=>{const D=n,O=typeof D["@type"]=="string"?D["@type"]:m,M=Nr(D).map(B=>({id:vs++,key:B.key,value:o?.[B.key]?.expr??B.value}));return{type:O,rows:M}},[n,o,m]),w=u.useCallback(D=>{if(y(D),D){if(v.length===0&&n&&typeof n=="object"){const O=k();x(O.type,O.rows);return}t(mc(m,v.map(O=>({key:O.key,value:O.value}))))}else t(null)},[m,v,n,k,x,t]),_=u.useCallback(()=>{if(!n)return;const D=k();x(D.type,D.rows)},[n,k,x]),S=u.useCallback(()=>{x(m,[...v,{id:vs++,key:"",value:""}])},[m,v,x]),E=u.useCallback(D=>{x(m,v.filter(O=>O.id!==D))},[m,v,x]),C=u.useCallback((D,O)=>{x(m,v.map(M=>M.id===D?{...M,key:O}:M))},[m,v,x]),N=u.useCallback((D,O)=>{x(m,v.map(M=>M.id===D?{...M,value:O}:M))},[m,v,x]),$=u.useMemo(()=>n?Nr(n):[],[n]),A=u.useMemo(()=>s?Nr(s):[],[s]),z=v.some(D=>D.key.trim()!==""&&D.value.trim()==="");if(r){const D=s&&typeof s["@type"]=="string"?s["@type"]:"";return i.jsxs("div",{"data-testid":c,style:vc,children:[i.jsxs("div",{style:_c,children:[i.jsx("span",{style:h0,children:"🔒"}),i.jsx("span",{style:{fontSize:13,fontWeight:600},children:d("layout_editor.page_settings.seo.sd_mode")}),i.jsx("span",{"data-testid":`${c}-locked-badge`,style:y0,children:d("layout_editor.page_settings.seo.sd_filter_locked")})]}),i.jsxs("div",{"data-testid":`${c}-locked`,style:b0,children:[i.jsxs("p",{style:{margin:"0 0 6px",fontSize:12,color:"#475569"},children:["ⓘ ",d("layout_editor.page_settings.seo.sd_filter_locked_note")]}),i.jsxs("div",{"data-testid":`${c}-filtered-block`,children:[D?i.jsxs("div",{style:{fontSize:12,fontWeight:600,marginBottom:4},children:["@type: ",D]}):null,A.map(O=>i.jsxs("div",{"data-testid":`${c}-filtered-row-${O.key}`,style:xc,children:[i.jsx("code",{style:wc,children:O.key}),i.jsx("span",{style:kc,children:O.value})]},O.key))]})]})]})}return i.jsxs("div",{"data-testid":c,style:vc,children:[i.jsx("div",{style:_c,children:i.jsx(Ar,{checked:f,onChange:w,testid:`${c}-mode`,label:d("layout_editor.page_settings.seo.sd_manual_toggle")})}),!f&&i.jsxs("div",{"data-testid":`${c}-auto`,style:{marginTop:8},children:[i.jsxs("div",{"data-testid":`${c}-auto-preview`,style:m0,children:[i.jsxs("div",{style:{fontSize:12,fontWeight:600,marginBottom:6},children:[d("layout_editor.page_settings.seo.sd_auto_title"),n&&typeof n["@type"]=="string"?`: ${n["@type"]}`:"",a?i.jsxs("span",{style:{marginLeft:6,color:"#94a3b8",fontWeight:400},children:["(",a,")"]}):null]}),$.length===0?i.jsx("p",{style:{margin:0,fontSize:12,color:"#94a3b8"},children:d("layout_editor.page_settings.seo.sd_auto_empty")}):$.map(D=>{const O=o?.[D.key];return i.jsxs("div",{"data-testid":`${c}-auto-row-${D.key}`,style:xc,children:[i.jsx("code",{style:wc,children:D.key}),O?i.jsxs("span",{"data-testid":`${c}-auto-chip-${D.key}`,style:v0,title:O.expr,children:["🔗 ",O.label||O.expr]}):i.jsx("span",{style:kc,children:D.value})]},D.key)})]}),i.jsxs("p",{"data-testid":`${c}-replace-warn`,style:_0,children:["ⓘ ",d("layout_editor.page_settings.seo.sd_replace_pre_warn")]})]}),f&&i.jsxs("div",{"data-testid":`${c}-manual`,style:{marginTop:8},children:[i.jsxs("p",{"data-testid":`${c}-replace-warn`,style:x0,children:["⚠ ",d("layout_editor.page_settings.seo.sd_replace_warn")]}),i.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:6},children:[i.jsx("label",{style:{fontSize:12,color:"#475569"},children:d("layout_editor.page_settings.seo.sd_type")}),i.jsx("input",{type:"text","data-testid":`${c}-type`,list:`${c}-type-options`,value:m,onChange:D=>x(D.target.value,v),style:w0}),i.jsx("datalist",{id:`${c}-type-options`,children:f0.map(D=>i.jsx("option",{value:D},D))})]}),i.jsx("div",{style:{marginTop:8,display:"flex",flexDirection:"column",gap:6},children:v.map(D=>i.jsxs("div",{"data-testid":`${c}-prop-${D.id}`,style:k0,children:[i.jsx("input",{type:"text","data-testid":`${c}-prop-key-${D.id}`,value:D.key,placeholder:d("layout_editor.page_settings.seo.sd_prop_key"),onChange:O=>C(D.id,O.target.value),style:{width:160,padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,minWidth:0}}),i.jsx("div",{style:{flex:1,minWidth:0},children:i.jsx(Ve,{value:D.value,onChange:O=>N(D.id,O),t:d,candidates:l,placeholder:d("layout_editor.page_settings.seo.sd_prop_value"),testidPrefix:`${c}-prop-value-${D.id}`})}),i.jsx("button",{type:"button","data-testid":`${c}-prop-remove-${D.id}`,onClick:()=>E(D.id),title:d("layout_editor.page_settings.seo.remove"),"aria-label":d("layout_editor.page_settings.seo.remove"),style:S0,children:"✕"})]},D.id))}),i.jsxs("div",{style:{marginTop:8,display:"flex",gap:8},children:[i.jsxs("button",{type:"button","data-testid":`${c}-add`,onClick:S,style:Sc,children:["+ ",d("layout_editor.page_settings.seo.sd_add_prop")]}),n?i.jsxs("button",{type:"button","data-testid":`${c}-seed-from-module`,onClick:_,style:Sc,children:["↩ ",d("layout_editor.page_settings.seo.sd_seed_from_module")]}):null]}),i.jsxs("p",{"data-testid":`${c}-empty-note`,style:C0,children:["ⓘ ",d("layout_editor.page_settings.seo.sd_empty_note"),z?i.jsxs("span",{style:{color:"#b45309"},children:[" ",d("layout_editor.page_settings.seo.sd_has_empty")]}):null]})]})]})}const vc={display:"flex",flexDirection:"column"},_c={display:"flex",alignItems:"center",gap:8},h0={fontSize:14},y0={fontSize:11,padding:"2px 6px",borderRadius:4,background:"#f1f5f9",color:"#64748b"},b0={marginTop:8,border:"1px solid #fcd34d",borderRadius:8,padding:10,background:"#fffbeb"},m0={border:"1px solid #e2e8f0",borderRadius:8,padding:10,background:"#f8fafc"},xc={display:"flex",gap:8,alignItems:"center",padding:"2px 0"},wc={fontSize:11,color:"#475569",background:"#fff",padding:"2px 6px",borderRadius:4,minWidth:120},kc={fontSize:11,color:"#0f172a",fontFamily:"monospace",wordBreak:"break-all"},v0={display:"inline-flex",alignItems:"center",padding:"2px 8px",fontSize:11,color:"#1d4ed8",background:"#eff6ff",border:"1px solid #bfdbfe",borderRadius:6},_0={margin:"6px 0 0",fontSize:12,color:"#64748b"},x0={margin:"0 0 8px",fontSize:12,color:"#b45309",background:"#fffbeb",border:"1px solid #fcd34d",borderRadius:6,padding:"6px 8px"},w0={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,width:"100%",minWidth:0},k0={display:"flex",gap:6,alignItems:"flex-start",border:"1px solid #e2e8f0",borderRadius:8,padding:8},S0={padding:"4px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#64748b",cursor:"pointer"},Sc={padding:"5px 10px",fontSize:12,border:"1px dashed #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer"},C0={margin:"8px 0 0",fontSize:11,color:"#94a3b8"},Cc=new Set(["core_setting","setting","query","route"]);function $0({vars:e,onChange:t,varCandidates:n,gatingMet:o=!1,candidates:r,t:s,testidPrefix:a="g7le-seo-vars"}){const l=u.useMemo(()=>e&&typeof e=="object"?{...e}:{},[e]),d=u.useMemo(()=>Array.isArray(n)?n:[],[n]),c=u.useMemo(()=>o?d.filter(_=>Cc.has(_.source)):[],[d,o]),p=u.useMemo(()=>o?d.filter(_=>_.source==="data"):[],[d,o]);u.useMemo(()=>new Set(c.map(_=>_.name)),[c]);const f=u.useMemo(()=>new Set(p.map(_=>_.name)),[p]),y=u.useMemo(()=>{const _=new Set;for(const S of d)Cc.has(S.source)&&_.add(S.name);return _},[d]),g=u.useMemo(()=>Object.keys(l).filter(_=>!f.has(_)),[l,f]),m=u.useCallback((_,S)=>{const E={...l};S===void 0?delete E[_]:E[_]=S,t(E)},[l,t]),[h,v]=u.useState(""),b=h.trim()!==""&&y.has(h.trim()),x=h.trim()!==""&&Object.prototype.hasOwnProperty.call(l,h.trim()),k=h.trim()!==""&&!b&&!x&&!f.has(h.trim()),w=u.useCallback(()=>{const _=h.trim();_===""||y.has(_)||Object.prototype.hasOwnProperty.call(l,_)||(t({...l,[_]:""}),v(""))},[h,y,l,t]);return i.jsxs("div",{"data-testid":a,style:{display:"flex",flexDirection:"column",gap:10},children:[i.jsx("span",{"data-testid":"g7le-seo-var-list",hidden:!0,"data-gating":o?"1":"0"}),!o&&i.jsxs("div",{"data-testid":`${a}-precondition`,style:E0,children:["⚠ ",s("layout_editor.page_settings.seo.vars_precondition")]}),c.length>0&&i.jsxs("div",{"data-testid":`${a}-auto-group`,style:_s,children:[i.jsx("div",{style:xs,children:s("layout_editor.page_settings.seo.vars_auto_group")}),c.map(_=>i.jsxs("div",{"data-testid":`g7le-seo-var-auto-${_.name}`,style:I0,children:[i.jsx("span",{style:A0,children:"🔒"}),i.jsx("code",{style:ws,children:_.name}),i.jsxs("span",{style:T0,children:["〔",s("layout_editor.page_settings.seo.vars_auto"),"·",_.source,"〕"]}),_.owner?.name?i.jsx("span",{style:D0,children:_.owner.name}):null]},_.name))]}),p.length>0&&i.jsxs("div",{"data-testid":`${a}-data-group`,style:_s,children:[i.jsx("div",{style:xs,children:s("layout_editor.page_settings.seo.vars_data_group")}),p.map(_=>{const S=l[_.name]??"",E=S.trim()==="";return i.jsxs("div",{"data-testid":`g7le-seo-var-data-${_.name}`,style:$c,children:[i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6,marginBottom:4},children:[i.jsx("code",{style:ws,children:_.name}),_.required?i.jsx("span",{"data-testid":`g7le-seo-var-required-${_.name}`,style:N0,children:"*"}):null]}),i.jsx(Ve,{value:S,onChange:C=>m(_.name,C??""),t:s,candidates:r,testidPrefix:`g7le-seo-var-data-field-${_.name}`}),_.required&&E?i.jsxs("p",{"data-testid":`g7le-seo-var-empty-warn-${_.name}`,style:ks,children:["⚠ ",s("layout_editor.page_settings.seo.vars_required_empty")]}):null]},_.name)})]}),i.jsxs("div",{"data-testid":`${a}-custom-group`,style:_s,children:[i.jsx("div",{style:xs,children:s("layout_editor.page_settings.seo.vars_custom_group")}),g.map(_=>i.jsxs("div",{"data-testid":`g7le-seo-var-custom-${_}`,style:$c,children:[i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6,marginBottom:4},children:[i.jsx("code",{style:ws,children:_}),i.jsx("button",{type:"button","data-testid":`g7le-seo-var-custom-remove-${_}`,onClick:()=>m(_,void 0),title:s("layout_editor.page_settings.seo.remove"),"aria-label":s("layout_editor.page_settings.seo.remove"),style:{marginLeft:"auto",padding:"2px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#64748b",cursor:"pointer"},children:"✕"})]}),i.jsx(Ve,{value:l[_]??"",onChange:S=>m(_,S??""),t:s,candidates:r,testidPrefix:`g7le-seo-var-custom-field-${_}`})]},_)),i.jsxs("div",{"data-testid":"g7le-seo-var-add",style:{display:"flex",flexDirection:"column",gap:4,marginTop:6},children:[i.jsxs("div",{style:{display:"flex",gap:6},children:[i.jsx("input",{type:"text","data-testid":"g7le-seo-var-add-name",value:h,placeholder:s("layout_editor.page_settings.seo.vars_add_name"),onChange:_=>v(_.target.value),style:{flex:1,padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,minWidth:0}}),i.jsxs("button",{type:"button","data-testid":"g7le-seo-var-add-confirm",onClick:w,disabled:h.trim()===""||b||x,style:z0,children:["+ ",s("layout_editor.page_settings.seo.vars_add")]})]}),b?i.jsxs("p",{"data-testid":"g7le-seo-var-add-reserved-warn",style:ks,children:["⚠ ",s("layout_editor.page_settings.seo.vars_name_reserved",{name:h.trim()})]}):null,x&&!b?i.jsxs("p",{"data-testid":"g7le-seo-var-add-duplicate-warn",style:ks,children:["⚠ ",s("layout_editor.page_settings.seo.vars_name_duplicate",{name:h.trim()})]}):null,k?i.jsxs("p",{"data-testid":"g7le-seo-var-add-not-provided-hint",style:{margin:0,fontSize:11,color:"#94a3b8"},children:["ⓘ ",s("layout_editor.page_settings.seo.vars_name_not_provided",{name:h.trim()})]}):null]})]}),i.jsxs("p",{style:{margin:0,fontSize:11,color:"#94a3b8"},children:["ⓘ ",s("layout_editor.page_settings.seo.vars_value_hint")]})]})}const E0={fontSize:12,color:"#b45309",background:"#fffbeb",border:"1px solid #fcd34d",borderRadius:6,padding:"6px 8px"},_s={border:"1px solid #e2e8f0",borderRadius:8,padding:10,display:"flex",flexDirection:"column",gap:6},xs={fontSize:12,fontWeight:600,color:"#475569"},I0={display:"flex",alignItems:"center",gap:6,padding:"2px 0"},$c={display:"flex",flexDirection:"column",border:"1px solid #f1f5f9",borderRadius:6,padding:8},A0={fontSize:13},ws={fontSize:12,color:"#0f172a",background:"#f1f5f9",padding:"2px 6px",borderRadius:4},T0={fontSize:11,color:"#64748b"},D0={fontSize:11,color:"#94a3b8"},N0={color:"#dc2626",fontWeight:700},ks={margin:"4px 0 0",fontSize:11,color:"#b45309"},z0={padding:"5px 10px",fontSize:12,border:"1px dashed #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer",whiteSpace:"nowrap"};function M0({templateIdentifier:e,layout:t,url:n,locale:o,seedContext:r,routeParams:s,settingsSignature:a,t:l,debounceMs:d=400,fetchImpl:c,testidPrefix:p="g7le-seo-bot-preview"}){const[f,y]=u.useState(!0),[g,m]=u.useState(null),[h,v]=u.useState(!1),[b,x]=u.useState(null),k=u.useRef(null),w=u.useRef({templateIdentifier:e,layout:t,url:n,locale:o,seedContext:r,routeParams:s});w.current={templateIdentifier:e,layout:t,url:n,locale:o,seedContext:r,routeParams:s};const _=u.useCallback(async()=>{const N=c??(typeof fetch<"u"?fetch:void 0);if(!N)return;const{templateIdentifier:$,layout:A,url:z,locale:D,seedContext:O,routeParams:M}=w.current;v(!0),x(null);try{const B=`/api/admin/templates/${encodeURIComponent($)}/editor/seo-bot-preview`,T=await N(B,{method:"POST",credentials:"same-origin",headers:je({"Content-Type":"application/json"}),body:JSON.stringify({layout:A,url:z,locale:D,seed_context:O??{},route_params:M??{}})}),H=await T.json().catch(()=>null);if(!T.ok){x(H?.message??`HTTP ${T.status}`);return}const L=H?.data??null;m(L)}catch(B){x(B instanceof Error?B.message:String(B))}finally{v(!1)}},[c]);u.useEffect(()=>{if(f)return k.current&&clearTimeout(k.current),k.current=setTimeout(()=>{_()},d),()=>{k.current&&clearTimeout(k.current)}},[a,f,d,_]);const S=u.useCallback(()=>{k.current&&clearTimeout(k.current),_()},[_]),E=g?.html??null,C=g?.enabled??!0;return i.jsxs("div",{"data-testid":p,style:L0,children:[i.jsxs("div",{style:P0,children:[i.jsxs("button",{type:"button","data-testid":`${p}-toggle`,onClick:()=>y(N=>!N),"aria-expanded":f,style:O0,children:[f?"▾":"▸"," ",l("layout_editor.page_settings.seo.bot_preview_title")]}),f?i.jsxs("button",{type:"button","data-testid":`${p}-refresh`,onClick:S,disabled:h,style:B0,children:["⟳ ",l("layout_editor.page_settings.seo.bot_preview_refresh")]}):null]}),f&&i.jsxs("div",{style:{marginTop:8},children:[g&&!C?i.jsxs("p",{"data-testid":`${p}-disabled`,style:W0,children:["ⓘ ",l("layout_editor.page_settings.seo.bot_preview_disabled")]}):i.jsx("div",{"data-testid":`${p}-body`,style:j0,children:h&&!E?i.jsx("p",{style:Ss,children:l("layout_editor.translation.loading")}):b?i.jsx("p",{"data-testid":`${p}-error`,style:{...Ss,color:"#dc2626"},children:b}):E==null?i.jsx("p",{style:Ss,children:l("layout_editor.page_settings.seo.bot_preview_empty")}):i.jsx("pre",{"data-testid":`${p}-code`,style:H0,children:E})}),i.jsxs("p",{"data-testid":`${p}-sample-note`,style:F0,children:["ⓘ ",l("layout_editor.page_settings.seo.bot_preview_sample_note")]})]})]})}const L0={borderTop:"1px solid #e2e8f0",paddingTop:10,marginTop:12},P0={display:"flex",alignItems:"center",gap:8},O0={background:"none",border:"none",cursor:"pointer",fontSize:13,fontWeight:600,color:"#0f172a",padding:0},B0={marginLeft:"auto",padding:"4px 10px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},W0={margin:0,fontSize:12,color:"#64748b",background:"#f8fafc",border:"1px solid #e2e8f0",borderRadius:6,padding:"8px 10px"},j0={border:"1px solid #e2e8f0",borderRadius:8,background:"#0b1020",padding:0,minHeight:80,overflow:"hidden"},H0={margin:0,padding:12,fontSize:11,lineHeight:1.5,color:"#d1fae5",fontFamily:"monospace",whiteSpace:"pre-wrap",wordBreak:"break-word",maxHeight:360,overflow:"auto"},Ss={margin:0,padding:12,fontSize:12,color:"#94a3b8"},F0={margin:"8px 0 0",fontSize:11,color:"#94a3b8"};function Ec({option:e,testIdPrefix:t}){const{id:n,friendly:o,source:r}=e;return i.jsxs("span",{style:K0,children:[i.jsx("span",{"data-testid":t?`${t}-title`:void 0,style:V0,children:o??n}),o?i.jsx("span",{"data-testid":t?`${t}-id`:void 0,style:U0,children:n}):null,r?i.jsx("span",{"data-testid":t?`${t}-source`:void 0,style:q0,children:r}):null]})}const K0={display:"inline-flex",alignItems:"baseline",gap:4,flexWrap:"wrap",minWidth:0},V0={fontSize:12,color:"#0f172a"},U0={fontSize:10,color:"#94a3b8",fontFamily:"ui-monospace, monospace"},q0={fontSize:9,fontWeight:600,color:"#7c3aed",background:"#f5f3ff",border:"1px solid #ddd6fe",borderRadius:4,padding:"0 5px",whiteSpace:"nowrap"},G0=["always","hourly","daily","weekly","monthly","yearly","never"];function Y0({getValue:e,patch:t,t:n,templateIdentifier:o,candidates:r,availableExtensions:s=[],dataSourceOptions:a=[],previewUrl:l="/",previewLocale:d="ko",seedContext:c,candidatesFetcher:p,ogPreviewFetcher:f,botFetchImpl:y,testidPrefix:g="g7le-seo"}){const m=e("meta",{}).seo,h=m&&typeof m=="object"?m:{},v=u.useCallback((K,ne)=>{const me={...e("meta",{})},Ce={...me.seo??{}};ne==null||ne===""?delete Ce[K]:Ce[K]=ne,me.seo=Ce,t("meta",me)},[e,t]),b=h.enabled===!0,x=typeof h.page_type=="string"?h.page_type:"",k=Array.isArray(h.extensions)?h.extensions:[],w=Array.isArray(h.data_sources)?h.data_sources:[],_=k.length>0&&x!=="",[S,E]=u.useState(null),[C,N]=u.useState(null),$=u.useCallback(async(K,ne)=>{if(p)return p(K,ne);if(typeof fetch>"u")return null;const me=new URLSearchParams;K&&me.set("page_type",K),Array.isArray(ne)&&ne.length>0&&me.set("extensions",JSON.stringify(ne.map(qe=>({type:qe.type,id:qe.id}))));const Ce=me.toString()?`?${me.toString()}`:"",ze=await fetch(`/api/admin/templates/${encodeURIComponent(o)}/editor/seo-candidates.json${Ce}`,{credentials:"same-origin",headers:je()}),Se=await ze.json().catch(()=>null);return ze.ok?Se?.data??null:null},[p,o]),A=u.useMemo(()=>{const me=(e("__editor",{})??{}).original?.meta?.seo;return me&&typeof me=="object"?me:void 0},[e]),z=u.useRef(A);z.current=A;const D=u.useCallback(async K=>{if(f)return f(K);if(typeof fetch>"u")return null;const ne=await fetch(`/api/admin/templates/${encodeURIComponent(o)}/editor/seo-og-preview`,{method:"POST",credentials:"same-origin",headers:je({"Content-Type":"application/json"}),body:JSON.stringify({seo:K,own_seo:z.current??null,seed_context:c??{},route_params:{}})}),me=await ne.json().catch(()=>null);return ne.ok?me?.data??null:null},[f,o,c]),O=u.useMemo(()=>JSON.stringify(k),[k]),M=u.useRef(h);M.current=h,u.useEffect(()=>{let K=!1;return(async()=>{const ne=await $(x,k);K||E(ne)})(),()=>{K=!0}},[x,O]);const B=u.useMemo(()=>JSON.stringify({og:h.og??null,twitter:h.twitter??null,structured_data:h.structured_data??null}),[h.og,h.twitter,h.structured_data]),T=u.useRef(!1);u.useEffect(()=>{let K=!1;const ne=()=>{(async()=>{const Ce=await D(M.current);K||N(Ce)})()};if(!T.current)return T.current=!0,ne(),()=>{K=!0};const me=setTimeout(ne,180);return()=>{K=!0,clearTimeout(me)}},[x,O,B]);const H=S?.page_types??[],L=S?.toggle_settings??[],I=S?.vars??[],P=u.useMemo(()=>JSON.stringify(h),[h]),F=u.useMemo(()=>e("meta")?{meta:e("meta"),components:e("components",[])}:{meta:{},components:[]},[e]),G=u.useCallback(K=>{k.some(ne=>ne.type===K.type&&ne.id===K.id)||v("extensions",[...k,{type:K.type,id:K.id}])},[k,v]),V=u.useCallback(K=>{const ne=k.filter(me=>!(me.type===K.type&&me.id===K.id));v("extensions",ne.length>0?ne:void 0)},[k,v]),[Y,Z]=u.useState(!1),[se,re]=u.useState(""),U=u.useMemo(()=>{const K=se.trim().toLowerCase();return K?L.filter(ne=>`${ne.ref} ${ne.label??""}`.toLowerCase().includes(K)):L},[L,se]),ee=typeof h.toggle_setting=="string"?h.toggle_setting:"",fe=u.useCallback(K=>{const me=w.includes(K)?w.filter(Ce=>Ce!==K):[...w,K];v("data_sources",me.length>0?me:void 0)},[w,v]);return i.jsxs("div",{"data-testid":g,style:{display:"flex",flexDirection:"column",gap:14},children:[i.jsxs("div",{style:Bt,children:[i.jsx(Ar,{checked:b,onChange:K=>v("enabled",K),testid:`${g}-enabled`,label:n("layout_editor.page_settings.seo.enabled")}),b?null:i.jsxs("p",{"data-testid":`${g}-disabled-note`,style:eo,children:["ⓘ ",n("layout_editor.page_settings.seo.disabled_note")]})]}),i.jsx(Tr,{disabled:!b,testid:`${g}-body`,style:{display:"flex",flexDirection:"column",gap:14},children:i.jsxs(i.Fragment,{children:[i.jsxs("div",{style:Bt,children:[i.jsx("label",{style:qt,children:n("layout_editor.page_settings.seo.page_type")}),H.length>0?i.jsxs("select",{"data-testid":`${g}-page-type`,value:x,onChange:K=>v("page_type",K.target.value),style:zr,children:[i.jsx("option",{value:"",children:n("layout_editor.page_settings.seo.page_type_none")}),H.map(K=>i.jsxs("option",{value:K.value,children:[K.label??K.value,K.owner?.name?` (${K.owner.name})`:""]},K.value))]}):i.jsxs(i.Fragment,{children:[i.jsx("input",{type:"text","data-testid":`${g}-page-type-free`,value:x,placeholder:n("layout_editor.page_settings.seo.page_type_free_placeholder"),onChange:K=>v("page_type",K.target.value),style:zr}),i.jsxs("p",{"data-testid":`${g}-page-type-free-note`,style:eo,children:["ⓘ ",n("layout_editor.page_settings.seo.page_type_free_note")]})]})]}),i.jsxs("div",{style:Bt,children:[i.jsxs("label",{style:qt,children:["★ ",n("layout_editor.page_settings.seo.extensions")]}),i.jsxs("div",{"data-testid":`${g}-extensions`,style:{display:"flex",flexWrap:"wrap",gap:6},children:[k.map(K=>i.jsxs("span",{"data-testid":`${g}-extension-${K.type}-${K.id}`,style:Ic,children:[K.name??`${K.type}:${K.id}`,i.jsx("button",{type:"button","data-testid":`${g}-extension-remove-${K.type}-${K.id}`,onClick:()=>V(K),style:Ac,children:"✕"})]},`${K.type}:${K.id}`)),i.jsxs("button",{type:"button","data-testid":`${g}-extension-add`,onClick:()=>Z(K=>!K),style:Z0,children:["+ ",n("layout_editor.page_settings.seo.add_extension")]})]}),Y&&i.jsx("div",{"data-testid":`${g}-extension-picker`,style:Tc,children:s.length===0?i.jsx("p",{style:eo,children:n("layout_editor.page_settings.seo.no_extension_candidate")}):s.map(K=>i.jsx("button",{type:"button","data-testid":`${g}-extension-option-${K.type}-${K.id}`,onClick:()=>{G(K),Z(!1)},style:Dc,children:K.name??`${K.type}:${K.id}`},`${K.type}:${K.id}`))})]}),!_&&i.jsxs("div",{"data-testid":`${g}-defaults-precondition`,style:J0,children:["⚠ ",n("layout_editor.page_settings.seo.defaults_precondition"),i.jsxs("div",{"data-testid":`${g}-defaults-missing`,style:{fontSize:11,marginTop:4},children:[k.length===0?n("layout_editor.page_settings.seo.missing_extensions"):"",x===""?` ${n("layout_editor.page_settings.seo.missing_page_type")}`:""]})]}),i.jsxs("div",{style:Bt,children:[i.jsx("label",{style:qt,children:n("layout_editor.page_settings.seo.toggle_setting")}),i.jsxs("div",{"data-testid":`${g}-toggle-setting`,children:[ee?i.jsxs("div",{style:{display:"flex",gap:6,alignItems:"center",marginBottom:4},children:[i.jsx("code",{style:Ic,children:ee}),i.jsx("button",{type:"button","data-testid":`${g}-toggle-setting-clear`,onClick:()=>v("toggle_setting",void 0),style:Ac,children:"✕"})]}):null,i.jsx("input",{type:"text","data-testid":`${g}-toggle-setting-search`,value:se,placeholder:n("layout_editor.page_settings.seo.toggle_setting_search"),onChange:K=>re(K.target.value),style:zr}),se.trim()!==""&&i.jsx("div",{"data-testid":`${g}-toggle-setting-results`,style:Tc,children:U.length===0?i.jsx("p",{style:eo,children:n("layout_editor.inline_binding.no_results")}):U.map(K=>i.jsx("button",{type:"button","data-testid":`${g}-toggle-setting-option-${K.ref}`,onClick:()=>{v("toggle_setting",K.ref),re("")},style:Dc,children:K.label??K.ref},K.ref))})]})]}),i.jsxs("div",{style:Bt,children:[i.jsx("label",{style:qt,children:n("layout_editor.page_settings.seo.data_sources")}),i.jsx("p",{style:eo,children:n("layout_editor.page_settings.seo.data_sources_hint")}),i.jsx("div",{"data-testid":`${g}-data-sources`,style:{display:"flex",flexDirection:"column",gap:6},children:a.length===0?i.jsx("span",{style:eo,children:n("layout_editor.page_settings.seo.data_sources_empty")}):a.map(K=>{const ne=w.includes(K.id);return i.jsxs("label",{"data-testid":`${g}-data-source-${K.id}`,style:X0(ne),children:[i.jsx("input",{type:"checkbox","data-testid":`${g}-data-source-check-${K.id}`,checked:ne,onChange:()=>fe(K.id)}),i.jsx(Ec,{option:K,testIdPrefix:`${g}-data-source-${K.id}`})]},K.id)})})]}),i.jsxs("div",{style:Bt,children:[i.jsx("label",{style:qt,children:n("layout_editor.page_settings.seo.priority")}),i.jsx("input",{type:"range","data-testid":`${g}-priority`,min:0,max:1,step:.1,value:typeof h.priority=="number"?h.priority:.5,onChange:K=>v("priority",Number(K.target.value))}),i.jsx("span",{"data-testid":`${g}-priority-value`,style:{fontSize:12,color:"#475569"},children:typeof h.priority=="number"?h.priority:.5})]}),i.jsxs("div",{style:Bt,children:[i.jsx("label",{style:qt,children:n("layout_editor.page_settings.seo.changefreq")}),i.jsxs("select",{"data-testid":`${g}-changefreq`,value:typeof h.changefreq=="string"?h.changefreq:"",onChange:K=>v("changefreq",K.target.value),style:zr,children:[i.jsx("option",{value:"",children:n("layout_editor.page_settings.seo.use_default")}),G0.map(K=>i.jsx("option",{value:K,children:K},K))]})]}),i.jsxs("div",{style:Bt,children:[i.jsx("label",{style:qt,children:n("layout_editor.page_settings.seo.title")}),i.jsx(vt,{value:typeof h.title=="string"?h.title:"",onChange:K=>v("title",K??""),t:n,candidates:r,testidPrefix:`${g}-title`,enableExpressionTree:!0,expressionTreeCollapsible:!0})]}),i.jsxs("div",{style:Bt,children:[i.jsx("label",{style:qt,children:n("layout_editor.page_settings.seo.description")}),i.jsx(vt,{value:typeof h.description=="string"?h.description:"",onChange:K=>v("description",K??""),t:n,candidates:r,testidPrefix:`${g}-description`,enableExpressionTree:!0,expressionTreeCollapsible:!0})]}),i.jsx(e0,{og:h.og,twitter:h.twitter,onChangeOg:K=>v("og",Object.keys(K).length>0?K:void 0),onChangeTwitter:K=>v("twitter",Object.keys(K).length>0?K:void 0),ogPreview:C?.og,twitterPreview:C?.twitter,defaultsAvailable:C?.defaultsAvailable??!1,candidates:r,t:n}),i.jsxs("div",{style:Bt,children:[i.jsx("label",{style:qt,children:n("layout_editor.page_settings.seo.structured_data")}),i.jsx(g0,{value:h.structured_data,onChange:K=>v("structured_data",K??void 0),autoBlock:C?.structured?.autoBlock,autoMeta:C?.structured?.autoMeta,lockedByFilter:C?.structured?.lockedByFilter,filteredBlock:C?.structured?.filteredBlock,pageType:x,candidates:r,t:n})]}),i.jsxs("div",{style:Bt,children:[i.jsx("label",{style:qt,children:n("layout_editor.page_settings.seo.vars")}),i.jsx($0,{vars:h.vars,onChange:K=>v("vars",Object.keys(K).length>0?K:void 0),varCandidates:I,gatingMet:_,candidates:r,t:n})]}),i.jsx(M0,{templateIdentifier:o,layout:F,url:l,locale:d,seedContext:c,settingsSignature:P,t:n,fetchImpl:y})]})})]})}const Bt={display:"flex",flexDirection:"column",gap:4},qt={fontSize:12,fontWeight:600,color:"#334155"},zr={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,width:"100%",minWidth:0,boxSizing:"border-box"},eo={margin:"4px 0 0",fontSize:11,color:"#94a3b8"},X0=e=>({display:"flex",alignItems:"center",gap:8,padding:"6px 10px",fontSize:12,border:`1px solid ${e?"#3b82f6":"#e2e8f0"}`,borderRadius:8,background:e?"#eff6ff":"#fff",cursor:"pointer",minWidth:0}),J0={fontSize:12,color:"#b45309",background:"#fffbeb",border:"1px solid #fcd34d",borderRadius:6,padding:"8px 10px"},Ic={display:"inline-flex",alignItems:"center",gap:4,fontSize:11,padding:"3px 8px",borderRadius:999,background:"#eef2ff",color:"#3730a3"},Ac={border:"none",background:"transparent",cursor:"pointer",color:"#6366f1",fontSize:11,padding:0},Z0={fontSize:11,padding:"3px 10px",borderRadius:999,border:"1px dashed #cbd5e1",background:"#f8fafc",color:"#475569",cursor:"pointer"},Tc={marginTop:4,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",padding:6,display:"flex",flexDirection:"column",gap:2},Dc={textAlign:"left",border:"none",background:"transparent",cursor:"pointer",padding:"5px 8px",borderRadius:4,fontSize:12,color:"#0f172a"},Q0={setState:"state",loadFromLocalStorage:"state",saveToLocalStorage:"state",navigate:"nav",openWindow:"nav",navigateBack:"nav",navigateForward:"nav",replaceUrl:"nav",toast:"notify",openModal:"notify",closeModal:"notify",showAlert:"notify",setError:"notify",refetchDataSource:"data",appendDataSource:"data",updateDataSource:"data",apiCall:"data",scrollIntoView:"etc",login:"etc",logout:"etc",setLocale:"locale",emitEvent:"etc",conditions:"flow",sequence:"flow",parallel:"flow",switch:"flow",suppress:"flow",showErrorPage:"error"},R0={state:"layout_editor.action.group.state",nav:"layout_editor.action.group.nav",notify:"layout_editor.action.group.notify",data:"layout_editor.action.group.data",locale:"layout_editor.action.group.locale",etc:"layout_editor.action.group.etc",flow:"layout_editor.action.group.flow",error:"layout_editor.action.group.error",extension:"layout_editor.action.group.extension"},ew={init:["state","data","notify","nav","locale","etc","flow","error","extension"],component:["nav","notify","data","state","locale","etc","flow","error","extension"]};function tw(e,t){const n=e?.[t];if(n&&typeof n=="object"){const o=n.__source;if(o&&typeof o=="object")return o}return null}function nw(e,t){return!e||e.kind==="core"?t("layout_editor.action.source_core"):e.id??t(`layout_editor.action.source_${e.kind}`)}function Nc(e,t,n){return typeof e!="string"?n:e.startsWith("$t:")?t(e.slice(3)):e}function ow({recipes:e,t,onAdd:n,context:o="init",testIdPrefix:r="g7le-action-add"}){const[s,a]=u.useState(!1),[l,d]=u.useState(""),c=u.useMemo(()=>vo(e),[e]),p=u.useMemo(()=>{const f=ew[o],y=new Map;for(const m of c){const h=tw(e,m.id),b=h!=null&&h.kind!=="core"?"extension":Q0[m.id]??"etc",x=y.get(b)??[];x.push({recipe:m,source:h}),y.set(b,x)}const g=l.trim().toLowerCase();return f.map(m=>{let h=y.get(m)??[];return g&&(h=h.filter(({recipe:v})=>Nc(v.label,t,v.id).toLowerCase().includes(g)||v.id.toLowerCase().includes(g)||v.build.handler.toLowerCase().includes(g))),{groupKey:m,items:h}}).filter(m=>m.items.length>0)},[c,e,o,l,t]);return c.length===0?i.jsx("div",{"data-testid":`${r}-empty`,style:fw,children:t("layout_editor.action.no_recipes")}):i.jsxs("div",{className:r,"data-testid":r,style:{minWidth:0},children:[i.jsxs("button",{type:"button","data-testid":`${r}-toggle`,onClick:()=>a(f=>!f),style:rw,children:[t("layout_editor.action.add_action")," ▾"]}),s?i.jsxs("div",{"data-testid":`${r}-list`,style:iw,children:[i.jsx("input",{type:"text","data-testid":`${r}-search`,value:l,placeholder:t("layout_editor.action.add_search"),onChange:f=>d(f.target.value),style:sw}),p.map(({groupKey:f,items:y})=>i.jsxs("div",{"data-testid":`${r}-group-${f}`,children:[i.jsxs("div",{style:aw,children:["━━ ",t(R0[f]??f)," ━━"]}),y.map(({recipe:g,source:m})=>i.jsxs("button",{type:"button","data-testid":`g7le-init-action-spec-${g.id}`,onClick:()=>{n(vr(g,{})),a(!1),d("")},style:lw,children:[i.jsx("span",{style:dw,children:Nc(g.label,t,g.id)}),i.jsxs("span",{"data-testid":`g7le-init-action-spec-source-${g.id}`,style:cw,children:["〔",nw(m,t),"〕"]}),i.jsx("code",{style:uw,children:g.build.handler})]},g.id))]},f)),p.length===0?i.jsx("div",{"data-testid":`${r}-no-match`,style:pw,children:t("layout_editor.action.add_no_match")}):null]}):null]})}const rw={padding:"6px 12px",fontSize:12,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},iw={marginTop:6,border:"1px solid #cbd5e1",borderRadius:8,background:"#fff",maxHeight:320,overflowY:"auto",padding:8},sw={width:"100%",padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,marginBottom:6,boxSizing:"border-box"},aw={fontSize:11,fontWeight:700,color:"#94a3b8",padding:"6px 4px 2px"},lw={display:"flex",alignItems:"center",gap:6,width:"100%",textAlign:"left",padding:"6px 8px",fontSize:12,border:"none",background:"transparent",cursor:"pointer",borderRadius:6},dw={flex:1,minWidth:0,color:"#0f172a"},cw={fontSize:10,color:"#64748b",whiteSpace:"nowrap"},uw={fontSize:10,color:"#cbd5e1",fontFamily:"monospace",whiteSpace:"nowrap"},pw={fontSize:12,color:"#94a3b8",padding:"8px 4px"},fw={fontSize:12,color:"#94a3b8",padding:"8px 0"};function gw(e,t){return t.startsWith("$t:")?e(t.slice(3)):t}function hw({action:e,recipe:t,values:n={},t:o,missingRequired:r=!1,showCodeButton:s=!0,testIdPrefix:a="g7le-action-preview"}){const[l,d]=u.useState(!1),c=t?Ld(t,n,p=>gw(o,p)):typeof e.handler=="string"?e.handler:o("layout_editor.action.advanced_action");return i.jsxs("div",{className:a,"data-testid":a,style:yw,children:[r?i.jsxs("span",{"data-testid":`${a}-missing`,style:mw,children:["⚠ ",o("layout_editor.action.summary_missing_required")]}):i.jsx("span",{"data-testid":`${a}-summary`,style:bw,children:c}),s?i.jsx("button",{type:"button","data-testid":`${a}-code-toggle`,onClick:()=>d(p=>!p),style:vw,"aria-expanded":l,"aria-label":o("layout_editor.action.view_code"),children:""}):null,s&&l?i.jsx("pre",{"data-testid":`${a}-code`,style:_w,children:JSON.stringify(e,null,2)}):null]})}const yw={display:"flex",flexWrap:"wrap",alignItems:"center",gap:6,minWidth:0},bw={flex:1,minWidth:0,fontSize:12,color:"#475569"},mw={flex:1,minWidth:0,fontSize:12,color:"#b45309"},vw={border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer",fontSize:11,padding:"2px 6px",whiteSpace:"nowrap"},_w={flexBasis:"100%",margin:"6px 0 0",padding:8,background:"#0f172a",color:"#e2e8f0",borderRadius:6,fontSize:11,overflow:"auto"},zc="__g7le_custom__";function Mr({value:e,onChange:t,candidates:n,placeholder:o,testid:r,t:s,bindingCandidates:a}){const l=e==null?"":String(e),d=Array.isArray(n)&&n.length>0,c=d&&n.some(y=>y.value===l),[p,f]=u.useState(!d||l!==""&&!c);return u.useEffect(()=>{l!==""&&d&&!c&&f(!0)},[l,d,c]),d&&!p?i.jsxs("div",{style:Oc,children:[i.jsxs("select",{className:"g7le-widget g7le-widget--picker","data-testid":r,value:c?l:"",onChange:y=>{const g=y.target.value;if(g===zc){f(!0);return}t(g===""?void 0:g)},style:{...Lc,flex:1,minWidth:0},children:[i.jsx("option",{value:"",children:s("layout_editor.control.default")}),n.map(y=>i.jsx("option",{value:y.value,children:y.label},y.value)),i.jsx("option",{value:zc,children:s("layout_editor.recipe.custom_input")})]}),i.jsx("button",{type:"button","data-testid":`${r}-to-custom`,title:s("layout_editor.recipe.custom_input"),"aria-label":s("layout_editor.recipe.custom_input"),onClick:()=>f(!0),style:Bc,children:"✎"})]}):i.jsxs("div",{style:Oc,children:[i.jsx("div",{style:{flex:1,minWidth:0},children:i.jsx(Ve,{value:l,onChange:y=>t(y===""?void 0:y),t:s,candidates:a,placeholder:o,testidPrefix:`${r}-input`})}),d&&i.jsx("button",{type:"button","data-testid":`${r}-pick`,title:s("layout_editor.recipe.pick_from_list"),onClick:()=>{f(!1),t(void 0)},style:Bc,children:"≡"})]})}function xw({value:e,onChange:t,candidates:n,t:o,bindingCandidates:r}){return i.jsx(Mr,{value:e,onChange:t,candidates:n,placeholder:o("layout_editor.recipe.page_placeholder"),testid:"g7le-widget-page-picker",t:o,bindingCandidates:r})}function Mc({value:e,onChange:t,candidates:n,t:o,bindingCandidates:r}){return i.jsx(Mr,{value:e,onChange:t,candidates:n,placeholder:o("layout_editor.recipe.datasource_placeholder"),testid:"g7le-widget-datasource-picker",t:o,bindingCandidates:r})}function Cs({value:e,onChange:t,candidates:n,t:o,bindingCandidates:r}){return i.jsx(Mr,{value:e,onChange:t,candidates:n,placeholder:o("layout_editor.recipe.state_key_placeholder"),testid:"g7le-widget-state-key-picker",t:o,bindingCandidates:r})}function ww({value:e,onChange:t,candidates:n,t:o,bindingCandidates:r}){return i.jsx(Mr,{value:e,onChange:t,candidates:n,placeholder:o("layout_editor.recipe.modal_placeholder"),testid:"g7le-widget-modal-picker",t:o,bindingCandidates:r})}function kw({value:e,onChange:t,t:n,bindingCandidates:o}){const r=typeof e=="string"?e:e==null?"":String(e);return i.jsx("div",{className:"g7le-widget g7le-widget--i18n-text","data-testid":"g7le-widget-i18n-text",style:{flex:1,minWidth:100,width:"100%"},children:i.jsx(vt,{value:r,onChange:s=>t(s===""?void 0:s),t:n,placeholder:n("layout_editor.recipe.i18n_text_placeholder"),testidPrefix:"g7le-widget-i18n-text-field",candidates:o,enableExpressionTree:!0,expressionTreeCollapsible:!0})})}function Sw({value:e,onChange:t,candidates:n,t:o}){const r=e==null?"":String(e),s=r.split(",").map(d=>d.trim()).filter(Boolean),a=Array.isArray(n)&&n.length>0,l=d=>{if(!d)return;const c=[...s,d];t(c.join(", "))};return i.jsxs("div",{className:"g7le-widget g7le-widget--binding-list","data-testid":"g7le-widget-binding-list",style:{display:"flex",flexDirection:"column",gap:4,width:"100%"},children:[i.jsx("input",{type:"text","data-testid":"g7le-widget-binding-list-input",value:r,placeholder:o("layout_editor.recipe.datasource_placeholder"),onChange:d=>t(d.target.value===""?void 0:d.target.value),style:Pc}),a?i.jsxs("select",{className:"g7le-widget g7le-widget--picker","data-testid":"g7le-widget-binding-list-add",value:"",onChange:d=>{l(d.target.value),d.target.value=""},style:{...Lc,minWidth:0},children:[i.jsx("option",{value:"",children:o("layout_editor.recipe.pick_from_list")}),n.map(d=>i.jsx("option",{value:d.value,children:d.label},d.value))]}):null]})}function Cw({value:e,onChange:t}){const n=e==null?"":String(e);return i.jsx("input",{type:"text",className:"g7le-widget g7le-widget--text","data-testid":"g7le-widget-text",value:n,onChange:o=>t(o.target.value===""?void 0:o.target.value),style:Pc})}const Lc={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",minWidth:140,width:"100%"},Pc={flex:1,padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,minWidth:100,width:"100%"},Oc={display:"flex",gap:4,alignItems:"flex-start",width:"100%"},Bc={padding:"4px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#64748b",cursor:"pointer"};function $w(e){if(e==null)return"null";if(Array.isArray(e))return"list";switch(typeof e){case"number":return"number";case"boolean":return"boolean";case"string":return"string";default:return"object"}}function Wc(e){switch(e){case"string":return"";case"number":return 0;case"boolean":return!1;case"null":return null;case"list":return[];case"object":return{};default:return null}}function $s(e){return/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(e)}function Ew(e,t,n){return t.includes(n)?"self":e.includes(n)?"inherited":"self"}function Iw(e){const t=e.state&&typeof e.state=="object"&&Object.keys(e.state).length>0,n=e.initLocal&&typeof e.initLocal=="object"?e.initLocal:void 0;if(!t)return{initLocal:n??{},migrated:!1};const o={...e.state};if(n)for(const[r,s]of Object.entries(n))o[r]=s;return{initLocal:o,migrated:!0}}const jc=[{kind:"string",labelKey:"layout_editor.initstate.kind_string"},{kind:"number",labelKey:"layout_editor.initstate.kind_number"},{kind:"boolean",labelKey:"layout_editor.initstate.kind_boolean"},{kind:"null",labelKey:"layout_editor.initstate.kind_null"},{kind:"list",labelKey:"layout_editor.initstate.kind_list"},{kind:"object",labelKey:"layout_editor.initstate.kind_object"}];function Dn({value:e,onChange:t,t:n,path:o,scope:r,depth:s=0,renderStringLeaf:a}){const l=$w(e),d=c=>{t(Wc(c))};return i.jsxs("div",{className:"g7le-initstate-value",style:{paddingLeft:s>0?12:0,minWidth:0},children:[i.jsxs("div",{style:Tw,children:[i.jsx("select",{"data-testid":`g7le-initstate-type-${o}`,value:l,onChange:c=>d(c.target.value),style:Hc,children:jc.map(({kind:c,labelKey:p})=>i.jsx("option",{value:c,children:n(p)},c))}),l==="string"?a?i.jsx("div",{style:{flex:1,minWidth:0},children:a({value:String(e??""),onChange:c=>t(c),testidPrefix:`g7le-initstate-value-${o}`})}):i.jsx("input",{type:"text","data-testid":`g7le-initstate-value-${o}`,value:String(e??""),onChange:c=>t(c.target.value),style:Fc}):l==="number"?i.jsx("input",{type:"number","data-testid":`g7le-initstate-value-${o}`,value:typeof e=="number"?e:"",onChange:c=>{const p=parseFloat(c.target.value);t(Number.isNaN(p)?0:p)},style:Fc}):l==="boolean"?i.jsx(Ar,{checked:!!e,onChange:t,testid:`g7le-initstate-value-${o}`,label:n(e?"layout_editor.overlay.on":"layout_editor.overlay.off")}):l==="null"?i.jsx("span",{"data-testid":`g7le-initstate-value-${o}`,style:Dw,children:"null"}):null]}),l==="list"?i.jsxs("div",{style:Kc,children:[e.map((c,p)=>i.jsxs("div",{style:Nw,children:[i.jsxs("span",{style:zw,children:["[",p,"]"]}),i.jsx("div",{style:{flex:1,minWidth:0},children:i.jsx(Dn,{value:c,onChange:f=>{const y=e.slice();y[p]=f,t(y)},t:n,path:`${o}.${p}`,scope:r,depth:s+1,renderStringLeaf:a})}),i.jsx("button",{type:"button","data-testid":`g7le-initstate-item-remove-${o}.${p}`,onClick:()=>t(e.filter((f,y)=>y!==p)),style:Vc,children:"✕"})]},p)),i.jsxs("button",{type:"button","data-testid":`g7le-initstate-item-add-${o}`,onClick:()=>t([...e,""]),style:Uc,children:["+ ",n("layout_editor.initstate.add_item")]})]}):null,l==="object"?i.jsx(Aw,{value:e,onChange:t,t:n,path:o,scope:r,depth:s+1,renderStringLeaf:a}):null]})}function Aw({value:e,onChange:t,t:n,path:o,scope:r,depth:s,renderStringLeaf:a}){const[l,d]=u.useState(""),[c,p]=u.useState("string"),[f,y]=u.useState(null),g=Object.entries(e),m=()=>{const h=l.trim();if(!h){y(n("layout_editor.initstate.name_required"));return}if(!$s(h)){y(n("layout_editor.initstate.name_invalid"));return}if(h in e){y(n("layout_editor.initstate.name_duplicate"));return}t({...e,[h]:Wc(c)}),d(""),y(null)};return i.jsxs("div",{style:Kc,children:[g.map(([h,v])=>i.jsxs("div",{style:Mw,children:[i.jsx("code",{style:Lw,children:h}),i.jsx("div",{style:{flex:1,minWidth:0},children:i.jsx(Dn,{value:v,onChange:b=>t({...e,[h]:b}),t:n,path:`${o}.${h}`,scope:r,depth:s,renderStringLeaf:a})}),i.jsx("button",{type:"button","data-testid":`g7le-initstate-subremove-${o}.${h}`,onClick:()=>{const b={...e};delete b[h],t(b)},style:Vc,children:"✕"})]},h)),i.jsxs("div",{style:Pw,children:[i.jsx("input",{type:"text","data-testid":`g7le-initstate-subkey-input-${o}`,value:l,placeholder:n("layout_editor.initstate.subkey_name"),onChange:h=>{d(h.target.value),f&&y(null)},onKeyDown:h=>{h.key==="Enter"&&(h.preventDefault(),m())},"aria-invalid":f?!0:void 0,style:{...Ow,...f?Bw:null}}),i.jsx("select",{"data-testid":`g7le-initstate-subkey-kind-${o}`,value:c,onChange:h=>p(h.target.value),"aria-label":n("layout_editor.initstate.value_kind"),style:Hc,children:jc.map(({kind:h,labelKey:v})=>i.jsx("option",{value:h,children:n(v)},h))}),i.jsxs("button",{type:"button","data-testid":`g7le-initstate-subkey-add-${o}`,onClick:m,style:Uc,children:["+ ",n("layout_editor.initstate.add_subkey")]})]}),f?i.jsx("p",{"data-testid":`g7le-initstate-subkey-error-${o}`,style:Ww,children:f}):null]})}const Tw={display:"flex",gap:6,alignItems:"center",minWidth:0},Hc={padding:"4px 6px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,color:"#64748b"},Fc={flex:1,minWidth:0,padding:"4px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,boxSizing:"border-box"},Dw={fontSize:12,color:"#94a3b8",fontFamily:"monospace"},Kc={display:"flex",flexDirection:"column",gap:6,marginTop:6,paddingLeft:8,borderLeft:"2px solid #e2e8f0"},Nw={display:"flex",gap:6,alignItems:"flex-start",minWidth:0},zw={fontSize:11,color:"#94a3b8",minWidth:28,paddingTop:4},Mw={display:"flex",gap:6,alignItems:"flex-start",minWidth:0},Lw={fontSize:11,color:"#475569",fontFamily:"monospace",minWidth:72,paddingTop:4},Vc={border:"none",background:"transparent",color:"#94a3b8",cursor:"pointer",fontSize:12},Uc={alignSelf:"flex-start",padding:"2px 8px",fontSize:11,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},Pw={display:"flex",gap:6,alignItems:"center"},Ow={padding:"4px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6},Bw={border:"1px solid #dc2626"},Ww={margin:"2px 0 0",fontSize:11,color:"#dc2626"},jw=new Set(["text","data-chip","number"]),Hw=new Set(["select","toggle","page-picker","datasource-picker","state-key-picker","modal-picker","component-target-picker","tag-input","locale-picker","i18n-text","segmented","slider","color","image"]);function Fw(e,t,n){return typeof e!="string"?n:e.startsWith("$t:")?t(e.slice(3)):e}function Kw(e,t){if(e==="page-picker")return t.pageCandidates;if(e==="datasource-picker")return t.dataSourceCandidates;if(e==="state-key-picker")return t.stateKeyCandidates;if(e==="modal-picker")return t.modalCandidates}function Vw({param:e,value:t,t:n,pools:o,onChange:r,testIdPrefix:s,renderActionList:a,siblingValues:l}){const d=Fw(e.label,n,e.key);if(e.advanced)return i.jsxs("div",{"data-testid":`${s}-locked-${e.key}`,style:ek,children:[i.jsx("span",{style:tk,children:d}),i.jsxs("span",{style:nk,children:["[",n("layout_editor.action.advanced"),"] ",n("layout_editor.init_actions.advanced_param_hint")]})]});if(e.widget==="action-list"&&a){const f=Array.isArray(t)?t.filter(y=>!!y&&typeof y=="object"):[];return i.jsxs("div",{"data-testid":`${s}-param-${e.key}`,style:{display:"flex",flexDirection:"column",gap:4},children:[i.jsx("span",{style:to,children:d}),a(f,y=>r(y.length===0?void 0:y),`${s}-${e.key}`,e.key)]})}if(e.widget==="branch-list"&&a){const f=Array.isArray(t)?t.filter(y=>!!y&&typeof y=="object"):[];return i.jsxs("div",{"data-testid":`${s}-param-${e.key}`,style:{display:"flex",flexDirection:"column",gap:4},children:[i.jsx("span",{style:to,children:d}),i.jsx(Uw,{branches:f,onChange:y=>r(y.length===0?void 0:y),t:n,pools:o,renderActionList:a,testIdPrefix:`${s}-${e.key}`})]})}if(e.widget==="key-value")return i.jsxs("div",{"data-testid":`${s}-param-${e.key}`,style:{display:"flex",flexDirection:"column",gap:4},children:[i.jsx("span",{style:to,children:d}),i.jsx(Jw,{value:t,onChange:r,t:n,candidates:o.bindingCandidates,testIdPrefix:`${s}-kv-${e.key}`})]});if(e.widget==="state-key-value")return i.jsxs("div",{"data-testid":`${s}-param-${e.key}`,style:{display:"flex",flexDirection:"column",gap:4},children:[i.jsx("span",{style:to,children:d}),i.jsx(Zw,{value:t,onChange:r,t:n,candidates:o.bindingCandidates,stateKeyCandidates:o.stateKeyCandidates,param:e,siblingTarget:l?.target,testIdPrefix:`${s}-skv-${e.key}`})]});if(jw.has(e.widget??"")||!Hw.has(e.widget??""))return i.jsxs("div",{"data-testid":`${s}-param-${e.key}`,style:Gc,children:[i.jsx("span",{style:to,children:d}),i.jsx("div",{style:{flex:1,minWidth:0},children:i.jsx(Ve,{value:typeof t=="string"?t:t==null?"":String(t),onChange:f=>r(f===""?void 0:f),t:n,candidates:o.bindingCandidates,placeholder:e.widget==="number"?n("layout_editor.action_param.number_placeholder"):void 0,testidPrefix:`${s}-chip-${e.key}`})})]});const p=An(e.widget);return i.jsxs("div",{"data-testid":`${s}-param-${e.key}`,style:Gc,children:[i.jsx("span",{style:to,children:d}),i.jsx("div",{style:{flex:1,minWidth:0},children:p?i.jsx(p,{control:{...e},value:t,onChange:r,t:n,candidates:Kw(e.widget,o),bindingCandidates:o.bindingCandidates}):i.jsx(Ve,{value:typeof t=="string"?t:t==null?"":String(t),onChange:f=>r(f===""?void 0:f),t:n,candidates:o.bindingCandidates,testidPrefix:`${s}-chip-${e.key}`})})]})}function Uw({branches:e,onChange:t,t:n,pools:o,renderActionList:r,testIdPrefix:s}){const a=y=>Array.isArray(y)?y.filter(g=>!!g&&typeof g=="object"):y&&typeof y=="object"?[y]:[],l=y=>{if(y.length!==0)return y.length===1?y[0]:y},d=(y,g)=>{const m=e.slice();m[y]=g,t(m)},c=y=>{t(e.filter((g,m)=>m!==y))},p=(y,g)=>{if(g<0||g>=e.length)return;const m=e.slice(),[h]=m.splice(y,1);m.splice(g,0,h),t(m)},f=()=>{t([...e,{if:"",then:void 0}])};return i.jsxs("div",{"data-testid":`${s}-branches`,style:{display:"flex",flexDirection:"column",gap:8},children:[e.map((y,g)=>{const m=typeof y.if=="string"?y.if:"",h=a(y.then);return i.jsxs("div",{"data-testid":`${s}-branch-${g}`,style:qw,children:[i.jsxs("div",{style:Gw,children:[i.jsxs("span",{style:Yw,children:[n(m?"layout_editor.action_recipe.conditions.branch_when":"layout_editor.action_recipe.conditions.branch_else")," ",g+1]}),i.jsxs("span",{style:{display:"flex",gap:4},children:[i.jsx("button",{type:"button","data-testid":`${s}-branch-${g}-up`,onClick:()=>p(g,g-1),disabled:g===0,style:Es,title:n("layout_editor.action_recipe.conditions.move_up"),children:"↑"}),i.jsx("button",{type:"button","data-testid":`${s}-branch-${g}-down`,onClick:()=>p(g,g+1),disabled:g===e.length-1,style:Es,title:n("layout_editor.action_recipe.conditions.move_down"),children:"↓"}),i.jsx("button",{type:"button","data-testid":`${s}-branch-${g}-remove`,onClick:()=>c(g),style:Es,title:n("layout_editor.action_recipe.conditions.remove_branch"),children:"✕"})]})]}),i.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:3},children:[i.jsx("span",{style:qc,children:n("layout_editor.action_recipe.conditions.param_if")}),i.jsx(Ve,{value:m,onChange:v=>d(g,{...y,if:v}),t:n,candidates:o.bindingCandidates,testidPrefix:`${s}-branch-${g}-if`})]}),i.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:3},children:[i.jsx("span",{style:qc,children:n("layout_editor.action_recipe.conditions.param_then")}),r(h,v=>d(g,{...y,then:l(v)}),`${s}-branch-${g}-then`)]})]},g)}),i.jsxs("button",{type:"button","data-testid":`${s}-add-branch`,onClick:f,style:Xw,children:["+ ",n("layout_editor.action_recipe.conditions.add_branch")]})]})}const qw={border:"1px solid #e2e8f0",borderRadius:6,padding:8,display:"flex",flexDirection:"column",gap:6,background:"#f8fafc"},Gw={display:"flex",justifyContent:"space-between",alignItems:"center"},Yw={fontSize:12,fontWeight:700,color:"#475569"},qc={fontSize:11,color:"#64748b"},Es={border:"1px solid #cbd5e1",background:"#fff",borderRadius:4,width:22,height:22,cursor:"pointer",fontSize:12,lineHeight:"1",color:"#475569"},Xw={border:"1px dashed #94a3b8",background:"#fff",borderRadius:6,padding:"6px 10px",cursor:"pointer",fontSize:12,color:"#475569",alignSelf:"flex-start"};function Jw({value:e,onChange:t,t:n,candidates:o,testIdPrefix:r,renderKeyInput:s}){const a=e&&typeof e=="object"&&!Array.isArray(e)?Object.entries(e).map(([l,d])=>({key:l,value:typeof d=="string"?d:d==null?"":String(d)})):[];return i.jsx(Dr,{value:a,onChange:l=>{const d={};for(const c of l)c.key.trim()!==""&&(d[c.key]=c.value??"");t(Object.keys(d).length===0?void 0:d)},keyField:"key",valueField:"value",t:n,candidates:o,testidPrefix:r,keyPlaceholder:n("layout_editor.action_param.key_placeholder"),valuePlaceholder:n("layout_editor.action_param.value_placeholder"),addLabel:n("layout_editor.action_param.add_pair"),renderKeyInput:s})}function Zw({value:e,onChange:t,t:n,candidates:o,stateKeyCandidates:r,param:s,siblingTarget:a,testIdPrefix:l}){const d=e&&typeof e=="object"&&!Array.isArray(e)?Object.entries(e):[],c=typeof a=="string"?a:"";if((c.startsWith("_local.")||c.startsWith("global.")||c.startsWith("$parent._local.")||c.startsWith("$root._local."))&&d.length===1&&d[0][0]==="value"){const b=d[0][1];return i.jsx("div",{"data-testid":`${l}-path-value`,style:{display:"flex",flexDirection:"column",gap:4},children:i.jsx(Dn,{value:b,onChange:x=>t(x===void 0?void 0:{value:x}),t:n,path:"value",scope:`${l}-path-value-val`,renderStringLeaf:x=>i.jsx(Ve,{value:x.value,onChange:x.onChange,t:n,candidates:o,testidPrefix:x.testidPrefix})})})}const y=(b,x)=>{const k={};for(const[w,_]of d)k[w]=w===b?x:_;t(Object.keys(k).length===0?void 0:k)},g=b=>{const x={};for(const[k,w]of d)k!==b&&(x[k]=w);t(Object.keys(x).length===0?void 0:x)},[m,h]=u.useState(""),v=()=>{const b=m.trim();if(b===""||d.some(([k])=>k===b))return;const x={};for(const[k,w]of d)x[k]=w;x[b]="",t(x),h("")};return i.jsxs("div",{"data-testid":l,style:{display:"flex",flexDirection:"column",gap:6},children:[d.map(([b,x])=>i.jsxs("div",{"data-testid":`${l}-row-${b}`,style:{display:"flex",gap:6,alignItems:"flex-start",border:"1px solid #e2e8f0",borderRadius:8,padding:8},children:[i.jsxs("div",{style:{flex:1,minWidth:0,display:"flex",flexDirection:"column",gap:6},children:[i.jsx("div",{style:{minWidth:0},children:i.jsx(Cs,{control:{...s},value:b,onChange:k=>{const w=k==null?"":String(k);if(w===""||w===b||d.some(([S])=>S===w))return;const _={};for(const[S,E]of d)_[S===b?w:S]=E;t(_)},t:n,candidates:r})}),i.jsx("div",{style:{minWidth:0},children:i.jsx(Dn,{value:x,onChange:k=>y(b,k),t:n,path:b,scope:`${l}-val`,renderStringLeaf:k=>i.jsx(Ve,{value:k.value,onChange:k.onChange,t:n,candidates:o,testidPrefix:k.testidPrefix})})})]}),i.jsx("button",{type:"button","data-testid":`${l}-remove-${b}`,onClick:()=>g(b),title:n("layout_editor.page_settings.seo.remove"),"aria-label":n("layout_editor.page_settings.seo.remove"),style:{flex:"0 0 auto",padding:"4px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#64748b",cursor:"pointer"},children:"✕"})]},b)),i.jsxs("div",{style:{display:"flex",gap:6,alignItems:"center"},children:[i.jsx("div",{style:{flex:1,minWidth:0},children:i.jsx(Cs,{control:{...s},value:m,onChange:b=>h(b==null?"":String(b)),t:n,candidates:r})}),i.jsxs("button",{type:"button","data-testid":`${l}-add`,onClick:v,style:{flex:"0 0 auto",padding:"5px 10px",fontSize:12,border:"1px dashed #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer"},children:["+ ",n("layout_editor.action_param.add_pair")]})]})]})}function Is({raw:e,recipe:t,values:n,t:o,pools:r,onChange:s,testIdPrefix:a,renderActionList:l}){const d=(c,p)=>{const f={...n,[c]:p},y=vr(t,f),g=is(e);s(g?Pd(y,g):y)};return i.jsx(i.Fragment,{children:t.params.filter(c=>Qw(c,n)).map(c=>i.jsx(Vw,{param:c,value:n[c.key],t:o,pools:r,onChange:p=>d(c.key,p),testIdPrefix:a,renderActionList:l,siblingValues:n},c.key))})}function Qw(e,t){const n=e.dependsOn;return!n||typeof n!="object"||typeof n.param!="string"?!0:t[n.param]===n.equals}function Rw({raw:e,t,onChange:n,testIdPrefix:o,candidates:r}){const[s,a]=u.useState(!1);return i.jsxs("div",{"data-testid":`${o}-if`,style:ok,children:[i.jsxs("button",{type:"button",onClick:()=>a(l=>!l),style:rk,children:["▸ ",t("layout_editor.init_actions.if_label")]}),s?i.jsx("div",{"data-testid":`${o}-if-input`,style:{minWidth:0},children:i.jsx(Ve,{value:is(e)??"",onChange:l=>n(Pd(e,l)),t,candidates:r,placeholder:t("layout_editor.init_actions.if_placeholder"),testidPrefix:`${o}-if-chip`})}):null]})}const Gc={display:"flex",alignItems:"center",gap:8,minWidth:0},to={fontSize:11,color:"#64748b",minWidth:80},ek={display:"flex",alignItems:"center",gap:8,padding:"4px 0"},tk={fontSize:11,color:"#64748b",minWidth:80},nk={fontSize:11,color:"#92400e"},ok={display:"flex",flexDirection:"column",gap:4},rk={textAlign:"left",border:"none",background:"transparent",color:"#475569",cursor:"pointer",fontSize:11,padding:0};function ik(e,t,n){if(!t)return n("layout_editor.action.source_core");const o=e?.[t],r=o&&typeof o=="object"&&o.__source?o.__source:null;return!r||r.kind==="core"?n("layout_editor.action.source_core"):r.id??n(`layout_editor.action.source_${r.kind}`)}function sk(e,t,n){const o=qn(e,t);if(o.kind==="preset"){const r=t.find(s=>s.id===o.recipeId);return Ld(r??null,o.values,s=>n(s.startsWith("$t:")?s.slice(3):s))}return typeof e.handler=="string"?e.handler:n("layout_editor.action_list.unknown")}function Gt({actions:e,onChange:t,t:n,recipes:o,chipContext:r,actionChipCandidates:s,addContext:a="init",renderAddPicker:l,renderEditor:d,candidatePools:c,showSourceBadge:p=!1,canDrag:f,canDropAt:y,isLocked:g,hideAddPicker:m=!1,testIdPrefix:h="g7le-action-list"}){const v=u.useMemo(()=>vo(o),[o]),b=u.useMemo(()=>{if(r==null||r==="init")return c;const O=Bl(r,n,s??null);if(O.length===0)return c;const M=c??{},B=new Set((M.bindingCandidates??[]).map(H=>H.expression)),T=O.filter(H=>!B.has(H.expression));return T.length===0?c:{...M,bindingCandidates:[...T,...M.bindingCandidates??[]]}},[r,s,c,n]),[x,k]=u.useState(null),[w,_]=u.useState(null),S=u.useCallback((O,M)=>{if(O===M||O<0||M<0||O>=e.length||M>=e.length)return;const B=e.slice(),[T]=B.splice(O,1);B.splice(M,0,T),t(B)},[e,t]),E=us({length:e.length,onMove:S,canDrag:f,canDropAt:y}),C=u.useCallback(O=>{t(e.filter((M,B)=>B!==O))},[e,t]),N=u.useCallback((O,M)=>{const B=e.slice();B[O]=M,t(B)},[e,t]),$=u.useCallback(O=>{t([...e,O])},[e,t]),A=u.useCallback((O,M)=>{const B=qn(O,v),T=B.kind==="preset"?v.find(L=>L.id===B.recipeId)??null:null;if(!T)return null;const H=B.kind==="preset"?B.values:{};return i.jsxs("div",{style:ck,children:[i.jsx(Is,{raw:O,recipe:T,values:H,t:n,pools:b??{},onChange:M,testIdPrefix:`${h}-edit`,renderActionList:(L,I,P,F)=>{const G=F==="onSuccess"?"response":F==="onError"?"error":void 0;return i.jsx(Gt,{actions:L,onChange:I,t:n,recipes:o,chipContext:G,actionChipCandidates:G?s:void 0,candidatePools:G?c:b,addContext:a,showSourceBadge:p,testIdPrefix:P})}}),i.jsx(Rw,{raw:O,t:n,onChange:M,testIdPrefix:`${h}-edit`,candidates:b?.bindingCandidates})]})},[v,n,b,c,s,h,o,a,p]),z=d??(o?A:void 0),D=m?null:l?l($):o?i.jsx(ow,{recipes:o,t:n,onAdd:$,context:a,testIdPrefix:`${h}-add-picker`}):null;return i.jsxs("div",{className:h,"data-testid":h,children:[i.jsxs("ul",{style:{listStyle:"none",margin:0,padding:0,display:"flex",flexDirection:"column",gap:6},children:[e.map((O,M)=>{const B=qn(O,v),T=B.kind==="preset"?B.recipeId:null,H=T?v.find(G=>G.id===T)??null:null,L=H===null,I=g?.(M)??!1,P=is(O)!==null,F=z?z(O,G=>N(M,G)):null;return i.jsxs(u.Fragment,{children:[I?null:i.jsx("li",{"aria-hidden":"true",style:{listStyle:"none",margin:0,padding:0},children:i.jsx(Zn,{active:E.isDropTarget(M),testid:`${h}-dropline-${M}`})}),i.jsxs("li",{"data-testid":`${h}-item-${M}`,"data-drag-source":E.dragIndex===M?"true":void 0,style:{border:"1px solid #e2e8f0",borderRadius:8,padding:"8px 10px",...E.dragIndex===M?{opacity:.5}:{}},onDragOver:E.dragging&&!I?G=>{G.preventDefault();const V=G.currentTarget.getBoundingClientRect();E.onDragOverItem(M,G.clientY{G.preventDefault(),E.onDrop()}:void 0,children:[i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8},children:[I?i.jsx("span",{style:{fontSize:12},children:"🔒"}):i.jsx("span",{"data-testid":`${h}-drag-${M}`,style:{color:"#94a3b8",cursor:"grab",fontSize:14,userSelect:"none"},draggable:!0,onDragStart:()=>E.onDragStart(M),onDragEnd:E.onDragEnd,"aria-label":n("layout_editor.action_list.drag_reorder"),role:"button",children:"⠿"}),i.jsx("span",{style:{flex:1,minWidth:0,fontSize:13},"data-testid":`${h}-summary-${M}`,children:sk(O,v,n)}),p?i.jsxs("span",{"data-testid":`${h}-source-${M}`,style:ak,children:["〔",ik(o,T,n),"〕"]}):null,P?i.jsxs("span",{"data-testid":`${h}-cond-badge-${M}`,style:lk,children:["〔",n("layout_editor.init_actions.cond_badge"),"〕"]}):null,L?i.jsxs("span",{"data-testid":`${h}-advanced-${M}`,style:dk,children:["[",n("layout_editor.action.advanced"),"]"]}):null,!I&&F?i.jsxs("button",{type:"button","data-testid":`${h}-edit-${M}`,onClick:()=>_(w===M?null:M),"aria-label":n("layout_editor.init_actions.edit"),title:n("layout_editor.init_actions.edit"),style:w===M?uk:Yc,children:["✎ ",n("layout_editor.init_actions.edit")]}):null,i.jsx("button",{type:"button","data-testid":`${h}-code-${M}`,onClick:()=>k(x===M?null:M),"aria-label":n("layout_editor.action_list.view_code"),title:n("layout_editor.action_list.view_code"),style:x===M?pk:As,children:""}),I?null:i.jsx("button",{type:"button","data-testid":`${h}-remove-${M}`,onClick:()=>C(M),"aria-label":n("layout_editor.action_list.remove"),title:n("layout_editor.action_list.remove"),style:fk,children:"✕"})]}),H?i.jsx(hw,{action:O,recipe:H,values:B.kind==="preset"?B.values:{},t:n,showCodeButton:!1,testIdPrefix:`${h}-preview-${M}`}):null,w===M&&!I&&F?i.jsx("div",{"data-testid":`${h}-edit-body-${M}`,children:F}):null,d&&w!==M?d(O,G=>N(M,G)):null,x===M?i.jsx("pre",{"data-testid":`${h}-code-view-${M}`,style:{margin:"8px 0 0",padding:8,background:"#0f172a",color:"#e2e8f0",borderRadius:6,fontSize:12,overflow:"auto"},children:JSON.stringify(O,null,2)}):null]})]},M)}),i.jsx("li",{"aria-hidden":"true",style:{listStyle:"none",margin:0,padding:0},children:i.jsx(Zn,{active:E.isDropTarget(e.length),testid:`${h}-dropline-end`})})]}),D?i.jsx("div",{style:{marginTop:8},children:D}):null]})}const ak={fontSize:10,color:"#64748b",whiteSpace:"nowrap"},lk={fontSize:10,color:"#7c3aed",whiteSpace:"nowrap"},dk={fontSize:10,color:"#92400e",whiteSpace:"nowrap"},ck={display:"flex",flexDirection:"column",gap:6,paddingTop:6,borderTop:"1px dashed #e2e8f0",marginTop:6},Yc={flex:"0 0 auto",padding:"3px 8px",fontSize:11,fontWeight:500,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",whiteSpace:"nowrap",lineHeight:1.4},uk={...Yc,background:"#eff6ff",borderColor:"#bfdbfe",color:"#2563eb"},As={flex:"0 0 auto",width:26,height:24,display:"inline-flex",alignItems:"center",justifyContent:"center",fontSize:11,border:"1px solid #e2e8f0",borderRadius:6,background:"#fff",color:"#64748b",cursor:"pointer",fontFamily:"ui-monospace, monospace"},pk={...As,background:"#0f172a",borderColor:"#0f172a",color:"#e2e8f0"},fk={...As,color:"#dc2626",borderColor:"#fecaca",fontFamily:"inherit"};function Xc(e){const t=e.__source;return t&&typeof t=="object"?t:null}function gk({actions:e,onChange:t,recipes:n,t:o,bindingCandidates:r,pageCandidates:s,dataSourceCandidates:a,stateKeyCandidates:l,modalCandidates:d}){const c=u.useMemo(()=>({pageCandidates:s,dataSourceCandidates:a,stateKeyCandidates:l,modalCandidates:d,bindingCandidates:r}),[s,a,l,d,r]),p=u.useMemo(()=>e.filter(h=>Xc(h)?.kind==="base"),[e]),f=u.useMemo(()=>e.filter(h=>Xc(h)?.kind!=="base"),[e]),y=p.length>0,g=u.useCallback(h=>{t(y?[...p,...h]:h)},[t,y,p]),m=u.useCallback(()=>{},[]);return i.jsxs("div",{className:"g7le-init-actions-form","data-testid":"g7le-init-actions-form",style:hk,children:[i.jsx("p",{style:yk,children:o("layout_editor.page_settings.init_actions.heading")}),y?i.jsxs(i.Fragment,{children:[i.jsxs("div",{"data-testid":"g7le-init-action-group-base",style:Jc,children:[i.jsx("div",{style:Zc,children:o("layout_editor.init_actions.group_base")}),i.jsx(Gt,{actions:p,onChange:m,t:o,recipes:n,candidatePools:c,addContext:"init",showSourceBadge:!0,isLocked:()=>!0,hideAddPicker:!0,testIdPrefix:"g7le-init-action-base"}),i.jsxs("p",{"data-testid":"g7le-init-action-base-hint",style:bk,children:["ⓘ ",o("layout_editor.init_actions.base_readonly_hint")]})]}),i.jsxs("div",{"data-testid":"g7le-init-action-group-self",style:Jc,children:[i.jsx("div",{style:Zc,children:o("layout_editor.init_actions.group_self")}),f.length===0?i.jsx("p",{style:Qc,children:o("layout_editor.init_actions.empty_self")}):null,i.jsx(Gt,{actions:f,onChange:g,t:o,recipes:n,candidatePools:c,addContext:"init",showSourceBadge:!0,testIdPrefix:"g7le-init-action-self"})]}),i.jsxs("p",{"data-testid":"g7le-init-action-merge-order",style:mk,children:["ⓘ ",o("layout_editor.init_actions.merge_order")]})]}):i.jsxs(i.Fragment,{children:[e.length===0?i.jsx("p",{"data-testid":"g7le-init-action-empty",style:Qc,children:o("layout_editor.init_actions.empty")}):null,i.jsx(Gt,{actions:e,onChange:t,t:o,recipes:n,candidatePools:c,addContext:"init",showSourceBadge:!0,testIdPrefix:"g7le-init-action"})]})]})}const hk={display:"flex",flexDirection:"column",gap:12,minWidth:0},yk={margin:0,fontSize:13,fontWeight:700,color:"#0f172a"},Jc={border:"1px solid #e2e8f0",borderRadius:10,padding:10,display:"flex",flexDirection:"column",gap:8},Zc={fontSize:12,fontWeight:700,color:"#475569"},bk={margin:0,fontSize:11,color:"#64748b"},Qc={margin:0,fontSize:12,color:"#94a3b8"},mk={margin:0,fontSize:11,color:"#64748b"};function vk(e){return e==="spinner"?new Set(["spinner","page"]):new Set(["skeleton"])}function Rc(e,t,n){return typeof e!="string"?n:e.startsWith("$t:")?t(e.slice(3)):e}function eu({candidates:e,value:t,onSelect:n,styleKind:o,t:r,testIdPrefix:s="g7le-loading-component-picker"}){const a=go(),l=()=>{const d=a.open({ariaLabel:r("layout_editor.overlay.loading_picker.select"),width:520,maxHeightRatio:.7,content:i.jsx(_k,{candidates:e,value:t,styleKind:o,t:r,testIdPrefix:s,onPick:c=>{n(c),a.close(d)},onClose:()=>a.close(d)})})};return i.jsx("div",{className:s,"data-testid":s,style:{minWidth:0},children:i.jsxs("div",{style:xk,children:[i.jsx("span",{"data-testid":`${s}-current`,style:wk,children:t&&t.length>0?t:r("layout_editor.overlay.loading_picker.none")}),i.jsxs("button",{type:"button","data-testid":`${s}-open`,onClick:l,style:kk,children:["🔍 ",r("layout_editor.overlay.loading_picker.select")]})]})})}function _k({candidates:e,value:t,styleKind:n,t:o,testIdPrefix:r,onPick:s,onClose:a}){const[l,d]=u.useState(""),c=vk(n),p=u.useMemo(()=>{const f=l.trim().toLowerCase();return e.filter(y=>c.has(y.role)).filter(y=>f?Rc(y.label,o,y.name).toLowerCase().includes(f)||y.name.toLowerCase().includes(f):!0)},[e,n,l,o]);return i.jsxs("div",{"data-testid":`${r}-modal`,style:Sk,children:[i.jsxs("div",{style:Ck,children:[i.jsxs("span",{style:$k,children:["🔍 ",o("layout_editor.overlay.loading_picker.select")]}),i.jsx("button",{type:"button","data-testid":`${r}-close`,onClick:a,"aria-label":o("layout_editor.overlay.loading_picker.select"),style:Ek,children:"✕"})]}),i.jsx("input",{type:"text","data-testid":`${r}-search`,value:l,placeholder:o("layout_editor.overlay.loading_picker.search"),onChange:f=>d(f.target.value),style:Ik,autoFocus:!0}),p.length===0?i.jsx("p",{"data-testid":`${r}-empty`,style:Lk,children:o("layout_editor.overlay.loading_picker.empty")}):i.jsx("ul",{style:Ak,children:p.map(f=>i.jsx("li",{children:i.jsxs("button",{type:"button","data-testid":`${r}-item-${f.name}`,onClick:()=>s(f.name),style:{...Tk,...t===f.name?Dk:{}},children:[i.jsx("span",{style:Nk,children:Rc(f.label,o,f.name)}),i.jsx("code",{style:zk,children:f.name}),i.jsx("span",{style:Mk,children:f.role})]})},f.name))})]})}const xk={display:"flex",alignItems:"center",gap:6,minWidth:0},wk={flex:1,minWidth:0,fontSize:12,color:"#0f172a",fontFamily:"monospace"},kk={padding:"4px 10px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer",whiteSpace:"nowrap"},Sk={display:"flex",flexDirection:"column",minHeight:0,padding:16,boxSizing:"border-box",fontFamily:"system-ui, -apple-system, sans-serif"},Ck={display:"flex",alignItems:"center",gap:8,paddingBottom:8,borderBottom:"1px solid #e2e8f0",marginBottom:10},$k={flex:1,fontSize:14,fontWeight:700,color:"#0f172a",minWidth:0},Ek={border:"none",background:"transparent",color:"#64748b",cursor:"pointer",fontSize:15,lineHeight:1,padding:4},Ik={width:"100%",padding:"6px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,marginBottom:8,boxSizing:"border-box"},Ak={listStyle:"none",margin:0,padding:0,display:"flex",flexDirection:"column",gap:2,overflowY:"auto",minHeight:0},Tk={display:"flex",alignItems:"center",gap:6,width:"100%",textAlign:"left",padding:"8px 10px",fontSize:12,border:"1px solid transparent",background:"transparent",cursor:"pointer",borderRadius:6},Dk={border:"1px solid #3b82f6",background:"#eff6ff"},Nk={flex:1,minWidth:0,color:"#0f172a"},zk={fontSize:10,color:"#94a3b8",fontFamily:"monospace"},Mk={fontSize:10,color:"#64748b",background:"#f1f5f9",borderRadius:4,padding:"0 4px"},Lk={margin:0,fontSize:12,color:"#94a3b8",padding:"8px 4px"};function tu(e){return e===!0?{enabled:!0}:e===!1||e===void 0?{}:e}const Pk=["opaque","blur","fade","skeleton","spinner"];function Ok({value:e,patch:t,t:n,baseValue:o,ownValue:r,loadingComponents:s=[],progressiveDataSources:a=[],bindingCandidates:l}){const d=tu(e),c=d.enabled??!1,p=!!o&&Object.keys(o).length>0,f=An("component-target-picker"),[y,g]=u.useState((d.wait_for??[]).length>0),m=u.useCallback(w=>{const _={...tu(e)};w(_),t(_)},[e,t]),h=r&&typeof r=="object"?r:e&&typeof e=="object"?e:{},v=u.useCallback(w=>p&&o!==void 0&&w in o&&!(w in h),[p,o,h]),b=u.useCallback(w=>{o&&m(_=>{_[w]=o[w]})},[o,m]),x=u.useCallback(w=>m(_=>{_.enabled=w}),[m]),k=d.target!==void 0?"region":"full";return i.jsxs("div",{className:"g7le-overlay-form","data-testid":"g7le-overlay-form",style:Bk,children:[p?i.jsxs("p",{"data-testid":"g7le-overlay-inherit-banner",style:Wk,children:["ⓘ ",n("layout_editor.overlay.inherit_banner")]}):null,i.jsxs("div",{style:Pr,children:[i.jsx(Ts,{label:n("layout_editor.overlay.enabled_label"),inherited:v("enabled"),hasInheritance:p,onOverride:()=>b("enabled"),testidKey:"enabled",t:n}),i.jsx(Tr,{disabled:v("enabled"),children:i.jsx(Ar,{checked:c,onChange:x,testid:"g7le-overlay-enabled",ariaLabel:n("layout_editor.overlay.enabled_label"),label:n(c?"layout_editor.overlay.on":"layout_editor.overlay.off")})}),i.jsxs("p",{style:Or,children:["ⓘ ",n("layout_editor.overlay.enabled_hint")]})]}),i.jsx(Tr,{disabled:!c,testid:"g7le-overlay-body",style:{display:"flex",flexDirection:"column",gap:16,minWidth:0},children:i.jsxs(i.Fragment,{children:[i.jsxs("div",{style:Pr,children:[i.jsx(Ts,{label:n("layout_editor.overlay.scope_label"),inherited:v("target"),hasInheritance:p,onOverride:()=>b("target"),testidKey:"target",t:n}),i.jsxs("div",{"data-testid":"g7le-overlay-scope",style:Ds,children:[i.jsx(Lr,{active:k==="full",onClick:()=>m(w=>{delete w.target,delete w.fallback_target}),testid:"g7le-overlay-scope-full",children:n("layout_editor.overlay.scope_full")}),i.jsx(Lr,{active:k==="region",onClick:()=>m(w=>{w.target=w.target??""}),testid:"g7le-overlay-scope-region",children:n("layout_editor.overlay.scope_region")})]}),i.jsx(Tr,{disabled:k!=="region",testid:"g7le-overlay-region-box",style:Kk,children:i.jsxs(i.Fragment,{children:[i.jsx(Nn,{label:n("layout_editor.overlay.target_label"),children:i.jsx("div",{"data-testid":"g7le-overlay-target-picker",children:f?i.jsx(f,{control:{label:n("layout_editor.overlay.target_label")},value:d.target??"",onChange:w=>m(_=>{_.target=typeof w=="string"?w:""}),t:n}):i.jsx("input",{type:"text","data-testid":"g7le-overlay-target-input",value:d.target??"",onChange:w=>m(_=>{_.target=w.target.value}),style:qk})})}),i.jsx(Nn,{label:n("layout_editor.overlay.fallback_label"),children:i.jsx("div",{"data-testid":"g7le-overlay-fallback-picker",children:f?i.jsx(f,{control:{label:n("layout_editor.overlay.fallback_label")},value:d.fallback_target??"",onChange:w=>m(_=>{const S=typeof w=="string"?w:"";S===""?delete _.fallback_target:_.fallback_target=S}),t:n}):null})}),i.jsxs("p",{style:Or,children:["ⓘ ",n("layout_editor.overlay.fallback_hint")]})]})})]}),i.jsxs("div",{style:Pr,children:[i.jsx(Ts,{label:n("layout_editor.overlay.style_label"),inherited:v("style"),hasInheritance:p,onOverride:()=>b("style"),testidKey:"style",t:n}),i.jsx("div",{"data-testid":"g7le-overlay-style",style:Ds,children:Pk.map(w=>i.jsx(Lr,{active:(d.style??"opaque")===w,onClick:()=>m(_=>{_.style=w}),testid:`g7le-overlay-style-${w}`,children:n(`layout_editor.overlay.style_${w}`)},w))}),d.style==="skeleton"?i.jsxs("div",{style:nu,children:[i.jsx(Nn,{label:n("layout_editor.overlay.skeleton_component"),children:i.jsx(eu,{candidates:s,value:d.skeleton?.component,styleKind:"skeleton",onSelect:w=>m(_=>{_.skeleton={..._.skeleton,component:w}}),t:n,testIdPrefix:"g7le-overlay-skeleton-component"})}),i.jsx(Nn,{label:n("layout_editor.overlay.skeleton_animation"),children:i.jsx("div",{"data-testid":"g7le-overlay-skeleton-anim",style:Ds,children:["wave","pulse","none"].map(w=>i.jsx(Lr,{active:(d.skeleton?.animation??"wave")===w,onClick:()=>m(_=>{_.skeleton={..._.skeleton,animation:w}}),testid:`g7le-overlay-skeleton-anim-${w}`,children:n(`layout_editor.overlay.anim_${w}`)},w))})}),i.jsx(Nn,{label:n("layout_editor.overlay.skeleton_count"),children:i.jsx("input",{type:"number","data-testid":"g7le-overlay-skeleton-count",value:d.skeleton?.iteration_count??"",onChange:w=>m(_=>{const S=parseInt(w.target.value,10);_.skeleton={..._.skeleton,iteration_count:Number.isNaN(S)?void 0:S}}),style:Gk})})]}):null,d.style==="spinner"?i.jsxs("div",{style:nu,children:[i.jsx(Nn,{label:n("layout_editor.overlay.spinner_component"),children:i.jsx(eu,{candidates:s,value:d.spinner?.component,styleKind:"spinner",onSelect:w=>m(_=>{_.spinner={..._.spinner,component:w}}),t:n,testIdPrefix:"g7le-overlay-spinner-component"})}),i.jsx(Nn,{label:n("layout_editor.overlay.spinner_text"),children:i.jsx("div",{"data-testid":"g7le-overlay-spinner-text",style:{minWidth:0},children:i.jsx(vt,{value:d.spinner?.text??"",onChange:w=>m(_=>{_.spinner={..._.spinner,text:w===void 0||w===""?void 0:w}}),t:n,candidates:l,enableExpressionTree:!0,expressionTreeCollapsible:!0,testidPrefix:"g7le-overlay-spinner-text-field"})})})]}):null]}),i.jsxs("div",{style:Pr,children:[i.jsxs("button",{type:"button","data-testid":"g7le-overlay-waitfor-toggle","aria-expanded":y,onClick:()=>g(w=>!w),style:Jk,children:[y?"▾":"▸"," ",n("layout_editor.overlay.waitfor_label")]}),y?i.jsxs("div",{"data-testid":"g7le-overlay-waitfor-body",style:Zk,children:[i.jsxs("p",{"data-testid":"g7le-overlay-waitfor-hint",style:Or,children:["ⓘ ",n("layout_editor.overlay.waitfor_hint")]}),a.length===0?i.jsx("p",{"data-testid":"g7le-overlay-waitfor-empty",style:Or,children:n("layout_editor.overlay.waitfor_empty")}):i.jsx("div",{"data-testid":"g7le-overlay-waitfor",style:{display:"flex",flexWrap:"wrap",gap:6},children:a.map(w=>{const _=(d.wait_for??[]).includes(w.id);return i.jsxs("label",{"data-testid":`g7le-overlay-waitfor-${w.id}`,style:Yk(_),children:[i.jsx("input",{type:"checkbox",checked:_,onChange:()=>m(S=>{const E=new Set(S.wait_for??[]);E.has(w.id)?E.delete(w.id):E.add(w.id);const C=Array.from(E);C.length===0?delete S.wait_for:S.wait_for=C})}),i.jsx(Ec,{option:w,testIdPrefix:`g7le-overlay-waitfor-${w.id}`})]},w.id)})})]}):null]}),p?i.jsxs("button",{type:"button","data-testid":"g7le-overlay-reset-inherit",onClick:()=>t(void 0),style:Xk,children:["⟲ ",n("layout_editor.overlay.reset_inherit")]}):null]})})]})}function Lr({active:e,onClick:t,children:n,testid:o}){return i.jsx("button",{type:"button","data-testid":o,"aria-pressed":e,onClick:t,style:{...Hk,...e?Fk:{}},children:n})}function Ts({label:e,inherited:t,hasInheritance:n,onOverride:o,testidKey:r,t:s}){return i.jsxs("div",{style:Qk,children:[i.jsx("label",{style:jk,children:e}),n?t?i.jsxs(i.Fragment,{children:[i.jsxs("span",{"data-testid":`g7le-overlay-inherited-${r}`,style:Rk,children:["〔",s("layout_editor.overlay.inherited_badge"),"〕"]}),i.jsx("button",{type:"button","data-testid":`g7le-overlay-override-${r}`,onClick:o,style:t1,children:s("layout_editor.overlay.override_field")})]}):i.jsxs("span",{"data-testid":`g7le-overlay-overridden-${r}`,style:e1,children:["〔",s("layout_editor.overlay.overridden_badge"),"〕"]}):null]})}function Nn({label:e,children:t}){return i.jsxs("div",{style:Vk,children:[i.jsx("span",{style:Uk,children:e}),i.jsx("div",{style:{flex:1,minWidth:0},children:t})]})}const Bk={display:"flex",flexDirection:"column",gap:16,minWidth:0},Wk={margin:0,fontSize:12,color:"#1d4ed8",background:"#eff6ff",padding:"8px 10px",borderRadius:6},Pr={display:"flex",flexDirection:"column",gap:6,minWidth:0},jk={fontSize:13,fontWeight:600,color:"#0f172a"},Or={margin:0,fontSize:11,color:"#94a3b8"},Ds={display:"flex",gap:4,flexWrap:"wrap"},Hk={padding:"4px 10px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},Fk={border:"1px solid #3b82f6",background:"#eff6ff",color:"#1d4ed8"},Kk={display:"flex",flexDirection:"column",gap:8,padding:8,border:"1px dashed #e2e8f0",borderRadius:8},nu={display:"flex",flexDirection:"column",gap:8,padding:8,border:"1px dashed #e2e8f0",borderRadius:8},Vk={display:"flex",alignItems:"center",gap:8,minWidth:0},Uk={fontSize:11,color:"#64748b",minWidth:90},qk={width:"100%",padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,boxSizing:"border-box"},Gk={width:80,padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6},Yk=e=>({display:"inline-flex",alignItems:"center",gap:4,padding:"4px 8px",fontSize:12,border:`1px solid ${e?"#3b82f6":"#cbd5e1"}`,borderRadius:12,background:e?"#eff6ff":"#fff",cursor:"pointer"}),Xk={alignSelf:"flex-start",padding:"6px 12px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},Jk={alignSelf:"flex-start",padding:"4px 0",fontSize:13,fontWeight:600,color:"#0f172a",background:"transparent",border:"none",cursor:"pointer"},Zk={display:"flex",flexDirection:"column",gap:6,paddingLeft:14,minWidth:0},Qk={display:"flex",alignItems:"center",gap:8,flexWrap:"wrap",minWidth:0},Rk={fontSize:11,color:"#64748b",background:"#f1f5f9",borderRadius:4,padding:"1px 6px"},e1={fontSize:11,color:"#0369a1",background:"#e0f2fe",borderRadius:4,padding:"1px 6px"},t1={padding:"2px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},n1="g7le:computed-effect-preview",o1={number:"layout_editor.computed.type_number",string:"layout_editor.computed.type_string",boolean:"layout_editor.computed.type_boolean",list:"layout_editor.computed.type_list",object:"layout_editor.computed.type_object",null:"layout_editor.computed.type_null"};function r1(e,t,n){if(t==="list"){const o=e,r=o.slice(0,2).map(s=>JSON.stringify(s)).join(", ");return o.length<=2?`[${r}]`:`[${r}, ${n("layout_editor.computed.list_more",{count:o.length})}]`}return t==="object"?JSON.stringify(e):t==="boolean"?e?"true":"false":t==="null"?n("layout_editor.computed.value_none"):String(e)}function ou({expr:e,computedKey:t,sampleContext:n,t:o,isEffect:r=!1,testIdPrefix:s="g7le-computed-preview"}){const a=Sd(e,n),l=()=>{typeof window>"u"||!t||window.dispatchEvent(new CustomEvent(n1,{detail:{key:t}}))};return a.ok?i.jsxs("div",{className:s,"data-testid":s,style:i1,children:[i.jsx("span",{style:s1,children:o("layout_editor.computed.preview_label")}),i.jsx("span",{"data-testid":`${s}-value`,style:a1,children:r1(a.value,a.type,o)}),i.jsxs("span",{"data-testid":`${s}-type`,style:l1,children:["(",o(o1[a.type]),")"]}),r&&a.type==="boolean"?i.jsxs("button",{type:"button","data-testid":`${s}-effect`,onClick:l,style:d1,children:["▷ ",o("layout_editor.computed.effect_preview")]}):null]}):i.jsxs("div",{"data-testid":`${s}-error`,style:c1,children:["⚠ ",o("layout_editor.computed.preview_error")]})}const i1={display:"flex",alignItems:"center",flexWrap:"wrap",gap:6,minWidth:0},s1={fontSize:11,color:"#94a3b8"},a1={fontSize:12,color:"#0f172a",fontFamily:"monospace",wordBreak:"break-all"},l1={fontSize:10,color:"#64748b"},d1={padding:"2px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer"},c1={fontSize:12,color:"#b45309"},u1=[{op:"count",labelKey:"layout_editor.computed.op_count"},{op:"sum",labelKey:"layout_editor.computed.op_sum"},{op:"filter",labelKey:"layout_editor.computed.op_filter"},{op:"toOptions",labelKey:"layout_editor.computed.op_to_options"},{op:"nth",labelKey:"layout_editor.computed.op_nth"},{op:"firstOf",labelKey:"layout_editor.computed.op_first_of"},{op:"literal",labelKey:"layout_editor.computed.op_literal"}],p1=["=","!=",">","<",">=","<=","includes"],f1=new Set(["count","sum","filter"]);function ru({model:e,onChange:t,t:n,dataSourceCandidates:o,bindingCandidates:r,existingKeys:s=[]}){const a=u.useCallback(g=>{const m={...e};g(m),t(m)},[e,t]),l=e.conditions??[],d=An("datasource-picker"),c=e.key!==""&&s.includes(e.key),p=()=>a(g=>{g.conditions=[...g.conditions??[],{field:"",cmp:"=",value:""}]}),f=(g,m)=>a(h=>{const v=(h.conditions??[]).slice(),b={...v[g]};m(b),v[g]=b,h.conditions=v}),y=g=>a(m=>{m.conditions=(m.conditions??[]).filter((h,v)=>v!==g)});return i.jsxs("div",{className:"g7le-computed-custom","data-testid":"g7le-computed-custom",style:g1,children:[i.jsx(Ns,{label:`① ${n("layout_editor.computed.custom_step1")}`,children:i.jsx("div",{"data-testid":"g7le-computed-custom-source",children:d?i.jsx(d,{control:{},value:e.source,onChange:g=>a(m=>{m.source=typeof g=="string"?g:void 0}),t:n,candidates:o}):i.jsx("input",{type:"text",value:e.source??"",onChange:g=>a(m=>{m.source=g.target.value}),style:no})})}),i.jsxs(Ns,{label:`② ${n("layout_editor.computed.custom_step2")}`,children:[i.jsx("select",{"data-testid":"g7le-computed-custom-op",value:e.op,onChange:g=>a(m=>{m.op=g.target.value}),style:no,children:u1.map(({op:g,labelKey:m})=>i.jsx("option",{value:g,children:n(m)},g))}),e.op==="sum"?i.jsx("div",{"data-testid":"g7le-computed-custom-sumfield",style:{minWidth:0},children:i.jsx(Ve,{value:e.sumField??"",onChange:g=>a(m=>{m.sumField=g}),t:n,candidates:r,placeholder:n("layout_editor.computed.custom_sum_field"),testidPrefix:"g7le-computed-custom-sumfield-chip"})}):null,e.op==="toOptions"?i.jsxs("div",{style:{display:"flex",gap:6},children:[i.jsx("div",{"data-testid":"g7le-computed-custom-valuefield",style:{flex:1,minWidth:0},children:i.jsx(Ve,{value:e.valueField??"",onChange:g=>a(m=>{m.valueField=g}),t:n,candidates:r,placeholder:n("layout_editor.computed.custom_value_field"),testidPrefix:"g7le-computed-custom-valuefield-chip"})}),i.jsx("div",{"data-testid":"g7le-computed-custom-labelfield",style:{flex:1,minWidth:0},children:i.jsx(Ve,{value:e.labelField??"",onChange:g=>a(m=>{m.labelField=g}),t:n,candidates:r,placeholder:n("layout_editor.computed.custom_label_field"),testidPrefix:"g7le-computed-custom-labelfield-chip"})})]}):null,e.op==="nth"?i.jsxs("div",{style:{display:"flex",gap:6},children:[i.jsx("input",{type:"text","data-testid":"g7le-computed-custom-index",value:e.index??"",placeholder:"0",onChange:g=>a(m=>{m.index=g.target.value}),style:no}),i.jsx("div",{"data-testid":"g7le-computed-custom-prop",style:{flex:1,minWidth:0},children:i.jsx(Ve,{value:e.prop??"",onChange:g=>a(m=>{m.prop=g}),t:n,candidates:r,placeholder:n("layout_editor.computed.custom_prop"),testidPrefix:"g7le-computed-custom-prop-chip"})})]}):null,e.op==="literal"?i.jsxs("div",{style:{display:"flex",gap:6},children:[i.jsxs("select",{"data-testid":"g7le-computed-custom-literalkind",value:e.literalKind??"string",onChange:g=>a(m=>{m.literalKind=g.target.value}),style:no,children:[i.jsx("option",{value:"string",children:n("layout_editor.computed.kind_string")}),i.jsx("option",{value:"number",children:n("layout_editor.computed.kind_number")}),i.jsx("option",{value:"boolean",children:n("layout_editor.computed.kind_boolean")})]}),i.jsx("div",{"data-testid":"g7le-computed-custom-literalvalue",style:{flex:1,minWidth:0},children:i.jsx(Ve,{value:e.literalValue??"",onChange:g=>a(m=>{m.literalValue=g}),t:n,candidates:r,testidPrefix:"g7le-computed-custom-literalvalue-chip"})})]}):null,e.op==="firstOf"?i.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:6},children:[i.jsx("input",{type:"text","data-testid":"g7le-computed-custom-candidates",value:(e.candidates??[]).join(", "),placeholder:n("layout_editor.computed.custom_candidates"),onChange:g=>a(m=>{m.candidates=g.target.value.split(",").map(h=>h.trim()).filter(Boolean)}),style:no}),i.jsx("div",{"data-testid":"g7le-computed-custom-fallback",style:{minWidth:0},children:i.jsx(Ve,{value:e.fallback??"",onChange:g=>a(m=>{m.fallback=g}),t:n,candidates:r,placeholder:n("layout_editor.computed.custom_fallback"),testidPrefix:"g7le-computed-custom-fallback-chip"})})]}):null,f1.has(e.op)?i.jsxs("div",{style:m1,children:[l.map((g,m)=>i.jsxs("div",{"data-testid":`g7le-computed-custom-cond-${m}`,style:v1,children:[i.jsx("div",{"data-testid":`g7le-computed-custom-cond-field-${m}`,style:{flex:1,minWidth:0},children:i.jsx(Ve,{value:g.field,onChange:h=>f(m,v=>{v.field=h}),t:n,candidates:r,placeholder:n("layout_editor.computed.cond_field"),testidPrefix:`g7le-computed-custom-cond-field-${m}-chip`})}),i.jsx("select",{value:g.cmp,onChange:h=>f(m,v=>{v.cmp=h.target.value}),style:_1,children:p1.map(h=>i.jsx("option",{value:h,children:h},h))}),i.jsx("div",{"data-testid":`g7le-computed-custom-cond-value-${m}`,style:{flex:1,minWidth:0},children:i.jsx(Ve,{value:g.value,onChange:h=>f(m,v=>{v.value=h}),t:n,candidates:r,placeholder:n("layout_editor.computed.cond_value"),testidPrefix:`g7le-computed-custom-cond-value-${m}-chip`})}),i.jsx("button",{type:"button","data-testid":`g7le-computed-custom-cond-remove-${m}`,onClick:()=>y(m),style:x1,children:"✕"})]},m)),i.jsxs("button",{type:"button","data-testid":"g7le-computed-custom-cond-add",onClick:p,style:w1,children:["+ ",n("layout_editor.computed.cond_add")]})]}):null]}),i.jsxs(Ns,{label:`③ ${n("layout_editor.computed.custom_step3")}`,children:[i.jsx("input",{type:"text","data-testid":"g7le-computed-custom-key",value:e.key,onChange:g=>a(m=>{m.key=g.target.value}),style:{...no,...c?b1:{}}}),c?i.jsxs("p",{"data-testid":"g7le-computed-custom-key-dup",style:k1,children:["⚠ ",n("layout_editor.computed.key_duplicate")]}):null]})]})}function Ns({label:e,children:t}){return i.jsxs("div",{style:h1,children:[i.jsx("div",{style:y1,children:e}),i.jsx("div",{style:{display:"flex",flexDirection:"column",gap:6,minWidth:0},children:t})]})}function zs(e){return Pm(e)}const g1={display:"flex",flexDirection:"column",gap:12,padding:10,border:"1px solid #cbd5e1",borderRadius:8,background:"#fff"},h1={display:"flex",flexDirection:"column",gap:4,minWidth:0},y1={fontSize:12,fontWeight:600,color:"#475569"},no={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,minWidth:0,width:"100%",boxSizing:"border-box"},b1={border:"1px solid #ef4444"},m1={display:"flex",flexDirection:"column",gap:6,marginTop:4},v1={display:"flex",gap:4,alignItems:"center",minWidth:0},_1={padding:"4px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6},x1={border:"none",background:"transparent",color:"#94a3b8",cursor:"pointer",fontSize:12},w1={alignSelf:"flex-start",padding:"2px 8px",fontSize:11,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},k1={margin:0,fontSize:11,color:"#b45309"};function Ms(e,t,n){return typeof e!="string"?n:e.startsWith("$t:")?t(e.slice(3)):e}function S1(e){if(e&&typeof e=="object"){const t=e.__source;if(t&&typeof t=="object")return t}return null}function C1({computed:e,onChange:t,recipes:n,t:o,sampleContext:r,dataSourceCandidates:s,bindingCandidates:a,computedSource:l={},effectKeys:d=[],onRevert:c}){const p=u.useMemo(()=>bd(n),[n]),[f,y]=u.useState(!1),[g,m]=u.useState(null),h=Object.keys(e),v=u.useCallback((_,S)=>{t({...e,[_]:S})},[e,t]),b=u.useCallback(_=>{const S={...e};delete S[_],t(S)},[e,t]),x=u.useCallback((_,S,E)=>{const C={...e};_&&_!==S&&delete C[_],C[S]=E,t(C)},[e,t]),k=u.useCallback(_=>{const S=vd(_,{});let E=_.id,C=1;for(;E in e;)E=`${_.id}${++C}`;t({...e,[E]:S}),y(!1)},[e,t]),w=u.useCallback(()=>{!g||!g.key||(t({...e,[g.key]:zs(g)}),m(null),y(!1))},[g,e,t]);return i.jsxs("div",{className:"g7le-computed-form","data-testid":"g7le-computed-form",style:A1,children:[i.jsx("p",{style:T1,children:o("layout_editor.page_settings.computed.heading")}),h.length>0?h.map(_=>i.jsx($1,{computedKey:_,expr:e[_],recipes:p,rawRecipes:n,t:o,sampleContext:r,dataSourceCandidates:s,bindingCandidates:a,source:l[_],isEffect:d.includes(_),existingKeys:h,onPatchExpr:S=>v(_,S),onRename:(S,E)=>x(_,S,E),onRemove:()=>b(_),onRevert:c?()=>c(_):void 0},_)):i.jsx("p",{"data-testid":"g7le-computed-empty",style:R1,children:o("layout_editor.computed.empty")}),i.jsxs("div",{style:{minWidth:0},children:[i.jsxs("button",{type:"button","data-testid":"g7le-computed-add",onClick:()=>y(_=>!_),style:U1,children:["+ ",o("layout_editor.computed.add")," ▾"]}),f?i.jsxs("div",{"data-testid":"g7le-computed-add-list",style:q1,children:[["common","more"].map(_=>{const S=p.filter(E=>(E.group??"more")===_);return S.length===0?null:i.jsxs("div",{children:[i.jsxs("div",{style:G1,children:["━━ ",o(`layout_editor.computed.group_${_}`)," ━━"]}),S.map(E=>{const C=S1(n?.[E.id]);return i.jsxs("button",{type:"button","data-testid":`g7le-computed-preset-${E.id}`,onClick:()=>k(E),style:Y1,children:[i.jsx("span",{style:X1,children:Ms(E.label,o,E.id)}),C&&C.kind!=="core"?i.jsxs("span",{"data-testid":`g7le-computed-preset-source-${E.id}`,style:J1,children:["〔",C.id??C.kind,"〕"]}):null]},E.id)})]},_)}),i.jsxs("div",{style:{borderTop:"1px solid #e2e8f0",marginTop:6,paddingTop:6},children:[i.jsxs("button",{type:"button","data-testid":"g7le-computed-custom-open",onClick:()=>m(g?null:{key:"",op:"count",source:"",conditions:[]}),style:Z1,children:["⚙ ",o("layout_editor.computed.custom_open")," ▾"]}),g?i.jsxs("div",{style:{marginTop:6},children:[i.jsx(ru,{model:g,onChange:m,t:o,dataSourceCandidates:s,bindingCandidates:a,existingKeys:h}),i.jsx("div",{"data-testid":"g7le-computed-custom-preview",style:{marginTop:6},children:i.jsx(ou,{expr:zs(g),sampleContext:r,t:o,testIdPrefix:"g7le-computed-custom-preview-eval"})}),i.jsx("button",{type:"button","data-testid":"g7le-computed-custom-commit",disabled:!g.key||h.includes(g.key),onClick:w,style:Q1,children:o("layout_editor.computed.custom_add")})]}):null]})]}):null]})]})}function $1({computedKey:e,expr:t,recipes:n,rawRecipes:o,t:r,sampleContext:s,dataSourceCandidates:a,bindingCandidates:l,source:d,isEffect:c,existingKeys:p,onPatchExpr:f,onRename:y,onRemove:g,onRevert:m}){const[h,v]=u.useState(!1),[b,x]=u.useState(!1),k=u.useMemo(()=>kd(t,n),[t,n]),w=d==="base",_=d==="route-override",S=k.kind==="advanced",E=k.kind==="preset"?n.find($=>$.id===k.recipeId)??null:null,C=E?Ms(E.label,r,e):e,N=($,A)=>{if(k.kind!=="preset"||!E)return;const z={...k.params,[$]:A};f(vd(E,z))};return i.jsxs("div",{"data-testid":`g7le-computed-item-${e}`,style:D1,children:[i.jsxs("div",{style:N1,children:[i.jsx("span",{style:z1,children:C}),i.jsx("code",{style:M1,children:e}),w?i.jsxs("span",{"data-testid":`g7le-computed-source-${e}`,style:L1,children:["〔",r("layout_editor.computed.source_base"),"〕"]}):null,_?i.jsxs("span",{"data-testid":`g7le-computed-overridden-${e}`,style:P1,children:["〔",r("layout_editor.computed.source_overridden"),"〕"]}):null,S?i.jsxs("span",{"data-testid":"g7le-computed-advanced",style:O1,children:["[",r("layout_editor.action.advanced"),"]"]}):null,i.jsx("span",{style:{flex:1}}),i.jsx("button",{type:"button","data-testid":`g7le-computed-code-${e}`,onClick:()=>x($=>!$),style:Ls,"aria-label":r("layout_editor.value_tree.show_source"),title:r("layout_editor.value_tree.show_source"),children:""}),S?null:i.jsx("button",{type:"button","data-testid":`g7le-computed-edit-${e}`,onClick:()=>v($=>!$),style:Ls,children:r("layout_editor.init_actions.edit")}),i.jsx("button",{type:"button","data-testid":`g7le-computed-remove-${e}`,onClick:g,style:Ls,"aria-label":r("layout_editor.init_actions.remove"),children:"✕"})]}),b?i.jsx("pre",{"data-testid":`g7le-computed-code-block-${e}`,style:eS,children:t}):null,i.jsx("div",{"data-testid":`g7le-computed-preview-${e}`,children:i.jsx(ou,{expr:t,computedKey:e,sampleContext:s,t:r,isEffect:c,testIdPrefix:`g7le-computed-preview-${e}-eval`})}),_&&m?i.jsxs("div",{style:B1,children:[i.jsxs("span",{style:W1,children:["ⓘ ",r("layout_editor.computed.overridden_hint")]}),i.jsx("button",{type:"button","data-testid":`g7le-computed-revert-${e}`,onClick:m,style:j1,children:r("layout_editor.computed.revert")})]}):null,h&&!S?i.jsxs("div",{style:H1,children:[w?i.jsxs("p",{"data-testid":`g7le-computed-override-notice-${e}`,style:F1,children:["⚠ ",r("layout_editor.computed.override_notice")]}):null,k.kind==="preset"&&E?i.jsx(i.Fragment,{children:E.params.map($=>i.jsx(I1,{param:$,value:k.params[$.key]??"",t:r,dataSourceCandidates:a,bindingCandidates:l,onChange:A=>N($.key,A)},$.key))}):k.kind==="custom"?i.jsx(ru,{model:{...k.model,key:e},onChange:$=>y($.key,zs($)),t:r,dataSourceCandidates:a,bindingCandidates:l,existingKeys:p.filter($=>$!==e)}):null]}):null]})}const E1=new Set(["datasource-picker","binding-picker","select","toggle","state-key-picker","page-picker"]);function I1({param:e,value:t,t:n,dataSourceCandidates:o,bindingCandidates:r,onChange:s}){const a=Ms(e.label,n,e.key),l=e.widget??"",c=E1.has(l)?An(e.widget):null,p=l==="datasource-picker"||l==="binding-picker"?o:void 0;return i.jsxs("div",{"data-testid":`g7le-computed-param-${e.key}`,style:K1,children:[i.jsx("span",{style:V1,children:a}),i.jsx("div",{style:{flex:1,minWidth:0},children:c?i.jsx(c,{control:{...e},value:t,onChange:f=>s(typeof f=="string"?f:""),t:n,candidates:p}):i.jsx(Ve,{value:t,onChange:f=>s(f),t:n,candidates:r,testidPrefix:`g7le-computed-param-${e.key}-chip`})})]})}const A1={display:"flex",flexDirection:"column",gap:12,minWidth:0},T1={margin:0,fontSize:13,fontWeight:700,color:"#0f172a"},D1={border:"1px solid #e2e8f0",borderRadius:8,padding:"8px 10px",display:"flex",flexDirection:"column",gap:6,background:"#f8fafc"},N1={display:"flex",alignItems:"center",gap:6},z1={fontSize:13,fontWeight:600,color:"#0f172a"},M1={fontSize:10,color:"#64748b",fontFamily:"monospace"},L1={fontSize:10,color:"#0891b2",whiteSpace:"nowrap"},P1={fontSize:10,color:"#7c3aed",whiteSpace:"nowrap"},O1={fontSize:10,color:"#92400e",whiteSpace:"nowrap"},B1={display:"flex",alignItems:"center",gap:8,marginTop:4,minWidth:0},W1={fontSize:11,color:"#7c3aed",flex:1,minWidth:0},j1={border:"1px solid #c4b5fd",borderRadius:6,background:"#f5f3ff",color:"#6d28d9",cursor:"pointer",fontSize:11,padding:"2px 8px",whiteSpace:"nowrap"},Ls={border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",fontSize:11,padding:"2px 6px"},H1={display:"flex",flexDirection:"column",gap:6,paddingTop:6,borderTop:"1px dashed #e2e8f0"},F1={margin:0,fontSize:11,color:"#b45309",background:"#fef3c7",padding:"6px 8px",borderRadius:6},K1={display:"flex",alignItems:"center",gap:8,minWidth:0},V1={fontSize:11,color:"#64748b",minWidth:90},U1={padding:"6px 12px",fontSize:12,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},q1={marginTop:6,border:"1px solid #cbd5e1",borderRadius:8,background:"#fff",padding:8,maxHeight:380,overflowY:"auto"},G1={fontSize:11,fontWeight:700,color:"#94a3b8",padding:"6px 4px 2px"},Y1={display:"flex",alignItems:"center",gap:6,width:"100%",textAlign:"left",padding:"6px 8px",fontSize:12,border:"none",background:"transparent",cursor:"pointer",borderRadius:6},X1={flex:1,minWidth:0,color:"#0f172a"},J1={fontSize:10,color:"#64748b",whiteSpace:"nowrap"},Z1={padding:"6px 10px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer"},Q1={marginTop:6,padding:"6px 12px",fontSize:12,border:"1px solid #3b82f6",borderRadius:6,background:"#3b82f6",color:"#fff",cursor:"pointer"},R1={margin:0,fontSize:12,color:"#94a3b8"},eS={margin:"2px 0 0",padding:"6px 8px",fontSize:11,fontFamily:"monospace",color:"#0f172a",background:"#f1f5f9",border:"1px solid #e2e8f0",borderRadius:6,whiteSpace:"pre-wrap",wordBreak:"break-all",overflowX:"auto"};function tS(e,t){return t==="any"?!0:e===null||typeof e!="object"?!1:t==="object"?!Array.isArray(e):!0}function Ps({value:e,onChange:t,onValidityChange:n,t:o,shape:r="any",emptyValue:s=void 0,placeholder:a,minHeight:l=80,testidPrefix:d="g7le-json-block",shapeErrorKey:c,invalidErrorKey:p,ariaLabel:f,id:y,validate:g}){const[m,h]=u.useState(()=>Br(e)),[v,b]=u.useState(null),x=u.useRef(!1),k=u.useRef(e);u.useEffect(()=>{if(!x.current&&!Object.is(e,k.current)){if(Br(e)===Br(k.current)){k.current=e;return}h(Br(e)),b(null),k.current=e,n?.(!0)}},[e]);const w=_=>{if(x.current=!0,h(_),_.trim()===""){b(null),k.current=s,n?.(!0),t(s);return}let S;try{S=JSON.parse(_)}catch{b(o(p??"layout_editor.json_block.invalid")),n?.(!1);return}if(!tS(S,r)){b(o(c??"layout_editor.json_block.invalid")),n?.(!1);return}const E=g?.(S)??null;if(E){b(E),n?.(!1);return}b(null),k.current=S,n?.(!0),t(S)};return i.jsxs("div",{className:"g7le-json-block",style:nS,children:[i.jsx("textarea",{id:y,"data-testid":d,style:{...oS,minHeight:l},value:m,spellCheck:!1,"aria-label":f,"aria-invalid":v?!0:void 0,placeholder:a,onChange:_=>w(_.target.value),onBlur:()=>{x.current=!1}}),v?i.jsx("div",{"data-testid":`${d}-error`,style:rS,children:v}):null]})}function Br(e){if(e===void 0)return"";try{return JSON.stringify(e,null,2)??""}catch{return""}}const nS={display:"flex",flexDirection:"column",gap:4,minWidth:0},oS={width:"100%",boxSizing:"border-box",fontFamily:"ui-monospace, SFMono-Regular, Menlo, monospace",fontSize:12,lineHeight:1.5,color:"#0f172a",background:"#fff",border:"1px solid #cbd5e1",borderRadius:6,padding:"6px 8px",resize:"vertical"},rS={fontSize:11,color:"#dc2626"},iS=[{kind:"string",labelKey:"layout_editor.initstate.kind_string"},{kind:"number",labelKey:"layout_editor.initstate.kind_number"},{kind:"boolean",labelKey:"layout_editor.initstate.kind_boolean"},{kind:"null",labelKey:"layout_editor.initstate.kind_null"},{kind:"list",labelKey:"layout_editor.initstate.kind_list"},{kind:"object",labelKey:"layout_editor.initstate.kind_object"}],Os={local:"initLocal",global:"initGlobal",isolated:"initIsolated"};function sS({raw:e,own:t,patch:n,t:o,isolatedOrphan:r={}}){const s=u.useMemo(()=>{const f=t?.state&&typeof t.state=="object"?t.state:void 0,y=e?.state&&typeof e.state=="object"?e.state:void 0;if(!(!f&&!y))return{...y??{},...f??{}}},[t,e]),{initLocal:a,migrated:l}=u.useMemo(()=>Iw({initLocal:e?.initLocal,state:s}),[e,s]),d=f=>{if(f==="local")return a;const y=e?.[Os[f]];return y&&typeof y=="object"?y:{}},c=f=>{if(f==="local"){const y=t?.initLocal??{},g=s?Object.keys(s):[];return Array.from(new Set([...Object.keys(y),...g]))}return Object.keys(t?.[Os[f]]??{})},p=u.useCallback((f,y)=>{const g=Os[f];f==="local"&&l&&n("state",void 0),n(g,Object.keys(y).length===0?void 0:y)},[n,l]);return i.jsxs("div",{className:"g7le-initstate-form","data-testid":"g7le-initstate-form",style:lS,children:[i.jsx("p",{style:dS,children:o("layout_editor.page_settings.initstate.heading")}),l?i.jsxs("p",{"data-testid":"g7le-initstate-legacy-state",style:cS,children:["ⓘ ",o("layout_editor.initstate.legacy_state")]}):null,i.jsx(Bs,{scope:"local",label:o("layout_editor.initstate.section_local"),value:d("local"),ownKeys:c("local"),patchSection:p,t:o}),i.jsx(Bs,{scope:"global",label:o("layout_editor.initstate.section_global"),value:d("global"),ownKeys:c("global"),patchSection:p,t:o}),i.jsx(Bs,{scope:"isolated",label:o("layout_editor.initstate.section_isolated"),description:o("layout_editor.initstate.section_isolated_desc"),value:d("isolated"),ownKeys:c("isolated"),patchSection:p,t:o,orphan:r}),i.jsxs("p",{style:IS,children:["ⓘ ",o("layout_editor.initstate.isolated_dynamic_hint")]})]})}function Bs({scope:e,label:t,description:n,value:o,ownKeys:r,patchSection:s,t:a,orphan:l={}}){const[d,c]=u.useState(""),[p,f]=u.useState("string"),[y,g]=u.useState(null),[m,h]=u.useState(!1),v=Object.keys(o),b=v,x=(_,S)=>{s(e,{...o,[_]:S})},k=_=>{const S={...o};delete S[_],s(e,S)},w=()=>{const _=d.trim();if(!_){g(a("layout_editor.initstate.name_required"));return}if(!$s(_)){g(a("layout_editor.initstate.name_invalid"));return}if(_ in o){g(a("layout_editor.initstate.name_duplicate"));return}const S={string:"",number:0,boolean:!1,null:null,list:[],object:{}};s(e,{...o,[_]:S[p]}),c(""),g(null)};return i.jsxs("div",{"data-testid":`g7le-initstate-section-${e}`,style:uS,children:[i.jsxs("div",{style:pS,children:[i.jsx("div",{style:fS,children:t}),i.jsx("button",{type:"button","data-testid":`g7le-initstate-${e}-mode-toggle`,onClick:()=>h(_=>!_),style:hS,children:a(m?"layout_editor.json_block.mode_block":"layout_editor.json_block.mode_code")})]}),n?i.jsxs("p",{style:gS,children:["ⓘ ",n]}):null,m?i.jsx(Ps,{value:o,shape:"object",emptyValue:{},onChange:_=>s(e,_??{}),t:a,testidPrefix:`g7le-initstate-${e}-code`,placeholder:`{ +var Rz=Object.defineProperty;var eM=(ln,i,u)=>i in ln?Rz(ln,i,{enumerable:!0,configurable:!0,writable:!0,value:u}):ln[i]=u;var Ii=(ln,i,u)=>eM(ln,typeof i!="symbol"?i+"":i,u);(function(ln,i,u,Sn){"use strict";function Lt(){const e=typeof window<"u"?window.G7Core?.__runtime:void 0;if(!e)throw new Error("[layout-editor] window.G7Core.__runtime 이 없습니다. 코어 번들(template-engine.min.js)이 편집기 번들보다 먼저 로드되어야 합니다.");return e}const Ai=Lt();Ai.TranslationProvider;const tt=Ai.useTranslation;Ai.TranslationReactContext;function rt(){return typeof window>"u"?null:window.__g7Devtools??null}function wl(e,t){const n=rt();if(!n?.trackEditorState)return;const o={templateIdentifier:e.templateIdentifier,editMode:e.editMode,selectedRoutePath:e.selectedRoute?.path??null,selectedLayoutName:e.selectedRoute?.layoutName??null,routeTreeSize:e.routeTree.length,isRouteTreeCollapsed:e.isRouteTreeCollapsed,locale:e.locale,previewDevice:e.previewDevice,previewZoom:e.previewZoom,lastAction:t,timestamp:Date.now()};try{n.trackEditorState(o)}catch{}}function Bh(){const e=rt();if(e?.clearEditorStateData)try{e.clearEditorStateData()}catch{}}function Wh(e){const t=rt();if(t?.trackEditorSampleMatch)try{t.trackEditorSampleMatch(e)}catch{}}function jh(){const e=rt();if(e?.clearEditorSampleData)try{e.clearEditorSampleData()}catch{}}function Hh(e){const t=rt();if(t?.trackEditorHistoryEntry)try{t.trackEditorHistoryEntry(e)}catch{}}function Fh(){const e=rt();if(e?.clearEditorHistoryData)try{e.clearEditorHistoryData()}catch{}}function kl(e){const t=rt();if(t?.trackEditorSelection)try{t.trackEditorSelection(e)}catch{}}function Kh(){const e=rt();if(e?.clearEditorSelectionData)try{e.clearEditorSelectionData()}catch{}}function or(e){const t=rt();if(t?.trackEditorPropertyPatch)try{t.trackEditorPropertyPatch(e)}catch{}}function Cn(e){const t=rt();if(t?.trackEditorDnd)try{t.trackEditorDnd(e)}catch{}}function Vh(){const e=rt();if(e?.clearEditorDndData)try{e.clearEditorDndData()}catch{}}function st(e){const t=rt();if(t?.trackEditorDocument)try{t.trackEditorDocument(e)}catch{}}function Uh(){const e=rt();if(e?.clearEditorDocumentData)try{e.clearEditorDocumentData()}catch{}}function qh(e){const t=rt();if(t?.trackEditorSpecMerge)try{t.trackEditorSpecMerge(e)}catch{}}function Gh(){const e=rt();if(e?.clearEditorSpecMergeData)try{e.clearEditorSpecMergeData()}catch{}}function Ti(e){let t=5381;for(let n=0;n>>0;return t}function Et(e){const t=rt();if(t?.trackEditorI18n)try{t.trackEditorI18n(e)}catch{}}function Yh(){const e=rt();if(e?.clearEditorI18nData)try{e.clearEditorI18nData()}catch{}}function Sl(e){const t=rt();if(t?.trackPageState)try{t.trackPageState(e)}catch{}}function Xh(){const e=rt();if(e?.clearPageStateData)try{e.clearPageStateData()}catch{}}const Cl="g7le.routeTree.collapsed";function Jh(){if(typeof window>"u")return!1;try{return window.localStorage?.getItem(Cl)==="1"}catch{return!1}}function Zh(e){if(!(typeof window>"u"))try{window.localStorage?.setItem(Cl,e?"1":"0")}catch{}}function Qh(e,t){return{templateIdentifier:e,routeTree:[],routesError:null,selectedRoute:null,isRouteTreeCollapsed:Jh(),editMode:"route",returnRoute:null,returnEditMode:null,locale:t,previewDevice:"desktop",previewZoom:1,previewCustomWidth:1024,previewColorScheme:"light",history:{canUndo:!1,canRedo:!1},isPaletteOpen:!1,availableStates:[],activeStateId:null,layoutVersions:{},extensionVersions:{}}}function Rh(e,t){switch(t.type){case"SET_ROUTE_TREE":return{...e,routeTree:t.tree,routesError:null};case"SET_ROUTES_ERROR":return{...e,routesError:t.error};case"SELECT_ROUTE":return{...e,editMode:"route",selectedRoute:t.route,returnRoute:null,returnEditMode:null};case"TOGGLE_ROUTE_TREE":return{...e,isRouteTreeCollapsed:!e.isRouteTreeCollapsed};case"SET_LOCALE":return{...e,locale:t.locale};case"ENTER_BASE_EDIT":return{...e,editMode:"base",returnRoute:t.returnRoute!==void 0?t.returnRoute:e.selectedRoute,selectedRoute:{path:`__base__/${t.layoutName}`,layoutName:t.layoutName}};case"EXIT_BASE_EDIT":return{...e,editMode:"route",selectedRoute:e.returnRoute,returnRoute:null};case"ENTER_MODAL_EDIT":return{...e,editMode:"modal",returnRoute:t.returnRoute!==void 0?t.returnRoute:e.selectedRoute,selectedRoute:{path:`__modal__/${t.modalId}`,layoutName:t.hostLayout}};case"EXIT_MODAL_EDIT":return{...e,editMode:"route",selectedRoute:e.returnRoute,returnRoute:null};case"ENTER_EXTENSION_EDIT":return{...e,editMode:"extension",returnRoute:t.returnRoute!==void 0?t.returnRoute:e.selectedRoute,selectedRoute:{path:`__extension__/${t.extensionId}`,layoutName:t.extensionHost??null}};case"SET_RETURN_ROUTE":return e.editMode==="route"||e.returnRoute!==null?e:{...e,returnRoute:t.route};case"EXIT_EXTENSION_EDIT":return{...e,editMode:"route",selectedRoute:e.returnRoute,returnRoute:null};case"ENTER_ITERATION_ITEM_EDIT":return{...e,editMode:"iteration_item",returnRoute:t.returnRoute!==void 0?t.returnRoute:e.selectedRoute,returnEditMode:e.editMode,selectedRoute:{path:`__iteration__/${t.sourcePath}`,layoutName:t.hostLayout??e.selectedRoute?.layoutName??null}};case"EXIT_ITERATION_ITEM_EDIT":return{...e,editMode:e.returnEditMode??"route",selectedRoute:e.returnRoute,returnRoute:null,returnEditMode:null};case"SET_PREVIEW_DEVICE":return{...e,previewDevice:t.device};case"SET_PREVIEW_ZOOM":return{...e,previewZoom:t.zoom};case"SET_PREVIEW_CUSTOM_WIDTH":return{...e,previewCustomWidth:t.width};case"SET_PREVIEW_COLOR_SCHEME":return{...e,previewColorScheme:t.scheme};case"TOGGLE_PALETTE":return{...e,isPaletteOpen:!e.isPaletteOpen};case"SET_PALETTE_OPEN":return{...e,isPaletteOpen:t.open};case"SET_AVAILABLE_STATES":return{...e,availableStates:t.states,activeStateId:t.activeStateId};case"SET_ACTIVE_STATE":return{...e,activeStateId:t.activeStateId};case"SET_LAYOUT_VERSIONS":return{...e,layoutVersions:t.versions};case"SET_LAYOUT_VERSION":return{...e,layoutVersions:{...e.layoutVersions,[t.layoutName]:t.version}};case"SET_EXTENSION_VERSIONS":return{...e,extensionVersions:t.versions};case"SET_EXTENSION_VERSION":return{...e,extensionVersions:{...e.extensionVersions,[t.extensionId]:t.version}};default:return e}}function ey(e,t){const n=Rh(e,t);return((n.selectedRoute?.path??null)!==(e.selectedRoute?.path??null)||n.editMode!==e.editMode)&&(n.availableStates.length>0||n.activeStateId!==null)?{...n,availableStates:[],activeStateId:null}:n}const $l=u.createContext(null);function ty(e,t){const n=ey(e,t);return wl(n,t.type),n}function ny({templateIdentifier:e,initialLocale:t,children:n}){const[o,r]=u.useReducer(ty,void 0,()=>{const a=Qh(e,t);return wl(a,void 0),a});u.useEffect(()=>{Zh(o.isRouteTreeCollapsed)},[o.isRouteTreeCollapsed]);const s=u.useMemo(()=>({state:o,dispatch:r}),[o]);return u.createElement($l.Provider,{value:s},n)}function Je(){const e=u.useContext($l);if(!e)throw new Error("useLayoutEditor must be used within LayoutEditorProvider");return e}const El=u.createContext(null);function oy({value:e,children:t}){return u.createElement(El.Provider,{value:e},t)}function Ft(){return u.useContext(El)}const Di=u.createContext(null);let ry=1;function iy({children:e,t}){const[n,o]=u.useState([]),r=u.useRef([]),[s,a]=u.useState({}),l=u.useCallback(b=>{const x=b.id??`editor-modal-${ry++}`,k={id:x,content:b.content,ariaLabel:b.ariaLabel??"",closeOnBackdrop:b.closeOnBackdrop??!0,closeOnEscape:b.closeOnEscape??!0,width:b.width??"auto",height:b.height??"auto",maxHeightRatio:b.maxHeightRatio??.85,draggable:b.draggable??!1,avoidRect:b.avoidRect,onClose:b.onClose};return o(w=>{const S=[...w.filter(E=>E.id!==x),k];return r.current=S,S}),x},[]),d=u.useCallback(b=>{o(x=>{const k=x.find(_=>_.id===b),w=x.filter(_=>_.id!==b);return r.current=w,k?.onClose&&queueMicrotask(()=>k.onClose?.()),w})},[]),c=u.useCallback(()=>{const b=r.current[r.current.length-1];b&&d(b.id)},[d]),p=u.useCallback(()=>{o(b=>{for(let x=b.length-1;x>=0;x--){const k=b[x];k?.onClose&&queueMicrotask(()=>k.onClose?.())}return r.current=[],[]})},[]),f=u.useCallback((b,x)=>{a(k=>{const w=r.current.find(S=>S.id===b),_=x??w?.ariaLabel??"";return{...k,[b]:_}})},[]),y=u.useCallback(b=>{a(x=>{if(!(b in x))return x;const k={...x};return delete k[b],k})},[]),g=u.useCallback(b=>{y(b),d(b)},[y,d]),m=u.useCallback(()=>{a({}),p()},[p]),h=u.useMemo(()=>Object.keys(s),[s]),v=u.useMemo(()=>({open:l,close:g,closeTop:c,closeAll:m,stack:n,minimize:f,restore:y,minimizedStack:h}),[l,g,c,m,n,f,y,h]);return u.createElement(Di.Provider,{value:v},e,u.createElement(ay,{minimized:s,onRestore:y,t,key:"__min_bar"}))}function go(){const e=u.useContext(Di);if(!e)throw new Error("useEditorModal must be used within EditorModalProvider");return e}function sy(){const e=u.useContext(Di);if(!e)return null;const{stack:t,close:n,minimizedStack:o}=e,r=u.useMemo(()=>new Set(o),[o]);return u.useEffect(()=>{if(t.length===0)return;const s=a=>{if(a.key!=="Escape"&&a.key!=="Esc")return;const l=t.filter(c=>!r.has(c.id)),d=l[l.length-1];d?.closeOnEscape&&(a.preventDefault(),a.stopPropagation(),n(d.id))};return window.addEventListener("keydown",s,!0),()=>window.removeEventListener("keydown",s,!0)},[t,n,r]),t.length===0?null:i.jsx(i.Fragment,{children:t.map((s,a)=>i.jsx(dy,{entry:s,depth:a,hidden:r.has(s.id),onClose:()=>n(s.id)},s.id))})}function ay({minimized:e,onRestore:t,t:n}){const o=n??(s=>s),r=Object.keys(e);return r.length===0?null:i.jsx("div",{className:"g7le-minimized-bar","data-testid":"g7le-minimized-bar",style:{position:"fixed",left:16,bottom:16,display:"flex",gap:8,zIndex:9300},children:r.map(s=>i.jsxs("button",{type:"button","data-testid":`g7le-minimized-${s}`,onClick:()=>t(s),style:{display:"flex",alignItems:"center",gap:6,padding:"8px 12px",background:"#1e293b",color:"#fff",border:"none",borderRadius:8,boxShadow:"0 8px 24px rgba(15, 23, 42, 0.3)",cursor:"pointer",fontSize:13},children:[i.jsx("span",{"aria-hidden":"true",children:"🗗"}),i.jsx("span",{children:e[s]||o("layout_editor.modal.minimized_default")}),i.jsx("span",{style:{opacity:.7},children:o("layout_editor.modal.restore")})]},s))})}function ly(e,t,n){if(!e||typeof window>"u")return null;const o=window.innerWidth,r=window.innerHeight,s=16,a=e.left,l=o-(e.left+e.width),d=t+s;let c;if(l>=a&&l>=d)c=e.left+e.width+s;else if(a>=d)c=Math.max(s,e.left-s-t);else return null;c=Math.min(Math.max(s,c),Math.max(s,o-t-s));let p=e.top;return p=Math.min(Math.max(s,p),Math.max(s,r-n-s)),{left:c,top:p}}const Il="clamp(16px, 6vh, 56px)";function dy({entry:e,depth:t,hidden:n,onClose:o}){const r=9100+t*10,s=e.maxHeightRatio>0?`${e.maxHeightRatio*100}vh`:void 0,a=e.draggable?s:`calc(100vh - (${Il}) * 2)`,[l,d]=u.useState(null),c=u.useRef(null),p=u.useRef(null);u.useEffect(()=>{if(!e.draggable)return;const v=c.current;if(!v)return;const b=v.getBoundingClientRect(),x=ly(e.avoidRect,b.width,b.height);x&&d(x)},[e.id,e.draggable]);const f=u.useCallback(v=>{if(!e.draggable)return;const b=v.target;if(!b.closest("[data-modal-drag-handle]")||b.closest("button, a, input, textarea, select"))return;const k=c.current,w=k?.getBoundingClientRect(),_=l?.left??w?.left??0,S=l?.top??w?.top??0;p.current={startX:v.clientX,startY:v.clientY,baseLeft:_,baseTop:S};const E=N=>{const $=p.current;if(!$)return;const A=window.innerWidth,z=window.innerHeight,D=k?.offsetWidth??0,O=k?.offsetHeight??0;let M=$.baseLeft+(N.clientX-$.startX),B=$.baseTop+(N.clientY-$.startY);M=Math.min(Math.max(0,M),Math.max(0,A-D)),B=Math.min(Math.max(0,B),Math.max(0,z-O)),d({left:M,top:B})},C=()=>{p.current=null,window.removeEventListener("pointermove",E),window.removeEventListener("pointerup",C)};window.addEventListener("pointermove",E),window.addEventListener("pointerup",C)},[e.draggable,l]),y=v=>{e.closeOnBackdrop&&v.target===v.currentTarget&&o()},g=e.draggable&&l!==null,m=e.draggable?{position:"fixed",inset:0,background:"transparent",pointerEvents:"none",zIndex:r,...n?{display:"none"}:{}}:{position:"fixed",inset:0,background:"rgba(15, 23, 42, 0.45)",display:n?"none":"flex",alignItems:"flex-start",justifyContent:"center",paddingTop:Il,boxSizing:"border-box",zIndex:r},h={background:"#fff",borderRadius:10,boxShadow:"0 20px 60px rgba(15, 23, 42, 0.25)",width:e.width,height:e.height,maxHeight:a,maxWidth:"92vw",overflow:"hidden",display:"flex",flexDirection:"column",...e.draggable?{position:"absolute",pointerEvents:"auto",left:g?l.left:"50%",top:g?l.top:"50%",transform:g?void 0:"translate(-50%, -50%)"}:{}};return i.jsx("div",{className:"g7le-modal-backdrop",role:"presentation","data-testid":`g7le-modal-backdrop-${e.id}`,"data-draggable":e.draggable?"true":"false",onClick:y,style:m,children:i.jsx("div",{ref:c,className:"g7le-modal",role:"dialog","aria-modal":"true","aria-label":e.ariaLabel||void 0,"data-testid":`g7le-modal-${e.id}`,"data-positioned":g?"true":"false",onClick:v=>v.stopPropagation(),onPointerDown:e.draggable?f:void 0,style:h,children:e.content})})}function wt({baseStyle:e,hoverStyle:t,disabled:n,style:o,children:r,...s}){const[a,l]=u.useState(!1),d={outline:"none",...e,...o,...a&&!n?t:{}};return i.jsx("button",{type:"button",disabled:n,onMouseEnter:()=>l(!0),onMouseLeave:()=>l(!1),style:d,...s,children:r})}function cy({templateIdentifier:e,templateName:t,templateVersion:n,templateList:o,onSwitch:r}){const{t:s}=tt(),[a,l]=u.useState(!1),[d,c]=u.useState(!1),p=u.useRef(null);u.useEffect(()=>{if(!a)return;const v=x=>{p.current&&!p.current.contains(x.target)&&l(!1)},b=x=>{x.key==="Escape"&&l(!1)};return document.addEventListener("mousedown",v),document.addEventListener("keydown",b),()=>{document.removeEventListener("mousedown",v),document.removeEventListener("keydown",b)}},[a]);const f=o??[],y=f.some(v=>v.identifier!==e),g=!!r&&y,m=t||e,h=v=>{if(v===e){l(!1);return}l(!1),r?.(v)};return i.jsxs("div",{ref:p,className:"g7le-toolbar__template-switcher",style:{position:"relative",display:"inline-flex"},children:[i.jsxs("button",{type:"button",className:"g7le-toolbar__template","data-testid":"g7le-toolbar-template-label","data-can-switch":g?"true":"false","aria-haspopup":g?"listbox":void 0,"aria-expanded":g?a:void 0,disabled:!g,onClick:()=>g&&l(v=>!v),onMouseEnter:()=>c(!0),onMouseLeave:()=>c(!1),title:g?s("layout_editor.chrome.toolbar.switch_template"):m,style:{display:"inline-flex",alignItems:"center",gap:6,padding:"4px 8px",fontSize:13,fontWeight:600,color:"#0f172a",letterSpacing:.1,borderRadius:6,borderWidth:1,borderStyle:"solid",borderColor:g&&(d||a)?"#cbd5e1":"transparent",backgroundColor:g&&(d||a)?"#f1f5f9":"transparent",cursor:g?"pointer":"default",outline:"none",transition:"background-color 120ms, border-color 120ms"},children:[i.jsx("span",{className:"g7le-toolbar__template-name","data-testid":"g7le-toolbar-template-name",children:m}),n&&i.jsxs("span",{className:"g7le-toolbar__template-version","data-testid":"g7le-toolbar-template-version",style:{fontSize:11,fontWeight:500,color:"#64748b",padding:"1px 6px",borderRadius:999,background:"#f1f5f9",border:"1px solid #e2e8f0"},children:["v",n]}),g&&i.jsx("span",{className:"g7le-toolbar__template-caret","aria-hidden":"true",style:{fontSize:9,color:"#94a3b8",marginLeft:1},children:"▼"})]}),g&&a&&i.jsx("div",{className:"g7le-toolbar__template-menu","data-testid":"g7le-toolbar-template-menu",role:"listbox",style:{position:"absolute",top:"calc(100% + 4px)",left:0,minWidth:240,maxWidth:360,maxHeight:360,overflowY:"auto",background:"#ffffff",border:"1px solid #e2e8f0",borderRadius:8,boxShadow:"0 8px 24px rgba(15, 23, 42, 0.14)",zIndex:30,padding:4},children:f.map(v=>{const b=v.identifier===e;return i.jsxs("button",{type:"button",role:"option","aria-selected":b,"aria-current":b?"true":void 0,className:"g7le-toolbar__template-menu-item","data-testid":"g7le-toolbar-template-menu-item","data-identifier":v.identifier,"data-current":b?"true":"false",onClick:()=>h(v.identifier),style:{display:"flex",alignItems:"center",justifyContent:"space-between",gap:8,width:"100%",padding:"8px 10px",fontSize:13,color:"#1e293b",background:b?"#eff6ff":"transparent",border:"none",borderRadius:6,cursor:b?"default":"pointer",textAlign:"left",outline:"none"},onMouseEnter:x=>{b||(x.currentTarget.style.background="#f1f5f9")},onMouseLeave:x=>{x.currentTarget.style.background=b?"#eff6ff":"transparent"},children:[i.jsxs("span",{style:{display:"flex",alignItems:"center",gap:8,minWidth:0},children:[i.jsx("span",{"aria-hidden":"true",style:{width:12,flex:"0 0 auto",color:"#2563eb",fontSize:12,textAlign:"center"},children:b?"✓":""}),i.jsxs("span",{style:{display:"flex",flexDirection:"column",gap:2,minWidth:0},children:[i.jsx("span",{style:{fontWeight:600,color:b?"#1d4ed8":"#1e293b",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:v.name||v.identifier}),i.jsx("span",{style:{fontSize:11,color:"#94a3b8",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:v.identifier})]})]}),i.jsxs("span",{style:{display:"inline-flex",alignItems:"center",gap:6,flex:"0 0 auto"},children:[i.jsx("span",{style:{fontSize:10,fontWeight:600,color:v.type==="admin"?"#7c3aed":"#0891b2",textTransform:"uppercase",letterSpacing:.4},children:v.type}),v.version&&i.jsxs("span",{style:{fontSize:11,color:"#64748b"},children:["v",v.version]})]})]},v.identifier)})})]})}function uy(e={}){const{onSave:t,isDirty:n=!1,onExit:o,onEditCode:r,onManageImages:s,onManageTranslations:a,onPageSettings:l,onReset:d,onShowShortcuts:c,onPreview:p,onShowVersions:f,templateName:y,templateVersion:g,templateList:m,onSwitchTemplate:h}=e,[v,b]=u.useState(!1),[x,k]=u.useState(!1),{t:w}=tt(),{state:_,dispatch:S}=Je(),[E,C]=u.useState(null);u.useEffect(()=>{const I=()=>{const F=window.__g7LayoutEditorHistory;C(F?{undo:F.undo,redo:F.redo,canUndo:!!F.canUndo,canRedo:!!F.canRedo}:null)};I();const P=window.setInterval(I,250);return()=>window.clearInterval(P)},[]);const N=()=>{S({type:"TOGGLE_ROUTE_TREE"})},$=()=>_.editMode==="base"?w("layout_editor.chrome.toolbar.mode_badge.base"):_.editMode==="modal"?w("layout_editor.chrome.toolbar.mode_badge.modal"):_.editMode==="extension"?w("layout_editor.chrome.toolbar.mode_badge.extension"):_.editMode==="iteration_item"?w("layout_editor.chrome.toolbar.mode_badge.iteration_item"):null,A=()=>{_.editMode==="base"?S({type:"EXIT_BASE_EDIT"}):_.editMode==="modal"?S({type:"EXIT_MODAL_EDIT"}):_.editMode==="extension"?S({type:"EXIT_EXTENSION_EDIT"}):_.editMode==="iteration_item"&&S({type:"EXIT_ITERATION_ITEM_EDIT"})},z=$(),D=_.editMode!=="route",O={display:"inline-flex",alignItems:"center",gap:6,padding:"6px 12px",fontSize:13,fontWeight:500,borderRadius:6,borderWidth:1,borderStyle:"solid",borderColor:"transparent",backgroundColor:"transparent",color:"#475569",cursor:"pointer",transition:"background-color 120ms, color 120ms, border-color 120ms"},M={...O,backgroundColor:"#2563eb",color:"#ffffff",borderColor:"#2563eb",fontWeight:600},B={...O,borderColor:"#e2e8f0",backgroundColor:"#ffffff",color:"#0f172a"},T={backgroundColor:"#f1f5f9",color:"#0f172a",borderColor:"#cbd5e1"},H={backgroundColor:"#1d4ed8",borderColor:"#1d4ed8"},L={backgroundColor:"#f8fafc",borderColor:"#cbd5e1"};return i.jsxs("div",{className:"g7le-toolbar","data-testid":"g7le-toolbar",role:"toolbar",style:{display:"flex",alignItems:"center",gap:6,padding:"8px 12px",borderBottom:"1px solid #e2e8f0",background:"#ffffff",boxShadow:"0 1px 2px rgba(15, 23, 42, 0.04)"},children:[i.jsx("style",{children:"@keyframes g7le-spin { to { transform: rotate(360deg); } }"}),i.jsx("button",{type:"button",className:"g7le-toolbar__route-tree-toggle","data-testid":"g7le-toolbar-toggle-route-tree","aria-label":w("layout_editor.chrome.toolbar.toggle_route_tree"),title:w("layout_editor.chrome.toolbar.toggle_route_tree"),onClick:N,style:{...O,outline:"none",padding:"6px 10px",fontSize:16,lineHeight:1,color:"#64748b"},children:"☰"}),i.jsx(cy,{templateIdentifier:_.templateIdentifier,templateName:y,templateVersion:g,templateList:m,onSwitch:h}),z&&i.jsx("span",{className:"g7le-toolbar__mode-badge","data-testid":"g7le-toolbar-mode-badge","data-mode":_.editMode,style:{padding:"2px 8px",borderRadius:999,background:"#fef3c7",color:"#92400e",fontSize:11,fontWeight:600,border:"1px solid #fde68a"},children:z}),i.jsx("div",{style:{flex:1}}),i.jsxs(i.Fragment,{children:[i.jsx(wt,{"data-testid":"g7le-toolbar-undo","aria-label":w("layout_editor.chrome.toolbar.undo"),title:w("layout_editor.chrome.toolbar.undo"),disabled:!E?.canUndo,onClick:()=>E?.undo?.(),baseStyle:O,hoverStyle:T,style:{opacity:E?.canUndo?1:.4},children:"↶"}),i.jsx(wt,{"data-testid":"g7le-toolbar-redo","aria-label":w("layout_editor.chrome.toolbar.redo"),title:w("layout_editor.chrome.toolbar.redo"),disabled:!E?.canRedo,onClick:()=>E?.redo?.(),baseStyle:O,hoverStyle:T,style:{opacity:E?.canRedo?1:.4},children:"↷"}),i.jsxs(wt,{"data-testid":"g7le-toolbar-add-element","data-open":_.isPaletteOpen?"true":"false",disabled:!_.selectedRoute,onClick:()=>S({type:"TOGGLE_PALETTE"}),"aria-pressed":_.isPaletteOpen,baseStyle:O,hoverStyle:T,style:_.isPaletteOpen?{backgroundColor:"#eff6ff",color:"#1d4ed8",borderColor:"#93c5fd"}:void 0,children:["+ ",w("layout_editor.chrome.toolbar.add_element")]}),i.jsx(wt,{"data-testid":"g7le-toolbar-preview","data-previewing":x?"true":"false",disabled:!p||!_.selectedRoute?.layoutName||x,onClick:()=>{const I=p?.();I&&typeof I.then=="function"&&(k(!0),I.finally(()=>k(!1)))},baseStyle:O,hoverStyle:T,style:{opacity:x?.7:1},children:x?i.jsxs(i.Fragment,{children:[i.jsx("span",{"data-testid":"g7le-toolbar-preview-spinner","aria-hidden":"true",style:{display:"inline-block",width:12,height:12,border:"2px solid rgba(71,85,105,0.35)",borderTopColor:"#475569",borderRadius:"50%",animation:"g7le-spin 600ms linear infinite"}}),w("layout_editor.chrome.toolbar.previewing")]}):i.jsxs(i.Fragment,{children:["👁 ",w("layout_editor.chrome.toolbar.preview")]})}),i.jsxs(wt,{"data-testid":"g7le-toolbar-versions",disabled:!f||!(_.selectedRoute?.layoutName||_.selectedRoute?.path?.startsWith("__extension__/")),onClick:()=>f?.(),"aria-label":w("layout_editor.chrome.toolbar.version_history"),title:w("layout_editor.chrome.toolbar.version_history"),baseStyle:O,hoverStyle:T,children:["🕘 ",w("layout_editor.chrome.toolbar.version_history")]}),i.jsxs(wt,{"data-testid":"g7le-toolbar-edit-code",disabled:!r,onClick:()=>r?.(),baseStyle:O,hoverStyle:T,children:["⟨/⟩ ",w("layout_editor.chrome.toolbar.edit_code")]}),i.jsxs(wt,{"data-testid":"g7le-toolbar-images",disabled:!s||!_.selectedRoute,onClick:()=>s?.(),baseStyle:O,hoverStyle:T,children:["🖼 ",w("layout_editor.chrome.toolbar.images")]}),i.jsxs(wt,{"data-testid":"g7le-toolbar-translations",disabled:!a||!_.selectedRoute,onClick:()=>a?.(),baseStyle:O,hoverStyle:T,children:["🌐 ",w("layout_editor.chrome.toolbar.translations")]}),i.jsxs(wt,{"data-testid":"g7le-toolbar-page-settings",disabled:!l||!_.selectedRoute||_.editMode==="extension"||_.editMode==="iteration_item",onClick:()=>l?.(),baseStyle:O,hoverStyle:T,children:["⚙ ",w("layout_editor.chrome.toolbar.page_settings")]}),i.jsxs(wt,{"data-testid":"g7le-toolbar-reset",disabled:!d||!n,onClick:()=>d?.(),"aria-label":w("layout_editor.chrome.toolbar.reset"),title:w("layout_editor.chrome.toolbar.reset"),baseStyle:O,hoverStyle:T,style:{opacity:n?1:.4},children:["↺ ",w("layout_editor.chrome.toolbar.reset")]}),i.jsx(wt,{"data-testid":"g7le-toolbar-save","data-dirty":n?"true":"false","data-saving":v?"true":"false",disabled:!t||v,onClick:()=>{const I=t?.();I&&typeof I.then=="function"&&(b(!0),I.finally(()=>b(!1)))},baseStyle:M,hoverStyle:H,style:{opacity:v?.7:n?1:.7},children:v?i.jsxs(i.Fragment,{children:[i.jsx("span",{"data-testid":"g7le-toolbar-save-spinner","aria-hidden":"true",className:"g7le-toolbar__spinner",style:{display:"inline-block",width:12,height:12,border:"2px solid rgba(255,255,255,0.4)",borderTopColor:"#ffffff",borderRadius:"50%",animation:"g7le-spin 600ms linear infinite"}}),w("layout_editor.chrome.toolbar.saving")]}):i.jsxs(i.Fragment,{children:["💾 ",w("layout_editor.chrome.toolbar.save")]})}),i.jsx(wt,{"data-testid":"g7le-toolbar-shortcuts",disabled:!c,onClick:()=>c?.(),"aria-label":w("layout_editor.shortcuts.title"),title:w("layout_editor.shortcuts.title"),baseStyle:O,hoverStyle:T,children:"⌨"}),i.jsxs(wt,{"data-testid":"g7le-toolbar-exit",disabled:!o,onClick:()=>o?.(),baseStyle:B,hoverStyle:L,children:["✕ ",w("layout_editor.chrome.toolbar.exit")]})]}),D&&i.jsxs(wt,{"data-testid":"g7le-toolbar-exit-alt-mode",onClick:A,baseStyle:B,hoverStyle:L,children:["← ",_.editMode==="base"&&w("layout_editor.chrome.toolbar.exit_base_edit"),_.editMode==="modal"&&w("layout_editor.chrome.toolbar.exit_modal_edit"),_.editMode==="extension"&&w("layout_editor.chrome.toolbar.exit_extension_edit"),_.editMode==="iteration_item"&&w("layout_editor.chrome.toolbar.exit_iteration_item_edit")]})]})}function rr(){if(typeof window>"u")return null;try{return window.localStorage?.getItem("auth_token")??null}catch{return null}}function je(e){const t={Accept:"application/json",...e??{}},n=rr();return n&&(t.Authorization=`Bearer ${n}`),t}function Ni(e){return encodeURIComponent(e)}async function Al(e,t){const n=`/api/admin/templates/${Ni(e)}/layout-attachments?layout_name=${encodeURIComponent(t)}`;try{const o=await fetch(n,{method:"GET",headers:je(),credentials:"same-origin"}),r=await o.json().catch(()=>null);return!o.ok||!r?.success?{ok:!1,status:o.status,message:zi(r,o.status)}:{ok:!0,data:Array.isArray(r?.data)?r.data:[]}}catch(o){return{ok:!1,status:0,message:o instanceof Error?o.message:"network error"}}}async function Tl(e,t,n){const o=`/api/admin/templates/${Ni(e)}/layout-attachments`,r=new FormData;r.append("file",n),r.append("layout_name",t);try{const s=await fetch(o,{method:"POST",headers:je(),body:r,credentials:"same-origin"}),a=await s.json().catch(()=>null);return!s.ok||!a?.success||!a?.data?.url?{ok:!1,status:s.status,message:zi(a,s.status)}:{ok:!0,data:a.data}}catch(s){return{ok:!1,status:0,message:s instanceof Error?s.message:"network error"}}}async function Dl(e){const t=`/api/admin/templates/layout-attachments/${Ni(String(e))}`;try{const n=await fetch(t,{method:"DELETE",headers:je(),credentials:"same-origin"}),o=await n.json().catch(()=>null);return!n.ok||o&&o.success===!1?{ok:!1,status:n.status,message:zi(o,n.status)}:{ok:!0,data:!0}}catch(n){return{ok:!1,status:0,message:n instanceof Error?n.message:"network error"}}}function zi(e,t){if(e&&typeof e=="object"&&"message"in e){const n=e.message;if(typeof n=="string"&&n.length>0)return n}return`HTTP ${t}`}function py(e){return e<1024?`${e} B`:e<1024*1024?`${Math.round(e/1024)} KB`:`${(e/(1024*1024)).toFixed(1)} MB`}function fy(e){if(!e)return"";const t=e.match(/^(\d{4})-(\d{2})-(\d{2})/);return t?`${t[2]}-${t[3]}`:""}function Nl({templateIdentifier:e,layoutName:t,t:n,onSelect:o,onChanged:r,onClose:s}){const[a,l]=u.useState([]),[d,c]=u.useState("idle"),[p,f]=u.useState(!1),[y,g]=u.useState(null),m=u.useRef(null),h=u.useCallback(async()=>{c("loading"),g(null);const x=await Al(e,t);x.ok?(l(x.data),c("idle")):(g(x.message),c("error"))},[e,t]);u.useEffect(()=>{h()},[h]);const v=async x=>{const k=x.target.files?.[0];if(!k)return;f(!0),g(null);const w=await Tl(e,t,k);w.ok?(l(_=>[w.data,..._.filter(S=>S.id!==w.data.id)]),r?.()):g(w.message),f(!1),m.current&&(m.current.value="")},b=async x=>{if(!(typeof window<"u"&&typeof window.confirm=="function"?window.confirm(n("layout_editor.attachment_manager.delete_confirm")):!0))return;const w=await Dl(x.id);w.ok?(l(_=>_.filter(S=>S.id!==x.id)),r?.()):g(w.message)};return i.jsxs("div",{className:"g7le-attachment-manager","data-testid":"g7le-attachment-manager",style:gy,children:[i.jsxs("div",{"data-modal-drag-handle":!0,style:hy,children:[i.jsx("span",{style:{fontSize:14,fontWeight:700},"data-testid":"g7le-attachment-manager-title",children:n("layout_editor.attachment_manager.title")}),i.jsx("button",{type:"button","aria-label":"close",onClick:s,style:yy,"data-testid":"g7le-attachment-manager-close",children:"✕"})]}),i.jsx("div",{style:by,children:i.jsxs("label",{style:my,"data-testid":"g7le-attachment-upload",children:[p?"…":`⬆ ${n("layout_editor.attachment_manager.upload")}`,i.jsx("input",{ref:m,type:"file",accept:"image/*",onChange:v,disabled:p,style:{display:"none"},"data-testid":"g7le-attachment-upload-input"})]})}),y&&i.jsx("div",{style:vy,"data-testid":"g7le-attachment-manager-error",children:y}),i.jsx("div",{style:_y,children:d==="loading"?i.jsx("div",{style:zl,"data-testid":"g7le-attachment-manager-loading",children:n("layout_editor.attachment_manager.loading")}):a.length===0&&d!=="error"?i.jsx("div",{style:zl,"data-testid":"g7le-attachment-manager-empty",children:n("layout_editor.attachment_manager.empty")}):i.jsx("div",{style:xy,"data-testid":"g7le-attachment-grid",children:a.map(x=>i.jsxs("div",{style:wy,"data-testid":`g7le-attachment-card-${x.id}`,children:[i.jsx("div",{style:{...ky,backgroundImage:`url(${x.url})`}}),i.jsx("div",{style:Sy,title:x.original_name,children:x.original_name}),i.jsxs("div",{style:Cy,children:[py(x.size),x.created_at?` · ${fy(x.created_at)}`:""]}),i.jsxs("div",{style:$y,children:[o&&i.jsx("button",{type:"button",onClick:()=>o(x.url),style:Ey,"data-testid":`g7le-attachment-use-${x.id}`,children:n("layout_editor.attachment_manager.use_as_bg")}),i.jsx("button",{type:"button",onClick:()=>b(x),style:Iy,"data-testid":`g7le-attachment-delete-${x.id}`,children:"🗑"})]})]},x.id))})}),i.jsx("div",{style:Ay,children:i.jsx("button",{type:"button",onClick:s,style:Ty,"data-testid":"g7le-attachment-manager-footer-close",children:n("layout_editor.attachment_manager.close")})})]})}const gy={display:"flex",flexDirection:"column",minHeight:0,flex:1},hy={display:"flex",alignItems:"center",justifyContent:"space-between",padding:"12px 16px",borderBottom:"1px solid #e2e8f0"},yy={border:"none",background:"transparent",fontSize:16,color:"#94a3b8",cursor:"pointer"},by={padding:"10px 16px",borderBottom:"1px solid #f1f5f9",display:"flex",gap:8},my={padding:"6px 12px",fontSize:13,border:"1px solid #2563eb",borderRadius:6,background:"#fff",color:"#2563eb",cursor:"pointer",display:"inline-flex",alignItems:"center"},vy={padding:"8px 16px",fontSize:12,color:"#b91c1c",background:"#fef2f2"},_y={padding:16,overflow:"auto",flex:1,minHeight:120},zl={fontSize:13,color:"#94a3b8",textAlign:"center",padding:"32px 0"},xy={display:"grid",gridTemplateColumns:"repeat(auto-fill, minmax(140px, 1fr))",gap:12},wy={display:"flex",flexDirection:"column",gap:4,border:"1px solid #e2e8f0",borderRadius:8,padding:8},ky={width:"100%",height:88,borderRadius:6,border:"1px solid #cbd5e1",backgroundSize:"cover",backgroundPosition:"center",backgroundColor:"#f8fafc"},Sy={fontSize:12,fontWeight:600,color:"#0f172a",whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis"},Cy={fontSize:11,color:"#64748b"},$y={display:"flex",gap:6,marginTop:2},Ey={flex:1,padding:"4px 6px",fontSize:11,border:"1px solid #2563eb",borderRadius:6,background:"#2563eb",color:"#fff",cursor:"pointer"},Iy={padding:"4px 8px",fontSize:12,border:"1px solid #fecaca",borderRadius:6,background:"#fff",color:"#dc2626",cursor:"pointer"},Ay={padding:"10px 16px",borderTop:"1px solid #e2e8f0",display:"flex",justifyContent:"flex-end"},Ty={padding:"6px 14px",fontSize:13,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"};function ir(e){return encodeURIComponent(e)}async function Dy(e,t,n){const o=new URLSearchParams;t&&o.set("layout_name",t);const r=o.toString(),s=`/api/admin/templates/${ir(e)}/custom-translations`+(r?`?${r}`:"");try{const a=await fetch(s,{method:"GET",headers:je(),credentials:"same-origin"}),l=await a.json().catch(()=>null);return!a.ok||!l?.success?{ok:!1,status:a.status,message:sr(l,a.status)}:{ok:!0,data:Array.isArray(l?.data)?l.data:[]}}catch(a){return{ok:!1,status:0,message:a instanceof Error?a.message:"network error"}}}async function Ny(e,t,n,o){const r=`/api/admin/templates/${ir(e)}/custom-translations/${t}`;try{const s=await fetch(r,{method:"PUT",headers:je({"Content-Type":"application/json"}),body:JSON.stringify({values:n,expected_lock_version:o}),credentials:"same-origin"}),a=await s.json().catch(()=>null);return!s.ok||!a?.success||!a?.data?{ok:!1,status:s.status,message:sr(a,s.status)}:{ok:!0,data:a.data}}catch(s){return{ok:!1,status:0,message:s instanceof Error?s.message:"network error"}}}async function zy(e,t){const n=`/api/admin/templates/${ir(e)}/custom-translations/${t}`;try{const o=await fetch(n,{method:"DELETE",headers:je(),credentials:"same-origin"}),r=await o.json().catch(()=>null);return!o.ok||r&&r.success===!1?{ok:!1,status:o.status,message:sr(r,o.status)}:{ok:!0,data:!0}}catch(o){return{ok:!1,status:0,message:o instanceof Error?o.message:"network error"}}}async function My(e,t){const n=`/api/admin/templates/${ir(e)}/custom-translations`;try{const o=await fetch(n,{method:"DELETE",headers:je({"Content-Type":"application/json"}),body:JSON.stringify({ids:t}),credentials:"same-origin"}),r=await o.json().catch(()=>null);return!o.ok||!r?.success?{ok:!1,status:o.status,message:sr(r,o.status)}:{ok:!0,data:{deleted:typeof r?.data?.deleted=="number"?r.data.deleted:t.length}}}catch(o){return{ok:!1,status:0,message:o instanceof Error?o.message:"network error"}}}const Ml=/\$t:(custom\.[A-Za-z0-9_\-]+\.\d+)/g;function Ly(e){const t=new Set,n=o=>{if(typeof o=="string"){if(o.includes("$t:custom.")){let r;for(Ml.lastIndex=0;(r=Ml.exec(o))!==null;)t.add(r[1])}return}if(Array.isArray(o)){for(const r of o)n(r);return}if(o&&typeof o=="object")for(const r of Object.values(o))n(r)};return n(e),t}function sr(e,t){if(e&&typeof e=="object"&&"message"in e){const n=e.message;if(typeof n=="string"&&n.length>0)return n}return`HTTP ${t}`}const Pt=Lt().TranslationEngine,ar=Lt().createLogger,Mi=ar("EditorSpecLoader"),Py=["controls","componentCapabilities","actionRecipes","conditionRecipes","pageSettings","initActionRecipes","errorRecipes","computedRecipes","loadingComponents"],Oy=["actionRecipes","initActionRecipes","errorRecipes","computedRecipes","loadingComponents"];function Ll(e){if(typeof window>"u")return[];const n=window.G7Config?.[e];if(!Array.isArray(n))return[];const o=[];for(const r of n){const s=r?.identifier;typeof s=="string"&&s.length>0&&o.push(s)}return o}async function By(e,t,n,o){const s=`${n}/${e==="template"?"templates":e==="module"?"modules":"plugins"}/${encodeURIComponent(t)}/editor-spec`;try{const a=await o(s);if(!a.ok)return a.status!==404&&Mi.warn(`fetchOneSpec: HTTP ${a.status}`,s),null;const l=await a.json();return!l?.success||!l.data?null:l.data.spec??null}catch(a){return Mi.warn("fetchOneSpec: fetch failed",s,a),null}}function Wy(e){return Oy.includes(e)}function jy(e,t){return e==null||typeof e!="object"?e:{...e,__source:t}}function Hy(e,t,n,o){const r=t[n];if(!r||typeof r!="object")return;const s=e[n]??{};if(o&&Wy(n)){const a={};for(const[l,d]of Object.entries(r))a[l]=jy(d,o);e[n]={...s,...a};return}e[n]={...s,...r}}function Fy(e,t){if(!t.nesting)return;const n=e.nesting??{},o=new Set(n.draggable??[]);for(const r of t.nesting.draggable??[])o.add(r);n.draggable=Array.from(o),n.containers={...n.containers??{},...t.nesting.containers??{}},e.nesting=n}function Ky(e,t){if(!t.componentPalette)return;const n=e.componentPalette??{},o=[...n.groups??[],...t.componentPalette.groups??[]];n.groups=o,n.entries={...n.entries??{},...t.componentPalette.entries??{}},e.componentPalette=n}function Vy(e,t){if(!t.states)return;const n=e.states??{},o=[...n.groups??[],...t.states.groups??[]];n.groups=o,e.states=n}function Uy(e,t){if(!Array.isArray(t.stateLabels)||t.stateLabels.length===0)return;const n=[...e.stateLabels??[],...t.stateLabels],o=new Map;for(const r of n)!r||typeof r.key!="string"||typeof r.scope!="string"||o.set(`${r.scope}:${r.key}`,r);e.stateLabels=Array.from(o.values())}function qy(e,t){if(!t.actionChipCandidates)return;const n=e.actionChipCandidates??{};for(const o of["response","error","payload"]){const r=t.actionChipCandidates[o];!Array.isArray(r)||r.length===0||(n[o]=[...n[o]??[],...r])}e.actionChipCandidates=n}function Gy(e,t){if(!t.sampleData)return;const n=e.sampleData??{};n.byDataSourceId={...n.byDataSourceId??{},...t.sampleData.byDataSourceId??{}},n.byEndpointPattern={...n.byEndpointPattern??{},...t.sampleData.byEndpointPattern??{}},e.sampleData=n}function Pl(e,t,n){for(const o of Py)Hy(e,t,o,n);Fy(e,t),Ky(e,t),Vy(e,t),Uy(e,t),qy(e,t),Gy(e,t),t.version&&(e.version=t.version)}async function Yy(e){const t=e.apiBaseUrl??"/api",n=e.fetcher??(typeof fetch<"u"?fetch:void 0);if(!n)return Mi.warn("loadEditorSpecBundle: fetch unavailable, returning null spec"),{spec:null,sampleGlobalSources:[]};const o=e.activeModuleIdentifiers??Ll("activeModules"),r=e.activePluginIdentifiers??Ll("activePlugins"),s=[...o.map(p=>({kind:"module",id:p})),...r.map(p=>({kind:"plugin",id:p})),{kind:"template",id:e.templateIdentifier}],a=await Promise.all(s.map(p=>By(p.kind,p.id,t,n)));let l=null;const d=[],c={};if(e.coreSeed&&(l={},Pl(l,e.coreSeed,{kind:"core"})),s.forEach((p,f)=>{const y=a[f];if(!y)return;y.sampleGlobal&&typeof y.sampleGlobal=="object"&&d.push({id:p.id,kind:p.kind,sampleGlobal:y.sampleGlobal});const g=y.sampleData?.byDataSourceId;if(g&&typeof g=="object"){const m=p.kind==="template"?"template":`${p.kind}:${p.id}`;c[m]={...c[m]??{},...g}}l===null&&(l={}),Pl(l,y,{kind:p.kind,id:p.id})}),l!==null&&Object.keys(c).length>0){const p=l;p.sampleData={...p.sampleData??{},bySource:c}}return{spec:l,sampleGlobalSources:d}}function Li(e,t){return e?.componentCapabilities?.[t]??null}function Ct(e,t){return e?.controls?.[t]??null}function Xy(e){return e?.initActionRecipes??{}}function Jy(e){return e?.computedRecipes??{}}function Zy(e){return e?.errorRecipes??{}}function Qy(e){const t=e?.loadingComponents;return!t||typeof t!="object"?[]:Object.values(t).filter(n=>n!=null&&typeof n=="object"&&typeof n.name=="string")}function Ry(e,t,n){const o=e?.stateLabels;if(!Array.isArray(o))return null;for(const r of o)if(r?.scope===t&&r?.key===n)return typeof r.label_key=="string"?r.label_key:null;return null}function Pi(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)}function eb(e){return e===null?"null":typeof e=="string"?e.length>40?`${e.slice(0,40)}…`:e:typeof e=="number"||typeof e=="boolean"?String(e):""}function tb(e){const t=e.find(n=>Pi(n));if(Pi(t))return Object.keys(t).slice(0,8)}function nb(e){return Object.keys(e).slice(0,8)}function ob(e){const t=Object.entries(e);return t.length===0?!1:t.some(([,n])=>n===null||typeof n!="object")}function Oi(e,t,n,o,r){if(Array.isArray(e)){r(t,"array",`[${e.length}]`,tb(e));return}if(Pi(e)){if(t&&ob(e)){const s=nb(e);r(t,"object",`{${s.length}}`,s)}if(n>=o)return;for(const[s,a]of Object.entries(e)){const l=t?`${t}.${s}`:s;Oi(a,l,n+1,o,r)}return}r(t,"scalar",eb(e))}function rb(e,t,n){const o=[];let r=e;for(const s of t){if(!r||(r=(Array.isArray(r.children)?r.children:[])[s],!r))break;const l=r.iteration;if(!(!l||l.source===void 0)&&(typeof l.index_var=="string"&&l.index_var.length>0&&o.push({name:l.index_var,kind:"index"}),typeof l.item_var=="string"&&l.item_var.length>0)){const d=typeof l.source=="string"?l.source:"",c=d&&n?.get(Ol(d))||[];o.push({name:l.item_var,kind:"item",itemFields:c})}}return o}function Ol(e){return e.replace(/^\{\{|\}\}$/g,"").trim()}function ib(e){const t=new Map;for(const n of e)n.shape==="array"&&n.itemFields&&n.itemFields.length>0&&t.set(Ol(n.expression),n.itemFields);return t}function Bi(e){const t=e.maxDepth??4,n=[];for(const o of e.dataSources??[])!o||typeof o.id!="string"||o.id.length===0||Oi(o.sample,"",0,t,(r,s,a,l)=>{const d=r?`{{${o.id}.${r}}}`:`{{${o.id}}}`;n.push({expression:d,source:"data_source",sourceId:o.id,path:r,shape:s,groupLabelKey:o.labelKey?.startsWith("$t:")?o.labelKey.slice(3):o.labelKey,preview:a,itemFields:l})});for(const o of e.states??[])!o||typeof o.scope!="string"||Oi(o.tree,"",0,t,(r,s,a,l)=>{if(!r)return;const d=Ry(e.spec,o.scope,r)??void 0;n.push({expression:`{{${o.scope}.${r}}}`,source:o.scope,sourceId:o.scope,path:r,shape:s,labelKey:d?.startsWith("$t:")?d.slice(3):d,preview:a,itemFields:l})});for(const o of e.iterationVars??[])if(!(!o||typeof o.name!="string"||o.name.length===0)){if(o.kind==="index"){n.push({expression:`{{${o.name}}}`,source:"iteration",sourceId:o.name,path:"",shape:"scalar",preview:"0"});continue}for(const r of o.itemFields??[])n.push({expression:`{{${o.name}.${r}}}`,source:"iteration",sourceId:o.name,path:r,shape:"scalar",preview:""})}return n}const sb=["$localized"];function ab(e){const t=e.trim(),n=t.indexOf("(");if(n===-1||!t.endsWith(")"))return{fn:null,inner:t};const o=t.slice(0,n).trim();if(!sb.includes(o))return{fn:null,inner:t};const r=t.slice(n+1,-1);let s=0,a=null;for(let l=0;ln.shape===t)}function Hi(e,t){const n=t.trim().toLowerCase();return n===""?e:e.filter(o=>[o.resolvedLabel??"",o.sourceId,o.path,o.preview].join(" ").toLowerCase().includes(n))}function Bl(e,t,n){const o={response:[{path:"data",labelKey:"layout_editor.action_chip.response_data",shape:"object"},{path:"message",labelKey:"layout_editor.action_chip.response_message",shape:"scalar"}],error:[{path:"status",labelKey:"layout_editor.action_chip.error_status",shape:"scalar"},{path:"message",labelKey:"layout_editor.action_chip.error_message",shape:"scalar"},{path:"errors",labelKey:"layout_editor.action_chip.error_errors",shape:"object"},{path:"data",labelKey:"layout_editor.action_chip.error_data",shape:"object"},{path:"statusText",labelKey:"layout_editor.action_chip.error_status_text",shape:"scalar"}],payload:[{path:"data",labelKey:"layout_editor.action_chip.payload_data",shape:"object"}]},r=e==="response"?"response":e==="error"?"error":"message",s=new Set,a=[];for(const d of o[e])s.has(d.path)||(s.add(d.path),a.push(d));const l=n?.[e];if(Array.isArray(l))for(const d of l)!d||typeof d.path!="string"||typeof d.labelKey!="string"||s.has(d.path)||(s.add(d.path),a.push({path:d.path,labelKey:d.labelKey,shape:d.shape??"scalar"}));return a.map(d=>({expression:`{{${r}.${d.path}}}`,source:"_local",sourceId:r,path:d.path,shape:d.shape,labelKey:d.labelKey,preview:t(d.labelKey)}))}const Fi=/\{\{(?:[^}]|\}(?!\}))*\}\}/g;function En(e){const t=[];if(typeof e!="string"||e.length===0)return t;let n=0,o=0;const r=new RegExp(Fi.source,"g");let s;for(;(s=r.exec(e))!==null;){const a=s.index,l=a+s[0].length;a>n&&t.push({kind:"literal",raw:e.slice(n,a),start:n,end:a}),t.push({kind:"binding",raw:s[0],start:a,end:l,parsed:Kt(s[0]),bindingIndex:o++}),n=l}return nt.kind==="binding")}function Ki(e){return typeof e!="string"?!1:new RegExp(Fi.source).test(e)}function db(e){const t=new Set(e.map(o=>o.trim()));let n=0;for(;t.has(`p${n}`);)n+=1;return`p${n}`}function Wl(e,t,n){const o=typeof e=="string"?e:"",r=Math.max(0,Math.min(t,o.length)),s=o.slice(0,r),a=o.slice(r),l=s.length>0&&!/\s$/.test(s)?" ":"",d=a.length>0&&!/^\s/.test(a)?" ":"";return`${s}${l}{${n}}${d}${a}`}function Vi(e,t){const n=typeof e=="string"?e:"";return In(n).includes(t)?n:n.length===0?`{${t}}`:/\s$/.test(n)?`${n}{${t}}`:`${n} {${t}}`}function jl(e){return lr(e).map(t=>({bindingIndex:t.bindingIndex??0,expression:t.raw,parsed:t.parsed??null,isComplex:(t.parsed??null)===null}))}function dr(e){return typeof e!="string"?"":e.replace(new RegExp(Fi.source,"g")," ").replace(/\s+/g," ").trim()}const cb=/\$t:([a-zA-Z0-9._-]+)/g,Hl="custom.";function Ui(e,t){return typeof e!="string"||e.length===0?typeof e=="string"?e:"":t?En(e).map(n=>n.kind!=="literal"?n.raw:n.raw.replace(cb,(o,r)=>{if(r.startsWith(Hl))return o;const s=t(r);return s&&s!==r?s:o})).join(""):e}const Fn="(?:(?!\\|[a-zA-Z0-9_]+=).)*",ub=new RegExp(`^\\s*\\$t:([a-zA-Z0-9._-]+)((?:\\|[a-zA-Z0-9_]+=${Fn})*)`),pb=new RegExp(`\\|([a-zA-Z0-9_]+)=(${Fn})`,"g");function Vt(e){if(typeof e!="string")return"";const t=Kt(e);if(t)return t.path||t.sourceId;const n=e.trim().replace(/^\{\{\s*/,"").replace(/\s*\}\}$/,"");let o=-1;for(let l=0;l0){const l=n.slice(0,o).trim(),d=Kt(`{{${l}}}`);if(d)return d.path||d.sourceId;const c=l.replace(/\?\./g,".").replace(/\s*\?\?.*$/,"").trim();if(/^[A-Za-z_$][\w$.]*$/.test(c)){const p=c.split(".").filter(Boolean);return p.length>1&&p.slice(1).join(".")||c}}const r=n.replace(/\s+/g," ").trim(),s=r.replace(/\s*(\?\?|\|\|)\s*(\[\s*\]|\{\s*\}|'[^']*'|"[^"]*"|\d+|null|undefined)\s*$/,"").trim(),a=/\.([A-Za-z_$][\w$]*)\s*$/.exec(s);return a?a[1]:r}const Fl=/\$(?:core|module|plugin)_settings:[a-zA-Z0-9._:-]+/g;function Kl(e){return typeof e=="string"&&new RegExp(Fl.source).test(e)}function fb(e){return typeof e!="string"?"":e.replace(/^\$(?:core|module|plugin)_settings:/,"").split(/[.:]/).filter(Boolean).pop()||e}function Vl(e){const t=[];for(const n of En(e)){if(n.kind==="binding"){t.push({kind:"chip",label:Vt(n.raw)});continue}let o=0;const r=n.raw,s=new RegExp(Fl.source,"g");let a;for(;(a=s.exec(r))!==null;)a.index>o&&t.push({kind:"text",label:r.slice(o,a.index)}),t.push({kind:"chip",label:fb(a[0])}),o=a.index+a[0].length;o{const m=`p${o.length}`;return o.push(g),r[m]=Vt(g),`{${m}}`};let a=e,l="";const d=ub.exec(e);if(d&&!d[1].startsWith(Hl)){const g=d[1],m=d[2]??"",h=e.slice(d[0].length),v=new Map,b=new RegExp(pb.source,"g");let x;for(;(x=b.exec(m))!==null;)x[2]&&x[2].includes("{{")&&v.set(x[1],x[2]);const k=t?t(g):"";if(k&&k!==g&&!k.startsWith("$t:"))l=k.replace(/\{\{?([a-zA-Z0-9_]+)\}?\}/g,(w,_)=>{const S=v.get(_);return S?s(S):w});else return n;a=h}const c=Ui(a,t),p=En(c).map(g=>g.kind==="binding"?s(g.raw):g.raw).join(""),f=`${l}${p}`;return/\$t:[a-zA-Z0-9._-]+/.test(f)||o.length===0?n:{chipValue:f,bindings:o,paramLabels:r,keyifiable:!0}}function Ul(e,t){return t.bindings.length?e+t.bindings.map((n,o)=>`|p${o}=${n}`).join(""):e}function ql(e,t){const n=lr(t).map(r=>r.raw);if(n.length===0)return e;const o=n.map((r,s)=>`|p${s}=${r}`).join("");return`${e}${o}`}function Gl(e){if(typeof e!="string")return"";const t=En(e);let n=0;return t.map(o=>o.kind==="binding"?`{p${n++}}`:o.raw).join("")}const Yl=new RegExp(`^\\s*\\$t:([a-zA-Z0-9._-]+)((?:\\|[a-zA-Z0-9_]+=${Fn})+)\\s*$`),hb=new RegExp(`\\|([a-zA-Z0-9_]+)=(${Fn})`,"g");function mt(e){if(typeof e!="string")return null;const t=Yl.exec(e);if(!t)return null;const n=t[1],o=t[2],r=[],s=new RegExp(hb.source,"g");let a;for(;(a=s.exec(o))!==null;){const l=a[1],d=a[2];r.push({name:l,expression:d,parsed:Kt(d)})}return{key:n,params:r}}function Xl(e){return typeof e=="string"&&Yl.test(e)}function In(e){if(typeof e!="string")return[];const t=[],n=/\{\{?(p\d+)\}?\}/g;let o;for(;(o=n.exec(e))!==null;)t.push(o[1]);return t.sort()}function yb(e,t,n){if(typeof e!="string")return e;const o=new RegExp(`(\\|${t}=)${Fn}`);return o.test(e)?e.replace(o,`$1${n}`):e}function ho(e,t){if(typeof e!="string")return e;const n=new RegExp(`\\|${t}=${Fn}`);return e.replace(n,"")}function Jl(e,t){return typeof e!="string"?e:e.replace(new RegExp(`\\s*\\{\\{?${t}\\}?\\}\\s*`,"g")," ").replace(/\s+/g," ").trim()}function bb(e,t){const n=s=>lr(typeof s=="string"?s:"").map(a=>a.raw.replace(/\s+/g,"")).sort(),o=n(e),r=n(t);return o.length!==r.length?!1:o.every((s,a)=>s===r[a])}function Zl(e){return e.__source?.kind==="base"}function Ql(e){return e.__source?.kind==="partial"}function mb(e){return e.__source?.kind==="route"}function cr(e,t){return e.__source?.kind!=="extension"?!1:t==null?!0:e.__source.extensionId!==t}function Rl(e){return e.type==="extension_point"}function Gi(e=[]){for(const t of e)if(t.iteration&&t.iteration.source!==void 0)return!0;return!1}function vb(e,t=[]){for(const n of t)if(n.iteration&&n.iteration.source!==void 0)return!0;return e.iteration&&e.iteration.source!==void 0?!0:ed(e)}function ed(e){if(typeof e.text=="string"&&td(e.text)&&!_b(e.text))return!0;if(e.props&&typeof e.props=="object"){for(const t of Object.values(e.props))if(typeof t=="string"&&td(t))return!0}return!1}function td(e){return/\{\{[\s\S]*?\}\}/.test(e)}function _b(e){const t=mt(e);return t!==null&&t.key.startsWith("custom.")}function xb(e,t){return e??t??null}function Yi(e,t,n){if(Rl(e))return!0;switch(t){case"route":return Zl(e)||Ql(e)||cr(e);case"base":return cr(e);case"modal":return cr(e);case"extension":return cr(e,n)?!0:Zl(e)||mb(e)||Ql(e);default:return!1}}function et(e){return typeof e=="object"&&e!==null&&typeof e.responsive=="string"}function ur(e,t){return et(e)?et(t)&&e.responsive===t.responsive:!et(t)&&e===t}function at(e){const t=[];let n=!1;for(const o of e){if(et(o)){t.push("responsive",o.responsive),n=!0;continue}n?t.push("children",String(o)):(t.push(String(o)),n=!0)}return t.join(".")}function Qt(e){return Array.isArray(e.children)?e.children:[]}function pr(e,t){const n=e.responsive?.[t];return n&&Array.isArray(n.children)?n.children:[]}function We(e,t){if(!e)return null;if(t.length===0)return e;let n=e,o=Qt(e);for(const r of t){if(et(r)){o=pr(n,r.responsive);continue}if(r<0||r>=o.length||(n=o[r],!n))return null;o=Qt(n)}return n}function wb(e,t,n){if(!e||t.length===0)return t;let o=e,r=Qt(e),s;for(let a=0;a=2?t[t.length-2]:void 0;if(r!==void 0&&et(r)){const a=We(e,t.slice(0,-2));return a?{owner:a,children:pr(a,r.responsive),branchKey:r.responsive,index:o}:null}const s=We(e,t.slice(0,-1));return s?{owner:s,children:Qt(s),index:o}:null}function Xi(e,t){if(e.branchKey!==void 0){const n={...e.owner.responsive??{}};n[e.branchKey]={...n[e.branchKey]??{},children:t},e.owner.responsive=n}else e.owner.children=t}function Rt(e){const t={...e};t.props&&typeof t.props=="object"&&(t.props={...t.props});const n=Qt(e);if(n.length>0&&(t.children=n.map(Rt)),e.responsive&&typeof e.responsive=="object"){const o={};for(const r of Object.keys(e.responsive)){const s=e.responsive[r];if(!s||typeof s!="object"){o[r]=s;continue}const a={...s};Array.isArray(s.children)&&(a.children=s.children.map(Rt)),a.props&&typeof a.props=="object"&&(a.props={...a.props}),o[r]=a}t.responsive=o}return t}function Ji(e,t,n,o){const r=Rt(e),s=t.length>0?t[t.length-1]:void 0;if(s!==void 0&&et(s)){const p=We(r,t.slice(0,-1));if(!p)return r;const f=pr(p,s.responsive),y=Math.max(0,Math.min(n,f.length)),g=[...f];return g.splice(y,0,o),Xi({owner:p,branchKey:s.responsive},g),r}const a=We(r,t);if(!a)return r;const l=Qt(a),d=Math.max(0,Math.min(n,l.length)),c=[...l];return c.splice(d,0,o),a.children=c,r}function Zi(e,t){if(t.length===0)return e;const n=Rt(e),o=nd(n,t);if(!o||o.index<0||o.index>=o.children.length)return n;const r=[...o.children];return r.splice(o.index,1),Xi(o,r),n}function kb(e,t,n,o){if(t.length===0||!n||et(t[t.length-1]))return e;const r=Rt(e),s=We(r,t);if(!s)return r;const a=s.responsive&&typeof s.responsive=="object"?s.responsive[n]:void 0;if(a&&Array.isArray(a.children))return r;const l=o&&s.responsive&&typeof s.responsive=="object"?s.responsive[o]:void 0,c=(l&&Array.isArray(l.children)?l.children:Qt(s)).map(Rt),p={...s.responsive??{}};return p[n]={...p[n]??{},children:c},s.responsive=p,r}function Sb(e,t,n){if(t.length===0||!n||et(t[t.length-1]))return e;const o=Rt(e),r=We(o,t);if(!r||!r.responsive||typeof r.responsive!="object")return o;const s=r.responsive[n];if(!s||!Array.isArray(s.children))return o;const a={...r.responsive},l={...a[n]};return delete l.children,Object.keys(l).length===0?delete a[n]:a[n]=l,r.responsive=Object.keys(a).length>0?a:void 0,o}function Cb(e,t,n,o){if(t.length===0)return e;const r=We(e,t);if(!r||$b(t,n))return e;const s=Rt(r),a=Zi(e,t),l=od(n,t);let d=o;const c=t.slice(0,-1);if(rd(c,n)){const p=t[t.length-1];(typeof p=="number"?p:0)o){const s=[...e];return s[n]=r-1,s}return e}function rd(e,t){if(e.length!==t.length)return!1;for(let n=0;ne.length||rd(e,t)}function Qi(e){const t={...e};if(t.props&&typeof t.props=="object"&&(t.props={...t.props}),typeof t.id=="string"&&t.id.length>0){const o=typeof t.name=="string"?t.name:"node";t.id=`${o}_${Eb()}`}const n=Qt(e);return n.length>0&&(t.children=n.map(Qi)),t}function Ot(e,t,n){if(t.length===0)return n(e);const o=Rt(e),r=We(o,t);if(!r)return o;const s=n(r),a=nd(o,t);if(!a||a.index<0||a.index>=a.children.length)return o;const l=[...a.children];return l[a.index]=s,Xi(a,l),o}function Eb(){return Math.floor(Math.random()*2821109907456).toString(36).padStart(8,"0")}function Kn(e){if(!Array.isArray(e)||e.length===0)return[];const t=[];for(const n of e){const o=Ib(n);o.length>0&&t.push(...o)}return t}function Ib(e){if(!e||typeof e!="object")return[];const t=e.__source,n=e._fromBase===!0,o=t?.kind;if(o==="extension"||o==="partial")return[];if(o==="base"||n){const s=Array.isArray(e.children)?e.children:[];return Kn(s)}const r={...e};return delete r.__source,delete r._fromBase,delete r.__injectedProps,Array.isArray(r.children)&&(r.children=Kn(r.children)),[r]}function yo(e){if(!e||typeof e!="object")return e;const t=e.__editor,n=t&&typeof t=="object"&&!Array.isArray(t)?t.original:void 0,o=n&&typeof n=="object"&&!Array.isArray(n)?n:void 0,r=Array.isArray(e.components)?e.components:[],s=r.length>0,a=Kn(r),l=o?{...o}:{...e};if(typeof l.extends=="string"&&l.extends.length>0){delete l.components;const c=l.slots&&typeof l.slots=="object"&&!Array.isArray(l.slots)?l.slots:{},p=Object.keys(c);if(!s&&p.length>0){const f={};for(const[y,g]of Object.entries(c))f[y]=Array.isArray(g)?Kn(g):g;l.slots=f}else if(p.length===1)l.slots={[p[0]]:a};else if(p.length===0)l.slots={content:a};else{const f={};for(const[y,g]of Object.entries(c))f[y]=Array.isArray(g)?Kn(g):g;l.slots=f}}else l.components=a,delete l.slots;return delete l.lock_version,delete l.__editor,l}const id=/^\s*\$t:([a-zA-Z0-9._-]+)\s*$/,fr="custom.",Ab=/\{\{.*?\}\}/;function Tb(e){return!!(!e||e.iteration&&typeof e.iteration=="object")}function sd(e,t){const n=e?.text,o=typeof n=="string"?n:"";if(Tb(e)||typeof n!="string")return{sourceState:"plain_text",editable:!1,customKey:null,displayValue:o};const r=mt(o);if(r&&r.key.startsWith(fr)){const a=t?t(r.key):r.key;return{sourceState:"custom_key",editable:!0,customKey:r.key,displayValue:a,isParamKey:!0}}const s=id.exec(o);if(s){const a=s[1];if(a.startsWith(fr)){const d=t?t(a):a;return{sourceState:"custom_key",editable:!0,customKey:a,displayValue:d}}const l=t?t(a):"";return{sourceState:"plain_text",editable:!0,customKey:null,displayValue:l&&l!==a?l:""}}if(Ab.test(o)){const a=dr(o),l=id.exec(a);if(l&&l[1].startsWith(fr)){const f=l[1],y=t?t(f):f;return{sourceState:"custom_key",editable:!0,customKey:f,displayValue:y}}const d=Ui(o,t),c=dr(d),p=/\$t:[a-zA-Z0-9._-]+/.test(c);if(c.length>0){const f=qi(o,t);return{sourceState:"plain_with_binding",editable:!0,customKey:null,displayValue:p?"":c,chipValue:f.keyifiable?f.chipValue:void 0,chipParamLabels:f.keyifiable?f.paramLabels:void 0}}return{sourceState:"binding_expression",editable:!1,customKey:null,displayValue:o}}return{sourceState:"plain_text",editable:!0,customKey:null,displayValue:o}}function Db(){const{state:e}=Je(),t=Ft(),n=e.templateIdentifier,o=e.locale,r=e.selectedRoute?.layoutName??null,s=u.useCallback(p=>{try{const y=Pt.getInstance().translate(p,{templateId:n,locale:o});return y&&y!==p?y:""}catch{return""}},[n,o]),a=u.useCallback(p=>sd(p,s),[s]),l=u.useCallback(p=>{Et({op:"inline_edit_cancel",componentPath:p?at(p):null,timestamp:Date.now()})},[]),d=u.useCallback(async(p,f)=>{if(!t)return{kind:"error",message:"no document context"};const g={children:t.document?.raw?.components??[]},m=We(g,p),h=sd(m,s);if(!h.editable)return Et({op:"inline_edit_blocked_binding",sourceState:h.sourceState,componentPath:at(p),timestamp:Date.now()}),{kind:"blocked"};if(h.customKey){if(f===h.displayValue)return{kind:"noop",translationKey:h.customKey};bo(n,o,h.customKey,f);const E=await gr(n,h.customKey,o,f);return E.kind==="error"?{kind:"error",message:E.message}:(await en(n,o),Et({op:"inline_edit_update_value",sourceState:"custom_key",componentPath:at(p),translationKey:h.customKey,toLocale:o,valueLength:f.length,valueHash:Ti(f),timestamp:Date.now()}),{kind:"updated",translationKey:h.customKey})}const v=typeof m?.text=="string"?m.text:"",b=h.sourceState==="plain_with_binding",x=Ui(v,s),k=b?Gl(x):f,w=E=>b?ql(E,v):E;t.patchLayout(E=>Ot({children:E},p,$=>({...$,text:f})).children??[]);const _=await Vn(n,r,o,k);if(_.kind==="error"||!_.resource)return{kind:"error",message:_.message};const S=_.resource.translation_key;return bo(n,o,S,k),t.patchLayout(E=>Ot({children:E},p,$=>({...$,text:w(`$t:${S}`)})).children??[]),await en(n,o),Et({op:"inline_edit_create_key",sourceState:h.sourceState,componentPath:at(p),translationKey:S,toLocale:o,valueLength:f.length,valueHash:Ti(f),timestamp:Date.now()}),{kind:"created",translationKey:S,id:_.resource.id}},[t,n,o,r,s]),c=u.useCallback(async(p,f)=>{if(!t)return{kind:"error",message:"no document context"};const g={children:t.document?.raw?.components??[]},m=We(g,p),h=typeof m?.text=="string"?m.text:"",v=mt(h);if(v&&v.key.startsWith(fr))return hr(n,v.key,o,f),bo(n,o,v.key,f),t.markDirty?.(),{kind:"updated",translationKey:v.key};const b=qi(h,s),x=dr(f.replace(/\{\{?p\d+\}?\}/g," ")).replace(/\s+/g," ").trim(),k=await Vn(n,r,o,x);if(k.kind==="error"||!k.resource)return{kind:"error",message:k.message};const w=k.resource.translation_key,_=In(f),S=k.resource.values??{},E={[o]:f};for(const C of Object.keys(S)){if(C===o)continue;let N=S[C]??"";for(const $ of _)N=Vi(N,$);E[C]=N}return mo(n,w,E),bo(n,o,w,f),t.patchLayout(C=>Ot({children:C},p,A=>({...A,text:Ul(`$t:${w}`,b)})).children??[]),t.markDirty?.(),await en(n,o),bo(n,o,w,f),Et({op:"inline_edit_create_key",sourceState:"plain_with_binding",componentPath:at(p),translationKey:w,toLocale:o,valueLength:f.length,valueHash:Ti(f),timestamp:Date.now()}),{kind:"created",translationKey:w}},[t,n,o,r]);return{classify:a,commit:d,trackCancel:l,keyifyChipValue:c}}async function Vn(e,t,n,o){const r=`/api/admin/templates/${encodeURIComponent(e)}/custom-translations`;try{const s=await fetch(r,{method:"POST",credentials:"same-origin",headers:je({"Content-Type":"application/json"}),body:JSON.stringify({layout_name:t,locale:n,value:o})}),a=await s.json().catch(()=>null);if(!s.ok)return{kind:"error",message:a?.message??`HTTP ${s.status}`};const l=a?.data;return!l||typeof l.translation_key!="string"?{kind:"error",message:"invalid response"}:{kind:"ok",resource:l}}catch(s){return{kind:"error",message:s instanceof Error?s.message:"network error"}}}async function gr(e,t,n,o){const r=await dn(e,t);if(!r)return{kind:"error",message:"custom key not found"};const s=`/api/admin/templates/${encodeURIComponent(e)}/custom-translations/${r.id}`,a={...r.values??{},[n]:o};try{const l=await fetch(s,{method:"PUT",credentials:"same-origin",headers:je({"Content-Type":"application/json"}),body:JSON.stringify({values:a,expected_lock_version:r.lock_version})}),d=await l.json().catch(()=>null);return l.status===409?{kind:"error",message:"concurrent_modification"}:l.ok?{kind:"ok"}:{kind:"error",message:d?.message??`HTTP ${l.status}`}}catch(l){return{kind:"error",message:l instanceof Error?l.message:"network error"}}}async function dn(e,t){const n=`/api/admin/templates/${encodeURIComponent(e)}/custom-translations`;try{const o=await fetch(n,{method:"GET",credentials:"same-origin",headers:je()});if(!o.ok)return null;const s=(await o.json().catch(()=>null))?.data;return Array.isArray(s)?s.find(a=>a.translation_key===t)??null:null}catch{return null}}function bo(e,t,n,o){try{Pt.getInstance().setTranslationValue(e,t,n,o)}catch{}if(typeof window<"u")try{window.dispatchEvent(new CustomEvent(kt,{detail:{templateIdentifier:e,locale:t}}))}catch{}}const kt="g7le:translations-refreshed";async function en(e,t){if(typeof window>"u")return;const n=t??window.__g7EditorContentLocale;if(n){try{const o=Pt.getInstance();try{const r=await fetch(`/api/templates/${encodeURIComponent(e)}/config.json`,{headers:{Accept:"application/json"},credentials:"same-origin"}).then(a=>a.ok?a.json():null),s=r?.data?.cache_version??r?.cache_version;if(typeof s=="number"&&s>0){o.setCacheVersion(s);const a=window;a.G7Config&&(a.G7Config.cache_version=s)}}catch{}await o.loadTranslations(e,n,"/api",!0)}catch{}try{window.dispatchEvent(new CustomEvent(kt,{detail:{templateIdentifier:e,locale:n}}))}catch{}}}const tn=new Map;function hr(e,t,n,o){const r=tn.get(t)??{};r[n]=o,tn.set(t,r);try{Pt.getInstance().setTranslationValue(e,n,t,o)}catch{}if(typeof window<"u")try{window.dispatchEvent(new CustomEvent(kt,{detail:{templateIdentifier:e,locale:n}}))}catch{}}function mo(e,t,n){for(const[o,r]of Object.entries(n))hr(e,t,o,r)}function It(e,t){return tn.get(e)?.[t]}function Nb(e){const t=tn.get(e);return t?{...t}:void 0}function zb(){return tn.size>0}function ad(e,t){for(const[n,o]of tn.entries())for(const[r,s]of Object.entries(o))try{e.setTranslationValue(t,r,n,s)}catch{}}async function Mb(e){if(tn.size===0)return{ok:0,failed:[]};const t=Array.from(tn.entries());let n=0;const o=[];for(const[r,s]of t)try{const a=await dn(e,r);if(!a){o.push(r);continue}const l={...a.values??{},...s},d=`/api/admin/templates/${encodeURIComponent(e)}/custom-translations/${a.id}`;if(!(await fetch(d,{method:"PUT",credentials:"same-origin",headers:je({"Content-Type":"application/json"}),body:JSON.stringify({values:l,expected_lock_version:a.lock_version})})).ok){o.push(r);continue}n+=1}catch{o.push(r)}for(const[r]of t)o.includes(r)||tn.delete(r);try{await en(e)}catch{}return{ok:n,failed:o}}function Un(e,t){const n=`layout_editor.locale.${e}`,o=t(n);return o&&o!==n?o:e.toUpperCase()}function ld(){if(typeof window>"u")return[];const t=window.G7Config?.appConfig?.supportedLocales;return Array.isArray(t)?t.filter(n=>typeof n=="string"&&n.length>0):[]}function Lb({locales:e}={}){const{t}=tt(),{state:n,dispatch:o}=Je();u.useEffect(()=>{window.__g7EditorContentLocale=n.locale},[n.locale]);const r=e??ld(),s=r.includes(n.locale)?r:[n.locale,...r];if(s.length<=1)return i.jsxs("span",{className:"g7le-locale-switcher g7le-locale-switcher--single","data-testid":"g7le-locale-switcher-single",style:{fontSize:11,color:"#475569",padding:"0 4px"},children:[t("layout_editor.chrome.toolbar.locale_switcher_label"),": ",Un(n.locale,t)]});const a=l=>{if(l===n.locale)return;const d=n.locale;o({type:"SET_LOCALE",locale:l}),Et({op:"locale_switch",fromLocale:d,toLocale:l,timestamp:Date.now()})};return i.jsxs("div",{className:"g7le-locale-switcher","data-testid":"g7le-locale-switcher",role:"group","aria-label":t("layout_editor.chrome.toolbar.locale_switcher_label"),style:{display:"inline-flex",alignItems:"center",gap:4},children:[i.jsx("span",{style:{fontSize:11,color:"#475569"},children:t("layout_editor.chrome.toolbar.locale_switcher_label")}),i.jsx("div",{style:{display:"inline-flex",gap:2},children:s.map(l=>{const d=l===n.locale;return i.jsx("button",{type:"button","data-testid":`g7le-locale-${l}`,"aria-pressed":d,onClick:()=>a(l),style:{padding:"2px 8px",fontSize:12,cursor:"pointer",background:d?"#0f172a":"#f8fafc",color:d?"#fff":"#0f172a",border:"1px solid #cbd5e1",borderRadius:4},children:Un(l,t)},l)})})]})}const Pb=/\{\{?(p\d+)\}?\}/g;function Ob(e){const t=[];if(typeof e!="string"||e.length===0)return t;const n=new RegExp(Pb.source,"g");let o=0,r;for(;(r=n.exec(e))!==null;){const s=r.index,a=s+r[0].length;s>o&&t.push({kind:"text",raw:e.slice(o,s),start:o,end:s}),t.push({kind:"chip",raw:r[0],paramName:r[1],start:s,end:a}),o=a}return o{n.push({kind:"text",text:a}),r=!0};for(const a of t)a.kind==="text"?(o=a.raw,s(o),o=""):(r||s(""),n.push({kind:"chip",paramName:a.paramName}),r=!1);return r||s(""),n}function Wb(e,t,n,o){const r=[];return e.forEach((s,a)=>{if(s.kind==="chip"){if(s.paramName===t)return;r.push(`{${s.paramName}}`)}else{const l=s.text??"";if(a===n){const d=Math.max(0,Math.min(o,l.length));r.push(l.slice(0,d),`{${t}}`,l.slice(d))}else r.push(l)}}),r.join("")}function yr({value:e,onChange:t,t:n,paramLabels:o,onRequestInsert:r,onRemoveChip:s,testIdSuffix:a="pc",disabled:l=!1,hideInsertButton:d=!1,caretRef:c}){const p=u.useMemo(()=>Bb(e),[e]),f=u.useRef(null),[y,g]=u.useState(null),[m,h]=u.useState(null),[v,b]=u.useState(null),x=u.useMemo(()=>p.map(B=>B.kind==="chip"?`c:${B.paramName}`:"t").join("|"),[p]),k=u.useRef(null),w=u.useRef([]),_=u.useRef(!1);u.useLayoutEffect(()=>{const B=_.current;_.current=!1,!(!B&&k.current===x)&&(k.current=x,p.forEach((T,H)=>{if(T.kind!=="text")return;const L=w.current[H];L&&L.textContent!==(T.text??"")&&(L.textContent=T.text??"")}))},[x,p,e]);const S=()=>{const B=f.current;if(!B)return e;const T=[];for(const H of Array.from(B.querySelectorAll("[data-seg]")))H.dataset.seg==="chip"?T.push(`{${H.dataset.param??""}}`):T.push(H.textContent??"");return T.join("")},E=()=>{l||t(S())},C=u.useRef(null),N=u.useRef(!1),$=(B,T)=>{if(!l){B.preventDefault(),B.stopPropagation(),C.current={param:T,pointerId:B.pointerId},N.current=!1,g(T);try{B.currentTarget.setPointerCapture(B.pointerId)}catch{}}},A=B=>{const T=C.current;if(!T||B.pointerId!==T.pointerId)return;N.current=!0;const H=O(B.clientX,B.clientY);H&&(h({slotIdx:H.slotIdx,offset:H.offset}),b(Hb(f.current,H.slotIdx,H.offset)))},z=(B,T)=>{if(B.stopPropagation(),N.current){N.current=!1;return}s?.(T)},D=B=>{const T=C.current;if(!T||B.pointerId!==T.pointerId)return;try{B.currentTarget.releasePointerCapture(B.pointerId)}catch{}C.current=null,N.current&&typeof setTimeout=="function"&&setTimeout(()=>{N.current=!1},0);const H=O(B.clientX,B.clientY);if(g(null),h(null),b(null),!H)return;const L=f.current;if(!L)return;const P=Array.from(L.querySelectorAll("[data-seg]")).map(G=>G.dataset.seg==="chip"?{kind:"chip",paramName:G.dataset.param??""}:{kind:"text",text:G.textContent??""}),F=Wb(P,T.param,H.slotIdx,H.offset);F!==e&&(_.current=!0,t(F))},O=(B,T)=>{const H=f.current;if(!H)return null;const L=Array.from(H.querySelectorAll("[data-seg]")),I=H.ownerDocument,P=I.elementFromPoint(B,T);let F=P?.closest('[data-seg="text"]');if(F)return{slotIdx:L.indexOf(F),offset:jb(I,F,B,T)};const G=P?.closest('[data-seg="chip"]');if(G){const Y=L.indexOf(G),Z=G.getBoundingClientRect();if(B=0){const se=L[Y-1];return{slotIdx:Y-1,offset:(se.textContent??"").length}}if(Y+1({s:Y,i:Z})).filter(Y=>Y.s.dataset.seg==="text").pop();return V?{slotIdx:V.i,offset:(V.s.textContent??"").length}:null},M=()=>{l||!r||r(dd(f.current,e.length))};return u.useEffect(()=>{if(c)return c.current=()=>dd(f.current,e.length),()=>{c.current=null}},[c,e.length]),i.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:4},children:[i.jsxs("div",{ref:f,"data-testid":`g7le-chip-input-${a}`,className:"g7le-chip-input",style:Fb,children:[p.map((B,T)=>B.kind==="chip"?i.jsxs("span",{"data-seg":"chip","data-param":B.paramName,"data-testid":`g7le-chip-${a}-${B.paramName}`,contentEditable:!1,onPointerDown:H=>$(H,B.paramName??""),onPointerMove:A,onPointerUp:D,title:n("layout_editor.translation.placeholder_locked"),style:{...qb,...y===B.paramName?Gb:null,touchAction:"none"},children:[i.jsx("span",{"aria-hidden":"true",style:Yb,children:"⠿"}),i.jsx("span",{"aria-hidden":"true",style:{marginRight:3},children:"🔗"}),i.jsx("span",{style:Xb,children:o?.[B.paramName??""]??B.paramName}),s&&!l&&i.jsx("button",{type:"button","data-testid":`g7le-chip-remove-${a}-${B.paramName}`,"aria-label":n("layout_editor.inline_binding.clear"),title:n("layout_editor.inline_binding.clear"),onPointerDown:H=>{H.stopPropagation(),H.preventDefault()},onClick:H=>z(H,B.paramName??""),style:Jb,children:"✕"})]},`chip-${B.paramName}`):i.jsx("span",{ref:H=>{w.current[T]=H},"data-seg":"text",contentEditable:!l,suppressContentEditableWarning:!0,onInput:E,"data-testid":`g7le-chip-text-${a}-${T}`,style:{...Kb,...y?Vb:null,...m?.slotIdx===T?Ub:null}},`text-slot-${T}`)),v&&i.jsx("span",{"aria-hidden":"true","data-testid":`g7le-chip-caret-${a}`,style:{position:"absolute",left:v.left,top:v.top,width:2,height:v.height,background:"#2563eb",borderRadius:1,pointerEvents:"none",boxShadow:"0 0 0 1px rgba(37,99,235,0.3)"}})]}),i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6},children:[r&&!d&&i.jsxs("button",{type:"button","data-testid":`g7le-chip-insert-${a}`,onClick:M,disabled:l,style:Zb,children:["+ ",n("layout_editor.inline_binding.insert_data")]}),i.jsx("span",{style:Qb,children:n("layout_editor.translation.placeholder_locked")})]})]})}function jb(e,t,n,o){const r=(t.textContent??"").length;try{const a=e;let l=null,d=0;if(typeof a.caretRangeFromPoint=="function"){const c=a.caretRangeFromPoint(n,o);c&&(l=c.startContainer,d=c.startOffset)}else if(typeof a.caretPositionFromPoint=="function"){const c=a.caretPositionFromPoint(n,o);c&&(l=c.offsetNode,d=c.offset)}if(l&&t.contains(l))return Math.max(0,Math.min(d,r))}catch{}const s=t.getBoundingClientRect();return n>s.left+s.width/2?r:0}function Hb(e,t,n){if(!e)return null;const r=Array.from(e.querySelectorAll("[data-seg]"))[t];if(!r)return null;const s=e.getBoundingClientRect();try{const d=r.firstChild,c=e.ownerDocument;if(d&&d.nodeType===Node.TEXT_NODE&&(d.textContent??"").length>0){const p=c.createRange(),f=Math.max(0,Math.min(n,(d.textContent??"").length));p.setStart(d,f),p.setEnd(d,f);const y=p.getClientRects(),g=y.length>0?y[0]:p.getBoundingClientRect();if(g&&(g.height>0||g.width>=0))return{left:g.left-s.left,top:g.top-s.top,height:g.height||r.getBoundingClientRect().height}}}catch{}const a=r.getBoundingClientRect();return{left:(n>0?a.right:a.left)-s.left,top:a.top-s.top,height:a.height||16}}function dd(e,t){if(!e)return t;try{const n=e.ownerDocument.defaultView?.getSelection();if(!n||n.rangeCount===0)return t;const o=n.getRangeAt(0);let r=o.startContainer,s=null;for(;r&&r!==e;){if(r instanceof HTMLElement&&r.dataset.seg==="text"){s=r;break}r=r.parentNode}if(!s||!e.contains(s))return t;const a=Array.from(e.querySelectorAll("[data-seg]"));let l=0;for(const d of a){if(d===s)break;l+=d.dataset.seg==="chip"?`{${d.dataset.param??""}}`.length:(d.textContent??"").length}return l+o.startOffset}catch{return t}}const Fb={position:"relative",display:"flex",flexWrap:"wrap",alignItems:"center",gap:2,minHeight:30,padding:"4px 8px",border:"1px solid #cbd5e1",borderRadius:4,background:"#fff",fontSize:13,lineHeight:1.6},Kb={outline:"none",minWidth:8,minHeight:"1.2em",whiteSpace:"pre-wrap",color:"#0f172a",display:"inline-block"},Vb={boxShadow:"inset 0 -2px 0 #93c5fd",background:"#eff6ff",borderRadius:2},Ub={background:"#dbeafe",boxShadow:"inset 0 -2px 0 #2563eb"},qb={display:"inline-flex",alignItems:"center",gap:1,background:"linear-gradient(180deg,#eff6ff,#dbeafe)",color:"#1d4ed8",border:"1px dashed #60a5fa",borderRadius:9999,padding:"1px 8px 1px 4px",margin:"0 1px",fontSize:12,fontWeight:600,cursor:"grab",userSelect:"none",whiteSpace:"nowrap",boxShadow:"0 1px 2px rgba(37,99,235,0.18)",verticalAlign:"middle"},Gb={opacity:.5,borderStyle:"solid",cursor:"grabbing"},Yb={color:"#2563eb",opacity:.7,fontSize:11,marginRight:1,cursor:"grab"},Xb={textDecoration:"none"},Jb={marginLeft:4,width:14,height:14,lineHeight:"12px",padding:0,border:"none",borderRadius:9999,background:"rgba(29,78,216,0.12)",color:"#1d4ed8",fontSize:9,fontWeight:700,cursor:"pointer",display:"inline-flex",alignItems:"center",justifyContent:"center",flex:"0 0 auto"},Zb={fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#334155",padding:"3px 10px",cursor:"pointer"},Qb={fontSize:10,color:"#94a3b8"};function cd(e){e&&(e.style.height="auto",e.style.height=`${Math.max(e.scrollHeight,28)}px`)}function Rb(e){cd(e)}function em(e){const t=new Set;for(const n of e)for(const o of Object.keys(n.values??{}))t.add(o);return Array.from(t).sort()}function tm({templateIdentifier:e,layoutName:t,t:n,referencedKeys:o,onClose:r}){const[s,a]=u.useState([]),[l,d]=u.useState("idle"),[c,p]=u.useState(null),[f,y]=u.useState("all"),[g,m]=u.useState(new Set),[h,v]=u.useState({}),[b,x]=u.useState(null),[k,w]=u.useState({}),_=u.useCallback(async()=>{d("loading"),p(null);const T=await Dy(e,t);T.ok?(a(T.data),v({}),m(new Set),w({}),d("idle")):(p(T.message),d("error"))},[e,t]);u.useEffect(()=>{_()},[_]);const S=u.useMemo(()=>em(s),[s]),E=u.useMemo(()=>s.filter(T=>f==="all"?!0:T.status===f).slice().sort((T,H)=>{const L=T.updated_at?Date.parse(T.updated_at):0,I=H.updated_at?Date.parse(H.updated_at):0;return I!==L?I-L:H.id-T.id}),[s,f]),C=u.useMemo(()=>s.filter(T=>T.status==="orphaned"),[s]),N=u.useCallback(T=>{const H={...T.values??{}};for(const L of Object.keys(H)){const I=It(T.translation_key,L);I!==void 0&&(H[L]=I)}return H},[]),$=u.useCallback(T=>h[T.id]??N(T),[h,N]),A=u.useCallback((T,H,L)=>{v(I=>{const P=I[T]??s.find(F=>F.id===T)?.values??{};return{...I,[T]:{...P,[H]:L}}})},[s]),z=u.useCallback(async T=>{x(T.id),w(I=>({...I,[T.id]:""}));const H=$(T),L=await Ny(e,T.id,H,T.lock_version);if(L.ok)a(I=>I.map(P=>P.id===T.id?L.data:P)),v(I=>{const P={...I};return delete P[T.id],P});else{const I=L.status===409?n("layout_editor.translation_manager.conflict"):L.message;w(P=>({...P,[T.id]:I}))}x(null)},[e,$,n]),D=u.useCallback(T=>typeof window<"u"&&typeof window.confirm=="function"?window.confirm(n("layout_editor.translation_manager.delete_confirm",{count:T})):!0,[n]),O=u.useCallback(async T=>{if(!D(1))return;const H=await zy(e,T.id);H.ok?(a(L=>L.filter(I=>I.id!==T.id)),m(L=>{const I=new Set(L);return I.delete(T.id),I})):p(H.message)},[e,D]),M=u.useCallback(async T=>{if(T.length===0||!D(T.length))return;const H=await My(e,T);if(H.ok){const L=new Set(T);a(I=>I.filter(P=>!L.has(P.id))),m(new Set)}else p(H.message)},[e,D]),B=u.useCallback(T=>{m(H=>{const L=new Set(H);return L.has(T)?L.delete(T):L.add(T),L})},[]);return i.jsxs("div",{className:"g7le-translation-manager","data-testid":"g7le-translation-manager",style:nm,children:[i.jsxs("div",{"data-modal-drag-handle":!0,style:om,children:[i.jsx("span",{style:{fontSize:14,fontWeight:700},"data-testid":"g7le-translation-manager-title",children:n("layout_editor.translation_manager.title")}),i.jsx("button",{type:"button","aria-label":"close",onClick:r,style:rm,"data-testid":"g7le-translation-manager-close",children:"✕"})]}),i.jsxs("div",{style:im,children:[i.jsx("div",{style:{display:"inline-flex",gap:4},role:"tablist","aria-label":n("layout_editor.translation_manager.filter_label"),children:["all","active","orphaned"].map(T=>i.jsx("button",{type:"button",role:"tab","aria-selected":f===T,"data-active":f===T?"true":"false",onClick:()=>y(T),style:f===T?sm:ud,"data-testid":`g7le-translation-filter-${T}`,children:n(`layout_editor.translation_manager.filter_${T}`)},T))}),i.jsx("div",{style:{flex:1}}),g.size>0&&i.jsxs("button",{type:"button",onClick:()=>{M(Array.from(g))},style:pd,"data-testid":"g7le-translation-bulk-delete",children:["🗑 ",n("layout_editor.translation_manager.delete_selected",{count:g.size})]}),C.length>0&&i.jsxs("button",{type:"button",onClick:()=>{M(C.map(T=>T.id))},style:pd,"data-testid":"g7le-translation-purge-orphaned",children:["🧹 ",n("layout_editor.translation_manager.purge_orphaned",{count:C.length})]})]}),c&&i.jsx("div",{style:am,"data-testid":"g7le-translation-manager-error",children:c}),i.jsx("div",{style:lm,children:l==="loading"?i.jsx("div",{style:fd,"data-testid":"g7le-translation-manager-loading",children:n("layout_editor.translation_manager.loading")}):E.length===0&&l!=="error"?i.jsx("div",{style:fd,"data-testid":"g7le-translation-manager-empty",children:n("layout_editor.translation_manager.empty")}):i.jsx("div",{"data-testid":"g7le-translation-list",style:dm,children:E.map(T=>{const H=$(T),L=T.status==="orphaned",I=o?o.has(T.translation_key):null;return i.jsxs("div",{style:cm,"data-testid":`g7le-translation-row-${T.id}`,children:[i.jsxs("div",{style:um,children:[i.jsx("input",{type:"checkbox",checked:g.has(T.id),onChange:()=>B(T.id),"aria-label":n("layout_editor.translation_manager.select_aria",{key:T.translation_key}),"data-testid":`g7le-translation-select-${T.id}`}),i.jsx("code",{style:pm,children:T.translation_key}),L&&i.jsx("span",{style:fm,"data-testid":`g7le-translation-badge-orphaned-${T.id}`,children:n("layout_editor.translation_manager.badge_orphaned")}),I===!0&&i.jsx("span",{style:gm,"data-testid":`g7le-translation-badge-live-inuse-${T.id}`,children:n("layout_editor.translation_manager.badge_live_in_use")}),I===!1&&i.jsx("span",{style:hm,"data-testid":`g7le-translation-badge-live-unused-${T.id}`,children:n("layout_editor.translation_manager.badge_live_unused")}),i.jsx("div",{style:{flex:1}}),i.jsx("button",{type:"button",onClick:()=>{O(T)},style:wm,"data-testid":`g7le-translation-delete-${T.id}`,children:"🗑"})]}),i.jsxs("div",{style:ym,children:[S.map(P=>{const F=H[P]??"",G=In(F).length>0;return i.jsxs("div",{style:bm,"data-testid":`g7le-translation-field-${T.id}-${P}`,children:[i.jsxs("label",{style:mm,title:P,children:[Un(P,n),i.jsxs("span",{style:vm,children:["(",P,")"]})]}),G?i.jsx("div",{style:{flex:1},"data-testid":`g7le-translation-chip-${T.id}-${P}`,children:i.jsx(yr,{value:F,onChange:V=>A(T.id,P,V),t:n,testIdSuffix:`${T.id}-${P}`})}):i.jsx("textarea",{value:F,rows:1,ref:Rb,onChange:V=>{A(T.id,P,V.currentTarget.value),cd(V.currentTarget)},style:_m,"data-testid":`g7le-translation-input-${T.id}-${P}`})]},P)}),i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8},children:[i.jsx("button",{type:"button",disabled:b===T.id,onClick:()=>{z(T)},style:xm,"data-testid":`g7le-translation-save-${T.id}`,children:b===T.id?n("layout_editor.translation_manager.saving"):n("layout_editor.translation_manager.save")}),k[T.id]?i.jsx("span",{style:{fontSize:11,color:"#dc2626"},"data-testid":`g7le-translation-row-error-${T.id}`,children:k[T.id]}):null]})]})]},T.id)})})}),i.jsx("div",{style:km,children:i.jsx("button",{type:"button",onClick:r,style:Sm,"data-testid":"g7le-translation-manager-footer-close",children:n("layout_editor.translation_manager.close")})})]})}const nm={display:"flex",flexDirection:"column",minHeight:0,flex:1},om={display:"flex",alignItems:"center",justifyContent:"space-between",padding:"12px 16px",borderBottom:"1px solid #e2e8f0"},rm={border:"none",background:"transparent",fontSize:16,color:"#94a3b8",cursor:"pointer"},im={padding:"10px 16px",borderBottom:"1px solid #f1f5f9",display:"flex",gap:8,alignItems:"center"},ud={padding:"4px 10px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},sm={...ud,borderColor:"#2563eb",background:"#eff6ff",color:"#1d4ed8",fontWeight:600},pd={padding:"4px 10px",fontSize:12,border:"1px solid #fecaca",borderRadius:6,background:"#fff",color:"#dc2626",cursor:"pointer"},am={padding:"8px 16px",fontSize:12,color:"#b91c1c",background:"#fef2f2"},lm={padding:16,overflow:"auto",flex:1,minHeight:120,display:"flex",flexDirection:"column",gap:12},fd={fontSize:13,color:"#94a3b8",textAlign:"center",padding:"32px 0"},dm={display:"flex",flexDirection:"column",gap:20},cm={border:"1px solid #e2e8f0",borderRadius:8,padding:10,display:"flex",flexDirection:"column",gap:6},um={display:"flex",alignItems:"center",gap:8},pm={fontSize:11,background:"#f1f5f9",padding:"2px 6px",borderRadius:4,color:"#0f172a"},fm={fontSize:10,color:"#b45309",background:"#fef3c7",padding:"1px 6px",borderRadius:4,fontWeight:600},gm={fontSize:10,color:"#15803d",background:"#dcfce7",padding:"1px 6px",borderRadius:4,fontWeight:600},hm={fontSize:10,color:"#9f1239",background:"#ffe4e6",padding:"1px 6px",borderRadius:4,fontWeight:600},ym={display:"flex",flexDirection:"column",gap:6},bm={display:"flex",alignItems:"flex-start",gap:8},mm={fontSize:12,color:"#475569",minWidth:88,fontWeight:600,paddingTop:4,display:"flex",flexDirection:"column",lineHeight:1.2},vm={fontSize:10,color:"#94a3b8",fontWeight:400},_m={flex:1,padding:"4px 8px",fontSize:13,border:"1px solid #cbd5e1",borderRadius:4,color:"#0f172a",resize:"vertical",overflow:"hidden",minHeight:28,lineHeight:1.4,fontFamily:"inherit",boxSizing:"border-box"},xm={padding:"5px 12px",fontSize:12,border:"none",borderRadius:6,background:"#2563eb",color:"#fff",cursor:"pointer"},wm={padding:"4px 8px",fontSize:12,border:"1px solid #fecaca",borderRadius:6,background:"#fff",color:"#dc2626",cursor:"pointer"},km={padding:"10px 16px",borderTop:"1px solid #e2e8f0",display:"flex",justifyContent:"flex-end"},Sm={padding:"6px 14px",fontSize:13,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"};function Cm(){const e=Ft(),{state:t}=Je(),n=e?.document?.raw??null,o=t.templateIdentifier??"",r=t.selectedRoute?.layoutName??"",s=u.useCallback((c,p)=>{const f=n?.[c];return f===void 0?p:f},[n]),a=u.useCallback((c,p,...f)=>{e&&(f.length>=1?e.patchDocumentRaw(c,p,f[0]):e.patchDocumentRaw(c,p))},[e]),l=u.useCallback(async(c,p)=>!o||!r?null:Vn(o,r,c,p),[o,r]),d=u.useCallback(async(c,p,f)=>o?gr(o,c,p,f):null,[o]);return{raw:n,getValue:s,patch:a,createI18nKey:l,updateI18nKeyValue:d}}function $m(e){const t=e.__source;return t&&(t.kind==="module"||t.kind==="plugin")&&t.identifier?`${t.kind}:${t.identifier}`:"template"}function gd(e,t){if(!t)return null;const n=$m(e);if(n&&t.bySource){const a=t.bySource[n]?.[e.id];if(a!==void 0)return{value:a,match:{dataSourceId:e.id,resolvedBy:"spec_byId",sourceKey:n}}}const o=t.byDataSourceId?.[e.id];if(o!==void 0)return{value:o,match:{dataSourceId:e.id,resolvedBy:"spec_byId"}};const r=e.endpoint??"";if(t.byEndpointPattern&&typeof r=="string"&&r.length>0){for(const[s,a]of Object.entries(t.byEndpointPattern))if(Em(r,s))return{value:a,match:{dataSourceId:e.id,resolvedBy:"spec_byEndpoint"}}}return null}function Ri(e,t){const n=gd(e,t);if(n)return n;const o=e.fallback?.data;return o!==void 0?{value:{data:Array.isArray(o)?o:[o]},match:{dataSourceId:e.id,resolvedBy:"fallback"}}:{value:{},match:{dataSourceId:e.id,resolvedBy:"inferred"}}}function Em(e,t){const n=t.replace(/[.+?^${}()|[\]\\]/g,"\\$&").replace(/\*/g,".*");return new RegExp(`^${n}$`).test(e)}function Im(e={}){const{spec:t,overrideSpec:n,onMatch:o}=e;return{has(r){return!0},resolve(r){const s=n?gd(r,n):null,{value:a,match:l}=s??Ri(r,t);if(o)try{o(l)}catch{}return a}}}const hd=Lt(),yd=hd.DataBindingEngine,Am=hd.dataBindingEngine,Tm=new Set(["comment"]);function bd(e){if(!e||typeof e!="object")return[];const t=[];for(const[n,o]of Object.entries(e)){if(Tm.has(n)||!o||typeof o!="object")continue;const r=o;typeof r.expr!="string"||r.expr.length===0||t.push({id:n,label:typeof r.label=="string"?r.label:void 0,params:Array.isArray(r.params)?r.params:[],expr:r.expr,group:typeof r.group=="string"?r.group:void 0,source:r.__source})}return t}function Dm(e,t){let n=e.replace(/\{(\w+)\*\}(\s*\?\?\s*)?/g,(o,r,s)=>{const a=t[r],l=a==null?[]:String(a).split(",").map(d=>d.trim()).filter(Boolean);return l.length===0?"":l.join(" ?? ")+(s?" ?? ":"")});return n=n.replace(/\{(\w+)\}/g,(o,r)=>{const s=t[r];return s===void 0?o:s}),n}function br(e){const t=e.trim(),n=/^\{\{([\s\S]*)\}\}$/.exec(t);return n?n[1].trim():t}function md(e){const t=e.trim();return/^\{\{[\s\S]*\}\}$/.test(t)?t:`{{ ${t} }}`}function vd(e,t){const n={};for(const[r,s]of Object.entries(t))n[r]=typeof s=="string"?br(s):s;const o=Dm(e.expr,n);return md(o)}function Nm(e,t){if(typeof e!="string")return null;const n=_d(br(e));for(const o of t){const r=zm(n,o.expr,o.params);if(r)return{recipeId:o.id,params:r}}return null}function _d(e){return e.replace(/\s+/g," ").trim()}function xd(e){return e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}function zm(e,t,n){const o=[],r=new Set;let s="";const a=/\{(\w+)(\*?)\}(\s*\?\?\s*)?/g;let l=0,d;const c=_d(t);for(;(d=a.exec(c))!==null;){const g=c.slice(l,d.index);s+=xd(g).replace(/\\ /g,"\\s*");const m=d[2]==="*",h=!!d[3];m?(r.add(d[1]),s+="(.*?)",h&&(s+="(?:\\s*\\?\\?\\s*)?")):(s+="(.+?)",h&&(s+="\\s*\\?\\?\\s*")),o.push(d[1]),l=a.lastIndex}s+=xd(c.slice(l)).replace(/\\ /g,"\\s*");let p;try{p=new RegExp(`^${s}$`)}catch{return null}const f=p.exec(e);if(!f)return null;const y={};if(o.forEach((g,m)=>{let h=(f[m+1]??"").trim();r.has(g)&&(h=h.split(/\s*\?\?\s*/).map(v=>v.trim()).filter(Boolean).join(", ")),y[g]=h}),n.length>0){const g=new Set(n.map(m=>m.key));for(const m of Object.keys(y))g.has(m)||delete y[m]}return y}function Mm(e){const t=`x.${e.field}`,n=Lm(e.value);switch(e.cmp){case"=":return`${t} === ${n}`;case"!=":return`${t} !== ${n}`;case">":return`${t} > ${n}`;case"<":return`${t} < ${n}`;case">=":return`${t} >= ${n}`;case"<=":return`${t} <= ${n}`;case"includes":return`(${t} ?? []).includes(${n})`;default:return`${t} === ${n}`}}function Lm(e){const t=e.trim();return t==="true"||t==="false"||t!==""&&!Number.isNaN(Number(t))||/^['"].*['"]$/.test(t)||/[.([{]/.test(t)?t:`'${t.replace(/'/g,"\\'")}'`}function es(e){return!e||e.length===0?"true":e.map(Mm).join(" && ")}function Pm(e){const n=`(${(e.source??"").trim()||"[]"} ?? [])`;let o;switch(e.op){case"count":o=`${n}.filter(x => ${es(e.conditions)}).length`;break;case"sum":{const r=(e.sumField??"").trim()||"value";o=`${n}.filter(x => ${es(e.conditions)}).reduce((s, x) => s + (x.${r} ?? 0), 0)`;break}case"filter":o=`${n}.filter(x => ${es(e.conditions)})`;break;case"toOptions":{const r=(e.valueField??"").trim()||"value",s=(e.labelField??"").trim()||"label";o=`${n}.map(x => ({ value: x.${r}, label: x.${s} }))`;break}case"nth":{const r=(e.index??"").trim()||"0",s=(e.prop??"").trim();o=`${n}[${r}]${s?`?.${s}`:""}`;break}case"firstOf":{const r=(e.candidates??[]).map(l=>l.trim()).filter(Boolean),s=r.length>0?r.join(" ?? "):"''",a=(e.fallback??"").trim();o=a?`${s} ?? '${a.replace(/'/g,"\\'")}'`:s;break}case"literal":{const r=e.literalKind??"string",s=(e.literalValue??"").trim();r==="number"?o=s===""?"0":s:r==="boolean"?o=s==="true"?"true":"false":o=`'${s.replace(/'/g,"\\'")}'`;break}default:o="[]"}return md(o)}function Om(e){if(typeof e!="string")return null;const t=br(e).trim();let n=/^\((.+?)\s*\?\?\s*\[\]\)\.filter\(x => (.+)\)\.length$/.exec(t);return n?{key:"",op:"count",source:n[1].trim(),conditions:ts(n[2])}:(n=/^\((.+?)\s*\?\?\s*\[\]\)\.filter\(x => (.+)\)\.reduce\(\(s, x\) => s \+ \(x\.(\w+(?:\.\w+)*) \?\? 0\), 0\)$/.exec(t),n?{key:"",op:"sum",source:n[1].trim(),conditions:ts(n[2]),sumField:n[3]}:(n=/^\((.+?)\s*\?\?\s*\[\]\)\.filter\(x => (.+)\)$/.exec(t),n?{key:"",op:"filter",source:n[1].trim(),conditions:ts(n[2])}:(n=/^\((.+?)\s*\?\?\s*\[\]\)\.map\(x => \(\{ value: x\.(\w+(?:\.\w+)*), label: x\.(\w+(?:\.\w+)*) \}\)\)$/.exec(t),n?{key:"",op:"toOptions",source:n[1].trim(),valueField:n[2],labelField:n[3]}:(n=/^\((.+?)\s*\?\?\s*\[\]\)\[(.+?)\](?:\?\.(\w+(?:\.\w+)*))?$/.exec(t),n?{key:"",op:"nth",source:n[1].trim(),index:n[2].trim(),prop:n[3]}:null))))}function ts(e){const t=e.trim();if(t==="true")return[];const n=[];for(const o of Bm(t)){const r=Wm(o.trim());r&&n.push(r)}return n}function Bm(e){const t=[];let n=0,o=0;for(let r=0;r=|<=|>|<)\s*(.+)$/.exec(e),t){const n={"===":"=","!==":"!=",">":">","<":"<",">=":">=","<=":"<="};return{field:t[1],cmp:n[t[2]]??"=",value:wd(t[3])}}return null}function wd(e){const t=e.trim(),n=/^'(.*)'$/.exec(t)||/^"(.*)"$/.exec(t);return n?n[1]:t}function kd(e,t){const n=Nm(e,t);if(n)return{kind:"preset",recipeId:n.recipeId,params:n.params};const o=Om(e);return o?{kind:"custom",model:o}:{kind:"advanced"}}function jm(e){if(e==null)return"null";if(Array.isArray(e))return"list";switch(typeof e){case"number":return"number";case"boolean":return"boolean";case"string":return"string";default:return"object"}}function Sd(e,t){if(typeof e!="string"||e.trim()==="")return{ok:!1,reason:"empty"};const n=br(e);try{const o=Am.evaluateExpression(n,t??{},{skipCache:!0});return{ok:!0,value:o,type:jm(o)}}catch(o){return{ok:!1,reason:o instanceof Error?o.message:String(o)}}}function Cd(e,t){const n=Array.isArray(e?.data_sources)?e.data_sources:[],o=[];for(const r of n){const s=r?.id;if(typeof s!="string"||s.length===0)continue;const{value:a}=Ri(r,t?.sampleData),l=typeof r.label_key=="string"?r.label_key:void 0;o.push({id:s,sample:a,labelKey:l})}return o}function $d(e,t){const n=[];t?.sampleGlobal&&typeof t.sampleGlobal=="object"&&n.push({scope:"_global",tree:t.sampleGlobal});const o={},r={},s={},a=(l,d)=>{if(!(!d||typeof d!="object"||Array.isArray(d)))for(const[c,p]of Object.entries(d))if(p&&typeof p=="object"&&!Array.isArray(p)){const f=l[c]&&typeof l[c]=="object"&&!Array.isArray(l[c])?l[c]:l[c]={};a(f,p)}else c in l||(l[c]=p)};for(const l of t?.states?.groups??[])for(const d of l.items??[]){const c=d.initialState;c?.local&&a(o,c.local),c?.query&&a(r,c.query),c?.route&&a(s,c.route)}return Object.keys(o).length&&n.push({scope:"_local",tree:o}),Object.keys(r).length&&n.push({scope:"query",tree:r}),Object.keys(s).length&&n.push({scope:"route",tree:s}),e?.computed&&typeof e.computed=="object"&&!Array.isArray(e.computed)&&n.push({scope:"_computed",tree:e.computed}),n}function Ed(e,t){const n={};for(const r of t)r.scope!=="_computed"&&(n[r.scope]=r.tree);const o={};for(const[r,s]of Object.entries(e)){if(typeof s!="string"){o[r]=s;continue}const a=Sd(s,n);o[r]=a.ok?a.value:s}return o}function Hm(e,t){const n=Cd(e,t),o=$d(e,t),r={};for(const s of n)s.sample!==void 0&&(r[s.id]=s.sample);for(const s of o)s.scope==="_computed"?r[s.scope]=Ed(s.tree,o):r[s.scope]=s.tree;return r}function Id(e){const{raw:t,spec:n,evaluateComputed:o}=e;return u.useMemo(()=>{const r=Cd(t,n),s=$d(t,n);if(o){const a=s.findIndex(l=>l.scope==="_computed");if(a>=0){const l=Ed(s[a].tree,s);s[a]={scope:"_computed",tree:l}}}return Bi({dataSources:r,states:s,spec:n??void 0})},[t,n,o])}function Fm(e){const{raw:t,spec:n,pageType:o}=e;return u.useMemo(()=>{const r=Array.isArray(t?.data_sources)?t.data_sources:[],s=[];for(const d of r){const c=d?.id;if(typeof c!="string"||c.length===0)continue;const{value:p}=Ri(d,n?.sampleData),f=typeof d.label_key=="string"?d.label_key:void 0;s.push({id:c,sample:p,labelKey:f})}const a=[];n?.sampleGlobal&&typeof n.sampleGlobal=="object"&&a.push({scope:"_global",tree:n.sampleGlobal}),t?.computed&&typeof t.computed=="object"&&!Array.isArray(t.computed)&&a.push({scope:"_computed",tree:t.computed});const l=Bi({dataSources:s,states:a,spec:n??void 0});if(typeof o=="string"&&o!=="")for(const d of["title","description"])l.push({expression:`{{_seo.${o}.${d}}}`,source:"_seo",sourceId:"_seo",path:`${o}.${d}`,shape:"scalar",preview:""});return l},[t,n,o])}function Ad(e,t){const n=[],o=new Set,r=s=>{for(const a of s??[]){if(a.kind==="route"&&!a.isRedirect&&typeof a.path=="string"&&a.path.startsWith("/")&&!o.has(a.path)){o.add(a.path);const l=a.label.startsWith("$t:")?a.label.slice(3):a.label,d=a.label.startsWith("$t:")?t(l):l,c=d&&d!==l&&!d.startsWith("$t:")?d:"";n.push({value:a.path,label:c?`${c} (${a.path})`:a.path})}r(a.children)}};return r(e),n}function Td(e){const t=Array.isArray(e?.data_sources)?e.data_sources:[],n=[];for(const o of t){const r=o?.id;typeof r=="string"&&r.length>0&&n.push({value:r,label:r})}return n}function ns(e,t){const n=typeof e.id=="string"?e.id:"",o=typeof e.label_key=="string"?e.label_key:"";if(!o)return null;const r=o.startsWith("$t:")?o.slice(3):o,s=t(r);return s&&s!==r&&!s.startsWith("$t:")&&s!==n?s:null}function os(e,t){const n=e.__source;if(!n||typeof n!="object"||Array.isArray(n))return null;const o=n;if(o.kind!=="extension")return null;const r=o.extensionSourceType==="plugin"?t("layout_editor.data_sources.source.plugin"):t("layout_editor.data_sources.source.module"),s=typeof o.extensionIdentifier=="string"?o.extensionIdentifier:"",a=typeof o.extensionName=="string"?o.extensionName:"",l=a&&a!==s?`${a} (${s})`:s;return l?`${r}: ${l}`:r}function Km(e,t,n){const o=Array.isArray(e?.data_sources)?e.data_sources:[],r=[],s=new Set;for(const a of o){const l=a?.id;typeof l!="string"||l.length===0||s.has(l)||(s.add(l),r.push({id:l,friendly:ns(a,t),source:os(a,n)}))}return r}function Dd(e){const t=e?.states?.groups??[],n=new Set;for(const o of t)for(const r of o.items??[]){for(const s of Object.keys(r.initialState?.local??{}))n.add(s);for(const s of Object.keys(r.initialState?.global??{}))n.add(s)}return Array.from(n).map(o=>({value:o,label:o}))}function Vm(e,t){const n=Array.isArray(e?.modals)?e.modals:[],o=[],r=new Set;for(const s of n){const a=s?.id;typeof a!="string"||a.length===0||r.has(a)||(r.add(a),o.push({value:a,label:Um(s,a,t)}))}return o}function Um(e,t,n){const r=e?.meta?.editor_label;if(typeof r=="string"&&r.length>0)return Nd(r,n);const s=e?.title;return typeof s=="string"&&s.length>0?Nd(s,n):t}function Nd(e,t){if(e.startsWith("$t:")){const n=e.slice(3),o=t?t(n):n;return o&&o!==n?o:n}return e}const zd=ar("ActionRecipeEngine"),qm={api:"apiCall",nav:"navigate",showToast:"toast",setLocalState:"setState"},Gm=new Set(["comment"]);function vo(e){if(!e||typeof e!="object")return[];const t=[];for(const[n,o]of Object.entries(e)){if(Gm.has(n)||!o||typeof o!="object")continue;const r=o,s=r.build&&typeof r.build=="object"?r.build:typeof r.handler=="string"?{handler:r.handler}:void 0;!s||typeof s.handler!="string"||s.handler.length===0||t.push({id:n,label:typeof r.label=="string"?r.label:void 0,params:Array.isArray(r.params)?r.params:[],build:s})}return t}function nn(e){const t=/^\{\{\s*([\w.]+)\s*\}\}$/.exec(e);return t?t[1]:null}const mr="...";function Ym(e,t){return e.replace(/\{\{\s*([\w.]+)\s*\}\}/g,(n,o)=>{const r=t[o];return r==null?"":String(r)})}function rs(e,t){if(typeof e=="string"){const n=nn(e);return n!==null?n in t?t[n]:void 0:e.includes("{{")?Ym(e,t):e}if(Array.isArray(e))return e.map(n=>rs(n,t)).filter(n=>n!==void 0);if(e&&typeof e=="object"){const n={};for(const[o,r]of Object.entries(e)){if(o===mr&&typeof r=="string"){const d=nn(r),c=d!==null?t[d]:void 0;if(c&&typeof c=="object"&&!Array.isArray(c))for(const[p,f]of Object.entries(c))f===void 0||p===""||(n[p]=f);continue}const s=rs(r,t);if(s===void 0||Array.isArray(s)&&s.length===0&&(o==="onSuccess"||o==="onError"))continue;const a=nn(o),l=a!==null?a in t?String(t[a]):null:o;l===null||l===""||s&&typeof s=="object"&&!Array.isArray(s)&&Object.keys(s).length===0||(n[l]=s)}return n}return e}function Xm(e){const t=e.handler;if(typeof t!="string")return e;const n=qm[t];if(n)return zd.warn(`action recipe used forbidden handler "${t}" — corrected to "${n}" (core handler rules)`),{...e,handler:n};if(t==="apiCall"||t==="navigate"){const o=e.params;if(o&&typeof o=="object")for(const r of["target","onSuccess","onError"])r in o&&zd.warn(`action recipe build placed "${r}" inside params for handler "${t}" — should be top-level (core handler rules)`)}return e}function vr(e,t){const n=rs(e.build,t);if(typeof n.handler!="string"&&e.build.handler.includes("{{")&&(n.handler=e.build.handler),e.build.handler.includes("{{")){const o=new Set(e.params.filter(r=>r.required).map(r=>r.key));o.size>0&&Md(e.build,n,o)}return Xm(n)}function Md(e,t,n){if(!e||typeof e!="object"||Array.isArray(e)||!t||typeof t!="object"||Array.isArray(t))return;const o=e,r=t;for(const[s,a]of Object.entries(o))if(s!=="handler"){if(typeof a=="string"){const l=nn(a);l!==null&&n.has(l)&&!(s in r)&&(r[s]=a)}else if(a&&typeof a=="object"){if(!(s in r)||typeof r[s]!="object"||r[s]===null)if(JSON.stringify(a).includes("{{"))r[s]={};else continue;Md(a,r[s],n)}}}function Jm(e,t){if(!e||typeof e!="object")return null;const n=e.handler;if(typeof n!="string")return null;let o=null,r=-1;for(const s of t){const a=s.build.handler.includes("{{");if(!a&&s.build.handler!==n||a&&!Zm(s.build,e))continue;const l=Qm(s.build,e);if(l===null)continue;const d=Object.keys(l).length;d>r&&(r=d,o={recipeId:s.id,values:l})}return o}function Zm(e,t){if(typeof t.handler=="string"&&t.handler===e.handler)return!0;const n=e.params;if(!n||typeof n!="object")return!1;const o=t.params;if(!o||typeof o!="object")return!1;const r=o,s=Object.entries(n).filter(([,a])=>typeof a=="string"&&nn(a)!==null);return s.length===0?!1:s.every(([a])=>a in r)}function Qm(e,t){const n={},o=(r,s)=>{if(typeof r=="string"){const a=nn(r);return a!==null?(n[a]=s,!0):r.includes("{{")?typeof s=="string":r===s}if(Array.isArray(r))return Array.isArray(s);if(r&&typeof r=="object"){if(!s||typeof s!="object"||Array.isArray(s))return!1;const a=s,l=r[mr],d=typeof l=="string"?nn(l):null,c=new Set(Object.keys(r).filter(p=>p!==mr));for(const[p,f]of Object.entries(r))if(p!==mr){if(typeof f=="string"&&nn(f)!==null){const y=nn(f);p in a&&(n[y]=a[p]);continue}if(!(p in a)){if(p==="onSuccess"||p==="onError"||p==="params"||p==="identity_target")continue;return!1}if(!o(f,a[p]))return!1}if(d!==null){const p={};for(const[f,y]of Object.entries(a))c.has(f)||(p[f]=y);Object.keys(p).length>0&&(n[d]=p)}return!0}return r===s};return o(e,t)?n:null}function qn(e,t){const n=typeof e?.handler=="string"?e.handler:"",o=Jm(e,t);return o?{kind:"preset",recipeId:o.recipeId,values:o.values,handler:n}:{kind:"advanced",handler:n}}function Ld(e,t,n){const o=e?.label,r=o?n?n(o):o:"",s=[],a=e?.params??[];for(const l of a){if(l.advanced||l.widget==="action-list")continue;const d=t[l.key];if(d==null||d==="")continue;const c=Rm(d);if(c!==""&&(s.push(c),s.length>=2))break}return s.length===0?r:`${r} (${s.join(", ")})`}function Rm(e){return typeof e=="string"?e:typeof e=="number"||typeof e=="boolean"?String(e):Array.isArray(e)?`[${e.length}]`:e&&typeof e=="object"?"{…}":""}function Pd(e,t){const n={...e};return typeof t=="string"&&t.trim().length>0?n.if=t.trim():delete n.if,n}function is(e){const t=e?.if;return typeof t=="string"&&t.trim().length>0?t:null}const ev=/^\s*\$t:(custom\.[a-zA-Z0-9._-]+)\s*$/,tv=/^\s*\$t:([a-zA-Z0-9._-]+)\s*$/,nv=/\{\{.*?\}\}/;function Od(e,t){const n=typeof e=="string"?e:"",o=ev.exec(n);if(o){const a=o[1],l=t?t(a):"";return{customKey:a,binding:!1,paramKey:!1,displayValue:l}}const r=mt(n);if(r&&r.key.startsWith("custom.")){const a=t?t(r.key):"";return{customKey:r.key,binding:!1,paramKey:!0,displayValue:a}}if(nv.test(n))return{customKey:null,binding:!0,paramKey:!1,displayValue:n};const s=tv.exec(n);if(s){const a=s[1],l=t?t(a):"";return{customKey:null,binding:!1,paramKey:!1,displayValue:l&&l!==a?l:""}}return{customKey:null,binding:!1,paramKey:!1,displayValue:n}}function ov(){const{state:e}=Je(),t=e.templateIdentifier,n=e.locale,o=e.selectedRoute?.layoutName??null,r=u.useCallback(l=>{try{const c=Pt.getInstance().translate(l,{templateId:t,locale:n});return c&&c!==l?c:""}catch{return""}},[t,n]),s=u.useCallback(l=>Od(l,r),[r]),a=u.useCallback(async(l,d)=>{const c=Od(l,r);if(c.binding)return{kind:"blocked"};if(c.customKey){if(d===c.displayValue)return{kind:"noop",customKey:c.customKey};const y=await gr(t,c.customKey,n,d);return y.kind==="error"?{kind:"error",message:y.message}:(await en(t,n),{kind:"updated",customKey:c.customKey})}const p=await Vn(t,o,n,d);if(p.kind==="error"||!p.resource)return{kind:"error",message:p.message};const f=p.resource.translation_key;return await en(t,n),{kind:"created",customKey:f,token:`$t:${f}`}},[t,n,o,r]);return{locale:n,templateIdentifier:t,translate:r,classify:s,commitText:a}}async function ss(e,t,n,o,r,s,a,l,d,c){const p=c?qi(o,c):null,f=!!(p&&p.keyifiable),y=d?d(o):o,g=f?p.chipValue:Gl(y),h=`p${f?p.bindings.length:lr(y).length}`,v=Math.max(0,Math.min(r,g.length)),b=Wl(g,v,h),x=dr(b).replace(/\{\{?p\d+\}?\}/g," ").replace(/\s+/g," ").trim(),k=x.length>0?x:b,w=await Vn(e,t,n,k);if(w.kind==="error"||!w.resource)return{kind:"error",message:w.message};const _=w.resource.translation_key,S=In(b),E=w.resource.values??{},C={[n]:b};for(const z of Object.keys(E)){if(z===n)continue;let D=E[z]??"";for(const O of S)D=Vi(D,O);C[z]=D}mo(e,_,C);const N=$n(s,a,l);return{kind:"ok",text:`${f?Ul(`$t:${_}`,p):ql(`$t:${_}`,y)}|${h}=${N}`,key:_}}async function _r(e,t,n,o,r,s,a){let l=mt(n);if(!l&&typeof n=="string"){const h=/^\s*\$t:(custom\.[A-Za-z0-9._-]+)\s*$/.exec(n);h&&(l={key:h[1],params:[]})}if(!l)return{kind:"error",message:"not a param key"};if(!l.key.startsWith("custom."))return{kind:"error",message:"not a custom param key"};const d=db(l.params.map(h=>h.name)),c=$n(r,s,a),p=`${n.trim()}|${d}=${c}`,f=await dn(e,l.key),y=Nb(l.key)??{},g=new Set([...Object.keys(f?.values??{}),...Object.keys(y),t]);if(g.size===0)return{kind:"error",message:"custom key not found"};const m={};for(const h of g){const v=It(l.key,h)??f?.values?.[h]??"";m[h]=h===t?Wl(v,o,d):Vi(v,d)}return mo(e,l.key,m),{kind:"ok",text:p,paramName:d}}async function _o(e,t,n,o){return hr(e,t,n,o),{kind:"ok"}}async function rv(e,t,n){const o=await dn(e,t);if(!o)return{kind:"error",message:"custom key not found"};const r={};for(const[s,a]of Object.entries(o.values??{})){const l=It(t,s)??a;r[s]=n(l)}mo(e,t,r);try{const s=Pt.getInstance();for(const[a,l]of Object.entries(r))s.setTranslationValue(e,a,t,l)}catch{}return{kind:"ok"}}function Bd(e,t,n){return rv(e,t,o=>Jl(o,n))}function Wd(e,t){if(!(typeof window>"u"))try{window.dispatchEvent(new CustomEvent(kt,{detail:{templateIdentifier:e,locale:t}}))}catch{}}async function xr(e,t,n,o){const r=await Bd(e,t,n);return Wd(e,o),r}const iv=/^\s*\$t:(custom\.[a-zA-Z0-9._-]+)\s*$/;function sv(e,t){const n=s=>{const a=[],l=/\{\{?(p\d+)\}?\}/g;let d;for(;(d=l.exec(s))!==null;)a.push(d[1]);return a},o=n(e),r=n(t);return o.length!==r.length?!0:o.some((s,a)=>s!==r[a])}function jd(e){const t=e.text;if(typeof t!="string")return null;const n=iv.exec(t);if(n)return n[1];const o=mt(t);return o&&o.key.startsWith("custom.")?o.key:null}function as(e){const t={},n=mt(typeof e=="string"?e:"");for(const o of n?.params??[])t[o.name]=Vt(o.expression);return t}function av(e){return as(typeof e.text=="string"?e.text:"")}function Hd({customKey:e,templateIdentifier:t,t:n,paramLabels:o,locales:r,onRemoveParam:s}){const a=r??ld(),l=Ft(),d=o??{},[c,p]=u.useState({status:"idle",values:{}}),[f,y]=u.useState(!1),[g,m]=u.useState(!1),[h,v]=u.useState(null),b=u.useRef({});u.useEffect(()=>{let $=!1;if(!e){p({status:"not_a_key",values:{}});return}return p({status:"loading",values:{}}),(async()=>{const A=await dn(t,e);if($)return;if(!A){p({status:"error",values:{}});return}const z={...A.values??{}};for(const D of Object.keys(z)){const O=It(e,D);O!==void 0&&(z[D]=O)}for(const D of a){const O=It(e,D);O!==void 0&&(z[D]=O)}b.current={...z},p({status:"loaded",id:A.id,lockVersion:A.lock_version,values:z})})(),()=>{$=!0}},[e,t]),u.useEffect(()=>{if(typeof window>"u"||!e)return;const $=A=>{const z=A.detail;z?.templateIdentifier&&z.templateIdentifier!==t||p(D=>{if(D.status!=="loaded")return D;let O=!1;const M={...D.values};for(const B of Object.keys(M)){const T=It(e,B);T!==void 0&&T!==M[B]&&(M[B]=T,O=!0)}return O?(b.current={...b.current,...M},{...D,values:M}):D})};return window.addEventListener(kt,$),()=>window.removeEventListener(kt,$)},[e,t]);const x=u.useCallback(($,A)=>{p(z=>{const D=z.values[$]??"",O={...z,values:{...z.values,[$]:A}};return e&&sv(D,A)&&(hr(t,e,$,A),b.current={...b.current,[$]:A},l?.markDirty?.(),Wd(t,$)),O})},[e,t,l]),k=u.useCallback($=>{p(A=>{const z={};for(const[D,O]of Object.entries(A.values))z[D]=Jl(O,$);return b.current={...z},{...A,values:z}}),s?.($)},[s]),w=u.useMemo(()=>[...Object.values(c.values),...Object.values(b.current)].some(A=>In(A).length>0),[c.values]),_=u.useRef(null),S=u.useCallback(()=>{m(!0),_.current&&clearTimeout(_.current),_.current=setTimeout(()=>m(!1),1500)},[]);u.useEffect(()=>()=>{_.current&&clearTimeout(_.current)},[]);const E=u.useCallback(async()=>{if(m(!1),c.status!=="loaded"||c.id===void 0)return;const $=b.current;for(const[z,D]of Object.entries(c.values)){const O=$[z]??"";if(w){const M=In(O);if(M.length===0)continue;const B=In(D);if(!(M.length===B.length&&M.every((H,L)=>H===B[L]))){v(n("layout_editor.translation.placeholder_mismatch"));return}continue}if(Ki(O)&&!bb(O,D)){v(n("layout_editor.translation.token_mismatch"));return}}if(y(!0),v(null),w){mo(t,e??"",c.values),b.current={...c.values},l?.markDirty?.(),Et({op:"translation_field_update",translationKey:e,changedLocales:Object.keys(c.values),timestamp:Date.now()}),await new Promise(z=>setTimeout(z,0)),y(!1),S();return}const A=`/api/admin/templates/${encodeURIComponent(t)}/custom-translations/${c.id}`;try{const z=await fetch(A,{method:"PUT",credentials:"same-origin",headers:je({"Content-Type":"application/json"}),body:JSON.stringify({values:c.values,expected_lock_version:c.lockVersion??0})}),D=await z.json().catch(()=>null);if(z.status===409){v(n("layout_editor.translation.conflict"));return}if(!z.ok){v(D?.message??n("layout_editor.translation.save_failed"));return}const O=typeof D?.data?.lock_version=="number"?D.data.lock_version:(c.lockVersion??0)+1;p(M=>({...M,lockVersion:O})),b.current={...c.values},await en(t),Et({op:"translation_field_update",translationKey:e,changedLocales:Object.keys(c.values),timestamp:Date.now()}),S()}catch(z){v(z instanceof Error?z.message:n("layout_editor.translation.save_failed"))}finally{y(!1)}},[c,t,e,n]);if(c.status==="not_a_key"||!e)return i.jsx("div",{"data-testid":"g7le-translation-not-a-key",style:ls,children:n("layout_editor.translation.not_a_key")});if(c.status==="loading"||c.status==="idle")return i.jsx("div",{"data-testid":"g7le-translation-loading",style:ls,children:n("layout_editor.translation.loading")});if(c.status==="error")return i.jsx("div",{"data-testid":"g7le-translation-error",style:ls,children:n("layout_editor.translation.load_failed")});const C=new Set([...a,...Object.keys(c.values)]),N=Array.from(C);return i.jsxs("div",{"data-testid":"g7le-translation-field",style:{display:"flex",flexDirection:"column",gap:8},children:[i.jsxs("div",{"data-testid":"g7le-translation-key",style:lv,children:[n("layout_editor.translation.key_label"),": ",i.jsx("code",{style:dv,children:e})]}),N.map($=>{const A=c.values[$]??"",z=A.trim().length===0;return i.jsxs("div",{className:"g7le-translation-row","data-testid":`g7le-translation-row-${$}`,style:cv,children:[i.jsxs("label",{htmlFor:`g7le-translation-input-${$}`,style:{fontSize:12,color:"#475569",minWidth:72,display:"inline-flex",alignItems:"center",gap:4},children:[Un($,n),z&&i.jsx("span",{"data-testid":`g7le-translation-missing-${$}`,style:uv,children:n("layout_editor.translation.needs_translation")})]}),w?i.jsx("div",{style:{flex:1},"data-testid":`g7le-translation-chip-${$}`,children:i.jsx(yr,{value:A,onChange:D=>x($,D),t:n,paramLabels:d,testIdSuffix:$,onRemoveChip:s?k:void 0})}):i.jsx("input",{id:`g7le-translation-input-${$}`,type:"text","data-testid":`g7le-translation-input-${$}`,value:A,placeholder:z?n("layout_editor.translation.needs_translation"):"",onChange:D=>x($,D.currentTarget.value),onBlur:E,style:{flex:1,padding:"4px 8px",fontSize:13,border:"1px solid #cbd5e1",borderRadius:4,color:z?"#94a3b8":"#0f172a"}})]},$)}),i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8},children:[i.jsx("button",{type:"button","data-testid":"g7le-translation-save",disabled:f,onClick:E,style:pv,children:n(f?"layout_editor.translation.saving":"layout_editor.translation.save")}),g&&!f&&i.jsxs("span",{"data-testid":"g7le-translation-saved",style:{fontSize:11,color:"#16a34a",display:"inline-flex",alignItems:"center",gap:4},children:["✓ ",n("layout_editor.translation.saved")]}),h&&i.jsx("span",{"data-testid":"g7le-translation-save-error",style:{fontSize:11,color:"#dc2626"},children:h})]})]})}const ls={fontSize:12,color:"#94a3b8",padding:"16px 0",textAlign:"center"},lv={fontSize:12,color:"#475569"},dv={fontSize:11,background:"#f1f5f9",padding:"1px 4px",borderRadius:3,color:"#0f172a"},cv={display:"flex",alignItems:"center",gap:8},uv={fontSize:10,color:"#b45309",background:"#fef3c7",padding:"0 4px",borderRadius:3},pv={padding:"6px 12px",fontSize:12,border:"none",borderRadius:6,background:"#2563eb",color:"#fff",cursor:"pointer"};function Gn({anchorRef:e,open:t,onClose:n,children:o,placement:r="bottom",minWidth:s=240,maxWidth:a=360,margin:l=8,testid:d}){const c=u.useRef(null),[p,f]=u.useState(null),y=u.useCallback(()=>{const m=e.current,h=c.current;if(!m||!h)return;const v=m.getBoundingClientRect(),b=window.innerWidth,x=window.innerHeight,k=h.getBoundingClientRect(),w=Math.min(Math.max(k.width,s),a),_=k.height;let S=v.left;S+w+l>b&&(S=v.right-w),S=Math.min(Math.max(S,l),b-w-l);const E=x-v.bottom-l,C=v.top-l;let N,$;const A=r==="bottom",z=E>=Math.min(_,160),D=C>=Math.min(_,160);A&&z||A&&!D?(N=v.bottom+4,$=E):!A&&D||D?(N=v.top-4-Math.min(_,C),$=C):E>=C?(N=v.bottom+4,$=E):(N=l,$=C),f({left:S,top:N,maxHeight:Math.max($,120)})},[e,r,s,a,l]);if(u.useLayoutEffect(()=>{if(!t){f(null);return}y()},[t,y]),u.useEffect(()=>{if(!t)return;const m=()=>y();window.addEventListener("resize",m),window.addEventListener("scroll",m,!0);const h=b=>{const x=c.current,k=e.current,w=b.target;x?.contains(w)||k?.contains(w)||n()},v=b=>{b.key==="Escape"&&n()};return document.addEventListener("pointerdown",h,!0),document.addEventListener("keydown",v),()=>{window.removeEventListener("resize",m),window.removeEventListener("scroll",m,!0),document.removeEventListener("pointerdown",h,!0),document.removeEventListener("keydown",v)}},[t,y,n,e]),!t)return null;const g={...fv,minWidth:s,maxWidth:`min(${a}px, calc(100vw - ${l*2}px))`,...p?{left:p.left,top:p.top,maxHeight:p.maxHeight,visibility:"visible"}:{left:-9999,top:-9999,visibility:"hidden"}};return i.jsx("div",{ref:c,"data-testid":d,style:g,role:"dialog",children:o})}const fv={position:"fixed",zIndex:1e3,background:"#fff",border:"1px solid #cbd5e1",borderRadius:8,boxShadow:"0 4px 16px rgba(0,0,0,0.14)",padding:6,overflowY:"auto",boxSizing:"border-box"};function Fd(e,t,n){if(!t)return n;const o=e(t);return!o||o===t||o.startsWith("$t:")?n:o}function $t({candidates:e,t,onSelect:n,testIdSuffix:o,defaultOpen:r=!1,floating:s=!0}){const[a,l]=u.useState(r),[d,c]=u.useState(""),p=u.useRef(null),f=u.useMemo(()=>ji(e,"scalar").map(h=>({...h,resolvedLabel:Fd(t,h.labelKey,h.path||h.sourceId)})),[e,t]),y=u.useMemo(()=>Hi(f,d),[f,d]),g=i.jsxs(i.Fragment,{children:[i.jsx("input",{type:"text",autoFocus:!0,"data-testid":`g7le-inline-binding-search-input-${o}`,value:d,placeholder:t("layout_editor.inline_binding.search_placeholder"),onChange:m=>c(m.target.value),style:bv}),i.jsx("div",{style:mv,children:y.length===0?i.jsx("div",{style:vv,children:t("layout_editor.inline_binding.no_results")}):y.map(m=>i.jsxs("button",{type:"button","data-testid":`g7le-inline-binding-candidate-${m.expression}`,onClick:()=>{n(m),l(!1),c("")},style:_v,children:[i.jsxs("span",{style:{display:"flex",flexDirection:"column",alignItems:"flex-start"},children:[i.jsx("span",{style:{fontSize:12},children:m.resolvedLabel}),i.jsxs("span",{style:xv,children:[Fd(t,m.groupLabelKey,m.sourceId)," · ",m.path||m.sourceId]})]}),i.jsx("span",{style:wv,children:m.preview})]},m.expression))})]});return i.jsxs("div",{style:s?yv:{marginTop:4},children:[i.jsx("button",{ref:p,type:"button","data-testid":`g7le-inline-binding-search-toggle-${o}`,onClick:()=>l(m=>!m),title:t("layout_editor.inline_binding.search"),"aria-label":t("layout_editor.inline_binding.search"),"aria-expanded":a,style:gv,children:"🔍"}),s?i.jsx(Gn,{anchorRef:p,open:a,onClose:()=>l(!1),testid:`g7le-inline-binding-picker-${o}`,children:g}):a&&i.jsx("div",{"data-testid":`g7le-inline-binding-picker-${o}`,style:hv,children:g})]})}const gv={fontSize:13,lineHeight:1,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#334155",padding:"4px 8px",cursor:"pointer",textAlign:"center",whiteSpace:"nowrap"},hv={marginTop:4,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",padding:6},yv={display:"inline-block"},bv={width:"100%",padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,boxSizing:"border-box"},mv={marginTop:4,maxHeight:200,overflowY:"auto",display:"flex",flexDirection:"column",gap:2},vv={fontSize:11,color:"#94a3b8",padding:"8px 0",textAlign:"center"},_v={display:"flex",alignItems:"center",justifyContent:"space-between",gap:8,border:"none",background:"transparent",cursor:"pointer",padding:"6px 8px",borderRadius:4,textAlign:"left"},xv={fontSize:10,color:"#94a3b8"},wv={fontSize:10,color:"#64748b",fontFamily:"monospace",whiteSpace:"nowrap"},kv=["$localized"];function Yn(e){const t=e.trim();if(!t.startsWith("{{")||!t.endsWith("}}")||t.length<4)return null;let n=0;for(let o=0;ocn(n).decomposed)}function xo(e,t,n,o){switch(e.kind){case"leaf":Vd(e.text,t,n,o);return;case"conditional":xo(e.then,t,n,o),o.push({kind:"ellipsis",text:"⋯"});return;case"fallback":xo(e.primary,t,n,o);return;case"concat":e.parts.forEach(r=>xo(r,t,n,o));return;default:o.push({kind:"text",text:e.source});return}}function Vd(e,t,n,o){const r=e.trim();if(/^\{\{([\s\S]*)\}\}$/.exec(r)&&!r.slice(2,-2).includes("}}")){o.push({kind:"chip",text:n(r)});return}if(r.startsWith("$t:")){const a=r.slice(3).split("|")[0];o.push({kind:"text",text:t(a)||a});return}for(const a of e.split(/(\{\{(?:[^}]|\}(?!\}))*\}\})/g))a!==""&&(/^\{\{[\s\S]*\}\}$/.test(a)?o.push({kind:"chip",text:n(a)}):o.push({kind:"text",text:a}))}function ds(e,t,n){const o=[];if(typeof e!="string"||e==="")return o;if(Yn(e)===null&&/\{\{/.test(e)){for(const a of e.split(/(\{\{(?:[^}]|\}(?!\}))*\}\})/g))if(!(a===void 0||a===""))if(/^\{\{[\s\S]*\}\}$/.test(a)){const l=cn(a);xo(l.node,t,n,o)}else Vd(a,t,n,o);return o}const s=cn(e);return xo(s.node,t,n,o),o}function Sv(e){if(typeof e!="string"||e.trim()==="")return"empty";const t=e.trim();if(Yn(t)===null)return"simple";const n=cn(e);return n.node.kind==="leaf"?"simple":n.decomposed?"expression":"raw"}function cn(e){const t=typeof e=="string"?e:"",n=t.trim();if(n==="")return{node:{kind:"leaf",text:t},decomposed:!1,original:t};const o=Yn(n);if(o===null)return{node:{kind:"leaf",text:t},decomposed:!1,original:t};let r;try{r=zv(o)}catch{r=null}if(r===null)return{node:{kind:"raw",source:o},decomposed:!1,original:t};if(r.kind==="leaf"||r.kind==="raw")return{node:r,decomposed:!1,original:t};if(r.kind==="fallback"&&r.op==="??"&&Cv(r.primary)&&$v(r.fallback))return{node:{kind:"leaf",text:t},decomposed:!1,original:t};const s=Xn(r,!0);return Bv(o,s)?{node:r,decomposed:!0,original:t}:{node:{kind:"raw",source:o},decomposed:!1,original:t}}function Xn(e,t=!1){const n=qd(e);return t?n:e.kind==="leaf"?e.text:`{{${n}}}`}function wo(e){return Xn({kind:"conditional",condition:{kind:"simple",left:"",op:"truthy",right:""},then:{kind:"leaf",text:typeof e=="string"?e:""},else:{kind:"leaf",text:""}},!1)}function cs(e){return Xn({kind:"fallback",op:"??",primary:{kind:"leaf",text:typeof e=="string"?e:""},fallback:{kind:"leaf",text:""}},!1)}function ko(e){switch(e.kind){case"leaf":return e.text;case"conditional":return ko(e.then);case"fallback":return ko(e.primary);case"concat":return e.parts.map(ko).join("");default:return e.kind==="raw"?`{{${e.source}}}`:""}}function tM(e){return e}function wr(e){if(typeof e!="string"||e.trim()==="")return"";if(Yn(e)===null&&/\{\{/.test(e)){for(const n of e.split(/(\{\{(?:[^}]|\}(?!\}))*\}\})/g))if(!(n===void 0||n===""))return/^\{\{[\s\S]*\}\}$/.test(n)?ko(cn(n).node):n;return""}return ko(cn(e).node)}function Cv(e){if(e.kind!=="leaf")return!1;const t=/^\{\{([\s\S]*)\}\}$/.exec(e.text.trim());return t?Jn(t[1].trim()):!1}function $v(e){if(e.kind!=="leaf")return!1;const t=e.text.trim();return!(/\{\{/.test(t)||t.startsWith("$t:"))}function kr(e){return`'${e.replace(/\\/g,"\\\\").replace(/'/g,"\\'")}'`}function Ev(e){const t=[];let n=0,o=0;const r=e.length;for(;on&&t.push(kr(e.slice(n,o)));let s=o+2;for(;s=r)break;const a=e.slice(o+2,s).trim();t.push(a),o=s+2,n=o;continue}o++}return nSo(t,"concat")).join(" + ");case"fallback":return`${So(e.primary,"fallback")} ${e.op} ${So(e.fallback,"fallback")}`;case"conditional":return`${Av(e.condition)} ? ${So(e.then,"conditional")} : ${So(e.else,"conditional")}`;default:return""}}function So(e,t){const n=qd(e);return e.kind==="leaf"||e.kind==="raw"?n:e.kind==="conditional"?t==="conditional"?n:`(${n})`:e.kind==="fallback"?t==="concat"?`(${n})`:n:(e.kind==="concat",n)}const Dv=["===","!==","??","||","&&","==","!=",">=","<="];function Gd(e){const t=[];let n=0;const o=e.length;for(;n=o)throw new Error("unterminated-binding-in-string");d+=e.slice(l,c+2),l=c+2;continue}if(e[l]===a){d+=a,l++;break}if(a==="`"&&e[l]==="$"&&e[l+1]==="{")throw new Error("template-literal-interpolation-unsupported");d+=e[l],l++}if(l>o||d[d.length-1]!==a)throw new Error("unterminated-string");t.push({kind:"string",value:d,start:n,end:l}),n=l;continue}if(r==="{"&&e[n+1]==="{"){let a=n+2;for(;a"||r==="<"){t.push({kind:"op",value:r,start:n,end:n+1}),n++;continue}if(r==="!"){t.push({kind:"op",value:"!",start:n,end:n+1}),n++;continue}if(r==="("){t.push({kind:"lparen",value:"(",start:n,end:n+1}),n++;continue}if(r===")"){t.push({kind:"rparen",value:")",start:n,end:n+1}),n++;continue}if(r==="["){t.push({kind:"lbracket",value:"[",start:n,end:n+1}),n++;continue}if(r==="]"){t.push({kind:"rbracket",value:"]",start:n,end:n+1}),n++;continue}if(r==="."){t.push({kind:"dot",value:".",start:n,end:n+1}),n++;continue}if(/[0-9]/.test(r)){let a=n;for(;a=n)return"";const o=this.toks[t].start,r=this.toks[n-1].end;return this.src.slice(o,r)}parseFallback(){let t=this.parseConcat(),n=this.peek();for(;n&&n.kind==="op"&&(n.value==="??"||n.value==="||");){const o=n.value;this.next();const r=this.parseConcat();t={kind:"fallback",op:o,primary:t,fallback:r},n=this.peek()}return t}parseConcat(){const t=this.parseUnaryOrPrimary(),n=[t];let o=this.peek();for(;o&&o.kind==="op"&&o.value==="+";)this.next(),n.push(this.parseUnaryOrPrimary()),o=this.peek();return n.length===1?t:{kind:"concat",parts:n}}parseUnaryOrPrimary(){return this.parsePrimary()}parsePrimary(){const t=this.peek();if(!t)throw new Error("unexpected-end");if(t.kind==="lparen"){this.next();const n=this.parseTernary(),o=this.next();if(!o||o.kind!=="rparen")throw new Error("expected-rparen");return n}if(t.kind==="string")return this.next(),{kind:"leaf",text:Xd(t.value)};if(t.kind==="tkey")return this.next(),{kind:"leaf",text:t.value};if(t.kind==="binding")return this.next(),{kind:"leaf",text:t.value};if(t.kind==="ident"&&kv.includes(t.value)&&this.toks[this.pos+1]?.kind==="lparen"){const n=this.tryParsePathWrapperCall(t.value);if(n!==null)return n;throw new Error("path-wrapper-non-simple-arg")}if(t.kind==="ident")return{kind:"leaf",text:`{{${this.parsePath()}}}`};if(t.kind==="number")return this.next(),{kind:"leaf",text:t.value};throw new Error(`unparseable-primary:${t.kind}`)}parsePath(){let t="";const n=this.next();if(!n||n.kind!=="ident")throw new Error("expected-ident");for(t+=n.value;;){const o=this.peek();if(!o)break;if(o.kind==="op"&&o.value==="?"&&this.toks[this.pos+1]?.kind==="dot"){this.next(),this.next();const r=this.next();if(!r||r.kind!=="ident")throw new Error("expected-ident-after-optional");t+="?."+r.value;continue}if(o.kind==="dot"){this.next();const r=this.next();if(!r||r.kind!=="ident")throw new Error("expected-ident-after-dot");t+="."+r.value;continue}if(o.kind==="lbracket"){this.next();const r=this.next(),s=this.next();if(!s||s.kind!=="rbracket")throw new Error("expected-rbracket");if(r&&(r.kind==="number"||r.kind==="string")){t+=`[${r.value}]`;continue}throw new Error("non-literal-index")}if(o.kind==="lparen")throw new Error("call-expression");break}return t}tryParsePathWrapperCall(t){const n=this.pos;this.next();const o=this.next();if(!o||o.kind!=="lparen")return this.pos=n,null;let r;try{if(this.peek()?.kind!=="ident")return this.pos=n,null;r=this.parsePath()}catch{return this.pos=n,null}const s=this.next();return!s||s.kind!=="rparen"?(this.pos=n,null):{kind:"leaf",text:`{{${t}(${r})}}`}}}function zv(e){return new Nv(e).parse()}const Mv=["===","!==","==","!=",">=","<=",">","<"];function Lv(e,t){const n=e.trim();if(n==="")return{kind:"raw",source:n};if(n===Ud)return{kind:"simple",left:"",op:"truthy",right:""};if(n.startsWith("!")){const s=n.slice(1).trim(),a=Yd(s);return Jn(a)?{kind:"simple",left:a,op:"falsy",right:""}:{kind:"raw",source:n}}const o=Pv(n);if(o){const{left:s,op:a,right:l}=o,d=s.trim(),c=l.trim();return Jn(d)&&(c==="''"||c==='""')?{kind:"simple",left:d,op:a,right:""}:Jn(d)&&Ov(c)?{kind:"simple",left:d,op:a,right:c}:{kind:"raw",source:n}}const r=Yd(n);return Jn(r)?{kind:"simple",left:r,op:"truthy",right:""}:{kind:"raw",source:n}}function Pv(e){let t=0,n=null;for(let o=0;o=o||!/[A-Za-z_$]/.test(t[n]))return!1;for(n++;n=o||!/[A-Za-z_$]/.test(t[n]))return!1;for(n++;n=2&&(t[0]==="'"||t[0]==='"'||t[0]==="`")?t.slice(1,-1).replace(/\\(['"`\\])/g,"$1"):t}function Bv(e,t){try{return Sr(e)===Sr(t)}catch{return!1}}function Sr(e){const t=Yn(e);return t!==null?Sr(t):Gd(e).map(o=>{if(o.kind==="string")return`S:${Xd(o.value)}`;if(o.kind==="binding"){const r=/^\{\{([\s\S]*)\}\}$/.exec(o.value);return`B:${r?Sr(r[1].trim()):o.value.replace(/\s+/g,"")}`}return o.kind==="tkey"?`S:${o.value}`:o.kind==="op"?`O:${o.value}`:o.kind==="ident"||o.kind==="number"?`I:${o.value}`:o.kind==="dot"?".":o.kind==="lbracket"?"[":o.kind==="rbracket"?"]":o.kind==="lparen"||o.kind==="rparen"?"":`X:${o.value}`}).join("")}function Zn({active:e,testid:t}){return i.jsx("div",{"data-testid":t,"data-active":e?"true":"false","aria-hidden":"true",style:e?jv:Wv})}const Wv={height:0,margin:0,transition:"height 80ms"},jv={height:3,margin:"1px 0",borderRadius:2,background:"#2563eb",boxShadow:"0 0 0 1px #93c5fd"};function us(e){const{length:t,onMove:n,canDrag:o,canDropAt:r}=e,[s,a]=u.useState(null),[l,d]=u.useState(null),c=u.useCallback(m=>{o&&!o(m)||(a(m),d(m))},[o]),p=u.useCallback(()=>{a(null),d(null)},[]),f=u.useCallback((m,h)=>{if(s===null)return;const v=h==="before"?m:m+1;if(r&&!r(s,v)){d(null);return}d(v)},[s,r]),y=u.useCallback(()=>{if(s!==null&&l!==null){const m=l>s?l-1:l;m!==s&&(!r||r(s,l))&&n(s,m)}a(null),d(null)},[s,l,n,r]),g=u.useCallback(m=>s!==null&&l===m&&m<=t,[s,l,t]);return{dragIndex:s,dropIndex:l,dragging:s!==null,onDragStart:c,onDragEnd:p,onDragOverItem:f,onDrop:y,isDropTarget:g}}const Hv={text:"",expression:"{{false ? '' : ''}}",fallback:"{{'' ?? ''}}",data:""};function Jd(e,t){const n=En(e);let o=t;const r=n.map(s=>({id:o++,kind:s.kind==="literal"?"text":"expression",value:s.raw}));return r.length===0&&r.push({id:o++,kind:"text",value:""}),{segs:r,nextSeq:o}}function ps({value:e,onChange:t,t:n,candidates:o,testidPrefix:r="g7le-seg-value",renderLeafInput:s}){const a=u.useRef(0),l=u.useRef(e),d=u.useMemo(()=>{const k=Jd(e,a.current);return a.current=k.nextSeq,k.segs},[]),[c,p]=u.useState(d),[f,y]=u.useState(!1);if(e!==l.current){l.current=e;const k=c.map(w=>w.value).join("");if(e!==k){const w=Jd(e,a.current);a.current=w.nextSeq,p(w.segs)}}const g=u.useCallback(k=>{p(k);const w=k.map(_=>_.value).join("");l.current=w,t(w)},[t]),m=u.useCallback((k,w)=>{g(c.map(_=>_.id===k?{..._,value:w??""}:_))},[c,g]),h=u.useCallback(k=>{const w=c.filter(_=>_.id!==k);g(w.length>0?w:[{id:a.current++,kind:"text",value:""}])},[c,g]),v=u.useCallback(k=>{g([...c,{id:a.current++,kind:k,value:Hv[k]}])},[c,g]),b=u.useCallback((k,w)=>{if(k===w||k<0||w<0||k>=c.length||w>=c.length)return;const _=c.slice(),[S]=_.splice(k,1);_.splice(w,0,S),g(_)},[c,g]),x=us({length:c.length,onMove:b});return i.jsxs("div",{"data-testid":r,style:Vv,children:[i.jsx("div",{style:Uv,children:n("layout_editor.value_tree.segmented_hint")}),c.map((k,w)=>i.jsxs(u.Fragment,{children:[i.jsx(Zn,{active:x.isDropTarget(w),testid:`${r}-dropline-${w}`}),i.jsx(Kv,{seg:k,index:w,t:n,candidates:o,testidPrefix:r,renderLeafInput:s,onValueChange:_=>m(k.id,_),onRemove:()=>h(k.id),dragging:x.dragIndex===w,onDragStartCard:()=>x.onDragStart(w),onDragEndCard:x.onDragEnd,onDragOverCard:_=>x.onDragOverItem(w,_),onDropCard:x.onDrop})]},k.id)),i.jsx(Zn,{active:x.isDropTarget(c.length),testid:`${r}-dropline-end`}),i.jsxs("div",{style:Zv,"data-testid":`${r}-add-row`,children:[i.jsxs("button",{type:"button","data-testid":`${r}-add-text`,onClick:()=>v("text"),style:Cr,children:["+ ",n("layout_editor.value_tree.segment.text")]}),i.jsxs("button",{type:"button","data-testid":`${r}-add-expression`,onClick:()=>v("expression"),style:Cr,children:["+ ",n("layout_editor.value_tree.segment.expression")]}),i.jsxs("button",{type:"button","data-testid":`${r}-add-fallback`,onClick:()=>v("fallback"),style:Cr,children:["+ ",n("layout_editor.value_tree.segment.fallback")]}),i.jsxs("button",{type:"button","data-testid":`${r}-add-data`,onClick:()=>v("data"),style:Cr,children:["+ ",n("layout_editor.value_tree.segment.data")]})]}),i.jsxs("div",{style:e_,children:[i.jsxs("button",{type:"button","data-testid":`${r}-source-toggle`,"aria-expanded":f,onClick:()=>y(k=>!k),style:t_,children:[""," ",n("layout_editor.value_tree.show_source")," ",f?"▴":"▾"]}),f&&i.jsx("code",{"data-testid":`${r}-source-code`,style:n_,children:c.map(k=>k.value).join("")})]})]})}function Fv({t:e,candidates:t,onPick:n,testidPrefix:o}){return!t||t.length===0?i.jsx("div",{"data-testid":`${o}-data-empty`,style:Rv,children:e("layout_editor.value_tree.segment.data_no_candidates")}):i.jsxs("div",{"data-testid":`${o}-data-picker`,style:{minWidth:0},children:[i.jsx("div",{style:Qv,children:e("layout_editor.value_tree.segment.data_pick_hint")}),i.jsx($t,{candidates:t,t:e,onSelect:r=>{n($n(r.sourceId,r.path,"scalar"))},testIdSuffix:`${o}-data`,defaultOpen:!0,floating:!0})]})}function Kv({seg:e,index:t,t:n,candidates:o,testidPrefix:r,renderLeafInput:s,onValueChange:a,onRemove:l,dragging:d,onDragStartCard:c,onDragEndCard:p,onDragOverCard:f,onDropCard:y}){const g=e.kind==="text"?"layout_editor.value_tree.segment.text":e.kind==="data"?"layout_editor.value_tree.segment.data":e.kind==="fallback"?"layout_editor.value_tree.segment.fallback":"layout_editor.value_tree.segment.expression";return i.jsxs("div",{"data-testid":`${r}-card-${t}`,"data-seg-kind":e.kind,style:d?{...Zd,opacity:.5}:Zd,onDragOver:m=>{m.preventDefault();const h=m.currentTarget.getBoundingClientRect();f(m.clientY{m.preventDefault(),y()},children:[i.jsxs("div",{style:qv,children:[i.jsx("span",{"data-testid":`${r}-drag-${t}`,style:Xv,draggable:!0,onDragStart:c,onDragEnd:p,title:n("layout_editor.value_tree.drag_reorder"),"aria-label":n("layout_editor.value_tree.drag_reorder"),role:"button",children:"⠿"}),i.jsx("span",{style:Gv,children:n(g)}),i.jsx("button",{type:"button","data-testid":`${r}-remove-${t}`,onClick:l,title:n("layout_editor.value_tree.segment.remove"),"aria-label":n("layout_editor.value_tree.segment.remove"),style:Jv,children:"✕"})]}),i.jsx("div",{style:Yv,children:e.kind==="data"&&e.value.trim()===""?i.jsx(Fv,{t:n,candidates:o,onPick:a,testidPrefix:`${r}-field-${t}`}):s?s({value:e.value,onChange:a,t:n,candidates:o,testidPrefix:`${r}-field-${t}`}):i.jsx(vt,{value:e.value,onChange:a,t:n,candidates:o,testidPrefix:`${r}-field-${t}`,enableExpressionTree:!0,expressionSourceToggle:!1})})]})}const Vv={display:"flex",flexDirection:"column",gap:6,width:"100%",minWidth:0},Uv={fontSize:11,color:"#64748b"},Zd={display:"flex",flexDirection:"column",gap:4,padding:"6px 8px",border:"1px solid #e2e8f0",borderLeft:"3px solid #6366f1",borderRadius:6,background:"#f8fafc",minWidth:0},qv={display:"flex",alignItems:"center",gap:6,minWidth:0},Gv={fontSize:10,fontWeight:600,color:"#6366f1",flex:1,minWidth:0},Yv={minWidth:0},Xv={color:"#94a3b8",cursor:"grab",fontSize:14,userSelect:"none"},Jv={border:"none",background:"transparent",color:"#94a3b8",cursor:"pointer",fontSize:12,padding:2,flexShrink:0},Zv={display:"flex",flexWrap:"wrap",gap:6,marginTop:2},Qv={fontSize:11,color:"#64748b",marginBottom:2},Rv={fontSize:11,color:"#94a3b8",padding:"4px 0"},Cr={padding:"4px 10px",fontSize:11,border:"1px dashed #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",whiteSpace:"nowrap"},e_={display:"flex",flexDirection:"column",gap:4,marginTop:2},t_={alignSelf:"flex-start",padding:"3px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer",fontFamily:"monospace"},n_={fontSize:11,background:"#0f172a",color:"#e2e8f0",padding:"6px 8px",borderRadius:4,wordBreak:"break-all",fontFamily:"monospace"},o_=[{value:"truthy",labelKey:"layout_editor.value_tree.cond.op_truthy"},{value:"falsy",labelKey:"layout_editor.value_tree.cond.op_falsy"},{value:"===",labelKey:"layout_editor.value_tree.cond.op_eq"},{value:"!==",labelKey:"layout_editor.value_tree.cond.op_neq"},{value:">",labelKey:"layout_editor.value_tree.cond.op_gt"},{value:"<",labelKey:"layout_editor.value_tree.cond.op_lt"},{value:">=",labelKey:"layout_editor.value_tree.cond.op_gte"},{value:"<=",labelKey:"layout_editor.value_tree.cond.op_lte"}];function Qd({node:e,onChange:t,t:n,candidates:o,testidPrefix:r="g7le-value-tree",depth:s=0,showSourceToggle:a=!0,renderLeafInput:l}){const[d,c]=u.useState(!1),p=s===0,f=i.jsx(Qn,{node:e,onChange:t,t:n,candidates:o,testidPrefix:r,depth:s,renderLeafInput:l});return p?a?i.jsxs("div",{"data-testid":r,style:ec,children:[f,i.jsxs("div",{style:k_,children:[i.jsxs("button",{type:"button","data-testid":`${r}-source-toggle`,"aria-expanded":d,onClick:()=>c(y=>!y),style:S_,children:[""," ",n("layout_editor.value_tree.show_source")," ",d?"▴":"▾"]}),d&&i.jsx("code",{"data-testid":`${r}-source-code`,style:C_,children:Xn(e,!1)})]})]}):i.jsx("div",{"data-testid":r,style:ec,children:f}):f}function Qn({node:e,onChange:t,t:n,candidates:o,testidPrefix:r,depth:s,renderLeafInput:a}){switch(e.kind){case"leaf":return i.jsx(ps,{value:e.text,onChange:l=>t({kind:"leaf",text:l}),t:n,candidates:o,testidPrefix:`${r}-leaf`,renderLeafInput:a});case"conditional":return i.jsx(r_,{node:e,onChange:t,t:n,candidates:o,testidPrefix:r,depth:s,renderLeafInput:a});case"fallback":return i.jsx(i_,{node:e,onChange:t,t:n,candidates:o,testidPrefix:r,depth:s,renderLeafInput:a});case"concat":return i.jsx(a_,{node:e,onChange:t,t:n,candidates:o,testidPrefix:r,depth:s,renderLeafInput:a});default:return i.jsxs("div",{"data-testid":`${r}-raw`,style:$_,children:[i.jsx("code",{style:oc,children:e.source??""}),i.jsxs("span",{style:rc,children:["🔒 ",n("layout_editor.value_tree.raw_code_only")]})]})}}function r_({node:e,onChange:t,t:n,candidates:o,testidPrefix:r,depth:s,renderLeafInput:a}){const l=u.useCallback(p=>t({...e,condition:p}),[e,t]),d=u.useCallback(p=>t({...e,then:p}),[e,t]),c=u.useCallback(p=>t({...e,else:p}),[e,t]);return i.jsxs("div",{"data-testid":`${r}-conditional`,style:fs(s),children:[i.jsx(l_,{condition:e.condition,onChange:l,t:n,candidates:o,testidPrefix:r}),i.jsx($r,{label:n("layout_editor.value_tree.cond.when_true"),testid:`${r}-then`,children:i.jsx(Qn,{node:e.then,onChange:d,t:n,candidates:o,testidPrefix:`${r}-then`,depth:s+1,renderLeafInput:a})}),i.jsx($r,{label:n("layout_editor.value_tree.cond.when_false"),testid:`${r}-else`,children:i.jsx(Qn,{node:e.else,onChange:c,t:n,candidates:o,testidPrefix:`${r}-else`,depth:s+1,renderLeafInput:a})})]})}function i_({node:e,onChange:t,t:n,candidates:o,testidPrefix:r,depth:s,renderLeafInput:a}){const l=u.useCallback(c=>t({...e,primary:c}),[e,t]),d=u.useCallback(c=>t({...e,fallback:c}),[e,t]);return i.jsxs("div",{"data-testid":`${r}-fallback`,style:fs(s),children:[i.jsx($r,{label:n("layout_editor.value_tree.fallback.primary"),testid:`${r}-primary`,children:i.jsx(Qn,{node:e.primary,onChange:l,t:n,candidates:o,testidPrefix:`${r}-primary`,depth:s+1,renderLeafInput:a})}),i.jsx($r,{label:n(e.op==="??"?"layout_editor.value_tree.fallback.when_empty":"layout_editor.value_tree.fallback.when_falsy"),testid:`${r}-fallback-branch`,children:i.jsx(Qn,{node:e.fallback,onChange:d,t:n,candidates:o,testidPrefix:`${r}-fallbackb`,depth:s+1,renderLeafInput:a})})]})}const s_={text:()=>({kind:"leaf",text:""}),data:()=>({kind:"leaf",text:""}),expression:()=>({kind:"conditional",condition:{kind:"simple",left:"",op:"truthy",right:""},then:{kind:"leaf",text:""},else:{kind:"leaf",text:""}}),fallback:()=>({kind:"fallback",op:"??",primary:{kind:"leaf",text:""},fallback:{kind:"leaf",text:""}})};function a_({node:e,onChange:t,t:n,candidates:o,testidPrefix:r,depth:s,renderLeafInput:a}){const l=u.useCallback((g,m)=>{const h=e.parts.slice();h[g]=m,t({...e,parts:h})},[e,t]),d=u.useCallback(g=>{g.length===0?t({kind:"leaf",text:""}):g.length===1?t(g[0]):t({...e,parts:g})},[e,t]),c=u.useCallback(g=>d(e.parts.filter((m,h)=>h!==g)),[e.parts,d]),p=u.useCallback(g=>d([...e.parts,s_[g]()]),[e.parts,d]),f=u.useCallback((g,m)=>{if(g===m||g<0||m<0||g>=e.parts.length||m>=e.parts.length)return;const h=e.parts.slice(),[v]=h.splice(g,1);h.splice(m,0,v),d(h)},[e.parts,d]),y=us({length:e.parts.length,onMove:f});return i.jsxs("div",{"data-testid":`${r}-concat`,style:fs(s),children:[i.jsx("div",{style:b_,children:n("layout_editor.value_tree.concat.hint")}),e.parts.map((g,m)=>i.jsxs(u.Fragment,{children:[i.jsx(Zn,{active:y.isDropTarget(m),testid:`${r}-part-dropline-${m}`}),i.jsxs("div",{"data-testid":`${r}-part-card-${m}`,style:y.dragIndex===m?{...nc,opacity:.5}:nc,onDragOver:h=>{h.preventDefault();const v=h.currentTarget.getBoundingClientRect();y.onDragOverItem(m,h.clientY{h.preventDefault(),y.onDrop()},children:[i.jsxs("div",{style:m_,children:[i.jsx("span",{"data-testid":`${r}-part-handle-${m}`,style:x_,draggable:!0,onDragStart:()=>y.onDragStart(m),onDragEnd:y.onDragEnd,title:n("layout_editor.value_tree.drag_reorder"),"aria-label":n("layout_editor.value_tree.drag_reorder"),role:"button",children:"⠿"}),i.jsx("span",{style:v_,children:n("layout_editor.value_tree.concat.part",{n:m+1})}),i.jsx("button",{type:"button","data-testid":`${r}-part-remove-${m}`,onClick:()=>c(m),title:n("layout_editor.value_tree.segment.remove"),"aria-label":n("layout_editor.value_tree.segment.remove"),style:w_,children:"✕"})]}),i.jsx("div",{style:{minWidth:0},children:i.jsx(Qn,{node:g,onChange:h=>l(m,h),t:n,candidates:o,testidPrefix:`${r}-part-${m}`,depth:s+1,renderLeafInput:a})})]})]},m)),i.jsx(Zn,{active:y.isDropTarget(e.parts.length),testid:`${r}-part-dropline-end`}),i.jsxs("div",{style:__,"data-testid":`${r}-part-add-row`,children:[i.jsxs("button",{type:"button","data-testid":`${r}-part-add-text`,onClick:()=>p("text"),style:Er,children:["+ ",n("layout_editor.value_tree.segment.text")]}),i.jsxs("button",{type:"button","data-testid":`${r}-part-add-expression`,onClick:()=>p("expression"),style:Er,children:["+ ",n("layout_editor.value_tree.segment.expression")]}),i.jsxs("button",{type:"button","data-testid":`${r}-part-add-fallback`,onClick:()=>p("fallback"),style:Er,children:["+ ",n("layout_editor.value_tree.segment.fallback")]}),i.jsxs("button",{type:"button","data-testid":`${r}-part-add-data`,onClick:()=>p("data"),style:Er,children:["+ ",n("layout_editor.value_tree.segment.data")]})]})]})}function l_({condition:e,onChange:t,t:n,candidates:o,testidPrefix:r}){if(e.kind==="raw")return i.jsxs("div",{"data-testid":`${r}-cond-raw`,style:f_,children:[i.jsx("span",{style:tc,children:n("layout_editor.value_tree.cond.label")}),i.jsx("code",{style:oc,children:e.source}),i.jsxs("span",{style:rc,children:["🔒 ",n("layout_editor.value_tree.cond.code_only")]})]});const s=e,a=s.op!=="truthy"&&s.op!=="falsy";return i.jsxs("div",{"data-testid":`${r}-cond`,style:p_,children:[i.jsx("span",{style:tc,children:n("layout_editor.value_tree.cond.label")}),i.jsx(Rd,{testid:`${r}-cond-left`,value:s.left,onCommit:l=>t({...s,left:l}),placeholder:n("layout_editor.value_tree.cond.field_placeholder"),candidates:o,t:n}),i.jsx("select",{"data-testid":`${r}-cond-op`,value:s.op,onChange:l=>t({...s,op:l.target.value}),style:y_,children:o_.map(l=>i.jsx("option",{value:l.value,children:n(l.labelKey)},l.value))}),a&&i.jsx(Rd,{testid:`${r}-cond-right`,value:s.right,onCommit:l=>t({...s,right:l}),placeholder:n("layout_editor.value_tree.cond.value_placeholder"),candidates:o,t:n,quoteDataPick:!0})]})}function Rd({testid:e,value:t,onCommit:n,placeholder:o,candidates:r,t:s,quoteDataPick:a=!1}){const[l,d]=u.useState(null),c=l!==null?l:t,p=()=>{l!==null&&(l!==t&&n(l),d(null))},f=(y,g)=>{const m=$n(y,g,"scalar"),h=/^\{\{\s*([\s\S]*?)\s*\}\}$/.exec(m.trim());let v=h?h[1]:m;v=v.replace(/\s*\?\?\s*''$/,"").trim(),n(a?`'${v}'`:v),d(null)};return i.jsxs("span",{style:g_,children:[i.jsx("input",{type:"text","data-testid":e,value:c,onChange:y=>d(y.target.value),onBlur:p,onKeyDown:y=>{y.key==="Enter"&&(y.preventDefault(),p())},placeholder:o,style:h_}),r&&r.length>0&&s&&i.jsx($t,{candidates:r,t:s,onSelect:y=>f(y.sourceId,y.path),testIdSuffix:`${e}-data`,floating:!0})]})}function $r({label:e,testid:t,children:n}){return i.jsxs("div",{"data-testid":t,style:d_,children:[i.jsx("span",{style:c_,children:e}),i.jsx("div",{style:u_,children:n})]})}const ec={display:"flex",flexDirection:"column",gap:6,width:"100%",minWidth:0},fs=e=>({display:"flex",flexDirection:"column",gap:6,padding:"8px",border:"1px solid #e2e8f0",borderLeft:`3px solid ${e%2===0?"#6366f1":"#0ea5e9"}`,borderRadius:6,background:e%2===0?"#f8fafc":"#fefefe",minWidth:0}),d_={display:"flex",flexDirection:"column",gap:3,minWidth:0},c_={fontSize:11,fontWeight:600,color:"#475569"},u_={paddingLeft:8,minWidth:0},p_={display:"flex",flexWrap:"wrap",gap:4,alignItems:"center",minWidth:0},f_={display:"flex",flexWrap:"wrap",gap:4,alignItems:"center",minWidth:0},tc={fontSize:11,fontWeight:600,color:"#0f172a",whiteSpace:"nowrap"},g_={display:"inline-flex",alignItems:"flex-start",gap:2,flex:"1 1 80px",minWidth:60},h_={flex:"1 1 80px",minWidth:60,padding:"4px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6},y_={padding:"4px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff"},b_={fontSize:11,color:"#94a3b8"},nc={display:"flex",flexDirection:"column",gap:4,padding:"6px 8px",border:"1px solid #e2e8f0",borderLeft:"3px solid #6366f1",borderRadius:6,background:"#fff",minWidth:0},m_={display:"flex",alignItems:"center",gap:6,minWidth:0},v_={fontSize:10,fontWeight:600,color:"#6366f1",flex:1,minWidth:0},__={display:"flex",flexWrap:"wrap",gap:6,marginTop:2},x_={color:"#94a3b8",cursor:"grab",fontSize:14,userSelect:"none"},w_={border:"none",background:"transparent",color:"#94a3b8",cursor:"pointer",fontSize:12,padding:2,flexShrink:0},Er={padding:"4px 10px",fontSize:11,border:"1px dashed #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",whiteSpace:"nowrap"},k_={display:"flex",flexDirection:"column",gap:4,marginTop:2},S_={alignSelf:"flex-start",padding:"3px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer",fontFamily:"monospace"},C_={fontSize:11,background:"#0f172a",color:"#e2e8f0",padding:"6px 8px",borderRadius:4,wordBreak:"break-all",fontFamily:"monospace"},$_={display:"flex",flexDirection:"column",gap:2,minWidth:0},oc={fontSize:11,background:"#f1f5f9",padding:"3px 6px",borderRadius:4,color:"#0f172a",wordBreak:"break-all",fontFamily:"monospace"},rc={fontSize:10,color:"#64748b"};function vt({value:e,onChange:t,t:n,placeholder:o,testidPrefix:r="g7le-i18n-text-field",locales:s,candidates:a,enableExpressionTree:l=!1,expressionTreeCollapsible:d=!1,expressionSourceToggle:c=!0}){const{locale:p,templateIdentifier:f,commitText:y,classify:g,translate:m}=ov(),[h,v]=u.useState(null),[b,x]=u.useState(!1),[k,w]=u.useState(!1),[_,S]=u.useState(!1),[E,C]=u.useState(!1),[N,$]=u.useState(!1),[,A]=u.useState(0);u.useEffect(()=>{if(typeof window>"u")return;const T=H=>{const L=H.detail;L?.templateIdentifier&&L.templateIdentifier!==f||A(I=>I+1)};return window.addEventListener(kt,T),()=>window.removeEventListener(kt,T)},[f]);const z=g(e);u.useEffect(()=>{v(null),$(!1)},[p,e]);const D=h!==null?h:z.displayValue,O=u.useCallback(async()=>{if(h!==null){if(h===z.displayValue){v(null);return}x(!0);try{const T=await y(e,h);T.kind==="created"&&T.token?(t(T.token),Et({op:"prop_i18n_create_key",sourceState:"plain_text",translationKey:T.customKey,toLocale:p,valueLength:h.length,timestamp:Date.now()})):T.kind==="updated"&&Et({op:"prop_i18n_update_value",sourceState:"custom_key",translationKey:T.customKey,toLocale:p,valueLength:h.length,timestamp:Date.now()})}finally{x(!1),v(null)}}},[h,z.displayValue,y,e,t,p]),M=u.useCallback(T=>{typeof e=="string"&&(t(ho(e,T)),z.customKey&&xr(f,z.customKey,T,p))},[e,t,z.customKey,f,p]);if(z.binding){if(l&&typeof e=="string"){const T=Kd(e),H=cn(e),L=!T&&Kt(e)!==null,I=(T||H.decomposed)&&!L;if(I&&d&&!_)return i.jsx(A_,{value:e,t:n,translate:m,onEdit:()=>S(!0),testidPrefix:`${r}-preview`});const F=I&&(d||T)?i.jsx(ps,{value:e,onChange:G=>t(G),t:n,candidates:a,testidPrefix:`${r}-seg`}):H.decomposed?i.jsx(Qd,{node:H.node,onChange:G=>t(Xn(G,!1)),t:n,candidates:a,testidPrefix:`${r}-tree`,showSourceToggle:c}):null;if(F){if(!d)return F;const G=wr(e),V=G?ds(G,Y=>{const Z=m(Y);return Z&&Z!==Y&&!Z.startsWith("$t:")?Z:""},Y=>Vt(Y)):[];return i.jsxs("div",{"data-testid":`${r}-builder`,style:{display:"flex",flexDirection:"column",gap:6,width:"100%",minWidth:0},children:[i.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",gap:6},children:[i.jsxs("button",{type:"button","data-testid":`${r}-to-plain`,onClick:()=>C(!0),style:P_,title:n("layout_editor.value_tree.to_plain"),"aria-label":n("layout_editor.value_tree.to_plain"),children:["↩ ",n("layout_editor.value_tree.to_plain")]}),i.jsxs("button",{type:"button","data-testid":`${r}-builder-collapse`,onClick:()=>S(!1),style:ac,title:n("layout_editor.value_tree.collapse"),"aria-label":n("layout_editor.value_tree.collapse"),children:["▴ ",n("layout_editor.value_tree.collapse")]})]}),E&&i.jsxs("div",{"data-testid":`${r}-to-plain-confirm`,style:O_,children:[i.jsx("p",{style:B_,children:n("layout_editor.value_tree.to_plain_lead")}),i.jsx("div",{style:W_,children:V.length===0?i.jsx("span",{style:{color:"#94a3b8"},children:n("layout_editor.prop_i18n.placeholder")}):V.map((Y,Z)=>Y.kind==="chip"?i.jsxs("span",{style:ic,children:["🔗 ",Y.text]},Z):i.jsx("span",{style:Y.kind==="ellipsis"?sc:void 0,children:Y.text},Z))}),i.jsxs("p",{style:j_,children:["⚠ ",n("layout_editor.value_tree.to_plain_warn")]}),i.jsxs("div",{style:{display:"flex",justifyContent:"flex-end",gap:6},children:[i.jsx("button",{type:"button","data-testid":`${r}-to-plain-confirm-cancel`,onClick:()=>C(!1),style:ac,children:n("layout_editor.value_tree.to_plain_cancel")}),i.jsx("button",{type:"button","data-testid":`${r}-to-plain-confirm-ok`,onClick:()=>{C(!1),S(!1),t(wr(e)||void 0)},style:H_,children:n("layout_editor.value_tree.to_plain_ok")})]})]}),F]})}}return l&&typeof e=="string"&&a&&a.length>0&&Kt(e)!==null?i.jsx(T_,{value:e,onChange:t,t:n,candidates:a,testidPrefix:`${r}-data`}):i.jsxs("div",{"data-testid":`${r}-binding`,style:G_,children:[i.jsx("code",{style:Y_,children:typeof e=="string"?e:""}),i.jsxs("span",{style:X_,children:["🔗 ",n("layout_editor.prop_i18n.bound_code_only")]})]})}if(!N&&typeof e=="string"&&Kl(e)){const T=Vl(e);return i.jsxs("div",{"data-testid":`${r}-settings-ref`,style:V_,children:[i.jsx("div",{"data-testid":`${r}-settings-ref-chips`,style:U_,children:T.map((H,L)=>H.kind==="chip"?i.jsxs("span",{style:q_,children:["🔗 ",H.label]},L):i.jsx("span",{children:H.label},L))}),i.jsxs("button",{type:"button","data-testid":`${r}-settings-ref-edit`,onClick:()=>$(!0),style:lc,title:n("layout_editor.value_tree.edit"),"aria-label":n("layout_editor.value_tree.edit"),children:["✎ ",n("layout_editor.value_tree.edit")]})]})}const B=i.jsxs("button",{type:"button","data-testid":`${r}-toggle`,title:`${n("layout_editor.prop_i18n.edit_languages")} (${Un(p,n)})`,"aria-label":`${n("layout_editor.prop_i18n.edit_languages")} (${Un(p,n)})`,"aria-expanded":k,onClick:()=>w(T=>!T),style:lc,children:["🌐 ",z.customKey?k?"⌃":"⌄":"+"]});return i.jsxs("div",{"data-testid":r,style:{display:"flex",flexDirection:"column",gap:4,width:"100%"},children:[i.jsx("div",{style:F_,children:z.paramKey&&z.customKey?i.jsx(E_,{customKey:z.customKey,value:typeof e=="string"?e:"",onChange:t,t:n,candidates:a,testidPrefix:r,showExprButton:l&&d,onToExpression:()=>{S(!0),t(wo(typeof e=="string"?e:""))},langToggle:B}):i.jsxs(i.Fragment,{children:[i.jsx("input",{type:"text","data-testid":`${r}-preview`,value:D,placeholder:o??n("layout_editor.prop_i18n.placeholder"),onChange:T=>v(T.target.value),onBlur:()=>{O(),N&&$(!1)},disabled:b,style:K_}),a&&a.length>0&&i.jsx(I_,{value:typeof e=="string"?e:"",customKey:z.customKey,onChange:t,t:n,candidates:a,testidPrefix:r}),l&&d&&i.jsx("button",{type:"button","data-testid":`${r}-to-expr`,onClick:()=>{S(!0),t(wo(typeof e=="string"?e:""))},title:n("layout_editor.value_tree.to_expression"),"aria-label":n("layout_editor.value_tree.to_expression"),style:Co,children:"ƒx"}),B]})}),k&&(z.customKey?i.jsx(Hd,{customKey:z.customKey,templateIdentifier:f,t:n,paramLabels:as(e),locales:s,onRemoveParam:M},z.customKey):i.jsx("div",{"data-testid":`${r}-expand-hint`,style:J_,children:n("layout_editor.prop_i18n.create_first")}))]})}function E_({customKey:e,value:t,onChange:n,t:o,candidates:r,testidPrefix:s,showExprButton:a=!1,onToExpression:l,langToggle:d}){const{state:c}=Je(),p=c.templateIdentifier,f=c.locale,y=Ft(),[g,m]=u.useState(null),h=u.useRef(""),v=u.useRef(null),b=u.useRef(null),x=u.useRef(null),[k,w]=u.useState(null),[_,S]=u.useState(!1),E=as(t),C=u.useCallback(async()=>{const D=It(e,f);if(D!==void 0){h.current=D,m(D);return}const M=(await dn(p,e))?.values?.[f]??"";h.current=M,m(M)},[e,p,f]);u.useEffect(()=>{let D=!1;return(async()=>{const O=It(e,f);if(O!==void 0){D||(h.current=O,m(O));return}const M=await dn(p,e);if(D)return;const B=M?.values?.[f]??"";h.current=B,m(B)})(),()=>{D=!0}},[e,p,f]),u.useEffect(()=>{if(typeof window>"u")return;const D=O=>{const M=O.detail;if(M?.templateIdentifier&&M.templateIdentifier!==p)return;const B=It(e,f);B!==void 0&&B!==g&&(h.current=B,m(B))};return window.addEventListener(kt,D),()=>window.removeEventListener(kt,D)},[e,p,f,g]);const N=u.useCallback(D=>{m(D),_o(p,e,f,D).then(()=>{y?.markDirty?.()})},[p,e,f,y]),$=u.useCallback(D=>{n(ho(t,D)),xr(p,e,D,f).then(()=>{C(),y?.markDirty?.()})},[t,n,p,e,f,C,y]),A=u.useCallback(async D=>{if(!(k===null||_)){S(!0);try{g!==null&&g!==h.current&&(await _o(p,e,f,g),h.current=g);const O=await _r(p,f,t,k,D.sourceId,D.path,"scalar");O.kind==="ok"&&(n(O.text),await C(),y?.markDirty?.())}finally{S(!1),w(null)}}},[k,_,g,t,p,e,f,n,C,y]),z=!!r&&r.length>0;return i.jsxs("div",{"data-testid":`${s}-chipfield-wrap`,style:{flex:1,minWidth:0,display:"flex",alignItems:"center",gap:4},children:[i.jsx("div",{ref:v,"data-testid":`${s}-chipfield`,style:{flex:1,minWidth:0},children:g===null?i.jsx("div",{"data-testid":`${s}-chipfield-loading`,style:{fontSize:11,color:"#94a3b8",padding:"4px 0"},children:o("layout_editor.translation.loading")}):i.jsx(yr,{value:g,onChange:N,t:o,paramLabels:E,testIdSuffix:`${s}-field`,onRequestInsert:z?D=>w(D):void 0,onRemoveChip:$,hideInsertButton:!0,caretRef:x})}),z&&i.jsx("button",{ref:b,type:"button","data-testid":`${s}-plus-data-btn`,onClick:()=>w(x.current?x.current():g?.length??0),title:o("layout_editor.inline_binding.insert_data"),"aria-label":o("layout_editor.inline_binding.insert_data"),style:Co,children:"+"}),a&&l&&i.jsx("button",{type:"button","data-testid":`${s}-to-expr`,onClick:l,title:o("layout_editor.value_tree.to_expression"),"aria-label":o("layout_editor.value_tree.to_expression"),style:Co,children:"ƒx"}),d,z&&i.jsx(Gn,{anchorRef:b,open:k!==null,onClose:()=>w(null),children:i.jsx("div",{"data-testid":`${s}-chipfield-picker`,children:i.jsx($t,{candidates:r,t:o,onSelect:D=>{A(D)},testIdSuffix:`${s}-field-insert`,defaultOpen:!0,floating:!1})})})]})}function I_({value:e,customKey:t,onChange:n,t:o,candidates:r,testidPrefix:s}){const{state:a}=Je(),l=a.templateIdentifier,d=a.selectedRoute?.layoutName??null,c=a.locale,p=Ft(),[f,y]=u.useState(!1),[g,m]=u.useState(!1),h=u.useRef(null),v=u.useCallback(k=>{const w=o(k);return w&&w!==k&&!w.startsWith("$t:")?w:""},[o]),b=u.useCallback(k=>k.replace(/\$t:[a-zA-Z0-9._-]+/g,w=>v(w.slice(3))).replace(/\s+/g," ").trim(),[v]),x=u.useCallback(async k=>{if(!g){m(!0);try{if(t){const w=await _r(l,c,e,Number.MAX_SAFE_INTEGER,k.sourceId,k.path,"scalar");w.kind==="ok"&&(n(w.text),p?.markDirty?.())}else{const w=await ss(l,d,c,e,e.length,k.sourceId,k.path,"scalar",b,_=>o(_));w.kind==="ok"&&(n(w.text),p?.markDirty?.())}}finally{m(!1),y(!1)}}},[g,t,l,d,c,e,n,p,o,b]);return i.jsxs("div",{"data-testid":`${s}-plus-data`,style:{display:"inline-block"},children:[i.jsx("button",{ref:h,type:"button","data-testid":`${s}-plus-data-btn`,disabled:g,onClick:()=>y(k=>!k),title:o("layout_editor.inline_binding.insert_data"),"aria-label":o("layout_editor.inline_binding.insert_data"),style:Co,children:"+"}),i.jsx(Gn,{anchorRef:h,open:f,onClose:()=>y(!1),children:i.jsx($t,{candidates:r,t:o,onSelect:k=>{x(k)},testIdSuffix:`${s}-plus-insert`,defaultOpen:!0,floating:!1})})]})}function A_({value:e,t,translate:n,onEdit:o,testidPrefix:r}){const s=ds(e,a=>{const l=n(a);return l&&l!==a&&!l.startsWith("$t:")?l:""},a=>Vt(a));return i.jsxs("div",{"data-testid":r,style:z_,children:[i.jsx("div",{"data-testid":`${r}-text`,style:M_,children:s.length===0?i.jsx("span",{style:{color:"#94a3b8"},children:t("layout_editor.prop_i18n.placeholder")}):s.map((a,l)=>a.kind==="chip"?i.jsxs("span",{style:ic,children:["🔗 ",a.text]},l):i.jsx("span",{style:a.kind==="ellipsis"?sc:void 0,children:a.text},l))}),i.jsxs("button",{type:"button","data-testid":`${r}-edit`,onClick:o,style:L_,title:t("layout_editor.value_tree.edit"),"aria-label":t("layout_editor.value_tree.edit"),children:["✎ ",t("layout_editor.value_tree.edit")]})]})}function T_({value:e,onChange:t,t:n,candidates:o,testidPrefix:r}){const[s,a]=u.useState(!1),l=u.useRef(null),d=Vt(e);return i.jsxs("div",{"data-testid":r,style:{display:"flex",flexDirection:"column",gap:4,width:"100%",minWidth:0},children:[i.jsxs("div",{style:D_,children:[i.jsxs("span",{"data-testid":`${r}-chip`,style:N_,children:["🔗 ",d]}),i.jsx("button",{ref:l,type:"button","data-testid":`${r}-change`,onClick:()=>a(c=>!c),style:Co,title:n("layout_editor.value_tree.change_data"),"aria-label":n("layout_editor.value_tree.change_data"),children:n("layout_editor.value_tree.change_data")})]}),i.jsx(Gn,{anchorRef:l,open:s,onClose:()=>a(!1),children:i.jsx($t,{candidates:o,t:n,onSelect:c=>{const p=c.path?c.path.split(".").filter(Boolean):[],f=[c.sourceId,...p].join("?.");t(`{{${f}}}`),a(!1)},testIdSuffix:`${r}-pick`,defaultOpen:!0,floating:!1})})]})}const D_={display:"flex",alignItems:"center",gap:6,width:"100%",minWidth:0,flexWrap:"wrap"},N_={fontSize:11,background:"#eef2ff",color:"#4338ca",padding:"3px 8px",borderRadius:12,wordBreak:"break-all"},z_={display:"flex",alignItems:"center",gap:8,width:"100%",minWidth:0},M_={flex:1,minWidth:0,display:"flex",flexWrap:"wrap",alignItems:"center",gap:4,padding:"5px 8px",fontSize:12,color:"#334155",border:"1px solid #e2e8f0",borderRadius:6,background:"#f8fafc",overflow:"hidden"},ic={fontSize:11,background:"#eef2ff",color:"#4338ca",padding:"1px 6px",borderRadius:10,whiteSpace:"nowrap"},sc={color:"#94a3b8"},L_={padding:"4px 10px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#2563eb",cursor:"pointer",whiteSpace:"nowrap",flexShrink:0},ac={padding:"3px 10px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer",whiteSpace:"nowrap"},P_={padding:"3px 10px",fontSize:11,border:"1px solid #fca5a5",borderRadius:6,background:"#fef2f2",color:"#b91c1c",cursor:"pointer",whiteSpace:"nowrap"},O_={display:"flex",flexDirection:"column",gap:6,padding:10,border:"1px solid #fca5a5",borderRadius:8,background:"#fffbeb"},B_={margin:0,fontSize:12,color:"#334155"},W_={padding:"5px 8px",fontSize:12,color:"#0f172a",background:"#fff",border:"1px solid #e2e8f0",borderRadius:6,wordBreak:"break-all"},j_={margin:0,fontSize:11,color:"#b45309"},H_={padding:"3px 12px",fontSize:11,border:"1px solid #dc2626",borderRadius:6,background:"#dc2626",color:"#fff",cursor:"pointer",whiteSpace:"nowrap"},F_={display:"flex",gap:4,alignItems:"center",width:"100%",minWidth:0},K_={flex:1,padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,minWidth:0,width:"100%"},lc={padding:"4px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer",whiteSpace:"nowrap"},Co={padding:"4px 8px",fontSize:13,lineHeight:1,border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer",whiteSpace:"nowrap"},V_={display:"flex",alignItems:"center",gap:6,width:"100%",minWidth:0,flexWrap:"wrap"},U_={flex:1,minWidth:0,display:"flex",flexWrap:"wrap",alignItems:"center",gap:4,fontSize:12,color:"#334155"},q_={fontSize:11,background:"#eef2ff",color:"#4338ca",padding:"2px 8px",borderRadius:12,whiteSpace:"nowrap"},G_={display:"flex",flexDirection:"column",gap:2,width:"100%"},Y_={fontSize:11,background:"#f1f5f9",padding:"3px 6px",borderRadius:4,color:"#0f172a",wordBreak:"break-all"},X_={fontSize:10,color:"#64748b"},J_={fontSize:11,color:"#94a3b8",padding:"6px 8px",border:"1px dashed #cbd5e1",borderRadius:6,background:"#f8fafc"};function gs({value:e,onChange:t,candidates:n,t:o}){const r=Array.isArray(e)?e:[],s=n??[],[a,l]=u.useState(!1),d=g=>s.find(m=>m.value===g)?.label??g,c=g=>d(g)!==g,p=g=>{const m=r.filter(h=>h!==g);t(m.length===0?void 0:m)},f=g=>{r.includes(g)||(t([...r,g]),l(!1))},y=s.filter(g=>!r.includes(g.value));return i.jsxs("div",{className:"g7le-widget g7le-widget--tag-input","data-testid":"g7le-widget-tag-input",style:Z_,children:[i.jsxs("div",{style:Q_,children:[r.map(g=>i.jsxs("span",{"data-testid":`g7le-tag-chip-${g}`,style:R_,children:[i.jsx("span",{style:ex,children:d(g)}),c(g)&&i.jsx("code",{"data-testid":`g7le-tag-chip-id-${g}`,style:tx,children:g}),i.jsx("button",{type:"button","aria-label":`remove ${g}`,"data-testid":`g7le-tag-remove-${g}`,onClick:()=>p(g),style:nx,children:"✕"})]},g)),i.jsx("button",{type:"button","data-testid":"g7le-tag-add",onClick:()=>l(g=>!g),disabled:y.length===0,style:ox,children:o("layout_editor.control.tag_add")})]}),a&&y.length>0&&i.jsx("div",{"data-testid":"g7le-tag-candidates",style:rx,children:y.map(g=>{const m=g.value!==g.label;return i.jsxs("button",{type:"button","data-testid":`g7le-tag-candidate-${g.value}`,onClick:()=>f(g.value),style:ix,children:[i.jsx("span",{style:sx,children:g.label}),m&&i.jsx("code",{"data-testid":`g7le-tag-candidate-id-${g.value}`,style:ax,children:g.value})]},g.value)})})]})}const Z_={display:"flex",flexDirection:"column",gap:6},Q_={display:"flex",flexWrap:"wrap",gap:4,alignItems:"center"},R_={display:"inline-flex",alignItems:"center",gap:4,padding:"2px 6px",fontSize:12,background:"#e0e7ff",color:"#1d4ed8",borderRadius:12},ex={fontSize:12},tx={fontSize:10,color:"#4f46e5",background:"#c7d2fe",borderRadius:4,padding:"0 4px",fontFamily:"monospace"},nx={border:"none",background:"transparent",color:"#1d4ed8",cursor:"pointer",fontSize:10,padding:0,lineHeight:1},ox={padding:"2px 8px",fontSize:12,border:"1px dashed #94a3b8",borderRadius:12,background:"#fff",cursor:"pointer",color:"#475569"},rx={display:"flex",flexDirection:"column",maxHeight:180,overflowY:"auto",border:"1px solid #cbd5e1",borderRadius:6,background:"#fff"},ix={display:"flex",flexDirection:"column",gap:1,textAlign:"left",padding:"6px 10px",fontSize:12,border:"none",borderBottom:"1px solid #f1f5f9",background:"transparent",cursor:"pointer",color:"#0f172a"},sx={fontSize:12,color:"#0f172a"},ax={fontSize:10,color:"#64748b",fontFamily:"monospace"},dc=new Map;function Xe(e,t){dc.set(e,t)}function An(e){return e?dc.get(e)??null:null}function Ir(e,t){const o=e("meta",{})?.[t];return typeof o=="string"?o:""}function lx({getValue:e,patch:t,t:n,fieldT:o,spec:r,permissionCandidates:s,bindingCandidates:a}){const l=o??n,d=Ir(e,"title"),c=Ir(e,"description"),p=Ir(e,"icon"),f=Ir(e,"editor_label"),y=e("permissions",[]),g=u.useCallback((b,x)=>{const k={...e("meta",{})??{}};x===void 0||x===""?delete k[b]:k[b]=x,t("meta",k)},[e,t]),m=u.useCallback(b=>{const x=Array.isArray(b)?b:[];x.length===0?t("permissions",void 0):t("permissions",x)},[t]),h=An("icon-picker"),v=Ct(r,"icon-picker")??Ct(r,"icon")??(r?.controls?Object.values(r.controls).find(b=>b&&typeof b=="object"&&b.widget==="icon-picker")??null:null);return i.jsxs("div",{className:"g7le-meta-form","data-testid":"g7le-meta-form",style:cx,children:[i.jsx($o,{label:n("layout_editor.page_settings.meta.title"),children:i.jsx(vt,{value:d,onChange:b=>g("title",b),t:l,testidPrefix:"g7le-meta-title",candidates:a,enableExpressionTree:!0,expressionTreeCollapsible:!0})}),i.jsx($o,{label:n("layout_editor.page_settings.meta.description"),children:i.jsx(vt,{value:c,onChange:b=>g("description",b),t:l,testidPrefix:"g7le-meta-description",candidates:a,enableExpressionTree:!0,expressionTreeCollapsible:!0})}),i.jsx($o,{label:n("layout_editor.page_settings.meta.icon"),children:h?i.jsx(dx,{icon:p,t:n,onChange:b=>g("icon",typeof b=="string"?b:void 0),IconWidget:h,iconControl:v??{widget:"icon-picker"}}):i.jsx("input",{type:"text","data-testid":"g7le-meta-icon",value:p,placeholder:n("layout_editor.page_settings.meta.icon_placeholder"),onChange:b=>g("icon",b.target.value),style:gx})}),i.jsx($o,{label:n("layout_editor.page_settings.meta.editor_label"),hint:n("layout_editor.page_settings.meta.editor_label_hint"),children:i.jsx(vt,{value:f,onChange:b=>g("editor_label",b),t:l,testidPrefix:"g7le-meta-editor-label",candidates:a,enableExpressionTree:!0,expressionTreeCollapsible:!0})}),i.jsx($o,{label:n("layout_editor.page_settings.meta.permissions"),hint:n("layout_editor.page_settings.meta.permissions_hint"),children:i.jsx("div",{"data-testid":"g7le-meta-permissions",children:i.jsx(gs,{control:{widget:"tag-input"},value:y,onChange:m,t:n,candidates:s})})})]})}function $o({label:e,hint:t,children:n}){return i.jsxs("div",{className:"g7le-meta-field",style:ux,children:[i.jsx("label",{style:px,children:e}),n,t?i.jsxs("p",{style:fx,children:["ⓘ ",t]}):null]})}function dx({icon:e,onChange:t,t:n,IconWidget:o,iconControl:r}){const[s,a]=u.useState(!1),l=e?/\bfa-/.test(e)?i.jsx("i",{className:e,"aria-hidden":"true"}):e:"📄";return i.jsxs("div",{"data-testid":"g7le-meta-icon",style:{display:"flex",flexDirection:"column",gap:6,minWidth:0},children:[i.jsxs("button",{type:"button","data-testid":"g7le-meta-icon-toggle","aria-expanded":s,onClick:()=>a(d=>!d),style:{display:"flex",alignItems:"center",gap:8,padding:"5px 8px",border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",cursor:"pointer",fontSize:12,color:"#475569",width:"fit-content"},children:[i.jsx("span",{style:{fontSize:16},"aria-hidden":"true",children:l}),i.jsx("span",{children:e||n("layout_editor.page_settings.meta.icon_placeholder")}),i.jsx("span",{"aria-hidden":"true",children:s?"▴":"▾"})]}),s?i.jsx(o,{control:r,value:e,onChange:d=>{t(d),a(!1)},t:n}):null]})}const cx={display:"flex",flexDirection:"column",gap:16,minWidth:0},ux={display:"flex",flexDirection:"column",gap:6,minWidth:0},px={fontSize:13,fontWeight:600,color:"#0f172a"},fx={margin:0,fontSize:11,color:"#94a3b8"},gx={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,minWidth:0,width:"100%"};function Ar({checked:e,onChange:t,label:n,testid:o,ariaLabel:r,disabled:s=!1}){return i.jsxs("label",{style:{...vx,...s?{cursor:"not-allowed"}:{}},children:[i.jsx("button",{type:"button","data-testid":o,role:"switch","aria-checked":e,"aria-label":r,disabled:s,onClick:()=>t(!e),style:_x(e,s),children:i.jsx("span",{style:xx(e)})}),n!=null?i.jsx("span",{style:wx(s),children:n}):null]})}function Tr({disabled:e,children:t,testid:n,style:o}){return i.jsx("div",{"data-testid":n,"data-disabled":e?"true":"false","aria-disabled":e,style:{...o,...e?{opacity:.5,pointerEvents:"none",userSelect:"none"}:{}},children:t})}function hx({filled:e,sourceLabel:t,onRevert:n,revertLabel:o,hint:r,children:s,testid:a}){const l=e&&typeof n=="function",d=!e&&t!=null&&t!=="";return i.jsxs("div",{"data-testid":a,style:yx,children:[s,l?i.jsxs("button",{type:"button","data-testid":a?`${a}-revert`:void 0,onClick:n,title:r??o,"aria-label":o,style:mx,children:["↩ ",o]}):d?i.jsx("span",{"data-testid":a?`${a}-source`:void 0,title:r,style:bx,children:t}):null]})}const yx={position:"relative",minWidth:0,width:"100%"},bx={position:"absolute",right:8,top:"50%",transform:"translateY(-50%)",fontSize:10,color:"#94a3b8",background:"#f1f5f9",borderRadius:4,padding:"1px 6px",pointerEvents:"none",maxWidth:"45%",overflow:"hidden",whiteSpace:"nowrap",textOverflow:"ellipsis"},mx={position:"absolute",right:6,top:"50%",transform:"translateY(-50%)",fontSize:10,color:"#0369a1",background:"#e0f2fe",border:"1px solid #bae6fd",borderRadius:4,padding:"1px 6px",cursor:"pointer",whiteSpace:"nowrap"},vx={display:"inline-flex",alignItems:"center",gap:8,cursor:"pointer"},_x=(e,t)=>({position:"relative",width:38,height:22,flexShrink:0,borderRadius:999,border:"none",padding:0,background:t?"#e2e8f0":e?"#2563eb":"#cbd5e1",cursor:t?"not-allowed":"pointer",transition:"background 0.15s ease"}),xx=e=>({position:"absolute",top:2,left:e?18:2,width:18,height:18,borderRadius:"50%",background:"#fff",boxShadow:"0 1px 2px rgba(0,0,0,0.2)",transition:"left 0.15s ease"}),wx=e=>({fontSize:13,fontWeight:600,color:e?"#94a3b8":"#0f172a"}),kx=/\$(?:core|module|plugin)_settings:[a-zA-Z0-9._:-]+/;function Sx(e){const t=[];let n=!1;const o=s=>{t.push({kind:"text",text:s}),n=!0},r=(s,a,l)=>{n||o(""),t.push({kind:"chip",raw:s,text:a,removable:l}),n=!1};for(const s of Cx(e))s.kind==="text"?o(s.raw):r(s.raw,s.label,s.removable);return n||o(""),t}function Cx(e){const t=[];if(typeof e!="string"||e.length===0)return t;const n=e.length;let o=0,r=0;const s=a=>{a>r&&t.push({kind:"text",raw:e.slice(r,a),label:"",removable:!1})};for(;o=n){o++;continue}s(o);const l=e.slice(o,a+2);t.push({kind:"chip",raw:l,label:Vt(l),removable:!0}),o=a+2,r=o;continue}if(e[o]==="$"){const a=kx.exec(e.slice(o));if(a&&a.index===0){s(o);const l=a[0],d=l.split(":").pop()||l;t.push({kind:"chip",raw:l,label:d,removable:!1}),o+=l.length,r=o;continue}}o++}return s(n),t}function $x(e,t,n,o){const r=[];let s=!1;return e.forEach((a,l)=>{if(a.kind==="chip"){if(!s&&a.raw===t){s=!0;return}r.push(a.raw??"")}else{const d=a.text??"";if(l===n){const c=Math.max(0,Math.min(o,d.length));r.push(d.slice(0,c),t,d.slice(c))}else r.push(d)}}),r.join("")}function Ex(e,t,n){const o=Math.max(0,Math.min(t,e.length));return e.slice(0,o)+n+e.slice(o)}function Ix(e,t){const n=e.indexOf(t);return n<0?e:e.slice(0,n)+e.slice(n+t.length)}function hs({value:e,onChange:t,t:n,candidates:o,onDone:r,testidPrefix:s="g7le-binding-chip-text",disabled:a=!1}){const l=u.useMemo(()=>Sx(e),[e]),d=u.useRef(null),c=!!o&&o.length>0,[p,f]=u.useState(null),[y,g]=u.useState(null),[m,h]=u.useState(null),[v,b]=u.useState(!1),x=u.useRef(0),k=u.useRef(null),w=u.useMemo(()=>l.map(L=>L.kind==="chip"?`c:${L.raw}`:"t").join("|"),[l]),_=u.useRef(null),S=u.useRef([]),E=u.useRef(!1);u.useLayoutEffect(()=>{const L=E.current;E.current=!1,!(!L&&_.current===w)&&(_.current=w,l.forEach((I,P)=>{if(I.kind!=="text")return;const F=S.current[P];F&&F.textContent!==(I.text??"")&&(F.textContent=I.text??"")}))},[w,l,e]);const C=u.useCallback(()=>{const L=d.current;if(!L)return e;const I=[];for(const P of Array.from(L.querySelectorAll("[data-seg]")))P.dataset.seg==="chip"?I.push(P.dataset.raw??""):I.push(P.textContent??"");return I.join("")},[e]),N=u.useCallback(()=>{a||t(C())},[a,t,C]),$=u.useRef(null),A=u.useRef(!1),z=(L,I)=>{if(!a){L.preventDefault(),L.stopPropagation(),$.current={raw:I,pointerId:L.pointerId},A.current=!1,f(I);try{L.currentTarget.setPointerCapture(L.pointerId)}catch{}}},D=L=>{const I=$.current;if(!I||L.pointerId!==I.pointerId)return;A.current=!0;const P=B(L.clientX,L.clientY);P&&(g({slotIdx:P.slotIdx,offset:P.offset}),h(Tx(d.current,P.slotIdx,P.offset)))},O=(L,I)=>{if(L.stopPropagation(),A.current){A.current=!1;return}E.current=!0,t(Ix(e,I))},M=L=>{const I=$.current;if(!I||L.pointerId!==I.pointerId)return;try{L.currentTarget.releasePointerCapture(L.pointerId)}catch{}$.current=null,A.current&&typeof setTimeout=="function"&&setTimeout(()=>{A.current=!1},0);const P=B(L.clientX,L.clientY);if(f(null),g(null),h(null),!P)return;const F=d.current;if(!F)return;const V=Array.from(F.querySelectorAll("[data-seg]")).map(Z=>Z.dataset.seg==="chip"?{kind:"chip",raw:Z.dataset.raw??""}:{kind:"text",text:Z.textContent??""}),Y=$x(V,I.raw,P.slotIdx,P.offset);Y!==e&&(E.current=!0,t(Y))},B=(L,I)=>{const P=d.current;if(!P)return null;const F=Array.from(P.querySelectorAll("[data-seg]")),G=P.ownerDocument,V=G.elementFromPoint(L,I),Y=V?.closest('[data-seg="text"]');if(Y)return{slotIdx:F.indexOf(Y),offset:Ax(G,Y,L,I)};const Z=V?.closest('[data-seg="chip"]');if(Z){const re=F.indexOf(Z),U=Z.getBoundingClientRect();if(L=0){const ee=F[re-1];return{slotIdx:re-1,offset:(ee.textContent??"").length}}if(re+1({s:re,i:U})).filter(re=>re.s.dataset.seg==="text").pop();return se?{slotIdx:se.i,offset:(se.s.textContent??"").length}:null},T=()=>{a||!c||(x.current=Dx(d.current,e.length),b(L=>!L))},H=L=>{const I=L.path?L.path.split(".").filter(Boolean):[],P=[L.sourceId,...I].join("?.");E.current=!0,t(Ex(e,x.current,`{{${P}}}`)),b(!1)};return i.jsxs("div",{"data-testid":s,style:{display:"flex",flexDirection:"column",gap:4},children:[i.jsxs("div",{ref:d,"data-testid":`${s}-box`,className:"g7le-binding-chip-input",style:Nx,children:[l.map((L,I)=>L.kind==="chip"?i.jsxs("span",{"data-seg":"chip","data-raw":L.raw,"data-testid":`${s}-chip-${I}`,contentEditable:!1,onPointerDown:P=>z(P,L.raw??""),onPointerMove:D,onPointerUp:M,title:L.removable?n("layout_editor.value_tree.change_data"):n("layout_editor.prop_i18n.bound_code_only"),style:{...Px,...p===L.raw?Ox:null,touchAction:"none"},children:[i.jsx("span",{"aria-hidden":"true",style:Bx,children:"⠿"}),i.jsx("span",{"aria-hidden":"true",style:{marginRight:3},children:"🔗"}),i.jsx("span",{children:L.text}),L.removable&&!a&&i.jsx("button",{type:"button","data-testid":`${s}-chip-remove-${I}`,"aria-label":n("layout_editor.inline_binding.clear"),title:n("layout_editor.inline_binding.clear"),onPointerDown:P=>{P.stopPropagation(),P.preventDefault()},onClick:P=>O(P,L.raw??""),style:Wx,children:"✕"})]},`chip-${L.raw}-${I}`):i.jsx("span",{ref:P=>{S.current[I]=P},"data-seg":"text",contentEditable:!a,suppressContentEditableWarning:!0,onInput:N,"data-testid":`${s}-text-${I}`,style:{...zx,...p?Mx:null,...y?.slotIdx===I?Lx:null}},`text-slot-${I}`)),m&&i.jsx("span",{"aria-hidden":"true","data-testid":`${s}-caret`,style:{position:"absolute",left:m.left,top:m.top,width:2,height:m.height,background:"#2563eb",borderRadius:1,pointerEvents:"none",boxShadow:"0 0 0 1px rgba(37,99,235,0.3)"}})]}),i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6,flexWrap:"wrap"},children:[r&&i.jsxs("button",{type:"button","data-testid":`${s}-done`,onClick:r,style:cc,title:n("layout_editor.value_tree.done"),"aria-label":n("layout_editor.value_tree.done"),children:["✓ ",n("layout_editor.value_tree.done")]}),c&&i.jsxs("div",{style:{position:"relative"},children:[i.jsxs("button",{ref:k,type:"button","data-testid":`${s}-insert`,onClick:T,disabled:a,style:cc,title:n("layout_editor.inline_binding.insert_data"),"aria-label":n("layout_editor.inline_binding.insert_data"),children:["+ ",n("layout_editor.inline_binding.insert_data")]}),i.jsx(Gn,{anchorRef:k,open:v,onClose:()=>b(!1),children:i.jsx($t,{candidates:o,t:n,onSelect:H,testIdSuffix:`${s}-insert`,defaultOpen:!0,floating:!1})})]}),i.jsx("span",{style:jx,children:n("layout_editor.translation.placeholder_locked")})]})]})}function Ax(e,t,n,o){const r=(t.textContent??"").length;try{const a=e;let l=null,d=0;if(typeof a.caretRangeFromPoint=="function"){const c=a.caretRangeFromPoint(n,o);c&&(l=c.startContainer,d=c.startOffset)}else if(typeof a.caretPositionFromPoint=="function"){const c=a.caretPositionFromPoint(n,o);c&&(l=c.offsetNode,d=c.offset)}if(l&&t.contains(l))return Math.max(0,Math.min(d,r))}catch{}const s=t.getBoundingClientRect();return n>s.left+s.width/2?r:0}function Tx(e,t,n){if(!e)return null;const r=Array.from(e.querySelectorAll("[data-seg]"))[t];if(!r)return null;const s=e.getBoundingClientRect();try{const d=r.firstChild,c=e.ownerDocument;if(d&&d.nodeType===Node.TEXT_NODE&&(d.textContent??"").length>0){const p=c.createRange(),f=Math.max(0,Math.min(n,(d.textContent??"").length));p.setStart(d,f),p.setEnd(d,f);const y=p.getClientRects(),g=y.length>0?y[0]:p.getBoundingClientRect();if(g&&(g.height>0||g.width>=0))return{left:g.left-s.left,top:g.top-s.top,height:g.height||r.getBoundingClientRect().height}}}catch{}const a=r.getBoundingClientRect();return{left:(n>0?a.right:a.left)-s.left,top:a.top-s.top,height:a.height||16}}function Dx(e,t){if(!e)return t;try{const n=e.ownerDocument.defaultView?.getSelection();if(!n||n.rangeCount===0)return t;const o=n.getRangeAt(0);let r=o.startContainer,s=null;for(;r&&r!==e;){if(r instanceof HTMLElement&&r.dataset.seg==="text"){s=r;break}r=r.parentNode}if(!s||!e.contains(s))return t;const a=Array.from(e.querySelectorAll("[data-seg]"));let l=0;for(const d of a){if(d===s)break;l+=d.dataset.seg==="chip"?(d.dataset.raw??"").length:(d.textContent??"").length}return l+o.startOffset}catch{return t}}const Nx={position:"relative",display:"flex",flexWrap:"wrap",alignItems:"center",gap:2,minHeight:30,padding:"4px 8px",border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",fontSize:12,lineHeight:1.6,minWidth:0,width:"100%",boxSizing:"border-box"},zx={outline:"none",minWidth:8,minHeight:"1.2em",whiteSpace:"pre-wrap",color:"#0f172a",display:"inline-block",wordBreak:"break-all"},Mx={boxShadow:"inset 0 -2px 0 #93c5fd",background:"#eff6ff",borderRadius:2},Lx={background:"#dbeafe",boxShadow:"inset 0 -2px 0 #2563eb"},Px={display:"inline-flex",alignItems:"center",gap:1,background:"linear-gradient(180deg,#eff6ff,#dbeafe)",color:"#1d4ed8",border:"1px dashed #60a5fa",borderRadius:9999,padding:"1px 8px 1px 4px",margin:"0 1px",fontSize:11,fontWeight:600,cursor:"grab",userSelect:"none",whiteSpace:"nowrap",boxShadow:"0 1px 2px rgba(37,99,235,0.18)",verticalAlign:"middle"},Ox={opacity:.5,borderStyle:"solid",cursor:"grabbing"},Bx={color:"#2563eb",opacity:.7,fontSize:11,marginRight:1,cursor:"grab"},Wx={marginLeft:4,width:14,height:14,lineHeight:"12px",padding:0,border:"none",borderRadius:9999,background:"rgba(29,78,216,0.12)",color:"#1d4ed8",fontSize:9,fontWeight:700,cursor:"pointer",display:"inline-flex",alignItems:"center",justifyContent:"center",flex:"0 0 auto"},cc={padding:"4px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer",whiteSpace:"nowrap"},jx={fontSize:10,color:"#94a3b8"},Hx=Vl;function ys(e){return gb(e)}function Ve({value:e,onChange:t,t:n,candidates:o,placeholder:r,testidPrefix:s="g7le-data-chip-value",readOnly:a=!1,enableExpressionTree:l=!0}){const d=typeof e=="string"?e:"",c=!!o&&o.length>0,[p,f]=u.useState(!1),[y,g]=u.useState(!1),[m,h]=u.useState(!1),v=u.useCallback(E=>i.jsx(Ve,{value:E.value,onChange:C=>E.onChange(C),t:E.t,candidates:E.candidates,testidPrefix:E.testidPrefix,enableExpressionTree:!0}),[]),b=()=>i.jsx("div",{style:{display:"flex",gap:4,alignItems:"center",width:"100%",minWidth:0},children:i.jsx("input",{type:"text","data-testid":`${s}-input`,value:d,placeholder:r??n("layout_editor.page_settings.seo.value_placeholder"),readOnly:a,onChange:E=>t(E.target.value),onFocus:()=>f(!0),onBlur:()=>f(!1),style:{flex:1,padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,minWidth:0,width:"100%",background:a?"#f1f5f9":"#fff",color:a?"#64748b":"#0f172a"}})}),x=u.useCallback(E=>{const C=d?`${d} ${E.expression}`:E.expression;t(C)},[d,t]);if(!l||a)return i.jsxs("div",{"data-testid":s,style:un,children:[b(),!a&&c&&i.jsx($t,{candidates:o,t:n,onSelect:x,testIdSuffix:`${s}-pick`})]});const k=Sv(d),w=Kd(d),_=Kt(d),S=E=>{const C=wr(d),N=C?ds(C,()=>"",$=>Vt($)):[];return i.jsxs(i.Fragment,{children:[i.jsx("div",{style:{display:"flex",justifyContent:"flex-start"},children:i.jsxs("button",{type:"button","data-testid":`${E}-to-plain`,onClick:()=>h(!0),style:Ux,title:n("layout_editor.value_tree.to_plain"),"aria-label":n("layout_editor.value_tree.to_plain"),children:["↩ ",n("layout_editor.value_tree.to_plain")]})}),m&&i.jsxs("div",{"data-testid":`${E}-to-plain-confirm`,style:qx,children:[i.jsx("p",{style:Gx,children:n("layout_editor.value_tree.to_plain_lead")}),i.jsx("div",{style:Yx,children:N.length===0?i.jsx("span",{style:{color:"#94a3b8"},children:n("layout_editor.page_settings.seo.value_placeholder")}):N.map(($,A)=>$.kind==="chip"?i.jsxs("span",{style:bs,children:["🔗 ",$.text]},A):i.jsx("span",{style:$.kind==="ellipsis"?{color:"#94a3b8"}:void 0,children:$.text},A))}),i.jsxs("p",{style:Xx,children:["⚠ ",n("layout_editor.value_tree.to_plain_warn")]}),i.jsxs("div",{style:{display:"flex",justifyContent:"flex-end",gap:6},children:[i.jsx("button",{type:"button","data-testid":`${E}-to-plain-confirm-cancel`,onClick:()=>h(!1),style:Ut,children:n("layout_editor.value_tree.to_plain_cancel")}),i.jsx("button",{type:"button","data-testid":`${E}-to-plain-confirm-ok`,onClick:()=>{h(!1),t(wr(d)||"")},style:Jx,children:n("layout_editor.value_tree.to_plain_ok")})]})]})]})};if(!p&&(k==="expression"||w)){if(w)return i.jsxs("div",{"data-testid":s,style:un,children:[!a&&S(`${s}-seg`),i.jsx(ps,{value:d,onChange:C=>t(C),t:n,candidates:o,testidPrefix:`${s}-seg`,renderLeafInput:v})]});const E=cn(d);return i.jsxs("div",{"data-testid":s,style:un,children:[!a&&S(`${s}-tree`),i.jsx(Qd,{node:E.node,onChange:C=>t(Xn(C,!1)),t:n,candidates:o,testidPrefix:`${s}-tree`,renderLeafInput:v})]})}if(_&&!p&&!y)return i.jsx(Fx,{value:d,onChange:t,t:n,candidates:o,testidPrefix:s,onEditRaw:()=>g(!0)});if(!p&&!y&&ys(d)){const E=Hx(d);return i.jsx("div",{"data-testid":s,style:un,children:i.jsxs("div",{style:uc,children:[i.jsx("div",{"data-testid":`${s}-chips`,style:Kx,children:E.map((C,N)=>C.kind==="chip"?i.jsxs("span",{style:bs,children:["🔗 ",C.label]},N):i.jsx("span",{children:C.label},N))}),i.jsxs("button",{type:"button","data-testid":`${s}-edit-raw`,onClick:()=>g(!0),style:Ut,title:n("layout_editor.value_tree.edit"),"aria-label":n("layout_editor.value_tree.edit"),children:["✎ ",n("layout_editor.value_tree.edit")]})]})})}return y&&ys(d)?i.jsxs("div",{"data-testid":s,style:un,children:[i.jsx(hs,{value:d,onChange:t,t:n,candidates:o,disabled:a,onDone:()=>g(!1),testidPrefix:`${s}-chipedit`}),!a&&k!=="raw"&&i.jsxs("div",{style:{display:"flex",gap:4,flexWrap:"wrap"},children:[i.jsxs("button",{type:"button","data-testid":`${s}-to-expr`,onClick:()=>t(wo(d)),style:Ut,title:n("layout_editor.value_tree.to_expression"),"aria-label":n("layout_editor.value_tree.to_expression"),children:["ƒx ",n("layout_editor.value_tree.to_expression")]}),i.jsxs("button",{type:"button","data-testid":`${s}-to-fallback`,onClick:()=>t(cs(d)),style:Ut,title:n("layout_editor.value_tree.segment.fallback"),"aria-label":n("layout_editor.value_tree.segment.fallback"),children:["?? ",n("layout_editor.value_tree.segment.fallback")]})]}),k==="raw"&&i.jsxs("p",{"data-testid":`${s}-raw-hint`,style:ms,children:["ⓘ ",n("layout_editor.value_tree.raw_expression_hint")]})]}):!p&&ys(d)?i.jsxs("div",{"data-testid":s,style:un,children:[i.jsx(hs,{value:d,onChange:t,t:n,candidates:o,disabled:a,onDone:()=>{g(!1),f(!1)},testidPrefix:`${s}-chipedit`}),!a&&k!=="raw"&&i.jsxs("div",{style:{display:"flex",gap:4,flexWrap:"wrap"},children:[i.jsxs("button",{type:"button","data-testid":`${s}-to-expr`,onClick:()=>t(wo(d)),style:Ut,title:n("layout_editor.value_tree.to_expression"),"aria-label":n("layout_editor.value_tree.to_expression"),children:["ƒx ",n("layout_editor.value_tree.to_expression")]}),i.jsxs("button",{type:"button","data-testid":`${s}-to-fallback`,onClick:()=>t(cs(d)),style:Ut,title:n("layout_editor.value_tree.segment.fallback"),"aria-label":n("layout_editor.value_tree.segment.fallback"),children:["?? ",n("layout_editor.value_tree.segment.fallback")]})]}),k==="raw"&&i.jsxs("p",{"data-testid":`${s}-raw-hint`,style:ms,children:["ⓘ ",n("layout_editor.value_tree.raw_expression_hint")]})]}):i.jsxs("div",{"data-testid":s,style:un,children:[b(),i.jsxs("div",{style:Vx,children:[c&&i.jsx($t,{candidates:o,t:n,onSelect:x,testIdSuffix:`${s}-pick`}),k!=="raw"&&i.jsxs("div",{style:{display:"flex",gap:4},children:[i.jsxs("button",{type:"button","data-testid":`${s}-to-expr`,onClick:()=>t(wo(d)),style:Ut,title:n("layout_editor.value_tree.to_expression"),"aria-label":n("layout_editor.value_tree.to_expression"),children:["ƒx ",n("layout_editor.value_tree.to_expression")]}),i.jsxs("button",{type:"button","data-testid":`${s}-to-fallback`,onClick:()=>t(cs(d)),style:Ut,title:n("layout_editor.value_tree.segment.fallback"),"aria-label":n("layout_editor.value_tree.segment.fallback"),children:["?? ",n("layout_editor.value_tree.segment.fallback")]})]})]}),k==="raw"&&!p&&i.jsxs("p",{"data-testid":`${s}-raw-hint`,style:ms,children:["ⓘ ",n("layout_editor.value_tree.raw_expression_hint")]})]})}function Fx({value:e,onChange:t,t:n,candidates:o,testidPrefix:r,onEditRaw:s}){const[a,l]=u.useState(!1),d=u.useRef(null),c=Vt(e),p=!!o&&o.length>0,f=Kt(e)?.localeFn;return i.jsxs("div",{"data-testid":r,style:un,children:[i.jsxs("div",{style:uc,children:[i.jsxs("span",{"data-testid":`${r}-chip`,style:bs,children:["🔗 ",c]}),p&&i.jsx("button",{ref:d,type:"button","data-testid":`${r}-change`,onClick:()=>l(y=>!y),style:Ut,title:n("layout_editor.value_tree.change_data"),"aria-label":n("layout_editor.value_tree.change_data"),children:n("layout_editor.value_tree.change_data")}),i.jsx("button",{type:"button","data-testid":`${r}-edit-raw`,onClick:s,style:Ut,title:n("layout_editor.value_tree.edit"),"aria-label":n("layout_editor.value_tree.edit"),children:"✎"})]}),p&&i.jsx(Gn,{anchorRef:d,open:a,onClose:()=>l(!1),children:i.jsx($t,{candidates:o,t:n,onSelect:y=>{if(f){const h=[y.sourceId,...y.path?y.path.split(".").filter(Boolean):[]].join(".");t(`{{${f}(${h})}}`),l(!1);return}const g=y.path?y.path.split(".").filter(Boolean):[],m=[y.sourceId,...g].join("?.");t(`{{${m}}}`),l(!1)},testIdSuffix:`${r}-pick`,defaultOpen:!0,floating:!1})})]})}const un={display:"flex",flexDirection:"column",gap:4,width:"100%",minWidth:0},uc={display:"flex",alignItems:"center",gap:6,width:"100%",minWidth:0,flexWrap:"wrap"},Kx={flex:1,minWidth:0,display:"flex",flexWrap:"wrap",alignItems:"center",gap:4,padding:"5px 8px",fontSize:12,color:"#334155",border:"1px solid #e2e8f0",borderRadius:6,background:"#f8fafc"},bs={fontSize:11,background:"#eef2ff",color:"#4338ca",padding:"2px 8px",borderRadius:12,whiteSpace:"nowrap"},Ut={padding:"4px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer",whiteSpace:"nowrap"},Vx={display:"flex",flexWrap:"wrap",alignItems:"center",gap:6},ms={margin:"2px 0 0",fontSize:11,color:"#94a3b8",lineHeight:1.4},Ux={padding:"3px 10px",fontSize:11,border:"1px solid #fca5a5",borderRadius:6,background:"#fef2f2",color:"#b91c1c",cursor:"pointer",whiteSpace:"nowrap"},qx={display:"flex",flexDirection:"column",gap:6,padding:10,border:"1px solid #fca5a5",borderRadius:8,background:"#fffbeb"},Gx={margin:0,fontSize:12,color:"#334155"},Yx={padding:"5px 8px",fontSize:12,color:"#0f172a",background:"#fff",border:"1px solid #e2e8f0",borderRadius:6,wordBreak:"break-all",display:"flex",flexWrap:"wrap",gap:4,alignItems:"center"},Xx={margin:0,fontSize:11,color:"#b45309"},Jx={padding:"3px 12px",fontSize:11,border:"1px solid #dc2626",borderRadius:6,background:"#dc2626",color:"#fff",cursor:"pointer",whiteSpace:"nowrap"};let pc=0;function Dr({value:e,onChange:t,keyField:n,valueField:o="content",t:r,candidates:s,testidPrefix:a="g7le-kv-chip-editor",keyPlaceholder:l,valuePlaceholder:d,addLabel:c,renderKeyInput:p}){const f=u.useMemo(()=>(Array.isArray(e)?e:[]).map(k=>({id:pc++,k:typeof k[n]=="string"?k[n]:"",v:typeof k[o]=="string"?k[o]:""})),[]),[y,g]=u.useState(f),m=u.useCallback(k=>{g(k);const w=k.filter(_=>_.k.trim()!=="").map(_=>({[n]:_.k,[o]:_.v}));t(w)},[n,o,t]),h=u.useCallback(()=>{m([...y,{id:pc++,k:"",v:""}])},[y,m]),v=u.useCallback(k=>{m(y.filter(w=>w.id!==k))},[y,m]),b=u.useCallback((k,w)=>{m(y.map(_=>_.id===k?{..._,k:w}:_))},[y,m]),x=u.useCallback((k,w)=>{m(y.map(_=>_.id===k?{..._,v:w}:_))},[y,m]);return i.jsxs("div",{"data-testid":a,style:{display:"flex",flexDirection:"column",gap:6},children:[y.map(k=>i.jsxs("div",{"data-testid":`${a}-row-${k.id}`,style:{display:"flex",gap:6,alignItems:"flex-start",border:"1px solid #e2e8f0",borderRadius:8,padding:8},children:[i.jsxs("div",{style:{flex:1,minWidth:0,display:"flex",flexDirection:"column",gap:6},children:[i.jsx("div",{style:{display:"flex",flexDirection:"column",gap:2,minWidth:0},children:p?i.jsx("div",{style:{minWidth:0},children:p({value:k.k,onChange:w=>b(k.id,w),testid:`${a}-key-${k.id}`,placeholder:l??r(`layout_editor.page_settings.seo.extra_${n}`)})}):i.jsx("input",{type:"text","data-testid":`${a}-key-${k.id}`,value:k.k,placeholder:l??r(`layout_editor.page_settings.seo.extra_${n}`),onChange:w=>b(k.id,w.target.value),style:{width:"100%",padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,minWidth:0,boxSizing:"border-box"}})}),i.jsx("div",{style:{minWidth:0},children:i.jsx(Ve,{value:k.v,onChange:w=>x(k.id,w),t:r,candidates:s,placeholder:d??r("layout_editor.page_settings.seo.extra_content"),testidPrefix:`${a}-value-${k.id}`})})]}),i.jsx("button",{type:"button","data-testid":`${a}-remove-${k.id}`,onClick:()=>v(k.id),title:r("layout_editor.page_settings.seo.remove"),"aria-label":r("layout_editor.page_settings.seo.remove"),style:{flex:"0 0 auto",padding:"4px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#64748b",cursor:"pointer"},children:"✕"})]},k.id)),i.jsxs("button",{type:"button","data-testid":`${a}-add`,onClick:h,style:{alignSelf:"flex-start",padding:"5px 10px",fontSize:12,border:"1px dashed #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer"},children:["+ ",c??r("layout_editor.page_settings.seo.add_extra")]})]})}const Zx=["website","article","product","profile"],Qx=["summary","summary_large_image","app","player"];function Rx(e){switch(e){case"core":return"layout_editor.page_settings.seo.og_source_core";case"inherited":return"layout_editor.page_settings.seo.og_source_inherited";case"filter":return"layout_editor.page_settings.seo.og_source_filter";case"layout":return null;default:return e?"layout_editor.page_settings.seo.og_source_module":null}}function fc(e){const t={};for(const n of e??[])t[n.key]=n;return t}function gc(e,t){return!!e&&t&&typeof e.sourceExpr=="string"&&e.sourceExpr!==""&&!e.overriddenByLayout&&!e.lockedByFilter}function hc({label:e,sourceExpr:t,onReplace:n,t:o,testid:r}){return i.jsxs("div",{"data-testid":r,style:d0,children:[i.jsxs("span",{"data-testid":`${r}-chip`,style:c0,title:t,children:["🔗 ",e||t]}),i.jsx("span",{"data-testid":`${r}-badge`,style:u0,children:o("layout_editor.page_settings.seo.auto_chip_source")}),i.jsx("button",{type:"button","data-testid":`${r}-replace`,onClick:n,style:p0,children:o("layout_editor.page_settings.seo.auto_chip_replace")})]})}function e0({og:e,twitter:t,onChangeOg:n,onChangeTwitter:o,ogPreview:r,twitterPreview:s,defaultsAvailable:a=!1,candidates:l,t:d,testidPrefix:c="g7le-seo-og"}){const p=e&&typeof e=="object"?e:{},f=t&&typeof t=="object"?t:{},[y,g]=u.useState(p.image_height!=null||p.image_type!=null||p.image_secure_url!=null),m=fc(r),h=fc(s),v=u.useCallback((N,$)=>{const A={...p};$===void 0||$===""||$===null?delete A[N]:A[N]=$,n(A)},[p,n]),b=u.useCallback((N,$)=>{const A={...f};$===void 0||$===""||$===null?delete A[N]:A[N]=$,o(A)},[f,o]),x=(N,$,A)=>!A||!a||!A.lockedByFilter?null:i.jsx("div",{style:{display:"flex",gap:6,alignItems:"center",marginTop:2},children:i.jsxs("span",{"data-testid":`${N}-locked-${$}`,style:s0,children:["🔒 ",d("layout_editor.page_settings.seo.og_locked")]})}),k=N=>{if(!N||!a||N.lockedByFilter||N.overriddenByLayout)return;const $=Rx(N.source);return $?d($):void 0},w=(N,$)=>N&&a?N.overriddenByLayout===!0:$!=="",_=(N,$,A,z,D,O)=>{const M=O[$],B=!!M?.lockedByFilter,T=typeof z[$]=="string"?z[$]:"";return i.jsxs("div",{"data-testid":`${N}-${$}`,style:Tn,children:[i.jsx("label",{style:Rn,children:d(A)}),B?i.jsx("div",{"data-testid":`${N}-locked-value-${$}`,style:i0,children:String(M?.effectiveValue??"")}):gc(M,a)?i.jsx(hc,{label:M.label??"",sourceExpr:M.sourceExpr,onReplace:()=>D($,M.sourceExpr),t:d,testid:`${N}-${$}-auto`}):i.jsx(vt,{value:T,onChange:H=>D($,H??""),t:d,candidates:l,placeholder:a&&M?.effectiveValue!=null?String(M.effectiveValue):void 0,testidPrefix:`${N}-${$}-field`,enableExpressionTree:!0,expressionTreeCollapsible:!0}),x(N,$,M)]})},S=(N,$,A,z,D,O)=>{const M=O[$],B=!!M?.lockedByFilter,T=typeof z[$]=="string"?z[$]:"";return i.jsxs("div",{"data-testid":`${N}-${$}`,style:Tn,children:[i.jsx("label",{style:Rn,children:d(A)}),gc(M,a)?i.jsx(hc,{label:M.label??"",sourceExpr:M.sourceExpr,onReplace:()=>D($,M.sourceExpr),t:d,testid:`${N}-${$}-auto`}):i.jsx(Ve,{value:T,onChange:H=>D($,H),t:d,candidates:l,readOnly:B,placeholder:a&&M?.effectiveValue!=null?String(M.effectiveValue):void 0,testidPrefix:`${N}-${$}-field`}),x(N,$,M)]})},E=(N,$,A,z,D,O)=>{const M=O[$],B=!!M?.lockedByFilter,T=typeof z[$]=="string"?z[$]:"",H=w(M,T),L=H?T:"";return i.jsxs("div",{"data-testid":`${N}-${$}`,style:Tn,children:[i.jsx("label",{style:Rn,children:d(A)}),i.jsx(hx,{filled:H,sourceLabel:k(M),onRevert:B?void 0:()=>D($,void 0),revertLabel:d("layout_editor.page_settings.seo.revert_default"),testid:`${N}-${$}-overlay`,children:i.jsx("div",{"data-testid":`${N}-${$}-input`,style:{minWidth:0},children:i.jsx(Ve,{value:L,onChange:I=>D($,I),t:d,candidates:l,readOnly:B,placeholder:a&&M?.effectiveValue!=null?String(M.effectiveValue):void 0,testidPrefix:`${N}-${$}-chip`})})}),x(N,$,M)]})},C=(N,$,A,z,D,O,M)=>{const B=M[$],T=typeof D[$]=="string"?D[$]:"",H=w(B,T),L=k(B),I=H?T:"";return i.jsxs("div",{"data-testid":`${N}-${$}`,style:Tn,children:[i.jsxs("div",{style:n0,children:[i.jsx("label",{style:Rn,children:d(A)}),L?i.jsxs("span",{"data-testid":`${N}-${$}-source-inline`,style:o0,children:["· ",L]}):null]}),i.jsxs("select",{"data-testid":`${N}-${$}-select`,value:I,onChange:P=>O($,P.target.value),style:r0,children:[i.jsx("option",{value:"",children:a&&B?.effectiveValue!=null&&B.effectiveValue!==""?`${d("layout_editor.page_settings.seo.use_default")} (${String(B.effectiveValue)})`:d("layout_editor.page_settings.seo.use_default")}),z.map(P=>i.jsx("option",{value:P,children:P},P))]}),x(N,$,B)]})};return i.jsxs("div",{"data-testid":c,style:{display:"flex",flexDirection:"column",gap:12},children:[i.jsxs("div",{"data-testid":`${c}-og-section`,style:yc,children:[i.jsx("div",{style:bc,children:d("layout_editor.page_settings.seo.og_section")}),i.jsxs("p",{"data-testid":`${c}-intro`,style:t0,children:["ⓘ ",d("layout_editor.page_settings.seo.og_intro")]}),C(c,"type","layout_editor.page_settings.seo.og_type",Zx,p,v,m),_(c,"title","layout_editor.page_settings.seo.og_title",p,v,m),_(c,"description","layout_editor.page_settings.seo.og_description",p,v,m),S(c,"image","layout_editor.page_settings.seo.og_image",p,v,m),_(c,"image_alt","layout_editor.page_settings.seo.og_image_alt",p,v,m),i.jsxs("div",{style:Tn,children:[i.jsxs("button",{type:"button","data-testid":`${c}-image-adv-toggle`,"aria-expanded":y,onClick:()=>g(N=>!N),style:a0,children:[y?"▾":"▸"," ",d("layout_editor.page_settings.seo.og_image_adv")]}),y?i.jsxs("div",{"data-testid":`${c}-image-adv`,style:l0,children:[E(c,"image_height","layout_editor.page_settings.seo.og_image_height",p,v,m),E(c,"image_type","layout_editor.page_settings.seo.og_image_type",p,v,m),E(c,"image_secure_url","layout_editor.page_settings.seo.og_image_secure_url",p,v,m)]}):null]}),E(c,"site_name","layout_editor.page_settings.seo.og_site_name",p,v,m),E(c,"locale","layout_editor.page_settings.seo.og_locale",p,v,m),i.jsxs("div",{"data-testid":`${c}-extra`,style:Tn,children:[i.jsx("label",{style:Rn,children:d("layout_editor.page_settings.seo.og_extra")}),i.jsx(Dr,{value:Array.isArray(p.extra)?p.extra:[],onChange:N=>v("extra",N.length>0?N:void 0),keyField:"property",t:d,candidates:l,testidPrefix:`${c}-extra-editor`})]})]}),i.jsxs("div",{"data-testid":`${c}-tw-section`,style:yc,children:[i.jsx("div",{style:bc,children:d("layout_editor.page_settings.seo.tw_section")}),i.jsx("p",{style:{margin:"0 0 6px",fontSize:11,color:"#94a3b8"},children:d("layout_editor.page_settings.seo.tw_fallback_note")}),C("g7le-seo-tw","card","layout_editor.page_settings.seo.tw_card",Qx,f,b,h),E("g7le-seo-tw","site","layout_editor.page_settings.seo.tw_site",f,b,h),E("g7le-seo-tw","creator","layout_editor.page_settings.seo.tw_creator",f,b,h),_("g7le-seo-tw","title","layout_editor.page_settings.seo.tw_title",f,b,h),_("g7le-seo-tw","description","layout_editor.page_settings.seo.tw_description",f,b,h),S("g7le-seo-tw","image","layout_editor.page_settings.seo.tw_image",f,b,h),_("g7le-seo-tw","image_alt","layout_editor.page_settings.seo.tw_image_alt",f,b,h),i.jsxs("div",{"data-testid":"g7le-seo-tw-extra",style:Tn,children:[i.jsx("label",{style:Rn,children:d("layout_editor.page_settings.seo.tw_extra")}),i.jsx(Dr,{value:Array.isArray(f.extra)?f.extra:[],onChange:N=>b("extra",N.length>0?N:void 0),keyField:"name",t:d,candidates:l,testidPrefix:"g7le-seo-tw-extra-editor"})]})]})]})}const yc={border:"1px solid #e2e8f0",borderRadius:8,padding:12,display:"flex",flexDirection:"column",gap:10},bc={fontSize:13,fontWeight:600,color:"#0f172a"},t0={margin:0,fontSize:11,color:"#64748b",background:"#f8fafc",borderRadius:6,padding:"6px 8px"},Tn={display:"flex",flexDirection:"column",gap:4},Rn={fontSize:12,color:"#475569"},n0={display:"flex",alignItems:"center",gap:6},o0={fontSize:11,color:"#94a3b8"},r0={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,width:"100%",minWidth:0,boxSizing:"border-box"},i0={padding:"5px 8px",fontSize:12,border:"1px solid #e2e8f0",borderRadius:6,background:"#f1f5f9",color:"#64748b"},s0={fontSize:11,color:"#7c3aed"},a0={alignSelf:"flex-start",padding:"2px 0",fontSize:12,fontWeight:600,color:"#475569",background:"transparent",border:"none",cursor:"pointer"},l0={display:"flex",flexDirection:"column",gap:8,paddingLeft:14,marginTop:4,minWidth:0},d0={display:"flex",alignItems:"center",gap:8,flexWrap:"wrap",minWidth:0},c0={display:"inline-flex",alignItems:"center",maxWidth:"100%",padding:"3px 8px",fontSize:12,color:"#1d4ed8",background:"#eff6ff",border:"1px solid #bfdbfe",borderRadius:6,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},u0={fontSize:11,color:"#94a3b8"},p0={padding:"2px 8px",fontSize:11,color:"#475569",background:"#fff",border:"1px solid #cbd5e1",borderRadius:6,cursor:"pointer"},f0=["WebPage","WebSite","ItemList","BreadcrumbList","Organization","Person","CollectionPage","AboutPage","ContactPage","SearchAction"];function Nr(e){const t=[],n=(o,r)=>{for(const[s,a]of Object.entries(o)){if(s==="@type"||s==="@context")continue;const l=r?`${r}.${s}`:s;a!==null&&typeof a=="object"&&!Array.isArray(a)?n(a,l):t.push({key:l,value:a==null?"":String(a)})}};return n(e,""),t}function mc(e,t){const n={"@type":e};for(const{key:o,value:r}of t){if(o.trim()==="")continue;const s=o.split(".");let a=n;for(let l=0;lp?Nr(e).map(D=>({id:vs++,...D})):[]),x=u.useCallback((D,O)=>{h(D),b(O),t(mc(D,O.map(M=>({key:M.key,value:M.value}))))},[t]),k=u.useCallback(()=>{const D=n,O=typeof D["@type"]=="string"?D["@type"]:m,M=Nr(D).map(B=>({id:vs++,key:B.key,value:o?.[B.key]?.expr??B.value}));return{type:O,rows:M}},[n,o,m]),w=u.useCallback(D=>{if(y(D),D){if(v.length===0&&n&&typeof n=="object"){const O=k();x(O.type,O.rows);return}t(mc(m,v.map(O=>({key:O.key,value:O.value}))))}else t(null)},[m,v,n,k,x,t]),_=u.useCallback(()=>{if(!n)return;const D=k();x(D.type,D.rows)},[n,k,x]),S=u.useCallback(()=>{x(m,[...v,{id:vs++,key:"",value:""}])},[m,v,x]),E=u.useCallback(D=>{x(m,v.filter(O=>O.id!==D))},[m,v,x]),C=u.useCallback((D,O)=>{x(m,v.map(M=>M.id===D?{...M,key:O}:M))},[m,v,x]),N=u.useCallback((D,O)=>{x(m,v.map(M=>M.id===D?{...M,value:O}:M))},[m,v,x]),$=u.useMemo(()=>n?Nr(n):[],[n]),A=u.useMemo(()=>s?Nr(s):[],[s]),z=v.some(D=>D.key.trim()!==""&&D.value.trim()==="");if(r){const D=s&&typeof s["@type"]=="string"?s["@type"]:"";return i.jsxs("div",{"data-testid":c,style:vc,children:[i.jsxs("div",{style:_c,children:[i.jsx("span",{style:h0,children:"🔒"}),i.jsx("span",{style:{fontSize:13,fontWeight:600},children:d("layout_editor.page_settings.seo.sd_mode")}),i.jsx("span",{"data-testid":`${c}-locked-badge`,style:y0,children:d("layout_editor.page_settings.seo.sd_filter_locked")})]}),i.jsxs("div",{"data-testid":`${c}-locked`,style:b0,children:[i.jsxs("p",{style:{margin:"0 0 6px",fontSize:12,color:"#475569"},children:["ⓘ ",d("layout_editor.page_settings.seo.sd_filter_locked_note")]}),i.jsxs("div",{"data-testid":`${c}-filtered-block`,children:[D?i.jsxs("div",{style:{fontSize:12,fontWeight:600,marginBottom:4},children:["@type: ",D]}):null,A.map(O=>i.jsxs("div",{"data-testid":`${c}-filtered-row-${O.key}`,style:xc,children:[i.jsx("code",{style:wc,children:O.key}),i.jsx("span",{style:kc,children:O.value})]},O.key))]})]})]})}return i.jsxs("div",{"data-testid":c,style:vc,children:[i.jsx("div",{style:_c,children:i.jsx(Ar,{checked:f,onChange:w,testid:`${c}-mode`,label:d("layout_editor.page_settings.seo.sd_manual_toggle")})}),!f&&i.jsxs("div",{"data-testid":`${c}-auto`,style:{marginTop:8},children:[i.jsxs("div",{"data-testid":`${c}-auto-preview`,style:m0,children:[i.jsxs("div",{style:{fontSize:12,fontWeight:600,marginBottom:6},children:[d("layout_editor.page_settings.seo.sd_auto_title"),n&&typeof n["@type"]=="string"?`: ${n["@type"]}`:"",a?i.jsxs("span",{style:{marginLeft:6,color:"#94a3b8",fontWeight:400},children:["(",a,")"]}):null]}),$.length===0?i.jsx("p",{style:{margin:0,fontSize:12,color:"#94a3b8"},children:d("layout_editor.page_settings.seo.sd_auto_empty")}):$.map(D=>{const O=o?.[D.key];return i.jsxs("div",{"data-testid":`${c}-auto-row-${D.key}`,style:xc,children:[i.jsx("code",{style:wc,children:D.key}),O?i.jsxs("span",{"data-testid":`${c}-auto-chip-${D.key}`,style:v0,title:O.expr,children:["🔗 ",O.label||O.expr]}):i.jsx("span",{style:kc,children:D.value})]},D.key)})]}),i.jsxs("p",{"data-testid":`${c}-replace-warn`,style:_0,children:["ⓘ ",d("layout_editor.page_settings.seo.sd_replace_pre_warn")]})]}),f&&i.jsxs("div",{"data-testid":`${c}-manual`,style:{marginTop:8},children:[i.jsxs("p",{"data-testid":`${c}-replace-warn`,style:x0,children:["⚠ ",d("layout_editor.page_settings.seo.sd_replace_warn")]}),i.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:6},children:[i.jsx("label",{style:{fontSize:12,color:"#475569"},children:d("layout_editor.page_settings.seo.sd_type")}),i.jsx("input",{type:"text","data-testid":`${c}-type`,list:`${c}-type-options`,value:m,onChange:D=>x(D.target.value,v),style:w0}),i.jsx("datalist",{id:`${c}-type-options`,children:f0.map(D=>i.jsx("option",{value:D},D))})]}),i.jsx("div",{style:{marginTop:8,display:"flex",flexDirection:"column",gap:6},children:v.map(D=>i.jsxs("div",{"data-testid":`${c}-prop-${D.id}`,style:k0,children:[i.jsx("input",{type:"text","data-testid":`${c}-prop-key-${D.id}`,value:D.key,placeholder:d("layout_editor.page_settings.seo.sd_prop_key"),onChange:O=>C(D.id,O.target.value),style:{width:160,padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,minWidth:0}}),i.jsx("div",{style:{flex:1,minWidth:0},children:i.jsx(Ve,{value:D.value,onChange:O=>N(D.id,O),t:d,candidates:l,placeholder:d("layout_editor.page_settings.seo.sd_prop_value"),testidPrefix:`${c}-prop-value-${D.id}`})}),i.jsx("button",{type:"button","data-testid":`${c}-prop-remove-${D.id}`,onClick:()=>E(D.id),title:d("layout_editor.page_settings.seo.remove"),"aria-label":d("layout_editor.page_settings.seo.remove"),style:S0,children:"✕"})]},D.id))}),i.jsxs("div",{style:{marginTop:8,display:"flex",gap:8},children:[i.jsxs("button",{type:"button","data-testid":`${c}-add`,onClick:S,style:Sc,children:["+ ",d("layout_editor.page_settings.seo.sd_add_prop")]}),n?i.jsxs("button",{type:"button","data-testid":`${c}-seed-from-module`,onClick:_,style:Sc,children:["↩ ",d("layout_editor.page_settings.seo.sd_seed_from_module")]}):null]}),i.jsxs("p",{"data-testid":`${c}-empty-note`,style:C0,children:["ⓘ ",d("layout_editor.page_settings.seo.sd_empty_note"),z?i.jsxs("span",{style:{color:"#b45309"},children:[" ",d("layout_editor.page_settings.seo.sd_has_empty")]}):null]})]})]})}const vc={display:"flex",flexDirection:"column"},_c={display:"flex",alignItems:"center",gap:8},h0={fontSize:14},y0={fontSize:11,padding:"2px 6px",borderRadius:4,background:"#f1f5f9",color:"#64748b"},b0={marginTop:8,border:"1px solid #fcd34d",borderRadius:8,padding:10,background:"#fffbeb"},m0={border:"1px solid #e2e8f0",borderRadius:8,padding:10,background:"#f8fafc"},xc={display:"flex",gap:8,alignItems:"center",padding:"2px 0"},wc={fontSize:11,color:"#475569",background:"#fff",padding:"2px 6px",borderRadius:4,minWidth:120},kc={fontSize:11,color:"#0f172a",fontFamily:"monospace",wordBreak:"break-all"},v0={display:"inline-flex",alignItems:"center",padding:"2px 8px",fontSize:11,color:"#1d4ed8",background:"#eff6ff",border:"1px solid #bfdbfe",borderRadius:6},_0={margin:"6px 0 0",fontSize:12,color:"#64748b"},x0={margin:"0 0 8px",fontSize:12,color:"#b45309",background:"#fffbeb",border:"1px solid #fcd34d",borderRadius:6,padding:"6px 8px"},w0={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,width:"100%",minWidth:0},k0={display:"flex",gap:6,alignItems:"flex-start",border:"1px solid #e2e8f0",borderRadius:8,padding:8},S0={padding:"4px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#64748b",cursor:"pointer"},Sc={padding:"5px 10px",fontSize:12,border:"1px dashed #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer"},C0={margin:"8px 0 0",fontSize:11,color:"#94a3b8"},Cc=new Set(["core_setting","setting","query","route"]);function $0({vars:e,onChange:t,varCandidates:n,gatingMet:o=!1,candidates:r,t:s,testidPrefix:a="g7le-seo-vars"}){const l=u.useMemo(()=>e&&typeof e=="object"?{...e}:{},[e]),d=u.useMemo(()=>Array.isArray(n)?n:[],[n]),c=u.useMemo(()=>o?d.filter(_=>Cc.has(_.source)):[],[d,o]),p=u.useMemo(()=>o?d.filter(_=>_.source==="data"):[],[d,o]);u.useMemo(()=>new Set(c.map(_=>_.name)),[c]);const f=u.useMemo(()=>new Set(p.map(_=>_.name)),[p]),y=u.useMemo(()=>{const _=new Set;for(const S of d)Cc.has(S.source)&&_.add(S.name);return _},[d]),g=u.useMemo(()=>Object.keys(l).filter(_=>!f.has(_)),[l,f]),m=u.useCallback((_,S)=>{const E={...l};S===void 0?delete E[_]:E[_]=S,t(E)},[l,t]),[h,v]=u.useState(""),b=h.trim()!==""&&y.has(h.trim()),x=h.trim()!==""&&Object.prototype.hasOwnProperty.call(l,h.trim()),k=h.trim()!==""&&!b&&!x&&!f.has(h.trim()),w=u.useCallback(()=>{const _=h.trim();_===""||y.has(_)||Object.prototype.hasOwnProperty.call(l,_)||(t({...l,[_]:""}),v(""))},[h,y,l,t]);return i.jsxs("div",{"data-testid":a,style:{display:"flex",flexDirection:"column",gap:10},children:[i.jsx("span",{"data-testid":"g7le-seo-var-list",hidden:!0,"data-gating":o?"1":"0"}),!o&&i.jsxs("div",{"data-testid":`${a}-precondition`,style:E0,children:["⚠ ",s("layout_editor.page_settings.seo.vars_precondition")]}),c.length>0&&i.jsxs("div",{"data-testid":`${a}-auto-group`,style:_s,children:[i.jsx("div",{style:xs,children:s("layout_editor.page_settings.seo.vars_auto_group")}),c.map(_=>i.jsxs("div",{"data-testid":`g7le-seo-var-auto-${_.name}`,style:I0,children:[i.jsx("span",{style:A0,children:"🔒"}),i.jsx("code",{style:ws,children:_.name}),i.jsxs("span",{style:T0,children:["〔",s("layout_editor.page_settings.seo.vars_auto"),"·",_.source,"〕"]}),_.owner?.name?i.jsx("span",{style:D0,children:_.owner.name}):null]},_.name))]}),p.length>0&&i.jsxs("div",{"data-testid":`${a}-data-group`,style:_s,children:[i.jsx("div",{style:xs,children:s("layout_editor.page_settings.seo.vars_data_group")}),p.map(_=>{const S=l[_.name]??"",E=S.trim()==="";return i.jsxs("div",{"data-testid":`g7le-seo-var-data-${_.name}`,style:$c,children:[i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6,marginBottom:4},children:[i.jsx("code",{style:ws,children:_.name}),_.required?i.jsx("span",{"data-testid":`g7le-seo-var-required-${_.name}`,style:N0,children:"*"}):null]}),i.jsx(Ve,{value:S,onChange:C=>m(_.name,C??""),t:s,candidates:r,testidPrefix:`g7le-seo-var-data-field-${_.name}`}),_.required&&E?i.jsxs("p",{"data-testid":`g7le-seo-var-empty-warn-${_.name}`,style:ks,children:["⚠ ",s("layout_editor.page_settings.seo.vars_required_empty")]}):null]},_.name)})]}),i.jsxs("div",{"data-testid":`${a}-custom-group`,style:_s,children:[i.jsx("div",{style:xs,children:s("layout_editor.page_settings.seo.vars_custom_group")}),g.map(_=>i.jsxs("div",{"data-testid":`g7le-seo-var-custom-${_}`,style:$c,children:[i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6,marginBottom:4},children:[i.jsx("code",{style:ws,children:_}),i.jsx("button",{type:"button","data-testid":`g7le-seo-var-custom-remove-${_}`,onClick:()=>m(_,void 0),title:s("layout_editor.page_settings.seo.remove"),"aria-label":s("layout_editor.page_settings.seo.remove"),style:{marginLeft:"auto",padding:"2px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#64748b",cursor:"pointer"},children:"✕"})]}),i.jsx(Ve,{value:l[_]??"",onChange:S=>m(_,S??""),t:s,candidates:r,testidPrefix:`g7le-seo-var-custom-field-${_}`})]},_)),i.jsxs("div",{"data-testid":"g7le-seo-var-add",style:{display:"flex",flexDirection:"column",gap:4,marginTop:6},children:[i.jsxs("div",{style:{display:"flex",gap:6},children:[i.jsx("input",{type:"text","data-testid":"g7le-seo-var-add-name",value:h,placeholder:s("layout_editor.page_settings.seo.vars_add_name"),onChange:_=>v(_.target.value),style:{flex:1,padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,minWidth:0}}),i.jsxs("button",{type:"button","data-testid":"g7le-seo-var-add-confirm",onClick:w,disabled:h.trim()===""||b||x,style:z0,children:["+ ",s("layout_editor.page_settings.seo.vars_add")]})]}),b?i.jsxs("p",{"data-testid":"g7le-seo-var-add-reserved-warn",style:ks,children:["⚠ ",s("layout_editor.page_settings.seo.vars_name_reserved",{name:h.trim()})]}):null,x&&!b?i.jsxs("p",{"data-testid":"g7le-seo-var-add-duplicate-warn",style:ks,children:["⚠ ",s("layout_editor.page_settings.seo.vars_name_duplicate",{name:h.trim()})]}):null,k?i.jsxs("p",{"data-testid":"g7le-seo-var-add-not-provided-hint",style:{margin:0,fontSize:11,color:"#94a3b8"},children:["ⓘ ",s("layout_editor.page_settings.seo.vars_name_not_provided",{name:h.trim()})]}):null]})]}),i.jsxs("p",{style:{margin:0,fontSize:11,color:"#94a3b8"},children:["ⓘ ",s("layout_editor.page_settings.seo.vars_value_hint")]})]})}const E0={fontSize:12,color:"#b45309",background:"#fffbeb",border:"1px solid #fcd34d",borderRadius:6,padding:"6px 8px"},_s={border:"1px solid #e2e8f0",borderRadius:8,padding:10,display:"flex",flexDirection:"column",gap:6},xs={fontSize:12,fontWeight:600,color:"#475569"},I0={display:"flex",alignItems:"center",gap:6,padding:"2px 0"},$c={display:"flex",flexDirection:"column",border:"1px solid #f1f5f9",borderRadius:6,padding:8},A0={fontSize:13},ws={fontSize:12,color:"#0f172a",background:"#f1f5f9",padding:"2px 6px",borderRadius:4},T0={fontSize:11,color:"#64748b"},D0={fontSize:11,color:"#94a3b8"},N0={color:"#dc2626",fontWeight:700},ks={margin:"4px 0 0",fontSize:11,color:"#b45309"},z0={padding:"5px 10px",fontSize:12,border:"1px dashed #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer",whiteSpace:"nowrap"};function M0({templateIdentifier:e,layout:t,url:n,locale:o,seedContext:r,routeParams:s,settingsSignature:a,t:l,debounceMs:d=400,fetchImpl:c,testidPrefix:p="g7le-seo-bot-preview"}){const[f,y]=u.useState(!0),[g,m]=u.useState(null),[h,v]=u.useState(!1),[b,x]=u.useState(null),k=u.useRef(null),w=u.useRef({templateIdentifier:e,layout:t,url:n,locale:o,seedContext:r,routeParams:s});w.current={templateIdentifier:e,layout:t,url:n,locale:o,seedContext:r,routeParams:s};const _=u.useCallback(async()=>{const N=c??(typeof fetch<"u"?fetch:void 0);if(!N)return;const{templateIdentifier:$,layout:A,url:z,locale:D,seedContext:O,routeParams:M}=w.current;v(!0),x(null);try{const B=`/api/admin/templates/${encodeURIComponent($)}/editor/seo-bot-preview`,T=await N(B,{method:"POST",credentials:"same-origin",headers:je({"Content-Type":"application/json"}),body:JSON.stringify({layout:A,url:z,locale:D,seed_context:O??{},route_params:M??{}})}),H=await T.json().catch(()=>null);if(!T.ok){x(H?.message??`HTTP ${T.status}`);return}const L=H?.data??null;m(L)}catch(B){x(B instanceof Error?B.message:String(B))}finally{v(!1)}},[c]);u.useEffect(()=>{if(f)return k.current&&clearTimeout(k.current),k.current=setTimeout(()=>{_()},d),()=>{k.current&&clearTimeout(k.current)}},[a,f,d,_]);const S=u.useCallback(()=>{k.current&&clearTimeout(k.current),_()},[_]),E=g?.html??null,C=g?.enabled??!0;return i.jsxs("div",{"data-testid":p,style:L0,children:[i.jsxs("div",{style:P0,children:[i.jsxs("button",{type:"button","data-testid":`${p}-toggle`,onClick:()=>y(N=>!N),"aria-expanded":f,style:O0,children:[f?"▾":"▸"," ",l("layout_editor.page_settings.seo.bot_preview_title")]}),f?i.jsxs("button",{type:"button","data-testid":`${p}-refresh`,onClick:S,disabled:h,style:B0,children:["⟳ ",l("layout_editor.page_settings.seo.bot_preview_refresh")]}):null]}),f&&i.jsxs("div",{style:{marginTop:8},children:[g&&!C?i.jsxs("p",{"data-testid":`${p}-disabled`,style:W0,children:["ⓘ ",l("layout_editor.page_settings.seo.bot_preview_disabled")]}):i.jsx("div",{"data-testid":`${p}-body`,style:j0,children:h&&!E?i.jsx("p",{style:Ss,children:l("layout_editor.translation.loading")}):b?i.jsx("p",{"data-testid":`${p}-error`,style:{...Ss,color:"#dc2626"},children:b}):E==null?i.jsx("p",{style:Ss,children:l("layout_editor.page_settings.seo.bot_preview_empty")}):i.jsx("pre",{"data-testid":`${p}-code`,style:H0,children:E})}),i.jsxs("p",{"data-testid":`${p}-sample-note`,style:F0,children:["ⓘ ",l("layout_editor.page_settings.seo.bot_preview_sample_note")]})]})]})}const L0={borderTop:"1px solid #e2e8f0",paddingTop:10,marginTop:12},P0={display:"flex",alignItems:"center",gap:8},O0={background:"none",border:"none",cursor:"pointer",fontSize:13,fontWeight:600,color:"#0f172a",padding:0},B0={marginLeft:"auto",padding:"4px 10px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},W0={margin:0,fontSize:12,color:"#64748b",background:"#f8fafc",border:"1px solid #e2e8f0",borderRadius:6,padding:"8px 10px"},j0={border:"1px solid #e2e8f0",borderRadius:8,background:"#0b1020",padding:0,minHeight:80,overflow:"hidden"},H0={margin:0,padding:12,fontSize:11,lineHeight:1.5,color:"#d1fae5",fontFamily:"monospace",whiteSpace:"pre-wrap",wordBreak:"break-word",maxHeight:360,overflow:"auto"},Ss={margin:0,padding:12,fontSize:12,color:"#94a3b8"},F0={margin:"8px 0 0",fontSize:11,color:"#94a3b8"};function Ec({option:e,testIdPrefix:t}){const{id:n,friendly:o,source:r}=e;return i.jsxs("span",{style:K0,children:[i.jsx("span",{"data-testid":t?`${t}-title`:void 0,style:V0,children:o??n}),o?i.jsx("span",{"data-testid":t?`${t}-id`:void 0,style:U0,children:n}):null,r?i.jsx("span",{"data-testid":t?`${t}-source`:void 0,style:q0,children:r}):null]})}const K0={display:"inline-flex",alignItems:"baseline",gap:4,flexWrap:"wrap",minWidth:0},V0={fontSize:12,color:"#0f172a"},U0={fontSize:10,color:"#94a3b8",fontFamily:"ui-monospace, monospace"},q0={fontSize:9,fontWeight:600,color:"#7c3aed",background:"#f5f3ff",border:"1px solid #ddd6fe",borderRadius:4,padding:"0 5px",whiteSpace:"nowrap"},G0=["always","hourly","daily","weekly","monthly","yearly","never"];function Y0({getValue:e,patch:t,t:n,templateIdentifier:o,candidates:r,availableExtensions:s=[],dataSourceOptions:a=[],previewUrl:l="/",previewLocale:d="ko",seedContext:c,candidatesFetcher:p,ogPreviewFetcher:f,botFetchImpl:y,testidPrefix:g="g7le-seo"}){const m=e("meta",{}).seo,h=m&&typeof m=="object"?m:{},v=u.useCallback((K,ne)=>{const me={...e("meta",{})},Ce={...me.seo??{}};ne==null||ne===""?delete Ce[K]:Ce[K]=ne,me.seo=Ce,t("meta",me)},[e,t]),b=h.enabled===!0,x=typeof h.page_type=="string"?h.page_type:"",k=Array.isArray(h.extensions)?h.extensions:[],w=Array.isArray(h.data_sources)?h.data_sources:[],_=k.length>0&&x!=="",[S,E]=u.useState(null),[C,N]=u.useState(null),$=u.useCallback(async(K,ne)=>{if(p)return p(K,ne);if(typeof fetch>"u")return null;const me=new URLSearchParams;K&&me.set("page_type",K),Array.isArray(ne)&&ne.length>0&&me.set("extensions",JSON.stringify(ne.map(qe=>({type:qe.type,id:qe.id}))));const Ce=me.toString()?`?${me.toString()}`:"",ze=await fetch(`/api/admin/templates/${encodeURIComponent(o)}/editor/seo-candidates.json${Ce}`,{credentials:"same-origin",headers:je()}),Se=await ze.json().catch(()=>null);return ze.ok?Se?.data??null:null},[p,o]),A=u.useMemo(()=>{const me=(e("__editor",{})??{}).original?.meta?.seo;return me&&typeof me=="object"?me:void 0},[e]),z=u.useRef(A);z.current=A;const D=u.useCallback(async K=>{if(f)return f(K);if(typeof fetch>"u")return null;const ne=await fetch(`/api/admin/templates/${encodeURIComponent(o)}/editor/seo-og-preview`,{method:"POST",credentials:"same-origin",headers:je({"Content-Type":"application/json"}),body:JSON.stringify({seo:K,own_seo:z.current??null,seed_context:c??{},route_params:{}})}),me=await ne.json().catch(()=>null);return ne.ok?me?.data??null:null},[f,o,c]),O=u.useMemo(()=>JSON.stringify(k),[k]),M=u.useRef(h);M.current=h,u.useEffect(()=>{let K=!1;return(async()=>{const ne=await $(x,k);K||E(ne)})(),()=>{K=!0}},[x,O]);const B=u.useMemo(()=>JSON.stringify({og:h.og??null,twitter:h.twitter??null,structured_data:h.structured_data??null}),[h.og,h.twitter,h.structured_data]),T=u.useRef(!1);u.useEffect(()=>{let K=!1;const ne=()=>{(async()=>{const Ce=await D(M.current);K||N(Ce)})()};if(!T.current)return T.current=!0,ne(),()=>{K=!0};const me=setTimeout(ne,180);return()=>{K=!0,clearTimeout(me)}},[x,O,B]);const H=S?.page_types??[],L=S?.toggle_settings??[],I=S?.vars??[],P=u.useMemo(()=>JSON.stringify(h),[h]),F=u.useMemo(()=>e("meta")?{meta:e("meta"),components:e("components",[])}:{meta:{},components:[]},[e]),G=u.useCallback(K=>{k.some(ne=>ne.type===K.type&&ne.id===K.id)||v("extensions",[...k,{type:K.type,id:K.id}])},[k,v]),V=u.useCallback(K=>{const ne=k.filter(me=>!(me.type===K.type&&me.id===K.id));v("extensions",ne.length>0?ne:void 0)},[k,v]),[Y,Z]=u.useState(!1),[se,re]=u.useState(""),U=u.useMemo(()=>{const K=se.trim().toLowerCase();return K?L.filter(ne=>`${ne.ref} ${ne.label??""}`.toLowerCase().includes(K)):L},[L,se]),ee=typeof h.toggle_setting=="string"?h.toggle_setting:"",fe=u.useCallback(K=>{const me=w.includes(K)?w.filter(Ce=>Ce!==K):[...w,K];v("data_sources",me.length>0?me:void 0)},[w,v]);return i.jsxs("div",{"data-testid":g,style:{display:"flex",flexDirection:"column",gap:14},children:[i.jsxs("div",{style:Bt,children:[i.jsx(Ar,{checked:b,onChange:K=>v("enabled",K),testid:`${g}-enabled`,label:n("layout_editor.page_settings.seo.enabled")}),b?null:i.jsxs("p",{"data-testid":`${g}-disabled-note`,style:eo,children:["ⓘ ",n("layout_editor.page_settings.seo.disabled_note")]})]}),i.jsx(Tr,{disabled:!b,testid:`${g}-body`,style:{display:"flex",flexDirection:"column",gap:14},children:i.jsxs(i.Fragment,{children:[i.jsxs("div",{style:Bt,children:[i.jsx("label",{style:qt,children:n("layout_editor.page_settings.seo.page_type")}),H.length>0?i.jsxs("select",{"data-testid":`${g}-page-type`,value:x,onChange:K=>v("page_type",K.target.value),style:zr,children:[i.jsx("option",{value:"",children:n("layout_editor.page_settings.seo.page_type_none")}),H.map(K=>i.jsxs("option",{value:K.value,children:[K.label??K.value,K.owner?.name?` (${K.owner.name})`:""]},K.value))]}):i.jsxs(i.Fragment,{children:[i.jsx("input",{type:"text","data-testid":`${g}-page-type-free`,value:x,placeholder:n("layout_editor.page_settings.seo.page_type_free_placeholder"),onChange:K=>v("page_type",K.target.value),style:zr}),i.jsxs("p",{"data-testid":`${g}-page-type-free-note`,style:eo,children:["ⓘ ",n("layout_editor.page_settings.seo.page_type_free_note")]})]})]}),i.jsxs("div",{style:Bt,children:[i.jsxs("label",{style:qt,children:["★ ",n("layout_editor.page_settings.seo.extensions")]}),i.jsxs("div",{"data-testid":`${g}-extensions`,style:{display:"flex",flexWrap:"wrap",gap:6},children:[k.map(K=>i.jsxs("span",{"data-testid":`${g}-extension-${K.type}-${K.id}`,style:Ic,children:[K.name??`${K.type}:${K.id}`,i.jsx("button",{type:"button","data-testid":`${g}-extension-remove-${K.type}-${K.id}`,onClick:()=>V(K),style:Ac,children:"✕"})]},`${K.type}:${K.id}`)),i.jsxs("button",{type:"button","data-testid":`${g}-extension-add`,onClick:()=>Z(K=>!K),style:Z0,children:["+ ",n("layout_editor.page_settings.seo.add_extension")]})]}),Y&&i.jsx("div",{"data-testid":`${g}-extension-picker`,style:Tc,children:s.length===0?i.jsx("p",{style:eo,children:n("layout_editor.page_settings.seo.no_extension_candidate")}):s.map(K=>i.jsx("button",{type:"button","data-testid":`${g}-extension-option-${K.type}-${K.id}`,onClick:()=>{G(K),Z(!1)},style:Dc,children:K.name??`${K.type}:${K.id}`},`${K.type}:${K.id}`))})]}),!_&&i.jsxs("div",{"data-testid":`${g}-defaults-precondition`,style:J0,children:["⚠ ",n("layout_editor.page_settings.seo.defaults_precondition"),i.jsxs("div",{"data-testid":`${g}-defaults-missing`,style:{fontSize:11,marginTop:4},children:[k.length===0?n("layout_editor.page_settings.seo.missing_extensions"):"",x===""?` ${n("layout_editor.page_settings.seo.missing_page_type")}`:""]})]}),i.jsxs("div",{style:Bt,children:[i.jsx("label",{style:qt,children:n("layout_editor.page_settings.seo.toggle_setting")}),i.jsxs("div",{"data-testid":`${g}-toggle-setting`,children:[ee?i.jsxs("div",{style:{display:"flex",gap:6,alignItems:"center",marginBottom:4},children:[i.jsx("code",{style:Ic,children:ee}),i.jsx("button",{type:"button","data-testid":`${g}-toggle-setting-clear`,onClick:()=>v("toggle_setting",void 0),style:Ac,children:"✕"})]}):null,i.jsx("input",{type:"text","data-testid":`${g}-toggle-setting-search`,value:se,placeholder:n("layout_editor.page_settings.seo.toggle_setting_search"),onChange:K=>re(K.target.value),style:zr}),se.trim()!==""&&i.jsx("div",{"data-testid":`${g}-toggle-setting-results`,style:Tc,children:U.length===0?i.jsx("p",{style:eo,children:n("layout_editor.inline_binding.no_results")}):U.map(K=>i.jsx("button",{type:"button","data-testid":`${g}-toggle-setting-option-${K.ref}`,onClick:()=>{v("toggle_setting",K.ref),re("")},style:Dc,children:K.label??K.ref},K.ref))})]})]}),i.jsxs("div",{style:Bt,children:[i.jsx("label",{style:qt,children:n("layout_editor.page_settings.seo.data_sources")}),i.jsx("p",{style:eo,children:n("layout_editor.page_settings.seo.data_sources_hint")}),i.jsx("div",{"data-testid":`${g}-data-sources`,style:{display:"flex",flexDirection:"column",gap:6},children:a.length===0?i.jsx("span",{style:eo,children:n("layout_editor.page_settings.seo.data_sources_empty")}):a.map(K=>{const ne=w.includes(K.id);return i.jsxs("label",{"data-testid":`${g}-data-source-${K.id}`,style:X0(ne),children:[i.jsx("input",{type:"checkbox","data-testid":`${g}-data-source-check-${K.id}`,checked:ne,onChange:()=>fe(K.id)}),i.jsx(Ec,{option:K,testIdPrefix:`${g}-data-source-${K.id}`})]},K.id)})})]}),i.jsxs("div",{style:Bt,children:[i.jsx("label",{style:qt,children:n("layout_editor.page_settings.seo.priority")}),i.jsx("input",{type:"range","data-testid":`${g}-priority`,min:0,max:1,step:.1,value:typeof h.priority=="number"?h.priority:.5,onChange:K=>v("priority",Number(K.target.value))}),i.jsx("span",{"data-testid":`${g}-priority-value`,style:{fontSize:12,color:"#475569"},children:typeof h.priority=="number"?h.priority:.5})]}),i.jsxs("div",{style:Bt,children:[i.jsx("label",{style:qt,children:n("layout_editor.page_settings.seo.changefreq")}),i.jsxs("select",{"data-testid":`${g}-changefreq`,value:typeof h.changefreq=="string"?h.changefreq:"",onChange:K=>v("changefreq",K.target.value),style:zr,children:[i.jsx("option",{value:"",children:n("layout_editor.page_settings.seo.use_default")}),G0.map(K=>i.jsx("option",{value:K,children:K},K))]})]}),i.jsxs("div",{style:Bt,children:[i.jsx("label",{style:qt,children:n("layout_editor.page_settings.seo.title")}),i.jsx(vt,{value:typeof h.title=="string"?h.title:"",onChange:K=>v("title",K??""),t:n,candidates:r,testidPrefix:`${g}-title`,enableExpressionTree:!0,expressionTreeCollapsible:!0})]}),i.jsxs("div",{style:Bt,children:[i.jsx("label",{style:qt,children:n("layout_editor.page_settings.seo.description")}),i.jsx(vt,{value:typeof h.description=="string"?h.description:"",onChange:K=>v("description",K??""),t:n,candidates:r,testidPrefix:`${g}-description`,enableExpressionTree:!0,expressionTreeCollapsible:!0})]}),i.jsx(e0,{og:h.og,twitter:h.twitter,onChangeOg:K=>v("og",Object.keys(K).length>0?K:void 0),onChangeTwitter:K=>v("twitter",Object.keys(K).length>0?K:void 0),ogPreview:C?.og,twitterPreview:C?.twitter,defaultsAvailable:C?.defaultsAvailable??!1,candidates:r,t:n}),i.jsxs("div",{style:Bt,children:[i.jsx("label",{style:qt,children:n("layout_editor.page_settings.seo.structured_data")}),i.jsx(g0,{value:h.structured_data,onChange:K=>v("structured_data",K??void 0),autoBlock:C?.structured?.autoBlock,autoMeta:C?.structured?.autoMeta,lockedByFilter:C?.structured?.lockedByFilter,filteredBlock:C?.structured?.filteredBlock,pageType:x,candidates:r,t:n})]}),i.jsxs("div",{style:Bt,children:[i.jsx("label",{style:qt,children:n("layout_editor.page_settings.seo.vars")}),i.jsx($0,{vars:h.vars,onChange:K=>v("vars",Object.keys(K).length>0?K:void 0),varCandidates:I,gatingMet:_,candidates:r,t:n})]}),i.jsx(M0,{templateIdentifier:o,layout:F,url:l,locale:d,seedContext:c,settingsSignature:P,t:n,fetchImpl:y})]})})]})}const Bt={display:"flex",flexDirection:"column",gap:4},qt={fontSize:12,fontWeight:600,color:"#334155"},zr={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,width:"100%",minWidth:0,boxSizing:"border-box"},eo={margin:"4px 0 0",fontSize:11,color:"#94a3b8"},X0=e=>({display:"flex",alignItems:"center",gap:8,padding:"6px 10px",fontSize:12,border:`1px solid ${e?"#3b82f6":"#e2e8f0"}`,borderRadius:8,background:e?"#eff6ff":"#fff",cursor:"pointer",minWidth:0}),J0={fontSize:12,color:"#b45309",background:"#fffbeb",border:"1px solid #fcd34d",borderRadius:6,padding:"8px 10px"},Ic={display:"inline-flex",alignItems:"center",gap:4,fontSize:11,padding:"3px 8px",borderRadius:999,background:"#eef2ff",color:"#3730a3"},Ac={border:"none",background:"transparent",cursor:"pointer",color:"#6366f1",fontSize:11,padding:0},Z0={fontSize:11,padding:"3px 10px",borderRadius:999,border:"1px dashed #cbd5e1",background:"#f8fafc",color:"#475569",cursor:"pointer"},Tc={marginTop:4,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",padding:6,display:"flex",flexDirection:"column",gap:2},Dc={textAlign:"left",border:"none",background:"transparent",cursor:"pointer",padding:"5px 8px",borderRadius:4,fontSize:12,color:"#0f172a"},Q0={setState:"state",loadFromLocalStorage:"state",saveToLocalStorage:"state",navigate:"nav",openWindow:"nav",navigateBack:"nav",navigateForward:"nav",replaceUrl:"nav",toast:"notify",openModal:"notify",closeModal:"notify",showAlert:"notify",setError:"notify",refetchDataSource:"data",appendDataSource:"data",updateDataSource:"data",apiCall:"data",scrollIntoView:"etc",login:"etc",logout:"etc",setLocale:"locale",emitEvent:"etc",conditions:"flow",sequence:"flow",parallel:"flow",switch:"flow",suppress:"flow",showErrorPage:"error"},R0={state:"layout_editor.action.group.state",nav:"layout_editor.action.group.nav",notify:"layout_editor.action.group.notify",data:"layout_editor.action.group.data",locale:"layout_editor.action.group.locale",etc:"layout_editor.action.group.etc",flow:"layout_editor.action.group.flow",error:"layout_editor.action.group.error",extension:"layout_editor.action.group.extension"},ew={init:["state","data","notify","nav","locale","etc","flow","error","extension"],component:["nav","notify","data","state","locale","etc","flow","error","extension"]};function tw(e,t){const n=e?.[t];if(n&&typeof n=="object"){const o=n.__source;if(o&&typeof o=="object")return o}return null}function nw(e,t){return!e||e.kind==="core"?t("layout_editor.action.source_core"):e.id??t(`layout_editor.action.source_${e.kind}`)}function Nc(e,t,n){return typeof e!="string"?n:e.startsWith("$t:")?t(e.slice(3)):e}function ow({recipes:e,t,onAdd:n,context:o="init",testIdPrefix:r="g7le-action-add"}){const[s,a]=u.useState(!1),[l,d]=u.useState(""),c=u.useMemo(()=>vo(e),[e]),p=u.useMemo(()=>{const f=ew[o],y=new Map;for(const m of c){const h=tw(e,m.id),b=h!=null&&h.kind!=="core"?"extension":Q0[m.id]??"etc",x=y.get(b)??[];x.push({recipe:m,source:h}),y.set(b,x)}const g=l.trim().toLowerCase();return f.map(m=>{let h=y.get(m)??[];return g&&(h=h.filter(({recipe:v})=>Nc(v.label,t,v.id).toLowerCase().includes(g)||v.id.toLowerCase().includes(g)||v.build.handler.toLowerCase().includes(g))),{groupKey:m,items:h}}).filter(m=>m.items.length>0)},[c,e,o,l,t]);return c.length===0?i.jsx("div",{"data-testid":`${r}-empty`,style:fw,children:t("layout_editor.action.no_recipes")}):i.jsxs("div",{className:r,"data-testid":r,style:{minWidth:0},children:[i.jsxs("button",{type:"button","data-testid":`${r}-toggle`,onClick:()=>a(f=>!f),style:rw,children:[t("layout_editor.action.add_action")," ▾"]}),s?i.jsxs("div",{"data-testid":`${r}-list`,style:iw,children:[i.jsx("input",{type:"text","data-testid":`${r}-search`,value:l,placeholder:t("layout_editor.action.add_search"),onChange:f=>d(f.target.value),style:sw}),p.map(({groupKey:f,items:y})=>i.jsxs("div",{"data-testid":`${r}-group-${f}`,children:[i.jsxs("div",{style:aw,children:["━━ ",t(R0[f]??f)," ━━"]}),y.map(({recipe:g,source:m})=>i.jsxs("button",{type:"button","data-testid":`g7le-init-action-spec-${g.id}`,onClick:()=>{n(vr(g,{})),a(!1),d("")},style:lw,children:[i.jsx("span",{style:dw,children:Nc(g.label,t,g.id)}),i.jsxs("span",{"data-testid":`g7le-init-action-spec-source-${g.id}`,style:cw,children:["〔",nw(m,t),"〕"]}),i.jsx("code",{style:uw,children:g.build.handler})]},g.id))]},f)),p.length===0?i.jsx("div",{"data-testid":`${r}-no-match`,style:pw,children:t("layout_editor.action.add_no_match")}):null]}):null]})}const rw={padding:"6px 12px",fontSize:12,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},iw={marginTop:6,border:"1px solid #cbd5e1",borderRadius:8,background:"#fff",maxHeight:320,overflowY:"auto",padding:8},sw={width:"100%",padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,marginBottom:6,boxSizing:"border-box"},aw={fontSize:11,fontWeight:700,color:"#94a3b8",padding:"6px 4px 2px"},lw={display:"flex",alignItems:"center",gap:6,width:"100%",textAlign:"left",padding:"6px 8px",fontSize:12,border:"none",background:"transparent",cursor:"pointer",borderRadius:6},dw={flex:1,minWidth:0,color:"#0f172a"},cw={fontSize:10,color:"#64748b",whiteSpace:"nowrap"},uw={fontSize:10,color:"#cbd5e1",fontFamily:"monospace",whiteSpace:"nowrap"},pw={fontSize:12,color:"#94a3b8",padding:"8px 4px"},fw={fontSize:12,color:"#94a3b8",padding:"8px 0"};function gw(e,t){return t.startsWith("$t:")?e(t.slice(3)):t}function hw({action:e,recipe:t,values:n={},t:o,missingRequired:r=!1,showCodeButton:s=!0,testIdPrefix:a="g7le-action-preview"}){const[l,d]=u.useState(!1),c=t?Ld(t,n,p=>gw(o,p)):typeof e.handler=="string"?e.handler:o("layout_editor.action.advanced_action");return i.jsxs("div",{className:a,"data-testid":a,style:yw,children:[r?i.jsxs("span",{"data-testid":`${a}-missing`,style:mw,children:["⚠ ",o("layout_editor.action.summary_missing_required")]}):i.jsx("span",{"data-testid":`${a}-summary`,style:bw,children:c}),s?i.jsx("button",{type:"button","data-testid":`${a}-code-toggle`,onClick:()=>d(p=>!p),style:vw,"aria-expanded":l,"aria-label":o("layout_editor.action.view_code"),children:""}):null,s&&l?i.jsx("pre",{"data-testid":`${a}-code`,style:_w,children:JSON.stringify(e,null,2)}):null]})}const yw={display:"flex",flexWrap:"wrap",alignItems:"center",gap:6,minWidth:0},bw={flex:1,minWidth:0,fontSize:12,color:"#475569"},mw={flex:1,minWidth:0,fontSize:12,color:"#b45309"},vw={border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer",fontSize:11,padding:"2px 6px",whiteSpace:"nowrap"},_w={flexBasis:"100%",margin:"6px 0 0",padding:8,background:"#0f172a",color:"#e2e8f0",borderRadius:6,fontSize:11,overflow:"auto"},zc="__g7le_custom__";function Mr({value:e,onChange:t,candidates:n,placeholder:o,testid:r,t:s,bindingCandidates:a}){const l=e==null?"":String(e),d=Array.isArray(n)&&n.length>0,c=d&&n.some(y=>y.value===l),[p,f]=u.useState(!d||l!==""&&!c);return u.useEffect(()=>{l!==""&&d&&!c&&f(!0)},[l,d,c]),d&&!p?i.jsxs("div",{style:Oc,children:[i.jsxs("select",{className:"g7le-widget g7le-widget--picker","data-testid":r,value:c?l:"",onChange:y=>{const g=y.target.value;if(g===zc){f(!0);return}t(g===""?void 0:g)},style:{...Lc,flex:1,minWidth:0},children:[i.jsx("option",{value:"",children:s("layout_editor.control.default")}),n.map(y=>i.jsx("option",{value:y.value,children:y.label},y.value)),i.jsx("option",{value:zc,children:s("layout_editor.recipe.custom_input")})]}),i.jsx("button",{type:"button","data-testid":`${r}-to-custom`,title:s("layout_editor.recipe.custom_input"),"aria-label":s("layout_editor.recipe.custom_input"),onClick:()=>f(!0),style:Bc,children:"✎"})]}):i.jsxs("div",{style:Oc,children:[i.jsx("div",{style:{flex:1,minWidth:0},children:i.jsx(Ve,{value:l,onChange:y=>t(y===""?void 0:y),t:s,candidates:a,placeholder:o,testidPrefix:`${r}-input`})}),d&&i.jsx("button",{type:"button","data-testid":`${r}-pick`,title:s("layout_editor.recipe.pick_from_list"),onClick:()=>{f(!1),t(void 0)},style:Bc,children:"≡"})]})}function xw({value:e,onChange:t,candidates:n,t:o,bindingCandidates:r}){return i.jsx(Mr,{value:e,onChange:t,candidates:n,placeholder:o("layout_editor.recipe.page_placeholder"),testid:"g7le-widget-page-picker",t:o,bindingCandidates:r})}function Mc({value:e,onChange:t,candidates:n,t:o,bindingCandidates:r}){return i.jsx(Mr,{value:e,onChange:t,candidates:n,placeholder:o("layout_editor.recipe.datasource_placeholder"),testid:"g7le-widget-datasource-picker",t:o,bindingCandidates:r})}function Cs({value:e,onChange:t,candidates:n,t:o,bindingCandidates:r}){return i.jsx(Mr,{value:e,onChange:t,candidates:n,placeholder:o("layout_editor.recipe.state_key_placeholder"),testid:"g7le-widget-state-key-picker",t:o,bindingCandidates:r})}function ww({value:e,onChange:t,candidates:n,t:o,bindingCandidates:r}){return i.jsx(Mr,{value:e,onChange:t,candidates:n,placeholder:o("layout_editor.recipe.modal_placeholder"),testid:"g7le-widget-modal-picker",t:o,bindingCandidates:r})}function kw({value:e,onChange:t,t:n,bindingCandidates:o}){const r=typeof e=="string"?e:e==null?"":String(e);return i.jsx("div",{className:"g7le-widget g7le-widget--i18n-text","data-testid":"g7le-widget-i18n-text",style:{flex:1,minWidth:100,width:"100%"},children:i.jsx(vt,{value:r,onChange:s=>t(s===""?void 0:s),t:n,placeholder:n("layout_editor.recipe.i18n_text_placeholder"),testidPrefix:"g7le-widget-i18n-text-field",candidates:o,enableExpressionTree:!0,expressionTreeCollapsible:!0})})}function Sw({value:e,onChange:t,candidates:n,t:o}){const r=e==null?"":String(e),s=r.split(",").map(d=>d.trim()).filter(Boolean),a=Array.isArray(n)&&n.length>0,l=d=>{if(!d)return;const c=[...s,d];t(c.join(", "))};return i.jsxs("div",{className:"g7le-widget g7le-widget--binding-list","data-testid":"g7le-widget-binding-list",style:{display:"flex",flexDirection:"column",gap:4,width:"100%"},children:[i.jsx("input",{type:"text","data-testid":"g7le-widget-binding-list-input",value:r,placeholder:o("layout_editor.recipe.datasource_placeholder"),onChange:d=>t(d.target.value===""?void 0:d.target.value),style:Pc}),a?i.jsxs("select",{className:"g7le-widget g7le-widget--picker","data-testid":"g7le-widget-binding-list-add",value:"",onChange:d=>{l(d.target.value),d.target.value=""},style:{...Lc,minWidth:0},children:[i.jsx("option",{value:"",children:o("layout_editor.recipe.pick_from_list")}),n.map(d=>i.jsx("option",{value:d.value,children:d.label},d.value))]}):null]})}function Cw({value:e,onChange:t}){const n=e==null?"":String(e);return i.jsx("input",{type:"text",className:"g7le-widget g7le-widget--text","data-testid":"g7le-widget-text",value:n,onChange:o=>t(o.target.value===""?void 0:o.target.value),style:Pc})}const Lc={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",minWidth:140,width:"100%"},Pc={flex:1,padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,minWidth:100,width:"100%"},Oc={display:"flex",gap:4,alignItems:"flex-start",width:"100%"},Bc={padding:"4px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#64748b",cursor:"pointer"};function $w(e){if(e==null)return"null";if(Array.isArray(e))return"list";switch(typeof e){case"number":return"number";case"boolean":return"boolean";case"string":return"string";default:return"object"}}function Wc(e){switch(e){case"string":return"";case"number":return 0;case"boolean":return!1;case"null":return null;case"list":return[];case"object":return{};default:return null}}function $s(e){return/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(e)}function Ew(e,t,n){return t.includes(n)?"self":e.includes(n)?"inherited":"self"}function Iw(e){const t=e.state&&typeof e.state=="object"&&Object.keys(e.state).length>0,n=e.initLocal&&typeof e.initLocal=="object"?e.initLocal:void 0;if(!t)return{initLocal:n??{},migrated:!1};const o={...e.state};if(n)for(const[r,s]of Object.entries(n))o[r]=s;return{initLocal:o,migrated:!0}}const jc=[{kind:"string",labelKey:"layout_editor.initstate.kind_string"},{kind:"number",labelKey:"layout_editor.initstate.kind_number"},{kind:"boolean",labelKey:"layout_editor.initstate.kind_boolean"},{kind:"null",labelKey:"layout_editor.initstate.kind_null"},{kind:"list",labelKey:"layout_editor.initstate.kind_list"},{kind:"object",labelKey:"layout_editor.initstate.kind_object"}];function Dn({value:e,onChange:t,t:n,path:o,scope:r,depth:s=0,renderStringLeaf:a}){const l=$w(e),d=c=>{t(Wc(c))};return i.jsxs("div",{className:"g7le-initstate-value",style:{paddingLeft:s>0?12:0,minWidth:0},children:[i.jsxs("div",{style:Tw,children:[i.jsx("select",{"data-testid":`g7le-initstate-type-${o}`,value:l,onChange:c=>d(c.target.value),style:Hc,children:jc.map(({kind:c,labelKey:p})=>i.jsx("option",{value:c,children:n(p)},c))}),l==="string"?a?i.jsx("div",{style:{flex:1,minWidth:0},children:a({value:String(e??""),onChange:c=>t(c),testidPrefix:`g7le-initstate-value-${o}`})}):i.jsx("input",{type:"text","data-testid":`g7le-initstate-value-${o}`,value:String(e??""),onChange:c=>t(c.target.value),style:Fc}):l==="number"?i.jsx("input",{type:"number","data-testid":`g7le-initstate-value-${o}`,value:typeof e=="number"?e:"",onChange:c=>{const p=parseFloat(c.target.value);t(Number.isNaN(p)?0:p)},style:Fc}):l==="boolean"?i.jsx(Ar,{checked:!!e,onChange:t,testid:`g7le-initstate-value-${o}`,label:n(e?"layout_editor.overlay.on":"layout_editor.overlay.off")}):l==="null"?i.jsx("span",{"data-testid":`g7le-initstate-value-${o}`,style:Dw,children:"null"}):null]}),l==="list"?i.jsxs("div",{style:Kc,children:[e.map((c,p)=>i.jsxs("div",{style:Nw,children:[i.jsxs("span",{style:zw,children:["[",p,"]"]}),i.jsx("div",{style:{flex:1,minWidth:0},children:i.jsx(Dn,{value:c,onChange:f=>{const y=e.slice();y[p]=f,t(y)},t:n,path:`${o}.${p}`,scope:r,depth:s+1,renderStringLeaf:a})}),i.jsx("button",{type:"button","data-testid":`g7le-initstate-item-remove-${o}.${p}`,onClick:()=>t(e.filter((f,y)=>y!==p)),style:Vc,children:"✕"})]},p)),i.jsxs("button",{type:"button","data-testid":`g7le-initstate-item-add-${o}`,onClick:()=>t([...e,""]),style:Uc,children:["+ ",n("layout_editor.initstate.add_item")]})]}):null,l==="object"?i.jsx(Aw,{value:e,onChange:t,t:n,path:o,scope:r,depth:s+1,renderStringLeaf:a}):null]})}function Aw({value:e,onChange:t,t:n,path:o,scope:r,depth:s,renderStringLeaf:a}){const[l,d]=u.useState(""),[c,p]=u.useState("string"),[f,y]=u.useState(null),g=Object.entries(e),m=()=>{const h=l.trim();if(!h){y(n("layout_editor.initstate.name_required"));return}if(!$s(h)){y(n("layout_editor.initstate.name_invalid"));return}if(h in e){y(n("layout_editor.initstate.name_duplicate"));return}t({...e,[h]:Wc(c)}),d(""),y(null)};return i.jsxs("div",{style:Kc,children:[g.map(([h,v])=>i.jsxs("div",{style:Mw,children:[i.jsx("code",{style:Lw,children:h}),i.jsx("div",{style:{flex:1,minWidth:0},children:i.jsx(Dn,{value:v,onChange:b=>t({...e,[h]:b}),t:n,path:`${o}.${h}`,scope:r,depth:s,renderStringLeaf:a})}),i.jsx("button",{type:"button","data-testid":`g7le-initstate-subremove-${o}.${h}`,onClick:()=>{const b={...e};delete b[h],t(b)},style:Vc,children:"✕"})]},h)),i.jsxs("div",{style:Pw,children:[i.jsx("input",{type:"text","data-testid":`g7le-initstate-subkey-input-${o}`,value:l,placeholder:n("layout_editor.initstate.subkey_name"),onChange:h=>{d(h.target.value),f&&y(null)},onKeyDown:h=>{h.key==="Enter"&&(h.preventDefault(),m())},"aria-invalid":f?!0:void 0,style:{...Ow,...f?Bw:null}}),i.jsx("select",{"data-testid":`g7le-initstate-subkey-kind-${o}`,value:c,onChange:h=>p(h.target.value),"aria-label":n("layout_editor.initstate.value_kind"),style:Hc,children:jc.map(({kind:h,labelKey:v})=>i.jsx("option",{value:h,children:n(v)},h))}),i.jsxs("button",{type:"button","data-testid":`g7le-initstate-subkey-add-${o}`,onClick:m,style:Uc,children:["+ ",n("layout_editor.initstate.add_subkey")]})]}),f?i.jsx("p",{"data-testid":`g7le-initstate-subkey-error-${o}`,style:Ww,children:f}):null]})}const Tw={display:"flex",gap:6,alignItems:"center",minWidth:0},Hc={padding:"4px 6px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,color:"#64748b"},Fc={flex:1,minWidth:0,padding:"4px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,boxSizing:"border-box"},Dw={fontSize:12,color:"#94a3b8",fontFamily:"monospace"},Kc={display:"flex",flexDirection:"column",gap:6,marginTop:6,paddingLeft:8,borderLeft:"2px solid #e2e8f0"},Nw={display:"flex",gap:6,alignItems:"flex-start",minWidth:0},zw={fontSize:11,color:"#94a3b8",minWidth:28,paddingTop:4},Mw={display:"flex",gap:6,alignItems:"flex-start",minWidth:0},Lw={fontSize:11,color:"#475569",fontFamily:"monospace",minWidth:72,paddingTop:4},Vc={border:"none",background:"transparent",color:"#94a3b8",cursor:"pointer",fontSize:12},Uc={alignSelf:"flex-start",padding:"2px 8px",fontSize:11,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},Pw={display:"flex",gap:6,alignItems:"center"},Ow={padding:"4px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6},Bw={border:"1px solid #dc2626"},Ww={margin:"2px 0 0",fontSize:11,color:"#dc2626"},jw=new Set(["text","data-chip","number"]),Hw=new Set(["select","toggle","page-picker","datasource-picker","state-key-picker","modal-picker","component-target-picker","tag-input","locale-picker","i18n-text","segmented","slider","color","image"]);function Fw(e,t,n){return typeof e!="string"?n:e.startsWith("$t:")?t(e.slice(3)):e}function Kw(e,t){if(e==="page-picker")return t.pageCandidates;if(e==="datasource-picker")return t.dataSourceCandidates;if(e==="state-key-picker")return t.stateKeyCandidates;if(e==="modal-picker")return t.modalCandidates}function Vw({param:e,value:t,t:n,pools:o,onChange:r,testIdPrefix:s,renderActionList:a,siblingValues:l}){const d=Fw(e.label,n,e.key);if(e.advanced)return i.jsxs("div",{"data-testid":`${s}-locked-${e.key}`,style:ek,children:[i.jsx("span",{style:tk,children:d}),i.jsxs("span",{style:nk,children:["[",n("layout_editor.action.advanced"),"] ",n("layout_editor.init_actions.advanced_param_hint")]})]});if(e.widget==="action-list"&&a){const f=Array.isArray(t)?t.filter(y=>!!y&&typeof y=="object"):[];return i.jsxs("div",{"data-testid":`${s}-param-${e.key}`,style:{display:"flex",flexDirection:"column",gap:4},children:[i.jsx("span",{style:to,children:d}),a(f,y=>r(y.length===0?void 0:y),`${s}-${e.key}`,e.key)]})}if(e.widget==="branch-list"&&a){const f=Array.isArray(t)?t.filter(y=>!!y&&typeof y=="object"):[];return i.jsxs("div",{"data-testid":`${s}-param-${e.key}`,style:{display:"flex",flexDirection:"column",gap:4},children:[i.jsx("span",{style:to,children:d}),i.jsx(Uw,{branches:f,onChange:y=>r(y.length===0?void 0:y),t:n,pools:o,renderActionList:a,testIdPrefix:`${s}-${e.key}`})]})}if(e.widget==="key-value")return i.jsxs("div",{"data-testid":`${s}-param-${e.key}`,style:{display:"flex",flexDirection:"column",gap:4},children:[i.jsx("span",{style:to,children:d}),i.jsx(Jw,{value:t,onChange:r,t:n,candidates:o.bindingCandidates,testIdPrefix:`${s}-kv-${e.key}`})]});if(e.widget==="state-key-value")return i.jsxs("div",{"data-testid":`${s}-param-${e.key}`,style:{display:"flex",flexDirection:"column",gap:4},children:[i.jsx("span",{style:to,children:d}),i.jsx(Zw,{value:t,onChange:r,t:n,candidates:o.bindingCandidates,stateKeyCandidates:o.stateKeyCandidates,param:e,siblingTarget:l?.target,testIdPrefix:`${s}-skv-${e.key}`})]});if(jw.has(e.widget??"")||!Hw.has(e.widget??""))return i.jsxs("div",{"data-testid":`${s}-param-${e.key}`,style:Gc,children:[i.jsx("span",{style:to,children:d}),i.jsx("div",{style:{flex:1,minWidth:0},children:i.jsx(Ve,{value:typeof t=="string"?t:t==null?"":String(t),onChange:f=>r(f===""?void 0:f),t:n,candidates:o.bindingCandidates,placeholder:e.widget==="number"?n("layout_editor.action_param.number_placeholder"):void 0,testidPrefix:`${s}-chip-${e.key}`})})]});const p=An(e.widget);return i.jsxs("div",{"data-testid":`${s}-param-${e.key}`,style:Gc,children:[i.jsx("span",{style:to,children:d}),i.jsx("div",{style:{flex:1,minWidth:0},children:p?i.jsx(p,{control:{...e},value:t,onChange:r,t:n,candidates:Kw(e.widget,o),bindingCandidates:o.bindingCandidates}):i.jsx(Ve,{value:typeof t=="string"?t:t==null?"":String(t),onChange:f=>r(f===""?void 0:f),t:n,candidates:o.bindingCandidates,testidPrefix:`${s}-chip-${e.key}`})})]})}function Uw({branches:e,onChange:t,t:n,pools:o,renderActionList:r,testIdPrefix:s}){const a=y=>Array.isArray(y)?y.filter(g=>!!g&&typeof g=="object"):y&&typeof y=="object"?[y]:[],l=y=>{if(y.length!==0)return y.length===1?y[0]:y},d=(y,g)=>{const m=e.slice();m[y]=g,t(m)},c=y=>{t(e.filter((g,m)=>m!==y))},p=(y,g)=>{if(g<0||g>=e.length)return;const m=e.slice(),[h]=m.splice(y,1);m.splice(g,0,h),t(m)},f=()=>{t([...e,{if:"",then:void 0}])};return i.jsxs("div",{"data-testid":`${s}-branches`,style:{display:"flex",flexDirection:"column",gap:8},children:[e.map((y,g)=>{const m=typeof y.if=="string"?y.if:"",h=a(y.then);return i.jsxs("div",{"data-testid":`${s}-branch-${g}`,style:qw,children:[i.jsxs("div",{style:Gw,children:[i.jsxs("span",{style:Yw,children:[n(m?"layout_editor.action_recipe.conditions.branch_when":"layout_editor.action_recipe.conditions.branch_else")," ",g+1]}),i.jsxs("span",{style:{display:"flex",gap:4},children:[i.jsx("button",{type:"button","data-testid":`${s}-branch-${g}-up`,onClick:()=>p(g,g-1),disabled:g===0,style:Es,title:n("layout_editor.action_recipe.conditions.move_up"),children:"↑"}),i.jsx("button",{type:"button","data-testid":`${s}-branch-${g}-down`,onClick:()=>p(g,g+1),disabled:g===e.length-1,style:Es,title:n("layout_editor.action_recipe.conditions.move_down"),children:"↓"}),i.jsx("button",{type:"button","data-testid":`${s}-branch-${g}-remove`,onClick:()=>c(g),style:Es,title:n("layout_editor.action_recipe.conditions.remove_branch"),children:"✕"})]})]}),i.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:3},children:[i.jsx("span",{style:qc,children:n("layout_editor.action_recipe.conditions.param_if")}),i.jsx(Ve,{value:m,onChange:v=>d(g,{...y,if:v}),t:n,candidates:o.bindingCandidates,testidPrefix:`${s}-branch-${g}-if`})]}),i.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:3},children:[i.jsx("span",{style:qc,children:n("layout_editor.action_recipe.conditions.param_then")}),r(h,v=>d(g,{...y,then:l(v)}),`${s}-branch-${g}-then`)]})]},g)}),i.jsxs("button",{type:"button","data-testid":`${s}-add-branch`,onClick:f,style:Xw,children:["+ ",n("layout_editor.action_recipe.conditions.add_branch")]})]})}const qw={border:"1px solid #e2e8f0",borderRadius:6,padding:8,display:"flex",flexDirection:"column",gap:6,background:"#f8fafc"},Gw={display:"flex",justifyContent:"space-between",alignItems:"center"},Yw={fontSize:12,fontWeight:700,color:"#475569"},qc={fontSize:11,color:"#64748b"},Es={border:"1px solid #cbd5e1",background:"#fff",borderRadius:4,width:22,height:22,cursor:"pointer",fontSize:12,lineHeight:"1",color:"#475569"},Xw={border:"1px dashed #94a3b8",background:"#fff",borderRadius:6,padding:"6px 10px",cursor:"pointer",fontSize:12,color:"#475569",alignSelf:"flex-start"};function Jw({value:e,onChange:t,t:n,candidates:o,testIdPrefix:r,renderKeyInput:s}){const a=e&&typeof e=="object"&&!Array.isArray(e)?Object.entries(e).map(([l,d])=>({key:l,value:typeof d=="string"?d:d==null?"":String(d)})):[];return i.jsx(Dr,{value:a,onChange:l=>{const d={};for(const c of l)c.key.trim()!==""&&(d[c.key]=c.value??"");t(Object.keys(d).length===0?void 0:d)},keyField:"key",valueField:"value",t:n,candidates:o,testidPrefix:r,keyPlaceholder:n("layout_editor.action_param.key_placeholder"),valuePlaceholder:n("layout_editor.action_param.value_placeholder"),addLabel:n("layout_editor.action_param.add_pair"),renderKeyInput:s})}function Zw({value:e,onChange:t,t:n,candidates:o,stateKeyCandidates:r,param:s,siblingTarget:a,testIdPrefix:l}){const d=e&&typeof e=="object"&&!Array.isArray(e)?Object.entries(e):[],c=typeof a=="string"?a:"";if((c.startsWith("_local.")||c.startsWith("global.")||c.startsWith("$parent._local.")||c.startsWith("$root._local."))&&d.length===1&&d[0][0]==="value"){const b=d[0][1];return i.jsx("div",{"data-testid":`${l}-path-value`,style:{display:"flex",flexDirection:"column",gap:4},children:i.jsx(Dn,{value:b,onChange:x=>t(x===void 0?void 0:{value:x}),t:n,path:"value",scope:`${l}-path-value-val`,renderStringLeaf:x=>i.jsx(Ve,{value:x.value,onChange:x.onChange,t:n,candidates:o,testidPrefix:x.testidPrefix})})})}const y=(b,x)=>{const k={};for(const[w,_]of d)k[w]=w===b?x:_;t(Object.keys(k).length===0?void 0:k)},g=b=>{const x={};for(const[k,w]of d)k!==b&&(x[k]=w);t(Object.keys(x).length===0?void 0:x)},[m,h]=u.useState(""),v=()=>{const b=m.trim();if(b===""||d.some(([k])=>k===b))return;const x={};for(const[k,w]of d)x[k]=w;x[b]="",t(x),h("")};return i.jsxs("div",{"data-testid":l,style:{display:"flex",flexDirection:"column",gap:6},children:[d.map(([b,x])=>i.jsxs("div",{"data-testid":`${l}-row-${b}`,style:{display:"flex",gap:6,alignItems:"flex-start",border:"1px solid #e2e8f0",borderRadius:8,padding:8},children:[i.jsxs("div",{style:{flex:1,minWidth:0,display:"flex",flexDirection:"column",gap:6},children:[i.jsx("div",{style:{minWidth:0},children:i.jsx(Cs,{control:{...s},value:b,onChange:k=>{const w=k==null?"":String(k);if(w===""||w===b||d.some(([S])=>S===w))return;const _={};for(const[S,E]of d)_[S===b?w:S]=E;t(_)},t:n,candidates:r})}),i.jsx("div",{style:{minWidth:0},children:i.jsx(Dn,{value:x,onChange:k=>y(b,k),t:n,path:b,scope:`${l}-val`,renderStringLeaf:k=>i.jsx(Ve,{value:k.value,onChange:k.onChange,t:n,candidates:o,testidPrefix:k.testidPrefix})})})]}),i.jsx("button",{type:"button","data-testid":`${l}-remove-${b}`,onClick:()=>g(b),title:n("layout_editor.page_settings.seo.remove"),"aria-label":n("layout_editor.page_settings.seo.remove"),style:{flex:"0 0 auto",padding:"4px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#64748b",cursor:"pointer"},children:"✕"})]},b)),i.jsxs("div",{style:{display:"flex",gap:6,alignItems:"center"},children:[i.jsx("div",{style:{flex:1,minWidth:0},children:i.jsx(Cs,{control:{...s},value:m,onChange:b=>h(b==null?"":String(b)),t:n,candidates:r})}),i.jsxs("button",{type:"button","data-testid":`${l}-add`,onClick:v,style:{flex:"0 0 auto",padding:"5px 10px",fontSize:12,border:"1px dashed #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer"},children:["+ ",n("layout_editor.action_param.add_pair")]})]})]})}function Is({raw:e,recipe:t,values:n,t:o,pools:r,onChange:s,testIdPrefix:a,renderActionList:l}){const d=(c,p)=>{const f={...n,[c]:p},y=vr(t,f),g=is(e);s(g?Pd(y,g):y)};return i.jsx(i.Fragment,{children:t.params.filter(c=>Qw(c,n)).map(c=>i.jsx(Vw,{param:c,value:n[c.key],t:o,pools:r,onChange:p=>d(c.key,p),testIdPrefix:a,renderActionList:l,siblingValues:n},c.key))})}function Qw(e,t){const n=e.dependsOn;return!n||typeof n!="object"||typeof n.param!="string"?!0:t[n.param]===n.equals}function Rw({raw:e,t,onChange:n,testIdPrefix:o,candidates:r}){const[s,a]=u.useState(!1);return i.jsxs("div",{"data-testid":`${o}-if`,style:ok,children:[i.jsxs("button",{type:"button",onClick:()=>a(l=>!l),style:rk,children:["▸ ",t("layout_editor.init_actions.if_label")]}),s?i.jsx("div",{"data-testid":`${o}-if-input`,style:{minWidth:0},children:i.jsx(Ve,{value:is(e)??"",onChange:l=>n(Pd(e,l)),t,candidates:r,placeholder:t("layout_editor.init_actions.if_placeholder"),testidPrefix:`${o}-if-chip`})}):null]})}const Gc={display:"flex",alignItems:"center",gap:8,minWidth:0},to={fontSize:11,color:"#64748b",minWidth:80},ek={display:"flex",alignItems:"center",gap:8,padding:"4px 0"},tk={fontSize:11,color:"#64748b",minWidth:80},nk={fontSize:11,color:"#92400e"},ok={display:"flex",flexDirection:"column",gap:4},rk={textAlign:"left",border:"none",background:"transparent",color:"#475569",cursor:"pointer",fontSize:11,padding:0};function ik(e,t,n){if(!t)return n("layout_editor.action.source_core");const o=e?.[t],r=o&&typeof o=="object"&&o.__source?o.__source:null;return!r||r.kind==="core"?n("layout_editor.action.source_core"):r.id??n(`layout_editor.action.source_${r.kind}`)}function sk(e,t,n){const o=qn(e,t);if(o.kind==="preset"){const r=t.find(s=>s.id===o.recipeId);return Ld(r??null,o.values,s=>n(s.startsWith("$t:")?s.slice(3):s))}return typeof e.handler=="string"?e.handler:n("layout_editor.action_list.unknown")}function Gt({actions:e,onChange:t,t:n,recipes:o,chipContext:r,actionChipCandidates:s,addContext:a="init",renderAddPicker:l,renderEditor:d,candidatePools:c,showSourceBadge:p=!1,canDrag:f,canDropAt:y,isLocked:g,hideAddPicker:m=!1,testIdPrefix:h="g7le-action-list"}){const v=u.useMemo(()=>vo(o),[o]),b=u.useMemo(()=>{if(r==null||r==="init")return c;const O=Bl(r,n,s??null);if(O.length===0)return c;const M=c??{},B=new Set((M.bindingCandidates??[]).map(H=>H.expression)),T=O.filter(H=>!B.has(H.expression));return T.length===0?c:{...M,bindingCandidates:[...T,...M.bindingCandidates??[]]}},[r,s,c,n]),[x,k]=u.useState(null),[w,_]=u.useState(null),S=u.useCallback((O,M)=>{if(O===M||O<0||M<0||O>=e.length||M>=e.length)return;const B=e.slice(),[T]=B.splice(O,1);B.splice(M,0,T),t(B)},[e,t]),E=us({length:e.length,onMove:S,canDrag:f,canDropAt:y}),C=u.useCallback(O=>{t(e.filter((M,B)=>B!==O))},[e,t]),N=u.useCallback((O,M)=>{const B=e.slice();B[O]=M,t(B)},[e,t]),$=u.useCallback(O=>{t([...e,O])},[e,t]),A=u.useCallback((O,M)=>{const B=qn(O,v),T=B.kind==="preset"?v.find(L=>L.id===B.recipeId)??null:null;if(!T)return null;const H=B.kind==="preset"?B.values:{};return i.jsxs("div",{style:ck,children:[i.jsx(Is,{raw:O,recipe:T,values:H,t:n,pools:b??{},onChange:M,testIdPrefix:`${h}-edit`,renderActionList:(L,I,P,F)=>{const G=F==="onSuccess"?"response":F==="onError"?"error":void 0;return i.jsx(Gt,{actions:L,onChange:I,t:n,recipes:o,chipContext:G,actionChipCandidates:G?s:void 0,candidatePools:G?c:b,addContext:a,showSourceBadge:p,testIdPrefix:P})}}),i.jsx(Rw,{raw:O,t:n,onChange:M,testIdPrefix:`${h}-edit`,candidates:b?.bindingCandidates})]})},[v,n,b,c,s,h,o,a,p]),z=d??(o?A:void 0),D=m?null:l?l($):o?i.jsx(ow,{recipes:o,t:n,onAdd:$,context:a,testIdPrefix:`${h}-add-picker`}):null;return i.jsxs("div",{className:h,"data-testid":h,children:[i.jsxs("ul",{style:{listStyle:"none",margin:0,padding:0,display:"flex",flexDirection:"column",gap:6},children:[e.map((O,M)=>{const B=qn(O,v),T=B.kind==="preset"?B.recipeId:null,H=T?v.find(G=>G.id===T)??null:null,L=H===null,I=g?.(M)??!1,P=is(O)!==null,F=z?z(O,G=>N(M,G)):null;return i.jsxs(u.Fragment,{children:[I?null:i.jsx("li",{"aria-hidden":"true",style:{listStyle:"none",margin:0,padding:0},children:i.jsx(Zn,{active:E.isDropTarget(M),testid:`${h}-dropline-${M}`})}),i.jsxs("li",{"data-testid":`${h}-item-${M}`,"data-drag-source":E.dragIndex===M?"true":void 0,style:{border:"1px solid #e2e8f0",borderRadius:8,padding:"8px 10px",...E.dragIndex===M?{opacity:.5}:{}},onDragOver:E.dragging&&!I?G=>{G.preventDefault();const V=G.currentTarget.getBoundingClientRect();E.onDragOverItem(M,G.clientY{G.preventDefault(),E.onDrop()}:void 0,children:[i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8},children:[I?i.jsx("span",{style:{fontSize:12},children:"🔒"}):i.jsx("span",{"data-testid":`${h}-drag-${M}`,style:{color:"#94a3b8",cursor:"grab",fontSize:14,userSelect:"none"},draggable:!0,onDragStart:()=>E.onDragStart(M),onDragEnd:E.onDragEnd,"aria-label":n("layout_editor.action_list.drag_reorder"),role:"button",children:"⠿"}),i.jsx("span",{style:{flex:1,minWidth:0,fontSize:13},"data-testid":`${h}-summary-${M}`,children:sk(O,v,n)}),p?i.jsxs("span",{"data-testid":`${h}-source-${M}`,style:ak,children:["〔",ik(o,T,n),"〕"]}):null,P?i.jsxs("span",{"data-testid":`${h}-cond-badge-${M}`,style:lk,children:["〔",n("layout_editor.init_actions.cond_badge"),"〕"]}):null,L?i.jsxs("span",{"data-testid":`${h}-advanced-${M}`,style:dk,children:["[",n("layout_editor.action.advanced"),"]"]}):null,!I&&F?i.jsxs("button",{type:"button","data-testid":`${h}-edit-${M}`,onClick:()=>_(w===M?null:M),"aria-label":n("layout_editor.init_actions.edit"),title:n("layout_editor.init_actions.edit"),style:w===M?uk:Yc,children:["✎ ",n("layout_editor.init_actions.edit")]}):null,i.jsx("button",{type:"button","data-testid":`${h}-code-${M}`,onClick:()=>k(x===M?null:M),"aria-label":n("layout_editor.action_list.view_code"),title:n("layout_editor.action_list.view_code"),style:x===M?pk:As,children:""}),I?null:i.jsx("button",{type:"button","data-testid":`${h}-remove-${M}`,onClick:()=>C(M),"aria-label":n("layout_editor.action_list.remove"),title:n("layout_editor.action_list.remove"),style:fk,children:"✕"})]}),H?i.jsx(hw,{action:O,recipe:H,values:B.kind==="preset"?B.values:{},t:n,showCodeButton:!1,testIdPrefix:`${h}-preview-${M}`}):null,w===M&&!I&&F?i.jsx("div",{"data-testid":`${h}-edit-body-${M}`,children:F}):null,d&&w!==M?d(O,G=>N(M,G)):null,x===M?i.jsx("pre",{"data-testid":`${h}-code-view-${M}`,style:{margin:"8px 0 0",padding:8,background:"#0f172a",color:"#e2e8f0",borderRadius:6,fontSize:12,overflow:"auto"},children:JSON.stringify(O,null,2)}):null]})]},M)}),i.jsx("li",{"aria-hidden":"true",style:{listStyle:"none",margin:0,padding:0},children:i.jsx(Zn,{active:E.isDropTarget(e.length),testid:`${h}-dropline-end`})})]}),D?i.jsx("div",{style:{marginTop:8},children:D}):null]})}const ak={fontSize:10,color:"#64748b",whiteSpace:"nowrap"},lk={fontSize:10,color:"#7c3aed",whiteSpace:"nowrap"},dk={fontSize:10,color:"#92400e",whiteSpace:"nowrap"},ck={display:"flex",flexDirection:"column",gap:6,paddingTop:6,borderTop:"1px dashed #e2e8f0",marginTop:6},Yc={flex:"0 0 auto",padding:"3px 8px",fontSize:11,fontWeight:500,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",whiteSpace:"nowrap",lineHeight:1.4},uk={...Yc,background:"#eff6ff",borderColor:"#bfdbfe",color:"#2563eb"},As={flex:"0 0 auto",width:26,height:24,display:"inline-flex",alignItems:"center",justifyContent:"center",fontSize:11,border:"1px solid #e2e8f0",borderRadius:6,background:"#fff",color:"#64748b",cursor:"pointer",fontFamily:"ui-monospace, monospace"},pk={...As,background:"#0f172a",borderColor:"#0f172a",color:"#e2e8f0"},fk={...As,color:"#dc2626",borderColor:"#fecaca",fontFamily:"inherit"};function Xc(e){const t=e.__source;return t&&typeof t=="object"?t:null}function gk({actions:e,onChange:t,recipes:n,t:o,bindingCandidates:r,pageCandidates:s,dataSourceCandidates:a,stateKeyCandidates:l,modalCandidates:d}){const c=u.useMemo(()=>({pageCandidates:s,dataSourceCandidates:a,stateKeyCandidates:l,modalCandidates:d,bindingCandidates:r}),[s,a,l,d,r]),p=u.useMemo(()=>e.filter(h=>Xc(h)?.kind==="base"),[e]),f=u.useMemo(()=>e.filter(h=>Xc(h)?.kind!=="base"),[e]),y=p.length>0,g=u.useCallback(h=>{t(y?[...p,...h]:h)},[t,y,p]),m=u.useCallback(()=>{},[]);return i.jsxs("div",{className:"g7le-init-actions-form","data-testid":"g7le-init-actions-form",style:hk,children:[i.jsx("p",{style:yk,children:o("layout_editor.page_settings.init_actions.heading")}),y?i.jsxs(i.Fragment,{children:[i.jsxs("div",{"data-testid":"g7le-init-action-group-base",style:Jc,children:[i.jsx("div",{style:Zc,children:o("layout_editor.init_actions.group_base")}),i.jsx(Gt,{actions:p,onChange:m,t:o,recipes:n,candidatePools:c,addContext:"init",showSourceBadge:!0,isLocked:()=>!0,hideAddPicker:!0,testIdPrefix:"g7le-init-action-base"}),i.jsxs("p",{"data-testid":"g7le-init-action-base-hint",style:bk,children:["ⓘ ",o("layout_editor.init_actions.base_readonly_hint")]})]}),i.jsxs("div",{"data-testid":"g7le-init-action-group-self",style:Jc,children:[i.jsx("div",{style:Zc,children:o("layout_editor.init_actions.group_self")}),f.length===0?i.jsx("p",{style:Qc,children:o("layout_editor.init_actions.empty_self")}):null,i.jsx(Gt,{actions:f,onChange:g,t:o,recipes:n,candidatePools:c,addContext:"init",showSourceBadge:!0,testIdPrefix:"g7le-init-action-self"})]}),i.jsxs("p",{"data-testid":"g7le-init-action-merge-order",style:mk,children:["ⓘ ",o("layout_editor.init_actions.merge_order")]})]}):i.jsxs(i.Fragment,{children:[e.length===0?i.jsx("p",{"data-testid":"g7le-init-action-empty",style:Qc,children:o("layout_editor.init_actions.empty")}):null,i.jsx(Gt,{actions:e,onChange:t,t:o,recipes:n,candidatePools:c,addContext:"init",showSourceBadge:!0,testIdPrefix:"g7le-init-action"})]})]})}const hk={display:"flex",flexDirection:"column",gap:12,minWidth:0},yk={margin:0,fontSize:13,fontWeight:700,color:"#0f172a"},Jc={border:"1px solid #e2e8f0",borderRadius:10,padding:10,display:"flex",flexDirection:"column",gap:8},Zc={fontSize:12,fontWeight:700,color:"#475569"},bk={margin:0,fontSize:11,color:"#64748b"},Qc={margin:0,fontSize:12,color:"#94a3b8"},mk={margin:0,fontSize:11,color:"#64748b"};function vk(e){return e==="spinner"?new Set(["spinner","page"]):new Set(["skeleton"])}function Rc(e,t,n){return typeof e!="string"?n:e.startsWith("$t:")?t(e.slice(3)):e}function eu({candidates:e,value:t,onSelect:n,styleKind:o,t:r,testIdPrefix:s="g7le-loading-component-picker"}){const a=go(),l=()=>{const d=a.open({ariaLabel:r("layout_editor.overlay.loading_picker.select"),width:520,maxHeightRatio:.7,content:i.jsx(_k,{candidates:e,value:t,styleKind:o,t:r,testIdPrefix:s,onPick:c=>{n(c),a.close(d)},onClose:()=>a.close(d)})})};return i.jsx("div",{className:s,"data-testid":s,style:{minWidth:0},children:i.jsxs("div",{style:xk,children:[i.jsx("span",{"data-testid":`${s}-current`,style:wk,children:t&&t.length>0?t:r("layout_editor.overlay.loading_picker.none")}),i.jsxs("button",{type:"button","data-testid":`${s}-open`,onClick:l,style:kk,children:["🔍 ",r("layout_editor.overlay.loading_picker.select")]})]})})}function _k({candidates:e,value:t,styleKind:n,t:o,testIdPrefix:r,onPick:s,onClose:a}){const[l,d]=u.useState(""),c=vk(n),p=u.useMemo(()=>{const f=l.trim().toLowerCase();return e.filter(y=>c.has(y.role)).filter(y=>f?Rc(y.label,o,y.name).toLowerCase().includes(f)||y.name.toLowerCase().includes(f):!0)},[e,n,l,o]);return i.jsxs("div",{"data-testid":`${r}-modal`,style:Sk,children:[i.jsxs("div",{style:Ck,children:[i.jsxs("span",{style:$k,children:["🔍 ",o("layout_editor.overlay.loading_picker.select")]}),i.jsx("button",{type:"button","data-testid":`${r}-close`,onClick:a,"aria-label":o("layout_editor.overlay.loading_picker.select"),style:Ek,children:"✕"})]}),i.jsx("input",{type:"text","data-testid":`${r}-search`,value:l,placeholder:o("layout_editor.overlay.loading_picker.search"),onChange:f=>d(f.target.value),style:Ik,autoFocus:!0}),p.length===0?i.jsx("p",{"data-testid":`${r}-empty`,style:Lk,children:o("layout_editor.overlay.loading_picker.empty")}):i.jsx("ul",{style:Ak,children:p.map(f=>i.jsx("li",{children:i.jsxs("button",{type:"button","data-testid":`${r}-item-${f.name}`,onClick:()=>s(f.name),style:{...Tk,...t===f.name?Dk:{}},children:[i.jsx("span",{style:Nk,children:Rc(f.label,o,f.name)}),i.jsx("code",{style:zk,children:f.name}),i.jsx("span",{style:Mk,children:f.role})]})},f.name))})]})}const xk={display:"flex",alignItems:"center",gap:6,minWidth:0},wk={flex:1,minWidth:0,fontSize:12,color:"#0f172a",fontFamily:"monospace"},kk={padding:"4px 10px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer",whiteSpace:"nowrap"},Sk={display:"flex",flexDirection:"column",minHeight:0,padding:16,boxSizing:"border-box",fontFamily:"system-ui, -apple-system, sans-serif"},Ck={display:"flex",alignItems:"center",gap:8,paddingBottom:8,borderBottom:"1px solid #e2e8f0",marginBottom:10},$k={flex:1,fontSize:14,fontWeight:700,color:"#0f172a",minWidth:0},Ek={border:"none",background:"transparent",color:"#64748b",cursor:"pointer",fontSize:15,lineHeight:1,padding:4},Ik={width:"100%",padding:"6px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,marginBottom:8,boxSizing:"border-box"},Ak={listStyle:"none",margin:0,padding:0,display:"flex",flexDirection:"column",gap:2,overflowY:"auto",minHeight:0},Tk={display:"flex",alignItems:"center",gap:6,width:"100%",textAlign:"left",padding:"8px 10px",fontSize:12,border:"1px solid transparent",background:"transparent",cursor:"pointer",borderRadius:6},Dk={border:"1px solid #3b82f6",background:"#eff6ff"},Nk={flex:1,minWidth:0,color:"#0f172a"},zk={fontSize:10,color:"#94a3b8",fontFamily:"monospace"},Mk={fontSize:10,color:"#64748b",background:"#f1f5f9",borderRadius:4,padding:"0 4px"},Lk={margin:0,fontSize:12,color:"#94a3b8",padding:"8px 4px"};function tu(e){return e===!0?{enabled:!0}:e===!1||e===void 0?{}:e}const Pk=["opaque","blur","fade","skeleton","spinner"];function Ok({value:e,patch:t,t:n,baseValue:o,ownValue:r,loadingComponents:s=[],progressiveDataSources:a=[],bindingCandidates:l}){const d=tu(e),c=d.enabled??!1,p=!!o&&Object.keys(o).length>0,f=An("component-target-picker"),[y,g]=u.useState((d.wait_for??[]).length>0),m=u.useCallback(w=>{const _={...tu(e)};w(_),t(_)},[e,t]),h=r&&typeof r=="object"?r:e&&typeof e=="object"?e:{},v=u.useCallback(w=>p&&o!==void 0&&w in o&&!(w in h),[p,o,h]),b=u.useCallback(w=>{o&&m(_=>{_[w]=o[w]})},[o,m]),x=u.useCallback(w=>m(_=>{_.enabled=w}),[m]),k=d.target!==void 0?"region":"full";return i.jsxs("div",{className:"g7le-overlay-form","data-testid":"g7le-overlay-form",style:Bk,children:[p?i.jsxs("p",{"data-testid":"g7le-overlay-inherit-banner",style:Wk,children:["ⓘ ",n("layout_editor.overlay.inherit_banner")]}):null,i.jsxs("div",{style:Pr,children:[i.jsx(Ts,{label:n("layout_editor.overlay.enabled_label"),inherited:v("enabled"),hasInheritance:p,onOverride:()=>b("enabled"),testidKey:"enabled",t:n}),i.jsx(Tr,{disabled:v("enabled"),children:i.jsx(Ar,{checked:c,onChange:x,testid:"g7le-overlay-enabled",ariaLabel:n("layout_editor.overlay.enabled_label"),label:n(c?"layout_editor.overlay.on":"layout_editor.overlay.off")})}),i.jsxs("p",{style:Or,children:["ⓘ ",n("layout_editor.overlay.enabled_hint")]})]}),i.jsx(Tr,{disabled:!c,testid:"g7le-overlay-body",style:{display:"flex",flexDirection:"column",gap:16,minWidth:0},children:i.jsxs(i.Fragment,{children:[i.jsxs("div",{style:Pr,children:[i.jsx(Ts,{label:n("layout_editor.overlay.scope_label"),inherited:v("target"),hasInheritance:p,onOverride:()=>b("target"),testidKey:"target",t:n}),i.jsxs("div",{"data-testid":"g7le-overlay-scope",style:Ds,children:[i.jsx(Lr,{active:k==="full",onClick:()=>m(w=>{delete w.target,delete w.fallback_target}),testid:"g7le-overlay-scope-full",children:n("layout_editor.overlay.scope_full")}),i.jsx(Lr,{active:k==="region",onClick:()=>m(w=>{w.target=w.target??""}),testid:"g7le-overlay-scope-region",children:n("layout_editor.overlay.scope_region")})]}),i.jsx(Tr,{disabled:k!=="region",testid:"g7le-overlay-region-box",style:Kk,children:i.jsxs(i.Fragment,{children:[i.jsx(Nn,{label:n("layout_editor.overlay.target_label"),children:i.jsx("div",{"data-testid":"g7le-overlay-target-picker",children:f?i.jsx(f,{control:{label:n("layout_editor.overlay.target_label")},value:d.target??"",onChange:w=>m(_=>{_.target=typeof w=="string"?w:""}),t:n}):i.jsx("input",{type:"text","data-testid":"g7le-overlay-target-input",value:d.target??"",onChange:w=>m(_=>{_.target=w.target.value}),style:qk})})}),i.jsx(Nn,{label:n("layout_editor.overlay.fallback_label"),children:i.jsx("div",{"data-testid":"g7le-overlay-fallback-picker",children:f?i.jsx(f,{control:{label:n("layout_editor.overlay.fallback_label")},value:d.fallback_target??"",onChange:w=>m(_=>{const S=typeof w=="string"?w:"";S===""?delete _.fallback_target:_.fallback_target=S}),t:n}):null})}),i.jsxs("p",{style:Or,children:["ⓘ ",n("layout_editor.overlay.fallback_hint")]})]})})]}),i.jsxs("div",{style:Pr,children:[i.jsx(Ts,{label:n("layout_editor.overlay.style_label"),inherited:v("style"),hasInheritance:p,onOverride:()=>b("style"),testidKey:"style",t:n}),i.jsx("div",{"data-testid":"g7le-overlay-style",style:Ds,children:Pk.map(w=>i.jsx(Lr,{active:(d.style??"opaque")===w,onClick:()=>m(_=>{_.style=w}),testid:`g7le-overlay-style-${w}`,children:n(`layout_editor.overlay.style_${w}`)},w))}),d.style==="skeleton"?i.jsxs("div",{style:nu,children:[i.jsx(Nn,{label:n("layout_editor.overlay.skeleton_component"),children:i.jsx(eu,{candidates:s,value:d.skeleton?.component,styleKind:"skeleton",onSelect:w=>m(_=>{_.skeleton={..._.skeleton,component:w}}),t:n,testIdPrefix:"g7le-overlay-skeleton-component"})}),i.jsx(Nn,{label:n("layout_editor.overlay.skeleton_animation"),children:i.jsx("div",{"data-testid":"g7le-overlay-skeleton-anim",style:Ds,children:["wave","pulse","none"].map(w=>i.jsx(Lr,{active:(d.skeleton?.animation??"wave")===w,onClick:()=>m(_=>{_.skeleton={..._.skeleton,animation:w}}),testid:`g7le-overlay-skeleton-anim-${w}`,children:n(`layout_editor.overlay.anim_${w}`)},w))})}),i.jsx(Nn,{label:n("layout_editor.overlay.skeleton_count"),children:i.jsx("input",{type:"number","data-testid":"g7le-overlay-skeleton-count",value:d.skeleton?.iteration_count??"",onChange:w=>m(_=>{const S=parseInt(w.target.value,10);_.skeleton={..._.skeleton,iteration_count:Number.isNaN(S)?void 0:S}}),style:Gk})})]}):null,d.style==="spinner"?i.jsxs("div",{style:nu,children:[i.jsx(Nn,{label:n("layout_editor.overlay.spinner_component"),children:i.jsx(eu,{candidates:s,value:d.spinner?.component,styleKind:"spinner",onSelect:w=>m(_=>{_.spinner={..._.spinner,component:w}}),t:n,testIdPrefix:"g7le-overlay-spinner-component"})}),i.jsx(Nn,{label:n("layout_editor.overlay.spinner_text"),children:i.jsx("div",{"data-testid":"g7le-overlay-spinner-text",style:{minWidth:0},children:i.jsx(vt,{value:d.spinner?.text??"",onChange:w=>m(_=>{_.spinner={..._.spinner,text:w===void 0||w===""?void 0:w}}),t:n,candidates:l,enableExpressionTree:!0,expressionTreeCollapsible:!0,testidPrefix:"g7le-overlay-spinner-text-field"})})})]}):null]}),i.jsxs("div",{style:Pr,children:[i.jsxs("button",{type:"button","data-testid":"g7le-overlay-waitfor-toggle","aria-expanded":y,onClick:()=>g(w=>!w),style:Jk,children:[y?"▾":"▸"," ",n("layout_editor.overlay.waitfor_label")]}),y?i.jsxs("div",{"data-testid":"g7le-overlay-waitfor-body",style:Zk,children:[i.jsxs("p",{"data-testid":"g7le-overlay-waitfor-hint",style:Or,children:["ⓘ ",n("layout_editor.overlay.waitfor_hint")]}),a.length===0?i.jsx("p",{"data-testid":"g7le-overlay-waitfor-empty",style:Or,children:n("layout_editor.overlay.waitfor_empty")}):i.jsx("div",{"data-testid":"g7le-overlay-waitfor",style:{display:"flex",flexWrap:"wrap",gap:6},children:a.map(w=>{const _=(d.wait_for??[]).includes(w.id);return i.jsxs("label",{"data-testid":`g7le-overlay-waitfor-${w.id}`,style:Yk(_),children:[i.jsx("input",{type:"checkbox",checked:_,onChange:()=>m(S=>{const E=new Set(S.wait_for??[]);E.has(w.id)?E.delete(w.id):E.add(w.id);const C=Array.from(E);C.length===0?delete S.wait_for:S.wait_for=C})}),i.jsx(Ec,{option:w,testIdPrefix:`g7le-overlay-waitfor-${w.id}`})]},w.id)})})]}):null]}),p?i.jsxs("button",{type:"button","data-testid":"g7le-overlay-reset-inherit",onClick:()=>t(void 0),style:Xk,children:["⟲ ",n("layout_editor.overlay.reset_inherit")]}):null]})})]})}function Lr({active:e,onClick:t,children:n,testid:o}){return i.jsx("button",{type:"button","data-testid":o,"aria-pressed":e,onClick:t,style:{...Hk,...e?Fk:{}},children:n})}function Ts({label:e,inherited:t,hasInheritance:n,onOverride:o,testidKey:r,t:s}){return i.jsxs("div",{style:Qk,children:[i.jsx("label",{style:jk,children:e}),n?t?i.jsxs(i.Fragment,{children:[i.jsxs("span",{"data-testid":`g7le-overlay-inherited-${r}`,style:Rk,children:["〔",s("layout_editor.overlay.inherited_badge"),"〕"]}),i.jsx("button",{type:"button","data-testid":`g7le-overlay-override-${r}`,onClick:o,style:t1,children:s("layout_editor.overlay.override_field")})]}):i.jsxs("span",{"data-testid":`g7le-overlay-overridden-${r}`,style:e1,children:["〔",s("layout_editor.overlay.overridden_badge"),"〕"]}):null]})}function Nn({label:e,children:t}){return i.jsxs("div",{style:Vk,children:[i.jsx("span",{style:Uk,children:e}),i.jsx("div",{style:{flex:1,minWidth:0},children:t})]})}const Bk={display:"flex",flexDirection:"column",gap:16,minWidth:0},Wk={margin:0,fontSize:12,color:"#1d4ed8",background:"#eff6ff",padding:"8px 10px",borderRadius:6},Pr={display:"flex",flexDirection:"column",gap:6,minWidth:0},jk={fontSize:13,fontWeight:600,color:"#0f172a"},Or={margin:0,fontSize:11,color:"#94a3b8"},Ds={display:"flex",gap:4,flexWrap:"wrap"},Hk={padding:"4px 10px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},Fk={border:"1px solid #3b82f6",background:"#eff6ff",color:"#1d4ed8"},Kk={display:"flex",flexDirection:"column",gap:8,padding:8,border:"1px dashed #e2e8f0",borderRadius:8},nu={display:"flex",flexDirection:"column",gap:8,padding:8,border:"1px dashed #e2e8f0",borderRadius:8},Vk={display:"flex",alignItems:"center",gap:8,minWidth:0},Uk={fontSize:11,color:"#64748b",minWidth:90},qk={width:"100%",padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,boxSizing:"border-box"},Gk={width:80,padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6},Yk=e=>({display:"inline-flex",alignItems:"center",gap:4,padding:"4px 8px",fontSize:12,border:`1px solid ${e?"#3b82f6":"#cbd5e1"}`,borderRadius:12,background:e?"#eff6ff":"#fff",cursor:"pointer"}),Xk={alignSelf:"flex-start",padding:"6px 12px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},Jk={alignSelf:"flex-start",padding:"4px 0",fontSize:13,fontWeight:600,color:"#0f172a",background:"transparent",border:"none",cursor:"pointer"},Zk={display:"flex",flexDirection:"column",gap:6,paddingLeft:14,minWidth:0},Qk={display:"flex",alignItems:"center",gap:8,flexWrap:"wrap",minWidth:0},Rk={fontSize:11,color:"#64748b",background:"#f1f5f9",borderRadius:4,padding:"1px 6px"},e1={fontSize:11,color:"#0369a1",background:"#e0f2fe",borderRadius:4,padding:"1px 6px"},t1={padding:"2px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},n1="g7le:computed-effect-preview",o1={number:"layout_editor.computed.type_number",string:"layout_editor.computed.type_string",boolean:"layout_editor.computed.type_boolean",list:"layout_editor.computed.type_list",object:"layout_editor.computed.type_object",null:"layout_editor.computed.type_null"};function r1(e,t,n){if(t==="list"){const o=e,r=o.slice(0,2).map(s=>JSON.stringify(s)).join(", ");return o.length<=2?`[${r}]`:`[${r}, ${n("layout_editor.computed.list_more",{count:o.length})}]`}return t==="object"?JSON.stringify(e):t==="boolean"?e?"true":"false":t==="null"?n("layout_editor.computed.value_none"):String(e)}function ou({expr:e,computedKey:t,sampleContext:n,t:o,isEffect:r=!1,testIdPrefix:s="g7le-computed-preview"}){const a=Sd(e,n),l=()=>{typeof window>"u"||!t||window.dispatchEvent(new CustomEvent(n1,{detail:{key:t}}))};return a.ok?i.jsxs("div",{className:s,"data-testid":s,style:i1,children:[i.jsx("span",{style:s1,children:o("layout_editor.computed.preview_label")}),i.jsx("span",{"data-testid":`${s}-value`,style:a1,children:r1(a.value,a.type,o)}),i.jsxs("span",{"data-testid":`${s}-type`,style:l1,children:["(",o(o1[a.type]),")"]}),r&&a.type==="boolean"?i.jsxs("button",{type:"button","data-testid":`${s}-effect`,onClick:l,style:d1,children:["▷ ",o("layout_editor.computed.effect_preview")]}):null]}):i.jsxs("div",{"data-testid":`${s}-error`,style:c1,children:["⚠ ",o("layout_editor.computed.preview_error")]})}const i1={display:"flex",alignItems:"center",flexWrap:"wrap",gap:6,minWidth:0},s1={fontSize:11,color:"#94a3b8"},a1={fontSize:12,color:"#0f172a",fontFamily:"monospace",wordBreak:"break-all"},l1={fontSize:10,color:"#64748b"},d1={padding:"2px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer"},c1={fontSize:12,color:"#b45309"},u1=[{op:"count",labelKey:"layout_editor.computed.op_count"},{op:"sum",labelKey:"layout_editor.computed.op_sum"},{op:"filter",labelKey:"layout_editor.computed.op_filter"},{op:"toOptions",labelKey:"layout_editor.computed.op_to_options"},{op:"nth",labelKey:"layout_editor.computed.op_nth"},{op:"firstOf",labelKey:"layout_editor.computed.op_first_of"},{op:"literal",labelKey:"layout_editor.computed.op_literal"}],p1=["=","!=",">","<",">=","<=","includes"],f1=new Set(["count","sum","filter"]);function ru({model:e,onChange:t,t:n,dataSourceCandidates:o,bindingCandidates:r,existingKeys:s=[]}){const a=u.useCallback(g=>{const m={...e};g(m),t(m)},[e,t]),l=e.conditions??[],d=An("datasource-picker"),c=e.key!==""&&s.includes(e.key),p=()=>a(g=>{g.conditions=[...g.conditions??[],{field:"",cmp:"=",value:""}]}),f=(g,m)=>a(h=>{const v=(h.conditions??[]).slice(),b={...v[g]};m(b),v[g]=b,h.conditions=v}),y=g=>a(m=>{m.conditions=(m.conditions??[]).filter((h,v)=>v!==g)});return i.jsxs("div",{className:"g7le-computed-custom","data-testid":"g7le-computed-custom",style:g1,children:[i.jsx(Ns,{label:`① ${n("layout_editor.computed.custom_step1")}`,children:i.jsx("div",{"data-testid":"g7le-computed-custom-source",children:d?i.jsx(d,{control:{},value:e.source,onChange:g=>a(m=>{m.source=typeof g=="string"?g:void 0}),t:n,candidates:o}):i.jsx("input",{type:"text",value:e.source??"",onChange:g=>a(m=>{m.source=g.target.value}),style:no})})}),i.jsxs(Ns,{label:`② ${n("layout_editor.computed.custom_step2")}`,children:[i.jsx("select",{"data-testid":"g7le-computed-custom-op",value:e.op,onChange:g=>a(m=>{m.op=g.target.value}),style:no,children:u1.map(({op:g,labelKey:m})=>i.jsx("option",{value:g,children:n(m)},g))}),e.op==="sum"?i.jsx("div",{"data-testid":"g7le-computed-custom-sumfield",style:{minWidth:0},children:i.jsx(Ve,{value:e.sumField??"",onChange:g=>a(m=>{m.sumField=g}),t:n,candidates:r,placeholder:n("layout_editor.computed.custom_sum_field"),testidPrefix:"g7le-computed-custom-sumfield-chip"})}):null,e.op==="toOptions"?i.jsxs("div",{style:{display:"flex",gap:6},children:[i.jsx("div",{"data-testid":"g7le-computed-custom-valuefield",style:{flex:1,minWidth:0},children:i.jsx(Ve,{value:e.valueField??"",onChange:g=>a(m=>{m.valueField=g}),t:n,candidates:r,placeholder:n("layout_editor.computed.custom_value_field"),testidPrefix:"g7le-computed-custom-valuefield-chip"})}),i.jsx("div",{"data-testid":"g7le-computed-custom-labelfield",style:{flex:1,minWidth:0},children:i.jsx(Ve,{value:e.labelField??"",onChange:g=>a(m=>{m.labelField=g}),t:n,candidates:r,placeholder:n("layout_editor.computed.custom_label_field"),testidPrefix:"g7le-computed-custom-labelfield-chip"})})]}):null,e.op==="nth"?i.jsxs("div",{style:{display:"flex",gap:6},children:[i.jsx("input",{type:"text","data-testid":"g7le-computed-custom-index",value:e.index??"",placeholder:"0",onChange:g=>a(m=>{m.index=g.target.value}),style:no}),i.jsx("div",{"data-testid":"g7le-computed-custom-prop",style:{flex:1,minWidth:0},children:i.jsx(Ve,{value:e.prop??"",onChange:g=>a(m=>{m.prop=g}),t:n,candidates:r,placeholder:n("layout_editor.computed.custom_prop"),testidPrefix:"g7le-computed-custom-prop-chip"})})]}):null,e.op==="literal"?i.jsxs("div",{style:{display:"flex",gap:6},children:[i.jsxs("select",{"data-testid":"g7le-computed-custom-literalkind",value:e.literalKind??"string",onChange:g=>a(m=>{m.literalKind=g.target.value}),style:no,children:[i.jsx("option",{value:"string",children:n("layout_editor.computed.kind_string")}),i.jsx("option",{value:"number",children:n("layout_editor.computed.kind_number")}),i.jsx("option",{value:"boolean",children:n("layout_editor.computed.kind_boolean")})]}),i.jsx("div",{"data-testid":"g7le-computed-custom-literalvalue",style:{flex:1,minWidth:0},children:i.jsx(Ve,{value:e.literalValue??"",onChange:g=>a(m=>{m.literalValue=g}),t:n,candidates:r,testidPrefix:"g7le-computed-custom-literalvalue-chip"})})]}):null,e.op==="firstOf"?i.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:6},children:[i.jsx("input",{type:"text","data-testid":"g7le-computed-custom-candidates",value:(e.candidates??[]).join(", "),placeholder:n("layout_editor.computed.custom_candidates"),onChange:g=>a(m=>{m.candidates=g.target.value.split(",").map(h=>h.trim()).filter(Boolean)}),style:no}),i.jsx("div",{"data-testid":"g7le-computed-custom-fallback",style:{minWidth:0},children:i.jsx(Ve,{value:e.fallback??"",onChange:g=>a(m=>{m.fallback=g}),t:n,candidates:r,placeholder:n("layout_editor.computed.custom_fallback"),testidPrefix:"g7le-computed-custom-fallback-chip"})})]}):null,f1.has(e.op)?i.jsxs("div",{style:m1,children:[l.map((g,m)=>i.jsxs("div",{"data-testid":`g7le-computed-custom-cond-${m}`,style:v1,children:[i.jsx("div",{"data-testid":`g7le-computed-custom-cond-field-${m}`,style:{flex:1,minWidth:0},children:i.jsx(Ve,{value:g.field,onChange:h=>f(m,v=>{v.field=h}),t:n,candidates:r,placeholder:n("layout_editor.computed.cond_field"),testidPrefix:`g7le-computed-custom-cond-field-${m}-chip`})}),i.jsx("select",{value:g.cmp,onChange:h=>f(m,v=>{v.cmp=h.target.value}),style:_1,children:p1.map(h=>i.jsx("option",{value:h,children:h},h))}),i.jsx("div",{"data-testid":`g7le-computed-custom-cond-value-${m}`,style:{flex:1,minWidth:0},children:i.jsx(Ve,{value:g.value,onChange:h=>f(m,v=>{v.value=h}),t:n,candidates:r,placeholder:n("layout_editor.computed.cond_value"),testidPrefix:`g7le-computed-custom-cond-value-${m}-chip`})}),i.jsx("button",{type:"button","data-testid":`g7le-computed-custom-cond-remove-${m}`,onClick:()=>y(m),style:x1,children:"✕"})]},m)),i.jsxs("button",{type:"button","data-testid":"g7le-computed-custom-cond-add",onClick:p,style:w1,children:["+ ",n("layout_editor.computed.cond_add")]})]}):null]}),i.jsxs(Ns,{label:`③ ${n("layout_editor.computed.custom_step3")}`,children:[i.jsx("input",{type:"text","data-testid":"g7le-computed-custom-key",value:e.key,onChange:g=>a(m=>{m.key=g.target.value}),style:{...no,...c?b1:{}}}),c?i.jsxs("p",{"data-testid":"g7le-computed-custom-key-dup",style:k1,children:["⚠ ",n("layout_editor.computed.key_duplicate")]}):null]})]})}function Ns({label:e,children:t}){return i.jsxs("div",{style:h1,children:[i.jsx("div",{style:y1,children:e}),i.jsx("div",{style:{display:"flex",flexDirection:"column",gap:6,minWidth:0},children:t})]})}function zs(e){return Pm(e)}const g1={display:"flex",flexDirection:"column",gap:12,padding:10,border:"1px solid #cbd5e1",borderRadius:8,background:"#fff"},h1={display:"flex",flexDirection:"column",gap:4,minWidth:0},y1={fontSize:12,fontWeight:600,color:"#475569"},no={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,minWidth:0,width:"100%",boxSizing:"border-box"},b1={border:"1px solid #ef4444"},m1={display:"flex",flexDirection:"column",gap:6,marginTop:4},v1={display:"flex",gap:4,alignItems:"center",minWidth:0},_1={padding:"4px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6},x1={border:"none",background:"transparent",color:"#94a3b8",cursor:"pointer",fontSize:12},w1={alignSelf:"flex-start",padding:"2px 8px",fontSize:11,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},k1={margin:0,fontSize:11,color:"#b45309"};function Ms(e,t,n){return typeof e!="string"?n:e.startsWith("$t:")?t(e.slice(3)):e}function S1(e){if(e&&typeof e=="object"){const t=e.__source;if(t&&typeof t=="object")return t}return null}function C1({computed:e,onChange:t,recipes:n,t:o,sampleContext:r,dataSourceCandidates:s,bindingCandidates:a,computedSource:l={},effectKeys:d=[],onRevert:c}){const p=u.useMemo(()=>bd(n),[n]),[f,y]=u.useState(!1),[g,m]=u.useState(null),h=Object.keys(e),v=u.useCallback((_,S)=>{t({...e,[_]:S})},[e,t]),b=u.useCallback(_=>{const S={...e};delete S[_],t(S)},[e,t]),x=u.useCallback((_,S,E)=>{const C={...e};_&&_!==S&&delete C[_],C[S]=E,t(C)},[e,t]),k=u.useCallback(_=>{const S=vd(_,{});let E=_.id,C=1;for(;E in e;)E=`${_.id}${++C}`;t({...e,[E]:S}),y(!1)},[e,t]),w=u.useCallback(()=>{!g||!g.key||(t({...e,[g.key]:zs(g)}),m(null),y(!1))},[g,e,t]);return i.jsxs("div",{className:"g7le-computed-form","data-testid":"g7le-computed-form",style:A1,children:[i.jsx("p",{style:T1,children:o("layout_editor.page_settings.computed.heading")}),h.length>0?h.map(_=>i.jsx($1,{computedKey:_,expr:e[_],recipes:p,rawRecipes:n,t:o,sampleContext:r,dataSourceCandidates:s,bindingCandidates:a,source:l[_],isEffect:d.includes(_),existingKeys:h,onPatchExpr:S=>v(_,S),onRename:(S,E)=>x(_,S,E),onRemove:()=>b(_),onRevert:c?()=>c(_):void 0},_)):i.jsx("p",{"data-testid":"g7le-computed-empty",style:R1,children:o("layout_editor.computed.empty")}),i.jsxs("div",{style:{minWidth:0},children:[i.jsxs("button",{type:"button","data-testid":"g7le-computed-add",onClick:()=>y(_=>!_),style:U1,children:["+ ",o("layout_editor.computed.add")," ▾"]}),f?i.jsxs("div",{"data-testid":"g7le-computed-add-list",style:q1,children:[["common","more"].map(_=>{const S=p.filter(E=>(E.group??"more")===_);return S.length===0?null:i.jsxs("div",{children:[i.jsxs("div",{style:G1,children:["━━ ",o(`layout_editor.computed.group_${_}`)," ━━"]}),S.map(E=>{const C=S1(n?.[E.id]);return i.jsxs("button",{type:"button","data-testid":`g7le-computed-preset-${E.id}`,onClick:()=>k(E),style:Y1,children:[i.jsx("span",{style:X1,children:Ms(E.label,o,E.id)}),C&&C.kind!=="core"?i.jsxs("span",{"data-testid":`g7le-computed-preset-source-${E.id}`,style:J1,children:["〔",C.id??C.kind,"〕"]}):null]},E.id)})]},_)}),i.jsxs("div",{style:{borderTop:"1px solid #e2e8f0",marginTop:6,paddingTop:6},children:[i.jsxs("button",{type:"button","data-testid":"g7le-computed-custom-open",onClick:()=>m(g?null:{key:"",op:"count",source:"",conditions:[]}),style:Z1,children:["⚙ ",o("layout_editor.computed.custom_open")," ▾"]}),g?i.jsxs("div",{style:{marginTop:6},children:[i.jsx(ru,{model:g,onChange:m,t:o,dataSourceCandidates:s,bindingCandidates:a,existingKeys:h}),i.jsx("div",{"data-testid":"g7le-computed-custom-preview",style:{marginTop:6},children:i.jsx(ou,{expr:zs(g),sampleContext:r,t:o,testIdPrefix:"g7le-computed-custom-preview-eval"})}),i.jsx("button",{type:"button","data-testid":"g7le-computed-custom-commit",disabled:!g.key||h.includes(g.key),onClick:w,style:Q1,children:o("layout_editor.computed.custom_add")})]}):null]})]}):null]})]})}function $1({computedKey:e,expr:t,recipes:n,rawRecipes:o,t:r,sampleContext:s,dataSourceCandidates:a,bindingCandidates:l,source:d,isEffect:c,existingKeys:p,onPatchExpr:f,onRename:y,onRemove:g,onRevert:m}){const[h,v]=u.useState(!1),[b,x]=u.useState(!1),k=u.useMemo(()=>kd(t,n),[t,n]),w=d==="base",_=d==="route-override",S=k.kind==="advanced",E=k.kind==="preset"?n.find($=>$.id===k.recipeId)??null:null,C=E?Ms(E.label,r,e):e,N=($,A)=>{if(k.kind!=="preset"||!E)return;const z={...k.params,[$]:A};f(vd(E,z))};return i.jsxs("div",{"data-testid":`g7le-computed-item-${e}`,style:D1,children:[i.jsxs("div",{style:N1,children:[i.jsx("span",{style:z1,children:C}),i.jsx("code",{style:M1,children:e}),w?i.jsxs("span",{"data-testid":`g7le-computed-source-${e}`,style:L1,children:["〔",r("layout_editor.computed.source_base"),"〕"]}):null,_?i.jsxs("span",{"data-testid":`g7le-computed-overridden-${e}`,style:P1,children:["〔",r("layout_editor.computed.source_overridden"),"〕"]}):null,S?i.jsxs("span",{"data-testid":"g7le-computed-advanced",style:O1,children:["[",r("layout_editor.action.advanced"),"]"]}):null,i.jsx("span",{style:{flex:1}}),i.jsx("button",{type:"button","data-testid":`g7le-computed-code-${e}`,onClick:()=>x($=>!$),style:Ls,"aria-label":r("layout_editor.value_tree.show_source"),title:r("layout_editor.value_tree.show_source"),children:""}),S?null:i.jsx("button",{type:"button","data-testid":`g7le-computed-edit-${e}`,onClick:()=>v($=>!$),style:Ls,children:r("layout_editor.init_actions.edit")}),i.jsx("button",{type:"button","data-testid":`g7le-computed-remove-${e}`,onClick:g,style:Ls,"aria-label":r("layout_editor.init_actions.remove"),children:"✕"})]}),b?i.jsx("pre",{"data-testid":`g7le-computed-code-block-${e}`,style:eS,children:t}):null,i.jsx("div",{"data-testid":`g7le-computed-preview-${e}`,children:i.jsx(ou,{expr:t,computedKey:e,sampleContext:s,t:r,isEffect:c,testIdPrefix:`g7le-computed-preview-${e}-eval`})}),_&&m?i.jsxs("div",{style:B1,children:[i.jsxs("span",{style:W1,children:["ⓘ ",r("layout_editor.computed.overridden_hint")]}),i.jsx("button",{type:"button","data-testid":`g7le-computed-revert-${e}`,onClick:m,style:j1,children:r("layout_editor.computed.revert")})]}):null,h&&!S?i.jsxs("div",{style:H1,children:[w?i.jsxs("p",{"data-testid":`g7le-computed-override-notice-${e}`,style:F1,children:["⚠ ",r("layout_editor.computed.override_notice")]}):null,k.kind==="preset"&&E?i.jsx(i.Fragment,{children:E.params.map($=>i.jsx(I1,{param:$,value:k.params[$.key]??"",t:r,dataSourceCandidates:a,bindingCandidates:l,onChange:A=>N($.key,A)},$.key))}):k.kind==="custom"?i.jsx(ru,{model:{...k.model,key:e},onChange:$=>y($.key,zs($)),t:r,dataSourceCandidates:a,bindingCandidates:l,existingKeys:p.filter($=>$!==e)}):null]}):null]})}const E1=new Set(["datasource-picker","binding-picker","select","toggle","state-key-picker","page-picker"]);function I1({param:e,value:t,t:n,dataSourceCandidates:o,bindingCandidates:r,onChange:s}){const a=Ms(e.label,n,e.key),l=e.widget??"",c=E1.has(l)?An(e.widget):null,p=l==="datasource-picker"||l==="binding-picker"?o:void 0;return i.jsxs("div",{"data-testid":`g7le-computed-param-${e.key}`,style:K1,children:[i.jsx("span",{style:V1,children:a}),i.jsx("div",{style:{flex:1,minWidth:0},children:c?i.jsx(c,{control:{...e},value:t,onChange:f=>s(typeof f=="string"?f:""),t:n,candidates:p}):i.jsx(Ve,{value:t,onChange:f=>s(f),t:n,candidates:r,testidPrefix:`g7le-computed-param-${e.key}-chip`})})]})}const A1={display:"flex",flexDirection:"column",gap:12,minWidth:0},T1={margin:0,fontSize:13,fontWeight:700,color:"#0f172a"},D1={border:"1px solid #e2e8f0",borderRadius:8,padding:"8px 10px",display:"flex",flexDirection:"column",gap:6,background:"#f8fafc"},N1={display:"flex",alignItems:"center",gap:6},z1={fontSize:13,fontWeight:600,color:"#0f172a"},M1={fontSize:10,color:"#64748b",fontFamily:"monospace"},L1={fontSize:10,color:"#0891b2",whiteSpace:"nowrap"},P1={fontSize:10,color:"#7c3aed",whiteSpace:"nowrap"},O1={fontSize:10,color:"#92400e",whiteSpace:"nowrap"},B1={display:"flex",alignItems:"center",gap:8,marginTop:4,minWidth:0},W1={fontSize:11,color:"#7c3aed",flex:1,minWidth:0},j1={border:"1px solid #c4b5fd",borderRadius:6,background:"#f5f3ff",color:"#6d28d9",cursor:"pointer",fontSize:11,padding:"2px 8px",whiteSpace:"nowrap"},Ls={border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",fontSize:11,padding:"2px 6px"},H1={display:"flex",flexDirection:"column",gap:6,paddingTop:6,borderTop:"1px dashed #e2e8f0"},F1={margin:0,fontSize:11,color:"#b45309",background:"#fef3c7",padding:"6px 8px",borderRadius:6},K1={display:"flex",alignItems:"center",gap:8,minWidth:0},V1={fontSize:11,color:"#64748b",minWidth:90},U1={padding:"6px 12px",fontSize:12,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},q1={marginTop:6,border:"1px solid #cbd5e1",borderRadius:8,background:"#fff",padding:8,maxHeight:380,overflowY:"auto"},G1={fontSize:11,fontWeight:700,color:"#94a3b8",padding:"6px 4px 2px"},Y1={display:"flex",alignItems:"center",gap:6,width:"100%",textAlign:"left",padding:"6px 8px",fontSize:12,border:"none",background:"transparent",cursor:"pointer",borderRadius:6},X1={flex:1,minWidth:0,color:"#0f172a"},J1={fontSize:10,color:"#64748b",whiteSpace:"nowrap"},Z1={padding:"6px 10px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer"},Q1={marginTop:6,padding:"6px 12px",fontSize:12,border:"1px solid #3b82f6",borderRadius:6,background:"#3b82f6",color:"#fff",cursor:"pointer"},R1={margin:0,fontSize:12,color:"#94a3b8"},eS={margin:"2px 0 0",padding:"6px 8px",fontSize:11,fontFamily:"monospace",color:"#0f172a",background:"#f1f5f9",border:"1px solid #e2e8f0",borderRadius:6,whiteSpace:"pre-wrap",wordBreak:"break-all",overflowX:"auto"};function tS(e,t){return t==="any"?!0:e===null||typeof e!="object"?!1:t==="object"?!Array.isArray(e):!0}function Ps({value:e,onChange:t,onValidityChange:n,t:o,shape:r="any",emptyValue:s=void 0,placeholder:a,minHeight:l=80,testidPrefix:d="g7le-json-block",shapeErrorKey:c,invalidErrorKey:p,ariaLabel:f,id:y,validate:g}){const[m,h]=u.useState(()=>Br(e)),[v,b]=u.useState(null),x=u.useRef(!1),k=u.useRef(e);u.useEffect(()=>{if(!x.current&&!Object.is(e,k.current)){if(Br(e)===Br(k.current)){k.current=e;return}h(Br(e)),b(null),k.current=e,n?.(!0)}},[e]);const w=_=>{if(x.current=!0,h(_),_.trim()===""){b(null),k.current=s,n?.(!0),t(s);return}let S;try{S=JSON.parse(_)}catch{b(o(p??"layout_editor.json_block.invalid")),n?.(!1);return}if(!tS(S,r)){b(o(c??"layout_editor.json_block.invalid")),n?.(!1);return}const E=g?.(S)??null;if(E){b(E),n?.(!1);return}b(null),k.current=S,n?.(!0),t(S)};return i.jsxs("div",{className:"g7le-json-block",style:nS,children:[i.jsx("textarea",{id:y,"data-testid":d,style:{...oS,minHeight:l},value:m,spellCheck:!1,"aria-label":f,"aria-invalid":v?!0:void 0,placeholder:a,onChange:_=>w(_.target.value),onBlur:()=>{x.current=!1}}),v?i.jsx("div",{"data-testid":`${d}-error`,style:rS,children:v}):null]})}function Br(e){if(e===void 0)return"";try{return JSON.stringify(e,null,2)??""}catch{return""}}const nS={display:"flex",flexDirection:"column",gap:4,minWidth:0},oS={width:"100%",boxSizing:"border-box",fontFamily:"ui-monospace, SFMono-Regular, Menlo, monospace",fontSize:12,lineHeight:1.5,color:"#0f172a",background:"#fff",border:"1px solid #cbd5e1",borderRadius:6,padding:"6px 8px",resize:"vertical"},rS={fontSize:11,color:"#dc2626"},iS=[{kind:"string",labelKey:"layout_editor.initstate.kind_string"},{kind:"number",labelKey:"layout_editor.initstate.kind_number"},{kind:"boolean",labelKey:"layout_editor.initstate.kind_boolean"},{kind:"null",labelKey:"layout_editor.initstate.kind_null"},{kind:"list",labelKey:"layout_editor.initstate.kind_list"},{kind:"object",labelKey:"layout_editor.initstate.kind_object"}],Os={local:"initLocal",global:"initGlobal",isolated:"initIsolated"};function sS({raw:e,own:t,patch:n,t:o,isolatedOrphan:r={}}){const s=u.useMemo(()=>{const f=t?.state&&typeof t.state=="object"?t.state:void 0,y=e?.state&&typeof e.state=="object"?e.state:void 0;if(!(!f&&!y))return{...y??{},...f??{}}},[t,e]),{initLocal:a,migrated:l}=u.useMemo(()=>Iw({initLocal:e?.initLocal,state:s}),[e,s]),d=f=>{if(f==="local")return a;const y=e?.[Os[f]];return y&&typeof y=="object"?y:{}},c=f=>{if(f==="local"){const y=t?.initLocal??{},g=s?Object.keys(s):[];return Array.from(new Set([...Object.keys(y),...g]))}return Object.keys(t?.[Os[f]]??{})},p=u.useCallback((f,y)=>{const g=Os[f];f==="local"&&l&&n("state",void 0),n(g,Object.keys(y).length===0?void 0:y)},[n,l]);return i.jsxs("div",{className:"g7le-initstate-form","data-testid":"g7le-initstate-form",style:lS,children:[i.jsx("p",{style:dS,children:o("layout_editor.page_settings.initstate.heading")}),l?i.jsxs("p",{"data-testid":"g7le-initstate-legacy-state",style:cS,children:["ⓘ ",o("layout_editor.initstate.legacy_state")]}):null,i.jsx(Bs,{scope:"local",label:o("layout_editor.initstate.section_local"),value:d("local"),ownKeys:c("local"),patchSection:p,t:o}),i.jsx(Bs,{scope:"global",label:o("layout_editor.initstate.section_global"),value:d("global"),ownKeys:c("global"),patchSection:p,t:o}),i.jsx(Bs,{scope:"isolated",label:o("layout_editor.initstate.section_isolated"),description:o("layout_editor.initstate.section_isolated_desc"),value:d("isolated"),ownKeys:c("isolated"),patchSection:p,t:o,orphan:r}),i.jsxs("p",{style:IS,children:["ⓘ ",o("layout_editor.initstate.isolated_dynamic_hint")]})]})}function Bs({scope:e,label:t,description:n,value:o,ownKeys:r,patchSection:s,t:a,orphan:l={}}){const[d,c]=u.useState(""),[p,f]=u.useState("string"),[y,g]=u.useState(null),[m,h]=u.useState(!1),v=Object.keys(o),b=v,x=(_,S)=>{s(e,{...o,[_]:S})},k=_=>{const S={...o};delete S[_],s(e,S)},w=()=>{const _=d.trim();if(!_){g(a("layout_editor.initstate.name_required"));return}if(!$s(_)){g(a("layout_editor.initstate.name_invalid"));return}if(_ in o){g(a("layout_editor.initstate.name_duplicate"));return}const S={string:"",number:0,boolean:!1,null:null,list:[],object:{}};s(e,{...o,[_]:S[p]}),c(""),g(null)};return i.jsxs("div",{"data-testid":`g7le-initstate-section-${e}`,style:uS,children:[i.jsxs("div",{style:pS,children:[i.jsx("div",{style:fS,children:t}),i.jsx("button",{type:"button","data-testid":`g7le-initstate-${e}-mode-toggle`,onClick:()=>h(_=>!_),style:hS,children:a(m?"layout_editor.json_block.mode_block":"layout_editor.json_block.mode_code")})]}),n?i.jsxs("p",{style:gS,children:["ⓘ ",n]}):null,m?i.jsx(Ps,{value:o,shape:"object",emptyValue:{},onChange:_=>s(e,_??{}),t:a,testidPrefix:`g7le-initstate-${e}-code`,placeholder:`{ "keyword": "", "page": 1 }`,shapeErrorKey:"layout_editor.json_block.must_be_object",validate:_=>{const S=Object.keys(_??{}).filter(E=>!$s(E));return S.length?a("layout_editor.json_block.invalid_keys",{keys:S.join(", ")}):null}}):i.jsxs(i.Fragment,{children:[v.length===0?i.jsx("p",{"data-testid":`g7le-initstate-${e}-empty`,style:ES,children:a("layout_editor.initstate.no_values")}):v.map(_=>{const E=Ew(b,r,_)==="inherited",C=e==="isolated"&&l[_];return i.jsx(aS,{scope:e,itemKey:_,value:o[_],inherited:E,isOrphan:!!C,onChangeValue:N=>x(_,N),onRemove:()=>k(_),t:a},_)}),i.jsxs("div",{style:xS,children:[i.jsx("input",{type:"text","data-testid":`g7le-initstate-${e}-add-name`,value:d,placeholder:a("layout_editor.initstate.value_name"),onChange:_=>{c(_.target.value),y&&g(null)},onKeyDown:_=>{_.key==="Enter"&&(_.preventDefault(),w())},"aria-invalid":y?!0:void 0,style:{...wS,...y?kS:null}}),i.jsx("select",{"data-testid":`g7le-initstate-${e}-add-kind`,value:p,onChange:_=>f(_.target.value),"aria-label":a("layout_editor.initstate.value_kind"),style:CS,children:iS.map(_=>i.jsx("option",{value:_.kind,children:a(_.labelKey)},_.kind))}),i.jsxs("button",{type:"button","data-testid":`g7le-initstate-${e}-add`,onClick:w,style:$S,children:["+ ",a("layout_editor.initstate.add_value")]})]}),y?i.jsx("p",{"data-testid":`g7le-initstate-${e}-add-error`,style:SS,children:y}):null]})]})}function aS({scope:e,itemKey:t,value:n,inherited:o,isOrphan:r,onChangeValue:s,onRemove:a,t:l}){const[d,c]=u.useState(!1);let p;try{p=JSON.stringify(n,null,2)??String(n)}catch{p=String(n)}return i.jsxs("div",{"data-testid":`g7le-initstate-${e}-item-${t}`,style:yS,children:[i.jsxs("div",{style:bS,children:[o?i.jsx("span",{"data-testid":`g7le-initstate-inherited-${t}`,style:mS,children:"🔗"}):null,i.jsx("code",{style:vS,children:t}),i.jsx("span",{style:{flex:1}}),i.jsx("button",{type:"button","data-testid":`g7le-initstate-code-${t}`,onClick:()=>c(f=>!f),style:Ws,"aria-label":l("layout_editor.value_tree.show_source"),title:l("layout_editor.value_tree.show_source"),children:""}),o?i.jsx("button",{type:"button","data-testid":`g7le-initstate-revert-${t}`,onClick:a,style:Ws,children:l("layout_editor.initstate.revert")}):i.jsx("button",{type:"button","data-testid":`g7le-initstate-remove-${t}`,onClick:a,style:Ws,"aria-label":l("layout_editor.init_actions.remove"),children:"✕"})]}),d?i.jsx("pre",{"data-testid":`g7le-initstate-code-block-${t}`,style:AS,children:p}):null,i.jsx(Dn,{value:n,onChange:s,t:l,path:t,scope:e}),r?i.jsxs("p",{"data-testid":`g7le-initstate-isolated-orphan-${t}`,style:_S,children:["⚠ ",l("layout_editor.initstate.isolated_orphan")]}):null]})}const lS={display:"flex",flexDirection:"column",gap:14,minWidth:0},dS={margin:0,fontSize:13,fontWeight:700,color:"#0f172a"},cS={margin:0,fontSize:11,color:"#1d4ed8",background:"#eff6ff",padding:"6px 8px",borderRadius:6},uS={display:"flex",flexDirection:"column",gap:8,padding:10,border:"1px solid #e2e8f0",borderRadius:10,minWidth:0},pS={display:"flex",alignItems:"center",justifyContent:"space-between",gap:8},fS={fontSize:12,fontWeight:700,color:"#475569"},gS={margin:0,fontSize:11,color:"#94a3b8"},hS={padding:"2px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},yS={display:"flex",flexDirection:"column",gap:4,padding:"6px 8px",border:"1px solid #f1f5f9",borderRadius:6,minWidth:0},bS={display:"flex",alignItems:"center",gap:6},mS={fontSize:12},vS={fontSize:12,color:"#0f172a",fontFamily:"monospace"},Ws={border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",fontSize:11,padding:"2px 6px"},_S={margin:0,fontSize:11,color:"#b45309",background:"#fef3c7",padding:"4px 6px",borderRadius:6},xS={display:"flex",gap:6,alignItems:"center"},wS={padding:"4px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6},kS={border:"1px solid #dc2626"},SS={margin:0,fontSize:11,color:"#dc2626"},CS={padding:"4px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#0f172a"},$S={padding:"2px 10px",fontSize:11,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},ES={margin:0,fontSize:12,color:"#94a3b8"},IS={margin:0,fontSize:11,color:"#94a3b8"},AS={margin:"2px 0",padding:"6px 8px",fontSize:11,fontFamily:"monospace",color:"#0f172a",background:"#f1f5f9",border:"1px solid #e2e8f0",borderRadius:6,whiteSpace:"pre-wrap",wordBreak:"break-all",overflowX:"auto"},TS={self:"layout_editor.error_rows.source_self",inherited:"layout_editor.error_rows.source_inherited",template:"layout_editor.error_rows.source_template",none:"layout_editor.error_rows.source_none"};function js({value:e,onChange:t,t:n,codes:o,mode:r="local",sourceOf:s,renderActionList:a,testIdPrefix:l="g7le-error-rows"}){const d=o??Object.keys(e),c=u.useCallback((f,y)=>{t({...e,[f]:y})},[e,t]),p=u.useCallback(f=>{const y={...e};delete y[f],t(y)},[e,t]);return i.jsx("div",{className:l,"data-testid":l,children:d.map(f=>{const y=e[f],g=y!==void 0,m=r==="badge"&&s?s(f):null;return i.jsxs("div",{"data-testid":`${l}-row-${f}`,style:{border:"1px solid #e2e8f0",borderRadius:8,padding:"8px 10px",marginBottom:6},children:[i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8},children:[i.jsx("strong",{"data-testid":`${l}-code-${f}`,style:{fontSize:13},children:f==="default"?n("layout_editor.error_rows.default"):f}),m?i.jsxs("span",{"data-testid":`${l}-source-${f}`,style:{fontSize:11,padding:"2px 6px",borderRadius:4,background:"#f1f5f9",color:"#475569"},children:["〔",n(TS[m]),"〕"]}):null,g?i.jsx("button",{type:"button","data-testid":`${l}-clear-${f}`,onClick:()=>p(f),style:{marginLeft:"auto",fontSize:12},children:n("layout_editor.error_rows.clear")}):i.jsx("span",{style:{marginLeft:"auto",fontSize:12,color:"#94a3b8"},children:n("layout_editor.error_rows.no_action")})]}),m==="inherited"||m==="template"?i.jsxs("div",{"data-testid":`${l}-override-${f}`,style:{marginTop:6,display:"flex",alignItems:"flex-start",gap:8},children:[i.jsxs("p",{style:{margin:0,flex:1,fontSize:12,color:"#64748b"},children:["ⓘ ",n(m==="inherited"?"layout_editor.error_rows.override_inherited":"layout_editor.error_rows.override_template")]}),g?i.jsx("button",{type:"button","data-testid":`${l}-revert-${f}`,onClick:()=>p(f),style:{fontSize:12,padding:"2px 8px",border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",whiteSpace:"nowrap"},children:n("layout_editor.error_rows.revert")}):i.jsx("button",{type:"button","data-testid":`${l}-override-here-${f}`,onClick:()=>c(f,{}),style:{fontSize:12,padding:"2px 8px",border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",whiteSpace:"nowrap"},children:n("layout_editor.error_rows.override_here")})]}):null,f==="default"?i.jsxs("p",{"data-testid":`${l}-default-warn-${f}`,style:{margin:"6px 0 0",fontSize:12,color:"#b45309"},children:["⚠ ",n("layout_editor.error_rows.default_warning")]}):null,i.jsx("div",{style:{marginTop:6},children:a?a(f,y,h=>c(f,h)):i.jsx("pre",{"data-testid":`${l}-json-${f}`,style:{margin:0,fontSize:12,color:"#475569"},children:g?JSON.stringify(y):""})})]},f)})})}function DS(e){return[...e.filter(n=>n!=="maintenance"),"default"]}function NS({value:e,onChange:t,t:n,errorConfigCodes:o=[],ownCodes:r=[],templateCodes:s=[],recipes:a,pageCandidates:l,modalCandidates:d,dataSourceCandidates:c,stateKeyCandidates:p}){const f=u.useMemo(()=>vo(a),[a]),y=u.useMemo(()=>DS(o),[o]),g=f.length>0,m=u.useMemo(()=>Bl("error",n),[n]),h=u.useMemo(()=>({pageCandidates:l,dataSourceCandidates:c,stateKeyCandidates:p,modalCandidates:d,bindingCandidates:m}),[l,c,p,d,m]),v=u.useCallback(b=>r.includes(b)?"self":b in e?"inherited":s.includes(b)?"template":"none",[r,e,s]);return g?i.jsxs("div",{className:"g7le-error-form","data-testid":"g7le-error-form",style:iu,children:[i.jsx("p",{style:su,children:n("layout_editor.page_settings.error.heading")}),i.jsxs("p",{style:MS,children:["ⓘ ",n("layout_editor.error.intro")]}),i.jsx(js,{value:e,onChange:t,t:n,codes:y,mode:"badge",sourceOf:v,testIdPrefix:"g7le-error-rows",renderActionList:(b,x,k)=>i.jsx(zS,{code:b,action:x,recipes:a,normalized:f,t:n,pools:h,onPatch:k})})]}):i.jsxs("div",{className:"g7le-error-form","data-testid":"g7le-error-form",style:iu,children:[i.jsx("p",{style:su,children:n("layout_editor.page_settings.error.heading")}),i.jsxs("p",{"data-testid":"g7le-error-degrade",style:LS,children:["ⓘ ",n("layout_editor.error.degrade")]}),i.jsx(js,{value:e,onChange:t,t:n,codes:y,mode:"badge",sourceOf:v,testIdPrefix:"g7le-error-rows"})]})}function zS({code:e,action:t,recipes:n,normalized:o,t:r,pools:s,onPatch:a}){const[l,d]=u.useState(!1),c=Array.isArray(t)?t[0]:t,p=typeof c?.handler=="string"?c.handler:"",f=o.find(x=>x.id===p)??null,y=c?qn(c,o):null,g=y?.kind==="preset"?y.values:{},m=p==="sequence"||p==="parallel",h=x=>{if(x===""){a({});return}const k=o.find(w=>w.id===x);k&&a(vr(k,{}))},v=["","showErrorPage","navigate","openModal","toast","setState","sequence","parallel"],b=m&&c?Array.isArray(c.params?.actions)?c.params.actions:[]:[];return i.jsxs("div",{"data-testid":`g7le-error-action-${e}`,style:PS,children:[i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6},children:[i.jsx("select",{"data-testid":`g7le-error-handler-${e}`,value:p,onChange:x=>h(x.target.value),style:{...OS,flex:1},children:v.map(x=>i.jsx("option",{value:x,children:r(x===""?"layout_editor.error.handler_none":`layout_editor.error.handler_${x}`)},x))}),p!==""?i.jsx("button",{type:"button","data-testid":`g7le-error-code-${e}`,onClick:()=>d(x=>!x),"aria-label":r("layout_editor.action_list.view_code"),title:r("layout_editor.action_list.view_code"),style:l?BS:au,children:""}):null]}),m?i.jsx("div",{"data-testid":`g7le-error-actions-${e}`,children:i.jsx(Gt,{actions:b,onChange:x=>a({handler:p,params:{actions:x}}),t:r,recipes:n,candidatePools:s,chipContext:"error",testIdPrefix:`g7le-error-seq-${e}`})}):f&&c?i.jsx(Is,{raw:c,recipe:f,values:g,t:r,pools:s,onChange:x=>a(x),testIdPrefix:`g7le-error-action-${e}-edit`}):null,l&&c?i.jsx("pre",{"data-testid":`g7le-error-code-view-${e}`,style:WS,children:JSON.stringify(t,null,2)}):null]})}const iu={display:"flex",flexDirection:"column",gap:12,minWidth:0},su={margin:0,fontSize:13,fontWeight:700,color:"#0f172a"},MS={margin:0,fontSize:11,color:"#94a3b8"},LS={margin:0,fontSize:12,color:"#b45309",background:"#fef3c7",padding:"8px 10px",borderRadius:6},PS={display:"flex",flexDirection:"column",gap:6,minWidth:0},OS={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6},au={flex:"0 0 auto",width:26,height:26,display:"inline-flex",alignItems:"center",justifyContent:"center",fontSize:11,border:"1px solid #e2e8f0",borderRadius:6,background:"#fff",color:"#64748b",cursor:"pointer",fontFamily:"ui-monospace, monospace"},BS={...au,background:"#0f172a",borderColor:"#0f172a",color:"#e2e8f0"},WS={margin:"4px 0 0",padding:8,background:"#0f172a",color:"#e2e8f0",borderRadius:6,fontSize:12,overflow:"auto",fontFamily:"ui-monospace, monospace"};function Wr(e){const t=e.trim(),n=/^\{\{([\s\S]*)\}\}$/.exec(t);return n?n[1].trim():t}function jS(e){let t=e;for(;t.length>=2&&t[0]==="("&&t[t.length-1]===")";){let n=0,o=!0;for(let r=0;r!!o&&typeof o.value=="string"&&typeof o.expr=="string").map(o=>({value:o.value,label:typeof o.label=="string"?o.label:void 0,params:Array.isArray(o.params)?o.params:[],expr:Wr(o.expr),aliases:lu(o.aliases)}));const n=[];for(const[o,r]of Object.entries(e)){if(o==="comment"||o==="operators"||!r||typeof r!="object")continue;const s=r;typeof s.expr=="string"&&n.push({value:o,label:typeof s.label=="string"?s.label:void 0,params:Array.isArray(s.params)?s.params:[],expr:Wr(s.expr),aliases:lu(s.aliases)})}return n}function lu(e){if(!Array.isArray(e))return[];const t=[];for(const n of e){if(typeof n!="string")continue;const o=Wr(n);o.trim().length>0&&t.push(o)}return t}function FS(e){const t=e.params.length*1e3;return jr(e.expr).replace(/\{\w+\}/g,"").length-t}function KS(e){return e.map((t,n)=>({op:t,idx:n,score:FS(t)})).sort((t,n)=>n.score-t.score||t.idx-n.idx).map(t=>t.op)}function VS(e,t){return e.replace(/\{(\w+)\}/g,(n,o)=>{const r=t[o];return r===void 0?n:r})}function US(e,t){const n=t.find(o=>o.value===e.operator);return n?VS(n.expr,e.params??{}):null}function du(e,t,n){const o=e.map(a=>US(a,n)).filter(a=>typeof a=="string"&&a.trim().length>0);if(o.length===0)return"";if(o.length===1)return`{{ ${o[0]} }}`;const r=t==="or"?" || ":" && ";return`{{ ${o.map(a=>`(${a})`).join(r)} }}`}function cu(e,t){const n=jr(e);for(const o of KS(t)){if(o.params.length===0){for(const r of[o.expr,...o.aliases])if(jr(r)===n)return{operator:o.value,params:{}};continue}for(const r of[o.expr,...o.aliases]){const s=qS(o,r,n);if(s)return s}}return null}function qS(e,t,n){const o=t.trim(),r=[];let s="",a=0;for(;a=|<=|>|<|\?|:|\(|\)|!/.test(y))return null;return{operator:e.value,params:c}}function GS(e){const t=new Set,n=/(['"])\{(\w+)\}\1/g;let o;for(;(o=n.exec(e))!==null;)t.add(o[2]);return t}function YS(e,t){if(typeof e!="string"||e.trim()==="")return null;const n=Wr(e),o=cu(n,t);if(o)return{clauses:[o],combinator:"and"};const r=n.includes(") || (")?"or":(n.includes(") && ("),"and"),s=r==="or"?") || (":") && (";if(!n.startsWith("(")||!n.endsWith(")")||!n.includes(s))return null;const l=n.slice(1,-1).split(s),d=[];for(const c of l){const p=cu(c.trim(),t);if(!p)return null;d.push(p)}return{clauses:d,combinator:r}}const uu=Lt(),zn=uu.responsiveManager,Eo=uu.BREAKPOINT_PRESETS;function pu(e){const t=e.groupPrefixes;return{group:typeof e.group=="string"?e.group:void 0,apply:e.apply&&typeof e.apply=="object"?e.apply:void 0,options:Array.isArray(e.options)?e.options:void 0,scale:Array.isArray(e.scale)?e.scale:void 0,groupPrefixes:Array.isArray(t)?t.filter(n=>typeof n=="string"&&n.length>0):void 0,groupTokens:Array.isArray(e.groupTokens)?e.groupTokens.filter(n=>typeof n=="string"&&n.length>0):void 0}}function fu(e){const t={...e??{}},n=t.style&&typeof t.style=="object"&&!Array.isArray(t.style)?{...t.style}:{};return t.style=n,{props:t,style:n}}function Hr(e){return typeof e!="string"?[]:e.split(/\s+/).filter(t=>t.length>0)}function gu(e,t=!1){const n=new Set,o=[],r=s=>{if(s){for(const a of s.tokens??[])n.add(a);s.tokenTemplate&&s.tokenTemplate.includes("{value}")&&o.push(s.tokenTemplate.split("{value}")[0]??"")}};r(e.apply);for(const s of e.options??[])r(s.apply);for(const s of e.groupPrefixes??[])o.push(s);for(const s of e.groupTokens??[])n.add(s);return s=>{if(t){if(!Yt(s))return!1;const a=Vs(s);return n.has(a)?!0:o.some(l=>l.length>0&&a.startsWith(l))}return Yt(s)?!1:n.has(s)?!0:o.some(a=>a.length>0&&s.startsWith(a))}}function XS(e,t,n,o,r=!1){const s=gu(t,r),a=Hr(e.className).filter(p=>!s(p)),l=[];if(Array.isArray(n.tokens))l.push(...n.tokens);else if(n.tokenTemplate&&n.tokenTemplate.includes("{value}")&&o!=null){const p=String(o).split(/\s+/).filter(f=>f.length>0);for(const f of p)l.push(n.tokenTemplate.replace("{value}",f))}const d=r?l.map(_u):l,c=[...a,...d];c.length===0?delete e.className:e.className=c.join(" ")}const Hs={url:"backgroundImage",size:"backgroundSize",repeat:"backgroundRepeat",position:"backgroundPosition"};function JS(e){return!e||typeof e!="object"||Array.isArray(e)?!1:Object.keys(Hs).some(t=>t in e)}function ZS(e){return/^url\(/i.test(e.trim())?e:`url(${e})`}function hu(e){const t=/^url\(\s*(['"]?)(.*?)\1\s*\)$/i.exec(e.trim());return t?t[2]:e}function QS(e){return e.includes("backgroundImage")}function Fs(e,t){if(e!=null){if(typeof e=="object"){const n=e[t];return typeof n=="string"||typeof n=="number"?n:void 0}return e}}function RS(e){const t=e.backgroundImage,n=Fs(e.backgroundSize,"size"),o=Fs(e.backgroundRepeat,"repeat"),r=Fs(e.backgroundPosition,"position");let s;if(typeof t=="string"?s=hu(t):t&&typeof t=="object"?s=t.url:s=void 0,s===void 0&&n===void 0&&o===void 0&&r===void 0)return;const a={};return s!==void 0&&(a.url=typeof s=="string"?hu(s):s),n!==void 0&&(a.size=n),o!==void 0&&(a.repeat=o),r!==void 0&&(a.position=r),a}function eC(e,t,n){const o=(r,s)=>{s==null||s===""?delete e[r]:e[r]=s};if(Array.isArray(t.props)){if(!t.values&&JS(n)){for(const r of t.props){const s=Object.keys(Hs).find(d=>Hs[d]===r);let a=s?n[s]:void 0;a!==null&&typeof a=="object"&&(a=void 0);const l=r==="backgroundImage"&&typeof a=="string"&&a!==""?ZS(a):a;o(r,l)}return}for(const r of t.props){const s=t.values&&r in t.values?t.values[r]:n;o(r,s)}return}typeof t.prop=="string"&&o(t.prop,t.value!==void 0?t.value:n)}function tC(e,t,n){if(typeof t.varName!="string")return;const o=t.value!==void 0?t.value:n;o==null||o===""?delete e[t.varName]:e[t.varName]=o}function nC(e,t,n){if(typeof t.propKey!="string")return;const o=t.value!==void 0?t.value:n;o==null||o===""?delete e[t.propKey]:e[t.propKey]=o}function pn(e,t,n,o=Wt){const r=pu(t),s=o.colorScheme==="dark";let a,l=!1;if(r.options){const y=r.options.find(g=>g.value===n);l=y!==void 0,a=y?.apply??r.apply}else a=r.apply;const d=n==null||n===""||l&&a===void 0,p=(d?r.apply:a)?.type==="classToken"||(r.options?.some(y=>y.apply?.type==="classToken")??!1);if(s&&!p)return e;const f=o.breakpoint!=="base"&&p?(()=>{const y=e.props??{};return typeof y.className=="string"?y.className:void 0})():void 0;return vu(e,o,(y,g)=>{if(f!==void 0&&typeof y.className!="string"&&(y.className=f),d){const m=gu(r,s),h=Hr(y.className).filter(v=>!m(v));if(h.length===0?delete y.className:y.className=h.join(" "),!s&&r.apply){const v=r.apply;if(v.type==="styleProp")for(const b of v.props??(v.prop?[v.prop]:[]))delete g[b];else v.type==="cssVar"&&v.varName?delete g[v.varName]:v.type==="propValue"&&v.propKey&&delete y[v.propKey]}}else if(a)switch(a.type){case"classToken":XS(y,r,a,n,s);break;case"styleProp":eC(g,a,n);break;case"cssVar":tC(g,a,n);break;case"propValue":nC(y,a,n);break}})}function Fr(e,t){const n=Hr(e);return t?n.filter(Yt).map(Vs):n.filter(o=>!Yt(o))}function oC(e,t,n,o){const r=new Set(t.filter(l=>typeof l=="string"&&l.length>0)),s=l=>o?Yt(l)?r.has(Vs(l)):!1:Yt(l)?!1:r.has(l),a=Hr(e).filter(l=>!s(l));return n&&n.length>0&&a.push(o?_u(n):n),a.join(" ").trim()}function yu(e,t,n,o){if(e.options&&e.options.some(s=>s.apply)){const s=Fr(t.className,o),a=[];for(const l of e.options)l.apply&&iC(l.apply,s,n,t)&&a.push(l.value);if(a.length>0)return{value:a[0],matched:!0,conflict:a.length>1?!0:void 0};if(!e.apply)return{value:void 0,matched:!1}}const r=e.apply;if(!r)return{value:void 0,matched:!1};switch(r.type){case"styleProp":{if(!r.values&&Array.isArray(r.props)&&QS(r.props)){const l=RS(n);return l===void 0?{value:void 0,matched:!1}:{value:l,matched:!0}}const s=r.prop??r.props?.[0];if(!s)return{value:void 0,matched:!1};const a=n[s];return a===void 0?{value:void 0,matched:!1}:{value:a,matched:!0}}case"cssVar":{if(!r.varName)return{value:void 0,matched:!1};const s=n[r.varName];return s===void 0?{value:void 0,matched:!1}:{value:s,matched:!0}}case"propValue":{if(!r.propKey)return{value:void 0,matched:!1};const s=t[r.propKey];return s===void 0?{value:void 0,matched:!1}:{value:s,matched:!0}}case"classToken":{const s=Fr(t.className,o);if(r.tokenTemplate&&r.tokenTemplate.includes("{value}")){const[a,l]=r.tokenTemplate.split("{value}");if((a??"")===""&&(l??"")===""&&e.groupPrefixes?.length){const d=s.filter(c=>e.groupPrefixes.some(p=>c.startsWith(p)));return d.length>0?{value:d.join(" "),matched:!0}:{value:void 0,matched:!1}}for(const d of s)if(d.startsWith(a)&&d.endsWith(l??""))return{value:d.slice(a.length,l?d.length-l.length:void 0),matched:!0}}return Array.isArray(r.tokens)&&r.tokens.every(a=>s.includes(a))?{value:!0,matched:!0}:{value:void 0,matched:!1}}default:return{value:void 0,matched:!1}}}function bu(e){return e.style&&typeof e.style=="object"&&!Array.isArray(e.style)?e.style:{}}function Io(e,t,n=Wt){const o=pu(t),r=n.colorScheme==="dark";if(r&&!Ks(o.apply)&&!o.options?.some(m=>Ks(m.apply)))return{value:void 0,matched:!1,darkReadonly:!0};const s=cC(e,n),a=bu(s),l=yu(o,s,a,r);if(n.breakpoint==="base")return l;const d=l.matched?l.value:void 0,c=e.props??{},p=bu(c),f=yu(o,c,p,r),y=l.matched&&l.value!==void 0,g=y&&!rC(l.value,f.value);return{value:y?l.value:f.value,matched:g?l.matched:f.matched,conflict:g?l.conflict:f.conflict,scopedValue:g?l.value:void 0,baseFallback:g?void 0:f.value,scopedRawValue:d}}function rC(e,t){if(e===t)return!0;if(e===null||t===null||typeof e!="object"||typeof t!="object")return!1;try{return JSON.stringify(e)===JSON.stringify(t)}catch{return!1}}function iC(e,t,n,o){switch(e.type){case"classToken":return Array.isArray(e.tokens)&&e.tokens.length>0?e.tokens.every(r=>t.includes(r)):!1;case"styleProp":{const r=e.prop??e.props?.[0];if(!r)return!1;const s=e.value;return s!==void 0?n[r]===s:n[r]!==void 0}case"cssVar":return e.varName?n[e.varName]!==void 0:!1;case"propValue":return e.propKey?e.value!==void 0?o[e.propKey]===e.value:o[e.propKey]!==void 0:!1;default:return!1}}const Wt={colorScheme:"base",breakpoint:"base"},Ao="dark:";function sC(e){return e==="base"?!0:zn.parseRange(e)!==null}function mu(e){return e in Eo}function Kr(e,t,n){if(e==="custom"){if(typeof n=="number"){const o=t.responsive;if(o&&typeof o=="object"){const r=zn.getMatchingKey(o,n);if(r)return r}}return"base"}return e}function aC(e){return!!e&&typeof e=="object"&&Array.isArray(e.children)}function lC(e,t,n){let o,r,s;if(e!=="custom"&&e in Eo){o=e;const l=Eo[e];r=l.min,s=l.max}else if(e!=="custom"){const l=zn.parseRange(e);if(l)o=e,r=l.min,s=l.max;else{const d=typeof n=="number"?n:zn.getWidth();o=`${d}-${d}`,r=d,s=d}}else{const l=typeof n=="number"?n:zn.getWidth();r=l,s=l,o=`${l}-${l}`}const a=t.responsive;if(a&&typeof a=="object"){let l=null,d=1/0;for(const c of Object.keys(a)){if(!aC(a[c]))continue;const p=zn.parseRange(c);if(p){if(p.min===r&&p.max===s)return{mode:"merge",key:c};if(p.min<=r&&p.max>=s){const f=p.max-p.min;ft.length>0):[]}function fC(e,t){if(t.colorScheme==="dark")return vu(e,t,l=>{const d=Vr(l.className).filter(c=>!Yt(c));d.length===0?delete l.className:l.className=d.join(" ")});if(t.breakpoint==="base")return e;const o=t.breakpoint;if(!e.responsive?.[o])return e;const r={...e.responsive},s={...r[o]};delete s.props,Object.keys(s).length===0?delete r[o]:r[o]=s;const a={...e};return Object.keys(r).length===0?delete a.responsive:a.responsive=r,a}function Us(e,t){if(t.colorScheme==="dark"){const c=t.breakpoint==="base"?e.props:e.responsive?.[t.breakpoint]?.props;return Vr(c?.className).some(Yt)}if(t.breakpoint==="base")return!1;const o=e.responsive?.[t.breakpoint];if(!o)return!1;const r=o.props;if(Object.keys(o).filter(c=>c!=="props").length>0)return!0;if(!r)return!1;if(Object.keys(r).filter(c=>c!=="className").length>0)return!0;const l=new Set(Vr(e.props?.className).filter(c=>!Yt(c))),d=Vr(r.className).filter(c=>!Yt(c));return d.length!==l.size?!0:d.some(c=>!l.has(c))}function xu(e,t,n=""){return typeof e!="string"?n:e.startsWith("$t:")?t(e.slice(3)):e}function wu(e,t,n){if(n.breakpoint!=="base")return pC(e,n,t.trim());const o={...e};if(o.props&&typeof o.props=="object"&&"if"in o.props){const r={...o.props};delete r.if,o.props=r}return t.trim()===""?delete o.if:o.if=t,o}function ku({node:e,spec:t,t:n,onPatchNode:o,dataSourceCandidates:r,stateKeyCandidates:s,scope:a=Wt}){const l=u.useMemo(()=>HS(t?.conditionRecipes),[t]),d=a.breakpoint!=="base"?uC(e,a):e.if??e.props?.if,c=u.useMemo(()=>YS(d,l),[d,l]),p=c?.clauses??[],f=c?.combinator??"and",y=typeof d=="string"&&d.trim()!==""&&c===null;if(l.length===0)return i.jsx("div",{"data-testid":"g7le-condition-no-recipes",style:gC,children:n("layout_editor.visibility.no_recipes")});const g=(k,w)=>{o(wu(e,du(k,w,l),a))},m=(k,w)=>{g(p.map((_,S)=>S===k?w:_),f)},h=k=>{g(p.filter((w,_)=>_!==k),f)},v=k=>{const w=l[0];g([...p,{operator:w.value,params:{}}],k)},b=k=>{if(k==="datasource-picker")return r;if(k==="state-key-picker")return s};if(y)return i.jsxs("div",{className:"g7le-condition-advanced","data-testid":"g7le-condition-advanced",style:{display:"flex",flexDirection:"column",gap:8},children:[i.jsx("div",{style:kC,children:n("layout_editor.visibility.advanced_preserved")}),i.jsx("code",{"data-testid":"g7le-condition-advanced-expr",style:Cu,children:String(d)}),i.jsx("button",{type:"button","data-testid":"g7le-condition-clear-advanced",onClick:()=>o(wu(e,"",a)),style:SC,children:n("layout_editor.visibility.clear")})]});const x=du(p,f,l);return i.jsxs("div",{className:"g7le-condition-builder","data-testid":"g7le-condition-builder",style:{display:"flex",flexDirection:"column",gap:10},children:[i.jsx("div",{style:hC,children:n("layout_editor.visibility.show_when")}),p.map((k,w)=>{const _=l.find(S=>S.value===k.operator)??l[0];return i.jsxs("div",{className:"g7le-condition-clause","data-testid":`g7le-condition-clause-${w}`,style:yC,children:[i.jsxs("div",{style:{display:"flex",gap:6,alignItems:"center"},children:[w>0&&i.jsx("span",{"data-testid":`g7le-condition-combinator-${w}`,style:mC,children:n(f==="or"?"layout_editor.visibility.or":"layout_editor.visibility.and")}),i.jsx("select",{"data-testid":`g7le-condition-select-${w}`,value:k.operator,onChange:S=>m(w,{operator:S.target.value,params:{}}),style:bC,children:l.map(S=>i.jsx("option",{value:S.value,children:xu(S.label,n,S.value)},S.value))}),i.jsx("button",{type:"button","data-testid":`g7le-condition-remove-${w}`,"aria-label":n("layout_editor.visibility.remove"),onClick:()=>h(w),style:vC,children:"✕"})]}),_.params.map(S=>{const E=An(S.widget);return i.jsxs("div",{className:"g7le-condition-param","data-testid":`g7le-condition-param-${w}-${S.key}`,style:_C,children:[i.jsx("span",{style:xC,children:xu(S.label,n,S.key)}),i.jsx("div",{style:{flex:1},children:E?i.jsx(E,{control:{...S},value:k.params[S.key],onChange:C=>m(w,{operator:k.operator,params:{...k.params,[S.key]:C===void 0?"":String(C)}}),t:n,candidates:b(S.widget)}):i.jsx("span",{style:{fontSize:11,color:"#94a3b8"},children:n("layout_editor.property_modal.unsupported_widget")})})]},S.key)})]},w)}),i.jsxs("div",{style:{display:"flex",gap:6},children:[i.jsxs("button",{type:"button","data-testid":"g7le-condition-add-and",onClick:()=>v("and"),style:Su,children:["+ ",n("layout_editor.visibility.and")]}),i.jsxs("button",{type:"button","data-testid":"g7le-condition-add-or",onClick:()=>v("or"),style:Su,children:["+ ",n("layout_editor.visibility.or")]})]}),x!==""&&i.jsxs("div",{"data-testid":"g7le-condition-preview",style:wC,children:[i.jsx("span",{style:{fontSize:11,color:"#64748b"},children:n("layout_editor.visibility.preview")}),i.jsx("code",{style:Cu,children:x})]})]})}const gC={fontSize:12,color:"#94a3b8",padding:"16px 0",textAlign:"center"},hC={fontSize:12,fontWeight:600,color:"#0f172a"},yC={border:"1px solid #e2e8f0",borderRadius:8,padding:10,display:"flex",flexDirection:"column",gap:8,background:"#f8fafc"},bC={flex:1,padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff"},mC={fontSize:11,fontWeight:700,color:"#2563eb",background:"#dbeafe",padding:"2px 8px",borderRadius:999},vC={border:"none",background:"transparent",color:"#94a3b8",cursor:"pointer",fontSize:12},Su={padding:"5px 12px",fontSize:12,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},_C={display:"flex",alignItems:"center",gap:8},xC={fontSize:11,color:"#64748b",minWidth:64},wC={display:"flex",flexDirection:"column",gap:4,padding:10,background:"#f1f5f9",borderRadius:6},Cu={fontSize:11,color:"#0f172a",fontFamily:"monospace",wordBreak:"break-all"},kC={fontSize:11,color:"#92400e",background:"#fef3c7",padding:"6px 8px",borderRadius:6},SC={padding:"5px 12px",fontSize:12,border:"1px solid #fecaca",borderRadius:6,background:"#fff",color:"#dc2626",cursor:"pointer",alignSelf:"flex-start"};function CC(e){return typeof e=="string"?e:e===void 0?"":JSON.stringify(e)}function $C(e){const t=e.trim();if(t==="")return"";if(/^-?\d+(\.\d+)?$/.test(t)||t==="true"||t==="false"||t==="null")try{return JSON.parse(t)}catch{return e}return e}function $u(e){return!e||typeof e!="object"||Array.isArray(e)?[]:Object.entries(e).map(([t,n])=>({key:t,value:CC(n)}))}function qs(e){const t={};for(const n of e){const o=n.key.trim();o!==""&&(t[o]=$C(n.value))}return t}function Eu(e){return!e||typeof e!="object"||Array.isArray(e)?!1:Object.values(e).some(t=>t!==null&&typeof t=="object")}function EC(e){const t={};return typeof e.if=="string"&&e.if.trim().length>0&&(t.if=e.if),t}function IC(e,t){const n={...t},o=e.if;return typeof o=="string"&&o.trim().length>0?n.if=o:delete n.if,n}const AC=["api","static","route_params","query_params","websocket"],TC=["GET","POST","PUT","PATCH","DELETE"],DC=["none","optional","required"],NC=["progressive","blocking","background"],zC=["application/json","multipart/form-data"],MC=["public","private","presence"];function LC(e){if(!e||typeof e!="object")return{own:[],inherited:[]};const t=e.__editor,n=t&&typeof t=="object"&&!Array.isArray(t)?t.original:void 0,o=n&&typeof n=="object"&&!Array.isArray(n)?n.data_sources:e.data_sources,r=Array.isArray(o)?o.filter(c=>c&&typeof c=="object"):[],s=new Set(r.map(c=>typeof c.id=="string"?c.id:"").filter(Boolean)),a=e.data_sources,d=(Array.isArray(a)?a.filter(c=>c&&typeof c=="object"):[]).filter(c=>typeof c.id=="string"&&!s.has(c.id));return{own:r,inherited:d}}function PC(e,t){const n=(o,r="")=>typeof o=="string"?o:r;return{index:t,id:n(e.id),type:n(e.type,"api"),label_key:n(e.label_key),endpoint:n(e.endpoint),method:n(e.method,"GET"),auth_mode:n(e.auth_mode,"none"),loading_strategy:n(e.loading_strategy,"progressive"),auto_fetch:e.auto_fetch!==!1,paramRows:$u(e.params),paramsRaw:Eu(e.params),paramsRawValue:e.params&&typeof e.params=="object"&&!Array.isArray(e.params)?e.params:{},paramsRawValid:!0,fallbackEnabled:e.fallback!==void 0,fallbackValue:e.fallback,if:n(e.if),contentType:n(e.contentType),refetchOnMount:e.refetchOnMount===!0,onSuccess:Array.isArray(e.onSuccess)?e.onSuccess.filter(o=>!!o&&typeof o=="object"):[],onError:Array.isArray(e.onError)?e.onError.filter(o=>!!o&&typeof o=="object"):[],errorHandling:e.errorHandling&&typeof e.errorHandling=="object"&&!Array.isArray(e.errorHandling)?e.errorHandling:{},channel:n(e.channel),event:n(e.event),channel_type:n(e.channel_type,"public"),target_source:n(e.target_source),onReceive:Array.isArray(e.onReceive)?e.onReceive.filter(o=>!!o&&typeof o=="object"):[]}}function OC(){return{index:-1,id:"",type:"api",label_key:"",endpoint:"",method:"GET",auth_mode:"none",loading_strategy:"progressive",auto_fetch:!0,paramRows:[],paramsRaw:!1,paramsRawValue:{},paramsRawValid:!0,fallbackEnabled:!1,fallbackValue:void 0,if:"",contentType:"",refetchOnMount:!1,onSuccess:[],onError:[],errorHandling:{},channel:"",event:"",channel_type:"public",target_source:"",onReceive:[]}}function BC(e,t,n,o){const r=e.id.trim();if(!r)return{error:o("layout_editor.data_sources.error.id_required")};if(!/^[A-Za-z_][\w]*$/.test(r))return{error:o("layout_editor.data_sources.error.id_invalid")};if(n.has(r))return{error:o("layout_editor.data_sources.error.id_duplicate",{id:r})};const s=t?{...t}:{};if(s.id=r,s.type=e.type,e.label_key.trim()?s.label_key=e.label_key.trim():delete s.label_key,e.type==="api"||e.type==="websocket"?(e.endpoint.trim()?s.endpoint=e.endpoint.trim():delete s.endpoint,s.method=e.method,s.auth_mode=e.auth_mode,s.loading_strategy=e.loading_strategy):(delete s.endpoint,delete s.method),s.auto_fetch=e.auto_fetch,e.paramsRaw){if(!e.paramsRawValid)return{error:o("layout_editor.data_sources.error.params_json")};const a=e.paramsRawValue;a&&typeof a=="object"&&Object.keys(a).length>0?s.params=a:delete s.params}else{const a=qs(e.paramRows);Object.keys(a).length>0?s.params=a:delete s.params}return e.fallbackEnabled?s.fallback=e.fallbackValue===void 0?{}:e.fallbackValue:delete s.fallback,e.if.trim()?s.if=e.if.trim():delete s.if,e.type==="api"&&e.contentType?s.contentType=e.contentType:delete s.contentType,e.refetchOnMount?s.refetchOnMount=!0:delete s.refetchOnMount,e.onSuccess.length>0?s.onSuccess=e.onSuccess:delete s.onSuccess,e.onError.length>0?s.onError=e.onError:delete s.onError,Object.keys(e.errorHandling).length>0?s.errorHandling=e.errorHandling:delete s.errorHandling,e.type==="websocket"?(e.channel?s.channel=e.channel:delete s.channel,e.event?s.event=e.event:delete s.event,s.channel_type=e.channel_type,e.target_source?s.target_source=e.target_source:delete s.target_source,e.onReceive.length>0?s.onReceive=e.onReceive:delete s.onReceive):(delete s.channel,delete s.event,delete s.channel_type,delete s.target_source,delete s.onReceive),{entry:s}}const jt={width:"100%",padding:"6px 8px",fontSize:13,border:"1px solid #cbd5e1",borderRadius:6,outline:"none",boxSizing:"border-box",fontFamily:"inherit",color:"#0f172a",background:"#ffffff"},ct={display:"block",fontSize:12,fontWeight:600,color:"#475569",marginBottom:4},ut={marginBottom:12},on={padding:"6px 12px",fontSize:13,fontWeight:500,borderRadius:6,border:"1px solid #cbd5e1",background:"#ffffff",color:"#0f172a",cursor:"pointer"},Iu={...on,background:"#2563eb",borderColor:"#2563eb",color:"#ffffff",fontWeight:600},To={marginTop:12,padding:10,border:"1px solid #e2e8f0",borderRadius:8,background:"#f8fafc"},Do={fontSize:12,fontWeight:700,color:"#475569",marginBottom:8},WC={margin:"0 0 6px",fontSize:11,color:"#94a3b8"},Au={marginTop:8},Tu={alignSelf:"flex-start",padding:"2px 0",fontSize:11,border:"none",background:"none",color:"#64748b",cursor:"pointer"},Du={margin:0,padding:8,background:"#0f172a",color:"#e2e8f0",borderRadius:6,fontSize:11,lineHeight:1.5,overflow:"auto",fontFamily:"ui-monospace, monospace"};function jC({code:e,action:t,recipes:n,pools:o,t:r,onPatch:s}){const[a,l]=u.useState(!1),d=u.useMemo(()=>vo(n),[n]),c=Array.isArray(t)?t[0]:t,p=typeof c?.handler=="string"?c.handler:"",f=d.find(x=>x.id===p)??null,y=c?qn(c,d):null,g=y?.kind==="preset"?y.values:{},m=p==="sequence"||p==="parallel",h=["","showErrorPage","navigate","openModal","toast","setState","sequence","parallel"],v=x=>{if(x===""){s({});return}const k=d.find(w=>w.id===x);s(k?vr(k,{}):{handler:x})},b=m&&c?Array.isArray(c.params?.actions)?c.params.actions:[]:[];return i.jsxs("div",{"data-testid":`g7le-ds-errorhandling-${e}`,style:{display:"flex",flexDirection:"column",gap:6},children:[i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6},children:[i.jsx("select",{"data-testid":`g7le-ds-eh-handler-${e}`,style:{...jt,flex:1},value:p,onChange:x=>v(x.target.value),children:h.map(x=>i.jsx("option",{value:x,children:r(x===""?"layout_editor.error.handler_none":`layout_editor.error.handler_${x}`)},x))}),p!==""?i.jsx("button",{type:"button","data-testid":`g7le-ds-eh-code-${e}`,onClick:()=>l(x=>!x),"aria-label":r("layout_editor.action_list.view_code"),title:r("layout_editor.action_list.view_code"),style:a?HC:Nu,children:""}):null]}),m?i.jsx("div",{"data-testid":`g7le-ds-eh-actions-${e}`,children:i.jsx(Gt,{actions:b,onChange:x=>s({handler:p,params:{actions:x}}),t:r,recipes:n,candidatePools:o,chipContext:"error",testIdPrefix:`g7le-ds-eh-seq-${e}`})}):f&&c?i.jsx(Is,{raw:c,recipe:f,values:g,t:r,pools:o,onChange:x=>s(x),testIdPrefix:`g7le-ds-eh-action-${e}-edit`}):null,a&&c?i.jsx("pre",{"data-testid":`g7le-ds-eh-code-view-${e}`,style:FC,children:JSON.stringify(t,null,2)}):null]})}const Nu={flex:"0 0 auto",width:26,height:26,display:"inline-flex",alignItems:"center",justifyContent:"center",fontSize:11,border:"1px solid #e2e8f0",borderRadius:6,background:"#fff",color:"#64748b",cursor:"pointer",fontFamily:"ui-monospace, monospace"},HC={...Nu,background:"#0f172a",borderColor:"#0f172a",color:"#e2e8f0"},FC={margin:"4px 0 0",padding:8,background:"#0f172a",color:"#e2e8f0",borderRadius:6,fontSize:12,overflow:"auto",fontFamily:"ui-monospace, monospace"};function KC({raw:e,onChange:t,t:n,resolveLabel:o,onClose:r,actionRecipes:s,errorRecipes:a,conditionSpec:l,bindingCandidates:d,pageCandidates:c,dataSourceCandidates:p,stateKeyCandidates:f}){const y=o??n,g=u.useMemo(()=>LC(e),[e]),m=g.inherited,[h,v]=u.useState(g.own),[b,x]=u.useState(null),[k,w]=u.useState(null),[_,S]=u.useState(!1),[E,C]=u.useState(!1),[N,$]=u.useState(()=>new Set),A=u.useCallback(I=>{$(P=>{const F=new Set(P);return F.has(I)?F.delete(I):F.add(I),F})},[]),z=u.useMemo(()=>({pageCandidates:c,dataSourceCandidates:p,stateKeyCandidates:f,bindingCandidates:d}),[c,p,f,d]),D=u.useCallback(I=>{v(I),t([...m,...I],I)},[m,t]),O=u.useCallback(()=>{w(null),x(OC())},[]),M=u.useCallback((I,P)=>{w(null),x(PC(I,P))},[]),B=u.useCallback(I=>{const P=h.filter((F,G)=>G!==I);D(P)},[h,D]),T=u.useCallback(()=>{x(null),w(null)},[]),H=u.useCallback(()=>{if(!b)return;const I=new Set;h.forEach((V,Y)=>{Y!==b.index&&typeof V.id=="string"&&I.add(V.id)}),m.forEach(V=>{typeof V.id=="string"&&I.add(V.id)});const P=b.index>=0?h[b.index]??null:null,F=BC(b,P,I,n);if("error"in F){w(F.error);return}const G=b.index>=0?h.map((V,Y)=>Y===b.index?F.entry:V):[...h,F.entry];D(G),x(null),w(null)},[b,h,m,D,n]),L=b?.type==="api"||b?.type==="websocket";return i.jsxs("div",{className:"g7le-data-sources","data-testid":"g7le-data-sources-panel",style:{flex:"1 1 auto",minHeight:0,display:"flex",flexDirection:"column",fontSize:13,color:"#0f172a",boxSizing:"border-box"},children:[i.jsxs("div",{className:"g7le-data-sources__header",style:{flex:"0 0 auto",padding:"16px 16px 12px",borderBottom:"1px solid #e2e8f0"},children:[i.jsxs("div",{style:{display:"flex",alignItems:"center",justifyContent:"space-between",marginBottom:8},children:[i.jsx("h2",{style:{margin:0,fontSize:16,fontWeight:700},children:n("layout_editor.data_sources.title")}),i.jsx("button",{type:"button","data-testid":"g7le-data-sources-close",onClick:r,"aria-label":n("layout_editor.data_sources.close"),style:{...on,padding:"4px 10px"},children:"✕"})]}),i.jsx("p",{style:{margin:0,fontSize:12,color:"#64748b",lineHeight:1.5},children:n("layout_editor.data_sources.description")})]}),i.jsxs("div",{className:"g7le-data-sources__body",style:{flex:"1 1 auto",minHeight:0,overflowY:"auto",padding:16},children:[b&&i.jsxs("div",{className:"g7le-data-sources__form","data-testid":"g7le-data-sources-form",children:[i.jsx("div",{style:{fontSize:13,fontWeight:700,marginBottom:12},children:b.index>=0?n("layout_editor.data_sources.form.edit_title"):n("layout_editor.data_sources.form.add_title")}),i.jsxs("div",{style:ut,children:[i.jsx("label",{style:ct,htmlFor:"g7le-ds-id",children:n("layout_editor.data_sources.field.id")}),i.jsx("input",{id:"g7le-ds-id","data-testid":"g7le-data-sources-field-id",style:jt,value:b.id,onChange:I=>x({...b,id:I.target.value}),placeholder:"products"})]}),i.jsxs("div",{style:ut,children:[i.jsx("label",{style:ct,htmlFor:"g7le-ds-label",children:n("layout_editor.data_sources.field.label_key")}),i.jsx(vt,{value:b.label_key,onChange:I=>x({...b,label_key:I??""}),t:n,placeholder:n("layout_editor.data_sources.field.label_key_placeholder"),testidPrefix:"g7le-data-sources-field-label",candidates:d,enableExpressionTree:!0,expressionTreeCollapsible:!0}),i.jsx("div",{style:{fontSize:11,color:"#94a3b8",marginTop:4},children:n("layout_editor.data_sources.field.label_key_hint")})]}),i.jsxs("div",{style:ut,children:[i.jsx("label",{style:ct,htmlFor:"g7le-ds-type",children:n("layout_editor.data_sources.field.type")}),i.jsx("select",{id:"g7le-ds-type","data-testid":"g7le-data-sources-field-type",style:jt,value:b.type,onChange:I=>x({...b,type:I.target.value}),children:AC.map(I=>i.jsx("option",{value:I,children:n(`layout_editor.data_sources.ds_type.${I}`)},I))})]}),L&&i.jsxs(i.Fragment,{children:[i.jsxs("div",{style:ut,children:[i.jsx("label",{style:ct,children:n("layout_editor.data_sources.field.endpoint")}),i.jsx("div",{"data-testid":"g7le-data-sources-field-endpoint",children:i.jsx(Ve,{value:b.endpoint,onChange:I=>x({...b,endpoint:I}),t:n,candidates:d,placeholder:"/api/shop/products/{{route.id}}",testidPrefix:"g7le-ds-endpoint"})})]}),i.jsxs("div",{style:{display:"flex",gap:12},children:[i.jsxs("div",{style:{...ut,flex:1},children:[i.jsx("label",{style:ct,htmlFor:"g7le-ds-method",children:n("layout_editor.data_sources.field.method")}),i.jsx("select",{id:"g7le-ds-method","data-testid":"g7le-data-sources-field-method",style:jt,value:b.method,onChange:I=>x({...b,method:I.target.value}),children:TC.map(I=>i.jsx("option",{value:I,children:I},I))})]}),i.jsxs("div",{style:{...ut,flex:1},children:[i.jsx("label",{style:ct,htmlFor:"g7le-ds-auth",children:n("layout_editor.data_sources.field.auth_mode")}),i.jsx("select",{id:"g7le-ds-auth","data-testid":"g7le-data-sources-field-auth",style:jt,value:b.auth_mode,onChange:I=>x({...b,auth_mode:I.target.value}),children:DC.map(I=>i.jsx("option",{value:I,children:n(`layout_editor.data_sources.auth_mode.${I}`)},I))})]})]}),i.jsxs("div",{style:ut,children:[i.jsx("label",{style:ct,htmlFor:"g7le-ds-loading",children:n("layout_editor.data_sources.field.loading_strategy")}),i.jsx("select",{id:"g7le-ds-loading","data-testid":"g7le-data-sources-field-loading",style:jt,value:b.loading_strategy,onChange:I=>x({...b,loading_strategy:I.target.value}),children:NC.map(I=>i.jsx("option",{value:I,children:n(`layout_editor.data_sources.loading_strategy.${I}`)},I))})]}),b.type==="api"&&i.jsxs("div",{style:ut,children:[i.jsx("label",{style:ct,htmlFor:"g7le-ds-contenttype",children:n("layout_editor.data_sources.field.content_type")}),i.jsx("select",{id:"g7le-ds-contenttype","data-testid":"g7le-ds-contenttype",style:jt,value:b.contentType||"application/json",onChange:I=>x({...b,contentType:I.target.value}),children:zC.map(I=>i.jsx("option",{value:I,children:I},I))})]})]}),i.jsx("div",{style:ut,children:i.jsxs("label",{style:{...ct,display:"flex",alignItems:"center",gap:8,cursor:"pointer"},children:[i.jsx("input",{type:"checkbox","data-testid":"g7le-data-sources-field-autofetch",checked:b.auto_fetch,onChange:I=>x({...b,auto_fetch:I.target.checked})}),n("layout_editor.data_sources.field.auto_fetch")]})}),i.jsx("div",{style:ut,children:i.jsxs("label",{style:{...ct,display:"flex",alignItems:"center",gap:8,cursor:"pointer"},children:[i.jsx("input",{type:"checkbox","data-testid":"g7le-ds-refetch",checked:b.refetchOnMount,onChange:I=>x({...b,refetchOnMount:I.target.checked})}),n("layout_editor.data_sources.field.refetch_on_mount")]})}),i.jsxs("div",{style:ut,children:[i.jsxs("div",{style:{display:"flex",alignItems:"center",justifyContent:"space-between"},children:[i.jsx("label",{style:ct,htmlFor:"g7le-ds-params",children:n("layout_editor.data_sources.field.params")}),i.jsx("button",{type:"button","data-testid":"g7le-ds-params-mode-toggle",onClick:()=>{if(b.paramsRaw){if(b.paramsRawValid){const I=b.paramsRawValue;I&&typeof I=="object"&&!Array.isArray(I)&&!Eu(I)&&x({...b,paramsRaw:!1,paramRows:$u(I)})}}else{const I=qs(b.paramRows);x({...b,paramsRaw:!0,paramsRawValue:I,paramsRawValid:!0})}},style:{...on,padding:"2px 8px",fontSize:11},children:b.paramsRaw?n("layout_editor.data_sources.field.params_mode_block"):n("layout_editor.data_sources.field.params_mode_code")})]}),b.paramsRaw?i.jsx(Ps,{id:"g7le-ds-params",value:b.paramsRawValue,shape:"object",emptyValue:{},minHeight:64,testidPrefix:"g7le-data-sources-field-params",placeholder:`{ "page": "{{query.page ?? 1}}" }`,shapeErrorKey:"layout_editor.data_sources.error.params_object",invalidErrorKey:"layout_editor.data_sources.error.params_json",t:n,onChange:I=>x(P=>P&&{...P,paramsRawValue:I??{},paramsRawValid:!0}),onValidityChange:I=>x(P=>P&&{...P,paramsRawValid:I})}):i.jsxs("div",{"data-testid":"g7le-data-sources-field-params",children:[b.paramRows.length===0&&i.jsx("p",{"data-testid":"g7le-ds-params-empty",style:WC,children:n("layout_editor.data_sources.field.params_empty")}),i.jsx(Dr,{value:b.paramRows.map(I=>({key:I.key,value:I.value})),onChange:I=>x({...b,paramRows:I.map(P=>({key:P.key??"",value:P.value??""}))}),keyField:"key",valueField:"value",t:n,candidates:d,testidPrefix:"g7le-ds-params-kv",keyPlaceholder:n("layout_editor.data_sources.field.param_key_placeholder"),valuePlaceholder:"{{query.page ?? 1}}",addLabel:n("layout_editor.data_sources.field.param_add")}),(()=>{const I=qs(b.paramRows);return Object.keys(I).length===0?null:i.jsxs("div",{style:Au,children:[i.jsxs("button",{type:"button","data-testid":"g7le-ds-params-preview-toggle","aria-expanded":_,onClick:()=>S(P=>!P),style:Tu,children:[_?"▴":"▾"," ",n("layout_editor.data_sources.field.params_preview")]}),_&&i.jsx("pre",{"data-testid":"g7le-ds-params-preview",style:{...Du,marginTop:4},children:JSON.stringify(I,null,2)})]})})()]}),i.jsx("div",{style:{fontSize:11,color:"#94a3b8",marginTop:4},children:n("layout_editor.data_sources.field.params_hint")})]}),i.jsxs("div",{style:ut,children:[i.jsx("label",{style:ct,children:n("layout_editor.data_sources.field.fallback")}),b.fallbackEnabled?i.jsxs("div",{"data-testid":"g7le-data-sources-field-fallback",style:{display:"flex",flexDirection:"column",gap:6},children:[i.jsx(Dn,{value:b.fallbackValue===void 0?{}:b.fallbackValue,onChange:I=>x({...b,fallbackValue:I}),t:n,path:"fallback",scope:"ds-fallback",renderStringLeaf:I=>i.jsx(Ve,{value:I.value,onChange:I.onChange,t:n,candidates:d,testidPrefix:I.testidPrefix})}),(()=>{const I=b.fallbackValue===void 0?{}:b.fallbackValue;return i.jsxs("div",{style:Au,children:[i.jsxs("button",{type:"button","data-testid":"g7le-ds-fallback-preview-toggle","aria-expanded":E,onClick:()=>C(P=>!P),style:Tu,children:[E?"▴":"▾"," ",n("layout_editor.data_sources.field.fallback_preview")]}),E&&i.jsx("pre",{"data-testid":"g7le-ds-fallback-preview",style:{...Du,marginTop:4},children:JSON.stringify(I,null,2)})]})})(),i.jsx("button",{type:"button","data-testid":"g7le-data-sources-field-fallback-clear",onClick:()=>x({...b,fallbackEnabled:!1,fallbackValue:void 0}),style:{...on,padding:"2px 8px",fontSize:11,alignSelf:"flex-start",color:"#dc2626",borderColor:"#fecaca"},children:n("layout_editor.data_sources.field.fallback_clear")})]}):i.jsxs("button",{type:"button","data-testid":"g7le-data-sources-field-fallback-add",onClick:()=>x({...b,fallbackEnabled:!0,fallbackValue:{}}),style:{...on,padding:"4px 10px",display:"block"},children:["+ ",n("layout_editor.data_sources.field.fallback_add")]})]}),b.type==="websocket"&&i.jsxs("div",{"data-testid":"g7le-ds-ws-section",style:To,children:[i.jsx("div",{style:Do,children:n("layout_editor.data_sources.section.websocket")}),i.jsxs("div",{style:ut,children:[i.jsx("label",{style:ct,children:n("layout_editor.data_sources.field.channel")}),i.jsx("input",{"data-testid":"g7le-ds-ws-channel",style:jt,value:b.channel,onChange:I=>x({...b,channel:I.target.value}),placeholder:"core.admin.dashboard"})]}),i.jsxs("div",{style:ut,children:[i.jsx("label",{style:ct,children:n("layout_editor.data_sources.field.event")}),i.jsx("input",{"data-testid":"g7le-ds-ws-event",style:jt,value:b.event,onChange:I=>x({...b,event:I.target.value}),placeholder:"dashboard.stats.updated"})]}),i.jsxs("div",{style:ut,children:[i.jsx("label",{style:ct,children:n("layout_editor.data_sources.field.channel_type")}),i.jsx("select",{"data-testid":"g7le-ds-ws-channeltype",style:jt,value:b.channel_type,onChange:I=>x({...b,channel_type:I.target.value}),children:MC.map(I=>i.jsx("option",{value:I,children:I},I))})]}),i.jsxs("div",{style:ut,children:[i.jsx("label",{style:ct,children:n("layout_editor.data_sources.field.target_source")}),i.jsx("input",{"data-testid":"g7le-ds-ws-target",style:jt,value:b.target_source,onChange:I=>x({...b,target_source:I.target.value}),list:"g7le-ds-ws-target-list"}),i.jsx("datalist",{id:"g7le-ds-ws-target-list",children:h.filter(I=>I.id!==b.id).map(I=>typeof I.id=="string"?i.jsx("option",{value:I.id},I.id):null)})]}),i.jsxs("div",{style:ut,children:[i.jsx("label",{style:ct,children:n("layout_editor.data_sources.field.on_receive")}),i.jsx("div",{"data-testid":"g7le-ds-ws-onreceive",children:i.jsx(Gt,{actions:b.onReceive,onChange:I=>x({...b,onReceive:I}),t:n,recipes:s,candidatePools:z,chipContext:"payload",actionChipCandidates:l?.actionChipCandidates??null,testIdPrefix:"g7le-ds-onreceive-list"})})]})]}),i.jsxs("div",{style:To,children:[i.jsx("div",{style:Do,children:n("layout_editor.data_sources.section.on_success")}),i.jsx("div",{"data-testid":"g7le-ds-onsuccess",children:i.jsx(Gt,{actions:b.onSuccess,onChange:I=>x({...b,onSuccess:I}),t:n,recipes:s,candidatePools:z,chipContext:"response",actionChipCandidates:l?.actionChipCandidates??null,testIdPrefix:"g7le-ds-onsuccess-list"})})]}),i.jsxs("div",{style:To,children:[i.jsx("div",{style:Do,children:n("layout_editor.data_sources.section.on_error")}),i.jsx("div",{"data-testid":"g7le-ds-onerror",children:i.jsx(Gt,{actions:b.onError,onChange:I=>x({...b,onError:I}),t:n,recipes:s,candidatePools:z,chipContext:"error",actionChipCandidates:l?.actionChipCandidates??null,testIdPrefix:"g7le-ds-onerror-list"})})]}),i.jsxs("div",{style:To,children:[i.jsx("div",{style:Do,children:n("layout_editor.data_sources.section.error_handling")}),i.jsxs("div",{"data-testid":"g7le-ds-errorhandling",children:[i.jsx(js,{value:b.errorHandling,onChange:I=>x({...b,errorHandling:I}),t:n,mode:"local",testIdPrefix:"g7le-ds-eh",renderActionList:(I,P,F)=>i.jsx(jC,{code:I,action:P,recipes:a,pools:z,t:n,onPatch:F})}),i.jsxs("button",{type:"button","data-testid":"g7le-ds-errorhandling-add",onClick:()=>{const I=Object.keys(b.errorHandling),P=["403","404","500","422","default"].find(F=>!I.includes(F))??"default";x({...b,errorHandling:{...b.errorHandling,[P]:{handler:"toast"}}})},style:{...on,padding:"4px 10px",marginTop:6},children:["+ ",n("layout_editor.data_sources.add_error_code")]})]})]}),i.jsxs("div",{style:To,children:[i.jsx("div",{style:Do,children:n("layout_editor.data_sources.section.load_condition")}),i.jsx("div",{"data-testid":"g7le-ds-loadcondition",children:i.jsx(ku,{node:EC({if:b.if||void 0}),spec:l??null,t:n,dataSourceCandidates:p,stateKeyCandidates:f,onPatchNode:I=>{const P=IC(I,{});x({...b,if:typeof P.if=="string"?P.if:""})}})})]})]}),!b&&i.jsxs("button",{type:"button","data-testid":"g7le-data-sources-add",onClick:O,style:{...Iu,marginBottom:16},children:["+ ",n("layout_editor.data_sources.add")]}),i.jsxs("div",{className:"g7le-data-sources__list","data-testid":"g7le-data-sources-list",style:{display:"flex",flexDirection:"column",gap:8},children:[h.length===0&&!b&&i.jsx("div",{"data-testid":"g7le-data-sources-empty",style:{fontSize:12,color:"#94a3b8",padding:"12px 0",textAlign:"center"},children:n("layout_editor.data_sources.empty")}),h.map((I,P)=>{const F=typeof I.id=="string"?I.id:`#${P}`,G=typeof I.endpoint=="string"?I.endpoint:"",V=typeof I.label_key=="string"?I.label_key:"",Y=ns(I,y);return i.jsxs("div",{"data-testid":"g7le-data-sources-item","data-ds-id":F,style:{border:"1px solid #e2e8f0",borderRadius:8,padding:"10px 12px",display:"flex",alignItems:"center",gap:8,background:"#ffffff"},children:[i.jsxs("div",{style:{flex:1,minWidth:0},children:[i.jsxs("div",{"data-testid":"g7le-data-sources-item-title",style:{fontWeight:600,fontSize:13,display:"flex",alignItems:"baseline",gap:6,flexWrap:"wrap"},children:[i.jsx("span",{children:Y??F}),Y&&i.jsx("span",{"data-testid":"g7le-data-sources-item-id",style:{fontSize:11,fontWeight:400,color:"#94a3b8"},children:F}),(()=>{const Z=os(I,n);return Z?i.jsx("span",{"data-testid":"g7le-data-sources-ext-badge",style:{fontSize:10,fontWeight:600,color:"#7c3aed",background:"#f5f3ff",border:"1px solid #ddd6fe",borderRadius:4,padding:"1px 6px"},children:Z}):null})()]}),G&&i.jsx("div",{style:{fontSize:11,color:"#94a3b8",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:G}),!V&&i.jsx("div",{"data-testid":"g7le-data-sources-nolabel",style:{fontSize:11,color:"#f59e0b"},children:n("layout_editor.data_sources.no_label")})]}),i.jsx("button",{type:"button","data-testid":"g7le-data-sources-edit",onClick:()=>M(I,P),style:{...on,padding:"4px 10px"},children:n("layout_editor.data_sources.edit")}),i.jsx("button",{type:"button","data-testid":"g7le-data-sources-remove",onClick:()=>B(P),style:{...on,padding:"4px 10px",color:"#dc2626",borderColor:"#fecaca"},children:n("layout_editor.data_sources.remove")})]},`${F}-${P}`)})]}),m.length>0&&i.jsxs("div",{style:{marginTop:16},children:[i.jsx("div",{style:{fontSize:12,fontWeight:600,color:"#64748b",textTransform:"uppercase",letterSpacing:.4,marginBottom:8},children:n("layout_editor.data_sources.inherited_title")}),i.jsx("div",{style:{display:"flex",flexDirection:"column",gap:6},children:m.map((I,P)=>{const F=typeof I.id=="string"?I.id:`#${P}`,G=N.has(F),V=[],Y=(Z,se)=>{const re=I[Z];re!=null&&V.push([se,typeof re=="object"?JSON.stringify(re):String(re)])};return Y("type",n("layout_editor.data_sources.field.type")),Y("endpoint",n("layout_editor.data_sources.field.endpoint")),Y("method",n("layout_editor.data_sources.field.method")),Y("auth_mode",n("layout_editor.data_sources.field.auth_mode")),Y("loading_strategy",n("layout_editor.data_sources.field.loading_strategy")),Y("auto_fetch",n("layout_editor.data_sources.field.auto_fetch")),Y("params",n("layout_editor.data_sources.field.params")),i.jsxs("div",{"data-testid":"g7le-data-sources-inherited-item","data-ds-id":F,"data-expanded":G?"true":"false",style:{border:"1px dashed #e2e8f0",borderRadius:6,fontSize:12,color:"#64748b",background:"#f8fafc",overflow:"hidden"},children:[i.jsxs("button",{type:"button","data-testid":"g7le-data-sources-inherited-toggle",onClick:()=>A(F),"aria-expanded":G,style:{width:"100%",display:"flex",alignItems:"center",gap:8,padding:"8px 12px",background:"transparent",border:"none",cursor:"pointer",color:"inherit",fontSize:12,textAlign:"left",outline:"none"},children:[i.jsx("span",{style:{flex:"0 0 auto",width:12,transition:"transform 120ms",transform:G?"rotate(90deg)":"none"},children:"▶"}),(()=>{const Z=ns(I,y),se=os(I,n);return i.jsxs("span",{style:{flex:1,display:"inline-flex",alignItems:"baseline",gap:6,flexWrap:"wrap"},children:[i.jsx("span",{style:{fontWeight:600},children:Z??F}),Z&&i.jsx("span",{style:{fontSize:11,fontWeight:400,color:"#94a3b8"},children:F}),se&&i.jsx("span",{"data-testid":"g7le-data-sources-ext-badge",style:{fontSize:10,fontWeight:600,color:"#7c3aed",background:"#f5f3ff",border:"1px solid #ddd6fe",borderRadius:4,padding:"1px 6px"},children:se})]})})()]}),G&&i.jsxs("div",{"data-testid":"g7le-data-sources-inherited-info",style:{padding:"0 12px 10px 32px",display:"flex",flexDirection:"column",gap:3},children:[V.length===0&&i.jsx("span",{style:{color:"#94a3b8"},children:"—"}),V.map(([Z,se])=>i.jsxs("div",{style:{display:"flex",gap:6},children:[i.jsxs("span",{style:{flex:"0 0 auto",color:"#94a3b8"},children:[Z,":"]}),i.jsx("span",{style:{flex:1,minWidth:0,wordBreak:"break-all",color:"#475569"},children:se})]},Z))]})]},`inh-${F}-${P}`)})})]})]}),b&&i.jsxs("div",{className:"g7le-data-sources__footer",style:{flex:"0 0 auto",padding:"12px 16px",borderTop:"1px solid #e2e8f0",background:"#ffffff"},children:[k&&i.jsx("div",{"data-testid":"g7le-data-sources-form-error",role:"alert",style:{fontSize:12,color:"#dc2626",marginBottom:10},children:k}),i.jsxs("div",{style:{display:"flex",gap:8,justifyContent:"flex-end"},children:[i.jsx("button",{type:"button","data-testid":"g7le-data-sources-form-cancel",onClick:T,style:on,children:n("layout_editor.data_sources.form.cancel")}),i.jsx("button",{type:"button","data-testid":"g7le-data-sources-form-submit",onClick:H,style:Iu,children:n("layout_editor.data_sources.form.save")})]})]})]})}function VC(e){const t=e?.globalHeaders;return Array.isArray(t)?t:[]}function UC(e){const t=e?.scripts;return Array.isArray(t)?t:[]}function qC(e){const t=VC(e.raw),n=UC(e.raw);return i.jsxs("div",{className:"g7le-data-source-tab","data-testid":"g7le-data-source-tab",style:GC,children:[i.jsx(KC,{...e}),i.jsxs("div",{style:zu,"data-testid":"g7le-global-headers",children:[i.jsx("div",{style:Mu,children:e.t("layout_editor.data_sources.global_headers")}),t.length===0?i.jsx("p",{"data-testid":"g7le-global-header-empty",style:Wu,children:e.t("layout_editor.data_sources.global_headers_empty")}):t.map((o,r)=>i.jsxs("div",{"data-testid":`g7le-global-header-${r}`,style:Lu,children:[i.jsx("code",{style:Pu,children:o.pattern??"*"}),i.jsx("span",{style:YC,children:"→"}),i.jsx("span",{style:Ou,children:Object.keys(o.headers??{}).join(", ")})]},r)),i.jsxs("p",{style:Bu,children:["ⓘ ",e.t("layout_editor.data_sources.global_headers_hint")]})]}),i.jsxs("div",{style:zu,"data-testid":"g7le-scripts",children:[i.jsx("div",{style:Mu,children:e.t("layout_editor.data_sources.scripts")}),n.length===0?i.jsx("p",{"data-testid":"g7le-script-empty",style:Wu,children:e.t("layout_editor.data_sources.scripts_empty")}):n.map((o,r)=>i.jsxs("div",{"data-testid":`g7le-script-${o.id??r}`,style:Lu,children:[i.jsx("code",{style:Pu,children:o.id??`#${r}`}),i.jsx("span",{style:Ou,children:o.src??""}),o.loading?i.jsx("span",{style:XC,children:o.loading}):null]},o.id??r)),i.jsxs("p",{style:Bu,children:["ⓘ ",e.t("layout_editor.data_sources.scripts_hint")]})]})]})}const GC={display:"flex",flexDirection:"column",gap:16,minWidth:0},zu={borderTop:"1px solid #e2e8f0",paddingTop:12,display:"flex",flexDirection:"column",gap:6},Mu={fontSize:12,fontWeight:700,color:"#475569"},Lu={display:"flex",alignItems:"center",gap:8,fontSize:12,padding:"4px 8px",border:"1px solid #f1f5f9",borderRadius:6},Pu={fontSize:11,color:"#0f172a",fontFamily:"monospace"},YC={color:"#94a3b8"},Ou={flex:1,minWidth:0,color:"#475569",wordBreak:"break-all"},XC={fontSize:10,color:"#64748b",background:"#f1f5f9",borderRadius:4,padding:"0 4px"},Bu={margin:0,fontSize:11,color:"#94a3b8"},Wu={margin:0,fontSize:12,color:"#94a3b8"},JC=["meta","seo","init","overlay","computed","state","error","data"],ZC=["401","403","404","500","503"],QC={meta:"layout_editor.page_settings.tab.meta",seo:"layout_editor.page_settings.tab.seo",init:"layout_editor.page_settings.tab.init",overlay:"layout_editor.page_settings.tab.overlay",computed:"layout_editor.page_settings.tab.computed",state:"layout_editor.page_settings.tab.state",error:"layout_editor.page_settings.tab.error",data:"layout_editor.page_settings.tab.data"};async function RC(e){if(typeof fetch!="function"||!e)return[];try{const t=await fetch(`/api/admin/templates/${encodeURIComponent(e)}/editor/seo-candidates.json`,{credentials:"same-origin",headers:je()});if(!t.ok)return[];const o=(await t.json().catch(()=>null))?.data?.extensions;if(!Array.isArray(o))return[];const r=[];for(const s of o)if(s&&typeof s=="object"){const a=s.type,l=s.id,d=s.label;typeof a=="string"&&typeof l=="string"&&l.length>0&&r.push({type:a,id:l,name:typeof d=="string"?d:l})}return r}catch{return[]}}function e$(e,t){let n=0;for(const o of e)qn(o,t).kind==="advanced"&&(n+=1);return n}function t$(e,t){let n=0;for(const o of Object.keys(e))kd(e[o],t).kind==="advanced"&&(n+=1);return n}function n$(e,t,n){const o=e("meta",{})??{},r=o.title;if(typeof r=="string"&&r.length>0){if(r.startsWith("$t:")){const a=n(r.slice(3));if(a&&a!==r.slice(3))return a}else if(!/\{\{[\s\S]*\}\}/.test(r))return r}const s=o.editor_label;return typeof s=="string"&&s.length>0?s.startsWith("$t:")?n(s.slice(3)):s:t||""}function o$({templateIdentifier:e,spec:t,t:n,resolveLabel:o,permissionCandidates:r,onClose:s,initialTab:a="meta",extensionsFetcher:l}){const{state:d}=Je(),{raw:c,getValue:p,patch:f}=Cm(),[y,g]=u.useState(a),m=d.selectedRoute?.path??"",h=u.useMemo(()=>Ad(d.routeTree,o),[d.routeTree,o]),v=u.useMemo(()=>Td(c),[c]),b=u.useMemo(()=>Dd(t),[t]),x=u.useMemo(()=>Vm(c,o),[c,o]),k=Id({raw:c,spec:t}),w=u.useMemo(()=>Xy(t),[t]),_=u.useMemo(()=>Jy(t),[t]),S=u.useMemo(()=>Zy(t),[t]),E=u.useMemo(()=>Qy(t),[t]),C=u.useMemo(()=>vo(w),[w]),N=u.useMemo(()=>bd(_),[_]),$=p("init_actions",void 0)??p("initActions",[]),A=p("computed",{}),z=p("__computedSource",{}),D=p("errorHandling",{}),O=p("transition_overlay",void 0),M=(p("__editor",{})??{}).original,B=u.useMemo(()=>{if(M===void 0)return;const U=M.transition_overlay;return U&&typeof U=="object"&&!Array.isArray(U)?U:{}},[M]),T=u.useMemo(()=>{const U=O;if(!U||typeof U!="object"||Array.isArray(U))return;const ee=B??{},fe={};for(const[K,ne]of Object.entries(U))K in ee||(fe[K]=ne);return Object.keys(fe).length>0?fe:void 0},[O,B]),[H,L]=u.useState([]);u.useEffect(()=>{let U=!1;const ee=l??RC;return(async()=>{const fe=await ee(e);U||L(fe)})(),()=>{U=!0}},[e,l]);const I=(p("meta",{})??{}).seo?.page_type,P=Fm({raw:c,spec:t,pageType:typeof I=="string"?I:null}),F=u.useMemo(()=>Hm(c,t),[c,t]),G=u.useMemo(()=>({meta:0,seo:0,init:e$($,C),overlay:0,computed:t$(A,N),state:0,error:0,data:0}),[$,C,A,N]),V=u.useMemo(()=>Object.values(G).reduce((U,ee)=>U+ee,0),[G]),Y=u.useMemo(()=>Km(c,o,n),[c,o,n]),Z=u.useCallback(U=>{const fe={...M?.computed??{}};delete fe[U];const K={...A};delete K[U],f("computed",K,Object.keys(fe).length===0?void 0:fe)},[M,A,f]),se=n$(p,m,n),re=()=>{switch(y){case"meta":return i.jsx(lx,{getValue:p,patch:f,t:n,fieldT:o,spec:t,permissionCandidates:r,bindingCandidates:k});case"seo":return i.jsx(Y0,{getValue:p,patch:f,t:n,templateIdentifier:e,candidates:P,availableExtensions:H,dataSourceOptions:Y,previewUrl:m||"/",seedContext:F});case"init":return i.jsx(gk,{actions:$,onChange:U=>f("init_actions",U),recipes:w,t:o,bindingCandidates:k,pageCandidates:h,dataSourceCandidates:v,stateKeyCandidates:b,modalCandidates:x});case"overlay":return i.jsx(Ok,{value:O,ownValue:B,baseValue:T,patch:U=>f("transition_overlay",U),t:o,loadingComponents:E,progressiveDataSources:Y,bindingCandidates:k});case"computed":return i.jsx(C1,{computed:A,onChange:U=>f("computed",U),recipes:_,t:o,sampleContext:F,dataSourceCandidates:v,bindingCandidates:k,computedSource:z,onRevert:Z});case"state":return i.jsx(sS,{raw:c,own:M?{initLocal:M.initLocal,initGlobal:M.initGlobal,initIsolated:M.initIsolated,state:M.state}:void 0,patch:f,t:n});case"error":return i.jsx(NS,{value:D,onChange:U=>f("errorHandling",U),t:o,errorConfigCodes:ZC,templateCodes:Object.keys(D),ownCodes:Object.keys(M?.errorHandling??{}),recipes:S,pageCandidates:h,modalCandidates:x,dataSourceCandidates:v,stateKeyCandidates:b});case"data":return i.jsx(qC,{raw:c,t:n,resolveLabel:o,onChange:(U,ee)=>f("data_sources",U,ee),onClose:s,actionRecipes:w,errorRecipes:S,conditionSpec:t,bindingCandidates:k,pageCandidates:h,dataSourceCandidates:v,stateKeyCandidates:b});default:return i.jsx("div",{})}};return i.jsxs("div",{className:"g7le-page-settings","data-testid":"g7le-page-settings",style:r$,children:[i.jsxs("div",{style:i$,children:[i.jsx("span",{"data-testid":"g7le-page-settings-title",style:s$,children:n("layout_editor.page_settings.title",{name:se})}),i.jsx("button",{type:"button","data-testid":"g7le-page-settings-close-x",onClick:s,"aria-label":n("layout_editor.page_settings.close"),style:a$,children:"✕"})]}),i.jsx("div",{role:"tablist",style:l$,children:JC.map(U=>{const ee=G[U];return i.jsxs("button",{type:"button",role:"tab","aria-selected":y===U,"data-testid":`g7le-page-settings-tab-${U}`,"data-active":y===U?"true":"false",onClick:()=>g(U),style:y===U?d$:ju,children:[n(QC[U]),ee>0?i.jsxs("span",{"data-testid":`g7le-page-settings-tab-badge-${U}`,style:c$,children:["(",ee,")"]}):null]},U)})}),V>0?i.jsxs("p",{"data-testid":"g7le-page-settings-advanced-summary",style:u$,children:["⚠ ",n("layout_editor.page_settings.advanced_summary",{count:V})," ⓘ"]}):null,i.jsx("div",{"data-testid":"g7le-page-settings-panel",style:p$,children:re()}),i.jsx("div",{style:f$,children:i.jsx("button",{type:"button","data-testid":"g7le-page-settings-close",onClick:s,style:g$,children:n("layout_editor.page_settings.close")})})]})}const r$={display:"flex",flexDirection:"column",minWidth:0,maxWidth:"100%",padding:"20px 24px",boxSizing:"border-box",flex:1,minHeight:0,maxHeight:"100%",overflow:"hidden",fontFamily:"system-ui, -apple-system, sans-serif"},i$={display:"flex",alignItems:"center",gap:8,paddingBottom:8,borderBottom:"1px solid #e2e8f0"},s$={flex:1,fontSize:15,fontWeight:700,color:"#0f172a",minWidth:0},a$={border:"none",background:"transparent",color:"#64748b",cursor:"pointer",fontSize:16,lineHeight:1,padding:4},l$={display:"flex",gap:2,paddingTop:8,borderBottom:"1px solid #e2e8f0",flexWrap:"nowrap",overflowX:"auto",overflowY:"hidden",flexShrink:0,minWidth:0},ju={border:"none",borderBottomWidth:"2px",borderBottomStyle:"solid",borderBottomColor:"transparent",background:"transparent",color:"#64748b",cursor:"pointer",fontSize:12,padding:"6px 10px",whiteSpace:"nowrap"},d$={...ju,color:"#2563eb",borderBottomColor:"#2563eb",fontWeight:600},c$={marginLeft:4,fontSize:10,color:"#b45309"},u$={margin:0,marginTop:8,fontSize:11,color:"#b45309",background:"#fffbeb",padding:"6px 8px",borderRadius:6},p$={flex:1,overflowY:"auto",paddingTop:12,minWidth:0,minHeight:0},f$={display:"flex",justifyContent:"flex-end",paddingTop:8,borderTop:"1px solid #e2e8f0"},g$={border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",fontSize:13,padding:"6px 16px"};function Gs(e,t){const n=`/api/admin/templates/${encodeURIComponent(e)}`;return t.kind==="layout"?`${n}/layouts/${t.layoutName}/versions`:`${n}/layout-extensions/${encodeURIComponent(t.extensionId)}/versions`}function h$(e,t,n){const[o,r]=u.useState([]),[s,a]=u.useState(!1),[l,d]=u.useState(null),[c,p]=u.useState(null),f=u.useCallback(async()=>{if(!t){r([]),d(null);return}a(!0),d(null);try{const m=Gs(e,t),h=await fetch(m,{credentials:"same-origin",headers:je()}),v=await h.json().catch(()=>null);if(!h.ok){d(v?.message??`HTTP ${h.status}`),r([]);return}const b=v?.data;r(Array.isArray(b)?b:[])}catch(m){d(m instanceof Error?m.message:"network error"),r([])}finally{a(!1)}},[e,t]),y=u.useCallback(async m=>{if(!t)return{kind:"not_found"};p(m);try{const h=`${Gs(e,t)}/${m}/restore`,v=await fetch(h,{method:"POST",credentials:"same-origin",headers:je({"Content-Type":"application/json"})}),b=await v.json().catch(()=>null);if(v.status===404)return{kind:"not_found"};if(!v.ok)return{kind:"network_error",message:b?.message??`HTTP ${v.status}`};const x=typeof b?.data?.version=="number"?b.data.version:-1;return await n?.(x>=0?x:void 0),{kind:"success",newVersion:x}}catch(h){return{kind:"network_error",message:h instanceof Error?h.message:"network error"}}finally{p(null)}},[e,t,n]),g=u.useCallback(async m=>{if(!t)return{kind:"not_found"};try{const h=`${Gs(e,t)}/${m}`,v=await fetch(h,{credentials:"same-origin",headers:je()}),b=await v.json().catch(()=>null);if(v.status===404)return{kind:"not_found"};if(!v.ok)return{kind:"network_error",message:b?.message??`HTTP ${v.status}`};const x=b?.data;if(!x)return{kind:"not_found"};if(t.kind==="extension"&&!x.full_content){const k=x.content;if(typeof k=="string")try{const w=JSON.parse(k);w&&typeof w=="object"&&(x.full_content=w)}catch{}}return{kind:"success",detail:x}}catch(h){return{kind:"network_error",message:h instanceof Error?h.message:"network error"}}},[e,t]);return{versions:o,isLoading:s,error:l,restoringId:c,loadVersions:f,restore:y,loadVersionDetail:g}}function y$(e,t){const n=e.length,o=t.length,r=Array.from({length:n+1},()=>new Array(o+1).fill(0));for(let s=n-1;s>=0;s--)for(let a=o-1;a>=0;a--)e[s]===t[a]?r[s][a]=r[s+1][a+1]+1:r[s][a]=Math.max(r[s+1][a],r[s][a+1]);return r}function b$(e,t,n){const o=[];let r=0,s=0;const a=e.length,l=t.length;for(;r=n[r][s+1]?(o.push({kind:"remove",oldIndex:r,content:e[r]}),r++):(o.push({kind:"add",newIndex:s,content:t[s]}),s++);for(;r0&&t[t.length-1]===""&&t.pop(),t}const Fu=4e3;function m$(e,t,n=3){const o=Hu(e),r=Hu(t);let s=0;const a=Math.min(o.length,r.length);for(;sFu||p.length>Fu)return{hunks:[],addedCount:-1,removedCount:-1,identical:!1};const f=y$(c,p),y=b$(c,p,f),g=[];let m=s,h=s,v=0,b=0;for(let w=0;w{const s=e.slice(r.start,r.end+1);let a=0,l=0,d=0,c=0;for(const g of s)g.oldLine!==null&&(a===0&&(a=g.oldLine),d++),g.newLine!==null&&(l===0&&(l=g.newLine),c++);const p=a||(d===0?0:1),f=l||(c===0?0:1);return{header:`@@ -${p},${d} +${f},${c} @@`,oldStart:p,oldCount:d,newStart:f,newCount:c,lines:s}})}function Ku(e){return JSON.stringify(Ys(e),null,2)}function Ys(e){if(Array.isArray(e))return e.map(Ys);if(e&&typeof e=="object"){const t=e,n={};for(const o of Object.keys(t).sort())n[o]=Ys(t[o]);return n}return e}const _$={display:"flex",flexDirection:"column",height:"100%",minHeight:0},x$={display:"flex",alignItems:"center",gap:8,padding:"12px 16px",borderBottom:"1px solid #e2e8f0",background:"#f8fafc"},w$={display:"inline-flex",alignItems:"center",gap:4,padding:"4px 10px",borderRadius:6,border:"1px solid #cbd5e1",background:"#ffffff",color:"#0f172a",fontSize:12,fontWeight:600,cursor:"pointer",outline:"none"},k$={marginLeft:"auto",border:"none",background:"transparent",fontSize:16,color:"#64748b",cursor:"pointer",outline:"none"},S$={display:"flex",alignItems:"center",gap:12,padding:"8px 16px",borderBottom:"1px solid #e2e8f0",background:"#ffffff",fontSize:12,fontWeight:600},C$={flex:1,minHeight:0,overflow:"auto",background:"#ffffff",fontFamily:'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',fontSize:12,lineHeight:1.5},Vu={padding:"24px 12px",textAlign:"center",color:"#94a3b8",fontSize:13},$$={padding:"2px 12px",background:"#f1f5f9",color:"#64748b",borderTop:"1px solid #e2e8f0",borderBottom:"1px solid #e2e8f0",userSelect:"none"};function E$(e){return e==="add"?{row:{background:"#e6ffed"},gutter:{background:"#cdffd8",color:"#22863a"},sign:"+",signColor:"#22863a"}:e==="remove"?{row:{background:"#ffeef0"},gutter:{background:"#ffdce0",color:"#b31d28"},sign:"-",signColor:"#b31d28"}:{row:{background:"#ffffff"},gutter:{background:"#f8fafc",color:"#94a3b8"},sign:" ",signColor:"#94a3b8"}}const Uu={display:"inline-block",width:44,textAlign:"right",padding:"0 6px",color:"#94a3b8",userSelect:"none",borderRight:"1px solid #e2e8f0",whiteSpace:"nowrap"};function I$({oldVersion:e,newVersion:t,oldContent:n,newContent:o,t:r,onBack:s,onClose:a}){const l=u.useMemo(()=>{const c=Ku(n),p=Ku(o);return m$(c,p)},[n,o]),d=l.addedCount<0;return i.jsxs("div",{className:"g7le-version-diff","data-testid":"g7le-version-diff",style:_$,children:[i.jsxs("div",{"data-modal-drag-handle":!0,style:x$,children:[i.jsxs("button",{type:"button",onClick:s,style:w$,"data-testid":"g7le-version-diff-back",children:["← ",r("layout_editor.version_history.diff_back")]}),i.jsx("span",{style:{fontSize:14,fontWeight:700},"data-testid":"g7le-version-diff-title",children:r("layout_editor.version_history.diff_title",{old:e,new:t})}),i.jsx("button",{type:"button","aria-label":r("layout_editor.version_history.close"),onClick:a,style:k$,"data-testid":"g7le-version-diff-close",children:"✕"})]}),!d&&i.jsxs("div",{style:S$,"data-testid":"g7le-version-diff-summary",children:[i.jsx("span",{style:{color:"#16a34a"},"data-testid":"g7le-version-diff-added",children:r("layout_editor.version_history.added",{count:Math.max(0,l.addedCount)})}),i.jsx("span",{style:{color:"#dc2626"},"data-testid":"g7le-version-diff-removed",children:r("layout_editor.version_history.removed",{count:Math.max(0,l.removedCount)})})]}),i.jsx("div",{style:C$,children:d?i.jsx("div",{style:Vu,"data-testid":"g7le-version-diff-too-large",children:r("layout_editor.version_history.diff_too_large")}):l.identical?i.jsx("div",{style:Vu,"data-testid":"g7le-version-diff-identical",children:r("layout_editor.version_history.diff_identical")}):l.hunks.map((c,p)=>i.jsxs("div",{"data-testid":`g7le-version-diff-hunk-${p}`,children:[i.jsx("div",{style:$$,"data-testid":`g7le-version-diff-hunk-header-${p}`,children:c.header}),c.lines.map((f,y)=>{const g=E$(f.kind);return i.jsxs("div",{style:{display:"flex",alignItems:"stretch",...g.row},"data-kind":f.kind,"data-testid":`g7le-version-diff-line-${p}-${y}`,children:[i.jsx("span",{style:{...Uu,...g.gutter},"aria-hidden":"true",children:f.oldLine??""}),i.jsx("span",{style:{...Uu,...g.gutter},"aria-hidden":"true",children:f.newLine??""}),i.jsx("span",{style:{display:"inline-block",width:16,textAlign:"center",color:g.signColor,userSelect:"none",fontWeight:700},"aria-hidden":"true",children:g.sign}),i.jsx("span",{style:{whiteSpace:"pre-wrap",wordBreak:"break-word",flex:1,padding:"0 4px"},children:f.content})]},y)})]},p))})]})}const A$={display:"flex",flexDirection:"column",height:"100%",minHeight:0},T$={display:"flex",alignItems:"center",gap:8,padding:"12px 16px",borderBottom:"1px solid #e2e8f0",background:"#f8fafc"},D$={marginLeft:"auto",border:"none",background:"transparent",fontSize:16,color:"#64748b",cursor:"pointer",outline:"none"},N$={flex:1,minHeight:0,overflowY:"auto",padding:12},qu={padding:"24px 12px",textAlign:"center",color:"#94a3b8",fontSize:13},z$={margin:"8px 16px 0",padding:"8px 12px",borderRadius:6,background:"#fef2f2",color:"#b91c1c",fontSize:12,border:"1px solid #fecaca"},M$={display:"flex",flexDirection:"column",gap:8},L$={display:"flex",alignItems:"center",gap:12,padding:"10px 12px",borderRadius:8,border:"1px solid #e2e8f0",background:"#ffffff"},P$={display:"inline-flex",alignItems:"center",justifyContent:"center",minWidth:44,padding:"2px 8px",borderRadius:999,background:"#eff6ff",color:"#1d4ed8",fontSize:12,fontWeight:700},O$={display:"inline-flex",alignItems:"center",padding:"1px 8px",borderRadius:999,background:"#0f172a",color:"#ffffff",fontSize:10,fontWeight:700,letterSpacing:.3},B$={display:"flex",alignItems:"center",gap:10,fontSize:11,fontWeight:600},W$={display:"flex",flexDirection:"column",gap:2,minWidth:0,flex:1},Gu={padding:"6px 12px",borderRadius:6,border:"1px solid #cbd5e1",background:"#ffffff",color:"#0f172a",fontSize:12,fontWeight:600,cursor:"pointer",outline:"none",whiteSpace:"nowrap"};function Yu(e){return e.full_content&&typeof e.full_content=="object"?e.full_content:{endpoint:e.endpoint,components:e.components,data_sources:e.data_sources,metadata:e.metadata}}function j$(e){if(!e)return"";const t=Date.parse(e);if(Number.isNaN(t))return e;try{return new Date(t).toLocaleString()}catch{return e}}function H$({templateIdentifier:e,target:t,t:n,onRestored:o,onClose:r}){const{versions:s,isLoading:a,error:l,restoringId:d,loadVersions:c,restore:p,loadVersionDetail:f}=h$(e,t,o),[y,g]=u.useState(null),[m,h]=u.useState(null),[v,b]=u.useState(null);u.useEffect(()=>{c()},[c]);const x=u.useCallback(async _=>{g(null),b(_.version);try{const S=await f(_.version);if(S.kind!=="success"){g(S.kind==="not_found"?n("layout_editor.version_history.restore_not_found"):S.message);return}const E=_.version-1,C=s.some($=>$.version===E);let N={};if(C){const $=await f(E);if($.kind==="success")N=Yu($.detail);else if($.kind==="network_error"){g($.message);return}}h({oldVersion:C?E:0,newVersion:_.version,oldContent:N,newContent:Yu(S.detail)})}finally{b(null)}},[f,s,n]),k=u.useCallback(async _=>{g(null);const S=n("layout_editor.version_history.restore_confirm");if(!(typeof window<"u"?window.confirm(S):!0))return;const C=await p(_);C.kind==="success"?r():C.kind==="not_found"?g(n("layout_editor.version_history.restore_not_found")):g(C.message)},[p,r,n]),w=y??l;return m?i.jsx(I$,{oldVersion:m.oldVersion,newVersion:m.newVersion,oldContent:m.oldContent,newContent:m.newContent,t:n,onBack:()=>h(null),onClose:r}):i.jsxs("div",{className:"g7le-version-history","data-testid":"g7le-version-history",style:A$,children:[i.jsxs("div",{"data-modal-drag-handle":!0,style:T$,children:[i.jsxs("span",{style:{fontSize:14,fontWeight:700},"data-testid":"g7le-version-history-title",children:["🕘 ",n("layout_editor.version_history.title")]}),i.jsx("button",{type:"button","aria-label":n("layout_editor.version_history.close"),onClick:r,style:D$,"data-testid":"g7le-version-history-close",children:"✕"})]}),w&&i.jsx("div",{style:z$,"data-testid":"g7le-version-history-error",children:w}),i.jsx("div",{style:N$,children:a?i.jsx("div",{style:qu,"data-testid":"g7le-version-history-loading",children:n("layout_editor.version_history.loading")}):s.length===0?i.jsx("div",{style:qu,"data-testid":"g7le-version-history-empty",children:n("layout_editor.version_history.empty")}):i.jsx("div",{"data-testid":"g7le-version-history-list",style:M$,children:s.map((_,S)=>{const E=_.changes_summary,C=E?.added_count??0,N=E?.removed_count??0,$=E?.char_diff??0,A=j$(_.created_at),z=d===_.id,D=S===0;return i.jsxs("div",{style:L$,"data-testid":`g7le-version-row-${_.id}`,children:[i.jsxs("span",{style:P$,"data-testid":`g7le-version-badge-${_.id}`,children:["v",_.version]}),i.jsxs("div",{style:W$,children:[i.jsxs("span",{style:{display:"flex",alignItems:"center",gap:8},children:[A&&i.jsx("span",{style:{fontSize:12,color:"#475569",fontWeight:600},children:A}),D&&i.jsx("span",{style:O$,"data-testid":`g7le-version-latest-${_.id}`,children:n("layout_editor.version_history.latest")})]}),i.jsxs("span",{style:B$,"data-testid":`g7le-version-summary-${_.id}`,children:[i.jsx("span",{style:{color:"#16a34a"},"data-testid":`g7le-version-added-${_.id}`,children:n("layout_editor.version_history.added",{count:C})}),i.jsx("span",{style:{color:"#dc2626"},"data-testid":`g7le-version-removed-${_.id}`,children:n("layout_editor.version_history.removed",{count:N})}),i.jsx("span",{style:{color:"#94a3b8",fontWeight:500},"data-testid":`g7le-version-chars-${_.id}`,children:n("layout_editor.version_history.chars",{diff:`${$>=0?"+":""}${$}`})})]}),i.jsx("span",{style:{fontSize:11,color:"#94a3b8"},"data-testid":`g7le-version-author-${_.id}`,children:n("layout_editor.version_history.saved_by",{name:_.created_by_name??n("layout_editor.version_history.unknown_author")})})]}),i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6},children:[i.jsx("button",{type:"button",onClick:()=>void x(_),disabled:v!==null,style:{...Gu,background:"#f8fafc",opacity:v!==null?.6:1},"data-testid":`g7le-version-compare-${_.id}`,children:v===_.version?n("layout_editor.version_history.comparing"):n("layout_editor.version_history.compare")}),!D&&i.jsx("button",{type:"button",onClick:()=>void k(_.id),disabled:z||d!==null,style:{...Gu,opacity:z||d!==null?.6:1},"data-testid":`g7le-version-restore-${_.id}`,children:n(z?"layout_editor.version_history.restoring":"layout_editor.version_history.restore")})]})]},_.id)})})})]})}const Xu=[{id:"copy",group:"clipboard",combos:[{key:"c",mod:!0}],labelKey:"layout_editor.shortcuts.action.copy",requiresSelection:!0},{id:"cut",group:"clipboard",combos:[{key:"x",mod:!0}],labelKey:"layout_editor.shortcuts.action.cut",requiresSelection:!0},{id:"paste",group:"clipboard",combos:[{key:"v",mod:!0}],labelKey:"layout_editor.shortcuts.action.paste"},{id:"openProps",group:"element",combos:[{key:"enter"}],labelKey:"layout_editor.shortcuts.action.open_props",requiresSelection:!0},{id:"delete",group:"element",combos:[{key:"delete"},{key:"backspace"}],labelKey:"layout_editor.shortcuts.action.delete",requiresSelection:!0},{id:"selectParent",group:"element",combos:[{key:"arrowup"}],labelKey:"layout_editor.shortcuts.action.select_parent",requiresSelection:!0},{id:"deselect",group:"element",combos:[{key:"escape"}],labelKey:"layout_editor.shortcuts.action.deselect"},{id:"addElement",group:"element",combos:[{key:"/",mod:!0}],labelKey:"layout_editor.shortcuts.action.add_element"},{id:"undo",group:"history",combos:[{key:"z",mod:!0}],labelKey:"layout_editor.shortcuts.action.undo"},{id:"redo",group:"history",combos:[{key:"z",mod:!0,shift:!0},{key:"y",mod:!0}],labelKey:"layout_editor.shortcuts.action.redo"},{id:"editCode",group:"view",combos:[{key:"e",mod:!0}],labelKey:"layout_editor.shortcuts.action.edit_code"},{id:"preview",group:"view",combos:[{key:"p",mod:!0}],labelKey:"layout_editor.shortcuts.action.preview"},{id:"translations",group:"view",combos:[{key:"l",mod:!0}],labelKey:"layout_editor.shortcuts.action.translations"},{id:"help",group:"view",combos:[{key:"?"}],labelKey:"layout_editor.shortcuts.action.help"},{id:"reset",group:"document",combos:[{key:"r",mod:!0,shift:!0}],labelKey:"layout_editor.shortcuts.action.reset"},{id:"save",group:"document",combos:[{key:"s",mod:!0}],labelKey:"layout_editor.shortcuts.action.save"},{id:"exit",group:"document",combos:[{key:"escape"}],labelKey:"layout_editor.shortcuts.action.exit"}];function F$(e){return{key:(e.key||"").toLowerCase(),mod:e.ctrlKey||e.metaKey,shift:e.shiftKey,alt:e.altKey}}function K$(e,t){return e.key===t.key&&!!e.mod==!!t.mod&&!!e.shift==!!t.shift&&!!e.alt==!!t.alt}function V$(e){for(const t of Xu)if(t.combos.some(n=>K$(n,e)))return t;return null}function U$(e,t){const n=[];return e.mod&&n.push(t?"⌘":"Ctrl"),e.shift&&n.push(t?"⇧":"Shift"),e.alt&&n.push(t?"⌥":"Alt"),n.push(q$(e.key)),n.join(" + ")}function q$(e){return{arrowup:"↑",arrowdown:"↓",arrowleft:"←",arrowright:"→",enter:"Enter",escape:"Esc",delete:"Delete",backspace:"Backspace"," ":"Space"}[e]??(e.length===1?e.toUpperCase():e)}const G$=["clipboard","element","history","view","document"],Y$={clipboard:"layout_editor.shortcuts.group.clipboard",element:"layout_editor.shortcuts.group.element",history:"layout_editor.shortcuts.group.history",view:"layout_editor.shortcuts.group.view",document:"layout_editor.shortcuts.group.document"};function X$(){return typeof navigator>"u"?!1:/mac|iphone|ipad|ipod/i.test(navigator.platform||navigator.userAgent||"")}function J$({t:e,onClose:t,isMac:n}){const o=n??X$(),r=u.useMemo(()=>{const s=new Map;for(const a of Xu){const l=s.get(a.group)??[];l.push(a),s.set(a.group,l)}return s},[]);return i.jsxs("div",{className:"g7le-shortcut-help","data-testid":"g7le-shortcut-help",style:Z$,children:[i.jsxs("div",{style:Q$,children:[i.jsx("span",{style:R$,children:e("layout_editor.shortcuts.title")}),i.jsx("button",{type:"button","data-testid":"g7le-shortcut-help-close",onClick:t,"aria-label":e("layout_editor.shortcuts.close"),style:eE,children:"✕"})]}),i.jsx("div",{style:tE,children:G$.map(s=>{const a=r.get(s);return!a||a.length===0?null:i.jsxs("div",{style:nE,"data-testid":`g7le-shortcut-group-${s}`,children:[i.jsx("div",{style:oE,children:e(Y$[s])}),i.jsx("table",{style:rE,children:i.jsx("tbody",{children:a.map(l=>i.jsxs("tr",{"data-testid":`g7le-shortcut-row-${l.id}`,children:[i.jsx("td",{style:iE,children:e(l.labelKey)}),i.jsx("td",{style:sE,children:l.combos.map((d,c)=>i.jsxs(u.Fragment,{children:[c>0&&i.jsx("span",{style:lE,children:e("layout_editor.shortcuts.or")}),i.jsx("kbd",{style:aE,children:U$(d,o)})]},c))})]},l.id))})})]},s)})}),i.jsx("div",{style:dE,children:e("layout_editor.shortcuts.footnote")})]})}const Z$={display:"flex",flexDirection:"column",padding:20,gap:16,width:"100%",boxSizing:"border-box"},Q$={display:"flex",alignItems:"center",justifyContent:"space-between",paddingBottom:12,borderBottom:"1px solid #e2e8f0"},R$={fontSize:15,fontWeight:700,color:"#0f172a"},eE={width:28,height:28,border:"none",background:"transparent",color:"#64748b",cursor:"pointer",fontSize:14,borderRadius:6},tE={display:"grid",gridTemplateColumns:"1fr 1fr",columnGap:28,rowGap:18,alignItems:"start"},nE={display:"flex",flexDirection:"column",gap:6},oE={fontSize:12,fontWeight:700,color:"#2563eb"},rE={width:"100%",borderCollapse:"collapse"},iE={padding:"4px 0",fontSize:12.5,color:"#334155",textAlign:"left",whiteSpace:"nowrap",verticalAlign:"middle"},sE={padding:"4px 0",paddingLeft:12,textAlign:"right",verticalAlign:"middle"},aE={display:"inline-block",padding:"1px 6px",fontSize:11,fontFamily:"ui-monospace, monospace",color:"#0f172a",background:"#f1f5f9",border:"1px solid #cbd5e1",borderBottomWidth:2,borderRadius:5,whiteSpace:"nowrap"},lE={fontSize:11,color:"#94a3b8",margin:"0 4px"},dE={fontSize:11,color:"#94a3b8",paddingTop:12,borderTop:"1px solid #f1f5f9"};function cE(e){if(!e||!(e instanceof HTMLElement))return!1;const t=e.tagName;return!!(t==="INPUT"||t==="TEXTAREA"||t==="SELECT"||e.isContentEditable)}function uE(){return typeof document>"u"?!1:!!document.querySelector(".g7le-modal-backdrop, .g7le-modal")}function Ju(e){const{handlers:t,hasSelection:n,enabled:o=!0}=e;u.useEffect(()=>{if(!o||typeof window>"u")return;const r=s=>{if(cE(s.target))return;const a=F$(s),l=V$(a);if(!l||uE()||l.requiresSelection&&!n)return;if(l.key==="escape"||a.key==="escape"){if(n){const c=t.deselect;c&&(s.preventDefault(),c())}else{const c=t.exit;c&&(s.preventDefault(),c())}return}const d=t[l.id];d&&(s.preventDefault(),d())};return window.addEventListener("keydown",r),()=>window.removeEventListener("keydown",r)},[t,n,o])}function pE(e){const{result:t,onDismiss:n,onLoadLatest:o,onKeepMyChanges:r}=e,{t:s}=tt();if(u.useEffect(()=>{if(!t||t.kind==="concurrent_modification"||t.kind==="validation_failed")return;const a=setTimeout(n,5e3);return()=>clearTimeout(a)},[t,n]),!t)return null;if(t.kind==="success")return i.jsx(No,{tone:"success",testId:"g7le-save-banner-success",onDismiss:n,children:s("layout_editor.save.success")});if(t.kind==="validation_failed"){const a=t.errors??{},l=Object.entries(a);return i.jsxs(No,{tone:"error",testId:"g7le-save-banner-validation",onDismiss:n,children:[i.jsx("strong",{children:s("layout_editor.save.validation_failed")}),l.length>0&&i.jsx("ul",{style:Zu,"data-testid":"g7le-save-banner-validation-errors",children:l.map(([d,c])=>i.jsxs("li",{children:[i.jsx("code",{style:Qu,children:d}),": ",c.join(", ")]},d))})]})}return t.kind==="blocked_inactive_extension"?i.jsxs(No,{tone:"warning",testId:"g7le-save-banner-blocked",onDismiss:n,children:[i.jsx("strong",{children:s("layout_editor.save.blocked_inactive_extension")}),t.blockedPaths.length>0&&i.jsx("ul",{style:Zu,"data-testid":"g7le-save-banner-blocked-paths",children:t.blockedPaths.map(a=>i.jsx("li",{children:i.jsx("code",{style:Qu,children:a})},a))})]}):t.kind==="network_error"?i.jsxs(No,{tone:"error",testId:"g7le-save-banner-network",onDismiss:n,children:[i.jsx("strong",{children:s("layout_editor.save.network_error_title")}),i.jsx("div",{style:{marginTop:4,fontSize:12},children:t.message})]}):t.kind==="guard_no_document"?i.jsx(No,{tone:"neutral",testId:"g7le-save-banner-guard-no-document",onDismiss:n,children:s("layout_editor.save.guard_no_document")}):i.jsx("div",{role:"dialog","aria-modal":"true","aria-label":s("layout_editor.save.concurrent.title"),"data-testid":"g7le-save-banner-concurrent",style:yE,children:i.jsxs("div",{style:bE,children:[i.jsx("h2",{style:mE,children:s("layout_editor.save.concurrent.title")}),i.jsx("p",{style:vE,children:s("layout_editor.save.concurrent.message")}),i.jsx("div",{style:_E,children:s("layout_editor.save.concurrent.version_info",{current:String(t.currentVersion),yours:String(t.yourVersion)})}),i.jsxs("div",{style:xE,children:[i.jsx("button",{type:"button","data-testid":"g7le-save-banner-concurrent-load-latest",onClick:()=>{o?.(),n()},style:wE,children:s("layout_editor.save.concurrent.load_latest")}),i.jsx("button",{type:"button","data-testid":"g7le-save-banner-concurrent-keep-mine",onClick:()=>{r?.(),n()},style:kE,children:s("layout_editor.save.concurrent.view_my_changes")}),i.jsx("button",{type:"button","data-testid":"g7le-save-banner-concurrent-cancel",onClick:n,style:SE,children:s("layout_editor.save.concurrent.cancel")})]})]})})}function No(e){const{tone:t,testId:n,onDismiss:o,children:r}=e,s=fE[t];return i.jsxs("div",{role:"status","data-testid":n,style:{...gE,background:s.bg,borderColor:s.border,color:s.fg},children:[i.jsx("div",{style:{flex:1,minWidth:0},children:r}),i.jsx("button",{type:"button",onClick:o,"aria-label":"close","data-testid":`${n}-close`,style:hE,children:"×"})]})}const fE={success:{bg:"#dcfce7",border:"#86efac",fg:"#14532d"},error:{bg:"#fee2e2",border:"#fca5a5",fg:"#7f1d1d"},warning:{bg:"#fef3c7",border:"#fcd34d",fg:"#78350f"},neutral:{bg:"#f1f5f9",border:"#cbd5e1",fg:"#334155"}},gE={display:"flex",alignItems:"flex-start",gap:12,padding:"12px 16px",border:"1px solid",borderRadius:0,fontSize:13,lineHeight:1.5},hE={background:"transparent",border:"none",fontSize:18,cursor:"pointer",lineHeight:1,color:"inherit",padding:"0 4px",flexShrink:0},Zu={margin:"8px 0 0",paddingLeft:20,fontSize:12},Qu={background:"rgba(0, 0, 0, 0.06)",padding:"1px 4px",borderRadius:3,fontFamily:"monospace"},yE={position:"fixed",inset:0,background:"rgba(15, 23, 42, 0.6)",display:"flex",alignItems:"center",justifyContent:"center",zIndex:1e4},bE={background:"#fff",borderRadius:8,padding:24,width:"min(480px, 90vw)",boxShadow:"0 10px 25px rgba(0,0,0,0.15)"},mE={margin:"0 0 8px",fontSize:16,fontWeight:600,color:"#0f172a"},vE={margin:"0 0 12px",color:"#334155",fontSize:13,lineHeight:1.5},_E={marginBottom:16,padding:"8px 12px",background:"#f8fafc",borderRadius:6,fontSize:12,color:"#64748b",fontFamily:"monospace"},xE={display:"flex",gap:8,justifyContent:"flex-end"},wE={padding:"8px 14px",background:"#2563eb",color:"#fff",border:"none",borderRadius:6,fontSize:13,fontWeight:600,cursor:"pointer"},kE={padding:"8px 14px",background:"#fff",color:"#334155",border:"1px solid #cbd5e1",borderRadius:6,fontSize:13,fontWeight:500,cursor:"pointer"},SE={padding:"8px 14px",background:"transparent",color:"#64748b",border:"none",borderRadius:6,fontSize:13,cursor:"pointer"};function Ru(e){if(!e)return null;const n=new URLSearchParams(e).get("route");return n&&n.length>0?n:null}function Xs(e,t,n){const o=`/admin/layout-editor/${encodeURIComponent(e)}`;if(!t||t.length===0)return o;if(t.startsWith("__")){let r=`${o}?edit=${encodeURIComponent(t)}`;return(t.startsWith("__modal__/")||t.startsWith("__iteration__/"))&&n&&(r+=`&host=${encodeURIComponent(n)}`),r}return`${o}?route=${encodeURIComponent(t)}`}function ep(e){if(!e)return null;const n=new URLSearchParams(e).get("host");return n&&n.length>0?n:null}function tp(e){if(!e)return null;const n=new URLSearchParams(e).get("edit");return n&&n.length>0&&n.startsWith("__")?n:null}function CE(e,t){const n=`/admin/templates/${encodeURIComponent(e)}/edit`;if(!t||t.length===0||t.startsWith("__"))return n;const o=t.replace(/^\*+/,"");return o.length===0?n:`${n}?route=${encodeURIComponent(o)}`}const $E="g7le-editor-route",EE=new Set;function Js(e){return e.path.startsWith("__group__/")||e.path.startsWith("__conngroup__/")}function np(e,t){const{t:n,translationEngine:o,translationContext:r,templateIdentifier:s}=t,a=Js(e);if(!e.label.startsWith("$t:"))return e.label;const l=e.label.slice(3),d=l.indexOf("|"),c=d===-1?l:l.slice(0,d),p={};if(d!==-1)for(const y of l.slice(d+1).split("|")){const g=y.indexOf("=");g!==-1&&(p[y.slice(0,g)]=y.slice(g+1))}if((e.kind==="route"||e.kind==="modal")&&!a&&o&&r&&s!==r.templateId){const y={templateId:s,locale:r.locale},g=Object.keys(p).length>0?"|"+Object.entries(p).map(([h,v])=>`${h}=${v}`).join("|"):void 0,m=o.translate(c,y,g);if(m!==c)return m}const f=n(c,p);if(f===c&&typeof window<"u"){const y=window.G7Core?.t;if(typeof y=="function")try{const g=y(c,p);if(g!==c)return g}catch{}}if(f===c&&e.kind==="route"&&!a){const y=e.path;if(y&&y!=="/"){const g=y.split("/").filter(Boolean);return g[g.length-1]??y}return y==="/"?"/":c}return f}function IE(e){return e.trim().toLocaleLowerCase()}function op(e,t){if(!t)return e;const n=e.toLocaleLowerCase(),o=[];let r=0,s=n.indexOf(t,r);if(s===-1)return e;let a=0;for(;s!==-1;)s>r&&o.push(e.slice(r,s)),o.push(i.jsx("mark",{className:"g7le-route-tree__highlight",style:{background:"#fde68a",color:"inherit",borderRadius:2,padding:"0 1px"},children:e.slice(s,s+t.length)},`m${a++}`)),r=s+t.length,s=n.indexOf(t,r);return r{const T=f.selectedRoute?.path??"";return T.startsWith("__extension__/")||T.startsWith("__modal__/")?T.slice(T.lastIndexOf("/")+1):null},[f.selectedRoute?.path]),h=u.useMemo(()=>!g||!m?!1:(e.children??[]).some(T=>T.kind==="extension"&&T.extensionId===m||T.kind==="modal"&&T.modalId===m),[g,m,e.children]),[v,b]=u.useState(!0);u.useEffect(()=>{h&&b(!1)},[h]);const x=f.editMode==="extension"?f.selectedRoute?.layoutName??null:null,k=e.kind==="extension"?ip(e,f.routeTree)??null:null,w=e.kind==="extension"&&o===e.path&&(x===null||k===null||x===k),_=f.editMode==="modal"&&o?.startsWith("__modal__/")?o.slice(o.lastIndexOf("/")+1):null,S=f.editMode==="modal"?f.selectedRoute?.layoutName??null:null,E=e.kind==="modal"&&!!_&&e.modalId===_&&(S===null||e.modalHostLayout==null||S===e.modalHostLayout),C=e.kind!=="extension"&&e.kind!=="modal"&&o===e.path||w||E||e.kind==="route"&&!y&&r===e.path,$=(e.children?.length??0)>0&&(!g||l||!v),A=!y&&!!e.layoutName&&s.has(e.layoutName),z=y?void 0:e.kind==="extension"?e.extensionId?f.extensionVersions[e.extensionId]:void 0:e.layoutName?f.layoutVersions[e.layoutName]:void 0,D=()=>{if(g){b(T=>!T);return}y||n(e)},O=np(e,{t:d,translationEngine:c,translationContext:p,templateIdentifier:f.templateIdentifier}),M=y?null:AE(e),B={paddingLeft:12+t*14,paddingRight:12,paddingTop:4,paddingBottom:4,opacity:e.isHidden?.55:1,cursor:g?"pointer":y?"default":"pointer",background:C?"#eff6ff":"transparent",borderLeft:C?"2px solid #2563eb":"2px solid transparent",fontWeight:y||C?600:400,fontSize:y?11:13,color:y?"#64748b":C?"#1d4ed8":"#1e293b",textTransform:y?"uppercase":"none",letterSpacing:y?.6:void 0,transition:"background-color 100ms, color 100ms",display:"flex",flexDirection:"column",alignItems:"stretch",gap:2,userSelect:"none"};return i.jsxs("li",{children:[i.jsxs("div",{"data-testid":y?"g7le-route-tree-group":"g7le-route-tree-item","data-route-path":e.path,"data-route-kind":e.kind,"data-conn-collapsed":g?l?"false":String(v):void 0,role:g||!y?"button":void 0,tabIndex:g||!y?0:void 0,"aria-expanded":g?l?!0:!v:void 0,onClick:D,onKeyDown:T=>{g&&(T.key==="Enter"||T.key===" ")?(T.preventDefault(),b(H=>!H)):!y&&(T.key==="Enter"||T.key===" ")&&(T.preventDefault(),n(e))},style:B,children:[i.jsxs("span",{style:{display:"flex",alignItems:"center",gap:6,minWidth:0},children:[g&&i.jsx("span",{"aria-hidden":"true",style:{flex:"0 0 auto",fontSize:9,color:"#94a3b8",width:10,display:"inline-block"},children:l||!v?"▾":"▸"}),A&&i.jsx("span",{className:"g7le-route-tree__dirty-dot","data-testid":"g7le-route-tree-dirty",title:d("layout_editor.chrome.route_tree.badge.modified"),"aria-label":d("layout_editor.chrome.route_tree.badge.modified"),style:{width:7,height:7,borderRadius:"50%",background:"#f59e0b",flex:"0 0 auto"}}),y?O:op(O,a),typeof z=="number"&&i.jsxs("span",{className:"g7le-route-tree__version-badge","data-testid":"g7le-route-tree-version",title:d("layout_editor.chrome.route_tree.badge.version_tooltip",{version:String(z)}),style:{flex:"0 0 auto",padding:"0 5px",fontSize:10,fontWeight:600,lineHeight:"16px",borderRadius:4,background:"#eef2ff",color:"#4f46e5",border:"1px solid #e0e7ff",letterSpacing:.2},children:["v",z]}),i.jsx(TE,{node:e})]}),M&&i.jsx("span",{className:"g7le-route-tree__layout-path","data-testid":"g7le-route-tree-layout-path",title:M,style:{fontSize:11,fontWeight:400,color:"#94a3b8",textTransform:"none",letterSpacing:0,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:op(M,a)})]}),$&&i.jsx("ul",{style:{listStyle:"none",margin:0,padding:0},children:e.children.map(T=>i.jsx(rp,{node:T,depth:t+1,onSelect:n,selectedRoutePath:o,activeHostRoutePath:r,dirtyLayoutNames:s,query:a,forceExpand:l},T.path))})]})}function DE(e,t){if(!t||!t.startsWith("__"))return null;const n=t.startsWith("__modal__/"),o=t.startsWith("__extension__/");if(!n&&!o)return null;const r=t.slice(t.lastIndexOf("/")+1),s=a=>{for(const l of a){if(l.connectedHostRoutePath&&(n&&l.kind==="modal"&&l.modalId===r||o&&l.kind==="extension"&&l.extensionId===r))return l.connectedHostRoutePath;if(l.children&&l.children.length>0){const d=s(l.children);if(d)return d}}return null};return s(e)}function ip(e,t){if(e.connectedHostRoutePath){const n=sp(t,e.connectedHostRoutePath);if(n?.layoutName)return n.layoutName}if(e.extensionType==="overlay"&&e.extensionTargetName)return e.extensionTargetName}function sp(e,t){for(const n of e){if(n.kind==="route"&&n.path===t)return n;if(n.children&&n.children.length>0){const o=sp(n.children,t);if(o)return o}}return null}function ap(e,t,n){const o=[];for(const r of e){const s=Js(r),a=r.children&&r.children.length>0?ap(r.children,t,n):[];if(s){a.length>0&&o.push({...r,children:a});continue}const l=np(r,n).toLocaleLowerCase(),d=r.path.toLocaleLowerCase();(l.includes(t)||!d.startsWith("__")&&d.includes(t)||a.length>0)&&o.push({...r,children:a})}return o}function NE(){const{t:e,translationEngine:t,translationContext:n}=tt(),{state:o,dispatch:r}=Je(),a=Ft()?.dirtyLayoutNames??EE,[l,d]=u.useState(""),c=IE(l),p=u.useMemo(()=>c?ap(o.routeTree,c,{t:e,translationEngine:t,translationContext:n,templateIdentifier:o.templateIdentifier}):o.routeTree,[c,o.routeTree,o.templateIdentifier,e,t,n]),f=h=>{if(h.kind==="base"&&h.layoutName){r({type:"ENTER_BASE_EDIT",layoutName:h.layoutName});return}if(h.kind==="modal"&&h.modalId&&h.modalHostLayout){r({type:"ENTER_MODAL_EDIT",modalId:h.modalId,hostLayout:h.modalHostLayout});return}if(h.kind==="extension"&&h.extensionId){const v=ip(h,o.routeTree);r({type:"ENTER_EXTENSION_EDIT",extensionId:h.extensionId,extensionHost:v});return}if(r({type:"SELECT_ROUTE",route:{path:h.path,layoutName:h.layoutName}}),typeof window<"u"&&window.history?.pushState){const v=Xs(o.templateIdentifier,h.path);`${window.location.pathname}${window.location.search}`!==v&&window.history.pushState({source:$E,routePath:h.path},"",v)}},y=o.selectedRoute?.path??null,g=u.useMemo(()=>DE(o.routeTree,y),[o.routeTree,y]);if(o.isRouteTreeCollapsed)return i.jsx("aside",{className:"g7le-route-tree g7le-route-tree--collapsed","data-testid":"g7le-route-tree-panel","data-collapsed":"true",style:{width:28,borderRight:"1px solid #e2e8f0",background:"#ffffff"}});const m=o.routeTree.length>0;return i.jsxs("aside",{className:"g7le-route-tree","data-testid":"g7le-route-tree-panel","data-collapsed":"false",style:{width:280,borderRight:"1px solid #e2e8f0",background:"#ffffff",position:"sticky",top:"var(--g7le-header-h, 0px)",alignSelf:"start",height:"calc(100vh - var(--g7le-header-h, 0px))",overflow:"hidden",minHeight:0,display:"flex",flexDirection:"column"},"aria-label":e("layout_editor.chrome.route_tree.panel_title"),children:[i.jsx("div",{className:"g7le-route-tree__title",style:{padding:"10px 14px",fontSize:12,fontWeight:600,color:"#475569",textTransform:"uppercase",letterSpacing:.6,borderBottom:"1px solid #e2e8f0",background:"#f8fafc",flex:"0 0 auto"},children:e("layout_editor.chrome.route_tree.panel_title")}),m&&i.jsxs("div",{className:"g7le-route-tree__search",style:{padding:"8px 10px",borderBottom:"1px solid #e2e8f0",background:"#ffffff",flex:"0 0 auto",position:"relative"},children:[i.jsx("input",{type:"text",className:"g7le-route-tree__search-input","data-testid":"g7le-route-tree-search",value:l,onChange:h=>d(h.target.value),placeholder:e("layout_editor.chrome.route_tree.search_placeholder"),"aria-label":e("layout_editor.chrome.route_tree.search_placeholder"),style:{width:"100%",boxSizing:"border-box",padding:l?"6px 26px 6px 10px":"6px 10px",fontSize:12,lineHeight:1.4,color:"#1e293b",border:"1px solid #cbd5e1",borderRadius:6,outline:"none",background:"#f8fafc"}}),l&&i.jsx("button",{type:"button",className:"g7le-route-tree__search-clear","data-testid":"g7le-route-tree-search-clear",onClick:()=>d(""),"aria-label":e("layout_editor.chrome.route_tree.search_clear"),title:e("layout_editor.chrome.route_tree.search_clear"),style:{position:"absolute",right:16,top:"50%",transform:"translateY(-50%)",width:18,height:18,display:"inline-flex",alignItems:"center",justifyContent:"center",border:"none",background:"transparent",color:"#94a3b8",cursor:"pointer",fontSize:14,lineHeight:1,padding:0},children:"✕"})]}),!m&&i.jsx("div",{className:"g7le-route-tree__empty","data-testid":"g7le-route-tree-empty",style:{padding:16,fontSize:12,color:"#94a3b8",textAlign:"center"},children:e("layout_editor.chrome.route_tree.empty")}),m&&c&&p.length===0&&i.jsx("div",{className:"g7le-route-tree__no-results","data-testid":"g7le-route-tree-no-results",style:{padding:16,fontSize:12,color:"#94a3b8",textAlign:"center"},children:e("layout_editor.chrome.route_tree.search_no_results")}),m&&p.length>0&&i.jsx("nav",{className:"g7le-route-tree__scroll","data-testid":"g7le-route-tree-scroll",style:{flex:"1 1 auto",minHeight:0,overflowY:"auto"},children:i.jsx("ul",{style:{listStyle:"none",margin:0,padding:0},children:p.map(h=>i.jsx(rp,{node:h,depth:0,onSelect:f,selectedRoutePath:y,activeHostRoutePath:g,dirtyLayoutNames:a,query:c,forceExpand:!!c},h.path))})})]})}const zE=Lt().DynamicRenderer,Zs=Lt(),ME=Zs.ResponsiveProvider;Zs.useResponsive,Zs.ResponsiveContext;const LE=Lt().ActionDispatcher,lp=Lt(),PE=lp.DataSourceManager;lp.dataSourceManager;function Ur(e,t,n){const o=t??{},r=o.message||o.error||`HTTP ${e}`,s=o?.data?.required_permissions??o?.required_permissions;let a;return e===401?a="unauthorized":e===403?a="forbidden":e===404?a="not_found":e>=500?a="server_error":a="unknown",{kind:a,status:e,message:String(r),requiredPermissions:s?String(s):void 0,source:n}}function oo(e,t){return{kind:"network",status:0,message:e instanceof Error?e.message:String(e??"Unknown network error"),source:t}}function dp(e){if(typeof e!="object"||e===null)return!1;const t=e;return typeof t.kind=="string"&&typeof t.status=="number"&&typeof t.message=="string"}function Ze(e){if(!e)return[];const t=[],n=e.split(".");for(let o=0;o0&&(t.push({responsive:a}),o+=1);continue}const s=Number(r);Number.isFinite(s)&&Number.isInteger(s)&&s>=0&&t.push(s)}return t}function OE(e,t){if(!e)return{affordance:"none",targetPath:null};const n=BE(e);return n?WE(n)?{affordance:"dynamic_path",targetPath:n}:/^(https?:)?\/\//.test(n)?{affordance:"external_url",targetPath:n}:n.startsWith("/")?t?{affordance:t(n),targetPath:n}:{affordance:"route_not_in_tree",targetPath:n}:{affordance:"route_not_in_tree",targetPath:n}:{affordance:"none",targetPath:null}}function BE(e){const t=e.actions;if(Array.isArray(t)){for(const n of t)if(n&&typeof n=="object"&&n.handler==="navigate"){const o=n.params?.path;if(typeof o=="string"&&o.length>0)return o}}if(e.name==="A"||e.type==="A"){const n=e.props?.href;if(typeof n=="string"&&n.length>0)return n}return null}function WE(e){return/\{\{[\s\S]*?\}\}/.test(e)}function jE(e,t,n){const o=s=>!!(s?.iteration&&s.iteration.source!==void 0),r=s=>{if(!s)return s;const a=s.indexOf(".iteration."),l=s.indexOf(".sortable."),d=[a,l].filter(c=>c>=0).sort((c,p)=>c-p)[0];return d===void 0?s:s.slice(0,d)};return o(e)?{isIteration:!0,iterationSourcePath:r(n)}:n&&n.includes(".iteration.")?{isIteration:!0,iterationSourcePath:r(n)}:t.some(o)?{isIteration:!0,iterationSourcePath:r(n)}:{isIteration:!1,iterationSourcePath:null}}function HE(e,t){const n=a=>{const l=a?.iteration&&a.iteration.source;return typeof l=="string"&&l.length>0?l:null},o=n(e)??t.map(n).find(a=>a!==null)??null;if(o!==null){const a=KE(o);if(a!==null)return a}const r=[];if(typeof e.text=="string"&&r.push(...cp(e.text)),e.props&&typeof e.props=="object")for(const a of Object.values(e.props))typeof a=="string"&&r.push(...cp(a));return r.length===0?null:r.find(a=>!FE.has(a))??r[0]}const FE=new Set(["_local","_global","_computed","route","query","props","item","index","event","$event","$response","response"]);function cp(e){const t=[],n=/\{\{([\s\S]*?)\}\}/g;let o;for(;(o=n.exec(e))!==null;){const r=up(o[1]);r!==null&&t.push(r)}return t}function KE(e){const t=e.replace(/\{\{|\}\}/g,"");return up(t)}function up(e){const t=/[A-Za-z_$][A-Za-z0-9_$]*/.exec(e.trim());return t?t[0]:null}function fn(e,t,n,o=[]){if(!e)return"none";if(Rl(e)||typeof e.slot=="string"||typeof e.__editorSlotName=="string")return"extension_point";if(t!=="route"&&Yi(e,t,n)){const s=e.__source?.kind;return s==="base"?"base":s==="partial"?"partial":s==="extension"?"extension":"base"}if(vb(e,o))return"data_bound";if(!Yi(e,t,n))return"none";const r=e.__source?.kind;return r==="base"?"base":r==="partial"?"partial":r==="extension"?"extension":"none"}function pp(e){if(!e)return null;if(e.__source?.kind==="extension"&&typeof e.__source.extensionId=="number")return e.__source.extensionId;const t=e.__injectedProps;if(Array.isArray(t)&&t.length>0){const n=t[0]?.extensionId;if(typeof n=="number")return n}return null}function VE(e,t){if(t<=0)return e;const n=e.split(".");let o=0;const r=[];for(let s=0;s=t)break;r.push(a);continue}if(a==="iteration"||a==="sortable"){if(o>=t)break;r.push(a);continue}r.push(a);const l=n[s-1];if((l===void 0||l==="children")&&(o+=1),o>=t)break}return r.join(".")}function fp(e,t,n,o){if(!e||!t)return t;const r=n(t),s=wb(e,r,o);return s.length===r.length?t:VE(t,s.length)}function gp(e){return e==="none"||e==="data_bound"}function UE(e){const{rootNode:t,editMode:n,currentExtensionId:o,editableRootPath:r,pathParser:s=Ze,resolveRouteMatch:a}=e,[l,d]=u.useState(null),[c,p]=u.useState(null),f=u.useCallback(x=>{if(x===null||n==="route")return!0;if(n==="iteration_item"||n==="modal"){if(!r||r.length===0)return!0;const S=s(x);return hp(S,r)}const k=Qs(t,x,s);if(!k)return!0;const w=Rs(t,x,s),_=fn(k,n,o,w);return _==="none"||_==="data_bound"},[t,s,n,o,r]),y=u.useCallback((x,k)=>{const _=(k?.currentTarget?.dataset??k?.dataset)?.editorPath??null,S=fp(t,_,s,o);f(S)&&d(S)},[t,s,o,f]),g=u.useCallback((x,k)=>{if(x===null){p(null);return}const _=(k?.currentTarget?.dataset??k?.dataset)?.editorPath??null,S=fp(t,_,s,o);if(!f(S)){p(null);return}p(S)},[t,s,o,f]),m=u.useCallback(()=>{d(null)},[]),h=u.useMemo(()=>{const x=Qs(t,l,s),k=Qs(t,c,s),w=x?Rs(t,l,s):[];let _=fn(x,n,o,w);(n==="iteration_item"||n==="modal")&&l!==null&&x&&r&&r.length>0&&hp(s(l),r)&&(ed(x)?_="data_bound":_==="data_bound"&&(_="none"));const{affordance:S,targetPath:E}=OE(x,a),C=_==="base"&&typeof x?.__source?.layout=="string"?x.__source.layout:null,N=_==="data_bound"&&x?HE(x,w):null,{isIteration:$,iterationSourcePath:A}=_==="data_bound"&&x?jE(x,w,l):{isIteration:!1,iterationSourcePath:null};return{selectedNode:x,hoverNode:k,selectedLockKind:_,selectedNavAffordance:S,selectedNavTargetPath:E,selectedBaseLayout:C,selectedDataSourceId:N,selectedIsIteration:$,selectedIterationSourcePath:A}},[t,l,c,n,o,r,s,a]),v=u.useRef(void 0);v.current!==l&&(v.current=l,kl({op:l===null?"clear":"select",componentPath:l,componentName:yp(h.selectedNode),lockKind:h.selectedLockKind,navAffordance:h.selectedNavAffordance,sourceExtensionId:pp(h.selectedNode),timestamp:Date.now()}));const b=u.useRef(void 0);return b.current!==c&&(b.current=c,c!==null&&kl({op:"hover",componentPath:c,componentName:yp(h.hoverNode),lockKind:fn(h.hoverNode,n,o,Rs(t,c,s)),navAffordance:"none",sourceExtensionId:pp(h.hoverNode),timestamp:Date.now()})),{selectedPath:l,hoverPath:c,...h,handleSelect:y,handleHover:g,clearSelection:m}}function Qs(e,t,n){if(!e||!t)return null;const o=n(t);return We(e,o)}function hp(e,t){if(e.length0?e.name:typeof e.type=="string"&&e.type.length>0?e.type:null:null}let bp=0;function mp(){return bp}function ea(){bp+=1}function qE(e){if(typeof e!="string")return null;const t="__modal__/";if(!e.startsWith(t))return null;const n=e.slice(t.length);return n===""||n.includes("/")?null:n}function GE(e){if(typeof e!="string")return null;const t="__iteration__/";if(!e.startsWith(t))return null;const n=e.slice(t.length);return n!==""?n:null}function vp(){const{state:e}=Je(),t=e.templateIdentifier,n=e.selectedRoute?.layoutName??null,o=e.editMode,r=o==="modal"?qE(e.selectedRoute?.path):null,s=o==="iteration_item"?GE(e.selectedRoute?.path):null,[a,l]=u.useState(null),[d,c]=u.useState(!1),[p,f]=u.useState(null),[y,g]=u.useState(!1),[m,h]=u.useState(0),[v,b]=u.useState(0),x=u.useRef(new Map),[k,w]=u.useState(()=>new Set),_=u.useCallback((L,I)=>`${L}|${I??""}`,[]),S=u.useRef(null);u.useEffect(()=>{S.current=a},[a]);const E=u.useCallback((L,I)=>{const P=_(o,n);x.current.set(P,{raw:L,lockVersion:I}),w(F=>{if(F.has(P))return F;const G=new Set(F);return G.add(P),G})},[_,o,n]),C=u.useCallback(L=>{x.current.delete(L),w(I=>{if(!I.has(L))return I;const P=new Set(I);return P.delete(L),P})},[]),N=u.useCallback(L=>{const I=`|${L??""}`,P=[];for(const F of x.current.keys())F.endsWith(I)&&P.push(F);for(const F of P)x.current.delete(F);P.length>0&&w(F=>{let G=!1;const V=new Set(F);for(const Y of P)V.delete(Y)&&(G=!0);return G?V:F})},[]),$=u.useCallback(async()=>{if(!n){l(null),f(null),g(!1);return}const L=_(o,n),I=x.current.get(L);if(I){l({layoutName:n,raw:I.raw,lockVersion:I.lockVersion}),g(!0),f(null),c(!1),st({op:"load",layoutName:n,editMode:o,statusCode:0,isDirty:!0,timestamp:Date.now()});return}c(!0),f(null);try{const P=window.G7Config?.cache_version??0,F=`/api/layouts/${encodeURIComponent(t)}/${n}.json?with_source_meta=1&v=${P}.${mp()}`,G=rr(),V={Accept:"application/json"};G&&(V.Authorization=`Bearer ${G}`);const Y=await fetch(F,{headers:V,credentials:"same-origin"}),Z=await Y.json().catch(()=>null);if(!Y.ok){f(Ur(Y.status,Z,"layout")),l(null),g(!1);return}const se=(Z&&(Z.data||Z))??{},re=typeof se.lock_version=="number"?se.lock_version:0;if(o==="modal"&&r){const ee=se,fe=Array.isArray(ee.modals)?ee.modals:[],K=fe.findIndex(ne=>ne&&(ne.id===r||ne.modal_id===r));if(K>=0){const ne=fe[K],me={...ne,props:{...ne.props??{},isOpen:!0}},ze=[...Array.isArray(ee.components)?ee.components:[],me],Se=[ze.length-1];l({layoutName:n,raw:{...ee,components:ze},lockVersion:re,modalContext:{modalId:r,modalIndex:K,hostRaw:ee,originalModalNode:ne,editIndexPath:Se}}),g(!1),st({op:"load",layoutName:n,editMode:o,statusCode:Y.status,timestamp:Date.now()});return}}if(o==="iteration_item"&&s){const ee=se,fe=Ze(s),K={children:ee.components??[]},ne=We(K,fe);if(ne&&Array.isArray(ne.children)){l({layoutName:n,raw:ee,lockVersion:re,iterationContext:{sourcePath:s,sourceIndexPath:fe,hostRaw:ee}}),g(!1),st({op:"load",layoutName:n,editMode:o,statusCode:Y.status,timestamp:Date.now()});return}}l({layoutName:n,raw:se,lockVersion:re}),g(!1),st({op:"load",layoutName:n,editMode:o,statusCode:Y.status,timestamp:Date.now()})}catch(P){f(oo(P,"layout")),l(null),g(!1)}finally{c(!1)}},[t,n,o,r,s,_]);u.useEffect(()=>{$()},[$]);const A=u.useCallback(async()=>{C(_(o,n)),ea(),b(L=>L+1),await $()},[C,_,o,n,$]),z=u.useCallback(()=>{C(_(o,n))},[C,_,o,n]),D=u.useCallback(L=>{l(I=>{if(!I)return I;const P=I.raw.components??[],F=L(P),G={...I.raw,components:F};return E(G,I.lockVersion),{...I,raw:G}}),g(!0),st({op:"patch",layoutName:n,editMode:o,isDirty:!0,timestamp:Date.now()})},[n,o,E]),O=u.useCallback(()=>{g(!0),st({op:"patch",layoutName:n,editMode:o,isDirty:!0,timestamp:Date.now()})},[n,o]),M=u.useCallback(L=>{l(I=>{if(!I)return I;const P={...I.raw,components:L};return E(P,I.lockVersion),{...I,raw:P}}),g(!0),st({op:"patch",layoutName:n,editMode:o,isDirty:!0,timestamp:Date.now()})},[n,o,E]),B=u.useCallback((L,I,...P)=>{const F=P.length>=1?P[0]:I;l(G=>{if(!G)return G;const V={...G.raw,[L]:I},Y=G.raw.__editor;if(Y&&typeof Y=="object"&&!Array.isArray(Y)){const Z=Y,se=Z.original;se&&typeof se=="object"&&!Array.isArray(se)&&(V.__editor={...Z,original:{...se,[L]:F}})}return E(V,G.lockVersion),{...G,raw:V}}),g(!0),st({op:"patch",layoutName:n,editMode:o,isDirty:!0,timestamp:Date.now()})},[n,o,E]),T=u.useCallback(async L=>{const I=S.current;if(!I)return st({op:"save_guard_result",layoutName:n,editMode:o,guardBlocked:!1,timestamp:Date.now()}),{kind:"guard_no_document"};if(L?.resolveActiveExtensions&&L.sessionAddedPaths?.length)try{const fe=await L.resolveActiveExtensions(),K=YE(I.raw.components,L.sessionAddedPaths,fe);if(K.length>0)return st({op:"save_guard_result",layoutName:n,editMode:o,guardBlocked:!0,guardBlockedPaths:K,timestamp:Date.now()}),{kind:"blocked_inactive_extension",blockedPaths:K}}catch{}if(zb())try{await Mb(t)}catch{}const P=`/api/admin/templates/${encodeURIComponent(t)}/layouts/${I.layoutName}`,F=rr(),G={Accept:"application/json","Content-Type":"application/json"};F&&(G.Authorization=`Bearer ${F}`);const V=!!I.modalContext,Y=!!I.iterationContext,Z=V||Y?"host_layout_modal_patch":"layout";st({op:"save",layoutName:I.layoutName,editMode:o,saveTarget:Z,endpoint:P,isDirty:y,timestamp:Date.now()});let se;if(V&&I.modalContext){const{modalIndex:fe,hostRaw:K,originalModalNode:ne,editIndexPath:me}=I.modalContext,Ce=Array.isArray(K.modals)?[...K.modals]:[],ze=Array.isArray(I.raw.components)?I.raw.components:[],Se=me&&me.length===1?me[0]:0,qe=ze.length>Se?ze[Se]:void 0,De=qe?Kn([qe]):[],Pe=De.length>0?{...De[0]}:void 0;if(fe>=0&&fe({...Me,children:ze})).children??[];se={...Se,components:Qe}}else se=yo(I.raw);const re=se;let U;try{U=await fetch(P,{method:"PUT",headers:G,credentials:"same-origin",body:JSON.stringify({content:JSON.stringify(re),expected_lock_version:I.lockVersion})})}catch(fe){const K=fe instanceof Error?fe.message:"network error";return st({op:"save_response",layoutName:I.layoutName,editMode:o,endpoint:P,statusCode:0,timestamp:Date.now()}),{kind:"network_error",message:K}}const ee=await U.json().catch(()=>null);if(st({op:"save_response",layoutName:I.layoutName,editMode:o,endpoint:P,statusCode:U.status,conflict:U.status===409?{currentVersion:ee?.current_version,yourVersion:ee?.your_version}:void 0,timestamp:Date.now()}),U.ok){const fe=typeof ee?.data?.lock_version=="number"?ee.data.lock_version:I.lockVersion+1;l({...I,lockVersion:fe}),g(!1),C(_(o,I.layoutName)),N(I.layoutName),ea(),h(ne=>ne+1);const K=typeof ee?.data?.current_version=="number"?ee.data.current_version:void 0;return{kind:"success",newLockVersion:fe,newContentVersion:K,savedLayoutName:I.layoutName}}return U.status===409?{kind:"concurrent_modification",currentVersion:ee?.current_version??-1,yourVersion:ee?.your_version??I.lockVersion}:U.status===422?{kind:"validation_failed",status:422,errors:ee?.errors??null}:{kind:"network_error",message:ee?.message??`HTTP ${U.status}`}},[t,n,o,y,C,N,_,r,s]),H=u.useMemo(()=>{const L=new Set,I="route|";for(const P of k)if(P.startsWith(I)){const F=P.slice(I.length);F&&L.add(F)}return L},[k]);return{document:a,isLoading:d,error:p,isDirty:y,saveSuccessCounter:m,reloadCounter:v,dirtyKeys:k,dirtyLayoutNames:H,invalidateCurrentCache:z,reload:A,patchLayout:D,setLayoutComponents:M,patchDocumentRaw:B,save:T,markDirty:O}}function YE(e,t,n){if(!e||e.length===0)return[];const o=[],r=new Set([...n.moduleIds,...n.pluginIds]);for(const s of t){const a=XE(e,s);if(!a)continue;const l=a.__source;if(!l||l.kind!=="extension")continue;const d=String(l.extensionId??"");d.length!==0&&(r.has(d)||o.push(s))}return o}function XE(e,t){const n=Ze(t);return n.length===0?null:We({children:e},n)}function ro(e,t){if(!e)return null;const n=e.getBoundingClientRect();if(n.width===0&&n.height===0)return null;if(!t)return{left:n.left,top:n.top,width:n.width,height:n.height,scale:1};const o=t.getBoundingClientRect();return{left:n.left-o.left,top:n.top-o.top,width:n.width,height:n.height,scale:1}}function io(e,t){if(!e)return!1;if(!t)return!0;const n=t.getBoundingClientRect(),o=1;return!(e.left>=n.width-o||e.left+e.width<=o||e.top>=n.height-o||e.top+e.height<=o)}function _p(e,t){if(typeof window>"u")return()=>{};const n=()=>{e()};window.addEventListener("scroll",n,!0),window.addEventListener("resize",n);let o=null;t&&typeof ResizeObserver<"u"&&(o=new ResizeObserver(()=>e()),o.observe(t));let r=null,s=null;const a=typeof requestAnimationFrame<"u",l=()=>{if(!a){e();return}s===null&&(s=requestAnimationFrame(()=>{s=null,e()}))};return t&&typeof MutationObserver<"u"&&(r=new MutationObserver(l),r.observe(t,{childList:!0,subtree:!0})),()=>{window.removeEventListener("scroll",n,!0),window.removeEventListener("resize",n),o&&(o.disconnect(),o=null),r&&(r.disconnect(),r=null),s!==null&&a&&typeof cancelAnimationFrame<"u"&&(cancelAnimationFrame(s),s=null)}}const xp=44;function wp(e){if(!e)return"inside";const t=e.scale||1,n=e.width*t,o=e.height*t;return n"u"||typeof window.getComputedStyle!="function")return{kind:"unknown",display:"",flexDirection:"",flexWrap:""};let t=e,n=(window.getComputedStyle(t).display??"").toLowerCase();for(;n==="contents"&&t.parentElement;)t=t.parentElement,n=(window.getComputedStyle(t).display??"").toLowerCase();const o=window.getComputedStyle(t),r=(o.display??"").toLowerCase(),s=(o.flexDirection??"").toLowerCase(),a=(o.flexWrap??"").toLowerCase(),l=r==="flex"||r==="inline-flex";if(r==="grid"||r==="inline-grid"){const f=Array.from(e.children).filter(h=>h.offsetParent!==null||h.getClientRects().length>0);if(f.length<=1)return{kind:"grid_2d",display:r,flexDirection:s,flexWrap:a};const y=f.map(h=>h.getBoundingClientRect()),g=new Set(y.map(h=>Math.round(h.top))),m=new Set(y.map(h=>Math.round(h.left)));return g.size===1?{kind:"grid_single_row",display:r,flexDirection:s,flexWrap:a}:m.size===1?{kind:"grid_single_column",display:r,flexDirection:s,flexWrap:a}:{kind:"grid_2d",display:r,flexDirection:s,flexWrap:a}}if(!l)return{kind:"block",display:r,flexDirection:s,flexWrap:a};const c=a==="wrap"||a==="wrap-reverse";return s==="column"||s==="column-reverse"?{kind:c?"flex_column_wrap":"flex_column_single",display:r,flexDirection:s,flexWrap:a}:{kind:c?"flex_row_wrap":"flex_row_single",display:r,flexDirection:s,flexWrap:a}}function ZE(e){const{parentEl:t,selectedPath:n,selectedEl:o,explicitFlow:r}=e;return u.useMemo(()=>{if(!n||n.length===0)return{points:[],flow:"unknown"};const s=r??Cp(t).kind,a=n.slice(0,-1),l=n[n.length-1]??0;if((s==="flex_row_wrap"||s==="flex_column_wrap"||s==="grid_2d")&&!!o){const p=QE(s,o,a,l);if(p)return{points:p,flow:s}}return{points:RE(s,a,l),flow:s}},[t,n,o,r])}function QE(e,t,n,o){let r=t.parentElement;for(;r&&$p(r)==="contents";)r=r.parentElement;if(!r)return null;const s=[];if(Ep(r,s),s.length===0)return null;const a=t.getBoundingClientRect(),l=a.top+a.height/2,d=[],c=[],p=[];for(const v of s){const b=v.rect,x=b.top+b.height/2,k=Math.min(a.bottom,b.bottom)-Math.max(a.top,b.top),w=Math.min(a.height,b.height)||1;k/w>.4?d.push({rect:b,path:v.path}):xv.rect.left-b.rect.left),c.sort((v,b)=>v.rect.top-b.rect.top),p.sort((v,b)=>v.rect.top-b.rect.top);const f=d.findIndex(v=>Math.abs(v.rect.left-a.left)<1&&Math.abs(v.rect.top-a.top)<1),y={direction:"above",disabled:!0,insertion:null},g={direction:"below",disabled:!0,insertion:null},m={direction:"left",disabled:!0,insertion:null},h={direction:"right",disabled:!0,insertion:null};if(f>0){const v=d[f-1],b=Gr(v.path);b&&(m.disabled=!1,m.insertion=b)}else if(d.length>0){const v=d[0],b=qr(v.path);b&&(m.disabled=!1,m.insertion=b)}if(f>=0&&f0){const v=d[d.length-1],b=Gr(v.path);b&&(h.disabled=!1,h.insertion=b)}if(c.length>0){const v=c[c.length-1],b=Gr(v.path);b&&(y.disabled=!1,y.insertion=b)}else if(s.length>0){const v=s[0],b=qr(v.path);b&&(y.disabled=!1,y.insertion=b)}if(p.length>0){const v=p[0],b=qr(v.path);b&&(g.disabled=!1,g.insertion=b)}else if(s.length>0){const v=s[s.length-1],b=Gr(v.path);b&&(g.disabled=!1,g.insertion=b)}else g.disabled=!1,g.insertion={parentPath:n,index:o+1};return y.disabled&&g.disabled&&m.disabled&&h.disabled?null:[y,g,m,h]}function $p(e){return typeof window>"u"?"":(window.getComputedStyle(e).display??"").toLowerCase()}function Ep(e,t){const n=Array.from(e.children);for(const o of n){if($p(o)==="contents"){Ep(o,t);continue}const s=o.getAttribute("data-editor-path");if(s&&/\.iteration\.\d+(?:$|\.)/.test(s)||!s)continue;const a=o.getBoundingClientRect();a.width===0&&a.height===0||t.push({el:o,rect:a,path:Ze(s)})}}function qr(e){if(!e||e.length===0)return null;const t=e[e.length-1];return typeof t!="number"?null:{parentPath:e.slice(0,-1),index:t}}function Gr(e){if(!e||e.length===0)return null;const t=e[e.length-1];return typeof t!="number"?null:{parentPath:e.slice(0,-1),index:t+1}}function RE(e,t,n){const o={direction:"above",disabled:!0,insertion:null},r={direction:"below",disabled:!0,insertion:null},s={direction:"left",disabled:!0,insertion:null},a={direction:"right",disabled:!0,insertion:null},l=d=>({parentPath:t,index:d});switch(e){case"block":case"unknown":{o.disabled=!1,o.insertion=l(n),r.disabled=!1,r.insertion=l(n+1);break}case"flex_row_single":case"grid_single_row":{s.disabled=!1,s.insertion=l(n),a.disabled=!1,a.insertion=l(n+1);break}case"flex_column_single":case"grid_single_column":{o.disabled=!1,o.insertion=l(n),r.disabled=!1,r.insertion=l(n+1);break}case"flex_row_wrap":case"grid_2d":{s.disabled=!1,s.insertion=l(n),a.disabled=!1,a.insertion=l(n+1),o.disabled=!1,o.insertion=l(0),r.disabled=!1,r.insertion=l(-1);break}case"flex_column_wrap":{o.disabled=!1,o.insertion=l(n),r.disabled=!1,r.insertion=l(n+1),s.disabled=!1,s.insertion=l(0),a.disabled=!1,a.insertion=l(-1);break}}return[o,r,s,a]}function e7(e=50){const t=u.useRef([]),n=u.useRef(-1),[,o]=u.useState(0),r=()=>{o(p=>(p+1)%1e6)},s=(p,f)=>{const y=t.current.length,g=n.current;Hh({op:p,actionKind:f?.actionKind,label:f?.label,stackSize:y,cursor:g,canUndo:g>0,canRedo:g>=0&&g{const y=t.current.slice(0,n.current+1);for(y.push(p);y.length>e;)y.shift();t.current=y,n.current=y.length-1,r(),s("push",p)},[e]),l=u.useCallback(()=>{if(n.current<=0)return null;n.current-=1,r();const p=t.current[n.current]??null;return s("undo",p??void 0),p},[]),d=u.useCallback(()=>{const p=n.current+1;if(p>=t.current.length)return null;n.current=p,r();const f=t.current[p]??null;return s("redo",f??void 0),f},[]),c=u.useCallback(()=>{t.current=[],n.current=-1,r(),s("clear")},[]);return{push:a,undo:l,redo:d,clear:c,canUndo:n.current>0,canRedo:n.current>=0&&n.current{if(!o||typeof window>"u")return;const r=s=>{if(t7(s.target)||!(s.ctrlKey||s.metaKey))return;const l=s.key.toLowerCase();if(l==="z"&&!s.shiftKey){s.preventDefault(),t();return}if(l==="z"&&s.shiftKey||l==="y"){s.preventDefault(),n();return}};return window.addEventListener("keydown",r),()=>{window.removeEventListener("keydown",r)}},[t,n,o])}const o7=20,Ip=50,r7=90,Mn=120,i7=130,Ap=80,s7=9e3,Tp=1e4;function a7(e){const t=e<0?0:e>Ip?Ip:e;return o7+t}function l7(e){const{anchor:t,open:n,onClose:o,onEditProps:r,onDuplicate:s,onDelete:a,onSeparateBranch:l,separateBranchLabel:d,onMergeBranch:c,mergeBranchLabel:p}=e,{t:f}=tt(),[y,g]=u.useState(null);if(!n||!t)return null;const m=[{index:0,testid:"g7le-context-menu-edit-props",label:f("layout_editor.context_menu.edit_props"),onSelect:r},{index:1,testid:"g7le-context-menu-duplicate",label:f("layout_editor.context_menu.duplicate"),onSelect:s}];return l?m.push({index:m.length,testid:"g7le-context-menu-separate-branch",label:d??f("layout_editor.context_menu.separate_branch"),onSelect:l,hint:f("layout_editor.context_menu.separate_branch_hint")}):c&&m.push({index:m.length,testid:"g7le-context-menu-merge-branch",label:p??f("layout_editor.context_menu.merge_branch"),onSelect:c,hint:f("layout_editor.context_menu.merge_branch_hint")}),m.push({index:m.length,testid:"g7le-context-menu-delete",label:f("layout_editor.context_menu.delete"),onSelect:a,danger:!0}),i.jsx("div",{className:"g7le-context-menu","data-testid":"g7le-context-menu",role:"menu",style:{position:"absolute",left:t.left,top:t.top,background:"#fff",border:"1px solid #cbd5e1",borderRadius:6,boxShadow:"0 4px 12px rgba(0,0,0,0.15)",zIndex:1200,minWidth:160,padding:4,pointerEvents:"auto"},onMouseLeave:()=>{g(null),o()},children:m.map(h=>{const v=y===h.index,b=h.danger?"#dc2626":"#0f172a",x=h.danger?"#fee2e2":"#e0e7ff",k=h.danger?"#b91c1c":"#1d4ed8";return i.jsx("button",{type:"button",role:"menuitem","data-testid":h.testid,title:h.hint,onClick:w=>{w.stopPropagation(),h.onSelect(),g(null),o()},onMouseEnter:()=>g(h.index),onFocus:()=>g(h.index),style:{...d7,color:v?k:b,background:v?x:"transparent"},children:h.label},h.testid)})})}const d7={display:"block",width:"100%",textAlign:"left",padding:"6px 12px",border:"none",fontSize:13,cursor:"pointer",borderRadius:4,pointerEvents:"auto",transition:"background-color 120ms ease, color 120ms ease"};function c7(e){const{hoverBox:t,selectedBox:n,lockKind:o,navAffordance:r,onEditProps:s,onDuplicate:a,onDelete:l,onSeparateBranch:d,separateBranchLabel:c,onMergeBranch:p,mergeBranchLabel:f,definedDeviceBranches:y,onJumpToDevice:g,onLinkEditDestination:m,onEditBase:h,onEditExtension:v,extensionLabel:b,baseLayoutLabel:x,dataSourceLabel:k,branchLabel:w,onEditIteration:_,resizeEnabledAxes:S,onResizeHandlePointerDown:E,selectedName:C,onSelectParent:N}=e,{t:$}=tt(),[A,z]=u.useState(!1),D=gp(o),O=A&&D,M=wp(n),B=M==="outside",T=B&&n?Sp(n):null,H=T?{left:T.right.left,top:T.above.top}:{right:-30,top:-30},L={top:-28,left:0},P=B&&!!C?{top:-50,left:0}:L,F=T?{top:T.below.top+28,left:0}:{top:"auto",bottom:-28},G=T?{top:T.below.top+56,left:0}:{top:"auto",bottom:-56};return i.jsxs("div",{className:"g7le-element-overlay","data-testid":"g7le-element-overlay",style:{position:"absolute",inset:0,pointerEvents:"none"},children:[t&&i.jsx("div",{"data-testid":"g7le-overlay-hover",style:{position:"absolute",left:t.left,top:t.top,width:t.width,height:t.height,border:"1px dashed #2563eb",pointerEvents:"none"}}),n&&i.jsxs("div",{"data-testid":"g7le-overlay-selected","data-lock-kind":o,style:{position:"absolute",left:n.left,top:n.top,width:n.width,height:n.height,border:o==="none"?"2px solid #2563eb":"2px dashed #f59e0b",pointerEvents:"none"},children:[w?i.jsxs("span",{"data-testid":"g7le-overlay-branch-badge",style:p7,children:["📱 ",w]}):null,C&&(N?i.jsxs("button",{type:"button","data-testid":"g7le-overlay-type-label","data-placement":B?"outside":"inside",title:$("layout_editor.overlay.select_parent"),"aria-label":$("layout_editor.overlay.select_parent"),onClick:V=>{V.preventDefault(),V.stopPropagation(),N()},style:B?g7:f7,children:[i.jsx("span",{"aria-hidden":!0,style:{marginRight:3,opacity:.85},children:"↑"}),C]}):i.jsx("span",{"data-testid":"g7le-overlay-type-label","data-placement":B?"outside":"inside",style:B?Np:Dp,children:C})),D&&i.jsx("button",{type:"button","data-testid":"g7le-overlay-info-button","aria-label":"info",onClick:V=>{V.stopPropagation(),z(Y=>!Y)},"data-placement":M,style:{position:"absolute",...H,width:20,height:20,borderRadius:10,border:"1px solid #2563eb",background:"#fff",color:"#2563eb",fontSize:12,fontWeight:700,cursor:"pointer",pointerEvents:"auto",padding:0,lineHeight:"18px",zIndex:Mn},children:"ⓘ"}),o==="none"&&i.jsx(u7,{enabledAxes:S,onHandlePointerDown:E}),o==="base"&&h&&i.jsxs("button",{type:"button","data-testid":"g7le-overlay-edit-base",onClick:V=>{V.stopPropagation(),h()},style:{...zo,...L},children:["🔒 ",$("layout_editor.overlay.edit_common_layout"),x?i.jsx("span",{"data-testid":"g7le-overlay-base-layout-label",style:na,children:x}):null]}),o==="extension"&&v&&i.jsxs("button",{type:"button","data-testid":"g7le-overlay-edit-extension",onClick:V=>{V.stopPropagation(),v()},style:{...zo,...L},children:["🔒 ",$("layout_editor.overlay.edit_extension"),b?i.jsx("span",{"data-testid":"g7le-overlay-extension-label",style:na,children:b}):null]}),o==="data_bound"&&i.jsxs("span",{"data-testid":"g7le-overlay-data-bound-notice",style:{...zo,...P,pointerEvents:"none",cursor:"default"},children:[$("layout_editor.overlay.locked_data_bound"),k?i.jsx("span",{"data-testid":"g7le-overlay-data-source-label",style:na,children:k}):null]}),o==="data_bound"&&_&&i.jsxs("button",{type:"button","data-testid":"g7le-overlay-edit-iteration",onClick:V=>{V.stopPropagation(),_()},style:{...zo,...F},children:["↳ ",$("layout_editor.overlay.edit_iteration_item")]}),r==="route_in_tree"&&i.jsx("button",{type:"button","data-testid":"g7le-overlay-link-edit-destination",onClick:V=>{V.stopPropagation(),m()},style:{...zo,...F},children:$("layout_editor.overlay.edit_destination_route")}),(d||p||g&&(y??[]).length>0)&&i.jsxs("div",{"data-testid":"g7le-overlay-branch-affordances",style:{position:"absolute",...G,display:"flex",flexDirection:"column",alignItems:"flex-start",gap:4,pointerEvents:"none"},children:[g&&(y??[]).map(V=>i.jsxs("button",{type:"button","data-testid":`g7le-overlay-jump-device-${V.key}`,title:$("layout_editor.overlay.jump_device_hint",{device:V.label}),onClick:Y=>{Y.stopPropagation(),g(V.key)},style:ta,children:["↗ ",$("layout_editor.overlay.jump_device",{device:V.label})]},V.key)),d&&i.jsxs("button",{type:"button","data-testid":"g7le-overlay-separate-branch",title:$("layout_editor.context_menu.separate_branch_hint"),onClick:V=>{V.stopPropagation(),d()},style:ta,children:["📱 ",c??$("layout_editor.context_menu.separate_branch")]}),p&&i.jsxs("button",{type:"button","data-testid":"g7le-overlay-merge-branch",title:$("layout_editor.context_menu.merge_branch_hint"),onClick:V=>{V.stopPropagation(),p()},style:ta,children:["↩️ ",f??$("layout_editor.context_menu.merge_branch")]})]})]}),i.jsx(l7,{anchor:n&&O?{left:n.left+n.width+8,top:n.top-4}:null,open:O,onClose:()=>z(!1),onEditProps:s,onDuplicate:a,onDelete:l,onSeparateBranch:d,separateBranchLabel:c,onMergeBranch:p,mergeBranchLabel:f})]})}function u7(e){const{enabledAxes:t,onHandlePointerDown:n}=e,o=t?.width??!1,r=t?.height??!1,s=[{key:"nw",style:{left:-4,top:-4},cursor:"nwse-resize",needsWidth:!0,needsHeight:!0},{key:"n",style:{left:"50%",top:-4,transform:"translateX(-50%)"},cursor:"ns-resize",needsWidth:!1,needsHeight:!0},{key:"ne",style:{right:-4,top:-4},cursor:"nesw-resize",needsWidth:!0,needsHeight:!0},{key:"e",style:{right:-4,top:"50%",transform:"translateY(-50%)"},cursor:"ew-resize",needsWidth:!0,needsHeight:!1},{key:"se",style:{right:-4,bottom:-4},cursor:"nwse-resize",needsWidth:!0,needsHeight:!0},{key:"s",style:{left:"50%",bottom:-4,transform:"translateX(-50%)"},cursor:"ns-resize",needsWidth:!1,needsHeight:!0},{key:"sw",style:{left:-4,bottom:-4},cursor:"nesw-resize",needsWidth:!0,needsHeight:!0},{key:"w",style:{left:-4,top:"50%",transform:"translateY(-50%)"},cursor:"ew-resize",needsWidth:!0,needsHeight:!1}];return i.jsx(i.Fragment,{children:s.map(a=>{const l=!a.needsWidth||o,d=!a.needsHeight||r,c=l&&d&&!!n;return!l||!d?null:i.jsx("span",{"data-testid":`g7le-resize-handle-${a.key}`,"data-resize-handle-active":c?"true":"false",onPointerDown:c?p=>{p.preventDefault(),p.stopPropagation(),n?.(a.key,{clientX:p.clientX,clientY:p.clientY})}:void 0,style:{position:"absolute",...a.style,width:8,height:8,background:"#fff",border:"1px solid #2563eb",cursor:a.cursor,pointerEvents:c?"auto":"none",zIndex:Mn}},a.key)})})}const zo={position:"absolute",top:-28,left:0,background:"#fff",border:"1px solid #2563eb",color:"#2563eb",padding:"2px 6px",fontSize:11,borderRadius:4,cursor:"pointer",pointerEvents:"auto",whiteSpace:"nowrap",zIndex:Mn},ta={background:"#fff",border:"1px solid #2563eb",color:"#2563eb",padding:"2px 6px",fontSize:11,borderRadius:4,cursor:"pointer",pointerEvents:"auto",whiteSpace:"nowrap",zIndex:Mn},na={marginLeft:6,padding:"0 5px",borderRadius:3,background:"#eef2ff",border:"1px solid #c7d2fe",fontSize:10,fontWeight:600,color:"#3730a3"},p7={position:"absolute",top:0,right:0,transform:"translateY(-100%)",background:"rgba(124,58,237,0.92)",color:"#fff",padding:"1px 6px",borderRadius:"3px 3px 0 0",fontSize:10,fontWeight:600,whiteSpace:"nowrap",pointerEvents:"none"},Dp={position:"absolute",top:0,left:0,background:"rgba(37,99,235,0.55)",color:"#fff",padding:"1px 6px",fontSize:10,fontWeight:600,lineHeight:"14px",borderBottomRightRadius:4,letterSpacing:"0.02em",whiteSpace:"nowrap",pointerEvents:"none",userSelect:"none",maxWidth:"100%",overflow:"hidden",textOverflow:"ellipsis"},Np={position:"absolute",bottom:"100%",left:0,marginBottom:2,background:"rgba(37,99,235,0.55)",color:"#fff",padding:"1px 6px",fontSize:10,fontWeight:600,lineHeight:"14px",borderRadius:4,letterSpacing:"0.02em",whiteSpace:"nowrap",pointerEvents:"none",userSelect:"none"},f7={...Dp,pointerEvents:"auto",cursor:"pointer",background:"rgba(37,99,235,0.82)",border:"none",display:"inline-flex",alignItems:"center",font:"inherit",fontSize:10,fontWeight:600,zIndex:Mn},g7={...Np,pointerEvents:"auto",cursor:"pointer",background:"rgba(37,99,235,0.82)",border:"none",display:"inline-flex",alignItems:"center",font:"inherit",fontSize:10,fontWeight:600,zIndex:Mn},h7={above:{top:-30,left:"50%",transform:"translateX(-50%)"},below:{bottom:-30,left:"50%",transform:"translateX(-50%)"},left:{left:-30,top:"50%",transform:"translateY(-50%)"},right:{right:-30,top:"50%",transform:"translateY(-50%)"}};function y7(e){const{selectedBox:t,points:n,onAddRequest:o}=e,{t:r}=tt();if(!t||n.length===0)return null;const s=wp(t),l=s==="outside"?Sp(t):null;return i.jsx("div",{className:"g7le-insertion-affordances","data-testid":"g7le-insertion-affordances",style:{position:"absolute",left:t.left,top:t.top,width:t.width,height:t.height,pointerEvents:"none"},children:n.map(d=>{const c=l?{left:l[d.direction].left,top:l[d.direction].top}:h7[d.direction],p=r(`layout_editor.insertion.${d.direction}`);return i.jsx("button",{type:"button",disabled:d.disabled,"data-testid":`g7le-insertion-${d.direction}`,"data-disabled":d.disabled?"true":"false","data-placement":s,"aria-label":p,title:p,onClick:f=>{f.stopPropagation(),d.disabled||o(d)},style:{position:"absolute",...c,width:24,height:24,borderRadius:12,border:"1px solid #2563eb",background:d.disabled?"#cbd5e1":"#fff",color:d.disabled?"#94a3b8":"#2563eb",cursor:d.disabled?"not-allowed":"pointer",opacity:d.disabled?.4:1,fontSize:16,fontWeight:700,lineHeight:"20px",padding:0,pointerEvents:"auto",boxShadow:"0 1px 3px rgba(0,0,0,0.15)",zIndex:Mn},children:"+"},d.direction)})})}const Yr=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function so(e){const t=Object.prototype.toString.call(e);return t==="[object Window]"||t==="[object global]"}function oa(e){return"nodeType"in e}function _t(e){var t,n;return e?so(e)?e:oa(e)&&(t=(n=e.ownerDocument)==null?void 0:n.defaultView)!=null?t:window:window}function ra(e){const{Document:t}=_t(e);return e instanceof t}function Mo(e){return so(e)?!1:e instanceof _t(e).HTMLElement}function zp(e){return e instanceof _t(e).SVGElement}function ao(e){return e?so(e)?e.document:oa(e)?ra(e)?e:Mo(e)||zp(e)?e.ownerDocument:document:document:document}const rn=Yr?u.useLayoutEffect:u.useEffect;function Xr(e){const t=u.useRef(e);return rn(()=>{t.current=e}),u.useCallback(function(){for(var n=arguments.length,o=new Array(n),r=0;r{e.current=setInterval(o,r)},[]),n=u.useCallback(()=>{e.current!==null&&(clearInterval(e.current),e.current=null)},[]);return[t,n]}function Lo(e,t){t===void 0&&(t=[e]);const n=u.useRef(e);return rn(()=>{n.current!==e&&(n.current=e)},t),n}function Po(e,t){const n=u.useRef();return u.useMemo(()=>{const o=e(n.current);return n.current=o,o},[...t])}function Jr(e){const t=Xr(e),n=u.useRef(null),o=u.useCallback(r=>{r!==n.current&&t?.(r,n.current),n.current=r},[]);return[n,o]}function Zr(e){const t=u.useRef();return u.useEffect(()=>{t.current=e},[e]),t.current}let ia={};function Qr(e,t){return u.useMemo(()=>{if(t)return t;const n=ia[e]==null?0:ia[e]+1;return ia[e]=n,e+"-"+n},[e,t])}function Mp(e){return function(t){for(var n=arguments.length,o=new Array(n>1?n-1:0),r=1;r{const l=Object.entries(a);for(const[d,c]of l){const p=s[d];p!=null&&(s[d]=p+e*c)}return s},{...t})}}const lo=Mp(1),Rr=Mp(-1);function m7(e){return"clientX"in e&&"clientY"in e}function sa(e){if(!e)return!1;const{KeyboardEvent:t}=_t(e.target);return t&&e instanceof t}function v7(e){if(!e)return!1;const{TouchEvent:t}=_t(e.target);return t&&e instanceof t}function ei(e){if(v7(e)){if(e.touches&&e.touches.length){const{clientX:t,clientY:n}=e.touches[0];return{x:t,y:n}}else if(e.changedTouches&&e.changedTouches.length){const{clientX:t,clientY:n}=e.changedTouches[0];return{x:t,y:n}}}return m7(e)?{x:e.clientX,y:e.clientY}:null}const Oo=Object.freeze({Translate:{toString(e){if(!e)return;const{x:t,y:n}=e;return"translate3d("+(t?Math.round(t):0)+"px, "+(n?Math.round(n):0)+"px, 0)"}},Scale:{toString(e){if(!e)return;const{scaleX:t,scaleY:n}=e;return"scaleX("+t+") scaleY("+n+")"}},Transform:{toString(e){if(e)return[Oo.Translate.toString(e),Oo.Scale.toString(e)].join(" ")}},Transition:{toString(e){let{property:t,duration:n,easing:o}=e;return t+" "+n+"ms "+o}}}),Lp="a,frame,iframe,input:not([type=hidden]):not(:disabled),select:not(:disabled),textarea:not(:disabled),button:not(:disabled),*[tabindex]";function _7(e){return e.matches(Lp)?e:e.querySelector(Lp)}const x7={display:"none"};function w7(e){let{id:t,value:n}=e;return u.createElement("div",{id:t,style:x7},n)}function k7(e){let{id:t,announcement:n,ariaLiveType:o="assertive"}=e;const r={position:"fixed",top:0,left:0,width:1,height:1,margin:-1,border:0,padding:0,overflow:"hidden",clip:"rect(0 0 0 0)",clipPath:"inset(100%)",whiteSpace:"nowrap"};return u.createElement("div",{id:t,style:r,role:"status","aria-live":o,"aria-atomic":!0},n)}function S7(){const[e,t]=u.useState("");return{announce:u.useCallback(o=>{o!=null&&t(o)},[]),announcement:e}}const Pp=u.createContext(null);function C7(e){const t=u.useContext(Pp);u.useEffect(()=>{if(!t)throw new Error("useDndMonitor must be used within a children of ");return t(e)},[e,t])}function $7(){const[e]=u.useState(()=>new Set),t=u.useCallback(o=>(e.add(o),()=>e.delete(o)),[e]);return[u.useCallback(o=>{let{type:r,event:s}=o;e.forEach(a=>{var l;return(l=a[r])==null?void 0:l.call(a,s)})},[e]),t]}const E7={draggable:` +`);return t.length>0&&t[t.length-1]===""&&t.pop(),t}const Fu=4e3;function m$(e,t,n=3){const o=Hu(e),r=Hu(t);let s=0;const a=Math.min(o.length,r.length);for(;sFu||p.length>Fu)return{hunks:[],addedCount:-1,removedCount:-1,identical:!1};const f=y$(c,p),y=b$(c,p,f),g=[];let m=s,h=s,v=0,b=0;for(let w=0;w{const s=e.slice(r.start,r.end+1);let a=0,l=0,d=0,c=0;for(const g of s)g.oldLine!==null&&(a===0&&(a=g.oldLine),d++),g.newLine!==null&&(l===0&&(l=g.newLine),c++);const p=a||(d===0?0:1),f=l||(c===0?0:1);return{header:`@@ -${p},${d} +${f},${c} @@`,oldStart:p,oldCount:d,newStart:f,newCount:c,lines:s}})}function Ku(e){return JSON.stringify(Ys(e),null,2)}function Ys(e){if(Array.isArray(e))return e.map(Ys);if(e&&typeof e=="object"){const t=e,n={};for(const o of Object.keys(t).sort())n[o]=Ys(t[o]);return n}return e}const _$={display:"flex",flexDirection:"column",height:"100%",minHeight:0},x$={display:"flex",alignItems:"center",gap:8,padding:"12px 16px",borderBottom:"1px solid #e2e8f0",background:"#f8fafc"},w$={display:"inline-flex",alignItems:"center",gap:4,padding:"4px 10px",borderRadius:6,border:"1px solid #cbd5e1",background:"#ffffff",color:"#0f172a",fontSize:12,fontWeight:600,cursor:"pointer",outline:"none"},k$={marginLeft:"auto",border:"none",background:"transparent",fontSize:16,color:"#64748b",cursor:"pointer",outline:"none"},S$={display:"flex",alignItems:"center",gap:12,padding:"8px 16px",borderBottom:"1px solid #e2e8f0",background:"#ffffff",fontSize:12,fontWeight:600},C$={flex:1,minHeight:0,overflow:"auto",background:"#ffffff",fontFamily:'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',fontSize:12,lineHeight:1.5},Vu={padding:"24px 12px",textAlign:"center",color:"#94a3b8",fontSize:13},$$={padding:"2px 12px",background:"#f1f5f9",color:"#64748b",borderTop:"1px solid #e2e8f0",borderBottom:"1px solid #e2e8f0",userSelect:"none"};function E$(e){return e==="add"?{row:{background:"#e6ffed"},gutter:{background:"#cdffd8",color:"#22863a"},sign:"+",signColor:"#22863a"}:e==="remove"?{row:{background:"#ffeef0"},gutter:{background:"#ffdce0",color:"#b31d28"},sign:"-",signColor:"#b31d28"}:{row:{background:"#ffffff"},gutter:{background:"#f8fafc",color:"#94a3b8"},sign:" ",signColor:"#94a3b8"}}const Uu={display:"inline-block",width:44,textAlign:"right",padding:"0 6px",color:"#94a3b8",userSelect:"none",borderRight:"1px solid #e2e8f0",whiteSpace:"nowrap"};function I$({oldVersion:e,newVersion:t,oldContent:n,newContent:o,t:r,onBack:s,onClose:a}){const l=u.useMemo(()=>{const c=Ku(n),p=Ku(o);return m$(c,p)},[n,o]),d=l.addedCount<0;return i.jsxs("div",{className:"g7le-version-diff","data-testid":"g7le-version-diff",style:_$,children:[i.jsxs("div",{"data-modal-drag-handle":!0,style:x$,children:[i.jsxs("button",{type:"button",onClick:s,style:w$,"data-testid":"g7le-version-diff-back",children:["← ",r("layout_editor.version_history.diff_back")]}),i.jsx("span",{style:{fontSize:14,fontWeight:700},"data-testid":"g7le-version-diff-title",children:r("layout_editor.version_history.diff_title",{old:e,new:t})}),i.jsx("button",{type:"button","aria-label":r("layout_editor.version_history.close"),onClick:a,style:k$,"data-testid":"g7le-version-diff-close",children:"✕"})]}),!d&&i.jsxs("div",{style:S$,"data-testid":"g7le-version-diff-summary",children:[i.jsx("span",{style:{color:"#16a34a"},"data-testid":"g7le-version-diff-added",children:r("layout_editor.version_history.added",{count:Math.max(0,l.addedCount)})}),i.jsx("span",{style:{color:"#dc2626"},"data-testid":"g7le-version-diff-removed",children:r("layout_editor.version_history.removed",{count:Math.max(0,l.removedCount)})})]}),i.jsx("div",{style:C$,children:d?i.jsx("div",{style:Vu,"data-testid":"g7le-version-diff-too-large",children:r("layout_editor.version_history.diff_too_large")}):l.identical?i.jsx("div",{style:Vu,"data-testid":"g7le-version-diff-identical",children:r("layout_editor.version_history.diff_identical")}):l.hunks.map((c,p)=>i.jsxs("div",{"data-testid":`g7le-version-diff-hunk-${p}`,children:[i.jsx("div",{style:$$,"data-testid":`g7le-version-diff-hunk-header-${p}`,children:c.header}),c.lines.map((f,y)=>{const g=E$(f.kind);return i.jsxs("div",{style:{display:"flex",alignItems:"stretch",...g.row},"data-kind":f.kind,"data-testid":`g7le-version-diff-line-${p}-${y}`,children:[i.jsx("span",{style:{...Uu,...g.gutter},"aria-hidden":"true",children:f.oldLine??""}),i.jsx("span",{style:{...Uu,...g.gutter},"aria-hidden":"true",children:f.newLine??""}),i.jsx("span",{style:{display:"inline-block",width:16,textAlign:"center",color:g.signColor,userSelect:"none",fontWeight:700},"aria-hidden":"true",children:g.sign}),i.jsx("span",{style:{whiteSpace:"pre-wrap",wordBreak:"break-word",flex:1,padding:"0 4px"},children:f.content})]},y)})]},p))})]})}const A$={display:"flex",flexDirection:"column",height:"100%",minHeight:0},T$={display:"flex",alignItems:"center",gap:8,padding:"12px 16px",borderBottom:"1px solid #e2e8f0",background:"#f8fafc"},D$={marginLeft:"auto",border:"none",background:"transparent",fontSize:16,color:"#64748b",cursor:"pointer",outline:"none"},N$={flex:1,minHeight:0,overflowY:"auto",padding:12},qu={padding:"24px 12px",textAlign:"center",color:"#94a3b8",fontSize:13},z$={margin:"8px 16px 0",padding:"8px 12px",borderRadius:6,background:"#fef2f2",color:"#b91c1c",fontSize:12,border:"1px solid #fecaca"},M$={display:"flex",flexDirection:"column",gap:8},L$={display:"flex",alignItems:"center",gap:12,padding:"10px 12px",borderRadius:8,border:"1px solid #e2e8f0",background:"#ffffff"},P$={display:"inline-flex",alignItems:"center",justifyContent:"center",minWidth:44,padding:"2px 8px",borderRadius:999,background:"#eff6ff",color:"#1d4ed8",fontSize:12,fontWeight:700},O$={display:"inline-flex",alignItems:"center",padding:"1px 8px",borderRadius:999,background:"#0f172a",color:"#ffffff",fontSize:10,fontWeight:700,letterSpacing:.3},B$={display:"flex",alignItems:"center",gap:10,fontSize:11,fontWeight:600},W$={display:"flex",flexDirection:"column",gap:2,minWidth:0,flex:1},Gu={padding:"6px 12px",borderRadius:6,border:"1px solid #cbd5e1",background:"#ffffff",color:"#0f172a",fontSize:12,fontWeight:600,cursor:"pointer",outline:"none",whiteSpace:"nowrap"};function Yu(e){return e.full_content&&typeof e.full_content=="object"?e.full_content:{endpoint:e.endpoint,components:e.components,data_sources:e.data_sources,metadata:e.metadata}}function j$(e){if(!e)return"";const t=Date.parse(e);if(Number.isNaN(t))return e;try{return new Date(t).toLocaleString()}catch{return e}}function H$({templateIdentifier:e,target:t,t:n,onRestored:o,onClose:r}){const{versions:s,isLoading:a,error:l,restoringId:d,loadVersions:c,restore:p,loadVersionDetail:f}=h$(e,t,o),[y,g]=u.useState(null),[m,h]=u.useState(null),[v,b]=u.useState(null);u.useEffect(()=>{c()},[c]);const x=u.useCallback(async _=>{g(null),b(_.version);try{const S=await f(_.version);if(S.kind!=="success"){g(S.kind==="not_found"?n("layout_editor.version_history.restore_not_found"):S.message);return}const E=_.version-1,C=s.some($=>$.version===E);let N={};if(C){const $=await f(E);if($.kind==="success")N=Yu($.detail);else if($.kind==="network_error"){g($.message);return}}h({oldVersion:C?E:0,newVersion:_.version,oldContent:N,newContent:Yu(S.detail)})}finally{b(null)}},[f,s,n]),k=u.useCallback(async _=>{g(null);const S=n("layout_editor.version_history.restore_confirm");if(!(typeof window<"u"?window.confirm(S):!0))return;const C=await p(_);C.kind==="success"?r():C.kind==="not_found"?g(n("layout_editor.version_history.restore_not_found")):g(C.message)},[p,r,n]),w=y??l;return m?i.jsx(I$,{oldVersion:m.oldVersion,newVersion:m.newVersion,oldContent:m.oldContent,newContent:m.newContent,t:n,onBack:()=>h(null),onClose:r}):i.jsxs("div",{className:"g7le-version-history","data-testid":"g7le-version-history",style:A$,children:[i.jsxs("div",{"data-modal-drag-handle":!0,style:T$,children:[i.jsxs("span",{style:{fontSize:14,fontWeight:700},"data-testid":"g7le-version-history-title",children:["🕘 ",n("layout_editor.version_history.title")]}),i.jsx("button",{type:"button","aria-label":n("layout_editor.version_history.close"),onClick:r,style:D$,"data-testid":"g7le-version-history-close",children:"✕"})]}),w&&i.jsx("div",{style:z$,"data-testid":"g7le-version-history-error",children:w}),i.jsx("div",{style:N$,children:a?i.jsx("div",{style:qu,"data-testid":"g7le-version-history-loading",children:n("layout_editor.version_history.loading")}):s.length===0?i.jsx("div",{style:qu,"data-testid":"g7le-version-history-empty",children:n("layout_editor.version_history.empty")}):i.jsx("div",{"data-testid":"g7le-version-history-list",style:M$,children:s.map((_,S)=>{const E=_.changes_summary,C=E?.added_count??0,N=E?.removed_count??0,$=E?.char_diff??0,A=j$(_.created_at),z=d===_.id,D=S===0;return i.jsxs("div",{style:L$,"data-testid":`g7le-version-row-${_.id}`,children:[i.jsxs("span",{style:P$,"data-testid":`g7le-version-badge-${_.id}`,children:["v",_.version]}),i.jsxs("div",{style:W$,children:[i.jsxs("span",{style:{display:"flex",alignItems:"center",gap:8},children:[A&&i.jsx("span",{style:{fontSize:12,color:"#475569",fontWeight:600},children:A}),D&&i.jsx("span",{style:O$,"data-testid":`g7le-version-latest-${_.id}`,children:n("layout_editor.version_history.latest")})]}),i.jsxs("span",{style:B$,"data-testid":`g7le-version-summary-${_.id}`,children:[i.jsx("span",{style:{color:"#16a34a"},"data-testid":`g7le-version-added-${_.id}`,children:n("layout_editor.version_history.added",{count:C})}),i.jsx("span",{style:{color:"#dc2626"},"data-testid":`g7le-version-removed-${_.id}`,children:n("layout_editor.version_history.removed",{count:N})}),i.jsx("span",{style:{color:"#94a3b8",fontWeight:500},"data-testid":`g7le-version-chars-${_.id}`,children:n("layout_editor.version_history.chars",{diff:`${$>=0?"+":""}${$}`})})]}),i.jsx("span",{style:{fontSize:11,color:"#94a3b8"},"data-testid":`g7le-version-author-${_.id}`,children:n("layout_editor.version_history.saved_by",{name:_.created_by_name??n("layout_editor.version_history.unknown_author")})})]}),i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6},children:[i.jsx("button",{type:"button",onClick:()=>{x(_)},disabled:v!==null,style:{...Gu,background:"#f8fafc",opacity:v!==null?.6:1},"data-testid":`g7le-version-compare-${_.id}`,children:v===_.version?n("layout_editor.version_history.comparing"):n("layout_editor.version_history.compare")}),!D&&i.jsx("button",{type:"button",onClick:()=>{k(_.id)},disabled:z||d!==null,style:{...Gu,opacity:z||d!==null?.6:1},"data-testid":`g7le-version-restore-${_.id}`,children:n(z?"layout_editor.version_history.restoring":"layout_editor.version_history.restore")})]})]},_.id)})})})]})}const Xu=[{id:"copy",group:"clipboard",combos:[{key:"c",mod:!0}],labelKey:"layout_editor.shortcuts.action.copy",requiresSelection:!0},{id:"cut",group:"clipboard",combos:[{key:"x",mod:!0}],labelKey:"layout_editor.shortcuts.action.cut",requiresSelection:!0},{id:"paste",group:"clipboard",combos:[{key:"v",mod:!0}],labelKey:"layout_editor.shortcuts.action.paste"},{id:"openProps",group:"element",combos:[{key:"enter"}],labelKey:"layout_editor.shortcuts.action.open_props",requiresSelection:!0},{id:"delete",group:"element",combos:[{key:"delete"},{key:"backspace"}],labelKey:"layout_editor.shortcuts.action.delete",requiresSelection:!0},{id:"selectParent",group:"element",combos:[{key:"arrowup"}],labelKey:"layout_editor.shortcuts.action.select_parent",requiresSelection:!0},{id:"deselect",group:"element",combos:[{key:"escape"}],labelKey:"layout_editor.shortcuts.action.deselect"},{id:"addElement",group:"element",combos:[{key:"/",mod:!0}],labelKey:"layout_editor.shortcuts.action.add_element"},{id:"undo",group:"history",combos:[{key:"z",mod:!0}],labelKey:"layout_editor.shortcuts.action.undo"},{id:"redo",group:"history",combos:[{key:"z",mod:!0,shift:!0},{key:"y",mod:!0}],labelKey:"layout_editor.shortcuts.action.redo"},{id:"editCode",group:"view",combos:[{key:"e",mod:!0}],labelKey:"layout_editor.shortcuts.action.edit_code"},{id:"preview",group:"view",combos:[{key:"p",mod:!0}],labelKey:"layout_editor.shortcuts.action.preview"},{id:"translations",group:"view",combos:[{key:"l",mod:!0}],labelKey:"layout_editor.shortcuts.action.translations"},{id:"help",group:"view",combos:[{key:"?"}],labelKey:"layout_editor.shortcuts.action.help"},{id:"reset",group:"document",combos:[{key:"r",mod:!0,shift:!0}],labelKey:"layout_editor.shortcuts.action.reset"},{id:"save",group:"document",combos:[{key:"s",mod:!0}],labelKey:"layout_editor.shortcuts.action.save"},{id:"exit",group:"document",combos:[{key:"escape"}],labelKey:"layout_editor.shortcuts.action.exit"}];function F$(e){return{key:(e.key||"").toLowerCase(),mod:e.ctrlKey||e.metaKey,shift:e.shiftKey,alt:e.altKey}}function K$(e,t){return e.key===t.key&&!!e.mod==!!t.mod&&!!e.shift==!!t.shift&&!!e.alt==!!t.alt}function V$(e){for(const t of Xu)if(t.combos.some(n=>K$(n,e)))return t;return null}function U$(e,t){const n=[];return e.mod&&n.push(t?"⌘":"Ctrl"),e.shift&&n.push(t?"⇧":"Shift"),e.alt&&n.push(t?"⌥":"Alt"),n.push(q$(e.key)),n.join(" + ")}function q$(e){return{arrowup:"↑",arrowdown:"↓",arrowleft:"←",arrowright:"→",enter:"Enter",escape:"Esc",delete:"Delete",backspace:"Backspace"," ":"Space"}[e]??(e.length===1?e.toUpperCase():e)}const G$=["clipboard","element","history","view","document"],Y$={clipboard:"layout_editor.shortcuts.group.clipboard",element:"layout_editor.shortcuts.group.element",history:"layout_editor.shortcuts.group.history",view:"layout_editor.shortcuts.group.view",document:"layout_editor.shortcuts.group.document"};function X$(){return typeof navigator>"u"?!1:/mac|iphone|ipad|ipod/i.test(navigator.platform||navigator.userAgent||"")}function J$({t:e,onClose:t,isMac:n}){const o=n??X$(),r=u.useMemo(()=>{const s=new Map;for(const a of Xu){const l=s.get(a.group)??[];l.push(a),s.set(a.group,l)}return s},[]);return i.jsxs("div",{className:"g7le-shortcut-help","data-testid":"g7le-shortcut-help",style:Z$,children:[i.jsxs("div",{style:Q$,children:[i.jsx("span",{style:R$,children:e("layout_editor.shortcuts.title")}),i.jsx("button",{type:"button","data-testid":"g7le-shortcut-help-close",onClick:t,"aria-label":e("layout_editor.shortcuts.close"),style:eE,children:"✕"})]}),i.jsx("div",{style:tE,children:G$.map(s=>{const a=r.get(s);return!a||a.length===0?null:i.jsxs("div",{style:nE,"data-testid":`g7le-shortcut-group-${s}`,children:[i.jsx("div",{style:oE,children:e(Y$[s])}),i.jsx("table",{style:rE,children:i.jsx("tbody",{children:a.map(l=>i.jsxs("tr",{"data-testid":`g7le-shortcut-row-${l.id}`,children:[i.jsx("td",{style:iE,children:e(l.labelKey)}),i.jsx("td",{style:sE,children:l.combos.map((d,c)=>i.jsxs(u.Fragment,{children:[c>0&&i.jsx("span",{style:lE,children:e("layout_editor.shortcuts.or")}),i.jsx("kbd",{style:aE,children:U$(d,o)})]},c))})]},l.id))})})]},s)})}),i.jsx("div",{style:dE,children:e("layout_editor.shortcuts.footnote")})]})}const Z$={display:"flex",flexDirection:"column",padding:20,gap:16,width:"100%",boxSizing:"border-box"},Q$={display:"flex",alignItems:"center",justifyContent:"space-between",paddingBottom:12,borderBottom:"1px solid #e2e8f0"},R$={fontSize:15,fontWeight:700,color:"#0f172a"},eE={width:28,height:28,border:"none",background:"transparent",color:"#64748b",cursor:"pointer",fontSize:14,borderRadius:6},tE={display:"grid",gridTemplateColumns:"1fr 1fr",columnGap:28,rowGap:18,alignItems:"start"},nE={display:"flex",flexDirection:"column",gap:6},oE={fontSize:12,fontWeight:700,color:"#2563eb"},rE={width:"100%",borderCollapse:"collapse"},iE={padding:"4px 0",fontSize:12.5,color:"#334155",textAlign:"left",whiteSpace:"nowrap",verticalAlign:"middle"},sE={padding:"4px 0",paddingLeft:12,textAlign:"right",verticalAlign:"middle"},aE={display:"inline-block",padding:"1px 6px",fontSize:11,fontFamily:"ui-monospace, monospace",color:"#0f172a",background:"#f1f5f9",border:"1px solid #cbd5e1",borderBottomWidth:2,borderRadius:5,whiteSpace:"nowrap"},lE={fontSize:11,color:"#94a3b8",margin:"0 4px"},dE={fontSize:11,color:"#94a3b8",paddingTop:12,borderTop:"1px solid #f1f5f9"};function cE(e){if(!e||!(e instanceof HTMLElement))return!1;const t=e.tagName;return!!(t==="INPUT"||t==="TEXTAREA"||t==="SELECT"||e.isContentEditable)}function uE(){return typeof document>"u"?!1:!!document.querySelector(".g7le-modal-backdrop, .g7le-modal")}function Ju(e){const{handlers:t,hasSelection:n,enabled:o=!0}=e;u.useEffect(()=>{if(!o||typeof window>"u")return;const r=s=>{if(cE(s.target))return;const a=F$(s),l=V$(a);if(!l||uE()||l.requiresSelection&&!n)return;if(l.key==="escape"||a.key==="escape"){if(n){const c=t.deselect;c&&(s.preventDefault(),c())}else{const c=t.exit;c&&(s.preventDefault(),c())}return}const d=t[l.id];d&&(s.preventDefault(),d())};return window.addEventListener("keydown",r),()=>window.removeEventListener("keydown",r)},[t,n,o])}function pE(e){const{result:t,onDismiss:n,onLoadLatest:o,onKeepMyChanges:r}=e,{t:s}=tt();if(u.useEffect(()=>{if(!t||t.kind==="concurrent_modification"||t.kind==="validation_failed")return;const a=setTimeout(n,5e3);return()=>clearTimeout(a)},[t,n]),!t)return null;if(t.kind==="success")return i.jsx(No,{tone:"success",testId:"g7le-save-banner-success",onDismiss:n,children:s("layout_editor.save.success")});if(t.kind==="validation_failed"){const a=t.errors??{},l=Object.entries(a);return i.jsxs(No,{tone:"error",testId:"g7le-save-banner-validation",onDismiss:n,children:[i.jsx("strong",{children:s("layout_editor.save.validation_failed")}),l.length>0&&i.jsx("ul",{style:Zu,"data-testid":"g7le-save-banner-validation-errors",children:l.map(([d,c])=>i.jsxs("li",{children:[i.jsx("code",{style:Qu,children:d}),": ",c.join(", ")]},d))})]})}return t.kind==="blocked_inactive_extension"?i.jsxs(No,{tone:"warning",testId:"g7le-save-banner-blocked",onDismiss:n,children:[i.jsx("strong",{children:s("layout_editor.save.blocked_inactive_extension")}),t.blockedPaths.length>0&&i.jsx("ul",{style:Zu,"data-testid":"g7le-save-banner-blocked-paths",children:t.blockedPaths.map(a=>i.jsx("li",{children:i.jsx("code",{style:Qu,children:a})},a))})]}):t.kind==="network_error"?i.jsxs(No,{tone:"error",testId:"g7le-save-banner-network",onDismiss:n,children:[i.jsx("strong",{children:s("layout_editor.save.network_error_title")}),i.jsx("div",{style:{marginTop:4,fontSize:12},children:t.message})]}):t.kind==="guard_no_document"?i.jsx(No,{tone:"neutral",testId:"g7le-save-banner-guard-no-document",onDismiss:n,children:s("layout_editor.save.guard_no_document")}):i.jsx("div",{role:"dialog","aria-modal":"true","aria-label":s("layout_editor.save.concurrent.title"),"data-testid":"g7le-save-banner-concurrent",style:yE,children:i.jsxs("div",{style:bE,children:[i.jsx("h2",{style:mE,children:s("layout_editor.save.concurrent.title")}),i.jsx("p",{style:vE,children:s("layout_editor.save.concurrent.message")}),i.jsx("div",{style:_E,children:s("layout_editor.save.concurrent.version_info",{current:String(t.currentVersion),yours:String(t.yourVersion)})}),i.jsxs("div",{style:xE,children:[i.jsx("button",{type:"button","data-testid":"g7le-save-banner-concurrent-load-latest",onClick:()=>{o?.(),n()},style:wE,children:s("layout_editor.save.concurrent.load_latest")}),i.jsx("button",{type:"button","data-testid":"g7le-save-banner-concurrent-keep-mine",onClick:()=>{r?.(),n()},style:kE,children:s("layout_editor.save.concurrent.view_my_changes")}),i.jsx("button",{type:"button","data-testid":"g7le-save-banner-concurrent-cancel",onClick:n,style:SE,children:s("layout_editor.save.concurrent.cancel")})]})]})})}function No(e){const{tone:t,testId:n,onDismiss:o,children:r}=e,s=fE[t];return i.jsxs("div",{role:"status","data-testid":n,style:{...gE,background:s.bg,borderColor:s.border,color:s.fg},children:[i.jsx("div",{style:{flex:1,minWidth:0},children:r}),i.jsx("button",{type:"button",onClick:o,"aria-label":"close","data-testid":`${n}-close`,style:hE,children:"×"})]})}const fE={success:{bg:"#dcfce7",border:"#86efac",fg:"#14532d"},error:{bg:"#fee2e2",border:"#fca5a5",fg:"#7f1d1d"},warning:{bg:"#fef3c7",border:"#fcd34d",fg:"#78350f"},neutral:{bg:"#f1f5f9",border:"#cbd5e1",fg:"#334155"}},gE={display:"flex",alignItems:"flex-start",gap:12,padding:"12px 16px",border:"1px solid",borderRadius:0,fontSize:13,lineHeight:1.5},hE={background:"transparent",border:"none",fontSize:18,cursor:"pointer",lineHeight:1,color:"inherit",padding:"0 4px",flexShrink:0},Zu={margin:"8px 0 0",paddingLeft:20,fontSize:12},Qu={background:"rgba(0, 0, 0, 0.06)",padding:"1px 4px",borderRadius:3,fontFamily:"monospace"},yE={position:"fixed",inset:0,background:"rgba(15, 23, 42, 0.6)",display:"flex",alignItems:"center",justifyContent:"center",zIndex:1e4},bE={background:"#fff",borderRadius:8,padding:24,width:"min(480px, 90vw)",boxShadow:"0 10px 25px rgba(0,0,0,0.15)"},mE={margin:"0 0 8px",fontSize:16,fontWeight:600,color:"#0f172a"},vE={margin:"0 0 12px",color:"#334155",fontSize:13,lineHeight:1.5},_E={marginBottom:16,padding:"8px 12px",background:"#f8fafc",borderRadius:6,fontSize:12,color:"#64748b",fontFamily:"monospace"},xE={display:"flex",gap:8,justifyContent:"flex-end"},wE={padding:"8px 14px",background:"#2563eb",color:"#fff",border:"none",borderRadius:6,fontSize:13,fontWeight:600,cursor:"pointer"},kE={padding:"8px 14px",background:"#fff",color:"#334155",border:"1px solid #cbd5e1",borderRadius:6,fontSize:13,fontWeight:500,cursor:"pointer"},SE={padding:"8px 14px",background:"transparent",color:"#64748b",border:"none",borderRadius:6,fontSize:13,cursor:"pointer"};function Ru(e){if(!e)return null;const n=new URLSearchParams(e).get("route");return n&&n.length>0?n:null}function Xs(e,t,n){const o=`/admin/layout-editor/${encodeURIComponent(e)}`;if(!t||t.length===0)return o;if(t.startsWith("__")){let r=`${o}?edit=${encodeURIComponent(t)}`;return(t.startsWith("__modal__/")||t.startsWith("__iteration__/"))&&n&&(r+=`&host=${encodeURIComponent(n)}`),r}return`${o}?route=${encodeURIComponent(t)}`}function ep(e){if(!e)return null;const n=new URLSearchParams(e).get("host");return n&&n.length>0?n:null}function tp(e){if(!e)return null;const n=new URLSearchParams(e).get("edit");return n&&n.length>0&&n.startsWith("__")?n:null}function CE(e,t){const n=`/admin/templates/${encodeURIComponent(e)}/edit`;if(!t||t.length===0||t.startsWith("__"))return n;const o=t.replace(/^\*+/,"");return o.length===0?n:`${n}?route=${encodeURIComponent(o)}`}const $E="g7le-editor-route",EE=new Set;function Js(e){return e.path.startsWith("__group__/")||e.path.startsWith("__conngroup__/")}function np(e,t){const{t:n,translationEngine:o,translationContext:r,templateIdentifier:s}=t,a=Js(e);if(!e.label.startsWith("$t:"))return e.label;const l=e.label.slice(3),d=l.indexOf("|"),c=d===-1?l:l.slice(0,d),p={};if(d!==-1)for(const y of l.slice(d+1).split("|")){const g=y.indexOf("=");g!==-1&&(p[y.slice(0,g)]=y.slice(g+1))}if((e.kind==="route"||e.kind==="modal")&&!a&&o&&r&&s!==r.templateId){const y={templateId:s,locale:r.locale},g=Object.keys(p).length>0?"|"+Object.entries(p).map(([h,v])=>`${h}=${v}`).join("|"):void 0,m=o.translate(c,y,g);if(m!==c)return m}const f=n(c,p);if(f===c&&typeof window<"u"){const y=window.G7Core?.t;if(typeof y=="function")try{const g=y(c,p);if(g!==c)return g}catch{}}if(f===c&&e.kind==="route"&&!a){const y=e.path;if(y&&y!=="/"){const g=y.split("/").filter(Boolean);return g[g.length-1]??y}return y==="/"?"/":c}return f}function IE(e){return e.trim().toLocaleLowerCase()}function op(e,t){if(!t)return e;const n=e.toLocaleLowerCase(),o=[];let r=0,s=n.indexOf(t,r);if(s===-1)return e;let a=0;for(;s!==-1;)s>r&&o.push(e.slice(r,s)),o.push(i.jsx("mark",{className:"g7le-route-tree__highlight",style:{background:"#fde68a",color:"inherit",borderRadius:2,padding:"0 1px"},children:e.slice(s,s+t.length)},`m${a++}`)),r=s+t.length,s=n.indexOf(t,r);return r{const T=f.selectedRoute?.path??"";return T.startsWith("__extension__/")||T.startsWith("__modal__/")?T.slice(T.lastIndexOf("/")+1):null},[f.selectedRoute?.path]),h=u.useMemo(()=>!g||!m?!1:(e.children??[]).some(T=>T.kind==="extension"&&T.extensionId===m||T.kind==="modal"&&T.modalId===m),[g,m,e.children]),[v,b]=u.useState(!0);u.useEffect(()=>{h&&b(!1)},[h]);const x=f.editMode==="extension"?f.selectedRoute?.layoutName??null:null,k=e.kind==="extension"?ip(e,f.routeTree)??null:null,w=e.kind==="extension"&&o===e.path&&(x===null||k===null||x===k),_=f.editMode==="modal"&&o?.startsWith("__modal__/")?o.slice(o.lastIndexOf("/")+1):null,S=f.editMode==="modal"?f.selectedRoute?.layoutName??null:null,E=e.kind==="modal"&&!!_&&e.modalId===_&&(S===null||e.modalHostLayout==null||S===e.modalHostLayout),C=e.kind!=="extension"&&e.kind!=="modal"&&o===e.path||w||E||e.kind==="route"&&!y&&r===e.path,$=(e.children?.length??0)>0&&(!g||l||!v),A=!y&&!!e.layoutName&&s.has(e.layoutName),z=y?void 0:e.kind==="extension"?e.extensionId?f.extensionVersions[e.extensionId]:void 0:e.layoutName?f.layoutVersions[e.layoutName]:void 0,D=()=>{if(g){b(T=>!T);return}y||n(e)},O=np(e,{t:d,translationEngine:c,translationContext:p,templateIdentifier:f.templateIdentifier}),M=y?null:AE(e),B={paddingLeft:12+t*14,paddingRight:12,paddingTop:4,paddingBottom:4,opacity:e.isHidden?.55:1,cursor:g?"pointer":y?"default":"pointer",background:C?"#eff6ff":"transparent",borderLeft:C?"2px solid #2563eb":"2px solid transparent",fontWeight:y||C?600:400,fontSize:y?11:13,color:y?"#64748b":C?"#1d4ed8":"#1e293b",textTransform:y?"uppercase":"none",letterSpacing:y?.6:void 0,transition:"background-color 100ms, color 100ms",display:"flex",flexDirection:"column",alignItems:"stretch",gap:2,userSelect:"none"};return i.jsxs("li",{children:[i.jsxs("div",{"data-testid":y?"g7le-route-tree-group":"g7le-route-tree-item","data-route-path":e.path,"data-route-kind":e.kind,"data-conn-collapsed":g?l?"false":String(v):void 0,role:g||!y?"button":void 0,tabIndex:g||!y?0:void 0,"aria-expanded":g?l?!0:!v:void 0,onClick:D,onKeyDown:T=>{g&&(T.key==="Enter"||T.key===" ")?(T.preventDefault(),b(H=>!H)):!y&&(T.key==="Enter"||T.key===" ")&&(T.preventDefault(),n(e))},style:B,children:[i.jsxs("span",{style:{display:"flex",alignItems:"center",gap:6,minWidth:0},children:[g&&i.jsx("span",{"aria-hidden":"true",style:{flex:"0 0 auto",fontSize:9,color:"#94a3b8",width:10,display:"inline-block"},children:l||!v?"▾":"▸"}),A&&i.jsx("span",{className:"g7le-route-tree__dirty-dot","data-testid":"g7le-route-tree-dirty",title:d("layout_editor.chrome.route_tree.badge.modified"),"aria-label":d("layout_editor.chrome.route_tree.badge.modified"),style:{width:7,height:7,borderRadius:"50%",background:"#f59e0b",flex:"0 0 auto"}}),y?O:op(O,a),typeof z=="number"&&i.jsxs("span",{className:"g7le-route-tree__version-badge","data-testid":"g7le-route-tree-version",title:d("layout_editor.chrome.route_tree.badge.version_tooltip",{version:String(z)}),style:{flex:"0 0 auto",padding:"0 5px",fontSize:10,fontWeight:600,lineHeight:"16px",borderRadius:4,background:"#eef2ff",color:"#4f46e5",border:"1px solid #e0e7ff",letterSpacing:.2},children:["v",z]}),i.jsx(TE,{node:e})]}),M&&i.jsx("span",{className:"g7le-route-tree__layout-path","data-testid":"g7le-route-tree-layout-path",title:M,style:{fontSize:11,fontWeight:400,color:"#94a3b8",textTransform:"none",letterSpacing:0,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:op(M,a)})]}),$&&i.jsx("ul",{style:{listStyle:"none",margin:0,padding:0},children:e.children.map(T=>i.jsx(rp,{node:T,depth:t+1,onSelect:n,selectedRoutePath:o,activeHostRoutePath:r,dirtyLayoutNames:s,query:a,forceExpand:l},T.path))})]})}function DE(e,t){if(!t||!t.startsWith("__"))return null;const n=t.startsWith("__modal__/"),o=t.startsWith("__extension__/");if(!n&&!o)return null;const r=t.slice(t.lastIndexOf("/")+1),s=a=>{for(const l of a){if(l.connectedHostRoutePath&&(n&&l.kind==="modal"&&l.modalId===r||o&&l.kind==="extension"&&l.extensionId===r))return l.connectedHostRoutePath;if(l.children&&l.children.length>0){const d=s(l.children);if(d)return d}}return null};return s(e)}function ip(e,t){if(e.connectedHostRoutePath){const n=sp(t,e.connectedHostRoutePath);if(n?.layoutName)return n.layoutName}if(e.extensionType==="overlay"&&e.extensionTargetName)return e.extensionTargetName}function sp(e,t){for(const n of e){if(n.kind==="route"&&n.path===t)return n;if(n.children&&n.children.length>0){const o=sp(n.children,t);if(o)return o}}return null}function ap(e,t,n){const o=[];for(const r of e){const s=Js(r),a=r.children&&r.children.length>0?ap(r.children,t,n):[];if(s){a.length>0&&o.push({...r,children:a});continue}const l=np(r,n).toLocaleLowerCase(),d=r.path.toLocaleLowerCase();(l.includes(t)||!d.startsWith("__")&&d.includes(t)||a.length>0)&&o.push({...r,children:a})}return o}function NE(){const{t:e,translationEngine:t,translationContext:n}=tt(),{state:o,dispatch:r}=Je(),a=Ft()?.dirtyLayoutNames??EE,[l,d]=u.useState(""),c=IE(l),p=u.useMemo(()=>c?ap(o.routeTree,c,{t:e,translationEngine:t,translationContext:n,templateIdentifier:o.templateIdentifier}):o.routeTree,[c,o.routeTree,o.templateIdentifier,e,t,n]),f=h=>{if(h.kind==="base"&&h.layoutName){r({type:"ENTER_BASE_EDIT",layoutName:h.layoutName});return}if(h.kind==="modal"&&h.modalId&&h.modalHostLayout){r({type:"ENTER_MODAL_EDIT",modalId:h.modalId,hostLayout:h.modalHostLayout});return}if(h.kind==="extension"&&h.extensionId){const v=ip(h,o.routeTree);r({type:"ENTER_EXTENSION_EDIT",extensionId:h.extensionId,extensionHost:v});return}if(r({type:"SELECT_ROUTE",route:{path:h.path,layoutName:h.layoutName}}),typeof window<"u"&&window.history?.pushState){const v=Xs(o.templateIdentifier,h.path);`${window.location.pathname}${window.location.search}`!==v&&window.history.pushState({source:$E,routePath:h.path},"",v)}},y=o.selectedRoute?.path??null,g=u.useMemo(()=>DE(o.routeTree,y),[o.routeTree,y]);if(o.isRouteTreeCollapsed)return i.jsx("aside",{className:"g7le-route-tree g7le-route-tree--collapsed","data-testid":"g7le-route-tree-panel","data-collapsed":"true",style:{width:28,borderRight:"1px solid #e2e8f0",background:"#ffffff"}});const m=o.routeTree.length>0;return i.jsxs("aside",{className:"g7le-route-tree","data-testid":"g7le-route-tree-panel","data-collapsed":"false",style:{width:280,borderRight:"1px solid #e2e8f0",background:"#ffffff",position:"sticky",top:"var(--g7le-header-h, 0px)",alignSelf:"start",height:"calc(100vh - var(--g7le-header-h, 0px))",overflow:"hidden",minHeight:0,display:"flex",flexDirection:"column"},"aria-label":e("layout_editor.chrome.route_tree.panel_title"),children:[i.jsx("div",{className:"g7le-route-tree__title",style:{padding:"10px 14px",fontSize:12,fontWeight:600,color:"#475569",textTransform:"uppercase",letterSpacing:.6,borderBottom:"1px solid #e2e8f0",background:"#f8fafc",flex:"0 0 auto"},children:e("layout_editor.chrome.route_tree.panel_title")}),m&&i.jsxs("div",{className:"g7le-route-tree__search",style:{padding:"8px 10px",borderBottom:"1px solid #e2e8f0",background:"#ffffff",flex:"0 0 auto",position:"relative"},children:[i.jsx("input",{type:"text",className:"g7le-route-tree__search-input","data-testid":"g7le-route-tree-search",value:l,onChange:h=>d(h.target.value),placeholder:e("layout_editor.chrome.route_tree.search_placeholder"),"aria-label":e("layout_editor.chrome.route_tree.search_placeholder"),style:{width:"100%",boxSizing:"border-box",padding:l?"6px 26px 6px 10px":"6px 10px",fontSize:12,lineHeight:1.4,color:"#1e293b",border:"1px solid #cbd5e1",borderRadius:6,outline:"none",background:"#f8fafc"}}),l&&i.jsx("button",{type:"button",className:"g7le-route-tree__search-clear","data-testid":"g7le-route-tree-search-clear",onClick:()=>d(""),"aria-label":e("layout_editor.chrome.route_tree.search_clear"),title:e("layout_editor.chrome.route_tree.search_clear"),style:{position:"absolute",right:16,top:"50%",transform:"translateY(-50%)",width:18,height:18,display:"inline-flex",alignItems:"center",justifyContent:"center",border:"none",background:"transparent",color:"#94a3b8",cursor:"pointer",fontSize:14,lineHeight:1,padding:0},children:"✕"})]}),!m&&i.jsx("div",{className:"g7le-route-tree__empty","data-testid":"g7le-route-tree-empty",style:{padding:16,fontSize:12,color:"#94a3b8",textAlign:"center"},children:e("layout_editor.chrome.route_tree.empty")}),m&&c&&p.length===0&&i.jsx("div",{className:"g7le-route-tree__no-results","data-testid":"g7le-route-tree-no-results",style:{padding:16,fontSize:12,color:"#94a3b8",textAlign:"center"},children:e("layout_editor.chrome.route_tree.search_no_results")}),m&&p.length>0&&i.jsx("nav",{className:"g7le-route-tree__scroll","data-testid":"g7le-route-tree-scroll",style:{flex:"1 1 auto",minHeight:0,overflowY:"auto"},children:i.jsx("ul",{style:{listStyle:"none",margin:0,padding:0},children:p.map(h=>i.jsx(rp,{node:h,depth:0,onSelect:f,selectedRoutePath:y,activeHostRoutePath:g,dirtyLayoutNames:a,query:c,forceExpand:!!c},h.path))})})]})}const zE=Lt().DynamicRenderer,Zs=Lt(),ME=Zs.ResponsiveProvider;Zs.useResponsive,Zs.ResponsiveContext;const LE=Lt().ActionDispatcher,lp=Lt(),PE=lp.DataSourceManager;lp.dataSourceManager;function Ur(e,t,n){const o=t??{},r=o.message||o.error||`HTTP ${e}`,s=o?.data?.required_permissions??o?.required_permissions;let a;return e===401?a="unauthorized":e===403?a="forbidden":e===404?a="not_found":e>=500?a="server_error":a="unknown",{kind:a,status:e,message:String(r),requiredPermissions:s?String(s):void 0,source:n}}function oo(e,t){return{kind:"network",status:0,message:e instanceof Error?e.message:String(e??"Unknown network error"),source:t}}function dp(e){if(typeof e!="object"||e===null)return!1;const t=e;return typeof t.kind=="string"&&typeof t.status=="number"&&typeof t.message=="string"}function Ze(e){if(!e)return[];const t=[],n=e.split(".");for(let o=0;o0&&(t.push({responsive:a}),o+=1);continue}const s=Number(r);Number.isFinite(s)&&Number.isInteger(s)&&s>=0&&t.push(s)}return t}function OE(e,t){if(!e)return{affordance:"none",targetPath:null};const n=BE(e);return n?WE(n)?{affordance:"dynamic_path",targetPath:n}:/^(https?:)?\/\//.test(n)?{affordance:"external_url",targetPath:n}:n.startsWith("/")?t?{affordance:t(n),targetPath:n}:{affordance:"route_not_in_tree",targetPath:n}:{affordance:"route_not_in_tree",targetPath:n}:{affordance:"none",targetPath:null}}function BE(e){const t=e.actions;if(Array.isArray(t)){for(const n of t)if(n&&typeof n=="object"&&n.handler==="navigate"){const o=n.params?.path;if(typeof o=="string"&&o.length>0)return o}}if(e.name==="A"||e.type==="A"){const n=e.props?.href;if(typeof n=="string"&&n.length>0)return n}return null}function WE(e){return/\{\{[\s\S]*?\}\}/.test(e)}function jE(e,t,n){const o=s=>!!(s?.iteration&&s.iteration.source!==void 0),r=s=>{if(!s)return s;const a=s.indexOf(".iteration."),l=s.indexOf(".sortable."),d=[a,l].filter(c=>c>=0).sort((c,p)=>c-p)[0];return d===void 0?s:s.slice(0,d)};return o(e)?{isIteration:!0,iterationSourcePath:r(n)}:n&&n.includes(".iteration.")?{isIteration:!0,iterationSourcePath:r(n)}:t.some(o)?{isIteration:!0,iterationSourcePath:r(n)}:{isIteration:!1,iterationSourcePath:null}}function HE(e,t){const n=a=>{const l=a?.iteration&&a.iteration.source;return typeof l=="string"&&l.length>0?l:null},o=n(e)??t.map(n).find(a=>a!==null)??null;if(o!==null){const a=KE(o);if(a!==null)return a}const r=[];if(typeof e.text=="string"&&r.push(...cp(e.text)),e.props&&typeof e.props=="object")for(const a of Object.values(e.props))typeof a=="string"&&r.push(...cp(a));return r.length===0?null:r.find(a=>!FE.has(a))??r[0]}const FE=new Set(["_local","_global","_computed","route","query","props","item","index","event","$event","$response","response"]);function cp(e){const t=[],n=/\{\{([\s\S]*?)\}\}/g;let o;for(;(o=n.exec(e))!==null;){const r=up(o[1]);r!==null&&t.push(r)}return t}function KE(e){const t=e.replace(/\{\{|\}\}/g,"");return up(t)}function up(e){const t=/[A-Za-z_$][A-Za-z0-9_$]*/.exec(e.trim());return t?t[0]:null}function fn(e,t,n,o=[]){if(!e)return"none";if(Rl(e)||typeof e.slot=="string"||typeof e.__editorSlotName=="string")return"extension_point";if(t!=="route"&&Yi(e,t,n)){const s=e.__source?.kind;return s==="base"?"base":s==="partial"?"partial":s==="extension"?"extension":"base"}if(vb(e,o))return"data_bound";if(!Yi(e,t,n))return"none";const r=e.__source?.kind;return r==="base"?"base":r==="partial"?"partial":r==="extension"?"extension":"none"}function pp(e){if(!e)return null;if(e.__source?.kind==="extension"&&typeof e.__source.extensionId=="number")return e.__source.extensionId;const t=e.__injectedProps;if(Array.isArray(t)&&t.length>0){const n=t[0]?.extensionId;if(typeof n=="number")return n}return null}function VE(e,t){if(t<=0)return e;const n=e.split(".");let o=0;const r=[];for(let s=0;s=t)break;r.push(a);continue}if(a==="iteration"||a==="sortable"){if(o>=t)break;r.push(a);continue}r.push(a);const l=n[s-1];if((l===void 0||l==="children")&&(o+=1),o>=t)break}return r.join(".")}function fp(e,t,n,o){if(!e||!t)return t;const r=n(t),s=wb(e,r,o);return s.length===r.length?t:VE(t,s.length)}function gp(e){return e==="none"||e==="data_bound"}function UE(e){const{rootNode:t,editMode:n,currentExtensionId:o,editableRootPath:r,pathParser:s=Ze,resolveRouteMatch:a}=e,[l,d]=u.useState(null),[c,p]=u.useState(null),f=u.useCallback(x=>{if(x===null||n==="route")return!0;if(n==="iteration_item"||n==="modal"){if(!r||r.length===0)return!0;const S=s(x);return hp(S,r)}const k=Qs(t,x,s);if(!k)return!0;const w=Rs(t,x,s),_=fn(k,n,o,w);return _==="none"||_==="data_bound"},[t,s,n,o,r]),y=u.useCallback((x,k)=>{const _=(k?.currentTarget?.dataset??k?.dataset)?.editorPath??null,S=fp(t,_,s,o);f(S)&&d(S)},[t,s,o,f]),g=u.useCallback((x,k)=>{if(x===null){p(null);return}const _=(k?.currentTarget?.dataset??k?.dataset)?.editorPath??null,S=fp(t,_,s,o);if(!f(S)){p(null);return}p(S)},[t,s,o,f]),m=u.useCallback(()=>{d(null)},[]),h=u.useMemo(()=>{const x=Qs(t,l,s),k=Qs(t,c,s),w=x?Rs(t,l,s):[];let _=fn(x,n,o,w);(n==="iteration_item"||n==="modal")&&l!==null&&x&&r&&r.length>0&&hp(s(l),r)&&(ed(x)?_="data_bound":_==="data_bound"&&(_="none"));const{affordance:S,targetPath:E}=OE(x,a),C=_==="base"&&typeof x?.__source?.layout=="string"?x.__source.layout:null,N=_==="data_bound"&&x?HE(x,w):null,{isIteration:$,iterationSourcePath:A}=_==="data_bound"&&x?jE(x,w,l):{isIteration:!1,iterationSourcePath:null};return{selectedNode:x,hoverNode:k,selectedLockKind:_,selectedNavAffordance:S,selectedNavTargetPath:E,selectedBaseLayout:C,selectedDataSourceId:N,selectedIsIteration:$,selectedIterationSourcePath:A}},[t,l,c,n,o,r,s,a]),v=u.useRef(void 0);v.current!==l&&(v.current=l,kl({op:l===null?"clear":"select",componentPath:l,componentName:yp(h.selectedNode),lockKind:h.selectedLockKind,navAffordance:h.selectedNavAffordance,sourceExtensionId:pp(h.selectedNode),timestamp:Date.now()}));const b=u.useRef(void 0);return b.current!==c&&(b.current=c,c!==null&&kl({op:"hover",componentPath:c,componentName:yp(h.hoverNode),lockKind:fn(h.hoverNode,n,o,Rs(t,c,s)),navAffordance:"none",sourceExtensionId:pp(h.hoverNode),timestamp:Date.now()})),{selectedPath:l,hoverPath:c,...h,handleSelect:y,handleHover:g,clearSelection:m}}function Qs(e,t,n){if(!e||!t)return null;const o=n(t);return We(e,o)}function hp(e,t){if(e.length0?e.name:typeof e.type=="string"&&e.type.length>0?e.type:null:null}let bp=0;function mp(){return bp}function ea(){bp+=1}function qE(e){if(typeof e!="string")return null;const t="__modal__/";if(!e.startsWith(t))return null;const n=e.slice(t.length);return n===""||n.includes("/")?null:n}function GE(e){if(typeof e!="string")return null;const t="__iteration__/";if(!e.startsWith(t))return null;const n=e.slice(t.length);return n!==""?n:null}function vp(){const{state:e}=Je(),t=e.templateIdentifier,n=e.selectedRoute?.layoutName??null,o=e.editMode,r=o==="modal"?qE(e.selectedRoute?.path):null,s=o==="iteration_item"?GE(e.selectedRoute?.path):null,[a,l]=u.useState(null),[d,c]=u.useState(!1),[p,f]=u.useState(null),[y,g]=u.useState(!1),[m,h]=u.useState(0),[v,b]=u.useState(0),x=u.useRef(new Map),[k,w]=u.useState(()=>new Set),_=u.useCallback((L,I)=>`${L}|${I??""}`,[]),S=u.useRef(null);u.useEffect(()=>{S.current=a},[a]);const E=u.useCallback((L,I)=>{const P=_(o,n);x.current.set(P,{raw:L,lockVersion:I}),w(F=>{if(F.has(P))return F;const G=new Set(F);return G.add(P),G})},[_,o,n]),C=u.useCallback(L=>{x.current.delete(L),w(I=>{if(!I.has(L))return I;const P=new Set(I);return P.delete(L),P})},[]),N=u.useCallback(L=>{const I=`|${L??""}`,P=[];for(const F of x.current.keys())F.endsWith(I)&&P.push(F);for(const F of P)x.current.delete(F);P.length>0&&w(F=>{let G=!1;const V=new Set(F);for(const Y of P)V.delete(Y)&&(G=!0);return G?V:F})},[]),$=u.useCallback(async()=>{if(!n){l(null),f(null),g(!1);return}const L=_(o,n),I=x.current.get(L);if(I){l({layoutName:n,raw:I.raw,lockVersion:I.lockVersion}),g(!0),f(null),c(!1),st({op:"load",layoutName:n,editMode:o,statusCode:0,isDirty:!0,timestamp:Date.now()});return}c(!0),f(null);try{const P=window.G7Config?.cache_version??0,F=`/api/layouts/${encodeURIComponent(t)}/${n}.json?with_source_meta=1&v=${P}.${mp()}`,G=rr(),V={Accept:"application/json"};G&&(V.Authorization=`Bearer ${G}`);const Y=await fetch(F,{headers:V,credentials:"same-origin"}),Z=await Y.json().catch(()=>null);if(!Y.ok){f(Ur(Y.status,Z,"layout")),l(null),g(!1);return}const se=(Z&&(Z.data||Z))??{},re=typeof se.lock_version=="number"?se.lock_version:0;if(o==="modal"&&r){const ee=se,fe=Array.isArray(ee.modals)?ee.modals:[],K=fe.findIndex(ne=>ne&&(ne.id===r||ne.modal_id===r));if(K>=0){const ne=fe[K],me={...ne,props:{...ne.props??{},isOpen:!0}},ze=[...Array.isArray(ee.components)?ee.components:[],me],Se=[ze.length-1];l({layoutName:n,raw:{...ee,components:ze},lockVersion:re,modalContext:{modalId:r,modalIndex:K,hostRaw:ee,originalModalNode:ne,editIndexPath:Se}}),g(!1),st({op:"load",layoutName:n,editMode:o,statusCode:Y.status,timestamp:Date.now()});return}}if(o==="iteration_item"&&s){const ee=se,fe=Ze(s),K={children:ee.components??[]},ne=We(K,fe);if(ne&&Array.isArray(ne.children)){l({layoutName:n,raw:ee,lockVersion:re,iterationContext:{sourcePath:s,sourceIndexPath:fe,hostRaw:ee}}),g(!1),st({op:"load",layoutName:n,editMode:o,statusCode:Y.status,timestamp:Date.now()});return}}l({layoutName:n,raw:se,lockVersion:re}),g(!1),st({op:"load",layoutName:n,editMode:o,statusCode:Y.status,timestamp:Date.now()})}catch(P){f(oo(P,"layout")),l(null),g(!1)}finally{c(!1)}},[t,n,o,r,s,_]);u.useEffect(()=>{$()},[$]);const A=u.useCallback(async()=>{C(_(o,n)),ea(),b(L=>L+1),await $()},[C,_,o,n,$]),z=u.useCallback(()=>{C(_(o,n))},[C,_,o,n]),D=u.useCallback(L=>{l(I=>{if(!I)return I;const P=I.raw.components??[],F=L(P),G={...I.raw,components:F};return E(G,I.lockVersion),{...I,raw:G}}),g(!0),st({op:"patch",layoutName:n,editMode:o,isDirty:!0,timestamp:Date.now()})},[n,o,E]),O=u.useCallback(()=>{g(!0),st({op:"patch",layoutName:n,editMode:o,isDirty:!0,timestamp:Date.now()})},[n,o]),M=u.useCallback(L=>{l(I=>{if(!I)return I;const P={...I.raw,components:L};return E(P,I.lockVersion),{...I,raw:P}}),g(!0),st({op:"patch",layoutName:n,editMode:o,isDirty:!0,timestamp:Date.now()})},[n,o,E]),B=u.useCallback((L,I,...P)=>{const F=P.length>=1?P[0]:I;l(G=>{if(!G)return G;const V={...G.raw,[L]:I},Y=G.raw.__editor;if(Y&&typeof Y=="object"&&!Array.isArray(Y)){const Z=Y,se=Z.original;se&&typeof se=="object"&&!Array.isArray(se)&&(V.__editor={...Z,original:{...se,[L]:F}})}return E(V,G.lockVersion),{...G,raw:V}}),g(!0),st({op:"patch",layoutName:n,editMode:o,isDirty:!0,timestamp:Date.now()})},[n,o,E]),T=u.useCallback(async L=>{const I=S.current;if(!I)return st({op:"save_guard_result",layoutName:n,editMode:o,guardBlocked:!1,timestamp:Date.now()}),{kind:"guard_no_document"};if(L?.resolveActiveExtensions&&L.sessionAddedPaths?.length)try{const fe=await L.resolveActiveExtensions(),K=YE(I.raw.components,L.sessionAddedPaths,fe);if(K.length>0)return st({op:"save_guard_result",layoutName:n,editMode:o,guardBlocked:!0,guardBlockedPaths:K,timestamp:Date.now()}),{kind:"blocked_inactive_extension",blockedPaths:K}}catch{}if(zb())try{await Mb(t)}catch{}const P=`/api/admin/templates/${encodeURIComponent(t)}/layouts/${I.layoutName}`,F=rr(),G={Accept:"application/json","Content-Type":"application/json"};F&&(G.Authorization=`Bearer ${F}`);const V=!!I.modalContext,Y=!!I.iterationContext,Z=V||Y?"host_layout_modal_patch":"layout";st({op:"save",layoutName:I.layoutName,editMode:o,saveTarget:Z,endpoint:P,isDirty:y,timestamp:Date.now()});let se;if(V&&I.modalContext){const{modalIndex:fe,hostRaw:K,originalModalNode:ne,editIndexPath:me}=I.modalContext,Ce=Array.isArray(K.modals)?[...K.modals]:[],ze=Array.isArray(I.raw.components)?I.raw.components:[],Se=me&&me.length===1?me[0]:0,qe=ze.length>Se?ze[Se]:void 0,De=qe?Kn([qe]):[],Pe=De.length>0?{...De[0]}:void 0;if(fe>=0&&fe({...Me,children:ze})).children??[];se={...Se,components:Qe}}else se=yo(I.raw);const re=se;let U;try{U=await fetch(P,{method:"PUT",headers:G,credentials:"same-origin",body:JSON.stringify({content:JSON.stringify(re),expected_lock_version:I.lockVersion})})}catch(fe){const K=fe instanceof Error?fe.message:"network error";return st({op:"save_response",layoutName:I.layoutName,editMode:o,endpoint:P,statusCode:0,timestamp:Date.now()}),{kind:"network_error",message:K}}const ee=await U.json().catch(()=>null);if(st({op:"save_response",layoutName:I.layoutName,editMode:o,endpoint:P,statusCode:U.status,conflict:U.status===409?{currentVersion:ee?.current_version,yourVersion:ee?.your_version}:void 0,timestamp:Date.now()}),U.ok){const fe=typeof ee?.data?.lock_version=="number"?ee.data.lock_version:I.lockVersion+1;l({...I,lockVersion:fe}),g(!1),C(_(o,I.layoutName)),N(I.layoutName),ea(),h(ne=>ne+1);const K=typeof ee?.data?.current_version=="number"?ee.data.current_version:void 0;return{kind:"success",newLockVersion:fe,newContentVersion:K,savedLayoutName:I.layoutName}}return U.status===409?{kind:"concurrent_modification",currentVersion:ee?.current_version??-1,yourVersion:ee?.your_version??I.lockVersion}:U.status===422?{kind:"validation_failed",status:422,errors:ee?.errors??null}:{kind:"network_error",message:ee?.message??`HTTP ${U.status}`}},[t,n,o,y,C,N,_,r,s]),H=u.useMemo(()=>{const L=new Set,I="route|";for(const P of k)if(P.startsWith(I)){const F=P.slice(I.length);F&&L.add(F)}return L},[k]);return{document:a,isLoading:d,error:p,isDirty:y,saveSuccessCounter:m,reloadCounter:v,dirtyKeys:k,dirtyLayoutNames:H,invalidateCurrentCache:z,reload:A,patchLayout:D,setLayoutComponents:M,patchDocumentRaw:B,save:T,markDirty:O}}function YE(e,t,n){if(!e||e.length===0)return[];const o=[],r=new Set([...n.moduleIds,...n.pluginIds]);for(const s of t){const a=XE(e,s);if(!a)continue;const l=a.__source;if(!l||l.kind!=="extension")continue;const d=String(l.extensionId??"");d.length!==0&&(r.has(d)||o.push(s))}return o}function XE(e,t){const n=Ze(t);return n.length===0?null:We({children:e},n)}function ro(e,t){if(!e)return null;const n=e.getBoundingClientRect();if(n.width===0&&n.height===0)return null;if(!t)return{left:n.left,top:n.top,width:n.width,height:n.height,scale:1};const o=t.getBoundingClientRect();return{left:n.left-o.left,top:n.top-o.top,width:n.width,height:n.height,scale:1}}function io(e,t){if(!e)return!1;if(!t)return!0;const n=t.getBoundingClientRect(),o=1;return!(e.left>=n.width-o||e.left+e.width<=o||e.top>=n.height-o||e.top+e.height<=o)}function _p(e,t){if(typeof window>"u")return()=>{};const n=()=>{e()};window.addEventListener("scroll",n,!0),window.addEventListener("resize",n);let o=null;t&&typeof ResizeObserver<"u"&&(o=new ResizeObserver(()=>e()),o.observe(t));let r=null,s=null;const a=typeof requestAnimationFrame<"u",l=()=>{if(!a){e();return}s===null&&(s=requestAnimationFrame(()=>{s=null,e()}))};return t&&typeof MutationObserver<"u"&&(r=new MutationObserver(l),r.observe(t,{childList:!0,subtree:!0})),()=>{window.removeEventListener("scroll",n,!0),window.removeEventListener("resize",n),o&&(o.disconnect(),o=null),r&&(r.disconnect(),r=null),s!==null&&a&&typeof cancelAnimationFrame<"u"&&(cancelAnimationFrame(s),s=null)}}const xp=44;function wp(e){if(!e)return"inside";const t=e.scale||1,n=e.width*t,o=e.height*t;return n"u"||typeof window.getComputedStyle!="function")return{kind:"unknown",display:"",flexDirection:"",flexWrap:""};let t=e,n=(window.getComputedStyle(t).display??"").toLowerCase();for(;n==="contents"&&t.parentElement;)t=t.parentElement,n=(window.getComputedStyle(t).display??"").toLowerCase();const o=window.getComputedStyle(t),r=(o.display??"").toLowerCase(),s=(o.flexDirection??"").toLowerCase(),a=(o.flexWrap??"").toLowerCase(),l=r==="flex"||r==="inline-flex";if(r==="grid"||r==="inline-grid"){const f=Array.from(e.children).filter(h=>h.offsetParent!==null||h.getClientRects().length>0);if(f.length<=1)return{kind:"grid_2d",display:r,flexDirection:s,flexWrap:a};const y=f.map(h=>h.getBoundingClientRect()),g=new Set(y.map(h=>Math.round(h.top))),m=new Set(y.map(h=>Math.round(h.left)));return g.size===1?{kind:"grid_single_row",display:r,flexDirection:s,flexWrap:a}:m.size===1?{kind:"grid_single_column",display:r,flexDirection:s,flexWrap:a}:{kind:"grid_2d",display:r,flexDirection:s,flexWrap:a}}if(!l)return{kind:"block",display:r,flexDirection:s,flexWrap:a};const c=a==="wrap"||a==="wrap-reverse";return s==="column"||s==="column-reverse"?{kind:c?"flex_column_wrap":"flex_column_single",display:r,flexDirection:s,flexWrap:a}:{kind:c?"flex_row_wrap":"flex_row_single",display:r,flexDirection:s,flexWrap:a}}function ZE(e){const{parentEl:t,selectedPath:n,selectedEl:o,explicitFlow:r}=e;return u.useMemo(()=>{if(!n||n.length===0)return{points:[],flow:"unknown"};const s=r??Cp(t).kind,a=n.slice(0,-1),l=n[n.length-1]??0;if((s==="flex_row_wrap"||s==="flex_column_wrap"||s==="grid_2d")&&!!o){const p=QE(s,o,a,l);if(p)return{points:p,flow:s}}return{points:RE(s,a,l),flow:s}},[t,n,o,r])}function QE(e,t,n,o){let r=t.parentElement;for(;r&&$p(r)==="contents";)r=r.parentElement;if(!r)return null;const s=[];if(Ep(r,s),s.length===0)return null;const a=t.getBoundingClientRect(),l=a.top+a.height/2,d=[],c=[],p=[];for(const v of s){const b=v.rect,x=b.top+b.height/2,k=Math.min(a.bottom,b.bottom)-Math.max(a.top,b.top),w=Math.min(a.height,b.height)||1;k/w>.4?d.push({rect:b,path:v.path}):xv.rect.left-b.rect.left),c.sort((v,b)=>v.rect.top-b.rect.top),p.sort((v,b)=>v.rect.top-b.rect.top);const f=d.findIndex(v=>Math.abs(v.rect.left-a.left)<1&&Math.abs(v.rect.top-a.top)<1),y={direction:"above",disabled:!0,insertion:null},g={direction:"below",disabled:!0,insertion:null},m={direction:"left",disabled:!0,insertion:null},h={direction:"right",disabled:!0,insertion:null};if(f>0){const v=d[f-1],b=Gr(v.path);b&&(m.disabled=!1,m.insertion=b)}else if(d.length>0){const v=d[0],b=qr(v.path);b&&(m.disabled=!1,m.insertion=b)}if(f>=0&&f0){const v=d[d.length-1],b=Gr(v.path);b&&(h.disabled=!1,h.insertion=b)}if(c.length>0){const v=c[c.length-1],b=Gr(v.path);b&&(y.disabled=!1,y.insertion=b)}else if(s.length>0){const v=s[0],b=qr(v.path);b&&(y.disabled=!1,y.insertion=b)}if(p.length>0){const v=p[0],b=qr(v.path);b&&(g.disabled=!1,g.insertion=b)}else if(s.length>0){const v=s[s.length-1],b=Gr(v.path);b&&(g.disabled=!1,g.insertion=b)}else g.disabled=!1,g.insertion={parentPath:n,index:o+1};return y.disabled&&g.disabled&&m.disabled&&h.disabled?null:[y,g,m,h]}function $p(e){return typeof window>"u"?"":(window.getComputedStyle(e).display??"").toLowerCase()}function Ep(e,t){const n=Array.from(e.children);for(const o of n){if($p(o)==="contents"){Ep(o,t);continue}const s=o.getAttribute("data-editor-path");if(s&&/\.iteration\.\d+(?:$|\.)/.test(s)||!s)continue;const a=o.getBoundingClientRect();a.width===0&&a.height===0||t.push({el:o,rect:a,path:Ze(s)})}}function qr(e){if(!e||e.length===0)return null;const t=e[e.length-1];return typeof t!="number"?null:{parentPath:e.slice(0,-1),index:t}}function Gr(e){if(!e||e.length===0)return null;const t=e[e.length-1];return typeof t!="number"?null:{parentPath:e.slice(0,-1),index:t+1}}function RE(e,t,n){const o={direction:"above",disabled:!0,insertion:null},r={direction:"below",disabled:!0,insertion:null},s={direction:"left",disabled:!0,insertion:null},a={direction:"right",disabled:!0,insertion:null},l=d=>({parentPath:t,index:d});switch(e){case"block":case"unknown":{o.disabled=!1,o.insertion=l(n),r.disabled=!1,r.insertion=l(n+1);break}case"flex_row_single":case"grid_single_row":{s.disabled=!1,s.insertion=l(n),a.disabled=!1,a.insertion=l(n+1);break}case"flex_column_single":case"grid_single_column":{o.disabled=!1,o.insertion=l(n),r.disabled=!1,r.insertion=l(n+1);break}case"flex_row_wrap":case"grid_2d":{s.disabled=!1,s.insertion=l(n),a.disabled=!1,a.insertion=l(n+1),o.disabled=!1,o.insertion=l(0),r.disabled=!1,r.insertion=l(-1);break}case"flex_column_wrap":{o.disabled=!1,o.insertion=l(n),r.disabled=!1,r.insertion=l(n+1),s.disabled=!1,s.insertion=l(0),a.disabled=!1,a.insertion=l(-1);break}}return[o,r,s,a]}function e7(e=50){const t=u.useRef([]),n=u.useRef(-1),[,o]=u.useState(0),r=()=>{o(p=>(p+1)%1e6)},s=(p,f)=>{const y=t.current.length,g=n.current;Hh({op:p,actionKind:f?.actionKind,label:f?.label,stackSize:y,cursor:g,canUndo:g>0,canRedo:g>=0&&g{const y=t.current.slice(0,n.current+1);for(y.push(p);y.length>e;)y.shift();t.current=y,n.current=y.length-1,r(),s("push",p)},[e]),l=u.useCallback(()=>{if(n.current<=0)return null;n.current-=1,r();const p=t.current[n.current]??null;return s("undo",p??void 0),p},[]),d=u.useCallback(()=>{const p=n.current+1;if(p>=t.current.length)return null;n.current=p,r();const f=t.current[p]??null;return s("redo",f??void 0),f},[]),c=u.useCallback(()=>{t.current=[],n.current=-1,r(),s("clear")},[]);return{push:a,undo:l,redo:d,clear:c,canUndo:n.current>0,canRedo:n.current>=0&&n.current{if(!o||typeof window>"u")return;const r=s=>{if(t7(s.target)||!(s.ctrlKey||s.metaKey))return;const l=s.key.toLowerCase();if(l==="z"&&!s.shiftKey){s.preventDefault(),t();return}if(l==="z"&&s.shiftKey||l==="y"){s.preventDefault(),n();return}};return window.addEventListener("keydown",r),()=>{window.removeEventListener("keydown",r)}},[t,n,o])}const o7=20,Ip=50,r7=90,Mn=120,i7=130,Ap=80,s7=9e3,Tp=1e4;function a7(e){const t=e<0?0:e>Ip?Ip:e;return o7+t}function l7(e){const{anchor:t,open:n,onClose:o,onEditProps:r,onDuplicate:s,onDelete:a,onSeparateBranch:l,separateBranchLabel:d,onMergeBranch:c,mergeBranchLabel:p}=e,{t:f}=tt(),[y,g]=u.useState(null);if(!n||!t)return null;const m=[{index:0,testid:"g7le-context-menu-edit-props",label:f("layout_editor.context_menu.edit_props"),onSelect:r},{index:1,testid:"g7le-context-menu-duplicate",label:f("layout_editor.context_menu.duplicate"),onSelect:s}];return l?m.push({index:m.length,testid:"g7le-context-menu-separate-branch",label:d??f("layout_editor.context_menu.separate_branch"),onSelect:l,hint:f("layout_editor.context_menu.separate_branch_hint")}):c&&m.push({index:m.length,testid:"g7le-context-menu-merge-branch",label:p??f("layout_editor.context_menu.merge_branch"),onSelect:c,hint:f("layout_editor.context_menu.merge_branch_hint")}),m.push({index:m.length,testid:"g7le-context-menu-delete",label:f("layout_editor.context_menu.delete"),onSelect:a,danger:!0}),i.jsx("div",{className:"g7le-context-menu","data-testid":"g7le-context-menu",role:"menu",style:{position:"absolute",left:t.left,top:t.top,background:"#fff",border:"1px solid #cbd5e1",borderRadius:6,boxShadow:"0 4px 12px rgba(0,0,0,0.15)",zIndex:1200,minWidth:160,padding:4,pointerEvents:"auto"},onMouseLeave:()=>{g(null),o()},children:m.map(h=>{const v=y===h.index,b=h.danger?"#dc2626":"#0f172a",x=h.danger?"#fee2e2":"#e0e7ff",k=h.danger?"#b91c1c":"#1d4ed8";return i.jsx("button",{type:"button",role:"menuitem","data-testid":h.testid,title:h.hint,onClick:w=>{w.stopPropagation(),h.onSelect(),g(null),o()},onMouseEnter:()=>g(h.index),onFocus:()=>g(h.index),style:{...d7,color:v?k:b,background:v?x:"transparent"},children:h.label},h.testid)})})}const d7={display:"block",width:"100%",textAlign:"left",padding:"6px 12px",border:"none",fontSize:13,cursor:"pointer",borderRadius:4,pointerEvents:"auto",transition:"background-color 120ms ease, color 120ms ease"};function c7(e){const{hoverBox:t,selectedBox:n,lockKind:o,navAffordance:r,onEditProps:s,onDuplicate:a,onDelete:l,onSeparateBranch:d,separateBranchLabel:c,onMergeBranch:p,mergeBranchLabel:f,definedDeviceBranches:y,onJumpToDevice:g,onLinkEditDestination:m,onEditBase:h,onEditExtension:v,extensionLabel:b,baseLayoutLabel:x,dataSourceLabel:k,branchLabel:w,onEditIteration:_,resizeEnabledAxes:S,onResizeHandlePointerDown:E,selectedName:C,onSelectParent:N}=e,{t:$}=tt(),[A,z]=u.useState(!1),D=gp(o),O=A&&D,M=wp(n),B=M==="outside",T=B&&n?Sp(n):null,H=T?{left:T.right.left,top:T.above.top}:{right:-30,top:-30},L={top:-28,left:0},P=B&&!!C?{top:-50,left:0}:L,F=T?{top:T.below.top+28,left:0}:{top:"auto",bottom:-28},G=T?{top:T.below.top+56,left:0}:{top:"auto",bottom:-56};return i.jsxs("div",{className:"g7le-element-overlay","data-testid":"g7le-element-overlay",style:{position:"absolute",inset:0,pointerEvents:"none"},children:[t&&i.jsx("div",{"data-testid":"g7le-overlay-hover",style:{position:"absolute",left:t.left,top:t.top,width:t.width,height:t.height,border:"1px dashed #2563eb",pointerEvents:"none"}}),n&&i.jsxs("div",{"data-testid":"g7le-overlay-selected","data-lock-kind":o,style:{position:"absolute",left:n.left,top:n.top,width:n.width,height:n.height,border:o==="none"?"2px solid #2563eb":"2px dashed #f59e0b",pointerEvents:"none"},children:[w?i.jsxs("span",{"data-testid":"g7le-overlay-branch-badge",style:p7,children:["📱 ",w]}):null,C&&(N?i.jsxs("button",{type:"button","data-testid":"g7le-overlay-type-label","data-placement":B?"outside":"inside",title:$("layout_editor.overlay.select_parent"),"aria-label":$("layout_editor.overlay.select_parent"),onClick:V=>{V.preventDefault(),V.stopPropagation(),N()},style:B?g7:f7,children:[i.jsx("span",{"aria-hidden":!0,style:{marginRight:3,opacity:.85},children:"↑"}),C]}):i.jsx("span",{"data-testid":"g7le-overlay-type-label","data-placement":B?"outside":"inside",style:B?Np:Dp,children:C})),D&&i.jsx("button",{type:"button","data-testid":"g7le-overlay-info-button","aria-label":"info",onClick:V=>{V.stopPropagation(),z(Y=>!Y)},"data-placement":M,style:{position:"absolute",...H,width:20,height:20,borderRadius:10,border:"1px solid #2563eb",background:"#fff",color:"#2563eb",fontSize:12,fontWeight:700,cursor:"pointer",pointerEvents:"auto",padding:0,lineHeight:"18px",zIndex:Mn},children:"ⓘ"}),o==="none"&&i.jsx(u7,{enabledAxes:S,onHandlePointerDown:E}),o==="base"&&h&&i.jsxs("button",{type:"button","data-testid":"g7le-overlay-edit-base",onClick:V=>{V.stopPropagation(),h()},style:{...zo,...L},children:["🔒 ",$("layout_editor.overlay.edit_common_layout"),x?i.jsx("span",{"data-testid":"g7le-overlay-base-layout-label",style:na,children:x}):null]}),o==="extension"&&v&&i.jsxs("button",{type:"button","data-testid":"g7le-overlay-edit-extension",onClick:V=>{V.stopPropagation(),v()},style:{...zo,...L},children:["🔒 ",$("layout_editor.overlay.edit_extension"),b?i.jsx("span",{"data-testid":"g7le-overlay-extension-label",style:na,children:b}):null]}),o==="data_bound"&&i.jsxs("span",{"data-testid":"g7le-overlay-data-bound-notice",style:{...zo,...P,pointerEvents:"none",cursor:"default"},children:[$("layout_editor.overlay.locked_data_bound"),k?i.jsx("span",{"data-testid":"g7le-overlay-data-source-label",style:na,children:k}):null]}),o==="data_bound"&&_&&i.jsxs("button",{type:"button","data-testid":"g7le-overlay-edit-iteration",onClick:V=>{V.stopPropagation(),_()},style:{...zo,...F},children:["↳ ",$("layout_editor.overlay.edit_iteration_item")]}),r==="route_in_tree"&&i.jsx("button",{type:"button","data-testid":"g7le-overlay-link-edit-destination",onClick:V=>{V.stopPropagation(),m()},style:{...zo,...F},children:$("layout_editor.overlay.edit_destination_route")}),(d||p||g&&(y??[]).length>0)&&i.jsxs("div",{"data-testid":"g7le-overlay-branch-affordances",style:{position:"absolute",...G,display:"flex",flexDirection:"column",alignItems:"flex-start",gap:4,pointerEvents:"none"},children:[g&&(y??[]).map(V=>i.jsxs("button",{type:"button","data-testid":`g7le-overlay-jump-device-${V.key}`,title:$("layout_editor.overlay.jump_device_hint",{device:V.label}),onClick:Y=>{Y.stopPropagation(),g(V.key)},style:ta,children:["↗ ",$("layout_editor.overlay.jump_device",{device:V.label})]},V.key)),d&&i.jsxs("button",{type:"button","data-testid":"g7le-overlay-separate-branch",title:$("layout_editor.context_menu.separate_branch_hint"),onClick:V=>{V.stopPropagation(),d()},style:ta,children:["📱 ",c??$("layout_editor.context_menu.separate_branch")]}),p&&i.jsxs("button",{type:"button","data-testid":"g7le-overlay-merge-branch",title:$("layout_editor.context_menu.merge_branch_hint"),onClick:V=>{V.stopPropagation(),p()},style:ta,children:["↩️ ",f??$("layout_editor.context_menu.merge_branch")]})]})]}),i.jsx(l7,{anchor:n&&O?{left:n.left+n.width+8,top:n.top-4}:null,open:O,onClose:()=>z(!1),onEditProps:s,onDuplicate:a,onDelete:l,onSeparateBranch:d,separateBranchLabel:c,onMergeBranch:p,mergeBranchLabel:f})]})}function u7(e){const{enabledAxes:t,onHandlePointerDown:n}=e,o=t?.width??!1,r=t?.height??!1,s=[{key:"nw",style:{left:-4,top:-4},cursor:"nwse-resize",needsWidth:!0,needsHeight:!0},{key:"n",style:{left:"50%",top:-4,transform:"translateX(-50%)"},cursor:"ns-resize",needsWidth:!1,needsHeight:!0},{key:"ne",style:{right:-4,top:-4},cursor:"nesw-resize",needsWidth:!0,needsHeight:!0},{key:"e",style:{right:-4,top:"50%",transform:"translateY(-50%)"},cursor:"ew-resize",needsWidth:!0,needsHeight:!1},{key:"se",style:{right:-4,bottom:-4},cursor:"nwse-resize",needsWidth:!0,needsHeight:!0},{key:"s",style:{left:"50%",bottom:-4,transform:"translateX(-50%)"},cursor:"ns-resize",needsWidth:!1,needsHeight:!0},{key:"sw",style:{left:-4,bottom:-4},cursor:"nesw-resize",needsWidth:!0,needsHeight:!0},{key:"w",style:{left:-4,top:"50%",transform:"translateY(-50%)"},cursor:"ew-resize",needsWidth:!0,needsHeight:!1}];return i.jsx(i.Fragment,{children:s.map(a=>{const l=!a.needsWidth||o,d=!a.needsHeight||r,c=l&&d&&!!n;return!l||!d?null:i.jsx("span",{"data-testid":`g7le-resize-handle-${a.key}`,"data-resize-handle-active":c?"true":"false",onPointerDown:c?p=>{p.preventDefault(),p.stopPropagation(),n?.(a.key,{clientX:p.clientX,clientY:p.clientY})}:void 0,style:{position:"absolute",...a.style,width:8,height:8,background:"#fff",border:"1px solid #2563eb",cursor:a.cursor,pointerEvents:c?"auto":"none",zIndex:Mn}},a.key)})})}const zo={position:"absolute",top:-28,left:0,background:"#fff",border:"1px solid #2563eb",color:"#2563eb",padding:"2px 6px",fontSize:11,borderRadius:4,cursor:"pointer",pointerEvents:"auto",whiteSpace:"nowrap",zIndex:Mn},ta={background:"#fff",border:"1px solid #2563eb",color:"#2563eb",padding:"2px 6px",fontSize:11,borderRadius:4,cursor:"pointer",pointerEvents:"auto",whiteSpace:"nowrap",zIndex:Mn},na={marginLeft:6,padding:"0 5px",borderRadius:3,background:"#eef2ff",border:"1px solid #c7d2fe",fontSize:10,fontWeight:600,color:"#3730a3"},p7={position:"absolute",top:0,right:0,transform:"translateY(-100%)",background:"rgba(124,58,237,0.92)",color:"#fff",padding:"1px 6px",borderRadius:"3px 3px 0 0",fontSize:10,fontWeight:600,whiteSpace:"nowrap",pointerEvents:"none"},Dp={position:"absolute",top:0,left:0,background:"rgba(37,99,235,0.55)",color:"#fff",padding:"1px 6px",fontSize:10,fontWeight:600,lineHeight:"14px",borderBottomRightRadius:4,letterSpacing:"0.02em",whiteSpace:"nowrap",pointerEvents:"none",userSelect:"none",maxWidth:"100%",overflow:"hidden",textOverflow:"ellipsis"},Np={position:"absolute",bottom:"100%",left:0,marginBottom:2,background:"rgba(37,99,235,0.55)",color:"#fff",padding:"1px 6px",fontSize:10,fontWeight:600,lineHeight:"14px",borderRadius:4,letterSpacing:"0.02em",whiteSpace:"nowrap",pointerEvents:"none",userSelect:"none"},f7={...Dp,pointerEvents:"auto",cursor:"pointer",background:"rgba(37,99,235,0.82)",border:"none",display:"inline-flex",alignItems:"center",font:"inherit",fontSize:10,fontWeight:600,zIndex:Mn},g7={...Np,pointerEvents:"auto",cursor:"pointer",background:"rgba(37,99,235,0.82)",border:"none",display:"inline-flex",alignItems:"center",font:"inherit",fontSize:10,fontWeight:600,zIndex:Mn},h7={above:{top:-30,left:"50%",transform:"translateX(-50%)"},below:{bottom:-30,left:"50%",transform:"translateX(-50%)"},left:{left:-30,top:"50%",transform:"translateY(-50%)"},right:{right:-30,top:"50%",transform:"translateY(-50%)"}};function y7(e){const{selectedBox:t,points:n,onAddRequest:o}=e,{t:r}=tt();if(!t||n.length===0)return null;const s=wp(t),l=s==="outside"?Sp(t):null;return i.jsx("div",{className:"g7le-insertion-affordances","data-testid":"g7le-insertion-affordances",style:{position:"absolute",left:t.left,top:t.top,width:t.width,height:t.height,pointerEvents:"none"},children:n.map(d=>{const c=l?{left:l[d.direction].left,top:l[d.direction].top}:h7[d.direction],p=r(`layout_editor.insertion.${d.direction}`);return i.jsx("button",{type:"button",disabled:d.disabled,"data-testid":`g7le-insertion-${d.direction}`,"data-disabled":d.disabled?"true":"false","data-placement":s,"aria-label":p,title:p,onClick:f=>{f.stopPropagation(),d.disabled||o(d)},style:{position:"absolute",...c,width:24,height:24,borderRadius:12,border:"1px solid #2563eb",background:d.disabled?"#cbd5e1":"#fff",color:d.disabled?"#94a3b8":"#2563eb",cursor:d.disabled?"not-allowed":"pointer",opacity:d.disabled?.4:1,fontSize:16,fontWeight:700,lineHeight:"20px",padding:0,pointerEvents:"auto",boxShadow:"0 1px 3px rgba(0,0,0,0.15)",zIndex:Mn},children:"+"},d.direction)})})}const Yr=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function so(e){const t=Object.prototype.toString.call(e);return t==="[object Window]"||t==="[object global]"}function oa(e){return"nodeType"in e}function _t(e){var t,n;return e?so(e)?e:oa(e)&&(t=(n=e.ownerDocument)==null?void 0:n.defaultView)!=null?t:window:window}function ra(e){const{Document:t}=_t(e);return e instanceof t}function Mo(e){return so(e)?!1:e instanceof _t(e).HTMLElement}function zp(e){return e instanceof _t(e).SVGElement}function ao(e){return e?so(e)?e.document:oa(e)?ra(e)?e:Mo(e)||zp(e)?e.ownerDocument:document:document:document}const rn=Yr?u.useLayoutEffect:u.useEffect;function Xr(e){const t=u.useRef(e);return rn(()=>{t.current=e}),u.useCallback(function(){for(var n=arguments.length,o=new Array(n),r=0;r{e.current=setInterval(o,r)},[]),n=u.useCallback(()=>{e.current!==null&&(clearInterval(e.current),e.current=null)},[]);return[t,n]}function Lo(e,t){t===void 0&&(t=[e]);const n=u.useRef(e);return rn(()=>{n.current!==e&&(n.current=e)},t),n}function Po(e,t){const n=u.useRef();return u.useMemo(()=>{const o=e(n.current);return n.current=o,o},[...t])}function Jr(e){const t=Xr(e),n=u.useRef(null),o=u.useCallback(r=>{r!==n.current&&t?.(r,n.current),n.current=r},[]);return[n,o]}function Zr(e){const t=u.useRef();return u.useEffect(()=>{t.current=e},[e]),t.current}let ia={};function Qr(e,t){return u.useMemo(()=>{if(t)return t;const n=ia[e]==null?0:ia[e]+1;return ia[e]=n,e+"-"+n},[e,t])}function Mp(e){return function(t){for(var n=arguments.length,o=new Array(n>1?n-1:0),r=1;r{const l=Object.entries(a);for(const[d,c]of l){const p=s[d];p!=null&&(s[d]=p+e*c)}return s},{...t})}}const lo=Mp(1),Rr=Mp(-1);function m7(e){return"clientX"in e&&"clientY"in e}function sa(e){if(!e)return!1;const{KeyboardEvent:t}=_t(e.target);return t&&e instanceof t}function v7(e){if(!e)return!1;const{TouchEvent:t}=_t(e.target);return t&&e instanceof t}function ei(e){if(v7(e)){if(e.touches&&e.touches.length){const{clientX:t,clientY:n}=e.touches[0];return{x:t,y:n}}else if(e.changedTouches&&e.changedTouches.length){const{clientX:t,clientY:n}=e.changedTouches[0];return{x:t,y:n}}}return m7(e)?{x:e.clientX,y:e.clientY}:null}const Oo=Object.freeze({Translate:{toString(e){if(!e)return;const{x:t,y:n}=e;return"translate3d("+(t?Math.round(t):0)+"px, "+(n?Math.round(n):0)+"px, 0)"}},Scale:{toString(e){if(!e)return;const{scaleX:t,scaleY:n}=e;return"scaleX("+t+") scaleY("+n+")"}},Transform:{toString(e){if(e)return[Oo.Translate.toString(e),Oo.Scale.toString(e)].join(" ")}},Transition:{toString(e){let{property:t,duration:n,easing:o}=e;return t+" "+n+"ms "+o}}}),Lp="a,frame,iframe,input:not([type=hidden]):not(:disabled),select:not(:disabled),textarea:not(:disabled),button:not(:disabled),*[tabindex]";function _7(e){return e.matches(Lp)?e:e.querySelector(Lp)}const x7={display:"none"};function w7(e){let{id:t,value:n}=e;return u.createElement("div",{id:t,style:x7},n)}function k7(e){let{id:t,announcement:n,ariaLiveType:o="assertive"}=e;const r={position:"fixed",top:0,left:0,width:1,height:1,margin:-1,border:0,padding:0,overflow:"hidden",clip:"rect(0 0 0 0)",clipPath:"inset(100%)",whiteSpace:"nowrap"};return u.createElement("div",{id:t,style:r,role:"status","aria-live":o,"aria-atomic":!0},n)}function S7(){const[e,t]=u.useState("");return{announce:u.useCallback(o=>{o!=null&&t(o)},[]),announcement:e}}const Pp=u.createContext(null);function C7(e){const t=u.useContext(Pp);u.useEffect(()=>{if(!t)throw new Error("useDndMonitor must be used within a children of ");return t(e)},[e,t])}function $7(){const[e]=u.useState(()=>new Set),t=u.useCallback(o=>(e.add(o),()=>e.delete(o)),[e]);return[u.useCallback(o=>{let{type:r,event:s}=o;e.forEach(a=>{var l;return(l=a[r])==null?void 0:l.call(a,s)})},[e]),t]}const E7={draggable:` To pick up a draggable item, press the space bar. While dragging, use the arrow keys to move the item. Press space again to drop the item in its new position, or press escape to cancel. - `},I7={onDragStart(e){let{active:t}=e;return"Picked up draggable item "+t.id+"."},onDragOver(e){let{active:t,over:n}=e;return n?"Draggable item "+t.id+" was moved over droppable area "+n.id+".":"Draggable item "+t.id+" is no longer over a droppable area."},onDragEnd(e){let{active:t,over:n}=e;return n?"Draggable item "+t.id+" was dropped over droppable area "+n.id:"Draggable item "+t.id+" was dropped."},onDragCancel(e){let{active:t}=e;return"Dragging was cancelled. Draggable item "+t.id+" was dropped."}};function A7(e){let{announcements:t=I7,container:n,hiddenTextDescribedById:o,screenReaderInstructions:r=E7}=e;const{announce:s,announcement:a}=S7(),l=Qr("DndLiveRegion"),[d,c]=u.useState(!1);if(u.useEffect(()=>{c(!0)},[]),C7(u.useMemo(()=>({onDragStart(f){let{active:y}=f;s(t.onDragStart({active:y}))},onDragMove(f){let{active:y,over:g}=f;t.onDragMove&&s(t.onDragMove({active:y,over:g}))},onDragOver(f){let{active:y,over:g}=f;s(t.onDragOver({active:y,over:g}))},onDragEnd(f){let{active:y,over:g}=f;s(t.onDragEnd({active:y,over:g}))},onDragCancel(f){let{active:y,over:g}=f;s(t.onDragCancel({active:y,over:g}))}}),[s,t])),!d)return null;const p=u.createElement(u.Fragment,null,u.createElement(w7,{id:o,value:r.draggable}),u.createElement(k7,{id:l,announcement:a}));return n?Sn.createPortal(p,n):p}var it;(function(e){e.DragStart="dragStart",e.DragMove="dragMove",e.DragEnd="dragEnd",e.DragCancel="dragCancel",e.DragOver="dragOver",e.RegisterDroppable="registerDroppable",e.SetDroppableDisabled="setDroppableDisabled",e.UnregisterDroppable="unregisterDroppable"})(it||(it={}));function ti(){}function T7(e,t){return u.useMemo(()=>({sensor:e,options:t??{}}),[e,t])}function D7(){for(var e=arguments.length,t=new Array(e),n=0;n[...t].filter(o=>o!=null),[...t])}const Ht=Object.freeze({x:0,y:0});function N7(e,t){return Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))}function z7(e,t){const n=ei(e);if(!n)return"0 0";const o={x:(n.x-t.left)/t.width*100,y:(n.y-t.top)/t.height*100};return o.x+"% "+o.y+"%"}function M7(e,t){let{data:{value:n}}=e,{data:{value:o}}=t;return n-o}function L7(e,t){let{data:{value:n}}=e,{data:{value:o}}=t;return o-n}function P7(e){let{left:t,top:n,height:o,width:r}=e;return[{x:t,y:n},{x:t+r,y:n},{x:t,y:n+o},{x:t+r,y:n+o}]}function O7(e,t){if(!e||e.length===0)return null;const[n]=e;return n[t]}function B7(e,t){const n=Math.max(t.top,e.top),o=Math.max(t.left,e.left),r=Math.min(t.left+t.width,e.left+e.width),s=Math.min(t.top+t.height,e.top+e.height),a=r-o,l=s-n;if(o{let{collisionRect:t,droppableRects:n,droppableContainers:o}=e;const r=[];for(const s of o){const{id:a}=s,l=n.get(a);if(l){const d=B7(l,t);d>0&&r.push({id:a,data:{droppableContainer:s,value:d}})}}return r.sort(L7)};function j7(e,t){const{top:n,left:o,bottom:r,right:s}=t;return n<=e.y&&e.y<=r&&o<=e.x&&e.x<=s}const H7=e=>{let{droppableContainers:t,droppableRects:n,pointerCoordinates:o}=e;if(!o)return[];const r=[];for(const s of t){const{id:a}=s,l=n.get(a);if(l&&j7(o,l)){const c=P7(l).reduce((f,y)=>f+N7(o,y),0),p=Number((c/4).toFixed(4));r.push({id:a,data:{droppableContainer:s,value:p}})}}return r.sort(M7)};function F7(e,t,n){return{...e,scaleX:t&&n?t.width/n.width:1,scaleY:t&&n?t.height/n.height:1}}function Op(e,t){return e&&t?{x:e.left-t.left,y:e.top-t.top}:Ht}function K7(e){return function(n){for(var o=arguments.length,r=new Array(o>1?o-1:0),s=1;s({...a,top:a.top+e*l.y,bottom:a.bottom+e*l.y,left:a.left+e*l.x,right:a.right+e*l.x}),{...n})}}const V7=K7(1);function Bp(e){if(e.startsWith("matrix3d(")){const t=e.slice(9,-1).split(/, /);return{x:+t[12],y:+t[13],scaleX:+t[0],scaleY:+t[5]}}else if(e.startsWith("matrix(")){const t=e.slice(7,-1).split(/, /);return{x:+t[4],y:+t[5],scaleX:+t[0],scaleY:+t[3]}}return null}function U7(e,t,n){const o=Bp(t);if(!o)return e;const{scaleX:r,scaleY:s,x:a,y:l}=o,d=e.left-a-(1-r)*parseFloat(n),c=e.top-l-(1-s)*parseFloat(n.slice(n.indexOf(" ")+1)),p=r?e.width/r:e.width,f=s?e.height/s:e.height;return{width:p,height:f,top:c,right:d+p,bottom:c+f,left:d}}const q7={ignoreTransform:!1};function Bo(e,t){t===void 0&&(t=q7);let n=e.getBoundingClientRect();if(t.ignoreTransform){const{transform:c,transformOrigin:p}=_t(e).getComputedStyle(e);c&&(n=U7(n,c,p))}const{top:o,left:r,width:s,height:a,bottom:l,right:d}=n;return{top:o,left:r,width:s,height:a,bottom:l,right:d}}function Wp(e){return Bo(e,{ignoreTransform:!0})}function G7(e){const t=e.innerWidth,n=e.innerHeight;return{top:0,left:0,right:t,bottom:n,width:t,height:n}}function Y7(e,t){return t===void 0&&(t=_t(e).getComputedStyle(e)),t.position==="fixed"}function X7(e,t){t===void 0&&(t=_t(e).getComputedStyle(e));const n=/(auto|scroll|overlay)/;return["overflow","overflowX","overflowY"].some(r=>{const s=t[r];return typeof s=="string"?n.test(s):!1})}function aa(e,t){const n=[];function o(r){if(t!=null&&n.length>=t||!r)return n;if(ra(r)&&r.scrollingElement!=null&&!n.includes(r.scrollingElement))return n.push(r.scrollingElement),n;if(!Mo(r)||zp(r)||n.includes(r))return n;const s=_t(e).getComputedStyle(r);return r!==e&&X7(r,s)&&n.push(r),Y7(r,s)?n:o(r.parentNode)}return e?o(e):n}function jp(e){const[t]=aa(e,1);return t??null}function la(e){return!Yr||!e?null:so(e)?e:oa(e)?ra(e)||e===ao(e).scrollingElement?window:Mo(e)?e:null:null}function Hp(e){return so(e)?e.scrollX:e.scrollLeft}function Fp(e){return so(e)?e.scrollY:e.scrollTop}function da(e){return{x:Hp(e),y:Fp(e)}}var lt;(function(e){e[e.Forward=1]="Forward",e[e.Backward=-1]="Backward"})(lt||(lt={}));function Kp(e){return!Yr||!e?!1:e===document.scrollingElement}function Vp(e){const t={x:0,y:0},n=Kp(e)?{height:window.innerHeight,width:window.innerWidth}:{height:e.clientHeight,width:e.clientWidth},o={x:e.scrollWidth-n.width,y:e.scrollHeight-n.height},r=e.scrollTop<=t.y,s=e.scrollLeft<=t.x,a=e.scrollTop>=o.y,l=e.scrollLeft>=o.x;return{isTop:r,isLeft:s,isBottom:a,isRight:l,maxScroll:o,minScroll:t}}const J7={x:.2,y:.2};function Z7(e,t,n,o,r){let{top:s,left:a,right:l,bottom:d}=n;o===void 0&&(o=10),r===void 0&&(r=J7);const{isTop:c,isBottom:p,isLeft:f,isRight:y}=Vp(e),g={x:0,y:0},m={x:0,y:0},h={height:t.height*r.y,width:t.width*r.x};return!c&&s<=t.top+h.height?(g.y=lt.Backward,m.y=o*Math.abs((t.top+h.height-s)/h.height)):!p&&d>=t.bottom-h.height&&(g.y=lt.Forward,m.y=o*Math.abs((t.bottom-h.height-d)/h.height)),!y&&l>=t.right-h.width?(g.x=lt.Forward,m.x=o*Math.abs((t.right-h.width-l)/h.width)):!f&&a<=t.left+h.width&&(g.x=lt.Backward,m.x=o*Math.abs((t.left+h.width-a)/h.width)),{direction:g,speed:m}}function Q7(e){if(e===document.scrollingElement){const{innerWidth:s,innerHeight:a}=window;return{top:0,left:0,right:s,bottom:a,width:s,height:a}}const{top:t,left:n,right:o,bottom:r}=e.getBoundingClientRect();return{top:t,left:n,right:o,bottom:r,width:e.clientWidth,height:e.clientHeight}}function Up(e){return e.reduce((t,n)=>lo(t,da(n)),Ht)}function R7(e){return e.reduce((t,n)=>t+Hp(n),0)}function e2(e){return e.reduce((t,n)=>t+Fp(n),0)}function qp(e,t){if(t===void 0&&(t=Bo),!e)return;const{top:n,left:o,bottom:r,right:s}=t(e);jp(e)&&(r<=0||s<=0||n>=window.innerHeight||o>=window.innerWidth)&&e.scrollIntoView({block:"center",inline:"center"})}const t2=[["x",["left","right"],R7],["y",["top","bottom"],e2]];class ca{constructor(t,n){this.rect=void 0,this.width=void 0,this.height=void 0,this.top=void 0,this.bottom=void 0,this.right=void 0,this.left=void 0;const o=aa(n),r=Up(o);this.rect={...t},this.width=t.width,this.height=t.height;for(const[s,a,l]of t2)for(const d of a)Object.defineProperty(this,d,{get:()=>{const c=l(o),p=r[s]-c;return this.rect[d]+p},enumerable:!0});Object.defineProperty(this,"rect",{enumerable:!1})}}class Wo{constructor(t){this.target=void 0,this.listeners=[],this.removeAll=()=>{this.listeners.forEach(n=>{var o;return(o=this.target)==null?void 0:o.removeEventListener(...n)})},this.target=t}add(t,n,o){var r;(r=this.target)==null||r.addEventListener(t,n,o),this.listeners.push([t,n,o])}}function n2(e){const{EventTarget:t}=_t(e);return e instanceof t?e:ao(e)}function ua(e,t){const n=Math.abs(e.x),o=Math.abs(e.y);return typeof t=="number"?Math.sqrt(n**2+o**2)>t:"x"in t&&"y"in t?n>t.x&&o>t.y:"x"in t?n>t.x:"y"in t?o>t.y:!1}var At;(function(e){e.Click="click",e.DragStart="dragstart",e.Keydown="keydown",e.ContextMenu="contextmenu",e.Resize="resize",e.SelectionChange="selectionchange",e.VisibilityChange="visibilitychange"})(At||(At={}));function Gp(e){e.preventDefault()}function o2(e){e.stopPropagation()}var Ke;(function(e){e.Space="Space",e.Down="ArrowDown",e.Right="ArrowRight",e.Left="ArrowLeft",e.Up="ArrowUp",e.Esc="Escape",e.Enter="Enter",e.Tab="Tab"})(Ke||(Ke={}));const Yp={start:[Ke.Space,Ke.Enter],cancel:[Ke.Esc],end:[Ke.Space,Ke.Enter,Ke.Tab]},r2=(e,t)=>{let{currentCoordinates:n}=t;switch(e.code){case Ke.Right:return{...n,x:n.x+25};case Ke.Left:return{...n,x:n.x-25};case Ke.Down:return{...n,y:n.y+25};case Ke.Up:return{...n,y:n.y-25}}};class Xp{constructor(t){this.props=void 0,this.autoScrollEnabled=!1,this.referenceCoordinates=void 0,this.listeners=void 0,this.windowListeners=void 0,this.props=t;const{event:{target:n}}=t;this.props=t,this.listeners=new Wo(ao(n)),this.windowListeners=new Wo(_t(n)),this.handleKeyDown=this.handleKeyDown.bind(this),this.handleCancel=this.handleCancel.bind(this),this.attach()}attach(){this.handleStart(),this.windowListeners.add(At.Resize,this.handleCancel),this.windowListeners.add(At.VisibilityChange,this.handleCancel),setTimeout(()=>this.listeners.add(At.Keydown,this.handleKeyDown))}handleStart(){const{activeNode:t,onStart:n}=this.props,o=t.node.current;o&&qp(o),n(Ht)}handleKeyDown(t){if(sa(t)){const{active:n,context:o,options:r}=this.props,{keyboardCodes:s=Yp,coordinateGetter:a=r2,scrollBehavior:l="smooth"}=r,{code:d}=t;if(s.end.includes(d)){this.handleEnd(t);return}if(s.cancel.includes(d)){this.handleCancel(t);return}const{collisionRect:c}=o.current,p=c?{x:c.left,y:c.top}:Ht;this.referenceCoordinates||(this.referenceCoordinates=p);const f=a(t,{active:n,context:o.current,currentCoordinates:p});if(f){const y=Rr(f,p),g={x:0,y:0},{scrollableAncestors:m}=o.current;for(const h of m){const v=t.code,{isTop:b,isRight:x,isLeft:k,isBottom:w,maxScroll:_,minScroll:S}=Vp(h),E=Q7(h),C={x:Math.min(v===Ke.Right?E.right-E.width/2:E.right,Math.max(v===Ke.Right?E.left:E.left+E.width/2,f.x)),y:Math.min(v===Ke.Down?E.bottom-E.height/2:E.bottom,Math.max(v===Ke.Down?E.top:E.top+E.height/2,f.y))},N=v===Ke.Right&&!x||v===Ke.Left&&!k,$=v===Ke.Down&&!w||v===Ke.Up&&!b;if(N&&C.x!==f.x){const A=h.scrollLeft+y.x,z=v===Ke.Right&&A<=_.x||v===Ke.Left&&A>=S.x;if(z&&!y.y){h.scrollTo({left:A,behavior:l});return}z?g.x=h.scrollLeft-A:g.x=v===Ke.Right?h.scrollLeft-_.x:h.scrollLeft-S.x,g.x&&h.scrollBy({left:-g.x,behavior:l});break}else if($&&C.y!==f.y){const A=h.scrollTop+y.y,z=v===Ke.Down&&A<=_.y||v===Ke.Up&&A>=S.y;if(z&&!y.x){h.scrollTo({top:A,behavior:l});return}z?g.y=h.scrollTop-A:g.y=v===Ke.Down?h.scrollTop-_.y:h.scrollTop-S.y,g.y&&h.scrollBy({top:-g.y,behavior:l});break}}this.handleMove(t,lo(Rr(f,this.referenceCoordinates),g))}}}handleMove(t,n){const{onMove:o}=this.props;t.preventDefault(),o(n)}handleEnd(t){const{onEnd:n}=this.props;t.preventDefault(),this.detach(),n()}handleCancel(t){const{onCancel:n}=this.props;t.preventDefault(),this.detach(),n()}detach(){this.listeners.removeAll(),this.windowListeners.removeAll()}}Xp.activators=[{eventName:"onKeyDown",handler:(e,t,n)=>{let{keyboardCodes:o=Yp,onActivation:r}=t,{active:s}=n;const{code:a}=e.nativeEvent;if(o.start.includes(a)){const l=s.activatorNode.current;return l&&e.target!==l?!1:(e.preventDefault(),r?.({event:e.nativeEvent}),!0)}return!1}}];function Jp(e){return!!(e&&"distance"in e)}function Zp(e){return!!(e&&"delay"in e)}class pa{constructor(t,n,o){var r;o===void 0&&(o=n2(t.event.target)),this.props=void 0,this.events=void 0,this.autoScrollEnabled=!0,this.document=void 0,this.activated=!1,this.initialCoordinates=void 0,this.timeoutId=null,this.listeners=void 0,this.documentListeners=void 0,this.windowListeners=void 0,this.props=t,this.events=n;const{event:s}=t,{target:a}=s;this.props=t,this.events=n,this.document=ao(a),this.documentListeners=new Wo(this.document),this.listeners=new Wo(o),this.windowListeners=new Wo(_t(a)),this.initialCoordinates=(r=ei(s))!=null?r:Ht,this.handleStart=this.handleStart.bind(this),this.handleMove=this.handleMove.bind(this),this.handleEnd=this.handleEnd.bind(this),this.handleCancel=this.handleCancel.bind(this),this.handleKeydown=this.handleKeydown.bind(this),this.removeTextSelection=this.removeTextSelection.bind(this),this.attach()}attach(){const{events:t,props:{options:{activationConstraint:n,bypassActivationConstraint:o}}}=this;if(this.listeners.add(t.move.name,this.handleMove,{passive:!1}),this.listeners.add(t.end.name,this.handleEnd),t.cancel&&this.listeners.add(t.cancel.name,this.handleCancel),this.windowListeners.add(At.Resize,this.handleCancel),this.windowListeners.add(At.DragStart,Gp),this.windowListeners.add(At.VisibilityChange,this.handleCancel),this.windowListeners.add(At.ContextMenu,Gp),this.documentListeners.add(At.Keydown,this.handleKeydown),n){if(o!=null&&o({event:this.props.event,activeNode:this.props.activeNode,options:this.props.options}))return this.handleStart();if(Zp(n)){this.timeoutId=setTimeout(this.handleStart,n.delay),this.handlePending(n);return}if(Jp(n)){this.handlePending(n);return}}this.handleStart()}detach(){this.listeners.removeAll(),this.windowListeners.removeAll(),setTimeout(this.documentListeners.removeAll,50),this.timeoutId!==null&&(clearTimeout(this.timeoutId),this.timeoutId=null)}handlePending(t,n){const{active:o,onPending:r}=this.props;r(o,t,this.initialCoordinates,n)}handleStart(){const{initialCoordinates:t}=this,{onStart:n}=this.props;t&&(this.activated=!0,this.documentListeners.add(At.Click,o2,{capture:!0}),this.removeTextSelection(),this.documentListeners.add(At.SelectionChange,this.removeTextSelection),n(t))}handleMove(t){var n;const{activated:o,initialCoordinates:r,props:s}=this,{onMove:a,options:{activationConstraint:l}}=s;if(!r)return;const d=(n=ei(t))!=null?n:Ht,c=Rr(r,d);if(!o&&l){if(Jp(l)){if(l.tolerance!=null&&ua(c,l.tolerance))return this.handleCancel();if(ua(c,l.distance))return this.handleStart()}if(Zp(l)&&ua(c,l.tolerance))return this.handleCancel();this.handlePending(l,c);return}t.cancelable&&t.preventDefault(),a(d)}handleEnd(){const{onAbort:t,onEnd:n}=this.props;this.detach(),this.activated||t(this.props.active),n()}handleCancel(){const{onAbort:t,onCancel:n}=this.props;this.detach(),this.activated||t(this.props.active),n()}handleKeydown(t){t.code===Ke.Esc&&this.handleCancel()}removeTextSelection(){var t;(t=this.document.getSelection())==null||t.removeAllRanges()}}const i2={cancel:{name:"pointercancel"},move:{name:"pointermove"},end:{name:"pointerup"}};class fa extends pa{constructor(t){const{event:n}=t,o=ao(n.target);super(t,i2,o)}}fa.activators=[{eventName:"onPointerDown",handler:(e,t)=>{let{nativeEvent:n}=e,{onActivation:o}=t;return!n.isPrimary||n.button!==0?!1:(o?.({event:n}),!0)}}];const s2={move:{name:"mousemove"},end:{name:"mouseup"}};var ga;(function(e){e[e.RightClick=2]="RightClick"})(ga||(ga={}));class a2 extends pa{constructor(t){super(t,s2,ao(t.event.target))}}a2.activators=[{eventName:"onMouseDown",handler:(e,t)=>{let{nativeEvent:n}=e,{onActivation:o}=t;return n.button===ga.RightClick?!1:(o?.({event:n}),!0)}}];const ha={cancel:{name:"touchcancel"},move:{name:"touchmove"},end:{name:"touchend"}};class l2 extends pa{constructor(t){super(t,ha)}static setup(){return window.addEventListener(ha.move.name,t,{capture:!1,passive:!1}),function(){window.removeEventListener(ha.move.name,t)};function t(){}}}l2.activators=[{eventName:"onTouchStart",handler:(e,t)=>{let{nativeEvent:n}=e,{onActivation:o}=t;const{touches:r}=n;return r.length>1?!1:(o?.({event:n}),!0)}}];var jo;(function(e){e[e.Pointer=0]="Pointer",e[e.DraggableRect=1]="DraggableRect"})(jo||(jo={}));var ni;(function(e){e[e.TreeOrder=0]="TreeOrder",e[e.ReversedTreeOrder=1]="ReversedTreeOrder"})(ni||(ni={}));function d2(e){let{acceleration:t,activator:n=jo.Pointer,canScroll:o,draggingRect:r,enabled:s,interval:a=5,order:l=ni.TreeOrder,pointerCoordinates:d,scrollableAncestors:c,scrollableAncestorRects:p,delta:f,threshold:y}=e;const g=u2({delta:f,disabled:!s}),[m,h]=b7(),v=u.useRef({x:0,y:0}),b=u.useRef({x:0,y:0}),x=u.useMemo(()=>{switch(n){case jo.Pointer:return d?{top:d.y,bottom:d.y,left:d.x,right:d.x}:null;case jo.DraggableRect:return r}},[n,r,d]),k=u.useRef(null),w=u.useCallback(()=>{const S=k.current;if(!S)return;const E=v.current.x*b.current.x,C=v.current.y*b.current.y;S.scrollBy(E,C)},[]),_=u.useMemo(()=>l===ni.TreeOrder?[...c].reverse():c,[l,c]);u.useEffect(()=>{if(!s||!c.length||!x){h();return}for(const S of _){if(o?.(S)===!1)continue;const E=c.indexOf(S),C=p[E];if(!C)continue;const{direction:N,speed:$}=Z7(S,C,x,t,y);for(const A of["x","y"])g[A][N[A]]||($[A]=0,N[A]=0);if($.x>0||$.y>0){h(),k.current=S,m(w,a),v.current=$,b.current=N;return}}v.current={x:0,y:0},b.current={x:0,y:0},h()},[t,w,o,h,s,a,JSON.stringify(x),JSON.stringify(g),m,c,_,p,JSON.stringify(y)])}const c2={x:{[lt.Backward]:!1,[lt.Forward]:!1},y:{[lt.Backward]:!1,[lt.Forward]:!1}};function u2(e){let{delta:t,disabled:n}=e;const o=Zr(t);return Po(r=>{if(n||!o||!r)return c2;const s={x:Math.sign(t.x-o.x),y:Math.sign(t.y-o.y)};return{x:{[lt.Backward]:r.x[lt.Backward]||s.x===-1,[lt.Forward]:r.x[lt.Forward]||s.x===1},y:{[lt.Backward]:r.y[lt.Backward]||s.y===-1,[lt.Forward]:r.y[lt.Forward]||s.y===1}}},[n,t,o])}function p2(e,t){const n=t!=null?e.get(t):void 0,o=n?n.node.current:null;return Po(r=>{var s;return t==null?null:(s=o??r)!=null?s:null},[o,t])}function f2(e,t){return u.useMemo(()=>e.reduce((n,o)=>{const{sensor:r}=o,s=r.activators.map(a=>({eventName:a.eventName,handler:t(a.handler,o)}));return[...n,...s]},[]),[e,t])}var Ho;(function(e){e[e.Always=0]="Always",e[e.BeforeDragging=1]="BeforeDragging",e[e.WhileDragging=2]="WhileDragging"})(Ho||(Ho={}));var ya;(function(e){e.Optimized="optimized"})(ya||(ya={}));const Qp=new Map;function g2(e,t){let{dragging:n,dependencies:o,config:r}=t;const[s,a]=u.useState(null),{frequency:l,measure:d,strategy:c}=r,p=u.useRef(e),f=v(),y=Lo(f),g=u.useCallback(function(b){b===void 0&&(b=[]),!y.current&&a(x=>x===null?b:x.concat(b.filter(k=>!x.includes(k))))},[y]),m=u.useRef(null),h=Po(b=>{if(f&&!n)return Qp;if(!b||b===Qp||p.current!==e||s!=null){const x=new Map;for(let k of e){if(!k)continue;if(s&&s.length>0&&!s.includes(k.id)&&k.rect.current){x.set(k.id,k.rect.current);continue}const w=k.node.current,_=w?new ca(d(w),w):null;k.rect.current=_,_&&x.set(k.id,_)}return x}return b},[e,s,n,f,d]);return u.useEffect(()=>{p.current=e},[e]),u.useEffect(()=>{f||g()},[n,f]),u.useEffect(()=>{s&&s.length>0&&a(null)},[JSON.stringify(s)]),u.useEffect(()=>{f||typeof l!="number"||m.current!==null||(m.current=setTimeout(()=>{g(),m.current=null},l))},[l,f,g,...o]),{droppableRects:h,measureDroppableContainers:g,measuringScheduled:s!=null};function v(){switch(c){case Ho.Always:return!1;case Ho.BeforeDragging:return n;default:return!n}}}function ba(e,t){return Po(n=>e?n||(typeof t=="function"?t(e):e):null,[t,e])}function h2(e,t){return ba(e,t)}function y2(e){let{callback:t,disabled:n}=e;const o=Xr(t),r=u.useMemo(()=>{if(n||typeof window>"u"||typeof window.MutationObserver>"u")return;const{MutationObserver:s}=window;return new s(o)},[o,n]);return u.useEffect(()=>()=>r?.disconnect(),[r]),r}function oi(e){let{callback:t,disabled:n}=e;const o=Xr(t),r=u.useMemo(()=>{if(n||typeof window>"u"||typeof window.ResizeObserver>"u")return;const{ResizeObserver:s}=window;return new s(o)},[n]);return u.useEffect(()=>()=>r?.disconnect(),[r]),r}function b2(e){return new ca(Bo(e),e)}function Rp(e,t,n){t===void 0&&(t=b2);const[o,r]=u.useState(null);function s(){r(d=>{if(!e)return null;if(e.isConnected===!1){var c;return(c=d??n)!=null?c:null}const p=t(e);return JSON.stringify(d)===JSON.stringify(p)?d:p})}const a=y2({callback(d){if(e)for(const c of d){const{type:p,target:f}=c;if(p==="childList"&&f instanceof HTMLElement&&f.contains(e)){s();break}}}}),l=oi({callback:s});return rn(()=>{s(),e?(l?.observe(e),a?.observe(document.body,{childList:!0,subtree:!0})):(l?.disconnect(),a?.disconnect())},[e]),o}function m2(e){const t=ba(e);return Op(e,t)}const ef=[];function v2(e){const t=u.useRef(e),n=Po(o=>e?o&&o!==ef&&e&&t.current&&e.parentNode===t.current.parentNode?o:aa(e):ef,[e]);return u.useEffect(()=>{t.current=e},[e]),n}function _2(e){const[t,n]=u.useState(null),o=u.useRef(e),r=u.useCallback(s=>{const a=la(s.target);a&&n(l=>l?(l.set(a,da(a)),new Map(l)):null)},[]);return u.useEffect(()=>{const s=o.current;if(e!==s){a(s);const l=e.map(d=>{const c=la(d);return c?(c.addEventListener("scroll",r,{passive:!0}),[c,da(c)]):null}).filter(d=>d!=null);n(l.length?new Map(l):null),o.current=e}return()=>{a(e),a(s)};function a(l){l.forEach(d=>{const c=la(d);c?.removeEventListener("scroll",r)})}},[r,e]),u.useMemo(()=>e.length?t?Array.from(t.values()).reduce((s,a)=>lo(s,a),Ht):Up(e):Ht,[e,t])}function tf(e,t){t===void 0&&(t=[]);const n=u.useRef(null);return u.useEffect(()=>{n.current=null},t),u.useEffect(()=>{const o=e!==Ht;o&&!n.current&&(n.current=e),!o&&n.current&&(n.current=null)},[e]),n.current?Rr(e,n.current):Ht}function x2(e){u.useEffect(()=>{if(!Yr)return;const t=e.map(n=>{let{sensor:o}=n;return o.setup==null?void 0:o.setup()});return()=>{for(const n of t)n?.()}},e.map(t=>{let{sensor:n}=t;return n}))}function w2(e,t){return u.useMemo(()=>e.reduce((n,o)=>{let{eventName:r,handler:s}=o;return n[r]=a=>{s(a,t)},n},{}),[e,t])}function nf(e){return u.useMemo(()=>e?G7(e):null,[e])}const of=[];function k2(e,t){t===void 0&&(t=Bo);const[n]=e,o=nf(n?_t(n):null),[r,s]=u.useState(of);function a(){s(()=>e.length?e.map(d=>Kp(d)?o:new ca(t(d),d)):of)}const l=oi({callback:a});return rn(()=>{l?.disconnect(),a(),e.forEach(d=>l?.observe(d))},[e]),r}function rf(e){if(!e)return null;if(e.children.length>1)return e;const t=e.children[0];return Mo(t)?t:e}function S2(e){let{measure:t}=e;const[n,o]=u.useState(null),r=u.useCallback(c=>{for(const{target:p}of c)if(Mo(p)){o(f=>{const y=t(p);return f?{...f,width:y.width,height:y.height}:y});break}},[t]),s=oi({callback:r}),a=u.useCallback(c=>{const p=rf(c);s?.disconnect(),p&&s?.observe(p),o(p?t(p):null)},[t,s]),[l,d]=Jr(a);return u.useMemo(()=>({nodeRef:l,rect:n,setRef:d}),[n,l,d])}const C2=[{sensor:fa,options:{}},{sensor:Xp,options:{}}],$2={current:{}},ri={draggable:{measure:Wp},droppable:{measure:Wp,strategy:Ho.WhileDragging,frequency:ya.Optimized},dragOverlay:{measure:Bo}};class Fo extends Map{get(t){var n;return t!=null&&(n=super.get(t))!=null?n:void 0}toArray(){return Array.from(this.values())}getEnabled(){return this.toArray().filter(t=>{let{disabled:n}=t;return!n})}getNodeFor(t){var n,o;return(n=(o=this.get(t))==null?void 0:o.node.current)!=null?n:void 0}}const E2={activatorEvent:null,active:null,activeNode:null,activeNodeRect:null,collisions:null,containerNodeRect:null,draggableNodes:new Map,droppableRects:new Map,droppableContainers:new Fo,over:null,dragOverlay:{nodeRef:{current:null},rect:null,setRef:ti},scrollableAncestors:[],scrollableAncestorRects:[],measuringConfiguration:ri,measureDroppableContainers:ti,windowRect:null,measuringScheduled:!1},sf={activatorEvent:null,activators:[],active:null,activeNodeRect:null,ariaDescribedById:{draggable:""},dispatch:ti,draggableNodes:new Map,over:null,measureDroppableContainers:ti},Ko=u.createContext(sf),af=u.createContext(E2);function I2(){return{draggable:{active:null,initialCoordinates:{x:0,y:0},nodes:new Map,translate:{x:0,y:0}},droppable:{containers:new Fo}}}function A2(e,t){switch(t.type){case it.DragStart:return{...e,draggable:{...e.draggable,initialCoordinates:t.initialCoordinates,active:t.active}};case it.DragMove:return e.draggable.active==null?e:{...e,draggable:{...e.draggable,translate:{x:t.coordinates.x-e.draggable.initialCoordinates.x,y:t.coordinates.y-e.draggable.initialCoordinates.y}}};case it.DragEnd:case it.DragCancel:return{...e,draggable:{...e.draggable,active:null,initialCoordinates:{x:0,y:0},translate:{x:0,y:0}}};case it.RegisterDroppable:{const{element:n}=t,{id:o}=n,r=new Fo(e.droppable.containers);return r.set(o,n),{...e,droppable:{...e.droppable,containers:r}}}case it.SetDroppableDisabled:{const{id:n,key:o,disabled:r}=t,s=e.droppable.containers.get(n);if(!s||o!==s.key)return e;const a=new Fo(e.droppable.containers);return a.set(n,{...s,disabled:r}),{...e,droppable:{...e.droppable,containers:a}}}case it.UnregisterDroppable:{const{id:n,key:o}=t,r=e.droppable.containers.get(n);if(!r||o!==r.key)return e;const s=new Fo(e.droppable.containers);return s.delete(n),{...e,droppable:{...e.droppable,containers:s}}}default:return e}}function T2(e){let{disabled:t}=e;const{active:n,activatorEvent:o,draggableNodes:r}=u.useContext(Ko),s=Zr(o),a=Zr(n?.id);return u.useEffect(()=>{if(!t&&!o&&s&&a!=null){if(!sa(s)||document.activeElement===s.target)return;const l=r.get(a);if(!l)return;const{activatorNode:d,node:c}=l;if(!d.current&&!c.current)return;requestAnimationFrame(()=>{for(const p of[d.current,c.current]){if(!p)continue;const f=_7(p);if(f){f.focus();break}}})}},[o,t,r,a,s]),null}function lf(e,t){let{transform:n,...o}=t;return e!=null&&e.length?e.reduce((r,s)=>s({transform:r,...o}),n):n}function D2(e){return u.useMemo(()=>({draggable:{...ri.draggable,...e?.draggable},droppable:{...ri.droppable,...e?.droppable},dragOverlay:{...ri.dragOverlay,...e?.dragOverlay}}),[e?.draggable,e?.droppable,e?.dragOverlay])}function N2(e){let{activeNode:t,measure:n,initialRect:o,config:r=!0}=e;const s=u.useRef(!1),{x:a,y:l}=typeof r=="boolean"?{x:r,y:r}:r;rn(()=>{if(!a&&!l||!t){s.current=!1;return}if(s.current||!o)return;const c=t?.node.current;if(!c||c.isConnected===!1)return;const p=n(c),f=Op(p,o);if(a||(f.x=0),l||(f.y=0),s.current=!0,Math.abs(f.x)>0||Math.abs(f.y)>0){const y=jp(c);y&&y.scrollBy({top:f.y,left:f.x})}},[t,a,l,o,n])}const ii=u.createContext({...Ht,scaleX:1,scaleY:1});var gn;(function(e){e[e.Uninitialized=0]="Uninitialized",e[e.Initializing=1]="Initializing",e[e.Initialized=2]="Initialized"})(gn||(gn={}));const z2=u.memo(function(t){var n,o,r,s;let{id:a,accessibility:l,autoScroll:d=!0,children:c,sensors:p=C2,collisionDetection:f=W7,measuring:y,modifiers:g,...m}=t;const h=u.useReducer(A2,void 0,I2),[v,b]=h,[x,k]=$7(),[w,_]=u.useState(gn.Uninitialized),S=w===gn.Initialized,{draggable:{active:E,nodes:C,translate:N},droppable:{containers:$}}=v,A=E!=null?C.get(E):null,z=u.useRef({initial:null,translated:null}),D=u.useMemo(()=>{var J;return E!=null?{id:E,data:(J=A?.data)!=null?J:$2,rect:z}:null},[E,A]),O=u.useRef(null),[M,B]=u.useState(null),[T,H]=u.useState(null),L=Lo(m,Object.values(m)),I=Qr("DndDescribedBy",a),P=u.useMemo(()=>$.getEnabled(),[$]),F=D2(y),{droppableRects:G,measureDroppableContainers:V,measuringScheduled:Y}=g2(P,{dragging:S,dependencies:[N.x,N.y],config:F.droppable}),Z=p2(C,E),se=u.useMemo(()=>T?ei(T):null,[T]),re=ue(),U=h2(Z,F.draggable.measure);N2({activeNode:E!=null?C.get(E):null,config:re.layoutShiftCompensation,initialRect:U,measure:F.draggable.measure});const ee=Rp(Z,F.draggable.measure,U),fe=Rp(Z?Z.parentElement:null),K=u.useRef({activatorEvent:null,active:null,activeNode:Z,collisionRect:null,collisions:null,droppableRects:G,draggableNodes:C,draggingNode:null,draggingNodeRect:null,droppableContainers:$,over:null,scrollableAncestors:[],scrollAdjustedTranslate:null}),ne=$.getNodeFor((n=K.current.over)==null?void 0:n.id),me=S2({measure:F.dragOverlay.measure}),Ce=(o=me.nodeRef.current)!=null?o:Z,ze=S?(r=me.rect)!=null?r:ee:null,Se=!!(me.nodeRef.current&&me.rect),qe=m2(Se?null:ee),De=nf(Ce?_t(Ce):null),Pe=v2(S?ne??Z:null),Qe=k2(Pe),Me=lf(g,{transform:{x:N.x-qe.x,y:N.y-qe.y,scaleX:1,scaleY:1},activatorEvent:T,active:D,activeNodeRect:ee,containerNodeRect:fe,draggingNodeRect:ze,over:K.current.over,overlayNodeRect:me.rect,scrollableAncestors:Pe,scrollableAncestorRects:Qe,windowRect:De}),Re=se?lo(se,N):null,ve=_2(Pe),te=tf(ve),he=tf(ve,[ee]),Q=lo(Me,te),ie=ze?V7(ze,Me):null,_e=D&&ie?f({active:D,collisionRect:ie,droppableRects:G,droppableContainers:P,pointerCoordinates:Re}):null,ge=O7(_e,"id"),[Ae,Ne]=u.useState(null),Le=Se?Me:lo(Me,he),q=F7(Le,(s=Ae?.rect)!=null?s:null,ee),ae=u.useRef(null),pe=u.useCallback((J,le)=>{let{sensor:Ie,options:dt}=le;if(O.current==null)return;const pt=C.get(O.current);if(!pt)return;const ot=J.nativeEvent,Oe=new Ie({active:O.current,activeNode:pt,event:ot,options:dt,context:K,onAbort(Ye){if(!C.get(Ye))return;const{onDragAbort:Dt}=L.current,Nt={id:Ye};Dt?.(Nt),x({type:"onDragAbort",event:Nt})},onPending(Ye,Tt,Dt,Nt){if(!C.get(Ye))return;const{onDragPending:xn}=L.current,Zt={id:Ye,constraint:Tt,initialCoordinates:Dt,offset:Nt};xn?.(Zt),x({type:"onDragPending",event:Zt})},onStart(Ye){const Tt=O.current;if(Tt==null)return;const Dt=C.get(Tt);if(!Dt)return;const{onDragStart:Nt}=L.current,_n={activatorEvent:ot,active:{id:Tt,data:Dt.data,rect:z}};Sn.unstable_batchedUpdates(()=>{Nt?.(_n),_(gn.Initializing),b({type:it.DragStart,initialCoordinates:Ye,active:Tt}),x({type:"onDragStart",event:_n}),B(ae.current),H(ot)})},onMove(Ye){b({type:it.DragMove,coordinates:Ye})},onEnd:He(it.DragEnd),onCancel:He(it.DragCancel)});ae.current=Oe;function He(Ye){return async function(){const{active:Dt,collisions:Nt,over:_n,scrollAdjustedTranslate:xn}=K.current;let Zt=null;if(Dt&&xn){const{cancelDrop:jn}=L.current;Zt={activatorEvent:ot,active:Dt,collisions:Nt,delta:xn,over:_n},Ye===it.DragEnd&&typeof jn=="function"&&await Promise.resolve(jn(Zt))&&(Ye=it.DragCancel)}O.current=null,Sn.unstable_batchedUpdates(()=>{b({type:Ye}),_(gn.Uninitialized),Ne(null),B(null),H(null),ae.current=null;const jn=Ye===it.DragEnd?"onDragEnd":"onDragCancel";if(Zt){const an=L.current[jn];an?.(Zt),x({type:jn,event:Zt})}})}}},[C]),$e=u.useCallback((J,le)=>(Ie,dt)=>{const pt=Ie.nativeEvent,ot=C.get(dt);if(O.current!==null||!ot||pt.dndKit||pt.defaultPrevented)return;const Oe={active:ot};J(Ie,le.options,Oe)===!0&&(pt.dndKit={capturedBy:le.sensor},O.current=dt,pe(Ie,le))},[C,pe]),be=f2(p,$e);x2(p),rn(()=>{ee&&w===gn.Initializing&&_(gn.Initialized)},[ee,w]),u.useEffect(()=>{const{onDragMove:J}=L.current,{active:le,activatorEvent:Ie,collisions:dt,over:pt}=K.current;if(!le||!Ie)return;const ot={active:le,activatorEvent:Ie,collisions:dt,delta:{x:Q.x,y:Q.y},over:pt};Sn.unstable_batchedUpdates(()=>{J?.(ot),x({type:"onDragMove",event:ot})})},[Q.x,Q.y]),u.useEffect(()=>{const{active:J,activatorEvent:le,collisions:Ie,droppableContainers:dt,scrollAdjustedTranslate:pt}=K.current;if(!J||O.current==null||!le||!pt)return;const{onDragOver:ot}=L.current,Oe=dt.get(ge),He=Oe&&Oe.rect.current?{id:Oe.id,rect:Oe.rect.current,data:Oe.data,disabled:Oe.disabled}:null,Ye={active:J,activatorEvent:le,collisions:Ie,delta:{x:pt.x,y:pt.y},over:He};Sn.unstable_batchedUpdates(()=>{Ne(He),ot?.(Ye),x({type:"onDragOver",event:Ye})})},[ge]),rn(()=>{K.current={activatorEvent:T,active:D,activeNode:Z,collisionRect:ie,collisions:_e,droppableRects:G,draggableNodes:C,draggingNode:Ce,draggingNodeRect:ze,droppableContainers:$,over:Ae,scrollableAncestors:Pe,scrollAdjustedTranslate:Q},z.current={initial:ze,translated:ie}},[D,Z,_e,ie,C,Ce,ze,G,$,Ae,Pe,Q]),d2({...re,delta:N,draggingRect:ie,pointerCoordinates:Re,scrollableAncestors:Pe,scrollableAncestorRects:Qe});const j=u.useMemo(()=>({active:D,activeNode:Z,activeNodeRect:ee,activatorEvent:T,collisions:_e,containerNodeRect:fe,dragOverlay:me,draggableNodes:C,droppableContainers:$,droppableRects:G,over:Ae,measureDroppableContainers:V,scrollableAncestors:Pe,scrollableAncestorRects:Qe,measuringConfiguration:F,measuringScheduled:Y,windowRect:De}),[D,Z,ee,T,_e,fe,me,C,$,G,Ae,V,Pe,Qe,F,Y,De]),oe=u.useMemo(()=>({activatorEvent:T,activators:be,active:D,activeNodeRect:ee,ariaDescribedById:{draggable:I},dispatch:b,draggableNodes:C,over:Ae,measureDroppableContainers:V}),[T,be,D,ee,b,I,C,Ae,V]);return u.createElement(Pp.Provider,{value:k},u.createElement(Ko.Provider,{value:oe},u.createElement(af.Provider,{value:j},u.createElement(ii.Provider,{value:q},c)),u.createElement(T2,{disabled:l?.restoreFocus===!1})),u.createElement(A7,{...l,hiddenTextDescribedById:I}));function ue(){const J=M?.autoScrollEnabled===!1,le=typeof d=="object"?d.enabled===!1:d===!1,Ie=S&&!J&&!le;return typeof d=="object"?{...d,enabled:Ie}:{enabled:Ie}}}),M2=u.createContext(null),df="button",L2="Draggable";function P2(e){let{id:t,data:n,disabled:o=!1,attributes:r}=e;const s=Qr(L2),{activators:a,activatorEvent:l,active:d,activeNodeRect:c,ariaDescribedById:p,draggableNodes:f,over:y}=u.useContext(Ko),{role:g=df,roleDescription:m="draggable",tabIndex:h=0}=r??{},v=d?.id===t,b=u.useContext(v?ii:M2),[x,k]=Jr(),[w,_]=Jr(),S=w2(a,t),E=Lo(n);rn(()=>(f.set(t,{id:t,key:s,node:x,activatorNode:w,data:E}),()=>{const N=f.get(t);N&&N.key===s&&f.delete(t)}),[f,t]);const C=u.useMemo(()=>({role:g,tabIndex:h,"aria-disabled":o,"aria-pressed":v&&g===df?!0:void 0,"aria-roledescription":m,"aria-describedby":p.draggable}),[o,g,h,v,m,p.draggable]);return{active:d,activatorEvent:l,activeNodeRect:c,attributes:C,isDragging:v,listeners:o?void 0:S,node:x,over:y,setNodeRef:k,setActivatorNodeRef:_,transform:b}}function O2(){return u.useContext(af)}const B2="Droppable",W2={timeout:25};function j2(e){let{data:t,disabled:n=!1,id:o,resizeObserverConfig:r}=e;const s=Qr(B2),{active:a,dispatch:l,over:d,measureDroppableContainers:c}=u.useContext(Ko),p=u.useRef({disabled:n}),f=u.useRef(!1),y=u.useRef(null),g=u.useRef(null),{disabled:m,updateMeasurementsFor:h,timeout:v}={...W2,...r},b=Lo(h??o),x=u.useCallback(()=>{if(!f.current){f.current=!0;return}g.current!=null&&clearTimeout(g.current),g.current=setTimeout(()=>{c(Array.isArray(b.current)?b.current:[b.current]),g.current=null},v)},[v]),k=oi({callback:x,disabled:m||!a}),w=u.useCallback((C,N)=>{k&&(N&&(k.unobserve(N),f.current=!1),C&&k.observe(C))},[k]),[_,S]=Jr(w),E=Lo(t);return u.useEffect(()=>{!k||!_.current||(k.disconnect(),f.current=!1,k.observe(_.current))},[_,k]),u.useEffect(()=>(l({type:it.RegisterDroppable,element:{id:o,key:s,disabled:n,node:_,rect:y,data:E}}),()=>l({type:it.UnregisterDroppable,key:s,id:o})),[o]),u.useEffect(()=>{n!==p.current.disabled&&(l({type:it.SetDroppableDisabled,id:o,key:s,disabled:n}),p.current.disabled=n)},[o,s,n,l]),{active:a,rect:y,isOver:d?.id===o,node:_,over:d,setNodeRef:S}}function H2(e){let{animation:t,children:n}=e;const[o,r]=u.useState(null),[s,a]=u.useState(null),l=Zr(n);return!n&&!o&&l&&r(l),rn(()=>{if(!s)return;const d=o?.key,c=o?.props.id;if(d==null||c==null){r(null);return}Promise.resolve(t(c,s)).then(()=>{r(null)})},[t,o,s]),u.createElement(u.Fragment,null,n,o?u.cloneElement(o,{ref:a}):null)}const F2={x:0,y:0,scaleX:1,scaleY:1};function K2(e){let{children:t}=e;return u.createElement(Ko.Provider,{value:sf},u.createElement(ii.Provider,{value:F2},t))}const V2={position:"fixed",touchAction:"none"},U2=e=>sa(e)?"transform 250ms ease":void 0,q2=u.forwardRef((e,t)=>{let{as:n,activatorEvent:o,adjustScale:r,children:s,className:a,rect:l,style:d,transform:c,transition:p=U2}=e;if(!l)return null;const f=r?c:{...c,scaleX:1,scaleY:1},y={...V2,width:l.width,height:l.height,top:l.top,left:l.left,transform:Oo.Transform.toString(f),transformOrigin:r&&o?z7(o,l):void 0,transition:typeof p=="function"?p(o):p,...d};return u.createElement(n,{className:a,style:y,ref:t},s)}),G2={duration:250,easing:"ease",keyframes:e=>{let{transform:{initial:t,final:n}}=e;return[{transform:Oo.Transform.toString(t)},{transform:Oo.Transform.toString(n)}]},sideEffects:(e=>t=>{let{active:n,dragOverlay:o}=t;const r={},{styles:s,className:a}=e;if(s!=null&&s.active)for(const[l,d]of Object.entries(s.active))d!==void 0&&(r[l]=n.node.style.getPropertyValue(l),n.node.style.setProperty(l,d));if(s!=null&&s.dragOverlay)for(const[l,d]of Object.entries(s.dragOverlay))d!==void 0&&o.node.style.setProperty(l,d);return a!=null&&a.active&&n.node.classList.add(a.active),a!=null&&a.dragOverlay&&o.node.classList.add(a.dragOverlay),function(){for(const[d,c]of Object.entries(r))n.node.style.setProperty(d,c);a!=null&&a.active&&n.node.classList.remove(a.active)}})({styles:{active:{opacity:"0"}}})};function Y2(e){let{config:t,draggableNodes:n,droppableContainers:o,measuringConfiguration:r}=e;return Xr((s,a)=>{if(t===null)return;const l=n.get(s);if(!l)return;const d=l.node.current;if(!d)return;const c=rf(a);if(!c)return;const{transform:p}=_t(a).getComputedStyle(a),f=Bp(p);if(!f)return;const y=typeof t=="function"?t:X2(t);return qp(d,r.draggable.measure),y({active:{id:s,data:l.data,node:d,rect:r.draggable.measure(d)},draggableNodes:n,dragOverlay:{node:a,rect:r.dragOverlay.measure(c)},droppableContainers:o,measuringConfiguration:r,transform:f})})}function X2(e){const{duration:t,easing:n,sideEffects:o,keyframes:r}={...G2,...e};return s=>{let{active:a,dragOverlay:l,transform:d,...c}=s;if(!t)return;const p={x:l.rect.left-a.rect.left,y:l.rect.top-a.rect.top},f={scaleX:d.scaleX!==1?a.rect.width*d.scaleX/l.rect.width:1,scaleY:d.scaleY!==1?a.rect.height*d.scaleY/l.rect.height:1},y={x:d.x-p.x,y:d.y-p.y,...f},g=r({...c,active:a,dragOverlay:l,transform:{initial:d,final:y}}),[m]=g,h=g[g.length-1];if(JSON.stringify(m)===JSON.stringify(h))return;const v=o?.({active:a,dragOverlay:l,...c}),b=l.node.animate(g,{duration:t,easing:n,fill:"forwards"});return new Promise(x=>{b.onfinish=()=>{v?.(),x()}})}}let cf=0;function J2(e){return u.useMemo(()=>{if(e!=null)return cf++,cf},[e])}const Z2=u.memo(e=>{let{adjustScale:t=!1,children:n,dropAnimation:o,style:r,transition:s,modifiers:a,wrapperElement:l="div",className:d,zIndex:c=999}=e;const{activatorEvent:p,active:f,activeNodeRect:y,containerNodeRect:g,draggableNodes:m,droppableContainers:h,dragOverlay:v,over:b,measuringConfiguration:x,scrollableAncestors:k,scrollableAncestorRects:w,windowRect:_}=O2(),S=u.useContext(ii),E=J2(f?.id),C=lf(a,{activatorEvent:p,active:f,activeNodeRect:y,containerNodeRect:g,draggingNodeRect:v.rect,over:b,overlayNodeRect:v.rect,scrollableAncestors:k,scrollableAncestorRects:w,transform:S,windowRect:_}),N=ba(y),$=Y2({config:o,draggableNodes:m,droppableContainers:h,measuringConfiguration:x}),A=N?v.setRef:void 0;return u.createElement(K2,null,u.createElement(H2,{animation:$},f&&E?u.createElement(q2,{key:E,id:f.id,ref:A,as:l,activatorEvent:p,adjustScale:t,className:d,transition:s,rect:N,style:{zIndex:c,...r},transform:C},n):null))});function Vo(e,t){return!t||!Array.isArray(t.draggable)?!1:t.draggable.includes(e)}function Q2({draggedComponentName:e,targetContainerName:t,nesting:n}){if(!n||!Vo(e,n))return!1;const o=n.containers;if(!o||typeof o!="object")return!1;const r=o[t];return!r||!Array.isArray(r.accepts)?!1:r.accepts.includes(e)}function uf(e,t){if(!t?.containers)return!1;const n=t.containers[e];return!!(n&&Array.isArray(n.accepts)&&n.accepts.length>0)}function R2(e,t,n,o,r){if(!n||n.length===0)return{parentPath:[],index:r};if(e&&uf(e,o))return{parentPath:n,index:t};const s=n.slice(0,-1),a=n[n.length-1]??0;return{parentPath:s,index:a+1}}function pf(e,t){return e===t||e.startsWith(`${t}.`)}function ff(e,t){if(e==="")return/^\d+$/.test(t)?Number(t):null;const n=`${e}.children.`;if(!t.startsWith(n))return null;const o=t.slice(n.length);return/^\d+$/.test(o)?Number(o):null}function eI(e,t){const n=[];return e.querySelectorAll("[data-editor-path]").forEach(o=>{if(o.hasAttribute("data-dnd-placeholder")||o.closest("[data-dnd-placeholder]"))return;const r=o.dataset.editorPath;typeof r=="string"&&n.push({path:t(r),domPath:r,rect:o.getBoundingClientRect()})}),n}const tI=16;function nI(e){const{frameEl:t,draggedPath:n,acceptsContainer:o,allowsNestSlot:r,includeContainer:s}=e,a=e.normalizeContainerPath??(g=>g),l=t.getBoundingClientRect(),d=eI(t,a),c=new Map;for(const g of d)c.has(g.path)||c.set(g.path,g.domPath);const p=a(n),f=new Set([""]);for(const g of d){const m=rI(g.path);m!==null&&f.add(m)}for(const g of d)f.add(g.path);const y=[];for(const g of f){const m=g==="",h=m?null:c.get(g)??g,v=m?t:t.querySelector(`[data-editor-path="${yf(h)}"]`);if(!v&&!m||!m&&pf(g,p))continue;const b=g;if(!m&&s&&!s(b))continue;const x=S=>{const E=c.get(S);if(!E||typeof window>"u")return!1;const C=t.querySelector(`[data-editor-path="${yf(E)}"]`);return C?(window.getComputedStyle(C).display??"").toLowerCase()==="contents":!1},k=d.filter(S=>ff(g,S.path)!==null).filter(S=>!pf(S.path,p)).filter(S=>!x(S.path)).map(S=>({origIndex:ff(g,S.path),rect:oI(S.rect,S.path,d)})).filter(S=>S.rect.width>0||S.rect.height>0).sort((S,E)=>S.origIndex-E.origIndex),w=Cp(m?t:v).kind,_=w==="flex_row_single"||w==="flex_row_wrap"||w==="grid_single_row"||w==="grid_2d";if(k.length>0){if(o(b)){k.forEach(E=>{y.push(gf(b,E.origIndex,E.rect,l,_,"before"))});const S=k[k.length-1];y.push(gf(b,S.origIndex+1,S.rect,l,_,"after"))}}else if(!m&&r(b)&&v){const S=v.getBoundingClientRect();S.width>0&&S.height>0&&y.push({id:`slot:${b}:0`,kind:"nest",containerPath:b,index:0,box:hf(S,l),orientation:"area"})}else m&&y.push({id:"slot::0",kind:"gap",containerPath:"",index:0,box:hf(l,l),orientation:"area"})}return y.filter(g=>io(g.box,t))}function gf(e,t,n,o,r,s){const a=tI;let l;if(r)l={left:(s==="before"?n.left:n.right)-a/2,top:n.top,width:a,height:n.height};else{const d=s==="before"?n.top:n.bottom;l={left:n.left,top:d-a/2,width:n.width,height:a}}return{id:`slot:${e}:${t}`,kind:"gap",containerPath:e,index:t,box:{left:l.left-o.left,top:l.top-o.top,width:l.width,height:l.height,scale:1},orientation:r?"vertical":"horizontal"}}function oI(e,t,n){if(e.width>0||e.height>0)return e;const o=`${t}.children.`;let r=1/0,s=1/0,a=-1/0,l=-1/0;for(const d of n)d.path.startsWith(o)&&(d.rect.width===0&&d.rect.height===0||(r=Math.min(r,d.rect.left),s=Math.min(s,d.rect.top),a=Math.max(a,d.rect.right),l=Math.max(l,d.rect.bottom)));return r===1/0?e:{left:r,top:s,width:a-r,height:l-s,right:a,bottom:l,x:r,y:s,toJSON:()=>({})}}function hf(e,t){return{left:e.left-t.left,top:e.top-t.top,width:e.width,height:e.height,scale:1}}function rI(e){if(e==="")return null;const t=e.lastIndexOf(".children.");return t<0?"":e.slice(0,t)}function iI(e){if(!e.startsWith("slot:"))return null;const t=e.slice(5),n=t.lastIndexOf(":");if(n<0)return null;const o=t.slice(0,n),r=Number(t.slice(n+1));return Number.isFinite(r)?{containerPath:o,index:r}:null}function yf(e){return typeof CSS<"u"&&typeof CSS.escape=="function"?CSS.escape(e):e.replace(/[^a-zA-Z0-9_\-.]/g,t=>`\\${t}`)}function ma(e){return typeof e=="string"&&e.length>0?e:null}function va(e,t){return t&&e.startsWith(`${t}.`)?t:e}function bf(e){if(typeof e!="string")return null;const t=iI(e);return t?{containerPath:t.containerPath,index:t.index}:null}function _a(e,t){return t?e.startsWith(`${t}.iteration.`):!1}function xa(e,t,n){return t?n==="iteration_item"?_a(e,t):e===t||e.startsWith(`${t}.`):!1}function sI(e){const{frameEl:t,nesting:n,editMode:o,currentExtensionId:r,editableRootPath:s,components:a,patchLayout:l,pushHistory:d,onMovePath:c,selectedPath:p,showBranchBoundaryHint:f}=e,y=(o==="iteration_item"||o==="modal")&&s&&s.length>0?at(s):null,[g,m]=u.useState(null),[h,v]=u.useState(null),[b,x]=u.useState(null),k=u.useRef({components:a,nesting:n,editMode:o,currentExtensionId:r,selectedPath:p,editableRootSourcePath:y});k.current={components:a,nesting:n,editMode:o,currentExtensionId:r,selectedPath:p,editableRootSourcePath:y};const w=u.useRef(null),_=u.useCallback(M=>({children:M}),[]),S=u.useCallback((M,B)=>{const T=We(_(M),B);return T?typeof T.name=="string"?T.name:typeof T.type=="string"?T.type:null:null},[_]),E=u.useCallback(M=>B=>{const T=k.current;if(B==="")return Vo(M,T.nesting);const H=Ze(B),L=We(_(T.components),H);if(!L)return!1;const I=typeof L.name=="string"?L.name:typeof L.type=="string"?L.type:"";if(!I)return!1;const P=!!T.editableRootSourcePath&&(B===T.editableRootSourcePath||B.startsWith(`${T.editableRootSourcePath}.`));if(T.editableRootSourcePath&&!P)return!1;if(!P){const F=wa(_(T.components),H);if(fn(L,T.editMode,T.currentExtensionId,F)!=="none")return!1}return uf(I,T.nesting)?Q2({draggedComponentName:M,targetContainerName:I,nesting:T.nesting}):!1},[_]),C=u.useCallback(M=>{if(M==="")return!0;const B=k.current,T=Ze(M),H=w.current;if(H&&H.length>0){const P=H.slice(0,-1);if(si(T,P))return!0}const L=We(_(B.components),T);return L?(typeof L.name=="string"?L.name:typeof L.type=="string"?L.type:"").length>0:!1},[_]),N=u.useCallback(M=>{const B=E(M),T=k.current,H=w.current,L=H?H.slice(0,-1):null,I=new Set;if(L)for(let V=L.length;V>=0;V--)I.add(ai(L.slice(0,V)));const P=ai(H??[]),F=V=>!!H&&(V===P||V.startsWith(`${P}.`)),G=(V,Y)=>{for(let Z=0;ZV===""?!0:F(V)?!1:I.has(V),allowsNestSlot:V=>{if(!B(V))return!1;if(L){const Y=Ze(V);if(si(Y,L))return!1}return C(V)}}},[E,C,_]),$=u.useCallback(M=>{const B=ma(M.active.id),T=k.current;if(!B)return;const H=va(B,T.selectedPath),L=Ze(H),I=We(_(T.components),L),P=I&&typeof I.name=="string"?I.name:I&&typeof I.type=="string"?I.type:null;m(H),v(P),x(null),w.current=I?L:null;let F="allowed";if(!P||!Vo(P,T.nesting))F="denied_no_draggable";else if(I){const G=wa(_(T.components),L),V=fn(I,T.editMode,T.currentExtensionId,G);T.editableRootSourcePath&&!xa(H,T.editableRootSourcePath,T.editMode)||Gi(G)&&!_a(H,T.editableRootSourcePath)?F="denied_data_bound":V==="base"||V==="partial"?F="denied_base_locked":(V==="extension"||V==="extension_point")&&(F="denied_extension_locked")}Cn({source:"drag",draggedComponentName:P,targetContainerName:null,targetContainerPath:null,decision:F,result:F==="allowed"?"completed":"denied",timestamp:Date.now()})},[_]),A=u.useCallback(M=>{if(!t)return;const B=ma(M.active.id);if(!B)return;const T=h;if(!T){x(null);return}const H=k.current,L=va(B,H.selectedPath),I=Ze(L),P=We(_(H.components),I);if(!P||!Vo(T,H.nesting)){x(null);return}const F=wa(_(H.components),I);if(H.editableRootSourcePath&&!xa(L,H.editableRootSourcePath,H.editMode)){x(null);return}const G=fn(P,H.editMode,H.currentExtensionId,F),V=_a(L,H.editableRootSourcePath);if(Gi(F)&&!V||G!=="none"&&G!=="data_bound"&&!V){x(null);return}x(bf(M.over?.id))},[t,h,_]),z=u.useCallback(()=>{m(null),v(null),x(null),w.current=null},[]),D=u.useCallback(M=>{const B=ma(M.active.id),T=h,H=k.current,L=B?va(B,H.selectedPath):null;if(!t||!L||!T){z();return}const I=bf(M.over?.id),P=Ze(L);if(!I){Cn({source:"drag",draggedComponentName:T,targetContainerName:null,targetContainerPath:null,decision:"denied_no_accepts",result:"cancelled",timestamp:Date.now()}),z();return}const F=I.containerPath===""?[]:Ze(I.containerPath),G=I.containerPath===""?null:S(H.components,F);if(!aI(P.slice(0,-1),F)){Cn({source:"drag",draggedComponentName:T,targetContainerName:G,targetContainerPath:I.containerPath||null,decision:"denied_responsive_branch_boundary",result:"denied",timestamp:Date.now()}),f?.(),z();return}if(H.editableRootSourcePath&&!xa(I.containerPath,H.editableRootSourcePath,H.editMode)){Cn({source:"drag",draggedComponentName:T,targetContainerName:G,targetContainerPath:I.containerPath||null,decision:"denied_data_bound",result:"denied",timestamp:Date.now()}),z();return}const V=P.slice(0,-1),Y=P[P.length-1]??-1;if(si(V,F)&&(I.index===Y||I.index===Y+1)){Cn({source:"drag",draggedComponentName:T,targetContainerName:G,targetContainerPath:I.containerPath||null,decision:"allowed",insertionIndex:I.index,result:"cancelled",timestamp:Date.now()}),z();return}const re=si(V,F),U=I.index;let ee=[];l(me=>{const Se=Cb({children:me},P,F,U).children??[];return ee=Se,Se}),d(ee,`move ${T}`);const fe=od(F,P),K=re&&Y{z()},[z]);return{activeDragPath:g,activeDragName:h,activeDropZone:b,onDragStart:$,onDragMove:A,onDragEnd:D,onDragCancel:O,buildSlotPredicates:N}}function wa(e,t){const n=[];let o=e,r=Array.isArray(e.children)?e.children:[];for(let s=0;s0?at(s):null,{t:m}=tt(),[h,v]=u.useState(!1),b=u.useRef(null),x=u.useCallback(()=>{v(!0),b.current&&clearTimeout(b.current),b.current=setTimeout(()=>v(!1),2600)},[]);u.useEffect(()=>()=>{b.current&&clearTimeout(b.current)},[]);const k=sI({frameEl:t,nesting:n,editMode:o,currentExtensionId:r,editableRootPath:s,components:a,patchLayout:l,pushHistory:d,onMovePath:p,selectedPath:y,showBranchBoundaryHint:x}),w=D7(T7(fa,{activationConstraint:{distance:8}})),[_,S]=u.useState([]),E=u.useCallback(()=>{if(!t){S([]);return}if(k.activeDragPath!==null)return;const L={children:a},I=t.querySelectorAll("[data-editor-path]"),P=[],F=new Map;I.forEach(G=>{const V=G.dataset.editorPath;if(typeof V!="string"||o!=="iteration_item"&&g&&!cI(V,g,o))return;const Y=lI(V);if(Y!==null&&!Uo(V,g)){if(/\.iteration\.\d+$/.test(V)){const K=ro(G,t);if(K&&io(K,t)){const ne=F.get(Y)??[];ne.push(K),F.set(Y,ne)}}return}if(Uo(V,g)&&/\.iteration\.\d+$/.test(V))return;const Z=Ze(V),se=We(L,Z);if(!se||se.iteration&&se.iteration.source!==void 0)return;const re=typeof se.name=="string"?se.name:typeof se.type=="string"?se.type:null;if(!re||!Vo(re,n))return;const U=vf(L,Z);if(Gi(U)&&!Uo(V,g))return;const ee=fn(se,o,r,U);if(ee!=="none"&&ee!=="data_bound"&&!Uo(V,g))return;const fe=ro(G,t);fe&&io(fe,t)&&P.push({path:V,name:re,box:fe})});for(const[G,V]of F){const Y=Ze(G),Z=We(L,Y);if(!Z)continue;const se=typeof Z.name=="string"?Z.name:typeof Z.type=="string"?Z.type:null;if(!se)continue;const re=vf(L,Y),U=fn(Z,o,r,re);if(U!=="none"&&U!=="data_bound")continue;const ee=uI(V);ee&&io(ee,t)&&P.push({path:G,name:se,box:ee,isIterationGroup:!0})}S(P)},[t,a,n,o,r,g,k.activeDragPath]);u.useEffect(()=>{E()},[E]),u.useEffect(()=>{if(t)return _p(E,t)},[t,E]);const C=k.activeDragPath!==null,N=k.activeDragName,$=k.activeDragPath,[A,z]=u.useState(null),D=u.useRef(null);u.useEffect(()=>{if(!$||!t){$||(D.current=null,z(null));return}if(D.current===$)return;D.current=$;const L=t.querySelector(`[data-editor-path="${mf($)}"]`);if(L){const re=L.getBoundingClientRect();z({node:L.cloneNode(!0),width:re.width,height:re.height});return}const I=Array.from(t.querySelectorAll(`[data-editor-path^="${mf($)}.iteration."]`)).filter(re=>/\.iteration\.\d+$/.test(re.dataset.editorPath??""));if(I.length===0){z(null);return}let P=1/0,F=1/0,G=-1/0,V=-1/0;for(const re of I){const U=re.getBoundingClientRect();P=Math.min(P,U.left),F=Math.min(F,U.top),G=Math.max(G,U.right),V=Math.max(V,U.bottom)}const Y=G-P,Z=V-F,se=document.createElement("div");se.style.position="relative",se.style.width=`${Y}px`,se.style.height=`${Z}px`;for(const re of I){const U=re.getBoundingClientRect(),ee=re.cloneNode(!0);ee.style.position="absolute",ee.style.left=`${U.left-P}px`,ee.style.top=`${U.top-F}px`,ee.style.width=`${U.width}px`,ee.style.height=`${U.height}px`,ee.style.margin="0",se.appendChild(ee)}z({node:se,width:Y,height:Z})},[$,t]);const[O,M]=u.useState([]),B=u.useRef(null),T=u.useRef(k.buildSlotPredicates);if(T.current=k.buildSlotPredicates,u.useEffect(()=>{if(!$||!N||!t){B.current=null,M(F=>F.length===0?F:[]);return}if(B.current===$)return;B.current=$;const{acceptsContainer:L,allowsNestSlot:I,includeContainer:P}=T.current(N);M(nI({frameEl:t,draggedPath:$,acceptsContainer:L,allowsNestSlot:I,includeContainer:P,normalizeContainerPath:F=>dI(F)}))},[$,N,t]),!t)return null;const H=k.activeDropZone?`slot:${k.activeDropZone.containerPath}:${k.activeDropZone.index}`:null;return i.jsxs(z2,{sensors:w,collisionDetection:H7,onDragStart:k.onDragStart,onDragMove:k.onDragMove,onDragEnd:k.onDragEnd,onDragCancel:k.onDragCancel,children:[_.map(L=>i.jsx(hI,{path:L.path,name:L.name,box:L.box,dimmed:C&&k.activeDragPath!==L.path,isIterationGroup:L.isIterationGroup,onSelect:c,onRequestInlineEdit:f},L.path)),C&&O.map(L=>i.jsx(gI,{slot:L,active:L.id===H},L.id)),Sn.createPortal(i.jsx(Z2,{dropAnimation:null,zIndex:Tp,children:N?A?i.jsx(fI,{name:N,node:A.node,width:A.width,height:A.height}):i.jsx("div",{className:"g7le-dnd-drag-ghost","data-testid":"g7le-dnd-drag-ghost",style:{padding:"4px 10px",background:"rgba(79,70,229,0.95)",color:"#fff",fontSize:12,fontWeight:600,borderRadius:4,pointerEvents:"none",whiteSpace:"nowrap"},children:`<${N}>`}):null}),document.body),h&&Sn.createPortal(i.jsxs("div",{"data-testid":"g7le-branch-boundary-hint",role:"status",style:{position:"absolute",left:"50%",top:12,transform:"translateX(-50%)",background:"rgba(245,158,11,0.97)",color:"#fff",fontSize:13,fontWeight:600,padding:"8px 16px",borderRadius:8,boxShadow:"0 4px 14px rgba(0,0,0,0.2)",pointerEvents:"none",whiteSpace:"nowrap",zIndex:Tp},children:["⚠️ ",m("layout_editor.overlay.branch_boundary_denied")]}),t)]})}function fI(e){const{node:t,width:n,height:o}=e,r=u.useRef(null);return u.useEffect(()=>{const s=r.current;if(s)return s.replaceChildren(),s.appendChild(t.cloneNode(!0)),()=>{s.replaceChildren()}},[t]),i.jsx("div",{ref:r,className:"g7le-dnd-drag-ghost","data-testid":"g7le-dnd-drag-ghost",style:{width:n,height:o,opacity:.7,pointerEvents:"none",boxShadow:"0 6px 18px rgba(0,0,0,0.25)",borderRadius:4,overflow:"hidden",cursor:"grabbing"}})}function gI(e){const{slot:t,active:n}=e,{setNodeRef:o}=j2({id:t.id}),r=t.orientation!=="area";return i.jsx("div",{ref:o,"data-testid":`g7le-dnd-slot-${t.id}`,"data-dnd-slot-id":t.id,style:{position:"absolute",left:t.box.left,top:t.box.top,width:t.box.width,height:t.box.height,pointerEvents:"auto",zIndex:s7,boxSizing:"border-box",...r?{background:n?"rgba(79,70,229,0.15)":"transparent",...n?t.orientation==="vertical"?{borderLeft:"2px solid #4f46e5"}:{borderTop:"2px solid #4f46e5"}:{}}:{border:n?"2px dashed #4f46e5":"1px dashed transparent",background:n?"rgba(79,70,229,0.08)":"transparent",borderRadius:4}}})}function mf(e){return typeof CSS<"u"&&typeof CSS.escape=="function"?CSS.escape(e):e.replace(/[^a-zA-Z0-9_\-.]/g,t=>`\\${t}`)}function hI(e){const{path:t,box:n,dimmed:o,isIterationGroup:r,onSelect:s,onRequestInlineEdit:a}=e,{attributes:l,listeners:d,setNodeRef:c,isDragging:p}=P2({id:t}),f=(t.match(/\.children\./g)||[]).length,y=a7(f);return i.jsx("div",{ref:c,"data-testid":`g7le-dnd-handle-${t}`,"data-dnd-handle-path":t,"data-dnd-iteration-group":r?"true":void 0,...d,...l,onClick:g=>{if(g.stopPropagation(),!r&&typeof document<"u"&&typeof document.elementsFromPoint=="function"){const m=document.elementsFromPoint(g.clientX,g.clientY);for(const h of m){const b=h.closest?.("[data-editor-path]")?.dataset?.editorPath;if(typeof b=="string"&&(b===t||b.startsWith(`${t}.`))){s(b);return}}}s(t)},onDoubleClick:g=>{g.stopPropagation(),g.preventDefault(),a?.(t)},style:{position:"absolute",left:n.left,top:n.top,width:n.width,height:n.height,cursor:"grab",pointerEvents:"auto",background:p?"rgba(79,70,229,0.06)":"transparent",...r?{border:"1px dashed rgba(79,70,229,0.5)",borderRadius:4}:{},opacity:o?.6:1,zIndex:y,touchAction:"none"}})}function vf(e,t){const n=[];let o=e,r=Array.isArray(e.children)?e.children:[];for(let s=0;s0?[o]:[];const r=[],s=(a,l)=>{a.forEach((d,c)=>{const p=[...l,c];if(!Yi(d,t,n)){r.push(p);return}const y=Array.isArray(d.children)?d.children:[];y.length>0&&s(y,p)})};return s(e,[]),r}function bI(e){return at(e)}function li(e){return typeof CSS<"u"&&typeof CSS.escape=="function"?CSS.escape(e):e.replace(/["\\]/g,"\\$&")}function mI({frameEl:e,components:t,editMode:n,currentExtensionId:o,editableRootPath:r}){const[s,a]=u.useState(null),[l]=u.useState(()=>`g7le-dim-mask-${Math.floor(Math.random()*1e9)}`);if(u.useEffect(()=>{if(!e){a(null);return}let g=0;const m=()=>{const x=yI(t,n,o,r);if(x.length===0){a(null);return}const k=e.getBoundingClientRect(),w={left:0,top:0,width:k.width,height:k.height},_=[];for(const S of x){const E=bI(S),C=e.querySelector(`[data-editor-path="${li(E)}"]`);let N=null;if(C){const $=C.getBoundingClientRect();$.width>0&&$.height>0&&(N={left:$.left,top:$.top,width:$.width,height:$.height})}if(!N){const $=Array.from(e.querySelectorAll(`[data-editor-path^="${li(E)}.iteration."], [data-editor-path^="${li(E)}.sortable."], [data-editor-path^="${li(E)}.children."]`));let A=1/0,z=1/0,D=-1/0,O=-1/0;for(const M of $){const B=M.getBoundingClientRect();B.width<=0||B.height<=0||(A=Math.min(A,B.left),z=Math.min(z,B.top),D=Math.max(D,B.right),O=Math.max(O,B.bottom))}if(D>A&&O>z&&(N={left:A,top:z,width:D-A,height:O-z}),N&&$.length>0){const M=$[0].closest('[role="dialog"]');if(M&&e.contains(M)){const B=M.getBoundingClientRect();B.width>0&&B.height>0&&(N={left:B.left,top:B.top,width:B.width,height:B.height})}}}N&&_.push({left:N.left-k.left,top:N.top-k.top,width:N.width,height:N.height})}if(_.length===0){a(null);return}a({frame:w,holes:_})},h=()=>{g&&cancelAnimationFrame(g),g=requestAnimationFrame(m)};h();const v=typeof ResizeObserver<"u"?new ResizeObserver(h):null;v?.observe(e);const b=typeof MutationObserver<"u"?new MutationObserver(h):null;return b?.observe(e,{childList:!0,subtree:!0,attributes:!0}),window.addEventListener("scroll",h,!0),window.addEventListener("resize",h),()=>{g&&cancelAnimationFrame(g),v?.disconnect(),b?.disconnect(),window.removeEventListener("scroll",h,!0),window.removeEventListener("resize",h)}},[e,t,n,o,r?.join(".")??""]),!s||s.holes.length===0)return null;const{frame:d,holes:c}=s,p=n==="modal"||n==="iteration_item";let f=[];if(p){let g=1/0,m=1/0,h=-1/0,v=-1/0;for(const S of c)g=Math.min(g,S.left),m=Math.min(m,S.top),h=Math.max(h,S.left+S.width),v=Math.max(v,S.top+S.height);const b=(S,E,C)=>Math.max(E,Math.min(C,S)),x=b(g,0,d.width),k=b(m,0,d.height),w=b(h,0,d.width),_=b(v,0,d.height);f=[{left:0,top:0,width:d.width,height:k},{left:0,top:_,width:d.width,height:d.height-_},{left:0,top:k,width:x,height:_-k},{left:w,top:k,width:d.width-w,height:_-k}].filter(S=>S.width>0&&S.height>0)}const y=g=>{g.preventDefault(),g.stopPropagation()};return i.jsxs(i.Fragment,{children:[f.map((g,m)=>i.jsx("div",{className:"g7le-source-lock-block","data-testid":"g7le-source-lock-block",onClickCapture:y,onPointerDownCapture:y,onMouseDownCapture:y,onDragStartCapture:y,style:{position:"absolute",left:g.left,top:g.top,width:g.width,height:g.height,zIndex:Ap,pointerEvents:"auto",cursor:"not-allowed",background:"transparent"}},m)),i.jsxs("svg",{className:"g7le-source-lock-dim","data-testid":"g7le-source-lock-dim",width:d.width,height:d.height,style:{position:"absolute",left:0,top:0,width:d.width,height:d.height,zIndex:Ap,pointerEvents:"none"},children:[i.jsx("defs",{children:i.jsxs("mask",{id:l,children:[i.jsx("rect",{x:0,y:0,width:d.width,height:d.height,fill:"white"}),c.map((g,m)=>i.jsx("rect",{"data-testid":"g7le-source-lock-dim-hole",x:g.left,y:g.top,width:g.width,height:g.height,rx:4,fill:"black"},m))]})}),i.jsx("rect",{"data-testid":"g7le-source-lock-dim-box",x:0,y:0,width:d.width,height:d.height,fill:"rgba(15, 23, 42, 0.45)",mask:`url(#${l})`})]})]})}function _f(e){const t={type:e.type??"basic",name:e.name},n={};if(e.props)for(const[o,r]of Object.entries(e.props))o!=="children"&&r?.default!==void 0&&(n[o]=r.default);return Object.keys(n).length>0&&(t.props=n),t}function vI(e){if(!e?.components)return[];const t=[];for(const[n,o]of Object.entries(e.components))if(Array.isArray(o))for(const r of o)t.push({...r,type:r.type??n});return t}function _I(e,t,n,o,r){const s=vI(e);let a=null;if(n&&t?.containers){const l=t.containers[n];l&&Array.isArray(l.accepts)?a=new Set(l.accepts):a=new Set}else!n&&t?.draggable&&(a=new Set(t.draggable));return s.filter(l=>{if(a&&!a.has(l.name))return!1;if(o){const d=o.toLowerCase(),c=r(l).toLowerCase(),p=l.name.toLowerCase();if(!c.includes(d)&&!p.includes(d)&&!`<${p}>`.includes(d)&&!(l.description??"").toLowerCase().includes(d))return!1}return!0})}function xI(e,t){const n={key:"all",label:"layout_editor.palette.category.all",entries:e,isI18nKey:!0};if(t?.groups&&t.groups.length>0){const a=new Map(e.map(p=>[p.name,p])),l=new Set,d=[];for(const p of t.groups){const f=[];for(const y of p.components??[]){const g=a.get(y);g&&(f.push(g),l.add(y))}f.length!==0&&d.push({key:p.kind??p.label,label:p.label,entries:f,isI18nKey:p.label.startsWith("$t:")||/^[a-z_.]+$/i.test(p.label)})}const c=e.filter(p=>!l.has(p.name));return c.length>0&&d.push({key:"other",label:"layout_editor.palette.category.other",entries:c,isI18nKey:!0}),[n,...d]}const o=new Map;for(const a of e){const l=(a.type??"basic").toLowerCase(),d=o.get(l)??[];d.push(a),o.set(l,d)}const r=["basic","composite","layout"],s=[];for(const a of r){const l=o.get(a);l&&l.length>0&&(s.push({key:a,label:`layout_editor.palette.category.fallback.${a}`,entries:l,isI18nKey:!0}),o.delete(a))}for(const[a,l]of o.entries())s.push({key:a,label:a,entries:l,isI18nKey:!1});return[n,...s]}function xf(e){const{manifest:t,nesting:n,componentPalette:o,targetParentPath:r,targetContainerName:s,onInsert:a,insertionIndex:l,onClose:d,editorTemplateId:c,editorLocale:p}=e,{t:f,translationEngine:y}=tt(),g=u.useMemo(()=>{if(!c||!y)return f;const A=p??null;return z=>{const D={templateId:c,locale:A??"ko"},O=y.translate(z,D);return O!==z?O:f(z)}},[c,p,y,f]),[m,h]=u.useState(""),[v,b]=u.useState("all"),x=o?.entries??null,k=u.useMemo(()=>A=>{const z=x?.[A.name];return z?.label?di(z.label,g):A.description??A.name},[x,g]),w=u.useMemo(()=>_I(t,n,s,m,k),[t,n,s,m,k]),_=u.useMemo(()=>xI(w,o??null),[w,o]),S=_.find(A=>A.key===v)??_[0]??null,E=!n||!n.draggable&&!n.containers,C=w.length===0,N=S?.entries??[],$=A=>{const z=x?.[A.name],D=!!z?.defaultNode&&typeof z.defaultNode=="object";if(z?.requiresDefaultNode&&!D)return;const O=D?JSON.parse(JSON.stringify(z.defaultNode)):_f(A),M=r??[];a(O,M,l),Cn({source:"palette",draggedComponentName:A.name,targetContainerName:s,targetContainerPath:M.length===0?null:M.join("/"),decision:"allowed",insertionIndex:l,result:"completed",timestamp:Date.now()}),d()};return i.jsxs("div",{className:"g7le-palette","data-testid":"g7le-palette",style:CI,children:[i.jsxs("div",{style:$I,children:[i.jsx("strong",{style:{fontSize:16},children:f("layout_editor.palette.title")}),i.jsx("button",{type:"button",onClick:d,"aria-label":"close","data-testid":"g7le-palette-close",style:EI,children:"×"})]}),i.jsxs("div",{style:II,children:[i.jsx("aside",{style:AI,"data-testid":"g7le-palette-sidebar",children:i.jsx("nav",{children:i.jsx("ul",{style:TI,children:_.map(A=>{const z=S?.key===A.key,D=A.isI18nKey?di(A.label,g):A.label;return i.jsx("li",{children:i.jsxs("button",{type:"button","data-testid":`g7le-palette-category-${A.key}`,"data-active":z?"true":"false",onClick:()=>b(A.key),style:{...DI,background:z?"#eff6ff":"transparent",color:z?"#1d4ed8":"#334155",fontWeight:z?600:500},children:[i.jsx("span",{children:D}),i.jsx("span",{style:LI,children:A.entries.length})]})},A.key)})})})}),i.jsxs("main",{style:NI,children:[i.jsx("input",{type:"text",placeholder:f("layout_editor.palette.search_placeholder"),value:m,onChange:A=>h(A.target.value),"data-testid":"g7le-palette-search",style:zI}),i.jsx("div",{style:MI,children:E?i.jsx("div",{style:wf,"data-testid":"g7le-palette-spec-missing",children:f("layout_editor.palette.spec_missing")}):C||N.length===0?i.jsx("div",{style:wf,"data-testid":"g7le-palette-empty",children:f("layout_editor.palette.empty")}):i.jsx("div",{"data-testid":`g7le-palette-group-${S?.key??"all"}`,style:{marginBottom:16},children:i.jsx("div",{style:PI,children:N.map(A=>i.jsx(wI,{entry:A,entrySpec:x?.[A.name]??null,onClick:$,t:f,entryT:g},A.name))})})})]})]})]})}function wI(e){const{entry:t,entrySpec:n,onClick:o,t:r,entryT:s}=e,a=n?.label?di(n.label,s):t.description??t.name,l=(t.type??"").toLowerCase(),d=l==="basic"||l==="composite"||l==="layout",c=!!n?.defaultNode&&typeof n.defaultNode=="object",p=!!n?.requiresDefaultNode&&!c,f=!c,y=p?r("layout_editor.palette.requires_default_node_missing"):n?.label?di(n.label,s):t.name;return i.jsxs("button",{type:"button",onClick:()=>o(t),disabled:p,"aria-disabled":p||void 0,"data-testid":`g7le-palette-item-${t.name}`,"data-component-type":l||void 0,"data-blocked":p?"true":void 0,"data-default-node-missing":f?"true":void 0,title:y,style:p?BI:OI,children:[i.jsx("div",{style:jI,"aria-hidden":"true",children:SI(t)}),i.jsx("div",{style:HI,children:a}),i.jsx("div",{"data-testid":`g7le-palette-item-${t.name}-tag`,style:FI,children:`<${t.name}>`}),d&&i.jsx("div",{"data-testid":`g7le-palette-item-${t.name}-badge`,style:kI(l),children:l}),f&&i.jsx("div",{"data-testid":`g7le-palette-item-${t.name}-default-missing-badge`,style:WI,children:r("layout_editor.palette.default_node_missing_badge")})]})}function di(e,t){return e.startsWith("$t:")?t(e.slice(3)):t(e)}function kI(e){const n={basic:{bg:"#dbeafe",fg:"#1e40af"},composite:{bg:"#dcfce7",fg:"#14532d"},layout:{bg:"#fef3c7",fg:"#854d0e"}}[e]??{bg:"#e2e8f0",fg:"#334155"};return{fontSize:9,fontWeight:600,color:n.fg,background:n.bg,padding:"1px 6px",borderRadius:999,textTransform:"uppercase",letterSpacing:.4,lineHeight:1.6}}function SI(e){const t=e.name;return{Button:"🔘",A:"🔗",Img:"🖼",Icon:"✨",Hr:"➖",P:"📄",Span:"T",H1:"H₁",H2:"H₂",H3:"H₃",H4:"H₄",Ul:"☰",Li:"•",Nav:"🧭",Div:"▢",Container:"▢",Flex:"⇆",Grid:"▦",SectionLayout:"▤",ThreeColumnLayout:"⫼",Form:"📝",Input:"⎕",Textarea:"▭",Select:"⏷",Checkbox:"☑",PasswordInput:"🔒",Label:"🏷",DataGrid:"▦",Pagination:"↔",SearchBar:"🔍",ProductCard:"🛍",ImageGallery:"🖼",Avatar:"👤",UserInfo:"👥",HtmlContent:"⟨/⟩",HtmlEditor:"✏",RichTextEditor:"✏",TabNavigation:"◰",QuantitySelector:"#",ProductImageViewer:"🖼",FileUploader:"📎",AvatarUploader:"📷",SocialLoginButtons:"🌐",PostReactions:"❤"}[t]??"◻"}const CI={display:"flex",flexDirection:"column",width:"100%",height:"100%",minHeight:0,fontFamily:'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple SD Gothic Neo", "Noto Sans KR", sans-serif'},$I={display:"flex",justifyContent:"space-between",alignItems:"center",padding:"14px 20px",borderBottom:"1px solid #e2e8f0",flexShrink:0},EI={background:"transparent",border:"none",fontSize:24,cursor:"pointer",lineHeight:1,color:"#64748b",padding:"0 4px"},II={display:"flex",flex:1,minHeight:0},AI={width:180,flexShrink:0,borderRight:"1px solid #e2e8f0",padding:"14px 8px",background:"#f8fafc",overflowY:"auto"},TI={listStyle:"none",margin:0,padding:0,display:"flex",flexDirection:"column",gap:4},DI={display:"flex",width:"100%",alignItems:"center",justifyContent:"space-between",textAlign:"left",padding:"8px 12px",border:"none",borderRadius:6,fontSize:13,cursor:"pointer",transition:"background-color 120ms"},NI={flex:1,display:"flex",flexDirection:"column",minHeight:0,padding:"14px 20px"},zI={width:"100%",padding:"8px 10px",border:"1px solid #cbd5e1",borderRadius:6,marginBottom:12,fontSize:13,flexShrink:0,boxSizing:"border-box"},MI={flex:1,overflowY:"auto",minHeight:0},LI={fontSize:11,color:"#94a3b8",marginLeft:8,fontWeight:500},PI={display:"grid",gridTemplateColumns:"repeat(auto-fill, minmax(96px, 1fr))",gap:10},OI={display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",gap:6,padding:"14px 6px",background:"#fff",border:"1px solid #e2e8f0",borderRadius:8,cursor:"pointer",fontSize:12,minHeight:84,transition:"border-color 120ms, box-shadow 120ms"},BI={display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",gap:6,padding:"14px 6px",background:"#f8fafc",border:"1px dashed #cbd5e1",borderRadius:8,cursor:"not-allowed",fontSize:12,minHeight:84,color:"#94a3b8",opacity:.65},WI={fontSize:9,fontWeight:600,color:"#92400e",background:"#fef3c7",padding:"1px 6px",borderRadius:999,textTransform:"none",letterSpacing:0,lineHeight:1.6},jI={fontSize:22,lineHeight:1,color:"#475569"},HI={fontSize:12,fontWeight:500,color:"#0f172a",textAlign:"center",wordBreak:"break-word"},FI={fontFamily:'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',fontSize:10,color:"#64748b",textAlign:"center",wordBreak:"break-all"},wf={padding:24,color:"#64748b",fontSize:13,textAlign:"center"};function KI(e){const t=[],n=r=>{if(Array.isArray(r))for(const s of r)s&&typeof s=="object"&&t.push(s)};n(e.children);const o=e.responsive;if(o&&typeof o=="object")for(const r of Object.values(o))r&&typeof r=="object"&&n(r.children);return t}function kf(e){const t=[],n=o=>{const r="isolatedState"in o&&o.isolatedState!=null,s=o.isolatedScopeId;(r||typeof s=="string"&&s.length>0)&&t.push({scopeId:typeof s=="string"?s:void 0,initialState:o.isolatedState&&typeof o.isolatedState=="object"?o.isolatedState:void 0});for(const a of KI(o))n(a)};if(Array.isArray(e))for(const o of e)o&&typeof o=="object"&&n(o);return t}const VI=["scroll","selector","wizard","slider"];function UI(e,t){const n=new Set;for(const o of e)o.scopeId&&n.add(o.scopeId);for(const o of t){n.add(o);for(const r of VI)n.add(`${o}-${r}`)}return Array.from(n)}function qI(e,t,n){const o=t.label;return typeof o=="string"?o.startsWith("$t:")?n(o.slice(3)):o:e}function ci({controlKey:e,control:t,node:n,t:o,onPatch:r,candidates:s,bindingCandidates:a,scope:l=Wt}){const d=An(t.widget),c=qI(e,t,o),p=u.useMemo(()=>Io(n,t,l),[n,t,l]),f=t.widget==="i18n-text"&&t.apply?.type==="propValue",y=t.widget==="text"&&t.apply?.type==="propValue",g=p.darkReadonly===!0||l.colorScheme==="dark"&&t.apply!==void 0&&!Ks(t.apply),m=t.apply,h=l.colorScheme==="dark"&&!g&&m?.type==="classToken"&&typeof m.tokenTemplate=="string"&&m.tokenTemplate.includes("{value}"),v=p.scopedValue===void 0&&p.baseFallback!==void 0,b=u.useCallback(x=>{if(g)return;const k=pn(n,t,x,l);r(k)},[n,t,r,l,g]);return i.jsxs("div",{className:"g7le-control-row","data-testid":`g7le-control-${e}`,style:GI,children:[i.jsxs("span",{className:"g7le-control-label",style:YI,children:[c,p.conflict&&i.jsx("span",{"data-testid":`g7le-control-conflict-${e}`,title:o("layout_editor.property_modal.value_conflict"),style:XI,children:"!"})]}),i.jsx("div",{className:"g7le-control-widget",style:{flex:1,minWidth:0,opacity:v?.45:1},"data-placeholder":v?"true":void 0,children:f?i.jsx(vt,{value:typeof p.value=="string"?p.value:"",onChange:x=>b(x),t:o,testidPrefix:`g7le-prop-i18n-${e}`,candidates:a,enableExpressionTree:!0,expressionTreeCollapsible:!0}):y?i.jsx(Ve,{value:typeof p.value=="string"?p.value:"",onChange:x=>b(x===""?void 0:x),t:o,testidPrefix:`g7le-prop-value-${e}`,candidates:a}):g?i.jsx("span",{"data-testid":`g7le-control-dark-readonly-${e}`,style:ZI,children:o("layout_editor.property_modal.dark_code_only")}):d?i.jsx(d,{control:t,value:p.value,onChange:b,t:o,candidates:s,bindingCandidates:a,freeValueDisabled:h}):i.jsx("span",{"data-testid":`g7le-control-unsupported-${e}`,style:JI,children:o("layout_editor.property_modal.unsupported_widget")})})]})}const GI={display:"flex",alignItems:"center",gap:12,padding:"6px 0",minHeight:32,minWidth:0},YI={fontSize:12,color:"#475569",minWidth:88,display:"inline-flex",alignItems:"center",gap:4},XI={display:"inline-flex",width:14,height:14,borderRadius:7,background:"#f59e0b",color:"#fff",fontSize:10,alignItems:"center",justifyContent:"center",fontWeight:700},JI={fontSize:11,color:"#94a3b8",fontStyle:"italic"},ZI={fontSize:11,color:"#94a3b8",fontStyle:"italic"};function ui(e,t,n){return typeof e=="string"?e.startsWith("$t:")?n(e.slice(3)):e:t}function QI(e,t){const n=e.props??{};return t.key in n?n[t.key]:t.default}function RI({spec:e,node:t,t:n,onPatchProp:o}){const r=e.groups??[];return i.jsx("div",{className:"g7le-composite-settings","data-testid":"g7le-composite-settings",style:tA,children:r.map((s,a)=>i.jsxs("div",{className:"g7le-settings-group",style:nA,children:[i.jsx("div",{style:oA,children:ui(s.label,n("layout_editor.property_modal.settings_group",{index:a+1}),n)}),(s.fields??[]).map(l=>i.jsxs("div",{className:"g7le-settings-field","data-testid":`g7le-setting-${l.key}`,style:rA,children:[i.jsx("span",{style:iA,children:ui(l.label,l.key,n)}),i.jsx("div",{style:{flex:1},children:eA(l,t,n,o)})]},l.key))]},a))})}function eA(e,t,n,o){const r=QI(t,e);switch(e.type){case"number":return i.jsx("input",{type:"number","data-testid":`g7le-setting-input-${e.key}`,value:typeof r=="number"?r:"",min:e.min,max:e.max,onChange:s=>o(e.key,s.target.value===""?void 0:Number(s.target.value)),style:ka});case"text":return i.jsx("input",{type:"text","data-testid":`g7le-setting-input-${e.key}`,value:typeof r=="string"?r:"",onChange:s=>o(e.key,s.target.value===""?void 0:s.target.value),style:ka});case"toggle":return i.jsx("input",{type:"checkbox","data-testid":`g7le-setting-input-${e.key}`,checked:r===!0,onChange:s=>o(e.key,s.target.checked)});case"checkbox-group":{const s=Array.isArray(r)?r:[];return i.jsx("div",{style:sA,"data-testid":`g7le-setting-input-${e.key}`,children:(e.options??[]).map(a=>{const l=s.includes(a.value);return i.jsxs("label",{style:aA,children:[i.jsx("input",{type:"checkbox",checked:l,onChange:d=>{const c=d.target.checked?[...s,a.value]:s.filter(p=>p!==a.value);o(e.key,c)}}),ui(a.label,String(a.value),n)]},String(a.value))})})}case"select":case"board-select":case"datasource-select":default:return i.jsxs("select",{"data-testid":`g7le-setting-input-${e.key}`,value:r==null?"":String(r),onChange:s=>{const a=s.target.value;if(a===""){o(e.key,void 0);return}const l=(e.options??[]).find(d=>String(d.value)===a);o(e.key,l?l.value:a)},style:ka,children:[i.jsx("option",{value:"",children:"—"}),(e.options??[]).map(s=>i.jsx("option",{value:String(s.value),children:ui(s.label,String(s.value),n)},String(s.value)))]})}}const tA={display:"flex",flexDirection:"column",gap:16},nA={display:"flex",flexDirection:"column",gap:6},oA={fontSize:12,fontWeight:700,color:"#0f172a",borderBottom:"1px solid #e2e8f0",paddingBottom:4},rA={display:"flex",alignItems:"center",gap:12,minHeight:30},iA={fontSize:12,color:"#475569",minWidth:96},ka={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,width:"100%",boxSizing:"border-box"},sA={display:"flex",flexWrap:"wrap",gap:10},aA={display:"inline-flex",alignItems:"center",gap:4,fontSize:12,color:"#0f172a"},lA=["data_binding","isolatedScopeId","parentFormContextProp","skipBindingKeys","isolatedState","component_layout","sortable","itemTemplate"],dA=/\{\{|\$switch|\$get|\|\s*(date|number|truncate|currency|relative)\b/;function pi(e,t=0){return t>6?!1:typeof e=="string"?dA.test(e):Array.isArray(e)?e.some(n=>pi(n,t+1)):e&&typeof e=="object"?Object.values(e).some(n=>pi(n,t+1)):!1}function cA(e){if(Xl(e))return!1;const t=jl(e);return t.length===0?!1:t.some(n=>n.isComplex)}function Sf(e){const t=[],n=e.props??{};for(const o of lA)o in e&&e[o]!==void 0?t.push({key:o,kind:"developer_prop"}):o in n&&n[o]!==void 0&&t.push({key:`props.${o}`,kind:"developer_prop"});typeof e.text=="string"?cA(e.text)&&t.push({key:"text",kind:"complex_expression"}):pi(e.text)&&t.push({key:"text",kind:"complex_expression"});for(const[o,r]of Object.entries(n))o==="style"||o==="className"||pi(r)&&t.push({key:`props.${o}`,kind:"complex_expression"});return t}function uA(e){return Sf(e).length>0}const pA=new Set(["permissions","blur_until_loaded","comment"]);function fA({node:e,advanced:t,t:n,onPatch:o,permissionCandidates:r}){const s=u.useMemo(()=>Sf(e),[e]),a=(l,d)=>{const c={...e};d===void 0?delete c[l]:c[l]=d,o(c)};return i.jsxs("div",{className:"g7le-advanced-props","data-testid":"g7le-advanced-props",style:gA,children:[t.map(l=>{if(!pA.has(l))return i.jsxs("div",{"data-testid":`g7le-advanced-deferred-${l}`,style:yA,children:[n("layout_editor.property_modal.advanced_deferred"),":",l]},l);if(l==="permissions")return i.jsxs("div",{className:"g7le-advanced-group","data-testid":"g7le-advanced-permissions",style:Sa,children:[i.jsx("div",{style:Ca,children:n("layout_editor.property_modal.advanced.permissions")}),i.jsx(gs,{control:{widget:"tag-input"},value:e.permissions,onChange:d=>a("permissions",d),t:n,candidates:r})]},l);if(l==="blur_until_loaded"){const d=e.blur_until_loaded===!0||e.blur_until_loaded!=null&&e.blur_until_loaded!==!1;return i.jsxs("div",{className:"g7le-advanced-group","data-testid":"g7le-advanced-blur",style:Sa,children:[i.jsx("div",{style:Ca,children:n("layout_editor.property_modal.advanced.blur_until_loaded")}),i.jsxs("label",{style:{display:"inline-flex",alignItems:"center",gap:6,fontSize:12},children:[i.jsx("input",{type:"checkbox","data-testid":"g7le-advanced-blur-toggle",checked:!!d,onChange:c=>a("blur_until_loaded",c.target.checked?!0:void 0)}),n("layout_editor.property_modal.advanced.blur_hint")]})]},l)}return i.jsxs("div",{className:"g7le-advanced-group","data-testid":"g7le-advanced-comment",style:Sa,children:[i.jsx("div",{style:Ca,children:n("layout_editor.property_modal.advanced.comment")}),i.jsx("input",{type:"text","data-testid":"g7le-advanced-comment-input",value:typeof e.comment=="string"?e.comment:"",onChange:d=>a("comment",d.target.value===""?void 0:d.target.value),style:hA})]},l)}),s.length>0&&i.jsxs("div",{className:"g7le-advanced-preserved","data-testid":"g7le-advanced-preserved",style:bA,children:[i.jsx("div",{style:mA,children:n("layout_editor.property_modal.advanced_preserved_title",{count:s.length})}),i.jsx("ul",{style:vA,children:s.map(l=>i.jsxs("li",{"data-testid":`g7le-advanced-preserved-${l.key}`,style:_A,children:[i.jsx("code",{style:{fontSize:11},children:l.key}),i.jsx("span",{style:{fontSize:11,color:"#94a3b8"},children:l.kind==="developer_prop"?n("layout_editor.property_modal.advanced_kind_developer"):n("layout_editor.property_modal.advanced_kind_expression")})]},l.key))})]})]})}const gA={display:"flex",flexDirection:"column",gap:14},Sa={display:"flex",flexDirection:"column",gap:6},Ca={fontSize:12,fontWeight:700,color:"#0f172a"},hA={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,width:"100%",boxSizing:"border-box"},yA={fontSize:11,color:"#94a3b8",fontStyle:"italic"},bA={borderTop:"1px dashed #cbd5e1",paddingTop:10,marginTop:4},mA={fontSize:11,color:"#b45309",marginBottom:6},vA={listStyle:"none",margin:0,padding:0,display:"flex",flexDirection:"column",gap:4},_A={display:"flex",justifyContent:"space-between",gap:8,padding:"3px 0"},xA={onClick:"click",onHover:"mouseenter",onChange:"change",onSubmit:"submit",onMount:"mount",onUnmount:"unmount",onFocus:"focus",onBlur:"blur"};function $a(e){return xA[e]??e.replace(/^on/,"").toLowerCase()}function Cf(e){const t=e.actions;if(Array.isArray(t))return t.filter(r=>!!r&&typeof r=="object");const n=e.props??{};if(Array.isArray(n.actions))return n.actions.filter(r=>!!r&&typeof r=="object");const o=n.events;if(o&&typeof o=="object"){const r=[];for(const[s,a]of Object.entries(o)){if(!Array.isArray(a))continue;const l=$a(s);for(const d of a)d&&typeof d=="object"&&r.push("type"in d?d:{type:l,...d})}return r}return[]}function wA(e,t){const n=$a(t);return Cf(e).filter(o=>o.type===n||t==="onClick"&&o.type===void 0)}function kA(e,t,n){const o=$a(t),r=Cf(e).filter(d=>!(d.type===o||t==="onClick"&&d.type===void 0)),s=n.map(d=>"type"in d?d:{type:o,...d}),a={...e};if(a.props&&typeof a.props=="object"){const d={...a.props};let c=!1;"actions"in d&&(delete d.actions,c=!0),"events"in d&&(delete d.events,c=!0),c&&(a.props=d)}const l=[...r,...s];return l.length===0?delete a.actions:a.actions=l,a}function SA({node:e,spec:t,capability:n,t:o,onPatchNode:r,pageCandidates:s,dataSourceCandidates:a,stateKeyCandidates:l,bindingCandidates:d}){const c=t?.actionRecipes,p=u.useMemo(()=>({pageCandidates:s,dataSourceCandidates:a,stateKeyCandidates:l,bindingCandidates:d}),[s,a,l,d]),f=Array.isArray(n?.events)?n.events:[];return f.length===0?i.jsx("div",{"data-testid":"g7le-action-no-events",style:$f,children:o("layout_editor.action.no_events")}):!c||Object.keys(c).length===0?i.jsx("div",{"data-testid":"g7le-action-no-recipes",style:$f,children:o("layout_editor.action.no_recipes")}):i.jsx("div",{className:"g7le-action-editor","data-testid":"g7le-action-editor",style:{display:"flex",flexDirection:"column",gap:14},children:f.map(y=>i.jsxs("div",{className:"g7le-action-event-slot","data-testid":`g7le-action-event-${y}`,children:[i.jsx("div",{style:CA,children:o(`layout_editor.action.event.${y}`)}),i.jsx(Gt,{actions:wA(e,y),onChange:g=>r(kA(e,y,g)),t:o,recipes:c,candidatePools:p,actionChipCandidates:t?.actionChipCandidates??null,addContext:"component",showSourceBadge:!0,testIdPrefix:`g7le-action-slot-${y}`})]},y))})}const $f={fontSize:12,color:"#94a3b8",padding:"16px 0",textAlign:"center"},CA={fontSize:12,fontWeight:700,color:"#475569",marginBottom:6};function Ea(e,t,n){if(!t)return n;const o=e(t);return!o||o===t||o.startsWith("$t:")?n:o}function $A(e,t){return Ea(t,e.labelKey,e.path||e.sourceId)}function EA({node:e,spec:t,candidates:n,t:o,onPatchNode:r}){const a=(e.props??{})[t.propKey],l=typeof a=="string"?a:void 0,d=u.useMemo(()=>Kt(a),[a]),c=l!==void 0&&l!==""&&d===null,[p,f]=u.useState(!1),[y,g]=u.useState(""),m=Ea(o,t.label,t.propKey),h=o(t.shape==="array"?"layout_editor.binding.shape_array":t.shape==="object"?"layout_editor.binding.shape_object":"layout_editor.binding.shape_scalar"),v=u.useMemo(()=>ji(n,t.shape).map(_=>({..._,resolvedLabel:$A(_,o)})),[n,t.shape,o]),b=u.useMemo(()=>Hi(v,y),[v,y]),x=w=>{const _={...e.props??{}};w===void 0?delete _[t.propKey]:_[t.propKey]=w,r({...e,props:_})},k=w=>{x($n(w.sourceId,w.path,t.shape)),f(!1),g("")};return i.jsxs("div",{"data-testid":`g7le-binding-row-${t.propKey}`,style:DA,children:[i.jsxs("div",{style:NA,children:[i.jsx("span",{style:{fontWeight:600,fontSize:12},children:m}),i.jsx("span",{style:zA,children:h}),t.required&&i.jsx("span",{"data-testid":`g7le-binding-required-${t.propKey}`,style:MA,children:o("layout_editor.binding.required")})]}),c?i.jsxs("div",{"data-testid":`g7le-binding-complex-${t.propKey}`,style:BA,children:[o("layout_editor.binding.complex"),i.jsx("code",{style:Ef,children:l})]}):d?i.jsxs("div",{style:LA,children:[i.jsx("code",{"data-testid":`g7le-binding-expr-${t.propKey}`,style:Ef,children:l}),i.jsx("button",{type:"button","data-testid":`g7le-binding-clear-${t.propKey}`,onClick:()=>x(void 0),style:OA,children:o("layout_editor.binding.clear")})]}):i.jsx("div",{"data-testid":`g7le-binding-empty-${t.propKey}`,style:PA,children:o("layout_editor.binding.not_connected")}),d&&(t.shape==="array"||t.shape==="object")&&t.itemFields&&t.itemFields.length>0&&i.jsxs("div",{style:WA,children:[o("layout_editor.binding.item_fields"),": ",t.itemFields.join(", ")]}),!c&&i.jsxs("div",{style:{marginTop:4},children:[i.jsxs("button",{type:"button","data-testid":`g7le-binding-search-toggle-${t.propKey}`,onClick:()=>f(w=>!w),style:jA,children:["🔍 ",o("layout_editor.binding.search")]}),p&&i.jsxs("div",{"data-testid":`g7le-binding-picker-${t.propKey}`,style:HA,children:[i.jsx("input",{type:"text",autoFocus:!0,"data-testid":`g7le-binding-search-input-${t.propKey}`,value:y,placeholder:o("layout_editor.binding.search_placeholder"),onChange:w=>g(w.target.value),style:FA}),i.jsx("div",{style:KA,children:b.length===0?i.jsx("div",{style:VA,children:o("layout_editor.binding.no_results")}):b.map(w=>i.jsxs("button",{type:"button","data-testid":`g7le-binding-candidate-${w.expression}`,onClick:()=>k(w),style:UA,children:[i.jsxs("span",{style:{display:"flex",flexDirection:"column",alignItems:"flex-start"},children:[i.jsx("span",{style:{fontSize:12},children:w.resolvedLabel}),i.jsxs("span",{style:qA,children:[Ea(o,w.groupLabelKey,w.sourceId)," · ",w.path||w.sourceId]})]}),i.jsx("span",{style:GA,children:w.preview})]},w.expression))})]})]})]})}function IA({node:e,dataProps:t,candidates:n,t:o,onPatchNode:r}){return!Array.isArray(t)||t.length===0?null:i.jsxs("div",{"data-testid":"g7le-data-binding-section",style:AA,children:[i.jsxs("div",{style:TA,children:["🔗 ",o("layout_editor.binding.section_title")]}),t.map(s=>i.jsx(EA,{node:e,spec:s,candidates:n,t:o,onPatchNode:r},s.propKey)),i.jsx("div",{style:YA})]})}const AA={display:"flex",flexDirection:"column",marginBottom:8},TA={fontSize:12,fontWeight:700,color:"#0f172a",marginBottom:6},DA={display:"flex",flexDirection:"column",gap:4,padding:"8px 0"},NA={display:"flex",alignItems:"center",gap:6},zA={fontSize:10,padding:"1px 6px",borderRadius:4,background:"#e0e7ff",color:"#3730a3"},MA={fontSize:10,padding:"1px 6px",borderRadius:4,background:"#fef3c7",color:"#92400e"},LA={display:"flex",alignItems:"center",gap:6},PA={fontSize:11,color:"#94a3b8"},Ef={fontSize:11,fontFamily:"monospace",background:"#f1f5f9",padding:"2px 6px",borderRadius:4,color:"#0f172a",wordBreak:"break-all"},OA={fontSize:11,border:"1px solid #e2e8f0",borderRadius:4,background:"#fff",color:"#dc2626",padding:"2px 8px",cursor:"pointer"},BA={display:"flex",flexDirection:"column",gap:2,fontSize:11,color:"#64748b"},WA={fontSize:10,color:"#94a3b8"},jA={fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#334155",padding:"4px 10px",cursor:"pointer",textAlign:"left",width:"100%"},HA={marginTop:4,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",padding:6},FA={width:"100%",padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,boxSizing:"border-box"},KA={marginTop:4,maxHeight:200,overflowY:"auto",display:"flex",flexDirection:"column",gap:2},VA={fontSize:11,color:"#94a3b8",padding:"8px 0",textAlign:"center"},UA={display:"flex",alignItems:"center",justifyContent:"space-between",gap:8,border:"none",background:"transparent",cursor:"pointer",padding:"6px 8px",borderRadius:4,textAlign:"left"},qA={fontSize:10,color:"#94a3b8"},GA={fontSize:10,color:"#64748b",fontFamily:"monospace",whiteSpace:"nowrap"},YA={borderTop:"1px solid #e2e8f0",marginTop:4};function XA({node:e,candidates:t,t:n,onPatchNode:o,templateIdentifier:r}){const s=typeof e.text=="string"?e.text:"",{state:a}=Je(),l=r??a.templateIdentifier,d=a.selectedRoute?.layoutName??null,c=a.locale,[p,f]=u.useState(!1),y=/^\s*\$t:custom\.[A-Za-z0-9._-]+\s*$/.test(s),m=Xl(s)&&(mt(s)?.key.startsWith("custom.")??!1)||y,h=u.useMemo(()=>m?mt(s):null,[m,s]),v=u.useMemo(()=>m?[]:jl(s),[m,s]),b=N=>{o({...e,text:N})},x=()=>{if(!(typeof window>"u"))try{window.dispatchEvent(new CustomEvent(kt,{detail:{templateIdentifier:l,locale:c}}))}catch{}},k=(N,$)=>{const A=$n($.sourceId,$.path,"scalar");b(yb(s,N,A))},w=N=>{const $=mt(s);b(ho(s,N));const A=$?.key;l&&A&&Bd(l,A,N).then(x)},_=N=>{const $=n(N);return $&&$!==N&&!$.startsWith("$t:")?$:""},S=N=>N.replace(/\$t:[a-zA-Z0-9._-]+/g,$=>_($.slice(3))).replace(/\s+/g," ").trim(),E=N=>{p||(f(!0),ss(l,d,c,s,s.length,N.sourceId,N.path,"scalar",S,$=>n($)).then($=>{$.kind==="ok"&&(b($.text),x())}).finally(()=>f(!1)))},C=N=>{p||!h&&!y||(f(!0),_r(l,c,s,Number.MAX_SAFE_INTEGER,N.sourceId,N.path,"scalar").then($=>{$.kind==="ok"&&(b($.text),x())}).finally(()=>f(!1)))};if(m){const N=h?.params??[];return i.jsxs("div",{"data-testid":"g7le-inline-binding-section",style:If,children:[i.jsxs("div",{style:Af,children:["🔗 ",n("layout_editor.inline_binding.section_title")]}),N.length===0?i.jsx("div",{"data-testid":"g7le-inline-binding-none",style:Nf,children:n("layout_editor.inline_binding.none")}):N.map($=>i.jsx("div",{"data-testid":`g7le-inline-binding-param-${$.name}`,style:Tf,children:$.parsed===null?i.jsxs("div",{"data-testid":`g7le-inline-binding-param-complex-${$.name}`,style:ZA,children:[n("layout_editor.inline_binding.complex"),i.jsx("code",{style:Ia,children:$.expression})]}):i.jsxs(i.Fragment,{children:[i.jsxs("div",{style:Df,children:[i.jsx("code",{"data-testid":`g7le-inline-binding-param-expr-${$.name}`,style:Ia,children:$.expression}),i.jsx("button",{type:"button","data-testid":`g7le-inline-binding-param-clear-${$.name}`,onClick:()=>w($.name),style:JA,children:n("layout_editor.inline_binding.clear")})]}),i.jsx($t,{candidates:t,t:n,onSelect:A=>k($.name,A),testIdSuffix:`param-${$.name}`})]})},$.name)),i.jsxs("div",{"data-testid":"g7le-inline-binding-param-append",style:zf,children:[i.jsx("div",{style:Aa,children:n("layout_editor.inline_binding.append_hint")}),i.jsx($t,{candidates:t,t:n,onSelect:C,testIdSuffix:"param-append"})]}),i.jsx("div",{"data-testid":"g7le-inline-binding-param-hint",style:Aa,children:n("layout_editor.inline_binding.param_hint")}),i.jsx("div",{style:Mf})]})}return i.jsxs("div",{"data-testid":"g7le-inline-binding-section",style:If,children:[i.jsxs("div",{style:Af,children:["🔗 ",n("layout_editor.inline_binding.section_title")]}),v.length===0?i.jsx("div",{"data-testid":"g7le-inline-binding-none",style:Nf,children:n("layout_editor.inline_binding.none")}):v.map(N=>i.jsx("div",{"data-testid":`g7le-inline-binding-row-${N.bindingIndex}`,style:Tf,children:i.jsx("div",{style:Df,children:i.jsx("code",{"data-testid":`g7le-inline-binding-expr-${N.bindingIndex}`,style:Ia,children:N.expression})})},N.bindingIndex)),i.jsxs("div",{"data-testid":"g7le-inline-binding-append",style:zf,children:[i.jsx("div",{style:Aa,children:n("layout_editor.inline_binding.append_hint")}),i.jsx($t,{candidates:t,t:n,onSelect:E,testIdSuffix:"append"})]}),i.jsx("div",{style:Mf})]})}const If={display:"flex",flexDirection:"column",marginBottom:8},Af={fontSize:12,fontWeight:700,color:"#0f172a",marginBottom:6},Tf={display:"flex",flexDirection:"column",gap:4,padding:"8px 0"},Df={display:"flex",alignItems:"center",gap:6},Nf={fontSize:11,color:"#94a3b8"},Ia={fontSize:11,fontFamily:"monospace",background:"#f1f5f9",padding:"2px 6px",borderRadius:4,color:"#0f172a",wordBreak:"break-all"},JA={fontSize:11,border:"1px solid #e2e8f0",borderRadius:4,background:"#fff",color:"#dc2626",padding:"2px 8px",cursor:"pointer"},ZA={display:"flex",flexDirection:"column",gap:2,fontSize:11,color:"#64748b"},zf={display:"flex",flexDirection:"column",gap:2,padding:"8px 0 0"},Aa={fontSize:11,color:"#475569",fontWeight:600},Mf={borderTop:"1px solid #e2e8f0",marginTop:4};function Lf(e,t,n){if(!t)return n;const o=e(t);return!o||o===t||o.startsWith("$t:")?n:o}function QA(e,t){return Lf(t,e.labelKey,e.path||e.sourceId)}function RA(e){const t=e.iteration;return t&&typeof t=="object"?t:null}function e6({node:e,candidates:t,t:n,onPatchNode:o}){const r=RA(e),s=r?.source,a=typeof s=="string"?s:void 0,l=u.useMemo(()=>Kt(s),[s]),d=a!==void 0&&a!==""&&l===null,[c,p]=u.useState(!1),[f,y]=u.useState(""),g=u.useMemo(()=>ji(t,"array").map(w=>({...w,resolvedLabel:QA(w,n)})),[t,n]),m=u.useMemo(()=>Hi(g,f),[g,f]);if(!r)return null;const h=typeof r.item_var=="string"?r.item_var:void 0,v=typeof r.index_var=="string"?r.index_var:void 0,b=k=>{const w={...r};k===void 0?delete w.source:w.source=k,o({...e,iteration:w})},x=k=>{b($n(k.sourceId,k.path,"array")),p(!1),y("")};return i.jsxs("div",{"data-testid":"g7le-iteration-binding-section",style:t6,children:[i.jsxs("div",{style:n6,children:["🔁 ",n("layout_editor.binding.iteration_title")]}),i.jsx("div",{style:o6,children:n("layout_editor.binding.iteration_hint")}),i.jsxs("div",{style:r6,children:[i.jsxs("div",{style:i6,children:[i.jsx("span",{style:{fontWeight:600,fontSize:12},children:n("layout_editor.binding.iteration_label")}),i.jsx("span",{style:s6,children:n("layout_editor.binding.shape_array")})]}),d?i.jsxs("div",{"data-testid":"g7le-iteration-binding-complex",style:c6,children:[n("layout_editor.binding.complex"),i.jsx("code",{style:Pf,children:a})]}):l?i.jsxs("div",{style:a6,children:[i.jsx("code",{"data-testid":"g7le-iteration-binding-expr",style:Pf,children:a}),i.jsx("button",{type:"button","data-testid":"g7le-iteration-binding-clear",onClick:()=>b(void 0),style:d6,children:n("layout_editor.binding.clear")})]}):i.jsx("div",{"data-testid":"g7le-iteration-binding-empty",style:l6,children:n("layout_editor.binding.not_connected")}),(h||v)&&i.jsxs("div",{"data-testid":"g7le-iteration-vars",style:u6,children:[h&&i.jsxs("span",{children:[n("layout_editor.binding.iteration_item_var"),": ",i.jsx("code",{style:Of,children:h})]}),v&&i.jsxs("span",{children:[n("layout_editor.binding.iteration_index_var"),": ",i.jsx("code",{style:Of,children:v})]})]}),!d&&i.jsxs("div",{style:{marginTop:4},children:[i.jsxs("button",{type:"button","data-testid":"g7le-iteration-binding-search-toggle",onClick:()=>p(k=>!k),style:p6,children:["🔍 ",n("layout_editor.binding.search")]}),c&&i.jsxs("div",{"data-testid":"g7le-iteration-binding-picker",style:f6,children:[i.jsx("input",{type:"text",autoFocus:!0,"data-testid":"g7le-iteration-binding-search-input",value:f,placeholder:n("layout_editor.binding.search_placeholder"),onChange:k=>y(k.target.value),style:g6}),i.jsx("div",{style:h6,children:m.length===0?i.jsx("div",{style:y6,children:n("layout_editor.binding.no_results")}):m.map(k=>i.jsxs("button",{type:"button","data-testid":`g7le-iteration-binding-candidate-${k.expression}`,onClick:()=>x(k),style:b6,children:[i.jsxs("span",{style:{display:"flex",flexDirection:"column",alignItems:"flex-start"},children:[i.jsx("span",{style:{fontSize:12},children:k.resolvedLabel}),i.jsxs("span",{style:m6,children:[Lf(n,k.groupLabelKey,k.sourceId)," · ",k.path||k.sourceId]})]}),i.jsx("span",{style:v6,children:k.preview})]},k.expression))})]})]})]}),i.jsx("div",{style:_6})]})}const t6={display:"flex",flexDirection:"column",marginBottom:8},n6={fontSize:12,fontWeight:700,color:"#0f172a",marginBottom:4},o6={fontSize:10,color:"#94a3b8",marginBottom:6,lineHeight:1.4},r6={display:"flex",flexDirection:"column",gap:4,padding:"8px 0"},i6={display:"flex",alignItems:"center",gap:6},s6={fontSize:10,padding:"1px 6px",borderRadius:4,background:"#e0e7ff",color:"#3730a3"},a6={display:"flex",alignItems:"center",gap:6},l6={fontSize:11,color:"#94a3b8"},Pf={fontSize:11,fontFamily:"monospace",background:"#f1f5f9",padding:"2px 6px",borderRadius:4,color:"#0f172a",wordBreak:"break-all"},d6={fontSize:11,border:"1px solid #e2e8f0",borderRadius:4,background:"#fff",color:"#dc2626",padding:"2px 8px",cursor:"pointer"},c6={display:"flex",flexDirection:"column",gap:2,fontSize:11,color:"#64748b"},u6={display:"flex",gap:12,fontSize:10,color:"#94a3b8",marginTop:2},Of={fontSize:10,fontFamily:"monospace",background:"#f1f5f9",padding:"1px 4px",borderRadius:3,color:"#475569"},p6={fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#334155",padding:"4px 10px",cursor:"pointer",textAlign:"left",width:"100%"},f6={marginTop:4,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",padding:6},g6={width:"100%",padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,boxSizing:"border-box"},h6={marginTop:4,maxHeight:200,overflowY:"auto",display:"flex",flexDirection:"column",gap:2},y6={fontSize:11,color:"#94a3b8",padding:"8px 0",textAlign:"center"},b6={display:"flex",alignItems:"center",justifyContent:"space-between",gap:8,border:"none",background:"transparent",cursor:"pointer",padding:"6px 8px",borderRadius:4,textAlign:"left"},m6={fontSize:10,color:"#94a3b8"},v6={fontSize:10,color:"#64748b",fontFamily:"monospace",whiteSpace:"nowrap"},_6={borderTop:"1px solid #e2e8f0",marginTop:4},x6=new Set(["Span","P","H1","H2","H3","H4","H5","H6","A","Button","Li","Label"]);function w6(e,t){if(!e||e.iteration&&typeof e.iteration=="object")return!1;const n=t?.textBinding;if(n===!1)return!1;if(n===!0)return!0;const o=typeof e.name=="string"?e.name:"";return x6.has(o)?!0:typeof e.text=="string"}const hn={direction:"flexDirection",justify:"flexJustify",align:"flexAlign",wrap:"flexWrap",gap:"flexGap",enable:"flexEnable"},fi={grow:"flexItemGrow",selfAlign:"flexItemAlign",order:"flexItemOrder"};function Bf(e){const t=e?.flexEditor;return t==="container"||t==="item"||t==="auto"?t:null}function k6(e){if(!e||typeof window>"u"||typeof window.getComputedStyle!="function")return!1;const t=(window.getComputedStyle(e).display??"").toLowerCase();return t==="flex"||t==="inline-flex"}function S6(e,t,n=Wt){if(!e||!t)return!1;const o=Io(e,t,n),r=t.options,s=Array.isArray(r)?t.onValue??r[0]?.value:void 0,a=l=>s!==void 0?l===s:l!==void 0&&l!==!1;return n.breakpoint!=="base"?a(o.scopedRawValue):o.matched&&a(o.value)}function C6(e,t,n=!1,o=!1,r=Wt){if(e==="container")return{showContainer:!0,showEnableButton:!1,showDisableButton:!1};if(e==="item")return{showContainer:!1,showEnableButton:!1,showDisableButton:!1};if(e==="auto"){const s=r.breakpoint==="base";return!o&&!s?{showContainer:!1,showEnableButton:!1,showDisableButton:!1}:(o?n:k6(t))?{showContainer:!0,showEnableButton:!1,showDisableButton:o}:{showContainer:!1,showEnableButton:!0,showDisableButton:!1}}return{showContainer:!1,showEnableButton:!1,showDisableButton:!1}}const $6={[hn.direction]:"layout_editor.flex.direction",[hn.justify]:"layout_editor.flex.justify",[hn.align]:"layout_editor.flex.align",[hn.wrap]:"layout_editor.flex.wrap",[hn.gap]:"layout_editor.flex.gap"},E6={[fi.grow]:"layout_editor.flex.item_grow",[fi.selfAlign]:"layout_editor.flex.item_align",[fi.order]:"layout_editor.flex.item_order"};function I6({node:e,spec:t,capability:n,t:o,onPatchNode:r,liveElement:s,scope:a=Wt}){const l=Bf(n),d=Ct(t,hn.enable),c=S6(e,d,a),{showContainer:p,showEnableButton:f,showDisableButton:y}=C6(l,s,c,!!d,a),g=Object.values(hn).filter(_=>_!==hn.enable&&Ct(t,_)),m=Object.values(fi).filter(_=>Ct(t,_)),h=d?.options,v=d?.onValue??h?.[0]?.value??!0,b=Array.isArray(h)&&h.length>=2?h[1].value:void 0,x=()=>{if(!d)return;const _=pn(e,d,v,a);r(_)},k=()=>{if(!d)return;const _=a.breakpoint!=="base"&&b!==void 0?b:void 0,S=pn(e,d,_,a);r(S)},w=(_,S,E)=>i.jsx("div",{"data-testid":E,style:{display:"flex",flexDirection:"column"},children:_.map(C=>{const N=Ct(t,C);if(!N)return null;const $=N.label?N:{...N,label:`$t:${S[C]}`};return i.jsx(ci,{controlKey:C,control:$,node:e,t:o,onPatch:r,scope:a},C)})});return i.jsxs("div",{className:"g7le-flex-editor","data-testid":"g7le-flex-editor",style:{display:"flex",flexDirection:"column",gap:12},children:[f&&i.jsx("button",{type:"button","data-testid":"g7le-flex-enable",onClick:x,style:A6,children:o("layout_editor.flex.make_flex")}),p&&g.length>0&&i.jsxs("div",{className:"g7le-flex-container-section","data-testid":"g7le-flex-container-section",children:[i.jsx("div",{style:Wf,children:o("layout_editor.flex.container_title")}),w(g,$6,"g7le-flex-container-controls"),y&&i.jsx("button",{type:"button","data-testid":"g7le-flex-disable",onClick:k,style:T6,children:o("layout_editor.flex.unmake_flex")})]}),l==="item"&&m.length>0&&i.jsxs("div",{className:"g7le-flex-item-section","data-testid":"g7le-flex-item-section",children:[i.jsx("div",{style:Wf,children:o("layout_editor.flex.item_title")}),w(m,E6,"g7le-flex-item-controls")]}),!p&&!f&&l!=="item"&&i.jsx("div",{"data-testid":"g7le-flex-empty",style:D6,children:o("layout_editor.flex.not_flex")})]})}const Wf={fontSize:12,fontWeight:700,color:"#0f172a",marginBottom:6},A6={padding:"8px 12px",fontSize:12,border:"1px dashed #2563eb",borderRadius:6,background:"#eff6ff",color:"#2563eb",cursor:"pointer"},T6={marginTop:8,padding:"6px 10px",fontSize:12,border:"1px solid #e2e8f0",borderRadius:6,background:"#f8fafc",color:"#64748b",cursor:"pointer"},D6={fontSize:12,color:"#94a3b8",padding:"16px 0",textAlign:"center"},jf=["desktop","tablet","mobile","portable"],Ta=1280,N6=820,z6=390,M6=1920,L6=320;function P6(e){const t=[],n=new Set,o=r=>{if(!r||typeof r!="object")return;const s=r.responsive;if(s&&typeof s=="object")for(const a of Object.keys(s)){n.has(a)||(n.add(a),t.push(a));const l=s[a]?.children;if(Array.isArray(l))for(const d of l)o(d)}if(Array.isArray(r.children))for(const a of r.children)o(a)};if(Array.isArray(e))for(const r of e)o(r);return t}function O6(e){const t=[...jf],n=new Set(jf);for(const o of P6(e))!n.has(o)&&!t.includes(o)&&t.push(o);return t}function B6(e,t){const n=e?.responsive;if(!n||typeof n!="object")return[];const o=[];for(const r of Object.keys(n)){const s=n[r];!s||!Array.isArray(s.children)||r!==t&&o.push(r)}return o}function W6(e){switch(e){case"desktop":return Ta;case"tablet":return N6;case"mobile":return z6}const t=e in Eo?Eo[e]:zn.parseRange(e);return!t||!Number.isFinite(t.max)?Ta:Math.round(Math.max(L6,Math.min(M6,t.max)))}function Hf(){const t=Ft()?.document?.raw?.components;return u.useMemo(()=>O6(t??null),[t])}const j6={desktop:"layout_editor.property_modal.scope.device_desktop",tablet:"layout_editor.property_modal.scope.device_tablet",mobile:"layout_editor.property_modal.scope.device_mobile",portable:"layout_editor.property_modal.scope.device_portable"},H6=[{key:"base",labelKey:"layout_editor.property_modal.scope.scheme_light"},{key:"dark",labelKey:"layout_editor.property_modal.scope.scheme_dark"}];function Ff({scope:e,onChange:t,node:n,t:o,showColorScheme:r=!0,onClearScope:s}){const[a,l]=u.useState(!1),[d,c]=u.useState(""),[p,f]=u.useState(""),[y,g]=u.useState(null),m=Hf(),h=u.useMemo(()=>{const k=["base",...m],w=new Set(k);for(const _ of Object.keys(n.responsive??{}))_!=="base"&&!w.has(_)&&(w.add(_),k.push(_));return e.breakpoint!=="base"&&!w.has(e.breakpoint)&&k.push(e.breakpoint),k},[m,n.responsive,e.breakpoint]),v=k=>t({...e,colorScheme:k}),b=k=>t({...e,breakpoint:k}),x=()=>{const k=d.trim(),w=p.trim();if(k===""&&w===""){g(o("layout_editor.property_modal.scope.custom_invalid"));return}const _=`${k}-${w}`;if(!sC(_)){g(o("layout_editor.property_modal.scope.custom_invalid"));return}g(null),l(!1),c(""),f(""),b(_)};return i.jsxs("div",{className:"g7le-style-scope-tabs","data-testid":"g7le-style-scope-tabs",style:F6,children:[r&&i.jsx("div",{className:"g7le-style-scope-scheme","data-testid":"g7le-style-scope-scheme",role:"tablist","aria-label":o("layout_editor.property_modal.scope.scheme_light"),style:Kf,children:H6.map(k=>{const w=e.colorScheme===k.key;return i.jsxs("button",{type:"button",role:"tab","aria-selected":w,"data-testid":`g7le-style-scheme-${k.key}`,"data-active":w?"true":"false",onClick:()=>v(k.key),style:Vf(w),children:[o(k.labelKey),k.key==="dark"&&Us(n,{colorScheme:"dark",breakpoint:e.breakpoint})&&i.jsx("span",{"data-testid":"g7le-style-scheme-dark-dot",style:Uf})]},k.key)})}),i.jsxs("div",{className:"g7le-style-scope-device","data-testid":"g7le-style-scope-device",role:"tablist","aria-label":o("layout_editor.property_modal.scope.device_base"),style:Kf,children:[h.map(k=>{const w=e.breakpoint===k,_=k==="base"||mu(k),S=k==="base"?"layout_editor.property_modal.scope.device_base":j6[k],E=S?o(S):k,C=_?`g7le-style-bp-${k}`:`g7le-style-bp-custom-${k}`;return i.jsxs("button",{type:"button",role:"tab","aria-selected":w,"data-testid":C,"data-active":w?"true":"false",onClick:()=>b(k),style:Vf(w),children:[E,Us(n,{colorScheme:"base",breakpoint:k})&&i.jsx("span",{"data-testid":`${C}-dot`,style:Uf})]},String(k))}),i.jsxs("button",{type:"button","data-testid":"g7le-style-bp-add-custom",onClick:()=>l(k=>!k),style:U6,children:["+ ",o("layout_editor.property_modal.scope.add_custom")]})]}),a&&i.jsxs("div",{className:"g7le-style-scope-custom-input","data-testid":"g7le-style-scope-custom-input",style:K6,children:[i.jsx("input",{type:"number","data-testid":"g7le-style-custom-min",placeholder:o("layout_editor.property_modal.scope.custom_min"),value:d,onChange:k=>c(k.target.value),style:qf}),i.jsx("span",{style:{color:"#94a3b8"},children:"—"}),i.jsx("input",{type:"number","data-testid":"g7le-style-custom-max",placeholder:o("layout_editor.property_modal.scope.custom_max"),value:p,onChange:k=>f(k.target.value),style:qf}),i.jsx("button",{type:"button","data-testid":"g7le-style-custom-confirm",onClick:x,style:q6,children:o("layout_editor.property_modal.scope.add_custom")})]}),y&&i.jsx("div",{"data-testid":"g7le-style-custom-error",style:G6,children:y}),e.breakpoint!=="base"&&!mu(e.breakpoint)&&i.jsx("div",{"data-testid":"g7le-style-custom-priority-note",style:Y6,children:o("layout_editor.property_modal.scope.custom_priority_note")}),s&&Us(n,e)&&i.jsxs("button",{type:"button","data-testid":"g7le-style-scope-reset",onClick:s,style:V6,children:["↺ ",o("layout_editor.property_modal.scope.reset_to_base")]})]})}const F6={display:"flex",flexDirection:"column",gap:0,padding:"0 0 8px",borderBottom:"1px solid #e2e8f0",marginBottom:8},K6={display:"flex",gap:6,alignItems:"center",marginTop:4},Kf={display:"flex",gap:2,flexWrap:"wrap",borderBottom:"1px solid #e2e8f0",margin:"0 -16px",padding:"0 12px"};function Vf(e){return{display:"inline-flex",alignItems:"center",gap:4,padding:"6px 12px",fontSize:12,border:"none",borderBottom:`2px solid ${e?"#2563eb":"transparent"}`,background:"transparent",color:e?"#2563eb":"#64748b",cursor:"pointer",fontWeight:e?700:500,marginBottom:-1}}const Uf={display:"inline-block",width:5,height:5,borderRadius:3,background:"#f59e0b"},V6={alignSelf:"flex-start",marginTop:2,padding:"3px 8px",fontSize:10,border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer"},U6={padding:"6px 12px",fontSize:11,border:"none",borderBottom:"2px dashed #cbd5e1",background:"transparent",color:"#475569",cursor:"pointer",marginBottom:-1},qf={width:70,padding:"4px 6px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6},q6={padding:"4px 10px",fontSize:11,border:"none",borderRadius:6,background:"#2563eb",color:"#fff",cursor:"pointer"},G6={fontSize:11,color:"#dc2626"},Y6={fontSize:10,color:"#94a3b8"},Gf=new Map;function Ln(e,t){Gf.set(e,t)}function X6(e){return e?Gf.get(e)??null:null}const Da={id:{label:"$t:layout_editor.core_props.id.label",widget:"core-id",placeholder:"$t:layout_editor.core_props.id.placeholder",apply:{type:"propValue",propKey:"id"}},dataKey:{label:"$t:layout_editor.core_props.dataKey.label",widget:"core-datakey",placeholder:"$t:layout_editor.core_props.dataKey.placeholder",apply:{type:"nodeKey",nodeKey:"dataKey"}},isolatedState:{label:"$t:layout_editor.core_props.isolatedState.label",widget:"toggle",apply:{type:"nodeKey",nodeKey:"isolatedState"}},isolatedScopeId:{label:"$t:layout_editor.core_props.isolatedScopeId.label",widget:"core-scopeid",placeholder:"$t:layout_editor.core_props.isolatedScopeId.placeholder",apply:{type:"nodeKey",nodeKey:"isolatedScopeId"}}},J6=["id"];function Z6(e){return e===!1?[]:Array.isArray(e)?e.filter(t=>t in Da):[...J6]}function Yf(e){const t=e.props?.id??e.id;return typeof t=="string"&&t.length>0?`${t}-scope`:"scope"}function Q6({node:e,onPatchNode:t,t:n,scopeIdCandidates:o=[],usedScopeIds:r=[]}){const s=e,a="isolatedState"in s&&s.isolatedState!=null,l=typeof s.isolatedScopeId=="string"?s.isolatedScopeId:"",[d,c]=u.useState(""),p=()=>{const h={...e};a?(delete h.isolatedState,delete h.isolatedScopeId):(h.isolatedState={},h.isolatedScopeId=Yf(e)),t(h)},f=h=>{const v={...e};v.isolatedScopeId=h.trim()===""?Yf(e):h,t(v)},y=h=>{const v={...e};v.isolatedState=h,t(v)},g=u.useMemo(()=>{const h=d.trim().toLowerCase();return o.filter(v=>!h||v.toLowerCase().includes(h))},[o,d]),m=l!==""&&r.includes(l);return i.jsxs("div",{className:"g7le-isolated-scope","data-testid":"g7le-isolated-scope-control",style:R6,children:[i.jsx("div",{style:eT,children:n("layout_editor.core_props.isolatedState.label")}),i.jsxs("div",{style:tT,children:[i.jsx("button",{type:"button","data-testid":"g7le-isolated-toggle",role:"switch","aria-checked":a,onClick:p,style:nT(a),children:n(a?"layout_editor.overlay.on":"layout_editor.overlay.off")}),a?null:i.jsx("span",{"data-testid":"g7le-isolated-off-hint",style:oT,children:n("layout_editor.core_props.isolatedState.off_hint")})]}),a?i.jsxs(i.Fragment,{children:[i.jsxs("div",{style:Xf,children:[i.jsx("label",{style:Jf,children:n("layout_editor.core_props.isolatedScopeId.label")}),i.jsx("input",{type:"text","data-testid":"g7le-isolated-scopeid-input",value:l,placeholder:n("layout_editor.core_props.isolatedScopeId.placeholder"),onChange:h=>f(h.target.value),style:rT}),g.length>0?i.jsxs("div",{"data-testid":"g7le-isolated-scopeid-picker",style:iT,children:[i.jsx("input",{type:"text","data-testid":"g7le-isolated-scopeid-search",value:d,placeholder:n("layout_editor.core_props.isolatedScopeId.search"),onChange:h=>c(h.target.value),style:sT}),i.jsx("div",{style:aT,children:g.map(h=>i.jsx("button",{type:"button","data-testid":`g7le-isolated-scopeid-candidate-${h}`,onClick:()=>{f(h),c("")},style:lT,children:h},h))})]}):null,m?i.jsxs("p",{"data-testid":"g7le-isolated-scopeid-dup",style:dT,children:["ⓘ ",n("layout_editor.core_props.isolatedScopeId.duplicate")]}):null]}),i.jsxs("div",{style:Xf,children:[i.jsx("label",{style:Jf,children:n("layout_editor.core_props.isolatedState.initial_label")}),i.jsx("div",{"data-testid":"g7le-isolated-initial-value",children:i.jsx(Dn,{value:s.isolatedState??{},onChange:y,t:n,path:"isolatedState",scope:"isolated-node"})})]})]}):null]})}const R6={display:"flex",flexDirection:"column",gap:8,padding:10,border:"1px solid #e2e8f0",borderRadius:8,minWidth:0},eT={fontSize:12,fontWeight:700,color:"#0f172a"},tT={display:"flex",gap:8,alignItems:"center"},nT=e=>({padding:"4px 12px",fontSize:11,border:`1px solid ${e?"#3b82f6":"#cbd5e1"}`,borderRadius:14,background:e?"#eff6ff":"#fff",color:e?"#1d4ed8":"#64748b",cursor:"pointer"}),oT={fontSize:11,color:"#94a3b8"},Xf={display:"flex",flexDirection:"column",gap:4,minWidth:0},Jf={fontSize:11,color:"#64748b"},rT={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,boxSizing:"border-box",width:"100%"},iT={border:"1px solid #e2e8f0",borderRadius:6,padding:4,background:"#fff"},sT={width:"100%",padding:"4px 6px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,marginBottom:4,boxSizing:"border-box"},aT={display:"flex",flexDirection:"column",maxHeight:120,overflowY:"auto"},lT={textAlign:"left",padding:"4px 6px",fontSize:12,border:"none",background:"transparent",cursor:"pointer",borderRadius:4,color:"#0f172a"},dT={margin:0,fontSize:11,color:"#64748b"},cT={borderTop:"2px solid #c7d2fe",background:"#eef2ff",padding:"10px 12px",display:"flex",flexDirection:"column",gap:8},Zf={fontSize:13,fontWeight:700,color:"#3730a3",display:"flex",alignItems:"center",gap:6},uT={fontSize:11,fontWeight:600,color:"#4338ca",background:"#e0e7ff",border:"1px solid #c7d2fe",borderRadius:4,padding:"1px 6px"},Na={fontSize:11,color:"#6366f1"},pT={alignSelf:"flex-start",fontSize:12,fontWeight:600,color:"#fff",background:"#4f46e5",border:"none",borderRadius:4,padding:"4px 10px",cursor:"pointer"},fT={opacity:.5,cursor:"not-allowed"},gT={fontSize:11,color:"#dc2626"};function hT({entry:e,t,onSave:n}){const[o,r]=u.useState(()=>e.props),[s,a]=u.useState(!0),[l,d]=u.useState(null),[c,p]=u.useState(!1),[f,y]=u.useState(!1),g=e.extensionName??e.extensionIdentifier??`#${e.extensionId}`,m=async()=>{if(!s){d(t("layout_editor.property_modal.injected_props.invalid_json"));return}d(null),p(!0),y(!1);try{await n(o),y(!0)}catch(h){d(h instanceof Error?h.message:String(h))}finally{p(!1)}};return i.jsxs("div",{"data-testid":`g7le-injected-props-entry-${e.extensionId}`,style:{display:"flex",flexDirection:"column",gap:6},children:[i.jsx("div",{style:Zf,children:i.jsx("span",{style:uT,"data-testid":"g7le-injected-props-origin-badge",children:t("layout_editor.property_modal.injected_props.origin",{source:g})})}),i.jsx(Ps,{value:o,shape:"object",emptyValue:{},testidPrefix:"g7le-injected-props-json",minHeight:80,invalidErrorKey:"layout_editor.property_modal.injected_props.invalid_json",shapeErrorKey:"layout_editor.property_modal.injected_props.invalid_json",t,onChange:h=>{r(h??{}),y(!1)},onValidityChange:h=>{a(h),h&&d(null),y(!1)}}),l&&i.jsx("div",{style:gT,"data-testid":"g7le-injected-props-error",children:l}),f&&i.jsx("div",{style:{...Na,color:"#16a34a"},"data-testid":"g7le-injected-props-saved",children:t("layout_editor.property_modal.injected_props.saved")}),i.jsx("div",{style:Na,children:t("layout_editor.property_modal.injected_props.save_target")}),i.jsx("button",{type:"button","data-testid":"g7le-injected-props-save",style:{...pT,...c||!s?fT:null},disabled:c||!s,onClick:m,children:c?t("layout_editor.property_modal.injected_props.saving"):t("layout_editor.property_modal.injected_props.save_button",{source:g})})]})}function yT({injectedProps:e,t,onSaveInjectedProps:n}){return!Array.isArray(e)||e.length===0?null:i.jsxs("div",{style:cT,"data-testid":"g7le-injected-props-section",children:[i.jsxs("div",{style:Zf,children:["🧩 ",t("layout_editor.property_modal.injected_props.title")]}),i.jsx("div",{style:Na,children:t("layout_editor.property_modal.injected_props.help")}),e.map(o=>i.jsx(hT,{entry:o,t,onSave:r=>n(o.extensionId,r)},o.extensionId))]})}function bT(e,t){const n=e?.components??{};for(const o of Object.values(n)){const r=o?.find(s=>s.name===t);if(r)return r}return null}function mT({node:e,spec:t,manifest:n,t:o,onPatchNode:r,onClose:s,onDelete:a,permissionCandidates:l,pageCandidates:d,dataSourceCandidates:c,stateKeyCandidates:p,bindingCandidates:f,liveElement:y,scope:g=Wt,onScopeChange:m,templateIdentifier:h,initialTab:v,onSaveInjectedProps:b,branchLabel:x,isolatedScopeIdCandidates:k,usedScopeIds:w}){const _=e.__injectedProps,S=Array.isArray(_)&&_.length>0&&!!b,E=ve=>{m?.(ve)},C=()=>{r(fC(e,g))},N=typeof e.name=="string"?e.name:"",$=Li(t,N),z=bT(n,N)?.settings,D=!!(z?.groups&&z.groups.length>0),O=Array.isArray($?.propControls)?$.propControls:[],M=Z6($?.coreProps),B=new Set(M.map(ve=>Da[ve].apply?.propKey)),T=O.filter(ve=>{const he=Ct(t,ve)?.apply?.propKey;return!(he!==void 0&&B.has(he))}),H=typeof $?.nodeEditor?.kind=="string"?$.nodeEditor.kind:void 0,L=$?.nodeEditor?.params,I=H?X6(H):null,P=!!I,F=Array.isArray($?.dataProps)?$.dataProps:[],G=F.length>0,V=!!(e.iteration&&typeof e.iteration=="object"),Y=w6(e,$),Z=M.length>0||T.length>0||P||G||Y||V,se=Array.isArray($?.styleControls)?$.styleControls:[],re=Array.isArray($?.advanced)?$.advanced:[],ee=(Array.isArray($?.events)?$.events:[]).length>0,fe=Bf($),K=se.length>0||fe!==null,me=!($?.visibilityCondition===!1)&&(Z||K||ee||D||re.length>0),Ce=u.useMemo(()=>{const ve=[];return D&&ve.push("settings"),Z&&ve.push("props"),K&&ve.push("style"),ee&&ve.push("action"),me&&ve.push("visibility"),ve.push("translation"),re.length>0&&ve.push("advanced"),ve},[D,Z,K,ee,me,re.length]),ze=T.length>0||P||G||V,[Se,qe]=u.useState(v&&Ce.includes(v)?v:D?"settings":ze?"props":K?"style":Z?"props":Ce[0]??"style"),De=uA(e),Pe=!Z&&!K&&!D&&!ee&&!me&&re.length===0,Qe=$?.label?$.label.startsWith("$t:")?o($.label.slice(3)):$.label:N||o("layout_editor.property_modal.element"),Me=(ve,te)=>{const he={...e.props??{}};te===void 0?delete he[ve]:he[ve]=te,r({...e,props:he})},Re={settings:o("layout_editor.property_modal.tab.settings"),props:o("layout_editor.property_modal.tab.props"),style:o("layout_editor.property_modal.tab.style"),action:o("layout_editor.property_modal.tab.action"),visibility:o("layout_editor.property_modal.tab.visibility"),translation:o("layout_editor.property_modal.tab.translation"),advanced:o("layout_editor.property_modal.tab.advanced")};return i.jsxs("div",{className:"g7le-property-modal","data-testid":"g7le-property-modal",style:vT,children:[i.jsxs("div",{"data-modal-drag-handle":!0,"data-testid":"g7le-property-modal-header",style:_T,children:[i.jsx("span",{"data-testid":"g7le-property-modal-title",style:{fontSize:14,fontWeight:700},children:D&&Se==="settings"?o("layout_editor.property_modal.settings_title",{name:Qe}):o("layout_editor.property_modal.title",{name:Qe})}),i.jsx("button",{type:"button","data-testid":"g7le-property-modal-close","aria-label":"close",onClick:s,style:xT,children:"✕"})]}),De&&i.jsx("div",{"data-testid":"g7le-property-modal-advanced-badge",style:wT,children:o("layout_editor.property_modal.advanced_badge")}),x&&i.jsx("div",{"data-testid":"g7le-property-modal-branch-badge",style:kT,children:o("layout_editor.property_modal.branch_badge",{device:x})}),i.jsx("div",{style:ST,role:"tablist",children:Ce.map(ve=>i.jsx("button",{type:"button",role:"tab","aria-selected":Se===ve,"data-testid":`g7le-property-tab-${ve}`,"data-active":Se===ve?"true":"false",onClick:()=>qe(ve),style:{...CT,color:Se===ve?"#2563eb":"#64748b",borderBottomColor:Se===ve?"#2563eb":"transparent"},children:Re[ve]},ve))}),i.jsxs("div",{style:$T,"data-testid":"g7le-property-modal-body",children:[Pe&&!S&&i.jsx("div",{"data-testid":"g7le-property-modal-no-editable",style:Rf,children:o("layout_editor.property_modal.no_editable_props")}),S&&i.jsx(yT,{injectedProps:_,t:o,onSaveInjectedProps:b}),Se==="settings"&&z&&i.jsx(RI,{spec:z,node:e,t:o,onPatchProp:Me}),Se==="props"&&i.jsxs("div",{"data-testid":"g7le-property-props-tab",style:{display:"flex",flexDirection:"column"},children:[V&&i.jsx(e6,{node:e,candidates:f??[],t:o,onPatchNode:r}),G&&i.jsx(IA,{node:e,dataProps:F,candidates:f??[],t:o,onPatchNode:r}),Y&&i.jsx(XA,{node:e,candidates:f??[],t:o,onPatchNode:r,templateIdentifier:h}),P&&I&&i.jsx(I,{node:e,params:L,spec:t,manifest:n,t:o,onPatchNode:r,templateIdentifier:h,candidates:f}),M.map(ve=>i.jsx(ci,{controlKey:`core-${ve}`,control:Da[ve],node:e,t:o,onPatch:r,scope:Wt,bindingCandidates:f},`core-${ve}`)),T.map(ve=>{const te=Ct(t,ve);return te?i.jsx(ci,{controlKey:ve,control:te,node:e,t:o,onPatch:r,scope:Wt,bindingCandidates:f},ve):null}),i.jsx(Q6,{node:e,onPatchNode:r,t:o,scopeIdCandidates:k,usedScopeIds:w})]}),Se==="style"&&i.jsxs("div",{"data-testid":"g7le-property-style-tab",style:{display:"flex",flexDirection:"column"},children:[i.jsx("div",{style:Qf,children:i.jsx(Ff,{scope:g,onChange:E,node:e,t:o,onClearScope:C})}),fe!==null&&i.jsx(I6,{node:e,spec:t,capability:$,t:o,onPatchNode:r,liveElement:y,scope:g}),se.map(ve=>{const te=Ct(t,ve);return te?i.jsx(ci,{controlKey:ve,control:te,node:e,t:o,onPatch:r,scope:g},ve):null})]}),Se==="action"&&i.jsx(SA,{node:e,spec:t,capability:$,t:o,onPatchNode:r,pageCandidates:d,dataSourceCandidates:c,stateKeyCandidates:p,bindingCandidates:f}),Se==="visibility"&&i.jsxs("div",{"data-testid":"g7le-property-visibility-tab",style:{display:"flex",flexDirection:"column"},children:[i.jsx("div",{style:Qf,children:i.jsx(Ff,{scope:g,onChange:E,node:e,t:o,showColorScheme:!1})}),i.jsx(ku,{node:e,spec:t,t:o,onPatchNode:r,dataSourceCandidates:c,stateKeyCandidates:p,scope:g})]}),Se==="translation"&&(h?i.jsx(Hd,{customKey:jd(e),templateIdentifier:h,t:o,paramLabels:av(e),onRemoveParam:ve=>{const te=typeof e.text=="string"?e.text:"";r({...e,text:ho(te,ve)});const he=jd(e);h&&he&&xr(h,he,ve)}}):i.jsx("div",{"data-testid":"g7le-property-translation-deferred",style:Rf,children:o("layout_editor.translation.not_a_key")})),Se==="advanced"&&i.jsx(fA,{node:e,advanced:re,t:o,onPatch:r,permissionCandidates:l})]}),i.jsxs("div",{style:ET,children:[i.jsx("span",{"data-testid":"g7le-property-modal-node-id",style:{fontSize:11,color:"#94a3b8"},children:typeof e.id=="string"||typeof e.id=="number"?`ID: ${e.id}`:""}),i.jsxs("div",{style:{display:"flex",gap:8},children:[i.jsx("button",{type:"button","data-testid":"g7le-property-modal-delete",onClick:a,style:IT,children:o("layout_editor.property_modal.delete_element")}),i.jsx("button",{type:"button","data-testid":"g7le-property-modal-done",onClick:s,style:AT,children:o("layout_editor.property_modal.close")})]})]})]})}const vT={display:"flex",flexDirection:"column",width:420,maxWidth:"92vw"},_T={display:"flex",alignItems:"center",justifyContent:"space-between",padding:"12px 16px",borderBottom:"1px solid #e2e8f0",cursor:"move",userSelect:"none"},xT={border:"none",background:"transparent",fontSize:14,cursor:"pointer",color:"#64748b"},wT={margin:"8px 16px 0",padding:"6px 10px",fontSize:11,background:"#fef3c7",color:"#92400e",borderRadius:6},kT={margin:"8px 16px 0",padding:"6px 10px",fontSize:11,background:"#dbeafe",color:"#1e40af",borderRadius:6},ST={display:"flex",gap:2,padding:"0 12px",borderBottom:"1px solid #e2e8f0",overflowX:"auto"},CT={padding:"8px 10px",fontSize:12,border:"none",borderBottom:"2px solid transparent",background:"transparent",cursor:"pointer",whiteSpace:"nowrap"},$T={padding:16,overflowY:"auto",maxHeight:"60vh"},Qf={position:"sticky",top:-16,zIndex:2,background:"#fff",margin:"-16px -16px 0",padding:"6px 16px 0"},Rf={fontSize:12,color:"#94a3b8",padding:"16px 0",textAlign:"center"},ET={display:"flex",alignItems:"center",justifyContent:"space-between",padding:"12px 16px",borderTop:"1px solid #e2e8f0"},IT={padding:"6px 12px",fontSize:12,border:"1px solid #fecaca",borderRadius:6,background:"#fff",color:"#dc2626",cursor:"pointer"},AT={padding:"6px 12px",fontSize:12,border:"none",borderRadius:6,background:"#2563eb",color:"#fff",cursor:"pointer"},za="g7le:component-target-picked",Ma="g7le:component-target-pick-request";let TT=1;function DT({value:e,onChange:t,t:n,control:o}){const r=go(),s=e==null?"":String(e),a=o?.allowManualInput!==!1,l=u.useCallback(()=>{if(typeof window>"u")return;const d=`pick-${TT++}`,p=r.stack[r.stack.length-1]?.id;p&&r.minimize(p,n("layout_editor.target_picker.minimized_hint"));const f=y=>{const g=y.detail;g?.requestId===d&&(window.removeEventListener(za,f),p&&r.restore(p),!g.cancelled&&typeof g.id=="string"&&t(g.id))};window.addEventListener(za,f),window.dispatchEvent(new CustomEvent(Ma,{detail:{requestId:d}}))},[r,t,n]);return i.jsx("div",{className:"g7le-component-target-picker","data-testid":"g7le-component-target-picker",children:i.jsxs("div",{style:{display:"flex",gap:6,alignItems:"center"},children:[a?i.jsx("input",{type:"text",className:"g7le-widget g7le-widget--text","data-testid":"g7le-component-target-input",value:s,placeholder:n("layout_editor.target_picker.id_placeholder"),onChange:d=>t(d.target.value===""?void 0:d.target.value),style:{flex:1,minWidth:0,padding:"6px 8px",border:"1px solid #cbd5e1",borderRadius:6,fontSize:13}}):i.jsx("span",{"data-testid":"g7le-component-target-value",style:{flex:1,minWidth:0},children:s||n("layout_editor.target_picker.none")}),i.jsxs("button",{type:"button","data-testid":"g7le-component-target-pick",onClick:l,style:{display:"flex",alignItems:"center",gap:4,padding:"6px 10px",background:"#1e293b",color:"#fff",border:"none",borderRadius:6,cursor:"pointer",fontSize:13,whiteSpace:"nowrap"},children:[i.jsx("span",{"aria-hidden":"true",children:"🎯"}),n("layout_editor.target_picker.select_area")]})]})})}async function NT(e,t,n,o,r=fetch){const s=`/api/admin/templates/${encodeURIComponent(e)}/layout-extensions/${t}`;let a;try{a=await r(s,{headers:je(),credentials:"same-origin"})}catch(b){return{kind:"error",message:b instanceof Error?b.message:String(b)}}if(a.status===404)return{kind:"not_found"};if(!a.ok)return{kind:"error",message:`HTTP ${a.status}`};const l=await a.json().catch(()=>null),d=(l&&(l.data||l))??{};let c={};const p=d.content;if(typeof p=="string")try{c=JSON.parse(p)}catch{return{kind:"error",message:"invalid extension content"}}else p&&typeof p=="object"&&(c=p);const f=typeof d.lock_version=="number"?d.lock_version:0,y=Array.isArray(c.injections)?[...c.injections]:[],g=y.findIndex(b=>b&&b.position==="inject_props"&&b.target_id===n);if(g<0)return{kind:"injection_not_found"};y[g]={...y[g],props:o};const m={...c,injections:y};let h;try{h=await r(s,{method:"PUT",headers:je({"Content-Type":"application/json"}),credentials:"same-origin",body:JSON.stringify({content:m,expected_lock_version:f})})}catch(b){return{kind:"error",message:b instanceof Error?b.message:String(b)}}const v=await h.json().catch(()=>null);return h.ok?{kind:"success",newLockVersion:typeof v?.data?.lock_version=="number"?v.data.lock_version:f+1}:h.status===409?{kind:"conflict",currentVersion:v?.current_version??-1,yourVersion:v?.your_version??f}:{kind:"error",message:v?.message??`HTTP ${h.status}`}}const eg=new Map;function La(e,t){eg.set(e,t)}function zT(e){return e?eg.get(e)??null:null}const tg="g7le.clipboard";function ng(){try{return typeof window>"u"||!window.sessionStorage?null:window.sessionStorage}catch{return null}}function MT(e){const t=JSON.parse(JSON.stringify(e)),n=o=>{for(const r of Object.keys(o))r.startsWith("__")&&delete o[r];Array.isArray(o.children)&&o.children.forEach(n)};return n(t),t}function og(e,t=0){const n=ng();if(!n)return!1;try{const o={node:MT(e),copiedAt:t};return n.setItem(tg,JSON.stringify(o)),!0}catch{return!1}}function LT(){const e=ng();if(!e)return null;try{const t=e.getItem(tg);if(!t)return null;const n=JSON.parse(t);return!n||typeof n.node!="object"||n.node===null?null:JSON.parse(JSON.stringify(n.node))}catch{return null}}const qo={nw:{width:-1,height:-1},n:{width:0,height:-1},ne:{width:1,height:-1},e:{width:1,height:0},se:{width:1,height:1},s:{width:0,height:1},sw:{width:-1,height:1},w:{width:-1,height:0}};function Pa(e){if(typeof e=="number")return e;if(typeof e=="string"){const t=e.match(/^(-?\d+(?:\.\d+)?)px$/);if(t)return Number(t[1])}return null}function PT(e,t){const n=Array.isArray(e.options)?e.options:[],o=Array.isArray(e.scale)?e.scale:[],r=n.length>0?n.map(l=>l.value):o;if(r.length===0)return;let s=r[0],a=1/0;return r.forEach((l,d)=>{const c=Pa(l)??d*40,p=Math.abs(c-t);p{if(!t)return;const m=qo[y];if(m.width!==0&&!n||m.height!==0&&!o)return;const h=t.props?.style??{},v=s?.()??null,b=Pa(h.width)??(v?Math.round(v.width):0),x=Pa(h.height)??(v?Math.round(v.height):0);p.current={handle:y,startX:g.clientX,startY:g.clientY,startW:b,startH:x,node:t};const k=m.width!==0&&m.height!==0?"both":m.width!==0?"width":"height";d?.(k);let w=null;const _=E=>{const C=p.current;if(!C)return;const N=r||1,$=(E.clientX-C.startX)/N*qo[C.handle].width,A=(E.clientY-C.startY)/N*qo[C.handle].height;let z=C.node;if(qo[C.handle].width!==0&&n){const D=rg(n,C.startW+$);z=pn(z,n,D)}if(qo[C.handle].height!==0&&o){const D=rg(o,C.startH+A);z=pn(z,o,D)}w=z,a(z,k)},S=()=>{p.current=null,d?.(null),window.removeEventListener("pointermove",_),window.removeEventListener("pointerup",S),w&&l?.(w,k)};window.addEventListener("pointermove",_),window.addEventListener("pointerup",S)},[t,n,o,r,a,l,d]);return{enabledAxes:c,onHandlePointerDown:f}}const ig=320,sg=1920,WT=1024;function Oa(e){return Number.isFinite(e)?Math.round(Math.max(ig,Math.min(sg,e))):WT}function Ba(e={}){const{state:t,dispatch:n}=Je(),{previewDevice:o,previewZoom:r,previewCustomWidth:s,previewColorScheme:a}=t,l=o==="custom"?Oa(s):W6(o),d=u.useMemo(()=>{const g=e.availableWidth??l,m=l*r;return r>=1||m<=g?r:Math.min(r,g/l)},[l,r,e.availableWidth]),c=u.useCallback(g=>{n({type:"SET_PREVIEW_DEVICE",device:g})},[n]),p=u.useCallback(g=>{const m=Math.max(.5,Math.min(1,g));n({type:"SET_PREVIEW_ZOOM",zoom:m})},[n]),f=u.useCallback(g=>{n({type:"SET_PREVIEW_CUSTOM_WIDTH",width:Oa(g)})},[n]),y=u.useCallback(g=>{n({type:"SET_PREVIEW_COLOR_SCHEME",scheme:g})},[n]);return{device:o,deviceWidth:l,zoom:r,scale:d,customWidth:Oa(s),setDevice:c,setZoom:p,setCustomWidth:f,colorScheme:a,setColorScheme:y}}const gi="g7le-dnd-handle-";function jT(e){if(typeof e!="string"||!e.startsWith(gi))return null;const t=e.slice(gi.length);return t.length>0?t:null}function HT(e){return typeof CSS<"u"&&typeof CSS.escape=="function"?CSS.escape(e):e.replace(/["\\\]]/g,"\\$&")}function FT(e,t,n,o){const r=o?.handleFallback??!1;if(e instanceof HTMLElement){const s=e.closest("[data-editor-path]");if(s)return s;const a=e.closest('[data-testid^="g7le-"]');if(a&&!(a.getAttribute("data-testid")??"").startsWith(gi))return null;if(r){const l=e.closest(`[data-testid^="${gi}"]`);if(l&&t){const d=jT(l.getAttribute("data-testid"));if(d){const c=t.querySelector(`[data-editor-path="${HT(d)}"]`);if(c)return c}}}}if(r&&n&&typeof document<"u"&&typeof document.elementsFromPoint=="function")for(const s of document.elementsFromPoint(n.x,n.y)){const a=s.closest?.("[data-editor-path]");if(a)return a}return null}function KT(e,t,n){if(typeof e=="string"&&e.trim().length>0)return{color:e};const o=typeof n?.color=="string"&&n.color.length>0,r=typeof t=="string"&&t.trim().length>0;return o||r?{}:{color:"#0f172a"}}const VT="#ffffff",UT="#0f172a";function qT(e){const t=e.trim(),n=t.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i);if(n)return[Number(n[1]),Number(n[2]),Number(n[3])];const o=t.replace(/^#/,"");return/^[0-9a-f]{3}$/i.test(o)?[parseInt(o[0]+o[0],16),parseInt(o[1]+o[1],16),parseInt(o[2]+o[2],16)]:/^[0-9a-f]{6}$/i.test(o)?[parseInt(o.slice(0,2),16),parseInt(o.slice(2,4),16),parseInt(o.slice(4,6),16)]:null}function GT(e){if(!e||/transparent/i.test(e))return!1;const t=e.trim().match(/^oklch\(\s*([\d.]+)(%?)/i);if(t)return(t[2]==="%"?Number(t[1])/100:Number(t[1]))>=.62;const n=qT(e);if(!n)return!1;const[o,r,s]=n;return .299*o+.587*r+.114*s>=180}function YT(e,t,n){const o=typeof n?.color=="string"?n.color:null;return GT(e??o??t)?UT:VT}function XT({box:e,initialValue:t,isCustomKey:n,t:o,onCommit:r,onCancel:s,mirrorClassName:a,mirrorStyle:l,nodeEffectiveColor:d,onOpenTranslations:c}){const p=u.useRef(null),f=u.useRef(!1),[y,g]=u.useState(t.trim().length>0),[m,h]=u.useState(null);u.useEffect(()=>{const w=p.current;if(w){w.textContent=t,w.focus();try{const _=window.document.createRange();_.selectNodeContents(w);const S=window.getSelection();S?.removeAllRanges(),S?.addRange(_)}catch{}f.current=!1}},[t]),u.useEffect(()=>{const w=p.current;if(!w||typeof window.getComputedStyle!="function"){h(null);return}try{h(window.getComputedStyle(w).color||null)}catch{h(null)}},[a,l]);const v=()=>(p.current?.textContent??"").trim(),b=()=>{f.current||(f.current=!0,r(v()))},x=()=>{f.current||(f.current=!0,s())},k=w=>{w.key==="Enter"&&!w.shiftKey?(w.preventDefault(),b()):w.key==="Escape"&&(w.preventDefault(),x())};return i.jsxs("div",{className:"g7le-inline-text-editor","data-testid":"g7le-inline-text-editor","data-custom-key":n?"true":"false",style:{position:"absolute",left:e.left,top:e.top,minWidth:Math.max(e.width,60),minHeight:e.height,pointerEvents:"auto",zIndex:1e4},children:[i.jsx("div",{ref:p,role:"textbox","aria-label":o("layout_editor.inline_edit.aria_label"),"aria-multiline":"false","data-testid":"g7le-inline-text-editable",className:a,contentEditable:!0,suppressContentEditableWarning:!0,spellCheck:!1,onInput:()=>g(v().length>0),onKeyDown:k,onBlur:b,style:{outline:"2px solid #2563eb",outlineOffset:1,borderRadius:3,background:YT(d,m,l),padding:"2px 4px",minHeight:e.height,whiteSpace:"pre-wrap",wordBreak:"break-word",boxShadow:"0 2px 8px rgba(37, 99, 235, 0.25)",...l??{},...KT(d,a,l)}}),i.jsxs("div",{className:"g7le-inline-text-editor__hint","data-testid":"g7le-inline-text-editor-hint",style:{marginTop:2,display:"inline-flex",alignItems:"center",gap:4,whiteSpace:"nowrap"},children:[c?i.jsxs("button",{type:"button","data-testid":"g7le-inline-text-editor-open-translations",onMouseDown:w=>w.preventDefault(),onClick:w=>{w.preventDefault(),w.stopPropagation(),c()},title:o("layout_editor.inline_edit.open_translations"),style:{padding:"2px 8px",fontSize:11,background:n?"#eff6ff":"#f0fdf4",color:n?"#1d4ed8":"#15803d",border:`1px solid ${n?"#bfdbfe":"#bbf7d0"}`,borderRadius:4,display:"inline-flex",alignItems:"center",gap:4,cursor:"pointer",whiteSpace:"nowrap"},children:[i.jsx("span",{"aria-hidden":"true",children:"🌐"}),i.jsx("span",{children:o(n?"layout_editor.inline_edit.existing_key_hint":"layout_editor.inline_edit.new_key_hint")}),i.jsxs("span",{"aria-hidden":"true",style:{opacity:.7},children:["· ",o("layout_editor.inline_edit.open_translations")," ›"]})]}):i.jsx("span",{style:{padding:"2px 6px",fontSize:11,background:n?"#eff6ff":"#f0fdf4",color:n?"#1d4ed8":"#15803d",border:`1px solid ${n?"#bfdbfe":"#bbf7d0"}`,borderRadius:4,display:"inline-flex",alignItems:"center",gap:4,whiteSpace:"nowrap"},children:n?i.jsxs(i.Fragment,{children:["⌐ ",o("layout_editor.inline_edit.existing_key_hint")]}):i.jsxs(i.Fragment,{children:["✎ ",o("layout_editor.inline_edit.new_key_hint")]})}),!y&&i.jsx("span",{"data-testid":"g7le-inline-text-editor-empty-warn",style:{color:"#b91c1c",marginLeft:4},children:o("layout_editor.inline_edit.empty_warning")})]})]})}function JT({box:e,node:t,customKey:n,initialChipValue:o,chipParamLabels:r,onKeyify:s,templateIdentifier:a,locale:l,t:d,candidates:c,onInsertBinding:p,onRemoveBinding:f,onCommit:y,onCancel:g}){const[m,h]=u.useState(null),v=u.useRef(""),[b,x]=u.useState(!1),k=u.useRef(!1),w=u.useRef(null),_=Ft(),S=u.useRef(()=>{}),[E,C]=u.useState(null),[N,$]=u.useState(!1),A=u.useRef(n??null);u.useEffect(()=>{n&&A.current!==n&&(A.current=n)},[n]);const z=u.useMemo(()=>{if(!n&&r)return r;const L={},I=mt(typeof t.text=="string"?t.text:"");for(const P of I?.params??[])L[P.name]=Vt(P.expression);return L},[t.text,n,r]);u.useEffect(()=>{let L=!1;if(!n){const I=o??"";return v.current=I,h(I),()=>{L=!0}}return(async()=>{const I=It(n,l);if(I!==void 0){v.current=I,h(I);return}const P=await dn(a,n);if(L)return;const F=P?.values?.[l]??"";v.current=F,h(F)})(),()=>{L=!0}},[a,n,l,o]);const D=async()=>{if(k.current)return;k.current=!0;const L=m??"";if(L===v.current){y();return}x(!0);let I=A.current;if(!I){if(!s){x(!1),k.current=!1;return}if(I=await s(L),x(!1),!I){k.current=!1;return}A.current=I,y();return}const P=await _o(a,I,l,L);if(x(!1),P.kind==="error"){k.current=!1;return}_?.markDirty?.(),y()},O=()=>{k.current||(k.current=!0,g())},M=L=>{C(L)},B=async L=>{if(!(E===null||!p||N)){$(!0);try{const I=A.current;I&&m!==null&&m!==v.current&&(await _o(a,I,l,m),v.current=m);const P=await p(E,L);P!==null&&(h(P),v.current=P,_?.markDirty?.())}finally{$(!1),C(null)}}},T=async L=>{if(!(!f||N)){$(!0);try{const I=A.current;I&&m!==null&&m!==v.current&&(await _o(a,I,l,m),v.current=m);const P=await f(L);P!==null&&(h(P),v.current=P,_?.markDirty?.())}finally{$(!1)}}},H=L=>{L.key==="Enter"&&!L.shiftKey?(L.preventDefault(),D()):L.key==="Escape"&&(L.preventDefault(),O())};return S.current=()=>{D()},u.useEffect(()=>{const L=P=>{const F=w.current;if(!F)return;const G=P.target;if(G&&F.contains(G))return;const V=G;V&&V.closest&&V.closest('[data-testid="g7le-inline-toolbar"], .g7le-inline-toolbar')||S.current()},I=w.current?.ownerDocument??document;return I.addEventListener("pointerdown",L,!0),()=>I.removeEventListener("pointerdown",L,!0)},[]),i.jsxs("div",{ref:w,className:"g7le-inline-param-chip-editor","data-testid":"g7le-inline-param-chip-editor",onKeyDown:H,style:{position:"absolute",left:e.left,top:e.top,minWidth:Math.max(e.width,120),pointerEvents:"auto",zIndex:1e4,background:"#fff",border:"2px solid #2563eb",borderRadius:4,boxShadow:"0 2px 8px rgba(37,99,235,0.25)",padding:4},children:[m===null?i.jsx("div",{"data-testid":"g7le-inline-param-chip-loading",style:{fontSize:12,color:"#94a3b8",padding:4},children:d("layout_editor.translation.loading")}):i.jsx(yr,{value:m,onChange:h,t:d,paramLabels:z,testIdSuffix:"inline",onRequestInsert:p?M:void 0,onRemoveChip:f?L=>void T(L):void 0}),E!==null&&p&&i.jsx("div",{"data-testid":"g7le-inline-param-chip-insert-picker",style:{marginTop:4},children:i.jsx($t,{candidates:c??[],t:d,onSelect:L=>void B(L),testIdSuffix:"inline-insert",defaultOpen:!0,floating:!1})}),i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6,marginTop:4},children:[i.jsx("button",{type:"button","data-testid":"g7le-inline-param-chip-save",disabled:b,onMouseDown:L=>L.preventDefault(),onClick:()=>void D(),style:{fontSize:11,border:"none",borderRadius:6,background:"#2563eb",color:"#fff",padding:"4px 12px",cursor:"pointer"},children:d(b?"layout_editor.translation.saving":"layout_editor.translation.save")}),i.jsx("span",{style:{fontSize:10,color:"#94a3b8"},children:d("layout_editor.translation.placeholder_locked")})]})]})}const ZT=["fontWeight","fontBold","fontStyle","fontItalic","textDecoration","textUnderline","textAlign","fontSize","textColor"],QT={fontWeight:"B",fontBold:"B",fontStyle:"I",fontItalic:"I",textDecoration:"U",textUnderline:"U",textAlign:"≡",fontSize:"T",textColor:"A"},RT=[["fontWeight","fontBold"],["fontItalic","fontStyle"],["textUnderline","textDecoration"]];function ag({node:e,spec:t,t:n,onApplyControl:o,box:r,scope:s=Wt}){const a=typeof e.name=="string"?e.name:"",l=Li(t,a),d=Array.isArray(l?.styleControls)?l.styleControls:[];let c=ZT.filter(p=>d.includes(p)&&Ct(t,p)!==null);for(const p of RT){const f=p.filter(y=>c.includes(y));if(f.length>1){const y=f[0];c=c.filter(g=>!p.includes(g)||g===y)}}return c.length===0?null:i.jsx("div",{className:"g7le-inline-toolbar","data-testid":"g7le-inline-toolbar",role:"toolbar","aria-label":n("layout_editor.inline_edit.toolbar_label"),style:{position:"absolute",left:r.left,top:r.top-40,display:"inline-flex",gap:2,padding:4,background:"#0f172a",borderRadius:8,boxShadow:"0 4px 12px rgba(15, 23, 42, 0.35)",pointerEvents:"auto",zIndex:10001},children:c.map(p=>i.jsx(t4,{controlKey:p,node:e,spec:t,t:n,scope:s,onApplyControl:o},p))})}const e4={fontWeight:"bold",fontBold:"bold",fontStyle:"italic",fontItalic:"italic",textDecoration:"underline",textUnderline:"underline"};function lg(e,t){const n=r=>(r.split(/[-\s]/).pop()??r)===t||r===t;return e.find(r=>{const s=typeof r.value=="string"?r.value:"",a=Array.isArray(r.apply?.tokens)?r.apply.tokens:[];return n(s)||a.some(n)})?.value}function t4({controlKey:e,node:t,spec:n,t:o,scope:r,onApplyControl:s}){const[a,l]=u.useState(!1),d=Ct(n,e);if(!d)return null;const c=d.options,p=Array.isArray(c),f=Io(t,d,r),y=e4[e],g=y&&Array.isArray(c)?lg(c,y):void 0,m=!y,h=g!==void 0?f.value===g:p&&f.matched&&f.value!==void 0&&f.value!=="",v=o(`layout_editor.inline_edit.format.${e}`),b=_=>typeof _.label=="string"?_.label.startsWith("$t:")?o(_.label.slice(3)):_.label:String(_.value??""),x=()=>{s(e,_=>{const S=Io(_,d,r),E=Array.isArray(c)?lg(c,y):void 0,C=E!==void 0&&S.value===E?void 0:E;return pn(_,d,C,r)})},k=_=>{s(e,S=>{const C=Io(S,d,r).value===_?void 0:_;return pn(S,d,C,r)}),l(!1)},w=_=>{if(_.preventDefault(),_.stopPropagation(),!m&&Array.isArray(c)){x();return}l(S=>!S)};return i.jsxs("div",{style:{position:"relative",display:"inline-flex"},children:[i.jsxs("button",{type:"button","data-testid":`g7le-inline-format-${e}`,"data-active":h?"true":"false","aria-label":v,"aria-pressed":p?h:void 0,title:v,onMouseDown:_=>_.preventDefault(),onClick:w,style:{display:"inline-flex",alignItems:"center",gap:5,height:28,padding:"0 9px",fontSize:12,border:"none",borderRadius:5,cursor:"pointer",background:h?"#2563eb":"transparent",color:"#ffffff",whiteSpace:"nowrap"},children:[i.jsx("span",{"aria-hidden":"true",style:{fontWeight:e==="fontWeight"||e==="fontBold"?700:600,fontStyle:e==="fontStyle"||e==="fontItalic"?"italic":"normal",textDecoration:e==="textDecoration"||e==="textUnderline"?"underline":"none"},children:QT[e]}),i.jsx("span",{children:v}),m&&i.jsx("span",{"aria-hidden":"true",style:{fontSize:9,opacity:.8},children:"▾"})]}),m&&a&&i.jsx("div",{"data-testid":`g7le-inline-popover-${e}`,role:"listbox",onMouseDown:_=>_.preventDefault(),style:{position:"absolute",top:32,left:0,minWidth:140,maxHeight:240,overflowY:"auto",background:"#0f172a",border:"1px solid #334155",borderRadius:8,boxShadow:"0 8px 24px rgba(15,23,42,0.45)",padding:4,zIndex:10002},children:Array.isArray(c)&&c.length>0?c.map((_,S)=>{const E=f.value===_.value;return i.jsxs("button",{type:"button",role:"option","aria-selected":E,"data-testid":`g7le-inline-option-${e}-${String(_.value??S)}`,onMouseDown:C=>C.preventDefault(),onClick:C=>{C.preventDefault(),C.stopPropagation(),k(_.value)},style:{display:"flex",alignItems:"center",gap:8,width:"100%",padding:"6px 8px",fontSize:12,textAlign:"left",border:"none",borderRadius:5,cursor:"pointer",background:E?"#2563eb":"transparent",color:"#ffffff"},children:[typeof _.swatch=="string"&&i.jsx("span",{"aria-hidden":"true",style:{width:14,height:14,borderRadius:3,background:_.swatch,border:"1px solid rgba(255,255,255,0.3)"}}),i.jsx("span",{children:b(_)}),E&&i.jsx("span",{"aria-hidden":"true",style:{marginLeft:"auto"},children:"✓"})]},String(_.value??S))}):i.jsx(n4,{testid:`g7le-inline-freeinput-${e}`,placeholder:o(`layout_editor.inline_edit.prompt.${e}`),initial:typeof f.value=="string"?f.value:"",onSubmit:_=>{s(e,S=>pn(S,d,_,r)),l(!1)}})})]})}function n4({testid:e,placeholder:t,initial:n,onSubmit:o}){const[r,s]=u.useState(n);return i.jsx("input",{type:"text","data-testid":e,value:r,placeholder:t,autoFocus:!0,onMouseDown:a=>a.stopPropagation(),onChange:a=>s(a.currentTarget.value),onKeyDown:a=>{a.key==="Enter"&&(a.preventDefault(),o(r.trim()))},style:{width:130,padding:"6px 8px",fontSize:12,border:"1px solid #334155",borderRadius:5,background:"#1e293b",color:"#ffffff"}})}function o4(e){const{frameEl:t,manifest:n,nesting:o,componentPalette:r,spec:s=null,permissionCandidates:a,resolveRouteMatch:l,onNavigateToDestination:d,onSessionAddedPathsChange:c,onEditProps:p}=e,{state:f,dispatch:y}=Je(),{t:g}=tt(),m=Ba(),h=u.useCallback((W,X)=>{const R=window.G7Core;if(R&&typeof R.t=="function"){const de=R.t(W,X);if(de&&de!==W&&!de.startsWith("$t:"))return de}return g(W,X)},[g]),v=Ft(),b=e7(50),x=go(),k=u.useRef([]),w=u.useMemo(()=>v?.document?.raw?.components??[],[v?.document?.raw?.components]),_=u.useMemo(()=>w.length===0?null:{children:w},[w]),S=u.useMemo(()=>{if(f.editMode!=="extension")return;const W=/^__extension__\/(\d+)/.exec(f.selectedRoute?.path??"");return W?Number(W[1]):void 0},[f.editMode,f.selectedRoute?.path]),E=u.useMemo(()=>{if(f.editMode==="iteration_item"){const W=v?.document?.iterationContext?.sourceIndexPath;return Array.isArray(W)&&W.length>0?W:null}if(f.editMode==="modal"){const W=v?.document?.modalContext?.editIndexPath;return Array.isArray(W)&&W.length>0?W:null}return null},[f.editMode,v?.document?.iterationContext?.sourceIndexPath,v?.document?.modalContext?.editIndexPath]),C=UE({rootNode:_,editMode:f.editMode,currentExtensionId:S,editableRootPath:E,pathParser:Ze,resolveRouteMatch:l}),[N,$]=u.useState(null),[A,z]=u.useState(null),[D,O]=u.useState(null),M=u.useRef(null);M.current=D;const[B,T]=u.useState(null),H=u.useRef(null),L=u.useRef(null);L.current=C.selectedPath;const I=Db(),[P,F]=u.useState(null),G=u.useCallback(W=>{if(!t)return;const X=Ze(W);if(!X)return;const R={children:Me.current.components},de=We(R,X),ye=I.classify(de);if(!ye.editable){Et({op:"inline_edit_blocked_binding",sourceState:ye.sourceState,componentPath:at(X),timestamp:Date.now()});return}const we=co(t,W);if(!we)return;const Te=de?.props??{},ke=cg(t,W);F({path:X,domPath:W,box:we,initialValue:ye.displayValue,isCustomKey:ye.sourceState==="custom_key",isParamKey:ye.isParamKey===!0,customKey:ye.customKey,chipValue:ye.chipValue,chipParamLabels:ye.chipParamLabels,mirrorClassName:typeof Te.className=="string"?Te.className:void 0,mirrorStyle:Te.style??void 0,nodeEffectiveColor:ke}),Et({op:"inline_edit_enter",sourceState:ye.sourceState,componentPath:at(X),timestamp:Date.now()})},[t,I]),V=u.useCallback(()=>{if(!t){$(null),z(null);return}z(co(t,C.selectedPath)),$(co(t,C.hoverPath))},[t,C.selectedPath,C.hoverPath,w]);u.useEffect(()=>{if(V(),typeof window<"u"&&typeof window.requestAnimationFrame=="function"){const W=window.requestAnimationFrame(()=>V());return()=>window.cancelAnimationFrame(W)}},[V]),u.useEffect(()=>{if(t)return _p(V,t)},[t,V]);const Y=u.useCallback((W,X)=>{if(typeof window>"u")return;window.dispatchEvent(new CustomEvent(za,{detail:X===null?{requestId:W,cancelled:!0}:{requestId:W,id:X}})),O(null),T(null);const R=H.current;R?C.handleSelect("",{dataset:{editorPath:R}}):C.clearSelection(),H.current=null},[C]);u.useEffect(()=>{if(typeof window>"u")return;const W=X=>{const R=X.detail;if(typeof R?.requestId!="string")return;const de=M.current;de&&de!==R.requestId&&Y(de,null),H.current=L.current,O(R.requestId)};return window.addEventListener(Ma,W),()=>window.removeEventListener(Ma,W)},[Y]),u.useEffect(()=>{if(!D)return;const W=X=>{X.key==="Escape"&&(X.preventDefault(),Y(D,null))};return window.addEventListener("keydown",W,!0),()=>window.removeEventListener("keydown",W,!0)},[D,Y]),u.useEffect(()=>{if(!t)return;const W=f.editMode==="iteration_item"||f.editMode==="modal",X=(xe,Ee)=>FT(xe,t,Ee?{x:Ee.clientX,y:Ee.clientY}:void 0,{handleFallback:W}),R=xe=>{const Ee=xe.dataset.editorPath??"",Fe=Ee?Ze(Ee):null,zt={children:Me.current.components},gt=Fe?We(zt,Fe):null,Mt=gt&&typeof gt.props?.id=="string"?gt.props.id:typeof gt?.id=="string"?gt.id:"";return Mt.startsWith("auto_")?"":Mt},de=xe=>{const Ee=X(xe.target,xe);if(!Ee)return;const Fe=M.current;if(Fe){xe.preventDefault(),xe.stopPropagation();const zt=R(Ee);zt&&Y(Fe,zt);return}xe.preventDefault(),xe.stopPropagation(),C.handleSelect(Ee.dataset.editorId??"",{currentTarget:Ee,dataset:Ee.dataset})},ye=xe=>{const Ee=X(xe.target,xe);if(M.current){if(!Ee){T(null);return}const Fe=co(t,Ee.dataset.editorPath??null);if(!Fe){T(null);return}T({box:Fe,hasId:R(Ee)!==""});return}if(!Ee){C.handleHover(null,{});return}C.handleHover(Ee.dataset.editorId??"",{currentTarget:Ee,dataset:Ee.dataset})},we=()=>{if(M.current){T(null);return}C.handleHover(null,{})},Te=xe=>{const Ee=X(xe.target,xe);if(!Ee)return;const Fe=Ee.dataset.editorPath??"";Fe&&(xe.preventDefault(),xe.stopPropagation(),G(Fe))},ke=t.parentElement??t;return ke.addEventListener("click",de,!0),ke.addEventListener("mousemove",ye,!0),ke.addEventListener("mouseleave",we,!0),ke.addEventListener("dblclick",Te,!0),()=>{ke.removeEventListener("click",de,!0),ke.removeEventListener("mousemove",ye,!0),ke.removeEventListener("mouseleave",we,!0),ke.removeEventListener("dblclick",Te,!0)}},[t,C,G,f.editMode,Y]);const Z=u.useMemo(()=>{const W=C.selectedPath;if(!W)return null;const X=W.match(/^(.*)\.children\.\d+$/);return X?X[1]:null},[C.selectedPath]),se=u.useCallback(()=>{Z&&C.handleSelect("",{dataset:{editorPath:Z}})},[Z,C]),re=u.useMemo(()=>!t||!C.selectedPath?null:t.querySelector(`[data-editor-path="${sn(C.selectedPath)}"]`),[t,C.selectedPath]),U=u.useMemo(()=>C.selectedPath?Ze(C.selectedPath):null,[C.selectedPath]),ee=u.useMemo(()=>{if(!U)return null;const W=U.find(et);if(!W)return null;const{i18nKey:X,raw:R}=dC(W.responsive);return X?g(X):g("layout_editor.overlay.branch_custom",{range:R})},[U,g]),fe=u.useMemo(()=>C.selectedPath?C.selectedIsIteration&&C.selectedIterationSourcePath?C.selectedIterationSourcePath:C.selectedPath:null,[C.selectedPath,C.selectedIsIteration,C.selectedIterationSourcePath]),K=u.useMemo(()=>fe?Ze(fe):null,[fe]),ne=u.useMemo(()=>!t||!fe?null:t.querySelector(`[data-editor-path="${sn(fe)}"]`),[t,fe]),me=u.useMemo(()=>{if(ne?.parentElement)return ne.parentElement;if(!t||!fe)return null;const W=fe.match(/^(.*)\.children\.\d+$/);return W?t.querySelector(`[data-editor-path="${sn(W[1])}"]`):null},[ne,t,fe]),Ce=u.useMemo(()=>{const W=C.selectedNode;return W&&(typeof W.name=="string"?W.name:typeof W.type=="string"?W.type:"")||null},[C.selectedNode]),ze=u.useMemo(()=>{if(C.selectedLockKind!=="extension")return null;const W=C.selectedNode?.__source;if(!W||W.kind!=="extension")return null;const X=W.extensionName?.trim(),R=W.extensionIdentifier?.trim();return X&&R?`${X} (${R})`:X||R||W.extensionSourceType||null},[C.selectedLockKind,C.selectedNode]),Se=ZE({parentEl:me,selectedPath:K,selectedEl:ne}),qe=u.useMemo(()=>!t||!fe||fe===C.selectedPath?A:co(t,fe),[t,fe,A,C.selectedPath,w]),De=u.useRef(null),Pe=u.useCallback(()=>{const W=De.current;W&&(x.close(W),De.current=null)},[x]),Qe=u.useRef(()=>{}),Me=u.useRef({manifest:n,nesting:o,componentPalette:r,components:w});Me.current={manifest:n,nesting:o,componentPalette:r,components:w};const Re=u.useCallback(W=>{const X=W.insertion?.parentPath??[],R=W.insertion?.index??0,de=Me.current,ye=dg(de.components,X);Pe();const we=x.open({ariaLabel:h("layout_editor.palette.title"),width:880,maxHeightRatio:.82,content:u.createElement(xf,{manifest:de.manifest,nesting:de.nesting,componentPalette:de.componentPalette??null,targetParentPath:X,targetContainerName:ye,onInsert:(Te,ke,xe)=>{Qe.current(Te,ke,xe)},insertionIndex:R,onClose:()=>Pe(),editorTemplateId:f.templateIdentifier,editorLocale:f.locale}),onClose:()=>{De.current=null}});De.current=we},[x,Pe]),ve=u.useMemo(()=>{if(!f.isPaletteOpen)return null;if(!U)return{parentPath:[],index:w.length};if(C.selectedLockKind!=="none"){const de=K;return!de||de.length===0?{parentPath:[],index:w.length}:{parentPath:de.slice(0,-1),index:(de[de.length-1]??0)+1}}const X=C.selectedNode,R=Array.isArray(X?.children)?X.children.length:0;return R2(X?.name,R,U,o,w.length)},[f.isPaletteOpen,U,K,C.selectedLockKind,C.selectedNode,w.length,o]),te=u.useCallback(()=>{y({type:"SET_PALETTE_OPEN",open:!1})},[y]),he=u.useRef(null);u.useEffect(()=>{if(!f.isPaletteOpen){const ye=he.current;ye&&(x.close(ye),he.current=null);return}if(!ve||he.current)return;const W=ve,X=Me.current,R=dg(X.components,W.parentPath),de=x.open({ariaLabel:h("layout_editor.palette.title"),width:880,maxHeightRatio:.82,content:u.createElement(xf,{manifest:X.manifest,nesting:X.nesting,componentPalette:X.componentPalette??null,targetParentPath:W.parentPath,targetContainerName:R,onInsert:(ye,we,Te)=>{Qe.current(ye,we,Te),te()},insertionIndex:W.index,onClose:()=>te(),editorTemplateId:f.templateIdentifier,editorLocale:f.locale}),onClose:()=>{he.current=null,y({type:"SET_PALETTE_OPEN",open:!1})}});he.current=de},[f.isPaletteOpen,ve,x,te,y]);const Q=u.useRef({layoutName:null,pushed:!1});u.useEffect(()=>{const W=v?.document?.layoutName??null;if(!W){Q.current={layoutName:null,pushed:!1};return}Q.current.layoutName!==W&&(b.clear(),b.push({actionKind:"inline_text_edit",label:"baseline",snapshot:w}),Q.current={layoutName:W,pushed:!0})},[v?.document?.layoutName,w,b]);const ie=u.useRef(0);u.useEffect(()=>{const W=v?.saveSuccessCounter??0;W!==ie.current&&(ie.current=W,W!==0&&(b.clear(),b.push({actionKind:"inline_text_edit",label:"save_baseline",snapshot:w}),k.current=[],c?.([])))},[v?.saveSuccessCounter,w,b,c]);const _e=u.useRef(0);u.useEffect(()=>{const W=v?.reloadCounter??0;W!==_e.current&&(_e.current=W,W!==0&&(Q.current={layoutName:null,pushed:!1},k.current=[],c?.([])))},[v?.reloadCounter,c]);const ge=u.useCallback((W,X,R)=>{if(!v)return;let de=[];v.patchLayout(ke=>{const xe={children:ke},Ee=Wa(xe,X),Fe=R<0?Array.isArray(Ee?.children)?Ee.children.length:0:R,gt=Ji(xe,X,Fe,W).children??[];return de=gt,gt}),b.push({actionKind:"insert",label:`insert ${W.name??W.type??""}`,snapshot:de});const ye=Wa({children:de},X),we=R<0?Array.isArray(ye?.children)?ye.children.length-1:0:R,Te=at([...X,we]);if(k.current=[...k.current,Te],c?.(k.current),t){const ke=`[data-editor-path="${sn(Te)}"]`,xe=()=>{const Ee=t.querySelector(ke);Ee&&typeof Ee.scrollIntoView=="function"&&Ee.scrollIntoView({behavior:"smooth",block:"center",inline:"nearest"})};typeof window<"u"&&typeof window.requestAnimationFrame=="function"?window.requestAnimationFrame(()=>window.requestAnimationFrame(xe)):setTimeout(xe,32)}},[v,b,c,t]);Qe.current=ge;const Ae=u.useCallback(()=>{if(!v||!C.selectedNode||!U)return;const W=Qi(C.selectedNode);let X=[];v.patchLayout(R=>{const de={children:R},ye=U.slice(0,-1),we=U[U.length-1]??0,ke=Ji(de,ye,we+1,W).children??[];return X=ke,ke}),b.push({actionKind:"insert",label:"duplicate",snapshot:X})},[v,C.selectedNode,U,b]),Ne=u.useCallback(()=>{if(!v||!U)return;let W=[];v.patchLayout(X=>{const ye=Zi({children:X},U).children??[];return W=ye,ye}),b.push({actionKind:"remove",label:"remove",snapshot:W}),C.clearSelection()},[v,U,b,C]),Le=u.useMemo(()=>{if(!C.selectedNode||!U||U.some(et))return null;const W=C.selectedNode;if(!Array.isArray(W.children))return null;const X=lC(f.previewDevice,W,f.previewCustomWidth);return{mode:X.mode,key:X.key,sourceKey:X.sourceKey}},[C.selectedNode,U,f.previewDevice,f.previewCustomWidth]),q=u.useCallback(()=>{if(!v||!U||Le?.mode!=="separate")return;const W=Le.key,X=Le.sourceKey;let R=[];v.patchLayout(de=>(R=kb({children:de},U,W,X).children??[],R)),b.push({actionKind:"insert",label:"separate_branch",snapshot:R})},[v,U,Le,b]),ae=u.useCallback(()=>{if(!v||!U||Le?.mode!=="merge")return;const W=Le.key;let X=[];v.patchLayout(R=>(X=Sb({children:R},U,W).children??[],X)),b.push({actionKind:"remove",label:"merge_branch",snapshot:X})},[v,U,Le,b]),pe=u.useCallback(W=>{switch(W){case"mobile":return g("layout_editor.context_menu.device_mobile");case"portable":return g("layout_editor.context_menu.device_portable");case"tablet":return g("layout_editor.context_menu.device_tablet");case"desktop":return g("layout_editor.context_menu.device_desktop");default:return g("layout_editor.context_menu.device_custom",{range:W})}},[g]),$e=u.useMemo(()=>{if(!Le)return null;const W=pe(Le.key);return{separate:g("layout_editor.context_menu.separate_branch_device",{device:W}),merge:g("layout_editor.context_menu.merge_branch_device",{device:W})}},[Le,g,pe]),be=u.useMemo(()=>{if(!C.selectedNode||!U)return[];if(U.some(et))return[];const W=Kr(f.previewDevice,C.selectedNode,f.previewCustomWidth);return B6(C.selectedNode,W).map(X=>({key:X,label:pe(X)}))},[C.selectedNode,U,f.previewDevice,f.previewCustomWidth,pe]),j=u.useCallback(W=>{if(W==="desktop"||W==="tablet"||W==="mobile"||W==="portable"){m.setDevice(W);return}m.setDevice(W)},[m]),oe=u.useCallback(()=>{C.selectedNode&&og(C.selectedNode)},[C.selectedNode]),ue=u.useCallback(()=>{C.selectedNode&&(og(C.selectedNode),Ne())},[C.selectedNode,Ne]),J=u.useCallback(()=>{if(!v)return;const W=LT();if(!W)return;const X=Qi(W);if(U&&U.length>0){const R=U.slice(0,-1),de=(U[U.length-1]??0)+1;ge(X,R,de)}else ge(X,[],w.length)},[v,U,ge,w.length]),le=u.useCallback((W,X)=>{b.push({actionKind:"move",label:X,snapshot:W})},[b]),Ie=u.useCallback(()=>{if(C.selectedNavAffordance==="route_in_tree"&&C.selectedNavTargetPath){if(d){d(C.selectedNavTargetPath);return}y({type:"SELECT_ROUTE",route:{path:C.selectedNavTargetPath,layoutName:null}})}},[C.selectedNavAffordance,C.selectedNavTargetPath,d,y]),dt=u.useCallback(()=>{const W=xb(C.selectedBaseLayout,f.selectedRoute?.layoutName);W&&y({type:"ENTER_BASE_EDIT",layoutName:W})},[C.selectedBaseLayout,f.selectedRoute?.layoutName,y]),pt=u.useCallback(()=>{const W=C.selectedNode?.__source?.extensionId;W!=null&&y({type:"ENTER_EXTENSION_EDIT",extensionId:String(W)})},[C.selectedNode,y]),ot=u.useCallback(()=>{const W=C.selectedIterationSourcePath;W&&y({type:"ENTER_ITERATION_ITEM_EDIT",sourcePath:W})},[C.selectedIterationSourcePath,y]),Oe=u.useRef(null),He=u.useRef(null),Ye=u.useRef(null),Tt=u.useRef({colorScheme:"base",breakpoint:"base"}),Dt=Oe.current!==null?x.stack.some(W=>W.id===Oe.current):!1,Nt=u.useMemo(()=>Ad(f.routeTree,h),[f.routeTree,h]),_n=u.useMemo(()=>Td(v?.document?.raw),[v?.document?.raw]),xn=u.useMemo(()=>Dd(s),[s?.states]),Zt=u.useMemo(()=>{const W=v?.document?.raw,X=kf(W?.components),R=W?.initIsolated,de=R&&typeof R=="object"&&!Array.isArray(R)?Object.keys(R):[];return UI(X,de)},[v?.document?.raw]),jn=u.useMemo(()=>{const W=v?.document?.raw;return kf(W?.components).map(X=>X.scopeId).filter(X=>typeof X=="string"&&X.length>0)},[v?.document?.raw]),an=Id({raw:v?.document?.raw,spec:s}),$i=u.useCallback((W,X)=>{if(!v)return;const R={children:Me.current.components},de=We(R,W);if(!de)return;Tt.current={colorScheme:f.previewColorScheme==="dark"?"dark":"base",breakpoint:Kr(f.previewDevice,de,f.previewCustomWidth)};const ye=t?.getBoundingClientRect(),we=A&&ye?{left:ye.left+A.left,top:ye.top+A.top,width:A.width,height:A.height}:void 0,Te=re,ke=W.find(et),xe=ke?pe(ke.responsive):null,Ee=W.filter(wn=>typeof wn=="number"),Fe=rb({children:Me.current.components},Ee,ib(an)),zt=Fe.length>0?[...Bi({iterationVars:Fe}),...an]:an,gt=(wn,Qz)=>u.createElement(mT,{key:`pem-${at(He.current??[])}`,node:wn,spec:s,manifest:Me.current.manifest,t:h,permissionCandidates:a,pageCandidates:Nt,dataSourceCandidates:_n,stateKeyCandidates:xn,bindingCandidates:zt,liveElement:Te,templateIdentifier:f.templateIdentifier,initialTab:Qz,branchLabel:xe,isolatedScopeIdCandidates:Zt,usedScopeIds:jn,onSaveInjectedProps:async(bt,kn)=>{const Ei=typeof wn.id=="string"?wn.id:"",Hn=await NT(f.templateIdentifier,bt,Ei,kn);if(st({op:"save",layoutName:`extension:${bt}`,editMode:f.editMode,saveTarget:"layout_extension",endpoint:`/api/admin/templates/${f.templateIdentifier}/layout-extensions/${bt}`,isDirty:!0,timestamp:Date.now()}),Hn.kind!=="success")throw new Error(Hn.kind==="conflict"?h("layout_editor.save.conflict"):Hn.kind==="injection_not_found"?"injection not found":Hn.kind==="not_found"?"extension not found":Hn.message??"save failed")},scope:Tt.current,onScopeChange:bt=>{Tt.current=bt;const kn=Oe.current;kn&&x.open({id:kn,ariaLabel:h("layout_editor.property_modal.title"),width:420,draggable:!0,avoidRect:we,content:gt(wn),onClose:()=>{Oe.current=null,He.current=null}})},onPatchNode:bt=>{let kn=[];v.patchLayout(Hn=>(kn=Ot({children:Hn},W,()=>bt).children??[],kn)),b.push({actionKind:"inline_text_edit",label:"edit_props",snapshot:kn}),or({source:"style_control",componentPath:at(W),componentName:typeof bt.name=="string"?bt.name:null,patchKey:null,timestamp:Date.now()});const Ei=Oe.current;Ei&&x.open({id:Ei,ariaLabel:h("layout_editor.property_modal.title"),width:420,draggable:!0,avoidRect:we,content:gt(bt),onClose:()=>{Oe.current=null,He.current=null}})},onDelete:()=>{const bt=Oe.current;bt&&x.close(bt),Ne()},onClose:()=>{const bt=Oe.current;bt&&x.close(bt)}});He.current=W;const Mt=x.open({ariaLabel:h("layout_editor.property_modal.title"),width:420,draggable:!0,avoidRect:we,content:gt(de,X),onClose:()=>{Oe.current=null,He.current=null,Ye.current=null}});Oe.current=Mt,Ye.current=wn=>{x.open({id:Mt,ariaLabel:h("layout_editor.property_modal.title"),width:420,draggable:!0,avoidRect:we,content:gt(wn),onClose:()=>{Oe.current=null,He.current=null,Ye.current=null}})}},[v,s,h,a,Nt,_n,xn,an,re,b,x,Ne,t,A,f.previewColorScheme,f.previewDevice,f.previewCustomWidth,pe]);u.useEffect(()=>{const W=Ye.current,X=He.current;if(!W||!X)return;const R={children:Me.current.components},de=We(R,X);de?W(de):Oe.current&&x.close(Oe.current)},[w]);const Lz=()=>{if(!Dt)return null;const X={position:"absolute",background:"rgba(15, 23, 42, 0.45)",pointerEvents:"auto",zIndex:r7},R=ke=>{ke.preventDefault(),ke.stopPropagation()};if(!A)return i.jsx("div",{"data-testid":"g7le-edit-lock-full",onClick:R,onMouseMove:R,style:{...X,inset:0}});const{left:de,top:ye,width:we,height:Te}=A;return i.jsxs("div",{"data-testid":"g7le-edit-lock",style:{position:"absolute",inset:0,pointerEvents:"none"},children:[i.jsx("div",{"data-testid":"g7le-edit-lock-top",onClick:R,onMouseMove:R,style:{...X,left:0,top:0,right:0,height:Math.max(0,ye)}}),i.jsx("div",{"data-testid":"g7le-edit-lock-bottom",onClick:R,onMouseMove:R,style:{...X,left:0,top:ye+Te,right:0,bottom:0}}),i.jsx("div",{"data-testid":"g7le-edit-lock-left",onClick:R,onMouseMove:R,style:{...X,left:0,top:ye,width:Math.max(0,de),height:Te}}),i.jsx("div",{"data-testid":"g7le-edit-lock-right",onClick:R,onMouseMove:R,style:{...X,left:de+we,top:ye,right:0,height:Te}})]})},yl=u.useCallback(()=>{if(U&&gp(C.selectedLockKind)){if(p&&C.selectedPath){p(C.selectedPath);return}$i(U)}},[U,C.selectedPath,C.selectedLockKind,p,$i]),{scale:bl}=m,ml=typeof C.selectedNode?.name=="string"?C.selectedNode.name:"",Nh=s?.componentCapabilities?.[ml]??null,zh=Array.isArray(Nh?.styleControls)?Nh.styleControls:[],Pz=zh.includes("width")?Ct(s,"width"):null,Oz=zh.includes("height")?Ct(s,"height"):null,Bz=u.useCallback((W,X)=>{if(!v||!U)return;let R=[];v.patchLayout(de=>(R=Ot({children:de},U,()=>W).children??[],R)),or({source:"resize",componentPath:at(U),componentName:ml||null,patchKey:X==="height"?"height":"width",resizeAxis:X,timestamp:Date.now()})},[v,U,ml]),Wz=u.useCallback((W,X)=>{if(!v||!U)return;let R=[];v.patchLayout(de=>(R=Ot({children:de},U,()=>W).children??[],R)),b.push({actionKind:"inline_text_edit",label:`resize ${X}`,snapshot:R})},[v,U,b]),jz=u.useCallback(()=>{if(!re)return null;const W=re.getBoundingClientRect(),X=bl||1;return{width:W.width/X,height:W.height/X}},[re,bl]),nr=BT({node:C.selectedNode,widthControl:Pz,heightControl:Oz,scale:bl,measureStartSize:jz,onResize:Bz,onResizeEnd:Wz}),Hz=u.useCallback((W,X)=>{nr.onHandlePointerDown(W,X)},[nr]),vl=u.useCallback(()=>{const W=b.undo();!W||!v||v.setLayoutComponents(W.snapshot)},[b,v]),_l=u.useCallback(()=>{const W=b.redo();!W||!v||v.setLayoutComponents(W.snapshot)},[b,v]);n7({onUndo:vl,onRedo:_l});const Fz=u.useMemo(()=>({copy:oe,cut:ue,paste:J,delete:Ne,openProps:yl,selectParent:Z?se:void 0,deselect:C.clearSelection}),[oe,ue,J,Ne,yl,Z,se,C.clearSelection]);Ju({handlers:Fz,hasSelection:!!C.selectedPath}),u.useEffect(()=>(window.__g7LayoutEditorHasSelection=!!C.selectedPath,()=>{try{delete window.__g7LayoutEditorHasSelection}catch{}}),[C.selectedPath]),u.useEffect(()=>(window.__g7LayoutEditorHistory={undo:vl,redo:_l,canUndo:b.canUndo,canRedo:b.canRedo},()=>{try{delete window.__g7LayoutEditorHistory}catch{}}),[vl,_l,b.canUndo,b.canRedo]);const Kz=u.useCallback(W=>{const X=P;F(null),X&&(async()=>{if((await I.commit(X.path,W)).kind==="created"&&v){const de=v.document?.raw?.components??[];b.push({actionKind:"inline_text_edit",label:"inline_create_key",snapshot:de})}})()},[P,I,v,b]),Mh=u.useCallback(()=>{const W=P;F(null),I.trackCancel(W?.path??null)},[P,I]),Vz=u.useCallback(()=>{F(null)},[]),Uz=u.useCallback(async W=>{const X=P;if(!X)return null;const R=await I.keyifyChipValue(X.path,W);if(R.kind!=="created"||!R.translationKey)return null;const de=v?.document?.raw?.components??[];return b.push({actionKind:"inline_text_edit",label:"inline_keyify_chip",snapshot:de}),R.translationKey},[P,I,v,b]),qz=u.useCallback(()=>{const W=P;W&&(F(null),C.handleSelect("",{dataset:{editorPath:W.domPath}}),$i(W.path,"translation"))},[P,C,$i]),xt=u.useMemo(()=>{if(!P)return null;const W=v?.document?.raw?.components??w;return We({children:W},P.path)},[P,v?.document,w]),Gz=u.useCallback(async(W,X)=>{const R=P;if(!v||!R)return null;const de=typeof xt?.text=="string"?xt.text:"";let ye;if(R.isParamKey)ye=await _r(f.templateIdentifier,f.locale,de,W,X.sourceId,X.path,"scalar");else{const Fe=zt=>zt.replace(/\$t:[a-zA-Z0-9._-]+/g,gt=>{const Mt=h(gt.slice(3));return Mt&&Mt!==gt.slice(3)&&!Mt.startsWith("$t:")?Mt:""}).replace(/\s+/g," ").trim();ye=await ss(f.templateIdentifier,f.selectedRoute?.layoutName??null,f.locale,de,W,X.sourceId,X.path,"scalar",Fe,h)}if(ye.kind==="error")return null;const we=R.path;let Te=[];v.patchLayout(Fe=>(Te=Ot({children:Fe},we,Mt=>({...Mt,text:ye.text})).children??[],Te)),b.push({actionKind:"inline_text_edit",label:"inline_insert_binding",snapshot:Te});const ke=ye.kind==="ok"&&"key"in ye?ye.key:void 0;!R.isParamKey&&ke&&F(Fe=>Fe&&Fe.path===we?{...Fe,isParamKey:!0,customKey:ke,chipValue:void 0,chipParamLabels:void 0}:Fe);try{window.dispatchEvent(new CustomEvent(kt,{detail:{templateIdentifier:f.templateIdentifier,locale:f.locale}}))}catch{}const xe=mt(ye.text),Ee=R.customKey??xe?.key??"";return Ee?It(Ee,f.locale)??null:null},[v,P,xt,f.templateIdentifier,f.locale,f.selectedRoute,b,h]),Yz=u.useCallback(async W=>{const X=P;if(!v||!X)return null;const R=typeof xt?.text=="string"?xt.text:"",de=mt(R),ye=X.customKey??de?.key??null,we=ho(R,W),Te=X.path;let ke=[];return v.patchLayout(xe=>(ke=Ot({children:xe},Te,zt=>({...zt,text:we})).children??[],ke)),b.push({actionKind:"inline_text_edit",label:"inline_remove_binding",snapshot:ke}),ye&&await xr(f.templateIdentifier,ye,W,f.locale),ye?It(ye,f.locale)??null:null},[v,P,xt,f.templateIdentifier,f.locale,b]),[xl,Lh]=u.useState({}),Xz=["fontSize","color","fontStyle","textDecoration","textAlign","letterSpacing","lineHeight","fontFamily"];u.useEffect(()=>{if(!P||!t||typeof MutationObserver>"u"){Lh({});return}const W=`[data-editor-path="${sn(P.domPath)}"]`,X=()=>{const ye=t.querySelector(W);if(!ye)return;const we={};for(const ke of Xz){const xe=ye.style.getPropertyValue(ke.replace(/([A-Z])/g,"-$1").toLowerCase());xe&&(we[ke]=xe)}const Te=cg(t,P.domPath)??void 0;Lh({className:ye.className,style:we,effectiveColor:Te})};X();const R=t.querySelector(W);if(!R)return;const de=new MutationObserver(X);return de.observe(R,{attributes:!0,attributeFilter:["class","style"]}),de.observe(t,{childList:!0,subtree:!0}),()=>de.disconnect()},[P?.path?at(P.path):null,t]);const Ph=u.useCallback((W,X)=>{if(!v||!P)return;const R=P.path;let de=[],ye=null;v.patchLayout(we=>(de=Ot({children:we},R,xe=>{const Ee=X(xe);return ye=typeof Ee.name=="string"?Ee.name:null,Ee}).children??[],de)),b.push({actionKind:"inline_text_edit",label:`inline_format ${W}`,snapshot:de}),or({source:"style_control",componentPath:at(R),componentName:ye,patchKey:W,timestamp:Date.now()})},[v,P,b]),ft=u.useMemo(()=>{const W=C.selectedPath;if(!W||!_)return null;const X=[];let R=W;for(;R;){X.push(R);const de=R.match(/^(.*)\.children\.\d+$/);R=de?de[1]:null}for(const de of X){const ye=Ze(de);if(!ye)continue;const we=We(_,ye),Te=typeof we?.name=="string"?we.name:null;if(!Te)continue;const ke=Li(s,Te),xe=ke?.canvasOverlay?.kind;if(typeof xe!="string"||!xe||!we)continue;const Ee=ke.canvasOverlay.params&&typeof ke.canvasOverlay.params=="object"?ke.canvasOverlay.params:void 0,Fe=de===W?null:W.slice(de.length+1);return{kind:xe,params:Ee,tablePath:de,tableNode:we,selectedCellRel:Fe}}return null},[C.selectedPath,_,s]),fo=ft?zT(ft.kind):null,Oh=u.useMemo(()=>!fo||!t||!ft?null:co(t,ft.tablePath),[fo,t,ft,A,w]),Jz=u.useMemo(()=>{if(!fo||!t||!ft)return[];const W=ft.tablePath,X=sn(W),R=[];return t.querySelectorAll(`[data-editor-path^="${X}.children."]`).forEach(we=>{const Te=we.dataset.editorPath??"";if(!Te.startsWith(W+"."))return;const ke=Te.slice(W.length+1),xe=ro(we,t);xe&&R.push({path:ke,top:xe.top,left:xe.left,width:xe.width,height:xe.height})}),t.querySelectorAll(`[data-editor-item-path^="${X}."]`).forEach(we=>{const Te=we.dataset.editorItemPath??"";if(!Te.startsWith(W+"."))return;const ke=Te.slice(W.length+1),xe=ro(we,t);xe&&R.push({path:ke,top:xe.top,left:xe.left,width:xe.width,height:xe.height})}),R},[fo,t,ft,A,w]),Zz=u.useCallback(W=>{const X=ft?Ze(ft.tablePath):null;if(!v||!X)return;let R=[];v.patchLayout(de=>(R=Ot({children:de},X,()=>W).children??[],R)),b.push({actionKind:"inline_text_edit",label:"inplace_edit",snapshot:R}),or({source:"style_control",componentPath:ft?.tablePath??"",componentName:typeof W.name=="string"?W.name:null,patchKey:"canvas_inplace",timestamp:Date.now()})},[v,U,b]);return t?i.jsxs("div",{className:"g7le-editor-canvas-overlay","data-testid":"g7le-editor-canvas-overlay",style:{position:"absolute",inset:0,pointerEvents:"none"},children:[!D&&Lz(),D&&i.jsxs("div",{"data-testid":"g7le-canvas-pick-banner",style:{position:"absolute",top:8,left:"50%",transform:"translateX(-50%)",zIndex:9999,display:"flex",alignItems:"center",gap:12,padding:"8px 14px",background:"#1e293b",color:"#fff",borderRadius:8,fontSize:13,boxShadow:"0 4px 12px rgba(0,0,0,0.25)",pointerEvents:"auto"},children:[i.jsxs("span",{children:["🎯 ",h("layout_editor.target_picker.canvas_pick_hint")]}),i.jsx("button",{type:"button","data-testid":"g7le-canvas-pick-cancel",onClick:()=>Y(D,null),style:{border:"1px solid #475569",borderRadius:6,background:"transparent",color:"#cbd5e1",cursor:"pointer",fontSize:12,padding:"3px 10px"},children:h("layout_editor.target_picker.cancel")})]}),f.editMode!=="route"&&i.jsx(mI,{frameEl:t,components:w,editMode:f.editMode,currentExtensionId:S,editableRootPath:E}),P&&((P.isParamKey&&P.customKey||P.chipValue)&&xt?i.jsxs(i.Fragment,{children:[xt&&i.jsx(ag,{node:xt,spec:s,t:h,onApplyControl:Ph,box:P.box,scope:{colorScheme:f.previewColorScheme==="dark"?"dark":"base",breakpoint:Kr(f.previewDevice,xt,f.previewCustomWidth)}}),i.jsx(JT,{box:P.box,node:xt,customKey:P.customKey??null,initialChipValue:P.chipValue,chipParamLabels:P.chipParamLabels,onKeyify:Uz,templateIdentifier:f.templateIdentifier,locale:f.locale,t:h,candidates:an,onInsertBinding:Gz,onRemoveBinding:Yz,onCommit:Vz,onCancel:Mh})]}):i.jsxs(i.Fragment,{children:[xt&&i.jsx(ag,{node:xt,spec:s,t:h,onApplyControl:Ph,box:P.box,scope:{colorScheme:f.previewColorScheme==="dark"?"dark":"base",breakpoint:xt?Kr(f.previewDevice,xt,f.previewCustomWidth):"base"}}),i.jsx(XT,{box:P.box,initialValue:P.initialValue,isCustomKey:P.isCustomKey,t:h,onCommit:Kz,onCancel:Mh,mirrorClassName:xl.className,mirrorStyle:xl.style,nodeEffectiveColor:xl.effectiveColor??P.nodeEffectiveColor,onOpenTranslations:qz})]})),!D&&i.jsx(c7,{hoverBox:N,selectedBox:A,lockKind:C.selectedLockKind,navAffordance:C.selectedNavAffordance,onEditProps:yl,onDuplicate:Ae,onDelete:Ne,onSeparateBranch:Le?.mode==="separate"?q:void 0,separateBranchLabel:$e?.separate,onMergeBranch:Le?.mode==="merge"?ae:void 0,mergeBranchLabel:$e?.merge,definedDeviceBranches:be,onJumpToDevice:be.length>0?j:void 0,onLinkEditDestination:Ie,onEditBase:dt,onEditExtension:pt,extensionLabel:ze,baseLayoutLabel:C.selectedBaseLayout,dataSourceLabel:C.selectedDataSourceId,branchLabel:ee,onEditIteration:C.selectedIsIteration&&f.editMode!=="iteration_item"?ot:void 0,resizeEnabledAxes:nr.enabledAxes,onResizeHandlePointerDown:nr.enabledAxes.width||nr.enabledAxes.height?Hz:void 0,selectedName:Ce,onSelectParent:Z?se:void 0}),D&&B&&i.jsx("div",{"data-testid":"g7le-canvas-pick-hover","data-pick-has-id":B.hasId?"true":"false",style:{position:"absolute",left:B.box.left,top:B.box.top,width:B.box.width,height:B.box.height,pointerEvents:"none",zIndex:9998,boxSizing:"border-box",border:B.hasId?"2px solid #2563eb":"2px dashed #94a3b8",background:B.hasId?"rgba(37,99,235,0.10)":"repeating-linear-gradient(45deg, rgba(148,163,184,0.18) 0, rgba(148,163,184,0.18) 6px, transparent 6px, transparent 12px)",cursor:B.hasId?"pointer":"not-allowed"},children:!B.hasId&&i.jsx("span",{"data-testid":"g7le-canvas-pick-hover-hint",style:{position:"absolute",top:0,left:0,transform:"translateY(-100%)",background:"#475569",color:"#fff",fontSize:11,lineHeight:1.4,padding:"2px 6px",borderRadius:4,whiteSpace:"nowrap"},children:h("layout_editor.target_picker.needs_id")})}),!D&&i.jsx(y7,{selectedBox:qe,points:Se.points,onAddRequest:Re}),fo&&ft&&Oh&&C.selectedLockKind==="none"&&i.jsx(fo,{node:ft.tableNode,params:ft.params,nodeBox:Oh,cellBoxes:Jz,selectedCellPath:ft.selectedCellRel??void 0,colorScheme:f.previewColorScheme==="dark"?"dark":"light",t:h,onPatchNode:Zz,onInsertChild:ge,onRemoveChild:W=>{const X=Ze(W);if(!v||!X)return;let R=[];v.patchLayout(de=>(R=Zi({children:de},X).children??[],R)),b.push({actionKind:"remove",label:"inplace_remove",snapshot:R})},onRequestInlineEdit:W=>{ft&&G(`${ft.tablePath}.${W}`)}}),v&&i.jsx(pI,{frameEl:t,nesting:o,editMode:f.editMode,currentExtensionId:S,editableRootPath:E,components:w,patchLayout:v.patchLayout,pushHistory:le,selectedPath:C.selectedPath,onSelectPath:W=>C.handleSelect("",{dataset:{editorPath:W}}),onMovePath:W=>C.handleSelect("",{dataset:{editorPath:W}}),onRequestInlineEdit:G})]}):null}function dg(e,t){if(t.length===0)return null;const o=Wa({children:e},t);return o&&typeof o.name=="string"?o.name:null}function Wa(e,t){return We(e,t)}function sn(e){return typeof CSS<"u"&&typeof CSS.escape=="function"?CSS.escape(e):e.replace(/[^a-zA-Z0-9_\-.]/g,t=>`\\${t}`)}function cg(e,t){if(!t)return null;const n=e.querySelector(`[data-editor-path="${sn(t)}"]`);if(!n||typeof window.getComputedStyle!="function")return null;let o=n;const r=n.ownerDocument.createTreeWalker(n,NodeFilter.SHOW_TEXT);let s=r.nextNode();for(;s;){if((s.textContent??"").trim().length>0){const a=s.parentElement;if(a&&n.contains(a)){o=a;break}}s=r.nextNode()}try{return window.getComputedStyle(o).color||null}catch{return null}}function co(e,t){if(!t)return null;const n=e.querySelector(`[data-editor-path="${sn(t)}"]`);if(n){const c=ro(n,e);return c&&io(c,e)?c:null}const o=e.querySelectorAll(`[data-editor-path^="${sn(t)}.iteration."]`);let r=1/0,s=1/0,a=-1/0,l=-1/0,d=!1;return o.forEach(c=>{const p=c.dataset.editorPath??"";if(!new RegExp("\\.iteration\\.\\d+$").test(p))return;const f=ro(c,e);f&&io(f,e)&&(d=!0,r=Math.min(r,f.left),s=Math.min(s,f.top),a=Math.max(a,f.left+f.width),l=Math.max(l,f.top+f.height))}),d?{left:r,top:s,width:a-r,height:l-s,scale:1}:null}function r4({isEditMode:e,editorSpec:t,sampleOverride:n}){const o=t?.sampleData;return u.useMemo(()=>Im({spec:o,overrideSpec:n,onMatch:r=>{Wh(r)}}),[e,o,n])}const i4=Lt().ComponentRegistry;function s4(e){return e.split(/[-_]/).map(t=>t.charAt(0).toUpperCase()+t.slice(1).toLowerCase()).join("")}async function a4(e){const t=typeof window<"u"?window.localStorage?.getItem("auth_token"):null,n={Accept:"application/json"};t&&(n.Authorization=`Bearer ${t}`);const o=await fetch(`/api/admin/templates/${encodeURIComponent(e)}/editor-assets`,{headers:n,credentials:"same-origin"});if(!o.ok){const s=await o.json().catch(()=>null);throw Ur(o.status,s,"assets")}const r=await o.json();return r.data||r}async function l4(e){try{if(typeof fetch!="function")return 0;const t=await fetch(`/api/templates/${encodeURIComponent(e)}/config.json`,{credentials:"same-origin",headers:{Accept:"application/json"}});if(!t.ok)return 0;const n=await t.json().catch(()=>null),o=n?.data?.cache_version??n?.cache_version;return typeof o=="number"&&o>0?o:0}catch{return 0}}function d4(e){return new Promise((t,n)=>{if(typeof document>"u"){n(new Error("document undefined"));return}if(document.querySelector(`script[data-g7le-asset="${e}"]`)){t();return}const r=document.createElement("script");r.src=e,r.async=!1,r.setAttribute("data-g7le-asset",e),r.addEventListener("load",()=>t()),r.addEventListener("error",()=>n(new Error(`script load failed: ${e}`))),document.head.appendChild(r)})}async function c4(e){if(typeof document>"u")return;const t=`[data-g7le-asset="${e}"]`;if(document.querySelector(`link${t}, style${t}`))return;if(e.includes("/api/admin/"))try{const r=typeof window<"u"?window.localStorage?.getItem("auth_token"):null,s={Accept:"text/css"};r&&(s.Authorization=`Bearer ${r}`);const a=await fetch(e,{headers:s,credentials:"same-origin"});if(a.ok){const l=await a.text(),d=document.createElement("style");d.textContent=l,d.setAttribute("data-g7le-asset",e),document.head.appendChild(d);return}}catch{}const o=document.createElement("link");o.rel="stylesheet",o.href=e,o.setAttribute("data-g7le-asset",e),document.head.appendChild(o)}function u4(e,t,n="user"){const[o,r]=u.useState({componentRegistry:null,translationEngine:null,isReady:!1,error:null});return u.useEffect(()=>{let s=!1;return(async()=>{r(a=>({...a,isReady:!1,error:null}));try{const a=await a4(e);if(s)return;for(const y of a.css??[])if(await c4(y),s)return;const l=s4(e);if(!(typeof window<"u"&&typeof window[l]=="object")){for(const y of a.js??[])if(await d4(y),s)return}const c=i4.createIsolatedInstance();if(await c.loadComponents(e,n),s)return;const p=await l4(e);if(s)return;const f=new Pt({defaultLocale:t});if(p>0&&f.setCacheVersion(p),await f.loadTranslations(e,t),s)return;ad(f,e);try{const y=Pt.getInstance();p>y.getCacheVersion()&&y.setCacheVersion(p),await y.loadTranslations(e,t),ad(y,e)}catch{}if(s)return;r({componentRegistry:c,translationEngine:f,isReady:!0,error:null})}catch(a){if(s)return;const l=dp(a)?a:oo(a,"assets");r({componentRegistry:null,translationEngine:null,isReady:!1,error:l})}})(),()=>{s=!0}},[e,t,n]),o}const ug=Lt().AuthManager;function p4(e){switch(e){case"unauthorized":return{icon:"🔒",tone:"blue",titleKey:"layout_editor.access_error.unauthorized.title",messageKey:"layout_editor.access_error.unauthorized.message",showRequiredPermissions:!0};case"forbidden":return{icon:"⛔",tone:"rose",titleKey:"layout_editor.access_error.forbidden.title",messageKey:"layout_editor.access_error.forbidden.message",showRequiredPermissions:!0};case"not_found":return{icon:"🗂️",tone:"slate",titleKey:"layout_editor.access_error.not_found.title",messageKey:"layout_editor.access_error.not_found.message",showRequiredPermissions:!1};case"server_error":return{icon:"🛠️",tone:"amber",titleKey:"layout_editor.access_error.server_error.title",messageKey:"layout_editor.access_error.server_error.message",showRequiredPermissions:!1};case"network":return{icon:"📡",tone:"amber",titleKey:"layout_editor.access_error.network.title",messageKey:"layout_editor.access_error.network.message",showRequiredPermissions:!1};default:return{icon:"⚠️",tone:"slate",titleKey:"layout_editor.access_error.unknown.title",messageKey:"layout_editor.access_error.unknown.message",showRequiredPermissions:!1}}}const f4={blue:{bg:"#eff6ff",border:"#bfdbfe",iconBg:"#dbeafe"},rose:{bg:"#fff1f2",border:"#fecdd3",iconBg:"#ffe4e6"},amber:{bg:"#fffbeb",border:"#fde68a",iconBg:"#fef3c7"},slate:{bg:"#f8fafc",border:"#e2e8f0",iconBg:"#f1f5f9"}};function g4({error:e}){return u.useEffect(()=>{if(e.kind!=="unauthorized"||typeof window>"u")return;const t=ug.getInstance(),n=`${window.location.pathname}${window.location.search}`,o=t.getLoginRedirectUrl("admin",n,"session_expired"),r=window.setTimeout(()=>{window.location.href=o},600);return()=>window.clearTimeout(r)},[e.kind]),null}function ja({error:e}){const{t}=tt(),n=p4(e.kind),o=f4[n.tone],r=()=>{typeof window<"u"&&(window.location.href="/admin")},s=()=>{typeof window<"u"&&window.location.reload()},a=()=>{if(typeof window>"u")return;const l=ug.getInstance(),d=`${window.location.pathname}${window.location.search}`;window.location.href=l.getLoginRedirectUrl("admin",d)};return i.jsxs("div",{className:"g7le-access-error","data-testid":"g7le-access-error","data-error-kind":e.kind,style:{flex:1,display:"flex",alignItems:"center",justifyContent:"center",padding:24,background:"#f1f5f9"},children:[i.jsx(g4,{error:e}),i.jsxs("div",{style:{maxWidth:480,width:"100%",background:o.bg,border:`1px solid ${o.border}`,borderRadius:12,padding:"28px 32px",boxShadow:"0 2px 8px rgba(15, 23, 42, 0.05)",textAlign:"center"},children:[i.jsx("div",{style:{width:64,height:64,margin:"0 auto 16px",borderRadius:"50%",background:o.iconBg,display:"flex",alignItems:"center",justifyContent:"center",fontSize:32,lineHeight:1},"aria-hidden":"true",children:n.icon}),i.jsx("h2",{"data-testid":"g7le-access-error-title",style:{margin:"0 0 8px",fontSize:18,fontWeight:700,color:"#0f172a"},children:t(n.titleKey)}),i.jsx("p",{"data-testid":"g7le-access-error-message",style:{margin:"0 0 16px",fontSize:13,lineHeight:1.6,color:"#475569"},children:t(n.messageKey)}),n.showRequiredPermissions&&e.requiredPermissions&&i.jsxs("div",{"data-testid":"g7le-access-error-permissions",style:{display:"inline-flex",flexWrap:"wrap",gap:6,justifyContent:"center",padding:"8px 12px",marginBottom:16,background:"rgba(15, 23, 42, 0.04)",borderRadius:8,fontSize:12,color:"#475569"},children:[i.jsx("span",{style:{fontWeight:600},children:t("layout_editor.access_error.required_permission_label")}),e.requiredPermissions.split(/[,\s]+/).filter(Boolean).map(l=>i.jsx("code",{style:{padding:"2px 6px",background:"#fff",border:"1px solid #e2e8f0",borderRadius:4,fontFamily:"ui-monospace, SFMono-Regular, Menlo, monospace",fontSize:11,color:"#1e293b"},children:l},l))]}),e.message&&i.jsxs("details",{"data-testid":"g7le-access-error-detail",style:{marginBottom:16,fontSize:11,color:"#94a3b8"},children:[i.jsx("summary",{style:{cursor:"pointer"},children:t("layout_editor.access_error.detail_summary")}),i.jsxs("pre",{style:{marginTop:8,padding:8,background:"#fff",border:"1px solid #e2e8f0",borderRadius:6,overflowX:"auto",whiteSpace:"pre-wrap",wordBreak:"break-word",textAlign:"left",fontSize:11,color:"#475569"},children:[e.status>0?`HTTP ${e.status} -`:"",e.message]})]}),i.jsxs("div",{style:{display:"flex",justifyContent:"center",gap:8,flexWrap:"wrap"},children:[e.kind==="unauthorized"&&i.jsx("button",{type:"button","data-testid":"g7le-access-error-action-signin",onClick:a,style:Ha,children:t("layout_editor.access_error.action.sign_in")}),e.kind==="forbidden"&&i.jsx("button",{type:"button","data-testid":"g7le-access-error-action-home",onClick:r,style:Ha,children:t("layout_editor.access_error.action.go_home")}),(e.kind==="not_found"||e.kind==="server_error"||e.kind==="network"||e.kind==="unknown")&&i.jsx("button",{type:"button","data-testid":"g7le-access-error-action-retry",onClick:s,style:Ha,children:t("layout_editor.access_error.action.retry")}),e.kind!=="unauthorized"&&i.jsx("button",{type:"button","data-testid":"g7le-access-error-action-home-secondary",onClick:r,style:h4,children:t("layout_editor.access_error.action.go_admin_home")})]})]})]})}const Ha={padding:"8px 16px",background:"#2563eb",color:"#fff",border:"1px solid #1d4ed8",borderRadius:6,fontSize:13,fontWeight:600,cursor:"pointer"},h4={padding:"8px 16px",background:"#fff",color:"#475569",border:"1px solid #cbd5e1",borderRadius:6,fontSize:13,fontWeight:500,cursor:"pointer"};function y4(e){const t=window;if(typeof t>"u")return{getSnapshot:()=>({globalState:{},localState:{}}),restore:()=>{}};const n=t.G7Core||{},o={templateApp:t.__templateApp,g7CoreState:n.state,g7CoreDispatch:n.dispatch,g7CoreData:n.data,g7CoreModal:n.modal,g7CoreToast:n.toast};let r={...e.initialGlobalState??{}};r._local||(r._local={});const s=[],a=()=>{const c={globalState:{...r},localState:{...r._local??{}}};for(const p of s)try{p(r)}catch{}e.onChange?.(c)},l={},d={getGlobalState:()=>r,setGlobalState:(c,p)=>{typeof c=="function"?r=c(r):r={...r,...c},a()},onGlobalStateChange:c=>(s.push(c),()=>{const p=s.indexOf(c);p>=0&&s.splice(p,1)}),get globalState(){return r},getActionDispatcher:()=>e.actionDispatcher,getDataSource:c=>l[c]??null,setDataSource:(c,p)=>{l[c]=p},refetchDataSource:async c=>null,updateDataSourceItem:(c,p,f)=>l[c]??null,getLocale:()=>e.locale,changeLocale:c=>{},getRouter:()=>({navigate:()=>{},replace:()=>{},push:()=>{}}),updateQueryParams:()=>{}};return t.__templateApp=d,n&&typeof n.dispatch=="function"&&(n.dispatch=(c,p)=>{try{return e.actionDispatcher.executeAction(c,p??{})}catch{return}}),n&&typeof n.modal=="object"&&n.modal!==null&&(n.modal={...n.modal,open:()=>{},close:()=>{},closeAll:()=>{}}),n&&typeof n.toast=="object"&&n.toast!==null&&(n.toast={...n.toast,push:()=>{},dismiss:()=>{}}),{getSnapshot:()=>({globalState:{...r},localState:{...r._local??{}}}),restore:()=>{t.__templateApp===d&&(t.__templateApp=o.templateApp),t.G7Core&&(o.g7CoreState!==void 0&&(t.G7Core.state=o.g7CoreState),o.g7CoreDispatch!==void 0&&(t.G7Core.dispatch=o.g7CoreDispatch),o.g7CoreData!==void 0&&(t.G7Core.data=o.g7CoreData),o.g7CoreModal!==void 0&&(t.G7Core.modal=o.g7CoreModal),o.g7CoreToast!==void 0&&(t.G7Core.toast=o.g7CoreToast)),s.length=0}}}const b4=new Set(["id","userId","postId","orderId","productId","categoryId","commentId","noticeId","inquiryId","addressId","wishlistId","notificationId","roleId","menuId"]);function m4(e){if(!e)return{};const t={},n=/:([a-zA-Z_][a-zA-Z0-9_]*)/g;let o;for(;(o=n.exec(e))!==null;){const r=o[1];t[r]=v4(r)}return t}function v4(e){return b4.has(e)||/Id$|_id$/.test(e)?"1":"sample"}const _4=ar("LayoutEditor");function Go(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function hi(e){if(Array.isArray(e))return e.map(t=>hi(t));if(Go(e)){const t={};for(const[n,o]of Object.entries(e))t[n]=hi(o);return t}return e}function pg(e,t="",n=new Set){for(const[o,r]of Object.entries(e)){const s=t?`${t}.${o}`:o;Go(r)?pg(r,s,n):n.add(s)}return n}function fg(e,t,n,o,r,s=""){for(const[a,l]of Object.entries(t)){const d=s?`${s}.${a}`:a;if(n.has(d)){r(`[LayoutEditor] sampleGlobal key conflict: extension '${o}' override of core key '${d}' ignored`);continue}const c=e[a];Go(l)&&Go(c)?fg(c,l,n,o,r,d):e[a]=hi(l)}}function Fa(e){const t=e.warn??(r=>_4.warn(r)),n=hi(e.coreSeed??{});e.isGuestOnly&&"currentUser"in n&&delete n.currentUser;const o=pg(n);for(const r of e.sources)Go(r.sampleGlobal)&&fg(n,r.sampleGlobal,o,r.id,t);return e.isGuestOnly&&"currentUser"in n&&delete n.currentUser,n}function x4(e){const t={};if(!e||typeof e!="object"||Array.isArray(e))return t;for(const[n,o]of Object.entries(e))n.startsWith("$")&&(t[n]=o);return t}function w4(e){const t={};if(!e||typeof e!="object"||Array.isArray(e))return t;for(const[n,o]of Object.entries(e))n.startsWith("$")||(t[n]=o);return t}function k4(e){if(typeof e!="string")return null;const t=/^\s*\{\{\s*([\s\S]*?)\s*\}\}\s*$/.exec(e);if(!t)return null;let n=t[1];const o=n.indexOf("??");return o>=0&&(n=n.slice(0,o)),n=n.replace(/\?\./g,".").replace(/\s+/g,""),n===""||!/^[A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z_$][A-Za-z0-9_$]*)*$/.test(n)?null:n.split(".")}function S4(e,t){const n=k4(t);if(!n||n.length===0)return e;let o=e;for(let a=0;ao.map(r=>{if(!r||typeof r!="object")return r;const s=r;let a=s;if(typeof s.slot=="string"&&!t.has(s.slot)){const{slot:l,...d}=s;a={...d,__editorSlotName:l}}if(Array.isArray(a.children)){const l=n(a.children);l!==a.children&&(a={...a,children:l})}return a});return n(e)}function St(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function Xt(e){if(Array.isArray(e))return e.map(t=>Xt(t));if(St(e)){const t={};for(const[n,o]of Object.entries(e))t[n]=Xt(o);return t}return e}function yi(e,t){for(const[n,o]of Object.entries(t)){const r=e[n];St(o)&&St(r)?yi(r,o):e[n]=Xt(o)}}function E4(e){const t=[];let n="";for(let o=0;o0&&(t.push(n),n="");const s=e[o+1];if(s==="'"||s==='"'){const l=e.indexOf(`${s}]`,o+2);if(l!==-1){t.push(e.slice(o+2,l)),o=l+1;continue}}const a=e.indexOf("]",o+1);if(a!==-1){t.push(e.slice(o+1,a)),o=a;continue}n+=r}else r==="."?n.length>0&&(t.push(n),n=""):n+=r}return n.length>0&&t.push(n),t.filter(o=>o.length>0)}function Ka(e,t,n){const o=E4(t);if(o.length===0)return;let r=e;for(let s=0;s0?t:void 0}const Ua="g7le:editor-t-ready",yg="data-g7le-vh-expanded";function A4(e,t){if(!e||!t)return;const n=t;e.querySelectorAll("*").forEach(r=>{const s=window.getComputedStyle(r),a=parseFloat(s.height),l=Number.isFinite(a)&&Math.abs(a-n)<=2,d=parseFloat(s.minHeight),c=Number.isFinite(d)&&Math.abs(d-n)<=2,p=s.overflowY==="auto"||s.overflowY==="scroll"||s.overflowY==="hidden"||s.overflow==="auto"||s.overflow==="scroll"||s.overflow==="hidden";l&&p?(r.style.setProperty("height","auto","important"),c&&r.style.setProperty("min-height","0","important"),r.style.setProperty("overflow","visible","important"),r.setAttribute(yg,"1")):(s.overflowY==="auto"||s.overflowY==="scroll")&&r.scrollHeight>r.clientHeight+4&&r.clientHeight>120&&(r.style.setProperty("overflow","visible","important"),r.style.setProperty("height","auto","important"),r.setAttribute(yg,"1"))})}const T4=[];function D4(e={}){const{manifest:t=null,nesting:n=null,componentPalette:o=null,spec:r=null,permissionCandidates:s,sampleGlobalSources:a=T4,resolveRouteMatch:l,onNavigateToDestination:d,onSessionAddedPathsChange:c}=e,{state:p}=Je(),f=Ft(),y=vp(),{document:g,isLoading:m,error:h}=f??y,v=u.useRef(null),[b,x]=u.useState(null),{deviceWidth:k,scale:w,colorScheme:_}=Ba(),S=u.useMemo(()=>p.activeStateId?p.availableStates.find(Q=>Q.id===p.activeStateId)??null:null,[p.activeStateId,p.availableStates]),E=u.useMemo(()=>hg(S),[S]),C=r4({isEditMode:!0,editorSpec:r??void 0,sampleOverride:E}),{t:N}=tt(),{componentRegistry:$,translationEngine:A,isReady:z,error:D}=u4(p.templateIdentifier,p.locale),O=u.useRef(null);O.current===null&&(O.current=new yd);const M=u.useRef(null);if(M.current===null){M.current=new LE({navigate:()=>{}}),M.current.setPreviewMode(!0);try{const ie=window.__templateApp?.getActionDispatcher?.();if(ie&&typeof ie.getRegisteredHandlers=="function"){const _e=ie.getRegisteredHandlers();for(const ge of _e){if(!ge.includes("."))continue;const Ae=typeof ie.getHandler=="function"?ie.getHandler(ge):void 0;typeof Ae=="function"&&M.current.registerHandler(ge,Ae,{category:"plugin",source:"preview-canvas-cloned-from-host"})}}}catch{}}u.useEffect(()=>{if(!z)return;const Q=M.current;if(Q)try{const ie=window.G7Core?.getActionDispatcher?.();if(!ie||typeof ie.getRegisteredHandlers!="function")return;const _e=ie.getRegisteredHandlers();for(const ge of _e){if(!ge.includes(".")||typeof Q.getHandler=="function"&&Q.getHandler(ge))continue;const Ae=typeof ie.getHandler=="function"?ie.getHandler(ge):void 0;typeof Ae=="function"&&Q.registerHandler(ge,Ae,{category:"plugin",source:"preview-canvas-iife-reclone"})}}catch{}},[z]);const B=u.useMemo(()=>new PE({sampleProvider:C,actionDispatcher:M.current}),[C]),T=u.useMemo(()=>g?.raw?.meta?.guest_only===!0,[g]),H=u.useMemo(()=>Fa({sources:a,isGuestOnly:T}),[a,T]),L=u.useMemo(()=>{const Q=N4(Va(S),A,{templateId:p.templateIdentifier,locale:p.locale}),ie=H._local,{global:_e,local:ge}=I4({globalBaseline:H,localBaseline:ie??{},patch:S?.initialState??null,formErrors:Q});return{..._e,_local:ge}},[H,S,A,p.templateIdentifier,p.locale]),I=u.useMemo(()=>!S||!(!!S.initialState?.local||!!Va(S))?null:{...L._local??{},_forceLocalInit:S.id,_merge:"deep"},[S,L]),P=Q=>typeof Q=="object"&&Q!==null&&!Array.isArray(Q),F=u.useMemo(()=>P(S?.initialState?.query)?S.initialState.query:null,[S]),G=u.useMemo(()=>P(S?.initialState?.route)?S.initialState.route:null,[S]),[V,Y]=u.useState(()=>Fa({sources:[],isGuestOnly:!1})),Z=u.useRef(null);u.useEffect(()=>{const Q={...L},ie=y4({actionDispatcher:M.current,locale:p.locale,initialGlobalState:Q,onChange:ge=>{Y(ge.globalState)}});Z.current=ie;const _e=window.__templateApp;return _e?.setGlobalState&&M.current.setGlobalStateUpdater((ge,Ae)=>{_e.setGlobalState(ge,Ae)}),Y(Q),Sl({kind:"applyPatch",activeStateId:p.activeStateId,routePath:p.selectedRoute?.path??null,hasInitialPatch:!!S?.initialState,hasFormErrors:!!Va(S),hasSampleOverride:!!hg(S)}),()=>{ie.restore(),Z.current=null}},[p.locale,L]),u.useEffect(()=>{if(!A)return;const Q=window.G7Core;if(!Q||typeof Q.t!="function")return;const ie=Q.t,_e=p.templateIdentifier,ge=p.locale;return Q.t=(Ae,Ne)=>{try{const Le=Ne?"|"+Object.entries(Ne).map(([ae,pe])=>`${ae}=${pe}`).join("|"):void 0,q=A.translate(Ae,{templateId:_e,locale:ge},Le);if(q!==Ae&&!q.startsWith("$t:"))return q}catch{}return ie(Ae,Ne)},typeof window<"u"&&window.dispatchEvent(new CustomEvent(Ua)),()=>{window.G7Core?.t&&window.G7Core.t!==ie&&(window.G7Core.t=ie)}},[A,p.templateIdentifier,p.locale]);const se=u.useMemo(()=>{const Q=g?.raw?.components??[];return p.editMode!=="base"?Q:$4(Q)},[g,p.editMode]),re=g?.raw?.__editability,U=g?.raw?.__extensionId,ee=u.useMemo(()=>p.editMode!=="extension"||re!=="ok"?[]:typeof U!="number"?[]:z4(se,U),[p.editMode,re,U,se]),[fe,K]=u.useState(null),[ne,me]=u.useState(!1),Ce=g?.layoutName??null,ze=u.useMemo(()=>{const Q=g?.raw?.scripts??[];return Array.isArray(Q)?Q.map(ie=>ie&&typeof ie.src=="string"?ie.src:"").join("|"):""},[g]);u.useEffect(()=>{me(!1)},[Ce,ze]),u.useEffect(()=>{let Q=!1;const ie=g?.raw?.scripts??[];if(!Array.isArray(ie)||ie.length===0){me(!0);return}const _e=[];for(const ge of ie){if(!ge?.src||typeof ge.src!="string")continue;const Ae=ge.id?`script#${CSS.escape(ge.id)}, script[data-g7le-canvas-script="${ge.src}"]`:`script[data-g7le-canvas-script="${ge.src}"], script[src="${ge.src}"]`,Ne=window.document.querySelector(Ae);if(Ne){const Le=Ne.hasAttribute("data-g7le-canvas-script");if(Ne.hasAttribute("data-g7le-script-loaded")||!Le)continue;_e.push(new Promise(ae=>{if(Ne.hasAttribute("data-g7le-script-loaded")){ae();return}Ne.addEventListener("load",()=>ae()),Ne.addEventListener("error",()=>ae())}));continue}_e.push(new Promise(Le=>{const q=window.document.createElement("script");q.src=ge.src,q.async=!1,ge.id&&(q.id=ge.id),q.setAttribute("data-g7le-canvas-script",ge.src);const ae=()=>{q.setAttribute("data-g7le-script-loaded","1"),Le()};q.addEventListener("load",ae),q.addEventListener("error",ae),window.document.head.appendChild(q)}))}if(_e.length===0){me(!0);return}return Promise.all(_e).then(()=>{Q||requestAnimationFrame(()=>{requestAnimationFrame(()=>{Q||me(!0)})})}),()=>{Q=!0}},[g]);const Se=u.useMemo(()=>g?.raw?.data_sources??[],[g]),qe=u.useMemo(()=>g?.raw?.computed??{},[g]),[De,Pe]=u.useState({}),Qe=u.useMemo(()=>{if(p.editMode!=="iteration_item")return De;const Q=g?.iterationContext;if(!Q)return De;const ie=g?.raw?.components??[],_e=We({children:ie},Q.sourceIndexPath),ge=_e&&_e.iteration&&typeof _e.iteration.source=="string"?_e.iteration.source:null;return ge?S4(De,ge):De},[p.editMode,De,g]),[Me,Re]=u.useState(0);u.useEffect(()=>{if(typeof window>"u")return;const Q=()=>Re(ie=>ie+1);return window.addEventListener(kt,Q),()=>window.removeEventListener(kt,Q)},[]);const ve=u.useMemo(()=>m4(p.selectedRoute?.path??null),[p.selectedRoute?.path]);if(u.useEffect(()=>{let Q=!1;return(async()=>{if(Se.length===0){Q||Pe({});return}try{const ie=await B.fetchDataSources(Se,ve,new URLSearchParams,void 0,void 0);Q||Pe(ie)}catch{Q||Pe({})}})(),()=>{Q=!0}},[Se,B,ve]),u.useEffect(()=>{const Q=b;if(!Q||typeof window>"u")return;let ie=0,_e=0,ge=0;const Ae=()=>A4(Q,window.innerHeight);ie=window.requestAnimationFrame(()=>{_e=window.requestAnimationFrame(Ae)});let Ne=null;return typeof MutationObserver<"u"&&(Ne=new MutationObserver(()=>{ge||(ge=window.requestAnimationFrame(()=>{ge=0,Ae()}))}),Ne.observe(Q,{childList:!0,subtree:!0})),()=>{window.cancelAnimationFrame(ie),window.cancelAnimationFrame(_e),ge&&window.cancelAnimationFrame(ge),Ne&&Ne.disconnect()}},[b,se,ne,k,De]),u.useEffect(()=>{const Q=b;if(!Q||typeof window>"u"||p.editMode!=="extension"||re!=="ok"||ee.length===0){K(null);return}let ie=0,_e=0;const ge=ae=>{if(!ae)return!1;const pe=ae.getBoundingClientRect();return pe.width>0&&pe.height>0},Ae=ae=>{const pe=Q.querySelector(`[data-editor-path="${ae}"]`);if(ge(pe))return!0;const $e=Q.querySelectorAll(`[data-editor-path^="${ae}.children."]`);for(const be of Array.from($e))if(ge(be))return!0;return!1},Ne=()=>{K(ee.some(Ae))};ie=window.requestAnimationFrame(()=>{_e=window.requestAnimationFrame(Ne)});let Le=null,q=0;return typeof MutationObserver<"u"&&(Le=new MutationObserver(()=>{q||(q=window.requestAnimationFrame(()=>{q=0,Ne()}))}),Le.observe(Q,{childList:!0,subtree:!0})),()=>{window.cancelAnimationFrame(ie),window.cancelAnimationFrame(_e),q&&window.cancelAnimationFrame(q),Le&&Le.disconnect()}},[b,p.editMode,re,ee,ne,De]),u.useEffect(()=>{if(typeof window>"u"||p.editMode!=="base")return;const Q=window.document,ie="g7le-base-slot-style";if(Q.getElementById(ie))return;const _e=Q.createElement("style");_e.id=ie;const ge=N("layout_editor.preview.slot_area_label");return _e.textContent=`.g7le-preview-frame [data-editor-slot]{position:relative;min-height:96px;outline:2px dashed #94a3b8;outline-offset:-2px;}.g7le-preview-frame [data-editor-slot]::after{content:'${ge.replace(/'/g,"\\'")} · ' attr(data-editor-slot);position:absolute;top:8px;left:50%;transform:translateX(-50%);font-size:12px;line-height:1;color:#64748b;background:rgba(241,245,249,0.92);border:1px solid #cbd5e1;border-radius:9999px;padding:4px 12px;pointer-events:none;white-space:nowrap;}`,Q.head.appendChild(_e),()=>{Q.getElementById(ie)?.remove()}},[p.editMode,N]),!(p.editMode==="extension"||p.editMode==="iteration_item")&&(p.selectedRoute?.layoutName===null||p.selectedRoute===null))return i.jsx("div",{className:"g7le-preview-canvas g7le-preview-canvas--empty","data-testid":"g7le-preview-empty",style:Yo,children:N("layout_editor.preview.empty")});if(m)return i.jsx("div",{className:"g7le-preview-canvas g7le-preview-canvas--loading","data-testid":"g7le-preview-loading",style:Yo,children:N("layout_editor.preview.loading")});if(h)return i.jsx(ja,{error:h});if(D)return i.jsx(ja,{error:D});if(!z||$===null||A===null)return i.jsx("div",{className:"g7le-preview-canvas g7le-preview-canvas--assets-loading","data-testid":"g7le-preview-assets-loading",style:Yo,children:N("layout_editor.preview.assets_loading")});if(se.length===0)return i.jsx("div",{className:"g7le-preview-canvas g7le-preview-canvas--empty-layout","data-testid":"g7le-preview-empty-layout",style:Yo,children:N("layout_editor.preview.empty_layout")});const he=p.editMode==="extension"?re==="no-injection"?"no-injection":re==="ok"&&ee.length>0&&fe!==!0?"gated-no-state":null:null;return ne?i.jsx("div",{className:"g7le-preview-canvas","data-testid":"g7le-preview-canvas",style:{overflow:"visible",background:"#f1f5f9",padding:24,display:"flex",justifyContent:"center",alignItems:"flex-start",minHeight:"calc(100vh - var(--g7le-header-h, 0px))",boxSizing:"border-box"},children:i.jsxs("div",{className:"g7le-preview-frame-wrapper","data-testid":"g7le-preview-frame-wrapper",style:{position:"relative",width:k,transform:`scale(${w})`,transformOrigin:"top center"},children:[i.jsx("div",{className:_==="dark"?"g7le-preview-frame g7le-preview-dark":"g7le-preview-frame","data-testid":"g7le-preview-frame","data-color-scheme":_,ref:Q=>{v.current=Q,x(Q)},style:{width:k,background:"#fff",border:"1px solid #cbd5e1",boxShadow:"0 1px 3px rgba(0,0,0,0.05)",minHeight:400,position:"relative",transform:"translateZ(0)",overflow:"hidden"},children:i.jsx(ME,{overrideWidth:k,children:se.map((Q,ie)=>i.jsx(zE,{componentDef:Q,dataContext:{...Qe,...x4(V),_global:w4(V),_computedDefinitions:qe,...I?{_localInit:I,_local:{...De._local??{},...V._local??{}}}:{},route:M4({...De.route??{},...ve,path:p.selectedRoute?.path??""},G),query:{...De.query??{},...F??{}}},translationContext:{templateId:p.templateIdentifier,locale:p.locale},registry:$,bindingEngine:O.current,translationEngine:Pt.getInstance(),actionDispatcher:M.current,isEditMode:!0,isRootRenderer:ie===0,componentPath:`${ie}`,onComponentSelect:(_e,ge)=>{},onComponentHover:(_e,ge)=>{}},`${Q.id??`preview-${ie}`}:t${Me}${p.editMode==="extension"?`:s${ne?1:0}`:""}`))})}),i.jsx("div",{className:"g7le-overlay-layer","data-testid":"g7le-overlay-layer",style:{position:"absolute",left:0,top:0,width:k,height:b?b.getBoundingClientRect().height/(w||1):"100%",overflow:"visible",pointerEvents:"none"},children:i.jsx(o4,{frameEl:b,manifest:t,nesting:n,componentPalette:o,spec:r,permissionCandidates:s,resolveRouteMatch:l,onNavigateToDestination:d,onSessionAddedPathsChange:c})}),he!==null&&i.jsx("div",{className:"g7le-preview-canvas--extension-fallback","data-testid":"g7le-preview-extension-fallback","data-fallback-reason":he,style:{position:"absolute",left:0,top:0,right:0,bottom:0,minHeight:b?b.getBoundingClientRect().height/(w||1):400,background:"#f1f5f9",display:"flex",alignItems:"flex-start",justifyContent:"center",padding:24,boxSizing:"border-box",pointerEvents:"auto",zIndex:50},children:i.jsxs("div",{className:"g7le-extension-fallback-card",style:{maxWidth:440,textAlign:"center",position:"sticky",top:"calc(40vh - var(--g7le-header-h, 0px))"},children:[i.jsx("p",{className:"g7le-extension-fallback-title",style:{fontWeight:600,marginBottom:8,color:"#0f172a"},children:N(he==="no-injection"?"layout_editor.preview.extension_no_injection_title":"layout_editor.preview.extension_gated_title")}),i.jsx("p",{className:"g7le-extension-fallback-desc",style:{color:"#475569",lineHeight:1.6},children:he==="no-injection"?N("layout_editor.preview.extension_no_injection_desc"):p.availableStates.length>1?N("layout_editor.preview.extension_gated_desc_with_states"):N("layout_editor.preview.extension_gated_desc")})]})})]})}):i.jsx("div",{className:"g7le-preview-canvas g7le-preview-canvas--scripts-loading","data-testid":"g7le-preview-scripts-loading",style:Yo,children:N("layout_editor.preview.loading")})}function N4(e,t,n){if(!e)return;if(!t)return e;const o=s=>{if(typeof s=="string"&&s.startsWith("$t:"))try{const a=s.slice(3),l=a.indexOf("|");if(l===-1)return t.translate(a,n);const d=a.slice(0,l),c=a.slice(l);return t.translate(d,n,c)}catch{return s}return Array.isArray(s)?s.map(o):s},r={};for(const[s,a]of Object.entries(e))r[s]=o(a);return r}function z4(e,t){const n=[],o=(r,s)=>{Array.isArray(r)&&r.forEach((a,l)=>{if(!a||typeof a!="object")return;const d=s?`${s}.children.${l}`:`${l}`,c=a.__source;if(c?.kind==="extension"&&c.extensionId===t){n.push(d);return}Array.isArray(a.children)&&o(a.children,d)})};return o(e,""),n}function M4(e,t){if(!t)return e;const n={...e};for(const[o,r]of Object.entries(t))r===null?delete n[o]:n[o]=r;return n}const Yo={flex:1,display:"flex",alignItems:"center",justifyContent:"center",padding:24,background:"#f1f5f9",color:"#64748b",fontSize:13},L4={desktop:"layout_editor.device.desktop",tablet:"layout_editor.device.tablet",mobile:"layout_editor.device.mobile",portable:"layout_editor.device.portable"};function P4(){const{device:e,zoom:t,customWidth:n,setDevice:o,setZoom:r,setCustomWidth:s,colorScheme:a,setColorScheme:l}=Ba(),{t:d}=tt(),c=Hf();return i.jsxs("div",{className:"g7le-device-toolbar","data-testid":"g7le-device-toolbar",style:{display:"inline-flex",alignItems:"center",gap:8,padding:"4px 8px",background:"#fff",border:"1px solid #cbd5e1",borderRadius:6},children:[i.jsxs("div",{role:"group","aria-label":d("layout_editor.device.group_label"),children:[c.map(p=>{const f=e===p,y=L4[p],g=y?d(y):p;return i.jsx("button",{type:"button","data-testid":`g7le-device-${p}`,"aria-pressed":f,onClick:()=>o(p),style:{padding:"2px 8px",marginRight:2,fontSize:12,cursor:"pointer",background:f?"#0f172a":"#f8fafc",color:f?"#fff":"#0f172a",border:"1px solid #cbd5e1",borderRadius:4},children:g},p)}),(()=>{const p=e==="custom";return i.jsx("button",{type:"button","data-testid":"g7le-device-custom","aria-pressed":p,onClick:()=>o("custom"),style:{padding:"2px 8px",marginRight:2,fontSize:12,cursor:"pointer",background:p?"#0f172a":"#f8fafc",color:p?"#fff":"#0f172a",border:"1px solid #cbd5e1",borderRadius:4},children:d("layout_editor.device.custom")})})()]}),e==="custom"&&i.jsxs("div",{style:{display:"inline-flex",alignItems:"center",gap:4},children:[i.jsx("label",{htmlFor:"g7le-custom-width-input",style:{fontSize:11,color:"#475569"},children:d("layout_editor.device.custom_width_label")}),i.jsx("input",{id:"g7le-custom-width-input",type:"number",min:ig,max:sg,step:10,value:n,"data-testid":"g7le-custom-width-input","aria-label":d("layout_editor.device.custom_width_label"),onChange:p=>{const f=parseInt(p.currentTarget.value,10);Number.isFinite(f)&&s(f)},style:{width:72,padding:"2px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:4}}),i.jsx("span",{style:{fontSize:11,color:"#475569"},children:"px"})]}),i.jsx("div",{role:"group","aria-label":d("layout_editor.toolbar.color_scheme_group"),style:{display:"inline-flex",gap:2},children:["light","dark"].map(p=>{const f=a===p;return i.jsx("button",{type:"button","data-testid":`g7le-toolbar-scheme-${p}`,"aria-pressed":f,onClick:()=>l(p),style:{padding:"2px 8px",fontSize:12,cursor:"pointer",background:f?"#0f172a":"#f8fafc",color:f?"#fff":"#0f172a",border:"1px solid #cbd5e1",borderRadius:4},children:d(p==="light"?"layout_editor.toolbar.color_scheme_light":"layout_editor.toolbar.color_scheme_dark")},p)})}),i.jsxs("div",{style:{display:"inline-flex",alignItems:"center",gap:4},children:[i.jsx("span",{style:{fontSize:11,color:"#475569"},children:d("layout_editor.zoom.label")}),i.jsx("input",{type:"range",min:.5,max:1,step:.05,value:t,"data-testid":"g7le-zoom-slider","aria-label":d("layout_editor.zoom.aria_label"),onChange:p=>r(parseFloat(p.currentTarget.value)),style:{width:100}}),i.jsxs("span",{style:{fontSize:11,color:"#475569",minWidth:36,textAlign:"right"},children:[Math.round(t*100),"%"]})]}),i.jsx(Lb,{})]})}function O4(){const{t:e}=tt(),{state:t,dispatch:n}=Je(),{availableStates:o,activeStateId:r}=t,[,s]=u.useState(0);u.useEffect(()=>{if(typeof window>"u")return;const p=()=>s(y=>y+1),f=window.requestAnimationFrame(p);return window.addEventListener(Ua,p),()=>{window.cancelAnimationFrame(f),window.removeEventListener(Ua,p)}},[]);const a=u.useCallback((p,f)=>{if(!p)return f;if(!p.startsWith("$t:"))return p;const y=p.slice(3),g=window.G7Core;if(g&&typeof g.t=="function"){const h=g.t(y);if(h&&h!==y&&!h.startsWith("$t:"))return h}const m=e(y);return m&&m!==y?m:f},[e]);if(!Array.isArray(o)||o.length<=1)return null;const l=p=>{const f=p.target.value;n({type:"SET_ACTIVE_STATE",activeStateId:f}),Sl({kind:"change",activeStateId:f,routePath:t.selectedRoute?.path??null,availableCount:o.length})},d=o.find(p=>p.id===r)??o[0],c=d?.description?a(d.description,""):"";return i.jsxs("div",{className:"g7le-state-switcher","data-testid":"g7le-state-switcher","data-active-state":r??"",style:{display:"inline-flex",alignItems:"center",gap:6,padding:"4px 8px",background:"#fff",border:"1px solid #cbd5e1",borderRadius:6},children:[i.jsx("span",{style:{fontSize:12,fontWeight:600,color:"#475569",whiteSpace:"nowrap"},children:e("layout_editor.toolbar.state")}),i.jsx("select",{"data-testid":"g7le-state-switcher-select","aria-label":e("layout_editor.toolbar.state"),value:r??o[0].id,onChange:l,style:{fontSize:12,padding:"2px 6px",border:"1px solid #cbd5e1",borderRadius:4,background:"#fff",color:"#0f172a",cursor:"pointer",outline:"none"},children:o.map(p=>i.jsx("option",{value:p.id,children:a(p.label,p.id)},p.id))}),c&&i.jsx("span",{"data-testid":"g7le-state-switcher-description",style:{fontSize:11,color:"#94a3b8",whiteSpace:"nowrap"},children:c})]})}function B4(e,t,n){if(e==="extension"){if(typeof n=="string"&&n.length>0){const o=qa(n,"__base__/");return o?{kind:"base",match:o}:{kind:"route",match:n}}return null}if(!t)return null;switch(e){case"route":return{kind:"route",match:t};case"base":{const o=qa(t,"__base__/");return o?{kind:"base",match:o}:null}case"modal":{const o=qa(t,"__modal__/");return o?{kind:"modal",match:o}:null}default:return null}}function W4(e,t){if(!t||!Array.isArray(e)||e.length===0)return[];const n=[];for(const o of e)if(H4(o.scope,t))for(const r of o.items??[])r&&typeof r.id=="string"&&r.id.length>0&&n.push(r);return n}function j4(e){if(e.length===0)return null;const t=e.find(n=>n.default===!0);return t?t.id:e[0].id}function H4(e,t){if(!e||e.kind!==t.kind)return!1;const n=e.match;return typeof n!="string"||n.length===0?!1:t.kind==="route"?F4(t.match,n):n===t.match}function F4(e,t){if(t===e)return!0;if(!t.includes("*"))return!1;const n=t.replace(/[.+?^${}()|[\]\\]/g,"\\$&").replace(/\*/g,"[^/]+");return new RegExp(`^${n}$`).test(e)}function qa(e,t){return e.startsWith(t)?e.slice(t.length):""}const K4="__conngroup__/modals/",V4="__conngroup__/extensions/";function U4(e){return e.meta?.editor_label?{label:e.meta.editor_label,labelSource:"editor_label"}:e.meta?.title?{label:e.meta.title,labelSource:"title"}:{label:e.path,labelSource:"path"}}function q4(e){switch(e.source.kind){case"template":return"template";case"module":return`module:${e.source.identifier??""}`;case"plugin":return`plugin:${e.source.identifier??""}`;case"core":return null;default:return null}}function G4(e,t){if(e.meta?.parent&&t.has(e.meta.parent))return e.meta.parent;const n=e.path.split("/").filter(Boolean);for(let o=n.length-1;o>=1;o--){const r="/"+n.slice(0,o).join("/");if(r!==e.path&&t.has(r))return r}return null}function Y4(e){const t=new Map;for(const n of e)n.layout&&t.set(n.layout,(t.get(n.layout)??0)+1);return t}function X4(e){const t=new Map;for(const n of e){if(!n.hostLayout)continue;const o=t.get(n.hostLayout)??[];o.push(n),t.set(n.hostLayout,o)}return t}function J4(e){const t=new Map;for(const n of e)for(const o of n.extensions)for(const r of o.hostLayouts??[]){if(!r)continue;const s=t.get(r)??[];s.push({extension:o,group:n}),t.set(r,s)}return t}function Z4(e,t){return{path:`__modal__/${e.hostLayout}/${e.modalId}`,layoutName:e.hostLayout,label:e.label??e.modalId,labelSource:e.label?"editor_label":"path",source:{kind:"core",identifier:null},kind:"modal",modalId:e.modalId,modalHostLayout:e.hostLayout,connectedHostRoutePath:t,children:[]}}function Q4(e,t){const{extension:n,group:o}=e,r=o.sourceType==="plugin"?"🔌":"🧩";return{path:`__extension__/${n.id}`,layoutName:null,label:`${r} ${o.sourceLabel} · ${n.targetName}`,labelSource:"path",source:{kind:o.sourceType,identifier:o.sourceIdentifier},kind:"extension",extensionId:String(n.id),extensionType:n.extensionType,extensionPriority:n.priority,extensionTargetName:n.targetName,isModified:n.isModified,isInactive:!n.isActive,connectedHostRoutePath:t,children:[]}}function bg(e,t,n){if(e.kind!=="route"&&e.kind!=="base"||!e.layoutName)return;const o=[],r=t.get(e.layoutName)??[];r.length>0&&o.push({path:`${K4}${e.path}`,layoutName:null,label:`$t:layout_editor.chrome.route_tree.connected.modals|count=${r.length}`,labelSource:"editor_label",source:{kind:"core",identifier:null},kind:"route",children:r.map(a=>Z4(a,e.path))});const s=n.get(e.layoutName)??[];s.length>0&&o.push({path:`${V4}${e.path}`,layoutName:null,label:`$t:layout_editor.chrome.route_tree.connected.extensions|count=${s.length}`,labelSource:"editor_label",source:{kind:"core",identifier:null},kind:"route",children:s.map(a=>Q4(a,e.path))}),o.length>0&&(e.children=[...o,...e.children??[]])}function R4(e){const t=[],n=new Set(e.routes.map(o=>o.path));for(const o of e.routes){const r=q4(o);if(!r)continue;const{label:s,labelSource:a}=U4(o),l=G4(o,n),d={path:o.path,layoutName:o.layout??null,label:s,labelSource:a,icon:o.meta?.icon??null,source:o.source,isRedirect:!!o.redirect,isHidden:!!o.meta?.hidden,kind:"route",children:[]};t.push({node:d,parentKey:l,groupKey:r})}return t}function eD(e,t){if(e==="template")return"$t:layout_editor.chrome.route_tree.group.template";if(e.startsWith("module:")){const n=e.slice(7);return`$t:layout_editor.chrome.route_tree.group.module|name=${t.moduleDisplayNames[n]??n}`}if(e.startsWith("plugin:")){const n=e.slice(7);return`$t:layout_editor.chrome.route_tree.group.plugin|name=${t.pluginDisplayNames[n]??n}`}return e}function tD(e,t){Y4(e.routes);const n=X4(e.modals),o=J4(e.extensionGroups??[]),r=new Map;for(const d of t)r.has(d.groupKey)||r.set(d.groupKey,[]),r.get(d.groupKey).push(d);const s=Array.from(r.keys()).sort((d,c)=>{const p=g=>g==="template"?1:g.startsWith("module:")?2:g.startsWith("plugin:")?3:4,f=p(d),y=p(c);return f!==y?f-y:d.localeCompare(c)}),a=[];if(e.baseLayouts.length>0){const d=e.baseLayouts.map(c=>({path:`__base__/${c.layoutName}`,layoutName:c.layoutName,label:c.label??c.layoutName,labelSource:c.label?"editor_label":"path",source:{kind:"core",identifier:null},kind:"base",children:[]}));for(const c of d)bg(c,n,o);a.push({path:"__group__/base",layoutName:null,label:"$t:layout_editor.chrome.route_tree.group.base",labelSource:"editor_label",source:{kind:"core",identifier:null},kind:"route",children:d})}for(const d of s){const c=r.get(d),p=new Map;for(const y of c)p.set(y.node.path,y);const f=[];for(const y of c)if(y.parentKey&&p.has(y.parentKey)){const g=p.get(y.parentKey);g.node.children=g.node.children??[],g.node.children.push(y.node)}else f.push(y.node);for(const y of c)bg(y.node,n,o);a.push({path:`__group__/${d}`,layoutName:null,label:eD(d,e),labelSource:"editor_label",source:{kind:"core",identifier:null},kind:"route",children:f})}if(e.modals.length>0){const d=e.modals.map(c=>({path:`__modal__/${c.hostLayout}/${c.modalId}`,layoutName:c.hostLayout,label:c.label??c.modalId,labelSource:c.label?"editor_label":"path",source:{kind:"core",identifier:null},kind:"modal",modalId:c.modalId,modalHostLayout:c.hostLayout,children:[]}));a.push({path:"__group__/modal",layoutName:null,label:"$t:layout_editor.chrome.route_tree.group.modal",labelSource:"editor_label",source:{kind:"core",identifier:null},kind:"route",children:d})}const l=e.extensionGroups??[];if(l.length>0){const d=l.map(c=>{const p=c.sourceType==="plugin"?"🔌":"🧩",f=c.extensions.map(y=>({path:`__extension__/${y.id}`,layoutName:null,label:`${c.sourceLabel} · ${y.targetName}`,labelSource:"path",source:{kind:c.sourceType,identifier:c.sourceIdentifier},kind:"extension",extensionId:String(y.id),extensionType:y.extensionType,extensionPriority:y.priority,extensionTargetName:y.targetName,isModified:y.isModified,isInactive:!y.isActive,children:[]}));return{path:`__extgroup__/${c.sourceType}/${c.sourceIdentifier}`,layoutName:null,label:`${p} ${c.sourceLabel}`,labelSource:"path",source:{kind:c.sourceType,identifier:c.sourceIdentifier},kind:"route",children:f}});a.push({path:"__group__/extension",layoutName:null,label:"$t:layout_editor.chrome.route_tree.group.extension",labelSource:"editor_label",source:{kind:"core",identifier:null},kind:"route",children:d})}return a}function nD(e){const t=R4(e);return tD(e,t)}const Pn=ar("useEditorRoutes");function oD(e,t){if(typeof e=="string")return e.length>0?e:null;if(e&&typeof e=="object"){const n=e[t];if(typeof n=="string"&&n.length>0)return n;if(typeof e.en=="string"&&e.en.length>0)return e.en;for(const o of Object.values(e))if(typeof o=="string"&&o.length>0)return o}return null}function mg(e,t){const n={};if(typeof window>"u")return n;const r=window.G7Config?.[e];if(!Array.isArray(r))return n;for(const s of r)s&&typeof s.identifier=="string"&&(n[s.identifier]=oD(s.display_name,t)??s.identifier);return n}function rD(e){const t=new yd,n=typeof window<"u"?window.G7Config:void 0,o={_global:{settings:n?.settings??{},plugins:n?.plugins??{},modules:n?.modules??{},appConfig:n?.appConfig??{}}};return e.map(r=>{const s={...r};if(typeof s.path=="string"&&s.path.includes("{{")){const a=s.path;let l=t.resolveBindings(s.path,o);l=l.replace(/\/\/+/g,"/")||"/",l.includes("{{")&&(Pn.warn("편집기 route path 표현식 평가 실패 — fallback 적용",{originalPath:a}),l=a.replace(/\{\{[^}]+\}\}/g,"").replace(/\/\/+/g,"/")||"/"),s.path=l}if(typeof s.redirect=="string"&&s.redirect.includes("{{")){const a=s.redirect;let l=t.resolveBindings(a,o);l=l.replace(/\/\/+/g,"/")||"/",l.includes("{{")&&(Pn.warn("편집기 route redirect 표현식 평가 실패 — fallback 적용",{originalRedirect:a}),l=a.replace(/\{\{[^}]+\}\}/g,"").replace(/\/\/+/g,"/")||"/"),s.redirect=l}return s})}function iD(e){const{dispatch:t}=Je(),{translationEngine:n,translationContext:o}=tt(),{templateIdentifier:r,cacheVersion:s}=e;u.useEffect(()=>{let a=!1;const l=s??(typeof window<"u"?window.G7Config?.cache_version:void 0)??"",d=l!==""?`?v=${encodeURIComponent(String(l))}`:"",c=`/api/admin/templates/${encodeURIComponent(r)}/editor/routes.json${d}`,p=o?.locale??"ko",f=n&&r!==o?.templateId?n.loadTranslations(r,p).then(()=>{Pn.log("편집 대상 lang 로드 완료",{targetTemplate:r,locale:p})}).catch(h=>{Pn.warn("편집 대상 lang 로드 실패 — 라우트 라벨 fallback 사용",h)}):Promise.resolve(),y=(()=>{if(typeof fetch!="function")return Promise.reject(oo(new Error("fetch unavailable"),"routes"));try{const h=fetch(c,{credentials:"same-origin",headers:je()});return!h||typeof h.then!="function"?Promise.reject(oo(new Error("fetch returned non-Promise (test stub?)"),"routes")):h.then(async v=>{if(!v.ok){const b=await v.json().catch(()=>null);throw Ur(v.status,b,"routes")}return v.json()})}catch(h){return Promise.reject(h)}})(),g=`/api/admin/templates/${encodeURIComponent(r)}/layout-extensions${d}`,m=(()=>{if(typeof fetch!="function")return Promise.resolve(null);try{const h=fetch(g,{credentials:"same-origin",headers:je()});return!h||typeof h.then!="function"?Promise.resolve(null):h.then(async v=>v.ok?await v.json():null).catch(()=>null)}catch{return Promise.resolve(null)}})();return Promise.all([y,f,m]).then(([h,,v])=>{if(a)return;if(!h||!h.success||!Array.isArray(h.data?.routes)){Pn.warn("routes 응답 형식이 비정상 — empty tree 로 둠",h),t({type:"SET_ROUTES_ERROR",error:{kind:"unknown",status:200,message:"routes 응답 형식이 비정상입니다.",source:"routes"}});return}const b=rD(h.data.routes),x=mg("activeModules",p),k=mg("activePlugins",p),w=(h.data.base_layouts??[]).map(A=>({layoutName:A.layout_name,label:A.label??void 0})),_=(h.data.modals??[]).map(A=>({modalId:A.modal_id,hostLayout:A.host_layout,label:A.label??void 0})),S=(v?.success&&Array.isArray(v.data)?v.data:[]).map(A=>({sourceIdentifier:A.source_identifier,sourceType:A.source_type,sourceLabel:A.source_label,extensions:(A.extensions??[]).map(z=>({id:z.id,extensionType:z.extension_type,targetName:z.target_name,priority:z.priority,isActive:z.is_active,isModified:z.is_modified,hostLayouts:Array.isArray(z.host_layouts)?z.host_layouts:[],currentVersion:typeof z.current_version=="number"?z.current_version:void 0}))})),E=nD({routes:b,modals:_,baseLayouts:w,extensionGroups:S,moduleDisplayNames:x,pluginDisplayNames:k});t({type:"SET_ROUTE_TREE",tree:E});const C=h.data.layout_versions,N={};if(C&&typeof C=="object"&&!Array.isArray(C))for(const[A,z]of Object.entries(C))typeof z=="number"&&Number.isFinite(z)&&(N[A]=z);t({type:"SET_LAYOUT_VERSIONS",versions:N});const $={};for(const A of S)for(const z of A.extensions){const D=z.currentVersion;typeof D=="number"&&Number.isFinite(D)&&($[String(z.id)]=D)}t({type:"SET_EXTENSION_VERSIONS",versions:$}),Pn.log("routes 로드 완료",{count:b.length,groups:E.length})}).catch(h=>{if(a)return;Pn.error("routes 로드 실패",h);const v=dp(h)?h:oo(h,"routes");t({type:"SET_ROUTES_ERROR",error:v})}),()=>{a=!0}},[r,s,t,n,o?.locale,o?.templateId])}function vg(e,t,n,o=[]){const r=t?_g(o,t):null,s=r!==null?{path:r.path,layoutName:r.layoutName}:void 0;if(e.startsWith("__base__/")){const a=e.slice(9);if(a)return n({type:"ENTER_BASE_EDIT",layoutName:a,returnRoute:s}),!0}else if(e.startsWith("__modal__/")){const a=e.slice(10);return a&&!a.includes("/")&&t?(n({type:"ENTER_MODAL_EDIT",modalId:a,hostLayout:t,returnRoute:s}),!0):!1}else if(e.startsWith("__extension__/")){const a=e.slice(14);if(a&&!a.includes("/"))return n({type:"ENTER_EXTENSION_EDIT",extensionId:a,returnRoute:s}),!0}else if(e.startsWith("__iteration__/")){const a=e.slice(14);return a&&t?(n({type:"ENTER_ITERATION_ITEM_EDIT",sourcePath:a,hostLayout:t,returnRoute:s}),!0):!1}return!1}function _g(e,t){for(const n of e){if(n.kind==="route"&&n.layoutName===t)return n;if(n.children&&n.children.length>0){const o=_g(n.children,t);if(o)return o}}return null}function Ga(e,t){for(const n of e){if(n.kind==="route"&&n.path===t)return n;if(n.children&&n.children.length>0){const o=Ga(n.children,t);if(o)return o}}return null}function sD(e){const{state:t,dispatch:n}=Je(),o=u.useRef(!1),r=u.useRef(t.editMode);r.current=t.editMode,u.useEffect(()=>{if(o.current||t.routeTree.length===0||typeof window>"u")return;const a=tp(window.location.search);if(a){const c=ep(window.location.search),p=vg(a,c,n,t.routeTree);if(o.current=!0,p)return}const l=Ru(window.location.search);if(!l){o.current=!0;return}const d=Ga(t.routeTree,l);d&&n({type:"SELECT_ROUTE",route:{path:d.path,layoutName:d.layoutName}}),o.current=!0},[t.routeTree,n]),u.useEffect(()=>{if(typeof window>"u")return;const a=()=>{const l=tp(window.location.search);if(l){const p=ep(window.location.search);if(vg(l,p,n,t.routeTree))return}if(r.current!=="route")switch(r.current){case"base":n({type:"EXIT_BASE_EDIT"});return;case"modal":n({type:"EXIT_MODAL_EDIT"});return;case"extension":n({type:"EXIT_EXTENSION_EDIT"});return;case"iteration_item":n({type:"EXIT_ITERATION_ITEM_EDIT"});return}const d=Ru(window.location.search);if(!d){n({type:"SELECT_ROUTE",route:null});return}const c=Ga(t.routeTree,d);c&&n({type:"SELECT_ROUTE",route:{path:c.path,layoutName:c.layoutName}})};return window.addEventListener("popstate",a),()=>{window.removeEventListener("popstate",a)}},[t.routeTree,n]);const s=u.useRef("route");u.useEffect(()=>{if(typeof window>"u"||!window.history?.replaceState||t.routeTree.length===0)return;const a=Xs(e,t.selectedRoute?.path??null,t.selectedRoute?.layoutName??null),l=`${window.location.pathname}${window.location.search}`,d=s.current;if(s.current=t.editMode,l===a)return;d==="route"&&t.editMode!=="route"&&window.history.pushState?window.history.pushState(window.history.state,"",a):window.history.replaceState(window.history.state,"",a)},[t.selectedRoute,t.editMode,t.routeTree,e])}function aD(){u.useEffect(()=>{if(typeof document>"u")return;const e=document.documentElement;let t=e.classList.contains("dark");const n=()=>{e.classList.contains("dark")&&(t=!0,e.classList.remove("dark"))};n();let o=null;return typeof MutationObserver<"u"&&(o=new MutationObserver(n),o.observe(e,{attributes:!0,attributeFilter:["class"]})),()=>{o?.disconnect(),t&&e.classList.add("dark")}},[])}function lD(){u.useEffect(()=>{if(typeof document>"u"||!document.body)return;const e=document.body,t=()=>{e.style.overflow==="hidden"&&(e.style.overflow=""),e.style.overflowY==="hidden"&&(e.style.overflowY="")};t();let n=null;return typeof MutationObserver<"u"&&(n=new MutationObserver(t),n.observe(e,{attributes:!0,attributeFilter:["style"]})),()=>{n?.disconnect()}},[])}function dD(e){if(typeof e!="string")return null;const t="__extension__/";if(!e.startsWith(t))return null;const n=e.slice(t.length);return n===""||n.includes("/")?null:n}function cD(e){if(Array.isArray(e.components))return e.components;if(Array.isArray(e.injections)){const t=[];return e.injections.forEach((n,o)=>{if(n&&Array.isArray(n.components))for(const r of n.components)t.push({...r,__injectionIndex:o})}),t}return[]}function uD(e,t){if(Array.isArray(e.components)){const n=t.map(o=>Ya(o));return{...e,components:n}}if(Array.isArray(e.injections)){const n=e.injections.map(o=>({...o}));n.forEach(o=>{Array.isArray(o.components)&&(o.components=[])});for(const o of t){const r=o.__injectionIndex,s=typeof r=="number"&&r>=0&&rYa(n))),t}function xg(e,t){const n=[],o=r=>{if(Array.isArray(r))for(const s of r){if(!s||typeof s!="object")continue;const a=s.__source;if(a?.kind==="extension"&&a.extensionId===t){n.push(s);continue}Array.isArray(s.children)&&o(s.children)}};return o(e),n}function wg(e,t){const o=xg(e,t)[0]?.__source;return o&&o.kind==="extension"&&o.extensionId===t?o:{kind:"extension",extensionId:t}}function Xa(e,t){for(const n of e){const o=n?.__source;if(o?.kind==="extension"&&o.extensionId===t||Array.isArray(n.children)&&Xa(n.children,t))return!0}return!1}function pD(){const{state:e}=Je(),t=e.templateIdentifier,n=e.editMode==="extension"?dD(e.selectedRoute?.path):null,o=e.editMode==="extension"&&typeof e.selectedRoute?.layoutName=="string"?e.selectedRoute.layoutName:null,[r,s]=u.useState(null),[a,l]=u.useState(!1),[d,c]=u.useState(null),[p,f]=u.useState(!1),[y,g]=u.useState(0),[m,h]=u.useState(null),v=u.useRef({}),b=u.useRef({kind:"extension",extensionId:0}),x=u.useRef(null);u.useEffect(()=>{x.current=r},[r]),u.useEffect(()=>{h(null)},[n]);const k=u.useCallback(async A=>{const z=window.G7Config?.cache_version??0,D=`/api/layouts/${encodeURIComponent(t)}/${A}.json?with_source_meta=1&v=${z}.${mp()}`,O=rr(),M={Accept:"application/json"};O&&(M.Authorization=`Bearer ${O}`);const B=await fetch(D,{headers:M,credentials:"same-origin"});if(!B.ok)return null;const T=await B.json().catch(()=>null);return(T&&(T.data||T))??{}},[t]),w=u.useCallback(async()=>{if(!n){s(null),c(null),f(!1);return}l(!0),c(null);try{const A=window.G7Config?.cache_version??0,z=`/api/admin/templates/${encodeURIComponent(t)}/layout-extensions/${encodeURIComponent(n)}?v=${A}`,D=await fetch(z,{headers:je(),credentials:"same-origin"}),O=await D.json().catch(()=>null);if(!D.ok){c(Ur(D.status,O,"layout")),s(null),f(!1);return}const M=(O&&(O.data||O))??{};let B={};const T=M.content;if(typeof T=="string")try{B=JSON.parse(T)}catch{B={}}else T&&typeof T=="object"&&(B=T);const H=M.extension_type==="overlay"?"overlay":"extension_point",L=typeof M.lock_version=="number"?M.lock_version:0,I=Array.isArray(M?.host_layouts)?M.host_layouts.filter(re=>typeof re=="string"):[],P=o??(I.length===1?I[0]:m&&I.includes(m)?m:null),F=Number(n);v.current=B;let V=cD(B),Y={},Z="pending";const se={kind:"extension",extensionId:F};if(b.current=se,P){const re=await k(P);if(re){Y=re;const U=Array.isArray(re.components)?re.components:[],ee=wg(U,F);if(b.current=ee,Xa(U,F))V=U,Z="ok";else{const K=(Array.isArray(re.modals)?re.modals:[]).filter(ne=>ne&&typeof ne=="object"&&Xa([ne],F));if(K.length>0){const ne=K.map(me=>({...me,props:{...me.props??{},isOpen:!0}}));V=[...U,...ne],b.current=wg(K,F),Z="ok"}else V=U,Z="no-injection"}}}s({extensionId:F,content:B,extensionType:H,components:V,lockVersion:L,hostLayouts:I,hostLayoutName:P,hostRaw:Y,editability:Z}),f(!1),st({op:"load",layoutName:`extension:${n}`,editMode:"extension",statusCode:D.status,timestamp:Date.now()})}catch(A){c(oo(A,"layout")),s(null),f(!1)}finally{l(!1)}},[t,n,m,o,k]);u.useEffect(()=>{w()},[w]);const _=u.useCallback(A=>{h(A)},[]),S=u.useCallback(A=>{s(z=>z&&{...z,components:A}),f(!0),st({op:"patch",layoutName:`extension:${x.current?.extensionId??""}`,editMode:"extension",isDirty:!0,timestamp:Date.now()})},[]),E=u.useCallback(A=>{s(z=>z&&{...z,components:A(z.components)}),f(!0)},[]),C=u.useCallback(async()=>{const A=x.current;if(!A)return{kind:"guard_no_document"};const z=`/api/admin/templates/${encodeURIComponent(t)}/layout-extensions/${A.extensionId}`,D=xg(A.components,A.extensionId),O=D.length>0?D:A.components,M=uD(v.current,O);st({op:"save",layoutName:`extension:${A.extensionId}`,editMode:"extension",saveTarget:"layout_extension",endpoint:z,isDirty:p,timestamp:Date.now()});let B;try{B=await fetch(z,{method:"PUT",headers:je({"Content-Type":"application/json"}),credentials:"same-origin",body:JSON.stringify({content:M,expected_lock_version:A.lockVersion})})}catch(H){return{kind:"network_error",message:H instanceof Error?H.message:"network error"}}const T=await B.json().catch(()=>null);if(st({op:"save_response",layoutName:`extension:${A.extensionId}`,editMode:"extension",endpoint:z,statusCode:B.status,conflict:B.status===409?{currentVersion:T?.errors?.current_version??T?.current_version,yourVersion:T?.errors?.your_version??T?.your_version}:void 0,timestamp:Date.now()}),B.ok){const H=T?.data??{},L=typeof H.lock_version=="number"?H.lock_version:A.lockVersion+1;s(P=>P&&{...P,lockVersion:L}),f(!1),g(P=>P+1);const I=typeof H.current_version=="number"?H.current_version:void 0;return{kind:"success",newLockVersion:L,newContentVersion:I,savedExtensionId:String(A.extensionId)}}return B.status===409?{kind:"concurrent_modification",currentVersion:T?.current_version??-1,yourVersion:T?.your_version??A.lockVersion}:B.status===422?{kind:"validation_failed",status:422,errors:T?.errors??null}:{kind:"network_error",message:T?.message??`HTTP ${B.status}`}},[t,p]),N=u.useCallback(async()=>{ea(),await w()},[w]),$=!!r&&r.hostLayouts.length>1&&r.hostLayoutName===null;return{document:r,isLoading:a,error:d,isDirty:p,saveSuccessCounter:y,setLayoutComponents:S,patchLayout:E,save:C,reload:N,needsHostPicker:$,selectHost:_}}const kg=new Set;function fD(e,t,n){const r=e&&Object.keys(e).length>0?e:t,s=Array.isArray(r.data_sources)?r.data_sources:[],a=Array.isArray(t.data_sources)?t.data_sources:[],l=new Set,d=[];for(const c of[...s,...a]){const p=c&&typeof c=="object"?c.id:void 0,f=typeof p=="string"?p:`__anon_${d.length}`;l.has(f)||(l.add(f),d.push(c))}return{...r,components:n,...d.length>0?{data_sources:d}:{},lock_version:void 0}}function gD(e){return{document:e.document?{layoutName:`extension:${e.document.extensionId}`,raw:{...fD(e.document.hostRaw,e.document.content,e.document.components),__editability:e.document.editability,__extensionId:e.document.extensionId},lockVersion:e.document.lockVersion}:null,isLoading:e.isLoading,error:e.error,isDirty:e.isDirty,saveSuccessCounter:e.saveSuccessCounter,reload:e.reload,patchLayout:e.patchLayout,setLayoutComponents:e.setLayoutComponents,patchDocumentRaw:()=>{},dirtyKeys:kg,dirtyLayoutNames:kg,invalidateCurrentCache:()=>{},reloadCounter:0,save:()=>e.save(),markDirty:()=>{}}}function hD({hostLayouts:e,extensionLabel:t,onSelect:n}){const{t:o}=tt();return i.jsx("div",{className:"g7le-host-picker__backdrop","data-testid":"g7le-host-picker",role:"dialog","aria-modal":"true","aria-label":o("layout_editor.host_picker.title"),style:{position:"fixed",inset:0,zIndex:9500,display:"flex",alignItems:"center",justifyContent:"center",background:"rgba(15, 23, 42, 0.55)"},children:i.jsxs("div",{className:"g7le-host-picker__panel",style:{width:"min(520px, 92vw)",maxHeight:"80vh",overflow:"auto",background:"#ffffff",color:"#0f172a",borderRadius:12,boxShadow:"0 20px 48px rgba(15, 23, 42, 0.28)",padding:"20px 22px"},children:[i.jsx("h2",{className:"g7le-host-picker__title",style:{margin:"0 0 6px",fontSize:16,fontWeight:700},children:o("layout_editor.host_picker.title")}),i.jsx("p",{className:"g7le-host-picker__desc",style:{margin:"0 0 16px",fontSize:13,color:"#475569",lineHeight:1.6},children:t?o("layout_editor.host_picker.description_with_label",{label:t}):o("layout_editor.host_picker.description")}),i.jsx("ul",{className:"g7le-host-picker__list",style:{listStyle:"none",margin:0,padding:0,display:"grid",gap:8},children:e.map(r=>i.jsx("li",{children:i.jsxs("button",{type:"button",className:"g7le-host-picker__item","data-testid":"g7le-host-picker-item","data-host":r,onClick:()=>n(r),style:{width:"100%",textAlign:"left",padding:"12px 14px",border:"1px solid #e2e8f0",borderRadius:8,background:"#f8fafc",cursor:"pointer",fontSize:13,fontWeight:600,color:"#0f172a",display:"flex",alignItems:"center",gap:8},children:[i.jsx("span",{"aria-hidden":!0,style:{fontSize:14},children:"🖼"}),i.jsx("span",{style:{flex:1,minWidth:0,overflow:"hidden",textOverflow:"ellipsis"},children:r}),i.jsx("span",{"aria-hidden":!0,style:{color:"#94a3b8"},children:"→"})]})},r))})]})})}function yD(e,t){const[n,o]=u.useState(!1),[r,s]=u.useState(null),a=u.useCallback(async l=>{if(!t||!l)return{kind:"no_document"};o(!0),s(null);try{const d=yo(l),c=`/api/admin/templates/${encodeURIComponent(e)}/layouts/${t}/preview`,p=await fetch(c,{method:"POST",credentials:"same-origin",headers:je({"Content-Type":"application/json"}),body:JSON.stringify({content:JSON.stringify(d)})}),f=await p.json().catch(()=>null);if(!p.ok){const h=f?.message??`HTTP ${p.status}`;return s(h),{kind:"network_error",message:h}}const y=f?.data,g=typeof y?.token=="string"?y.token:"",m=typeof y?.preview_url=="string"&&y.preview_url?y.preview_url:g?`/preview/${g}`:"";return m?(window.G7Core?.dispatch?.({handler:"openWindow",params:{path:m}}),{kind:"success",previewUrl:m,token:g}):(s("invalid preview response"),{kind:"network_error",message:"invalid preview response"})}catch(d){const c=d instanceof Error?d.message:"network error";return s(c),{kind:"network_error",message:c}}finally{o(!1)}},[e,t]);return{isCreating:n,error:r,createPreview:a}}const ce="layout_editor.action_recipe",yn={setState:{label:`$t:${ce}.set_state.label`,params:[{key:"target",label:`$t:${ce}.set_state.param_target`,widget:"select",options:[{value:"local"},{value:"global"}]},{key:"state",label:`$t:${ce}.set_state.param_state`,widget:"state-key-value"},{key:"merge",label:`$t:${ce}.set_state.param_merge`,widget:"toggle"}],build:{handler:"setState",params:{target:"{{target}}",merge:"{{merge}}","...":"{{state}}"}}},loadFromLocalStorage:{label:`$t:${ce}.load_local.label`,params:[{key:"key",label:`$t:${ce}.load_local.param_key`,widget:"text",required:!0},{key:"stateKey",label:`$t:${ce}.load_local.param_state_key`,widget:"state-key-picker"},{key:"defaultValue",label:`$t:${ce}.load_local.param_default`,widget:"text"}],build:{handler:"loadFromLocalStorage",params:{key:"{{key}}",stateKey:"{{stateKey}}",defaultValue:"{{defaultValue}}"}}},saveToLocalStorage:{label:`$t:${ce}.save_local.label`,params:[{key:"key",label:`$t:${ce}.save_local.param_key`,widget:"text",required:!0},{key:"value",label:`$t:${ce}.save_local.param_value`,widget:"data-chip"}],build:{handler:"saveToLocalStorage",params:{key:"{{key}}",value:"{{value}}"}}},navigate:{label:`$t:${ce}.navigate.label`,params:[{key:"path",label:`$t:${ce}.navigate.param_path`,widget:"page-picker",required:!0},{key:"query",label:`$t:${ce}.navigate.param_query`,widget:"key-value"},{key:"mergeQuery",label:`$t:${ce}.navigate.param_merge_query`,widget:"toggle"},{key:"scroll",label:`$t:${ce}.navigate.param_scroll`,widget:"select",options:[{value:"top"},{value:"preserve"},{value:"none"}]},{key:"replace",label:`$t:${ce}.navigate.param_replace`,widget:"toggle"},{key:"transition_overlay_target",label:`$t:${ce}.navigate.param_overlay_target`,widget:"component-target-picker",dependsOn:{param:"replace",equals:!0}}],build:{handler:"navigate",params:{path:"{{path}}",query:"{{query}}",mergeQuery:"{{mergeQuery}}",scroll:"{{scroll}}",replace:"{{replace}}",transition_overlay_target:"{{transition_overlay_target}}"}}},openWindow:{label:`$t:${ce}.open_window.label`,params:[{key:"target",label:`$t:${ce}.open_window.param_target`,widget:"page-picker",required:!0},{key:"windowTarget",label:`$t:${ce}.open_window.param_window_target`,widget:"select",options:[{value:"_blank"},{value:"_self"}]}],build:{handler:"openWindow",target:"{{target}}",params:{windowTarget:"{{windowTarget}}"}}},navigateBack:{label:`$t:${ce}.navigate_back.label`,params:[],build:{handler:"navigateBack"}},navigateForward:{label:`$t:${ce}.navigate_forward.label`,params:[],build:{handler:"navigateForward"}},replaceUrl:{label:`$t:${ce}.replace_url.label`,params:[{key:"path",label:`$t:${ce}.replace_url.param_path`,widget:"page-picker"},{key:"query",label:`$t:${ce}.replace_url.param_query`,widget:"key-value"}],build:{handler:"replaceUrl",params:{path:"{{path}}",query:"{{query}}"}}},toast:{label:`$t:${ce}.toast.label`,params:[{key:"message",label:`$t:${ce}.toast.param_message`,widget:"i18n-text",required:!0},{key:"type",label:`$t:${ce}.toast.param_type`,widget:"select",options:[{value:"info"},{value:"success"},{value:"warning"},{value:"error"}]},{key:"duration",label:`$t:${ce}.toast.param_duration`,widget:"number"},{key:"icon",label:`$t:${ce}.toast.param_icon`,widget:"text"}],build:{handler:"toast",params:{message:"{{message}}",type:"{{type}}",duration:"{{duration}}",icon:"{{icon}}"}}},openModal:{label:`$t:${ce}.open_modal.label`,params:[{key:"target",label:`$t:${ce}.open_modal.param_target`,widget:"modal-picker",required:!0}],build:{handler:"openModal",target:"{{target}}"}},closeModal:{label:`$t:${ce}.close_modal.label`,params:[],build:{handler:"closeModal"}},showAlert:{label:`$t:${ce}.show_alert.label`,params:[{key:"target",label:`$t:${ce}.show_alert.param_message`,widget:"i18n-text",required:!0}],build:{handler:"showAlert",target:"{{target}}"}},setError:{label:`$t:${ce}.set_error.label`,params:[{key:"target",label:`$t:${ce}.set_error.param_message`,widget:"i18n-text"},{key:"stateTarget",label:`$t:${ce}.set_error.param_state_target`,widget:"select",options:[{value:"local"},{value:"global"}]}],build:{handler:"setError",target:"{{target}}",params:{stateTarget:"{{stateTarget}}"}}},refetchDataSource:{label:`$t:${ce}.refetch.label`,params:[{key:"dataSourceId",label:`$t:${ce}.refetch.param_data_source`,widget:"datasource-picker",required:!0},{key:"sync",label:`$t:${ce}.refetch.param_sync`,widget:"toggle"}],build:{handler:"refetchDataSource",params:{dataSourceId:"{{dataSourceId}}",sync:"{{sync}}"}}},appendDataSource:{label:`$t:${ce}.append_data.label`,params:[{key:"dataSourceId",label:`$t:${ce}.append_data.param_data_source`,widget:"datasource-picker",required:!0},{key:"dataPath",label:`$t:${ce}.append_data.param_data_path`,widget:"text"},{key:"newData",label:`$t:${ce}.append_data.param_new_data`,widget:"data-chip"}],build:{handler:"appendDataSource",params:{dataSourceId:"{{dataSourceId}}",dataPath:"{{dataPath}}",newData:"{{newData}}"}}},updateDataSource:{label:`$t:${ce}.update_data.label`,params:[{key:"dataSourceId",label:`$t:${ce}.update_data.param_data_source`,widget:"datasource-picker",required:!0},{key:"data",label:`$t:${ce}.update_data.param_data`,widget:"data-chip"},{key:"merge",label:`$t:${ce}.update_data.param_merge`,widget:"toggle"}],build:{handler:"updateDataSource",params:{dataSourceId:"{{dataSourceId}}",data:"{{data}}",merge:"{{merge}}"}}},scrollIntoView:{label:`$t:${ce}.scroll.label`,params:[{key:"selector",label:`$t:${ce}.scroll.param_selector`,widget:"text",required:!0},{key:"behavior",label:`$t:${ce}.scroll.param_behavior`,widget:"select",options:[{value:"smooth"},{value:"auto"}]},{key:"block",label:`$t:${ce}.scroll.param_block`,widget:"select",options:[{value:"start"},{value:"center"},{value:"end"},{value:"nearest"}]}],build:{handler:"scrollIntoView",params:{selector:"{{selector}}",behavior:"{{behavior}}",block:"{{block}}"}}},login:{label:`$t:${ce}.login.label`,params:[{key:"body",label:`$t:${ce}.login.param_body`,widget:"key-value"}],build:{handler:"login",params:{body:"{{body}}"}}},logout:{label:`$t:${ce}.logout.label`,params:[{key:"target",label:`$t:${ce}.logout.param_target`,widget:"text"}],build:{handler:"logout",target:"{{target}}"}},setLocale:{label:`$t:${ce}.set_locale.label`,params:[{key:"target",label:`$t:${ce}.set_locale.param_target`,widget:"locale-picker",required:!0}],build:{handler:"setLocale",target:"{{target}}"}},emitEvent:{label:`$t:${ce}.emit_event.label`,params:[{key:"event",label:`$t:${ce}.emit_event.param_event`,widget:"text",required:!0},{key:"data",label:`$t:${ce}.emit_event.param_data`,widget:"data-chip"}],build:{handler:"emitEvent",params:{event:"{{event}}",data:"{{data}}"}}},apiCall:{label:`$t:${ce}.api_call.label`,params:[{key:"target",label:`$t:${ce}.api_call.param_target`,widget:"page-picker",required:!0},{key:"method",label:`$t:${ce}.api_call.param_method`,widget:"select",options:[{value:"GET"},{value:"POST"},{value:"PUT"},{value:"PATCH"},{value:"DELETE"}]},{key:"body",label:`$t:${ce}.api_call.param_body`,widget:"key-value"},{key:"query",label:`$t:${ce}.api_call.param_query`,widget:"key-value"},{key:"identity_target_email",label:`$t:${ce}.api_call.param_identity_target_email`,widget:"data-chip"},{key:"identity_target_phone",label:`$t:${ce}.api_call.param_identity_target_phone`,widget:"data-chip"},{key:"onSuccess",label:`$t:${ce}.api_call.param_on_success`,widget:"action-list"},{key:"onError",label:`$t:${ce}.api_call.param_on_error`,widget:"action-list"}],build:{handler:"apiCall",target:"{{target}}",identity_target:{email:"{{identity_target_email}}",phone:"{{identity_target_phone}}"},onSuccess:"{{onSuccess}}",onError:"{{onError}}",params:{method:"{{method}}",body:"{{body}}",query:"{{query}}"}}},conditions:{label:`$t:${ce}.conditions.label`,params:[{key:"branches",label:`$t:${ce}.conditions.param_branches`,widget:"branch-list"}],build:{handler:"conditions",conditions:"{{branches}}"}},sequence:{label:`$t:${ce}.sequence.label`,params:[{key:"actions",label:`$t:${ce}.sequence.param_actions`,widget:"action-list"}],build:{handler:"sequence",params:{actions:"{{actions}}"}}},parallel:{label:`$t:${ce}.parallel.label`,params:[{key:"actions",label:`$t:${ce}.parallel.param_actions`,widget:"action-list"}],build:{handler:"parallel",params:{actions:"{{actions}}"}}},switch:{label:`$t:${ce}.switch.label`,params:[{key:"value",label:`$t:${ce}.switch.param_value`,widget:"data-chip"},{key:"cases",label:`$t:${ce}.switch.param_cases`,widget:"action-list",advanced:!0}],build:{handler:"switch",params:{value:"{{value}}",cases:"{{cases}}"}}},suppress:{label:`$t:${ce}.suppress.label`,params:[],build:{handler:"suppress"}}};function bD(){return{actionRecipes:yn,initActionRecipes:yn,errorRecipes:{navigate:yn.navigate,openModal:yn.openModal,toast:yn.toast,setState:yn.setState,sequence:yn.sequence,parallel:yn.parallel,showErrorPage:{label:`$t:${ce}.show_error_page.label`,params:[{key:"target",label:`$t:${ce}.show_error_page.param_target`,widget:"select",options:[{value:"content"},{value:"full"}]}],build:{handler:"showErrorPage",params:{target:"{{target}}"}}}}}}function Ja(e,t){return typeof e.label=="string"?e.label.startsWith("$t:")?t(e.label.slice(3)):e.label:String(e.value)}function bi(e){return Array.isArray(e.options)?e.options:[]}function mD({control:e,value:t,onChange:n,t:o}){const r=bi(e);return i.jsx("div",{className:"g7le-widget g7le-widget--segmented","data-testid":"g7le-widget-segmented",style:TD,children:r.map(s=>{const a=s.value===t;return i.jsx("button",{type:"button","data-testid":`g7le-segment-${String(s.value)}`,"data-active":a?"true":"false",onClick:()=>n(a?void 0:s.value),style:{...BD,background:a?"#2563eb":"#fff",color:a?"#fff":"#0f172a",borderColor:a?"#2563eb":"#cbd5e1"},children:Ja(s,o)},String(s.value))})})}function vD({control:e,value:t,onChange:n,t:o}){const r=Array.isArray(e.scale)?e.scale:[],s=r.findIndex(l=>l===t),a=t!=null;return i.jsxs("div",{className:"g7le-widget g7le-widget--slider","data-testid":"g7le-widget-slider",style:Cg,children:[i.jsxs("label",{style:Za,children:[i.jsx("input",{type:"checkbox","data-testid":"g7le-slider-enabled",checked:a,onChange:l=>{l.target.checked?n(r.length>0?r[Math.max(0,s===-1?0:s)]:void 0):n(void 0)}}),i.jsx("span",{style:{fontSize:12,color:"#64748b"},children:o("layout_editor.control.use")})]}),i.jsx("input",{type:"range","data-testid":"g7le-slider-range",min:0,max:Math.max(0,r.length-1),step:1,disabled:!a||r.length===0,value:s===-1?0:s,onChange:l=>{const d=Number(l.target.value);n(r[d])},style:{flex:1}}),i.jsx("span",{"data-testid":"g7le-slider-value",style:{fontSize:12,color:"#0f172a",minWidth:48,textAlign:"right"},children:a?String(t):o("layout_editor.control.default")})]})}function _D({control:e,value:t,onChange:n,t:o}){const r=bi(e);return i.jsxs("select",{className:"g7le-widget g7le-widget--select","data-testid":"g7le-widget-select",value:t==null?"":String(t),onChange:s=>{const a=s.target.value;if(a===""){n(void 0);return}const l=r.find(d=>String(d.value)===a);n(l?l.value:a)},style:WD,children:[i.jsx("option",{value:"",children:o("layout_editor.control.default")}),r.map(s=>i.jsx("option",{value:String(s.value),children:Ja(s,o)},String(s.value)))]})}function xD({control:e,value:t,onChange:n}){const o=bi(e),r=o[1]?.value??!0,s=o[0]?.value??void 0,a=t===r;return i.jsx("button",{type:"button",className:"g7le-widget g7le-widget--toggle","data-testid":"g7le-widget-toggle",role:"switch","aria-checked":a,onClick:()=>n(a?s:r),style:{...jD,background:a?"#2563eb":"#cbd5e1"},children:i.jsx("span",{style:{...HD,transform:a?"translateX(18px)":"translateX(0)"}})})}function wD({control:e,value:t,onChange:n,t:o,freeValueDisabled:r}){const s=bi(e),a=t==null?"":String(t),[l,d]=u.useState(a);u.useEffect(()=>{d(a)},[a]);const c=p=>{const f=p.trim();if(f===""){n(void 0);return}n(f)};return i.jsxs("div",{className:"g7le-widget g7le-widget--dimension","data-testid":"g7le-widget-dimension",style:DD,children:[r?i.jsx("div",{"data-testid":"g7le-dimension-free-disabled",style:PD,children:o("layout_editor.property_modal.dark_preset_only")}):i.jsxs("div",{style:ND,children:[i.jsx("input",{type:"text","data-testid":"g7le-dimension-input",value:l,placeholder:o("layout_editor.control.dimension.placeholder"),onChange:p=>d(p.target.value),onBlur:p=>c(p.target.value),onKeyDown:p=>{p.key==="Enter"&&(p.preventDefault(),c(p.target.value))},style:zD}),i.jsx("button",{type:"button","data-testid":"g7le-dimension-clear",title:o("layout_editor.control.default"),onClick:()=>{d(""),n(void 0)},style:MD,children:o("layout_editor.control.default")})]}),s.length>0&&i.jsx("div",{style:LD,"data-testid":"g7le-dimension-chips",children:s.map(p=>{const f=String(p.value),y=f===a;return i.jsx("button",{type:"button","data-testid":`g7le-dimension-chip-${f}`,"data-active":y?"true":"false",onClick:()=>{d(f),n(f)},style:{...OD,background:y?"#2563eb":"#fff",color:y?"#fff":"#0f172a",borderColor:y?"#2563eb":"#cbd5e1"},children:Ja(p,o)},f)})})]})}function kD(e){return[{key:"t",infix:`${e}t-`,labelKey:"layout_editor.control.spacing.top"},{key:"r",infix:`${e}r-`,labelKey:"layout_editor.control.spacing.right"},{key:"b",infix:`${e}b-`,labelKey:"layout_editor.control.spacing.bottom"},{key:"l",infix:`${e}l-`,labelKey:"layout_editor.control.spacing.left"}]}function SD(e,t,n,o){const r={all:-1,sides:{}};if(typeof e!="string"||e.trim()==="")return r;const s=e.split(/\s+/).filter(a=>a.length>0);for(const a of s){const l=n.find(d=>a.startsWith(d.infix));if(l){r.sides[l.key]=o.indexOf(a.slice(l.infix.length));continue}if(a.startsWith(`${t}x-`)){const d=o.indexOf(a.slice(`${t}x-`.length));r.sides.l=d,r.sides.r=d;continue}if(a.startsWith(`${t}y-`)){const d=o.indexOf(a.slice(`${t}y-`.length));r.sides.t=d,r.sides.b=d;continue}a.startsWith(`${t}-`)&&(r.all=o.indexOf(a.slice(`${t}-`.length)))}return r}function CD({control:e,value:t,onChange:n,t:o}){const r=typeof e.spacingPrefix=="string"?e.spacingPrefix:"p",s=(Array.isArray(e.scale)?e.scale:[]).map(b=>String(b)),a=kD(r),l=SD(t,r,a,s),d=Object.values(l.sides).some(b=>b>=0),c=l.all>=0||d,[p,f]=u.useState(d);u.useEffect(()=>{d&&f(!0)},[d]);const y=Math.min(4,Math.max(0,s.length-1)),g=b=>{const x=[];for(const k of a){const w=b[k.key];w!==void 0&&w>=0&&x.push(`${k.infix}${s[w]}`)}n(x.length>0?x.join(" "):void 0)},m=b=>{n(b>=0?`${r}-${s[Math.max(0,Math.min(b,s.length-1))]}`:void 0)},h=()=>({...l.sides}),v=(b,x,k,w,_)=>{const S=k>=0;return i.jsxs("div",{style:AD,"data-testid":b,children:[i.jsxs("label",{style:{...Za,minWidth:64},children:[i.jsx("input",{type:"checkbox","data-testid":`${b}-enabled`,checked:S,onChange:E=>_(E.target.checked)}),i.jsx("span",{style:{fontSize:12,color:"#0f172a"},children:o(x)})]}),i.jsx("input",{type:"range","data-testid":`${b}-range`,min:0,max:Math.max(0,s.length-1),step:1,disabled:!S||s.length===0,value:k<0?0:k,onChange:E=>w(Number(E.target.value)),style:{flex:1}}),i.jsx("span",{"data-testid":`${b}-value`,style:{fontSize:12,color:S?"#0f172a":"#94a3b8",minWidth:28,textAlign:"right"},children:S?s[k]:"–"})]},b)};return i.jsxs("div",{className:"g7le-widget g7le-widget--spacing","data-testid":"g7le-widget-spacing",style:$D,children:[i.jsxs("div",{style:ED,children:[i.jsxs("div",{style:ID,"data-testid":"g7le-spacing-mode",children:[i.jsx("button",{type:"button","data-testid":"g7le-spacing-mode-all","data-active":p?"false":"true",onClick:()=>{f(!1),d&&n(void 0)},style:{...Sg,background:p?"#fff":"#2563eb",color:p?"#0f172a":"#fff",borderColor:p?"#cbd5e1":"#2563eb"},children:o("layout_editor.control.spacing.mode_all")}),i.jsx("button",{type:"button","data-testid":"g7le-spacing-mode-sides","data-active":p?"true":"false",onClick:()=>{if(f(!0),l.all>=0){const b={};for(const x of a)b[x.key]=l.all;g(b)}},style:{...Sg,background:p?"#2563eb":"#fff",color:p?"#fff":"#0f172a",borderColor:p?"#2563eb":"#cbd5e1"},children:o("layout_editor.control.spacing.mode_sides")})]}),i.jsx("span",{"data-testid":"g7le-spacing-value",style:{fontSize:11,color:"#64748b",minWidth:80,textAlign:"right"},children:c?String(t):o("layout_editor.control.default")})]}),p?i.jsx("div",{"data-testid":"g7le-spacing-sides",style:{display:"flex",flexDirection:"column",gap:4},children:a.map(b=>v(`g7le-spacing-side-${b.key}`,b.labelKey,l.sides[b.key]??-1,x=>{const k=h();k[b.key]=x,g(k)},x=>{const k=h();x?k[b.key]=l.sides[b.key]>=0?l.sides[b.key]:y:delete k[b.key],g(k)}))}):i.jsxs("div",{style:Cg,children:[i.jsxs("label",{style:{...Za,minWidth:64},children:[i.jsx("input",{type:"checkbox","data-testid":"g7le-spacing-all-enabled",checked:l.all>=0,onChange:b=>m(b.target.checked?y:-1)}),i.jsx("span",{style:{fontSize:12,color:"#64748b"},children:o("layout_editor.control.spacing.all")})]}),i.jsx("input",{type:"range","data-testid":"g7le-spacing-all-range",min:0,max:Math.max(0,s.length-1),step:1,disabled:l.all<0||s.length===0,value:l.all<0?0:l.all,onChange:b=>m(Number(b.target.value)),style:{flex:1}}),i.jsx("span",{"data-testid":"g7le-spacing-all-value",style:{fontSize:12,color:l.all>=0?"#0f172a":"#94a3b8",minWidth:28,textAlign:"right"},children:l.all>=0?s[l.all]:"–"})]})]})}const $D={display:"flex",flexDirection:"column",gap:6,width:"100%"},ED={display:"flex",alignItems:"center",justifyContent:"space-between",gap:8},ID={display:"inline-flex",gap:0,border:"1px solid #cbd5e1",borderRadius:6,overflow:"hidden"},Sg={padding:"3px 10px",fontSize:11,border:"none",borderRight:"1px solid #e2e8f0",cursor:"pointer"},AD={display:"flex",alignItems:"center",gap:8,width:"100%"},TD={display:"inline-flex",gap:0,border:"1px solid #cbd5e1",borderRadius:6,overflow:"hidden"},DD={display:"flex",flexDirection:"column",gap:6,width:"100%"},ND={display:"flex",gap:6,alignItems:"center"},zD={flex:1,padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,minWidth:80},MD={padding:"4px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#64748b",cursor:"pointer",whiteSpace:"nowrap"},LD={display:"flex",flexWrap:"wrap",gap:4},PD={fontSize:11,color:"#94a3b8",fontStyle:"italic",padding:"2px 0"},OD={padding:"3px 9px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:999,background:"#fff",cursor:"pointer"},BD={padding:"4px 10px",fontSize:12,border:"none",borderRight:"1px solid #e2e8f0",cursor:"pointer"},Cg={display:"flex",alignItems:"center",gap:8,width:"100%"},Za={display:"inline-flex",alignItems:"center",gap:4},WD={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",minWidth:120},jD={position:"relative",width:38,height:20,borderRadius:10,border:"none",cursor:"pointer",padding:2,display:"inline-flex",alignItems:"center",transition:"background 120ms ease"},HD={display:"block",width:16,height:16,borderRadius:8,background:"#fff",transition:"transform 120ms ease"},FD=["#0f172a","#334155","#64748b","#94a3b8","#e2e8f0","#ffffff","#dc2626","#ea580c","#d97706","#16a34a","#0891b2","#2563eb","#7c3aed","#db2777"],$g=/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;function KD(e){return Array.isArray(e.options)?e.options.filter(t=>typeof t.value=="string").map(t=>({value:t.value,swatch:typeof t.swatch=="string"?t.swatch:void 0,label:typeof t.label=="string"?t.label:void 0})):[]}function VD({control:e,value:t,onChange:n,t:o,freeValueDisabled:r}){const s=KD(e),a=s.length>0,l=typeof t=="string"&&s.some(g=>g.value===t)?t:"",d=typeof t=="string"&&$g.test(t)?t:"",[c,p]=u.useState(d);u.useEffect(()=>{p(d)},[d]);const f=g=>{const m=g.trim();if(m===""){n(void 0);return}const h=m.startsWith("#")?m:`#${m}`;$g.test(h)&&n(h)},y=FD;return i.jsxs("div",{className:"g7le-widget g7le-widget--color","data-testid":"g7le-widget-color",style:UD,children:[r?i.jsx("div",{"data-testid":"g7le-color-free-disabled",style:ZD,children:o("layout_editor.property_modal.dark_preset_only")}):i.jsxs("div",{style:qD,children:[i.jsxs("label",{style:GD,children:[i.jsx("span",{"data-testid":"g7le-color-swatch",style:{...YD,background:d||"transparent",backgroundImage:d?void 0:"linear-gradient(45deg,#e2e8f0 25%,transparent 25%,transparent 75%,#e2e8f0 75%),linear-gradient(45deg,#e2e8f0 25%,#fff 25%,#fff 75%,#e2e8f0 75%)",backgroundSize:"8px 8px",backgroundPosition:"0 0, 4px 4px"}}),i.jsx("input",{type:"color","data-testid":"g7le-color-native",value:d||"#000000",onChange:g=>n(g.target.value),style:{width:0,height:0,opacity:0,position:"absolute"}})]}),i.jsx("input",{type:"text","data-testid":"g7le-color-hex",value:c,placeholder:o("layout_editor.control.color_hex_placeholder"),onChange:g=>p(g.target.value),onBlur:g=>f(g.target.value),onKeyDown:g=>{g.key==="Enter"&&f(g.target.value)},style:XD}),i.jsx("button",{type:"button","data-testid":"g7le-color-clear",onClick:()=>n(void 0),style:JD,children:o("layout_editor.control.default")})]}),a?i.jsx("div",{style:Eg,"data-testid":"g7le-color-token-presets",children:s.map(g=>{const m=l===g.value,h=g.label?g.label.startsWith("$t:")?o(g.label.slice(3)):g.label:g.value;return i.jsx("button",{type:"button","aria-label":h,title:h,"data-testid":`g7le-color-token-${g.value}`,"data-active":m?"true":"false",onClick:()=>n(m?void 0:g.value),style:{...Ig,background:g.swatch||"#e2e8f0",outline:m?"2px solid #2563eb":"none"}},g.value)})}):!r&&i.jsx("div",{style:Eg,"data-testid":"g7le-color-presets",children:y.map(g=>i.jsx("button",{type:"button","aria-label":g,"data-testid":`g7le-color-preset-${g}`,onClick:()=>n(g),style:{...Ig,background:g,outline:d.toLowerCase()===g.toLowerCase()?"2px solid #2563eb":"none"}},g))})]})}const UD={display:"flex",flexDirection:"column",gap:8},qD={display:"flex",alignItems:"center",gap:6},GD={position:"relative",display:"inline-flex",cursor:"pointer"},YD={width:24,height:24,borderRadius:4,border:"1px solid #cbd5e1",display:"inline-block"},XD={flex:1,padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,fontFamily:"monospace"},JD={padding:"4px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",cursor:"pointer",color:"#64748b"},Eg={display:"grid",gridTemplateColumns:"repeat(7, 1fr)",gap:4},Ig={width:"100%",height:18,borderRadius:4,border:"1px solid #cbd5e1",cursor:"pointer",padding:0},ZD={fontSize:11,color:"#94a3b8",fontStyle:"italic",padding:"2px 0"},Xo=[{value:"fill",labelKey:"layout_editor.control.background_image.mode_fill",size:"cover",repeat:"no-repeat"},{value:"fit",labelKey:"layout_editor.control.background_image.mode_fit",size:"contain",repeat:"no-repeat"},{value:"tile",labelKey:"layout_editor.control.background_image.mode_tile",size:"auto",repeat:"repeat"}];function QD(e){return e&&typeof e=="object"&&!Array.isArray(e)?e:{}}function Ag(e){return Xo.find(n=>n.size===e.size&&n.repeat===e.repeat)?.value??"fill"}function RD({value:e,onChange:t,t:n}){const o=QD(e),{state:r}=Je(),s=go(),[a,l]=u.useState(!1),[d,c]=u.useState(null),[p,f]=u.useState(o.url??""),[y,g]=u.useState([]),m=u.useRef(null),h=r.templateIdentifier,v=r.selectedRoute?.layoutName??"";u.useEffect(()=>{f(o.url??"")},[o.url]);const b=u.useCallback(async()=>{if(!h||!v){g([]);return}const E=await Al(h,v);E.ok&&g(E.data)},[h,v]);u.useEffect(()=>{b()},[b]);const x=E=>{if(!E){t(void 0);return}const C=Xo.find(N=>N.value===Ag(o))??Xo[0];t({url:E,size:o.size??C.size,repeat:o.repeat??C.repeat,position:o.position??"center"})},k=E=>{const C=Xo.find(N=>N.value===E);!C||!o.url||t({url:o.url,size:C.size,repeat:C.repeat,position:o.position??"center"})},w=async E=>{const C=E.target.files?.[0];if(!C)return;l(!0),c(null);const N=await Tl(h,v,C);N.ok?(x(N.data.url),b()):c(n("layout_editor.control.background_image.upload_failed")),l(!1),m.current&&(m.current.value="")},_=async E=>{(await Dl(E.id)).ok&&g(N=>N.filter($=>$.id!==E.id))},S=()=>{const E=s.open({ariaLabel:n("layout_editor.attachment_manager.title"),width:720,maxHeightRatio:.82,content:u.createElement(Nl,{templateIdentifier:h,layoutName:v,t:n,onSelect:C=>{x(C),s.close(E)},onChanged:()=>{b()},onClose:()=>s.close(E)})})};return i.jsxs("div",{className:"g7le-widget g7le-widget--image","data-testid":"g7le-widget-image",style:eN,children:[o.url&&i.jsx("div",{"data-testid":"g7le-image-preview",style:{...tN,backgroundImage:`url(${o.url})`,backgroundSize:o.size??"cover",backgroundRepeat:o.repeat??"no-repeat",backgroundPosition:o.position??"center"}}),i.jsxs("div",{style:nN,children:[i.jsxs("label",{style:oN,children:[a?"…":n("layout_editor.control.background_image.upload"),i.jsx("input",{ref:m,type:"file",accept:"image/*","data-testid":"g7le-image-file",onChange:w,disabled:a,style:{display:"none"}})]}),i.jsx("button",{type:"button","data-testid":"g7le-image-clear",onClick:()=>t(void 0),style:rN,children:n("layout_editor.control.background_image.clear")})]}),i.jsx("input",{type:"text","data-testid":"g7le-image-url",placeholder:"https://… / URL",value:p,onChange:E=>f(E.target.value),onBlur:E=>x(E.target.value.trim()||void 0),style:iN}),i.jsx("div",{style:sN,"data-testid":"g7le-image-modes",children:Xo.map(E=>{const C=Ag(o)===E.value;return i.jsx("button",{type:"button","data-testid":`g7le-image-mode-${E.value}`,"data-active":C?"true":"false",disabled:!o.url,onClick:()=>k(E.value),style:{...aN,background:C&&o.url?"#2563eb":"#fff",color:C&&o.url?"#fff":"#0f172a"},children:n(E.labelKey)},E.value)})}),i.jsxs("div",{style:lN,"data-testid":"g7le-image-gallery",children:[i.jsxs("div",{style:dN,children:[i.jsx("span",{style:{fontSize:11,color:"#64748b"},children:n("layout_editor.attachment_manager.recent")}),i.jsxs("button",{type:"button","data-testid":"g7le-image-manage",onClick:S,style:cN,children:["🖼 ",n("layout_editor.attachment_manager.manage_link")]})]}),y.length>0?i.jsx("div",{style:uN,"data-testid":"g7le-image-thumbs",children:y.slice(0,8).map(E=>i.jsxs("div",{style:pN,"data-testid":`g7le-image-thumb-${E.id}`,children:[i.jsx("button",{type:"button",title:E.original_name,onClick:()=>x(E.url),style:{...fN,backgroundImage:`url(${E.url})`},"data-testid":`g7le-image-thumb-use-${E.id}`}),i.jsx("button",{type:"button",title:n("layout_editor.attachment_manager.delete"),onClick:()=>_(E),style:gN,"data-testid":`g7le-image-thumb-del-${E.id}`,children:"✕"})]},E.id))}):i.jsx("span",{style:{fontSize:11,color:"#94a3b8"},children:n("layout_editor.attachment_manager.empty")})]}),d&&i.jsx("span",{"data-testid":"g7le-image-error",style:{fontSize:11,color:"#dc2626"},children:d})]})}const eN={display:"flex",flexDirection:"column",gap:6},tN={width:"100%",height:80,borderRadius:6,border:"1px solid #cbd5e1"},nN={display:"flex",gap:6},oN={padding:"5px 10px",fontSize:12,border:"1px solid #2563eb",borderRadius:6,background:"#fff",color:"#2563eb",cursor:"pointer",display:"inline-flex",alignItems:"center"},rN={padding:"5px 10px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#64748b",cursor:"pointer"},iN={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6},sN={display:"inline-flex",border:"1px solid #cbd5e1",borderRadius:6,overflow:"hidden"},aN={padding:"4px 10px",fontSize:12,border:"none",borderRight:"1px solid #e2e8f0",cursor:"pointer"},lN={display:"flex",flexDirection:"column",gap:4,border:"1px solid #e2e8f0",borderRadius:6,padding:6,marginTop:2},dN={display:"flex",alignItems:"center",justifyContent:"space-between"},cN={fontSize:11,color:"#2563eb",background:"transparent",border:"none",cursor:"pointer",padding:0},uN={display:"flex",gap:6,flexWrap:"wrap"},pN={position:"relative",width:44,height:44},fN={width:44,height:44,borderRadius:6,border:"1px solid #cbd5e1",backgroundSize:"cover",backgroundPosition:"center",cursor:"pointer",padding:0},gN={position:"absolute",top:-6,right:-6,width:16,height:16,borderRadius:"50%",border:"none",background:"#dc2626",color:"#fff",fontSize:9,lineHeight:"16px",cursor:"pointer",padding:0};function hN(e){return Array.isArray(e)&&e.every(t=>t!==null&&typeof t=="object"&&!Array.isArray(t))}function yN(e){return typeof e=="string"&&/\{\{.*\}\}/.test(e)}function bN({value:e,onChange:t,t:n,bindingCandidates:o}){if(yN(e))return i.jsx("div",{className:"g7le-widget g7le-widget--options-list","data-testid":"g7le-widget-options-list-bound",style:CN,children:n("layout_editor.list_editor.bound_degraded")});const r=hN(e)?e:[],s=y=>{t(y.length===0?void 0:y)},a=(y,g)=>{const m=r.map((h,v)=>v===y?{...h,...g}:h);s(m)},l=y=>{s(r.filter((g,m)=>m!==y))},d=(y,g)=>{const m=y+g;if(m<0||m>=r.length)return;const h=[...r];[h[y],h[m]]=[h[m],h[y]],s(h)},c=()=>{s([...r,{value:"",label:""}])},p=y=>typeof y.label=="string"?y.label:y.label==null?"":String(y.label),f=y=>y.value===void 0||y.value===null?"":String(y.value);return i.jsxs("div",{className:"g7le-widget g7le-widget--options-list","data-testid":"g7le-widget-options-list",style:mN,children:[r.length===0&&i.jsx("div",{"data-testid":"g7le-options-empty",style:SN,children:n("layout_editor.list_editor.empty")}),r.map((y,g)=>i.jsxs("div",{"data-testid":`g7le-options-row-${g}`,style:vN,children:[i.jsxs("div",{style:Tg,children:[i.jsx("span",{style:Dg,children:n("layout_editor.list_editor.option_value")}),i.jsx("input",{type:"text","data-testid":`g7le-options-value-${g}`,value:f(y),placeholder:n("layout_editor.list_editor.option_value"),onChange:m=>a(g,{value:m.target.value}),style:xN})]}),i.jsxs("div",{style:Tg,children:[i.jsx("span",{style:Dg,children:n("layout_editor.list_editor.option_label")}),i.jsx("div",{"data-testid":`g7le-options-label-${g}`,style:{flex:1,minWidth:0},children:i.jsx(vt,{value:p(y),onChange:m=>a(g,{label:m??""}),t:n,placeholder:n("layout_editor.list_editor.option_label"),testidPrefix:`g7le-options-label-i18n-${g}`,candidates:o,enableExpressionTree:!0,expressionTreeCollapsible:!0})})]}),i.jsxs("div",{style:_N,children:[i.jsx("button",{type:"button","data-testid":`g7le-options-up-${g}`,title:n("layout_editor.list_editor.move_up"),disabled:g===0,onClick:()=>d(g,-1),style:Ng,children:"↑"}),i.jsx("button",{type:"button","data-testid":`g7le-options-down-${g}`,title:n("layout_editor.list_editor.move_down"),disabled:g===r.length-1,onClick:()=>d(g,1),style:Ng,children:"↓"}),i.jsx("button",{type:"button","data-testid":`g7le-options-remove-${g}`,title:n("layout_editor.list_editor.remove"),onClick:()=>l(g),style:wN,children:"✕"})]})]},g)),i.jsx("button",{type:"button","data-testid":"g7le-options-add",onClick:c,style:kN,children:n("layout_editor.list_editor.add")})]})}const mN={display:"flex",flexDirection:"column",gap:8,width:"100%"},vN={display:"flex",flexDirection:"column",gap:4,width:"100%",minWidth:0,paddingBottom:6,borderBottom:"1px solid #f1f5f9"},Tg={display:"flex",gap:6,alignItems:"center",width:"100%",minWidth:0},Dg={flexShrink:0,width:36,fontSize:11,color:"#94a3b8",textAlign:"right"},_N={display:"flex",gap:4,alignItems:"center",justifyContent:"flex-end"},xN={flex:1,minWidth:0,padding:"4px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6},Ng={padding:"2px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},wN={padding:"2px 6px",fontSize:12,border:"1px solid #fecaca",borderRadius:6,background:"#fff",color:"#dc2626",cursor:"pointer"},kN={padding:"4px 8px",fontSize:12,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",alignSelf:"flex-start"},SN={fontSize:11,color:"#94a3b8",fontStyle:"italic"},CN={fontSize:11,color:"#b45309",fontStyle:"italic",padding:"4px 2px"},zg=/[^A-Za-z0-9\-_:.]/g;function $N(e){return typeof e=="string"&&Ki(e)}function Mg(e){return Ki(e)?En(e).map(t=>t.kind==="binding"?t.raw:t.raw.replace(zg,"")).join(""):e.replace(zg,"")}const EN={width:"100%",padding:"6px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:4,fontFamily:"monospace"},IN={marginTop:4,fontSize:11,color:"#94a3b8"},AN={display:"flex",gap:6,alignItems:"center"},TN={flexShrink:0,padding:"5px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:4,background:"#f8fafc",cursor:"pointer",whiteSpace:"nowrap"};function DN({value:e,onChange:t,t:n,bindingCandidates:o}){const r=e==null?"":String(e),s=$N(e),a=!!o&&o.length>0,[l,d]=u.useState(!1),c=p=>{const f=Mg(p);t(f===""?void 0:f)};return s||l?i.jsxs("div",{"data-testid":"g7le-core-id-chip",children:[i.jsx(hs,{value:r,onChange:c,t:n,candidates:o,onDone:()=>d(!1),testidPrefix:"g7le-core-id-chip-input"}),i.jsx("div",{style:IN,children:n("layout_editor.core_props.id.binding_hint")})]}):i.jsx("div",{children:i.jsxs("div",{style:AN,children:[i.jsx("input",{type:"text",className:"g7le-widget g7le-widget--core-id","data-testid":"g7le-widget-core-id",value:r,onChange:p=>{const f=Mg(p.target.value);t(f===""?void 0:f)},spellCheck:!1,autoComplete:"off",style:EN}),a&&i.jsxs("button",{type:"button","data-testid":"g7le-core-id-add-data",onClick:()=>d(!0),style:TN,title:n("layout_editor.core_props.id.add_data"),children:["🔗 ",n("layout_editor.core_props.id.add_data")]})]})})}const NN=/[^A-Za-z0-9.\-_]/g;function zN(e){return typeof e=="string"&&/\{\{[\s\S]*?\}\}/.test(e)}function MN(e){return e.replace(NN,"")}const Lg={width:"100%",padding:"6px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:4,fontFamily:"monospace",boxSizing:"border-box"},LN={...Lg,background:"#f1f5f9",color:"#64748b",cursor:"not-allowed"},Pg={marginTop:4,fontSize:11,color:"#94a3b8"};function PN({value:e,onChange:t,t:n}){const o=e==null?"":String(e);return zN(e)?i.jsxs("div",{"data-testid":"g7le-core-datakey-bound",children:[i.jsx("input",{type:"text",className:"g7le-widget g7le-widget--core-datakey-bound","data-testid":"g7le-widget-core-datakey",value:o,readOnly:!0,disabled:!0,style:LN}),i.jsx("div",{style:Pg,children:n("layout_editor.core_props.dataKey.bound_degraded")})]}):i.jsxs("div",{children:[i.jsx("input",{type:"text",className:"g7le-widget g7le-widget--core-datakey","data-testid":"g7le-widget-core-datakey",value:o,placeholder:n("layout_editor.core_props.dataKey.placeholder"),onChange:s=>{const a=MN(s.target.value);t(a===""?void 0:a)},spellCheck:!1,autoComplete:"off",style:Lg}),i.jsx("div",{style:Pg,children:n("layout_editor.core_props.dataKey.hint")})]})}let Og=!1;function ON(){Og||(Xe("segmented",mD),Xe("slider",vD),Xe("select",_D),Xe("dimension",wD),Xe("spacing",CD),Xe("toggle",xD),Xe("color",VD),Xe("image",RD),Xe("tag-input",gs),Xe("page-picker",xw),Xe("datasource-picker",Mc),Xe("binding-picker",Mc),Xe("binding-list",Sw),Xe("state-key-picker",Cs),Xe("modal-picker",ww),Xe("i18n-text",kw),Xe("text",Cw),Xe("options-list",bN),Xe("core-id",DN),Xe("core-datakey",PN),Xe("component-target-picker",DT),Og=!0)}function Jo(e){return Array.isArray(e.children)?e.children:[]}function Zo(e){if(typeof e.text=="string")return[];let t=null;const n=(o,r)=>{const s=Jo(o);for(let a=0;ad===o?Qo(l,r,n):l);return{...e,children:a}}function Bg(e,t){const n=r=>typeof r.props?.[t]=="string";if(n(e))return[];const o=(r,s)=>{const a=Jo(r);for(let l=0;lc===r?Wg(d,s,n,o):d);return{...e,children:l}}function BN(e){return Array.isArray(e.children)?e.children:[]}function WN(e){const t=e?.itemFields;if(!Array.isArray(t))return[{kind:"text"}];const n=[];for(const o of t){if(!o||typeof o!="object")continue;const r=o;r.kind==="text"?n.push({kind:"text",label:typeof r.label=="string"?r.label:void 0}):r.kind==="prop"&&typeof r.prop=="string"&&r.prop.length>0&&n.push({kind:"prop",prop:r.prop,label:typeof r.label=="string"?r.label:void 0})}return n.length>0?n:[{kind:"text"}]}function Qa(e,t){if(!(typeof e!="string"||e.length===0))return e.startsWith("$t:")?t(e.slice(3)):e}function jg({node:e,params:t,spec:n,manifest:o,t:r,onPatchNode:s,templateIdentifier:a,candidates:l}){const d=typeof t?.childComponent=="string"?t.childComponent:null,c=t?.childTemplate&&typeof t.childTemplate=="object"?t.childTemplate:null,p=WN(t),f=Qa(typeof t?.childLabel=="string"?t.childLabel:void 0,r)??d??"",y=BN(e),g=u.useCallback(_=>{s({...e,children:_})},[e,s]),m=u.useCallback(()=>{if(c)return JSON.parse(JSON.stringify(c));if(!d)return null;const _=n?.componentPalette?.entries?.[d];if(_?.defaultNode&&typeof _.defaultNode=="object")return JSON.parse(JSON.stringify(_.defaultNode));const S=o?.components??{};let E=null;for(const C of Object.values(S))if(Array.isArray(C)){const N=C.find($=>$.name===d);if(N){E=N;break}}return E?_f(E):{type:"basic",name:d}},[c,d,n,o]),h=u.useCallback(()=>{const _=m();if(_){if(_.text==="")_.text=r("layout_editor.list_editor.new_item");else{const S=Zo(_);if(S!==null&&S.length>0){const E=mi(_,S);if(E&&E.text===""){const C=Qo(_,S,r("layout_editor.list_editor.new_item"));g([...y,C]);return}}}g([...y,_])}},[m,g,y,r]),v=u.useCallback(_=>{g(y.filter((S,E)=>E!==_))},[g,y]),b=u.useCallback((_,S)=>{const E=_+S;if(E<0||E>=y.length)return;const C=[...y];[C[_],C[E]]=[C[E],C[_]],g(C)},[g,y]),x=u.useCallback((_,S)=>{if(typeof S!="string"||S.length===0)return;const E=y[_];if(!E)return;const C=Zo(E);if(C===null)return;const N=y.map(($,A)=>A===_?Qo($,C,S):$);g(N)},[y,g]),k=u.useCallback((_,S,E)=>{if(typeof E!="string"||E.length===0)return;const C=y[_];if(!C)return;const N=Bg(C,S);if(N===null)return;const $=y.map((A,z)=>z===_?Wg(A,N,S,E):A);g($)},[y,g]);if(!d&&!c)return i.jsx("div",{className:"g7le-node-editor g7le-node-editor--children","data-testid":"g7le-children-editor-misconfigured",style:Kg,children:r("layout_editor.list_editor.no_child_component")});const w=p.length>1||p.some(_=>_.label);return i.jsxs("div",{className:"g7le-node-editor g7le-node-editor--children","data-testid":"g7le-children-editor",style:jN,children:[i.jsx("div",{style:HN,children:r("layout_editor.list_editor.items_title")}),y.length===0&&i.jsx("div",{"data-testid":"g7le-children-empty",style:Kg,children:r("layout_editor.list_editor.empty")}),y.map((_,S)=>{const E=[];for(const N of p)if(N.kind==="text"){const $=Zo(_),A=$!==null?mi(_,$):null;A&&typeof A.text=="string"&&E.push({key:"text",caption:Qa(N.label,r)??(w?r("layout_editor.list_editor.item_label"):void 0),value:A.text,onChange:z=>x(S,z),testid:`g7le-children-i18n-${S}`})}else if(N.kind==="prop"&&N.prop){const $=Bg(_,N.prop);if($!==null){const z=mi(_,$)?.props?.[N.prop];E.push({key:`prop-${N.prop}`,caption:Qa(N.label,r)??N.prop,value:typeof z=="string"?z:"",onChange:D=>k(S,N.prop,D),testid:`g7le-children-prop-${N.prop}-${S}`})}}const C=typeof _?.name=="string"?_.name:r("layout_editor.list_editor.item");return i.jsxs("div",{"data-testid":`g7le-children-row-${S}`,style:FN,children:[E.length>0?E.map(N=>i.jsx("div",{style:Hg,children:i.jsxs("div",{"data-testid":N.key==="text"?`g7le-children-text-${S}`:`g7le-children-field-${N.key}-${S}`,style:KN,children:[N.caption!==void 0&&i.jsx("div",{"data-testid":`g7le-children-caption-${N.key}-${S}`,style:VN,children:N.caption}),i.jsx(vt,{value:N.value,onChange:N.onChange,t:r,placeholder:r("layout_editor.list_editor.item_text"),testidPrefix:N.testid,candidates:l,enableExpressionTree:!0,expressionTreeCollapsible:!0})]})},N.key)):i.jsx("div",{style:Hg,children:i.jsx("span",{"data-testid":`g7le-children-label-${S}`,style:qN,children:C})}),i.jsxs("div",{style:UN,children:[i.jsx("button",{type:"button","data-testid":`g7le-children-up-${S}`,title:r("layout_editor.list_editor.move_up"),disabled:S===0,onClick:()=>b(S,-1),style:Fg,children:"↑"}),i.jsx("button",{type:"button","data-testid":`g7le-children-down-${S}`,title:r("layout_editor.list_editor.move_down"),disabled:S===y.length-1,onClick:()=>b(S,1),style:Fg,children:"↓"}),i.jsx("button",{type:"button","data-testid":`g7le-children-remove-${S}`,title:r("layout_editor.list_editor.remove"),onClick:()=>v(S),style:GN,children:"✕"})]})]},S)}),i.jsx("button",{type:"button","data-testid":"g7le-children-add",onClick:h,style:YN,children:r("layout_editor.list_editor.add_child",{component:f})})]})}const jN={display:"flex",flexDirection:"column",gap:6,width:"100%",marginBottom:12},HN={fontSize:12,fontWeight:700,color:"#0f172a",marginBottom:2},FN={display:"flex",flexDirection:"column",gap:4,width:"100%",minWidth:0,paddingBottom:6,borderBottom:"1px solid #f1f5f9"},Hg={display:"flex",gap:4,alignItems:"flex-start",width:"100%",minWidth:0},KN={flex:1,minWidth:0},VN={fontSize:11,color:"#64748b",marginBottom:2},UN={display:"flex",gap:4,alignItems:"center",justifyContent:"flex-end"},qN={flex:1,minWidth:0,padding:"4px 6px",fontSize:12,color:"#475569",fontStyle:"italic",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},Fg={padding:"2px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},GN={padding:"2px 6px",fontSize:12,border:"1px solid #fecaca",borderRadius:6,background:"#fff",color:"#dc2626",cursor:"pointer"},YN={padding:"4px 8px",fontSize:12,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",alignSelf:"flex-start"},Kg={fontSize:11,color:"#94a3b8",fontStyle:"italic"},uo={rowContainer:"Tbody",row:"Tr",cell:"Td",headerCell:"Th",colSpanProp:"colSpan",rowSpanProp:"rowSpan"};function ht(e){return{rowContainer:e?.rowContainer??uo.rowContainer,row:e?.row??uo.row,cell:e?.cell??uo.cell,headerCell:e?.headerCell??uo.headerCell,colSpanProp:e?.colSpanProp??uo.colSpanProp,rowSpanProp:e?.rowSpanProp??uo.rowSpanProp}}function On(e){return e?Array.isArray(e.children)?e.children:[]:[]}function Vg(e,t){const n=e.props?.[t],o=typeof n=="number"?n:typeof n=="string"?parseInt(n,10):NaN;return Number.isFinite(o)&&o>=1?Math.floor(o):1}function Be(e,t){const n=ht(t),o=[],r=On(e),s=[];r.forEach((f,y)=>{(On(f).some(h=>h.name===n.row)||f.name===n.rowContainer||f.name==="Thead"||f.name==="Tfoot")&&s.push({container:f,containerIndex:y,isHeader:f.name==="Thead"})}),s.length===0&&r.some(f=>f.name===n.row)&&s.push({container:e,containerIndex:-1,isHeader:!1});const a=[],l=[];let d=0,c=0;const p=f=>{for(;a.length<=f;)a.push([]);return a[f]};s.forEach((f,y)=>{On(f.container).filter(m=>m.name===n.row).forEach((m,h)=>{const v=[],b=p(d);let x=0;On(m).filter(w=>w.name===n.cell||w.name===n.headerCell).forEach((w,_)=>{for(;b[x];)x++;const S=Vg(w,n.colSpanProp),E=Vg(w,n.rowSpanProp),C={cell:w,sectionIndex:y,rowIndexInSection:h,cellIndexInRow:_,gridRow:d,gridCol:x,colSpan:S,rowSpan:E,isHeader:w.name===n.headerCell};v.push(C);for(let N=0;N=0?(a.push(s.containerIndex),l=On(e)[s.containerIndex]??e):l=e;let d=-1,c=-1,p=null;const f=On(l);for(let m=0;m=r.gridRow&&t=r.gridCol&&nr){s=!0;break}if(s)break}s||t.add(r)}const n=Array.from(t).sort((r,s)=>r-s),o=[];for(let r=0;re.rows.find(l=>(l.cells[0]?.gridRow??-1)===a)?.sectionIndex??e.rows[a]?.sectionIndex??0,o=new Set([0,t]);for(let a=1;aa){l=!0;break}if(l)break}l||o.add(a)}const r=Array.from(o).sort((a,l)=>a-l),s=[];for(let a=0;at>=a.start&&t=o.length?{ok:!1,reason:"boundary"}:o[r].sectionIndex!==o[s].sectionIndex?{ok:!1,reason:"section"}:{ok:!0}}function _i(e,t,n){const o=Ug(e),r=o.findIndex(a=>t>=a.start&&t=o.length?{ok:!1,reason:"boundary"}:{ok:!0}}function Ge(e){return e?Array.isArray(e.children)?e.children:[]:[]}function bn(e,t,n){const o={...e.props??{}};n<=1?delete o[t]:o[t]=n;const r={...e,props:o};return Object.keys(o).length===0&&delete r.props,r}function xi(e,t,n=!1){const o={type:"basic",name:n?e.headerCell:e.cell},r=t?.props?.className;return typeof r=="string"&&r&&(o.props={className:r}),o.text="",o}function Ro(e,t){return e.name===t.cell||e.name===t.headerCell}function nt(e,t,n,o){const r=t.sections[n];if(!r)return e;if(r.containerIndex===-1)return o(e);const s=Ge(e).map((a,l)=>l===r.containerIndex?o(a):a);return{...e,children:s}}function yt(e,t,n,o){let r=-1;const s=Ge(e).map(a=>a.name===t&&(r++,r===n)?o(a):a);return{...e,children:s}}function wi(e,t,n){let o=-1;const r=Ge(e);for(let s=0;s0?nt(e,r,r.sections.length-1,x=>({...x,children:[...Ge(x),b]})):{...e,children:[...Ge(e),b]}}const s=Math.max(-1,Math.min(n,r.rows.length-1)),a=s<0?r.rows[0]:r.rows[s],l=a.cells[0]?.gridRow??s,d=s<0?0:l+1,c=a.sectionIndex,p=s<0?0:a.rowIndexInSection+1,f=[],y=[];let g=0;for(;g0?Ue(r,d-1,g):null;b&&b.gridRow+b.rowSpan>d&&b.rowSpan>1?(y.push(b),g+=b.colSpan):(f.push(xi(o)),g+=1)}let m=e;for(const b of y)m=nt(m,r,b.sectionIndex,x=>yt(x,o.row,b.rowIndexInSection,k=>({...k,children:Ge(k).map((w,_)=>_===b.cellIndexInRow?bn(w,o.rowSpanProp,b.rowSpan+1):w)})));const h={type:"basic",name:o.row,children:f},v=Be(m,t);return m=nt(m,v,c,b=>{const x=Ge(b);let k=0,w=x.length;for(let _=0;_=r.rows.length||r.rows.length<=1)return e;const s=r.rows[n],a=s.cells[0]?.gridRow??n;let l=e;const d=new Set;for(let p=0;pyt(y,o.row,f.rowIndexInSection,g=>({...g,children:Ge(g).map((m,h)=>h===f.cellIndexInRow?bn(m,o.rowSpanProp,f.rowSpan-1):m)})))),p+=f?f.colSpan:1}const c=Be(l,t);return l=nt(l,c,s.sectionIndex,p=>{let f=-1;const y=Ge(p).filter(g=>g.name===o.row?(f++,f!==s.rowIndexInSection):!0);return{...p,children:y}}),l}function Yg(e,t,n,o){const r=ht(t),s=Be(e,t);if(!vi(s,n,o).ok)return e;const a=qg(s),l=a.findIndex(h=>n>=h.start&&ns.rows.filter(v=>{const b=v.cells[0]?.gridRow??-1;return v.sectionIndex===f&&b>=h.start&&bv.rowIndexInSection),g=y(c),m=y(p);return g.length===0||m.length===0?e:nt(e,s,f,h=>{const v=[...Ge(h)],b=[];v.forEach((M,B)=>{M.name===r.row&&b.push(B)});const x=g.map(M=>b[M]).filter(M=>M!==void 0),k=m.map(M=>b[M]).filter(M=>M!==void 0);if(x.length===0||k.length===0)return h;const w=x.map(M=>v[M]),_=k.map(M=>v[M]),S=Math.min(...x)n>=m.start&&nw.gridCol>=f.start&&w.gridColw.gridCol>=y.start&&w.gridColw.cellIndexInRow),x=v.map(w=>w.cellIndexInRow),k=Be(g,t);g=nt(g,k,m.sectionIndex,w=>yt(w,r.row,m.rowIndexInSection,_=>{const S=[...Ge(_)],E=b.map(M=>wi(_,r,M)).filter(M=>M>=0),C=x.map(M=>wi(_,r,M)).filter(M=>M>=0);if(E.length===0||C.length===0)return _;const N=E.map(M=>S[M]),$=C.map(M=>S[M]),A=Math.min(...E,...C),z=Math.max(...E,...C),D=[...$,...N],O=[...S.slice(0,A),...D,...S.slice(z+1)];return{..._,children:O}}))}return g}function Jg(e,t,n){const o=ht(t),r=Be(e,t);if(r.rows.length===0||r.colCount===0)return el(e,t,-1);const s=Math.max(0,Math.min(n+1,r.colCount));let a=e;const l=new Set;for(const d of r.rows){const c=d.cells[0]?.gridRow??0,p=s>0?Ue(r,c,s-1):null,f=s1){if(!l.has(p.cell)){l.add(p.cell);const h=Be(a,t);a=nt(a,h,p.sectionIndex,v=>yt(v,o.row,p.rowIndexInSection,b=>({...b,children:Ge(b).map((x,k)=>k===p.cellIndexInRow?bn(x,o.colSpanProp,p.colSpan+1):x)})))}continue}if(p&&p.gridRow0&&d.cells.every(h=>h.isHeader),m=Be(a,t);a=nt(a,m,d.sectionIndex,h=>yt(h,o.row,d.rowIndexInSection,v=>{const b=[...Ge(v)];let x;if(y<0)x=b.findIndex(k=>Ro(k,o)),x<0&&(x=b.length);else{const k=wi(v,o,y);x=k<0?b.length:k+1}return b.splice(x,0,xi(o,void 0,g)),{...v,children:b}}))}return a}function Zg(e,t,n){const o=ht(t),r=Be(e,t);if(n<0||n>=r.colCount||r.colCount<=1)return e;let s=e;const a=new Set;for(const l of r.rows){const d=l.cells[0]?.gridRow??0,c=Ue(r,d,n);if(!(!c||a.has(c.cell)||c.gridRow1){const p=Be(s,t);s=nt(s,p,c.sectionIndex,f=>yt(f,o.row,c.rowIndexInSection,y=>({...y,children:Ge(y).map((g,m)=>m===c.cellIndexInRow?bn(g,o.colSpanProp,c.colSpan-1):g)})))}else{const p=Be(s,t);s=nt(s,p,c.sectionIndex,f=>yt(f,o.row,c.rowIndexInSection,y=>{let g=-1;const m=Ge(y).filter(h=>Ro(h,o)?(g++,g!==c.cellIndexInRow):!0);return{...y,children:m}}))}}return s}function tl(e,t,n,o,r,s){const a=ht(t),l=Be(e,t),d=Math.min(n,r),c=Math.max(n,r),p=Math.min(o,s),f=Math.max(o,s);if(d===c&&p===f)return{table:e,ok:!1,reason:"single"};if(!l.valid)return{table:e,ok:!1,reason:"invalid-grid"};const y=new Set;for(let b=d;b<=c;b++)for(let x=p;x<=f;x++){const k=Ue(l,b,x);if(!k)return{table:e,ok:!1,reason:"hole"};if(k.gridRowc||k.gridCol+k.colSpan-1>f)return{table:e,ok:!1,reason:"not-rectangular"};y.add(k)}const g=Ue(l,d,p),m=Array.from(y).filter(b=>b!==g);let h=nt(e,l,g.sectionIndex,b=>yt(b,a.row,g.rowIndexInSection,x=>({...x,children:Ge(x).map((k,w)=>{if(w!==g.cellIndexInRow)return k;let _=bn(k,a.colSpanProp,f-p+1);return _=bn(_,a.rowSpanProp,c-d+1),_})})));const v=new Map;for(const b of m){const x=`${b.sectionIndex}:${b.rowIndexInSection}`,k=v.get(x)??[];k.push(b),v.set(x,k)}for(const[b,x]of v){const k=parseInt(b.split(":")[0],10),w=x[0].rowIndexInSection,_=new Set(x.map(E=>E.cellIndexInRow)),S=Be(h,t);h=nt(h,S,k,E=>yt(E,a.row,w,C=>{let N=-1;const $=Ge(C).filter(A=>Ro(A,a)?(N++,!_.has(N)):!0);return{...C,children:$}}))}return{table:h,ok:!0}}function Qg(e,t,n,o){const r=ht(t),s=Be(e,t),a=Ue(s,n,o);if(!a||a.colSpan<=1&&a.rowSpan<=1)return e;const l=a.gridRow,d=a.gridCol,c=a.gridRow+a.rowSpan-1,p=a.gridCol+a.colSpan-1,f=a.isHeader,y=typeof a.cell.props?.className=="string"?a.cell.props.className:void 0;let g=nt(e,s,a.sectionIndex,m=>yt(m,r.row,a.rowIndexInSection,h=>({...h,children:Ge(h).map((v,b)=>{if(b!==a.cellIndexInRow)return v;let x=bn(v,r.colSpanProp,1);return x=bn(x,r.rowSpanProp,1),x})})));for(let m=l;m<=c;m++)for(let h=d;h<=p;h++){if(m===l&&h===d)continue;const v=Be(g,t),b=v.rows.find(w=>w.cells.some(_=>_.gridRow===m))??v.rows[m];if(!b)continue;let x=-1;for(const w of b.cells)w.gridRow===m&&w.gridColyt(w,r.row,b.rowIndexInSection,_=>{const S=[...Ge(_)];let E;if(x<0)E=S.findIndex(C=>Ro(C,r)),E<0&&(E=S.length);else{const C=wi(_,r,x);E=C<0?S.length:C+1}return S.splice(E,0,xi(r,k,f)),{..._,children:S}}))}return g}function ki(e,t,n,o,r){const s=ht(t),a=Be(e,t),l=Ue(a,n,o);return l?nt(e,a,l.sectionIndex,d=>yt(d,s.row,l.rowIndexInSection,c=>({...c,children:Ge(c).map((p,f)=>{if(f!==l.cellIndexInRow)return p;const y={...p.props??{}};r.trim()===""?delete y.className:y.className=r;const g={...p,props:y};return Object.keys(y).length===0&&delete g.props,g})}))):e}function Jt(e,t,n,o,r,s){const a=ht(t),l=Be(e,t),d=Ue(l,n,o);return d?nt(e,l,d.sectionIndex,c=>yt(c,a.row,d.rowIndexInSection,p=>({...p,children:Ge(p).map((f,y)=>{if(y!==d.cellIndexInRow)return f;const g={...f.props??{}},m={...g.style??{}};!s||s.trim()===""?delete m[r]:m[r]=s,Object.keys(m).length===0?delete g.style:g.style=m;const h={...f,props:g};return Object.keys(g).length===0&&delete h.props,h})}))):e}function po(e,t,n,o,r,s,a){const l=ht(t),d=Be(e,t),c=Ue(d,n,o);if(!c)return e;const p=a==="dark";return nt(e,d,c.sectionIndex,f=>yt(f,l.row,c.rowIndexInSection,y=>({...y,children:Ge(y).map((g,m)=>{if(m!==c.cellIndexInRow)return g;const h={...g.props??{}},v=typeof h.className=="string"?h.className:"",b=oC(v,s,r,p);b===""?delete h.className:h.className=b;const x={...g,props:h};return Object.keys(h).length===0&&delete x.props,x})})))}function Rg(e,t,n,o,r){const s=ht(t),a=Be(e,t),l=Ue(a,n,o);return l?nt(e,a,l.sectionIndex,d=>yt(d,s.row,l.rowIndexInSection,c=>({...c,children:Ge(c).map((p,f)=>f===l.cellIndexInRow?{...p,text:r}:p)}))):e}function eh(e,t,n,o,r){const s=ht(t),a=Be(e,t),l=Ue(a,n,o);return l?nt(e,a,l.sectionIndex,d=>yt(d,s.row,l.rowIndexInSection,c=>({...c,children:Ge(c).map((p,f)=>f===l.cellIndexInRow?r:p)}))):e}const th=/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;function XN(e){return e.split(/\s+/).filter(Boolean)}function nh(e,t){for(const n of t)if(e===n||/^-?\d+$/.test(e.slice(n.length+1))&&e.startsWith(n+"-"))return!0;return!1}function oh({className:e,catalog:t,t:n,onChange:o,colorScheme:r="light",onPresetToken:s,colorStyle:a,onColorStyle:l,disabled:d}){const c=t?.sides??[],p=t?.widths??[],f=t?.colors??[],y=r==="dark",g=u.useMemo(()=>c.map($=>$.prefix),[c]),m=u.useMemo(()=>new Set(f.map($=>$.token)),[f]),h=u.useMemo(()=>XN(e),[e]),v=u.useMemo(()=>Fr(e,y),[e,y]),b=u.useMemo(()=>v.find($=>m.has($))??null,[v,m]),x=u.useMemo(()=>{const $=new Set;for(const A of c)h.some(D=>D===A.prefix||D.startsWith(A.prefix+"-")&&/^-?\d+$/.test(D.slice(A.prefix.length+1)))&&$.add(A.value);return $},[h,c]),k=u.useMemo(()=>{const $=h.find(z=>nh(z,g));if(!$)return"none";const A=p.filter(z=>z.value!=="none");for(const z of c){if($===z.prefix)return A.find(D=>(D.suffix??"")==="")?.value??"thin";if($.startsWith(z.prefix+"-")){const D=$.slice(z.prefix.length),O=A.find(M=>(M.suffix??"")===D);if(O)return O.value}}return"thin"},[h,g,c,p]);if(!t||c.length===0||p.length===0)return null;function w($){return $.filter(A=>!nh(A,g))}function _($,A){const z=w(h),D=p.find(B=>B.value===A);if(!D||A==="none"||$.size===0)return z.join(" ").trim();const O=D.suffix??"",M=[...z];for(const B of c)$.has(B.value)&&M.push(O?`${B.prefix}${O}`:B.prefix);return M.join(" ").trim()}const S=$=>{const A=new Set(x);A.has($)?A.delete($):($==="all"?A.clear():A.delete("all"),A.add($));const z=k==="none"?p.find(D=>D.value!=="none")?.value??"thin":k;o(_(A,z))},E=$=>{const A=x.size===0?new Set(["all"]):x;o(_(A,$))},C=$=>{s?.($)},N=($,A)=>$&&$.startsWith("$t:")?n($.slice(3)):$||A;return i.jsxs("div",{className:"g7le-cell-border","data-testid":"g7le-cell-border",style:JN,children:[i.jsx("div",{style:ZN,children:i.jsx("span",{style:QN,children:n("layout_editor.table_editor.cell_border")})}),i.jsx("div",{style:nl,"data-testid":"g7le-cell-border-widths",children:p.map($=>i.jsx("button",{type:"button",disabled:d,"data-testid":`g7le-cell-border-width-${$.value}`,"aria-pressed":k===$.value,onClick:()=>E($.value),style:k===$.value?rh:ol,children:N($.label,$.value)},$.value))}),k!=="none"&&i.jsx("div",{style:nl,"data-testid":"g7le-cell-border-sides",children:c.map($=>i.jsx("button",{type:"button",disabled:d,"data-testid":`g7le-cell-border-side-${$.value}`,"aria-pressed":x.has($.value),onClick:()=>S($.value),style:x.has($.value)?rh:ol,children:N($.label,$.value)},$.value))}),k!=="none"&&(f.length>0||l)&&i.jsx(i.Fragment,{children:i.jsxs("div",{style:nl,"data-testid":"g7le-cell-border-colors",children:[f.map($=>{const A=b===$.token;return i.jsx("button",{type:"button",disabled:d,title:N($.label,$.value),"data-testid":`g7le-cell-border-color-${$.value}`,"aria-pressed":A,onClick:()=>C($.token),style:{...ih,background:$.swatch??"#fff",outline:A?"2px solid #2563eb":"1px solid #cbd5e1"}},$.value)}),l&&(()=>{const $=!!a&&f.some(z=>z.swatch&&z.swatch.toLowerCase()===a.toLowerCase()),A=!y&&!!a&&!$;return i.jsx("label",{title:n(y?"layout_editor.table_editor.dark_free_color_hint":"layout_editor.table_inplace.custom_color"),"data-testid":"g7le-cell-border-color-custom",style:{...ih,position:"relative",overflow:"hidden",display:"inline-flex",alignItems:"center",justifyContent:"center",background:A&&th.test(a)?a:"conic-gradient(red, yellow, lime, aqua, blue, magenta, red)",outline:A?"2px solid #2563eb":"1px solid #cbd5e1",opacity:y?.4:1,cursor:y?"not-allowed":"pointer"},children:i.jsx("input",{type:"color",disabled:d||y,"data-testid":"g7le-cell-border-color-picker",value:a&&th.test(a)?a:"#000000",onChange:z=>l(z.target.value),style:{position:"absolute",width:0,height:0,opacity:0}})})})()]})})]})}const JN={display:"flex",flexDirection:"column",gap:6},ZN={display:"flex",alignItems:"center",justifyContent:"space-between",gap:8},QN={fontSize:12,fontWeight:600,color:"#475569"},nl={display:"flex",gap:4,flexWrap:"wrap",alignItems:"center"},ol={padding:"3px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},rh={...ol,border:"1px solid #2563eb",background:"#eff6ff",color:"#1d4ed8",fontWeight:600},ih={width:22,height:22,borderRadius:5,padding:0,cursor:"pointer",boxSizing:"border-box"},sh=/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;function ah({colorStyle:e,className:t,catalog:n,t:o,colorScheme:r="light",onPresetToken:s,onCustomColor:a,onClear:l,disabled:d}){const c=n?.colors??[],p=r==="dark",f=e??"",y=Fr(t??"",p),g=c.find(x=>x.token&&y.includes(x.token))?.token??null,m=(x,k)=>x&&x.startsWith("$t:")?o(x.slice(3)):x||k,h=!g&&(p||f.trim()===""),v=!!f&&c.some(x=>x.swatch&&x.swatch.toLowerCase()===f.toLowerCase()),b=!p&&!!f&&!v;return i.jsxs("div",{className:"g7le-cell-fill","data-testid":"g7le-cell-fill",style:RN,children:[i.jsx("div",{style:e8,children:i.jsx("span",{style:t8,children:o("layout_editor.table_editor.cell_fill")})}),i.jsxs("div",{style:n8,"data-testid":"g7le-cell-fill-colors",children:[i.jsx("button",{type:"button",disabled:d,title:o("layout_editor.table_editor.cell_fill_none"),"data-testid":"g7le-cell-fill-none","aria-pressed":h,onClick:()=>l?.(),style:{...rl,background:"repeating-conic-gradient(#e2e8f0 0% 25%, #ffffff 0% 50%) 50% / 10px 10px",outline:h?"2px solid #2563eb":"1px solid #cbd5e1"}}),c.map(x=>{const k=!!x.token&&g===x.token,w=!p&&!!x.swatch&&f.toLowerCase()===(x.swatch??"").toLowerCase(),_=k||w,S=d||p&&!x.token;return i.jsx("button",{type:"button",disabled:S,title:m(x.label,x.value),"data-testid":`g7le-cell-fill-color-${x.value}`,"aria-pressed":_,onClick:()=>{x.token&&s?s(x.token):!p&&x.swatch&&a&&a(x.swatch)},style:{...rl,background:x.swatch??"#fff",outline:_?"2px solid #2563eb":"1px solid #cbd5e1",opacity:S?.4:1,cursor:S?"not-allowed":"pointer"}},x.value)}),a&&i.jsx("label",{title:o(p?"layout_editor.table_editor.dark_free_color_hint":"layout_editor.table_editor.cell_fill_custom"),"data-testid":"g7le-cell-fill-custom",style:{...rl,position:"relative",overflow:"hidden",display:"inline-flex",alignItems:"center",justifyContent:"center",background:b&&sh.test(f)?f:"conic-gradient(red, yellow, lime, aqua, blue, magenta, red)",outline:b?"2px solid #2563eb":"1px solid #cbd5e1",opacity:p?.4:1,cursor:p?"not-allowed":"pointer"},children:i.jsx("input",{type:"color",disabled:d||p,"data-testid":"g7le-cell-fill-color-picker",value:f&&sh.test(f)?f:"#ffffff",onChange:x=>a(x.target.value),style:{position:"absolute",width:0,height:0,opacity:0}})})]})]})}const RN={display:"flex",flexDirection:"column",gap:6},e8={display:"flex",alignItems:"center",justifyContent:"space-between",gap:8},t8={fontSize:12,fontWeight:600,color:"#475569"},n8={display:"flex",gap:4,flexWrap:"wrap",alignItems:"center"},rl={width:22,height:22,borderRadius:5,padding:0,cursor:"pointer",boxSizing:"border-box"};function o8(e){const t=/^(\d+)px$/.exec(e.trim());return t?parseInt(t[1],10):null}function lh({paddingStyle:e,catalog:t,t:n,onChange:o,disabled:r}){const s=t?.steps??[],a=e??"",l=o8(a),d=(h,v)=>h&&h.startsWith("$t:")?n(h.slice(3)):h||v,c=s.some(h=>(h.px??0)===(l??-1)),p=l!==null&&!c?String(l):"",[f,y]=u.useState(p);u.useEffect(()=>{y(p)},[e]);const g=h=>{o(h&&h>0?`${h}px`:""),y("")},m=h=>{const v=parseInt(h,10);Number.isFinite(v)&&v>=0&&o(v>0?`${v}px`:"")};return i.jsxs("div",{className:"g7le-cell-padding","data-testid":"g7le-cell-padding",style:r8,children:[i.jsx("div",{style:i8,children:n("layout_editor.table_editor.cell_padding")}),i.jsxs("div",{style:s8,"data-testid":"g7le-cell-padding-steps",children:[s.map(h=>{const v=(h.px??0)===(l??-1)||!h.px&&a.trim()==="";return i.jsx("button",{type:"button",disabled:r,"data-testid":`g7le-cell-padding-step-${h.value}`,"aria-pressed":v,onClick:()=>g(h.px),style:v?a8:dh,children:d(h.label,h.value)},h.value)}),i.jsxs("span",{style:{display:"inline-flex",alignItems:"center",gap:3},children:[i.jsx("input",{type:"number",min:0,disabled:r,"data-testid":"g7le-cell-padding-free",value:f,placeholder:n("layout_editor.table_editor.cell_padding_custom"),onChange:h=>y(h.target.value),onBlur:()=>f.trim()!==""&&m(f),onKeyDown:h=>{h.key==="Enter"&&f.trim()!==""&&m(f)},style:l8}),i.jsx("span",{style:{fontSize:11,color:"#94a3b8"},children:"px"})]})]})]})}const r8={display:"flex",flexDirection:"column",gap:6},i8={fontSize:12,fontWeight:600,color:"#475569"},s8={display:"flex",gap:4,flexWrap:"wrap",alignItems:"center"},dh={padding:"3px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},a8={...dh,border:"1px solid #2563eb",background:"#eff6ff",color:"#1d4ed8",fontWeight:600},l8={width:48,padding:"3px 5px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,boxSizing:"border-box"};function ch({colorScheme:e,onChange:t,t:n,disabled:o}){const r=e==="dark";return i.jsxs("div",{className:"g7le-color-scheme-tabs","data-testid":"g7le-color-scheme-tabs",style:d8,children:[i.jsxs("div",{style:c8,children:[i.jsx("span",{style:u8,children:n("layout_editor.table_editor.cell_color")}),i.jsxs("div",{style:p8,children:[i.jsx("button",{type:"button",disabled:o,"data-testid":"g7le-color-scheme-light","aria-pressed":!r,onClick:()=>t("light"),style:r?il:uh,children:n("layout_editor.property_modal.scope.scheme_light")}),i.jsx("button",{type:"button",disabled:o,"data-testid":"g7le-color-scheme-dark","aria-pressed":r,onClick:()=>t("dark"),style:r?uh:il,children:n("layout_editor.property_modal.scope.scheme_dark")})]})]}),r&&i.jsx("div",{style:f8,"data-testid":"g7le-color-scheme-dark-hint",children:n("layout_editor.table_editor.dark_free_color_hint")})]})}const d8={display:"flex",flexDirection:"column",gap:3},c8={display:"flex",alignItems:"center",justifyContent:"space-between",gap:8},u8={fontSize:12,fontWeight:700,color:"#0f172a"},p8={display:"flex",gap:2},il={padding:"2px 9px",fontSize:11,border:"none",borderBottom:"2px solid transparent",background:"transparent",color:"#94a3b8",cursor:"pointer"},uh={...il,color:"#1d4ed8",borderBottom:"2px solid #2563eb",fontWeight:600},f8={fontSize:10,color:"#94a3b8",fontStyle:"italic"},sl="dark:";function ph(e){return typeof e=="string"?e.split(/\s+/).filter(Boolean):[]}function fh(e,t){return t==="dark"?e.filter(n=>n.startsWith(sl)).map(n=>n.slice(sl.length)):e.filter(n=>!n.startsWith(sl))}const g8={none:0,thin:1,medium:2,thick:4},gh={top:{w:"borderTopWidth",s:"borderTopStyle",c:"borderTopColor"},bottom:{w:"borderBottomWidth",s:"borderBottomStyle",c:"borderBottomColor"},left:{w:"borderLeftWidth",s:"borderLeftStyle",c:"borderLeftColor"},right:{w:"borderRightWidth",s:"borderRightStyle",c:"borderRightColor"}},hh=["top","bottom","left","right"];function h8(e,t,n="light"){if(!e||!t)return{};const o=t.sides??[],r=t.widths??[],s=t.colors??[],a=ph(e.props?.className);if(a.length===0&&!e.props?.style)return{};const l=[...r].filter(b=>b.value!=="none").sort((b,x)=>(x.suffix??"").length-(b.suffix??"").length),d={};for(const b of o)for(const x of l){const k=x.suffix?`${b.prefix}${x.suffix}`:b.prefix;if(a.includes(k)){d[b.value]=x.value;break}}const c=e.props?.style??{},p=fh(a,n),f=s.find(b=>p.includes(b.token))?.swatch??"",g=(n==="light"&&typeof c.borderColor=="string"?c.borderColor:"")||f||"#94a3b8",m=b=>{const x=gh[b]?.c,k=x?c[x]:void 0;return typeof k=="string"&&k?k:g},h={},v=(b,x)=>{const k=gh[b];if(!k)return;const w=g8[x]??1;h[k.w]=`${w}px`,h[k.s]=w>0?"solid":"none",w>0&&(h[k.c]=m(b))};if(d.all)for(const b of hh)v(b,d.all);for(const b of hh)d[b]&&v(b,d[b]);return h}function y8(e,t,n="light"){if(!e?.props)return{};const o=e.props.style??{},r={},s=ph(e.props.className),a=t?.colors?.find(c=>c.token&&fh(s,n).includes(c.token))?.swatch??"",d=(n==="light"&&typeof o.backgroundColor=="string"?o.backgroundColor:"")||a;return d&&(r.backgroundColor=d),typeof o.padding=="string"&&o.padding&&(r.padding=o.padding),r}const b8=/^\s*\$t:(custom\.[a-zA-Z0-9._-]+)\s*$/,m8=/^\s*\$t:([a-zA-Z0-9._-]+)\s*$/,v8=/\{\{.*?\}\}/;function yh(e,t,n){const o=e?.text;if(typeof o!="string")return{display:"",editable:!1,isKey:!1};const r=mt(o);if(r&&r.key.startsWith("custom.")){let a="";try{const l=Pt.getInstance().translate(r.key,{templateId:t,locale:n});a=l&&l!==r.key?l:""}catch{a=""}return{display:a,editable:!0,isKey:!0}}if(v8.test(o))return{display:o,editable:!1,isKey:!1};const s=m8.exec(o);if(s){const a=s[1];let l="";try{const d=Pt.getInstance().translate(a,{templateId:t,locale:n});l=d&&d!==a?d:""}catch{l=""}return{display:l,editable:!0,isKey:!0}}return{display:o,editable:!0,isKey:!1}}function al(e){const t=Zo(e);return t===null?{node:null,path:null}:{node:mi(e,t),path:t}}function _8(e){if(!e||typeof e.text!="string")return null;const t=b8.exec(e.text);if(t)return t[1];const n=mt(e.text);return n&&n.key.startsWith("custom.")?n.key:null}function x8({node:e,params:t,t:n,onPatchNode:o,templateIdentifier:r,candidates:s}){const{state:a}=Je(),l=a.selectedRoute?.layoutName??null,d=a.locale,c=r??a.templateIdentifier,p=u.useMemo(()=>t&&typeof t=="object"?t:null,[t]);u.useMemo(()=>ht(p),[p]);const f=u.useMemo(()=>Be(e,p),[e,p]),y=u.useMemo(()=>{const q=p?.cellBorder;return q&&typeof q=="object"?q:null},[p]),g=u.useMemo(()=>{const q=p?.cellBackground;return q&&typeof q=="object"?q:null},[p]),m=u.useMemo(()=>{const q=p?.cellPadding;return q&&typeof q=="object"?q:null},[p]),[h,v]=u.useState(null),[b,x]=u.useState(null),[k,w]=u.useState({}),[_,S]=u.useState(!1),[E,C]=u.useState(null),[N,$]=u.useState(null),[A,z]=u.useState("light"),D=u.useCallback(q=>o(q),[o]),O=u.useMemo(()=>h?Ue(f,h.r,h.c):null,[f,h]),M=N!==null?N:h?.c??null,B=E!==null?E:h?.r??null,T=u.useMemo(()=>{const q=(y?.sides??[]).find($e=>$e.value==="all")??(y?.sides??[])[0],ae=(y?.widths??[]).find($e=>$e.value!=="none");if(!q||!ae)return[];const pe=ae.suffix??"";return[pe?`${q.prefix}${pe}`:q.prefix]},[y]),H=u.useCallback((q,ae,pe)=>{if(T.length===0)return q;const $e=(y?.sides??[]).map(J=>J.prefix),be=Ue(Be(q,p),ae,pe),oe=(typeof be?.cell?.props?.className=="string"?be.cell.props.className:"").split(/\s+/).filter(Boolean);return oe.some(J=>$e.some(le=>J===le||J.startsWith(le+"-")&&/^-?\d+$/.test(J.slice(le.length+1))))?q:ki(q,p,ae,pe,[...oe,...T].join(" ").trim())},[T,y,p]),L=u.useCallback(q=>{if(!h)return;const ae=Math.min(h.r,b?.r??h.r),pe=Math.max(h.r,b?.r??h.r),$e=Math.min(h.c,b?.c??h.c),be=Math.max(h.c,b?.c??h.c),j=new Set,oe=[];for(let J=ae;J<=pe;J++)for(let le=$e;le<=be;le++)j.add(`${J}:${le}`),oe.push({r:J,c:le});let ue=e;for(const J of oe){if(ue=H(ue,J.r,J.c),ue=Jt(ue,p,J.r,J.c,"borderColor",q),J.r>0&&!j.has(`${J.r-1}:${J.c}`)){const le=Ue(Be(ue,p),J.r-1,J.c);le&&(ue=Jt(ue,p,le.gridRow,le.gridCol,"borderBottomColor",q))}if(J.c>0&&!j.has(`${J.r}:${J.c-1}`)){const le=Ue(Be(ue,p),J.r,J.c-1);le&&(ue=Jt(ue,p,le.gridRow,le.gridCol,"borderRightColor",q))}}D(ue)},[h,b,e,p,D,H]),I=u.useCallback(q=>L(q),[L]),P=u.useMemo(()=>(y?.colors??[]).map(q=>q.token).filter(Boolean),[y]),F=u.useCallback(q=>{if(!h||P.length===0)return;const ae=Math.min(h.r,b?.r??h.r),pe=Math.max(h.r,b?.r??h.r),$e=Math.min(h.c,b?.c??h.c),be=Math.max(h.c,b?.c??h.c);let j=e;for(let oe=ae;oe<=pe;oe++)for(let ue=$e;ue<=be;ue++)j=H(j,oe,ue),j=po(j,p,oe,ue,q,P,A);D(j)},[h,b,e,p,D,H,P,A]),G=u.useMemo(()=>{if(A==="dark")return"";const q=O?.cell.props?.style;return typeof q?.borderColor=="string"?q.borderColor:""},[O,A]),V=u.useCallback((q,ae)=>{if(!h)return;const pe=Math.min(h.r,b?.r??h.r),$e=Math.max(h.r,b?.r??h.r),be=Math.min(h.c,b?.c??h.c),j=Math.max(h.c,b?.c??h.c);let oe=e;for(let ue=pe;ue<=$e;ue++)for(let J=be;J<=j;J++)oe=Jt(oe,p,ue,J,q,ae);D(oe)},[h,b,e,p,D]),Y=u.useMemo(()=>(g?.colors??[]).map(q=>q.token).filter(q=>!!q),[g]),Z=u.useCallback(()=>h?{r0:Math.min(h.r,b?.r??h.r),r1:Math.max(h.r,b?.r??h.r),c0:Math.min(h.c,b?.c??h.c),c1:Math.max(h.c,b?.c??h.c)}:null,[h,b]),se=u.useCallback(q=>{const ae=Z();if(!ae||Y.length===0)return;let pe=e;for(let $e=ae.r0;$e<=ae.r1;$e++)for(let be=ae.c0;be<=ae.c1;be++)pe=po(pe,p,$e,be,q,Y,A);D(pe)},[Z,e,p,D,Y,A]),re=u.useCallback(q=>V("backgroundColor",q),[V]),U=u.useCallback(()=>{const q=Z();if(!q)return;let ae=e;for(let pe=q.r0;pe<=q.r1;pe++)for(let $e=q.c0;$e<=q.c1;$e++)ae=po(ae,p,pe,$e,void 0,Y,A),A==="light"&&(ae=Jt(ae,p,pe,$e,"backgroundColor",void 0));D(ae)},[Z,e,p,D,Y,A]),ee=u.useCallback(q=>V("padding",q),[V]),fe=u.useMemo(()=>{if(A==="dark")return"";const q=O?.cell.props?.style;return typeof q?.backgroundColor=="string"?q.backgroundColor:""},[O,A]),K=u.useMemo(()=>typeof O?.cell.props?.className=="string"?O.cell.props.className:"",[O]),ne=u.useMemo(()=>{const q=O?.cell.props?.style;return typeof q?.padding=="string"?q.padding:""},[O]),me=u.useCallback(q=>q==="section"?n("layout_editor.table_editor.move_blocked_section"):"",[n]),Ce=u.useCallback((q,ae)=>vi(f,q,ae),[f]),ze=u.useCallback((q,ae)=>_i(f,q,ae),[f]),Se=u.useCallback(q=>D(el(e,p,q)),[e,p,D]),qe=u.useCallback(q=>{D(Gg(e,p,q)),v(null),x(null)},[e,p,D]),De=u.useCallback((q,ae)=>D(Yg(e,p,q,ae)),[e,p,D]),Pe=u.useCallback(q=>D(Jg(e,p,q)),[e,p,D]),Qe=u.useCallback(q=>{D(Zg(e,p,q)),v(null),x(null)},[e,p,D]),Me=u.useCallback((q,ae)=>D(Xg(e,p,q,ae)),[e,p,D]),Re=u.useCallback(()=>{if(!h||!b)return;const q=tl(e,p,h.r,h.c,b.r,b.c);q.ok&&(D(q.table),v({r:Math.min(h.r,b.r),c:Math.min(h.c,b.c)}),x(null))},[e,p,h,b,D]),ve=u.useCallback(()=>{h&&(D(Qg(e,p,h.r,h.c)),x(null))},[e,p,h,D]),te=u.useCallback(q=>{if(!h)return;const ae=Math.min(h.r,b?.r??h.r),pe=Math.max(h.r,b?.r??h.r),$e=Math.min(h.c,b?.c??h.c),be=Math.max(h.c,b?.c??h.c);let j=e;for(let oe=ae;oe<=pe;oe++)for(let ue=$e;ue<=be;ue++)j=ki(j,p,oe,ue,q);D(j)},[e,p,h,b,D]),he=u.useCallback(async(q,ae)=>{const pe=`${q}:${ae}`,$e=k[pe];if($e===void 0)return;const be=Ue(f,q,ae);if(!be)return;const{node:j,path:oe}=al(be.cell);if(oe===null)return;const ue=yh(j,c,d);if(ue.editable){if($e===ue.display){w(J=>{const le={...J};return delete le[pe],le});return}S(!0);try{const J=_8(j);if(J)await gr(c,J,d,$e),await en(c,d);else{const le=await Vn(c,l,d,$e);if(le.kind==="ok"&&le.resource){const Ie=`$t:${le.resource.translation_key}`;if(oe.length===0)D(Rg(e,p,q,ae,Ie));else{const dt=Qo(be.cell,oe,Ie);D(eh(e,p,q,ae,dt))}await en(c,d)}}}finally{S(!1),w(J=>{const le={...J};return delete le[pe],le})}}},[k,f,c,d,l,e,p,D]),Q=u.useMemo(()=>{if(!O)return null;const{node:q,path:ae}=al(O.cell);return ae===null?null:{value:typeof q?.text=="string"?q.text:"",editable:!0}},[O]),ie=u.useCallback(q=>{if(typeof q!="string"||q.length===0||!h)return;const ae=Ue(f,h.r,h.c);if(!ae)return;const pe=Zo(ae.cell);if(pe!==null)if(pe.length===0)D(Rg(e,p,h.r,h.c,q));else{const $e=Qo(ae.cell,pe,q);D(eh(e,p,h.r,h.c,$e))}},[h,f,e,p,D]),_e=u.useCallback((q,ae,pe)=>{pe?x({r:q,c:ae}):(v({r:q,c:ae}),x(null))},[]),ge=u.useMemo(()=>Array.from({length:f.colCount},(q,ae)=>ae),[f.colCount]),Ae=!!(h&&b&&(h.r!==b.r||h.c!==b.c)),Ne=!!(O&&(O.colSpan>1||O.rowSpan>1)),Le=O&&typeof O.cell.props?.className=="string"?O.cell.props.className:"";return i.jsxs("div",{className:"g7le-node-editor g7le-node-editor--table","data-testid":"g7le-table-editor",style:w8,children:[i.jsx("div",{style:k8,children:n("layout_editor.table_editor.title")}),i.jsx("div",{style:{overflowX:"auto"},children:i.jsxs("table",{"data-testid":"g7le-table-editor-grid",style:S8,children:[i.jsx("thead",{children:i.jsxs("tr",{children:[i.jsx("th",{style:C8}),ge.map(q=>{const ae=ze(q,-1),pe=ze(q,1),$e=M===q;return i.jsx("th",{style:$8,"data-testid":`g7le-table-colgutter-${q}`,onMouseEnter:()=>$(q),onMouseLeave:()=>$(be=>be===q?null:be),children:i.jsxs("div",{style:{...I8,visibility:$e?"visible":"hidden"},children:[i.jsx("button",{type:"button",title:ae.ok?n("layout_editor.table_editor.move_col_left"):me(ae.reason)||n("layout_editor.table_editor.move_col_left"),disabled:!ae.ok||_,onClick:()=>Me(q,-1),"data-testid":`g7le-table-col-left-${q}`,style:ae.ok?mn:Si,children:"←"}),i.jsx("button",{type:"button",title:n("layout_editor.table_editor.add_col"),disabled:_,onClick:()=>Pe(q),"data-testid":`g7le-table-col-add-${q}`,style:mn,children:"+"}),i.jsx("button",{type:"button",title:n("layout_editor.table_editor.remove_col"),disabled:f.colCount<=1||_,onClick:()=>Qe(q),"data-testid":`g7le-table-col-remove-${q}`,style:bh,children:"✕"}),i.jsx("button",{type:"button",title:pe.ok?n("layout_editor.table_editor.move_col_right"):me(pe.reason)||n("layout_editor.table_editor.move_col_right"),disabled:!pe.ok||_,onClick:()=>Me(q,1),"data-testid":`g7le-table-col-right-${q}`,style:pe.ok?mn:Si,children:"→"})]})},q)})]})}),i.jsx("tbody",{children:f.rows.map((q,ae)=>{const pe=q.cells[0]?.gridRow??ae,$e=B===pe;return i.jsxs("tr",{"data-testid":`g7le-table-row-${pe}`,onMouseEnter:()=>C(pe),onMouseLeave:()=>C(be=>be===pe?null:be),children:[i.jsx("td",{style:E8,"data-testid":`g7le-table-rowgutter-${pe}`,children:(()=>{const be=Ce(pe,-1),j=Ce(pe,1);return i.jsxs("div",{style:{...A8,visibility:$e?"visible":"hidden"},children:[i.jsx("button",{type:"button",title:be.ok?n("layout_editor.table_editor.move_row_up"):me(be.reason)||n("layout_editor.table_editor.move_row_up"),disabled:!be.ok||_,onClick:()=>De(pe,-1),"data-testid":`g7le-table-row-up-${pe}`,style:be.ok?mn:Si,children:"↑"}),i.jsx("button",{type:"button",title:n("layout_editor.table_editor.add_row"),disabled:_,onClick:()=>Se(pe),"data-testid":`g7le-table-row-add-${pe}`,style:mn,children:"+"}),i.jsx("button",{type:"button",title:n("layout_editor.table_editor.remove_row"),disabled:f.rows.length<=1||_,onClick:()=>qe(pe),"data-testid":`g7le-table-row-remove-${pe}`,style:bh,children:"✕"}),i.jsx("button",{type:"button",title:j.ok?n("layout_editor.table_editor.move_row_down"):me(j.reason)||n("layout_editor.table_editor.move_row_down"),disabled:!j.ok||_,onClick:()=>De(pe,1),"data-testid":`g7le-table-row-down-${pe}`,style:j.ok?mn:Si,children:"↓"})]})})()}),q.cells.map(be=>{const j=`${be.gridRow}:${be.gridCol}`,{node:oe,path:ue}=al(be.cell),J=yh(oe,c,d),le=ue!==null&&J.editable,Ie=ue===null,dt=k[j],pt=dt!==void 0?dt:J.display,ot=h&&h.r===be.gridRow&&h.c===be.gridCol,Oe=h&&b&&be.gridRow>=Math.min(h.r,b.r)&&be.gridRow<=Math.max(h.r,b.r)&&be.gridCol>=Math.min(h.c,b.c)&&be.gridCol<=Math.max(h.c,b.c);return i.jsx("td",{colSpan:be.colSpan,rowSpan:be.rowSpan,"data-testid":`g7le-table-cell-${be.gridRow}-${be.gridCol}`,onClick:He=>_e(be.gridRow,be.gridCol,He.shiftKey),style:{...T8,...h8(be.cell,y,A),...y8(be.cell,g,A),...be.isHeader?D8:{},...ot?N8:{},...Oe&&!ot?z8:{}},children:le?i.jsx("input",{type:"text","data-testid":`g7le-table-cell-input-${be.gridRow}-${be.gridCol}`,value:pt,placeholder:n("layout_editor.table_editor.cell_text"),disabled:_,onMouseDown:He=>{He.shiftKey&&He.preventDefault(),_e(be.gridRow,be.gridCol,He.shiftKey)},onClick:He=>He.stopPropagation(),onChange:He=>w(Ye=>({...Ye,[j]:He.target.value})),onBlur:()=>void he(be.gridRow,be.gridCol),style:M8}):Ie?i.jsx("span",{"data-testid":`g7le-table-cell-struct-${be.gridRow}-${be.gridCol}`,style:mh,children:n("layout_editor.table_editor.structural_cell")}):i.jsx("span",{style:mh,children:J.display})},j)})]},ae)})})]})}),i.jsxs("button",{type:"button","data-testid":"g7le-table-add-row-bottom",onClick:()=>Se(f.rows.length-1),disabled:_,style:L8,children:["+ ",n("layout_editor.table_editor.add_row")]}),i.jsxs("div",{style:P8,"data-testid":"g7le-table-cell-tools",children:[i.jsx("button",{type:"button","data-testid":"g7le-table-merge",onClick:Re,disabled:!Ae||_,style:vh,children:n("layout_editor.table_editor.merge_cells")}),i.jsx("button",{type:"button","data-testid":"g7le-table-unmerge",onClick:ve,disabled:!Ne||_,style:vh,children:n("layout_editor.table_editor.unmerge_cells")})]}),O&&Q&&i.jsxs("div",{style:B8,"data-testid":"g7le-table-cell-i18n-section",children:[i.jsx("div",{style:W8,children:n("layout_editor.table_editor.cell_i18n_label")}),i.jsx(vt,{value:Q.value,onChange:ie,t:n,placeholder:n("layout_editor.table_editor.cell_text"),testidPrefix:"g7le-table-cell-i18n",candidates:s,enableExpressionTree:!0,expressionTreeCollapsible:!0})]}),O&&i.jsxs("div",{style:O8,"data-testid":"g7le-table-cell-color-section",children:[i.jsx(ch,{colorScheme:A,onChange:z,t:n,disabled:_}),y&&i.jsx("div",{style:ll,"data-testid":"g7le-table-cell-border-row",children:i.jsx(oh,{className:Le,catalog:y,t:n,onChange:te,colorScheme:A,onPresetToken:F,colorStyle:G,onColorStyle:I,disabled:_})}),i.jsx("div",{style:ll,"data-testid":"g7le-table-cell-fill-row",children:i.jsx(ah,{colorStyle:fe,className:K,catalog:g,t:n,colorScheme:A,onPresetToken:se,onCustomColor:re,onClear:U,disabled:_})})]}),O&&i.jsx("div",{style:ll,"data-testid":"g7le-table-cell-padding-row",children:i.jsx(lh,{paddingStyle:ne,catalog:m,t:n,onChange:ee,disabled:_})}),O&&!y&&i.jsxs("label",{style:j8,"data-testid":"g7le-table-cell-border-row",children:[i.jsx("span",{style:H8,children:n("layout_editor.table_editor.cell_border")}),i.jsx("input",{type:"text","data-testid":"g7le-table-cell-border-input",value:Le,placeholder:n("layout_editor.table_editor.cell_class_placeholder"),onChange:q=>te(q.target.value),disabled:_,style:F8})]}),!f.valid&&i.jsx("div",{"data-testid":"g7le-table-grid-warning",style:K8,children:n("layout_editor.table_editor.grid_invalid")}),i.jsx("div",{style:V8,children:n("layout_editor.table_editor.select_hint")})]})}const w8={display:"flex",flexDirection:"column",gap:8,width:"100%",marginBottom:12},k8={fontSize:12,fontWeight:700,color:"#0f172a"},S8={borderCollapse:"collapse",width:"auto"},C8={width:28,background:"transparent",border:"none"},$8={padding:2,border:"none",background:"transparent"},E8={padding:2,border:"none",background:"transparent",verticalAlign:"middle"},I8={display:"flex",gap:2,justifyContent:"center"},A8={display:"grid",gridTemplateColumns:"repeat(2, 16px)",gap:2,justifyContent:"center"},mn={width:16,height:16,lineHeight:"14px",padding:0,fontSize:10,border:"1px solid #cbd5e1",borderRadius:4,background:"#fff",color:"#475569",cursor:"pointer"},bh={...mn,border:"1px solid #fecaca",color:"#dc2626"},Si={...mn,cursor:"not-allowed",opacity:.4,color:"#94a3b8",borderColor:"#e2e8f0"},T8={border:"1px solid #cbd5e1",padding:0,width:64,minWidth:64,maxWidth:120,height:24,background:"#fff",cursor:"pointer"},D8={background:"#f1f5f9",fontWeight:600},N8={outline:"2px solid #2563eb",outlineOffset:-2},z8={background:"#dbeafe"},M8={width:"100%",boxSizing:"border-box",padding:"2px 5px",fontSize:11,height:22,lineHeight:"18px",border:"none",background:"transparent",outline:"none"},mh={display:"block",padding:"2px 5px",fontSize:10,color:"#64748b",fontStyle:"italic",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap",maxWidth:110},L8={padding:"4px 8px",fontSize:12,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",alignSelf:"flex-start"},P8={display:"flex",gap:6},vh={padding:"4px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},O8={display:"flex",flexDirection:"column",gap:8,borderTop:"1px solid #f1f5f9",paddingTop:8},B8={display:"flex",flexDirection:"column",gap:6,borderTop:"1px solid #f1f5f9",paddingTop:8},W8={fontSize:12,fontWeight:700,color:"#0f172a"},ll={display:"flex",flexDirection:"column",gap:6},j8={display:"flex",alignItems:"center",gap:6},H8={fontSize:12,color:"#475569",minWidth:60},F8={flex:1,minWidth:0,padding:"4px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6},K8={fontSize:11,color:"#b45309",background:"#fffbeb",border:"1px solid #fde68a",borderRadius:6,padding:"4px 6px"},V8={fontSize:11,color:"#94a3b8"},U8=/\{\{.*?\}\}/;function q8(e,t,n){const r=(e.props??{})[t];return Array.isArray(r)?r:r===void 0&&n?JSON.parse(JSON.stringify(n)):[]}function G8(e){return typeof e=="string"&&U8.test(e)}function dl(e,t,n){return e?e.startsWith("$t:")?t(e.slice(3)):e:n}function Y8(e,t,n){return typeof e=="object"&&e!==null?e[t.key]:n?e:void 0}function X8(e,t,n,o){if(typeof e=="object"&&e!==null){const r={...e};return o===void 0?delete r[t.key]:r[t.key]=o,r}return n?o:{[t.key]:o}}function _h({node:e,params:t,t:n,onPatchNode:o,templateIdentifier:r,candidates:s}){const a=typeof t?.arrayProp=="string"?t.arrayProp:null,l=Array.isArray(t?.fields)?t.fields:[],d=dl(typeof t?.itemLabel=="string"?t.itemLabel:void 0,n,n("layout_editor.array_editor.item")),c=t?.newItem,p=c&&typeof c=="object"?c:null,f=typeof c=="string"||typeof c=="number"?c:null,y=(l.find(S=>S.primary)??l[0])?.key,g=(e.props??{})[a??""],m=Array.isArray(t?.defaultItems)?t.defaultItems:null,h=a?q8(e,a,m):[],v=u.useCallback(S=>{if(!a)return;const E={...e.props??{}};E[a]=S,o({...e,props:E})},[a,e,o]),b=u.useCallback(()=>{if(p)return JSON.parse(JSON.stringify(p));if(f!==null)return f;const S={};for(const E of l)(E.widget??"text")==="boolean"?S[E.key]=!1:S[E.key]="";return S},[p,f,l]),x=u.useCallback(()=>{v([...h,b()])},[v,h,b]),k=u.useCallback(S=>{v(h.filter((E,C)=>C!==S))},[v,h]),w=u.useCallback((S,E)=>{const C=S+E;if(C<0||C>=h.length)return;const N=[...h];[N[S],N[C]]=[N[C],N[S]],v(N)},[v,h]),_=u.useCallback((S,E,C)=>{const N=E.key===y,$=h.map((A,z)=>z===S?X8(A,E,N,C):A);v($)},[h,v,y]);return a?G8(g)||g!==void 0&&!Array.isArray(g)?i.jsx("div",{className:"g7le-node-editor g7le-node-editor--array","data-testid":"g7le-array-editor-bound",style:rz,children:n("layout_editor.array_editor.bound_degraded")}):i.jsxs("div",{className:"g7le-node-editor g7le-node-editor--array","data-testid":"g7le-array-editor",style:J8,children:[i.jsx("div",{style:Z8,children:n("layout_editor.array_editor.items_title")}),h.length===0&&i.jsx("div",{"data-testid":"g7le-array-empty",style:wh,children:n("layout_editor.array_editor.empty")}),h.map((S,E)=>i.jsxs("div",{"data-testid":`g7le-array-row-${E}`,style:Q8,children:[i.jsxs("div",{style:R8,children:[i.jsxs("span",{style:ez,children:["#",E+1]}),i.jsx("div",{style:{flex:1}}),i.jsx("button",{type:"button","data-testid":`g7le-array-up-${E}`,title:n("layout_editor.array_editor.move_up"),disabled:E===0,onClick:()=>w(E,-1),style:xh,children:"↑"}),i.jsx("button",{type:"button","data-testid":`g7le-array-down-${E}`,title:n("layout_editor.array_editor.move_down"),disabled:E===h.length-1,onClick:()=>w(E,1),style:xh,children:"↓"}),i.jsx("button",{type:"button","data-testid":`g7le-array-remove-${E}`,title:n("layout_editor.array_editor.remove"),onClick:()=>k(E),style:nz,children:"✕"})]}),l.map(C=>{const N=C.widget??"text",$=C.key===y,A=dl(C.label,n,C.key),z=Y8(S,C,$),D=`g7le-array-field-${E}-${C.key}`;if(N==="boolean")return i.jsxs("label",{style:Bn,children:[i.jsx("span",{style:Wn,children:A}),i.jsx("input",{type:"checkbox","data-testid":D,checked:z===!0,onChange:M=>_(E,C,M.target.checked)})]},C.key);if(N==="select"){const M=Array.isArray(C.options)?C.options:[];return i.jsxs("label",{style:Bn,children:[i.jsx("span",{style:Wn,children:A}),i.jsxs("select",{"data-testid":D,value:z==null?"":String(z),onChange:B=>_(E,C,B.target.value===""?void 0:B.target.value),style:er,children:[i.jsx("option",{value:"",children:"—"}),M.map((B,T)=>i.jsx("option",{value:String(B.value),children:dl(B.label,n,String(B.value))},T))]})]},C.key)}if(N==="i18n-text"){const M=typeof z=="string"?z:z==null?"":String(z);return i.jsxs("label",{style:Bn,children:[i.jsx("span",{style:Wn,children:A}),i.jsx("div",{"data-testid":D,style:{flex:1,minWidth:0},children:i.jsx(vt,{value:M,onChange:B=>_(E,C,B===""?void 0:B),t:n,placeholder:A,testidPrefix:`${D}-i18n`,candidates:s,enableExpressionTree:!0,expressionTreeCollapsible:!0})})]},C.key)}if(N==="number"){const M=z==null?"":String(z);return i.jsxs("label",{style:Bn,children:[i.jsx("span",{style:Wn,children:A}),i.jsx("input",{type:"number","data-testid":D,value:M,onChange:B=>{const T=B.target.value;_(E,C,T===""?void 0:Number(T))},style:er})]},C.key)}if(N==="color"){const M=typeof z=="string"?z:"",B=/^#[0-9a-fA-F]{6}$/.test(M)?M:"#000000";return i.jsxs("label",{style:Bn,children:[i.jsx("span",{style:Wn,children:A}),i.jsx("input",{type:"color","data-testid":`${D}-swatch`,value:B,onChange:T=>_(E,C,T.target.value),style:tz}),i.jsx("input",{type:"text","data-testid":D,value:M,placeholder:"#7C3AED",onChange:T=>_(E,C,T.target.value===""?void 0:T.target.value),style:er})]},C.key)}if(N==="number-list"){const M=Array.isArray(z)?z.join(", "):"";return i.jsxs("label",{style:Bn,children:[i.jsx("span",{style:Wn,children:A}),i.jsx("input",{type:"text","data-testid":D,value:M,placeholder:n("layout_editor.array_editor.number_list_placeholder"),onChange:B=>{const T=B.target.value.split(",").map(H=>H.trim()).filter(H=>H!=="").map(H=>Number(H)).filter(H=>!Number.isNaN(H));_(E,C,T)},style:er})]},C.key)}const O=z==null?"":String(z);return i.jsxs("label",{style:Bn,children:[i.jsx("span",{style:Wn,children:A}),i.jsx("input",{type:"text","data-testid":D,value:O,placeholder:N==="icon"?n("layout_editor.array_editor.icon_placeholder"):A,onChange:M=>_(E,C,M.target.value===""?void 0:M.target.value),style:er})]},C.key)})]},E)),i.jsx("button",{type:"button","data-testid":"g7le-array-add",onClick:x,style:oz,children:n("layout_editor.array_editor.add",{item:d})})]}):i.jsx("div",{className:"g7le-node-editor g7le-node-editor--array","data-testid":"g7le-array-editor-misconfigured",style:wh,children:n("layout_editor.array_editor.no_array_prop")})}const J8={display:"flex",flexDirection:"column",gap:8,width:"100%",marginBottom:12},Z8={fontSize:12,fontWeight:700,color:"#0f172a",marginBottom:2},Q8={display:"flex",flexDirection:"column",gap:4,padding:8,border:"1px solid #e2e8f0",borderRadius:8,background:"#f8fafc"},R8={display:"flex",gap:4,alignItems:"center"},ez={fontSize:11,fontWeight:700,color:"#64748b"},Bn={display:"flex",flexWrap:"wrap",gap:6,alignItems:"center"},Wn={fontSize:11,color:"#475569",minWidth:64,flexShrink:0},er={flex:1,minWidth:0,padding:"4px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6},tz={width:32,height:28,padding:0,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",cursor:"pointer",flexShrink:0},xh={padding:"2px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},nz={padding:"2px 6px",fontSize:12,border:"1px solid #fecaca",borderRadius:6,background:"#fff",color:"#dc2626",cursor:"pointer"},oz={padding:"4px 8px",fontSize:12,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",alignSelf:"flex-start"},wh={fontSize:11,color:"#94a3b8",fontStyle:"italic"},rz={fontSize:11,color:"#b45309",fontStyle:"italic",padding:"4px 2px"};function iz(e,t){return e?e.startsWith("$t:")?t(e.slice(3)):e:null}function sz(e){const{params:t,t:n}=e,o=Array.isArray(t?.groups)?t.groups:[];return o.length===0?i.jsx("div",{className:"g7le-node-editor g7le-node-editor--array-group","data-testid":"g7le-array-group-misconfigured",style:cz,children:n("layout_editor.array_editor.no_array_prop")}):i.jsx("div",{className:"g7le-node-editor g7le-node-editor--array-group","data-testid":"g7le-array-group-editor",style:az,children:o.map((r,s)=>{const a=iz(r.title,n),{title:l,...d}=r;return i.jsxs("div",{"data-testid":`g7le-array-group-${r.arrayProp??s}`,style:lz,children:[a&&i.jsx("div",{style:dz,children:a}),i.jsx(_h,{...e,params:d})]},r.arrayProp??s)})})}const az={display:"flex",flexDirection:"column",gap:16,width:"100%",marginBottom:12},lz={display:"flex",flexDirection:"column",gap:4},dz={fontSize:12,fontWeight:700,color:"#1e293b",borderBottom:"1px solid #e2e8f0",paddingBottom:4},cz={fontSize:11,color:"#94a3b8",fontStyle:"italic"},uz=/\{\{.*?\}\}/;function pz(e){return typeof e=="string"&&uz.test(e)}function fz(e,t,n){return e?e.startsWith("$t:")?t(e.slice(3)):e:n}function gz(e){const{node:t,params:n,t:o,onPatchNode:r}=e,s=typeof n?.arrayProp=="string"?n.arrayProp:null,a=typeof n?.idField=="string"?n.idField:"id",l=typeof n?.cellChildrenProp=="string"?n.cellChildrenProp:"cellChildren",d=typeof n?.cellChildComponent=="string"?n.cellChildComponent:"Div",c=fz(typeof n?.itemLabel=="string"?n.itemLabel:void 0,o,o("layout_editor.array_editor.item")),[p,f]=u.useState(null),y=s?(t.props??{})[s]:void 0,g=Array.isArray(y)?y:[],m=u.useCallback(S=>{if(!s)return;const E={...t.props??{}};E[s]=S,r({...t,props:E})},[s,t,r]),h=u.useCallback(()=>{const S=n?.newItem;return S&&typeof S=="object"?JSON.parse(JSON.stringify(S)):{[a]:"",[l]:[]}},[n,a,l]),v=u.useCallback(()=>{m([...g,h()])},[m,g,h]),b=u.useCallback(S=>{m(g.filter((E,C)=>C!==S)),f(null)},[m,g]),x=u.useCallback((S,E)=>{const C=S+E;if(C<0||C>=g.length)return;const N=[...g];[N[S],N[C]]=[N[C],N[S]],m(N)},[m,g]),k=u.useCallback((S,E)=>{const C=g.map((N,$)=>$===S?{...N,[a]:E}:N);m(C)},[g,m,a]),w=u.useCallback(S=>({type:"composite",name:"CardGridCell",children:Array.isArray(S[l])?S[l]:[]}),[l]),_=u.useCallback(S=>E=>{const C=Array.isArray(E.children)?E.children:[],N=g.map(($,A)=>A===S?{...$,[l]:C}:$);m(N)},[g,m,l]);return s?pz(y)||y!==void 0&&!Array.isArray(y)?i.jsx("div",{className:"g7le-node-editor g7le-node-editor--cell-tree","data-testid":"g7le-cell-tree-bound",style:Cz,children:o("layout_editor.array_editor.bound_degraded")}):i.jsxs("div",{className:"g7le-node-editor g7le-node-editor--cell-tree","data-testid":"g7le-cell-tree-editor",style:hz,children:[i.jsx("div",{style:yz,children:o("layout_editor.cell_tree_editor.columns_title")}),g.length===0&&i.jsx("div",{"data-testid":"g7le-cell-tree-empty",style:Sh,children:o("layout_editor.array_editor.empty")}),g.map((S,E)=>{const C=typeof S[a]=="string"?S[a]:"",N=p===E;return i.jsxs("div",{"data-testid":`g7le-cell-tree-row-${E}`,style:bz,children:[i.jsxs("div",{style:mz,children:[i.jsxs("span",{style:vz,children:["#",E+1]}),i.jsx("input",{type:"text","data-testid":`g7le-cell-tree-id-${E}`,value:C,placeholder:o("layout_editor.cell_tree_editor.id_placeholder"),onChange:$=>k(E,$.target.value),style:_z}),i.jsx("button",{type:"button","data-testid":`g7le-cell-tree-up-${E}`,title:o("layout_editor.array_editor.move_up"),disabled:E===0,onClick:()=>x(E,-1),style:kh,children:"↑"}),i.jsx("button",{type:"button","data-testid":`g7le-cell-tree-down-${E}`,title:o("layout_editor.array_editor.move_down"),disabled:E===g.length-1,onClick:()=>x(E,1),style:kh,children:"↓"}),i.jsx("button",{type:"button","data-testid":`g7le-cell-tree-remove-${E}`,title:o("layout_editor.array_editor.remove"),onClick:()=>b(E),style:xz,children:"✕"})]}),i.jsx("button",{type:"button","data-testid":`g7le-cell-tree-toggle-${E}`,onClick:()=>f(N?null:E),style:wz,children:o(N?"layout_editor.cell_tree_editor.collapse_cells":"layout_editor.cell_tree_editor.expand_cells")}),N&&i.jsx("div",{"data-testid":`g7le-cell-tree-cells-${E}`,style:kz,children:i.jsx(jg,{...e,node:w(S),params:{childComponent:d},onPatchNode:_(E)})})]},E)}),i.jsx("button",{type:"button","data-testid":"g7le-cell-tree-add",onClick:v,style:Sz,children:o("layout_editor.cell_tree_editor.add_column",{item:c})})]}):i.jsx("div",{className:"g7le-node-editor g7le-node-editor--cell-tree","data-testid":"g7le-cell-tree-misconfigured",style:Sh,children:o("layout_editor.array_editor.no_array_prop")})}const hz={display:"flex",flexDirection:"column",gap:8,width:"100%",marginBottom:12},yz={fontSize:12,fontWeight:700,color:"#0f172a",marginBottom:2},bz={display:"flex",flexDirection:"column",gap:6,padding:8,border:"1px solid #e2e8f0",borderRadius:8,background:"#f8fafc"},mz={display:"flex",gap:4,alignItems:"center"},vz={fontSize:11,fontWeight:700,color:"#64748b",flexShrink:0},_z={flex:1,minWidth:0,padding:"4px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6},kh={padding:"2px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},xz={padding:"2px 6px",fontSize:12,border:"1px solid #fecaca",borderRadius:6,background:"#fff",color:"#dc2626",cursor:"pointer"},wz={padding:"3px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#2563eb",cursor:"pointer",alignSelf:"flex-start"},kz={paddingLeft:8,borderLeft:"2px solid #e2e8f0"},Sz={padding:"4px 8px",fontSize:12,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",alignSelf:"flex-start"},Sh={fontSize:11,color:"#94a3b8",fontStyle:"italic"},Cz={fontSize:11,color:"#b45309",fontStyle:"italic",padding:"4px 2px"};function $z(e,t,n){const o=Be(e,t);if(!n||n.length===0)return{measured:[],grid:o};const r=[];for(const s of o.rows)for(const a of s.cells){const d=Ra(e,a,t).map(p=>`children.${p}`).join("."),c=n.find(p=>p.path===d||p.path.endsWith("."+d));c&&r.push({ref:a,box:{top:c.top,left:c.left,width:c.width,height:c.height}})}return{measured:r,grid:o}}const cl=26,ul=50,pl=6,Ch=44,$h=18,Eh="g7le-table-inplace-empty-cell-style";function Ez(){if(typeof document>"u"||document.getElementById(Eh))return;const e=document.createElement("style");e.id=Eh,e.textContent=".g7le-preview-frame td[data-editor-name],.g7le-preview-frame th[data-editor-name]{min-width:56px;height:38px;}.g7le-preview-frame table[data-editor-name]{min-width:120px;}",document.head.appendChild(e)}function Iz({node:e,params:t,nodeBox:n,cellBoxes:o,selectedCellPath:r,colorScheme:s,t:a,onPatchNode:l,onRequestInlineEdit:d}){const c=u.useMemo(()=>t&&typeof t=="object"?t:null,[t]),{measured:p,grid:f}=u.useMemo(()=>$z(e,c,o),[e,c,o]),[y,g]=u.useState(null),[m,h]=u.useState(null),v=u.useMemo(()=>{if(!r)return null;for(const j of f.rows)for(const oe of j.cells){const ue=Ra(e,oe,c).map(J=>`children.${J}`).join(".");if(r===ue||r.startsWith(ue+"."))return{r:oe.gridRow,c:oe.gridCol}}return null},[r,f,e,c]),b=u.useMemo(()=>y&&Ue(f,y.r,y.c)?y:v,[y,v,f]),[x,k]=u.useState(!1),[w,_]=u.useState(s==="dark"?"dark":"light"),[S,E]=u.useState(null),[C,N]=u.useState(null),$=u.useRef(null),A=u.useCallback(()=>{$.current!==null&&(clearTimeout($.current),$.current=null)},[]),z=u.useCallback(()=>{A(),$.current=setTimeout(()=>{E(null),N(null),$.current=null},240)},[A]);u.useEffect(()=>()=>A(),[A]),u.useEffect(()=>{Ez()},[]);const D=u.useCallback(j=>l(j),[l]),O=u.useMemo(()=>b?Ue(f,b.r,b.c):null,[f,b]),M=u.useMemo(()=>O&&typeof O.cell.props?.className=="string"?O.cell.props.className:"",[O]),B=u.useCallback(j=>D(el(e,c,j)),[e,c,D]),T=u.useCallback(j=>D(Gg(e,c,j)),[e,c,D]),H=u.useCallback((j,oe)=>D(Yg(e,c,j,oe)),[e,c,D]),L=u.useCallback(j=>D(Jg(e,c,j)),[e,c,D]),I=u.useCallback(j=>D(Zg(e,c,j)),[e,c,D]),P=u.useCallback((j,oe)=>D(Xg(e,c,j,oe)),[e,c,D]),F=u.useCallback(j=>{if(!O)return;const oe=O.gridRow,ue=O.gridCol,J=j==="down"?oe+O.rowSpan:oe+O.rowSpan-1,le=j==="right"?ue+O.colSpan:ue+O.colSpan-1;if(J>=f.rows.length||le>=f.colCount)return;const Ie=tl(e,c,oe,ue,J,le);Ie.ok&&D(Ie.table)},[O,f,e,c,D]),G=!!(O&&O.gridCol+O.colSpan!b||!m||!Ue(f,m.r,m.c)?null:{r0:Math.min(b.r,m.r),c0:Math.min(b.c,m.c),r1:Math.max(b.r,m.r),c1:Math.max(b.c,m.c)},[b,m,f]),Z=u.useMemo(()=>{if(!Y)return[];const j=new Set,oe=[];for(const ue of f.rows)for(const J of ue.cells)if(J.gridRow>=Y.r0&&J.gridRow<=Y.r1&&J.gridCol>=Y.c0&&J.gridCol<=Y.c1){const le=`${J.gridRow}:${J.gridCol}`;j.has(le)||(j.add(le),oe.push({r:J.gridRow,c:J.gridCol}))}return oe},[Y,f]),se=Z.length>1,re=u.useCallback(()=>{if(!Y)return;const j=tl(e,c,Y.r0,Y.c0,Y.r1,Y.c1);j.ok&&(D(j.table),h(null))},[Y,e,c,D]),U=u.useCallback(()=>{b&&D(Qg(e,c,b.r,b.c))},[e,c,b,D]),ee=u.useMemo(()=>{const j=c?.cellBorder;return j&&typeof j=="object"?j:null},[c]),fe=u.useMemo(()=>{const j=c?.cellBackground;return j&&typeof j=="object"?j:null},[c]),K=u.useMemo(()=>{const j=c?.cellPadding;return j&&typeof j=="object"?j:null},[c]),ne=u.useMemo(()=>{if(x){const j=new Set,oe=[];for(const ue of f.rows)for(const J of ue.cells){const le=`${J.gridRow}:${J.gridCol}`;j.has(le)||(j.add(le),oe.push({r:J.gridRow,c:J.gridCol}))}return oe}return se?Z:b?[{r:b.r,c:b.c}]:[]},[x,se,Z,b,f]),me=u.useCallback(j=>{if(ne.length===0)return;let oe=e;for(const ue of ne){const J=(ee?.colors??[]).map(Ie=>Ie.token),le=j.split(/\s+/).filter(Ie=>Ie&&!J.includes(Ie)).join(" ");oe=ki(oe,c,ue.r,ue.c,le)}D(oe)},[e,c,ne,ee,D]),Ce=u.useMemo(()=>{const j=(ee?.sides??[]).find(J=>J.value==="all")??(ee?.sides??[])[0],oe=(ee?.widths??[]).find(J=>J.value!=="none");if(!j||!oe)return[];const ue=oe.suffix??"";return[ue?`${j.prefix}${ue}`:j.prefix]},[ee]),ze=u.useCallback((j,oe,ue)=>{if(Ce.length===0)return j;const J=(ee?.sides??[]).map(Oe=>Oe.prefix),le=Ue(Be(j,c),oe,ue),dt=(typeof le?.cell?.props?.className=="string"?le.cell.props.className:"").split(/\s+/).filter(Boolean);if(dt.some(Oe=>J.some(He=>Oe===He||Oe.startsWith(He+"-")&&/^-?\d+$/.test(Oe.slice(He.length+1)))))return j;const ot=[...dt,...Ce].join(" ").trim();return ki(j,c,oe,ue,ot)},[Ce,ee,c]),Se=u.useCallback((j,oe)=>{if(j.length===0)return;const ue=new Set(j.map(le=>`${le.r}:${le.c}`));let J=e;for(const le of j){if(J=ze(J,le.r,le.c),J=Jt(J,c,le.r,le.c,"borderColor",oe),le.r>0&&!ue.has(`${le.r-1}:${le.c}`)){const Ie=Ue(Be(J,c),le.r-1,le.c);Ie&&(J=Jt(J,c,Ie.gridRow,Ie.gridCol,"borderBottomColor",oe))}if(le.c>0&&!ue.has(`${le.r}:${le.c-1}`)){const Ie=Ue(Be(J,c),le.r,le.c-1);Ie&&(J=Jt(J,c,Ie.gridRow,Ie.gridCol,"borderRightColor",oe))}}D(J)},[e,c,D,ze]),qe=u.useMemo(()=>(ee?.colors??[]).map(j=>j.token).filter(Boolean),[ee]),De=u.useCallback(j=>{if(ne.length===0||qe.length===0)return;let oe=e;for(const ue of ne)oe=ze(oe,ue.r,ue.c),oe=po(oe,c,ue.r,ue.c,j,qe,w);D(oe)},[ne,e,c,D,ze,qe,w]),Pe=u.useCallback(j=>Se(ne,j),[Se,ne]),Qe=u.useMemo(()=>{if(w==="dark")return"";const j=O?.cell.props?.style;return typeof j?.borderColor=="string"?j.borderColor:""},[O,w]),Me=u.useCallback((j,oe)=>{if(ne.length===0)return;let ue=e;for(const J of ne)ue=Jt(ue,c,J.r,J.c,j,oe);D(ue)},[ne,e,c,D]),Re=u.useMemo(()=>(fe?.colors??[]).map(j=>j.token).filter(j=>!!j),[fe]),ve=u.useCallback(j=>{if(ne.length===0||Re.length===0)return;let oe=e;for(const ue of ne)oe=po(oe,c,ue.r,ue.c,j,Re,w);D(oe)},[ne,e,c,D,Re,w]),te=u.useCallback(j=>Me("backgroundColor",j),[Me]),he=u.useCallback(()=>{if(ne.length===0)return;let j=e;for(const oe of ne)j=po(j,c,oe.r,oe.c,void 0,Re,w),w==="light"&&(j=Jt(j,c,oe.r,oe.c,"backgroundColor",void 0));D(j)},[ne,e,c,D,Re,w]),Q=u.useCallback(j=>Me("padding",j),[Me]),ie=u.useMemo(()=>{if(w==="dark")return"";const j=O?.cell.props?.style;return typeof j?.backgroundColor=="string"?j.backgroundColor:""},[O,w]),_e=u.useMemo(()=>typeof O?.cell.props?.className=="string"?O.cell.props.className:"",[O]),ge=u.useMemo(()=>{const j=O?.cell.props?.style;return typeof j?.padding=="string"?j.padding:""},[O]),Ae=u.useCallback(j=>{const oe=j.currentTarget,ue=oe.style.pointerEvents;oe.style.pointerEvents="none";const J=document.elementFromPoint(j.clientX,j.clientY);oe.style.pointerEvents=ue;const le=J?.closest?.("[data-dnd-handle-path]");le&&le.dispatchEvent(new PointerEvent("pointerdown",{bubbles:!0,cancelable:!0,clientX:j.clientX,clientY:j.clientY,pointerId:j.pointerId,pointerType:j.pointerType,button:j.button,isPrimary:!0}))},[]),Ne=u.useMemo(()=>{const j=new Map;for(const oe of p)j.has(oe.ref.gridCol)||j.set(oe.ref.gridCol,oe.box);return j},[p]),Le=u.useMemo(()=>{const j=new Map;for(const oe of p)j.has(oe.ref.gridRow)||j.set(oe.ref.gridRow,oe.box);return j},[p]);if(p.length===0)return null;const q=!!(O&&(O.colSpan>1||O.rowSpan>1)),ae=C!==null?C:b?.c??null,pe=S!==null?S:b?.r??null,$e=n.top-cl-pl-Ch,be=n.left-ul-pl-Ch;return i.jsxs("div",{className:"g7le-table-inplace","data-testid":"g7le-table-inplace",style:{position:"absolute",inset:0,pointerEvents:"none",zIndex:i7},children:[p.map(j=>{const oe=b&&b.r===j.ref.gridRow&&b.c===j.ref.gridCol,ue=Y&&j.ref.gridRow>=Y.r0&&j.ref.gridRow<=Y.r1&&j.ref.gridCol>=Y.c0&&j.ref.gridCol<=Y.c1;return i.jsx("div",{"data-testid":`g7le-inplace-cell-${j.ref.gridRow}-${j.ref.gridCol}`,role:"button",tabIndex:-1,"aria-label":a("layout_editor.table_inplace.select_cell"),onMouseEnter:()=>{A(),E(j.ref.gridRow),N(j.ref.gridCol)},onMouseLeave:()=>{z()},onPointerDown:J=>Ae(J),onClick:J=>{if(J.stopPropagation(),J.shiftKey&&(y||b))h({r:j.ref.gridRow,c:j.ref.gridCol});else{g({r:j.ref.gridRow,c:j.ref.gridCol}),h(null);const le=Ra(e,j.ref,c).map(Ie=>`children.${Ie}`).join(".");d?.(le)}},style:{position:"absolute",top:j.box.top,left:j.box.left,width:j.box.width,height:j.box.height,minWidth:$h,minHeight:$h,boxSizing:"border-box",pointerEvents:"auto",cursor:"pointer",outline:oe?"2px solid #2563eb":"none",outlineOffset:-2,background:ue&&!oe?"rgba(37,99,235,0.14)":"transparent"}},`cell-${j.ref.gridRow}-${j.ref.gridCol}`)}),i.jsx("div",{"data-testid":"g7le-inplace-col-rail",onMouseEnter:A,onMouseLeave:z,style:{position:"absolute",top:$e,left:n.left,width:n.width,height:cl,pointerEvents:"none"},children:ae!==null&&Ne.has(ae)&&(()=>{const j=ae,oe=Ne.get(j),ue=_i(f,j,-1),J=_i(f,j,1),le=oe.left-n.left;return i.jsxs("div",{"data-testid":`g7le-inplace-colgutter-${j}`,onMouseEnter:()=>{A(),N(j)},onMouseLeave:z,style:{position:"absolute",top:0,left:le,width:oe.width,minWidth:96,height:cl,display:"flex",gap:3,justifyContent:"center",alignItems:"center",pointerEvents:"auto"},children:[i.jsx("button",{type:"button",title:a("layout_editor.table_inplace.move_col_left"),disabled:!ue.ok,onClick:()=>P(j,-1),"data-testid":`g7le-inplace-col-left-${j}`,style:ue.ok?vn:Ci,children:"←"}),i.jsx("button",{type:"button",title:a("layout_editor.table_inplace.add_col"),onClick:()=>L(j),"data-testid":`g7le-inplace-col-add-${j}`,style:vn,children:"+"}),i.jsx("button",{type:"button",title:a("layout_editor.table_inplace.remove_col"),disabled:f.colCount<=1,onClick:()=>I(j),"data-testid":`g7le-inplace-col-remove-${j}`,style:Ih,children:"✕"}),i.jsx("button",{type:"button",title:a("layout_editor.table_inplace.move_col_right"),disabled:!J.ok,onClick:()=>P(j,1),"data-testid":`g7le-inplace-col-right-${j}`,style:J.ok?vn:Ci,children:"→"})]},`colgut-${j}`)})()}),i.jsx("div",{"data-testid":"g7le-inplace-row-rail",style:{position:"absolute",top:n.top,left:be,width:ul,height:n.height,pointerEvents:"none"},children:pe!==null&&Le.has(pe)&&(()=>{const j=pe,oe=Le.get(j),ue=vi(f,j,-1),J=vi(f,j,1),le=oe.top-n.top;return i.jsxs("div",{"data-testid":`g7le-inplace-rowgutter-${j}`,onMouseEnter:()=>{A(),E(j)},onMouseLeave:z,style:{position:"absolute",top:le,left:0,width:ul,height:oe.height,minHeight:48,display:"grid",gridTemplateColumns:"repeat(2, 22px)",gridAutoRows:"22px",gap:3,justifyContent:"center",alignContent:"center",pointerEvents:"auto"},children:[i.jsx("button",{type:"button",title:a("layout_editor.table_inplace.move_row_up"),disabled:!ue.ok,onClick:()=>H(j,-1),"data-testid":`g7le-inplace-row-up-${j}`,style:ue.ok?vn:Ci,children:"↑"}),i.jsx("button",{type:"button",title:a("layout_editor.table_inplace.add_row"),onClick:()=>B(j),"data-testid":`g7le-inplace-row-add-${j}`,style:vn,children:"+"}),i.jsx("button",{type:"button",title:a("layout_editor.table_inplace.remove_row"),disabled:f.rows.length<=1,onClick:()=>T(j),"data-testid":`g7le-inplace-row-remove-${j}`,style:Ih,children:"✕"}),i.jsx("button",{type:"button",title:a("layout_editor.table_inplace.move_row_down"),disabled:!J.ok,onClick:()=>H(j,1),"data-testid":`g7le-inplace-row-down-${j}`,style:J.ok?vn:Ci,children:"↓"})]},`rowgut-${j}`)})()}),i.jsxs("div",{"data-testid":"g7le-inplace-corner-add",style:{position:"absolute",top:n.top+n.height+pl,left:n.left,display:"flex",flexDirection:"column",gap:6,pointerEvents:"auto",minWidth:200,maxWidth:Math.max(240,n.width),background:"#fff",border:"1px solid #e2e8f0",borderRadius:8,padding:8,boxShadow:"0 2px 8px rgba(0,0,0,0.12)"},children:[i.jsxs("div",{style:{display:"flex",gap:6,flexWrap:"wrap"},children:[i.jsxs("button",{type:"button","data-testid":"g7le-inplace-add-row-bottom",onClick:()=>B(f.rows.length-1),style:Ah,children:["+ ",a("layout_editor.table_inplace.add_row")]}),i.jsxs("button",{type:"button","data-testid":"g7le-inplace-add-col-right",onClick:()=>L(f.colCount-1),style:Ah,children:["+ ",a("layout_editor.table_inplace.add_col")]})]}),!b&&i.jsx("div",{"data-testid":"g7le-inplace-select-hint",style:{fontSize:11,color:"#94a3b8",borderTop:"1px solid #f1f5f9",paddingTop:6},children:a("layout_editor.table_inplace.select_cell_hint")}),b&&i.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:6,borderTop:"1px solid #f1f5f9",paddingTop:6},children:[se&&i.jsx("div",{"data-testid":"g7le-inplace-range-count",style:{fontSize:11,color:"#2563eb"},children:a("layout_editor.table_inplace.range_selected",{count:Z.length})}),i.jsxs("div",{style:{display:"flex",gap:6,flexWrap:"wrap",alignItems:"center"},children:[se?i.jsx("button",{type:"button","data-testid":"g7le-inplace-merge-range",onClick:re,style:tr,children:a("layout_editor.table_inplace.merge_range")}):i.jsxs(i.Fragment,{children:[i.jsx("button",{type:"button","data-testid":"g7le-inplace-merge-right",disabled:!G,onClick:()=>F("right"),style:G?tr:fl,children:a("layout_editor.table_inplace.merge_right")}),i.jsx("button",{type:"button","data-testid":"g7le-inplace-merge-down",disabled:!V,onClick:()=>F("down"),style:V?tr:fl,children:a("layout_editor.table_inplace.merge_down")})]}),i.jsx("button",{type:"button","data-testid":"g7le-inplace-unmerge",disabled:!q,onClick:U,style:q?tr:fl,children:a("layout_editor.table_inplace.unmerge")})]}),i.jsxs("div",{"data-testid":"g7le-inplace-cell-color",style:{borderTop:"1px solid #f1f5f9",paddingTop:6,display:"flex",flexDirection:"column",gap:6},children:[i.jsxs("label",{style:{display:"flex",alignItems:"center",gap:6,fontSize:11,color:"#475569",cursor:"pointer"},children:[i.jsx("input",{type:"checkbox","data-testid":"g7le-inplace-border-all-toggle",checked:x,onChange:j=>k(j.target.checked)}),a("layout_editor.table_inplace.border_apply_all")]}),i.jsx(ch,{colorScheme:w,onChange:_,t:a}),ee&&i.jsx("div",{"data-testid":"g7le-inplace-cell-border",children:i.jsx(oh,{className:x?"":M,catalog:ee,t:a,onChange:me,colorScheme:w,onPresetToken:De,colorStyle:x?"":Qe,onColorStyle:Pe})}),i.jsx("div",{"data-testid":"g7le-inplace-cell-fill",children:i.jsx(ah,{colorStyle:x?"":ie,className:x?"":_e,catalog:fe,t:a,colorScheme:w,onPresetToken:ve,onCustomColor:te,onClear:he})})]}),i.jsx("div",{"data-testid":"g7le-inplace-cell-padding",style:{borderTop:"1px solid #f1f5f9",paddingTop:6},children:i.jsx(lh,{paddingStyle:x?"":ge,catalog:K,t:a,onChange:Q})})]})]})]})}const vn={width:22,height:22,lineHeight:"20px",padding:0,fontSize:13,border:"1px solid #cbd5e1",borderRadius:5,background:"#fff",color:"#475569",cursor:"pointer",boxShadow:"0 1px 3px rgba(0,0,0,0.18)"},Ih={...vn,border:"1px solid #fecaca",color:"#dc2626"},Ci={...vn,cursor:"not-allowed",opacity:.4,color:"#94a3b8",borderColor:"#e2e8f0"},tr={padding:"4px 10px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",boxShadow:"0 1px 2px rgba(0,0,0,0.12)"},fl={...tr,cursor:"not-allowed",opacity:.4},Ah={padding:"3px 8px",fontSize:11,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"};let Th=!1;function Az(){Th||(Ln("children",jg),Ln("table",x8),Ln("array",_h),Ln("array-group",sz),Ln("array-cell-tree",gz),La("table",Iz),Th=!0)}function Tz(){if(typeof window>"u")return;const e=window.G7Core;if(!e)return;const t=e.layoutEditor,n=Array.isArray(t?.__queue)?t.__queue:[],o=Array.isArray(t?.__readyCallbacks)?t.__readyCallbacks:[];e.layoutEditor={registerWidget:Xe,registerNodeEditor:Ln,registerCanvasOverlay:La,onReady:r=>{typeof r=="function"&&r()}};for(const r of n){const[s,...a]=r;try{s==="widget"?Xe(a[0],a[1]):s==="nodeEditor"?Ln(a[0],a[1]):s==="canvasOverlay"&&La(a[0],a[1])}catch{}}for(const r of o)try{r()}catch{}}ON(),Az(),Tz();async function Dz(e){if(typeof fetch!="function")return[];try{const t=await fetch(`/api/admin/templates/${encodeURIComponent(e)}/editor/permission-candidates.json`,{credentials:"same-origin",headers:je()});if(!t.ok)return[];const o=(await t.json().catch(()=>null))?.data?.permissions;if(!Array.isArray(o))return[];const r=[];for(const s of o)if(s&&typeof s=="object"){const a=s.key,l=s.name;typeof a=="string"&&a.length>0&&r.push({value:a,label:typeof l=="string"&&l?l:a})}return r}catch{return[]}}function Nz({templateIdentifier:e}){iD({templateIdentifier:e}),sD(e);const{state:t,dispatch:n}=Je(),{t:o}=tt(),r=go(),s=vp(),a=pD(),l=t.selectedRoute?.layoutName??null,d=yD(e,l),c=t.editMode==="extension",p=u.useMemo(()=>c?gD(a):s,[c,a,s]),[f,y]=u.useState(null),[g,m]=u.useState(null),[h,v]=u.useState([]),[b,x]=u.useState("user"),[k,w]=u.useState(null),[_,S]=u.useState(null),[E,C]=u.useState([]);u.useEffect(()=>{let te=!1;return(async()=>{try{const he=await fetch(`/api/templates/${encodeURIComponent(e)}/components.json`,{credentials:"same-origin",headers:{Accept:"application/json"}});if(!he.ok)return;const Q=await he.json().catch(()=>null);!te&&Q&&typeof Q=="object"&&y(Q)}catch{}})(),()=>{te=!0}},[e]),u.useEffect(()=>{let te=!1;return(async()=>{const{spec:he,sampleGlobalSources:Q}=await Yy({templateIdentifier:e,coreSeed:bD()});te||(m(he),v(Q),Mz(e,he,Q))})(),()=>{te=!0}},[e]),u.useEffect(()=>{let te=!1;return(async()=>{if(typeof fetch=="function")try{const he=await fetch(`/api/admin/templates/${encodeURIComponent(e)}`,{credentials:"same-origin",headers:je()});if(!he.ok)return;const ie=(await he.json().catch(()=>null))?.data,_e=ie?.type;te||((_e==="admin"||_e==="user")&&x(_e),typeof ie?.name=="string"&&ie.name&&w(ie.name),typeof ie?.version=="string"&&ie.version&&S(ie.version))}catch{}})(),()=>{te=!0}},[e]),u.useEffect(()=>{let te=!1;return(async()=>{if(typeof fetch=="function")try{const he=await fetch("/api/admin/templates/?per_page=100&page=1",{credentials:"same-origin",headers:je()});if(!he.ok)return;const ie=(await he.json().catch(()=>null))?.data?.data;if(!te&&Array.isArray(ie)){const _e=[];for(const ge of ie){if(!ge||typeof ge!="object")continue;const Ae=ge.identifier;if(typeof Ae!="string"||!Ae||ge.is_pending===!0)continue;const Le=ge.type==="admin"?"admin":"user",q=ge.name,ae=ge.version;_e.push({identifier:Ae,name:typeof q=="string"?q:Ae,version:typeof ae=="string"?ae:"",type:Le})}C(_e)}}catch{}})(),()=>{te=!0}},[e]);const N=g?.nesting??null,$=t.selectedRoute?.path??null,A=t.editMode,z=t.selectedRoute?.layoutName??(c?a.document?.hostLayoutName??null:null),D=t.routeTree,O=u.useMemo(()=>{if(A!=="extension"||!z)return null;const te=hl(D,z);return te||`__base__/${z}`},[A,z,D]),M=c&&t.returnRoute===null&&!!z;u.useEffect(()=>{if(!M||!z)return;const te=hl(D,z);te&&n({type:"SET_RETURN_ROUTE",route:{path:te,layoutName:z}})},[M,z,D,n]),u.useEffect(()=>{const te=B4(A,$,O),he=W4(g?.states?.groups,te);n({type:"SET_AVAILABLE_STATES",states:he,activeStateId:j4(he)})},[g,A,$,O,n]);const[B,T]=u.useState([]);u.useEffect(()=>{let te=!1;return(async()=>{const he=await Dz(e);te||T(he)})(),()=>{te=!0}},[e]),aD(),lD();const H=u.useCallback(te=>{const he=t.routeTree,Q=te.split("?")[0]?.split("#")[0]??te;return zz(he,Q)?"route_in_tree":"route_not_in_tree"},[t.routeTree]),L=u.useCallback(te=>{const he=te.split("?")[0]?.split("#")[0]??te,Q=gl(t.routeTree,he);Q&&(p.isDirty&&!(!(typeof window<"u")||window.confirm(o("layout_editor.chrome.dirty_guard.message")))||n({type:"SELECT_ROUTE",route:{path:Q.path,layoutName:Q.layoutName}}))},[t.routeTree,p.isDirty,n]),I=u.useRef([]),P=u.useCallback(te=>{I.current=te},[]),[F,G]=u.useState(null),V=u.useCallback(()=>G(null),[]),Y=u.useRef(null),Z=u.useRef(null);u.useEffect(()=>{const te=Z.current,he=Y.current;if(!te||!he||typeof ResizeObserver>"u")return;const Q=()=>{he.style.setProperty("--g7le-header-h",`${Math.round(te.getBoundingClientRect().height)}px`)};Q();const ie=new ResizeObserver(Q);return ie.observe(te),()=>ie.disconnect()},[]);const se=u.useCallback(()=>{p.reload()},[p]),re=u.useCallback(()=>{p.isDirty&&!(!(typeof window<"u")||window.confirm(o("layout_editor.chrome.dirty_guard.message")))||window.G7Core?.dispatch?.({handler:"openWindow",params:{path:`/admin/templates/${b}`,target:"_self"}})},[p.isDirty,o,b]),U=u.useCallback(te=>{if(!te||te===e||p.isDirty&&!(typeof window<"u"?window.confirm(o("layout_editor.chrome.dirty_guard.message")):!0))return;const he=Xs(te);window.G7Core?.dispatch?.({handler:"openWindow",params:{path:he,target:"_self"}})},[e,p.isDirty,o]),ee=u.useCallback(()=>{const te=CE(e,t.selectedRoute?.path??null);window.G7Core?.dispatch?.({handler:"openWindow",params:{path:te}})},[e,t.selectedRoute]),fe=u.useCallback(()=>{const te=t.selectedRoute?.layoutName??"";if(!te)return;const he=r.open({ariaLabel:o("layout_editor.attachment_manager.title"),width:720,maxHeightRatio:.82,content:u.createElement(Nl,{templateIdentifier:e,layoutName:te,t:o,onClose:()=>r.close(he)})})},[r,e,t.selectedRoute,o]),K=u.useCallback(()=>{const te=t.selectedRoute?.layoutName??"";if(!te)return;const he=Ly(s.document?.raw??{}),Q=r.open({ariaLabel:o("layout_editor.translation_manager.title"),width:720,maxHeightRatio:.82,content:u.createElement(tm,{templateIdentifier:e,layoutName:te,t:o,referencedKeys:he,onClose:()=>r.close(Q)})})},[r,e,t.selectedRoute,o,s.document]),ne=u.useCallback((te,he)=>{const Q=window.G7Core;if(Q&&typeof Q.t=="function"){const ie=Q.t(te,he);if(ie&&ie!==te&&!ie.startsWith("$t:"))return ie}return o(te,he)},[o]),me=u.useCallback(()=>{if(!t.selectedRoute||t.editMode==="extension"||t.editMode==="iteration_item")return;const te=r.open({ariaLabel:o("layout_editor.page_settings.title",{name:t.selectedRoute.path??""}),width:1080,maxHeightRatio:.88,closeOnEscape:!1,content:u.createElement(o$,{templateIdentifier:e,spec:g,t:o,resolveLabel:ne,permissionCandidates:B,onClose:()=>r.close(te)})})},[r,t.selectedRoute,t.editMode,o,ne,e,g,B]),Ce=u.useCallback(async()=>{const te=await d.createPreview(s.document?.raw??null);if(te.kind!=="success"){const he=te.kind==="network_error"?te.message:o("layout_editor.preview_action.no_document");typeof window<"u"&&window.alert(he)}},[d,s.document,o]),ze=u.useCallback(()=>{const te=t.selectedRoute?.layoutName??"",he=t.selectedRoute?.path??"",Q=t.editMode==="extension"&&he.startsWith("__extension__/")?he.slice(14):null,ie=Q?{kind:"extension",extensionId:Q}:te?{kind:"layout",layoutName:te}:null;if(!ie)return;const _e=r.open({ariaLabel:o("layout_editor.version_history.title"),width:640,maxHeightRatio:.82,content:u.createElement(H$,{templateIdentifier:e,target:ie,t:o,onRestored:async ge=>{typeof ge=="number"&&(ie.kind==="extension"?n({type:"SET_EXTENSION_VERSION",extensionId:ie.extensionId,version:ge}):n({type:"SET_LAYOUT_VERSION",layoutName:ie.layoutName,version:ge})),await p.reload()},onClose:()=>r.close(_e)})})},[r,e,t.selectedRoute,t.editMode,o,p,n]),Se=p.dirtyKeys.size>0||p.isDirty;u.useEffect(()=>{if(!Se)return;const te=he=>{he.preventDefault(),he.returnValue=""};return window.addEventListener("beforeunload",te),()=>window.removeEventListener("beforeunload",te)},[Se]);const qe=u.useCallback(()=>{!p.isDirty||!(!(typeof window<"u")||window.confirm(o("layout_editor.chrome.reset_guard.message")))||p.reload()},[p,o]),De=u.useCallback(async()=>{const te=await p.save({sessionAddedPaths:I.current});G(te),te.kind==="success"&&typeof te.newContentVersion=="number"&&(te.savedExtensionId?n({type:"SET_EXTENSION_VERSION",extensionId:te.savedExtensionId,version:te.newContentVersion}):te.savedLayoutName&&n({type:"SET_LAYOUT_VERSION",layoutName:te.savedLayoutName,version:te.newContentVersion}))},[p,n]),Pe=u.useCallback(()=>{const te=r.open({ariaLabel:o("layout_editor.shortcuts.title"),width:560,maxHeightRatio:.85,content:u.createElement(J$,{t:o,onClose:()=>r.close(te)})})},[r,o]),Qe=u.useMemo(()=>({save:()=>{De()},exit:re,editCode:ee,reset:qe,translations:K,addElement:()=>{t.selectedRoute&&n({type:"TOGGLE_PALETTE"})},help:Pe,preview:()=>{t.selectedRoute?.layoutName&&Ce()}}),[De,re,ee,qe,K,t.selectedRoute,n,Pe,Ce]),[Me,Re]=u.useState(!1);u.useEffect(()=>{const te=window.setInterval(()=>{Re(!!window.__g7LayoutEditorHasSelection)},200);return()=>window.clearInterval(te)},[]),Ju({handlers:Qe,hasSelection:Me});const ve=p;return t.routesError?i.jsx("div",{className:"g7le-chrome g7le-chrome--routes-error","data-testid":"g7le-chrome-routes-error",style:{position:"fixed",inset:0,display:"flex",background:"#f8fafc",color:"#0f172a",fontFamily:'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple SD Gothic Neo", "Noto Sans KR", sans-serif',fontSize:13,lineHeight:1.5,zIndex:9e3},children:i.jsx(ja,{error:t.routesError})}):i.jsx("div",{className:"g7le-chrome","data-testid":"g7le-chrome",ref:Y,style:{position:"static",minHeight:"100vh",background:"#f8fafc",color:"#0f172a",fontFamily:'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple SD Gothic Neo", "Noto Sans KR", sans-serif',fontSize:13,lineHeight:1.5,zIndex:9e3},children:i.jsxs(oy,{value:ve,children:[c&&a.needsHostPicker&&a.document&&i.jsx(hD,{hostLayouts:a.document.hostLayouts,extensionLabel:null,onSelect:a.selectHost}),i.jsxs("div",{className:"g7le-chrome__header","data-testid":"g7le-chrome-header",ref:Z,style:{position:"sticky",top:0,zIndex:20,background:"#f8fafc"},children:[i.jsx(uy,{onSave:()=>{De()},isDirty:p.isDirty,onExit:re,onEditCode:ee,onManageImages:fe,onManageTranslations:K,onPageSettings:me,onReset:qe,onShowShortcuts:Pe,onPreview:Ce,onShowVersions:ze,templateName:k,templateVersion:_,templateList:E,onSwitchTemplate:U}),i.jsx(pE,{result:F,onDismiss:V,onLoadLatest:se}),i.jsxs("div",{className:"g7le-chrome__device-toolbar","data-testid":"g7le-chrome-device-toolbar",style:{display:"flex",justifyContent:"center",padding:"6px 12px",background:"#f1f5f9",borderBottom:"1px solid #e2e8f0"},children:[i.jsx(P4,{}),i.jsx(O4,{})]})]}),i.jsxs("div",{className:"g7le-chrome__body",style:{display:"grid",gridTemplateColumns:"auto 1fr",alignItems:"stretch"},children:[i.jsx(NE,{}),i.jsx(D4,{manifest:f,nesting:N,componentPalette:g?.componentPalette??null,spec:g,permissionCandidates:B,sampleGlobalSources:h,resolveRouteMatch:H,onNavigateToDestination:L,onSessionAddedPathsChange:P})]}),i.jsx(sy,{})]})})}function gl(e,t){for(const n of e){if(n.kind==="route"&&n.path===t)return n;if(n.children&&n.children.length>0){const o=gl(n.children,t);if(o)return o}}return null}function zz(e,t){return gl(e,t)!==null}function hl(e,t){for(const n of e){if(n.kind==="route"&&n.layoutName===t&&typeof n.path=="string")return n.path;if(n.children&&n.children.length>0){const o=hl(n.children,t);if(o)return o}}return null}function Mz(e,t,n){const o=[];Fa({sources:n,isGuestOnly:!1,warn:s=>{const a=/extension '([^']+)' override of core key '([^']+)'/.exec(s);a&&o.push(`${a[1]}:${a[2]}`)}});const r=t?.sampleData?.byDataSourceId?Object.keys(t.sampleData.byDataSourceId).length:0;qh({templateIdentifier:e,mergedSources:[...n.map(s=>({kind:s.kind,id:s.id}))],blockCounts:{controls:t?.controls?Object.keys(t.controls).length:0,componentCapabilities:t?.componentCapabilities?Object.keys(t.componentCapabilities).length:0,actionRecipes:t?.actionRecipes?Object.keys(t.actionRecipes).length:0,conditionRecipes:t?.conditionRecipes?Object.keys(t.conditionRecipes).length:0,paletteGroups:t?.componentPalette?.groups?.length??0,stateGroups:t?.states?.groups?.length??0,sampleDataIds:r},sampleGlobalSourceCount:n.length,sampleGlobalConflicts:o,timestamp:Date.now()})}function Dh({templateIdentifier:e,initialLocale:t}){return u.useEffect(()=>()=>{Bh(),jh(),Fh(),Kh(),Vh(),Uh(),Gh(),Yh(),Xh()},[]),i.jsx(ny,{templateIdentifier:e,initialLocale:t,children:i.jsx(iy,{children:i.jsx(Nz,{templateIdentifier:e})})})}if(typeof window<"u"){const e=window.G7Core||(window.G7Core={});if(e.__LayoutEditorChrome=Dh,typeof e.__onChromeReady=="function")try{e.__onChromeReady()}catch{}}ln.LayoutEditorChrome=Dh,Object.defineProperty(ln,Symbol.toStringTag,{value:"Module"})})(this.G7LayoutEditor=this.G7LayoutEditor||{},ReactJSXRuntime,React,ReactDOM); + `},I7={onDragStart(e){let{active:t}=e;return"Picked up draggable item "+t.id+"."},onDragOver(e){let{active:t,over:n}=e;return n?"Draggable item "+t.id+" was moved over droppable area "+n.id+".":"Draggable item "+t.id+" is no longer over a droppable area."},onDragEnd(e){let{active:t,over:n}=e;return n?"Draggable item "+t.id+" was dropped over droppable area "+n.id:"Draggable item "+t.id+" was dropped."},onDragCancel(e){let{active:t}=e;return"Dragging was cancelled. Draggable item "+t.id+" was dropped."}};function A7(e){let{announcements:t=I7,container:n,hiddenTextDescribedById:o,screenReaderInstructions:r=E7}=e;const{announce:s,announcement:a}=S7(),l=Qr("DndLiveRegion"),[d,c]=u.useState(!1);if(u.useEffect(()=>{c(!0)},[]),C7(u.useMemo(()=>({onDragStart(f){let{active:y}=f;s(t.onDragStart({active:y}))},onDragMove(f){let{active:y,over:g}=f;t.onDragMove&&s(t.onDragMove({active:y,over:g}))},onDragOver(f){let{active:y,over:g}=f;s(t.onDragOver({active:y,over:g}))},onDragEnd(f){let{active:y,over:g}=f;s(t.onDragEnd({active:y,over:g}))},onDragCancel(f){let{active:y,over:g}=f;s(t.onDragCancel({active:y,over:g}))}}),[s,t])),!d)return null;const p=u.createElement(u.Fragment,null,u.createElement(w7,{id:o,value:r.draggable}),u.createElement(k7,{id:l,announcement:a}));return n?Sn.createPortal(p,n):p}var it;(function(e){e.DragStart="dragStart",e.DragMove="dragMove",e.DragEnd="dragEnd",e.DragCancel="dragCancel",e.DragOver="dragOver",e.RegisterDroppable="registerDroppable",e.SetDroppableDisabled="setDroppableDisabled",e.UnregisterDroppable="unregisterDroppable"})(it||(it={}));function ti(){}function T7(e,t){return u.useMemo(()=>({sensor:e,options:t??{}}),[e,t])}function D7(){for(var e=arguments.length,t=new Array(e),n=0;n[...t].filter(o=>o!=null),[...t])}const Ht=Object.freeze({x:0,y:0});function N7(e,t){return Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))}function z7(e,t){const n=ei(e);if(!n)return"0 0";const o={x:(n.x-t.left)/t.width*100,y:(n.y-t.top)/t.height*100};return o.x+"% "+o.y+"%"}function M7(e,t){let{data:{value:n}}=e,{data:{value:o}}=t;return n-o}function L7(e,t){let{data:{value:n}}=e,{data:{value:o}}=t;return o-n}function P7(e){let{left:t,top:n,height:o,width:r}=e;return[{x:t,y:n},{x:t+r,y:n},{x:t,y:n+o},{x:t+r,y:n+o}]}function O7(e,t){if(!e||e.length===0)return null;const[n]=e;return n[t]}function B7(e,t){const n=Math.max(t.top,e.top),o=Math.max(t.left,e.left),r=Math.min(t.left+t.width,e.left+e.width),s=Math.min(t.top+t.height,e.top+e.height),a=r-o,l=s-n;if(o{let{collisionRect:t,droppableRects:n,droppableContainers:o}=e;const r=[];for(const s of o){const{id:a}=s,l=n.get(a);if(l){const d=B7(l,t);d>0&&r.push({id:a,data:{droppableContainer:s,value:d}})}}return r.sort(L7)};function j7(e,t){const{top:n,left:o,bottom:r,right:s}=t;return n<=e.y&&e.y<=r&&o<=e.x&&e.x<=s}const H7=e=>{let{droppableContainers:t,droppableRects:n,pointerCoordinates:o}=e;if(!o)return[];const r=[];for(const s of t){const{id:a}=s,l=n.get(a);if(l&&j7(o,l)){const c=P7(l).reduce((f,y)=>f+N7(o,y),0),p=Number((c/4).toFixed(4));r.push({id:a,data:{droppableContainer:s,value:p}})}}return r.sort(M7)};function F7(e,t,n){return{...e,scaleX:t&&n?t.width/n.width:1,scaleY:t&&n?t.height/n.height:1}}function Op(e,t){return e&&t?{x:e.left-t.left,y:e.top-t.top}:Ht}function K7(e){return function(n){for(var o=arguments.length,r=new Array(o>1?o-1:0),s=1;s({...a,top:a.top+e*l.y,bottom:a.bottom+e*l.y,left:a.left+e*l.x,right:a.right+e*l.x}),{...n})}}const V7=K7(1);function Bp(e){if(e.startsWith("matrix3d(")){const t=e.slice(9,-1).split(/, /);return{x:+t[12],y:+t[13],scaleX:+t[0],scaleY:+t[5]}}else if(e.startsWith("matrix(")){const t=e.slice(7,-1).split(/, /);return{x:+t[4],y:+t[5],scaleX:+t[0],scaleY:+t[3]}}return null}function U7(e,t,n){const o=Bp(t);if(!o)return e;const{scaleX:r,scaleY:s,x:a,y:l}=o,d=e.left-a-(1-r)*parseFloat(n),c=e.top-l-(1-s)*parseFloat(n.slice(n.indexOf(" ")+1)),p=r?e.width/r:e.width,f=s?e.height/s:e.height;return{width:p,height:f,top:c,right:d+p,bottom:c+f,left:d}}const q7={ignoreTransform:!1};function Bo(e,t){t===void 0&&(t=q7);let n=e.getBoundingClientRect();if(t.ignoreTransform){const{transform:c,transformOrigin:p}=_t(e).getComputedStyle(e);c&&(n=U7(n,c,p))}const{top:o,left:r,width:s,height:a,bottom:l,right:d}=n;return{top:o,left:r,width:s,height:a,bottom:l,right:d}}function Wp(e){return Bo(e,{ignoreTransform:!0})}function G7(e){const t=e.innerWidth,n=e.innerHeight;return{top:0,left:0,right:t,bottom:n,width:t,height:n}}function Y7(e,t){return t===void 0&&(t=_t(e).getComputedStyle(e)),t.position==="fixed"}function X7(e,t){t===void 0&&(t=_t(e).getComputedStyle(e));const n=/(auto|scroll|overlay)/;return["overflow","overflowX","overflowY"].some(r=>{const s=t[r];return typeof s=="string"?n.test(s):!1})}function aa(e,t){const n=[];function o(r){if(t!=null&&n.length>=t||!r)return n;if(ra(r)&&r.scrollingElement!=null&&!n.includes(r.scrollingElement))return n.push(r.scrollingElement),n;if(!Mo(r)||zp(r)||n.includes(r))return n;const s=_t(e).getComputedStyle(r);return r!==e&&X7(r,s)&&n.push(r),Y7(r,s)?n:o(r.parentNode)}return e?o(e):n}function jp(e){const[t]=aa(e,1);return t??null}function la(e){return!Yr||!e?null:so(e)?e:oa(e)?ra(e)||e===ao(e).scrollingElement?window:Mo(e)?e:null:null}function Hp(e){return so(e)?e.scrollX:e.scrollLeft}function Fp(e){return so(e)?e.scrollY:e.scrollTop}function da(e){return{x:Hp(e),y:Fp(e)}}var lt;(function(e){e[e.Forward=1]="Forward",e[e.Backward=-1]="Backward"})(lt||(lt={}));function Kp(e){return!Yr||!e?!1:e===document.scrollingElement}function Vp(e){const t={x:0,y:0},n=Kp(e)?{height:window.innerHeight,width:window.innerWidth}:{height:e.clientHeight,width:e.clientWidth},o={x:e.scrollWidth-n.width,y:e.scrollHeight-n.height},r=e.scrollTop<=t.y,s=e.scrollLeft<=t.x,a=e.scrollTop>=o.y,l=e.scrollLeft>=o.x;return{isTop:r,isLeft:s,isBottom:a,isRight:l,maxScroll:o,minScroll:t}}const J7={x:.2,y:.2};function Z7(e,t,n,o,r){let{top:s,left:a,right:l,bottom:d}=n;o===void 0&&(o=10),r===void 0&&(r=J7);const{isTop:c,isBottom:p,isLeft:f,isRight:y}=Vp(e),g={x:0,y:0},m={x:0,y:0},h={height:t.height*r.y,width:t.width*r.x};return!c&&s<=t.top+h.height?(g.y=lt.Backward,m.y=o*Math.abs((t.top+h.height-s)/h.height)):!p&&d>=t.bottom-h.height&&(g.y=lt.Forward,m.y=o*Math.abs((t.bottom-h.height-d)/h.height)),!y&&l>=t.right-h.width?(g.x=lt.Forward,m.x=o*Math.abs((t.right-h.width-l)/h.width)):!f&&a<=t.left+h.width&&(g.x=lt.Backward,m.x=o*Math.abs((t.left+h.width-a)/h.width)),{direction:g,speed:m}}function Q7(e){if(e===document.scrollingElement){const{innerWidth:s,innerHeight:a}=window;return{top:0,left:0,right:s,bottom:a,width:s,height:a}}const{top:t,left:n,right:o,bottom:r}=e.getBoundingClientRect();return{top:t,left:n,right:o,bottom:r,width:e.clientWidth,height:e.clientHeight}}function Up(e){return e.reduce((t,n)=>lo(t,da(n)),Ht)}function R7(e){return e.reduce((t,n)=>t+Hp(n),0)}function e2(e){return e.reduce((t,n)=>t+Fp(n),0)}function qp(e,t){if(t===void 0&&(t=Bo),!e)return;const{top:n,left:o,bottom:r,right:s}=t(e);jp(e)&&(r<=0||s<=0||n>=window.innerHeight||o>=window.innerWidth)&&e.scrollIntoView({block:"center",inline:"center"})}const t2=[["x",["left","right"],R7],["y",["top","bottom"],e2]];class ca{constructor(t,n){this.rect=void 0,this.width=void 0,this.height=void 0,this.top=void 0,this.bottom=void 0,this.right=void 0,this.left=void 0;const o=aa(n),r=Up(o);this.rect={...t},this.width=t.width,this.height=t.height;for(const[s,a,l]of t2)for(const d of a)Object.defineProperty(this,d,{get:()=>{const c=l(o),p=r[s]-c;return this.rect[d]+p},enumerable:!0});Object.defineProperty(this,"rect",{enumerable:!1})}}class Wo{constructor(t){this.target=void 0,this.listeners=[],this.removeAll=()=>{this.listeners.forEach(n=>{var o;return(o=this.target)==null?void 0:o.removeEventListener(...n)})},this.target=t}add(t,n,o){var r;(r=this.target)==null||r.addEventListener(t,n,o),this.listeners.push([t,n,o])}}function n2(e){const{EventTarget:t}=_t(e);return e instanceof t?e:ao(e)}function ua(e,t){const n=Math.abs(e.x),o=Math.abs(e.y);return typeof t=="number"?Math.sqrt(n**2+o**2)>t:"x"in t&&"y"in t?n>t.x&&o>t.y:"x"in t?n>t.x:"y"in t?o>t.y:!1}var At;(function(e){e.Click="click",e.DragStart="dragstart",e.Keydown="keydown",e.ContextMenu="contextmenu",e.Resize="resize",e.SelectionChange="selectionchange",e.VisibilityChange="visibilitychange"})(At||(At={}));function Gp(e){e.preventDefault()}function o2(e){e.stopPropagation()}var Ke;(function(e){e.Space="Space",e.Down="ArrowDown",e.Right="ArrowRight",e.Left="ArrowLeft",e.Up="ArrowUp",e.Esc="Escape",e.Enter="Enter",e.Tab="Tab"})(Ke||(Ke={}));const Yp={start:[Ke.Space,Ke.Enter],cancel:[Ke.Esc],end:[Ke.Space,Ke.Enter,Ke.Tab]},r2=(e,t)=>{let{currentCoordinates:n}=t;switch(e.code){case Ke.Right:return{...n,x:n.x+25};case Ke.Left:return{...n,x:n.x-25};case Ke.Down:return{...n,y:n.y+25};case Ke.Up:return{...n,y:n.y-25}}};class Xp{constructor(t){this.props=void 0,this.autoScrollEnabled=!1,this.referenceCoordinates=void 0,this.listeners=void 0,this.windowListeners=void 0,this.props=t;const{event:{target:n}}=t;this.props=t,this.listeners=new Wo(ao(n)),this.windowListeners=new Wo(_t(n)),this.handleKeyDown=this.handleKeyDown.bind(this),this.handleCancel=this.handleCancel.bind(this),this.attach()}attach(){this.handleStart(),this.windowListeners.add(At.Resize,this.handleCancel),this.windowListeners.add(At.VisibilityChange,this.handleCancel),setTimeout(()=>this.listeners.add(At.Keydown,this.handleKeyDown))}handleStart(){const{activeNode:t,onStart:n}=this.props,o=t.node.current;o&&qp(o),n(Ht)}handleKeyDown(t){if(sa(t)){const{active:n,context:o,options:r}=this.props,{keyboardCodes:s=Yp,coordinateGetter:a=r2,scrollBehavior:l="smooth"}=r,{code:d}=t;if(s.end.includes(d)){this.handleEnd(t);return}if(s.cancel.includes(d)){this.handleCancel(t);return}const{collisionRect:c}=o.current,p=c?{x:c.left,y:c.top}:Ht;this.referenceCoordinates||(this.referenceCoordinates=p);const f=a(t,{active:n,context:o.current,currentCoordinates:p});if(f){const y=Rr(f,p),g={x:0,y:0},{scrollableAncestors:m}=o.current;for(const h of m){const v=t.code,{isTop:b,isRight:x,isLeft:k,isBottom:w,maxScroll:_,minScroll:S}=Vp(h),E=Q7(h),C={x:Math.min(v===Ke.Right?E.right-E.width/2:E.right,Math.max(v===Ke.Right?E.left:E.left+E.width/2,f.x)),y:Math.min(v===Ke.Down?E.bottom-E.height/2:E.bottom,Math.max(v===Ke.Down?E.top:E.top+E.height/2,f.y))},N=v===Ke.Right&&!x||v===Ke.Left&&!k,$=v===Ke.Down&&!w||v===Ke.Up&&!b;if(N&&C.x!==f.x){const A=h.scrollLeft+y.x,z=v===Ke.Right&&A<=_.x||v===Ke.Left&&A>=S.x;if(z&&!y.y){h.scrollTo({left:A,behavior:l});return}z?g.x=h.scrollLeft-A:g.x=v===Ke.Right?h.scrollLeft-_.x:h.scrollLeft-S.x,g.x&&h.scrollBy({left:-g.x,behavior:l});break}else if($&&C.y!==f.y){const A=h.scrollTop+y.y,z=v===Ke.Down&&A<=_.y||v===Ke.Up&&A>=S.y;if(z&&!y.x){h.scrollTo({top:A,behavior:l});return}z?g.y=h.scrollTop-A:g.y=v===Ke.Down?h.scrollTop-_.y:h.scrollTop-S.y,g.y&&h.scrollBy({top:-g.y,behavior:l});break}}this.handleMove(t,lo(Rr(f,this.referenceCoordinates),g))}}}handleMove(t,n){const{onMove:o}=this.props;t.preventDefault(),o(n)}handleEnd(t){const{onEnd:n}=this.props;t.preventDefault(),this.detach(),n()}handleCancel(t){const{onCancel:n}=this.props;t.preventDefault(),this.detach(),n()}detach(){this.listeners.removeAll(),this.windowListeners.removeAll()}}Xp.activators=[{eventName:"onKeyDown",handler:(e,t,n)=>{let{keyboardCodes:o=Yp,onActivation:r}=t,{active:s}=n;const{code:a}=e.nativeEvent;if(o.start.includes(a)){const l=s.activatorNode.current;return l&&e.target!==l?!1:(e.preventDefault(),r?.({event:e.nativeEvent}),!0)}return!1}}];function Jp(e){return!!(e&&"distance"in e)}function Zp(e){return!!(e&&"delay"in e)}class pa{constructor(t,n,o){var r;o===void 0&&(o=n2(t.event.target)),this.props=void 0,this.events=void 0,this.autoScrollEnabled=!0,this.document=void 0,this.activated=!1,this.initialCoordinates=void 0,this.timeoutId=null,this.listeners=void 0,this.documentListeners=void 0,this.windowListeners=void 0,this.props=t,this.events=n;const{event:s}=t,{target:a}=s;this.props=t,this.events=n,this.document=ao(a),this.documentListeners=new Wo(this.document),this.listeners=new Wo(o),this.windowListeners=new Wo(_t(a)),this.initialCoordinates=(r=ei(s))!=null?r:Ht,this.handleStart=this.handleStart.bind(this),this.handleMove=this.handleMove.bind(this),this.handleEnd=this.handleEnd.bind(this),this.handleCancel=this.handleCancel.bind(this),this.handleKeydown=this.handleKeydown.bind(this),this.removeTextSelection=this.removeTextSelection.bind(this),this.attach()}attach(){const{events:t,props:{options:{activationConstraint:n,bypassActivationConstraint:o}}}=this;if(this.listeners.add(t.move.name,this.handleMove,{passive:!1}),this.listeners.add(t.end.name,this.handleEnd),t.cancel&&this.listeners.add(t.cancel.name,this.handleCancel),this.windowListeners.add(At.Resize,this.handleCancel),this.windowListeners.add(At.DragStart,Gp),this.windowListeners.add(At.VisibilityChange,this.handleCancel),this.windowListeners.add(At.ContextMenu,Gp),this.documentListeners.add(At.Keydown,this.handleKeydown),n){if(o!=null&&o({event:this.props.event,activeNode:this.props.activeNode,options:this.props.options}))return this.handleStart();if(Zp(n)){this.timeoutId=setTimeout(this.handleStart,n.delay),this.handlePending(n);return}if(Jp(n)){this.handlePending(n);return}}this.handleStart()}detach(){this.listeners.removeAll(),this.windowListeners.removeAll(),setTimeout(this.documentListeners.removeAll,50),this.timeoutId!==null&&(clearTimeout(this.timeoutId),this.timeoutId=null)}handlePending(t,n){const{active:o,onPending:r}=this.props;r(o,t,this.initialCoordinates,n)}handleStart(){const{initialCoordinates:t}=this,{onStart:n}=this.props;t&&(this.activated=!0,this.documentListeners.add(At.Click,o2,{capture:!0}),this.removeTextSelection(),this.documentListeners.add(At.SelectionChange,this.removeTextSelection),n(t))}handleMove(t){var n;const{activated:o,initialCoordinates:r,props:s}=this,{onMove:a,options:{activationConstraint:l}}=s;if(!r)return;const d=(n=ei(t))!=null?n:Ht,c=Rr(r,d);if(!o&&l){if(Jp(l)){if(l.tolerance!=null&&ua(c,l.tolerance))return this.handleCancel();if(ua(c,l.distance))return this.handleStart()}if(Zp(l)&&ua(c,l.tolerance))return this.handleCancel();this.handlePending(l,c);return}t.cancelable&&t.preventDefault(),a(d)}handleEnd(){const{onAbort:t,onEnd:n}=this.props;this.detach(),this.activated||t(this.props.active),n()}handleCancel(){const{onAbort:t,onCancel:n}=this.props;this.detach(),this.activated||t(this.props.active),n()}handleKeydown(t){t.code===Ke.Esc&&this.handleCancel()}removeTextSelection(){var t;(t=this.document.getSelection())==null||t.removeAllRanges()}}const i2={cancel:{name:"pointercancel"},move:{name:"pointermove"},end:{name:"pointerup"}};class fa extends pa{constructor(t){const{event:n}=t,o=ao(n.target);super(t,i2,o)}}fa.activators=[{eventName:"onPointerDown",handler:(e,t)=>{let{nativeEvent:n}=e,{onActivation:o}=t;return!n.isPrimary||n.button!==0?!1:(o?.({event:n}),!0)}}];const s2={move:{name:"mousemove"},end:{name:"mouseup"}};var ga;(function(e){e[e.RightClick=2]="RightClick"})(ga||(ga={}));class a2 extends pa{constructor(t){super(t,s2,ao(t.event.target))}}a2.activators=[{eventName:"onMouseDown",handler:(e,t)=>{let{nativeEvent:n}=e,{onActivation:o}=t;return n.button===ga.RightClick?!1:(o?.({event:n}),!0)}}];const ha={cancel:{name:"touchcancel"},move:{name:"touchmove"},end:{name:"touchend"}};class l2 extends pa{constructor(t){super(t,ha)}static setup(){return window.addEventListener(ha.move.name,t,{capture:!1,passive:!1}),function(){window.removeEventListener(ha.move.name,t)};function t(){}}}l2.activators=[{eventName:"onTouchStart",handler:(e,t)=>{let{nativeEvent:n}=e,{onActivation:o}=t;const{touches:r}=n;return r.length>1?!1:(o?.({event:n}),!0)}}];var jo;(function(e){e[e.Pointer=0]="Pointer",e[e.DraggableRect=1]="DraggableRect"})(jo||(jo={}));var ni;(function(e){e[e.TreeOrder=0]="TreeOrder",e[e.ReversedTreeOrder=1]="ReversedTreeOrder"})(ni||(ni={}));function d2(e){let{acceleration:t,activator:n=jo.Pointer,canScroll:o,draggingRect:r,enabled:s,interval:a=5,order:l=ni.TreeOrder,pointerCoordinates:d,scrollableAncestors:c,scrollableAncestorRects:p,delta:f,threshold:y}=e;const g=u2({delta:f,disabled:!s}),[m,h]=b7(),v=u.useRef({x:0,y:0}),b=u.useRef({x:0,y:0}),x=u.useMemo(()=>{switch(n){case jo.Pointer:return d?{top:d.y,bottom:d.y,left:d.x,right:d.x}:null;case jo.DraggableRect:return r}},[n,r,d]),k=u.useRef(null),w=u.useCallback(()=>{const S=k.current;if(!S)return;const E=v.current.x*b.current.x,C=v.current.y*b.current.y;S.scrollBy(E,C)},[]),_=u.useMemo(()=>l===ni.TreeOrder?[...c].reverse():c,[l,c]);u.useEffect(()=>{if(!s||!c.length||!x){h();return}for(const S of _){if(o?.(S)===!1)continue;const E=c.indexOf(S),C=p[E];if(!C)continue;const{direction:N,speed:$}=Z7(S,C,x,t,y);for(const A of["x","y"])g[A][N[A]]||($[A]=0,N[A]=0);if($.x>0||$.y>0){h(),k.current=S,m(w,a),v.current=$,b.current=N;return}}v.current={x:0,y:0},b.current={x:0,y:0},h()},[t,w,o,h,s,a,JSON.stringify(x),JSON.stringify(g),m,c,_,p,JSON.stringify(y)])}const c2={x:{[lt.Backward]:!1,[lt.Forward]:!1},y:{[lt.Backward]:!1,[lt.Forward]:!1}};function u2(e){let{delta:t,disabled:n}=e;const o=Zr(t);return Po(r=>{if(n||!o||!r)return c2;const s={x:Math.sign(t.x-o.x),y:Math.sign(t.y-o.y)};return{x:{[lt.Backward]:r.x[lt.Backward]||s.x===-1,[lt.Forward]:r.x[lt.Forward]||s.x===1},y:{[lt.Backward]:r.y[lt.Backward]||s.y===-1,[lt.Forward]:r.y[lt.Forward]||s.y===1}}},[n,t,o])}function p2(e,t){const n=t!=null?e.get(t):void 0,o=n?n.node.current:null;return Po(r=>{var s;return t==null?null:(s=o??r)!=null?s:null},[o,t])}function f2(e,t){return u.useMemo(()=>e.reduce((n,o)=>{const{sensor:r}=o,s=r.activators.map(a=>({eventName:a.eventName,handler:t(a.handler,o)}));return[...n,...s]},[]),[e,t])}var Ho;(function(e){e[e.Always=0]="Always",e[e.BeforeDragging=1]="BeforeDragging",e[e.WhileDragging=2]="WhileDragging"})(Ho||(Ho={}));var ya;(function(e){e.Optimized="optimized"})(ya||(ya={}));const Qp=new Map;function g2(e,t){let{dragging:n,dependencies:o,config:r}=t;const[s,a]=u.useState(null),{frequency:l,measure:d,strategy:c}=r,p=u.useRef(e),f=v(),y=Lo(f),g=u.useCallback(function(b){b===void 0&&(b=[]),!y.current&&a(x=>x===null?b:x.concat(b.filter(k=>!x.includes(k))))},[y]),m=u.useRef(null),h=Po(b=>{if(f&&!n)return Qp;if(!b||b===Qp||p.current!==e||s!=null){const x=new Map;for(let k of e){if(!k)continue;if(s&&s.length>0&&!s.includes(k.id)&&k.rect.current){x.set(k.id,k.rect.current);continue}const w=k.node.current,_=w?new ca(d(w),w):null;k.rect.current=_,_&&x.set(k.id,_)}return x}return b},[e,s,n,f,d]);return u.useEffect(()=>{p.current=e},[e]),u.useEffect(()=>{f||g()},[n,f]),u.useEffect(()=>{s&&s.length>0&&a(null)},[JSON.stringify(s)]),u.useEffect(()=>{f||typeof l!="number"||m.current!==null||(m.current=setTimeout(()=>{g(),m.current=null},l))},[l,f,g,...o]),{droppableRects:h,measureDroppableContainers:g,measuringScheduled:s!=null};function v(){switch(c){case Ho.Always:return!1;case Ho.BeforeDragging:return n;default:return!n}}}function ba(e,t){return Po(n=>e?n||(typeof t=="function"?t(e):e):null,[t,e])}function h2(e,t){return ba(e,t)}function y2(e){let{callback:t,disabled:n}=e;const o=Xr(t),r=u.useMemo(()=>{if(n||typeof window>"u"||typeof window.MutationObserver>"u")return;const{MutationObserver:s}=window;return new s(o)},[o,n]);return u.useEffect(()=>()=>r?.disconnect(),[r]),r}function oi(e){let{callback:t,disabled:n}=e;const o=Xr(t),r=u.useMemo(()=>{if(n||typeof window>"u"||typeof window.ResizeObserver>"u")return;const{ResizeObserver:s}=window;return new s(o)},[n]);return u.useEffect(()=>()=>r?.disconnect(),[r]),r}function b2(e){return new ca(Bo(e),e)}function Rp(e,t,n){t===void 0&&(t=b2);const[o,r]=u.useState(null);function s(){r(d=>{if(!e)return null;if(e.isConnected===!1){var c;return(c=d??n)!=null?c:null}const p=t(e);return JSON.stringify(d)===JSON.stringify(p)?d:p})}const a=y2({callback(d){if(e)for(const c of d){const{type:p,target:f}=c;if(p==="childList"&&f instanceof HTMLElement&&f.contains(e)){s();break}}}}),l=oi({callback:s});return rn(()=>{s(),e?(l?.observe(e),a?.observe(document.body,{childList:!0,subtree:!0})):(l?.disconnect(),a?.disconnect())},[e]),o}function m2(e){const t=ba(e);return Op(e,t)}const ef=[];function v2(e){const t=u.useRef(e),n=Po(o=>e?o&&o!==ef&&e&&t.current&&e.parentNode===t.current.parentNode?o:aa(e):ef,[e]);return u.useEffect(()=>{t.current=e},[e]),n}function _2(e){const[t,n]=u.useState(null),o=u.useRef(e),r=u.useCallback(s=>{const a=la(s.target);a&&n(l=>l?(l.set(a,da(a)),new Map(l)):null)},[]);return u.useEffect(()=>{const s=o.current;if(e!==s){a(s);const l=e.map(d=>{const c=la(d);return c?(c.addEventListener("scroll",r,{passive:!0}),[c,da(c)]):null}).filter(d=>d!=null);n(l.length?new Map(l):null),o.current=e}return()=>{a(e),a(s)};function a(l){l.forEach(d=>{const c=la(d);c?.removeEventListener("scroll",r)})}},[r,e]),u.useMemo(()=>e.length?t?Array.from(t.values()).reduce((s,a)=>lo(s,a),Ht):Up(e):Ht,[e,t])}function tf(e,t){t===void 0&&(t=[]);const n=u.useRef(null);return u.useEffect(()=>{n.current=null},t),u.useEffect(()=>{const o=e!==Ht;o&&!n.current&&(n.current=e),!o&&n.current&&(n.current=null)},[e]),n.current?Rr(e,n.current):Ht}function x2(e){u.useEffect(()=>{if(!Yr)return;const t=e.map(n=>{let{sensor:o}=n;return o.setup==null?void 0:o.setup()});return()=>{for(const n of t)n?.()}},e.map(t=>{let{sensor:n}=t;return n}))}function w2(e,t){return u.useMemo(()=>e.reduce((n,o)=>{let{eventName:r,handler:s}=o;return n[r]=a=>{s(a,t)},n},{}),[e,t])}function nf(e){return u.useMemo(()=>e?G7(e):null,[e])}const of=[];function k2(e,t){t===void 0&&(t=Bo);const[n]=e,o=nf(n?_t(n):null),[r,s]=u.useState(of);function a(){s(()=>e.length?e.map(d=>Kp(d)?o:new ca(t(d),d)):of)}const l=oi({callback:a});return rn(()=>{l?.disconnect(),a(),e.forEach(d=>l?.observe(d))},[e]),r}function rf(e){if(!e)return null;if(e.children.length>1)return e;const t=e.children[0];return Mo(t)?t:e}function S2(e){let{measure:t}=e;const[n,o]=u.useState(null),r=u.useCallback(c=>{for(const{target:p}of c)if(Mo(p)){o(f=>{const y=t(p);return f?{...f,width:y.width,height:y.height}:y});break}},[t]),s=oi({callback:r}),a=u.useCallback(c=>{const p=rf(c);s?.disconnect(),p&&s?.observe(p),o(p?t(p):null)},[t,s]),[l,d]=Jr(a);return u.useMemo(()=>({nodeRef:l,rect:n,setRef:d}),[n,l,d])}const C2=[{sensor:fa,options:{}},{sensor:Xp,options:{}}],$2={current:{}},ri={draggable:{measure:Wp},droppable:{measure:Wp,strategy:Ho.WhileDragging,frequency:ya.Optimized},dragOverlay:{measure:Bo}};class Fo extends Map{get(t){var n;return t!=null&&(n=super.get(t))!=null?n:void 0}toArray(){return Array.from(this.values())}getEnabled(){return this.toArray().filter(t=>{let{disabled:n}=t;return!n})}getNodeFor(t){var n,o;return(n=(o=this.get(t))==null?void 0:o.node.current)!=null?n:void 0}}const E2={activatorEvent:null,active:null,activeNode:null,activeNodeRect:null,collisions:null,containerNodeRect:null,draggableNodes:new Map,droppableRects:new Map,droppableContainers:new Fo,over:null,dragOverlay:{nodeRef:{current:null},rect:null,setRef:ti},scrollableAncestors:[],scrollableAncestorRects:[],measuringConfiguration:ri,measureDroppableContainers:ti,windowRect:null,measuringScheduled:!1},sf={activatorEvent:null,activators:[],active:null,activeNodeRect:null,ariaDescribedById:{draggable:""},dispatch:ti,draggableNodes:new Map,over:null,measureDroppableContainers:ti},Ko=u.createContext(sf),af=u.createContext(E2);function I2(){return{draggable:{active:null,initialCoordinates:{x:0,y:0},nodes:new Map,translate:{x:0,y:0}},droppable:{containers:new Fo}}}function A2(e,t){switch(t.type){case it.DragStart:return{...e,draggable:{...e.draggable,initialCoordinates:t.initialCoordinates,active:t.active}};case it.DragMove:return e.draggable.active==null?e:{...e,draggable:{...e.draggable,translate:{x:t.coordinates.x-e.draggable.initialCoordinates.x,y:t.coordinates.y-e.draggable.initialCoordinates.y}}};case it.DragEnd:case it.DragCancel:return{...e,draggable:{...e.draggable,active:null,initialCoordinates:{x:0,y:0},translate:{x:0,y:0}}};case it.RegisterDroppable:{const{element:n}=t,{id:o}=n,r=new Fo(e.droppable.containers);return r.set(o,n),{...e,droppable:{...e.droppable,containers:r}}}case it.SetDroppableDisabled:{const{id:n,key:o,disabled:r}=t,s=e.droppable.containers.get(n);if(!s||o!==s.key)return e;const a=new Fo(e.droppable.containers);return a.set(n,{...s,disabled:r}),{...e,droppable:{...e.droppable,containers:a}}}case it.UnregisterDroppable:{const{id:n,key:o}=t,r=e.droppable.containers.get(n);if(!r||o!==r.key)return e;const s=new Fo(e.droppable.containers);return s.delete(n),{...e,droppable:{...e.droppable,containers:s}}}default:return e}}function T2(e){let{disabled:t}=e;const{active:n,activatorEvent:o,draggableNodes:r}=u.useContext(Ko),s=Zr(o),a=Zr(n?.id);return u.useEffect(()=>{if(!t&&!o&&s&&a!=null){if(!sa(s)||document.activeElement===s.target)return;const l=r.get(a);if(!l)return;const{activatorNode:d,node:c}=l;if(!d.current&&!c.current)return;requestAnimationFrame(()=>{for(const p of[d.current,c.current]){if(!p)continue;const f=_7(p);if(f){f.focus();break}}})}},[o,t,r,a,s]),null}function lf(e,t){let{transform:n,...o}=t;return e!=null&&e.length?e.reduce((r,s)=>s({transform:r,...o}),n):n}function D2(e){return u.useMemo(()=>({draggable:{...ri.draggable,...e?.draggable},droppable:{...ri.droppable,...e?.droppable},dragOverlay:{...ri.dragOverlay,...e?.dragOverlay}}),[e?.draggable,e?.droppable,e?.dragOverlay])}function N2(e){let{activeNode:t,measure:n,initialRect:o,config:r=!0}=e;const s=u.useRef(!1),{x:a,y:l}=typeof r=="boolean"?{x:r,y:r}:r;rn(()=>{if(!a&&!l||!t){s.current=!1;return}if(s.current||!o)return;const c=t?.node.current;if(!c||c.isConnected===!1)return;const p=n(c),f=Op(p,o);if(a||(f.x=0),l||(f.y=0),s.current=!0,Math.abs(f.x)>0||Math.abs(f.y)>0){const y=jp(c);y&&y.scrollBy({top:f.y,left:f.x})}},[t,a,l,o,n])}const ii=u.createContext({...Ht,scaleX:1,scaleY:1});var gn;(function(e){e[e.Uninitialized=0]="Uninitialized",e[e.Initializing=1]="Initializing",e[e.Initialized=2]="Initialized"})(gn||(gn={}));const z2=u.memo(function(t){var n,o,r,s;let{id:a,accessibility:l,autoScroll:d=!0,children:c,sensors:p=C2,collisionDetection:f=W7,measuring:y,modifiers:g,...m}=t;const h=u.useReducer(A2,void 0,I2),[v,b]=h,[x,k]=$7(),[w,_]=u.useState(gn.Uninitialized),S=w===gn.Initialized,{draggable:{active:E,nodes:C,translate:N},droppable:{containers:$}}=v,A=E!=null?C.get(E):null,z=u.useRef({initial:null,translated:null}),D=u.useMemo(()=>{var J;return E!=null?{id:E,data:(J=A?.data)!=null?J:$2,rect:z}:null},[E,A]),O=u.useRef(null),[M,B]=u.useState(null),[T,H]=u.useState(null),L=Lo(m,Object.values(m)),I=Qr("DndDescribedBy",a),P=u.useMemo(()=>$.getEnabled(),[$]),F=D2(y),{droppableRects:G,measureDroppableContainers:V,measuringScheduled:Y}=g2(P,{dragging:S,dependencies:[N.x,N.y],config:F.droppable}),Z=p2(C,E),se=u.useMemo(()=>T?ei(T):null,[T]),re=ue(),U=h2(Z,F.draggable.measure);N2({activeNode:E!=null?C.get(E):null,config:re.layoutShiftCompensation,initialRect:U,measure:F.draggable.measure});const ee=Rp(Z,F.draggable.measure,U),fe=Rp(Z?Z.parentElement:null),K=u.useRef({activatorEvent:null,active:null,activeNode:Z,collisionRect:null,collisions:null,droppableRects:G,draggableNodes:C,draggingNode:null,draggingNodeRect:null,droppableContainers:$,over:null,scrollableAncestors:[],scrollAdjustedTranslate:null}),ne=$.getNodeFor((n=K.current.over)==null?void 0:n.id),me=S2({measure:F.dragOverlay.measure}),Ce=(o=me.nodeRef.current)!=null?o:Z,ze=S?(r=me.rect)!=null?r:ee:null,Se=!!(me.nodeRef.current&&me.rect),qe=m2(Se?null:ee),De=nf(Ce?_t(Ce):null),Pe=v2(S?ne??Z:null),Qe=k2(Pe),Me=lf(g,{transform:{x:N.x-qe.x,y:N.y-qe.y,scaleX:1,scaleY:1},activatorEvent:T,active:D,activeNodeRect:ee,containerNodeRect:fe,draggingNodeRect:ze,over:K.current.over,overlayNodeRect:me.rect,scrollableAncestors:Pe,scrollableAncestorRects:Qe,windowRect:De}),Re=se?lo(se,N):null,ve=_2(Pe),te=tf(ve),he=tf(ve,[ee]),Q=lo(Me,te),ie=ze?V7(ze,Me):null,_e=D&&ie?f({active:D,collisionRect:ie,droppableRects:G,droppableContainers:P,pointerCoordinates:Re}):null,ge=O7(_e,"id"),[Ae,Ne]=u.useState(null),Le=Se?Me:lo(Me,he),q=F7(Le,(s=Ae?.rect)!=null?s:null,ee),ae=u.useRef(null),pe=u.useCallback((J,le)=>{let{sensor:Ie,options:dt}=le;if(O.current==null)return;const pt=C.get(O.current);if(!pt)return;const ot=J.nativeEvent,Oe=new Ie({active:O.current,activeNode:pt,event:ot,options:dt,context:K,onAbort(Ye){if(!C.get(Ye))return;const{onDragAbort:Dt}=L.current,Nt={id:Ye};Dt?.(Nt),x({type:"onDragAbort",event:Nt})},onPending(Ye,Tt,Dt,Nt){if(!C.get(Ye))return;const{onDragPending:xn}=L.current,Zt={id:Ye,constraint:Tt,initialCoordinates:Dt,offset:Nt};xn?.(Zt),x({type:"onDragPending",event:Zt})},onStart(Ye){const Tt=O.current;if(Tt==null)return;const Dt=C.get(Tt);if(!Dt)return;const{onDragStart:Nt}=L.current,_n={activatorEvent:ot,active:{id:Tt,data:Dt.data,rect:z}};Sn.unstable_batchedUpdates(()=>{Nt?.(_n),_(gn.Initializing),b({type:it.DragStart,initialCoordinates:Ye,active:Tt}),x({type:"onDragStart",event:_n}),B(ae.current),H(ot)})},onMove(Ye){b({type:it.DragMove,coordinates:Ye})},onEnd:He(it.DragEnd),onCancel:He(it.DragCancel)});ae.current=Oe;function He(Ye){return async function(){const{active:Dt,collisions:Nt,over:_n,scrollAdjustedTranslate:xn}=K.current;let Zt=null;if(Dt&&xn){const{cancelDrop:jn}=L.current;Zt={activatorEvent:ot,active:Dt,collisions:Nt,delta:xn,over:_n},Ye===it.DragEnd&&typeof jn=="function"&&await Promise.resolve(jn(Zt))&&(Ye=it.DragCancel)}O.current=null,Sn.unstable_batchedUpdates(()=>{b({type:Ye}),_(gn.Uninitialized),Ne(null),B(null),H(null),ae.current=null;const jn=Ye===it.DragEnd?"onDragEnd":"onDragCancel";if(Zt){const an=L.current[jn];an?.(Zt),x({type:jn,event:Zt})}})}}},[C]),$e=u.useCallback((J,le)=>(Ie,dt)=>{const pt=Ie.nativeEvent,ot=C.get(dt);if(O.current!==null||!ot||pt.dndKit||pt.defaultPrevented)return;const Oe={active:ot};J(Ie,le.options,Oe)===!0&&(pt.dndKit={capturedBy:le.sensor},O.current=dt,pe(Ie,le))},[C,pe]),be=f2(p,$e);x2(p),rn(()=>{ee&&w===gn.Initializing&&_(gn.Initialized)},[ee,w]),u.useEffect(()=>{const{onDragMove:J}=L.current,{active:le,activatorEvent:Ie,collisions:dt,over:pt}=K.current;if(!le||!Ie)return;const ot={active:le,activatorEvent:Ie,collisions:dt,delta:{x:Q.x,y:Q.y},over:pt};Sn.unstable_batchedUpdates(()=>{J?.(ot),x({type:"onDragMove",event:ot})})},[Q.x,Q.y]),u.useEffect(()=>{const{active:J,activatorEvent:le,collisions:Ie,droppableContainers:dt,scrollAdjustedTranslate:pt}=K.current;if(!J||O.current==null||!le||!pt)return;const{onDragOver:ot}=L.current,Oe=dt.get(ge),He=Oe&&Oe.rect.current?{id:Oe.id,rect:Oe.rect.current,data:Oe.data,disabled:Oe.disabled}:null,Ye={active:J,activatorEvent:le,collisions:Ie,delta:{x:pt.x,y:pt.y},over:He};Sn.unstable_batchedUpdates(()=>{Ne(He),ot?.(Ye),x({type:"onDragOver",event:Ye})})},[ge]),rn(()=>{K.current={activatorEvent:T,active:D,activeNode:Z,collisionRect:ie,collisions:_e,droppableRects:G,draggableNodes:C,draggingNode:Ce,draggingNodeRect:ze,droppableContainers:$,over:Ae,scrollableAncestors:Pe,scrollAdjustedTranslate:Q},z.current={initial:ze,translated:ie}},[D,Z,_e,ie,C,Ce,ze,G,$,Ae,Pe,Q]),d2({...re,delta:N,draggingRect:ie,pointerCoordinates:Re,scrollableAncestors:Pe,scrollableAncestorRects:Qe});const j=u.useMemo(()=>({active:D,activeNode:Z,activeNodeRect:ee,activatorEvent:T,collisions:_e,containerNodeRect:fe,dragOverlay:me,draggableNodes:C,droppableContainers:$,droppableRects:G,over:Ae,measureDroppableContainers:V,scrollableAncestors:Pe,scrollableAncestorRects:Qe,measuringConfiguration:F,measuringScheduled:Y,windowRect:De}),[D,Z,ee,T,_e,fe,me,C,$,G,Ae,V,Pe,Qe,F,Y,De]),oe=u.useMemo(()=>({activatorEvent:T,activators:be,active:D,activeNodeRect:ee,ariaDescribedById:{draggable:I},dispatch:b,draggableNodes:C,over:Ae,measureDroppableContainers:V}),[T,be,D,ee,b,I,C,Ae,V]);return u.createElement(Pp.Provider,{value:k},u.createElement(Ko.Provider,{value:oe},u.createElement(af.Provider,{value:j},u.createElement(ii.Provider,{value:q},c)),u.createElement(T2,{disabled:l?.restoreFocus===!1})),u.createElement(A7,{...l,hiddenTextDescribedById:I}));function ue(){const J=M?.autoScrollEnabled===!1,le=typeof d=="object"?d.enabled===!1:d===!1,Ie=S&&!J&&!le;return typeof d=="object"?{...d,enabled:Ie}:{enabled:Ie}}}),M2=u.createContext(null),df="button",L2="Draggable";function P2(e){let{id:t,data:n,disabled:o=!1,attributes:r}=e;const s=Qr(L2),{activators:a,activatorEvent:l,active:d,activeNodeRect:c,ariaDescribedById:p,draggableNodes:f,over:y}=u.useContext(Ko),{role:g=df,roleDescription:m="draggable",tabIndex:h=0}=r??{},v=d?.id===t,b=u.useContext(v?ii:M2),[x,k]=Jr(),[w,_]=Jr(),S=w2(a,t),E=Lo(n);rn(()=>(f.set(t,{id:t,key:s,node:x,activatorNode:w,data:E}),()=>{const N=f.get(t);N&&N.key===s&&f.delete(t)}),[f,t]);const C=u.useMemo(()=>({role:g,tabIndex:h,"aria-disabled":o,"aria-pressed":v&&g===df?!0:void 0,"aria-roledescription":m,"aria-describedby":p.draggable}),[o,g,h,v,m,p.draggable]);return{active:d,activatorEvent:l,activeNodeRect:c,attributes:C,isDragging:v,listeners:o?void 0:S,node:x,over:y,setNodeRef:k,setActivatorNodeRef:_,transform:b}}function O2(){return u.useContext(af)}const B2="Droppable",W2={timeout:25};function j2(e){let{data:t,disabled:n=!1,id:o,resizeObserverConfig:r}=e;const s=Qr(B2),{active:a,dispatch:l,over:d,measureDroppableContainers:c}=u.useContext(Ko),p=u.useRef({disabled:n}),f=u.useRef(!1),y=u.useRef(null),g=u.useRef(null),{disabled:m,updateMeasurementsFor:h,timeout:v}={...W2,...r},b=Lo(h??o),x=u.useCallback(()=>{if(!f.current){f.current=!0;return}g.current!=null&&clearTimeout(g.current),g.current=setTimeout(()=>{c(Array.isArray(b.current)?b.current:[b.current]),g.current=null},v)},[v]),k=oi({callback:x,disabled:m||!a}),w=u.useCallback((C,N)=>{k&&(N&&(k.unobserve(N),f.current=!1),C&&k.observe(C))},[k]),[_,S]=Jr(w),E=Lo(t);return u.useEffect(()=>{!k||!_.current||(k.disconnect(),f.current=!1,k.observe(_.current))},[_,k]),u.useEffect(()=>(l({type:it.RegisterDroppable,element:{id:o,key:s,disabled:n,node:_,rect:y,data:E}}),()=>l({type:it.UnregisterDroppable,key:s,id:o})),[o]),u.useEffect(()=>{n!==p.current.disabled&&(l({type:it.SetDroppableDisabled,id:o,key:s,disabled:n}),p.current.disabled=n)},[o,s,n,l]),{active:a,rect:y,isOver:d?.id===o,node:_,over:d,setNodeRef:S}}function H2(e){let{animation:t,children:n}=e;const[o,r]=u.useState(null),[s,a]=u.useState(null),l=Zr(n);return!n&&!o&&l&&r(l),rn(()=>{if(!s)return;const d=o?.key,c=o?.props.id;if(d==null||c==null){r(null);return}Promise.resolve(t(c,s)).then(()=>{r(null)})},[t,o,s]),u.createElement(u.Fragment,null,n,o?u.cloneElement(o,{ref:a}):null)}const F2={x:0,y:0,scaleX:1,scaleY:1};function K2(e){let{children:t}=e;return u.createElement(Ko.Provider,{value:sf},u.createElement(ii.Provider,{value:F2},t))}const V2={position:"fixed",touchAction:"none"},U2=e=>sa(e)?"transform 250ms ease":void 0,q2=u.forwardRef((e,t)=>{let{as:n,activatorEvent:o,adjustScale:r,children:s,className:a,rect:l,style:d,transform:c,transition:p=U2}=e;if(!l)return null;const f=r?c:{...c,scaleX:1,scaleY:1},y={...V2,width:l.width,height:l.height,top:l.top,left:l.left,transform:Oo.Transform.toString(f),transformOrigin:r&&o?z7(o,l):void 0,transition:typeof p=="function"?p(o):p,...d};return u.createElement(n,{className:a,style:y,ref:t},s)}),G2={duration:250,easing:"ease",keyframes:e=>{let{transform:{initial:t,final:n}}=e;return[{transform:Oo.Transform.toString(t)},{transform:Oo.Transform.toString(n)}]},sideEffects:(e=>t=>{let{active:n,dragOverlay:o}=t;const r={},{styles:s,className:a}=e;if(s!=null&&s.active)for(const[l,d]of Object.entries(s.active))d!==void 0&&(r[l]=n.node.style.getPropertyValue(l),n.node.style.setProperty(l,d));if(s!=null&&s.dragOverlay)for(const[l,d]of Object.entries(s.dragOverlay))d!==void 0&&o.node.style.setProperty(l,d);return a!=null&&a.active&&n.node.classList.add(a.active),a!=null&&a.dragOverlay&&o.node.classList.add(a.dragOverlay),function(){for(const[d,c]of Object.entries(r))n.node.style.setProperty(d,c);a!=null&&a.active&&n.node.classList.remove(a.active)}})({styles:{active:{opacity:"0"}}})};function Y2(e){let{config:t,draggableNodes:n,droppableContainers:o,measuringConfiguration:r}=e;return Xr((s,a)=>{if(t===null)return;const l=n.get(s);if(!l)return;const d=l.node.current;if(!d)return;const c=rf(a);if(!c)return;const{transform:p}=_t(a).getComputedStyle(a),f=Bp(p);if(!f)return;const y=typeof t=="function"?t:X2(t);return qp(d,r.draggable.measure),y({active:{id:s,data:l.data,node:d,rect:r.draggable.measure(d)},draggableNodes:n,dragOverlay:{node:a,rect:r.dragOverlay.measure(c)},droppableContainers:o,measuringConfiguration:r,transform:f})})}function X2(e){const{duration:t,easing:n,sideEffects:o,keyframes:r}={...G2,...e};return s=>{let{active:a,dragOverlay:l,transform:d,...c}=s;if(!t)return;const p={x:l.rect.left-a.rect.left,y:l.rect.top-a.rect.top},f={scaleX:d.scaleX!==1?a.rect.width*d.scaleX/l.rect.width:1,scaleY:d.scaleY!==1?a.rect.height*d.scaleY/l.rect.height:1},y={x:d.x-p.x,y:d.y-p.y,...f},g=r({...c,active:a,dragOverlay:l,transform:{initial:d,final:y}}),[m]=g,h=g[g.length-1];if(JSON.stringify(m)===JSON.stringify(h))return;const v=o?.({active:a,dragOverlay:l,...c}),b=l.node.animate(g,{duration:t,easing:n,fill:"forwards"});return new Promise(x=>{b.onfinish=()=>{v?.(),x()}})}}let cf=0;function J2(e){return u.useMemo(()=>{if(e!=null)return cf++,cf},[e])}const Z2=u.memo(e=>{let{adjustScale:t=!1,children:n,dropAnimation:o,style:r,transition:s,modifiers:a,wrapperElement:l="div",className:d,zIndex:c=999}=e;const{activatorEvent:p,active:f,activeNodeRect:y,containerNodeRect:g,draggableNodes:m,droppableContainers:h,dragOverlay:v,over:b,measuringConfiguration:x,scrollableAncestors:k,scrollableAncestorRects:w,windowRect:_}=O2(),S=u.useContext(ii),E=J2(f?.id),C=lf(a,{activatorEvent:p,active:f,activeNodeRect:y,containerNodeRect:g,draggingNodeRect:v.rect,over:b,overlayNodeRect:v.rect,scrollableAncestors:k,scrollableAncestorRects:w,transform:S,windowRect:_}),N=ba(y),$=Y2({config:o,draggableNodes:m,droppableContainers:h,measuringConfiguration:x}),A=N?v.setRef:void 0;return u.createElement(K2,null,u.createElement(H2,{animation:$},f&&E?u.createElement(q2,{key:E,id:f.id,ref:A,as:l,activatorEvent:p,adjustScale:t,className:d,transition:s,rect:N,style:{zIndex:c,...r},transform:C},n):null))});function Vo(e,t){return!t||!Array.isArray(t.draggable)?!1:t.draggable.includes(e)}function Q2({draggedComponentName:e,targetContainerName:t,nesting:n}){if(!n||!Vo(e,n))return!1;const o=n.containers;if(!o||typeof o!="object")return!1;const r=o[t];return!r||!Array.isArray(r.accepts)?!1:r.accepts.includes(e)}function uf(e,t){if(!t?.containers)return!1;const n=t.containers[e];return!!(n&&Array.isArray(n.accepts)&&n.accepts.length>0)}function R2(e,t,n,o,r){if(!n||n.length===0)return{parentPath:[],index:r};if(e&&uf(e,o))return{parentPath:n,index:t};const s=n.slice(0,-1),a=n[n.length-1]??0;return{parentPath:s,index:a+1}}function pf(e,t){return e===t||e.startsWith(`${t}.`)}function ff(e,t){if(e==="")return/^\d+$/.test(t)?Number(t):null;const n=`${e}.children.`;if(!t.startsWith(n))return null;const o=t.slice(n.length);return/^\d+$/.test(o)?Number(o):null}function eI(e,t){const n=[];return e.querySelectorAll("[data-editor-path]").forEach(o=>{if(o.hasAttribute("data-dnd-placeholder")||o.closest("[data-dnd-placeholder]"))return;const r=o.dataset.editorPath;typeof r=="string"&&n.push({path:t(r),domPath:r,rect:o.getBoundingClientRect()})}),n}const tI=16;function nI(e){const{frameEl:t,draggedPath:n,acceptsContainer:o,allowsNestSlot:r,includeContainer:s}=e,a=e.normalizeContainerPath??(g=>g),l=t.getBoundingClientRect(),d=eI(t,a),c=new Map;for(const g of d)c.has(g.path)||c.set(g.path,g.domPath);const p=a(n),f=new Set([""]);for(const g of d){const m=rI(g.path);m!==null&&f.add(m)}for(const g of d)f.add(g.path);const y=[];for(const g of f){const m=g==="",h=m?null:c.get(g)??g,v=m?t:t.querySelector(`[data-editor-path="${yf(h)}"]`);if(!v&&!m||!m&&pf(g,p))continue;const b=g;if(!m&&s&&!s(b))continue;const x=S=>{const E=c.get(S);if(!E||typeof window>"u")return!1;const C=t.querySelector(`[data-editor-path="${yf(E)}"]`);return C?(window.getComputedStyle(C).display??"").toLowerCase()==="contents":!1},k=d.filter(S=>ff(g,S.path)!==null).filter(S=>!pf(S.path,p)).filter(S=>!x(S.path)).map(S=>({origIndex:ff(g,S.path),rect:oI(S.rect,S.path,d)})).filter(S=>S.rect.width>0||S.rect.height>0).sort((S,E)=>S.origIndex-E.origIndex),w=Cp(m?t:v).kind,_=w==="flex_row_single"||w==="flex_row_wrap"||w==="grid_single_row"||w==="grid_2d";if(k.length>0){if(o(b)){k.forEach(E=>{y.push(gf(b,E.origIndex,E.rect,l,_,"before"))});const S=k[k.length-1];y.push(gf(b,S.origIndex+1,S.rect,l,_,"after"))}}else if(!m&&r(b)&&v){const S=v.getBoundingClientRect();S.width>0&&S.height>0&&y.push({id:`slot:${b}:0`,kind:"nest",containerPath:b,index:0,box:hf(S,l),orientation:"area"})}else m&&y.push({id:"slot::0",kind:"gap",containerPath:"",index:0,box:hf(l,l),orientation:"area"})}return y.filter(g=>io(g.box,t))}function gf(e,t,n,o,r,s){const a=tI;let l;if(r)l={left:(s==="before"?n.left:n.right)-a/2,top:n.top,width:a,height:n.height};else{const d=s==="before"?n.top:n.bottom;l={left:n.left,top:d-a/2,width:n.width,height:a}}return{id:`slot:${e}:${t}`,kind:"gap",containerPath:e,index:t,box:{left:l.left-o.left,top:l.top-o.top,width:l.width,height:l.height,scale:1},orientation:r?"vertical":"horizontal"}}function oI(e,t,n){if(e.width>0||e.height>0)return e;const o=`${t}.children.`;let r=1/0,s=1/0,a=-1/0,l=-1/0;for(const d of n)d.path.startsWith(o)&&(d.rect.width===0&&d.rect.height===0||(r=Math.min(r,d.rect.left),s=Math.min(s,d.rect.top),a=Math.max(a,d.rect.right),l=Math.max(l,d.rect.bottom)));return r===1/0?e:{left:r,top:s,width:a-r,height:l-s,right:a,bottom:l,x:r,y:s,toJSON:()=>({})}}function hf(e,t){return{left:e.left-t.left,top:e.top-t.top,width:e.width,height:e.height,scale:1}}function rI(e){if(e==="")return null;const t=e.lastIndexOf(".children.");return t<0?"":e.slice(0,t)}function iI(e){if(!e.startsWith("slot:"))return null;const t=e.slice(5),n=t.lastIndexOf(":");if(n<0)return null;const o=t.slice(0,n),r=Number(t.slice(n+1));return Number.isFinite(r)?{containerPath:o,index:r}:null}function yf(e){return typeof CSS<"u"&&typeof CSS.escape=="function"?CSS.escape(e):e.replace(/[^a-zA-Z0-9_\-.]/g,t=>`\\${t}`)}function ma(e){return typeof e=="string"&&e.length>0?e:null}function va(e,t){return t&&e.startsWith(`${t}.`)?t:e}function bf(e){if(typeof e!="string")return null;const t=iI(e);return t?{containerPath:t.containerPath,index:t.index}:null}function _a(e,t){return t?e.startsWith(`${t}.iteration.`):!1}function xa(e,t,n){return t?n==="iteration_item"?_a(e,t):e===t||e.startsWith(`${t}.`):!1}function sI(e){const{frameEl:t,nesting:n,editMode:o,currentExtensionId:r,editableRootPath:s,components:a,patchLayout:l,pushHistory:d,onMovePath:c,selectedPath:p,showBranchBoundaryHint:f}=e,y=(o==="iteration_item"||o==="modal")&&s&&s.length>0?at(s):null,[g,m]=u.useState(null),[h,v]=u.useState(null),[b,x]=u.useState(null),k=u.useRef({components:a,nesting:n,editMode:o,currentExtensionId:r,selectedPath:p,editableRootSourcePath:y});k.current={components:a,nesting:n,editMode:o,currentExtensionId:r,selectedPath:p,editableRootSourcePath:y};const w=u.useRef(null),_=u.useCallback(M=>({children:M}),[]),S=u.useCallback((M,B)=>{const T=We(_(M),B);return T?typeof T.name=="string"?T.name:typeof T.type=="string"?T.type:null:null},[_]),E=u.useCallback(M=>B=>{const T=k.current;if(B==="")return Vo(M,T.nesting);const H=Ze(B),L=We(_(T.components),H);if(!L)return!1;const I=typeof L.name=="string"?L.name:typeof L.type=="string"?L.type:"";if(!I)return!1;const P=!!T.editableRootSourcePath&&(B===T.editableRootSourcePath||B.startsWith(`${T.editableRootSourcePath}.`));if(T.editableRootSourcePath&&!P)return!1;if(!P){const F=wa(_(T.components),H);if(fn(L,T.editMode,T.currentExtensionId,F)!=="none")return!1}return uf(I,T.nesting)?Q2({draggedComponentName:M,targetContainerName:I,nesting:T.nesting}):!1},[_]),C=u.useCallback(M=>{if(M==="")return!0;const B=k.current,T=Ze(M),H=w.current;if(H&&H.length>0){const P=H.slice(0,-1);if(si(T,P))return!0}const L=We(_(B.components),T);return L?(typeof L.name=="string"?L.name:typeof L.type=="string"?L.type:"").length>0:!1},[_]),N=u.useCallback(M=>{const B=E(M),T=k.current,H=w.current,L=H?H.slice(0,-1):null,I=new Set;if(L)for(let V=L.length;V>=0;V--)I.add(ai(L.slice(0,V)));const P=ai(H??[]),F=V=>!!H&&(V===P||V.startsWith(`${P}.`)),G=(V,Y)=>{for(let Z=0;ZV===""?!0:F(V)?!1:I.has(V),allowsNestSlot:V=>{if(!B(V))return!1;if(L){const Y=Ze(V);if(si(Y,L))return!1}return C(V)}}},[E,C,_]),$=u.useCallback(M=>{const B=ma(M.active.id),T=k.current;if(!B)return;const H=va(B,T.selectedPath),L=Ze(H),I=We(_(T.components),L),P=I&&typeof I.name=="string"?I.name:I&&typeof I.type=="string"?I.type:null;m(H),v(P),x(null),w.current=I?L:null;let F="allowed";if(!P||!Vo(P,T.nesting))F="denied_no_draggable";else if(I){const G=wa(_(T.components),L),V=fn(I,T.editMode,T.currentExtensionId,G);T.editableRootSourcePath&&!xa(H,T.editableRootSourcePath,T.editMode)||Gi(G)&&!_a(H,T.editableRootSourcePath)?F="denied_data_bound":V==="base"||V==="partial"?F="denied_base_locked":(V==="extension"||V==="extension_point")&&(F="denied_extension_locked")}Cn({source:"drag",draggedComponentName:P,targetContainerName:null,targetContainerPath:null,decision:F,result:F==="allowed"?"completed":"denied",timestamp:Date.now()})},[_]),A=u.useCallback(M=>{if(!t)return;const B=ma(M.active.id);if(!B)return;const T=h;if(!T){x(null);return}const H=k.current,L=va(B,H.selectedPath),I=Ze(L),P=We(_(H.components),I);if(!P||!Vo(T,H.nesting)){x(null);return}const F=wa(_(H.components),I);if(H.editableRootSourcePath&&!xa(L,H.editableRootSourcePath,H.editMode)){x(null);return}const G=fn(P,H.editMode,H.currentExtensionId,F),V=_a(L,H.editableRootSourcePath);if(Gi(F)&&!V||G!=="none"&&G!=="data_bound"&&!V){x(null);return}x(bf(M.over?.id))},[t,h,_]),z=u.useCallback(()=>{m(null),v(null),x(null),w.current=null},[]),D=u.useCallback(M=>{const B=ma(M.active.id),T=h,H=k.current,L=B?va(B,H.selectedPath):null;if(!t||!L||!T){z();return}const I=bf(M.over?.id),P=Ze(L);if(!I){Cn({source:"drag",draggedComponentName:T,targetContainerName:null,targetContainerPath:null,decision:"denied_no_accepts",result:"cancelled",timestamp:Date.now()}),z();return}const F=I.containerPath===""?[]:Ze(I.containerPath),G=I.containerPath===""?null:S(H.components,F);if(!aI(P.slice(0,-1),F)){Cn({source:"drag",draggedComponentName:T,targetContainerName:G,targetContainerPath:I.containerPath||null,decision:"denied_responsive_branch_boundary",result:"denied",timestamp:Date.now()}),f?.(),z();return}if(H.editableRootSourcePath&&!xa(I.containerPath,H.editableRootSourcePath,H.editMode)){Cn({source:"drag",draggedComponentName:T,targetContainerName:G,targetContainerPath:I.containerPath||null,decision:"denied_data_bound",result:"denied",timestamp:Date.now()}),z();return}const V=P.slice(0,-1),Y=P[P.length-1]??-1;if(si(V,F)&&(I.index===Y||I.index===Y+1)){Cn({source:"drag",draggedComponentName:T,targetContainerName:G,targetContainerPath:I.containerPath||null,decision:"allowed",insertionIndex:I.index,result:"cancelled",timestamp:Date.now()}),z();return}const re=si(V,F),U=I.index;let ee=[];l(me=>{const Se=Cb({children:me},P,F,U).children??[];return ee=Se,Se}),d(ee,`move ${T}`);const fe=od(F,P),K=re&&Y{z()},[z]);return{activeDragPath:g,activeDragName:h,activeDropZone:b,onDragStart:$,onDragMove:A,onDragEnd:D,onDragCancel:O,buildSlotPredicates:N}}function wa(e,t){const n=[];let o=e,r=Array.isArray(e.children)?e.children:[];for(let s=0;s0?at(s):null,{t:m}=tt(),[h,v]=u.useState(!1),b=u.useRef(null),x=u.useCallback(()=>{v(!0),b.current&&clearTimeout(b.current),b.current=setTimeout(()=>v(!1),2600)},[]);u.useEffect(()=>()=>{b.current&&clearTimeout(b.current)},[]);const k=sI({frameEl:t,nesting:n,editMode:o,currentExtensionId:r,editableRootPath:s,components:a,patchLayout:l,pushHistory:d,onMovePath:p,selectedPath:y,showBranchBoundaryHint:x}),w=D7(T7(fa,{activationConstraint:{distance:8}})),[_,S]=u.useState([]),E=u.useCallback(()=>{if(!t){S([]);return}if(k.activeDragPath!==null)return;const L={children:a},I=t.querySelectorAll("[data-editor-path]"),P=[],F=new Map;I.forEach(G=>{const V=G.dataset.editorPath;if(typeof V!="string"||o!=="iteration_item"&&g&&!cI(V,g,o))return;const Y=lI(V);if(Y!==null&&!Uo(V,g)){if(/\.iteration\.\d+$/.test(V)){const K=ro(G,t);if(K&&io(K,t)){const ne=F.get(Y)??[];ne.push(K),F.set(Y,ne)}}return}if(Uo(V,g)&&/\.iteration\.\d+$/.test(V))return;const Z=Ze(V),se=We(L,Z);if(!se||se.iteration&&se.iteration.source!==void 0)return;const re=typeof se.name=="string"?se.name:typeof se.type=="string"?se.type:null;if(!re||!Vo(re,n))return;const U=vf(L,Z);if(Gi(U)&&!Uo(V,g))return;const ee=fn(se,o,r,U);if(ee!=="none"&&ee!=="data_bound"&&!Uo(V,g))return;const fe=ro(G,t);fe&&io(fe,t)&&P.push({path:V,name:re,box:fe})});for(const[G,V]of F){const Y=Ze(G),Z=We(L,Y);if(!Z)continue;const se=typeof Z.name=="string"?Z.name:typeof Z.type=="string"?Z.type:null;if(!se)continue;const re=vf(L,Y),U=fn(Z,o,r,re);if(U!=="none"&&U!=="data_bound")continue;const ee=uI(V);ee&&io(ee,t)&&P.push({path:G,name:se,box:ee,isIterationGroup:!0})}S(P)},[t,a,n,o,r,g,k.activeDragPath]);u.useEffect(()=>{E()},[E]),u.useEffect(()=>{if(t)return _p(E,t)},[t,E]);const C=k.activeDragPath!==null,N=k.activeDragName,$=k.activeDragPath,[A,z]=u.useState(null),D=u.useRef(null);u.useEffect(()=>{if(!$||!t){$||(D.current=null,z(null));return}if(D.current===$)return;D.current=$;const L=t.querySelector(`[data-editor-path="${mf($)}"]`);if(L){const re=L.getBoundingClientRect();z({node:L.cloneNode(!0),width:re.width,height:re.height});return}const I=Array.from(t.querySelectorAll(`[data-editor-path^="${mf($)}.iteration."]`)).filter(re=>/\.iteration\.\d+$/.test(re.dataset.editorPath??""));if(I.length===0){z(null);return}let P=1/0,F=1/0,G=-1/0,V=-1/0;for(const re of I){const U=re.getBoundingClientRect();P=Math.min(P,U.left),F=Math.min(F,U.top),G=Math.max(G,U.right),V=Math.max(V,U.bottom)}const Y=G-P,Z=V-F,se=document.createElement("div");se.style.position="relative",se.style.width=`${Y}px`,se.style.height=`${Z}px`;for(const re of I){const U=re.getBoundingClientRect(),ee=re.cloneNode(!0);ee.style.position="absolute",ee.style.left=`${U.left-P}px`,ee.style.top=`${U.top-F}px`,ee.style.width=`${U.width}px`,ee.style.height=`${U.height}px`,ee.style.margin="0",se.appendChild(ee)}z({node:se,width:Y,height:Z})},[$,t]);const[O,M]=u.useState([]),B=u.useRef(null),T=u.useRef(k.buildSlotPredicates);if(T.current=k.buildSlotPredicates,u.useEffect(()=>{if(!$||!N||!t){B.current=null,M(F=>F.length===0?F:[]);return}if(B.current===$)return;B.current=$;const{acceptsContainer:L,allowsNestSlot:I,includeContainer:P}=T.current(N);M(nI({frameEl:t,draggedPath:$,acceptsContainer:L,allowsNestSlot:I,includeContainer:P,normalizeContainerPath:F=>dI(F)}))},[$,N,t]),!t)return null;const H=k.activeDropZone?`slot:${k.activeDropZone.containerPath}:${k.activeDropZone.index}`:null;return i.jsxs(z2,{sensors:w,collisionDetection:H7,onDragStart:k.onDragStart,onDragMove:k.onDragMove,onDragEnd:k.onDragEnd,onDragCancel:k.onDragCancel,children:[_.map(L=>i.jsx(hI,{path:L.path,name:L.name,box:L.box,dimmed:C&&k.activeDragPath!==L.path,isIterationGroup:L.isIterationGroup,onSelect:c,onRequestInlineEdit:f},L.path)),C&&O.map(L=>i.jsx(gI,{slot:L,active:L.id===H},L.id)),Sn.createPortal(i.jsx(Z2,{dropAnimation:null,zIndex:Tp,children:N?A?i.jsx(fI,{name:N,node:A.node,width:A.width,height:A.height}):i.jsx("div",{className:"g7le-dnd-drag-ghost","data-testid":"g7le-dnd-drag-ghost",style:{padding:"4px 10px",background:"rgba(79,70,229,0.95)",color:"#fff",fontSize:12,fontWeight:600,borderRadius:4,pointerEvents:"none",whiteSpace:"nowrap"},children:`<${N}>`}):null}),document.body),h&&Sn.createPortal(i.jsxs("div",{"data-testid":"g7le-branch-boundary-hint",role:"status",style:{position:"absolute",left:"50%",top:12,transform:"translateX(-50%)",background:"rgba(245,158,11,0.97)",color:"#fff",fontSize:13,fontWeight:600,padding:"8px 16px",borderRadius:8,boxShadow:"0 4px 14px rgba(0,0,0,0.2)",pointerEvents:"none",whiteSpace:"nowrap",zIndex:Tp},children:["⚠️ ",m("layout_editor.overlay.branch_boundary_denied")]}),t)]})}function fI(e){const{node:t,width:n,height:o}=e,r=u.useRef(null);return u.useEffect(()=>{const s=r.current;if(s)return s.replaceChildren(),s.appendChild(t.cloneNode(!0)),()=>{s.replaceChildren()}},[t]),i.jsx("div",{ref:r,className:"g7le-dnd-drag-ghost","data-testid":"g7le-dnd-drag-ghost",style:{width:n,height:o,opacity:.7,pointerEvents:"none",boxShadow:"0 6px 18px rgba(0,0,0,0.25)",borderRadius:4,overflow:"hidden",cursor:"grabbing"}})}function gI(e){const{slot:t,active:n}=e,{setNodeRef:o}=j2({id:t.id}),r=t.orientation!=="area";return i.jsx("div",{ref:o,"data-testid":`g7le-dnd-slot-${t.id}`,"data-dnd-slot-id":t.id,style:{position:"absolute",left:t.box.left,top:t.box.top,width:t.box.width,height:t.box.height,pointerEvents:"auto",zIndex:s7,boxSizing:"border-box",...r?{background:n?"rgba(79,70,229,0.15)":"transparent",...n?t.orientation==="vertical"?{borderLeft:"2px solid #4f46e5"}:{borderTop:"2px solid #4f46e5"}:{}}:{border:n?"2px dashed #4f46e5":"1px dashed transparent",background:n?"rgba(79,70,229,0.08)":"transparent",borderRadius:4}}})}function mf(e){return typeof CSS<"u"&&typeof CSS.escape=="function"?CSS.escape(e):e.replace(/[^a-zA-Z0-9_\-.]/g,t=>`\\${t}`)}function hI(e){const{path:t,box:n,dimmed:o,isIterationGroup:r,onSelect:s,onRequestInlineEdit:a}=e,{attributes:l,listeners:d,setNodeRef:c,isDragging:p}=P2({id:t}),f=(t.match(/\.children\./g)||[]).length,y=a7(f);return i.jsx("div",{ref:c,"data-testid":`g7le-dnd-handle-${t}`,"data-dnd-handle-path":t,"data-dnd-iteration-group":r?"true":void 0,...d,...l,onClick:g=>{if(g.stopPropagation(),!r&&typeof document<"u"&&typeof document.elementsFromPoint=="function"){const m=document.elementsFromPoint(g.clientX,g.clientY);for(const h of m){const b=h.closest?.("[data-editor-path]")?.dataset?.editorPath;if(typeof b=="string"&&(b===t||b.startsWith(`${t}.`))){s(b);return}}}s(t)},onDoubleClick:g=>{g.stopPropagation(),g.preventDefault(),a?.(t)},style:{position:"absolute",left:n.left,top:n.top,width:n.width,height:n.height,cursor:"grab",pointerEvents:"auto",background:p?"rgba(79,70,229,0.06)":"transparent",...r?{border:"1px dashed rgba(79,70,229,0.5)",borderRadius:4}:{},opacity:o?.6:1,zIndex:y,touchAction:"none"}})}function vf(e,t){const n=[];let o=e,r=Array.isArray(e.children)?e.children:[];for(let s=0;s0?[o]:[];const r=[],s=(a,l)=>{a.forEach((d,c)=>{const p=[...l,c];if(!Yi(d,t,n)){r.push(p);return}const y=Array.isArray(d.children)?d.children:[];y.length>0&&s(y,p)})};return s(e,[]),r}function bI(e){return at(e)}function li(e){return typeof CSS<"u"&&typeof CSS.escape=="function"?CSS.escape(e):e.replace(/["\\]/g,"\\$&")}function mI({frameEl:e,components:t,editMode:n,currentExtensionId:o,editableRootPath:r}){const[s,a]=u.useState(null),[l]=u.useState(()=>`g7le-dim-mask-${Math.floor(Math.random()*1e9)}`);if(u.useEffect(()=>{if(!e){a(null);return}let g=0;const m=()=>{const x=yI(t,n,o,r);if(x.length===0){a(null);return}const k=e.getBoundingClientRect(),w={left:0,top:0,width:k.width,height:k.height},_=[];for(const S of x){const E=bI(S),C=e.querySelector(`[data-editor-path="${li(E)}"]`);let N=null;if(C){const $=C.getBoundingClientRect();$.width>0&&$.height>0&&(N={left:$.left,top:$.top,width:$.width,height:$.height})}if(!N){const $=Array.from(e.querySelectorAll(`[data-editor-path^="${li(E)}.iteration."], [data-editor-path^="${li(E)}.sortable."], [data-editor-path^="${li(E)}.children."]`));let A=1/0,z=1/0,D=-1/0,O=-1/0;for(const M of $){const B=M.getBoundingClientRect();B.width<=0||B.height<=0||(A=Math.min(A,B.left),z=Math.min(z,B.top),D=Math.max(D,B.right),O=Math.max(O,B.bottom))}if(D>A&&O>z&&(N={left:A,top:z,width:D-A,height:O-z}),N&&$.length>0){const M=$[0].closest('[role="dialog"]');if(M&&e.contains(M)){const B=M.getBoundingClientRect();B.width>0&&B.height>0&&(N={left:B.left,top:B.top,width:B.width,height:B.height})}}}N&&_.push({left:N.left-k.left,top:N.top-k.top,width:N.width,height:N.height})}if(_.length===0){a(null);return}a({frame:w,holes:_})},h=()=>{g&&cancelAnimationFrame(g),g=requestAnimationFrame(m)};h();const v=typeof ResizeObserver<"u"?new ResizeObserver(h):null;v?.observe(e);const b=typeof MutationObserver<"u"?new MutationObserver(h):null;return b?.observe(e,{childList:!0,subtree:!0,attributes:!0}),window.addEventListener("scroll",h,!0),window.addEventListener("resize",h),()=>{g&&cancelAnimationFrame(g),v?.disconnect(),b?.disconnect(),window.removeEventListener("scroll",h,!0),window.removeEventListener("resize",h)}},[e,t,n,o,r?.join(".")??""]),!s||s.holes.length===0)return null;const{frame:d,holes:c}=s,p=n==="modal"||n==="iteration_item";let f=[];if(p){let g=1/0,m=1/0,h=-1/0,v=-1/0;for(const S of c)g=Math.min(g,S.left),m=Math.min(m,S.top),h=Math.max(h,S.left+S.width),v=Math.max(v,S.top+S.height);const b=(S,E,C)=>Math.max(E,Math.min(C,S)),x=b(g,0,d.width),k=b(m,0,d.height),w=b(h,0,d.width),_=b(v,0,d.height);f=[{left:0,top:0,width:d.width,height:k},{left:0,top:_,width:d.width,height:d.height-_},{left:0,top:k,width:x,height:_-k},{left:w,top:k,width:d.width-w,height:_-k}].filter(S=>S.width>0&&S.height>0)}const y=g=>{g.preventDefault(),g.stopPropagation()};return i.jsxs(i.Fragment,{children:[f.map((g,m)=>i.jsx("div",{className:"g7le-source-lock-block","data-testid":"g7le-source-lock-block",onClickCapture:y,onPointerDownCapture:y,onMouseDownCapture:y,onDragStartCapture:y,style:{position:"absolute",left:g.left,top:g.top,width:g.width,height:g.height,zIndex:Ap,pointerEvents:"auto",cursor:"not-allowed",background:"transparent"}},m)),i.jsxs("svg",{className:"g7le-source-lock-dim","data-testid":"g7le-source-lock-dim",width:d.width,height:d.height,style:{position:"absolute",left:0,top:0,width:d.width,height:d.height,zIndex:Ap,pointerEvents:"none"},children:[i.jsx("defs",{children:i.jsxs("mask",{id:l,children:[i.jsx("rect",{x:0,y:0,width:d.width,height:d.height,fill:"white"}),c.map((g,m)=>i.jsx("rect",{"data-testid":"g7le-source-lock-dim-hole",x:g.left,y:g.top,width:g.width,height:g.height,rx:4,fill:"black"},m))]})}),i.jsx("rect",{"data-testid":"g7le-source-lock-dim-box",x:0,y:0,width:d.width,height:d.height,fill:"rgba(15, 23, 42, 0.45)",mask:`url(#${l})`})]})]})}function _f(e){const t={type:e.type??"basic",name:e.name},n={};if(e.props)for(const[o,r]of Object.entries(e.props))o!=="children"&&r?.default!==void 0&&(n[o]=r.default);return Object.keys(n).length>0&&(t.props=n),t}function vI(e){if(!e?.components)return[];const t=[];for(const[n,o]of Object.entries(e.components))if(Array.isArray(o))for(const r of o)t.push({...r,type:r.type??n});return t}function _I(e,t,n,o,r){const s=vI(e);let a=null;if(n&&t?.containers){const l=t.containers[n];l&&Array.isArray(l.accepts)?a=new Set(l.accepts):a=new Set}else!n&&t?.draggable&&(a=new Set(t.draggable));return s.filter(l=>{if(a&&!a.has(l.name))return!1;if(o){const d=o.toLowerCase(),c=r(l).toLowerCase(),p=l.name.toLowerCase();if(!c.includes(d)&&!p.includes(d)&&!`<${p}>`.includes(d)&&!(l.description??"").toLowerCase().includes(d))return!1}return!0})}function xI(e,t){const n={key:"all",label:"layout_editor.palette.category.all",entries:e,isI18nKey:!0};if(t?.groups&&t.groups.length>0){const a=new Map(e.map(p=>[p.name,p])),l=new Set,d=[];for(const p of t.groups){const f=[];for(const y of p.components??[]){const g=a.get(y);g&&(f.push(g),l.add(y))}f.length!==0&&d.push({key:p.kind??p.label,label:p.label,entries:f,isI18nKey:p.label.startsWith("$t:")||/^[a-z_.]+$/i.test(p.label)})}const c=e.filter(p=>!l.has(p.name));return c.length>0&&d.push({key:"other",label:"layout_editor.palette.category.other",entries:c,isI18nKey:!0}),[n,...d]}const o=new Map;for(const a of e){const l=(a.type??"basic").toLowerCase(),d=o.get(l)??[];d.push(a),o.set(l,d)}const r=["basic","composite","layout"],s=[];for(const a of r){const l=o.get(a);l&&l.length>0&&(s.push({key:a,label:`layout_editor.palette.category.fallback.${a}`,entries:l,isI18nKey:!0}),o.delete(a))}for(const[a,l]of o.entries())s.push({key:a,label:a,entries:l,isI18nKey:!1});return[n,...s]}function xf(e){const{manifest:t,nesting:n,componentPalette:o,targetParentPath:r,targetContainerName:s,onInsert:a,insertionIndex:l,onClose:d,editorTemplateId:c,editorLocale:p}=e,{t:f,translationEngine:y}=tt(),g=u.useMemo(()=>{if(!c||!y)return f;const A=p??null;return z=>{const D={templateId:c,locale:A??"ko"},O=y.translate(z,D);return O!==z?O:f(z)}},[c,p,y,f]),[m,h]=u.useState(""),[v,b]=u.useState("all"),x=o?.entries??null,k=u.useMemo(()=>A=>{const z=x?.[A.name];return z?.label?di(z.label,g):A.description??A.name},[x,g]),w=u.useMemo(()=>_I(t,n,s,m,k),[t,n,s,m,k]),_=u.useMemo(()=>xI(w,o??null),[w,o]),S=_.find(A=>A.key===v)??_[0]??null,E=!n||!n.draggable&&!n.containers,C=w.length===0,N=S?.entries??[],$=A=>{const z=x?.[A.name],D=!!z?.defaultNode&&typeof z.defaultNode=="object";if(z?.requiresDefaultNode&&!D)return;const O=D?JSON.parse(JSON.stringify(z.defaultNode)):_f(A),M=r??[];a(O,M,l),Cn({source:"palette",draggedComponentName:A.name,targetContainerName:s,targetContainerPath:M.length===0?null:M.join("/"),decision:"allowed",insertionIndex:l,result:"completed",timestamp:Date.now()}),d()};return i.jsxs("div",{className:"g7le-palette","data-testid":"g7le-palette",style:CI,children:[i.jsxs("div",{style:$I,children:[i.jsx("strong",{style:{fontSize:16},children:f("layout_editor.palette.title")}),i.jsx("button",{type:"button",onClick:d,"aria-label":"close","data-testid":"g7le-palette-close",style:EI,children:"×"})]}),i.jsxs("div",{style:II,children:[i.jsx("aside",{style:AI,"data-testid":"g7le-palette-sidebar",children:i.jsx("nav",{children:i.jsx("ul",{style:TI,children:_.map(A=>{const z=S?.key===A.key,D=A.isI18nKey?di(A.label,g):A.label;return i.jsx("li",{children:i.jsxs("button",{type:"button","data-testid":`g7le-palette-category-${A.key}`,"data-active":z?"true":"false",onClick:()=>b(A.key),style:{...DI,background:z?"#eff6ff":"transparent",color:z?"#1d4ed8":"#334155",fontWeight:z?600:500},children:[i.jsx("span",{children:D}),i.jsx("span",{style:LI,children:A.entries.length})]})},A.key)})})})}),i.jsxs("main",{style:NI,children:[i.jsx("input",{type:"text",placeholder:f("layout_editor.palette.search_placeholder"),value:m,onChange:A=>h(A.target.value),"data-testid":"g7le-palette-search",style:zI}),i.jsx("div",{style:MI,children:E?i.jsx("div",{style:wf,"data-testid":"g7le-palette-spec-missing",children:f("layout_editor.palette.spec_missing")}):C||N.length===0?i.jsx("div",{style:wf,"data-testid":"g7le-palette-empty",children:f("layout_editor.palette.empty")}):i.jsx("div",{"data-testid":`g7le-palette-group-${S?.key??"all"}`,style:{marginBottom:16},children:i.jsx("div",{style:PI,children:N.map(A=>i.jsx(wI,{entry:A,entrySpec:x?.[A.name]??null,onClick:$,t:f,entryT:g},A.name))})})})]})]})]})}function wI(e){const{entry:t,entrySpec:n,onClick:o,t:r,entryT:s}=e,a=n?.label?di(n.label,s):t.description??t.name,l=(t.type??"").toLowerCase(),d=l==="basic"||l==="composite"||l==="layout",c=!!n?.defaultNode&&typeof n.defaultNode=="object",p=!!n?.requiresDefaultNode&&!c,f=!c,y=p?r("layout_editor.palette.requires_default_node_missing"):n?.label?di(n.label,s):t.name;return i.jsxs("button",{type:"button",onClick:()=>o(t),disabled:p,"aria-disabled":p||void 0,"data-testid":`g7le-palette-item-${t.name}`,"data-component-type":l||void 0,"data-blocked":p?"true":void 0,"data-default-node-missing":f?"true":void 0,title:y,style:p?BI:OI,children:[i.jsx("div",{style:jI,"aria-hidden":"true",children:SI(t)}),i.jsx("div",{style:HI,children:a}),i.jsx("div",{"data-testid":`g7le-palette-item-${t.name}-tag`,style:FI,children:`<${t.name}>`}),d&&i.jsx("div",{"data-testid":`g7le-palette-item-${t.name}-badge`,style:kI(l),children:l}),f&&i.jsx("div",{"data-testid":`g7le-palette-item-${t.name}-default-missing-badge`,style:WI,children:r("layout_editor.palette.default_node_missing_badge")})]})}function di(e,t){return e.startsWith("$t:")?t(e.slice(3)):t(e)}function kI(e){const n={basic:{bg:"#dbeafe",fg:"#1e40af"},composite:{bg:"#dcfce7",fg:"#14532d"},layout:{bg:"#fef3c7",fg:"#854d0e"}}[e]??{bg:"#e2e8f0",fg:"#334155"};return{fontSize:9,fontWeight:600,color:n.fg,background:n.bg,padding:"1px 6px",borderRadius:999,textTransform:"uppercase",letterSpacing:.4,lineHeight:1.6}}function SI(e){const t=e.name;return{Button:"🔘",A:"🔗",Img:"🖼",Icon:"✨",Hr:"➖",P:"📄",Span:"T",H1:"H₁",H2:"H₂",H3:"H₃",H4:"H₄",Ul:"☰",Li:"•",Nav:"🧭",Div:"▢",Container:"▢",Flex:"⇆",Grid:"▦",SectionLayout:"▤",ThreeColumnLayout:"⫼",Form:"📝",Input:"⎕",Textarea:"▭",Select:"⏷",Checkbox:"☑",PasswordInput:"🔒",Label:"🏷",DataGrid:"▦",Pagination:"↔",SearchBar:"🔍",ProductCard:"🛍",ImageGallery:"🖼",Avatar:"👤",UserInfo:"👥",HtmlContent:"⟨/⟩",HtmlEditor:"✏",RichTextEditor:"✏",TabNavigation:"◰",QuantitySelector:"#",ProductImageViewer:"🖼",FileUploader:"📎",AvatarUploader:"📷",SocialLoginButtons:"🌐",PostReactions:"❤"}[t]??"◻"}const CI={display:"flex",flexDirection:"column",width:"100%",height:"100%",minHeight:0,fontFamily:'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple SD Gothic Neo", "Noto Sans KR", sans-serif'},$I={display:"flex",justifyContent:"space-between",alignItems:"center",padding:"14px 20px",borderBottom:"1px solid #e2e8f0",flexShrink:0},EI={background:"transparent",border:"none",fontSize:24,cursor:"pointer",lineHeight:1,color:"#64748b",padding:"0 4px"},II={display:"flex",flex:1,minHeight:0},AI={width:180,flexShrink:0,borderRight:"1px solid #e2e8f0",padding:"14px 8px",background:"#f8fafc",overflowY:"auto"},TI={listStyle:"none",margin:0,padding:0,display:"flex",flexDirection:"column",gap:4},DI={display:"flex",width:"100%",alignItems:"center",justifyContent:"space-between",textAlign:"left",padding:"8px 12px",border:"none",borderRadius:6,fontSize:13,cursor:"pointer",transition:"background-color 120ms"},NI={flex:1,display:"flex",flexDirection:"column",minHeight:0,padding:"14px 20px"},zI={width:"100%",padding:"8px 10px",border:"1px solid #cbd5e1",borderRadius:6,marginBottom:12,fontSize:13,flexShrink:0,boxSizing:"border-box"},MI={flex:1,overflowY:"auto",minHeight:0},LI={fontSize:11,color:"#94a3b8",marginLeft:8,fontWeight:500},PI={display:"grid",gridTemplateColumns:"repeat(auto-fill, minmax(96px, 1fr))",gap:10},OI={display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",gap:6,padding:"14px 6px",background:"#fff",border:"1px solid #e2e8f0",borderRadius:8,cursor:"pointer",fontSize:12,minHeight:84,transition:"border-color 120ms, box-shadow 120ms"},BI={display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",gap:6,padding:"14px 6px",background:"#f8fafc",border:"1px dashed #cbd5e1",borderRadius:8,cursor:"not-allowed",fontSize:12,minHeight:84,color:"#94a3b8",opacity:.65},WI={fontSize:9,fontWeight:600,color:"#92400e",background:"#fef3c7",padding:"1px 6px",borderRadius:999,textTransform:"none",letterSpacing:0,lineHeight:1.6},jI={fontSize:22,lineHeight:1,color:"#475569"},HI={fontSize:12,fontWeight:500,color:"#0f172a",textAlign:"center",wordBreak:"break-word"},FI={fontFamily:'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',fontSize:10,color:"#64748b",textAlign:"center",wordBreak:"break-all"},wf={padding:24,color:"#64748b",fontSize:13,textAlign:"center"};function KI(e){const t=[],n=r=>{if(Array.isArray(r))for(const s of r)s&&typeof s=="object"&&t.push(s)};n(e.children);const o=e.responsive;if(o&&typeof o=="object")for(const r of Object.values(o))r&&typeof r=="object"&&n(r.children);return t}function kf(e){const t=[],n=o=>{const r="isolatedState"in o&&o.isolatedState!=null,s=o.isolatedScopeId;(r||typeof s=="string"&&s.length>0)&&t.push({scopeId:typeof s=="string"?s:void 0,initialState:o.isolatedState&&typeof o.isolatedState=="object"?o.isolatedState:void 0});for(const a of KI(o))n(a)};if(Array.isArray(e))for(const o of e)o&&typeof o=="object"&&n(o);return t}const VI=["scroll","selector","wizard","slider"];function UI(e,t){const n=new Set;for(const o of e)o.scopeId&&n.add(o.scopeId);for(const o of t){n.add(o);for(const r of VI)n.add(`${o}-${r}`)}return Array.from(n)}function qI(e,t,n){const o=t.label;return typeof o=="string"?o.startsWith("$t:")?n(o.slice(3)):o:e}function ci({controlKey:e,control:t,node:n,t:o,onPatch:r,candidates:s,bindingCandidates:a,scope:l=Wt}){const d=An(t.widget),c=qI(e,t,o),p=u.useMemo(()=>Io(n,t,l),[n,t,l]),f=t.widget==="i18n-text"&&t.apply?.type==="propValue",y=t.widget==="text"&&t.apply?.type==="propValue",g=p.darkReadonly===!0||l.colorScheme==="dark"&&t.apply!==void 0&&!Ks(t.apply),m=t.apply,h=l.colorScheme==="dark"&&!g&&m?.type==="classToken"&&typeof m.tokenTemplate=="string"&&m.tokenTemplate.includes("{value}"),v=p.scopedValue===void 0&&p.baseFallback!==void 0,b=u.useCallback(x=>{if(g)return;const k=pn(n,t,x,l);r(k)},[n,t,r,l,g]);return i.jsxs("div",{className:"g7le-control-row","data-testid":`g7le-control-${e}`,style:GI,children:[i.jsxs("span",{className:"g7le-control-label",style:YI,children:[c,p.conflict&&i.jsx("span",{"data-testid":`g7le-control-conflict-${e}`,title:o("layout_editor.property_modal.value_conflict"),style:XI,children:"!"})]}),i.jsx("div",{className:"g7le-control-widget",style:{flex:1,minWidth:0,opacity:v?.45:1},"data-placeholder":v?"true":void 0,children:f?i.jsx(vt,{value:typeof p.value=="string"?p.value:"",onChange:x=>b(x),t:o,testidPrefix:`g7le-prop-i18n-${e}`,candidates:a,enableExpressionTree:!0,expressionTreeCollapsible:!0}):y?i.jsx(Ve,{value:typeof p.value=="string"?p.value:"",onChange:x=>b(x===""?void 0:x),t:o,testidPrefix:`g7le-prop-value-${e}`,candidates:a}):g?i.jsx("span",{"data-testid":`g7le-control-dark-readonly-${e}`,style:ZI,children:o("layout_editor.property_modal.dark_code_only")}):d?i.jsx(d,{control:t,value:p.value,onChange:b,t:o,candidates:s,bindingCandidates:a,freeValueDisabled:h}):i.jsx("span",{"data-testid":`g7le-control-unsupported-${e}`,style:JI,children:o("layout_editor.property_modal.unsupported_widget")})})]})}const GI={display:"flex",alignItems:"center",gap:12,padding:"6px 0",minHeight:32,minWidth:0},YI={fontSize:12,color:"#475569",minWidth:88,display:"inline-flex",alignItems:"center",gap:4},XI={display:"inline-flex",width:14,height:14,borderRadius:7,background:"#f59e0b",color:"#fff",fontSize:10,alignItems:"center",justifyContent:"center",fontWeight:700},JI={fontSize:11,color:"#94a3b8",fontStyle:"italic"},ZI={fontSize:11,color:"#94a3b8",fontStyle:"italic"};function ui(e,t,n){return typeof e=="string"?e.startsWith("$t:")?n(e.slice(3)):e:t}function QI(e,t){const n=e.props??{};return t.key in n?n[t.key]:t.default}function RI({spec:e,node:t,t:n,onPatchProp:o}){const r=e.groups??[];return i.jsx("div",{className:"g7le-composite-settings","data-testid":"g7le-composite-settings",style:tA,children:r.map((s,a)=>i.jsxs("div",{className:"g7le-settings-group",style:nA,children:[i.jsx("div",{style:oA,children:ui(s.label,n("layout_editor.property_modal.settings_group",{index:a+1}),n)}),(s.fields??[]).map(l=>i.jsxs("div",{className:"g7le-settings-field","data-testid":`g7le-setting-${l.key}`,style:rA,children:[i.jsx("span",{style:iA,children:ui(l.label,l.key,n)}),i.jsx("div",{style:{flex:1},children:eA(l,t,n,o)})]},l.key))]},a))})}function eA(e,t,n,o){const r=QI(t,e);switch(e.type){case"number":return i.jsx("input",{type:"number","data-testid":`g7le-setting-input-${e.key}`,value:typeof r=="number"?r:"",min:e.min,max:e.max,onChange:s=>o(e.key,s.target.value===""?void 0:Number(s.target.value)),style:ka});case"text":return i.jsx("input",{type:"text","data-testid":`g7le-setting-input-${e.key}`,value:typeof r=="string"?r:"",onChange:s=>o(e.key,s.target.value===""?void 0:s.target.value),style:ka});case"toggle":return i.jsx("input",{type:"checkbox","data-testid":`g7le-setting-input-${e.key}`,checked:r===!0,onChange:s=>o(e.key,s.target.checked)});case"checkbox-group":{const s=Array.isArray(r)?r:[];return i.jsx("div",{style:sA,"data-testid":`g7le-setting-input-${e.key}`,children:(e.options??[]).map(a=>{const l=s.includes(a.value);return i.jsxs("label",{style:aA,children:[i.jsx("input",{type:"checkbox",checked:l,onChange:d=>{const c=d.target.checked?[...s,a.value]:s.filter(p=>p!==a.value);o(e.key,c)}}),ui(a.label,String(a.value),n)]},String(a.value))})})}default:return i.jsxs("select",{"data-testid":`g7le-setting-input-${e.key}`,value:r==null?"":String(r),onChange:s=>{const a=s.target.value;if(a===""){o(e.key,void 0);return}const l=(e.options??[]).find(d=>String(d.value)===a);o(e.key,l?l.value:a)},style:ka,children:[i.jsx("option",{value:"",children:"—"}),(e.options??[]).map(s=>i.jsx("option",{value:String(s.value),children:ui(s.label,String(s.value),n)},String(s.value)))]})}}const tA={display:"flex",flexDirection:"column",gap:16},nA={display:"flex",flexDirection:"column",gap:6},oA={fontSize:12,fontWeight:700,color:"#0f172a",borderBottom:"1px solid #e2e8f0",paddingBottom:4},rA={display:"flex",alignItems:"center",gap:12,minHeight:30},iA={fontSize:12,color:"#475569",minWidth:96},ka={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,width:"100%",boxSizing:"border-box"},sA={display:"flex",flexWrap:"wrap",gap:10},aA={display:"inline-flex",alignItems:"center",gap:4,fontSize:12,color:"#0f172a"},lA=["data_binding","isolatedScopeId","parentFormContextProp","skipBindingKeys","isolatedState","component_layout","sortable","itemTemplate"],dA=/\{\{|\$switch|\$get|\|\s*(date|number|truncate|currency|relative)\b/;function pi(e,t=0){return t>6?!1:typeof e=="string"?dA.test(e):Array.isArray(e)?e.some(n=>pi(n,t+1)):e&&typeof e=="object"?Object.values(e).some(n=>pi(n,t+1)):!1}function cA(e){if(Xl(e))return!1;const t=jl(e);return t.length===0?!1:t.some(n=>n.isComplex)}function Sf(e){const t=[],n=e.props??{};for(const o of lA)o in e&&e[o]!==void 0?t.push({key:o,kind:"developer_prop"}):o in n&&n[o]!==void 0&&t.push({key:`props.${o}`,kind:"developer_prop"});typeof e.text=="string"?cA(e.text)&&t.push({key:"text",kind:"complex_expression"}):pi(e.text)&&t.push({key:"text",kind:"complex_expression"});for(const[o,r]of Object.entries(n))o==="style"||o==="className"||pi(r)&&t.push({key:`props.${o}`,kind:"complex_expression"});return t}function uA(e){return Sf(e).length>0}const pA=new Set(["permissions","blur_until_loaded","comment"]);function fA({node:e,advanced:t,t:n,onPatch:o,permissionCandidates:r}){const s=u.useMemo(()=>Sf(e),[e]),a=(l,d)=>{const c={...e};d===void 0?delete c[l]:c[l]=d,o(c)};return i.jsxs("div",{className:"g7le-advanced-props","data-testid":"g7le-advanced-props",style:gA,children:[t.map(l=>{if(!pA.has(l))return i.jsxs("div",{"data-testid":`g7le-advanced-deferred-${l}`,style:yA,children:[n("layout_editor.property_modal.advanced_deferred"),":",l]},l);if(l==="permissions")return i.jsxs("div",{className:"g7le-advanced-group","data-testid":"g7le-advanced-permissions",style:Sa,children:[i.jsx("div",{style:Ca,children:n("layout_editor.property_modal.advanced.permissions")}),i.jsx(gs,{control:{widget:"tag-input"},value:e.permissions,onChange:d=>a("permissions",d),t:n,candidates:r})]},l);if(l==="blur_until_loaded"){const d=e.blur_until_loaded===!0||e.blur_until_loaded!=null&&e.blur_until_loaded!==!1;return i.jsxs("div",{className:"g7le-advanced-group","data-testid":"g7le-advanced-blur",style:Sa,children:[i.jsx("div",{style:Ca,children:n("layout_editor.property_modal.advanced.blur_until_loaded")}),i.jsxs("label",{style:{display:"inline-flex",alignItems:"center",gap:6,fontSize:12},children:[i.jsx("input",{type:"checkbox","data-testid":"g7le-advanced-blur-toggle",checked:!!d,onChange:c=>a("blur_until_loaded",c.target.checked?!0:void 0)}),n("layout_editor.property_modal.advanced.blur_hint")]})]},l)}return i.jsxs("div",{className:"g7le-advanced-group","data-testid":"g7le-advanced-comment",style:Sa,children:[i.jsx("div",{style:Ca,children:n("layout_editor.property_modal.advanced.comment")}),i.jsx("input",{type:"text","data-testid":"g7le-advanced-comment-input",value:typeof e.comment=="string"?e.comment:"",onChange:d=>a("comment",d.target.value===""?void 0:d.target.value),style:hA})]},l)}),s.length>0&&i.jsxs("div",{className:"g7le-advanced-preserved","data-testid":"g7le-advanced-preserved",style:bA,children:[i.jsx("div",{style:mA,children:n("layout_editor.property_modal.advanced_preserved_title",{count:s.length})}),i.jsx("ul",{style:vA,children:s.map(l=>i.jsxs("li",{"data-testid":`g7le-advanced-preserved-${l.key}`,style:_A,children:[i.jsx("code",{style:{fontSize:11},children:l.key}),i.jsx("span",{style:{fontSize:11,color:"#94a3b8"},children:l.kind==="developer_prop"?n("layout_editor.property_modal.advanced_kind_developer"):n("layout_editor.property_modal.advanced_kind_expression")})]},l.key))})]})]})}const gA={display:"flex",flexDirection:"column",gap:14},Sa={display:"flex",flexDirection:"column",gap:6},Ca={fontSize:12,fontWeight:700,color:"#0f172a"},hA={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,width:"100%",boxSizing:"border-box"},yA={fontSize:11,color:"#94a3b8",fontStyle:"italic"},bA={borderTop:"1px dashed #cbd5e1",paddingTop:10,marginTop:4},mA={fontSize:11,color:"#b45309",marginBottom:6},vA={listStyle:"none",margin:0,padding:0,display:"flex",flexDirection:"column",gap:4},_A={display:"flex",justifyContent:"space-between",gap:8,padding:"3px 0"},xA={onClick:"click",onHover:"mouseenter",onChange:"change",onSubmit:"submit",onMount:"mount",onUnmount:"unmount",onFocus:"focus",onBlur:"blur"};function $a(e){return xA[e]??e.replace(/^on/,"").toLowerCase()}function Cf(e){const t=e.actions;if(Array.isArray(t))return t.filter(r=>!!r&&typeof r=="object");const n=e.props??{};if(Array.isArray(n.actions))return n.actions.filter(r=>!!r&&typeof r=="object");const o=n.events;if(o&&typeof o=="object"){const r=[];for(const[s,a]of Object.entries(o)){if(!Array.isArray(a))continue;const l=$a(s);for(const d of a)d&&typeof d=="object"&&r.push("type"in d?d:{type:l,...d})}return r}return[]}function wA(e,t){const n=$a(t);return Cf(e).filter(o=>o.type===n||t==="onClick"&&o.type===void 0)}function kA(e,t,n){const o=$a(t),r=Cf(e).filter(d=>!(d.type===o||t==="onClick"&&d.type===void 0)),s=n.map(d=>"type"in d?d:{type:o,...d}),a={...e};if(a.props&&typeof a.props=="object"){const d={...a.props};let c=!1;"actions"in d&&(delete d.actions,c=!0),"events"in d&&(delete d.events,c=!0),c&&(a.props=d)}const l=[...r,...s];return l.length===0?delete a.actions:a.actions=l,a}function SA({node:e,spec:t,capability:n,t:o,onPatchNode:r,pageCandidates:s,dataSourceCandidates:a,stateKeyCandidates:l,bindingCandidates:d}){const c=t?.actionRecipes,p=u.useMemo(()=>({pageCandidates:s,dataSourceCandidates:a,stateKeyCandidates:l,bindingCandidates:d}),[s,a,l,d]),f=Array.isArray(n?.events)?n.events:[];return f.length===0?i.jsx("div",{"data-testid":"g7le-action-no-events",style:$f,children:o("layout_editor.action.no_events")}):!c||Object.keys(c).length===0?i.jsx("div",{"data-testid":"g7le-action-no-recipes",style:$f,children:o("layout_editor.action.no_recipes")}):i.jsx("div",{className:"g7le-action-editor","data-testid":"g7le-action-editor",style:{display:"flex",flexDirection:"column",gap:14},children:f.map(y=>i.jsxs("div",{className:"g7le-action-event-slot","data-testid":`g7le-action-event-${y}`,children:[i.jsx("div",{style:CA,children:o(`layout_editor.action.event.${y}`)}),i.jsx(Gt,{actions:wA(e,y),onChange:g=>r(kA(e,y,g)),t:o,recipes:c,candidatePools:p,actionChipCandidates:t?.actionChipCandidates??null,addContext:"component",showSourceBadge:!0,testIdPrefix:`g7le-action-slot-${y}`})]},y))})}const $f={fontSize:12,color:"#94a3b8",padding:"16px 0",textAlign:"center"},CA={fontSize:12,fontWeight:700,color:"#475569",marginBottom:6};function Ea(e,t,n){if(!t)return n;const o=e(t);return!o||o===t||o.startsWith("$t:")?n:o}function $A(e,t){return Ea(t,e.labelKey,e.path||e.sourceId)}function EA({node:e,spec:t,candidates:n,t:o,onPatchNode:r}){const a=(e.props??{})[t.propKey],l=typeof a=="string"?a:void 0,d=u.useMemo(()=>Kt(a),[a]),c=l!==void 0&&l!==""&&d===null,[p,f]=u.useState(!1),[y,g]=u.useState(""),m=Ea(o,t.label,t.propKey),h=o(t.shape==="array"?"layout_editor.binding.shape_array":t.shape==="object"?"layout_editor.binding.shape_object":"layout_editor.binding.shape_scalar"),v=u.useMemo(()=>ji(n,t.shape).map(_=>({..._,resolvedLabel:$A(_,o)})),[n,t.shape,o]),b=u.useMemo(()=>Hi(v,y),[v,y]),x=w=>{const _={...e.props??{}};w===void 0?delete _[t.propKey]:_[t.propKey]=w,r({...e,props:_})},k=w=>{x($n(w.sourceId,w.path,t.shape)),f(!1),g("")};return i.jsxs("div",{"data-testid":`g7le-binding-row-${t.propKey}`,style:DA,children:[i.jsxs("div",{style:NA,children:[i.jsx("span",{style:{fontWeight:600,fontSize:12},children:m}),i.jsx("span",{style:zA,children:h}),t.required&&i.jsx("span",{"data-testid":`g7le-binding-required-${t.propKey}`,style:MA,children:o("layout_editor.binding.required")})]}),c?i.jsxs("div",{"data-testid":`g7le-binding-complex-${t.propKey}`,style:BA,children:[o("layout_editor.binding.complex"),i.jsx("code",{style:Ef,children:l})]}):d?i.jsxs("div",{style:LA,children:[i.jsx("code",{"data-testid":`g7le-binding-expr-${t.propKey}`,style:Ef,children:l}),i.jsx("button",{type:"button","data-testid":`g7le-binding-clear-${t.propKey}`,onClick:()=>x(void 0),style:OA,children:o("layout_editor.binding.clear")})]}):i.jsx("div",{"data-testid":`g7le-binding-empty-${t.propKey}`,style:PA,children:o("layout_editor.binding.not_connected")}),d&&(t.shape==="array"||t.shape==="object")&&t.itemFields&&t.itemFields.length>0&&i.jsxs("div",{style:WA,children:[o("layout_editor.binding.item_fields"),": ",t.itemFields.join(", ")]}),!c&&i.jsxs("div",{style:{marginTop:4},children:[i.jsxs("button",{type:"button","data-testid":`g7le-binding-search-toggle-${t.propKey}`,onClick:()=>f(w=>!w),style:jA,children:["🔍 ",o("layout_editor.binding.search")]}),p&&i.jsxs("div",{"data-testid":`g7le-binding-picker-${t.propKey}`,style:HA,children:[i.jsx("input",{type:"text",autoFocus:!0,"data-testid":`g7le-binding-search-input-${t.propKey}`,value:y,placeholder:o("layout_editor.binding.search_placeholder"),onChange:w=>g(w.target.value),style:FA}),i.jsx("div",{style:KA,children:b.length===0?i.jsx("div",{style:VA,children:o("layout_editor.binding.no_results")}):b.map(w=>i.jsxs("button",{type:"button","data-testid":`g7le-binding-candidate-${w.expression}`,onClick:()=>k(w),style:UA,children:[i.jsxs("span",{style:{display:"flex",flexDirection:"column",alignItems:"flex-start"},children:[i.jsx("span",{style:{fontSize:12},children:w.resolvedLabel}),i.jsxs("span",{style:qA,children:[Ea(o,w.groupLabelKey,w.sourceId)," · ",w.path||w.sourceId]})]}),i.jsx("span",{style:GA,children:w.preview})]},w.expression))})]})]})]})}function IA({node:e,dataProps:t,candidates:n,t:o,onPatchNode:r}){return!Array.isArray(t)||t.length===0?null:i.jsxs("div",{"data-testid":"g7le-data-binding-section",style:AA,children:[i.jsxs("div",{style:TA,children:["🔗 ",o("layout_editor.binding.section_title")]}),t.map(s=>i.jsx(EA,{node:e,spec:s,candidates:n,t:o,onPatchNode:r},s.propKey)),i.jsx("div",{style:YA})]})}const AA={display:"flex",flexDirection:"column",marginBottom:8},TA={fontSize:12,fontWeight:700,color:"#0f172a",marginBottom:6},DA={display:"flex",flexDirection:"column",gap:4,padding:"8px 0"},NA={display:"flex",alignItems:"center",gap:6},zA={fontSize:10,padding:"1px 6px",borderRadius:4,background:"#e0e7ff",color:"#3730a3"},MA={fontSize:10,padding:"1px 6px",borderRadius:4,background:"#fef3c7",color:"#92400e"},LA={display:"flex",alignItems:"center",gap:6},PA={fontSize:11,color:"#94a3b8"},Ef={fontSize:11,fontFamily:"monospace",background:"#f1f5f9",padding:"2px 6px",borderRadius:4,color:"#0f172a",wordBreak:"break-all"},OA={fontSize:11,border:"1px solid #e2e8f0",borderRadius:4,background:"#fff",color:"#dc2626",padding:"2px 8px",cursor:"pointer"},BA={display:"flex",flexDirection:"column",gap:2,fontSize:11,color:"#64748b"},WA={fontSize:10,color:"#94a3b8"},jA={fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#334155",padding:"4px 10px",cursor:"pointer",textAlign:"left",width:"100%"},HA={marginTop:4,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",padding:6},FA={width:"100%",padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,boxSizing:"border-box"},KA={marginTop:4,maxHeight:200,overflowY:"auto",display:"flex",flexDirection:"column",gap:2},VA={fontSize:11,color:"#94a3b8",padding:"8px 0",textAlign:"center"},UA={display:"flex",alignItems:"center",justifyContent:"space-between",gap:8,border:"none",background:"transparent",cursor:"pointer",padding:"6px 8px",borderRadius:4,textAlign:"left"},qA={fontSize:10,color:"#94a3b8"},GA={fontSize:10,color:"#64748b",fontFamily:"monospace",whiteSpace:"nowrap"},YA={borderTop:"1px solid #e2e8f0",marginTop:4};function XA({node:e,candidates:t,t:n,onPatchNode:o,templateIdentifier:r}){const s=typeof e.text=="string"?e.text:"",{state:a}=Je(),l=r??a.templateIdentifier,d=a.selectedRoute?.layoutName??null,c=a.locale,[p,f]=u.useState(!1),y=/^\s*\$t:custom\.[A-Za-z0-9._-]+\s*$/.test(s),m=Xl(s)&&(mt(s)?.key.startsWith("custom.")??!1)||y,h=u.useMemo(()=>m?mt(s):null,[m,s]),v=u.useMemo(()=>m?[]:jl(s),[m,s]),b=N=>{o({...e,text:N})},x=()=>{if(!(typeof window>"u"))try{window.dispatchEvent(new CustomEvent(kt,{detail:{templateIdentifier:l,locale:c}}))}catch{}},k=(N,$)=>{const A=$n($.sourceId,$.path,"scalar");b(yb(s,N,A))},w=N=>{const $=mt(s);b(ho(s,N));const A=$?.key;l&&A&&Bd(l,A,N).then(x)},_=N=>{const $=n(N);return $&&$!==N&&!$.startsWith("$t:")?$:""},S=N=>N.replace(/\$t:[a-zA-Z0-9._-]+/g,$=>_($.slice(3))).replace(/\s+/g," ").trim(),E=N=>{p||(f(!0),ss(l,d,c,s,s.length,N.sourceId,N.path,"scalar",S,$=>n($)).then($=>{$.kind==="ok"&&(b($.text),x())}).finally(()=>f(!1)))},C=N=>{p||!h&&!y||(f(!0),_r(l,c,s,Number.MAX_SAFE_INTEGER,N.sourceId,N.path,"scalar").then($=>{$.kind==="ok"&&(b($.text),x())}).finally(()=>f(!1)))};if(m){const N=h?.params??[];return i.jsxs("div",{"data-testid":"g7le-inline-binding-section",style:If,children:[i.jsxs("div",{style:Af,children:["🔗 ",n("layout_editor.inline_binding.section_title")]}),N.length===0?i.jsx("div",{"data-testid":"g7le-inline-binding-none",style:Nf,children:n("layout_editor.inline_binding.none")}):N.map($=>i.jsx("div",{"data-testid":`g7le-inline-binding-param-${$.name}`,style:Tf,children:$.parsed===null?i.jsxs("div",{"data-testid":`g7le-inline-binding-param-complex-${$.name}`,style:ZA,children:[n("layout_editor.inline_binding.complex"),i.jsx("code",{style:Ia,children:$.expression})]}):i.jsxs(i.Fragment,{children:[i.jsxs("div",{style:Df,children:[i.jsx("code",{"data-testid":`g7le-inline-binding-param-expr-${$.name}`,style:Ia,children:$.expression}),i.jsx("button",{type:"button","data-testid":`g7le-inline-binding-param-clear-${$.name}`,onClick:()=>w($.name),style:JA,children:n("layout_editor.inline_binding.clear")})]}),i.jsx($t,{candidates:t,t:n,onSelect:A=>k($.name,A),testIdSuffix:`param-${$.name}`})]})},$.name)),i.jsxs("div",{"data-testid":"g7le-inline-binding-param-append",style:zf,children:[i.jsx("div",{style:Aa,children:n("layout_editor.inline_binding.append_hint")}),i.jsx($t,{candidates:t,t:n,onSelect:C,testIdSuffix:"param-append"})]}),i.jsx("div",{"data-testid":"g7le-inline-binding-param-hint",style:Aa,children:n("layout_editor.inline_binding.param_hint")}),i.jsx("div",{style:Mf})]})}return i.jsxs("div",{"data-testid":"g7le-inline-binding-section",style:If,children:[i.jsxs("div",{style:Af,children:["🔗 ",n("layout_editor.inline_binding.section_title")]}),v.length===0?i.jsx("div",{"data-testid":"g7le-inline-binding-none",style:Nf,children:n("layout_editor.inline_binding.none")}):v.map(N=>i.jsx("div",{"data-testid":`g7le-inline-binding-row-${N.bindingIndex}`,style:Tf,children:i.jsx("div",{style:Df,children:i.jsx("code",{"data-testid":`g7le-inline-binding-expr-${N.bindingIndex}`,style:Ia,children:N.expression})})},N.bindingIndex)),i.jsxs("div",{"data-testid":"g7le-inline-binding-append",style:zf,children:[i.jsx("div",{style:Aa,children:n("layout_editor.inline_binding.append_hint")}),i.jsx($t,{candidates:t,t:n,onSelect:E,testIdSuffix:"append"})]}),i.jsx("div",{style:Mf})]})}const If={display:"flex",flexDirection:"column",marginBottom:8},Af={fontSize:12,fontWeight:700,color:"#0f172a",marginBottom:6},Tf={display:"flex",flexDirection:"column",gap:4,padding:"8px 0"},Df={display:"flex",alignItems:"center",gap:6},Nf={fontSize:11,color:"#94a3b8"},Ia={fontSize:11,fontFamily:"monospace",background:"#f1f5f9",padding:"2px 6px",borderRadius:4,color:"#0f172a",wordBreak:"break-all"},JA={fontSize:11,border:"1px solid #e2e8f0",borderRadius:4,background:"#fff",color:"#dc2626",padding:"2px 8px",cursor:"pointer"},ZA={display:"flex",flexDirection:"column",gap:2,fontSize:11,color:"#64748b"},zf={display:"flex",flexDirection:"column",gap:2,padding:"8px 0 0"},Aa={fontSize:11,color:"#475569",fontWeight:600},Mf={borderTop:"1px solid #e2e8f0",marginTop:4};function Lf(e,t,n){if(!t)return n;const o=e(t);return!o||o===t||o.startsWith("$t:")?n:o}function QA(e,t){return Lf(t,e.labelKey,e.path||e.sourceId)}function RA(e){const t=e.iteration;return t&&typeof t=="object"?t:null}function e6({node:e,candidates:t,t:n,onPatchNode:o}){const r=RA(e),s=r?.source,a=typeof s=="string"?s:void 0,l=u.useMemo(()=>Kt(s),[s]),d=a!==void 0&&a!==""&&l===null,[c,p]=u.useState(!1),[f,y]=u.useState(""),g=u.useMemo(()=>ji(t,"array").map(w=>({...w,resolvedLabel:QA(w,n)})),[t,n]),m=u.useMemo(()=>Hi(g,f),[g,f]);if(!r)return null;const h=typeof r.item_var=="string"?r.item_var:void 0,v=typeof r.index_var=="string"?r.index_var:void 0,b=k=>{const w={...r};k===void 0?delete w.source:w.source=k,o({...e,iteration:w})},x=k=>{b($n(k.sourceId,k.path,"array")),p(!1),y("")};return i.jsxs("div",{"data-testid":"g7le-iteration-binding-section",style:t6,children:[i.jsxs("div",{style:n6,children:["🔁 ",n("layout_editor.binding.iteration_title")]}),i.jsx("div",{style:o6,children:n("layout_editor.binding.iteration_hint")}),i.jsxs("div",{style:r6,children:[i.jsxs("div",{style:i6,children:[i.jsx("span",{style:{fontWeight:600,fontSize:12},children:n("layout_editor.binding.iteration_label")}),i.jsx("span",{style:s6,children:n("layout_editor.binding.shape_array")})]}),d?i.jsxs("div",{"data-testid":"g7le-iteration-binding-complex",style:c6,children:[n("layout_editor.binding.complex"),i.jsx("code",{style:Pf,children:a})]}):l?i.jsxs("div",{style:a6,children:[i.jsx("code",{"data-testid":"g7le-iteration-binding-expr",style:Pf,children:a}),i.jsx("button",{type:"button","data-testid":"g7le-iteration-binding-clear",onClick:()=>b(void 0),style:d6,children:n("layout_editor.binding.clear")})]}):i.jsx("div",{"data-testid":"g7le-iteration-binding-empty",style:l6,children:n("layout_editor.binding.not_connected")}),(h||v)&&i.jsxs("div",{"data-testid":"g7le-iteration-vars",style:u6,children:[h&&i.jsxs("span",{children:[n("layout_editor.binding.iteration_item_var"),": ",i.jsx("code",{style:Of,children:h})]}),v&&i.jsxs("span",{children:[n("layout_editor.binding.iteration_index_var"),": ",i.jsx("code",{style:Of,children:v})]})]}),!d&&i.jsxs("div",{style:{marginTop:4},children:[i.jsxs("button",{type:"button","data-testid":"g7le-iteration-binding-search-toggle",onClick:()=>p(k=>!k),style:p6,children:["🔍 ",n("layout_editor.binding.search")]}),c&&i.jsxs("div",{"data-testid":"g7le-iteration-binding-picker",style:f6,children:[i.jsx("input",{type:"text",autoFocus:!0,"data-testid":"g7le-iteration-binding-search-input",value:f,placeholder:n("layout_editor.binding.search_placeholder"),onChange:k=>y(k.target.value),style:g6}),i.jsx("div",{style:h6,children:m.length===0?i.jsx("div",{style:y6,children:n("layout_editor.binding.no_results")}):m.map(k=>i.jsxs("button",{type:"button","data-testid":`g7le-iteration-binding-candidate-${k.expression}`,onClick:()=>x(k),style:b6,children:[i.jsxs("span",{style:{display:"flex",flexDirection:"column",alignItems:"flex-start"},children:[i.jsx("span",{style:{fontSize:12},children:k.resolvedLabel}),i.jsxs("span",{style:m6,children:[Lf(n,k.groupLabelKey,k.sourceId)," · ",k.path||k.sourceId]})]}),i.jsx("span",{style:v6,children:k.preview})]},k.expression))})]})]})]}),i.jsx("div",{style:_6})]})}const t6={display:"flex",flexDirection:"column",marginBottom:8},n6={fontSize:12,fontWeight:700,color:"#0f172a",marginBottom:4},o6={fontSize:10,color:"#94a3b8",marginBottom:6,lineHeight:1.4},r6={display:"flex",flexDirection:"column",gap:4,padding:"8px 0"},i6={display:"flex",alignItems:"center",gap:6},s6={fontSize:10,padding:"1px 6px",borderRadius:4,background:"#e0e7ff",color:"#3730a3"},a6={display:"flex",alignItems:"center",gap:6},l6={fontSize:11,color:"#94a3b8"},Pf={fontSize:11,fontFamily:"monospace",background:"#f1f5f9",padding:"2px 6px",borderRadius:4,color:"#0f172a",wordBreak:"break-all"},d6={fontSize:11,border:"1px solid #e2e8f0",borderRadius:4,background:"#fff",color:"#dc2626",padding:"2px 8px",cursor:"pointer"},c6={display:"flex",flexDirection:"column",gap:2,fontSize:11,color:"#64748b"},u6={display:"flex",gap:12,fontSize:10,color:"#94a3b8",marginTop:2},Of={fontSize:10,fontFamily:"monospace",background:"#f1f5f9",padding:"1px 4px",borderRadius:3,color:"#475569"},p6={fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#334155",padding:"4px 10px",cursor:"pointer",textAlign:"left",width:"100%"},f6={marginTop:4,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",padding:6},g6={width:"100%",padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,boxSizing:"border-box"},h6={marginTop:4,maxHeight:200,overflowY:"auto",display:"flex",flexDirection:"column",gap:2},y6={fontSize:11,color:"#94a3b8",padding:"8px 0",textAlign:"center"},b6={display:"flex",alignItems:"center",justifyContent:"space-between",gap:8,border:"none",background:"transparent",cursor:"pointer",padding:"6px 8px",borderRadius:4,textAlign:"left"},m6={fontSize:10,color:"#94a3b8"},v6={fontSize:10,color:"#64748b",fontFamily:"monospace",whiteSpace:"nowrap"},_6={borderTop:"1px solid #e2e8f0",marginTop:4},x6=new Set(["Span","P","H1","H2","H3","H4","H5","H6","A","Button","Li","Label"]);function w6(e,t){if(!e||e.iteration&&typeof e.iteration=="object")return!1;const n=t?.textBinding;if(n===!1)return!1;if(n===!0)return!0;const o=typeof e.name=="string"?e.name:"";return x6.has(o)?!0:typeof e.text=="string"}const hn={direction:"flexDirection",justify:"flexJustify",align:"flexAlign",wrap:"flexWrap",gap:"flexGap",enable:"flexEnable"},fi={grow:"flexItemGrow",selfAlign:"flexItemAlign",order:"flexItemOrder"};function Bf(e){const t=e?.flexEditor;return t==="container"||t==="item"||t==="auto"?t:null}function k6(e){if(!e||typeof window>"u"||typeof window.getComputedStyle!="function")return!1;const t=(window.getComputedStyle(e).display??"").toLowerCase();return t==="flex"||t==="inline-flex"}function S6(e,t,n=Wt){if(!e||!t)return!1;const o=Io(e,t,n),r=t.options,s=Array.isArray(r)?t.onValue??r[0]?.value:void 0,a=l=>s!==void 0?l===s:l!==void 0&&l!==!1;return n.breakpoint!=="base"?a(o.scopedRawValue):o.matched&&a(o.value)}function C6(e,t,n=!1,o=!1,r=Wt){if(e==="container")return{showContainer:!0,showEnableButton:!1,showDisableButton:!1};if(e==="item")return{showContainer:!1,showEnableButton:!1,showDisableButton:!1};if(e==="auto"){const s=r.breakpoint==="base";return!o&&!s?{showContainer:!1,showEnableButton:!1,showDisableButton:!1}:(o?n:k6(t))?{showContainer:!0,showEnableButton:!1,showDisableButton:o}:{showContainer:!1,showEnableButton:!0,showDisableButton:!1}}return{showContainer:!1,showEnableButton:!1,showDisableButton:!1}}const $6={[hn.direction]:"layout_editor.flex.direction",[hn.justify]:"layout_editor.flex.justify",[hn.align]:"layout_editor.flex.align",[hn.wrap]:"layout_editor.flex.wrap",[hn.gap]:"layout_editor.flex.gap"},E6={[fi.grow]:"layout_editor.flex.item_grow",[fi.selfAlign]:"layout_editor.flex.item_align",[fi.order]:"layout_editor.flex.item_order"};function I6({node:e,spec:t,capability:n,t:o,onPatchNode:r,liveElement:s,scope:a=Wt}){const l=Bf(n),d=Ct(t,hn.enable),c=S6(e,d,a),{showContainer:p,showEnableButton:f,showDisableButton:y}=C6(l,s,c,!!d,a),g=Object.values(hn).filter(_=>_!==hn.enable&&Ct(t,_)),m=Object.values(fi).filter(_=>Ct(t,_)),h=d?.options,v=d?.onValue??h?.[0]?.value??!0,b=Array.isArray(h)&&h.length>=2?h[1].value:void 0,x=()=>{if(!d)return;const _=pn(e,d,v,a);r(_)},k=()=>{if(!d)return;const _=a.breakpoint!=="base"&&b!==void 0?b:void 0,S=pn(e,d,_,a);r(S)},w=(_,S,E)=>i.jsx("div",{"data-testid":E,style:{display:"flex",flexDirection:"column"},children:_.map(C=>{const N=Ct(t,C);if(!N)return null;const $=N.label?N:{...N,label:`$t:${S[C]}`};return i.jsx(ci,{controlKey:C,control:$,node:e,t:o,onPatch:r,scope:a},C)})});return i.jsxs("div",{className:"g7le-flex-editor","data-testid":"g7le-flex-editor",style:{display:"flex",flexDirection:"column",gap:12},children:[f&&i.jsx("button",{type:"button","data-testid":"g7le-flex-enable",onClick:x,style:A6,children:o("layout_editor.flex.make_flex")}),p&&g.length>0&&i.jsxs("div",{className:"g7le-flex-container-section","data-testid":"g7le-flex-container-section",children:[i.jsx("div",{style:Wf,children:o("layout_editor.flex.container_title")}),w(g,$6,"g7le-flex-container-controls"),y&&i.jsx("button",{type:"button","data-testid":"g7le-flex-disable",onClick:k,style:T6,children:o("layout_editor.flex.unmake_flex")})]}),l==="item"&&m.length>0&&i.jsxs("div",{className:"g7le-flex-item-section","data-testid":"g7le-flex-item-section",children:[i.jsx("div",{style:Wf,children:o("layout_editor.flex.item_title")}),w(m,E6,"g7le-flex-item-controls")]}),!p&&!f&&l!=="item"&&i.jsx("div",{"data-testid":"g7le-flex-empty",style:D6,children:o("layout_editor.flex.not_flex")})]})}const Wf={fontSize:12,fontWeight:700,color:"#0f172a",marginBottom:6},A6={padding:"8px 12px",fontSize:12,border:"1px dashed #2563eb",borderRadius:6,background:"#eff6ff",color:"#2563eb",cursor:"pointer"},T6={marginTop:8,padding:"6px 10px",fontSize:12,border:"1px solid #e2e8f0",borderRadius:6,background:"#f8fafc",color:"#64748b",cursor:"pointer"},D6={fontSize:12,color:"#94a3b8",padding:"16px 0",textAlign:"center"},jf=["desktop","tablet","mobile","portable"],Ta=1280,N6=820,z6=390,M6=1920,L6=320;function P6(e){const t=[],n=new Set,o=r=>{if(!r||typeof r!="object")return;const s=r.responsive;if(s&&typeof s=="object")for(const a of Object.keys(s)){n.has(a)||(n.add(a),t.push(a));const l=s[a]?.children;if(Array.isArray(l))for(const d of l)o(d)}if(Array.isArray(r.children))for(const a of r.children)o(a)};if(Array.isArray(e))for(const r of e)o(r);return t}function O6(e){const t=[...jf],n=new Set(jf);for(const o of P6(e))!n.has(o)&&!t.includes(o)&&t.push(o);return t}function B6(e,t){const n=e?.responsive;if(!n||typeof n!="object")return[];const o=[];for(const r of Object.keys(n)){const s=n[r];!s||!Array.isArray(s.children)||r!==t&&o.push(r)}return o}function W6(e){switch(e){case"desktop":return Ta;case"tablet":return N6;case"mobile":return z6}const t=e in Eo?Eo[e]:zn.parseRange(e);return!t||!Number.isFinite(t.max)?Ta:Math.round(Math.max(L6,Math.min(M6,t.max)))}function Hf(){const t=Ft()?.document?.raw?.components;return u.useMemo(()=>O6(t??null),[t])}const j6={desktop:"layout_editor.property_modal.scope.device_desktop",tablet:"layout_editor.property_modal.scope.device_tablet",mobile:"layout_editor.property_modal.scope.device_mobile",portable:"layout_editor.property_modal.scope.device_portable"},H6=[{key:"base",labelKey:"layout_editor.property_modal.scope.scheme_light"},{key:"dark",labelKey:"layout_editor.property_modal.scope.scheme_dark"}];function Ff({scope:e,onChange:t,node:n,t:o,showColorScheme:r=!0,onClearScope:s}){const[a,l]=u.useState(!1),[d,c]=u.useState(""),[p,f]=u.useState(""),[y,g]=u.useState(null),m=Hf(),h=u.useMemo(()=>{const k=["base",...m],w=new Set(k);for(const _ of Object.keys(n.responsive??{}))_!=="base"&&!w.has(_)&&(w.add(_),k.push(_));return e.breakpoint!=="base"&&!w.has(e.breakpoint)&&k.push(e.breakpoint),k},[m,n.responsive,e.breakpoint]),v=k=>t({...e,colorScheme:k}),b=k=>t({...e,breakpoint:k}),x=()=>{const k=d.trim(),w=p.trim();if(k===""&&w===""){g(o("layout_editor.property_modal.scope.custom_invalid"));return}const _=`${k}-${w}`;if(!sC(_)){g(o("layout_editor.property_modal.scope.custom_invalid"));return}g(null),l(!1),c(""),f(""),b(_)};return i.jsxs("div",{className:"g7le-style-scope-tabs","data-testid":"g7le-style-scope-tabs",style:F6,children:[r&&i.jsx("div",{className:"g7le-style-scope-scheme","data-testid":"g7le-style-scope-scheme",role:"tablist","aria-label":o("layout_editor.property_modal.scope.scheme_light"),style:Kf,children:H6.map(k=>{const w=e.colorScheme===k.key;return i.jsxs("button",{type:"button",role:"tab","aria-selected":w,"data-testid":`g7le-style-scheme-${k.key}`,"data-active":w?"true":"false",onClick:()=>v(k.key),style:Vf(w),children:[o(k.labelKey),k.key==="dark"&&Us(n,{colorScheme:"dark",breakpoint:e.breakpoint})&&i.jsx("span",{"data-testid":"g7le-style-scheme-dark-dot",style:Uf})]},k.key)})}),i.jsxs("div",{className:"g7le-style-scope-device","data-testid":"g7le-style-scope-device",role:"tablist","aria-label":o("layout_editor.property_modal.scope.device_base"),style:Kf,children:[h.map(k=>{const w=e.breakpoint===k,_=k==="base"||mu(k),S=k==="base"?"layout_editor.property_modal.scope.device_base":j6[k],E=S?o(S):k,C=_?`g7le-style-bp-${k}`:`g7le-style-bp-custom-${k}`;return i.jsxs("button",{type:"button",role:"tab","aria-selected":w,"data-testid":C,"data-active":w?"true":"false",onClick:()=>b(k),style:Vf(w),children:[E,Us(n,{colorScheme:"base",breakpoint:k})&&i.jsx("span",{"data-testid":`${C}-dot`,style:Uf})]},String(k))}),i.jsxs("button",{type:"button","data-testid":"g7le-style-bp-add-custom",onClick:()=>l(k=>!k),style:U6,children:["+ ",o("layout_editor.property_modal.scope.add_custom")]})]}),a&&i.jsxs("div",{className:"g7le-style-scope-custom-input","data-testid":"g7le-style-scope-custom-input",style:K6,children:[i.jsx("input",{type:"number","data-testid":"g7le-style-custom-min",placeholder:o("layout_editor.property_modal.scope.custom_min"),value:d,onChange:k=>c(k.target.value),style:qf}),i.jsx("span",{style:{color:"#94a3b8"},children:"—"}),i.jsx("input",{type:"number","data-testid":"g7le-style-custom-max",placeholder:o("layout_editor.property_modal.scope.custom_max"),value:p,onChange:k=>f(k.target.value),style:qf}),i.jsx("button",{type:"button","data-testid":"g7le-style-custom-confirm",onClick:x,style:q6,children:o("layout_editor.property_modal.scope.add_custom")})]}),y&&i.jsx("div",{"data-testid":"g7le-style-custom-error",style:G6,children:y}),e.breakpoint!=="base"&&!mu(e.breakpoint)&&i.jsx("div",{"data-testid":"g7le-style-custom-priority-note",style:Y6,children:o("layout_editor.property_modal.scope.custom_priority_note")}),s&&Us(n,e)&&i.jsxs("button",{type:"button","data-testid":"g7le-style-scope-reset",onClick:s,style:V6,children:["↺ ",o("layout_editor.property_modal.scope.reset_to_base")]})]})}const F6={display:"flex",flexDirection:"column",gap:0,padding:"0 0 8px",borderBottom:"1px solid #e2e8f0",marginBottom:8},K6={display:"flex",gap:6,alignItems:"center",marginTop:4},Kf={display:"flex",gap:2,flexWrap:"wrap",borderBottom:"1px solid #e2e8f0",margin:"0 -16px",padding:"0 12px"};function Vf(e){return{display:"inline-flex",alignItems:"center",gap:4,padding:"6px 12px",fontSize:12,border:"none",borderBottom:`2px solid ${e?"#2563eb":"transparent"}`,background:"transparent",color:e?"#2563eb":"#64748b",cursor:"pointer",fontWeight:e?700:500,marginBottom:-1}}const Uf={display:"inline-block",width:5,height:5,borderRadius:3,background:"#f59e0b"},V6={alignSelf:"flex-start",marginTop:2,padding:"3px 8px",fontSize:10,border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer"},U6={padding:"6px 12px",fontSize:11,border:"none",borderBottom:"2px dashed #cbd5e1",background:"transparent",color:"#475569",cursor:"pointer",marginBottom:-1},qf={width:70,padding:"4px 6px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6},q6={padding:"4px 10px",fontSize:11,border:"none",borderRadius:6,background:"#2563eb",color:"#fff",cursor:"pointer"},G6={fontSize:11,color:"#dc2626"},Y6={fontSize:10,color:"#94a3b8"},Gf=new Map;function Ln(e,t){Gf.set(e,t)}function X6(e){return e?Gf.get(e)??null:null}const Da={id:{label:"$t:layout_editor.core_props.id.label",widget:"core-id",placeholder:"$t:layout_editor.core_props.id.placeholder",apply:{type:"propValue",propKey:"id"}},dataKey:{label:"$t:layout_editor.core_props.dataKey.label",widget:"core-datakey",placeholder:"$t:layout_editor.core_props.dataKey.placeholder",apply:{type:"nodeKey",nodeKey:"dataKey"}},isolatedState:{label:"$t:layout_editor.core_props.isolatedState.label",widget:"toggle",apply:{type:"nodeKey",nodeKey:"isolatedState"}},isolatedScopeId:{label:"$t:layout_editor.core_props.isolatedScopeId.label",widget:"core-scopeid",placeholder:"$t:layout_editor.core_props.isolatedScopeId.placeholder",apply:{type:"nodeKey",nodeKey:"isolatedScopeId"}}},J6=["id"];function Z6(e){return e===!1?[]:Array.isArray(e)?e.filter(t=>t in Da):[...J6]}function Yf(e){const t=e.props?.id??e.id;return typeof t=="string"&&t.length>0?`${t}-scope`:"scope"}function Q6({node:e,onPatchNode:t,t:n,scopeIdCandidates:o=[],usedScopeIds:r=[]}){const s=e,a="isolatedState"in s&&s.isolatedState!=null,l=typeof s.isolatedScopeId=="string"?s.isolatedScopeId:"",[d,c]=u.useState(""),p=()=>{const h={...e};a?(delete h.isolatedState,delete h.isolatedScopeId):(h.isolatedState={},h.isolatedScopeId=Yf(e)),t(h)},f=h=>{const v={...e};v.isolatedScopeId=h.trim()===""?Yf(e):h,t(v)},y=h=>{const v={...e};v.isolatedState=h,t(v)},g=u.useMemo(()=>{const h=d.trim().toLowerCase();return o.filter(v=>!h||v.toLowerCase().includes(h))},[o,d]),m=l!==""&&r.includes(l);return i.jsxs("div",{className:"g7le-isolated-scope","data-testid":"g7le-isolated-scope-control",style:R6,children:[i.jsx("div",{style:eT,children:n("layout_editor.core_props.isolatedState.label")}),i.jsxs("div",{style:tT,children:[i.jsx("button",{type:"button","data-testid":"g7le-isolated-toggle",role:"switch","aria-checked":a,onClick:p,style:nT(a),children:n(a?"layout_editor.overlay.on":"layout_editor.overlay.off")}),a?null:i.jsx("span",{"data-testid":"g7le-isolated-off-hint",style:oT,children:n("layout_editor.core_props.isolatedState.off_hint")})]}),a?i.jsxs(i.Fragment,{children:[i.jsxs("div",{style:Xf,children:[i.jsx("label",{style:Jf,children:n("layout_editor.core_props.isolatedScopeId.label")}),i.jsx("input",{type:"text","data-testid":"g7le-isolated-scopeid-input",value:l,placeholder:n("layout_editor.core_props.isolatedScopeId.placeholder"),onChange:h=>f(h.target.value),style:rT}),g.length>0?i.jsxs("div",{"data-testid":"g7le-isolated-scopeid-picker",style:iT,children:[i.jsx("input",{type:"text","data-testid":"g7le-isolated-scopeid-search",value:d,placeholder:n("layout_editor.core_props.isolatedScopeId.search"),onChange:h=>c(h.target.value),style:sT}),i.jsx("div",{style:aT,children:g.map(h=>i.jsx("button",{type:"button","data-testid":`g7le-isolated-scopeid-candidate-${h}`,onClick:()=>{f(h),c("")},style:lT,children:h},h))})]}):null,m?i.jsxs("p",{"data-testid":"g7le-isolated-scopeid-dup",style:dT,children:["ⓘ ",n("layout_editor.core_props.isolatedScopeId.duplicate")]}):null]}),i.jsxs("div",{style:Xf,children:[i.jsx("label",{style:Jf,children:n("layout_editor.core_props.isolatedState.initial_label")}),i.jsx("div",{"data-testid":"g7le-isolated-initial-value",children:i.jsx(Dn,{value:s.isolatedState??{},onChange:y,t:n,path:"isolatedState",scope:"isolated-node"})})]})]}):null]})}const R6={display:"flex",flexDirection:"column",gap:8,padding:10,border:"1px solid #e2e8f0",borderRadius:8,minWidth:0},eT={fontSize:12,fontWeight:700,color:"#0f172a"},tT={display:"flex",gap:8,alignItems:"center"},nT=e=>({padding:"4px 12px",fontSize:11,border:`1px solid ${e?"#3b82f6":"#cbd5e1"}`,borderRadius:14,background:e?"#eff6ff":"#fff",color:e?"#1d4ed8":"#64748b",cursor:"pointer"}),oT={fontSize:11,color:"#94a3b8"},Xf={display:"flex",flexDirection:"column",gap:4,minWidth:0},Jf={fontSize:11,color:"#64748b"},rT={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,boxSizing:"border-box",width:"100%"},iT={border:"1px solid #e2e8f0",borderRadius:6,padding:4,background:"#fff"},sT={width:"100%",padding:"4px 6px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,marginBottom:4,boxSizing:"border-box"},aT={display:"flex",flexDirection:"column",maxHeight:120,overflowY:"auto"},lT={textAlign:"left",padding:"4px 6px",fontSize:12,border:"none",background:"transparent",cursor:"pointer",borderRadius:4,color:"#0f172a"},dT={margin:0,fontSize:11,color:"#64748b"},cT={borderTop:"2px solid #c7d2fe",background:"#eef2ff",padding:"10px 12px",display:"flex",flexDirection:"column",gap:8},Zf={fontSize:13,fontWeight:700,color:"#3730a3",display:"flex",alignItems:"center",gap:6},uT={fontSize:11,fontWeight:600,color:"#4338ca",background:"#e0e7ff",border:"1px solid #c7d2fe",borderRadius:4,padding:"1px 6px"},Na={fontSize:11,color:"#6366f1"},pT={alignSelf:"flex-start",fontSize:12,fontWeight:600,color:"#fff",background:"#4f46e5",border:"none",borderRadius:4,padding:"4px 10px",cursor:"pointer"},fT={opacity:.5,cursor:"not-allowed"},gT={fontSize:11,color:"#dc2626"};function hT({entry:e,t,onSave:n}){const[o,r]=u.useState(()=>e.props),[s,a]=u.useState(!0),[l,d]=u.useState(null),[c,p]=u.useState(!1),[f,y]=u.useState(!1),g=e.extensionName??e.extensionIdentifier??`#${e.extensionId}`,m=async()=>{if(!s){d(t("layout_editor.property_modal.injected_props.invalid_json"));return}d(null),p(!0),y(!1);try{await n(o),y(!0)}catch(h){d(h instanceof Error?h.message:String(h))}finally{p(!1)}};return i.jsxs("div",{"data-testid":`g7le-injected-props-entry-${e.extensionId}`,style:{display:"flex",flexDirection:"column",gap:6},children:[i.jsx("div",{style:Zf,children:i.jsx("span",{style:uT,"data-testid":"g7le-injected-props-origin-badge",children:t("layout_editor.property_modal.injected_props.origin",{source:g})})}),i.jsx(Ps,{value:o,shape:"object",emptyValue:{},testidPrefix:"g7le-injected-props-json",minHeight:80,invalidErrorKey:"layout_editor.property_modal.injected_props.invalid_json",shapeErrorKey:"layout_editor.property_modal.injected_props.invalid_json",t,onChange:h=>{r(h??{}),y(!1)},onValidityChange:h=>{a(h),h&&d(null),y(!1)}}),l&&i.jsx("div",{style:gT,"data-testid":"g7le-injected-props-error",children:l}),f&&i.jsx("div",{style:{...Na,color:"#16a34a"},"data-testid":"g7le-injected-props-saved",children:t("layout_editor.property_modal.injected_props.saved")}),i.jsx("div",{style:Na,children:t("layout_editor.property_modal.injected_props.save_target")}),i.jsx("button",{type:"button","data-testid":"g7le-injected-props-save",style:{...pT,...c||!s?fT:null},disabled:c||!s,onClick:m,children:c?t("layout_editor.property_modal.injected_props.saving"):t("layout_editor.property_modal.injected_props.save_button",{source:g})})]})}function yT({injectedProps:e,t,onSaveInjectedProps:n}){return!Array.isArray(e)||e.length===0?null:i.jsxs("div",{style:cT,"data-testid":"g7le-injected-props-section",children:[i.jsxs("div",{style:Zf,children:["🧩 ",t("layout_editor.property_modal.injected_props.title")]}),i.jsx("div",{style:Na,children:t("layout_editor.property_modal.injected_props.help")}),e.map(o=>i.jsx(hT,{entry:o,t,onSave:r=>n(o.extensionId,r)},o.extensionId))]})}function bT(e,t){const n=e?.components??{};for(const o of Object.values(n)){const r=o?.find(s=>s.name===t);if(r)return r}return null}function mT({node:e,spec:t,manifest:n,t:o,onPatchNode:r,onClose:s,onDelete:a,permissionCandidates:l,pageCandidates:d,dataSourceCandidates:c,stateKeyCandidates:p,bindingCandidates:f,liveElement:y,scope:g=Wt,onScopeChange:m,templateIdentifier:h,initialTab:v,onSaveInjectedProps:b,branchLabel:x,isolatedScopeIdCandidates:k,usedScopeIds:w}){const _=e.__injectedProps,S=Array.isArray(_)&&_.length>0&&!!b,E=ve=>{m?.(ve)},C=()=>{r(fC(e,g))},N=typeof e.name=="string"?e.name:"",$=Li(t,N),z=bT(n,N)?.settings,D=!!(z?.groups&&z.groups.length>0),O=Array.isArray($?.propControls)?$.propControls:[],M=Z6($?.coreProps),B=new Set(M.map(ve=>Da[ve].apply?.propKey)),T=O.filter(ve=>{const he=Ct(t,ve)?.apply?.propKey;return!(he!==void 0&&B.has(he))}),H=typeof $?.nodeEditor?.kind=="string"?$.nodeEditor.kind:void 0,L=$?.nodeEditor?.params,I=H?X6(H):null,P=!!I,F=Array.isArray($?.dataProps)?$.dataProps:[],G=F.length>0,V=!!(e.iteration&&typeof e.iteration=="object"),Y=w6(e,$),Z=M.length>0||T.length>0||P||G||Y||V,se=Array.isArray($?.styleControls)?$.styleControls:[],re=Array.isArray($?.advanced)?$.advanced:[],ee=(Array.isArray($?.events)?$.events:[]).length>0,fe=Bf($),K=se.length>0||fe!==null,me=!($?.visibilityCondition===!1)&&(Z||K||ee||D||re.length>0),Ce=u.useMemo(()=>{const ve=[];return D&&ve.push("settings"),Z&&ve.push("props"),K&&ve.push("style"),ee&&ve.push("action"),me&&ve.push("visibility"),ve.push("translation"),re.length>0&&ve.push("advanced"),ve},[D,Z,K,ee,me,re.length]),ze=T.length>0||P||G||V,[Se,qe]=u.useState(v&&Ce.includes(v)?v:D?"settings":ze?"props":K?"style":Z?"props":Ce[0]??"style"),De=uA(e),Pe=!Z&&!K&&!D&&!ee&&!me&&re.length===0,Qe=$?.label?$.label.startsWith("$t:")?o($.label.slice(3)):$.label:N||o("layout_editor.property_modal.element"),Me=(ve,te)=>{const he={...e.props??{}};te===void 0?delete he[ve]:he[ve]=te,r({...e,props:he})},Re={settings:o("layout_editor.property_modal.tab.settings"),props:o("layout_editor.property_modal.tab.props"),style:o("layout_editor.property_modal.tab.style"),action:o("layout_editor.property_modal.tab.action"),visibility:o("layout_editor.property_modal.tab.visibility"),translation:o("layout_editor.property_modal.tab.translation"),advanced:o("layout_editor.property_modal.tab.advanced")};return i.jsxs("div",{className:"g7le-property-modal","data-testid":"g7le-property-modal",style:vT,children:[i.jsxs("div",{"data-modal-drag-handle":!0,"data-testid":"g7le-property-modal-header",style:_T,children:[i.jsx("span",{"data-testid":"g7le-property-modal-title",style:{fontSize:14,fontWeight:700},children:D&&Se==="settings"?o("layout_editor.property_modal.settings_title",{name:Qe}):o("layout_editor.property_modal.title",{name:Qe})}),i.jsx("button",{type:"button","data-testid":"g7le-property-modal-close","aria-label":"close",onClick:s,style:xT,children:"✕"})]}),De&&i.jsx("div",{"data-testid":"g7le-property-modal-advanced-badge",style:wT,children:o("layout_editor.property_modal.advanced_badge")}),x&&i.jsx("div",{"data-testid":"g7le-property-modal-branch-badge",style:kT,children:o("layout_editor.property_modal.branch_badge",{device:x})}),i.jsx("div",{style:ST,role:"tablist",children:Ce.map(ve=>i.jsx("button",{type:"button",role:"tab","aria-selected":Se===ve,"data-testid":`g7le-property-tab-${ve}`,"data-active":Se===ve?"true":"false",onClick:()=>qe(ve),style:{...CT,color:Se===ve?"#2563eb":"#64748b",borderBottomColor:Se===ve?"#2563eb":"transparent"},children:Re[ve]},ve))}),i.jsxs("div",{style:$T,"data-testid":"g7le-property-modal-body",children:[Pe&&!S&&i.jsx("div",{"data-testid":"g7le-property-modal-no-editable",style:Rf,children:o("layout_editor.property_modal.no_editable_props")}),S&&i.jsx(yT,{injectedProps:_,t:o,onSaveInjectedProps:b}),Se==="settings"&&z&&i.jsx(RI,{spec:z,node:e,t:o,onPatchProp:Me}),Se==="props"&&i.jsxs("div",{"data-testid":"g7le-property-props-tab",style:{display:"flex",flexDirection:"column"},children:[V&&i.jsx(e6,{node:e,candidates:f??[],t:o,onPatchNode:r}),G&&i.jsx(IA,{node:e,dataProps:F,candidates:f??[],t:o,onPatchNode:r}),Y&&i.jsx(XA,{node:e,candidates:f??[],t:o,onPatchNode:r,templateIdentifier:h}),P&&I&&i.jsx(I,{node:e,params:L,spec:t,manifest:n,t:o,onPatchNode:r,templateIdentifier:h,candidates:f}),M.map(ve=>i.jsx(ci,{controlKey:`core-${ve}`,control:Da[ve],node:e,t:o,onPatch:r,scope:Wt,bindingCandidates:f},`core-${ve}`)),T.map(ve=>{const te=Ct(t,ve);return te?i.jsx(ci,{controlKey:ve,control:te,node:e,t:o,onPatch:r,scope:Wt,bindingCandidates:f},ve):null}),i.jsx(Q6,{node:e,onPatchNode:r,t:o,scopeIdCandidates:k,usedScopeIds:w})]}),Se==="style"&&i.jsxs("div",{"data-testid":"g7le-property-style-tab",style:{display:"flex",flexDirection:"column"},children:[i.jsx("div",{style:Qf,children:i.jsx(Ff,{scope:g,onChange:E,node:e,t:o,onClearScope:C})}),fe!==null&&i.jsx(I6,{node:e,spec:t,capability:$,t:o,onPatchNode:r,liveElement:y,scope:g}),se.map(ve=>{const te=Ct(t,ve);return te?i.jsx(ci,{controlKey:ve,control:te,node:e,t:o,onPatch:r,scope:g},ve):null})]}),Se==="action"&&i.jsx(SA,{node:e,spec:t,capability:$,t:o,onPatchNode:r,pageCandidates:d,dataSourceCandidates:c,stateKeyCandidates:p,bindingCandidates:f}),Se==="visibility"&&i.jsxs("div",{"data-testid":"g7le-property-visibility-tab",style:{display:"flex",flexDirection:"column"},children:[i.jsx("div",{style:Qf,children:i.jsx(Ff,{scope:g,onChange:E,node:e,t:o,showColorScheme:!1})}),i.jsx(ku,{node:e,spec:t,t:o,onPatchNode:r,dataSourceCandidates:c,stateKeyCandidates:p,scope:g})]}),Se==="translation"&&(h?i.jsx(Hd,{customKey:jd(e),templateIdentifier:h,t:o,paramLabels:av(e),onRemoveParam:ve=>{const te=typeof e.text=="string"?e.text:"";r({...e,text:ho(te,ve)});const he=jd(e);h&&he&&xr(h,he,ve)}}):i.jsx("div",{"data-testid":"g7le-property-translation-deferred",style:Rf,children:o("layout_editor.translation.not_a_key")})),Se==="advanced"&&i.jsx(fA,{node:e,advanced:re,t:o,onPatch:r,permissionCandidates:l})]}),i.jsxs("div",{style:ET,children:[i.jsx("span",{"data-testid":"g7le-property-modal-node-id",style:{fontSize:11,color:"#94a3b8"},children:typeof e.id=="string"||typeof e.id=="number"?`ID: ${e.id}`:""}),i.jsxs("div",{style:{display:"flex",gap:8},children:[i.jsx("button",{type:"button","data-testid":"g7le-property-modal-delete",onClick:a,style:IT,children:o("layout_editor.property_modal.delete_element")}),i.jsx("button",{type:"button","data-testid":"g7le-property-modal-done",onClick:s,style:AT,children:o("layout_editor.property_modal.close")})]})]})]})}const vT={display:"flex",flexDirection:"column",width:420,maxWidth:"92vw"},_T={display:"flex",alignItems:"center",justifyContent:"space-between",padding:"12px 16px",borderBottom:"1px solid #e2e8f0",cursor:"move",userSelect:"none"},xT={border:"none",background:"transparent",fontSize:14,cursor:"pointer",color:"#64748b"},wT={margin:"8px 16px 0",padding:"6px 10px",fontSize:11,background:"#fef3c7",color:"#92400e",borderRadius:6},kT={margin:"8px 16px 0",padding:"6px 10px",fontSize:11,background:"#dbeafe",color:"#1e40af",borderRadius:6},ST={display:"flex",gap:2,padding:"0 12px",borderBottom:"1px solid #e2e8f0",overflowX:"auto"},CT={padding:"8px 10px",fontSize:12,border:"none",borderBottom:"2px solid transparent",background:"transparent",cursor:"pointer",whiteSpace:"nowrap"},$T={padding:16,overflowY:"auto",maxHeight:"60vh"},Qf={position:"sticky",top:-16,zIndex:2,background:"#fff",margin:"-16px -16px 0",padding:"6px 16px 0"},Rf={fontSize:12,color:"#94a3b8",padding:"16px 0",textAlign:"center"},ET={display:"flex",alignItems:"center",justifyContent:"space-between",padding:"12px 16px",borderTop:"1px solid #e2e8f0"},IT={padding:"6px 12px",fontSize:12,border:"1px solid #fecaca",borderRadius:6,background:"#fff",color:"#dc2626",cursor:"pointer"},AT={padding:"6px 12px",fontSize:12,border:"none",borderRadius:6,background:"#2563eb",color:"#fff",cursor:"pointer"},za="g7le:component-target-picked",Ma="g7le:component-target-pick-request";let TT=1;function DT({value:e,onChange:t,t:n,control:o}){const r=go(),s=e==null?"":String(e),a=o?.allowManualInput!==!1,l=u.useCallback(()=>{if(typeof window>"u")return;const d=`pick-${TT++}`,p=r.stack[r.stack.length-1]?.id;p&&r.minimize(p,n("layout_editor.target_picker.minimized_hint"));const f=y=>{const g=y.detail;g?.requestId===d&&(window.removeEventListener(za,f),p&&r.restore(p),!g.cancelled&&typeof g.id=="string"&&t(g.id))};window.addEventListener(za,f),window.dispatchEvent(new CustomEvent(Ma,{detail:{requestId:d}}))},[r,t,n]);return i.jsx("div",{className:"g7le-component-target-picker","data-testid":"g7le-component-target-picker",children:i.jsxs("div",{style:{display:"flex",gap:6,alignItems:"center"},children:[a?i.jsx("input",{type:"text",className:"g7le-widget g7le-widget--text","data-testid":"g7le-component-target-input",value:s,placeholder:n("layout_editor.target_picker.id_placeholder"),onChange:d=>t(d.target.value===""?void 0:d.target.value),style:{flex:1,minWidth:0,padding:"6px 8px",border:"1px solid #cbd5e1",borderRadius:6,fontSize:13}}):i.jsx("span",{"data-testid":"g7le-component-target-value",style:{flex:1,minWidth:0},children:s||n("layout_editor.target_picker.none")}),i.jsxs("button",{type:"button","data-testid":"g7le-component-target-pick",onClick:l,style:{display:"flex",alignItems:"center",gap:4,padding:"6px 10px",background:"#1e293b",color:"#fff",border:"none",borderRadius:6,cursor:"pointer",fontSize:13,whiteSpace:"nowrap"},children:[i.jsx("span",{"aria-hidden":"true",children:"🎯"}),n("layout_editor.target_picker.select_area")]})]})})}async function NT(e,t,n,o,r=fetch){const s=`/api/admin/templates/${encodeURIComponent(e)}/layout-extensions/${t}`;let a;try{a=await r(s,{headers:je(),credentials:"same-origin"})}catch(b){return{kind:"error",message:b instanceof Error?b.message:String(b)}}if(a.status===404)return{kind:"not_found"};if(!a.ok)return{kind:"error",message:`HTTP ${a.status}`};const l=await a.json().catch(()=>null),d=(l&&(l.data||l))??{};let c={};const p=d.content;if(typeof p=="string")try{c=JSON.parse(p)}catch{return{kind:"error",message:"invalid extension content"}}else p&&typeof p=="object"&&(c=p);const f=typeof d.lock_version=="number"?d.lock_version:0,y=Array.isArray(c.injections)?[...c.injections]:[],g=y.findIndex(b=>b&&b.position==="inject_props"&&b.target_id===n);if(g<0)return{kind:"injection_not_found"};y[g]={...y[g],props:o};const m={...c,injections:y};let h;try{h=await r(s,{method:"PUT",headers:je({"Content-Type":"application/json"}),credentials:"same-origin",body:JSON.stringify({content:m,expected_lock_version:f})})}catch(b){return{kind:"error",message:b instanceof Error?b.message:String(b)}}const v=await h.json().catch(()=>null);return h.ok?{kind:"success",newLockVersion:typeof v?.data?.lock_version=="number"?v.data.lock_version:f+1}:h.status===409?{kind:"conflict",currentVersion:v?.current_version??-1,yourVersion:v?.your_version??f}:{kind:"error",message:v?.message??`HTTP ${h.status}`}}const eg=new Map;function La(e,t){eg.set(e,t)}function zT(e){return e?eg.get(e)??null:null}const tg="g7le.clipboard";function ng(){try{return typeof window>"u"||!window.sessionStorage?null:window.sessionStorage}catch{return null}}function MT(e){const t=JSON.parse(JSON.stringify(e)),n=o=>{for(const r of Object.keys(o))r.startsWith("__")&&delete o[r];Array.isArray(o.children)&&o.children.forEach(n)};return n(t),t}function og(e,t=0){const n=ng();if(!n)return!1;try{const o={node:MT(e),copiedAt:t};return n.setItem(tg,JSON.stringify(o)),!0}catch{return!1}}function LT(){const e=ng();if(!e)return null;try{const t=e.getItem(tg);if(!t)return null;const n=JSON.parse(t);return!n||typeof n.node!="object"||n.node===null?null:JSON.parse(JSON.stringify(n.node))}catch{return null}}const qo={nw:{width:-1,height:-1},n:{width:0,height:-1},ne:{width:1,height:-1},e:{width:1,height:0},se:{width:1,height:1},s:{width:0,height:1},sw:{width:-1,height:1},w:{width:-1,height:0}};function Pa(e){if(typeof e=="number")return e;if(typeof e=="string"){const t=e.match(/^(-?\d+(?:\.\d+)?)px$/);if(t)return Number(t[1])}return null}function PT(e,t){const n=Array.isArray(e.options)?e.options:[],o=Array.isArray(e.scale)?e.scale:[],r=n.length>0?n.map(l=>l.value):o;if(r.length===0)return;let s=r[0],a=1/0;return r.forEach((l,d)=>{const c=Pa(l)??d*40,p=Math.abs(c-t);p{if(!t)return;const m=qo[y];if(m.width!==0&&!n||m.height!==0&&!o)return;const h=t.props?.style??{},v=s?.()??null,b=Pa(h.width)??(v?Math.round(v.width):0),x=Pa(h.height)??(v?Math.round(v.height):0);p.current={handle:y,startX:g.clientX,startY:g.clientY,startW:b,startH:x,node:t};const k=m.width!==0&&m.height!==0?"both":m.width!==0?"width":"height";d?.(k);let w=null;const _=E=>{const C=p.current;if(!C)return;const N=r||1,$=(E.clientX-C.startX)/N*qo[C.handle].width,A=(E.clientY-C.startY)/N*qo[C.handle].height;let z=C.node;if(qo[C.handle].width!==0&&n){const D=rg(n,C.startW+$);z=pn(z,n,D)}if(qo[C.handle].height!==0&&o){const D=rg(o,C.startH+A);z=pn(z,o,D)}w=z,a(z,k)},S=()=>{p.current=null,d?.(null),window.removeEventListener("pointermove",_),window.removeEventListener("pointerup",S),w&&l?.(w,k)};window.addEventListener("pointermove",_),window.addEventListener("pointerup",S)},[t,n,o,r,a,l,d]);return{enabledAxes:c,onHandlePointerDown:f}}const ig=320,sg=1920,WT=1024;function Oa(e){return Number.isFinite(e)?Math.round(Math.max(ig,Math.min(sg,e))):WT}function Ba(e={}){const{state:t,dispatch:n}=Je(),{previewDevice:o,previewZoom:r,previewCustomWidth:s,previewColorScheme:a}=t,l=o==="custom"?Oa(s):W6(o),d=u.useMemo(()=>{const g=e.availableWidth??l,m=l*r;return r>=1||m<=g?r:Math.min(r,g/l)},[l,r,e.availableWidth]),c=u.useCallback(g=>{n({type:"SET_PREVIEW_DEVICE",device:g})},[n]),p=u.useCallback(g=>{const m=Math.max(.5,Math.min(1,g));n({type:"SET_PREVIEW_ZOOM",zoom:m})},[n]),f=u.useCallback(g=>{n({type:"SET_PREVIEW_CUSTOM_WIDTH",width:Oa(g)})},[n]),y=u.useCallback(g=>{n({type:"SET_PREVIEW_COLOR_SCHEME",scheme:g})},[n]);return{device:o,deviceWidth:l,zoom:r,scale:d,customWidth:Oa(s),setDevice:c,setZoom:p,setCustomWidth:f,colorScheme:a,setColorScheme:y}}const gi="g7le-dnd-handle-";function jT(e){if(typeof e!="string"||!e.startsWith(gi))return null;const t=e.slice(gi.length);return t.length>0?t:null}function HT(e){return typeof CSS<"u"&&typeof CSS.escape=="function"?CSS.escape(e):e.replace(/["\\\]]/g,"\\$&")}function FT(e,t,n,o){const r=o?.handleFallback??!1;if(e instanceof HTMLElement){const s=e.closest("[data-editor-path]");if(s)return s;const a=e.closest('[data-testid^="g7le-"]');if(a&&!(a.getAttribute("data-testid")??"").startsWith(gi))return null;if(r){const l=e.closest(`[data-testid^="${gi}"]`);if(l&&t){const d=jT(l.getAttribute("data-testid"));if(d){const c=t.querySelector(`[data-editor-path="${HT(d)}"]`);if(c)return c}}}}if(r&&n&&typeof document<"u"&&typeof document.elementsFromPoint=="function")for(const s of document.elementsFromPoint(n.x,n.y)){const a=s.closest?.("[data-editor-path]");if(a)return a}return null}function KT(e,t,n){if(typeof e=="string"&&e.trim().length>0)return{color:e};const o=typeof n?.color=="string"&&n.color.length>0,r=typeof t=="string"&&t.trim().length>0;return o||r?{}:{color:"#0f172a"}}const VT="#ffffff",UT="#0f172a";function qT(e){const t=e.trim(),n=t.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i);if(n)return[Number(n[1]),Number(n[2]),Number(n[3])];const o=t.replace(/^#/,"");return/^[0-9a-f]{3}$/i.test(o)?[parseInt(o[0]+o[0],16),parseInt(o[1]+o[1],16),parseInt(o[2]+o[2],16)]:/^[0-9a-f]{6}$/i.test(o)?[parseInt(o.slice(0,2),16),parseInt(o.slice(2,4),16),parseInt(o.slice(4,6),16)]:null}function GT(e){if(!e||/transparent/i.test(e))return!1;const t=e.trim().match(/^oklch\(\s*([\d.]+)(%?)/i);if(t)return(t[2]==="%"?Number(t[1])/100:Number(t[1]))>=.62;const n=qT(e);if(!n)return!1;const[o,r,s]=n;return .299*o+.587*r+.114*s>=180}function YT(e,t,n){const o=typeof n?.color=="string"?n.color:null;return GT(e??o??t)?UT:VT}function XT({box:e,initialValue:t,isCustomKey:n,t:o,onCommit:r,onCancel:s,mirrorClassName:a,mirrorStyle:l,nodeEffectiveColor:d,onOpenTranslations:c}){const p=u.useRef(null),f=u.useRef(!1),[y,g]=u.useState(t.trim().length>0),[m,h]=u.useState(null);u.useEffect(()=>{const w=p.current;if(w){w.textContent=t,w.focus();try{const _=window.document.createRange();_.selectNodeContents(w);const S=window.getSelection();S?.removeAllRanges(),S?.addRange(_)}catch{}f.current=!1}},[t]),u.useEffect(()=>{const w=p.current;if(!w||typeof window.getComputedStyle!="function"){h(null);return}try{h(window.getComputedStyle(w).color||null)}catch{h(null)}},[a,l]);const v=()=>(p.current?.textContent??"").trim(),b=()=>{f.current||(f.current=!0,r(v()))},x=()=>{f.current||(f.current=!0,s())},k=w=>{w.key==="Enter"&&!w.shiftKey?(w.preventDefault(),b()):w.key==="Escape"&&(w.preventDefault(),x())};return i.jsxs("div",{className:"g7le-inline-text-editor","data-testid":"g7le-inline-text-editor","data-custom-key":n?"true":"false",style:{position:"absolute",left:e.left,top:e.top,minWidth:Math.max(e.width,60),minHeight:e.height,pointerEvents:"auto",zIndex:1e4},children:[i.jsx("div",{ref:p,role:"textbox","aria-label":o("layout_editor.inline_edit.aria_label"),"aria-multiline":"false","data-testid":"g7le-inline-text-editable",className:a,contentEditable:!0,suppressContentEditableWarning:!0,spellCheck:!1,onInput:()=>g(v().length>0),onKeyDown:k,onBlur:b,style:{outline:"2px solid #2563eb",outlineOffset:1,borderRadius:3,background:YT(d,m,l),padding:"2px 4px",minHeight:e.height,whiteSpace:"pre-wrap",wordBreak:"break-word",boxShadow:"0 2px 8px rgba(37, 99, 235, 0.25)",...l??{},...KT(d,a,l)}}),i.jsxs("div",{className:"g7le-inline-text-editor__hint","data-testid":"g7le-inline-text-editor-hint",style:{marginTop:2,display:"inline-flex",alignItems:"center",gap:4,whiteSpace:"nowrap"},children:[c?i.jsxs("button",{type:"button","data-testid":"g7le-inline-text-editor-open-translations",onMouseDown:w=>w.preventDefault(),onClick:w=>{w.preventDefault(),w.stopPropagation(),c()},title:o("layout_editor.inline_edit.open_translations"),style:{padding:"2px 8px",fontSize:11,background:n?"#eff6ff":"#f0fdf4",color:n?"#1d4ed8":"#15803d",border:`1px solid ${n?"#bfdbfe":"#bbf7d0"}`,borderRadius:4,display:"inline-flex",alignItems:"center",gap:4,cursor:"pointer",whiteSpace:"nowrap"},children:[i.jsx("span",{"aria-hidden":"true",children:"🌐"}),i.jsx("span",{children:o(n?"layout_editor.inline_edit.existing_key_hint":"layout_editor.inline_edit.new_key_hint")}),i.jsxs("span",{"aria-hidden":"true",style:{opacity:.7},children:["· ",o("layout_editor.inline_edit.open_translations")," ›"]})]}):i.jsx("span",{style:{padding:"2px 6px",fontSize:11,background:n?"#eff6ff":"#f0fdf4",color:n?"#1d4ed8":"#15803d",border:`1px solid ${n?"#bfdbfe":"#bbf7d0"}`,borderRadius:4,display:"inline-flex",alignItems:"center",gap:4,whiteSpace:"nowrap"},children:n?i.jsxs(i.Fragment,{children:["⌐ ",o("layout_editor.inline_edit.existing_key_hint")]}):i.jsxs(i.Fragment,{children:["✎ ",o("layout_editor.inline_edit.new_key_hint")]})}),!y&&i.jsx("span",{"data-testid":"g7le-inline-text-editor-empty-warn",style:{color:"#b91c1c",marginLeft:4},children:o("layout_editor.inline_edit.empty_warning")})]})]})}function JT({box:e,node:t,customKey:n,initialChipValue:o,chipParamLabels:r,onKeyify:s,templateIdentifier:a,locale:l,t:d,candidates:c,onInsertBinding:p,onRemoveBinding:f,onCommit:y,onCancel:g}){const[m,h]=u.useState(null),v=u.useRef(""),[b,x]=u.useState(!1),k=u.useRef(!1),w=u.useRef(null),_=Ft(),S=u.useRef(()=>{}),[E,C]=u.useState(null),[N,$]=u.useState(!1),A=u.useRef(n??null);u.useEffect(()=>{n&&A.current!==n&&(A.current=n)},[n]);const z=u.useMemo(()=>{if(!n&&r)return r;const L={},I=mt(typeof t.text=="string"?t.text:"");for(const P of I?.params??[])L[P.name]=Vt(P.expression);return L},[t.text,n,r]);u.useEffect(()=>{let L=!1;if(!n){const I=o??"";return v.current=I,h(I),()=>{L=!0}}return(async()=>{const I=It(n,l);if(I!==void 0){v.current=I,h(I);return}const P=await dn(a,n);if(L)return;const F=P?.values?.[l]??"";v.current=F,h(F)})(),()=>{L=!0}},[a,n,l,o]);const D=async()=>{if(k.current)return;k.current=!0;const L=m??"";if(L===v.current){y();return}x(!0);let I=A.current;if(!I){if(!s){x(!1),k.current=!1;return}if(I=await s(L),x(!1),!I){k.current=!1;return}A.current=I,y();return}const P=await _o(a,I,l,L);if(x(!1),P.kind==="error"){k.current=!1;return}_?.markDirty?.(),y()},O=()=>{k.current||(k.current=!0,g())},M=L=>{C(L)},B=async L=>{if(!(E===null||!p||N)){$(!0);try{const I=A.current;I&&m!==null&&m!==v.current&&(await _o(a,I,l,m),v.current=m);const P=await p(E,L);P!==null&&(h(P),v.current=P,_?.markDirty?.())}finally{$(!1),C(null)}}},T=async L=>{if(!(!f||N)){$(!0);try{const I=A.current;I&&m!==null&&m!==v.current&&(await _o(a,I,l,m),v.current=m);const P=await f(L);P!==null&&(h(P),v.current=P,_?.markDirty?.())}finally{$(!1)}}},H=L=>{L.key==="Enter"&&!L.shiftKey?(L.preventDefault(),D()):L.key==="Escape"&&(L.preventDefault(),O())};return S.current=()=>{D()},u.useEffect(()=>{const L=P=>{const F=w.current;if(!F)return;const G=P.target;if(G&&F.contains(G))return;const V=G;V&&V.closest&&V.closest('[data-testid="g7le-inline-toolbar"], .g7le-inline-toolbar')||S.current()},I=w.current?.ownerDocument??document;return I.addEventListener("pointerdown",L,!0),()=>I.removeEventListener("pointerdown",L,!0)},[]),i.jsxs("div",{ref:w,className:"g7le-inline-param-chip-editor","data-testid":"g7le-inline-param-chip-editor",onKeyDown:H,style:{position:"absolute",left:e.left,top:e.top,minWidth:Math.max(e.width,120),pointerEvents:"auto",zIndex:1e4,background:"#fff",border:"2px solid #2563eb",borderRadius:4,boxShadow:"0 2px 8px rgba(37,99,235,0.25)",padding:4},children:[m===null?i.jsx("div",{"data-testid":"g7le-inline-param-chip-loading",style:{fontSize:12,color:"#94a3b8",padding:4},children:d("layout_editor.translation.loading")}):i.jsx(yr,{value:m,onChange:h,t:d,paramLabels:z,testIdSuffix:"inline",onRequestInsert:p?M:void 0,onRemoveChip:f?L=>{T(L)}:void 0}),E!==null&&p&&i.jsx("div",{"data-testid":"g7le-inline-param-chip-insert-picker",style:{marginTop:4},children:i.jsx($t,{candidates:c??[],t:d,onSelect:L=>{B(L)},testIdSuffix:"inline-insert",defaultOpen:!0,floating:!1})}),i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6,marginTop:4},children:[i.jsx("button",{type:"button","data-testid":"g7le-inline-param-chip-save",disabled:b,onMouseDown:L=>L.preventDefault(),onClick:()=>{D()},style:{fontSize:11,border:"none",borderRadius:6,background:"#2563eb",color:"#fff",padding:"4px 12px",cursor:"pointer"},children:d(b?"layout_editor.translation.saving":"layout_editor.translation.save")}),i.jsx("span",{style:{fontSize:10,color:"#94a3b8"},children:d("layout_editor.translation.placeholder_locked")})]})]})}const ZT=["fontWeight","fontBold","fontStyle","fontItalic","textDecoration","textUnderline","textAlign","fontSize","textColor"],QT={fontWeight:"B",fontBold:"B",fontStyle:"I",fontItalic:"I",textDecoration:"U",textUnderline:"U",textAlign:"≡",fontSize:"T",textColor:"A"},RT=[["fontWeight","fontBold"],["fontItalic","fontStyle"],["textUnderline","textDecoration"]];function ag({node:e,spec:t,t:n,onApplyControl:o,box:r,scope:s=Wt}){const a=typeof e.name=="string"?e.name:"",l=Li(t,a),d=Array.isArray(l?.styleControls)?l.styleControls:[];let c=ZT.filter(p=>d.includes(p)&&Ct(t,p)!==null);for(const p of RT){const f=p.filter(y=>c.includes(y));if(f.length>1){const y=f[0];c=c.filter(g=>!p.includes(g)||g===y)}}return c.length===0?null:i.jsx("div",{className:"g7le-inline-toolbar","data-testid":"g7le-inline-toolbar",role:"toolbar","aria-label":n("layout_editor.inline_edit.toolbar_label"),style:{position:"absolute",left:r.left,top:r.top-40,display:"inline-flex",gap:2,padding:4,background:"#0f172a",borderRadius:8,boxShadow:"0 4px 12px rgba(15, 23, 42, 0.35)",pointerEvents:"auto",zIndex:10001},children:c.map(p=>i.jsx(t4,{controlKey:p,node:e,spec:t,t:n,scope:s,onApplyControl:o},p))})}const e4={fontWeight:"bold",fontBold:"bold",fontStyle:"italic",fontItalic:"italic",textDecoration:"underline",textUnderline:"underline"};function lg(e,t){const n=r=>(r.split(/[-\s]/).pop()??r)===t||r===t;return e.find(r=>{const s=typeof r.value=="string"?r.value:"",a=Array.isArray(r.apply?.tokens)?r.apply.tokens:[];return n(s)||a.some(n)})?.value}function t4({controlKey:e,node:t,spec:n,t:o,scope:r,onApplyControl:s}){const[a,l]=u.useState(!1),d=Ct(n,e);if(!d)return null;const c=d.options,p=Array.isArray(c),f=Io(t,d,r),y=e4[e],g=y&&Array.isArray(c)?lg(c,y):void 0,m=!y,h=g!==void 0?f.value===g:p&&f.matched&&f.value!==void 0&&f.value!=="",v=o(`layout_editor.inline_edit.format.${e}`),b=_=>typeof _.label=="string"?_.label.startsWith("$t:")?o(_.label.slice(3)):_.label:String(_.value??""),x=()=>{s(e,_=>{const S=Io(_,d,r),E=Array.isArray(c)?lg(c,y):void 0,C=E!==void 0&&S.value===E?void 0:E;return pn(_,d,C,r)})},k=_=>{s(e,S=>{const C=Io(S,d,r).value===_?void 0:_;return pn(S,d,C,r)}),l(!1)},w=_=>{if(_.preventDefault(),_.stopPropagation(),!m&&Array.isArray(c)){x();return}l(S=>!S)};return i.jsxs("div",{style:{position:"relative",display:"inline-flex"},children:[i.jsxs("button",{type:"button","data-testid":`g7le-inline-format-${e}`,"data-active":h?"true":"false","aria-label":v,"aria-pressed":p?h:void 0,title:v,onMouseDown:_=>_.preventDefault(),onClick:w,style:{display:"inline-flex",alignItems:"center",gap:5,height:28,padding:"0 9px",fontSize:12,border:"none",borderRadius:5,cursor:"pointer",background:h?"#2563eb":"transparent",color:"#ffffff",whiteSpace:"nowrap"},children:[i.jsx("span",{"aria-hidden":"true",style:{fontWeight:e==="fontWeight"||e==="fontBold"?700:600,fontStyle:e==="fontStyle"||e==="fontItalic"?"italic":"normal",textDecoration:e==="textDecoration"||e==="textUnderline"?"underline":"none"},children:QT[e]}),i.jsx("span",{children:v}),m&&i.jsx("span",{"aria-hidden":"true",style:{fontSize:9,opacity:.8},children:"▾"})]}),m&&a&&i.jsx("div",{"data-testid":`g7le-inline-popover-${e}`,role:"listbox",onMouseDown:_=>_.preventDefault(),style:{position:"absolute",top:32,left:0,minWidth:140,maxHeight:240,overflowY:"auto",background:"#0f172a",border:"1px solid #334155",borderRadius:8,boxShadow:"0 8px 24px rgba(15,23,42,0.45)",padding:4,zIndex:10002},children:Array.isArray(c)&&c.length>0?c.map((_,S)=>{const E=f.value===_.value;return i.jsxs("button",{type:"button",role:"option","aria-selected":E,"data-testid":`g7le-inline-option-${e}-${String(_.value??S)}`,onMouseDown:C=>C.preventDefault(),onClick:C=>{C.preventDefault(),C.stopPropagation(),k(_.value)},style:{display:"flex",alignItems:"center",gap:8,width:"100%",padding:"6px 8px",fontSize:12,textAlign:"left",border:"none",borderRadius:5,cursor:"pointer",background:E?"#2563eb":"transparent",color:"#ffffff"},children:[typeof _.swatch=="string"&&i.jsx("span",{"aria-hidden":"true",style:{width:14,height:14,borderRadius:3,background:_.swatch,border:"1px solid rgba(255,255,255,0.3)"}}),i.jsx("span",{children:b(_)}),E&&i.jsx("span",{"aria-hidden":"true",style:{marginLeft:"auto"},children:"✓"})]},String(_.value??S))}):i.jsx(n4,{testid:`g7le-inline-freeinput-${e}`,placeholder:o(`layout_editor.inline_edit.prompt.${e}`),initial:typeof f.value=="string"?f.value:"",onSubmit:_=>{s(e,S=>pn(S,d,_,r)),l(!1)}})})]})}function n4({testid:e,placeholder:t,initial:n,onSubmit:o}){const[r,s]=u.useState(n);return i.jsx("input",{type:"text","data-testid":e,value:r,placeholder:t,autoFocus:!0,onMouseDown:a=>a.stopPropagation(),onChange:a=>s(a.currentTarget.value),onKeyDown:a=>{a.key==="Enter"&&(a.preventDefault(),o(r.trim()))},style:{width:130,padding:"6px 8px",fontSize:12,border:"1px solid #334155",borderRadius:5,background:"#1e293b",color:"#ffffff"}})}function o4(e){const{frameEl:t,manifest:n,nesting:o,componentPalette:r,spec:s=null,permissionCandidates:a,resolveRouteMatch:l,onNavigateToDestination:d,onSessionAddedPathsChange:c,onEditProps:p}=e,{state:f,dispatch:y}=Je(),{t:g}=tt(),m=Ba(),h=u.useCallback((W,X)=>{const R=window.G7Core;if(R&&typeof R.t=="function"){const de=R.t(W,X);if(de&&de!==W&&!de.startsWith("$t:"))return de}return g(W,X)},[g]),v=Ft(),b=e7(50),x=go(),k=u.useRef([]),w=u.useMemo(()=>v?.document?.raw?.components??[],[v?.document?.raw?.components]),_=u.useMemo(()=>w.length===0?null:{children:w},[w]),S=u.useMemo(()=>{if(f.editMode!=="extension")return;const W=/^__extension__\/(\d+)/.exec(f.selectedRoute?.path??"");return W?Number(W[1]):void 0},[f.editMode,f.selectedRoute?.path]),E=u.useMemo(()=>{if(f.editMode==="iteration_item"){const W=v?.document?.iterationContext?.sourceIndexPath;return Array.isArray(W)&&W.length>0?W:null}if(f.editMode==="modal"){const W=v?.document?.modalContext?.editIndexPath;return Array.isArray(W)&&W.length>0?W:null}return null},[f.editMode,v?.document?.iterationContext?.sourceIndexPath,v?.document?.modalContext?.editIndexPath]),C=UE({rootNode:_,editMode:f.editMode,currentExtensionId:S,editableRootPath:E,pathParser:Ze,resolveRouteMatch:l}),[N,$]=u.useState(null),[A,z]=u.useState(null),[D,O]=u.useState(null),M=u.useRef(null);M.current=D;const[B,T]=u.useState(null),H=u.useRef(null),L=u.useRef(null);L.current=C.selectedPath;const I=Db(),[P,F]=u.useState(null),G=u.useCallback(W=>{if(!t)return;const X=Ze(W);if(!X)return;const R={children:Me.current.components},de=We(R,X),ye=I.classify(de);if(!ye.editable){Et({op:"inline_edit_blocked_binding",sourceState:ye.sourceState,componentPath:at(X),timestamp:Date.now()});return}const we=co(t,W);if(!we)return;const Te=de?.props??{},ke=cg(t,W);F({path:X,domPath:W,box:we,initialValue:ye.displayValue,isCustomKey:ye.sourceState==="custom_key",isParamKey:ye.isParamKey===!0,customKey:ye.customKey,chipValue:ye.chipValue,chipParamLabels:ye.chipParamLabels,mirrorClassName:typeof Te.className=="string"?Te.className:void 0,mirrorStyle:Te.style??void 0,nodeEffectiveColor:ke}),Et({op:"inline_edit_enter",sourceState:ye.sourceState,componentPath:at(X),timestamp:Date.now()})},[t,I]),V=u.useCallback(()=>{if(!t){$(null),z(null);return}z(co(t,C.selectedPath)),$(co(t,C.hoverPath))},[t,C.selectedPath,C.hoverPath,w]);u.useEffect(()=>{if(V(),typeof window<"u"&&typeof window.requestAnimationFrame=="function"){const W=window.requestAnimationFrame(()=>V());return()=>window.cancelAnimationFrame(W)}},[V]),u.useEffect(()=>{if(t)return _p(V,t)},[t,V]);const Y=u.useCallback((W,X)=>{if(typeof window>"u")return;window.dispatchEvent(new CustomEvent(za,{detail:X===null?{requestId:W,cancelled:!0}:{requestId:W,id:X}})),O(null),T(null);const R=H.current;R?C.handleSelect("",{dataset:{editorPath:R}}):C.clearSelection(),H.current=null},[C]);u.useEffect(()=>{if(typeof window>"u")return;const W=X=>{const R=X.detail;if(typeof R?.requestId!="string")return;const de=M.current;de&&de!==R.requestId&&Y(de,null),H.current=L.current,O(R.requestId)};return window.addEventListener(Ma,W),()=>window.removeEventListener(Ma,W)},[Y]),u.useEffect(()=>{if(!D)return;const W=X=>{X.key==="Escape"&&(X.preventDefault(),Y(D,null))};return window.addEventListener("keydown",W,!0),()=>window.removeEventListener("keydown",W,!0)},[D,Y]),u.useEffect(()=>{if(!t)return;const W=f.editMode==="iteration_item"||f.editMode==="modal",X=(xe,Ee)=>FT(xe,t,Ee?{x:Ee.clientX,y:Ee.clientY}:void 0,{handleFallback:W}),R=xe=>{const Ee=xe.dataset.editorPath??"",Fe=Ee?Ze(Ee):null,zt={children:Me.current.components},gt=Fe?We(zt,Fe):null,Mt=gt&&typeof gt.props?.id=="string"?gt.props.id:typeof gt?.id=="string"?gt.id:"";return Mt.startsWith("auto_")?"":Mt},de=xe=>{const Ee=X(xe.target,xe);if(!Ee)return;const Fe=M.current;if(Fe){xe.preventDefault(),xe.stopPropagation();const zt=R(Ee);zt&&Y(Fe,zt);return}xe.preventDefault(),xe.stopPropagation(),C.handleSelect(Ee.dataset.editorId??"",{currentTarget:Ee,dataset:Ee.dataset})},ye=xe=>{const Ee=X(xe.target,xe);if(M.current){if(!Ee){T(null);return}const Fe=co(t,Ee.dataset.editorPath??null);if(!Fe){T(null);return}T({box:Fe,hasId:R(Ee)!==""});return}if(!Ee){C.handleHover(null,{});return}C.handleHover(Ee.dataset.editorId??"",{currentTarget:Ee,dataset:Ee.dataset})},we=()=>{if(M.current){T(null);return}C.handleHover(null,{})},Te=xe=>{const Ee=X(xe.target,xe);if(!Ee)return;const Fe=Ee.dataset.editorPath??"";Fe&&(xe.preventDefault(),xe.stopPropagation(),G(Fe))},ke=t.parentElement??t;return ke.addEventListener("click",de,!0),ke.addEventListener("mousemove",ye,!0),ke.addEventListener("mouseleave",we,!0),ke.addEventListener("dblclick",Te,!0),()=>{ke.removeEventListener("click",de,!0),ke.removeEventListener("mousemove",ye,!0),ke.removeEventListener("mouseleave",we,!0),ke.removeEventListener("dblclick",Te,!0)}},[t,C,G,f.editMode,Y]);const Z=u.useMemo(()=>{const W=C.selectedPath;if(!W)return null;const X=W.match(/^(.*)\.children\.\d+$/);return X?X[1]:null},[C.selectedPath]),se=u.useCallback(()=>{Z&&C.handleSelect("",{dataset:{editorPath:Z}})},[Z,C]),re=u.useMemo(()=>!t||!C.selectedPath?null:t.querySelector(`[data-editor-path="${sn(C.selectedPath)}"]`),[t,C.selectedPath]),U=u.useMemo(()=>C.selectedPath?Ze(C.selectedPath):null,[C.selectedPath]),ee=u.useMemo(()=>{if(!U)return null;const W=U.find(et);if(!W)return null;const{i18nKey:X,raw:R}=dC(W.responsive);return X?g(X):g("layout_editor.overlay.branch_custom",{range:R})},[U,g]),fe=u.useMemo(()=>C.selectedPath?C.selectedIsIteration&&C.selectedIterationSourcePath?C.selectedIterationSourcePath:C.selectedPath:null,[C.selectedPath,C.selectedIsIteration,C.selectedIterationSourcePath]),K=u.useMemo(()=>fe?Ze(fe):null,[fe]),ne=u.useMemo(()=>!t||!fe?null:t.querySelector(`[data-editor-path="${sn(fe)}"]`),[t,fe]),me=u.useMemo(()=>{if(ne?.parentElement)return ne.parentElement;if(!t||!fe)return null;const W=fe.match(/^(.*)\.children\.\d+$/);return W?t.querySelector(`[data-editor-path="${sn(W[1])}"]`):null},[ne,t,fe]),Ce=u.useMemo(()=>{const W=C.selectedNode;return W&&(typeof W.name=="string"?W.name:typeof W.type=="string"?W.type:"")||null},[C.selectedNode]),ze=u.useMemo(()=>{if(C.selectedLockKind!=="extension")return null;const W=C.selectedNode?.__source;if(!W||W.kind!=="extension")return null;const X=W.extensionName?.trim(),R=W.extensionIdentifier?.trim();return X&&R?`${X} (${R})`:X||R||W.extensionSourceType||null},[C.selectedLockKind,C.selectedNode]),Se=ZE({parentEl:me,selectedPath:K,selectedEl:ne}),qe=u.useMemo(()=>!t||!fe||fe===C.selectedPath?A:co(t,fe),[t,fe,A,C.selectedPath,w]),De=u.useRef(null),Pe=u.useCallback(()=>{const W=De.current;W&&(x.close(W),De.current=null)},[x]),Qe=u.useRef(()=>{}),Me=u.useRef({manifest:n,nesting:o,componentPalette:r,components:w});Me.current={manifest:n,nesting:o,componentPalette:r,components:w};const Re=u.useCallback(W=>{const X=W.insertion?.parentPath??[],R=W.insertion?.index??0,de=Me.current,ye=dg(de.components,X);Pe();const we=x.open({ariaLabel:h("layout_editor.palette.title"),width:880,maxHeightRatio:.82,content:u.createElement(xf,{manifest:de.manifest,nesting:de.nesting,componentPalette:de.componentPalette??null,targetParentPath:X,targetContainerName:ye,onInsert:(Te,ke,xe)=>{Qe.current(Te,ke,xe)},insertionIndex:R,onClose:()=>Pe(),editorTemplateId:f.templateIdentifier,editorLocale:f.locale}),onClose:()=>{De.current=null}});De.current=we},[x,Pe]),ve=u.useMemo(()=>{if(!f.isPaletteOpen)return null;if(!U)return{parentPath:[],index:w.length};if(C.selectedLockKind!=="none"){const de=K;return!de||de.length===0?{parentPath:[],index:w.length}:{parentPath:de.slice(0,-1),index:(de[de.length-1]??0)+1}}const X=C.selectedNode,R=Array.isArray(X?.children)?X.children.length:0;return R2(X?.name,R,U,o,w.length)},[f.isPaletteOpen,U,K,C.selectedLockKind,C.selectedNode,w.length,o]),te=u.useCallback(()=>{y({type:"SET_PALETTE_OPEN",open:!1})},[y]),he=u.useRef(null);u.useEffect(()=>{if(!f.isPaletteOpen){const ye=he.current;ye&&(x.close(ye),he.current=null);return}if(!ve||he.current)return;const W=ve,X=Me.current,R=dg(X.components,W.parentPath),de=x.open({ariaLabel:h("layout_editor.palette.title"),width:880,maxHeightRatio:.82,content:u.createElement(xf,{manifest:X.manifest,nesting:X.nesting,componentPalette:X.componentPalette??null,targetParentPath:W.parentPath,targetContainerName:R,onInsert:(ye,we,Te)=>{Qe.current(ye,we,Te),te()},insertionIndex:W.index,onClose:()=>te(),editorTemplateId:f.templateIdentifier,editorLocale:f.locale}),onClose:()=>{he.current=null,y({type:"SET_PALETTE_OPEN",open:!1})}});he.current=de},[f.isPaletteOpen,ve,x,te,y]);const Q=u.useRef({layoutName:null,pushed:!1});u.useEffect(()=>{const W=v?.document?.layoutName??null;if(!W){Q.current={layoutName:null,pushed:!1};return}Q.current.layoutName!==W&&(b.clear(),b.push({actionKind:"inline_text_edit",label:"baseline",snapshot:w}),Q.current={layoutName:W,pushed:!0})},[v?.document?.layoutName,w,b]);const ie=u.useRef(0);u.useEffect(()=>{const W=v?.saveSuccessCounter??0;W!==ie.current&&(ie.current=W,W!==0&&(b.clear(),b.push({actionKind:"inline_text_edit",label:"save_baseline",snapshot:w}),k.current=[],c?.([])))},[v?.saveSuccessCounter,w,b,c]);const _e=u.useRef(0);u.useEffect(()=>{const W=v?.reloadCounter??0;W!==_e.current&&(_e.current=W,W!==0&&(Q.current={layoutName:null,pushed:!1},k.current=[],c?.([])))},[v?.reloadCounter,c]);const ge=u.useCallback((W,X,R)=>{if(!v)return;let de=[];v.patchLayout(ke=>{const xe={children:ke},Ee=Wa(xe,X),Fe=R<0?Array.isArray(Ee?.children)?Ee.children.length:0:R,gt=Ji(xe,X,Fe,W).children??[];return de=gt,gt}),b.push({actionKind:"insert",label:`insert ${W.name??W.type??""}`,snapshot:de});const ye=Wa({children:de},X),we=R<0?Array.isArray(ye?.children)?ye.children.length-1:0:R,Te=at([...X,we]);if(k.current=[...k.current,Te],c?.(k.current),t){const ke=`[data-editor-path="${sn(Te)}"]`,xe=()=>{const Ee=t.querySelector(ke);Ee&&typeof Ee.scrollIntoView=="function"&&Ee.scrollIntoView({behavior:"smooth",block:"center",inline:"nearest"})};typeof window<"u"&&typeof window.requestAnimationFrame=="function"?window.requestAnimationFrame(()=>window.requestAnimationFrame(xe)):setTimeout(xe,32)}},[v,b,c,t]);Qe.current=ge;const Ae=u.useCallback(()=>{if(!v||!C.selectedNode||!U)return;const W=Qi(C.selectedNode);let X=[];v.patchLayout(R=>{const de={children:R},ye=U.slice(0,-1),we=U[U.length-1]??0,ke=Ji(de,ye,we+1,W).children??[];return X=ke,ke}),b.push({actionKind:"insert",label:"duplicate",snapshot:X})},[v,C.selectedNode,U,b]),Ne=u.useCallback(()=>{if(!v||!U)return;let W=[];v.patchLayout(X=>{const ye=Zi({children:X},U).children??[];return W=ye,ye}),b.push({actionKind:"remove",label:"remove",snapshot:W}),C.clearSelection()},[v,U,b,C]),Le=u.useMemo(()=>{if(!C.selectedNode||!U||U.some(et))return null;const W=C.selectedNode;if(!Array.isArray(W.children))return null;const X=lC(f.previewDevice,W,f.previewCustomWidth);return{mode:X.mode,key:X.key,sourceKey:X.sourceKey}},[C.selectedNode,U,f.previewDevice,f.previewCustomWidth]),q=u.useCallback(()=>{if(!v||!U||Le?.mode!=="separate")return;const W=Le.key,X=Le.sourceKey;let R=[];v.patchLayout(de=>(R=kb({children:de},U,W,X).children??[],R)),b.push({actionKind:"insert",label:"separate_branch",snapshot:R})},[v,U,Le,b]),ae=u.useCallback(()=>{if(!v||!U||Le?.mode!=="merge")return;const W=Le.key;let X=[];v.patchLayout(R=>(X=Sb({children:R},U,W).children??[],X)),b.push({actionKind:"remove",label:"merge_branch",snapshot:X})},[v,U,Le,b]),pe=u.useCallback(W=>{switch(W){case"mobile":return g("layout_editor.context_menu.device_mobile");case"portable":return g("layout_editor.context_menu.device_portable");case"tablet":return g("layout_editor.context_menu.device_tablet");case"desktop":return g("layout_editor.context_menu.device_desktop");default:return g("layout_editor.context_menu.device_custom",{range:W})}},[g]),$e=u.useMemo(()=>{if(!Le)return null;const W=pe(Le.key);return{separate:g("layout_editor.context_menu.separate_branch_device",{device:W}),merge:g("layout_editor.context_menu.merge_branch_device",{device:W})}},[Le,g,pe]),be=u.useMemo(()=>{if(!C.selectedNode||!U)return[];if(U.some(et))return[];const W=Kr(f.previewDevice,C.selectedNode,f.previewCustomWidth);return B6(C.selectedNode,W).map(X=>({key:X,label:pe(X)}))},[C.selectedNode,U,f.previewDevice,f.previewCustomWidth,pe]),j=u.useCallback(W=>{if(W==="desktop"||W==="tablet"||W==="mobile"||W==="portable"){m.setDevice(W);return}m.setDevice(W)},[m]),oe=u.useCallback(()=>{C.selectedNode&&og(C.selectedNode)},[C.selectedNode]),ue=u.useCallback(()=>{C.selectedNode&&(og(C.selectedNode),Ne())},[C.selectedNode,Ne]),J=u.useCallback(()=>{if(!v)return;const W=LT();if(!W)return;const X=Qi(W);if(U&&U.length>0){const R=U.slice(0,-1),de=(U[U.length-1]??0)+1;ge(X,R,de)}else ge(X,[],w.length)},[v,U,ge,w.length]),le=u.useCallback((W,X)=>{b.push({actionKind:"move",label:X,snapshot:W})},[b]),Ie=u.useCallback(()=>{if(C.selectedNavAffordance==="route_in_tree"&&C.selectedNavTargetPath){if(d){d(C.selectedNavTargetPath);return}y({type:"SELECT_ROUTE",route:{path:C.selectedNavTargetPath,layoutName:null}})}},[C.selectedNavAffordance,C.selectedNavTargetPath,d,y]),dt=u.useCallback(()=>{const W=xb(C.selectedBaseLayout,f.selectedRoute?.layoutName);W&&y({type:"ENTER_BASE_EDIT",layoutName:W})},[C.selectedBaseLayout,f.selectedRoute?.layoutName,y]),pt=u.useCallback(()=>{const W=C.selectedNode?.__source?.extensionId;W!=null&&y({type:"ENTER_EXTENSION_EDIT",extensionId:String(W)})},[C.selectedNode,y]),ot=u.useCallback(()=>{const W=C.selectedIterationSourcePath;W&&y({type:"ENTER_ITERATION_ITEM_EDIT",sourcePath:W})},[C.selectedIterationSourcePath,y]),Oe=u.useRef(null),He=u.useRef(null),Ye=u.useRef(null),Tt=u.useRef({colorScheme:"base",breakpoint:"base"}),Dt=Oe.current!==null?x.stack.some(W=>W.id===Oe.current):!1,Nt=u.useMemo(()=>Ad(f.routeTree,h),[f.routeTree,h]),_n=u.useMemo(()=>Td(v?.document?.raw),[v?.document?.raw]),xn=u.useMemo(()=>Dd(s),[s?.states]),Zt=u.useMemo(()=>{const W=v?.document?.raw,X=kf(W?.components),R=W?.initIsolated,de=R&&typeof R=="object"&&!Array.isArray(R)?Object.keys(R):[];return UI(X,de)},[v?.document?.raw]),jn=u.useMemo(()=>{const W=v?.document?.raw;return kf(W?.components).map(X=>X.scopeId).filter(X=>typeof X=="string"&&X.length>0)},[v?.document?.raw]),an=Id({raw:v?.document?.raw,spec:s}),$i=u.useCallback((W,X)=>{if(!v)return;const R={children:Me.current.components},de=We(R,W);if(!de)return;Tt.current={colorScheme:f.previewColorScheme==="dark"?"dark":"base",breakpoint:Kr(f.previewDevice,de,f.previewCustomWidth)};const ye=t?.getBoundingClientRect(),we=A&&ye?{left:ye.left+A.left,top:ye.top+A.top,width:A.width,height:A.height}:void 0,Te=re,ke=W.find(et),xe=ke?pe(ke.responsive):null,Ee=W.filter(wn=>typeof wn=="number"),Fe=rb({children:Me.current.components},Ee,ib(an)),zt=Fe.length>0?[...Bi({iterationVars:Fe}),...an]:an,gt=(wn,Qz)=>u.createElement(mT,{key:`pem-${at(He.current??[])}`,node:wn,spec:s,manifest:Me.current.manifest,t:h,permissionCandidates:a,pageCandidates:Nt,dataSourceCandidates:_n,stateKeyCandidates:xn,bindingCandidates:zt,liveElement:Te,templateIdentifier:f.templateIdentifier,initialTab:Qz,branchLabel:xe,isolatedScopeIdCandidates:Zt,usedScopeIds:jn,onSaveInjectedProps:async(bt,kn)=>{const Ei=typeof wn.id=="string"?wn.id:"",Hn=await NT(f.templateIdentifier,bt,Ei,kn);if(st({op:"save",layoutName:`extension:${bt}`,editMode:f.editMode,saveTarget:"layout_extension",endpoint:`/api/admin/templates/${f.templateIdentifier}/layout-extensions/${bt}`,isDirty:!0,timestamp:Date.now()}),Hn.kind!=="success")throw new Error(Hn.kind==="conflict"?h("layout_editor.save.conflict"):Hn.kind==="injection_not_found"?"injection not found":Hn.kind==="not_found"?"extension not found":Hn.message??"save failed")},scope:Tt.current,onScopeChange:bt=>{Tt.current=bt;const kn=Oe.current;kn&&x.open({id:kn,ariaLabel:h("layout_editor.property_modal.title"),width:420,draggable:!0,avoidRect:we,content:gt(wn),onClose:()=>{Oe.current=null,He.current=null}})},onPatchNode:bt=>{let kn=[];v.patchLayout(Hn=>(kn=Ot({children:Hn},W,()=>bt).children??[],kn)),b.push({actionKind:"inline_text_edit",label:"edit_props",snapshot:kn}),or({source:"style_control",componentPath:at(W),componentName:typeof bt.name=="string"?bt.name:null,patchKey:null,timestamp:Date.now()});const Ei=Oe.current;Ei&&x.open({id:Ei,ariaLabel:h("layout_editor.property_modal.title"),width:420,draggable:!0,avoidRect:we,content:gt(bt),onClose:()=>{Oe.current=null,He.current=null}})},onDelete:()=>{const bt=Oe.current;bt&&x.close(bt),Ne()},onClose:()=>{const bt=Oe.current;bt&&x.close(bt)}});He.current=W;const Mt=x.open({ariaLabel:h("layout_editor.property_modal.title"),width:420,draggable:!0,avoidRect:we,content:gt(de,X),onClose:()=>{Oe.current=null,He.current=null,Ye.current=null}});Oe.current=Mt,Ye.current=wn=>{x.open({id:Mt,ariaLabel:h("layout_editor.property_modal.title"),width:420,draggable:!0,avoidRect:we,content:gt(wn),onClose:()=>{Oe.current=null,He.current=null,Ye.current=null}})}},[v,s,h,a,Nt,_n,xn,an,re,b,x,Ne,t,A,f.previewColorScheme,f.previewDevice,f.previewCustomWidth,pe]);u.useEffect(()=>{const W=Ye.current,X=He.current;if(!W||!X)return;const R={children:Me.current.components},de=We(R,X);de?W(de):Oe.current&&x.close(Oe.current)},[w]);const Lz=()=>{if(!Dt)return null;const X={position:"absolute",background:"rgba(15, 23, 42, 0.45)",pointerEvents:"auto",zIndex:r7},R=ke=>{ke.preventDefault(),ke.stopPropagation()};if(!A)return i.jsx("div",{"data-testid":"g7le-edit-lock-full",onClick:R,onMouseMove:R,style:{...X,inset:0}});const{left:de,top:ye,width:we,height:Te}=A;return i.jsxs("div",{"data-testid":"g7le-edit-lock",style:{position:"absolute",inset:0,pointerEvents:"none"},children:[i.jsx("div",{"data-testid":"g7le-edit-lock-top",onClick:R,onMouseMove:R,style:{...X,left:0,top:0,right:0,height:Math.max(0,ye)}}),i.jsx("div",{"data-testid":"g7le-edit-lock-bottom",onClick:R,onMouseMove:R,style:{...X,left:0,top:ye+Te,right:0,bottom:0}}),i.jsx("div",{"data-testid":"g7le-edit-lock-left",onClick:R,onMouseMove:R,style:{...X,left:0,top:ye,width:Math.max(0,de),height:Te}}),i.jsx("div",{"data-testid":"g7le-edit-lock-right",onClick:R,onMouseMove:R,style:{...X,left:de+we,top:ye,right:0,height:Te}})]})},yl=u.useCallback(()=>{if(U&&gp(C.selectedLockKind)){if(p&&C.selectedPath){p(C.selectedPath);return}$i(U)}},[U,C.selectedPath,C.selectedLockKind,p,$i]),{scale:bl}=m,ml=typeof C.selectedNode?.name=="string"?C.selectedNode.name:"",Nh=s?.componentCapabilities?.[ml]??null,zh=Array.isArray(Nh?.styleControls)?Nh.styleControls:[],Pz=zh.includes("width")?Ct(s,"width"):null,Oz=zh.includes("height")?Ct(s,"height"):null,Bz=u.useCallback((W,X)=>{if(!v||!U)return;let R=[];v.patchLayout(de=>(R=Ot({children:de},U,()=>W).children??[],R)),or({source:"resize",componentPath:at(U),componentName:ml||null,patchKey:X==="height"?"height":"width",resizeAxis:X,timestamp:Date.now()})},[v,U,ml]),Wz=u.useCallback((W,X)=>{if(!v||!U)return;let R=[];v.patchLayout(de=>(R=Ot({children:de},U,()=>W).children??[],R)),b.push({actionKind:"inline_text_edit",label:`resize ${X}`,snapshot:R})},[v,U,b]),jz=u.useCallback(()=>{if(!re)return null;const W=re.getBoundingClientRect(),X=bl||1;return{width:W.width/X,height:W.height/X}},[re,bl]),nr=BT({node:C.selectedNode,widthControl:Pz,heightControl:Oz,scale:bl,measureStartSize:jz,onResize:Bz,onResizeEnd:Wz}),Hz=u.useCallback((W,X)=>{nr.onHandlePointerDown(W,X)},[nr]),vl=u.useCallback(()=>{const W=b.undo();!W||!v||v.setLayoutComponents(W.snapshot)},[b,v]),_l=u.useCallback(()=>{const W=b.redo();!W||!v||v.setLayoutComponents(W.snapshot)},[b,v]);n7({onUndo:vl,onRedo:_l});const Fz=u.useMemo(()=>({copy:oe,cut:ue,paste:J,delete:Ne,openProps:yl,selectParent:Z?se:void 0,deselect:C.clearSelection}),[oe,ue,J,Ne,yl,Z,se,C.clearSelection]);Ju({handlers:Fz,hasSelection:!!C.selectedPath}),u.useEffect(()=>(window.__g7LayoutEditorHasSelection=!!C.selectedPath,()=>{try{delete window.__g7LayoutEditorHasSelection}catch{}}),[C.selectedPath]),u.useEffect(()=>(window.__g7LayoutEditorHistory={undo:vl,redo:_l,canUndo:b.canUndo,canRedo:b.canRedo},()=>{try{delete window.__g7LayoutEditorHistory}catch{}}),[vl,_l,b.canUndo,b.canRedo]);const Kz=u.useCallback(W=>{const X=P;F(null),X&&(async()=>{if((await I.commit(X.path,W)).kind==="created"&&v){const de=v.document?.raw?.components??[];b.push({actionKind:"inline_text_edit",label:"inline_create_key",snapshot:de})}})()},[P,I,v,b]),Mh=u.useCallback(()=>{const W=P;F(null),I.trackCancel(W?.path??null)},[P,I]),Vz=u.useCallback(()=>{F(null)},[]),Uz=u.useCallback(async W=>{const X=P;if(!X)return null;const R=await I.keyifyChipValue(X.path,W);if(R.kind!=="created"||!R.translationKey)return null;const de=v?.document?.raw?.components??[];return b.push({actionKind:"inline_text_edit",label:"inline_keyify_chip",snapshot:de}),R.translationKey},[P,I,v,b]),qz=u.useCallback(()=>{const W=P;W&&(F(null),C.handleSelect("",{dataset:{editorPath:W.domPath}}),$i(W.path,"translation"))},[P,C,$i]),xt=u.useMemo(()=>{if(!P)return null;const W=v?.document?.raw?.components??w;return We({children:W},P.path)},[P,v?.document,w]),Gz=u.useCallback(async(W,X)=>{const R=P;if(!v||!R)return null;const de=typeof xt?.text=="string"?xt.text:"";let ye;if(R.isParamKey)ye=await _r(f.templateIdentifier,f.locale,de,W,X.sourceId,X.path,"scalar");else{const Fe=zt=>zt.replace(/\$t:[a-zA-Z0-9._-]+/g,gt=>{const Mt=h(gt.slice(3));return Mt&&Mt!==gt.slice(3)&&!Mt.startsWith("$t:")?Mt:""}).replace(/\s+/g," ").trim();ye=await ss(f.templateIdentifier,f.selectedRoute?.layoutName??null,f.locale,de,W,X.sourceId,X.path,"scalar",Fe,h)}if(ye.kind==="error")return null;const we=R.path;let Te=[];v.patchLayout(Fe=>(Te=Ot({children:Fe},we,Mt=>({...Mt,text:ye.text})).children??[],Te)),b.push({actionKind:"inline_text_edit",label:"inline_insert_binding",snapshot:Te});const ke=ye.kind==="ok"&&"key"in ye?ye.key:void 0;!R.isParamKey&&ke&&F(Fe=>Fe&&Fe.path===we?{...Fe,isParamKey:!0,customKey:ke,chipValue:void 0,chipParamLabels:void 0}:Fe);try{window.dispatchEvent(new CustomEvent(kt,{detail:{templateIdentifier:f.templateIdentifier,locale:f.locale}}))}catch{}const xe=mt(ye.text),Ee=R.customKey??xe?.key??"";return Ee?It(Ee,f.locale)??null:null},[v,P,xt,f.templateIdentifier,f.locale,f.selectedRoute,b,h]),Yz=u.useCallback(async W=>{const X=P;if(!v||!X)return null;const R=typeof xt?.text=="string"?xt.text:"",de=mt(R),ye=X.customKey??de?.key??null,we=ho(R,W),Te=X.path;let ke=[];return v.patchLayout(xe=>(ke=Ot({children:xe},Te,zt=>({...zt,text:we})).children??[],ke)),b.push({actionKind:"inline_text_edit",label:"inline_remove_binding",snapshot:ke}),ye&&await xr(f.templateIdentifier,ye,W,f.locale),ye?It(ye,f.locale)??null:null},[v,P,xt,f.templateIdentifier,f.locale,b]),[xl,Lh]=u.useState({}),Xz=["fontSize","color","fontStyle","textDecoration","textAlign","letterSpacing","lineHeight","fontFamily"];u.useEffect(()=>{if(!P||!t||typeof MutationObserver>"u"){Lh({});return}const W=`[data-editor-path="${sn(P.domPath)}"]`,X=()=>{const ye=t.querySelector(W);if(!ye)return;const we={};for(const ke of Xz){const xe=ye.style.getPropertyValue(ke.replace(/([A-Z])/g,"-$1").toLowerCase());xe&&(we[ke]=xe)}const Te=cg(t,P.domPath)??void 0;Lh({className:ye.className,style:we,effectiveColor:Te})};X();const R=t.querySelector(W);if(!R)return;const de=new MutationObserver(X);return de.observe(R,{attributes:!0,attributeFilter:["class","style"]}),de.observe(t,{childList:!0,subtree:!0}),()=>de.disconnect()},[P?.path?at(P.path):null,t]);const Ph=u.useCallback((W,X)=>{if(!v||!P)return;const R=P.path;let de=[],ye=null;v.patchLayout(we=>(de=Ot({children:we},R,xe=>{const Ee=X(xe);return ye=typeof Ee.name=="string"?Ee.name:null,Ee}).children??[],de)),b.push({actionKind:"inline_text_edit",label:`inline_format ${W}`,snapshot:de}),or({source:"style_control",componentPath:at(R),componentName:ye,patchKey:W,timestamp:Date.now()})},[v,P,b]),ft=u.useMemo(()=>{const W=C.selectedPath;if(!W||!_)return null;const X=[];let R=W;for(;R;){X.push(R);const de=R.match(/^(.*)\.children\.\d+$/);R=de?de[1]:null}for(const de of X){const ye=Ze(de);if(!ye)continue;const we=We(_,ye),Te=typeof we?.name=="string"?we.name:null;if(!Te)continue;const ke=Li(s,Te),xe=ke?.canvasOverlay?.kind;if(typeof xe!="string"||!xe||!we)continue;const Ee=ke.canvasOverlay.params&&typeof ke.canvasOverlay.params=="object"?ke.canvasOverlay.params:void 0,Fe=de===W?null:W.slice(de.length+1);return{kind:xe,params:Ee,tablePath:de,tableNode:we,selectedCellRel:Fe}}return null},[C.selectedPath,_,s]),fo=ft?zT(ft.kind):null,Oh=u.useMemo(()=>!fo||!t||!ft?null:co(t,ft.tablePath),[fo,t,ft,A,w]),Jz=u.useMemo(()=>{if(!fo||!t||!ft)return[];const W=ft.tablePath,X=sn(W),R=[];return t.querySelectorAll(`[data-editor-path^="${X}.children."]`).forEach(we=>{const Te=we.dataset.editorPath??"";if(!Te.startsWith(W+"."))return;const ke=Te.slice(W.length+1),xe=ro(we,t);xe&&R.push({path:ke,top:xe.top,left:xe.left,width:xe.width,height:xe.height})}),t.querySelectorAll(`[data-editor-item-path^="${X}."]`).forEach(we=>{const Te=we.dataset.editorItemPath??"";if(!Te.startsWith(W+"."))return;const ke=Te.slice(W.length+1),xe=ro(we,t);xe&&R.push({path:ke,top:xe.top,left:xe.left,width:xe.width,height:xe.height})}),R},[fo,t,ft,A,w]),Zz=u.useCallback(W=>{const X=ft?Ze(ft.tablePath):null;if(!v||!X)return;let R=[];v.patchLayout(de=>(R=Ot({children:de},X,()=>W).children??[],R)),b.push({actionKind:"inline_text_edit",label:"inplace_edit",snapshot:R}),or({source:"style_control",componentPath:ft?.tablePath??"",componentName:typeof W.name=="string"?W.name:null,patchKey:"canvas_inplace",timestamp:Date.now()})},[v,U,b]);return t?i.jsxs("div",{className:"g7le-editor-canvas-overlay","data-testid":"g7le-editor-canvas-overlay",style:{position:"absolute",inset:0,pointerEvents:"none"},children:[!D&&Lz(),D&&i.jsxs("div",{"data-testid":"g7le-canvas-pick-banner",style:{position:"absolute",top:8,left:"50%",transform:"translateX(-50%)",zIndex:9999,display:"flex",alignItems:"center",gap:12,padding:"8px 14px",background:"#1e293b",color:"#fff",borderRadius:8,fontSize:13,boxShadow:"0 4px 12px rgba(0,0,0,0.25)",pointerEvents:"auto"},children:[i.jsxs("span",{children:["🎯 ",h("layout_editor.target_picker.canvas_pick_hint")]}),i.jsx("button",{type:"button","data-testid":"g7le-canvas-pick-cancel",onClick:()=>Y(D,null),style:{border:"1px solid #475569",borderRadius:6,background:"transparent",color:"#cbd5e1",cursor:"pointer",fontSize:12,padding:"3px 10px"},children:h("layout_editor.target_picker.cancel")})]}),f.editMode!=="route"&&i.jsx(mI,{frameEl:t,components:w,editMode:f.editMode,currentExtensionId:S,editableRootPath:E}),P&&((P.isParamKey&&P.customKey||P.chipValue)&&xt?i.jsxs(i.Fragment,{children:[xt&&i.jsx(ag,{node:xt,spec:s,t:h,onApplyControl:Ph,box:P.box,scope:{colorScheme:f.previewColorScheme==="dark"?"dark":"base",breakpoint:Kr(f.previewDevice,xt,f.previewCustomWidth)}}),i.jsx(JT,{box:P.box,node:xt,customKey:P.customKey??null,initialChipValue:P.chipValue,chipParamLabels:P.chipParamLabels,onKeyify:Uz,templateIdentifier:f.templateIdentifier,locale:f.locale,t:h,candidates:an,onInsertBinding:Gz,onRemoveBinding:Yz,onCommit:Vz,onCancel:Mh})]}):i.jsxs(i.Fragment,{children:[xt&&i.jsx(ag,{node:xt,spec:s,t:h,onApplyControl:Ph,box:P.box,scope:{colorScheme:f.previewColorScheme==="dark"?"dark":"base",breakpoint:xt?Kr(f.previewDevice,xt,f.previewCustomWidth):"base"}}),i.jsx(XT,{box:P.box,initialValue:P.initialValue,isCustomKey:P.isCustomKey,t:h,onCommit:Kz,onCancel:Mh,mirrorClassName:xl.className,mirrorStyle:xl.style,nodeEffectiveColor:xl.effectiveColor??P.nodeEffectiveColor,onOpenTranslations:qz})]})),!D&&i.jsx(c7,{hoverBox:N,selectedBox:A,lockKind:C.selectedLockKind,navAffordance:C.selectedNavAffordance,onEditProps:yl,onDuplicate:Ae,onDelete:Ne,onSeparateBranch:Le?.mode==="separate"?q:void 0,separateBranchLabel:$e?.separate,onMergeBranch:Le?.mode==="merge"?ae:void 0,mergeBranchLabel:$e?.merge,definedDeviceBranches:be,onJumpToDevice:be.length>0?j:void 0,onLinkEditDestination:Ie,onEditBase:dt,onEditExtension:pt,extensionLabel:ze,baseLayoutLabel:C.selectedBaseLayout,dataSourceLabel:C.selectedDataSourceId,branchLabel:ee,onEditIteration:C.selectedIsIteration&&f.editMode!=="iteration_item"?ot:void 0,resizeEnabledAxes:nr.enabledAxes,onResizeHandlePointerDown:nr.enabledAxes.width||nr.enabledAxes.height?Hz:void 0,selectedName:Ce,onSelectParent:Z?se:void 0}),D&&B&&i.jsx("div",{"data-testid":"g7le-canvas-pick-hover","data-pick-has-id":B.hasId?"true":"false",style:{position:"absolute",left:B.box.left,top:B.box.top,width:B.box.width,height:B.box.height,pointerEvents:"none",zIndex:9998,boxSizing:"border-box",border:B.hasId?"2px solid #2563eb":"2px dashed #94a3b8",background:B.hasId?"rgba(37,99,235,0.10)":"repeating-linear-gradient(45deg, rgba(148,163,184,0.18) 0, rgba(148,163,184,0.18) 6px, transparent 6px, transparent 12px)",cursor:B.hasId?"pointer":"not-allowed"},children:!B.hasId&&i.jsx("span",{"data-testid":"g7le-canvas-pick-hover-hint",style:{position:"absolute",top:0,left:0,transform:"translateY(-100%)",background:"#475569",color:"#fff",fontSize:11,lineHeight:1.4,padding:"2px 6px",borderRadius:4,whiteSpace:"nowrap"},children:h("layout_editor.target_picker.needs_id")})}),!D&&i.jsx(y7,{selectedBox:qe,points:Se.points,onAddRequest:Re}),fo&&ft&&Oh&&C.selectedLockKind==="none"&&i.jsx(fo,{node:ft.tableNode,params:ft.params,nodeBox:Oh,cellBoxes:Jz,selectedCellPath:ft.selectedCellRel??void 0,colorScheme:f.previewColorScheme==="dark"?"dark":"light",t:h,onPatchNode:Zz,onInsertChild:ge,onRemoveChild:W=>{const X=Ze(W);if(!v||!X)return;let R=[];v.patchLayout(de=>(R=Zi({children:de},X).children??[],R)),b.push({actionKind:"remove",label:"inplace_remove",snapshot:R})},onRequestInlineEdit:W=>{ft&&G(`${ft.tablePath}.${W}`)}}),v&&i.jsx(pI,{frameEl:t,nesting:o,editMode:f.editMode,currentExtensionId:S,editableRootPath:E,components:w,patchLayout:v.patchLayout,pushHistory:le,selectedPath:C.selectedPath,onSelectPath:W=>C.handleSelect("",{dataset:{editorPath:W}}),onMovePath:W=>C.handleSelect("",{dataset:{editorPath:W}}),onRequestInlineEdit:G})]}):null}function dg(e,t){if(t.length===0)return null;const o=Wa({children:e},t);return o&&typeof o.name=="string"?o.name:null}function Wa(e,t){return We(e,t)}function sn(e){return typeof CSS<"u"&&typeof CSS.escape=="function"?CSS.escape(e):e.replace(/[^a-zA-Z0-9_\-.]/g,t=>`\\${t}`)}function cg(e,t){if(!t)return null;const n=e.querySelector(`[data-editor-path="${sn(t)}"]`);if(!n||typeof window.getComputedStyle!="function")return null;let o=n;const r=n.ownerDocument.createTreeWalker(n,NodeFilter.SHOW_TEXT);let s=r.nextNode();for(;s;){if((s.textContent??"").trim().length>0){const a=s.parentElement;if(a&&n.contains(a)){o=a;break}}s=r.nextNode()}try{return window.getComputedStyle(o).color||null}catch{return null}}function co(e,t){if(!t)return null;const n=e.querySelector(`[data-editor-path="${sn(t)}"]`);if(n){const c=ro(n,e);return c&&io(c,e)?c:null}const o=e.querySelectorAll(`[data-editor-path^="${sn(t)}.iteration."]`);let r=1/0,s=1/0,a=-1/0,l=-1/0,d=!1;return o.forEach(c=>{const p=c.dataset.editorPath??"";if(!new RegExp("\\.iteration\\.\\d+$").test(p))return;const f=ro(c,e);f&&io(f,e)&&(d=!0,r=Math.min(r,f.left),s=Math.min(s,f.top),a=Math.max(a,f.left+f.width),l=Math.max(l,f.top+f.height))}),d?{left:r,top:s,width:a-r,height:l-s,scale:1}:null}function r4({isEditMode:e,editorSpec:t,sampleOverride:n}){const o=t?.sampleData;return u.useMemo(()=>Im({spec:o,overrideSpec:n,onMatch:r=>{Wh(r)}}),[e,o,n])}const i4=Lt().ComponentRegistry;function s4(e){return e.split(/[-_]/).map(t=>t.charAt(0).toUpperCase()+t.slice(1).toLowerCase()).join("")}async function a4(e){const t=typeof window<"u"?window.localStorage?.getItem("auth_token"):null,n={Accept:"application/json"};t&&(n.Authorization=`Bearer ${t}`);const o=await fetch(`/api/admin/templates/${encodeURIComponent(e)}/editor-assets`,{headers:n,credentials:"same-origin"});if(!o.ok){const s=await o.json().catch(()=>null);throw Ur(o.status,s,"assets")}const r=await o.json();return r.data||r}async function l4(e){try{if(typeof fetch!="function")return 0;const t=await fetch(`/api/templates/${encodeURIComponent(e)}/config.json`,{credentials:"same-origin",headers:{Accept:"application/json"}});if(!t.ok)return 0;const n=await t.json().catch(()=>null),o=n?.data?.cache_version??n?.cache_version;return typeof o=="number"&&o>0?o:0}catch{return 0}}function d4(e){return new Promise((t,n)=>{if(typeof document>"u"){n(new Error("document undefined"));return}if(document.querySelector(`script[data-g7le-asset="${e}"]`)){t();return}const r=document.createElement("script");r.src=e,r.async=!1,r.setAttribute("data-g7le-asset",e),r.addEventListener("load",()=>t()),r.addEventListener("error",()=>n(new Error(`script load failed: ${e}`))),document.head.appendChild(r)})}async function c4(e){if(typeof document>"u")return;const t=`[data-g7le-asset="${e}"]`;if(document.querySelector(`link${t}, style${t}`))return;if(e.includes("/api/admin/"))try{const r=typeof window<"u"?window.localStorage?.getItem("auth_token"):null,s={Accept:"text/css"};r&&(s.Authorization=`Bearer ${r}`);const a=await fetch(e,{headers:s,credentials:"same-origin"});if(a.ok){const l=await a.text(),d=document.createElement("style");d.textContent=l,d.setAttribute("data-g7le-asset",e),document.head.appendChild(d);return}}catch{}const o=document.createElement("link");o.rel="stylesheet",o.href=e,o.setAttribute("data-g7le-asset",e),document.head.appendChild(o)}function u4(e,t,n="user"){const[o,r]=u.useState({componentRegistry:null,translationEngine:null,isReady:!1,error:null});return u.useEffect(()=>{let s=!1;return(async()=>{r(a=>({...a,isReady:!1,error:null}));try{const a=await a4(e);if(s)return;for(const y of a.css??[])if(await c4(y),s)return;const l=s4(e);if(!(typeof window<"u"&&typeof window[l]=="object")){for(const y of a.js??[])if(await d4(y),s)return}const c=i4.createIsolatedInstance();if(await c.loadComponents(e,n),s)return;const p=await l4(e);if(s)return;const f=new Pt({defaultLocale:t});if(p>0&&f.setCacheVersion(p),await f.loadTranslations(e,t),s)return;ad(f,e);try{const y=Pt.getInstance();p>y.getCacheVersion()&&y.setCacheVersion(p),await y.loadTranslations(e,t),ad(y,e)}catch{}if(s)return;r({componentRegistry:c,translationEngine:f,isReady:!0,error:null})}catch(a){if(s)return;const l=dp(a)?a:oo(a,"assets");r({componentRegistry:null,translationEngine:null,isReady:!1,error:l})}})(),()=>{s=!0}},[e,t,n]),o}const ug=Lt().AuthManager;function p4(e){switch(e){case"unauthorized":return{icon:"🔒",tone:"blue",titleKey:"layout_editor.access_error.unauthorized.title",messageKey:"layout_editor.access_error.unauthorized.message",showRequiredPermissions:!0};case"forbidden":return{icon:"⛔",tone:"rose",titleKey:"layout_editor.access_error.forbidden.title",messageKey:"layout_editor.access_error.forbidden.message",showRequiredPermissions:!0};case"not_found":return{icon:"🗂️",tone:"slate",titleKey:"layout_editor.access_error.not_found.title",messageKey:"layout_editor.access_error.not_found.message",showRequiredPermissions:!1};case"server_error":return{icon:"🛠️",tone:"amber",titleKey:"layout_editor.access_error.server_error.title",messageKey:"layout_editor.access_error.server_error.message",showRequiredPermissions:!1};case"network":return{icon:"📡",tone:"amber",titleKey:"layout_editor.access_error.network.title",messageKey:"layout_editor.access_error.network.message",showRequiredPermissions:!1};default:return{icon:"⚠️",tone:"slate",titleKey:"layout_editor.access_error.unknown.title",messageKey:"layout_editor.access_error.unknown.message",showRequiredPermissions:!1}}}const f4={blue:{bg:"#eff6ff",border:"#bfdbfe",iconBg:"#dbeafe"},rose:{bg:"#fff1f2",border:"#fecdd3",iconBg:"#ffe4e6"},amber:{bg:"#fffbeb",border:"#fde68a",iconBg:"#fef3c7"},slate:{bg:"#f8fafc",border:"#e2e8f0",iconBg:"#f1f5f9"}};function g4({error:e}){return u.useEffect(()=>{if(e.kind!=="unauthorized"||typeof window>"u")return;const t=ug.getInstance(),n=`${window.location.pathname}${window.location.search}`,o=t.getLoginRedirectUrl("admin",n,"session_expired"),r=window.setTimeout(()=>{window.location.href=o},600);return()=>window.clearTimeout(r)},[e.kind]),null}function ja({error:e}){const{t}=tt(),n=p4(e.kind),o=f4[n.tone],r=()=>{typeof window<"u"&&(window.location.href="/admin")},s=()=>{typeof window<"u"&&window.location.reload()},a=()=>{if(typeof window>"u")return;const l=ug.getInstance(),d=`${window.location.pathname}${window.location.search}`;window.location.href=l.getLoginRedirectUrl("admin",d)};return i.jsxs("div",{className:"g7le-access-error","data-testid":"g7le-access-error","data-error-kind":e.kind,style:{flex:1,display:"flex",alignItems:"center",justifyContent:"center",padding:24,background:"#f1f5f9"},children:[i.jsx(g4,{error:e}),i.jsxs("div",{style:{maxWidth:480,width:"100%",background:o.bg,border:`1px solid ${o.border}`,borderRadius:12,padding:"28px 32px",boxShadow:"0 2px 8px rgba(15, 23, 42, 0.05)",textAlign:"center"},children:[i.jsx("div",{style:{width:64,height:64,margin:"0 auto 16px",borderRadius:"50%",background:o.iconBg,display:"flex",alignItems:"center",justifyContent:"center",fontSize:32,lineHeight:1},"aria-hidden":"true",children:n.icon}),i.jsx("h2",{"data-testid":"g7le-access-error-title",style:{margin:"0 0 8px",fontSize:18,fontWeight:700,color:"#0f172a"},children:t(n.titleKey)}),i.jsx("p",{"data-testid":"g7le-access-error-message",style:{margin:"0 0 16px",fontSize:13,lineHeight:1.6,color:"#475569"},children:t(n.messageKey)}),n.showRequiredPermissions&&e.requiredPermissions&&i.jsxs("div",{"data-testid":"g7le-access-error-permissions",style:{display:"inline-flex",flexWrap:"wrap",gap:6,justifyContent:"center",padding:"8px 12px",marginBottom:16,background:"rgba(15, 23, 42, 0.04)",borderRadius:8,fontSize:12,color:"#475569"},children:[i.jsx("span",{style:{fontWeight:600},children:t("layout_editor.access_error.required_permission_label")}),e.requiredPermissions.split(/[,\s]+/).filter(Boolean).map(l=>i.jsx("code",{style:{padding:"2px 6px",background:"#fff",border:"1px solid #e2e8f0",borderRadius:4,fontFamily:"ui-monospace, SFMono-Regular, Menlo, monospace",fontSize:11,color:"#1e293b"},children:l},l))]}),e.message&&i.jsxs("details",{"data-testid":"g7le-access-error-detail",style:{marginBottom:16,fontSize:11,color:"#94a3b8"},children:[i.jsx("summary",{style:{cursor:"pointer"},children:t("layout_editor.access_error.detail_summary")}),i.jsxs("pre",{style:{marginTop:8,padding:8,background:"#fff",border:"1px solid #e2e8f0",borderRadius:6,overflowX:"auto",whiteSpace:"pre-wrap",wordBreak:"break-word",textAlign:"left",fontSize:11,color:"#475569"},children:[e.status>0?`HTTP ${e.status} +`:"",e.message]})]}),i.jsxs("div",{style:{display:"flex",justifyContent:"center",gap:8,flexWrap:"wrap"},children:[e.kind==="unauthorized"&&i.jsx("button",{type:"button","data-testid":"g7le-access-error-action-signin",onClick:a,style:Ha,children:t("layout_editor.access_error.action.sign_in")}),e.kind==="forbidden"&&i.jsx("button",{type:"button","data-testid":"g7le-access-error-action-home",onClick:r,style:Ha,children:t("layout_editor.access_error.action.go_home")}),(e.kind==="not_found"||e.kind==="server_error"||e.kind==="network"||e.kind==="unknown")&&i.jsx("button",{type:"button","data-testid":"g7le-access-error-action-retry",onClick:s,style:Ha,children:t("layout_editor.access_error.action.retry")}),e.kind!=="unauthorized"&&i.jsx("button",{type:"button","data-testid":"g7le-access-error-action-home-secondary",onClick:r,style:h4,children:t("layout_editor.access_error.action.go_admin_home")})]})]})]})}const Ha={padding:"8px 16px",background:"#2563eb",color:"#fff",border:"1px solid #1d4ed8",borderRadius:6,fontSize:13,fontWeight:600,cursor:"pointer"},h4={padding:"8px 16px",background:"#fff",color:"#475569",border:"1px solid #cbd5e1",borderRadius:6,fontSize:13,fontWeight:500,cursor:"pointer"};function y4(e){const t=window;if(typeof t>"u")return{getSnapshot:()=>({globalState:{},localState:{}}),restore:()=>{}};const n=t.G7Core||{},o={templateApp:t.__templateApp,g7CoreState:n.state,g7CoreDispatch:n.dispatch,g7CoreData:n.data,g7CoreModal:n.modal,g7CoreToast:n.toast};let r={...e.initialGlobalState??{}};r._local||(r._local={});const s=[],a=()=>{const c={globalState:{...r},localState:{...r._local??{}}};for(const p of s)try{p(r)}catch{}e.onChange?.(c)},l={},d={getGlobalState:()=>r,setGlobalState:(c,p)=>{typeof c=="function"?r=c(r):r={...r,...c},a()},onGlobalStateChange:c=>(s.push(c),()=>{const p=s.indexOf(c);p>=0&&s.splice(p,1)}),get globalState(){return r},getActionDispatcher:()=>e.actionDispatcher,getDataSource:c=>l[c]??null,setDataSource:(c,p)=>{l[c]=p},refetchDataSource:async c=>null,updateDataSourceItem:(c,p,f)=>l[c]??null,getLocale:()=>e.locale,changeLocale:c=>{},getRouter:()=>({navigate:()=>{},replace:()=>{},push:()=>{}}),updateQueryParams:()=>{}};return t.__templateApp=d,n&&typeof n.dispatch=="function"&&(n.dispatch=(c,p)=>{try{return e.actionDispatcher.executeAction(c,p??{})}catch{return}}),n&&typeof n.modal=="object"&&n.modal!==null&&(n.modal={...n.modal,open:()=>{},close:()=>{},closeAll:()=>{}}),n&&typeof n.toast=="object"&&n.toast!==null&&(n.toast={...n.toast,push:()=>{},dismiss:()=>{}}),{getSnapshot:()=>({globalState:{...r},localState:{...r._local??{}}}),restore:()=>{t.__templateApp===d&&(t.__templateApp=o.templateApp),t.G7Core&&(o.g7CoreState!==void 0&&(t.G7Core.state=o.g7CoreState),o.g7CoreDispatch!==void 0&&(t.G7Core.dispatch=o.g7CoreDispatch),o.g7CoreData!==void 0&&(t.G7Core.data=o.g7CoreData),o.g7CoreModal!==void 0&&(t.G7Core.modal=o.g7CoreModal),o.g7CoreToast!==void 0&&(t.G7Core.toast=o.g7CoreToast)),s.length=0}}}const b4=new Set(["id","userId","postId","orderId","productId","categoryId","commentId","noticeId","inquiryId","addressId","wishlistId","notificationId","roleId","menuId"]);function m4(e){if(!e)return{};const t={},n=/:([a-zA-Z_][a-zA-Z0-9_]*)/g;let o;for(;(o=n.exec(e))!==null;){const r=o[1];t[r]=v4(r)}return t}function v4(e){return b4.has(e)||/Id$|_id$/.test(e)?"1":"sample"}const _4=ar("LayoutEditor");function Go(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function hi(e){if(Array.isArray(e))return e.map(t=>hi(t));if(Go(e)){const t={};for(const[n,o]of Object.entries(e))t[n]=hi(o);return t}return e}function pg(e,t="",n=new Set){for(const[o,r]of Object.entries(e)){const s=t?`${t}.${o}`:o;Go(r)?pg(r,s,n):n.add(s)}return n}function fg(e,t,n,o,r,s=""){for(const[a,l]of Object.entries(t)){const d=s?`${s}.${a}`:a;if(n.has(d)){r(`[LayoutEditor] sampleGlobal key conflict: extension '${o}' override of core key '${d}' ignored`);continue}const c=e[a];Go(l)&&Go(c)?fg(c,l,n,o,r,d):e[a]=hi(l)}}function Fa(e){const t=e.warn??(r=>_4.warn(r)),n=hi(e.coreSeed??{});e.isGuestOnly&&"currentUser"in n&&delete n.currentUser;const o=pg(n);for(const r of e.sources)Go(r.sampleGlobal)&&fg(n,r.sampleGlobal,o,r.id,t);return e.isGuestOnly&&"currentUser"in n&&delete n.currentUser,n}function x4(e){const t={};if(!e||typeof e!="object"||Array.isArray(e))return t;for(const[n,o]of Object.entries(e))n.startsWith("$")&&(t[n]=o);return t}function w4(e){const t={};if(!e||typeof e!="object"||Array.isArray(e))return t;for(const[n,o]of Object.entries(e))n.startsWith("$")||(t[n]=o);return t}function k4(e){if(typeof e!="string")return null;const t=/^\s*\{\{\s*([\s\S]*?)\s*\}\}\s*$/.exec(e);if(!t)return null;let n=t[1];const o=n.indexOf("??");return o>=0&&(n=n.slice(0,o)),n=n.replace(/\?\./g,".").replace(/\s+/g,""),n===""||!/^[A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z_$][A-Za-z0-9_$]*)*$/.test(n)?null:n.split(".")}function S4(e,t){const n=k4(t);if(!n||n.length===0)return e;let o=e;for(let a=0;ao.map(r=>{if(!r||typeof r!="object")return r;const s=r;let a=s;if(typeof s.slot=="string"&&!t.has(s.slot)){const{slot:l,...d}=s;a={...d,__editorSlotName:l}}if(Array.isArray(a.children)){const l=n(a.children);l!==a.children&&(a={...a,children:l})}return a});return n(e)}function St(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function Xt(e){if(Array.isArray(e))return e.map(t=>Xt(t));if(St(e)){const t={};for(const[n,o]of Object.entries(e))t[n]=Xt(o);return t}return e}function yi(e,t){for(const[n,o]of Object.entries(t)){const r=e[n];St(o)&&St(r)?yi(r,o):e[n]=Xt(o)}}function E4(e){const t=[];let n="";for(let o=0;o0&&(t.push(n),n="");const s=e[o+1];if(s==="'"||s==='"'){const l=e.indexOf(`${s}]`,o+2);if(l!==-1){t.push(e.slice(o+2,l)),o=l+1;continue}}const a=e.indexOf("]",o+1);if(a!==-1){t.push(e.slice(o+1,a)),o=a;continue}n+=r}else r==="."?n.length>0&&(t.push(n),n=""):n+=r}return n.length>0&&t.push(n),t.filter(o=>o.length>0)}function Ka(e,t,n){const o=E4(t);if(o.length===0)return;let r=e;for(let s=0;s0?t:void 0}const Ua="g7le:editor-t-ready",yg="data-g7le-vh-expanded";function A4(e,t){if(!e||!t)return;const n=t;e.querySelectorAll("*").forEach(r=>{const s=window.getComputedStyle(r),a=parseFloat(s.height),l=Number.isFinite(a)&&Math.abs(a-n)<=2,d=parseFloat(s.minHeight),c=Number.isFinite(d)&&Math.abs(d-n)<=2,p=s.overflowY==="auto"||s.overflowY==="scroll"||s.overflowY==="hidden"||s.overflow==="auto"||s.overflow==="scroll"||s.overflow==="hidden";l&&p?(r.style.setProperty("height","auto","important"),c&&r.style.setProperty("min-height","0","important"),r.style.setProperty("overflow","visible","important"),r.setAttribute(yg,"1")):(s.overflowY==="auto"||s.overflowY==="scroll")&&r.scrollHeight>r.clientHeight+4&&r.clientHeight>120&&(r.style.setProperty("overflow","visible","important"),r.style.setProperty("height","auto","important"),r.setAttribute(yg,"1"))})}const T4=[];function D4(e={}){const{manifest:t=null,nesting:n=null,componentPalette:o=null,spec:r=null,permissionCandidates:s,sampleGlobalSources:a=T4,resolveRouteMatch:l,onNavigateToDestination:d,onSessionAddedPathsChange:c}=e,{state:p}=Je(),f=Ft(),y=vp(),{document:g,isLoading:m,error:h}=f??y,v=u.useRef(null),[b,x]=u.useState(null),{deviceWidth:k,scale:w,colorScheme:_}=Ba(),S=u.useMemo(()=>p.activeStateId?p.availableStates.find(Q=>Q.id===p.activeStateId)??null:null,[p.activeStateId,p.availableStates]),E=u.useMemo(()=>hg(S),[S]),C=r4({isEditMode:!0,editorSpec:r??void 0,sampleOverride:E}),{t:N}=tt(),{componentRegistry:$,translationEngine:A,isReady:z,error:D}=u4(p.templateIdentifier,p.locale),O=u.useRef(null);O.current===null&&(O.current=new yd);const M=u.useRef(null);if(M.current===null){M.current=new LE({navigate:()=>{}}),M.current.setPreviewMode(!0);try{const ie=window.__templateApp?.getActionDispatcher?.();if(ie&&typeof ie.getRegisteredHandlers=="function"){const _e=ie.getRegisteredHandlers();for(const ge of _e){if(!ge.includes("."))continue;const Ae=typeof ie.getHandler=="function"?ie.getHandler(ge):void 0;typeof Ae=="function"&&M.current.registerHandler(ge,Ae,{category:"plugin",source:"preview-canvas-cloned-from-host"})}}}catch{}}u.useEffect(()=>{if(!z)return;const Q=M.current;if(Q)try{const ie=window.G7Core?.getActionDispatcher?.();if(!ie||typeof ie.getRegisteredHandlers!="function")return;const _e=ie.getRegisteredHandlers();for(const ge of _e){if(!ge.includes(".")||typeof Q.getHandler=="function"&&Q.getHandler(ge))continue;const Ae=typeof ie.getHandler=="function"?ie.getHandler(ge):void 0;typeof Ae=="function"&&Q.registerHandler(ge,Ae,{category:"plugin",source:"preview-canvas-iife-reclone"})}}catch{}},[z]);const B=u.useMemo(()=>new PE({sampleProvider:C,actionDispatcher:M.current}),[C]),T=u.useMemo(()=>g?.raw?.meta?.guest_only===!0,[g]),H=u.useMemo(()=>Fa({sources:a,isGuestOnly:T}),[a,T]),L=u.useMemo(()=>{const Q=N4(Va(S),A,{templateId:p.templateIdentifier,locale:p.locale}),ie=H._local,{global:_e,local:ge}=I4({globalBaseline:H,localBaseline:ie??{},patch:S?.initialState??null,formErrors:Q});return{..._e,_local:ge}},[H,S,A,p.templateIdentifier,p.locale]),I=u.useMemo(()=>!S||!(!!S.initialState?.local||!!Va(S))?null:{...L._local??{},_forceLocalInit:S.id,_merge:"deep"},[S,L]),P=Q=>typeof Q=="object"&&Q!==null&&!Array.isArray(Q),F=u.useMemo(()=>P(S?.initialState?.query)?S.initialState.query:null,[S]),G=u.useMemo(()=>P(S?.initialState?.route)?S.initialState.route:null,[S]),[V,Y]=u.useState(()=>Fa({sources:[],isGuestOnly:!1})),Z=u.useRef(null);u.useEffect(()=>{const Q={...L},ie=y4({actionDispatcher:M.current,locale:p.locale,initialGlobalState:Q,onChange:ge=>{Y(ge.globalState)}});Z.current=ie;const _e=window.__templateApp;return _e?.setGlobalState&&M.current.setGlobalStateUpdater((ge,Ae)=>{_e.setGlobalState(ge,Ae)}),Y(Q),Sl({kind:"applyPatch",activeStateId:p.activeStateId,routePath:p.selectedRoute?.path??null,hasInitialPatch:!!S?.initialState,hasFormErrors:!!Va(S),hasSampleOverride:!!hg(S)}),()=>{ie.restore(),Z.current=null}},[p.locale,L]),u.useEffect(()=>{if(!A)return;const Q=window.G7Core;if(!Q||typeof Q.t!="function")return;const ie=Q.t,_e=p.templateIdentifier,ge=p.locale;return Q.t=(Ae,Ne)=>{try{const Le=Ne?"|"+Object.entries(Ne).map(([ae,pe])=>`${ae}=${pe}`).join("|"):void 0,q=A.translate(Ae,{templateId:_e,locale:ge},Le);if(q!==Ae&&!q.startsWith("$t:"))return q}catch{}return ie(Ae,Ne)},typeof window<"u"&&window.dispatchEvent(new CustomEvent(Ua)),()=>{window.G7Core?.t&&window.G7Core.t!==ie&&(window.G7Core.t=ie)}},[A,p.templateIdentifier,p.locale]);const se=u.useMemo(()=>{const Q=g?.raw?.components??[];return p.editMode!=="base"?Q:$4(Q)},[g,p.editMode]),re=g?.raw?.__editability,U=g?.raw?.__extensionId,ee=u.useMemo(()=>p.editMode!=="extension"||re!=="ok"?[]:typeof U!="number"?[]:z4(se,U),[p.editMode,re,U,se]),[fe,K]=u.useState(null),[ne,me]=u.useState(!1),Ce=g?.layoutName??null,ze=u.useMemo(()=>{const Q=g?.raw?.scripts??[];return Array.isArray(Q)?Q.map(ie=>ie&&typeof ie.src=="string"?ie.src:"").join("|"):""},[g]);u.useEffect(()=>{me(!1)},[Ce,ze]),u.useEffect(()=>{let Q=!1;const ie=g?.raw?.scripts??[];if(!Array.isArray(ie)||ie.length===0){me(!0);return}const _e=[];for(const ge of ie){if(!ge?.src||typeof ge.src!="string")continue;const Ae=ge.id?`script#${CSS.escape(ge.id)}, script[data-g7le-canvas-script="${ge.src}"]`:`script[data-g7le-canvas-script="${ge.src}"], script[src="${ge.src}"]`,Ne=window.document.querySelector(Ae);if(Ne){const Le=Ne.hasAttribute("data-g7le-canvas-script");if(Ne.hasAttribute("data-g7le-script-loaded")||!Le)continue;_e.push(new Promise(ae=>{if(Ne.hasAttribute("data-g7le-script-loaded")){ae();return}Ne.addEventListener("load",()=>ae()),Ne.addEventListener("error",()=>ae())}));continue}_e.push(new Promise(Le=>{const q=window.document.createElement("script");q.src=ge.src,q.async=!1,ge.id&&(q.id=ge.id),q.setAttribute("data-g7le-canvas-script",ge.src);const ae=()=>{q.setAttribute("data-g7le-script-loaded","1"),Le()};q.addEventListener("load",ae),q.addEventListener("error",ae),window.document.head.appendChild(q)}))}if(_e.length===0){me(!0);return}return Promise.all(_e).then(()=>{Q||requestAnimationFrame(()=>{requestAnimationFrame(()=>{Q||me(!0)})})}),()=>{Q=!0}},[g]);const Se=u.useMemo(()=>g?.raw?.data_sources??[],[g]),qe=u.useMemo(()=>g?.raw?.computed??{},[g]),[De,Pe]=u.useState({}),Qe=u.useMemo(()=>{if(p.editMode!=="iteration_item")return De;const Q=g?.iterationContext;if(!Q)return De;const ie=g?.raw?.components??[],_e=We({children:ie},Q.sourceIndexPath),ge=_e&&_e.iteration&&typeof _e.iteration.source=="string"?_e.iteration.source:null;return ge?S4(De,ge):De},[p.editMode,De,g]),[Me,Re]=u.useState(0);u.useEffect(()=>{if(typeof window>"u")return;const Q=()=>Re(ie=>ie+1);return window.addEventListener(kt,Q),()=>window.removeEventListener(kt,Q)},[]);const ve=u.useMemo(()=>m4(p.selectedRoute?.path??null),[p.selectedRoute?.path]);if(u.useEffect(()=>{let Q=!1;return(async()=>{if(Se.length===0){Q||Pe({});return}try{const ie=await B.fetchDataSources(Se,ve,new URLSearchParams,void 0,void 0);Q||Pe(ie)}catch{Q||Pe({})}})(),()=>{Q=!0}},[Se,B,ve]),u.useEffect(()=>{const Q=b;if(!Q||typeof window>"u")return;let ie=0,_e=0,ge=0;const Ae=()=>A4(Q,window.innerHeight);ie=window.requestAnimationFrame(()=>{_e=window.requestAnimationFrame(Ae)});let Ne=null;return typeof MutationObserver<"u"&&(Ne=new MutationObserver(()=>{ge||(ge=window.requestAnimationFrame(()=>{ge=0,Ae()}))}),Ne.observe(Q,{childList:!0,subtree:!0})),()=>{window.cancelAnimationFrame(ie),window.cancelAnimationFrame(_e),ge&&window.cancelAnimationFrame(ge),Ne&&Ne.disconnect()}},[b,se,ne,k,De]),u.useEffect(()=>{const Q=b;if(!Q||typeof window>"u"||p.editMode!=="extension"||re!=="ok"||ee.length===0){K(null);return}let ie=0,_e=0;const ge=ae=>{if(!ae)return!1;const pe=ae.getBoundingClientRect();return pe.width>0&&pe.height>0},Ae=ae=>{const pe=Q.querySelector(`[data-editor-path="${ae}"]`);if(ge(pe))return!0;const $e=Q.querySelectorAll(`[data-editor-path^="${ae}.children."]`);for(const be of Array.from($e))if(ge(be))return!0;return!1},Ne=()=>{K(ee.some(Ae))};ie=window.requestAnimationFrame(()=>{_e=window.requestAnimationFrame(Ne)});let Le=null,q=0;return typeof MutationObserver<"u"&&(Le=new MutationObserver(()=>{q||(q=window.requestAnimationFrame(()=>{q=0,Ne()}))}),Le.observe(Q,{childList:!0,subtree:!0})),()=>{window.cancelAnimationFrame(ie),window.cancelAnimationFrame(_e),q&&window.cancelAnimationFrame(q),Le&&Le.disconnect()}},[b,p.editMode,re,ee,ne,De]),u.useEffect(()=>{if(typeof window>"u"||p.editMode!=="base")return;const Q=window.document,ie="g7le-base-slot-style";if(Q.getElementById(ie))return;const _e=Q.createElement("style");_e.id=ie;const ge=N("layout_editor.preview.slot_area_label");return _e.textContent=`.g7le-preview-frame [data-editor-slot]{position:relative;min-height:96px;outline:2px dashed #94a3b8;outline-offset:-2px;}.g7le-preview-frame [data-editor-slot]::after{content:'${ge.replace(/'/g,"\\'")} · ' attr(data-editor-slot);position:absolute;top:8px;left:50%;transform:translateX(-50%);font-size:12px;line-height:1;color:#64748b;background:rgba(241,245,249,0.92);border:1px solid #cbd5e1;border-radius:9999px;padding:4px 12px;pointer-events:none;white-space:nowrap;}`,Q.head.appendChild(_e),()=>{Q.getElementById(ie)?.remove()}},[p.editMode,N]),!(p.editMode==="extension"||p.editMode==="iteration_item")&&(p.selectedRoute?.layoutName===null||p.selectedRoute===null))return i.jsx("div",{className:"g7le-preview-canvas g7le-preview-canvas--empty","data-testid":"g7le-preview-empty",style:Yo,children:N("layout_editor.preview.empty")});if(m)return i.jsx("div",{className:"g7le-preview-canvas g7le-preview-canvas--loading","data-testid":"g7le-preview-loading",style:Yo,children:N("layout_editor.preview.loading")});if(h)return i.jsx(ja,{error:h});if(D)return i.jsx(ja,{error:D});if(!z||$===null||A===null)return i.jsx("div",{className:"g7le-preview-canvas g7le-preview-canvas--assets-loading","data-testid":"g7le-preview-assets-loading",style:Yo,children:N("layout_editor.preview.assets_loading")});if(se.length===0)return i.jsx("div",{className:"g7le-preview-canvas g7le-preview-canvas--empty-layout","data-testid":"g7le-preview-empty-layout",style:Yo,children:N("layout_editor.preview.empty_layout")});const he=p.editMode==="extension"?re==="no-injection"?"no-injection":re==="ok"&&ee.length>0&&fe!==!0?"gated-no-state":null:null;return ne?i.jsx("div",{className:"g7le-preview-canvas","data-testid":"g7le-preview-canvas",style:{overflow:"visible",background:"#f1f5f9",padding:24,display:"flex",justifyContent:"center",alignItems:"flex-start",minHeight:"calc(100vh - var(--g7le-header-h, 0px))",boxSizing:"border-box"},children:i.jsxs("div",{className:"g7le-preview-frame-wrapper","data-testid":"g7le-preview-frame-wrapper",style:{position:"relative",width:k,transform:`scale(${w})`,transformOrigin:"top center"},children:[i.jsx("div",{className:_==="dark"?"g7le-preview-frame g7le-preview-dark":"g7le-preview-frame","data-testid":"g7le-preview-frame","data-color-scheme":_,ref:Q=>{v.current=Q,x(Q)},style:{width:k,background:"#fff",border:"1px solid #cbd5e1",boxShadow:"0 1px 3px rgba(0,0,0,0.05)",minHeight:400,position:"relative",transform:"translateZ(0)",overflow:"hidden"},children:i.jsx(ME,{overrideWidth:k,children:se.map((Q,ie)=>i.jsx(zE,{componentDef:Q,dataContext:{...Qe,...x4(V),_global:w4(V),_computedDefinitions:qe,...I?{_localInit:I,_local:{...De._local??{},...V._local??{}}}:{},route:M4({...De.route??{},...ve,path:p.selectedRoute?.path??""},G),query:{...De.query??{},...F??{}}},translationContext:{templateId:p.templateIdentifier,locale:p.locale},registry:$,bindingEngine:O.current,translationEngine:Pt.getInstance(),actionDispatcher:M.current,isEditMode:!0,isRootRenderer:ie===0,componentPath:`${ie}`,onComponentSelect:(_e,ge)=>{},onComponentHover:(_e,ge)=>{}},`${Q.id??`preview-${ie}`}:t${Me}${p.editMode==="extension"?`:s${ne?1:0}`:""}`))})}),i.jsx("div",{className:"g7le-overlay-layer","data-testid":"g7le-overlay-layer",style:{position:"absolute",left:0,top:0,width:k,height:b?b.getBoundingClientRect().height/(w||1):"100%",overflow:"visible",pointerEvents:"none"},children:i.jsx(o4,{frameEl:b,manifest:t,nesting:n,componentPalette:o,spec:r,permissionCandidates:s,resolveRouteMatch:l,onNavigateToDestination:d,onSessionAddedPathsChange:c})}),he!==null&&i.jsx("div",{className:"g7le-preview-canvas--extension-fallback","data-testid":"g7le-preview-extension-fallback","data-fallback-reason":he,style:{position:"absolute",left:0,top:0,right:0,bottom:0,minHeight:b?b.getBoundingClientRect().height/(w||1):400,background:"#f1f5f9",display:"flex",alignItems:"flex-start",justifyContent:"center",padding:24,boxSizing:"border-box",pointerEvents:"auto",zIndex:50},children:i.jsxs("div",{className:"g7le-extension-fallback-card",style:{maxWidth:440,textAlign:"center",position:"sticky",top:"calc(40vh - var(--g7le-header-h, 0px))"},children:[i.jsx("p",{className:"g7le-extension-fallback-title",style:{fontWeight:600,marginBottom:8,color:"#0f172a"},children:N(he==="no-injection"?"layout_editor.preview.extension_no_injection_title":"layout_editor.preview.extension_gated_title")}),i.jsx("p",{className:"g7le-extension-fallback-desc",style:{color:"#475569",lineHeight:1.6},children:he==="no-injection"?N("layout_editor.preview.extension_no_injection_desc"):p.availableStates.length>1?N("layout_editor.preview.extension_gated_desc_with_states"):N("layout_editor.preview.extension_gated_desc")})]})})]})}):i.jsx("div",{className:"g7le-preview-canvas g7le-preview-canvas--scripts-loading","data-testid":"g7le-preview-scripts-loading",style:Yo,children:N("layout_editor.preview.loading")})}function N4(e,t,n){if(!e)return;if(!t)return e;const o=s=>{if(typeof s=="string"&&s.startsWith("$t:"))try{const a=s.slice(3),l=a.indexOf("|");if(l===-1)return t.translate(a,n);const d=a.slice(0,l),c=a.slice(l);return t.translate(d,n,c)}catch{return s}return Array.isArray(s)?s.map(o):s},r={};for(const[s,a]of Object.entries(e))r[s]=o(a);return r}function z4(e,t){const n=[],o=(r,s)=>{Array.isArray(r)&&r.forEach((a,l)=>{if(!a||typeof a!="object")return;const d=s?`${s}.children.${l}`:`${l}`,c=a.__source;if(c?.kind==="extension"&&c.extensionId===t){n.push(d);return}Array.isArray(a.children)&&o(a.children,d)})};return o(e,""),n}function M4(e,t){if(!t)return e;const n={...e};for(const[o,r]of Object.entries(t))r===null?delete n[o]:n[o]=r;return n}const Yo={flex:1,display:"flex",alignItems:"center",justifyContent:"center",padding:24,background:"#f1f5f9",color:"#64748b",fontSize:13},L4={desktop:"layout_editor.device.desktop",tablet:"layout_editor.device.tablet",mobile:"layout_editor.device.mobile",portable:"layout_editor.device.portable"};function P4(){const{device:e,zoom:t,customWidth:n,setDevice:o,setZoom:r,setCustomWidth:s,colorScheme:a,setColorScheme:l}=Ba(),{t:d}=tt(),c=Hf();return i.jsxs("div",{className:"g7le-device-toolbar","data-testid":"g7le-device-toolbar",style:{display:"inline-flex",alignItems:"center",gap:8,padding:"4px 8px",background:"#fff",border:"1px solid #cbd5e1",borderRadius:6},children:[i.jsxs("div",{role:"group","aria-label":d("layout_editor.device.group_label"),children:[c.map(p=>{const f=e===p,y=L4[p],g=y?d(y):p;return i.jsx("button",{type:"button","data-testid":`g7le-device-${p}`,"aria-pressed":f,onClick:()=>o(p),style:{padding:"2px 8px",marginRight:2,fontSize:12,cursor:"pointer",background:f?"#0f172a":"#f8fafc",color:f?"#fff":"#0f172a",border:"1px solid #cbd5e1",borderRadius:4},children:g},p)}),(()=>{const p=e==="custom";return i.jsx("button",{type:"button","data-testid":"g7le-device-custom","aria-pressed":p,onClick:()=>o("custom"),style:{padding:"2px 8px",marginRight:2,fontSize:12,cursor:"pointer",background:p?"#0f172a":"#f8fafc",color:p?"#fff":"#0f172a",border:"1px solid #cbd5e1",borderRadius:4},children:d("layout_editor.device.custom")})})()]}),e==="custom"&&i.jsxs("div",{style:{display:"inline-flex",alignItems:"center",gap:4},children:[i.jsx("label",{htmlFor:"g7le-custom-width-input",style:{fontSize:11,color:"#475569"},children:d("layout_editor.device.custom_width_label")}),i.jsx("input",{id:"g7le-custom-width-input",type:"number",min:ig,max:sg,step:10,value:n,"data-testid":"g7le-custom-width-input","aria-label":d("layout_editor.device.custom_width_label"),onChange:p=>{const f=parseInt(p.currentTarget.value,10);Number.isFinite(f)&&s(f)},style:{width:72,padding:"2px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:4}}),i.jsx("span",{style:{fontSize:11,color:"#475569"},children:"px"})]}),i.jsx("div",{role:"group","aria-label":d("layout_editor.toolbar.color_scheme_group"),style:{display:"inline-flex",gap:2},children:["light","dark"].map(p=>{const f=a===p;return i.jsx("button",{type:"button","data-testid":`g7le-toolbar-scheme-${p}`,"aria-pressed":f,onClick:()=>l(p),style:{padding:"2px 8px",fontSize:12,cursor:"pointer",background:f?"#0f172a":"#f8fafc",color:f?"#fff":"#0f172a",border:"1px solid #cbd5e1",borderRadius:4},children:d(p==="light"?"layout_editor.toolbar.color_scheme_light":"layout_editor.toolbar.color_scheme_dark")},p)})}),i.jsxs("div",{style:{display:"inline-flex",alignItems:"center",gap:4},children:[i.jsx("span",{style:{fontSize:11,color:"#475569"},children:d("layout_editor.zoom.label")}),i.jsx("input",{type:"range",min:.5,max:1,step:.05,value:t,"data-testid":"g7le-zoom-slider","aria-label":d("layout_editor.zoom.aria_label"),onChange:p=>r(parseFloat(p.currentTarget.value)),style:{width:100}}),i.jsxs("span",{style:{fontSize:11,color:"#475569",minWidth:36,textAlign:"right"},children:[Math.round(t*100),"%"]})]}),i.jsx(Lb,{})]})}function O4(){const{t:e}=tt(),{state:t,dispatch:n}=Je(),{availableStates:o,activeStateId:r}=t,[,s]=u.useState(0);u.useEffect(()=>{if(typeof window>"u")return;const p=()=>s(y=>y+1),f=window.requestAnimationFrame(p);return window.addEventListener(Ua,p),()=>{window.cancelAnimationFrame(f),window.removeEventListener(Ua,p)}},[]);const a=u.useCallback((p,f)=>{if(!p)return f;if(!p.startsWith("$t:"))return p;const y=p.slice(3),g=window.G7Core;if(g&&typeof g.t=="function"){const h=g.t(y);if(h&&h!==y&&!h.startsWith("$t:"))return h}const m=e(y);return m&&m!==y?m:f},[e]);if(!Array.isArray(o)||o.length<=1)return null;const l=p=>{const f=p.target.value;n({type:"SET_ACTIVE_STATE",activeStateId:f}),Sl({kind:"change",activeStateId:f,routePath:t.selectedRoute?.path??null,availableCount:o.length})},d=o.find(p=>p.id===r)??o[0],c=d?.description?a(d.description,""):"";return i.jsxs("div",{className:"g7le-state-switcher","data-testid":"g7le-state-switcher","data-active-state":r??"",style:{display:"inline-flex",alignItems:"center",gap:6,padding:"4px 8px",background:"#fff",border:"1px solid #cbd5e1",borderRadius:6},children:[i.jsx("span",{style:{fontSize:12,fontWeight:600,color:"#475569",whiteSpace:"nowrap"},children:e("layout_editor.toolbar.state")}),i.jsx("select",{"data-testid":"g7le-state-switcher-select","aria-label":e("layout_editor.toolbar.state"),value:r??o[0].id,onChange:l,style:{fontSize:12,padding:"2px 6px",border:"1px solid #cbd5e1",borderRadius:4,background:"#fff",color:"#0f172a",cursor:"pointer",outline:"none"},children:o.map(p=>i.jsx("option",{value:p.id,children:a(p.label,p.id)},p.id))}),c&&i.jsx("span",{"data-testid":"g7le-state-switcher-description",style:{fontSize:11,color:"#94a3b8",whiteSpace:"nowrap"},children:c})]})}function B4(e,t,n){if(e==="extension"){if(typeof n=="string"&&n.length>0){const o=qa(n,"__base__/");return o?{kind:"base",match:o}:{kind:"route",match:n}}return null}if(!t)return null;switch(e){case"route":return{kind:"route",match:t};case"base":{const o=qa(t,"__base__/");return o?{kind:"base",match:o}:null}case"modal":{const o=qa(t,"__modal__/");return o?{kind:"modal",match:o}:null}default:return null}}function W4(e,t){if(!t||!Array.isArray(e)||e.length===0)return[];const n=[];for(const o of e)if(H4(o.scope,t))for(const r of o.items??[])r&&typeof r.id=="string"&&r.id.length>0&&n.push(r);return n}function j4(e){if(e.length===0)return null;const t=e.find(n=>n.default===!0);return t?t.id:e[0].id}function H4(e,t){if(!e||e.kind!==t.kind)return!1;const n=e.match;return typeof n!="string"||n.length===0?!1:t.kind==="route"?F4(t.match,n):n===t.match}function F4(e,t){if(t===e)return!0;if(!t.includes("*"))return!1;const n=t.replace(/[.+?^${}()|[\]\\]/g,"\\$&").replace(/\*/g,"[^/]+");return new RegExp(`^${n}$`).test(e)}function qa(e,t){return e.startsWith(t)?e.slice(t.length):""}const K4="__conngroup__/modals/",V4="__conngroup__/extensions/";function U4(e){return e.meta?.editor_label?{label:e.meta.editor_label,labelSource:"editor_label"}:e.meta?.title?{label:e.meta.title,labelSource:"title"}:{label:e.path,labelSource:"path"}}function q4(e){switch(e.source.kind){case"template":return"template";case"module":return`module:${e.source.identifier??""}`;case"plugin":return`plugin:${e.source.identifier??""}`;case"core":return null;default:return null}}function G4(e,t){if(e.meta?.parent&&t.has(e.meta.parent))return e.meta.parent;const n=e.path.split("/").filter(Boolean);for(let o=n.length-1;o>=1;o--){const r="/"+n.slice(0,o).join("/");if(r!==e.path&&t.has(r))return r}return null}function Y4(e){const t=new Map;for(const n of e)n.layout&&t.set(n.layout,(t.get(n.layout)??0)+1);return t}function X4(e){const t=new Map;for(const n of e){if(!n.hostLayout)continue;const o=t.get(n.hostLayout)??[];o.push(n),t.set(n.hostLayout,o)}return t}function J4(e){const t=new Map;for(const n of e)for(const o of n.extensions)for(const r of o.hostLayouts??[]){if(!r)continue;const s=t.get(r)??[];s.push({extension:o,group:n}),t.set(r,s)}return t}function Z4(e,t){return{path:`__modal__/${e.hostLayout}/${e.modalId}`,layoutName:e.hostLayout,label:e.label??e.modalId,labelSource:e.label?"editor_label":"path",source:{kind:"core",identifier:null},kind:"modal",modalId:e.modalId,modalHostLayout:e.hostLayout,connectedHostRoutePath:t,children:[]}}function Q4(e,t){const{extension:n,group:o}=e,r=o.sourceType==="plugin"?"🔌":"🧩";return{path:`__extension__/${n.id}`,layoutName:null,label:`${r} ${o.sourceLabel} · ${n.targetName}`,labelSource:"path",source:{kind:o.sourceType,identifier:o.sourceIdentifier},kind:"extension",extensionId:String(n.id),extensionType:n.extensionType,extensionPriority:n.priority,extensionTargetName:n.targetName,isModified:n.isModified,isInactive:!n.isActive,connectedHostRoutePath:t,children:[]}}function bg(e,t,n){if(e.kind!=="route"&&e.kind!=="base"||!e.layoutName)return;const o=[],r=t.get(e.layoutName)??[];r.length>0&&o.push({path:`${K4}${e.path}`,layoutName:null,label:`$t:layout_editor.chrome.route_tree.connected.modals|count=${r.length}`,labelSource:"editor_label",source:{kind:"core",identifier:null},kind:"route",children:r.map(a=>Z4(a,e.path))});const s=n.get(e.layoutName)??[];s.length>0&&o.push({path:`${V4}${e.path}`,layoutName:null,label:`$t:layout_editor.chrome.route_tree.connected.extensions|count=${s.length}`,labelSource:"editor_label",source:{kind:"core",identifier:null},kind:"route",children:s.map(a=>Q4(a,e.path))}),o.length>0&&(e.children=[...o,...e.children??[]])}function R4(e){const t=[],n=new Set(e.routes.map(o=>o.path));for(const o of e.routes){const r=q4(o);if(!r)continue;const{label:s,labelSource:a}=U4(o),l=G4(o,n),d={path:o.path,layoutName:o.layout??null,label:s,labelSource:a,icon:o.meta?.icon??null,source:o.source,isRedirect:!!o.redirect,isHidden:!!o.meta?.hidden,kind:"route",children:[]};t.push({node:d,parentKey:l,groupKey:r})}return t}function eD(e,t){if(e==="template")return"$t:layout_editor.chrome.route_tree.group.template";if(e.startsWith("module:")){const n=e.slice(7);return`$t:layout_editor.chrome.route_tree.group.module|name=${t.moduleDisplayNames[n]??n}`}if(e.startsWith("plugin:")){const n=e.slice(7);return`$t:layout_editor.chrome.route_tree.group.plugin|name=${t.pluginDisplayNames[n]??n}`}return e}function tD(e,t){Y4(e.routes);const n=X4(e.modals),o=J4(e.extensionGroups??[]),r=new Map;for(const d of t)r.has(d.groupKey)||r.set(d.groupKey,[]),r.get(d.groupKey).push(d);const s=Array.from(r.keys()).sort((d,c)=>{const p=g=>g==="template"?1:g.startsWith("module:")?2:g.startsWith("plugin:")?3:4,f=p(d),y=p(c);return f!==y?f-y:d.localeCompare(c)}),a=[];if(e.baseLayouts.length>0){const d=e.baseLayouts.map(c=>({path:`__base__/${c.layoutName}`,layoutName:c.layoutName,label:c.label??c.layoutName,labelSource:c.label?"editor_label":"path",source:{kind:"core",identifier:null},kind:"base",children:[]}));for(const c of d)bg(c,n,o);a.push({path:"__group__/base",layoutName:null,label:"$t:layout_editor.chrome.route_tree.group.base",labelSource:"editor_label",source:{kind:"core",identifier:null},kind:"route",children:d})}for(const d of s){const c=r.get(d),p=new Map;for(const y of c)p.set(y.node.path,y);const f=[];for(const y of c)if(y.parentKey&&p.has(y.parentKey)){const g=p.get(y.parentKey);g.node.children=g.node.children??[],g.node.children.push(y.node)}else f.push(y.node);for(const y of c)bg(y.node,n,o);a.push({path:`__group__/${d}`,layoutName:null,label:eD(d,e),labelSource:"editor_label",source:{kind:"core",identifier:null},kind:"route",children:f})}if(e.modals.length>0){const d=e.modals.map(c=>({path:`__modal__/${c.hostLayout}/${c.modalId}`,layoutName:c.hostLayout,label:c.label??c.modalId,labelSource:c.label?"editor_label":"path",source:{kind:"core",identifier:null},kind:"modal",modalId:c.modalId,modalHostLayout:c.hostLayout,children:[]}));a.push({path:"__group__/modal",layoutName:null,label:"$t:layout_editor.chrome.route_tree.group.modal",labelSource:"editor_label",source:{kind:"core",identifier:null},kind:"route",children:d})}const l=e.extensionGroups??[];if(l.length>0){const d=l.map(c=>{const p=c.sourceType==="plugin"?"🔌":"🧩",f=c.extensions.map(y=>({path:`__extension__/${y.id}`,layoutName:null,label:`${c.sourceLabel} · ${y.targetName}`,labelSource:"path",source:{kind:c.sourceType,identifier:c.sourceIdentifier},kind:"extension",extensionId:String(y.id),extensionType:y.extensionType,extensionPriority:y.priority,extensionTargetName:y.targetName,isModified:y.isModified,isInactive:!y.isActive,children:[]}));return{path:`__extgroup__/${c.sourceType}/${c.sourceIdentifier}`,layoutName:null,label:`${p} ${c.sourceLabel}`,labelSource:"path",source:{kind:c.sourceType,identifier:c.sourceIdentifier},kind:"route",children:f}});a.push({path:"__group__/extension",layoutName:null,label:"$t:layout_editor.chrome.route_tree.group.extension",labelSource:"editor_label",source:{kind:"core",identifier:null},kind:"route",children:d})}return a}function nD(e){const t=R4(e);return tD(e,t)}const Pn=ar("useEditorRoutes");function oD(e,t){if(typeof e=="string")return e.length>0?e:null;if(e&&typeof e=="object"){const n=e[t];if(typeof n=="string"&&n.length>0)return n;if(typeof e.en=="string"&&e.en.length>0)return e.en;for(const o of Object.values(e))if(typeof o=="string"&&o.length>0)return o}return null}function mg(e,t){const n={};if(typeof window>"u")return n;const r=window.G7Config?.[e];if(!Array.isArray(r))return n;for(const s of r)s&&typeof s.identifier=="string"&&(n[s.identifier]=oD(s.display_name,t)??s.identifier);return n}function rD(e){const t=new yd,n=typeof window<"u"?window.G7Config:void 0,o={_global:{settings:n?.settings??{},plugins:n?.plugins??{},modules:n?.modules??{},appConfig:n?.appConfig??{}}};return e.map(r=>{const s={...r};if(typeof s.path=="string"&&s.path.includes("{{")){const a=s.path;let l=t.resolveBindings(s.path,o);l=l.replace(/\/\/+/g,"/")||"/",l.includes("{{")&&(Pn.warn("편집기 route path 표현식 평가 실패 — fallback 적용",{originalPath:a}),l=a.replace(/\{\{[^}]+\}\}/g,"").replace(/\/\/+/g,"/")||"/"),s.path=l}if(typeof s.redirect=="string"&&s.redirect.includes("{{")){const a=s.redirect;let l=t.resolveBindings(a,o);l=l.replace(/\/\/+/g,"/")||"/",l.includes("{{")&&(Pn.warn("편집기 route redirect 표현식 평가 실패 — fallback 적용",{originalRedirect:a}),l=a.replace(/\{\{[^}]+\}\}/g,"").replace(/\/\/+/g,"/")||"/"),s.redirect=l}return s})}function iD(e){const{dispatch:t}=Je(),{translationEngine:n,translationContext:o}=tt(),{templateIdentifier:r,cacheVersion:s}=e;u.useEffect(()=>{let a=!1;const l=s??(typeof window<"u"?window.G7Config?.cache_version:void 0)??"",d=l!==""?`?v=${encodeURIComponent(String(l))}`:"",c=`/api/admin/templates/${encodeURIComponent(r)}/editor/routes.json${d}`,p=o?.locale??"ko",f=n&&r!==o?.templateId?n.loadTranslations(r,p).then(()=>{Pn.log("편집 대상 lang 로드 완료",{targetTemplate:r,locale:p})}).catch(h=>{Pn.warn("편집 대상 lang 로드 실패 — 라우트 라벨 fallback 사용",h)}):Promise.resolve(),y=(()=>{if(typeof fetch!="function")return Promise.reject(oo(new Error("fetch unavailable"),"routes"));try{const h=fetch(c,{credentials:"same-origin",headers:je()});return!h||typeof h.then!="function"?Promise.reject(oo(new Error("fetch returned non-Promise (test stub?)"),"routes")):h.then(async v=>{if(!v.ok){const b=await v.json().catch(()=>null);throw Ur(v.status,b,"routes")}return v.json()})}catch(h){return Promise.reject(h)}})(),g=`/api/admin/templates/${encodeURIComponent(r)}/layout-extensions${d}`,m=(()=>{if(typeof fetch!="function")return Promise.resolve(null);try{const h=fetch(g,{credentials:"same-origin",headers:je()});return!h||typeof h.then!="function"?Promise.resolve(null):h.then(async v=>v.ok?await v.json():null).catch(()=>null)}catch{return Promise.resolve(null)}})();return Promise.all([y,f,m]).then(([h,,v])=>{if(a)return;if(!h||!h.success||!Array.isArray(h.data?.routes)){Pn.warn("routes 응답 형식이 비정상 — empty tree 로 둠",h),t({type:"SET_ROUTES_ERROR",error:{kind:"unknown",status:200,message:"routes 응답 형식이 비정상입니다.",source:"routes"}});return}const b=rD(h.data.routes),x=mg("activeModules",p),k=mg("activePlugins",p),w=(h.data.base_layouts??[]).map(A=>({layoutName:A.layout_name,label:A.label??void 0})),_=(h.data.modals??[]).map(A=>({modalId:A.modal_id,hostLayout:A.host_layout,label:A.label??void 0})),S=(v?.success&&Array.isArray(v.data)?v.data:[]).map(A=>({sourceIdentifier:A.source_identifier,sourceType:A.source_type,sourceLabel:A.source_label,extensions:(A.extensions??[]).map(z=>({id:z.id,extensionType:z.extension_type,targetName:z.target_name,priority:z.priority,isActive:z.is_active,isModified:z.is_modified,hostLayouts:Array.isArray(z.host_layouts)?z.host_layouts:[],currentVersion:typeof z.current_version=="number"?z.current_version:void 0}))})),E=nD({routes:b,modals:_,baseLayouts:w,extensionGroups:S,moduleDisplayNames:x,pluginDisplayNames:k});t({type:"SET_ROUTE_TREE",tree:E});const C=h.data.layout_versions,N={};if(C&&typeof C=="object"&&!Array.isArray(C))for(const[A,z]of Object.entries(C))typeof z=="number"&&Number.isFinite(z)&&(N[A]=z);t({type:"SET_LAYOUT_VERSIONS",versions:N});const $={};for(const A of S)for(const z of A.extensions){const D=z.currentVersion;typeof D=="number"&&Number.isFinite(D)&&($[String(z.id)]=D)}t({type:"SET_EXTENSION_VERSIONS",versions:$}),Pn.log("routes 로드 완료",{count:b.length,groups:E.length})}).catch(h=>{if(a)return;Pn.error("routes 로드 실패",h);const v=dp(h)?h:oo(h,"routes");t({type:"SET_ROUTES_ERROR",error:v})}),()=>{a=!0}},[r,s,t,n,o?.locale,o?.templateId])}function vg(e,t,n,o=[]){const r=t?_g(o,t):null,s=r!==null?{path:r.path,layoutName:r.layoutName}:void 0;if(e.startsWith("__base__/")){const a=e.slice(9);if(a)return n({type:"ENTER_BASE_EDIT",layoutName:a,returnRoute:s}),!0}else if(e.startsWith("__modal__/")){const a=e.slice(10);return a&&!a.includes("/")&&t?(n({type:"ENTER_MODAL_EDIT",modalId:a,hostLayout:t,returnRoute:s}),!0):!1}else if(e.startsWith("__extension__/")){const a=e.slice(14);if(a&&!a.includes("/"))return n({type:"ENTER_EXTENSION_EDIT",extensionId:a,returnRoute:s}),!0}else if(e.startsWith("__iteration__/")){const a=e.slice(14);return a&&t?(n({type:"ENTER_ITERATION_ITEM_EDIT",sourcePath:a,hostLayout:t,returnRoute:s}),!0):!1}return!1}function _g(e,t){for(const n of e){if(n.kind==="route"&&n.layoutName===t)return n;if(n.children&&n.children.length>0){const o=_g(n.children,t);if(o)return o}}return null}function Ga(e,t){for(const n of e){if(n.kind==="route"&&n.path===t)return n;if(n.children&&n.children.length>0){const o=Ga(n.children,t);if(o)return o}}return null}function sD(e){const{state:t,dispatch:n}=Je(),o=u.useRef(!1),r=u.useRef(t.editMode);r.current=t.editMode,u.useEffect(()=>{if(o.current||t.routeTree.length===0||typeof window>"u")return;const a=tp(window.location.search);if(a){const c=ep(window.location.search),p=vg(a,c,n,t.routeTree);if(o.current=!0,p)return}const l=Ru(window.location.search);if(!l){o.current=!0;return}const d=Ga(t.routeTree,l);d&&n({type:"SELECT_ROUTE",route:{path:d.path,layoutName:d.layoutName}}),o.current=!0},[t.routeTree,n]),u.useEffect(()=>{if(typeof window>"u")return;const a=()=>{const l=tp(window.location.search);if(l){const p=ep(window.location.search);if(vg(l,p,n,t.routeTree))return}if(r.current!=="route")switch(r.current){case"base":n({type:"EXIT_BASE_EDIT"});return;case"modal":n({type:"EXIT_MODAL_EDIT"});return;case"extension":n({type:"EXIT_EXTENSION_EDIT"});return;case"iteration_item":n({type:"EXIT_ITERATION_ITEM_EDIT"});return}const d=Ru(window.location.search);if(!d){n({type:"SELECT_ROUTE",route:null});return}const c=Ga(t.routeTree,d);c&&n({type:"SELECT_ROUTE",route:{path:c.path,layoutName:c.layoutName}})};return window.addEventListener("popstate",a),()=>{window.removeEventListener("popstate",a)}},[t.routeTree,n]);const s=u.useRef("route");u.useEffect(()=>{if(typeof window>"u"||!window.history?.replaceState||t.routeTree.length===0)return;const a=Xs(e,t.selectedRoute?.path??null,t.selectedRoute?.layoutName??null),l=`${window.location.pathname}${window.location.search}`,d=s.current;if(s.current=t.editMode,l===a)return;d==="route"&&t.editMode!=="route"&&window.history.pushState?window.history.pushState(window.history.state,"",a):window.history.replaceState(window.history.state,"",a)},[t.selectedRoute,t.editMode,t.routeTree,e])}function aD(){u.useEffect(()=>{if(typeof document>"u")return;const e=document.documentElement;let t=e.classList.contains("dark");const n=()=>{e.classList.contains("dark")&&(t=!0,e.classList.remove("dark"))};n();let o=null;return typeof MutationObserver<"u"&&(o=new MutationObserver(n),o.observe(e,{attributes:!0,attributeFilter:["class"]})),()=>{o?.disconnect(),t&&e.classList.add("dark")}},[])}function lD(){u.useEffect(()=>{if(typeof document>"u"||!document.body)return;const e=document.body,t=()=>{e.style.overflow==="hidden"&&(e.style.overflow=""),e.style.overflowY==="hidden"&&(e.style.overflowY="")};t();let n=null;return typeof MutationObserver<"u"&&(n=new MutationObserver(t),n.observe(e,{attributes:!0,attributeFilter:["style"]})),()=>{n?.disconnect()}},[])}function dD(e){if(typeof e!="string")return null;const t="__extension__/";if(!e.startsWith(t))return null;const n=e.slice(t.length);return n===""||n.includes("/")?null:n}function cD(e){if(Array.isArray(e.components))return e.components;if(Array.isArray(e.injections)){const t=[];return e.injections.forEach((n,o)=>{if(n&&Array.isArray(n.components))for(const r of n.components)t.push({...r,__injectionIndex:o})}),t}return[]}function uD(e,t){if(Array.isArray(e.components)){const n=t.map(o=>Ya(o));return{...e,components:n}}if(Array.isArray(e.injections)){const n=e.injections.map(o=>({...o}));n.forEach(o=>{Array.isArray(o.components)&&(o.components=[])});for(const o of t){const r=o.__injectionIndex,s=typeof r=="number"&&r>=0&&rYa(n))),t}function xg(e,t){const n=[],o=r=>{if(Array.isArray(r))for(const s of r){if(!s||typeof s!="object")continue;const a=s.__source;if(a?.kind==="extension"&&a.extensionId===t){n.push(s);continue}Array.isArray(s.children)&&o(s.children)}};return o(e),n}function wg(e,t){const o=xg(e,t)[0]?.__source;return o&&o.kind==="extension"&&o.extensionId===t?o:{kind:"extension",extensionId:t}}function Xa(e,t){for(const n of e){const o=n?.__source;if(o?.kind==="extension"&&o.extensionId===t||Array.isArray(n.children)&&Xa(n.children,t))return!0}return!1}function pD(){const{state:e}=Je(),t=e.templateIdentifier,n=e.editMode==="extension"?dD(e.selectedRoute?.path):null,o=e.editMode==="extension"&&typeof e.selectedRoute?.layoutName=="string"?e.selectedRoute.layoutName:null,[r,s]=u.useState(null),[a,l]=u.useState(!1),[d,c]=u.useState(null),[p,f]=u.useState(!1),[y,g]=u.useState(0),[m,h]=u.useState(null),v=u.useRef({}),b=u.useRef({kind:"extension",extensionId:0}),x=u.useRef(null);u.useEffect(()=>{x.current=r},[r]),u.useEffect(()=>{h(null)},[n]);const k=u.useCallback(async A=>{const z=window.G7Config?.cache_version??0,D=`/api/layouts/${encodeURIComponent(t)}/${A}.json?with_source_meta=1&v=${z}.${mp()}`,O=rr(),M={Accept:"application/json"};O&&(M.Authorization=`Bearer ${O}`);const B=await fetch(D,{headers:M,credentials:"same-origin"});if(!B.ok)return null;const T=await B.json().catch(()=>null);return(T&&(T.data||T))??{}},[t]),w=u.useCallback(async()=>{if(!n){s(null),c(null),f(!1);return}l(!0),c(null);try{const A=window.G7Config?.cache_version??0,z=`/api/admin/templates/${encodeURIComponent(t)}/layout-extensions/${encodeURIComponent(n)}?v=${A}`,D=await fetch(z,{headers:je(),credentials:"same-origin"}),O=await D.json().catch(()=>null);if(!D.ok){c(Ur(D.status,O,"layout")),s(null),f(!1);return}const M=(O&&(O.data||O))??{};let B={};const T=M.content;if(typeof T=="string")try{B=JSON.parse(T)}catch{B={}}else T&&typeof T=="object"&&(B=T);const H=M.extension_type==="overlay"?"overlay":"extension_point",L=typeof M.lock_version=="number"?M.lock_version:0,I=Array.isArray(M?.host_layouts)?M.host_layouts.filter(re=>typeof re=="string"):[],P=o??(I.length===1?I[0]:m&&I.includes(m)?m:null),F=Number(n);v.current=B;let V=cD(B),Y={},Z="pending";const se={kind:"extension",extensionId:F};if(b.current=se,P){const re=await k(P);if(re){Y=re;const U=Array.isArray(re.components)?re.components:[],ee=wg(U,F);if(b.current=ee,Xa(U,F))V=U,Z="ok";else{const K=(Array.isArray(re.modals)?re.modals:[]).filter(ne=>ne&&typeof ne=="object"&&Xa([ne],F));if(K.length>0){const ne=K.map(me=>({...me,props:{...me.props??{},isOpen:!0}}));V=[...U,...ne],b.current=wg(K,F),Z="ok"}else V=U,Z="no-injection"}}}s({extensionId:F,content:B,extensionType:H,components:V,lockVersion:L,hostLayouts:I,hostLayoutName:P,hostRaw:Y,editability:Z}),f(!1),st({op:"load",layoutName:`extension:${n}`,editMode:"extension",statusCode:D.status,timestamp:Date.now()})}catch(A){c(oo(A,"layout")),s(null),f(!1)}finally{l(!1)}},[t,n,m,o,k]);u.useEffect(()=>{w()},[w]);const _=u.useCallback(A=>{h(A)},[]),S=u.useCallback(A=>{s(z=>z&&{...z,components:A}),f(!0),st({op:"patch",layoutName:`extension:${x.current?.extensionId??""}`,editMode:"extension",isDirty:!0,timestamp:Date.now()})},[]),E=u.useCallback(A=>{s(z=>z&&{...z,components:A(z.components)}),f(!0)},[]),C=u.useCallback(async()=>{const A=x.current;if(!A)return{kind:"guard_no_document"};const z=`/api/admin/templates/${encodeURIComponent(t)}/layout-extensions/${A.extensionId}`,D=xg(A.components,A.extensionId),O=D.length>0?D:A.components,M=uD(v.current,O);st({op:"save",layoutName:`extension:${A.extensionId}`,editMode:"extension",saveTarget:"layout_extension",endpoint:z,isDirty:p,timestamp:Date.now()});let B;try{B=await fetch(z,{method:"PUT",headers:je({"Content-Type":"application/json"}),credentials:"same-origin",body:JSON.stringify({content:M,expected_lock_version:A.lockVersion})})}catch(H){return{kind:"network_error",message:H instanceof Error?H.message:"network error"}}const T=await B.json().catch(()=>null);if(st({op:"save_response",layoutName:`extension:${A.extensionId}`,editMode:"extension",endpoint:z,statusCode:B.status,conflict:B.status===409?{currentVersion:T?.errors?.current_version??T?.current_version,yourVersion:T?.errors?.your_version??T?.your_version}:void 0,timestamp:Date.now()}),B.ok){const H=T?.data??{},L=typeof H.lock_version=="number"?H.lock_version:A.lockVersion+1;s(P=>P&&{...P,lockVersion:L}),f(!1),g(P=>P+1);const I=typeof H.current_version=="number"?H.current_version:void 0;return{kind:"success",newLockVersion:L,newContentVersion:I,savedExtensionId:String(A.extensionId)}}return B.status===409?{kind:"concurrent_modification",currentVersion:T?.current_version??-1,yourVersion:T?.your_version??A.lockVersion}:B.status===422?{kind:"validation_failed",status:422,errors:T?.errors??null}:{kind:"network_error",message:T?.message??`HTTP ${B.status}`}},[t,p]),N=u.useCallback(async()=>{ea(),await w()},[w]),$=!!r&&r.hostLayouts.length>1&&r.hostLayoutName===null;return{document:r,isLoading:a,error:d,isDirty:p,saveSuccessCounter:y,setLayoutComponents:S,patchLayout:E,save:C,reload:N,needsHostPicker:$,selectHost:_}}const kg=new Set;function fD(e,t,n){const r=e&&Object.keys(e).length>0?e:t,s=Array.isArray(r.data_sources)?r.data_sources:[],a=Array.isArray(t.data_sources)?t.data_sources:[],l=new Set,d=[];for(const c of[...s,...a]){const p=c&&typeof c=="object"?c.id:void 0,f=typeof p=="string"?p:`__anon_${d.length}`;l.has(f)||(l.add(f),d.push(c))}return{...r,components:n,...d.length>0?{data_sources:d}:{},lock_version:void 0}}function gD(e){return{document:e.document?{layoutName:`extension:${e.document.extensionId}`,raw:{...fD(e.document.hostRaw,e.document.content,e.document.components),__editability:e.document.editability,__extensionId:e.document.extensionId},lockVersion:e.document.lockVersion}:null,isLoading:e.isLoading,error:e.error,isDirty:e.isDirty,saveSuccessCounter:e.saveSuccessCounter,reload:e.reload,patchLayout:e.patchLayout,setLayoutComponents:e.setLayoutComponents,patchDocumentRaw:()=>{},dirtyKeys:kg,dirtyLayoutNames:kg,invalidateCurrentCache:()=>{},reloadCounter:0,save:()=>e.save(),markDirty:()=>{}}}function hD({hostLayouts:e,extensionLabel:t,onSelect:n}){const{t:o}=tt();return i.jsx("div",{className:"g7le-host-picker__backdrop","data-testid":"g7le-host-picker",role:"dialog","aria-modal":"true","aria-label":o("layout_editor.host_picker.title"),style:{position:"fixed",inset:0,zIndex:9500,display:"flex",alignItems:"center",justifyContent:"center",background:"rgba(15, 23, 42, 0.55)"},children:i.jsxs("div",{className:"g7le-host-picker__panel",style:{width:"min(520px, 92vw)",maxHeight:"80vh",overflow:"auto",background:"#ffffff",color:"#0f172a",borderRadius:12,boxShadow:"0 20px 48px rgba(15, 23, 42, 0.28)",padding:"20px 22px"},children:[i.jsx("h2",{className:"g7le-host-picker__title",style:{margin:"0 0 6px",fontSize:16,fontWeight:700},children:o("layout_editor.host_picker.title")}),i.jsx("p",{className:"g7le-host-picker__desc",style:{margin:"0 0 16px",fontSize:13,color:"#475569",lineHeight:1.6},children:t?o("layout_editor.host_picker.description_with_label",{label:t}):o("layout_editor.host_picker.description")}),i.jsx("ul",{className:"g7le-host-picker__list",style:{listStyle:"none",margin:0,padding:0,display:"grid",gap:8},children:e.map(r=>i.jsx("li",{children:i.jsxs("button",{type:"button",className:"g7le-host-picker__item","data-testid":"g7le-host-picker-item","data-host":r,onClick:()=>n(r),style:{width:"100%",textAlign:"left",padding:"12px 14px",border:"1px solid #e2e8f0",borderRadius:8,background:"#f8fafc",cursor:"pointer",fontSize:13,fontWeight:600,color:"#0f172a",display:"flex",alignItems:"center",gap:8},children:[i.jsx("span",{"aria-hidden":!0,style:{fontSize:14},children:"🖼"}),i.jsx("span",{style:{flex:1,minWidth:0,overflow:"hidden",textOverflow:"ellipsis"},children:r}),i.jsx("span",{"aria-hidden":!0,style:{color:"#94a3b8"},children:"→"})]})},r))})]})})}function yD(e,t){const[n,o]=u.useState(!1),[r,s]=u.useState(null),a=u.useCallback(async l=>{if(!t||!l)return{kind:"no_document"};o(!0),s(null);try{const d=yo(l),c=`/api/admin/templates/${encodeURIComponent(e)}/layouts/${t}/preview`,p=await fetch(c,{method:"POST",credentials:"same-origin",headers:je({"Content-Type":"application/json"}),body:JSON.stringify({content:JSON.stringify(d)})}),f=await p.json().catch(()=>null);if(!p.ok){const h=f?.message??`HTTP ${p.status}`;return s(h),{kind:"network_error",message:h}}const y=f?.data,g=typeof y?.token=="string"?y.token:"",m=typeof y?.preview_url=="string"&&y.preview_url?y.preview_url:g?`/preview/${g}`:"";return m?(window.G7Core?.dispatch?.({handler:"openWindow",params:{path:m}}),{kind:"success",previewUrl:m,token:g}):(s("invalid preview response"),{kind:"network_error",message:"invalid preview response"})}catch(d){const c=d instanceof Error?d.message:"network error";return s(c),{kind:"network_error",message:c}}finally{o(!1)}},[e,t]);return{isCreating:n,error:r,createPreview:a}}const ce="layout_editor.action_recipe",yn={setState:{label:`$t:${ce}.set_state.label`,params:[{key:"target",label:`$t:${ce}.set_state.param_target`,widget:"select",options:[{value:"local"},{value:"global"}]},{key:"state",label:`$t:${ce}.set_state.param_state`,widget:"state-key-value"},{key:"merge",label:`$t:${ce}.set_state.param_merge`,widget:"toggle"}],build:{handler:"setState",params:{target:"{{target}}",merge:"{{merge}}","...":"{{state}}"}}},loadFromLocalStorage:{label:`$t:${ce}.load_local.label`,params:[{key:"key",label:`$t:${ce}.load_local.param_key`,widget:"text",required:!0},{key:"stateKey",label:`$t:${ce}.load_local.param_state_key`,widget:"state-key-picker"},{key:"defaultValue",label:`$t:${ce}.load_local.param_default`,widget:"text"}],build:{handler:"loadFromLocalStorage",params:{key:"{{key}}",stateKey:"{{stateKey}}",defaultValue:"{{defaultValue}}"}}},saveToLocalStorage:{label:`$t:${ce}.save_local.label`,params:[{key:"key",label:`$t:${ce}.save_local.param_key`,widget:"text",required:!0},{key:"value",label:`$t:${ce}.save_local.param_value`,widget:"data-chip"}],build:{handler:"saveToLocalStorage",params:{key:"{{key}}",value:"{{value}}"}}},navigate:{label:`$t:${ce}.navigate.label`,params:[{key:"path",label:`$t:${ce}.navigate.param_path`,widget:"page-picker",required:!0},{key:"query",label:`$t:${ce}.navigate.param_query`,widget:"key-value"},{key:"mergeQuery",label:`$t:${ce}.navigate.param_merge_query`,widget:"toggle"},{key:"scroll",label:`$t:${ce}.navigate.param_scroll`,widget:"select",options:[{value:"top"},{value:"preserve"},{value:"none"}]},{key:"replace",label:`$t:${ce}.navigate.param_replace`,widget:"toggle"},{key:"transition_overlay_target",label:`$t:${ce}.navigate.param_overlay_target`,widget:"component-target-picker",dependsOn:{param:"replace",equals:!0}}],build:{handler:"navigate",params:{path:"{{path}}",query:"{{query}}",mergeQuery:"{{mergeQuery}}",scroll:"{{scroll}}",replace:"{{replace}}",transition_overlay_target:"{{transition_overlay_target}}"}}},openWindow:{label:`$t:${ce}.open_window.label`,params:[{key:"target",label:`$t:${ce}.open_window.param_target`,widget:"page-picker",required:!0},{key:"windowTarget",label:`$t:${ce}.open_window.param_window_target`,widget:"select",options:[{value:"_blank"},{value:"_self"}]}],build:{handler:"openWindow",target:"{{target}}",params:{windowTarget:"{{windowTarget}}"}}},navigateBack:{label:`$t:${ce}.navigate_back.label`,params:[],build:{handler:"navigateBack"}},navigateForward:{label:`$t:${ce}.navigate_forward.label`,params:[],build:{handler:"navigateForward"}},replaceUrl:{label:`$t:${ce}.replace_url.label`,params:[{key:"path",label:`$t:${ce}.replace_url.param_path`,widget:"page-picker"},{key:"query",label:`$t:${ce}.replace_url.param_query`,widget:"key-value"}],build:{handler:"replaceUrl",params:{path:"{{path}}",query:"{{query}}"}}},toast:{label:`$t:${ce}.toast.label`,params:[{key:"message",label:`$t:${ce}.toast.param_message`,widget:"i18n-text",required:!0},{key:"type",label:`$t:${ce}.toast.param_type`,widget:"select",options:[{value:"info"},{value:"success"},{value:"warning"},{value:"error"}]},{key:"duration",label:`$t:${ce}.toast.param_duration`,widget:"number"},{key:"icon",label:`$t:${ce}.toast.param_icon`,widget:"text"}],build:{handler:"toast",params:{message:"{{message}}",type:"{{type}}",duration:"{{duration}}",icon:"{{icon}}"}}},openModal:{label:`$t:${ce}.open_modal.label`,params:[{key:"target",label:`$t:${ce}.open_modal.param_target`,widget:"modal-picker",required:!0}],build:{handler:"openModal",target:"{{target}}"}},closeModal:{label:`$t:${ce}.close_modal.label`,params:[],build:{handler:"closeModal"}},showAlert:{label:`$t:${ce}.show_alert.label`,params:[{key:"target",label:`$t:${ce}.show_alert.param_message`,widget:"i18n-text",required:!0}],build:{handler:"showAlert",target:"{{target}}"}},setError:{label:`$t:${ce}.set_error.label`,params:[{key:"target",label:`$t:${ce}.set_error.param_message`,widget:"i18n-text"},{key:"stateTarget",label:`$t:${ce}.set_error.param_state_target`,widget:"select",options:[{value:"local"},{value:"global"}]}],build:{handler:"setError",target:"{{target}}",params:{stateTarget:"{{stateTarget}}"}}},refetchDataSource:{label:`$t:${ce}.refetch.label`,params:[{key:"dataSourceId",label:`$t:${ce}.refetch.param_data_source`,widget:"datasource-picker",required:!0},{key:"sync",label:`$t:${ce}.refetch.param_sync`,widget:"toggle"}],build:{handler:"refetchDataSource",params:{dataSourceId:"{{dataSourceId}}",sync:"{{sync}}"}}},appendDataSource:{label:`$t:${ce}.append_data.label`,params:[{key:"dataSourceId",label:`$t:${ce}.append_data.param_data_source`,widget:"datasource-picker",required:!0},{key:"dataPath",label:`$t:${ce}.append_data.param_data_path`,widget:"text"},{key:"newData",label:`$t:${ce}.append_data.param_new_data`,widget:"data-chip"}],build:{handler:"appendDataSource",params:{dataSourceId:"{{dataSourceId}}",dataPath:"{{dataPath}}",newData:"{{newData}}"}}},updateDataSource:{label:`$t:${ce}.update_data.label`,params:[{key:"dataSourceId",label:`$t:${ce}.update_data.param_data_source`,widget:"datasource-picker",required:!0},{key:"data",label:`$t:${ce}.update_data.param_data`,widget:"data-chip"},{key:"merge",label:`$t:${ce}.update_data.param_merge`,widget:"toggle"}],build:{handler:"updateDataSource",params:{dataSourceId:"{{dataSourceId}}",data:"{{data}}",merge:"{{merge}}"}}},scrollIntoView:{label:`$t:${ce}.scroll.label`,params:[{key:"selector",label:`$t:${ce}.scroll.param_selector`,widget:"text",required:!0},{key:"behavior",label:`$t:${ce}.scroll.param_behavior`,widget:"select",options:[{value:"smooth"},{value:"auto"}]},{key:"block",label:`$t:${ce}.scroll.param_block`,widget:"select",options:[{value:"start"},{value:"center"},{value:"end"},{value:"nearest"}]}],build:{handler:"scrollIntoView",params:{selector:"{{selector}}",behavior:"{{behavior}}",block:"{{block}}"}}},login:{label:`$t:${ce}.login.label`,params:[{key:"body",label:`$t:${ce}.login.param_body`,widget:"key-value"}],build:{handler:"login",params:{body:"{{body}}"}}},logout:{label:`$t:${ce}.logout.label`,params:[{key:"target",label:`$t:${ce}.logout.param_target`,widget:"text"}],build:{handler:"logout",target:"{{target}}"}},setLocale:{label:`$t:${ce}.set_locale.label`,params:[{key:"target",label:`$t:${ce}.set_locale.param_target`,widget:"locale-picker",required:!0}],build:{handler:"setLocale",target:"{{target}}"}},emitEvent:{label:`$t:${ce}.emit_event.label`,params:[{key:"event",label:`$t:${ce}.emit_event.param_event`,widget:"text",required:!0},{key:"data",label:`$t:${ce}.emit_event.param_data`,widget:"data-chip"}],build:{handler:"emitEvent",params:{event:"{{event}}",data:"{{data}}"}}},apiCall:{label:`$t:${ce}.api_call.label`,params:[{key:"target",label:`$t:${ce}.api_call.param_target`,widget:"page-picker",required:!0},{key:"method",label:`$t:${ce}.api_call.param_method`,widget:"select",options:[{value:"GET"},{value:"POST"},{value:"PUT"},{value:"PATCH"},{value:"DELETE"}]},{key:"body",label:`$t:${ce}.api_call.param_body`,widget:"key-value"},{key:"query",label:`$t:${ce}.api_call.param_query`,widget:"key-value"},{key:"identity_target_email",label:`$t:${ce}.api_call.param_identity_target_email`,widget:"data-chip"},{key:"identity_target_phone",label:`$t:${ce}.api_call.param_identity_target_phone`,widget:"data-chip"},{key:"onSuccess",label:`$t:${ce}.api_call.param_on_success`,widget:"action-list"},{key:"onError",label:`$t:${ce}.api_call.param_on_error`,widget:"action-list"}],build:{handler:"apiCall",target:"{{target}}",identity_target:{email:"{{identity_target_email}}",phone:"{{identity_target_phone}}"},onSuccess:"{{onSuccess}}",onError:"{{onError}}",params:{method:"{{method}}",body:"{{body}}",query:"{{query}}"}}},conditions:{label:`$t:${ce}.conditions.label`,params:[{key:"branches",label:`$t:${ce}.conditions.param_branches`,widget:"branch-list"}],build:{handler:"conditions",conditions:"{{branches}}"}},sequence:{label:`$t:${ce}.sequence.label`,params:[{key:"actions",label:`$t:${ce}.sequence.param_actions`,widget:"action-list"}],build:{handler:"sequence",params:{actions:"{{actions}}"}}},parallel:{label:`$t:${ce}.parallel.label`,params:[{key:"actions",label:`$t:${ce}.parallel.param_actions`,widget:"action-list"}],build:{handler:"parallel",params:{actions:"{{actions}}"}}},switch:{label:`$t:${ce}.switch.label`,params:[{key:"value",label:`$t:${ce}.switch.param_value`,widget:"data-chip"},{key:"cases",label:`$t:${ce}.switch.param_cases`,widget:"action-list",advanced:!0}],build:{handler:"switch",params:{value:"{{value}}",cases:"{{cases}}"}}},suppress:{label:`$t:${ce}.suppress.label`,params:[],build:{handler:"suppress"}}};function bD(){return{actionRecipes:yn,initActionRecipes:yn,errorRecipes:{navigate:yn.navigate,openModal:yn.openModal,toast:yn.toast,setState:yn.setState,sequence:yn.sequence,parallel:yn.parallel,showErrorPage:{label:`$t:${ce}.show_error_page.label`,params:[{key:"target",label:`$t:${ce}.show_error_page.param_target`,widget:"select",options:[{value:"content"},{value:"full"}]}],build:{handler:"showErrorPage",params:{target:"{{target}}"}}}}}}function Ja(e,t){return typeof e.label=="string"?e.label.startsWith("$t:")?t(e.label.slice(3)):e.label:String(e.value)}function bi(e){return Array.isArray(e.options)?e.options:[]}function mD({control:e,value:t,onChange:n,t:o}){const r=bi(e);return i.jsx("div",{className:"g7le-widget g7le-widget--segmented","data-testid":"g7le-widget-segmented",style:TD,children:r.map(s=>{const a=s.value===t;return i.jsx("button",{type:"button","data-testid":`g7le-segment-${String(s.value)}`,"data-active":a?"true":"false",onClick:()=>n(a?void 0:s.value),style:{...BD,background:a?"#2563eb":"#fff",color:a?"#fff":"#0f172a",borderColor:a?"#2563eb":"#cbd5e1"},children:Ja(s,o)},String(s.value))})})}function vD({control:e,value:t,onChange:n,t:o}){const r=Array.isArray(e.scale)?e.scale:[],s=r.findIndex(l=>l===t),a=t!=null;return i.jsxs("div",{className:"g7le-widget g7le-widget--slider","data-testid":"g7le-widget-slider",style:Cg,children:[i.jsxs("label",{style:Za,children:[i.jsx("input",{type:"checkbox","data-testid":"g7le-slider-enabled",checked:a,onChange:l=>{l.target.checked?n(r.length>0?r[Math.max(0,s===-1?0:s)]:void 0):n(void 0)}}),i.jsx("span",{style:{fontSize:12,color:"#64748b"},children:o("layout_editor.control.use")})]}),i.jsx("input",{type:"range","data-testid":"g7le-slider-range",min:0,max:Math.max(0,r.length-1),step:1,disabled:!a||r.length===0,value:s===-1?0:s,onChange:l=>{const d=Number(l.target.value);n(r[d])},style:{flex:1}}),i.jsx("span",{"data-testid":"g7le-slider-value",style:{fontSize:12,color:"#0f172a",minWidth:48,textAlign:"right"},children:a?String(t):o("layout_editor.control.default")})]})}function _D({control:e,value:t,onChange:n,t:o}){const r=bi(e);return i.jsxs("select",{className:"g7le-widget g7le-widget--select","data-testid":"g7le-widget-select",value:t==null?"":String(t),onChange:s=>{const a=s.target.value;if(a===""){n(void 0);return}const l=r.find(d=>String(d.value)===a);n(l?l.value:a)},style:WD,children:[i.jsx("option",{value:"",children:o("layout_editor.control.default")}),r.map(s=>i.jsx("option",{value:String(s.value),children:Ja(s,o)},String(s.value)))]})}function xD({control:e,value:t,onChange:n}){const o=bi(e),r=o[1]?.value??!0,s=o[0]?.value??void 0,a=t===r;return i.jsx("button",{type:"button",className:"g7le-widget g7le-widget--toggle","data-testid":"g7le-widget-toggle",role:"switch","aria-checked":a,onClick:()=>n(a?s:r),style:{...jD,background:a?"#2563eb":"#cbd5e1"},children:i.jsx("span",{style:{...HD,transform:a?"translateX(18px)":"translateX(0)"}})})}function wD({control:e,value:t,onChange:n,t:o,freeValueDisabled:r}){const s=bi(e),a=t==null?"":String(t),[l,d]=u.useState(a);u.useEffect(()=>{d(a)},[a]);const c=p=>{const f=p.trim();if(f===""){n(void 0);return}n(f)};return i.jsxs("div",{className:"g7le-widget g7le-widget--dimension","data-testid":"g7le-widget-dimension",style:DD,children:[r?i.jsx("div",{"data-testid":"g7le-dimension-free-disabled",style:PD,children:o("layout_editor.property_modal.dark_preset_only")}):i.jsxs("div",{style:ND,children:[i.jsx("input",{type:"text","data-testid":"g7le-dimension-input",value:l,placeholder:o("layout_editor.control.dimension.placeholder"),onChange:p=>d(p.target.value),onBlur:p=>c(p.target.value),onKeyDown:p=>{p.key==="Enter"&&(p.preventDefault(),c(p.target.value))},style:zD}),i.jsx("button",{type:"button","data-testid":"g7le-dimension-clear",title:o("layout_editor.control.default"),onClick:()=>{d(""),n(void 0)},style:MD,children:o("layout_editor.control.default")})]}),s.length>0&&i.jsx("div",{style:LD,"data-testid":"g7le-dimension-chips",children:s.map(p=>{const f=String(p.value),y=f===a;return i.jsx("button",{type:"button","data-testid":`g7le-dimension-chip-${f}`,"data-active":y?"true":"false",onClick:()=>{d(f),n(f)},style:{...OD,background:y?"#2563eb":"#fff",color:y?"#fff":"#0f172a",borderColor:y?"#2563eb":"#cbd5e1"},children:Ja(p,o)},f)})})]})}function kD(e){return[{key:"t",infix:`${e}t-`,labelKey:"layout_editor.control.spacing.top"},{key:"r",infix:`${e}r-`,labelKey:"layout_editor.control.spacing.right"},{key:"b",infix:`${e}b-`,labelKey:"layout_editor.control.spacing.bottom"},{key:"l",infix:`${e}l-`,labelKey:"layout_editor.control.spacing.left"}]}function SD(e,t,n,o){const r={all:-1,sides:{}};if(typeof e!="string"||e.trim()==="")return r;const s=e.split(/\s+/).filter(a=>a.length>0);for(const a of s){const l=n.find(d=>a.startsWith(d.infix));if(l){r.sides[l.key]=o.indexOf(a.slice(l.infix.length));continue}if(a.startsWith(`${t}x-`)){const d=o.indexOf(a.slice(`${t}x-`.length));r.sides.l=d,r.sides.r=d;continue}if(a.startsWith(`${t}y-`)){const d=o.indexOf(a.slice(`${t}y-`.length));r.sides.t=d,r.sides.b=d;continue}a.startsWith(`${t}-`)&&(r.all=o.indexOf(a.slice(`${t}-`.length)))}return r}function CD({control:e,value:t,onChange:n,t:o}){const r=typeof e.spacingPrefix=="string"?e.spacingPrefix:"p",s=(Array.isArray(e.scale)?e.scale:[]).map(b=>String(b)),a=kD(r),l=SD(t,r,a,s),d=Object.values(l.sides).some(b=>b>=0),c=l.all>=0||d,[p,f]=u.useState(d);u.useEffect(()=>{d&&f(!0)},[d]);const y=Math.min(4,Math.max(0,s.length-1)),g=b=>{const x=[];for(const k of a){const w=b[k.key];w!==void 0&&w>=0&&x.push(`${k.infix}${s[w]}`)}n(x.length>0?x.join(" "):void 0)},m=b=>{n(b>=0?`${r}-${s[Math.max(0,Math.min(b,s.length-1))]}`:void 0)},h=()=>({...l.sides}),v=(b,x,k,w,_)=>{const S=k>=0;return i.jsxs("div",{style:AD,"data-testid":b,children:[i.jsxs("label",{style:{...Za,minWidth:64},children:[i.jsx("input",{type:"checkbox","data-testid":`${b}-enabled`,checked:S,onChange:E=>_(E.target.checked)}),i.jsx("span",{style:{fontSize:12,color:"#0f172a"},children:o(x)})]}),i.jsx("input",{type:"range","data-testid":`${b}-range`,min:0,max:Math.max(0,s.length-1),step:1,disabled:!S||s.length===0,value:k<0?0:k,onChange:E=>w(Number(E.target.value)),style:{flex:1}}),i.jsx("span",{"data-testid":`${b}-value`,style:{fontSize:12,color:S?"#0f172a":"#94a3b8",minWidth:28,textAlign:"right"},children:S?s[k]:"–"})]},b)};return i.jsxs("div",{className:"g7le-widget g7le-widget--spacing","data-testid":"g7le-widget-spacing",style:$D,children:[i.jsxs("div",{style:ED,children:[i.jsxs("div",{style:ID,"data-testid":"g7le-spacing-mode",children:[i.jsx("button",{type:"button","data-testid":"g7le-spacing-mode-all","data-active":p?"false":"true",onClick:()=>{f(!1),d&&n(void 0)},style:{...Sg,background:p?"#fff":"#2563eb",color:p?"#0f172a":"#fff",borderColor:p?"#cbd5e1":"#2563eb"},children:o("layout_editor.control.spacing.mode_all")}),i.jsx("button",{type:"button","data-testid":"g7le-spacing-mode-sides","data-active":p?"true":"false",onClick:()=>{if(f(!0),l.all>=0){const b={};for(const x of a)b[x.key]=l.all;g(b)}},style:{...Sg,background:p?"#2563eb":"#fff",color:p?"#fff":"#0f172a",borderColor:p?"#2563eb":"#cbd5e1"},children:o("layout_editor.control.spacing.mode_sides")})]}),i.jsx("span",{"data-testid":"g7le-spacing-value",style:{fontSize:11,color:"#64748b",minWidth:80,textAlign:"right"},children:c?String(t):o("layout_editor.control.default")})]}),p?i.jsx("div",{"data-testid":"g7le-spacing-sides",style:{display:"flex",flexDirection:"column",gap:4},children:a.map(b=>v(`g7le-spacing-side-${b.key}`,b.labelKey,l.sides[b.key]??-1,x=>{const k=h();k[b.key]=x,g(k)},x=>{const k=h();x?k[b.key]=l.sides[b.key]>=0?l.sides[b.key]:y:delete k[b.key],g(k)}))}):i.jsxs("div",{style:Cg,children:[i.jsxs("label",{style:{...Za,minWidth:64},children:[i.jsx("input",{type:"checkbox","data-testid":"g7le-spacing-all-enabled",checked:l.all>=0,onChange:b=>m(b.target.checked?y:-1)}),i.jsx("span",{style:{fontSize:12,color:"#64748b"},children:o("layout_editor.control.spacing.all")})]}),i.jsx("input",{type:"range","data-testid":"g7le-spacing-all-range",min:0,max:Math.max(0,s.length-1),step:1,disabled:l.all<0||s.length===0,value:l.all<0?0:l.all,onChange:b=>m(Number(b.target.value)),style:{flex:1}}),i.jsx("span",{"data-testid":"g7le-spacing-all-value",style:{fontSize:12,color:l.all>=0?"#0f172a":"#94a3b8",minWidth:28,textAlign:"right"},children:l.all>=0?s[l.all]:"–"})]})]})}const $D={display:"flex",flexDirection:"column",gap:6,width:"100%"},ED={display:"flex",alignItems:"center",justifyContent:"space-between",gap:8},ID={display:"inline-flex",gap:0,border:"1px solid #cbd5e1",borderRadius:6,overflow:"hidden"},Sg={padding:"3px 10px",fontSize:11,border:"none",borderRight:"1px solid #e2e8f0",cursor:"pointer"},AD={display:"flex",alignItems:"center",gap:8,width:"100%"},TD={display:"inline-flex",gap:0,border:"1px solid #cbd5e1",borderRadius:6,overflow:"hidden"},DD={display:"flex",flexDirection:"column",gap:6,width:"100%"},ND={display:"flex",gap:6,alignItems:"center"},zD={flex:1,padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,minWidth:80},MD={padding:"4px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#64748b",cursor:"pointer",whiteSpace:"nowrap"},LD={display:"flex",flexWrap:"wrap",gap:4},PD={fontSize:11,color:"#94a3b8",fontStyle:"italic",padding:"2px 0"},OD={padding:"3px 9px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:999,background:"#fff",cursor:"pointer"},BD={padding:"4px 10px",fontSize:12,border:"none",borderRight:"1px solid #e2e8f0",cursor:"pointer"},Cg={display:"flex",alignItems:"center",gap:8,width:"100%"},Za={display:"inline-flex",alignItems:"center",gap:4},WD={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",minWidth:120},jD={position:"relative",width:38,height:20,borderRadius:10,border:"none",cursor:"pointer",padding:2,display:"inline-flex",alignItems:"center",transition:"background 120ms ease"},HD={display:"block",width:16,height:16,borderRadius:8,background:"#fff",transition:"transform 120ms ease"},FD=["#0f172a","#334155","#64748b","#94a3b8","#e2e8f0","#ffffff","#dc2626","#ea580c","#d97706","#16a34a","#0891b2","#2563eb","#7c3aed","#db2777"],$g=/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;function KD(e){return Array.isArray(e.options)?e.options.filter(t=>typeof t.value=="string").map(t=>({value:t.value,swatch:typeof t.swatch=="string"?t.swatch:void 0,label:typeof t.label=="string"?t.label:void 0})):[]}function VD({control:e,value:t,onChange:n,t:o,freeValueDisabled:r}){const s=KD(e),a=s.length>0,l=typeof t=="string"&&s.some(g=>g.value===t)?t:"",d=typeof t=="string"&&$g.test(t)?t:"",[c,p]=u.useState(d);u.useEffect(()=>{p(d)},[d]);const f=g=>{const m=g.trim();if(m===""){n(void 0);return}const h=m.startsWith("#")?m:`#${m}`;$g.test(h)&&n(h)},y=FD;return i.jsxs("div",{className:"g7le-widget g7le-widget--color","data-testid":"g7le-widget-color",style:UD,children:[r?i.jsx("div",{"data-testid":"g7le-color-free-disabled",style:ZD,children:o("layout_editor.property_modal.dark_preset_only")}):i.jsxs("div",{style:qD,children:[i.jsxs("label",{style:GD,children:[i.jsx("span",{"data-testid":"g7le-color-swatch",style:{...YD,background:d||"transparent",backgroundImage:d?void 0:"linear-gradient(45deg,#e2e8f0 25%,transparent 25%,transparent 75%,#e2e8f0 75%),linear-gradient(45deg,#e2e8f0 25%,#fff 25%,#fff 75%,#e2e8f0 75%)",backgroundSize:"8px 8px",backgroundPosition:"0 0, 4px 4px"}}),i.jsx("input",{type:"color","data-testid":"g7le-color-native",value:d||"#000000",onChange:g=>n(g.target.value),style:{width:0,height:0,opacity:0,position:"absolute"}})]}),i.jsx("input",{type:"text","data-testid":"g7le-color-hex",value:c,placeholder:o("layout_editor.control.color_hex_placeholder"),onChange:g=>p(g.target.value),onBlur:g=>f(g.target.value),onKeyDown:g=>{g.key==="Enter"&&f(g.target.value)},style:XD}),i.jsx("button",{type:"button","data-testid":"g7le-color-clear",onClick:()=>n(void 0),style:JD,children:o("layout_editor.control.default")})]}),a?i.jsx("div",{style:Eg,"data-testid":"g7le-color-token-presets",children:s.map(g=>{const m=l===g.value,h=g.label?g.label.startsWith("$t:")?o(g.label.slice(3)):g.label:g.value;return i.jsx("button",{type:"button","aria-label":h,title:h,"data-testid":`g7le-color-token-${g.value}`,"data-active":m?"true":"false",onClick:()=>n(m?void 0:g.value),style:{...Ig,background:g.swatch||"#e2e8f0",outline:m?"2px solid #2563eb":"none"}},g.value)})}):!r&&i.jsx("div",{style:Eg,"data-testid":"g7le-color-presets",children:y.map(g=>i.jsx("button",{type:"button","aria-label":g,"data-testid":`g7le-color-preset-${g}`,onClick:()=>n(g),style:{...Ig,background:g,outline:d.toLowerCase()===g.toLowerCase()?"2px solid #2563eb":"none"}},g))})]})}const UD={display:"flex",flexDirection:"column",gap:8},qD={display:"flex",alignItems:"center",gap:6},GD={position:"relative",display:"inline-flex",cursor:"pointer"},YD={width:24,height:24,borderRadius:4,border:"1px solid #cbd5e1",display:"inline-block"},XD={flex:1,padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,fontFamily:"monospace"},JD={padding:"4px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",cursor:"pointer",color:"#64748b"},Eg={display:"grid",gridTemplateColumns:"repeat(7, 1fr)",gap:4},Ig={width:"100%",height:18,borderRadius:4,border:"1px solid #cbd5e1",cursor:"pointer",padding:0},ZD={fontSize:11,color:"#94a3b8",fontStyle:"italic",padding:"2px 0"},Xo=[{value:"fill",labelKey:"layout_editor.control.background_image.mode_fill",size:"cover",repeat:"no-repeat"},{value:"fit",labelKey:"layout_editor.control.background_image.mode_fit",size:"contain",repeat:"no-repeat"},{value:"tile",labelKey:"layout_editor.control.background_image.mode_tile",size:"auto",repeat:"repeat"}];function QD(e){return e&&typeof e=="object"&&!Array.isArray(e)?e:{}}function Ag(e){return Xo.find(n=>n.size===e.size&&n.repeat===e.repeat)?.value??"fill"}function RD({value:e,onChange:t,t:n}){const o=QD(e),{state:r}=Je(),s=go(),[a,l]=u.useState(!1),[d,c]=u.useState(null),[p,f]=u.useState(o.url??""),[y,g]=u.useState([]),m=u.useRef(null),h=r.templateIdentifier,v=r.selectedRoute?.layoutName??"";u.useEffect(()=>{f(o.url??"")},[o.url]);const b=u.useCallback(async()=>{if(!h||!v){g([]);return}const E=await Al(h,v);E.ok&&g(E.data)},[h,v]);u.useEffect(()=>{b()},[b]);const x=E=>{if(!E){t(void 0);return}const C=Xo.find(N=>N.value===Ag(o))??Xo[0];t({url:E,size:o.size??C.size,repeat:o.repeat??C.repeat,position:o.position??"center"})},k=E=>{const C=Xo.find(N=>N.value===E);!C||!o.url||t({url:o.url,size:C.size,repeat:C.repeat,position:o.position??"center"})},w=async E=>{const C=E.target.files?.[0];if(!C)return;l(!0),c(null);const N=await Tl(h,v,C);N.ok?(x(N.data.url),b()):c(n("layout_editor.control.background_image.upload_failed")),l(!1),m.current&&(m.current.value="")},_=async E=>{(await Dl(E.id)).ok&&g(N=>N.filter($=>$.id!==E.id))},S=()=>{const E=s.open({ariaLabel:n("layout_editor.attachment_manager.title"),width:720,maxHeightRatio:.82,content:u.createElement(Nl,{templateIdentifier:h,layoutName:v,t:n,onSelect:C=>{x(C),s.close(E)},onChanged:()=>{b()},onClose:()=>s.close(E)})})};return i.jsxs("div",{className:"g7le-widget g7le-widget--image","data-testid":"g7le-widget-image",style:eN,children:[o.url&&i.jsx("div",{"data-testid":"g7le-image-preview",style:{...tN,backgroundImage:`url(${o.url})`,backgroundSize:o.size??"cover",backgroundRepeat:o.repeat??"no-repeat",backgroundPosition:o.position??"center"}}),i.jsxs("div",{style:nN,children:[i.jsxs("label",{style:oN,children:[a?"…":n("layout_editor.control.background_image.upload"),i.jsx("input",{ref:m,type:"file",accept:"image/*","data-testid":"g7le-image-file",onChange:w,disabled:a,style:{display:"none"}})]}),i.jsx("button",{type:"button","data-testid":"g7le-image-clear",onClick:()=>t(void 0),style:rN,children:n("layout_editor.control.background_image.clear")})]}),i.jsx("input",{type:"text","data-testid":"g7le-image-url",placeholder:"https://… / URL",value:p,onChange:E=>f(E.target.value),onBlur:E=>x(E.target.value.trim()||void 0),style:iN}),i.jsx("div",{style:sN,"data-testid":"g7le-image-modes",children:Xo.map(E=>{const C=Ag(o)===E.value;return i.jsx("button",{type:"button","data-testid":`g7le-image-mode-${E.value}`,"data-active":C?"true":"false",disabled:!o.url,onClick:()=>k(E.value),style:{...aN,background:C&&o.url?"#2563eb":"#fff",color:C&&o.url?"#fff":"#0f172a"},children:n(E.labelKey)},E.value)})}),i.jsxs("div",{style:lN,"data-testid":"g7le-image-gallery",children:[i.jsxs("div",{style:dN,children:[i.jsx("span",{style:{fontSize:11,color:"#64748b"},children:n("layout_editor.attachment_manager.recent")}),i.jsxs("button",{type:"button","data-testid":"g7le-image-manage",onClick:S,style:cN,children:["🖼 ",n("layout_editor.attachment_manager.manage_link")]})]}),y.length>0?i.jsx("div",{style:uN,"data-testid":"g7le-image-thumbs",children:y.slice(0,8).map(E=>i.jsxs("div",{style:pN,"data-testid":`g7le-image-thumb-${E.id}`,children:[i.jsx("button",{type:"button",title:E.original_name,onClick:()=>x(E.url),style:{...fN,backgroundImage:`url(${E.url})`},"data-testid":`g7le-image-thumb-use-${E.id}`}),i.jsx("button",{type:"button",title:n("layout_editor.attachment_manager.delete"),onClick:()=>_(E),style:gN,"data-testid":`g7le-image-thumb-del-${E.id}`,children:"✕"})]},E.id))}):i.jsx("span",{style:{fontSize:11,color:"#94a3b8"},children:n("layout_editor.attachment_manager.empty")})]}),d&&i.jsx("span",{"data-testid":"g7le-image-error",style:{fontSize:11,color:"#dc2626"},children:d})]})}const eN={display:"flex",flexDirection:"column",gap:6},tN={width:"100%",height:80,borderRadius:6,border:"1px solid #cbd5e1"},nN={display:"flex",gap:6},oN={padding:"5px 10px",fontSize:12,border:"1px solid #2563eb",borderRadius:6,background:"#fff",color:"#2563eb",cursor:"pointer",display:"inline-flex",alignItems:"center"},rN={padding:"5px 10px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#64748b",cursor:"pointer"},iN={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6},sN={display:"inline-flex",border:"1px solid #cbd5e1",borderRadius:6,overflow:"hidden"},aN={padding:"4px 10px",fontSize:12,border:"none",borderRight:"1px solid #e2e8f0",cursor:"pointer"},lN={display:"flex",flexDirection:"column",gap:4,border:"1px solid #e2e8f0",borderRadius:6,padding:6,marginTop:2},dN={display:"flex",alignItems:"center",justifyContent:"space-between"},cN={fontSize:11,color:"#2563eb",background:"transparent",border:"none",cursor:"pointer",padding:0},uN={display:"flex",gap:6,flexWrap:"wrap"},pN={position:"relative",width:44,height:44},fN={width:44,height:44,borderRadius:6,border:"1px solid #cbd5e1",backgroundSize:"cover",backgroundPosition:"center",cursor:"pointer",padding:0},gN={position:"absolute",top:-6,right:-6,width:16,height:16,borderRadius:"50%",border:"none",background:"#dc2626",color:"#fff",fontSize:9,lineHeight:"16px",cursor:"pointer",padding:0};function hN(e){return Array.isArray(e)&&e.every(t=>t!==null&&typeof t=="object"&&!Array.isArray(t))}function yN(e){return typeof e=="string"&&/\{\{.*\}\}/.test(e)}function bN({value:e,onChange:t,t:n,bindingCandidates:o}){if(yN(e))return i.jsx("div",{className:"g7le-widget g7le-widget--options-list","data-testid":"g7le-widget-options-list-bound",style:CN,children:n("layout_editor.list_editor.bound_degraded")});const r=hN(e)?e:[],s=y=>{t(y.length===0?void 0:y)},a=(y,g)=>{const m=r.map((h,v)=>v===y?{...h,...g}:h);s(m)},l=y=>{s(r.filter((g,m)=>m!==y))},d=(y,g)=>{const m=y+g;if(m<0||m>=r.length)return;const h=[...r];[h[y],h[m]]=[h[m],h[y]],s(h)},c=()=>{s([...r,{value:"",label:""}])},p=y=>typeof y.label=="string"?y.label:y.label==null?"":String(y.label),f=y=>y.value===void 0||y.value===null?"":String(y.value);return i.jsxs("div",{className:"g7le-widget g7le-widget--options-list","data-testid":"g7le-widget-options-list",style:mN,children:[r.length===0&&i.jsx("div",{"data-testid":"g7le-options-empty",style:SN,children:n("layout_editor.list_editor.empty")}),r.map((y,g)=>i.jsxs("div",{"data-testid":`g7le-options-row-${g}`,style:vN,children:[i.jsxs("div",{style:Tg,children:[i.jsx("span",{style:Dg,children:n("layout_editor.list_editor.option_value")}),i.jsx("input",{type:"text","data-testid":`g7le-options-value-${g}`,value:f(y),placeholder:n("layout_editor.list_editor.option_value"),onChange:m=>a(g,{value:m.target.value}),style:xN})]}),i.jsxs("div",{style:Tg,children:[i.jsx("span",{style:Dg,children:n("layout_editor.list_editor.option_label")}),i.jsx("div",{"data-testid":`g7le-options-label-${g}`,style:{flex:1,minWidth:0},children:i.jsx(vt,{value:p(y),onChange:m=>a(g,{label:m??""}),t:n,placeholder:n("layout_editor.list_editor.option_label"),testidPrefix:`g7le-options-label-i18n-${g}`,candidates:o,enableExpressionTree:!0,expressionTreeCollapsible:!0})})]}),i.jsxs("div",{style:_N,children:[i.jsx("button",{type:"button","data-testid":`g7le-options-up-${g}`,title:n("layout_editor.list_editor.move_up"),disabled:g===0,onClick:()=>d(g,-1),style:Ng,children:"↑"}),i.jsx("button",{type:"button","data-testid":`g7le-options-down-${g}`,title:n("layout_editor.list_editor.move_down"),disabled:g===r.length-1,onClick:()=>d(g,1),style:Ng,children:"↓"}),i.jsx("button",{type:"button","data-testid":`g7le-options-remove-${g}`,title:n("layout_editor.list_editor.remove"),onClick:()=>l(g),style:wN,children:"✕"})]})]},g)),i.jsx("button",{type:"button","data-testid":"g7le-options-add",onClick:c,style:kN,children:n("layout_editor.list_editor.add")})]})}const mN={display:"flex",flexDirection:"column",gap:8,width:"100%"},vN={display:"flex",flexDirection:"column",gap:4,width:"100%",minWidth:0,paddingBottom:6,borderBottom:"1px solid #f1f5f9"},Tg={display:"flex",gap:6,alignItems:"center",width:"100%",minWidth:0},Dg={flexShrink:0,width:36,fontSize:11,color:"#94a3b8",textAlign:"right"},_N={display:"flex",gap:4,alignItems:"center",justifyContent:"flex-end"},xN={flex:1,minWidth:0,padding:"4px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6},Ng={padding:"2px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},wN={padding:"2px 6px",fontSize:12,border:"1px solid #fecaca",borderRadius:6,background:"#fff",color:"#dc2626",cursor:"pointer"},kN={padding:"4px 8px",fontSize:12,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",alignSelf:"flex-start"},SN={fontSize:11,color:"#94a3b8",fontStyle:"italic"},CN={fontSize:11,color:"#b45309",fontStyle:"italic",padding:"4px 2px"},zg=/[^A-Za-z0-9\-_:.]/g;function $N(e){return typeof e=="string"&&Ki(e)}function Mg(e){return Ki(e)?En(e).map(t=>t.kind==="binding"?t.raw:t.raw.replace(zg,"")).join(""):e.replace(zg,"")}const EN={width:"100%",padding:"6px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:4,fontFamily:"monospace"},IN={marginTop:4,fontSize:11,color:"#94a3b8"},AN={display:"flex",gap:6,alignItems:"center"},TN={flexShrink:0,padding:"5px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:4,background:"#f8fafc",cursor:"pointer",whiteSpace:"nowrap"};function DN({value:e,onChange:t,t:n,bindingCandidates:o}){const r=e==null?"":String(e),s=$N(e),a=!!o&&o.length>0,[l,d]=u.useState(!1),c=p=>{const f=Mg(p);t(f===""?void 0:f)};return s||l?i.jsxs("div",{"data-testid":"g7le-core-id-chip",children:[i.jsx(hs,{value:r,onChange:c,t:n,candidates:o,onDone:()=>d(!1),testidPrefix:"g7le-core-id-chip-input"}),i.jsx("div",{style:IN,children:n("layout_editor.core_props.id.binding_hint")})]}):i.jsx("div",{children:i.jsxs("div",{style:AN,children:[i.jsx("input",{type:"text",className:"g7le-widget g7le-widget--core-id","data-testid":"g7le-widget-core-id",value:r,onChange:p=>{const f=Mg(p.target.value);t(f===""?void 0:f)},spellCheck:!1,autoComplete:"off",style:EN}),a&&i.jsxs("button",{type:"button","data-testid":"g7le-core-id-add-data",onClick:()=>d(!0),style:TN,title:n("layout_editor.core_props.id.add_data"),children:["🔗 ",n("layout_editor.core_props.id.add_data")]})]})})}const NN=/[^A-Za-z0-9.\-_]/g;function zN(e){return typeof e=="string"&&/\{\{[\s\S]*?\}\}/.test(e)}function MN(e){return e.replace(NN,"")}const Lg={width:"100%",padding:"6px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:4,fontFamily:"monospace",boxSizing:"border-box"},LN={...Lg,background:"#f1f5f9",color:"#64748b",cursor:"not-allowed"},Pg={marginTop:4,fontSize:11,color:"#94a3b8"};function PN({value:e,onChange:t,t:n}){const o=e==null?"":String(e);return zN(e)?i.jsxs("div",{"data-testid":"g7le-core-datakey-bound",children:[i.jsx("input",{type:"text",className:"g7le-widget g7le-widget--core-datakey-bound","data-testid":"g7le-widget-core-datakey",value:o,readOnly:!0,disabled:!0,style:LN}),i.jsx("div",{style:Pg,children:n("layout_editor.core_props.dataKey.bound_degraded")})]}):i.jsxs("div",{children:[i.jsx("input",{type:"text",className:"g7le-widget g7le-widget--core-datakey","data-testid":"g7le-widget-core-datakey",value:o,placeholder:n("layout_editor.core_props.dataKey.placeholder"),onChange:s=>{const a=MN(s.target.value);t(a===""?void 0:a)},spellCheck:!1,autoComplete:"off",style:Lg}),i.jsx("div",{style:Pg,children:n("layout_editor.core_props.dataKey.hint")})]})}let Og=!1;function ON(){Og||(Xe("segmented",mD),Xe("slider",vD),Xe("select",_D),Xe("dimension",wD),Xe("spacing",CD),Xe("toggle",xD),Xe("color",VD),Xe("image",RD),Xe("tag-input",gs),Xe("page-picker",xw),Xe("datasource-picker",Mc),Xe("binding-picker",Mc),Xe("binding-list",Sw),Xe("state-key-picker",Cs),Xe("modal-picker",ww),Xe("i18n-text",kw),Xe("text",Cw),Xe("options-list",bN),Xe("core-id",DN),Xe("core-datakey",PN),Xe("component-target-picker",DT),Og=!0)}function Jo(e){return Array.isArray(e.children)?e.children:[]}function Zo(e){if(typeof e.text=="string")return[];let t=null;const n=(o,r)=>{const s=Jo(o);for(let a=0;ad===o?Qo(l,r,n):l);return{...e,children:a}}function Bg(e,t){const n=r=>typeof r.props?.[t]=="string";if(n(e))return[];const o=(r,s)=>{const a=Jo(r);for(let l=0;lc===r?Wg(d,s,n,o):d);return{...e,children:l}}function BN(e){return Array.isArray(e.children)?e.children:[]}function WN(e){const t=e?.itemFields;if(!Array.isArray(t))return[{kind:"text"}];const n=[];for(const o of t){if(!o||typeof o!="object")continue;const r=o;r.kind==="text"?n.push({kind:"text",label:typeof r.label=="string"?r.label:void 0}):r.kind==="prop"&&typeof r.prop=="string"&&r.prop.length>0&&n.push({kind:"prop",prop:r.prop,label:typeof r.label=="string"?r.label:void 0})}return n.length>0?n:[{kind:"text"}]}function Qa(e,t){if(!(typeof e!="string"||e.length===0))return e.startsWith("$t:")?t(e.slice(3)):e}function jg({node:e,params:t,spec:n,manifest:o,t:r,onPatchNode:s,templateIdentifier:a,candidates:l}){const d=typeof t?.childComponent=="string"?t.childComponent:null,c=t?.childTemplate&&typeof t.childTemplate=="object"?t.childTemplate:null,p=WN(t),f=Qa(typeof t?.childLabel=="string"?t.childLabel:void 0,r)??d??"",y=BN(e),g=u.useCallback(_=>{s({...e,children:_})},[e,s]),m=u.useCallback(()=>{if(c)return JSON.parse(JSON.stringify(c));if(!d)return null;const _=n?.componentPalette?.entries?.[d];if(_?.defaultNode&&typeof _.defaultNode=="object")return JSON.parse(JSON.stringify(_.defaultNode));const S=o?.components??{};let E=null;for(const C of Object.values(S))if(Array.isArray(C)){const N=C.find($=>$.name===d);if(N){E=N;break}}return E?_f(E):{type:"basic",name:d}},[c,d,n,o]),h=u.useCallback(()=>{const _=m();if(_){if(_.text==="")_.text=r("layout_editor.list_editor.new_item");else{const S=Zo(_);if(S!==null&&S.length>0){const E=mi(_,S);if(E&&E.text===""){const C=Qo(_,S,r("layout_editor.list_editor.new_item"));g([...y,C]);return}}}g([...y,_])}},[m,g,y,r]),v=u.useCallback(_=>{g(y.filter((S,E)=>E!==_))},[g,y]),b=u.useCallback((_,S)=>{const E=_+S;if(E<0||E>=y.length)return;const C=[...y];[C[_],C[E]]=[C[E],C[_]],g(C)},[g,y]),x=u.useCallback((_,S)=>{if(typeof S!="string"||S.length===0)return;const E=y[_];if(!E)return;const C=Zo(E);if(C===null)return;const N=y.map(($,A)=>A===_?Qo($,C,S):$);g(N)},[y,g]),k=u.useCallback((_,S,E)=>{if(typeof E!="string"||E.length===0)return;const C=y[_];if(!C)return;const N=Bg(C,S);if(N===null)return;const $=y.map((A,z)=>z===_?Wg(A,N,S,E):A);g($)},[y,g]);if(!d&&!c)return i.jsx("div",{className:"g7le-node-editor g7le-node-editor--children","data-testid":"g7le-children-editor-misconfigured",style:Kg,children:r("layout_editor.list_editor.no_child_component")});const w=p.length>1||p.some(_=>_.label);return i.jsxs("div",{className:"g7le-node-editor g7le-node-editor--children","data-testid":"g7le-children-editor",style:jN,children:[i.jsx("div",{style:HN,children:r("layout_editor.list_editor.items_title")}),y.length===0&&i.jsx("div",{"data-testid":"g7le-children-empty",style:Kg,children:r("layout_editor.list_editor.empty")}),y.map((_,S)=>{const E=[];for(const N of p)if(N.kind==="text"){const $=Zo(_),A=$!==null?mi(_,$):null;A&&typeof A.text=="string"&&E.push({key:"text",caption:Qa(N.label,r)??(w?r("layout_editor.list_editor.item_label"):void 0),value:A.text,onChange:z=>x(S,z),testid:`g7le-children-i18n-${S}`})}else if(N.kind==="prop"&&N.prop){const $=Bg(_,N.prop);if($!==null){const z=mi(_,$)?.props?.[N.prop];E.push({key:`prop-${N.prop}`,caption:Qa(N.label,r)??N.prop,value:typeof z=="string"?z:"",onChange:D=>k(S,N.prop,D),testid:`g7le-children-prop-${N.prop}-${S}`})}}const C=typeof _?.name=="string"?_.name:r("layout_editor.list_editor.item");return i.jsxs("div",{"data-testid":`g7le-children-row-${S}`,style:FN,children:[E.length>0?E.map(N=>i.jsx("div",{style:Hg,children:i.jsxs("div",{"data-testid":N.key==="text"?`g7le-children-text-${S}`:`g7le-children-field-${N.key}-${S}`,style:KN,children:[N.caption!==void 0&&i.jsx("div",{"data-testid":`g7le-children-caption-${N.key}-${S}`,style:VN,children:N.caption}),i.jsx(vt,{value:N.value,onChange:N.onChange,t:r,placeholder:r("layout_editor.list_editor.item_text"),testidPrefix:N.testid,candidates:l,enableExpressionTree:!0,expressionTreeCollapsible:!0})]})},N.key)):i.jsx("div",{style:Hg,children:i.jsx("span",{"data-testid":`g7le-children-label-${S}`,style:qN,children:C})}),i.jsxs("div",{style:UN,children:[i.jsx("button",{type:"button","data-testid":`g7le-children-up-${S}`,title:r("layout_editor.list_editor.move_up"),disabled:S===0,onClick:()=>b(S,-1),style:Fg,children:"↑"}),i.jsx("button",{type:"button","data-testid":`g7le-children-down-${S}`,title:r("layout_editor.list_editor.move_down"),disabled:S===y.length-1,onClick:()=>b(S,1),style:Fg,children:"↓"}),i.jsx("button",{type:"button","data-testid":`g7le-children-remove-${S}`,title:r("layout_editor.list_editor.remove"),onClick:()=>v(S),style:GN,children:"✕"})]})]},S)}),i.jsx("button",{type:"button","data-testid":"g7le-children-add",onClick:h,style:YN,children:r("layout_editor.list_editor.add_child",{component:f})})]})}const jN={display:"flex",flexDirection:"column",gap:6,width:"100%",marginBottom:12},HN={fontSize:12,fontWeight:700,color:"#0f172a",marginBottom:2},FN={display:"flex",flexDirection:"column",gap:4,width:"100%",minWidth:0,paddingBottom:6,borderBottom:"1px solid #f1f5f9"},Hg={display:"flex",gap:4,alignItems:"flex-start",width:"100%",minWidth:0},KN={flex:1,minWidth:0},VN={fontSize:11,color:"#64748b",marginBottom:2},UN={display:"flex",gap:4,alignItems:"center",justifyContent:"flex-end"},qN={flex:1,minWidth:0,padding:"4px 6px",fontSize:12,color:"#475569",fontStyle:"italic",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},Fg={padding:"2px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},GN={padding:"2px 6px",fontSize:12,border:"1px solid #fecaca",borderRadius:6,background:"#fff",color:"#dc2626",cursor:"pointer"},YN={padding:"4px 8px",fontSize:12,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",alignSelf:"flex-start"},Kg={fontSize:11,color:"#94a3b8",fontStyle:"italic"},uo={rowContainer:"Tbody",row:"Tr",cell:"Td",headerCell:"Th",colSpanProp:"colSpan",rowSpanProp:"rowSpan"};function ht(e){return{rowContainer:e?.rowContainer??uo.rowContainer,row:e?.row??uo.row,cell:e?.cell??uo.cell,headerCell:e?.headerCell??uo.headerCell,colSpanProp:e?.colSpanProp??uo.colSpanProp,rowSpanProp:e?.rowSpanProp??uo.rowSpanProp}}function On(e){return e?Array.isArray(e.children)?e.children:[]:[]}function Vg(e,t){const n=e.props?.[t],o=typeof n=="number"?n:typeof n=="string"?parseInt(n,10):NaN;return Number.isFinite(o)&&o>=1?Math.floor(o):1}function Be(e,t){const n=ht(t),o=[],r=On(e),s=[];r.forEach((f,y)=>{(On(f).some(h=>h.name===n.row)||f.name===n.rowContainer||f.name==="Thead"||f.name==="Tfoot")&&s.push({container:f,containerIndex:y,isHeader:f.name==="Thead"})}),s.length===0&&r.some(f=>f.name===n.row)&&s.push({container:e,containerIndex:-1,isHeader:!1});const a=[],l=[];let d=0,c=0;const p=f=>{for(;a.length<=f;)a.push([]);return a[f]};s.forEach((f,y)=>{On(f.container).filter(m=>m.name===n.row).forEach((m,h)=>{const v=[],b=p(d);let x=0;On(m).filter(w=>w.name===n.cell||w.name===n.headerCell).forEach((w,_)=>{for(;b[x];)x++;const S=Vg(w,n.colSpanProp),E=Vg(w,n.rowSpanProp),C={cell:w,sectionIndex:y,rowIndexInSection:h,cellIndexInRow:_,gridRow:d,gridCol:x,colSpan:S,rowSpan:E,isHeader:w.name===n.headerCell};v.push(C);for(let N=0;N=0?(a.push(s.containerIndex),l=On(e)[s.containerIndex]??e):l=e;let d=-1,c=-1,p=null;const f=On(l);for(let m=0;m=r.gridRow&&t=r.gridCol&&nr){s=!0;break}if(s)break}s||t.add(r)}const n=Array.from(t).sort((r,s)=>r-s),o=[];for(let r=0;re.rows.find(l=>(l.cells[0]?.gridRow??-1)===a)?.sectionIndex??e.rows[a]?.sectionIndex??0,o=new Set([0,t]);for(let a=1;aa){l=!0;break}if(l)break}l||o.add(a)}const r=Array.from(o).sort((a,l)=>a-l),s=[];for(let a=0;at>=a.start&&t=o.length?{ok:!1,reason:"boundary"}:o[r].sectionIndex!==o[s].sectionIndex?{ok:!1,reason:"section"}:{ok:!0}}function _i(e,t,n){const o=Ug(e),r=o.findIndex(a=>t>=a.start&&t=o.length?{ok:!1,reason:"boundary"}:{ok:!0}}function Ge(e){return e?Array.isArray(e.children)?e.children:[]:[]}function bn(e,t,n){const o={...e.props??{}};n<=1?delete o[t]:o[t]=n;const r={...e,props:o};return Object.keys(o).length===0&&delete r.props,r}function xi(e,t,n=!1){const o={type:"basic",name:n?e.headerCell:e.cell},r=t?.props?.className;return typeof r=="string"&&r&&(o.props={className:r}),o.text="",o}function Ro(e,t){return e.name===t.cell||e.name===t.headerCell}function nt(e,t,n,o){const r=t.sections[n];if(!r)return e;if(r.containerIndex===-1)return o(e);const s=Ge(e).map((a,l)=>l===r.containerIndex?o(a):a);return{...e,children:s}}function yt(e,t,n,o){let r=-1;const s=Ge(e).map(a=>a.name===t&&(r++,r===n)?o(a):a);return{...e,children:s}}function wi(e,t,n){let o=-1;const r=Ge(e);for(let s=0;s0?nt(e,r,r.sections.length-1,x=>({...x,children:[...Ge(x),b]})):{...e,children:[...Ge(e),b]}}const s=Math.max(-1,Math.min(n,r.rows.length-1)),a=s<0?r.rows[0]:r.rows[s],l=a.cells[0]?.gridRow??s,d=s<0?0:l+1,c=a.sectionIndex,p=s<0?0:a.rowIndexInSection+1,f=[],y=[];let g=0;for(;g0?Ue(r,d-1,g):null;b&&b.gridRow+b.rowSpan>d&&b.rowSpan>1?(y.push(b),g+=b.colSpan):(f.push(xi(o)),g+=1)}let m=e;for(const b of y)m=nt(m,r,b.sectionIndex,x=>yt(x,o.row,b.rowIndexInSection,k=>({...k,children:Ge(k).map((w,_)=>_===b.cellIndexInRow?bn(w,o.rowSpanProp,b.rowSpan+1):w)})));const h={type:"basic",name:o.row,children:f},v=Be(m,t);return m=nt(m,v,c,b=>{const x=Ge(b);let k=0,w=x.length;for(let _=0;_=r.rows.length||r.rows.length<=1)return e;const s=r.rows[n],a=s.cells[0]?.gridRow??n;let l=e;const d=new Set;for(let p=0;pyt(y,o.row,f.rowIndexInSection,g=>({...g,children:Ge(g).map((m,h)=>h===f.cellIndexInRow?bn(m,o.rowSpanProp,f.rowSpan-1):m)})))),p+=f?f.colSpan:1}const c=Be(l,t);return l=nt(l,c,s.sectionIndex,p=>{let f=-1;const y=Ge(p).filter(g=>g.name===o.row?(f++,f!==s.rowIndexInSection):!0);return{...p,children:y}}),l}function Yg(e,t,n,o){const r=ht(t),s=Be(e,t);if(!vi(s,n,o).ok)return e;const a=qg(s),l=a.findIndex(h=>n>=h.start&&ns.rows.filter(v=>{const b=v.cells[0]?.gridRow??-1;return v.sectionIndex===f&&b>=h.start&&bv.rowIndexInSection),g=y(c),m=y(p);return g.length===0||m.length===0?e:nt(e,s,f,h=>{const v=[...Ge(h)],b=[];v.forEach((M,B)=>{M.name===r.row&&b.push(B)});const x=g.map(M=>b[M]).filter(M=>M!==void 0),k=m.map(M=>b[M]).filter(M=>M!==void 0);if(x.length===0||k.length===0)return h;const w=x.map(M=>v[M]),_=k.map(M=>v[M]),S=Math.min(...x)n>=m.start&&nw.gridCol>=f.start&&w.gridColw.gridCol>=y.start&&w.gridColw.cellIndexInRow),x=v.map(w=>w.cellIndexInRow),k=Be(g,t);g=nt(g,k,m.sectionIndex,w=>yt(w,r.row,m.rowIndexInSection,_=>{const S=[...Ge(_)],E=b.map(M=>wi(_,r,M)).filter(M=>M>=0),C=x.map(M=>wi(_,r,M)).filter(M=>M>=0);if(E.length===0||C.length===0)return _;const N=E.map(M=>S[M]),$=C.map(M=>S[M]),A=Math.min(...E,...C),z=Math.max(...E,...C),D=[...$,...N],O=[...S.slice(0,A),...D,...S.slice(z+1)];return{..._,children:O}}))}return g}function Jg(e,t,n){const o=ht(t),r=Be(e,t);if(r.rows.length===0||r.colCount===0)return el(e,t,-1);const s=Math.max(0,Math.min(n+1,r.colCount));let a=e;const l=new Set;for(const d of r.rows){const c=d.cells[0]?.gridRow??0,p=s>0?Ue(r,c,s-1):null,f=s1){if(!l.has(p.cell)){l.add(p.cell);const h=Be(a,t);a=nt(a,h,p.sectionIndex,v=>yt(v,o.row,p.rowIndexInSection,b=>({...b,children:Ge(b).map((x,k)=>k===p.cellIndexInRow?bn(x,o.colSpanProp,p.colSpan+1):x)})))}continue}if(p&&p.gridRow0&&d.cells.every(h=>h.isHeader),m=Be(a,t);a=nt(a,m,d.sectionIndex,h=>yt(h,o.row,d.rowIndexInSection,v=>{const b=[...Ge(v)];let x;if(y<0)x=b.findIndex(k=>Ro(k,o)),x<0&&(x=b.length);else{const k=wi(v,o,y);x=k<0?b.length:k+1}return b.splice(x,0,xi(o,void 0,g)),{...v,children:b}}))}return a}function Zg(e,t,n){const o=ht(t),r=Be(e,t);if(n<0||n>=r.colCount||r.colCount<=1)return e;let s=e;const a=new Set;for(const l of r.rows){const d=l.cells[0]?.gridRow??0,c=Ue(r,d,n);if(!(!c||a.has(c.cell)||c.gridRow1){const p=Be(s,t);s=nt(s,p,c.sectionIndex,f=>yt(f,o.row,c.rowIndexInSection,y=>({...y,children:Ge(y).map((g,m)=>m===c.cellIndexInRow?bn(g,o.colSpanProp,c.colSpan-1):g)})))}else{const p=Be(s,t);s=nt(s,p,c.sectionIndex,f=>yt(f,o.row,c.rowIndexInSection,y=>{let g=-1;const m=Ge(y).filter(h=>Ro(h,o)?(g++,g!==c.cellIndexInRow):!0);return{...y,children:m}}))}}return s}function tl(e,t,n,o,r,s){const a=ht(t),l=Be(e,t),d=Math.min(n,r),c=Math.max(n,r),p=Math.min(o,s),f=Math.max(o,s);if(d===c&&p===f)return{table:e,ok:!1,reason:"single"};if(!l.valid)return{table:e,ok:!1,reason:"invalid-grid"};const y=new Set;for(let b=d;b<=c;b++)for(let x=p;x<=f;x++){const k=Ue(l,b,x);if(!k)return{table:e,ok:!1,reason:"hole"};if(k.gridRowc||k.gridCol+k.colSpan-1>f)return{table:e,ok:!1,reason:"not-rectangular"};y.add(k)}const g=Ue(l,d,p),m=Array.from(y).filter(b=>b!==g);let h=nt(e,l,g.sectionIndex,b=>yt(b,a.row,g.rowIndexInSection,x=>({...x,children:Ge(x).map((k,w)=>{if(w!==g.cellIndexInRow)return k;let _=bn(k,a.colSpanProp,f-p+1);return _=bn(_,a.rowSpanProp,c-d+1),_})})));const v=new Map;for(const b of m){const x=`${b.sectionIndex}:${b.rowIndexInSection}`,k=v.get(x)??[];k.push(b),v.set(x,k)}for(const[b,x]of v){const k=parseInt(b.split(":")[0],10),w=x[0].rowIndexInSection,_=new Set(x.map(E=>E.cellIndexInRow)),S=Be(h,t);h=nt(h,S,k,E=>yt(E,a.row,w,C=>{let N=-1;const $=Ge(C).filter(A=>Ro(A,a)?(N++,!_.has(N)):!0);return{...C,children:$}}))}return{table:h,ok:!0}}function Qg(e,t,n,o){const r=ht(t),s=Be(e,t),a=Ue(s,n,o);if(!a||a.colSpan<=1&&a.rowSpan<=1)return e;const l=a.gridRow,d=a.gridCol,c=a.gridRow+a.rowSpan-1,p=a.gridCol+a.colSpan-1,f=a.isHeader,y=typeof a.cell.props?.className=="string"?a.cell.props.className:void 0;let g=nt(e,s,a.sectionIndex,m=>yt(m,r.row,a.rowIndexInSection,h=>({...h,children:Ge(h).map((v,b)=>{if(b!==a.cellIndexInRow)return v;let x=bn(v,r.colSpanProp,1);return x=bn(x,r.rowSpanProp,1),x})})));for(let m=l;m<=c;m++)for(let h=d;h<=p;h++){if(m===l&&h===d)continue;const v=Be(g,t),b=v.rows.find(w=>w.cells.some(_=>_.gridRow===m))??v.rows[m];if(!b)continue;let x=-1;for(const w of b.cells)w.gridRow===m&&w.gridColyt(w,r.row,b.rowIndexInSection,_=>{const S=[...Ge(_)];let E;if(x<0)E=S.findIndex(C=>Ro(C,r)),E<0&&(E=S.length);else{const C=wi(_,r,x);E=C<0?S.length:C+1}return S.splice(E,0,xi(r,k,f)),{..._,children:S}}))}return g}function ki(e,t,n,o,r){const s=ht(t),a=Be(e,t),l=Ue(a,n,o);return l?nt(e,a,l.sectionIndex,d=>yt(d,s.row,l.rowIndexInSection,c=>({...c,children:Ge(c).map((p,f)=>{if(f!==l.cellIndexInRow)return p;const y={...p.props??{}};r.trim()===""?delete y.className:y.className=r;const g={...p,props:y};return Object.keys(y).length===0&&delete g.props,g})}))):e}function Jt(e,t,n,o,r,s){const a=ht(t),l=Be(e,t),d=Ue(l,n,o);return d?nt(e,l,d.sectionIndex,c=>yt(c,a.row,d.rowIndexInSection,p=>({...p,children:Ge(p).map((f,y)=>{if(y!==d.cellIndexInRow)return f;const g={...f.props??{}},m={...g.style??{}};!s||s.trim()===""?delete m[r]:m[r]=s,Object.keys(m).length===0?delete g.style:g.style=m;const h={...f,props:g};return Object.keys(g).length===0&&delete h.props,h})}))):e}function po(e,t,n,o,r,s,a){const l=ht(t),d=Be(e,t),c=Ue(d,n,o);if(!c)return e;const p=a==="dark";return nt(e,d,c.sectionIndex,f=>yt(f,l.row,c.rowIndexInSection,y=>({...y,children:Ge(y).map((g,m)=>{if(m!==c.cellIndexInRow)return g;const h={...g.props??{}},v=typeof h.className=="string"?h.className:"",b=oC(v,s,r,p);b===""?delete h.className:h.className=b;const x={...g,props:h};return Object.keys(h).length===0&&delete x.props,x})})))}function Rg(e,t,n,o,r){const s=ht(t),a=Be(e,t),l=Ue(a,n,o);return l?nt(e,a,l.sectionIndex,d=>yt(d,s.row,l.rowIndexInSection,c=>({...c,children:Ge(c).map((p,f)=>f===l.cellIndexInRow?{...p,text:r}:p)}))):e}function eh(e,t,n,o,r){const s=ht(t),a=Be(e,t),l=Ue(a,n,o);return l?nt(e,a,l.sectionIndex,d=>yt(d,s.row,l.rowIndexInSection,c=>({...c,children:Ge(c).map((p,f)=>f===l.cellIndexInRow?r:p)}))):e}const th=/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;function XN(e){return e.split(/\s+/).filter(Boolean)}function nh(e,t){for(const n of t)if(e===n||/^-?\d+$/.test(e.slice(n.length+1))&&e.startsWith(n+"-"))return!0;return!1}function oh({className:e,catalog:t,t:n,onChange:o,colorScheme:r="light",onPresetToken:s,colorStyle:a,onColorStyle:l,disabled:d}){const c=t?.sides??[],p=t?.widths??[],f=t?.colors??[],y=r==="dark",g=u.useMemo(()=>c.map($=>$.prefix),[c]),m=u.useMemo(()=>new Set(f.map($=>$.token)),[f]),h=u.useMemo(()=>XN(e),[e]),v=u.useMemo(()=>Fr(e,y),[e,y]),b=u.useMemo(()=>v.find($=>m.has($))??null,[v,m]),x=u.useMemo(()=>{const $=new Set;for(const A of c)h.some(D=>D===A.prefix||D.startsWith(A.prefix+"-")&&/^-?\d+$/.test(D.slice(A.prefix.length+1)))&&$.add(A.value);return $},[h,c]),k=u.useMemo(()=>{const $=h.find(z=>nh(z,g));if(!$)return"none";const A=p.filter(z=>z.value!=="none");for(const z of c){if($===z.prefix)return A.find(D=>(D.suffix??"")==="")?.value??"thin";if($.startsWith(z.prefix+"-")){const D=$.slice(z.prefix.length),O=A.find(M=>(M.suffix??"")===D);if(O)return O.value}}return"thin"},[h,g,c,p]);if(!t||c.length===0||p.length===0)return null;function w($){return $.filter(A=>!nh(A,g))}function _($,A){const z=w(h),D=p.find(B=>B.value===A);if(!D||A==="none"||$.size===0)return z.join(" ").trim();const O=D.suffix??"",M=[...z];for(const B of c)$.has(B.value)&&M.push(O?`${B.prefix}${O}`:B.prefix);return M.join(" ").trim()}const S=$=>{const A=new Set(x);A.has($)?A.delete($):($==="all"?A.clear():A.delete("all"),A.add($));const z=k==="none"?p.find(D=>D.value!=="none")?.value??"thin":k;o(_(A,z))},E=$=>{const A=x.size===0?new Set(["all"]):x;o(_(A,$))},C=$=>{s?.($)},N=($,A)=>$&&$.startsWith("$t:")?n($.slice(3)):$||A;return i.jsxs("div",{className:"g7le-cell-border","data-testid":"g7le-cell-border",style:JN,children:[i.jsx("div",{style:ZN,children:i.jsx("span",{style:QN,children:n("layout_editor.table_editor.cell_border")})}),i.jsx("div",{style:nl,"data-testid":"g7le-cell-border-widths",children:p.map($=>i.jsx("button",{type:"button",disabled:d,"data-testid":`g7le-cell-border-width-${$.value}`,"aria-pressed":k===$.value,onClick:()=>E($.value),style:k===$.value?rh:ol,children:N($.label,$.value)},$.value))}),k!=="none"&&i.jsx("div",{style:nl,"data-testid":"g7le-cell-border-sides",children:c.map($=>i.jsx("button",{type:"button",disabled:d,"data-testid":`g7le-cell-border-side-${$.value}`,"aria-pressed":x.has($.value),onClick:()=>S($.value),style:x.has($.value)?rh:ol,children:N($.label,$.value)},$.value))}),k!=="none"&&(f.length>0||l)&&i.jsx(i.Fragment,{children:i.jsxs("div",{style:nl,"data-testid":"g7le-cell-border-colors",children:[f.map($=>{const A=b===$.token;return i.jsx("button",{type:"button",disabled:d,title:N($.label,$.value),"data-testid":`g7le-cell-border-color-${$.value}`,"aria-pressed":A,onClick:()=>C($.token),style:{...ih,background:$.swatch??"#fff",outline:A?"2px solid #2563eb":"1px solid #cbd5e1"}},$.value)}),l&&(()=>{const $=!!a&&f.some(z=>z.swatch&&z.swatch.toLowerCase()===a.toLowerCase()),A=!y&&!!a&&!$;return i.jsx("label",{title:n(y?"layout_editor.table_editor.dark_free_color_hint":"layout_editor.table_inplace.custom_color"),"data-testid":"g7le-cell-border-color-custom",style:{...ih,position:"relative",overflow:"hidden",display:"inline-flex",alignItems:"center",justifyContent:"center",background:A&&th.test(a)?a:"conic-gradient(red, yellow, lime, aqua, blue, magenta, red)",outline:A?"2px solid #2563eb":"1px solid #cbd5e1",opacity:y?.4:1,cursor:y?"not-allowed":"pointer"},children:i.jsx("input",{type:"color",disabled:d||y,"data-testid":"g7le-cell-border-color-picker",value:a&&th.test(a)?a:"#000000",onChange:z=>l(z.target.value),style:{position:"absolute",width:0,height:0,opacity:0}})})})()]})})]})}const JN={display:"flex",flexDirection:"column",gap:6},ZN={display:"flex",alignItems:"center",justifyContent:"space-between",gap:8},QN={fontSize:12,fontWeight:600,color:"#475569"},nl={display:"flex",gap:4,flexWrap:"wrap",alignItems:"center"},ol={padding:"3px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},rh={...ol,border:"1px solid #2563eb",background:"#eff6ff",color:"#1d4ed8",fontWeight:600},ih={width:22,height:22,borderRadius:5,padding:0,cursor:"pointer",boxSizing:"border-box"},sh=/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;function ah({colorStyle:e,className:t,catalog:n,t:o,colorScheme:r="light",onPresetToken:s,onCustomColor:a,onClear:l,disabled:d}){const c=n?.colors??[],p=r==="dark",f=e??"",y=Fr(t??"",p),g=c.find(x=>x.token&&y.includes(x.token))?.token??null,m=(x,k)=>x&&x.startsWith("$t:")?o(x.slice(3)):x||k,h=!g&&(p||f.trim()===""),v=!!f&&c.some(x=>x.swatch&&x.swatch.toLowerCase()===f.toLowerCase()),b=!p&&!!f&&!v;return i.jsxs("div",{className:"g7le-cell-fill","data-testid":"g7le-cell-fill",style:RN,children:[i.jsx("div",{style:e8,children:i.jsx("span",{style:t8,children:o("layout_editor.table_editor.cell_fill")})}),i.jsxs("div",{style:n8,"data-testid":"g7le-cell-fill-colors",children:[i.jsx("button",{type:"button",disabled:d,title:o("layout_editor.table_editor.cell_fill_none"),"data-testid":"g7le-cell-fill-none","aria-pressed":h,onClick:()=>l?.(),style:{...rl,background:"repeating-conic-gradient(#e2e8f0 0% 25%, #ffffff 0% 50%) 50% / 10px 10px",outline:h?"2px solid #2563eb":"1px solid #cbd5e1"}}),c.map(x=>{const k=!!x.token&&g===x.token,w=!p&&!!x.swatch&&f.toLowerCase()===(x.swatch??"").toLowerCase(),_=k||w,S=d||p&&!x.token;return i.jsx("button",{type:"button",disabled:S,title:m(x.label,x.value),"data-testid":`g7le-cell-fill-color-${x.value}`,"aria-pressed":_,onClick:()=>{x.token&&s?s(x.token):!p&&x.swatch&&a&&a(x.swatch)},style:{...rl,background:x.swatch??"#fff",outline:_?"2px solid #2563eb":"1px solid #cbd5e1",opacity:S?.4:1,cursor:S?"not-allowed":"pointer"}},x.value)}),a&&i.jsx("label",{title:o(p?"layout_editor.table_editor.dark_free_color_hint":"layout_editor.table_editor.cell_fill_custom"),"data-testid":"g7le-cell-fill-custom",style:{...rl,position:"relative",overflow:"hidden",display:"inline-flex",alignItems:"center",justifyContent:"center",background:b&&sh.test(f)?f:"conic-gradient(red, yellow, lime, aqua, blue, magenta, red)",outline:b?"2px solid #2563eb":"1px solid #cbd5e1",opacity:p?.4:1,cursor:p?"not-allowed":"pointer"},children:i.jsx("input",{type:"color",disabled:d||p,"data-testid":"g7le-cell-fill-color-picker",value:f&&sh.test(f)?f:"#ffffff",onChange:x=>a(x.target.value),style:{position:"absolute",width:0,height:0,opacity:0}})})]})]})}const RN={display:"flex",flexDirection:"column",gap:6},e8={display:"flex",alignItems:"center",justifyContent:"space-between",gap:8},t8={fontSize:12,fontWeight:600,color:"#475569"},n8={display:"flex",gap:4,flexWrap:"wrap",alignItems:"center"},rl={width:22,height:22,borderRadius:5,padding:0,cursor:"pointer",boxSizing:"border-box"};function o8(e){const t=/^(\d+)px$/.exec(e.trim());return t?parseInt(t[1],10):null}function lh({paddingStyle:e,catalog:t,t:n,onChange:o,disabled:r}){const s=t?.steps??[],a=e??"",l=o8(a),d=(h,v)=>h&&h.startsWith("$t:")?n(h.slice(3)):h||v,c=s.some(h=>(h.px??0)===(l??-1)),p=l!==null&&!c?String(l):"",[f,y]=u.useState(p);u.useEffect(()=>{y(p)},[e]);const g=h=>{o(h&&h>0?`${h}px`:""),y("")},m=h=>{const v=parseInt(h,10);Number.isFinite(v)&&v>=0&&o(v>0?`${v}px`:"")};return i.jsxs("div",{className:"g7le-cell-padding","data-testid":"g7le-cell-padding",style:r8,children:[i.jsx("div",{style:i8,children:n("layout_editor.table_editor.cell_padding")}),i.jsxs("div",{style:s8,"data-testid":"g7le-cell-padding-steps",children:[s.map(h=>{const v=(h.px??0)===(l??-1)||!h.px&&a.trim()==="";return i.jsx("button",{type:"button",disabled:r,"data-testid":`g7le-cell-padding-step-${h.value}`,"aria-pressed":v,onClick:()=>g(h.px),style:v?a8:dh,children:d(h.label,h.value)},h.value)}),i.jsxs("span",{style:{display:"inline-flex",alignItems:"center",gap:3},children:[i.jsx("input",{type:"number",min:0,disabled:r,"data-testid":"g7le-cell-padding-free",value:f,placeholder:n("layout_editor.table_editor.cell_padding_custom"),onChange:h=>y(h.target.value),onBlur:()=>f.trim()!==""&&m(f),onKeyDown:h=>{h.key==="Enter"&&f.trim()!==""&&m(f)},style:l8}),i.jsx("span",{style:{fontSize:11,color:"#94a3b8"},children:"px"})]})]})]})}const r8={display:"flex",flexDirection:"column",gap:6},i8={fontSize:12,fontWeight:600,color:"#475569"},s8={display:"flex",gap:4,flexWrap:"wrap",alignItems:"center"},dh={padding:"3px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},a8={...dh,border:"1px solid #2563eb",background:"#eff6ff",color:"#1d4ed8",fontWeight:600},l8={width:48,padding:"3px 5px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,boxSizing:"border-box"};function ch({colorScheme:e,onChange:t,t:n,disabled:o}){const r=e==="dark";return i.jsxs("div",{className:"g7le-color-scheme-tabs","data-testid":"g7le-color-scheme-tabs",style:d8,children:[i.jsxs("div",{style:c8,children:[i.jsx("span",{style:u8,children:n("layout_editor.table_editor.cell_color")}),i.jsxs("div",{style:p8,children:[i.jsx("button",{type:"button",disabled:o,"data-testid":"g7le-color-scheme-light","aria-pressed":!r,onClick:()=>t("light"),style:r?il:uh,children:n("layout_editor.property_modal.scope.scheme_light")}),i.jsx("button",{type:"button",disabled:o,"data-testid":"g7le-color-scheme-dark","aria-pressed":r,onClick:()=>t("dark"),style:r?uh:il,children:n("layout_editor.property_modal.scope.scheme_dark")})]})]}),r&&i.jsx("div",{style:f8,"data-testid":"g7le-color-scheme-dark-hint",children:n("layout_editor.table_editor.dark_free_color_hint")})]})}const d8={display:"flex",flexDirection:"column",gap:3},c8={display:"flex",alignItems:"center",justifyContent:"space-between",gap:8},u8={fontSize:12,fontWeight:700,color:"#0f172a"},p8={display:"flex",gap:2},il={padding:"2px 9px",fontSize:11,border:"none",borderBottom:"2px solid transparent",background:"transparent",color:"#94a3b8",cursor:"pointer"},uh={...il,color:"#1d4ed8",borderBottom:"2px solid #2563eb",fontWeight:600},f8={fontSize:10,color:"#94a3b8",fontStyle:"italic"},sl="dark:";function ph(e){return typeof e=="string"?e.split(/\s+/).filter(Boolean):[]}function fh(e,t){return t==="dark"?e.filter(n=>n.startsWith(sl)).map(n=>n.slice(sl.length)):e.filter(n=>!n.startsWith(sl))}const g8={none:0,thin:1,medium:2,thick:4},gh={top:{w:"borderTopWidth",s:"borderTopStyle",c:"borderTopColor"},bottom:{w:"borderBottomWidth",s:"borderBottomStyle",c:"borderBottomColor"},left:{w:"borderLeftWidth",s:"borderLeftStyle",c:"borderLeftColor"},right:{w:"borderRightWidth",s:"borderRightStyle",c:"borderRightColor"}},hh=["top","bottom","left","right"];function h8(e,t,n="light"){if(!e||!t)return{};const o=t.sides??[],r=t.widths??[],s=t.colors??[],a=ph(e.props?.className);if(a.length===0&&!e.props?.style)return{};const l=[...r].filter(b=>b.value!=="none").sort((b,x)=>(x.suffix??"").length-(b.suffix??"").length),d={};for(const b of o)for(const x of l){const k=x.suffix?`${b.prefix}${x.suffix}`:b.prefix;if(a.includes(k)){d[b.value]=x.value;break}}const c=e.props?.style??{},p=fh(a,n),f=s.find(b=>p.includes(b.token))?.swatch??"",g=(n==="light"&&typeof c.borderColor=="string"?c.borderColor:"")||f||"#94a3b8",m=b=>{const x=gh[b]?.c,k=x?c[x]:void 0;return typeof k=="string"&&k?k:g},h={},v=(b,x)=>{const k=gh[b];if(!k)return;const w=g8[x]??1;h[k.w]=`${w}px`,h[k.s]=w>0?"solid":"none",w>0&&(h[k.c]=m(b))};if(d.all)for(const b of hh)v(b,d.all);for(const b of hh)d[b]&&v(b,d[b]);return h}function y8(e,t,n="light"){if(!e?.props)return{};const o=e.props.style??{},r={},s=ph(e.props.className),a=t?.colors?.find(c=>c.token&&fh(s,n).includes(c.token))?.swatch??"",d=(n==="light"&&typeof o.backgroundColor=="string"?o.backgroundColor:"")||a;return d&&(r.backgroundColor=d),typeof o.padding=="string"&&o.padding&&(r.padding=o.padding),r}const b8=/^\s*\$t:(custom\.[a-zA-Z0-9._-]+)\s*$/,m8=/^\s*\$t:([a-zA-Z0-9._-]+)\s*$/,v8=/\{\{.*?\}\}/;function yh(e,t,n){const o=e?.text;if(typeof o!="string")return{display:"",editable:!1,isKey:!1};const r=mt(o);if(r&&r.key.startsWith("custom.")){let a="";try{const l=Pt.getInstance().translate(r.key,{templateId:t,locale:n});a=l&&l!==r.key?l:""}catch{a=""}return{display:a,editable:!0,isKey:!0}}if(v8.test(o))return{display:o,editable:!1,isKey:!1};const s=m8.exec(o);if(s){const a=s[1];let l="";try{const d=Pt.getInstance().translate(a,{templateId:t,locale:n});l=d&&d!==a?d:""}catch{l=""}return{display:l,editable:!0,isKey:!0}}return{display:o,editable:!0,isKey:!1}}function al(e){const t=Zo(e);return t===null?{node:null,path:null}:{node:mi(e,t),path:t}}function _8(e){if(!e||typeof e.text!="string")return null;const t=b8.exec(e.text);if(t)return t[1];const n=mt(e.text);return n&&n.key.startsWith("custom.")?n.key:null}function x8({node:e,params:t,t:n,onPatchNode:o,templateIdentifier:r,candidates:s}){const{state:a}=Je(),l=a.selectedRoute?.layoutName??null,d=a.locale,c=r??a.templateIdentifier,p=u.useMemo(()=>t&&typeof t=="object"?t:null,[t]);u.useMemo(()=>ht(p),[p]);const f=u.useMemo(()=>Be(e,p),[e,p]),y=u.useMemo(()=>{const q=p?.cellBorder;return q&&typeof q=="object"?q:null},[p]),g=u.useMemo(()=>{const q=p?.cellBackground;return q&&typeof q=="object"?q:null},[p]),m=u.useMemo(()=>{const q=p?.cellPadding;return q&&typeof q=="object"?q:null},[p]),[h,v]=u.useState(null),[b,x]=u.useState(null),[k,w]=u.useState({}),[_,S]=u.useState(!1),[E,C]=u.useState(null),[N,$]=u.useState(null),[A,z]=u.useState("light"),D=u.useCallback(q=>o(q),[o]),O=u.useMemo(()=>h?Ue(f,h.r,h.c):null,[f,h]),M=N!==null?N:h?.c??null,B=E!==null?E:h?.r??null,T=u.useMemo(()=>{const q=(y?.sides??[]).find($e=>$e.value==="all")??(y?.sides??[])[0],ae=(y?.widths??[]).find($e=>$e.value!=="none");if(!q||!ae)return[];const pe=ae.suffix??"";return[pe?`${q.prefix}${pe}`:q.prefix]},[y]),H=u.useCallback((q,ae,pe)=>{if(T.length===0)return q;const $e=(y?.sides??[]).map(J=>J.prefix),be=Ue(Be(q,p),ae,pe),oe=(typeof be?.cell?.props?.className=="string"?be.cell.props.className:"").split(/\s+/).filter(Boolean);return oe.some(J=>$e.some(le=>J===le||J.startsWith(le+"-")&&/^-?\d+$/.test(J.slice(le.length+1))))?q:ki(q,p,ae,pe,[...oe,...T].join(" ").trim())},[T,y,p]),L=u.useCallback(q=>{if(!h)return;const ae=Math.min(h.r,b?.r??h.r),pe=Math.max(h.r,b?.r??h.r),$e=Math.min(h.c,b?.c??h.c),be=Math.max(h.c,b?.c??h.c),j=new Set,oe=[];for(let J=ae;J<=pe;J++)for(let le=$e;le<=be;le++)j.add(`${J}:${le}`),oe.push({r:J,c:le});let ue=e;for(const J of oe){if(ue=H(ue,J.r,J.c),ue=Jt(ue,p,J.r,J.c,"borderColor",q),J.r>0&&!j.has(`${J.r-1}:${J.c}`)){const le=Ue(Be(ue,p),J.r-1,J.c);le&&(ue=Jt(ue,p,le.gridRow,le.gridCol,"borderBottomColor",q))}if(J.c>0&&!j.has(`${J.r}:${J.c-1}`)){const le=Ue(Be(ue,p),J.r,J.c-1);le&&(ue=Jt(ue,p,le.gridRow,le.gridCol,"borderRightColor",q))}}D(ue)},[h,b,e,p,D,H]),I=u.useCallback(q=>L(q),[L]),P=u.useMemo(()=>(y?.colors??[]).map(q=>q.token).filter(Boolean),[y]),F=u.useCallback(q=>{if(!h||P.length===0)return;const ae=Math.min(h.r,b?.r??h.r),pe=Math.max(h.r,b?.r??h.r),$e=Math.min(h.c,b?.c??h.c),be=Math.max(h.c,b?.c??h.c);let j=e;for(let oe=ae;oe<=pe;oe++)for(let ue=$e;ue<=be;ue++)j=H(j,oe,ue),j=po(j,p,oe,ue,q,P,A);D(j)},[h,b,e,p,D,H,P,A]),G=u.useMemo(()=>{if(A==="dark")return"";const q=O?.cell.props?.style;return typeof q?.borderColor=="string"?q.borderColor:""},[O,A]),V=u.useCallback((q,ae)=>{if(!h)return;const pe=Math.min(h.r,b?.r??h.r),$e=Math.max(h.r,b?.r??h.r),be=Math.min(h.c,b?.c??h.c),j=Math.max(h.c,b?.c??h.c);let oe=e;for(let ue=pe;ue<=$e;ue++)for(let J=be;J<=j;J++)oe=Jt(oe,p,ue,J,q,ae);D(oe)},[h,b,e,p,D]),Y=u.useMemo(()=>(g?.colors??[]).map(q=>q.token).filter(q=>!!q),[g]),Z=u.useCallback(()=>h?{r0:Math.min(h.r,b?.r??h.r),r1:Math.max(h.r,b?.r??h.r),c0:Math.min(h.c,b?.c??h.c),c1:Math.max(h.c,b?.c??h.c)}:null,[h,b]),se=u.useCallback(q=>{const ae=Z();if(!ae||Y.length===0)return;let pe=e;for(let $e=ae.r0;$e<=ae.r1;$e++)for(let be=ae.c0;be<=ae.c1;be++)pe=po(pe,p,$e,be,q,Y,A);D(pe)},[Z,e,p,D,Y,A]),re=u.useCallback(q=>V("backgroundColor",q),[V]),U=u.useCallback(()=>{const q=Z();if(!q)return;let ae=e;for(let pe=q.r0;pe<=q.r1;pe++)for(let $e=q.c0;$e<=q.c1;$e++)ae=po(ae,p,pe,$e,void 0,Y,A),A==="light"&&(ae=Jt(ae,p,pe,$e,"backgroundColor",void 0));D(ae)},[Z,e,p,D,Y,A]),ee=u.useCallback(q=>V("padding",q),[V]),fe=u.useMemo(()=>{if(A==="dark")return"";const q=O?.cell.props?.style;return typeof q?.backgroundColor=="string"?q.backgroundColor:""},[O,A]),K=u.useMemo(()=>typeof O?.cell.props?.className=="string"?O.cell.props.className:"",[O]),ne=u.useMemo(()=>{const q=O?.cell.props?.style;return typeof q?.padding=="string"?q.padding:""},[O]),me=u.useCallback(q=>q==="section"?n("layout_editor.table_editor.move_blocked_section"):"",[n]),Ce=u.useCallback((q,ae)=>vi(f,q,ae),[f]),ze=u.useCallback((q,ae)=>_i(f,q,ae),[f]),Se=u.useCallback(q=>D(el(e,p,q)),[e,p,D]),qe=u.useCallback(q=>{D(Gg(e,p,q)),v(null),x(null)},[e,p,D]),De=u.useCallback((q,ae)=>D(Yg(e,p,q,ae)),[e,p,D]),Pe=u.useCallback(q=>D(Jg(e,p,q)),[e,p,D]),Qe=u.useCallback(q=>{D(Zg(e,p,q)),v(null),x(null)},[e,p,D]),Me=u.useCallback((q,ae)=>D(Xg(e,p,q,ae)),[e,p,D]),Re=u.useCallback(()=>{if(!h||!b)return;const q=tl(e,p,h.r,h.c,b.r,b.c);q.ok&&(D(q.table),v({r:Math.min(h.r,b.r),c:Math.min(h.c,b.c)}),x(null))},[e,p,h,b,D]),ve=u.useCallback(()=>{h&&(D(Qg(e,p,h.r,h.c)),x(null))},[e,p,h,D]),te=u.useCallback(q=>{if(!h)return;const ae=Math.min(h.r,b?.r??h.r),pe=Math.max(h.r,b?.r??h.r),$e=Math.min(h.c,b?.c??h.c),be=Math.max(h.c,b?.c??h.c);let j=e;for(let oe=ae;oe<=pe;oe++)for(let ue=$e;ue<=be;ue++)j=ki(j,p,oe,ue,q);D(j)},[e,p,h,b,D]),he=u.useCallback(async(q,ae)=>{const pe=`${q}:${ae}`,$e=k[pe];if($e===void 0)return;const be=Ue(f,q,ae);if(!be)return;const{node:j,path:oe}=al(be.cell);if(oe===null)return;const ue=yh(j,c,d);if(ue.editable){if($e===ue.display){w(J=>{const le={...J};return delete le[pe],le});return}S(!0);try{const J=_8(j);if(J)await gr(c,J,d,$e),await en(c,d);else{const le=await Vn(c,l,d,$e);if(le.kind==="ok"&&le.resource){const Ie=`$t:${le.resource.translation_key}`;if(oe.length===0)D(Rg(e,p,q,ae,Ie));else{const dt=Qo(be.cell,oe,Ie);D(eh(e,p,q,ae,dt))}await en(c,d)}}}finally{S(!1),w(J=>{const le={...J};return delete le[pe],le})}}},[k,f,c,d,l,e,p,D]),Q=u.useMemo(()=>{if(!O)return null;const{node:q,path:ae}=al(O.cell);return ae===null?null:{value:typeof q?.text=="string"?q.text:"",editable:!0}},[O]),ie=u.useCallback(q=>{if(typeof q!="string"||q.length===0||!h)return;const ae=Ue(f,h.r,h.c);if(!ae)return;const pe=Zo(ae.cell);if(pe!==null)if(pe.length===0)D(Rg(e,p,h.r,h.c,q));else{const $e=Qo(ae.cell,pe,q);D(eh(e,p,h.r,h.c,$e))}},[h,f,e,p,D]),_e=u.useCallback((q,ae,pe)=>{pe?x({r:q,c:ae}):(v({r:q,c:ae}),x(null))},[]),ge=u.useMemo(()=>Array.from({length:f.colCount},(q,ae)=>ae),[f.colCount]),Ae=!!(h&&b&&(h.r!==b.r||h.c!==b.c)),Ne=!!(O&&(O.colSpan>1||O.rowSpan>1)),Le=O&&typeof O.cell.props?.className=="string"?O.cell.props.className:"";return i.jsxs("div",{className:"g7le-node-editor g7le-node-editor--table","data-testid":"g7le-table-editor",style:w8,children:[i.jsx("div",{style:k8,children:n("layout_editor.table_editor.title")}),i.jsx("div",{style:{overflowX:"auto"},children:i.jsxs("table",{"data-testid":"g7le-table-editor-grid",style:S8,children:[i.jsx("thead",{children:i.jsxs("tr",{children:[i.jsx("th",{style:C8}),ge.map(q=>{const ae=ze(q,-1),pe=ze(q,1),$e=M===q;return i.jsx("th",{style:$8,"data-testid":`g7le-table-colgutter-${q}`,onMouseEnter:()=>$(q),onMouseLeave:()=>$(be=>be===q?null:be),children:i.jsxs("div",{style:{...I8,visibility:$e?"visible":"hidden"},children:[i.jsx("button",{type:"button",title:ae.ok?n("layout_editor.table_editor.move_col_left"):me(ae.reason)||n("layout_editor.table_editor.move_col_left"),disabled:!ae.ok||_,onClick:()=>Me(q,-1),"data-testid":`g7le-table-col-left-${q}`,style:ae.ok?mn:Si,children:"←"}),i.jsx("button",{type:"button",title:n("layout_editor.table_editor.add_col"),disabled:_,onClick:()=>Pe(q),"data-testid":`g7le-table-col-add-${q}`,style:mn,children:"+"}),i.jsx("button",{type:"button",title:n("layout_editor.table_editor.remove_col"),disabled:f.colCount<=1||_,onClick:()=>Qe(q),"data-testid":`g7le-table-col-remove-${q}`,style:bh,children:"✕"}),i.jsx("button",{type:"button",title:pe.ok?n("layout_editor.table_editor.move_col_right"):me(pe.reason)||n("layout_editor.table_editor.move_col_right"),disabled:!pe.ok||_,onClick:()=>Me(q,1),"data-testid":`g7le-table-col-right-${q}`,style:pe.ok?mn:Si,children:"→"})]})},q)})]})}),i.jsx("tbody",{children:f.rows.map((q,ae)=>{const pe=q.cells[0]?.gridRow??ae,$e=B===pe;return i.jsxs("tr",{"data-testid":`g7le-table-row-${pe}`,onMouseEnter:()=>C(pe),onMouseLeave:()=>C(be=>be===pe?null:be),children:[i.jsx("td",{style:E8,"data-testid":`g7le-table-rowgutter-${pe}`,children:(()=>{const be=Ce(pe,-1),j=Ce(pe,1);return i.jsxs("div",{style:{...A8,visibility:$e?"visible":"hidden"},children:[i.jsx("button",{type:"button",title:be.ok?n("layout_editor.table_editor.move_row_up"):me(be.reason)||n("layout_editor.table_editor.move_row_up"),disabled:!be.ok||_,onClick:()=>De(pe,-1),"data-testid":`g7le-table-row-up-${pe}`,style:be.ok?mn:Si,children:"↑"}),i.jsx("button",{type:"button",title:n("layout_editor.table_editor.add_row"),disabled:_,onClick:()=>Se(pe),"data-testid":`g7le-table-row-add-${pe}`,style:mn,children:"+"}),i.jsx("button",{type:"button",title:n("layout_editor.table_editor.remove_row"),disabled:f.rows.length<=1||_,onClick:()=>qe(pe),"data-testid":`g7le-table-row-remove-${pe}`,style:bh,children:"✕"}),i.jsx("button",{type:"button",title:j.ok?n("layout_editor.table_editor.move_row_down"):me(j.reason)||n("layout_editor.table_editor.move_row_down"),disabled:!j.ok||_,onClick:()=>De(pe,1),"data-testid":`g7le-table-row-down-${pe}`,style:j.ok?mn:Si,children:"↓"})]})})()}),q.cells.map(be=>{const j=`${be.gridRow}:${be.gridCol}`,{node:oe,path:ue}=al(be.cell),J=yh(oe,c,d),le=ue!==null&&J.editable,Ie=ue===null,dt=k[j],pt=dt!==void 0?dt:J.display,ot=h&&h.r===be.gridRow&&h.c===be.gridCol,Oe=h&&b&&be.gridRow>=Math.min(h.r,b.r)&&be.gridRow<=Math.max(h.r,b.r)&&be.gridCol>=Math.min(h.c,b.c)&&be.gridCol<=Math.max(h.c,b.c);return i.jsx("td",{colSpan:be.colSpan,rowSpan:be.rowSpan,"data-testid":`g7le-table-cell-${be.gridRow}-${be.gridCol}`,onClick:He=>_e(be.gridRow,be.gridCol,He.shiftKey),style:{...T8,...h8(be.cell,y,A),...y8(be.cell,g,A),...be.isHeader?D8:{},...ot?N8:{},...Oe&&!ot?z8:{}},children:le?i.jsx("input",{type:"text","data-testid":`g7le-table-cell-input-${be.gridRow}-${be.gridCol}`,value:pt,placeholder:n("layout_editor.table_editor.cell_text"),disabled:_,onMouseDown:He=>{He.shiftKey&&He.preventDefault(),_e(be.gridRow,be.gridCol,He.shiftKey)},onClick:He=>He.stopPropagation(),onChange:He=>w(Ye=>({...Ye,[j]:He.target.value})),onBlur:()=>{he(be.gridRow,be.gridCol)},style:M8}):Ie?i.jsx("span",{"data-testid":`g7le-table-cell-struct-${be.gridRow}-${be.gridCol}`,style:mh,children:n("layout_editor.table_editor.structural_cell")}):i.jsx("span",{style:mh,children:J.display})},j)})]},ae)})})]})}),i.jsxs("button",{type:"button","data-testid":"g7le-table-add-row-bottom",onClick:()=>Se(f.rows.length-1),disabled:_,style:L8,children:["+ ",n("layout_editor.table_editor.add_row")]}),i.jsxs("div",{style:P8,"data-testid":"g7le-table-cell-tools",children:[i.jsx("button",{type:"button","data-testid":"g7le-table-merge",onClick:Re,disabled:!Ae||_,style:vh,children:n("layout_editor.table_editor.merge_cells")}),i.jsx("button",{type:"button","data-testid":"g7le-table-unmerge",onClick:ve,disabled:!Ne||_,style:vh,children:n("layout_editor.table_editor.unmerge_cells")})]}),O&&Q&&i.jsxs("div",{style:B8,"data-testid":"g7le-table-cell-i18n-section",children:[i.jsx("div",{style:W8,children:n("layout_editor.table_editor.cell_i18n_label")}),i.jsx(vt,{value:Q.value,onChange:ie,t:n,placeholder:n("layout_editor.table_editor.cell_text"),testidPrefix:"g7le-table-cell-i18n",candidates:s,enableExpressionTree:!0,expressionTreeCollapsible:!0})]}),O&&i.jsxs("div",{style:O8,"data-testid":"g7le-table-cell-color-section",children:[i.jsx(ch,{colorScheme:A,onChange:z,t:n,disabled:_}),y&&i.jsx("div",{style:ll,"data-testid":"g7le-table-cell-border-row",children:i.jsx(oh,{className:Le,catalog:y,t:n,onChange:te,colorScheme:A,onPresetToken:F,colorStyle:G,onColorStyle:I,disabled:_})}),i.jsx("div",{style:ll,"data-testid":"g7le-table-cell-fill-row",children:i.jsx(ah,{colorStyle:fe,className:K,catalog:g,t:n,colorScheme:A,onPresetToken:se,onCustomColor:re,onClear:U,disabled:_})})]}),O&&i.jsx("div",{style:ll,"data-testid":"g7le-table-cell-padding-row",children:i.jsx(lh,{paddingStyle:ne,catalog:m,t:n,onChange:ee,disabled:_})}),O&&!y&&i.jsxs("label",{style:j8,"data-testid":"g7le-table-cell-border-row",children:[i.jsx("span",{style:H8,children:n("layout_editor.table_editor.cell_border")}),i.jsx("input",{type:"text","data-testid":"g7le-table-cell-border-input",value:Le,placeholder:n("layout_editor.table_editor.cell_class_placeholder"),onChange:q=>te(q.target.value),disabled:_,style:F8})]}),!f.valid&&i.jsx("div",{"data-testid":"g7le-table-grid-warning",style:K8,children:n("layout_editor.table_editor.grid_invalid")}),i.jsx("div",{style:V8,children:n("layout_editor.table_editor.select_hint")})]})}const w8={display:"flex",flexDirection:"column",gap:8,width:"100%",marginBottom:12},k8={fontSize:12,fontWeight:700,color:"#0f172a"},S8={borderCollapse:"collapse",width:"auto"},C8={width:28,background:"transparent",border:"none"},$8={padding:2,border:"none",background:"transparent"},E8={padding:2,border:"none",background:"transparent",verticalAlign:"middle"},I8={display:"flex",gap:2,justifyContent:"center"},A8={display:"grid",gridTemplateColumns:"repeat(2, 16px)",gap:2,justifyContent:"center"},mn={width:16,height:16,lineHeight:"14px",padding:0,fontSize:10,border:"1px solid #cbd5e1",borderRadius:4,background:"#fff",color:"#475569",cursor:"pointer"},bh={...mn,border:"1px solid #fecaca",color:"#dc2626"},Si={...mn,cursor:"not-allowed",opacity:.4,color:"#94a3b8",borderColor:"#e2e8f0"},T8={border:"1px solid #cbd5e1",padding:0,width:64,minWidth:64,maxWidth:120,height:24,background:"#fff",cursor:"pointer"},D8={background:"#f1f5f9",fontWeight:600},N8={outline:"2px solid #2563eb",outlineOffset:-2},z8={background:"#dbeafe"},M8={width:"100%",boxSizing:"border-box",padding:"2px 5px",fontSize:11,height:22,lineHeight:"18px",border:"none",background:"transparent",outline:"none"},mh={display:"block",padding:"2px 5px",fontSize:10,color:"#64748b",fontStyle:"italic",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap",maxWidth:110},L8={padding:"4px 8px",fontSize:12,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",alignSelf:"flex-start"},P8={display:"flex",gap:6},vh={padding:"4px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},O8={display:"flex",flexDirection:"column",gap:8,borderTop:"1px solid #f1f5f9",paddingTop:8},B8={display:"flex",flexDirection:"column",gap:6,borderTop:"1px solid #f1f5f9",paddingTop:8},W8={fontSize:12,fontWeight:700,color:"#0f172a"},ll={display:"flex",flexDirection:"column",gap:6},j8={display:"flex",alignItems:"center",gap:6},H8={fontSize:12,color:"#475569",minWidth:60},F8={flex:1,minWidth:0,padding:"4px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6},K8={fontSize:11,color:"#b45309",background:"#fffbeb",border:"1px solid #fde68a",borderRadius:6,padding:"4px 6px"},V8={fontSize:11,color:"#94a3b8"},U8=/\{\{.*?\}\}/;function q8(e,t,n){const r=(e.props??{})[t];return Array.isArray(r)?r:r===void 0&&n?JSON.parse(JSON.stringify(n)):[]}function G8(e){return typeof e=="string"&&U8.test(e)}function dl(e,t,n){return e?e.startsWith("$t:")?t(e.slice(3)):e:n}function Y8(e,t,n){return typeof e=="object"&&e!==null?e[t.key]:n?e:void 0}function X8(e,t,n,o){if(typeof e=="object"&&e!==null){const r={...e};return o===void 0?delete r[t.key]:r[t.key]=o,r}return n?o:{[t.key]:o}}function _h({node:e,params:t,t:n,onPatchNode:o,templateIdentifier:r,candidates:s}){const a=typeof t?.arrayProp=="string"?t.arrayProp:null,l=Array.isArray(t?.fields)?t.fields:[],d=dl(typeof t?.itemLabel=="string"?t.itemLabel:void 0,n,n("layout_editor.array_editor.item")),c=t?.newItem,p=c&&typeof c=="object"?c:null,f=typeof c=="string"||typeof c=="number"?c:null,y=(l.find(S=>S.primary)??l[0])?.key,g=(e.props??{})[a??""],m=Array.isArray(t?.defaultItems)?t.defaultItems:null,h=a?q8(e,a,m):[],v=u.useCallback(S=>{if(!a)return;const E={...e.props??{}};E[a]=S,o({...e,props:E})},[a,e,o]),b=u.useCallback(()=>{if(p)return JSON.parse(JSON.stringify(p));if(f!==null)return f;const S={};for(const E of l)(E.widget??"text")==="boolean"?S[E.key]=!1:S[E.key]="";return S},[p,f,l]),x=u.useCallback(()=>{v([...h,b()])},[v,h,b]),k=u.useCallback(S=>{v(h.filter((E,C)=>C!==S))},[v,h]),w=u.useCallback((S,E)=>{const C=S+E;if(C<0||C>=h.length)return;const N=[...h];[N[S],N[C]]=[N[C],N[S]],v(N)},[v,h]),_=u.useCallback((S,E,C)=>{const N=E.key===y,$=h.map((A,z)=>z===S?X8(A,E,N,C):A);v($)},[h,v,y]);return a?G8(g)||g!==void 0&&!Array.isArray(g)?i.jsx("div",{className:"g7le-node-editor g7le-node-editor--array","data-testid":"g7le-array-editor-bound",style:rz,children:n("layout_editor.array_editor.bound_degraded")}):i.jsxs("div",{className:"g7le-node-editor g7le-node-editor--array","data-testid":"g7le-array-editor",style:J8,children:[i.jsx("div",{style:Z8,children:n("layout_editor.array_editor.items_title")}),h.length===0&&i.jsx("div",{"data-testid":"g7le-array-empty",style:wh,children:n("layout_editor.array_editor.empty")}),h.map((S,E)=>i.jsxs("div",{"data-testid":`g7le-array-row-${E}`,style:Q8,children:[i.jsxs("div",{style:R8,children:[i.jsxs("span",{style:ez,children:["#",E+1]}),i.jsx("div",{style:{flex:1}}),i.jsx("button",{type:"button","data-testid":`g7le-array-up-${E}`,title:n("layout_editor.array_editor.move_up"),disabled:E===0,onClick:()=>w(E,-1),style:xh,children:"↑"}),i.jsx("button",{type:"button","data-testid":`g7le-array-down-${E}`,title:n("layout_editor.array_editor.move_down"),disabled:E===h.length-1,onClick:()=>w(E,1),style:xh,children:"↓"}),i.jsx("button",{type:"button","data-testid":`g7le-array-remove-${E}`,title:n("layout_editor.array_editor.remove"),onClick:()=>k(E),style:nz,children:"✕"})]}),l.map(C=>{const N=C.widget??"text",$=C.key===y,A=dl(C.label,n,C.key),z=Y8(S,C,$),D=`g7le-array-field-${E}-${C.key}`;if(N==="boolean")return i.jsxs("label",{style:Bn,children:[i.jsx("span",{style:Wn,children:A}),i.jsx("input",{type:"checkbox","data-testid":D,checked:z===!0,onChange:M=>_(E,C,M.target.checked)})]},C.key);if(N==="select"){const M=Array.isArray(C.options)?C.options:[];return i.jsxs("label",{style:Bn,children:[i.jsx("span",{style:Wn,children:A}),i.jsxs("select",{"data-testid":D,value:z==null?"":String(z),onChange:B=>_(E,C,B.target.value===""?void 0:B.target.value),style:er,children:[i.jsx("option",{value:"",children:"—"}),M.map((B,T)=>i.jsx("option",{value:String(B.value),children:dl(B.label,n,String(B.value))},T))]})]},C.key)}if(N==="i18n-text"){const M=typeof z=="string"?z:z==null?"":String(z);return i.jsxs("label",{style:Bn,children:[i.jsx("span",{style:Wn,children:A}),i.jsx("div",{"data-testid":D,style:{flex:1,minWidth:0},children:i.jsx(vt,{value:M,onChange:B=>_(E,C,B===""?void 0:B),t:n,placeholder:A,testidPrefix:`${D}-i18n`,candidates:s,enableExpressionTree:!0,expressionTreeCollapsible:!0})})]},C.key)}if(N==="number"){const M=z==null?"":String(z);return i.jsxs("label",{style:Bn,children:[i.jsx("span",{style:Wn,children:A}),i.jsx("input",{type:"number","data-testid":D,value:M,onChange:B=>{const T=B.target.value;_(E,C,T===""?void 0:Number(T))},style:er})]},C.key)}if(N==="color"){const M=typeof z=="string"?z:"",B=/^#[0-9a-fA-F]{6}$/.test(M)?M:"#000000";return i.jsxs("label",{style:Bn,children:[i.jsx("span",{style:Wn,children:A}),i.jsx("input",{type:"color","data-testid":`${D}-swatch`,value:B,onChange:T=>_(E,C,T.target.value),style:tz}),i.jsx("input",{type:"text","data-testid":D,value:M,placeholder:"#7C3AED",onChange:T=>_(E,C,T.target.value===""?void 0:T.target.value),style:er})]},C.key)}if(N==="number-list"){const M=Array.isArray(z)?z.join(", "):"";return i.jsxs("label",{style:Bn,children:[i.jsx("span",{style:Wn,children:A}),i.jsx("input",{type:"text","data-testid":D,value:M,placeholder:n("layout_editor.array_editor.number_list_placeholder"),onChange:B=>{const T=B.target.value.split(",").map(H=>H.trim()).filter(H=>H!=="").map(H=>Number(H)).filter(H=>!Number.isNaN(H));_(E,C,T)},style:er})]},C.key)}const O=z==null?"":String(z);return i.jsxs("label",{style:Bn,children:[i.jsx("span",{style:Wn,children:A}),i.jsx("input",{type:"text","data-testid":D,value:O,placeholder:N==="icon"?n("layout_editor.array_editor.icon_placeholder"):A,onChange:M=>_(E,C,M.target.value===""?void 0:M.target.value),style:er})]},C.key)})]},E)),i.jsx("button",{type:"button","data-testid":"g7le-array-add",onClick:x,style:oz,children:n("layout_editor.array_editor.add",{item:d})})]}):i.jsx("div",{className:"g7le-node-editor g7le-node-editor--array","data-testid":"g7le-array-editor-misconfigured",style:wh,children:n("layout_editor.array_editor.no_array_prop")})}const J8={display:"flex",flexDirection:"column",gap:8,width:"100%",marginBottom:12},Z8={fontSize:12,fontWeight:700,color:"#0f172a",marginBottom:2},Q8={display:"flex",flexDirection:"column",gap:4,padding:8,border:"1px solid #e2e8f0",borderRadius:8,background:"#f8fafc"},R8={display:"flex",gap:4,alignItems:"center"},ez={fontSize:11,fontWeight:700,color:"#64748b"},Bn={display:"flex",flexWrap:"wrap",gap:6,alignItems:"center"},Wn={fontSize:11,color:"#475569",minWidth:64,flexShrink:0},er={flex:1,minWidth:0,padding:"4px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6},tz={width:32,height:28,padding:0,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",cursor:"pointer",flexShrink:0},xh={padding:"2px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},nz={padding:"2px 6px",fontSize:12,border:"1px solid #fecaca",borderRadius:6,background:"#fff",color:"#dc2626",cursor:"pointer"},oz={padding:"4px 8px",fontSize:12,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",alignSelf:"flex-start"},wh={fontSize:11,color:"#94a3b8",fontStyle:"italic"},rz={fontSize:11,color:"#b45309",fontStyle:"italic",padding:"4px 2px"};function iz(e,t){return e?e.startsWith("$t:")?t(e.slice(3)):e:null}function sz(e){const{params:t,t:n}=e,o=Array.isArray(t?.groups)?t.groups:[];return o.length===0?i.jsx("div",{className:"g7le-node-editor g7le-node-editor--array-group","data-testid":"g7le-array-group-misconfigured",style:cz,children:n("layout_editor.array_editor.no_array_prop")}):i.jsx("div",{className:"g7le-node-editor g7le-node-editor--array-group","data-testid":"g7le-array-group-editor",style:az,children:o.map((r,s)=>{const a=iz(r.title,n),{title:l,...d}=r;return i.jsxs("div",{"data-testid":`g7le-array-group-${r.arrayProp??s}`,style:lz,children:[a&&i.jsx("div",{style:dz,children:a}),i.jsx(_h,{...e,params:d})]},r.arrayProp??s)})})}const az={display:"flex",flexDirection:"column",gap:16,width:"100%",marginBottom:12},lz={display:"flex",flexDirection:"column",gap:4},dz={fontSize:12,fontWeight:700,color:"#1e293b",borderBottom:"1px solid #e2e8f0",paddingBottom:4},cz={fontSize:11,color:"#94a3b8",fontStyle:"italic"},uz=/\{\{.*?\}\}/;function pz(e){return typeof e=="string"&&uz.test(e)}function fz(e,t,n){return e?e.startsWith("$t:")?t(e.slice(3)):e:n}function gz(e){const{node:t,params:n,t:o,onPatchNode:r}=e,s=typeof n?.arrayProp=="string"?n.arrayProp:null,a=typeof n?.idField=="string"?n.idField:"id",l=typeof n?.cellChildrenProp=="string"?n.cellChildrenProp:"cellChildren",d=typeof n?.cellChildComponent=="string"?n.cellChildComponent:"Div",c=fz(typeof n?.itemLabel=="string"?n.itemLabel:void 0,o,o("layout_editor.array_editor.item")),[p,f]=u.useState(null),y=s?(t.props??{})[s]:void 0,g=Array.isArray(y)?y:[],m=u.useCallback(S=>{if(!s)return;const E={...t.props??{}};E[s]=S,r({...t,props:E})},[s,t,r]),h=u.useCallback(()=>{const S=n?.newItem;return S&&typeof S=="object"?JSON.parse(JSON.stringify(S)):{[a]:"",[l]:[]}},[n,a,l]),v=u.useCallback(()=>{m([...g,h()])},[m,g,h]),b=u.useCallback(S=>{m(g.filter((E,C)=>C!==S)),f(null)},[m,g]),x=u.useCallback((S,E)=>{const C=S+E;if(C<0||C>=g.length)return;const N=[...g];[N[S],N[C]]=[N[C],N[S]],m(N)},[m,g]),k=u.useCallback((S,E)=>{const C=g.map((N,$)=>$===S?{...N,[a]:E}:N);m(C)},[g,m,a]),w=u.useCallback(S=>({type:"composite",name:"CardGridCell",children:Array.isArray(S[l])?S[l]:[]}),[l]),_=u.useCallback(S=>E=>{const C=Array.isArray(E.children)?E.children:[],N=g.map(($,A)=>A===S?{...$,[l]:C}:$);m(N)},[g,m,l]);return s?pz(y)||y!==void 0&&!Array.isArray(y)?i.jsx("div",{className:"g7le-node-editor g7le-node-editor--cell-tree","data-testid":"g7le-cell-tree-bound",style:Cz,children:o("layout_editor.array_editor.bound_degraded")}):i.jsxs("div",{className:"g7le-node-editor g7le-node-editor--cell-tree","data-testid":"g7le-cell-tree-editor",style:hz,children:[i.jsx("div",{style:yz,children:o("layout_editor.cell_tree_editor.columns_title")}),g.length===0&&i.jsx("div",{"data-testid":"g7le-cell-tree-empty",style:Sh,children:o("layout_editor.array_editor.empty")}),g.map((S,E)=>{const C=typeof S[a]=="string"?S[a]:"",N=p===E;return i.jsxs("div",{"data-testid":`g7le-cell-tree-row-${E}`,style:bz,children:[i.jsxs("div",{style:mz,children:[i.jsxs("span",{style:vz,children:["#",E+1]}),i.jsx("input",{type:"text","data-testid":`g7le-cell-tree-id-${E}`,value:C,placeholder:o("layout_editor.cell_tree_editor.id_placeholder"),onChange:$=>k(E,$.target.value),style:_z}),i.jsx("button",{type:"button","data-testid":`g7le-cell-tree-up-${E}`,title:o("layout_editor.array_editor.move_up"),disabled:E===0,onClick:()=>x(E,-1),style:kh,children:"↑"}),i.jsx("button",{type:"button","data-testid":`g7le-cell-tree-down-${E}`,title:o("layout_editor.array_editor.move_down"),disabled:E===g.length-1,onClick:()=>x(E,1),style:kh,children:"↓"}),i.jsx("button",{type:"button","data-testid":`g7le-cell-tree-remove-${E}`,title:o("layout_editor.array_editor.remove"),onClick:()=>b(E),style:xz,children:"✕"})]}),i.jsx("button",{type:"button","data-testid":`g7le-cell-tree-toggle-${E}`,onClick:()=>f(N?null:E),style:wz,children:o(N?"layout_editor.cell_tree_editor.collapse_cells":"layout_editor.cell_tree_editor.expand_cells")}),N&&i.jsx("div",{"data-testid":`g7le-cell-tree-cells-${E}`,style:kz,children:i.jsx(jg,{...e,node:w(S),params:{childComponent:d},onPatchNode:_(E)})})]},E)}),i.jsx("button",{type:"button","data-testid":"g7le-cell-tree-add",onClick:v,style:Sz,children:o("layout_editor.cell_tree_editor.add_column",{item:c})})]}):i.jsx("div",{className:"g7le-node-editor g7le-node-editor--cell-tree","data-testid":"g7le-cell-tree-misconfigured",style:Sh,children:o("layout_editor.array_editor.no_array_prop")})}const hz={display:"flex",flexDirection:"column",gap:8,width:"100%",marginBottom:12},yz={fontSize:12,fontWeight:700,color:"#0f172a",marginBottom:2},bz={display:"flex",flexDirection:"column",gap:6,padding:8,border:"1px solid #e2e8f0",borderRadius:8,background:"#f8fafc"},mz={display:"flex",gap:4,alignItems:"center"},vz={fontSize:11,fontWeight:700,color:"#64748b",flexShrink:0},_z={flex:1,minWidth:0,padding:"4px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6},kh={padding:"2px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},xz={padding:"2px 6px",fontSize:12,border:"1px solid #fecaca",borderRadius:6,background:"#fff",color:"#dc2626",cursor:"pointer"},wz={padding:"3px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#2563eb",cursor:"pointer",alignSelf:"flex-start"},kz={paddingLeft:8,borderLeft:"2px solid #e2e8f0"},Sz={padding:"4px 8px",fontSize:12,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",alignSelf:"flex-start"},Sh={fontSize:11,color:"#94a3b8",fontStyle:"italic"},Cz={fontSize:11,color:"#b45309",fontStyle:"italic",padding:"4px 2px"};function $z(e,t,n){const o=Be(e,t);if(!n||n.length===0)return{measured:[],grid:o};const r=[];for(const s of o.rows)for(const a of s.cells){const d=Ra(e,a,t).map(p=>`children.${p}`).join("."),c=n.find(p=>p.path===d||p.path.endsWith("."+d));c&&r.push({ref:a,box:{top:c.top,left:c.left,width:c.width,height:c.height}})}return{measured:r,grid:o}}const cl=26,ul=50,pl=6,Ch=44,$h=18,Eh="g7le-table-inplace-empty-cell-style";function Ez(){if(typeof document>"u"||document.getElementById(Eh))return;const e=document.createElement("style");e.id=Eh,e.textContent=".g7le-preview-frame td[data-editor-name],.g7le-preview-frame th[data-editor-name]{min-width:56px;height:38px;}.g7le-preview-frame table[data-editor-name]{min-width:120px;}",document.head.appendChild(e)}function Iz({node:e,params:t,nodeBox:n,cellBoxes:o,selectedCellPath:r,colorScheme:s,t:a,onPatchNode:l,onRequestInlineEdit:d}){const c=u.useMemo(()=>t&&typeof t=="object"?t:null,[t]),{measured:p,grid:f}=u.useMemo(()=>$z(e,c,o),[e,c,o]),[y,g]=u.useState(null),[m,h]=u.useState(null),v=u.useMemo(()=>{if(!r)return null;for(const j of f.rows)for(const oe of j.cells){const ue=Ra(e,oe,c).map(J=>`children.${J}`).join(".");if(r===ue||r.startsWith(ue+"."))return{r:oe.gridRow,c:oe.gridCol}}return null},[r,f,e,c]),b=u.useMemo(()=>y&&Ue(f,y.r,y.c)?y:v,[y,v,f]),[x,k]=u.useState(!1),[w,_]=u.useState(s==="dark"?"dark":"light"),[S,E]=u.useState(null),[C,N]=u.useState(null),$=u.useRef(null),A=u.useCallback(()=>{$.current!==null&&(clearTimeout($.current),$.current=null)},[]),z=u.useCallback(()=>{A(),$.current=setTimeout(()=>{E(null),N(null),$.current=null},240)},[A]);u.useEffect(()=>()=>A(),[A]),u.useEffect(()=>{Ez()},[]);const D=u.useCallback(j=>l(j),[l]),O=u.useMemo(()=>b?Ue(f,b.r,b.c):null,[f,b]),M=u.useMemo(()=>O&&typeof O.cell.props?.className=="string"?O.cell.props.className:"",[O]),B=u.useCallback(j=>D(el(e,c,j)),[e,c,D]),T=u.useCallback(j=>D(Gg(e,c,j)),[e,c,D]),H=u.useCallback((j,oe)=>D(Yg(e,c,j,oe)),[e,c,D]),L=u.useCallback(j=>D(Jg(e,c,j)),[e,c,D]),I=u.useCallback(j=>D(Zg(e,c,j)),[e,c,D]),P=u.useCallback((j,oe)=>D(Xg(e,c,j,oe)),[e,c,D]),F=u.useCallback(j=>{if(!O)return;const oe=O.gridRow,ue=O.gridCol,J=j==="down"?oe+O.rowSpan:oe+O.rowSpan-1,le=j==="right"?ue+O.colSpan:ue+O.colSpan-1;if(J>=f.rows.length||le>=f.colCount)return;const Ie=tl(e,c,oe,ue,J,le);Ie.ok&&D(Ie.table)},[O,f,e,c,D]),G=!!(O&&O.gridCol+O.colSpan!b||!m||!Ue(f,m.r,m.c)?null:{r0:Math.min(b.r,m.r),c0:Math.min(b.c,m.c),r1:Math.max(b.r,m.r),c1:Math.max(b.c,m.c)},[b,m,f]),Z=u.useMemo(()=>{if(!Y)return[];const j=new Set,oe=[];for(const ue of f.rows)for(const J of ue.cells)if(J.gridRow>=Y.r0&&J.gridRow<=Y.r1&&J.gridCol>=Y.c0&&J.gridCol<=Y.c1){const le=`${J.gridRow}:${J.gridCol}`;j.has(le)||(j.add(le),oe.push({r:J.gridRow,c:J.gridCol}))}return oe},[Y,f]),se=Z.length>1,re=u.useCallback(()=>{if(!Y)return;const j=tl(e,c,Y.r0,Y.c0,Y.r1,Y.c1);j.ok&&(D(j.table),h(null))},[Y,e,c,D]),U=u.useCallback(()=>{b&&D(Qg(e,c,b.r,b.c))},[e,c,b,D]),ee=u.useMemo(()=>{const j=c?.cellBorder;return j&&typeof j=="object"?j:null},[c]),fe=u.useMemo(()=>{const j=c?.cellBackground;return j&&typeof j=="object"?j:null},[c]),K=u.useMemo(()=>{const j=c?.cellPadding;return j&&typeof j=="object"?j:null},[c]),ne=u.useMemo(()=>{if(x){const j=new Set,oe=[];for(const ue of f.rows)for(const J of ue.cells){const le=`${J.gridRow}:${J.gridCol}`;j.has(le)||(j.add(le),oe.push({r:J.gridRow,c:J.gridCol}))}return oe}return se?Z:b?[{r:b.r,c:b.c}]:[]},[x,se,Z,b,f]),me=u.useCallback(j=>{if(ne.length===0)return;let oe=e;for(const ue of ne){const J=(ee?.colors??[]).map(Ie=>Ie.token),le=j.split(/\s+/).filter(Ie=>Ie&&!J.includes(Ie)).join(" ");oe=ki(oe,c,ue.r,ue.c,le)}D(oe)},[e,c,ne,ee,D]),Ce=u.useMemo(()=>{const j=(ee?.sides??[]).find(J=>J.value==="all")??(ee?.sides??[])[0],oe=(ee?.widths??[]).find(J=>J.value!=="none");if(!j||!oe)return[];const ue=oe.suffix??"";return[ue?`${j.prefix}${ue}`:j.prefix]},[ee]),ze=u.useCallback((j,oe,ue)=>{if(Ce.length===0)return j;const J=(ee?.sides??[]).map(Oe=>Oe.prefix),le=Ue(Be(j,c),oe,ue),dt=(typeof le?.cell?.props?.className=="string"?le.cell.props.className:"").split(/\s+/).filter(Boolean);if(dt.some(Oe=>J.some(He=>Oe===He||Oe.startsWith(He+"-")&&/^-?\d+$/.test(Oe.slice(He.length+1)))))return j;const ot=[...dt,...Ce].join(" ").trim();return ki(j,c,oe,ue,ot)},[Ce,ee,c]),Se=u.useCallback((j,oe)=>{if(j.length===0)return;const ue=new Set(j.map(le=>`${le.r}:${le.c}`));let J=e;for(const le of j){if(J=ze(J,le.r,le.c),J=Jt(J,c,le.r,le.c,"borderColor",oe),le.r>0&&!ue.has(`${le.r-1}:${le.c}`)){const Ie=Ue(Be(J,c),le.r-1,le.c);Ie&&(J=Jt(J,c,Ie.gridRow,Ie.gridCol,"borderBottomColor",oe))}if(le.c>0&&!ue.has(`${le.r}:${le.c-1}`)){const Ie=Ue(Be(J,c),le.r,le.c-1);Ie&&(J=Jt(J,c,Ie.gridRow,Ie.gridCol,"borderRightColor",oe))}}D(J)},[e,c,D,ze]),qe=u.useMemo(()=>(ee?.colors??[]).map(j=>j.token).filter(Boolean),[ee]),De=u.useCallback(j=>{if(ne.length===0||qe.length===0)return;let oe=e;for(const ue of ne)oe=ze(oe,ue.r,ue.c),oe=po(oe,c,ue.r,ue.c,j,qe,w);D(oe)},[ne,e,c,D,ze,qe,w]),Pe=u.useCallback(j=>Se(ne,j),[Se,ne]),Qe=u.useMemo(()=>{if(w==="dark")return"";const j=O?.cell.props?.style;return typeof j?.borderColor=="string"?j.borderColor:""},[O,w]),Me=u.useCallback((j,oe)=>{if(ne.length===0)return;let ue=e;for(const J of ne)ue=Jt(ue,c,J.r,J.c,j,oe);D(ue)},[ne,e,c,D]),Re=u.useMemo(()=>(fe?.colors??[]).map(j=>j.token).filter(j=>!!j),[fe]),ve=u.useCallback(j=>{if(ne.length===0||Re.length===0)return;let oe=e;for(const ue of ne)oe=po(oe,c,ue.r,ue.c,j,Re,w);D(oe)},[ne,e,c,D,Re,w]),te=u.useCallback(j=>Me("backgroundColor",j),[Me]),he=u.useCallback(()=>{if(ne.length===0)return;let j=e;for(const oe of ne)j=po(j,c,oe.r,oe.c,void 0,Re,w),w==="light"&&(j=Jt(j,c,oe.r,oe.c,"backgroundColor",void 0));D(j)},[ne,e,c,D,Re,w]),Q=u.useCallback(j=>Me("padding",j),[Me]),ie=u.useMemo(()=>{if(w==="dark")return"";const j=O?.cell.props?.style;return typeof j?.backgroundColor=="string"?j.backgroundColor:""},[O,w]),_e=u.useMemo(()=>typeof O?.cell.props?.className=="string"?O.cell.props.className:"",[O]),ge=u.useMemo(()=>{const j=O?.cell.props?.style;return typeof j?.padding=="string"?j.padding:""},[O]),Ae=u.useCallback(j=>{const oe=j.currentTarget,ue=oe.style.pointerEvents;oe.style.pointerEvents="none";const J=document.elementFromPoint(j.clientX,j.clientY);oe.style.pointerEvents=ue;const le=J?.closest?.("[data-dnd-handle-path]");le&&le.dispatchEvent(new PointerEvent("pointerdown",{bubbles:!0,cancelable:!0,clientX:j.clientX,clientY:j.clientY,pointerId:j.pointerId,pointerType:j.pointerType,button:j.button,isPrimary:!0}))},[]),Ne=u.useMemo(()=>{const j=new Map;for(const oe of p)j.has(oe.ref.gridCol)||j.set(oe.ref.gridCol,oe.box);return j},[p]),Le=u.useMemo(()=>{const j=new Map;for(const oe of p)j.has(oe.ref.gridRow)||j.set(oe.ref.gridRow,oe.box);return j},[p]);if(p.length===0)return null;const q=!!(O&&(O.colSpan>1||O.rowSpan>1)),ae=C!==null?C:b?.c??null,pe=S!==null?S:b?.r??null,$e=n.top-cl-pl-Ch,be=n.left-ul-pl-Ch;return i.jsxs("div",{className:"g7le-table-inplace","data-testid":"g7le-table-inplace",style:{position:"absolute",inset:0,pointerEvents:"none",zIndex:i7},children:[p.map(j=>{const oe=b&&b.r===j.ref.gridRow&&b.c===j.ref.gridCol,ue=Y&&j.ref.gridRow>=Y.r0&&j.ref.gridRow<=Y.r1&&j.ref.gridCol>=Y.c0&&j.ref.gridCol<=Y.c1;return i.jsx("div",{"data-testid":`g7le-inplace-cell-${j.ref.gridRow}-${j.ref.gridCol}`,role:"button",tabIndex:-1,"aria-label":a("layout_editor.table_inplace.select_cell"),onMouseEnter:()=>{A(),E(j.ref.gridRow),N(j.ref.gridCol)},onMouseLeave:()=>{z()},onPointerDown:J=>Ae(J),onClick:J=>{if(J.stopPropagation(),J.shiftKey&&(y||b))h({r:j.ref.gridRow,c:j.ref.gridCol});else{g({r:j.ref.gridRow,c:j.ref.gridCol}),h(null);const le=Ra(e,j.ref,c).map(Ie=>`children.${Ie}`).join(".");d?.(le)}},style:{position:"absolute",top:j.box.top,left:j.box.left,width:j.box.width,height:j.box.height,minWidth:$h,minHeight:$h,boxSizing:"border-box",pointerEvents:"auto",cursor:"pointer",outline:oe?"2px solid #2563eb":"none",outlineOffset:-2,background:ue&&!oe?"rgba(37,99,235,0.14)":"transparent"}},`cell-${j.ref.gridRow}-${j.ref.gridCol}`)}),i.jsx("div",{"data-testid":"g7le-inplace-col-rail",onMouseEnter:A,onMouseLeave:z,style:{position:"absolute",top:$e,left:n.left,width:n.width,height:cl,pointerEvents:"none"},children:ae!==null&&Ne.has(ae)&&(()=>{const j=ae,oe=Ne.get(j),ue=_i(f,j,-1),J=_i(f,j,1),le=oe.left-n.left;return i.jsxs("div",{"data-testid":`g7le-inplace-colgutter-${j}`,onMouseEnter:()=>{A(),N(j)},onMouseLeave:z,style:{position:"absolute",top:0,left:le,width:oe.width,minWidth:96,height:cl,display:"flex",gap:3,justifyContent:"center",alignItems:"center",pointerEvents:"auto"},children:[i.jsx("button",{type:"button",title:a("layout_editor.table_inplace.move_col_left"),disabled:!ue.ok,onClick:()=>P(j,-1),"data-testid":`g7le-inplace-col-left-${j}`,style:ue.ok?vn:Ci,children:"←"}),i.jsx("button",{type:"button",title:a("layout_editor.table_inplace.add_col"),onClick:()=>L(j),"data-testid":`g7le-inplace-col-add-${j}`,style:vn,children:"+"}),i.jsx("button",{type:"button",title:a("layout_editor.table_inplace.remove_col"),disabled:f.colCount<=1,onClick:()=>I(j),"data-testid":`g7le-inplace-col-remove-${j}`,style:Ih,children:"✕"}),i.jsx("button",{type:"button",title:a("layout_editor.table_inplace.move_col_right"),disabled:!J.ok,onClick:()=>P(j,1),"data-testid":`g7le-inplace-col-right-${j}`,style:J.ok?vn:Ci,children:"→"})]},`colgut-${j}`)})()}),i.jsx("div",{"data-testid":"g7le-inplace-row-rail",style:{position:"absolute",top:n.top,left:be,width:ul,height:n.height,pointerEvents:"none"},children:pe!==null&&Le.has(pe)&&(()=>{const j=pe,oe=Le.get(j),ue=vi(f,j,-1),J=vi(f,j,1),le=oe.top-n.top;return i.jsxs("div",{"data-testid":`g7le-inplace-rowgutter-${j}`,onMouseEnter:()=>{A(),E(j)},onMouseLeave:z,style:{position:"absolute",top:le,left:0,width:ul,height:oe.height,minHeight:48,display:"grid",gridTemplateColumns:"repeat(2, 22px)",gridAutoRows:"22px",gap:3,justifyContent:"center",alignContent:"center",pointerEvents:"auto"},children:[i.jsx("button",{type:"button",title:a("layout_editor.table_inplace.move_row_up"),disabled:!ue.ok,onClick:()=>H(j,-1),"data-testid":`g7le-inplace-row-up-${j}`,style:ue.ok?vn:Ci,children:"↑"}),i.jsx("button",{type:"button",title:a("layout_editor.table_inplace.add_row"),onClick:()=>B(j),"data-testid":`g7le-inplace-row-add-${j}`,style:vn,children:"+"}),i.jsx("button",{type:"button",title:a("layout_editor.table_inplace.remove_row"),disabled:f.rows.length<=1,onClick:()=>T(j),"data-testid":`g7le-inplace-row-remove-${j}`,style:Ih,children:"✕"}),i.jsx("button",{type:"button",title:a("layout_editor.table_inplace.move_row_down"),disabled:!J.ok,onClick:()=>H(j,1),"data-testid":`g7le-inplace-row-down-${j}`,style:J.ok?vn:Ci,children:"↓"})]},`rowgut-${j}`)})()}),i.jsxs("div",{"data-testid":"g7le-inplace-corner-add",style:{position:"absolute",top:n.top+n.height+pl,left:n.left,display:"flex",flexDirection:"column",gap:6,pointerEvents:"auto",minWidth:200,maxWidth:Math.max(240,n.width),background:"#fff",border:"1px solid #e2e8f0",borderRadius:8,padding:8,boxShadow:"0 2px 8px rgba(0,0,0,0.12)"},children:[i.jsxs("div",{style:{display:"flex",gap:6,flexWrap:"wrap"},children:[i.jsxs("button",{type:"button","data-testid":"g7le-inplace-add-row-bottom",onClick:()=>B(f.rows.length-1),style:Ah,children:["+ ",a("layout_editor.table_inplace.add_row")]}),i.jsxs("button",{type:"button","data-testid":"g7le-inplace-add-col-right",onClick:()=>L(f.colCount-1),style:Ah,children:["+ ",a("layout_editor.table_inplace.add_col")]})]}),!b&&i.jsx("div",{"data-testid":"g7le-inplace-select-hint",style:{fontSize:11,color:"#94a3b8",borderTop:"1px solid #f1f5f9",paddingTop:6},children:a("layout_editor.table_inplace.select_cell_hint")}),b&&i.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:6,borderTop:"1px solid #f1f5f9",paddingTop:6},children:[se&&i.jsx("div",{"data-testid":"g7le-inplace-range-count",style:{fontSize:11,color:"#2563eb"},children:a("layout_editor.table_inplace.range_selected",{count:Z.length})}),i.jsxs("div",{style:{display:"flex",gap:6,flexWrap:"wrap",alignItems:"center"},children:[se?i.jsx("button",{type:"button","data-testid":"g7le-inplace-merge-range",onClick:re,style:tr,children:a("layout_editor.table_inplace.merge_range")}):i.jsxs(i.Fragment,{children:[i.jsx("button",{type:"button","data-testid":"g7le-inplace-merge-right",disabled:!G,onClick:()=>F("right"),style:G?tr:fl,children:a("layout_editor.table_inplace.merge_right")}),i.jsx("button",{type:"button","data-testid":"g7le-inplace-merge-down",disabled:!V,onClick:()=>F("down"),style:V?tr:fl,children:a("layout_editor.table_inplace.merge_down")})]}),i.jsx("button",{type:"button","data-testid":"g7le-inplace-unmerge",disabled:!q,onClick:U,style:q?tr:fl,children:a("layout_editor.table_inplace.unmerge")})]}),i.jsxs("div",{"data-testid":"g7le-inplace-cell-color",style:{borderTop:"1px solid #f1f5f9",paddingTop:6,display:"flex",flexDirection:"column",gap:6},children:[i.jsxs("label",{style:{display:"flex",alignItems:"center",gap:6,fontSize:11,color:"#475569",cursor:"pointer"},children:[i.jsx("input",{type:"checkbox","data-testid":"g7le-inplace-border-all-toggle",checked:x,onChange:j=>k(j.target.checked)}),a("layout_editor.table_inplace.border_apply_all")]}),i.jsx(ch,{colorScheme:w,onChange:_,t:a}),ee&&i.jsx("div",{"data-testid":"g7le-inplace-cell-border",children:i.jsx(oh,{className:x?"":M,catalog:ee,t:a,onChange:me,colorScheme:w,onPresetToken:De,colorStyle:x?"":Qe,onColorStyle:Pe})}),i.jsx("div",{"data-testid":"g7le-inplace-cell-fill",children:i.jsx(ah,{colorStyle:x?"":ie,className:x?"":_e,catalog:fe,t:a,colorScheme:w,onPresetToken:ve,onCustomColor:te,onClear:he})})]}),i.jsx("div",{"data-testid":"g7le-inplace-cell-padding",style:{borderTop:"1px solid #f1f5f9",paddingTop:6},children:i.jsx(lh,{paddingStyle:x?"":ge,catalog:K,t:a,onChange:Q})})]})]})]})}const vn={width:22,height:22,lineHeight:"20px",padding:0,fontSize:13,border:"1px solid #cbd5e1",borderRadius:5,background:"#fff",color:"#475569",cursor:"pointer",boxShadow:"0 1px 3px rgba(0,0,0,0.18)"},Ih={...vn,border:"1px solid #fecaca",color:"#dc2626"},Ci={...vn,cursor:"not-allowed",opacity:.4,color:"#94a3b8",borderColor:"#e2e8f0"},tr={padding:"4px 10px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",boxShadow:"0 1px 2px rgba(0,0,0,0.12)"},fl={...tr,cursor:"not-allowed",opacity:.4},Ah={padding:"3px 8px",fontSize:11,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"};let Th=!1;function Az(){Th||(Ln("children",jg),Ln("table",x8),Ln("array",_h),Ln("array-group",sz),Ln("array-cell-tree",gz),La("table",Iz),Th=!0)}function Tz(){if(typeof window>"u")return;const e=window.G7Core;if(!e)return;const t=e.layoutEditor,n=Array.isArray(t?.__queue)?t.__queue:[],o=Array.isArray(t?.__readyCallbacks)?t.__readyCallbacks:[];e.layoutEditor={registerWidget:Xe,registerNodeEditor:Ln,registerCanvasOverlay:La,onReady:r=>{typeof r=="function"&&r()}};for(const r of n){const[s,...a]=r;try{s==="widget"?Xe(a[0],a[1]):s==="nodeEditor"?Ln(a[0],a[1]):s==="canvasOverlay"&&La(a[0],a[1])}catch{}}for(const r of o)try{r()}catch{}}ON(),Az(),Tz();async function Dz(e){if(typeof fetch!="function")return[];try{const t=await fetch(`/api/admin/templates/${encodeURIComponent(e)}/editor/permission-candidates.json`,{credentials:"same-origin",headers:je()});if(!t.ok)return[];const o=(await t.json().catch(()=>null))?.data?.permissions;if(!Array.isArray(o))return[];const r=[];for(const s of o)if(s&&typeof s=="object"){const a=s.key,l=s.name;typeof a=="string"&&a.length>0&&r.push({value:a,label:typeof l=="string"&&l?l:a})}return r}catch{return[]}}function Nz({templateIdentifier:e}){iD({templateIdentifier:e}),sD(e);const{state:t,dispatch:n}=Je(),{t:o}=tt(),r=go(),s=vp(),a=pD(),l=t.selectedRoute?.layoutName??null,d=yD(e,l),c=t.editMode==="extension",p=u.useMemo(()=>c?gD(a):s,[c,a,s]),[f,y]=u.useState(null),[g,m]=u.useState(null),[h,v]=u.useState([]),[b,x]=u.useState("user"),[k,w]=u.useState(null),[_,S]=u.useState(null),[E,C]=u.useState([]);u.useEffect(()=>{let te=!1;return(async()=>{try{const he=await fetch(`/api/templates/${encodeURIComponent(e)}/components.json`,{credentials:"same-origin",headers:{Accept:"application/json"}});if(!he.ok)return;const Q=await he.json().catch(()=>null);!te&&Q&&typeof Q=="object"&&y(Q)}catch{}})(),()=>{te=!0}},[e]),u.useEffect(()=>{let te=!1;return(async()=>{const{spec:he,sampleGlobalSources:Q}=await Yy({templateIdentifier:e,coreSeed:bD()});te||(m(he),v(Q),Mz(e,he,Q))})(),()=>{te=!0}},[e]),u.useEffect(()=>{let te=!1;return(async()=>{if(typeof fetch=="function")try{const he=await fetch(`/api/admin/templates/${encodeURIComponent(e)}`,{credentials:"same-origin",headers:je()});if(!he.ok)return;const ie=(await he.json().catch(()=>null))?.data,_e=ie?.type;te||((_e==="admin"||_e==="user")&&x(_e),typeof ie?.name=="string"&&ie.name&&w(ie.name),typeof ie?.version=="string"&&ie.version&&S(ie.version))}catch{}})(),()=>{te=!0}},[e]),u.useEffect(()=>{let te=!1;return(async()=>{if(typeof fetch=="function")try{const he=await fetch("/api/admin/templates/?per_page=100&page=1",{credentials:"same-origin",headers:je()});if(!he.ok)return;const ie=(await he.json().catch(()=>null))?.data?.data;if(!te&&Array.isArray(ie)){const _e=[];for(const ge of ie){if(!ge||typeof ge!="object")continue;const Ae=ge.identifier;if(typeof Ae!="string"||!Ae||ge.is_pending===!0)continue;const Le=ge.type==="admin"?"admin":"user",q=ge.name,ae=ge.version;_e.push({identifier:Ae,name:typeof q=="string"?q:Ae,version:typeof ae=="string"?ae:"",type:Le})}C(_e)}}catch{}})(),()=>{te=!0}},[e]);const N=g?.nesting??null,$=t.selectedRoute?.path??null,A=t.editMode,z=t.selectedRoute?.layoutName??(c?a.document?.hostLayoutName??null:null),D=t.routeTree,O=u.useMemo(()=>{if(A!=="extension"||!z)return null;const te=hl(D,z);return te||`__base__/${z}`},[A,z,D]),M=c&&t.returnRoute===null&&!!z;u.useEffect(()=>{if(!M||!z)return;const te=hl(D,z);te&&n({type:"SET_RETURN_ROUTE",route:{path:te,layoutName:z}})},[M,z,D,n]),u.useEffect(()=>{const te=B4(A,$,O),he=W4(g?.states?.groups,te);n({type:"SET_AVAILABLE_STATES",states:he,activeStateId:j4(he)})},[g,A,$,O,n]);const[B,T]=u.useState([]);u.useEffect(()=>{let te=!1;return(async()=>{const he=await Dz(e);te||T(he)})(),()=>{te=!0}},[e]),aD(),lD();const H=u.useCallback(te=>{const he=t.routeTree,Q=te.split("?")[0]?.split("#")[0]??te;return zz(he,Q)?"route_in_tree":"route_not_in_tree"},[t.routeTree]),L=u.useCallback(te=>{const he=te.split("?")[0]?.split("#")[0]??te,Q=gl(t.routeTree,he);Q&&(p.isDirty&&!(!(typeof window<"u")||window.confirm(o("layout_editor.chrome.dirty_guard.message")))||n({type:"SELECT_ROUTE",route:{path:Q.path,layoutName:Q.layoutName}}))},[t.routeTree,p.isDirty,n]),I=u.useRef([]),P=u.useCallback(te=>{I.current=te},[]),[F,G]=u.useState(null),V=u.useCallback(()=>G(null),[]),Y=u.useRef(null),Z=u.useRef(null);u.useEffect(()=>{const te=Z.current,he=Y.current;if(!te||!he||typeof ResizeObserver>"u")return;const Q=()=>{he.style.setProperty("--g7le-header-h",`${Math.round(te.getBoundingClientRect().height)}px`)};Q();const ie=new ResizeObserver(Q);return ie.observe(te),()=>ie.disconnect()},[]);const se=u.useCallback(()=>{p.reload()},[p]),re=u.useCallback(()=>{p.isDirty&&!(!(typeof window<"u")||window.confirm(o("layout_editor.chrome.dirty_guard.message")))||window.G7Core?.dispatch?.({handler:"openWindow",params:{path:`/admin/templates/${b}`,target:"_self"}})},[p.isDirty,o,b]),U=u.useCallback(te=>{if(!te||te===e||p.isDirty&&!(typeof window<"u"?window.confirm(o("layout_editor.chrome.dirty_guard.message")):!0))return;const he=Xs(te);window.G7Core?.dispatch?.({handler:"openWindow",params:{path:he,target:"_self"}})},[e,p.isDirty,o]),ee=u.useCallback(()=>{const te=CE(e,t.selectedRoute?.path??null);window.G7Core?.dispatch?.({handler:"openWindow",params:{path:te}})},[e,t.selectedRoute]),fe=u.useCallback(()=>{const te=t.selectedRoute?.layoutName??"";if(!te)return;const he=r.open({ariaLabel:o("layout_editor.attachment_manager.title"),width:720,maxHeightRatio:.82,content:u.createElement(Nl,{templateIdentifier:e,layoutName:te,t:o,onClose:()=>r.close(he)})})},[r,e,t.selectedRoute,o]),K=u.useCallback(()=>{const te=t.selectedRoute?.layoutName??"";if(!te)return;const he=Ly(s.document?.raw??{}),Q=r.open({ariaLabel:o("layout_editor.translation_manager.title"),width:720,maxHeightRatio:.82,content:u.createElement(tm,{templateIdentifier:e,layoutName:te,t:o,referencedKeys:he,onClose:()=>r.close(Q)})})},[r,e,t.selectedRoute,o,s.document]),ne=u.useCallback((te,he)=>{const Q=window.G7Core;if(Q&&typeof Q.t=="function"){const ie=Q.t(te,he);if(ie&&ie!==te&&!ie.startsWith("$t:"))return ie}return o(te,he)},[o]),me=u.useCallback(()=>{if(!t.selectedRoute||t.editMode==="extension"||t.editMode==="iteration_item")return;const te=r.open({ariaLabel:o("layout_editor.page_settings.title",{name:t.selectedRoute.path??""}),width:1080,maxHeightRatio:.88,closeOnEscape:!1,content:u.createElement(o$,{templateIdentifier:e,spec:g,t:o,resolveLabel:ne,permissionCandidates:B,onClose:()=>r.close(te)})})},[r,t.selectedRoute,t.editMode,o,ne,e,g,B]),Ce=u.useCallback(async()=>{const te=await d.createPreview(s.document?.raw??null);if(te.kind!=="success"){const he=te.kind==="network_error"?te.message:o("layout_editor.preview_action.no_document");typeof window<"u"&&window.alert(he)}},[d,s.document,o]),ze=u.useCallback(()=>{const te=t.selectedRoute?.layoutName??"",he=t.selectedRoute?.path??"",Q=t.editMode==="extension"&&he.startsWith("__extension__/")?he.slice(14):null,ie=Q?{kind:"extension",extensionId:Q}:te?{kind:"layout",layoutName:te}:null;if(!ie)return;const _e=r.open({ariaLabel:o("layout_editor.version_history.title"),width:640,maxHeightRatio:.82,content:u.createElement(H$,{templateIdentifier:e,target:ie,t:o,onRestored:async ge=>{typeof ge=="number"&&(ie.kind==="extension"?n({type:"SET_EXTENSION_VERSION",extensionId:ie.extensionId,version:ge}):n({type:"SET_LAYOUT_VERSION",layoutName:ie.layoutName,version:ge})),await p.reload()},onClose:()=>r.close(_e)})})},[r,e,t.selectedRoute,t.editMode,o,p,n]),Se=p.dirtyKeys.size>0||p.isDirty;u.useEffect(()=>{if(!Se)return;const te=he=>{he.preventDefault(),he.returnValue=""};return window.addEventListener("beforeunload",te),()=>window.removeEventListener("beforeunload",te)},[Se]);const qe=u.useCallback(()=>{!p.isDirty||!(!(typeof window<"u")||window.confirm(o("layout_editor.chrome.reset_guard.message")))||p.reload()},[p,o]),De=u.useCallback(async()=>{const te=await p.save({sessionAddedPaths:I.current});G(te),te.kind==="success"&&typeof te.newContentVersion=="number"&&(te.savedExtensionId?n({type:"SET_EXTENSION_VERSION",extensionId:te.savedExtensionId,version:te.newContentVersion}):te.savedLayoutName&&n({type:"SET_LAYOUT_VERSION",layoutName:te.savedLayoutName,version:te.newContentVersion}))},[p,n]),Pe=u.useCallback(()=>{const te=r.open({ariaLabel:o("layout_editor.shortcuts.title"),width:560,maxHeightRatio:.85,content:u.createElement(J$,{t:o,onClose:()=>r.close(te)})})},[r,o]),Qe=u.useMemo(()=>({save:()=>{De()},exit:re,editCode:ee,reset:qe,translations:K,addElement:()=>{t.selectedRoute&&n({type:"TOGGLE_PALETTE"})},help:Pe,preview:()=>{t.selectedRoute?.layoutName&&Ce()}}),[De,re,ee,qe,K,t.selectedRoute,n,Pe,Ce]),[Me,Re]=u.useState(!1);u.useEffect(()=>{const te=window.setInterval(()=>{Re(!!window.__g7LayoutEditorHasSelection)},200);return()=>window.clearInterval(te)},[]),Ju({handlers:Qe,hasSelection:Me});const ve=p;return t.routesError?i.jsx("div",{className:"g7le-chrome g7le-chrome--routes-error","data-testid":"g7le-chrome-routes-error",style:{position:"fixed",inset:0,display:"flex",background:"#f8fafc",color:"#0f172a",fontFamily:'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple SD Gothic Neo", "Noto Sans KR", sans-serif',fontSize:13,lineHeight:1.5,zIndex:9e3},children:i.jsx(ja,{error:t.routesError})}):i.jsx("div",{className:"g7le-chrome","data-testid":"g7le-chrome",ref:Y,style:{position:"static",minHeight:"100vh",background:"#f8fafc",color:"#0f172a",fontFamily:'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple SD Gothic Neo", "Noto Sans KR", sans-serif',fontSize:13,lineHeight:1.5,zIndex:9e3},children:i.jsxs(oy,{value:ve,children:[c&&a.needsHostPicker&&a.document&&i.jsx(hD,{hostLayouts:a.document.hostLayouts,extensionLabel:null,onSelect:a.selectHost}),i.jsxs("div",{className:"g7le-chrome__header","data-testid":"g7le-chrome-header",ref:Z,style:{position:"sticky",top:0,zIndex:20,background:"#f8fafc"},children:[i.jsx(uy,{onSave:()=>{De()},isDirty:p.isDirty,onExit:re,onEditCode:ee,onManageImages:fe,onManageTranslations:K,onPageSettings:me,onReset:qe,onShowShortcuts:Pe,onPreview:Ce,onShowVersions:ze,templateName:k,templateVersion:_,templateList:E,onSwitchTemplate:U}),i.jsx(pE,{result:F,onDismiss:V,onLoadLatest:se}),i.jsxs("div",{className:"g7le-chrome__device-toolbar","data-testid":"g7le-chrome-device-toolbar",style:{display:"flex",justifyContent:"center",padding:"6px 12px",background:"#f1f5f9",borderBottom:"1px solid #e2e8f0"},children:[i.jsx(P4,{}),i.jsx(O4,{})]})]}),i.jsxs("div",{className:"g7le-chrome__body",style:{display:"grid",gridTemplateColumns:"auto 1fr",alignItems:"stretch"},children:[i.jsx(NE,{}),i.jsx(D4,{manifest:f,nesting:N,componentPalette:g?.componentPalette??null,spec:g,permissionCandidates:B,sampleGlobalSources:h,resolveRouteMatch:H,onNavigateToDestination:L,onSessionAddedPathsChange:P})]}),i.jsx(sy,{})]})})}function gl(e,t){for(const n of e){if(n.kind==="route"&&n.path===t)return n;if(n.children&&n.children.length>0){const o=gl(n.children,t);if(o)return o}}return null}function zz(e,t){return gl(e,t)!==null}function hl(e,t){for(const n of e){if(n.kind==="route"&&n.layoutName===t&&typeof n.path=="string")return n.path;if(n.children&&n.children.length>0){const o=hl(n.children,t);if(o)return o}}return null}function Mz(e,t,n){const o=[];Fa({sources:n,isGuestOnly:!1,warn:s=>{const a=/extension '([^']+)' override of core key '([^']+)'/.exec(s);a&&o.push(`${a[1]}:${a[2]}`)}});const r=t?.sampleData?.byDataSourceId?Object.keys(t.sampleData.byDataSourceId).length:0;qh({templateIdentifier:e,mergedSources:[...n.map(s=>({kind:s.kind,id:s.id}))],blockCounts:{controls:t?.controls?Object.keys(t.controls).length:0,componentCapabilities:t?.componentCapabilities?Object.keys(t.componentCapabilities).length:0,actionRecipes:t?.actionRecipes?Object.keys(t.actionRecipes).length:0,conditionRecipes:t?.conditionRecipes?Object.keys(t.conditionRecipes).length:0,paletteGroups:t?.componentPalette?.groups?.length??0,stateGroups:t?.states?.groups?.length??0,sampleDataIds:r},sampleGlobalSourceCount:n.length,sampleGlobalConflicts:o,timestamp:Date.now()})}function Dh({templateIdentifier:e,initialLocale:t}){return u.useEffect(()=>()=>{Bh(),jh(),Fh(),Kh(),Vh(),Uh(),Gh(),Yh(),Xh()},[]),i.jsx(ny,{templateIdentifier:e,initialLocale:t,children:i.jsx(iy,{children:i.jsx(Nz,{templateIdentifier:e})})})}if(typeof window<"u"){const e=window.G7Core||(window.G7Core={});if(e.__LayoutEditorChrome=Dh,typeof e.__onChromeReady=="function")try{e.__onChromeReady()}catch{}}ln.LayoutEditorChrome=Dh,Object.defineProperty(ln,Symbol.toStringTag,{value:"Module"})})(this.G7LayoutEditor=this.G7LayoutEditor||{},ReactJSXRuntime,React,ReactDOM); //# sourceMappingURL=layout-editor.min.js.map diff --git a/public/build/core/layout-editor.min.js.map b/public/build/core/layout-editor.min.js.map index 70ff4431..3b6baaae 100644 --- a/public/build/core/layout-editor.min.js.map +++ b/public/build/core/layout-editor.min.js.map @@ -1 +1 @@ -{"version":3,"file":"layout-editor.min.js","sources":["../../../resources/js/core/template-engine/layout-editor/__runtime-shims/runtime.ts","../../../resources/js/core/template-engine/layout-editor/__runtime-shims/TranslationContext.ts","../../../resources/js/core/template-engine/layout-editor/devtools/editorTrackers.ts","../../../resources/js/core/template-engine/layout-editor/LayoutEditorContext.tsx","../../../resources/js/core/template-engine/layout-editor/LayoutDocumentContext.tsx","../../../resources/js/core/template-engine/layout-editor/EditorModalContext.tsx","../../../resources/js/core/template-engine/layout-editor/components/EditorToolbar.tsx","../../../resources/js/core/template-engine/layout-editor/utils/authToken.ts","../../../resources/js/core/template-engine/layout-editor/utils/layoutAttachments.ts","../../../resources/js/core/template-engine/layout-editor/components/property-controls/LayoutAttachmentManager.tsx","../../../resources/js/core/template-engine/layout-editor/utils/customTranslations.ts","../../../resources/js/core/template-engine/layout-editor/__runtime-shims/TranslationEngine.ts","../../../resources/js/core/template-engine/layout-editor/__runtime-shims/Logger.ts","../../../resources/js/core/template-engine/layout-editor/spec/editorSpecLoader.ts","../../../resources/js/core/template-engine/layout-editor/spec/bindingCandidates.ts","../../../resources/js/core/template-engine/layout-editor/spec/inlineBindingUtils.ts","../../../resources/js/core/template-engine/layout-editor/utils/layoutTreeUtils.ts","../../../resources/js/core/template-engine/layout-editor/hooks/useInlineEdit.ts","../../../resources/js/core/template-engine/layout-editor/hooks/pendingCustomTranslations.ts","../../../resources/js/core/template-engine/layout-editor/components/LocaleSwitcher.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/PlaceholderChipInput.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/CustomTranslationManager.tsx","../../../resources/js/core/template-engine/layout-editor/hooks/usePageSettings.ts","../../../resources/js/core/template-engine/layout-editor/sample-data/sampleDataProvider.ts","../../../resources/js/core/template-engine/layout-editor/__runtime-shims/DataBindingEngine.ts","../../../resources/js/core/template-engine/layout-editor/spec/computedRecipeEngine.ts","../../../resources/js/core/template-engine/layout-editor/hooks/useBindingCandidates.ts","../../../resources/js/core/template-engine/layout-editor/hooks/useSeoBindingCandidates.ts","../../../resources/js/core/template-engine/layout-editor/spec/candidatePools.ts","../../../resources/js/core/template-engine/layout-editor/spec/actionRecipeEngine.ts","../../../resources/js/core/template-engine/layout-editor/hooks/useCustomTranslation.ts","../../../resources/js/core/template-engine/layout-editor/components/property-controls/inlineBindingApi.ts","../../../resources/js/core/template-engine/layout-editor/components/property-controls/TranslationField.tsx","../../../resources/js/core/template-engine/layout-editor/components/shared/FloatingDropdown.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/InlineBindingScalarPicker.tsx","../../../resources/js/core/template-engine/layout-editor/spec/expressionValueTree.ts","../../../resources/js/core/template-engine/layout-editor/components/DropLine.tsx","../../../resources/js/core/template-engine/layout-editor/hooks/useListDragReorder.ts","../../../resources/js/core/template-engine/layout-editor/components/page-settings/SegmentedValueEditor.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/ConditionalValueEditor.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/I18nTextField.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/TagInputControl.tsx","../../../resources/js/core/template-engine/layout-editor/spec/widgetRegistry.ts","../../../resources/js/core/template-engine/layout-editor/components/page-settings/MetaForm.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/FormPrimitives.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/BindingChipTextInput.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/DataChipValueInput.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/KeyValueChipEditor.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/SeoOgForm.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/SeoStructuredDataEditor.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/SeoVarsEditor.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/SeoBotPreviewPanel.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/DataSourceChipLabel.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/SeoForm.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/ActionAddPicker.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/ActionPreview.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/RecipePickerControls.tsx","../../../resources/js/core/template-engine/layout-editor/spec/initialStateValueUtils.ts","../../../resources/js/core/template-engine/layout-editor/components/page-settings/InitialStateValueEditor.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/ActionParamFields.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/ActionListBuilder.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/InitActionsForm.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/LoadingComponentPicker.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/TransitionOverlayForm.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/ComputedPreview.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/CustomComputedBuilder.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/ComputedForm.tsx","../../../resources/js/core/template-engine/layout-editor/components/JsonBlockField.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/InitialStateForm.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/ErrorHandlingRows.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/ErrorHandlingForm.tsx","../../../resources/js/core/template-engine/layout-editor/spec/conditionRecipeEngine.ts","../../../resources/js/core/template-engine/layout-editor/__runtime-shims/ResponsiveManager.ts","../../../resources/js/core/template-engine/layout-editor/spec/recipeEngine.ts","../../../resources/js/core/template-engine/layout-editor/spec/styleScope.ts","../../../resources/js/core/template-engine/layout-editor/components/property-controls/ConditionBuilder.tsx","../../../resources/js/core/template-engine/layout-editor/spec/dataSourceParamsUtils.ts","../../../resources/js/core/template-engine/layout-editor/spec/dataSourceConditionAdapter.ts","../../../resources/js/core/template-engine/layout-editor/components/property-controls/DataSourcesPanel.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/DataSourceTab.tsx","../../../resources/js/core/template-engine/layout-editor/components/page-settings/PageSettingsModal.tsx","../../../resources/js/core/template-engine/layout-editor/hooks/useLayoutVersions.ts","../../../resources/js/core/template-engine/layout-editor/utils/lineDiff.ts","../../../resources/js/core/template-engine/layout-editor/components/VersionDiffView.tsx","../../../resources/js/core/template-engine/layout-editor/components/VersionHistoryModal.tsx","../../../resources/js/core/template-engine/layout-editor/spec/editorShortcuts.ts","../../../resources/js/core/template-engine/layout-editor/components/ShortcutHelpModal.tsx","../../../resources/js/core/template-engine/layout-editor/hooks/useEditorShortcuts.ts","../../../resources/js/core/template-engine/layout-editor/components/SaveFeedbackBanner.tsx","../../../resources/js/core/template-engine/layout-editor/hooks/useEditorMode.ts","../../../resources/js/core/template-engine/layout-editor/components/RouteTreePanel.tsx","../../../resources/js/core/template-engine/layout-editor/__runtime-shims/DynamicRenderer.ts","../../../resources/js/core/template-engine/layout-editor/__runtime-shims/ResponsiveContext.ts","../../../resources/js/core/template-engine/layout-editor/__runtime-shims/ActionDispatcher.ts","../../../resources/js/core/template-engine/layout-editor/__runtime-shims/DataSourceManager.ts","../../../resources/js/core/template-engine/layout-editor/types/editorErrors.ts","../../../resources/js/core/template-engine/layout-editor/hooks/useElementSelection.ts","../../../resources/js/core/template-engine/layout-editor/utils/editorCacheBust.ts","../../../resources/js/core/template-engine/layout-editor/hooks/useLayoutDocument.ts","../../../resources/js/core/template-engine/layout-editor/utils/overlayGeometry.ts","../../../resources/js/core/template-engine/layout-editor/hooks/useInsertionPoints.ts","../../../resources/js/core/template-engine/layout-editor/hooks/useEditorHistory.ts","../../../resources/js/core/template-engine/layout-editor/hooks/useUndoRedoShortcuts.ts","../../../resources/js/core/template-engine/layout-editor/utils/overlayZIndex.ts","../../../resources/js/core/template-engine/layout-editor/components/ComponentContextMenu.tsx","../../../resources/js/core/template-engine/layout-editor/components/ElementOverlay.tsx","../../../resources/js/core/template-engine/layout-editor/components/InsertionAffordances.tsx","../../../node_modules/@dnd-kit/utilities/dist/utilities.esm.js","../../../node_modules/@dnd-kit/accessibility/dist/accessibility.esm.js","../../../node_modules/@dnd-kit/core/dist/core.esm.js","../../../resources/js/core/template-engine/layout-editor/dnd/nestingRules.ts","../../../resources/js/core/template-engine/layout-editor/dnd/dropSlots.ts","../../../resources/js/core/template-engine/layout-editor/hooks/useCanvasDnd.ts","../../../resources/js/core/template-engine/layout-editor/components/DndCanvasLayer.tsx","../../../resources/js/core/template-engine/layout-editor/components/SourceLockDimLayer.tsx","../../../resources/js/core/template-engine/layout-editor/components/ComponentPalette.tsx","../../../resources/js/core/template-engine/layout-editor/spec/isolatedScopeUtils.ts","../../../resources/js/core/template-engine/layout-editor/components/property-controls/ControlRenderer.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/CompositeSettingsForm.tsx","../../../resources/js/core/template-engine/layout-editor/utils/advancedValueUtils.ts","../../../resources/js/core/template-engine/layout-editor/components/property-controls/AdvancedPropsForm.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/ActionRecipeEditor.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/DataBindingSection.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/InlineBindingSection.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/IterationBindingSection.tsx","../../../resources/js/core/template-engine/layout-editor/spec/textComponents.ts","../../../resources/js/core/template-engine/layout-editor/spec/flexModel.ts","../../../resources/js/core/template-engine/layout-editor/components/property-controls/FlexEditor.tsx","../../../resources/js/core/template-engine/layout-editor/spec/deviceList.ts","../../../resources/js/core/template-engine/layout-editor/hooks/useDeviceList.ts","../../../resources/js/core/template-engine/layout-editor/components/property-controls/StyleScopeTabs.tsx","../../../resources/js/core/template-engine/layout-editor/spec/nodeEditorRegistry.ts","../../../resources/js/core/template-engine/layout-editor/spec/coreProps.ts","../../../resources/js/core/template-engine/layout-editor/components/property-controls/IsolatedScopeControl.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/InjectedPropsSection.tsx","../../../resources/js/core/template-engine/layout-editor/components/PropertyEditorModal.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/ComponentTargetPicker.tsx","../../../resources/js/core/template-engine/layout-editor/utils/injectedPropsCrossSave.ts","../../../resources/js/core/template-engine/layout-editor/spec/canvasOverlayRegistry.ts","../../../resources/js/core/template-engine/layout-editor/utils/editorClipboard.ts","../../../resources/js/core/template-engine/layout-editor/hooks/useResizeHandles.ts","../../../resources/js/core/template-engine/layout-editor/hooks/useDevicePreview.ts","../../../resources/js/core/template-engine/layout-editor/utils/resolveEditorTarget.ts","../../../resources/js/core/template-engine/layout-editor/components/InlineTextEditor.tsx","../../../resources/js/core/template-engine/layout-editor/components/InlineParamChipEditor.tsx","../../../resources/js/core/template-engine/layout-editor/components/InlineTextToolbar.tsx","../../../resources/js/core/template-engine/layout-editor/components/EditorCanvasOverlay.tsx","../../../resources/js/core/template-engine/layout-editor/hooks/useSampleData.ts","../../../resources/js/core/template-engine/layout-editor/__runtime-shims/ComponentRegistry.ts","../../../resources/js/core/template-engine/layout-editor/hooks/useEditorTemplateAssets.ts","../../../resources/js/core/template-engine/layout-editor/__runtime-shims/AuthManager.ts","../../../resources/js/core/template-engine/layout-editor/components/AccessErrorPanel.tsx","../../../resources/js/core/template-engine/layout-editor/utils/previewCanvasStore.ts","../../../resources/js/core/template-engine/layout-editor/utils/sampleRouteParams.ts","../../../resources/js/core/template-engine/layout-editor/sample-data/sampleGlobalChain.ts","../../../resources/js/core/template-engine/layout-editor/sample-data/dollarGlobals.ts","../../../resources/js/core/template-engine/layout-editor/utils/iterationSampleLimit.ts","../../../resources/js/core/template-engine/layout-editor/utils/baseEditorSlotMarkers.ts","../../../resources/js/core/template-engine/layout-editor/state/pageStateSimulator.ts","../../../resources/js/core/template-engine/layout-editor/components/PreviewCanvas.tsx","../../../resources/js/core/template-engine/layout-editor/components/DevicePreviewToolbar.tsx","../../../resources/js/core/template-engine/layout-editor/components/PageStateSwitcher.tsx","../../../resources/js/core/template-engine/layout-editor/utils/matchStateScope.ts","../../../resources/js/core/template-engine/layout-editor/hooks/useRouteTree.ts","../../../resources/js/core/template-engine/layout-editor/hooks/useEditorRoutes.ts","../../../resources/js/core/template-engine/layout-editor/hooks/useEditorUrlSync.ts","../../../resources/js/core/template-engine/layout-editor/hooks/usePreviewDarkIsolation.ts","../../../resources/js/core/template-engine/layout-editor/hooks/usePreviewBodyScrollIsolation.ts","../../../resources/js/core/template-engine/layout-editor/hooks/useExtensionDocument.ts","../../../resources/js/core/template-engine/layout-editor/hooks/adaptExtensionToLayoutDocument.ts","../../../resources/js/core/template-engine/layout-editor/components/ExtensionHostPickerModal.tsx","../../../resources/js/core/template-engine/layout-editor/hooks/useLayoutPreview.ts","../../../resources/js/core/template-engine/layout-editor/spec/coreActionRecipes.ts","../../../resources/js/core/template-engine/layout-editor/components/property-controls/StyleControlWidgets.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/ColorPickerControl.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/ImagePickerControl.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/OptionsListControl.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/CoreIdControl.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/CoreDataKeyControl.tsx","../../../resources/js/core/template-engine/layout-editor/spec/registerCoreWidgets.ts","../../../resources/js/core/template-engine/layout-editor/utils/nodeTextPath.ts","../../../resources/js/core/template-engine/layout-editor/components/property-controls/ChildrenListControl.tsx","../../../resources/js/core/template-engine/layout-editor/spec/tableGridModel.ts","../../../resources/js/core/template-engine/layout-editor/spec/tableGridMutations.ts","../../../resources/js/core/template-engine/layout-editor/components/property-controls/CellBorderControl.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/CellFillControl.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/CellPaddingControl.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/ColorSchemeTabs.tsx","../../../resources/js/core/template-engine/layout-editor/spec/cellBorderPreview.ts","../../../resources/js/core/template-engine/layout-editor/components/property-controls/TableEditor.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/ArrayItemsEditor.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/ArrayGroupEditor.tsx","../../../resources/js/core/template-engine/layout-editor/components/property-controls/ArrayCellTreeEditor.tsx","../../../resources/js/core/template-engine/layout-editor/components/inplace/TableInplaceOverlay.tsx","../../../resources/js/core/template-engine/layout-editor/spec/registerCoreEditors.ts","../../../resources/js/core/template-engine/layout-editor/spec/exposeLayoutEditorGlobals.ts","../../../resources/js/core/template-engine/layout-editor/LayoutEditorChrome.tsx","../../../resources/js/core/layout-editor-entry.ts"],"sourcesContent":["/**\n * 코어 런타임 접근 헬퍼 — 편집기 번들 전용 shim 공용 유틸\n *\n * @since engine-v1.51.0\n *\n * 편집기 lazy 번들의 런타임 shim 들이 `window.G7Core.__runtime`(메인 번들이 노출)에서\n * 코어 런타임 값을 안전하게 획득한다. 메인 `