astro-tractstack 2.3.1 → 2.3.3

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 (73) hide show
  1. package/bin/create-tractstack.js +3 -3
  2. package/dist/index.js +69 -11
  3. package/package.json +1 -1
  4. package/templates/custom/shopify/Cart.tsx +99 -19
  5. package/templates/custom/shopify/CheckoutModal.tsx +196 -10
  6. package/templates/custom/shopify/ShopifyCartManager.tsx +79 -76
  7. package/templates/custom/shopify/ShopifyCheckout.tsx +4 -33
  8. package/templates/custom/shopify/ShopifyProductGrid.tsx +42 -14
  9. package/templates/custom/shopify/ShopifyServiceList.tsx +94 -50
  10. package/templates/custom/shopify/cart.astro +7 -1
  11. package/templates/src/components/Footer.astro +2 -2
  12. package/templates/src/components/Header.astro +17 -9
  13. package/templates/src/components/Menu.tsx +157 -135
  14. package/templates/src/components/codehooks/BunnyVideoSetup.tsx +2 -2
  15. package/templates/src/components/codehooks/EpinetDurationSelector.tsx +27 -6
  16. package/templates/src/components/codehooks/EpinetTableView.tsx +153 -112
  17. package/templates/src/components/codehooks/EpinetWrapper.tsx +4 -1
  18. package/templates/src/components/codehooks/FeaturedArticleSetup.tsx +8 -1
  19. package/templates/src/components/codehooks/ProductCardSetup.tsx +9 -1
  20. package/templates/src/components/codehooks/ProductGridSetup.tsx +9 -1
  21. package/templates/src/components/compositor/nodes/BgPaneWrapper.tsx +2 -1
  22. package/templates/src/components/compositor/nodes/GhostInsertBlock.tsx +1 -1
  23. package/templates/src/components/edit/ToolBar.tsx +2 -1
  24. package/templates/src/components/edit/context/ContextPaneConfig_slug.tsx +2 -2
  25. package/templates/src/components/edit/pane/AddPanePanel_codehook.tsx +13 -0
  26. package/templates/src/components/edit/pane/AddPanePanel_newCustomCopy.tsx +2 -2
  27. package/templates/src/components/edit/pane/ConfigPanePanel.tsx +1 -1
  28. package/templates/src/components/edit/state/SaveModal.tsx +1 -1
  29. package/templates/src/components/edit/widgets/InteractiveDisclosureWidget.tsx +8 -3
  30. package/templates/src/components/form/DateTimeInput.tsx +10 -3
  31. package/templates/src/components/form/FileUpload.tsx +11 -5
  32. package/templates/src/components/form/NumberInput.tsx +2 -2
  33. package/templates/src/components/form/advanced/APIConfigSection.tsx +221 -39
  34. package/templates/src/components/form/brand/SiteConfigSection.tsx +10 -0
  35. package/templates/src/components/form/shopify/SchedulingSection.tsx +44 -0
  36. package/templates/src/components/storykeep/Dashboard_Advanced.tsx +10 -1
  37. package/templates/src/components/storykeep/Dashboard_Shopify.tsx +16 -8
  38. package/templates/src/components/storykeep/controls/content/BeliefForm.tsx +2 -2
  39. package/templates/src/components/storykeep/controls/content/ManageContent.tsx +1 -0
  40. package/templates/src/components/storykeep/controls/content/ProductTable.tsx +2 -2
  41. package/templates/src/components/storykeep/controls/content/ResourceBulkIngest.tsx +79 -51
  42. package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +80 -0
  43. package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +1 -0
  44. package/templates/src/components/storykeep/email-builder/Blocks.tsx +169 -0
  45. package/templates/src/components/storykeep/email-builder/EmailBuilder.tsx +223 -0
  46. package/templates/src/components/storykeep/email-builder/PreviewModal.tsx +136 -0
  47. package/templates/src/components/storykeep/email-builder/PropertyPanel.tsx +154 -0
  48. package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +1 -8
  49. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +118 -14
  50. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Emails.tsx +105 -0
  51. package/templates/src/constants.ts +2 -0
  52. package/templates/src/layouts/Layout.astro +8 -5
  53. package/templates/src/pages/api/google/oauth/callback.ts +50 -0
  54. package/templates/src/pages/api/google/oauth/disconnect.ts +32 -0
  55. package/templates/src/pages/api/google/oauth/start.ts +32 -0
  56. package/templates/src/pages/api/google/oauth/status.ts +32 -0
  57. package/templates/src/pages/privacy.astro +84 -0
  58. package/templates/src/pages/terms.astro +47 -0
  59. package/templates/src/stores/shopify.ts +21 -0
  60. package/templates/src/types/formTypes.ts +4 -2
  61. package/templates/src/types/tractstack.ts +35 -2
  62. package/templates/src/utils/api/advancedHelpers.ts +16 -0
  63. package/templates/src/utils/api/bookingHelpers.ts +3 -1
  64. package/templates/src/utils/api/brandConfig.ts +2 -0
  65. package/templates/src/utils/api/brandHelpers.ts +24 -1
  66. package/templates/src/utils/api/emailHelpers.ts +105 -0
  67. package/templates/src/utils/booking/appointmentMode.ts +135 -0
  68. package/templates/src/utils/customHelpers.ts +2 -0
  69. package/templates/src/utils/tenantResolver.ts +1 -1
  70. package/utils/inject-files.ts +63 -5
  71. package/templates/src/components/codehooks/SandboxAuthWrapper.tsx +0 -101
  72. package/templates/src/utils/actions/actionButton.ts +0 -103
  73. package/templates/src/utils/actions/preParse_Clicked.ts +0 -87
@@ -7,6 +7,8 @@ interface ResourceBulkIngestProps {
7
7
  onClose: (saved: boolean) => void;
8
8
  onRefresh: () => void;
9
9
  fullContentMap: FullContentMapItem[];
10
+ /** When set, placeholder shows one example for this category only (validation still accepts all types). */
11
+ exampleCategorySlug?: string;
10
12
  }
11
13
 
12
14
  interface ParsedResource {
@@ -40,10 +42,63 @@ interface FieldDefinition {
40
42
  maxNumber?: number;
41
43
  }
42
44
 
45
+ function buildExampleObjectForCategory(
46
+ categorySlug: string,
47
+ index: number,
48
+ knownResources: Record<string, Record<string, FieldDefinition>>,
49
+ categoryKeys: string[]
50
+ ): Record<string, unknown> {
51
+ const schema = knownResources[categorySlug];
52
+ const example: Record<string, unknown> = {
53
+ title: `Example ${categorySlug.charAt(0).toUpperCase() + categorySlug.slice(1)} ${index + 1}`,
54
+ slug: `${categorySlug}-example-${index + 1}`,
55
+ category: categorySlug,
56
+ oneliner: `A brief description of this ${categorySlug}`,
57
+ };
58
+
59
+ Object.entries(schema).forEach(([key, def]: [string, FieldDefinition]) => {
60
+ switch (def.type) {
61
+ case 'string':
62
+ if (
63
+ def.belongsToCategory &&
64
+ categoryKeys.includes(def.belongsToCategory)
65
+ ) {
66
+ example[key] = `${def.belongsToCategory}-example-slug`;
67
+ } else {
68
+ example[key] = def.defaultValue || `example ${key}`;
69
+ }
70
+ break;
71
+ case 'number':
72
+ example[key] = def.defaultValue ?? (def.minNumber || 0);
73
+ break;
74
+ case 'boolean':
75
+ example[key] = def.defaultValue ?? true;
76
+ break;
77
+ case 'multi':
78
+ example[key] = def.defaultValue || [
79
+ `example ${key} 1`,
80
+ `example ${key} 2`,
81
+ ];
82
+ break;
83
+ case 'date':
84
+ example[key] = new Date().toISOString();
85
+ break;
86
+ case 'image':
87
+ example[key] = 'file-id-placeholder';
88
+ break;
89
+ default:
90
+ example[key] = def.defaultValue || `example ${key}`;
91
+ }
92
+ });
93
+
94
+ return example;
95
+ }
96
+
43
97
  export default function ResourceBulkIngest({
44
98
  onClose,
45
99
  onRefresh,
46
100
  fullContentMap,
101
+ exampleCategorySlug,
47
102
  }: ResourceBulkIngestProps) {
48
103
  const [brandConfig, setBrandConfig] = useState<BrandConfig | null>(null);
49
104
  const [loading, setLoading] = useState(false);
@@ -418,7 +473,7 @@ export default function ResourceBulkIngest({
418
473
  };
419
474
  }, [jsonInput, knownResources, fullContentMap]);
420
475
 
421
- // Generate example JSON based on available categories - ENHANCED VERSION
476
+ // Placeholder JSON: one category when exampleCategorySlug is set, else one object per known category
422
477
  const exampleJson = useMemo(() => {
423
478
  const categories = Object.keys(knownResources);
424
479
  if (categories.length === 0) {
@@ -436,59 +491,32 @@ export default function ResourceBulkIngest({
436
491
  );
437
492
  }
438
493
 
439
- // Create examples for ALL categories, not just the first one
440
- const examples = categories.map((categorySlug, index) => {
441
- const schema = knownResources[categorySlug];
442
- const example: any = {
443
- title: `Example ${categorySlug.charAt(0).toUpperCase() + categorySlug.slice(1)} ${index + 1}`,
444
- slug: `${categorySlug}-example-${index + 1}`,
445
- category: categorySlug,
446
- oneliner: `A brief description of this ${categorySlug}`,
447
- };
448
-
449
- // Add example values for schema fields
450
- Object.entries(schema).forEach(
451
- ([key, def]: [string, FieldDefinition]) => {
452
- switch (def.type) {
453
- case 'string':
454
- if (
455
- def.belongsToCategory &&
456
- categories.includes(def.belongsToCategory)
457
- ) {
458
- example[key] = `${def.belongsToCategory}-example-slug`;
459
- } else {
460
- example[key] = def.defaultValue || `example ${key}`;
461
- }
462
- break;
463
- case 'number':
464
- example[key] = def.defaultValue ?? (def.minNumber || 0);
465
- break;
466
- case 'boolean':
467
- example[key] = def.defaultValue ?? true;
468
- break;
469
- case 'multi':
470
- example[key] = def.defaultValue || [
471
- `example ${key} 1`,
472
- `example ${key} 2`,
473
- ];
474
- break;
475
- case 'date':
476
- example[key] = new Date().toISOString();
477
- break;
478
- case 'image':
479
- example[key] = 'file-id-placeholder';
480
- break;
481
- default:
482
- example[key] = def.defaultValue || `example ${key}`;
483
- }
484
- }
485
- );
494
+ if (
495
+ exampleCategorySlug &&
496
+ knownResources[exampleCategorySlug] !== undefined
497
+ ) {
498
+ const examples = [
499
+ buildExampleObjectForCategory(
500
+ exampleCategorySlug,
501
+ 0,
502
+ knownResources,
503
+ categories
504
+ ),
505
+ ];
506
+ return JSON.stringify(examples, null, 2);
507
+ }
486
508
 
487
- return example;
488
- });
509
+ const examples = categories.map((categorySlug, index) =>
510
+ buildExampleObjectForCategory(
511
+ categorySlug,
512
+ index,
513
+ knownResources,
514
+ categories
515
+ )
516
+ );
489
517
 
490
518
  return JSON.stringify(examples, null, 2);
491
- }, [knownResources]);
519
+ }, [knownResources, exampleCategorySlug]);
492
520
 
493
521
  const handleSave = useCallback(async () => {
494
522
  if (validationResult.validResources.length === 0 || isProcessing) return;
@@ -1,3 +1,4 @@
1
+ import { useEffect } from 'react';
1
2
  import { useFormState } from '@/hooks/useFormState';
2
3
  import { convertToLocalState } from '@/utils/api/resourceHelpers';
3
4
  import { saveResourceWithStateUpdate } from '@/utils/api/resourceConfig';
@@ -26,6 +27,7 @@ interface ResourceFormProps {
26
27
  fullContentMap: FullContentMapItem[];
27
28
  categorySlug: string;
28
29
  categorySchema: Record<string, FieldDefinition>;
30
+ tenantRemoteOnly?: boolean;
29
31
  isCreate?: boolean;
30
32
  onClose?: (saved: boolean) => void;
31
33
  }
@@ -35,6 +37,7 @@ export default function ResourceForm({
35
37
  fullContentMap,
36
38
  categorySlug,
37
39
  categorySchema,
40
+ tenantRemoteOnly = false,
38
41
  isCreate = false,
39
42
  onClose,
40
43
  }: ResourceFormProps) {
@@ -184,6 +187,31 @@ export default function ResourceForm({
184
187
  });
185
188
 
186
189
  const { state, updateField, errors } = formState;
190
+ const isServiceCategory = categorySlug === 'service';
191
+ const serviceRemoteOnly = Boolean(state.optionsPayload?.remoteOnly);
192
+ const effectiveServiceRemoteOnly = tenantRemoteOnly || serviceRemoteOnly;
193
+
194
+ useEffect(() => {
195
+ if (!isServiceCategory) return;
196
+ if (!effectiveServiceRemoteOnly) return;
197
+
198
+ const nextPayload = {
199
+ ...state.optionsPayload,
200
+ allowRemote: true,
201
+ remoteOnly: true,
202
+ };
203
+ const needsUpdate =
204
+ state.optionsPayload?.allowRemote !== true ||
205
+ state.optionsPayload?.remoteOnly !== true;
206
+ if (needsUpdate) {
207
+ updateField('optionsPayload', nextPayload);
208
+ }
209
+ }, [
210
+ effectiveServiceRemoteOnly,
211
+ isServiceCategory,
212
+ state.optionsPayload,
213
+ updateField,
214
+ ]);
187
215
 
188
216
  // Helper to get category reference options for a field
189
217
  const getCategoryReferenceOptions = (belongsToCategory: string) => {
@@ -213,6 +241,13 @@ export default function ResourceForm({
213
241
  };
214
242
 
215
243
  const renderDynamicField = (fieldName: string, fieldDef: FieldDefinition) => {
244
+ if (
245
+ !isServiceCategory &&
246
+ (fieldName === 'allowRemote' || fieldName === 'remoteOnly')
247
+ ) {
248
+ return null;
249
+ }
250
+
216
251
  if (
217
252
  resourceFormHideFields.includes(fieldName)
218
253
  // && initialData.optionsPayload?.[fieldName]
@@ -322,6 +357,51 @@ export default function ResourceForm({
322
357
  );
323
358
 
324
359
  case 'boolean':
360
+ if (isServiceCategory && fieldName === 'allowRemote') {
361
+ const locked = effectiveServiceRemoteOnly;
362
+ return (
363
+ <BooleanToggle
364
+ key={fieldName}
365
+ label="Allow Remote"
366
+ value={locked ? true : Boolean(fieldValue)}
367
+ onChange={(value) =>
368
+ updateOptionsField(fieldName, locked ? true : Boolean(value))
369
+ }
370
+ error={fieldError}
371
+ disabled={locked}
372
+ description={
373
+ locked
374
+ ? 'Locked to true because remoteOnly is enabled at tenant or service scope.'
375
+ : undefined
376
+ }
377
+ />
378
+ );
379
+ }
380
+ if (isServiceCategory && fieldName === 'remoteOnly') {
381
+ const locked = tenantRemoteOnly;
382
+ return (
383
+ <BooleanToggle
384
+ key={fieldName}
385
+ label="Remote Only"
386
+ value={locked ? true : Boolean(fieldValue)}
387
+ onChange={(value) => {
388
+ const next = Boolean(value);
389
+ updateOptionsField(fieldName, locked ? true : next);
390
+ if (next || locked) {
391
+ updateOptionsField('allowRemote', true);
392
+ }
393
+ }}
394
+ error={fieldError}
395
+ disabled={locked}
396
+ description={
397
+ locked
398
+ ? 'Locked to true because tenant scheduling is set to remoteOnly.'
399
+ : 'When enabled, this service can only be booked remotely.'
400
+ }
401
+ />
402
+ );
403
+ }
404
+
325
405
  return (
326
406
  <BooleanToggle
327
407
  key={fieldName}
@@ -216,6 +216,7 @@ export default function ResourceTable({
216
216
  </div>
217
217
  {showBulkIngest && (
218
218
  <ResourceBulkIngest
219
+ exampleCategorySlug={categorySlug}
219
220
  fullContentMap={fullContentMap}
220
221
  onClose={(saved) => {
221
222
  setShowBulkIngest(false);
@@ -0,0 +1,169 @@
1
+ import { useLayoutEffect, useRef, type ClipboardEvent } from 'react';
2
+ import type { EmailBlock } from '@/utils/api/emailHelpers';
3
+
4
+ interface BlocksProps {
5
+ blocks: EmailBlock[];
6
+ selectedIdx: number | null;
7
+ onSelect: (index: number) => void;
8
+ onChange: (index: number, block: EmailBlock) => void;
9
+ }
10
+
11
+ function TextBlockEditor({
12
+ idx,
13
+ block,
14
+ isSelected,
15
+ onSelect,
16
+ onChange,
17
+ handlePaste,
18
+ }: {
19
+ idx: number;
20
+ block: Extract<EmailBlock, { type: 'text' }>;
21
+ isSelected: boolean;
22
+ onSelect: (index: number) => void;
23
+ onChange: (index: number, block: EmailBlock) => void;
24
+ handlePaste: (
25
+ e: ClipboardEvent<HTMLTextAreaElement>,
26
+ idx: number,
27
+ block: Extract<EmailBlock, { type: 'text' }>
28
+ ) => void;
29
+ }) {
30
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
31
+
32
+ useLayoutEffect(() => {
33
+ const el = textareaRef.current;
34
+ if (!el) return;
35
+ el.style.height = 'auto';
36
+ el.style.height = `${el.scrollHeight}px`;
37
+ }, [block.content]);
38
+
39
+ const containerStyle = {
40
+ border: isSelected ? '2px solid #0867ec' : '2px solid transparent',
41
+ padding: '8px',
42
+ cursor: 'pointer',
43
+ marginBottom: '8px',
44
+ };
45
+
46
+ return (
47
+ <div style={containerStyle} onClick={() => onSelect(idx)}>
48
+ <textarea
49
+ ref={textareaRef}
50
+ value={block.content}
51
+ className="box-border w-full py-1 leading-relaxed"
52
+ onChange={(e) => onChange(idx, { ...block, content: e.target.value })}
53
+ onPaste={(e) => handlePaste(e, idx, block)}
54
+ onFocus={() => onSelect(idx)}
55
+ onClick={(e) => e.stopPropagation()}
56
+ style={{
57
+ minHeight: '2.5rem',
58
+ fontFamily: 'Helvetica, sans-serif',
59
+ fontSize: '16px',
60
+ color: block.color,
61
+ textAlign: block.align,
62
+ fontWeight: block.isBold ? 'bold' : 'normal',
63
+ border: 'none',
64
+ background: 'transparent',
65
+ resize: 'none',
66
+ overflow: 'hidden',
67
+ }}
68
+ rows={1}
69
+ />
70
+ </div>
71
+ );
72
+ }
73
+
74
+ export default function Blocks({
75
+ blocks,
76
+ selectedIdx,
77
+ onSelect,
78
+ onChange,
79
+ }: BlocksProps) {
80
+ const handlePaste = (
81
+ e: ClipboardEvent<HTMLTextAreaElement>,
82
+ idx: number,
83
+ block: Extract<EmailBlock, { type: 'text' }>
84
+ ) => {
85
+ e.preventDefault();
86
+ const pastedText = e.clipboardData.getData('text/plain');
87
+ const target = e.currentTarget;
88
+
89
+ const start = target.selectionStart;
90
+ const end = target.selectionEnd;
91
+
92
+ const newContent =
93
+ block.content.substring(0, start) +
94
+ pastedText +
95
+ block.content.substring(end);
96
+
97
+ onChange(idx, { ...block, content: newContent });
98
+
99
+ window.requestAnimationFrame(() => {
100
+ target.setSelectionRange(
101
+ start + pastedText.length,
102
+ start + pastedText.length
103
+ );
104
+ });
105
+ };
106
+
107
+ return (
108
+ <div className="flex flex-col p-8">
109
+ {blocks.map((block, idx) => {
110
+ const isSelected = selectedIdx === idx;
111
+
112
+ if (block.type === 'text') {
113
+ return (
114
+ <TextBlockEditor
115
+ key={idx}
116
+ idx={idx}
117
+ block={block}
118
+ isSelected={isSelected}
119
+ onSelect={onSelect}
120
+ onChange={onChange}
121
+ handlePaste={handlePaste}
122
+ />
123
+ );
124
+ }
125
+
126
+ const containerStyle = {
127
+ border: isSelected ? '2px solid #0867ec' : '2px solid transparent',
128
+ padding: '8px',
129
+ cursor: 'pointer',
130
+ marginBottom: '8px',
131
+ };
132
+
133
+ return (
134
+ <div key={idx} style={containerStyle} onClick={() => onSelect(idx)}>
135
+ {block.type === 'button' && (
136
+ <div style={{ textAlign: 'center' }}>
137
+ <span
138
+ style={{
139
+ display: 'inline-block',
140
+ padding: '12px 24px',
141
+ backgroundColor: block.bgColor,
142
+ color: block.textColor,
143
+ borderRadius: '4px',
144
+ fontFamily: 'Helvetica, sans-serif',
145
+ fontSize: '16px',
146
+ fontWeight: 'bold',
147
+ }}
148
+ >
149
+ {block.label}
150
+ </span>
151
+ </div>
152
+ )}
153
+
154
+ {block.type === 'divider' && (
155
+ <div style={{ width: '100%', padding: '12px 0' }}>
156
+ <div
157
+ style={{
158
+ borderTop: `1px solid ${block.color}`,
159
+ width: '100%',
160
+ }}
161
+ />
162
+ </div>
163
+ )}
164
+ </div>
165
+ );
166
+ })}
167
+ </div>
168
+ );
169
+ }
@@ -0,0 +1,223 @@
1
+ import { useState, useEffect } from 'react';
2
+ import {
3
+ emailHelpers,
4
+ type EmailTemplate,
5
+ type EmailBlock,
6
+ } from '@/utils/api/emailHelpers';
7
+ import Blocks from './Blocks';
8
+ import PropertyPanel from './PropertyPanel';
9
+ import PreviewModal from './PreviewModal';
10
+
11
+ interface EmailBuilderProps {
12
+ category: string;
13
+ templateName: string;
14
+ onClose: () => void;
15
+ }
16
+
17
+ export default function EmailBuilder({
18
+ category,
19
+ templateName,
20
+ onClose,
21
+ }: EmailBuilderProps) {
22
+ const [template, setTemplate] = useState<EmailTemplate | null>(null);
23
+ const [isLoading, setIsLoading] = useState(true);
24
+ const [isSaving, setIsSaving] = useState(false);
25
+ const [error, setError] = useState<string | null>(null);
26
+ const [selectedIdx, setSelectedIdx] = useState<number | null>(null);
27
+ const [isPreviewOpen, setIsPreviewOpen] = useState(false);
28
+
29
+ useEffect(() => {
30
+ const load = async () => {
31
+ try {
32
+ const data = await emailHelpers.getTemplate(category, templateName);
33
+ setTemplate(data);
34
+ } catch (err) {
35
+ setError(
36
+ err instanceof Error ? err.message : 'Failed to load template'
37
+ );
38
+ } finally {
39
+ setIsLoading(false);
40
+ }
41
+ };
42
+ load();
43
+ }, [category, templateName]);
44
+
45
+ const handleSave = async () => {
46
+ if (!template) return;
47
+ try {
48
+ setIsSaving(true);
49
+ await emailHelpers.saveTemplate(category, templateName, template);
50
+ onClose();
51
+ } catch (err) {
52
+ alert(err instanceof Error ? err.message : 'Failed to save');
53
+ } finally {
54
+ setIsSaving(false);
55
+ }
56
+ };
57
+
58
+ const updateBlock = (index: number, newBlock: EmailBlock) => {
59
+ if (!template) return;
60
+ const newBlocks = [...template.blocks];
61
+ newBlocks[index] = newBlock;
62
+ setTemplate({ ...template, blocks: newBlocks });
63
+ };
64
+
65
+ const addBlock = (type: 'text' | 'button' | 'divider') => {
66
+ if (!template) return;
67
+ let newBlock: EmailBlock;
68
+ if (type === 'text') {
69
+ newBlock = {
70
+ type: 'text',
71
+ content: 'New Text',
72
+ align: 'left',
73
+ color: '#333333',
74
+ isBold: false,
75
+ };
76
+ } else if (type === 'button') {
77
+ newBlock = {
78
+ type: 'button',
79
+ label: 'Click Here',
80
+ url: 'https://',
81
+ bgColor: '#0867ec',
82
+ textColor: '#ffffff',
83
+ };
84
+ } else {
85
+ newBlock = { type: 'divider', color: '#e5e7eb' };
86
+ }
87
+ setTemplate({ ...template, blocks: [...template.blocks, newBlock] });
88
+ setSelectedIdx(template.blocks.length);
89
+ };
90
+
91
+ const deleteBlock = (index: number) => {
92
+ if (!template) return;
93
+ const newBlocks = [...template.blocks];
94
+ newBlocks.splice(index, 1);
95
+ setTemplate({ ...template, blocks: newBlocks });
96
+ setSelectedIdx(null);
97
+ };
98
+
99
+ const moveBlock = (index: number, direction: 'up' | 'down') => {
100
+ if (!template) return;
101
+ if (direction === 'up' && index === 0) return;
102
+ if (direction === 'down' && index === template.blocks.length - 1) return;
103
+ const newBlocks = [...template.blocks];
104
+ const targetIdx = direction === 'up' ? index - 1 : index + 1;
105
+ const temp = newBlocks[index];
106
+ newBlocks[index] = newBlocks[targetIdx];
107
+ newBlocks[targetIdx] = temp;
108
+ setTemplate({ ...template, blocks: newBlocks });
109
+ setSelectedIdx(targetIdx);
110
+ };
111
+
112
+ if (isLoading) return <div>Loading...</div>;
113
+ if (error) return <div className="text-red-600">{error}</div>;
114
+ if (!template) return null;
115
+
116
+ return (
117
+ <div className="flex max-h-screen min-h-96 flex-col overflow-hidden rounded-lg border border-gray-200 bg-white">
118
+ <div className="flex items-center justify-between border-b border-gray-200 px-4 py-3">
119
+ <div className="flex min-w-0 flex-1 items-center gap-4">
120
+ <button
121
+ onClick={onClose}
122
+ className="text-sm font-bold text-gray-500 hover:text-gray-900"
123
+ >
124
+ &larr; Back
125
+ </button>
126
+ <div className="hidden shrink-0 flex-col border-r border-gray-200 pr-4 md:flex">
127
+ <span className="text-xs font-bold uppercase tracking-wide text-gray-400">
128
+ Template file
129
+ </span>
130
+ <span className="max-w-xs truncate text-sm font-bold text-gray-700">
131
+ {category}/{templateName}.json
132
+ </span>
133
+ </div>
134
+ <div className="flex min-w-0 flex-1 flex-col">
135
+ <label className="text-xs font-bold text-gray-500">
136
+ Subject Line
137
+ </label>
138
+ <input
139
+ type="text"
140
+ value={template.subject}
141
+ onChange={(e) =>
142
+ setTemplate({ ...template, subject: e.target.value })
143
+ }
144
+ className="w-full min-w-0 border-0 bg-transparent p-0 text-sm font-bold focus:ring-0"
145
+ placeholder="Email Subject..."
146
+ />
147
+ </div>
148
+ </div>
149
+ <div className="flex gap-2">
150
+ <button
151
+ onClick={() => setIsPreviewOpen(true)}
152
+ className="rounded-md bg-gray-100 px-4 py-2 text-sm font-bold text-gray-700 hover:bg-gray-200"
153
+ >
154
+ Preview
155
+ </button>
156
+ <button
157
+ onClick={handleSave}
158
+ disabled={isSaving}
159
+ className="rounded-md bg-cyan-600 px-4 py-2 text-sm font-bold text-white hover:bg-cyan-500 disabled:opacity-50"
160
+ >
161
+ {isSaving ? 'Saving...' : 'Save Template'}
162
+ </button>
163
+ </div>
164
+ </div>
165
+
166
+ <div className="flex min-h-0 flex-1 overflow-hidden">
167
+ <div className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-gray-50 p-8">
168
+ <div className="mx-auto min-h-96 w-full max-w-2xl bg-white shadow-sm">
169
+ <Blocks
170
+ blocks={template.blocks}
171
+ selectedIdx={selectedIdx}
172
+ onSelect={setSelectedIdx}
173
+ onChange={updateBlock}
174
+ />
175
+ </div>
176
+ <div className="mx-auto mt-8 flex max-w-2xl gap-2">
177
+ <button
178
+ onClick={() => addBlock('text')}
179
+ className="rounded border px-3 py-1 text-sm font-bold text-gray-600"
180
+ >
181
+ + Text
182
+ </button>
183
+ <button
184
+ onClick={() => addBlock('button')}
185
+ className="rounded border px-3 py-1 text-sm font-bold text-gray-600"
186
+ >
187
+ + Button
188
+ </button>
189
+ <button
190
+ onClick={() => addBlock('divider')}
191
+ className="rounded border px-3 py-1 text-sm font-bold text-gray-600"
192
+ >
193
+ + Divider
194
+ </button>
195
+ </div>
196
+ </div>
197
+
198
+ <div className="w-80 shrink-0 overflow-y-auto border-l border-gray-200 bg-white p-4">
199
+ {selectedIdx !== null && template.blocks[selectedIdx] ? (
200
+ <PropertyPanel
201
+ block={template.blocks[selectedIdx]}
202
+ onChange={(b) => updateBlock(selectedIdx, b)}
203
+ onDelete={() => deleteBlock(selectedIdx)}
204
+ onMoveUp={() => moveBlock(selectedIdx, 'up')}
205
+ onMoveDown={() => moveBlock(selectedIdx, 'down')}
206
+ />
207
+ ) : (
208
+ <div className="text-sm text-gray-500">
209
+ Select a block to edit its properties.
210
+ </div>
211
+ )}
212
+ </div>
213
+ </div>
214
+
215
+ {isPreviewOpen && (
216
+ <PreviewModal
217
+ template={template}
218
+ onClose={() => setIsPreviewOpen(false)}
219
+ />
220
+ )}
221
+ </div>
222
+ );
223
+ }