fix(basic,admin_basic): 상한 목록의 페이저 소비단 정합 및 화면 표기
저장소가 총 건수를 상한까지만 세게 되면서 마지막 페이지 번호가 없는 목록이 생겼는데, 화면은 그 값을 1 로 접거나 페이저 노출 조건으로만 쓰고 있었다. 앞은 "1페이지뿐" 으로 잘못 말하고 뒤는 페이저를 통째로 지워 뒤쪽 페이지로 갈 방법 자체를 없앤다. 둘 다 예외도 404 도 내지 않아 화면은 정상으로 보이고 기능만 없다. Pagination 컴포넌트가 세 형태를 모두 이해하도록 넓혔다. 마지막 페이지를 알면 페이지 산술이 우선이고, 모를 때만 다음 페이지 존재 여부로 판정한다. 처음·마지막 버튼을 따로 켜고 끌 수 있게 나눠, 마지막 페이지를 모르는 목록에서는 그 버튼만 감춰지고 나머지 이동은 그대로 열려 있다. DataGrid 는 페이저를 그리는 두 경로 모두에 그 값을 전달한다 — 한쪽만 전달하면 그 화면만 첫 페이지에 갇힌다. 마이페이지 주문·마일리지·위시리스트·알림함, 게시판 목록, 상품 리뷰·문의, 내가 쓴 글, 관리자 설정 탭과 스케줄 이력의 소비 지점을 전건 옮겼다. 검색 화면은 총 건수를 정확히 세지 못한 경우 "N건 이상" 으로 표기하고 검색어를 좁히면 정확한 건수를 볼 수 있다고 안내한다. 탭 배지도 세지 못한 값을 정확한 숫자처럼 보여주지 않는다. 최신순은 첫 페이지부터 커서로 이동하며, 주소로 특정 페이지를 지목한 딥링크는 종전 동작을 유지한다. 쇼핑 첫 화면과 분류 화면을 단일 요청으로 재배선하고, 섹션 partial 이 부모가 선언한 이름을 읽도록 통일했다. 데이터소스 이름에 직결돼 있으면 다른 화면이 재사용할 때 폴백에 걸려 예외도 로그도 없이 섹션이 사라진다. 상품 상세의 "최근 본 상품" 이 계속 비어 있던 것은 존재하지 않는 라우트 파라미터를 읽고 있어서였다. 게시판 목록 세 스킨의 블라인드 아이콘 대비와 이미지 위 공통 색의 다크 모드 표기를 정리했다.
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
|
||||
- 역할 관리 목록의 「권한 수」 열 제목 일본어 번역 추가.
|
||||
- 확장 업데이트 모달의 「검색 인덱스 재생성」 선택 항목 문구 일본어 번역 추가 — 항목 이름과 설명(재생성 시 인덱스 잠금·재색인 안내)이 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
- 관리자 목록의 총 건수 정확도 표기와 고급 환경설정의 목록 한계값 항목 문구 일본어 번역 추가 — 총 건수를 상한까지만 센 경우의 "N건 이상" 표기와 상한 설정 항목이 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
|
||||
## [1.0.4] - 2026-07-17
|
||||
|
||||
|
||||
@@ -1788,7 +1788,13 @@
|
||||
"geoip_update_success": "GeoIP DBの更新が完了しました。",
|
||||
"geoip_update_failed": "GeoIP DBの更新に失敗しました。",
|
||||
"seo_sitemap_cache": "Sitemap キャッシュ",
|
||||
"seo_sitemap_cache_desc": "sitemap.xml をキャッシュします。"
|
||||
"seo_sitemap_cache_desc": "sitemap.xml をキャッシュします。",
|
||||
"pagination": "リスト制限値",
|
||||
"pagination_desc": "大規模リストにおいて、総件数を集計する範囲と直接リクエスト可能なページ番号の上限です。上限を超えると、総件数を「N件以上」と表示し、最後のページへのジャンプのみ非表示になります — 次ページへの移動はそのまま利用可能です。",
|
||||
"pagination_result_cap": "総件数集計の上限",
|
||||
"pagination_result_cap_desc": "この件数までのみ正確に集計します。0の場合は常にすべてを集計します(大規模リストの場合、リストが遅くなる可能性があります)。",
|
||||
"pagination_max_page": "ページ番号の上限",
|
||||
"pagination_max_page_desc": "アドレスから直接リクエスト可能な最大ページ番号です。0の場合は制限しません。通常の次ページへの移動はこの値に関わらず引き続き可能です。"
|
||||
},
|
||||
"notification_definitions": {
|
||||
"title": "通知設定",
|
||||
@@ -2307,7 +2313,8 @@
|
||||
"no_value": "-"
|
||||
},
|
||||
"pagination": {
|
||||
"total": "合計{{count}}件"
|
||||
"total": "合計{{count}}件",
|
||||
"total_at_least": "合計{{count}}件以上"
|
||||
},
|
||||
"empty": {
|
||||
"title": "アクティビティログがありません",
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
- 회원가입 화면의 휴대폰번호·전화번호 입력란 라벨·안내 문구(`auth.mobile`, `auth.phone`) 일본어 번역 추가 — 회원가입 시 연락처 입력란이 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
- 모달 닫기 버튼의 안내 라벨(`common.close_modal`) 일본어 번역 추가 — 화면 낭독기 사용자에게 버튼 용도가 일본어로 안내됩니다.
|
||||
- 검색 결과 건수 표기와 검색어 구체화 안내(`search.result_count_suffix_at_least`, `search.refine_query_hint`) 일본어 번역 추가 — 총 건수를 상한까지만 센 경우의 "N건 이상" 표기가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
- 편집기 화면 문구(`editor.*`) 일본어 번역 보강.
|
||||
|
||||
## [1.0.1] - 2026-07-08
|
||||
|
||||
|
||||
@@ -1103,7 +1103,8 @@
|
||||
"wishlist": "ウィッシュリスト",
|
||||
"mileage_balance": "マイレージ残高",
|
||||
"mileage_history": "マイレージ履歴",
|
||||
"reviewSettings": "レビューポリシー設定"
|
||||
"reviewSettings": "レビューポリシー設定",
|
||||
"storefront": "ショッピング最初の画面統合データ"
|
||||
},
|
||||
"provider": {
|
||||
"naver": "Naver",
|
||||
|
||||
@@ -47,5 +47,7 @@
|
||||
"hint_title": "検索のコツ",
|
||||
"hint_1": "2語以上を入力するとより正確な結果が得られます",
|
||||
"hint_2": "検索キーワードはタイトルと内容から一緒に検索されます"
|
||||
}
|
||||
},
|
||||
"result_count_suffix_at_least": "件以上",
|
||||
"refine_query_hint": "検索キーワードをより具体的に入力すると、正確な件数と最後のページを確認できます。"
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
|
||||
- 코어 최소 요구 버전을 7.0.6 로 상향했습니다. 역할 관리 목록의 「권한 수」 열과 메뉴 관리의 하위 메뉴 역할 표시가 7.0.6 에서 추가된 응답 항목을 사용하므로, 이전 버전에서는 값이 비어 보입니다.
|
||||
- SEO 설정의 "지금 생성" 버튼이 sitemap 생성을 예약하고 바로 응답합니다. 사이트 규모가 커도 화면이 멈추지 않으며, 생성 중에는 버튼이 비활성화되고 진행 상황이 화면에 표시됩니다.
|
||||
- 총 건수를 정확히 셀 수 없을 만큼 기록이 많은 관리자 목록에서도 페이지 이동 버튼이 사라지지 않습니다. 마지막 페이지로 바로 뛰는 버튼만 이때 감춰집니다.
|
||||
- 목록의 총 건수 정확도 표시를 사용하기 위해 이 템플릿은 이제 코어 7.0.6 이상이 필요합니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -40,9 +42,12 @@
|
||||
- 표 하단 합계 영역의 값이 계산되지 않고 표현식 원문 그대로 표시되던 문제를 수정했습니다. 주문 상세의 합계 행에서 수량·적립금·배송비 자리에 값 대신 알 수 없는 문자열이 보였습니다.
|
||||
- 표의 셀 안쪽·확장 영역·합계 영역에서 일부 화면 요소가 아무 표시 없이 사라지던 문제를 수정했습니다. 표 바깥에서는 정상 표시되는 요소가 표 안에서만 빠졌으며, 본인인증 이력의 확장 영역에서 부가 정보 내용이 제목만 남고 보이지 않았습니다.
|
||||
- 카드 목록의 카드 내용 항목 이름이 실제 동작과 다르게 안내되던 문제를 수정했습니다(레이아웃 편집기의 속성 목록에 없는 이름이 표시되고 실제로 쓰는 이름은 빠져 있었습니다).
|
||||
- 활동 로그 목록에서 이전·다음 버튼이 모두 눌리지 않아 첫 페이지에 갇히던 문제를 수정했습니다.
|
||||
- 활동 로그의 총 건수가 끝까지 세지 못한 값인데도 정확한 숫자처럼 표시되던 문제를 수정했습니다. 이제 그 경우 「N건 이상」으로 표시되며, 글 번호도 「-」로 표시됩니다.
|
||||
- 모듈·플러그인·템플릿 관리 화면에서 '업데이트 확인'을 눌러 업데이트가 발견돼도 항상 "모두 최신 상태입니다"라고 안내되던 문제를 수정했습니다.
|
||||
- 게시판·상품 카테고리 목록의 새로고침 버튼과 언어팩 재활성화 안내에서 번역되지 않은 내부 문구가 그대로 보이던 문제를 수정했습니다.
|
||||
- 모듈·플러그인·템플릿 관리 화면의 목록 요약이 항상 "총 0개 중 1-0개 표시"로 나오고, 목록이 한 화면을 넘어도 다음 쪽으로 넘어갈 수 없던 문제를 수정했습니다.
|
||||
- 기록이 아주 많아 총 건수를 정확히 세지 못한 목록에서 페이지 이동 버튼이 통째로 사라지던 문제를 수정했습니다. 마지막 페이지로 바로 뛰는 버튼만 감춰지고 이전·다음 이동은 그대로 동작합니다.
|
||||
- 업데이트 모달에서 수정된 화면 확인에 실패했는데도 "수정된 레이아웃이 없습니다"라고 표시되던 문제를 수정했습니다. 그대로 '모두 교체'를 진행하면 직접 고친 화면이 사라질 수 있어, 이제 확인하지 못했다는 안내와 함께 '수정 유지'를 권장합니다.
|
||||
- 본인확인 코드 입력 화면에서 "남은 시도" 안내를 제거했습니다. 외부 인증 화면을 거쳐 돌아온 경우 실제보다 많은 횟수가 표시될 수 있어, 잘못된 숫자를 보여주는 대신 표시하지 않도록 했습니다. 시도 횟수 제한 자체는 종전대로 적용됩니다.
|
||||
- 회원 목록에서 검색할 때 페이지를 넘길 때와 달리 표 위에 로딩 표시가 뜨지 않던 문제를 수정했습니다.
|
||||
@@ -66,6 +71,14 @@
|
||||
- 메일 템플릿·알림 정의 목록에서 각 행이 같은 식별자를 공유해 화면 읽기 도구가 항목을 구분하지 못하던 문제를 수정했습니다.
|
||||
- 관리자 목록에서 검색·필터·정렬을 바꿀 때 표가 다시 그려지는 동안 로딩 표시가 나오지 않던 문제를 수정했습니다. 응답이 늦으면 아무 반응이 없는 것처럼 보여 같은 조작을 반복하게 되던 상황이 사라집니다.
|
||||
|
||||
### Added
|
||||
|
||||
- 환경설정 > 고급에 목록 한계값 항목을 추가했습니다. 총 건수를 세는 범위와 주소로 직접 요청할 수 있는 최대 페이지 번호를 조정할 수 있으며, 두 값 모두 0 으로 두면 제한하지 않습니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 페이지네이션에서 처음/마지막 버튼을 따로 켜고 끌 수 있게 했습니다. 총 건수를 정확히 셀 수 없는 목록에서는 마지막 페이지 버튼이 자동으로 감춰지고 다음 페이지 이동은 그대로 됩니다.
|
||||
|
||||
## [1.0.3] - 2026-07-14
|
||||
|
||||
### Fixed
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* @file pagination-null-last-page-contract.test.tsx
|
||||
* @description 상한 목록의 last_page=null 을 화면이 1 로 붕괴시키지 않는지 전수 검증 (#519)
|
||||
*
|
||||
* 총 건수를 상한까지만 센 목록은 마지막 페이지를 계산할 수 없어 서버가 `last_page: null`
|
||||
* 을 보낸다. 화면이 이를 `?? 1` 로 채우면 "1페이지뿐" 이라고 잘못 말하고, 페이저 표시
|
||||
* 조건을 `last_page > 1` 로만 잡으면 페이저가 통째로 사라져 **"다음" 이동 경로 자체가
|
||||
* 없어진다.**
|
||||
*
|
||||
* 특정 화면을 손으로 열거하면 새로 추가되는 목록을 놓치므로, 이 템플릿(관리자)의 레이아웃 전체를
|
||||
* 스캔해 조건으로 도출한다.
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const layoutsDir = path.resolve(__dirname, '../../layouts');
|
||||
|
||||
/** 레이아웃 JSON 파일 경로를 재귀 수집한다. */
|
||||
function collectLayoutFiles(dir: string, found: string[] = []): string[] {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) collectLayoutFiles(full, found);
|
||||
else if (entry.isFile() && entry.name.endsWith('.json')) found.push(full);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
const layoutFiles = collectLayoutFiles(layoutsDir);
|
||||
|
||||
/** 저장소 기준 상대 경로로 바꾼다 (실패 메시지 가독성). */
|
||||
function rel(file: string): string {
|
||||
return path.relative(layoutsDir, file).replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
describe('상한 목록의 last_page=null 계약 (#519)', () => {
|
||||
it('레이아웃 파일을 실제로 수집해야 함 (스캔 대상이 0이면 이 테스트는 아무것도 증명하지 못한다)', () => {
|
||||
expect(layoutFiles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('last_page 를 1 로 채우는 표현식이 없어야 함', () => {
|
||||
// `last_page ?? 1` / `last_page || 1` — 계산 불가한 값을 사실처럼 채우는 형태
|
||||
const pattern = /last_page\s*(\?\?|\|\|)\s*1(?![0-9])/;
|
||||
const offenders: string[] = [];
|
||||
|
||||
for (const file of layoutFiles) {
|
||||
const raw = fs.readFileSync(file, 'utf-8');
|
||||
raw.split('\n').forEach((line, i) => {
|
||||
if (pattern.test(line)) offenders.push(`${rel(file)}:${i + 1}`);
|
||||
});
|
||||
}
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it('페이저 표시 조건이 last_page 하나에만 의존하지 않아야 함', () => {
|
||||
// `"if": "{{... last_page > 1}}"` 만으로 판정하면 null 일 때 페이저가 사라진다.
|
||||
// has_more_pages 또는 current_page 를 함께 보아야 한다.
|
||||
const offenders: string[] = [];
|
||||
|
||||
for (const file of layoutFiles) {
|
||||
const raw = fs.readFileSync(file, 'utf-8');
|
||||
raw.split('\n').forEach((line, i) => {
|
||||
if (!/"if"\s*:/.test(line) || !/last_page/.test(line)) return;
|
||||
if (/has_more_pages/.test(line) || /current_page/.test(line)) return;
|
||||
offenders.push(`${rel(file)}:${i + 1}`);
|
||||
});
|
||||
}
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it('totalPages 를 null 로 넘기는 Pagination 은 hasMorePages 를 함께 넘겨야 함', () => {
|
||||
// totalPages 가 null 이면 컴포넌트는 총 페이지 수를 모르므로,
|
||||
// hasMorePages 없이는 "다음" 버튼을 열 근거가 없어 비활성화된다.
|
||||
const offenders: string[] = [];
|
||||
|
||||
/** Pagination 컴포넌트 노드를 재귀 수집한다. */
|
||||
function collectPaginations(node: unknown, acc: any[] = []): any[] {
|
||||
if (!node || typeof node !== 'object') return acc;
|
||||
const obj = node as Record<string, any>;
|
||||
if (obj.name === 'Pagination' && obj.props) acc.push(obj);
|
||||
for (const value of Object.values(obj)) {
|
||||
if (Array.isArray(value)) value.forEach((v) => collectPaginations(v, acc));
|
||||
else if (value && typeof value === 'object') collectPaginations(value, acc);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
for (const file of layoutFiles) {
|
||||
const raw = fs.readFileSync(file, 'utf-8');
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const node of collectPaginations(parsed)) {
|
||||
const totalPages = node.props.totalPages;
|
||||
if (typeof totalPages !== 'string') continue;
|
||||
if (!/\?\?\s*null/.test(totalPages)) continue;
|
||||
if (node.props.hasMorePages === undefined) {
|
||||
offenders.push(`${rel(file)} — ${totalPages}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1299,10 +1299,15 @@
|
||||
"required": false,
|
||||
"description": "서버 사이드 페이지네이션 현재 페이지"
|
||||
},
|
||||
"serverHasMorePages": {
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"description": "다음 페이지 존재 여부 — 총 건수를 상한까지만 센 목록에서 '다음' 이동을 열어 둔다"
|
||||
},
|
||||
"serverTotalPages": {
|
||||
"type": "number",
|
||||
"required": false,
|
||||
"description": "서버 사이드 페이지네이션 전체 페이지 수"
|
||||
"description": "서버 사이드 페이지네이션 전체 페이지 수 (총 건수가 부정확하면 null)"
|
||||
},
|
||||
"alwaysShowPagination": {
|
||||
"type": "boolean",
|
||||
@@ -1443,8 +1448,8 @@
|
||||
},
|
||||
"totalPages": {
|
||||
"type": "number",
|
||||
"required": true,
|
||||
"description": "전체 페이지 수"
|
||||
"required": false,
|
||||
"description": "전체 페이지 수. 총 건수를 정확히 세지 못한 목록은 null (페이지 번호 목록과 마지막 페이지 점프가 감춰지고 이전/다음만 남음)"
|
||||
},
|
||||
"onPageChange": {
|
||||
"type": "function",
|
||||
@@ -1463,6 +1468,21 @@
|
||||
"default": true,
|
||||
"description": "처음/마지막 버튼 표시 여부"
|
||||
},
|
||||
"showFirst": {
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"description": "처음 버튼 표시 여부 (미지정 시 showFirstLast 를 따름)"
|
||||
},
|
||||
"showLast": {
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"description": "마지막 버튼 표시 여부 (미지정 시 showFirstLast 를 따름). 총 건수가 부정확해 totalPages 가 null 이면 이 값과 무관하게 감춰짐"
|
||||
},
|
||||
"hasMorePages": {
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"description": "다음 페이지 존재 여부. 총 건수를 모르는 목록에서 '다음' 버튼 활성 여부를 이 값으로 판정"
|
||||
},
|
||||
"className": {
|
||||
"type": "string",
|
||||
"required": false,
|
||||
@@ -4767,7 +4787,11 @@
|
||||
"type": "composite",
|
||||
"description": "커스텀 항목 렌더링이 가능한 리치 셀렉트 컴포넌트",
|
||||
"path": "src/components/composite/RichSelect.tsx",
|
||||
"skipBindingKeys": ["children", "selectedChildren", "__componentLayoutDefs"],
|
||||
"skipBindingKeys": [
|
||||
"children",
|
||||
"selectedChildren",
|
||||
"__componentLayoutDefs"
|
||||
],
|
||||
"props": {
|
||||
"options": {
|
||||
"type": "array",
|
||||
@@ -4824,7 +4848,9 @@
|
||||
"type": "composite",
|
||||
"description": "Dropdown button with portal-based menu similar to ActionMenu",
|
||||
"path": "src/components/composite/DropdownButton.tsx",
|
||||
"skipBindingKeys": ["children"],
|
||||
"skipBindingKeys": [
|
||||
"children"
|
||||
],
|
||||
"props": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14
-1
@@ -114,7 +114,20 @@ export interface DataGridProps {
|
||||
alwaysShowPagination?: boolean;
|
||||
serverSidePagination?: boolean;
|
||||
serverCurrentPage?: number;
|
||||
serverTotalPages?: number;
|
||||
/**
|
||||
* 서버가 알려준 마지막 페이지 번호
|
||||
*
|
||||
* 총 건수를 상한까지만 센 목록은 마지막 페이지를 계산할 수 없어 서버가 `null` 을 보낸다.
|
||||
* 1 로 채우면 화면이 "1페이지뿐" 이라고 잘못 말하므로 null 을 그대로 흘려보낸다.
|
||||
*/
|
||||
serverTotalPages?: number | null;
|
||||
/**
|
||||
* 다음 페이지 존재 여부 (총 건수를 모르는 목록에서 사용)
|
||||
*
|
||||
* 총 건수를 몰라도 서버는 이 값을 정확히 판정한다. 마지막 페이지를 계산할 수 없어도
|
||||
* "다음" 이동은 이 값으로 끝까지 열어 둘 수 있다.
|
||||
*/
|
||||
serverHasMorePages?: boolean;
|
||||
onPageChange?: (page: number) => void;
|
||||
prevText?: string;
|
||||
nextText?: string;
|
||||
|
||||
+31
-1
@@ -2,10 +2,40 @@ import { default as React } from 'react';
|
||||
import { EditorAttrs } from '../../types';
|
||||
export interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
/**
|
||||
* 마지막 페이지 번호
|
||||
*
|
||||
* 총 건수가 상한을 넘겨 정확히 세지 않은 목록은 마지막 페이지를 계산할 수 없다.
|
||||
* 그 경우 서버가 `null` 을 보내며, 페이지 번호 목록과 마지막 페이지 점프가 사라지고
|
||||
* 이전/다음 이동만 남는다. 0 이나 1 로 채우면 화면이 "1페이지뿐" 이라고 잘못 말하게 된다.
|
||||
*/
|
||||
totalPages: number | null;
|
||||
onPageChange: (page: number) => void;
|
||||
maxVisiblePages?: number;
|
||||
/**
|
||||
* 첫/마지막 페이지 버튼 표시 여부 (두 버튼의 기본값)
|
||||
*
|
||||
* 마지막 페이지만 따로 감춰야 하는 경우가 있으므로 `showLast` 로 덮어쓸 수 있다.
|
||||
*/
|
||||
showFirstLast?: boolean;
|
||||
/**
|
||||
* 첫 페이지 버튼 표시 여부 (미지정 시 showFirstLast 를 따른다)
|
||||
*/
|
||||
showFirst?: boolean;
|
||||
/**
|
||||
* 마지막 페이지 버튼 표시 여부 (미지정 시 showFirstLast 를 따른다)
|
||||
*
|
||||
* 총 건수가 부정확한 목록에서는 마지막 페이지를 계산할 수 없으므로,
|
||||
* `totalPages` 가 null 이면 이 값과 무관하게 감춰진다.
|
||||
*/
|
||||
showLast?: boolean;
|
||||
/**
|
||||
* 다음 페이지 존재 여부 (총 건수를 모르는 목록에서 사용)
|
||||
*
|
||||
* 총 건수를 몰라도 다음 페이지 존재 여부는 서버가 정확히 판정한다.
|
||||
* 지정하면 "다음" 버튼의 활성 여부를 이 값으로 정한다.
|
||||
*/
|
||||
hasMorePages?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
prevText?: string;
|
||||
|
||||
@@ -1764,6 +1764,12 @@
|
||||
"dev_dashboard": "Dev Dashboard",
|
||||
"sql_query_log": "SQL Query Log",
|
||||
"sql_query_log_desc": "Log executed SQL queries. Log file location: /storage/logs/query.log",
|
||||
"pagination": "List limits",
|
||||
"pagination_desc": "How far totals are counted on large lists, and the highest page number that can be requested directly. Beyond the cap the total is shown as \"N+\" and only the last-page jump is hidden — moving to the next page stays available.",
|
||||
"pagination_result_cap": "Total count cap",
|
||||
"pagination_result_cap_desc": "Counts exactly up to this many rows. 0 always counts everything (lists may slow down on large data).",
|
||||
"pagination_max_page": "Maximum page number",
|
||||
"pagination_max_page_desc": "Highest page number that can be requested directly via URL. 0 means no limit. Normal next-page navigation is unaffected.",
|
||||
"core_update": "Update Settings",
|
||||
"core_update_desc": "Configure GitHub authentication for core and extension (module/plugin/template) updates.",
|
||||
"core_update_github_url": "GitHub Repository URL",
|
||||
@@ -2303,7 +2309,8 @@
|
||||
"no_value": "-"
|
||||
},
|
||||
"pagination": {
|
||||
"total": "Total {{count}} items"
|
||||
"total": "Total {{count}} items",
|
||||
"total_at_least": "Total {{count}}+ items"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No activity logs found",
|
||||
|
||||
@@ -1768,6 +1768,12 @@
|
||||
"dev_dashboard": "개발 대시보드",
|
||||
"sql_query_log": "SQL 쿼리 로그",
|
||||
"sql_query_log_desc": "실행된 SQL 쿼리를 로그에 기록합니다. 로그 파일 위치: /storage/logs/query.log",
|
||||
"pagination": "목록 한계값",
|
||||
"pagination_desc": "대용량 목록에서 총 건수를 세는 범위와 직접 요청 가능한 페이지 번호의 상한입니다. 상한을 넘으면 총 건수를 \"N건 이상\" 으로 표시하고 마지막 페이지 점프만 감춥니다 — 다음 페이지 이동은 그대로 열려 있습니다.",
|
||||
"pagination_result_cap": "총 건수 집계 상한",
|
||||
"pagination_result_cap_desc": "이 건수까지만 정확히 셉니다. 0 이면 항상 전부 셉니다(대용량에서 목록이 느려질 수 있습니다).",
|
||||
"pagination_max_page": "페이지 번호 상한",
|
||||
"pagination_max_page_desc": "주소로 직접 요청할 수 있는 최대 페이지 번호입니다. 0 이면 제한하지 않습니다. 정상적인 다음 페이지 이동은 이 값과 무관하게 계속 가능합니다.",
|
||||
"core_update": "업데이트 설정",
|
||||
"core_update_desc": "코어 및 확장(모듈/플러그인/템플릿) 업데이트에 사용할 GitHub 인증 정보를 설정합니다.",
|
||||
"core_update_github_url": "GitHub 저장소 URL",
|
||||
@@ -2307,7 +2313,8 @@
|
||||
"no_value": "-"
|
||||
},
|
||||
"pagination": {
|
||||
"total": "총 {{count}}건"
|
||||
"total": "총 {{count}}건",
|
||||
"total_at_least": "총 {{count}}건 이상"
|
||||
},
|
||||
"empty": {
|
||||
"title": "활동 로그가 없습니다",
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "refresh",
|
||||
"className": "w-5 h-5"
|
||||
"className": "text-xl"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -194,12 +194,24 @@
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"comment": "총 건수를 끝까지 센 경우",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{activityLogs?.data?.pagination?.total_is_exact !== false}}",
|
||||
"props": {
|
||||
"className": "text-label"
|
||||
},
|
||||
"text": "$t:admin.activity_log.pagination.total|count={{activityLogs?.data?.pagination?.total || 0}}"
|
||||
},
|
||||
{
|
||||
"comment": "총 건수가 상한에 걸려 잘린 경우 — 세지 않은 값을 정확한 것처럼 말하지 않는다",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{activityLogs?.data?.pagination?.total_is_exact === false}}",
|
||||
"props": {
|
||||
"className": "text-label"
|
||||
},
|
||||
"text": "$t:admin.activity_log.pagination.total_at_least|count={{activityLogs?.data?.pagination?.total || 0}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -409,7 +421,7 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "flex-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"className": "flex-center gap-2 px-4 py-2 bg-red-600 dark:bg-red-700 text-white dark:text-white rounded-lg hover:bg-red-700 dark:hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"disabled": "{{_global.isDeleting}}"
|
||||
},
|
||||
"actions": [
|
||||
@@ -584,7 +596,7 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "flex-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"className": "flex-center gap-2 px-4 py-2 bg-red-600 dark:bg-red-700 text-white dark:text-white rounded-lg hover:bg-red-700 dark:hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"disabled": "{{_global.isDeleting}}"
|
||||
},
|
||||
"actions": [
|
||||
|
||||
@@ -643,7 +643,8 @@
|
||||
"alwaysShowPagination": true,
|
||||
"serverSidePagination": true,
|
||||
"serverCurrentPage": "{{modules?.data?.pagination?.current_page ?? 1}}",
|
||||
"serverTotalPages": "{{modules?.data?.pagination?.last_page ?? 1}}",
|
||||
"serverTotalPages": "{{modules?.data?.pagination?.last_page ?? null}}",
|
||||
"serverHasMorePages": "{{modules?.data?.pagination?.has_more_pages ?? false}}",
|
||||
"cardClassName": "border border-gray-200 dark:border-gray-700 rounded-lg p-4 relative",
|
||||
"cardColumns": [
|
||||
{
|
||||
|
||||
@@ -652,7 +652,8 @@
|
||||
"alwaysShowPagination": true,
|
||||
"serverSidePagination": true,
|
||||
"serverCurrentPage": "{{plugins?.data?.pagination?.current_page ?? 1}}",
|
||||
"serverTotalPages": "{{plugins?.data?.pagination?.last_page ?? 1}}",
|
||||
"serverTotalPages": "{{plugins?.data?.pagination?.last_page ?? null}}",
|
||||
"serverHasMorePages": "{{plugins?.data?.pagination?.has_more_pages ?? false}}",
|
||||
"cardClassName": "border border-gray-200 dark:border-gray-700 rounded-lg p-4 relative",
|
||||
"cardColumns": [
|
||||
{
|
||||
|
||||
@@ -449,7 +449,8 @@
|
||||
"pagination": true,
|
||||
"serverSidePagination": true,
|
||||
"serverCurrentPage": "{{roles?.data?.pagination?.current_page ?? 1}}",
|
||||
"serverTotalPages": "{{roles?.data?.pagination?.last_page ?? 1}}",
|
||||
"serverTotalPages": "{{roles?.data?.pagination?.last_page ?? null}}",
|
||||
"serverHasMorePages": "{{roles?.data?.pagination?.has_more_pages ?? false}}",
|
||||
"pageSize": 20,
|
||||
"alwaysShowPagination": true,
|
||||
"idField": "id",
|
||||
|
||||
@@ -981,7 +981,8 @@
|
||||
"pageSize": 15,
|
||||
"serverSidePagination": true,
|
||||
"serverCurrentPage": "{{users?.data?.pagination?.current_page ?? 1}}",
|
||||
"serverTotalPages": "{{users?.data?.pagination?.last_page ?? 1}}",
|
||||
"serverTotalPages": "{{users?.data?.pagination?.last_page ?? null}}",
|
||||
"serverHasMorePages": "{{users?.data?.pagination?.has_more_pages ?? false}}",
|
||||
"selectable": "{{users?.data?.abilities?.can_update === true}}",
|
||||
"selectedIds": "{{_global.selectedIds_admin_users || []}}",
|
||||
"idField": "uuid",
|
||||
|
||||
+3
-2
@@ -11,7 +11,8 @@
|
||||
"pagination": true,
|
||||
"serverSidePagination": true,
|
||||
"serverCurrentPage": "{{activityLogs?.data?.pagination?.current_page ?? 1}}",
|
||||
"serverTotalPages": "{{activityLogs?.data?.pagination?.last_page ?? 1}}",
|
||||
"serverTotalPages": "{{activityLogs?.data?.pagination?.last_page ?? null}}",
|
||||
"serverHasMorePages": "{{activityLogs?.data?.pagination?.has_more_pages ?? false}}",
|
||||
"pageSize": "{{+(query.per_page || 20)}}",
|
||||
"alwaysShowPagination": true,
|
||||
"idField": "id",
|
||||
@@ -290,7 +291,7 @@
|
||||
"props": {
|
||||
"className": "text-label-subtle"
|
||||
},
|
||||
"text": "{{row.number}}"
|
||||
"text": "{{row.number ?? '-'}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+2
-1
@@ -11,7 +11,8 @@
|
||||
"pagination": true,
|
||||
"serverSidePagination": true,
|
||||
"serverCurrentPage": "{{identityLogs?.data?.pagination?.current_page ?? 1}}",
|
||||
"serverTotalPages": "{{identityLogs?.data?.pagination?.last_page ?? 1}}",
|
||||
"serverTotalPages": "{{identityLogs?.data?.pagination?.last_page ?? null}}",
|
||||
"serverHasMorePages": "{{identityLogs?.data?.pagination?.has_more_pages ?? false}}",
|
||||
"pageSize": "{{+(query.per_page || 20)}}",
|
||||
"alwaysShowPagination": true,
|
||||
"idField": "id",
|
||||
|
||||
+2
-1
@@ -671,7 +671,8 @@
|
||||
"alwaysShowPagination": true,
|
||||
"serverSidePagination": true,
|
||||
"serverCurrentPage": "{{language_packs?.data?.meta?.current_page ?? 1}}",
|
||||
"serverTotalPages": "{{language_packs?.data?.meta?.last_page ?? 1}}",
|
||||
"serverTotalPages": "{{language_packs?.data?.meta?.last_page ?? null}}",
|
||||
"serverHasMorePages": "{{language_packs?.data?.meta?.has_more_pages ?? false}}",
|
||||
"cardClassName": "border border-gray-200 dark:border-gray-700 rounded-lg p-4 relative",
|
||||
"cardColumns": [
|
||||
{
|
||||
|
||||
+2
-1
@@ -11,7 +11,8 @@
|
||||
"pagination": true,
|
||||
"serverSidePagination": true,
|
||||
"serverCurrentPage": "{{notificationLogs?.data?.pagination?.current_page ?? 1}}",
|
||||
"serverTotalPages": "{{notificationLogs?.data?.pagination?.last_page ?? 1}}",
|
||||
"serverTotalPages": "{{notificationLogs?.data?.pagination?.last_page ?? null}}",
|
||||
"serverHasMorePages": "{{notificationLogs?.data?.pagination?.has_more_pages ?? false}}",
|
||||
"pageSize": "{{+(query.per_page || 20)}}",
|
||||
"alwaysShowPagination": true,
|
||||
"idField": "id",
|
||||
|
||||
+4
-3
@@ -57,7 +57,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "clock",
|
||||
"className": "w-12 h-12 mx-auto mb-2 text-gray-300 dark:text-gray-600"
|
||||
"className": "text-5xl mx-auto mb-2 text-gray-300 dark:text-gray-600"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -233,7 +233,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{schedule_history?.data?.last_page > 1}}",
|
||||
"if": "{{((schedule_history?.data?.last_page ?? 0) > 1) || (schedule_history?.data?.has_more_pages === true) || ((schedule_history?.data?.current_page ?? 1) > 1)}}",
|
||||
"props": {
|
||||
"className": "mt-4 flex justify-center"
|
||||
},
|
||||
@@ -243,7 +243,8 @@
|
||||
"name": "Pagination",
|
||||
"props": {
|
||||
"currentPage": "{{schedule_history?.data?.current_page ?? 1}}",
|
||||
"totalPages": "{{schedule_history?.data?.last_page ?? 1}}",
|
||||
"totalPages": "{{schedule_history?.data?.last_page ?? null}}",
|
||||
"hasMorePages": "{{schedule_history?.data?.has_more_pages ?? false}}",
|
||||
"totalItems": "{{schedule_history?.data?.total ?? 0}}",
|
||||
"perPage": 10,
|
||||
"showAlways": false
|
||||
|
||||
+2
-1
@@ -1641,7 +1641,8 @@
|
||||
"pagination": true,
|
||||
"serverSidePagination": true,
|
||||
"serverCurrentPage": "{{schedules?.data?.current_page ?? 1}}",
|
||||
"serverTotalPages": "{{schedules?.data?.last_page ?? 1}}",
|
||||
"serverTotalPages": "{{schedules?.data?.last_page ?? null}}",
|
||||
"serverHasMorePages": "{{schedules?.data?.has_more_pages ?? false}}",
|
||||
"pageSize": "{{+(query.per_page || 20)}}",
|
||||
"alwaysShowPagination": true,
|
||||
"idField": "id",
|
||||
|
||||
+143
-2
@@ -822,7 +822,7 @@
|
||||
"href": "/dev",
|
||||
"target": "_blank",
|
||||
"rel": "noopener noreferrer",
|
||||
"className": "px-3 py-1.5 bg-indigo-600 text-white text-xs font-medium rounded-md hover:bg-indigo-700 transition-colors whitespace-nowrap no-underline"
|
||||
"className": "px-3 py-1.5 bg-indigo-600 dark:bg-indigo-500 text-white dark:text-white text-xs font-medium rounded-md hover:bg-indigo-700 dark:hover:bg-indigo-600 transition-colors whitespace-nowrap no-underline"
|
||||
},
|
||||
"text": "$t:admin.settings.advanced.dev_dashboard"
|
||||
},
|
||||
@@ -910,6 +910,147 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "card_pagination_settings",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "card-stack-item"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "H3",
|
||||
"props": {
|
||||
"className": "card-title"
|
||||
},
|
||||
"text": "$t:admin.settings.advanced.pagination"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "card-description"
|
||||
},
|
||||
"text": "$t:admin.settings.advanced.pagination_desc"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "row-stack"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "input_pagination_result_cap",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex-center"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-heading"
|
||||
},
|
||||
"text": "$t:admin.settings.advanced.pagination_result_cap"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "text-label-subtle"
|
||||
},
|
||||
"text": "$t:admin.settings.advanced.pagination_result_cap_desc"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "number",
|
||||
"name": "advanced.pagination_result_cap",
|
||||
"min": "{{settings?.data?._meta?.limits?.advanced_pagination_result_cap_min ?? 0}}",
|
||||
"max": "{{settings?.data?._meta?.limits?.advanced_pagination_result_cap_max ?? 1000000}}",
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
"className": "input {{_local.errors?.['advanced.pagination_result_cap'] ? 'input-error' : ''}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{_local.errors?.['advanced.pagination_result_cap']}}",
|
||||
"props": {
|
||||
"className": "form-error"
|
||||
},
|
||||
"text": "{{_local.errors?.['advanced.pagination_result_cap']?.[0] ?? ''}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "input_pagination_max_page",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex-center"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-heading"
|
||||
},
|
||||
"text": "$t:admin.settings.advanced.pagination_max_page"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "text-label-subtle"
|
||||
},
|
||||
"text": "$t:admin.settings.advanced.pagination_max_page_desc"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "number",
|
||||
"name": "advanced.pagination_max_page",
|
||||
"min": "{{settings?.data?._meta?.limits?.advanced_pagination_max_page_min ?? 0}}",
|
||||
"max": "{{settings?.data?._meta?.limits?.advanced_pagination_max_page_max ?? 100000}}",
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
"className": "input {{_local.errors?.['advanced.pagination_max_page'] ? 'input-error' : ''}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{_local.errors?.['advanced.pagination_max_page']}}",
|
||||
"props": {
|
||||
"className": "form-error"
|
||||
},
|
||||
"text": "{{_local.errors?.['advanced.pagination_max_page']?.[0] ?? ''}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "card_core_update_settings",
|
||||
"type": "basic",
|
||||
@@ -1267,7 +1408,7 @@
|
||||
"props": {
|
||||
"type": "button",
|
||||
"disabled": "{{_computed.isReadOnly || _local.geoipUpdating === true}}",
|
||||
"className": "w-full md:w-auto px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap"
|
||||
"className": "w-full md:w-auto px-4 py-2 bg-blue-600 dark:bg-blue-500 hover:bg-blue-700 dark:hover:bg-blue-600 disabled:bg-gray-400 dark:disabled:bg-gray-600 text-white dark:text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap"
|
||||
},
|
||||
"text": "{{_local.geoipUpdating === true ? $t('admin.settings.advanced.geoip_updating') : $t('admin.settings.advanced.geoip_manual_update')}}",
|
||||
"actions": [
|
||||
|
||||
+2
-1
@@ -628,7 +628,8 @@
|
||||
"name": "Pagination",
|
||||
"props": {
|
||||
"currentPage": "{{identityMessages?.data?.meta?.current_page ?? identityMessages?.data?.pagination?.current_page ?? 1}}",
|
||||
"totalPages": "{{identityMessages?.data?.meta?.last_page ?? identityMessages?.data?.pagination?.last_page ?? 1}}"
|
||||
"totalPages": "{{identityMessages?.data?.meta?.last_page ?? identityMessages?.data?.pagination?.last_page ?? null}}",
|
||||
"hasMorePages": "{{identityMessages?.data?.meta?.has_more_pages ?? identityMessages?.data?.pagination?.has_more_pages ?? false}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
|
||||
+4
-4
@@ -1094,7 +1094,7 @@
|
||||
"props": {
|
||||
"className": "text-tertiary"
|
||||
},
|
||||
"text": "$t:admin.identity.policies.pagination_summary|total={{policies?.data?.meta?.total ?? 0}}|page={{policies?.data?.meta?.current_page ?? 1}}|last={{policies?.data?.meta?.last_page ?? 1}}"
|
||||
"text": "$t:admin.identity.policies.pagination_summary|total={{policies?.data?.meta?.total ?? 0}}|page={{policies?.data?.meta?.current_page ?? 1}}|last={{policies?.data?.meta?.last_page ?? '?'}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -1147,14 +1147,14 @@
|
||||
"props": {
|
||||
"className": "px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-200"
|
||||
},
|
||||
"text": "$t:admin.identity.policies.page_indicator|page={{policies?.data?.meta?.current_page ?? 1}}|last={{policies?.data?.meta?.last_page ?? 1}}"
|
||||
"text": "$t:admin.identity.policies.page_indicator|page={{policies?.data?.meta?.current_page ?? 1}}|last={{policies?.data?.meta?.last_page ?? '?'}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"disabled": "{{(policies?.data?.meta?.current_page ?? 1) >= (policies?.data?.meta?.last_page ?? 1)}}",
|
||||
"disabled": "{{!((policies?.data?.meta?.has_more_pages === true) || ((policies?.data?.meta?.current_page ?? 1) < (policies?.data?.meta?.last_page ?? 0)))}}",
|
||||
"className": "px-3 py-1.5 text-xs font-medium bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
},
|
||||
"actions": [
|
||||
@@ -1167,7 +1167,7 @@
|
||||
"mergeQuery": true,
|
||||
"query": {
|
||||
"tab": "identity",
|
||||
"page": "{{Math.min(policies?.data?.meta?.last_page ?? 1, (policies?.data?.meta?.current_page ?? 1) + 1)}}"
|
||||
"page": "{{policies?.data?.meta?.last_page ? Math.min(policies.data.meta.last_page, (policies?.data?.meta?.current_page ?? 1) + 1) : ((policies?.data?.meta?.current_page ?? 1) + 1)}}"
|
||||
},
|
||||
"transition_overlay_target": "policy_table_card",
|
||||
"scroll": {
|
||||
|
||||
+3
-2
@@ -687,8 +687,9 @@
|
||||
"type": "composite",
|
||||
"name": "Pagination",
|
||||
"props": {
|
||||
"currentPage": "{{mailTemplates?.data?.pagination?.current_page || 1}}",
|
||||
"totalPages": "{{mailTemplates?.data?.pagination?.last_page || 1}}"
|
||||
"currentPage": "{{mailTemplates?.data?.pagination?.current_page ?? 1}}",
|
||||
"totalPages": "{{mailTemplates?.data?.pagination?.last_page ?? null}}",
|
||||
"hasMorePages": "{{mailTemplates?.data?.pagination?.has_more_pages ?? false}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
|
||||
+3
-2
@@ -705,8 +705,9 @@
|
||||
"type": "composite",
|
||||
"name": "Pagination",
|
||||
"props": {
|
||||
"currentPage": "{{notificationDefinitions?.data?.pagination?.current_page || 1}}",
|
||||
"totalPages": "{{notificationDefinitions?.data?.pagination?.last_page || 1}}"
|
||||
"currentPage": "{{notificationDefinitions?.data?.pagination?.current_page ?? 1}}",
|
||||
"totalPages": "{{notificationDefinitions?.data?.pagination?.last_page ?? null}}",
|
||||
"hasMorePages": "{{notificationDefinitions?.data?.pagination?.has_more_pages ?? false}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
|
||||
+2
-1
@@ -105,7 +105,8 @@
|
||||
"alwaysShowPagination": true,
|
||||
"serverSidePagination": true,
|
||||
"serverCurrentPage": "{{templates?.data?.pagination?.current_page ?? 1}}",
|
||||
"serverTotalPages": "{{templates?.data?.pagination?.last_page ?? 1}}",
|
||||
"serverTotalPages": "{{templates?.data?.pagination?.last_page ?? null}}",
|
||||
"serverHasMorePages": "{{templates?.data?.pagination?.has_more_pages ?? false}}",
|
||||
"cardClassName": "border border-gray-200 dark:border-gray-700 rounded-lg p-4 relative",
|
||||
"cardColumns": [
|
||||
{
|
||||
|
||||
+2
-1
@@ -105,7 +105,8 @@
|
||||
"alwaysShowPagination": true,
|
||||
"serverSidePagination": true,
|
||||
"serverCurrentPage": "{{templates?.data?.pagination?.current_page ?? 1}}",
|
||||
"serverTotalPages": "{{templates?.data?.pagination?.last_page ?? 1}}",
|
||||
"serverTotalPages": "{{templates?.data?.pagination?.last_page ?? null}}",
|
||||
"serverHasMorePages": "{{templates?.data?.pagination?.has_more_pages ?? false}}",
|
||||
"cardClassName": "border border-gray-200 dark:border-gray-700 rounded-lg p-4 relative",
|
||||
"cardColumns": [
|
||||
{
|
||||
|
||||
@@ -236,7 +236,20 @@ export interface DataGridProps {
|
||||
// 서버 사이드 페이지네이션 (API에서 페이지네이션 처리 시)
|
||||
serverSidePagination?: boolean;
|
||||
serverCurrentPage?: number;
|
||||
serverTotalPages?: number;
|
||||
/**
|
||||
* 서버가 알려준 마지막 페이지 번호
|
||||
*
|
||||
* 총 건수를 상한까지만 센 목록은 마지막 페이지를 계산할 수 없어 서버가 `null` 을 보낸다.
|
||||
* 1 로 채우면 화면이 "1페이지뿐" 이라고 잘못 말하므로 null 을 그대로 흘려보낸다.
|
||||
*/
|
||||
serverTotalPages?: number | null;
|
||||
/**
|
||||
* 다음 페이지 존재 여부 (총 건수를 모르는 목록에서 사용)
|
||||
*
|
||||
* 총 건수를 몰라도 서버는 이 값을 정확히 판정한다. 마지막 페이지를 계산할 수 없어도
|
||||
* "다음" 이동은 이 값으로 끝까지 열어 둘 수 있다.
|
||||
*/
|
||||
serverHasMorePages?: boolean;
|
||||
onPageChange?: (page: number) => void;
|
||||
|
||||
// 다국어 지원 텍스트 (레이아웃 JSON에서 $t: 문법으로 전달)
|
||||
@@ -651,6 +664,7 @@ export const DataGrid: React.FC<DataGridProps> = ({
|
||||
serverSidePagination = false,
|
||||
serverCurrentPage,
|
||||
serverTotalPages,
|
||||
serverHasMorePages,
|
||||
onPageChange,
|
||||
// 다국어 텍스트
|
||||
prevText,
|
||||
@@ -926,7 +940,18 @@ export const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
// 서버 사이드 페이지네이션인 경우 서버에서 전달된 값 사용
|
||||
const effectiveCurrentPage = serverSidePagination ? (serverCurrentPage || 1) : currentPage;
|
||||
const effectiveTotalPages = serverSidePagination ? (serverTotalPages || 1) : Math.ceil((sortedData?.length || 0) / pageSize);
|
||||
// 총 건수를 상한까지만 센 목록은 마지막 페이지가 null 로 온다. 1 로 채우면
|
||||
// 페이저가 통째로 접혀 1페이지 밖 행에 도달할 방법이 사라지므로 null 을 보존한다.
|
||||
const effectiveTotalPages: number | null = serverSidePagination
|
||||
? (serverTotalPages ?? null)
|
||||
: Math.ceil((sortedData?.length || 0) / pageSize);
|
||||
|
||||
// 마지막 페이지를 모르더라도 "다음" 이 열려 있거나 이미 2페이지 이상이면 페이저는 필요하다.
|
||||
const shouldShowPagination =
|
||||
alwaysShowPagination ||
|
||||
(effectiveTotalPages ?? 0) > 1 ||
|
||||
serverHasMorePages === true ||
|
||||
effectiveCurrentPage > 1;
|
||||
|
||||
// 페이지 변경 핸들러
|
||||
const handlePageChange = useCallback((page: number) => {
|
||||
@@ -1293,11 +1318,12 @@ export const DataGrid: React.FC<DataGridProps> = ({
|
||||
)}
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{pagination && (alwaysShowPagination || effectiveTotalPages > 1) && (
|
||||
{pagination && shouldShowPagination && (
|
||||
<Div className="flex justify-center mt-4">
|
||||
<Pagination
|
||||
currentPage={effectiveCurrentPage}
|
||||
totalPages={effectiveTotalPages}
|
||||
hasMorePages={serverSidePagination ? serverHasMorePages : undefined}
|
||||
onPageChange={handlePageChange}
|
||||
showFirstLast={showFirstLast}
|
||||
maxVisiblePages={5}
|
||||
@@ -1613,11 +1639,12 @@ export const DataGrid: React.FC<DataGridProps> = ({
|
||||
</Div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{pagination && (alwaysShowPagination || effectiveTotalPages > 1) && (
|
||||
{pagination && shouldShowPagination && (
|
||||
<Div className="flex justify-center mt-4">
|
||||
<Pagination
|
||||
currentPage={effectiveCurrentPage}
|
||||
totalPages={effectiveTotalPages}
|
||||
hasMorePages={serverSidePagination ? serverHasMorePages : undefined}
|
||||
onPageChange={handlePageChange}
|
||||
showFirstLast={showFirstLast}
|
||||
maxVisiblePages={5}
|
||||
|
||||
@@ -10,10 +10,40 @@ const t = (key: string, params?: Record<string, string | number>) =>
|
||||
|
||||
export interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
/**
|
||||
* 마지막 페이지 번호
|
||||
*
|
||||
* 총 건수가 상한을 넘겨 정확히 세지 않은 목록은 마지막 페이지를 계산할 수 없다.
|
||||
* 그 경우 서버가 `null` 을 보내며, 페이지 번호 목록과 마지막 페이지 점프가 사라지고
|
||||
* 이전/다음 이동만 남는다. 0 이나 1 로 채우면 화면이 "1페이지뿐" 이라고 잘못 말하게 된다.
|
||||
*/
|
||||
totalPages: number | null;
|
||||
onPageChange: (page: number) => void;
|
||||
maxVisiblePages?: number;
|
||||
/**
|
||||
* 첫/마지막 페이지 버튼 표시 여부 (두 버튼의 기본값)
|
||||
*
|
||||
* 마지막 페이지만 따로 감춰야 하는 경우가 있으므로 `showLast` 로 덮어쓸 수 있다.
|
||||
*/
|
||||
showFirstLast?: boolean;
|
||||
/**
|
||||
* 첫 페이지 버튼 표시 여부 (미지정 시 showFirstLast 를 따른다)
|
||||
*/
|
||||
showFirst?: boolean;
|
||||
/**
|
||||
* 마지막 페이지 버튼 표시 여부 (미지정 시 showFirstLast 를 따른다)
|
||||
*
|
||||
* 총 건수가 부정확한 목록에서는 마지막 페이지를 계산할 수 없으므로,
|
||||
* `totalPages` 가 null 이면 이 값과 무관하게 감춰진다.
|
||||
*/
|
||||
showLast?: boolean;
|
||||
/**
|
||||
* 다음 페이지 존재 여부 (총 건수를 모르는 목록에서 사용)
|
||||
*
|
||||
* 총 건수를 몰라도 다음 페이지 존재 여부는 서버가 정확히 판정한다.
|
||||
* 지정하면 "다음" 버튼의 활성 여부를 이 값으로 정한다.
|
||||
*/
|
||||
hasMorePages?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
prevText?: string;
|
||||
@@ -50,6 +80,9 @@ export const Pagination: React.FC<PaginationProps> = ({
|
||||
onPageChange,
|
||||
maxVisiblePages = 5,
|
||||
showFirstLast = true,
|
||||
showFirst,
|
||||
showLast,
|
||||
hasMorePages,
|
||||
className = '',
|
||||
style,
|
||||
prevText,
|
||||
@@ -57,14 +90,35 @@ export const Pagination: React.FC<PaginationProps> = ({
|
||||
id,
|
||||
editorAttrs,
|
||||
}) => {
|
||||
// 총 건수를 정확히 세지 못한 목록은 마지막 페이지를 계산할 수 없다.
|
||||
// 이때는 페이지 번호 목록과 마지막 페이지 점프를 감추고 이전/다음 이동만 남긴다 —
|
||||
// 끝까지 넘겨 보는 것은 그대로 가능하다.
|
||||
const isBounded = totalPages !== null && totalPages !== undefined;
|
||||
const resolvedTotalPages = isBounded ? (totalPages as number) : 0;
|
||||
|
||||
// 마지막 페이지를 알면 페이지 산술이 언제나 옳으므로 그쪽을 먼저 본다.
|
||||
// `hasMorePages` 를 우선하면, 응답에 `has_more_pages` 가 없어 레이아웃이 `?? false` 로
|
||||
// 채운 값이 정상 페이저의 "다음" 을 막아버린다 (실측: 1/2 페이지인데 다음 비활성).
|
||||
// 서버 판정이 필요한 자리는 마지막 페이지를 모르는 목록뿐이다.
|
||||
const canGoNext = isBounded
|
||||
? currentPage < resolvedTotalPages
|
||||
: (hasMorePages ?? false);
|
||||
|
||||
const displayFirst = (showFirst ?? showFirstLast) && isBounded;
|
||||
const displayLast = (showLast ?? showFirstLast) && isBounded;
|
||||
|
||||
// 페이지 번호 생성 알고리즘
|
||||
const pageNumbers = useMemo(() => {
|
||||
const pages: (number | string)[] = [];
|
||||
|
||||
if (totalPages <= maxVisiblePages + 2) {
|
||||
if (!isBounded) {
|
||||
// 마지막 페이지를 모르면 번호 목록을 만들 수 없다 (현재 페이지만 별도로 표시)
|
||||
return pages;
|
||||
}
|
||||
|
||||
if (resolvedTotalPages <= maxVisiblePages + 2) {
|
||||
// 페이지가 적으면 모두 표시
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
for (let i = 1; i <= resolvedTotalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
@@ -72,13 +126,13 @@ export const Pagination: React.FC<PaginationProps> = ({
|
||||
const halfVisible = Math.floor(maxVisiblePages / 2);
|
||||
|
||||
let startPage = Math.max(2, currentPage - halfVisible);
|
||||
let endPage = Math.min(totalPages - 1, currentPage + halfVisible);
|
||||
let endPage = Math.min(resolvedTotalPages - 1, currentPage + halfVisible);
|
||||
|
||||
// 시작 또는 끝 근처일 때 조정
|
||||
if (currentPage <= halfVisible + 1) {
|
||||
endPage = maxVisiblePages;
|
||||
} else if (currentPage >= totalPages - halfVisible) {
|
||||
startPage = totalPages - maxVisiblePages + 1;
|
||||
} else if (currentPage >= resolvedTotalPages - halfVisible) {
|
||||
startPage = resolvedTotalPages - maxVisiblePages + 1;
|
||||
}
|
||||
|
||||
// 첫 페이지
|
||||
@@ -95,21 +149,22 @@ export const Pagination: React.FC<PaginationProps> = ({
|
||||
}
|
||||
|
||||
// 끝 생략 부호
|
||||
if (endPage < totalPages - 1) {
|
||||
if (endPage < resolvedTotalPages - 1) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// 마지막 페이지
|
||||
if (totalPages > 1) {
|
||||
pages.push(totalPages);
|
||||
if (resolvedTotalPages > 1) {
|
||||
pages.push(resolvedTotalPages);
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
}, [currentPage, totalPages, maxVisiblePages]);
|
||||
}, [currentPage, resolvedTotalPages, isBounded, maxVisiblePages]);
|
||||
|
||||
const handlePageClick = (page: number) => {
|
||||
if (page < 1 || page > totalPages || page === currentPage) return;
|
||||
if (page < 1 || page === currentPage) return;
|
||||
if (isBounded && page > resolvedTotalPages) return;
|
||||
onPageChange(page);
|
||||
};
|
||||
|
||||
@@ -120,7 +175,7 @@ export const Pagination: React.FC<PaginationProps> = ({
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentPage < totalPages) {
|
||||
if (canGoNext) {
|
||||
onPageChange(currentPage + 1);
|
||||
}
|
||||
};
|
||||
@@ -134,7 +189,7 @@ export const Pagination: React.FC<PaginationProps> = ({
|
||||
id={id} {...editorAttrs}
|
||||
>
|
||||
{/* First Button */}
|
||||
{showFirstLast && (
|
||||
{displayFirst && (
|
||||
<Button
|
||||
onClick={() => handlePageClick(1)}
|
||||
disabled={currentPage === 1}
|
||||
@@ -155,8 +210,13 @@ export const Pagination: React.FC<PaginationProps> = ({
|
||||
{prevText || <>‹</>}
|
||||
</Button>
|
||||
|
||||
{/* Page Numbers */}
|
||||
{/* Page Numbers — 마지막 페이지를 모르면 현재 페이지만 표시 */}
|
||||
<Div className="pagination">
|
||||
{!isBounded && (
|
||||
<Span className="px-3 py-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
{currentPage}
|
||||
</Span>
|
||||
)}
|
||||
{pageNumbers.map((page, index) => {
|
||||
if (page === '...') {
|
||||
return (
|
||||
@@ -189,7 +249,7 @@ export const Pagination: React.FC<PaginationProps> = ({
|
||||
{/* Next Button */}
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={currentPage === totalPages}
|
||||
disabled={!canGoNext}
|
||||
className="pagination-btn"
|
||||
aria-label={t('common.next_page')}
|
||||
>
|
||||
@@ -197,10 +257,10 @@ export const Pagination: React.FC<PaginationProps> = ({
|
||||
</Button>
|
||||
|
||||
{/* Last Button */}
|
||||
{showFirstLast && (
|
||||
{displayLast && (
|
||||
<Button
|
||||
onClick={() => handlePageClick(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => handlePageClick(resolvedTotalPages)}
|
||||
disabled={currentPage === resolvedTotalPages}
|
||||
className="pagination-btn"
|
||||
aria-label={t('common.last_page')}
|
||||
>
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* DataGrid 의 모든 Pagination 렌더 경로가 hasMorePages 를 넘기는지 전수 확인 (#519)
|
||||
*
|
||||
* DataGrid 는 표 뷰와 카드/모바일 뷰에서 Pagination 을 각각 렌더한다. 총 건수를 상한까지만
|
||||
* 센 목록은 `last_page` 가 null 로 오므로 "다음" 이동 가능 여부는 `hasMorePages` 로만
|
||||
* 판정된다. 렌더 경로 중 하나라도 그 값을 넘기지 않으면 그 뷰에서만 이전·다음이 모두
|
||||
* 비활성이 되어 1 페이지에 갇힌다 — 예외도 경고도 없이 화면에서만 드러난다.
|
||||
*
|
||||
* 실측 회귀 (2026-08-03): 관리자 활동 로그(88,792 건)에서 표 뷰 경로만 이 값을 빠뜨려
|
||||
* 이전·다음이 둘 다 비활성이었다. 한 곳을 고쳐도 다음에 추가되는 렌더 경로가 또 빠질 수
|
||||
* 있으므로 **개별 지점을 열거하지 않고 소스를 스캔해 전수로 강제한다**.
|
||||
*/
|
||||
describe('DataGrid — Pagination 렌더 경로의 hasMorePages 전달', () => {
|
||||
const source = readFileSync(
|
||||
resolve(dirname(fileURLToPath(import.meta.url)), '../DataGrid.tsx'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
/**
|
||||
* `<Pagination ... />` 블록을 모두 잘라냅니다.
|
||||
*/
|
||||
const paginationBlocks = (): string[] => {
|
||||
const blocks: string[] = [];
|
||||
let from = 0;
|
||||
|
||||
for (;;) {
|
||||
const start = source.indexOf('<Pagination', from);
|
||||
if (start === -1) break;
|
||||
|
||||
const end = source.indexOf('/>', start);
|
||||
expect(end, 'Pagination 태그가 닫히지 않았습니다').toBeGreaterThan(start);
|
||||
|
||||
blocks.push(source.slice(start, end + 2));
|
||||
from = end + 2;
|
||||
}
|
||||
|
||||
return blocks;
|
||||
};
|
||||
|
||||
it('Pagination 렌더 경로가 하나 이상 존재한다', () => {
|
||||
// 스캔 대상이 0 개면 아래 단언이 조용히 통과한다 — 모집단부터 고정한다.
|
||||
expect(paginationBlocks().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('모든 Pagination 렌더 경로가 hasMorePages 를 넘긴다', () => {
|
||||
const missing = paginationBlocks()
|
||||
.map((block, index) => ({ index, block }))
|
||||
.filter(({ block }) => !block.includes('hasMorePages'));
|
||||
|
||||
expect(
|
||||
missing.map(({ index }) => `#${index + 1}`),
|
||||
`hasMorePages 를 넘기지 않는 Pagination 렌더 경로가 있습니다:\n${missing
|
||||
.map(({ block }) => block)
|
||||
.join('\n---\n')}`
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('모든 Pagination 렌더 경로가 totalPages 로 null 을 보존한다', () => {
|
||||
// last_page 를 1 로 채우면 페이저가 접혀 1 페이지 밖 행에 도달할 수 없다.
|
||||
const blocks = paginationBlocks();
|
||||
|
||||
for (const block of blocks) {
|
||||
expect(block).toContain('totalPages');
|
||||
expect(block, `totalPages 에 1 을 폴백으로 채우면 안 됩니다:\n${block}`).not.toMatch(
|
||||
/totalPages=\{[^}]*\?\?\s*1\s*\}/
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
+45
@@ -164,4 +164,49 @@ describe('Pagination', () => {
|
||||
);
|
||||
expect(container.firstChild).toHaveClass('custom-pagination');
|
||||
});
|
||||
|
||||
/**
|
||||
* hasMorePages 와 last_page 의 우선순위 회귀 (#519)
|
||||
*
|
||||
* 총 건수가 정확해 last_page 를 아는 목록은 페이지 산술이 언제나 옳다. 그때 응답에
|
||||
* has_more_pages 가 없어 레이아웃이 `?? false` 로 채워 넣더라도 그 false 가 "다음" 을
|
||||
* 막아서는 안 된다. has_more_pages 가 필요한 자리는 last_page 를 모르는 목록뿐이다.
|
||||
*
|
||||
* 실측 회귀: /user/orders 응답에 has_more_pages 가 없어 1/2 페이지인데 다음이 비활성.
|
||||
*/
|
||||
describe('hasMorePages 와 last_page 의 우선순위', () => {
|
||||
it('last_page 를 알면 hasMorePages=false 여도 다음이 열려 있다', () => {
|
||||
render(
|
||||
<Pagination currentPage={1} totalPages={2} hasMorePages={false} onPageChange={vi.fn()} />
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('다음 페이지')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('last_page 를 알고 마지막 페이지면 다음이 막힌다', () => {
|
||||
render(
|
||||
<Pagination currentPage={2} totalPages={2} hasMorePages={false} onPageChange={vi.fn()} />
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('다음 페이지')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('last_page 가 null 이면 hasMorePages 가 다음 이동을 정한다', () => {
|
||||
const { rerender } = render(
|
||||
<Pagination currentPage={5} totalPages={null} hasMorePages={true} onPageChange={vi.fn()} />
|
||||
);
|
||||
expect(screen.getByLabelText('다음 페이지')).not.toBeDisabled();
|
||||
|
||||
rerender(
|
||||
<Pagination currentPage={5} totalPages={null} hasMorePages={false} onPageChange={vi.fn()} />
|
||||
);
|
||||
expect(screen.getByLabelText('다음 페이지')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('last_page 가 null 이고 hasMorePages 도 없으면 다음이 막힌다', () => {
|
||||
render(<Pagination currentPage={1} totalPages={null} onPageChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByLabelText('다음 페이지')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,9 +16,12 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- 총 건수를 끝까지 세지 못한 목록에서 페이지 이동 막대가 사라지거나 "1페이지뿐" 으로 잘못 표시되던 문제를 수정했습니다. 마이페이지의 주문·마일리지·위시리스트·알림함, 게시판 목록, 상품 리뷰·문의, 내가 쓴 글 목록이 해당합니다.
|
||||
- 검색 결과의 탭 배지가 끝까지 세지 못한 건수를 정확한 숫자처럼 보여주던 문제를 수정했습니다. 이제 그 경우 숫자 뒤에 "이상" 표시가 붙습니다.
|
||||
#### 검색·봇 화면
|
||||
|
||||
- 통합 검색 결과의 상품 이름이 표시되지 않던 문제를 수정했습니다. 검색어 강조 표시 방식이 잘못 지정되어 상품 이름 자리가 비어 있었습니다.
|
||||
- 상품 목록의 페이지 이동이 총 건수를 정확히 세지 못한 경우에도 유지됩니다. 예전에는 마지막 페이지를 알 수 없으면 페이지 이동 영역 자체가 사라져 1페이지 밖 상품에 도달할 방법이 없었습니다.
|
||||
- 검색엔진 봇에게 보여줄 자료 목록에서 빠져 있던 항목을 채웠습니다. 게시글의 이전글·다음글 링크, 인기글 목록, 상품 문의, 추천·신상품 목록, 검색 결과, 회원 프로필의 활동 통계·최근 글, 회원 작성글 목록이 봇 화면에서 비어 있던 문제가 해소됩니다. (#86 @koojunho 님께서 제보해주셨습니다.)
|
||||
- 봇 화면의 선택 상자에 항목 이름 대신 내부 식별자가 글자로 노출되던 문제를 수정했습니다. 이제 일반 화면과 같이 선택 항목의 이름이 표시됩니다.
|
||||
|
||||
@@ -46,10 +49,17 @@
|
||||
- 본인확인 코드 입력 화면에서 "남은 시도" 안내를 제거했습니다. 외부 인증 화면을 거쳐 돌아온 경우 실제보다 많은 횟수가 표시될 수 있어, 잘못된 숫자를 보여주는 대신 표시하지 않도록 했습니다. 시도 횟수 제한 자체는 종전대로 적용됩니다.
|
||||
- 해외 배송지를 수정할 때 도시·주·우편번호 입력칸이 비어 보이고, 그대로 저장하면 저장돼 있던 값이 사라지던 문제를 수정했습니다. 마이페이지 배송지 관리와 주문서의 배송지 관리 모두에 적용됩니다. (#78 @glitter-gim 님께서 제보해주셨습니다.)
|
||||
- 마이페이지 배송지 목록에서 해외 배송지가 주소 대신 "[]" 로만 표시되던 문제를 수정했습니다. 이제 국가에 맞는 전체 주소가 표시됩니다. (#78 @glitter-gim 님께서 제보해주셨습니다.)
|
||||
- 게시판 목록 세 종류(일반형·카드형·갤러리형) 모두에서 블라인드 처리된 글의 아이콘 색이 배경과 대비가 낮아 잘 보이지 않던 문제를 수정했습니다.
|
||||
- 목록의 이전/다음 이동이 페이지가 더 있는데도 눌리지 않던 문제를 수정했습니다. 마이페이지의 주문·상품 문의·내가 쓴 글 목록이 해당합니다.
|
||||
- 상품 상세를 열어도 "최근 본 상품" 이 계속 비어 있던 문제를 수정했습니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 옵션 담기 방식 개선에 따라 이 템플릿은 이커머스 모듈 1.0.5 이상이 필요합니다.
|
||||
- 쇼핑 첫 화면이 필요한 데이터를 한 번에 받도록 바꿨습니다. 화면에 보이는 내용은 동일하며 첫 화면이 뜨는 속도가 빨라집니다.
|
||||
- 검색 결과가 아주 많아 총 건수를 정확히 셀 수 없는 경우, "N건 이상" 으로 표시하고 검색어를 좁히면 정확한 건수를 볼 수 있다는 안내를 함께 보여 줍니다. 이때 마지막 페이지로 바로 뛰는 버튼은 감춰지고 다음 페이지 이동은 그대로 됩니다.
|
||||
- 페이지네이션에서 처음/마지막 버튼을 따로 켜고 끌 수 있게 했습니다.
|
||||
- 목록의 총 건수 정확도 표시를 사용하기 위해 이 템플릿은 이제 코어 7.0.6 이상이 필요합니다.
|
||||
|
||||
## [1.0.3] - 2026-07-16
|
||||
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* 게시판 스킨 3종의 블라인드 아이콘 색 패리티 (#519)
|
||||
*
|
||||
* 블라인드 아이콘은 흰 배경 위에서 배경과 구분되어야 한다. `orange-300` 은 라이트 배경
|
||||
* 대비가 1.63 에 그쳐 사실상 보이지 않는다(WCAG 비텍스트 최소 3.0). 이 색을 `orange-500`
|
||||
* 으로 올리는 수정이 일반·카드 스킨에는 들어갔는데 갤러리만 빠져 있었다 — 같은 의미의
|
||||
* 아이콘이 스킨에 따라 보이기도 하고 안 보이기도 했다.
|
||||
*
|
||||
* 특정 스킨을 열거하면 스킨이 늘어날 때 또 빠지므로 **디렉토리를 훑어 전수로 강제한다**.
|
||||
*/
|
||||
describe('게시판 스킨 — 블라인드 아이콘 색 패리티', () => {
|
||||
const typesDir = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'../../layouts/partials/board/types'
|
||||
);
|
||||
|
||||
const skins = ['basic', 'card', 'gallery'];
|
||||
|
||||
/**
|
||||
* 스킨 레이아웃에서 eye-slash 아이콘의 className 을 모두 뽑아냅니다.
|
||||
*/
|
||||
const blindedIconClasses = (skin: string): string[] => {
|
||||
const source = readFileSync(resolve(typesDir, skin, 'index.json'), 'utf8');
|
||||
const found: string[] = [];
|
||||
|
||||
for (const match of source.matchAll(/"name":\s*"eye-slash"[^}]*?"className":\s*"([^"]*)"/g)) {
|
||||
found.push(match[1]);
|
||||
}
|
||||
|
||||
return found;
|
||||
};
|
||||
|
||||
it('모든 스킨에 블라인드 아이콘이 존재한다', () => {
|
||||
// 모집단이 비면 아래 단언이 조용히 통과한다.
|
||||
for (const skin of skins) {
|
||||
expect(blindedIconClasses(skin).length, `${skin} 스킨`).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('어느 스킨도 대비가 모자란 orange-300 을 쓰지 않는다', () => {
|
||||
for (const skin of skins) {
|
||||
for (const className of blindedIconClasses(skin)) {
|
||||
expect(className, `${skin} 스킨의 블라인드 아이콘`).not.toMatch(/text-orange-300\b/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('모든 스킨이 라이트/다크 양쪽 색을 함께 지정한다', () => {
|
||||
for (const skin of skins) {
|
||||
for (const className of blindedIconClasses(skin)) {
|
||||
expect(className, `${skin} 스킨의 블라인드 아이콘 라이트 색`).toMatch(/(^|\s)text-orange-\d{3}\b/);
|
||||
expect(className, `${skin} 스킨의 블라인드 아이콘 다크 색`).toMatch(/dark:text-orange-\d{3}\b/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('스킨끼리 같은 색을 쓴다', () => {
|
||||
const palettes = skins.map((skin) => {
|
||||
const classes = blindedIconClasses(skin);
|
||||
const light = classes[0].match(/(?:^|\s)(text-orange-\d{3})\b/)?.[1];
|
||||
const dark = classes[0].match(/(dark:text-orange-\d{3})\b/)?.[1];
|
||||
|
||||
return { skin, light, dark };
|
||||
});
|
||||
|
||||
const [first] = palettes;
|
||||
|
||||
for (const palette of palettes) {
|
||||
expect(palette.light, `${palette.skin} 라이트 색`).toBe(first.light);
|
||||
expect(palette.dark, `${palette.skin} 다크 색`).toBe(first.dark);
|
||||
}
|
||||
});
|
||||
});
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* @file pagination-null-last-page-contract.test.tsx
|
||||
* @description 상한 목록의 last_page=null 을 화면이 1 로 붕괴시키지 않는지 전수 검증 (#519)
|
||||
*
|
||||
* 총 건수를 상한까지만 센 목록은 마지막 페이지를 계산할 수 없어 서버가 `last_page: null`
|
||||
* 을 보낸다. 화면이 이를 `?? 1` 로 채우면 "1페이지뿐" 이라고 잘못 말하고, 페이저 표시
|
||||
* 조건을 `last_page > 1` 로만 잡으면 페이저가 통째로 사라져 **"다음" 이동 경로 자체가
|
||||
* 없어진다.**
|
||||
*
|
||||
* 특정 화면을 손으로 열거하면 새로 추가되는 목록을 놓치므로, 이 템플릿의 레이아웃 전체를
|
||||
* 스캔해 조건으로 도출한다.
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const layoutsDir = path.resolve(__dirname, '../../layouts');
|
||||
|
||||
/** 레이아웃 JSON 파일 경로를 재귀 수집한다. */
|
||||
function collectLayoutFiles(dir: string, found: string[] = []): string[] {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) collectLayoutFiles(full, found);
|
||||
else if (entry.isFile() && entry.name.endsWith('.json')) found.push(full);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
const layoutFiles = collectLayoutFiles(layoutsDir);
|
||||
|
||||
/** 저장소 기준 상대 경로로 바꾼다 (실패 메시지 가독성). */
|
||||
function rel(file: string): string {
|
||||
return path.relative(layoutsDir, file).replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
describe('상한 목록의 last_page=null 계약 (#519)', () => {
|
||||
it('레이아웃 파일을 실제로 수집해야 함 (스캔 대상이 0이면 이 테스트는 아무것도 증명하지 못한다)', () => {
|
||||
expect(layoutFiles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('last_page 를 1 로 채우는 표현식이 없어야 함', () => {
|
||||
// `last_page ?? 1` / `last_page || 1` — 계산 불가한 값을 사실처럼 채우는 형태
|
||||
const pattern = /last_page\s*(\?\?|\|\|)\s*1(?![0-9])/;
|
||||
const offenders: string[] = [];
|
||||
|
||||
for (const file of layoutFiles) {
|
||||
const raw = fs.readFileSync(file, 'utf-8');
|
||||
raw.split('\n').forEach((line, i) => {
|
||||
if (pattern.test(line)) offenders.push(`${rel(file)}:${i + 1}`);
|
||||
});
|
||||
}
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it('페이저 표시 조건이 last_page 하나에만 의존하지 않아야 함', () => {
|
||||
// `"if": "{{... last_page > 1}}"` 만으로 판정하면 null 일 때 페이저가 사라진다.
|
||||
// has_more_pages 또는 current_page 를 함께 보아야 한다.
|
||||
const offenders: string[] = [];
|
||||
|
||||
for (const file of layoutFiles) {
|
||||
const raw = fs.readFileSync(file, 'utf-8');
|
||||
raw.split('\n').forEach((line, i) => {
|
||||
if (!/"if"\s*:/.test(line) || !/last_page/.test(line)) return;
|
||||
if (/has_more_pages/.test(line) || /current_page/.test(line)) return;
|
||||
offenders.push(`${rel(file)}:${i + 1}`);
|
||||
});
|
||||
}
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it('totalPages 를 null 로 넘기는 Pagination 은 hasMorePages 를 함께 넘겨야 함', () => {
|
||||
// totalPages 가 null 이면 컴포넌트는 총 페이지 수를 모르므로,
|
||||
// hasMorePages 없이는 "다음" 버튼을 열 근거가 없어 비활성화된다.
|
||||
const offenders: string[] = [];
|
||||
|
||||
/** Pagination 컴포넌트 노드를 재귀 수집한다. */
|
||||
function collectPaginations(node: unknown, acc: any[] = []): any[] {
|
||||
if (!node || typeof node !== 'object') return acc;
|
||||
const obj = node as Record<string, any>;
|
||||
if (obj.name === 'Pagination' && obj.props) acc.push(obj);
|
||||
for (const value of Object.values(obj)) {
|
||||
if (Array.isArray(value)) value.forEach((v) => collectPaginations(v, acc));
|
||||
else if (value && typeof value === 'object') collectPaginations(value, acc);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
for (const file of layoutFiles) {
|
||||
const raw = fs.readFileSync(file, 'utf-8');
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const node of collectPaginations(parsed)) {
|
||||
const totalPages = node.props.totalPages;
|
||||
if (typeof totalPages !== 'string') continue;
|
||||
if (!/\?\?\s*null/.test(totalPages)) continue;
|
||||
if (node.props.hasMorePages === undefined) {
|
||||
offenders.push(`${rel(file)} — ${totalPages}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
+9
-5
@@ -5,7 +5,7 @@
|
||||
* 테스트 대상: templates/.../layouts/shop/index.json
|
||||
*
|
||||
* 검증 항목:
|
||||
* - products 데이터소스가 category_id 를 query.category 에 바인딩
|
||||
* - 목록 데이터소스가 category_id 를 query.category 에 바인딩 (#519 이후 storefront 단일 요청)
|
||||
* - 카테고리 버튼 navigate 가 /products + query.category = cat.id 로 이동 (slug 페이지 이동 금지)
|
||||
* - "전체" 버튼은 query 비움
|
||||
* - 선택 카테고리 active className 조건부 적용
|
||||
@@ -36,10 +36,14 @@ function findNodes(node: any, predicate: (n: any) => boolean, results: any[] = [
|
||||
}
|
||||
|
||||
describe('shop/index 카테고리 필터 (U6②)', () => {
|
||||
it('products 데이터소스가 category_id 를 query.category 에 바인딩해야 함', () => {
|
||||
const products = indexLayout.data_sources.find((ds: any) => ds.id === 'products');
|
||||
expect(products).toBeDefined();
|
||||
expect(products.params.category_id).toBe("{{query.category ?? ''}}");
|
||||
it('목록 데이터소스가 category_id 를 query.category 에 바인딩해야 함', () => {
|
||||
const storefront = indexLayout.data_sources.find((ds: any) => ds.id === 'storefront');
|
||||
expect(storefront).toBeDefined();
|
||||
expect(storefront.params.category_id).toBe("{{query.category ?? ''}}");
|
||||
});
|
||||
|
||||
it('화면이 목록을 한 번의 요청으로 받아야 함 (#519 — 종전 5요청 통합)', () => {
|
||||
expect(indexLayout.data_sources.map((ds: any) => ds.id)).toEqual(['storefront']);
|
||||
});
|
||||
|
||||
it('iteration 카테고리 버튼이 /products 로 이동하며 query.category = cat.id 를 설정해야 함', () => {
|
||||
|
||||
+19
-9
@@ -80,12 +80,23 @@ function findNextButton(layout: any): any {
|
||||
|
||||
describe('인기상품/신상품/최근본상품 섹션 모바일 반응형', () => {
|
||||
const partials = [
|
||||
{ name: '인기상품', layout: popularProducts, dataSource: 'popularProducts' },
|
||||
{ name: '신상품', layout: newProducts, dataSource: 'newProducts' },
|
||||
{ name: '최근본상품', layout: recentProducts, dataSource: 'recentProducts' },
|
||||
{ name: '인기상품', layout: popularProducts },
|
||||
{ name: '신상품', layout: newProducts },
|
||||
{ name: '최근본상품', layout: recentProducts },
|
||||
];
|
||||
|
||||
describe.each(partials)('$name 섹션', ({ layout, dataSource }) => {
|
||||
/**
|
||||
* 기본 disabled 식에서 «세는 대상» 표현식을 뽑아낸다.
|
||||
* 데이터소스 이름을 테스트에 박아 두면 바인딩 경로가 바뀔 때마다 stale 이 되므로,
|
||||
* 브레이크포인트 오버라이드가 기본값과 «같은 컬렉션» 을 보는지만 확인한다.
|
||||
*/
|
||||
function countedCollection(nextBtn: any): string {
|
||||
const match = /\(_isolated\.scrollIdx \+ 4\) >= \((.*)\.length\)/.exec(nextBtn.props.disabled);
|
||||
expect(match, `기본 disabled 식에서 대상 컬렉션을 찾지 못함: ${nextBtn.props.disabled}`).not.toBeNull();
|
||||
return match![1];
|
||||
}
|
||||
|
||||
describe.each(partials)('$name 섹션', ({ layout }) => {
|
||||
describe('grid 기반 카드 크기 반응형', () => {
|
||||
it('컨테이너가 grid grid-flow-col을 사용해야 한다', () => {
|
||||
const container = findScrollContainer(layout);
|
||||
@@ -126,14 +137,14 @@ describe('인기상품/신상품/최근본상품 섹션 모바일 반응형', ()
|
||||
expect(nextBtn.props.className).toContain('+ 4');
|
||||
});
|
||||
|
||||
it('모바일 disabled에 올바른 데이터소스를 참조해야 한다', () => {
|
||||
it('모바일 disabled 가 기본값과 같은 컬렉션을 세야 한다', () => {
|
||||
const nextBtn = findNextButton(layout);
|
||||
expect(nextBtn.responsive['0-639'].props.disabled).toContain(`${dataSource}.data.length`);
|
||||
expect(nextBtn.responsive['0-639'].props.disabled).toContain(countedCollection(nextBtn));
|
||||
});
|
||||
|
||||
it('태블릿 disabled에 올바른 데이터소스를 참조해야 한다', () => {
|
||||
it('태블릿 disabled 가 기본값과 같은 컬렉션을 세야 한다', () => {
|
||||
const nextBtn = findNextButton(layout);
|
||||
expect(nextBtn.responsive['640-1023'].props.disabled).toContain(`${dataSource}.data.length`);
|
||||
expect(nextBtn.responsive['640-1023'].props.disabled).toContain(countedCollection(nextBtn));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -152,7 +163,6 @@ describe('인기상품/신상품/최근본상품 섹션 모바일 반응형', ()
|
||||
|
||||
it.each(partials)('$name: if 조건으로 데이터 없을 때 비노출 처리되어야 한다', ({ layout }) => {
|
||||
expect(layout.if).toBeDefined();
|
||||
expect(layout.if).toContain('.data');
|
||||
expect(layout.if).toContain('.length > 0');
|
||||
});
|
||||
});
|
||||
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* @file shop-shared-partial-datasource-contract.test.tsx
|
||||
* @description 공유 partial 이 참조하는 데이터소스가 모든 포함 레이아웃에 존재하는지 전수 검사 (#519 회귀)
|
||||
*
|
||||
* 배경:
|
||||
* shop/index 를 단일 storefront 데이터소스로 통합하면서, 여러 레이아웃이 함께 쓰는
|
||||
* partial 을 index 전용 데이터소스 이름에 결합시켰다. 그 결과 shop/category 의 상품
|
||||
* 그리드와 shop/show 의 인기 상품 섹션이 예외도 404 도 없이 화면에서만 사라졌다.
|
||||
* `?? []` 폴백 때문에 런타임 오류가 나지 않아 어떤 정적 검사에도 걸리지 않는 유형이다.
|
||||
*
|
||||
* 검사 방식:
|
||||
* 특정 파일을 열거하지 않는다. layouts/**\/*.json 전수를 읽어
|
||||
* ① 템플릿 전체의 데이터소스 ID 집합을 만들고
|
||||
* ② partial 이 표현식에서 참조하는 루트 식별자 중 ①에 속한 것만 추려
|
||||
* ③ 그 partial 을 포함하는 모든 레이아웃이 해당 ID 를 실제로 정의하는지 확인한다.
|
||||
* 새 partial/레이아웃이 추가돼도 목록 갱신 없이 자동으로 검사 대상이 된다.
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const layoutsRoot = path.resolve(__dirname, '../../layouts');
|
||||
|
||||
/** layouts 하위 모든 JSON 을 재귀 수집한다 */
|
||||
function collectLayoutFiles(dir: string, acc: string[] = []): string[] {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) collectLayoutFiles(full, acc);
|
||||
else if (entry.name.endsWith('.json')) acc.push(full);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
/** layouts 루트 기준 상대 경로 (partial 참조 문자열과 같은 형태) */
|
||||
function toRelative(file: string): string {
|
||||
return path.relative(layoutsRoot, file).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
const layoutFiles = collectLayoutFiles(layoutsRoot);
|
||||
const parsed = new Map<string, any>();
|
||||
for (const file of layoutFiles) {
|
||||
parsed.set(toRelative(file), JSON.parse(fs.readFileSync(file, 'utf-8')));
|
||||
}
|
||||
|
||||
/** 노드 트리 전체를 순회하며 문자열 값을 모은다 */
|
||||
function collectStrings(node: any, acc: string[] = []): string[] {
|
||||
if (typeof node === 'string') {
|
||||
acc.push(node);
|
||||
return acc;
|
||||
}
|
||||
if (!node || typeof node !== 'object') return acc;
|
||||
for (const value of Object.values(node)) collectStrings(value, acc);
|
||||
return acc;
|
||||
}
|
||||
|
||||
/** 이 레이아웃이 정의한 데이터소스 ID */
|
||||
function dataSourceIds(layout: any): Set<string> {
|
||||
return new Set<string>((layout?.data_sources ?? []).map((ds: any) => ds.id).filter(Boolean));
|
||||
}
|
||||
|
||||
/** 상속 체인(extends)까지 포함해 이 레이아웃에서 참조 가능한 데이터소스 ID */
|
||||
function definedDataSources(entry: string, seen = new Set<string>()): Set<string> {
|
||||
const layout = parsed.get(entry);
|
||||
const ids = dataSourceIds(layout);
|
||||
const base = layout?.extends ?? layout?.meta?.extends;
|
||||
if (typeof base === 'string' && !seen.has(base)) {
|
||||
seen.add(base);
|
||||
const baseEntry = base.endsWith('.json') ? base : `${base}.json`;
|
||||
for (const id of definedDataSources(baseEntry, seen)) ids.add(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** 이 레이아웃이 포함하는 partial 경로 (중첩 partial 은 재귀로 펼친다) */
|
||||
function directPartials(layout: any): string[] {
|
||||
const found: string[] = [];
|
||||
const walk = (node: any) => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
if (typeof node.partial === 'string') found.push(node.partial);
|
||||
for (const value of Object.values(node)) {
|
||||
if (value && typeof value === 'object') walk(value);
|
||||
}
|
||||
};
|
||||
walk(layout);
|
||||
return found;
|
||||
}
|
||||
|
||||
function expandPartials(entry: string, seen = new Set<string>()): string[] {
|
||||
const layout = parsed.get(entry);
|
||||
if (!layout) return [];
|
||||
const result: string[] = [];
|
||||
for (const p of directPartials(layout)) {
|
||||
if (seen.has(p)) continue;
|
||||
seen.add(p);
|
||||
result.push(p, ...expandPartials(p, seen));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 표현식에서 참조하는 루트 식별자 추출 (`a?.b`, `a.b`, `a[` 모두 a 로 환원) */
|
||||
function rootIdentifiers(text: string): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
const bindings = text.match(/\{\{[\s\S]*?\}\}/g) ?? [];
|
||||
for (const binding of bindings) {
|
||||
// `$t:foo.bar` 는 다국어 키다 — 점 표기가 같아도 데이터 참조가 아니다
|
||||
const inner = binding.slice(2, -2).replace(/\$t:[\w.]+/g, '');
|
||||
for (const m of inner.matchAll(/(^|[^\w$.?])([A-Za-z_$][\w$]*)\s*(\??\.|\[)/g)) {
|
||||
ids.add(m[2]);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* partial 안에서 자체적으로 이름이 묶이는 식별자.
|
||||
* 반복 렌더링 변수와 화살표 함수 파라미터는 데이터소스와 이름이 겹쳐도 참조가 아니다.
|
||||
*/
|
||||
function locallyBoundNames(partial: any): Set<string> {
|
||||
const bound = new Set<string>();
|
||||
const walk = (node: any) => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
if (node.iteration) {
|
||||
if (typeof node.iteration.item_var === 'string') bound.add(node.iteration.item_var);
|
||||
if (typeof node.iteration.index_var === 'string') bound.add(node.iteration.index_var);
|
||||
}
|
||||
for (const value of Object.values(node)) {
|
||||
if (value && typeof value === 'object') walk(value);
|
||||
}
|
||||
};
|
||||
walk(partial);
|
||||
|
||||
for (const text of collectStrings(partial)) {
|
||||
// (a, b) => ... / a => ...
|
||||
for (const m of text.matchAll(/\(([^()]*)\)\s*=>/g)) {
|
||||
for (const part of m[1].split(',')) {
|
||||
const name = part.trim().replace(/[^\w$].*$/, '');
|
||||
if (name) bound.add(name);
|
||||
}
|
||||
}
|
||||
for (const m of text.matchAll(/(^|[^\w$.])([A-Za-z_$][\w$]*)\s*=>/g)) bound.add(m[2]);
|
||||
// const x = ... (표현식 내부 지역 변수)
|
||||
for (const m of text.matchAll(/\b(?:const|let)\s+([A-Za-z_$][\w$]*)/g)) bound.add(m[1]);
|
||||
}
|
||||
return bound;
|
||||
}
|
||||
|
||||
/** 부모 레이아웃과 그 부모가 펼치는 모든 partial 에서 이름이 묶이는 식별자 */
|
||||
function boundNamesInTree(entry: string): Set<string> {
|
||||
const names = locallyBoundNames(parsed.get(entry));
|
||||
const base = parsed.get(entry)?.extends;
|
||||
if (typeof base === 'string') {
|
||||
const baseEntry = base.endsWith('.json') ? base : `${base}.json`;
|
||||
for (const n of locallyBoundNames(parsed.get(baseEntry))) names.add(n);
|
||||
for (const p of expandPartials(baseEntry)) {
|
||||
for (const n of locallyBoundNames(parsed.get(p))) names.add(n);
|
||||
}
|
||||
}
|
||||
for (const p of expandPartials(entry)) {
|
||||
for (const n of locallyBoundNames(parsed.get(p))) names.add(n);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
// 템플릿 전체에서 한 번이라도 데이터소스 ID 로 쓰인 이름
|
||||
const allDataSourceIds = new Set<string>();
|
||||
for (const layout of parsed.values()) {
|
||||
for (const id of dataSourceIds(layout)) allDataSourceIds.add(id);
|
||||
}
|
||||
|
||||
// partial → 그 partial 을 (직접/간접) 포함하는 최상위 레이아웃 목록
|
||||
const includers = new Map<string, string[]>();
|
||||
for (const [entry, layout] of parsed.entries()) {
|
||||
if (layout?.meta?.is_partial) continue;
|
||||
for (const p of expandPartials(entry)) {
|
||||
if (!includers.has(p)) includers.set(p, []);
|
||||
includers.get(p)!.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 의도적으로 허용하는 조합.
|
||||
* 비회원 주문에는 회원 주소록이 없다 — 저장 주소 목록은 빈 상태 분기로 접히고 직접 입력 폼만 남는
|
||||
* 것이 설계된 동작이다. 조용히 넘기지 않고 여기에 남겨 사유를 코드에 기록한다.
|
||||
*/
|
||||
const ALLOWED_MISSING = new Set<string>([
|
||||
'partials/mypage/orders/_modal_change_address.json → shop/guest_order_show.json → userAddresses',
|
||||
]);
|
||||
|
||||
describe('공유 partial ↔ 데이터소스 계약 (#519)', () => {
|
||||
it('템플릿에 데이터소스와 partial 이 실제로 수집되어야 함 (스캔 자체의 공회전 방지)', () => {
|
||||
expect(allDataSourceIds.size).toBeGreaterThan(10);
|
||||
expect(includers.size).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it('partial 이 참조하는 데이터소스는 그 partial 을 포함하는 모든 레이아웃에 정의되어야 함', () => {
|
||||
const violations: string[] = [];
|
||||
|
||||
for (const [partialPath, parents] of includers.entries()) {
|
||||
const partial = parsed.get(partialPath);
|
||||
if (!partial) continue;
|
||||
|
||||
const referenced = new Set<string>();
|
||||
for (const text of collectStrings(partial)) {
|
||||
for (const id of rootIdentifiers(text)) {
|
||||
if (allDataSourceIds.has(id)) referenced.add(id);
|
||||
}
|
||||
}
|
||||
if (referenced.size === 0) continue;
|
||||
|
||||
for (const parent of parents) {
|
||||
const defined = definedDataSources(parent);
|
||||
// 함께 렌더되는 트리(부모 + 그 부모가 펼치는 모든 partial)에서 이름이 묶이면 참조가 아니다
|
||||
const bound = boundNamesInTree(parent);
|
||||
|
||||
for (const id of referenced) {
|
||||
if (defined.has(id) || bound.has(id)) continue;
|
||||
if (ALLOWED_MISSING.has(`${partialPath} → ${parent} → ${id}`)) continue;
|
||||
violations.push(`${partialPath} 가 "${id}" 를 참조하지만 ${parent} 에 그 데이터소스가 없음`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('쇼핑 섹션 partial 은 데이터소스 이름이 아니라 $computed 를 읽어야 함 (#519 F4)', () => {
|
||||
/**
|
||||
* 섹션 partial(최근 본 상품 / 인기 상품 / 신상품)은 여러 화면이 재사용한다.
|
||||
* 데이터소스 이름에 직결하면 그 이름을 가진 화면에서만 동작하고, 다른 화면에서는
|
||||
* `?? []` 폴백에 걸려 예외도 로그도 없이 섹션이 통째로 사라진다.
|
||||
* 그래서 값 전달은 부모가 정의한 $computed 로만 한다.
|
||||
*/
|
||||
const sectionPartials = Array.from(parsed.keys()).filter(
|
||||
(entry) => entry.startsWith('partials/shop/list/_') && entry.endsWith('_products.json')
|
||||
);
|
||||
|
||||
it('검사 대상 섹션 partial 이 실제로 수집되어야 함', () => {
|
||||
expect(sectionPartials.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it.each(sectionPartials)('%s 이 데이터소스 ID 를 직접 참조하지 않아야 함', (entry) => {
|
||||
const referenced = new Set<string>();
|
||||
for (const text of collectStrings(parsed.get(entry))) {
|
||||
for (const id of rootIdentifiers(text)) {
|
||||
if (allDataSourceIds.has(id)) referenced.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
expect(Array.from(referenced)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(sectionPartials)('%s 이 $computed 로 목록을 받아야 함', (entry) => {
|
||||
const texts = collectStrings(parsed.get(entry)).join('\n');
|
||||
|
||||
expect(texts).toContain('$computed.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('상품 그리드 페이저 — 총 건수가 부정확해도 다음 페이지가 열려 있어야 함 (#519)', () => {
|
||||
const grid = parsed.get('partials/shop/list/_product_grid.json');
|
||||
|
||||
/** 조건에 맞는 노드를 재귀 탐색 */
|
||||
function findNodes(node: any, predicate: (n: any) => boolean, acc: any[] = []): any[] {
|
||||
if (!node || typeof node !== 'object') return acc;
|
||||
if (predicate(node)) acc.push(node);
|
||||
for (const value of Object.values(node)) {
|
||||
if (Array.isArray(value)) value.forEach((v) => findNodes(v, predicate, acc));
|
||||
else if (value && typeof value === 'object') findNodes(value, predicate, acc);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
it('페이저 노출 조건이 last_page 단독 비교가 아니어야 함 (null 이면 접힌다)', () => {
|
||||
const pager = findNodes(grid, (n) => typeof n.comment === 'string' && n.comment.startsWith('페이지네이션'))[0];
|
||||
expect(pager).toBeDefined();
|
||||
expect(pager.if).toContain('has_more_pages');
|
||||
});
|
||||
|
||||
it('다음 버튼 비활성 판정이 has_more_pages 를 근거로 해야 함', () => {
|
||||
const next = findNodes(grid, (n) => n.comment === '다음 페이지 버튼')[0];
|
||||
expect(next).toBeDefined();
|
||||
expect(next.props.disabled).toContain('has_more_pages');
|
||||
// last_page 와 current_page 를 비교하는 종전 판정이 남아 있으면 null 에서 항상 비활성이 된다
|
||||
expect(next.props.disabled).not.toContain('last_page');
|
||||
});
|
||||
|
||||
it('총 건수가 부정확하면 마지막 페이지 번호를 단정하지 않아야 함', () => {
|
||||
const label = findNodes(grid, (n) => n.comment === '페이지 번호 표시')[0];
|
||||
expect(label).toBeDefined();
|
||||
expect(label.text).not.toMatch(/last_page\s*\?\?\s*1/);
|
||||
});
|
||||
});
|
||||
@@ -724,7 +724,11 @@
|
||||
"providers": {
|
||||
"type": "array",
|
||||
"required": false,
|
||||
"default": ["google", "naver", "kakao"],
|
||||
"default": [
|
||||
"google",
|
||||
"naver",
|
||||
"kakao"
|
||||
],
|
||||
"description": "활성화할 소셜 로그인 프로바이더"
|
||||
},
|
||||
"className": {
|
||||
@@ -883,20 +887,83 @@
|
||||
"description": "알림 센터 드롭다운 — 알림 목록 표시, 무한스크롤, 읽음 추적, 안 읽은 필터, 개별/전체 삭제",
|
||||
"path": "src/components/composite/NotificationCenter.tsx",
|
||||
"props": {
|
||||
"notifications": { "type": "array", "required": false, "default": [], "description": "알림 아이템 목록 (id, title, message, time, read, iconName, data)" },
|
||||
"unreadCount": { "type": "number", "required": false, "default": 0, "description": "서버 기반 미읽음 알림 수" },
|
||||
"hasMore": { "type": "boolean", "required": false, "default": false, "description": "추가 로드 가능 여부 (무한스크롤)" },
|
||||
"loading": { "type": "boolean", "required": false, "default": false, "description": "로딩 상태" },
|
||||
"titleText": { "type": "string", "required": false, "default": "알림", "description": "드롭다운 제목" },
|
||||
"emptyText": { "type": "string", "required": false, "description": "알림 없음 텍스트" },
|
||||
"markAllReadText": { "type": "string", "required": false, "description": "모두 읽음 버튼 텍스트" },
|
||||
"deleteAllText": { "type": "string", "required": false, "description": "모두 삭제 버튼 텍스트" },
|
||||
"unreadOnlyText": { "type": "string", "required": false, "description": "안 읽은 알림만 체크박스 텍스트" },
|
||||
"unreadOnly": { "type": "boolean", "required": false, "default": false, "description": "안 읽은 알림만 필터 상태" },
|
||||
"dropdownAlign": { "type": "string", "required": false, "default": "right", "description": "드롭다운 정렬 방향 (right/left)" },
|
||||
"className": { "type": "string", "required": false, "description": "추가 CSS 클래스" }
|
||||
"notifications": {
|
||||
"type": "array",
|
||||
"required": false,
|
||||
"default": [],
|
||||
"description": "알림 아이템 목록 (id, title, message, time, read, iconName, data)"
|
||||
},
|
||||
"unreadCount": {
|
||||
"type": "number",
|
||||
"required": false,
|
||||
"default": 0,
|
||||
"description": "서버 기반 미읽음 알림 수"
|
||||
},
|
||||
"hasMore": {
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": false,
|
||||
"description": "추가 로드 가능 여부 (무한스크롤)"
|
||||
},
|
||||
"loading": {
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": false,
|
||||
"description": "로딩 상태"
|
||||
},
|
||||
"titleText": {
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "알림",
|
||||
"description": "드롭다운 제목"
|
||||
},
|
||||
"emptyText": {
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"description": "알림 없음 텍스트"
|
||||
},
|
||||
"markAllReadText": {
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"description": "모두 읽음 버튼 텍스트"
|
||||
},
|
||||
"deleteAllText": {
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"description": "모두 삭제 버튼 텍스트"
|
||||
},
|
||||
"unreadOnlyText": {
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"description": "안 읽은 알림만 체크박스 텍스트"
|
||||
},
|
||||
"unreadOnly": {
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": false,
|
||||
"description": "안 읽은 알림만 필터 상태"
|
||||
},
|
||||
"dropdownAlign": {
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "right",
|
||||
"description": "드롭다운 정렬 방향 (right/left)"
|
||||
},
|
||||
"className": {
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"description": "추가 CSS 클래스"
|
||||
}
|
||||
},
|
||||
"events": ["onClose", "onNotificationClick", "onLoadMore", "onMarkAllRead", "onDeleteAll", "onDelete", "onUnreadOnlyToggle"]
|
||||
"events": [
|
||||
"onClose",
|
||||
"onNotificationClick",
|
||||
"onLoadMore",
|
||||
"onMarkAllRead",
|
||||
"onDeleteAll",
|
||||
"onDelete",
|
||||
"onUnreadOnlyToggle"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Pagination",
|
||||
@@ -911,8 +978,8 @@
|
||||
},
|
||||
"totalPages": {
|
||||
"type": "number",
|
||||
"required": true,
|
||||
"description": "전체 페이지 수"
|
||||
"required": false,
|
||||
"description": "전체 페이지 수. 총 건수를 정확히 세지 못한 목록은 null (페이지 번호 목록과 마지막 페이지 점프가 감춰지고 이전/다음만 남음)"
|
||||
},
|
||||
"maxVisiblePages": {
|
||||
"type": "number",
|
||||
@@ -926,6 +993,21 @@
|
||||
"default": true,
|
||||
"description": "처음/마지막 버튼 표시 여부"
|
||||
},
|
||||
"showFirst": {
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"description": "처음 버튼 표시 여부 (미지정 시 showFirstLast 를 따름)"
|
||||
},
|
||||
"showLast": {
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"description": "마지막 버튼 표시 여부 (미지정 시 showFirstLast 를 따름). 총 건수가 부정확해 totalPages 가 null 이면 이 값과 무관하게 감춰짐"
|
||||
},
|
||||
"hasMorePages": {
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"description": "다음 페이지 존재 여부. 총 건수를 모르는 목록에서 '다음' 버튼 활성 여부를 이 값으로 판정"
|
||||
},
|
||||
"prevText": {
|
||||
"type": "string",
|
||||
"required": false,
|
||||
|
||||
File diff suppressed because one or more lines are too long
+13
-13
File diff suppressed because one or more lines are too long
+31
-1
@@ -2,10 +2,40 @@ import { default as React } from 'react';
|
||||
import { EditorAttrs } from '../../types';
|
||||
export interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
/**
|
||||
* 마지막 페이지 번호
|
||||
*
|
||||
* 총 건수가 상한을 넘겨 정확히 세지 않은 목록은 마지막 페이지를 계산할 수 없다.
|
||||
* 그 경우 서버가 `null` 을 보내며, 페이지 번호 목록과 마지막 페이지 점프가 사라지고
|
||||
* 이전/다음 이동만 남는다. 0 이나 1 로 채우면 화면이 "1페이지뿐" 이라고 잘못 말하게 된다.
|
||||
*/
|
||||
totalPages: number | null;
|
||||
onPageChange: (page: number) => void;
|
||||
maxVisiblePages?: number;
|
||||
/**
|
||||
* 첫/마지막 페이지 버튼 표시 여부 (두 버튼의 기본값)
|
||||
*
|
||||
* 마지막 페이지만 따로 감춰야 하는 경우가 있으므로 `showLast` 로 덮어쓸 수 있다.
|
||||
*/
|
||||
showFirstLast?: boolean;
|
||||
/**
|
||||
* 첫 페이지 버튼 표시 여부 (미지정 시 showFirstLast 를 따른다)
|
||||
*/
|
||||
showFirst?: boolean;
|
||||
/**
|
||||
* 마지막 페이지 버튼 표시 여부 (미지정 시 showFirstLast 를 따른다)
|
||||
*
|
||||
* 총 건수가 부정확한 목록에서는 마지막 페이지를 계산할 수 없으므로,
|
||||
* `totalPages` 가 null 이면 이 값과 무관하게 감춰진다.
|
||||
*/
|
||||
showLast?: boolean;
|
||||
/**
|
||||
* 다음 페이지 존재 여부 (총 건수를 모르는 목록에서 사용)
|
||||
*
|
||||
* 총 건수를 몰라도 다음 페이지 존재 여부는 서버가 정확히 판정한다.
|
||||
* `totalPages` 가 null 일 때만 쓰인다 — 아래 `canGoNext` 주석 참조.
|
||||
*/
|
||||
hasMorePages?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
prevText?: string;
|
||||
|
||||
@@ -1163,6 +1163,7 @@
|
||||
"searchResults": "Search Results",
|
||||
"shippingSettings": "Shipping Settings",
|
||||
"stats": "Stats",
|
||||
"storefront": "Storefront (combined)",
|
||||
"termsContent": "Terms Content",
|
||||
"tokenValidation": "Token Validation",
|
||||
"user": "User",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"results_count": "{{count}} results",
|
||||
"result_summary": "{{count}} results found",
|
||||
"result_count_suffix": " results found",
|
||||
"result_count_suffix_at_least": "+ results",
|
||||
"no_results": "No results found.",
|
||||
"try_different": "Try a different search term.",
|
||||
"tabs": {
|
||||
@@ -47,5 +48,6 @@
|
||||
"hint_title": "Search Tips",
|
||||
"hint_1": "Use multiple words for more accurate results",
|
||||
"hint_2": "Search covers both titles and content"
|
||||
}
|
||||
}
|
||||
},
|
||||
"refine_query_hint": "Narrow your search terms to see the exact total and jump to the last page."
|
||||
}
|
||||
|
||||
@@ -1163,6 +1163,7 @@
|
||||
"searchResults": "검색 결과",
|
||||
"shippingSettings": "배송 설정",
|
||||
"stats": "통계",
|
||||
"storefront": "쇼핑 첫 화면 통합 데이터",
|
||||
"termsContent": "이용약관 내용",
|
||||
"tokenValidation": "토큰 검증",
|
||||
"user": "사용자",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"results_count": "{{count}}개의 결과",
|
||||
"result_summary": "검색 결과 {{count}}건",
|
||||
"result_count_suffix": "건",
|
||||
"result_count_suffix_at_least": "건 이상",
|
||||
"no_results": "검색 결과가 없습니다.",
|
||||
"try_different": "다른 검색어로 시도해보세요.",
|
||||
"tabs": {
|
||||
@@ -47,5 +48,6 @@
|
||||
"hint_title": "검색 팁",
|
||||
"hint_1": "두 단어 이상을 입력하면 더 정확한 결과를 얻을 수 있습니다",
|
||||
"hint_2": "검색어는 제목과 내용에서 함께 검색됩니다"
|
||||
}
|
||||
}
|
||||
},
|
||||
"refine_query_hint": "검색어를 더 구체적으로 입력하면 정확한 건수와 마지막 페이지를 볼 수 있습니다."
|
||||
}
|
||||
|
||||
@@ -251,7 +251,7 @@
|
||||
"props": { "className": "inline-flex items-center gap-1 px-2.5 py-0.5 rounded text-xs font-bold bg-blue-600 text-white dark:bg-blue-500" },
|
||||
"if": "{{post?.row_type === 'notice'}}",
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Span", "text": "{{post?.number}}" }
|
||||
{ "type": "basic", "name": "Span", "text": "{{post?.number ?? '-'}}" }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -267,7 +267,7 @@
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": { "className": "text-sm text-gray-500 dark:text-gray-400" },
|
||||
"text": "{{post?.number}}",
|
||||
"text": "{{post?.number ?? '-'}}",
|
||||
"if": "{{post?.row_type === 'normal'}}"
|
||||
}
|
||||
]
|
||||
@@ -308,7 +308,7 @@
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"if": "{{post?.status === 'blinded'}}",
|
||||
"props": { "name": "eye-slash", "size": "sm", "className": "text-orange-300 flex-shrink-0" }
|
||||
"props": { "name": "eye-slash", "size": "sm", "className": "text-orange-500 dark:text-orange-400 flex-shrink-0" }
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -344,7 +344,7 @@
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{post?.is_new}}",
|
||||
"props": { "className": "px-1.5 py-0.5 text-xs bg-red-500 text-white rounded flex-shrink-0" },
|
||||
"props": { "className": "px-1.5 py-0.5 text-xs bg-red-500 dark:bg-red-500 text-white rounded flex-shrink-0" },
|
||||
"text": "N"
|
||||
}
|
||||
]
|
||||
@@ -412,7 +412,7 @@
|
||||
"props": { "className": "inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-bold bg-blue-600 text-white dark:bg-blue-500 flex-shrink-0" },
|
||||
"if": "{{post?.row_type === 'notice'}}",
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Span", "text": "{{post?.number}}" }
|
||||
{ "type": "basic", "name": "Span", "text": "{{post?.number ?? '-'}}" }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -472,7 +472,7 @@
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"if": "{{post?.status === 'blinded'}}",
|
||||
"props": { "name": "eye-slash", "size": "sm", "className": "text-orange-300 flex-shrink-0" }
|
||||
"props": { "name": "eye-slash", "size": "sm", "className": "text-orange-500 dark:text-orange-400 flex-shrink-0" }
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -508,7 +508,7 @@
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{post?.is_new}}",
|
||||
"props": { "className": "px-1.5 py-0.5 text-xs bg-red-500 text-white rounded flex-shrink-0" },
|
||||
"props": { "className": "px-1.5 py-0.5 text-xs bg-red-500 dark:bg-red-500 text-white rounded flex-shrink-0" },
|
||||
"text": "N"
|
||||
}
|
||||
]
|
||||
@@ -590,7 +590,8 @@
|
||||
"name": "Pagination",
|
||||
"props": {
|
||||
"currentPage": "{{posts?.data?.pagination?.current_page ?? 1}}",
|
||||
"totalPages": "{{posts?.data?.pagination?.last_page ?? 1}}",
|
||||
"totalPages": "{{posts?.data?.pagination?.last_page ?? null}}",
|
||||
"hasMorePages": "{{posts?.data?.pagination?.has_more_pages ?? false}}",
|
||||
"className": "mt-6 justify-center"
|
||||
},
|
||||
"actions": [
|
||||
|
||||
@@ -220,7 +220,7 @@
|
||||
"name": "Div",
|
||||
"if": "{{post?.status === 'blinded' || post?.deleted_at}}",
|
||||
"props": {
|
||||
"className": "absolute inset-0 bg-gray-900/40 z-10"
|
||||
"className": "absolute inset-0 bg-gray-900/40 dark:bg-gray-900/40 z-10"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -229,7 +229,7 @@
|
||||
"name": "Div",
|
||||
"if": "{{post?.is_secret && !post?.abilities?.can_view}}",
|
||||
"props": {
|
||||
"className": "absolute inset-0 bg-gray-900/50 flex items-center justify-center z-10"
|
||||
"className": "absolute inset-0 bg-gray-900/50 dark:bg-gray-900/50 flex items-center justify-center z-10"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -278,7 +278,7 @@
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{post?.is_new}}",
|
||||
"props": { "className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-bold bg-red-500 text-white flex-shrink-0" },
|
||||
"props": { "className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-bold bg-red-500 dark:bg-red-500 text-white flex-shrink-0" },
|
||||
"text": "N"
|
||||
}
|
||||
]
|
||||
@@ -301,7 +301,7 @@
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"if": "{{post?.status === 'blinded'}}",
|
||||
"props": { "name": "eye-slash", "size": "sm", "className": "text-orange-300 flex-shrink-0" }
|
||||
"props": { "name": "eye-slash", "size": "sm", "className": "text-orange-500 dark:text-orange-400 flex-shrink-0" }
|
||||
},
|
||||
{
|
||||
"comment": "답글 배지 (제목 앞)",
|
||||
@@ -448,7 +448,8 @@
|
||||
"name": "Pagination",
|
||||
"props": {
|
||||
"currentPage": "{{posts?.data?.pagination?.current_page ?? 1}}",
|
||||
"totalPages": "{{posts?.data?.pagination?.last_page ?? 1}}",
|
||||
"totalPages": "{{posts?.data?.pagination?.last_page ?? null}}",
|
||||
"hasMorePages": "{{posts?.data?.pagination?.has_more_pages ?? false}}",
|
||||
"className": "mt-6 justify-center"
|
||||
},
|
||||
"actions": [
|
||||
|
||||
@@ -231,7 +231,7 @@
|
||||
"name": "Div",
|
||||
"if": "{{post?.status === 'blinded' || post?.deleted_at}}",
|
||||
"props": {
|
||||
"className": "absolute inset-0 bg-gray-900/40 z-10"
|
||||
"className": "absolute inset-0 bg-gray-900/40 dark:bg-gray-900/40 z-10"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -240,7 +240,7 @@
|
||||
"name": "Div",
|
||||
"if": "{{post?.is_secret && !post?.abilities?.can_view}}",
|
||||
"props": {
|
||||
"className": "absolute inset-0 bg-gray-900/50 flex items-center justify-center z-10"
|
||||
"className": "absolute inset-0 bg-gray-900/50 dark:bg-gray-900/50 flex items-center justify-center z-10"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -288,7 +288,7 @@
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{post?.is_new}}",
|
||||
"props": { "className": "px-1.5 py-0.5 text-xs font-bold bg-red-500 text-white rounded flex-shrink-0" },
|
||||
"props": { "className": "px-1.5 py-0.5 text-xs font-bold bg-red-500 dark:bg-red-500 text-white rounded flex-shrink-0" },
|
||||
"text": "N"
|
||||
}
|
||||
]
|
||||
@@ -312,14 +312,14 @@
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"if": "{{post?.status === 'blinded'}}",
|
||||
"props": { "name": "eye-slash", "size": "sm", "className": "text-orange-300 flex-shrink-0" }
|
||||
"props": { "name": "eye-slash", "size": "sm", "className": "text-orange-500 dark:text-orange-400 flex-shrink-0" }
|
||||
},
|
||||
{
|
||||
"comment": "답글 배지",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{post?.is_reply}}",
|
||||
"props": { "className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-500/80 text-white flex-shrink-0" },
|
||||
"props": { "className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-500/80 dark:bg-purple-500/80 text-white flex-shrink-0" },
|
||||
"text": "RE"
|
||||
},
|
||||
{
|
||||
@@ -327,13 +327,13 @@
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"if": "{{post?.is_secret}}",
|
||||
"props": { "name": "lock", "size": "sm", "className": "text-gray-300 flex-shrink-0" }
|
||||
"props": { "name": "lock", "size": "sm", "className": "text-gray-300 dark:text-gray-300 flex-shrink-0" }
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-base font-semibold truncate transition-colors {{(post?.status === 'blinded' || post?.deleted_at) ? 'text-gray-400' : 'text-white'}}"
|
||||
"className": "text-base font-semibold truncate transition-colors {{(post?.status === 'blinded' || post?.deleted_at) ? 'text-gray-400 dark:text-gray-400' : 'text-white'}}"
|
||||
},
|
||||
"text": "{{post?.title}}"
|
||||
},
|
||||
@@ -342,7 +342,7 @@
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"if": "{{post?.has_attachment}}",
|
||||
"props": { "name": "paperclip", "size": "sm", "className": "text-gray-300 flex-shrink-0" }
|
||||
"props": { "name": "paperclip", "size": "sm", "className": "text-gray-300 dark:text-gray-300 flex-shrink-0" }
|
||||
},
|
||||
{
|
||||
"comment": "삭제됨 배지 (제목 옆)",
|
||||
@@ -381,19 +381,19 @@
|
||||
"author": "{{post?.author}}",
|
||||
"showDropdown": true,
|
||||
"stopPropagation": true,
|
||||
"className": "text-xs font-medium {{(post?.status === 'blinded' || post?.deleted_at) ? 'text-gray-400' : 'text-gray-200'}}"
|
||||
"className": "text-xs font-medium {{(post?.status === 'blinded' || post?.deleted_at) ? 'text-gray-400 dark:text-gray-400' : 'text-gray-200 dark:text-gray-200'}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": { "className": "text-gray-400 flex-shrink-0" },
|
||||
"props": { "className": "text-gray-400 dark:text-gray-400 flex-shrink-0" },
|
||||
"text": "·"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": { "className": "flex-shrink-0 {{(post?.status === 'blinded' || post?.deleted_at) ? 'text-gray-400' : 'text-gray-300'}}", "title": "{{post?.created_at ?? ''}}" },
|
||||
"props": { "className": "flex-shrink-0 {{(post?.status === 'blinded' || post?.deleted_at) ? 'text-gray-400 dark:text-gray-400' : 'text-gray-300 dark:text-gray-300'}}", "title": "{{post?.created_at ?? ''}}" },
|
||||
"text": "{{post?.created_at_formatted ?? ''}}"
|
||||
}
|
||||
]
|
||||
@@ -407,7 +407,7 @@
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{posts?.data?.board?.settings?.show_view_count !== false}}",
|
||||
"props": { "className": "flex items-center gap-0.5 {{(post?.status === 'blinded' || post?.deleted_at) ? 'text-gray-400' : 'text-gray-300'}}" },
|
||||
"props": { "className": "flex items-center gap-0.5 {{(post?.status === 'blinded' || post?.deleted_at) ? 'text-gray-400 dark:text-gray-400' : 'text-gray-300 dark:text-gray-300'}}" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "eye", "size": "xs" } },
|
||||
{ "type": "basic", "name": "Span", "text": "{{post?.view_count ?? 0}}" }
|
||||
@@ -417,7 +417,7 @@
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{post?.comment_count > 0}}",
|
||||
"props": { "className": "flex items-center gap-0.5 {{(post?.status === 'blinded' || post?.deleted_at) ? 'text-gray-400' : 'text-blue-300'}}" },
|
||||
"props": { "className": "flex items-center gap-0.5 {{(post?.status === 'blinded' || post?.deleted_at) ? 'text-gray-400 dark:text-gray-400' : 'text-blue-300 dark:text-blue-300'}}" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "message", "size": "xs" } },
|
||||
{ "type": "basic", "name": "Span", "text": "{{post?.comment_count ?? 0}}" }
|
||||
@@ -443,7 +443,8 @@
|
||||
"name": "Pagination",
|
||||
"props": {
|
||||
"currentPage": "{{posts?.data?.pagination?.current_page ?? 1}}",
|
||||
"totalPages": "{{posts?.data?.pagination?.last_page ?? 1}}",
|
||||
"totalPages": "{{posts?.data?.pagination?.last_page ?? null}}",
|
||||
"hasMorePages": "{{posts?.data?.pagination?.has_more_pages ?? false}}",
|
||||
"className": "mt-6 justify-center"
|
||||
},
|
||||
"actions": [
|
||||
|
||||
@@ -434,10 +434,11 @@
|
||||
"comment": "페이지네이션",
|
||||
"type": "composite",
|
||||
"name": "Pagination",
|
||||
"if": "{{myComments?.data?.last_page > 1}}",
|
||||
"if": "{{((myComments?.data?.last_page ?? 0) > 1) || (myComments?.data?.has_more_pages === true) || ((myComments?.data?.current_page ?? 1) > 1)}}",
|
||||
"props": {
|
||||
"currentPage": "{{myComments?.data?.current_page ?? 1}}",
|
||||
"totalPages": "{{myComments?.data?.last_page ?? 1}}",
|
||||
"totalPages": "{{myComments?.data?.last_page ?? null}}",
|
||||
"hasMorePages": "{{myComments?.data?.has_more_pages ?? false}}",
|
||||
"className": "mt-4 flex justify-center"
|
||||
},
|
||||
"actions": [
|
||||
|
||||
@@ -501,10 +501,11 @@
|
||||
"comment": "페이지네이션",
|
||||
"type": "composite",
|
||||
"name": "Pagination",
|
||||
"if": "{{myPosts?.data?.last_page > 1}}",
|
||||
"if": "{{((myPosts?.data?.last_page ?? 0) > 1) || (myPosts?.data?.has_more_pages === true) || ((myPosts?.data?.current_page ?? 1) > 1)}}",
|
||||
"props": {
|
||||
"currentPage": "{{myPosts?.data?.current_page ?? 1}}",
|
||||
"totalPages": "{{myPosts?.data?.last_page ?? 1}}",
|
||||
"totalPages": "{{myPosts?.data?.last_page ?? null}}",
|
||||
"hasMorePages": "{{myPosts?.data?.has_more_pages ?? false}}",
|
||||
"className": "mt-4 flex justify-center"
|
||||
},
|
||||
"actions": [
|
||||
|
||||
@@ -591,7 +591,8 @@
|
||||
"name": "Pagination",
|
||||
"props": {
|
||||
"currentPage": "{{myInquiries.data.meta.current_page ?? 1}}",
|
||||
"totalPages": "{{myInquiries.data.meta.last_page ?? 1}}",
|
||||
"totalPages": "{{myInquiries.data.meta.last_page ?? null}}",
|
||||
"hasMorePages": "{{myInquiries.data.meta.has_more_pages ?? false}}",
|
||||
"className": "pt-4 flex justify-center"
|
||||
},
|
||||
"actions": [
|
||||
|
||||
@@ -556,17 +556,19 @@
|
||||
"comment": "페이지네이션",
|
||||
"type": "composite",
|
||||
"name": "Pagination",
|
||||
"if": "{{mileage_history.data.transactions.pagination.last_page > 1}}",
|
||||
"if": "{{((mileage_history.data.transactions.pagination.last_page ?? 0) > 1) || (mileage_history.data.transactions.pagination.has_more_pages === true) || ((mileage_history.data.transactions.pagination.current_page ?? 1) > 1)}}",
|
||||
"props": {
|
||||
"currentPage": "{{mileage_history.data.transactions.pagination.current_page}}",
|
||||
"totalPages": "{{mileage_history.data.transactions.pagination.last_page}}",
|
||||
"totalPages": "{{mileage_history.data.transactions.pagination.last_page ?? null}}",
|
||||
"hasMorePages": "{{mileage_history.data.transactions.pagination.has_more_pages ?? false}}",
|
||||
"className": "mt-4"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"currentPage": "{{mileage_history.data.transactions.pagination.current_page}}",
|
||||
"totalPages": "{{mileage_history.data.transactions.pagination.last_page}}",
|
||||
"totalPages": "{{mileage_history.data.transactions.pagination.last_page ?? null}}",
|
||||
"hasMorePages": "{{mileage_history.data.transactions.pagination.has_more_pages ?? false}}",
|
||||
"className": "mt-3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,17 +374,19 @@
|
||||
"comment": "페이지네이션",
|
||||
"type": "composite",
|
||||
"name": "Pagination",
|
||||
"if": "{{userNotifications.data.last_page > 1}}",
|
||||
"if": "{{((userNotifications.data.last_page ?? 0) > 1) || (userNotifications.data.has_more_pages === true) || ((userNotifications.data.current_page ?? 1) > 1)}}",
|
||||
"props": {
|
||||
"currentPage": "{{userNotifications.data.current_page}}",
|
||||
"totalPages": "{{userNotifications.data.last_page}}",
|
||||
"totalPages": "{{userNotifications.data.last_page ?? null}}",
|
||||
"hasMorePages": "{{userNotifications.data.has_more_pages ?? false}}",
|
||||
"className": "p-4 border-t border-gray-200 dark:border-gray-700"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"currentPage": "{{userNotifications.data.current_page}}",
|
||||
"totalPages": "{{userNotifications.data.last_page}}",
|
||||
"totalPages": "{{userNotifications.data.last_page ?? null}}",
|
||||
"hasMorePages": "{{userNotifications.data.has_more_pages ?? false}}",
|
||||
"className": "p-3 border-t border-gray-200 dark:border-gray-700"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1035,17 +1035,19 @@
|
||||
"comment": "페이지네이션",
|
||||
"type": "composite",
|
||||
"name": "Pagination",
|
||||
"if": "{{orders.data.pagination.last_page > 1}}",
|
||||
"if": "{{((orders.data.pagination.last_page ?? 0) > 1) || (orders.data.pagination.has_more_pages === true) || ((orders.data.pagination.current_page ?? 1) > 1)}}",
|
||||
"props": {
|
||||
"currentPage": "{{orders.data.pagination.current_page}}",
|
||||
"totalPages": "{{orders.data.pagination.last_page}}",
|
||||
"totalPages": "{{orders.data.pagination.last_page ?? null}}",
|
||||
"hasMorePages": "{{orders.data.pagination.has_more_pages ?? false}}",
|
||||
"className": "mt-4"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"currentPage": "{{orders.data.pagination.current_page}}",
|
||||
"totalPages": "{{orders.data.pagination.last_page}}",
|
||||
"totalPages": "{{orders.data.pagination.last_page ?? null}}",
|
||||
"hasMorePages": "{{orders.data.pagination.has_more_pages ?? false}}",
|
||||
"className": "mt-3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,10 +146,11 @@
|
||||
"comment": "페이지네이션",
|
||||
"type": "composite",
|
||||
"name": "Pagination",
|
||||
"if": "{{wishlist.data.pagination.last_page > 1}}",
|
||||
"if": "{{((wishlist.data.pagination.last_page ?? 0) > 1) || (wishlist.data.pagination.has_more_pages === true) || ((wishlist.data.pagination.current_page ?? 1) > 1)}}",
|
||||
"props": {
|
||||
"currentPage": "{{wishlist.data.pagination.current_page}}",
|
||||
"totalPages": "{{wishlist.data.pagination.last_page}}",
|
||||
"totalPages": "{{wishlist.data.pagination.last_page ?? null}}",
|
||||
"hasMorePages": "{{wishlist.data.pagination.has_more_pages ?? false}}",
|
||||
"className": "pt-4 flex justify-center"
|
||||
},
|
||||
"actions": [
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"type": "change",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{ "handler": "setState", "params": { "target": "global", "searchBoardFilter": "{{$event.target.value}}", "searchPage": 1 } },
|
||||
{ "handler": "setState", "params": { "target": "global", "searchBoardFilter": "{{$event.target.value}}", "searchPage": 1, "searchCursor": "" } },
|
||||
{ "handler": "refetchDataSource", "params": { "dataSourceId": "searchResults" } }
|
||||
]
|
||||
}
|
||||
@@ -54,7 +54,7 @@
|
||||
"type": "change",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{ "handler": "setState", "params": { "target": "global", "searchSortBy": "{{$event.target.value}}", "searchPage": 1 } },
|
||||
{ "handler": "setState", "params": { "target": "global", "searchSortBy": "{{$event.target.value}}", "searchPage": 1, "searchCursor": "" } },
|
||||
{ "handler": "refetchDataSource", "params": { "dataSourceId": "searchResults" } }
|
||||
]
|
||||
}
|
||||
@@ -81,7 +81,7 @@
|
||||
"type": "change",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{ "handler": "setState", "params": { "target": "global", "searchSortBy": "{{$event.target.value}}", "searchPage": 1 } },
|
||||
{ "handler": "setState", "params": { "target": "global", "searchSortBy": "{{$event.target.value}}", "searchPage": 1, "searchCursor": "" } },
|
||||
{ "handler": "refetchDataSource", "params": { "dataSourceId": "searchResults" } }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": { "name": "search", "size": "sm", "className": "absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" }
|
||||
"props": { "name": "search", "size": "sm", "className": "absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500 pointer-events-none" }
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -51,7 +51,7 @@
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{ "handler": "setState", "params": { "target": "global", "searchPage": 1 } },
|
||||
{ "handler": "setState", "params": { "target": "global", "searchPage": 1, "searchCursor": "" } },
|
||||
{
|
||||
"comment": "audit:allow layout-list-context-navigate-merge-query 새 검색 실행 — 검색어를 바꾸면 결과 집합 자체가 달라지므로 이전 페이지·필터를 물려받지 않는다.",
|
||||
"handler": "navigate",
|
||||
|
||||
@@ -18,8 +18,28 @@
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "text-blue-600 dark:text-blue-400 font-medium" }, "text": "\"{{query?.q}}\"" },
|
||||
{ "type": "basic", "name": "Span", "text": " $t:search.results" },
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "font-medium" }, "text": " {{(searchResults.data.total ?? 0) | number}}" },
|
||||
{ "type": "basic", "name": "Span", "text": "$t:search.result_count_suffix" }
|
||||
{
|
||||
"comment": "총 건수가 상한에 걸리면 '건' 이 아니라 '건 이상' — 세지 않은 값을 정확한 것처럼 말하지 않는다",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{(searchResults?.data?.total_is_exact ?? true) === true}}",
|
||||
"text": "$t:search.result_count_suffix"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{(searchResults?.data?.total_is_exact ?? true) === false}}",
|
||||
"text": "$t:search.result_count_suffix_at_least"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "상한 초과 안내 — 검색어를 좁히면 정확한 건수와 마지막 페이지를 볼 수 있다",
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"if": "{{(searchResults?.data?.total_is_exact ?? true) === false}}",
|
||||
"props": { "className": "mt-1 text-xs text-gray-500 dark:text-gray-400" },
|
||||
"text": "$t:search.refine_query_hint"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -12,14 +12,14 @@
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "grid", "size": "sm", "className": "mr-1.5 inline" } },
|
||||
{ "type": "basic", "name": "Span", "text": "$t:search.tabs.all" },
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "ml-1.5 {{(_global.searchActiveTab ?? 'all') === 'all' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400'}}" }, "text": "{{(searchResults.data.all_count ?? searchResults.data.total ?? 0) | number}}" }
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "ml-1.5 {{(_global.searchActiveTab ?? 'all') === 'all' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400'}}" }, "text": "{{(searchResults.data.all_count ?? searchResults.data.total ?? 0) | number}}{{(searchResults.data.all_count_is_exact ?? true) ? '' : '+'}}" }
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{ "handler": "setState", "params": { "target": "global", "searchActiveTab": "all", "searchBoardFilter": "", "searchPage": 1 } },
|
||||
{ "handler": "setState", "params": { "target": "global", "searchActiveTab": "all", "searchBoardFilter": "", "searchPage": 1, "searchCursor": "" } },
|
||||
{ "handler": "refetchDataSource", "params": { "dataSourceId": "searchResults" } }
|
||||
]
|
||||
}
|
||||
@@ -32,14 +32,14 @@
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "file-text", "size": "sm", "className": "mr-1.5 inline" } },
|
||||
{ "type": "basic", "name": "Span", "text": "$t:search.tabs.posts" },
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "ml-1.5 {{_global.searchActiveTab === 'posts' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400'}}" }, "text": "{{(searchResults.data.posts_count ?? 0) | number}}" }
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "ml-1.5 {{_global.searchActiveTab === 'posts' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400'}}" }, "text": "{{(searchResults.data.posts_count ?? 0) | number}}{{(searchResults.data.counts_are_exact?.posts ?? true) ? '' : '+'}}" }
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{ "handler": "setState", "params": { "target": "global", "searchActiveTab": "posts", "searchBoardFilter": "", "searchPage": 1 } },
|
||||
{ "handler": "setState", "params": { "target": "global", "searchActiveTab": "posts", "searchBoardFilter": "", "searchPage": 1, "searchCursor": "" } },
|
||||
{ "handler": "refetchDataSource", "params": { "dataSourceId": "searchResults" } }
|
||||
]
|
||||
}
|
||||
@@ -52,14 +52,14 @@
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "shopping-bag", "size": "sm", "className": "mr-1.5 inline" } },
|
||||
{ "type": "basic", "name": "Span", "text": "$t:search.tabs.products" },
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "ml-1.5 {{_global.searchActiveTab === 'products' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400'}}" }, "text": "{{(searchResults.data.products_count ?? 0) | number}}" }
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "ml-1.5 {{_global.searchActiveTab === 'products' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400'}}" }, "text": "{{(searchResults.data.products_count ?? 0) | number}}{{(searchResults.data.counts_are_exact?.products ?? true) ? '' : '+'}}" }
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{ "handler": "setState", "params": { "target": "global", "searchActiveTab": "products", "searchPage": 1 } },
|
||||
{ "handler": "setState", "params": { "target": "global", "searchActiveTab": "products", "searchPage": 1, "searchCursor": "" } },
|
||||
{ "handler": "refetchDataSource", "params": { "dataSourceId": "searchResults" } }
|
||||
]
|
||||
}
|
||||
@@ -72,14 +72,14 @@
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "file-alt", "size": "sm", "className": "mr-1.5 inline" } },
|
||||
{ "type": "basic", "name": "Span", "text": "$t:search.tabs.pages" },
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "ml-1.5 {{_global.searchActiveTab === 'pages' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400'}}" }, "text": "{{(searchResults.data.pages_count ?? 0) | number}}" }
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "ml-1.5 {{_global.searchActiveTab === 'pages' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400'}}" }, "text": "{{(searchResults.data.pages_count ?? 0) | number}}{{(searchResults.data.counts_are_exact?.pages ?? true) ? '' : '+'}}" }
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{ "handler": "setState", "params": { "target": "global", "searchActiveTab": "pages", "searchPage": 1 } },
|
||||
{ "handler": "setState", "params": { "target": "global", "searchActiveTab": "pages", "searchPage": 1, "searchCursor": "" } },
|
||||
{ "handler": "refetchDataSource", "params": { "dataSourceId": "searchResults" } }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -875,7 +875,8 @@
|
||||
"name": "Pagination",
|
||||
"props": {
|
||||
"currentPage": "{{qna.data.meta.current_page ?? 1}}",
|
||||
"totalPages": "{{qna.data.meta.last_page ?? 1}}",
|
||||
"totalPages": "{{qna.data.meta.last_page ?? null}}",
|
||||
"hasMorePages": "{{qna.data.meta.has_more_pages ?? false}}",
|
||||
"className": "pt-4 flex justify-center"
|
||||
},
|
||||
"actions": [
|
||||
|
||||
@@ -58,13 +58,13 @@
|
||||
"iteration": { "source": "{{_local.starNums}}", "item_var": "starNum" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "text-xs text-gray-500 dark:text-gray-400 w-3 shrink-0 text-right" }, "text": "{{starNum}}" },
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "star", "size": "sm", "className": "text-yellow-400 shrink-0" } },
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "star", "size": "sm", "className": "text-yellow-400 dark:text-yellow-400 shrink-0" } },
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "flex-1 bg-gray-200 dark:bg-gray-600 rounded-full h-2" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Div", "props": { "className": "bg-yellow-400 h-2 rounded-full", "style": { "width": "{{(reviews.data?.rating_stats?.[starNum + '']?.percent ?? 0) + '%'}}" } } }
|
||||
{ "type": "basic", "name": "Div", "props": { "className": "bg-yellow-400 dark:bg-yellow-400 h-2 rounded-full", "style": { "width": "{{(reviews.data?.rating_stats?.[starNum + '']?.percent ?? 0) + '%'}}" } } }
|
||||
]
|
||||
},
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "text-xs text-gray-500 dark:text-gray-400 w-5 text-right shrink-0" }, "text": "{{reviews.data?.rating_stats?.[starNum + '']?.count ?? 0}}" }
|
||||
@@ -401,7 +401,8 @@
|
||||
"name": "Pagination",
|
||||
"props": {
|
||||
"currentPage": "{{reviews.data?.reviews?.meta?.current_page ?? 1}}",
|
||||
"totalPages": "{{reviews.data?.reviews?.meta?.last_page ?? 1}}",
|
||||
"totalPages": "{{reviews.data?.reviews?.meta?.last_page ?? null}}",
|
||||
"hasMorePages": "{{reviews.data?.reviews?.meta?.has_more_pages ?? false}}",
|
||||
"className": "mt-6 flex justify-center"
|
||||
},
|
||||
"actions": [
|
||||
|
||||
+4
-4
@@ -25,7 +25,7 @@
|
||||
"if": "{{!!query.d1 && !!query.d2}}",
|
||||
"props": { "className": "flex items-center gap-1" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "chevron-right", "className": "w-3 h-3 text-gray-400 dark:text-gray-500" } },
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "chevron-right", "className": "text-xs text-gray-400 dark:text-gray-500" } },
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
@@ -44,7 +44,7 @@
|
||||
"if": "{{!!query.d2 && !!query.d3}}",
|
||||
"props": { "className": "flex items-center gap-1" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "chevron-right", "className": "w-3 h-3 text-gray-400 dark:text-gray-500" } },
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "chevron-right", "className": "text-xs text-gray-400 dark:text-gray-500" } },
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
@@ -63,7 +63,7 @@
|
||||
"if": "{{!!query.d3 && query.category != query.d3}}",
|
||||
"props": { "className": "flex items-center gap-1" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "chevron-right", "className": "w-3 h-3 text-gray-400 dark:text-gray-500" } },
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "chevron-right", "className": "text-xs text-gray-400 dark:text-gray-500" } },
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
@@ -81,7 +81,7 @@
|
||||
"name": "Div",
|
||||
"props": { "className": "flex items-center gap-1" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "chevron-right", "className": "w-3 h-3 text-gray-400 dark:text-gray-500" } },
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "chevron-right", "className": "text-xs text-gray-400 dark:text-gray-500" } },
|
||||
{
|
||||
"comment": "1단계 선택 (d1만, d2 없음) - 현재 카테고리명 + × 삭제",
|
||||
"type": "basic",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"name": "Div",
|
||||
"props": { "className": "flex items-center gap-2" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "filter", "className": "w-4 h-4 text-gray-500 dark:text-gray-400" } },
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "filter", "className": "text-base text-gray-500 dark:text-gray-400" } },
|
||||
{ "type": "basic", "name": "Span", "text": "$t:shop.category_filter" }
|
||||
]
|
||||
},
|
||||
@@ -27,14 +27,14 @@
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"if": "{{_local.categoryFilterOpen !== false}}",
|
||||
"props": { "name": "chevron-up", "className": "w-4 h-4" }
|
||||
"props": { "name": "chevron-up", "className": "text-base" }
|
||||
},
|
||||
{
|
||||
"comment": "접힘 아이콘",
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"if": "{{_local.categoryFilterOpen === false}}",
|
||||
"props": { "name": "chevron-down", "className": "w-4 h-4" }
|
||||
"props": { "name": "chevron-down", "className": "text-base" }
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
@@ -102,7 +102,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": { "name": "turn-up", "className": "w-4 h-4 text-gray-400 dark:text-gray-500 rotate-90 flex-shrink-0 mt-1 ml-2" }
|
||||
"props": { "name": "turn-up", "className": "text-base text-gray-400 dark:text-gray-500 rotate-90 flex-shrink-0 mt-1 ml-2" }
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -137,7 +137,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": { "name": "turn-up", "className": "w-4 h-4 text-gray-400 dark:text-gray-500 rotate-90 flex-shrink-0 mt-1 ml-2" }
|
||||
"props": { "name": "turn-up", "className": "text-base text-gray-400 dark:text-gray-500 rotate-90 flex-shrink-0 mt-1 ml-2" }
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -172,7 +172,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": { "name": "turn-up", "className": "w-4 h-4 text-gray-400 dark:text-gray-500 rotate-90 flex-shrink-0 mt-1 ml-2" }
|
||||
"props": { "name": "turn-up", "className": "text-base text-gray-400 dark:text-gray-500 rotate-90 flex-shrink-0 mt-1 ml-2" }
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"meta": {
|
||||
"is_partial": true,
|
||||
"description": "신상품 섹션"
|
||||
"description": "신상품 섹션. 데이터소스 이름에 직접 결합하지 않고 부모 레이아웃이 정의한 $computed.newItems 를 읽는다 (다른 화면이 재사용해도 섹션이 조용히 비지 않는다)"
|
||||
},
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{newProducts.data && newProducts.data.length > 0}}",
|
||||
"if": "{{($computed.newItems ?? []).length > 0}}",
|
||||
"isolatedState": {
|
||||
"scrollIdx": 0
|
||||
},
|
||||
@@ -22,7 +22,7 @@
|
||||
"name": "H2",
|
||||
"props": { "className": "text-lg font-bold text-gray-900 dark:text-white flex items-center gap-2" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "sparkles", "className": "w-5 h-5 text-yellow-500" } },
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "sparkles", "className": "text-xl text-yellow-500" } },
|
||||
{ "type": "basic", "name": "Span", "text": "$t:shop.new_products" }
|
||||
]
|
||||
},
|
||||
@@ -63,26 +63,26 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"children": [{ "type": "basic", "name": "Icon", "props": { "name": "chevron-left", "className": "w-4 h-4" } }]
|
||||
"children": [{ "type": "basic", "name": "Icon", "props": { "name": "chevron-left", "className": "text-base" } }]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"className": "{{(_isolated.scrollIdx + 4) >= (newProducts.data.length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 4) >= (newProducts.data.length)}}"
|
||||
"className": "{{(_isolated.scrollIdx + 4) >= (($computed.newItems ?? []).length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 4) >= (($computed.newItems ?? []).length)}}"
|
||||
},
|
||||
"responsive": {
|
||||
"0-639": {
|
||||
"props": {
|
||||
"className": "{{(_isolated.scrollIdx + 2) >= (newProducts.data.length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 2) >= (newProducts.data.length)}}"
|
||||
"className": "{{(_isolated.scrollIdx + 2) >= (($computed.newItems ?? []).length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 2) >= (($computed.newItems ?? []).length)}}"
|
||||
}
|
||||
},
|
||||
"640-1023": {
|
||||
"props": {
|
||||
"className": "{{(_isolated.scrollIdx + 3) >= (newProducts.data.length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 3) >= (newProducts.data.length)}}"
|
||||
"className": "{{(_isolated.scrollIdx + 3) >= (($computed.newItems ?? []).length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 3) >= (($computed.newItems ?? []).length)}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -111,7 +111,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"children": [{ "type": "basic", "name": "Icon", "props": { "name": "chevron-right", "className": "w-4 h-4" } }]
|
||||
"children": [{ "type": "basic", "name": "Icon", "props": { "name": "chevron-right", "className": "text-base" } }]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -125,7 +125,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"iteration": { "source": "{{newProducts.data ?? []}}", "item_var": "product", "index_var": "idx" },
|
||||
"iteration": { "source": "{{$computed.newItems ?? []}}", "item_var": "product", "index_var": "idx" },
|
||||
"props": {
|
||||
"id": "new-product-{{idx}}"
|
||||
},
|
||||
|
||||
+12
-12
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"meta": {
|
||||
"is_partial": true,
|
||||
"description": "인기 상품 섹션"
|
||||
"description": "인기 상품 섹션. 데이터소스 이름에 직접 결합하지 않고 부모 레이아웃이 정의한 $computed.popularItems 를 읽는다 (shop/index 와 shop/show 가 함께 쓰는 partial)"
|
||||
},
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{popularProducts.data && popularProducts.data.length > 0}}",
|
||||
"if": "{{($computed.popularItems ?? []).length > 0}}",
|
||||
"isolatedState": {
|
||||
"scrollIdx": 0
|
||||
},
|
||||
@@ -22,7 +22,7 @@
|
||||
"name": "H2",
|
||||
"props": { "className": "text-lg font-bold text-gray-900 dark:text-white flex items-center gap-2" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "flame", "className": "w-5 h-5 text-orange-500" } },
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "flame", "className": "text-xl text-orange-500" } },
|
||||
{ "type": "basic", "name": "Span", "text": "$t:shop.popular_products" }
|
||||
]
|
||||
},
|
||||
@@ -63,26 +63,26 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"children": [{ "type": "basic", "name": "Icon", "props": { "name": "chevron-left", "className": "w-4 h-4" } }]
|
||||
"children": [{ "type": "basic", "name": "Icon", "props": { "name": "chevron-left", "className": "text-base" } }]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"className": "{{(_isolated.scrollIdx + 4) >= (popularProducts.data.length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 4) >= (popularProducts.data.length)}}"
|
||||
"className": "{{(_isolated.scrollIdx + 4) >= (($computed.popularItems ?? []).length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 4) >= (($computed.popularItems ?? []).length)}}"
|
||||
},
|
||||
"responsive": {
|
||||
"0-639": {
|
||||
"props": {
|
||||
"className": "{{(_isolated.scrollIdx + 2) >= (popularProducts.data.length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 2) >= (popularProducts.data.length)}}"
|
||||
"className": "{{(_isolated.scrollIdx + 2) >= (($computed.popularItems ?? []).length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 2) >= (($computed.popularItems ?? []).length)}}"
|
||||
}
|
||||
},
|
||||
"640-1023": {
|
||||
"props": {
|
||||
"className": "{{(_isolated.scrollIdx + 3) >= (popularProducts.data.length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 3) >= (popularProducts.data.length)}}"
|
||||
"className": "{{(_isolated.scrollIdx + 3) >= (($computed.popularItems ?? []).length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 3) >= (($computed.popularItems ?? []).length)}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -111,7 +111,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"children": [{ "type": "basic", "name": "Icon", "props": { "name": "chevron-right", "className": "w-4 h-4" } }]
|
||||
"children": [{ "type": "basic", "name": "Icon", "props": { "name": "chevron-right", "className": "text-base" } }]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -125,7 +125,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"iteration": { "source": "{{popularProducts.data ?? []}}", "item_var": "product", "index_var": "idx" },
|
||||
"iteration": { "source": "{{$computed.popularItems ?? []}}", "item_var": "product", "index_var": "idx" },
|
||||
"props": {
|
||||
"id": "popular-product-{{idx}}"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"meta": {
|
||||
"is_partial": true,
|
||||
"description": "상품 그리드 + 페이지네이션 (products 데이터소스 사용, route.path 기반 navigate)"
|
||||
"description": "상품 그리드 + 페이지네이션. 데이터소스 이름에 직접 결합하지 않고 부모 레이아웃이 정의한 $computed.gridProducts / $computed.gridPagination 을 읽는다 (여러 레이아웃이 공유하는 partial)"
|
||||
},
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
@@ -18,7 +18,7 @@
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"iteration": {
|
||||
"source": "{{products?.data?.data ?? []}}",
|
||||
"source": "{{$computed.gridProducts ?? []}}",
|
||||
"item_var": "product"
|
||||
},
|
||||
"children": [
|
||||
@@ -39,7 +39,7 @@
|
||||
"comment": "빈 상태",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{(products?.data?.data ?? []).length === 0}}",
|
||||
"if": "{{($computed.gridProducts ?? []).length === 0}}",
|
||||
"props": {
|
||||
"className": "flex flex-col items-center justify-center py-20 px-4 my-16"
|
||||
},
|
||||
@@ -81,10 +81,10 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "페이지네이션",
|
||||
"comment": "페이지네이션 — 총 건수가 부정확하면 last_page 가 null 이므로 has_more_pages 로 판정한다",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{(products?.data?.pagination?.last_page ?? 1) > 1}}",
|
||||
"if": "{{(($computed.gridPagination?.last_page ?? 0) > 1) || ($computed.gridPagination?.has_more_pages ?? false) || (($computed.gridPagination?.current_page ?? 1) > 1)}}",
|
||||
"props": {
|
||||
"className": "flex justify-center items-center gap-2 mb-12"
|
||||
},
|
||||
@@ -94,8 +94,8 @@
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"className": "p-2 border border-gray-300 dark:border-gray-600 rounded-lg {{(products?.data?.pagination?.current_page ?? 1) <= 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer'}}",
|
||||
"disabled": "{{(products?.data?.pagination?.current_page ?? 1) <= 1}}"
|
||||
"className": "p-2 border border-gray-300 dark:border-gray-600 rounded-lg {{($computed.gridPagination?.current_page ?? 1) <= 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer'}}",
|
||||
"disabled": "{{($computed.gridPagination?.current_page ?? 1) <= 1}}"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -103,7 +103,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "chevron-left",
|
||||
"className": "w-4 h-4"
|
||||
"className": "text-base"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -115,7 +115,7 @@
|
||||
"path": "{{route.path}}",
|
||||
"mergeQuery": true,
|
||||
"query": {
|
||||
"page": "{{(products?.data?.pagination?.current_page ?? 1) - 1}}"
|
||||
"page": "{{($computed.gridPagination?.current_page ?? 1) - 1}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,15 +128,15 @@
|
||||
"props": {
|
||||
"className": "px-3 py-1 text-sm text-gray-700 dark:text-gray-300"
|
||||
},
|
||||
"text": "{{products?.data?.pagination?.current_page ?? 1}} / {{products?.data?.pagination?.last_page ?? 1}}"
|
||||
"text": "{{$computed.gridPagination?.last_page ? (($computed.gridPagination?.current_page ?? 1) + ' / ' + $computed.gridPagination.last_page) : ($computed.gridPagination?.current_page ?? 1)}}"
|
||||
},
|
||||
{
|
||||
"comment": "다음 페이지 버튼",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"className": "p-2 border border-gray-300 dark:border-gray-600 rounded-lg {{(products?.data?.pagination?.current_page ?? 1) >= (products?.data?.pagination?.last_page ?? 1) ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer'}}",
|
||||
"disabled": "{{(products?.data?.pagination?.current_page ?? 1) >= (products?.data?.pagination?.last_page ?? 1)}}"
|
||||
"className": "p-2 border border-gray-300 dark:border-gray-600 rounded-lg {{($computed.gridPagination?.has_more_pages ?? false) ? 'hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer' : 'opacity-50 cursor-not-allowed'}}",
|
||||
"disabled": "{{!($computed.gridPagination?.has_more_pages ?? false)}}"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -144,7 +144,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "chevron-right",
|
||||
"className": "w-4 h-4"
|
||||
"className": "text-base"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -156,7 +156,7 @@
|
||||
"path": "{{route.path}}",
|
||||
"mergeQuery": true,
|
||||
"query": {
|
||||
"page": "{{(products?.data?.pagination?.current_page ?? 1) + 1}}"
|
||||
"page": "{{($computed.gridPagination?.current_page ?? 1) + 1}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,4 +165,4 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"meta": {
|
||||
"is_partial": true,
|
||||
"description": "최근 본 상품 섹션"
|
||||
"description": "최근 본 상품 섹션. 데이터소스 이름에 직접 결합하지 않고 부모 레이아웃이 정의한 $computed.recentItems 를 읽는다 (다른 화면이 재사용해도 섹션이 조용히 비지 않는다)"
|
||||
},
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{recentProducts.data && recentProducts.data.length > 0}}",
|
||||
"if": "{{($computed.recentItems ?? []).length > 0}}",
|
||||
"isolatedState": {
|
||||
"scrollIdx": 0
|
||||
},
|
||||
@@ -22,7 +22,7 @@
|
||||
"name": "H2",
|
||||
"props": { "className": "text-lg font-bold text-gray-900 dark:text-white flex items-center gap-2" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "clock", "className": "w-5 h-5" } },
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "clock", "className": "text-xl" } },
|
||||
{ "type": "basic", "name": "Span", "text": "$t:shop.recent_products" }
|
||||
]
|
||||
},
|
||||
@@ -63,26 +63,26 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"children": [{ "type": "basic", "name": "Icon", "props": { "name": "chevron-left", "className": "w-4 h-4" } }]
|
||||
"children": [{ "type": "basic", "name": "Icon", "props": { "name": "chevron-left", "className": "text-base" } }]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"className": "{{(_isolated.scrollIdx + 4) >= (recentProducts.data.length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 4) >= (recentProducts.data.length)}}"
|
||||
"className": "{{(_isolated.scrollIdx + 4) >= (($computed.recentItems ?? []).length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 4) >= (($computed.recentItems ?? []).length)}}"
|
||||
},
|
||||
"responsive": {
|
||||
"0-639": {
|
||||
"props": {
|
||||
"className": "{{(_isolated.scrollIdx + 2) >= (recentProducts.data.length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 2) >= (recentProducts.data.length)}}"
|
||||
"className": "{{(_isolated.scrollIdx + 2) >= (($computed.recentItems ?? []).length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 2) >= (($computed.recentItems ?? []).length)}}"
|
||||
}
|
||||
},
|
||||
"640-1023": {
|
||||
"props": {
|
||||
"className": "{{(_isolated.scrollIdx + 3) >= (recentProducts.data.length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 3) >= (recentProducts.data.length)}}"
|
||||
"className": "{{(_isolated.scrollIdx + 3) >= (($computed.recentItems ?? []).length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 3) >= (($computed.recentItems ?? []).length)}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -111,7 +111,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"children": [{ "type": "basic", "name": "Icon", "props": { "name": "chevron-right", "className": "w-4 h-4" } }]
|
||||
"children": [{ "type": "basic", "name": "Icon", "props": { "name": "chevron-right", "className": "text-base" } }]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -125,7 +125,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"iteration": { "source": "{{recentProducts.data ?? []}}", "item_var": "product", "index_var": "idx" },
|
||||
"iteration": { "source": "{{$computed.recentItems ?? []}}", "item_var": "product", "index_var": "idx" },
|
||||
"props": {
|
||||
"id": "recent-product-{{idx}}"
|
||||
},
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": { "className": "text-sm font-semibold text-gray-700 dark:text-gray-300" },
|
||||
"text": "$t:shop.total_count|count={{products?.data?.pagination?.total ?? 0}}"
|
||||
"text": "$t:shop.total_count|count={{storefront?.data?.products?.pagination?.total ?? 0}}"
|
||||
},
|
||||
{
|
||||
"comment": "오른쪽: 초기화 버튼 + 정렬",
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"searchActiveTab": "all",
|
||||
"searchBoardFilter": "",
|
||||
"searchSortBy": "relevance",
|
||||
"searchPage": 1,
|
||||
"searchPage": 1, "searchCursor": "",
|
||||
"_local": {
|
||||
"q": "{{query?.q || ''}}"
|
||||
}
|
||||
@@ -49,14 +49,15 @@
|
||||
"searchActiveTab": "all",
|
||||
"searchBoardFilter": "",
|
||||
"searchSortBy": "relevance",
|
||||
"searchPage": 1
|
||||
"searchPage": 1, "searchCursor": ""
|
||||
},
|
||||
"data_sources": [
|
||||
{
|
||||
"id": "searchResults",
|
||||
"label_key": "$t:editor.data_source.searchResults",
|
||||
"type": "api",
|
||||
"endpoint": "/api/search?q={{query?.q}}&type={{_global.searchActiveTab ?? 'all'}}&board_slug={{_global.searchBoardFilter}}&sort={{_global.searchSortBy ?? 'relevance'}}&page={{_global.searchPage ?? 1}}&per_page={{(_global.searchActiveTab === 'products') ? 12 : 10}}",
|
||||
"comment": "cursor 는 커서로 넘길 수 있는 정렬(최신순 등)에서 이전/다음 이동 시 채워진다. 비어 있으면 서버가 첫 페이지로 읽고 다음 커서를 발급한다 — 관련도순은 계산값 정렬이라 서버가 커서를 발급하지 않고 page 로만 움직인다",
|
||||
"endpoint": "/api/search?q={{query?.q}}&type={{_global.searchActiveTab ?? 'all'}}&board_slug={{_global.searchBoardFilter}}&sort={{_global.searchSortBy ?? 'relevance'}}&page={{_global.searchPage ?? 1}}&cursor={{_global.searchCursor ?? ''}}&per_page={{(_global.searchActiveTab === 'products') ? 12 : 10}}",
|
||||
"method": "GET",
|
||||
"auto_fetch": true,
|
||||
"loading_strategy": "progressive",
|
||||
@@ -147,13 +148,15 @@
|
||||
"partial": "partials/search/_search_results.json"
|
||||
},
|
||||
{
|
||||
"comment": "페이지네이션",
|
||||
"comment": "페이지네이션 — 총 건수가 상한에 걸리면 last_page 가 null 로 온다. 그때는 페이지 번호 목록과 마지막 페이지 점프가 사라지고 이전/다음만 남으며, 다음 이동은 has_more_pages 로 끝까지 열려 있다",
|
||||
"type": "composite",
|
||||
"name": "Pagination",
|
||||
"if": "{{query?.q && (_global.searchActiveTab ?? 'all') !== 'all' && searchResults?.data?.last_page > 1}}",
|
||||
"if": "{{query?.q && (_global.searchActiveTab ?? 'all') !== 'all' && ((searchResults?.data?.last_page ?? 0) > 1 || searchResults?.data?.has_more_pages === true || (searchResults?.data?.current_page ?? 1) > 1)}}",
|
||||
"props": {
|
||||
"currentPage": "{{searchResults.data.current_page}}",
|
||||
"totalPages": "{{searchResults.data.last_page}}",
|
||||
"comment": "커서로 넘길 때 서버는 페이지 번호를 세지 않으므로 표시용 번호는 화면이 센다",
|
||||
"currentPage": "{{_global.searchPage ?? searchResults?.data?.current_page ?? 1}}",
|
||||
"totalPages": "{{searchResults?.data?.last_page ?? null}}",
|
||||
"hasMorePages": "{{searchResults?.data?.has_more_pages ?? false}}",
|
||||
"className": "mt-6 flex justify-center"
|
||||
},
|
||||
"actions": [
|
||||
@@ -161,6 +164,14 @@
|
||||
"event": "onPageChange",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{
|
||||
"comment": "커서를 먼저 정한다 — 이 시점의 _global.searchPage 는 아직 이동 전 값이라 앞뒤 방향을 알 수 있다. 서버가 커서를 발급하지 않는 정렬(관련도순)에서는 빈 값이 되어 page 로만 움직인다",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"searchCursor": "{{$args[0] > (_global.searchPage ?? 1) ? (searchResults?.data?.next_cursor ?? '') : ($args[0] < (_global.searchPage ?? 1) ? (searchResults?.data?.prev_cursor ?? '') : '')}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
|
||||
@@ -3,20 +3,24 @@
|
||||
"layout_name": "shop/category",
|
||||
"permissions": [],
|
||||
"extends": "_user_base",
|
||||
"computed": {
|
||||
"gridProducts": "{{storefront?.data?.products?.data ?? []}}",
|
||||
"gridPagination": "{{storefront?.data?.products?.pagination ?? null}}"
|
||||
},
|
||||
"meta": {
|
||||
"title": "{{category?.data?.name_localized ?? $t:shop.categories}}",
|
||||
"description": "{{category?.data?.description_localized ?? ''}}",
|
||||
"title": "{{storefront?.data?.category?.name_localized ?? $t:shop.categories}}",
|
||||
"description": "{{storefront?.data?.category?.description_localized ?? ''}}",
|
||||
"seo": {
|
||||
"enabled": true,
|
||||
"extensions": [
|
||||
{ "type": "module", "id": "sirsoft-ecommerce" }
|
||||
],
|
||||
"data_sources": ["category", "products"],
|
||||
"data_sources": ["storefront"],
|
||||
"page_type": "category",
|
||||
"toggle_setting": "$module_settings:sirsoft-ecommerce:seo.seo_category",
|
||||
"vars": {
|
||||
"category_name": "{{category.data.name ?? ''}}",
|
||||
"category_description": "{{category.data.description ?? ''}}",
|
||||
"category_name": "{{storefront?.data?.category?.name ?? ''}}",
|
||||
"category_description": "{{storefront?.data?.category?.description ?? ''}}",
|
||||
"site_name": "$core_settings:general.site_name",
|
||||
"shopBase": "$module_settings:sirsoft-ecommerce:basic_info.route_path"
|
||||
},
|
||||
@@ -24,61 +28,48 @@
|
||||
"changefreq": "weekly",
|
||||
"og": {
|
||||
"type": "website",
|
||||
"title": "{{_seo.category.title ?? category.data.name ?? ''}}",
|
||||
"description": "{{_seo.category.description ?? category.data.description ?? ''}}"
|
||||
"title": "{{_seo.category.title ?? storefront?.data?.category?.name ?? ''}}",
|
||||
"description": "{{_seo.category.description ?? storefront?.data?.category?.description ?? ''}}"
|
||||
},
|
||||
"structured_data": {
|
||||
"@type": "ItemList",
|
||||
"name": "{{_seo.category.title ?? category.data.name ?? ''}}"
|
||||
"name": "{{_seo.category.title ?? storefront?.data?.category?.name ?? ''}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"data_sources": [
|
||||
{
|
||||
"id": "category",
|
||||
"label_key": "$t:editor.data_source.category",
|
||||
"id": "storefront",
|
||||
"label_key": "$t:editor.data_source.storefront",
|
||||
"comment": "보고 있는 분류와 그 분류의 상품 목록을 한 번에 받는다. 종전에는 분류·분류트리·상품목록으로 요청이 3번 나갔고, 그중 분류트리는 이 화면의 어떤 표현식도 읽지 않는 낭비였다. 각 묶음의 응답 형태는 개별 엔드포인트와 동일하다.",
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/categories/{{route.slug}}",
|
||||
"method": "GET",
|
||||
"auto_fetch": true,
|
||||
"loading_strategy": "progressive",
|
||||
"fallback": {
|
||||
"data": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "categories",
|
||||
"label_key": "$t:editor.data_source.categories",
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/categories",
|
||||
"method": "GET",
|
||||
"auto_fetch": true,
|
||||
"loading_strategy": "progressive",
|
||||
"fallback": {
|
||||
"data": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "products",
|
||||
"label_key": "$t:editor.data_source.products",
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/products",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/storefront",
|
||||
"method": "GET",
|
||||
"auth_mode": "optional",
|
||||
"params": {
|
||||
"page": "{{query.page ?? 1}}",
|
||||
"per_page": 12,
|
||||
"category_slug": "{{route.slug}}",
|
||||
"sort": "{{query.sort ?? 'latest'}}",
|
||||
"search": "{{query.keyword ?? ''}}"
|
||||
"search": "{{query.keyword ?? ''}}",
|
||||
"popular_limit": 0,
|
||||
"new_limit": 0
|
||||
},
|
||||
"auto_fetch": true,
|
||||
"loading_strategy": "progressive",
|
||||
"fallback": {
|
||||
"data": [],
|
||||
"meta": {
|
||||
"current_page": 1,
|
||||
"last_page": 1,
|
||||
"per_page": 12,
|
||||
"total": 0
|
||||
"data": {
|
||||
"category": null,
|
||||
"products": {
|
||||
"data": [],
|
||||
"pagination": {
|
||||
"current_page": 1,
|
||||
"last_page": null,
|
||||
"per_page": 12,
|
||||
"total": 0,
|
||||
"has_more_pages": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,7 +109,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": { "name": "arrow-left", "className": "w-5 h-5" }
|
||||
"props": { "name": "arrow-left", "className": "text-xl" }
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
@@ -126,7 +117,7 @@
|
||||
"type": "click",
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "{{(category?.data?.breadcrumb ?? []).length > 1 ? _global.shopBase + '/category/' + (category?.data?.breadcrumb ?? []).slice(-2, -1)[0]?.slug : _global.shopBase + '/products'}}"
|
||||
"path": "{{(storefront?.data?.category?.breadcrumb ?? []).length > 1 ? _global.shopBase + '/category/' + (storefront?.data?.category?.breadcrumb ?? []).slice(-2, -1)[0]?.slug : _global.shopBase + '/products'}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -134,7 +125,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "{{category?.data?.name_localized ?? $t:shop.categories}}"
|
||||
"text": "{{storefront?.data?.category?.name_localized ?? $t:shop.categories}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -149,11 +140,11 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"if": "{{!!category?.data?.description_localized}}",
|
||||
"if": "{{!!storefront?.data?.category?.description_localized}}",
|
||||
"props": {
|
||||
"className": "text-gray-600 dark:text-gray-400"
|
||||
},
|
||||
"text": "{{category?.data?.description_localized ?? ''}}"
|
||||
"text": "{{storefront?.data?.category?.description_localized ?? ''}}"
|
||||
},
|
||||
{
|
||||
"comment": "브레드크럼 (전체 > 경로 > 현재)",
|
||||
@@ -176,7 +167,7 @@
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"iteration": {
|
||||
"source": "{{category?.data?.breadcrumb ?? []}}",
|
||||
"source": "{{storefront?.data?.category?.breadcrumb ?? []}}",
|
||||
"item_var": "crumb"
|
||||
},
|
||||
"children": [
|
||||
@@ -192,7 +183,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "chevron-right",
|
||||
"className": "w-3 h-3"
|
||||
"className": "text-xs"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -228,7 +219,7 @@
|
||||
"comment": "카테고리 배너 이미지",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{(category?.data?.images ?? []).length > 0}}",
|
||||
"if": "{{(storefront?.data?.category?.images ?? []).length > 0}}",
|
||||
"props": {
|
||||
"className": "mb-6 rounded-lg overflow-hidden"
|
||||
},
|
||||
@@ -237,8 +228,8 @@
|
||||
"type": "basic",
|
||||
"name": "Img",
|
||||
"props": {
|
||||
"src": "{{category?.data?.images?.[0]?.download_url ?? ''}}",
|
||||
"alt": "{{category?.data?.images?.[0]?.alt_text ?? category?.data?.name_localized ?? ''}}",
|
||||
"src": "{{storefront?.data?.category?.images?.[0]?.download_url ?? ''}}",
|
||||
"alt": "{{storefront?.data?.category?.images?.[0]?.alt_text ?? storefront?.data?.category?.name_localized ?? ''}}",
|
||||
"className": "w-full h-auto object-cover max-h-64"
|
||||
}
|
||||
}
|
||||
@@ -307,13 +298,13 @@
|
||||
"comment": "하위 카테고리 칩 (자식 카테고리가 있을 때만 표시)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{(category?.data?.children ?? []).length > 0}}",
|
||||
"if": "{{(storefront?.data?.category?.children ?? []).length > 0}}",
|
||||
"props": { "className": "flex flex-wrap gap-2 mb-4" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"iteration": { "source": "{{category?.data?.children ?? []}}", "item_var": "sub" },
|
||||
"iteration": { "source": "{{storefront?.data?.category?.children ?? []}}", "item_var": "sub" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -344,7 +335,7 @@
|
||||
"props": {
|
||||
"className": "text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
},
|
||||
"text": "$t:shop.total_count|count={{products?.data?.pagination?.total ?? 0}}"
|
||||
"text": "$t:shop.total_count|count={{storefront?.data?.products?.pagination?.total ?? 0}}"
|
||||
},
|
||||
{
|
||||
"type": "composite",
|
||||
|
||||
@@ -3,13 +3,25 @@
|
||||
"layout_name": "shop/index",
|
||||
"permissions": [],
|
||||
"extends": "_user_base",
|
||||
"computed": {
|
||||
"gridProducts": "{{storefront?.data?.products?.data ?? []}}",
|
||||
"gridPagination": "{{storefront?.data?.products?.pagination ?? null}}",
|
||||
"popularItems": "{{storefront?.data?.popular_products ?? []}}",
|
||||
"recentItems": "{{storefront?.data?.recent_products ?? []}}",
|
||||
"newItems": "{{storefront?.data?.new_products ?? []}}"
|
||||
},
|
||||
"meta": {
|
||||
"seo": {
|
||||
"enabled": true,
|
||||
"extensions": [
|
||||
{ "type": "module", "id": "sirsoft-ecommerce" }
|
||||
{
|
||||
"type": "module",
|
||||
"id": "sirsoft-ecommerce"
|
||||
}
|
||||
],
|
||||
"data_sources": [
|
||||
"storefront"
|
||||
],
|
||||
"data_sources": ["products", "categories", "popularProducts", "newProducts"],
|
||||
"page_type": "shop_index",
|
||||
"toggle_setting": "$module_settings:sirsoft-ecommerce:seo.seo_shop_index",
|
||||
"vars": {
|
||||
@@ -62,22 +74,11 @@
|
||||
},
|
||||
"data_sources": [
|
||||
{
|
||||
"id": "categories",
|
||||
"label_key": "$t:editor.data_source.categories",
|
||||
"id": "storefront",
|
||||
"label_key": "$t:editor.data_source.storefront",
|
||||
"comment": "분류·상품목록·최근본·인기·신상품을 한 번에 받는다. 종전에는 같은 화면에 요청이 5번 나갔고 그 5번이 전부 같은 부팅을 되풀이했다. 각 묶음의 응답 형태는 개별 엔드포인트와 동일하다.",
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/categories",
|
||||
"method": "GET",
|
||||
"auto_fetch": true,
|
||||
"loading_strategy": "progressive",
|
||||
"fallback": {
|
||||
"data": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "products",
|
||||
"label_key": "$t:editor.data_source.products",
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/products",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/storefront",
|
||||
"method": "GET",
|
||||
"auth_mode": "optional",
|
||||
"params": {
|
||||
@@ -85,61 +86,30 @@
|
||||
"per_page": 12,
|
||||
"category_id": "{{query.category ?? ''}}",
|
||||
"sort": "{{query.sort ?? 'latest'}}",
|
||||
"search": "{{query.keyword ?? ''}}"
|
||||
"search": "{{query.keyword ?? ''}}",
|
||||
"popular_limit": 8,
|
||||
"new_limit": 8,
|
||||
"recent_ids": "{{(_global.recentProductIds ?? []).join(',')}}"
|
||||
},
|
||||
"auto_fetch": true,
|
||||
"loading_strategy": "progressive",
|
||||
"fallback": {
|
||||
"data": [],
|
||||
"meta": {
|
||||
"current_page": 1,
|
||||
"last_page": 1,
|
||||
"per_page": 12,
|
||||
"total": 0
|
||||
"data": {
|
||||
"categories": [],
|
||||
"products": {
|
||||
"data": [],
|
||||
"pagination": {
|
||||
"current_page": 1,
|
||||
"last_page": 1,
|
||||
"per_page": 12,
|
||||
"total": 0
|
||||
}
|
||||
},
|
||||
"recent_products": [],
|
||||
"popular_products": [],
|
||||
"new_products": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "recentProducts",
|
||||
"label_key": "$t:editor.data_source.recentProducts",
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/products/recent",
|
||||
"method": "GET",
|
||||
"auth_mode": "optional",
|
||||
"params": {
|
||||
"ids": "{{(_global.recentProductIds ?? []).join(',')}}"
|
||||
},
|
||||
"auto_fetch": true,
|
||||
"loading_strategy": "progressive",
|
||||
"fallback": {
|
||||
"data": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "popularProducts",
|
||||
"label_key": "$t:editor.data_source.popularProducts",
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/products/popular?limit=8",
|
||||
"method": "GET",
|
||||
"auth_mode": "optional",
|
||||
"auto_fetch": true,
|
||||
"loading_strategy": "progressive",
|
||||
"fallback": {
|
||||
"data": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "newProducts",
|
||||
"label_key": "$t:editor.data_source.newProducts",
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/products/new?limit=8",
|
||||
"method": "GET",
|
||||
"auth_mode": "optional",
|
||||
"auto_fetch": true,
|
||||
"loading_strategy": "progressive",
|
||||
"fallback": {
|
||||
"data": []
|
||||
}
|
||||
}
|
||||
],
|
||||
"slots": {
|
||||
@@ -221,7 +191,9 @@
|
||||
"comment": "최상위 카테고리 버튼 (전체 + 1단계 카테고리)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "flex flex-wrap gap-2 mb-4" },
|
||||
"props": {
|
||||
"className": "flex flex-wrap gap-2 mb-4"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -231,13 +203,24 @@
|
||||
},
|
||||
"text": "$t:shop.category.all",
|
||||
"actions": [
|
||||
{ "comment": "audit:allow layout-list-context-navigate-merge-query 카테고리 '전체' 선택 — 카테고리 필터를 푸는 의도적 리셋이므로 병합하지 않는다", "type": "click", "handler": "navigate", "params": { "path": "{{_global.shopBase}}/products", "query": {} } }
|
||||
{
|
||||
"comment": "audit:allow layout-list-context-navigate-merge-query 카테고리 '전체' 선택 — 카테고리 필터를 푸는 의도적 리셋이므로 병합하지 않는다",
|
||||
"type": "click",
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "{{_global.shopBase}}/products",
|
||||
"query": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"iteration": { "source": "{{categories?.data ?? []}}", "item_var": "cat" },
|
||||
"iteration": {
|
||||
"source": "{{storefront?.data?.categories ?? []}}",
|
||||
"item_var": "cat"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -247,7 +230,18 @@
|
||||
},
|
||||
"text": "{{cat?.name_localized}}",
|
||||
"actions": [
|
||||
{ "comment": "audit:allow layout-list-context-navigate-merge-query 카테고리 전환 — 다른 목록으로의 이동이라 이전 카테고리의 검색·정렬 상태를 승계하지 않는다", "type": "click", "handler": "navigate", "params": { "path": "{{_global.shopBase}}/products", "query": { "category": "{{cat?.id}}", "page": "1" } } }
|
||||
{
|
||||
"comment": "audit:allow layout-list-context-navigate-merge-query 카테고리 전환 — 다른 목록으로의 이동이라 이전 카테고리의 검색·정렬 상태를 승계하지 않는다",
|
||||
"type": "click",
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "{{_global.shopBase}}/products",
|
||||
"query": {
|
||||
"category": "{{cat?.id}}",
|
||||
"page": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"permissions": [],
|
||||
"extends": "_user_base",
|
||||
"computed": {
|
||||
"popularItems": "{{popularProducts?.data ?? []}}",
|
||||
"optionChoices": "{{(product.data?.option_groups ?? []).map((g, gi) => (g.values_localized ?? []).map(v => { const combos = (product.data?.options ?? []).filter(o => o.option_values_localized?.[g.name_localized] === v && (product.data?.option_groups ?? []).slice(0, gi).every(ug => !_local.currentSelection?.[ug.name_localized] || o.option_values_localized?.[ug.name_localized] === _local.currentSelection[ug.name_localized])); const soldOut = combos.length > 0 && combos.every(o => o.is_sold_out || (o.stock_quantity ?? 0) <= 0 || !o.is_active); return { value: v, label: soldOut ? v + ' ' + $t('shop.product.sold_out_suffix') : v, disabled: soldOut }; }))}}"
|
||||
},
|
||||
"meta": {
|
||||
@@ -42,13 +43,6 @@
|
||||
"key": "recentProductIds"
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "saveToLocalStorage",
|
||||
"params": {
|
||||
"key": "recentProductIds",
|
||||
"value": "{{[Number(route.id), ...(_global.recentProductIds ?? []).filter(id => id !== Number(route.id))].slice(0, 20)}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "이전 페이지에서 열린 모달 닫기",
|
||||
"handler": "closeModal"
|
||||
@@ -119,6 +113,24 @@
|
||||
"fallback": {
|
||||
"data": null
|
||||
},
|
||||
"onSuccess": [
|
||||
{
|
||||
"comment": "최근 본 상품 기록 — 상품이 실제로 조회된 뒤에 남긴다. 라우트 파라미터는 상품 코드(문자열)라 서버가 요구하는 숫자 ID 가 아니므로, 응답에서 받은 id 를 쓴다",
|
||||
"handler": "saveToLocalStorage",
|
||||
"params": {
|
||||
"key": "recentProductIds",
|
||||
"value": "{{(response?.data?.data?.id ?? response?.data?.id) ? [Number(response?.data?.data?.id ?? response?.data?.id), ...(_global.recentProductIds ?? []).filter(id => Number(id) !== Number(response?.data?.data?.id ?? response?.data?.id))].slice(0, 20) : (_global.recentProductIds ?? [])}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "같은 화면에서 이어지는 참조를 위해 전역 상태도 맞춰 둔다",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"recentProductIds": "{{(response?.data?.data?.id ?? response?.data?.id) ? [Number(response?.data?.data?.id ?? response?.data?.id), ...(_global.recentProductIds ?? []).filter(id => Number(id) !== Number(response?.data?.data?.id ?? response?.data?.id))].slice(0, 20) : (_global.recentProductIds ?? [])}}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"errorHandling": {
|
||||
"403": {
|
||||
"handler": "showErrorPage",
|
||||
|
||||
@@ -472,7 +472,8 @@
|
||||
"name": "Pagination",
|
||||
"props": {
|
||||
"currentPage": "{{userPosts.data?.current_page ?? 1}}",
|
||||
"totalPages": "{{userPosts.data?.last_page ?? 1}}"
|
||||
"totalPages": "{{userPosts.data?.last_page ?? null}}",
|
||||
"hasMorePages": "{{userPosts.data?.has_more_pages ?? false}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
|
||||
@@ -10,10 +10,40 @@ const t = (key: string, params?: Record<string, string | number>) =>
|
||||
|
||||
export interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
/**
|
||||
* 마지막 페이지 번호
|
||||
*
|
||||
* 총 건수가 상한을 넘겨 정확히 세지 않은 목록은 마지막 페이지를 계산할 수 없다.
|
||||
* 그 경우 서버가 `null` 을 보내며, 페이지 번호 목록과 마지막 페이지 점프가 사라지고
|
||||
* 이전/다음 이동만 남는다. 0 이나 1 로 채우면 화면이 "1페이지뿐" 이라고 잘못 말하게 된다.
|
||||
*/
|
||||
totalPages: number | null;
|
||||
onPageChange: (page: number) => void;
|
||||
maxVisiblePages?: number;
|
||||
/**
|
||||
* 첫/마지막 페이지 버튼 표시 여부 (두 버튼의 기본값)
|
||||
*
|
||||
* 마지막 페이지만 따로 감춰야 하는 경우가 있으므로 `showLast` 로 덮어쓸 수 있다.
|
||||
*/
|
||||
showFirstLast?: boolean;
|
||||
/**
|
||||
* 첫 페이지 버튼 표시 여부 (미지정 시 showFirstLast 를 따른다)
|
||||
*/
|
||||
showFirst?: boolean;
|
||||
/**
|
||||
* 마지막 페이지 버튼 표시 여부 (미지정 시 showFirstLast 를 따른다)
|
||||
*
|
||||
* 총 건수가 부정확한 목록에서는 마지막 페이지를 계산할 수 없으므로,
|
||||
* `totalPages` 가 null 이면 이 값과 무관하게 감춰진다.
|
||||
*/
|
||||
showLast?: boolean;
|
||||
/**
|
||||
* 다음 페이지 존재 여부 (총 건수를 모르는 목록에서 사용)
|
||||
*
|
||||
* 총 건수를 몰라도 다음 페이지 존재 여부는 서버가 정확히 판정한다.
|
||||
* `totalPages` 가 null 일 때만 쓰인다 — 아래 `canGoNext` 주석 참조.
|
||||
*/
|
||||
hasMorePages?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
prevText?: string;
|
||||
@@ -52,6 +82,9 @@ export const Pagination: React.FC<PaginationProps> = ({
|
||||
onPageChange,
|
||||
maxVisiblePages = 5,
|
||||
showFirstLast = true,
|
||||
showFirst,
|
||||
showLast,
|
||||
hasMorePages,
|
||||
className = '',
|
||||
style,
|
||||
prevText,
|
||||
@@ -59,14 +92,35 @@ export const Pagination: React.FC<PaginationProps> = ({
|
||||
id,
|
||||
editorAttrs,
|
||||
}) => {
|
||||
// 총 건수를 정확히 세지 못한 목록은 마지막 페이지를 계산할 수 없다.
|
||||
// 이때는 페이지 번호 목록과 마지막 페이지 점프를 감추고 이전/다음 이동만 남긴다 —
|
||||
// 끝까지 넘겨 보는 것은 그대로 가능하다.
|
||||
const isBounded = totalPages !== null && totalPages !== undefined;
|
||||
const resolvedTotalPages = isBounded ? (totalPages as number) : 0;
|
||||
|
||||
// 마지막 페이지를 알면 페이지 산술이 언제나 옳으므로 그쪽을 먼저 본다.
|
||||
// `hasMorePages` 를 우선하면, 응답에 `has_more_pages` 가 없어 레이아웃이 `?? false` 로
|
||||
// 채운 값이 정상 페이저의 "다음" 을 막아버린다 (실측: 1/2 페이지인데 다음 비활성).
|
||||
// 서버 판정이 필요한 자리는 마지막 페이지를 모르는 목록뿐이다.
|
||||
const canGoNext = isBounded
|
||||
? currentPage < resolvedTotalPages
|
||||
: (hasMorePages ?? false);
|
||||
|
||||
const displayFirst = (showFirst ?? showFirstLast) && isBounded;
|
||||
const displayLast = (showLast ?? showFirstLast) && isBounded;
|
||||
|
||||
// 페이지 번호 생성 알고리즘
|
||||
const pageNumbers = useMemo(() => {
|
||||
const pages: (number | string)[] = [];
|
||||
|
||||
if (totalPages <= maxVisiblePages + 2) {
|
||||
if (!isBounded) {
|
||||
// 마지막 페이지를 모르면 번호 목록을 만들 수 없다 (현재 페이지만 별도로 표시)
|
||||
return pages;
|
||||
}
|
||||
|
||||
if (resolvedTotalPages <= maxVisiblePages + 2) {
|
||||
// 페이지가 적으면 모두 표시
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
for (let i = 1; i <= resolvedTotalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
@@ -74,13 +128,13 @@ export const Pagination: React.FC<PaginationProps> = ({
|
||||
const halfVisible = Math.floor(maxVisiblePages / 2);
|
||||
|
||||
let startPage = Math.max(2, currentPage - halfVisible);
|
||||
let endPage = Math.min(totalPages - 1, currentPage + halfVisible);
|
||||
let endPage = Math.min(resolvedTotalPages - 1, currentPage + halfVisible);
|
||||
|
||||
// 시작 또는 끝 근처일 때 조정
|
||||
if (currentPage <= halfVisible + 1) {
|
||||
endPage = maxVisiblePages;
|
||||
} else if (currentPage >= totalPages - halfVisible) {
|
||||
startPage = totalPages - maxVisiblePages + 1;
|
||||
} else if (currentPage >= resolvedTotalPages - halfVisible) {
|
||||
startPage = resolvedTotalPages - maxVisiblePages + 1;
|
||||
}
|
||||
|
||||
// 첫 페이지
|
||||
@@ -97,21 +151,22 @@ export const Pagination: React.FC<PaginationProps> = ({
|
||||
}
|
||||
|
||||
// 끝 생략 부호
|
||||
if (endPage < totalPages - 1) {
|
||||
if (endPage < resolvedTotalPages - 1) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// 마지막 페이지
|
||||
if (totalPages > 1) {
|
||||
pages.push(totalPages);
|
||||
if (resolvedTotalPages > 1) {
|
||||
pages.push(resolvedTotalPages);
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
}, [currentPage, totalPages, maxVisiblePages]);
|
||||
}, [currentPage, resolvedTotalPages, isBounded, maxVisiblePages]);
|
||||
|
||||
const handlePageClick = (page: number) => {
|
||||
if (page < 1 || page > totalPages || page === currentPage) return;
|
||||
if (page < 1 || page === currentPage) return;
|
||||
if (isBounded && page > resolvedTotalPages) return;
|
||||
onPageChange(page);
|
||||
};
|
||||
|
||||
@@ -122,7 +177,7 @@ export const Pagination: React.FC<PaginationProps> = ({
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentPage < totalPages) {
|
||||
if (canGoNext) {
|
||||
onPageChange(currentPage + 1);
|
||||
}
|
||||
};
|
||||
@@ -136,7 +191,7 @@ export const Pagination: React.FC<PaginationProps> = ({
|
||||
id={id} {...editorAttrs}
|
||||
>
|
||||
{/* First Button */}
|
||||
{showFirstLast && (
|
||||
{displayFirst && (
|
||||
<Button
|
||||
onClick={() => handlePageClick(1)}
|
||||
disabled={currentPage === 1}
|
||||
@@ -157,8 +212,13 @@ export const Pagination: React.FC<PaginationProps> = ({
|
||||
{prevText || <>‹</>}
|
||||
</Button>
|
||||
|
||||
{/* Page Numbers */}
|
||||
{/* Page Numbers — 마지막 페이지를 모르면 현재 페이지만 표시 */}
|
||||
<Div className="flex items-center gap-1">
|
||||
{!isBounded && (
|
||||
<Span className="px-3 py-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
{currentPage}
|
||||
</Span>
|
||||
)}
|
||||
{pageNumbers.map((page, index) => {
|
||||
if (page === '...') {
|
||||
return (
|
||||
@@ -195,7 +255,7 @@ export const Pagination: React.FC<PaginationProps> = ({
|
||||
{/* Next Button */}
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={currentPage === totalPages}
|
||||
disabled={!canGoNext}
|
||||
className="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800"
|
||||
aria-label={t('common.next_page')}
|
||||
>
|
||||
@@ -203,10 +263,10 @@ export const Pagination: React.FC<PaginationProps> = ({
|
||||
</Button>
|
||||
|
||||
{/* Last Button */}
|
||||
{showFirstLast && (
|
||||
{displayLast && (
|
||||
<Button
|
||||
onClick={() => handlePageClick(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => handlePageClick(resolvedTotalPages)}
|
||||
disabled={currentPage === resolvedTotalPages}
|
||||
className="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800"
|
||||
aria-label={t('common.last_page')}
|
||||
>
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Pagination } from '../Pagination';
|
||||
|
||||
/**
|
||||
* hasMorePages 와 last_page 의 우선순위 회귀 (#519)
|
||||
*
|
||||
* 총 건수가 정확해 `last_page` 를 아는 목록은 페이지 산술이 언제나 옳다. 그때 응답에
|
||||
* `has_more_pages` 가 없어 레이아웃이 `?? false` 로 채워 넣더라도 그 false 가 "다음" 을
|
||||
* 막아서는 안 된다. `has_more_pages` 가 필요한 자리는 `last_page` 를 모르는 목록뿐이다.
|
||||
*
|
||||
* 실측 회귀 (2026-08-03): `/user/orders` 응답의 pagination 에 `has_more_pages` 가 없어
|
||||
* 1/2 페이지인데 다음 버튼이 비활성이었다. 마이페이지 주문·문의·내글 3 화면이 같은 증상.
|
||||
*/
|
||||
describe('Pagination — hasMorePages 와 last_page 의 우선순위', () => {
|
||||
const next = () => screen.getByLabelText('다음 페이지');
|
||||
|
||||
it('last_page 를 알면 hasMorePages=false 여도 다음이 열려 있다', () => {
|
||||
render(
|
||||
<Pagination currentPage={1} totalPages={2} hasMorePages={false} onPageChange={vi.fn()} />
|
||||
);
|
||||
|
||||
expect(next()).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('last_page 를 알고 마지막 페이지면 다음이 막힌다', () => {
|
||||
render(
|
||||
<Pagination currentPage={2} totalPages={2} hasMorePages={false} onPageChange={vi.fn()} />
|
||||
);
|
||||
|
||||
expect(next()).toBeDisabled();
|
||||
});
|
||||
|
||||
it('last_page 가 null 이면 hasMorePages 가 다음 이동을 정한다', () => {
|
||||
const { rerender } = render(
|
||||
<Pagination currentPage={5} totalPages={null} hasMorePages={true} onPageChange={vi.fn()} />
|
||||
);
|
||||
expect(next()).not.toBeDisabled();
|
||||
|
||||
rerender(
|
||||
<Pagination currentPage={5} totalPages={null} hasMorePages={false} onPageChange={vi.fn()} />
|
||||
);
|
||||
expect(next()).toBeDisabled();
|
||||
});
|
||||
|
||||
it('last_page 가 null 이고 hasMorePages 도 없으면 다음이 막힌다', () => {
|
||||
render(<Pagination currentPage={1} totalPages={null} onPageChange={vi.fn()} />);
|
||||
|
||||
expect(next()).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,7 @@
|
||||
"url": "https://sirsoft.com"
|
||||
},
|
||||
"release_date": "2026-01-07",
|
||||
"g7_version": ">=7.0.0",
|
||||
"g7_version": ">=7.0.6",
|
||||
"dependencies": {
|
||||
"modules": {
|
||||
"sirsoft-board": ">=1.0.0",
|
||||
|
||||
Reference in New Issue
Block a user