astro-tractstack 2.0.3 → 2.0.7

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  import { fileURLToPath as d } from "node:url";
2
2
  import { dirname as i, resolve as l } from "node:path";
3
- import { existsSync as r, mkdirSync as x, copyFileSync as k, writeFileSync as u } from "node:fs";
3
+ import { existsSync as n, mkdirSync as x, copyFileSync as k, writeFileSync as u } from "node:fs";
4
4
  import { resolve as a } from "path";
5
5
  function b(t) {
6
6
  const e = i(d(t));
@@ -10,7 +10,7 @@ function b(t) {
10
10
  }
11
11
  function g(t, e) {
12
12
  e.info("TractStack configuration applied"), t.enableMultiTenant && e.info("Multi-tenant mode enabled"), t.includeExamples && e.info("Example components will be included");
13
- const c = process.env.PUBLIC_GO_BACKEND, o = process.env.PUBLIC_TENANTID;
13
+ const c = process.env.PUBLIC_GO_BACKEND, r = process.env.PUBLIC_TENANTID;
14
14
  if (!c)
15
15
  e.warn("PUBLIC_GO_BACKEND not set - this will be required at runtime");
16
16
  else
@@ -19,11 +19,11 @@ function g(t, e) {
19
19
  } catch {
20
20
  e.error(`PUBLIC_GO_BACKEND is not a valid URL: ${c}`);
21
21
  }
22
- return o ? /^[a-zA-Z0-9_-]+$/.test(o) ? e.info(`Tenant ID validated: ${o}`) : e.error(`PUBLIC_TENANTID contains invalid characters: ${o}`) : e.warn("PUBLIC_TENANTID not set - this will be required at runtime"), t;
22
+ return r ? /^[a-zA-Z0-9_-]+$/.test(r) ? e.info(`Tenant ID validated: ${r}`) : e.error(`PUBLIC_TENANTID contains invalid characters: ${r}`) : e.warn("PUBLIC_TENANTID not set - this will be required at runtime"), t;
23
23
  }
24
24
  async function w(t, e, c) {
25
25
  e.info("TractStack: Injecting template files");
26
- const o = [
26
+ const r = [
27
27
  // Core Configuration
28
28
  {
29
29
  src: t("../templates/env.example"),
@@ -564,6 +564,10 @@ async function w(t, e, c) {
564
564
  src: t("../templates/src/stores/backend.ts"),
565
565
  dest: "src/stores/backend.ts"
566
566
  },
567
+ {
568
+ src: t("../templates/src/stores/resources.ts"),
569
+ dest: "src/stores/resources.ts"
570
+ },
567
571
  // Compositor stores
568
572
  {
569
573
  src: t("../templates/src/stores/nodes.ts"),
@@ -1298,6 +1302,10 @@ async function w(t, e, c) {
1298
1302
  src: t("../templates/src/lib/session.ts"),
1299
1303
  dest: "src/lib/session.ts"
1300
1304
  },
1305
+ {
1306
+ src: t("../templates/src/lib/resources.ts"),
1307
+ dest: "src/lib/resources.ts"
1308
+ },
1301
1309
  // Client Scripts
1302
1310
  {
1303
1311
  src: t("../templates/src/client/htmx.min.js"),
@@ -2056,6 +2064,13 @@ async function w(t, e, c) {
2056
2064
  dest: "src/custom/CustomRoutes.astro",
2057
2065
  protected: !0
2058
2066
  },
2067
+ {
2068
+ src: t(
2069
+ c?.includeExamples ? "../templates/custom/with-examples/HeaderWidget.astro" : "../templates/custom/minimal/HeaderWidget.astro"
2070
+ ),
2071
+ dest: "src/custom/HeaderWidget.astro",
2072
+ protected: !0
2073
+ },
2059
2074
  {
2060
2075
  src: t("../templates/src/utils/customHelpers.ts"),
2061
2076
  dest: "src/utils/customHelpers.ts",
@@ -2099,13 +2114,13 @@ async function w(t, e, c) {
2099
2114
  }
2100
2115
  ] : []
2101
2116
  ];
2102
- for (const s of o)
2117
+ for (const s of r)
2103
2118
  try {
2104
2119
  const p = i(s.dest);
2105
- r(p) || x(p, { recursive: !0 });
2106
- const n = !s.protected && (s.dest === "tailwind.config.cjs" || s.dest.startsWith("src/components/codehooks/") || s.dest.startsWith("src/components/widgets/") || s.dest.startsWith("src/") || s.dest.startsWith("public/client/") || s.dest === ".gitignore");
2107
- if (!r(s.dest) || n)
2108
- if (r(s.src))
2120
+ n(p) || x(p, { recursive: !0 });
2121
+ const o = !s.protected && (s.dest === "tailwind.config.cjs" || s.dest.startsWith("src/components/codehooks/") || s.dest.startsWith("src/components/widgets/") || s.dest.startsWith("src/") || s.dest.startsWith("public/client/") || s.dest === ".gitignore");
2122
+ if (!n(s.dest) || o)
2123
+ if (n(s.src))
2109
2124
  k(s.src, s.dest), e.info(`Updated ${s.dest}`);
2110
2125
  else {
2111
2126
  const m = _(s.dest);
@@ -2113,8 +2128,8 @@ async function w(t, e, c) {
2113
2128
  }
2114
2129
  else s.protected ? e.info(`Protected: ${s.dest} (skipped overwrite)`) : e.info(`Skipped existing ${s.dest}`);
2115
2130
  } catch (p) {
2116
- const n = p instanceof Error ? p.message : String(p);
2117
- e.error(`Failed to create ${s.dest}: ${n}`);
2131
+ const o = p instanceof Error ? p.message : String(p);
2132
+ e.error(`Failed to create ${s.dest}: ${o}`);
2118
2133
  }
2119
2134
  }
2120
2135
  function _(t) {
@@ -2133,7 +2148,7 @@ function C(t = {}) {
2133
2148
  return {
2134
2149
  name: "astro-tractstack",
2135
2150
  hooks: {
2136
- "astro:config:setup": async ({ config: c, updateConfig: o, logger: s }) => {
2151
+ "astro:config:setup": async ({ config: c, updateConfig: r, logger: s }) => {
2137
2152
  g(t, s);
2138
2153
  const p = t.enableMultiTenant || !1;
2139
2154
  if (s.info(
@@ -2153,7 +2168,7 @@ function C(t = {}) {
2153
2168
  ), new Error(
2154
2169
  "TractStack requires an SSR adapter. Please add @astrojs/node adapter to your astro.config.mjs"
2155
2170
  );
2156
- o({
2171
+ r({
2157
2172
  vite: {
2158
2173
  define: {
2159
2174
  __TRACTSTACK_VERSION__: JSON.stringify("2.0.0-alpha.1"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-tractstack",
3
- "version": "2.0.3",
3
+ "version": "2.0.7",
4
4
  "description": "Astro integration for TractStack - redeeming the web from boring experiences",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,35 @@
1
+ ---
2
+ /*
3
+ // ========================================================================================
4
+ // CUSTOM HEADER WIDGET
5
+ // ========================================================================================
6
+ // This widget is rendered on every page above the main header. It is designed to
7
+ // fetch and display site-wide content, such as promotional banners or alerts.
8
+ //
9
+ // To activate this component:
10
+ // 1. Uncomment all the code within these '---' fences.
11
+ // 2. Define your desired resource categories in the `resourceCategories` array.
12
+ // 3. Add your Astro/HTML markup in the empty space after the closing '---' below.
13
+ // ========================================================================================
14
+
15
+ // --- UNCOMMENT BELOW TO ACTIVATE ---
16
+
17
+ // import { getHeaderResources } from '@/lib/resources';
18
+ // import type { ResourceNode } from '@/types/compositorTypes';
19
+
20
+ // export interface Props {
21
+ // tenantId: string;
22
+ // }
23
+ // const { tenantId } = Astro.props;
24
+
25
+ // // Add the slugs of the resource categories you want to fetch here.
26
+ // const resourceCategories = []; // e.g., ['promotions', 'site-alerts']
27
+
28
+ // // This function automatically fetches and caches the resources.
29
+ // const resources: ResourceNode[] =
30
+ // resourceCategories.length > 0
31
+ // ? await getHeaderResources(tenantId, resourceCategories)
32
+ // : [];
33
+
34
+ */
35
+ ---
@@ -0,0 +1,35 @@
1
+ ---
2
+ /*
3
+ // ========================================================================================
4
+ // CUSTOM HEADER WIDGET
5
+ // ========================================================================================
6
+ // This widget is rendered on every page above the main header. It is designed to
7
+ // fetch and display site-wide content, such as promotional banners or alerts.
8
+ //
9
+ // To activate this component:
10
+ // 1. Uncomment all the code within these '---' fences.
11
+ // 2. Define your desired resource categories in the `resourceCategories` array.
12
+ // 3. Add your Astro/HTML markup in the empty space after the closing '---' below.
13
+ // ========================================================================================
14
+
15
+ // --- UNCOMMENT BELOW TO ACTIVATE ---
16
+
17
+ // import { getHeaderResources } from '@/lib/resources';
18
+ // import type { ResourceNode } from '@/types/compositorTypes';
19
+
20
+ // export interface Props {
21
+ // tenantId: string;
22
+ // }
23
+ // const { tenantId } = Astro.props;
24
+
25
+ // // Add the slugs of the resource categories you want to fetch here.
26
+ // const resourceCategories = []; // e.g., ['promotions', 'site-alerts']
27
+
28
+ // // This function automatically fetches and caches the resources.
29
+ // const resources: ResourceNode[] =
30
+ // resourceCategories.length > 0
31
+ // ? await getHeaderResources(tenantId, resourceCategories)
32
+ // : [];
33
+
34
+ */
35
+ ---
@@ -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
  );
@@ -67,8 +67,20 @@ const StyleLinkConfigPanel = ({ node, config }: StyleLinkConfigPanelProps) => {
67
67
  linkNode.tagName = 'button';
68
68
  } else if (callbackPayload.startsWith('(bunnyMoment')) {
69
69
  linkNode.tagName = 'button';
70
- newButtonPayload.bunnyPayload =
71
- (tokens && preParseBunny([tokens])) ?? undefined;
70
+ if (
71
+ tokens &&
72
+ Array.isArray(tokens) &&
73
+ tokens[0] === 'bunnyMoment' &&
74
+ Array.isArray(tokens[1])
75
+ ) {
76
+ const params = tokens[1];
77
+ if (params.length === 2) {
78
+ newButtonPayload.bunnyPayload = {
79
+ videoId: String(params[0]),
80
+ t: String(params[1]),
81
+ };
82
+ }
83
+ }
72
84
  } else if (callbackPayload.startsWith('(goto')) {
73
85
  const targetUrl =
74
86
  tokens && preParseAction([tokens], slug, isContext, config);
@@ -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}
@@ -87,10 +87,11 @@ export default function ActionBuilderField({
87
87
  return;
88
88
  }
89
89
 
90
- if (command === 'identifyAs') {
90
+ if (command === 'bunnyMoment') {
91
+ onChange(trimmedParams);
92
+ } else if (command === 'identifyAs') {
91
93
  const firstSpaceIndex = trimmedParams.indexOf(' ');
92
94
  if (firstSpaceIndex === -1) {
93
- // Handle case with only beliefId and no value
94
95
  onChange(`(${command} ${trimmedParams})`);
95
96
  } else {
96
97
  const beliefId = trimmedParams.substring(0, firstSpaceIndex);
@@ -99,7 +100,6 @@ export default function ActionBuilderField({
99
100
  onChange(`(${command} ${beliefId} ${finalValue})`);
100
101
  }
101
102
  } else {
102
- // Original behavior for all other commands
103
103
  onChange(`(${command} ${trimmedParams})`);
104
104
  }
105
105
  };
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import { ClientRouter } from 'astro:transitions';
3
+ import HeaderWidget from '@/custom/HeaderWidget.astro';
3
4
  import Header from '@/components/Header.astro';
4
5
  import Footer from '@/components/Footer.astro';
5
6
  import { getBrandConfig } from '@/utils/api/brandConfig';
@@ -22,6 +23,7 @@ export interface Props {
22
23
  brandConfig?: any;
23
24
  storyfragmentId?: string;
24
25
  sessionId?: string;
26
+ description?: string;
25
27
  impressions?: ImpressionNode[];
26
28
  }
27
29
 
@@ -237,6 +239,7 @@ const enableBunny = import.meta.env.PUBLIC_ENABLE_BUNNY === 'true';
237
239
  </head>
238
240
  <body class="font-main w-full">
239
241
  <div class="overflow-hidden">
242
+ {!isStoryKeep && !isEditor && <HeaderWidget tenantId={tenantId} />}
240
243
  {
241
244
  !isEditor && (
242
245
  <Header
@@ -0,0 +1,69 @@
1
+ import { headerResourcesStore, HEADER_RESOURCES_TTL } from '@/stores/resources';
2
+ import type { ResourceNode } from '@/types/compositorTypes';
3
+
4
+ /**
5
+ * Fetches resource nodes based on categories, with server-side in-memory caching
6
+ * to prevent redundant API calls for high-traffic, site-wide components.
7
+ *
8
+ * @param tenantId The ID of the current tenant.
9
+ * @param categories An array of resource category slugs to fetch.
10
+ * @param ttl Optional. The Time-To-Live for the cache in milliseconds. Defaults to 5 minutes.
11
+ * @returns A promise that resolves to an array of ResourceNode objects.
12
+ */
13
+ export async function getHeaderResources(
14
+ tenantId: string,
15
+ categories: string[],
16
+ ttl: number = HEADER_RESOURCES_TTL
17
+ ): Promise<ResourceNode[]> {
18
+ const cache = headerResourcesStore.get();
19
+ const now = Date.now();
20
+
21
+ // If we have fresh data in the cache, return it immediately.
22
+ if (cache.data.length > 0 && now - cache.lastFetched < ttl) {
23
+ return cache.data;
24
+ }
25
+
26
+ // If no categories are requested, there's nothing to fetch.
27
+ if (!categories || categories.length === 0) {
28
+ return [];
29
+ }
30
+
31
+ const goBackend =
32
+ import.meta.env.PUBLIC_GO_BACKEND || 'http://localhost:8080';
33
+
34
+ try {
35
+ // THIS IS THE CORRECTED ENDPOINT
36
+ const response = await fetch(`${goBackend}/api/v1/nodes/resources`, {
37
+ method: 'POST',
38
+ headers: {
39
+ 'Content-Type': 'application/json',
40
+ 'X-Tenant-ID': tenantId,
41
+ },
42
+ body: JSON.stringify({ categories }),
43
+ });
44
+
45
+ if (!response.ok) {
46
+ console.error(
47
+ `Failed to fetch header resources. Status: ${response.status}`
48
+ );
49
+ // Gracefully degrade: return old data if we have it, otherwise an empty array.
50
+ return cache.data;
51
+ }
52
+
53
+ // The backend returns a payload like { resources: [...] }
54
+ const responsePayload = await response.json();
55
+ const resources: ResourceNode[] = responsePayload.resources || [];
56
+
57
+ // Update the store with the new data and timestamp.
58
+ headerResourcesStore.set({
59
+ data: resources,
60
+ lastFetched: now,
61
+ });
62
+
63
+ return resources;
64
+ } catch (error) {
65
+ console.error('Error fetching header resources:', error);
66
+ // On network error, also return stale data if available.
67
+ return cache.data;
68
+ }
69
+ }
@@ -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}
@@ -0,0 +1,17 @@
1
+ import { atom } from 'nanostores';
2
+ import type { ResourceNode } from '@/types/compositorTypes';
3
+
4
+ export interface ResourcesCache {
5
+ data: ResourceNode[];
6
+ lastFetched: number;
7
+ }
8
+
9
+ // Initialize with an empty state. This atom will live on the server and persist
10
+ // across page requests for the lifetime of the server instance.
11
+ export const headerResourcesStore = atom<ResourcesCache>({
12
+ data: [],
13
+ lastFetched: 0,
14
+ });
15
+
16
+ // Default Time-To-Live for the cache: 5 minutes in milliseconds.
17
+ export const HEADER_RESOURCES_TTL = 5 * 60 * 1000;
@@ -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);
@@ -566,6 +566,10 @@ export async function injectTemplateFiles(
566
566
  src: resolve('../templates/src/stores/backend.ts'),
567
567
  dest: 'src/stores/backend.ts',
568
568
  },
569
+ {
570
+ src: resolve('../templates/src/stores/resources.ts'),
571
+ dest: 'src/stores/resources.ts',
572
+ },
569
573
 
570
574
  // Compositor stores
571
575
  {
@@ -1322,6 +1326,10 @@ export async function injectTemplateFiles(
1322
1326
  src: resolve('../templates/src/lib/session.ts'),
1323
1327
  dest: 'src/lib/session.ts',
1324
1328
  },
1329
+ {
1330
+ src: resolve('../templates/src/lib/resources.ts'),
1331
+ dest: 'src/lib/resources.ts',
1332
+ },
1325
1333
 
1326
1334
  // Client Scripts
1327
1335
  {
@@ -2098,6 +2106,15 @@ export async function injectTemplateFiles(
2098
2106
  dest: 'src/custom/CustomRoutes.astro',
2099
2107
  protected: true,
2100
2108
  },
2109
+ {
2110
+ src: resolve(
2111
+ config?.includeExamples
2112
+ ? '../templates/custom/with-examples/HeaderWidget.astro'
2113
+ : '../templates/custom/minimal/HeaderWidget.astro'
2114
+ ),
2115
+ dest: 'src/custom/HeaderWidget.astro',
2116
+ protected: true,
2117
+ },
2101
2118
  {
2102
2119
  src: resolve('../templates/src/utils/customHelpers.ts'),
2103
2120
  dest: 'src/utils/customHelpers.ts',