astro-tractstack 2.0.0-rc.36 → 2.0.0-rc.38

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
@@ -893,6 +893,23 @@ async function w(t, e, c) {
893
893
  src: t("../templates/src/components/Fragment.astro"),
894
894
  dest: "src/components/Fragment.astro"
895
895
  },
896
+ // Search Components
897
+ {
898
+ src: t("../templates/src/components/search/SearchWrapper.tsx"),
899
+ dest: "src/components/search/SearchWrapper.tsx"
900
+ },
901
+ {
902
+ src: t("../templates/src/components/search/SearchModal.tsx"),
903
+ dest: "src/components/search/SearchModal.tsx"
904
+ },
905
+ {
906
+ src: t("../templates/src/components/search/SearchResults.tsx"),
907
+ dest: "src/components/search/SearchResults.tsx"
908
+ },
909
+ {
910
+ src: t("../templates/src/hooks/useSearch.ts"),
911
+ dest: "src/hooks/useSearch.ts"
912
+ },
896
913
  // Profile Components
897
914
  {
898
915
  src: t("../templates/src/components/profile/ProfileConsent.tsx"),
@@ -2015,6 +2032,11 @@ async function w(t, e, c) {
2015
2032
  dest: "src/custom/CustomRoutes.astro",
2016
2033
  protected: !0
2017
2034
  },
2035
+ {
2036
+ src: t("../templates/src/utils/customHelpers.ts"),
2037
+ dest: "src/utils/customHelpers.ts",
2038
+ protected: !0
2039
+ },
2018
2040
  // Example Components (Conditional)
2019
2041
  ...c?.includeExamples ? [
2020
2042
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-tractstack",
3
- "version": "2.0.0-rc.36",
3
+ "version": "2.0.0-rc.38",
4
4
  "description": "Astro integration for TractStack - redeeming the web from boring experiences",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,5 +1,7 @@
1
1
  ---
2
2
  import Menu from './Menu';
3
+ import SearchWrapper from '@/components/search/SearchWrapper';
4
+ import { getFullContentMap } from '@/stores/analytics';
3
5
  import { isAuthenticated, isAdmin, getUserRole } from '@/utils/auth';
4
6
  import ImpressionWrapper from '@/components/widgets/ImpressionWrapper';
5
7
  import type { MenuNode } from '@/types/tractstack';
@@ -33,6 +35,10 @@ const {
33
35
 
34
36
  const isHome = slug === brandConfig?.HOME_SLUG;
35
37
 
38
+ const tenantId =
39
+ Astro.locals.tenant?.id || import.meta.env.PUBLIC_TENANTID || 'default';
40
+ const fullContentMap = await getFullContentMap(tenantId);
41
+
36
42
  const getAssetPath = (configPath: string, fallback: string) => {
37
43
  // Always prioritize brandConfig values when they exist
38
44
  if (configPath && configPath !== '') {
@@ -40,11 +46,8 @@ const getAssetPath = (configPath: string, fallback: string) => {
40
46
  }
41
47
  return fallback;
42
48
  };
43
-
44
49
  const logo = getAssetPath(brandConfig?.LOGO, '/brand/logo.svg');
45
50
  const wordmark = getAssetPath(brandConfig?.WORDMARK, '/brand/wordmark.svg');
46
-
47
- // Handle empty WORDMARK_MODE by defaulting to "default"
48
51
  const wordmarkMode =
49
52
  brandConfig?.WORDMARK_MODE && brandConfig.WORDMARK_MODE !== ''
50
53
  ? brandConfig.WORDMARK_MODE
@@ -108,7 +111,6 @@ const authStatus = {
108
111
  >
109
112
  <h1 class="text-mydarkgrey truncate text-xl">{title}</h1>
110
113
  <div class="flex flex-row flex-nowrap items-center gap-x-2">
111
- {/* Home Icon */}
112
114
  {
113
115
  !isHome ? (
114
116
  <a
@@ -133,7 +135,6 @@ const authStatus = {
133
135
  ) : null
134
136
  }
135
137
 
136
- {/* StoryKeep Dashboard Icon */}
137
138
  {
138
139
  authStatus.isAdmin || authStatus.isAuthenticated ? (
139
140
  <a
@@ -158,7 +159,6 @@ const authStatus = {
158
159
  ) : null
159
160
  }
160
161
 
161
- {/* Edit Icon */}
162
162
  {
163
163
  isEditable &&
164
164
  (authStatus.isAdmin || authStatus.userRole === 'editor') ? (
@@ -185,7 +185,8 @@ const authStatus = {
185
185
  ) : null
186
186
  }
187
187
 
188
- {/* Remember Me Icon */}
188
+ <SearchWrapper contentMap={fullContentMap} client:load />
189
+
189
190
  <script is:inline define:vars={{ sessionId }}>
190
191
  function initRememberMe() {
191
192
  const consent = localStorage.getItem('tractstack_consent') === '1';
@@ -247,7 +248,6 @@ const authStatus = {
247
248
  ) : null
248
249
  }
249
250
 
250
- {/* Logout Icon - Admin/Editor Only */}
251
251
  {
252
252
  authStatus.isAdmin || authStatus.userRole === 'editor' ? (
253
253
  <a
@@ -0,0 +1,164 @@
1
+ import { useState, useEffect, useRef } from 'react';
2
+ import { Dialog } from '@ark-ui/react/dialog';
3
+ import { Portal } from '@ark-ui/react/portal';
4
+ import { MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/outline';
5
+ import { useSearch } from '@/hooks/useSearch';
6
+ import SearchResults from './SearchResults';
7
+ import type { FullContentMapItem } from '@/types/tractstack';
8
+
9
+ interface SearchModalProps {
10
+ isOpen: boolean;
11
+ onClose: () => void;
12
+ contentMap: FullContentMapItem[];
13
+ }
14
+
15
+ export default function SearchModal({
16
+ isOpen,
17
+ onClose,
18
+ contentMap,
19
+ }: SearchModalProps) {
20
+ const [query, setQuery] = useState('');
21
+ const inputRef = useRef<HTMLInputElement>(null);
22
+ const {
23
+ searchResults,
24
+ isLoading,
25
+ error,
26
+ totalResults,
27
+ executeSearch,
28
+ clearResults,
29
+ } = useSearch();
30
+
31
+ useEffect(() => {
32
+ if (isOpen && inputRef.current) {
33
+ inputRef.current.focus();
34
+ }
35
+ }, [isOpen]);
36
+
37
+ useEffect(() => {
38
+ if (!isOpen) {
39
+ setQuery('');
40
+ clearResults();
41
+ }
42
+ }, [isOpen, clearResults]);
43
+
44
+ useEffect(() => {
45
+ if (query.trim().length >= 3) {
46
+ executeSearch(query.trim());
47
+ } else {
48
+ clearResults();
49
+ }
50
+ }, [query, executeSearch, clearResults]);
51
+
52
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
53
+ setQuery(e.target.value);
54
+ };
55
+
56
+ const handleClose = () => {
57
+ setQuery('');
58
+ clearResults();
59
+ onClose();
60
+ };
61
+
62
+ const handleKeyDown = (e: React.KeyboardEvent) => {
63
+ if (e.key === 'Escape') {
64
+ handleClose();
65
+ }
66
+ };
67
+
68
+ return (
69
+ <Dialog.Root
70
+ open={isOpen}
71
+ onOpenChange={(details) => !details.open && handleClose()}
72
+ >
73
+ <Portal>
74
+ <Dialog.Backdrop className="fixed inset-0 z-50 bg-black bg-opacity-50 backdrop-blur-sm" />
75
+ <Dialog.Positioner className="fixed inset-0 z-50 flex items-start justify-center p-4 pt-16">
76
+ <Dialog.Content
77
+ className="bg-mywhite w-full max-w-5xl overflow-hidden rounded-lg shadow-2xl"
78
+ style={{ height: '80vh', display: 'flex', flexDirection: 'column' }}
79
+ >
80
+ {/* Fixed Header */}
81
+ <div
82
+ className="flex items-center gap-4 border-b border-gray-200 p-6"
83
+ style={{ flexShrink: 0 }}
84
+ >
85
+ <MagnifyingGlassIcon className="text-mydarkgrey h-6 w-6" />
86
+ <input
87
+ ref={inputRef}
88
+ type="text"
89
+ value={query}
90
+ onChange={handleInputChange}
91
+ onKeyDown={handleKeyDown}
92
+ placeholder="Search content..."
93
+ className="text-mydarkgrey flex-1 border-none bg-transparent px-4 text-xl placeholder-gray-500 outline-none"
94
+ />
95
+ <button
96
+ onClick={handleClose}
97
+ className="text-mydarkgrey hover:text-myblue rounded-lg p-2 transition-colors hover:bg-gray-100"
98
+ aria-label="Close search"
99
+ >
100
+ <XMarkIcon className="h-6 w-6" />
101
+ </button>
102
+ </div>
103
+
104
+ {/* Scrollable Content Area */}
105
+ <div className="overflow-y-auto" style={{ flex: 1 }}>
106
+ {query.length < 3 && (
107
+ <div className="p-8 text-center text-gray-500">
108
+ <MagnifyingGlassIcon className="mx-auto mb-4 h-16 w-16 text-gray-300" />
109
+ <p className="text-lg">Search across all content</p>
110
+ <p className="mt-2 text-sm">
111
+ Type at least 3 characters to search pages, context, and
112
+ resources
113
+ </p>
114
+ </div>
115
+ )}
116
+
117
+ {query.length >= 3 && isLoading && (
118
+ <div className="p-8 text-center">
119
+ <div className="border-myblue inline-block h-8 w-8 animate-spin rounded-full border-b-2"></div>
120
+ <p className="text-mydarkgrey mt-4">Searching...</p>
121
+ </div>
122
+ )}
123
+
124
+ {query.length >= 3 && error && (
125
+ <div className="p-8 text-center text-red-600">
126
+ <p>Search failed: {error}</p>
127
+ <button
128
+ onClick={() => executeSearch(query.trim())}
129
+ className="text-myblue mt-2 hover:underline"
130
+ >
131
+ Try again
132
+ </button>
133
+ </div>
134
+ )}
135
+
136
+ {query.length >= 3 &&
137
+ !isLoading &&
138
+ !error &&
139
+ totalResults === 0 && (
140
+ <div className="p-8 text-center text-gray-500">
141
+ <p className="text-lg">No results found for "{query}"</p>
142
+ <p className="mt-2 text-sm">
143
+ Try different keywords or check your spelling
144
+ </p>
145
+ </div>
146
+ )}
147
+
148
+ {query.length >= 3 &&
149
+ !isLoading &&
150
+ !error &&
151
+ totalResults > 0 && (
152
+ <SearchResults
153
+ results={searchResults}
154
+ contentMap={contentMap}
155
+ onResultClick={handleClose}
156
+ />
157
+ )}
158
+ </div>
159
+ </Dialog.Content>
160
+ </Dialog.Positioner>
161
+ </Portal>
162
+ </Dialog.Root>
163
+ );
164
+ }
@@ -0,0 +1,282 @@
1
+ import { useState, useMemo } from 'react';
2
+ import { Pagination } from '@ark-ui/react/pagination';
3
+ import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
4
+ import type { SearchResults as SearchResultsType } from '@/hooks/useSearch';
5
+ import type { FullContentMapItem } from '@/types/tractstack';
6
+ import { getResourceUrl, getResourceImage } from '@/utils/customHelpers';
7
+
8
+ interface SearchResultsProps {
9
+ results: SearchResultsType;
10
+ contentMap: FullContentMapItem[];
11
+ onResultClick: () => void;
12
+ }
13
+
14
+ interface ResultItem {
15
+ id: string;
16
+ type: 'StoryFragment' | 'ContextPane' | 'Resource';
17
+ title: string;
18
+ slug: string;
19
+ description?: string;
20
+ topics?: string[];
21
+ changed?: string;
22
+ thumbSrc?: string;
23
+ categorySlug?: string;
24
+ url: string;
25
+ imageSrc: string;
26
+ }
27
+
28
+ const ITEMS_PER_PAGE = 10;
29
+
30
+ export default function SearchResults({
31
+ results,
32
+ contentMap,
33
+ onResultClick,
34
+ }: SearchResultsProps) {
35
+ const [currentPage, setCurrentPage] = useState(1);
36
+
37
+ const allResultItems = useMemo(() => {
38
+ const items: ResultItem[] = [];
39
+
40
+ results.storyFragmentIds.forEach((id) => {
41
+ const item = contentMap.find(
42
+ (item) => item.id === id && item.type === 'StoryFragment'
43
+ );
44
+ if (item) {
45
+ items.push({
46
+ id: item.id,
47
+ type: 'StoryFragment',
48
+ title: item.title,
49
+ slug: item.slug,
50
+ description: item.description || undefined,
51
+ topics: item.topics || undefined,
52
+ changed: item.changed || undefined,
53
+ thumbSrc: item.thumbSrc || undefined,
54
+ url: `/${item.slug}`,
55
+ imageSrc: item.thumbSrc || '/static.jpg',
56
+ });
57
+ }
58
+ });
59
+
60
+ results.contextPaneIds.forEach((id) => {
61
+ const item = contentMap.find(
62
+ (item) => item.id === id && item.type === 'Pane'
63
+ );
64
+ if (item) {
65
+ items.push({
66
+ id: item.id,
67
+ type: 'ContextPane',
68
+ title: item.title,
69
+ slug: item.slug,
70
+ url: `/context/${item.slug}`,
71
+ imageSrc: '/static.jpg',
72
+ });
73
+ }
74
+ });
75
+
76
+ results.resourceIds.forEach((id) => {
77
+ const item = contentMap.find(
78
+ (item) => item.id === id && item.type === 'Resource'
79
+ );
80
+ if (item) {
81
+ const resourceUrl = getResourceUrl(item.categorySlug || '', item.slug);
82
+ const resourceImage = getResourceImage(
83
+ item.id,
84
+ item.slug,
85
+ item.categorySlug || ''
86
+ );
87
+
88
+ items.push({
89
+ id: item.id,
90
+ type: 'Resource',
91
+ title: item.title,
92
+ slug: item.slug,
93
+ categorySlug: item.categorySlug || undefined,
94
+ url: resourceUrl,
95
+ imageSrc: resourceImage,
96
+ });
97
+ }
98
+ });
99
+
100
+ return items.sort((a, b) => {
101
+ const aHasRealImage = a.imageSrc !== '/static.jpg';
102
+ const bHasRealImage = b.imageSrc !== '/static.jpg';
103
+
104
+ if (aHasRealImage && !bHasRealImage) return -1;
105
+ if (!aHasRealImage && bHasRealImage) return 1;
106
+ return 0;
107
+ });
108
+ }, [results, contentMap]);
109
+
110
+ const totalPages = Math.ceil(allResultItems.length / ITEMS_PER_PAGE);
111
+ const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
112
+ const paginatedItems = allResultItems.slice(
113
+ startIndex,
114
+ startIndex + ITEMS_PER_PAGE
115
+ );
116
+
117
+ const handlePageChange = (page: number) => {
118
+ setCurrentPage(page);
119
+ };
120
+
121
+ const getResultBadge = (type: string, categorySlug?: string) => {
122
+ switch (type) {
123
+ case 'StoryFragment':
124
+ return (
125
+ <span className="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800">
126
+ Page
127
+ </span>
128
+ );
129
+ case 'ContextPane':
130
+ return (
131
+ <span className="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-800">
132
+ Context
133
+ </span>
134
+ );
135
+ case 'Resource':
136
+ return (
137
+ <span className="inline-flex items-center rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-800">
138
+ {categorySlug || 'Resource'}
139
+ </span>
140
+ );
141
+ default:
142
+ return null;
143
+ }
144
+ };
145
+
146
+ if (allResultItems.length === 0) {
147
+ return null;
148
+ }
149
+
150
+ return (
151
+ <div className="p-6">
152
+ <div className="mb-6">
153
+ <h2 className="text-mydarkgrey text-lg font-semibold">
154
+ {allResultItems.length} result{allResultItems.length !== 1 ? 's' : ''}{' '}
155
+ found
156
+ </h2>
157
+ <p className="mt-1 text-sm text-gray-600">
158
+ Showing {startIndex + 1}-
159
+ {Math.min(startIndex + ITEMS_PER_PAGE, allResultItems.length)} of{' '}
160
+ {allResultItems.length}
161
+ </p>
162
+ </div>
163
+
164
+ <div className="mb-8 space-y-4">
165
+ {paginatedItems.map((item) => (
166
+ <div
167
+ key={item.id}
168
+ className="hover:border-myblue rounded-lg border border-gray-200 p-4 transition-colors"
169
+ >
170
+ <a href={item.url} onClick={onResultClick} className="group block">
171
+ <div className="flex items-start gap-4">
172
+ <div
173
+ className="flex-shrink-0 overflow-hidden rounded-lg bg-gray-100"
174
+ style={{ width: '120px', height: '67.5px' }}
175
+ >
176
+ <img
177
+ src={item.imageSrc}
178
+ alt={item.title}
179
+ className="h-full w-full object-cover"
180
+ style={{ width: '100%', height: '100%' }}
181
+ />
182
+ </div>
183
+
184
+ <div className="min-w-0 flex-1">
185
+ <div className="flex items-start justify-between gap-4">
186
+ <div className="flex-1">
187
+ <div className="mb-2">
188
+ <h3 className="text-mydarkgrey group-hover:text-myblue line-clamp-2 font-semibold transition-colors">
189
+ {item.title}
190
+ </h3>
191
+ </div>
192
+
193
+ {item.type === 'StoryFragment' && item.description && (
194
+ <p className="mb-2 line-clamp-2 text-sm text-gray-600">
195
+ {item.description}
196
+ </p>
197
+ )}
198
+
199
+ {item.topics && item.topics.length > 0 && (
200
+ <div className="mb-2 flex flex-wrap gap-1">
201
+ {item.topics.slice(0, 3).map((topic) => (
202
+ <span
203
+ key={topic}
204
+ className="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs text-gray-700"
205
+ >
206
+ {topic}
207
+ </span>
208
+ ))}
209
+ {item.topics.length > 3 && (
210
+ <span className="text-xs text-gray-500">
211
+ +{item.topics.length - 3} more
212
+ </span>
213
+ )}
214
+ </div>
215
+ )}
216
+
217
+ <p className="truncate text-xs text-gray-500">
218
+ {item.url}
219
+ </p>
220
+ </div>
221
+
222
+ <div className="flex-shrink-0 text-right">
223
+ <div className="mb-2">
224
+ {getResultBadge(item.type, item.categorySlug)}
225
+ </div>
226
+ </div>
227
+ </div>
228
+ </div>
229
+ </div>
230
+ </a>
231
+ </div>
232
+ ))}
233
+ </div>
234
+
235
+ {totalPages > 1 && (
236
+ <div className="flex items-center space-x-1">
237
+ <Pagination.Root
238
+ count={allResultItems.length}
239
+ pageSize={ITEMS_PER_PAGE}
240
+ page={currentPage}
241
+ onPageChange={(details) => handlePageChange(details.page)}
242
+ >
243
+ <Pagination.PrevTrigger className="text-mydarkgrey flex items-center rounded-md border border-gray-300 px-3 py-2 text-sm hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50">
244
+ <ChevronLeftIcon className="mr-1 h-4 w-4" />
245
+ Previous
246
+ </Pagination.PrevTrigger>
247
+
248
+ <Pagination.Context>
249
+ {(pagination) =>
250
+ pagination.pages.map((page, index) =>
251
+ page.type === 'page' ? (
252
+ <Pagination.Item
253
+ key={index}
254
+ value={page.value}
255
+ type="page"
256
+ className={`cursor-pointer rounded-md px-3 py-2 text-sm ${
257
+ page.value === currentPage
258
+ ? 'bg-myblue text-white'
259
+ : 'text-mydarkgrey hover:bg-gray-50'
260
+ }`}
261
+ >
262
+ {page.value}
263
+ </Pagination.Item>
264
+ ) : (
265
+ <span key={index} className="px-2 text-gray-400">
266
+ ...
267
+ </span>
268
+ )
269
+ )
270
+ }
271
+ </Pagination.Context>
272
+
273
+ <Pagination.NextTrigger className="text-mydarkgrey flex items-center rounded-md border border-gray-300 px-3 py-2 text-sm hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50">
274
+ Next
275
+ <ChevronRightIcon className="ml-1 h-4 w-4" />
276
+ </Pagination.NextTrigger>
277
+ </Pagination.Root>
278
+ </div>
279
+ )}
280
+ </div>
281
+ );
282
+ }
@@ -0,0 +1,41 @@
1
+ import { useState } from 'react';
2
+ import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
3
+ import SearchModal from './SearchModal';
4
+ import type { FullContentMapItem } from '@/types/tractstack';
5
+
6
+ interface SearchWrapperProps {
7
+ contentMap: FullContentMapItem[];
8
+ }
9
+
10
+ export default function SearchWrapper({ contentMap }: SearchWrapperProps) {
11
+ const [isSearchOpen, setIsSearchOpen] = useState(false);
12
+
13
+ const handleSearchOpen = () => {
14
+ setIsSearchOpen(true);
15
+ };
16
+
17
+ const handleSearchClose = () => {
18
+ setIsSearchOpen(false);
19
+ };
20
+
21
+ return (
22
+ <>
23
+ <button
24
+ onClick={handleSearchOpen}
25
+ className="text-myblue/80 hover:text-myblue hover:rotate-6"
26
+ title="Search content"
27
+ aria-label="Search content"
28
+ >
29
+ <MagnifyingGlassIcon className="h-6 w-6" />
30
+ </button>
31
+
32
+ {isSearchOpen && (
33
+ <SearchModal
34
+ isOpen={isSearchOpen}
35
+ onClose={handleSearchClose}
36
+ contentMap={contentMap}
37
+ />
38
+ )}
39
+ </>
40
+ );
41
+ }
@@ -0,0 +1,179 @@
1
+ import { useState, useCallback, useRef, useMemo } from 'react';
2
+ import { TractStackAPI } from '@/utils/api';
3
+
4
+ export interface SearchResults {
5
+ storyFragmentIds: string[];
6
+ contextPaneIds: string[];
7
+ resourceIds: string[];
8
+ }
9
+
10
+ interface UseSearchReturn {
11
+ searchResults: SearchResults;
12
+ isLoading: boolean;
13
+ error: string | null;
14
+ totalResults: number;
15
+ executeSearch: (query: string) => void;
16
+ clearResults: () => void;
17
+ }
18
+
19
+ const DEBOUNCE_MS = 100;
20
+ const BACKEND_THROTTLE_MS = 1000;
21
+
22
+ export function useSearch(): UseSearchReturn {
23
+ const [searchResults, setSearchResults] = useState<SearchResults>({
24
+ storyFragmentIds: [],
25
+ contextPaneIds: [],
26
+ resourceIds: [],
27
+ });
28
+ const [isLoading, setIsLoading] = useState(false);
29
+ const [error, setError] = useState<string | null>(null);
30
+
31
+ const debounceRef = useRef<NodeJS.Timeout>();
32
+ const lastSearchTimeRef = useRef<number>(0);
33
+ const queuedQueryRef = useRef<string | null>(null);
34
+ const queueTimeoutRef = useRef<NodeJS.Timeout>();
35
+ const isFirstSearchRef = useRef<boolean>(true);
36
+
37
+ const api = useMemo(() => new TractStackAPI(), []);
38
+
39
+ const performSearch = useCallback(
40
+ async (query: string) => {
41
+ if (!query.trim() || query.trim().length < 3) {
42
+ return;
43
+ }
44
+
45
+ setIsLoading(true);
46
+ setError(null);
47
+
48
+ try {
49
+ const response = await api.search(query.trim());
50
+
51
+ if (response.success && response.data) {
52
+ setSearchResults(response.data);
53
+ lastSearchTimeRef.current = Date.now();
54
+ } else {
55
+ // Handle 429 silently - backend is protecting itself
56
+ if (response.error?.includes('too frequently')) {
57
+ // Don't set error for rate limiting, just maintain loading state
58
+ return;
59
+ }
60
+ setError(response.error || 'Search failed');
61
+ setSearchResults({
62
+ storyFragmentIds: [],
63
+ contextPaneIds: [],
64
+ resourceIds: [],
65
+ });
66
+ }
67
+ } catch (err) {
68
+ setError(err instanceof Error ? err.message : 'Search failed');
69
+ setSearchResults({
70
+ storyFragmentIds: [],
71
+ contextPaneIds: [],
72
+ resourceIds: [],
73
+ });
74
+ } finally {
75
+ setIsLoading(false);
76
+ }
77
+ },
78
+ [api]
79
+ );
80
+
81
+ const processQueue = useCallback(() => {
82
+ if (queuedQueryRef.current) {
83
+ const queryToProcess = queuedQueryRef.current;
84
+ queuedQueryRef.current = null;
85
+ performSearch(queryToProcess);
86
+ }
87
+ }, [performSearch]);
88
+
89
+ const executeSearchWithThrottling = useCallback(
90
+ (query: string) => {
91
+ const now = Date.now();
92
+ const timeSinceLastSearch = now - lastSearchTimeRef.current;
93
+
94
+ // First search or enough time has passed - execute immediately
95
+ if (
96
+ isFirstSearchRef.current ||
97
+ timeSinceLastSearch >= BACKEND_THROTTLE_MS
98
+ ) {
99
+ isFirstSearchRef.current = false;
100
+ performSearch(query);
101
+ } else {
102
+ // Queue the search and schedule it to run when throttle window expires
103
+ queuedQueryRef.current = query;
104
+
105
+ // Clear any existing queue timeout
106
+ if (queueTimeoutRef.current) {
107
+ clearTimeout(queueTimeoutRef.current);
108
+ }
109
+
110
+ // Schedule execution for when the throttle window expires
111
+ const remainingTime = BACKEND_THROTTLE_MS - timeSinceLastSearch;
112
+ queueTimeoutRef.current = setTimeout(processQueue, remainingTime);
113
+ }
114
+ },
115
+ [performSearch, processQueue]
116
+ );
117
+
118
+ const executeSearch = useCallback(
119
+ (query: string) => {
120
+ // Clear existing debounce
121
+ if (debounceRef.current) {
122
+ clearTimeout(debounceRef.current);
123
+ }
124
+
125
+ // Handle empty or short queries immediately
126
+ if (!query.trim() || query.trim().length < 3) {
127
+ setSearchResults({
128
+ storyFragmentIds: [],
129
+ contextPaneIds: [],
130
+ resourceIds: [],
131
+ });
132
+ setError(null);
133
+ setIsLoading(false);
134
+ return;
135
+ }
136
+
137
+ // Debounce the actual search execution
138
+ debounceRef.current = setTimeout(() => {
139
+ executeSearchWithThrottling(query);
140
+ }, DEBOUNCE_MS);
141
+ },
142
+ [executeSearchWithThrottling]
143
+ );
144
+
145
+ const clearResults = useCallback(() => {
146
+ // Clear all timeouts
147
+ if (debounceRef.current) {
148
+ clearTimeout(debounceRef.current);
149
+ }
150
+ if (queueTimeoutRef.current) {
151
+ clearTimeout(queueTimeoutRef.current);
152
+ }
153
+
154
+ // Reset state
155
+ setSearchResults({
156
+ storyFragmentIds: [],
157
+ contextPaneIds: [],
158
+ resourceIds: [],
159
+ });
160
+ setIsLoading(false);
161
+ setError(null);
162
+ queuedQueryRef.current = null;
163
+ isFirstSearchRef.current = true;
164
+ }, []);
165
+
166
+ const totalResults =
167
+ searchResults.storyFragmentIds.length +
168
+ searchResults.contextPaneIds.length +
169
+ searchResults.resourceIds.length;
170
+
171
+ return {
172
+ searchResults,
173
+ isLoading,
174
+ error,
175
+ totalResults,
176
+ executeSearch,
177
+ clearResults,
178
+ };
179
+ }
@@ -73,6 +73,11 @@ export class TractStackAPI {
73
73
  const defaultHeaders = {
74
74
  'Content-Type': 'application/json',
75
75
  'X-Tenant-ID': this.tenantId,
76
+ ...(typeof window !== 'undefined' &&
77
+ (window as any).TRACTSTACK_CONFIG?.sessionId && {
78
+ 'X-TractStack-Session-ID': (window as any).TRACTSTACK_CONFIG
79
+ .sessionId,
80
+ }),
76
81
  };
77
82
 
78
83
  try {
@@ -133,6 +138,16 @@ export class TractStackAPI {
133
138
  });
134
139
  }
135
140
 
141
+ async search(query: string): Promise<
142
+ APIResponse<{
143
+ storyFragmentIds: string[];
144
+ contextPaneIds: string[];
145
+ resourceIds: string[];
146
+ }>
147
+ > {
148
+ return this.post('/api/v1/nodes/panes/search', { query });
149
+ }
150
+
136
151
  async getContent(slug: string): Promise<APIResponse> {
137
152
  return this.get(`/api/v1/content/${slug}`);
138
153
  }
@@ -0,0 +1,23 @@
1
+ // URL Helper: Strip category prefix from slug
2
+ // e.g., "people-bleako" -> "bleako"
3
+ export function getCleanSlug(categorySlug: string, fullSlug: string): string {
4
+ const prefix = `${categorySlug}-`;
5
+ return fullSlug.startsWith(prefix) ? fullSlug.slice(prefix.length) : fullSlug;
6
+ }
7
+
8
+ // Build proper URL for resource
9
+ // e.g., category="people", slug="people-bleako" -> "/people/bleako"
10
+ export function getResourceUrl(categorySlug: string, fullSlug: string): string {
11
+ const cleanSlug = getCleanSlug(categorySlug, fullSlug);
12
+ return `/${categorySlug}/${cleanSlug}`;
13
+ }
14
+
15
+ // Image Helper: Placeholder implementation
16
+ export function getResourceImage(
17
+ id: string,
18
+ slug: string,
19
+ category: string
20
+ ): string {
21
+ console.log(`please define getResourceImage`, id, slug, category);
22
+ return '/static.jpg';
23
+ }
@@ -906,7 +906,23 @@ export async function injectTemplateFiles(
906
906
  src: resolve('../templates/src/components/Fragment.astro'),
907
907
  dest: 'src/components/Fragment.astro',
908
908
  },
909
-
909
+ // Search Components
910
+ {
911
+ src: resolve('../templates/src/components/search/SearchWrapper.tsx'),
912
+ dest: 'src/components/search/SearchWrapper.tsx',
913
+ },
914
+ {
915
+ src: resolve('../templates/src/components/search/SearchModal.tsx'),
916
+ dest: 'src/components/search/SearchModal.tsx',
917
+ },
918
+ {
919
+ src: resolve('../templates/src/components/search/SearchResults.tsx'),
920
+ dest: 'src/components/search/SearchResults.tsx',
921
+ },
922
+ {
923
+ src: resolve('../templates/src/hooks/useSearch.ts'),
924
+ dest: 'src/hooks/useSearch.ts',
925
+ },
910
926
  // Profile Components
911
927
  {
912
928
  src: resolve('../templates/src/components/profile/ProfileConsent.tsx'),
@@ -2058,6 +2074,11 @@ export async function injectTemplateFiles(
2058
2074
  dest: 'src/custom/CustomRoutes.astro',
2059
2075
  protected: true,
2060
2076
  },
2077
+ {
2078
+ src: resolve('../templates/src/utils/customHelpers.ts'),
2079
+ dest: 'src/utils/customHelpers.ts',
2080
+ protected: true,
2081
+ },
2061
2082
 
2062
2083
  // Example Components (Conditional)
2063
2084
  ...(config?.includeExamples