Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/app/api/stores/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {NextRequest, NextResponse} from 'next/server';

import {API_ENDPOINTS, type AdminApiTypes} from '@/src/shared/api';
import {normalizeApiError} from '@/src/shared/api/api-error';
import {createServerApi} from '@/src/shared/api/server-client';
import {AUTH_COOKIE_NAMES} from '@/src/shared/auth';

const DEFAULT_PAGE = 1;
const DEFAULT_PAGE_SIZE = 10;

const createApiErrorResponse = (error: unknown) => {
const apiError = normalizeApiError(error);

return NextResponse.json(
{
code: apiError.code,
message: apiError.message,
},
{
status: apiError.status ?? 500,
}
);
};

const getPositiveIntegerParam = (
searchParams: URLSearchParams,
name: string,
fallback: number
) => {
const value = Number(searchParams.get(name));

return Number.isInteger(value) && value > 0 ? value : fallback;
};

export async function GET(request: NextRequest) {
const {searchParams} = new URL(request.url);
const keyword = searchParams.get('keyword')?.trim();
const page = getPositiveIntegerParam(searchParams, 'page', DEFAULT_PAGE);
const size = getPositiveIntegerParam(searchParams, 'size', DEFAULT_PAGE_SIZE);
const accessToken = request.cookies.get(AUTH_COOKIE_NAMES.accessToken)?.value;

try {
const serverApi = await createServerApi({accessToken});
const {data} =
await serverApi.get<AdminApiTypes.PageResponseGetStoreListResponse>(
API_ENDPOINTS.stores.root,
{
maxRedirects: 0,
params: {
...(keyword ? {keyword} : {}),
page,
size,
},
}
);

return NextResponse.json(data);
} catch (error) {
return createApiErrorResponse(error);
}
}
21 changes: 18 additions & 3 deletions src/features/store-management/StoreManagementPage.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,34 @@
'use client';

import {PageTitle} from '@/src/shared/components/layout/PageTitle';

import {StoreAddDialogTrigger} from './components/StoreAddDialogTrigger';
import {
StoreManagementClientProvider,
useStoreManagementSummary,
} from './components/StoreManagementClientProvider';
import {StoreManagementTable} from './components/StoreManagementTable';
import {stores} from './model/mockStores';

function StoreManagementPage() {
return (
<StoreManagementClientProvider>
<StoreManagementPageContent />
</StoreManagementClientProvider>
);
}

function StoreManagementPageContent() {
const {totalElements} = useStoreManagementSummary();

return (
<section aria-labelledby='store-management-title' className='min-w-0'>
<PageTitle
title={<span id='store-management-title'>매장 관리</span>}
subtitle={`총 ${stores.length}개의 매장`}
subtitle={`총 ${totalElements}개의 매장`}
action={<StoreAddDialogTrigger />}
/>

<StoreManagementTable stores={stores} />
<StoreManagementTable />
</section>
);
}
Expand Down
81 changes: 81 additions & 0 deletions src/features/store-management/api/store-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
API_ENDPOINTS,
buildApiPath,
getBrowserApi,
type AdminApiTypes,
} from '@/src/shared/api';

import type {Store, StoreStatus} from '../model/store';

type GetStoreListParams = {
keyword?: string;
page: number;
size: number;
};

type StoreListResult = {
stores: Store[];
page: number;
size: number;
totalElements: number;
totalPages: number;
hasNextPage: boolean;
};

type ApiStoreStatus = NonNullable<AdminApiTypes.GetStoreListResponse['status']>;

const storeStatusMap = {
OPEN_SOON: 'upcoming',
NEW_OPEN: 'new',
NORMAL: 'operating',
RENEWAL: 'renovation',
CLOSING_SOON: 'closing',
CLOSED: 'closed',
} satisfies Record<ApiStoreStatus, StoreStatus>;

const mapApiStoreStatus = (
status: AdminApiTypes.GetStoreListResponse['status']
): StoreStatus => {
return status ? storeStatusMap[status] : 'operating';
};

const mapApiStore = (
store: AdminApiTypes.GetStoreListResponse,
index: number
): Store => {
return {
id: store.id ?? index + 1,
name: store.name ?? '',
address: store.address ?? '',
station: store.station ?? '',
status: mapApiStoreStatus(store.status),
phone: store.contact ?? '',
website: store.websiteUrl ?? '',
};
};

export const getStoreList = async ({
keyword,
page,
size,
}: GetStoreListParams): Promise<StoreListResult> => {
const {data} =
await getBrowserApi().get<AdminApiTypes.PageResponseGetStoreListResponse>(
buildApiPath(API_ENDPOINTS.stores.root, {
keyword: keyword?.trim() || undefined,
page,
size,
})
);

return {
stores: data.contents?.map(mapApiStore) ?? [],
page: data.page ?? page,
size: data.size ?? size,
totalElements: data.totalElements ?? 0,
totalPages: data.totalPages ?? 1,
hasNextPage: data.hasNextPage ?? false,
};
};

export type {GetStoreListParams, StoreListResult};
24 changes: 24 additions & 0 deletions src/features/store-management/api/store-queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {useQuery} from '@tanstack/react-query';

import {
getStoreList,
type GetStoreListParams,
type StoreListResult,
} from './store-api';

const storeQueryKeys = {
all: ['stores'] as const,
lists: () => [...storeQueryKeys.all, 'list'] as const,
list: (params: GetStoreListParams) =>
[...storeQueryKeys.lists(), params] as const,
};

const useStoreListQuery = (params: GetStoreListParams) => {
return useQuery<StoreListResult>({
queryKey: storeQueryKeys.list(params),
queryFn: () => getStoreList(params),
placeholderData: (previousData) => previousData,
});
};

export {storeQueryKeys, useStoreListQuery};
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import * as React from 'react';

import {isApiError} from '@/src/shared/api';

import {useStoreListQuery} from '../api/store-queries';
import type {Store} from '../model/store';

type StoreManagementControlsContextValue = {
Expand All @@ -13,6 +16,9 @@ type StoreManagementControlsContextValue = {

type StoreManagementRowsContextValue = {
stores: Store[];
isError: boolean;
isLoading: boolean;
errorMessage: string;
};

type StoreManagementPaginationContextValue = {
Expand All @@ -23,8 +29,11 @@ type StoreManagementPaginationContextValue = {
movePage: (nextPage: number) => void;
};

type StoreManagementSummaryContextValue = {
totalElements: number;
};

type StoreManagementClientProviderProps = {
stores: Store[];
children: React.ReactNode;
};

Expand All @@ -34,35 +43,23 @@ const StoreManagementRowsContext =
React.createContext<StoreManagementRowsContextValue | null>(null);
const StoreManagementPaginationContext =
React.createContext<StoreManagementPaginationContextValue | null>(null);
const StoreManagementSummaryContext =
React.createContext<StoreManagementSummaryContextValue | null>(null);

function StoreManagementClientProvider({
stores,
children,
}: StoreManagementClientProviderProps) {
const [pageSize, setPageSize] = React.useState(10);
const [currentPage, setCurrentPage] = React.useState(1);
const [searchKeyword, setSearchKeyword] = React.useState('');

const normalizedSearchKeyword = searchKeyword.trim().toLowerCase();
const filteredStores = React.useMemo(() => {
if (!normalizedSearchKeyword) {
return stores;
}

return stores.filter((store) =>
[store.name, store.address, store.station, store.phone].some((value) =>
value.toLowerCase().includes(normalizedSearchKeyword)
)
);
}, [normalizedSearchKeyword, stores]);

const totalPages = Math.max(Math.ceil(filteredStores.length / pageSize), 1);
const normalizedSearchKeyword = searchKeyword.trim();
const storeListQuery = useStoreListQuery({
keyword: normalizedSearchKeyword || undefined,
page: currentPage,
size: pageSize,
});
const totalPages = Math.max(storeListQuery.data?.totalPages ?? 1, 1);
const safeCurrentPage = Math.min(currentPage, totalPages);
const firstVisibleStoreIndex = (safeCurrentPage - 1) * pageSize;
const paginatedStores = filteredStores.slice(
firstVisibleStoreIndex,
firstVisibleStoreIndex + pageSize
);

const handlePageSizeChange = React.useCallback((nextPageSize: string) => {
setPageSize(Number(nextPageSize));
Expand Down Expand Up @@ -96,33 +93,59 @@ function StoreManagementClientProvider({

const rowsValue = React.useMemo(
() => ({
stores: paginatedStores,
stores: storeListQuery.data?.stores ?? [],
isError: storeListQuery.isError,
isLoading: storeListQuery.isLoading,
errorMessage: getStoreListErrorMessage(storeListQuery.error),
}),
[paginatedStores]
[
storeListQuery.data?.stores,
storeListQuery.error,
storeListQuery.isError,
storeListQuery.isLoading,
]
);

const paginationValue = React.useMemo(
() => ({
currentPage: safeCurrentPage,
totalPages,
hasPreviousPage: safeCurrentPage > 1,
hasNextPage: safeCurrentPage < totalPages,
hasNextPage:
storeListQuery.data?.hasNextPage ?? safeCurrentPage < totalPages,
movePage,
}),
[safeCurrentPage, totalPages, movePage]
[safeCurrentPage, totalPages, storeListQuery.data?.hasNextPage, movePage]
);

const summaryValue = React.useMemo(
() => ({
totalElements: storeListQuery.data?.totalElements ?? 0,
}),
[storeListQuery.data?.totalElements]
);

return (
<StoreManagementControlsContext.Provider value={controlsValue}>
<StoreManagementRowsContext.Provider value={rowsValue}>
<StoreManagementPaginationContext.Provider value={paginationValue}>
{children}
<StoreManagementSummaryContext.Provider value={summaryValue}>
{children}
</StoreManagementSummaryContext.Provider>
</StoreManagementPaginationContext.Provider>
</StoreManagementRowsContext.Provider>
</StoreManagementControlsContext.Provider>
);
}

function getStoreListErrorMessage(error: unknown) {
if (!error) {
return '';
}

return isApiError(error) ? error.message : '매장 목록을 불러오지 못했습니다.';
}

function useStoreManagementControls() {
const value = React.useContext(StoreManagementControlsContext);

Expand Down Expand Up @@ -159,9 +182,22 @@ function useStoreManagementPagination() {
return value;
}

function useStoreManagementSummary() {
const value = React.useContext(StoreManagementSummaryContext);

if (!value) {
throw new Error(
'useStoreManagementSummary must be used within StoreManagementClientProvider.'
);
}

return value;
}

export {
StoreManagementClientProvider,
useStoreManagementControls,
useStoreManagementPagination,
useStoreManagementRows,
useStoreManagementSummary,
};
Original file line number Diff line number Diff line change
@@ -1,20 +1,14 @@
import type {Store} from '../model/store';
import {StoreManagementClientProvider} from './StoreManagementClientProvider';
import {StoreManagementPagination} from './StoreManagementPagination';
import {StoreManagementTableContent} from './StoreManagementTableContent';
import {StoreManagementToolbar} from './StoreManagementToolbar';

type StoreManagementTableProps = {
stores: Store[];
};

function StoreManagementTable({stores}: StoreManagementTableProps) {
function StoreManagementTable() {
return (
<StoreManagementClientProvider stores={stores}>
<>
<StoreManagementToolbar />
<StoreManagementTableContent />
<StoreManagementPagination />
</StoreManagementClientProvider>
</>
);
}

Expand Down
Loading
Loading