astro-tractstack 2.3.0 → 2.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/create-tractstack.js +2 -2
- package/dist/index.js +130 -19
- package/package.json +2 -2
- package/templates/custom/minimal/CodeHook.astro +10 -2
- package/templates/custom/shopify/Cart.tsx +115 -77
- package/templates/custom/shopify/CheckoutModal.tsx +509 -120
- package/templates/custom/shopify/NativeBookingCalendar.tsx +375 -0
- package/templates/custom/shopify/ShopifyCartManager.tsx +91 -45
- package/templates/custom/shopify/ShopifyCheckout.tsx +4 -33
- package/templates/custom/shopify/ShopifyProductGrid.tsx +170 -176
- package/templates/custom/shopify/ShopifyServiceList.tsx +112 -51
- package/templates/custom/with-examples/CodeHook.astro +10 -2
- package/templates/src/components/Footer.astro +6 -6
- package/templates/src/components/Header.astro +23 -11
- package/templates/src/components/Menu.tsx +157 -135
- package/templates/src/components/codehooks/BunnyVideoSetup.tsx +2 -2
- package/templates/src/components/codehooks/EpinetDurationSelector.tsx +27 -6
- package/templates/src/components/codehooks/EpinetTableView.tsx +153 -112
- package/templates/src/components/codehooks/EpinetWrapper.tsx +4 -1
- package/templates/src/components/codehooks/FeaturedArticleSetup.tsx +8 -1
- package/templates/src/components/codehooks/ProductCardSetup.tsx +9 -1
- package/templates/src/components/codehooks/ProductGridSetup.tsx +9 -1
- package/templates/src/components/compositor/nodes/BgPaneWrapper.tsx +2 -1
- package/templates/src/components/compositor/nodes/GhostInsertBlock.tsx +1 -1
- package/templates/src/components/edit/ToolBar.tsx +2 -1
- package/templates/src/components/edit/context/ContextPaneConfig_slug.tsx +2 -2
- package/templates/src/components/edit/pane/AddPanePanel_codehook.tsx +13 -0
- package/templates/src/components/edit/pane/AddPanePanel_new.tsx +3 -3
- package/templates/src/components/edit/pane/AddPanePanel_newCustomCopy.tsx +2 -2
- package/templates/src/components/edit/pane/AiRestylePaneModal.tsx +2 -2
- package/templates/src/components/edit/pane/ConfigPanePanel.tsx +1 -1
- package/templates/src/components/edit/pane/steps/AiCreativeDesignStep.tsx +2 -2
- package/templates/src/components/edit/pane/steps/AiLibraryCopyStep.tsx +3 -3
- package/templates/src/components/edit/pane/steps/AiRefineDesignStep.tsx +2 -2
- package/templates/src/components/edit/pane/steps/AiStandardDesignStep.tsx +7 -7
- package/templates/src/components/edit/state/SaveModal.tsx +1 -1
- package/templates/src/components/edit/widgets/InteractiveDisclosureWidget.tsx +8 -3
- package/templates/src/components/form/DateTimeInput.tsx +10 -3
- package/templates/src/components/form/FileUpload.tsx +11 -5
- package/templates/src/components/form/NumberInput.tsx +2 -2
- package/templates/src/components/form/advanced/APIConfigSection.tsx +208 -2
- package/templates/src/components/form/brand/SiteConfigSection.tsx +10 -0
- package/templates/src/components/form/shopify/SchedulingSection.tsx +354 -0
- package/templates/src/components/storykeep/Dashboard.tsx +1 -1
- package/templates/src/components/storykeep/Dashboard_Shopify.tsx +252 -110
- package/templates/src/components/storykeep/controls/content/BeliefForm.tsx +2 -2
- package/templates/src/components/storykeep/controls/content/BeliefTable.tsx +14 -5
- package/templates/src/components/storykeep/controls/content/KnownResourceTable.tsx +5 -2
- package/templates/src/components/storykeep/controls/content/MenuTable.tsx +14 -5
- package/templates/src/components/storykeep/controls/content/ProductTable.tsx +180 -101
- package/templates/src/components/storykeep/controls/content/ResourceBulkIngest.tsx +88 -56
- package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +14 -4
- package/templates/src/components/storykeep/controls/content/StoryFragmentTable.tsx +14 -5
- package/templates/src/components/storykeep/email-builder/Blocks.tsx +169 -0
- package/templates/src/components/storykeep/email-builder/EmailBuilder.tsx +223 -0
- package/templates/src/components/storykeep/email-builder/PreviewModal.tsx +136 -0
- package/templates/src/components/storykeep/email-builder/PropertyPanel.tsx +154 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +104 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +419 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Emails.tsx +105 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Products.tsx +46 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Schedule.tsx +78 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Search.tsx +55 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Services.tsx +47 -0
- package/templates/src/layouts/Layout.astro +8 -5
- package/templates/src/pages/api/auth/lookup-lead.ts +72 -0
- package/templates/src/pages/api/booking/availability.ts +72 -0
- package/templates/src/pages/api/booking/cancel.ts +73 -0
- package/templates/src/pages/api/booking/confirm.ts +82 -0
- package/templates/src/pages/api/booking/hold.ts +75 -0
- package/templates/src/pages/api/booking/list.ts +66 -0
- package/templates/src/pages/api/booking/metrics.ts +60 -0
- package/templates/src/pages/api/booking/release.ts +76 -0
- package/templates/src/pages/api/sandbox.ts +2 -2
- package/templates/src/pages/api/shopify/createCart.ts +4 -8
- package/templates/src/pages/api/shopify/getProducts.ts +15 -15
- package/templates/src/pages/storykeep/login.astro +21 -14
- package/templates/src/stores/shopify.ts +97 -25
- package/templates/src/types/formTypes.ts +4 -2
- package/templates/src/types/tractstack.ts +59 -2
- package/templates/src/utils/api/advancedConfig.ts +2 -0
- package/templates/src/utils/api/advancedHelpers.ts +40 -3
- package/templates/src/utils/api/bookingHelpers.ts +125 -0
- package/templates/src/utils/api/brandConfig.ts +2 -0
- package/templates/src/utils/api/brandHelpers.ts +26 -0
- package/templates/src/utils/api/emailHelpers.ts +105 -0
- package/templates/src/utils/auth.ts +29 -9
- package/templates/src/utils/compositor/aiGeneration.ts +3 -3
- package/templates/src/utils/compositor/aiPaneParser.ts +2 -2
- package/templates/src/utils/customHelpers.ts +0 -21
- package/templates/src/utils/profileStorage.ts +5 -0
- package/templates/src/utils/tenantResolver.ts +3 -2
- package/utils/inject-files.ts +116 -5
- package/templates/custom/shopify/CalDotComBooking.tsx +0 -44
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { EmailBlock } from '@/utils/api/emailHelpers';
|
|
2
|
+
|
|
3
|
+
interface PropertyPanelProps {
|
|
4
|
+
block: EmailBlock;
|
|
5
|
+
onChange: (block: EmailBlock) => void;
|
|
6
|
+
onDelete: () => void;
|
|
7
|
+
onMoveUp: () => void;
|
|
8
|
+
onMoveDown: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default function PropertyPanel({
|
|
12
|
+
block,
|
|
13
|
+
onChange,
|
|
14
|
+
onDelete,
|
|
15
|
+
onMoveUp,
|
|
16
|
+
onMoveDown,
|
|
17
|
+
}: PropertyPanelProps) {
|
|
18
|
+
return (
|
|
19
|
+
<div className="space-y-6">
|
|
20
|
+
<div className="flex min-w-0 items-center justify-between border-b border-gray-200 pb-4">
|
|
21
|
+
<h3 className="min-w-0 flex-1 truncate text-sm font-bold capitalize text-gray-900">
|
|
22
|
+
{block.type} Settings
|
|
23
|
+
</h3>
|
|
24
|
+
<div className="flex shrink-0 gap-2 text-gray-400">
|
|
25
|
+
<button onClick={onMoveUp} className="hover:text-gray-900">
|
|
26
|
+
↑
|
|
27
|
+
</button>
|
|
28
|
+
<button onClick={onMoveDown} className="hover:text-gray-900">
|
|
29
|
+
↓
|
|
30
|
+
</button>
|
|
31
|
+
<button onClick={onDelete} className="hover:text-red-600">
|
|
32
|
+
×
|
|
33
|
+
</button>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
{block.type === 'text' && (
|
|
38
|
+
<div className="space-y-4">
|
|
39
|
+
<div>
|
|
40
|
+
<label className="mb-1 block text-xs font-bold text-gray-700">
|
|
41
|
+
Alignment
|
|
42
|
+
</label>
|
|
43
|
+
<select
|
|
44
|
+
value={block.align}
|
|
45
|
+
onChange={(e) =>
|
|
46
|
+
onChange({ ...block, align: e.target.value as any })
|
|
47
|
+
}
|
|
48
|
+
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-cyan-500 focus:ring-cyan-500"
|
|
49
|
+
>
|
|
50
|
+
<option value="left">Left</option>
|
|
51
|
+
<option value="center">Center</option>
|
|
52
|
+
<option value="right">Right</option>
|
|
53
|
+
</select>
|
|
54
|
+
</div>
|
|
55
|
+
<div>
|
|
56
|
+
<label className="mb-1 block text-xs font-bold text-gray-700">
|
|
57
|
+
Text Color
|
|
58
|
+
</label>
|
|
59
|
+
<div className="flex gap-2">
|
|
60
|
+
<input
|
|
61
|
+
type="color"
|
|
62
|
+
value={block.color}
|
|
63
|
+
onChange={(e) => onChange({ ...block, color: e.target.value })}
|
|
64
|
+
className="h-8 w-8 cursor-pointer rounded"
|
|
65
|
+
/>
|
|
66
|
+
<input
|
|
67
|
+
type="text"
|
|
68
|
+
value={block.color}
|
|
69
|
+
onChange={(e) => onChange({ ...block, color: e.target.value })}
|
|
70
|
+
className="flex-1 rounded-md border border-gray-300 px-3 py-1 text-sm"
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
<div className="flex items-center gap-2">
|
|
75
|
+
<input
|
|
76
|
+
type="checkbox"
|
|
77
|
+
checked={block.isBold}
|
|
78
|
+
onChange={(e) => onChange({ ...block, isBold: e.target.checked })}
|
|
79
|
+
className="rounded border-gray-300 text-cyan-600 focus:ring-cyan-500"
|
|
80
|
+
/>
|
|
81
|
+
<label className="text-sm font-bold text-gray-700">Bold Text</label>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
85
|
+
|
|
86
|
+
{block.type === 'button' && (
|
|
87
|
+
<div className="space-y-4">
|
|
88
|
+
<div>
|
|
89
|
+
<label className="mb-1 block text-xs font-bold text-gray-700">
|
|
90
|
+
Label
|
|
91
|
+
</label>
|
|
92
|
+
<input
|
|
93
|
+
type="text"
|
|
94
|
+
value={block.label}
|
|
95
|
+
onChange={(e) => onChange({ ...block, label: e.target.value })}
|
|
96
|
+
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-cyan-500 focus:ring-cyan-500"
|
|
97
|
+
/>
|
|
98
|
+
</div>
|
|
99
|
+
<div>
|
|
100
|
+
<label className="mb-1 block text-xs font-bold text-gray-700">
|
|
101
|
+
URL
|
|
102
|
+
</label>
|
|
103
|
+
<input
|
|
104
|
+
type="text"
|
|
105
|
+
value={block.url}
|
|
106
|
+
onChange={(e) => onChange({ ...block, url: e.target.value })}
|
|
107
|
+
className="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm focus:border-cyan-500 focus:ring-cyan-500"
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
<div>
|
|
111
|
+
<label className="mb-1 block text-xs font-bold text-gray-700">
|
|
112
|
+
Background Color
|
|
113
|
+
</label>
|
|
114
|
+
<input
|
|
115
|
+
type="color"
|
|
116
|
+
value={block.bgColor}
|
|
117
|
+
onChange={(e) => onChange({ ...block, bgColor: e.target.value })}
|
|
118
|
+
className="h-8 w-full cursor-pointer rounded"
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
<div>
|
|
122
|
+
<label className="mb-1 block text-xs font-bold text-gray-700">
|
|
123
|
+
Text Color
|
|
124
|
+
</label>
|
|
125
|
+
<input
|
|
126
|
+
type="color"
|
|
127
|
+
value={block.textColor}
|
|
128
|
+
onChange={(e) =>
|
|
129
|
+
onChange({ ...block, textColor: e.target.value })
|
|
130
|
+
}
|
|
131
|
+
className="h-8 w-full cursor-pointer rounded"
|
|
132
|
+
/>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
|
|
137
|
+
{block.type === 'divider' && (
|
|
138
|
+
<div className="space-y-4">
|
|
139
|
+
<div>
|
|
140
|
+
<label className="mb-1 block text-xs font-bold text-gray-700">
|
|
141
|
+
Line Color
|
|
142
|
+
</label>
|
|
143
|
+
<input
|
|
144
|
+
type="color"
|
|
145
|
+
value={block.color}
|
|
146
|
+
onChange={(e) => onChange({ ...block, color: e.target.value })}
|
|
147
|
+
className="h-8 w-full cursor-pointer rounded"
|
|
148
|
+
/>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { bookingHelpers } from '@/utils/api/bookingHelpers';
|
|
3
|
+
import type { BookingMetricsResponse } from '@/types/tractstack';
|
|
4
|
+
|
|
5
|
+
export default function ShopifyDashboard({}) {
|
|
6
|
+
const [metrics, setMetrics] = useState<BookingMetricsResponse | null>(null);
|
|
7
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
8
|
+
const [error, setError] = useState<string | null>(null);
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const loadMetrics = async () => {
|
|
12
|
+
try {
|
|
13
|
+
setIsLoading(true);
|
|
14
|
+
const data = await bookingHelpers.getMetrics();
|
|
15
|
+
setMetrics(data);
|
|
16
|
+
} catch (err) {
|
|
17
|
+
console.error('Failed to fetch metrics:', err);
|
|
18
|
+
setError('Failed to load dashboard metrics.');
|
|
19
|
+
} finally {
|
|
20
|
+
setIsLoading(false);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
loadMetrics();
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
if (isLoading) {
|
|
28
|
+
return (
|
|
29
|
+
<div className="flex h-48 items-center justify-center rounded-lg border-2 border-dashed border-gray-200">
|
|
30
|
+
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-cyan-600" />
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (error) {
|
|
36
|
+
return (
|
|
37
|
+
<div className="rounded-lg border-2 border-red-200 bg-red-50 p-6 text-center">
|
|
38
|
+
<p className="font-bold text-red-600">{error}</p>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const totalLast24h =
|
|
44
|
+
(metrics?.confirmedLast24h || 0) + (metrics?.pendingLast24h || 0);
|
|
45
|
+
const intentRatio =
|
|
46
|
+
totalLast24h > 0
|
|
47
|
+
? Math.round(((metrics?.confirmedLast24h || 0) / totalLast24h) * 100)
|
|
48
|
+
: 0;
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="space-y-6">
|
|
52
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
53
|
+
<div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
|
54
|
+
<h3 className="text-sm font-bold text-gray-500">Monthly Confirmed</h3>
|
|
55
|
+
<p className="mt-2 text-3xl font-bold text-gray-900">
|
|
56
|
+
{metrics?.totalMonthlyConfirmed || 0}
|
|
57
|
+
</p>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
|
61
|
+
<h3 className="text-sm font-bold text-gray-500">Weekly Confirmed</h3>
|
|
62
|
+
<p className="mt-2 text-3xl font-bold text-gray-900">
|
|
63
|
+
{metrics?.totalWeeklyConfirmed || 0}
|
|
64
|
+
</p>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
|
68
|
+
<h3 className="text-sm font-bold text-gray-500">Annual Confirmed</h3>
|
|
69
|
+
<p className="mt-2 text-3xl font-bold text-gray-900">
|
|
70
|
+
{metrics?.totalAnnualConfirmed || 0}
|
|
71
|
+
</p>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
|
75
|
+
<h3 className="text-sm font-bold text-gray-500">
|
|
76
|
+
Total Leads Converted
|
|
77
|
+
</h3>
|
|
78
|
+
<p className="mt-2 text-3xl font-bold text-gray-900">
|
|
79
|
+
{metrics?.leadConversionAnchor || 0}
|
|
80
|
+
</p>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
|
84
|
+
<h3 className="text-sm font-bold text-gray-500">
|
|
85
|
+
Pending (Last 24h)
|
|
86
|
+
</h3>
|
|
87
|
+
<p className="mt-2 text-3xl font-bold text-gray-900">
|
|
88
|
+
{metrics?.pendingLast24h || 0}
|
|
89
|
+
</p>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
|
93
|
+
<h3 className="text-sm font-bold text-gray-500">
|
|
94
|
+
Checkout Intent Ratio
|
|
95
|
+
</h3>
|
|
96
|
+
<div className="mt-2 flex items-baseline gap-2">
|
|
97
|
+
<p className="text-3xl font-bold text-gray-900">{intentRatio}%</p>
|
|
98
|
+
<p className="text-sm font-bold text-gray-500">conversion</p>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useState,
|
|
3
|
+
useEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useCallback,
|
|
6
|
+
type ChangeEvent,
|
|
7
|
+
} from 'react';
|
|
8
|
+
import { Toggle } from '@ark-ui/react/toggle';
|
|
9
|
+
import CalendarIcon from '@heroicons/react/24/outline/CalendarIcon';
|
|
10
|
+
import TableCellsIcon from '@heroicons/react/24/outline/TableCellsIcon';
|
|
11
|
+
import XCircleIcon from '@heroicons/react/24/outline/XCircleIcon';
|
|
12
|
+
import { bookingHelpers } from '@/utils/api/bookingHelpers';
|
|
13
|
+
import type { BookingEntity } from '@/types/tractstack';
|
|
14
|
+
import type { ResourceNode } from '@/types/compositorTypes';
|
|
15
|
+
|
|
16
|
+
interface ShopifyDashboardBookingsProps {
|
|
17
|
+
existingResources: ResourceNode[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ITEMS_PER_PAGE = 10;
|
|
21
|
+
|
|
22
|
+
export default function ShopifyDashboard_Bookings({
|
|
23
|
+
existingResources,
|
|
24
|
+
}: ShopifyDashboardBookingsProps) {
|
|
25
|
+
const [isCalendarView, setIsCalendarView] = useState(false);
|
|
26
|
+
const [currentPage, setCurrentPage] = useState(0);
|
|
27
|
+
const [statusFilter, setStatusFilter] = useState('ALL');
|
|
28
|
+
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
|
29
|
+
const [shopTimezone, setShopTimezone] = useState(
|
|
30
|
+
Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const [bookings, setBookings] = useState<BookingEntity[]>([]);
|
|
34
|
+
const [totalCount, setTotalCount] = useState(0);
|
|
35
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
36
|
+
const [isCanceling, setIsCanceling] = useState<string | null>(null);
|
|
37
|
+
|
|
38
|
+
const resourceMap = useMemo(() => {
|
|
39
|
+
return new Map(existingResources.map((r) => [r.id, r.title]));
|
|
40
|
+
}, [existingResources]);
|
|
41
|
+
|
|
42
|
+
const fetchBookings = useCallback(async () => {
|
|
43
|
+
setIsLoading(true);
|
|
44
|
+
try {
|
|
45
|
+
const limit = isCalendarView ? 100 : ITEMS_PER_PAGE;
|
|
46
|
+
const offset = isCalendarView ? 0 : currentPage * ITEMS_PER_PAGE;
|
|
47
|
+
const response = await bookingHelpers.listBookings(
|
|
48
|
+
limit,
|
|
49
|
+
offset,
|
|
50
|
+
statusFilter
|
|
51
|
+
);
|
|
52
|
+
setBookings(response.data || []);
|
|
53
|
+
setTotalCount(response.totalCount || 0);
|
|
54
|
+
|
|
55
|
+
const availability = await bookingHelpers.getAvailability(
|
|
56
|
+
new Date().toISOString(),
|
|
57
|
+
new Date().toISOString()
|
|
58
|
+
);
|
|
59
|
+
if (availability?.scheduling?.timezone) {
|
|
60
|
+
setShopTimezone(availability.scheduling.timezone);
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error('Failed to fetch bookings:', error);
|
|
64
|
+
} finally {
|
|
65
|
+
setIsLoading(false);
|
|
66
|
+
}
|
|
67
|
+
}, [currentPage, statusFilter, isCalendarView]);
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
fetchBookings();
|
|
71
|
+
}, [fetchBookings]);
|
|
72
|
+
|
|
73
|
+
const handlePageChange = (newPage: number) => {
|
|
74
|
+
setCurrentPage(newPage);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const handleStatusChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
|
78
|
+
setStatusFilter(e.target.value);
|
|
79
|
+
setCurrentPage(0);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const handleCancelBooking = async (traceId: string) => {
|
|
83
|
+
if (!window.confirm('Are you sure you want to cancel this booking?')) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
setIsCanceling(traceId);
|
|
88
|
+
try {
|
|
89
|
+
await bookingHelpers.cancelBooking(traceId);
|
|
90
|
+
await fetchBookings();
|
|
91
|
+
} catch (error) {
|
|
92
|
+
console.error('Failed to cancel booking:', error);
|
|
93
|
+
alert('Failed to cancel booking. Please try again.');
|
|
94
|
+
} finally {
|
|
95
|
+
setIsCanceling(null);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const dayBookings = useMemo(() => {
|
|
100
|
+
return bookings.filter((b) => {
|
|
101
|
+
const bDate = new Date(b.startTime);
|
|
102
|
+
return bDate.toLocaleDateString() === selectedDate.toLocaleDateString();
|
|
103
|
+
});
|
|
104
|
+
}, [bookings, selectedDate]);
|
|
105
|
+
|
|
106
|
+
const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
|
|
107
|
+
|
|
108
|
+
const getStatusColor = (status: string) => {
|
|
109
|
+
switch (status) {
|
|
110
|
+
case 'CONFIRMED':
|
|
111
|
+
return 'bg-green-100 text-green-800';
|
|
112
|
+
case 'PENDING':
|
|
113
|
+
return 'bg-yellow-100 text-yellow-800';
|
|
114
|
+
case 'CANCELLED':
|
|
115
|
+
return 'bg-red-100 text-red-800';
|
|
116
|
+
default:
|
|
117
|
+
return 'bg-gray-100 text-gray-800';
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const todayStr = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate()).padStart(2, '0')}`;
|
|
122
|
+
|
|
123
|
+
const renderCustomerInfo = (booking: BookingEntity) => {
|
|
124
|
+
if (booking.leadName && booking.leadEmail) {
|
|
125
|
+
return `${booking.leadName} (${booking.leadEmail})`;
|
|
126
|
+
}
|
|
127
|
+
return 'Guest';
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div className="space-y-6">
|
|
132
|
+
<div className="flex flex-wrap items-center justify-between gap-4 rounded-lg border border-gray-200 bg-gray-50 p-4">
|
|
133
|
+
<div className="flex items-center gap-4">
|
|
134
|
+
<select
|
|
135
|
+
value={statusFilter}
|
|
136
|
+
onChange={handleStatusChange}
|
|
137
|
+
className="rounded-md border border-gray-300 py-1.5 pl-3 pr-8 text-sm text-gray-700 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
|
138
|
+
>
|
|
139
|
+
<option value="ALL">All Statuses</option>
|
|
140
|
+
<option value="CONFIRMED">Confirmed</option>
|
|
141
|
+
<option value="PENDING">Pending</option>
|
|
142
|
+
<option value="CANCELLED">Cancelled</option>
|
|
143
|
+
</select>
|
|
144
|
+
<span className="text-sm text-gray-500">
|
|
145
|
+
{totalCount} total bookings
|
|
146
|
+
</span>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<div className="flex items-center gap-2 border-l border-gray-300 pl-6">
|
|
150
|
+
<span className="text-xs font-bold uppercase tracking-wider text-gray-500">
|
|
151
|
+
View:
|
|
152
|
+
</span>
|
|
153
|
+
<Toggle.Root
|
|
154
|
+
pressed={isCalendarView}
|
|
155
|
+
onPressedChange={setIsCalendarView}
|
|
156
|
+
className={`flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs font-bold shadow-sm transition-all ${
|
|
157
|
+
isCalendarView
|
|
158
|
+
? 'border-cyan-600 bg-cyan-600 text-white'
|
|
159
|
+
: 'border-gray-300 bg-white text-gray-600 hover:bg-gray-50'
|
|
160
|
+
}`}
|
|
161
|
+
>
|
|
162
|
+
{isCalendarView ? (
|
|
163
|
+
<>
|
|
164
|
+
<CalendarIcon className="h-4 w-4" />
|
|
165
|
+
Calendar
|
|
166
|
+
</>
|
|
167
|
+
) : (
|
|
168
|
+
<>
|
|
169
|
+
<TableCellsIcon className="h-4 w-4" />
|
|
170
|
+
Table
|
|
171
|
+
</>
|
|
172
|
+
)}
|
|
173
|
+
</Toggle.Root>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
{isCalendarView ? (
|
|
178
|
+
<div className="flex flex-col space-y-6">
|
|
179
|
+
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
|
180
|
+
<div className="space-y-4">
|
|
181
|
+
<label className="block text-sm font-bold text-gray-700">
|
|
182
|
+
Select Date
|
|
183
|
+
</label>
|
|
184
|
+
<input
|
|
185
|
+
type="date"
|
|
186
|
+
defaultValue={todayStr}
|
|
187
|
+
onChange={(e) => {
|
|
188
|
+
const [y, m, d] = e.target.value.split('-');
|
|
189
|
+
setSelectedDate(
|
|
190
|
+
new Date(parseInt(y), parseInt(m) - 1, parseInt(d))
|
|
191
|
+
);
|
|
192
|
+
}}
|
|
193
|
+
className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-cyan-600 focus:outline-none focus:ring-1 focus:ring-cyan-600"
|
|
194
|
+
/>
|
|
195
|
+
<div className="rounded-md border border-cyan-100 bg-cyan-50 p-4">
|
|
196
|
+
<p className="text-xs text-cyan-800">
|
|
197
|
+
Showing loaded bookings in <strong>{shopTimezone}</strong>{' '}
|
|
198
|
+
time.
|
|
199
|
+
</p>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
<div className="space-y-4">
|
|
204
|
+
<label className="block text-sm font-bold text-gray-700">
|
|
205
|
+
Bookings for {selectedDate.toLocaleDateString()}
|
|
206
|
+
</label>
|
|
207
|
+
<div className="grid max-h-96 grid-cols-1 gap-3 overflow-y-auto pr-2">
|
|
208
|
+
{isLoading ? (
|
|
209
|
+
<div className="flex h-32 items-center justify-center">
|
|
210
|
+
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-cyan-600" />
|
|
211
|
+
</div>
|
|
212
|
+
) : dayBookings.length > 0 ? (
|
|
213
|
+
dayBookings.map((booking) => (
|
|
214
|
+
<div
|
|
215
|
+
key={booking.id}
|
|
216
|
+
className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition-colors hover:border-cyan-200"
|
|
217
|
+
>
|
|
218
|
+
<div className="flex items-start justify-between">
|
|
219
|
+
<div className="space-y-1">
|
|
220
|
+
<span
|
|
221
|
+
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-bold ${getStatusColor(booking.status)}`}
|
|
222
|
+
>
|
|
223
|
+
{booking.status}
|
|
224
|
+
</span>
|
|
225
|
+
<div className="text-sm font-bold text-gray-900">
|
|
226
|
+
{new Date(booking.startTime).toLocaleTimeString(
|
|
227
|
+
'en-US',
|
|
228
|
+
{
|
|
229
|
+
hour: '2-digit',
|
|
230
|
+
minute: '2-digit',
|
|
231
|
+
timeZone: shopTimezone,
|
|
232
|
+
}
|
|
233
|
+
)}
|
|
234
|
+
{' - '}
|
|
235
|
+
{new Date(booking.endTime).toLocaleTimeString(
|
|
236
|
+
'en-US',
|
|
237
|
+
{
|
|
238
|
+
hour: '2-digit',
|
|
239
|
+
minute: '2-digit',
|
|
240
|
+
timeZone: shopTimezone,
|
|
241
|
+
}
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
{booking.status !== 'CANCELLED' && (
|
|
246
|
+
<button
|
|
247
|
+
onClick={() => handleCancelBooking(booking.id)}
|
|
248
|
+
disabled={isCanceling === booking.id}
|
|
249
|
+
className="text-red-600 hover:text-red-900 disabled:opacity-50"
|
|
250
|
+
>
|
|
251
|
+
<XCircleIcon className="h-5 w-5" />
|
|
252
|
+
</button>
|
|
253
|
+
)}
|
|
254
|
+
</div>
|
|
255
|
+
<div className="mt-3 space-y-1 text-xs text-gray-500">
|
|
256
|
+
<div className="flex items-center justify-between text-gray-900">
|
|
257
|
+
<span>{renderCustomerInfo(booking)}</span>
|
|
258
|
+
{booking.shopifyOrderId && (
|
|
259
|
+
<a
|
|
260
|
+
href={`https://admin.shopify.com/orders/${booking.shopifyOrderId}`}
|
|
261
|
+
target="_blank"
|
|
262
|
+
rel="noopener noreferrer"
|
|
263
|
+
className="text-cyan-600 hover:text-cyan-800 hover:underline"
|
|
264
|
+
>
|
|
265
|
+
Order #{booking.shopifyOrderId}
|
|
266
|
+
</a>
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
<div className="text-gray-700">
|
|
270
|
+
{booking.resourceIds
|
|
271
|
+
.map(
|
|
272
|
+
(id) => resourceMap.get(id) || 'Unknown Service'
|
|
273
|
+
)
|
|
274
|
+
.join(', ')}
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
))
|
|
279
|
+
) : (
|
|
280
|
+
<div className="flex h-32 flex-col items-center justify-center rounded-md border border-dashed border-gray-200 text-gray-400">
|
|
281
|
+
<CalendarIcon className="mb-1 h-6 w-6" />
|
|
282
|
+
<p className="text-xs">No bookings found for this date.</p>
|
|
283
|
+
</div>
|
|
284
|
+
)}
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
) : (
|
|
290
|
+
<div className="space-y-4">
|
|
291
|
+
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
|
|
292
|
+
<table className="min-w-full divide-y divide-gray-200">
|
|
293
|
+
<thead className="bg-gray-50">
|
|
294
|
+
<tr>
|
|
295
|
+
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
|
|
296
|
+
Status
|
|
297
|
+
</th>
|
|
298
|
+
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
|
|
299
|
+
Service(s)
|
|
300
|
+
</th>
|
|
301
|
+
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
|
|
302
|
+
Customer
|
|
303
|
+
</th>
|
|
304
|
+
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
|
|
305
|
+
Date & Time
|
|
306
|
+
</th>
|
|
307
|
+
<th className="px-6 py-3 text-right text-xs font-bold uppercase tracking-wider text-gray-500">
|
|
308
|
+
Actions
|
|
309
|
+
</th>
|
|
310
|
+
</tr>
|
|
311
|
+
</thead>
|
|
312
|
+
<tbody className="divide-y divide-gray-200 bg-white">
|
|
313
|
+
{isLoading ? (
|
|
314
|
+
<tr>
|
|
315
|
+
<td colSpan={5} className="py-12 text-center">
|
|
316
|
+
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-cyan-600" />
|
|
317
|
+
</td>
|
|
318
|
+
</tr>
|
|
319
|
+
) : bookings.length === 0 ? (
|
|
320
|
+
<tr>
|
|
321
|
+
<td colSpan={5} className="py-12 text-center text-gray-500">
|
|
322
|
+
No bookings found.
|
|
323
|
+
</td>
|
|
324
|
+
</tr>
|
|
325
|
+
) : (
|
|
326
|
+
bookings.map((booking) => (
|
|
327
|
+
<tr key={booking.id} className="hover:bg-gray-50">
|
|
328
|
+
<td className="whitespace-nowrap px-6 py-4">
|
|
329
|
+
<span
|
|
330
|
+
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold ${getStatusColor(
|
|
331
|
+
booking.status
|
|
332
|
+
)}`}
|
|
333
|
+
>
|
|
334
|
+
{booking.status}
|
|
335
|
+
</span>
|
|
336
|
+
</td>
|
|
337
|
+
<td className="px-6 py-4 text-sm text-gray-900">
|
|
338
|
+
{booking.resourceIds
|
|
339
|
+
.map((id) => resourceMap.get(id) || 'Unknown Service')
|
|
340
|
+
.join(', ')}
|
|
341
|
+
</td>
|
|
342
|
+
<td className="px-6 py-4 text-sm text-gray-500">
|
|
343
|
+
<div>{renderCustomerInfo(booking)}</div>
|
|
344
|
+
{booking.shopifyOrderId && (
|
|
345
|
+
<a
|
|
346
|
+
href={`https://admin.shopify.com/orders/${booking.shopifyOrderId}`}
|
|
347
|
+
target="_blank"
|
|
348
|
+
rel="noopener noreferrer"
|
|
349
|
+
className="mt-1 inline-block text-xs font-bold text-cyan-600 hover:text-cyan-800 hover:underline"
|
|
350
|
+
>
|
|
351
|
+
Order #{booking.shopifyOrderId}
|
|
352
|
+
</a>
|
|
353
|
+
)}
|
|
354
|
+
</td>
|
|
355
|
+
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
|
|
356
|
+
<div>
|
|
357
|
+
{new Date(booking.startTime).toLocaleDateString()}
|
|
358
|
+
</div>
|
|
359
|
+
<div className="text-gray-500">
|
|
360
|
+
{new Date(booking.startTime).toLocaleTimeString([], {
|
|
361
|
+
hour: '2-digit',
|
|
362
|
+
minute: '2-digit',
|
|
363
|
+
})}{' '}
|
|
364
|
+
-{' '}
|
|
365
|
+
{new Date(booking.endTime).toLocaleTimeString([], {
|
|
366
|
+
hour: '2-digit',
|
|
367
|
+
minute: '2-digit',
|
|
368
|
+
})}
|
|
369
|
+
</div>
|
|
370
|
+
</td>
|
|
371
|
+
<td className="whitespace-nowrap px-6 py-4 text-right text-sm font-bold">
|
|
372
|
+
{booking.status !== 'CANCELLED' && (
|
|
373
|
+
<button
|
|
374
|
+
onClick={() => handleCancelBooking(booking.id)}
|
|
375
|
+
disabled={isCanceling === booking.id}
|
|
376
|
+
className="inline-flex items-center gap-1 text-red-600 hover:text-red-900 disabled:opacity-50"
|
|
377
|
+
>
|
|
378
|
+
{isCanceling === booking.id ? (
|
|
379
|
+
<div className="h-4 w-4 animate-spin rounded-full border-2 border-red-200 border-t-red-600" />
|
|
380
|
+
) : (
|
|
381
|
+
<XCircleIcon className="h-5 w-5" />
|
|
382
|
+
)}
|
|
383
|
+
Cancel
|
|
384
|
+
</button>
|
|
385
|
+
)}
|
|
386
|
+
</td>
|
|
387
|
+
</tr>
|
|
388
|
+
))
|
|
389
|
+
)}
|
|
390
|
+
</tbody>
|
|
391
|
+
</table>
|
|
392
|
+
</div>
|
|
393
|
+
|
|
394
|
+
{totalPages > 1 && (
|
|
395
|
+
<div className="flex justify-center gap-2 pt-4">
|
|
396
|
+
<button
|
|
397
|
+
onClick={() => handlePageChange(currentPage - 1)}
|
|
398
|
+
disabled={currentPage === 0 || isLoading}
|
|
399
|
+
className="rounded border border-gray-300 bg-white px-3 py-1 text-sm shadow-sm hover:bg-gray-50 disabled:opacity-50"
|
|
400
|
+
>
|
|
401
|
+
Previous
|
|
402
|
+
</button>
|
|
403
|
+
<span className="flex items-center text-sm text-gray-600">
|
|
404
|
+
Page {currentPage + 1} of {totalPages}
|
|
405
|
+
</span>
|
|
406
|
+
<button
|
|
407
|
+
onClick={() => handlePageChange(currentPage + 1)}
|
|
408
|
+
disabled={currentPage === totalPages - 1 || isLoading}
|
|
409
|
+
className="rounded border border-gray-300 bg-white px-3 py-1 text-sm shadow-sm hover:bg-gray-50 disabled:opacity-50"
|
|
410
|
+
>
|
|
411
|
+
Next
|
|
412
|
+
</button>
|
|
413
|
+
</div>
|
|
414
|
+
)}
|
|
415
|
+
</div>
|
|
416
|
+
)}
|
|
417
|
+
</div>
|
|
418
|
+
);
|
|
419
|
+
}
|