sh-ui-cli 0.59.9 → 0.61.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 +53 -0
- package/data/registry/flutter/widgets/sh_ui_input.dart +0 -79
- package/data/registry/react/components/input/index.module.tsx +0 -70
- package/data/registry/react/components/input/index.tailwind.tsx +0 -53
- package/data/registry/react/components/input/index.tsx +0 -70
- package/data/registry/react/components/input/index.vanilla-extract.tsx +0 -63
- package/data/summaries/react.json +1 -1
- package/package.json +2 -2
- package/src/create/architectures/flat.js +1 -1
- package/src/create/generator.js +717 -26
- package/src/create/index.mjs +1 -1
- package/src/create/plugins/authJwt.js +51 -1
- package/src/create/plugins/nextIntl.js +163 -17
- package/src/create/plugins/sentry.js +43 -23
- package/src/mcp.mjs +2 -2
- package/src/rename-app.mjs +3 -3
- package/templates/flutter-standalone/sh-ui.config.json +0 -1
- package/templates/monorepo/README.md +14 -5
- package/templates/monorepo/packages/eslint-config/flat.js +71 -0
- package/templates/monorepo/packages/eslint-config/fsd.js +0 -21
- package/templates/monorepo/packages/eslint-config/package.json +2 -3
- package/templates/monorepo/packages/typescript-config/package.json +6 -1
- package/templates/monorepo/packages/ui/ui-core/tsconfig.json +1 -1
- package/templates/monorepo/pnpm-workspace.yaml +2 -1
- package/templates/nextjs-app/.env.example +3 -2
- package/templates/nextjs-app/README.md +9 -9
- package/templates/nextjs-app/_arch/flat/app/api/proxy/[...path]/route.ts +1 -1
- package/templates/nextjs-app/_arch/flat/app/layout.tsx +2 -2
- package/templates/nextjs-app/_arch/flat/components/common/PrefetchBoundary/index.tsx +1 -1
- package/templates/nextjs-app/_arch/flat/components/layouts/RootLayout.tsx +2 -0
- package/templates/nextjs-app/_arch/flat/components/providers/GlobalProvider/index.tsx +3 -3
- package/templates/nextjs-app/_arch/flat/components/providers/theme/ThemeProvider.tsx +12 -0
- package/templates/nextjs-app/_arch/flat/eslint.config.js +10 -0
- package/templates/nextjs-app/_arch/flat/lib/api/clientFetch.ts +4 -4
- package/templates/nextjs-app/_arch/flat/lib/api/errorMessages.ts +37 -0
- package/templates/nextjs-app/_arch/flat/lib/hooks/useAppMutation.ts +14 -7
- package/templates/nextjs-app/_arch/flat/lib/test/renderWithProviders.tsx +32 -7
- package/templates/nextjs-app/_arch/flat/lib/utils/formatDate.ts +4 -0
- package/templates/nextjs-app/_arch/flat/lib/utils/formatPrice.ts +13 -5
- package/templates/nextjs-app/_arch/flat/lib/utils/getQueryClient.ts +1 -1
- package/templates/nextjs-app/_arch/flat/tsconfig.json +0 -1
- package/templates/nextjs-app/_arch/fsd/app/api/proxy/[...path]/route.ts +1 -1
- package/templates/nextjs-app/_arch/fsd/app/layout.tsx +2 -2
- package/templates/nextjs-app/_arch/fsd/src/app/layouts/RootLayout.tsx +2 -0
- package/templates/nextjs-app/_arch/fsd/src/app/providers/GlobalProvider/index.tsx +3 -3
- package/templates/nextjs-app/_arch/fsd/src/app/providers/theme/ThemeProvider.tsx +12 -0
- package/templates/nextjs-app/_arch/fsd/src/shared/api/clientFetch.ts +4 -4
- package/templates/nextjs-app/_arch/fsd/src/shared/api/errorMessages.ts +37 -0
- package/templates/nextjs-app/_arch/fsd/src/shared/hooks/useAppMutation.ts +14 -7
- package/templates/nextjs-app/_arch/fsd/src/shared/lib/formatDate.ts +4 -0
- package/templates/nextjs-app/_arch/fsd/src/shared/lib/formatPrice.ts +13 -5
- package/templates/nextjs-app/_arch/fsd/src/shared/lib/getQueryClient.ts +1 -1
- package/templates/nextjs-app/_arch/fsd/src/shared/test/renderWithProviders.tsx +32 -7
- package/templates/nextjs-app/_arch/fsd/src/shared/ui/PrefetchBoundary/index.tsx +1 -1
- package/templates/nextjs-app/vitest.config.ts +4 -0
- package/templates/nextjs-standalone/.env.example +3 -2
- package/templates/nextjs-standalone/_arch/flat/app/api/proxy/[...path]/route.ts +1 -1
- package/templates/nextjs-standalone/_arch/flat/app/layout.tsx +2 -2
- package/templates/nextjs-standalone/_arch/flat/components/common/PrefetchBoundary/index.tsx +1 -1
- package/templates/nextjs-standalone/_arch/flat/components/layouts/RootLayout.tsx +2 -0
- package/templates/nextjs-standalone/_arch/flat/components/providers/GlobalProvider/index.tsx +3 -3
- package/templates/nextjs-standalone/_arch/flat/components/providers/theme/ThemeProvider.tsx +12 -0
- package/templates/nextjs-standalone/_arch/flat/eslint.config.js +123 -0
- package/templates/nextjs-standalone/_arch/flat/lib/api/clientFetch.ts +4 -4
- package/templates/nextjs-standalone/_arch/flat/lib/api/errorMessages.ts +37 -0
- package/templates/nextjs-standalone/_arch/flat/lib/hooks/useAppMutation.ts +14 -7
- package/templates/nextjs-standalone/_arch/flat/lib/test/renderWithProviders.tsx +32 -7
- package/templates/nextjs-standalone/_arch/flat/lib/utils/formatDate.ts +4 -0
- package/templates/nextjs-standalone/_arch/flat/lib/utils/formatPrice.ts +13 -5
- package/templates/nextjs-standalone/_arch/flat/lib/utils/getQueryClient.ts +1 -1
- package/templates/nextjs-standalone/_arch/flat/tsconfig.json +1 -2
- package/templates/nextjs-standalone/_arch/fsd/app/api/proxy/[...path]/route.ts +1 -1
- package/templates/nextjs-standalone/_arch/fsd/app/layout.tsx +2 -2
- package/templates/nextjs-standalone/_arch/fsd/src/app/layouts/RootLayout.tsx +2 -0
- package/templates/nextjs-standalone/_arch/fsd/src/app/providers/GlobalProvider/index.tsx +3 -3
- package/templates/nextjs-standalone/_arch/fsd/src/app/providers/theme/ThemeProvider.tsx +12 -0
- package/templates/nextjs-standalone/_arch/fsd/src/shared/api/clientFetch.ts +4 -4
- package/templates/nextjs-standalone/_arch/fsd/src/shared/api/errorMessages.ts +37 -0
- package/templates/nextjs-standalone/_arch/fsd/src/shared/hooks/useAppMutation.ts +14 -7
- package/templates/nextjs-standalone/_arch/fsd/src/shared/lib/formatDate.ts +4 -0
- package/templates/nextjs-standalone/_arch/fsd/src/shared/lib/formatPrice.ts +13 -5
- package/templates/nextjs-standalone/_arch/fsd/src/shared/lib/getQueryClient.ts +1 -1
- package/templates/nextjs-standalone/_arch/fsd/src/shared/test/renderWithProviders.tsx +32 -7
- package/templates/nextjs-standalone/_arch/fsd/src/shared/ui/PrefetchBoundary/index.tsx +1 -1
- package/templates/nextjs-standalone/eslint.config.js +0 -15
- package/templates/nextjs-standalone/package.json +0 -2
- package/templates/ui-app-template/package.json +2 -2
- package/templates/ui-app-template/postcss.config.mjs +1 -1
- package/templates/monorepo/.eslintrc.js +0 -8
- package/templates/nextjs-app/Dockerfile +0 -11
- package/templates/nextjs-app/_arch/flat/components/providers/theme/ThemeProviders.tsx +0 -12
- package/templates/nextjs-app/_arch/fsd/src/app/providers/theme/ThemeProviders.tsx +0 -12
- package/templates/nextjs-standalone/_arch/flat/components/providers/theme/ThemeProviders.tsx +0 -12
- package/templates/nextjs-standalone/_arch/fsd/src/app/providers/theme/ThemeProviders.tsx +0 -12
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
# API
|
|
2
|
-
|
|
1
|
+
# API — serverFetch 가 prefix 없이 그대로 fetch.
|
|
2
|
+
# 백엔드의 base path 까지 포함해서 적어 주세요. 예: http://localhost:8080/api
|
|
3
|
+
API_URL=http://localhost:8080/api
|
|
@@ -9,22 +9,23 @@ UI 컴포넌트는 `@workspace/ui-{name}` 패키지를 참조하며, sh-ui 설
|
|
|
9
9
|
- **React 19**
|
|
10
10
|
- **TypeScript 5.9**
|
|
11
11
|
- **@workspace/ui-{name}** (sh-ui 컴포넌트 패키지)
|
|
12
|
-
- **TanStack React Query**
|
|
13
|
-
- **Zustand**
|
|
12
|
+
- **TanStack React Query** (server state, isomorphic fetch — Axios 미사용)
|
|
13
|
+
- **Zustand** (client state)
|
|
14
14
|
- **next-themes** + **Sonner**
|
|
15
15
|
- **Zod**
|
|
16
16
|
- **Vitest** + **Testing Library**
|
|
17
|
-
- **Docker** 지원
|
|
18
17
|
|
|
19
18
|
## 프로젝트 구조
|
|
20
19
|
|
|
21
20
|
```
|
|
22
21
|
├── app/ # Next.js App Router
|
|
23
22
|
│ ├── layout.tsx # 루트 레이아웃 (@workspace/ui-{name}/globals.css import)
|
|
24
|
-
│
|
|
25
|
-
|
|
23
|
+
│ │ # next-intl 활성 시 app/[locale]/layout.tsx 로 이동
|
|
24
|
+
│ └── page.tsx # next-intl 활성 시 app/[locale]/page.tsx 로 이동
|
|
25
|
+
├── src/ # FSD 아키텍처 — flat 선택 시 `lib/` + `components/` 로 분기
|
|
26
26
|
│ ├── app/
|
|
27
|
-
│ │
|
|
27
|
+
│ │ ├── providers/ # QueryClient, Theme, Toaster (Sentry 시 FallbackBoundary)
|
|
28
|
+
│ │ └── layouts/ # RootLayout (html/body)
|
|
28
29
|
│ ├── shared/ # FSD: 공유 유틸, 설정, 타입
|
|
29
30
|
│ ├── entities/
|
|
30
31
|
│ ├── features/
|
|
@@ -35,7 +36,6 @@ UI 컴포넌트는 `@workspace/ui-{name}` 패키지를 참조하며, sh-ui 설
|
|
|
35
36
|
├── postcss.config.mjs # @workspace/ui-{name}/postcss.config 재사용
|
|
36
37
|
├── next.config.ts
|
|
37
38
|
├── vitest.config.ts
|
|
38
|
-
├── Dockerfile
|
|
39
39
|
└── .env.example
|
|
40
40
|
```
|
|
41
41
|
|
|
@@ -55,10 +55,10 @@ apps/{name}/
|
|
|
55
55
|
|
|
56
56
|
```bash
|
|
57
57
|
# 모든 ui 패키지에 추가 (대화형)
|
|
58
|
-
npx sh-ui-create add-component button
|
|
58
|
+
npx sh-ui-cli create add-component button
|
|
59
59
|
|
|
60
60
|
# 이 앱의 ui 패키지에만 추가
|
|
61
|
-
npx sh-ui-create add-component button --app {name}
|
|
61
|
+
npx sh-ui-cli create add-component button --app {name}
|
|
62
62
|
```
|
|
63
63
|
|
|
64
64
|
각 `ui-{app}/` 패키지의 `sh-ui.config.json` 경로 설정에 따라 `src/components/` 로 복사됨.
|
|
@@ -3,8 +3,8 @@ import '@workspace/ui-app-name/globals.css';
|
|
|
3
3
|
import { RootLayout } from '@/components/layouts/RootLayout';
|
|
4
4
|
|
|
5
5
|
export const metadata: Metadata = {
|
|
6
|
-
title: '
|
|
7
|
-
description: '
|
|
6
|
+
title: 'sh-ui app',
|
|
7
|
+
description: 'sh-ui 기반 앱 — metadata 를 변경하세요.',
|
|
8
8
|
};
|
|
9
9
|
|
|
10
10
|
export default function Layout({
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
HydrationBoundary,
|
|
7
7
|
} from '@tanstack/react-query';
|
|
8
8
|
|
|
9
|
-
import getQueryClient from '@/lib/utils/getQueryClient';
|
|
9
|
+
import { getQueryClient } from '@/lib/utils/getQueryClient';
|
|
10
10
|
|
|
11
11
|
export type FetchOptions = Pick<FetchQueryOptions, 'queryKey' | 'queryFn'>;
|
|
12
12
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { GlobalProvider } from '@/components/providers';
|
|
2
2
|
|
|
3
3
|
export function RootLayout({ children }: { children: React.ReactNode }) {
|
|
4
|
+
// `lang` 은 앱의 주 언어. 영어 등 다른 언어 우선이면 'en' 으로 바꾸거나
|
|
5
|
+
// next-intl 플러그인을 활성화해 라우트 기반 자동 분기로 전환하세요.
|
|
4
6
|
return (
|
|
5
7
|
<html lang='ko' suppressHydrationWarning>
|
|
6
8
|
<body>
|
|
@@ -3,7 +3,7 @@ import { Toaster } from 'sonner';
|
|
|
3
3
|
|
|
4
4
|
import { QueryClientProvider } from '../tanstack/QueryClientProvider';
|
|
5
5
|
import { TanstackDevtoolsProvider } from '../tanstack/TanstackDevtoolsProvider';
|
|
6
|
-
import {
|
|
6
|
+
import { ThemeProvider } from '../theme/ThemeProvider';
|
|
7
7
|
|
|
8
8
|
interface GlobalProviderProps {
|
|
9
9
|
children: ReactNode;
|
|
@@ -11,13 +11,13 @@ interface GlobalProviderProps {
|
|
|
11
11
|
|
|
12
12
|
export function GlobalProvider({ children }: GlobalProviderProps) {
|
|
13
13
|
return (
|
|
14
|
-
<
|
|
14
|
+
<ThemeProvider>
|
|
15
15
|
<QueryClientProvider>
|
|
16
16
|
<TanstackDevtoolsProvider>
|
|
17
17
|
<Toaster />
|
|
18
18
|
{children}
|
|
19
19
|
</TanstackDevtoolsProvider>
|
|
20
20
|
</QueryClientProvider>
|
|
21
|
-
</
|
|
21
|
+
</ThemeProvider>
|
|
22
22
|
);
|
|
23
23
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
export function ThemeProvider({ children }: { children: ReactNode }) {
|
|
7
|
+
return (
|
|
8
|
+
<NextThemesProvider attribute='class' defaultTheme='system' enableSystem>
|
|
9
|
+
{children}
|
|
10
|
+
</NextThemesProvider>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -23,10 +23,10 @@ export async function clientFetch<T>(
|
|
|
23
23
|
const body = (await res.json()) as ApiResponse<T>;
|
|
24
24
|
|
|
25
25
|
if (res.status === 401) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
// 이미 sign-in 페이지면 redirect 루프 방지.
|
|
27
|
+
// next-intl 활성 시 path 가 `/ko/sign-in` 형태가 되므로 regex 로 match.
|
|
28
|
+
if (typeof window !== 'undefined' && !/\/sign-in(\/|$)/.test(window.location.pathname)) {
|
|
29
|
+
// /sign-in 으로만 보내면 middleware (next-intl 활성 시) 가 default locale 을 prefix 추가.
|
|
30
30
|
window.location.href = '/sign-in';
|
|
31
31
|
}
|
|
32
32
|
throw new ApiError(401, body.error?.code ?? 'UNAUTHORIZED', body.error);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ApiError } from './error';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 에러 코드 → 사용자 facing 메시지 mapping.
|
|
5
|
+
*
|
|
6
|
+
* 백엔드가 보내는 raw `error.message` 를 그대로 toast 로 띄우면 i18n 어긋나거나
|
|
7
|
+
* 내부 노출이 될 수 있어, frontend 가 정의한 안전한 메시지로 변환하는 게 권장.
|
|
8
|
+
* 코드별로 명시 안 된 경우 `error.data.message` (backend 메시지) → fallback.
|
|
9
|
+
*
|
|
10
|
+
* 사용자 프로젝트에서 자유롭게 추가/수정. next-intl 활성 시엔 `useTranslations`
|
|
11
|
+
* 와 결합해 hook 형태로 변환 가능 (이 파일은 RSC/CSR 양쪽 호환을 위해 module).
|
|
12
|
+
*/
|
|
13
|
+
export const ERROR_MESSAGES: Record<string, string> = {
|
|
14
|
+
UNAUTHORIZED: '로그인이 필요합니다.',
|
|
15
|
+
FORBIDDEN: '접근 권한이 없습니다.',
|
|
16
|
+
NOT_FOUND: '요청한 리소스를 찾을 수 없습니다.',
|
|
17
|
+
NETWORK_ERROR: '서버에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.',
|
|
18
|
+
// 사용자 정의 코드를 여기에 추가하세요.
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const DEFAULT_FALLBACK = '일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 에러 → 사용자 facing 메시지 결정.
|
|
25
|
+
* 1. ApiError + 코드가 ERROR_MESSAGES 에 있으면 그 메시지
|
|
26
|
+
* 2. ApiError 의 backend `data.message` (있으면)
|
|
27
|
+
* 3. 호출자의 fallback
|
|
28
|
+
* 4. 글로벌 DEFAULT_FALLBACK
|
|
29
|
+
*/
|
|
30
|
+
export function resolveErrorMessage(error: unknown, fallback?: string): string {
|
|
31
|
+
if (error instanceof ApiError) {
|
|
32
|
+
const mapped = ERROR_MESSAGES[error.code];
|
|
33
|
+
if (mapped) return mapped;
|
|
34
|
+
if (error.data?.message) return error.data.message;
|
|
35
|
+
}
|
|
36
|
+
return fallback ?? DEFAULT_FALLBACK;
|
|
37
|
+
}
|
|
@@ -5,7 +5,8 @@ import {
|
|
|
5
5
|
} from '@tanstack/react-query';
|
|
6
6
|
import { toast } from 'sonner';
|
|
7
7
|
|
|
8
|
-
import { ApiError } from '
|
|
8
|
+
import { ApiError } from '@/lib/api/error';
|
|
9
|
+
import { resolveErrorMessage } from '@/lib/api/errorMessages';
|
|
9
10
|
|
|
10
11
|
type AppMutationOptions<
|
|
11
12
|
TData = unknown,
|
|
@@ -13,13 +14,19 @@ type AppMutationOptions<
|
|
|
13
14
|
TVariables = void,
|
|
14
15
|
TContext = unknown,
|
|
15
16
|
> = UseMutationOptions<TData, TError, TVariables, TContext> & {
|
|
17
|
+
/** ApiError 가 아니거나 mapping 에 없을 때의 fallback 메시지. */
|
|
16
18
|
errorMessage?: string;
|
|
19
|
+
/** false 면 toast 띄우지 않음. */
|
|
17
20
|
showErrorToast?: boolean;
|
|
18
21
|
};
|
|
19
22
|
|
|
20
23
|
/**
|
|
21
|
-
* useMutation 래퍼 — 에러 발생 시
|
|
22
|
-
*
|
|
24
|
+
* useMutation 래퍼 — 에러 발생 시 `resolveErrorMessage` 를 통해 안전한
|
|
25
|
+
* 사용자 facing 메시지를 toast 로 띄운다. backend 가 보낸 raw 메시지를 그대로
|
|
26
|
+
* 띄우지 않고 `errorMessages.ts` 의 mapping 을 우선 사용해 일관된 사용자
|
|
27
|
+
* 경험과 i18n 친화성을 확보.
|
|
28
|
+
*
|
|
29
|
+
* showErrorToast: false 로 자동 toast 끌 수 있고, errorMessage 로 fallback 지정.
|
|
23
30
|
*/
|
|
24
31
|
export const useAppMutation = <
|
|
25
32
|
TData = unknown,
|
|
@@ -39,10 +46,7 @@ export const useAppMutation = <
|
|
|
39
46
|
if (!showErrorToast) return;
|
|
40
47
|
|
|
41
48
|
const [error] = args;
|
|
42
|
-
const message =
|
|
43
|
-
error instanceof ApiError
|
|
44
|
-
? (error.data?.message ?? errorMessage)
|
|
45
|
-
: errorMessage;
|
|
49
|
+
const message = resolveErrorMessage(error, errorMessage);
|
|
46
50
|
|
|
47
51
|
if (message) {
|
|
48
52
|
toast.error(message);
|
|
@@ -50,3 +54,6 @@ export const useAppMutation = <
|
|
|
50
54
|
},
|
|
51
55
|
});
|
|
52
56
|
};
|
|
57
|
+
|
|
58
|
+
// re-export ApiError 타입을 쓰는 사용처 편의용 (선택).
|
|
59
|
+
export type { ApiError };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ReactElement, ReactNode } from 'react';
|
|
1
|
+
import type { ComponentType, ReactElement, ReactNode } from 'react';
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
render,
|
|
@@ -7,11 +7,17 @@ import {
|
|
|
7
7
|
} from '@testing-library/react';
|
|
8
8
|
import userEvent from '@testing-library/user-event';
|
|
9
9
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
10
|
+
import { ThemeProvider } from 'next-themes';
|
|
10
11
|
|
|
11
12
|
import { createTestQueryClient } from './createTestQueryClient';
|
|
12
13
|
|
|
13
14
|
type Options = Omit<RenderOptions, 'wrapper'> & {
|
|
14
15
|
queryClient?: QueryClient;
|
|
16
|
+
/**
|
|
17
|
+
* 외부 Provider 가 필요할 때 (예: next-intl 활성 프로젝트에서
|
|
18
|
+
* `NextIntlClientProvider`) 사용. ThemeProvider 안쪽 / QueryClientProvider 바깥에 wrap.
|
|
19
|
+
*/
|
|
20
|
+
extraWrapper?: ComponentType<{ children: ReactNode }>;
|
|
15
21
|
};
|
|
16
22
|
|
|
17
23
|
type Result = RenderResult & {
|
|
@@ -20,18 +26,37 @@ type Result = RenderResult & {
|
|
|
20
26
|
};
|
|
21
27
|
|
|
22
28
|
/**
|
|
23
|
-
* RTL render + QueryClientProvider + userEvent setup 한 번에.
|
|
24
|
-
*
|
|
29
|
+
* RTL render + ThemeProvider + QueryClientProvider + userEvent setup 한 번에.
|
|
30
|
+
*
|
|
31
|
+
* next-intl 프로젝트에서 `useTranslations` 사용 컴포넌트 테스트 시:
|
|
32
|
+
*
|
|
33
|
+
* const Intl = ({ children }) => (
|
|
34
|
+
* <NextIntlClientProvider locale='ko' messages={ko}>
|
|
35
|
+
* {children}
|
|
36
|
+
* </NextIntlClientProvider>
|
|
37
|
+
* );
|
|
38
|
+
* renderWithProviders(<MyComponent />, { extraWrapper: Intl });
|
|
25
39
|
*/
|
|
26
40
|
export const renderWithProviders = (
|
|
27
41
|
ui: ReactElement,
|
|
28
42
|
options: Options = {},
|
|
29
43
|
): Result => {
|
|
30
|
-
const {
|
|
44
|
+
const {
|
|
45
|
+
queryClient = createTestQueryClient(),
|
|
46
|
+
extraWrapper: Extra,
|
|
47
|
+
...rtlOptions
|
|
48
|
+
} = options;
|
|
31
49
|
|
|
32
|
-
const Wrapper = ({ children }: { children: ReactNode }) =>
|
|
33
|
-
|
|
34
|
-
|
|
50
|
+
const Wrapper = ({ children }: { children: ReactNode }) => {
|
|
51
|
+
const inner = (
|
|
52
|
+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
53
|
+
);
|
|
54
|
+
return (
|
|
55
|
+
<ThemeProvider attribute='class' defaultTheme='light' enableSystem={false}>
|
|
56
|
+
{Extra ? <Extra>{inner}</Extra> : inner}
|
|
57
|
+
</ThemeProvider>
|
|
58
|
+
);
|
|
59
|
+
};
|
|
35
60
|
|
|
36
61
|
const result = render(ui, { wrapper: Wrapper, ...rtlOptions });
|
|
37
62
|
const user = userEvent.setup();
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Date → 로케일 기반 날짜 포맷 (시간 없음).
|
|
3
|
+
*
|
|
4
|
+
* 비-i18n 프로젝트면 default 'ko-KR' 사용. next-intl 활성 시엔 이 util 을 직접
|
|
5
|
+
* 부르지 말고 같은 모듈의 hook (`useFormatDate`) 을 사용하세요 — 현재 locale 을
|
|
6
|
+
* 자동으로 따릅니다 (next-intl 플러그인이 emit).
|
|
3
7
|
*/
|
|
4
8
|
export const formatDate = (date: Date, locale = 'ko-KR'): string =>
|
|
5
9
|
new Intl.DateTimeFormat(locale, {
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 숫자 →
|
|
3
|
-
* 예: 12000
|
|
2
|
+
* 숫자 → 통화 포맷. default ko-KR + KRW 이지만 두 인자 모두 override 가능.
|
|
3
|
+
* 예: formatPrice(12000) → "₩12,000"
|
|
4
|
+
* formatPrice(99.5, 'en-US', 'USD') → "$99.50"
|
|
5
|
+
*
|
|
6
|
+
* next-intl 활성 시엔 같은 모듈의 hook (`useFormatPrice`) 을 사용하면 현재
|
|
7
|
+
* locale 을 자동으로 따릅니다 (next-intl 플러그인이 emit).
|
|
4
8
|
*/
|
|
5
|
-
export const formatPrice = (
|
|
9
|
+
export const formatPrice = (
|
|
10
|
+
amount: number,
|
|
11
|
+
locale = 'ko-KR',
|
|
12
|
+
currency = 'KRW',
|
|
13
|
+
): string =>
|
|
6
14
|
new Intl.NumberFormat(locale, {
|
|
7
15
|
style: 'currency',
|
|
8
|
-
currency
|
|
9
|
-
maximumFractionDigits: 0,
|
|
16
|
+
currency,
|
|
17
|
+
maximumFractionDigits: currency === 'KRW' ? 0 : 2,
|
|
10
18
|
}).format(amount);
|
|
@@ -9,6 +9,6 @@ import {
|
|
|
9
9
|
* RSC 와 클라이언트 양쪽에서 안전하게 호출 가능한 QueryClient 핸들.
|
|
10
10
|
* 서버에서는 React `cache()` 로 요청 단위 싱글턴, 브라우저에서는 모듈 싱글턴.
|
|
11
11
|
*/
|
|
12
|
-
export
|
|
12
|
+
export function getQueryClient() {
|
|
13
13
|
return isServer ? getServerQueryClient() : getBrowserQueryClient();
|
|
14
14
|
}
|
|
@@ -3,8 +3,8 @@ import '@workspace/ui-app-name/globals.css';
|
|
|
3
3
|
import { RootLayout } from '@/src/app/layouts/RootLayout';
|
|
4
4
|
|
|
5
5
|
export const metadata: Metadata = {
|
|
6
|
-
title: '
|
|
7
|
-
description: '
|
|
6
|
+
title: 'sh-ui app',
|
|
7
|
+
description: 'sh-ui 기반 앱 — metadata 를 변경하세요.',
|
|
8
8
|
};
|
|
9
9
|
|
|
10
10
|
export default function Layout({
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { GlobalProvider } from '@/src/app/providers';
|
|
2
2
|
|
|
3
3
|
export function RootLayout({ children }: { children: React.ReactNode }) {
|
|
4
|
+
// `lang` 은 앱의 주 언어. 영어 등 다른 언어 우선이면 'en' 으로 바꾸거나
|
|
5
|
+
// next-intl 플러그인을 활성화해 라우트 기반 자동 분기로 전환하세요.
|
|
4
6
|
return (
|
|
5
7
|
<html lang='ko' suppressHydrationWarning>
|
|
6
8
|
<body>
|
|
@@ -3,7 +3,7 @@ import { Toaster } from 'sonner';
|
|
|
3
3
|
|
|
4
4
|
import { QueryClientProvider } from '../tanstack/QueryClientProvider';
|
|
5
5
|
import { TanstackDevtoolsProvider } from '../tanstack/TanstackDevtoolsProvider';
|
|
6
|
-
import {
|
|
6
|
+
import { ThemeProvider } from '../theme/ThemeProvider';
|
|
7
7
|
|
|
8
8
|
interface GlobalProviderProps {
|
|
9
9
|
children: ReactNode;
|
|
@@ -11,13 +11,13 @@ interface GlobalProviderProps {
|
|
|
11
11
|
|
|
12
12
|
export function GlobalProvider({ children }: GlobalProviderProps) {
|
|
13
13
|
return (
|
|
14
|
-
<
|
|
14
|
+
<ThemeProvider>
|
|
15
15
|
<QueryClientProvider>
|
|
16
16
|
<TanstackDevtoolsProvider>
|
|
17
17
|
<Toaster />
|
|
18
18
|
{children}
|
|
19
19
|
</TanstackDevtoolsProvider>
|
|
20
20
|
</QueryClientProvider>
|
|
21
|
-
</
|
|
21
|
+
</ThemeProvider>
|
|
22
22
|
);
|
|
23
23
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
export function ThemeProvider({ children }: { children: ReactNode }) {
|
|
7
|
+
return (
|
|
8
|
+
<NextThemesProvider attribute='class' defaultTheme='system' enableSystem>
|
|
9
|
+
{children}
|
|
10
|
+
</NextThemesProvider>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -23,10 +23,10 @@ export async function clientFetch<T>(
|
|
|
23
23
|
const body = (await res.json()) as ApiResponse<T>;
|
|
24
24
|
|
|
25
25
|
if (res.status === 401) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
// 이미 sign-in 페이지면 redirect 루프 방지.
|
|
27
|
+
// next-intl 활성 시 path 가 `/ko/sign-in` 형태가 되므로 regex 로 match.
|
|
28
|
+
if (typeof window !== 'undefined' && !/\/sign-in(\/|$)/.test(window.location.pathname)) {
|
|
29
|
+
// /sign-in 으로만 보내면 middleware (next-intl 활성 시) 가 default locale 을 prefix 추가.
|
|
30
30
|
window.location.href = '/sign-in';
|
|
31
31
|
}
|
|
32
32
|
throw new ApiError(401, body.error?.code ?? 'UNAUTHORIZED', body.error);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ApiError } from './error';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 에러 코드 → 사용자 facing 메시지 mapping.
|
|
5
|
+
*
|
|
6
|
+
* 백엔드가 보내는 raw `error.message` 를 그대로 toast 로 띄우면 i18n 어긋나거나
|
|
7
|
+
* 내부 노출이 될 수 있어, frontend 가 정의한 안전한 메시지로 변환하는 게 권장.
|
|
8
|
+
* 코드별로 명시 안 된 경우 `error.data.message` (backend 메시지) → fallback.
|
|
9
|
+
*
|
|
10
|
+
* 사용자 프로젝트에서 자유롭게 추가/수정. next-intl 활성 시엔 `useTranslations`
|
|
11
|
+
* 와 결합해 hook 형태로 변환 가능 (이 파일은 RSC/CSR 양쪽 호환을 위해 module).
|
|
12
|
+
*/
|
|
13
|
+
export const ERROR_MESSAGES: Record<string, string> = {
|
|
14
|
+
UNAUTHORIZED: '로그인이 필요합니다.',
|
|
15
|
+
FORBIDDEN: '접근 권한이 없습니다.',
|
|
16
|
+
NOT_FOUND: '요청한 리소스를 찾을 수 없습니다.',
|
|
17
|
+
NETWORK_ERROR: '서버에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.',
|
|
18
|
+
// 사용자 정의 코드를 여기에 추가하세요.
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const DEFAULT_FALLBACK = '일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 에러 → 사용자 facing 메시지 결정.
|
|
25
|
+
* 1. ApiError + 코드가 ERROR_MESSAGES 에 있으면 그 메시지
|
|
26
|
+
* 2. ApiError 의 backend `data.message` (있으면)
|
|
27
|
+
* 3. 호출자의 fallback
|
|
28
|
+
* 4. 글로벌 DEFAULT_FALLBACK
|
|
29
|
+
*/
|
|
30
|
+
export function resolveErrorMessage(error: unknown, fallback?: string): string {
|
|
31
|
+
if (error instanceof ApiError) {
|
|
32
|
+
const mapped = ERROR_MESSAGES[error.code];
|
|
33
|
+
if (mapped) return mapped;
|
|
34
|
+
if (error.data?.message) return error.data.message;
|
|
35
|
+
}
|
|
36
|
+
return fallback ?? DEFAULT_FALLBACK;
|
|
37
|
+
}
|
|
@@ -5,7 +5,8 @@ import {
|
|
|
5
5
|
} from '@tanstack/react-query';
|
|
6
6
|
import { toast } from 'sonner';
|
|
7
7
|
|
|
8
|
-
import { ApiError } from '
|
|
8
|
+
import { ApiError } from '@/src/shared/api/error';
|
|
9
|
+
import { resolveErrorMessage } from '@/src/shared/api/errorMessages';
|
|
9
10
|
|
|
10
11
|
type AppMutationOptions<
|
|
11
12
|
TData = unknown,
|
|
@@ -13,13 +14,19 @@ type AppMutationOptions<
|
|
|
13
14
|
TVariables = void,
|
|
14
15
|
TContext = unknown,
|
|
15
16
|
> = UseMutationOptions<TData, TError, TVariables, TContext> & {
|
|
17
|
+
/** ApiError 가 아니거나 mapping 에 없을 때의 fallback 메시지. */
|
|
16
18
|
errorMessage?: string;
|
|
19
|
+
/** false 면 toast 띄우지 않음. */
|
|
17
20
|
showErrorToast?: boolean;
|
|
18
21
|
};
|
|
19
22
|
|
|
20
23
|
/**
|
|
21
|
-
* useMutation 래퍼 — 에러 발생 시
|
|
22
|
-
*
|
|
24
|
+
* useMutation 래퍼 — 에러 발생 시 `resolveErrorMessage` 를 통해 안전한
|
|
25
|
+
* 사용자 facing 메시지를 toast 로 띄운다. backend 가 보낸 raw 메시지를 그대로
|
|
26
|
+
* 띄우지 않고 `errorMessages.ts` 의 mapping 을 우선 사용해 일관된 사용자
|
|
27
|
+
* 경험과 i18n 친화성을 확보.
|
|
28
|
+
*
|
|
29
|
+
* showErrorToast: false 로 자동 toast 끌 수 있고, errorMessage 로 fallback 지정.
|
|
23
30
|
*/
|
|
24
31
|
export const useAppMutation = <
|
|
25
32
|
TData = unknown,
|
|
@@ -39,10 +46,7 @@ export const useAppMutation = <
|
|
|
39
46
|
if (!showErrorToast) return;
|
|
40
47
|
|
|
41
48
|
const [error] = args;
|
|
42
|
-
const message =
|
|
43
|
-
error instanceof ApiError
|
|
44
|
-
? (error.data?.message ?? errorMessage)
|
|
45
|
-
: errorMessage;
|
|
49
|
+
const message = resolveErrorMessage(error, errorMessage);
|
|
46
50
|
|
|
47
51
|
if (message) {
|
|
48
52
|
toast.error(message);
|
|
@@ -50,3 +54,6 @@ export const useAppMutation = <
|
|
|
50
54
|
},
|
|
51
55
|
});
|
|
52
56
|
};
|
|
57
|
+
|
|
58
|
+
// re-export ApiError 타입을 쓰는 사용처 편의용 (선택).
|
|
59
|
+
export type { ApiError };
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Date → 로케일 기반 날짜 포맷 (시간 없음).
|
|
3
|
+
*
|
|
4
|
+
* 비-i18n 프로젝트면 default 'ko-KR' 사용. next-intl 활성 시엔 이 util 을 직접
|
|
5
|
+
* 부르지 말고 같은 모듈의 hook (`useFormatDate`) 을 사용하세요 — 현재 locale 을
|
|
6
|
+
* 자동으로 따릅니다 (next-intl 플러그인이 emit).
|
|
3
7
|
*/
|
|
4
8
|
export const formatDate = (date: Date, locale = 'ko-KR'): string =>
|
|
5
9
|
new Intl.DateTimeFormat(locale, {
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 숫자 →
|
|
3
|
-
* 예: 12000
|
|
2
|
+
* 숫자 → 통화 포맷. default ko-KR + KRW 이지만 두 인자 모두 override 가능.
|
|
3
|
+
* 예: formatPrice(12000) → "₩12,000"
|
|
4
|
+
* formatPrice(99.5, 'en-US', 'USD') → "$99.50"
|
|
5
|
+
*
|
|
6
|
+
* next-intl 활성 시엔 같은 모듈의 hook (`useFormatPrice`) 을 사용하면 현재
|
|
7
|
+
* locale 을 자동으로 따릅니다 (next-intl 플러그인이 emit).
|
|
4
8
|
*/
|
|
5
|
-
export const formatPrice = (
|
|
9
|
+
export const formatPrice = (
|
|
10
|
+
amount: number,
|
|
11
|
+
locale = 'ko-KR',
|
|
12
|
+
currency = 'KRW',
|
|
13
|
+
): string =>
|
|
6
14
|
new Intl.NumberFormat(locale, {
|
|
7
15
|
style: 'currency',
|
|
8
|
-
currency
|
|
9
|
-
maximumFractionDigits: 0,
|
|
16
|
+
currency,
|
|
17
|
+
maximumFractionDigits: currency === 'KRW' ? 0 : 2,
|
|
10
18
|
}).format(amount);
|
|
@@ -9,6 +9,6 @@ import {
|
|
|
9
9
|
* RSC 와 클라이언트 양쪽에서 안전하게 호출 가능한 QueryClient 핸들.
|
|
10
10
|
* 서버에서는 React `cache()` 로 요청 단위 싱글턴, 브라우저에서는 모듈 싱글턴.
|
|
11
11
|
*/
|
|
12
|
-
export
|
|
12
|
+
export function getQueryClient() {
|
|
13
13
|
return isServer ? getServerQueryClient() : getBrowserQueryClient();
|
|
14
14
|
}
|