fix(sirsoft-basic): 모바일 뷰포트 가로 오버플로·압착 해소 및 확장 조각 audit 사각지대 수정
390px 실측 결과 26개 라우트 중 24개에서 문서가 뷰포트를 11px 넘겼고, 320px 에서는 햄버거 버튼이 화면 밖으로 밀려 내비게이션 자체가 불가능했다. 원인은 예외 없이 헤더 우측의 언어(71px)+통화(91px) 선택기였다. 이를 모바일 드로어로 옮기고, 좁은 화면에서는 세로 목록 대신 가로 칩으로 나열하도록 했다. 통화·배송국가는 이커머스 모듈이 슬롯에 주입하는 조각이므로 모듈 쪽에도 responsive.portable 오버라이드를 넣었다. 템플릿의 드로어 자리는 overflow-y-auto 라 옛 absolute 드롭다운이 잘리므로, 템플릿의 이커머스 모듈 의존 하한을 >=1.0.2 로 올렸다. 게시글 상단 네비는 성격이 다른 회귀였다. 게시판 이름이 길수록 flex 형제인 목록·이전글·다음글 버튼이 좁아지는데(87→73→62px), document.scrollWidth 는 내내 뷰포트와 같아 오버플로 검사로는 잡히지 않았다. 넘치는 게 아니라 줄어드는 회귀다. shrink-0 으로 버튼 폭을 고정하고, 모바일에서만 게시판 이름을 자기 줄로 올렸다. 버튼 3개 고정폭 261px + 21자 제목 323px = 584px < 태블릿 콘텐츠 735px 이므로 portable 이 아니라 mobile 스코프로 한정했다. 커밋 전 점검에서 audit 결함 두 건이 드러나 함께 고쳤다. 첫째, layout-* 룰 28개 중 27개가 appliesTo 에 확장 주입 조각 경로를 빠뜨려 조각 32개가 정적 검사에서 통째로 빠져 있었다. 실제 위반 60건이 은닉돼 있었고 그중 45건은 error 다. 다만 glob 만 넓히면 layout-grammar 의 root 화이트리스트가 조각의 정당한 키(target_layout/injections/extension_point/mode/priority)를 전부 오탐해 90건의 error 를 새로 만든다. 조각은 레이아웃이 아니라 별도 스키마이므로 extension-fragment 스키마를 신설해 오탐을 없애는 동시에 조각 고유의 결함(대상 키 누락으로 인한 silent drop, target_layout ↔ extension_point 동시 정의, 무동작 조각)을 새로 검출하게 했다. 둘째, Icon 의 w-N h-N 을 size prop 으로 바꾸라는 규정이 틀렸다. Icon 은 <i> 글리프라 박스 크기가 곧 font-size 인데, size prop 은 Font Awesome fa-* 로 매핑되어 상대값 0.875em + line-height 붕괴를 동반한다. 실측하면 w-4 h-4(16×16)가 fa-sm 에서 12.25×0.88 이 된다. 등가는 text-base 하나뿐이다. 앞선 작업에서 size:"sm" 으로 치환했던 2건을 되돌리고, 규정과 룰 메시지를 실측 등가표로 교체했다.
This commit is contained in:
1 parent
0200f1d043
commit
e3c00addd8
33 files changed
+1442
-237
No files matched your search
@@ -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` 의 치환용으로는 쓰지 않는다.
|
||||
|
||||
### 상태 관리
|
||||
|
||||
| 금지 | 올바른 사용 |
|
||||
|
||||
@@ -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-09
|
||||
|
||||
### Changed
|
||||
|
||||
- 휴대폰·태블릿 화면에서 통화·배송국가 선택이 드롭다운 대신 항상 펼쳐진 가로 버튼으로 표시됩니다. 좁은 화면의 메뉴 안에서 드롭다운이 잘려 보이던 문제가 함께 해소됩니다. 데스크톱 화면의 드롭다운은 그대로입니다.
|
||||
|
||||
## [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
@@ -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",
|
||||
|
||||
+4
-4
@@ -63,9 +63,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": [
|
||||
{
|
||||
@@ -119,7 +119,7 @@
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"if": "{{_global.preferredCurrency === currency.code}}",
|
||||
"props": { "name": "check", "className": "w-4 h-4" }
|
||||
"props": { "name": "check", "className": "text-base" }
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
@@ -174,7 +174,7 @@
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"if": "{{(_global.preferredShippingCountry ?? 'KR') === shipCountry.code}}",
|
||||
"props": { "name": "check", "className": "w-4 h-4" }
|
||||
"props": { "name": "check", "className": "text-base" }
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
|
||||
+82
-6
@@ -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": "트리거 버튼 — 데스크톱 전용. 모바일/태블릿(portable)에서는 이 조각이 드로어(overflow-y-auto) 안에 렌더되어 absolute 드롭다운이 잘리므로, 트리거를 숨기고 패널을 항상 펼친다.",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
@@ -22,6 +29,11 @@
|
||||
"aria-expanded": "{{_local.showCurrencyDropdown ?? false}}",
|
||||
"aria-label": "$t:sirsoft-ecommerce.common.currency_label"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": { "className": "hidden" }
|
||||
}
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"comment": "트리거 라벨: 해외배송 활성이면 선택 배송국가 코드 병기 (관리자 헤더와 동일하게 코드 텍스트로 표기 — flag-icons 의존 제거).",
|
||||
@@ -31,9 +43,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 +56,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "드롭다운 메뉴 (통화 + 배송국가 2섹션)",
|
||||
"comment": "드롭다운 메뉴 (통화 + 배송국가 2섹션). portable(0~1023px) 에서는 트리거 없이 항상 펼쳐진 정적 목록 — 드로어 스크롤 경계에서 absolute 패널이 잘리는 문제 회피.",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{_local.showCurrencyDropdown}}",
|
||||
@@ -52,6 +64,15 @@
|
||||
"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": {
|
||||
"if": "{{true}}",
|
||||
"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": "── 통화 섹션 ──",
|
||||
@@ -63,8 +84,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.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 +110,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 +172,8 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -147,8 +193,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 +219,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,6 +291,8 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
+106
@@ -116,6 +116,112 @@ 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)" 을 선언한다.
|
||||
*
|
||||
* SlotContainer 는 주입 자식에게 variant/context prop 을 전달하지 않으므로 컨테이너별로
|
||||
* 다르게 그릴 방법이 이것뿐이다. 데스크톱(≥1024px)은 기존 드롭다운을 그대로 유지한다.
|
||||
*/
|
||||
it('트리거 버튼은 portable(0~1023px)에서 숨겨진다 (모바일 드로어 = 펼친 목록)', () => {
|
||||
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();
|
||||
expect(trigger.responsive?.portable?.props?.className).toBe('hidden');
|
||||
});
|
||||
|
||||
it('패널은 portable 에서 if 해제 + static 흐름 (데스크톱 absolute/z-50 은 보존)', () => {
|
||||
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');
|
||||
|
||||
// portable 오버라이드 — 엔진은 override.if 로 base if 를 완전 교체한다
|
||||
const portable = panel.responsive?.portable;
|
||||
expect(portable).toBeTruthy();
|
||||
expect(portable.if).toBe('{{true}}');
|
||||
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');
|
||||
});
|
||||
|
||||
/**
|
||||
* 모바일(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) &&
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -4,6 +4,33 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.2] - 2026-07-09
|
||||
|
||||
### Changed
|
||||
|
||||
- 이커머스 모듈 최소 요구 버전을 1.0.2 로 상향했습니다. 휴대폰 메뉴 안의 통화·배송국가 선택이 이 템플릿의 새 배치에서 잘리지 않고 표시되려면 해당 버전 이상이 필요합니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
#### 모바일 화면
|
||||
|
||||
- 휴대폰 화면에서 헤더가 가로로 넘쳐 사이트명이 잘리고 메뉴 버튼이 화면 밖으로 밀려나던 문제를 수정했습니다. 언어·통화 선택은 메뉴(햄버거) 안으로 옮겼습니다.
|
||||
- 좁은 화면(320px)에서 메뉴 버튼을 누를 수 없어 사이트 이동 자체가 불가능하던 문제를 수정했습니다.
|
||||
- 메뉴 안의 언어·통화·배송국가 선택이 세로로 길게 늘어지지 않고 가로로 나란히 배치됩니다.
|
||||
- 언어 선택이 로그인하지 않은 방문자에게도 보입니다. 이전에는 메뉴 안에서 로그인한 사용자만 언어를 바꿀 수 있었습니다.
|
||||
- 게시판 이름이 길면 게시글 위쪽의 목록·이전글·다음글 버튼이 찌그러지거나 보이지 않던 문제를 수정했습니다. 이제 게시판 이름이 윗줄에 표시되고 버튼은 아랫줄에 원래 크기로 놓입니다.
|
||||
|
||||
#### 입력 화면
|
||||
|
||||
- 비회원이 글을 쓰거나 댓글을 달 때 이름·비밀번호 입력칸이 화면을 벗어나거나 지나치게 좁아지던 문제를 수정했습니다.
|
||||
- 해외 배송지를 등록할 때 도시·주(州) 입력칸이 좁아 내용을 확인하기 어렵던 문제를 수정했습니다.
|
||||
|
||||
#### 표시
|
||||
|
||||
- 상품 상세의 쿠폰 할인 금액이 두 줄로 잘려 보이던 문제를 수정했습니다. 쿠폰이 많으면 여러 줄로 나눠 표시합니다.
|
||||
- 주문 취소 화면에서 쿠폰 이름과 항목 이름이 단어 중간에서 잘려 읽기 어렵던 문제를 수정했습니다.
|
||||
- 좁은 화면에서 팝업 창이 화면 가장자리에 붙어 보이던 문제를 수정했습니다. 이제 양옆에 여백이 생깁니다.
|
||||
|
||||
## [1.0.1] - 2026-07-08
|
||||
|
||||
### Added
|
||||
|
||||
@@ -9,10 +9,17 @@
|
||||
* - 표시 통화 초기화 핸들러(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 +41,32 @@ 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;
|
||||
}
|
||||
|
||||
describe('A1 — _user_base.json 통화 주입', () => {
|
||||
const userBase = loadJson('layouts/_user_base.json');
|
||||
const initActions = userBase.init_actions ?? [];
|
||||
@@ -119,31 +152,110 @@ 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();
|
||||
|
||||
// [0] = 라벨, [1] = 칩 컨테이너
|
||||
const chipRow = langSection.children[1];
|
||||
expect(chipRow.props.className).toContain('flex');
|
||||
expect(chipRow.props.className).toContain('flex-wrap');
|
||||
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');
|
||||
});
|
||||
|
||||
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 +309,40 @@ 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) 오버라이드를 둔다.
|
||||
*/
|
||||
it('유저 확장 트리거 버튼은 portable(모바일/태블릿)에서 숨겨진다', () => {
|
||||
const node = userExt.injections[0].components[0];
|
||||
const trigger = (node.children ?? []).find((c: any) => c.name === 'Button');
|
||||
expect(trigger).toBeTruthy();
|
||||
expect(trigger.responsive?.portable?.props?.className).toBe('hidden');
|
||||
});
|
||||
|
||||
it('유저 확장 패널은 portable 에서 항상 펼쳐진 정적 목록이다 (absolute/z-50 해제)', () => {
|
||||
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 해제 + 정적 흐름
|
||||
const portable = panel.responsive?.portable;
|
||||
expect(portable).toBeTruthy();
|
||||
expect(portable.if).toBe('{{true}}');
|
||||
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('관리자 확장이 _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');
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1 +1 @@
|
||||
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-scroll-snap-strictness:proximity;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-divide-y-reverse:0;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:""}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-orange-950:oklch(26.6% .079 36.259);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-amber-950:oklch(27.9% .077 45.635);--color-yellow-50:oklch(98.7% .026 102.212);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-300:oklch(90.5% .182 98.111);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-700:oklch(55.4% .135 66.442);--color-yellow-800:oklch(47.6% .114 61.907);--color-yellow-900:oklch(42.1% .095 57.708);--color-yellow-950:oklch(28.6% .066 53.813);--color-lime-50:oklch(98.6% .031 120.757);--color-lime-100:oklch(96.7% .067 122.328);--color-lime-200:oklch(93.8% .127 124.321);--color-lime-300:oklch(89.7% .196 126.665);--color-lime-400:oklch(84.1% .238 128.85);--color-lime-500:oklch(76.8% .233 130.85);--color-lime-600:oklch(64.8% .2 131.684);--color-lime-700:oklch(53.2% .157 131.589);--color-lime-800:oklch(45.3% .124 130.933);--color-lime-900:oklch(40.5% .101 131.063);--color-lime-950:oklch(27.4% .072 132.109);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(Line truncated
|
||||
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-scroll-snap-strictness:proximity;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-divide-y-reverse:0;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:""}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-orange-950:oklch(26.6% .079 36.259);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-amber-950:oklch(27.9% .077 45.635);--color-yellow-50:oklch(98.7% .026 102.212);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-300:oklch(90.5% .182 98.111);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-700:oklch(55.4% .135 66.442);--color-yellow-800:oklch(47.6% .114 61.907);--color-yellow-900:oklch(42.1% .095 57.708);--color-yellow-950:oklch(28.6% .066 53.813);--color-lime-50:oklch(98.6% .031 120.757);--color-lime-100:oklch(96.7% .067 122.328);--color-lime-200:oklch(93.8% .127 124.321);--color-lime-300:oklch(89.7% .196 126.665);--color-lime-400:oklch(84.1% .238 128.85);--color-lime-500:oklch(76.8% .233 130.85);--color-lime-600:oklch(64.8% .2 131.684);--color-lime-700:oklch(53.2% .157 131.589);--color-lime-800:oklch(45.3% .124 130.933);--color-lime-900:oklch(40.5% .101 131.063);--color-lime-950:oklch(27.4% .072 132.109);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(Line truncated
|
||||
@@ -39,7 +39,7 @@ var SirsoftBasic=(function(J,d,k,Jt){"use strict";function Mc(e){const t=Object.
|
||||
[&_tr:hover]:bg-gray-50 dark:[&_tr:hover]:bg-gray-700/50
|
||||
after:content-[''] after:block after:clear-both
|
||||
${n}
|
||||
`.trim().replace(/\s+/g," ");return d.jsx(W,{id:a,className:c,dangerouslySetInnerHTML:{__html:h},...s})},Wf=Object.freeze(Object.defineProperty({__proto__:null,HtmlContent:Yo},Symbol.toStringTag,{value:"Module"})),Xo=()=>window.G7Core,qo=(e,t)=>Xo()?.t?.(e,t)??e,Dl=({content:e="",onChange:t,isHtml:r=!1,onIsHtmlChange:n,rows:o=15,placeholder:a="",label:s,showHtmlModeToggle:l=!0,contentClassName:g="",purifyConfig:h,className:c="",name:i="content",htmlFieldName:u="content_mode",readOnly:f=!1,id:p,editorAttrs:m})=>{const[w,x]=k.useState(r),[A,v]=k.useState(e),[E,O]=k.useState(!1);k.useEffect(()=>{x(r)},[r]),k.useEffect(()=>{v(e)},[e]);const _=k.useCallback(y=>{const N=y.target.value;if(v(N),t){const M=Xo()?.createChangeEvent?.({value:N,name:i,type:"textarea"})??{target:{name:i,value:N}};t(M)}},[t,i]),I=k.useCallback(y=>{const N=y.target.checked;if(x(N),N||O(!1),n){const M=Xo()?.createChangeEvent?.({checked:N,name:u,type:"checkbox"})??{target:{name:u,checked:N}};n(M)}},[n,u]),C=k.useCallback(()=>{O(y=>!y)},[]);return d.jsxs(W,{className:`space-y-2 ${c}`,id:p,...m,children:[d.jsxs(W,{className:"flex items-center justify-between",children:[s&&d.jsx(yr,{className:"block text-sm font-medium text-gray-500 dark:text-gray-400",children:s}),d.jsxs(W,{className:"flex items-center gap-3",children:[w&&!f&&d.jsx(Q,{type:"button",onClick:C,className:`px-3 py-1.5 text-xs font-bold rounded-lg focus:outline-none focus:ring-2 ${E?"text-gray-700 dark:text-gray-200 bg-gray-200 dark:bg-gray-600 border border-gray-300 dark:border-gray-500 hover:bg-gray-300 dark:hover:bg-gray-500 focus:ring-gray-400 dark:focus:ring-gray-500":"text-blue-600 dark:text-blue-400 bg-white dark:bg-gray-700 border border-blue-300 dark:border-blue-600 hover:bg-blue-50 dark:hover:bg-gray-600 focus:ring-blue-500 dark:focus:ring-blue-600"}`,children:qo(E?"common.preview_off":"common.preview")}),l&&d.jsxs(yr,{className:"flex items-center gap-2 p-2 cursor-pointer",children:[d.jsx(xt,{type:"checkbox",name:u,checked:w,onChange:I,disabled:f,className:"w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"}),d.jsx(W,{className:"text-sm font-medium text-gray-700 dark:text-gray-300",children:qo("common.html_mode")})]})]})]}),!E&&d.jsx(no,{name:i,value:A,onChange:_,placeholder:a,rows:o,readOnly:f,className:`block w-full rounded-lg border px-3 py-2 text-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-1 disabled:opacity-50 disabled:cursor-not-allowed ${w?"font-mono bg-white dark:bg-gray-800 border-blue-300 dark:border-blue-600 text-gray-800 dark:text-gray-200 focus:border-blue-500 focus:ring-blue-500":"bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-900 dark:text-white focus:border-blue-500 focus:ring-blue-500"}`}),E&&d.jsx(W,{className:"p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 min-h-[400px]",children:d.jsx(Yo,{content:A,isHtml:!0,className:g||"prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100",purifyConfig:h})})]})},Kf=Object.freeze(Object.defineProperty({__proto__:null,HtmlEditor:Dl},Symbol.toStringTag,{value:"Module"})),Ll=e=>window.G7Core?.t?.(e)??e,Zo=({maxHeight:e=500,expandText:t,collapseText:r,className:n="",children:o,id:a,editorAttrs:s})=>{const[l,g]=k.useState(!1),[h,c]=k.useState(!1),i=k.useRef(null),u=k.useCallback(()=>{if(i.current){const w=i.current.scrollHeight;c(w>e)}},[e]);k.useEffect(()=>{u();const w=i.current;if(!w)return;const x=w.querySelectorAll("img");x.forEach(v=>{v.complete||v.addEventListener("load",u)});let A=null;return typeof ResizeObserver<"u"&&(A=new ResizeObserver(u),A.observe(w)),()=>{x.forEach(v=>{v.removeEventListener("load",u)}),A?.disconnect()}},[u,o]);const f=()=>{g(w=>!w)},p=t||Ll("sirsoft-ecommerce.shop.product.expand_detail"),m=r||Ll("sirsoft-ecommerce.shop.product.collapse_detail");return d.jsxs(W,{className:n,id:a,...s,children:[d.jsxs(W,{className:"relative",children:[d.jsx(W,{ref:i,className:!l&&h?"overflow-hidden transition-[max-height] duration-300 ease-in-out":"transition-[max-height] duration-300 ease-in-out",style:!l&&h?{maxHeight:`${e}px`}:void 0,children:o}),!l&&h&&d.jsx(W,{className:"absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-white dark:from-gray-900 to-transparent pointer-events-none"})]}),h&&d.jsxs(Q,{type:"button",onClick:f,className:"flex items-center justify-center w-full py-3.5 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer gap-1.5",children:[d.jsx(te,{className:"text-sm font-semibold text-gray-600 dark:text-gray-400",children:l?m:p}),d.jsx(de,{name:l?"chevron-up":"chevron-down",size:"sm"})]})]})},Vf=Object.freeze(Object.defineProperty({__proto__:null,ExpandableContent:Zo,default:Zo},Symbol.toStringTag,{value:"Module"})),Yf=(e,t)=>window.G7Core?.t?.(e,t)??e
Line truncated
|
||||
`.trim().replace(/\s+/g," ");return d.jsx(W,{id:a,className:c,dangerouslySetInnerHTML:{__html:h},...s})},Wf=Object.freeze(Object.defineProperty({__proto__:null,HtmlContent:Yo},Symbol.toStringTag,{value:"Module"})),Xo=()=>window.G7Core,qo=(e,t)=>Xo()?.t?.(e,t)??e,Dl=({content:e="",onChange:t,isHtml:r=!1,onIsHtmlChange:n,rows:o=15,placeholder:a="",label:s,showHtmlModeToggle:l=!0,contentClassName:g="",purifyConfig:h,className:c="",name:i="content",htmlFieldName:u="content_mode",readOnly:f=!1,id:p,editorAttrs:m})=>{const[w,x]=k.useState(r),[A,v]=k.useState(e),[E,O]=k.useState(!1);k.useEffect(()=>{x(r)},[r]),k.useEffect(()=>{v(e)},[e]);const _=k.useCallback(y=>{const N=y.target.value;if(v(N),t){const M=Xo()?.createChangeEvent?.({value:N,name:i,type:"textarea"})??{target:{name:i,value:N}};t(M)}},[t,i]),I=k.useCallback(y=>{const N=y.target.checked;if(x(N),N||O(!1),n){const M=Xo()?.createChangeEvent?.({checked:N,name:u,type:"checkbox"})??{target:{name:u,checked:N}};n(M)}},[n,u]),C=k.useCallback(()=>{O(y=>!y)},[]);return d.jsxs(W,{className:`space-y-2 ${c}`,id:p,...m,children:[d.jsxs(W,{className:"flex items-center justify-between",children:[s&&d.jsx(yr,{className:"block text-sm font-medium text-gray-500 dark:text-gray-400",children:s}),d.jsxs(W,{className:"flex items-center gap-3",children:[w&&!f&&d.jsx(Q,{type:"button",onClick:C,className:`px-3 py-1.5 text-xs font-bold rounded-lg focus:outline-none focus:ring-2 ${E?"text-gray-700 dark:text-gray-200 bg-gray-200 dark:bg-gray-600 border border-gray-300 dark:border-gray-500 hover:bg-gray-300 dark:hover:bg-gray-500 focus:ring-gray-400 dark:focus:ring-gray-500":"text-blue-600 dark:text-blue-400 bg-white dark:bg-gray-700 border border-blue-300 dark:border-blue-600 hover:bg-blue-50 dark:hover:bg-gray-600 focus:ring-blue-500 dark:focus:ring-blue-600"}`,children:qo(E?"common.preview_off":"common.preview")}),l&&d.jsxs(yr,{className:"flex items-center gap-2 p-2 cursor-pointer",children:[d.jsx(xt,{type:"checkbox",name:u,checked:w,onChange:I,disabled:f,className:"w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"}),d.jsx(W,{className:"text-sm font-medium text-gray-700 dark:text-gray-300",children:qo("common.html_mode")})]})]})]}),!E&&d.jsx(no,{name:i,value:A,onChange:_,placeholder:a,rows:o,readOnly:f,className:`block w-full rounded-lg border px-3 py-2 text-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-1 disabled:opacity-50 disabled:cursor-not-allowed ${w?"font-mono bg-white dark:bg-gray-800 border-blue-300 dark:border-blue-600 text-gray-800 dark:text-gray-200 focus:border-blue-500 focus:ring-blue-500":"bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-900 dark:text-white focus:border-blue-500 focus:ring-blue-500"}`}),E&&d.jsx(W,{className:"p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 min-h-[400px]",children:d.jsx(Yo,{content:A,isHtml:!0,className:g||"prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100",purifyConfig:h})})]})},Kf=Object.freeze(Object.defineProperty({__proto__:null,HtmlEditor:Dl},Symbol.toStringTag,{value:"Module"})),Ll=e=>window.G7Core?.t?.(e)??e,Zo=({maxHeight:e=500,expandText:t,collapseText:r,className:n="",children:o,id:a,editorAttrs:s})=>{const[l,g]=k.useState(!1),[h,c]=k.useState(!1),i=k.useRef(null),u=k.useCallback(()=>{if(i.current){const w=i.current.scrollHeight;c(w>e)}},[e]);k.useEffect(()=>{u();const w=i.current;if(!w)return;const x=w.querySelectorAll("img");x.forEach(v=>{v.complete||v.addEventListener("load",u)});let A=null;return typeof ResizeObserver<"u"&&(A=new ResizeObserver(u),A.observe(w)),()=>{x.forEach(v=>{v.removeEventListener("load",u)}),A?.disconnect()}},[u,o]);const f=()=>{g(w=>!w)},p=t||Ll("sirsoft-ecommerce.shop.product.expand_detail"),m=r||Ll("sirsoft-ecommerce.shop.product.collapse_detail");return d.jsxs(W,{className:n,id:a,...s,children:[d.jsxs(W,{className:"relative",children:[d.jsx(W,{ref:i,className:!l&&h?"overflow-hidden transition-[max-height] duration-300 ease-in-out":"transition-[max-height] duration-300 ease-in-out",style:!l&&h?{maxHeight:`${e}px`}:void 0,children:o}),!l&&h&&d.jsx(W,{className:"absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-white dark:from-gray-900 to-transparent pointer-events-none"})]}),h&&d.jsxs(Q,{type:"button",onClick:f,className:"flex items-center justify-center w-full py-3.5 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer gap-1.5",children:[d.jsx(te,{className:"text-sm font-semibold text-gray-600 dark:text-gray-400",children:l?m:p}),d.jsx(de,{name:l?"chevron-up":"chevron-down",size:"sm"})]})]})},Vf=Object.freeze(Object.defineProperty({__proto__:null,ExpandableContent:Zo,default:Zo},Symbol.toStringTag,{value:"Module"})),Yf=(e,t)=>window.G7Core?.t?.(e,t)??e
Line truncated
|
||||
let scriptImported = false
|
||||
self.addEventListener('message', async (e) => {
|
||||
const { file, id, imageCompressionLibUrl, options } = e.data
|
||||
@@ -83,5 +83,5 @@ self.addEventListener('message', async (e) => {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
`})]}):null},Rh=Object.freeze(Object.defineProperty({__proto__:null,PageTransitionIndicator:ka,default:ka},Symbol.toStringTag,{value:"Module"})),Vi=window.G7Core?.createLogger?.("Comp:PageTransitionBlur")??{log:(...e)=>console.log("[Comp:PageTransitionBlur]",...e),warn:(...e)=>console.warn("[Comp:PageTransitionBlur]",...e)},Ca=({className:e="",style:t})=>{const[r,n]=k.useState(!1);return k.useEffect(()=>{const o=window.G7Core?.TransitionManager;if(!o){Vi.warn("TransitionManager를 찾을 수 없습니다.");return}return n(o.getIsPending()),o.subscribe(s=>{Vi.log("isPending changed:",s),n(s)})},[]),r?d.jsx(W,{className:`fixed inset-0 z-40 bg-white/30 dark:bg-gray-900/30 backdrop-blur-sm transition-opacity duration-150 pointer-events-none ${e}`,style:t,role:"status","aria-label":"페이지 전환 중","data-testid":"page-transition-blur"}):null},e0=Object.freeze(Object.defineProperty({__proto__:null,PageTransitionBlur:Ca,default:Ca},Symbol.toStringTag,{value:"Module"})),Yi={sm:640,md:768,lg:1024,xl:1280,"2xl":1536},Xi=new Set(["hidden","block","inline-block","inline","flex","inline-flex","grid","inline-grid","table","table-row","table-cell","contents","list-item","flow-root"]),t0=new Set(["Div","Flex","Grid","Section","Article","Nav","Aside","Main","Header","Footer","Fragment","Form","Container"]),r0=new Set(["H1","H2","H3","H4","H5","H6","P","Span","Label","A","Strong","Em","Small","Badge"]),n0=new Set(["Input","Textarea","Select","Checkbox","Radio","Toggle","DatePicker","TagInput","SearchBar"]),o0=new Set(["Img","Image","Avatar","AvatarUploader","ImageGallery","ProductImageViewer","FileUploader"]),qi={DataGrid:(e,t)=>d.jsxs("div",{className:"w-full border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden",children:[d.jsx("div",{className:`h-10 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 ${e}`,children:d.jsxs("div",{className:"flex items-center h-full px-4 gap-8",children:[d.jsx("div",{className:"h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded"}),d.jsx("div",{className:"h-3 w-24 bg-gray-300 dark:bg-gray-600 rounded"}),d.jsx("div",{className:"h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded"}),d.jsx("div",{className:"h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded"})]})}),Array.from({length:t},(r,n)=>d.jsx("div",{className:`h-12 border-b border-gray-100 dark:border-gray-800 ${e}`,style:{animationDelay:`${n*75}ms`},children:d.jsxs("div",{className:"flex items-center h-full px-4 gap-8",children:[d.jsx("div",{className:"h-3 w-12 bg-gray-200 dark:bg-gray-700 rounded"}),d.jsx("div",{className:"h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded"}),d.jsx("div",{className:"h-3 w-16 bg-gray-100 dark:bg-gray-800 rounded"}),d.jsx("div",{className:"h-3 w-14 bg-gray-100 dark:bg-gray-800 rounded"})]})},n))]}),Pagination:e=>d.jsx("div",{className:"flex justify-center gap-1.5 py-3",children:Array.from({length:5},(t,r)=>d.jsx("div",{className:`w-8 h-8 bg-gray-200 dark:bg-gray-700 rounded ${e}`},r))}),Tabs:e=>d.jsxs("div",{children:[d.jsx("div",{className:"flex gap-4 border-b border-gray-200 dark:border-gray-700 pb-2 mb-3",children:Array.from({length:3},(t,r)=>d.jsx("div",{className:`h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded ${e}`},r))}),d.jsx("div",{className:`h-32 bg-gray-100 dark:bg-gray-800 rounded ${e}`})]}),TabNavigation:e=>d.jsx("div",{className:"flex gap-4 border-b border-gray-200 dark:border-gray-700 pb-2",children:Array.from({length:3},(t,r)=>d.jsx("div",{className:`h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded ${e}`},r))}),ProductCard:e=>d.jsxs("div",{className:"rounded-lg overflow-hidden",children:[d.jsx("div",{className:`h-48 w-full bg-gray-200 dark:bg-gray-700 ${e}`}),d.jsxs("div",{className:"p-3 space-y-2",children:[d.jsx("div",{className:`h-3.5 w-3/4 bg-gray-200 dark:bg-gray-700 rounded ${e}`}),d.jsx("div",{className:`h-4 w-1/3 bg-gray-300 dark:bg-gray-600 rounded ${e}`})]})]}),UserInfo:e=>d.jsx("div",{className:`h-3 w-14 bg-gray-200 dark:bg-gray-700 rounded ${e}`}),QuantitySelector:e=>d.jsx("div",{className:`h-9 w-28 bg-gray-100 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 ${e}`}),Header:e=>d.jsx("header",{className:"bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700",children:d.jsx("div",{className:"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8",children:d.jsxs("div",{className:"flex items-center justify-between h-16",children:[d.jsxs("div",{className:"flex items-center gap-8",children:[d.jsx("div",{className:`h-8 w-24 bg-gray-200 dark:bg-gray-700 rounded ${e}`}),d.jsx("div",{className:"hidden lg:flex items-center gap-6",children:Array.from({length:4},(t,r)=>d.jsx("div",{className:`h-3.5 w-16 bg-gray-200 dark:bg-gray-700 rounded ${e}`},r))})]}),d.jsx("div",{className:"flex items-center gap-4",children:Array.from({length:3},(t,r)=>d.jsx("div",{className:`h-8 w-8 bg-gray-200 dark:bg-gray-700 rounded-full ${e}`},r))})]})})}),Footer:e=>d.jsx("footer",{className:"bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-
Line truncated
|
||||
`})]}):null},Rh=Object.freeze(Object.defineProperty({__proto__:null,PageTransitionIndicator:ka,default:ka},Symbol.toStringTag,{value:"Module"})),Vi=window.G7Core?.createLogger?.("Comp:PageTransitionBlur")??{log:(...e)=>console.log("[Comp:PageTransitionBlur]",...e),warn:(...e)=>console.warn("[Comp:PageTransitionBlur]",...e)},Ca=({className:e="",style:t})=>{const[r,n]=k.useState(!1);return k.useEffect(()=>{const o=window.G7Core?.TransitionManager;if(!o){Vi.warn("TransitionManager를 찾을 수 없습니다.");return}return n(o.getIsPending()),o.subscribe(s=>{Vi.log("isPending changed:",s),n(s)})},[]),r?d.jsx(W,{className:`fixed inset-0 z-40 bg-white/30 dark:bg-gray-900/30 backdrop-blur-sm transition-opacity duration-150 pointer-events-none ${e}`,style:t,role:"status","aria-label":"페이지 전환 중","data-testid":"page-transition-blur"}):null},e0=Object.freeze(Object.defineProperty({__proto__:null,PageTransitionBlur:Ca,default:Ca},Symbol.toStringTag,{value:"Module"})),Yi={sm:640,md:768,lg:1024,xl:1280,"2xl":1536},Xi=new Set(["hidden","block","inline-block","inline","flex","inline-flex","grid","inline-grid","table","table-row","table-cell","contents","list-item","flow-root"]),t0=new Set(["Div","Flex","Grid","Section","Article","Nav","Aside","Main","Header","Footer","Fragment","Form","Container"]),r0=new Set(["H1","H2","H3","H4","H5","H6","P","Span","Label","A","Strong","Em","Small","Badge"]),n0=new Set(["Input","Textarea","Select","Checkbox","Radio","Toggle","DatePicker","TagInput","SearchBar"]),o0=new Set(["Img","Image","Avatar","AvatarUploader","ImageGallery","ProductImageViewer","FileUploader"]),qi={DataGrid:(e,t)=>d.jsxs("div",{className:"w-full border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden",children:[d.jsx("div",{className:`h-10 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 ${e}`,children:d.jsxs("div",{className:"flex items-center h-full px-4 gap-8",children:[d.jsx("div",{className:"h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded"}),d.jsx("div",{className:"h-3 w-24 bg-gray-300 dark:bg-gray-600 rounded"}),d.jsx("div",{className:"h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded"}),d.jsx("div",{className:"h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded"})]})}),Array.from({length:t},(r,n)=>d.jsx("div",{className:`h-12 border-b border-gray-100 dark:border-gray-800 ${e}`,style:{animationDelay:`${n*75}ms`},children:d.jsxs("div",{className:"flex items-center h-full px-4 gap-8",children:[d.jsx("div",{className:"h-3 w-12 bg-gray-200 dark:bg-gray-700 rounded"}),d.jsx("div",{className:"h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded"}),d.jsx("div",{className:"h-3 w-16 bg-gray-100 dark:bg-gray-800 rounded"}),d.jsx("div",{className:"h-3 w-14 bg-gray-100 dark:bg-gray-800 rounded"})]})},n))]}),Pagination:e=>d.jsx("div",{className:"flex justify-center gap-1.5 py-3",children:Array.from({length:5},(t,r)=>d.jsx("div",{className:`w-8 h-8 bg-gray-200 dark:bg-gray-700 rounded ${e}`},r))}),Tabs:e=>d.jsxs("div",{children:[d.jsx("div",{className:"flex gap-4 border-b border-gray-200 dark:border-gray-700 pb-2 mb-3",children:Array.from({length:3},(t,r)=>d.jsx("div",{className:`h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded ${e}`},r))}),d.jsx("div",{className:`h-32 bg-gray-100 dark:bg-gray-800 rounded ${e}`})]}),TabNavigation:e=>d.jsx("div",{className:"flex gap-4 border-b border-gray-200 dark:border-gray-700 pb-2",children:Array.from({length:3},(t,r)=>d.jsx("div",{className:`h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded ${e}`},r))}),ProductCard:e=>d.jsxs("div",{className:"rounded-lg overflow-hidden",children:[d.jsx("div",{className:`h-48 w-full bg-gray-200 dark:bg-gray-700 ${e}`}),d.jsxs("div",{className:"p-3 space-y-2",children:[d.jsx("div",{className:`h-3.5 w-3/4 bg-gray-200 dark:bg-gray-700 rounded ${e}`}),d.jsx("div",{className:`h-4 w-1/3 bg-gray-300 dark:bg-gray-600 rounded ${e}`})]})]}),UserInfo:e=>d.jsx("div",{className:`h-3 w-14 bg-gray-200 dark:bg-gray-700 rounded ${e}`}),QuantitySelector:e=>d.jsx("div",{className:`h-9 w-28 bg-gray-100 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 ${e}`}),Header:e=>d.jsx("header",{className:"bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700",children:d.jsx("div",{className:"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8",children:d.jsxs("div",{className:"flex items-center justify-between h-16",children:[d.jsxs("div",{className:"flex items-center gap-8",children:[d.jsx("div",{className:`h-8 w-24 bg-gray-200 dark:bg-gray-700 rounded ${e}`}),d.jsx("div",{className:"hidden lg:flex items-center gap-6",children:Array.from({length:4},(t,r)=>d.jsx("div",{className:`h-3.5 w-16 bg-gray-200 dark:bg-gray-700 rounded ${e}`},r))})]}),d.jsx("div",{className:"flex items-center gap-4",children:Array.from({length:3},(t,r)=>d.jsx("div",{className:`h-8 w-8 bg-gray-200 dark:bg-gray-700 rounded-full ${e}`},r))})]})})}),Footer:e=>d.jsx("footer",{className:"bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-
Line truncated
|
||||
//# sourceMappingURL=components.iife.js.map
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"components.iife.js","sources":["../../src/components/basic/Button.tsx","../../src/components/basic/Div.tsx","../../src/components/basic/Span.tsx","../../src/components/basic/Svg.tsx","../../src/components/basic/Input.tsx","../../src/components/basic/FileInput.tsx","../../src/components/basic/IconTypes.ts","../../src/components/basic/Icon.tsx","../../src/components/basic/PasswordInput.tsx","../../src/components/basic/Textarea.tsx","../../src/components/basic/Label.tsx","../../src/components/basic/P.tsx","../../src/components/basic/Img.tsx","../../src/components/basic/H1.tsx","../../src/components/basic/H2.tsx","../../src/components/basic/H3.tsx","../../src/components/basic/H4.tsx","../../src/components/basic/Ul.tsx","../../src/components/basic/Ol.tsx","../../src/components/basic/Li.tsx","../../src/components/basic/A.tsx","../../src/components/basic/Form.tsx","../../src/components/basic/Select.tsx","../../src/components/basic/Option.tsx","../../src/components/basic/Optgroup.tsx","../../src/components/basic/Checkbox.tsx","../../src/components/basic/Table.tsx","../../src/components/basic/Thead.tsx","../../src/components/basic/Tbody.tsx","../../src/components/basic/Tr.tsx","../../src/components/basic/Th.tsx","../../src/components/basic/Td.tsx","../../src/components/basic/Nav.tsx","../../src/components/basic/Section.tsx","../../src/components/basic/Code.tsx","../../src/components/basic/Hr.tsx","../../src/components/basic/Header.tsx","../../src/components/composite/ThemeToggle.tsx","../../src/components/composite/Avatar.tsx","../../src/components/composite/SlotContainer.tsx","../../src/components/composite/NotificationCenter.tsx","../../src/components/composite/Header.tsx","../../src/components/basic/Footer.tsx","../../src/components/composite/Footer.tsx","../../src/components/composite/MobileNav.tsx","../../node_modules/dompurify/dist/purify.es.mjs","../../src/components/composite/ProductCard.tsx","../../node_modules/yet-another-react-lightbox/dist/types.js","../../node_modules/yet-another-react-lightbox/dist/index.js","../../node_modules/yet-another-react-lightbox/dist/plugins/zoom/index.js","../../node_modules/yet-another-react-lightbox/dist/plugins/counter/index.js","../../node_modules/yet-another-react-lightbox/dist/plugins/slideshow/index.js","../../node_modules/yet-another-react-lightbox/dist/plugins/fullscreen/index.js","../../node_modules/yet-another-react-lightbox/dist/plugins/thumbnails/index.js","../../src/components/basic/I.tsx","../../src/components/composite/ImageGallery.tsx","../../src/components/composite/ProductImageViewer.tsx","../../src/components/composite/QuantitySelector.tsx","../../src/components/composite/PostReactions.tsx","../../src/components/composite/RichTextEditor.tsx","../../src/components/composite/HtmlContent.tsx","../../src/components/composite/HtmlEditor.tsx","../../src/components/composite/ExpandableContent.tsx","../../src/components/composite/Modal.tsx","../../src/components/composite/ConfirmDialog.tsx","../../src/components/composite/FileUploader/FileUploaderContext.tsx","../../node_modules/browser-image-compression/dist/browser-image-compression.mjs","../../src/components/composite/FileUploader/utils.ts","../../src/components/composite/FileUploader/useFileUploader.ts","../../src/components/composite/FileUploader/FileDropZone.tsx","../../node_modules/@dnd-kit/utilities/dist/utilities.esm.js","../../node_modules/@dnd-kit/accessibility/dist/accessibility.esm.js","../../node_modules/@dnd-kit/core/dist/core.esm.js","../../node_modules/@dnd-kit/sortable/dist/sortable.esm.js","../../src/components/composite/FileUploader/SortableThumbnailItem.tsx","../../src/components/composite/FileUploader/FileList.tsx","../../src/components/composite/FileUploader/FileUploader.tsx","../../src/components/composite/SocialLoginButtons.tsx","../../src/components/composite/Toast.tsx","../../src/components/composite/PageTransitionIndicator.tsx","../../src/components/composite/PageTransitionBlur.tsx","../../src/components/composite/PageSkeleton.tsx","../../src/components/composite/PageLoading.tsx","../../src/components/composite/Pagination.tsx","../../src/components/composite/SearchBar.tsx","../../src/components/composite/AvatarUploader.tsx","../../src/components/composite/UserInfo.tsx","../../src/components/composite/TabNavigation.tsx","../../src/components/composite/index.ts","../../src/components/layout/Container.tsx","../../src/components/layout/Grid.tsx","../../src/components/layout/Flex.tsx","../../src/components/layout/SectionLayout.tsx","../../src/components/layout/ThreeColumnLayout.tsx","../../src/handlers/productOptions.ts","../../src/handlers/cartHandlers.ts","../../src/handlers/cartOptionChange.ts","../../src/handlers/storageHandlers.ts","../../src/handlers/getDisplayPrice.ts","../../src/handlers/formatCurrency.ts","../../src/handlers/loadPreferredCurrency.ts","../../src/handlers/setThemeHandler.ts","../../src/handlers/redirectToLoginWithReturn.ts","../../src/handlers/doLine truncated
|
||||
{"version":3,"file":"components.iife.js","sources":["../../src/components/basic/Button.tsx","../../src/components/basic/Div.tsx","../../src/components/basic/Span.tsx","../../src/components/basic/Svg.tsx","../../src/components/basic/Input.tsx","../../src/components/basic/FileInput.tsx","../../src/components/basic/IconTypes.ts","../../src/components/basic/Icon.tsx","../../src/components/basic/PasswordInput.tsx","../../src/components/basic/Textarea.tsx","../../src/components/basic/Label.tsx","../../src/components/basic/P.tsx","../../src/components/basic/Img.tsx","../../src/components/basic/H1.tsx","../../src/components/basic/H2.tsx","../../src/components/basic/H3.tsx","../../src/components/basic/H4.tsx","../../src/components/basic/Ul.tsx","../../src/components/basic/Ol.tsx","../../src/components/basic/Li.tsx","../../src/components/basic/A.tsx","../../src/components/basic/Form.tsx","../../src/components/basic/Select.tsx","../../src/components/basic/Option.tsx","../../src/components/basic/Optgroup.tsx","../../src/components/basic/Checkbox.tsx","../../src/components/basic/Table.tsx","../../src/components/basic/Thead.tsx","../../src/components/basic/Tbody.tsx","../../src/components/basic/Tr.tsx","../../src/components/basic/Th.tsx","../../src/components/basic/Td.tsx","../../src/components/basic/Nav.tsx","../../src/components/basic/Section.tsx","../../src/components/basic/Code.tsx","../../src/components/basic/Hr.tsx","../../src/components/basic/Header.tsx","../../src/components/composite/ThemeToggle.tsx","../../src/components/composite/Avatar.tsx","../../src/components/composite/SlotContainer.tsx","../../src/components/composite/NotificationCenter.tsx","../../src/components/composite/Header.tsx","../../src/components/basic/Footer.tsx","../../src/components/composite/Footer.tsx","../../src/components/composite/MobileNav.tsx","../../node_modules/dompurify/dist/purify.es.mjs","../../src/components/composite/ProductCard.tsx","../../node_modules/yet-another-react-lightbox/dist/types.js","../../node_modules/yet-another-react-lightbox/dist/index.js","../../node_modules/yet-another-react-lightbox/dist/plugins/zoom/index.js","../../node_modules/yet-another-react-lightbox/dist/plugins/counter/index.js","../../node_modules/yet-another-react-lightbox/dist/plugins/slideshow/index.js","../../node_modules/yet-another-react-lightbox/dist/plugins/fullscreen/index.js","../../node_modules/yet-another-react-lightbox/dist/plugins/thumbnails/index.js","../../src/components/basic/I.tsx","../../src/components/composite/ImageGallery.tsx","../../src/components/composite/ProductImageViewer.tsx","../../src/components/composite/QuantitySelector.tsx","../../src/components/composite/PostReactions.tsx","../../src/components/composite/RichTextEditor.tsx","../../src/components/composite/HtmlContent.tsx","../../src/components/composite/HtmlEditor.tsx","../../src/components/composite/ExpandableContent.tsx","../../src/components/composite/Modal.tsx","../../src/components/composite/ConfirmDialog.tsx","../../src/components/composite/FileUploader/FileUploaderContext.tsx","../../node_modules/browser-image-compression/dist/browser-image-compression.mjs","../../src/components/composite/FileUploader/utils.ts","../../src/components/composite/FileUploader/useFileUploader.ts","../../src/components/composite/FileUploader/FileDropZone.tsx","../../node_modules/@dnd-kit/utilities/dist/utilities.esm.js","../../node_modules/@dnd-kit/accessibility/dist/accessibility.esm.js","../../node_modules/@dnd-kit/core/dist/core.esm.js","../../node_modules/@dnd-kit/sortable/dist/sortable.esm.js","../../src/components/composite/FileUploader/SortableThumbnailItem.tsx","../../src/components/composite/FileUploader/FileList.tsx","../../src/components/composite/FileUploader/FileUploader.tsx","../../src/components/composite/SocialLoginButtons.tsx","../../src/components/composite/Toast.tsx","../../src/components/composite/PageTransitionIndicator.tsx","../../src/components/composite/PageTransitionBlur.tsx","../../src/components/composite/PageSkeleton.tsx","../../src/components/composite/PageLoading.tsx","../../src/components/composite/Pagination.tsx","../../src/components/composite/SearchBar.tsx","../../src/components/composite/AvatarUploader.tsx","../../src/components/composite/UserInfo.tsx","../../src/components/composite/TabNavigation.tsx","../../src/components/composite/index.ts","../../src/components/layout/Container.tsx","../../src/components/layout/Grid.tsx","../../src/components/layout/Flex.tsx","../../src/components/layout/SectionLayout.tsx","../../src/components/layout/ThreeColumnLayout.tsx","../../src/handlers/productOptions.ts","../../src/handlers/cartHandlers.ts","../../src/handlers/cartOptionChange.ts","../../src/handlers/storageHandlers.ts","../../src/handlers/getDisplayPrice.ts","../../src/handlers/formatCurrency.ts","../../src/handlers/loadPreferredCurrency.ts","../../src/handlers/setThemeHandler.ts","../../src/handlers/redirectToLoginWithReturn.ts","../../src/handlers/doLine truncated
|
||||
@@ -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,71 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "언어 선택 (여러 언어 지원 시)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{$locales.length > 1}}",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "py-1 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"
|
||||
},
|
||||
"text": "$t:common.language"
|
||||
},
|
||||
{
|
||||
"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 +1115,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": [
|
||||
{
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+22
-23
@@ -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" }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+2
-3
@@ -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",
|
||||
|
||||
-1
@@ -9,7 +9,6 @@
|
||||
"id": "modal_write_review",
|
||||
"props": {
|
||||
"width": "560px",
|
||||
"className": "max-w-full",
|
||||
"title": "$t:mypage.order_detail.review_modal.title"
|
||||
},
|
||||
"children": [
|
||||
|
||||
@@ -768,7 +768,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",
|
||||
|
||||
@@ -70,10 +70,10 @@
|
||||
"props": { "className": "border-t border-gray-200 dark:border-gray-700 pt-3 mt-3" },
|
||||
"children": [
|
||||
{
|
||||
"comment": "쿠폰 배지 목록 (가로 스크롤)",
|
||||
"comment": "쿠폰 배지 목록 (좁은 화면에서 여러 줄로 줄바꿈 — 칩 라벨 자체는 whitespace-nowrap 으로 한 줄 유지)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "flex items-center gap-2 overflow-x-auto pb-1" },
|
||||
"props": { "className": "flex flex-wrap items-center gap-2 pb-1" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -101,6 +101,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": { "className": "whitespace-nowrap" },
|
||||
"text": "{{dlCoupon.multi_currency_benefit_formatted?.[_global.preferredCurrency ?? 'KRW'] ?? dlCoupon.benefit_formatted}}"
|
||||
},
|
||||
{
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sirsoft-basic",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sirsoft-basic",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sirsoft-basic",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"description": "Gnuboard7 Basic User Template Components - Nexibase Style",
|
||||
"type": "module",
|
||||
"main": "dist/components.js",
|
||||
|
||||
@@ -177,7 +177,7 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
{/* Modal Content */}
|
||||
<Div
|
||||
ref={modalRef}
|
||||
className={`relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-h-[90vh] overflow-hidden ${className}`}
|
||||
className={`relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-h-[90vh] max-w-[calc(100vw-2rem)] overflow-hidden ${className}`}
|
||||
style={{ width, ...(style ? { ...style, zIndex: undefined } : {}) }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "Basic",
|
||||
"en": "Basic"
|
||||
},
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "그누보드7 기본 사용자 템플릿",
|
||||
@@ -26,7 +26,7 @@
|
||||
"dependencies": {
|
||||
"modules": {
|
||||
"sirsoft-board": ">=1.0.0",
|
||||
"sirsoft-ecommerce": ">=1.0.0",
|
||||
"sirsoft-ecommerce": ">=1.0.2",
|
||||
"sirsoft-page": ">=1.0.0"
|
||||
},
|
||||
"plugins": {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* sirsoft-basic 템플릿 Playwright E2E 설정.
|
||||
*
|
||||
* 코어 `playwright.config.ts` 와 동일한 base URL 해석 우선순위를 따른다 — 활성 호스트가
|
||||
* 가변(개발자/CI/운영 환경별로 다른 도메인)이므로 하드코딩 회피.
|
||||
*
|
||||
* Base URL 해석:
|
||||
* 1. PLAYWRIGHT_BASE_URL 환경변수 (CI/명시적 오버라이드)
|
||||
* 2. .env (코어 루트) 의 APP_URL — 단 localhost 류는 fallback 부적합
|
||||
* 3. 그 외 — 명시 에러
|
||||
*
|
||||
* 뷰포트는 각 spec 이 `page.setViewportSize()` 로 직접 지정한다(모바일 390/320px ↔ 데스크톱
|
||||
* 1280px 을 한 파일에서 교차 검증하므로, 프로젝트를 뷰포트별로 나누면 같은 테스트가
|
||||
* 중복 실행된다).
|
||||
*
|
||||
* 실행 예시:
|
||||
* PowerShell — $env:PLAYWRIGHT_BASE_URL='https://example.com'; npx playwright test --config templates/_bundled/sirsoft-basic/tests/Playwright/playwright.config.ts
|
||||
*/
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* .env 파일에서 키 하나를 읽습니다.
|
||||
*
|
||||
* @param filePath .env 파일 절대 경로
|
||||
* @param key 읽을 키 이름
|
||||
* @return 값 (없으면 null)
|
||||
*/
|
||||
function readEnvFile(filePath: string, key: string): string | null {
|
||||
if (!existsSync(filePath)) return null;
|
||||
const content = readFileSync(filePath, { encoding: 'utf-8' });
|
||||
const pattern = new RegExp(`^${key}=(.*)$`, 'm');
|
||||
const match = content.match(pattern);
|
||||
if (!match) return null;
|
||||
let value = match[1].trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
return value || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* E2E base URL 을 해석합니다.
|
||||
*
|
||||
* @return base URL
|
||||
* @throws Error 활성 호스트를 확정할 수 없을 때
|
||||
*/
|
||||
function resolveBaseUrl(): string {
|
||||
if (process.env.PLAYWRIGHT_BASE_URL) {
|
||||
return process.env.PLAYWRIGHT_BASE_URL;
|
||||
}
|
||||
// 템플릿 디렉토리 기준 5단계 상위 = 코어 루트 (.env 위치)
|
||||
const coreRoot = process.env.G7_ROOT || resolve(__dirname, '../../../../../');
|
||||
const appUrl = readEnvFile(resolve(coreRoot, '.env'), 'APP_URL');
|
||||
if (appUrl && !/^https?:\/\/localhost(:\d+)?\/?$/i.test(appUrl)) {
|
||||
return appUrl;
|
||||
}
|
||||
throw new Error(
|
||||
'sirsoft-basic 템플릿 E2E base URL 미설정. PLAYWRIGHT_BASE_URL 환경변수를 지정하거나 코어 .env 의 APP_URL 을 활성 호스트로 설정하세요.'
|
||||
);
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './specs',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: [
|
||||
['html', { outputFolder: 'playwright-report', open: 'never' }],
|
||||
['list'],
|
||||
],
|
||||
use: {
|
||||
baseURL: resolveBaseUrl(),
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
ignoreHTTPSErrors: true,
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
});
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* 게시글 상단 네비게이션 — 긴 게시판명이 좌우 버튼을 압착하지 않는지 검증.
|
||||
*
|
||||
* 배경 (390px 실측, `flex items-center justify-between` 한 줄에 목록/게시판명/이전글·다음글):
|
||||
* - 게시판명 Span 이 `flex-shrink` 를 받아 남은 폭을 먹으면서 형제 버튼을 눌렀다.
|
||||
* '이전글' 버튼 폭: 5자 '자유게시판' 87px → 11자 'API 문서 샘플 게시판' 73px
|
||||
* → 21자 62px. 62px 에서는 chevron 아이콘과 텍스트가 겹친다.
|
||||
* - `document.scrollWidth` 는 내내 뷰포트와 같아 오버플로 검사로는 잡히지 않는다.
|
||||
* 버튼이 넘치는 게 아니라 *줄어드는* 회귀이므로 폭을 직접 재야 한다.
|
||||
*
|
||||
* 조치:
|
||||
* - 목록 버튼과 이전/다음 그룹에 `shrink-0` → 게시판명 길이와 무관하게 고정폭
|
||||
* - 모바일(0~767px)에서 컨테이너 `flex-wrap` + 게시판명 `order-first w-full`
|
||||
* → 게시판명이 첫 줄 전체를 차지하고 버튼은 아래 줄에서 폭 경쟁을 하지 않는다
|
||||
* - 태블릿 이상(768px+)은 한 줄 유지. 버튼 3개 고정폭 261px + 21자 제목 자연폭 323px
|
||||
* = 584px < 콘텐츠 735px 이므로 줄바꿈이 필요 없다.
|
||||
*
|
||||
* 선택자 규율:
|
||||
* - 버튼 라벨(`목록`/`이전글`)은 브라우저 로케일에 따라 영문으로 렌더된다(`Back to List`).
|
||||
* 로케일 독립적인 Font Awesome 아이콘 클래스(`fa-list`/`fa-chevron-left`)로 찾는다.
|
||||
* - 게시판명 자체는 시드 데이터에 따라 짧을 수 있으므로, 실제 게시판을 고르는 대신
|
||||
* 제목 노드에 최악 케이스 문자열을 주입해 재측정한다. 회귀는 레이아웃에 있지
|
||||
* 특정 게시판에 있지 않다.
|
||||
*/
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test';
|
||||
|
||||
/** 기준선 게시판명 (5자, 자연폭 83px — 이 폭에서는 회귀 상태에서도 버튼이 안 눌린다) */
|
||||
const SHORT_BOARD_NAME = '자유게시판';
|
||||
|
||||
/** 최악 케이스 게시판명 (21자, 자연폭 323px) */
|
||||
const LONG_BOARD_NAME = '아주 긴 이름을 가진 커뮤니티 게시판입니다';
|
||||
|
||||
/** 상단 네비게이션 컨테이너 — 목록(fa-list) + 게시판명 + 이전/다음 그룹 */
|
||||
function navLocator(page: Page): Locator {
|
||||
return page.locator('div.mb-6').filter({ has: page.locator('button i.fa-list') }).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 이전글/다음글이 모두 있는 게시글을 찾습니다. 시드 데이터에 의존하지 않도록 공개 API 로 탐색합니다.
|
||||
*
|
||||
* @param page Playwright 페이지
|
||||
* @return { slug, postId } 또는 조건을 만족하는 글이 없으면 null
|
||||
*/
|
||||
async function findPostWithBothNeighbors(
|
||||
page: Page
|
||||
): Promise<{ slug: string; postId: number } | null> {
|
||||
return page.evaluate(async () => {
|
||||
const boardsRes = await fetch('/api/modules/sirsoft-board/boards');
|
||||
const boards = (await boardsRes.json())?.data ?? [];
|
||||
|
||||
for (const board of boards) {
|
||||
if ((board.posts_count ?? 0) < 3) continue;
|
||||
|
||||
const listRes = await fetch(
|
||||
`/api/modules/sirsoft-board/boards/${board.slug}/posts?per_page=5`
|
||||
);
|
||||
const posts = (await listRes.json())?.data?.data ?? [];
|
||||
|
||||
for (const post of posts) {
|
||||
const navRes = await fetch(
|
||||
`/api/modules/sirsoft-board/boards/${board.slug}/posts/${post.id}/navigation`
|
||||
);
|
||||
const nav = (await navRes.json())?.data;
|
||||
if (nav?.prev?.id && nav?.next?.id) {
|
||||
return { slug: board.slug as string, postId: post.id as number };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 짧은 게시판명 → 긴 게시판명으로 바꾸며 기하를 측정합니다.
|
||||
*
|
||||
* 핵심 단언은 "긴 이름일 때의 버튼 폭 == 짧은 이름일 때의 버튼 폭" 이다. 절대 폭 하한만
|
||||
* 보면 시드 데이터의 게시판명 길이에 따라 단언이 무력해진다(짧은 이름 게시판에서는
|
||||
* 회귀 상태여도 버튼이 하한을 넘는다). 같은 페이지에서 두 상태를 재 차이를 본다.
|
||||
*
|
||||
* @param nav 네비게이션 컨테이너 로케이터
|
||||
* @param shortName 기준선으로 쓸 짧은 게시판명
|
||||
* @param longName 최악 케이스 게시판명
|
||||
* @return 짧은/긴 이름 각각의 flex 배치와 버튼 폭
|
||||
*/
|
||||
async function measureShortVsLongName(nav: Locator, shortName: string, longName: string) {
|
||||
return nav.evaluate(
|
||||
(el: HTMLElement, { short, long }: { short: string; long: string }) => {
|
||||
const title = el.children[1] as HTMLElement;
|
||||
|
||||
const snapshot = () => ({
|
||||
flexWrap: getComputedStyle(el).flexWrap,
|
||||
titleOrder: Number(getComputedStyle(title).order),
|
||||
titleTop: Math.round(title.getBoundingClientRect().top),
|
||||
titleWidth: Math.round(title.getBoundingClientRect().width),
|
||||
firstButtonTop: Math.round((el.children[0] as HTMLElement).getBoundingClientRect().top),
|
||||
containerWidth: Math.round(el.getBoundingClientRect().width),
|
||||
viewportWidth: document.documentElement.clientWidth,
|
||||
docOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
buttons: [...el.querySelectorAll('button')].map((b) => ({
|
||||
icon: b.querySelector('i')?.className ?? '',
|
||||
width: Math.round(b.getBoundingClientRect().width),
|
||||
left: Math.round(b.getBoundingClientRect().left),
|
||||
right: Math.round(b.getBoundingClientRect().right),
|
||||
})),
|
||||
});
|
||||
|
||||
title.textContent = short;
|
||||
const withShortName = snapshot();
|
||||
title.textContent = long;
|
||||
const withLongName = snapshot();
|
||||
return { withShortName, withLongName };
|
||||
},
|
||||
{ short: shortName, long: longName }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이전글·다음글이 모두 있는 게시글로 이동하고 네비 컨테이너를 반환합니다.
|
||||
*
|
||||
* navigation API 가 이웃을 돌려줘도 화면에는 렌더되지 않을 수 있으므로(비밀글·권한 필터)
|
||||
* 실제 버튼 3개가 보이는지까지 확인한다.
|
||||
*
|
||||
* @param page Playwright 페이지
|
||||
* @return 네비게이션 컨테이너 로케이터 (조건을 만족하는 글이 없으면 테스트 skip)
|
||||
*/
|
||||
async function gotoPostWithNeighbors(page: Page): Promise<Locator> {
|
||||
await page.goto('/');
|
||||
const target = await findPostWithBothNeighbors(page);
|
||||
test.skip(target === null, '이전글·다음글이 모두 있는 게시글이 없다');
|
||||
|
||||
await page.goto(`/board/${target!.slug}/${target!.postId}`);
|
||||
const nav = navLocator(page);
|
||||
await expect(nav).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// 라벨은 브라우저 로케일에 따라 영문이 되므로 아이콘으로 확인한다
|
||||
await expect(nav.locator('button i.fa-chevron-left')).toBeVisible();
|
||||
await expect(nav.locator('button i.fa-chevron-right')).toBeVisible();
|
||||
await expect(nav.locator('button')).toHaveCount(3);
|
||||
return nav;
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시판명 길이가 버튼 폭에 영향을 주지 않는지 단언합니다 (이 회귀의 본질).
|
||||
*
|
||||
* @param withShortName 짧은 이름일 때의 측정치
|
||||
* @param withLongName 긴 이름일 때의 측정치
|
||||
*/
|
||||
function expectButtonsUnaffectedByBoardName(
|
||||
withShortName: { buttons: Array<{ width: number }> },
|
||||
withLongName: { buttons: Array<{ width: number }> }
|
||||
): void {
|
||||
expect(withShortName.buttons).toHaveLength(3);
|
||||
expect(withLongName.buttons).toHaveLength(3);
|
||||
// 회귀 상태에서는 87 → 62px 로 줄었다. shrink-0 이면 두 값이 완전히 같다.
|
||||
expect(withLongName.buttons.map((b) => b.width)).toEqual(
|
||||
withShortName.buttons.map((b) => b.width)
|
||||
);
|
||||
}
|
||||
|
||||
test.describe('게시글 상단 네비 — 긴 게시판명', () => {
|
||||
test('@smoke 모바일(390px)에서 이전글/다음글이 압착되지 않고 게시판명은 자기 줄을 갖는다', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const nav = await gotoPostWithNeighbors(page);
|
||||
|
||||
const { withShortName, withLongName } = await measureShortVsLongName(
|
||||
nav,
|
||||
SHORT_BOARD_NAME,
|
||||
LONG_BOARD_NAME
|
||||
);
|
||||
|
||||
expectButtonsUnaffectedByBoardName(withShortName, withLongName);
|
||||
|
||||
// 긴 이름에서도 세 버튼이 뷰포트 안에 온전히 들어온다
|
||||
for (const btn of withLongName.buttons) {
|
||||
expect(btn.left).toBeGreaterThanOrEqual(-1);
|
||||
expect(btn.right).toBeLessThanOrEqual(withLongName.viewportWidth + 1);
|
||||
}
|
||||
|
||||
// 게시판명이 첫 줄 전체(order-first w-full)를 차지하고 버튼 행보다 위에 있다
|
||||
expect(withLongName.flexWrap).toBe('wrap');
|
||||
expect(withLongName.titleOrder).toBeLessThan(0);
|
||||
expect(withLongName.titleWidth).toBe(withLongName.containerWidth);
|
||||
expect(withLongName.titleTop).toBeLessThan(withLongName.firstButtonTop);
|
||||
|
||||
expect(withLongName.docOverflow).toBe(0);
|
||||
});
|
||||
|
||||
test('320px 에서도 버튼 폭이 게시판명 길이와 무관하다', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 320, height: 844 });
|
||||
const nav = await gotoPostWithNeighbors(page);
|
||||
|
||||
const { withShortName, withLongName } = await measureShortVsLongName(
|
||||
nav,
|
||||
SHORT_BOARD_NAME,
|
||||
LONG_BOARD_NAME
|
||||
);
|
||||
|
||||
expectButtonsUnaffectedByBoardName(withShortName, withLongName);
|
||||
for (const btn of withLongName.buttons) {
|
||||
expect(btn.left).toBeGreaterThanOrEqual(-1);
|
||||
expect(btn.right).toBeLessThanOrEqual(withLongName.viewportWidth + 1);
|
||||
}
|
||||
expect(withLongName.flexWrap).toBe('wrap');
|
||||
expect(withLongName.docOverflow).toBe(0);
|
||||
});
|
||||
|
||||
test('태블릿(768px)은 한 줄을 유지하면서도 버튼이 압착되지 않는다', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 768, height: 900 });
|
||||
const nav = await gotoPostWithNeighbors(page);
|
||||
|
||||
const { withShortName, withLongName } = await measureShortVsLongName(
|
||||
nav,
|
||||
SHORT_BOARD_NAME,
|
||||
LONG_BOARD_NAME
|
||||
);
|
||||
|
||||
// 버튼 3개 고정폭 261px + 21자 제목 자연폭 323px = 584px < 콘텐츠 폭 → 줄바꿈 불필요
|
||||
expect(withLongName.flexWrap).toBe('nowrap');
|
||||
expect(withLongName.titleOrder).toBe(0);
|
||||
expect(withLongName.titleWidth).toBeLessThan(withLongName.containerWidth);
|
||||
expectButtonsUnaffectedByBoardName(withShortName, withLongName);
|
||||
expect(withLongName.docOverflow).toBe(0);
|
||||
});
|
||||
|
||||
test('데스크톱(1280px)은 기존 한 줄 레이아웃을 유지한다 (모바일 오버라이드 누출 없음)', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
const nav = await gotoPostWithNeighbors(page);
|
||||
|
||||
const { withShortName, withLongName } = await measureShortVsLongName(
|
||||
nav,
|
||||
SHORT_BOARD_NAME,
|
||||
LONG_BOARD_NAME
|
||||
);
|
||||
|
||||
expect(withLongName.flexWrap).toBe('nowrap');
|
||||
expect(withLongName.titleOrder).toBe(0);
|
||||
expect(withLongName.titleWidth).toBeLessThan(withLongName.containerWidth);
|
||||
expectButtonsUnaffectedByBoardName(withShortName, withLongName);
|
||||
expect(withLongName.docOverflow).toBe(0);
|
||||
});
|
||||
});
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* sirsoft-basic 모바일 뷰포트 가로 오버플로 회귀 E2E.
|
||||
*
|
||||
* 배경 (390x844 실측):
|
||||
* - 헤더 우측 그룹에 언어(71px)+통화(91px)가 있어 26개 라우트 중 24개에서
|
||||
* document.scrollWidth = 401px (뷰포트 390px 대비 +11px).
|
||||
* 320px(iPhone SE)에서는 햄버거 버튼이 화면 밖으로 밀려 내비게이션 자체가 불가능했다.
|
||||
* - 비회원 댓글폼: 아바타 76px 들여쓰기 + flex-1 입력 2개 → 문서 +81px 초과
|
||||
* - 상품상세 쿠폰 칩: 라벨에 whitespace-nowrap 이 없어 39px 폭에서 2줄로 줄바꿈
|
||||
*
|
||||
* 조치:
|
||||
* - 언어/통화를 모바일 드로어(mobile_drawer_prefs)로 이동, 가로 칩으로 나열
|
||||
* - 통화/배송국가 주입 조각은 responsive.portable 로 모바일에서만 펼친 칩 목록
|
||||
* - 데스크톱(≥1024px)은 기존 헤더 드롭다운 유지
|
||||
*
|
||||
* 단위 테스트(Vitest)는 레이아웃 JSON 구조만 본다. 실제 브라우저 폭·줄 수·가시성은
|
||||
* 여기서만 검증된다 (위지윅 발행 회귀 #238 교훈).
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
/** 문서가 뷰포트보다 가로로 넘치는 px (0 이어야 정상) */
|
||||
async function docOverflow(page: Page): Promise<number> {
|
||||
return page.evaluate(
|
||||
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 뷰포트를 벗어난 가시 요소 목록.
|
||||
*
|
||||
* 제외 대상 (자기 자신부터 조상까지 거슬러 올라가며 판정):
|
||||
* - 숨김 요소(display/visibility/opacity)
|
||||
* - 화면 밖으로 밀어둔 off-canvas 서브트리 — 닫힌 드로어(`translate-x-full`)
|
||||
* - `position: fixed` 서브트리 — 문서 스크롤 폭에 기여하지 않는다.
|
||||
* (예: PageTransitionIndicator 의 로딩 바는 translateX 애니메이션으로 한때
|
||||
* `right > VW` 가 되지만 fixed 라 문서를 넓히지 않는다)
|
||||
* - 조상이 `overflow-x: hidden|clip` 으로 잘라내는 요소
|
||||
*
|
||||
* 문서 전체 오버플로는 `docOverflow()` 가 별도로 본다. 이 함수는 "어떤 요소가 범인인가"를
|
||||
* 진단하기 위한 것이다.
|
||||
*/
|
||||
async function overflowingElements(page: Page): Promise<Array<{ id: string; cls: string }>> {
|
||||
return page.evaluate(() => {
|
||||
const VW = document.documentElement.clientWidth;
|
||||
const isExcluded = (el: Element): boolean => {
|
||||
let n: Element | null = el;
|
||||
while (n && n !== document.documentElement) {
|
||||
const cs = getComputedStyle(n);
|
||||
if (cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0) {
|
||||
return true;
|
||||
}
|
||||
if (cs.position === 'fixed') return true;
|
||||
if (n !== el && /hidden|clip/.test(cs.overflowX)) return true;
|
||||
const r = n.getBoundingClientRect();
|
||||
if (r.width > 0 && (r.left >= VW - 1 || r.right <= 1)) return true;
|
||||
n = n.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return [...document.body.querySelectorAll('*')]
|
||||
.filter((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (!r.width || !r.height || isExcluded(el)) return false;
|
||||
return r.right > VW + 1 || r.left < -1;
|
||||
})
|
||||
.map((el) => ({ id: el.id, cls: String(el.className).slice(0, 60) }));
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('모바일 헤더/드로어 (390px)', () => {
|
||||
test('@smoke 홈에서 문서가 가로로 넘치지 않는다', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/');
|
||||
await expect(page.locator('#mobile_menu_toggle')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
expect(await docOverflow(page)).toBe(0);
|
||||
expect(await overflowingElements(page)).toEqual([]);
|
||||
});
|
||||
|
||||
test('헤더 우측 그룹에 언어/통화 셀렉터가 없다', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/');
|
||||
await expect(page.locator('#mobile_header_right')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await expect(page.locator('#mobile_header_right #mobile_lang_selector_wrap')).toHaveCount(0);
|
||||
await expect(page.locator('#mobile_header_right #mobile_currency_selector_wrap')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('언어/통화는 드로어 안에 가로 칩으로 나열되고 비회원도 볼 수 있다', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/');
|
||||
await page.locator('#mobile_menu_toggle').click();
|
||||
|
||||
const prefs = page.locator('#mobile_drawer_prefs');
|
||||
await expect(prefs).toBeVisible();
|
||||
|
||||
// 언어 칩: 여러 로케일이 한 줄에 나열 (iteration 이 Button 에 걸려 있어야 함)
|
||||
const langChips = prefs.locator('button[role="option"]:not(#mobile_drawer_currency_wrap button)');
|
||||
expect(await langChips.count()).toBeGreaterThan(0);
|
||||
|
||||
// 통화 슬롯: 이커머스 모듈 주입 + id 스코프
|
||||
const currencyRoot = page.locator('#mobile_drawer_currency_wrap [id^="ext_header_currency_selector"]');
|
||||
await expect(currencyRoot).toBeVisible();
|
||||
|
||||
// portable 오버라이드 — 트리거는 숨고 패널은 펼쳐진 정적 목록
|
||||
await expect(currencyRoot.locator('button[aria-haspopup="listbox"]')).toBeHidden();
|
||||
const panel = currencyRoot.locator('[role="listbox"]');
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel).toHaveClass(/\bstatic\b/);
|
||||
|
||||
// 모든 옵션이 칩(rounded-full) 이고 뷰포트를 넘지 않는다
|
||||
const chips = currencyRoot.locator('button[role="option"]');
|
||||
const n = await chips.count();
|
||||
expect(n).toBeGreaterThan(0);
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
await expect(chips.nth(i)).toHaveClass(/rounded-full/);
|
||||
}
|
||||
expect(await docOverflow(page)).toBe(0);
|
||||
});
|
||||
|
||||
test('320px 에서도 햄버거 버튼을 누를 수 있다 (내비게이션 접근성)', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 320, height: 844 });
|
||||
await page.goto('/');
|
||||
|
||||
const toggle = page.locator('#mobile_menu_toggle');
|
||||
// toBeInViewport 는 요소 존재를 기다리지 않는다(기본 5s 안에 하이드레이션이 끝나지 않으면
|
||||
// element(s) not found). 병렬 워커 부하에서 플레이키했으므로 먼저 가시성을 기다린다.
|
||||
await expect(toggle).toBeVisible({ timeout: 15_000 });
|
||||
await expect(toggle).toBeInViewport();
|
||||
expect(await docOverflow(page)).toBe(0);
|
||||
|
||||
await toggle.click();
|
||||
await expect(page.locator('#mobile_drawer_prefs')).toBeVisible();
|
||||
expect(await docOverflow(page)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('데스크톱 회귀 방지 (1280px)', () => {
|
||||
test('헤더 통화 셀렉터는 여전히 absolute 드롭다운이다', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.goto('/');
|
||||
|
||||
const root = page.locator('#ext_header_currency_selector__header_currency_slot_desktop');
|
||||
const trigger = root.locator('button[aria-haspopup="listbox"]');
|
||||
await expect(trigger).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await trigger.click();
|
||||
const panel = root.locator('[role="listbox"]');
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel).toHaveClass(/\babsolute\b/);
|
||||
await expect(panel).toHaveClass(/\bz-50\b/);
|
||||
|
||||
// 세로 목록 — 칩이 아니다
|
||||
const options = panel.locator('button[role="option"]');
|
||||
expect(await options.count()).toBeGreaterThan(0);
|
||||
await expect(options.first()).not.toHaveClass(/rounded-full/);
|
||||
|
||||
expect(await docOverflow(page)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('비회원 폼/칩 (390px)', () => {
|
||||
test('게시판 글쓰기폼의 이름/비밀번호가 세로로 쌓인다', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/board/free/write');
|
||||
|
||||
const pw = page.locator('input[type="password"]').first();
|
||||
await expect(pw).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// 압착(147px) 이 아니라 전체폭에 가까워야 한다
|
||||
const box = await pw.boundingBox();
|
||||
expect(box!.width).toBeGreaterThan(250);
|
||||
expect(await docOverflow(page)).toBe(0);
|
||||
});
|
||||
|
||||
test('상품 상세의 쿠폰 칩 라벨이 한 줄을 유지한다', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/shop/products/105');
|
||||
|
||||
// 쿠폰 칩 라벨은 whitespace-nowrap Span. 상품에 다운로드 가능 쿠폰이 없으면 렌더되지 않는다.
|
||||
const labels = page.locator('button span.whitespace-nowrap');
|
||||
await page.waitForLoadState('networkidle');
|
||||
const count = await labels.count();
|
||||
test.skip(count === 0, '이 상품에 다운로드 가능한 쿠폰이 없다');
|
||||
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const lines = await labels.nth(i).evaluate((el) => {
|
||||
const lh = parseFloat(getComputedStyle(el).lineHeight) || 16;
|
||||
return Math.max(1, Math.round(el.getBoundingClientRect().height / lh));
|
||||
});
|
||||
expect(lines).toBe(1);
|
||||
}
|
||||
|
||||
// 칩 컨테이너는 가로 스크롤이 아니라 줄바꿈으로 흘린다.
|
||||
// (컨테이너 > [ticket Icon, iteration Div ×N, 더보기 Button] > Button > Span 구조라
|
||||
// DOM 깊이가 가변적이므로 조상 체인을 올라가며 flex 컨테이너를 찾는다)
|
||||
const containerInfo = await labels.first().evaluate((el) => {
|
||||
// 칩 Button 자신도 inline-flex 이므로, wrap 을 켠 첫 조상(=칩 컨테이너)까지 올라간다
|
||||
let n: HTMLElement | null = el.parentElement;
|
||||
while (n && n !== document.body) {
|
||||
const cs = getComputedStyle(n);
|
||||
if (cs.flexWrap === 'wrap') {
|
||||
return { flexWrap: cs.flexWrap, overflowX: cs.overflowX };
|
||||
}
|
||||
n = n.parentElement;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
expect(containerInfo).not.toBeNull();
|
||||
expect(containerInfo!.overflowX).not.toBe('auto');
|
||||
|
||||
expect(await docOverflow(page)).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user