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
|
@@ -18,6 +18,12 @@ const resources = await getHeaderResources(tenantId, resourceCategories);
|
|
|
18
18
|
|
|
19
19
|
<Layout title="Your Cart" slug="cart">
|
|
20
20
|
<main class="mx-auto max-w-7xl px-4 py-16 md:px-6 xl:px-8">
|
|
21
|
-
<Cart
|
|
21
|
+
<Cart
|
|
22
|
+
resources={resources}
|
|
23
|
+
allowRemote={brandConfig?.SCHEDULING?.allowRemote || false}
|
|
24
|
+
remoteOnly={brandConfig?.SCHEDULING?.remoteOnly || false}
|
|
25
|
+
embedded={true}
|
|
26
|
+
client:only="react"
|
|
27
|
+
/>
|
|
22
28
|
</main>
|
|
23
29
|
</Layout>
|
|
@@ -82,7 +82,9 @@ if (hasShopify) {
|
|
|
82
82
|
<CheckoutModal
|
|
83
83
|
client:only="react"
|
|
84
84
|
resources={shopifyResources}
|
|
85
|
-
maxLength={brandConfig?.
|
|
85
|
+
maxLength={brandConfig?.SCHEDULING?.maxLengthMinutes || 180}
|
|
86
|
+
allowRemote={brandConfig?.SCHEDULING?.allowRemote || false}
|
|
87
|
+
remoteOnly={brandConfig?.SCHEDULING?.remoteOnly || false}
|
|
86
88
|
/>
|
|
87
89
|
)}
|
|
88
90
|
</>
|
|
@@ -13,13 +13,25 @@ interface APIConfigSectionProps {
|
|
|
13
13
|
status: AdvancedConfigStatus | null;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
const GOOGLE_APP_HOME_URL = 'https://renees.freewebpress.com/';
|
|
17
|
+
const GOOGLE_PRIVACY_URL = 'https://renees.freewebpress.com/privacy';
|
|
18
|
+
const GOOGLE_TERMS_URL = 'https://renees.freewebpress.com/terms';
|
|
19
|
+
const GOOGLE_AUTHORIZED_DOMAIN = 'freewebpress.com';
|
|
20
|
+
const GOOGLE_REDIRECT_URI =
|
|
21
|
+
'https://renees.freewebpress.com/api/google/oauth/callback';
|
|
22
|
+
const GOOGLE_SCOPE = 'https://www.googleapis.com/auth/calendar.events';
|
|
23
|
+
const GOOGLE_VERIFICATION_VIDEO_URL =
|
|
24
|
+
'https://www.youtube.com/watch?v=kJI4XdqiiAI';
|
|
25
|
+
|
|
16
26
|
export default function APIConfigSection({
|
|
17
27
|
formState,
|
|
18
28
|
status,
|
|
19
29
|
}: APIConfigSectionProps) {
|
|
20
|
-
const { state, updateField, errors } = formState;
|
|
30
|
+
const { state, updateField, errors, isDirty, saveState } = formState;
|
|
21
31
|
|
|
22
32
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
33
|
+
const [isGoogleConnecting, setIsGoogleConnecting] = useState(false);
|
|
34
|
+
const [isGoogleDisconnecting, setIsGoogleDisconnecting] = useState(false);
|
|
23
35
|
const goBackend =
|
|
24
36
|
import.meta.env.PUBLIC_GO_BACKEND || 'http://localhost:8080';
|
|
25
37
|
|
|
@@ -32,6 +44,18 @@ export default function APIConfigSection({
|
|
|
32
44
|
const shopifyAdminSlugConfigured = status?.shopifyAdminSlugSet;
|
|
33
45
|
const shopifyWebhooksConfigured = status?.userSetupWebhooks;
|
|
34
46
|
const resendConfigured = status?.resendApiKeySet;
|
|
47
|
+
const googleHasSync = status?.hasGoogleSync;
|
|
48
|
+
const googleClientIDConfigured = status?.googleOauthClientIdSet;
|
|
49
|
+
const googleClientSecretConfigured = status?.googleOauthClientSecretSet;
|
|
50
|
+
const googleCalendarConfigured = status?.googleCalendarIdSet;
|
|
51
|
+
const googleCredentialsSaved = Boolean(
|
|
52
|
+
googleClientIDConfigured &&
|
|
53
|
+
googleClientSecretConfigured &&
|
|
54
|
+
googleCalendarConfigured
|
|
55
|
+
);
|
|
56
|
+
const googleCredentialsPendingSave = isDirty || saveState === 'saving';
|
|
57
|
+
const canStartGoogleConnect =
|
|
58
|
+
googleCredentialsSaved && !googleCredentialsPendingSave && !googleHasSync;
|
|
35
59
|
|
|
36
60
|
const renderStatusBadge = (isConfigured: boolean | undefined) => {
|
|
37
61
|
if (status === null) {
|
|
@@ -52,6 +76,44 @@ export default function APIConfigSection({
|
|
|
52
76
|
);
|
|
53
77
|
};
|
|
54
78
|
|
|
79
|
+
const handleGoogleConnect = async () => {
|
|
80
|
+
setIsGoogleConnecting(true);
|
|
81
|
+
try {
|
|
82
|
+
const response = await fetch('/api/google/oauth/start');
|
|
83
|
+
const payload = await response.json();
|
|
84
|
+
if (!response.ok || !payload.authorization) {
|
|
85
|
+
throw new Error(payload?.error || 'Failed to start Google OAuth');
|
|
86
|
+
}
|
|
87
|
+
window.location.href = payload.authorization;
|
|
88
|
+
} catch (error) {
|
|
89
|
+
console.error('Failed to start Google OAuth:', error);
|
|
90
|
+
alert(error instanceof Error ? error.message : 'Google OAuth failed');
|
|
91
|
+
} finally {
|
|
92
|
+
setIsGoogleConnecting(false);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const handleGoogleDisconnect = async () => {
|
|
97
|
+
setIsGoogleDisconnecting(true);
|
|
98
|
+
try {
|
|
99
|
+
const response = await fetch('/api/google/oauth/disconnect', {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
});
|
|
102
|
+
const payload = await response.json();
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
throw new Error(payload?.error || 'Failed to disconnect Google OAuth');
|
|
105
|
+
}
|
|
106
|
+
window.location.reload();
|
|
107
|
+
} catch (error) {
|
|
108
|
+
console.error('Failed to disconnect Google OAuth:', error);
|
|
109
|
+
alert(
|
|
110
|
+
error instanceof Error ? error.message : 'Failed to disconnect Google'
|
|
111
|
+
);
|
|
112
|
+
} finally {
|
|
113
|
+
setIsGoogleDisconnecting(false);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
55
117
|
return (
|
|
56
118
|
<div className="bg-white shadow md:rounded-lg">
|
|
57
119
|
<div className="px-4 py-5 md:p-6">
|
|
@@ -244,6 +306,162 @@ export default function APIConfigSection({
|
|
|
244
306
|
{resendConfigured && ' Leave blank to keep existing key.'}
|
|
245
307
|
</p>
|
|
246
308
|
</div>
|
|
309
|
+
|
|
310
|
+
{/* Google Calendar / Meet Section */}
|
|
311
|
+
<div className="border-t border-gray-100 pt-6">
|
|
312
|
+
<div className="mb-4 flex items-center justify-between">
|
|
313
|
+
<h4 className="text-sm font-bold text-gray-900">
|
|
314
|
+
Google Calendar + Meet (Remote Sync)
|
|
315
|
+
</h4>
|
|
316
|
+
{renderStatusBadge(googleHasSync)}
|
|
317
|
+
</div>
|
|
318
|
+
<div className="space-y-4">
|
|
319
|
+
<StringInput
|
|
320
|
+
label="Google OAuth Client ID"
|
|
321
|
+
value={state.googleOauthClientId}
|
|
322
|
+
onChange={(value) => updateField('googleOauthClientId', value)}
|
|
323
|
+
placeholder={
|
|
324
|
+
googleClientIDConfigured
|
|
325
|
+
? '••••••••••••••••'
|
|
326
|
+
: 'Enter Google OAuth client ID'
|
|
327
|
+
}
|
|
328
|
+
/>
|
|
329
|
+
<StringInput
|
|
330
|
+
label="Google OAuth Client Secret"
|
|
331
|
+
value={state.googleOauthClientSecret}
|
|
332
|
+
onChange={(value) =>
|
|
333
|
+
updateField('googleOauthClientSecret', value)
|
|
334
|
+
}
|
|
335
|
+
type="password"
|
|
336
|
+
placeholder={
|
|
337
|
+
googleClientSecretConfigured
|
|
338
|
+
? '••••••••••••••••'
|
|
339
|
+
: 'Enter Google OAuth client secret'
|
|
340
|
+
}
|
|
341
|
+
/>
|
|
342
|
+
<StringInput
|
|
343
|
+
label="Google Calendar ID"
|
|
344
|
+
value={state.googleCalendarId}
|
|
345
|
+
onChange={(value) => updateField('googleCalendarId', value)}
|
|
346
|
+
placeholder={
|
|
347
|
+
googleCalendarConfigured
|
|
348
|
+
? '••••••••••••••••'
|
|
349
|
+
: 'example@gmail.com or calendar ID'
|
|
350
|
+
}
|
|
351
|
+
/>
|
|
352
|
+
<div className="rounded-md border border-gray-200 bg-gray-50 p-4 text-xs text-gray-700">
|
|
353
|
+
<p className="font-bold text-gray-900">OAuth setup notes</p>
|
|
354
|
+
<p className="mt-2">
|
|
355
|
+
Ensure Google Calendar API is enabled in APIs & Services
|
|
356
|
+
for this same project before running OAuth.
|
|
357
|
+
</p>
|
|
358
|
+
<ol className="mt-2 list-decimal space-y-1 pl-4">
|
|
359
|
+
<li>
|
|
360
|
+
Open Google Auth Platform <strong>Clients</strong>, create a
|
|
361
|
+
Web application client, and set Authorized redirect URI to:
|
|
362
|
+
<code className="ml-1 rounded bg-gray-100 px-1 py-0.5">
|
|
363
|
+
{GOOGLE_REDIRECT_URI}
|
|
364
|
+
</code>
|
|
365
|
+
</li>
|
|
366
|
+
<li>
|
|
367
|
+
Open <strong>Audience</strong> and set:
|
|
368
|
+
<ul className="mt-1 list-disc space-y-1 pl-4">
|
|
369
|
+
<li>App type: External</li>
|
|
370
|
+
<li>Publishing status: In production</li>
|
|
371
|
+
</ul>
|
|
372
|
+
</li>
|
|
373
|
+
<li>
|
|
374
|
+
Open <strong>Data Access</strong> and add scope:
|
|
375
|
+
<code className="ml-1 rounded bg-gray-100 px-1 py-0.5">
|
|
376
|
+
{GOOGLE_SCOPE}
|
|
377
|
+
</code>
|
|
378
|
+
</li>
|
|
379
|
+
<li>
|
|
380
|
+
Open <strong>Branding</strong> and set:
|
|
381
|
+
<ul className="mt-1 list-disc space-y-1 pl-4">
|
|
382
|
+
<li>
|
|
383
|
+
Application home page:
|
|
384
|
+
<code className="ml-1 rounded bg-gray-100 px-1 py-0.5">
|
|
385
|
+
{GOOGLE_APP_HOME_URL}
|
|
386
|
+
</code>
|
|
387
|
+
</li>
|
|
388
|
+
<li>
|
|
389
|
+
Application privacy policy link:
|
|
390
|
+
<code className="ml-1 rounded bg-gray-100 px-1 py-0.5">
|
|
391
|
+
{GOOGLE_PRIVACY_URL}
|
|
392
|
+
</code>
|
|
393
|
+
</li>
|
|
394
|
+
<li>
|
|
395
|
+
Application terms of service link:
|
|
396
|
+
<code className="ml-1 rounded bg-gray-100 px-1 py-0.5">
|
|
397
|
+
{GOOGLE_TERMS_URL}
|
|
398
|
+
</code>
|
|
399
|
+
</li>
|
|
400
|
+
<li>
|
|
401
|
+
Authorized domain:
|
|
402
|
+
<code className="ml-1 rounded bg-gray-100 px-1 py-0.5">
|
|
403
|
+
{GOOGLE_AUTHORIZED_DOMAIN}
|
|
404
|
+
</code>
|
|
405
|
+
</li>
|
|
406
|
+
</ul>
|
|
407
|
+
</li>
|
|
408
|
+
<li>
|
|
409
|
+
Open <strong>Verification Center</strong>, start
|
|
410
|
+
verification, and use this default demo video URL:
|
|
411
|
+
<code className="ml-1 rounded bg-gray-100 px-1 py-0.5">
|
|
412
|
+
{GOOGLE_VERIFICATION_VIDEO_URL}
|
|
413
|
+
</code>
|
|
414
|
+
</li>
|
|
415
|
+
<li>
|
|
416
|
+
Create client credentials and copy:
|
|
417
|
+
<ul className="mt-1 list-disc space-y-1 pl-4">
|
|
418
|
+
<li>
|
|
419
|
+
web.client_id into Google OAuth Client ID in StoryKeep.
|
|
420
|
+
</li>
|
|
421
|
+
<li>
|
|
422
|
+
web.client_secret into Google OAuth Client Secret in
|
|
423
|
+
StoryKeep.
|
|
424
|
+
</li>
|
|
425
|
+
</ul>
|
|
426
|
+
</li>
|
|
427
|
+
<li>
|
|
428
|
+
Open Google Calendar, select the target calendar, then copy
|
|
429
|
+
Calendar ID from Integrate calendar settings.
|
|
430
|
+
</li>
|
|
431
|
+
<li>
|
|
432
|
+
Save credentials in StoryKeep, then click Connect Google to
|
|
433
|
+
complete OAuth and store refresh token linkage.
|
|
434
|
+
</li>
|
|
435
|
+
</ol>
|
|
436
|
+
</div>
|
|
437
|
+
<div className="flex gap-3">
|
|
438
|
+
{canStartGoogleConnect ? (
|
|
439
|
+
<button
|
|
440
|
+
type="button"
|
|
441
|
+
onClick={handleGoogleConnect}
|
|
442
|
+
disabled={isGoogleConnecting}
|
|
443
|
+
className="rounded-md bg-black px-4 py-2 text-xs font-bold text-white hover:bg-gray-800 disabled:opacity-50"
|
|
444
|
+
>
|
|
445
|
+
{isGoogleConnecting ? 'Connecting...' : 'Connect Google'}
|
|
446
|
+
</button>
|
|
447
|
+
) : (
|
|
448
|
+
<div className="flex items-center rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-bold text-amber-800">
|
|
449
|
+
{googleHasSync
|
|
450
|
+
? 'Google is already connected'
|
|
451
|
+
: 'Save Google credentials before connecting'}
|
|
452
|
+
</div>
|
|
453
|
+
)}
|
|
454
|
+
<button
|
|
455
|
+
type="button"
|
|
456
|
+
onClick={handleGoogleDisconnect}
|
|
457
|
+
disabled={isGoogleDisconnecting || !googleHasSync}
|
|
458
|
+
className="rounded-md border border-gray-300 bg-white px-4 py-2 text-xs font-bold text-gray-700 hover:bg-gray-100 disabled:opacity-50"
|
|
459
|
+
>
|
|
460
|
+
{isGoogleDisconnecting ? 'Disconnecting...' : 'Disconnect'}
|
|
461
|
+
</button>
|
|
462
|
+
</div>
|
|
463
|
+
</div>
|
|
464
|
+
</div>
|
|
247
465
|
</div>
|
|
248
466
|
</div>
|
|
249
467
|
|
|
@@ -222,6 +222,50 @@ export default function SchedulingSection({
|
|
|
222
222
|
className="mt-2 block w-full rounded-md border-gray-300 px-2 py-3 focus:border-cyan-700 focus:ring-cyan-700 md:text-sm"
|
|
223
223
|
/>
|
|
224
224
|
</div>
|
|
225
|
+
<div className="md:col-span-3 xl:col-span-4">
|
|
226
|
+
<label className="block text-xs font-black uppercase tracking-widest text-gray-500">
|
|
227
|
+
Allow Remote Booking
|
|
228
|
+
</label>
|
|
229
|
+
<label className="mt-2 flex items-center gap-3">
|
|
230
|
+
<input
|
|
231
|
+
type="checkbox"
|
|
232
|
+
checked={config.remoteOnly ? true : config.allowRemote}
|
|
233
|
+
disabled={config.remoteOnly}
|
|
234
|
+
onChange={(e) =>
|
|
235
|
+
updateField('scheduling', {
|
|
236
|
+
...config,
|
|
237
|
+
allowRemote: e.target.checked,
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
className="h-4 w-4 rounded border-gray-300 text-cyan-700 focus:ring-cyan-700 disabled:opacity-50"
|
|
241
|
+
/>
|
|
242
|
+
<span className="text-sm font-bold text-gray-700">
|
|
243
|
+
Enable remote option in checkout
|
|
244
|
+
</span>
|
|
245
|
+
</label>
|
|
246
|
+
</div>
|
|
247
|
+
<div className="md:col-span-3 xl:col-span-4">
|
|
248
|
+
<label className="block text-xs font-black uppercase tracking-widest text-gray-500">
|
|
249
|
+
Remote Only
|
|
250
|
+
</label>
|
|
251
|
+
<label className="mt-2 flex items-center gap-3">
|
|
252
|
+
<input
|
|
253
|
+
type="checkbox"
|
|
254
|
+
checked={config.remoteOnly}
|
|
255
|
+
onChange={(e) =>
|
|
256
|
+
updateField('scheduling', {
|
|
257
|
+
...config,
|
|
258
|
+
remoteOnly: e.target.checked,
|
|
259
|
+
allowRemote: e.target.checked ? true : config.allowRemote,
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
className="h-4 w-4 rounded border-gray-300 text-cyan-700 focus:ring-cyan-700"
|
|
263
|
+
/>
|
|
264
|
+
<span className="text-sm font-bold text-gray-700">
|
|
265
|
+
Force all bookings to remote
|
|
266
|
+
</span>
|
|
267
|
+
</label>
|
|
268
|
+
</div>
|
|
225
269
|
</div>
|
|
226
270
|
</div>
|
|
227
271
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect } from 'react';
|
|
1
|
+
import { useState, useEffect, useRef } from 'react';
|
|
2
2
|
import { useFormState } from '@/hooks/useFormState';
|
|
3
3
|
import {
|
|
4
4
|
convertToLocalState,
|
|
@@ -28,6 +28,7 @@ export default function StoryKeepDashboard_Advanced({
|
|
|
28
28
|
const [status, setStatus] = useState<AdvancedConfigStatus | null>(null);
|
|
29
29
|
const [isLoading, setIsLoading] = useState(true);
|
|
30
30
|
const [error, setError] = useState<string>('');
|
|
31
|
+
const hasHydratedInitialFormState = useRef(false);
|
|
31
32
|
|
|
32
33
|
// Load status on mount
|
|
33
34
|
useEffect(() => {
|
|
@@ -77,6 +78,14 @@ export default function StoryKeepDashboard_Advanced({
|
|
|
77
78
|
},
|
|
78
79
|
});
|
|
79
80
|
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (!status || hasHydratedInitialFormState.current) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
formState.resetToState(convertToLocalState(status));
|
|
86
|
+
hasHydratedInitialFormState.current = true;
|
|
87
|
+
}, [status, formState]);
|
|
88
|
+
|
|
80
89
|
if (isLoading) {
|
|
81
90
|
return (
|
|
82
91
|
<div className="flex justify-center py-12">
|
|
@@ -278,6 +278,12 @@ export default function StoryKeepDashboard_Shopify({
|
|
|
278
278
|
group: { type: 'string', optional: true },
|
|
279
279
|
shopifyData: { type: 'string', optional: true },
|
|
280
280
|
shopifyImage: { type: 'string', optional: true, defaultValue: '{}' },
|
|
281
|
+
allowRemote: {
|
|
282
|
+
type: 'boolean',
|
|
283
|
+
optional: false,
|
|
284
|
+
defaultValue: false,
|
|
285
|
+
},
|
|
286
|
+
remoteOnly: { type: 'boolean', optional: false, defaultValue: false },
|
|
281
287
|
bookingLengthMinutes: {
|
|
282
288
|
type: 'number',
|
|
283
289
|
optional: false,
|
|
@@ -651,6 +657,9 @@ export default function StoryKeepDashboard_Shopify({
|
|
|
651
657
|
draftResource.categorySlug || ''
|
|
652
658
|
] || {}
|
|
653
659
|
}
|
|
660
|
+
tenantRemoteOnly={Boolean(
|
|
661
|
+
internalBrandConfig?.scheduling?.remoteOnly
|
|
662
|
+
)}
|
|
654
663
|
isCreate={isCreateMode}
|
|
655
664
|
onClose={(saved) => {
|
|
656
665
|
setShowResourceModal(false);
|
|
@@ -278,6 +278,7 @@ const ManageContent = ({
|
|
|
278
278
|
fullContentMap={currentContentMap}
|
|
279
279
|
categorySlug={activeResourceForm.category}
|
|
280
280
|
categorySchema={knownResources[activeResourceForm.category] || {}}
|
|
281
|
+
tenantRemoteOnly={Boolean(brandConfig?.SCHEDULING?.remoteOnly)}
|
|
281
282
|
onClose={async (saved: boolean) => {
|
|
282
283
|
setActiveResourceForm(null);
|
|
283
284
|
if (saved) await refreshData();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
1
2
|
import { useFormState } from '@/hooks/useFormState';
|
|
2
3
|
import { convertToLocalState } from '@/utils/api/resourceHelpers';
|
|
3
4
|
import { saveResourceWithStateUpdate } from '@/utils/api/resourceConfig';
|
|
@@ -26,6 +27,7 @@ interface ResourceFormProps {
|
|
|
26
27
|
fullContentMap: FullContentMapItem[];
|
|
27
28
|
categorySlug: string;
|
|
28
29
|
categorySchema: Record<string, FieldDefinition>;
|
|
30
|
+
tenantRemoteOnly?: boolean;
|
|
29
31
|
isCreate?: boolean;
|
|
30
32
|
onClose?: (saved: boolean) => void;
|
|
31
33
|
}
|
|
@@ -35,6 +37,7 @@ export default function ResourceForm({
|
|
|
35
37
|
fullContentMap,
|
|
36
38
|
categorySlug,
|
|
37
39
|
categorySchema,
|
|
40
|
+
tenantRemoteOnly = false,
|
|
38
41
|
isCreate = false,
|
|
39
42
|
onClose,
|
|
40
43
|
}: ResourceFormProps) {
|
|
@@ -184,6 +187,31 @@ export default function ResourceForm({
|
|
|
184
187
|
});
|
|
185
188
|
|
|
186
189
|
const { state, updateField, errors } = formState;
|
|
190
|
+
const isServiceCategory = categorySlug === 'service';
|
|
191
|
+
const serviceRemoteOnly = Boolean(state.optionsPayload?.remoteOnly);
|
|
192
|
+
const effectiveServiceRemoteOnly = tenantRemoteOnly || serviceRemoteOnly;
|
|
193
|
+
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
if (!isServiceCategory) return;
|
|
196
|
+
if (!effectiveServiceRemoteOnly) return;
|
|
197
|
+
|
|
198
|
+
const nextPayload = {
|
|
199
|
+
...state.optionsPayload,
|
|
200
|
+
allowRemote: true,
|
|
201
|
+
remoteOnly: true,
|
|
202
|
+
};
|
|
203
|
+
const needsUpdate =
|
|
204
|
+
state.optionsPayload?.allowRemote !== true ||
|
|
205
|
+
state.optionsPayload?.remoteOnly !== true;
|
|
206
|
+
if (needsUpdate) {
|
|
207
|
+
updateField('optionsPayload', nextPayload);
|
|
208
|
+
}
|
|
209
|
+
}, [
|
|
210
|
+
effectiveServiceRemoteOnly,
|
|
211
|
+
isServiceCategory,
|
|
212
|
+
state.optionsPayload,
|
|
213
|
+
updateField,
|
|
214
|
+
]);
|
|
187
215
|
|
|
188
216
|
// Helper to get category reference options for a field
|
|
189
217
|
const getCategoryReferenceOptions = (belongsToCategory: string) => {
|
|
@@ -213,6 +241,13 @@ export default function ResourceForm({
|
|
|
213
241
|
};
|
|
214
242
|
|
|
215
243
|
const renderDynamicField = (fieldName: string, fieldDef: FieldDefinition) => {
|
|
244
|
+
if (
|
|
245
|
+
!isServiceCategory &&
|
|
246
|
+
(fieldName === 'allowRemote' || fieldName === 'remoteOnly')
|
|
247
|
+
) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
216
251
|
if (
|
|
217
252
|
resourceFormHideFields.includes(fieldName)
|
|
218
253
|
// && initialData.optionsPayload?.[fieldName]
|
|
@@ -322,6 +357,51 @@ export default function ResourceForm({
|
|
|
322
357
|
);
|
|
323
358
|
|
|
324
359
|
case 'boolean':
|
|
360
|
+
if (isServiceCategory && fieldName === 'allowRemote') {
|
|
361
|
+
const locked = effectiveServiceRemoteOnly;
|
|
362
|
+
return (
|
|
363
|
+
<BooleanToggle
|
|
364
|
+
key={fieldName}
|
|
365
|
+
label="Allow Remote"
|
|
366
|
+
value={locked ? true : Boolean(fieldValue)}
|
|
367
|
+
onChange={(value) =>
|
|
368
|
+
updateOptionsField(fieldName, locked ? true : Boolean(value))
|
|
369
|
+
}
|
|
370
|
+
error={fieldError}
|
|
371
|
+
disabled={locked}
|
|
372
|
+
description={
|
|
373
|
+
locked
|
|
374
|
+
? 'Locked to true because remoteOnly is enabled at tenant or service scope.'
|
|
375
|
+
: undefined
|
|
376
|
+
}
|
|
377
|
+
/>
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
if (isServiceCategory && fieldName === 'remoteOnly') {
|
|
381
|
+
const locked = tenantRemoteOnly;
|
|
382
|
+
return (
|
|
383
|
+
<BooleanToggle
|
|
384
|
+
key={fieldName}
|
|
385
|
+
label="Remote Only"
|
|
386
|
+
value={locked ? true : Boolean(fieldValue)}
|
|
387
|
+
onChange={(value) => {
|
|
388
|
+
const next = Boolean(value);
|
|
389
|
+
updateOptionsField(fieldName, locked ? true : next);
|
|
390
|
+
if (next || locked) {
|
|
391
|
+
updateOptionsField('allowRemote', true);
|
|
392
|
+
}
|
|
393
|
+
}}
|
|
394
|
+
error={fieldError}
|
|
395
|
+
disabled={locked}
|
|
396
|
+
description={
|
|
397
|
+
locked
|
|
398
|
+
? 'Locked to true because tenant scheduling is set to remoteOnly.'
|
|
399
|
+
: 'When enabled, this service can only be booked remotely.'
|
|
400
|
+
}
|
|
401
|
+
/>
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
325
405
|
return (
|
|
326
406
|
<BooleanToggle
|
|
327
407
|
key={fieldName}
|
|
@@ -118,6 +118,26 @@ export default function ShopifyDashboard_Bookings({
|
|
|
118
118
|
}
|
|
119
119
|
};
|
|
120
120
|
|
|
121
|
+
const getModeColor = (mode?: string) => {
|
|
122
|
+
if (mode === 'REMOTE') return 'bg-violet-100 text-violet-800';
|
|
123
|
+
return 'bg-slate-100 text-slate-700';
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const getSyncColor = (syncStatus?: string) => {
|
|
127
|
+
switch (syncStatus) {
|
|
128
|
+
case 'SYNCED':
|
|
129
|
+
case 'DELETE_SYNCED':
|
|
130
|
+
return 'bg-green-100 text-green-800';
|
|
131
|
+
case 'FAILED':
|
|
132
|
+
return 'bg-red-100 text-red-800';
|
|
133
|
+
case 'PENDING':
|
|
134
|
+
case 'DELETE_PENDING':
|
|
135
|
+
return 'bg-amber-100 text-amber-800';
|
|
136
|
+
default:
|
|
137
|
+
return 'bg-gray-100 text-gray-700';
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
121
141
|
const todayStr = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate()).padStart(2, '0')}`;
|
|
122
142
|
|
|
123
143
|
const renderCustomerInfo = (booking: BookingEntity) => {
|
|
@@ -213,15 +233,31 @@ export default function ShopifyDashboard_Bookings({
|
|
|
213
233
|
dayBookings.map((booking) => (
|
|
214
234
|
<div
|
|
215
235
|
key={booking.id}
|
|
216
|
-
className=
|
|
236
|
+
className={`rounded-lg border p-4 shadow-sm transition-colors hover:border-cyan-200 ${
|
|
237
|
+
booking.appointmentMode === 'REMOTE'
|
|
238
|
+
? 'border-violet-200 bg-violet-50'
|
|
239
|
+
: 'border-gray-200 bg-white'
|
|
240
|
+
}`}
|
|
217
241
|
>
|
|
218
242
|
<div className="flex items-start justify-between">
|
|
219
243
|
<div className="space-y-1">
|
|
220
|
-
<
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
244
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
245
|
+
<span
|
|
246
|
+
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-bold ${getStatusColor(booking.status)}`}
|
|
247
|
+
>
|
|
248
|
+
{booking.status}
|
|
249
|
+
</span>
|
|
250
|
+
<span
|
|
251
|
+
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-bold ${getModeColor(booking.appointmentMode)}`}
|
|
252
|
+
>
|
|
253
|
+
{booking.appointmentMode || 'IN_PERSON'}
|
|
254
|
+
</span>
|
|
255
|
+
<span
|
|
256
|
+
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-bold ${getSyncColor(booking.googleSyncStatus)}`}
|
|
257
|
+
>
|
|
258
|
+
{booking.googleSyncStatus || 'NOT_SYNCED'}
|
|
259
|
+
</span>
|
|
260
|
+
</div>
|
|
225
261
|
<div className="text-sm font-bold text-gray-900">
|
|
226
262
|
{new Date(booking.startTime).toLocaleTimeString(
|
|
227
263
|
'en-US',
|
|
@@ -273,6 +309,21 @@ export default function ShopifyDashboard_Bookings({
|
|
|
273
309
|
)
|
|
274
310
|
.join(', ')}
|
|
275
311
|
</div>
|
|
312
|
+
{booking.googleMeetURL && (
|
|
313
|
+
<a
|
|
314
|
+
className="text-cyan-700 underline"
|
|
315
|
+
href={booking.googleMeetURL}
|
|
316
|
+
target="_blank"
|
|
317
|
+
rel="noreferrer"
|
|
318
|
+
>
|
|
319
|
+
Open Meet Link
|
|
320
|
+
</a>
|
|
321
|
+
)}
|
|
322
|
+
{booking.googleLastError && (
|
|
323
|
+
<div className="text-xs font-bold text-red-700">
|
|
324
|
+
Google sync error: {booking.googleLastError}
|
|
325
|
+
</div>
|
|
326
|
+
)}
|
|
276
327
|
</div>
|
|
277
328
|
</div>
|
|
278
329
|
))
|
|
@@ -295,6 +346,9 @@ export default function ShopifyDashboard_Bookings({
|
|
|
295
346
|
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
|
|
296
347
|
Status
|
|
297
348
|
</th>
|
|
349
|
+
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
|
|
350
|
+
Mode / Sync
|
|
351
|
+
</th>
|
|
298
352
|
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
|
|
299
353
|
Service(s)
|
|
300
354
|
</th>
|
|
@@ -312,13 +366,13 @@ export default function ShopifyDashboard_Bookings({
|
|
|
312
366
|
<tbody className="divide-y divide-gray-200 bg-white">
|
|
313
367
|
{isLoading ? (
|
|
314
368
|
<tr>
|
|
315
|
-
<td colSpan={
|
|
369
|
+
<td colSpan={6} className="py-12 text-center">
|
|
316
370
|
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-cyan-600" />
|
|
317
371
|
</td>
|
|
318
372
|
</tr>
|
|
319
373
|
) : bookings.length === 0 ? (
|
|
320
374
|
<tr>
|
|
321
|
-
<td colSpan={
|
|
375
|
+
<td colSpan={6} className="py-12 text-center text-gray-500">
|
|
322
376
|
No bookings found.
|
|
323
377
|
</td>
|
|
324
378
|
</tr>
|
|
@@ -334,6 +388,30 @@ export default function ShopifyDashboard_Bookings({
|
|
|
334
388
|
{booking.status}
|
|
335
389
|
</span>
|
|
336
390
|
</td>
|
|
391
|
+
<td className="whitespace-nowrap px-6 py-4 text-xs">
|
|
392
|
+
<div className="flex flex-col gap-1">
|
|
393
|
+
<span
|
|
394
|
+
className={`inline-flex w-fit items-center rounded-full px-2 py-0.5 font-bold ${getModeColor(booking.appointmentMode)}`}
|
|
395
|
+
>
|
|
396
|
+
{booking.appointmentMode || 'IN_PERSON'}
|
|
397
|
+
</span>
|
|
398
|
+
<span
|
|
399
|
+
className={`inline-flex w-fit items-center rounded-full px-2 py-0.5 font-bold ${getSyncColor(booking.googleSyncStatus)}`}
|
|
400
|
+
>
|
|
401
|
+
{booking.googleSyncStatus || 'NOT_SYNCED'}
|
|
402
|
+
</span>
|
|
403
|
+
{booking.googleMeetURL && (
|
|
404
|
+
<a
|
|
405
|
+
className="text-cyan-700 underline"
|
|
406
|
+
href={booking.googleMeetURL}
|
|
407
|
+
target="_blank"
|
|
408
|
+
rel="noreferrer"
|
|
409
|
+
>
|
|
410
|
+
Meet link
|
|
411
|
+
</a>
|
|
412
|
+
)}
|
|
413
|
+
</div>
|
|
414
|
+
</td>
|
|
337
415
|
<td className="px-6 py-4 text-sm text-gray-900">
|
|
338
416
|
{booking.resourceIds
|
|
339
417
|
.map((id) => resourceMap.get(id) || 'Unknown Service')
|