@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,286 @@
1
+ import {
2
+ Button,
3
+ Drawer,
4
+ Heading,
5
+ Label,
6
+ Skeleton,
7
+ StatusBadge,
8
+ Switch,
9
+ Text,
10
+ Toaster,
11
+ Tooltip,
12
+ clx,
13
+ toast,
14
+ } from "@medusajs/ui"
15
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
16
+ import { useEffect, useState } from "react"
17
+ import { useNavigate } from "react-router-dom"
18
+ import { faireApi } from "../../../../lib/api"
19
+
20
+ const STATUS_KEY = ["faire", "status"]
21
+ const PARENT = "/settings/faire"
22
+
23
+ // Radix Select can't use an empty-string item value, so "Not set" uses this
24
+ // sentinel. It must never be persisted — map it back to null on save.
25
+ const NONE = "__none__"
26
+
27
+ const cleanSettings = (form: any) => {
28
+ const out: any = { ...form }
29
+ for (const key of [
30
+ "default_brand_id",
31
+ "default_shipping_policy_id",
32
+ "default_category",
33
+ ]) {
34
+ if (!out[key] || out[key] === NONE) out[key] = null
35
+ }
36
+ if (
37
+ out.default_wholesale_markup_percent === "" ||
38
+ out.default_wholesale_markup_percent === NONE
39
+ ) {
40
+ out.default_wholesale_markup_percent = null
41
+ }
42
+ if (out.default_lead_time_days === "" || out.default_lead_time_days === NONE) {
43
+ out.default_lead_time_days = null
44
+ }
45
+ return out
46
+ }
47
+
48
+ type Status = {
49
+ connected: boolean
50
+ account: any | null
51
+ settings: any
52
+ readiness: {
53
+ connected: boolean
54
+ brand: boolean
55
+ wholesale_pricing: boolean
56
+ shipping_policy: boolean
57
+ ready_to_publish: boolean
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Routed Sync-settings drawer. Lives at /settings/faire/settings so it's its
63
+ * own route (linkable, back-button friendly) rather than an inline <Drawer>.
64
+ */
65
+ const FaireSyncSettingsDrawer = () => {
66
+ const navigate = useNavigate()
67
+ const queryClient = useQueryClient()
68
+ const [open, setOpen] = useState(true)
69
+ const [form, setForm] = useState<any>({})
70
+
71
+ const close = () => {
72
+ setOpen(false)
73
+ navigate(PARENT)
74
+ }
75
+
76
+ const statusQuery = useQuery({
77
+ queryKey: STATUS_KEY,
78
+ queryFn: () => faireApi.status() as Promise<Status>,
79
+ })
80
+ const status = statusQuery.data
81
+ const connected = !!status?.connected
82
+
83
+ useEffect(() => {
84
+ if (status?.settings) setForm(status.settings)
85
+ }, [status?.settings])
86
+
87
+ const saveMutation = useMutation({
88
+ mutationFn: (payload: any) => faireApi.saveSettings(payload),
89
+ onSuccess: () => {
90
+ queryClient.invalidateQueries({ queryKey: STATUS_KEY })
91
+ toast.success("Sync settings saved")
92
+ close()
93
+ },
94
+ onError: (err: any) =>
95
+ toast.error("Failed to save settings", { description: err.message }),
96
+ })
97
+
98
+ return (
99
+ <Drawer open={open} onOpenChange={(o) => (o ? setOpen(true) : close())}>
100
+ <Drawer.Content>
101
+ <Drawer.Header>
102
+ <Drawer.Title>Sync settings</Drawer.Title>
103
+ <Drawer.Description>
104
+ Publish readiness and the defaults applied to every product pushed
105
+ to Faire.
106
+ </Drawer.Description>
107
+ </Drawer.Header>
108
+ <Drawer.Body className="flex flex-col gap-6 overflow-y-auto">
109
+ {statusQuery.isLoading ? (
110
+ <div className="flex flex-col gap-3">
111
+ {Array.from({ length: 6 }).map((_, i) => (
112
+ <Skeleton key={i} className="h-10 w-full" />
113
+ ))}
114
+ </div>
115
+ ) : (
116
+ <>
117
+ {/* Readiness checklist */}
118
+ <div className="flex flex-col gap-3">
119
+ <div className="flex flex-col gap-1">
120
+ <Heading level="h2">Publish readiness</Heading>
121
+ <Text className="text-ui-fg-subtle" size="small">
122
+ Faire needs a brand and a wholesale-pricing strategy to
123
+ publish products. Missing fields sync products as drafts.
124
+ </Text>
125
+ </div>
126
+ <div className="flex flex-col gap-2">
127
+ <ChecklistItem ok={status?.readiness.connected} label="Faire connected" />
128
+ <ChecklistItem ok={status?.readiness.brand} label="Brand configured" />
129
+ <ChecklistItem ok={status?.readiness.wholesale_pricing} label="Wholesale pricing" />
130
+ <ChecklistItem ok={status?.readiness.shipping_policy} label="Shipping policy" />
131
+ </div>
132
+ </div>
133
+
134
+ {/* Defaults */}
135
+ <div className="flex flex-col gap-3">
136
+ <div className="flex flex-col gap-1">
137
+ <Heading level="h2">Sync defaults</Heading>
138
+ <Text className="text-ui-fg-subtle" size="small">
139
+ Applied to every product unless overridden in product
140
+ metadata.
141
+ </Text>
142
+ </div>
143
+ <div className="grid grid-cols-1 gap-4">
144
+ <Field label="Brand ID">
145
+ <input
146
+ className="border-ui-border-base rounded-lg border px-3 py-2 text-sm"
147
+ value={String(form.default_brand_id ?? "")}
148
+ onChange={(e) =>
149
+ setForm({ ...form, default_brand_id: e.target.value || null })
150
+ }
151
+ disabled={!connected}
152
+ placeholder="b_..."
153
+ />
154
+ </Field>
155
+ <Field label="Wholesale markup % (off retail)">
156
+ <Tooltip content="Wholesale price = retail × (100 − markup%) / 100. E.g. 50 → half of retail.">
157
+ <input
158
+ type="number"
159
+ className="border-ui-border-base rounded-lg border px-3 py-2 text-sm"
160
+ value={String(form.default_wholesale_markup_percent ?? "")}
161
+ onChange={(e) =>
162
+ setForm({
163
+ ...form,
164
+ default_wholesale_markup_percent: e.target.value
165
+ ? Number(e.target.value)
166
+ : null,
167
+ })
168
+ }
169
+ disabled={!connected}
170
+ placeholder="e.g. 50"
171
+ />
172
+ </Tooltip>
173
+ </Field>
174
+ <Field label="Default min order quantity">
175
+ <input
176
+ type="number"
177
+ className="border-ui-border-base rounded-lg border px-3 py-2 text-sm"
178
+ value={String(form.default_min_order_quantity ?? 1)}
179
+ onChange={(e) =>
180
+ setForm({
181
+ ...form,
182
+ default_min_order_quantity: e.target.value
183
+ ? Number(e.target.value)
184
+ : 1,
185
+ })
186
+ }
187
+ disabled={!connected}
188
+ />
189
+ </Field>
190
+ <Field label="Default lead time (days)">
191
+ <input
192
+ type="number"
193
+ className="border-ui-border-base rounded-lg border px-3 py-2 text-sm"
194
+ value={String(form.default_lead_time_days ?? "")}
195
+ onChange={(e) =>
196
+ setForm({
197
+ ...form,
198
+ default_lead_time_days: e.target.value
199
+ ? Number(e.target.value)
200
+ : null,
201
+ })
202
+ }
203
+ disabled={!connected}
204
+ placeholder="e.g. 14"
205
+ />
206
+ </Field>
207
+ <Field label="Default category">
208
+ <input
209
+ className="border-ui-border-base rounded-lg border px-3 py-2 text-sm"
210
+ value={String(form.default_category ?? "")}
211
+ onChange={(e) =>
212
+ setForm({ ...form, default_category: e.target.value || null })
213
+ }
214
+ disabled={!connected}
215
+ placeholder="e.g. Home Decor"
216
+ />
217
+ </Field>
218
+ <Field label="Follow product status">
219
+ <Tooltip content="If on, published Medusa products are published on Faire; draft products sync as drafts.">
220
+ <Switch
221
+ checked={form.follow_product_status !== false}
222
+ onCheckedChange={(v: boolean) =>
223
+ setForm({ ...form, follow_product_status: v })
224
+ }
225
+ />
226
+ </Tooltip>
227
+ </Field>
228
+ <Field label="Auto publish">
229
+ <Tooltip content="Publish products to Faire automatically on sync (otherwise create as draft).">
230
+ <Switch
231
+ checked={!!form.auto_publish}
232
+ onCheckedChange={(v: boolean) =>
233
+ setForm({ ...form, auto_publish: v })
234
+ }
235
+ />
236
+ </Tooltip>
237
+ </Field>
238
+ </div>
239
+ </div>
240
+ </>
241
+ )}
242
+ </Drawer.Body>
243
+ <Drawer.Footer>
244
+ <Button variant="secondary" onClick={close}>
245
+ Cancel
246
+ </Button>
247
+ <Button
248
+ onClick={() => saveMutation.mutate(cleanSettings(form))}
249
+ isLoading={saveMutation.isPending}
250
+ disabled={!connected}
251
+ >
252
+ Save settings
253
+ </Button>
254
+ </Drawer.Footer>
255
+ </Drawer.Content>
256
+ <Toaster />
257
+ </Drawer>
258
+ )
259
+ }
260
+
261
+ const Field = ({ label, children }: { label: string; children: React.ReactNode }) => (
262
+ <div className="flex flex-col gap-1.5">
263
+ <Label>{label}</Label>
264
+ {children}
265
+ </div>
266
+ )
267
+
268
+ const ChecklistItem = ({ ok, label }: { ok?: boolean; label: string }) => (
269
+ <div
270
+ className={clx(
271
+ "flex items-center gap-2 rounded-lg border px-3 py-2",
272
+ ok
273
+ ? "border-ui-tag-green-border bg-ui-tag-green-bg"
274
+ : "border-ui-tag-red-border bg-ui-tag-red-bg"
275
+ )}
276
+ >
277
+ <StatusBadge color={ok ? "green" : "red"}>{ok ? "Ready" : "Missing"}</StatusBadge>
278
+ <Text>{label}</Text>
279
+ </div>
280
+ )
281
+
282
+ export const handle = {
283
+ breadcrumb: () => "Sync settings",
284
+ }
285
+
286
+ export default FaireSyncSettingsDrawer
@@ -0,0 +1,67 @@
1
+ import { Container, Heading, Text, Alert, Button } from "@medusajs/ui"
2
+ import { useEffect, useRef, useState } from "react"
3
+ import { useNavigate, useSearchParams } from "react-router-dom"
4
+ import { faireApi } from "../../../../../lib/api"
5
+
6
+ const FaireOauthCallback = () => {
7
+ const [params] = useSearchParams()
8
+ const navigate = useNavigate()
9
+ 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)
14
+
15
+ useEffect(() => {
16
+ if (exchangedRef.current) return
17
+ exchangedRef.current = true
18
+
19
+ const code = params.get("code")
20
+ const state = params.get("state")
21
+ const errParam = params.get("error")
22
+
23
+ if (errParam) {
24
+ setError(
25
+ `Faire authorization failed: ${params.get("error_description") || errParam}`
26
+ )
27
+ return
28
+ }
29
+ if (!code || !state) {
30
+ setError("Missing code or state in Faire callback.")
31
+ return
32
+ }
33
+
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
+ })
42
+ }, [params, navigate])
43
+
44
+ return (
45
+ <Container className="flex flex-col items-center gap-4 p-8">
46
+ <Heading level="h1">Connecting Faire…</Heading>
47
+ {error ? (
48
+ <Alert variant="error">
49
+ <Text>{error}</Text>
50
+ </Alert>
51
+ ) : (
52
+ <Text>Completing authorization, please wait…</Text>
53
+ )}
54
+ {error && (
55
+ <Button
56
+ size="small"
57
+ variant="secondary"
58
+ onClick={() => navigate("/settings/faire", { replace: true })}
59
+ >
60
+ Back to Faire settings
61
+ </Button>
62
+ )}
63
+ </Container>
64
+ )
65
+ }
66
+
67
+ export default FaireOauthCallback
@@ -0,0 +1,180 @@
1
+ import { defineWidgetConfig } from "@medusajs/admin-sdk"
2
+ import {
3
+ Button,
4
+ Container,
5
+ Heading,
6
+ Text,
7
+ Alert,
8
+ StatusBadge,
9
+ Tooltip,
10
+ } from "@medusajs/ui"
11
+ import { DetailWidgetProps, AdminProduct } from "@medusajs/framework/types"
12
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
13
+ import { faireApi } from "../lib/api"
14
+
15
+ const badgeColor = (status?: string): "green" | "red" | "orange" | "grey" => {
16
+ switch (status) {
17
+ case "synced":
18
+ case "success":
19
+ return "green"
20
+ case "failed":
21
+ return "red"
22
+ case "draft":
23
+ case "pending":
24
+ return "orange"
25
+ default:
26
+ return "grey"
27
+ }
28
+ }
29
+
30
+ const FaireProductWidget = ({ data }: DetailWidgetProps<AdminProduct>) => {
31
+ const queryClient = useQueryClient()
32
+ const productKey = ["faire", "product-status", data.id]
33
+
34
+ const statusQuery = useQuery({
35
+ queryKey: ["faire", "status"],
36
+ queryFn: () => faireApi.status() as Promise<any>,
37
+ })
38
+ const connected = !!statusQuery.data?.connected
39
+ const readiness = statusQuery.data?.readiness
40
+
41
+ const productStatusQuery = useQuery({
42
+ queryKey: productKey,
43
+ queryFn: () => faireApi.productStatus(data.id),
44
+ refetchInterval: (query) => {
45
+ const s = (query.state.data as any)?.latest?.status
46
+ return s === "draft" || s === "pending" ? 10_000 : false
47
+ },
48
+ })
49
+ const latest = productStatusQuery.data?.latest
50
+
51
+ const syncMutation = useMutation({
52
+ mutationFn: () => faireApi.syncProduct(data.id),
53
+ onSuccess: () => {
54
+ queryClient.invalidateQueries({ queryKey: productKey })
55
+ },
56
+ })
57
+ const result: any = syncMutation.data ? (syncMutation.data as any).result : null
58
+ const error = syncMutation.error
59
+ ? (syncMutation.error as any).message || "Sync failed"
60
+ : null
61
+
62
+ const view = result
63
+ ? {
64
+ published: result.published,
65
+ state: result.state,
66
+ product_url: result.product_url,
67
+ product_token: result.product_token,
68
+ warnings: result.warnings as string[] | undefined,
69
+ status: result.published ? "success" : result.state,
70
+ }
71
+ : latest
72
+ ? {
73
+ published: latest.published,
74
+ state: latest.product_state,
75
+ product_url: latest.product_url,
76
+ product_token: latest.product_token,
77
+ warnings: latest.error_message
78
+ ? String(latest.error_message).split(" | ")
79
+ : undefined,
80
+ status: latest.status,
81
+ }
82
+ : null
83
+
84
+ const notReady = connected && readiness && !readiness.ready_to_publish
85
+
86
+ return (
87
+ <Container className="divide-y p-0">
88
+ <div className="flex items-center justify-between px-6 py-4">
89
+ <div className="flex flex-col gap-1">
90
+ <Heading level="h2">Faire Sync</Heading>
91
+ <Text className="text-ui-fg-subtle">
92
+ Sync this product to your Faire brand.
93
+ </Text>
94
+ </div>
95
+ {view && (
96
+ <StatusBadge color={badgeColor(view.status)}>
97
+ {view.published ? "Active" : view.state || view.status}
98
+ </StatusBadge>
99
+ )}
100
+ </div>
101
+
102
+ <div className="flex flex-col gap-4 px-6 py-4">
103
+ {!connected && !statusQuery.isLoading && (
104
+ <Alert variant="warning">
105
+ <Text className="text-ui-fg-subtle">
106
+ Faire is not connected. Connect it under Settings → Faire.
107
+ </Text>
108
+ </Alert>
109
+ )}
110
+
111
+ {notReady && (
112
+ <Alert variant="warning">
113
+ <Text className="text-ui-fg-subtle">
114
+ Publish readiness incomplete (brand or wholesale pricing config is
115
+ missing). Syncing will still run and create the product as a draft.
116
+ </Text>
117
+ </Alert>
118
+ )}
119
+
120
+ {error && (
121
+ <Alert variant="error">
122
+ <Text className="text-ui-fg-subtle">{error}</Text>
123
+ </Alert>
124
+ )}
125
+
126
+ {view && (
127
+ <Alert variant={view.published ? "success" : "warning"}>
128
+ <div className="flex flex-col gap-1">
129
+ <Text>
130
+ {view.published
131
+ ? "Published to Faire as active."
132
+ : `Synced as ${view.state || view.status}.`}
133
+ </Text>
134
+ {view.warnings && view.warnings.length > 0 && (
135
+ <Text className="text-ui-fg-subtle">
136
+ {view.warnings.join(" | ")}
137
+ </Text>
138
+ )}
139
+ {view.product_url && (
140
+ <a
141
+ href={view.product_url}
142
+ target="_blank"
143
+ rel="noreferrer"
144
+ className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
145
+ >
146
+ {view.published ? "View on Faire →" : "Open in Faire →"}
147
+ </a>
148
+ )}
149
+ </div>
150
+ </Alert>
151
+ )}
152
+
153
+ <div className="flex items-center gap-3">
154
+ <Tooltip
155
+ content={
156
+ connected
157
+ ? undefined
158
+ : "Connect Faire under Settings → Faire first."
159
+ }
160
+ >
161
+ <Button
162
+ size="small"
163
+ isLoading={syncMutation.isPending}
164
+ disabled={!connected}
165
+ onClick={() => syncMutation.mutate()}
166
+ >
167
+ {view ? "Re-sync to Faire" : "Sync to Faire"}
168
+ </Button>
169
+ </Tooltip>
170
+ </div>
171
+ </div>
172
+ </Container>
173
+ )
174
+ }
175
+
176
+ export const config = defineWidgetConfig({
177
+ zone: "product.details.side.before",
178
+ })
179
+
180
+ export default FaireProductWidget
@@ -0,0 +1,10 @@
1
+ import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
2
+ import { FAIRE_SYNC_MODULE } from "../../../../../modules/faire-sync"
3
+ import FaireSyncService from "../../../../../modules/faire-sync/service"
4
+
5
+ // GET /admin/faire/auth/authorize — begins OAuth, returns the Faire authorize URL
6
+ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
7
+ const service: FaireSyncService = req.scope.resolve(FAIRE_SYNC_MODULE)
8
+ const { authorization_url, state } = await service.startOAuth()
9
+ res.json({ authorization_url, state })
10
+ }
@@ -0,0 +1,29 @@
1
+ import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
2
+ import { MedusaError } from "@medusajs/framework/utils"
3
+ import { FAIRE_SYNC_MODULE } from "../../../../../modules/faire-sync"
4
+ import FaireSyncService from "../../../../../modules/faire-sync/service"
5
+
6
+ // POST /admin/faire/auth/callback — exchanges the OAuth code (called by the
7
+ // admin SPA callback page, so it runs with an authenticated admin session).
8
+ export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
9
+ const { code, state } = req.body as { code: string; state: string }
10
+
11
+ if (!code || !state) {
12
+ throw new MedusaError(
13
+ MedusaError.Types.INVALID_DATA,
14
+ "code and state are required"
15
+ )
16
+ }
17
+
18
+ const service: FaireSyncService = req.scope.resolve(FAIRE_SYNC_MODULE)
19
+ const { account, brand } = await service.completeOAuth(code, state)
20
+
21
+ res.json({
22
+ account: {
23
+ id: (account as any).id,
24
+ brand_id: (account as any).brand_id,
25
+ brand_name: (account as any).brand_name,
26
+ },
27
+ brand,
28
+ })
29
+ }
@@ -0,0 +1,10 @@
1
+ import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
2
+ import { FAIRE_SYNC_MODULE } from "../../../../../modules/faire-sync"
3
+ import FaireSyncService from "../../../../../modules/faire-sync/service"
4
+
5
+ // POST /admin/faire/auth/disconnect
6
+ export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
7
+ const service: FaireSyncService = req.scope.resolve(FAIRE_SYNC_MODULE)
8
+ await service.disconnect()
9
+ res.json({ disconnected: true })
10
+ }
@@ -0,0 +1,12 @@
1
+ import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
2
+ import { FAIRE_SYNC_MODULE } from "../../../../modules/faire-sync"
3
+ import FaireSyncService from "../../../../modules/faire-sync/service"
4
+
5
+ // GET /admin/faire/brand — the connected Faire brand (fresh token)
6
+ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
7
+ const service: FaireSyncService = req.scope.resolve(FAIRE_SYNC_MODULE)
8
+ const account = await service.ensureFreshToken()
9
+ const client = service.getClient()
10
+ const brand = await client.getBrand(account.access_token)
11
+ res.json({ brand })
12
+ }
@@ -0,0 +1,31 @@
1
+ import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
2
+ import { MedusaError } from "@medusajs/framework/utils"
3
+ import { FAIRE_SYNC_MODULE } from "../../../../../../modules/faire-sync"
4
+ import FaireSyncService from "../../../../../../modules/faire-sync/service"
5
+
6
+ // GET /admin/faire/ingest/orders/:batchId — poll bulk order-ingest progress
7
+ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
8
+ const { batchId } = req.params
9
+ const service: FaireSyncService = req.scope.resolve(FAIRE_SYNC_MODULE)
10
+
11
+ const batch = await service.retrieveFaireSyncBatch(batchId).catch(() => null)
12
+
13
+ if (!batch) {
14
+ throw new MedusaError(MedusaError.Types.NOT_FOUND, "Batch not found")
15
+ }
16
+
17
+ const total = (batch as any).total_products || 0
18
+ const done =
19
+ ((batch as any).synced_count || 0) + ((batch as any).failed_count || 0)
20
+
21
+ res.json({
22
+ batch,
23
+ progress: {
24
+ total,
25
+ done,
26
+ pct: total ? Math.round((done / total) * 100) : 0,
27
+ finished:
28
+ (batch as any).status === "completed" || (batch as any).status === "failed",
29
+ },
30
+ })
31
+ }
@@ -0,0 +1,29 @@
1
+ import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
2
+ import { MedusaError } from "@medusajs/framework/utils"
3
+ import { ingestFaireOrdersBulkWorkflow } from "../../../../../workflows/ingest-faire-orders-bulk"
4
+
5
+ type IngestOrdersBody = {
6
+ limit?: number
7
+ }
8
+
9
+ // POST /admin/faire/ingest/orders — pull Faire orders and create Medusa orders
10
+ // for them. Runs as a long-running background workflow; returns a batch id the
11
+ // client can poll at GET /admin/faire/ingest/orders/:batchId.
12
+ export const POST = async (
13
+ req: MedusaRequest<IngestOrdersBody>,
14
+ res: MedusaResponse
15
+ ) => {
16
+ const limit = req.body?.limit
17
+ if (limit != null && (!Number.isFinite(limit) || limit <= 0)) {
18
+ throw new MedusaError(
19
+ MedusaError.Types.INVALID_DATA,
20
+ "limit must be a positive number"
21
+ )
22
+ }
23
+
24
+ const { result } = await ingestFaireOrdersBulkWorkflow(req.scope).run({
25
+ input: { limit },
26
+ })
27
+
28
+ res.status(202).json({ batch_id: (result as any).batch_id, status: "processing" })
29
+ }
@@ -0,0 +1,14 @@
1
+ import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
2
+ import { FAIRE_SYNC_MODULE } from "../../../../modules/faire-sync"
3
+ import FaireSyncService from "../../../../modules/faire-sync/service"
4
+
5
+ // GET /admin/faire/products — list products already on Faire (paginated)
6
+ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
7
+ const service: FaireSyncService = req.scope.resolve(FAIRE_SYNC_MODULE)
8
+ const account = await service.ensureFreshToken()
9
+ const client = service.getClient()
10
+ const limit = Number(req.query.limit) || 50
11
+ const page = req.query.page ? Number(req.query.page) : 1
12
+ const data = await client.listProducts(account.access_token, { limit, page })
13
+ res.json({ products: data.results, count: data.count })
14
+ }