@jytextiles/medusa-plugin-faire-store-sync 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.medusa/server/src/admin/index.js +318 -31
- package/.medusa/server/src/admin/index.mjs +320 -33
- package/package.json +1 -1
- package/src/admin/components/route-focus-modal.tsx +51 -0
- package/src/admin/routes/settings/faire/bulk/page.tsx +263 -0
- package/src/admin/routes/settings/faire/bulk/use-bulk-product-columns.tsx +60 -0
- package/src/admin/routes/settings/faire/page.tsx +9 -1
- package/src/admin/routes/settings/oauth/faire/callback/page.tsx +63 -20
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Badge,
|
|
3
|
+
Button,
|
|
4
|
+
DataTable,
|
|
5
|
+
DataTableFilteringState,
|
|
6
|
+
DataTablePaginationState,
|
|
7
|
+
DataTableRowSelectionState,
|
|
8
|
+
Heading,
|
|
9
|
+
Input,
|
|
10
|
+
Skeleton,
|
|
11
|
+
Text,
|
|
12
|
+
Toaster,
|
|
13
|
+
toast,
|
|
14
|
+
useDataTable,
|
|
15
|
+
} from "@medusajs/ui"
|
|
16
|
+
import {
|
|
17
|
+
keepPreviousData,
|
|
18
|
+
useMutation,
|
|
19
|
+
useQuery,
|
|
20
|
+
useQueryClient,
|
|
21
|
+
} from "@tanstack/react-query"
|
|
22
|
+
import { useMemo, useState } from "react"
|
|
23
|
+
import { faireApi, sdk } from "../../../../lib/api"
|
|
24
|
+
import { RouteFocusModal } from "../../../../components/route-focus-modal"
|
|
25
|
+
import {
|
|
26
|
+
BulkProduct,
|
|
27
|
+
useBulkProductColumns,
|
|
28
|
+
} from "./use-bulk-product-columns"
|
|
29
|
+
|
|
30
|
+
const PAGE_SIZE = 20
|
|
31
|
+
const PRODUCT_FIELDS = "id,title,status,thumbnail,handle,created_at,updated_at"
|
|
32
|
+
// Safety cap for "select all matching" — a runaway catalog shouldn't queue an
|
|
33
|
+
// unbounded batch. If the match set exceeds this, we sync the first N and warn.
|
|
34
|
+
const SELECT_ALL_CAP = 2000
|
|
35
|
+
|
|
36
|
+
type ProductListResult = { products: BulkProduct[]; count: number }
|
|
37
|
+
|
|
38
|
+
// Reuse the Medusa admin SDK's product list instead of a bespoke fetch wrapper.
|
|
39
|
+
const listProducts = (query: Record<string, string | number | undefined>) =>
|
|
40
|
+
sdk.admin.product.list({
|
|
41
|
+
fields: PRODUCT_FIELDS,
|
|
42
|
+
...query,
|
|
43
|
+
}) as unknown as Promise<ProductListResult>
|
|
44
|
+
|
|
45
|
+
const FaireBulkPage = () => {
|
|
46
|
+
const queryClient = useQueryClient()
|
|
47
|
+
|
|
48
|
+
const [pagination, setPagination] = useState<DataTablePaginationState>({
|
|
49
|
+
pageIndex: 0,
|
|
50
|
+
pageSize: PAGE_SIZE,
|
|
51
|
+
})
|
|
52
|
+
const [filtering, setFiltering] = useState<DataTableFilteringState>({})
|
|
53
|
+
const [search, setSearch] = useState("")
|
|
54
|
+
const [rowSelection, setRowSelection] = useState<DataTableRowSelectionState>({})
|
|
55
|
+
// When true, the action targets every product matching the current search /
|
|
56
|
+
// filter across all pages — not just the rows selected on screen.
|
|
57
|
+
const [selectAllMatching, setSelectAllMatching] = useState(false)
|
|
58
|
+
|
|
59
|
+
const statusFilter = (filtering.status as any)?.[0] as string | undefined
|
|
60
|
+
|
|
61
|
+
const statusQuery = useQuery({
|
|
62
|
+
queryKey: ["faire", "status"],
|
|
63
|
+
queryFn: () => faireApi.status() as Promise<any>,
|
|
64
|
+
})
|
|
65
|
+
const connected = !!statusQuery.data?.connected
|
|
66
|
+
|
|
67
|
+
const productsQuery = useQuery({
|
|
68
|
+
queryKey: ["faire", "bulk-products", pagination, statusFilter, search],
|
|
69
|
+
placeholderData: keepPreviousData,
|
|
70
|
+
queryFn: () =>
|
|
71
|
+
listProducts({
|
|
72
|
+
limit: pagination.pageSize,
|
|
73
|
+
offset: pagination.pageIndex * pagination.pageSize,
|
|
74
|
+
q: search || undefined,
|
|
75
|
+
status: statusFilter,
|
|
76
|
+
order: "-created_at",
|
|
77
|
+
}),
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const products = productsQuery.data?.products ?? []
|
|
81
|
+
const count = productsQuery.data?.count ?? 0
|
|
82
|
+
|
|
83
|
+
const selectedIds = useMemo(
|
|
84
|
+
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
|
|
85
|
+
[rowSelection]
|
|
86
|
+
)
|
|
87
|
+
const targetCount = selectAllMatching ? count : selectedIds.length
|
|
88
|
+
|
|
89
|
+
// Resolve the id set to sync: either the explicit selection, or every product
|
|
90
|
+
// matching the current query (paged out, capped).
|
|
91
|
+
const resolveTargetIds = async (): Promise<string[]> => {
|
|
92
|
+
if (!selectAllMatching) return selectedIds
|
|
93
|
+
const ids: string[] = []
|
|
94
|
+
let offset = 0
|
|
95
|
+
while (ids.length < count && ids.length < SELECT_ALL_CAP) {
|
|
96
|
+
const res = await listProducts({
|
|
97
|
+
limit: 200,
|
|
98
|
+
offset,
|
|
99
|
+
q: search || undefined,
|
|
100
|
+
status: statusFilter,
|
|
101
|
+
order: "-created_at",
|
|
102
|
+
})
|
|
103
|
+
const batch = res.products ?? []
|
|
104
|
+
if (!batch.length) break
|
|
105
|
+
ids.push(...batch.map((p) => p.id))
|
|
106
|
+
offset += batch.length
|
|
107
|
+
}
|
|
108
|
+
return ids.slice(0, SELECT_ALL_CAP)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const pushMutation = useMutation({
|
|
112
|
+
mutationFn: async () => {
|
|
113
|
+
const ids = await resolveTargetIds()
|
|
114
|
+
if (!ids.length) throw new Error("No products to sync")
|
|
115
|
+
const res = await faireApi.syncBulk(ids)
|
|
116
|
+
return { res, queued: ids.length }
|
|
117
|
+
},
|
|
118
|
+
onSuccess: ({ res, queued }: any) => {
|
|
119
|
+
toast.success("Bulk product sync started", {
|
|
120
|
+
description:
|
|
121
|
+
`Queued ${queued} product(s). Batch ${res.batch_id}.` +
|
|
122
|
+
(selectAllMatching && count > SELECT_ALL_CAP
|
|
123
|
+
? ` Capped at ${SELECT_ALL_CAP} — run again for the rest.`
|
|
124
|
+
: ""),
|
|
125
|
+
})
|
|
126
|
+
queryClient.invalidateQueries({ queryKey: ["faire", "syncs"] })
|
|
127
|
+
setRowSelection({})
|
|
128
|
+
setSelectAllMatching(false)
|
|
129
|
+
},
|
|
130
|
+
onError: (err: any) =>
|
|
131
|
+
toast.error("Failed to start bulk sync", { description: err.message }),
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const columns = useBulkProductColumns()
|
|
135
|
+
|
|
136
|
+
const commands = useMemo(
|
|
137
|
+
() => [
|
|
138
|
+
{
|
|
139
|
+
label: connected ? "Push to Faire" : "Connect Faire first",
|
|
140
|
+
shortcut: "p",
|
|
141
|
+
action: () => {
|
|
142
|
+
if (!connected) {
|
|
143
|
+
toast.error("Faire is not connected")
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
pushMutation.mutate()
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
label: "Clear selection",
|
|
151
|
+
shortcut: "c",
|
|
152
|
+
action: () => {
|
|
153
|
+
setRowSelection({})
|
|
154
|
+
setSelectAllMatching(false)
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
[connected, pushMutation]
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
const table = useDataTable({
|
|
162
|
+
data: products,
|
|
163
|
+
columns,
|
|
164
|
+
rowCount: count,
|
|
165
|
+
getRowId: (row) => row.id,
|
|
166
|
+
isLoading: productsQuery.isLoading,
|
|
167
|
+
commands,
|
|
168
|
+
rowSelection: {
|
|
169
|
+
state: rowSelection,
|
|
170
|
+
onRowSelectionChange: (updater) => {
|
|
171
|
+
setSelectAllMatching(false)
|
|
172
|
+
setRowSelection(updater)
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
pagination: { state: pagination, onPaginationChange: setPagination },
|
|
176
|
+
filtering: { state: filtering, onFilteringChange: setFiltering },
|
|
177
|
+
search: { state: search, onSearchChange: setSearch },
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<RouteFocusModal prev="/settings/faire">
|
|
182
|
+
<RouteFocusModal.Header>
|
|
183
|
+
<div className="flex flex-col">
|
|
184
|
+
<Heading>Bulk sync products to Faire</Heading>
|
|
185
|
+
<Text className="text-ui-fg-subtle" size="small">
|
|
186
|
+
Select products (or all matching) and push them to Faire as a
|
|
187
|
+
background sync via the command bar.
|
|
188
|
+
</Text>
|
|
189
|
+
</div>
|
|
190
|
+
</RouteFocusModal.Header>
|
|
191
|
+
<RouteFocusModal.Body className="flex flex-1 flex-col overflow-hidden p-0">
|
|
192
|
+
<DataTable instance={table}>
|
|
193
|
+
<DataTable.Toolbar className="flex flex-col gap-y-3 border-b px-6 py-4">
|
|
194
|
+
<div className="flex flex-col gap-y-2 md:flex-row md:items-center md:justify-between">
|
|
195
|
+
<div className="flex items-center gap-x-2">
|
|
196
|
+
<Input
|
|
197
|
+
type="search"
|
|
198
|
+
placeholder="Search products…"
|
|
199
|
+
value={search}
|
|
200
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
201
|
+
className="w-full md:w-72"
|
|
202
|
+
/>
|
|
203
|
+
{!connected && (
|
|
204
|
+
<Badge color="orange" size="2xsmall">
|
|
205
|
+
Not connected
|
|
206
|
+
</Badge>
|
|
207
|
+
)}
|
|
208
|
+
</div>
|
|
209
|
+
<div className="flex items-center gap-x-2">
|
|
210
|
+
<Button
|
|
211
|
+
size="small"
|
|
212
|
+
variant="secondary"
|
|
213
|
+
onClick={() => productsQuery.refetch()}
|
|
214
|
+
isLoading={productsQuery.isFetching}
|
|
215
|
+
>
|
|
216
|
+
Refresh
|
|
217
|
+
</Button>
|
|
218
|
+
<Button
|
|
219
|
+
size="small"
|
|
220
|
+
variant={selectAllMatching ? "primary" : "secondary"}
|
|
221
|
+
disabled={!count}
|
|
222
|
+
onClick={() => {
|
|
223
|
+
setRowSelection({})
|
|
224
|
+
setSelectAllMatching((v) => !v)
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
{selectAllMatching
|
|
228
|
+
? `All ${count} selected`
|
|
229
|
+
: `Select all ${count}`}
|
|
230
|
+
</Button>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
{targetCount > 0 && (
|
|
234
|
+
<Text size="small" className="text-ui-fg-subtle">
|
|
235
|
+
{selectAllMatching
|
|
236
|
+
? `All ${count} matching product(s) will be synced`
|
|
237
|
+
: `${targetCount} product(s) selected`}
|
|
238
|
+
{pushMutation.isPending ? " — queuing…" : ""}
|
|
239
|
+
</Text>
|
|
240
|
+
)}
|
|
241
|
+
</DataTable.Toolbar>
|
|
242
|
+
{productsQuery.isLoading ? (
|
|
243
|
+
<div className="flex flex-col gap-3 p-6">
|
|
244
|
+
{Array.from({ length: 8 }).map((_, i) => (
|
|
245
|
+
<Skeleton key={i} className="h-10 w-full" />
|
|
246
|
+
))}
|
|
247
|
+
</div>
|
|
248
|
+
) : (
|
|
249
|
+
<DataTable.Table />
|
|
250
|
+
)}
|
|
251
|
+
<DataTable.Pagination />
|
|
252
|
+
</DataTable>
|
|
253
|
+
</RouteFocusModal.Body>
|
|
254
|
+
<Toaster />
|
|
255
|
+
</RouteFocusModal>
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export const handle = {
|
|
260
|
+
breadcrumb: () => "Bulk sync",
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
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
|
+
}
|
|
@@ -361,7 +361,15 @@ const BulkOperations = ({ connected }: { connected: boolean }) => {
|
|
|
361
361
|
onChange={(e) => setProductIds(e.target.value)}
|
|
362
362
|
disabled={!connected || pushMutation.isPending}
|
|
363
363
|
/>
|
|
364
|
-
<div className="flex items-center gap-3">
|
|
364
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
365
|
+
<Button
|
|
366
|
+
size="small"
|
|
367
|
+
variant="secondary"
|
|
368
|
+
disabled={!connected}
|
|
369
|
+
onClick={() => navigate("/settings/faire/bulk")}
|
|
370
|
+
>
|
|
371
|
+
Select products
|
|
372
|
+
</Button>
|
|
365
373
|
<Button
|
|
366
374
|
size="small"
|
|
367
375
|
onClick={handlePush}
|
|
@@ -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
|