@jytextiles/medusa-plugin-faire-store-sync 0.2.1 → 0.2.3
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/.medusa/server/src/admin/index.js +359 -164
- package/.medusa/server/src/admin/index.mjs +361 -166
- package/.medusa/server/src/lib/crypto.js +84 -0
- package/.medusa/server/src/lib/faire-client.js +25 -1
- package/.medusa/server/src/modules/faire-sync/service.js +51 -10
- package/README.md +21 -19
- package/package.json +1 -1
- package/src/admin/components/route-focus-modal.tsx +51 -0
- package/src/admin/routes/settings/faire/bulk/page.tsx +318 -0
- package/src/admin/routes/settings/faire/bulk/use-bulk-product-columns.tsx +60 -0
- package/src/admin/routes/settings/faire/page.tsx +7 -164
- package/src/admin/routes/settings/oauth/faire/callback/page.tsx +63 -20
- package/src/lib/crypto.ts +84 -0
- package/src/lib/faire-client.ts +24 -0
- package/src/modules/faire-sync/service.ts +57 -9
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Badge,
|
|
3
|
+
Button,
|
|
4
|
+
DataTable,
|
|
5
|
+
DataTableFilteringState,
|
|
6
|
+
DataTablePaginationState,
|
|
7
|
+
DataTableRowSelectionState,
|
|
8
|
+
Heading,
|
|
9
|
+
Input,
|
|
10
|
+
Skeleton,
|
|
11
|
+
StatusBadge,
|
|
12
|
+
Text,
|
|
13
|
+
Toaster,
|
|
14
|
+
toast,
|
|
15
|
+
useDataTable,
|
|
16
|
+
} from "@medusajs/ui"
|
|
17
|
+
import {
|
|
18
|
+
keepPreviousData,
|
|
19
|
+
useMutation,
|
|
20
|
+
useQuery,
|
|
21
|
+
useQueryClient,
|
|
22
|
+
} from "@tanstack/react-query"
|
|
23
|
+
import { useMemo, useState } from "react"
|
|
24
|
+
import { faireApi, sdk } from "../../../../lib/api"
|
|
25
|
+
import { RouteFocusModal } from "../../../../components/route-focus-modal"
|
|
26
|
+
import {
|
|
27
|
+
BulkProduct,
|
|
28
|
+
useBulkProductColumns,
|
|
29
|
+
} from "./use-bulk-product-columns"
|
|
30
|
+
|
|
31
|
+
const PAGE_SIZE = 20
|
|
32
|
+
const PRODUCT_FIELDS = "id,title,status,thumbnail,handle,created_at,updated_at"
|
|
33
|
+
// Safety cap for "select all matching" — a runaway catalog shouldn't queue an
|
|
34
|
+
// unbounded batch. If the match set exceeds this, we sync the first N and warn.
|
|
35
|
+
const SELECT_ALL_CAP = 2000
|
|
36
|
+
|
|
37
|
+
type ProductListResult = { products: BulkProduct[]; count: number }
|
|
38
|
+
|
|
39
|
+
// Reuse the Medusa admin SDK's product list instead of a bespoke fetch wrapper.
|
|
40
|
+
const listProducts = (query: Record<string, string | number | undefined>) =>
|
|
41
|
+
sdk.admin.product.list({
|
|
42
|
+
fields: PRODUCT_FIELDS,
|
|
43
|
+
...query,
|
|
44
|
+
}) as unknown as Promise<ProductListResult>
|
|
45
|
+
|
|
46
|
+
const FaireBulkPage = () => {
|
|
47
|
+
const queryClient = useQueryClient()
|
|
48
|
+
|
|
49
|
+
const [pagination, setPagination] = useState<DataTablePaginationState>({
|
|
50
|
+
pageIndex: 0,
|
|
51
|
+
pageSize: PAGE_SIZE,
|
|
52
|
+
})
|
|
53
|
+
const [filtering, setFiltering] = useState<DataTableFilteringState>({})
|
|
54
|
+
const [search, setSearch] = useState("")
|
|
55
|
+
const [rowSelection, setRowSelection] = useState<DataTableRowSelectionState>({})
|
|
56
|
+
// When true, the action targets every product matching the current search /
|
|
57
|
+
// filter across all pages — not just the rows selected on screen.
|
|
58
|
+
const [selectAllMatching, setSelectAllMatching] = useState(false)
|
|
59
|
+
|
|
60
|
+
const statusFilter = (filtering.status as any)?.[0] as string | undefined
|
|
61
|
+
|
|
62
|
+
const statusQuery = useQuery({
|
|
63
|
+
queryKey: ["faire", "status"],
|
|
64
|
+
queryFn: () => faireApi.status() as Promise<any>,
|
|
65
|
+
})
|
|
66
|
+
const connected = !!statusQuery.data?.connected
|
|
67
|
+
|
|
68
|
+
const productsQuery = useQuery({
|
|
69
|
+
queryKey: ["faire", "bulk-products", pagination, statusFilter, search],
|
|
70
|
+
placeholderData: keepPreviousData,
|
|
71
|
+
queryFn: () =>
|
|
72
|
+
listProducts({
|
|
73
|
+
limit: pagination.pageSize,
|
|
74
|
+
offset: pagination.pageIndex * pagination.pageSize,
|
|
75
|
+
q: search || undefined,
|
|
76
|
+
status: statusFilter,
|
|
77
|
+
order: "-created_at",
|
|
78
|
+
}),
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const products = productsQuery.data?.products ?? []
|
|
82
|
+
const count = productsQuery.data?.count ?? 0
|
|
83
|
+
|
|
84
|
+
const selectedIds = useMemo(
|
|
85
|
+
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
|
|
86
|
+
[rowSelection]
|
|
87
|
+
)
|
|
88
|
+
const targetCount = selectAllMatching ? count : selectedIds.length
|
|
89
|
+
|
|
90
|
+
// Resolve the id set to sync: either the explicit selection, or every product
|
|
91
|
+
// matching the current query (paged out, capped).
|
|
92
|
+
const resolveTargetIds = async (): Promise<string[]> => {
|
|
93
|
+
if (!selectAllMatching) return selectedIds
|
|
94
|
+
const ids: string[] = []
|
|
95
|
+
let offset = 0
|
|
96
|
+
while (ids.length < count && ids.length < SELECT_ALL_CAP) {
|
|
97
|
+
const res = await listProducts({
|
|
98
|
+
limit: 200,
|
|
99
|
+
offset,
|
|
100
|
+
q: search || undefined,
|
|
101
|
+
status: statusFilter,
|
|
102
|
+
order: "-created_at",
|
|
103
|
+
})
|
|
104
|
+
const batch = res.products ?? []
|
|
105
|
+
if (!batch.length) break
|
|
106
|
+
ids.push(...batch.map((p) => p.id))
|
|
107
|
+
offset += batch.length
|
|
108
|
+
}
|
|
109
|
+
return ids.slice(0, SELECT_ALL_CAP)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const pushMutation = useMutation({
|
|
113
|
+
mutationFn: async () => {
|
|
114
|
+
const ids = await resolveTargetIds()
|
|
115
|
+
if (!ids.length) throw new Error("No products to sync")
|
|
116
|
+
const res = await faireApi.syncBulk(ids)
|
|
117
|
+
return { res, queued: ids.length }
|
|
118
|
+
},
|
|
119
|
+
onSuccess: ({ res, queued }: any) => {
|
|
120
|
+
toast.success("Bulk product sync started", {
|
|
121
|
+
description:
|
|
122
|
+
`Queued ${queued} product(s). Batch ${res.batch_id}.` +
|
|
123
|
+
(selectAllMatching && count > SELECT_ALL_CAP
|
|
124
|
+
? ` Capped at ${SELECT_ALL_CAP} — run again for the rest.`
|
|
125
|
+
: ""),
|
|
126
|
+
})
|
|
127
|
+
queryClient.invalidateQueries({ queryKey: ["faire", "syncs"] })
|
|
128
|
+
setRowSelection({})
|
|
129
|
+
setSelectAllMatching(false)
|
|
130
|
+
},
|
|
131
|
+
onError: (err: any) =>
|
|
132
|
+
toast.error("Failed to start bulk sync", { description: err.message }),
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// ── Pull Faire orders into Medusa (the other bulk operation) ───────────────
|
|
136
|
+
const [pullBatch, setPullBatch] = useState<string | null>(null)
|
|
137
|
+
const pullMutation = useMutation({
|
|
138
|
+
mutationFn: () => faireApi.ingestOrders(),
|
|
139
|
+
onSuccess: (res: any) => {
|
|
140
|
+
setPullBatch(res.batch_id)
|
|
141
|
+
toast.success("Faire order pull started", {
|
|
142
|
+
description: "Running in the background. Polling progress…",
|
|
143
|
+
})
|
|
144
|
+
},
|
|
145
|
+
onError: (err: any) =>
|
|
146
|
+
toast.error("Failed to start order pull", { description: err.message }),
|
|
147
|
+
})
|
|
148
|
+
const pullStatus = useQuery({
|
|
149
|
+
queryKey: ["faire", "ingest", pullBatch],
|
|
150
|
+
enabled: !!pullBatch,
|
|
151
|
+
refetchInterval: (q) =>
|
|
152
|
+
(q.state.data as any)?.progress?.finished ? false : 3000,
|
|
153
|
+
queryFn: () => faireApi.ingestStatus(pullBatch!),
|
|
154
|
+
})
|
|
155
|
+
const pullProgress = (pullStatus.data as any)?.progress
|
|
156
|
+
|
|
157
|
+
const columns = useBulkProductColumns()
|
|
158
|
+
|
|
159
|
+
const commands = useMemo(
|
|
160
|
+
() => [
|
|
161
|
+
{
|
|
162
|
+
label: connected ? "Push to Faire" : "Connect Faire first",
|
|
163
|
+
shortcut: "p",
|
|
164
|
+
action: () => {
|
|
165
|
+
if (!connected) {
|
|
166
|
+
toast.error("Faire is not connected")
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
pushMutation.mutate()
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
label: "Clear selection",
|
|
174
|
+
shortcut: "c",
|
|
175
|
+
action: () => {
|
|
176
|
+
setRowSelection({})
|
|
177
|
+
setSelectAllMatching(false)
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
[connected, pushMutation]
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
const table = useDataTable({
|
|
185
|
+
data: products,
|
|
186
|
+
columns,
|
|
187
|
+
rowCount: count,
|
|
188
|
+
getRowId: (row) => row.id,
|
|
189
|
+
isLoading: productsQuery.isLoading,
|
|
190
|
+
commands,
|
|
191
|
+
rowSelection: {
|
|
192
|
+
state: rowSelection,
|
|
193
|
+
onRowSelectionChange: (updater) => {
|
|
194
|
+
setSelectAllMatching(false)
|
|
195
|
+
setRowSelection(updater)
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
pagination: { state: pagination, onPaginationChange: setPagination },
|
|
199
|
+
filtering: { state: filtering, onFilteringChange: setFiltering },
|
|
200
|
+
search: { state: search, onSearchChange: setSearch },
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<RouteFocusModal prev="/settings/faire">
|
|
205
|
+
<RouteFocusModal.Header>
|
|
206
|
+
<div className="flex flex-col">
|
|
207
|
+
<Heading>Bulk sync products to Faire</Heading>
|
|
208
|
+
<Text className="text-ui-fg-subtle" size="small">
|
|
209
|
+
Select products (or all matching) and push them to Faire as a
|
|
210
|
+
background sync via the command bar.
|
|
211
|
+
</Text>
|
|
212
|
+
</div>
|
|
213
|
+
</RouteFocusModal.Header>
|
|
214
|
+
<RouteFocusModal.Body className="flex flex-1 flex-col overflow-hidden p-0">
|
|
215
|
+
<DataTable instance={table}>
|
|
216
|
+
<DataTable.Toolbar className="flex flex-col gap-y-3 border-b px-6 py-4">
|
|
217
|
+
<div className="flex flex-col gap-y-2 md:flex-row md:items-center md:justify-between">
|
|
218
|
+
<div className="flex items-center gap-x-2">
|
|
219
|
+
<Input
|
|
220
|
+
type="search"
|
|
221
|
+
placeholder="Search products…"
|
|
222
|
+
value={search}
|
|
223
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
224
|
+
className="w-full md:w-72"
|
|
225
|
+
/>
|
|
226
|
+
{!connected && (
|
|
227
|
+
<Badge color="orange" size="2xsmall">
|
|
228
|
+
Not connected
|
|
229
|
+
</Badge>
|
|
230
|
+
)}
|
|
231
|
+
</div>
|
|
232
|
+
<div className="flex items-center gap-x-2">
|
|
233
|
+
<Button
|
|
234
|
+
size="small"
|
|
235
|
+
variant="secondary"
|
|
236
|
+
onClick={() => productsQuery.refetch()}
|
|
237
|
+
isLoading={productsQuery.isFetching}
|
|
238
|
+
>
|
|
239
|
+
Refresh
|
|
240
|
+
</Button>
|
|
241
|
+
<Button
|
|
242
|
+
size="small"
|
|
243
|
+
variant={selectAllMatching ? "primary" : "secondary"}
|
|
244
|
+
disabled={!count}
|
|
245
|
+
onClick={() => {
|
|
246
|
+
setRowSelection({})
|
|
247
|
+
setSelectAllMatching((v) => !v)
|
|
248
|
+
}}
|
|
249
|
+
>
|
|
250
|
+
{selectAllMatching
|
|
251
|
+
? `All ${count} selected`
|
|
252
|
+
: `Select all ${count}`}
|
|
253
|
+
</Button>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
{targetCount > 0 && (
|
|
257
|
+
<Text size="small" className="text-ui-fg-subtle">
|
|
258
|
+
{selectAllMatching
|
|
259
|
+
? `All ${count} matching product(s) will be synced`
|
|
260
|
+
: `${targetCount} product(s) selected`}
|
|
261
|
+
{pushMutation.isPending ? " — queuing…" : ""}
|
|
262
|
+
</Text>
|
|
263
|
+
)}
|
|
264
|
+
</DataTable.Toolbar>
|
|
265
|
+
{productsQuery.isLoading ? (
|
|
266
|
+
<div className="flex flex-col gap-3 p-6">
|
|
267
|
+
{Array.from({ length: 8 }).map((_, i) => (
|
|
268
|
+
<Skeleton key={i} className="h-10 w-full" />
|
|
269
|
+
))}
|
|
270
|
+
</div>
|
|
271
|
+
) : (
|
|
272
|
+
<DataTable.Table />
|
|
273
|
+
)}
|
|
274
|
+
<DataTable.Pagination />
|
|
275
|
+
</DataTable>
|
|
276
|
+
</RouteFocusModal.Body>
|
|
277
|
+
<RouteFocusModal.Footer>
|
|
278
|
+
<div className="flex w-full items-center justify-between gap-x-3">
|
|
279
|
+
<div className="flex items-center gap-x-3">
|
|
280
|
+
<Text size="small" className="text-ui-fg-subtle">
|
|
281
|
+
Pull Faire orders into Medusa (idempotent)
|
|
282
|
+
</Text>
|
|
283
|
+
{pullBatch && pullProgress && (
|
|
284
|
+
<StatusBadge
|
|
285
|
+
color={
|
|
286
|
+
pullProgress.finished
|
|
287
|
+
? (pullStatus.data as any)?.batch?.status === "failed"
|
|
288
|
+
? "red"
|
|
289
|
+
: "green"
|
|
290
|
+
: "orange"
|
|
291
|
+
}
|
|
292
|
+
>
|
|
293
|
+
Pull: {pullProgress.done}/{pullProgress.total || "?"} (
|
|
294
|
+
{pullProgress.pct}%)
|
|
295
|
+
</StatusBadge>
|
|
296
|
+
)}
|
|
297
|
+
</div>
|
|
298
|
+
<Button
|
|
299
|
+
size="small"
|
|
300
|
+
variant="secondary"
|
|
301
|
+
onClick={() => pullMutation.mutate()}
|
|
302
|
+
disabled={!connected || pullMutation.isPending}
|
|
303
|
+
isLoading={pullMutation.isPending}
|
|
304
|
+
>
|
|
305
|
+
Pull orders
|
|
306
|
+
</Button>
|
|
307
|
+
</div>
|
|
308
|
+
</RouteFocusModal.Footer>
|
|
309
|
+
<Toaster />
|
|
310
|
+
</RouteFocusModal>
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export const handle = {
|
|
315
|
+
breadcrumb: () => "Bulk sync",
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export default FaireBulkPage
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createDataTableColumnHelper, StatusBadge } from "@medusajs/ui"
|
|
2
|
+
|
|
3
|
+
export type BulkProduct = {
|
|
4
|
+
id: string
|
|
5
|
+
title: string
|
|
6
|
+
status: string
|
|
7
|
+
thumbnail: string | null
|
|
8
|
+
handle: string | null
|
|
9
|
+
created_at: string
|
|
10
|
+
updated_at: string | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const columnHelper = createDataTableColumnHelper<BulkProduct>()
|
|
14
|
+
|
|
15
|
+
export const useBulkProductColumns = () => {
|
|
16
|
+
return [
|
|
17
|
+
columnHelper.accessor("title", {
|
|
18
|
+
header: "Product",
|
|
19
|
+
cell: ({ row, getValue }) => {
|
|
20
|
+
const p = row.original
|
|
21
|
+
return (
|
|
22
|
+
<div className="flex items-center gap-3">
|
|
23
|
+
{p.thumbnail ? (
|
|
24
|
+
<img
|
|
25
|
+
src={p.thumbnail}
|
|
26
|
+
alt=""
|
|
27
|
+
className="h-8 w-8 rounded border border-ui-border-base object-cover"
|
|
28
|
+
/>
|
|
29
|
+
) : (
|
|
30
|
+
<div className="h-8 w-8 rounded border border-ui-border-base bg-ui-bg-subtle" />
|
|
31
|
+
)}
|
|
32
|
+
<div className="flex flex-col">
|
|
33
|
+
<span className="font-medium">{getValue()}</span>
|
|
34
|
+
{p.handle && (
|
|
35
|
+
<span className="text-ui-fg-subtle text-xs font-mono">
|
|
36
|
+
{p.handle}
|
|
37
|
+
</span>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
},
|
|
43
|
+
}),
|
|
44
|
+
columnHelper.accessor("status", {
|
|
45
|
+
header: "Status",
|
|
46
|
+
cell: (info) => {
|
|
47
|
+
const s = info.getValue()
|
|
48
|
+
const color = s === "published" ? "green" : s === "draft" ? "orange" : "grey"
|
|
49
|
+
return <StatusBadge color={color as any}>{s}</StatusBadge>
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
columnHelper.accessor("updated_at", {
|
|
53
|
+
header: "Updated",
|
|
54
|
+
cell: (info) => {
|
|
55
|
+
const v = info.getValue()
|
|
56
|
+
return v ? new Date(v).toLocaleString() : "—"
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
]
|
|
60
|
+
}
|
|
@@ -187,6 +187,13 @@ const FaireSettingsPage = () => {
|
|
|
187
187
|
{connected ? (
|
|
188
188
|
<div className="flex items-center gap-3">
|
|
189
189
|
<StatusBadge color="green">Connected</StatusBadge>
|
|
190
|
+
<Button
|
|
191
|
+
size="small"
|
|
192
|
+
variant="secondary"
|
|
193
|
+
onClick={() => navigate("/settings/faire/bulk")}
|
|
194
|
+
>
|
|
195
|
+
Bulk sync
|
|
196
|
+
</Button>
|
|
190
197
|
<Button
|
|
191
198
|
size="small"
|
|
192
199
|
variant="secondary"
|
|
@@ -230,9 +237,6 @@ const FaireSettingsPage = () => {
|
|
|
230
237
|
)}
|
|
231
238
|
</Container>
|
|
232
239
|
|
|
233
|
-
{/* Bulk operations */}
|
|
234
|
-
<BulkOperations connected={connected} />
|
|
235
|
-
|
|
236
240
|
{/* Recent syncs */}
|
|
237
241
|
<Container className="divide-y p-0">
|
|
238
242
|
<DataTable instance={table}>
|
|
@@ -272,167 +276,6 @@ const InfoField = ({ label, value }: { label: string; value: string }) => (
|
|
|
272
276
|
</div>
|
|
273
277
|
)
|
|
274
278
|
|
|
275
|
-
/**
|
|
276
|
-
* Bulk operations card: push many products to Faire, and pull Faire orders into
|
|
277
|
-
* Medusa. Both run as long-running background workflows; progress is polled.
|
|
278
|
-
*/
|
|
279
|
-
const BulkOperations = ({ connected }: { connected: boolean }) => {
|
|
280
|
-
const [productIds, setProductIds] = useState("")
|
|
281
|
-
const [pushBatch, setPushBatch] = useState<string | null>(null)
|
|
282
|
-
const [pullBatch, setPullBatch] = useState<string | null>(null)
|
|
283
|
-
|
|
284
|
-
const pushMutation = useMutation({
|
|
285
|
-
mutationFn: (ids: string[]) => faireApi.syncBulk(ids),
|
|
286
|
-
onSuccess: (res: any) => {
|
|
287
|
-
setPushBatch(res.batch_id)
|
|
288
|
-
toast.success("Bulk product sync started", {
|
|
289
|
-
description: "Running in the background. Polling progress…",
|
|
290
|
-
})
|
|
291
|
-
},
|
|
292
|
-
onError: (err: any) =>
|
|
293
|
-
toast.error("Failed to start bulk sync", { description: err.message }),
|
|
294
|
-
})
|
|
295
|
-
|
|
296
|
-
const pullMutation = useMutation({
|
|
297
|
-
mutationFn: () => faireApi.ingestOrders(),
|
|
298
|
-
onSuccess: (res: any) => {
|
|
299
|
-
setPullBatch(res.batch_id)
|
|
300
|
-
toast.success("Faire order pull started", {
|
|
301
|
-
description: "Running in the background. Polling progress…",
|
|
302
|
-
})
|
|
303
|
-
},
|
|
304
|
-
onError: (err: any) =>
|
|
305
|
-
toast.error("Failed to start order pull", { description: err.message }),
|
|
306
|
-
})
|
|
307
|
-
|
|
308
|
-
// Poll both batches until finished.
|
|
309
|
-
const pushStatus = useQuery({
|
|
310
|
-
queryKey: ["faire", "bulk", pushBatch],
|
|
311
|
-
enabled: !!pushBatch,
|
|
312
|
-
refetchInterval: (q) =>
|
|
313
|
-
(q.state.data as any)?.progress?.finished ? false : 3000,
|
|
314
|
-
queryFn: () => faireApi.bulkStatus(pushBatch!),
|
|
315
|
-
})
|
|
316
|
-
const pullStatus = useQuery({
|
|
317
|
-
queryKey: ["faire", "ingest", pullBatch],
|
|
318
|
-
enabled: !!pullBatch,
|
|
319
|
-
refetchInterval: (q) =>
|
|
320
|
-
(q.state.data as any)?.progress?.finished ? false : 3000,
|
|
321
|
-
queryFn: () => faireApi.ingestStatus(pullBatch!),
|
|
322
|
-
})
|
|
323
|
-
|
|
324
|
-
const pushProgress = (pushStatus.data as any)?.progress
|
|
325
|
-
const pullProgress = (pullStatus.data as any)?.progress
|
|
326
|
-
|
|
327
|
-
const handlePush = () => {
|
|
328
|
-
const ids = productIds
|
|
329
|
-
.split(/[\s,]+/)
|
|
330
|
-
.map((s) => s.trim())
|
|
331
|
-
.filter(Boolean)
|
|
332
|
-
if (!ids.length) {
|
|
333
|
-
toast.error("Enter at least one product id")
|
|
334
|
-
return
|
|
335
|
-
}
|
|
336
|
-
pushMutation.mutate(ids)
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
return (
|
|
340
|
-
<Container className="divide-y p-0">
|
|
341
|
-
<div className="px-6 py-4">
|
|
342
|
-
<Heading>Bulk operations</Heading>
|
|
343
|
-
<Text className="text-ui-fg-subtle" size="small">
|
|
344
|
-
Push many products to Faire, or pull wholesale orders from Faire into
|
|
345
|
-
Medusa. Both run as long-running background workflows.
|
|
346
|
-
</Text>
|
|
347
|
-
</div>
|
|
348
|
-
<div className="px-6 py-4 flex flex-col gap-6">
|
|
349
|
-
{/* Push products */}
|
|
350
|
-
<div className="flex flex-col gap-3">
|
|
351
|
-
<div className="flex flex-col gap-1">
|
|
352
|
-
<Label>Push products to Faire</Label>
|
|
353
|
-
<Text className="text-ui-fg-subtle" size="small">
|
|
354
|
-
Comma- or line-separated Medusa product IDs.
|
|
355
|
-
</Text>
|
|
356
|
-
</div>
|
|
357
|
-
<textarea
|
|
358
|
-
className="border-ui-border-base rounded-lg border px-3 py-2 text-sm min-h-[72px]"
|
|
359
|
-
placeholder="prod_01..., prod_02..."
|
|
360
|
-
value={productIds}
|
|
361
|
-
onChange={(e) => setProductIds(e.target.value)}
|
|
362
|
-
disabled={!connected || pushMutation.isPending}
|
|
363
|
-
/>
|
|
364
|
-
<div className="flex items-center gap-3">
|
|
365
|
-
<Button
|
|
366
|
-
size="small"
|
|
367
|
-
onClick={handlePush}
|
|
368
|
-
disabled={!connected || pushMutation.isPending}
|
|
369
|
-
isLoading={pushMutation.isPending}
|
|
370
|
-
>
|
|
371
|
-
Start bulk push
|
|
372
|
-
</Button>
|
|
373
|
-
{pushBatch && pushProgress && (
|
|
374
|
-
<BulkProgress
|
|
375
|
-
label="Push"
|
|
376
|
-
progress={pushProgress}
|
|
377
|
-
status={(pushStatus.data as any)?.batch?.status}
|
|
378
|
-
/>
|
|
379
|
-
)}
|
|
380
|
-
</div>
|
|
381
|
-
</div>
|
|
382
|
-
|
|
383
|
-
{/* Pull orders */}
|
|
384
|
-
<div className="flex flex-col gap-3">
|
|
385
|
-
<div className="flex flex-col gap-1">
|
|
386
|
-
<Label>Pull Faire orders into Medusa</Label>
|
|
387
|
-
<Text className="text-ui-fg-subtle" size="small">
|
|
388
|
-
Ingests all available Faire orders as Medusa orders (idempotent).
|
|
389
|
-
</Text>
|
|
390
|
-
</div>
|
|
391
|
-
<div className="flex items-center gap-3">
|
|
392
|
-
<Button
|
|
393
|
-
size="small"
|
|
394
|
-
variant="secondary"
|
|
395
|
-
onClick={() => pullMutation.mutate()}
|
|
396
|
-
disabled={!connected || pullMutation.isPending}
|
|
397
|
-
isLoading={pullMutation.isPending}
|
|
398
|
-
>
|
|
399
|
-
Start order pull
|
|
400
|
-
</Button>
|
|
401
|
-
{pullBatch && pullProgress && (
|
|
402
|
-
<BulkProgress
|
|
403
|
-
label="Pull"
|
|
404
|
-
progress={pullProgress}
|
|
405
|
-
status={(pullStatus.data as any)?.batch?.status}
|
|
406
|
-
/>
|
|
407
|
-
)}
|
|
408
|
-
</div>
|
|
409
|
-
</div>
|
|
410
|
-
</div>
|
|
411
|
-
</Container>
|
|
412
|
-
)
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
const BulkProgress = ({
|
|
416
|
-
label,
|
|
417
|
-
progress,
|
|
418
|
-
status,
|
|
419
|
-
}: {
|
|
420
|
-
label: string
|
|
421
|
-
progress: { total: number; done: number; pct: number; finished: boolean }
|
|
422
|
-
status?: string
|
|
423
|
-
}) => (
|
|
424
|
-
<div className="flex items-center gap-3">
|
|
425
|
-
<StatusBadge color={progress.finished ? (status === "failed" ? "red" : "green") : "orange"}>
|
|
426
|
-
{label}: {progress.done}/{progress.total || "?"} ({progress.pct}%)
|
|
427
|
-
</StatusBadge>
|
|
428
|
-
{progress.finished && (
|
|
429
|
-
<Text className="text-ui-fg-subtle" size="small">
|
|
430
|
-
{status === "failed" ? "Completed with errors" : "Done"}
|
|
431
|
-
</Text>
|
|
432
|
-
)}
|
|
433
|
-
</div>
|
|
434
|
-
)
|
|
435
|
-
|
|
436
279
|
export const config = defineRouteConfig({
|
|
437
280
|
label: "Faire",
|
|
438
281
|
icon: BuildingStorefront,
|
|
@@ -1,44 +1,87 @@
|
|
|
1
1
|
import { Container, Heading, Text, Alert, Button } from "@medusajs/ui"
|
|
2
|
-
import { useEffect,
|
|
2
|
+
import { useEffect, useState } from "react"
|
|
3
3
|
import { useNavigate, useSearchParams } from "react-router-dom"
|
|
4
4
|
import { faireApi } from "../../../../../lib/api"
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Faire redirects here with `?code=...&state=...`. Faire OAuth codes are
|
|
8
|
+
* single-use: exchanging the same code twice yields `invalid_grant`.
|
|
9
|
+
*
|
|
10
|
+
* A `useRef` guard is not sufficient — React StrictMode (dev) simulates an
|
|
11
|
+
* unmount/remount which resets the ref, and any real remount (e.g. a parent
|
|
12
|
+
* re-render, route transition, or HMR) would too. We therefore persist the
|
|
13
|
+
* "already exchanged" flag in `sessionStorage` keyed by the code itself, which
|
|
14
|
+
* survives remounts. We also short-circuit if Faire is already connected.
|
|
15
|
+
*/
|
|
16
|
+
const EXCHANGED_PREFIX = "faire:oauth:exchanged:"
|
|
17
|
+
|
|
6
18
|
const FaireOauthCallback = () => {
|
|
7
19
|
const [params] = useSearchParams()
|
|
8
20
|
const navigate = useNavigate()
|
|
9
21
|
const [error, setError] = useState<string | null>(null)
|
|
10
|
-
|
|
11
|
-
// and any re-render would re-run this — exchanging the same code twice yields
|
|
12
|
-
// an invalid_grant error. Guard so it runs once.
|
|
13
|
-
const exchangedRef = useRef(false)
|
|
22
|
+
const [exchanging, setExchanging] = useState(true)
|
|
14
23
|
|
|
15
24
|
useEffect(() => {
|
|
16
|
-
if (exchangedRef.current) return
|
|
17
|
-
exchangedRef.current = true
|
|
18
|
-
|
|
19
|
-
const code = params.get("code")
|
|
20
|
-
const state = params.get("state")
|
|
21
25
|
const errParam = params.get("error")
|
|
22
|
-
|
|
23
26
|
if (errParam) {
|
|
24
27
|
setError(
|
|
25
28
|
`Faire authorization failed: ${params.get("error_description") || errParam}`
|
|
26
29
|
)
|
|
30
|
+
setExchanging(false)
|
|
27
31
|
return
|
|
28
32
|
}
|
|
33
|
+
|
|
34
|
+
// Faire returns the authorization code as `authorization_code`
|
|
35
|
+
// (some flows use `code`). Accept either.
|
|
36
|
+
const code = params.get("authorization_code") || params.get("code")
|
|
37
|
+
const state = params.get("state")
|
|
29
38
|
if (!code || !state) {
|
|
30
39
|
setError("Missing code or state in Faire callback.")
|
|
40
|
+
setExchanging(false)
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Already exchanged this exact code in this browser session — don't retry.
|
|
45
|
+
// This guard is checked AND set synchronously, before any async work, so
|
|
46
|
+
// that React StrictMode's double effect invocation (or any concurrent
|
|
47
|
+
// remount) cannot race past it and exchange the single-use code twice.
|
|
48
|
+
if (sessionStorage.getItem(EXCHANGED_PREFIX + code)) {
|
|
49
|
+
navigate("/settings/faire", { replace: true })
|
|
31
50
|
return
|
|
32
51
|
}
|
|
52
|
+
// Claim the code synchronously — any subsequent effect run will see this.
|
|
53
|
+
sessionStorage.setItem(EXCHANGED_PREFIX + code, "1")
|
|
33
54
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
55
|
+
let cancelled = false
|
|
56
|
+
|
|
57
|
+
const run = async () => {
|
|
58
|
+
try {
|
|
59
|
+
await faireApi.callback(code, state)
|
|
60
|
+
if (!cancelled) navigate("/settings/faire", { replace: true })
|
|
61
|
+
} catch (err: any) {
|
|
62
|
+
if (!cancelled) {
|
|
63
|
+
// "code already used" can happen if a prior exchange succeeded but
|
|
64
|
+
// the response was lost. If we are in fact connected, treat as success.
|
|
65
|
+
try {
|
|
66
|
+
const status: any = await faireApi.status()
|
|
67
|
+
if (status?.connected) {
|
|
68
|
+
navigate("/settings/faire", { replace: true })
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
/* ignore */
|
|
73
|
+
}
|
|
74
|
+
setError(err.message || "Failed to complete Faire authorization.")
|
|
75
|
+
setExchanging(false)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
run()
|
|
81
|
+
|
|
82
|
+
return () => {
|
|
83
|
+
cancelled = true
|
|
84
|
+
}
|
|
42
85
|
}, [params, navigate])
|
|
43
86
|
|
|
44
87
|
return (
|
|
@@ -49,7 +92,7 @@ const FaireOauthCallback = () => {
|
|
|
49
92
|
<Text>{error}</Text>
|
|
50
93
|
</Alert>
|
|
51
94
|
) : (
|
|
52
|
-
<Text>Completing authorization, please wait
|
|
95
|
+
<Text>{exchanging ? "Completing authorization, please wait…" : "Redirecting…"}</Text>
|
|
53
96
|
)}
|
|
54
97
|
{error && (
|
|
55
98
|
<Button
|