astro-tractstack 2.3.3 → 2.3.5
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 +5 -2
- package/dist/index.js +32 -4
- package/package.json +1 -1
- package/templates/custom/customHelpers.ts +45 -0
- package/templates/custom/shopify/Cart.tsx +197 -105
- package/templates/custom/shopify/CartIcon.tsx +8 -8
- package/templates/custom/shopify/CheckoutModal.tsx +145 -68
- package/templates/custom/shopify/ShopifyCartManager.tsx +67 -22
- 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/shopifyCustomHelper.ts +10 -0
- package/templates/custom/shopify/shopifyHelpers.ts +298 -0
- package/templates/src/components/Header.astro +2 -2
- package/templates/src/components/codehooks/SearchWidget.tsx +1 -1
- 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 +35 -8
- package/templates/src/components/form/brand/SiteConfigSection.tsx +9 -0
- package/templates/src/components/search/SearchResults.tsx +1 -1
- package/templates/src/components/search/SearchWrapper.tsx +1 -1
- package/templates/src/components/storykeep/Dashboard_Advanced.tsx +59 -23
- package/templates/src/components/storykeep/Dashboard_Shopify.tsx +257 -18
- package/templates/src/components/storykeep/controls/content/ProductTable.tsx +38 -24
- package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +162 -67
- package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +175 -48
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +5 -2
- 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/layouts/Layout.astro +26 -0
- package/templates/src/pages/api/auth/logout.ts +35 -2
- 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/storykeep/advanced.astro +4 -1
- package/templates/src/stores/nodes.ts +8 -0
- package/templates/src/types/tractstack.ts +57 -0
- package/templates/src/utils/api/advancedConfig.ts +2 -1
- package/templates/src/utils/api/advancedHelpers.ts +4 -0
- package/templates/src/utils/api/brandConfig.ts +2 -0
- package/templates/src/utils/api/brandHelpers.ts +6 -0
- package/templates/src/utils/api/salesHelpers.ts +21 -0
- package/utils/inject-files.ts +32 -4
- package/templates/src/utils/customHelpers.ts +0 -89
- /package/templates/{src/utils/booking → custom/shopify}/appointmentMode.ts +0 -0
|
@@ -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 '@/custom/shopify/shopifyHelpers';
|
|
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
|
/>
|
|
@@ -303,6 +303,27 @@ const enableBunny = import.meta.env.PUBLIC_ENABLE_BUNNY === 'true';
|
|
|
303
303
|
<script is:inline is:persist>
|
|
304
304
|
let navProgressInterval = null;
|
|
305
305
|
let navSafetyTimeout = null;
|
|
306
|
+
const IN_SITE_FROM_KEY = 'tractstack:inSiteFrom';
|
|
307
|
+
let isInSiteNavigationTrackingBound = false;
|
|
308
|
+
|
|
309
|
+
const navEntry = performance.getEntriesByType('navigation')[0];
|
|
310
|
+
if (navEntry?.type === 'navigate' && history.length <= 1) {
|
|
311
|
+
sessionStorage.removeItem(IN_SITE_FROM_KEY);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function setupInSiteNavigationTracking() {
|
|
315
|
+
if (isInSiteNavigationTrackingBound) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
document.addEventListener('astro:before-preparation', () => {
|
|
320
|
+
sessionStorage.setItem(
|
|
321
|
+
IN_SITE_FROM_KEY,
|
|
322
|
+
location.pathname + location.search + location.hash
|
|
323
|
+
);
|
|
324
|
+
});
|
|
325
|
+
isInSiteNavigationTrackingBound = true;
|
|
326
|
+
}
|
|
306
327
|
|
|
307
328
|
function startNavLoadingAnimation() {
|
|
308
329
|
const loadingIndicator = document.getElementById('loading-indicator');
|
|
@@ -402,8 +423,13 @@ const enableBunny = import.meta.env.PUBLIC_ENABLE_BUNNY === 'true';
|
|
|
402
423
|
|
|
403
424
|
if (document.readyState === 'loading') {
|
|
404
425
|
document.addEventListener('DOMContentLoaded', setupNavigationLoading);
|
|
426
|
+
document.addEventListener(
|
|
427
|
+
'DOMContentLoaded',
|
|
428
|
+
setupInSiteNavigationTracking
|
|
429
|
+
);
|
|
405
430
|
} else {
|
|
406
431
|
setupNavigationLoading();
|
|
432
|
+
setupInSiteNavigationTracking();
|
|
407
433
|
}
|
|
408
434
|
|
|
409
435
|
document.addEventListener('astro:page-load', setupNavigationLoading);
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { APIRoute } from '@/types/astro';
|
|
2
2
|
|
|
3
|
-
export const POST: APIRoute = async ({ cookies, url }) => {
|
|
3
|
+
export const POST: APIRoute = async ({ cookies, url, locals }) => {
|
|
4
|
+
const tenantId =
|
|
5
|
+
locals.tenant?.id || import.meta.env.PUBLIC_TENANTID || 'default';
|
|
6
|
+
|
|
4
7
|
try {
|
|
5
8
|
const isLocalhost =
|
|
6
9
|
url.hostname === 'localhost' || url.hostname === '127.0.0.1';
|
|
@@ -15,6 +18,36 @@ export const POST: APIRoute = async ({ cookies, url }) => {
|
|
|
15
18
|
cookies.delete('admin_auth', cookieOptions);
|
|
16
19
|
cookies.delete('editor_auth', cookieOptions);
|
|
17
20
|
|
|
21
|
+
const responseHeaders = new Headers({
|
|
22
|
+
'Content-Type': 'application/json',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const backendUrl =
|
|
26
|
+
import.meta.env.PUBLIC_GO_BACKEND || 'http://localhost:8080';
|
|
27
|
+
|
|
28
|
+
const controller = new AbortController();
|
|
29
|
+
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const backendResponse = await fetch(`${backendUrl}/api/v1/auth/logout`, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: {
|
|
35
|
+
'Content-Type': 'application/json',
|
|
36
|
+
'X-Tenant-ID': tenantId,
|
|
37
|
+
},
|
|
38
|
+
signal: controller.signal,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
clearTimeout(timeoutId);
|
|
42
|
+
|
|
43
|
+
for (const setCookie of backendResponse.headers.getSetCookie()) {
|
|
44
|
+
responseHeaders.append('Set-Cookie', setCookie);
|
|
45
|
+
}
|
|
46
|
+
} catch (fetchError) {
|
|
47
|
+
clearTimeout(timeoutId);
|
|
48
|
+
console.error('Logout backend request failed:', fetchError);
|
|
49
|
+
}
|
|
50
|
+
|
|
18
51
|
return new Response(
|
|
19
52
|
JSON.stringify({
|
|
20
53
|
success: true,
|
|
@@ -22,7 +55,7 @@ export const POST: APIRoute = async ({ cookies, url }) => {
|
|
|
22
55
|
}),
|
|
23
56
|
{
|
|
24
57
|
status: 200,
|
|
25
|
-
headers:
|
|
58
|
+
headers: responseHeaders,
|
|
26
59
|
}
|
|
27
60
|
);
|
|
28
61
|
} catch (error) {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { APIRoute } from '@/types/astro';
|
|
2
|
+
import { getAdminToken } from '@/utils/auth';
|
|
3
|
+
|
|
4
|
+
export const GET: APIRoute = async (context) => {
|
|
5
|
+
const { request, locals } = context;
|
|
6
|
+
const GO_BACKEND =
|
|
7
|
+
import.meta.env.PUBLIC_GO_BACKEND || 'http://localhost:8080';
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
const url = new URL(request.url);
|
|
11
|
+
const searchParams = url.searchParams;
|
|
12
|
+
|
|
13
|
+
const controller = new AbortController();
|
|
14
|
+
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
|
15
|
+
const tenantId =
|
|
16
|
+
locals.tenant?.id || import.meta.env.PUBLIC_TENANTID || 'default';
|
|
17
|
+
const token = getAdminToken(context);
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const response = await fetch(
|
|
21
|
+
`${GO_BACKEND}/api/v1/sales/list?${searchParams.toString()}`,
|
|
22
|
+
{
|
|
23
|
+
method: 'GET',
|
|
24
|
+
headers: {
|
|
25
|
+
'Content-Type': 'application/json',
|
|
26
|
+
'X-Tenant-ID': tenantId,
|
|
27
|
+
...(token && { Authorization: `Bearer ${token}` }),
|
|
28
|
+
...(request.headers.get('Authorization') && {
|
|
29
|
+
Authorization: request.headers.get('Authorization')!,
|
|
30
|
+
}),
|
|
31
|
+
},
|
|
32
|
+
signal: controller.signal,
|
|
33
|
+
}
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
clearTimeout(timeoutId);
|
|
37
|
+
const data = await response.json();
|
|
38
|
+
|
|
39
|
+
return new Response(JSON.stringify(data), {
|
|
40
|
+
status: response.status,
|
|
41
|
+
headers: { 'Content-Type': 'application/json' },
|
|
42
|
+
});
|
|
43
|
+
} catch (fetchError) {
|
|
44
|
+
clearTimeout(timeoutId);
|
|
45
|
+
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
|
|
46
|
+
return new Response(
|
|
47
|
+
JSON.stringify({
|
|
48
|
+
success: false,
|
|
49
|
+
error: 'Sales list lookup timeout',
|
|
50
|
+
}),
|
|
51
|
+
{ status: 408, headers: { 'Content-Type': 'application/json' } }
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
throw fetchError;
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error('Sales list API proxy error:', error);
|
|
58
|
+
return new Response(
|
|
59
|
+
JSON.stringify({
|
|
60
|
+
success: false,
|
|
61
|
+
error: 'Failed to connect to backend service',
|
|
62
|
+
}),
|
|
63
|
+
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
};
|