sh-ui-cli 0.57.0 → 0.58.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/data/changelog/versions.json +27 -0
- package/data/registry/react/components/sidebar/index.tsx +3 -3
- package/package.json +1 -1
- package/src/api.d.ts +34 -0
- package/src/api.js +7 -0
- package/src/create/architectures/archSchema.js +69 -0
- package/src/create/architectures/flat.js +51 -0
- package/src/create/architectures/fsd.js +42 -0
- package/src/create/architectures/index.js +55 -0
- package/src/create/cli-args.js +8 -1
- package/src/create/generator.js +101 -32
- package/src/create/index.mjs +7 -0
- package/src/create/plugins/authJwt.js +14 -8
- package/src/create/plugins/nextIntl.js +25 -17
- package/src/create/plugins/pluginSchema.js +26 -10
- package/src/create/plugins/sentry.js +9 -5
- package/src/mcp.mjs +10 -0
- package/templates/nextjs-app/_arch/flat/app/api/proxy/[...path]/route.ts +112 -0
- package/templates/nextjs-app/_arch/flat/app/layout.tsx +16 -0
- package/templates/nextjs-app/_arch/flat/components/common/PrefetchBoundary/index.tsx +35 -0
- package/templates/nextjs-app/_arch/flat/components/layouts/RootLayout.tsx +11 -0
- package/templates/nextjs-app/_arch/flat/components/providers/tanstack/QueryClientProvider.tsx +14 -0
- package/templates/nextjs-app/_arch/flat/lib/utils/getQueryClient.ts +14 -0
- package/templates/nextjs-app/_arch/flat/tsconfig.json +25 -0
- package/templates/nextjs-standalone/_arch/flat/app/api/proxy/[...path]/route.ts +112 -0
- package/templates/nextjs-standalone/_arch/flat/app/layout.tsx +16 -0
- package/templates/nextjs-standalone/_arch/flat/components/common/FallbackBoundary/index.tsx +89 -0
- package/templates/nextjs-standalone/_arch/flat/components/common/PrefetchBoundary/index.tsx +35 -0
- package/templates/nextjs-standalone/_arch/flat/components/layouts/RootLayout.tsx +11 -0
- package/templates/nextjs-standalone/_arch/flat/components/providers/GlobalProvider/index.tsx +23 -0
- package/templates/nextjs-standalone/_arch/flat/components/providers/index.tsx +1 -0
- package/templates/nextjs-standalone/_arch/flat/components/providers/tanstack/QueryClientProvider.tsx +14 -0
- package/templates/nextjs-standalone/_arch/flat/components/providers/tanstack/TanstackDevtoolsProvider.tsx +13 -0
- package/templates/nextjs-standalone/_arch/flat/components/providers/theme/ThemeProviders.tsx +12 -0
- package/templates/nextjs-standalone/_arch/flat/lib/api/apiTypes.ts +21 -0
- package/templates/nextjs-standalone/_arch/flat/lib/api/clientFetch.ts +40 -0
- package/templates/nextjs-standalone/_arch/flat/lib/api/error.ts +12 -0
- package/templates/nextjs-standalone/_arch/flat/lib/api/http.ts +13 -0
- package/templates/nextjs-standalone/_arch/flat/lib/api/observability.ts +20 -0
- package/templates/nextjs-standalone/_arch/flat/lib/api/queryClient.ts +30 -0
- package/templates/nextjs-standalone/_arch/flat/lib/api/serverFetch.ts +59 -0
- package/templates/nextjs-standalone/_arch/flat/lib/hooks/useAppMutation.ts +52 -0
- package/templates/nextjs-standalone/_arch/flat/lib/test/createTestQueryClient.ts +18 -0
- package/templates/nextjs-standalone/_arch/flat/lib/test/index.ts +2 -0
- package/templates/nextjs-standalone/_arch/flat/lib/test/renderWithProviders.tsx +40 -0
- package/templates/nextjs-standalone/_arch/flat/lib/utils/formatDate.ts +22 -0
- package/templates/nextjs-standalone/_arch/flat/lib/utils/formatPrice.ts +10 -0
- package/templates/nextjs-standalone/_arch/flat/lib/utils/getQueryClient.ts +14 -0
- package/templates/nextjs-standalone/_arch/flat/sh-ui.config.json +19 -0
- package/templates/nextjs-standalone/_arch/flat/tsconfig.json +41 -0
- package/templates/nextjs-standalone/_arch/fsd/src/app/providers/GlobalProvider/index.tsx +23 -0
- package/templates/nextjs-standalone/_arch/fsd/src/app/providers/index.tsx +1 -0
- package/templates/nextjs-standalone/_arch/fsd/src/app/providers/tanstack/TanstackDevtoolsProvider.tsx +13 -0
- package/templates/nextjs-standalone/_arch/fsd/src/app/providers/theme/ThemeProviders.tsx +12 -0
- package/templates/nextjs-standalone/_arch/fsd/src/entities/.gitkeep +0 -0
- package/templates/nextjs-standalone/_arch/fsd/src/features/.gitkeep +0 -0
- package/templates/nextjs-standalone/_arch/fsd/src/shared/api/.gitkeep +0 -0
- package/templates/nextjs-standalone/_arch/fsd/src/shared/api/apiTypes.ts +21 -0
- package/templates/nextjs-standalone/_arch/fsd/src/shared/api/clientFetch.ts +40 -0
- package/templates/nextjs-standalone/_arch/fsd/src/shared/api/error.ts +12 -0
- package/templates/nextjs-standalone/_arch/fsd/src/shared/api/http.ts +13 -0
- package/templates/nextjs-standalone/_arch/fsd/src/shared/api/observability.ts +20 -0
- package/templates/nextjs-standalone/_arch/fsd/src/shared/api/queryClient.ts +30 -0
- package/templates/nextjs-standalone/_arch/fsd/src/shared/api/serverFetch.ts +59 -0
- package/templates/nextjs-standalone/_arch/fsd/src/shared/config/.gitkeep +0 -0
- package/templates/nextjs-standalone/_arch/fsd/src/shared/hooks/.gitkeep +0 -0
- package/templates/nextjs-standalone/_arch/fsd/src/shared/hooks/useAppMutation.ts +52 -0
- package/templates/nextjs-standalone/_arch/fsd/src/shared/lib/formatDate.ts +22 -0
- package/templates/nextjs-standalone/_arch/fsd/src/shared/lib/formatPrice.ts +10 -0
- package/templates/nextjs-standalone/_arch/fsd/src/shared/lib/utils.ts +6 -0
- package/templates/nextjs-standalone/_arch/fsd/src/shared/model/.gitkeep +0 -0
- package/templates/nextjs-standalone/_arch/fsd/src/shared/styles/tokens.css +135 -0
- package/templates/nextjs-standalone/_arch/fsd/src/shared/test/createTestQueryClient.ts +18 -0
- package/templates/nextjs-standalone/_arch/fsd/src/shared/test/index.ts +2 -0
- package/templates/nextjs-standalone/_arch/fsd/src/shared/test/renderWithProviders.tsx +40 -0
- package/templates/nextjs-standalone/_arch/fsd/src/shared/ui/.gitkeep +0 -0
- package/templates/nextjs-standalone/_arch/fsd/src/shared/ui/FallbackBoundary/index.tsx +89 -0
- package/templates/nextjs-standalone/_arch/fsd/src/views/.gitkeep +0 -0
- package/templates/nextjs-standalone/_arch/fsd/src/widgets/.gitkeep +0 -0
- /package/templates/nextjs-app/{src/entities → _arch/flat/components/common}/.gitkeep +0 -0
- /package/templates/nextjs-app/{src/shared/ui → _arch/flat/components/common}/FallbackBoundary/index.tsx +0 -0
- /package/templates/nextjs-app/{src/app → _arch/flat/components}/providers/GlobalProvider/index.tsx +0 -0
- /package/templates/nextjs-app/{src/app → _arch/flat/components}/providers/index.tsx +0 -0
- /package/templates/nextjs-app/{src/app → _arch/flat/components}/providers/tanstack/TanstackDevtoolsProvider.tsx +0 -0
- /package/templates/nextjs-app/{src/app → _arch/flat/components}/providers/theme/ThemeProviders.tsx +0 -0
- /package/templates/nextjs-app/{src/features → _arch/flat/lib/api}/.gitkeep +0 -0
- /package/templates/nextjs-app/{src/shared → _arch/flat/lib}/api/apiTypes.ts +0 -0
- /package/templates/nextjs-app/{src/shared → _arch/flat/lib}/api/clientFetch.ts +0 -0
- /package/templates/nextjs-app/{src/shared → _arch/flat/lib}/api/error.ts +0 -0
- /package/templates/nextjs-app/{src/shared → _arch/flat/lib}/api/http.ts +0 -0
- /package/templates/nextjs-app/{src/shared → _arch/flat/lib}/api/observability.ts +0 -0
- /package/templates/nextjs-app/{src/shared → _arch/flat/lib}/api/queryClient.ts +0 -0
- /package/templates/nextjs-app/{src/shared → _arch/flat/lib}/api/serverFetch.ts +0 -0
- /package/templates/nextjs-app/{src/shared/api → _arch/flat/lib/config}/.gitkeep +0 -0
- /package/templates/nextjs-app/{src/shared/config → _arch/flat/lib/hooks}/.gitkeep +0 -0
- /package/templates/nextjs-app/{src/shared → _arch/flat/lib}/hooks/useAppMutation.ts +0 -0
- /package/templates/nextjs-app/{src/shared → _arch/flat/lib}/test/createTestQueryClient.ts +0 -0
- /package/templates/nextjs-app/{src/shared → _arch/flat/lib}/test/index.ts +0 -0
- /package/templates/nextjs-app/{src/shared → _arch/flat/lib}/test/renderWithProviders.tsx +0 -0
- /package/templates/nextjs-app/{src/shared/hooks → _arch/flat/lib/utils}/.gitkeep +0 -0
- /package/templates/nextjs-app/{src/shared/lib → _arch/flat/lib/utils}/formatDate.ts +0 -0
- /package/templates/nextjs-app/{src/shared/lib → _arch/flat/lib/utils}/formatPrice.ts +0 -0
- /package/templates/nextjs-app/{app → _arch/fsd/app}/api/proxy/[...path]/route.ts +0 -0
- /package/templates/nextjs-app/{app → _arch/fsd/app}/layout.tsx +0 -0
- /package/templates/nextjs-app/{src → _arch/fsd/src}/app/layouts/RootLayout.tsx +0 -0
- /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/app/providers/GlobalProvider/index.tsx +0 -0
- /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/app/providers/index.tsx +0 -0
- /package/templates/nextjs-app/{src → _arch/fsd/src}/app/providers/tanstack/QueryClientProvider.tsx +0 -0
- /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/app/providers/tanstack/TanstackDevtoolsProvider.tsx +0 -0
- /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/app/providers/theme/ThemeProviders.tsx +0 -0
- /package/templates/nextjs-app/{src/shared/lib → _arch/fsd/src/entities}/.gitkeep +0 -0
- /package/templates/nextjs-app/{src/shared/model → _arch/fsd/src/features}/.gitkeep +0 -0
- /package/templates/nextjs-app/{src/shared/ui → _arch/fsd/src/shared/api}/.gitkeep +0 -0
- /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/api/apiTypes.ts +0 -0
- /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/api/clientFetch.ts +0 -0
- /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/api/error.ts +0 -0
- /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/api/http.ts +0 -0
- /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/api/observability.ts +0 -0
- /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/api/queryClient.ts +0 -0
- /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/api/serverFetch.ts +0 -0
- /package/templates/nextjs-app/{src/views → _arch/fsd/src/shared/config}/.gitkeep +0 -0
- /package/templates/nextjs-app/{src/widgets → _arch/fsd/src/shared/hooks}/.gitkeep +0 -0
- /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/hooks/useAppMutation.ts +0 -0
- /package/templates/{nextjs-standalone/src/entities → nextjs-app/_arch/fsd/src/shared/lib}/.gitkeep +0 -0
- /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/lib/formatDate.ts +0 -0
- /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/lib/formatPrice.ts +0 -0
- /package/templates/nextjs-app/{src → _arch/fsd/src}/shared/lib/getQueryClient.ts +0 -0
- /package/templates/{nextjs-standalone/src/features → nextjs-app/_arch/fsd/src/shared/model}/.gitkeep +0 -0
- /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/test/createTestQueryClient.ts +0 -0
- /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/test/index.ts +0 -0
- /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/test/renderWithProviders.tsx +0 -0
- /package/templates/{nextjs-standalone/src/shared/api → nextjs-app/_arch/fsd/src/shared/ui}/.gitkeep +0 -0
- /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/ui/FallbackBoundary/index.tsx +0 -0
- /package/templates/nextjs-app/{src → _arch/fsd/src}/shared/ui/PrefetchBoundary/index.tsx +0 -0
- /package/templates/{nextjs-standalone/src/shared/config → nextjs-app/_arch/fsd/src/views}/.gitkeep +0 -0
- /package/templates/{nextjs-standalone/src/shared/hooks → nextjs-app/_arch/fsd/src/widgets}/.gitkeep +0 -0
- /package/templates/nextjs-app/{tsconfig.json → _arch/fsd/tsconfig.json} +0 -0
- /package/templates/nextjs-standalone/{src/shared/model → _arch/flat/components/common}/.gitkeep +0 -0
- /package/templates/nextjs-standalone/{src/shared/ui → _arch/flat/lib/api}/.gitkeep +0 -0
- /package/templates/nextjs-standalone/{src/views → _arch/flat/lib/config}/.gitkeep +0 -0
- /package/templates/nextjs-standalone/{src/widgets → _arch/flat/lib/hooks}/.gitkeep +0 -0
- /package/templates/nextjs-standalone/{src/shared → _arch/flat/lib}/styles/tokens.css +0 -0
- /package/templates/nextjs-standalone/{src/shared/lib → _arch/flat/lib/utils}/utils.ts +0 -0
- /package/templates/nextjs-standalone/{app → _arch/fsd/app}/api/proxy/[...path]/route.ts +0 -0
- /package/templates/nextjs-standalone/{app → _arch/fsd/app}/layout.tsx +0 -0
- /package/templates/nextjs-standalone/{sh-ui.config.json → _arch/fsd/sh-ui.config.json} +0 -0
- /package/templates/nextjs-standalone/{src → _arch/fsd/src}/app/layouts/RootLayout.tsx +0 -0
- /package/templates/nextjs-standalone/{src → _arch/fsd/src}/app/providers/tanstack/QueryClientProvider.tsx +0 -0
- /package/templates/nextjs-standalone/{src → _arch/fsd/src}/shared/lib/getQueryClient.ts +0 -0
- /package/templates/nextjs-standalone/{src → _arch/fsd/src}/shared/ui/PrefetchBoundary/index.tsx +0 -0
- /package/templates/nextjs-standalone/{tsconfig.json → _arch/fsd/tsconfig.json} +0 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 숫자 → 한국 원화(KRW) 포맷. 소수점 없음.
|
|
3
|
+
* 예: 12000 → "₩12,000"
|
|
4
|
+
*/
|
|
5
|
+
export const formatPrice = (amount: number, locale = 'ko-KR'): string =>
|
|
6
|
+
new Intl.NumberFormat(locale, {
|
|
7
|
+
style: 'currency',
|
|
8
|
+
currency: 'KRW',
|
|
9
|
+
maximumFractionDigits: 0,
|
|
10
|
+
}).format(amount);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { isServer } from '@tanstack/react-query';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getBrowserQueryClient,
|
|
5
|
+
getServerQueryClient,
|
|
6
|
+
} from '@/lib/api/queryClient';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* RSC 와 클라이언트 양쪽에서 안전하게 호출 가능한 QueryClient 핸들.
|
|
10
|
+
* 서버에서는 React `cache()` 로 요청 단위 싱글턴, 브라우저에서는 모듈 싱글턴.
|
|
11
|
+
*/
|
|
12
|
+
export default function getQueryClient() {
|
|
13
|
+
return isServer ? getServerQueryClient() : getBrowserQueryClient();
|
|
14
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"platform": "react",
|
|
3
|
+
"cssFramework": "plain",
|
|
4
|
+
"theme": {
|
|
5
|
+
"base": "neutral",
|
|
6
|
+
"radius": "md",
|
|
7
|
+
"mode": "light-dark"
|
|
8
|
+
},
|
|
9
|
+
"paths": {
|
|
10
|
+
"tokens": "lib/styles/tokens.css",
|
|
11
|
+
"components": "components/common",
|
|
12
|
+
"utils": "lib/utils/utils.ts"
|
|
13
|
+
},
|
|
14
|
+
"aliases": {
|
|
15
|
+
"components": "@/components/common",
|
|
16
|
+
"utils": "@/lib/utils/utils",
|
|
17
|
+
"ui": "@/components/common"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/tsconfig",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"target": "ES2022",
|
|
5
|
+
"lib": ["es2022", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"moduleResolution": "Bundler",
|
|
8
|
+
"moduleDetection": "force",
|
|
9
|
+
"declaration": true,
|
|
10
|
+
"declarationMap": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"isolatedModules": true,
|
|
13
|
+
"resolveJsonModule": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"strict": true,
|
|
16
|
+
"noUncheckedIndexedAccess": true,
|
|
17
|
+
"allowJs": true,
|
|
18
|
+
"jsx": "preserve",
|
|
19
|
+
"noEmit": true,
|
|
20
|
+
"incremental": true,
|
|
21
|
+
"baseUrl": ".",
|
|
22
|
+
"paths": {
|
|
23
|
+
"@/lib/*": ["./lib/*"],
|
|
24
|
+
"@/components/*": ["./components/*"],
|
|
25
|
+
"@/app/*": ["./app/*"]
|
|
26
|
+
},
|
|
27
|
+
"plugins": [
|
|
28
|
+
{
|
|
29
|
+
"name": "next"
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"include": [
|
|
34
|
+
"next-env.d.ts",
|
|
35
|
+
"next.config.ts",
|
|
36
|
+
"**/*.ts",
|
|
37
|
+
"**/*.tsx",
|
|
38
|
+
".next/types/**/*.ts"
|
|
39
|
+
],
|
|
40
|
+
"exclude": ["node_modules"]
|
|
41
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { Toaster } from 'sonner';
|
|
3
|
+
|
|
4
|
+
import { QueryClientProvider } from '../tanstack/QueryClientProvider';
|
|
5
|
+
import { TanstackDevtoolsProvider } from '../tanstack/TanstackDevtoolsProvider';
|
|
6
|
+
import { ThemeProviders } from '../theme/ThemeProviders';
|
|
7
|
+
|
|
8
|
+
interface GlobalProviderProps {
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function GlobalProvider({ children }: GlobalProviderProps) {
|
|
13
|
+
return (
|
|
14
|
+
<ThemeProviders>
|
|
15
|
+
<QueryClientProvider>
|
|
16
|
+
<TanstackDevtoolsProvider>
|
|
17
|
+
<Toaster />
|
|
18
|
+
{children}
|
|
19
|
+
</TanstackDevtoolsProvider>
|
|
20
|
+
</QueryClientProvider>
|
|
21
|
+
</ThemeProviders>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { GlobalProvider } from './GlobalProvider';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
export function TanstackDevtoolsProvider({ children }: { children: ReactNode }) {
|
|
7
|
+
return (
|
|
8
|
+
<>
|
|
9
|
+
{children}
|
|
10
|
+
<ReactQueryDevtools initialIsOpen={false} />
|
|
11
|
+
</>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { ThemeProvider } from 'next-themes';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
export function ThemeProviders({ children }: { children: ReactNode }) {
|
|
7
|
+
return (
|
|
8
|
+
<ThemeProvider attribute='class' defaultTheme='system' enableSystem>
|
|
9
|
+
{children}
|
|
10
|
+
</ThemeProvider>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** 공통 API 에러 형식 */
|
|
2
|
+
export type ApiErrorBody = {
|
|
3
|
+
message: string;
|
|
4
|
+
code: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/** 백엔드 공통 응답 래퍼 */
|
|
8
|
+
export type ApiResponse<TData = unknown, TError = ApiErrorBody> = {
|
|
9
|
+
result: 'SUCCESS' | 'ERROR';
|
|
10
|
+
data: TData;
|
|
11
|
+
error: TError | null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** 페이지네이션 응답 래퍼 */
|
|
15
|
+
export type PaginatedData<T> = {
|
|
16
|
+
content: T[];
|
|
17
|
+
totalItems: number;
|
|
18
|
+
offset: number;
|
|
19
|
+
limit: number;
|
|
20
|
+
hasNext: boolean;
|
|
21
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ApiResponse } from './apiTypes';
|
|
4
|
+
import { ApiError } from './error';
|
|
5
|
+
|
|
6
|
+
const PROXY_BASE = '/api/proxy';
|
|
7
|
+
|
|
8
|
+
export async function clientFetch<T>(
|
|
9
|
+
path: string,
|
|
10
|
+
init: RequestInit = {},
|
|
11
|
+
): Promise<T> {
|
|
12
|
+
const url = `${PROXY_BASE}${path.startsWith('/') ? path : `/${path}`}`;
|
|
13
|
+
|
|
14
|
+
const res = await fetch(url, {
|
|
15
|
+
...init,
|
|
16
|
+
headers: {
|
|
17
|
+
'Content-Type': 'application/json',
|
|
18
|
+
...(init.headers as Record<string, string> | undefined),
|
|
19
|
+
},
|
|
20
|
+
credentials: 'include',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const body = (await res.json()) as ApiResponse<T>;
|
|
24
|
+
|
|
25
|
+
if (res.status === 401) {
|
|
26
|
+
if (
|
|
27
|
+
typeof window !== 'undefined' &&
|
|
28
|
+
!window.location.pathname.startsWith('/sign-in')
|
|
29
|
+
) {
|
|
30
|
+
window.location.href = '/sign-in';
|
|
31
|
+
}
|
|
32
|
+
throw new ApiError(401, body.error?.code ?? 'UNAUTHORIZED', body.error);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!res.ok || body.result === 'ERROR') {
|
|
36
|
+
throw new ApiError(res.status, body.error?.code ?? '', body.error);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return body.data;
|
|
40
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ApiErrorBody } from './apiTypes';
|
|
2
|
+
|
|
3
|
+
export class ApiError extends Error {
|
|
4
|
+
constructor(
|
|
5
|
+
public readonly status: number,
|
|
6
|
+
public readonly code: string,
|
|
7
|
+
public readonly data: ApiErrorBody | null,
|
|
8
|
+
) {
|
|
9
|
+
super(data?.message ?? `API 요청 실패 (${status})`);
|
|
10
|
+
this.name = 'ApiError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { clientFetch } from './clientFetch';
|
|
2
|
+
import { serverFetch } from './serverFetch';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* isomorphic 진입점.
|
|
6
|
+
* RSC/서버에서는 백엔드로 직통, 브라우저에서는 /api/proxy 경유.
|
|
7
|
+
* API 함수는 한 번만 작성하고 환경 분기는 여기서 처리한다.
|
|
8
|
+
*/
|
|
9
|
+
export function http<T>(path: string, init?: RequestInit): Promise<T> {
|
|
10
|
+
return typeof window === 'undefined'
|
|
11
|
+
? serverFetch<T>(path, init)
|
|
12
|
+
: clientFetch<T>(path, init);
|
|
13
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
type ApiCaptureParams = {
|
|
2
|
+
url: string;
|
|
3
|
+
apiPath: string;
|
|
4
|
+
method: string;
|
|
5
|
+
status: number | undefined;
|
|
6
|
+
responseBody?: unknown;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type ApiLogParams = {
|
|
10
|
+
url: string;
|
|
11
|
+
method: string;
|
|
12
|
+
status: number | undefined;
|
|
13
|
+
requestHeaders?: Record<string, string | undefined>;
|
|
14
|
+
requestBody?: unknown;
|
|
15
|
+
responseBody?: unknown;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const captureApiError = (_params: ApiCaptureParams): void => {};
|
|
19
|
+
|
|
20
|
+
export const logApiError = (_prefix: string, _params: ApiLogParams): void => {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {
|
|
2
|
+
QueryClient,
|
|
3
|
+
defaultShouldDehydrateQuery,
|
|
4
|
+
isServer,
|
|
5
|
+
} from '@tanstack/react-query';
|
|
6
|
+
import { cache } from 'react';
|
|
7
|
+
|
|
8
|
+
function makeQueryClient(): QueryClient {
|
|
9
|
+
return new QueryClient({
|
|
10
|
+
defaultOptions: {
|
|
11
|
+
queries: {
|
|
12
|
+
staleTime: 60 * 1000,
|
|
13
|
+
retry: 1,
|
|
14
|
+
},
|
|
15
|
+
dehydrate: {
|
|
16
|
+
shouldDehydrateQuery: (q) =>
|
|
17
|
+
defaultShouldDehydrateQuery(q) || q.state.status === 'pending',
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const getServerQueryClient = cache(makeQueryClient);
|
|
24
|
+
|
|
25
|
+
let browserQueryClient: QueryClient | undefined;
|
|
26
|
+
|
|
27
|
+
export function getBrowserQueryClient(): QueryClient {
|
|
28
|
+
if (isServer) return makeQueryClient();
|
|
29
|
+
return (browserQueryClient ??= makeQueryClient());
|
|
30
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { cookies } from 'next/headers';
|
|
2
|
+
|
|
3
|
+
import type { ApiResponse } from './apiTypes';
|
|
4
|
+
import { ApiError } from './error';
|
|
5
|
+
import { captureApiError, logApiError } from './observability';
|
|
6
|
+
|
|
7
|
+
const API_URL = process.env.API_URL ?? 'http://localhost:8080/api';
|
|
8
|
+
|
|
9
|
+
const ACCESS_TOKEN_COOKIE = 'accessToken';
|
|
10
|
+
const LOCALE_COOKIE = 'NEXT_LOCALE';
|
|
11
|
+
|
|
12
|
+
export async function serverFetch<T>(
|
|
13
|
+
path: string,
|
|
14
|
+
init: RequestInit = {},
|
|
15
|
+
): Promise<T> {
|
|
16
|
+
const jar = await cookies();
|
|
17
|
+
const accessToken = jar.get(ACCESS_TOKEN_COOKIE)?.value;
|
|
18
|
+
const locale = jar.get(LOCALE_COOKIE)?.value;
|
|
19
|
+
|
|
20
|
+
const url = `${API_URL}${path.startsWith('/') ? path : `/${path}`}`;
|
|
21
|
+
const method = (init.method ?? 'GET').toUpperCase();
|
|
22
|
+
|
|
23
|
+
const headers: Record<string, string> = {
|
|
24
|
+
'Content-Type': 'application/json',
|
|
25
|
+
...(accessToken && { Authorization: `Bearer ${accessToken}` }),
|
|
26
|
+
...(locale && { 'Accept-Language': locale }),
|
|
27
|
+
...(init.headers as Record<string, string> | undefined),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const res = await fetch(url, {
|
|
31
|
+
...init,
|
|
32
|
+
headers,
|
|
33
|
+
cache: init.cache ?? 'no-store',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const body = (await res.json()) as ApiResponse<T>;
|
|
37
|
+
|
|
38
|
+
if (!res.ok || body.result === 'ERROR') {
|
|
39
|
+
logApiError('SERVER_FETCH', {
|
|
40
|
+
url,
|
|
41
|
+
method,
|
|
42
|
+
status: res.status,
|
|
43
|
+
requestBody: typeof init.body === 'string' ? init.body : undefined,
|
|
44
|
+
responseBody: body,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
captureApiError({
|
|
48
|
+
url,
|
|
49
|
+
apiPath: path,
|
|
50
|
+
method,
|
|
51
|
+
status: res.status,
|
|
52
|
+
responseBody: body,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
throw new ApiError(res.status, body.error?.code ?? '', body.error);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return body.data;
|
|
59
|
+
}
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useMutation,
|
|
3
|
+
type UseMutationOptions,
|
|
4
|
+
type DefaultError,
|
|
5
|
+
} from '@tanstack/react-query';
|
|
6
|
+
import { toast } from 'sonner';
|
|
7
|
+
|
|
8
|
+
import { ApiError } from '../api/error';
|
|
9
|
+
|
|
10
|
+
type AppMutationOptions<
|
|
11
|
+
TData = unknown,
|
|
12
|
+
TError = DefaultError,
|
|
13
|
+
TVariables = void,
|
|
14
|
+
TContext = unknown,
|
|
15
|
+
> = UseMutationOptions<TData, TError, TVariables, TContext> & {
|
|
16
|
+
errorMessage?: string;
|
|
17
|
+
showErrorToast?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* useMutation 래퍼 — 에러 발생 시 ApiError.message 를 toast 로 띄운다.
|
|
22
|
+
* showErrorToast: false 로 끌 수 있고, errorMessage 로 fallback 메시지 지정.
|
|
23
|
+
*/
|
|
24
|
+
export const useAppMutation = <
|
|
25
|
+
TData = unknown,
|
|
26
|
+
TError = DefaultError,
|
|
27
|
+
TVariables = void,
|
|
28
|
+
TContext = unknown,
|
|
29
|
+
>(
|
|
30
|
+
options: AppMutationOptions<TData, TError, TVariables, TContext>,
|
|
31
|
+
) => {
|
|
32
|
+
const { errorMessage, showErrorToast = true, onError, ...rest } = options;
|
|
33
|
+
|
|
34
|
+
return useMutation({
|
|
35
|
+
...rest,
|
|
36
|
+
onError: (...args) => {
|
|
37
|
+
onError?.(...args);
|
|
38
|
+
|
|
39
|
+
if (!showErrorToast) return;
|
|
40
|
+
|
|
41
|
+
const [error] = args;
|
|
42
|
+
const message =
|
|
43
|
+
error instanceof ApiError
|
|
44
|
+
? (error.data?.message ?? errorMessage)
|
|
45
|
+
: errorMessage;
|
|
46
|
+
|
|
47
|
+
if (message) {
|
|
48
|
+
toast.error(message);
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Date → 로케일 기반 날짜 포맷 (시간 없음).
|
|
3
|
+
*/
|
|
4
|
+
export const formatDate = (date: Date, locale = 'ko-KR'): string =>
|
|
5
|
+
new Intl.DateTimeFormat(locale, {
|
|
6
|
+
year: 'numeric',
|
|
7
|
+
month: '2-digit',
|
|
8
|
+
day: '2-digit',
|
|
9
|
+
}).format(date);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Date → 로케일 기반 날짜 + 시간 포맷 (24h).
|
|
13
|
+
*/
|
|
14
|
+
export const formatDateTime = (date: Date, locale = 'ko-KR'): string =>
|
|
15
|
+
new Intl.DateTimeFormat(locale, {
|
|
16
|
+
year: 'numeric',
|
|
17
|
+
month: '2-digit',
|
|
18
|
+
day: '2-digit',
|
|
19
|
+
hour: '2-digit',
|
|
20
|
+
minute: '2-digit',
|
|
21
|
+
hour12: false,
|
|
22
|
+
}).format(date);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 숫자 → 한국 원화(KRW) 포맷. 소수점 없음.
|
|
3
|
+
* 예: 12000 → "₩12,000"
|
|
4
|
+
*/
|
|
5
|
+
export const formatPrice = (amount: number, locale = 'ko-KR'): string =>
|
|
6
|
+
new Intl.NumberFormat(locale, {
|
|
7
|
+
style: 'currency',
|
|
8
|
+
currency: 'KRW',
|
|
9
|
+
maximumFractionDigits: 0,
|
|
10
|
+
}).format(amount);
|
|
File without changes
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/* Generated by @sh-ui/tokens — do not edit directly */
|
|
2
|
+
/* base=neutral radius=md mode=light-dark */
|
|
3
|
+
|
|
4
|
+
/* sh-ui:theme-colors-start */
|
|
5
|
+
:root {
|
|
6
|
+
--background: #FFFFFF;
|
|
7
|
+
--background-subtle: #FAFAFA;
|
|
8
|
+
--background-muted: #F5F5F5;
|
|
9
|
+
--background-inverse: #0A0A0A;
|
|
10
|
+
--foreground: #0A0A0A;
|
|
11
|
+
--foreground-muted: #525252;
|
|
12
|
+
--foreground-subtle: #A3A3A3;
|
|
13
|
+
--foreground-inverse: #FFFFFF;
|
|
14
|
+
--border: #E5E5E5;
|
|
15
|
+
--border-strong: #D4D4D4;
|
|
16
|
+
--primary: #171717;
|
|
17
|
+
--primary-foreground: #FAFAFA;
|
|
18
|
+
--primary-hover: #262626;
|
|
19
|
+
--danger: #DC2626;
|
|
20
|
+
--danger-foreground: #FFFFFF;
|
|
21
|
+
}
|
|
22
|
+
@media (prefers-color-scheme: dark) {
|
|
23
|
+
:root:not(.light):not(.dark) {
|
|
24
|
+
--background: #0A0A0A;
|
|
25
|
+
--background-subtle: #171717;
|
|
26
|
+
--background-muted: #262626;
|
|
27
|
+
--background-inverse: #FFFFFF;
|
|
28
|
+
--foreground: #FAFAFA;
|
|
29
|
+
--foreground-muted: #A3A3A3;
|
|
30
|
+
--foreground-subtle: #737373;
|
|
31
|
+
--foreground-inverse: #0A0A0A;
|
|
32
|
+
--border: #262626;
|
|
33
|
+
--border-strong: #404040;
|
|
34
|
+
--primary: #FAFAFA;
|
|
35
|
+
--primary-foreground: #171717;
|
|
36
|
+
--primary-hover: #E5E5E5;
|
|
37
|
+
--danger: #DC2626;
|
|
38
|
+
--danger-foreground: #FFFFFF;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
.dark {
|
|
42
|
+
--background: #0A0A0A;
|
|
43
|
+
--background-subtle: #171717;
|
|
44
|
+
--background-muted: #262626;
|
|
45
|
+
--background-inverse: #FFFFFF;
|
|
46
|
+
--foreground: #FAFAFA;
|
|
47
|
+
--foreground-muted: #A3A3A3;
|
|
48
|
+
--foreground-subtle: #737373;
|
|
49
|
+
--foreground-inverse: #0A0A0A;
|
|
50
|
+
--border: #262626;
|
|
51
|
+
--border-strong: #404040;
|
|
52
|
+
--primary: #FAFAFA;
|
|
53
|
+
--primary-foreground: #171717;
|
|
54
|
+
--primary-hover: #E5E5E5;
|
|
55
|
+
--danger: #DC2626;
|
|
56
|
+
--danger-foreground: #FFFFFF;
|
|
57
|
+
}
|
|
58
|
+
/* sh-ui:theme-colors-end */
|
|
59
|
+
|
|
60
|
+
:root {
|
|
61
|
+
/* sh-ui:theme-radius-start */
|
|
62
|
+
--radius: 0.5rem;
|
|
63
|
+
/* sh-ui:theme-radius-end */
|
|
64
|
+
/* sh-ui:theme-space-start */
|
|
65
|
+
--space-0: 0px;
|
|
66
|
+
--space-1: 4px;
|
|
67
|
+
--space-2: 8px;
|
|
68
|
+
--space-3: 12px;
|
|
69
|
+
--space-4: 16px;
|
|
70
|
+
--space-5: 20px;
|
|
71
|
+
--space-6: 24px;
|
|
72
|
+
--space-8: 32px;
|
|
73
|
+
--space-10: 40px;
|
|
74
|
+
--space-12: 48px;
|
|
75
|
+
--space-16: 64px;
|
|
76
|
+
/* sh-ui:theme-space-end */
|
|
77
|
+
/* sh-ui:theme-text-start */
|
|
78
|
+
--text-xs: 12px;
|
|
79
|
+
--text-sm: 14px;
|
|
80
|
+
--text-base: 16px;
|
|
81
|
+
--text-lg: 18px;
|
|
82
|
+
--text-xl: 20px;
|
|
83
|
+
--text-2xl: 24px;
|
|
84
|
+
--text-3xl: 30px;
|
|
85
|
+
--text-4xl: 36px;
|
|
86
|
+
/* sh-ui:theme-text-end */
|
|
87
|
+
/* sh-ui:theme-weight-start */
|
|
88
|
+
--weight-regular: 400;
|
|
89
|
+
--weight-medium: 500;
|
|
90
|
+
--weight-semibold: 600;
|
|
91
|
+
--weight-bold: 700;
|
|
92
|
+
/* sh-ui:theme-weight-end */
|
|
93
|
+
/* sh-ui:theme-shadow-start */
|
|
94
|
+
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.08);
|
|
95
|
+
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.12);
|
|
96
|
+
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.15);
|
|
97
|
+
--shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.18);
|
|
98
|
+
/* sh-ui:theme-shadow-end */
|
|
99
|
+
/* sh-ui:theme-duration-start */
|
|
100
|
+
--duration-fast: 120ms;
|
|
101
|
+
--duration-base: 160ms;
|
|
102
|
+
--duration-slow: 200ms;
|
|
103
|
+
/* sh-ui:theme-duration-end */
|
|
104
|
+
/* sh-ui:theme-ease-start */
|
|
105
|
+
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
|
106
|
+
--ease-emphasized: cubic-bezier(0.2, 0, 0, 1);
|
|
107
|
+
/* sh-ui:theme-ease-end */
|
|
108
|
+
/* sh-ui:theme-control-start */
|
|
109
|
+
--control-sm: 32px;
|
|
110
|
+
--control-md: 40px;
|
|
111
|
+
--control-lg: 48px;
|
|
112
|
+
/* sh-ui:theme-control-end */
|
|
113
|
+
/* sh-ui:theme-border-width-start */
|
|
114
|
+
--border-width: 1px;
|
|
115
|
+
--border-width-strong: 2px;
|
|
116
|
+
/* sh-ui:theme-border-width-end */
|
|
117
|
+
/* sh-ui:theme-gradient-start */
|
|
118
|
+
--gradient-primary: linear-gradient(135deg, #171717 0%, #525252 100%);
|
|
119
|
+
--gradient-surface: linear-gradient(180deg, #FFFFFF 0%, #F5F5F5 100%);
|
|
120
|
+
--gradient-overlay: linear-gradient(180deg, #000000 0%, #1F1F1F 100%);
|
|
121
|
+
/* sh-ui:theme-gradient-end */
|
|
122
|
+
--opacity-disabled: 0.5;
|
|
123
|
+
--z-base: 0;
|
|
124
|
+
--z-sticky: 100;
|
|
125
|
+
--z-dropdown: 200;
|
|
126
|
+
--z-overlay: 300;
|
|
127
|
+
--z-modal: 400;
|
|
128
|
+
--z-popover: 500;
|
|
129
|
+
--z-toast: 600;
|
|
130
|
+
--z-tooltip: 700;
|
|
131
|
+
--bp-sm: 640px;
|
|
132
|
+
--bp-md: 768px;
|
|
133
|
+
--bp-lg: 1024px;
|
|
134
|
+
--bp-xl: 1280px;
|
|
135
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { QueryClient } from '@tanstack/react-query';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 테스트 전용 QueryClient — retry/refetch 끄고 gcTime 0 으로 격리.
|
|
5
|
+
*/
|
|
6
|
+
export const createTestQueryClient = (): QueryClient =>
|
|
7
|
+
new QueryClient({
|
|
8
|
+
defaultOptions: {
|
|
9
|
+
queries: {
|
|
10
|
+
retry: false,
|
|
11
|
+
refetchOnWindowFocus: false,
|
|
12
|
+
gcTime: 0,
|
|
13
|
+
},
|
|
14
|
+
mutations: {
|
|
15
|
+
retry: false,
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
render,
|
|
5
|
+
type RenderOptions,
|
|
6
|
+
type RenderResult,
|
|
7
|
+
} from '@testing-library/react';
|
|
8
|
+
import userEvent from '@testing-library/user-event';
|
|
9
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
10
|
+
|
|
11
|
+
import { createTestQueryClient } from './createTestQueryClient';
|
|
12
|
+
|
|
13
|
+
type Options = Omit<RenderOptions, 'wrapper'> & {
|
|
14
|
+
queryClient?: QueryClient;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type Result = RenderResult & {
|
|
18
|
+
user: ReturnType<typeof userEvent.setup>;
|
|
19
|
+
queryClient: QueryClient;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* RTL render + QueryClientProvider + userEvent setup 한 번에.
|
|
24
|
+
* 추가 Provider 가 필요하면 이 파일을 직접 수정하거나 wrapper 옵션을 쓴다.
|
|
25
|
+
*/
|
|
26
|
+
export const renderWithProviders = (
|
|
27
|
+
ui: ReactElement,
|
|
28
|
+
options: Options = {},
|
|
29
|
+
): Result => {
|
|
30
|
+
const { queryClient = createTestQueryClient(), ...rtlOptions } = options;
|
|
31
|
+
|
|
32
|
+
const Wrapper = ({ children }: { children: ReactNode }) => (
|
|
33
|
+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const result = render(ui, { wrapper: Wrapper, ...rtlOptions });
|
|
37
|
+
const user = userEvent.setup();
|
|
38
|
+
|
|
39
|
+
return { ...result, user, queryClient };
|
|
40
|
+
};
|
|
File without changes
|