@startsimpli/ui 0.4.15 → 0.4.17
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/package.json +2 -1
- package/src/components/__tests__/error-surfaces.test.tsx +70 -0
- package/src/components/error/GlobalError.tsx +261 -0
- package/src/components/error/NotFound.tsx +78 -0
- package/src/components/error/RouteErrorBoundary.tsx +175 -0
- package/src/components/error/index.ts +6 -0
- package/src/components/index.ts +5 -0
- package/src/components/team/__tests__/team-settings-page.test.tsx +146 -0
- package/src/components/team/index.ts +5 -0
- package/src/components/team/pages/DomainsSettingsPage.tsx +289 -0
- package/src/components/team/pages/TeamSettingsPage.tsx +423 -0
- package/src/components/team/pages/domains-settings-page-default-class-names.ts +89 -0
- package/src/components/team/pages/index.ts +33 -0
- package/src/components/team/pages/team-settings-page-default-class-names.ts +116 -0
- package/src/components/team/pages/types.ts +135 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.17",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"./gantt": "./src/components/gantt/index.ts",
|
|
11
11
|
"./gantt/styles": "./src/components/gantt/gantt.css",
|
|
12
12
|
"./components": "./src/components/index.ts",
|
|
13
|
+
"./error": "./src/components/error/index.ts",
|
|
13
14
|
"./utils": "./src/utils/index.ts",
|
|
14
15
|
"./theme": "./src/theme/index.ts",
|
|
15
16
|
"./theme/contract": "./theme/contract.css",
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
2
|
+
import { RouteErrorBoundary } from '../error/RouteErrorBoundary'
|
|
3
|
+
import { NotFound } from '../error/NotFound'
|
|
4
|
+
|
|
5
|
+
describe('RouteErrorBoundary', () => {
|
|
6
|
+
const makeError = () => Object.assign(new Error('boom'), { digest: 'abc123' })
|
|
7
|
+
|
|
8
|
+
it('renders default title and description', () => {
|
|
9
|
+
render(<RouteErrorBoundary error={makeError()} reset={jest.fn()} />)
|
|
10
|
+
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
|
|
11
|
+
expect(screen.getByRole('alert')).toHaveAttribute('aria-live', 'assertive')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('calls reset when the retry button is clicked', () => {
|
|
15
|
+
const reset = jest.fn()
|
|
16
|
+
render(<RouteErrorBoundary error={makeError()} reset={reset} />)
|
|
17
|
+
fireEvent.click(screen.getByText('Try again'))
|
|
18
|
+
expect(reset).toHaveBeenCalledTimes(1)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('invokes onError once on mount', () => {
|
|
22
|
+
const onError = jest.fn()
|
|
23
|
+
const error = makeError()
|
|
24
|
+
render(<RouteErrorBoundary error={error} reset={jest.fn()} onError={onError} />)
|
|
25
|
+
expect(onError).toHaveBeenCalledTimes(1)
|
|
26
|
+
expect(onError).toHaveBeenCalledWith(error)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('hides the home button when homeHref is null', () => {
|
|
30
|
+
render(
|
|
31
|
+
<RouteErrorBoundary error={makeError()} reset={jest.fn()} homeHref={null} />
|
|
32
|
+
)
|
|
33
|
+
expect(screen.queryByText('Go home')).not.toBeInTheDocument()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('supports a render-prop override via children', () => {
|
|
37
|
+
render(
|
|
38
|
+
<RouteErrorBoundary error={makeError()} reset={jest.fn()}>
|
|
39
|
+
{({ error }) => <div>custom: {error.message}</div>}
|
|
40
|
+
</RouteErrorBoundary>
|
|
41
|
+
)
|
|
42
|
+
expect(screen.getByText('custom: boom')).toBeInTheDocument()
|
|
43
|
+
expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument()
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('NotFound', () => {
|
|
48
|
+
it('renders code, title, and description', () => {
|
|
49
|
+
render(<NotFound />)
|
|
50
|
+
expect(screen.getByText('404')).toBeInTheDocument()
|
|
51
|
+
expect(screen.getByText('Page not found')).toBeInTheDocument()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('renders a default home anchor when no action is given', () => {
|
|
55
|
+
render(<NotFound />)
|
|
56
|
+
const link = screen.getByText('Go home')
|
|
57
|
+
expect(link).toHaveAttribute('href', '/')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('renders a custom action node', () => {
|
|
61
|
+
render(<NotFound action={<a href="/browse">Browse</a>} />)
|
|
62
|
+
expect(screen.getByText('Browse')).toBeInTheDocument()
|
|
63
|
+
expect(screen.queryByText('Go home')).not.toBeInTheDocument()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('hides the code when set to null', () => {
|
|
67
|
+
render(<NotFound code={null} />)
|
|
68
|
+
expect(screen.queryByText('404')).not.toBeInTheDocument()
|
|
69
|
+
})
|
|
70
|
+
})
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import type { RouteError } from './RouteErrorBoundary'
|
|
5
|
+
|
|
6
|
+
export interface GlobalErrorProps {
|
|
7
|
+
/** The error thrown during render — matches the Next.js `global-error.tsx` contract. */
|
|
8
|
+
error: RouteError
|
|
9
|
+
/** Re-render the root — matches the Next.js `global-error.tsx` contract. */
|
|
10
|
+
reset: () => void
|
|
11
|
+
/** `lang` attribute for the replacement `<html>` element. */
|
|
12
|
+
lang?: string
|
|
13
|
+
/** Document title shown while the global error surface is active. */
|
|
14
|
+
pageTitle?: string
|
|
15
|
+
/** Heading shown above the message. */
|
|
16
|
+
title?: string
|
|
17
|
+
/** Body copy shown to users. */
|
|
18
|
+
description?: string
|
|
19
|
+
/** Label for the primary (retry) action. */
|
|
20
|
+
retryLabel?: string
|
|
21
|
+
/** Label for the secondary (home) action. Hidden when `homeHref` is null. */
|
|
22
|
+
homeLabel?: string
|
|
23
|
+
/** Destination for the secondary action. Pass `null` to hide it. Defaults to `/`. */
|
|
24
|
+
homeHref?: string | null
|
|
25
|
+
/** Accent color used for the primary button. Defaults to a neutral blue. */
|
|
26
|
+
accentColor?: string
|
|
27
|
+
/** Called once on mount with the error — wire up your logger/Sentry here. */
|
|
28
|
+
onError?: (error: RouteError) => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Standalone global error surface for an app's `app/global-error.tsx`.
|
|
33
|
+
*
|
|
34
|
+
* Because `global-error.tsx` replaces the root layout, this renders its own
|
|
35
|
+
* `<html>`/`<body>` and uses inline styles only — it does NOT depend on the
|
|
36
|
+
* app's Tailwind/CSS being loaded. Brandable via the `accentColor` and copy
|
|
37
|
+
* props.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* // app/global-error.tsx
|
|
41
|
+
* 'use client'
|
|
42
|
+
* import { GlobalError } from '@startsimpli/ui'
|
|
43
|
+
* export default function Error(props: { error: Error & { digest?: string }; reset: () => void }) {
|
|
44
|
+
* return <GlobalError {...props} accentColor="#f86c4f" />
|
|
45
|
+
* }
|
|
46
|
+
*/
|
|
47
|
+
export function GlobalError({
|
|
48
|
+
error,
|
|
49
|
+
reset,
|
|
50
|
+
lang = 'en',
|
|
51
|
+
pageTitle = 'Application error',
|
|
52
|
+
title = 'Application error',
|
|
53
|
+
description = 'We encountered a problem loading the application. Please try refreshing the page, or contact support if the issue persists.',
|
|
54
|
+
retryLabel = 'Try again',
|
|
55
|
+
homeLabel = 'Go home',
|
|
56
|
+
homeHref = '/',
|
|
57
|
+
accentColor = '#2563eb',
|
|
58
|
+
onError,
|
|
59
|
+
}: GlobalErrorProps) {
|
|
60
|
+
React.useEffect(() => {
|
|
61
|
+
onError?.(error)
|
|
62
|
+
}, [error, onError])
|
|
63
|
+
|
|
64
|
+
const isDevelopment = process.env.NODE_ENV === 'development'
|
|
65
|
+
|
|
66
|
+
const goHome = () => {
|
|
67
|
+
if (homeHref && typeof window !== 'undefined') {
|
|
68
|
+
window.location.href = homeHref
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<html lang={lang}>
|
|
74
|
+
<head>
|
|
75
|
+
<meta charSet="utf-8" />
|
|
76
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
77
|
+
<title>{pageTitle}</title>
|
|
78
|
+
</head>
|
|
79
|
+
<body
|
|
80
|
+
style={{
|
|
81
|
+
margin: 0,
|
|
82
|
+
minHeight: '100vh',
|
|
83
|
+
display: 'flex',
|
|
84
|
+
alignItems: 'center',
|
|
85
|
+
justifyContent: 'center',
|
|
86
|
+
padding: '1rem',
|
|
87
|
+
backgroundColor: '#f9fafb',
|
|
88
|
+
color: '#1f2937',
|
|
89
|
+
fontFamily:
|
|
90
|
+
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
|
|
91
|
+
lineHeight: 1.5,
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
<div
|
|
95
|
+
style={{
|
|
96
|
+
maxWidth: '36rem',
|
|
97
|
+
width: '100%',
|
|
98
|
+
background: '#ffffff',
|
|
99
|
+
borderRadius: '0.75rem',
|
|
100
|
+
boxShadow: '0 10px 25px rgba(0, 0, 0, 0.1)',
|
|
101
|
+
border: '1px solid #e5e7eb',
|
|
102
|
+
padding: '2rem',
|
|
103
|
+
textAlign: 'center',
|
|
104
|
+
}}
|
|
105
|
+
role="alert"
|
|
106
|
+
>
|
|
107
|
+
<div
|
|
108
|
+
style={{
|
|
109
|
+
width: '4rem',
|
|
110
|
+
height: '4rem',
|
|
111
|
+
margin: '0 auto 1.5rem',
|
|
112
|
+
borderRadius: '50%',
|
|
113
|
+
backgroundColor: '#fee2e2',
|
|
114
|
+
display: 'flex',
|
|
115
|
+
alignItems: 'center',
|
|
116
|
+
justifyContent: 'center',
|
|
117
|
+
}}
|
|
118
|
+
aria-hidden="true"
|
|
119
|
+
>
|
|
120
|
+
<svg
|
|
121
|
+
width="32"
|
|
122
|
+
height="32"
|
|
123
|
+
viewBox="0 0 24 24"
|
|
124
|
+
fill="none"
|
|
125
|
+
stroke="#dc2626"
|
|
126
|
+
strokeWidth="2"
|
|
127
|
+
strokeLinecap="round"
|
|
128
|
+
strokeLinejoin="round"
|
|
129
|
+
>
|
|
130
|
+
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
|
|
131
|
+
<line x1="12" y1="9" x2="12" y2="13" />
|
|
132
|
+
<line x1="12" y1="17" x2="12.01" y2="17" />
|
|
133
|
+
</svg>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<h1
|
|
137
|
+
style={{
|
|
138
|
+
fontSize: '1.75rem',
|
|
139
|
+
fontWeight: 700,
|
|
140
|
+
margin: '0 0 0.75rem',
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
{title}
|
|
144
|
+
</h1>
|
|
145
|
+
<p style={{ color: '#6b7280', margin: '0 0 1.5rem' }}>{description}</p>
|
|
146
|
+
|
|
147
|
+
{isDevelopment && (
|
|
148
|
+
<div
|
|
149
|
+
style={{
|
|
150
|
+
backgroundColor: '#fef2f2',
|
|
151
|
+
border: '1px solid #fecaca',
|
|
152
|
+
borderRadius: '0.5rem',
|
|
153
|
+
padding: '1rem',
|
|
154
|
+
marginBottom: '1.5rem',
|
|
155
|
+
textAlign: 'left',
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
<p
|
|
159
|
+
style={{
|
|
160
|
+
fontSize: '0.75rem',
|
|
161
|
+
fontWeight: 600,
|
|
162
|
+
color: '#7f1d1d',
|
|
163
|
+
margin: '0 0 0.5rem',
|
|
164
|
+
}}
|
|
165
|
+
>
|
|
166
|
+
Error (development only)
|
|
167
|
+
</p>
|
|
168
|
+
<p
|
|
169
|
+
style={{
|
|
170
|
+
fontFamily: 'monospace',
|
|
171
|
+
fontSize: '0.875rem',
|
|
172
|
+
color: '#991b1b',
|
|
173
|
+
wordBreak: 'break-word',
|
|
174
|
+
margin: 0,
|
|
175
|
+
}}
|
|
176
|
+
>
|
|
177
|
+
{error.message}
|
|
178
|
+
</p>
|
|
179
|
+
{error.digest && (
|
|
180
|
+
<p
|
|
181
|
+
style={{
|
|
182
|
+
fontFamily: 'monospace',
|
|
183
|
+
fontSize: '0.75rem',
|
|
184
|
+
color: '#991b1b',
|
|
185
|
+
marginTop: '0.5rem',
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
Digest: {error.digest}
|
|
189
|
+
</p>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
|
|
194
|
+
{!isDevelopment && error.digest && (
|
|
195
|
+
<p
|
|
196
|
+
style={{
|
|
197
|
+
fontSize: '0.75rem',
|
|
198
|
+
color: '#6b7280',
|
|
199
|
+
marginBottom: '1.5rem',
|
|
200
|
+
}}
|
|
201
|
+
>
|
|
202
|
+
Reference:{' '}
|
|
203
|
+
<code
|
|
204
|
+
style={{
|
|
205
|
+
fontFamily: 'monospace',
|
|
206
|
+
backgroundColor: '#f3f4f6',
|
|
207
|
+
padding: '0.125rem 0.375rem',
|
|
208
|
+
borderRadius: '0.25rem',
|
|
209
|
+
}}
|
|
210
|
+
>
|
|
211
|
+
{error.digest}
|
|
212
|
+
</code>
|
|
213
|
+
</p>
|
|
214
|
+
)}
|
|
215
|
+
|
|
216
|
+
<div
|
|
217
|
+
style={{
|
|
218
|
+
display: 'flex',
|
|
219
|
+
flexWrap: 'wrap',
|
|
220
|
+
gap: '0.75rem',
|
|
221
|
+
justifyContent: 'center',
|
|
222
|
+
}}
|
|
223
|
+
>
|
|
224
|
+
<button
|
|
225
|
+
onClick={reset}
|
|
226
|
+
style={{
|
|
227
|
+
padding: '0.625rem 1.25rem',
|
|
228
|
+
borderRadius: '0.5rem',
|
|
229
|
+
border: 'none',
|
|
230
|
+
cursor: 'pointer',
|
|
231
|
+
fontWeight: 600,
|
|
232
|
+
fontSize: '0.95rem',
|
|
233
|
+
color: '#ffffff',
|
|
234
|
+
backgroundColor: accentColor,
|
|
235
|
+
}}
|
|
236
|
+
>
|
|
237
|
+
{retryLabel}
|
|
238
|
+
</button>
|
|
239
|
+
{homeHref && (
|
|
240
|
+
<button
|
|
241
|
+
onClick={goHome}
|
|
242
|
+
style={{
|
|
243
|
+
padding: '0.625rem 1.25rem',
|
|
244
|
+
borderRadius: '0.5rem',
|
|
245
|
+
border: 'none',
|
|
246
|
+
cursor: 'pointer',
|
|
247
|
+
fontWeight: 600,
|
|
248
|
+
fontSize: '0.95rem',
|
|
249
|
+
color: '#374151',
|
|
250
|
+
backgroundColor: '#f3f4f6',
|
|
251
|
+
}}
|
|
252
|
+
>
|
|
253
|
+
{homeLabel}
|
|
254
|
+
</button>
|
|
255
|
+
)}
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
</body>
|
|
259
|
+
</html>
|
|
260
|
+
)
|
|
261
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cn } from '../../lib/utils'
|
|
3
|
+
|
|
4
|
+
export interface NotFoundProps {
|
|
5
|
+
/** Large code shown above the title (e.g. "404"). Pass null to hide. */
|
|
6
|
+
code?: string | null
|
|
7
|
+
/** Heading. */
|
|
8
|
+
title?: string
|
|
9
|
+
/** Body copy. */
|
|
10
|
+
description?: string
|
|
11
|
+
/**
|
|
12
|
+
* Primary action. Provide your own element (e.g. a Next `<Link>`) so the
|
|
13
|
+
* package stays free of a hard `next/link` dependency. Defaults to a plain
|
|
14
|
+
* anchor to `/`.
|
|
15
|
+
*/
|
|
16
|
+
action?: React.ReactNode
|
|
17
|
+
/** Override the outer wrapper className. */
|
|
18
|
+
className?: string
|
|
19
|
+
/** Extra content rendered below the action (e.g. a secondary link). */
|
|
20
|
+
children?: React.ReactNode
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Default 404 surface for an app's `app/not-found.tsx`.
|
|
25
|
+
*
|
|
26
|
+
* Neutral, brandable, and SSR-safe (no client hooks). Pass an `action` node —
|
|
27
|
+
* typically a Next `<Link>` — for app-aware navigation.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* // app/not-found.tsx
|
|
31
|
+
* import Link from 'next/link'
|
|
32
|
+
* import { NotFound } from '@startsimpli/ui'
|
|
33
|
+
* export default function Page() {
|
|
34
|
+
* return (
|
|
35
|
+
* <NotFound action={<Link href="/" className="...">Go home</Link>} />
|
|
36
|
+
* )
|
|
37
|
+
* }
|
|
38
|
+
*/
|
|
39
|
+
export function NotFound({
|
|
40
|
+
code = '404',
|
|
41
|
+
title = 'Page not found',
|
|
42
|
+
description = "The page you're looking for doesn't exist or has been moved.",
|
|
43
|
+
action,
|
|
44
|
+
className,
|
|
45
|
+
children,
|
|
46
|
+
}: NotFoundProps) {
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
className={cn(
|
|
50
|
+
'min-h-[60vh] flex items-center justify-center p-4 text-center',
|
|
51
|
+
className
|
|
52
|
+
)}
|
|
53
|
+
role="status"
|
|
54
|
+
aria-live="polite"
|
|
55
|
+
>
|
|
56
|
+
<div className="max-w-md w-full">
|
|
57
|
+
{code != null && (
|
|
58
|
+
<p className="mb-2 text-6xl font-bold tracking-tight text-muted-foreground">
|
|
59
|
+
{code}
|
|
60
|
+
</p>
|
|
61
|
+
)}
|
|
62
|
+
<h1 className="mb-3 text-2xl font-semibold text-foreground">{title}</h1>
|
|
63
|
+
<p className="mb-8 text-sm text-muted-foreground">{description}</p>
|
|
64
|
+
<div className="flex flex-col items-center gap-3 sm:flex-row sm:justify-center">
|
|
65
|
+
{action ?? (
|
|
66
|
+
<a
|
|
67
|
+
href="/"
|
|
68
|
+
className="inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
|
69
|
+
>
|
|
70
|
+
Go home
|
|
71
|
+
</a>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
{children}
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { cn } from '../../lib/utils'
|
|
5
|
+
|
|
6
|
+
export interface RouteError extends Error {
|
|
7
|
+
digest?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RouteErrorBoundaryProps {
|
|
11
|
+
/** The error thrown during render — matches the Next.js `error.tsx` contract. */
|
|
12
|
+
error: RouteError
|
|
13
|
+
/** Re-render the route segment — matches the Next.js `error.tsx` contract. */
|
|
14
|
+
reset: () => void
|
|
15
|
+
/** Heading shown above the message. */
|
|
16
|
+
title?: string
|
|
17
|
+
/** Body copy shown in production (the raw error is only shown in development). */
|
|
18
|
+
description?: string
|
|
19
|
+
/** Label for the primary (retry) action. */
|
|
20
|
+
retryLabel?: string
|
|
21
|
+
/** Label for the secondary (home) action. Hidden when `homeHref` is null. */
|
|
22
|
+
homeLabel?: string
|
|
23
|
+
/** Destination for the secondary action. Pass `null` to hide it. Defaults to `/`. */
|
|
24
|
+
homeHref?: string | null
|
|
25
|
+
/**
|
|
26
|
+
* Render-prop override for the entire surface. Receives the error plus the
|
|
27
|
+
* resolved `reset`/`goHome` handlers so apps can fully brand the page while
|
|
28
|
+
* keeping the Next.js wiring.
|
|
29
|
+
*/
|
|
30
|
+
children?: (props: {
|
|
31
|
+
error: RouteError
|
|
32
|
+
reset: () => void
|
|
33
|
+
goHome: () => void
|
|
34
|
+
}) => React.ReactNode
|
|
35
|
+
/** Called once on mount with the error — wire up your logger/Sentry here. */
|
|
36
|
+
onError?: (error: RouteError) => void
|
|
37
|
+
/** Override the outer wrapper className. */
|
|
38
|
+
className?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Route-level error surface for an app's `app/error.tsx`.
|
|
43
|
+
*
|
|
44
|
+
* Implements the Next.js App Router error contract (`{ error, reset }`) and
|
|
45
|
+
* renders a neutral, brandable error card. Stack/digest details are only shown
|
|
46
|
+
* in development. Use render-prop `children` for full custom UI.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* // app/error.tsx
|
|
50
|
+
* 'use client'
|
|
51
|
+
* import { RouteErrorBoundary } from '@startsimpli/ui'
|
|
52
|
+
* export default function Error(props: { error: Error & { digest?: string }; reset: () => void }) {
|
|
53
|
+
* return <RouteErrorBoundary {...props} />
|
|
54
|
+
* }
|
|
55
|
+
*/
|
|
56
|
+
export function RouteErrorBoundary({
|
|
57
|
+
error,
|
|
58
|
+
reset,
|
|
59
|
+
title = 'Something went wrong',
|
|
60
|
+
description = 'We encountered an unexpected error. Please try again, or head back home if the problem persists.',
|
|
61
|
+
retryLabel = 'Try again',
|
|
62
|
+
homeLabel = 'Go home',
|
|
63
|
+
homeHref = '/',
|
|
64
|
+
children,
|
|
65
|
+
onError,
|
|
66
|
+
className,
|
|
67
|
+
}: RouteErrorBoundaryProps) {
|
|
68
|
+
React.useEffect(() => {
|
|
69
|
+
onError?.(error)
|
|
70
|
+
}, [error, onError])
|
|
71
|
+
|
|
72
|
+
const goHome = React.useCallback(() => {
|
|
73
|
+
if (homeHref && typeof window !== 'undefined') {
|
|
74
|
+
window.location.href = homeHref
|
|
75
|
+
}
|
|
76
|
+
}, [homeHref])
|
|
77
|
+
|
|
78
|
+
if (children) {
|
|
79
|
+
return <>{children({ error, reset, goHome })}</>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const isDevelopment = process.env.NODE_ENV === 'development'
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div
|
|
86
|
+
className={cn(
|
|
87
|
+
'min-h-[60vh] flex items-center justify-center p-4',
|
|
88
|
+
className
|
|
89
|
+
)}
|
|
90
|
+
role="alert"
|
|
91
|
+
aria-live="assertive"
|
|
92
|
+
>
|
|
93
|
+
<div className="max-w-lg w-full rounded-xl border border-border bg-background p-8 shadow-sm text-center">
|
|
94
|
+
<div
|
|
95
|
+
className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-red-50"
|
|
96
|
+
aria-hidden="true"
|
|
97
|
+
>
|
|
98
|
+
<svg
|
|
99
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
100
|
+
width="32"
|
|
101
|
+
height="32"
|
|
102
|
+
viewBox="0 0 24 24"
|
|
103
|
+
fill="none"
|
|
104
|
+
stroke="currentColor"
|
|
105
|
+
strokeWidth="2"
|
|
106
|
+
strokeLinecap="round"
|
|
107
|
+
strokeLinejoin="round"
|
|
108
|
+
className="text-red-500"
|
|
109
|
+
>
|
|
110
|
+
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
|
|
111
|
+
<line x1="12" y1="9" x2="12" y2="13" />
|
|
112
|
+
<line x1="12" y1="17" x2="12.01" y2="17" />
|
|
113
|
+
</svg>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<h1 className="text-2xl font-semibold text-foreground mb-3">{title}</h1>
|
|
117
|
+
<p className="text-sm text-muted-foreground mb-6 max-w-md mx-auto">
|
|
118
|
+
{description}
|
|
119
|
+
</p>
|
|
120
|
+
|
|
121
|
+
{isDevelopment && (
|
|
122
|
+
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 p-4 text-left">
|
|
123
|
+
<p className="mb-1 text-xs font-semibold text-red-800">
|
|
124
|
+
Error (development only)
|
|
125
|
+
</p>
|
|
126
|
+
<p className="break-words font-mono text-sm text-red-700">
|
|
127
|
+
{error.message}
|
|
128
|
+
</p>
|
|
129
|
+
{error.digest && (
|
|
130
|
+
<p className="mt-2 font-mono text-xs text-red-700">
|
|
131
|
+
Digest: {error.digest}
|
|
132
|
+
</p>
|
|
133
|
+
)}
|
|
134
|
+
{error.stack && (
|
|
135
|
+
<details className="mt-2">
|
|
136
|
+
<summary className="cursor-pointer text-xs font-semibold text-red-800">
|
|
137
|
+
Stack trace
|
|
138
|
+
</summary>
|
|
139
|
+
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-all rounded border border-red-200 bg-background p-3 text-xs">
|
|
140
|
+
{error.stack}
|
|
141
|
+
</pre>
|
|
142
|
+
</details>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{!isDevelopment && error.digest && (
|
|
148
|
+
<p className="mb-6 text-xs text-muted-foreground">
|
|
149
|
+
Reference:{' '}
|
|
150
|
+
<code className="rounded bg-muted px-1.5 py-0.5 font-mono">
|
|
151
|
+
{error.digest}
|
|
152
|
+
</code>
|
|
153
|
+
</p>
|
|
154
|
+
)}
|
|
155
|
+
|
|
156
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:justify-center">
|
|
157
|
+
<button
|
|
158
|
+
onClick={reset}
|
|
159
|
+
className="inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
|
160
|
+
>
|
|
161
|
+
{retryLabel}
|
|
162
|
+
</button>
|
|
163
|
+
{homeHref && (
|
|
164
|
+
<button
|
|
165
|
+
onClick={goHome}
|
|
166
|
+
className="inline-flex h-9 items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
|
167
|
+
>
|
|
168
|
+
{homeLabel}
|
|
169
|
+
</button>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { RouteErrorBoundary } from './RouteErrorBoundary'
|
|
2
|
+
export type { RouteErrorBoundaryProps, RouteError } from './RouteErrorBoundary'
|
|
3
|
+
export { GlobalError } from './GlobalError'
|
|
4
|
+
export type { GlobalErrorProps } from './GlobalError'
|
|
5
|
+
export { NotFound } from './NotFound'
|
|
6
|
+
export type { NotFoundProps } from './NotFound'
|
package/src/components/index.ts
CHANGED
|
@@ -71,6 +71,11 @@ export * from './toast'
|
|
|
71
71
|
// State components (ErrorState, EmptyState)
|
|
72
72
|
export * from './states'
|
|
73
73
|
|
|
74
|
+
// Route-level error/404 surfaces (RouteErrorBoundary, GlobalError, NotFound)
|
|
75
|
+
// for app/error.tsx, app/global-error.tsx, app/not-found.tsx shells.
|
|
76
|
+
// Note: ApiErrorBoundary (subtree React error boundary) lives in ./ui.
|
|
77
|
+
export * from './error'
|
|
78
|
+
|
|
74
79
|
// BaseDialog compound component
|
|
75
80
|
export * from './dialog'
|
|
76
81
|
|