@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.
@@ -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, 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