@next-feature/client 0.1.0-MIGRATION.22.0.3

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/.babelrc ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "presets": [
3
+ [
4
+ "@nx/react/babel",
5
+ {
6
+ "runtime": "automatic",
7
+ "useBuiltIns": "usage"
8
+ }
9
+ ]
10
+ ],
11
+ "plugins": []
12
+ }
package/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # client
2
+
3
+ This library was generated with [Nx](https://nx.dev).
4
+
5
+ ## Running unit tests
6
+
7
+ Run `nx test client` to execute the unit tests via [Vitest](https://vitest.dev/).
@@ -0,0 +1,12 @@
1
+ import nx from '@nx/eslint-plugin';
2
+ import baseConfig from '../../eslint.config.mjs';
3
+
4
+ export default [
5
+ ...baseConfig,
6
+ ...nx.configs['flat/react'],
7
+ {
8
+ files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
9
+ // Override or add rules here
10
+ rules: {},
11
+ },
12
+ ];
package/jest.config.ts ADDED
@@ -0,0 +1,10 @@
1
+ export default {
2
+ displayName: 'client',
3
+ preset: '../../jest.preset.js',
4
+ transform: {
5
+ '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest',
6
+ '^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/react/babel'] }],
7
+ },
8
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
9
+ coverageDirectory: '../../coverage/clients/client',
10
+ };
package/package.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@next-feature/client",
3
+ "version": "0.1.0-MIGRATION.22.0.3",
4
+ "main": "./index.js",
5
+ "types": "./index.d.ts",
6
+ "exports": {
7
+ ".": {
8
+ "import": "./index.mjs",
9
+ "require": "./index.js"
10
+ }
11
+ }
12
+ }
package/project.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "client",
3
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
4
+ "sourceRoot": "clients/client/src",
5
+ "projectType": "library",
6
+ "tags": [],
7
+ "targets": {
8
+ "build": {
9
+ "executor": "@nx/vite:build",
10
+ "outputs": ["{options.outputPath}"],
11
+ "defaultConfiguration": "production",
12
+ "options": {
13
+ "outputPath": "dist/clients/client"
14
+ },
15
+ "configurations": {
16
+ "development": {
17
+ "mode": "development"
18
+ },
19
+ "production": {
20
+ "mode": "production"
21
+ }
22
+ }
23
+ },
24
+ "test": {
25
+ "executor": "@nx/jest:jest",
26
+ "outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
27
+ "options": {
28
+ "jestConfig": "clients/client/jest.config.ts"
29
+ }
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,58 @@
1
+ 'use client';
2
+
3
+ import React, { Component, ErrorInfo, ReactNode } from 'react';
4
+ import { ApiError } from '../lib/error';
5
+ import { getErrorMessage } from '../lib/utils/error';
6
+
7
+ interface Props {
8
+ children: ReactNode;
9
+ fallback?: (error: ApiError) => ReactNode;
10
+ onError?: (error: Error, errorInfo: ErrorInfo) => void;
11
+ }
12
+
13
+ interface State {
14
+ hasError: boolean;
15
+ error: Error | null;
16
+ }
17
+
18
+ export class ApiErrorBoundary extends Component<Props, State> {
19
+ constructor(props: Props) {
20
+ super(props);
21
+ this.state = { hasError: false, error: null };
22
+ }
23
+
24
+ static getDerivedStateFromError(error: Error): State {
25
+ return { hasError: true, error };
26
+ }
27
+
28
+ componentDidCatch(error: Error, errorInfo: ErrorInfo) {
29
+ console.error('ApiErrorBoundary caught error:', error, errorInfo);
30
+
31
+ if (this.props.onError) {
32
+ this.props.onError(error, errorInfo);
33
+ }
34
+ }
35
+
36
+ render() {
37
+ if (this.state.hasError && this.state.error) {
38
+ if (this.state.error instanceof ApiError && this.props.fallback) {
39
+ return this.props.fallback(this.state.error);
40
+ }
41
+
42
+ // Default error UI
43
+ return (
44
+ <div className="error-container">
45
+ <h2>Something went wrong</h2>
46
+ <p>{getErrorMessage(this.state.error)}</p>
47
+ <button
48
+ onClick={() => this.setState({ hasError: false, error: null })}
49
+ >
50
+ Try again
51
+ </button>
52
+ </div>
53
+ );
54
+ }
55
+
56
+ return this.props.children;
57
+ }
58
+ }
@@ -0,0 +1,39 @@
1
+ 'use client';
2
+
3
+ import { useState, useCallback } from 'react';
4
+ import { ApiError } from '../lib/error';
5
+ import { getErrorMessage } from '../lib/utils/error';
6
+
7
+ interface UseApiErrorResult {
8
+ error: ApiError | null;
9
+ setError: (error: ApiError | null) => void;
10
+ clearError: () => void;
11
+ handleError: (error: unknown) => void;
12
+ errorMessage: string | null;
13
+ }
14
+
15
+ export function useApiError(): UseApiErrorResult {
16
+ const [error, setError] = useState<ApiError | null>(null);
17
+
18
+ const clearError = useCallback(() => {
19
+ setError(null);
20
+ }, []);
21
+
22
+ const handleError = useCallback((err: unknown) => {
23
+ if (err instanceof ApiError) {
24
+ setError(err);
25
+ } else {
26
+ console.error('Non-API error:', err);
27
+ }
28
+ }, []);
29
+
30
+ const errorMessage = error ? getErrorMessage(error) : null;
31
+
32
+ return {
33
+ error,
34
+ setError,
35
+ clearError,
36
+ handleError,
37
+ errorMessage,
38
+ };
39
+ }
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Client package exports
3
+ *
4
+ * This client package provides all necessary API client utilities.
5
+ * All implementations are self-contained and do not depend on external packages.
6
+ *
7
+ * CUSTOMIZATION:
8
+ * You can easily customize any part of the implementation:
9
+ *
10
+ * 1. For custom ApiClient/ApiError logic with interceptors:
11
+ * - Run: npx nx g next-feature:client-config --projectName=<your-project>
12
+ * - This creates lib/client/config.ts for centralized setup
13
+ *
14
+ * 2. For full custom implementations:
15
+ * - Edit src/lib/client.ts to customize ApiClient behavior
16
+ * - Edit src/lib/error.ts to customize error handling
17
+ * - The changes are immediately reflected in all imports
18
+ *
19
+ * 3. For project-specific hooks/components:
20
+ * - Import directly: import { useApiError } from './hooks/use-api-error'
21
+ * - Customize src/hooks/use-api-error.tsx
22
+ * - Customize src/components/api-error-boundary.tsx
23
+ */
24
+
25
+ // Core API utilities
26
+ export { ApiClient, type ApiClientConfig } from './lib/client';
27
+ export { ApiError, ApiErrorBuilder, type ProblemDetail } from './lib/error';
28
+ export * from './lib/types';
29
+ export * from './lib/utils/error';
30
+ export * from './lib/utils/helper';
@@ -0,0 +1,431 @@
1
+ import axios, {
2
+ AxiosError,
3
+ AxiosInstance,
4
+ AxiosRequestConfig,
5
+ AxiosResponse,
6
+ InternalAxiosRequestConfig,
7
+ } from 'axios';
8
+ import { ApiError, ProblemDetail } from './error';
9
+
10
+ /**
11
+ * Configuration options for the API client
12
+ */
13
+ export interface ApiClientConfig {
14
+ baseURL: string;
15
+ timeout?: number;
16
+ enableRefreshToken?: boolean;
17
+ maxRetries?: number;
18
+ retryDelay?: number;
19
+ onAuthenticated?: (
20
+ config: InternalAxiosRequestConfig
21
+ ) => void | Promise<void>;
22
+ onUnauthorized?: () => void | Promise<void>;
23
+ onRefreshTokenExpired?: () => void | Promise<void>;
24
+ onRefreshToken?: () => string | Promise<string>;
25
+ }
26
+
27
+ /**
28
+ * Pending request queue item
29
+ */
30
+ interface PendingRequest {
31
+ resolve: (token: string) => void;
32
+ reject: (error: any) => void;
33
+ }
34
+
35
+ /**
36
+ * Axios wrapper with JWT/Refresh token handling
37
+ */
38
+ export class ApiClient {
39
+ private readonly instance: AxiosInstance;
40
+ private isRefreshing = false;
41
+ private pendingRequests: PendingRequest[] = [];
42
+ private config: Required<ApiClientConfig>;
43
+
44
+ constructor(config: ApiClientConfig) {
45
+ this.config = {
46
+ timeout: 30000,
47
+ enableRefreshToken: true,
48
+ maxRetries: 3,
49
+ retryDelay: 1000,
50
+ onUnauthorized: async () => {
51
+ // Redirect to login page
52
+ if (typeof window !== 'undefined') {
53
+ window.location.href = '/login';
54
+ }
55
+ },
56
+ onRefreshTokenExpired: async () => {
57
+ console.error('Session expired. Please login again.');
58
+ if (typeof window !== 'undefined') {
59
+ window.location.href = '/login?expired=true';
60
+ }
61
+ },
62
+ onAuthenticated: async (config) => {
63
+ console.log('Authenticated', config.baseURL);
64
+ },
65
+ onRefreshToken: async () => {
66
+ return '';
67
+ },
68
+ ...config,
69
+ };
70
+
71
+ this.instance = axios.create({
72
+ baseURL: this.config.baseURL,
73
+ timeout: this.config.timeout,
74
+ headers: {
75
+ 'Content-Type': 'application/json',
76
+ },
77
+ });
78
+
79
+ this.setupInterceptors();
80
+ }
81
+
82
+ /**
83
+ * Setup request and response interceptors
84
+ */
85
+ private setupInterceptors(): void {
86
+ // Request interceptor
87
+ this.instance.interceptors.request.use(
88
+ this.handleRequestFulfilled.bind(this),
89
+ this.handleRequestRejected.bind(this)
90
+ );
91
+
92
+ // Response interceptor
93
+ this.instance.interceptors.response.use(
94
+ this.handleResponseFulfilled.bind(this),
95
+ this.handleResponseRejected.bind(this)
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Attach JWT token to request headers
101
+ */
102
+ private async handleRequestFulfilled(
103
+ config: InternalAxiosRequestConfig
104
+ ): Promise<InternalAxiosRequestConfig> {
105
+ try {
106
+ if (this.config.onAuthenticated) {
107
+ this.config.onAuthenticated(config);
108
+ }
109
+
110
+ return config;
111
+ } catch (error) {
112
+ console.error('Error attaching token to request:', error);
113
+ return config;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Handle request errors
119
+ */
120
+ private handleRequestRejected(error: any): Promise<never> {
121
+ console.error('Request configuration error:', error);
122
+ return Promise.reject(error);
123
+ }
124
+
125
+ /**
126
+ * Pass through successful responses
127
+ */
128
+ private handleResponseFulfilled(response: AxiosResponse): AxiosResponse {
129
+ return response;
130
+ }
131
+
132
+ /**
133
+ * Handle response errors with retry logic and token refresh
134
+ */
135
+ private async handleResponseRejected(error: AxiosError): Promise<any> {
136
+ const originalRequest = error.config as InternalAxiosRequestConfig & {
137
+ _retry?: boolean;
138
+ _retryCount?: number;
139
+ };
140
+
141
+ if (!originalRequest) {
142
+ return Promise.reject(this.createApiError(error));
143
+ }
144
+
145
+ // Handle 401 Unauthorized - attempt token refresh
146
+ if (error.response?.status === 401 && this.config.enableRefreshToken) {
147
+ return this.handleUnauthorizedError(error, originalRequest);
148
+ }
149
+
150
+ // Handle network errors and 5xx errors with retry logic
151
+ if (this.shouldRetry(error, originalRequest)) {
152
+ return this.retryRequest(originalRequest);
153
+ }
154
+
155
+ // Create and reject with custom ApiError
156
+ return Promise.reject(this.createApiError(error));
157
+ }
158
+
159
+ /**
160
+ * Handle 401 errors with token refresh
161
+ */
162
+ private async handleUnauthorizedError(
163
+ error: AxiosError,
164
+ originalRequest: InternalAxiosRequestConfig & { _retry?: boolean }
165
+ ): Promise<any> {
166
+ // Prevent infinite loops
167
+ if (originalRequest._retry) {
168
+ if (this.config.onRefreshTokenExpired) {
169
+ await this.config.onRefreshTokenExpired();
170
+ }
171
+ return Promise.reject(this.createApiError(error));
172
+ }
173
+
174
+ originalRequest._retry = true;
175
+
176
+ // If already refreshing, queue the request
177
+ if (this.isRefreshing) {
178
+ return new Promise((resolve, reject) => {
179
+ this.pendingRequests.push({ resolve, reject });
180
+ })
181
+ .then((token) => {
182
+ originalRequest.headers.Authorization = `Bearer ${token}`;
183
+ return this.instance(originalRequest);
184
+ })
185
+ .catch((err) => {
186
+ return Promise.reject(err);
187
+ });
188
+ }
189
+
190
+ this.isRefreshing = true;
191
+
192
+ try {
193
+ const newToken = await this.refreshToken();
194
+
195
+ // Update the original request with new token
196
+ originalRequest.headers.Authorization = `Bearer ${newToken}`;
197
+
198
+ // Resolve all pending requests with new token
199
+ this.processPendingRequests(null, newToken);
200
+
201
+ // Retry the original request
202
+ return this.instance(originalRequest);
203
+ } catch (refreshError) {
204
+ // Reject all pending requests
205
+ this.processPendingRequests(refreshError, null);
206
+
207
+ if (this.config.onUnauthorized) {
208
+ await this.config.onUnauthorized();
209
+ }
210
+
211
+ return Promise.reject(this.createApiError(error));
212
+ } finally {
213
+ this.isRefreshing = false;
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Refresh the JWT token using the refresh token
219
+ */
220
+ private async refreshToken(): Promise<string> {
221
+ try {
222
+ // const session = await auth();
223
+ //
224
+ // if (!session?.user || !('refreshToken' in session.user)) {
225
+ // throw new Error('No refresh token available');
226
+ // }
227
+ //
228
+ // const refreshToken = session.user.refreshToken as string;
229
+ const refreshToken = this.config.onRefreshToken();
230
+
231
+ // Call your refresh token endpoint
232
+ const response = await axios.post<{ jwtToken: string }>(
233
+ `${this.config.baseURL}/auth/refresh`,
234
+ { refreshToken },
235
+ {
236
+ headers: {
237
+ 'Content-Type': 'application/json',
238
+ },
239
+ }
240
+ );
241
+
242
+ const newToken = response.data.jwtToken;
243
+
244
+ // Update the session with the new token
245
+ // Note: You'll need to implement this based on your auth setup
246
+ // This might involve updating cookies or calling an API route
247
+ await this.updateSession(newToken);
248
+
249
+ return newToken;
250
+ } catch (error) {
251
+ console.error('Token refresh failed:', error);
252
+ throw error;
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Update session with new token
258
+ * Implement this based on your Next.js auth setup
259
+ */
260
+ private async updateSession(newToken: string): Promise<void> {
261
+ // Example implementation - adjust based on your auth setup
262
+ try {
263
+ await fetch('/api/auth/update-token', {
264
+ method: 'POST',
265
+ headers: {
266
+ 'Content-Type': 'application/json',
267
+ },
268
+ body: JSON.stringify({ jwtToken: newToken }),
269
+ });
270
+ } catch (error) {
271
+ console.error('Failed to update session:', error);
272
+ throw error;
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Process all pending requests after token refresh
278
+ */
279
+ private processPendingRequests(error: any, token: string | null): void {
280
+ this.pendingRequests.forEach((request) => {
281
+ if (error) {
282
+ request.reject(error);
283
+ } else if (token) {
284
+ request.resolve(token);
285
+ }
286
+ });
287
+
288
+ this.pendingRequests = [];
289
+ }
290
+
291
+ /**
292
+ * Determine if request should be retried
293
+ */
294
+ private shouldRetry(
295
+ error: AxiosError,
296
+ config: InternalAxiosRequestConfig & { _retryCount?: number }
297
+ ): boolean {
298
+ const retryCount = config._retryCount || 0;
299
+
300
+ // Don't retry if max retries exceeded
301
+ if (retryCount >= this.config.maxRetries) {
302
+ return false;
303
+ }
304
+
305
+ // Retry on network errors
306
+ if (!error.response) {
307
+ return true;
308
+ }
309
+
310
+ // Retry on 5xx server errors (except 501)
311
+ const status = error.response.status;
312
+ if (status >= 500 && status !== 501) {
313
+ return true;
314
+ }
315
+
316
+ // Retry on 429 (Too Many Requests)
317
+ if (status === 429) {
318
+ return true;
319
+ }
320
+
321
+ return false;
322
+ }
323
+
324
+ /**
325
+ * Retry failed request with exponential backoff
326
+ */
327
+ private async retryRequest(
328
+ config: InternalAxiosRequestConfig & { _retryCount?: number }
329
+ ): Promise<any> {
330
+ config._retryCount = (config._retryCount || 0) + 1;
331
+
332
+ const delay = this.config.retryDelay * Math.pow(2, config._retryCount - 1);
333
+
334
+ await this.sleep(delay);
335
+
336
+ console.log(
337
+ `Retrying request (attempt ${config._retryCount}):`,
338
+ config.url
339
+ );
340
+
341
+ return this.instance(config);
342
+ }
343
+
344
+ /**
345
+ * Sleep helper for retry delays
346
+ */
347
+ private sleep(ms: number): Promise<void> {
348
+ return new Promise((resolve) => setTimeout(resolve, ms));
349
+ }
350
+
351
+ /**
352
+ * Create ApiError from AxiosError
353
+ */
354
+ private createApiError(error: AxiosError): ApiError {
355
+ const status = error.response?.status || 0;
356
+ const problemDetail = this.extractProblemDetail(error);
357
+
358
+ return new ApiError(status, problemDetail, error);
359
+ }
360
+
361
+ /**
362
+ * Extract ProblemDetail from error response
363
+ */
364
+ private extractProblemDetail(error: AxiosError): ProblemDetail | null {
365
+ if (!error.response?.data) {
366
+ return null;
367
+ }
368
+
369
+ const data = error.response.data;
370
+
371
+ // Check if response matches ProblemDetail structure
372
+ if (
373
+ typeof data === 'object' &&
374
+ 'type' in data &&
375
+ 'title' in data &&
376
+ 'status' in data
377
+ ) {
378
+ return data as ProblemDetail;
379
+ }
380
+
381
+ return null;
382
+ }
383
+
384
+ /**
385
+ * HTTP Methods with proper typing
386
+ */
387
+
388
+ async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
389
+ const response = await this.instance.get<T>(url, config);
390
+ return response.data;
391
+ }
392
+
393
+ async post<T = any>(
394
+ url: string,
395
+ data?: any,
396
+ config?: AxiosRequestConfig
397
+ ): Promise<T> {
398
+ const response = await this.instance.post<T>(url, data, config);
399
+ return response.data;
400
+ }
401
+
402
+ async put<T = any>(
403
+ url: string,
404
+ data?: any,
405
+ config?: AxiosRequestConfig
406
+ ): Promise<T> {
407
+ const response = await this.instance.put<T>(url, data, config);
408
+ return response.data;
409
+ }
410
+
411
+ async patch<T = any>(
412
+ url: string,
413
+ data?: any,
414
+ config?: AxiosRequestConfig
415
+ ): Promise<T> {
416
+ const response = await this.instance.patch<T>(url, data, config);
417
+ return response.data;
418
+ }
419
+
420
+ async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
421
+ const response = await this.instance.delete<T>(url, config);
422
+ return response.data;
423
+ }
424
+
425
+ /**
426
+ * Get the underlying Axios instance for advanced usage
427
+ */
428
+ getAxiosInstance(): AxiosInstance {
429
+ return this.instance;
430
+ }
431
+ }
@@ -0,0 +1,157 @@
1
+ import { AxiosError, HttpStatusCode } from 'axios';
2
+ import { ZodError } from 'zod';
3
+
4
+ /**
5
+ * Spring Boot ProblemDetail structure
6
+ */
7
+ export interface ProblemDetail {
8
+ type: string;
9
+ title: string;
10
+ status: HttpStatusCode;
11
+ detail?: string;
12
+ instance?: string;
13
+ errors?: Record<string, unknown>;
14
+ [key: string]: unknown;
15
+ }
16
+
17
+ /**
18
+ * Custom error class for API errors
19
+ */
20
+ export class ApiError extends Error {
21
+ constructor(
22
+ public status: HttpStatusCode,
23
+ public problemDetail: ProblemDetail | null,
24
+ public originalError: Error,
25
+ message?: string
26
+ ) {
27
+ super(
28
+ message ||
29
+ problemDetail?.detail ||
30
+ problemDetail?.title ||
31
+ originalError?.message ||
32
+ 'An error occurred'
33
+ );
34
+ this.name = 'ApiError';
35
+ Object.setPrototypeOf(this, ApiError.prototype);
36
+ }
37
+
38
+ get isClientError(): boolean {
39
+ return this.status >= 400 && this.status < 500;
40
+ }
41
+
42
+ get isServerError(): boolean {
43
+ return this.status >= 500;
44
+ }
45
+
46
+ get isUnauthorized(): boolean {
47
+ return this.status === 401;
48
+ }
49
+
50
+ get isForbidden(): boolean {
51
+ return this.status === 403;
52
+ }
53
+
54
+ get isNotFound(): boolean {
55
+ return this.status === 404;
56
+ }
57
+
58
+ static builder<T>(): ApiErrorBuilder {
59
+ return new ApiErrorBuilder<T>();
60
+ }
61
+
62
+ static of(error: Error) {
63
+ if (error instanceof ApiError) {
64
+ return error;
65
+ }
66
+
67
+ return ApiError.builder().originalError(error).build();
68
+ }
69
+
70
+ /**
71
+ * Create ApiError from Zod validation error
72
+ */
73
+ static fromZodError(zodError: ZodError) {
74
+ const errors: Record<string, string> = {};
75
+
76
+ zodError.errors.forEach((error) => {
77
+ error.path.forEach((path) => {
78
+ errors[path] = error.message;
79
+ });
80
+ });
81
+
82
+ return ApiError.builder()
83
+ .originalError(zodError)
84
+ .status(HttpStatusCode.BadRequest)
85
+ .message('Validation error')
86
+ .problemDetail('errors', errors)
87
+ .build();
88
+ }
89
+ }
90
+
91
+ export class ApiErrorBuilder<
92
+ AdditionalProblemDetails = Record<string, unknown>
93
+ > {
94
+ private readonly _problemDetail: ProblemDetail;
95
+ private _status: HttpStatusCode;
96
+ private _originalError: Error;
97
+ private _message: string;
98
+
99
+ constructor() {
100
+ this._status = HttpStatusCode.InternalServerError;
101
+ this._problemDetail = {
102
+ status: this._status,
103
+ title: '',
104
+ type: 'about:blank',
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Set standard ProblemDetail fields
110
+ */
111
+ problemDetail<K extends keyof (ProblemDetail & AdditionalProblemDetails)>(
112
+ key: K,
113
+ value: K extends keyof ProblemDetail
114
+ ? ProblemDetail[K]
115
+ : K extends keyof AdditionalProblemDetails
116
+ ? AdditionalProblemDetails[K]
117
+ : unknown
118
+ ): ApiErrorBuilder<AdditionalProblemDetails> {
119
+ (this._problemDetail as any)[key] = value;
120
+ return this;
121
+ }
122
+
123
+ originalError(error: Error): ApiErrorBuilder<AdditionalProblemDetails> {
124
+ this._originalError = error;
125
+ this._problemDetail.title = error.name;
126
+
127
+ if (error instanceof AxiosError) {
128
+ this.status(error.status);
129
+ }
130
+ if (error instanceof ZodError) {
131
+ this.status(HttpStatusCode.BadRequest);
132
+ this.message('Validation error');
133
+ }
134
+ return this;
135
+ }
136
+
137
+ status(status: HttpStatusCode): ApiErrorBuilder<AdditionalProblemDetails> {
138
+ this._status = status;
139
+ this._problemDetail.status = status;
140
+ return this;
141
+ }
142
+
143
+ message(msg: string): ApiErrorBuilder<AdditionalProblemDetails> {
144
+ this._message = msg;
145
+ this._problemDetail.detail = msg;
146
+ return this;
147
+ }
148
+
149
+ build(): ApiError {
150
+ return new ApiError(
151
+ this._status,
152
+ this._problemDetail,
153
+ this._originalError,
154
+ this._message
155
+ );
156
+ }
157
+ }
@@ -0,0 +1,13 @@
1
+ import { ProblemDetail } from '../error';
2
+
3
+ /**
4
+ * [api-response]
5
+ * next-feature@0.0.11-beta
6
+ * November 4th 2025, 6:37:27 pm
7
+ */
8
+ export interface ApiResponse<Response> {
9
+ success?: boolean;
10
+ message?: string;
11
+ error?: ProblemDetail;
12
+ data: Response;
13
+ }
@@ -0,0 +1,136 @@
1
+ import { ApiError } from '../error';
2
+
3
+ /**
4
+ * Extract user-friendly error message from ApiError
5
+ *
6
+ * [get-error-message]
7
+ * next-feature@0.0.11-beta
8
+ * November 4th 2025, 11:47:45 am
9
+ *
10
+ */
11
+ export function getErrorMessage(error: unknown): string {
12
+ if (error instanceof ApiError) {
13
+ // Use Spring Boot ProblemDetail information
14
+ if (error.problemDetail?.detail) {
15
+ return error.problemDetail.detail;
16
+ }
17
+ if (error.problemDetail?.title) {
18
+ return error.problemDetail.title;
19
+ }
20
+ return error.message;
21
+ }
22
+
23
+ if (error instanceof Error) {
24
+ return error.message;
25
+ }
26
+
27
+ return 'An unexpected error occurred';
28
+ }
29
+
30
+ /**
31
+ * Format ProblemDetail for display
32
+ */
33
+ export function formatProblemDetail(error: ApiError): string {
34
+ if (!error.problemDetail) {
35
+ return error.message;
36
+ }
37
+
38
+ const parts: string[] = [];
39
+
40
+ if (error.problemDetail.title) {
41
+ parts.push(`Title: ${error.problemDetail.title}`);
42
+ }
43
+
44
+ if (error.problemDetail.detail) {
45
+ parts.push(`Detail: ${error.problemDetail.detail}`);
46
+ }
47
+
48
+ if (error.problemDetail.instance) {
49
+ parts.push(`Instance: ${error.problemDetail.instance}`);
50
+ }
51
+
52
+ return parts.join('\n');
53
+ }
54
+
55
+ /**
56
+ * Check if error is a specific HTTP status
57
+ */
58
+ export function isHttpStatus(error: unknown, status: number): boolean {
59
+ return error instanceof ApiError && error.status === status;
60
+ }
61
+
62
+ /**
63
+ * Handle common API errors
64
+ */
65
+ export function handleApiError(error: unknown): void {
66
+ if (!(error instanceof ApiError)) {
67
+ console.error('Unexpected error:', error);
68
+ return;
69
+ }
70
+
71
+ switch (error.status) {
72
+ case 400:
73
+ console.error('Bad Request:', error.problemDetail?.detail);
74
+ break;
75
+ case 401:
76
+ console.error('Unauthorized - Please login');
77
+ break;
78
+ case 403:
79
+ console.error('Forbidden - Access denied');
80
+ break;
81
+ case 404:
82
+ console.error('Not Found:', error.problemDetail?.detail);
83
+ break;
84
+ case 409:
85
+ console.error('Conflict:', error.problemDetail?.detail);
86
+ break;
87
+ case 422:
88
+ console.error('Validation Error:', error.problemDetail);
89
+ break;
90
+ case 429:
91
+ console.error('Too Many Requests - Please slow down');
92
+ break;
93
+ case 500:
94
+ console.error('Server Error:', error.problemDetail?.detail);
95
+ break;
96
+ case 503:
97
+ console.error('Service Unavailable - Please try again later');
98
+ break;
99
+ default:
100
+ console.error(`Error ${error.status}:`, error.message);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Validation error extractor for Spring Boot validation errors
106
+ */
107
+ export function extractValidationErrors(
108
+ error: ApiError
109
+ ): Record<string, string[]> | null {
110
+ if (!error.problemDetail || error.status !== 400) {
111
+ return null;
112
+ }
113
+
114
+ // Spring Boot often includes validation errors in a 'errors' or 'fieldErrors' property
115
+ const errors = error.problemDetail.errors || error.problemDetail.fieldErrors;
116
+
117
+ if (!errors) {
118
+ return null;
119
+ }
120
+
121
+ // Convert to a more usable format
122
+ if (Array.isArray(errors)) {
123
+ const result: Record<string, string[]> = {};
124
+ errors.forEach((err: any) => {
125
+ const field = err.field || err.property || 'general';
126
+ const message = err.message || err.defaultMessage || 'Validation error';
127
+ if (!result[field]) {
128
+ result[field] = [];
129
+ }
130
+ result[field].push(message);
131
+ });
132
+ return result;
133
+ }
134
+
135
+ return errors as Record<string, string[]>;
136
+ }
@@ -0,0 +1,20 @@
1
+ import { ProblemDetail } from '../error';
2
+
3
+ /**
4
+ * [show-error-toast]
5
+ * next-feature@0.0.11-beta
6
+ * November 4th 2025, 11:58:52 am
7
+ */
8
+ export function showErrorToast(data: any) {
9
+ return data;
10
+ }
11
+
12
+ export function getFieldError(
13
+ error: ProblemDetail | undefined,
14
+ fieldName: string
15
+ ): string | undefined {
16
+ if (!error) return undefined;
17
+
18
+ const errors = error.errors as Record<string, string>;
19
+ return errors?.[fieldName];
20
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "jsx": "react-jsx",
4
+ "allowJs": false,
5
+ "esModuleInterop": false,
6
+ "allowSyntheticDefaultImports": true,
7
+ "types": ["vite/client"]
8
+ },
9
+ "files": [],
10
+ "include": [],
11
+ "references": [
12
+ {
13
+ "path": "./tsconfig.lib.json"
14
+ },
15
+ {
16
+ "path": "./tsconfig.spec.json"
17
+ }
18
+ ],
19
+ "extends": "../../tsconfig.base.json"
20
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "../../dist/out-tsc",
5
+ "types": [
6
+ "node",
7
+ "@nx/react/typings/cssmodule.d.ts",
8
+ "@nx/react/typings/image.d.ts",
9
+ "vite/client",
10
+ "next",
11
+ "@nx/next/typings/image.d.ts"
12
+ ]
13
+ },
14
+ "exclude": [
15
+ "**/*.spec.ts",
16
+ "**/*.test.ts",
17
+ "**/*.spec.tsx",
18
+ "**/*.test.tsx",
19
+ "**/*.spec.js",
20
+ "**/*.test.js",
21
+ "**/*.spec.jsx",
22
+ "**/*.test.jsx",
23
+ "jest.config.ts",
24
+ "src/**/*.spec.ts",
25
+ "src/**/*.test.ts"
26
+ ],
27
+ "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
28
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "../../dist/out-tsc",
5
+ "module": "commonjs",
6
+ "moduleResolution": "node10",
7
+ "jsx": "react-jsx",
8
+ "types": ["jest", "node"]
9
+ },
10
+ "include": [
11
+ "jest.config.ts",
12
+ "src/**/*.test.ts",
13
+ "src/**/*.spec.ts",
14
+ "src/**/*.test.tsx",
15
+ "src/**/*.spec.tsx",
16
+ "src/**/*.test.js",
17
+ "src/**/*.spec.js",
18
+ "src/**/*.test.jsx",
19
+ "src/**/*.spec.jsx",
20
+ "src/**/*.d.ts"
21
+ ]
22
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,48 @@
1
+ /// <reference types='vitest' />
2
+ import { defineConfig } from 'vite';
3
+ import react from '@vitejs/plugin-react';
4
+ import dts from 'vite-plugin-dts';
5
+ import * as path from 'path';
6
+ import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
7
+ import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
8
+
9
+ export default defineConfig(() => ({
10
+ root: __dirname,
11
+ cacheDir: '../../node_modules/.vite/clients/client',
12
+ plugins: [
13
+ react(),
14
+ nxViteTsPaths(),
15
+ nxCopyAssetsPlugin(['*.md']),
16
+ dts({
17
+ entryRoot: 'src',
18
+ tsconfigPath: path.join(__dirname, 'tsconfig.lib.json'),
19
+ }),
20
+ ],
21
+ // Uncomment this if you are using workers.
22
+ // worker: {
23
+ // plugins: [ nxViteTsPaths() ],
24
+ // },
25
+ // Configuration for building your library.
26
+ // See: https://vitejs.dev/guide/build.html#library-mode
27
+ build: {
28
+ outDir: '../../dist/clients/client',
29
+ emptyOutDir: true,
30
+ reportCompressedSize: true,
31
+ commonjsOptions: {
32
+ transformMixedEsModules: true,
33
+ },
34
+ lib: {
35
+ // Could also be a dictionary or array of multiple entry points.
36
+ entry: 'src/index.ts',
37
+ name: 'client',
38
+ fileName: 'index',
39
+ // Change this to the formats you want to support.
40
+ // Don't forget to update your package.json as well.
41
+ formats: ['es' as const],
42
+ },
43
+ rollupOptions: {
44
+ // External packages that should not be bundled into your library.
45
+ external: ['react', 'react-dom', 'react/jsx-runtime'],
46
+ },
47
+ },
48
+ }));