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.
- package/bin/create-tractstack.js +3 -3
- package/dist/index.js +33 -8
- package/package.json +1 -1
- package/templates/custom/shopify/Cart.tsx +83 -14
- package/templates/custom/shopify/CheckoutModal.tsx +192 -6
- package/templates/custom/shopify/ShopifyCartManager.tsx +53 -41
- package/templates/custom/shopify/cart.astro +7 -1
- package/templates/src/components/Header.astro +3 -1
- package/templates/src/components/form/advanced/APIConfigSection.tsx +219 -1
- package/templates/src/components/form/shopify/SchedulingSection.tsx +44 -0
- package/templates/src/components/storykeep/Dashboard_Advanced.tsx +10 -1
- package/templates/src/components/storykeep/Dashboard_Shopify.tsx +9 -0
- package/templates/src/components/storykeep/controls/content/ManageContent.tsx +1 -0
- package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +80 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +86 -8
- package/templates/src/constants.ts +2 -0
- package/templates/src/pages/api/google/oauth/callback.ts +50 -0
- package/templates/src/pages/api/google/oauth/disconnect.ts +32 -0
- package/templates/src/pages/api/google/oauth/start.ts +32 -0
- package/templates/src/pages/api/google/oauth/status.ts +32 -0
- package/templates/src/pages/privacy.astro +84 -0
- package/templates/src/pages/terms.astro +47 -0
- package/templates/src/stores/shopify.ts +5 -0
- package/templates/src/types/tractstack.ts +30 -0
- package/templates/src/utils/api/advancedHelpers.ts +16 -0
- package/templates/src/utils/api/bookingHelpers.ts +3 -1
- package/templates/src/utils/api/brandHelpers.ts +8 -1
- package/templates/src/utils/booking/appointmentMode.ts +135 -0
- package/templates/src/utils/customHelpers.ts +2 -0
- package/utils/inject-files.ts +29 -4
- package/templates/src/components/codehooks/SandboxAuthWrapper.tsx +0 -101
- package/templates/src/utils/actions/actionButton.ts +0 -103
- package/templates/src/utils/actions/preParse_Clicked.ts +0 -87
package/bin/create-tractstack.js
CHANGED
|
@@ -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@
|
|
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
|
|
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
|
|
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 } =
|
|
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
|
-
|
|
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,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({
|
|
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 =
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
<
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
className="
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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 {
|
|
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
|
-
<
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
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: '
|
|
144
|
-
message: RESTRICTION_MESSAGES.
|
|
138
|
+
title: 'Incompatible Booking Modes',
|
|
139
|
+
message: RESTRICTION_MESSAGES.INCOMPATIBLE_REMOTE,
|
|
145
140
|
});
|
|
146
141
|
} else {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
}
|