@mars-stack/core 0.4.0

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