@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.
@@ -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,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
  },
@@ -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
- this.taxonomyTypesCache = []
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(account.access_token)
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 a Faire " +
256
- "category in sync settings (default_category) or on the product " +
257
- "(metadata.faire_taxonomy_type_id a `tt_…` id or a category name)."
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