astro-tractstack 2.3.3 → 2.3.5

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 (49) hide show
  1. package/bin/create-tractstack.js +5 -2
  2. package/dist/index.js +32 -4
  3. package/package.json +1 -1
  4. package/templates/custom/customHelpers.ts +45 -0
  5. package/templates/custom/shopify/Cart.tsx +197 -105
  6. package/templates/custom/shopify/CartIcon.tsx +8 -8
  7. package/templates/custom/shopify/CheckoutModal.tsx +145 -68
  8. package/templates/custom/shopify/ShopifyCartManager.tsx +67 -22
  9. package/templates/custom/shopify/ShopifyCheckout.tsx +2 -26
  10. package/templates/custom/shopify/ShopifyProductGrid.tsx +8 -0
  11. package/templates/custom/shopify/ShopifyServiceList.tsx +25 -37
  12. package/templates/custom/shopify/shopifyCustomHelper.ts +10 -0
  13. package/templates/custom/shopify/shopifyHelpers.ts +298 -0
  14. package/templates/src/components/Header.astro +2 -2
  15. package/templates/src/components/codehooks/SearchWidget.tsx +1 -1
  16. package/templates/src/components/compositor/Node.tsx +39 -9
  17. package/templates/src/components/compositor/nodes/CreativePane.tsx +175 -88
  18. package/templates/src/components/edit/pane/AddPanePanel.tsx +2 -2
  19. package/templates/src/components/edit/pane/AddPanePanel_new.tsx +6 -5
  20. package/templates/src/components/edit/pane/AddPanePanel_reuse.tsx +9 -15
  21. package/templates/src/components/edit/pane/ConfigPanePanel.tsx +1 -1
  22. package/templates/src/components/form/advanced/APIConfigSection.tsx +35 -8
  23. package/templates/src/components/form/brand/SiteConfigSection.tsx +9 -0
  24. package/templates/src/components/search/SearchResults.tsx +1 -1
  25. package/templates/src/components/search/SearchWrapper.tsx +1 -1
  26. package/templates/src/components/storykeep/Dashboard_Advanced.tsx +59 -23
  27. package/templates/src/components/storykeep/Dashboard_Shopify.tsx +257 -18
  28. package/templates/src/components/storykeep/controls/content/ProductTable.tsx +38 -24
  29. package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +162 -67
  30. package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +175 -48
  31. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +5 -2
  32. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Sales.tsx +479 -0
  33. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Search.tsx +7 -3
  34. package/templates/src/layouts/Layout.astro +26 -0
  35. package/templates/src/pages/api/auth/logout.ts +35 -2
  36. package/templates/src/pages/api/sales/list.ts +66 -0
  37. package/templates/src/pages/api/sales/metrics.ts +60 -0
  38. package/templates/src/pages/context/[...contextSlug].astro +50 -31
  39. package/templates/src/pages/storykeep/advanced.astro +4 -1
  40. package/templates/src/stores/nodes.ts +8 -0
  41. package/templates/src/types/tractstack.ts +57 -0
  42. package/templates/src/utils/api/advancedConfig.ts +2 -1
  43. package/templates/src/utils/api/advancedHelpers.ts +4 -0
  44. package/templates/src/utils/api/brandConfig.ts +2 -0
  45. package/templates/src/utils/api/brandHelpers.ts +6 -0
  46. package/templates/src/utils/api/salesHelpers.ts +21 -0
  47. package/utils/inject-files.ts +32 -4
  48. package/templates/src/utils/customHelpers.ts +0 -89
  49. /package/templates/{src/utils/booking → custom/shopify}/appointmentMode.ts +0 -0
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  useEffect,
3
+ useLayoutEffect,
3
4
  useState,
4
5
  useRef,
5
6
  type FocusEvent,
@@ -23,16 +24,147 @@ const viewportMap = {
23
24
  desktop: 'xl',
24
25
  } as const;
25
26
 
27
+ function removeEditProxies(container: HTMLElement) {
28
+ container.querySelectorAll('[data-proxy-positioned]').forEach((el) => {
29
+ const htmlEl = el as HTMLElement;
30
+ const prev = htmlEl.getAttribute('data-proxy-prev-position');
31
+ htmlEl.style.position = prev ?? '';
32
+ htmlEl.removeAttribute('data-proxy-positioned');
33
+ htmlEl.removeAttribute('data-proxy-prev-position');
34
+ });
35
+
36
+ container
37
+ .querySelectorAll('[data-proxy-for]')
38
+ .forEach((icon) => icon.remove());
39
+
40
+ container.querySelectorAll('[data-ast-id]').forEach((el) => {
41
+ const htmlEl = el as HTMLElement;
42
+ htmlEl.style.outline = '';
43
+ htmlEl.style.outlineOffset = '';
44
+ htmlEl.style.cursor = '';
45
+ });
46
+ }
47
+
48
+ function ensurePositionedAnchor(anchorEl: HTMLElement) {
49
+ if (getComputedStyle(anchorEl).position !== 'static') return;
50
+
51
+ anchorEl.setAttribute('data-proxy-positioned', 'true');
52
+ anchorEl.setAttribute('data-proxy-prev-position', anchorEl.style.position);
53
+ anchorEl.style.position = 'relative';
54
+ }
55
+
56
+ function createProxyIcon(
57
+ htmlEl: HTMLElement,
58
+ astId: string,
59
+ htmlAst: CreativePanePayload,
60
+ paneNodeId: string
61
+ ) {
62
+ const icon = document.createElement('div');
63
+ icon.setAttribute('data-proxy-for', astId);
64
+ icon.className = 'compositor-chrome';
65
+ icon.style.position = 'absolute';
66
+ icon.style.zIndex = '1003';
67
+ icon.style.width = '24px';
68
+ icon.style.height = '24px';
69
+ icon.style.backgroundColor = '#06b6d4';
70
+ icon.style.borderRadius = '9999px';
71
+ icon.style.display = 'flex';
72
+ icon.style.alignItems = 'center';
73
+ icon.style.justifyContent = 'center';
74
+ icon.style.color = 'white';
75
+ icon.style.fontSize = '12px';
76
+ icon.style.boxShadow = '0 10px 15px -3px rgb(0 0 0 / 0.1)';
77
+ icon.style.cursor = 'pointer';
78
+ icon.style.pointerEvents = 'auto';
79
+ icon.innerHTML = '✎';
80
+
81
+ icon.onmouseenter = () => {
82
+ htmlEl.style.outline = '3px solid #06b6d4';
83
+ };
84
+ icon.onmouseleave = () => {
85
+ htmlEl.style.outline = '2px dotted #06b6d4';
86
+ };
87
+ icon.onclick = (e) => {
88
+ e.stopPropagation();
89
+ const meta = htmlAst.editableElements?.[astId];
90
+ if (!meta) return;
91
+
92
+ let action = '';
93
+ if (meta.isCssBackground) {
94
+ action = 'style-creative-bg';
95
+ } else if (meta.tagName === 'img') {
96
+ action = 'style-creative-img';
97
+ } else if (meta.tagName === 'a') {
98
+ action = 'style-creative-link';
99
+ } else if (meta.tagName === 'button') {
100
+ action = 'style-creative-btn';
101
+ }
102
+
103
+ if (action) {
104
+ settingsPanelStore.set({
105
+ action,
106
+ nodeId: paneNodeId,
107
+ childId: astId,
108
+ expanded: true,
109
+ });
110
+ }
111
+ };
112
+
113
+ return icon;
114
+ }
115
+
116
+ function syncEditProxies(
117
+ container: HTMLElement,
118
+ htmlAst: CreativePanePayload,
119
+ paneNodeId: string
120
+ ) {
121
+ removeEditProxies(container);
122
+
123
+ container.querySelectorAll('[data-ast-id]').forEach((el) => {
124
+ const htmlEl = el as HTMLElement;
125
+ if (htmlEl.isContentEditable) return;
126
+
127
+ const astId = htmlEl.getAttribute('data-ast-id');
128
+ if (!astId) return;
129
+
130
+ htmlEl.style.outline = '2px dotted #06b6d4';
131
+ htmlEl.style.outlineOffset = '2px';
132
+
133
+ const icon = createProxyIcon(htmlEl, astId, htmlAst, paneNodeId);
134
+
135
+ if (htmlEl.tagName === 'IMG') {
136
+ const parent = htmlEl.parentElement;
137
+ if (parent instanceof HTMLElement) {
138
+ ensurePositionedAnchor(parent);
139
+ icon.style.top = `${htmlEl.offsetTop - 12}px`;
140
+ icon.style.left = `${htmlEl.offsetLeft - 12}px`;
141
+ parent.appendChild(icon);
142
+ return;
143
+ }
144
+ }
145
+
146
+ ensurePositionedAnchor(htmlEl);
147
+ icon.style.top = '-12px';
148
+ icon.style.left = '-12px';
149
+ htmlEl.appendChild(icon);
150
+ });
151
+ }
152
+
26
153
  export const CreativePane = ({
27
154
  nodeId,
28
155
  htmlAst,
29
156
  isProtected = false,
30
157
  }: CreativePaneProps) => {
158
+ const ctx = getCtx();
31
159
  const previews = useStore(renderedPreviews);
32
160
  const { value: viewportKey } = useStore(viewportKeyStore);
161
+ const { value: toolModeVal } = useStore(ctx.toolModeValStore);
33
162
  const [loading, setLoading] = useState(false);
34
163
  const [error, setError] = useState<string | null>(null);
35
164
  const contentRef = useRef<HTMLDivElement>(null);
165
+ const previewRef = useRef<HTMLDivElement>(null);
166
+ const htmlAstRef = useRef(htmlAst);
167
+ htmlAstRef.current = htmlAst;
36
168
 
37
169
  const activeViewport = viewportMap[viewportKey];
38
170
  const htmlContent = previews[nodeId];
@@ -101,100 +233,56 @@ export const CreativePane = ({
101
233
  };
102
234
  }, [htmlAst?.css, htmlAst?.tree, nodeId]);
103
235
 
104
- useEffect(() => {
105
- const ctx = getCtx();
106
- const unsubscribe = ctx.toolModeValStore.subscribe((state) => {
107
- const container = contentRef.current;
108
- if (!container) return;
236
+ useLayoutEffect(() => {
237
+ const el = previewRef.current;
238
+ if (!el || htmlContent == null) return;
239
+ if (el.innerHTML !== htmlContent) {
240
+ el.innerHTML = htmlContent;
241
+ }
242
+ }, [htmlContent]);
109
243
 
110
- const editables = container.querySelectorAll('[data-ast-id]');
244
+ useLayoutEffect(() => {
245
+ const container = contentRef.current;
246
+ if (!container || !htmlContent) return;
111
247
 
112
- if (state.value === 'text') {
113
- editables.forEach((el) => {
114
- const htmlEl = el as HTMLElement;
115
- if (htmlEl.isContentEditable) return;
116
- htmlEl.style.outline = '2px dotted #06b6d4';
117
- htmlEl.style.outlineOffset = '2px';
248
+ if (toolModeVal === 'text') {
249
+ syncEditProxies(container, htmlAstRef.current, nodeId);
250
+ } else {
251
+ removeEditProxies(container);
252
+ }
253
+ });
118
254
 
119
- const astId = htmlEl.getAttribute('data-ast-id');
120
- if (!astId) return;
255
+ useEffect(() => {
256
+ const container = contentRef.current;
257
+ if (!container || !htmlContent) return;
121
258
 
122
- const existingIcon = container.querySelector(
123
- `[data-proxy-for="${astId}"]`
124
- );
125
- if (existingIcon) return;
126
-
127
- const icon = document.createElement('div');
128
- icon.setAttribute('data-proxy-for', astId);
129
- icon.style.position = 'absolute';
130
- icon.style.zIndex = '1003';
131
- icon.style.width = '24px';
132
- icon.style.height = '24px';
133
- icon.style.backgroundColor = '#06b6d4';
134
- icon.style.borderRadius = '9999px';
135
- icon.style.display = 'flex';
136
- icon.style.alignItems = 'center';
137
- icon.style.justifyContent = 'center';
138
- icon.style.color = 'white';
139
- icon.style.fontSize = '12px';
140
- icon.style.boxShadow = '0 10px 15px -3px rgb(0 0 0 / 0.1)';
141
- icon.style.cursor = 'pointer';
142
- icon.innerHTML = '✎';
143
-
144
- const rect = htmlEl.getBoundingClientRect();
145
- const containerRect = container.getBoundingClientRect();
146
- icon.style.top = `${rect.top - containerRect.top - 12}px`;
147
- icon.style.left = `${rect.left - containerRect.left - 12}px`;
148
-
149
- icon.onmouseenter = () => {
150
- htmlEl.style.outline = '3px solid #06b6d4';
151
- };
152
- icon.onmouseleave = () => {
153
- htmlEl.style.outline = '2px dotted #06b6d4';
154
- };
155
- icon.onclick = () => {
156
- const meta = htmlAst.editableElements?.[astId];
157
- if (meta) {
158
- let action = '';
159
- if (meta.isCssBackground) {
160
- action = 'style-creative-bg';
161
- } else if (meta.tagName === 'img') {
162
- action = 'style-creative-img';
163
- } else if (meta.tagName === 'a') {
164
- action = 'style-creative-link';
165
- } else if (meta.tagName === 'button') {
166
- action = 'style-creative-btn';
167
- }
168
-
169
- if (action) {
170
- settingsPanelStore.set({
171
- action,
172
- nodeId,
173
- childId: astId,
174
- expanded: true,
175
- });
176
- }
177
- }
178
- };
179
-
180
- container.appendChild(icon);
181
- });
182
- } else {
183
- editables.forEach((el) => {
184
- (el as HTMLElement).style.outline = '';
185
- (el as HTMLElement).style.outlineOffset = '';
186
- (el as HTMLElement).style.cursor = '';
187
- });
188
- const icons = container.querySelectorAll('[data-proxy-for]');
189
- icons.forEach((icon) => icon.remove());
190
- }
259
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
260
+
261
+ const resizeObserver = new ResizeObserver(() => {
262
+ if (ctx.toolModeValStore.get().value !== 'text') return;
263
+ if (debounceTimer) clearTimeout(debounceTimer);
264
+ debounceTimer = setTimeout(() => {
265
+ if (ctx.toolModeValStore.get().value === 'text') {
266
+ syncEditProxies(container, htmlAstRef.current, nodeId);
267
+ }
268
+ }, 100);
191
269
  });
270
+ resizeObserver.observe(container);
192
271
 
193
- return () => unsubscribe();
194
- }, [htmlContent]);
272
+ return () => {
273
+ resizeObserver.disconnect();
274
+ if (debounceTimer) clearTimeout(debounceTimer);
275
+ };
276
+ }, [htmlContent, nodeId, toolModeVal, ctx]);
277
+
278
+ useEffect(() => {
279
+ return () => {
280
+ const container = contentRef.current;
281
+ if (container) removeEditProxies(container);
282
+ };
283
+ }, []);
195
284
 
196
285
  const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
197
- const ctx = getCtx();
198
286
  const mode = ctx.toolModeValStore.get().value;
199
287
  if (mode !== 'text' || isProtected) return;
200
288
 
@@ -208,7 +296,6 @@ export const CreativePane = ({
208
296
  };
209
297
 
210
298
  const handleBlur = (e: FocusEvent<HTMLDivElement>) => {
211
- const ctx = getCtx();
212
299
  const mode = ctx.toolModeValStore.get().value;
213
300
  if (mode !== 'text' || isProtected) return;
214
301
 
@@ -255,7 +342,7 @@ export const CreativePane = ({
255
342
  {isProtected && (
256
343
  <div className="absolute inset-0 z-50 cursor-crosshair bg-transparent" />
257
344
  )}
258
- <div dangerouslySetInnerHTML={{ __html: htmlContent }} />
345
+ <div ref={previewRef} />
259
346
  </div>
260
347
  </>
261
348
  );
@@ -94,7 +94,7 @@ const AddPanePanel = ({
94
94
  />
95
95
  ) : mode === PaneAddMode.REUSE && !isContextPane ? (
96
96
  <AddPaneReUsePanel nodeId={nodeId} first={first} setMode={setMode} />
97
- ) : mode === PaneAddMode.CODEHOOK ? (
97
+ ) : mode === PaneAddMode.CODEHOOK && !isContextPane ? (
98
98
  <AddPaneCodeHookPanel
99
99
  nodeId={nodeId}
100
100
  first={first}
@@ -139,7 +139,7 @@ const AddPanePanel = ({
139
139
  )}
140
140
  </>
141
141
  )}
142
- {!isTemplate && (
142
+ {!isTemplate && !isContextPane && (
143
143
  <button
144
144
  onClick={() => setMode(PaneAddMode.CODEHOOK)}
145
145
  className="rounded bg-white px-2 py-1 text-sm text-cyan-700 shadow-sm transition-colors hover:bg-cyan-700 hover:text-white"
@@ -294,13 +294,14 @@ const AddPaneNewPanel = ({
294
294
 
295
295
  <div className="mx-auto mb-6 max-w-xl text-center">
296
296
  <h3 className="text-lg font-bold text-gray-800">
297
- Don't publish a page. Stack your story.
297
+ {isContextPane
298
+ ? 'One focused page for in-context links.'
299
+ : "Don't publish a page. Stack your story."}
298
300
  </h3>
299
301
  <p className="text-sm text-gray-500">
300
- A static page is a wall of text. A Smart Tract is a series of beats.
301
- Build one Pane at a time and give each one a single job. Every Pane
302
- acts as a listening device—telling you exactly when your audience
303
- leans in, and when they drift away.
302
+ {isContextPane
303
+ ? 'Readers open this URL from links on your Smart Tract—definitions, offers, FAQs, or anything that needs its own address without breaking the flow. Design this single pane for that job; they close the page and return to where they came from.'
304
+ : 'A static page is a wall of text. A Smart Tract is a series of beats. Build one Pane at a time and give each one a single job. Every Pane acts as a listening device—telling you exactly when your audience leans in, and when they drift away.'}
304
305
  </p>
305
306
  </div>
306
307
 
@@ -174,24 +174,13 @@ const AddPaneReUsePanel = ({
174
174
 
175
175
  templateData.paneNode.parentId = storyfragmentId;
176
176
 
177
- let specificIdx = -1;
178
- let elIdx = -1;
179
177
  const location = first ? 'before' : 'after';
180
-
181
- specificIdx = storyFragmentNode.paneIds.indexOf(nodeId);
182
- elIdx = specificIdx;
183
- if (elIdx === -1) {
184
- storyFragmentNode.paneIds.push(templateData.paneNode.id);
185
- } else {
178
+ const elIdx = storyFragmentNode.paneIds.indexOf(nodeId);
179
+ let specificIdx = elIdx;
180
+ if (elIdx !== -1) {
186
181
  if (location === 'before') {
187
- storyFragmentNode.paneIds.splice(elIdx, 0, templateData.paneNode.id);
188
182
  specificIdx = Math.max(0, specificIdx - 1);
189
183
  } else {
190
- storyFragmentNode.paneIds.splice(
191
- elIdx + 1,
192
- 0,
193
- templateData.paneNode.id
194
- );
195
184
  specificIdx = Math.min(
196
185
  specificIdx + 1,
197
186
  storyFragmentNode.paneIds.length
@@ -206,7 +195,12 @@ const AddPaneReUsePanel = ({
206
195
  specificIdx
207
196
  );
208
197
  ctx.addNodes(templateData.childNodes);
209
- ctx.notifyNode(storyfragmentId);
198
+ ctx.insertPaneId(
199
+ storyfragmentId,
200
+ templateData.paneNode.id,
201
+ nodeId,
202
+ location
203
+ );
210
204
 
211
205
  setMode(PaneAddMode.DEFAULT);
212
206
  } catch (error) {
@@ -260,7 +260,7 @@ const ConfigPanePanel = ({
260
260
  )}
261
261
  </>
262
262
  )}
263
- {isCodeHook && (
263
+ {isCodeHook && !isContextPane && (
264
264
  <button
265
265
  onClick={handleCodeHookConfig}
266
266
  className={buttonClass}
@@ -19,7 +19,10 @@ const GOOGLE_TERMS_URL = 'https://renees.freewebpress.com/terms';
19
19
  const GOOGLE_AUTHORIZED_DOMAIN = 'freewebpress.com';
20
20
  const GOOGLE_REDIRECT_URI =
21
21
  'https://renees.freewebpress.com/api/google/oauth/callback';
22
- const GOOGLE_SCOPE = 'https://www.googleapis.com/auth/calendar.events';
22
+ const GOOGLE_SCOPES = [
23
+ 'https://www.googleapis.com/auth/calendar.events',
24
+ 'https://www.googleapis.com/auth/calendar.readonly',
25
+ ] as const;
23
26
  const GOOGLE_VERIFICATION_VIDEO_URL =
24
27
  'https://www.youtube.com/watch?v=kJI4XdqiiAI';
25
28
 
@@ -43,7 +46,8 @@ export default function APIConfigSection({
43
46
  const shopifyVersionConfigured = Boolean(status?.shopifyApiVersion);
44
47
  const shopifyAdminSlugConfigured = status?.shopifyAdminSlugSet;
45
48
  const shopifyWebhooksConfigured = status?.userSetupWebhooks;
46
- const resendConfigured = status?.resendApiKeySet;
49
+ const resendConfigured = status?.hasResend;
50
+ const resendKeyConfigured = status?.resendApiKeySet;
47
51
  const googleHasSync = status?.hasGoogleSync;
48
52
  const googleClientIDConfigured = status?.googleOauthClientIdSet;
49
53
  const googleClientSecretConfigured = status?.googleOauthClientSecretSet;
@@ -298,13 +302,30 @@ export default function APIConfigSection({
298
302
  value={state.resendApiKey}
299
303
  onChange={(value) => updateField('resendApiKey', value)}
300
304
  type="password"
301
- placeholder={resendConfigured ? '••••••••••••••••' : 're_...'}
305
+ placeholder={resendKeyConfigured ? '••••••••••••••••' : 're_...'}
302
306
  error={errors.resendApiKey}
303
307
  />
304
308
  <p className="mt-2 text-xs text-gray-500">
305
309
  Required for sending system emails.
306
- {resendConfigured && ' Leave blank to keep existing key.'}
310
+ {resendKeyConfigured && ' Leave blank to keep existing key.'}
307
311
  </p>
312
+ <StringInput
313
+ label="From Email"
314
+ value={state.adminEmail}
315
+ onChange={(value) => updateField('adminEmail', value)}
316
+ type="email"
317
+ placeholder="admin@example.com"
318
+ required={true}
319
+ error={errors.adminEmail}
320
+ />
321
+ <StringInput
322
+ label="From Name"
323
+ value={state.adminEmailName}
324
+ onChange={(value) => updateField('adminEmailName', value)}
325
+ placeholder="Your Site Name"
326
+ required={true}
327
+ error={errors.adminEmailName}
328
+ />
308
329
  </div>
309
330
 
310
331
  {/* Google Calendar / Meet Section */}
@@ -371,10 +392,16 @@ export default function APIConfigSection({
371
392
  </ul>
372
393
  </li>
373
394
  <li>
374
- Open <strong>Data Access</strong> and add scope:
375
- <code className="ml-1 rounded bg-gray-100 px-1 py-0.5">
376
- {GOOGLE_SCOPE}
377
- </code>
395
+ Open <strong>Data Access</strong> and add scopes:
396
+ <ul className="mt-1 list-disc space-y-1 pl-4">
397
+ {GOOGLE_SCOPES.map((scope) => (
398
+ <li key={scope}>
399
+ <code className="rounded bg-gray-100 px-1 py-0.5">
400
+ {scope}
401
+ </code>
402
+ </li>
403
+ ))}
404
+ </ul>
378
405
  </li>
379
406
  <li>
380
407
  Open <strong>Branding</strong> and set:
@@ -58,6 +58,15 @@ export default function SiteConfigSection({
58
58
  error={errors.adminEmail}
59
59
  />
60
60
 
61
+ <StringInput
62
+ value={state.adminEmailName}
63
+ onChange={(value) => updateField('adminEmailName', value)}
64
+ label="Admin Email Name"
65
+ placeholder="Your Site Name"
66
+ required={true}
67
+ error={errors.adminEmailName}
68
+ />
69
+
61
70
  <StringInput
62
71
  value={state.gtag}
63
72
  onChange={(value) => updateField('gtag', value)}
@@ -8,7 +8,7 @@ import {
8
8
  getResourceUrl,
9
9
  getResourceImage,
10
10
  getResourceDescription,
11
- } from '@/utils/customHelpers';
11
+ } from '@/custom/customHelpers';
12
12
 
13
13
  const VERBOSE = false;
14
14
 
@@ -1,6 +1,6 @@
1
1
  import { useState, useEffect } from 'react';
2
2
  import MagnifyingGlassIcon from '@heroicons/react/24/outline/MagnifyingGlassIcon';
3
- import { initSearch } from '@/utils/customHelpers';
3
+ import { initSearch } from '@/custom/customHelpers';
4
4
  import SearchModal from './SearchModal';
5
5
  import type { FullContentMapItem } from '@/types/tractstack';
6
6
 
@@ -1,28 +1,37 @@
1
- import { useState, useEffect, useRef } from 'react';
1
+ import { useState, useEffect, useRef, useCallback } from 'react';
2
2
  import { useFormState } from '@/hooks/useFormState';
3
3
  import {
4
- convertToLocalState,
4
+ convertToLocalState as convertAdvancedToLocalState,
5
5
  convertToBackendFormat,
6
6
  validateAdvancedConfig,
7
7
  advancedStateIntercept,
8
8
  } from '@/utils/api/advancedHelpers';
9
+ import {
10
+ convertToLocalState as convertBrandToLocalState,
11
+ convertToBackendFormat as convertBrandToBackendFormat,
12
+ validateBrandConfig,
13
+ } from '@/utils/api/brandHelpers';
9
14
  import {
10
15
  getAdvancedConfigStatus,
11
16
  saveAdvancedConfig,
12
17
  } from '@/utils/api/advancedConfig';
18
+ import { getBrandConfig, saveBrandConfig } from '@/utils/api/brandConfig';
13
19
  import UnsavedChangesBar from '@/components/form/UnsavedChangesBar';
14
20
  import AuthConfigSection from '@/components/form/advanced/AuthConfigSection';
15
21
  import APIConfigSection from '@/components/form/advanced/APIConfigSection';
16
22
  import type {
17
23
  AdvancedConfigState,
18
24
  AdvancedConfigStatus,
25
+ BrandConfig,
19
26
  } from '@/types/tractstack';
20
27
 
21
28
  interface StoryKeepDashboardAdvancedProps {
29
+ brandConfig: BrandConfig;
22
30
  initialize?: boolean;
23
31
  }
24
32
 
25
33
  export default function StoryKeepDashboard_Advanced({
34
+ brandConfig,
26
35
  initialize = false,
27
36
  }: StoryKeepDashboardAdvancedProps) {
28
37
  const [status, setStatus] = useState<AdvancedConfigStatus | null>(null);
@@ -30,14 +39,33 @@ export default function StoryKeepDashboard_Advanced({
30
39
  const [error, setError] = useState<string>('');
31
40
  const hasHydratedInitialFormState = useRef(false);
32
41
 
33
- // Load status on mount
42
+ const tenantId = window.TRACTSTACK_CONFIG?.tenantId || 'default';
43
+
44
+ const validateAdvancedForm = useCallback(
45
+ (state: AdvancedConfigState) => {
46
+ const advancedErrors = validateAdvancedConfig(state);
47
+ const brandLocal = {
48
+ ...convertBrandToLocalState(brandConfig),
49
+ adminEmail: state.adminEmail,
50
+ adminEmailName: state.adminEmailName,
51
+ };
52
+ const brandErrors = validateBrandConfig(brandLocal);
53
+ return {
54
+ ...advancedErrors,
55
+ ...(brandErrors.adminEmail && { adminEmail: brandErrors.adminEmail }),
56
+ ...(brandErrors.adminEmailName && {
57
+ adminEmailName: brandErrors.adminEmailName,
58
+ }),
59
+ };
60
+ },
61
+ [brandConfig]
62
+ );
63
+
34
64
  useEffect(() => {
35
65
  async function loadStatus() {
36
66
  try {
37
67
  setIsLoading(true);
38
- const statusData = await getAdvancedConfigStatus(
39
- window.TRACTSTACK_CONFIG?.tenantId || 'default'
40
- );
68
+ const statusData = await getAdvancedConfigStatus(tenantId);
41
69
  setStatus(statusData);
42
70
  } catch (err) {
43
71
  setError(
@@ -50,27 +78,31 @@ export default function StoryKeepDashboard_Advanced({
50
78
  }
51
79
  }
52
80
  loadStatus();
53
- }, []);
81
+ }, [tenantId]);
54
82
 
55
83
  const formState = useFormState<AdvancedConfigState>({
56
- initialData: convertToLocalState(status),
57
- validator: validateAdvancedConfig,
84
+ initialData: convertAdvancedToLocalState(status),
85
+ validator: validateAdvancedForm,
58
86
  interceptor: advancedStateIntercept,
59
87
  onSave: async (state: AdvancedConfigState) => {
60
88
  const backendPayload = convertToBackendFormat(state);
61
- await saveAdvancedConfig(
62
- window.TRACTSTACK_CONFIG?.tenantId || 'default',
63
- backendPayload
64
- );
65
-
66
- // Reload status after save
67
- const newStatus = await getAdvancedConfigStatus(
68
- window.TRACTSTACK_CONFIG?.tenantId || 'default'
69
- );
89
+ await saveAdvancedConfig(tenantId, backendPayload);
90
+
91
+ const brandConfigFresh = await getBrandConfig(tenantId);
92
+ const brandLocal = convertBrandToLocalState(brandConfigFresh);
93
+ brandLocal.adminEmail = state.adminEmail.trim();
94
+ brandLocal.adminEmailName = state.adminEmailName.trim();
95
+ await saveBrandConfig(tenantId, convertBrandToBackendFormat(brandLocal));
96
+
97
+ const newStatus = await getAdvancedConfigStatus(tenantId);
70
98
  setStatus(newStatus);
71
99
 
72
- // Reset form to new state (clears password fields and isDirty)
73
- const newState = convertToLocalState(newStatus);
100
+ const brandLocalHydrate = convertBrandToLocalState(brandConfigFresh);
101
+ const newState = {
102
+ ...convertAdvancedToLocalState(newStatus),
103
+ adminEmail: brandLocalHydrate.adminEmail,
104
+ adminEmailName: brandLocalHydrate.adminEmailName,
105
+ };
74
106
  formState.resetToState(newState);
75
107
 
76
108
  window.location.reload();
@@ -82,9 +114,14 @@ export default function StoryKeepDashboard_Advanced({
82
114
  if (!status || hasHydratedInitialFormState.current) {
83
115
  return;
84
116
  }
85
- formState.resetToState(convertToLocalState(status));
117
+ const brandLocal = convertBrandToLocalState(brandConfig);
118
+ formState.resetToState({
119
+ ...convertAdvancedToLocalState(status),
120
+ adminEmail: brandLocal.adminEmail,
121
+ adminEmailName: brandLocal.adminEmailName,
122
+ });
86
123
  hasHydratedInitialFormState.current = true;
87
- }, [status, formState]);
124
+ }, [status, formState, brandConfig]);
88
125
 
89
126
  if (isLoading) {
90
127
  return (
@@ -102,7 +139,6 @@ export default function StoryKeepDashboard_Advanced({
102
139
  );
103
140
  }
104
141
 
105
- // Database status component
106
142
  const DatabaseStatusSection = () => {
107
143
  const databaseType = status?.tursoEnabled
108
144
  ? 'Turso Cloud Database'