astro-tractstack 2.0.11 → 2.0.13

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.
@@ -0,0 +1,575 @@
1
+ import { useState, useCallback } from 'react';
2
+ import { AiPanePreview } from './AiPanePreview';
3
+ import type { TemplatePane } from '@/types/compositorTypes';
4
+ import prompts from '@/constants/prompts.json';
5
+ import ColorPickerCombo from '@/components/fields/ColorPickerCombo';
6
+ import { parseAiPane } from '@/utils/compositor/aiPaneParser';
7
+ import { classNames } from '@/utils/helpers';
8
+ import type { BrandConfig } from '@/types/tractstack';
9
+
10
+ interface AiPaneGeneratorProps {
11
+ ownerId: string;
12
+ onComplete: (pane: TemplatePane) => void;
13
+ onCancel: () => void;
14
+ config?: BrandConfig;
15
+ }
16
+
17
+ type GenerationStep = 'input' | 'preview' | 'loading';
18
+ type CopyMode = 'prompt' | 'raw';
19
+
20
+ interface GenerationResponse {
21
+ success: boolean;
22
+ data?: {
23
+ response: string | object;
24
+ };
25
+ error?: string;
26
+ }
27
+
28
+ const harmonyOptions = [
29
+ 'Analogous',
30
+ 'Monochromatic',
31
+ 'Complementary',
32
+ 'Triadic',
33
+ ];
34
+ const themeOptions = ['Light', 'Dark', 'Bright', 'Muted', 'Pastel', 'Earthy'];
35
+
36
+ export function AiPaneGenerator({
37
+ ownerId,
38
+ onComplete,
39
+ onCancel,
40
+ config,
41
+ }: AiPaneGeneratorProps) {
42
+ const [currentStep, setCurrentStep] = useState<GenerationStep>('input');
43
+ const [selectedLayout] = useState<string>('Text Only');
44
+ const [copyMode, setCopyMode] = useState<CopyMode>('prompt');
45
+ const [copyPrompt, setCopyPrompt] = useState('');
46
+ const [rawCopy, setRawCopy] = useState('');
47
+ const [generatedShell, setGeneratedShell] = useState<string | null>(null);
48
+ const [generatedCopy, setGeneratedCopy] = useState<string | null>(null);
49
+ const [error, setError] = useState<string | null>(null);
50
+
51
+ const [selectedHarmony, setSelectedHarmony] = useState<string>(
52
+ harmonyOptions[0]
53
+ );
54
+ const [baseColor, setBaseColor] = useState<string>('');
55
+ const [accentColor, setAccentColor] = useState<string>('');
56
+ const [selectedTheme, setSelectedTheme] = useState<string>(themeOptions[0]);
57
+ const [additionalNotes, setAdditionalNotes] = useState<string>('');
58
+
59
+ const [isInjectMode, setIsInjectMode] = useState(false);
60
+ const [injectShell, setInjectShell] = useState('');
61
+ const [injectCopy, setInjectCopy] = useState('');
62
+
63
+ const callAskLemurAPI = useCallback(
64
+ async (
65
+ prompt: string,
66
+ context: string,
67
+ expectJson: boolean
68
+ ): Promise<string> => {
69
+ const goBackend =
70
+ import.meta.env.PUBLIC_GO_BACKEND || 'http://localhost:8080';
71
+ const tenantId = import.meta.env.PUBLIC_TENANTID || 'default';
72
+
73
+ const requestBody = {
74
+ prompt: prompt,
75
+ input_text: context,
76
+ final_model: 'anthropic/claude-3-5-sonnet',
77
+ temperature: 0.5,
78
+ max_tokens: 2000,
79
+ };
80
+
81
+ const response = await fetch(`${goBackend}/api/v1/aai/askLemur`, {
82
+ method: 'POST',
83
+ headers: {
84
+ 'Content-Type': 'application/json',
85
+ 'X-Tenant-ID': tenantId,
86
+ },
87
+ credentials: 'include',
88
+ body: JSON.stringify(requestBody),
89
+ });
90
+
91
+ if (!response.ok) {
92
+ const errorText = await response.text();
93
+ console.error('AskLemur API Error Response:', errorText);
94
+ let backendError = `API call failed: ${response.status} ${response.statusText}`;
95
+ try {
96
+ const errorJson = JSON.parse(errorText);
97
+ if (errorJson && errorJson.error) {
98
+ backendError = errorJson.error;
99
+ }
100
+ } catch (e) {
101
+ /* Ignore */
102
+ }
103
+ throw new Error(backendError);
104
+ }
105
+
106
+ const result = (await response.json()) as GenerationResponse;
107
+
108
+ if (!result.success || !result.data?.response) {
109
+ throw new Error(
110
+ result.error || 'Generation failed to return valid response.'
111
+ );
112
+ }
113
+
114
+ let rawResponseData = result.data.response;
115
+
116
+ if (expectJson && typeof rawResponseData === 'object') {
117
+ return JSON.stringify(rawResponseData);
118
+ }
119
+
120
+ if (typeof rawResponseData === 'string') {
121
+ let responseString = rawResponseData;
122
+ try {
123
+ if (
124
+ responseString.startsWith('```json') &&
125
+ responseString.endsWith('```')
126
+ ) {
127
+ responseString = responseString.slice(7, -3).trim();
128
+ } else if (
129
+ responseString.startsWith('```html') &&
130
+ responseString.endsWith('```')
131
+ ) {
132
+ responseString = responseString.slice(7, -3).trim();
133
+ }
134
+ } catch (e) {
135
+ /* Ignore stripping errors */
136
+ }
137
+ return responseString;
138
+ }
139
+
140
+ throw new Error('Unexpected response format received from API.');
141
+ },
142
+ []
143
+ );
144
+
145
+ const handleGenerate = useCallback(async () => {
146
+ setError(null);
147
+ setCurrentStep('loading');
148
+ setGeneratedShell(null);
149
+ setGeneratedCopy(null);
150
+
151
+ let designInput = `Generate a design using a **${selectedHarmony.toLowerCase()}** color scheme with a **${selectedTheme.toLowerCase()}** theme.`;
152
+ if (baseColor) {
153
+ designInput += ` Base the colors around **${baseColor}**.`;
154
+ }
155
+ if (accentColor) {
156
+ designInput += ` Use **${accentColor}** as an accent color.`;
157
+ }
158
+ if (additionalNotes) {
159
+ designInput += ` Refine the design with these additional notes: "${additionalNotes}"`;
160
+ }
161
+
162
+ try {
163
+ const shellPromptDetails = prompts.aiPaneShellPrompt;
164
+ const copyPromptDetails = prompts.aiPaneCopyPrompt;
165
+
166
+ if (
167
+ !shellPromptDetails?.user_template ||
168
+ !copyPromptDetails?.user_template
169
+ ) {
170
+ throw new Error('AI prompts not found or incomplete in prompts.json');
171
+ }
172
+
173
+ const formattedShellPrompt = shellPromptDetails.user_template
174
+ .replace('{{DESIGN_INPUT}}', designInput)
175
+ .replace('{{LAYOUT_TYPE}}', selectedLayout);
176
+
177
+ const shellResult = await callAskLemurAPI(
178
+ formattedShellPrompt,
179
+ shellPromptDetails.system || '',
180
+ true
181
+ );
182
+ setGeneratedShell(shellResult);
183
+
184
+ const copyInputContent = copyMode === 'prompt' ? copyPrompt : rawCopy;
185
+ const formattedCopyPrompt = copyPromptDetails.user_template
186
+ .replace('{{COPY_INPUT}}', copyInputContent)
187
+ .replace('{{DESIGN_INPUT}}', designInput)
188
+ .replace('{{LAYOUT_TYPE}}', selectedLayout)
189
+ .replace('{{SHELL_JSON}}', shellResult);
190
+
191
+ const copyResult = await callAskLemurAPI(
192
+ formattedCopyPrompt,
193
+ copyPromptDetails.system || '',
194
+ false
195
+ );
196
+ setGeneratedCopy(copyResult);
197
+
198
+ setCurrentStep('preview');
199
+ } catch (err: any) {
200
+ console.error('AI Pane Generation Error:', err);
201
+ setError(err.message || 'Failed to generate AI pane.');
202
+ setCurrentStep('input');
203
+ }
204
+ }, [
205
+ selectedHarmony,
206
+ baseColor,
207
+ accentColor,
208
+ selectedTheme,
209
+ additionalNotes,
210
+ selectedLayout,
211
+ copyMode,
212
+ copyPrompt,
213
+ rawCopy,
214
+ callAskLemurAPI,
215
+ ]);
216
+
217
+ const handleInject = useCallback(() => {
218
+ setError(null);
219
+ if (!injectShell || !injectCopy) {
220
+ setError('Both Shell JSON and Copy HTML must be provided.');
221
+ return;
222
+ }
223
+ try {
224
+ const shellResponse = JSON.parse(injectShell);
225
+ const copyResponse = JSON.parse(injectCopy);
226
+
227
+ const shellPayloadString = JSON.stringify(shellResponse?.data?.response);
228
+ const copyPayloadString = copyResponse?.data?.response;
229
+
230
+ if (
231
+ !shellPayloadString ||
232
+ shellPayloadString === 'null' ||
233
+ typeof copyPayloadString !== 'string'
234
+ ) {
235
+ throw new Error(
236
+ 'Payloads are in an unexpected format. Could not find "data.response".'
237
+ );
238
+ }
239
+
240
+ const pane = parseAiPane(
241
+ shellPayloadString,
242
+ copyPayloadString,
243
+ selectedLayout
244
+ );
245
+ onComplete(pane);
246
+ } catch (err: any) {
247
+ console.error('Payload Injection Error:', err);
248
+ setError(err.message || 'Failed to parse payloads. Check JSON format.');
249
+ }
250
+ }, [injectShell, injectCopy, selectedLayout, onComplete]);
251
+
252
+ const handleBack = () => {
253
+ setError(null);
254
+ if (currentStep === 'preview') {
255
+ setCurrentStep('input');
256
+ } else if (currentStep === 'input') {
257
+ if (isInjectMode) {
258
+ setIsInjectMode(false);
259
+ } else {
260
+ onCancel();
261
+ }
262
+ } else if (currentStep === 'loading') {
263
+ setCurrentStep('input');
264
+ }
265
+ };
266
+
267
+ if (currentStep === 'loading') {
268
+ return (
269
+ <div className="flex min-h-[200px] flex-col items-center justify-center space-y-4 p-6">
270
+ <div className="h-8 w-8 animate-spin rounded-full border-b-2 border-gray-400"></div>
271
+ <p className="text-sm text-gray-500">Generating AI Pane...</p>
272
+ <button
273
+ type="button"
274
+ onClick={handleBack}
275
+ className="mt-2 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-bold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2"
276
+ >
277
+ Cancel
278
+ </button>
279
+ </div>
280
+ );
281
+ }
282
+
283
+ if (currentStep === 'preview' && generatedShell && generatedCopy) {
284
+ return (
285
+ <AiPanePreview
286
+ shellJson={generatedShell}
287
+ copyHtml={generatedCopy}
288
+ layout={selectedLayout}
289
+ ownerId={ownerId}
290
+ onComplete={onComplete}
291
+ onBack={handleBack}
292
+ />
293
+ );
294
+ }
295
+
296
+ if (currentStep === 'input') {
297
+ if (isInjectMode) {
298
+ return (
299
+ <div className="space-y-6 p-4">
300
+ <div>
301
+ <label
302
+ htmlFor="shell-json"
303
+ className="block text-lg font-semibold text-gray-800"
304
+ >
305
+ Shell JSON Payload
306
+ </label>
307
+ <textarea
308
+ id="shell-json"
309
+ value={injectShell}
310
+ onChange={(e) => setInjectShell(e.target.value)}
311
+ placeholder="Paste raw API response for ShellJson here..."
312
+ rows={8}
313
+ className="mt-2 block w-full rounded-md border-gray-300 p-2 font-mono text-sm shadow-sm focus:border-cyan-500 focus:ring-cyan-500"
314
+ />
315
+ </div>
316
+
317
+ <div>
318
+ <label
319
+ htmlFor="copy-html"
320
+ className="block text-lg font-semibold text-gray-800"
321
+ >
322
+ Copy HTML Payload
323
+ </label>
324
+ <textarea
325
+ id="copy-html"
326
+ value={injectCopy}
327
+ onChange={(e) => setInjectCopy(e.target.value)}
328
+ placeholder="Paste raw API response for copyHtml here..."
329
+ rows={8}
330
+ className="mt-2 block w-full rounded-md border-gray-300 p-2 font-mono text-sm shadow-sm focus:border-cyan-500 focus:ring-cyan-500"
331
+ />
332
+ </div>
333
+
334
+ {error && <p className="text-sm text-red-600">{error}</p>}
335
+
336
+ <div className="flex justify-between pt-4">
337
+ <button
338
+ type="button"
339
+ onClick={handleBack}
340
+ className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-bold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2"
341
+ >
342
+ Back to Generator
343
+ </button>
344
+ <button
345
+ type="button"
346
+ onClick={handleInject}
347
+ disabled={!injectShell || !injectCopy}
348
+ className={`rounded-md border border-transparent px-4 py-2 text-sm font-bold text-white shadow-sm ${
349
+ !injectShell || !injectCopy
350
+ ? 'cursor-not-allowed bg-gray-400'
351
+ : 'bg-cyan-600 hover:bg-cyan-700 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2'
352
+ }`}
353
+ >
354
+ Create from Payloads
355
+ </button>
356
+ </div>
357
+ </div>
358
+ );
359
+ }
360
+
361
+ return (
362
+ <div className="space-y-6 p-4">
363
+ <div>
364
+ <label className="block text-lg font-semibold text-gray-800">
365
+ Color Harmony
366
+ </label>
367
+ <div className="mt-2 flex flex-wrap gap-x-4 gap-y-2">
368
+ {harmonyOptions.map((option) => (
369
+ <div key={option} className="flex items-center space-x-2">
370
+ <input
371
+ type="radio"
372
+ id={`harmony-${option}`}
373
+ name="harmonyOptions"
374
+ value={option}
375
+ checked={selectedHarmony === option}
376
+ onChange={(e) => setSelectedHarmony(e.target.value)}
377
+ className="h-4 w-4 border-gray-300 text-cyan-600 focus:ring-cyan-500"
378
+ />
379
+ <label
380
+ htmlFor={`harmony-${option}`}
381
+ className="text-sm font-medium text-gray-700"
382
+ >
383
+ {option}
384
+ </label>
385
+ </div>
386
+ ))}
387
+ </div>
388
+ </div>
389
+
390
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
391
+ <div>
392
+ <ColorPickerCombo
393
+ title="Base Color (Optional)"
394
+ config={config!}
395
+ defaultColor={baseColor}
396
+ onColorChange={setBaseColor}
397
+ allowNull={true}
398
+ />
399
+ </div>
400
+ <div>
401
+ <ColorPickerCombo
402
+ title="Accent Color (Optional)"
403
+ config={config!}
404
+ defaultColor={accentColor}
405
+ onColorChange={setAccentColor}
406
+ allowNull={true}
407
+ />
408
+ </div>
409
+ </div>
410
+
411
+ <div>
412
+ <label className="block text-lg font-semibold text-gray-800">
413
+ Theme / Mood
414
+ </label>
415
+ <div className="mt-2 flex flex-wrap gap-x-4 gap-y-2">
416
+ {themeOptions.map((option) => (
417
+ <div key={option} className="flex items-center space-x-2">
418
+ <input
419
+ type="radio"
420
+ id={`theme-${option}`}
421
+ name="themeOptions"
422
+ value={option}
423
+ checked={selectedTheme === option}
424
+ onChange={(e) => setSelectedTheme(e.target.value)}
425
+ className="h-4 w-4 border-gray-300 text-cyan-600 focus:ring-cyan-500"
426
+ />
427
+ <label
428
+ htmlFor={`theme-${option}`}
429
+ className="text-sm font-medium text-gray-700"
430
+ >
431
+ {option}
432
+ </label>
433
+ </div>
434
+ ))}
435
+ </div>
436
+ </div>
437
+
438
+ <div>
439
+ <label
440
+ htmlFor="additional-notes"
441
+ className="block text-lg font-semibold text-gray-800"
442
+ >
443
+ Additional Design Notes (Optional)
444
+ </label>
445
+ <p className="mb-2 mt-1 text-sm text-gray-500">
446
+ Add specific requests like "use rounded corners", "add subtle
447
+ texture".
448
+ </p>
449
+ <textarea
450
+ id="additional-notes"
451
+ value={additionalNotes}
452
+ onChange={(e) => setAdditionalNotes(e.target.value)}
453
+ placeholder="Enter additional notes..."
454
+ rows={3}
455
+ className="block w-full rounded-md border-gray-300 p-2 shadow-sm focus:border-cyan-500 focus:ring-cyan-500 sm:text-sm"
456
+ />
457
+ </div>
458
+
459
+ <div>
460
+ <label className="block text-lg font-semibold text-gray-800">
461
+ Provide Content
462
+ </label>
463
+ <div className="my-2 flex space-x-4">
464
+ <div className="flex items-center space-x-2">
465
+ <input
466
+ type="radio"
467
+ id="copy-prompt-mode"
468
+ name="copyModeOptions"
469
+ value="prompt"
470
+ checked={copyMode === 'prompt'}
471
+ onChange={(e) => setCopyMode(e.target.value as CopyMode)}
472
+ className="h-4 w-4 border-gray-300 text-cyan-600 focus:ring-cyan-500"
473
+ />
474
+ <label
475
+ htmlFor="copy-prompt-mode"
476
+ className="text-sm font-medium text-gray-700"
477
+ >
478
+ Write a prompt
479
+ </label>
480
+ </div>
481
+ <div className="flex items-center space-x-2">
482
+ <input
483
+ type="radio"
484
+ id="copy-raw-mode"
485
+ name="copyModeOptions"
486
+ value="raw"
487
+ checked={copyMode === 'raw'}
488
+ onChange={(e) => setCopyMode(e.target.value as CopyMode)}
489
+ className="h-4 w-4 border-gray-300 text-cyan-600 focus:ring-cyan-500"
490
+ />
491
+ <label
492
+ htmlFor="copy-raw-mode"
493
+ className="text-sm font-medium text-gray-700"
494
+ >
495
+ Provide Copy
496
+ </label>
497
+ </div>
498
+ </div>
499
+
500
+ {copyMode === 'prompt' ? (
501
+ <>
502
+ <p className="mb-2 text-sm text-gray-500">
503
+ Let the AI write the copy based on your prompt.
504
+ </p>
505
+ <textarea
506
+ id="copy-prompt"
507
+ value={copyPrompt}
508
+ onChange={(e) => setCopyPrompt(e.target.value)}
509
+ placeholder="Enter copy prompt..."
510
+ rows={4}
511
+ className="block w-full rounded-md border-gray-300 p-2 shadow-sm focus:border-cyan-500 focus:ring-cyan-500 sm:text-sm"
512
+ />
513
+ </>
514
+ ) : (
515
+ <>
516
+ <p className="mb-2 text-sm text-gray-500">
517
+ Provide your raw copy text here. The AI will structure and style
518
+ it.
519
+ </p>
520
+ <textarea
521
+ id="raw-copy"
522
+ value={rawCopy}
523
+ onChange={(e) => setRawCopy(e.target.value)}
524
+ placeholder="Paste or type your copy text..."
525
+ rows={6}
526
+ className="block w-full rounded-md border-gray-300 p-2 shadow-sm focus:border-cyan-500 focus:ring-cyan-500 sm:text-sm"
527
+ />
528
+ </>
529
+ )}
530
+ </div>
531
+
532
+ {error && <p className="text-sm text-red-600">{error}</p>}
533
+
534
+ <div className="flex justify-between pt-4">
535
+ <button
536
+ type="button"
537
+ onClick={handleBack}
538
+ className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-bold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2"
539
+ >
540
+ Cancel
541
+ </button>
542
+ <button
543
+ type="button"
544
+ onClick={handleGenerate}
545
+ disabled={copyMode === 'prompt' ? !copyPrompt : !rawCopy}
546
+ className={classNames(
547
+ `rounded-md border border-transparent px-4 py-2 text-sm font-bold shadow-sm transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2`,
548
+ (copyMode === 'prompt' && !copyPrompt) ||
549
+ (copyMode === `raw` && !rawCopy)
550
+ ? 'cursor-not-allowed bg-gray-300 text-gray-500'
551
+ : 'bg-cyan-600 text-white hover:bg-cyan-700'
552
+ )}
553
+ >
554
+ Generate Pane
555
+ </button>
556
+ </div>
557
+
558
+ <div className="border-t border-gray-200 pt-4 text-center">
559
+ <button
560
+ type="button"
561
+ onClick={() => {
562
+ setError(null);
563
+ setIsInjectMode(true);
564
+ }}
565
+ className="text-sm text-cyan-600 hover:text-cyan-800 hover:underline"
566
+ >
567
+ Direct Inject Payload
568
+ </button>
569
+ </div>
570
+ </div>
571
+ );
572
+ }
573
+
574
+ return null;
575
+ }
@@ -0,0 +1,107 @@
1
+ import { useState, useEffect, useMemo } from 'react';
2
+ import type { TemplatePane } from '@/types/compositorTypes';
3
+ import { parseAiPane } from '@/utils/compositor/aiPaneParser';
4
+
5
+ interface AiPanePreviewProps {
6
+ shellJson: string;
7
+ copyHtml: string;
8
+ layout: string;
9
+ ownerId: string;
10
+ onComplete: (pane: TemplatePane) => void;
11
+ onBack: () => void;
12
+ }
13
+
14
+ export function AiPanePreview({
15
+ shellJson,
16
+ copyHtml,
17
+ layout,
18
+ onComplete,
19
+ onBack,
20
+ }: AiPanePreviewProps) {
21
+ const [error, setError] = useState<string | null>(null);
22
+ const [hasCompleted, setHasCompleted] = useState<boolean>(false);
23
+ const [isLoading, setIsLoading] = useState<boolean>(true);
24
+
25
+ useEffect(() => {
26
+ setError(null);
27
+ setHasCompleted(false);
28
+ setIsLoading(true);
29
+ let isActive = true;
30
+
31
+ if (shellJson && copyHtml) {
32
+ try {
33
+ const pane = parseAiPane(shellJson, copyHtml, layout);
34
+ if (isActive && !hasCompleted) {
35
+ onComplete(pane);
36
+ setHasCompleted(true);
37
+ setIsLoading(false);
38
+ }
39
+ } catch (err: any) {
40
+ console.error('Error parsing AI Pane:', err);
41
+ if (isActive) {
42
+ setError(err.message || 'Failed to parse generated content.');
43
+ setIsLoading(false);
44
+ }
45
+ }
46
+ } else {
47
+ // Handle case where inputs might be initially empty
48
+ setIsLoading(false);
49
+ }
50
+
51
+ return () => {
52
+ isActive = false;
53
+ };
54
+ }, [shellJson, copyHtml, layout, onComplete, hasCompleted]);
55
+
56
+ const displayContent = useMemo(() => {
57
+ if (isLoading) {
58
+ return (
59
+ <div className="p-4 text-center text-gray-500">
60
+ <div className="mx-auto mb-2 h-8 w-8 animate-spin rounded-full border-b-2 border-gray-400"></div>
61
+ <p className="text-sm">Processing...</p>
62
+ </div>
63
+ );
64
+ }
65
+ if (error) {
66
+ return (
67
+ <div className="p-4 text-center text-red-600">
68
+ <p className="font-semibold">Error:</p>
69
+ <p className="mt-1 text-sm">{error}</p>
70
+ </div>
71
+ );
72
+ }
73
+ if (hasCompleted) {
74
+ return (
75
+ <div className="p-4 text-center text-green-700">
76
+ <p className="font-semibold">Pane Applied Successfully!</p>
77
+ <p className="mt-1 text-sm">
78
+ You can now go back or continue editing.
79
+ </p>
80
+ </div>
81
+ );
82
+ }
83
+ // Fallback/initial state before useEffect runs if needed
84
+ return (
85
+ <div className="p-4 text-center text-gray-500">
86
+ <p className="text-sm">Preparing...</p>
87
+ </div>
88
+ );
89
+ }, [isLoading, error, hasCompleted]);
90
+
91
+ return (
92
+ <div className="flex h-full flex-col p-4">
93
+ <div className="relative mb-4 flex min-h-[200px] flex-grow items-center justify-center overflow-auto rounded border bg-gray-50">
94
+ {displayContent}
95
+ </div>
96
+ <div className="flex flex-shrink-0 justify-start">
97
+ <button
98
+ onClick={onBack}
99
+ className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
100
+ type="button"
101
+ >
102
+ Back
103
+ </button>
104
+ </div>
105
+ </div>
106
+ );
107
+ }