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.
Files changed (95) hide show
  1. package/README.md +1 -1
  2. package/bin/create-tractstack.js +2 -2
  3. package/dist/index.js +130 -19
  4. package/package.json +2 -2
  5. package/templates/custom/minimal/CodeHook.astro +10 -2
  6. package/templates/custom/shopify/Cart.tsx +115 -77
  7. package/templates/custom/shopify/CheckoutModal.tsx +509 -120
  8. package/templates/custom/shopify/NativeBookingCalendar.tsx +375 -0
  9. package/templates/custom/shopify/ShopifyCartManager.tsx +91 -45
  10. package/templates/custom/shopify/ShopifyCheckout.tsx +4 -33
  11. package/templates/custom/shopify/ShopifyProductGrid.tsx +170 -176
  12. package/templates/custom/shopify/ShopifyServiceList.tsx +112 -51
  13. package/templates/custom/with-examples/CodeHook.astro +10 -2
  14. package/templates/src/components/Footer.astro +6 -6
  15. package/templates/src/components/Header.astro +23 -11
  16. package/templates/src/components/Menu.tsx +157 -135
  17. package/templates/src/components/codehooks/BunnyVideoSetup.tsx +2 -2
  18. package/templates/src/components/codehooks/EpinetDurationSelector.tsx +27 -6
  19. package/templates/src/components/codehooks/EpinetTableView.tsx +153 -112
  20. package/templates/src/components/codehooks/EpinetWrapper.tsx +4 -1
  21. package/templates/src/components/codehooks/FeaturedArticleSetup.tsx +8 -1
  22. package/templates/src/components/codehooks/ProductCardSetup.tsx +9 -1
  23. package/templates/src/components/codehooks/ProductGridSetup.tsx +9 -1
  24. package/templates/src/components/compositor/nodes/BgPaneWrapper.tsx +2 -1
  25. package/templates/src/components/compositor/nodes/GhostInsertBlock.tsx +1 -1
  26. package/templates/src/components/edit/ToolBar.tsx +2 -1
  27. package/templates/src/components/edit/context/ContextPaneConfig_slug.tsx +2 -2
  28. package/templates/src/components/edit/pane/AddPanePanel_codehook.tsx +13 -0
  29. package/templates/src/components/edit/pane/AddPanePanel_new.tsx +3 -3
  30. package/templates/src/components/edit/pane/AddPanePanel_newCustomCopy.tsx +2 -2
  31. package/templates/src/components/edit/pane/AiRestylePaneModal.tsx +2 -2
  32. package/templates/src/components/edit/pane/ConfigPanePanel.tsx +1 -1
  33. package/templates/src/components/edit/pane/steps/AiCreativeDesignStep.tsx +2 -2
  34. package/templates/src/components/edit/pane/steps/AiLibraryCopyStep.tsx +3 -3
  35. package/templates/src/components/edit/pane/steps/AiRefineDesignStep.tsx +2 -2
  36. package/templates/src/components/edit/pane/steps/AiStandardDesignStep.tsx +7 -7
  37. package/templates/src/components/edit/state/SaveModal.tsx +1 -1
  38. package/templates/src/components/edit/widgets/InteractiveDisclosureWidget.tsx +8 -3
  39. package/templates/src/components/form/DateTimeInput.tsx +10 -3
  40. package/templates/src/components/form/FileUpload.tsx +11 -5
  41. package/templates/src/components/form/NumberInput.tsx +2 -2
  42. package/templates/src/components/form/advanced/APIConfigSection.tsx +208 -2
  43. package/templates/src/components/form/brand/SiteConfigSection.tsx +10 -0
  44. package/templates/src/components/form/shopify/SchedulingSection.tsx +354 -0
  45. package/templates/src/components/storykeep/Dashboard.tsx +1 -1
  46. package/templates/src/components/storykeep/Dashboard_Shopify.tsx +252 -110
  47. package/templates/src/components/storykeep/controls/content/BeliefForm.tsx +2 -2
  48. package/templates/src/components/storykeep/controls/content/BeliefTable.tsx +14 -5
  49. package/templates/src/components/storykeep/controls/content/KnownResourceTable.tsx +5 -2
  50. package/templates/src/components/storykeep/controls/content/MenuTable.tsx +14 -5
  51. package/templates/src/components/storykeep/controls/content/ProductTable.tsx +180 -101
  52. package/templates/src/components/storykeep/controls/content/ResourceBulkIngest.tsx +88 -56
  53. package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +14 -4
  54. package/templates/src/components/storykeep/controls/content/StoryFragmentTable.tsx +14 -5
  55. package/templates/src/components/storykeep/email-builder/Blocks.tsx +169 -0
  56. package/templates/src/components/storykeep/email-builder/EmailBuilder.tsx +223 -0
  57. package/templates/src/components/storykeep/email-builder/PreviewModal.tsx +136 -0
  58. package/templates/src/components/storykeep/email-builder/PropertyPanel.tsx +154 -0
  59. package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +104 -0
  60. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +419 -0
  61. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Emails.tsx +105 -0
  62. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Products.tsx +46 -0
  63. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Schedule.tsx +78 -0
  64. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Search.tsx +55 -0
  65. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Services.tsx +47 -0
  66. package/templates/src/layouts/Layout.astro +8 -5
  67. package/templates/src/pages/api/auth/lookup-lead.ts +72 -0
  68. package/templates/src/pages/api/booking/availability.ts +72 -0
  69. package/templates/src/pages/api/booking/cancel.ts +73 -0
  70. package/templates/src/pages/api/booking/confirm.ts +82 -0
  71. package/templates/src/pages/api/booking/hold.ts +75 -0
  72. package/templates/src/pages/api/booking/list.ts +66 -0
  73. package/templates/src/pages/api/booking/metrics.ts +60 -0
  74. package/templates/src/pages/api/booking/release.ts +76 -0
  75. package/templates/src/pages/api/sandbox.ts +2 -2
  76. package/templates/src/pages/api/shopify/createCart.ts +4 -8
  77. package/templates/src/pages/api/shopify/getProducts.ts +15 -15
  78. package/templates/src/pages/storykeep/login.astro +21 -14
  79. package/templates/src/stores/shopify.ts +97 -25
  80. package/templates/src/types/formTypes.ts +4 -2
  81. package/templates/src/types/tractstack.ts +59 -2
  82. package/templates/src/utils/api/advancedConfig.ts +2 -0
  83. package/templates/src/utils/api/advancedHelpers.ts +40 -3
  84. package/templates/src/utils/api/bookingHelpers.ts +125 -0
  85. package/templates/src/utils/api/brandConfig.ts +2 -0
  86. package/templates/src/utils/api/brandHelpers.ts +26 -0
  87. package/templates/src/utils/api/emailHelpers.ts +105 -0
  88. package/templates/src/utils/auth.ts +29 -9
  89. package/templates/src/utils/compositor/aiGeneration.ts +3 -3
  90. package/templates/src/utils/compositor/aiPaneParser.ts +2 -2
  91. package/templates/src/utils/customHelpers.ts +0 -21
  92. package/templates/src/utils/profileStorage.ts +5 -0
  93. package/templates/src/utils/tenantResolver.ts +3 -2
  94. package/utils/inject-files.ts +116 -5
  95. 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
+ &uarr;
27
+ </button>
28
+ <button onClick={onMoveDown} className="hover:text-gray-900">
29
+ &darr;
30
+ </button>
31
+ <button onClick={onDelete} className="hover:text-red-600">
32
+ &times;
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
+ }