@jytextiles/medusa-plugin-faire-store-sync 0.2.6 → 0.2.8
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__/faire-client.spec.js +98 -1
- package/.medusa/server/src/admin/index.js +312 -151
- package/.medusa/server/src/admin/index.mjs +314 -153
- package/.medusa/server/src/api/admin/faire/status/route.js +4 -4
- package/.medusa/server/src/api/admin/faire/taxonomy/route.js +12 -0
- package/.medusa/server/src/lib/faire-client.js +21 -1
- package/.medusa/server/src/modules/faire-sync/service.js +8 -1
- package/.medusa/server/src/workflows/sync-product-to-faire.js +11 -5
- package/package.json +1 -1
- package/src/__tests__/faire-client.spec.ts +107 -0
- package/src/admin/lib/api.ts +5 -1
- package/src/admin/routes/settings/faire/{bulk → @bulk}/page.tsx +17 -7
- package/src/admin/routes/settings/faire/{settings → @settings}/page.tsx +59 -21
- package/src/admin/routes/settings/faire/page.tsx +9 -2
- package/src/admin/widgets/faire-product-widget.tsx +182 -1
- package/src/api/admin/faire/status/route.ts +3 -3
- package/src/api/admin/faire/taxonomy/route.ts +10 -0
- package/src/lib/faire-client.ts +27 -0
- package/src/modules/faire-sync/service.ts +7 -0
- package/src/workflows/sync-product-to-faire.ts +16 -6
- /package/src/admin/routes/settings/faire/{bulk → @bulk}/use-bulk-product-columns.tsx +0 -0
|
@@ -2,7 +2,9 @@ import {
|
|
|
2
2
|
Button,
|
|
3
3
|
Drawer,
|
|
4
4
|
Heading,
|
|
5
|
+
Input,
|
|
5
6
|
Label,
|
|
7
|
+
Select,
|
|
6
8
|
Skeleton,
|
|
7
9
|
StatusBadge,
|
|
8
10
|
Switch,
|
|
@@ -18,6 +20,7 @@ import { useNavigate } from "react-router-dom"
|
|
|
18
20
|
import { faireApi } from "../../../../lib/api"
|
|
19
21
|
|
|
20
22
|
const STATUS_KEY = ["faire", "status"]
|
|
23
|
+
const TAXONOMY_KEY = ["faire", "taxonomy"]
|
|
21
24
|
const PARENT = "/settings/faire"
|
|
22
25
|
|
|
23
26
|
// Radix Select can't use an empty-string item value, so "Not set" uses this
|
|
@@ -28,7 +31,6 @@ const cleanSettings = (form: any) => {
|
|
|
28
31
|
const out: any = { ...form }
|
|
29
32
|
for (const key of [
|
|
30
33
|
"default_brand_id",
|
|
31
|
-
"default_shipping_policy_id",
|
|
32
34
|
"default_category",
|
|
33
35
|
]) {
|
|
34
36
|
if (!out[key] || out[key] === NONE) out[key] = null
|
|
@@ -53,7 +55,7 @@ type Status = {
|
|
|
53
55
|
connected: boolean
|
|
54
56
|
brand: boolean
|
|
55
57
|
wholesale_pricing: boolean
|
|
56
|
-
|
|
58
|
+
taxonomy: boolean
|
|
57
59
|
ready_to_publish: boolean
|
|
58
60
|
}
|
|
59
61
|
}
|
|
@@ -80,6 +82,14 @@ const FaireSyncSettingsDrawer = () => {
|
|
|
80
82
|
const status = statusQuery.data
|
|
81
83
|
const connected = !!status?.connected
|
|
82
84
|
|
|
85
|
+
const taxonomyQuery = useQuery({
|
|
86
|
+
queryKey: TAXONOMY_KEY,
|
|
87
|
+
queryFn: async () =>
|
|
88
|
+
((await faireApi.taxonomy().catch(() => ({ taxonomy: [] }))) as any)
|
|
89
|
+
.taxonomy || [],
|
|
90
|
+
})
|
|
91
|
+
const taxonomy = taxonomyQuery.data ?? []
|
|
92
|
+
|
|
83
93
|
useEffect(() => {
|
|
84
94
|
if (status?.settings) setForm(status.settings)
|
|
85
95
|
}, [status?.settings])
|
|
@@ -127,7 +137,7 @@ const FaireSyncSettingsDrawer = () => {
|
|
|
127
137
|
<ChecklistItem ok={status?.readiness.connected} label="Faire connected" />
|
|
128
138
|
<ChecklistItem ok={status?.readiness.brand} label="Brand configured" />
|
|
129
139
|
<ChecklistItem ok={status?.readiness.wholesale_pricing} label="Wholesale pricing" />
|
|
130
|
-
<ChecklistItem ok={status?.readiness.
|
|
140
|
+
<ChecklistItem ok={status?.readiness.taxonomy} label="Default category" />
|
|
131
141
|
</div>
|
|
132
142
|
</div>
|
|
133
143
|
|
|
@@ -142,8 +152,7 @@ const FaireSyncSettingsDrawer = () => {
|
|
|
142
152
|
</div>
|
|
143
153
|
<div className="grid grid-cols-1 gap-4">
|
|
144
154
|
<Field label="Brand ID">
|
|
145
|
-
<
|
|
146
|
-
className="border-ui-border-base rounded-lg border px-3 py-2 text-sm"
|
|
155
|
+
<Input
|
|
147
156
|
value={String(form.default_brand_id ?? "")}
|
|
148
157
|
onChange={(e) =>
|
|
149
158
|
setForm({ ...form, default_brand_id: e.target.value || null })
|
|
@@ -154,9 +163,8 @@ const FaireSyncSettingsDrawer = () => {
|
|
|
154
163
|
</Field>
|
|
155
164
|
<Field label="Wholesale markup % (off retail)">
|
|
156
165
|
<Tooltip content="Wholesale price = retail × (100 − markup%) / 100. E.g. 50 → half of retail.">
|
|
157
|
-
<
|
|
166
|
+
<Input
|
|
158
167
|
type="number"
|
|
159
|
-
className="border-ui-border-base rounded-lg border px-3 py-2 text-sm"
|
|
160
168
|
value={String(form.default_wholesale_markup_percent ?? "")}
|
|
161
169
|
onChange={(e) =>
|
|
162
170
|
setForm({
|
|
@@ -172,9 +180,8 @@ const FaireSyncSettingsDrawer = () => {
|
|
|
172
180
|
</Tooltip>
|
|
173
181
|
</Field>
|
|
174
182
|
<Field label="Default min order quantity">
|
|
175
|
-
<
|
|
183
|
+
<Input
|
|
176
184
|
type="number"
|
|
177
|
-
className="border-ui-border-base rounded-lg border px-3 py-2 text-sm"
|
|
178
185
|
value={String(form.default_min_order_quantity ?? 1)}
|
|
179
186
|
onChange={(e) =>
|
|
180
187
|
setForm({
|
|
@@ -188,9 +195,8 @@ const FaireSyncSettingsDrawer = () => {
|
|
|
188
195
|
/>
|
|
189
196
|
</Field>
|
|
190
197
|
<Field label="Default lead time (days)">
|
|
191
|
-
<
|
|
198
|
+
<Input
|
|
192
199
|
type="number"
|
|
193
|
-
className="border-ui-border-base rounded-lg border px-3 py-2 text-sm"
|
|
194
200
|
value={String(form.default_lead_time_days ?? "")}
|
|
195
201
|
onChange={(e) =>
|
|
196
202
|
setForm({
|
|
@@ -204,16 +210,17 @@ const FaireSyncSettingsDrawer = () => {
|
|
|
204
210
|
placeholder="e.g. 14"
|
|
205
211
|
/>
|
|
206
212
|
</Field>
|
|
207
|
-
<Field label="
|
|
208
|
-
<
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
213
|
+
<Field label="Fallback category (taxonomy)">
|
|
214
|
+
<Tooltip content="Faire requires a category per product. Resolution order: product metadata → the product's Type (matched by name) → this fallback. Set the product Type or pick a category on the product page for accuracy.">
|
|
215
|
+
<SelectField
|
|
216
|
+
value={String(form.default_category ?? "")}
|
|
217
|
+
onValueChange={(v) =>
|
|
218
|
+
setForm({ ...form, default_category: v || null })
|
|
219
|
+
}
|
|
220
|
+
disabled={!connected}
|
|
221
|
+
options={taxonomy.map((t: any) => ({ value: t.id, label: t.name }))}
|
|
222
|
+
/>
|
|
223
|
+
</Tooltip>
|
|
217
224
|
</Field>
|
|
218
225
|
<Field label="Follow product status">
|
|
219
226
|
<Tooltip content="If on, published Medusa products are published on Faire; draft products sync as drafts.">
|
|
@@ -265,6 +272,37 @@ const Field = ({ label, children }: { label: string; children: React.ReactNode }
|
|
|
265
272
|
</div>
|
|
266
273
|
)
|
|
267
274
|
|
|
275
|
+
const SelectField = ({
|
|
276
|
+
value,
|
|
277
|
+
onValueChange,
|
|
278
|
+
options,
|
|
279
|
+
disabled,
|
|
280
|
+
}: {
|
|
281
|
+
value: string
|
|
282
|
+
onValueChange: (v: string) => void
|
|
283
|
+
options: { value: string; label: string }[]
|
|
284
|
+
disabled?: boolean
|
|
285
|
+
}) => (
|
|
286
|
+
<Select
|
|
287
|
+
value={value ? value : NONE}
|
|
288
|
+
onValueChange={(v) => onValueChange(v === NONE ? "" : v)}
|
|
289
|
+
disabled={disabled}
|
|
290
|
+
size="small"
|
|
291
|
+
>
|
|
292
|
+
<Select.Trigger>
|
|
293
|
+
<Select.Value placeholder="Not set" />
|
|
294
|
+
</Select.Trigger>
|
|
295
|
+
<Select.Content>
|
|
296
|
+
<Select.Item value={NONE}>Not set</Select.Item>
|
|
297
|
+
{options.map((o) => (
|
|
298
|
+
<Select.Item key={o.value} value={o.value}>
|
|
299
|
+
{o.label}
|
|
300
|
+
</Select.Item>
|
|
301
|
+
))}
|
|
302
|
+
</Select.Content>
|
|
303
|
+
</Select>
|
|
304
|
+
)
|
|
305
|
+
|
|
268
306
|
const ChecklistItem = ({ ok, label }: { ok?: boolean; label: string }) => (
|
|
269
307
|
<div
|
|
270
308
|
className={clx(
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
useQueryClient,
|
|
28
28
|
} from "@tanstack/react-query"
|
|
29
29
|
import { useState } from "react"
|
|
30
|
-
import { useNavigate } from "react-router-dom"
|
|
30
|
+
import { Outlet, useNavigate } from "react-router-dom"
|
|
31
31
|
import { faireApi } from "../../../lib/api"
|
|
32
32
|
import { useFaireSyncColumns, FaireSyncRecord } from "./hooks/use-faire-sync-columns"
|
|
33
33
|
|
|
@@ -42,7 +42,7 @@ type Status = {
|
|
|
42
42
|
connected: boolean
|
|
43
43
|
brand: boolean
|
|
44
44
|
wholesale_pricing: boolean
|
|
45
|
-
|
|
45
|
+
taxonomy: boolean
|
|
46
46
|
ready_to_publish: boolean
|
|
47
47
|
}
|
|
48
48
|
}
|
|
@@ -181,6 +181,9 @@ const FaireSettingsPage = () => {
|
|
|
181
181
|
))}
|
|
182
182
|
</div>
|
|
183
183
|
</Container>
|
|
184
|
+
{/* Nested @-routes (the @settings drawer) render here so they overlay
|
|
185
|
+
the list instead of replacing it. */}
|
|
186
|
+
<Outlet />
|
|
184
187
|
</div>
|
|
185
188
|
)
|
|
186
189
|
}
|
|
@@ -334,6 +337,10 @@ const FaireSettingsPage = () => {
|
|
|
334
337
|
</Drawer.Content>
|
|
335
338
|
</Drawer>
|
|
336
339
|
|
|
340
|
+
{/* Nested routes (e.g. @settings drawer) render here so they overlay the
|
|
341
|
+
list instead of navigating away and blanking the page. */}
|
|
342
|
+
<Outlet />
|
|
343
|
+
|
|
337
344
|
<Toaster />
|
|
338
345
|
</div>
|
|
339
346
|
)
|
|
@@ -2,15 +2,24 @@ import { defineWidgetConfig } from "@medusajs/admin-sdk"
|
|
|
2
2
|
import {
|
|
3
3
|
Button,
|
|
4
4
|
Container,
|
|
5
|
+
Drawer,
|
|
5
6
|
Heading,
|
|
7
|
+
Input,
|
|
8
|
+
Label,
|
|
6
9
|
Text,
|
|
7
10
|
Alert,
|
|
11
|
+
Badge,
|
|
8
12
|
StatusBadge,
|
|
9
13
|
Tooltip,
|
|
14
|
+
clx,
|
|
15
|
+
toast,
|
|
10
16
|
} from "@medusajs/ui"
|
|
11
17
|
import { DetailWidgetProps, AdminProduct } from "@medusajs/framework/types"
|
|
12
18
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
|
13
|
-
import {
|
|
19
|
+
import { useMemo, useState } from "react"
|
|
20
|
+
import { faireApi, sdk } from "../lib/api"
|
|
21
|
+
|
|
22
|
+
const MAX_RENDERED = 60
|
|
14
23
|
|
|
15
24
|
const badgeColor = (status?: string): "green" | "red" | "orange" | "grey" => {
|
|
16
25
|
switch (status) {
|
|
@@ -83,6 +92,72 @@ const FaireProductWidget = ({ data }: DetailWidgetProps<AdminProduct>) => {
|
|
|
83
92
|
|
|
84
93
|
const notReady = connected && readiness && !readiness.ready_to_publish
|
|
85
94
|
|
|
95
|
+
// ── Faire category (taxonomy_type) picker ────────────────────────────────
|
|
96
|
+
// Faire requires a category per product. Sync resolves it in this order:
|
|
97
|
+
// metadata.faire_taxonomy_type_id → metadata.faire_category →
|
|
98
|
+
// product Type (by name) → account fallback. This panel pins an exact
|
|
99
|
+
// `tt_…` id in metadata so the mapping is unambiguous.
|
|
100
|
+
const metadata: Record<string, any> = (data as any).metadata || {}
|
|
101
|
+
const productType: string | undefined = (data as any).type?.value
|
|
102
|
+
|
|
103
|
+
const [pickerOpen, setPickerOpen] = useState(false)
|
|
104
|
+
const [pickerSearch, setPickerSearch] = useState("")
|
|
105
|
+
// Local echo of the saved pin so the panel updates instantly (the parent
|
|
106
|
+
// product page's `data.metadata` only refreshes on its own refetch).
|
|
107
|
+
const [pinnedOverride, setPinnedOverride] = useState<string | null | undefined>(
|
|
108
|
+
undefined
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
const taxonomyQuery = useQuery({
|
|
112
|
+
queryKey: ["faire", "taxonomy"],
|
|
113
|
+
enabled: pickerOpen,
|
|
114
|
+
queryFn: async () => (await faireApi.taxonomy()).taxonomy ?? [],
|
|
115
|
+
})
|
|
116
|
+
const taxonomy = taxonomyQuery.data ?? []
|
|
117
|
+
|
|
118
|
+
const pinnedId: string | undefined =
|
|
119
|
+
pinnedOverride !== undefined
|
|
120
|
+
? pinnedOverride ?? undefined
|
|
121
|
+
: metadata.faire_taxonomy_type_id
|
|
122
|
+
const pinnedName = useMemo(() => {
|
|
123
|
+
if (!pinnedId) return undefined
|
|
124
|
+
if (!/^tt_/.test(String(pinnedId))) return String(pinnedId)
|
|
125
|
+
return taxonomy.find((t: any) => t.id === pinnedId)?.name
|
|
126
|
+
}, [pinnedId, taxonomy])
|
|
127
|
+
|
|
128
|
+
// What the sync will actually use as the category source, for display.
|
|
129
|
+
const resolvedSource = pinnedId
|
|
130
|
+
? { label: pinnedName || String(pinnedId), from: "pinned" as const }
|
|
131
|
+
: metadata.faire_category
|
|
132
|
+
? { label: String(metadata.faire_category), from: "metadata" as const }
|
|
133
|
+
: productType
|
|
134
|
+
? { label: productType, from: "type" as const }
|
|
135
|
+
: { label: "Account fallback", from: "fallback" as const }
|
|
136
|
+
|
|
137
|
+
const filteredTaxonomy = useMemo(() => {
|
|
138
|
+
const q = pickerSearch.trim().toLowerCase()
|
|
139
|
+
const list = q
|
|
140
|
+
? taxonomy.filter((t: any) => t.name.toLowerCase().includes(q))
|
|
141
|
+
: taxonomy
|
|
142
|
+
return list.slice(0, MAX_RENDERED)
|
|
143
|
+
}, [taxonomy, pickerSearch])
|
|
144
|
+
|
|
145
|
+
const saveCategory = useMutation({
|
|
146
|
+
mutationFn: (value: string | null) =>
|
|
147
|
+
sdk.admin.product.update(data.id, {
|
|
148
|
+
metadata: { ...metadata, faire_taxonomy_type_id: value },
|
|
149
|
+
}),
|
|
150
|
+
onSuccess: (_res, value) => {
|
|
151
|
+
setPinnedOverride(value)
|
|
152
|
+
queryClient.invalidateQueries({ queryKey: ["product", data.id] })
|
|
153
|
+
setPickerOpen(false)
|
|
154
|
+
setPickerSearch("")
|
|
155
|
+
toast.success(value ? "Faire category set" : "Faire category cleared")
|
|
156
|
+
},
|
|
157
|
+
onError: (err: any) =>
|
|
158
|
+
toast.error("Failed to save category", { description: err?.message }),
|
|
159
|
+
})
|
|
160
|
+
|
|
86
161
|
return (
|
|
87
162
|
<Container className="divide-y p-0">
|
|
88
163
|
<div className="flex items-center justify-between px-6 py-4">
|
|
@@ -150,6 +225,37 @@ const FaireProductWidget = ({ data }: DetailWidgetProps<AdminProduct>) => {
|
|
|
150
225
|
</Alert>
|
|
151
226
|
)}
|
|
152
227
|
|
|
228
|
+
{/* Faire category (taxonomy_type) */}
|
|
229
|
+
<div className="flex items-center justify-between gap-3 rounded-lg border px-3 py-2">
|
|
230
|
+
<div className="flex flex-col gap-0.5">
|
|
231
|
+
<div className="flex items-center gap-2">
|
|
232
|
+
<Text size="small" weight="plus">
|
|
233
|
+
Faire category
|
|
234
|
+
</Text>
|
|
235
|
+
<Badge size="2xsmall" color={resolvedSource.from === "fallback" ? "orange" : "grey"}>
|
|
236
|
+
{resolvedSource.from === "pinned"
|
|
237
|
+
? "Pinned"
|
|
238
|
+
: resolvedSource.from === "type"
|
|
239
|
+
? "From Type"
|
|
240
|
+
: resolvedSource.from === "metadata"
|
|
241
|
+
? "Metadata"
|
|
242
|
+
: "Fallback"}
|
|
243
|
+
</Badge>
|
|
244
|
+
</div>
|
|
245
|
+
<Text size="small" className="text-ui-fg-subtle">
|
|
246
|
+
{resolvedSource.label}
|
|
247
|
+
</Text>
|
|
248
|
+
</div>
|
|
249
|
+
<Button
|
|
250
|
+
size="small"
|
|
251
|
+
variant="secondary"
|
|
252
|
+
disabled={!connected}
|
|
253
|
+
onClick={() => setPickerOpen(true)}
|
|
254
|
+
>
|
|
255
|
+
{pinnedId ? "Change" : "Set category"}
|
|
256
|
+
</Button>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
153
259
|
<div className="flex items-center gap-3">
|
|
154
260
|
<Tooltip
|
|
155
261
|
content={
|
|
@@ -169,6 +275,81 @@ const FaireProductWidget = ({ data }: DetailWidgetProps<AdminProduct>) => {
|
|
|
169
275
|
</Tooltip>
|
|
170
276
|
</div>
|
|
171
277
|
</div>
|
|
278
|
+
|
|
279
|
+
{/* Category picker — searchable list from Faire's taxonomy API. */}
|
|
280
|
+
<Drawer open={pickerOpen} onOpenChange={setPickerOpen}>
|
|
281
|
+
<Drawer.Content>
|
|
282
|
+
<Drawer.Header>
|
|
283
|
+
<Drawer.Title>Pick a Faire category</Drawer.Title>
|
|
284
|
+
<Drawer.Description>
|
|
285
|
+
Search Faire's product taxonomy and pin an exact category to this
|
|
286
|
+
product.
|
|
287
|
+
</Drawer.Description>
|
|
288
|
+
</Drawer.Header>
|
|
289
|
+
<Drawer.Body className="flex flex-col gap-3 overflow-hidden">
|
|
290
|
+
<Input
|
|
291
|
+
type="search"
|
|
292
|
+
autoFocus
|
|
293
|
+
placeholder="Search categories…"
|
|
294
|
+
value={pickerSearch}
|
|
295
|
+
onChange={(e) => setPickerSearch(e.target.value)}
|
|
296
|
+
/>
|
|
297
|
+
<div className="flex flex-col gap-1 overflow-y-auto">
|
|
298
|
+
{taxonomyQuery.isLoading ? (
|
|
299
|
+
<Text size="small" className="text-ui-fg-subtle px-1 py-2">
|
|
300
|
+
Loading categories…
|
|
301
|
+
</Text>
|
|
302
|
+
) : filteredTaxonomy.length === 0 ? (
|
|
303
|
+
<Text size="small" className="text-ui-fg-subtle px-1 py-2">
|
|
304
|
+
No categories match “{pickerSearch}”.
|
|
305
|
+
</Text>
|
|
306
|
+
) : (
|
|
307
|
+
filteredTaxonomy.map((t: any) => (
|
|
308
|
+
<button
|
|
309
|
+
key={t.id}
|
|
310
|
+
type="button"
|
|
311
|
+
onClick={() => saveCategory.mutate(t.id)}
|
|
312
|
+
disabled={saveCategory.isPending}
|
|
313
|
+
className={clx(
|
|
314
|
+
"flex items-center justify-between rounded-md px-3 py-2 text-left text-sm",
|
|
315
|
+
"hover:bg-ui-bg-base-hover",
|
|
316
|
+
t.id === pinnedId && "bg-ui-bg-highlight"
|
|
317
|
+
)}
|
|
318
|
+
>
|
|
319
|
+
<span>{t.name}</span>
|
|
320
|
+
{t.id === pinnedId && (
|
|
321
|
+
<Badge size="2xsmall" color="green">
|
|
322
|
+
Current
|
|
323
|
+
</Badge>
|
|
324
|
+
)}
|
|
325
|
+
</button>
|
|
326
|
+
))
|
|
327
|
+
)}
|
|
328
|
+
{!taxonomyQuery.isLoading &&
|
|
329
|
+
taxonomy.length > filteredTaxonomy.length && (
|
|
330
|
+
<Text size="xsmall" className="text-ui-fg-muted px-1 py-1">
|
|
331
|
+
Showing {filteredTaxonomy.length} of {taxonomy.length} — refine
|
|
332
|
+
your search to narrow down.
|
|
333
|
+
</Text>
|
|
334
|
+
)}
|
|
335
|
+
</div>
|
|
336
|
+
</Drawer.Body>
|
|
337
|
+
<Drawer.Footer>
|
|
338
|
+
{pinnedId && (
|
|
339
|
+
<Button
|
|
340
|
+
variant="secondary"
|
|
341
|
+
onClick={() => saveCategory.mutate(null)}
|
|
342
|
+
isLoading={saveCategory.isPending}
|
|
343
|
+
>
|
|
344
|
+
Clear
|
|
345
|
+
</Button>
|
|
346
|
+
)}
|
|
347
|
+
<Drawer.Close asChild>
|
|
348
|
+
<Button variant="secondary">Cancel</Button>
|
|
349
|
+
</Drawer.Close>
|
|
350
|
+
</Drawer.Footer>
|
|
351
|
+
</Drawer.Content>
|
|
352
|
+
</Drawer>
|
|
172
353
|
</Container>
|
|
173
354
|
)
|
|
174
355
|
}
|
|
@@ -13,7 +13,7 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
|
|
|
13
13
|
const hasMarkup =
|
|
14
14
|
settings.default_wholesale_markup_percent != null &&
|
|
15
15
|
settings.default_wholesale_markup_percent > 0
|
|
16
|
-
const
|
|
16
|
+
const hasTaxonomy = !!settings.default_category
|
|
17
17
|
|
|
18
18
|
res.json({
|
|
19
19
|
connected,
|
|
@@ -40,8 +40,8 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
|
|
|
40
40
|
connected,
|
|
41
41
|
brand: hasBrand,
|
|
42
42
|
wholesale_pricing: hasMarkup,
|
|
43
|
-
|
|
44
|
-
ready_to_publish: connected && hasBrand,
|
|
43
|
+
taxonomy: hasTaxonomy,
|
|
44
|
+
ready_to_publish: connected && hasBrand && hasTaxonomy,
|
|
45
45
|
},
|
|
46
46
|
})
|
|
47
47
|
}
|
|
@@ -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/taxonomy — taxonomy types from Faire's /products/types
|
|
6
|
+
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
|
|
7
|
+
const service: FaireSyncService = req.scope.resolve(FAIRE_SYNC_MODULE)
|
|
8
|
+
const types = await service.getTaxonomyTypes()
|
|
9
|
+
res.json({ taxonomy: types })
|
|
10
|
+
}
|
package/src/lib/faire-client.ts
CHANGED
|
@@ -478,6 +478,33 @@ export class FaireClient {
|
|
|
478
478
|
* 2026-07-09; the `limit` param is ignored). Cached per client.
|
|
479
479
|
*/
|
|
480
480
|
private taxonomyTypesCache: Array<{ id: string; name: string }> | null = null
|
|
481
|
+
/**
|
|
482
|
+
* Fetch the full taxonomy types list from Faire (cached per client instance).
|
|
483
|
+
* Returns `[{ id, name }, …]` sorted by name or `[]` on error.
|
|
484
|
+
*/
|
|
485
|
+
async getTaxonomyTypes(
|
|
486
|
+
accessToken: string
|
|
487
|
+
): Promise<Array<{ id: string; name: string }>> {
|
|
488
|
+
if (!this.taxonomyTypesCache) {
|
|
489
|
+
try {
|
|
490
|
+
const data = await this.requestJson<any>(
|
|
491
|
+
`${this.apiBase}/products/types`,
|
|
492
|
+
{ method: "GET", accessToken }
|
|
493
|
+
)
|
|
494
|
+
this.taxonomyTypesCache = (
|
|
495
|
+
data.taxonomy_types ??
|
|
496
|
+
data.product_types ??
|
|
497
|
+
data.types ??
|
|
498
|
+
data.results ??
|
|
499
|
+
[]
|
|
500
|
+
).map((t: any) => ({ id: String(t.id), name: String(t.name ?? "") }))
|
|
501
|
+
} catch {
|
|
502
|
+
this.taxonomyTypesCache = []
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return this.taxonomyTypesCache ?? []
|
|
506
|
+
}
|
|
507
|
+
|
|
481
508
|
async resolveTaxonomyTypeId(
|
|
482
509
|
accessToken: string,
|
|
483
510
|
nameOrId: string
|
|
@@ -253,6 +253,13 @@ class FaireSyncService extends MedusaService({
|
|
|
253
253
|
return this.updateFaireSyncSettings({ id: settings.id, ...data } as any)
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
async getTaxonomyTypes(): Promise<Array<{ id: string; name: string }>> {
|
|
257
|
+
const account = await this.getActiveAccount()
|
|
258
|
+
if (!account) return []
|
|
259
|
+
const client = this.getClient(account.auth_mode as "oauth" | "apiKey")
|
|
260
|
+
return client.getTaxonomyTypes(account.access_token)
|
|
261
|
+
}
|
|
262
|
+
|
|
256
263
|
/**
|
|
257
264
|
* High-water mark for incremental order polling. Returns the last successful
|
|
258
265
|
* sync timestamp, or null if orders have never been polled (full backfill).
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
WorkflowData,
|
|
7
7
|
transform,
|
|
8
8
|
} from "@medusajs/framework/workflows-sdk"
|
|
9
|
-
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
|
|
9
|
+
import { ContainerRegistrationKeys, MedusaError, Modules } from "@medusajs/framework/utils"
|
|
10
10
|
import type { Link } from "@medusajs/modules-sdk"
|
|
11
11
|
import type { RemoteQueryFunction } from "@medusajs/types"
|
|
12
12
|
import { FAIRE_SYNC_MODULE } from "../modules/faire-sync"
|
|
@@ -131,6 +131,7 @@ const prepareProductStep = createStep(
|
|
|
131
131
|
"description",
|
|
132
132
|
"status",
|
|
133
133
|
"metadata",
|
|
134
|
+
"type.value",
|
|
134
135
|
"tags.*",
|
|
135
136
|
"variants.*",
|
|
136
137
|
"variants.prices.*",
|
|
@@ -145,7 +146,10 @@ const prepareProductStep = createStep(
|
|
|
145
146
|
|
|
146
147
|
const product: any = products?.[0]
|
|
147
148
|
if (!product) {
|
|
148
|
-
throw new
|
|
149
|
+
throw new MedusaError(
|
|
150
|
+
MedusaError.Types.NOT_FOUND,
|
|
151
|
+
`Product ${input.product_id} not found`
|
|
152
|
+
)
|
|
149
153
|
}
|
|
150
154
|
|
|
151
155
|
// Resolve existing Faire product (from link)
|
|
@@ -235,11 +239,16 @@ const prepareProductStep = createStep(
|
|
|
235
239
|
|
|
236
240
|
// Resolve taxonomy_type.id — REQUIRED by Faire create. Priority:
|
|
237
241
|
// product.metadata.faire_taxonomy_type_id (tt_… or a category name)
|
|
242
|
+
// → product.metadata.faire_category (name)
|
|
243
|
+
// → product.type.value (the native Medusa Product Type, matched by name)
|
|
238
244
|
// → settings.default_category (id or name) → error.
|
|
245
|
+
// Product Type is the natural per-product carrier; the product widget lets an
|
|
246
|
+
// operator pin an exact `tt_…` id in metadata when the type name is ambiguous.
|
|
239
247
|
const client = service.getClient(input.auth_mode)
|
|
240
248
|
const categoryHint =
|
|
241
249
|
metadata.faire_taxonomy_type_id ||
|
|
242
250
|
metadata.faire_category ||
|
|
251
|
+
product.type?.value ||
|
|
243
252
|
settings.default_category ||
|
|
244
253
|
""
|
|
245
254
|
const taxonomyTypeId = await client.resolveTaxonomyTypeId(
|
|
@@ -247,10 +256,11 @@ const prepareProductStep = createStep(
|
|
|
247
256
|
String(categoryHint)
|
|
248
257
|
)
|
|
249
258
|
if (!taxonomyTypeId) {
|
|
250
|
-
throw new
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
"
|
|
259
|
+
throw new MedusaError(
|
|
260
|
+
MedusaError.Types.INVALID_DATA,
|
|
261
|
+
"Faire requires a product category (taxonomy_type). Set the product's " +
|
|
262
|
+
"Type to a Faire category name, pick one from the Faire panel on the " +
|
|
263
|
+
"product page, or set a fallback category in Faire sync settings."
|
|
254
264
|
)
|
|
255
265
|
}
|
|
256
266
|
|
|
File without changes
|