astro-tractstack 2.3.0 → 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/README.md +1 -1
- package/bin/create-tractstack.js +2 -2
- package/dist/index.js +130 -19
- package/package.json +2 -2
- package/templates/custom/minimal/CodeHook.astro +10 -2
- package/templates/custom/shopify/Cart.tsx +115 -77
- package/templates/custom/shopify/CheckoutModal.tsx +509 -120
- package/templates/custom/shopify/NativeBookingCalendar.tsx +375 -0
- package/templates/custom/shopify/ShopifyCartManager.tsx +91 -45
- package/templates/custom/shopify/ShopifyCheckout.tsx +4 -33
- package/templates/custom/shopify/ShopifyProductGrid.tsx +170 -176
- package/templates/custom/shopify/ShopifyServiceList.tsx +112 -51
- package/templates/custom/with-examples/CodeHook.astro +10 -2
- package/templates/src/components/Footer.astro +6 -6
- package/templates/src/components/Header.astro +23 -11
- 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_new.tsx +3 -3
- package/templates/src/components/edit/pane/AddPanePanel_newCustomCopy.tsx +2 -2
- package/templates/src/components/edit/pane/AiRestylePaneModal.tsx +2 -2
- package/templates/src/components/edit/pane/ConfigPanePanel.tsx +1 -1
- package/templates/src/components/edit/pane/steps/AiCreativeDesignStep.tsx +2 -2
- package/templates/src/components/edit/pane/steps/AiLibraryCopyStep.tsx +3 -3
- package/templates/src/components/edit/pane/steps/AiRefineDesignStep.tsx +2 -2
- package/templates/src/components/edit/pane/steps/AiStandardDesignStep.tsx +7 -7
- 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 +208 -2
- package/templates/src/components/form/brand/SiteConfigSection.tsx +10 -0
- package/templates/src/components/form/shopify/SchedulingSection.tsx +354 -0
- package/templates/src/components/storykeep/Dashboard.tsx +1 -1
- package/templates/src/components/storykeep/Dashboard_Shopify.tsx +252 -110
- package/templates/src/components/storykeep/controls/content/BeliefForm.tsx +2 -2
- package/templates/src/components/storykeep/controls/content/BeliefTable.tsx +14 -5
- package/templates/src/components/storykeep/controls/content/KnownResourceTable.tsx +5 -2
- package/templates/src/components/storykeep/controls/content/MenuTable.tsx +14 -5
- package/templates/src/components/storykeep/controls/content/ProductTable.tsx +180 -101
- package/templates/src/components/storykeep/controls/content/ResourceBulkIngest.tsx +88 -56
- package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +14 -4
- package/templates/src/components/storykeep/controls/content/StoryFragmentTable.tsx +14 -5
- 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 +104 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +419 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Emails.tsx +105 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Products.tsx +46 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Schedule.tsx +78 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Search.tsx +55 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Services.tsx +47 -0
- package/templates/src/layouts/Layout.astro +8 -5
- package/templates/src/pages/api/auth/lookup-lead.ts +72 -0
- package/templates/src/pages/api/booking/availability.ts +72 -0
- package/templates/src/pages/api/booking/cancel.ts +73 -0
- package/templates/src/pages/api/booking/confirm.ts +82 -0
- package/templates/src/pages/api/booking/hold.ts +75 -0
- package/templates/src/pages/api/booking/list.ts +66 -0
- package/templates/src/pages/api/booking/metrics.ts +60 -0
- package/templates/src/pages/api/booking/release.ts +76 -0
- package/templates/src/pages/api/sandbox.ts +2 -2
- package/templates/src/pages/api/shopify/createCart.ts +4 -8
- package/templates/src/pages/api/shopify/getProducts.ts +15 -15
- package/templates/src/pages/storykeep/login.astro +21 -14
- package/templates/src/stores/shopify.ts +97 -25
- package/templates/src/types/formTypes.ts +4 -2
- package/templates/src/types/tractstack.ts +59 -2
- package/templates/src/utils/api/advancedConfig.ts +2 -0
- package/templates/src/utils/api/advancedHelpers.ts +40 -3
- package/templates/src/utils/api/bookingHelpers.ts +125 -0
- package/templates/src/utils/api/brandConfig.ts +2 -0
- package/templates/src/utils/api/brandHelpers.ts +26 -0
- package/templates/src/utils/api/emailHelpers.ts +105 -0
- package/templates/src/utils/auth.ts +29 -9
- package/templates/src/utils/compositor/aiGeneration.ts +3 -3
- package/templates/src/utils/compositor/aiPaneParser.ts +2 -2
- package/templates/src/utils/customHelpers.ts +0 -21
- package/templates/src/utils/profileStorage.ts +5 -0
- package/templates/src/utils/tenantResolver.ts +3 -2
- package/utils/inject-files.ts +116 -5
- package/templates/custom/shopify/CalDotComBooking.tsx +0 -44
|
@@ -199,7 +199,7 @@ const StoryFragmentTable = ({
|
|
|
199
199
|
</div>
|
|
200
200
|
|
|
201
201
|
{/* Table Container */}
|
|
202
|
-
<div className="overflow-
|
|
202
|
+
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white shadow">
|
|
203
203
|
{filteredFragments.length === 0 ? (
|
|
204
204
|
<div className="px-6 py-12 text-center">
|
|
205
205
|
<svg
|
|
@@ -241,7 +241,7 @@ const StoryFragmentTable = ({
|
|
|
241
241
|
)}
|
|
242
242
|
</div>
|
|
243
243
|
) : (
|
|
244
|
-
<div className="
|
|
244
|
+
<div className="inline-block min-w-full align-middle">
|
|
245
245
|
<table className="min-w-full divide-y divide-gray-200">
|
|
246
246
|
<thead className="bg-gray-50">
|
|
247
247
|
<tr>
|
|
@@ -271,15 +271,24 @@ const StoryFragmentTable = ({
|
|
|
271
271
|
<tr key={item.id} className="hover:bg-gray-50">
|
|
272
272
|
<td className="px-3 py-4 md:px-6">
|
|
273
273
|
<div className="flex flex-col">
|
|
274
|
-
<div
|
|
274
|
+
<div
|
|
275
|
+
className="max-w-xs truncate text-sm font-bold text-gray-900"
|
|
276
|
+
title={item.title}
|
|
277
|
+
>
|
|
275
278
|
{item.title}
|
|
276
279
|
</div>
|
|
277
|
-
<div
|
|
280
|
+
<div
|
|
281
|
+
className="max-w-xs truncate text-sm text-gray-500 md:hidden"
|
|
282
|
+
title={`/${item.slug}`}
|
|
283
|
+
>
|
|
278
284
|
/{item.slug}
|
|
279
285
|
</div>
|
|
280
286
|
</div>
|
|
281
287
|
</td>
|
|
282
|
-
<td
|
|
288
|
+
<td
|
|
289
|
+
className="hidden max-w-xs truncate whitespace-nowrap px-3 py-4 text-sm text-gray-500 md:table-cell md:px-6"
|
|
290
|
+
title={`/${item.slug}`}
|
|
291
|
+
>
|
|
283
292
|
/{item.slug}
|
|
284
293
|
</td>
|
|
285
294
|
<td className="hidden whitespace-nowrap px-3 py-4 text-sm md:table-cell md:px-6">
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { emailHelpers, type EmailTemplate } from '@/utils/api/emailHelpers';
|
|
3
|
+
|
|
4
|
+
interface PreviewModalProps {
|
|
5
|
+
template: EmailTemplate;
|
|
6
|
+
onClose: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default function PreviewModal({ template, onClose }: PreviewModalProps) {
|
|
10
|
+
const [variables, setVariables] = useState<string[]>([]);
|
|
11
|
+
const [mockData, setMockData] = useState<Record<string, string>>({});
|
|
12
|
+
const [html, setHtml] = useState<string>('');
|
|
13
|
+
const [subject, setSubject] = useState<string>('');
|
|
14
|
+
const [error, setError] = useState<string | null>(null);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const rawString = JSON.stringify(template);
|
|
18
|
+
const regex = /\{\{\.([a-zA-Z0-9_]+)\}\}/g;
|
|
19
|
+
const found = new Set<string>();
|
|
20
|
+
|
|
21
|
+
let match;
|
|
22
|
+
while ((match = regex.exec(rawString)) !== null) {
|
|
23
|
+
found.add(match[1]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
setVariables(Array.from(found));
|
|
27
|
+
|
|
28
|
+
const initialData: Record<string, string> = {};
|
|
29
|
+
found.forEach((v) => {
|
|
30
|
+
initialData[v] = `[${v}]`;
|
|
31
|
+
});
|
|
32
|
+
setMockData(initialData);
|
|
33
|
+
}, [template]);
|
|
34
|
+
|
|
35
|
+
const handleGenerate = async () => {
|
|
36
|
+
try {
|
|
37
|
+
setError(null);
|
|
38
|
+
const res = await emailHelpers.previewTemplate(template, mockData);
|
|
39
|
+
setHtml(res.html);
|
|
40
|
+
setSubject(res.subject);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
setError(
|
|
43
|
+
err instanceof Error ? err.message : 'Preview generation failed'
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900 bg-opacity-75 p-4 backdrop-blur-sm">
|
|
50
|
+
<div
|
|
51
|
+
className="flex h-full w-full max-w-6xl flex-col overflow-hidden rounded-lg bg-white shadow-xl md:flex-row"
|
|
52
|
+
style={{ maxHeight: '90vh' }}
|
|
53
|
+
>
|
|
54
|
+
<div className="w-full border-r border-gray-200 bg-gray-50 p-6 md:w-80">
|
|
55
|
+
<div className="mb-6 flex items-center justify-between">
|
|
56
|
+
<h3 className="text-lg font-bold text-gray-900">Mock Data</h3>
|
|
57
|
+
<button
|
|
58
|
+
onClick={onClose}
|
|
59
|
+
className="text-gray-400 hover:text-gray-900 md:hidden"
|
|
60
|
+
>
|
|
61
|
+
×
|
|
62
|
+
</button>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
{variables.length === 0 ? (
|
|
66
|
+
<p className="text-sm text-gray-500">
|
|
67
|
+
No template variables found.
|
|
68
|
+
</p>
|
|
69
|
+
) : (
|
|
70
|
+
<div className="space-y-4">
|
|
71
|
+
{variables.map((v) => (
|
|
72
|
+
<div key={v}>
|
|
73
|
+
<label className="mb-1 block text-xs font-bold text-gray-700">
|
|
74
|
+
{v}
|
|
75
|
+
</label>
|
|
76
|
+
<input
|
|
77
|
+
type="text"
|
|
78
|
+
value={mockData[v] || ''}
|
|
79
|
+
onChange={(e) =>
|
|
80
|
+
setMockData({ ...mockData, [v]: e.target.value })
|
|
81
|
+
}
|
|
82
|
+
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-cyan-500 focus:ring-cyan-500"
|
|
83
|
+
/>
|
|
84
|
+
</div>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
|
|
89
|
+
<button
|
|
90
|
+
onClick={handleGenerate}
|
|
91
|
+
className="mt-8 w-full rounded-md bg-cyan-600 px-4 py-2 text-sm font-bold text-white hover:bg-cyan-500"
|
|
92
|
+
>
|
|
93
|
+
Generate Preview
|
|
94
|
+
</button>
|
|
95
|
+
|
|
96
|
+
{error && (
|
|
97
|
+
<p className="mt-4 text-xs font-bold text-red-600">{error}</p>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<div className="flex flex-1 flex-col overflow-hidden bg-white">
|
|
102
|
+
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
|
103
|
+
<div className="flex flex-col">
|
|
104
|
+
<span className="text-xs font-bold text-gray-500">Subject</span>
|
|
105
|
+
<span className="text-sm font-bold text-gray-900">
|
|
106
|
+
{subject || 'Generate preview to view subject...'}
|
|
107
|
+
</span>
|
|
108
|
+
</div>
|
|
109
|
+
<button
|
|
110
|
+
onClick={onClose}
|
|
111
|
+
className="hidden text-gray-400 hover:text-gray-900 md:block"
|
|
112
|
+
>
|
|
113
|
+
× Close
|
|
114
|
+
</button>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div className="flex-1 bg-gray-100 p-8">
|
|
118
|
+
<div className="mx-auto h-full w-full max-w-2xl overflow-hidden rounded bg-white shadow-lg">
|
|
119
|
+
{html ? (
|
|
120
|
+
<iframe
|
|
121
|
+
srcDoc={html}
|
|
122
|
+
className="h-full w-full border-0"
|
|
123
|
+
title="Email Preview"
|
|
124
|
+
/>
|
|
125
|
+
) : (
|
|
126
|
+
<div className="flex h-full items-center justify-center text-sm font-bold text-gray-400">
|
|
127
|
+
Awaiting preview generation...
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|