Merge pull request from gnuboard:HeuJung/issue459

HeuJung/issue459
This commit is contained in:
정정홍
2026-07-10 19:33:00 +09:00
committed by GitHub
112 changed files with 5290 additions and 1024 deletions
+12 -1
View File
@@ -248,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 은 `<i>` 글리프라 박스 크기가 곧 `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` 의 치환용으로는 쓰지 않는다.
### 상태 관리
| 금지 | 올바른 사용 |
+3 -1
View File
@@ -4,10 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [7.0.3] - 2026-07-08
## [7.0.3] - 2026-07-10
### Fixed
- 화면 구성 파일이 목록을 만들어 내는 과정에서 다국어 이름을 골라 쓸 때, 사이트를 어떤 언어로 보고 있든 항상 한국어 이름이 선택되던 문제를 수정했습니다. 이제 보고 있는 언어의 이름이 표시됩니다. 헤더의 배송국가 선택처럼 화면이 처음 열릴 때 목록을 만들어 두는 곳이 여기에 해당합니다.
- 메뉴를 눌러 화면을 이동했을 때, 필요한 정보를 여러 갈래로 나눠 받아오는 화면에서 먼저 도착한 정보가 뒤늦게 도착한 정보에 덮여 사라지는 경우가 있었습니다. 그 탓에 목록이 있어야 할 자리가 간헐적으로 비어 보이고 새로고침하면 정상으로 돌아오곤 했습니다. 이제 어느 정보가 먼저 도착하든 모두 반영됩니다. 이커머스 환경설정의 배송설정 탭에서 배송가능 국가 목록이 비어 보이던 것이 여기에 해당합니다.
- 이미 설치를 마친 환경의 설정 파일(`.env`)을 새 서버로 복사해, 데이터베이스가 아직 비어 있는 상태에서 설치를 이어가려 할 때 `php artisan migrate`로 테이블을 만들기도 전에 부팅 단계에서 오류가 나며 중단되던 문제를 수정했습니다. 이제 데이터베이스 작업 명령이 실행되는 동안에는 테이블 존재 여부를 실제로 확인하므로, 기존 서버의 설정을 재사용하는 신규 배포에서도 설치가 정상적으로 진행됩니다. (#41 @GyusoonKim 님께서 제보해주셨습니다.)
## [7.0.2] - 2026-07-08
+19 -8
View File
@@ -252,6 +252,17 @@ class ApiDocSampleService implements ApiDocSampleSeeder
/**
* 완전한 언어팩 샘플(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
@@ -264,27 +275,27 @@ class ApiDocSampleService implements ApiDocSampleSeeder
$pack = LanguagePack::create([
'identifier' => 'apidoc-sample-lang',
'vendor' => 'apidoc',
'scope' => 'core',
'target_identifier' => null,
'locale' => 'fr',
'locale_name' => 'French',
'locale_native_name' => 'Français',
'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' => 'active',
'status' => 'installed',
'is_protected' => false,
'manifest' => [
'name' => ['ko' => 'API 문서 샘플 언어팩', 'en' => 'API Doc Sample Pack'],
'version' => '1.0.0',
'locale' => 'fr',
'locale' => 'ja',
],
'source_type' => 'bundled',
'installed_by' => $installer?->id,
'installed_at' => now()->subDays(3),
'activated_at' => now()->subDays(3),
'activated_at' => null,
]);
}
+8 -2
View File
@@ -126,6 +126,11 @@ class ApiDocScaffolder
*/
private const GEN_KEY_INDEX = 'api-readme-index';
/**
* @var string 코어 README 의 확장 API 목차 섹션 헤딩 (사람 서술 보존 경계)
*/
private const EXTENSIONS_HEADING = '확장 API 레퍼런스';
/**
* 대상(코어/확장)의 API 문서 디렉토리 목차(README.md)를 생성합니다.
*
@@ -245,7 +250,7 @@ class ApiDocScaffolder
});
$lines = [];
$lines[] = '## 확장 API 레퍼런스';
$lines[] = '## '.self::EXTENSIONS_HEADING;
$lines[] = '';
$lines[] = '> 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다.';
$lines[] = '';
@@ -995,7 +1000,8 @@ class ApiDocScaffolder
$afterGen = substr($existing, $endPos + strlen(self::GEN_END));
// 확장 목차 섹션(`## 확장 API 레퍼런스`)부터는 생성 블록 소관이므로 제외한다.
$nextSection = preg_match('/\n## /', $afterGen, $m, PREG_OFFSET_CAPTURE)
// 임의의 `## ` 헤딩에서 자르면 사람이 덧붙인 헤딩 섹션이 재생성 때마다 통째로 소실된다.
$nextSection = preg_match('/\n## '.preg_quote(self::EXTENSIONS_HEADING, '/').'/', $afterGen, $m, PREG_OFFSET_CAPTURE)
? $m[0][1]
: strlen($afterGen);
+21
View File
@@ -289,6 +289,27 @@ php artisan api:docgen --scope=core --dry-run
- 확장에 새 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-*`)
파라미터/응답 필드 표의 `<!-- TODO: 설명 -->` 셀 중 **도메인 무관 공통 필드**(페이지네이션·정렬·검색·
+34
View File
@@ -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` 상태는 **레이아웃 이름 기준으로 선택적 초기화**됩니다:
@@ -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
@@ -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": "削除",
@@ -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",
@@ -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
@@ -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": "すべて",
@@ -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",
@@ -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
@@ -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": {
@@ -5,7 +5,7 @@
"ko": "이커머스",
"en": "Ecommerce"
},
"version": "1.0.1",
"version": "1.0.2",
"license": "MIT",
"description": {
"ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리",
+2 -2
View File
@@ -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",
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-ecommerce",
"version": "1.0.1",
"version": "1.0.2",
"description": "그누보드7 이커머스 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
@@ -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"
}
}
@@ -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": [
{
@@ -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": [
{
@@ -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}}"
}
@@ -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');
});
});
@@ -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');
});
});
@@ -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[<loc>] 로 써야 한다 — 고정 .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');
@@ -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된다', () => {
@@ -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",
@@ -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": "삭제",
@@ -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"
}
}
]
@@ -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"
}
},
{
@@ -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"
}
}
}
}
]
}
@@ -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": {}
}
]
}
@@ -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'}}"
}
]
},
@@ -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"
}
}
}
}
]
@@ -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": [
@@ -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",
@@ -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.<loc>` 같은 동적 키 대신 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
@@ -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": [
{
@@ -27,6 +27,12 @@ type EcommerceAuthFixtures = {
mileageReadOnlyToken: string;
/** 상품 조회 + 수정 권한 보유 토큰 (상품폼 등록/수정 검증용) */
productManageToken: string;
/** 주문 조회(`orders.read`) 권한 보유 토큰 (주문 상세 표시 검증용) */
ordersReadToken: string;
/** 일반 쇼핑 사용자 토큰 (관리자 권한 없음 — 유저 화면 검증용) */
userToken: string;
/** 구매 고객 토큰 (장바구니/추가옵션 등 쇼핑 플로우 검증용) */
customerToken: string;
};
export const test = base.extend<EcommerceAuthFixtures>({
@@ -49,6 +55,18 @@ export const test = base.extend<EcommerceAuthFixtures>({
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 };
@@ -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 보강):
@@ -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 을 얻는다 (주문번호 하드코딩 회피).
*
* 목록 화면은 좁은 뷰포트에서 표가 아니라 카드로 렌더되므로, 진입은 항상 데스크톱 폭에서
* 한다. 상세 진입은 `<a href>` 가 아니라 주문번호 `<span>` 클릭(DataGrid onRowClick 계열).
* 상세 URL 은 숫자 id 가 아니라 주문번호(예: `20260709-0144414102`) 다.
*/
async function firstOrderDetailUrl(page: Page): Promise<string> {
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<number[]> {
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);
});
});
@@ -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<Array<Record<string, string>>> {
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<string> {
return page.evaluate(() => (window as any).__templateApp?.getConfig?.()?.locale ?? '');
}
/** 헤더 배송국가 셀렉터가 파생한 표시명 (init_actions/ActionDispatcher 경로 — 회귀 지점). */
async function derivedHeaderNames(page: Page): Promise<string[]> {
return page.evaluate(() =>
((window as any).G7Core?.state?.getGlobal?.()?.availableShippingCountries ?? []).map((c: any) => c.name),
);
}
/**
* 설치된 언어의 `{ 코드: 표시명 }` 맵. 하드코딩 금지 — 언어는 계속 추가된다(PO 지시).
* 레이아웃의 이름 입력칸 라벨이 쓰는 것과 동일한 출처(`appConfig.localeNames`)를 읽는다.
*/
async function localeDisplayNames(page: Page): Promise<Record<string, string>> {
return page.evaluate(() => (window as any).G7Core?.state?.getGlobal?.()?.appConfig?.localeNames ?? {});
}
/** 국가 표(`_local.form.shipping`)가 시드됐는지. */
async function formSeeded(page: Page): Promise<boolean> {
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<void> {
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();
});
});
@@ -0,0 +1,234 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Upgrades;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\File;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 배송가능 국가명의 빈 로케일 키 청소 마이그레이션 테스트 (#459)
*
* 구 국가 추가 폼은 한국어/영문 두 입력칸을 항상 렌더했고, 운영자가 한쪽을 비운 채 저장하면
* `{"ko":"프랑스","en":""}` 처럼 빈 문자열이 저장본에 박혔다. 빈 문자열은 "값이 있음" 도
* "부재" 도 아닌 어중간한 상태라, 저장본을 읽는 쪽마다 처리가 갈린다.
*
* 부재 로케일은 비워 두는 것이 이 시스템의 계약이다 — 기본 10개국은 언어팩
* (settings.countries.{code}.name)이 읽기 시점에 보강하고, 운영자가 직접 추가한 국가는
* 운영자가 채운다. 따라서 빈 문자열 키는 제거해 "부재" 로 정규화한다.
*
* 이름을 새로 채우지는 않는다(PO 결정) — 청소만 한다.
*/
class PruneEmptyShippingCountryNameLocalesTest extends ModuleTestCase
{
private const MIGRATION_NS = 'App\\Upgrades\\Data\\Ext\\Modules\\SirsoftEcommerce\\V1_0_2\\Migrations\\PruneEmptyShippingCountryNameLocales';
private string $settingsPath;
private string $settingsFile;
protected function setUp(): void
{
parent::setUp();
// DataMigration 클래스는 오토로드 밖 — 직접 로드
$migDir = dirname(__DIR__, 3).'/upgrades/data/1.0.2/migrations';
require_once $migDir.'/PruneEmptyShippingCountryNameLocales.php';
// 운영 storage 오염 방지: 테스트 환경 경로 (마이그레이션의 runningUnitTests 분기와 일치)
$this->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']);
}
}
@@ -0,0 +1,21 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Upgrades;
use App\Extension\AbstractUpgradeStep;
/**
* Ecommerce 모듈 1.0.2 업그레이드 스텝
*
* 배송가능 국가명(shipping.available_countries[].name)의 저장본에 남아 있는 빈 로케일 키를
* 제거한다. 구 국가 추가 폼이 한국어/영문 두 칸만 렌더했던 탓에, 한쪽을 비운 채 저장하면
* 빈 문자열이 저장본에 박혔다. 부재 로케일은 비워 두는 것이 계약이며(언어팩이 읽기 시점에
* 보강), 빈 문자열은 그 계약을 어기는 어중간한 상태였다.
*
* 국가명을 새로 채우지는 않는다 — 언어팩 보강이 그 역할을 한다.
*
* 모든 비즈니스 로직은 data/1.0.2/migrations/ 로 격리(AbstractUpgradeStep 규약).
*
* @upgrade-path B
*/
class Upgrade_1_0_2 extends AbstractUpgradeStep {}
@@ -0,0 +1,139 @@
<?php
namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_0_2\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\File;
/**
* 배송가능 국가명(available_countries[].name)의 빈 로케일 키를 제거.
*
* 구 국가 추가 폼은 한국어/영문 두 입력칸만 렌더했고, 운영자가 한쪽을 비운 채 저장하면
* `{"ko":"프랑스","en":""}` 처럼 빈 문자열이 저장본에 박혔다. 빈 문자열은 "값이 있음" 도
* "부재" 도 아닌 어중간한 상태다.
*
* 이 시스템의 계약은 "부재 로케일은 비워 둔다" 이다 — 기본 국가는 언어팩
* (`sirsoft-ecommerce::settings.countries.{code}.name`)이 읽기 시점에 보강하고
* (EcommerceSettingsService::getAllSettings), 운영자가 직접 추가한 국가는 운영자가 채운다.
* 따라서 빈 문자열 키를 제거해 "부재" 로 정규화한다.
*
* 이름을 새로 채우지는 않는다 — 언어팩 보강이 그 역할을 하며, 저장본에 값을 박으면
* 운영자 편집값으로 승격되어 이후 언어팩 갱신이 반영되지 않는다.
*
* name 이 배열이 아닌 구 스키마 잔재(문자열)는 이 마이그레이션의 책임이 아니라 건드리지 않는다.
*
* idempotent: 빈 키가 없고 모든 값이 trim 상태면 no-op (파일 쓰기 없음).
*
* V-1 안전: Illuminate\Support\Facades\File + 로컬 헬퍼만 사용.
*/
class PruneEmptyShippingCountryNameLocales implements DataMigration
{
private const MODULE_IDENTIFIER = 'sirsoft-ecommerce';
public function name(): string
{
return 'PruneEmptyShippingCountryNameLocales';
}
public function run(UpgradeContext $context): void
{
$path = $this->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<string, mixed> $name 로케일 => 국가명 맵
* @return array<string, string> 빈 로케일이 제거되고 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');
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6
View File
@@ -28,6 +28,7 @@ import { createLogger, Logger } from './utils/Logger';
import { webSocketManager } from './websocket/WebSocketManager';
import { getModuleAssetLoader, parseModuleAssetsFromConfig, parsePluginAssetsFromConfig, parseBundleUrlsFromConfig } from './modules';
import { SystemBannerManager } from './template-engine/SystemBannerManager';
import { resetLocalInitTracking } from './template-engine/localInitSlot';
/**
* DevTools 추적 - G7DevToolsCore.getInstance() 직접 호출 대신 G7Core.devTools를 사용합니다.
*/
@@ -1146,6 +1147,11 @@ export class TemplateApp {
// 다른 레이아웃으로 전환 → _local 완전 초기화 (이전 레이아웃 잔존값 제거)
logger.log('_local reset due to layout change:', { from: this.currentLayoutName, to: newLayoutName });
this.globalState._local = {};
// @since engine-v1.52.2: _localInit 추적 레지스트리도 함께 초기화.
// _local 을 비웠는데 추적 해시가 남아 있으면, 새 레이아웃의 _localInit payload 가
// 이전 레이아웃과 우연히 동일할 때 "이미 적용됨"으로 오판되어 건너뛴다.
resetLocalInitTracking();
}
this.currentLayoutName = newLayoutName;
@@ -14,6 +14,7 @@ import TemplateEngine, {
getState,
} from '../template-engine';
import { ComponentRegistry } from '../template-engine/ComponentRegistry';
import { markLocalInitConsumed, resetLocalInitTracking } from '../template-engine/localInitSlot';
import { Logger } from '../utils/Logger';
/**
@@ -45,12 +46,19 @@ vi.mock('../template-engine/ComponentRegistry', () => {
});
// DataBindingEngine mock
vi.mock('../template-engine/DataBindingEngine', () => ({
DataBindingEngine: vi.fn(function(this: any) {
// G7CoreGlobals.initCoreRuntimeExports가 싱글톤 `dataBindingEngine`도 참조하므로 함께 노출
vi.mock('../template-engine/DataBindingEngine', () => {
const DataBindingEngine = vi.fn(function(this: any) {
this.bind = vi.fn();
this.unbind = vi.fn();
}),
}));
this.invalidateCacheByKeys = vi.fn();
});
return {
DataBindingEngine,
dataBindingEngine: new (DataBindingEngine as any)(),
};
});
// TranslationEngine mock
vi.mock('../template-engine/TranslationEngine', () => {
@@ -97,10 +105,13 @@ vi.mock('../template-engine/ResponsiveManager', () => ({
getMatchingKey: vi.fn(() => null),
parseRange: vi.fn(() => null),
},
BREAKPOINT_PRESETS: {},
}));
// ResponsiveContext mock
// G7CoreGlobals.initCoreRuntimeExports가 Context 객체 자체도 참조하므로 함께 노출
vi.mock('../template-engine/ResponsiveContext', () => ({
ResponsiveContext: {},
ResponsiveProvider: ({ children }: { children: React.ReactNode }) => children,
useResponsive: vi.fn(() => ({
width: 1024,
@@ -374,6 +385,8 @@ describe('TemplateEngine - updateTemplateData()', () => {
beforeEach(async () => {
destroyTemplate();
vi.clearAllMocks();
// DynamicRenderer가 모킹되어 _localInit 슬롯을 소비하지 않으므로 테스트 간 격리 필요
resetLocalInitTracking();
document.body.innerHTML = '<div id="app"></div>';
@@ -456,6 +469,53 @@ describe('TemplateEngine - updateTemplateData()', () => {
});
});
/**
* 다중 progressive 데이터소스의 _localInit 상호 덮어쓰기 회귀
*
* 증상: initLocal을 가진 progressive 데이터소스가 한 레이아웃에 둘 이상이면,
* React commit 전에 두 번의 updateTemplateData가 연달아 들어올 때
* 나중 payload가 _localInit 슬롯을 통째로 교체하여 앞선 소스의 초기값이 유실됨.
* 해결: 아직 어떤 렌더러도 관측하지 않은(unconsumed) 슬롯은 교체하지 않고 누적 병합.
*
* @see troubleshooting-state-global.md
*/
it('소비되지 않은 _localInit 슬롯은 후속 payload와 누적 병합되어야 함', () => {
// settings 소스 응답 (refetchOnMount → _forceLocalInit 보유)
updateTemplateData({
settings: { shipping: {} },
_localInit: { form: { name: 'shipping' }, _forceLocalInit: 1700000000000 },
});
// notification 소스 응답 (같은 commit 이전에 도착)
updateTemplateData({
notificationDefinitions: { data: [] },
_localInit: { notificationDefinitionCurrentPage: 1 },
});
const state = getState();
// 먼저 도착한 settings의 form이 살아있어야 함
expect(state.currentDataContext._localInit.form).toEqual({ name: 'shipping' });
// 나중에 도착한 notification 값도 함께 있어야 함
expect(state.currentDataContext._localInit.notificationDefinitionCurrentPage).toBe(1);
// refetchOnMount 타임스탬프가 보존되어야 함
expect(state.currentDataContext._localInit._forceLocalInit).toBe(1700000000000);
});
it('이미 소비된 _localInit 슬롯은 교체되어 stale 재적용이 없어야 함', () => {
updateTemplateData({ _localInit: { form: { name: 'shipping' } } });
// 렌더러가 슬롯을 관측 (DynamicRenderer의 _localInit useEffect가 하는 일)
markLocalInitConsumed(getState().currentDataContext._localInit);
// 사용자가 화면을 쓰는 도중 다른 소스를 refetch
updateTemplateData({ _localInit: { notificationDefinitionCurrentPage: 2 } });
const state = getState();
// 소비가 끝난 form은 재적용 대상이 아니어야 함 (폼 편집 결과 되돌림 방지)
expect(state.currentDataContext._localInit.form).toBeUndefined();
expect(state.currentDataContext._localInit.notificationDefinitionCurrentPage).toBe(2);
});
it('sync 옵션으로 업데이트해도 정상 작동해야 함', () => {
updateTemplateData({ c: 5 }, { sync: true });
+10
View File
@@ -24,6 +24,7 @@ import { DataSourceManager } from './template-engine/DataSourceManager';
import { ModalDataSourceWrapper } from './template-engine/ModalDataSourceWrapper';
import { ParentContextProvider } from './template-engine/ParentContextProvider';
import { TemplateNotFoundError } from './template-engine/TemplateEngineError';
import { mergeLocalInitSlot } from './template-engine/localInitSlot';
import { ErrorDisplay } from './template-engine/ErrorDisplay';
import { TemplateApp, initTemplateApp } from './TemplateApp';
import { createLogger } from './utils/Logger';
@@ -749,11 +750,20 @@ function updateTemplateData(data: Record<string, any>, options?: UpdateOptions):
...(data._global || {}),
};
// _localInit도 얕은 스프레드로 교체하면 안 됨 (engine-v1.52.2)
// progressive 데이터소스가 둘 이상이면 각자 독립적으로 updateTemplateData를 호출하는데,
// 소비부(DynamicRenderer의 useEffect)는 React commit 이후에 실행된다.
// 두 호출이 같은 commit 사이에 들어오면 나중 payload가 슬롯을 통째로 교체하여
// 먼저 도착한 소스의 initLocal이 한 번도 관측되지 않고 사라진다.
// → 아직 관측되지 않은(unconsumed) 슬롯만 누적 병합한다. 상세: localInitSlot.ts
const mergedLocalInit = mergeLocalInitSlot(state.currentDataContext._localInit, data._localInit);
state.currentDataContext = {
...globalVariables, // 전역 변수 (낮은 우선순위)
...state.currentDataContext, // 기존 데이터
...data, // 새 데이터 (높은 우선순위)
_global: mergedGlobalState, // _global은 명시적으로 깊은 병합된 값 사용
_localInit: mergedLocalInit, // _localInit은 소비 전이면 누적 병합된 값 사용
};
// _local 및 _computed는 _global의 값을 canonical source로 동기화
@@ -5,6 +5,32 @@
>
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
## [engine-v1.52.2] - 2026-07-10
### Fixed
#### 다중 progressive 데이터소스의 `_localInit` 상호 덮어쓰기
- `template-engine.ts::updateTemplateData()` — `_localInit` 을 얕은 스프레드(`...data`)로 교체하던 것을 **소비 여부를 가드로 둔 누적 병합**으로 바꿨다. `_localInit` 소비부(`DynamicRenderer` 의 useEffect)는 React commit 이후에 실행되는데, `initLocal` 을 가진 progressive 데이터소스가 한 레이아웃에 둘 이상이면 각 소스가 응답 순서대로 독립적으로 `updateTemplateData({ _localInit })` 를 호출한다. 두 호출이 같은 commit 사이에 들어오면 나중 payload 가 슬롯을 통째로 교체해, 먼저 도착한 소스의 `initLocal` 이 **한 번도 관측되지 않은 채** 사라졌다. `_global` 은 바로 윗줄에서 깊은 병합을 받고 있었으나 `_localInit` 에는 그 처리가 없었다.
- `localInitSlot.ts`(신규) — `_localInit` 슬롯 병합과 `__g7LocalInitTracking` 레지스트리의 단일 소유자. 아직 어떤 렌더러도 관측하지 않은(unconsumed) 슬롯만 누적 병합하고, 이미 관측된 슬롯은 종전대로 교체한다. 그래야 사용자가 화면을 쓰는 도중 `refetchDataSource` 가 발생해도 소비가 끝난 과거 payload 가 재적용되어 폼 편집 결과를 되돌리지 않는다. `_forceLocalInit` 은 한쪽에만 있으면 보존하고 양쪽에 있으면 최신 타임스탬프를 취한다.
- `DynamicRenderer.tsx` — `_localInit` 관측 시 그 슬롯 참조를 `markLocalInitConsumed()` 로 기록한다. 해시 계산식을 생산부에 복제하지 않으므로 생산·소비 양쪽의 판정 기준이 어긋날 수 없다. 적용(`shouldApply`)/건너뜀(동일 해시) 여부와 무관하게 기록한다 — 건너뛴 payload 도 이미 `_local` 에 반영되어 있으므로 소비된 것이다. A(`setLocalDynamicState`)/B(`_globalSetState`) 이중 저장소 갱신 대칭은 그대로 유지한다.
- `TemplateApp.ts` — SPA 레이아웃 전환으로 `_local` 을 리셋할 때 `__g7LocalInitTracking` 도 함께 초기화한다(`resetLocalInitTracking()`). 종전에는 리셋 지점이 아예 없어, 새 레이아웃의 `_localInit` payload 가 이전 레이아웃과 우연히 같으면 "이미 적용됨" 으로 오판되어 건너뛰었다.
- 부수 수정 — `init_actions` 안의 `refetchDataSource` 가 React commit 이전에 `updateTemplateData` 를 호출하는 경우(`pendingDataUpdates` 큐 경유)에도 blocking 소스의 `initLocal` 이 조용히 유실되던 잠재 결함이 함께 닫힌다.
> 사용자 영향: `initLocal` 을 가진 데이터소스가 둘 이상인 화면에 SPA 네비게이션으로 진입할 때, 먼저 도착한 데이터소스의 초기값이 간헐적으로 비어 보이던 문제가 해소된다.
## [engine-v1.52.1] - 2026-07-10
### Fixed
#### `$localized()` 가 ActionDispatcher 경로에서 항상 ko 를 반환하던 문제
- `DataBindingEngine.evaluateExpression()` — `$localized()` 의 활성 로케일 해석이 `context.$locale || 'ko'` 로 하드코딩 폴백해, `$locale` 을 컨텍스트에 담지 않는 경로(`init_actions` / `actions` 등 ActionDispatcher 가 빌드하는 data context)에서 로케일과 무관하게 늘 ko 값을 반환했다. `$locale` 부재는 "로케일이 ko" 가 아니라 "이 경로가 로케일을 넘기지 않는다" 는 뜻이다.
- 로케일/`templateId` 해석을 `$localized` 와 `$t` 앞으로 끌어올려 공유한다. `$t` 가 engine-v1.38.2 에서 이미 갖고 있던 `window.__templateApp.getConfig()` 회수 폴백을 `$localized` 도 그대로 사용한다. 컨텍스트의 `$locale` 이 있으면 그것이 우선하고, 없을 때만 앱 설정에서 회수하며, 그마저 없으면 종전대로 ko 로 폴백한다.
- 부수 효과 — `$t` 의 로케일 회수 조건이 `!templateId` 에서 `!templateId || !locale` 로 넓어졌다. `$templateId` 는 있는데 `$locale` 만 없는 컨텍스트에서 종전에는 ko 로 번역되던 것이 이제 앱 로케일로 번역된다.
> 사용자 영향: 헤더 배송국가 셀렉터처럼 `init_actions` 파생으로 만든 다국어 목록이, 서버가 내려준 en/ja 이름 대신 한국어로 표시되던 문제가 해소된다.
## [engine-v1.52.0] - 2026-07-04
### Added
@@ -1119,13 +1119,33 @@ export class DataBindingEngine {
extendedContext.$computed = context._computed;
}
// 활성 로케일 / 템플릿 ID 해석 — $localized 와 $t 가 공유한다.
//
// @since engine-v1.38.2 ActionDispatcher 경로 fallback — bindActionsToProps
// 이후 createHandler 에서 빌드된 action data context 는 `$templateId`/`$locale`
// 을 명시적으로 포함하지 않을 수 있다. 이 경우 `window.__templateApp.getConfig()`
// 로부터 회수하여 `{{$event.target.checked ? '$t:A' : '$t:B'}}` 같은 조건부
// $t: 평가가 raw key 를 반환하던 버그를 해결한다.
//
// @since engine-v1.52.1 회수 결과를 `$localized` 도 공유한다. 이전에는 `$localized`
// 가 `$t` 보다 먼저 `context.$locale || 'ko'` 로 로케일을 확정해, ActionDispatcher
// 경로(init_actions 파생 등)에서 항상 ko 값을 반환했다. `$locale` 이 컨텍스트에
// 없는 것은 로케일이 ko 라는 뜻이 아니라 "이 경로가 로케일을 안 넘긴다"는 뜻이다.
let templateId = context.$templateId;
let resolvedLocale = context.$locale;
if (typeof window !== 'undefined' && (!templateId || !resolvedLocale)) {
const appConfig = (window as any).__templateApp?.getConfig?.();
templateId ??= appConfig?.templateId;
resolvedLocale ??= appConfig?.locale;
}
const locale = resolvedLocale || 'ko';
// $localized 헬퍼 함수 추가
// 다국어 객체에서 현재 로케일에 해당하는 값을 반환
// 사용법:
// $localized(value) - 객체면 로케일 우선순위로 반환, 문자열이면 그대로
// $localized(value, fallbackKey) - 활성 로케일 키 부재 시 $t(fallbackKey) 호출
// (settings JSON 등 시스템 카탈로그의 lang pack 보강용)
const locale = context.$locale || 'ko';
extendedContext.$localized = (value: any, fallbackKey?: string): string => {
if (value == null && !fallbackKey) {
return '';
@@ -1175,28 +1195,11 @@ export class DataBindingEngine {
// $t 헬퍼 함수 추가
// 번역 키를 사용하여 다국어 텍스트를 반환
// 사용법: $t('admin.settings.info.write_only') - 번역된 텍스트 반환
// 컨텍스트에서 번역 관련 정보 추출
//
// @since engine-v1.38.2 ActionDispatcher 경로 fallback — bindActionsToProps
// 이후 createHandler 에서 빌드된 action data context 는 `$templateId`/`$locale`
// 을 명시적으로 포함하지 않을 수 있다. 이 경우 `window.__templateApp.getConfig()`
// 로부터 회수하여 `{{$event.target.checked ? '$t:A' : '$t:B'}}` 같은 조건부
// $t: 평가가 raw key 를 반환하던 버그를 해결한다.
let templateId = context.$templateId;
let resolvedLocale = locale;
if (!templateId && typeof window !== 'undefined') {
const templateApp = (window as any).__templateApp;
const appConfig = templateApp?.getConfig?.();
if (appConfig?.templateId) {
templateId = appConfig.templateId;
if (!context.$locale && appConfig.locale) {
resolvedLocale = appConfig.locale;
}
}
}
// templateId / locale 은 위에서 해석된 값을 그대로 사용한다($localized 와 공유).
const translationContext = {
templateId: templateId || '',
locale: resolvedLocale,
locale,
};
extendedContext.$t = (key: string): string => {
if (!key || typeof key !== 'string') {
@@ -29,6 +29,7 @@ import { hasPipes } from './PipeRegistry';
import { RAW_PREFIX, RAW_MARKER_START, RAW_MARKER_END, RAW_PLACEHOLDER_MARKER, isRawWrapped, unwrapRaw, containsRawMarker, wrapRawDeep } from './rawMarkers';
import type { ConditionsProperty } from './helpers/ConditionEvaluator';
import { useTransitionState } from './TransitionContext';
import { getLocalInitTracking, markLocalInitConsumed } from './localInitSlot';
import { useResponsive } from './ResponsiveContext';
import { createLogger } from '../utils/Logger';
import { shallowObjectEqual } from '../hooks/useControllableState';
@@ -1318,11 +1319,7 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = memo(
// - extends 레이아웃에서 여러 컴포넌트가 동일한 _localInit을 받을 수 있음
// - 컴포넌트 ID 기반 추적은 각 컴포넌트가 독립적으로 처리하여 중복 적용됨
// - 데이터 해시만으로 추적하여, 같은 데이터는 어떤 컴포넌트에서든 한 번만 처리
const globalTrackingKey = '__g7LocalInitTracking';
if (!(window as any)[globalTrackingKey]) {
(window as any)[globalTrackingKey] = { hash: '', timestamp: undefined };
}
const tracking = (window as any)[globalTrackingKey] as { hash: string; timestamp?: number };
const tracking = getLocalInitTracking();
// 추적 키는 데이터 해시 + 타임스탬프 (컴포넌트 ID 제외)
// refetchOnMount 시 _forceLocalInit 타임스탬프가 변경되면 재적용됨
@@ -1398,6 +1395,11 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = memo(
// hash tracking에 의해 shouldApply가 false(skip)된 경우에도 갱신하여
// extendedDataContext useMemo가 정상 병합(deepMergeState)을 재개하도록 함
lastProcessedInitRef.current = dataContext._localInit;
// @since engine-v1.52.2: 이 슬롯을 관측했음을 생산부(updateTemplateData)에 알림.
// 생산부는 아직 관측되지 않은 슬롯만 누적 병합하고, 관측된 슬롯은 교체한다.
// skip(동일 해시)된 경우에도 그 payload는 이미 _local에 반영되어 있으므로 소비된 것이다.
markLocalInitConsumed(dataContext._localInit);
}
}, [dataContext._localInit, parentDataContext, bindingEngine]);
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { DataBindingEngine, DataBindingError } from '../DataBindingEngine';
describe('DataBindingEngine', () => {
@@ -566,19 +566,103 @@ describe('DataBindingEngine', () => {
expect(result).toBe('');
});
it('$locale 없으면 ko 기본값 사용', () => {
it('$locale 없고 앱도 미초기화면 ko 기본값 사용', () => {
const context = {
_global: {
selectedModule: {
name: { ko: '게시판 모듈', en: 'Board Module' },
},
},
// $locale 없음
// $locale 없음 — window.__templateApp 도 없는 순수 환경
};
const result = engine.evaluateExpression('$localized(_global.selectedModule.name)', context);
expect(result).toBe('게시판 모듈');
});
/**
* ActionDispatcher 경로($locale 미주입)에서의 로케일 회수.
*
* `init_actions` / `actions` 로 실행되는 표현식의 data context 는 `$locale` 을 담지
* 않는다(ActionDispatcher 가 빌드하는 컨텍스트라 렌더 컨텍스트와 다르다). 이때
* `$localized` 가 `'ko'` 로 하드코딩 폴백하면, en/ja 로케일에서도 한국어 값이 나온다.
*
* `$t` 는 engine-v1.38.2 에서 동일 함정을 `window.__templateApp.getConfig()` 회수로
* 이미 해결했다. `$localized` 도 같은 회수 경로를 공유해야 한다.
*
* 회귀 배경(#459): 헤더 배송국가 셀렉터가 `init_actions` 파생이라, 서버가 en/ja 이름을
* 내려줘도 화면에는 늘 한국어 국가명이 표시됐다.
*/
describe('$locale 미주입 시 window.__templateApp 로케일 회수 (ActionDispatcher 경로)', () => {
const name = { ko: '대한민국', en: 'South Korea', ja: '大韓民国' };
afterEach(() => {
delete (window as any).__templateApp;
});
it('앱 로케일이 en 이면 en 값을 반환한다', () => {
(window as any).__templateApp = {
getConfig: () => ({ templateId: 'sirsoft-admin_basic', locale: 'en' }),
};
// $locale 없음 — ActionDispatcher 컨텍스트 모사
const result = engine.evaluateExpression('$localized(_global.country.name)', {
_global: { country: { name } },
});
expect(result).toBe('South Korea');
});
it('앱 로케일이 ja 이면 ja 값을 반환한다', () => {
(window as any).__templateApp = {
getConfig: () => ({ templateId: 'sirsoft-basic', locale: 'ja' }),
};
const result = engine.evaluateExpression('$localized(_global.country.name)', {
_global: { country: { name } },
});
expect(result).toBe('大韓民国');
});
it('컨텍스트의 $locale 이 앱 설정보다 우선한다', () => {
(window as any).__templateApp = {
getConfig: () => ({ templateId: 'sirsoft-basic', locale: 'ja' }),
};
const result = engine.evaluateExpression('$localized(_global.country.name)', {
_global: { country: { name } },
$locale: 'en',
});
expect(result).toBe('South Korea');
});
it('map() 콜백 안에서도 회수된 로케일이 적용된다 (파생 목록 생성 패턴)', () => {
(window as any).__templateApp = {
getConfig: () => ({ templateId: 'sirsoft-admin_basic', locale: 'en' }),
};
const result = engine.evaluateExpression(
'(_global.countries ?? []).map(c => ({ code: c.code, name: $localized(c.name) }))',
{
_global: {
countries: [
{ code: 'KR', name },
{ code: 'US', name: { ko: '미국', en: 'United States', ja: 'アメリカ' } },
],
},
}
);
expect(result).toEqual([
{ code: 'KR', name: 'South Korea' },
{ code: 'US', name: 'United States' },
]);
});
it('앱 설정에 locale 이 없으면 ko 로 폴백한다', () => {
(window as any).__templateApp = {
getConfig: () => ({ templateId: 'sirsoft-basic' }),
};
const result = engine.evaluateExpression('$localized(_global.country.name)', {
_global: { country: { name } },
});
expect(result).toBe('대한민국');
});
});
it('resolveBindings에서 $localized 사용', () => {
const context = {
_global: {
@@ -12,6 +12,51 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { Logger } from '../../utils/Logger';
import { hasExplicitSetStateForField } from '../FormContext';
import { responsiveManager } from '../ResponsiveManager';
describe('트러블슈팅 회귀 테스트 - flex 압착(shrink)', () => {
/**
* 사례: 텍스트가 길어질수록 형제 버튼이 좁아지는데 오버플로 검사로는 안 잡힘
*
* 해결책이 `responsive.mobile`(0~767) 로 좁힌 2행 레이아웃에 의존한다.
* `portable`(0~1023) 이면 태블릿에서 불필요하게 줄바꿈된다.
* 여기서는 그 전제인 스코프 경계와 우선순위를 고정한다.
*
* @see docs/frontend/troubleshooting-components-misc.md "flex 압착(shrink) 관련 이슈"
*/
describe('[사례 1] responsive 스코프 경계 — mobile(0~767) vs portable(0~1023)', () => {
it('mobile 은 767px 까지만 매칭되고 768px 부터는 미적용', () => {
const responsive = { mobile: { props: { className: 'flex-wrap' } } };
expect(responsiveManager.getMatchingKey(responsive, 320)).toBe('mobile');
expect(responsiveManager.getMatchingKey(responsive, 390)).toBe('mobile');
expect(responsiveManager.getMatchingKey(responsive, 767)).toBe('mobile');
// 768px 부터 태블릿 — 한 줄 유지 (버튼 261px + 21자 제목 323px = 584px < 735px)
expect(responsiveManager.getMatchingKey(responsive, 768)).toBeNull();
expect(responsiveManager.getMatchingKey(responsive, 1280)).toBeNull();
});
it('portable 은 1023px 까지 매칭되어 태블릿도 포함한다', () => {
const responsive = { portable: { props: { className: 'flex-wrap' } } };
expect(responsiveManager.getMatchingKey(responsive, 767)).toBe('portable');
expect(responsiveManager.getMatchingKey(responsive, 768)).toBe('portable');
expect(responsiveManager.getMatchingKey(responsive, 1023)).toBe('portable');
expect(responsiveManager.getMatchingKey(responsive, 1024)).toBeNull();
});
it('mobile 과 portable 이 함께 정의되면 좁은 범위(mobile)가 우선한다', () => {
const responsive = {
portable: { props: { className: 'portable-wins' } },
mobile: { props: { className: 'mobile-wins' } },
};
expect(responsiveManager.getMatchingKey(responsive, 390)).toBe('mobile');
// 태블릿 구간에서는 mobile 이 매칭되지 않으므로 portable 로 폴백
expect(responsiveManager.getMatchingKey(responsive, 900)).toBe('portable');
});
});
});
describe('트러블슈팅 회귀 테스트 - DataGrid 컴포넌트', () => {
beforeEach(() => {
@@ -8,6 +8,13 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { ActionDispatcher } from '../ActionDispatcher';
import {
getLocalInitTracking,
isLocalInitConsumed,
markLocalInitConsumed,
mergeLocalInitSlot,
resetLocalInitTracking,
} from '../localInitSlot';
import { Logger } from '../../utils/Logger';
// AuthManager mock
@@ -580,4 +587,91 @@ describe('트러블슈팅 회귀 테스트 - 데이터소스 업데이트 및
expect(keyNoForce).toContain('no-force');
});
});
describe('[사례 6] 다중 progressive 데이터소스의 _localInit 상호 덮어쓰기 (engine-v1.52.2)', () => {
/**
* 증상: initLocal을 가진 progressive 데이터소스가 한 레이아웃에 둘 이상일 때,
* SPA 네비게이션으로 진입하면 먼저 도착한 소스의 initLocal이 간헐적으로 유실됨
* (배송설정 탭: 배송가능 국가 표가 비고 "No shipping countries registered.")
* 원인: progressive 소스는 응답이 오는 대로 각자 updateTemplateData({ _localInit })를 호출하는데,
* 소비부(DynamicRenderer의 useEffect)는 React commit 이후에 실행된다.
* 두 호출이 같은 commit 사이에 들어오면 나중 payload가 _localInit 슬롯을 통째로 교체하여
* 먼저 도착한 소스의 payload는 한 번도 관측되지 않고 사라진다.
* 해결: 아직 어떤 렌더러도 관측하지 않은(unconsumed) 슬롯만 누적 병합.
* 이미 소비된 슬롯은 교체 (refetch 시 stale 재적용으로 폼 편집이 되돌아가는 것 방지).
*
* @see resources/js/core/template-engine/localInitSlot.ts
* @see resources/js/core/template-engine.ts - updateTemplateData
*/
beforeEach(() => {
resetLocalInitTracking();
});
afterEach(() => {
resetLocalInitTracking();
});
it('소비 전 슬롯은 후속 payload와 누적 병합되어 양쪽 키가 모두 남아야 함', () => {
const settingsInit = { form: { name: 'shipping' } };
const notificationInit = { notificationDefinitionCurrentPage: 1 };
const merged = mergeLocalInitSlot(settingsInit, notificationInit) as Record<string, any>;
expect(merged.form).toEqual({ name: 'shipping' });
expect(merged.notificationDefinitionCurrentPage).toBe(1);
});
it('한쪽에만 있는 _forceLocalInit 타임스탬프가 보존되어야 함', () => {
// settings만 refetchOnMount: true → _forceLocalInit 보유
const settingsInit = { form: { name: 'shipping' }, _forceLocalInit: 1700000000000 };
const notificationInit = { notificationDefinitionCurrentPage: 1 };
const merged = mergeLocalInitSlot(settingsInit, notificationInit) as Record<string, any>;
expect(merged._forceLocalInit).toBe(1700000000000);
});
it('양쪽 모두 _forceLocalInit을 가지면 최신 타임스탬프를 취해야 함', () => {
const older = { a: 1, _forceLocalInit: 1700000000000 };
const newer = { b: 2, _forceLocalInit: 1700000001000 };
const merged = mergeLocalInitSlot(older, newer) as Record<string, any>;
expect(merged._forceLocalInit).toBe(1700000001000);
});
it('이미 소비된 슬롯은 병합하지 않고 교체해야 함 (stale 재적용 방지)', () => {
const settingsInit = { form: { name: 'shipping' } };
// 렌더러가 슬롯을 관측 (DynamicRenderer의 _localInit useEffect)
markLocalInitConsumed(settingsInit);
// 이후 refetchDataSource가 다른 소스의 initLocal을 실어 옴
const refetchInit = { notificationDefinitionCurrentPage: 2 };
const merged = mergeLocalInitSlot(settingsInit, refetchInit) as Record<string, any>;
// 소비가 끝난 form은 재적용 대상이 아님 → 사용자 폼 편집이 되돌아가지 않음
expect(merged.form).toBeUndefined();
expect(merged.notificationDefinitionCurrentPage).toBe(2);
});
it('새 payload가 없으면 기존 슬롯 참조를 그대로 유지해야 함 (소비부 effect 재발화 방지)', () => {
const slot = { form: { name: 'shipping' } };
// updateTemplateData({ someData: 1 }) 처럼 _localInit이 없는 업데이트
expect(mergeLocalInitSlot(slot, undefined)).toBe(slot);
});
it('레이아웃 전환 시 추적 레지스트리가 초기화되어야 함', () => {
const slot = { form: { name: 'shipping' } };
markLocalInitConsumed(slot);
expect(isLocalInitConsumed(slot)).toBe(true);
// TemplateApp.loadRoute의 레이아웃 전환 감지 → _local 리셋과 함께 호출
resetLocalInitTracking();
expect(isLocalInitConsumed(slot)).toBe(false);
expect(getLocalInitTracking().hash).toBe('');
});
});
});
@@ -0,0 +1,149 @@
/**
* `_localInit` 슬롯 관리 (생산부 ↔ 소비부 공용)
*
* `_localInit` 은 데이터소스의 `initLocal` 옵션이 만든 로컬 상태 초기화 payload 를
* 렌더 컨텍스트에 실어 나르는 단일 키다. 생산부(TemplateApp → updateTemplateData)와
* 소비부(DynamicRenderer 의 useEffect)가 이 키 하나를 공유한다.
*
* 문제: 소비는 React commit 이후(useEffect)에 일어난다. progressive 데이터소스가
* 둘 이상이면 각자 독립적으로 `updateTemplateData({ _localInit })` 를 호출하는데,
* 두 호출이 같은 commit 사이에 들어오면 나중 payload 가 슬롯을 통째로 교체하여
* 먼저 도착한 소스의 초기값이 **한 번도 관측되지 않은 채** 사라진다.
*
* 해결: 아직 어떤 렌더러도 관측하지 않은(unconsumed) 슬롯에 한해 누적 병합한다.
* 이미 소비된 슬롯은 지금까지처럼 교체한다 — 그래야 사용자가 화면을 쓰는 도중
* `refetchDataSource` 가 발생해도 소비가 끝난 과거 payload 가 재적용되어
* 폼 편집 결과를 되돌리는 일이 없다.
*
* 소비 여부는 소비부(DynamicRenderer)가 관측한 슬롯의 **참조**를 기록해 판정한다.
* 해시 계산식을 생산부에 복제하지 않으므로 두 곳의 판정 기준이 어긋날 수 없다.
*
* @since engine-v1.52.2
*/
/** `_localInit` 전역 추적 레지스트리 */
export interface LocalInitTracking {
/** 마지막으로 적용된 payload 의 추적 키 (`해시:_forceLocalInit`) */
hash: string;
/** 마지막으로 적용된 `_forceLocalInit` 타임스탬프 */
timestamp?: number;
/**
* 마지막으로 렌더러가 관측한 슬롯 객체의 참조.
* 생산부가 이 참조로 "소비 완료" 여부를 판정한다.
*/
consumed?: unknown;
}
/** window 전역 키 (기존 이름 유지 — 디버깅 스크립트 호환) */
const TRACKING_KEY = '__g7LocalInitTracking';
/**
* `_localInit` 전역 추적 레지스트리를 반환합니다. 없으면 생성합니다.
*
* @returns 전역 추적 레지스트리
*/
export function getLocalInitTracking(): LocalInitTracking {
const target = globalThis as Record<string, any>;
if (!target[TRACKING_KEY]) {
target[TRACKING_KEY] = { hash: '', timestamp: undefined, consumed: undefined };
}
return target[TRACKING_KEY] as LocalInitTracking;
}
/**
* 추적 레지스트리를 초기화합니다.
*
* SPA 네비게이션으로 레이아웃이 바뀌면 `_local` 이 통째로 리셋되므로
* (`TemplateApp.loadRoute` 의 레이아웃 전환 감지), 추적 해시도 함께 비워야
* 새 레이아웃의 `_localInit` 이 이전 레이아웃과 payload 가 우연히 같더라도
* 정상 적용된다.
*
* @returns void
*/
export function resetLocalInitTracking(): void {
const target = globalThis as Record<string, any>;
target[TRACKING_KEY] = { hash: '', timestamp: undefined, consumed: undefined };
}
/**
* 렌더러가 관측한 `_localInit` 슬롯을 "소비 완료"로 표시합니다.
*
* 적용(shouldApply)/건너뜀(동일 해시) 여부와 무관하게 호출한다.
* 건너뛴 경우에도 그 payload 는 이미 `_local` 에 반영되어 있으므로 소비된 것이다.
*
* @param slot 관측한 `_localInit` 슬롯 객체
* @returns void
*/
export function markLocalInitConsumed(slot: unknown): void {
getLocalInitTracking().consumed = slot;
}
/**
* 해당 슬롯이 이미 렌더러에게 관측되었는지 판정합니다.
*
* @param slot 판정할 `_localInit` 슬롯 객체
* @returns 관측 완료 시 true
*/
export function isLocalInitConsumed(slot: unknown): boolean {
return getLocalInitTracking().consumed === slot;
}
/**
* `_forceLocalInit` 타임스탬프를 병합합니다.
*
* `refetchOnMount: true` 인 소스만 이 값을 갖는다. 두 payload 를 합칠 때
* 어느 한쪽에만 있으면 그 값을 보존해야 강제 초기화가 유실되지 않는다.
*
* @param prev 기존 슬롯의 타임스탬프
* @param next 새 payload 의 타임스탬프
* @returns 병합된 타임스탬프 (양쪽 모두 없으면 undefined)
*/
function mergeForceFlag(prev: unknown, next: unknown): unknown {
if (typeof prev === 'number' && typeof next === 'number') {
return Math.max(prev, next);
}
return next ?? prev;
}
/**
* `_localInit` 슬롯에 새 payload 를 반영합니다.
*
* - 새 payload 가 없으면 기존 슬롯을 그대로 반환 (참조 동일성 유지 — 소비부 effect 재발화 방지)
* - 기존 슬롯이 없거나 이미 소비되었으면 새 payload 로 교체
* - 기존 슬롯이 아직 소비되지 않았으면 누적 병합 (나중 payload 우선)
*
* @param prev 현재 데이터 컨텍스트의 `_localInit` 슬롯
* @param next 이번 업데이트가 실어 온 `_localInit` payload
* @returns 슬롯에 담길 값
*/
export function mergeLocalInitSlot(prev: unknown, next: unknown): unknown {
if (next === undefined) {
return prev;
}
const isMergeable =
prev !== null &&
typeof prev === 'object' &&
next !== null &&
typeof next === 'object' &&
!isLocalInitConsumed(prev);
if (!isMergeable) {
return next;
}
const { _forceLocalInit: prevForce, ...prevData } = prev as Record<string, any>;
const { _forceLocalInit: nextForce, ...nextData } = next as Record<string, any>;
const merged: Record<string, any> = { ...prevData, ...nextData };
const force = mergeForceFlag(prevForce, nextForce);
if (force !== undefined) {
merged._forceLocalInit = force;
}
return merged;
}
@@ -4,6 +4,25 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.2] - 2026-07-09
### Added
- 레이아웃 편집기에서 관리자 기본 레이아웃을 편집할 때 "모바일 메뉴 펼침" 상태를 선택할 수 있습니다 — 모바일 메뉴 안의 언어·통화·배송국가 버튼을 캔버스에서 바로 편집할 수 있습니다.
### Changed
- 모바일에서 언어·통화·배송국가 선택을 관리자 메뉴 안으로 옮기고, 사용자 화면과 동일하게 가로로 나열된 버튼 형태로 통일했습니다. 상단 헤더에는 테마 전환과 알림만 남습니다.
- 모바일 메뉴 안의 언어와 통화·배송국가가 각각 접었다 펼치는 항목으로 바뀌어, 메뉴를 열었을 때 곧바로 관리자 메뉴가 보입니다. 접혀 있을 때도 현재 선택된 언어와 통화·배송국가가 표시되므로 펼치지 않아도 확인할 수 있으며, 두 항목은 서로 독립적으로 여닫힙니다.
### Fixed
- 모바일에서 상단 헤더의 통화 선택 버튼이 화면 밖으로 잘려 보이던 문제를 수정했습니다. 화면이 좁을수록 심해져 320px 기기에서는 대부분이 가려졌습니다.
- 관리자 메뉴에서 언어를 선택해도 언어가 바뀌지 않던 문제를 수정했습니다. 로그인·비밀번호 찾기·비밀번호 재설정 화면의 언어 선택도 함께 정리했습니다.
- 좁은 화면에서 팝업 창이 화면 좌우에 빈틈 없이 붙어 보이던 문제를 수정했습니다 — 이제 양옆에 여백이 생깁니다.
- 좁은 화면에서 목록 아래 페이지 번호가 한 줄에 다 들어가지 않아 화면 밖으로 넘치던 문제를 수정했습니다 — 이제 여러 줄로 나뉘어 표시됩니다.
- 좁은 화면에서 다국어 입력란의 언어 탭(한국어·English·日本語)이 한 줄에 다 들어가지 않아 넘치던 문제를 수정했습니다. 상품·게시판·페이지 등 다국어 입력이 있는 모든 화면에 적용됩니다.
## [1.0.1] - 2026-07-08
### Added
@@ -0,0 +1,77 @@
/**
* @file admin-auth-setlocale-target.test.tsx
* @description 비인증 화면(로그인·비밀번호찾기·비밀번호재설정) 언어 선택기의 setLocale 액션 형태 회귀 테스트
*
* 배경: 관리자 템플릿은 자체 `setLocaleHandler` 를 등록해 엔진 빌트인 `setLocale`(ActionDispatcher)
* 을 덮어쓰고 있었다. 그 사본에는 두 결함이 있었다.
* ① `<button>` 의 `value` 는 항상 `''` 인데 `!== undefined && !== null` 로만 걸러 빈 문자열을
* 로케일로 채택 → 이후 검증에서 조기 반환(드로어 언어 칩이 눌러도 무반응).
* ② `G7Core.config` 는 존재하지 않고, `state.get('_global.appConfig.supportedLocales')` 는
* `_global` 객체 전체를 반환해 `validLocales.includes` 가 TypeError.
*
* 조치: 사용자 템플릿(`sirsoft-basic`)과 동일하게 템플릿 사본을 제거하고 엔진 빌트인을 쓴다.
* 엔진 빌트인은 **`action.target` 만** 읽으므로(`params.locale` 은 무시), 세 화면의 Select 액션도
* `params` → `target` 으로 옮겨야 한다. `Select` 컴포넌트는 네이티브 `<select>` 가 아니라 커스텀
* 위젯이지만 `{target:{value}}` 형태의 합성 이벤트를 발생시키므로 `{{$event.target.value}}` 가
* 그대로 해석된다(브라우저 실측 확인).
*
* 이 테스트를 `params.locale` 로 되돌리면 세 화면의 언어 전환이 조용히 죽는다.
*
* @vitest-environment jsdom
*/
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
const baseDir = path.resolve(__dirname, '../..');
function loadJson(relPath: string): any {
return JSON.parse(fs.readFileSync(path.resolve(baseDir, relPath), 'utf8'));
}
/** 레이아웃 전체에서 handler === 'setLocale' 인 액션을 모두 수집한다. */
function collectSetLocaleActions(node: any, acc: any[] = []): any[] {
if (!node || typeof node !== 'object') return acc;
if (Array.isArray(node)) {
for (const n of node) collectSetLocaleActions(n, acc);
return acc;
}
for (const action of node.actions ?? []) {
if (action?.handler === 'setLocale') acc.push(action);
}
for (const k of ['children', 'components']) {
if (node[k]) collectSetLocaleActions(node[k], acc);
}
return acc;
}
const layouts: Array<[string, string]> = [
['admin_login', 'layouts/admin_login.json'],
['admin_forgot_password', 'layouts/admin_forgot_password.json'],
['admin_reset_password', 'layouts/admin_reset_password.json'],
];
describe('비인증 화면 언어 선택기 — 엔진 빌트인 setLocale 규약', () => {
it.each(layouts)('%s 의 setLocale 은 target 으로 로케일을 넘긴다', (_name, relPath) => {
const layout = loadJson(relPath);
const actions = collectSetLocaleActions(layout.components ?? layout);
expect(actions.length).toBe(1);
const [action] = actions;
expect(action.type).toBe('change');
expect(action.target).toBe('{{$event.target.value}}');
// 엔진 빌트인은 params 를 읽지 않는다. 남아 있으면 무시되어 언어 전환이 죽는다.
expect(action.params).toBeUndefined();
});
it('템플릿은 setLocale 핸들러를 자체 등록하지 않는다 (엔진 빌트인 사용)', () => {
const indexTs = fs.readFileSync(
path.resolve(baseDir, 'src/handlers/index.ts'),
'utf8',
);
expect(indexTs).not.toContain('setLocaleHandler');
expect(fs.existsSync(path.resolve(baseDir, 'src/handlers/setLocaleHandler.ts'))).toBe(false);
});
});
@@ -1,14 +1,17 @@
/**
* @file admin-base-header-lang-currency.test.tsx
* @description 관리자 헤더 언어·통화 선택기 패리티 회귀 테스트
* @description 관리자 언어·통화·배송국가 선택기 배치 회귀 테스트
*
* 배경(검수 지적):
* - 관리자 데스크톱 언어 버튼이 globe 아이콘만 표시하고 현재 선택 언어를 안 보여줌(유저는 코드 표시).
* → LanguageSelector 에 showCode 도입, _admin_base 가 showCode:true 로 사용.
* - 관리자 모바일 헤더에 언어·통화 버튼이 둘 다 없음(데스크톱에만 존재).
* → mobile_header_right 에 language_selector_mobile + header_currency_slot_mobile 추가.
* 배경 (구 정본): 4표면(유저·관리자 × 데스크톱·모바일) 모두 언어+통화가 헤더 같은 줄에 노출.
*
* 합의 정본: 4표면(유저·관리자 × 데스크톱·모바일) 모두 언어+통화가 같은 줄에 현재값과 함께 노출.
* 반전 사유 (현 정본): 모바일 헤더 우측 그룹은 `flex-shrink-0` 이라 좁아져도 폭을 유지한 채
* 화면 밖으로 밀려난다. theme(36) + noti(36) + lang(79) + 통화(112) + gap = **420px 를 요구**해
* 390px 에서 30px, 320px 에서 100px 이 잘렸다(통화 셀렉터 단독이 클립 전량의 원인 — 통화만
* 숨기면 clip 0). 따라서 언어·통화·배송국가를 모두 모바일 드로어(`admin_drawer_prefs`)로 옮겨
* 사용자 템플릿(`_user_base` 의 `mobile_drawer_prefs`)과 동일한 가로 칩 목록으로 통일했다.
*
* → 모바일 헤더에는 theme·notification 만 남는다. 이 테스트를 "패리티 회복"이라는 이유로
* 되돌리면 폰 뷰포트 클립이 재발한다. 데스크톱(≥1024px)은 기존 헤더 배치를 유지한다.
*
* @vitest-environment jsdom
*/
@@ -31,7 +34,22 @@ function find(node: any, id: string): any {
return null;
}
describe('관리자 헤더 언어·통화 패리티', () => {
/**
* 술어를 만족하는 첫 노드를 재귀 탐색합니다 (위치 인덱스/직계 자식 가정 회피).
*
* @param node 탐색 시작 노드/배열
* @param pred 노드 술어
* @return 찾은 노드 (없으면 null)
*/
function walk(node: any, pred: (n: any) => boolean): any {
if (!node || typeof node !== 'object') return null;
if (Array.isArray(node)) { for (const n of node) { const r = walk(n, pred); if (r) return r; } return null; }
if (pred(node)) return node;
for (const k of ['children', 'components']) { if (node[k]) { const r = walk(node[k], pred); if (r) return r; } }
return null;
}
describe('관리자 헤더 언어·통화 배치', () => {
const adminBase = loadJson('layouts/_admin_base.json');
it('데스크톱 언어 선택기는 showCode:true 로 현재 로케일 코드를 표시한다', () => {
@@ -41,25 +59,115 @@ describe('관리자 헤더 언어·통화 패리티', () => {
expect(node.props?.showCode).toBe(true);
});
it('모바일 헤더에 언어 선택기 + 통화 슬롯이 같은 줄에 노출된다', () => {
const mobileRight = find(adminBase.components, 'mobile_header_right');
expect(mobileRight).toBeTruthy();
const ids = (mobileRight.children ?? []).map((c: any) => c.id);
expect(ids).toContain('language_selector_mobile');
expect(ids).toContain('header_currency_slot_mobile');
});
it('모바일 언어 선택기도 showCode:true (데스크톱과 동일 표기)', () => {
const node = find(adminBase.components, 'language_selector_mobile');
expect(node).toBeTruthy();
expect(node.name).toBe('LanguageSelector');
expect(node.props?.showCode).toBe(true);
});
it('모바일 통화 슬롯은 데스크톱과 동일한 header_currency 슬롯을 공유한다', () => {
const node = find(adminBase.components, 'header_currency_slot_mobile');
it('데스크톱 헤더는 통화 슬롯을 계속 보유한다 (데스크톱 회귀 방지)', () => {
const node = find(adminBase.components, 'header_currency_slot_desktop');
expect(node).toBeTruthy();
expect(node.name).toBe('SlotContainer');
expect(node.props?.slotId).toBe('header_currency');
});
it('모바일 헤더 우측에는 언어·통화가 없다 (420px 요구 → 폰에서 클립되던 원인 제거)', () => {
const mobileRight = find(adminBase.components, 'mobile_header_right');
expect(mobileRight).toBeTruthy();
const ids = (mobileRight.children ?? []).map((c: any) => c.id);
expect(ids).not.toContain('language_selector_mobile');
expect(ids).not.toContain('header_currency_slot_mobile');
// 남아야 하는 것: 테마 토글 + 알림
expect(ids).toContain('theme_toggle_mobile');
expect(ids).toContain('notification_center_mobile');
});
it('모바일 드로어 선호설정 블록은 portable 에서만 렌더된다', () => {
const prefs = find(adminBase.components, 'admin_drawer_prefs');
expect(prefs).toBeTruthy();
// 데스크톱에서는 미렌더 (드로어에 통화가 중복 노출되면 회귀)
expect(prefs.if).toBe('{{false}}');
expect(prefs.responsive?.portable?.if).toBe('{{true}}');
});
it('드로어 언어는 가로 칩이며 iteration 은 Button 자신에 걸린다', () => {
const lang = find(adminBase.components, 'admin_drawer_language');
expect(lang).toBeTruthy();
expect(lang.if).toBe('{{$locales.length > 1}}');
// 정적 flex-wrap 래퍼 + iteration 을 가진 Button 자식.
// 래퍼는 아코디언 펼침 영역 안에 있으므로 직계 자식이 아니라 재귀로 찾는다.
const wrapper = walk(lang,
(c: any) => typeof c.props?.className === 'string' && c.props.className.includes('flex-wrap'),
);
expect(wrapper).toBeTruthy();
const chip = (wrapper.children ?? [])[0];
expect(chip?.name).toBe('Button');
// 엔진은 iteration 을 가진 노드를 복제한다. flex 컨테이너에 걸면 컨테이너가 복제되어
// 칩이 세로로 쌓이므로, 반드시 리프(Button)에 건다.
expect(chip?.iteration?.item_var).toBe('loc');
expect(wrapper.iteration).toBeUndefined();
});
it('드로어 언어 칩은 엔진 빌트인 setLocale 을 action.target 으로 호출한다', () => {
const lang = find(adminBase.components, 'admin_drawer_language');
const wrapper = walk(lang,
(c: any) => typeof c.props?.className === 'string' && c.props.className.includes('flex-wrap'),
);
const chip = (wrapper.children ?? [])[0];
const action = (chip.actions ?? [])[0];
expect(action.handler).toBe('setLocale');
// 엔진 빌트인(ActionDispatcher)은 action.target 만 읽는다. params.locale 로 주면 무시된다.
expect(action.target).toBe('{{loc}}');
expect(action.params).toBeUndefined();
});
/**
* 접기/펼치기 — 언어·통화·배송국가를 전부 펼쳐 두면 드로어가 세로로 길어져 정작 메뉴가
* 스크롤 밖으로 밀린다. 언어와 통화/배송국가는 각각 독립 아코디언이며 기본 접힘이고,
* 접힘 상태에서도 트리거에 현재 선택값을 요약 표기한다.
*
* 언어 = 템플릿 소유(`_global.mobileLanguageOpen`),
* 통화/배송국가 = 이커머스 주입 조각 소유(`_local.showCurrencyDropdown`) → 서로 독립.
*/
it('드로어 언어 섹션은 기본 접힘 + 트리거에 현재 언어를 요약 표기한다', () => {
const lang = find(adminBase.components, 'admin_drawer_language');
const toggle = find(lang, 'admin_drawer_language_toggle');
expect(toggle?.name).toBe('Button');
expect(toggle.props['aria-expanded']).toBe('{{_global.mobileLanguageOpen ?? false}}');
const act = toggle.actions[0];
expect(act.handler).toBe('setState');
expect(act.params.target).toBe('global');
expect(act.params.mobileLanguageOpen).toBe('{{!_global.mobileLanguageOpen}}');
// 접힘 상태 요약 = 현재 언어명 + chevron
const summary = JSON.stringify(toggle.children);
expect(summary).toContain('localeNames?.[$locale]');
expect(summary).toContain('chevron-down');
expect(summary).toContain("{{_global.mobileLanguageOpen ? 'rotate-180' : ''}}");
// 펼침 영역: falsy(기본) 분기가 닫힘(max-h-0)이어야 기본 접힘이 성립한다
const body = walk(lang, (n: any) =>
typeof n.props?.className === 'string' &&
n.props.className.includes('overflow-hidden') &&
n.props.className.includes('mobileLanguageOpen'),
);
expect(body).toBeTruthy();
expect(body.props.className).toContain("'max-h-screen opacity-100 mt-2' : 'max-h-0 opacity-0 mt-0'");
});
it('언어 토글은 통화 플래그를 건드리지 않는다 (독립 개폐)', () => {
const toggle = find(adminBase.components, 'admin_drawer_language_toggle');
expect(toggle).toBeTruthy();
expect(JSON.stringify(toggle)).not.toContain('showCurrencyDropdown');
});
it('드로어 통화 슬롯은 데스크톱과 동일한 header_currency 슬롯을 공유하고 고유 id 를 갖는다', () => {
const node = find(adminBase.components, 'admin_drawer_currency_wrap');
expect(node).toBeTruthy();
expect(node.name).toBe('SlotContainer');
expect(node.props?.slotId).toBe('header_currency');
// SlotContainer 는 주입 root id 를 `{id}__{containerId}` 로 스코프하므로 id 가 필수다.
expect(node.id).toBe('admin_drawer_currency_wrap');
// 헤더의 `flex items-center` 를 복사하면 주입 루트가 콘텐츠 폭으로 줄어든다.
expect(node.props?.className).toBe('block');
});
});
@@ -0,0 +1,59 @@
/**
* @file admin-mobile-viewport-css.test.tsx
* @description 관리자 모바일 뷰포트 넘침 회귀 테스트 (템플릿 소유분: CSS + 컴포넌트)
*
* 브라우저 실측(390px / 320px)으로 확인된 결함에 1:1 대응한다.
*
* jsdom 은 레이아웃을 계산하지 않으므로 폭 자체는 검증할 수 없다.
* 여기서는 "규칙/클래스가 선언되어 있는가" 만 단언하고, 실제 폭은 Playwright E2E
* (tests/Playwright/specs/admin/mobile-viewport-overflow.spec.ts) 가 검증한다.
*
* @vitest-environment jsdom
*/
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
const baseDir = path.resolve(__dirname, '../..');
const read = (rel: string) => fs.readFileSync(path.resolve(baseDir, rel), 'utf8');
describe('B1 — .modal-container 뷰포트 clamp', () => {
const css = read('src/styles/main.css');
it('max-w-[calc(100vw-2rem)] 로 좌우 16px 여백을 확보한다', () => {
const rule = css.match(/\.modal-container\s*\{[\s\S]*?\}/)?.[0] ?? '';
expect(rule).toContain('max-w-[calc(100vw-2rem)]');
});
it('선언 순서 트랩 경고가 주석으로 남아 있다', () => {
// .max-w-full 은 명시도가 같고(0,1,0) 빌드 CSS 에서 뒤에 정의되어 이 clamp 를 이긴다.
// 이 사실을 모르면 레이아웃에 max-w-full 을 붙여 clamp 를 무효화하기 쉽다.
expect(css).toContain('max-w-full');
expect(css).toMatch(/명시도|승리/);
});
});
describe('C4 — .pagination 줄바꿈', () => {
const css = read('src/styles/main.css');
it('flex-wrap 이 있어 페이지 수가 많아도 넘치지 않는다', () => {
const rule = css.match(/\.pagination\s*\{[\s\S]*?\}/)?.[0] ?? '';
expect(rule).toContain('flex-wrap');
// 공통 컴포넌트라 이 한 줄로 주문상세(27px)·상품수정(29px) 두 지점이 동시에 해소된다.
expect(rule).toContain('justify-center');
});
});
describe('C1 — 다국어 로케일 탭 줄바꿈', () => {
it.each([
['MultilingualInput.tsx', 'src/components/composite/MultilingualInput.tsx'],
['HtmlEditor.tsx', 'src/components/composite/HtmlEditor.tsx'],
])('%s 의 로케일 탭 행이 flex-wrap 을 갖는다', (_name, rel) => {
const src = read(rel);
// 로케일 3개 이상(ko/en/ja) + md 미만 뷰포트에서 탭 행이 컨테이너를 넘친다.
// 이 두 컴포넌트는 40개 레이아웃에서 쓰이므로 여기서 고치면 전 화면이 해소된다.
expect(src).toContain('flex flex-wrap items-center gap-2 mb-3');
expect(src).not.toContain('"flex items-center gap-2 mb-3"');
});
});
@@ -0,0 +1,67 @@
/**
* @file editor-spec-sidebar-open-state.test.tsx
* @description 편집기 캔버스에서 모바일 드로어를 펼쳐 편집할 수 있는지 (editor-spec states)
*
* 배경: 언어·통화·배송국가 칩은 모바일 드로어(`admin_drawer_prefs`) 안에 있고, 드로어는
* `_global.sidebarOpen` 으로 열린다. 편집기 캔버스는 정적 시뮬레이션이라 햄버거 클릭이 없으므로,
* `sidebarOpen` 을 페이지 상태로 주입하지 않으면 드로어 내부를 영영 편집할 수 없다.
*
* `_admin_base` 는 라우트가 아니라 베이스 레이아웃이므로 `scope.kind: 'base'` 로 매칭해야 한다
* (`LayoutEditorChrome` 이 `__base__/{layoutName}` → `{kind:'base', match:layoutName}` 로 도출).
* 사용자 템플릿 `_user_base` 의 `mobile_menu_open` 과 동형.
*
* @vitest-environment jsdom
*/
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
const baseDir = path.resolve(__dirname, '../..');
const states = JSON.parse(
fs.readFileSync(path.resolve(baseDir, 'editor-spec/states.json'), 'utf8'),
);
/** `matchStateItems` 와 동일한 매칭 규칙 (kind + match 완전 일치). */
function matchGroup(kind: string, match: string) {
return (states.groups ?? []).filter(
(g: any) => g.scope?.kind === kind && g.scope?.match === match,
);
}
describe('editor-spec states — _admin_base 드로어 상태', () => {
it('_admin_base 에 base scope 상태 그룹이 있다', () => {
const groups = matchGroup('base', '_admin_base');
expect(groups).toHaveLength(1);
});
it('sidebar_open 상태가 _global.sidebarOpen 을 true 로 주입한다', () => {
const [group] = matchGroup('base', '_admin_base');
const item = (group.items ?? []).find((i: any) => i.id === 'sidebar_open');
expect(item).toBeTruthy();
// 드로어 게이트는 `_admin_base.json` 의 `left_sidebar_area_portable_open` → `_global.sidebarOpen`.
expect(item.initialState?.global?.sidebarOpen).toBe(true);
});
it('기본 상태(default)가 존재해 드로어 닫힌 화면도 미리본다', () => {
const [group] = matchGroup('base', '_admin_base');
const def = (group.items ?? []).find((i: any) => i.default === true);
expect(def?.id).toBe('default');
// 기본 상태는 sidebarOpen 을 주입하지 않는다(닫힌 드로어).
expect(def.initialState?.global?.sidebarOpen).toBeUndefined();
});
it('모든 상태 라벨이 $t: 다국어 키이고 ko/en 에 정의되어 있다', () => {
const [group] = matchGroup('base', '_admin_base');
const ko = JSON.parse(fs.readFileSync(path.resolve(baseDir, 'lang/partial/ko/editor.json'), 'utf8'));
const en = JSON.parse(fs.readFileSync(path.resolve(baseDir, 'lang/partial/en/editor.json'), 'utf8'));
for (const item of group.items ?? []) {
expect(item.label).toMatch(/^\$t:editor\.state\./);
const key = item.label.replace('$t:editor.state.', '');
expect(ko.state?.[key], `ko editor.state.${key}`).toBeTruthy();
expect(en.state?.[key], `en editor.state.${key}`).toBeTruthy();
}
});
});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,3 @@
import { setLocaleHandler } from './setLocaleHandler';
import { setThemeHandler, initThemeHandler } from './setThemeHandler';
import { scrollToSectionHandler } from './scrollToSectionHandler';
import { initMenuFromUrlHandler } from './initMenuFromUrlHandler';
@@ -16,7 +15,6 @@ import { downloadAttachmentHandler } from './downloadAttachment';
* 새로운 핸들러 추가 시 여기에만 등록하면 자동으로 ActionDispatcher에 등록됩니다.
*/
export declare const handlerMap: {
readonly setLocale: typeof setLocaleHandler;
readonly setTheme: typeof setThemeHandler;
readonly initTheme: typeof initThemeHandler;
readonly scrollToSection: typeof scrollToSectionHandler;
@@ -1,16 +0,0 @@
/**
* setLocale 핸들러
*
* 사용자가 선택한 언어로 변경합니다.
* localStorage에 g7_locale 값을 저장하고 페이지를 새로고침합니다.
*/
/**
* 언어 변경 핸들러
*
* ActionDispatcher는 handler(action, context) 형태로 호출합니다.
* context 객체에는 { data, event, props, state, setState, navigate } 등이 포함됩니다.
*
* @param action 액션 정의
* @param context 액션 컨텍스트 (ActionContext 타입)
*/
export declare function setLocaleHandler(action: any, context?: any): Promise<void>;
@@ -1,6 +1,29 @@
{
"comment": "admin 목록/폼 상태 세트. with_data/empty_data/loading + edit_existing/validation_failed/saving.",
"groups": [
{
"comment": "베이스 레이아웃 _admin_base. 모바일 드로어(left_sidebar_area)는 _global.sidebarOpen 으로 열리고, 그 안의 언어/통화/배송국가 칩(admin_drawer_prefs)은 responsive.portable 게이트라 디바이스=모바일 + 드로어 열림 상태에서만 캔버스에 보인다. 편집기 캔버스는 정적 시뮬레이션이라 햄버거 클릭이 없으므로 sidebarOpen 을 상태로 주입해야 드로어 내부를 편집할 수 있다.",
"scope": {
"kind": "base",
"match": "_admin_base"
},
"items": [
{
"id": "default",
"label": "$t:editor.state.admin_base_default",
"default": true
},
{
"id": "sidebar_open",
"label": "$t:editor.state.sidebar_open",
"initialState": {
"global": {
"sidebarOpen": true
}
}
}
]
},
{
"scope": {
"kind": "route",
@@ -1408,6 +1408,8 @@
"param_field_b": "Second value path"
},
"state": {
"admin_base_default": "Default",
"sidebar_open": "Mobile menu open",
"with_data": "Has data",
"empty_data": "No data",
"edit_existing": "Edit existing",
@@ -1408,6 +1408,8 @@
"param_field_b": "두 번째 값 경로"
},
"state": {
"admin_base_default": "기본",
"sidebar_open": "모바일 메뉴 펼침",
"with_data": "데이터 있음",
"empty_data": "데이터 없음",
"edit_existing": "기존 수정",
@@ -241,6 +241,135 @@
}
]
},
{
"comment": "언어/통화/배송국가 선택 — 모바일 전용. 헤더 우측 그룹에서 이동(모바일 헤더가 420px 를 요구해 폰 뷰포트에서 통화 선택기가 화면 밖으로 잘렸다). 사용자 템플릿 _user_base 의 mobile_drawer_prefs 와 동일하게 셋 다 가로 나열 칩으로 통일. 통화/배송국가는 이커머스 모듈이 'header_currency' 슬롯에 주입하므로 SlotContainer 만 배치(모듈 비활성 시 자동 미렌더). 스크롤 영역(admin_sidebar_menu) 바깥 최상단에 둔다.",
"id": "admin_drawer_prefs",
"type": "basic",
"name": "Div",
"if": "{{false}}",
"responsive": {
"portable": {
"if": "{{true}}"
}
},
"props": {
"className": "px-4 py-3 border-b border-gray-200 dark:border-gray-700 space-y-3"
},
"children": [
{
"comment": "언어 선택 (여러 언어 지원 시) — 헤더 LanguageSelector 드롭다운에서 가로 칩으로 전환. 접기/펼치기: 접힘이 기본이고 트리거에 현재 언어를 요약 표기한다. 통화/배송국가(이커머스 주입 슬롯)와는 독립된 토글(_global.mobileLanguageOpen)이라 서로 영향 없음.",
"id": "admin_drawer_language",
"type": "basic",
"name": "Div",
"if": "{{$locales.length > 1}}",
"children": [
{
"comment": "트리거 — 좌측 '언어' 라벨, 우측 현재 언어명 + chevron. 접힘 상태에서도 현재 값을 알 수 있어야 하므로 요약을 항상 노출한다.",
"id": "admin_drawer_language_toggle",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "w-full flex items-center justify-between py-1 cursor-pointer",
"aria-expanded": "{{_global.mobileLanguageOpen ?? false}}"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "global",
"mobileLanguageOpen": "{{!_global.mobileLanguageOpen}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"
},
"text": "$t:admin.language"
},
{
"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.appConfig?.localeNames?.[$locale] ?? $locale}}"
},
{
"type": "basic",
"name": "Icon",
"props": {
"name": "chevron-down",
"className": "{{_global.mobileLanguageOpen ? 'rotate-180' : ''}} text-xs text-gray-400 dark:text-gray-500 transition-transform duration-200"
}
}
]
}
]
},
{
"comment": "펼침 영역 — max-h/opacity 트랜지션. overflow-hidden 이라 접힘 시 칩이 잘려 사라진다.",
"type": "basic",
"name": "Div",
"props": {
"className": "overflow-hidden transition-all duration-300 ease-in-out {{_global.mobileLanguageOpen ? 'max-h-screen opacity-100 mt-2' : 'max-h-0 opacity-0 mt-0'}}"
},
"children": [
{
"comment": "가로 나열 칩. iteration 은 반드시 Button 자신에 건다 — 엔진은 iteration 을 가진 노드를 항목 수만큼 복제하므로, flex 컨테이너에 걸면 컨테이너가 복제되어 칩이 세로로 쌓인다.",
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-wrap gap-2"
},
"children": [
{
"type": "basic",
"name": "Button",
"iteration": { "source": "{{$locales}}", "item_var": "loc" },
"props": {
"type": "button",
"className": "{{loc === $locale ? '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'}} inline-flex items-center gap-1.5 px-3 py-1.5 text-sm border rounded-full cursor-pointer whitespace-nowrap",
"role": "option",
"aria-selected": "{{loc === $locale}}"
},
"actions": [
{ "type": "click", "handler": "setLocale", "target": "{{loc}}" }
],
"children": [
{ "type": "basic", "name": "Icon", "props": { "name": "globe", "size": "sm" } },
{ "type": "basic", "name": "Span", "text": "{{_global.appConfig?.localeNames?.[loc] ?? loc}}" }
]
}
]
}
]
}
]
},
{
"comment": "통화/배송국가 선택 — 이커머스 모듈 주입 슬롯. id 필수: SlotContainer 는 주입 컴포넌트 root id 를 {id}__{containerId} 로 스코프한다. className 은 block(헤더의 flex items-center 를 쓰면 주입 루트가 콘텐츠 폭으로 줄어든다). 주입 루트가 portable 에서 스스로 w-full 을 갖는다.",
"id": "admin_drawer_currency_wrap",
"type": "composite",
"name": "SlotContainer",
"props": {
"slotId": "header_currency",
"className": "block"
}
}
]
},
{
"id": "admin_sidebar_menu",
"type": "basic",
@@ -1117,27 +1246,6 @@
}
}
]
},
{
"comment": "언어 선택(모바일) — 데스크톱과 동일하게 헤더에 노출(유저 템플릿 모바일과 패리티). showCode 로 현재 로케일 코드 표시.",
"id": "language_selector_mobile",
"type": "composite",
"name": "LanguageSelector",
"props": {
"availableLocales": "{{_global.appConfig.supportedLocales ?? $locales}}",
"languageText": "$t:admin.language",
"showCode": true
}
},
{
"comment": "통화 선택(모바일) — 이커머스 모듈이 'header_currency' 슬롯에 주입. 데스크톱 SlotContainer 와 동일 슬롯 공유(언어와 같은 줄, 유저 모바일과 패리티). 모듈 비활성 시 빈 슬롯.",
"id": "header_currency_slot_mobile",
"type": "composite",
"name": "SlotContainer",
"props": {
"slotId": "header_currency",
"className": "flex items-center"
}
}
]
}
@@ -336,9 +336,7 @@
{
"type": "change",
"handler": "setLocale",
"params": {
"locale": "{{$event.target.value}}"
}
"target": "{{$event.target.value}}"
}
]
}
@@ -380,9 +380,7 @@
{
"type": "change",
"handler": "setLocale",
"params": {
"locale": "{{$event.target.value}}"
}
"target": "{{$event.target.value}}"
}
]
}
@@ -478,9 +478,7 @@
{
"type": "change",
"handler": "setLocale",
"params": {
"locale": "{{$event.target.value}}"
}
"target": "{{$event.target.value}}"
}
]
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sirsoft-admin_basic",
"version": "1.0.1",
"version": "1.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sirsoft-admin_basic",
"version": "1.0.1",
"version": "1.0.2",
"license": "MIT",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -1,6 +1,6 @@
{
"name": "sirsoft-admin_basic",
"version": "1.0.1",
"version": "1.0.2",
"description": "Gnuboard7 Basic Admin Template Components",
"type": "module",
"main": "dist/components.js",
@@ -297,8 +297,9 @@ export const HtmlEditor: React.FC<HtmlEditorProps> = ({
}, [localMultilingualContent]);
// 다국어 탭 렌더링
// flex-wrap: 로케일이 3개 이상이면 md 미만 뷰포트에서 탭 행이 컨테이너를 넘친다.
const renderLocaleTabs = () => (
<Div className="flex items-center gap-2 mb-3">
<Div className="flex flex-wrap items-center gap-2 mb-3">
{supportedLocales.map(localeCode => {
const isDefault = localeCode === defaultLocale;
const isActive = currentLocale === localeCode;
@@ -485,7 +485,8 @@ export const MultilingualInput: React.FC<MultilingualInputProps> = ({
const renderTabsLayout = () => (
<Div className={className}>
{/* 언어 탭들 + 추가 버튼 (Pill Badge 스타일) */}
<Div className="flex items-center gap-2 mb-3">
{/* flex-wrap: 로케일이 3개 이상이면 md 미만 뷰포트에서 탭 행이 컨테이너를 넘친다. */}
<Div className="flex flex-wrap items-center gap-2 mb-3">
{activeLocales.map(localeCode => (
<Button
key={localeCode}
@@ -1,286 +0,0 @@
/**
* setLocaleHandler 테스트
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { setLocaleHandler } from '../setLocaleHandler';
describe('setLocaleHandler', () => {
// localStorage mock
let localStorageMock: { [key: string]: string } = {};
let setItemSpy: ReturnType<typeof vi.spyOn>;
let reloadSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
// localStorage mock 설정
localStorageMock = {};
vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key: string) => localStorageMock[key] || null);
setItemSpy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation((key: string, value: string) => {
localStorageMock[key] = value;
});
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation((key: string) => {
delete localStorageMock[key];
});
// window.location.reload mock
reloadSpy = vi.fn();
delete (window as any).location;
(window as any).location = { reload: reloadSpy };
// console 스파이 설정
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.clearAllMocks();
});
describe('정상 동작', () => {
it('이벤트 데이터에서 locale 값을 추출하여 localStorage에 저장하고 페이지를 리로드해야 함 (ko)', async () => {
const action = {
type: 'change',
handler: 'setLocale',
target: '{{event.target.value}}',
};
const context = {
event: {
target: { value: 'ko' },
},
};
await setLocaleHandler(action, context);
expect(setItemSpy).toHaveBeenCalledWith('g7_locale', 'ko');
expect(localStorageMock['g7_locale']).toBe('ko');
expect(reloadSpy).toHaveBeenCalled();
});
it('이벤트 데이터에서 locale 값을 추출하여 localStorage에 저장하고 페이지를 리로드해야 함 (en)', async () => {
const action = {
type: 'change',
handler: 'setLocale',
target: '{{event.target.value}}',
};
const context = {
event: {
target: { value: 'en' },
},
};
await setLocaleHandler(action, context);
expect(setItemSpy).toHaveBeenCalledWith('g7_locale', 'en');
expect(localStorageMock['g7_locale']).toBe('en');
expect(reloadSpy).toHaveBeenCalled();
});
it('React Select 형태의 이벤트 데이터를 처리해야 함', async () => {
const action = {
type: 'change',
handler: 'setLocale',
target: '{{event.target.value}}',
};
const context = {
event: {
value: 'ko', // React Select는 value를 직접 전달
},
};
await setLocaleHandler(action, context);
expect(setItemSpy).toHaveBeenCalledWith('g7_locale', 'ko');
expect(localStorageMock['g7_locale']).toBe('ko');
expect(reloadSpy).toHaveBeenCalled();
});
it('action.target에 직접 locale이 지정된 경우 처리해야 함', async () => {
const action = {
type: 'change',
handler: 'setLocale',
target: 'ko', // 템플릿 문자열이 아닌 직접 값
};
await setLocaleHandler(action, undefined);
expect(setItemSpy).toHaveBeenCalledWith('g7_locale', 'ko');
expect(localStorageMock['g7_locale']).toBe('ko');
expect(reloadSpy).toHaveBeenCalled();
});
});
describe('예외 처리', () => {
it('지원하지 않는 locale은 경고를 출력하고 localStorage에 저장하지 않아야 함', async () => {
const action = {
type: 'change',
handler: 'setLocale',
target: '{{event.target.value}}',
};
const context = {
event: {
target: { value: 'fr' }, // 지원하지 않는 언어
},
};
await setLocaleHandler(action, context);
expect(console.warn).toHaveBeenCalledWith('[Handler:SetLocale]', 'Unsupported locale:', 'fr');
expect(setItemSpy).not.toHaveBeenCalled();
expect(reloadSpy).not.toHaveBeenCalled();
});
it('locale 값이 없으면 경고를 출력하고 localStorage에 저장하지 않아야 함', async () => {
const action = {
type: 'change',
handler: 'setLocale',
target: '{{event.target.value}}',
};
const context = {
event: {
target: { value: undefined },
},
};
await setLocaleHandler(action, context);
expect(console.warn).toHaveBeenCalledWith('[Handler:SetLocale]', 'Invalid locale:', undefined);
expect(setItemSpy).not.toHaveBeenCalled();
expect(reloadSpy).not.toHaveBeenCalled();
});
it('템플릿 문자열이 그대로 전달되면 경고를 출력하고 localStorage에 저장하지 않아야 함', async () => {
const action = {
type: 'change',
handler: 'setLocale',
target: '{{event.target.value}}', // 템플릿 문자열 그대로
};
// 이벤트 데이터 없음
await setLocaleHandler(action, undefined);
expect(console.warn).toHaveBeenCalledWith('[Handler:SetLocale]', 'Invalid locale:', undefined);
expect(setItemSpy).not.toHaveBeenCalled();
expect(reloadSpy).not.toHaveBeenCalled();
});
it('locale 값이 문자열이 아니면 경고를 출력하고 localStorage에 저장하지 않아야 함', async () => {
const action = {
type: 'change',
handler: 'setLocale',
target: '{{event.target.value}}',
};
const context = {
event: {
target: { value: 123 }, // 숫자
},
};
await setLocaleHandler(action, context);
expect(console.warn).toHaveBeenCalledWith('[Handler:SetLocale]', 'Invalid locale:', 123);
expect(setItemSpy).not.toHaveBeenCalled();
expect(reloadSpy).not.toHaveBeenCalled();
});
it('빈 문자열은 경고를 출력하고 localStorage에 저장하지 않아야 함', async () => {
const action = {
type: 'change',
handler: 'setLocale',
target: '{{event.target.value}}',
};
const context = {
event: {
target: { value: '' },
},
};
await setLocaleHandler(action, context);
expect(console.warn).toHaveBeenCalledWith('[Handler:SetLocale]', 'Invalid locale:', '');
expect(setItemSpy).not.toHaveBeenCalled();
expect(reloadSpy).not.toHaveBeenCalled();
});
it('localStorage 저장 실패 시 오류를 출력하고 리로드하지 않아야 함', async () => {
// localStorage.setItem이 실패하도록 모의
const setItemError = new Error('Storage quota exceeded');
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw setItemError;
});
const action = {
type: 'change',
handler: 'setLocale',
target: '{{event.target.value}}',
};
const context = {
event: {
target: { value: 'ko' },
},
};
await setLocaleHandler(action, context);
expect(console.error).toHaveBeenCalledWith(
'[Handler:SetLocale]',
'Failed to save locale to localStorage:',
setItemError
);
expect(window.location.reload).not.toHaveBeenCalled();
});
});
describe('다양한 이벤트 데이터 형식', () => {
it('context.event.target.value가 우선순위가 가장 높아야 함', async () => {
const action = {
type: 'change',
handler: 'setLocale',
target: 'en', // 직접 값 지정
};
const context = {
event: {
target: { value: 'ko' }, // 이벤트 데이터 우선
value: 'en', // React Select 형식도 있음
},
};
await setLocaleHandler(action, context);
// context.event.target.value가 우선되어야 함
expect(setItemSpy).toHaveBeenCalledWith('g7_locale', 'ko');
expect(localStorageMock['g7_locale']).toBe('ko');
expect(reloadSpy).toHaveBeenCalled();
});
it('context.event.value가 두 번째 우선순위를 가져야 함', async () => {
const action = {
type: 'change',
handler: 'setLocale',
target: 'en', // 직접 값 지정
};
const context = {
event: {
value: 'ko', // React Select 형식
},
};
await setLocaleHandler(action, context);
// context.event.value가 action.target보다 우선되어야 함
expect(setItemSpy).toHaveBeenCalledWith('g7_locale', 'ko');
expect(localStorageMock['g7_locale']).toBe('ko');
expect(reloadSpy).toHaveBeenCalled();
});
});
});
@@ -4,7 +4,8 @@
* 템플릿에서 사용하는 모든 커스텀 핸들러를 정의합니다.
*/
import { setLocaleHandler } from './setLocaleHandler';
// 언어 관련 핸들러는 엔진 레벨(ActionDispatcher)에서 처리
// setLocale 핸들러는 ActionDispatcher에 빌트인으로 등록되어 있음
import { setThemeHandler, initThemeHandler } from './setThemeHandler';
import { scrollToSectionHandler } from './scrollToSectionHandler';
import { initMenuFromUrlHandler } from './initMenuFromUrlHandler';
@@ -33,7 +34,7 @@ import { downloadAttachmentHandler } from './downloadAttachment';
* 새로운 핸들러 추가 시 여기에만 등록하면 자동으로 ActionDispatcher에 등록됩니다.
*/
export const handlerMap = {
setLocale: setLocaleHandler,
// 언어: setLocale은 엔진 레벨(ActionDispatcher)에서 빌트인으로 처리
setTheme: setThemeHandler,
initTheme: initThemeHandler,
scrollToSection: scrollToSectionHandler,
@@ -1,70 +0,0 @@
/**
* setLocale 핸들러
*
* 사용자가 선택한 언어로 변경합니다.
* localStorage에 g7_locale 값을 저장하고 페이지를 새로고침합니다.
*/
// Logger 설정 (G7Core 초기화 전에도 동작하도록 폴백 포함)
const logger = ((window as any).G7Core?.createLogger?.('Handler:SetLocale')) ?? {
log: (...args: unknown[]) => console.log('[Handler:SetLocale]', ...args),
warn: (...args: unknown[]) => console.warn('[Handler:SetLocale]', ...args),
error: (...args: unknown[]) => console.error('[Handler:SetLocale]', ...args),
};
/**
* 언어 변경 핸들러
*
* ActionDispatcher는 handler(action, context) 형태로 호출합니다.
* context 객체에는 { data, event, props, state, setState, navigate } 등이 포함됩니다.
*
* @param action 액션 정의
* @param context 액션 컨텍스트 (ActionContext 타입)
*/
export async function setLocaleHandler(
action: any,
context?: any
): Promise<void> {
// 1. 이벤트 데이터에서 locale 값 추출
let locale: string | undefined;
const eventData = context?.event;
if (eventData?.target?.value !== undefined && eventData.target.value !== null) {
// HTML Select onChange 이벤트
locale = eventData.target.value;
} else if (eventData?.value !== undefined && eventData.value !== null) {
// React Select 등 커스텀 이벤트 대응
locale = eventData.value;
} else if (action?.target && typeof action.target === 'string' && !action.target.includes('{{')) {
// action.target이 템플릿 문자열이 아닌 경우에만 사용
locale = action.target;
}
// 2. 유효성 검증
if (!locale || typeof locale !== 'string') {
logger.warn('Invalid locale:', locale);
return;
}
// 유효한 로케일 확인 — 활성 언어팩(supported_locales)을 우선, 미초기화 시 ko/en 폴백
const G7Core = (window as any).G7Core;
const validLocales: string[] = G7Core?.config?.('app.supported_locales')
?? G7Core?.state?.get?.('_global.appConfig.supportedLocales')
?? ['ko', 'en'];
if (!validLocales.includes(locale)) {
logger.warn('Unsupported locale:', locale);
return;
}
// 3. localStorage에 저장
try {
localStorage.setItem('g7_locale', locale);
} catch (error) {
logger.error('Failed to save locale to localStorage:', error);
return;
}
// 4. 페이지 새로고침
window.location.reload();
}
@@ -363,6 +363,10 @@
@@ -1328,8 +1332,13 @@
}
/* Pagination */
/*
* flex-wrap: 페이지 수가 많으면 한 줄에 들어가지 않아 좁은 뷰포트에서 콘텐츠 영역을 넘친다
* (주문상세·상품수정의 활동로그 페이지네이션에서 재현). 공통 컴포넌트이므로 여기 한 줄로
* 두 지점이 동시에 해소된다 — 개별 모듈 레이아웃을 고치지 말 것.
*/
.pagination {
@apply flex items-center gap-2;
@apply flex flex-wrap items-center justify-center gap-2;
}
.pagination-btn {
@apply px-3 py-1 text-sm border rounded;
@@ -1524,8 +1533,16 @@
.modal-backdrop {
@apply absolute inset-0 bg-black/50;
}
/*
* max-w-[calc(100vw-2rem)]: 좁은 뷰포트에서 모달이 화면 좌우에 붙지 않도록 16px 여백을 남긴다.
* 인라인 width(기본 500px)는 .modal-wrapper 의 flex 정렬이 이미 문서폭으로 clamp 하므로
* 폭이 넘치지는 않지만, 여백이 0 이 되어 화면에 꽉 찬다.
*
* 주의: `.max-w-full`(max-width:100%)은 이 규칙과 명시도가 같고(0,1,0) 빌드 CSS 에서 더 뒤에
* 정의되므로 승리한다. 레이아웃 className 에 `max-w-full` 을 함께 주면 이 clamp 가 무효화된다.
*/
.modal-container {
@apply relative bg-white rounded-lg shadow-xl max-h-[90vh] overflow-hidden;
@apply relative bg-white rounded-lg shadow-xl max-h-[90vh] overflow-hidden max-w-[calc(100vw-2rem)];
@apply dark:bg-gray-800;
}
.modal-header-row {
@@ -5,7 +5,7 @@
"ko": "Admin Basic",
"en": "Admin Basic"
},
"version": "1.0.1",
"version": "1.0.2",
"license": "MIT",
"description": {
"ko": "그누보드7 기본 관리자 템플릿",
@@ -12,12 +12,17 @@ import { issueToken, authenticatePage } from '../../../../../../tests/Playwright
type AdminTemplateAuthFixtures = {
/** 레이아웃 편집/조회 권한 보유 토큰 */
layoutEditToken: string;
/** 관리자 대시보드 진입 권한 보유 토큰 (모바일 뷰포트 검증용) */
adminDashboardToken: string;
};
export const test = base.extend<AdminTemplateAuthFixtures>({
layoutEditToken: async ({}, use) => {
await use(issueToken('core.templates.layouts.edit', 'core.templates.read'));
},
adminDashboardToken: async ({}, use) => {
await use(issueToken('core.dashboard.read'));
},
});
export { issueToken, authenticatePage };
@@ -0,0 +1,323 @@
/**
* sirsoft-admin_basic — 모바일 뷰포트 가로 오버플로 회귀
*
* 배경: 모바일 헤더 우측 그룹은 `flex-shrink-0` 이라 좁아져도 폭을 유지한 채 화면 밖으로
* 밀려난다. theme(36) + noti(36) + lang(79) + 통화(112) + gap 합계가 **420px** 를 요구해
* 390px 에서 30px, 320px 에서 100px 이 잘렸다. 언어/통화/배송국가를 모바일 드로어의
* 가로 칩으로 옮겨 해소했다.
*
* 이후 드로어가 세로로 길어져 관리자 메뉴가 스크롤 밖으로 밀리는 문제가 생겨,
* 언어 / 통화·배송국가를 각각 독립 아코디언(기본 접힘 + 트리거에 현재값 요약)으로 바꿨다.
*
* 측정 규율 (이 spec 을 고칠 사람에게):
* - `document.scrollWidth - clientWidth`(docOver)는 **항상 0** 이다. `.admin_layout_root` 가
* `overflow-hidden` 이라 초과분이 문서 스크롤폭에 기여하지 않기 때문. 이걸 단언하면
* 회귀를 영원히 못 잡는다.
* - 절대 폭 하한(`toBeGreaterThanOrEqual(80)`)도 회귀를 못 잡는다. **클립 0 을 직접 단언**한다.
* - 대신 두 지표를 쓴다: ① 요소 우측 끝이 뷰포트를 넘는가(clip)
* ② `#right_content_area` 의 `scrollWidth - clientWidth`(rcaExcess)
*
* @scenario admin_viewport=390|320|1023|1024|1280 x surface=header|drawer|content
* @effects mobile_header_no_clip, drawer_prefs_chips_on_screen, content_area_no_overflow,
* portable_desktop_boundary_flips_at_1024, desktop_header_currency_dropdown_preserved,
* drawer_prefs_collapsed_by_default, drawer_prefs_accordions_independent
*/
import { test, expect, authenticatePage } from '../../fixtures/admin-template-auth';
import type { Page } from '@playwright/test';
/** 세로 스크롤바(≈15px) 때문에 clientWidth 를 원하는 값으로 맞추려면 창을 그만큼 넓게 잡는다. */
const SCROLLBAR = 15;
const phone = { width: 390 + SCROLLBAR, height: 844 };
const smallPhone = { width: 320 + SCROLLBAR, height: 568 };
/**
* `portable` 경계 양쪽. 엔진 `ResponsiveManager` 는 `portable = 0~1023px` 을
* `window.innerWidth` 로 판정하므로 스크롤바 보정 없이 창 폭을 그대로 쓴다.
* 1023 = portable 마지막 픽셀(드로어 렌더), 1024 = desktop 첫 픽셀(헤더 렌더).
*/
const portableEdge = { width: 1023, height: 800 };
const desktopEdge = { width: 1024, height: 800 };
const desktop = { width: 1280, height: 900 };
/** 요소의 우측 끝이 뷰포트를 넘는 픽셀 수 (넘지 않으면 0). */
async function clipOf(page: Page, selector: string): Promise<number> {
return page.evaluate((sel) => {
const el = document.querySelector(sel);
if (!el) return -1;
const vw = document.documentElement.clientWidth;
return Math.max(0, Math.round(el.getBoundingClientRect().right - vw));
}, selector);
}
/** #right_content_area 의 가로 초과분. */
async function rcaExcess(page: Page): Promise<number> {
return page.evaluate(() => {
const rca = document.getElementById('right_content_area');
return rca ? rca.scrollWidth - rca.clientWidth : -1;
});
}
/** settle 곡선 확인 — 지연 로딩(활동로그 페이지네이션 등) 때문에 첫 측정은 위음성이 난다. */
async function settledRcaExcess(page: Page, samples = 3): Promise<number> {
let last = -1;
for (let i = 0; i < samples; i += 1) {
await page.waitForTimeout(1200);
last = await rcaExcess(page);
}
return last;
}
async function openDrawer(page: Page): Promise<void> {
await page.locator('#mobile_menu_toggle').click();
// 드로어는 `transform: translateX(-256px) → 0` 을 0.3s 에 걸쳐 이동한다. 이 트랜지션이 끝나기
// 전에 좌표를 읽으면 칩이 화면 밖(음수 x)에 있어 위양성이 난다.
//
// `getAnimations()` 로 기다리면 안 된다 — 클릭 직후에는 트랜지션이 아직 시작되지 않아
// 빈 배열을 반환하고, waitForFunction 이 즉시 통과해 애니메이션 도중에 측정하게 된다.
// 최종 기하(left === 0)가 안정될 때까지 기다린다.
await page.waitForFunction(() => {
const d = document.getElementById('left_sidebar_area');
if (!d) return false;
return Math.round(d.getBoundingClientRect().left) === 0;
}, undefined, { timeout: 5_000 });
}
test.describe('@sirsoft-admin_basic 모바일 뷰포트 가로 오버플로', () => {
test('390px: 모바일 헤더가 잘리지 않고 통화·언어는 헤더에 없다', async ({ page, adminDashboardToken }) => {
await page.setViewportSize(phone);
await authenticatePage(page, adminDashboardToken);
await page.goto('/admin/dashboard');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await page.locator('#mobile_header_right').waitFor({ timeout: 20_000 });
// 회귀 시 정확히 30 이 되어 실패한다.
expect(await clipOf(page, '#mobile_header_right')).toBe(0);
// 헤더에는 테마·알림만 남는다.
const headerIds = await page.evaluate(() =>
[...(document.getElementById('mobile_header_right')?.children ?? [])].map((c) => c.id),
);
expect(headerIds).toContain('theme_toggle_mobile');
expect(headerIds).toContain('notification_center_mobile');
expect(headerIds).not.toContain('language_selector_mobile');
expect(headerIds).not.toContain('header_currency_slot_mobile');
});
test('320px: 헤더 클립 0 + 햄버거 탭 가능 (최소 폰)', async ({ page, adminDashboardToken }) => {
await page.setViewportSize(smallPhone);
await authenticatePage(page, adminDashboardToken);
await page.goto('/admin/dashboard');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await page.locator('#mobile_header_right').waitFor({ timeout: 20_000 });
// 회귀 시 100 이 된다.
expect(await clipOf(page, '#mobile_header_right')).toBe(0);
// 햄버거가 화면 안이고 실제 히트 타깃이어야 한다(다른 요소가 덮으면 실패).
const burgerTappable = await page.evaluate(() => {
const b = document.getElementById('mobile_menu_toggle');
if (!b) return false;
const r = b.getBoundingClientRect();
if (r.left < 0 || r.right > document.documentElement.clientWidth) return false;
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
return !!hit && (b.contains(hit) || hit.contains(b));
});
expect(burgerTappable).toBe(true);
});
/**
* 드로어 선호설정은 언어 / 통화·배송국가 두 개의 독립 아코디언이며 기본은 접힘이다.
* 접힘 상태에서는 트리거에 현재 선택값만 요약 표기되고, 펼쳐야 칩이 렌더된다.
* (전부 펼쳐 두면 드로어가 세로로 길어져 정작 관리자 메뉴가 스크롤 밖으로 밀린다.)
*/
test('390px: 드로어 선호설정은 기본 접힘이고 트리거에 현재값을 요약 표기한다', async ({ page, adminDashboardToken }) => {
await page.setViewportSize(phone);
await authenticatePage(page, adminDashboardToken);
await page.goto('/admin/dashboard');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await openDrawer(page);
const prefs = page.locator('#admin_drawer_prefs');
await expect(prefs).toBeVisible();
// 언어: 접힘 → aria-expanded=false.
const langToggle = page.locator('#admin_drawer_language_toggle');
await expect(langToggle).toHaveAttribute('aria-expanded', 'false');
// 접힘 상태 요약 = 현재 언어명. E2E 세션 로케일이 ko/en 중 무엇이든 성립해야 하므로
// 특정 문자열('한국어')을 박지 않고, aria-selected 인 칩의 이름과 일치하는지로 본다.
const summaryMatchesSelectedChip = await page.evaluate(() => {
const toggle = document.querySelector('#admin_drawer_language_toggle');
const selected = document.querySelector('#admin_drawer_language [role="option"][aria-selected="true"]');
if (!toggle || !selected) return null;
const chipName = (selected.textContent ?? '').trim();
// 트리거 텍스트 = "<라벨><현재 언어명>" (칩 목록은 별도 래퍼라 포함되지 않는다)
return { toggleText: (toggle.textContent ?? '').trim(), chipName };
});
expect(summaryMatchesSelectedChip).not.toBeNull();
expect(summaryMatchesSelectedChip!.chipName.length).toBeGreaterThan(0);
expect(summaryMatchesSelectedChip!.toggleText).toContain(summaryMatchesSelectedChip!.chipName);
// 칩은 `max-h-0 overflow-hidden` 래퍼에 잘려 화면에 안 보인다.
// 칩 자신은 레이아웃 박스를 갖고 있어(=Playwright 기준 visible) 칩에 not.toBeVisible()
// 을 걸면 안 된다. 잘림을 만드는 **래퍼의 실제 높이 0** 을 단언한다.
const langBodyHeight = await page.evaluate(() => {
const body = document
.querySelector('#admin_drawer_language_toggle')
?.parentElement?.querySelector('.overflow-hidden');
return body ? Math.round(body.getBoundingClientRect().height) : -1;
});
expect(langBodyHeight).toBe(0);
// 통화/배송국가(이커머스 주입): 접힘 → 패널 자체가 언마운트.
// 모듈 비활성 환경에서는 슬롯이 비므로 존재할 때만 검증한다.
const curTrigger = prefs.locator('button[aria-haspopup="listbox"]');
if (await curTrigger.count()) {
await expect(curTrigger).toHaveAttribute('aria-expanded', 'false');
await expect(prefs.locator('[role="listbox"]')).toHaveCount(0);
}
});
test('390px: 아코디언을 펼치면 언어·통화·배송국가 칩이 모두 화면 안에 있다', async ({ page, adminDashboardToken }) => {
await page.setViewportSize(phone);
await authenticatePage(page, adminDashboardToken);
await page.goto('/admin/dashboard');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await openDrawer(page);
// 언어 펼치기 (엔진 빌트인 setLocale 을 action.target 으로 호출하는 칩 3개)
await page.locator('#admin_drawer_language_toggle').click();
await expect(page.locator('#admin_drawer_language_toggle')).toHaveAttribute('aria-expanded', 'true');
await expect(page.locator('#admin_drawer_language [role="option"]')).toHaveCount(3);
// 칩 자신은 접힘 상태에서도 레이아웃 박스를 가지므로, 잘림 래퍼가 실제로 열렸는지를 본다.
await expect
.poll(async () =>
page.evaluate(() => {
const body = document
.querySelector('#admin_drawer_language_toggle')
?.parentElement?.querySelector('.overflow-hidden');
return body ? Math.round(body.getBoundingClientRect().height) : -1;
}),
)
.toBeGreaterThan(0);
// 통화/배송국가 펼치기 (모듈 비활성 환경에서는 트리거 자체가 없다)
const prefs = page.locator('#admin_drawer_prefs');
const curTrigger = prefs.locator('button[aria-haspopup="listbox"]');
if (await curTrigger.count()) {
await curTrigger.click();
await expect(prefs.locator('[role="listbox"]')).toHaveCount(1);
}
const chips = prefs.locator('[role="option"]');
expect(await chips.count()).toBeGreaterThanOrEqual(3);
// 칩이 한 개라도 뷰포트를 넘으면 실패.
const anyChipClipped = await page.evaluate(() => {
const vw = document.documentElement.clientWidth;
return [...document.querySelectorAll('#admin_drawer_prefs [role="option"]')].some((el) => {
const r = el.getBoundingClientRect();
return r.width > 0 && (r.right > vw + 1 || r.left < -1);
});
});
expect(anyChipClipped).toBe(false);
});
/**
* 두 아코디언은 서로 다른 상태를 쓴다(언어=_global.mobileLanguageOpen,
* 통화=_local.showCurrencyDropdown). 하나를 닫아도 다른 하나는 열린 채여야 한다.
*/
test('390px: 언어와 통화 아코디언은 독립적으로 개폐된다', async ({ page, adminDashboardToken }) => {
await page.setViewportSize(phone);
await authenticatePage(page, adminDashboardToken);
await page.goto('/admin/dashboard');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await openDrawer(page);
const prefs = page.locator('#admin_drawer_prefs');
const langToggle = page.locator('#admin_drawer_language_toggle');
const curTrigger = prefs.locator('button[aria-haspopup="listbox"]');
test.skip(!(await curTrigger.count()), '이커머스 모듈 비활성 — 통화 슬롯 없음');
await langToggle.click();
await curTrigger.click();
await expect(langToggle).toHaveAttribute('aria-expanded', 'true');
await expect(curTrigger).toHaveAttribute('aria-expanded', 'true');
// 언어만 닫는다 → 통화는 열린 채 유지 (독립성)
await langToggle.click();
await expect(langToggle).toHaveAttribute('aria-expanded', 'false');
await expect(curTrigger).toHaveAttribute('aria-expanded', 'true');
await expect(prefs.locator('[role="listbox"]')).toHaveCount(1);
// 드로어 안 인라인 아코디언이므로 배경 오버레이(fixed inset-0)는 렌더되지 않는다.
// 렌더되면 드로어 전체 클릭을 가로챈다.
const backdrops = await page.evaluate(
() => document.querySelectorAll('#admin_drawer_prefs .fixed.inset-0').length,
);
expect(backdrops).toBe(0);
});
test('390px: 이커머스 환경설정 콘텐츠 영역이 넘치지 않는다', async ({ page, adminDashboardToken }) => {
await page.setViewportSize(phone);
await authenticatePage(page, adminDashboardToken);
await page.goto('/admin/ecommerce/settings');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await page.locator('#right_content_area').waitFor({ timeout: 20_000 });
// 회귀 시 71 이 된다(이메일 행 nowrap + sticky-tab-nav 과다 bleed).
expect(await settledRcaExcess(page)).toBe(0);
});
test('320px: 이커머스 환경설정 고정폭 입력이 넘치지 않는다', async ({ page, adminDashboardToken }) => {
await page.setViewportSize(smallPhone);
await authenticatePage(page, adminDashboardToken);
await page.goto('/admin/ecommerce/settings');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await page.locator('#right_content_area').waitFor({ timeout: 20_000 });
// 회귀 시 32 가 된다(w-80 고정폭 입력 2개).
expect(await settledRcaExcess(page)).toBe(0);
});
test('1023px: portable 마지막 픽셀 — 드로어 선호설정이 렌더된다', async ({ page, adminDashboardToken }) => {
await page.setViewportSize(portableEdge);
await authenticatePage(page, adminDashboardToken);
await page.goto('/admin/dashboard');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await page.locator('#right_content_area').waitFor({ timeout: 20_000 });
// 경계가 1px 밀리면(예: `<` → `<=` 오타) 여기서 잡힌다.
await expect(page.locator('#admin_drawer_prefs')).toHaveCount(1);
expect(await settledRcaExcess(page)).toBe(0);
});
test('1024px: desktop 첫 픽셀 — 드로어 선호설정이 사라지고 헤더 통화가 렌더된다', async ({ page, adminDashboardToken }) => {
await page.setViewportSize(desktopEdge);
await authenticatePage(page, adminDashboardToken);
await page.goto('/admin/dashboard');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await page.locator('#right_content_area').waitFor({ timeout: 20_000 });
// 데스크톱에서 드로어 통화가 중복 노출되면 회귀다.
await expect(page.locator('#admin_drawer_prefs')).toHaveCount(0);
await expect(page.locator('#header_currency_slot_desktop')).toHaveCount(1);
expect(await settledRcaExcess(page)).toBe(0);
});
test('1280px: 데스크톱 헤더/통화 드롭다운이 회귀하지 않는다', async ({ page, adminDashboardToken }) => {
await page.setViewportSize(desktop);
await authenticatePage(page, adminDashboardToken);
await page.goto('/admin/dashboard');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await page.locator('#right_content_area').waitFor({ timeout: 20_000 });
// 드로어 선호설정 블록은 데스크톱에서 렌더되지 않아야 한다(중복 노출 = 회귀).
await expect(page.locator('#admin_drawer_prefs')).toHaveCount(0);
// 통화는 데스크톱 헤더 슬롯에 남는다.
const desktopSlot = page.locator('#header_currency_slot_desktop');
await expect(desktopSlot).toHaveCount(1);
expect(await settledRcaExcess(page)).toBe(0);
});
});
@@ -4,6 +4,36 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.2] - 2026-07-10
### Changed
- 이커머스 모듈 최소 요구 버전을 1.0.2 로 상향했습니다. 휴대폰 메뉴 안의 통화·배송국가 선택이 이 템플릿의 새 배치에서 잘리지 않고 표시되려면 해당 버전 이상이 필요합니다.
### Fixed
- 헤더의 배송국가 선택에서 국가 이름이 어떤 언어로 사이트를 보든 항상 한국어로만 표시되던 문제를 수정했습니다. 이제 현재 보고 있는 언어로 국가 이름이 표시됩니다.
#### 모바일 화면
- 휴대폰 화면에서 헤더가 가로로 넘쳐 사이트명이 잘리고 메뉴 버튼이 화면 밖으로 밀려나던 문제를 수정했습니다. 언어·통화 선택은 메뉴(햄버거) 안으로 옮겼습니다.
- 좁은 화면(320px)에서 메뉴 버튼을 누를 수 없어 사이트 이동 자체가 불가능하던 문제를 수정했습니다.
- 메뉴 안의 언어·통화·배송국가 선택이 세로로 길게 늘어지지 않고 가로로 나란히 배치됩니다.
- 메뉴 안의 언어와 통화·배송국가가 각각 접었다 펼치는 항목으로 바뀌어, 메뉴를 열었을 때 곧바로 사이트 메뉴가 보입니다. 접혀 있을 때도 현재 선택된 언어와 통화·배송국가가 표시되므로 펼치지 않아도 확인할 수 있으며, 두 항목은 서로 독립적으로 여닫힙니다.
- 언어 선택이 로그인하지 않은 방문자에게도 보입니다. 이전에는 메뉴 안에서 로그인한 사용자만 언어를 바꿀 수 있었습니다.
- 게시판 이름이 길면 게시글 위쪽의 목록·이전글·다음글 버튼이 찌그러지거나 보이지 않던 문제를 수정했습니다. 이제 게시판 이름이 윗줄에 표시되고 버튼은 아랫줄에 원래 크기로 놓입니다.
#### 입력 화면
- 비회원이 글을 쓰거나 댓글을 달 때 이름·비밀번호 입력칸이 화면을 벗어나거나 지나치게 좁아지던 문제를 수정했습니다.
- 해외 배송지를 등록할 때 도시·주(州) 입력칸이 좁아 내용을 확인하기 어렵던 문제를 수정했습니다.
#### 표시
- 상품 상세의 쿠폰 할인 금액이 두 줄로 잘려 보이던 문제를 수정했습니다. 쿠폰이 많으면 여러 줄로 나눠 표시합니다.
- 주문 취소 화면에서 쿠폰 이름과 항목 이름이 단어 중간에서 잘려 읽기 어렵던 문제를 수정했습니다.
- 좁은 화면에서 팝업 창이 화면 가장자리에 붙어 보이던 문제를 수정했습니다. 이제 양옆에 여백이 생깁니다.
## [1.0.1] - 2026-07-08
### Added
@@ -9,10 +9,19 @@
* - 표시 통화 초기화 핸들러(initPreferredCurrency)는 sirsoft-ecommerce 모듈 소유
* (`sirsoft-ecommerce.initPreferredCurrency`). _user_base.json init_actions 가 모듈 네임스페이스로 호출.
*
* 모바일 배치 (390px 오버플로 해소 후):
* - 언어·통화는 헤더 우측 그룹이 아니라 모바일 드로어 최상단(mobile_drawer_prefs)에 있다.
* 데스크톱(Header.tsx)은 기존 헤더 배치를 유지한다. 사유는 아래 해당 it 블록 주석 참조.
* - 드로어는 overflow-y-auto 라 absolute 드롭다운이 잘린다 → 주입 조각이 responsive.portable
* 오버라이드로 모바일에서만 static 인라인 목록이 된다.
* - 언어 / 통화·배송국가는 각각 독립 아코디언이며 기본 접힘이다. 접힘 상태에서도 트리거에
* 현재 선택값(언어명 / 통화·배송국가)을 요약 표기한다.
*
* 회귀 차단:
* - _user_base.json: defaultCurrency/preferredCurrency(_global)/availableCurrencies 주입 + X-Currency 헤더.
* - init 핸들러 호출이 모듈 네임스페이스(sirsoft-ecommerce.initPreferredCurrency)로 되어 있다.
* - 헤더에 통화 슬롯(SlotContainer header_currency)과 언어 버튼이 있고, Header.tsx 는 통화 코드/모듈을 모른다.
* - 데스크톱 드롭다운(absolute/z-50)은 responsive 오버라이드로 무력화되지 않는다.
*
* @vitest-environment jsdom
*/
@@ -34,6 +43,61 @@ function loadRepo(relPath: string): string {
return fs.readFileSync(path.resolve(repoRoot, relPath), 'utf8');
}
/**
* 레이아웃 트리에서 id 로 노드를 찾습니다.
*
* @param node 탐색 시작 노드/배열
* @param id 찾을 노드 id
* @return 찾은 노드 (없으면 null)
*/
function findNode(node: any, id: string): any {
if (!node || typeof node !== 'object') return null;
if (Array.isArray(node)) {
for (const n of node) {
const r = findNode(n, id);
if (r) return r;
}
return null;
}
if (node.id === id) return node;
for (const k of ['children', 'components']) {
if (node[k]) {
const r = findNode(node[k], id);
if (r) return r;
}
}
return null;
}
/**
* 레이아웃 트리에서 술어를 만족하는 첫 노드를 찾습니다.
*
* 위치 인덱스(children[1] 등) 대신 구조로 찾기 위한 헬퍼 — 노드가 앞에 추가되어도
* 테스트가 깨지지 않습니다.
*
* @param node 탐색 시작 노드/배열
* @param pred 노드 술어
* @return 찾은 노드 (없으면 null)
*/
function walkNode(node: any, pred: (n: any) => boolean): any {
if (!node || typeof node !== 'object') return null;
if (Array.isArray(node)) {
for (const n of node) {
const r = walkNode(n, pred);
if (r) return r;
}
return null;
}
if (pred(node)) return node;
for (const k of ['children', 'components']) {
if (node[k]) {
const r = walkNode(node[k], pred);
if (r) return r;
}
}
return null;
}
describe('A1 — _user_base.json 통화 주입', () => {
const userBase = loadJson('layouts/_user_base.json');
const initActions = userBase.init_actions ?? [];
@@ -119,31 +183,162 @@ describe('헤더 통화 슬롯(이커머스 주입) + 언어 버튼(코어 내
expect(headerTsx).toContain('currentLocale');
});
it('모바일 헤더(_user_base.json)도 header_currency 슬롯(SlotContainer)을 마운트한다', () => {
expect(userBaseText).toContain('mobile_currency_selector_wrap');
it('모바일 표면(_user_base.json)도 header_currency 슬롯(SlotContainer)을 마운트한다', () => {
// 마운트 위치는 헤더 우측 그룹 → 모바일 드로어로 이동했으나, 슬롯 소비 자체는 유지된다.
expect(userBaseText).toContain('mobile_drawer_currency_wrap');
expect(userBaseText).toContain('"slotId": "header_currency"');
});
it('모바일 헤더에 언어 선택기가 통화와 같은 줄에 노출된다 (데스크톱 패리티, 4표면 일관)', () => {
/**
* 배치 반전 사유 (과거 결정의 의도적 해제):
*
* 이 자리에는 원래 `it('모바일 헤더에 언어 선택기가 통화와 같은 줄에 노출된다
* (데스크톱 패리티, 4표면 일관)')` 이 있었고, 헤더 우측 그룹(#mobile_header_right)의
* 직계 자식에 mobile_lang_selector_wrap / mobile_currency_selector_wrap 이 있음을 단언했다.
*
* 390px(iPhone) 실측 결과 그 배치가 사이트 전역 가로 오버플로의 단일 원인이었다:
* 우측 그룹 = theme(36)+notification(36)+cart(34)+lang(71)+currency(91)+user(30)+hamburger(30)
* = 351px, 로고 뒤 x=50 에서 시작 → 우측 끝 401px → 뷰포트 390px 를 11px 초과.
* 언어+통화만 제거하면 document.scrollWidth 가 401 → 390 으로 떨어진다.
* 320px(iPhone SE)에서는 햄버거 버튼이 화면 밖으로 밀려 내비게이션 자체가 불가능했다.
*
* 따라서 "데스크톱 패리티"는 **모바일에 한해** 의도적으로 해제하고, 언어·통화를
* 드로어(mobile_nav_drawer > mobile_drawer_prefs) 최상단으로 옮겼다.
* 데스크톱(Header.tsx)의 배치는 그대로다 — 위 'Header.tsx' 단언들이 그것을 지킨다.
*
* 되돌리려면 먼저 390px 오버플로를 다른 방법으로 해소해야 한다.
*/
it('모바일 헤더 우측 그룹에 언어/통화가 없다 (390px 오버플로 해소 — 데스크톱 패리티 모바일 한정 해제)', () => {
const userBase = loadJson('layouts/_user_base.json');
// 모바일 헤더 우측 영역에서 언어 셀렉터와 통화 슬롯을 찾는다
function find(node: any, id: string): any {
if (!node || typeof node !== 'object') return null;
if (Array.isArray(node)) { for (const n of node) { const r = find(n, id); if (r) return r; } return null; }
if (node.id === id) return node;
for (const k of ['children', 'components']) { if (node[k]) { const r = find(node[k], id); if (r) return r; } }
return null;
}
const mobileRight = find(userBase.components, 'mobile_header_right');
const mobileRight = findNode(userBase.components, 'mobile_header_right');
expect(mobileRight).toBeTruthy();
const ids = (mobileRight.children ?? []).map((c: any) => c.id);
expect(ids).toContain('mobile_lang_selector_wrap');
expect(ids).toContain('mobile_currency_selector_wrap');
// 언어 버튼은 setLocale + 현재 로케일 코드($locale) 표기
const langWrap = find(mobileRight, 'mobile_lang_selector_wrap');
const raw = JSON.stringify(langWrap);
expect(raw).toContain('setLocale');
expect(raw).toContain('{{$locale}}');
expect(ids).not.toContain('mobile_lang_selector_wrap');
expect(ids).not.toContain('mobile_currency_selector_wrap');
});
it('언어/통화는 모바일 드로어 최상단(mobile_drawer_prefs)에 있다', () => {
const userBase = loadJson('layouts/_user_base.json');
const drawer = findNode(userBase.components, 'mobile_nav_drawer');
expect(drawer).toBeTruthy();
const prefs = findNode(drawer, 'mobile_drawer_prefs');
expect(prefs).toBeTruthy();
// 언어: setLocale 액션 + $locales iteration
const prefsRaw = JSON.stringify(prefs);
expect(prefsRaw).toContain('setLocale');
expect(prefsRaw).toContain('{{$locales}}');
// 통화: 슬롯 컨테이너. id 는 필수 — SlotContainer 가 같은 슬롯을 여러 컨테이너에서
// 렌더할 때 주입 컴포넌트 root id 를 `{id}__{containerId}` 로 스코프하기 때문
// (데스크톱 Header.tsx 와 동시 소비 → id 없으면 HTML id 중복).
const currencyWrap = findNode(prefs, 'mobile_drawer_currency_wrap');
expect(currencyWrap).toBeTruthy();
expect(currencyWrap.name).toBe('SlotContainer');
expect(currencyWrap.props?.slotId).toBe('header_currency');
});
/**
* 엔진의 `iteration` 은 그 속성을 가진 노드 **자신**을 항목 수만큼 복제한다.
* flex 컨테이너에 iteration 을 걸면 컨테이너가 복제되어 각 칩이 자기 컨테이너를 갖고
* 세로로 쌓인다. 가로 나열을 유지하려면 iteration 은 반드시 Button 자신에 있어야 한다.
*/
it('드로어 언어 칩은 flex-wrap 컨테이너 + Button 에 iteration (가로 나열)', () => {
const userBase = loadJson('layouts/_user_base.json');
const prefs = findNode(userBase.components, 'mobile_drawer_prefs');
const langSection = (prefs.children ?? []).find(
(c: any) => typeof c.if === 'string' && c.if.includes('$locales.length')
);
expect(langSection).toBeTruthy();
// 칩 컨테이너는 아코디언 펼침 영역 안에 있다. 위치 인덱스가 아니라 구조로 찾는다
// (트리거 행이 앞에 추가되어도 깨지지 않도록).
const chipRow = walkNode(langSection, (n: any) =>
n.name === 'Div' &&
typeof n.props?.className === 'string' &&
n.props.className.includes('flex-wrap')
);
expect(chipRow).toBeTruthy();
expect(chipRow.props.className).toContain('flex');
expect(chipRow.iteration).toBeUndefined();
const button = chipRow.children[0];
expect(button.name).toBe('Button');
expect(button.iteration?.source).toBe('{{$locales}}');
expect(button.iteration?.item_var).toBe('loc');
expect(button.props.className).toContain('rounded-full');
expect(button.props.className).toContain('whitespace-nowrap');
});
/**
* 접기/펼치기 — 언어·통화·배송국가를 전부 펼쳐 두면 드로어가 세로로 길어져 정작 메뉴가
* 스크롤 밖으로 밀린다. 언어와 통화/배송국가를 각각 독립 아코디언으로 접고, 접힘
* 상태에서도 현재 선택값을 요약 표기한다(무엇이 선택돼 있는지 펼치지 않고 알 수 있어야 함).
*
* 언어 = 템플릿 소유(_global.mobileLanguageOpen),
* 통화/배송국가 = 이커머스 주입 조각 소유(_local.showCurrencyDropdown) → 서로 독립.
*/
it('드로어 언어 섹션은 기본 접힘 + 트리거에 현재 언어를 요약 표기한다', () => {
const userBase = loadJson('layouts/_user_base.json');
const langSection = findNode(userBase.components, 'mobile_drawer_language');
expect(langSection).toBeTruthy();
const toggle = findNode(langSection, 'mobile_drawer_language_toggle');
expect(toggle?.name).toBe('Button');
expect(toggle.props['aria-expanded']).toBe('{{_global.mobileLanguageOpen ?? false}}');
// 토글은 언어 전용 플래그를 뒤집는다
const act = toggle.actions[0];
expect(act.handler).toBe('setState');
expect(act.params.target).toBe('global');
expect(act.params.mobileLanguageOpen).toBe('{{!_global.mobileLanguageOpen}}');
// 접힘 상태 요약 = 현재 언어명 + chevron
const summary = JSON.stringify(toggle.children);
expect(summary).toContain('localeNames?.[$locale]');
expect(summary).toContain('chevron-down');
expect(summary).toContain("{{_global.mobileLanguageOpen ? 'rotate-180' : ''}}");
// 펼침 영역: falsy(기본) 분기가 닫힘(max-h-0)이어야 기본 접힘이 성립한다
const body = walkNode(langSection, (n: any) =>
typeof n.props?.className === 'string' &&
n.props.className.includes('overflow-hidden') &&
n.props.className.includes('mobileLanguageOpen')
);
expect(body).toBeTruthy();
expect(body.props.className).toContain("'max-h-screen opacity-100 mt-2' : 'max-h-0 opacity-0 mt-0'");
});
it('언어 토글은 통화 플래그를 건드리지 않는다 (독립 개폐)', () => {
const userBase = loadJson('layouts/_user_base.json');
const toggle = findNode(userBase.components, 'mobile_drawer_language_toggle');
expect(toggle).toBeTruthy();
// 언어 = _global.mobileLanguageOpen / 통화 = _local.showCurrencyDropdown (모듈 조각 소유)
expect(JSON.stringify(toggle)).not.toContain('showCurrencyDropdown');
});
it('드로어 언어/통화 섹션은 비회원에게도 노출된다 (아코디언 currentUser 게이트 바깥)', () => {
const userBase = loadJson('layouts/_user_base.json');
const drawer = findNode(userBase.components, 'mobile_nav_drawer');
const prefs = (drawer.children ?? []).find((c: any) => c.id === 'mobile_drawer_prefs');
// 드로어의 직계 자식이어야 한다 (로그인 아코디언 `if: currentUser?.uuid` 안이면 비회원 미노출)
expect(prefs).toBeTruthy();
expect(prefs.if).toBeUndefined();
});
it('로그인 아코디언 안에 중복 언어 블록이 남아있지 않다 (드로어 단일 소스)', () => {
const userBase = loadJson('layouts/_user_base.json');
const drawer = findNode(userBase.components, 'mobile_nav_drawer');
const accordion = (drawer.children ?? []).find(
(c: any) => typeof c.if === 'string' && c.if.includes('currentUser?.uuid')
);
expect(accordion).toBeTruthy();
expect(JSON.stringify(accordion)).not.toContain('setLocale');
});
it('헤더 드롭다운 잔여 상태(mobileLangMenuOpen)가 제거되었다', () => {
expect(userBaseText).not.toContain('mobileLangMenuOpen');
});
it('통화 슬롯 주입 앵커(header_currency_inject_anchor)가 _user_base 에 있다', () => {
@@ -197,6 +392,63 @@ describe('이커머스 헤더 통화 주입 확장 (slot=header_currency)', () =
expect(raw).not.toContain('/api/modules/sirsoft-ecommerce/user/currency');
});
/**
* 모바일 드로어는 overflow-y-auto + w-80 이라 absolute 드롭다운 패널이 스크롤 경계에서 잘린다.
* SlotContainer 는 주입 자식에게 variant/context prop 을 전달하지 않으므로 컨테이너별로
* 다르게 그릴 수 없다 → 주입 노드 자체에 responsive.portable(0~1023px) 오버라이드를 둔다.
*
* portable 에서는 팝오버가 아니라 드로어 안 인라인 아코디언이 되며, 개폐는 데스크톱과 같은
* `_local.showCurrencyDropdown` 이 결정한다(기본 접힘).
*/
it('유저 확장 트리거는 portable 에서 아코디언 행 + 현재값 요약을 노출한다', () => {
const node = userExt.injections[0].components[0];
const trigger = (node.children ?? []).find((c: any) => c.name === 'Button');
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');
// 접힘 상태에서도 현재 통화/배송국가를 알 수 있어야 한다
const summary = JSON.stringify(portable.children);
expect(summary).toContain('preferredCurrency');
expect(summary).toContain('preferredShippingCountry');
expect(summary).toContain('chevron-down');
});
it('유저 확장 패널은 portable 에서도 _local.showCurrencyDropdown 으로 게이트된다 (기본 접힘)', () => {
const node = userExt.injections[0].components[0];
const panel = (node.children ?? []).find(
(c: any) => c.name === 'Div' && c.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');
// portable 오버라이드: if 를 생략해 base if 를 상속(접힘 기본) + 정적 흐름
const portable = panel.responsive?.portable;
expect(portable).toBeTruthy();
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');
expect(portable.props.role).toBe('listbox');
});
it('유저 확장 백드롭은 portable 에서 꺼진다 (드로어 클릭 가로채기 방지)', () => {
const node = userExt.injections[0].components[0];
const backdrop = (node.children ?? []).find(
(c: any) => c.name === 'Div' && (c.props?.className ?? '').includes('fixed inset-0')
);
expect(backdrop).toBeTruthy();
expect(backdrop.responsive?.portable?.if).toBe('{{false}}');
});
it('관리자 확장이 _admin_base 에 header_currency 슬롯 + init_actions(통화 복원)를 기여한다', () => {
expect(adminExt.target_layout).toBe('_admin_base');
const node = adminExt.injections[0].components[0];
@@ -0,0 +1,325 @@
/**
* @file mobile-viewport-overflow.test.tsx
* @description 390px(iPhone) 모바일 뷰포트 가로 오버플로/압착 회귀 차단
*
* 배경 (Chrome DevTools 390x844x3, mobile UA 실측):
* - 헤더 우측 그룹에 언어(71px)+통화(91px)가 있어 26개 라우트 중 24개에서
* document.scrollWidth = 401px (뷰포트 390px 대비 +11px). 320px(iPhone SE)에서는
* 햄버거 버튼이 화면 밖으로 밀려 내비게이션 자체가 불가능했다.
* → 언어/통화를 드로어로 이동 (currencySelector.test.tsx 가 배치를 단언)
* - 비회원 댓글폼: 아바타 76px 들여쓰기 + flex-1 입력 2개 → 문서 +81px 초과
* - 비회원 글쓰기폼: grid-cols-2 → 각 입력 147px 로 압착, placeholder 잘림
* - 상품상세 쿠폰 칩: 라벨 Span 에 className 이 없어 39px 폭에서 2줄로 줄바꿈
* - 부분환불 모달: 91px 3열에서 한글 쿠폰명이 단어 중간에서 잘림
* - 해외주소 도시/주: 무접두 grid-cols-2 → 390px 에서 각 입력 155px
* - 게시글 상단 네비: 게시판명이 길수록 좌우 버튼을 압착 (390px 실측 — 5자 '이전글' 87px,
* 11자 73px, 21자 62px 로 chevron 과 텍스트가 겹침). docOverflow 는 0 이라 오버플로
* 검사로는 잡히지 않는다.
*
* 이 파일은 위 좌표들이 되돌아가지 않도록 레이아웃 JSON 구조를 직접 단언한다.
* 2열 행의 좁은 화면 대비는 두 방식 모두 허용한다: Tailwind 접두(`grid-cols-1 sm:grid-cols-2`)
* 또는 엔진 노드 레벨 `responsive.portable`(0~1023px) 오버라이드.
*
* @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');
/**
* 레이아웃 JSON 을 로드합니다.
*
* @param relativePath layouts/ 기준 상대 경로
* @return 파싱된 레이아웃 객체
*/
function loadLayout(relativePath: string): any {
return JSON.parse(fs.readFileSync(path.join(layoutsDir, relativePath), 'utf-8'));
}
/**
* 술어를 만족하는 첫 노드를 찾습니다.
*
* @param node 탐색 시작 노드/배열
* @param predicate 판정 함수
* @return 찾은 노드 (없으면 null)
*/
function findNode(node: any, predicate: (n: any) => boolean): any {
if (!node || typeof node !== 'object') return null;
if (Array.isArray(node)) {
for (const n of node) {
const r = findNode(n, predicate);
if (r) return r;
}
return null;
}
if (predicate(node)) return node;
for (const k of ['children', 'components']) {
if (node[k]) {
const r = findNode(node[k], predicate);
if (r) return r;
}
}
return null;
}
/**
* 술어를 만족하는 모든 노드를 찾습니다.
*
* @param node 탐색 시작 노드/배열
* @param predicate 판정 함수
* @param acc 누적 배열 (재귀 내부용)
* @return 찾은 노드 배열
*/
function findAllNodes(node: any, predicate: (n: any) => boolean, acc: any[] = []): any[] {
if (!node || typeof node !== 'object') return acc;
if (Array.isArray(node)) {
for (const n of node) findAllNodes(n, predicate, acc);
return acc;
}
if (predicate(node)) acc.push(node);
for (const k of ['children', 'components']) {
if (node[k]) findAllNodes(node[k], predicate, acc);
}
return acc;
}
describe('게시판 비회원 입력 행 — 좁은 화면에서 세로 스택', () => {
it('댓글 입력폼의 이름/비밀번호 행이 flex-col sm:flex-row 다 (+81px 오버플로 회귀)', () => {
const layout = loadLayout('partials/board/show/_comment_input.json');
const guestRow = findNode(
layout,
(n) => typeof n.if === 'string' && n.if.includes('!_global.currentUser?.uuid')
);
expect(guestRow).toBeTruthy();
const cls: string = guestRow.props?.className ?? '';
expect(cls).toContain('flex-col');
expect(cls).toContain('sm:flex-row');
});
it('글쓰기폼의 이름/비밀번호 행이 grid-cols-1 sm:grid-cols-2 다 (147px 압착 회귀)', () => {
const layout = loadLayout('partials/board/form/_post_form.json');
const guestRow = findNode(
layout,
(n) =>
typeof n.if === 'string' &&
n.if.includes('!_global.currentUser?.uuid') &&
n.if.includes('!route.id')
);
expect(guestRow).toBeTruthy();
const cls: string = guestRow.props?.className ?? '';
expect(cls).toContain('grid-cols-1');
expect(cls).toContain('sm:grid-cols-2');
// 무접두 grid-cols-2 는 모바일에서 그대로 2열이 된다
expect(cls).not.toMatch(/(^|\s)grid-cols-2(\s|$)/);
});
});
describe('상품상세 쿠폰 칩 — 라벨 한 줄, 목록은 여러 줄', () => {
const layout = loadLayout('partials/shop/detail/_info_summary.json');
it('칩 컨테이너가 가로 스크롤 대신 줄바꿈한다', () => {
const container = findNode(
layout,
(n) => typeof n.comment === 'string' && n.comment.includes('쿠폰 배지 목록')
);
expect(container).toBeTruthy();
const cls: string = container.props?.className ?? '';
expect(cls).toContain('flex-wrap');
// 실측상 스크롤되지 않았고(scrollWidth == clientWidth), 칩이 숨겨질 뿐이었다
expect(cls).not.toContain('overflow-x-auto');
});
it('칩 라벨(할인 금액)이 whitespace-nowrap 으로 한 줄을 유지한다', () => {
const label = findNode(
layout,
(n) => n.name === 'Span' && typeof n.text === 'string' && n.text.includes('benefit_formatted')
);
expect(label).toBeTruthy();
expect(label.props?.className ?? '').toContain('whitespace-nowrap');
});
});
describe('Modal 좌우 여백 — max-w-full 이 clamp 를 이기지 못하게', () => {
/**
* Modal.tsx 는 `max-w-[calc(100vw-2rem)]` 로 화면 좌우 16px 여백을 보장한다.
* 그런데 그 뒤에 `${className}` 이 붙으므로, 레이아웃이 `max-w-full`(=100%)을 주면
* CSS 우선순위상 100% 가 이겨 모달이 화면에 딱 붙는다(390px 실측: left=0, right=390).
*
* Modal 이 스스로 clamp 하므로 `max-w-full` 은 불필요하며 해롭다.
*/
const modalFiles = [
'partials/mypage/orders/_modal_cancel.json',
'partials/mypage/orders/_modal_confirm_purchase.json',
'partials/mypage/orders/_modal_write_review.json',
];
for (const file of modalFiles) {
it(`${file} 의 Modal props.className 에 max-w-full 이 없다`, () => {
const layout = loadLayout(file);
expect(layout.name).toBe('Modal');
const cls: string = layout.props?.className ?? '';
expect(cls.split(/\s+/)).not.toContain('max-w-full');
});
}
it('Modal.tsx 패널이 max-w-[calc(100vw-2rem)] 로 화면 폭을 clamp 한다', () => {
const tsx = fs.readFileSync(
path.resolve(__dirname, '../../src/components/composite/Modal.tsx'),
'utf-8'
);
expect(tsx).toContain('max-w-[calc(100vw-2rem)]');
});
});
describe('부분환불 계산 모달 — 좁은 열에서 단어 중간 줄바꿈 금지', () => {
const layout = loadLayout('partials/mypage/orders/_modal_cancel.json');
it('행 라벨(상품쿠폰 할인 등)이 모두 break-keep 이다', () => {
// 390px 에서 3열 grid 의 첫 열은 71px — keep-all 이 없으면 "상품쿠폰 할" / "인" 으로 잘린다
const labels = findAllNodes(
layout,
(n) =>
n.name === 'Span' &&
typeof n.text === 'string' &&
n.text.startsWith('$t:mypage.order_detail.cancel_modal.')
).filter((n) => (n.props?.className ?? '').includes('text-gray-600'));
expect(labels.length).toBeGreaterThan(0);
for (const node of labels) {
expect(node.props.className).toContain('break-keep');
}
});
it('쿠폰 상세 행(쿠폰명 + 할인액)이 모두 break-keep 이다', () => {
// `{{pc.name}} -{{amount}}원` 형태의 P 노드 — 91px 3열에서 한글 단어가 중간에서 잘렸다
const couponLines = findAllNodes(
layout,
(n) =>
n.name === 'P' &&
typeof n.text === 'string' &&
n.text.includes('.name}}') &&
n.text.includes('discount_amount')
);
expect(couponLines.length).toBeGreaterThan(0);
for (const node of couponLines) {
expect(node.props?.className ?? '').toContain('break-keep');
}
});
it('복원 쿠폰 행은 쿠폰명 break-keep + 금액 whitespace-nowrap 이다', () => {
const name = findNode(
layout,
(n) => n.name === 'Span' && typeof n.text === 'string' && n.text.includes('coupon.coupon_name')
);
expect(name).toBeTruthy();
expect(name.props?.className ?? '').toContain('break-keep');
const amount = findNode(
layout,
(n) =>
n.name === 'Span' &&
typeof n.text === 'string' &&
n.text.includes('coupon.discount_amount')
);
expect(amount).toBeTruthy();
expect(amount.props?.className ?? '').toContain('whitespace-nowrap');
});
});
describe('게시글 상단 네비 — 긴 게시판명이 좌우 버튼을 압착하지 않는다', () => {
const layout = loadLayout('partials/board/show/_navigation.json');
it('컨테이너가 모바일에서 줄바꿈한다 (flex-wrap)', () => {
// 셋을 한 줄에 두면 게시판명이 남은 폭을 먹고 형제를 눌러버린다.
// 390px 실측: '이전글' 버튼 폭이 5자 87px → 11자 73px → 21자 62px 로 줄었다.
expect(layout.props.className).toContain('justify-between');
// portable(0~1023) 이 아니라 mobile(0~767). 버튼 3개 고정폭 261px + 21자 제목 자연폭
// 323px = 584px 이므로 태블릿(콘텐츠 735px)은 한 줄로 충분하다.
expect(layout.responsive?.portable).toBeUndefined();
const mobile: string = layout.responsive?.mobile?.props?.className ?? '';
expect(mobile).toContain('flex-wrap');
expect(mobile).toContain('justify-between');
// 줄바꿈 시 두 행이 붙지 않도록 세로 gap 이 필요하다
expect(mobile).toMatch(/\bgap-y-\d/);
});
it('게시판명이 모바일에서 자기 줄을 차지한다 (order-first w-full)', () => {
const title = findNode(
layout,
(n) => n.name === 'Span' && typeof n.text === 'string' && n.text.includes('board?.name')
);
expect(title).toBeTruthy();
const mobile: string = title.responsive?.mobile?.props?.className ?? '';
const tokens = mobile.split(/\s+/);
expect(tokens).toContain('order-first');
expect(tokens).toContain('w-full');
});
it('목록 버튼과 이전/다음 그룹은 shrink-0 이라 눌리지 않는다', () => {
const backBtn = findNode(
layout,
(n) => n.name === 'Button' && findNode(n, (c) => c.text === '$t:board.back_to_list') !== null
);
expect(backBtn).toBeTruthy();
expect(backBtn.props.className.split(/\s+/)).toContain('shrink-0');
// 루트 컨테이너도 이전글/다음글 을 자손으로 갖는다. 직계 자식이 두 Button 인 노드만 고른다.
const prevNextGroup = findNode(
layout,
(n) =>
n.name === 'Div' &&
Array.isArray(n.children) &&
n.children.length === 2 &&
n.children.every((c: any) => c.name === 'Button') &&
findNode(n, (c: any) => c.text === '$t:board.prev') !== null &&
findNode(n, (c: any) => c.text === '$t:board.next') !== null
);
expect(prevNextGroup).toBeTruthy();
expect(prevNextGroup.props.className.split(/\s+/)).toContain('shrink-0');
});
});
describe('해외주소 도시/주 입력 — 무접두 grid-cols-2 금지', () => {
const files = [
'partials/shop/_modal_address_manage.json',
'partials/mypage/addresses/_modal_address.json',
];
for (const file of files) {
it(`${file} 의 2열 행은 모두 좁은 화면 대비가 있다 (sm: 접두 또는 responsive.portable)`, () => {
const layout = loadLayout(file);
// 390px 에서 2열을 그대로 유지하는 노드를 찾는다.
// 허용되는 두 가지 대비책:
// (a) Tailwind breakpoint 접두 — "grid-cols-1 sm:grid-cols-2"
// (b) 엔진 노드 레벨 responsive.portable(0~1023px) 오버라이드 — 받는분/연락처 행 패턴
const offenders = findAllNodes(layout, (n) => {
const cls = n.props?.className;
if (typeof cls !== 'string' || !cls.split(/\s+/).includes('grid-cols-2')) return false;
const portableCls = n.responsive?.portable?.props?.className;
const portableIsSingleColumn =
typeof portableCls === 'string' && portableCls.split(/\s+/).includes('grid-cols-1');
return !portableIsSingleColumn;
});
expect(offenders.map((n) => n.props.className)).toEqual([]);
});
it(`${file} 의 도시/주 행은 grid-cols-1 sm:grid-cols-2 다`, () => {
const layout = loadLayout(file);
const row = findNode(layout, (n) => {
const cls = n.props?.className;
return typeof cls === 'string' && cls.includes('sm:grid-cols-2') && cls.includes('gap-2');
});
expect(row).toBeTruthy();
expect(row.props.className.split(/\s+/)).toContain('grid-cols-1');
});
}
});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -85,11 +85,11 @@
}
},
{
"_comment": "헤더 배송국가 셀렉터 목록 주입 (MP08 후속). 활성 국가만 {code, name(현지화), flag_class(fi fi-xx)} 형태로. 해외배송 OFF 시 KR 만 활성이라 셀렉터 자연 숨김(2개↑ 게이트).",
"_comment": "헤더 배송국가 셀렉터 목록 주입 (MP08 후속). 활성 국가만 {code, name(현지화), flag_class(fi fi-xx)} 형태로. 해외배송 OFF 시 KR 만 활성이라 셀렉터 자연 숨김(2개↑ 게이트). 국가명 $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), flag_class: ('fi fi-' + (c.code ?? '').toLowerCase()) }))}}"
"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), flag_class: ('fi fi-' + (c.code ?? '').toLowerCase()) }))}}"
}
},
{
@@ -625,104 +625,6 @@
}
]
},
{
"comment": "언어 선택(모바일) — 데스크톱 헤더와 동일하게 통화 옆 같은 줄에 노출(globe + 현재 로케일 코드). 비회원 포함 전체. setLocale 은 코어 빌트인.",
"id": "mobile_lang_selector_wrap",
"type": "basic",
"name": "Div",
"if": "{{$locales.length > 1}}",
"props": { "className": "relative" },
"children": [
{
"id": "mobile_lang_btn",
"type": "basic",
"name": "Button",
"props": {
"className": "flex items-center gap-1 px-2 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg cursor-pointer",
"aria-haspopup": "listbox",
"aria-expanded": "{{_global.mobileLangMenuOpen ?? false}}",
"aria-label": "$t:common.language"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": { "target": "global", "mobileLangMenuOpen": "{{!_global.mobileLangMenuOpen}}" }
}
],
"children": [
{ "type": "basic", "name": "Icon", "size": "sm", "props": { "name": "globe" } },
{ "type": "basic", "name": "Span", "props": { "className": "font-medium uppercase" }, "text": "{{$locale}}" },
{ "type": "basic", "name": "Icon", "size": "xs", "props": { "name": "chevron-down" } }
]
},
{
"comment": "드롭다운 메뉴",
"id": "mobile_lang_menu",
"type": "basic",
"name": "Div",
"if": "{{_global.mobileLangMenuOpen}}",
"props": {
"className": "absolute right-0 top-full mt-2 w-40 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"
},
"children": [
{
"type": "basic",
"name": "Div",
"iteration": { "source": "{{$locales}}", "item_var": "loc" },
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"className": "{{loc === $locale ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}} w-full px-4 py-2.5 text-left text-sm flex items-center gap-3 cursor-pointer",
"role": "option",
"aria-selected": "{{loc === $locale}}"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{ "handler": "setLocale", "target": "{{loc}}" },
{ "handler": "setState", "params": { "target": "global", "mobileLangMenuOpen": false } }
]
}
],
"children": [
{ "type": "basic", "name": "Icon", "size": "sm", "props": { "name": "globe" } },
{ "type": "basic", "name": "Span", "props": { "className": "flex-1 font-medium" }, "text": "{{_global.appConfig?.localeNames?.[loc] ?? loc}}" },
{ "type": "basic", "name": "Icon", "size": "sm", "if": "{{loc === $locale}}", "props": { "name": "check" } }
]
}
]
}
]
},
{
"comment": "배경 클릭 시 닫기",
"id": "mobile_lang_backdrop",
"type": "basic",
"name": "Div",
"if": "{{_global.mobileLangMenuOpen}}",
"props": { "className": "fixed inset-0 z-40" },
"actions": [
{ "type": "click", "handler": "setState", "params": { "target": "global", "mobileLangMenuOpen": false } }
]
}
]
},
{
"comment": "통화 선택 — 이커머스 모듈이 'header_currency' 슬롯에 주입(layout_extensions). 헤더는 슬롯 이름만 알고 통화/모듈을 모름. 모듈 비활성 시 빈 슬롯 → 미렌더(인프라 자동 게이트). 데스크톱 Header.tsx 의 SlotContainer 와 동일 슬롯 공유(언어와 같은 줄).",
"id": "mobile_currency_selector_wrap",
"type": "composite",
"name": "SlotContainer",
"props": {
"slotId": "header_currency",
"className": "flex items-center"
}
},
{
"comment": "사용자 아이콘 (로그인 시) / 로그인 버튼",
"id": "mobile_user_btn",
@@ -900,6 +802,129 @@
}
]
},
{
"comment": "언어/통화 선택 — 회원·비회원 공통. 헤더 우측 그룹에서 이동(모바일 오버플로 해소). 통화는 이커머스 모듈이 header_currency 슬롯에 주입하므로 SlotContainer 만 배치(모듈 비활성 시 자동 미렌더).",
"id": "mobile_drawer_prefs",
"type": "basic",
"name": "Div",
"props": {
"className": "px-4 pb-4 border-b border-gray-200 dark:border-gray-700 space-y-3"
},
"children": [
{
"comment": "언어 선택 (여러 언어 지원 시). 접기/펼치기 — 접힘이 기본이고 트리거에 현재 언어를 요약 표기한다. 통화/배송국가(이커머스 주입 슬롯)와는 독립된 토글(_global.mobileLanguageOpen)이라 서로 영향 없음.",
"id": "mobile_drawer_language",
"type": "basic",
"name": "Div",
"if": "{{$locales.length > 1}}",
"children": [
{
"comment": "트리거 — 좌측 '언어' 라벨, 우측 현재 언어명 + chevron. 접힘 상태에서 현재 값을 알 수 있어야 하므로 요약을 항상 노출한다.",
"id": "mobile_drawer_language_toggle",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "w-full flex items-center justify-between py-1 cursor-pointer",
"aria-expanded": "{{_global.mobileLanguageOpen ?? false}}"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "global",
"mobileLanguageOpen": "{{!_global.mobileLanguageOpen}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"
},
"text": "$t:common.language"
},
{
"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.appConfig?.localeNames?.[$locale] ?? $locale}}"
},
{
"type": "basic",
"name": "Icon",
"props": {
"name": "chevron-down",
"className": "{{_global.mobileLanguageOpen ? 'rotate-180' : ''}} text-xs text-gray-400 dark:text-gray-500 transition-transform duration-200"
}
}
]
}
]
},
{
"comment": "펼침 영역 — 사용자 정보 아코디언과 동일한 max-h/opacity 트랜지션. overflow-hidden 이라 접힘 시 칩이 잘려 사라진다.",
"type": "basic",
"name": "Div",
"props": {
"className": "overflow-hidden transition-all duration-300 ease-in-out {{_global.mobileLanguageOpen ? 'max-h-screen opacity-100 mt-2' : 'max-h-0 opacity-0 mt-0'}}"
},
"children": [
{
"comment": "가로 나열 칩. iteration 은 반드시 Button 자신에 건다 — 엔진은 iteration 을 가진 노드를 항목 수만큼 복제하므로, flex 컨테이너에 걸면 컨테이너가 복제되어 칩이 세로로 쌓인다.",
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-wrap gap-2"
},
"children": [
{
"type": "basic",
"name": "Button",
"iteration": { "source": "{{$locales}}", "item_var": "loc" },
"props": {
"type": "button",
"className": "{{loc === $locale ? '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'}} inline-flex items-center gap-1.5 px-3 py-1.5 text-sm border rounded-full cursor-pointer whitespace-nowrap",
"role": "option",
"aria-selected": "{{loc === $locale}}"
},
"actions": [
{ "type": "click", "handler": "setLocale", "target": "{{loc}}" }
],
"children": [
{ "type": "basic", "name": "Icon", "props": { "name": "globe", "size": "sm" } },
{ "type": "basic", "name": "Span", "text": "{{_global.appConfig?.localeNames?.[loc] ?? loc}}" }
]
}
]
}
]
}
]
},
{
"comment": "통화 선택 — 이커머스 모듈 주입 슬롯(헤더에서 이동). 모듈 미설치 시 빈 슬롯 → 렌더 영향 0. 헤더는 가로 flex 였으나 드로어는 세로 블록이므로 block 으로 둔다(flex items-center 면 주입 루트가 콘텐츠 폭으로 줄어 펼친 목록이 좁아짐). 주입 루트가 portable 에서 스스로 w-full 을 갖는다.",
"id": "mobile_drawer_currency_wrap",
"type": "composite",
"name": "SlotContainer",
"props": {
"slotId": "header_currency",
"className": "block"
}
}
]
},
{
"comment": "로그인된 사용자 정보 - 아코디언 토글",
"type": "basic",
@@ -1148,64 +1173,6 @@
}
]
},
{
"comment": "언어 선택 (여러 언어 지원 시)",
"type": "basic",
"name": "Div",
"if": "{{$locales.length > 1}}",
"props": {
"className": "mt-2 pt-2 border-t border-gray-200 dark:border-gray-600"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "px-4 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"
},
"text": "$t:common.language"
},
{
"type": "basic",
"name": "Div",
"iteration": {
"source": "{{$locales}}",
"item_var": "loc"
},
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"className": "{{loc === $locale ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300'}} w-full flex items-center justify-start gap-3 px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-600 cursor-pointer"
},
"actions": [
{
"type": "click",
"handler": "setLocale",
"target": "{{loc}}"
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "globe",
"size": "sm"
}
},
{
"type": "basic",
"name": "Span",
"text": "{{_global.appConfig?.localeNames?.[loc] ?? loc}}"
}
]
}
]
}
]
},
{
"comment": "로그아웃 버튼",
"type": "basic",
@@ -371,7 +371,7 @@
"name": "Div",
"if": "{{form_meta?.data && !_global.currentUser?.uuid && !route.id}}",
"props": {
"className": "grid grid-cols-2 gap-4"
"className": "grid grid-cols-1 sm:grid-cols-2 gap-4"
},
"children": [
{
@@ -40,7 +40,7 @@
"name": "Div",
"if": "{{!_global.currentUser?.uuid}}",
"props": {
"className": "flex gap-3"
"className": "flex flex-col sm:flex-row gap-3"
},
"children": [
{
@@ -9,13 +9,19 @@
"props": {
"className": "flex items-center justify-between mb-6"
},
"responsive": {
"mobile": {
"comment": "모바일(0~767px)에서는 게시판명을 자기 줄로 올리고(order-first w-full) 아래 줄에 목록/이전글/다음글만 남긴다. 한 줄에 셋을 밀어넣으면 게시판명이 길수록 flex 가 좌우 버튼을 압착했다 (390px 실측: '이전글' 폭 5자 87px → 11자 73px → 21자 62px, chevron 과 텍스트가 겹침). 버튼 3개 고정폭 261px + 21자 제목 자연폭 323px = 584px 이므로 태블릿(768px+, 콘텐츠 735px)은 한 줄로 충분하다 — portable 이 아니라 mobile 로 한정한다.",
"props": { "className": "flex flex-wrap items-center justify-between gap-y-3 mb-6" }
}
},
"children": [
{
"comment": "목록으로 버튼 — 상세 URL 에 실린 목록 상태(category/search/page/del/filters)를 그대로 목록으로 복귀 (45-1)",
"comment": "목록으로 버튼 — 상세 URL 에 실린 목록 상태(category/search/page/del/filters)를 그대로 목록으로 복귀 (45-1). shrink-0: 게시판명이 길어도 눌리지 않는다.",
"type": "basic",
"name": "Button",
"props": {
"className": "flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-white bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-all shadow-sm cursor-pointer"
"className": "shrink-0 flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-white bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-all shadow-sm cursor-pointer"
},
"actions": [
{
@@ -51,13 +57,20 @@
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white"
},
"responsive": {
"mobile": {
"comment": "order-first + w-full 로 첫 줄 전체를 차지. 이 노드가 형제를 압착하던 원인이므로 폭 경쟁에서 아예 빼낸다.",
"props": { "className": "order-first w-full text-center text-lg font-semibold text-gray-900 dark:text-white" }
}
},
"text": "{{post?.data?.board?.name ?? ''}}"
},
{
"comment": "이전글/다음글 그룹. shrink-0: 게시판명이 길어도 눌리지 않는다.",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
"className": "shrink-0 flex items-center gap-2"
},
"children": [
{
@@ -409,7 +409,7 @@
{
"type": "basic",
"name": "Div",
"props": { "className": "grid grid-cols-2 gap-2" },
"props": { "className": "grid grid-cols-1 sm:grid-cols-2 gap-2" },
"children": [
{
"type": "basic",
@@ -9,7 +9,6 @@
"id": "modal_cancel_order",
"props": {
"width": "750px",
"className": "max-w-full",
"title": "$t:mypage.order_detail.cancel_modal.title"
},
"actions": [
@@ -372,7 +371,7 @@
"name": "Div",
"props": { "className": "grid grid-cols-3 gap-2 py-1.5 text-sm" },
"children": [
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400" }, "text": "$t:mypage.order_detail.cancel_modal.item_count" },
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400 break-keep" }, "text": "$t:mypage.order_detail.cancel_modal.item_count" },
{ "type": "basic", "name": "Span", "props": { "className": "text-right text-gray-900 dark:text-white" }, "text": "{{(_local.refundEstimate?.original_snapshot?.item_count ?? 0)}}$t:mypage.order_detail.cancel_modal.count_unit" },
{ "type": "basic", "name": "Span", "props": { "className": "text-right {{(_local.refundEstimate?.original_snapshot?.item_count ?? 0) !== (_local.refundEstimate?.recalculated_snapshot?.item_count ?? 0) ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-900 dark:text-white'}}" }, "text": "{{(_local.refundEstimate?.recalculated_snapshot?.item_count ?? 0)}}$t:mypage.order_detail.cancel_modal.count_unit" }
]
@@ -383,7 +382,7 @@
"name": "Div",
"props": { "className": "grid grid-cols-3 gap-2 py-1.5 text-sm" },
"children": [
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400" }, "text": "$t:mypage.order_detail.cancel_modal.list_price_amount" },
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400 break-keep" }, "text": "$t:mypage.order_detail.cancel_modal.list_price_amount" },
{
"type": "basic",
"name": "Div",
@@ -440,7 +439,7 @@
"name": "Div",
"props": { "className": "grid grid-cols-3 gap-2 py-1.5 text-sm" },
"children": [
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400" }, "text": "$t:mypage.order_detail.cancel_modal.subtotal_amount" },
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400 break-keep" }, "text": "$t:mypage.order_detail.cancel_modal.subtotal_amount" },
{
"type": "basic",
"name": "Div",
@@ -502,7 +501,7 @@
"name": "Div",
"props": { "className": "grid grid-cols-3 gap-2 text-sm" },
"children": [
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400" }, "text": "$t:mypage.order_detail.cancel_modal.product_coupon_discount" },
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400 break-keep" }, "text": "$t:mypage.order_detail.cancel_modal.product_coupon_discount" },
{ "type": "basic", "name": "Span", "props": { "className": "text-right text-gray-900 dark:text-white" }, "text": "-{{_local.refundEstimate?.original_snapshot?.formatted?.total_product_coupon_discount_amount ?? (_local.refundEstimate?.original_snapshot?.total_product_coupon_discount_amount ?? 0).toLocaleString()}}" },
{ "type": "basic", "name": "Span", "props": { "className": "text-right {{(_local.refundEstimate?.original_snapshot?.total_product_coupon_discount_amount ?? 0) !== (_local.refundEstimate?.recalculated_snapshot?.total_product_coupon_discount_amount ?? 0) ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-900 dark:text-white'}}" }, "text": "-{{_local.refundEstimate?.recalculated_snapshot?.formatted?.total_product_coupon_discount_amount ?? (_local.refundEstimate?.recalculated_snapshot?.total_product_coupon_discount_amount ?? 0).toLocaleString()}}" }
]
@@ -528,7 +527,7 @@
"item_var": "pc",
"index_var": "pcIdx"
},
"props": { "className": "text-xs text-gray-400 dark:text-gray-500" },
"props": { "className": "text-xs text-gray-400 dark:text-gray-500 break-keep" },
"text": "{{pc.name}} -{{(pc.discount_amount ?? 0).toLocaleString()}}$t:common.currency_unit"
}
]
@@ -546,7 +545,7 @@
"item_var": "rpc",
"index_var": "rpcIdx"
},
"props": { "className": "text-xs text-gray-400 dark:text-gray-500" },
"props": { "className": "text-xs text-gray-400 dark:text-gray-500 break-keep" },
"text": "{{rpc.name}} -{{(rpc.discount_amount ?? 0).toLocaleString()}}$t:common.currency_unit"
}
]
@@ -566,7 +565,7 @@
"name": "Div",
"props": { "className": "grid grid-cols-3 gap-2 text-sm" },
"children": [
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400" }, "text": "$t:mypage.order_detail.cancel_modal.order_coupon_discount" },
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400 break-keep" }, "text": "$t:mypage.order_detail.cancel_modal.order_coupon_discount" },
{ "type": "basic", "name": "Span", "props": { "className": "text-right text-gray-900 dark:text-white" }, "text": "-{{_local.refundEstimate?.original_snapshot?.formatted?.total_order_coupon_discount_amount ?? (_local.refundEstimate?.original_snapshot?.total_order_coupon_discount_amount ?? 0).toLocaleString()}}" },
{ "type": "basic", "name": "Span", "props": { "className": "text-right {{(_local.refundEstimate?.original_snapshot?.total_order_coupon_discount_amount ?? 0) !== (_local.refundEstimate?.recalculated_snapshot?.total_order_coupon_discount_amount ?? 0) ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-900 dark:text-white'}}" }, "text": "-{{_local.refundEstimate?.recalculated_snapshot?.formatted?.total_order_coupon_discount_amount ?? (_local.refundEstimate?.recalculated_snapshot?.total_order_coupon_discount_amount ?? 0).toLocaleString()}}" }
]
@@ -592,7 +591,7 @@
"item_var": "oc",
"index_var": "ocIdx"
},
"props": { "className": "text-xs text-gray-400 dark:text-gray-500" },
"props": { "className": "text-xs text-gray-400 dark:text-gray-500 break-keep" },
"text": "{{oc.name}} -{{(oc.discount_amount ?? 0).toLocaleString()}}$t:common.currency_unit"
}
]
@@ -610,7 +609,7 @@
"item_var": "roc",
"index_var": "rocIdx"
},
"props": { "className": "text-xs text-gray-400 dark:text-gray-500" },
"props": { "className": "text-xs text-gray-400 dark:text-gray-500 break-keep" },
"text": "{{roc.name}} -{{(roc.discount_amount ?? 0).toLocaleString()}}$t:common.currency_unit"
}
]
@@ -625,7 +624,7 @@
"name": "Div",
"props": { "className": "grid grid-cols-3 gap-2 py-1.5 text-sm" },
"children": [
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400" }, "text": "$t:mypage.order_detail.cancel_modal.code_discount" },
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400 break-keep" }, "text": "$t:mypage.order_detail.cancel_modal.code_discount" },
{ "type": "basic", "name": "Span", "props": { "className": "text-right text-gray-900 dark:text-white" }, "text": "-{{_local.refundEstimate?.original_snapshot?.formatted?.total_code_discount_amount ?? (_local.refundEstimate?.original_snapshot?.total_code_discount_amount ?? 0).toLocaleString()}}" },
{ "type": "basic", "name": "Span", "props": { "className": "text-right {{(_local.refundEstimate?.original_snapshot?.total_code_discount_amount ?? 0) !== (_local.refundEstimate?.recalculated_snapshot?.total_code_discount_amount ?? 0) ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-900 dark:text-white'}}" }, "text": "-{{_local.refundEstimate?.recalculated_snapshot?.formatted?.total_code_discount_amount ?? (_local.refundEstimate?.recalculated_snapshot?.total_code_discount_amount ?? 0).toLocaleString()}}" }
]
@@ -641,7 +640,7 @@
"name": "Div",
"props": { "className": "grid grid-cols-3 gap-2 text-sm" },
"children": [
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400" }, "text": "$t:mypage.order_detail.cancel_modal.shipping_coupon_discount" },
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400 break-keep" }, "text": "$t:mypage.order_detail.cancel_modal.shipping_coupon_discount" },
{ "type": "basic", "name": "Span", "props": { "className": "text-right text-gray-900 dark:text-white" }, "text": "-{{_local.refundEstimate?.original_snapshot?.formatted?.shipping_discount_amount ?? (_local.refundEstimate?.original_snapshot?.shipping_discount_amount ?? 0).toLocaleString()}}" },
{ "type": "basic", "name": "Span", "props": { "className": "text-right {{(_local.refundEstimate?.original_snapshot?.shipping_discount_amount ?? 0) !== (_local.refundEstimate?.recalculated_snapshot?.shipping_discount_amount ?? 0) ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-900 dark:text-white'}}" }, "text": "-{{_local.refundEstimate?.recalculated_snapshot?.formatted?.shipping_discount_amount ?? (_local.refundEstimate?.recalculated_snapshot?.shipping_discount_amount ?? 0).toLocaleString()}}" }
]
@@ -667,7 +666,7 @@
"item_var": "sc",
"index_var": "scIdx"
},
"props": { "className": "text-xs text-gray-400 dark:text-gray-500" },
"props": { "className": "text-xs text-gray-400 dark:text-gray-500 break-keep" },
"text": "{{sc.name}} -{{(sc.discount_amount ?? 0).toLocaleString()}}$t:common.currency_unit"
}
]
@@ -685,7 +684,7 @@
"item_var": "rsc",
"index_var": "rscIdx"
},
"props": { "className": "text-xs text-gray-400 dark:text-gray-500" },
"props": { "className": "text-xs text-gray-400 dark:text-gray-500 break-keep" },
"text": "{{rsc.name}} -{{(rsc.discount_amount ?? 0).toLocaleString()}}$t:common.currency_unit"
}
]
@@ -700,7 +699,7 @@
"name": "Div",
"props": { "className": "grid grid-cols-3 gap-2 py-1.5 text-sm" },
"children": [
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400" }, "text": "$t:mypage.order_detail.cancel_modal.base_shipping" },
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400 break-keep" }, "text": "$t:mypage.order_detail.cancel_modal.base_shipping" },
{ "type": "basic", "name": "Span", "props": { "className": "text-right text-gray-900 dark:text-white" }, "text": "{{_local.refundEstimate?.original_snapshot?.formatted?.base_shipping_amount ?? (_local.refundEstimate?.original_snapshot?.base_shipping_amount ?? 0).toLocaleString()}}" },
{ "type": "basic", "name": "Span", "props": { "className": "text-right {{(_local.refundEstimate?.original_snapshot?.base_shipping_amount ?? 0) !== (_local.refundEstimate?.recalculated_snapshot?.base_shipping_amount ?? 0) ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-900 dark:text-white'}}" }, "text": "{{_local.refundEstimate?.recalculated_snapshot?.formatted?.base_shipping_amount ?? (_local.refundEstimate?.recalculated_snapshot?.base_shipping_amount ?? 0).toLocaleString()}}" }
]
@@ -711,7 +710,7 @@
"name": "Div",
"props": { "className": "grid grid-cols-3 gap-2 py-1.5 text-sm" },
"children": [
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400" }, "text": "$t:mypage.order_detail.cancel_modal.extra_shipping" },
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400 break-keep" }, "text": "$t:mypage.order_detail.cancel_modal.extra_shipping" },
{ "type": "basic", "name": "Span", "props": { "className": "text-right text-gray-900 dark:text-white" }, "text": "{{_local.refundEstimate?.original_snapshot?.formatted?.extra_shipping_amount ?? (_local.refundEstimate?.original_snapshot?.extra_shipping_amount ?? 0).toLocaleString()}}" },
{ "type": "basic", "name": "Span", "props": { "className": "text-right {{(_local.refundEstimate?.original_snapshot?.extra_shipping_amount ?? 0) !== (_local.refundEstimate?.recalculated_snapshot?.extra_shipping_amount ?? 0) ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-900 dark:text-white'}}" }, "text": "{{_local.refundEstimate?.recalculated_snapshot?.formatted?.extra_shipping_amount ?? (_local.refundEstimate?.recalculated_snapshot?.extra_shipping_amount ?? 0).toLocaleString()}}" }
]
@@ -722,7 +721,7 @@
"name": "Div",
"props": { "className": "grid grid-cols-3 gap-2 py-1.5 text-sm" },
"children": [
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400" }, "text": "$t:mypage.order_detail.cancel_modal.points_used" },
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400 break-keep" }, "text": "$t:mypage.order_detail.cancel_modal.points_used" },
{ "type": "basic", "name": "Span", "props": { "className": "text-right text-gray-900 dark:text-white" }, "text": "-{{_local.refundEstimate?.original_snapshot?.formatted?.total_points_used_amount ?? (_local.refundEstimate?.original_snapshot?.total_points_used_amount ?? 0).toLocaleString()}}" },
{ "type": "basic", "name": "Span", "props": { "className": "text-right {{(_local.refundEstimate?.original_snapshot?.total_points_used_amount ?? 0) !== (_local.refundEstimate?.recalculated_snapshot?.total_points_used_amount ?? 0) ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-900 dark:text-white'}}" }, "text": "-{{_local.refundEstimate?.recalculated_snapshot?.formatted?.total_points_used_amount ?? (_local.refundEstimate?.recalculated_snapshot?.total_points_used_amount ?? 0).toLocaleString()}}" }
]
@@ -733,7 +732,7 @@
"name": "Div",
"props": { "className": "grid grid-cols-3 gap-2 py-1.5 text-sm" },
"children": [
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400" }, "text": "$t:mypage.order_detail.cancel_modal.taxable_amount" },
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400 break-keep" }, "text": "$t:mypage.order_detail.cancel_modal.taxable_amount" },
{ "type": "basic", "name": "Span", "props": { "className": "text-right text-gray-900 dark:text-white" }, "text": "{{_local.refundEstimate?.original_snapshot?.formatted?.total_tax_amount ?? (_local.refundEstimate?.original_snapshot?.total_tax_amount ?? 0).toLocaleString()}}" },
{ "type": "basic", "name": "Span", "props": { "className": "text-right {{(_local.refundEstimate?.original_snapshot?.total_tax_amount ?? 0) !== (_local.refundEstimate?.recalculated_snapshot?.total_tax_amount ?? 0) ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-900 dark:text-white'}}" }, "text": "{{_local.refundEstimate?.recalculated_snapshot?.formatted?.total_tax_amount ?? (_local.refundEstimate?.recalculated_snapshot?.total_tax_amount ?? 0).toLocaleString()}}" }
]
@@ -744,7 +743,7 @@
"name": "Div",
"props": { "className": "grid grid-cols-3 gap-2 py-1.5 text-sm" },
"children": [
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400" }, "text": "$t:mypage.order_detail.cancel_modal.vat_amount" },
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400 break-keep" }, "text": "$t:mypage.order_detail.cancel_modal.vat_amount" },
{ "type": "basic", "name": "Span", "props": { "className": "text-right text-gray-900 dark:text-white" }, "text": "{{_local.refundEstimate?.original_snapshot?.formatted?.total_vat_amount ?? (_local.refundEstimate?.original_snapshot?.total_vat_amount ?? 0).toLocaleString()}}" },
{ "type": "basic", "name": "Span", "props": { "className": "text-right {{(_local.refundEstimate?.original_snapshot?.total_vat_amount ?? 0) !== (_local.refundEstimate?.recalculated_snapshot?.total_vat_amount ?? 0) ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-900 dark:text-white'}}" }, "text": "{{_local.refundEstimate?.recalculated_snapshot?.formatted?.total_vat_amount ?? (_local.refundEstimate?.recalculated_snapshot?.total_vat_amount ?? 0).toLocaleString()}}" }
]
@@ -812,7 +811,7 @@
"name": "Div",
"props": { "className": "grid grid-cols-3 gap-2 py-1.5 text-sm" },
"children": [
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400" }, "text": "$t:mypage.order_detail.cancel_modal.earned_points" },
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400 break-keep" }, "text": "$t:mypage.order_detail.cancel_modal.earned_points" },
{ "type": "basic", "name": "Span", "props": { "className": "text-right text-gray-900 dark:text-white" }, "text": "{{(_local.refundEstimate?.original_snapshot?.total_earned_points_amount ?? 0).toLocaleString()}}P" },
{ "type": "basic", "name": "Span", "props": { "className": "text-right {{(_local.refundEstimate?.original_snapshot?.total_earned_points_amount ?? 0) !== (_local.refundEstimate?.recalculated_snapshot?.total_earned_points_amount ?? 0) ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-900 dark:text-white'}}" }, "text": "{{(_local.refundEstimate?.recalculated_snapshot?.total_earned_points_amount ?? 0).toLocaleString()}}P" }
]
@@ -892,7 +891,7 @@
{
"type": "basic",
"name": "Span",
"props": { "className": "text-xs font-medium text-gray-600 dark:text-gray-400 block mb-1" },
"props": { "className": "text-xs font-medium text-gray-600 dark:text-gray-400 block mb-1 break-keep" },
"text": "$t:mypage.order_detail.cancel_modal.restored_coupons_label"
},
{
@@ -905,8 +904,8 @@
},
"props": { "className": "flex justify-between text-xs py-0.5" },
"children": [
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400" }, "text": "{{coupon.coupon_name ?? ''}}" },
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-700 dark:text-gray-300" }, "text": "{{(coupon.discount_amount ?? 0).toLocaleString()}}$t:common.currency_unit" }
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-600 dark:text-gray-400 break-keep" }, "text": "{{coupon.coupon_name ?? ''}}" },
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-700 dark:text-gray-300 whitespace-nowrap" }, "text": "{{(coupon.discount_amount ?? 0).toLocaleString()}}$t:common.currency_unit" }
]
},
{
@@ -9,7 +9,6 @@
"id": "modal_confirm_purchase",
"props": {
"width": "480px",
"className": "max-w-full",
"title": "$t:mypage.order_detail.confirm_modal.title"
},
"children": [
@@ -77,7 +76,7 @@
{
"type": "basic",
"name": "Icon",
"props": { "name": "gift", "className": "w-4 h-4 text-blue-600 dark:text-blue-400" }
"props": { "name": "gift", "className": "text-base text-blue-600 dark:text-blue-400" }
},
{
"type": "basic",
@@ -104,7 +103,7 @@
{
"type": "basic",
"name": "Icon",
"props": { "name": "triangle-exclamation", "className": "w-4 h-4 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" }
"props": { "name": "triangle-exclamation", "className": "text-base text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" }
},
{
"type": "basic",
@@ -9,7 +9,6 @@
"id": "modal_write_review",
"props": {
"width": "560px",
"className": "max-w-full",
"title": "$t:mypage.order_detail.review_modal.title"
},
"children": [

Some files were not shown because too many files have changed in this diff Show More