astro-tractstack 2.3.2 → 2.3.3

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 (33) hide show
  1. package/bin/create-tractstack.js +3 -3
  2. package/dist/index.js +33 -8
  3. package/package.json +1 -1
  4. package/templates/custom/shopify/Cart.tsx +83 -14
  5. package/templates/custom/shopify/CheckoutModal.tsx +192 -6
  6. package/templates/custom/shopify/ShopifyCartManager.tsx +53 -41
  7. package/templates/custom/shopify/cart.astro +7 -1
  8. package/templates/src/components/Header.astro +3 -1
  9. package/templates/src/components/form/advanced/APIConfigSection.tsx +219 -1
  10. package/templates/src/components/form/shopify/SchedulingSection.tsx +44 -0
  11. package/templates/src/components/storykeep/Dashboard_Advanced.tsx +10 -1
  12. package/templates/src/components/storykeep/Dashboard_Shopify.tsx +9 -0
  13. package/templates/src/components/storykeep/controls/content/ManageContent.tsx +1 -0
  14. package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +80 -0
  15. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +86 -8
  16. package/templates/src/constants.ts +2 -0
  17. package/templates/src/pages/api/google/oauth/callback.ts +50 -0
  18. package/templates/src/pages/api/google/oauth/disconnect.ts +32 -0
  19. package/templates/src/pages/api/google/oauth/start.ts +32 -0
  20. package/templates/src/pages/api/google/oauth/status.ts +32 -0
  21. package/templates/src/pages/privacy.astro +84 -0
  22. package/templates/src/pages/terms.astro +47 -0
  23. package/templates/src/stores/shopify.ts +5 -0
  24. package/templates/src/types/tractstack.ts +30 -0
  25. package/templates/src/utils/api/advancedHelpers.ts +16 -0
  26. package/templates/src/utils/api/bookingHelpers.ts +3 -1
  27. package/templates/src/utils/api/brandHelpers.ts +8 -1
  28. package/templates/src/utils/booking/appointmentMode.ts +135 -0
  29. package/templates/src/utils/customHelpers.ts +2 -0
  30. package/utils/inject-files.ts +29 -4
  31. package/templates/src/components/codehooks/SandboxAuthWrapper.tsx +0 -101
  32. package/templates/src/utils/actions/actionButton.ts +0 -103
  33. package/templates/src/utils/actions/preParse_Clicked.ts +0 -87
@@ -154,7 +154,7 @@ ${kleur.bold('Examples:')}
154
154
  console.log(kleur.red("❌ This doesn't appear to be an Astro project."));
155
155
  console.log('Please run this command in the root of your Astro project.\n');
156
156
  console.log('To create a new Astro project with TractStack:');
157
- console.log(kleur.cyan('pnpm create astro@latest my-tractstack-site'));
157
+ console.log(kleur.cyan('pnpm create astro@5 my-tractstack-site'));
158
158
  console.log(kleur.cyan('cd my-tractstack-site'));
159
159
  console.log(kleur.cyan('npx create-tractstack'));
160
160
  process.exit(1);
@@ -332,7 +332,7 @@ PUBLIC_ENABLE_BUNNY="${finalResponses.enableBunny ? 'true' : 'false'}"
332
332
  try {
333
333
  // Install React and Node adapter
334
334
  execSync(
335
- `${addCommand} react@^19.0.0 react-dom@^19.0.0 @astrojs/react@^4.4.2 @astrojs/node@^9.4.3`,
335
+ `${addCommand} react@^19.0.0 react-dom@^19.0.0 astro@^5.16.6 @astrojs/react@^4.4.2 @astrojs/node@^9.4.3`,
336
336
  { stdio: 'inherit' }
337
337
  );
338
338
  console.log(kleur.green('✅ React and Node adapter installed'));
@@ -382,7 +382,7 @@ PUBLIC_ENABLE_BUNNY="${finalResponses.enableBunny ? 'true' : 'false'}"
382
382
  console.log('Please run manually:');
383
383
  console.log(
384
384
  kleur.cyan(
385
- `${addCommand} react@^19.0.0 react-dom@^19.0.0 @astrojs/react@^4.4.2 @astrojs/node@^9.4.3 @nanostores/react@^1.0.0 nanostores@^1.0.1 @nanostores/persistent ulid@^3.0.1 @ark-ui/react@^5.30.0 @heroicons/react@^2.1.1 @internationalized/date@3.10.0 d3@^7.9.0 d3-sankey@^0.12.3 recharts@^3.1.2 player.js@^0.1.0 tinycolor2@1.6.0 html-to-image@^1.11.13 path-to-regexp@^8.0.0 postcss postcss-selector-parser`
385
+ `${addCommand} react@^19.0.0 react-dom@^19.0.0 astro@^5.16.6 @astrojs/react@^4.4.2 @astrojs/node@^9.4.3 @nanostores/react@^1.0.0 nanostores@^1.0.1 @nanostores/persistent ulid@^3.0.1 @ark-ui/react@^5.30.0 @heroicons/react@^2.1.1 @internationalized/date@3.10.0 d3@^7.9.0 d3-sankey@^0.12.3 recharts@^3.1.2 player.js@^0.1.0 tinycolor2@1.6.0 html-to-image@^1.11.13 path-to-regexp@^8.0.0 postcss postcss-selector-parser`
386
386
  )
387
387
  );
388
388
  console.log(
package/dist/index.js CHANGED
@@ -2,13 +2,13 @@ import { fileURLToPath as d } from "node:url";
2
2
  import { dirname as i, resolve as l } from "node:path";
3
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
- function b(t) {
5
+ function g(t) {
6
6
  const e = i(d(t));
7
7
  return {
8
8
  resolve: (...c) => l(e, ...c)
9
9
  };
10
10
  }
11
- function g(t, e) {
11
+ function b(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
13
  const c = process.env.PUBLIC_GO_BACKEND, o = process.env.PUBLIC_TENANTID;
14
14
  if (!c)
@@ -747,10 +747,6 @@ async function y(t, e, c) {
747
747
  src: t("../templates/src/utils/actions/preParse_Action.ts"),
748
748
  dest: "src/utils/actions/preParse_Action.ts"
749
749
  },
750
- {
751
- src: t("../templates/src/utils/actions/preParse_Clicked.ts"),
752
- dest: "src/utils/actions/preParse_Clicked.ts"
753
- },
754
750
  {
755
751
  src: t("../templates/src/utils/actions/preParse_Impression.ts"),
756
752
  dest: "src/utils/actions/preParse_Impression.ts"
@@ -891,6 +887,14 @@ async function y(t, e, c) {
891
887
  src: t("../templates/custom/shopify/cart.astro"),
892
888
  dest: "src/pages/cart.astro"
893
889
  },
890
+ {
891
+ src: t("../templates/src/pages/privacy.astro"),
892
+ dest: "src/pages/privacy.astro"
893
+ },
894
+ {
895
+ src: t("../templates/src/pages/terms.astro"),
896
+ dest: "src/pages/terms.astro"
897
+ },
894
898
  {
895
899
  src: t("../templates/src/pages/404.astro"),
896
900
  dest: "src/pages/404.astro"
@@ -989,6 +993,22 @@ async function y(t, e, c) {
989
993
  src: t("../templates/src/pages/api/auth/logout.ts"),
990
994
  dest: "src/pages/api/auth/logout.ts"
991
995
  },
996
+ {
997
+ src: t("../templates/src/pages/api/google/oauth/start.ts"),
998
+ dest: "src/pages/api/google/oauth/start.ts"
999
+ },
1000
+ {
1001
+ src: t("../templates/src/pages/api/google/oauth/status.ts"),
1002
+ dest: "src/pages/api/google/oauth/status.ts"
1003
+ },
1004
+ {
1005
+ src: t("../templates/src/pages/api/google/oauth/disconnect.ts"),
1006
+ dest: "src/pages/api/google/oauth/disconnect.ts"
1007
+ },
1008
+ {
1009
+ src: t("../templates/src/pages/api/google/oauth/callback.ts"),
1010
+ dest: "src/pages/api/google/oauth/callback.ts"
1011
+ },
992
1012
  {
993
1013
  src: t("../templates/src/pages/api/orphan-analysis.ts"),
994
1014
  dest: "src/pages/api/orphan-analysis.ts"
@@ -2307,6 +2327,11 @@ async function y(t, e, c) {
2307
2327
  dest: "src/utils/customHelpers.ts",
2308
2328
  protected: !0
2309
2329
  },
2330
+ {
2331
+ src: t("../templates/src/utils/booking/appointmentMode.ts"),
2332
+ dest: "src/utils/booking/appointmentMode.ts",
2333
+ protected: !0
2334
+ },
2310
2335
  {
2311
2336
  src: t("../templates/custom/shopify/ShopifyProductGrid.tsx"),
2312
2337
  dest: "src/custom/shopify/ShopifyProductGrid.tsx",
@@ -2426,12 +2451,12 @@ export default function Placeholder() {
2426
2451
  export const placeholder = "${t}";` : `# TractStack placeholder: ${t}`;
2427
2452
  }
2428
2453
  function P(t = {}) {
2429
- const { resolve: e } = b(import.meta.url);
2454
+ const { resolve: e } = g(import.meta.url);
2430
2455
  return {
2431
2456
  name: "astro-tractstack",
2432
2457
  hooks: {
2433
2458
  "astro:config:setup": async ({ config: c, updateConfig: o, logger: s }) => {
2434
- g(t, s);
2459
+ b(t, s);
2435
2460
  const p = t.enableMultiTenant || !1;
2436
2461
  if (s.info(
2437
2462
  `DEBUG: enableMultiTenant = ${p}, process.env.PUBLIC_ENABLE_MULTI_TENANT = ${process.env.PUBLIC_ENABLE_MULTI_TENANT}`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-tractstack",
3
- "version": "2.3.2",
3
+ "version": "2.3.3",
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",
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from 'react';
1
+ import { useState, useEffect, useMemo } from 'react';
2
2
  import { ulid } from 'ulid';
3
3
  import { useStore } from '@nanostores/react';
4
4
  import {
@@ -7,14 +7,18 @@ import {
7
7
  cartState,
8
8
  CART_STATES,
9
9
  isShopifyHandoff,
10
+ preferredAppointmentMode,
10
11
  transactionTraceId,
11
12
  type CartItemState,
12
13
  } from '@/stores/shopify';
13
14
  import { getShopifyImage } from '@/utils/helpers';
15
+ import { deriveAppointmentConstraints } from '@/utils/booking/appointmentMode';
14
16
  import type { ResourceNode } from '@/types/compositorTypes';
15
17
 
16
18
  interface CartProps {
17
19
  resources: ResourceNode[];
20
+ allowRemote?: boolean;
21
+ remoteOnly?: boolean;
18
22
  }
19
23
 
20
24
  const getCleanVariantTitle = (variant: any) => {
@@ -34,9 +38,14 @@ const getCleanVariantTitle = (variant: any) => {
34
38
  return title === 'Default Title' ? '' : title;
35
39
  };
36
40
 
37
- export default function Cart({ resources = [] }: CartProps) {
41
+ export default function Cart({
42
+ resources = [],
43
+ allowRemote = false,
44
+ remoteOnly = false,
45
+ }: CartProps) {
38
46
  const cart = useStore(cartStore);
39
47
  const isHandoff = useStore(isShopifyHandoff);
48
+ const appointmentMode = useStore(preferredAppointmentMode);
40
49
  const [pickupEnabled, setPickupEnabled] = useState(false);
41
50
 
42
51
  const cartValues = Object.values(cart);
@@ -67,12 +76,34 @@ export default function Cart({ resources = [] }: CartProps) {
67
76
  return !!resource?.optionsPayload?.bookingLengthMinutes;
68
77
  });
69
78
 
79
+ const appointmentConstraints = useMemo(
80
+ () =>
81
+ deriveAppointmentConstraints(cart, resources, {
82
+ allowRemote,
83
+ remoteOnly,
84
+ }),
85
+ [cart, resources, allowRemote, remoteOnly]
86
+ );
87
+ const { effectiveRemoteOnly, remoteAvailable, inPersonAvailable, canRemote } =
88
+ appointmentConstraints;
89
+
70
90
  const hasPhysicalProductWithPickup = cartValues.some(
71
91
  (item) =>
72
92
  item.variantIdPickup && item.variantIdPickup !== item.variantIdShipped
73
93
  );
74
94
 
75
- const canPickup = hasService && hasPhysicalProductWithPickup;
95
+ const canPickup =
96
+ hasService && hasPhysicalProductWithPickup && appointmentMode !== 'REMOTE';
97
+
98
+ useEffect(() => {
99
+ if (effectiveRemoteOnly) {
100
+ preferredAppointmentMode.set('REMOTE');
101
+ return;
102
+ }
103
+ if (!canRemote && preferredAppointmentMode.get() === 'REMOTE') {
104
+ preferredAppointmentMode.set('IN_PERSON');
105
+ }
106
+ }, [effectiveRemoteOnly, canRemote]);
76
107
 
77
108
  useEffect(() => {
78
109
  if (canPickup) {
@@ -131,17 +162,55 @@ export default function Cart({ resources = [] }: CartProps) {
131
162
  <div className="rounded-lg bg-white shadow">
132
163
  <div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
133
164
  <h2 className="text-xl font-bold text-gray-800">Shopping Cart</h2>
134
- {canPickup && (
135
- <label className="flex items-center space-x-2 text-sm font-bold text-gray-900">
136
- <input
137
- type="checkbox"
138
- checked={pickupEnabled}
139
- onChange={(e) => setPickupEnabled(e.target.checked)}
140
- className="h-4 w-4 rounded border-gray-300 text-black focus:ring-black"
141
- />
142
- <span>Pick up at Store</span>
143
- </label>
144
- )}
165
+ <div className="flex items-center gap-4">
166
+ {hasService && remoteAvailable && inPersonAvailable && (
167
+ <div className="flex flex-col items-end gap-1">
168
+ <p className="text-xs font-bold uppercase tracking-wide text-gray-500">
169
+ Appointment Mode
170
+ </p>
171
+ <div className="flex gap-2">
172
+ <button
173
+ type="button"
174
+ onClick={() => preferredAppointmentMode.set('IN_PERSON')}
175
+ className={`rounded-md px-3 py-2 text-sm font-bold ${
176
+ appointmentMode === 'IN_PERSON'
177
+ ? 'bg-black text-white'
178
+ : 'border border-gray-300 bg-white text-gray-700'
179
+ }`}
180
+ >
181
+ In Person
182
+ </button>
183
+ <button
184
+ type="button"
185
+ onClick={() => preferredAppointmentMode.set('REMOTE')}
186
+ className={`rounded-md px-3 py-2 text-sm font-bold ${
187
+ appointmentMode === 'REMOTE'
188
+ ? 'bg-black text-white'
189
+ : 'border border-gray-300 bg-white text-gray-700'
190
+ }`}
191
+ >
192
+ Remote
193
+ </button>
194
+ </div>
195
+ </div>
196
+ )}
197
+ {hasService && effectiveRemoteOnly && (
198
+ <p className="text-sm font-bold text-gray-900">
199
+ Appointment: Remote
200
+ </p>
201
+ )}
202
+ {canPickup && (
203
+ <label className="flex items-center space-x-2 text-sm font-bold text-gray-900">
204
+ <input
205
+ type="checkbox"
206
+ checked={pickupEnabled}
207
+ onChange={(e) => setPickupEnabled(e.target.checked)}
208
+ className="h-4 w-4 rounded border-gray-300 text-black focus:ring-black"
209
+ />
210
+ <span>Pick up at Store</span>
211
+ </label>
212
+ )}
213
+ </div>
145
214
  </div>
146
215
 
147
216
  <ul className="divide-y divide-gray-200">
@@ -1,4 +1,11 @@
1
- import { useState, useEffect, useMemo, type FormEvent } from 'react';
1
+ import {
2
+ useState,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useCallback,
7
+ type FormEvent,
8
+ } from 'react';
2
9
  import { ulid } from 'ulid';
3
10
  import { useStore } from '@nanostores/react';
4
11
  import { Dialog } from '@ark-ui/react/dialog';
@@ -9,10 +16,15 @@ import {
9
16
  customerDetails,
10
17
  setCustomerDetails,
11
18
  CART_STATES,
19
+ preferredAppointmentMode,
12
20
  transactionTraceId,
13
21
  isShopifyHandoff,
14
22
  } from '@/stores/shopify';
15
23
  import { bookingHelpers } from '@/utils/api/bookingHelpers';
24
+ import {
25
+ deriveAppointmentConstraints,
26
+ pickInitialAppointmentMode,
27
+ } from '@/utils/booking/appointmentMode';
16
28
  import { NativeBookingCalendar } from './NativeBookingCalendar';
17
29
  import { ProfileStorage } from '@/utils/profileStorage';
18
30
  import type { ResourceNode } from '@/types/compositorTypes';
@@ -24,14 +36,19 @@ type CheckoutState =
24
36
  | 'SUMMARY'
25
37
  | 'PROCESSING'
26
38
  | 'SUCCESS';
39
+ type AppointmentMode = 'IN_PERSON' | 'REMOTE';
27
40
 
28
41
  interface CheckoutModalProps {
29
42
  maxLength: number;
43
+ allowRemote?: boolean;
44
+ remoteOnly?: boolean;
30
45
  resources?: ResourceNode[];
31
46
  }
32
47
 
33
48
  export default function CheckoutModal({
34
49
  maxLength,
50
+ allowRemote = false,
51
+ remoteOnly = false,
35
52
  resources = [],
36
53
  }: CheckoutModalProps) {
37
54
  const $globalCartState = useStore(cartState);
@@ -57,6 +74,24 @@ export default function CheckoutModal({
57
74
  start: Date;
58
75
  end: Date;
59
76
  } | null>(null);
77
+ const [appointmentMode, setAppointmentMode] = useState<AppointmentMode>(() =>
78
+ pickInitialAppointmentMode(
79
+ deriveAppointmentConstraints(cartStore.get(), resources, {
80
+ allowRemote,
81
+ remoteOnly,
82
+ }),
83
+ preferredAppointmentMode.get()
84
+ )
85
+ );
86
+ const appointmentModeRef = useRef<AppointmentMode>(
87
+ pickInitialAppointmentMode(
88
+ deriveAppointmentConstraints(cartStore.get(), resources, {
89
+ allowRemote,
90
+ remoteOnly,
91
+ }),
92
+ preferredAppointmentMode.get()
93
+ )
94
+ );
60
95
  const [error, setError] = useState<string | null>(null);
61
96
 
62
97
  const isOpen =
@@ -82,6 +117,7 @@ export default function CheckoutModal({
82
117
  ...item,
83
118
  title: productData?.title || resource?.title || 'Loading...',
84
119
  price: variant?.price?.amount || '0.00',
120
+ resourceNode: resource,
85
121
  resource: {
86
122
  id: item.resourceId,
87
123
  needsBooking:
@@ -111,6 +147,51 @@ export default function CheckoutModal({
111
147
  return Math.min(Math.ceil(rawMinutes / 15) * 15, maxLength);
112
148
  }, [enrichedCart]);
113
149
 
150
+ const appointmentConstraints = useMemo(
151
+ () =>
152
+ deriveAppointmentConstraints($cartItems, resources, {
153
+ allowRemote,
154
+ remoteOnly,
155
+ }),
156
+ [$cartItems, resources, allowRemote, remoteOnly]
157
+ );
158
+
159
+ const { effectiveRemoteOnly, inPersonAvailable } = appointmentConstraints;
160
+
161
+ const remoteAvailable =
162
+ appointmentConstraints.effectiveRemoteOnly ||
163
+ (needsBooking &&
164
+ appointmentConstraints.effectiveAllowRemote &&
165
+ appointmentConstraints.serviceResources.length > 0 &&
166
+ appointmentConstraints.allServicesAllowRemote);
167
+
168
+ const applyAppointmentMode = useCallback((nextMode: AppointmentMode) => {
169
+ appointmentModeRef.current = nextMode;
170
+ setAppointmentMode((currentMode) =>
171
+ currentMode === nextMode ? currentMode : nextMode
172
+ );
173
+ preferredAppointmentMode.set(nextMode);
174
+ }, []);
175
+
176
+ useEffect(() => {
177
+ if (selectedSlot) {
178
+ return;
179
+ }
180
+ if (effectiveRemoteOnly) {
181
+ applyAppointmentMode('REMOTE');
182
+ return;
183
+ }
184
+ if (!remoteAvailable && appointmentMode === 'REMOTE') {
185
+ applyAppointmentMode('IN_PERSON');
186
+ }
187
+ }, [
188
+ effectiveRemoteOnly,
189
+ remoteAvailable,
190
+ appointmentMode,
191
+ selectedSlot,
192
+ applyAppointmentMode,
193
+ ]);
194
+
114
195
  useEffect(() => {
115
196
  const profile = ProfileStorage.getProfileData();
116
197
  if (ProfileStorage.isProfileUnlocked() && profile.email) {
@@ -146,6 +227,36 @@ export default function CheckoutModal({
146
227
  }
147
228
  }, [$globalCartState, needsBooking, $customer.leadId, internalState]);
148
229
 
230
+ useEffect(() => {
231
+ if (!isOpen || selectedSlot) {
232
+ return;
233
+ }
234
+
235
+ if (
236
+ internalState !== 'IDENTITY_EMAIL' &&
237
+ internalState !== 'IDENTITY_NEW_USER'
238
+ ) {
239
+ return;
240
+ }
241
+
242
+ if (effectiveRemoteOnly) {
243
+ applyAppointmentMode('REMOTE');
244
+ return;
245
+ }
246
+
247
+ const next = pickInitialAppointmentMode(
248
+ appointmentConstraints,
249
+ preferredAppointmentMode.get()
250
+ );
251
+ applyAppointmentMode(next);
252
+ }, [
253
+ isOpen,
254
+ selectedSlot,
255
+ internalState,
256
+ appointmentConstraints,
257
+ applyAppointmentMode,
258
+ ]);
259
+
149
260
  const handleClose = async () => {
150
261
  const redirect = internalState === 'SUCCESS';
151
262
  const currentTraceId = transactionTraceId.get();
@@ -249,7 +360,8 @@ export default function CheckoutModal({
249
360
  transactionTraceId.get(),
250
361
  start.toISOString(),
251
362
  end.toISOString(),
252
- cartResourceIds
363
+ cartResourceIds,
364
+ appointmentModeRef.current
253
365
  );
254
366
  if (response && (response.success || response.status === 'PENDING')) {
255
367
  setInternalState('SUMMARY');
@@ -343,6 +455,13 @@ export default function CheckoutModal({
343
455
  }
344
456
  ),
345
457
  },
458
+ {
459
+ key: 'Appointment Mode',
460
+ value:
461
+ appointmentMode === 'REMOTE'
462
+ ? 'Remote'
463
+ : 'In Person',
464
+ },
346
465
  ]
347
466
  : []),
348
467
  ],
@@ -462,13 +581,75 @@ export default function CheckoutModal({
462
581
  </form>
463
582
  )}
464
583
  {internalState === 'BOOKING' && (
465
- <NativeBookingCalendar
466
- totalDurationMinutes={totalDuration}
467
- onSlotSelected={handleSlotSelection}
468
- />
584
+ <div className="space-y-6">
585
+ {needsBooking && (inPersonAvailable || remoteAvailable) && (
586
+ <div className="rounded-xl border border-gray-200 bg-white p-4">
587
+ <p className="text-xs font-bold uppercase tracking-wide text-gray-500">
588
+ Appointment Mode
589
+ </p>
590
+ <div className="mt-3 flex flex-wrap gap-2">
591
+ {inPersonAvailable && (
592
+ <button
593
+ type="button"
594
+ onClick={() => {
595
+ applyAppointmentMode('IN_PERSON');
596
+ }}
597
+ className={`rounded-md px-3 py-2 text-sm font-bold ${
598
+ appointmentMode === 'IN_PERSON'
599
+ ? 'bg-black text-white'
600
+ : 'border border-gray-300 bg-white text-gray-700'
601
+ }`}
602
+ >
603
+ In Person
604
+ </button>
605
+ )}
606
+ {remoteAvailable && (
607
+ <button
608
+ type="button"
609
+ onClick={() => {
610
+ applyAppointmentMode('REMOTE');
611
+ }}
612
+ className={`rounded-md px-3 py-2 text-sm font-bold ${
613
+ appointmentMode === 'REMOTE'
614
+ ? 'bg-black text-white'
615
+ : 'border border-gray-300 bg-white text-gray-700'
616
+ }`}
617
+ >
618
+ Remote
619
+ </button>
620
+ )}
621
+ </div>
622
+ {effectiveRemoteOnly && (
623
+ <p className="mt-2 text-xs font-bold text-gray-500">
624
+ Remote mode is required by shop or service settings.
625
+ </p>
626
+ )}
627
+ </div>
628
+ )}
629
+ <NativeBookingCalendar
630
+ totalDurationMinutes={totalDuration}
631
+ onSlotSelected={handleSlotSelection}
632
+ />
633
+ </div>
469
634
  )}
470
635
  {internalState === 'SUMMARY' && (
471
636
  <div className="space-y-6">
637
+ {needsBooking && (
638
+ <div className="rounded-xl border border-gray-200 bg-white p-4">
639
+ <p className="text-xs font-bold uppercase tracking-wide text-gray-500">
640
+ Appointment Mode
641
+ </p>
642
+ <p className="mt-3 text-sm font-bold text-gray-900">
643
+ {appointmentMode === 'REMOTE' ? 'Remote' : 'In Person'}
644
+ </p>
645
+ {effectiveRemoteOnly && (
646
+ <p className="mt-2 text-xs font-bold text-gray-500">
647
+ Remote mode is required by shop or service settings.
648
+ </p>
649
+ )}
650
+ </div>
651
+ )}
652
+
472
653
  <div className="rounded-xl border border-gray-200 bg-gray-50 p-6">
473
654
  <h3 className="mb-4 font-bold text-gray-900">
474
655
  Order Summary
@@ -492,6 +673,11 @@ export default function CheckoutModal({
492
673
  <p className="text-xs font-bold uppercase text-gray-500">
493
674
  Appointment
494
675
  </p>
676
+ <p className="mt-1 text-xs font-bold uppercase tracking-wide text-gray-500">
677
+ {appointmentMode === 'REMOTE'
678
+ ? 'Remote'
679
+ : 'In Person'}
680
+ </p>
495
681
  <p className="mt-1 text-sm font-bold text-gray-900">
496
682
  {selectedSlot.start.toLocaleDateString('en-US', {
497
683
  timeZone: shopTimeZone,
@@ -12,6 +12,7 @@ import {
12
12
  RESTRICTION_MESSAGES,
13
13
  calculateCartDuration,
14
14
  } from '@/utils/customHelpers';
15
+ import { wouldCartHaveImpossibleRemoteMix } from '@/utils/booking/appointmentMode';
15
16
  import type { ResourceNode } from '@/types/compositorTypes';
16
17
  import type { CartItemState } from '@/stores/shopify';
17
18
  import type { BrandConfigState } from '@/types/tractstack';
@@ -130,54 +131,65 @@ export default function ShopifyCartManager({
130
131
  }
131
132
  }
132
133
 
133
- const rawDuration = calculateCartDuration(nextCart, resources);
134
-
135
- const interval = 15;
136
- const snappedDuration = Math.ceil(rawDuration / interval) * interval;
137
-
138
- const dynamicMax = brandConfig?.scheduling?.maxLengthMinutes || 180;
139
- if (snappedDuration > dynamicMax) {
134
+ if (wouldCartHaveImpossibleRemoteMix(nextCart, resources)) {
140
135
  modalState.set({
141
136
  isOpen: true,
142
137
  type: 'restriction',
143
- title: 'Appointment Length Limit Reached',
144
- message: RESTRICTION_MESSAGES.MAX_DURATION(dynamicMax),
138
+ title: 'Incompatible Booking Modes',
139
+ message: RESTRICTION_MESSAGES.INCOMPATIBLE_REMOTE,
145
140
  });
146
141
  } else {
147
- cartStore.set(nextCart);
148
-
149
- if (!actionItem.suppressModal) {
150
- let targetResource = resource;
151
- if (newItem.boundResourceId) {
152
- const bound = resources.find(
153
- (r) => r.id === newItem.boundResourceId
154
- );
155
- if (bound) {
156
- targetResource = bound;
142
+ const rawDuration = calculateCartDuration(nextCart, resources);
143
+
144
+ const interval = 15;
145
+ const snappedDuration = Math.ceil(rawDuration / interval) * interval;
146
+
147
+ const dynamicMax = brandConfig?.scheduling?.maxLengthMinutes || 180;
148
+ if (snappedDuration > dynamicMax) {
149
+ modalState.set({
150
+ isOpen: true,
151
+ type: 'restriction',
152
+ title: 'Appointment Length Limit Reached',
153
+ message: RESTRICTION_MESSAGES.MAX_DURATION(dynamicMax),
154
+ });
155
+ } else {
156
+ cartStore.set(nextCart);
157
+
158
+ if (!actionItem.suppressModal) {
159
+ let targetResource = resource;
160
+ if (newItem.boundResourceId) {
161
+ const bound = resources.find(
162
+ (r) => r.id === newItem.boundResourceId
163
+ );
164
+ if (bound) {
165
+ targetResource = bound;
166
+ }
157
167
  }
158
- }
159
168
 
160
- if (
161
- targetResource.categorySlug === 'service' ||
162
- targetResource.optionsPayload?.needsBooking
163
- ) {
164
- modalState.set({
165
- isOpen: true,
166
- type: 'success',
167
- title: 'Booking Required',
168
- message: RESTRICTION_MESSAGES.BOOKING(
169
- (
170
- targetResource.optionsPayload?.bookingLengthMinutes || 0
171
- ).toString()
172
- ),
173
- });
174
- } else {
175
- modalState.set({
176
- isOpen: true,
177
- type: 'success',
178
- title: 'Added to Cart',
179
- message: RESTRICTION_MESSAGES.DEFAULT_ADD(targetResource.title),
180
- });
169
+ if (
170
+ targetResource.categorySlug === 'service' ||
171
+ targetResource.optionsPayload?.needsBooking
172
+ ) {
173
+ modalState.set({
174
+ isOpen: true,
175
+ type: 'success',
176
+ title: 'Booking Required',
177
+ message: RESTRICTION_MESSAGES.BOOKING(
178
+ (
179
+ targetResource.optionsPayload?.bookingLengthMinutes || 0
180
+ ).toString()
181
+ ),
182
+ });
183
+ } else {
184
+ modalState.set({
185
+ isOpen: true,
186
+ type: 'success',
187
+ title: 'Added to Cart',
188
+ message: RESTRICTION_MESSAGES.DEFAULT_ADD(
189
+ targetResource.title
190
+ ),
191
+ });
192
+ }
181
193
  }
182
194
  }
183
195
  }