@jytextiles/medusa-plugin-faire-store-sync 0.2.7 → 0.2.9
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/admin/index.js +368 -242
- package/.medusa/server/src/admin/index.mjs +370 -244
- package/.medusa/server/src/api/admin/faire/status/route.js +1 -3
- package/.medusa/server/src/lib/faire-client.js +8 -3
- package/.medusa/server/src/modules/faire-sync/service.js +7 -2
- package/.medusa/server/src/workflows/sync-product-to-faire.js +10 -4
- package/package.json +1 -1
- package/src/admin/lib/api.ts +1 -1
- package/src/admin/routes/settings/faire/{bulk → @bulk}/page.tsx +17 -7
- package/src/admin/routes/settings/faire/{settings → @settings}/page.tsx +11 -22
- 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 +0 -2
- package/src/lib/faire-client.ts +7 -2
- package/src/modules/faire-sync/service.ts +6 -1
- package/src/workflows/sync-product-to-faire.ts +9 -3
- /package/src/admin/routes/settings/faire/{bulk → @bulk}/use-bulk-product-columns.tsx +0 -0
|
@@ -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,6 @@ 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 hasShipping = !!settings.default_shipping_policy_id
|
|
17
16
|
const hasTaxonomy = !!settings.default_category
|
|
18
17
|
|
|
19
18
|
res.json({
|
|
@@ -41,7 +40,6 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
|
|
|
41
40
|
connected,
|
|
42
41
|
brand: hasBrand,
|
|
43
42
|
wholesale_pricing: hasMarkup,
|
|
44
|
-
shipping_policy: hasShipping,
|
|
45
43
|
taxonomy: hasTaxonomy,
|
|
46
44
|
ready_to_publish: connected && hasBrand && hasTaxonomy,
|
|
47
45
|
},
|
package/src/lib/faire-client.ts
CHANGED
|
@@ -498,8 +498,13 @@ export class FaireClient {
|
|
|
498
498
|
data.results ??
|
|
499
499
|
[]
|
|
500
500
|
).map((t: any) => ({ id: String(t.id), name: String(t.name ?? "") }))
|
|
501
|
-
} catch {
|
|
502
|
-
|
|
501
|
+
} catch (err: any) {
|
|
502
|
+
// Don't cache the failure — a transient/auth error shouldn't blank the
|
|
503
|
+
// picker for the rest of the process lifetime. Surface it so an empty
|
|
504
|
+
// list is diagnosable (e.g. a 401 from an un-decrypted token).
|
|
505
|
+
// eslint-disable-next-line no-console
|
|
506
|
+
console.warn("[faire-sync] getTaxonomyTypes failed:", err?.message)
|
|
507
|
+
return []
|
|
503
508
|
}
|
|
504
509
|
}
|
|
505
510
|
return this.taxonomyTypesCache ?? []
|
|
@@ -256,8 +256,13 @@ class FaireSyncService extends MedusaService({
|
|
|
256
256
|
async getTaxonomyTypes(): Promise<Array<{ id: string; name: string }>> {
|
|
257
257
|
const account = await this.getActiveAccount()
|
|
258
258
|
if (!account) return []
|
|
259
|
+
// Tokens are stored AES-256-GCM encrypted at rest — go through
|
|
260
|
+
// ensureFreshToken so we send Faire the DECRYPTED (and non-expired) token.
|
|
261
|
+
// Passing the raw ciphertext gets a 401 that the client silently swallows to
|
|
262
|
+
// [], which surfaces as an empty taxonomy picker.
|
|
263
|
+
const fresh = await this.ensureFreshToken(account)
|
|
259
264
|
const client = this.getClient(account.auth_mode as "oauth" | "apiKey")
|
|
260
|
-
return client.getTaxonomyTypes(
|
|
265
|
+
return client.getTaxonomyTypes(fresh!.access_token)
|
|
261
266
|
}
|
|
262
267
|
|
|
263
268
|
/**
|
|
@@ -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.*",
|
|
@@ -238,11 +239,16 @@ const prepareProductStep = createStep(
|
|
|
238
239
|
|
|
239
240
|
// Resolve taxonomy_type.id — REQUIRED by Faire create. Priority:
|
|
240
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)
|
|
241
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.
|
|
242
247
|
const client = service.getClient(input.auth_mode)
|
|
243
248
|
const categoryHint =
|
|
244
249
|
metadata.faire_taxonomy_type_id ||
|
|
245
250
|
metadata.faire_category ||
|
|
251
|
+
product.type?.value ||
|
|
246
252
|
settings.default_category ||
|
|
247
253
|
""
|
|
248
254
|
const taxonomyTypeId = await client.resolveTaxonomyTypeId(
|
|
@@ -252,9 +258,9 @@ const prepareProductStep = createStep(
|
|
|
252
258
|
if (!taxonomyTypeId) {
|
|
253
259
|
throw new MedusaError(
|
|
254
260
|
MedusaError.Types.INVALID_DATA,
|
|
255
|
-
"Faire requires a product category (taxonomy_type). Set
|
|
256
|
-
"category
|
|
257
|
-
"
|
|
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."
|
|
258
264
|
)
|
|
259
265
|
}
|
|
260
266
|
|
|
File without changes
|