@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.
@@ -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
- shipping_policy: boolean
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.shipping_policy} label="Shipping policy" />
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
- <input
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
- <input
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
- <input
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
- <input
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="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
- />
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
- shipping_policy: boolean
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 { faireApi } from "../lib/api"
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 hasShipping = !!settings.default_shipping_policy_id
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
- shipping_policy: hasShipping,
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
+ }
@@ -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 Error(`Product ${input.product_id} not found`)
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 Error(
251
- "Faire requires a product category (taxonomy_type). Set a Faire " +
252
- "category in sync settings (default_category) or on the product " +
253
- "(metadata.faire_taxonomy_type_id a `tt_…` id or a category name)."
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