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

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, FocusModal, Input, Badge, DropdownMenu, IconButton, 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,15 +225,15 @@ 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;
232
- const navigate = useNavigate();
232
+ const navigate2 = useNavigate();
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({
@@ -297,7 +297,7 @@ const FaireSettingsPage = () => {
297
297
  rowCount: ((_b = syncsQuery.data) == null ? void 0 : _b.count) ?? 0,
298
298
  getRowId: (row) => row.id,
299
299
  onRowClick: (_, row) => {
300
- navigate(`/settings/faire/${row.id}`);
300
+ navigate2(`/settings/faire/${row.id}`);
301
301
  },
302
302
  isLoading: syncsQuery.isLoading,
303
303
  filters,
@@ -340,7 +340,7 @@ const FaireSettingsPage = () => {
340
340
  {
341
341
  size: "small",
342
342
  variant: "secondary",
343
- onClick: () => navigate("/settings/faire/settings"),
343
+ onClick: () => navigate2("/settings/faire/settings"),
344
344
  children: "Sync settings"
345
345
  }
346
346
  ),
@@ -482,7 +482,17 @@ const BulkOperations = ({ connected }) => {
482
482
  disabled: !connected || pushMutation.isPending
483
483
  }
484
484
  ),
485
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
485
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [
486
+ /* @__PURE__ */ jsx(
487
+ Button,
488
+ {
489
+ size: "small",
490
+ variant: "secondary",
491
+ disabled: !connected,
492
+ onClick: () => navigate("/settings/faire/bulk"),
493
+ children: "Select products"
494
+ }
495
+ ),
486
496
  /* @__PURE__ */ jsx(
487
497
  Button,
488
498
  {
@@ -554,9 +564,256 @@ const config = defineRouteConfig({
554
564
  label: "Faire",
555
565
  icon: BuildingStorefront
556
566
  });
557
- const handle$2 = {
567
+ const handle$3 = {
558
568
  breadcrumb: () => "Faire Sync"
559
569
  };
570
+ const Root = ({ prev = "..", children }) => {
571
+ const navigate2 = useNavigate();
572
+ const [open, setOpen] = useState(false);
573
+ useEffect(() => {
574
+ setOpen(true);
575
+ return () => setOpen(false);
576
+ }, []);
577
+ const handleOpenChange = (next) => {
578
+ if (!next) {
579
+ document.body.style.pointerEvents = "auto";
580
+ navigate2(prev, { replace: true });
581
+ return;
582
+ }
583
+ setOpen(next);
584
+ };
585
+ return /* @__PURE__ */ jsx(FocusModal, { open, onOpenChange: handleOpenChange, children: /* @__PURE__ */ jsx(FocusModal.Content, { className: "flex flex-col", children }) });
586
+ };
587
+ const RouteFocusModal = Object.assign(Root, {
588
+ Header: FocusModal.Header,
589
+ Title: FocusModal.Title,
590
+ Description: FocusModal.Description,
591
+ Body: FocusModal.Body,
592
+ Footer: FocusModal.Footer,
593
+ Close: FocusModal.Close
594
+ });
595
+ const columnHelper = createDataTableColumnHelper();
596
+ const useBulkProductColumns = () => {
597
+ return [
598
+ columnHelper.accessor("title", {
599
+ header: "Product",
600
+ cell: ({ row, getValue }) => {
601
+ const p = row.original;
602
+ return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
603
+ p.thumbnail ? /* @__PURE__ */ jsx(
604
+ "img",
605
+ {
606
+ src: p.thumbnail,
607
+ alt: "",
608
+ className: "h-8 w-8 rounded border border-ui-border-base object-cover"
609
+ }
610
+ ) : /* @__PURE__ */ jsx("div", { className: "h-8 w-8 rounded border border-ui-border-base bg-ui-bg-subtle" }),
611
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-col", children: [
612
+ /* @__PURE__ */ jsx("span", { className: "font-medium", children: getValue() }),
613
+ p.handle && /* @__PURE__ */ jsx("span", { className: "text-ui-fg-subtle text-xs font-mono", children: p.handle })
614
+ ] })
615
+ ] });
616
+ }
617
+ }),
618
+ columnHelper.accessor("status", {
619
+ header: "Status",
620
+ cell: (info) => {
621
+ const s = info.getValue();
622
+ const color = s === "published" ? "green" : s === "draft" ? "orange" : "grey";
623
+ return /* @__PURE__ */ jsx(StatusBadge, { color, children: s });
624
+ }
625
+ }),
626
+ columnHelper.accessor("updated_at", {
627
+ header: "Updated",
628
+ cell: (info) => {
629
+ const v = info.getValue();
630
+ return v ? new Date(v).toLocaleString() : "—";
631
+ }
632
+ })
633
+ ];
634
+ };
635
+ const PAGE_SIZE = 20;
636
+ const PRODUCT_FIELDS = "id,title,status,thumbnail,handle,created_at,updated_at";
637
+ const SELECT_ALL_CAP = 2e3;
638
+ const listProducts = (query) => sdk.admin.product.list({
639
+ fields: PRODUCT_FIELDS,
640
+ ...query
641
+ });
642
+ const FaireBulkPage = () => {
643
+ var _a, _b, _c, _d;
644
+ const queryClient = useQueryClient();
645
+ const [pagination, setPagination] = useState({
646
+ pageIndex: 0,
647
+ pageSize: PAGE_SIZE
648
+ });
649
+ const [filtering, setFiltering] = useState({});
650
+ const [search, setSearch] = useState("");
651
+ const [rowSelection, setRowSelection] = useState({});
652
+ const [selectAllMatching, setSelectAllMatching] = useState(false);
653
+ const statusFilter = (_a = filtering.status) == null ? void 0 : _a[0];
654
+ const statusQuery = useQuery({
655
+ queryKey: ["faire", "status"],
656
+ queryFn: () => faireApi.status()
657
+ });
658
+ const connected = !!((_b = statusQuery.data) == null ? void 0 : _b.connected);
659
+ const productsQuery = useQuery({
660
+ queryKey: ["faire", "bulk-products", pagination, statusFilter, search],
661
+ placeholderData: keepPreviousData,
662
+ queryFn: () => listProducts({
663
+ limit: pagination.pageSize,
664
+ offset: pagination.pageIndex * pagination.pageSize,
665
+ q: search || void 0,
666
+ status: statusFilter,
667
+ order: "-created_at"
668
+ })
669
+ });
670
+ const products = ((_c = productsQuery.data) == null ? void 0 : _c.products) ?? [];
671
+ const count = ((_d = productsQuery.data) == null ? void 0 : _d.count) ?? 0;
672
+ const selectedIds = useMemo(
673
+ () => Object.keys(rowSelection).filter((id) => rowSelection[id]),
674
+ [rowSelection]
675
+ );
676
+ const targetCount = selectAllMatching ? count : selectedIds.length;
677
+ const resolveTargetIds = async () => {
678
+ if (!selectAllMatching) return selectedIds;
679
+ const ids = [];
680
+ let offset = 0;
681
+ while (ids.length < count && ids.length < SELECT_ALL_CAP) {
682
+ const res = await listProducts({
683
+ limit: 200,
684
+ offset,
685
+ q: search || void 0,
686
+ status: statusFilter,
687
+ order: "-created_at"
688
+ });
689
+ const batch = res.products ?? [];
690
+ if (!batch.length) break;
691
+ ids.push(...batch.map((p) => p.id));
692
+ offset += batch.length;
693
+ }
694
+ return ids.slice(0, SELECT_ALL_CAP);
695
+ };
696
+ const pushMutation = useMutation({
697
+ mutationFn: async () => {
698
+ const ids = await resolveTargetIds();
699
+ if (!ids.length) throw new Error("No products to sync");
700
+ const res = await faireApi.syncBulk(ids);
701
+ return { res, queued: ids.length };
702
+ },
703
+ onSuccess: ({ res, queued }) => {
704
+ toast.success("Bulk product sync started", {
705
+ description: `Queued ${queued} product(s). Batch ${res.batch_id}.` + (selectAllMatching && count > SELECT_ALL_CAP ? ` Capped at ${SELECT_ALL_CAP} — run again for the rest.` : "")
706
+ });
707
+ queryClient.invalidateQueries({ queryKey: ["faire", "syncs"] });
708
+ setRowSelection({});
709
+ setSelectAllMatching(false);
710
+ },
711
+ onError: (err) => toast.error("Failed to start bulk sync", { description: err.message })
712
+ });
713
+ const columns = useBulkProductColumns();
714
+ const commands = useMemo(
715
+ () => [
716
+ {
717
+ label: connected ? "Push to Faire" : "Connect Faire first",
718
+ shortcut: "p",
719
+ action: () => {
720
+ if (!connected) {
721
+ toast.error("Faire is not connected");
722
+ return;
723
+ }
724
+ pushMutation.mutate();
725
+ }
726
+ },
727
+ {
728
+ label: "Clear selection",
729
+ shortcut: "c",
730
+ action: () => {
731
+ setRowSelection({});
732
+ setSelectAllMatching(false);
733
+ }
734
+ }
735
+ ],
736
+ [connected, pushMutation]
737
+ );
738
+ const table = useDataTable({
739
+ data: products,
740
+ columns,
741
+ rowCount: count,
742
+ getRowId: (row) => row.id,
743
+ isLoading: productsQuery.isLoading,
744
+ commands,
745
+ rowSelection: {
746
+ state: rowSelection,
747
+ onRowSelectionChange: (updater) => {
748
+ setSelectAllMatching(false);
749
+ setRowSelection(updater);
750
+ }
751
+ },
752
+ pagination: { state: pagination, onPaginationChange: setPagination },
753
+ filtering: { state: filtering, onFilteringChange: setFiltering },
754
+ search: { state: search, onSearchChange: setSearch }
755
+ });
756
+ return /* @__PURE__ */ jsxs(RouteFocusModal, { prev: "/settings/faire", children: [
757
+ /* @__PURE__ */ jsx(RouteFocusModal.Header, { children: /* @__PURE__ */ jsxs("div", { className: "flex flex-col", children: [
758
+ /* @__PURE__ */ jsx(Heading, { children: "Bulk sync products to Faire" }),
759
+ /* @__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." })
760
+ ] }) }),
761
+ /* @__PURE__ */ jsx(RouteFocusModal.Body, { className: "flex flex-1 flex-col overflow-hidden p-0", children: /* @__PURE__ */ jsxs(DataTable, { instance: table, children: [
762
+ /* @__PURE__ */ jsxs(DataTable.Toolbar, { className: "flex flex-col gap-y-3 border-b px-6 py-4", children: [
763
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-y-2 md:flex-row md:items-center md:justify-between", children: [
764
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-x-2", children: [
765
+ /* @__PURE__ */ jsx(
766
+ Input,
767
+ {
768
+ type: "search",
769
+ placeholder: "Search products…",
770
+ value: search,
771
+ onChange: (e) => setSearch(e.target.value),
772
+ className: "w-full md:w-72"
773
+ }
774
+ ),
775
+ !connected && /* @__PURE__ */ jsx(Badge, { color: "orange", size: "2xsmall", children: "Not connected" })
776
+ ] }),
777
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-x-2", children: [
778
+ /* @__PURE__ */ jsx(
779
+ Button,
780
+ {
781
+ size: "small",
782
+ variant: "secondary",
783
+ onClick: () => productsQuery.refetch(),
784
+ isLoading: productsQuery.isFetching,
785
+ children: "Refresh"
786
+ }
787
+ ),
788
+ /* @__PURE__ */ jsx(
789
+ Button,
790
+ {
791
+ size: "small",
792
+ variant: selectAllMatching ? "primary" : "secondary",
793
+ disabled: !count,
794
+ onClick: () => {
795
+ setRowSelection({});
796
+ setSelectAllMatching((v) => !v);
797
+ },
798
+ children: selectAllMatching ? `All ${count} selected` : `Select all ${count}`
799
+ }
800
+ )
801
+ ] })
802
+ ] }),
803
+ targetCount > 0 && /* @__PURE__ */ jsxs(Text, { size: "small", className: "text-ui-fg-subtle", children: [
804
+ selectAllMatching ? `All ${count} matching product(s) will be synced` : `${targetCount} product(s) selected`,
805
+ pushMutation.isPending ? " — queuing…" : ""
806
+ ] })
807
+ ] }),
808
+ 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, {}),
809
+ /* @__PURE__ */ jsx(DataTable.Pagination, {})
810
+ ] }) }),
811
+ /* @__PURE__ */ jsx(Toaster, {})
812
+ ] });
813
+ };
814
+ const handle$2 = {
815
+ breadcrumb: () => "Bulk sync"
816
+ };
560
817
  const STATUS_COLORS = {
561
818
  success: "green",
562
819
  failed: "red",
@@ -566,7 +823,7 @@ const STATUS_COLORS = {
566
823
  };
567
824
  const FaireSyncDetailPage = () => {
568
825
  const { id } = useParams();
569
- const navigate = useNavigate();
826
+ const navigate2 = useNavigate();
570
827
  const [record, setRecord] = useState(null);
571
828
  const [loading, setLoading] = useState(true);
572
829
  const [retrying, setRetrying] = useState(false);
@@ -598,7 +855,7 @@ const FaireSyncDetailPage = () => {
598
855
  if (!record) {
599
856
  return /* @__PURE__ */ jsxs(Container, { className: "p-6 flex flex-col gap-4", children: [
600
857
  /* @__PURE__ */ jsx(Heading, { level: "h1", children: "Sync record not found" }),
601
- /* @__PURE__ */ jsx(Button, { size: "small", variant: "secondary", onClick: () => navigate("/settings/faire"), children: "Back to Faire settings" })
858
+ /* @__PURE__ */ jsx(Button, { size: "small", variant: "secondary", onClick: () => navigate2("/settings/faire"), children: "Back to Faire settings" })
602
859
  ] });
603
860
  }
604
861
  const warnings = record.error_message ? record.error_message.split(" | ") : [];
@@ -696,13 +953,13 @@ const cleanSettings = (form) => {
696
953
  return out;
697
954
  };
698
955
  const FaireSyncSettingsDrawer = () => {
699
- const navigate = useNavigate();
956
+ const navigate2 = useNavigate();
700
957
  const queryClient = useQueryClient();
701
958
  const [open, setOpen] = useState(true);
702
959
  const [form, setForm] = useState({});
703
960
  const close = () => {
704
961
  setOpen(false);
705
- navigate(PARENT);
962
+ navigate2(PARENT);
706
963
  };
707
964
  const statusQuery = useQuery({
708
965
  queryKey: STATUS_KEY,
@@ -861,42 +1118,67 @@ const ChecklistItem = ({ ok, label }) => /* @__PURE__ */ jsxs(
861
1118
  const handle = {
862
1119
  breadcrumb: () => "Sync settings"
863
1120
  };
1121
+ const EXCHANGED_PREFIX = "faire:oauth:exchanged:";
864
1122
  const FaireOauthCallback = () => {
865
1123
  const [params] = useSearchParams();
866
- const navigate = useNavigate();
1124
+ const navigate2 = useNavigate();
867
1125
  const [error, setError] = useState(null);
868
- const exchangedRef = useRef(false);
1126
+ const [exchanging, setExchanging] = useState(true);
869
1127
  useEffect(() => {
870
- if (exchangedRef.current) return;
871
- exchangedRef.current = true;
872
- const code = params.get("code");
873
- const state = params.get("state");
874
1128
  const errParam = params.get("error");
875
1129
  if (errParam) {
876
1130
  setError(
877
1131
  `Faire authorization failed: ${params.get("error_description") || errParam}`
878
1132
  );
1133
+ setExchanging(false);
879
1134
  return;
880
1135
  }
1136
+ const code = params.get("authorization_code") || params.get("code");
1137
+ const state = params.get("state");
881
1138
  if (!code || !state) {
882
1139
  setError("Missing code or state in Faire callback.");
1140
+ setExchanging(false);
1141
+ return;
1142
+ }
1143
+ if (sessionStorage.getItem(EXCHANGED_PREFIX + code)) {
1144
+ navigate2("/settings/faire", { replace: true });
883
1145
  return;
884
1146
  }
885
- faireApi.callback(code, state).then(() => {
886
- navigate("/settings/faire", { replace: true });
887
- }).catch((err) => {
888
- setError(err.message || "Failed to complete Faire authorization.");
889
- });
890
- }, [params, navigate]);
1147
+ sessionStorage.setItem(EXCHANGED_PREFIX + code, "1");
1148
+ let cancelled = false;
1149
+ const run = async () => {
1150
+ try {
1151
+ await faireApi.callback(code, state);
1152
+ if (!cancelled) navigate2("/settings/faire", { replace: true });
1153
+ } catch (err) {
1154
+ if (!cancelled) {
1155
+ try {
1156
+ const status = await faireApi.status();
1157
+ if (status == null ? void 0 : status.connected) {
1158
+ navigate2("/settings/faire", { replace: true });
1159
+ return;
1160
+ }
1161
+ } catch {
1162
+ }
1163
+ setError(err.message || "Failed to complete Faire authorization.");
1164
+ setExchanging(false);
1165
+ }
1166
+ }
1167
+ };
1168
+ run();
1169
+ return () => {
1170
+ cancelled = true;
1171
+ };
1172
+ }, [params, navigate2]);
891
1173
  return /* @__PURE__ */ jsxs(Container, { className: "flex flex-col items-center gap-4 p-8", children: [
892
1174
  /* @__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…" }),
1175
+ error ? /* @__PURE__ */ jsx(Alert, { variant: "error", children: /* @__PURE__ */ jsx(Text, { children: error }) }) : /* @__PURE__ */ jsx(Text, { children: exchanging ? "Completing authorization, please wait…" : "Redirecting…" }),
894
1176
  error && /* @__PURE__ */ jsx(
895
1177
  Button,
896
1178
  {
897
1179
  size: "small",
898
1180
  variant: "secondary",
899
- onClick: () => navigate("/settings/faire", { replace: true }),
1181
+ onClick: () => navigate2("/settings/faire", { replace: true }),
900
1182
  children: "Back to Faire settings"
901
1183
  }
902
1184
  )
@@ -914,6 +1196,11 @@ const routeModule = {
914
1196
  {
915
1197
  Component: FaireSettingsPage,
916
1198
  path: "/settings/faire",
1199
+ handle: handle$3
1200
+ },
1201
+ {
1202
+ Component: FaireBulkPage,
1203
+ path: "/settings/faire/bulk",
917
1204
  handle: handle$2
918
1205
  },
919
1206
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jytextiles/medusa-plugin-faire-store-sync",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -0,0 +1,51 @@
1
+ import { FocusModal } from "@medusajs/ui"
2
+ import { PropsWithChildren, useEffect, useState } from "react"
3
+ import { useNavigate } from "react-router-dom"
4
+
5
+ /**
6
+ * Minimal route-driven FocusModal for plugin admin routes.
7
+ *
8
+ * Mirrors the Medusa dashboard's `RouteFocusModal` (which isn't exported for
9
+ * plugins) without the form / stacked-modal / provider machinery — a full-page
10
+ * FocusModal that opens on mount (so the entry animation plays) and navigates
11
+ * back to `prev` on close, so the route itself drives open/closed state.
12
+ */
13
+ type RouteFocusModalProps = PropsWithChildren<{
14
+ /** Where to navigate on close. Defaults to the parent route. */
15
+ prev?: string
16
+ }>
17
+
18
+ const Root = ({ prev = "..", children }: RouteFocusModalProps) => {
19
+ const navigate = useNavigate()
20
+ const [open, setOpen] = useState(false)
21
+
22
+ // Open on mount so the entry animation plays; reset on unmount.
23
+ useEffect(() => {
24
+ setOpen(true)
25
+ return () => setOpen(false)
26
+ }, [])
27
+
28
+ const handleOpenChange = (next: boolean) => {
29
+ if (!next) {
30
+ document.body.style.pointerEvents = "auto"
31
+ navigate(prev, { replace: true })
32
+ return
33
+ }
34
+ setOpen(next)
35
+ }
36
+
37
+ return (
38
+ <FocusModal open={open} onOpenChange={handleOpenChange}>
39
+ <FocusModal.Content className="flex flex-col">{children}</FocusModal.Content>
40
+ </FocusModal>
41
+ )
42
+ }
43
+
44
+ export const RouteFocusModal = Object.assign(Root, {
45
+ Header: FocusModal.Header,
46
+ Title: FocusModal.Title,
47
+ Description: FocusModal.Description,
48
+ Body: FocusModal.Body,
49
+ Footer: FocusModal.Footer,
50
+ Close: FocusModal.Close,
51
+ })