astro-tractstack 2.3.0 → 2.3.2

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 (95) hide show
  1. package/README.md +1 -1
  2. package/bin/create-tractstack.js +2 -2
  3. package/dist/index.js +130 -19
  4. package/package.json +2 -2
  5. package/templates/custom/minimal/CodeHook.astro +10 -2
  6. package/templates/custom/shopify/Cart.tsx +115 -77
  7. package/templates/custom/shopify/CheckoutModal.tsx +509 -120
  8. package/templates/custom/shopify/NativeBookingCalendar.tsx +375 -0
  9. package/templates/custom/shopify/ShopifyCartManager.tsx +91 -45
  10. package/templates/custom/shopify/ShopifyCheckout.tsx +4 -33
  11. package/templates/custom/shopify/ShopifyProductGrid.tsx +170 -176
  12. package/templates/custom/shopify/ShopifyServiceList.tsx +112 -51
  13. package/templates/custom/with-examples/CodeHook.astro +10 -2
  14. package/templates/src/components/Footer.astro +6 -6
  15. package/templates/src/components/Header.astro +23 -11
  16. package/templates/src/components/Menu.tsx +157 -135
  17. package/templates/src/components/codehooks/BunnyVideoSetup.tsx +2 -2
  18. package/templates/src/components/codehooks/EpinetDurationSelector.tsx +27 -6
  19. package/templates/src/components/codehooks/EpinetTableView.tsx +153 -112
  20. package/templates/src/components/codehooks/EpinetWrapper.tsx +4 -1
  21. package/templates/src/components/codehooks/FeaturedArticleSetup.tsx +8 -1
  22. package/templates/src/components/codehooks/ProductCardSetup.tsx +9 -1
  23. package/templates/src/components/codehooks/ProductGridSetup.tsx +9 -1
  24. package/templates/src/components/compositor/nodes/BgPaneWrapper.tsx +2 -1
  25. package/templates/src/components/compositor/nodes/GhostInsertBlock.tsx +1 -1
  26. package/templates/src/components/edit/ToolBar.tsx +2 -1
  27. package/templates/src/components/edit/context/ContextPaneConfig_slug.tsx +2 -2
  28. package/templates/src/components/edit/pane/AddPanePanel_codehook.tsx +13 -0
  29. package/templates/src/components/edit/pane/AddPanePanel_new.tsx +3 -3
  30. package/templates/src/components/edit/pane/AddPanePanel_newCustomCopy.tsx +2 -2
  31. package/templates/src/components/edit/pane/AiRestylePaneModal.tsx +2 -2
  32. package/templates/src/components/edit/pane/ConfigPanePanel.tsx +1 -1
  33. package/templates/src/components/edit/pane/steps/AiCreativeDesignStep.tsx +2 -2
  34. package/templates/src/components/edit/pane/steps/AiLibraryCopyStep.tsx +3 -3
  35. package/templates/src/components/edit/pane/steps/AiRefineDesignStep.tsx +2 -2
  36. package/templates/src/components/edit/pane/steps/AiStandardDesignStep.tsx +7 -7
  37. package/templates/src/components/edit/state/SaveModal.tsx +1 -1
  38. package/templates/src/components/edit/widgets/InteractiveDisclosureWidget.tsx +8 -3
  39. package/templates/src/components/form/DateTimeInput.tsx +10 -3
  40. package/templates/src/components/form/FileUpload.tsx +11 -5
  41. package/templates/src/components/form/NumberInput.tsx +2 -2
  42. package/templates/src/components/form/advanced/APIConfigSection.tsx +208 -2
  43. package/templates/src/components/form/brand/SiteConfigSection.tsx +10 -0
  44. package/templates/src/components/form/shopify/SchedulingSection.tsx +354 -0
  45. package/templates/src/components/storykeep/Dashboard.tsx +1 -1
  46. package/templates/src/components/storykeep/Dashboard_Shopify.tsx +252 -110
  47. package/templates/src/components/storykeep/controls/content/BeliefForm.tsx +2 -2
  48. package/templates/src/components/storykeep/controls/content/BeliefTable.tsx +14 -5
  49. package/templates/src/components/storykeep/controls/content/KnownResourceTable.tsx +5 -2
  50. package/templates/src/components/storykeep/controls/content/MenuTable.tsx +14 -5
  51. package/templates/src/components/storykeep/controls/content/ProductTable.tsx +180 -101
  52. package/templates/src/components/storykeep/controls/content/ResourceBulkIngest.tsx +88 -56
  53. package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +14 -4
  54. package/templates/src/components/storykeep/controls/content/StoryFragmentTable.tsx +14 -5
  55. package/templates/src/components/storykeep/email-builder/Blocks.tsx +169 -0
  56. package/templates/src/components/storykeep/email-builder/EmailBuilder.tsx +223 -0
  57. package/templates/src/components/storykeep/email-builder/PreviewModal.tsx +136 -0
  58. package/templates/src/components/storykeep/email-builder/PropertyPanel.tsx +154 -0
  59. package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +104 -0
  60. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +419 -0
  61. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Emails.tsx +105 -0
  62. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Products.tsx +46 -0
  63. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Schedule.tsx +78 -0
  64. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Search.tsx +55 -0
  65. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Services.tsx +47 -0
  66. package/templates/src/layouts/Layout.astro +8 -5
  67. package/templates/src/pages/api/auth/lookup-lead.ts +72 -0
  68. package/templates/src/pages/api/booking/availability.ts +72 -0
  69. package/templates/src/pages/api/booking/cancel.ts +73 -0
  70. package/templates/src/pages/api/booking/confirm.ts +82 -0
  71. package/templates/src/pages/api/booking/hold.ts +75 -0
  72. package/templates/src/pages/api/booking/list.ts +66 -0
  73. package/templates/src/pages/api/booking/metrics.ts +60 -0
  74. package/templates/src/pages/api/booking/release.ts +76 -0
  75. package/templates/src/pages/api/sandbox.ts +2 -2
  76. package/templates/src/pages/api/shopify/createCart.ts +4 -8
  77. package/templates/src/pages/api/shopify/getProducts.ts +15 -15
  78. package/templates/src/pages/storykeep/login.astro +21 -14
  79. package/templates/src/stores/shopify.ts +97 -25
  80. package/templates/src/types/formTypes.ts +4 -2
  81. package/templates/src/types/tractstack.ts +59 -2
  82. package/templates/src/utils/api/advancedConfig.ts +2 -0
  83. package/templates/src/utils/api/advancedHelpers.ts +40 -3
  84. package/templates/src/utils/api/bookingHelpers.ts +125 -0
  85. package/templates/src/utils/api/brandConfig.ts +2 -0
  86. package/templates/src/utils/api/brandHelpers.ts +26 -0
  87. package/templates/src/utils/api/emailHelpers.ts +105 -0
  88. package/templates/src/utils/auth.ts +29 -9
  89. package/templates/src/utils/compositor/aiGeneration.ts +3 -3
  90. package/templates/src/utils/compositor/aiPaneParser.ts +2 -2
  91. package/templates/src/utils/customHelpers.ts +0 -21
  92. package/templates/src/utils/profileStorage.ts +5 -0
  93. package/templates/src/utils/tenantResolver.ts +3 -2
  94. package/utils/inject-files.ts +116 -5
  95. package/templates/custom/shopify/CalDotComBooking.tsx +0 -44
@@ -0,0 +1,75 @@
1
+ import type { APIRoute } from '@/types/astro';
2
+
3
+ export const POST: APIRoute = async ({ request, locals }) => {
4
+ const GO_BACKEND =
5
+ import.meta.env.PUBLIC_GO_BACKEND || 'http://localhost:8080';
6
+
7
+ try {
8
+ const body = await request.text();
9
+
10
+ const controller = new AbortController();
11
+ const timeoutId = setTimeout(() => controller.abort(), 30000);
12
+ const tenantId =
13
+ locals.tenant?.id || import.meta.env.PUBLIC_TENANTID || 'default';
14
+
15
+ try {
16
+ const response = await fetch(`${GO_BACKEND}/api/v1/bookings/hold`, {
17
+ method: 'POST',
18
+ headers: {
19
+ 'Content-Type': 'application/json',
20
+ 'X-Tenant-ID': tenantId,
21
+ ...(request.headers.get('Authorization') && {
22
+ Authorization: request.headers.get('Authorization')!,
23
+ }),
24
+ },
25
+ body: body,
26
+ signal: controller.signal,
27
+ });
28
+
29
+ clearTimeout(timeoutId);
30
+
31
+ const data = await response.json();
32
+
33
+ return new Response(JSON.stringify(data), {
34
+ status: response.status,
35
+ headers: {
36
+ 'Content-Type': 'application/json',
37
+ },
38
+ });
39
+ } catch (fetchError) {
40
+ clearTimeout(timeoutId);
41
+
42
+ if (fetchError instanceof Error && fetchError.name === 'AbortError') {
43
+ console.error('Hold-slot request timeout');
44
+ return new Response(
45
+ JSON.stringify({
46
+ success: false,
47
+ error: 'Request timeout - please try again',
48
+ }),
49
+ {
50
+ status: 408,
51
+ headers: {
52
+ 'Content-Type': 'application/json',
53
+ },
54
+ }
55
+ );
56
+ }
57
+ throw fetchError;
58
+ }
59
+ } catch (error) {
60
+ console.error('Hold-slot API proxy error:', error);
61
+
62
+ return new Response(
63
+ JSON.stringify({
64
+ success: false,
65
+ error: 'Failed to connect to backend service',
66
+ }),
67
+ {
68
+ status: 500,
69
+ headers: {
70
+ 'Content-Type': 'application/json',
71
+ },
72
+ }
73
+ );
74
+ }
75
+ };
@@ -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/bookings/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: 'Booking list lookup timeout',
50
+ }),
51
+ { status: 408, headers: { 'Content-Type': 'application/json' } }
52
+ );
53
+ }
54
+ throw fetchError;
55
+ }
56
+ } catch (error) {
57
+ console.error('Booking 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
+ };
@@ -0,0 +1,60 @@
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 controller = new AbortController();
11
+ const timeoutId = setTimeout(() => controller.abort(), 10000);
12
+ const tenantId =
13
+ locals.tenant?.id || import.meta.env.PUBLIC_TENANTID || 'default';
14
+ const token = getAdminToken(context);
15
+
16
+ try {
17
+ const response = await fetch(`${GO_BACKEND}/api/v1/bookings/metrics`, {
18
+ method: 'GET',
19
+ headers: {
20
+ 'Content-Type': 'application/json',
21
+ 'X-Tenant-ID': tenantId,
22
+ ...(token && { Authorization: `Bearer ${token}` }),
23
+ ...(request.headers.get('Authorization') && {
24
+ Authorization: request.headers.get('Authorization')!,
25
+ }),
26
+ },
27
+ signal: controller.signal,
28
+ });
29
+
30
+ clearTimeout(timeoutId);
31
+ const data = await response.json();
32
+
33
+ return new Response(JSON.stringify(data), {
34
+ status: response.status,
35
+ headers: { 'Content-Type': 'application/json' },
36
+ });
37
+ } catch (fetchError) {
38
+ clearTimeout(timeoutId);
39
+ if (fetchError instanceof Error && fetchError.name === 'AbortError') {
40
+ return new Response(
41
+ JSON.stringify({
42
+ success: false,
43
+ error: 'Booking metrics lookup timeout',
44
+ }),
45
+ { status: 408, headers: { 'Content-Type': 'application/json' } }
46
+ );
47
+ }
48
+ throw fetchError;
49
+ }
50
+ } catch (error) {
51
+ console.error('Booking metrics API proxy error:', error);
52
+ return new Response(
53
+ JSON.stringify({
54
+ success: false,
55
+ error: 'Failed to connect to backend service',
56
+ }),
57
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
58
+ );
59
+ }
60
+ };
@@ -0,0 +1,76 @@
1
+ import type { APIRoute } from '@/types/astro';
2
+
3
+ export const POST: APIRoute = async ({ request, locals }) => {
4
+ const GO_BACKEND =
5
+ import.meta.env.PUBLIC_GO_BACKEND || 'http://localhost:8080';
6
+
7
+ try {
8
+ const { traceId } = await request.json();
9
+
10
+ if (!traceId) {
11
+ return new Response(JSON.stringify({ error: 'traceId is required' }), {
12
+ status: 400,
13
+ headers: { 'Content-Type': 'application/json' },
14
+ });
15
+ }
16
+
17
+ const controller = new AbortController();
18
+ const timeoutId = setTimeout(() => controller.abort(), 10000);
19
+ const tenantId =
20
+ locals.tenant?.id || import.meta.env.PUBLIC_TENANTID || 'default';
21
+
22
+ try {
23
+ const response = await fetch(
24
+ `${GO_BACKEND}/api/v1/bookings/hold/${traceId}`,
25
+ {
26
+ method: 'DELETE',
27
+ headers: {
28
+ 'X-Tenant-ID': tenantId,
29
+ ...(request.headers.get('Authorization') && {
30
+ Authorization: request.headers.get('Authorization')!,
31
+ }),
32
+ },
33
+ signal: controller.signal,
34
+ }
35
+ );
36
+
37
+ clearTimeout(timeoutId);
38
+
39
+ return new Response(JSON.stringify({ success: response.ok }), {
40
+ status: response.status,
41
+ headers: {
42
+ 'Content-Type': 'application/json',
43
+ },
44
+ });
45
+ } catch (fetchError) {
46
+ clearTimeout(timeoutId);
47
+
48
+ if (fetchError instanceof Error && fetchError.name === 'AbortError') {
49
+ return new Response(
50
+ JSON.stringify({
51
+ success: false,
52
+ error: 'Release-hold request timeout',
53
+ }),
54
+ {
55
+ status: 408,
56
+ headers: { 'Content-Type': 'application/json' },
57
+ }
58
+ );
59
+ }
60
+ throw fetchError;
61
+ }
62
+ } catch (error) {
63
+ console.error('Release-hold API proxy error:', error);
64
+
65
+ return new Response(
66
+ JSON.stringify({
67
+ success: false,
68
+ error: 'Failed to connect to backend service',
69
+ }),
70
+ {
71
+ status: 500,
72
+ headers: { 'Content-Type': 'application/json' },
73
+ }
74
+ );
75
+ }
76
+ };
@@ -69,14 +69,14 @@ export const POST: APIRoute = async ({ request, locals }) => {
69
69
  const body = await request.json();
70
70
  const { action, payload } = body;
71
71
 
72
- if (action !== 'askLemur') {
72
+ if (action !== 'aai') {
73
73
  return new Response(
74
74
  JSON.stringify({ success: false, error: 'Invalid action.' }),
75
75
  { status: 400, headers: { 'Content-Type': 'application/json' } }
76
76
  );
77
77
  }
78
78
 
79
- const backendResponse = await fetch(`${goBackend}/api/v1/aai/askLemur`, {
79
+ const backendResponse = await fetch(`${goBackend}/api/v1/aai/aai`, {
80
80
  method: 'POST',
81
81
  headers: {
82
82
  'Content-Type': 'application/json',
@@ -7,16 +7,18 @@ interface CreateCartPayload {
7
7
  lines: Array<{
8
8
  merchandiseId: string;
9
9
  quantity: number;
10
+ attributes?: Array<{ key: string; value: string }>;
10
11
  }>;
11
12
  attributes?: Array<{
12
13
  key: string;
13
14
  value: string;
14
15
  }>;
15
16
  email?: string;
17
+ traceId?: string;
16
18
  }
17
19
 
18
20
  const getBackendUrl = () => {
19
- return import.meta.env.PUBLIC_API_URL || 'http://localhost:8080';
21
+ return import.meta.env.PUBLIC_GO_BACKEND || 'http://localhost:8080';
20
22
  };
21
23
 
22
24
  export const POST: APIRoute = async ({ request }) => {
@@ -29,12 +31,6 @@ export const POST: APIRoute = async ({ request }) => {
29
31
  try {
30
32
  const body = (await request.json()) as CreateCartPayload;
31
33
 
32
- const payload: CreateCartPayload = {
33
- lines: body.lines,
34
- attributes: body.attributes || [],
35
- email: body.email,
36
- };
37
-
38
34
  const backendResponse = await fetch(backendEndpoint, {
39
35
  method: 'POST',
40
36
  headers: {
@@ -42,7 +38,7 @@ export const POST: APIRoute = async ({ request }) => {
42
38
  'X-Tenant-ID': tenantId,
43
39
  Cookie: cookieHeader,
44
40
  },
45
- body: JSON.stringify(payload),
41
+ body: JSON.stringify(body),
46
42
  });
47
43
 
48
44
  if (!backendResponse.ok) {
@@ -1,24 +1,32 @@
1
1
  import type { APIRoute } from '@/types/astro';
2
- import { shopifyData } from '@/stores/shopify';
3
2
  import { resolveTenantId } from '@/utils/tenantResolver';
4
3
 
5
4
  export const prerender = false;
6
5
 
7
6
  const getBackendUrl = () => {
8
- return import.meta.env.PUBLIC_API_URL || 'http://localhost:8080';
7
+ return import.meta.env.PUBLIC_GO_BACKEND || 'http://localhost:8080';
9
8
  };
10
9
 
11
10
  export const GET: APIRoute = async ({ request }) => {
12
- // 1. Resolve Tenant Identity
13
11
  const resolution = await resolveTenantId(request);
14
12
  const tenantId = resolution.id;
15
13
 
16
- // 2. Fetch from Backend Proxy
17
- const backendEndpoint = `${getBackendUrl()}/api/v1/shopify/products`;
14
+ const url = new URL(request.url);
15
+ const q = url.searchParams.get('q') || '';
16
+ const cursor = url.searchParams.get('cursor') || '';
17
+
18
+ const backendUrl = new URL(`${getBackendUrl()}/api/v1/shopify/products`);
19
+ if (q) {
20
+ backendUrl.searchParams.set('q', q);
21
+ }
22
+ if (cursor) {
23
+ backendUrl.searchParams.set('cursor', cursor);
24
+ }
25
+
18
26
  const cookieHeader = request.headers.get('cookie') || '';
19
27
 
20
28
  try {
21
- const backendResponse = await fetch(backendEndpoint, {
29
+ const backendResponse = await fetch(backendUrl.toString(), {
22
30
  method: 'GET',
23
31
  headers: {
24
32
  'Content-Type': 'application/json',
@@ -36,15 +44,7 @@ export const GET: APIRoute = async ({ request }) => {
36
44
 
37
45
  const result = await backendResponse.json();
38
46
 
39
- // 3. Update Client Store
40
- const newState = {
41
- products: result.products || [],
42
- lastFetched: Date.now(),
43
- };
44
-
45
- shopifyData.set(newState);
46
-
47
- return new Response(JSON.stringify(newState), {
47
+ return new Response(JSON.stringify(result), {
48
48
  status: 200,
49
49
  headers: { 'Content-Type': 'application/json' },
50
50
  });
@@ -55,20 +55,27 @@ const mainStylesUrl = isDev
55
55
  <div class="mx-auto pb-6">
56
56
  <!-- Logo and Wordmark -->
57
57
  <div class="flex flex-col items-center justify-center gap-4">
58
- <div class="h-16 w-auto">
59
- <img
60
- src={logo}
61
- class="pointer-events-none h-full w-auto"
62
- alt="Logo"
63
- />
64
- </div>
65
- <div class="h-16 w-auto">
66
- <img
67
- src={wordmark}
68
- class="pointer-events-none h-full w-auto max-w-48 md:max-w-72"
69
- alt="Wordmark"
70
- />
71
- </div>
58
+ {
59
+ brandConfig?.WORDMARK_MODE !== 'wordmark' && (
60
+ <img
61
+ id="t8k-logo"
62
+ src={logo}
63
+ class="pointer-events-none h-16 w-auto"
64
+ alt="Logo"
65
+ />
66
+ )
67
+ }
68
+
69
+ {
70
+ brandConfig?.WORDMARK_MODE !== 'logo' && (
71
+ <img
72
+ id="t8k-wordmark"
73
+ src={wordmark}
74
+ class="pointer-events-none h-16 w-auto max-w-48 md:max-w-72"
75
+ alt="Wordmark"
76
+ />
77
+ )
78
+ }
72
79
  </div>
73
80
 
74
81
  <h2
@@ -53,6 +53,11 @@ export interface ShopifyProduct {
53
53
  variants: ShopifyVariant[];
54
54
  }
55
55
 
56
+ export interface ShopifyPageInfo {
57
+ hasNextPage: boolean;
58
+ endCursor: string;
59
+ }
60
+
56
61
  export type CartActionType = 'add' | 'remove';
57
62
 
58
63
  export interface CartAction {
@@ -90,30 +95,23 @@ export const CART_STATES = {
90
95
  LOADED: 'LOADED',
91
96
  CHECKOUT: 'CHECKOUT',
92
97
  BOOKING: 'BOOKING',
93
- BOOKED: 'BOOKED',
94
98
  SHOPIFY_HANDOFF: 'SHOPIFY_HANDOFF',
95
99
  } as const;
96
100
 
97
101
  export type CartState = (typeof CART_STATES)[keyof typeof CART_STATES];
98
102
 
99
103
  export const isShopifyHandoff = atom<boolean>(false);
104
+ export const shopifyActiveTabStore = atom<string>('dashboards');
100
105
 
101
- export const shopifyData = persistentAtom<{
106
+ export const shopifyData = atom<{
102
107
  products: ShopifyProduct[];
108
+ pageInfo?: ShopifyPageInfo;
103
109
  lastFetched: number;
104
- }>(
105
- 'tractstack_shopify_data',
106
- {
107
- products: [],
108
- lastFetched: 0,
109
- },
110
- {
111
- encode: JSON.stringify,
112
- decode: JSON.parse,
113
- }
114
- );
110
+ }>({
111
+ products: [],
112
+ lastFetched: 0,
113
+ });
115
114
 
116
- // Non-persistent Status (Load states should reset on refresh)
117
115
  export const shopifyStatus = map<{
118
116
  isLoading: boolean;
119
117
  error: string | null;
@@ -131,9 +129,7 @@ export const addQueue = persistentAtom<CartAction[]>(
131
129
  }
132
130
  );
133
131
 
134
- // We use a backing persistentAtom for the cart to ensure data survives reloads,
135
- // but expose it as a 'map' to preserve the .setKey() API used by consumers.
136
- const cartPersistence = persistentAtom<Record<string, CartItemState>>(
132
+ export const cartPersistence = persistentAtom<Record<string, CartItemState>>(
137
133
  'tractstack_shopify_cart',
138
134
  {},
139
135
  {
@@ -147,10 +143,8 @@ export const cartStore = map<Record<string, CartItemState>>(
147
143
  );
148
144
 
149
145
  onMount(cartStore, () => {
150
- // Sync initial state from persistence (in case of race conditions or hydration delay)
151
146
  cartStore.set(cartPersistence.get());
152
147
 
153
- // Persist any changes made to the map
154
148
  const unbind = cartStore.listen((value) => {
155
149
  cartPersistence.set(value);
156
150
  });
@@ -167,11 +161,29 @@ export const cartState = persistentAtom<CartState>(
167
161
  CART_STATES.INIT
168
162
  );
169
163
 
170
- export async function fetchShopifyProducts() {
164
+ let currentAbortController: AbortController | null = null;
165
+
166
+ export async function fetchShopifyProducts(
167
+ q: string = '',
168
+ cursor: string | null = null
169
+ ) {
170
+ if (currentAbortController) {
171
+ currentAbortController.abort();
172
+ }
173
+ currentAbortController = new AbortController();
174
+ const signal = currentAbortController.signal;
175
+
171
176
  shopifyStatus.set({ isLoading: true, error: null });
172
177
 
173
178
  try {
174
- const response = await fetch('/api/shopify/getProducts');
179
+ const params = new URLSearchParams();
180
+ if (q) params.set('q', q);
181
+ if (cursor) params.set('cursor', cursor);
182
+
183
+ const queryString = params.toString();
184
+ const url = `/api/shopify/getProducts${queryString ? `?${queryString}` : ''}`;
185
+
186
+ const response = await fetch(url, { signal });
175
187
  const result = await response.json();
176
188
 
177
189
  if (!response.ok) {
@@ -179,12 +191,17 @@ export async function fetchShopifyProducts() {
179
191
  }
180
192
 
181
193
  shopifyData.set({
182
- products: result.products,
194
+ products: result.products || [],
195
+ pageInfo: result.pageInfo,
183
196
  lastFetched: Date.now(),
184
197
  });
185
198
 
186
199
  shopifyStatus.set({ isLoading: false, error: null });
187
- } catch (error) {
200
+ } catch (error: unknown) {
201
+ if (error instanceof DOMException && error.name === 'AbortError') {
202
+ return;
203
+ }
204
+
188
205
  console.error('Shopify fetch failed:', error);
189
206
  shopifyStatus.set({
190
207
  isLoading: false,
@@ -194,17 +211,72 @@ export async function fetchShopifyProducts() {
194
211
  }
195
212
  }
196
213
 
197
- export const customerDetails = persistentAtom<{
214
+ export function clearShopifySearch() {
215
+ if (currentAbortController) {
216
+ currentAbortController.abort();
217
+ }
218
+ shopifyData.set({
219
+ products: [],
220
+ pageInfo: undefined,
221
+ lastFetched: 0,
222
+ });
223
+ shopifyStatus.set({ isLoading: false, error: null });
224
+ }
225
+
226
+ export const transactionTraceId = persistentAtom<string>(
227
+ 'tractstack_shopify_trace_id',
228
+ ''
229
+ );
230
+
231
+ export interface CustomerDetails {
198
232
  name: string;
199
233
  email: string;
200
- }>(
234
+ leadId: string;
235
+ }
236
+
237
+ export const customerDetails = persistentAtom<CustomerDetails>(
201
238
  'tractstack_shopify_customer',
202
239
  {
203
240
  name: '',
204
241
  email: '',
242
+ leadId: '',
205
243
  },
206
244
  {
207
245
  encode: JSON.stringify,
208
246
  decode: JSON.parse,
209
247
  }
210
248
  );
249
+
250
+ export function setCustomerDetails(details: Partial<CustomerDetails>) {
251
+ customerDetails.set({
252
+ ...customerDetails.get(),
253
+ ...details,
254
+ });
255
+ }
256
+
257
+ export function clearCommerceState() {
258
+ cartStore.set({});
259
+ customerDetails.set({
260
+ name: '',
261
+ email: '',
262
+ leadId: '',
263
+ });
264
+ transactionTraceId.set('');
265
+ cartState.set(CART_STATES.READY);
266
+ }
267
+
268
+ export interface CartKeyParams {
269
+ resourceId: string;
270
+ variantId?: string;
271
+ variantIdShipped?: string;
272
+ variantIdPickup?: string;
273
+ }
274
+
275
+ export function getCartItemKey(params: CartKeyParams): string {
276
+ if (params.variantId) {
277
+ return params.variantId;
278
+ }
279
+ return `${params.resourceId}_${params.variantIdShipped || 'null'}_${
280
+ params.variantIdPickup || 'null'
281
+ }`;
282
+ }
@@ -1,3 +1,5 @@
1
+ import type { ReactNode } from 'react';
2
+
1
3
  // Base props interface for all atomic form components
2
4
  export interface BaseFormComponentProps<T> {
3
5
  value: T;
@@ -71,7 +73,7 @@ export interface NumberInputProps extends BaseFormComponentProps<number> {
71
73
  export interface FormSectionProps {
72
74
  title: string;
73
75
  description?: string;
74
- children: React.ReactNode;
76
+ children: ReactNode;
75
77
  collapsible?: boolean;
76
78
  defaultExpanded?: boolean;
77
79
  }
@@ -216,7 +218,7 @@ export interface NumberInputProps extends BaseFormComponentProps<number> {
216
218
  export interface FormSectionProps {
217
219
  title: string;
218
220
  description?: string;
219
- children: React.ReactNode;
221
+ children: ReactNode;
220
222
  collapsible?: boolean;
221
223
  defaultExpanded?: boolean;
222
224
  }