@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.
@@ -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, useRef, useState } from "react"
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
- // OAuth codes are single-use. React StrictMode (dev) double-invokes effects,
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
- faireApi
35
- .callback(code, state)
36
- .then(() => {
37
- navigate("/settings/faire", { replace: true })
38
- })
39
- .catch((err: any) => {
40
- setError(err.message || "Failed to complete Faire authorization.")
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…</Text>
95
+ <Text>{exchanging ? "Completing authorization, please wait…" : "Redirecting…"}</Text>
53
96
  )}
54
97
  {error && (
55
98
  <Button