@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.
- package/.medusa/server/src/__tests__/ingest-faire-order-support.spec.js +74 -0
- package/.medusa/server/src/__tests__/webhook.spec.js +77 -0
- package/.medusa/server/src/admin/index.js +960 -0
- package/.medusa/server/src/admin/index.mjs +959 -0
- package/.medusa/server/src/api/admin/faire/auth/authorize/route.js +12 -0
- package/.medusa/server/src/api/admin/faire/auth/callback/route.js +25 -0
- package/.medusa/server/src/api/admin/faire/auth/disconnect/route.js +12 -0
- package/.medusa/server/src/api/admin/faire/brand/route.js +14 -0
- package/.medusa/server/src/api/admin/faire/ingest/orders/[batchId]/route.js +27 -0
- package/.medusa/server/src/api/admin/faire/ingest/orders/route.js +20 -0
- package/.medusa/server/src/api/admin/faire/products/route.js +16 -0
- package/.medusa/server/src/api/admin/faire/settings/route.js +19 -0
- package/.medusa/server/src/api/admin/faire/status/product/[id]/route.js +30 -0
- package/.medusa/server/src/api/admin/faire/status/route.js +46 -0
- package/.medusa/server/src/api/admin/faire/sync/bulk/[batchId]/route.js +29 -0
- package/.medusa/server/src/api/admin/faire/sync/bulk/route.js +20 -0
- package/.medusa/server/src/api/admin/faire/sync/product/[id]/route.js +14 -0
- package/.medusa/server/src/api/admin/faire/syncs/[id]/route.js +32 -0
- package/.medusa/server/src/api/admin/faire/syncs/route.js +18 -0
- package/.medusa/server/src/api/webhooks/faire/route.js +125 -0
- package/.medusa/server/src/jobs/pull-faire-orders.js +32 -0
- package/.medusa/server/src/jobs/refresh-faire-token.js +25 -0
- package/.medusa/server/src/lib/faire-client.js +321 -0
- package/.medusa/server/src/lib/types.js +10 -0
- package/.medusa/server/src/lib/webhook.js +58 -0
- package/.medusa/server/src/links/product-faire-sync-link.js +57 -0
- package/.medusa/server/src/modules/faire-sync/index.js +15 -0
- package/.medusa/server/src/modules/faire-sync/migrations/Migration20260707211346.js +34 -0
- package/.medusa/server/src/modules/faire-sync/models/faire-order.js +21 -0
- package/.medusa/server/src/modules/faire-sync/models/faire-sync-account.js +17 -0
- package/.medusa/server/src/modules/faire-sync/models/faire-sync-batch.js +18 -0
- package/.medusa/server/src/modules/faire-sync/models/faire-sync-record.js +22 -0
- package/.medusa/server/src/modules/faire-sync/models/faire-sync-settings.js +26 -0
- package/.medusa/server/src/modules/faire-sync/models/faire-webhook-event.js +21 -0
- package/.medusa/server/src/modules/faire-sync/service.js +222 -0
- package/.medusa/server/src/subscribers/faire-inventory-sync.js +87 -0
- package/.medusa/server/src/subscribers/faire-listing-events.js +46 -0
- package/.medusa/server/src/subscribers/faire-order-ingest.js +54 -0
- package/.medusa/server/src/subscribers/faire-order-lifecycle.js +71 -0
- package/.medusa/server/src/subscribers/faire-product-status-sync.js +71 -0
- package/.medusa/server/src/workflows/index.js +21 -0
- package/.medusa/server/src/workflows/ingest-faire-order-support.js +76 -0
- package/.medusa/server/src/workflows/ingest-faire-order.js +148 -0
- package/.medusa/server/src/workflows/ingest-faire-orders-bulk.js +124 -0
- package/.medusa/server/src/workflows/refresh-faire-token.js +20 -0
- package/.medusa/server/src/workflows/sync-product-to-faire.js +262 -0
- package/.medusa/server/src/workflows/sync-products-to-faire.js +90 -0
- package/LICENSE +21 -0
- package/README.md +180 -0
- package/package.json +84 -0
- package/scripts/postbuild.js +36 -0
- package/src/__tests__/ingest-faire-order-support.spec.ts +77 -0
- package/src/__tests__/webhook.spec.ts +81 -0
- package/src/admin/lib/api.ts +88 -0
- package/src/admin/routes/settings/faire/[id]/page.tsx +184 -0
- package/src/admin/routes/settings/faire/hooks/use-faire-sync-columns.tsx +80 -0
- package/src/admin/routes/settings/faire/page.tsx +445 -0
- package/src/admin/routes/settings/faire/settings/page.tsx +286 -0
- package/src/admin/routes/settings/oauth/faire/callback/page.tsx +67 -0
- package/src/admin/widgets/faire-product-widget.tsx +180 -0
- package/src/api/admin/faire/auth/authorize/route.ts +10 -0
- package/src/api/admin/faire/auth/callback/route.ts +29 -0
- package/src/api/admin/faire/auth/disconnect/route.ts +10 -0
- package/src/api/admin/faire/brand/route.ts +12 -0
- package/src/api/admin/faire/ingest/orders/[batchId]/route.ts +31 -0
- package/src/api/admin/faire/ingest/orders/route.ts +29 -0
- package/src/api/admin/faire/products/route.ts +14 -0
- package/src/api/admin/faire/settings/route.ts +17 -0
- package/src/api/admin/faire/status/product/[id]/route.ts +30 -0
- package/src/api/admin/faire/status/route.ts +47 -0
- package/src/api/admin/faire/sync/bulk/[batchId]/route.ts +33 -0
- package/src/api/admin/faire/sync/bulk/route.ts +27 -0
- package/src/api/admin/faire/sync/product/[id]/route.ts +13 -0
- package/src/api/admin/faire/syncs/[id]/route.ts +32 -0
- package/src/api/admin/faire/syncs/route.ts +17 -0
- package/src/api/webhooks/faire/route.ts +133 -0
- package/src/jobs/pull-faire-orders.ts +30 -0
- package/src/jobs/refresh-faire-token.ts +23 -0
- package/src/lib/faire-client.ts +436 -0
- package/src/lib/types.ts +117 -0
- package/src/lib/webhook.ts +85 -0
- package/src/links/product-faire-sync-link.ts +56 -0
- package/src/modules/faire-sync/index.ts +11 -0
- package/src/modules/faire-sync/migrations/.snapshot-medusa-faire-sync.json +1486 -0
- package/src/modules/faire-sync/migrations/Migration20260707211346.ts +43 -0
- package/src/modules/faire-sync/models/faire-order.ts +20 -0
- package/src/modules/faire-sync/models/faire-sync-account.ts +16 -0
- package/src/modules/faire-sync/models/faire-sync-batch.ts +17 -0
- package/src/modules/faire-sync/models/faire-sync-record.ts +21 -0
- package/src/modules/faire-sync/models/faire-sync-settings.ts +25 -0
- package/src/modules/faire-sync/models/faire-webhook-event.ts +20 -0
- package/src/modules/faire-sync/service.ts +252 -0
- package/src/subscribers/faire-inventory-sync.ts +97 -0
- package/src/subscribers/faire-listing-events.ts +48 -0
- package/src/subscribers/faire-order-ingest.ts +61 -0
- package/src/subscribers/faire-order-lifecycle.ts +81 -0
- package/src/subscribers/faire-product-status-sync.ts +76 -0
- package/src/workflows/index.ts +4 -0
- package/src/workflows/ingest-faire-order-support.ts +96 -0
- package/src/workflows/ingest-faire-order.ts +212 -0
- package/src/workflows/ingest-faire-orders-bulk.ts +168 -0
- package/src/workflows/refresh-faire-token.ts +28 -0
- package/src/workflows/sync-product-to-faire.ts +375 -0
- 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
|
+
}
|