astro-tractstack 2.3.1 → 2.3.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.
- package/dist/index.js +36 -3
- package/package.json +1 -1
- package/templates/custom/shopify/Cart.tsx +16 -5
- package/templates/custom/shopify/CheckoutModal.tsx +4 -4
- package/templates/custom/shopify/ShopifyCartManager.tsx +27 -36
- 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/src/components/Footer.astro +2 -2
- package/templates/src/components/Header.astro +14 -8
- 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 +2 -38
- package/templates/src/components/form/brand/SiteConfigSection.tsx +10 -0
- package/templates/src/components/storykeep/Dashboard_Shopify.tsx +7 -8
- package/templates/src/components/storykeep/controls/content/BeliefForm.tsx +2 -2
- 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/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 +32 -6
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Emails.tsx +105 -0
- package/templates/src/layouts/Layout.astro +8 -5
- package/templates/src/stores/shopify.ts +16 -0
- package/templates/src/types/formTypes.ts +4 -2
- package/templates/src/types/tractstack.ts +5 -2
- package/templates/src/utils/api/brandConfig.ts +2 -0
- package/templates/src/utils/api/brandHelpers.ts +16 -0
- package/templates/src/utils/api/emailHelpers.ts +105 -0
- package/templates/src/utils/tenantResolver.ts +1 -1
- package/utils/inject-files.ts +34 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { forwardRef, useId } from 'react';
|
|
1
|
+
import { forwardRef, useId, type ChangeEvent } from 'react';
|
|
2
2
|
import { classNames } from '@/utils/helpers';
|
|
3
3
|
|
|
4
4
|
interface NumberInputProps {
|
|
@@ -42,7 +42,7 @@ const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
|
|
|
42
42
|
const errorId = `${inputId}-error`;
|
|
43
43
|
const inputName = customName || inputId;
|
|
44
44
|
|
|
45
|
-
const handleChange = (e:
|
|
45
|
+
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
46
46
|
const newValue = parseFloat(e.target.value);
|
|
47
47
|
if (!isNaN(newValue)) {
|
|
48
48
|
onChange(newValue);
|
|
@@ -212,7 +212,7 @@ export default function APIConfigSection({
|
|
|
212
212
|
|
|
213
213
|
<BooleanToggle
|
|
214
214
|
label="Webhooks Manually Configured"
|
|
215
|
-
description="I have manually created the required webhooks
|
|
215
|
+
description="I have manually created the required webhooks in my Shopify Admin."
|
|
216
216
|
value={state.userSetupWebhooks}
|
|
217
217
|
onChange={(value) =>
|
|
218
218
|
updateField('userSetupWebhooks', value)
|
|
@@ -300,7 +300,7 @@ export default function APIConfigSection({
|
|
|
300
300
|
<ul className="mt-2 list-disc space-y-1 pl-5">
|
|
301
301
|
<li>
|
|
302
302
|
<strong>Event:</strong> Select the specific event (e.g.,
|
|
303
|
-
Order payment, Product
|
|
303
|
+
Order payment, Product update).
|
|
304
304
|
</li>
|
|
305
305
|
<li>
|
|
306
306
|
<strong>Format:</strong> Select JSON (TractStack relies
|
|
@@ -358,12 +358,6 @@ export default function APIConfigSection({
|
|
|
358
358
|
</span>{' '}
|
|
359
359
|
Order payment
|
|
360
360
|
</li>
|
|
361
|
-
<li>
|
|
362
|
-
<span className="font-bold text-gray-900">
|
|
363
|
-
Topic Header:
|
|
364
|
-
</span>{' '}
|
|
365
|
-
orders/paid
|
|
366
|
-
</li>
|
|
367
361
|
<li>
|
|
368
362
|
<span className="font-bold text-gray-900">
|
|
369
363
|
Purpose:
|
|
@@ -374,24 +368,6 @@ export default function APIConfigSection({
|
|
|
374
368
|
</ul>
|
|
375
369
|
</div>
|
|
376
370
|
|
|
377
|
-
<div className="rounded border border-gray-200 bg-gray-50 p-4">
|
|
378
|
-
<h5 className="font-bold">2. Product Creation</h5>
|
|
379
|
-
<ul className="mt-2 space-y-1 text-xs">
|
|
380
|
-
<li>
|
|
381
|
-
<span className="font-bold text-gray-900">
|
|
382
|
-
Shopify Event Name:
|
|
383
|
-
</span>{' '}
|
|
384
|
-
Product creation
|
|
385
|
-
</li>
|
|
386
|
-
<li>
|
|
387
|
-
<span className="font-bold text-gray-900">
|
|
388
|
-
Topic Header:
|
|
389
|
-
</span>{' '}
|
|
390
|
-
products/create
|
|
391
|
-
</li>
|
|
392
|
-
</ul>
|
|
393
|
-
</div>
|
|
394
|
-
|
|
395
371
|
<div className="rounded border border-gray-200 bg-gray-50 p-4">
|
|
396
372
|
<h5 className="font-bold">3. Product Update</h5>
|
|
397
373
|
<ul className="mt-2 space-y-1 text-xs">
|
|
@@ -401,12 +377,6 @@ export default function APIConfigSection({
|
|
|
401
377
|
</span>{' '}
|
|
402
378
|
Product update
|
|
403
379
|
</li>
|
|
404
|
-
<li>
|
|
405
|
-
<span className="font-bold text-gray-900">
|
|
406
|
-
Topic Header:
|
|
407
|
-
</span>{' '}
|
|
408
|
-
products/update
|
|
409
|
-
</li>
|
|
410
380
|
</ul>
|
|
411
381
|
</div>
|
|
412
382
|
|
|
@@ -419,12 +389,6 @@ export default function APIConfigSection({
|
|
|
419
389
|
</span>{' '}
|
|
420
390
|
Product deletion
|
|
421
391
|
</li>
|
|
422
|
-
<li>
|
|
423
|
-
<span className="font-bold text-gray-900">
|
|
424
|
-
Topic Header:
|
|
425
|
-
</span>{' '}
|
|
426
|
-
products/delete
|
|
427
|
-
</li>
|
|
428
392
|
</ul>
|
|
429
393
|
</div>
|
|
430
394
|
</div>
|
|
@@ -48,6 +48,16 @@ export default function SiteConfigSection({
|
|
|
48
48
|
error={errors.footer}
|
|
49
49
|
/>
|
|
50
50
|
|
|
51
|
+
<StringInput
|
|
52
|
+
value={state.adminEmail}
|
|
53
|
+
onChange={(value) => updateField('adminEmail', value)}
|
|
54
|
+
label="Admin Email"
|
|
55
|
+
type="email"
|
|
56
|
+
placeholder="admin@example.com"
|
|
57
|
+
required={true}
|
|
58
|
+
error={errors.adminEmail}
|
|
59
|
+
/>
|
|
60
|
+
|
|
51
61
|
<StringInput
|
|
52
62
|
value={state.gtag}
|
|
53
63
|
onChange={(value) => updateField('gtag', value)}
|
|
@@ -26,6 +26,7 @@ import ShopifyDashboard_Services from './shopify/ShopifyDashboard_Services';
|
|
|
26
26
|
import ShopifyDashboard_Schedule from './shopify/ShopifyDashboard_Schedule';
|
|
27
27
|
import ShopifyDashboard_Search from './shopify/ShopifyDashboard_Search';
|
|
28
28
|
import ShopifyDashboard_Bookings from './shopify/ShopifyDashboard_Bookings';
|
|
29
|
+
import ShopifyDashboard_Emails from './shopify/ShopifyDashboard_Emails';
|
|
29
30
|
|
|
30
31
|
interface DashboardShopifyProps {
|
|
31
32
|
brandConfig: BrandConfig;
|
|
@@ -78,7 +79,8 @@ export default function StoryKeepDashboard_Shopify({
|
|
|
78
79
|
{ id: 'products', name: 'Products' },
|
|
79
80
|
{ id: 'services', name: 'Services' },
|
|
80
81
|
{ id: 'schedule', name: 'Schedule' },
|
|
81
|
-
{ id: 'search', name: '
|
|
82
|
+
{ id: 'search', name: 'Import Products' },
|
|
83
|
+
{ id: 'emails', name: 'Emails' },
|
|
82
84
|
];
|
|
83
85
|
|
|
84
86
|
useEffect(() => {
|
|
@@ -171,7 +173,7 @@ export default function StoryKeepDashboard_Shopify({
|
|
|
171
173
|
|
|
172
174
|
const executePreFlightCheck = (category: string, product: ShopifyProduct) => {
|
|
173
175
|
const hasMode = product.options.some((opt) => opt.name === 'Mode');
|
|
174
|
-
if (hasMode) {
|
|
176
|
+
if (category === 'service' || hasMode) {
|
|
175
177
|
startCreateFlow(category, product);
|
|
176
178
|
} else {
|
|
177
179
|
setPendingImport({ category, product });
|
|
@@ -465,15 +467,12 @@ export default function StoryKeepDashboard_Shopify({
|
|
|
465
467
|
</div>
|
|
466
468
|
)}
|
|
467
469
|
|
|
468
|
-
{activeTab === 'dashboards' &&
|
|
469
|
-
<ShopifyDashboard existingResources={resources} />
|
|
470
|
-
)}
|
|
470
|
+
{activeTab === 'dashboards' && <ShopifyDashboard />}
|
|
471
471
|
|
|
472
472
|
{activeTab === 'bookings' && (
|
|
473
473
|
<ShopifyDashboard_Bookings existingResources={resources} />
|
|
474
474
|
)}
|
|
475
475
|
|
|
476
|
-
{/* Local Management Tabs */}
|
|
477
476
|
{activeTab === 'products' && (
|
|
478
477
|
<ShopifyDashboard_Products
|
|
479
478
|
resources={resources}
|
|
@@ -492,12 +491,10 @@ export default function StoryKeepDashboard_Shopify({
|
|
|
492
491
|
/>
|
|
493
492
|
)}
|
|
494
493
|
|
|
495
|
-
{/* Schedule Tab */}
|
|
496
494
|
{activeTab === 'schedule' && (
|
|
497
495
|
<ShopifyDashboard_Schedule brandConfig={brandConfig} />
|
|
498
496
|
)}
|
|
499
497
|
|
|
500
|
-
{/* Catalog Discovery Tab */}
|
|
501
498
|
{activeTab === 'search' && (
|
|
502
499
|
<ShopifyDashboard_Search
|
|
503
500
|
linkedResourceMap={linkedResourceMap}
|
|
@@ -507,6 +504,8 @@ export default function StoryKeepDashboard_Shopify({
|
|
|
507
504
|
onEdit={handleEditFromCatalog}
|
|
508
505
|
/>
|
|
509
506
|
)}
|
|
507
|
+
|
|
508
|
+
{activeTab === 'emails' && <ShopifyDashboard_Emails />}
|
|
510
509
|
</div>
|
|
511
510
|
|
|
512
511
|
{/* Shared Modals */}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect } from 'react';
|
|
1
|
+
import { useState, useEffect, type KeyboardEvent } from 'react';
|
|
2
2
|
import { useStore } from '@nanostores/react';
|
|
3
3
|
import PlusIcon from '@heroicons/react/24/outline/PlusIcon';
|
|
4
4
|
import XMarkIcon from '@heroicons/react/24/outline/XMarkIcon';
|
|
@@ -125,7 +125,7 @@ export default function BeliefForm({
|
|
|
125
125
|
}
|
|
126
126
|
};
|
|
127
127
|
|
|
128
|
-
const handleKeyDown = (e:
|
|
128
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
129
129
|
if (e.key === 'Enter') {
|
|
130
130
|
e.preventDefault();
|
|
131
131
|
handleAddCustomValue();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useRef, useEffect } from 'react';
|
|
1
|
+
import { useState, useRef, useEffect, type ChangeEvent } from 'react';
|
|
2
2
|
import { useStore } from '@nanostores/react';
|
|
3
3
|
import ChevronRightIcon from '@heroicons/react/24/outline/ChevronRightIcon';
|
|
4
4
|
import ChevronLeftIcon from '@heroicons/react/24/outline/ChevronLeftIcon';
|
|
@@ -53,7 +53,7 @@ export default function ProductTable({
|
|
|
53
53
|
};
|
|
54
54
|
}, []);
|
|
55
55
|
|
|
56
|
-
const handleSearchChange = (e:
|
|
56
|
+
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
57
57
|
const val = e.target.value;
|
|
58
58
|
setInputValue(val);
|
|
59
59
|
setIsDebouncing(true);
|
|
@@ -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;
|
|
@@ -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
|
+
}
|