astro-tractstack 2.3.2 → 2.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-tractstack.js +7 -4
- package/dist/index.js +51 -8
- package/package.json +1 -1
- package/templates/custom/shopify/Cart.tsx +279 -118
- package/templates/custom/shopify/CartIcon.tsx +8 -8
- package/templates/custom/shopify/CheckoutModal.tsx +328 -65
- package/templates/custom/shopify/ShopifyCartManager.tsx +117 -60
- package/templates/custom/shopify/ShopifyCheckout.tsx +2 -26
- package/templates/custom/shopify/ShopifyProductGrid.tsx +8 -0
- package/templates/custom/shopify/ShopifyServiceList.tsx +25 -37
- package/templates/custom/shopify/cart.astro +7 -1
- package/templates/src/components/Header.astro +4 -2
- package/templates/src/components/compositor/Node.tsx +39 -9
- package/templates/src/components/compositor/nodes/CreativePane.tsx +175 -88
- package/templates/src/components/edit/pane/AddPanePanel.tsx +2 -2
- package/templates/src/components/edit/pane/AddPanePanel_new.tsx +6 -5
- package/templates/src/components/edit/pane/AddPanePanel_reuse.tsx +9 -15
- package/templates/src/components/edit/pane/ConfigPanePanel.tsx +1 -1
- package/templates/src/components/form/advanced/APIConfigSection.tsx +249 -4
- package/templates/src/components/form/brand/SiteConfigSection.tsx +9 -0
- package/templates/src/components/form/shopify/SchedulingSection.tsx +44 -0
- package/templates/src/components/storykeep/Dashboard_Advanced.tsx +66 -21
- package/templates/src/components/storykeep/Dashboard_Shopify.tsx +266 -18
- package/templates/src/components/storykeep/controls/content/ManageContent.tsx +1 -0
- package/templates/src/components/storykeep/controls/content/ProductTable.tsx +38 -24
- package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +240 -65
- package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +175 -48
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +91 -10
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Sales.tsx +479 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Search.tsx +7 -3
- package/templates/src/constants.ts +2 -0
- package/templates/src/layouts/Layout.astro +26 -0
- package/templates/src/pages/api/auth/logout.ts +35 -2
- package/templates/src/pages/api/google/oauth/callback.ts +50 -0
- package/templates/src/pages/api/google/oauth/disconnect.ts +32 -0
- package/templates/src/pages/api/google/oauth/start.ts +32 -0
- package/templates/src/pages/api/google/oauth/status.ts +32 -0
- package/templates/src/pages/api/sales/list.ts +66 -0
- package/templates/src/pages/api/sales/metrics.ts +60 -0
- package/templates/src/pages/context/[...contextSlug].astro +50 -31
- package/templates/src/pages/privacy.astro +84 -0
- package/templates/src/pages/storykeep/advanced.astro +4 -1
- package/templates/src/pages/terms.astro +47 -0
- package/templates/src/stores/nodes.ts +8 -0
- package/templates/src/stores/shopify.ts +5 -0
- package/templates/src/types/tractstack.ts +87 -0
- package/templates/src/utils/api/advancedConfig.ts +2 -1
- package/templates/src/utils/api/advancedHelpers.ts +20 -0
- package/templates/src/utils/api/bookingHelpers.ts +3 -1
- package/templates/src/utils/api/brandConfig.ts +2 -0
- package/templates/src/utils/api/brandHelpers.ts +14 -1
- package/templates/src/utils/api/salesHelpers.ts +21 -0
- package/templates/src/utils/booking/appointmentMode.ts +135 -0
- package/templates/src/utils/customHelpers.ts +287 -2
- package/utils/inject-files.ts +47 -4
- package/templates/src/components/codehooks/SandboxAuthWrapper.tsx +0 -101
- package/templates/src/utils/actions/actionButton.ts +0 -103
- package/templates/src/utils/actions/preParse_Clicked.ts +0 -87
|
@@ -52,9 +52,12 @@ export default function ShopifyDashboard_Bookings({
|
|
|
52
52
|
setBookings(response.data || []);
|
|
53
53
|
setTotalCount(response.totalCount || 0);
|
|
54
54
|
|
|
55
|
+
const availabilityStart = new Date();
|
|
56
|
+
const availabilityEnd = new Date(availabilityStart);
|
|
57
|
+
availabilityEnd.setDate(availabilityEnd.getDate() + 30);
|
|
55
58
|
const availability = await bookingHelpers.getAvailability(
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
availabilityStart.toISOString(),
|
|
60
|
+
availabilityEnd.toISOString()
|
|
58
61
|
);
|
|
59
62
|
if (availability?.scheduling?.timezone) {
|
|
60
63
|
setShopTimezone(availability.scheduling.timezone);
|
|
@@ -118,6 +121,26 @@ export default function ShopifyDashboard_Bookings({
|
|
|
118
121
|
}
|
|
119
122
|
};
|
|
120
123
|
|
|
124
|
+
const getModeColor = (mode?: string) => {
|
|
125
|
+
if (mode === 'REMOTE') return 'bg-violet-100 text-violet-800';
|
|
126
|
+
return 'bg-slate-100 text-slate-700';
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const getSyncColor = (syncStatus?: string) => {
|
|
130
|
+
switch (syncStatus) {
|
|
131
|
+
case 'SYNCED':
|
|
132
|
+
case 'DELETE_SYNCED':
|
|
133
|
+
return 'bg-green-100 text-green-800';
|
|
134
|
+
case 'FAILED':
|
|
135
|
+
return 'bg-red-100 text-red-800';
|
|
136
|
+
case 'PENDING':
|
|
137
|
+
case 'DELETE_PENDING':
|
|
138
|
+
return 'bg-amber-100 text-amber-800';
|
|
139
|
+
default:
|
|
140
|
+
return 'bg-gray-100 text-gray-700';
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
121
144
|
const todayStr = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate()).padStart(2, '0')}`;
|
|
122
145
|
|
|
123
146
|
const renderCustomerInfo = (booking: BookingEntity) => {
|
|
@@ -213,15 +236,31 @@ export default function ShopifyDashboard_Bookings({
|
|
|
213
236
|
dayBookings.map((booking) => (
|
|
214
237
|
<div
|
|
215
238
|
key={booking.id}
|
|
216
|
-
className=
|
|
239
|
+
className={`rounded-lg border p-4 shadow-sm transition-colors hover:border-cyan-200 ${
|
|
240
|
+
booking.appointmentMode === 'REMOTE'
|
|
241
|
+
? 'border-violet-200 bg-violet-50'
|
|
242
|
+
: 'border-gray-200 bg-white'
|
|
243
|
+
}`}
|
|
217
244
|
>
|
|
218
245
|
<div className="flex items-start justify-between">
|
|
219
246
|
<div className="space-y-1">
|
|
220
|
-
<
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
247
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
248
|
+
<span
|
|
249
|
+
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-bold ${getStatusColor(booking.status)}`}
|
|
250
|
+
>
|
|
251
|
+
{booking.status}
|
|
252
|
+
</span>
|
|
253
|
+
<span
|
|
254
|
+
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-bold ${getModeColor(booking.appointmentMode)}`}
|
|
255
|
+
>
|
|
256
|
+
{booking.appointmentMode || 'IN_PERSON'}
|
|
257
|
+
</span>
|
|
258
|
+
<span
|
|
259
|
+
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-bold ${getSyncColor(booking.googleSyncStatus)}`}
|
|
260
|
+
>
|
|
261
|
+
{booking.googleSyncStatus || 'NOT_SYNCED'}
|
|
262
|
+
</span>
|
|
263
|
+
</div>
|
|
225
264
|
<div className="text-sm font-bold text-gray-900">
|
|
226
265
|
{new Date(booking.startTime).toLocaleTimeString(
|
|
227
266
|
'en-US',
|
|
@@ -273,6 +312,21 @@ export default function ShopifyDashboard_Bookings({
|
|
|
273
312
|
)
|
|
274
313
|
.join(', ')}
|
|
275
314
|
</div>
|
|
315
|
+
{booking.googleMeetURL && (
|
|
316
|
+
<a
|
|
317
|
+
className="text-cyan-700 underline"
|
|
318
|
+
href={booking.googleMeetURL}
|
|
319
|
+
target="_blank"
|
|
320
|
+
rel="noreferrer"
|
|
321
|
+
>
|
|
322
|
+
Open Meet Link
|
|
323
|
+
</a>
|
|
324
|
+
)}
|
|
325
|
+
{booking.googleLastError && (
|
|
326
|
+
<div className="text-xs font-bold text-red-700">
|
|
327
|
+
Google sync error: {booking.googleLastError}
|
|
328
|
+
</div>
|
|
329
|
+
)}
|
|
276
330
|
</div>
|
|
277
331
|
</div>
|
|
278
332
|
))
|
|
@@ -295,6 +349,9 @@ export default function ShopifyDashboard_Bookings({
|
|
|
295
349
|
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
|
|
296
350
|
Status
|
|
297
351
|
</th>
|
|
352
|
+
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
|
|
353
|
+
Mode / Sync
|
|
354
|
+
</th>
|
|
298
355
|
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
|
|
299
356
|
Service(s)
|
|
300
357
|
</th>
|
|
@@ -312,13 +369,13 @@ export default function ShopifyDashboard_Bookings({
|
|
|
312
369
|
<tbody className="divide-y divide-gray-200 bg-white">
|
|
313
370
|
{isLoading ? (
|
|
314
371
|
<tr>
|
|
315
|
-
<td colSpan={
|
|
372
|
+
<td colSpan={6} className="py-12 text-center">
|
|
316
373
|
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-cyan-600" />
|
|
317
374
|
</td>
|
|
318
375
|
</tr>
|
|
319
376
|
) : bookings.length === 0 ? (
|
|
320
377
|
<tr>
|
|
321
|
-
<td colSpan={
|
|
378
|
+
<td colSpan={6} className="py-12 text-center text-gray-500">
|
|
322
379
|
No bookings found.
|
|
323
380
|
</td>
|
|
324
381
|
</tr>
|
|
@@ -334,6 +391,30 @@ export default function ShopifyDashboard_Bookings({
|
|
|
334
391
|
{booking.status}
|
|
335
392
|
</span>
|
|
336
393
|
</td>
|
|
394
|
+
<td className="whitespace-nowrap px-6 py-4 text-xs">
|
|
395
|
+
<div className="flex flex-col gap-1">
|
|
396
|
+
<span
|
|
397
|
+
className={`inline-flex w-fit items-center rounded-full px-2 py-0.5 font-bold ${getModeColor(booking.appointmentMode)}`}
|
|
398
|
+
>
|
|
399
|
+
{booking.appointmentMode || 'IN_PERSON'}
|
|
400
|
+
</span>
|
|
401
|
+
<span
|
|
402
|
+
className={`inline-flex w-fit items-center rounded-full px-2 py-0.5 font-bold ${getSyncColor(booking.googleSyncStatus)}`}
|
|
403
|
+
>
|
|
404
|
+
{booking.googleSyncStatus || 'NOT_SYNCED'}
|
|
405
|
+
</span>
|
|
406
|
+
{booking.googleMeetURL && (
|
|
407
|
+
<a
|
|
408
|
+
className="text-cyan-700 underline"
|
|
409
|
+
href={booking.googleMeetURL}
|
|
410
|
+
target="_blank"
|
|
411
|
+
rel="noreferrer"
|
|
412
|
+
>
|
|
413
|
+
Meet link
|
|
414
|
+
</a>
|
|
415
|
+
)}
|
|
416
|
+
</div>
|
|
417
|
+
</td>
|
|
337
418
|
<td className="px-6 py-4 text-sm text-gray-900">
|
|
338
419
|
{booking.resourceIds
|
|
339
420
|
.map((id) => resourceMap.get(id) || 'Unknown Service')
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import ChevronDownIcon from '@heroicons/react/24/outline/ChevronDownIcon';
|
|
3
|
+
import ChevronRightIcon from '@heroicons/react/24/outline/ChevronRightIcon';
|
|
4
|
+
import { salesHelpers } from '@/utils/api/salesHelpers';
|
|
5
|
+
import type { SaleEntity, SaleProductLine } from '@/types/tractstack';
|
|
6
|
+
import type { ResourceNode } from '@/types/compositorTypes';
|
|
7
|
+
import {
|
|
8
|
+
getServiceLinkedProduct,
|
|
9
|
+
isSharedFeeService,
|
|
10
|
+
parsePrimaryShopifyProductData,
|
|
11
|
+
} from '@/utils/customHelpers';
|
|
12
|
+
|
|
13
|
+
interface ShopifyDashboardSalesProps {
|
|
14
|
+
existingResources: ResourceNode[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ITEMS_PER_PAGE = 10;
|
|
18
|
+
|
|
19
|
+
export default function ShopifyDashboard_Sales({
|
|
20
|
+
existingResources,
|
|
21
|
+
}: ShopifyDashboardSalesProps) {
|
|
22
|
+
const [sales, setSales] = useState<SaleEntity[]>([]);
|
|
23
|
+
const [totalCount, setTotalCount] = useState(0);
|
|
24
|
+
const [currentPage, setCurrentPage] = useState(0);
|
|
25
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
26
|
+
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
|
27
|
+
|
|
28
|
+
const resourceById = useMemo(() => {
|
|
29
|
+
return new Map(
|
|
30
|
+
existingResources.map((resource) => [resource.id, resource])
|
|
31
|
+
);
|
|
32
|
+
}, [existingResources]);
|
|
33
|
+
|
|
34
|
+
const resourceBySlug = useMemo(() => {
|
|
35
|
+
return new Map(
|
|
36
|
+
existingResources.map((resource) => [resource.slug, resource])
|
|
37
|
+
);
|
|
38
|
+
}, [existingResources]);
|
|
39
|
+
|
|
40
|
+
const fetchSales = useCallback(async () => {
|
|
41
|
+
setIsLoading(true);
|
|
42
|
+
try {
|
|
43
|
+
const response = await salesHelpers.listSales(
|
|
44
|
+
ITEMS_PER_PAGE,
|
|
45
|
+
currentPage * ITEMS_PER_PAGE
|
|
46
|
+
);
|
|
47
|
+
setSales(response.data || []);
|
|
48
|
+
setTotalCount(response.totalCount || 0);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
console.error('Failed to fetch sales:', error);
|
|
51
|
+
} finally {
|
|
52
|
+
setIsLoading(false);
|
|
53
|
+
}
|
|
54
|
+
}, [currentPage]);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
fetchSales();
|
|
58
|
+
}, [fetchSales]);
|
|
59
|
+
|
|
60
|
+
const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
|
|
61
|
+
|
|
62
|
+
const toggleExpanded = (saleId: string) => {
|
|
63
|
+
setExpandedRows((current) => {
|
|
64
|
+
const next = new Set(current);
|
|
65
|
+
if (next.has(saleId)) {
|
|
66
|
+
next.delete(saleId);
|
|
67
|
+
} else {
|
|
68
|
+
next.add(saleId);
|
|
69
|
+
}
|
|
70
|
+
return next;
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const getStatusColor = (status: string) => {
|
|
75
|
+
if (status === 'PAID') return 'bg-green-100 text-green-800';
|
|
76
|
+
return 'bg-gray-100 text-gray-800';
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const getTagColor = (tag: string) => {
|
|
80
|
+
switch (tag) {
|
|
81
|
+
case 'local-pickup':
|
|
82
|
+
return 'bg-cyan-100 text-cyan-800';
|
|
83
|
+
case 'orphan':
|
|
84
|
+
return 'bg-red-100 text-red-800';
|
|
85
|
+
case 'remote':
|
|
86
|
+
return 'bg-violet-100 text-violet-800';
|
|
87
|
+
case 'in-person':
|
|
88
|
+
return 'bg-slate-100 text-slate-700';
|
|
89
|
+
default:
|
|
90
|
+
return 'bg-gray-100 text-gray-700';
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const formatMoney = (amount: string, currencyCode?: string) => {
|
|
95
|
+
const parsed = parseFloat(amount || '0');
|
|
96
|
+
const safeAmount = Number.isFinite(parsed) ? parsed : 0;
|
|
97
|
+
return `${safeAmount.toFixed(2)} ${currencyCode || 'USD'}`;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const formatLineTotal = (line: SaleProductLine) => {
|
|
101
|
+
const parsed = parseFloat(line.price || '0');
|
|
102
|
+
const safeAmount = Number.isFinite(parsed) ? parsed : 0;
|
|
103
|
+
return formatMoney(
|
|
104
|
+
(safeAmount * (line.quantity || 1)).toString(),
|
|
105
|
+
line.currencyCode
|
|
106
|
+
);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const productSummary = (sale: SaleEntity) => {
|
|
110
|
+
if (sale.products.length === 0) return 'No line items';
|
|
111
|
+
const first = sale.products[0];
|
|
112
|
+
const suffix =
|
|
113
|
+
sale.products.length > 1 ? `, +${sale.products.length - 1} more` : '';
|
|
114
|
+
return `${first.title} x${first.quantity || 1}${suffix}`;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const customerLabel = (sale: SaleEntity) => {
|
|
118
|
+
if (sale.leadName && sale.leadEmail) {
|
|
119
|
+
return `${sale.leadName} (${sale.leadEmail})`;
|
|
120
|
+
}
|
|
121
|
+
return sale.leadName || sale.leadEmail || 'Guest';
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const variantTitle = (line: SaleProductLine) => {
|
|
125
|
+
const product = resourceById.get(line.resourceId);
|
|
126
|
+
const parsed = parsePrimaryShopifyProductData(product);
|
|
127
|
+
const variant = parsed?.variants?.find(
|
|
128
|
+
(v: any) => v?.id === line.variantId
|
|
129
|
+
);
|
|
130
|
+
return variant?.title && variant.title !== 'Default Title'
|
|
131
|
+
? variant.title
|
|
132
|
+
: '';
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const resolveBookingServices = (sale: SaleEntity) => {
|
|
136
|
+
const serviceMap = new Map<string, ResourceNode>();
|
|
137
|
+
const bookingResources =
|
|
138
|
+
sale.booking?.resourceIds
|
|
139
|
+
?.map((id) => resourceById.get(id))
|
|
140
|
+
.filter((resource): resource is ResourceNode => Boolean(resource)) ||
|
|
141
|
+
[];
|
|
142
|
+
|
|
143
|
+
bookingResources.forEach((resource) => {
|
|
144
|
+
if (
|
|
145
|
+
resource.categorySlug === 'service' ||
|
|
146
|
+
resource.optionsPayload?.bookingLengthMinutes
|
|
147
|
+
) {
|
|
148
|
+
serviceMap.set(resource.id, resource);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const boundSlug = resource.optionsPayload?.serviceBound;
|
|
152
|
+
if (typeof boundSlug === 'string' && boundSlug.trim()) {
|
|
153
|
+
const boundService = resourceBySlug.get(boundSlug);
|
|
154
|
+
if (boundService) {
|
|
155
|
+
serviceMap.set(boundService.id, boundService);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
return Array.from(serviceMap.values()).sort((a, b) =>
|
|
161
|
+
a.title.localeCompare(b.title)
|
|
162
|
+
);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const renderSharedFeeBlock = (sale: SaleEntity) => {
|
|
166
|
+
if (!sale.booking) return null;
|
|
167
|
+
const services = resolveBookingServices(sale);
|
|
168
|
+
const sharedServices = services.filter((service) =>
|
|
169
|
+
isSharedFeeService(service, existingResources)
|
|
170
|
+
);
|
|
171
|
+
if (sharedServices.length === 0) return null;
|
|
172
|
+
|
|
173
|
+
const chargeLine = sale.products.find((line) => {
|
|
174
|
+
const product = resourceById.get(line.resourceId);
|
|
175
|
+
return product?.optionsPayload?.sharedServiceFee === true;
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<div className="rounded-md border border-cyan-100 bg-cyan-50 p-3">
|
|
180
|
+
<div className="text-xs font-bold uppercase text-cyan-700">
|
|
181
|
+
Shared service charge
|
|
182
|
+
</div>
|
|
183
|
+
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-cyan-950">
|
|
184
|
+
{sharedServices.map((service) => (
|
|
185
|
+
<li key={service.id}>{service.title}</li>
|
|
186
|
+
))}
|
|
187
|
+
</ul>
|
|
188
|
+
{chargeLine && (
|
|
189
|
+
<div className="mt-3 border-t border-cyan-200 pt-2 text-sm font-bold text-cyan-950">
|
|
190
|
+
Charge: {formatLineTotal(chargeLine)}
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const renderNonSharedServices = (sale: SaleEntity) => {
|
|
198
|
+
if (!sale.booking) return null;
|
|
199
|
+
const services = resolveBookingServices(sale).filter(
|
|
200
|
+
(service) => !isSharedFeeService(service, existingResources)
|
|
201
|
+
);
|
|
202
|
+
if (services.length === 0) return null;
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<div className="rounded-md border border-gray-200 bg-white p-3">
|
|
206
|
+
<div className="text-xs font-bold uppercase text-gray-500">
|
|
207
|
+
Service charges
|
|
208
|
+
</div>
|
|
209
|
+
<div className="mt-2 space-y-2">
|
|
210
|
+
{services.map((service) => {
|
|
211
|
+
const product = getServiceLinkedProduct(service, existingResources);
|
|
212
|
+
const line = sale.products.find(
|
|
213
|
+
(candidate) => candidate.gid === product?.optionsPayload?.gid
|
|
214
|
+
);
|
|
215
|
+
return (
|
|
216
|
+
<div
|
|
217
|
+
key={service.id}
|
|
218
|
+
className="flex justify-between gap-4 text-sm text-gray-700"
|
|
219
|
+
>
|
|
220
|
+
<span>{service.title}</span>
|
|
221
|
+
{line && (
|
|
222
|
+
<span className="font-bold">{formatLineTotal(line)}</span>
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
})}
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const renderAppointment = (sale: SaleEntity) => {
|
|
233
|
+
if (!sale.booking) return null;
|
|
234
|
+
const services = resolveBookingServices(sale);
|
|
235
|
+
return (
|
|
236
|
+
<div className="rounded-md border border-gray-200 bg-white p-3">
|
|
237
|
+
<div className="text-xs font-bold uppercase text-gray-500">
|
|
238
|
+
Appointment
|
|
239
|
+
</div>
|
|
240
|
+
<div className="mt-2 grid gap-2 text-sm text-gray-700 md:grid-cols-2">
|
|
241
|
+
<div>
|
|
242
|
+
<span className="font-bold">Status:</span> {sale.booking.status}
|
|
243
|
+
</div>
|
|
244
|
+
<div>
|
|
245
|
+
<span className="font-bold">Mode:</span>{' '}
|
|
246
|
+
{sale.booking.appointmentMode || 'IN_PERSON'}
|
|
247
|
+
</div>
|
|
248
|
+
<div>
|
|
249
|
+
<span className="font-bold">Date:</span>{' '}
|
|
250
|
+
{new Date(sale.booking.startTime).toLocaleDateString()}
|
|
251
|
+
</div>
|
|
252
|
+
<div>
|
|
253
|
+
<span className="font-bold">Time:</span>{' '}
|
|
254
|
+
{new Date(sale.booking.startTime).toLocaleTimeString([], {
|
|
255
|
+
hour: '2-digit',
|
|
256
|
+
minute: '2-digit',
|
|
257
|
+
})}{' '}
|
|
258
|
+
-{' '}
|
|
259
|
+
{new Date(sale.booking.endTime).toLocaleTimeString([], {
|
|
260
|
+
hour: '2-digit',
|
|
261
|
+
minute: '2-digit',
|
|
262
|
+
})}
|
|
263
|
+
</div>
|
|
264
|
+
<div>
|
|
265
|
+
<span className="font-bold">Google sync:</span>{' '}
|
|
266
|
+
{sale.booking.googleSyncStatus || 'NOT_SYNCED'}
|
|
267
|
+
</div>
|
|
268
|
+
<div>
|
|
269
|
+
<span className="font-bold">Customer:</span> {customerLabel(sale)}
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
{services.length > 0 && (
|
|
273
|
+
<div className="mt-3 text-sm text-gray-700">
|
|
274
|
+
<span className="font-bold">Services:</span>{' '}
|
|
275
|
+
{services.map((service) => service.title).join(', ')}
|
|
276
|
+
</div>
|
|
277
|
+
)}
|
|
278
|
+
{sale.booking.googleMeetURL && (
|
|
279
|
+
<a
|
|
280
|
+
className="mt-2 inline-block text-sm text-cyan-700 underline"
|
|
281
|
+
href={sale.booking.googleMeetURL}
|
|
282
|
+
target="_blank"
|
|
283
|
+
rel="noreferrer"
|
|
284
|
+
>
|
|
285
|
+
Meet link
|
|
286
|
+
</a>
|
|
287
|
+
)}
|
|
288
|
+
</div>
|
|
289
|
+
);
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const renderExpandedRow = (sale: SaleEntity) => (
|
|
293
|
+
<tr>
|
|
294
|
+
<td colSpan={5} className="bg-gray-50 px-6 py-4">
|
|
295
|
+
<div className="space-y-4">
|
|
296
|
+
<div className="rounded-md border border-gray-200 bg-white p-3">
|
|
297
|
+
<div className="text-xs font-bold uppercase text-gray-500">
|
|
298
|
+
Products
|
|
299
|
+
</div>
|
|
300
|
+
<div className="mt-2 divide-y divide-gray-100">
|
|
301
|
+
{sale.products.map((line) => {
|
|
302
|
+
const product = resourceById.get(line.resourceId);
|
|
303
|
+
const variant = variantTitle(line);
|
|
304
|
+
return (
|
|
305
|
+
<div
|
|
306
|
+
key={`${line.resourceId}-${line.variantId}`}
|
|
307
|
+
className="flex flex-wrap items-center justify-between gap-3 py-2 text-sm"
|
|
308
|
+
>
|
|
309
|
+
<div>
|
|
310
|
+
<div className="font-bold text-gray-900">
|
|
311
|
+
{line.title || product?.title || 'Product'}
|
|
312
|
+
</div>
|
|
313
|
+
{variant && (
|
|
314
|
+
<div className="text-xs text-gray-500">{variant}</div>
|
|
315
|
+
)}
|
|
316
|
+
{line.isLocalPickup && (
|
|
317
|
+
<span className="mt-1 inline-flex rounded-full bg-cyan-100 px-2 py-0.5 text-xs font-bold text-cyan-800">
|
|
318
|
+
Local pickup
|
|
319
|
+
</span>
|
|
320
|
+
)}
|
|
321
|
+
</div>
|
|
322
|
+
<div className="text-right">
|
|
323
|
+
<div className="font-bold text-gray-900">
|
|
324
|
+
{formatLineTotal(line)}
|
|
325
|
+
</div>
|
|
326
|
+
<div className="text-xs text-gray-500">
|
|
327
|
+
Qty {line.quantity || 1}
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
</div>
|
|
331
|
+
);
|
|
332
|
+
})}
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
{renderSharedFeeBlock(sale)}
|
|
336
|
+
{renderNonSharedServices(sale)}
|
|
337
|
+
{renderAppointment(sale)}
|
|
338
|
+
{sale.tags.includes('orphan') && (
|
|
339
|
+
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-sm font-bold text-red-800">
|
|
340
|
+
Orphaned payment: appointment payment received with no active
|
|
341
|
+
booking row.
|
|
342
|
+
</div>
|
|
343
|
+
)}
|
|
344
|
+
</div>
|
|
345
|
+
</td>
|
|
346
|
+
</tr>
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
return (
|
|
350
|
+
<div className="space-y-4">
|
|
351
|
+
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
|
|
352
|
+
<table className="min-w-full divide-y divide-gray-200">
|
|
353
|
+
<thead className="bg-gray-50">
|
|
354
|
+
<tr>
|
|
355
|
+
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
|
|
356
|
+
Sale
|
|
357
|
+
</th>
|
|
358
|
+
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
|
|
359
|
+
Status / Tags
|
|
360
|
+
</th>
|
|
361
|
+
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
|
|
362
|
+
Customer
|
|
363
|
+
</th>
|
|
364
|
+
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
|
|
365
|
+
Products
|
|
366
|
+
</th>
|
|
367
|
+
<th className="px-6 py-3 text-right text-xs font-bold uppercase tracking-wider text-gray-500">
|
|
368
|
+
Total
|
|
369
|
+
</th>
|
|
370
|
+
</tr>
|
|
371
|
+
</thead>
|
|
372
|
+
<tbody className="divide-y divide-gray-200 bg-white">
|
|
373
|
+
{isLoading ? (
|
|
374
|
+
<tr>
|
|
375
|
+
<td colSpan={5} className="py-12 text-center">
|
|
376
|
+
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-cyan-600" />
|
|
377
|
+
</td>
|
|
378
|
+
</tr>
|
|
379
|
+
) : sales.length === 0 ? (
|
|
380
|
+
<tr>
|
|
381
|
+
<td colSpan={5} className="py-12 text-center text-gray-500">
|
|
382
|
+
No sales found.
|
|
383
|
+
</td>
|
|
384
|
+
</tr>
|
|
385
|
+
) : (
|
|
386
|
+
sales.map((sale) => {
|
|
387
|
+
const expanded = expandedRows.has(sale.id);
|
|
388
|
+
return (
|
|
389
|
+
<Fragment key={sale.id}>
|
|
390
|
+
<tr className="hover:bg-gray-50">
|
|
391
|
+
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
|
|
392
|
+
<button
|
|
393
|
+
onClick={() => toggleExpanded(sale.id)}
|
|
394
|
+
className="inline-flex items-center gap-2 font-bold text-gray-900"
|
|
395
|
+
>
|
|
396
|
+
{expanded ? (
|
|
397
|
+
<ChevronDownIcon className="h-4 w-4" />
|
|
398
|
+
) : (
|
|
399
|
+
<ChevronRightIcon className="h-4 w-4" />
|
|
400
|
+
)}
|
|
401
|
+
{new Date(sale.createdAt).toLocaleDateString()}
|
|
402
|
+
</button>
|
|
403
|
+
<div className="mt-1">
|
|
404
|
+
<a
|
|
405
|
+
href={`https://admin.shopify.com/orders/${sale.shopifyOrderId}`}
|
|
406
|
+
target="_blank"
|
|
407
|
+
rel="noopener noreferrer"
|
|
408
|
+
className="text-xs font-bold text-cyan-600 hover:text-cyan-800 hover:underline"
|
|
409
|
+
>
|
|
410
|
+
Order #{sale.shopifyOrderId}
|
|
411
|
+
</a>
|
|
412
|
+
</div>
|
|
413
|
+
</td>
|
|
414
|
+
<td className="px-6 py-4 text-xs">
|
|
415
|
+
<div className="flex flex-col gap-1">
|
|
416
|
+
<span
|
|
417
|
+
className={`inline-flex w-fit items-center rounded-full px-2 py-0.5 font-bold ${getStatusColor(
|
|
418
|
+
sale.status
|
|
419
|
+
)}`}
|
|
420
|
+
>
|
|
421
|
+
{sale.status}
|
|
422
|
+
</span>
|
|
423
|
+
<div className="flex flex-wrap gap-1">
|
|
424
|
+
{sale.tags.map((tag) => (
|
|
425
|
+
<span
|
|
426
|
+
key={tag}
|
|
427
|
+
className={`inline-flex w-fit items-center rounded-full px-2 py-0.5 font-bold ${getTagColor(
|
|
428
|
+
tag
|
|
429
|
+
)}`}
|
|
430
|
+
>
|
|
431
|
+
{tag}
|
|
432
|
+
</span>
|
|
433
|
+
))}
|
|
434
|
+
</div>
|
|
435
|
+
</div>
|
|
436
|
+
</td>
|
|
437
|
+
<td className="px-6 py-4 text-sm text-gray-500">
|
|
438
|
+
{customerLabel(sale)}
|
|
439
|
+
</td>
|
|
440
|
+
<td className="px-6 py-4 text-sm text-gray-900">
|
|
441
|
+
{productSummary(sale)}
|
|
442
|
+
</td>
|
|
443
|
+
<td className="whitespace-nowrap px-6 py-4 text-right text-sm font-bold text-gray-900">
|
|
444
|
+
{formatMoney(sale.totalAmount)}
|
|
445
|
+
</td>
|
|
446
|
+
</tr>
|
|
447
|
+
{expanded && renderExpandedRow(sale)}
|
|
448
|
+
</Fragment>
|
|
449
|
+
);
|
|
450
|
+
})
|
|
451
|
+
)}
|
|
452
|
+
</tbody>
|
|
453
|
+
</table>
|
|
454
|
+
</div>
|
|
455
|
+
|
|
456
|
+
{totalPages > 1 && (
|
|
457
|
+
<div className="flex justify-center gap-2 pt-4">
|
|
458
|
+
<button
|
|
459
|
+
onClick={() => setCurrentPage((page) => page - 1)}
|
|
460
|
+
disabled={currentPage === 0 || isLoading}
|
|
461
|
+
className="rounded border border-gray-300 bg-white px-3 py-1 text-sm shadow-sm hover:bg-gray-50 disabled:opacity-50"
|
|
462
|
+
>
|
|
463
|
+
Previous
|
|
464
|
+
</button>
|
|
465
|
+
<span className="flex items-center text-sm text-gray-600">
|
|
466
|
+
Page {currentPage + 1} of {totalPages}
|
|
467
|
+
</span>
|
|
468
|
+
<button
|
|
469
|
+
onClick={() => setCurrentPage((page) => page + 1)}
|
|
470
|
+
disabled={currentPage === totalPages - 1 || isLoading}
|
|
471
|
+
className="rounded border border-gray-300 bg-white px-3 py-1 text-sm shadow-sm hover:bg-gray-50 disabled:opacity-50"
|
|
472
|
+
>
|
|
473
|
+
Next
|
|
474
|
+
</button>
|
|
475
|
+
</div>
|
|
476
|
+
)}
|
|
477
|
+
</div>
|
|
478
|
+
);
|
|
479
|
+
}
|
|
@@ -7,19 +7,22 @@ import {
|
|
|
7
7
|
} from '@/stores/shopify';
|
|
8
8
|
import ProductTable from '@/components/storykeep/controls/content/ProductTable';
|
|
9
9
|
import type { ResourceNode } from '@/types/compositorTypes';
|
|
10
|
+
import type { ShopifyLinkedStatus } from '@/components/storykeep/Dashboard_Shopify';
|
|
10
11
|
|
|
11
12
|
interface ShopifyDashboardSearchProps {
|
|
12
|
-
|
|
13
|
+
linkedStatusMap: Map<string, ShopifyLinkedStatus>;
|
|
13
14
|
onSelectProduct: (product: ShopifyProduct) => void;
|
|
14
15
|
onLink: (product: ShopifyProduct) => void;
|
|
16
|
+
onMarkShared: (product: ShopifyProduct) => void;
|
|
15
17
|
onUnlink: (resourceId: string) => void;
|
|
16
18
|
onEdit: (product: ShopifyProduct, resource: ResourceNode) => void;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export default function ShopifyDashboard_Search({
|
|
20
|
-
|
|
22
|
+
linkedStatusMap,
|
|
21
23
|
onSelectProduct,
|
|
22
24
|
onLink,
|
|
25
|
+
onMarkShared,
|
|
23
26
|
onUnlink,
|
|
24
27
|
onEdit,
|
|
25
28
|
}: ShopifyDashboardSearchProps) {
|
|
@@ -42,11 +45,12 @@ export default function ShopifyDashboard_Search({
|
|
|
42
45
|
|
|
43
46
|
<ProductTable
|
|
44
47
|
products={data.products}
|
|
45
|
-
|
|
48
|
+
linkedStatusMap={linkedStatusMap}
|
|
46
49
|
onRefresh={handleRefresh}
|
|
47
50
|
isRefreshing={status.isLoading}
|
|
48
51
|
onSelectProduct={onSelectProduct}
|
|
49
52
|
onLink={onLink}
|
|
53
|
+
onMarkShared={onMarkShared}
|
|
50
54
|
onUnlink={onUnlink}
|
|
51
55
|
onEdit={onEdit}
|
|
52
56
|
/>
|