@hustle-together/api-dev-tools 3.6.5 → 3.9.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 (61) hide show
  1. package/README.md +5307 -258
  2. package/bin/cli.js +348 -20
  3. package/commands/README.md +459 -71
  4. package/commands/hustle-api-continue.md +158 -0
  5. package/commands/{api-create.md → hustle-api-create.md} +22 -2
  6. package/commands/{api-env.md → hustle-api-env.md} +4 -4
  7. package/commands/{api-interview.md → hustle-api-interview.md} +1 -1
  8. package/commands/{api-research.md → hustle-api-research.md} +3 -3
  9. package/commands/hustle-api-sessions.md +149 -0
  10. package/commands/{api-status.md → hustle-api-status.md} +16 -16
  11. package/commands/{api-verify.md → hustle-api-verify.md} +2 -2
  12. package/commands/hustle-combine.md +763 -0
  13. package/commands/hustle-ui-create.md +825 -0
  14. package/hooks/api-workflow-check.py +385 -19
  15. package/hooks/cache-research.py +337 -0
  16. package/hooks/check-playwright-setup.py +103 -0
  17. package/hooks/check-storybook-setup.py +81 -0
  18. package/hooks/detect-interruption.py +165 -0
  19. package/hooks/enforce-brand-guide.py +131 -0
  20. package/hooks/enforce-documentation.py +60 -8
  21. package/hooks/enforce-freshness.py +184 -0
  22. package/hooks/enforce-questions-sourced.py +146 -0
  23. package/hooks/enforce-schema-from-interview.py +248 -0
  24. package/hooks/enforce-ui-disambiguation.py +108 -0
  25. package/hooks/enforce-ui-interview.py +130 -0
  26. package/hooks/generate-manifest-entry.py +981 -0
  27. package/hooks/session-logger.py +297 -0
  28. package/hooks/session-startup.py +65 -10
  29. package/hooks/track-scope-coverage.py +220 -0
  30. package/hooks/track-tool-use.py +81 -1
  31. package/hooks/update-api-showcase.py +149 -0
  32. package/hooks/update-registry.py +352 -0
  33. package/hooks/update-ui-showcase.py +148 -0
  34. package/package.json +8 -2
  35. package/templates/BRAND_GUIDE.md +299 -0
  36. package/templates/CLAUDE-SECTION.md +56 -24
  37. package/templates/SPEC.json +640 -0
  38. package/templates/api-dev-state.json +179 -161
  39. package/templates/api-showcase/APICard.tsx +153 -0
  40. package/templates/api-showcase/APIModal.tsx +375 -0
  41. package/templates/api-showcase/APIShowcase.tsx +231 -0
  42. package/templates/api-showcase/APITester.tsx +522 -0
  43. package/templates/api-showcase/page.tsx +41 -0
  44. package/templates/component/Component.stories.tsx +172 -0
  45. package/templates/component/Component.test.tsx +237 -0
  46. package/templates/component/Component.tsx +86 -0
  47. package/templates/component/Component.types.ts +55 -0
  48. package/templates/component/index.ts +15 -0
  49. package/templates/dev-tools/_components/DevToolsLanding.tsx +320 -0
  50. package/templates/dev-tools/page.tsx +10 -0
  51. package/templates/page/page.e2e.test.ts +218 -0
  52. package/templates/page/page.tsx +42 -0
  53. package/templates/performance-budgets.json +58 -0
  54. package/templates/registry.json +13 -0
  55. package/templates/settings.json +74 -0
  56. package/templates/shared/HeroHeader.tsx +261 -0
  57. package/templates/shared/index.ts +1 -0
  58. package/templates/ui-showcase/PreviewCard.tsx +315 -0
  59. package/templates/ui-showcase/PreviewModal.tsx +676 -0
  60. package/templates/ui-showcase/UIShowcase.tsx +262 -0
  61. package/templates/ui-showcase/page.tsx +26 -0
@@ -0,0 +1,522 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+
5
+ interface ParameterDoc {
6
+ name: string;
7
+ type: string;
8
+ description?: string;
9
+ required?: boolean;
10
+ default?: string | number | boolean;
11
+ enum?: string[];
12
+ }
13
+
14
+ interface SchemaDoc {
15
+ request?: ParameterDoc[];
16
+ response?: ParameterDoc[];
17
+ queryParams?: ParameterDoc[];
18
+ }
19
+
20
+ interface APITesterProps {
21
+ id: string;
22
+ endpoint: string;
23
+ methods: string[];
24
+ selectedEndpoint?: string | null;
25
+ schemaPath?: string;
26
+ schema?: SchemaDoc;
27
+ }
28
+
29
+ interface RequestState {
30
+ method: string;
31
+ body: string;
32
+ queryParams: string;
33
+ headers: Record<string, string>;
34
+ }
35
+
36
+ interface ResponseState {
37
+ status: number | null;
38
+ statusText: string;
39
+ body: string;
40
+ time: number | null;
41
+ error: string | null;
42
+ contentType: string | null;
43
+ }
44
+
45
+ // Default request bodies for known APIs
46
+ const DEFAULT_BODIES: Record<string, Record<string, object>> = {
47
+ brandfetch: {
48
+ default: { domain: 'stripe.com' },
49
+ },
50
+ elevenlabs: {
51
+ tts: {
52
+ text: 'Hello, this is a test of the ElevenLabs text-to-speech API.',
53
+ voiceId: '21m00Tcm4TlvDq8ikWAM',
54
+ modelId: 'eleven_multilingual_v2',
55
+ outputFormat: 'mp3_44100_128',
56
+ responseFormat: 'json',
57
+ },
58
+ voices: {},
59
+ models: {},
60
+ },
61
+ };
62
+
63
+ // Default query params for GET requests
64
+ const DEFAULT_QUERY_PARAMS: Record<string, Record<string, string>> = {
65
+ elevenlabs: {
66
+ voices: 'search=&pageSize=10',
67
+ models: '',
68
+ },
69
+ };
70
+
71
+ /**
72
+ * API Tester Component
73
+ *
74
+ * Interactive form for testing API endpoints directly from the showcase.
75
+ * Features:
76
+ * - Method selection
77
+ * - Schema-driven default values
78
+ * - Query parameter support for GET requests
79
+ * - JSON body editor
80
+ * - Response display with timing
81
+ * - Audio playback for binary responses
82
+ *
83
+ * Created with Hustle API Dev Tools (v3.9.2)
84
+ */
85
+ export function APITester({ id, endpoint, methods, selectedEndpoint, schemaPath, schema }: APITesterProps) {
86
+ // Get default body for this API/endpoint
87
+ const getDefaultBody = () => {
88
+ const apiDefaults = DEFAULT_BODIES[id];
89
+ if (apiDefaults) {
90
+ const endpointDefaults = apiDefaults[selectedEndpoint || 'default'];
91
+ if (endpointDefaults && Object.keys(endpointDefaults).length > 0) {
92
+ return JSON.stringify(endpointDefaults, null, 2);
93
+ }
94
+ }
95
+ return JSON.stringify({}, null, 2);
96
+ };
97
+
98
+ // Get default query params for GET requests
99
+ const getDefaultQueryParams = () => {
100
+ const apiParams = DEFAULT_QUERY_PARAMS[id];
101
+ if (apiParams && selectedEndpoint) {
102
+ return apiParams[selectedEndpoint] || '';
103
+ }
104
+ return '';
105
+ };
106
+
107
+ const [request, setRequest] = useState<RequestState>({
108
+ method: methods[0] || 'POST',
109
+ body: getDefaultBody(),
110
+ queryParams: getDefaultQueryParams(),
111
+ headers: {
112
+ 'Content-Type': 'application/json',
113
+ },
114
+ });
115
+
116
+ const [response, setResponse] = useState<ResponseState>({
117
+ status: null,
118
+ statusText: '',
119
+ body: '',
120
+ time: null,
121
+ error: null,
122
+ contentType: null,
123
+ });
124
+
125
+ const [isLoading, setIsLoading] = useState(false);
126
+ const [audioUrl, setAudioUrl] = useState<string | null>(null);
127
+
128
+ // Update defaults when endpoint changes
129
+ useEffect(() => {
130
+ setRequest((prev) => ({
131
+ ...prev,
132
+ method: methods[0] || 'POST',
133
+ body: getDefaultBody(),
134
+ queryParams: getDefaultQueryParams(),
135
+ }));
136
+ // Clear previous response
137
+ setResponse({
138
+ status: null,
139
+ statusText: '',
140
+ body: '',
141
+ time: null,
142
+ error: null,
143
+ contentType: null,
144
+ });
145
+ setAudioUrl(null);
146
+ // eslint-disable-next-line react-hooks/exhaustive-deps
147
+ }, [selectedEndpoint, id]);
148
+
149
+ const handleSubmit = async () => {
150
+ setIsLoading(true);
151
+ setResponse({ status: null, statusText: '', body: '', time: null, error: null, contentType: null });
152
+ setAudioUrl(null);
153
+
154
+ const startTime = performance.now();
155
+
156
+ try {
157
+ // Build URL with query params for GET
158
+ let url = endpoint;
159
+ if (request.method === 'GET' && request.queryParams.trim()) {
160
+ url = `${endpoint}?${request.queryParams}`;
161
+ }
162
+
163
+ const fetchOptions: RequestInit = {
164
+ method: request.method,
165
+ headers: request.headers,
166
+ };
167
+
168
+ // Add body for non-GET requests
169
+ if (request.method !== 'GET' && request.body.trim()) {
170
+ fetchOptions.body = request.body;
171
+ }
172
+
173
+ const res = await fetch(url, fetchOptions);
174
+ const endTime = performance.now();
175
+
176
+ const contentType = res.headers.get('content-type') || '';
177
+ let responseBody = '';
178
+
179
+ // Handle different content types
180
+ if (contentType.includes('audio/') || contentType.includes('application/octet-stream')) {
181
+ // Binary audio response
182
+ const blob = await res.blob();
183
+ const url = URL.createObjectURL(blob);
184
+ setAudioUrl(url);
185
+ responseBody = `[Audio Response - ${blob.size} bytes]\nContent-Type: ${contentType}`;
186
+ } else if (contentType.includes('application/json')) {
187
+ const json = await res.json();
188
+ responseBody = JSON.stringify(json, null, 2);
189
+
190
+ // Check if JSON contains base64 audio
191
+ if (json.audio && typeof json.audio === 'string') {
192
+ try {
193
+ const format = json.format || 'mp3';
194
+ const audioData = atob(json.audio);
195
+ const bytes = new Uint8Array(audioData.length);
196
+ for (let i = 0; i < audioData.length; i++) {
197
+ bytes[i] = audioData.charCodeAt(i);
198
+ }
199
+ const blob = new Blob([bytes], { type: `audio/${format}` });
200
+ const url = URL.createObjectURL(blob);
201
+ setAudioUrl(url);
202
+ } catch {
203
+ // Not valid base64, ignore
204
+ }
205
+ }
206
+ } else {
207
+ responseBody = await res.text();
208
+ }
209
+
210
+ setResponse({
211
+ status: res.status,
212
+ statusText: res.statusText,
213
+ body: responseBody,
214
+ time: Math.round(endTime - startTime),
215
+ error: null,
216
+ contentType,
217
+ });
218
+ } catch (error) {
219
+ const endTime = performance.now();
220
+ setResponse({
221
+ status: null,
222
+ statusText: '',
223
+ body: '',
224
+ time: Math.round(endTime - startTime),
225
+ error: error instanceof Error ? error.message : 'Unknown error occurred',
226
+ contentType: null,
227
+ });
228
+ } finally {
229
+ setIsLoading(false);
230
+ }
231
+ };
232
+
233
+ const getStatusColor = (status: number | null) => {
234
+ if (!status) return 'text-gray-500';
235
+ if (status >= 200 && status < 300) return 'text-green-500';
236
+ if (status >= 400 && status < 500) return 'text-yellow-500';
237
+ if (status >= 500) return 'text-red-500';
238
+ return 'text-gray-500';
239
+ };
240
+
241
+ return (
242
+ <div className="grid gap-6 lg:grid-cols-2">
243
+ {/* Request Panel */}
244
+ <div className="space-y-4">
245
+ <h3 className="text-lg font-bold text-black dark:text-white">Request</h3>
246
+
247
+ {/* Method Selection */}
248
+ <div>
249
+ <label className="mb-1 block text-sm font-bold text-black dark:text-white">Method</label>
250
+ <div className="flex gap-2">
251
+ {methods.map((method) => (
252
+ <button
253
+ key={method}
254
+ onClick={() => setRequest((prev) => ({ ...prev, method }))}
255
+ className={`border-2 px-4 py-2 text-sm font-medium transition-colors ${
256
+ request.method === method
257
+ ? 'border-[#BA0C2F] bg-[#BA0C2F] text-white'
258
+ : 'border-black bg-white text-black hover:border-[#BA0C2F] dark:border-gray-600 dark:bg-gray-800 dark:text-white'
259
+ }`}
260
+ >
261
+ {method}
262
+ </button>
263
+ ))}
264
+ </div>
265
+ </div>
266
+
267
+ {/* Endpoint Display */}
268
+ <div>
269
+ <label className="mb-1 block text-sm font-bold text-black dark:text-white">Endpoint</label>
270
+ <div className="flex items-center border-2 border-black bg-gray-50 px-3 py-2 dark:border-gray-600 dark:bg-gray-800">
271
+ <span className="font-mono text-sm text-gray-700 dark:text-gray-300">{endpoint}</span>
272
+ </div>
273
+ </div>
274
+
275
+ {/* Query Parameters (for GET requests) */}
276
+ {request.method === 'GET' && (
277
+ <div>
278
+ <label className="mb-1 block text-sm font-bold text-black dark:text-white">Query Parameters</label>
279
+ <input
280
+ type="text"
281
+ value={request.queryParams}
282
+ onChange={(e) => setRequest((prev) => ({ ...prev, queryParams: e.target.value }))}
283
+ className="w-full border-2 border-black bg-white px-3 py-2 font-mono text-sm focus:border-[#BA0C2F] focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-white"
284
+ placeholder="key1=value1&key2=value2"
285
+ />
286
+ <p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
287
+ Add query string parameters (without the ?)
288
+ </p>
289
+ </div>
290
+ )}
291
+
292
+ {/* Body Editor (hide for GET) */}
293
+ {request.method !== 'GET' && (
294
+ <div>
295
+ <div className="mb-1 flex items-center justify-between">
296
+ <label className="block text-sm font-bold text-black dark:text-white">Body (JSON)</label>
297
+ <button
298
+ onClick={() => setRequest((prev) => ({ ...prev, body: getDefaultBody() }))}
299
+ className="text-xs text-gray-600 hover:text-[#BA0C2F] dark:text-gray-400"
300
+ >
301
+ Reset to defaults
302
+ </button>
303
+ </div>
304
+ <textarea
305
+ value={request.body}
306
+ onChange={(e) => setRequest((prev) => ({ ...prev, body: e.target.value }))}
307
+ className="h-48 w-full border-2 border-black bg-white p-3 font-mono text-sm focus:border-[#BA0C2F] focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-white"
308
+ placeholder='{"key": "value"}'
309
+ />
310
+ </div>
311
+ )}
312
+
313
+ {/* Parameter Documentation */}
314
+ {schema && (schema.request?.length || schema.queryParams?.length) ? (
315
+ <ParameterDocs
316
+ requestParams={schema.request}
317
+ queryParams={schema.queryParams}
318
+ isGetRequest={request.method === 'GET'}
319
+ />
320
+ ) : null}
321
+
322
+ {/* Headers */}
323
+ <div>
324
+ <label className="mb-1 block text-sm font-bold text-black dark:text-white">Headers</label>
325
+ <div className="border-2 border-black bg-gray-50 p-3 dark:border-gray-600 dark:bg-gray-800">
326
+ {Object.entries(request.headers).map(([key, value]) => (
327
+ <div key={key} className="flex items-center gap-2 text-sm">
328
+ <span className="font-bold text-black dark:text-white">{key}:</span>
329
+ <span className="text-gray-600 dark:text-gray-400">{value}</span>
330
+ </div>
331
+ ))}
332
+ </div>
333
+ <p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
334
+ API keys loaded from .env automatically
335
+ </p>
336
+ </div>
337
+
338
+ {/* Submit Button */}
339
+ <button
340
+ onClick={handleSubmit}
341
+ disabled={isLoading}
342
+ className="w-full border-2 border-black bg-[#BA0C2F] py-3 font-bold text-white transition-colors hover:bg-[#8a0923] disabled:opacity-50"
343
+ >
344
+ {isLoading ? (
345
+ <span className="flex items-center justify-center gap-2">
346
+ <svg
347
+ className="h-4 w-4 animate-spin"
348
+ xmlns="http://www.w3.org/2000/svg"
349
+ fill="none"
350
+ viewBox="0 0 24 24"
351
+ >
352
+ <circle
353
+ className="opacity-25"
354
+ cx="12"
355
+ cy="12"
356
+ r="10"
357
+ stroke="currentColor"
358
+ strokeWidth="4"
359
+ />
360
+ <path
361
+ className="opacity-75"
362
+ fill="currentColor"
363
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
364
+ />
365
+ </svg>
366
+ Sending...
367
+ </span>
368
+ ) : (
369
+ `Send ${request.method} Request`
370
+ )}
371
+ </button>
372
+ </div>
373
+
374
+ {/* Response Panel */}
375
+ <div className="space-y-4">
376
+ <div className="flex items-center justify-between">
377
+ <h3 className="text-lg font-bold text-black dark:text-white">Response</h3>
378
+ {response.time !== null && (
379
+ <span className="text-sm text-gray-600 dark:text-gray-400">{response.time}ms</span>
380
+ )}
381
+ </div>
382
+
383
+ {/* Status */}
384
+ {response.status !== null && (
385
+ <div className="flex items-center gap-2">
386
+ <span className={`text-2xl font-bold ${getStatusColor(response.status)}`}>
387
+ {response.status}
388
+ </span>
389
+ <span className="text-gray-600 dark:text-gray-400">{response.statusText}</span>
390
+ </div>
391
+ )}
392
+
393
+ {/* Error */}
394
+ {response.error && (
395
+ <div className="border-2 border-red-600 bg-red-50 p-4 dark:bg-red-900/20">
396
+ <p className="text-sm text-red-800 dark:text-red-300">{response.error}</p>
397
+ </div>
398
+ )}
399
+
400
+ {/* Audio Player */}
401
+ {audioUrl && (
402
+ <div className="border-2 border-black bg-gray-50 p-4 dark:border-gray-600 dark:bg-gray-800">
403
+ <p className="mb-2 text-sm font-bold text-black dark:text-white">Audio Response</p>
404
+ <audio controls className="w-full" src={audioUrl}>
405
+ Your browser does not support the audio element.
406
+ </audio>
407
+ </div>
408
+ )}
409
+
410
+ {/* Body */}
411
+ {response.body ? (
412
+ <div className="relative">
413
+ <pre className="max-h-96 overflow-auto border-2 border-black bg-zinc-900 p-4 text-sm text-zinc-100">
414
+ <code>{response.body}</code>
415
+ </pre>
416
+ <button
417
+ onClick={() => navigator.clipboard.writeText(response.body)}
418
+ className="absolute right-2 top-2 border border-zinc-600 bg-zinc-700 px-2 py-1 text-xs text-zinc-300 hover:bg-zinc-600"
419
+ >
420
+ Copy
421
+ </button>
422
+ </div>
423
+ ) : (
424
+ <div className="flex h-48 items-center justify-center border-2 border-dashed border-black dark:border-gray-600">
425
+ <p className="text-sm text-gray-600 dark:text-gray-400">
426
+ {isLoading ? 'Waiting for response...' : 'Send a request to see the response'}
427
+ </p>
428
+ </div>
429
+ )}
430
+ </div>
431
+ </div>
432
+ );
433
+ }
434
+
435
+ /**
436
+ * Parameter Documentation Component
437
+ * Displays request body and query parameter documentation in a collapsible panel.
438
+ */
439
+ function ParameterDocs({
440
+ requestParams,
441
+ queryParams,
442
+ isGetRequest,
443
+ }: {
444
+ requestParams?: ParameterDoc[];
445
+ queryParams?: ParameterDoc[];
446
+ isGetRequest: boolean;
447
+ }) {
448
+ const [isExpanded, setIsExpanded] = useState(true);
449
+
450
+ const paramsToShow = isGetRequest ? queryParams : requestParams;
451
+ if (!paramsToShow?.length) return null;
452
+
453
+ return (
454
+ <div className="border-2 border-black dark:border-gray-600">
455
+ <button
456
+ onClick={() => setIsExpanded(!isExpanded)}
457
+ className="flex w-full items-center justify-between bg-gray-50 px-3 py-2 text-left dark:bg-gray-800"
458
+ >
459
+ <span className="text-sm font-bold text-black dark:text-white">
460
+ {isGetRequest ? 'Query Parameters' : 'Request Body'} Documentation
461
+ </span>
462
+ <svg
463
+ xmlns="http://www.w3.org/2000/svg"
464
+ width="16"
465
+ height="16"
466
+ viewBox="0 0 24 24"
467
+ fill="none"
468
+ stroke="currentColor"
469
+ strokeWidth="2"
470
+ strokeLinecap="round"
471
+ strokeLinejoin="round"
472
+ className={`text-gray-500 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
473
+ >
474
+ <polyline points="6 9 12 15 18 9" />
475
+ </svg>
476
+ </button>
477
+
478
+ {isExpanded && (
479
+ <div className="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
480
+ {paramsToShow.map((param) => (
481
+ <div key={param.name} className="px-3 py-2">
482
+ <div className="flex items-center gap-2">
483
+ <code className="text-sm font-bold text-[#BA0C2F]">{param.name}</code>
484
+ <span className="border border-gray-300 bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400">
485
+ {param.type}
486
+ </span>
487
+ {param.required && (
488
+ <span className="border border-red-300 bg-red-50 px-1.5 py-0.5 text-xs text-red-600 dark:border-red-800 dark:bg-red-900/30 dark:text-red-400">
489
+ required
490
+ </span>
491
+ )}
492
+ </div>
493
+ {param.description && (
494
+ <p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
495
+ {param.description}
496
+ </p>
497
+ )}
498
+ {param.enum && (
499
+ <div className="mt-1 flex flex-wrap gap-1">
500
+ <span className="text-xs text-gray-500">Options:</span>
501
+ {param.enum.map((val) => (
502
+ <code
503
+ key={val}
504
+ className="border border-gray-200 bg-gray-50 px-1 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400"
505
+ >
506
+ {val}
507
+ </code>
508
+ ))}
509
+ </div>
510
+ )}
511
+ {param.default !== undefined && (
512
+ <p className="mt-1 text-xs text-gray-500">
513
+ Default: <code className="text-gray-700 dark:text-gray-300">{String(param.default)}</code>
514
+ </p>
515
+ )}
516
+ </div>
517
+ ))}
518
+ </div>
519
+ )}
520
+ </div>
521
+ );
522
+ }
@@ -0,0 +1,41 @@
1
+ 'use client';
2
+
3
+ import { HeroHeader } from '../shared/HeroHeader';
4
+ import { APIShowcase } from './_components/APIShowcase';
5
+
6
+ /**
7
+ * API Showcase Page
8
+ *
9
+ * Auto-generated grid view of all APIs from registry.json.
10
+ * Click any API card to open interactive testing modal.
11
+ *
12
+ * Features:
13
+ * - Animated 3D grid hero header
14
+ * - Grid layout showing all registered APIs
15
+ * - Interactive "Try It" testing for each endpoint
16
+ * - Request/response schema display
17
+ * - Curl example generation
18
+ * - Test status indicators
19
+ *
20
+ * Created with Hustle API Dev Tools (v3.9.2)
21
+ */
22
+ export default function APIShowcasePage() {
23
+ return (
24
+ <main className="min-h-screen bg-white dark:bg-[#050505]">
25
+ <HeroHeader
26
+ title="API Showcase"
27
+ badge="API Documentation"
28
+ description={
29
+ <>
30
+ Interactive testing and documentation for all{' '}
31
+ <strong>Hustle</strong> API endpoints.
32
+ </>
33
+ }
34
+ />
35
+
36
+ <div className="container mx-auto px-4 py-8">
37
+ <APIShowcase />
38
+ </div>
39
+ </main>
40
+ );
41
+ }