astro-tractstack 2.3.0 → 2.3.1
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 +94 -16
- package/package.json +2 -2
- package/templates/custom/minimal/CodeHook.astro +10 -2
- package/templates/custom/shopify/Cart.tsx +100 -73
- package/templates/custom/shopify/CheckoutModal.tsx +509 -120
- package/templates/custom/shopify/NativeBookingCalendar.tsx +375 -0
- package/templates/custom/shopify/ShopifyCartManager.tsx +92 -37
- package/templates/custom/shopify/ShopifyProductGrid.tsx +139 -173
- package/templates/custom/shopify/ShopifyServiceList.tsx +20 -3
- package/templates/custom/with-examples/CodeHook.astro +10 -2
- package/templates/src/components/Footer.astro +4 -4
- package/templates/src/components/Header.astro +9 -3
- package/templates/src/components/edit/pane/AddPanePanel_new.tsx +3 -3
- package/templates/src/components/edit/pane/AiRestylePaneModal.tsx +2 -2
- 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/form/advanced/APIConfigSection.tsx +244 -2
- 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 +253 -110
- 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 +9 -5
- package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +13 -4
- package/templates/src/components/storykeep/controls/content/StoryFragmentTable.tsx +14 -5
- package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +111 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +393 -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/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 +81 -25
- package/templates/src/types/tractstack.ts +54 -0
- 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/brandHelpers.ts +10 -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 +2 -1
- package/utils/inject-files.ts +82 -4
- package/templates/custom/shopify/CalDotComBooking.tsx +0 -44
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo, useCallback } from 'react';
|
|
2
|
+
import { Toggle } from '@ark-ui/react/toggle';
|
|
3
|
+
import CalendarIcon from '@heroicons/react/24/outline/CalendarIcon';
|
|
4
|
+
import TableCellsIcon from '@heroicons/react/24/outline/TableCellsIcon';
|
|
5
|
+
import XCircleIcon from '@heroicons/react/24/outline/XCircleIcon';
|
|
6
|
+
import { bookingHelpers } from '@/utils/api/bookingHelpers';
|
|
7
|
+
import type { BookingEntity } from '@/types/tractstack';
|
|
8
|
+
import type { ResourceNode } from '@/types/compositorTypes';
|
|
9
|
+
|
|
10
|
+
interface ShopifyDashboardBookingsProps {
|
|
11
|
+
existingResources: ResourceNode[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ITEMS_PER_PAGE = 10;
|
|
15
|
+
|
|
16
|
+
export default function ShopifyDashboard_Bookings({
|
|
17
|
+
existingResources,
|
|
18
|
+
}: ShopifyDashboardBookingsProps) {
|
|
19
|
+
const [isCalendarView, setIsCalendarView] = useState(false);
|
|
20
|
+
const [currentPage, setCurrentPage] = useState(0);
|
|
21
|
+
const [statusFilter, setStatusFilter] = useState('ALL');
|
|
22
|
+
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
|
23
|
+
const [shopTimezone, setShopTimezone] = useState(
|
|
24
|
+
Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const [bookings, setBookings] = useState<BookingEntity[]>([]);
|
|
28
|
+
const [totalCount, setTotalCount] = useState(0);
|
|
29
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
30
|
+
const [isCanceling, setIsCanceling] = useState<string | null>(null);
|
|
31
|
+
|
|
32
|
+
const resourceMap = useMemo(() => {
|
|
33
|
+
return new Map(existingResources.map((r) => [r.id, r.title]));
|
|
34
|
+
}, [existingResources]);
|
|
35
|
+
|
|
36
|
+
const fetchBookings = useCallback(async () => {
|
|
37
|
+
setIsLoading(true);
|
|
38
|
+
try {
|
|
39
|
+
const limit = isCalendarView ? 100 : ITEMS_PER_PAGE;
|
|
40
|
+
const offset = isCalendarView ? 0 : currentPage * ITEMS_PER_PAGE;
|
|
41
|
+
const response = await bookingHelpers.listBookings(
|
|
42
|
+
limit,
|
|
43
|
+
offset,
|
|
44
|
+
statusFilter
|
|
45
|
+
);
|
|
46
|
+
setBookings(response.data || []);
|
|
47
|
+
setTotalCount(response.totalCount || 0);
|
|
48
|
+
|
|
49
|
+
const availability = await bookingHelpers.getAvailability(
|
|
50
|
+
new Date().toISOString(),
|
|
51
|
+
new Date().toISOString()
|
|
52
|
+
);
|
|
53
|
+
if (availability?.scheduling?.timezone) {
|
|
54
|
+
setShopTimezone(availability.scheduling.timezone);
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error('Failed to fetch bookings:', error);
|
|
58
|
+
} finally {
|
|
59
|
+
setIsLoading(false);
|
|
60
|
+
}
|
|
61
|
+
}, [currentPage, statusFilter, isCalendarView]);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
fetchBookings();
|
|
65
|
+
}, [fetchBookings]);
|
|
66
|
+
|
|
67
|
+
const handlePageChange = (newPage: number) => {
|
|
68
|
+
setCurrentPage(newPage);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const handleStatusChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
72
|
+
setStatusFilter(e.target.value);
|
|
73
|
+
setCurrentPage(0);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const handleCancelBooking = async (traceId: string) => {
|
|
77
|
+
if (!window.confirm('Are you sure you want to cancel this booking?')) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
setIsCanceling(traceId);
|
|
82
|
+
try {
|
|
83
|
+
await bookingHelpers.cancelBooking(traceId);
|
|
84
|
+
await fetchBookings();
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.error('Failed to cancel booking:', error);
|
|
87
|
+
alert('Failed to cancel booking. Please try again.');
|
|
88
|
+
} finally {
|
|
89
|
+
setIsCanceling(null);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const dayBookings = useMemo(() => {
|
|
94
|
+
return bookings.filter((b) => {
|
|
95
|
+
const bDate = new Date(b.startTime);
|
|
96
|
+
return bDate.toLocaleDateString() === selectedDate.toLocaleDateString();
|
|
97
|
+
});
|
|
98
|
+
}, [bookings, selectedDate]);
|
|
99
|
+
|
|
100
|
+
const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
|
|
101
|
+
|
|
102
|
+
const getStatusColor = (status: string) => {
|
|
103
|
+
switch (status) {
|
|
104
|
+
case 'CONFIRMED':
|
|
105
|
+
return 'bg-green-100 text-green-800';
|
|
106
|
+
case 'PENDING':
|
|
107
|
+
return 'bg-yellow-100 text-yellow-800';
|
|
108
|
+
case 'CANCELLED':
|
|
109
|
+
return 'bg-red-100 text-red-800';
|
|
110
|
+
default:
|
|
111
|
+
return 'bg-gray-100 text-gray-800';
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const todayStr = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate()).padStart(2, '0')}`;
|
|
116
|
+
|
|
117
|
+
const renderCustomerInfo = (booking: BookingEntity) => {
|
|
118
|
+
if (booking.leadName && booking.leadEmail) {
|
|
119
|
+
return `${booking.leadName} (${booking.leadEmail})`;
|
|
120
|
+
}
|
|
121
|
+
return 'Guest';
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div className="space-y-6">
|
|
126
|
+
<div className="flex flex-wrap items-center justify-between gap-4 rounded-lg border border-gray-200 bg-gray-50 p-4">
|
|
127
|
+
<div className="flex items-center gap-4">
|
|
128
|
+
<select
|
|
129
|
+
value={statusFilter}
|
|
130
|
+
onChange={handleStatusChange}
|
|
131
|
+
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"
|
|
132
|
+
>
|
|
133
|
+
<option value="ALL">All Statuses</option>
|
|
134
|
+
<option value="CONFIRMED">Confirmed</option>
|
|
135
|
+
<option value="PENDING">Pending</option>
|
|
136
|
+
<option value="CANCELLED">Cancelled</option>
|
|
137
|
+
</select>
|
|
138
|
+
<span className="text-sm text-gray-500">
|
|
139
|
+
{totalCount} total bookings
|
|
140
|
+
</span>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<div className="flex items-center gap-2 border-l border-gray-300 pl-6">
|
|
144
|
+
<span className="text-xs font-bold uppercase tracking-wider text-gray-500">
|
|
145
|
+
View:
|
|
146
|
+
</span>
|
|
147
|
+
<Toggle.Root
|
|
148
|
+
pressed={isCalendarView}
|
|
149
|
+
onPressedChange={setIsCalendarView}
|
|
150
|
+
className={`flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs font-bold shadow-sm transition-all ${
|
|
151
|
+
isCalendarView
|
|
152
|
+
? 'border-cyan-600 bg-cyan-600 text-white'
|
|
153
|
+
: 'border-gray-300 bg-white text-gray-600 hover:bg-gray-50'
|
|
154
|
+
}`}
|
|
155
|
+
>
|
|
156
|
+
{isCalendarView ? (
|
|
157
|
+
<>
|
|
158
|
+
<CalendarIcon className="h-4 w-4" />
|
|
159
|
+
Calendar
|
|
160
|
+
</>
|
|
161
|
+
) : (
|
|
162
|
+
<>
|
|
163
|
+
<TableCellsIcon className="h-4 w-4" />
|
|
164
|
+
Table
|
|
165
|
+
</>
|
|
166
|
+
)}
|
|
167
|
+
</Toggle.Root>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
{isCalendarView ? (
|
|
172
|
+
<div className="flex flex-col space-y-6">
|
|
173
|
+
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
|
174
|
+
<div className="space-y-4">
|
|
175
|
+
<label className="block text-sm font-bold text-gray-700">
|
|
176
|
+
Select Date
|
|
177
|
+
</label>
|
|
178
|
+
<input
|
|
179
|
+
type="date"
|
|
180
|
+
defaultValue={todayStr}
|
|
181
|
+
onChange={(e) => {
|
|
182
|
+
const [y, m, d] = e.target.value.split('-');
|
|
183
|
+
setSelectedDate(
|
|
184
|
+
new Date(parseInt(y), parseInt(m) - 1, parseInt(d))
|
|
185
|
+
);
|
|
186
|
+
}}
|
|
187
|
+
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"
|
|
188
|
+
/>
|
|
189
|
+
<div className="rounded-md border border-cyan-100 bg-cyan-50 p-4">
|
|
190
|
+
<p className="text-xs text-cyan-800">
|
|
191
|
+
Showing loaded bookings in <strong>{shopTimezone}</strong>{' '}
|
|
192
|
+
time.
|
|
193
|
+
</p>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<div className="space-y-4">
|
|
198
|
+
<label className="block text-sm font-bold text-gray-700">
|
|
199
|
+
Bookings for {selectedDate.toLocaleDateString()}
|
|
200
|
+
</label>
|
|
201
|
+
<div className="grid max-h-96 grid-cols-1 gap-3 overflow-y-auto pr-2">
|
|
202
|
+
{isLoading ? (
|
|
203
|
+
<div className="flex h-32 items-center justify-center">
|
|
204
|
+
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-cyan-600" />
|
|
205
|
+
</div>
|
|
206
|
+
) : dayBookings.length > 0 ? (
|
|
207
|
+
dayBookings.map((booking) => (
|
|
208
|
+
<div
|
|
209
|
+
key={booking.id}
|
|
210
|
+
className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition-colors hover:border-cyan-200"
|
|
211
|
+
>
|
|
212
|
+
<div className="flex items-start justify-between">
|
|
213
|
+
<div className="space-y-1">
|
|
214
|
+
<span
|
|
215
|
+
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-bold ${getStatusColor(booking.status)}`}
|
|
216
|
+
>
|
|
217
|
+
{booking.status}
|
|
218
|
+
</span>
|
|
219
|
+
<div className="text-sm font-bold text-gray-900">
|
|
220
|
+
{new Date(booking.startTime).toLocaleTimeString(
|
|
221
|
+
'en-US',
|
|
222
|
+
{
|
|
223
|
+
hour: '2-digit',
|
|
224
|
+
minute: '2-digit',
|
|
225
|
+
timeZone: shopTimezone,
|
|
226
|
+
}
|
|
227
|
+
)}
|
|
228
|
+
{' - '}
|
|
229
|
+
{new Date(booking.endTime).toLocaleTimeString(
|
|
230
|
+
'en-US',
|
|
231
|
+
{
|
|
232
|
+
hour: '2-digit',
|
|
233
|
+
minute: '2-digit',
|
|
234
|
+
timeZone: shopTimezone,
|
|
235
|
+
}
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
{booking.status !== 'CANCELLED' && (
|
|
240
|
+
<button
|
|
241
|
+
onClick={() => handleCancelBooking(booking.id)}
|
|
242
|
+
disabled={isCanceling === booking.id}
|
|
243
|
+
className="text-red-600 hover:text-red-900 disabled:opacity-50"
|
|
244
|
+
>
|
|
245
|
+
<XCircleIcon className="h-5 w-5" />
|
|
246
|
+
</button>
|
|
247
|
+
)}
|
|
248
|
+
</div>
|
|
249
|
+
<div className="mt-3 space-y-1 text-xs text-gray-500">
|
|
250
|
+
<div className="font-medium text-gray-900">
|
|
251
|
+
{renderCustomerInfo(booking)}
|
|
252
|
+
</div>
|
|
253
|
+
<div className="font-medium text-gray-700">
|
|
254
|
+
{booking.resourceIds
|
|
255
|
+
.map(
|
|
256
|
+
(id) => resourceMap.get(id) || 'Unknown Service'
|
|
257
|
+
)
|
|
258
|
+
.join(', ')}
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
))
|
|
263
|
+
) : (
|
|
264
|
+
<div className="flex h-32 flex-col items-center justify-center rounded-md border border-dashed border-gray-200 text-gray-400">
|
|
265
|
+
<CalendarIcon className="mb-1 h-6 w-6" />
|
|
266
|
+
<p className="text-xs">No bookings found for this date.</p>
|
|
267
|
+
</div>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
) : (
|
|
274
|
+
<div className="space-y-4">
|
|
275
|
+
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
|
|
276
|
+
<table className="min-w-full divide-y divide-gray-200">
|
|
277
|
+
<thead className="bg-gray-50">
|
|
278
|
+
<tr>
|
|
279
|
+
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
|
|
280
|
+
Status
|
|
281
|
+
</th>
|
|
282
|
+
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
|
|
283
|
+
Service(s)
|
|
284
|
+
</th>
|
|
285
|
+
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
|
|
286
|
+
Customer
|
|
287
|
+
</th>
|
|
288
|
+
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
|
|
289
|
+
Date & Time
|
|
290
|
+
</th>
|
|
291
|
+
<th className="px-6 py-3 text-right text-xs font-bold uppercase tracking-wider text-gray-500">
|
|
292
|
+
Actions
|
|
293
|
+
</th>
|
|
294
|
+
</tr>
|
|
295
|
+
</thead>
|
|
296
|
+
<tbody className="divide-y divide-gray-200 bg-white">
|
|
297
|
+
{isLoading ? (
|
|
298
|
+
<tr>
|
|
299
|
+
<td colSpan={5} className="py-12 text-center">
|
|
300
|
+
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-cyan-600" />
|
|
301
|
+
</td>
|
|
302
|
+
</tr>
|
|
303
|
+
) : bookings.length === 0 ? (
|
|
304
|
+
<tr>
|
|
305
|
+
<td colSpan={5} className="py-12 text-center text-gray-500">
|
|
306
|
+
No bookings found.
|
|
307
|
+
</td>
|
|
308
|
+
</tr>
|
|
309
|
+
) : (
|
|
310
|
+
bookings.map((booking) => (
|
|
311
|
+
<tr key={booking.id} className="hover:bg-gray-50">
|
|
312
|
+
<td className="whitespace-nowrap px-6 py-4">
|
|
313
|
+
<span
|
|
314
|
+
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold ${getStatusColor(
|
|
315
|
+
booking.status
|
|
316
|
+
)}`}
|
|
317
|
+
>
|
|
318
|
+
{booking.status}
|
|
319
|
+
</span>
|
|
320
|
+
</td>
|
|
321
|
+
<td className="px-6 py-4 text-sm text-gray-900">
|
|
322
|
+
{booking.resourceIds
|
|
323
|
+
.map((id) => resourceMap.get(id) || 'Unknown Service')
|
|
324
|
+
.join(', ')}
|
|
325
|
+
</td>
|
|
326
|
+
<td className="px-6 py-4 text-sm text-gray-500">
|
|
327
|
+
{renderCustomerInfo(booking)}
|
|
328
|
+
</td>
|
|
329
|
+
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
|
|
330
|
+
<div>
|
|
331
|
+
{new Date(booking.startTime).toLocaleDateString()}
|
|
332
|
+
</div>
|
|
333
|
+
<div className="text-gray-500">
|
|
334
|
+
{new Date(booking.startTime).toLocaleTimeString([], {
|
|
335
|
+
hour: '2-digit',
|
|
336
|
+
minute: '2-digit',
|
|
337
|
+
})}{' '}
|
|
338
|
+
-{' '}
|
|
339
|
+
{new Date(booking.endTime).toLocaleTimeString([], {
|
|
340
|
+
hour: '2-digit',
|
|
341
|
+
minute: '2-digit',
|
|
342
|
+
})}
|
|
343
|
+
</div>
|
|
344
|
+
</td>
|
|
345
|
+
<td className="whitespace-nowrap px-6 py-4 text-right text-sm font-bold">
|
|
346
|
+
{booking.status !== 'CANCELLED' && (
|
|
347
|
+
<button
|
|
348
|
+
onClick={() => handleCancelBooking(booking.id)}
|
|
349
|
+
disabled={isCanceling === booking.id}
|
|
350
|
+
className="inline-flex items-center gap-1 text-red-600 hover:text-red-900 disabled:opacity-50"
|
|
351
|
+
>
|
|
352
|
+
{isCanceling === booking.id ? (
|
|
353
|
+
<div className="h-4 w-4 animate-spin rounded-full border-2 border-red-200 border-t-red-600" />
|
|
354
|
+
) : (
|
|
355
|
+
<XCircleIcon className="h-5 w-5" />
|
|
356
|
+
)}
|
|
357
|
+
Cancel
|
|
358
|
+
</button>
|
|
359
|
+
)}
|
|
360
|
+
</td>
|
|
361
|
+
</tr>
|
|
362
|
+
))
|
|
363
|
+
)}
|
|
364
|
+
</tbody>
|
|
365
|
+
</table>
|
|
366
|
+
</div>
|
|
367
|
+
|
|
368
|
+
{totalPages > 1 && (
|
|
369
|
+
<div className="flex justify-center gap-2 pt-4">
|
|
370
|
+
<button
|
|
371
|
+
onClick={() => handlePageChange(currentPage - 1)}
|
|
372
|
+
disabled={currentPage === 0 || isLoading}
|
|
373
|
+
className="rounded border border-gray-300 bg-white px-3 py-1 text-sm shadow-sm hover:bg-gray-50 disabled:opacity-50"
|
|
374
|
+
>
|
|
375
|
+
Previous
|
|
376
|
+
</button>
|
|
377
|
+
<span className="flex items-center text-sm text-gray-600">
|
|
378
|
+
Page {currentPage + 1} of {totalPages}
|
|
379
|
+
</span>
|
|
380
|
+
<button
|
|
381
|
+
onClick={() => handlePageChange(currentPage + 1)}
|
|
382
|
+
disabled={currentPage === totalPages - 1 || isLoading}
|
|
383
|
+
className="rounded border border-gray-300 bg-white px-3 py-1 text-sm shadow-sm hover:bg-gray-50 disabled:opacity-50"
|
|
384
|
+
>
|
|
385
|
+
Next
|
|
386
|
+
</button>
|
|
387
|
+
</div>
|
|
388
|
+
)}
|
|
389
|
+
</div>
|
|
390
|
+
)}
|
|
391
|
+
</div>
|
|
392
|
+
);
|
|
393
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import ResourceTable from '@/components/storykeep/controls/content/ResourceTable';
|
|
2
|
+
import type { ResourceNode } from '@/types/compositorTypes';
|
|
3
|
+
import type { FullContentMapItem } from '@/types/tractstack';
|
|
4
|
+
|
|
5
|
+
interface ShopifyDashboardProductsProps {
|
|
6
|
+
resources: ResourceNode[];
|
|
7
|
+
onEdit: (resourceId: string) => void;
|
|
8
|
+
onCreate: () => void;
|
|
9
|
+
onRefresh: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function ShopifyDashboard_Products({
|
|
13
|
+
resources,
|
|
14
|
+
onEdit,
|
|
15
|
+
onCreate,
|
|
16
|
+
onRefresh,
|
|
17
|
+
}: ShopifyDashboardProductsProps) {
|
|
18
|
+
/**
|
|
19
|
+
* Convert local ResourceNode[] into FullContentMapItem[] to satisfy the
|
|
20
|
+
* ResourceTable interface requirements.
|
|
21
|
+
*/
|
|
22
|
+
const resourceItems = resources.map((r) => ({
|
|
23
|
+
...r,
|
|
24
|
+
type: 'Resource' as const,
|
|
25
|
+
})) as FullContentMapItem[];
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className="space-y-6">
|
|
29
|
+
<div className="border-b border-gray-200 pb-4">
|
|
30
|
+
<h2 className="text-2xl font-bold text-gray-900">Imported Products</h2>
|
|
31
|
+
<p className="mt-2 text-sm text-gray-600">
|
|
32
|
+
Manage the Shopify products you have already imported into StoryKeep.
|
|
33
|
+
Edit their metadata, SEO, and layout parameters.
|
|
34
|
+
</p>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<ResourceTable
|
|
38
|
+
categorySlug="product"
|
|
39
|
+
fullContentMap={resourceItems}
|
|
40
|
+
onEdit={onEdit}
|
|
41
|
+
onCreate={onCreate}
|
|
42
|
+
onRefresh={onRefresh}
|
|
43
|
+
/>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useFormState } from '@/hooks/useFormState';
|
|
3
|
+
import {
|
|
4
|
+
convertToLocalState,
|
|
5
|
+
convertToBackendFormat,
|
|
6
|
+
validateBrandConfig,
|
|
7
|
+
} from '@/utils/api/brandHelpers';
|
|
8
|
+
import { saveBrandConfigWithStateUpdate } from '@/utils/api/brandConfig';
|
|
9
|
+
import SchedulingSection from '@/components/form/shopify/SchedulingSection';
|
|
10
|
+
import UnsavedChangesBar from '@/components/form/UnsavedChangesBar';
|
|
11
|
+
import type { BrandConfig, BrandConfigState } from '@/types/tractstack';
|
|
12
|
+
|
|
13
|
+
interface ShopifyDashboardScheduleProps {
|
|
14
|
+
brandConfig: BrandConfig;
|
|
15
|
+
onBrandConfigUpdate?: (config: BrandConfig) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default function ShopifyDashboard_Schedule({
|
|
19
|
+
brandConfig,
|
|
20
|
+
onBrandConfigUpdate,
|
|
21
|
+
}: ShopifyDashboardScheduleProps) {
|
|
22
|
+
const [currentBrandConfig, setCurrentBrandConfig] = useState(brandConfig);
|
|
23
|
+
const initialState: BrandConfigState =
|
|
24
|
+
convertToLocalState(currentBrandConfig);
|
|
25
|
+
|
|
26
|
+
const formState = useFormState({
|
|
27
|
+
initialData: initialState,
|
|
28
|
+
validator: validateBrandConfig,
|
|
29
|
+
onSave: async (data) => {
|
|
30
|
+
try {
|
|
31
|
+
const updatedState = await saveBrandConfigWithStateUpdate(
|
|
32
|
+
window.TRACTSTACK_CONFIG?.tenantId || 'default',
|
|
33
|
+
data
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// Preserve existing paths when updating parent state
|
|
37
|
+
const updatedBrandConfig = {
|
|
38
|
+
...currentBrandConfig,
|
|
39
|
+
...convertToBackendFormat(updatedState),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Update local state
|
|
43
|
+
setCurrentBrandConfig(updatedBrandConfig);
|
|
44
|
+
|
|
45
|
+
if (onBrandConfigUpdate) {
|
|
46
|
+
onBrandConfigUpdate(updatedBrandConfig);
|
|
47
|
+
}
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.error('Save failed:', error);
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
unsavedChanges: {
|
|
54
|
+
enableBrowserWarning: true,
|
|
55
|
+
browserWarningMessage: 'Your scheduling changes will be lost!',
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className="space-y-8">
|
|
61
|
+
<div className="border-b border-gray-200 pb-4">
|
|
62
|
+
<h2 className="text-2xl font-bold text-gray-900">Booking Schedule</h2>
|
|
63
|
+
<p className="mt-2 text-sm text-gray-600">
|
|
64
|
+
Manage your store timezone, business hours, and blackout dates.
|
|
65
|
+
</p>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<SchedulingSection formState={formState} />
|
|
69
|
+
|
|
70
|
+
<UnsavedChangesBar
|
|
71
|
+
formState={formState}
|
|
72
|
+
message="You have unsaved scheduling changes"
|
|
73
|
+
saveLabel="Save Schedule"
|
|
74
|
+
cancelLabel="Discard Changes"
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useStore } from '@nanostores/react';
|
|
2
|
+
import {
|
|
3
|
+
shopifyData,
|
|
4
|
+
shopifyStatus,
|
|
5
|
+
fetchShopifyProducts,
|
|
6
|
+
type ShopifyProduct,
|
|
7
|
+
} from '@/stores/shopify';
|
|
8
|
+
import ProductTable from '@/components/storykeep/controls/content/ProductTable';
|
|
9
|
+
import type { ResourceNode } from '@/types/compositorTypes';
|
|
10
|
+
|
|
11
|
+
interface ShopifyDashboardSearchProps {
|
|
12
|
+
linkedResourceMap: Map<string, ResourceNode>;
|
|
13
|
+
onSelectProduct: (product: ShopifyProduct) => void;
|
|
14
|
+
onLink: (product: ShopifyProduct) => void;
|
|
15
|
+
onUnlink: (resourceId: string) => void;
|
|
16
|
+
onEdit: (product: ShopifyProduct, resource: ResourceNode) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default function ShopifyDashboard_Search({
|
|
20
|
+
linkedResourceMap,
|
|
21
|
+
onSelectProduct,
|
|
22
|
+
onLink,
|
|
23
|
+
onUnlink,
|
|
24
|
+
onEdit,
|
|
25
|
+
}: ShopifyDashboardSearchProps) {
|
|
26
|
+
const data = useStore(shopifyData);
|
|
27
|
+
const status = useStore(shopifyStatus);
|
|
28
|
+
|
|
29
|
+
const handleRefresh = () => {
|
|
30
|
+
fetchShopifyProducts();
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="space-y-6">
|
|
35
|
+
<div className="border-b border-gray-200 pb-4">
|
|
36
|
+
<h2 className="text-2xl font-bold text-gray-900">Catalog Search</h2>
|
|
37
|
+
<p className="mt-2 text-sm text-gray-600">
|
|
38
|
+
Search your live Shopify store to import products and services into
|
|
39
|
+
your StoryKeep.
|
|
40
|
+
</p>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<ProductTable
|
|
44
|
+
products={data.products}
|
|
45
|
+
linkedResourceMap={linkedResourceMap}
|
|
46
|
+
onRefresh={handleRefresh}
|
|
47
|
+
isRefreshing={status.isLoading}
|
|
48
|
+
onSelectProduct={onSelectProduct}
|
|
49
|
+
onLink={onLink}
|
|
50
|
+
onUnlink={onUnlink}
|
|
51
|
+
onEdit={onEdit}
|
|
52
|
+
/>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import ResourceTable from '@/components/storykeep/controls/content/ResourceTable';
|
|
2
|
+
import type { ResourceNode } from '@/types/compositorTypes';
|
|
3
|
+
import type { FullContentMapItem } from '@/types/tractstack';
|
|
4
|
+
|
|
5
|
+
interface ShopifyDashboardServicesProps {
|
|
6
|
+
resources: ResourceNode[];
|
|
7
|
+
onEdit: (resourceId: string) => void;
|
|
8
|
+
onCreate: () => void;
|
|
9
|
+
onRefresh: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function ShopifyDashboard_Services({
|
|
13
|
+
resources,
|
|
14
|
+
onEdit,
|
|
15
|
+
onCreate,
|
|
16
|
+
onRefresh,
|
|
17
|
+
}: ShopifyDashboardServicesProps) {
|
|
18
|
+
/**
|
|
19
|
+
* Convert local ResourceNode[] into FullContentMapItem[] to satisfy the
|
|
20
|
+
* ResourceTable interface requirements.
|
|
21
|
+
*/
|
|
22
|
+
const resourceItems = resources.map((r) => ({
|
|
23
|
+
...r,
|
|
24
|
+
type: 'Resource' as const,
|
|
25
|
+
})) as FullContentMapItem[];
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className="space-y-6">
|
|
29
|
+
<div className="border-b border-gray-200 pb-4">
|
|
30
|
+
<h2 className="text-2xl font-bold text-gray-900">Imported Services</h2>
|
|
31
|
+
<p className="mt-2 text-sm text-gray-600">
|
|
32
|
+
Manage the services and bookable appointments you have already
|
|
33
|
+
imported into StoryKeep. Edit metadata, scheduling requirements, and
|
|
34
|
+
SEO.
|
|
35
|
+
</p>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<ResourceTable
|
|
39
|
+
categorySlug="service"
|
|
40
|
+
fullContentMap={resourceItems}
|
|
41
|
+
onEdit={onEdit}
|
|
42
|
+
onCreate={onCreate}
|
|
43
|
+
onRefresh={onRefresh}
|
|
44
|
+
/>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { APIRoute } from '@/types/astro';
|
|
2
|
+
|
|
3
|
+
export const POST: APIRoute = async ({ request, locals }) => {
|
|
4
|
+
const GO_BACKEND =
|
|
5
|
+
import.meta.env.PUBLIC_GO_BACKEND || 'http://localhost:8080';
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
const body = await request.text();
|
|
9
|
+
|
|
10
|
+
const controller = new AbortController();
|
|
11
|
+
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
|
12
|
+
const tenantId =
|
|
13
|
+
locals.tenant?.id || import.meta.env.PUBLIC_TENANTID || 'default';
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const response = await fetch(`${GO_BACKEND}/api/v1/auth/lookup-lead`, {
|
|
17
|
+
method: 'POST',
|
|
18
|
+
headers: {
|
|
19
|
+
'Content-Type': 'application/json',
|
|
20
|
+
'X-Tenant-ID': tenantId,
|
|
21
|
+
},
|
|
22
|
+
body: body,
|
|
23
|
+
signal: controller.signal,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
clearTimeout(timeoutId);
|
|
27
|
+
|
|
28
|
+
const data = await response.json();
|
|
29
|
+
|
|
30
|
+
return new Response(JSON.stringify(data), {
|
|
31
|
+
status: response.status,
|
|
32
|
+
headers: {
|
|
33
|
+
'Content-Type': 'application/json',
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
} catch (fetchError) {
|
|
37
|
+
clearTimeout(timeoutId);
|
|
38
|
+
|
|
39
|
+
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
|
|
40
|
+
console.error('Lookup-lead request timeout');
|
|
41
|
+
return new Response(
|
|
42
|
+
JSON.stringify({
|
|
43
|
+
success: false,
|
|
44
|
+
error: 'Request timeout - please try again',
|
|
45
|
+
}),
|
|
46
|
+
{
|
|
47
|
+
status: 408,
|
|
48
|
+
headers: {
|
|
49
|
+
'Content-Type': 'application/json',
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
throw fetchError;
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error('Lookup-lead API proxy error:', error);
|
|
58
|
+
|
|
59
|
+
return new Response(
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
success: false,
|
|
62
|
+
error: 'Failed to connect to backend service',
|
|
63
|
+
}),
|
|
64
|
+
{
|
|
65
|
+
status: 500,
|
|
66
|
+
headers: {
|
|
67
|
+
'Content-Type': 'application/json',
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
};
|