astro-tractstack 2.0.2 → 2.0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-tractstack",
3
- "version": "2.0.2",
3
+ "version": "2.0.5",
4
4
  "description": "Astro integration for TractStack - redeeming the web from boring experiences",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -353,7 +353,7 @@ const EpinetWrapper = ({
353
353
  }
354
354
  >
355
355
  <div className="space-y-6">
356
- <div className="rounded-lg bg-white p-6 shadow">
356
+ <div className="rounded-lg bg-white p-2 shadow md:p-6">
357
357
  <div className="mb-4 flex items-center justify-between">
358
358
  {(isLoading || status === 'loading') && (
359
359
  <div className="flex items-center space-x-2 text-sm text-gray-500">
@@ -3,7 +3,8 @@ import * as d3 from 'd3';
3
3
  import { sankey, sankeyLinkHorizontal } from 'd3-sankey';
4
4
 
5
5
  const MAX_HEIGHT = 1600;
6
- const COMPRESSED_HEIGHT = 256; // Fixed height for compressed view
6
+ const COMPRESSED_HEIGHT = 256;
7
+ const MIN_DIAGRAM_WIDTH = 800; // Define a minimum width for the diagram
7
8
 
8
9
  const colors = [
9
10
  '#ef4444',
@@ -52,7 +53,7 @@ const SankeyDiagram = ({ data, isLoading = false }: SankeyDiagramProps) => {
52
53
  const containerRef = useRef<HTMLDivElement | null>(null);
53
54
  const hasScrolledRef = useRef(false);
54
55
  const [dimensions, setDimensions] = useState({
55
- width: 800,
56
+ width: MIN_DIAGRAM_WIDTH,
56
57
  height: 500,
57
58
  });
58
59
  const [isExpanded, setIsExpanded] = useState(false);
@@ -60,7 +61,11 @@ const SankeyDiagram = ({ data, isLoading = false }: SankeyDiagramProps) => {
60
61
  useEffect(() => {
61
62
  const updateDimensions = () => {
62
63
  if (containerRef.current) {
63
- const containerWidth = containerRef.current.offsetWidth;
64
+ // Ensure the diagram width is the larger of the container or our defined minimum
65
+ const containerWidth = Math.max(
66
+ MIN_DIAGRAM_WIDTH,
67
+ containerRef.current.offsetWidth
68
+ );
64
69
  const nodeCount = data.nodes.length || 1;
65
70
  const optimalHeight = nodeCount * (40 + 10) + 50;
66
71
  const finalHeight = Math.min(MAX_HEIGHT, optimalHeight);
@@ -245,7 +250,6 @@ const SankeyDiagram = ({ data, isLoading = false }: SankeyDiagramProps) => {
245
250
 
246
251
  return (
247
252
  <div ref={containerRef} className="relative w-full">
248
- {/* Expand/Compress Controls */}
249
253
  <div className="mb-3 flex items-center justify-between">
250
254
  <div className="text-sm text-gray-600">
251
255
  {data.nodes.length} nodes • {data.links.length} connections
@@ -292,23 +296,21 @@ const SankeyDiagram = ({ data, isLoading = false }: SankeyDiagramProps) => {
292
296
  </button>
293
297
  </div>
294
298
 
295
- {/* Compression Warning */}
296
299
  {needsCompression && (
297
300
  <div className="mb-2 rounded bg-amber-50 px-3 py-2 text-sm text-amber-800">
298
301
  <strong>Compressed view</strong> - click anywhere to expand!
299
302
  </div>
300
303
  )}
301
304
 
302
- {/* SVG Container - Clickable when compressed */}
303
305
  <div
304
- className={`transition-all duration-300 ${
306
+ className={`overflow-x-auto transition-all duration-300 md:overflow-visible ${
305
307
  needsCompression
306
308
  ? 'cursor-pointer hover:bg-gray-50 hover:shadow-md'
307
309
  : ''
308
310
  }`}
309
311
  style={{
310
312
  height: `${displayHeight}px`,
311
- overflow: 'hidden',
313
+ overflowY: 'hidden',
312
314
  }}
313
315
  onClick={needsCompression ? handleExpand : undefined}
314
316
  role={needsCompression ? 'button' : undefined}
@@ -333,7 +335,7 @@ const SankeyDiagram = ({ data, isLoading = false }: SankeyDiagramProps) => {
333
335
  height={dimensions.height}
334
336
  style={{
335
337
  display: 'block',
336
- width: '100%',
338
+ minWidth: `${dimensions.width}px`, // Ensure SVG itself doesn't shrink
337
339
  height: `${dimensions.height}px`,
338
340
  transform: needsCompression
339
341
  ? `scaleY(${displayHeight / dimensions.height})`
@@ -345,6 +347,10 @@ const SankeyDiagram = ({ data, isLoading = false }: SankeyDiagramProps) => {
345
347
  ></svg>
346
348
  </div>
347
349
 
350
+ <div className="mt-2 text-center text-xs text-gray-500 md:hidden">
351
+ &larr; Scroll to see full journey map &rarr;
352
+ </div>
353
+
348
354
  {isLoading && (
349
355
  <div className="absolute inset-0 flex items-center justify-center rounded bg-black bg-opacity-80">
350
356
  <div className="flex items-center space-x-2 text-white">
@@ -22,15 +22,16 @@ const getWidgetElement = (
22
22
  classNames: string
23
23
  ): ReactElement | null => {
24
24
  const { hook, value1, value2, value3, nodeId } = props;
25
- if (!hook || !value1) return null;
25
+ if (!hook) return null;
26
26
 
27
27
  switch (hook) {
28
28
  case 'youtube':
29
- return value2 ? (
30
- <div className={`${classNames} pointer-events-none`}>
31
- <YouTubeWrapper embedCode={value1} title={value2} />
32
- </div>
33
- ) : null;
29
+ if (value1)
30
+ return value2 ? (
31
+ <div className={`${classNames} pointer-events-none`}>
32
+ <YouTubeWrapper embedCode={value1} title={value2} />
33
+ </div>
34
+ ) : null;
34
35
 
35
36
  case 'signup':
36
37
  return (
@@ -45,19 +46,21 @@ const getWidgetElement = (
45
46
  );
46
47
 
47
48
  case 'belief':
48
- return value2 ? (
49
- <div className={`${classNames} pointer-events-none`}>
50
- <Belief value={{ slug: value1, scale: value2, extra: value3 }} />
51
- </div>
52
- ) : null;
49
+ if (value1)
50
+ return value2 ? (
51
+ <div className={`${classNames} pointer-events-none`}>
52
+ <Belief value={{ slug: value1, scale: value2, extra: value3 }} />
53
+ </div>
54
+ ) : null;
53
55
 
54
56
  case 'identifyAs':
55
- return value2 ? (
56
- <IdentifyAs
57
- classNames={`${classNames} pointer-events-none`}
58
- value={{ slug: value1, target: value2, extra: value3 || `` }}
59
- />
60
- ) : null;
57
+ if (value1)
58
+ return value2 ? (
59
+ <IdentifyAs
60
+ classNames={`${classNames} pointer-events-none`}
61
+ value={{ slug: value1, target: value2, extra: value3 || `` }}
62
+ />
63
+ ) : null;
61
64
 
62
65
  case 'toggle':
63
66
  return value2 ? (
@@ -90,9 +93,16 @@ const getWidgetElement = (
90
93
  <p className="text-sm font-bold text-gray-700">
91
94
  Interactive Disclosure
92
95
  </p>
93
- <p className="mt-1 text-xs text-gray-500">
94
- Belief Trigger: <code className="font-bold">{value1}</code>
95
- </p>
96
+ {value1 ? (
97
+ <p className="mt-1 text-xs text-gray-500">
98
+ Mode: Belief-Driven (<code className="font-bold">{value1}</code>
99
+ )
100
+ </p>
101
+ ) : (
102
+ <p className="mt-1 text-xs text-gray-500">
103
+ Mode: Open (Custom Actions)
104
+ </p>
105
+ )}
96
106
  </div>
97
107
  </div>
98
108
  );
@@ -187,21 +187,27 @@ const StoryFragmentOpenGraphPanel = ({
187
187
  (item) => item.type === 'Topic' && item.id === 'all-topics'
188
188
  );
189
189
 
190
- // Convert topic strings to Topic objects with mock IDs (since V2 doesn't expose topic IDs in content map)
190
+ // Convert topic strings to Topic objects with mock IDs
191
191
  const allTopicsArray = topicsContent?.topics || [];
192
192
  const topicsWithIds: Topic[] = allTopicsArray.map(
193
193
  (topicTitle, index) => ({
194
- id: index + 1, // Mock ID - in V2 we don't have access to actual topic IDs from content map
194
+ id: index + 1,
195
195
  title: topicTitle,
196
196
  })
197
197
  );
198
198
 
199
199
  setExistingTopics(topicsWithIds);
200
200
 
201
+ // Prioritize the description from the definitive fullContentMap
202
+ const sfContent = $contentMap.find(
203
+ (item) => item.type === 'StoryFragment' && item.id === nodeId
204
+ );
205
+ const initialDescription = sfContent?.description || '';
206
+ setDraftDetails(initialDescription);
207
+
201
208
  let initialTopics: Topic[] = [];
202
- let initialDescription = '';
203
209
 
204
- // Check stored draft data first
210
+ // Check stored draft data for topics
205
211
  if (storedData) {
206
212
  initialTopics = Array.isArray(storedData.topics)
207
213
  ? storedData.topics.map((t) => ({
@@ -209,29 +215,18 @@ const StoryFragmentOpenGraphPanel = ({
209
215
  title: t.title,
210
216
  }))
211
217
  : [];
212
- initialDescription = storedData.description || '';
213
218
  setDraftTopics(initialTopics);
214
- setDraftDetails(initialDescription);
219
+ } else if (sfContent && sfContent.topics && sfContent.topics.length > 0) {
220
+ // Fall back to content map data for initial topics if no draft exists
221
+ initialTopics = sfContent.topics.map((topicTitle) => {
222
+ const existingTopic = topicsWithIds.find(
223
+ (t) => t.title.toLowerCase() === topicTitle.toLowerCase()
224
+ );
225
+ return existingTopic || { id: -1, title: topicTitle };
226
+ });
227
+ setDraftTopics(initialTopics);
215
228
  } else {
216
- // Fall back to content map data
217
- const sfContent = $contentMap.find(
218
- (item) => item.type === 'StoryFragment' && item.id === nodeId
219
- );
220
-
221
- if (sfContent && sfContent.topics && sfContent.topics.length > 0) {
222
- initialTopics = sfContent.topics.map((topicTitle) => {
223
- const existingTopic = topicsWithIds.find(
224
- (t) => t.title.toLowerCase() === topicTitle.toLowerCase()
225
- );
226
- return existingTopic || { id: -1, title: topicTitle };
227
- });
228
- initialDescription = sfContent.description || '';
229
- setDraftTopics(initialTopics);
230
- setDraftDetails(initialDescription);
231
- } else {
232
- setDraftTopics([]);
233
- setDraftDetails('');
234
- }
229
+ setDraftTopics([]);
235
230
  }
236
231
 
237
232
  if (initialState.current) {
@@ -124,7 +124,6 @@ const DisclosureItemEditor = ({
124
124
  item,
125
125
  onUpdate,
126
126
  onToggle,
127
- config,
128
127
  onMoveUp,
129
128
  onMoveDown,
130
129
  isFirst,
@@ -133,7 +132,6 @@ const DisclosureItemEditor = ({
133
132
  item: DisclosureItem;
134
133
  onUpdate: (updates: Partial<DisclosureItem>) => void;
135
134
  onToggle: () => void;
136
- config: BrandConfig;
137
135
  onMoveUp: () => void;
138
136
  onMoveDown: () => void;
139
137
  isFirst: boolean;
@@ -258,6 +256,7 @@ export default function InteractiveDisclosureWidget({
258
256
  onUpdate,
259
257
  config,
260
258
  }: InteractiveDisclosureWidgetProps) {
259
+ const [mode, setMode] = useState<'belief' | 'open'>('belief');
261
260
  const [beliefs, setBeliefs] = useState<BeliefNode[]>([]);
262
261
  const [selectedBeliefTag, setSelectedBeliefTag] = useState<string>('');
263
262
  const [disclosures, setDisclosures] = useState<DisclosureItem[]>([]);
@@ -276,14 +275,16 @@ export default function InteractiveDisclosureWidget({
276
275
  const beliefTag = String(node.codeHookParams?.[0] || '');
277
276
  const payloadJson = String(node.codeHookParams?.[1] || '');
278
277
 
279
- if (beliefs.length === 0 && beliefTag && beliefTag !== 'BELIEF') {
280
- return;
278
+ if (beliefTag && beliefTag !== 'BELIEF') {
279
+ setMode('belief');
280
+ } else {
281
+ setMode('open');
281
282
  }
282
283
 
283
284
  setSelectedBeliefTag(beliefTag && beliefTag !== 'BELIEF' ? beliefTag : '');
284
285
  const currentBelief = beliefs.find((b) => b.slug === beliefTag);
285
286
 
286
- if (payloadJson && currentBelief) {
287
+ if (payloadJson) {
287
288
  try {
288
289
  const parsed = JSON.parse(payloadJson);
289
290
  setWidgetStyles(
@@ -296,50 +297,62 @@ export default function InteractiveDisclosureWidget({
296
297
  const loadedDisclosures =
297
298
  (parsed.disclosures as StoredDisclosureItem[]) || [];
298
299
 
299
- const scaleKeys =
300
- currentBelief.scale === 'custom'
301
- ? (currentBelief.customValues || []).map((v) => ({
302
- slug: v,
303
- name: v,
304
- }))
305
- : heldBeliefsScales[
306
- currentBelief.scale as keyof typeof heldBeliefsScales
307
- ] || [];
308
-
309
- const actionCommand =
310
- currentBelief.scale === 'custom' ? 'identifyAs' : 'declare';
311
- const finalDisclosures: DisclosureItem[] = loadedDisclosures.map(
312
- (loadedItem) => {
313
- const isFromScale = scaleKeys.some(
314
- (sk) => sk.slug === loadedItem.beliefValue
315
- );
316
-
317
- return {
300
+ if (currentBelief) {
301
+ const scaleKeys =
302
+ currentBelief.scale === 'custom'
303
+ ? (currentBelief.customValues || []).map((v) => ({
304
+ slug: v,
305
+ name: v,
306
+ }))
307
+ : heldBeliefsScales[
308
+ currentBelief.scale as keyof typeof heldBeliefsScales
309
+ ] || [];
310
+
311
+ const actionCommand =
312
+ currentBelief.scale === 'custom' ? 'identifyAs' : 'declare';
313
+ const finalDisclosures: DisclosureItem[] = loadedDisclosures.map(
314
+ (loadedItem) => {
315
+ const isFromScale = scaleKeys.some(
316
+ (sk) => sk.slug === loadedItem.beliefValue
317
+ );
318
+
319
+ return {
320
+ ...loadedItem,
321
+ id: generateId(),
322
+ isCustom: !isFromScale,
323
+ actionLisp: isFromScale
324
+ ? `(${actionCommand} ${beliefTag} ${quoteIfNecessary(actionCommand, loadedItem.beliefValue)})`
325
+ : loadedItem.actionLisp,
326
+ isDisabled: false,
327
+ };
328
+ }
329
+ );
330
+ scaleKeys.forEach(({ slug, name }) => {
331
+ if (!finalDisclosures.some((d) => d.beliefValue === slug)) {
332
+ finalDisclosures.push({
333
+ id: generateId(),
334
+ beliefValue: slug,
335
+ title: name,
336
+ description: '',
337
+ icon: 'chat-heart-fill',
338
+ actionLisp: `(${actionCommand} ${beliefTag} ${quoteIfNecessary(actionCommand, slug)})`,
339
+ isCustom: false,
340
+ isDisabled: true,
341
+ });
342
+ }
343
+ });
344
+ setDisclosures(finalDisclosures);
345
+ } else {
346
+ const finalDisclosures: DisclosureItem[] = loadedDisclosures.map(
347
+ (loadedItem) => ({
318
348
  ...loadedItem,
319
349
  id: generateId(),
320
- isCustom: !isFromScale,
321
- actionLisp: isFromScale
322
- ? `(${actionCommand} ${beliefTag} ${quoteIfNecessary(actionCommand, loadedItem.beliefValue)})`
323
- : loadedItem.actionLisp,
350
+ isCustom: true,
324
351
  isDisabled: false,
325
- };
326
- }
327
- );
328
- scaleKeys.forEach(({ slug, name }) => {
329
- if (!finalDisclosures.some((d) => d.beliefValue === slug)) {
330
- finalDisclosures.push({
331
- id: generateId(),
332
- beliefValue: slug,
333
- title: name,
334
- description: '',
335
- icon: 'chat-heart-fill',
336
- actionLisp: `(${actionCommand} ${beliefTag} ${quoteIfNecessary(actionCommand, slug)})`,
337
- isCustom: false,
338
- isDisabled: true,
339
- });
340
- }
341
- });
342
- setDisclosures(finalDisclosures);
352
+ })
353
+ );
354
+ setDisclosures(finalDisclosures);
355
+ }
343
356
  } catch (e) {
344
357
  console.error('Error parsing disclosure payload:', e);
345
358
  }
@@ -417,6 +430,25 @@ export default function InteractiveDisclosureWidget({
417
430
  setDisclosures(newDisclosures);
418
431
  };
419
432
 
433
+ const handleModeChange = (newMode: 'belief' | 'open') => {
434
+ if (mode === newMode) return;
435
+
436
+ setMode(newMode);
437
+ setSelectedBeliefTag('');
438
+ setDisclosures([]);
439
+ setWidgetStyles({
440
+ textColor: '#000000',
441
+ bgColor: '#ffffff',
442
+ bgOpacity: 100,
443
+ });
444
+
445
+ if (newMode === 'open') {
446
+ onUpdate(['', '{}']);
447
+ } else {
448
+ onUpdate(['BELIEF', '{}']);
449
+ }
450
+ };
451
+
420
452
  const moveDisclosure = (id: string, direction: 'up' | 'down') => {
421
453
  const index = disclosures.findIndex((d) => d.id === id);
422
454
  if (index === -1) return;
@@ -477,35 +509,66 @@ export default function InteractiveDisclosureWidget({
477
509
 
478
510
  return (
479
511
  <div className="space-y-4">
480
- <div className="flex items-center gap-2">
481
- <select
482
- value={selectedBeliefTag}
483
- onChange={(e) => handleBeliefChange(e.target.value)}
484
- className="flex-1 rounded-md border-gray-300 shadow-sm"
485
- disabled={hasRealSelection}
486
- >
487
- <option value="">Select a Belief...</option>
488
- {beliefs.map((b) => (
489
- <option key={b.slug} value={b.slug}>
490
- {b.title} ({b.scale})
491
- </option>
492
- ))}
493
- </select>
494
- {hasRealSelection && (
512
+ <div>
513
+ <label className="block text-xs font-bold text-gray-600">
514
+ Configuration Mode
515
+ </label>
516
+ <div className="isolate mt-1 inline-flex rounded-md shadow-sm">
495
517
  <button
496
518
  type="button"
497
- onClick={() => {
498
- setSelectedBeliefTag('');
499
- setDisclosures([]);
500
- onUpdate(['BELIEF', '{}']);
501
- }}
502
- className="rounded p-1 text-red-600 hover:bg-gray-100"
519
+ onClick={() => handleModeChange('belief')}
520
+ className={`relative inline-flex items-center rounded-l-md px-3 py-2 text-sm font-semibold ring-1 ring-inset ring-gray-300 focus:z-10 ${
521
+ mode === 'belief'
522
+ ? 'bg-cyan-600 text-white'
523
+ : 'bg-white text-gray-900 hover:bg-gray-50'
524
+ }`}
503
525
  >
504
- <XMarkIcon className="h-5 w-5" />
526
+ Belief-Driven
505
527
  </button>
506
- )}
528
+ <button
529
+ type="button"
530
+ onClick={() => handleModeChange('open')}
531
+ className={`relative -ml-px inline-flex items-center rounded-r-md px-3 py-2 text-sm font-semibold ring-1 ring-inset ring-gray-300 focus:z-10 ${
532
+ mode === 'open'
533
+ ? 'bg-cyan-600 text-white'
534
+ : 'bg-white text-gray-900 hover:bg-gray-50'
535
+ }`}
536
+ >
537
+ Open
538
+ </button>
539
+ </div>
507
540
  </div>
508
- {hasRealSelection && (
541
+ {mode === 'belief' && (
542
+ <div className="flex items-center gap-2">
543
+ <select
544
+ value={selectedBeliefTag}
545
+ onChange={(e) => handleBeliefChange(e.target.value)}
546
+ className="flex-1 rounded-md border-gray-300 shadow-sm"
547
+ disabled={hasRealSelection}
548
+ >
549
+ <option value="">Select a Belief...</option>
550
+ {beliefs.map((b) => (
551
+ <option key={b.slug} value={b.slug}>
552
+ {b.title} ({b.scale})
553
+ </option>
554
+ ))}
555
+ </select>
556
+ {hasRealSelection && (
557
+ <button
558
+ type="button"
559
+ onClick={() => {
560
+ setSelectedBeliefTag('');
561
+ setDisclosures([]);
562
+ onUpdate(['BELIEF', '{}']);
563
+ }}
564
+ className="rounded p-1 text-red-600 hover:bg-gray-100"
565
+ >
566
+ <XMarkIcon className="h-5 w-5" />
567
+ </button>
568
+ )}
569
+ </div>
570
+ )}
571
+ {(hasRealSelection || mode === 'open') && (
509
572
  <div className="mt-4 border-t border-gray-200 pt-4">
510
573
  <button
511
574
  type="button"
@@ -513,8 +576,8 @@ export default function InteractiveDisclosureWidget({
513
576
  className="flex w-full items-center justify-center rounded-md bg-gray-100 px-3 py-2 text-sm font-bold text-gray-700 hover:bg-gray-200"
514
577
  >
515
578
  <ChevronDownIcon className="mr-2 h-5 w-5" />
516
- Configure {disclosures.filter((d) => !d.isDisabled).length} of{' '}
517
- {disclosures.length} Disclosure(s) & Styles
579
+ Configure {disclosures.filter((d) => !d.isDisabled).length}{' '}
580
+ Disclosure(s) & Styles
518
581
  </button>
519
582
  </div>
520
583
  )}
@@ -546,7 +609,8 @@ export default function InteractiveDisclosureWidget({
546
609
  <div className="flex h-full flex-col">
547
610
  <div className="flex-shrink-0 border-b border-gray-200 bg-white px-6 py-3">
548
611
  <Dialog.Title className="text-lg font-bold text-gray-900">
549
- Disclosure Configuration: {selectedBelief?.title}
612
+ Disclosure Configuration
613
+ {selectedBelief && `: ${selectedBelief.title}`}
550
614
  </Dialog.Title>
551
615
  </div>
552
616
  <div className="flex-1 space-y-6 overflow-y-auto p-4">
@@ -614,7 +678,6 @@ export default function InteractiveDisclosureWidget({
614
678
  updateDisclosure(item.id, updates)
615
679
  }
616
680
  onToggle={() => toggleDisclosure(item.id)}
617
- config={config}
618
681
  onMoveUp={() => moveDisclosure(item.id, 'up')}
619
682
  onMoveDown={() => moveDisclosure(item.id, 'down')}
620
683
  isFirst={index === 0}
@@ -39,36 +39,25 @@ const FIELD_TYPES = [
39
39
  { value: 'image', label: 'Image' },
40
40
  ];
41
41
 
42
- const KnownResourceForm = ({
42
+ const KnownResourceFormRenderer = ({
43
43
  categorySlug,
44
44
  contentMap,
45
45
  onClose,
46
- }: KnownResourceFormProps) => {
46
+ brandConfig,
47
+ }: KnownResourceFormProps & { brandConfig: BrandConfig }) => {
47
48
  const [newFieldName, setNewFieldName] = useState('');
48
49
  const [showAddField, setShowAddField] = useState(false);
49
- const [brandConfig, setBrandConfig] = useState<BrandConfig | null>(null);
50
- const [loading, setLoading] = useState(false);
51
-
52
- useEffect(() => {
53
- if (!brandConfig && !loading) {
54
- setLoading(true);
55
- getBrandConfig(window.TRACTSTACK_CONFIG?.tenantId || 'default')
56
- .then(setBrandConfig)
57
- .catch(console.error)
58
- .finally(() => setLoading(false));
59
- }
60
- }, [brandConfig, loading]);
61
50
 
62
- const knownResources = brandConfig?.KNOWN_RESOURCES || {};
63
51
  const isCreate = categorySlug === 'new';
64
- const currentCategory = isCreate ? {} : knownResources[categorySlug] || {};
52
+ const knownResources = brandConfig.KNOWN_RESOURCES || {};
53
+ const currentCategory = isCreate ? {} : knownResources[categorySlug];
65
54
 
66
55
  const hasExistingResources =
67
56
  !isCreate && contentMap.some((item) => item.categorySlug === categorySlug);
68
57
 
69
58
  const initialState: KnownResourceState = {
70
59
  categorySlug: isCreate ? '' : categorySlug,
71
- fields: isCreate ? {} : currentCategory,
60
+ fields: currentCategory,
72
61
  };
73
62
 
74
63
  const validator = (state: KnownResourceState): FieldErrors => {
@@ -90,8 +79,6 @@ const KnownResourceForm = ({
90
79
  validator,
91
80
  onSave: async (data) => {
92
81
  try {
93
- // Update known resources in brand config
94
- if (!brandConfig) throw new Error('Brand config not loaded');
95
82
  const brandState = convertToLocalState(brandConfig);
96
83
  const updatedKnownResources = {
97
84
  ...brandState.knownResources,
@@ -108,7 +95,6 @@ const KnownResourceForm = ({
108
95
  updatedBrandState
109
96
  );
110
97
 
111
- // Call success callback after save (original pattern)
112
98
  setTimeout(() => {
113
99
  onClose?.(true);
114
100
  }, 1000);
@@ -179,7 +165,6 @@ const KnownResourceForm = ({
179
165
 
180
166
  return (
181
167
  <div className="space-y-8">
182
- {/* Header */}
183
168
  <div className="border-b border-gray-200 pb-4">
184
169
  <h2 className="text-2xl font-bold text-gray-900">
185
170
  {isCreate ? 'Create Resource Category' : `Edit ${categorySlug}`}
@@ -200,7 +185,6 @@ const KnownResourceForm = ({
200
185
  </div>
201
186
 
202
187
  <div className="space-y-6">
203
- {/* Category Name */}
204
188
  <StringInput
205
189
  label="Category Name"
206
190
  value={formState.state.categorySlug}
@@ -214,7 +198,6 @@ const KnownResourceForm = ({
214
198
  Must be lowercase with hyphens. Cannot be changed after creation.
215
199
  </p>
216
200
 
217
- {/* Fields Section */}
218
201
  <div className="space-y-6">
219
202
  <div className="flex items-center justify-between">
220
203
  <h3 className="text-lg font-bold text-gray-900">Fields</h3>
@@ -228,7 +211,6 @@ const KnownResourceForm = ({
228
211
  </button>
229
212
  </div>
230
213
 
231
- {/* Add Field Form */}
232
214
  {showAddField && (
233
215
  <div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
234
216
  <h4 className="mb-3 text-sm font-bold text-gray-900">
@@ -264,7 +246,6 @@ const KnownResourceForm = ({
264
246
  </div>
265
247
  )}
266
248
 
267
- {/* Existing Fields */}
268
249
  {Object.keys(formState.state.fields).length === 0 ? (
269
250
  <div className="py-6 text-center text-gray-500">
270
251
  No fields defined yet. Click "Add Field" to create your first
@@ -275,7 +256,6 @@ const KnownResourceForm = ({
275
256
  {Object.entries(formState.state.fields).map(
276
257
  ([fieldName, fieldDef]) => {
277
258
  const locked = isFieldLocked(fieldName);
278
-
279
259
  return (
280
260
  <div
281
261
  key={fieldName}
@@ -304,7 +284,6 @@ const KnownResourceForm = ({
304
284
  </button>
305
285
  )}
306
286
  </div>
307
-
308
287
  <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
309
288
  <EnumSelect
310
289
  label="Type"
@@ -315,7 +294,6 @@ const KnownResourceForm = ({
315
294
  options={FIELD_TYPES}
316
295
  disabled={locked}
317
296
  />
318
-
319
297
  <BooleanToggle
320
298
  label="Optional"
321
299
  value={fieldDef.optional || false}
@@ -324,7 +302,6 @@ const KnownResourceForm = ({
324
302
  }
325
303
  disabled={locked}
326
304
  />
327
-
328
305
  {fieldDef.type === 'categoryReference' && (
329
306
  <EnumSelect
330
307
  label="Reference Category"
@@ -341,7 +318,6 @@ const KnownResourceForm = ({
341
318
  disabled={locked}
342
319
  />
343
320
  )}
344
-
345
321
  {fieldDef.type === 'number' && (
346
322
  <>
347
323
  <NumberInput
@@ -372,7 +348,6 @@ const KnownResourceForm = ({
372
348
  </div>
373
349
  </div>
374
350
 
375
- {/* Save/Cancel Bar */}
376
351
  <UnsavedChangesBar
377
352
  formState={formState}
378
353
  message="You have unsaved resource category changes"
@@ -380,7 +355,6 @@ const KnownResourceForm = ({
380
355
  cancelLabel="Discard Changes"
381
356
  />
382
357
 
383
- {/* Cancel Navigation Button */}
384
358
  <div className="flex justify-start">
385
359
  <button
386
360
  type="button"
@@ -394,4 +368,60 @@ const KnownResourceForm = ({
394
368
  );
395
369
  };
396
370
 
371
+ const KnownResourceForm = ({
372
+ categorySlug,
373
+ contentMap,
374
+ onClose,
375
+ }: KnownResourceFormProps) => {
376
+ const [brandConfig, setBrandConfig] = useState<BrandConfig | null>(null);
377
+ const [loading, setLoading] = useState(true);
378
+ const [error, setError] = useState<string | null>(null);
379
+
380
+ useEffect(() => {
381
+ getBrandConfig(window.TRACTSTACK_CONFIG?.tenantId || 'default')
382
+ .then(setBrandConfig)
383
+ .catch((err) => {
384
+ console.error('Failed to load brand configuration:', err);
385
+ setError(
386
+ 'Could not load resource category configuration. Please try again.'
387
+ );
388
+ })
389
+ .finally(() => setLoading(false));
390
+ }, []);
391
+
392
+ if (loading) {
393
+ return (
394
+ <div className="py-12 text-center text-gray-500">
395
+ Loading configuration...
396
+ </div>
397
+ );
398
+ }
399
+
400
+ if (error) {
401
+ return (
402
+ <div className="rounded-md bg-red-50 p-4 text-center text-red-700">
403
+ {error}
404
+ </div>
405
+ );
406
+ }
407
+
408
+ const isCreate = categorySlug === 'new';
409
+ if (!isCreate && !(brandConfig?.KNOWN_RESOURCES || {})[categorySlug]) {
410
+ return (
411
+ <div className="py-12 text-center text-gray-500">
412
+ Resource category "{categorySlug}" not found.
413
+ </div>
414
+ );
415
+ }
416
+
417
+ return (
418
+ <KnownResourceFormRenderer
419
+ categorySlug={categorySlug}
420
+ contentMap={contentMap}
421
+ onClose={onClose}
422
+ brandConfig={brandConfig!}
423
+ />
424
+ );
425
+ };
426
+
397
427
  export default KnownResourceForm;
@@ -22,6 +22,7 @@ export interface Props {
22
22
  brandConfig?: any;
23
23
  storyfragmentId?: string;
24
24
  sessionId?: string;
25
+ description?: string;
25
26
  impressions?: ImpressionNode[];
26
27
  }
27
28
 
@@ -125,6 +125,9 @@ const ogImage = storyFragment?.socialImagePath
125
125
  const fullContentMap = await getFullContentMap(
126
126
  Astro.locals.tenant?.id || import.meta.env.PUBLIC_TENANTID || 'default'
127
127
  );
128
+ const description = fullContentMap.find(
129
+ (item) => item.id === storyFragmentID
130
+ )?.description;
128
131
  const urlParams: Record<string, string | boolean> = {};
129
132
  for (const [key, value] of Astro.url.searchParams) {
130
133
  urlParams[key] = value === '' ? true : value;
@@ -133,6 +136,7 @@ for (const [key, value] of Astro.url.searchParams) {
133
136
 
134
137
  <Layout
135
138
  title={title}
139
+ description={description}
136
140
  slug={slug}
137
141
  canonicalURL={canonicalURL}
138
142
  pubDatetime={pubDatetime}
@@ -219,5 +223,5 @@ for (const [key, value] of Astro.url.searchParams) {
219
223
 
220
224
  <script>
221
225
  import { setupLayoutObservers } from '@/utils/layout';
222
- setupLayoutObservers();
226
+ document.addEventListener('astro:page-load', setupLayoutObservers);
223
227
  </script>
@@ -99,6 +99,9 @@ if (!fragmentsData) {
99
99
  }
100
100
 
101
101
  const fullContentMap = await getFullContentMap(tenantId);
102
+ const description = fullContentMap.find(
103
+ (item) => item.id === storyfragmentId
104
+ )?.description;
102
105
  const brandConfig = await getBrandConfig(tenantId);
103
106
 
104
107
  if (!brandConfig.SITE_INIT) {
@@ -118,6 +121,7 @@ paneIds.forEach((paneId: string) => {
118
121
 
119
122
  <Layout
120
123
  title={storyfragmentTitle}
124
+ description={description}
121
125
  slug={lookup || brandConfig.HOME_SLUG}
122
126
  ogImage={ogImage}
123
127
  menu={storyData.menu || null}
@@ -8,15 +8,41 @@ import {
8
8
  import { debounce } from '@/utils/helpers';
9
9
 
10
10
  let hasScrolledForSettingsPanel = false;
11
+ let currentPaneObserver: IntersectionObserver | null = null;
12
+ let settingsPanelSubscription: (() => void) | null = null;
13
+ let debouncedUpdateListener: (() => void) | null = null;
14
+
15
+ function cleanupLayoutObservers() {
16
+ if (currentPaneObserver) {
17
+ currentPaneObserver.disconnect();
18
+ currentPaneObserver = null;
19
+ }
20
+ if (settingsPanelSubscription) {
21
+ settingsPanelSubscription();
22
+ settingsPanelSubscription = null;
23
+ }
24
+ if (debouncedUpdateListener) {
25
+ window.removeEventListener('scroll', debouncedUpdateListener);
26
+ window.removeEventListener('resize', debouncedUpdateListener);
27
+ debouncedUpdateListener = null;
28
+ }
29
+ const storykeepHeader = document.getElementById('storykeepHeader');
30
+ if (storykeepHeader) {
31
+ document.body.style.paddingTop = '';
32
+ storykeepHeader.style.position = '';
33
+ storykeepHeader.style.top = '';
34
+ }
35
+ }
11
36
 
12
- // Replace your existing setupPaneObserver with this one.
13
37
  function setupPaneObserver() {
14
- let currentObserver: IntersectionObserver | null = null;
38
+ if (currentPaneObserver) {
39
+ currentPaneObserver.disconnect();
40
+ }
15
41
 
16
- settingsPanelStore.subscribe((signalValue) => {
17
- if (currentObserver) {
18
- currentObserver.disconnect();
19
- currentObserver = null;
42
+ settingsPanelSubscription = settingsPanelStore.subscribe((signalValue) => {
43
+ if (currentPaneObserver) {
44
+ currentPaneObserver.disconnect();
45
+ currentPaneObserver = null;
20
46
  }
21
47
 
22
48
  if (signalValue && signalValue.nodeId) {
@@ -28,7 +54,7 @@ function setupPaneObserver() {
28
54
  document.querySelector(`[data-node-id="${nodeId}"]`);
29
55
 
30
56
  if (targetElement) {
31
- currentObserver = new IntersectionObserver(
57
+ currentPaneObserver = new IntersectionObserver(
32
58
  ([entry]) => {
33
59
  const signal = settingsPanelStore.get();
34
60
  const now = Date.now();
@@ -41,7 +67,7 @@ function setupPaneObserver() {
41
67
  },
42
68
  { threshold: 0 }
43
69
  );
44
- currentObserver.observe(targetElement);
70
+ currentPaneObserver.observe(targetElement);
45
71
  }
46
72
  }, 100);
47
73
  }
@@ -49,6 +75,8 @@ function setupPaneObserver() {
49
75
  }
50
76
 
51
77
  export function setupLayoutObservers(): void {
78
+ cleanupLayoutObservers();
79
+
52
80
  const storykeepHeader = document.getElementById('storykeepHeader');
53
81
  const settingsControls = document.getElementById('settingsControls');
54
82
  const standardHeader = document.querySelector('header');
@@ -86,7 +114,7 @@ export function setupLayoutObservers(): void {
86
114
  }
87
115
  };
88
116
 
89
- const debouncedUpdate = debounce(() => {
117
+ debouncedUpdateListener = debounce(() => {
90
118
  updateStandardHeaderHeight();
91
119
  handleScroll();
92
120
  updatePanelPosition();
@@ -98,8 +126,8 @@ export function setupLayoutObservers(): void {
98
126
  }
99
127
  };
100
128
 
101
- window.addEventListener('scroll', debouncedUpdate, { passive: true });
102
- window.addEventListener('resize', debouncedUpdate);
129
+ window.addEventListener('scroll', debouncedUpdateListener, { passive: true });
130
+ window.addEventListener('resize', debouncedUpdateListener);
103
131
  settingsPanelOpenStore.subscribe(handleSettingsPanelChange);
104
132
 
105
133
  setupPaneObserver();
@@ -128,3 +156,5 @@ export function handleSettingsPanelMobile(isOpen: boolean): void {
128
156
  hasScrolledForSettingsPanel = false;
129
157
  }
130
158
  }
159
+
160
+ document.addEventListener('astro:before-swap', cleanupLayoutObservers);