@jytextiles/medusa-plugin-faire-store-sync 0.2.1 → 0.2.3

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.
@@ -1,10 +1,10 @@
1
1
  import { jsxs, jsx, Fragment } from "react/jsx-runtime";
2
2
  import { defineWidgetConfig, defineRouteConfig } from "@medusajs/admin-sdk";
3
- import { Container, Heading, Text, StatusBadge, Alert, Tooltip, Button, createDataTableColumnHelper, toast, createDataTableFilterHelper, useDataTable, Skeleton, DataTable, Toaster, Label, DropdownMenu, IconButton, Drawer, Switch, clx } from "@medusajs/ui";
3
+ import { Container, Heading, Text, StatusBadge, Alert, Tooltip, Button, createDataTableColumnHelper, toast, createDataTableFilterHelper, useDataTable, Skeleton, DataTable, Toaster, Label, DropdownMenu, IconButton, FocusModal, Input, Badge, Drawer, Switch, clx } from "@medusajs/ui";
4
4
  import { useQueryClient, useQuery, useMutation, keepPreviousData } from "@tanstack/react-query";
5
5
  import Medusa from "@medusajs/js-sdk";
6
6
  import { BuildingStorefront, EllipsisHorizontal, ArrowPath, ArrowUpRightOnBox } from "@medusajs/icons";
7
- import { useState, useEffect, useRef } from "react";
7
+ import { useState, useEffect, useMemo } from "react";
8
8
  import { useNavigate, useParams, useSearchParams } from "react-router-dom";
9
9
  const sdk = new Medusa({
10
10
  baseUrl: "/",
@@ -174,14 +174,14 @@ const FaireProductWidget = ({ data }) => {
174
174
  defineWidgetConfig({
175
175
  zone: "product.details.side.before"
176
176
  });
177
- const columnHelper = createDataTableColumnHelper();
177
+ const columnHelper$1 = createDataTableColumnHelper();
178
178
  const useFaireSyncColumns = () => {
179
179
  return [
180
- columnHelper.accessor("product_id", {
180
+ columnHelper$1.accessor("product_id", {
181
181
  header: "Product",
182
182
  cell: (info) => /* @__PURE__ */ jsx("span", { className: "font-mono text-ui-fg-subtle text-xs", children: info.getValue() })
183
183
  }),
184
- columnHelper.accessor("status", {
184
+ columnHelper$1.accessor("status", {
185
185
  header: "Status",
186
186
  cell: (info) => {
187
187
  const status = info.getValue();
@@ -189,11 +189,11 @@ const useFaireSyncColumns = () => {
189
189
  return /* @__PURE__ */ jsx(StatusBadge, { color, children: status });
190
190
  }
191
191
  }),
192
- columnHelper.accessor("product_state", {
192
+ columnHelper$1.accessor("product_state", {
193
193
  header: "State",
194
194
  cell: (info) => info.getValue() || "—"
195
195
  }),
196
- columnHelper.display({
196
+ columnHelper$1.display({
197
197
  id: "product",
198
198
  header: "Faire product",
199
199
  cell: ({ row }) => {
@@ -212,7 +212,7 @@ const useFaireSyncColumns = () => {
212
212
  );
213
213
  }
214
214
  }),
215
- columnHelper.accessor("synced_at", {
215
+ columnHelper$1.accessor("synced_at", {
216
216
  header: "Synced",
217
217
  cell: (info) => {
218
218
  const v = info.getValue();
@@ -225,7 +225,7 @@ const useFaireSyncColumns = () => {
225
225
  })
226
226
  ];
227
227
  };
228
- const PAGE_SIZE = 10;
228
+ const PAGE_SIZE$1 = 10;
229
229
  const STATUS_KEY$1 = ["faire", "status"];
230
230
  const FaireSettingsPage = () => {
231
231
  var _a, _b, _c;
@@ -233,7 +233,7 @@ const FaireSettingsPage = () => {
233
233
  const queryClient = useQueryClient();
234
234
  const [pagination, setPagination] = useState({
235
235
  pageIndex: 0,
236
- pageSize: PAGE_SIZE
236
+ pageSize: PAGE_SIZE$1
237
237
  });
238
238
  const [filtering, setFiltering] = useState({});
239
239
  const statusQuery = useQuery({
@@ -335,6 +335,15 @@ const FaireSettingsPage = () => {
335
335
  ] }),
336
336
  connected ? /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
337
337
  /* @__PURE__ */ jsx(StatusBadge, { color: "green", children: "Connected" }),
338
+ /* @__PURE__ */ jsx(
339
+ Button,
340
+ {
341
+ size: "small",
342
+ variant: "secondary",
343
+ onClick: () => navigate("/settings/faire/bulk"),
344
+ children: "Bulk sync"
345
+ }
346
+ ),
338
347
  /* @__PURE__ */ jsx(
339
348
  Button,
340
349
  {
@@ -377,7 +386,6 @@ const FaireSettingsPage = () => {
377
386
  )
378
387
  ] })
379
388
  ] }),
380
- /* @__PURE__ */ jsx(BulkOperations, { connected }),
381
389
  /* @__PURE__ */ jsx(Container, { className: "divide-y p-0", children: /* @__PURE__ */ jsxs(DataTable, { instance: table, children: [
382
390
  /* @__PURE__ */ jsxs(DataTable.Toolbar, { className: "flex flex-col md:flex-row justify-between gap-y-4 px-6 py-4", children: [
383
391
  /* @__PURE__ */ jsxs("div", { children: [
@@ -408,153 +416,11 @@ const InfoField = ({ label, value }) => /* @__PURE__ */ jsxs("div", { className:
408
416
  /* @__PURE__ */ jsx(Label, { children: label }),
409
417
  /* @__PURE__ */ jsx(Text, { children: value })
410
418
  ] });
411
- const BulkOperations = ({ connected }) => {
412
- var _a, _b, _c, _d, _e, _f;
413
- const [productIds, setProductIds] = useState("");
414
- const [pushBatch, setPushBatch] = useState(null);
415
- const [pullBatch, setPullBatch] = useState(null);
416
- const pushMutation = useMutation({
417
- mutationFn: (ids) => faireApi.syncBulk(ids),
418
- onSuccess: (res) => {
419
- setPushBatch(res.batch_id);
420
- toast.success("Bulk product sync started", {
421
- description: "Running in the background. Polling progress…"
422
- });
423
- },
424
- onError: (err) => toast.error("Failed to start bulk sync", { description: err.message })
425
- });
426
- const pullMutation = useMutation({
427
- mutationFn: () => faireApi.ingestOrders(),
428
- onSuccess: (res) => {
429
- setPullBatch(res.batch_id);
430
- toast.success("Faire order pull started", {
431
- description: "Running in the background. Polling progress…"
432
- });
433
- },
434
- onError: (err) => toast.error("Failed to start order pull", { description: err.message })
435
- });
436
- const pushStatus = useQuery({
437
- queryKey: ["faire", "bulk", pushBatch],
438
- enabled: !!pushBatch,
439
- refetchInterval: (q) => {
440
- var _a2, _b2;
441
- return ((_b2 = (_a2 = q.state.data) == null ? void 0 : _a2.progress) == null ? void 0 : _b2.finished) ? false : 3e3;
442
- },
443
- queryFn: () => faireApi.bulkStatus(pushBatch)
444
- });
445
- const pullStatus = useQuery({
446
- queryKey: ["faire", "ingest", pullBatch],
447
- enabled: !!pullBatch,
448
- refetchInterval: (q) => {
449
- var _a2, _b2;
450
- return ((_b2 = (_a2 = q.state.data) == null ? void 0 : _a2.progress) == null ? void 0 : _b2.finished) ? false : 3e3;
451
- },
452
- queryFn: () => faireApi.ingestStatus(pullBatch)
453
- });
454
- const pushProgress = (_a = pushStatus.data) == null ? void 0 : _a.progress;
455
- const pullProgress = (_b = pullStatus.data) == null ? void 0 : _b.progress;
456
- const handlePush = () => {
457
- const ids = productIds.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
458
- if (!ids.length) {
459
- toast.error("Enter at least one product id");
460
- return;
461
- }
462
- pushMutation.mutate(ids);
463
- };
464
- return /* @__PURE__ */ jsxs(Container, { className: "divide-y p-0", children: [
465
- /* @__PURE__ */ jsxs("div", { className: "px-6 py-4", children: [
466
- /* @__PURE__ */ jsx(Heading, { children: "Bulk operations" }),
467
- /* @__PURE__ */ jsx(Text, { className: "text-ui-fg-subtle", size: "small", children: "Push many products to Faire, or pull wholesale orders from Faire into Medusa. Both run as long-running background workflows." })
468
- ] }),
469
- /* @__PURE__ */ jsxs("div", { className: "px-6 py-4 flex flex-col gap-6", children: [
470
- /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-3", children: [
471
- /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1", children: [
472
- /* @__PURE__ */ jsx(Label, { children: "Push products to Faire" }),
473
- /* @__PURE__ */ jsx(Text, { className: "text-ui-fg-subtle", size: "small", children: "Comma- or line-separated Medusa product IDs." })
474
- ] }),
475
- /* @__PURE__ */ jsx(
476
- "textarea",
477
- {
478
- className: "border-ui-border-base rounded-lg border px-3 py-2 text-sm min-h-[72px]",
479
- placeholder: "prod_01..., prod_02...",
480
- value: productIds,
481
- onChange: (e) => setProductIds(e.target.value),
482
- disabled: !connected || pushMutation.isPending
483
- }
484
- ),
485
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
486
- /* @__PURE__ */ jsx(
487
- Button,
488
- {
489
- size: "small",
490
- onClick: handlePush,
491
- disabled: !connected || pushMutation.isPending,
492
- isLoading: pushMutation.isPending,
493
- children: "Start bulk push"
494
- }
495
- ),
496
- pushBatch && pushProgress && /* @__PURE__ */ jsx(
497
- BulkProgress,
498
- {
499
- label: "Push",
500
- progress: pushProgress,
501
- status: (_d = (_c = pushStatus.data) == null ? void 0 : _c.batch) == null ? void 0 : _d.status
502
- }
503
- )
504
- ] })
505
- ] }),
506
- /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-3", children: [
507
- /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1", children: [
508
- /* @__PURE__ */ jsx(Label, { children: "Pull Faire orders into Medusa" }),
509
- /* @__PURE__ */ jsx(Text, { className: "text-ui-fg-subtle", size: "small", children: "Ingests all available Faire orders as Medusa orders (idempotent)." })
510
- ] }),
511
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
512
- /* @__PURE__ */ jsx(
513
- Button,
514
- {
515
- size: "small",
516
- variant: "secondary",
517
- onClick: () => pullMutation.mutate(),
518
- disabled: !connected || pullMutation.isPending,
519
- isLoading: pullMutation.isPending,
520
- children: "Start order pull"
521
- }
522
- ),
523
- pullBatch && pullProgress && /* @__PURE__ */ jsx(
524
- BulkProgress,
525
- {
526
- label: "Pull",
527
- progress: pullProgress,
528
- status: (_f = (_e = pullStatus.data) == null ? void 0 : _e.batch) == null ? void 0 : _f.status
529
- }
530
- )
531
- ] })
532
- ] })
533
- ] })
534
- ] });
535
- };
536
- const BulkProgress = ({
537
- label,
538
- progress,
539
- status
540
- }) => /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
541
- /* @__PURE__ */ jsxs(StatusBadge, { color: progress.finished ? status === "failed" ? "red" : "green" : "orange", children: [
542
- label,
543
- ": ",
544
- progress.done,
545
- "/",
546
- progress.total || "?",
547
- " (",
548
- progress.pct,
549
- "%)"
550
- ] }),
551
- progress.finished && /* @__PURE__ */ jsx(Text, { className: "text-ui-fg-subtle", size: "small", children: status === "failed" ? "Completed with errors" : "Done" })
552
- ] });
553
419
  const config = defineRouteConfig({
554
420
  label: "Faire",
555
421
  icon: BuildingStorefront
556
422
  });
557
- const handle$2 = {
423
+ const handle$3 = {
558
424
  breadcrumb: () => "Faire Sync"
559
425
  };
560
426
  const STATUS_COLORS = {
@@ -672,9 +538,308 @@ const Detail = ({
672
538
  /* @__PURE__ */ jsx(Label, { children: label }),
673
539
  /* @__PURE__ */ jsx(Text, { className: mono ? "font-mono text-xs" : "", children: value })
674
540
  ] });
675
- const handle$1 = {
541
+ const handle$2 = {
676
542
  breadcrumb: () => "Sync record"
677
543
  };
544
+ const Root = ({ prev = "..", children }) => {
545
+ const navigate = useNavigate();
546
+ const [open, setOpen] = useState(false);
547
+ useEffect(() => {
548
+ setOpen(true);
549
+ return () => setOpen(false);
550
+ }, []);
551
+ const handleOpenChange = (next) => {
552
+ if (!next) {
553
+ document.body.style.pointerEvents = "auto";
554
+ navigate(prev, { replace: true });
555
+ return;
556
+ }
557
+ setOpen(next);
558
+ };
559
+ return /* @__PURE__ */ jsx(FocusModal, { open, onOpenChange: handleOpenChange, children: /* @__PURE__ */ jsx(FocusModal.Content, { className: "flex flex-col", children }) });
560
+ };
561
+ const RouteFocusModal = Object.assign(Root, {
562
+ Header: FocusModal.Header,
563
+ Title: FocusModal.Title,
564
+ Description: FocusModal.Description,
565
+ Body: FocusModal.Body,
566
+ Footer: FocusModal.Footer,
567
+ Close: FocusModal.Close
568
+ });
569
+ const columnHelper = createDataTableColumnHelper();
570
+ const useBulkProductColumns = () => {
571
+ return [
572
+ columnHelper.accessor("title", {
573
+ header: "Product",
574
+ cell: ({ row, getValue }) => {
575
+ const p = row.original;
576
+ return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
577
+ p.thumbnail ? /* @__PURE__ */ jsx(
578
+ "img",
579
+ {
580
+ src: p.thumbnail,
581
+ alt: "",
582
+ className: "h-8 w-8 rounded border border-ui-border-base object-cover"
583
+ }
584
+ ) : /* @__PURE__ */ jsx("div", { className: "h-8 w-8 rounded border border-ui-border-base bg-ui-bg-subtle" }),
585
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-col", children: [
586
+ /* @__PURE__ */ jsx("span", { className: "font-medium", children: getValue() }),
587
+ p.handle && /* @__PURE__ */ jsx("span", { className: "text-ui-fg-subtle text-xs font-mono", children: p.handle })
588
+ ] })
589
+ ] });
590
+ }
591
+ }),
592
+ columnHelper.accessor("status", {
593
+ header: "Status",
594
+ cell: (info) => {
595
+ const s = info.getValue();
596
+ const color = s === "published" ? "green" : s === "draft" ? "orange" : "grey";
597
+ return /* @__PURE__ */ jsx(StatusBadge, { color, children: s });
598
+ }
599
+ }),
600
+ columnHelper.accessor("updated_at", {
601
+ header: "Updated",
602
+ cell: (info) => {
603
+ const v = info.getValue();
604
+ return v ? new Date(v).toLocaleString() : "—";
605
+ }
606
+ })
607
+ ];
608
+ };
609
+ const PAGE_SIZE = 20;
610
+ const PRODUCT_FIELDS = "id,title,status,thumbnail,handle,created_at,updated_at";
611
+ const SELECT_ALL_CAP = 2e3;
612
+ const listProducts = (query) => sdk.admin.product.list({
613
+ fields: PRODUCT_FIELDS,
614
+ ...query
615
+ });
616
+ const FaireBulkPage = () => {
617
+ var _a, _b, _c, _d, _e, _f, _g;
618
+ const queryClient = useQueryClient();
619
+ const [pagination, setPagination] = useState({
620
+ pageIndex: 0,
621
+ pageSize: PAGE_SIZE
622
+ });
623
+ const [filtering, setFiltering] = useState({});
624
+ const [search, setSearch] = useState("");
625
+ const [rowSelection, setRowSelection] = useState({});
626
+ const [selectAllMatching, setSelectAllMatching] = useState(false);
627
+ const statusFilter = (_a = filtering.status) == null ? void 0 : _a[0];
628
+ const statusQuery = useQuery({
629
+ queryKey: ["faire", "status"],
630
+ queryFn: () => faireApi.status()
631
+ });
632
+ const connected = !!((_b = statusQuery.data) == null ? void 0 : _b.connected);
633
+ const productsQuery = useQuery({
634
+ queryKey: ["faire", "bulk-products", pagination, statusFilter, search],
635
+ placeholderData: keepPreviousData,
636
+ queryFn: () => listProducts({
637
+ limit: pagination.pageSize,
638
+ offset: pagination.pageIndex * pagination.pageSize,
639
+ q: search || void 0,
640
+ status: statusFilter,
641
+ order: "-created_at"
642
+ })
643
+ });
644
+ const products = ((_c = productsQuery.data) == null ? void 0 : _c.products) ?? [];
645
+ const count = ((_d = productsQuery.data) == null ? void 0 : _d.count) ?? 0;
646
+ const selectedIds = useMemo(
647
+ () => Object.keys(rowSelection).filter((id) => rowSelection[id]),
648
+ [rowSelection]
649
+ );
650
+ const targetCount = selectAllMatching ? count : selectedIds.length;
651
+ const resolveTargetIds = async () => {
652
+ if (!selectAllMatching) return selectedIds;
653
+ const ids = [];
654
+ let offset = 0;
655
+ while (ids.length < count && ids.length < SELECT_ALL_CAP) {
656
+ const res = await listProducts({
657
+ limit: 200,
658
+ offset,
659
+ q: search || void 0,
660
+ status: statusFilter,
661
+ order: "-created_at"
662
+ });
663
+ const batch = res.products ?? [];
664
+ if (!batch.length) break;
665
+ ids.push(...batch.map((p) => p.id));
666
+ offset += batch.length;
667
+ }
668
+ return ids.slice(0, SELECT_ALL_CAP);
669
+ };
670
+ const pushMutation = useMutation({
671
+ mutationFn: async () => {
672
+ const ids = await resolveTargetIds();
673
+ if (!ids.length) throw new Error("No products to sync");
674
+ const res = await faireApi.syncBulk(ids);
675
+ return { res, queued: ids.length };
676
+ },
677
+ onSuccess: ({ res, queued }) => {
678
+ toast.success("Bulk product sync started", {
679
+ description: `Queued ${queued} product(s). Batch ${res.batch_id}.` + (selectAllMatching && count > SELECT_ALL_CAP ? ` Capped at ${SELECT_ALL_CAP} — run again for the rest.` : "")
680
+ });
681
+ queryClient.invalidateQueries({ queryKey: ["faire", "syncs"] });
682
+ setRowSelection({});
683
+ setSelectAllMatching(false);
684
+ },
685
+ onError: (err) => toast.error("Failed to start bulk sync", { description: err.message })
686
+ });
687
+ const [pullBatch, setPullBatch] = useState(null);
688
+ const pullMutation = useMutation({
689
+ mutationFn: () => faireApi.ingestOrders(),
690
+ onSuccess: (res) => {
691
+ setPullBatch(res.batch_id);
692
+ toast.success("Faire order pull started", {
693
+ description: "Running in the background. Polling progress…"
694
+ });
695
+ },
696
+ onError: (err) => toast.error("Failed to start order pull", { description: err.message })
697
+ });
698
+ const pullStatus = useQuery({
699
+ queryKey: ["faire", "ingest", pullBatch],
700
+ enabled: !!pullBatch,
701
+ refetchInterval: (q) => {
702
+ var _a2, _b2;
703
+ return ((_b2 = (_a2 = q.state.data) == null ? void 0 : _a2.progress) == null ? void 0 : _b2.finished) ? false : 3e3;
704
+ },
705
+ queryFn: () => faireApi.ingestStatus(pullBatch)
706
+ });
707
+ const pullProgress = (_e = pullStatus.data) == null ? void 0 : _e.progress;
708
+ const columns = useBulkProductColumns();
709
+ const commands = useMemo(
710
+ () => [
711
+ {
712
+ label: connected ? "Push to Faire" : "Connect Faire first",
713
+ shortcut: "p",
714
+ action: () => {
715
+ if (!connected) {
716
+ toast.error("Faire is not connected");
717
+ return;
718
+ }
719
+ pushMutation.mutate();
720
+ }
721
+ },
722
+ {
723
+ label: "Clear selection",
724
+ shortcut: "c",
725
+ action: () => {
726
+ setRowSelection({});
727
+ setSelectAllMatching(false);
728
+ }
729
+ }
730
+ ],
731
+ [connected, pushMutation]
732
+ );
733
+ const table = useDataTable({
734
+ data: products,
735
+ columns,
736
+ rowCount: count,
737
+ getRowId: (row) => row.id,
738
+ isLoading: productsQuery.isLoading,
739
+ commands,
740
+ rowSelection: {
741
+ state: rowSelection,
742
+ onRowSelectionChange: (updater) => {
743
+ setSelectAllMatching(false);
744
+ setRowSelection(updater);
745
+ }
746
+ },
747
+ pagination: { state: pagination, onPaginationChange: setPagination },
748
+ filtering: { state: filtering, onFilteringChange: setFiltering },
749
+ search: { state: search, onSearchChange: setSearch }
750
+ });
751
+ return /* @__PURE__ */ jsxs(RouteFocusModal, { prev: "/settings/faire", children: [
752
+ /* @__PURE__ */ jsx(RouteFocusModal.Header, { children: /* @__PURE__ */ jsxs("div", { className: "flex flex-col", children: [
753
+ /* @__PURE__ */ jsx(Heading, { children: "Bulk sync products to Faire" }),
754
+ /* @__PURE__ */ jsx(Text, { className: "text-ui-fg-subtle", size: "small", children: "Select products (or all matching) and push them to Faire as a background sync via the command bar." })
755
+ ] }) }),
756
+ /* @__PURE__ */ jsx(RouteFocusModal.Body, { className: "flex flex-1 flex-col overflow-hidden p-0", children: /* @__PURE__ */ jsxs(DataTable, { instance: table, children: [
757
+ /* @__PURE__ */ jsxs(DataTable.Toolbar, { className: "flex flex-col gap-y-3 border-b px-6 py-4", children: [
758
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-y-2 md:flex-row md:items-center md:justify-between", children: [
759
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-x-2", children: [
760
+ /* @__PURE__ */ jsx(
761
+ Input,
762
+ {
763
+ type: "search",
764
+ placeholder: "Search products…",
765
+ value: search,
766
+ onChange: (e) => setSearch(e.target.value),
767
+ className: "w-full md:w-72"
768
+ }
769
+ ),
770
+ !connected && /* @__PURE__ */ jsx(Badge, { color: "orange", size: "2xsmall", children: "Not connected" })
771
+ ] }),
772
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-x-2", children: [
773
+ /* @__PURE__ */ jsx(
774
+ Button,
775
+ {
776
+ size: "small",
777
+ variant: "secondary",
778
+ onClick: () => productsQuery.refetch(),
779
+ isLoading: productsQuery.isFetching,
780
+ children: "Refresh"
781
+ }
782
+ ),
783
+ /* @__PURE__ */ jsx(
784
+ Button,
785
+ {
786
+ size: "small",
787
+ variant: selectAllMatching ? "primary" : "secondary",
788
+ disabled: !count,
789
+ onClick: () => {
790
+ setRowSelection({});
791
+ setSelectAllMatching((v) => !v);
792
+ },
793
+ children: selectAllMatching ? `All ${count} selected` : `Select all ${count}`
794
+ }
795
+ )
796
+ ] })
797
+ ] }),
798
+ targetCount > 0 && /* @__PURE__ */ jsxs(Text, { size: "small", className: "text-ui-fg-subtle", children: [
799
+ selectAllMatching ? `All ${count} matching product(s) will be synced` : `${targetCount} product(s) selected`,
800
+ pushMutation.isPending ? " — queuing…" : ""
801
+ ] })
802
+ ] }),
803
+ productsQuery.isLoading ? /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-3 p-6", children: Array.from({ length: 8 }).map((_, i) => /* @__PURE__ */ jsx(Skeleton, { className: "h-10 w-full" }, i)) }) : /* @__PURE__ */ jsx(DataTable.Table, {}),
804
+ /* @__PURE__ */ jsx(DataTable.Pagination, {})
805
+ ] }) }),
806
+ /* @__PURE__ */ jsx(RouteFocusModal.Footer, { children: /* @__PURE__ */ jsxs("div", { className: "flex w-full items-center justify-between gap-x-3", children: [
807
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-x-3", children: [
808
+ /* @__PURE__ */ jsx(Text, { size: "small", className: "text-ui-fg-subtle", children: "Pull Faire orders into Medusa (idempotent)" }),
809
+ pullBatch && pullProgress && /* @__PURE__ */ jsxs(
810
+ StatusBadge,
811
+ {
812
+ color: pullProgress.finished ? ((_g = (_f = pullStatus.data) == null ? void 0 : _f.batch) == null ? void 0 : _g.status) === "failed" ? "red" : "green" : "orange",
813
+ children: [
814
+ "Pull: ",
815
+ pullProgress.done,
816
+ "/",
817
+ pullProgress.total || "?",
818
+ " (",
819
+ pullProgress.pct,
820
+ "%)"
821
+ ]
822
+ }
823
+ )
824
+ ] }),
825
+ /* @__PURE__ */ jsx(
826
+ Button,
827
+ {
828
+ size: "small",
829
+ variant: "secondary",
830
+ onClick: () => pullMutation.mutate(),
831
+ disabled: !connected || pullMutation.isPending,
832
+ isLoading: pullMutation.isPending,
833
+ children: "Pull orders"
834
+ }
835
+ )
836
+ ] }) }),
837
+ /* @__PURE__ */ jsx(Toaster, {})
838
+ ] });
839
+ };
840
+ const handle$1 = {
841
+ breadcrumb: () => "Bulk sync"
842
+ };
678
843
  const STATUS_KEY = ["faire", "status"];
679
844
  const PARENT = "/settings/faire";
680
845
  const NONE = "__none__";
@@ -861,36 +1026,61 @@ const ChecklistItem = ({ ok, label }) => /* @__PURE__ */ jsxs(
861
1026
  const handle = {
862
1027
  breadcrumb: () => "Sync settings"
863
1028
  };
1029
+ const EXCHANGED_PREFIX = "faire:oauth:exchanged:";
864
1030
  const FaireOauthCallback = () => {
865
1031
  const [params] = useSearchParams();
866
1032
  const navigate = useNavigate();
867
1033
  const [error, setError] = useState(null);
868
- const exchangedRef = useRef(false);
1034
+ const [exchanging, setExchanging] = useState(true);
869
1035
  useEffect(() => {
870
- if (exchangedRef.current) return;
871
- exchangedRef.current = true;
872
- const code = params.get("code");
873
- const state = params.get("state");
874
1036
  const errParam = params.get("error");
875
1037
  if (errParam) {
876
1038
  setError(
877
1039
  `Faire authorization failed: ${params.get("error_description") || errParam}`
878
1040
  );
1041
+ setExchanging(false);
879
1042
  return;
880
1043
  }
1044
+ const code = params.get("authorization_code") || params.get("code");
1045
+ const state = params.get("state");
881
1046
  if (!code || !state) {
882
1047
  setError("Missing code or state in Faire callback.");
1048
+ setExchanging(false);
883
1049
  return;
884
1050
  }
885
- faireApi.callback(code, state).then(() => {
1051
+ if (sessionStorage.getItem(EXCHANGED_PREFIX + code)) {
886
1052
  navigate("/settings/faire", { replace: true });
887
- }).catch((err) => {
888
- setError(err.message || "Failed to complete Faire authorization.");
889
- });
1053
+ return;
1054
+ }
1055
+ sessionStorage.setItem(EXCHANGED_PREFIX + code, "1");
1056
+ let cancelled = false;
1057
+ const run = async () => {
1058
+ try {
1059
+ await faireApi.callback(code, state);
1060
+ if (!cancelled) navigate("/settings/faire", { replace: true });
1061
+ } catch (err) {
1062
+ if (!cancelled) {
1063
+ try {
1064
+ const status = await faireApi.status();
1065
+ if (status == null ? void 0 : status.connected) {
1066
+ navigate("/settings/faire", { replace: true });
1067
+ return;
1068
+ }
1069
+ } catch {
1070
+ }
1071
+ setError(err.message || "Failed to complete Faire authorization.");
1072
+ setExchanging(false);
1073
+ }
1074
+ }
1075
+ };
1076
+ run();
1077
+ return () => {
1078
+ cancelled = true;
1079
+ };
890
1080
  }, [params, navigate]);
891
1081
  return /* @__PURE__ */ jsxs(Container, { className: "flex flex-col items-center gap-4 p-8", children: [
892
1082
  /* @__PURE__ */ jsx(Heading, { level: "h1", children: "Connecting Faire…" }),
893
- error ? /* @__PURE__ */ jsx(Alert, { variant: "error", children: /* @__PURE__ */ jsx(Text, { children: error }) }) : /* @__PURE__ */ jsx(Text, { children: "Completing authorization, please wait…" }),
1083
+ error ? /* @__PURE__ */ jsx(Alert, { variant: "error", children: /* @__PURE__ */ jsx(Text, { children: error }) }) : /* @__PURE__ */ jsx(Text, { children: exchanging ? "Completing authorization, please wait…" : "Redirecting…" }),
894
1084
  error && /* @__PURE__ */ jsx(
895
1085
  Button,
896
1086
  {
@@ -914,11 +1104,16 @@ const routeModule = {
914
1104
  {
915
1105
  Component: FaireSettingsPage,
916
1106
  path: "/settings/faire",
917
- handle: handle$2
1107
+ handle: handle$3
918
1108
  },
919
1109
  {
920
1110
  Component: FaireSyncDetailPage,
921
1111
  path: "/settings/faire/:id",
1112
+ handle: handle$2
1113
+ },
1114
+ {
1115
+ Component: FaireBulkPage,
1116
+ path: "/settings/faire/bulk",
922
1117
  handle: handle$1
923
1118
  },
924
1119
  {