astro-tractstack 2.4.0 → 2.4.1

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
@@ -891,6 +891,14 @@ async function y(t, e, c) {
891
891
  src: t("../templates/custom/shopify/cart.astro"),
892
892
  dest: "src/pages/cart.astro"
893
893
  },
894
+ {
895
+ src: t("../templates/custom/shopify/cart-reset.astro"),
896
+ dest: "src/pages/cart/reset.astro"
897
+ },
898
+ {
899
+ src: t("../templates/custom/shopify/CartReset.tsx"),
900
+ dest: "src/custom/shopify/CartReset.tsx"
901
+ },
894
902
  {
895
903
  src: t("../templates/src/pages/privacy.astro"),
896
904
  dest: "src/pages/privacy.astro"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-tractstack",
3
- "version": "2.4.0",
3
+ "version": "2.4.1",
4
4
  "description": "Astro integration for TractStack - the free web press by At Risk Media",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react';
2
2
  import { Dialog } from '@ark-ui/react/dialog';
3
3
  import { Portal } from '@ark-ui/react/portal';
4
4
  import { modalState } from '@/stores/shopify';
5
+ import { classNames } from '@/utils/helpers';
5
6
 
6
7
  export default function CartModal() {
7
8
  const state = useStore(modalState);
@@ -20,15 +21,24 @@ export default function CartModal() {
20
21
  const isCartPage =
21
22
  typeof window !== 'undefined' && window.location.pathname === '/cart';
22
23
 
24
+ const isMaxDurationRestriction =
25
+ state.type === 'restriction' && state.restrictionReason === 'maxDuration';
26
+
23
27
  return (
24
28
  <Dialog.Root
25
29
  open={state.isOpen}
30
+ closeOnInteractOutside={false}
26
31
  onOpenChange={(e) => !e.open && handleClose()}
27
32
  >
28
33
  <Portal>
29
34
  <Dialog.Backdrop className="fixed inset-0 z-50 bg-black bg-opacity-75 backdrop-blur-sm" />
30
35
  <Dialog.Positioner className="fixed inset-0 z-50 flex items-center justify-center p-4">
31
- <Dialog.Content className="w-full max-w-md overflow-hidden rounded-lg bg-white shadow-xl">
36
+ <Dialog.Content
37
+ className={classNames(
38
+ `w-full max-w-md overflow-hidden rounded-lg bg-white shadow-xl`,
39
+ isMaxDurationRestriction ? 'border-4 border-brand-3' : ''
40
+ )}
41
+ >
32
42
  <div className="p-6">
33
43
  <Dialog.Title className="text-xl font-bold text-gray-900">
34
44
  {state.title}
@@ -0,0 +1,38 @@
1
+ import { useEffect } from 'react';
2
+ import {
3
+ resetShopifyCommerceState,
4
+ transactionTraceId,
5
+ } from '@/stores/shopify';
6
+ import { bookingHelpers } from '@/utils/api/bookingHelpers';
7
+
8
+ export default function CartReset() {
9
+ useEffect(() => {
10
+ let cancelled = false;
11
+
12
+ const run = async () => {
13
+ const traceId = transactionTraceId.get();
14
+ if (traceId) {
15
+ try {
16
+ await bookingHelpers.releaseHold(traceId);
17
+ } catch (err) {
18
+ console.error('Failed to release hold during cart reset:', err);
19
+ }
20
+ }
21
+
22
+ if (cancelled) {
23
+ return;
24
+ }
25
+
26
+ resetShopifyCommerceState();
27
+ window.location.replace('/cart');
28
+ };
29
+
30
+ void run();
31
+
32
+ return () => {
33
+ cancelled = true;
34
+ };
35
+ }, []);
36
+
37
+ return <p className="text-center text-sm text-gray-600">Resetting cart…</p>;
38
+ }
@@ -182,6 +182,7 @@ export default function ShopifyCartManager({
182
182
  type: 'restriction',
183
183
  title: 'Incompatible Booking Modes',
184
184
  message: RESTRICTION_MESSAGES.INCOMPATIBLE_REMOTE,
185
+ restrictionReason: 'incompatibleRemote',
185
186
  });
186
187
  } else {
187
188
  const rawDuration = calculateCartDuration(nextCart, resources);
@@ -196,6 +197,7 @@ export default function ShopifyCartManager({
196
197
  type: 'restriction',
197
198
  title: 'Appointment Length Limit Reached',
198
199
  message: RESTRICTION_MESSAGES.MAX_DURATION(dynamicMax),
200
+ restrictionReason: 'maxDuration',
199
201
  });
200
202
  } else {
201
203
  cartStore.set(nextCart);
@@ -1,10 +1,12 @@
1
1
  import { useStore } from '@nanostores/react';
2
+ import { Switch } from '@ark-ui/react/switch';
2
3
  import { cartStore, addQueue, type CartAction } from '@/stores/shopify';
3
4
  import {
4
5
  getCartItemKey,
5
6
  getServiceVariantIdFromCanonicalProduct,
6
7
  isSharedFeeService,
7
8
  } from '@/custom/shopify/shopifyHelpers';
9
+ import { classNames } from '@/utils/helpers';
8
10
  import type { ResourceNode } from '@/types/compositorTypes';
9
11
 
10
12
  interface Props {
@@ -51,12 +53,12 @@ export default function ShopifyServiceList({ resources = [], options }: Props) {
51
53
  .filter((s): s is string => !!s)
52
54
  );
53
55
 
54
- const displayServices = services.filter(
55
- (s) => !boundServiceSlugs.has(s.slug)
56
- );
56
+ const displayServices = services
57
+ .filter((s) => !boundServiceSlugs.has(s.slug))
58
+ .sort((a, b) => a.title.localeCompare(b.title));
57
59
 
58
- const handleToggle = (resource: ResourceNode, currentQuantity: number) => {
59
- const actionType = currentQuantity > 0 ? 'remove' : 'add';
60
+ const handleCheckedChange = (resource: ResourceNode, checked: boolean) => {
61
+ const actionType = checked ? 'add' : 'remove';
60
62
  const gid =
61
63
  typeof resource.optionsPayload?.gid === 'string'
62
64
  ? resource.optionsPayload.gid
@@ -116,47 +118,58 @@ export default function ShopifyServiceList({ resources = [], options }: Props) {
116
118
  const duration = resource.optionsPayload?.bookingLengthMinutes;
117
119
 
118
120
  return (
119
- <div
121
+ <Switch.Root
120
122
  key={resource.id}
121
- className={`flex items-center justify-between rounded-lg border p-4 transition-colors ${
122
- isSelected
123
- ? 'border-black bg-gray-50'
124
- : 'border-gray-200 bg-white hover:border-gray-300'
125
- }`}
123
+ checked={isSelected}
124
+ onCheckedChange={(details) =>
125
+ handleCheckedChange(resource, details.checked)
126
+ }
127
+ className="block w-full"
126
128
  >
127
- <div className="flex-grow">
128
- <div className="flex items-center gap-2">
129
- <h3 className="font-bold text-gray-900">
130
- {resource.title}
131
- </h3>
132
- {duration && (
133
- <span className="inline-flex items-center rounded-sm bg-blue-50 px-2 py-0.5 text-xs font-bold text-blue-700">
134
- {duration} mins
135
- </span>
129
+ <div
130
+ className={`flex cursor-pointer items-center justify-between rounded-lg border p-4 transition-colors ${
131
+ isSelected
132
+ ? 'border-black bg-gray-50'
133
+ : 'border-gray-200 bg-white hover:border-gray-300'
134
+ }`}
135
+ >
136
+ <Switch.Label className="min-w-0 flex-grow cursor-pointer">
137
+ <div className="flex flex-wrap items-center gap-2">
138
+ <h3 className="font-bold text-gray-900">
139
+ {resource.title}
140
+ </h3>
141
+ {duration ? (
142
+ <span className="inline-flex items-center rounded-sm bg-blue-50 px-2 py-0.5 text-xs font-bold text-blue-700">
143
+ {duration} mins
144
+ </span>
145
+ ) : null}
146
+ {isSelected ? (
147
+ <span className="inline-flex items-center rounded-sm bg-gray-100 px-2 py-0.5 text-xs font-bold text-gray-800">
148
+ In Cart
149
+ </span>
150
+ ) : null}
151
+ </div>
152
+ <p className="mt-1 text-sm text-gray-500">
153
+ {resource.oneliner}
154
+ </p>
155
+ </Switch.Label>
156
+
157
+ <Switch.Control
158
+ className={classNames(
159
+ `relative ml-4 inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none`,
160
+ isSelected ? 'bg-brand-5' : 'bg-gray-200'
136
161
  )}
137
- </div>
138
- <p className="mt-1 text-sm text-gray-500">
139
- {resource.oneliner}
140
- </p>
141
- </div>
142
-
143
- <div className="ml-4 flex-shrink-0">
144
- <button
145
- onClick={() => handleToggle(resource, selectedQuantity)}
146
- className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
147
- isSelected ? 'bg-black' : 'bg-gray-200'
148
- }`}
149
- role="switch"
150
- aria-checked={isSelected}
151
162
  >
152
- <span
153
- className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
163
+ <Switch.Thumb
164
+ className={classNames(
165
+ `pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`,
154
166
  isSelected ? 'translate-x-5' : 'translate-x-0'
155
- }`}
167
+ )}
156
168
  />
157
- </button>
169
+ </Switch.Control>
170
+ <Switch.HiddenInput />
158
171
  </div>
159
- </div>
172
+ </Switch.Root>
160
173
  );
161
174
  })}
162
175
  </div>
@@ -0,0 +1,19 @@
1
+ ---
2
+ import Layout from '@/layouts/Layout.astro';
3
+ import CartReset from '@/custom/shopify/CartReset';
4
+ import { getBrandConfig } from '@/utils/api/brandConfig';
5
+
6
+ const tenantId =
7
+ Astro.locals.tenant?.id || import.meta.env.PUBLIC_TENANTID || 'default';
8
+
9
+ const brandConfig = await getBrandConfig(tenantId);
10
+ if (!brandConfig.HAS_SHOPIFY) {
11
+ return Astro.redirect('/storykeep');
12
+ }
13
+ ---
14
+
15
+ <Layout title="Reset Cart" slug="cart">
16
+ <main class="mx-auto max-w-7xl px-4 py-16 md:px-6 xl:px-8">
17
+ <CartReset client:only="react" />
18
+ </main>
19
+ </Layout>
@@ -141,7 +141,7 @@ if (hasShopify) {
141
141
  <div
142
142
  class="flex flex-row flex-nowrap justify-between bg-mywhite px-4 pb-3 pt-4 shadow-inner md:px-8"
143
143
  >
144
- <h1 class="truncate text-2xl text-mydarkgrey">{title}</h1>
144
+ <h1 id="page-title" class="truncate text-2xl text-mydarkgrey">{title}</h1>
145
145
  <div class="flex flex-row flex-nowrap items-center gap-x-2">
146
146
  {
147
147
  !isHome ? (
@@ -16,10 +16,8 @@ const resources = await resolveCodeHookResources(
16
16
  );
17
17
  ---
18
18
 
19
- <div style="min-height: 400px;">
20
- <ShopifyProductGrid
21
- options={options}
22
- resources={resources}
23
- client:only="react"
24
- />
25
- </div>
19
+ <ShopifyProductGrid
20
+ options={options}
21
+ resources={resources}
22
+ client:only="react"
23
+ />
@@ -16,10 +16,8 @@ const resources = await resolveCodeHookResources(
16
16
  );
17
17
  ---
18
18
 
19
- <div style="min-height: 300px;">
20
- <ShopifyServiceList
21
- options={options}
22
- resources={resources}
23
- client:only="react"
24
- />
25
- </div>
19
+ <ShopifyServiceList
20
+ options={options}
21
+ resources={resources}
22
+ client:only="react"
23
+ />
@@ -26,6 +26,7 @@ export const modalState = atom<{
26
26
  type: 'success' | 'restriction';
27
27
  title: string;
28
28
  message: string;
29
+ restrictionReason?: 'maxDuration' | 'incompatibleRemote';
29
30
  }>({
30
31
  isOpen: false,
31
32
  type: 'success',
@@ -258,16 +259,45 @@ export function setCustomerDetails(details: Partial<CustomerDetails>) {
258
259
  });
259
260
  }
260
261
 
261
- export function clearCommerceState() {
262
+ const SHOPIFY_PERSISTENCE_KEYS = [
263
+ 'tractstack_shopify_queue',
264
+ 'tractstack_shopify_cart',
265
+ 'tractstack_shopify_queue_state',
266
+ 'tractstack_shopify_cart_state',
267
+ 'tractstack_shopify_trace_id',
268
+ 'tractstack_shopify_appointment_mode',
269
+ 'tractstack_shopify_customer',
270
+ ] as const;
271
+
272
+ export function resetShopifyCommerceState() {
273
+ addQueue.set([]);
262
274
  cartStore.set({});
275
+ cartState.set(CART_STATES.READY);
276
+ queueState.set(QUEUE_STATES.READY);
277
+ transactionTraceId.set('');
278
+ preferredAppointmentMode.set('IN_PERSON');
263
279
  customerDetails.set({
264
280
  name: '',
265
281
  email: '',
266
282
  leadId: '',
267
283
  });
268
- transactionTraceId.set('');
269
- preferredAppointmentMode.set('IN_PERSON');
270
- cartState.set(CART_STATES.READY);
284
+ modalState.set({
285
+ isOpen: false,
286
+ type: 'success',
287
+ title: '',
288
+ message: '',
289
+ restrictionReason: undefined,
290
+ });
291
+
292
+ if (typeof localStorage !== 'undefined') {
293
+ SHOPIFY_PERSISTENCE_KEYS.forEach((key) => {
294
+ localStorage.removeItem(key);
295
+ });
296
+ }
297
+ }
298
+
299
+ export function clearCommerceState() {
300
+ resetShopifyCommerceState();
271
301
  }
272
302
 
273
303
  export interface CartKeyParams {
@@ -900,6 +900,14 @@ export async function injectTemplateFiles(
900
900
  src: resolve('../templates/custom/shopify/cart.astro'),
901
901
  dest: 'src/pages/cart.astro',
902
902
  },
903
+ {
904
+ src: resolve('../templates/custom/shopify/cart-reset.astro'),
905
+ dest: 'src/pages/cart/reset.astro',
906
+ },
907
+ {
908
+ src: resolve('../templates/custom/shopify/CartReset.tsx'),
909
+ dest: 'src/custom/shopify/CartReset.tsx',
910
+ },
903
911
  {
904
912
  src: resolve('../templates/src/pages/privacy.astro'),
905
913
  dest: 'src/pages/privacy.astro',