sh-ui-cli 0.57.0 → 0.58.0

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.
Files changed (150) hide show
  1. package/data/changelog/versions.json +17 -0
  2. package/package.json +1 -1
  3. package/src/api.d.ts +34 -0
  4. package/src/api.js +7 -0
  5. package/src/create/architectures/archSchema.js +69 -0
  6. package/src/create/architectures/flat.js +51 -0
  7. package/src/create/architectures/fsd.js +42 -0
  8. package/src/create/architectures/index.js +55 -0
  9. package/src/create/cli-args.js +8 -1
  10. package/src/create/generator.js +101 -32
  11. package/src/create/index.mjs +7 -0
  12. package/src/create/plugins/authJwt.js +14 -8
  13. package/src/create/plugins/nextIntl.js +25 -17
  14. package/src/create/plugins/pluginSchema.js +26 -10
  15. package/src/create/plugins/sentry.js +9 -5
  16. package/src/mcp.mjs +10 -0
  17. package/templates/nextjs-app/_arch/flat/app/api/proxy/[...path]/route.ts +112 -0
  18. package/templates/nextjs-app/_arch/flat/app/layout.tsx +16 -0
  19. package/templates/nextjs-app/_arch/flat/components/common/PrefetchBoundary/index.tsx +35 -0
  20. package/templates/nextjs-app/_arch/flat/components/layouts/RootLayout.tsx +11 -0
  21. package/templates/nextjs-app/_arch/flat/components/providers/tanstack/QueryClientProvider.tsx +14 -0
  22. package/templates/nextjs-app/_arch/flat/lib/utils/getQueryClient.ts +14 -0
  23. package/templates/nextjs-app/_arch/flat/tsconfig.json +25 -0
  24. package/templates/nextjs-standalone/_arch/flat/app/api/proxy/[...path]/route.ts +112 -0
  25. package/templates/nextjs-standalone/_arch/flat/app/layout.tsx +16 -0
  26. package/templates/nextjs-standalone/_arch/flat/components/common/FallbackBoundary/index.tsx +89 -0
  27. package/templates/nextjs-standalone/_arch/flat/components/common/PrefetchBoundary/index.tsx +35 -0
  28. package/templates/nextjs-standalone/_arch/flat/components/layouts/RootLayout.tsx +11 -0
  29. package/templates/nextjs-standalone/_arch/flat/components/providers/GlobalProvider/index.tsx +23 -0
  30. package/templates/nextjs-standalone/_arch/flat/components/providers/index.tsx +1 -0
  31. package/templates/nextjs-standalone/_arch/flat/components/providers/tanstack/QueryClientProvider.tsx +14 -0
  32. package/templates/nextjs-standalone/_arch/flat/components/providers/tanstack/TanstackDevtoolsProvider.tsx +13 -0
  33. package/templates/nextjs-standalone/_arch/flat/components/providers/theme/ThemeProviders.tsx +12 -0
  34. package/templates/nextjs-standalone/_arch/flat/lib/api/apiTypes.ts +21 -0
  35. package/templates/nextjs-standalone/_arch/flat/lib/api/clientFetch.ts +40 -0
  36. package/templates/nextjs-standalone/_arch/flat/lib/api/error.ts +12 -0
  37. package/templates/nextjs-standalone/_arch/flat/lib/api/http.ts +13 -0
  38. package/templates/nextjs-standalone/_arch/flat/lib/api/observability.ts +20 -0
  39. package/templates/nextjs-standalone/_arch/flat/lib/api/queryClient.ts +30 -0
  40. package/templates/nextjs-standalone/_arch/flat/lib/api/serverFetch.ts +59 -0
  41. package/templates/nextjs-standalone/_arch/flat/lib/hooks/useAppMutation.ts +52 -0
  42. package/templates/nextjs-standalone/_arch/flat/lib/test/createTestQueryClient.ts +18 -0
  43. package/templates/nextjs-standalone/_arch/flat/lib/test/index.ts +2 -0
  44. package/templates/nextjs-standalone/_arch/flat/lib/test/renderWithProviders.tsx +40 -0
  45. package/templates/nextjs-standalone/_arch/flat/lib/utils/formatDate.ts +22 -0
  46. package/templates/nextjs-standalone/_arch/flat/lib/utils/formatPrice.ts +10 -0
  47. package/templates/nextjs-standalone/_arch/flat/lib/utils/getQueryClient.ts +14 -0
  48. package/templates/nextjs-standalone/_arch/flat/sh-ui.config.json +19 -0
  49. package/templates/nextjs-standalone/_arch/flat/tsconfig.json +41 -0
  50. package/templates/nextjs-standalone/_arch/fsd/src/app/providers/GlobalProvider/index.tsx +23 -0
  51. package/templates/nextjs-standalone/_arch/fsd/src/app/providers/index.tsx +1 -0
  52. package/templates/nextjs-standalone/_arch/fsd/src/app/providers/tanstack/TanstackDevtoolsProvider.tsx +13 -0
  53. package/templates/nextjs-standalone/_arch/fsd/src/app/providers/theme/ThemeProviders.tsx +12 -0
  54. package/templates/nextjs-standalone/_arch/fsd/src/entities/.gitkeep +0 -0
  55. package/templates/nextjs-standalone/_arch/fsd/src/features/.gitkeep +0 -0
  56. package/templates/nextjs-standalone/_arch/fsd/src/shared/api/.gitkeep +0 -0
  57. package/templates/nextjs-standalone/_arch/fsd/src/shared/api/apiTypes.ts +21 -0
  58. package/templates/nextjs-standalone/_arch/fsd/src/shared/api/clientFetch.ts +40 -0
  59. package/templates/nextjs-standalone/_arch/fsd/src/shared/api/error.ts +12 -0
  60. package/templates/nextjs-standalone/_arch/fsd/src/shared/api/http.ts +13 -0
  61. package/templates/nextjs-standalone/_arch/fsd/src/shared/api/observability.ts +20 -0
  62. package/templates/nextjs-standalone/_arch/fsd/src/shared/api/queryClient.ts +30 -0
  63. package/templates/nextjs-standalone/_arch/fsd/src/shared/api/serverFetch.ts +59 -0
  64. package/templates/nextjs-standalone/_arch/fsd/src/shared/config/.gitkeep +0 -0
  65. package/templates/nextjs-standalone/_arch/fsd/src/shared/hooks/.gitkeep +0 -0
  66. package/templates/nextjs-standalone/_arch/fsd/src/shared/hooks/useAppMutation.ts +52 -0
  67. package/templates/nextjs-standalone/_arch/fsd/src/shared/lib/formatDate.ts +22 -0
  68. package/templates/nextjs-standalone/_arch/fsd/src/shared/lib/formatPrice.ts +10 -0
  69. package/templates/nextjs-standalone/_arch/fsd/src/shared/lib/utils.ts +6 -0
  70. package/templates/nextjs-standalone/_arch/fsd/src/shared/model/.gitkeep +0 -0
  71. package/templates/nextjs-standalone/_arch/fsd/src/shared/styles/tokens.css +135 -0
  72. package/templates/nextjs-standalone/_arch/fsd/src/shared/test/createTestQueryClient.ts +18 -0
  73. package/templates/nextjs-standalone/_arch/fsd/src/shared/test/index.ts +2 -0
  74. package/templates/nextjs-standalone/_arch/fsd/src/shared/test/renderWithProviders.tsx +40 -0
  75. package/templates/nextjs-standalone/_arch/fsd/src/shared/ui/.gitkeep +0 -0
  76. package/templates/nextjs-standalone/_arch/fsd/src/shared/ui/FallbackBoundary/index.tsx +89 -0
  77. package/templates/nextjs-standalone/_arch/fsd/src/views/.gitkeep +0 -0
  78. package/templates/nextjs-standalone/_arch/fsd/src/widgets/.gitkeep +0 -0
  79. /package/templates/nextjs-app/{src/entities → _arch/flat/components/common}/.gitkeep +0 -0
  80. /package/templates/nextjs-app/{src/shared/ui → _arch/flat/components/common}/FallbackBoundary/index.tsx +0 -0
  81. /package/templates/nextjs-app/{src/app → _arch/flat/components}/providers/GlobalProvider/index.tsx +0 -0
  82. /package/templates/nextjs-app/{src/app → _arch/flat/components}/providers/index.tsx +0 -0
  83. /package/templates/nextjs-app/{src/app → _arch/flat/components}/providers/tanstack/TanstackDevtoolsProvider.tsx +0 -0
  84. /package/templates/nextjs-app/{src/app → _arch/flat/components}/providers/theme/ThemeProviders.tsx +0 -0
  85. /package/templates/nextjs-app/{src/features → _arch/flat/lib/api}/.gitkeep +0 -0
  86. /package/templates/nextjs-app/{src/shared → _arch/flat/lib}/api/apiTypes.ts +0 -0
  87. /package/templates/nextjs-app/{src/shared → _arch/flat/lib}/api/clientFetch.ts +0 -0
  88. /package/templates/nextjs-app/{src/shared → _arch/flat/lib}/api/error.ts +0 -0
  89. /package/templates/nextjs-app/{src/shared → _arch/flat/lib}/api/http.ts +0 -0
  90. /package/templates/nextjs-app/{src/shared → _arch/flat/lib}/api/observability.ts +0 -0
  91. /package/templates/nextjs-app/{src/shared → _arch/flat/lib}/api/queryClient.ts +0 -0
  92. /package/templates/nextjs-app/{src/shared → _arch/flat/lib}/api/serverFetch.ts +0 -0
  93. /package/templates/nextjs-app/{src/shared/api → _arch/flat/lib/config}/.gitkeep +0 -0
  94. /package/templates/nextjs-app/{src/shared/config → _arch/flat/lib/hooks}/.gitkeep +0 -0
  95. /package/templates/nextjs-app/{src/shared → _arch/flat/lib}/hooks/useAppMutation.ts +0 -0
  96. /package/templates/nextjs-app/{src/shared → _arch/flat/lib}/test/createTestQueryClient.ts +0 -0
  97. /package/templates/nextjs-app/{src/shared → _arch/flat/lib}/test/index.ts +0 -0
  98. /package/templates/nextjs-app/{src/shared → _arch/flat/lib}/test/renderWithProviders.tsx +0 -0
  99. /package/templates/nextjs-app/{src/shared/hooks → _arch/flat/lib/utils}/.gitkeep +0 -0
  100. /package/templates/nextjs-app/{src/shared/lib → _arch/flat/lib/utils}/formatDate.ts +0 -0
  101. /package/templates/nextjs-app/{src/shared/lib → _arch/flat/lib/utils}/formatPrice.ts +0 -0
  102. /package/templates/nextjs-app/{app → _arch/fsd/app}/api/proxy/[...path]/route.ts +0 -0
  103. /package/templates/nextjs-app/{app → _arch/fsd/app}/layout.tsx +0 -0
  104. /package/templates/nextjs-app/{src → _arch/fsd/src}/app/layouts/RootLayout.tsx +0 -0
  105. /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/app/providers/GlobalProvider/index.tsx +0 -0
  106. /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/app/providers/index.tsx +0 -0
  107. /package/templates/nextjs-app/{src → _arch/fsd/src}/app/providers/tanstack/QueryClientProvider.tsx +0 -0
  108. /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/app/providers/tanstack/TanstackDevtoolsProvider.tsx +0 -0
  109. /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/app/providers/theme/ThemeProviders.tsx +0 -0
  110. /package/templates/nextjs-app/{src/shared/lib → _arch/fsd/src/entities}/.gitkeep +0 -0
  111. /package/templates/nextjs-app/{src/shared/model → _arch/fsd/src/features}/.gitkeep +0 -0
  112. /package/templates/nextjs-app/{src/shared/ui → _arch/fsd/src/shared/api}/.gitkeep +0 -0
  113. /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/api/apiTypes.ts +0 -0
  114. /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/api/clientFetch.ts +0 -0
  115. /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/api/error.ts +0 -0
  116. /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/api/http.ts +0 -0
  117. /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/api/observability.ts +0 -0
  118. /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/api/queryClient.ts +0 -0
  119. /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/api/serverFetch.ts +0 -0
  120. /package/templates/nextjs-app/{src/views → _arch/fsd/src/shared/config}/.gitkeep +0 -0
  121. /package/templates/nextjs-app/{src/widgets → _arch/fsd/src/shared/hooks}/.gitkeep +0 -0
  122. /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/hooks/useAppMutation.ts +0 -0
  123. /package/templates/{nextjs-standalone/src/entities → nextjs-app/_arch/fsd/src/shared/lib}/.gitkeep +0 -0
  124. /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/lib/formatDate.ts +0 -0
  125. /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/lib/formatPrice.ts +0 -0
  126. /package/templates/nextjs-app/{src → _arch/fsd/src}/shared/lib/getQueryClient.ts +0 -0
  127. /package/templates/{nextjs-standalone/src/features → nextjs-app/_arch/fsd/src/shared/model}/.gitkeep +0 -0
  128. /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/test/createTestQueryClient.ts +0 -0
  129. /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/test/index.ts +0 -0
  130. /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/test/renderWithProviders.tsx +0 -0
  131. /package/templates/{nextjs-standalone/src/shared/api → nextjs-app/_arch/fsd/src/shared/ui}/.gitkeep +0 -0
  132. /package/templates/{nextjs-standalone → nextjs-app/_arch/fsd}/src/shared/ui/FallbackBoundary/index.tsx +0 -0
  133. /package/templates/nextjs-app/{src → _arch/fsd/src}/shared/ui/PrefetchBoundary/index.tsx +0 -0
  134. /package/templates/{nextjs-standalone/src/shared/config → nextjs-app/_arch/fsd/src/views}/.gitkeep +0 -0
  135. /package/templates/{nextjs-standalone/src/shared/hooks → nextjs-app/_arch/fsd/src/widgets}/.gitkeep +0 -0
  136. /package/templates/nextjs-app/{tsconfig.json → _arch/fsd/tsconfig.json} +0 -0
  137. /package/templates/nextjs-standalone/{src/shared/model → _arch/flat/components/common}/.gitkeep +0 -0
  138. /package/templates/nextjs-standalone/{src/shared/ui → _arch/flat/lib/api}/.gitkeep +0 -0
  139. /package/templates/nextjs-standalone/{src/views → _arch/flat/lib/config}/.gitkeep +0 -0
  140. /package/templates/nextjs-standalone/{src/widgets → _arch/flat/lib/hooks}/.gitkeep +0 -0
  141. /package/templates/nextjs-standalone/{src/shared → _arch/flat/lib}/styles/tokens.css +0 -0
  142. /package/templates/nextjs-standalone/{src/shared/lib → _arch/flat/lib/utils}/utils.ts +0 -0
  143. /package/templates/nextjs-standalone/{app → _arch/fsd/app}/api/proxy/[...path]/route.ts +0 -0
  144. /package/templates/nextjs-standalone/{app → _arch/fsd/app}/layout.tsx +0 -0
  145. /package/templates/nextjs-standalone/{sh-ui.config.json → _arch/fsd/sh-ui.config.json} +0 -0
  146. /package/templates/nextjs-standalone/{src → _arch/fsd/src}/app/layouts/RootLayout.tsx +0 -0
  147. /package/templates/nextjs-standalone/{src → _arch/fsd/src}/app/providers/tanstack/QueryClientProvider.tsx +0 -0
  148. /package/templates/nextjs-standalone/{src → _arch/fsd/src}/shared/lib/getQueryClient.ts +0 -0
  149. /package/templates/nextjs-standalone/{src → _arch/fsd/src}/shared/ui/PrefetchBoundary/index.tsx +0 -0
  150. /package/templates/nextjs-standalone/{tsconfig.json → _arch/fsd/tsconfig.json} +0 -0
@@ -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
+ }
@@ -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
+ }
@@ -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);
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from 'clsx';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -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,2 @@
1
+ export { createTestQueryClient } from './createTestQueryClient';
2
+ export { renderWithProviders } from './renderWithProviders';
@@ -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
+ };
@@ -0,0 +1,89 @@
1
+ 'use client';
2
+
3
+ import {
4
+ Component,
5
+ type ComponentType,
6
+ type ErrorInfo,
7
+ type ReactNode,
8
+ Suspense,
9
+ } from 'react';
10
+ import { QueryErrorResetBoundary } from '@tanstack/react-query';
11
+
12
+ export type ErrorFallbackProps = {
13
+ error: Error | null;
14
+ resetErrorBoundary: () => void;
15
+ };
16
+
17
+ type ErrorBoundaryProps = {
18
+ children: ReactNode;
19
+ fallback?: ComponentType<ErrorFallbackProps>;
20
+ onReset: () => void;
21
+ onError?: (error: Error, info: ErrorInfo) => void;
22
+ };
23
+
24
+ type ErrorBoundaryState = {
25
+ hasError: boolean;
26
+ error: Error | null;
27
+ };
28
+
29
+ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
30
+ state: ErrorBoundaryState = { hasError: false, error: null };
31
+
32
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
33
+ return { hasError: true, error };
34
+ }
35
+
36
+ componentDidCatch(error: Error, info: ErrorInfo) {
37
+ this.props.onError?.(error, info);
38
+ }
39
+
40
+ resetErrorBoundary = () => {
41
+ this.props.onReset();
42
+ this.setState({ hasError: false, error: null });
43
+ };
44
+
45
+ render() {
46
+ const { hasError, error } = this.state;
47
+ const { children, fallback: Fallback } = this.props;
48
+
49
+ if (hasError && Fallback) {
50
+ return (
51
+ <Fallback error={error} resetErrorBoundary={this.resetErrorBoundary} />
52
+ );
53
+ }
54
+
55
+ return children;
56
+ }
57
+ }
58
+
59
+ type FallbackBoundaryProps = {
60
+ children: ReactNode;
61
+ errorFallback?: ComponentType<ErrorFallbackProps>;
62
+ suspenseFallback?: ReactNode;
63
+ onError?: (error: Error, info: ErrorInfo) => void;
64
+ };
65
+
66
+ /**
67
+ * Suspense + ErrorBoundary 합성. React Query 의 reset 신호에 맞춰
68
+ * `errorFallback` 의 `resetErrorBoundary` 가 쿼리까지 함께 리셋한다.
69
+ */
70
+ export function FallbackBoundary({
71
+ children,
72
+ errorFallback,
73
+ suspenseFallback,
74
+ onError,
75
+ }: FallbackBoundaryProps) {
76
+ return (
77
+ <QueryErrorResetBoundary>
78
+ {({ reset }) => (
79
+ <ErrorBoundary
80
+ onReset={reset}
81
+ fallback={errorFallback}
82
+ onError={onError}
83
+ >
84
+ <Suspense fallback={suspenseFallback}>{children}</Suspense>
85
+ </ErrorBoundary>
86
+ )}
87
+ </QueryErrorResetBoundary>
88
+ );
89
+ }