@mars-stack/cli 0.2.0 → 1.0.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 (175) hide show
  1. package/dist/index.js +137 -12
  2. package/dist/index.js.map +1 -1
  3. package/package.json +4 -3
  4. package/template/.cursor/rules/composition-patterns.mdc +186 -0
  5. package/template/.cursor/rules/data-access.mdc +29 -0
  6. package/template/.cursor/rules/project-structure.mdc +34 -0
  7. package/template/.cursor/rules/security.mdc +25 -0
  8. package/template/.cursor/rules/testing.mdc +24 -0
  9. package/template/.cursor/rules/ui-conventions.mdc +29 -0
  10. package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
  11. package/template/.cursor/skills/add-audit-log/SKILL.md +375 -0
  12. package/template/.cursor/skills/add-blog/SKILL.md +447 -0
  13. package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
  14. package/template/.cursor/skills/add-component/SKILL.md +158 -0
  15. package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
  16. package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
  17. package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
  18. package/template/.cursor/skills/add-feature/SKILL.md +174 -0
  19. package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
  20. package/template/.cursor/skills/add-page/SKILL.md +151 -0
  21. package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
  22. package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
  23. package/template/.cursor/skills/add-role/SKILL.md +156 -0
  24. package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
  25. package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
  26. package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
  27. package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
  28. package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
  29. package/template/.cursor/skills/build-form/SKILL.md +231 -0
  30. package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
  31. package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
  32. package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
  33. package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
  34. package/template/.cursor/skills/configure-email/SKILL.md +170 -0
  35. package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
  36. package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
  37. package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
  38. package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
  39. package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
  40. package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
  41. package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
  42. package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
  43. package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
  44. package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
  45. package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
  46. package/template/.cursor/skills/configure-search/SKILL.md +581 -0
  47. package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
  48. package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
  49. package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
  50. package/template/.cursor/skills/create-seed/SKILL.md +191 -0
  51. package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
  52. package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
  53. package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
  54. package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
  55. package/template/.cursor/skills/setup-project/SKILL.md +104 -0
  56. package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
  57. package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
  58. package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
  59. package/template/AGENTS.md +104 -0
  60. package/template/ARCHITECTURE.md +102 -0
  61. package/template/docs/QUALITY_SCORE.md +20 -0
  62. package/template/docs/design-docs/conversation-as-system-record.md +70 -0
  63. package/template/docs/design-docs/core-beliefs.md +43 -0
  64. package/template/docs/design-docs/index.md +8 -0
  65. package/template/docs/exec-plans/active/.gitkeep +0 -0
  66. package/template/docs/exec-plans/completed/.gitkeep +0 -0
  67. package/template/docs/exec-plans/tech-debt.md +7 -0
  68. package/template/docs/generated/.gitkeep +0 -0
  69. package/template/docs/product-specs/index.md +7 -0
  70. package/template/docs/references/index.md +18 -0
  71. package/template/e2e/api.spec.ts +20 -0
  72. package/template/e2e/auth.spec.ts +24 -0
  73. package/template/e2e/public.spec.ts +25 -0
  74. package/template/eslint.config.mjs +24 -0
  75. package/template/next-env.d.ts +6 -0
  76. package/template/next.config.ts +45 -0
  77. package/template/package.json +80 -0
  78. package/template/playwright.config.ts +31 -0
  79. package/template/postcss.config.mjs +8 -0
  80. package/template/prisma/generated/prisma/browser.ts +49 -0
  81. package/template/prisma/generated/prisma/client.ts +73 -0
  82. package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
  83. package/template/prisma/generated/prisma/enums.ts +15 -0
  84. package/template/prisma/generated/prisma/internal/class.ts +254 -0
  85. package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
  86. package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
  87. package/template/prisma/generated/prisma/models/Account.ts +1543 -0
  88. package/template/prisma/generated/prisma/models/File.ts +1529 -0
  89. package/template/prisma/generated/prisma/models/Session.ts +1415 -0
  90. package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
  91. package/template/prisma/generated/prisma/models/User.ts +2235 -0
  92. package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
  93. package/template/prisma/generated/prisma/models.ts +17 -0
  94. package/template/prisma/schema/auth.prisma +69 -0
  95. package/template/prisma/schema/base.prisma +8 -0
  96. package/template/prisma/schema/file.prisma +15 -0
  97. package/template/prisma/schema/subscription.prisma +17 -0
  98. package/template/prisma.config.ts +13 -0
  99. package/template/scripts/check-architecture.ts +221 -0
  100. package/template/scripts/check-doc-freshness.ts +242 -0
  101. package/template/scripts/ensure-db.mjs +291 -0
  102. package/template/scripts/generate-docs.ts +143 -0
  103. package/template/scripts/generate-env-example.ts +89 -0
  104. package/template/scripts/seed.ts +56 -0
  105. package/template/scripts/update-quality-score.ts +263 -0
  106. package/template/src/__tests__/architecture.test.ts +114 -0
  107. package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
  108. package/template/src/app/(auth)/layout.tsx +11 -0
  109. package/template/src/app/(auth)/register/page.tsx +162 -0
  110. package/template/src/app/(auth)/reset-password/page.tsx +109 -0
  111. package/template/src/app/(auth)/sign-in/page.tsx +122 -0
  112. package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
  113. package/template/src/app/(auth)/verify/page.tsx +56 -0
  114. package/template/src/app/(protected)/admin/page.tsx +108 -0
  115. package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
  116. package/template/src/app/(protected)/dashboard/page.tsx +22 -0
  117. package/template/src/app/(protected)/layout.tsx +262 -0
  118. package/template/src/app/(protected)/settings/page.tsx +370 -0
  119. package/template/src/app/api/auth/forgot/route.ts +63 -0
  120. package/template/src/app/api/auth/login/route.ts +121 -0
  121. package/template/src/app/api/auth/logout/route.ts +19 -0
  122. package/template/src/app/api/auth/me/route.ts +30 -0
  123. package/template/src/app/api/auth/reset/route.ts +45 -0
  124. package/template/src/app/api/auth/signup/route.ts +85 -0
  125. package/template/src/app/api/auth/verify/route.ts +46 -0
  126. package/template/src/app/api/csrf/route.ts +12 -0
  127. package/template/src/app/api/health/route.ts +10 -0
  128. package/template/src/app/api/protected/admin/users/route.ts +24 -0
  129. package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
  130. package/template/src/app/api/protected/billing/portal/route.ts +39 -0
  131. package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
  132. package/template/src/app/api/protected/files/upload/route.ts +64 -0
  133. package/template/src/app/api/protected/user/password/route.ts +63 -0
  134. package/template/src/app/api/protected/user/profile/route.ts +35 -0
  135. package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
  136. package/template/src/app/api/protected/user/sessions/route.ts +22 -0
  137. package/template/src/app/api/readiness/route.ts +15 -0
  138. package/template/src/app/api/webhooks/stripe/route.ts +166 -0
  139. package/template/src/app/error.tsx +33 -0
  140. package/template/src/app/layout.tsx +29 -0
  141. package/template/src/app/not-found.tsx +20 -0
  142. package/template/src/app/page.tsx +136 -0
  143. package/template/src/app/privacy/page.tsx +178 -0
  144. package/template/src/app/providers.tsx +8 -0
  145. package/template/src/app/terms/page.tsx +139 -0
  146. package/template/src/config/app.config.ts +70 -0
  147. package/template/src/config/routes.ts +17 -0
  148. package/template/src/features/admin/index.ts +11 -0
  149. package/template/src/features/admin/permissions.ts +64 -0
  150. package/template/src/features/auth/context/AuthContext.tsx +96 -0
  151. package/template/src/features/auth/context/index.ts +2 -0
  152. package/template/src/features/auth/index.ts +3 -0
  153. package/template/src/features/auth/server/consent.ts +66 -0
  154. package/template/src/features/auth/server/session-revocation.ts +20 -0
  155. package/template/src/features/auth/server/sessions.ts +66 -0
  156. package/template/src/features/auth/server/user.ts +166 -0
  157. package/template/src/features/auth/types.ts +19 -0
  158. package/template/src/features/auth/validators.ts +29 -0
  159. package/template/src/features/billing/server/index.ts +66 -0
  160. package/template/src/features/billing/types.ts +43 -0
  161. package/template/src/features/uploads/server/index.ts +49 -0
  162. package/template/src/features/uploads/types.ts +26 -0
  163. package/template/src/lib/core/email/templates/base-layout.ts +122 -0
  164. package/template/src/lib/core/email/templates/index.ts +4 -0
  165. package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
  166. package/template/src/lib/core/email/templates/verification-email.ts +41 -0
  167. package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
  168. package/template/src/lib/mars.ts +56 -0
  169. package/template/src/lib/prisma.ts +19 -0
  170. package/template/src/proxy.ts +92 -0
  171. package/template/src/styles/brand.css +15 -0
  172. package/template/src/styles/globals.css +7 -0
  173. package/template/tsconfig.json +59 -0
  174. package/template/vitest.config.ts +41 -0
  175. package/template/vitest.setup.ts +24 -0
@@ -0,0 +1,283 @@
1
+ # Skill: Build a Data Table
2
+
3
+ Create a data table with sorting, pagination, and actions using MARS design system components.
4
+
5
+ ## When to Use
6
+
7
+ Use this skill when the user asks to display tabular data, build a list view, create a data grid, or show records in a table.
8
+
9
+ ## Architecture
10
+
11
+ MARS provides `Table` primitives (`Table`, `TableHead`, `TableBody`, `TableRow`, `TableHeaderCell`, `TableCell`) that use design tokens. Build feature-specific tables by composing these.
12
+
13
+ ## Template: Full Data Table
14
+
15
+ ```tsx
16
+ 'use client';
17
+
18
+ import {
19
+ Table, TableHead, TableBody, TableRow, TableHeaderCell, TableCell, Badge, Button, Spinner, Card, CardHeader, CardBody, EmptyState,
20
+ } from '@mars-stack/ui';
21
+ import { useEffect, useState, useCallback } from 'react';
22
+
23
+ interface Item {
24
+ id: string;
25
+ name: string;
26
+ status: 'active' | 'inactive' | 'pending';
27
+ createdAt: string;
28
+ }
29
+
30
+ interface PaginatedResponse {
31
+ items: Item[];
32
+ pagination: { page: number; limit: number; total: number; totalPages: number };
33
+ }
34
+
35
+ type SortField = 'name' | 'status' | 'createdAt';
36
+ type SortDirection = 'asc' | 'desc';
37
+
38
+ const statusVariant: Record<Item['status'], 'success' | 'neutral' | 'warning'> = {
39
+ active: 'success',
40
+ inactive: 'neutral',
41
+ pending: 'warning',
42
+ };
43
+
44
+ export function ItemTable() {
45
+ const [data, setData] = useState<PaginatedResponse | null>(null);
46
+ const [loading, setLoading] = useState(true);
47
+ const [page, setPage] = useState(1);
48
+ const [sortField, setSortField] = useState<SortField>('createdAt');
49
+ const [sortDir, setSortDir] = useState<SortDirection>('desc');
50
+
51
+ const fetchData = useCallback(async () => {
52
+ setLoading(true);
53
+ try {
54
+ const params = new URLSearchParams({
55
+ page: String(page),
56
+ limit: '20',
57
+ sort: sortField,
58
+ dir: sortDir,
59
+ });
60
+ const res = await fetch(`/api/protected/items?${params}`);
61
+ const json = await res.json();
62
+ setData(json);
63
+ } finally {
64
+ setLoading(false);
65
+ }
66
+ }, [page, sortField, sortDir]);
67
+
68
+ useEffect(() => { fetchData(); }, [fetchData]);
69
+
70
+ function handleSort(field: SortField) {
71
+ if (sortField === field) {
72
+ setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
73
+ } else {
74
+ setSortField(field);
75
+ setSortDir('asc');
76
+ }
77
+ setPage(1);
78
+ }
79
+
80
+ function SortIndicator({ field }: { field: SortField }) {
81
+ if (sortField !== field) return <span className="text-text-muted ml-1">↕</span>;
82
+ return <span className="text-text-primary ml-1">{sortDir === 'asc' ? '↑' : '↓'}</span>;
83
+ }
84
+
85
+ if (loading && !data) {
86
+ return (
87
+ <div className="flex justify-center py-12">
88
+ <Spinner size="lg" />
89
+ </div>
90
+ );
91
+ }
92
+
93
+ if (!data || data.items.length === 0) {
94
+ return (
95
+ <EmptyState
96
+ title="No items yet"
97
+ description="Create your first item to get started."
98
+ />
99
+ );
100
+ }
101
+
102
+ const { items, pagination } = data;
103
+
104
+ return (
105
+ <Card>
106
+ <CardHeader className="flex items-center justify-between">
107
+ <h2 className="text-lg font-semibold text-text-primary">
108
+ Items ({pagination.total})
109
+ </h2>
110
+ </CardHeader>
111
+ <CardBody className="p-0">
112
+ <Table>
113
+ <TableHead>
114
+ <TableRow>
115
+ <TableHeaderCell>
116
+ <button onClick={() => handleSort('name')} className="flex items-center">
117
+ Name <SortIndicator field="name" />
118
+ </button>
119
+ </TableHeaderCell>
120
+ <TableHeaderCell>
121
+ <button onClick={() => handleSort('status')} className="flex items-center">
122
+ Status <SortIndicator field="status" />
123
+ </button>
124
+ </TableHeaderCell>
125
+ <TableHeaderCell>
126
+ <button onClick={() => handleSort('createdAt')} className="flex items-center">
127
+ Created <SortIndicator field="createdAt" />
128
+ </button>
129
+ </TableHeaderCell>
130
+ <TableHeaderCell>
131
+ <span className="sr-only">Actions</span>
132
+ </TableHeaderCell>
133
+ </TableRow>
134
+ </TableHead>
135
+ <TableBody>
136
+ {items.map((item) => (
137
+ <TableRow key={item.id}>
138
+ <TableCell className="font-medium text-text-primary">{item.name}</TableCell>
139
+ <TableCell>
140
+ <Badge variant={statusVariant[item.status]}>{item.status}</Badge>
141
+ </TableCell>
142
+ <TableCell className="text-text-secondary">
143
+ {new Date(item.createdAt).toLocaleDateString()}
144
+ </TableCell>
145
+ <TableCell className="text-right">
146
+ <Button variant="subtle" size="sm">Edit</Button>
147
+ </TableCell>
148
+ </TableRow>
149
+ ))}
150
+ </TableBody>
151
+ </Table>
152
+ </CardBody>
153
+
154
+ {pagination.totalPages > 1 && (
155
+ <div className="flex items-center justify-between border-t border-border-default px-4 py-3">
156
+ <p className="text-sm text-text-secondary">
157
+ Page {pagination.page} of {pagination.totalPages}
158
+ </p>
159
+ <div className="flex gap-2">
160
+ <Button
161
+ variant="subtle"
162
+ size="sm"
163
+ disabled={pagination.page <= 1}
164
+ onClick={() => setPage((p) => p - 1)}
165
+ >
166
+ Previous
167
+ </Button>
168
+ <Button
169
+ variant="subtle"
170
+ size="sm"
171
+ disabled={pagination.page >= pagination.totalPages}
172
+ onClick={() => setPage((p) => p + 1)}
173
+ >
174
+ Next
175
+ </Button>
176
+ </div>
177
+ </div>
178
+ )}
179
+ </Card>
180
+ );
181
+ }
182
+ ```
183
+
184
+ ## API Endpoint for Sorted + Paginated Data
185
+
186
+ ```typescript
187
+ export const GET = withAuthNoParams(async (request: AuthenticatedRequest) => {
188
+ try {
189
+ const url = new URL(request.url);
190
+ const page = Math.max(1, Number(url.searchParams.get('page') || '1'));
191
+ const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit') || '20')));
192
+ const sort = url.searchParams.get('sort') || 'createdAt';
193
+ const dir = url.searchParams.get('dir') === 'asc' ? 'asc' : 'desc';
194
+
195
+ const allowedSortFields = ['name', 'status', 'createdAt'];
196
+ const orderBy = allowedSortFields.includes(sort)
197
+ ? { [sort]: dir }
198
+ : { createdAt: 'desc' as const };
199
+
200
+ const where = { userId: request.session.userId };
201
+ const [items, total] = await Promise.all([
202
+ prisma.item.findMany({ where, orderBy, skip: (page - 1) * limit, take: limit }),
203
+ prisma.item.count({ where }),
204
+ ]);
205
+
206
+ return NextResponse.json({
207
+ items,
208
+ pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
209
+ });
210
+ } catch (error) {
211
+ return handleApiError(error, { endpoint: '/api/protected/items' });
212
+ }
213
+ });
214
+ ```
215
+
216
+ ## Patterns
217
+
218
+ ### Search/Filter Bar
219
+
220
+ ```tsx
221
+ <CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
222
+ <h2 className="text-lg font-semibold text-text-primary">Items</h2>
223
+ <div className="flex gap-2">
224
+ <Input
225
+ name="search"
226
+ placeholder="Search..."
227
+ value={search}
228
+ onChange={(e) => setSearch(e.target.value)}
229
+ className="w-48"
230
+ />
231
+ <Select
232
+ name="status"
233
+ value={statusFilter}
234
+ onChange={(e) => setStatusFilter(e.target.value)}
235
+ options={[
236
+ { value: '', label: 'All statuses' },
237
+ { value: 'active', label: 'Active' },
238
+ { value: 'inactive', label: 'Inactive' },
239
+ ]}
240
+ />
241
+ </div>
242
+ </CardHeader>
243
+ ```
244
+
245
+ ### Row Actions (Delete with Confirmation)
246
+
247
+ ```tsx
248
+ <TableCell className="text-right">
249
+ <div className="flex justify-end gap-1">
250
+ <Button variant="subtle" size="sm" onClick={() => handleEdit(item.id)}>
251
+ Edit
252
+ </Button>
253
+ <Button variant="danger" size="sm" onClick={() => handleDelete(item.id)}>
254
+ Delete
255
+ </Button>
256
+ </div>
257
+ </TableCell>
258
+ ```
259
+
260
+ ### Loading Overlay
261
+
262
+ ```tsx
263
+ <div className="relative">
264
+ {loading && (
265
+ <div className="absolute inset-0 flex items-center justify-center bg-surface-card/80 z-10">
266
+ <Spinner size="md" />
267
+ </div>
268
+ )}
269
+ <Table>...</Table>
270
+ </div>
271
+ ```
272
+
273
+ ## Checklist
274
+
275
+ - [ ] Table uses MARS `Table` primitives
276
+ - [ ] Sortable column headers with visual indicators
277
+ - [ ] Pagination with page controls
278
+ - [ ] Empty state when no data
279
+ - [ ] Loading spinner on initial load
280
+ - [ ] Status badges use `Badge` with appropriate variants
281
+ - [ ] All colours use semantic tokens
282
+ - [ ] API endpoint supports `page`, `limit`, `sort`, `dir` params
283
+ - [ ] Sort field allowlist prevents injection
@@ -0,0 +1,231 @@
1
+ # Skill: Build a Form
2
+
3
+ Create a validated form using MARS design system components, Zod schemas, and proper error handling.
4
+
5
+ ## When to Use
6
+
7
+ Use this skill when the user asks to build a form, input form, settings form, contact form, or any data entry UI.
8
+
9
+ ## Architecture
10
+
11
+ MARS forms use:
12
+ - **Primitives** (`Input`, `Select`, `Checkbox`, `Textarea`, `Button`) for fields
13
+ - **Patterns** (`FormField`, `Card`) for layout
14
+ - **Zod schemas** for validation (shared with API routes)
15
+ - **`useZodForm` hook** for client-side form state (if available), or native React state
16
+
17
+ ## Template: Basic Form
18
+
19
+ ```tsx
20
+ 'use client';
21
+
22
+ import {
23
+ Button, Input, Select, Textarea, Checkbox, Card, CardHeader, CardBody, CardFooter, H2,
24
+ } from '@mars-stack/ui';
25
+ import { useState, type FormEvent } from 'react';
26
+ import { z } from 'zod';
27
+
28
+ const formSchema = z.object({
29
+ name: z.string().min(1, 'Name is required').max(100),
30
+ email: z.string().email('Invalid email address'),
31
+ category: z.string().min(1, 'Please select a category'),
32
+ message: z.string().min(10, 'Message must be at least 10 characters').max(1000),
33
+ agree: z.literal(true, { errorMap: () => ({ message: 'You must agree to continue' }) }),
34
+ });
35
+
36
+ type FormData = z.infer<typeof formSchema>;
37
+ type FormErrors = Partial<Record<keyof FormData, string>>;
38
+
39
+ export function ContactForm() {
40
+ const [errors, setErrors] = useState<FormErrors>({});
41
+ const [submitting, setSubmitting] = useState(false);
42
+ const [success, setSuccess] = useState(false);
43
+
44
+ async function handleSubmit(e: FormEvent<HTMLFormElement>) {
45
+ e.preventDefault();
46
+ setErrors({});
47
+
48
+ const formData = new FormData(e.currentTarget);
49
+ const raw = {
50
+ name: formData.get('name') as string,
51
+ email: formData.get('email') as string,
52
+ category: formData.get('category') as string,
53
+ message: formData.get('message') as string,
54
+ agree: formData.get('agree') === 'on' ? true : false,
55
+ };
56
+
57
+ const result = formSchema.safeParse(raw);
58
+ if (!result.success) {
59
+ const fieldErrors: FormErrors = {};
60
+ for (const issue of result.error.issues) {
61
+ const field = issue.path[0] as keyof FormData;
62
+ if (!fieldErrors[field]) fieldErrors[field] = issue.message;
63
+ }
64
+ setErrors(fieldErrors);
65
+ return;
66
+ }
67
+
68
+ setSubmitting(true);
69
+ try {
70
+ const response = await fetch('/api/protected/contact', {
71
+ method: 'POST',
72
+ headers: { 'Content-Type': 'application/json' },
73
+ body: JSON.stringify(result.data),
74
+ });
75
+
76
+ if (!response.ok) {
77
+ const data = await response.json();
78
+ setErrors({ name: data.error || 'Something went wrong' });
79
+ return;
80
+ }
81
+
82
+ setSuccess(true);
83
+ } finally {
84
+ setSubmitting(false);
85
+ }
86
+ }
87
+
88
+ if (success) {
89
+ return (
90
+ <Card>
91
+ <CardBody className="text-center py-8">
92
+ <p className="text-text-success font-medium">Message sent successfully!</p>
93
+ </CardBody>
94
+ </Card>
95
+ );
96
+ }
97
+
98
+ return (
99
+ <form onSubmit={handleSubmit} noValidate>
100
+ <Card>
101
+ <CardHeader>
102
+ <H2>Contact Us</H2>
103
+ </CardHeader>
104
+ <CardBody className="space-y-4">
105
+ <Input
106
+ name="name"
107
+ label="Full Name"
108
+ placeholder="Jane Smith"
109
+ error={errors.name}
110
+ required
111
+ />
112
+ <Input
113
+ name="email"
114
+ label="Email"
115
+ type="email"
116
+ placeholder="jane@example.com"
117
+ error={errors.email}
118
+ required
119
+ />
120
+ <Select
121
+ name="category"
122
+ label="Category"
123
+ placeholder="Select a category"
124
+ error={errors.category}
125
+ options={[
126
+ { value: 'general', label: 'General Inquiry' },
127
+ { value: 'support', label: 'Technical Support' },
128
+ { value: 'billing', label: 'Billing' },
129
+ { value: 'feedback', label: 'Feedback' },
130
+ ]}
131
+ />
132
+ <Textarea
133
+ name="message"
134
+ label="Message"
135
+ placeholder="Tell us how we can help..."
136
+ rows={5}
137
+ error={errors.message}
138
+ required
139
+ />
140
+ <Checkbox
141
+ name="agree"
142
+ label="I agree to the terms and conditions"
143
+ error={errors.agree}
144
+ />
145
+ </CardBody>
146
+ <CardFooter className="flex justify-end gap-3">
147
+ <Button type="button" variant="subtle">
148
+ Cancel
149
+ </Button>
150
+ <Button type="submit" disabled={submitting}>
151
+ {submitting ? 'Sending...' : 'Send Message'}
152
+ </Button>
153
+ </CardFooter>
154
+ </Card>
155
+ </form>
156
+ );
157
+ }
158
+ ```
159
+
160
+ ## Validation Pattern
161
+
162
+ **Share schemas between client and API:**
163
+
164
+ ```typescript
165
+ // src/features/contact/validation/schemas.ts
166
+ import { z } from 'zod';
167
+
168
+ export const contactSchemas = {
169
+ create: z.object({
170
+ name: z.string().min(1).max(100),
171
+ email: z.string().email(),
172
+ category: z.string().min(1),
173
+ message: z.string().min(10).max(1000),
174
+ }),
175
+ };
176
+ ```
177
+
178
+ Import in both the form component and the API route handler.
179
+
180
+ ## Form Layouts
181
+
182
+ ### Two-Column Layout
183
+
184
+ ```tsx
185
+ <CardBody className="grid grid-cols-1 gap-4 md:grid-cols-2">
186
+ <Input name="firstName" label="First Name" />
187
+ <Input name="lastName" label="Last Name" />
188
+ <Input name="email" label="Email" className="md:col-span-2" />
189
+ </CardBody>
190
+ ```
191
+
192
+ ### Sections with Dividers
193
+
194
+ ```tsx
195
+ <CardBody className="space-y-6">
196
+ <div className="space-y-4">
197
+ <h3 className="text-sm font-medium text-text-secondary uppercase tracking-wide">Personal</h3>
198
+ <Input name="name" label="Name" />
199
+ <Input name="email" label="Email" />
200
+ </div>
201
+ <Divider />
202
+ <div className="space-y-4">
203
+ <h3 className="text-sm font-medium text-text-secondary uppercase tracking-wide">Preferences</h3>
204
+ <Select name="timezone" label="Timezone" options={timezones} />
205
+ </div>
206
+ </CardBody>
207
+ ```
208
+
209
+ ## With Server Actions
210
+
211
+ For progressive enhancement, use Server Actions instead of `fetch`:
212
+
213
+ ```tsx
214
+ <form action={submitContact}>
215
+ <Input name="name" label="Name" />
216
+ <Button type="submit">Send</Button>
217
+ </form>
218
+ ```
219
+
220
+ See the `add-server-action` skill for the full pattern.
221
+
222
+ ## Checklist
223
+
224
+ - [ ] Zod schema defined and shared with API route
225
+ - [ ] Client-side validation with `safeParse` before submission
226
+ - [ ] Error messages displayed on individual fields
227
+ - [ ] Loading state on submit button
228
+ - [ ] Success state after submission
229
+ - [ ] All form inputs use MARS primitives (`Input`, `Select`, `Textarea`, `Checkbox`)
230
+ - [ ] Form wrapped in `Card` pattern for consistent layout
231
+ - [ ] `noValidate` on `<form>` to use custom validation