/** * 꾸미기 아이템 목록의 유형탭·검색·페이징 조건 — 순수 규칙(허용 값·기본값·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; 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 = {} ): 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; }