@mars-stack/cli 0.2.0 → 0.2.2

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 (173) hide show
  1. package/package.json +2 -2
  2. package/template/.cursor/rules/composition-patterns.mdc +186 -0
  3. package/template/.cursor/rules/data-access.mdc +29 -0
  4. package/template/.cursor/rules/project-structure.mdc +34 -0
  5. package/template/.cursor/rules/security.mdc +25 -0
  6. package/template/.cursor/rules/testing.mdc +24 -0
  7. package/template/.cursor/rules/ui-conventions.mdc +29 -0
  8. package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
  9. package/template/.cursor/skills/add-audit-log/SKILL.md +373 -0
  10. package/template/.cursor/skills/add-blog/SKILL.md +447 -0
  11. package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
  12. package/template/.cursor/skills/add-component/SKILL.md +158 -0
  13. package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
  14. package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
  15. package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
  16. package/template/.cursor/skills/add-feature/SKILL.md +174 -0
  17. package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
  18. package/template/.cursor/skills/add-page/SKILL.md +151 -0
  19. package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
  20. package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
  21. package/template/.cursor/skills/add-role/SKILL.md +156 -0
  22. package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
  23. package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
  24. package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
  25. package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
  26. package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
  27. package/template/.cursor/skills/build-form/SKILL.md +231 -0
  28. package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
  29. package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
  30. package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
  31. package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
  32. package/template/.cursor/skills/configure-email/SKILL.md +170 -0
  33. package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
  34. package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
  35. package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
  36. package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
  37. package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
  38. package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
  39. package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
  40. package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
  41. package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
  42. package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
  43. package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
  44. package/template/.cursor/skills/configure-search/SKILL.md +581 -0
  45. package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
  46. package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
  47. package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
  48. package/template/.cursor/skills/create-seed/SKILL.md +191 -0
  49. package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
  50. package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
  51. package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
  52. package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
  53. package/template/.cursor/skills/setup-project/SKILL.md +104 -0
  54. package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
  55. package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
  56. package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
  57. package/template/AGENTS.md +104 -0
  58. package/template/ARCHITECTURE.md +102 -0
  59. package/template/docs/QUALITY_SCORE.md +20 -0
  60. package/template/docs/design-docs/conversation-as-system-record.md +70 -0
  61. package/template/docs/design-docs/core-beliefs.md +43 -0
  62. package/template/docs/design-docs/index.md +8 -0
  63. package/template/docs/exec-plans/active/.gitkeep +0 -0
  64. package/template/docs/exec-plans/completed/.gitkeep +0 -0
  65. package/template/docs/exec-plans/tech-debt.md +7 -0
  66. package/template/docs/generated/.gitkeep +0 -0
  67. package/template/docs/product-specs/index.md +7 -0
  68. package/template/docs/references/index.md +18 -0
  69. package/template/e2e/api.spec.ts +20 -0
  70. package/template/e2e/auth.spec.ts +24 -0
  71. package/template/e2e/public.spec.ts +25 -0
  72. package/template/eslint.config.mjs +24 -0
  73. package/template/next-env.d.ts +6 -0
  74. package/template/next.config.ts +45 -0
  75. package/template/package.json +80 -0
  76. package/template/playwright.config.ts +31 -0
  77. package/template/postcss.config.mjs +8 -0
  78. package/template/prisma/generated/prisma/browser.ts +49 -0
  79. package/template/prisma/generated/prisma/client.ts +73 -0
  80. package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
  81. package/template/prisma/generated/prisma/enums.ts +15 -0
  82. package/template/prisma/generated/prisma/internal/class.ts +254 -0
  83. package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
  84. package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
  85. package/template/prisma/generated/prisma/models/Account.ts +1543 -0
  86. package/template/prisma/generated/prisma/models/File.ts +1529 -0
  87. package/template/prisma/generated/prisma/models/Session.ts +1415 -0
  88. package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
  89. package/template/prisma/generated/prisma/models/User.ts +2235 -0
  90. package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
  91. package/template/prisma/generated/prisma/models.ts +17 -0
  92. package/template/prisma/schema/auth.prisma +69 -0
  93. package/template/prisma/schema/base.prisma +8 -0
  94. package/template/prisma/schema/file.prisma +15 -0
  95. package/template/prisma/schema/subscription.prisma +17 -0
  96. package/template/prisma.config.ts +13 -0
  97. package/template/scripts/check-architecture.ts +221 -0
  98. package/template/scripts/check-doc-freshness.ts +242 -0
  99. package/template/scripts/ensure-db.mjs +291 -0
  100. package/template/scripts/generate-docs.ts +143 -0
  101. package/template/scripts/generate-env-example.ts +89 -0
  102. package/template/scripts/seed.ts +56 -0
  103. package/template/scripts/update-quality-score.ts +263 -0
  104. package/template/src/__tests__/architecture.test.ts +114 -0
  105. package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
  106. package/template/src/app/(auth)/layout.tsx +11 -0
  107. package/template/src/app/(auth)/register/page.tsx +162 -0
  108. package/template/src/app/(auth)/reset-password/page.tsx +109 -0
  109. package/template/src/app/(auth)/sign-in/page.tsx +122 -0
  110. package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
  111. package/template/src/app/(auth)/verify/page.tsx +56 -0
  112. package/template/src/app/(protected)/admin/page.tsx +108 -0
  113. package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
  114. package/template/src/app/(protected)/dashboard/page.tsx +22 -0
  115. package/template/src/app/(protected)/layout.tsx +262 -0
  116. package/template/src/app/(protected)/settings/page.tsx +370 -0
  117. package/template/src/app/api/auth/forgot/route.ts +63 -0
  118. package/template/src/app/api/auth/login/route.ts +121 -0
  119. package/template/src/app/api/auth/logout/route.ts +19 -0
  120. package/template/src/app/api/auth/me/route.ts +30 -0
  121. package/template/src/app/api/auth/reset/route.ts +45 -0
  122. package/template/src/app/api/auth/signup/route.ts +85 -0
  123. package/template/src/app/api/auth/verify/route.ts +46 -0
  124. package/template/src/app/api/csrf/route.ts +12 -0
  125. package/template/src/app/api/health/route.ts +10 -0
  126. package/template/src/app/api/protected/admin/users/route.ts +24 -0
  127. package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
  128. package/template/src/app/api/protected/billing/portal/route.ts +39 -0
  129. package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
  130. package/template/src/app/api/protected/files/upload/route.ts +64 -0
  131. package/template/src/app/api/protected/user/password/route.ts +63 -0
  132. package/template/src/app/api/protected/user/profile/route.ts +35 -0
  133. package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
  134. package/template/src/app/api/protected/user/sessions/route.ts +22 -0
  135. package/template/src/app/api/readiness/route.ts +15 -0
  136. package/template/src/app/api/webhooks/stripe/route.ts +166 -0
  137. package/template/src/app/error.tsx +33 -0
  138. package/template/src/app/layout.tsx +29 -0
  139. package/template/src/app/not-found.tsx +20 -0
  140. package/template/src/app/page.tsx +136 -0
  141. package/template/src/app/privacy/page.tsx +178 -0
  142. package/template/src/app/providers.tsx +8 -0
  143. package/template/src/app/terms/page.tsx +139 -0
  144. package/template/src/config/app.config.ts +70 -0
  145. package/template/src/config/routes.ts +17 -0
  146. package/template/src/features/admin/index.ts +11 -0
  147. package/template/src/features/admin/permissions.ts +64 -0
  148. package/template/src/features/auth/context/AuthContext.tsx +96 -0
  149. package/template/src/features/auth/context/index.ts +2 -0
  150. package/template/src/features/auth/index.ts +3 -0
  151. package/template/src/features/auth/server/consent.ts +66 -0
  152. package/template/src/features/auth/server/session-revocation.ts +20 -0
  153. package/template/src/features/auth/server/sessions.ts +66 -0
  154. package/template/src/features/auth/server/user.ts +166 -0
  155. package/template/src/features/auth/types.ts +19 -0
  156. package/template/src/features/auth/validators.ts +29 -0
  157. package/template/src/features/billing/server/index.ts +66 -0
  158. package/template/src/features/billing/types.ts +43 -0
  159. package/template/src/features/uploads/server/index.ts +49 -0
  160. package/template/src/features/uploads/types.ts +26 -0
  161. package/template/src/lib/core/email/templates/base-layout.ts +122 -0
  162. package/template/src/lib/core/email/templates/index.ts +4 -0
  163. package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
  164. package/template/src/lib/core/email/templates/verification-email.ts +41 -0
  165. package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
  166. package/template/src/lib/mars.ts +56 -0
  167. package/template/src/lib/prisma.ts +19 -0
  168. package/template/src/proxy.ts +92 -0
  169. package/template/src/styles/brand.css +17 -0
  170. package/template/src/styles/globals.css +6 -0
  171. package/template/tsconfig.json +59 -0
  172. package/template/vitest.config.ts +41 -0
  173. package/template/vitest.setup.ts +24 -0
@@ -0,0 +1,472 @@
1
+ # Skill: Add Error Boundary
2
+
3
+ Set up React error boundaries with fallback UI, Next.js `error.tsx` files for route segments, error reporting integration, and toast notifications for caught errors.
4
+
5
+ ## When to Use
6
+
7
+ Use this skill when the user asks to add error handling, error boundaries, crash recovery, error reporting, Sentry integration, or global error handling to the application.
8
+
9
+ ## Prerequisites
10
+
11
+ - Read `src/app/layout.tsx` to see the current root layout structure.
12
+ - Read `src/app/(protected)/layout.tsx` to see the protected layout.
13
+
14
+ ## Step 1: Error Boundary Component
15
+
16
+ ```tsx
17
+ // src/features/error-handling/components/ErrorBoundary.tsx
18
+ 'use client';
19
+
20
+ import { Component, type ErrorInfo, type ReactNode } from 'react';
21
+
22
+ interface ErrorBoundaryProps {
23
+ children: ReactNode;
24
+ fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);
25
+ onError?: (error: Error, errorInfo: ErrorInfo) => void;
26
+ }
27
+
28
+ interface ErrorBoundaryState {
29
+ hasError: boolean;
30
+ error: Error | null;
31
+ }
32
+
33
+ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
34
+ constructor(props: ErrorBoundaryProps) {
35
+ super(props);
36
+ this.state = { hasError: false, error: null };
37
+ }
38
+
39
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
40
+ return { hasError: true, error };
41
+ }
42
+
43
+ componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
44
+ this.props.onError?.(error, errorInfo);
45
+ reportError(error, errorInfo);
46
+ }
47
+
48
+ reset = (): void => {
49
+ this.setState({ hasError: false, error: null });
50
+ };
51
+
52
+ render(): ReactNode {
53
+ if (this.state.hasError && this.state.error) {
54
+ if (typeof this.props.fallback === 'function') {
55
+ return this.props.fallback(this.state.error, this.reset);
56
+ }
57
+ if (this.props.fallback) {
58
+ return this.props.fallback;
59
+ }
60
+ return <DefaultErrorFallback error={this.state.error} reset={this.reset} />;
61
+ }
62
+ return this.props.children;
63
+ }
64
+ }
65
+
66
+ function DefaultErrorFallback({ error, reset }: { error: Error; reset: () => void }) {
67
+ return (
68
+ <div className="flex min-h-[300px] flex-col items-center justify-center gap-4 p-6">
69
+ <div className="rounded-lg border border-border-primary bg-surface-secondary p-6 text-center">
70
+ <h2 className="mb-2 text-lg font-semibold text-text-primary">
71
+ Something went wrong
72
+ </h2>
73
+ <p className="mb-4 text-sm text-text-secondary">
74
+ {error.message || 'An unexpected error occurred.'}
75
+ </p>
76
+ <button
77
+ onClick={reset}
78
+ className="rounded-md bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2"
79
+ >
80
+ Try again
81
+ </button>
82
+ </div>
83
+ </div>
84
+ );
85
+ }
86
+
87
+ function reportError(error: Error, errorInfo: ErrorInfo): void {
88
+ if (typeof window !== 'undefined' && window.Sentry) {
89
+ window.Sentry.captureException(error, {
90
+ extra: { componentStack: errorInfo.componentStack },
91
+ });
92
+ }
93
+ }
94
+
95
+ declare global {
96
+ interface Window {
97
+ Sentry?: {
98
+ captureException: (error: unknown, context?: Record<string, unknown>) => void;
99
+ };
100
+ }
101
+ }
102
+ ```
103
+
104
+ ## Step 2: Route-Level Error Files
105
+
106
+ ### Global error handler
107
+
108
+ ```tsx
109
+ // src/app/global-error.tsx
110
+ 'use client';
111
+
112
+ export default function GlobalError({
113
+ error,
114
+ reset,
115
+ }: {
116
+ error: Error & { digest?: string };
117
+ reset: () => void;
118
+ }) {
119
+ return (
120
+ <html>
121
+ <body>
122
+ <div style={{
123
+ display: 'flex',
124
+ minHeight: '100vh',
125
+ alignItems: 'center',
126
+ justifyContent: 'center',
127
+ fontFamily: 'system-ui, sans-serif',
128
+ }}>
129
+ <div style={{ textAlign: 'center', maxWidth: '400px', padding: '2rem' }}>
130
+ <h1 style={{ fontSize: '1.5rem', fontWeight: 600, marginBottom: '0.5rem' }}>
131
+ Application Error
132
+ </h1>
133
+ <p style={{ color: '#666', marginBottom: '1.5rem' }}>
134
+ A critical error occurred. Please try refreshing the page.
135
+ </p>
136
+ {error.digest && (
137
+ <p style={{ color: '#999', fontSize: '0.75rem', marginBottom: '1rem' }}>
138
+ Error ID: {error.digest}
139
+ </p>
140
+ )}
141
+ <button
142
+ onClick={reset}
143
+ style={{
144
+ padding: '0.5rem 1.5rem',
145
+ borderRadius: '0.375rem',
146
+ border: 'none',
147
+ backgroundColor: '#2563eb',
148
+ color: 'white',
149
+ cursor: 'pointer',
150
+ fontSize: '0.875rem',
151
+ fontWeight: 500,
152
+ }}
153
+ >
154
+ Refresh
155
+ </button>
156
+ </div>
157
+ </div>
158
+ </body>
159
+ </html>
160
+ );
161
+ }
162
+ ```
163
+
164
+ ### Protected route error handler
165
+
166
+ ```tsx
167
+ // src/app/(protected)/error.tsx
168
+ 'use client';
169
+
170
+ import { Button } from '@mars-stack/ui';
171
+ import { H1, Paragraph } from '@mars-stack/ui';
172
+
173
+ export default function ProtectedError({
174
+ error,
175
+ reset,
176
+ }: {
177
+ error: Error & { digest?: string };
178
+ reset: () => void;
179
+ }) {
180
+ return (
181
+ <div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 p-6">
182
+ <H1>Something went wrong</H1>
183
+ <Paragraph className="max-w-md text-center text-text-secondary">
184
+ {error.message || 'An unexpected error occurred. Please try again.'}
185
+ </Paragraph>
186
+ {error.digest && (
187
+ <p className="text-xs text-text-tertiary">Error ID: {error.digest}</p>
188
+ )}
189
+ <div className="flex gap-3">
190
+ <Button onClick={reset} variant="primary">
191
+ Try again
192
+ </Button>
193
+ <Button onClick={() => window.location.href = '/'} variant="secondary">
194
+ Go home
195
+ </Button>
196
+ </div>
197
+ </div>
198
+ );
199
+ }
200
+ ```
201
+
202
+ ### Auth route error handler
203
+
204
+ ```tsx
205
+ // src/app/(auth)/error.tsx
206
+ 'use client';
207
+
208
+ import { Button } from '@mars-stack/ui';
209
+
210
+ export default function AuthError({
211
+ error,
212
+ reset,
213
+ }: {
214
+ error: Error & { digest?: string };
215
+ reset: () => void;
216
+ }) {
217
+ return (
218
+ <div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 p-6">
219
+ <h2 className="text-lg font-semibold text-text-primary">Authentication Error</h2>
220
+ <p className="text-sm text-text-secondary">
221
+ {error.message || 'Something went wrong during authentication.'}
222
+ </p>
223
+ <Button onClick={reset} variant="primary">
224
+ Try again
225
+ </Button>
226
+ </div>
227
+ );
228
+ }
229
+ ```
230
+
231
+ ## Step 3: Not Found Page
232
+
233
+ ```tsx
234
+ // src/app/not-found.tsx
235
+ import { Button } from '@mars-stack/ui';
236
+ import { H1, Paragraph } from '@mars-stack/ui';
237
+ import Link from 'next/link';
238
+
239
+ export default function NotFound() {
240
+ return (
241
+ <div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 p-6">
242
+ <H1>Page not found</H1>
243
+ <Paragraph className="text-text-secondary">
244
+ The page you're looking for doesn't exist or has been moved.
245
+ </Paragraph>
246
+ <Link href="/">
247
+ <Button variant="primary">Go home</Button>
248
+ </Link>
249
+ </div>
250
+ );
251
+ }
252
+ ```
253
+
254
+ ## Step 4: Sentry Integration (Optional)
255
+
256
+ ```bash
257
+ yarn add @sentry/nextjs
258
+ ```
259
+
260
+ ```typescript
261
+ // sentry.client.config.ts
262
+ import * as Sentry from '@sentry/nextjs';
263
+
264
+ Sentry.init({
265
+ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
266
+ tracesSampleRate: 0.1,
267
+ replaysSessionSampleRate: 0.1,
268
+ replaysOnErrorSampleRate: 1.0,
269
+ environment: process.env.NODE_ENV,
270
+ enabled: process.env.NODE_ENV === 'production',
271
+ });
272
+ ```
273
+
274
+ ```typescript
275
+ // sentry.server.config.ts
276
+ import * as Sentry from '@sentry/nextjs';
277
+
278
+ Sentry.init({
279
+ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
280
+ tracesSampleRate: 0.1,
281
+ environment: process.env.NODE_ENV,
282
+ enabled: process.env.NODE_ENV === 'production',
283
+ });
284
+ ```
285
+
286
+ ```typescript
287
+ // instrumentation.ts
288
+ export async function register() {
289
+ if (process.env.NEXT_RUNTIME === 'nodejs') {
290
+ await import('./sentry.server.config');
291
+ }
292
+ }
293
+
294
+ export const onRequestError = async (
295
+ error: { digest: string },
296
+ request: { method: string; url: string; headers: Record<string, string> },
297
+ context: { routerKind: string; routePath: string; routeType: string; renderSource: string },
298
+ ) => {
299
+ const Sentry = await import('@sentry/nextjs');
300
+ Sentry.captureRequestError(error, request, context);
301
+ };
302
+ ```
303
+
304
+ Update `next.config.ts`:
305
+
306
+ ```typescript
307
+ import { withSentryConfig } from '@sentry/nextjs';
308
+
309
+ const nextConfig = { /* existing config */ };
310
+
311
+ export default withSentryConfig(nextConfig, {
312
+ org: process.env.SENTRY_ORG,
313
+ project: process.env.SENTRY_PROJECT,
314
+ silent: true,
315
+ });
316
+ ```
317
+
318
+ ## Step 5: Toast Notifications for Caught Errors
319
+
320
+ Use the existing Toast pattern from `@mars-stack/ui`:
321
+
322
+ ```tsx
323
+ // src/features/error-handling/hooks/useErrorHandler.ts
324
+ 'use client';
325
+
326
+ import { useCallback } from 'react';
327
+ import { useToast } from '@mars-stack/ui/hooks';
328
+
329
+ export function useErrorHandler() {
330
+ const { addToast } = useToast();
331
+
332
+ const handleError = useCallback((error: unknown, context?: string) => {
333
+ const message = error instanceof Error ? error.message : 'An unexpected error occurred';
334
+
335
+ addToast({
336
+ type: 'error',
337
+ title: context ?? 'Error',
338
+ message,
339
+ });
340
+
341
+ if (process.env.NODE_ENV === 'development') {
342
+ console.error(`[${context ?? 'Error'}]`, error);
343
+ }
344
+ }, [addToast]);
345
+
346
+ return { handleError };
347
+ }
348
+ ```
349
+
350
+ Usage in components:
351
+
352
+ ```tsx
353
+ 'use client';
354
+
355
+ import { useErrorHandler } from '@/features/error-handling/hooks/useErrorHandler';
356
+
357
+ export function SaveButton() {
358
+ const { handleError } = useErrorHandler();
359
+
360
+ async function handleSave() {
361
+ try {
362
+ await fetch('/api/protected/resource', { method: 'POST', body: JSON.stringify(data) });
363
+ } catch (error) {
364
+ handleError(error, 'Failed to save');
365
+ }
366
+ }
367
+
368
+ return <Button onClick={handleSave}>Save</Button>;
369
+ }
370
+ ```
371
+
372
+ ## Step 6: API Error Handler Enhancement
373
+
374
+ The existing `handleApiError` from `@/lib/mars` handles server-side API errors. For client-side API calls, create a typed fetch wrapper:
375
+
376
+ ```typescript
377
+ // src/features/error-handling/utils/api-client.ts
378
+ export class ApiError extends Error {
379
+ constructor(
380
+ message: string,
381
+ public status: number,
382
+ public code?: string,
383
+ ) {
384
+ super(message);
385
+ this.name = 'ApiError';
386
+ }
387
+ }
388
+
389
+ export async function apiFetch<T>(
390
+ url: string,
391
+ options?: RequestInit,
392
+ ): Promise<T> {
393
+ const response = await fetch(url, {
394
+ ...options,
395
+ headers: {
396
+ 'Content-Type': 'application/json',
397
+ ...options?.headers,
398
+ },
399
+ });
400
+
401
+ if (!response.ok) {
402
+ const body = await response.json().catch(() => ({}));
403
+ throw new ApiError(
404
+ body.error ?? `Request failed with status ${response.status}`,
405
+ response.status,
406
+ body.code,
407
+ );
408
+ }
409
+
410
+ return response.json();
411
+ }
412
+ ```
413
+
414
+ ## Testing
415
+
416
+ ```typescript
417
+ import { describe, it, expect, vi } from 'vitest';
418
+ import { render, screen, fireEvent } from '@testing-library/react';
419
+ import { ErrorBoundary } from './ErrorBoundary';
420
+
421
+ function ThrowingComponent({ shouldThrow }: { shouldThrow: boolean }) {
422
+ if (shouldThrow) throw new Error('Test error');
423
+ return <div>Content</div>;
424
+ }
425
+
426
+ describe('ErrorBoundary', () => {
427
+ it('renders children when no error', () => {
428
+ render(
429
+ <ErrorBoundary>
430
+ <ThrowingComponent shouldThrow={false} />
431
+ </ErrorBoundary>,
432
+ );
433
+ expect(screen.getByText('Content')).toBeDefined();
434
+ });
435
+
436
+ it('renders fallback on error', () => {
437
+ vi.spyOn(console, 'error').mockImplementation(() => {});
438
+ render(
439
+ <ErrorBoundary>
440
+ <ThrowingComponent shouldThrow={true} />
441
+ </ErrorBoundary>,
442
+ );
443
+ expect(screen.getByText('Something went wrong')).toBeDefined();
444
+ });
445
+
446
+ it('calls onError callback', () => {
447
+ vi.spyOn(console, 'error').mockImplementation(() => {});
448
+ const onError = vi.fn();
449
+ render(
450
+ <ErrorBoundary onError={onError}>
451
+ <ThrowingComponent shouldThrow={true} />
452
+ </ErrorBoundary>,
453
+ );
454
+ expect(onError).toHaveBeenCalled();
455
+ });
456
+ });
457
+ ```
458
+
459
+ ## Checklist
460
+
461
+ - [ ] `ErrorBoundary` component created in `src/features/error-handling/components/`
462
+ - [ ] `global-error.tsx` created at app root (uses inline styles, no imports)
463
+ - [ ] `error.tsx` created for `(protected)` route group
464
+ - [ ] `error.tsx` created for `(auth)` route group
465
+ - [ ] `not-found.tsx` created at app root
466
+ - [ ] Error components use design system tokens (except `global-error.tsx`)
467
+ - [ ] Sentry configured (if error reporting is needed)
468
+ - [ ] `useErrorHandler` hook created for toast-based error display
469
+ - [ ] `apiFetch` utility created for typed client-side API calls
470
+ - [ ] Error boundaries wrap key feature sections
471
+ - [ ] Tests written for ErrorBoundary component
472
+ - [ ] Error digest IDs displayed for user-reportable errors
@@ -0,0 +1,174 @@
1
+ # Skill: Add a Feature Module
2
+
3
+ Add a new feature module to the MARS application, following the established conventions for structure, imports, and testing.
4
+
5
+ ## When to Use
6
+
7
+ Use this skill when the user asks to add a new feature, domain, or module to the app (e.g., "add billing", "add a blog", "add notifications").
8
+
9
+ ## Prerequisites
10
+
11
+ - Read `src/config/app.config.ts` to check if the feature already has a flag defined.
12
+
13
+ ## Steps
14
+
15
+ ### 1. Create the Feature Directory
16
+
17
+ ```
18
+ src/features/<name>/
19
+ ├── components/ # React components with business logic
20
+ │ └── index.ts # Barrel export
21
+ ├── server/ # Server-only logic (queries, mutations, services)
22
+ │ └── index.ts # Barrel export
23
+ ├── hooks/ # Feature-specific React hooks (optional)
24
+ │ └── index.ts
25
+ ├── validation/ # Zod schemas for API input validation
26
+ │ └── schemas.ts
27
+ └── types.ts # Feature-specific TypeScript types
28
+ ```
29
+
30
+ ### 2. Server Module Pattern
31
+
32
+ Every server module MUST start with `import 'server-only'` to prevent client bundling.
33
+
34
+ ```typescript
35
+ import 'server-only';
36
+
37
+ import { prisma } from '@/lib/prisma';
38
+
39
+ export async function findWidgetsByUserId(userId: string) {
40
+ return prisma.widget.findMany({
41
+ where: { userId },
42
+ orderBy: { createdAt: 'desc' },
43
+ });
44
+ }
45
+
46
+ export async function createWidget(userId: string, data: CreateWidgetInput) {
47
+ return prisma.widget.create({
48
+ data: { ...data, userId },
49
+ });
50
+ }
51
+ ```
52
+
53
+ Key rules:
54
+ - Always scope queries by `userId` from the session, never from request params.
55
+ - Use `$transaction` for multi-step writes.
56
+ - Import `prisma` from `@/lib/prisma`.
57
+
58
+ ### 3. Validation Schemas
59
+
60
+ Define Zod schemas for all API inputs:
61
+
62
+ ```typescript
63
+ import { z } from 'zod';
64
+
65
+ export const widgetSchemas = {
66
+ create: z.object({
67
+ name: z.string().min(1, 'Name is required').max(100),
68
+ description: z.string().max(500).optional(),
69
+ }),
70
+ update: z.object({
71
+ name: z.string().min(1).max(100).optional(),
72
+ description: z.string().max(500).optional(),
73
+ }),
74
+ };
75
+
76
+ export type CreateWidgetInput = z.infer<typeof widgetSchemas.create>;
77
+ export type UpdateWidgetInput = z.infer<typeof widgetSchemas.update>;
78
+ ```
79
+
80
+ ### 4. API Routes
81
+
82
+ Create routes under `src/app/api/protected/<name>/`:
83
+
84
+ ```typescript
85
+ import { handleApiError, withAuth, withAuthNoParams } from '@/lib/mars';
86
+ import { findWidgetsByUserId, createWidget } from '@/features/<name>/server';
87
+ import { widgetSchemas } from '@/features/<name>/validation/schemas';
88
+ import { NextResponse } from 'next/server';
89
+
90
+ export const GET = withAuthNoParams(async (request) => {
91
+ try {
92
+ const widgets = await findWidgetsByUserId(request.session.userId);
93
+ return NextResponse.json(widgets);
94
+ } catch (error) {
95
+ return handleApiError(error, { endpoint: '/api/protected/<name>' });
96
+ }
97
+ });
98
+
99
+ export const POST = withAuthNoParams(async (request) => {
100
+ try {
101
+ const body = widgetSchemas.create.parse(await request.json());
102
+ const widget = await createWidget(request.session.userId, body);
103
+ return NextResponse.json(widget, { status: 201 });
104
+ } catch (error) {
105
+ return handleApiError(error, { endpoint: '/api/protected/<name>' });
106
+ }
107
+ });
108
+ ```
109
+
110
+ Key rules:
111
+ - Protected routes use `withAuth` / `withAuthNoParams` / `withRole`.
112
+ - All catch blocks use `handleApiError` from `@/lib/mars`.
113
+ - Parse input with Zod schemas before use.
114
+
115
+ ### 5. Prisma Schema
116
+
117
+ Add a new schema file at `prisma/schema/<name>.prisma`:
118
+
119
+ ```prisma
120
+ model Widget {
121
+ id String @id @default(cuid())
122
+ name String
123
+ description String?
124
+ userId String
125
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
126
+ createdAt DateTime @default(now())
127
+ updatedAt DateTime @updatedAt
128
+
129
+ @@index([userId])
130
+ }
131
+ ```
132
+
133
+ Then update the User model in `prisma/schema/auth.prisma` to add the relation.
134
+
135
+ Run `yarn db:push` to sync.
136
+
137
+ ### 6. Feature Flag (Optional)
138
+
139
+ If the feature should be toggleable, add it to `appConfig.features` in `src/config/app.config.ts` and gate runtime behaviour on it.
140
+
141
+ ### 7. Tests
142
+
143
+ Create `route.test.ts` next to each API route. Mock Prisma and auth:
144
+
145
+ ```typescript
146
+ import { mockAuth } from '@mars-stack/core/test-utils';
147
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
148
+ import { GET, POST } from './route';
149
+
150
+ vi.mock('@/lib/prisma', () => ({
151
+ prisma: {
152
+ widget: {
153
+ findMany: vi.fn(),
154
+ create: vi.fn(),
155
+ },
156
+ },
157
+ }));
158
+
159
+ vi.mock('@/lib/mars', () => ({
160
+ verifySessionForAPI: vi.fn(() => Promise.resolve(mockAuth)),
161
+ }));
162
+ ```
163
+
164
+ ## Checklist
165
+
166
+ - [ ] Feature directory created with correct structure
167
+ - [ ] Server modules import `'server-only'`
168
+ - [ ] Queries scoped by session `userId`
169
+ - [ ] Zod schemas for all inputs
170
+ - [ ] API routes use auth wrappers and `handleApiError`
171
+ - [ ] Prisma schema added and `db:push` run
172
+ - [ ] Feature flag added to `app.config.ts` if toggleable
173
+ - [ ] Barrel exports created
174
+ - [ ] Tests written for API routes