@jytextiles/medusa-plugin-faire-store-sync 0.1.0

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.
Files changed (104) hide show
  1. package/.medusa/server/src/__tests__/ingest-faire-order-support.spec.js +74 -0
  2. package/.medusa/server/src/__tests__/webhook.spec.js +77 -0
  3. package/.medusa/server/src/admin/index.js +960 -0
  4. package/.medusa/server/src/admin/index.mjs +959 -0
  5. package/.medusa/server/src/api/admin/faire/auth/authorize/route.js +12 -0
  6. package/.medusa/server/src/api/admin/faire/auth/callback/route.js +25 -0
  7. package/.medusa/server/src/api/admin/faire/auth/disconnect/route.js +12 -0
  8. package/.medusa/server/src/api/admin/faire/brand/route.js +14 -0
  9. package/.medusa/server/src/api/admin/faire/ingest/orders/[batchId]/route.js +27 -0
  10. package/.medusa/server/src/api/admin/faire/ingest/orders/route.js +20 -0
  11. package/.medusa/server/src/api/admin/faire/products/route.js +16 -0
  12. package/.medusa/server/src/api/admin/faire/settings/route.js +19 -0
  13. package/.medusa/server/src/api/admin/faire/status/product/[id]/route.js +30 -0
  14. package/.medusa/server/src/api/admin/faire/status/route.js +46 -0
  15. package/.medusa/server/src/api/admin/faire/sync/bulk/[batchId]/route.js +29 -0
  16. package/.medusa/server/src/api/admin/faire/sync/bulk/route.js +20 -0
  17. package/.medusa/server/src/api/admin/faire/sync/product/[id]/route.js +14 -0
  18. package/.medusa/server/src/api/admin/faire/syncs/[id]/route.js +32 -0
  19. package/.medusa/server/src/api/admin/faire/syncs/route.js +18 -0
  20. package/.medusa/server/src/api/webhooks/faire/route.js +125 -0
  21. package/.medusa/server/src/jobs/pull-faire-orders.js +32 -0
  22. package/.medusa/server/src/jobs/refresh-faire-token.js +25 -0
  23. package/.medusa/server/src/lib/faire-client.js +321 -0
  24. package/.medusa/server/src/lib/types.js +10 -0
  25. package/.medusa/server/src/lib/webhook.js +58 -0
  26. package/.medusa/server/src/links/product-faire-sync-link.js +57 -0
  27. package/.medusa/server/src/modules/faire-sync/index.js +15 -0
  28. package/.medusa/server/src/modules/faire-sync/migrations/Migration20260707211346.js +34 -0
  29. package/.medusa/server/src/modules/faire-sync/models/faire-order.js +21 -0
  30. package/.medusa/server/src/modules/faire-sync/models/faire-sync-account.js +17 -0
  31. package/.medusa/server/src/modules/faire-sync/models/faire-sync-batch.js +18 -0
  32. package/.medusa/server/src/modules/faire-sync/models/faire-sync-record.js +22 -0
  33. package/.medusa/server/src/modules/faire-sync/models/faire-sync-settings.js +26 -0
  34. package/.medusa/server/src/modules/faire-sync/models/faire-webhook-event.js +21 -0
  35. package/.medusa/server/src/modules/faire-sync/service.js +222 -0
  36. package/.medusa/server/src/subscribers/faire-inventory-sync.js +87 -0
  37. package/.medusa/server/src/subscribers/faire-listing-events.js +46 -0
  38. package/.medusa/server/src/subscribers/faire-order-ingest.js +54 -0
  39. package/.medusa/server/src/subscribers/faire-order-lifecycle.js +71 -0
  40. package/.medusa/server/src/subscribers/faire-product-status-sync.js +71 -0
  41. package/.medusa/server/src/workflows/index.js +21 -0
  42. package/.medusa/server/src/workflows/ingest-faire-order-support.js +76 -0
  43. package/.medusa/server/src/workflows/ingest-faire-order.js +148 -0
  44. package/.medusa/server/src/workflows/ingest-faire-orders-bulk.js +124 -0
  45. package/.medusa/server/src/workflows/refresh-faire-token.js +20 -0
  46. package/.medusa/server/src/workflows/sync-product-to-faire.js +262 -0
  47. package/.medusa/server/src/workflows/sync-products-to-faire.js +90 -0
  48. package/LICENSE +21 -0
  49. package/README.md +180 -0
  50. package/package.json +84 -0
  51. package/scripts/postbuild.js +36 -0
  52. package/src/__tests__/ingest-faire-order-support.spec.ts +77 -0
  53. package/src/__tests__/webhook.spec.ts +81 -0
  54. package/src/admin/lib/api.ts +88 -0
  55. package/src/admin/routes/settings/faire/[id]/page.tsx +184 -0
  56. package/src/admin/routes/settings/faire/hooks/use-faire-sync-columns.tsx +80 -0
  57. package/src/admin/routes/settings/faire/page.tsx +445 -0
  58. package/src/admin/routes/settings/faire/settings/page.tsx +286 -0
  59. package/src/admin/routes/settings/oauth/faire/callback/page.tsx +67 -0
  60. package/src/admin/widgets/faire-product-widget.tsx +180 -0
  61. package/src/api/admin/faire/auth/authorize/route.ts +10 -0
  62. package/src/api/admin/faire/auth/callback/route.ts +29 -0
  63. package/src/api/admin/faire/auth/disconnect/route.ts +10 -0
  64. package/src/api/admin/faire/brand/route.ts +12 -0
  65. package/src/api/admin/faire/ingest/orders/[batchId]/route.ts +31 -0
  66. package/src/api/admin/faire/ingest/orders/route.ts +29 -0
  67. package/src/api/admin/faire/products/route.ts +14 -0
  68. package/src/api/admin/faire/settings/route.ts +17 -0
  69. package/src/api/admin/faire/status/product/[id]/route.ts +30 -0
  70. package/src/api/admin/faire/status/route.ts +47 -0
  71. package/src/api/admin/faire/sync/bulk/[batchId]/route.ts +33 -0
  72. package/src/api/admin/faire/sync/bulk/route.ts +27 -0
  73. package/src/api/admin/faire/sync/product/[id]/route.ts +13 -0
  74. package/src/api/admin/faire/syncs/[id]/route.ts +32 -0
  75. package/src/api/admin/faire/syncs/route.ts +17 -0
  76. package/src/api/webhooks/faire/route.ts +133 -0
  77. package/src/jobs/pull-faire-orders.ts +30 -0
  78. package/src/jobs/refresh-faire-token.ts +23 -0
  79. package/src/lib/faire-client.ts +436 -0
  80. package/src/lib/types.ts +117 -0
  81. package/src/lib/webhook.ts +85 -0
  82. package/src/links/product-faire-sync-link.ts +56 -0
  83. package/src/modules/faire-sync/index.ts +11 -0
  84. package/src/modules/faire-sync/migrations/.snapshot-medusa-faire-sync.json +1486 -0
  85. package/src/modules/faire-sync/migrations/Migration20260707211346.ts +43 -0
  86. package/src/modules/faire-sync/models/faire-order.ts +20 -0
  87. package/src/modules/faire-sync/models/faire-sync-account.ts +16 -0
  88. package/src/modules/faire-sync/models/faire-sync-batch.ts +17 -0
  89. package/src/modules/faire-sync/models/faire-sync-record.ts +21 -0
  90. package/src/modules/faire-sync/models/faire-sync-settings.ts +25 -0
  91. package/src/modules/faire-sync/models/faire-webhook-event.ts +20 -0
  92. package/src/modules/faire-sync/service.ts +252 -0
  93. package/src/subscribers/faire-inventory-sync.ts +97 -0
  94. package/src/subscribers/faire-listing-events.ts +48 -0
  95. package/src/subscribers/faire-order-ingest.ts +61 -0
  96. package/src/subscribers/faire-order-lifecycle.ts +81 -0
  97. package/src/subscribers/faire-product-status-sync.ts +76 -0
  98. package/src/workflows/index.ts +4 -0
  99. package/src/workflows/ingest-faire-order-support.ts +96 -0
  100. package/src/workflows/ingest-faire-order.ts +212 -0
  101. package/src/workflows/ingest-faire-orders-bulk.ts +168 -0
  102. package/src/workflows/refresh-faire-token.ts +28 -0
  103. package/src/workflows/sync-product-to-faire.ts +375 -0
  104. package/src/workflows/sync-products-to-faire.ts +133 -0
@@ -0,0 +1,445 @@
1
+ import { defineRouteConfig } from "@medusajs/admin-sdk"
2
+ import { BuildingStorefront } from "@medusajs/icons"
3
+ import {
4
+ Alert,
5
+ Button,
6
+ Container,
7
+ DataTable,
8
+ DataTableFilteringState,
9
+ DataTablePaginationState,
10
+ Heading,
11
+ Label,
12
+ Skeleton,
13
+ StatusBadge,
14
+ Text,
15
+ Toaster,
16
+ toast,
17
+ createDataTableFilterHelper,
18
+ useDataTable,
19
+ } from "@medusajs/ui"
20
+ import {
21
+ keepPreviousData,
22
+ useMutation,
23
+ useQuery,
24
+ useQueryClient,
25
+ } from "@tanstack/react-query"
26
+ import { useState } from "react"
27
+ import { useNavigate } from "react-router-dom"
28
+ import { faireApi } from "../../../lib/api"
29
+ import { useFaireSyncColumns, FaireSyncRecord } from "./hooks/use-faire-sync-columns"
30
+
31
+ const PAGE_SIZE = 10
32
+ const STATUS_KEY = ["faire", "status"]
33
+
34
+ type Status = {
35
+ connected: boolean
36
+ account: any | null
37
+ settings: any
38
+ readiness: {
39
+ connected: boolean
40
+ brand: boolean
41
+ wholesale_pricing: boolean
42
+ shipping_policy: boolean
43
+ ready_to_publish: boolean
44
+ }
45
+ }
46
+
47
+ const FaireSettingsPage = () => {
48
+ const navigate = useNavigate()
49
+ const queryClient = useQueryClient()
50
+
51
+ const [pagination, setPagination] = useState<DataTablePaginationState>({
52
+ pageIndex: 0,
53
+ pageSize: PAGE_SIZE,
54
+ })
55
+ const [filtering, setFiltering] = useState<DataTableFilteringState>({})
56
+
57
+ // ── Queries ───────────────────────────────────────────────────────────────
58
+ const statusQuery = useQuery({
59
+ queryKey: STATUS_KEY,
60
+ queryFn: () => faireApi.status() as Promise<Status>,
61
+ })
62
+ const status = statusQuery.data
63
+ const connected = !!status?.connected
64
+
65
+ const syncsQuery = useQuery({
66
+ queryKey: ["faire", "syncs", pagination, filtering],
67
+ placeholderData: keepPreviousData,
68
+ queryFn: async () => {
69
+ const statusFilter = (filtering.status as any)?.[0] as string | undefined
70
+ const res = await faireApi.listSyncs({
71
+ take: pagination.pageSize,
72
+ skip: pagination.pageIndex * pagination.pageSize,
73
+ status: statusFilter,
74
+ })
75
+ return { syncs: (res as any).syncs || [], count: (res as any).count || 0 }
76
+ },
77
+ })
78
+
79
+ // ── Mutations ───────────────────────────────────────────────────────────────
80
+ const connectMutation = useMutation({
81
+ mutationFn: () => faireApi.authorize(),
82
+ onSuccess: (res: any) => {
83
+ window.open(res.authorization_url, "_blank")
84
+ toast.info("Faire authorization opened", {
85
+ description: "Complete the authorization in the new tab.",
86
+ })
87
+ },
88
+ onError: (err: any) =>
89
+ toast.error("Failed to start Faire authorization", {
90
+ description: err.message,
91
+ }),
92
+ })
93
+
94
+ const disconnectMutation = useMutation({
95
+ mutationFn: () => faireApi.disconnect(),
96
+ onSuccess: () => {
97
+ queryClient.invalidateQueries({ queryKey: STATUS_KEY })
98
+ toast.success("Disconnected from Faire")
99
+ },
100
+ onError: (err: any) =>
101
+ toast.error("Failed to disconnect", { description: err.message }),
102
+ })
103
+
104
+ // ── DataTable ───────────────────────────────────────────────────────────────
105
+ const columns = useFaireSyncColumns()
106
+ const filterHelper = createDataTableFilterHelper<FaireSyncRecord>()
107
+ const filters = [
108
+ filterHelper.accessor("status", {
109
+ type: "select",
110
+ label: "Status",
111
+ options: [
112
+ { label: "Success", value: "success" },
113
+ { label: "Draft", value: "draft" },
114
+ { label: "Failed", value: "failed" },
115
+ { label: "Pending", value: "pending" },
116
+ { label: "Syncing", value: "syncing" },
117
+ ],
118
+ }),
119
+ ]
120
+
121
+ const table = useDataTable({
122
+ data: syncsQuery.data?.syncs ?? [],
123
+ columns,
124
+ rowCount: syncsQuery.data?.count ?? 0,
125
+ getRowId: (row: FaireSyncRecord) => row.id,
126
+ onRowClick: (_: any, row: FaireSyncRecord) => {
127
+ navigate(`/settings/faire/${row.id}`)
128
+ },
129
+ isLoading: syncsQuery.isLoading,
130
+ filters,
131
+ pagination: {
132
+ state: pagination,
133
+ onPaginationChange: setPagination,
134
+ },
135
+ filtering: {
136
+ state: filtering,
137
+ onFilteringChange: setFiltering,
138
+ },
139
+ })
140
+
141
+ if (statusQuery.isLoading) {
142
+ return (
143
+ <div className="flex flex-col gap-4">
144
+ <Container className="divide-y p-0">
145
+ <div className="flex items-center justify-between px-6 py-4">
146
+ <Skeleton className="h-6 w-32" />
147
+ <Skeleton className="h-7 w-28" />
148
+ </div>
149
+ <div className="grid grid-cols-4 gap-4 px-6 py-4">
150
+ {Array.from({ length: 4 }).map((_, i) => (
151
+ <Skeleton key={i} className="h-10 w-full" />
152
+ ))}
153
+ </div>
154
+ </Container>
155
+ <Container className="p-0">
156
+ <div className="px-6 py-4">
157
+ <Skeleton className="h-5 w-32" />
158
+ </div>
159
+ <div className="flex flex-col gap-3 px-6 pb-6">
160
+ {Array.from({ length: 5 }).map((_, i) => (
161
+ <Skeleton key={i} className="h-10 w-full" />
162
+ ))}
163
+ </div>
164
+ </Container>
165
+ </div>
166
+ )
167
+ }
168
+
169
+ return (
170
+ <div className="flex flex-col gap-4">
171
+ {statusQuery.isError && (
172
+ <Alert variant="error">
173
+ {(statusQuery.error as any)?.message || "Failed to load Faire status"}
174
+ </Alert>
175
+ )}
176
+
177
+ {/* Connection */}
178
+ <Container className="divide-y p-0">
179
+ <div className="flex items-center justify-between px-6 py-4">
180
+ <div className="flex flex-col gap-1">
181
+ <Heading level="h1">Faire Sync</Heading>
182
+ <Text className="text-ui-fg-subtle">
183
+ Connect your Faire brand and sync Medusa products, inventory and
184
+ orders bidirectionally.
185
+ </Text>
186
+ </div>
187
+ {connected ? (
188
+ <div className="flex items-center gap-3">
189
+ <StatusBadge color="green">Connected</StatusBadge>
190
+ <Button
191
+ size="small"
192
+ variant="secondary"
193
+ onClick={() => navigate("/settings/faire/settings")}
194
+ >
195
+ Sync settings
196
+ </Button>
197
+ <Button
198
+ size="small"
199
+ variant="danger"
200
+ onClick={() => disconnectMutation.mutate()}
201
+ isLoading={disconnectMutation.isPending}
202
+ >
203
+ Disconnect
204
+ </Button>
205
+ </div>
206
+ ) : (
207
+ <Button
208
+ size="small"
209
+ onClick={() => connectMutation.mutate()}
210
+ isLoading={connectMutation.isPending}
211
+ >
212
+ Connect Faire
213
+ </Button>
214
+ )}
215
+ </div>
216
+ {connected && status?.account && (
217
+ <div className="px-6 py-4 grid grid-cols-4 gap-4">
218
+ <InfoField label="Brand" value={status.account.brand_name} />
219
+ <InfoField label="Brand ID" value={status.account.brand_id} />
220
+ <InfoField label="Currency" value={status.account.currency || "—"} />
221
+ <InfoField
222
+ label="Token expires"
223
+ value={
224
+ status.account.token_expires_at
225
+ ? new Date(status.account.token_expires_at).toLocaleString()
226
+ : "Does not expire"
227
+ }
228
+ />
229
+ </div>
230
+ )}
231
+ </Container>
232
+
233
+ {/* Bulk operations */}
234
+ <BulkOperations connected={connected} />
235
+
236
+ {/* Recent syncs */}
237
+ <Container className="divide-y p-0">
238
+ <DataTable instance={table}>
239
+ <DataTable.Toolbar className="flex flex-col md:flex-row justify-between gap-y-4 px-6 py-4">
240
+ <div>
241
+ <Heading>Recent syncs</Heading>
242
+ <Text className="text-ui-fg-subtle" size="small">
243
+ Latest product sync attempts. Click a row for details.
244
+ </Text>
245
+ </div>
246
+ <div className="flex items-center gap-x-2">
247
+ <DataTable.FilterMenu tooltip="Filter syncs" />
248
+ <Button
249
+ size="small"
250
+ variant="secondary"
251
+ onClick={() => syncsQuery.refetch()}
252
+ isLoading={syncsQuery.isFetching}
253
+ >
254
+ Refresh
255
+ </Button>
256
+ </div>
257
+ </DataTable.Toolbar>
258
+ <DataTable.Table />
259
+ <DataTable.Pagination />
260
+ </DataTable>
261
+ </Container>
262
+
263
+ <Toaster />
264
+ </div>
265
+ )
266
+ }
267
+
268
+ const InfoField = ({ label, value }: { label: string; value: string }) => (
269
+ <div className="flex flex-col gap-1">
270
+ <Label>{label}</Label>
271
+ <Text>{value}</Text>
272
+ </div>
273
+ )
274
+
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
+ export const config = defineRouteConfig({
437
+ label: "Faire",
438
+ icon: BuildingStorefront,
439
+ })
440
+
441
+ export const handle = {
442
+ breadcrumb: () => "Faire Sync",
443
+ }
444
+
445
+ export default FaireSettingsPage