@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.
Files changed (104) hide show
  1. package/.medusa/server/src/__tests__/ingest-faire-order-support.spec.js +74 -0
  2. package/.medusa/server/src/__tests__/webhook.spec.js +77 -0
  3. package/.medusa/server/src/admin/index.js +960 -0
  4. package/.medusa/server/src/admin/index.mjs +959 -0
  5. package/.medusa/server/src/api/admin/faire/auth/authorize/route.js +12 -0
  6. package/.medusa/server/src/api/admin/faire/auth/callback/route.js +25 -0
  7. package/.medusa/server/src/api/admin/faire/auth/disconnect/route.js +12 -0
  8. package/.medusa/server/src/api/admin/faire/brand/route.js +14 -0
  9. package/.medusa/server/src/api/admin/faire/ingest/orders/[batchId]/route.js +27 -0
  10. package/.medusa/server/src/api/admin/faire/ingest/orders/route.js +20 -0
  11. package/.medusa/server/src/api/admin/faire/products/route.js +16 -0
  12. package/.medusa/server/src/api/admin/faire/settings/route.js +19 -0
  13. package/.medusa/server/src/api/admin/faire/status/product/[id]/route.js +30 -0
  14. package/.medusa/server/src/api/admin/faire/status/route.js +46 -0
  15. package/.medusa/server/src/api/admin/faire/sync/bulk/[batchId]/route.js +29 -0
  16. package/.medusa/server/src/api/admin/faire/sync/bulk/route.js +20 -0
  17. package/.medusa/server/src/api/admin/faire/sync/product/[id]/route.js +14 -0
  18. package/.medusa/server/src/api/admin/faire/syncs/[id]/route.js +32 -0
  19. package/.medusa/server/src/api/admin/faire/syncs/route.js +18 -0
  20. package/.medusa/server/src/api/webhooks/faire/route.js +125 -0
  21. package/.medusa/server/src/jobs/pull-faire-orders.js +32 -0
  22. package/.medusa/server/src/jobs/refresh-faire-token.js +25 -0
  23. package/.medusa/server/src/lib/faire-client.js +321 -0
  24. package/.medusa/server/src/lib/types.js +10 -0
  25. package/.medusa/server/src/lib/webhook.js +58 -0
  26. package/.medusa/server/src/links/product-faire-sync-link.js +57 -0
  27. package/.medusa/server/src/modules/faire-sync/index.js +15 -0
  28. package/.medusa/server/src/modules/faire-sync/migrations/Migration20260707211346.js +34 -0
  29. package/.medusa/server/src/modules/faire-sync/models/faire-order.js +21 -0
  30. package/.medusa/server/src/modules/faire-sync/models/faire-sync-account.js +17 -0
  31. package/.medusa/server/src/modules/faire-sync/models/faire-sync-batch.js +18 -0
  32. package/.medusa/server/src/modules/faire-sync/models/faire-sync-record.js +22 -0
  33. package/.medusa/server/src/modules/faire-sync/models/faire-sync-settings.js +26 -0
  34. package/.medusa/server/src/modules/faire-sync/models/faire-webhook-event.js +21 -0
  35. package/.medusa/server/src/modules/faire-sync/service.js +222 -0
  36. package/.medusa/server/src/subscribers/faire-inventory-sync.js +87 -0
  37. package/.medusa/server/src/subscribers/faire-listing-events.js +46 -0
  38. package/.medusa/server/src/subscribers/faire-order-ingest.js +54 -0
  39. package/.medusa/server/src/subscribers/faire-order-lifecycle.js +71 -0
  40. package/.medusa/server/src/subscribers/faire-product-status-sync.js +71 -0
  41. package/.medusa/server/src/workflows/index.js +21 -0
  42. package/.medusa/server/src/workflows/ingest-faire-order-support.js +76 -0
  43. package/.medusa/server/src/workflows/ingest-faire-order.js +148 -0
  44. package/.medusa/server/src/workflows/ingest-faire-orders-bulk.js +124 -0
  45. package/.medusa/server/src/workflows/refresh-faire-token.js +20 -0
  46. package/.medusa/server/src/workflows/sync-product-to-faire.js +262 -0
  47. package/.medusa/server/src/workflows/sync-products-to-faire.js +90 -0
  48. package/LICENSE +21 -0
  49. package/README.md +180 -0
  50. package/package.json +84 -0
  51. package/scripts/postbuild.js +36 -0
  52. package/src/__tests__/ingest-faire-order-support.spec.ts +77 -0
  53. package/src/__tests__/webhook.spec.ts +81 -0
  54. package/src/admin/lib/api.ts +88 -0
  55. package/src/admin/routes/settings/faire/[id]/page.tsx +184 -0
  56. package/src/admin/routes/settings/faire/hooks/use-faire-sync-columns.tsx +80 -0
  57. package/src/admin/routes/settings/faire/page.tsx +445 -0
  58. package/src/admin/routes/settings/faire/settings/page.tsx +286 -0
  59. package/src/admin/routes/settings/oauth/faire/callback/page.tsx +67 -0
  60. package/src/admin/widgets/faire-product-widget.tsx +180 -0
  61. package/src/api/admin/faire/auth/authorize/route.ts +10 -0
  62. package/src/api/admin/faire/auth/callback/route.ts +29 -0
  63. package/src/api/admin/faire/auth/disconnect/route.ts +10 -0
  64. package/src/api/admin/faire/brand/route.ts +12 -0
  65. package/src/api/admin/faire/ingest/orders/[batchId]/route.ts +31 -0
  66. package/src/api/admin/faire/ingest/orders/route.ts +29 -0
  67. package/src/api/admin/faire/products/route.ts +14 -0
  68. package/src/api/admin/faire/settings/route.ts +17 -0
  69. package/src/api/admin/faire/status/product/[id]/route.ts +30 -0
  70. package/src/api/admin/faire/status/route.ts +47 -0
  71. package/src/api/admin/faire/sync/bulk/[batchId]/route.ts +33 -0
  72. package/src/api/admin/faire/sync/bulk/route.ts +27 -0
  73. package/src/api/admin/faire/sync/product/[id]/route.ts +13 -0
  74. package/src/api/admin/faire/syncs/[id]/route.ts +32 -0
  75. package/src/api/admin/faire/syncs/route.ts +17 -0
  76. package/src/api/webhooks/faire/route.ts +133 -0
  77. package/src/jobs/pull-faire-orders.ts +30 -0
  78. package/src/jobs/refresh-faire-token.ts +23 -0
  79. package/src/lib/faire-client.ts +436 -0
  80. package/src/lib/types.ts +117 -0
  81. package/src/lib/webhook.ts +85 -0
  82. package/src/links/product-faire-sync-link.ts +56 -0
  83. package/src/modules/faire-sync/index.ts +11 -0
  84. package/src/modules/faire-sync/migrations/.snapshot-medusa-faire-sync.json +1486 -0
  85. package/src/modules/faire-sync/migrations/Migration20260707211346.ts +43 -0
  86. package/src/modules/faire-sync/models/faire-order.ts +20 -0
  87. package/src/modules/faire-sync/models/faire-sync-account.ts +16 -0
  88. package/src/modules/faire-sync/models/faire-sync-batch.ts +17 -0
  89. package/src/modules/faire-sync/models/faire-sync-record.ts +21 -0
  90. package/src/modules/faire-sync/models/faire-sync-settings.ts +25 -0
  91. package/src/modules/faire-sync/models/faire-webhook-event.ts +20 -0
  92. package/src/modules/faire-sync/service.ts +252 -0
  93. package/src/subscribers/faire-inventory-sync.ts +97 -0
  94. package/src/subscribers/faire-listing-events.ts +48 -0
  95. package/src/subscribers/faire-order-ingest.ts +61 -0
  96. package/src/subscribers/faire-order-lifecycle.ts +81 -0
  97. package/src/subscribers/faire-product-status-sync.ts +76 -0
  98. package/src/workflows/index.ts +4 -0
  99. package/src/workflows/ingest-faire-order-support.ts +96 -0
  100. package/src/workflows/ingest-faire-order.ts +212 -0
  101. package/src/workflows/ingest-faire-orders-bulk.ts +168 -0
  102. package/src/workflows/refresh-faire-token.ts +28 -0
  103. package/src/workflows/sync-product-to-faire.ts +375 -0
  104. package/src/workflows/sync-products-to-faire.ts +133 -0
@@ -0,0 +1,36 @@
1
+ const fs = require("fs")
2
+ const path = require("path")
3
+
4
+ const serverDir = path.join(__dirname, "..", ".medusa", "server")
5
+ const srcDir = path.join(serverDir, "src")
6
+
7
+ if (!fs.existsSync(serverDir)) {
8
+ process.exit(0)
9
+ }
10
+
11
+ if (!fs.existsSync(srcDir)) {
12
+ fs.mkdirSync(srcDir, { recursive: true })
13
+ }
14
+
15
+ const dirsToMove = [
16
+ "api",
17
+ "jobs",
18
+ "lib",
19
+ "links",
20
+ "modules",
21
+ "workflows",
22
+ "__tests__",
23
+ ]
24
+
25
+ for (const dir of dirsToMove) {
26
+ const from = path.join(serverDir, dir)
27
+ const to = path.join(srcDir, dir)
28
+ if (fs.existsSync(from)) {
29
+ if (fs.existsSync(to)) {
30
+ fs.rmSync(to, { recursive: true, force: true })
31
+ }
32
+ fs.renameSync(from, to)
33
+ }
34
+ }
35
+
36
+ console.log("postbuild: restructured server output into src/")
@@ -0,0 +1,77 @@
1
+ import { mapFaireOrderToOrder, faireMoney } from "../workflows/ingest-faire-order-support"
2
+
3
+ describe("faireMoney", () => {
4
+ it("converts integer cents to decimal major units", () => {
5
+ expect(faireMoney(2500)).toBe(25)
6
+ expect(faireMoney(4999)).toBe(49.99)
7
+ })
8
+ it("coerces undefined/NaN to 0", () => {
9
+ expect(faireMoney(undefined)).toBe(0)
10
+ expect(faireMoney(NaN)).toBe(0)
11
+ })
12
+ })
13
+
14
+ describe("mapFaireOrderToOrder", () => {
15
+ it("maps items, address and idempotency token", () => {
16
+ const order = {
17
+ order_token: "o_123",
18
+ currency: "USD",
19
+ total_cents: 4999,
20
+ buyer_name: "Ada Lovelace",
21
+ address: {
22
+ first_name: "Ada",
23
+ last_name: "Lovelace",
24
+ street1: "1 Main St",
25
+ city: "London",
26
+ state: "ENG",
27
+ zip: "EC1",
28
+ country: "GB",
29
+ },
30
+ items: [
31
+ {
32
+ id: "i_1",
33
+ product_token: "p_1",
34
+ sku: "SKU-1",
35
+ quantity: 2,
36
+ wholesale_price_cents: 1250,
37
+ name: "Cushion",
38
+ },
39
+ ],
40
+ }
41
+
42
+ const mapped = mapFaireOrderToOrder(order)
43
+
44
+ expect(mapped.order_token).toBe("o_123")
45
+ expect(mapped.currency_code).toBe("usd")
46
+ expect(mapped.total).toBe(49.99)
47
+ expect(mapped.buyer_name).toBe("Ada Lovelace")
48
+ expect(mapped.email).toBe("faire+o_123@marketplace.invalid")
49
+ expect(mapped.shipping_address.country_code).toBe("gb")
50
+ expect(mapped.items).toHaveLength(1)
51
+ expect(mapped.items[0]).toMatchObject({
52
+ title: "Cushion",
53
+ quantity: 2,
54
+ unit_price: 12.5,
55
+ })
56
+ expect(mapped.items[0].metadata).toMatchObject({
57
+ faire_order_token: "o_123",
58
+ faire_product_token: "p_1",
59
+ faire_sku: "SKU-1",
60
+ })
61
+ })
62
+
63
+ it("falls back when address fields are missing", () => {
64
+ const mapped = mapFaireOrderToOrder({ order_token: "o_1", items: [] })
65
+ expect(mapped.shipping_address.first_name).toBe("Faire")
66
+ expect(mapped.items).toEqual([])
67
+ })
68
+
69
+ it("uses buyer email when provided", () => {
70
+ const mapped = mapFaireOrderToOrder({
71
+ order_token: "o_2",
72
+ buyer_email: "retailer@example.com",
73
+ items: [{ quantity: 1, name: "x" }],
74
+ })
75
+ expect(mapped.email).toBe("retailer@example.com")
76
+ })
77
+ })
@@ -0,0 +1,81 @@
1
+ import { verifyFaireWebhook } from "../lib/webhook"
2
+ import crypto from "crypto"
3
+
4
+ const secret = "faire-webhook-secret"
5
+
6
+ const sign = (body: string, enc: "hex" | "base64" = "hex") =>
7
+ crypto.createHmac("sha256", Buffer.from(secret, "utf8")).update(body, "utf8").digest(enc)
8
+
9
+ describe("verifyFaireWebhook", () => {
10
+ it("accepts a valid hex signature", () => {
11
+ const body = '{"event_type":"ORDER_PLACED"}'
12
+ const res = verifyFaireWebhook({
13
+ secret,
14
+ headers: { signature: sign(body, "hex") },
15
+ rawBody: body,
16
+ })
17
+ expect(res.valid).toBe(true)
18
+ })
19
+
20
+ it("accepts a valid base64 signature", () => {
21
+ const body = '{"event_type":"ORDER_PLACED"}'
22
+ const res = verifyFaireWebhook({
23
+ secret,
24
+ headers: { signature: sign(body, "base64") },
25
+ rawBody: body,
26
+ })
27
+ expect(res.valid).toBe(true)
28
+ })
29
+
30
+ it("accepts svix-style v1,<sig> entries", () => {
31
+ const body = '{"event_type":"ORDER_PLACED"}'
32
+ const res = verifyFaireWebhook({
33
+ secret,
34
+ headers: { signature: `v1,${sign(body, "hex")}` },
35
+ rawBody: body,
36
+ })
37
+ expect(res.valid).toBe(true)
38
+ })
39
+
40
+ it("rejects a tampered body", () => {
41
+ const body = '{"event_type":"ORDER_PLACED"}'
42
+ const res = verifyFaireWebhook({
43
+ secret,
44
+ headers: { signature: sign(body, "hex") },
45
+ rawBody: body + "tampered",
46
+ })
47
+ expect(res.valid).toBe(false)
48
+ expect(res.reason).toBe("signature mismatch")
49
+ })
50
+
51
+ it("rejects when signature header is missing", () => {
52
+ const res = verifyFaireWebhook({
53
+ secret,
54
+ headers: {},
55
+ rawBody: "{}",
56
+ })
57
+ expect(res.valid).toBe(false)
58
+ expect(res.reason).toMatch(/missing/)
59
+ })
60
+
61
+ it("rejects when secret is empty", () => {
62
+ const res = verifyFaireWebhook({
63
+ secret: "",
64
+ headers: { signature: "abc" },
65
+ rawBody: "{}",
66
+ })
67
+ expect(res.valid).toBe(false)
68
+ expect(res.reason).toMatch(/secret/)
69
+ })
70
+
71
+ it("enforces timestamp tolerance when a timestamp is present", () => {
72
+ const body = "{}"
73
+ const res = verifyFaireWebhook({
74
+ secret,
75
+ headers: { signature: sign(body, "hex"), timestamp: String(Math.floor(Date.now() / 1000) - 600) },
76
+ rawBody: body,
77
+ })
78
+ expect(res.valid).toBe(false)
79
+ expect(res.reason).toMatch(/timestamp/)
80
+ })
81
+ })
@@ -0,0 +1,88 @@
1
+ import Medusa from "@medusajs/js-sdk"
2
+
3
+ export const sdk = new Medusa({
4
+ baseUrl: "/",
5
+ auth: {
6
+ type: "session",
7
+ },
8
+ })
9
+
10
+ export const faireApi = {
11
+ status: () =>
12
+ sdk.client.fetch<{
13
+ connected: boolean
14
+ account: any | null
15
+ settings: any
16
+ readiness: {
17
+ connected: boolean
18
+ brand: boolean
19
+ wholesale_pricing: boolean
20
+ shipping_policy: boolean
21
+ ready_to_publish: boolean
22
+ }
23
+ }>("/admin/faire/status"),
24
+ getSettings: () =>
25
+ sdk.client.fetch<Record<string, any>>("/admin/faire/settings"),
26
+ saveSettings: (settings: Record<string, any>) =>
27
+ sdk.client.fetch("/admin/faire/settings", {
28
+ method: "POST",
29
+ body: settings,
30
+ }),
31
+ authorize: () =>
32
+ sdk.client.fetch<{ authorization_url: string; state: string }>(
33
+ "/admin/faire/auth/authorize"
34
+ ),
35
+ callback: (code: string, state: string) =>
36
+ sdk.client.fetch("/admin/faire/auth/callback", {
37
+ method: "POST",
38
+ body: { code, state },
39
+ }),
40
+ disconnect: () =>
41
+ sdk.client.fetch("/admin/faire/auth/disconnect", { method: "POST" }),
42
+ syncProduct: (id: string) =>
43
+ sdk.client.fetch(`/admin/faire/sync/product/${id}`, { method: "POST" }),
44
+ productStatus: (id: string) =>
45
+ sdk.client.fetch<{
46
+ connected: boolean
47
+ synced: boolean
48
+ latest: any | null
49
+ }>(`/admin/faire/status/product/${id}`),
50
+ syncBulk: (product_ids: string[]) =>
51
+ sdk.client.fetch<{ batch_id: string; status: string }>(
52
+ "/admin/faire/sync/bulk",
53
+ {
54
+ method: "POST",
55
+ body: { product_ids },
56
+ }
57
+ ),
58
+ bulkStatus: (batchId: string) =>
59
+ sdk.client.fetch<{ batch: any; progress: any }>(
60
+ `/admin/faire/sync/bulk/${batchId}`
61
+ ),
62
+ ingestOrders: (limit?: number) =>
63
+ sdk.client.fetch<{ batch_id: string; status: string }>(
64
+ "/admin/faire/ingest/orders",
65
+ { method: "POST", body: limit != null ? { limit } : {} }
66
+ ),
67
+ ingestStatus: (batchId: string) =>
68
+ sdk.client.fetch<{ batch: any; progress: any }>(
69
+ `/admin/faire/ingest/orders/${batchId}`
70
+ ),
71
+ listSyncs: (opts: { take?: number; skip?: number; status?: string } = {}) => {
72
+ const query: Record<string, string> = {}
73
+ if (opts.take !== undefined) query.take = String(opts.take)
74
+ if (opts.skip !== undefined) query.skip = String(opts.skip)
75
+ if (opts.status) query.status = opts.status
76
+ return sdk.client.fetch("/admin/faire/syncs", { query })
77
+ },
78
+ getSync: (id: string) => sdk.client.fetch(`/admin/faire/syncs/${id}`),
79
+ retrySync: (id: string) =>
80
+ sdk.client.fetch(`/admin/faire/syncs/${id}`, { method: "POST" }),
81
+ brand: () => sdk.client.fetch("/admin/faire/brand"),
82
+ products: (opts: { limit?: number; page?: number } = {}) => {
83
+ const query: Record<string, string> = {}
84
+ if (opts.limit !== undefined) query.limit = String(opts.limit)
85
+ if (opts.page !== undefined) query.page = String(opts.page)
86
+ return sdk.client.fetch("/admin/faire/products", { query })
87
+ },
88
+ }
@@ -0,0 +1,184 @@
1
+ import {
2
+ Button,
3
+ Container,
4
+ DropdownMenu,
5
+ Heading,
6
+ IconButton,
7
+ Label,
8
+ Skeleton,
9
+ StatusBadge,
10
+ Text,
11
+ Toaster,
12
+ } from "@medusajs/ui"
13
+ import { EllipsisHorizontal, ArrowPath, ArrowUpRightOnBox } from "@medusajs/icons"
14
+ import { useEffect, useState } from "react"
15
+ import { useNavigate, useParams } from "react-router-dom"
16
+ import { faireApi } from "../../../../lib/api"
17
+ import type { FaireSyncRecord } from "../hooks/use-faire-sync-columns"
18
+
19
+ const STATUS_COLORS: Record<string, "green" | "red" | "orange" | "grey"> = {
20
+ success: "green",
21
+ failed: "red",
22
+ draft: "orange",
23
+ pending: "grey",
24
+ syncing: "grey",
25
+ }
26
+
27
+ const FaireSyncDetailPage = () => {
28
+ const { id } = useParams<{ id: string }>()
29
+ const navigate = useNavigate()
30
+ const [record, setRecord] = useState<FaireSyncRecord | null>(null)
31
+ const [loading, setLoading] = useState(true)
32
+ const [retrying, setRetrying] = useState(false)
33
+ const [error, setError] = useState<string | null>(null)
34
+
35
+ useEffect(() => {
36
+ if (!id) return
37
+ setLoading(true)
38
+ faireApi
39
+ .getSync(id)
40
+ .then((res: any) => {
41
+ setRecord(res.sync || res)
42
+ })
43
+ .catch((err: any) => setError(err.message || "Failed to load sync record"))
44
+ .finally(() => setLoading(false))
45
+ }, [id])
46
+
47
+ const handleRetry = async () => {
48
+ if (!record) return
49
+ setRetrying(true)
50
+ setError(null)
51
+ try {
52
+ await faireApi.retrySync(record.id)
53
+ const updated = await faireApi.getSync(record.id).catch(() => null)
54
+ if (updated) setRecord((updated as any).sync || updated)
55
+ } catch (err: any) {
56
+ setError(err.message || "Retry failed")
57
+ } finally {
58
+ setRetrying(false)
59
+ }
60
+ }
61
+
62
+ if (loading) {
63
+ return (
64
+ <Container className="p-6">
65
+ <Skeleton className="h-7 w-12 rounded-md" />
66
+ </Container>
67
+ )
68
+ }
69
+
70
+ if (!record) {
71
+ return (
72
+ <Container className="p-6 flex flex-col gap-4">
73
+ <Heading level="h1">Sync record not found</Heading>
74
+ <Button size="small" variant="secondary" onClick={() => navigate("/settings/faire")}>
75
+ Back to Faire settings
76
+ </Button>
77
+ </Container>
78
+ )
79
+ }
80
+
81
+ const warnings: string[] = record.error_message
82
+ ? record.error_message.split(" | ")
83
+ : []
84
+
85
+ return (
86
+ <div className="flex flex-col gap-4">
87
+ {error && (
88
+ <Container className="p-4">
89
+ <Text className="text-ui-tag-red-text">{error}</Text>
90
+ </Container>
91
+ )}
92
+
93
+ <Container className="divide-y p-0">
94
+ <div className="flex items-center justify-between px-6 py-4">
95
+ <div className="flex flex-col gap-1">
96
+ <Heading level="h1">Sync record</Heading>
97
+ <Text className="text-ui-fg-subtle font-mono text-xs">{record.id}</Text>
98
+ </div>
99
+ <div className="flex items-center gap-3">
100
+ <StatusBadge color={STATUS_COLORS[record.status] || "grey"}>
101
+ {record.status}
102
+ </StatusBadge>
103
+ <DropdownMenu>
104
+ <DropdownMenu.Trigger asChild>
105
+ <IconButton size="small" variant="transparent" disabled={retrying}>
106
+ <EllipsisHorizontal />
107
+ </IconButton>
108
+ </DropdownMenu.Trigger>
109
+ <DropdownMenu.Content align="end">
110
+ <DropdownMenu.Item
111
+ className="gap-x-2"
112
+ onClick={handleRetry}
113
+ disabled={retrying}
114
+ >
115
+ <ArrowPath className="text-ui-fg-subtle" />
116
+ {retrying ? "Re-syncing…" : "Re-sync product"}
117
+ </DropdownMenu.Item>
118
+ {record.product_url && (
119
+ <DropdownMenu.Item
120
+ className="gap-x-2"
121
+ onClick={() => window.open(record.product_url!, "_blank")}
122
+ >
123
+ <ArrowUpRightOnBox className="text-ui-fg-subtle" />
124
+ Open on Faire
125
+ </DropdownMenu.Item>
126
+ )}
127
+ </DropdownMenu.Content>
128
+ </DropdownMenu>
129
+ </div>
130
+ </div>
131
+
132
+ <div className="px-6 py-4 grid grid-cols-2 gap-4">
133
+ <Detail label="Product" value={record.product_id} mono />
134
+ <Detail label="Action" value={record.action} />
135
+ <Detail label="Faire product token" value={record.product_token || "—"} mono />
136
+ <Detail label="State" value={record.product_state || "—"} />
137
+ <Detail label="Published" value={record.published ? "Yes" : "No"} />
138
+ <Detail
139
+ label="Synced at"
140
+ value={record.synced_at ? new Date(record.synced_at).toLocaleString() : "—"}
141
+ />
142
+ </div>
143
+ </Container>
144
+
145
+ {warnings.length > 0 && (
146
+ <Container className="divide-y p-0">
147
+ <div className="px-6 py-4">
148
+ <Heading level="h2">Issues</Heading>
149
+ </div>
150
+ <div className="px-6 py-4 flex flex-col gap-2">
151
+ {warnings.map((w, i) => (
152
+ <Text key={i} className="text-ui-tag-red-text text-sm">
153
+ {w}
154
+ </Text>
155
+ ))}
156
+ </div>
157
+ </Container>
158
+ )}
159
+
160
+ <Toaster />
161
+ </div>
162
+ )
163
+ }
164
+
165
+ const Detail = ({
166
+ label,
167
+ value,
168
+ mono,
169
+ }: {
170
+ label: string
171
+ value: string
172
+ mono?: boolean
173
+ }) => (
174
+ <div className="flex flex-col gap-1">
175
+ <Label>{label}</Label>
176
+ <Text className={mono ? "font-mono text-xs" : ""}>{value}</Text>
177
+ </div>
178
+ )
179
+
180
+ export const handle = {
181
+ breadcrumb: () => "Sync record",
182
+ }
183
+
184
+ export default FaireSyncDetailPage
@@ -0,0 +1,80 @@
1
+ import { createDataTableColumnHelper, StatusBadge } from "@medusajs/ui"
2
+
3
+ export type FaireSyncRecord = {
4
+ id: string
5
+ product_id: string
6
+ account_id: string
7
+ product_token: string | null
8
+ product_url: string | null
9
+ product_state: string | null
10
+ action: string
11
+ status: string
12
+ published: boolean
13
+ error_message: string | null
14
+ synced_at: string | null
15
+ created_at: string
16
+ }
17
+
18
+ const columnHelper = createDataTableColumnHelper<FaireSyncRecord>()
19
+
20
+ export const useFaireSyncColumns = () => {
21
+ return [
22
+ columnHelper.accessor("product_id", {
23
+ header: "Product",
24
+ cell: (info) => (
25
+ <span className="font-mono text-ui-fg-subtle text-xs">
26
+ {info.getValue()}
27
+ </span>
28
+ ),
29
+ }),
30
+ columnHelper.accessor("status", {
31
+ header: "Status",
32
+ cell: (info) => {
33
+ const status = info.getValue()
34
+ const color =
35
+ status === "success"
36
+ ? "green"
37
+ : status === "failed"
38
+ ? "red"
39
+ : status === "draft"
40
+ ? "orange"
41
+ : "grey"
42
+ return <StatusBadge color={color as any}>{status}</StatusBadge>
43
+ },
44
+ }),
45
+ columnHelper.accessor("product_state", {
46
+ header: "State",
47
+ cell: (info) => info.getValue() || "—",
48
+ }),
49
+ columnHelper.display({
50
+ id: "product",
51
+ header: "Faire product",
52
+ cell: ({ row }: any) => {
53
+ const r = row.original as FaireSyncRecord
54
+ if (!r.product_url) return "—"
55
+ return (
56
+ <a
57
+ href={r.product_url}
58
+ target="_blank"
59
+ rel="noreferrer"
60
+ onClick={(e) => e.stopPropagation()}
61
+ className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
62
+ >
63
+ {r.product_token}
64
+ </a>
65
+ )
66
+ },
67
+ }),
68
+ columnHelper.accessor("synced_at", {
69
+ header: "Synced",
70
+ cell: (info) => {
71
+ const v = info.getValue()
72
+ return v ? new Date(v).toLocaleString() : "—"
73
+ },
74
+ enableSorting: true,
75
+ sortLabel: "Synced",
76
+ sortAscLabel: "Oldest first",
77
+ sortDescLabel: "Newest first",
78
+ }),
79
+ ]
80
+ }