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.
- package/bin/create-tractstack.js +3 -3
- package/dist/index.js +69 -11
- package/package.json +1 -1
- package/templates/custom/shopify/Cart.tsx +99 -19
- package/templates/custom/shopify/CheckoutModal.tsx +196 -10
- package/templates/custom/shopify/ShopifyCartManager.tsx +79 -76
- package/templates/custom/shopify/ShopifyCheckout.tsx +4 -33
- package/templates/custom/shopify/ShopifyProductGrid.tsx +42 -14
- package/templates/custom/shopify/ShopifyServiceList.tsx +94 -50
- package/templates/custom/shopify/cart.astro +7 -1
- package/templates/src/components/Footer.astro +2 -2
- package/templates/src/components/Header.astro +17 -9
- package/templates/src/components/Menu.tsx +157 -135
- package/templates/src/components/codehooks/BunnyVideoSetup.tsx +2 -2
- package/templates/src/components/codehooks/EpinetDurationSelector.tsx +27 -6
- package/templates/src/components/codehooks/EpinetTableView.tsx +153 -112
- package/templates/src/components/codehooks/EpinetWrapper.tsx +4 -1
- package/templates/src/components/codehooks/FeaturedArticleSetup.tsx +8 -1
- package/templates/src/components/codehooks/ProductCardSetup.tsx +9 -1
- package/templates/src/components/codehooks/ProductGridSetup.tsx +9 -1
- package/templates/src/components/compositor/nodes/BgPaneWrapper.tsx +2 -1
- package/templates/src/components/compositor/nodes/GhostInsertBlock.tsx +1 -1
- package/templates/src/components/edit/ToolBar.tsx +2 -1
- package/templates/src/components/edit/context/ContextPaneConfig_slug.tsx +2 -2
- package/templates/src/components/edit/pane/AddPanePanel_codehook.tsx +13 -0
- package/templates/src/components/edit/pane/AddPanePanel_newCustomCopy.tsx +2 -2
- package/templates/src/components/edit/pane/ConfigPanePanel.tsx +1 -1
- package/templates/src/components/edit/state/SaveModal.tsx +1 -1
- package/templates/src/components/edit/widgets/InteractiveDisclosureWidget.tsx +8 -3
- package/templates/src/components/form/DateTimeInput.tsx +10 -3
- package/templates/src/components/form/FileUpload.tsx +11 -5
- package/templates/src/components/form/NumberInput.tsx +2 -2
- package/templates/src/components/form/advanced/APIConfigSection.tsx +221 -39
- package/templates/src/components/form/brand/SiteConfigSection.tsx +10 -0
- package/templates/src/components/form/shopify/SchedulingSection.tsx +44 -0
- package/templates/src/components/storykeep/Dashboard_Advanced.tsx +10 -1
- package/templates/src/components/storykeep/Dashboard_Shopify.tsx +16 -8
- package/templates/src/components/storykeep/controls/content/BeliefForm.tsx +2 -2
- package/templates/src/components/storykeep/controls/content/ManageContent.tsx +1 -0
- package/templates/src/components/storykeep/controls/content/ProductTable.tsx +2 -2
- package/templates/src/components/storykeep/controls/content/ResourceBulkIngest.tsx +79 -51
- package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +80 -0
- package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +1 -0
- package/templates/src/components/storykeep/email-builder/Blocks.tsx +169 -0
- package/templates/src/components/storykeep/email-builder/EmailBuilder.tsx +223 -0
- package/templates/src/components/storykeep/email-builder/PreviewModal.tsx +136 -0
- package/templates/src/components/storykeep/email-builder/PropertyPanel.tsx +154 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +1 -8
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +118 -14
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Emails.tsx +105 -0
- package/templates/src/constants.ts +2 -0
- package/templates/src/layouts/Layout.astro +8 -5
- package/templates/src/pages/api/google/oauth/callback.ts +50 -0
- package/templates/src/pages/api/google/oauth/disconnect.ts +32 -0
- package/templates/src/pages/api/google/oauth/start.ts +32 -0
- package/templates/src/pages/api/google/oauth/status.ts +32 -0
- package/templates/src/pages/privacy.astro +84 -0
- package/templates/src/pages/terms.astro +47 -0
- package/templates/src/stores/shopify.ts +21 -0
- package/templates/src/types/formTypes.ts +4 -2
- package/templates/src/types/tractstack.ts +35 -2
- package/templates/src/utils/api/advancedHelpers.ts +16 -0
- package/templates/src/utils/api/bookingHelpers.ts +3 -1
- package/templates/src/utils/api/brandConfig.ts +2 -0
- package/templates/src/utils/api/brandHelpers.ts +24 -1
- package/templates/src/utils/api/emailHelpers.ts +105 -0
- package/templates/src/utils/booking/appointmentMode.ts +135 -0
- package/templates/src/utils/customHelpers.ts +2 -0
- package/templates/src/utils/tenantResolver.ts +1 -1
- package/utils/inject-files.ts +63 -5
- package/templates/src/components/codehooks/SandboxAuthWrapper.tsx +0 -101
- package/templates/src/utils/actions/actionButton.ts +0 -103
- 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
|
-
//
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
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}
|
|
@@ -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
|
+
← 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
|
+
}
|