astro-tractstack 2.3.3 → 2.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/bin/create-tractstack.js +5 -2
  2. package/dist/index.js +18 -0
  3. package/package.json +1 -1
  4. package/templates/custom/shopify/Cart.tsx +196 -104
  5. package/templates/custom/shopify/CartIcon.tsx +8 -8
  6. package/templates/custom/shopify/CheckoutModal.tsx +143 -66
  7. package/templates/custom/shopify/ShopifyCartManager.tsx +64 -19
  8. package/templates/custom/shopify/ShopifyCheckout.tsx +2 -26
  9. package/templates/custom/shopify/ShopifyProductGrid.tsx +8 -0
  10. package/templates/custom/shopify/ShopifyServiceList.tsx +25 -37
  11. package/templates/src/components/Header.astro +1 -1
  12. package/templates/src/components/compositor/Node.tsx +39 -9
  13. package/templates/src/components/compositor/nodes/CreativePane.tsx +175 -88
  14. package/templates/src/components/edit/pane/AddPanePanel.tsx +2 -2
  15. package/templates/src/components/edit/pane/AddPanePanel_new.tsx +6 -5
  16. package/templates/src/components/edit/pane/AddPanePanel_reuse.tsx +9 -15
  17. package/templates/src/components/edit/pane/ConfigPanePanel.tsx +1 -1
  18. package/templates/src/components/form/advanced/APIConfigSection.tsx +35 -8
  19. package/templates/src/components/form/brand/SiteConfigSection.tsx +9 -0
  20. package/templates/src/components/storykeep/Dashboard_Advanced.tsx +59 -23
  21. package/templates/src/components/storykeep/Dashboard_Shopify.tsx +257 -18
  22. package/templates/src/components/storykeep/controls/content/ProductTable.tsx +38 -24
  23. package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +161 -66
  24. package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +175 -48
  25. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +5 -2
  26. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Sales.tsx +479 -0
  27. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Search.tsx +7 -3
  28. package/templates/src/layouts/Layout.astro +26 -0
  29. package/templates/src/pages/api/auth/logout.ts +35 -2
  30. package/templates/src/pages/api/sales/list.ts +66 -0
  31. package/templates/src/pages/api/sales/metrics.ts +60 -0
  32. package/templates/src/pages/context/[...contextSlug].astro +50 -31
  33. package/templates/src/pages/storykeep/advanced.astro +4 -1
  34. package/templates/src/stores/nodes.ts +8 -0
  35. package/templates/src/types/tractstack.ts +57 -0
  36. package/templates/src/utils/api/advancedConfig.ts +2 -1
  37. package/templates/src/utils/api/advancedHelpers.ts +4 -0
  38. package/templates/src/utils/api/brandConfig.ts +2 -0
  39. package/templates/src/utils/api/brandHelpers.ts +6 -0
  40. package/templates/src/utils/api/salesHelpers.ts +21 -0
  41. package/templates/src/utils/customHelpers.ts +285 -2
  42. package/utils/inject-files.ts +18 -0
@@ -0,0 +1,479 @@
1
+ import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
2
+ import ChevronDownIcon from '@heroicons/react/24/outline/ChevronDownIcon';
3
+ import ChevronRightIcon from '@heroicons/react/24/outline/ChevronRightIcon';
4
+ import { salesHelpers } from '@/utils/api/salesHelpers';
5
+ import type { SaleEntity, SaleProductLine } from '@/types/tractstack';
6
+ import type { ResourceNode } from '@/types/compositorTypes';
7
+ import {
8
+ getServiceLinkedProduct,
9
+ isSharedFeeService,
10
+ parsePrimaryShopifyProductData,
11
+ } from '@/utils/customHelpers';
12
+
13
+ interface ShopifyDashboardSalesProps {
14
+ existingResources: ResourceNode[];
15
+ }
16
+
17
+ const ITEMS_PER_PAGE = 10;
18
+
19
+ export default function ShopifyDashboard_Sales({
20
+ existingResources,
21
+ }: ShopifyDashboardSalesProps) {
22
+ const [sales, setSales] = useState<SaleEntity[]>([]);
23
+ const [totalCount, setTotalCount] = useState(0);
24
+ const [currentPage, setCurrentPage] = useState(0);
25
+ const [isLoading, setIsLoading] = useState(true);
26
+ const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
27
+
28
+ const resourceById = useMemo(() => {
29
+ return new Map(
30
+ existingResources.map((resource) => [resource.id, resource])
31
+ );
32
+ }, [existingResources]);
33
+
34
+ const resourceBySlug = useMemo(() => {
35
+ return new Map(
36
+ existingResources.map((resource) => [resource.slug, resource])
37
+ );
38
+ }, [existingResources]);
39
+
40
+ const fetchSales = useCallback(async () => {
41
+ setIsLoading(true);
42
+ try {
43
+ const response = await salesHelpers.listSales(
44
+ ITEMS_PER_PAGE,
45
+ currentPage * ITEMS_PER_PAGE
46
+ );
47
+ setSales(response.data || []);
48
+ setTotalCount(response.totalCount || 0);
49
+ } catch (error) {
50
+ console.error('Failed to fetch sales:', error);
51
+ } finally {
52
+ setIsLoading(false);
53
+ }
54
+ }, [currentPage]);
55
+
56
+ useEffect(() => {
57
+ fetchSales();
58
+ }, [fetchSales]);
59
+
60
+ const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
61
+
62
+ const toggleExpanded = (saleId: string) => {
63
+ setExpandedRows((current) => {
64
+ const next = new Set(current);
65
+ if (next.has(saleId)) {
66
+ next.delete(saleId);
67
+ } else {
68
+ next.add(saleId);
69
+ }
70
+ return next;
71
+ });
72
+ };
73
+
74
+ const getStatusColor = (status: string) => {
75
+ if (status === 'PAID') return 'bg-green-100 text-green-800';
76
+ return 'bg-gray-100 text-gray-800';
77
+ };
78
+
79
+ const getTagColor = (tag: string) => {
80
+ switch (tag) {
81
+ case 'local-pickup':
82
+ return 'bg-cyan-100 text-cyan-800';
83
+ case 'orphan':
84
+ return 'bg-red-100 text-red-800';
85
+ case 'remote':
86
+ return 'bg-violet-100 text-violet-800';
87
+ case 'in-person':
88
+ return 'bg-slate-100 text-slate-700';
89
+ default:
90
+ return 'bg-gray-100 text-gray-700';
91
+ }
92
+ };
93
+
94
+ const formatMoney = (amount: string, currencyCode?: string) => {
95
+ const parsed = parseFloat(amount || '0');
96
+ const safeAmount = Number.isFinite(parsed) ? parsed : 0;
97
+ return `${safeAmount.toFixed(2)} ${currencyCode || 'USD'}`;
98
+ };
99
+
100
+ const formatLineTotal = (line: SaleProductLine) => {
101
+ const parsed = parseFloat(line.price || '0');
102
+ const safeAmount = Number.isFinite(parsed) ? parsed : 0;
103
+ return formatMoney(
104
+ (safeAmount * (line.quantity || 1)).toString(),
105
+ line.currencyCode
106
+ );
107
+ };
108
+
109
+ const productSummary = (sale: SaleEntity) => {
110
+ if (sale.products.length === 0) return 'No line items';
111
+ const first = sale.products[0];
112
+ const suffix =
113
+ sale.products.length > 1 ? `, +${sale.products.length - 1} more` : '';
114
+ return `${first.title} x${first.quantity || 1}${suffix}`;
115
+ };
116
+
117
+ const customerLabel = (sale: SaleEntity) => {
118
+ if (sale.leadName && sale.leadEmail) {
119
+ return `${sale.leadName} (${sale.leadEmail})`;
120
+ }
121
+ return sale.leadName || sale.leadEmail || 'Guest';
122
+ };
123
+
124
+ const variantTitle = (line: SaleProductLine) => {
125
+ const product = resourceById.get(line.resourceId);
126
+ const parsed = parsePrimaryShopifyProductData(product);
127
+ const variant = parsed?.variants?.find(
128
+ (v: any) => v?.id === line.variantId
129
+ );
130
+ return variant?.title && variant.title !== 'Default Title'
131
+ ? variant.title
132
+ : '';
133
+ };
134
+
135
+ const resolveBookingServices = (sale: SaleEntity) => {
136
+ const serviceMap = new Map<string, ResourceNode>();
137
+ const bookingResources =
138
+ sale.booking?.resourceIds
139
+ ?.map((id) => resourceById.get(id))
140
+ .filter((resource): resource is ResourceNode => Boolean(resource)) ||
141
+ [];
142
+
143
+ bookingResources.forEach((resource) => {
144
+ if (
145
+ resource.categorySlug === 'service' ||
146
+ resource.optionsPayload?.bookingLengthMinutes
147
+ ) {
148
+ serviceMap.set(resource.id, resource);
149
+ }
150
+
151
+ const boundSlug = resource.optionsPayload?.serviceBound;
152
+ if (typeof boundSlug === 'string' && boundSlug.trim()) {
153
+ const boundService = resourceBySlug.get(boundSlug);
154
+ if (boundService) {
155
+ serviceMap.set(boundService.id, boundService);
156
+ }
157
+ }
158
+ });
159
+
160
+ return Array.from(serviceMap.values()).sort((a, b) =>
161
+ a.title.localeCompare(b.title)
162
+ );
163
+ };
164
+
165
+ const renderSharedFeeBlock = (sale: SaleEntity) => {
166
+ if (!sale.booking) return null;
167
+ const services = resolveBookingServices(sale);
168
+ const sharedServices = services.filter((service) =>
169
+ isSharedFeeService(service, existingResources)
170
+ );
171
+ if (sharedServices.length === 0) return null;
172
+
173
+ const chargeLine = sale.products.find((line) => {
174
+ const product = resourceById.get(line.resourceId);
175
+ return product?.optionsPayload?.sharedServiceFee === true;
176
+ });
177
+
178
+ return (
179
+ <div className="rounded-md border border-cyan-100 bg-cyan-50 p-3">
180
+ <div className="text-xs font-bold uppercase text-cyan-700">
181
+ Shared service charge
182
+ </div>
183
+ <ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-cyan-950">
184
+ {sharedServices.map((service) => (
185
+ <li key={service.id}>{service.title}</li>
186
+ ))}
187
+ </ul>
188
+ {chargeLine && (
189
+ <div className="mt-3 border-t border-cyan-200 pt-2 text-sm font-bold text-cyan-950">
190
+ Charge: {formatLineTotal(chargeLine)}
191
+ </div>
192
+ )}
193
+ </div>
194
+ );
195
+ };
196
+
197
+ const renderNonSharedServices = (sale: SaleEntity) => {
198
+ if (!sale.booking) return null;
199
+ const services = resolveBookingServices(sale).filter(
200
+ (service) => !isSharedFeeService(service, existingResources)
201
+ );
202
+ if (services.length === 0) return null;
203
+
204
+ return (
205
+ <div className="rounded-md border border-gray-200 bg-white p-3">
206
+ <div className="text-xs font-bold uppercase text-gray-500">
207
+ Service charges
208
+ </div>
209
+ <div className="mt-2 space-y-2">
210
+ {services.map((service) => {
211
+ const product = getServiceLinkedProduct(service, existingResources);
212
+ const line = sale.products.find(
213
+ (candidate) => candidate.gid === product?.optionsPayload?.gid
214
+ );
215
+ return (
216
+ <div
217
+ key={service.id}
218
+ className="flex justify-between gap-4 text-sm text-gray-700"
219
+ >
220
+ <span>{service.title}</span>
221
+ {line && (
222
+ <span className="font-bold">{formatLineTotal(line)}</span>
223
+ )}
224
+ </div>
225
+ );
226
+ })}
227
+ </div>
228
+ </div>
229
+ );
230
+ };
231
+
232
+ const renderAppointment = (sale: SaleEntity) => {
233
+ if (!sale.booking) return null;
234
+ const services = resolveBookingServices(sale);
235
+ return (
236
+ <div className="rounded-md border border-gray-200 bg-white p-3">
237
+ <div className="text-xs font-bold uppercase text-gray-500">
238
+ Appointment
239
+ </div>
240
+ <div className="mt-2 grid gap-2 text-sm text-gray-700 md:grid-cols-2">
241
+ <div>
242
+ <span className="font-bold">Status:</span> {sale.booking.status}
243
+ </div>
244
+ <div>
245
+ <span className="font-bold">Mode:</span>{' '}
246
+ {sale.booking.appointmentMode || 'IN_PERSON'}
247
+ </div>
248
+ <div>
249
+ <span className="font-bold">Date:</span>{' '}
250
+ {new Date(sale.booking.startTime).toLocaleDateString()}
251
+ </div>
252
+ <div>
253
+ <span className="font-bold">Time:</span>{' '}
254
+ {new Date(sale.booking.startTime).toLocaleTimeString([], {
255
+ hour: '2-digit',
256
+ minute: '2-digit',
257
+ })}{' '}
258
+ -{' '}
259
+ {new Date(sale.booking.endTime).toLocaleTimeString([], {
260
+ hour: '2-digit',
261
+ minute: '2-digit',
262
+ })}
263
+ </div>
264
+ <div>
265
+ <span className="font-bold">Google sync:</span>{' '}
266
+ {sale.booking.googleSyncStatus || 'NOT_SYNCED'}
267
+ </div>
268
+ <div>
269
+ <span className="font-bold">Customer:</span> {customerLabel(sale)}
270
+ </div>
271
+ </div>
272
+ {services.length > 0 && (
273
+ <div className="mt-3 text-sm text-gray-700">
274
+ <span className="font-bold">Services:</span>{' '}
275
+ {services.map((service) => service.title).join(', ')}
276
+ </div>
277
+ )}
278
+ {sale.booking.googleMeetURL && (
279
+ <a
280
+ className="mt-2 inline-block text-sm text-cyan-700 underline"
281
+ href={sale.booking.googleMeetURL}
282
+ target="_blank"
283
+ rel="noreferrer"
284
+ >
285
+ Meet link
286
+ </a>
287
+ )}
288
+ </div>
289
+ );
290
+ };
291
+
292
+ const renderExpandedRow = (sale: SaleEntity) => (
293
+ <tr>
294
+ <td colSpan={5} className="bg-gray-50 px-6 py-4">
295
+ <div className="space-y-4">
296
+ <div className="rounded-md border border-gray-200 bg-white p-3">
297
+ <div className="text-xs font-bold uppercase text-gray-500">
298
+ Products
299
+ </div>
300
+ <div className="mt-2 divide-y divide-gray-100">
301
+ {sale.products.map((line) => {
302
+ const product = resourceById.get(line.resourceId);
303
+ const variant = variantTitle(line);
304
+ return (
305
+ <div
306
+ key={`${line.resourceId}-${line.variantId}`}
307
+ className="flex flex-wrap items-center justify-between gap-3 py-2 text-sm"
308
+ >
309
+ <div>
310
+ <div className="font-bold text-gray-900">
311
+ {line.title || product?.title || 'Product'}
312
+ </div>
313
+ {variant && (
314
+ <div className="text-xs text-gray-500">{variant}</div>
315
+ )}
316
+ {line.isLocalPickup && (
317
+ <span className="mt-1 inline-flex rounded-full bg-cyan-100 px-2 py-0.5 text-xs font-bold text-cyan-800">
318
+ Local pickup
319
+ </span>
320
+ )}
321
+ </div>
322
+ <div className="text-right">
323
+ <div className="font-bold text-gray-900">
324
+ {formatLineTotal(line)}
325
+ </div>
326
+ <div className="text-xs text-gray-500">
327
+ Qty {line.quantity || 1}
328
+ </div>
329
+ </div>
330
+ </div>
331
+ );
332
+ })}
333
+ </div>
334
+ </div>
335
+ {renderSharedFeeBlock(sale)}
336
+ {renderNonSharedServices(sale)}
337
+ {renderAppointment(sale)}
338
+ {sale.tags.includes('orphan') && (
339
+ <div className="rounded-md border border-red-200 bg-red-50 p-3 text-sm font-bold text-red-800">
340
+ Orphaned payment: appointment payment received with no active
341
+ booking row.
342
+ </div>
343
+ )}
344
+ </div>
345
+ </td>
346
+ </tr>
347
+ );
348
+
349
+ return (
350
+ <div className="space-y-4">
351
+ <div className="overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
352
+ <table className="min-w-full divide-y divide-gray-200">
353
+ <thead className="bg-gray-50">
354
+ <tr>
355
+ <th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
356
+ Sale
357
+ </th>
358
+ <th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
359
+ Status / Tags
360
+ </th>
361
+ <th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
362
+ Customer
363
+ </th>
364
+ <th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
365
+ Products
366
+ </th>
367
+ <th className="px-6 py-3 text-right text-xs font-bold uppercase tracking-wider text-gray-500">
368
+ Total
369
+ </th>
370
+ </tr>
371
+ </thead>
372
+ <tbody className="divide-y divide-gray-200 bg-white">
373
+ {isLoading ? (
374
+ <tr>
375
+ <td colSpan={5} className="py-12 text-center">
376
+ <div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-cyan-600" />
377
+ </td>
378
+ </tr>
379
+ ) : sales.length === 0 ? (
380
+ <tr>
381
+ <td colSpan={5} className="py-12 text-center text-gray-500">
382
+ No sales found.
383
+ </td>
384
+ </tr>
385
+ ) : (
386
+ sales.map((sale) => {
387
+ const expanded = expandedRows.has(sale.id);
388
+ return (
389
+ <Fragment key={sale.id}>
390
+ <tr className="hover:bg-gray-50">
391
+ <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
392
+ <button
393
+ onClick={() => toggleExpanded(sale.id)}
394
+ className="inline-flex items-center gap-2 font-bold text-gray-900"
395
+ >
396
+ {expanded ? (
397
+ <ChevronDownIcon className="h-4 w-4" />
398
+ ) : (
399
+ <ChevronRightIcon className="h-4 w-4" />
400
+ )}
401
+ {new Date(sale.createdAt).toLocaleDateString()}
402
+ </button>
403
+ <div className="mt-1">
404
+ <a
405
+ href={`https://admin.shopify.com/orders/${sale.shopifyOrderId}`}
406
+ target="_blank"
407
+ rel="noopener noreferrer"
408
+ className="text-xs font-bold text-cyan-600 hover:text-cyan-800 hover:underline"
409
+ >
410
+ Order #{sale.shopifyOrderId}
411
+ </a>
412
+ </div>
413
+ </td>
414
+ <td className="px-6 py-4 text-xs">
415
+ <div className="flex flex-col gap-1">
416
+ <span
417
+ className={`inline-flex w-fit items-center rounded-full px-2 py-0.5 font-bold ${getStatusColor(
418
+ sale.status
419
+ )}`}
420
+ >
421
+ {sale.status}
422
+ </span>
423
+ <div className="flex flex-wrap gap-1">
424
+ {sale.tags.map((tag) => (
425
+ <span
426
+ key={tag}
427
+ className={`inline-flex w-fit items-center rounded-full px-2 py-0.5 font-bold ${getTagColor(
428
+ tag
429
+ )}`}
430
+ >
431
+ {tag}
432
+ </span>
433
+ ))}
434
+ </div>
435
+ </div>
436
+ </td>
437
+ <td className="px-6 py-4 text-sm text-gray-500">
438
+ {customerLabel(sale)}
439
+ </td>
440
+ <td className="px-6 py-4 text-sm text-gray-900">
441
+ {productSummary(sale)}
442
+ </td>
443
+ <td className="whitespace-nowrap px-6 py-4 text-right text-sm font-bold text-gray-900">
444
+ {formatMoney(sale.totalAmount)}
445
+ </td>
446
+ </tr>
447
+ {expanded && renderExpandedRow(sale)}
448
+ </Fragment>
449
+ );
450
+ })
451
+ )}
452
+ </tbody>
453
+ </table>
454
+ </div>
455
+
456
+ {totalPages > 1 && (
457
+ <div className="flex justify-center gap-2 pt-4">
458
+ <button
459
+ onClick={() => setCurrentPage((page) => page - 1)}
460
+ disabled={currentPage === 0 || isLoading}
461
+ className="rounded border border-gray-300 bg-white px-3 py-1 text-sm shadow-sm hover:bg-gray-50 disabled:opacity-50"
462
+ >
463
+ Previous
464
+ </button>
465
+ <span className="flex items-center text-sm text-gray-600">
466
+ Page {currentPage + 1} of {totalPages}
467
+ </span>
468
+ <button
469
+ onClick={() => setCurrentPage((page) => page + 1)}
470
+ disabled={currentPage === totalPages - 1 || isLoading}
471
+ className="rounded border border-gray-300 bg-white px-3 py-1 text-sm shadow-sm hover:bg-gray-50 disabled:opacity-50"
472
+ >
473
+ Next
474
+ </button>
475
+ </div>
476
+ )}
477
+ </div>
478
+ );
479
+ }
@@ -7,19 +7,22 @@ import {
7
7
  } from '@/stores/shopify';
8
8
  import ProductTable from '@/components/storykeep/controls/content/ProductTable';
9
9
  import type { ResourceNode } from '@/types/compositorTypes';
10
+ import type { ShopifyLinkedStatus } from '@/components/storykeep/Dashboard_Shopify';
10
11
 
11
12
  interface ShopifyDashboardSearchProps {
12
- linkedResourceMap: Map<string, ResourceNode>;
13
+ linkedStatusMap: Map<string, ShopifyLinkedStatus>;
13
14
  onSelectProduct: (product: ShopifyProduct) => void;
14
15
  onLink: (product: ShopifyProduct) => void;
16
+ onMarkShared: (product: ShopifyProduct) => void;
15
17
  onUnlink: (resourceId: string) => void;
16
18
  onEdit: (product: ShopifyProduct, resource: ResourceNode) => void;
17
19
  }
18
20
 
19
21
  export default function ShopifyDashboard_Search({
20
- linkedResourceMap,
22
+ linkedStatusMap,
21
23
  onSelectProduct,
22
24
  onLink,
25
+ onMarkShared,
23
26
  onUnlink,
24
27
  onEdit,
25
28
  }: ShopifyDashboardSearchProps) {
@@ -42,11 +45,12 @@ export default function ShopifyDashboard_Search({
42
45
 
43
46
  <ProductTable
44
47
  products={data.products}
45
- linkedResourceMap={linkedResourceMap}
48
+ linkedStatusMap={linkedStatusMap}
46
49
  onRefresh={handleRefresh}
47
50
  isRefreshing={status.isLoading}
48
51
  onSelectProduct={onSelectProduct}
49
52
  onLink={onLink}
53
+ onMarkShared={onMarkShared}
50
54
  onUnlink={onUnlink}
51
55
  onEdit={onEdit}
52
56
  />
@@ -303,6 +303,27 @@ const enableBunny = import.meta.env.PUBLIC_ENABLE_BUNNY === 'true';
303
303
  <script is:inline is:persist>
304
304
  let navProgressInterval = null;
305
305
  let navSafetyTimeout = null;
306
+ const IN_SITE_FROM_KEY = 'tractstack:inSiteFrom';
307
+ let isInSiteNavigationTrackingBound = false;
308
+
309
+ const navEntry = performance.getEntriesByType('navigation')[0];
310
+ if (navEntry?.type === 'navigate' && history.length <= 1) {
311
+ sessionStorage.removeItem(IN_SITE_FROM_KEY);
312
+ }
313
+
314
+ function setupInSiteNavigationTracking() {
315
+ if (isInSiteNavigationTrackingBound) {
316
+ return;
317
+ }
318
+
319
+ document.addEventListener('astro:before-preparation', () => {
320
+ sessionStorage.setItem(
321
+ IN_SITE_FROM_KEY,
322
+ location.pathname + location.search + location.hash
323
+ );
324
+ });
325
+ isInSiteNavigationTrackingBound = true;
326
+ }
306
327
 
307
328
  function startNavLoadingAnimation() {
308
329
  const loadingIndicator = document.getElementById('loading-indicator');
@@ -402,8 +423,13 @@ const enableBunny = import.meta.env.PUBLIC_ENABLE_BUNNY === 'true';
402
423
 
403
424
  if (document.readyState === 'loading') {
404
425
  document.addEventListener('DOMContentLoaded', setupNavigationLoading);
426
+ document.addEventListener(
427
+ 'DOMContentLoaded',
428
+ setupInSiteNavigationTracking
429
+ );
405
430
  } else {
406
431
  setupNavigationLoading();
432
+ setupInSiteNavigationTracking();
407
433
  }
408
434
 
409
435
  document.addEventListener('astro:page-load', setupNavigationLoading);
@@ -1,6 +1,9 @@
1
1
  import type { APIRoute } from '@/types/astro';
2
2
 
3
- export const POST: APIRoute = async ({ cookies, url }) => {
3
+ export const POST: APIRoute = async ({ cookies, url, locals }) => {
4
+ const tenantId =
5
+ locals.tenant?.id || import.meta.env.PUBLIC_TENANTID || 'default';
6
+
4
7
  try {
5
8
  const isLocalhost =
6
9
  url.hostname === 'localhost' || url.hostname === '127.0.0.1';
@@ -15,6 +18,36 @@ export const POST: APIRoute = async ({ cookies, url }) => {
15
18
  cookies.delete('admin_auth', cookieOptions);
16
19
  cookies.delete('editor_auth', cookieOptions);
17
20
 
21
+ const responseHeaders = new Headers({
22
+ 'Content-Type': 'application/json',
23
+ });
24
+
25
+ const backendUrl =
26
+ import.meta.env.PUBLIC_GO_BACKEND || 'http://localhost:8080';
27
+
28
+ const controller = new AbortController();
29
+ const timeoutId = setTimeout(() => controller.abort(), 10000);
30
+
31
+ try {
32
+ const backendResponse = await fetch(`${backendUrl}/api/v1/auth/logout`, {
33
+ method: 'POST',
34
+ headers: {
35
+ 'Content-Type': 'application/json',
36
+ 'X-Tenant-ID': tenantId,
37
+ },
38
+ signal: controller.signal,
39
+ });
40
+
41
+ clearTimeout(timeoutId);
42
+
43
+ for (const setCookie of backendResponse.headers.getSetCookie()) {
44
+ responseHeaders.append('Set-Cookie', setCookie);
45
+ }
46
+ } catch (fetchError) {
47
+ clearTimeout(timeoutId);
48
+ console.error('Logout backend request failed:', fetchError);
49
+ }
50
+
18
51
  return new Response(
19
52
  JSON.stringify({
20
53
  success: true,
@@ -22,7 +55,7 @@ export const POST: APIRoute = async ({ cookies, url }) => {
22
55
  }),
23
56
  {
24
57
  status: 200,
25
- headers: { 'Content-Type': 'application/json' },
58
+ headers: responseHeaders,
26
59
  }
27
60
  );
28
61
  } catch (error) {
@@ -0,0 +1,66 @@
1
+ import type { APIRoute } from '@/types/astro';
2
+ import { getAdminToken } from '@/utils/auth';
3
+
4
+ export const GET: APIRoute = async (context) => {
5
+ const { request, locals } = context;
6
+ const GO_BACKEND =
7
+ import.meta.env.PUBLIC_GO_BACKEND || 'http://localhost:8080';
8
+
9
+ try {
10
+ const url = new URL(request.url);
11
+ const searchParams = url.searchParams;
12
+
13
+ const controller = new AbortController();
14
+ const timeoutId = setTimeout(() => controller.abort(), 10000);
15
+ const tenantId =
16
+ locals.tenant?.id || import.meta.env.PUBLIC_TENANTID || 'default';
17
+ const token = getAdminToken(context);
18
+
19
+ try {
20
+ const response = await fetch(
21
+ `${GO_BACKEND}/api/v1/sales/list?${searchParams.toString()}`,
22
+ {
23
+ method: 'GET',
24
+ headers: {
25
+ 'Content-Type': 'application/json',
26
+ 'X-Tenant-ID': tenantId,
27
+ ...(token && { Authorization: `Bearer ${token}` }),
28
+ ...(request.headers.get('Authorization') && {
29
+ Authorization: request.headers.get('Authorization')!,
30
+ }),
31
+ },
32
+ signal: controller.signal,
33
+ }
34
+ );
35
+
36
+ clearTimeout(timeoutId);
37
+ const data = await response.json();
38
+
39
+ return new Response(JSON.stringify(data), {
40
+ status: response.status,
41
+ headers: { 'Content-Type': 'application/json' },
42
+ });
43
+ } catch (fetchError) {
44
+ clearTimeout(timeoutId);
45
+ if (fetchError instanceof Error && fetchError.name === 'AbortError') {
46
+ return new Response(
47
+ JSON.stringify({
48
+ success: false,
49
+ error: 'Sales list lookup timeout',
50
+ }),
51
+ { status: 408, headers: { 'Content-Type': 'application/json' } }
52
+ );
53
+ }
54
+ throw fetchError;
55
+ }
56
+ } catch (error) {
57
+ console.error('Sales list API proxy error:', error);
58
+ return new Response(
59
+ JSON.stringify({
60
+ success: false,
61
+ error: 'Failed to connect to backend service',
62
+ }),
63
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
64
+ );
65
+ }
66
+ };