File name
Commit message
Commit date
File name
Commit message
Commit date
09-03
File name
Commit message
Commit date
/**
* 꾸미기 아이템 목록의 유형탭·검색·페이징 조건 — 순수 규칙(허용 값·기본값·URL 직렬화)만
* 담는다. next/react 의존이 없다(`URLSearchParams`는 서버·브라우저 양쪽에서 쓸 수 있는 표준
* Web API).
*
* `app/(protected)/(basic)/decoration-items/page.tsx`가 `searchParams`를
* `parseDecorationItemQuery`로 정규화하는 지점이자 단일 진실원천이며, 유형 탭·검색바·툴바·
* 페이지네이션은 모두 `buildDecorationItemHref`로 같은 규칙에 따라 URL을 만들어 파라미터
* 이름·기본값이 여러 파일에 흩어져 드리프트하는 것을 막는다(admin-member-query.ts와 동일한
* 설계).
*
* **유형 탭(개별/셋트)도 이 쿼리의 일부다** — 시안이 "셋트아이템도 동일한 항목으로 처리하며
* 별도 화면이 없다"고 명시해, 탭 전환은 라우트 이동이 아니라 이 화면 안에서 `itemType` 값만
* 바뀌는 것으로 구현한다.
*
* 시안(ADM_ITM_101)에는 admins의 "정렬" select에 대응하는 항목이 없다 — 목록은 항상 정렬순서
* 오름차순 고정이라 이 파일에 정렬 옵션을 두지 않는다(Repository가 고정 규칙으로 정렬한다).
*/
import {
DECORATION_ITEM_TYPE_OPTIONS,
DEFAULT_DECORATION_ITEM_TYPE,
type DecorationItemType,
} from '@/lib/domain/decoration-item';
/** 라우트 경로 — 이 파일 안에서만 하드코딩하고 나머지는 이 상수를 참조한다. */
export const DECORATION_ITEMS_PATH = '/decoration-items';
/** 검색 대상 — 시안(ADM_ITM_101 ①) "아이템명 / 아이템ID" 그대로. */
export type DecorationItemSearchField = 'name' | 'itemId';
export const DECORATION_ITEM_SEARCH_FIELD_OPTIONS: ReadonlyArray<{
value: DecorationItemSearchField;
label: string;
}> = [
{ value: 'name', label: '아이템명' },
{ value: 'itemId', label: '아이템ID' },
];
export const DECORATION_ITEM_PAGE_SIZE_OPTIONS = [10, 30, 50] as const;
export type DecorationItemPageSize =
(typeof DECORATION_ITEM_PAGE_SIZE_OPTIONS)[number];
export const DEFAULT_DECORATION_ITEM_SEARCH_FIELD: DecorationItemSearchField =
'name';
export const DEFAULT_DECORATION_ITEM_PAGE_SIZE: DecorationItemPageSize = 10;
const DEFAULT_PAGE = 1;
const MAX_KEYWORD_LENGTH = 100;
export type DecorationItemQuery = {
itemType: DecorationItemType;
searchField: DecorationItemSearchField;
keyword: string;
page: number;
pageSize: DecorationItemPageSize;
};
/** Next.js `page.tsx`의 `searchParams`가 리졸브하는 값 형태를 그대로 옮긴 구조 타입 —
* next 패키지를 import하지 않고도 같은 shape을 표현해 domain 계층의 무의존 규칙을 지킨다. */
type RawSearchParams = Record<string, string | string[] | undefined>;
function readParam(params: RawSearchParams, key: string): string | undefined {
const value = params[key];
return Array.isArray(value) ? value[0] : value;
}
function isDecorationItemType(
value: string | undefined
): value is DecorationItemType {
return (
value !== undefined &&
DECORATION_ITEM_TYPE_OPTIONS.some((option) => option.value === value)
);
}
function isDecorationItemSearchField(
value: string | undefined
): value is DecorationItemSearchField {
return (
value !== undefined &&
DECORATION_ITEM_SEARCH_FIELD_OPTIONS.some((option) => option.value === value)
);
}
function isDecorationItemPageSize(
value: number
): value is DecorationItemPageSize {
return (DECORATION_ITEM_PAGE_SIZE_OPTIONS as readonly number[]).includes(
value
);
}
/**
* URL의 searchParams를 검증된 `DecorationItemQuery`로 정규화한다. 값이 없거나 허용 목록을
* 벗어나면 기본값으로 fallback한다 — searchParams는 사용자가 임의로 조작 가능한 값이라
* 신뢰하지 않는다.
*/
export function parseDecorationItemQuery(
searchParams: RawSearchParams
): DecorationItemQuery {
const itemTypeRaw = readParam(searchParams, 'itemType');
const searchFieldRaw = readParam(searchParams, 'searchField');
const keywordRaw = readParam(searchParams, 'keyword');
const pageRaw = Number(readParam(searchParams, 'page'));
const pageSizeRaw = Number(readParam(searchParams, 'pageSize'));
return {
itemType: isDecorationItemType(itemTypeRaw)
? itemTypeRaw
: DEFAULT_DECORATION_ITEM_TYPE,
searchField: isDecorationItemSearchField(searchFieldRaw)
? searchFieldRaw
: DEFAULT_DECORATION_ITEM_SEARCH_FIELD,
keyword: (keywordRaw ?? '').trim().slice(0, MAX_KEYWORD_LENGTH),
page: Number.isInteger(pageRaw) && pageRaw > 0 ? pageRaw : DEFAULT_PAGE,
pageSize: isDecorationItemPageSize(pageSizeRaw)
? pageSizeRaw
: DEFAULT_DECORATION_ITEM_PAGE_SIZE,
};
}
/**
* `DecorationItemQuery`(+ 부분 override)를 `/decoration-items` 링크로 직렬화한다.
* `parseDecorationItemQuery`의 역연산이며, 기본값과 같은 필드는 URL에서 생략해 링크를 짧게
* 유지한다.
*/
export function buildDecorationItemHref(
query: DecorationItemQuery,
overrides: Partial<DecorationItemQuery> = {}
): string {
const merged = { ...query, ...overrides };
const params = new URLSearchParams();
if (merged.itemType !== DEFAULT_DECORATION_ITEM_TYPE) {
params.set('itemType', merged.itemType);
}
if (merged.searchField !== DEFAULT_DECORATION_ITEM_SEARCH_FIELD) {
params.set('searchField', merged.searchField);
}
if (merged.keyword) {
params.set('keyword', merged.keyword);
}
if (merged.pageSize !== DEFAULT_DECORATION_ITEM_PAGE_SIZE) {
params.set('pageSize', String(merged.pageSize));
}
if (merged.page !== DEFAULT_PAGE) {
params.set('page', String(merged.page));
}
const queryString = params.toString();
return queryString
? `${DECORATION_ITEMS_PATH}?${queryString}`
: DECORATION_ITEMS_PATH;
}