diff --git a/.changeset/red-donkeys-speak.md b/.changeset/red-donkeys-speak.md new file mode 100644 index 0000000000..8ef9848d55 --- /dev/null +++ b/.changeset/red-donkeys-speak.md @@ -0,0 +1,5 @@ +--- +"@medusajs/ui": patch +--- + +fix(ui): Left aligns text in RadioGroup.Choicebox component. diff --git a/packages/admin-next/dashboard/package.json b/packages/admin-next/dashboard/package.json index 8e0a599e8d..11bd6c40ec 100644 --- a/packages/admin-next/dashboard/package.json +++ b/packages/admin-next/dashboard/package.json @@ -16,8 +16,7 @@ "package.json" ], "dependencies": { - "@headlessui/react": "^1.7.18", - "@headlessui/tailwindcss": "^0.2.0", + "@ariakit/react": "^0.4.1", "@hookform/resolvers": "3.3.2", "@medusajs/icons": "workspace:^", "@medusajs/ui": "workspace:^", @@ -33,9 +32,11 @@ "i18next": "23.7.11", "i18next-browser-languagedetector": "7.2.0", "i18next-http-backend": "2.4.2", + "match-sorter": "^6.3.4", "medusa-react": "workspace:^", "react": "18.2.0", "react-dom": "18.2.0", + "react-focus-lock": "^2.11.1", "react-hook-form": "7.49.1", "react-i18next": "13.5.0", "react-jwt": "^1.2.0", diff --git a/packages/admin-next/dashboard/public/locales/en-US/translation.json b/packages/admin-next/dashboard/public/locales/en-US/translation.json index 6e724bce3c..63df2daa59 100644 --- a/packages/admin-next/dashboard/public/locales/en-US/translation.json +++ b/packages/admin-next/dashboard/public/locales/en-US/translation.json @@ -15,6 +15,8 @@ "next": "Next", "prev": "Prev", "is": "is", + "select": "Select", + "selected": "Selected", "extensions": "Extensions", "settings": "Settings", "general": "General", @@ -38,7 +40,8 @@ "noRecordsTitle": "No records", "noRecordsMessage": "There are no records to show", "unsavedChangesTitle": "Are you sure you want to leave this page?", - "unsavedChangesDescription": "You have unsaved changes that will be lost if you leave this page." + "unsavedChangesDescription": "You have unsaved changes that will be lost if you leave this page.", + "includesTaxTooltip": "Enter the total amount including tax. The net amount excluding tax will be automatically calculated and saved." }, "actions": { "create": "Create", @@ -49,7 +52,8 @@ "save": "Save", "continue": "Continue", "edit": "Edit", - "download": "Download" + "download": "Download", + "clearAll": "Clear all" }, "errorBoundary": { "badRequestTitle": "Bad request", @@ -252,17 +256,47 @@ "domain": "Regions", "createRegion": "Create Region", "createRegionHint": "Manage tax rates and providers for a set of countries.", + "addCountries": "Add countries", "editRegion": "Edit Region", + "countriesHint": "Add the countries that should be included in this region.", "deleteRegionWarning": "You are about to delete the region {{name}}. This action cannot be undone.", + "removeCountriesWarning_one": "You are about to remove {{count}} country from the region. This action cannot be undone.", + "removeCountriesWarning_other": "You are about to remove {{count}} countries from the region. This action cannot be undone.", + "removeCountryWarning": "You are about to remove the country {{name}} from the region. This action cannot be undone.", "taxInclusiveHint": "When enabled all prices in the region will be tax inclusive.", - "providersHint": "The providers that are available in the region.", + "providersHint": " Add which fulfillment and payment providers should be available in this region.", "shippingOptions": "Shipping Options", - "returnShippingOptions": "Return Shipping Options", + "deleteShippingOptionWarning": "You are about to delete the shipping option {{name}}. This action cannot be undone.", "return": "Return", "outbound": "Outbound", "priceType": "Price Type", "flatRate": "Flat Rate", - "calculated": "Calculated" + "calculated": "Calculated", + "shippingOption": { + "createShippingOption": "Create Shipping Option", + "createShippingOptionHint": "Create a new shipping option for the region.", + "editShippingOption": "Edit Shipping Option", + "fulfillmentMethod": "Fulfillment Method", + "type": { + "outbound": "Outbound", + "outboundHint": "Use this if you are creating a shipping option for sending products to the customer.", + "return": "Return", + "returnHint": "Use this if you are creating a shipping option for the customer to return products to you." + }, + "priceType": { + "label": "Price Type", + "flatRate": "Flat rate", + "calculated": "Calculated" + }, + "availability": { + "adminOnly": "Admin only", + "adminOnlyHint": "When enabled the shipping option will only be available in the admin dashboard, and not in the storefront." + }, + "requirements": { + "label": "Requirements", + "hint": "Specify the requirements for the shipping option." + } + } }, "locations": { "domain": "Locations", @@ -370,6 +404,7 @@ "countries": "Countries", "paymentProviders": "Payment Providers", "fulfillmentProviders": "Fulfillment Providers", + "fulfillmentProvider": "Fulfillment Provider", "providers": "Providers", "availability": "Availability", "inventory": "Inventory", @@ -430,6 +465,9 @@ "material": "Material", "thumbnail": "Thumbnail", "sku": "SKU", - "managedInventory": "Managed inventory" + "managedInventory": "Managed inventory", + "minSubtotal": "Min. Subtotal", + "maxSubtotal": "Max. Subtotal", + "shippingProfile": "Shipping Profile" } } diff --git a/packages/admin-next/dashboard/src/components/common/combobox/combobox.tsx b/packages/admin-next/dashboard/src/components/common/combobox/combobox.tsx index 549d0c1103..c4afbc229a 100644 --- a/packages/admin-next/dashboard/src/components/common/combobox/combobox.tsx +++ b/packages/admin-next/dashboard/src/components/common/combobox/combobox.tsx @@ -1,17 +1,18 @@ -import { Combobox as Primitive } from "@headlessui/react" -import { EllipseMiniSolid, TrianglesMini } from "@medusajs/icons" -import { Product } from "@medusajs/medusa" -import { clx } from "@medusajs/ui" +import { + Combobox as PrimitiveCombobox, + ComboboxItem as PrimitiveComboboxItem, + ComboboxItemCheck as PrimitiveComboboxItemCheck, + ComboboxItemValue as PrimitiveComboboxItemValue, + ComboboxList as PrimitiveComboboxList, + ComboboxProvider as PrimitiveComboboxProvider, +} from "@ariakit/react" +import { EllipseMiniSolid, TrianglesMini, XMarkMini } from "@medusajs/icons" +import { Text, clx } from "@medusajs/ui" import * as Popover from "@radix-ui/react-popover" -import { useAdminProducts } from "medusa-react" +import { matchSorter } from "match-sorter" import { ComponentPropsWithoutRef, - ElementRef, - ReactNode, - createContext, forwardRef, - useContext, - useEffect, useImperativeHandle, useMemo, useRef, @@ -24,370 +25,204 @@ type ComboboxOption = { label: string } -type ComboboxProps = { - size?: "base" | "small" +interface ComboboxProps + extends Omit, "onChange" | "value"> { + value?: string[] + onChange?: (value?: string[]) => void options: ComboboxOption[] - value: string } -export const Combobox = ({ size = "base" }: ComboboxProps) => { - const [product, setProduct] = useState(null) - const [query, setQuery] = useState("") - const { products, count, isLoading } = useAdminProducts( - { - q: query, - }, - { - keepPreviousData: true, - } - ) - - return ( - -
-
- setQuery(e.target.value)} - displayValue={(value: Product) => value?.title} - className={clx( - "bg-ui-bg-field shadow-buttons-neutral transition-fg flex w-full select-none items-center justify-between rounded-md outline-none", - "placeholder:text-ui-fg-muted text-ui-fg-base", - "hover:bg-ui-bg-field-hover", - "focus-visible:shadow-borders-interactive-with-active data-[state=open]:!shadow-borders-interactive-with-active", - "aria-[invalid=true]:border-ui-border-error aria-[invalid=true]:shadow-borders-error", - "invalid:border-ui-border-error invalid:shadow-borders-error", - "disabled:!bg-ui-bg-disabled disabled:!text-ui-fg-disabled", - { - "h-8 px-2 py-1.5 txt-compact-small": size === "base", - "h-7 px-2 py-1 txt-compact-small": size === "small", - } - )} - /> - - -
- - {products?.map((p) => ( - - - - {p.title} - - - ))} - -
-
- ) -} - -type ComboboxContextValue = { - size: "base" | "small" - open: boolean - setOpen: (open: boolean) => void -} - -const ComboboxContext = createContext(null) - -const useComboboxContext = () => { - const context = useContext(ComboboxContext) - - if (!context) { - throw new Error( - "Combobox compound components cannot be rendered outside the Combobox component" - ) - } - - return context -} - -const Root = forwardRef< - ElementRef, - Omit, "children"> & { - className?: string - size?: "base" | "small" - children: ReactNode - } ->(({ children, className, size = "base", ...props }, ref) => { - const [open, setOpen] = useState(false) - - const value = useMemo(() => ({ size, open, setOpen }), [size, open]) - - return ( - - - - {children} - - - - ) -}) -Root.displayName = "Combobox" - -const Trigger = forwardRef>( - ({ className, children, ...props }, ref) => { - const { size } = useComboboxContext() - - return ( - -
- {children} - - -
-
- ) - } -) -Trigger.displayName = "Combobox.Trigger" - -const Value = forwardRef< - ElementRef, - ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const { size } = useComboboxContext() - - return ( - - ) -}) -Value.displayName = "Combobox.Value" - -const Item = forwardRef< - ElementRef, - Omit, "children"> & { - children?: ReactNode - } ->(({ children, className, ...props }, ref) => { - const { size } = useComboboxContext() - - return ( - - - {children} - - ) -}) -Item.displayName = "Combobox.Item" - -const NoResults = forwardRef< - ElementRef<"span">, - ComponentPropsWithoutRef<"span"> ->(({ children, className, ...props }, ref) => { - const { size } = useComboboxContext() - const { t } = useTranslation() - - return ( - - {children ?? t("general.noResultsTitle")} - - ) -}) -Item.displayName = "Combobox.NoResults" - -const Content = forwardRef< - ElementRef, - ComponentPropsWithoutRef ->( +export const Combobox = forwardRef( ( { - children, + value: controlledValue, + onChange, + options, className, - side = "bottom", - sideOffset = 8, - collisionPadding = 24, - ...props + placeholder, + ...inputProps }, ref ) => { + const [open, setOpen] = useState(false) + const { t } = useTranslation() + + const comboboxRef = useRef(null) + const listboxRef = useRef(null) + + useImperativeHandle(ref, () => comboboxRef.current!) + + const isControlled = controlledValue !== undefined + const [searchValue, setSearchValue] = useState("") + const [uncontrolledValue, setUncontrolledValue] = useState([]) + + const selectedValues = isControlled ? controlledValue : uncontrolledValue + + const handleValueChange = (newValues?: string[]) => { + if (!isControlled) { + setUncontrolledValue(newValues || []) + } + if (onChange) { + onChange(newValues) + } + } + + /** + * Filter and sort the options based on the search value, + * and whether the value is already selected. + */ + const matches = useMemo(() => { + return matchSorter(options, searchValue, { + keys: ["label"], + baseSort: (a, b) => { + const aIndex = selectedValues.indexOf(a.item.value) + const bIndex = selectedValues.indexOf(b.item.value) + + if (aIndex === -1 && bIndex === -1) { + return 0 + } + + if (aIndex === -1) { + return 1 + } + + if (bIndex === -1) { + return -1 + } + + return aIndex - bIndex + }, + }) + }, [options, searchValue, selectedValues]) + + const hasValues = selectedValues.length > 0 + const showSelected = hasValues && !searchValue && !open + const hidePlaceholder = showSelected || open + return ( - - + { + setSearchValue(value) + }} > - - {children} - - - + +
+ {hasValues && ( +
+ {selectedValues.length} + +
+ )} +
+ {showSelected && ( + + {t("general.selected")} + + )} + +
+ +
+
+ + event.preventDefault()} + onInteractOutside={(event) => { + const target = event.target as Element | null + const isCombobox = target === comboboxRef.current + const inListbox = target && listboxRef.current?.contains(target) + + if (isCombobox || inListbox) { + event.preventDefault() + } + }} + > + + {matches.map(({ value, label }) => ( + + + + + + {label} + + + ))} + {!matches.length && ( +
+ + {t("general.noResultsTitle")} + +
+ )} +
+
+
+ + ) } ) -Content.displayName = "Combobox.Content" - -const Pagination = forwardRef< - ElementRef<"div">, - { - isLoading?: boolean - hasNext?: boolean - onPaginate: () => void - className?: string - } ->(({ isLoading, hasNext, onPaginate, className, ...props }, ref) => { - const observerRef = useRef(null) - const innerRef = useRef(null) - - // Merge innerRef and ref - useImperativeHandle(ref, () => { - return innerRef.current - }) - - useEffect(() => { - if (innerRef.current) { - observerRef.current = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting) { - onPaginate() - } - }) - - observerRef.current.observe(innerRef.current) - } - - return () => { - if (observerRef.current && innerRef.current) { - observerRef.current.unobserve(innerRef.current) - } - } - }, [isLoading, hasNext, onPaginate, ref]) - - return
-}) -Pagination.displayName = "Combobox.Pagination" - -const Combo = Object.assign(Root, { - Trigger, - Value, - Item, - NoResults, - Pagination, - Content, -}) - -export const TestCombobox = () => { - const [product, setProduct] = useState([]) - const [query, setQuery] = useState("") - const { products, count, isLoading } = useAdminProducts( - { - q: query, - }, - { - keepPreviousData: true, - } - ) - - return ( - - - setQuery(e.target.value)} - displayValue={(value: Product[]) => `${value?.length}`} - /> - - - {!products?.length && } - console.log("Heyo!")} /> - {products?.map((p) => ( - - {p.title} - - ))} - - - ) -} +Combobox.displayName = "Combobox" diff --git a/packages/admin-next/dashboard/src/components/common/combobox/index.ts b/packages/admin-next/dashboard/src/components/common/combobox/index.ts new file mode 100644 index 0000000000..abd73dd024 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/combobox/index.ts @@ -0,0 +1 @@ +export * from "./combobox" diff --git a/packages/admin-next/dashboard/src/components/common/form/form.tsx b/packages/admin-next/dashboard/src/components/common/form/form.tsx index 945bbd9b54..4f55b9e901 100644 --- a/packages/admin-next/dashboard/src/components/common/form/form.tsx +++ b/packages/admin-next/dashboard/src/components/common/form/form.tsx @@ -100,8 +100,9 @@ const Label = forwardRef< React.ComponentPropsWithoutRef & { optional?: boolean tooltip?: ReactNode + icon?: ReactNode } ->(({ className, optional = false, tooltip, ...props }, ref) => { +>(({ className, optional = false, tooltip, icon, ...props }, ref) => { const { formItemId } = useFormField() const { t } = useTranslation() @@ -120,6 +121,7 @@ const Label = forwardRef< )} + {icon} {optional && ( ({t("fields.optional")}) diff --git a/packages/admin-next/dashboard/src/components/common/tax-badge/tax-badge.tsx b/packages/admin-next/dashboard/src/components/common/tax-badge/tax-badge.tsx new file mode 100644 index 0000000000..02f3c0b14c --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/tax-badge/tax-badge.tsx @@ -0,0 +1,23 @@ +import { BuildingTax } from "@medusajs/icons" +import { Tooltip } from "@medusajs/ui" +import { useTranslation } from "react-i18next" + +type IncludesTaxTooltipProps = { + includesTax?: boolean +} + +export const IncludesTaxTooltip = ({ + includesTax, +}: IncludesTaxTooltipProps) => { + const { t } = useTranslation() + + if (!includesTax) { + return null + } + + return ( + + + + ) +} diff --git a/packages/admin-next/dashboard/src/components/layout/split-view/index.ts b/packages/admin-next/dashboard/src/components/layout/split-view/index.ts new file mode 100644 index 0000000000..b717c0fded --- /dev/null +++ b/packages/admin-next/dashboard/src/components/layout/split-view/index.ts @@ -0,0 +1 @@ +export * from "./split-view" diff --git a/packages/admin-next/dashboard/src/components/layout/split-view/split-view.tsx b/packages/admin-next/dashboard/src/components/layout/split-view/split-view.tsx new file mode 100644 index 0000000000..decc9dd612 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/layout/split-view/split-view.tsx @@ -0,0 +1,178 @@ +import { Button, clx } from "@medusajs/ui" +import { AnimatePresence, motion } from "framer-motion" +import { + ComponentPropsWithoutRef, + PropsWithChildren, + createContext, + useContext, + useRef, + useState, +} from "react" +import FocusLock from "react-focus-lock" + +import { useMediaQuery } from "../../../hooks/use-media-query" + +type SplitViewContextValue = { + open: boolean + onOpenChange: (open: boolean) => void +} + +const SplitViewContext = createContext(null) + +const useSplitViewContext = () => { + const context = useContext(SplitViewContext) + + if (!context) { + throw new Error("useSplitViewContext must be used within a SplitView") + } + + return context +} + +type SplitViewProps = PropsWithChildren<{ + open?: boolean + onOpenChange?: (open: boolean) => void +}> + +const Root = ({ + open: controlledOpen, + onOpenChange, + children, +}: SplitViewProps) => { + const containerRef = useRef(null) + + const isControlled = controlledOpen !== undefined + const [uncontrolledOpen, setUncontrolledOpen] = useState(false) + + const open = isControlled ? controlledOpen : uncontrolledOpen + + const handleOpenChange = (newOpen: boolean) => { + if (!isControlled) { + setUncontrolledOpen(newOpen) + } + + if (onOpenChange) { + onOpenChange(newOpen) + } + } + + return ( + +
+ {children} +
+
+ ) +} + +const Content = ({ children }: PropsWithChildren) => { + const { open, onOpenChange } = useSplitViewContext() + const isLargeScreenSize = useMediaQuery("(min-width: 1024px)") + + const contentWidth = !isLargeScreenSize ? "100%" : open ? "50%" : "100%" + + return ( + + + {open && ( + onOpenChange(false)} + /> + )} + + + {children} + + ) +} + +const MotionFocusLock = motion(FocusLock) + +const Drawer = ({ children }: PropsWithChildren) => { + const { open } = useSplitViewContext() + const isLargeScreenSize = useMediaQuery("(min-width: 1024px)") + + return ( + + {open && ( + + {children} + + )} + + ) +} + +const Close = ({ + variant = "secondary", + size = "small", + onClick, + children, + ...props +}: ComponentPropsWithoutRef) => { + const { onOpenChange } = useSplitViewContext() + const handleClick = onClick ?? (() => onOpenChange(false)) + + return ( + + ) +} + +const Open = ({ + variant = "secondary", + size = "small", + onClick, + children, + ...props +}: ComponentPropsWithoutRef) => { + const { onOpenChange } = useSplitViewContext() + const handleClick = onClick ?? (() => onOpenChange(true)) + + return ( + + ) +} + +/** + * SplitView is a layout component that allows you to create a split view layout within a FocusModal. + */ +export const SplitView = Object.assign(Root, { + Content, + Drawer, + Close, + Open, +}) diff --git a/packages/admin-next/dashboard/src/components/table/data-table/data-table-root/data-table-root.tsx b/packages/admin-next/dashboard/src/components/table/data-table/data-table-root/data-table-root.tsx index e06d8e502e..2a95a8da0a 100644 --- a/packages/admin-next/dashboard/src/components/table/data-table/data-table-root/data-table-root.tsx +++ b/packages/admin-next/dashboard/src/components/table/data-table/data-table-root/data-table-root.tsx @@ -181,6 +181,7 @@ export const DataTableRoot = ({ {table.getRowModel().rows.map((row) => { const to = navigateTo ? navigateTo(row) : undefined + const isRowDisabled = hasSelect && !row.getCanSelect() return ( ({ "cursor-pointer": !!to, "bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover": row.getIsSelected(), + "bg-ui-bg-subtle hover:bg-ui-bg-subtle": isRowDisabled, } )} onClick={to ? () => navigate(to) : undefined} @@ -220,6 +222,8 @@ export const DataTableRoot = ({ isStickyCell && hasSelect && !isSelectCell, "after:bg-ui-border-base": showStickyBorder && isStickyCell && !isSelectCell, + "bg-ui-bg-subtle hover:bg-ui-bg-subtle": + isRowDisabled, })} > {flexRender( diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/region/countries-cell/countries-cell.tsx b/packages/admin-next/dashboard/src/components/table/table-cells/region/countries-cell/countries-cell.tsx index 6ae9fa8f2c..4cef4e3c6c 100644 --- a/packages/admin-next/dashboard/src/components/table/table-cells/region/countries-cell/countries-cell.tsx +++ b/packages/admin-next/dashboard/src/components/table/table-cells/region/countries-cell/countries-cell.tsx @@ -1,5 +1,4 @@ import { Country } from "@medusajs/medusa" -import { Tooltip } from "@medusajs/ui" import { useTranslation } from "react-i18next" import { PlaceholderCell } from "../../common/placeholder-cell" @@ -19,28 +18,21 @@ export const CountriesCell = ({ countries }: CountriesCellProps) => { .map((c) => c.display_name) .join(", ") - const additionalCountries = countries.slice(2).map((c) => c.display_name) + const additionalCountries = countries + .slice(2) + .map((c) => c.display_name).length + + const text = `${displayValue}${ + additionalCountries > 0 + ? ` ${t("general.plusCountMore", { + count: additionalCountries, + })}` + : "" + }` return ( -
- {displayValue} - {additionalCountries.length > 0 && ( - - {additionalCountries.map((c) => ( -
  • {c}
  • - ))} - - } - > - - {t("general.plusCountMore", { - count: additionalCountries.length, - })} - -
    - )} +
    + {text}
    ) } diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/region/fulfillment-providers-cell/fulfillment-providers-cell.tsx b/packages/admin-next/dashboard/src/components/table/table-cells/region/fulfillment-providers-cell/fulfillment-providers-cell.tsx index 43df61c899..6f95a396e5 100644 --- a/packages/admin-next/dashboard/src/components/table/table-cells/region/fulfillment-providers-cell/fulfillment-providers-cell.tsx +++ b/packages/admin-next/dashboard/src/components/table/table-cells/region/fulfillment-providers-cell/fulfillment-providers-cell.tsx @@ -1,6 +1,6 @@ import { FulfillmentProvider } from "@medusajs/medusa" -import { Tooltip } from "@medusajs/ui" import { useTranslation } from "react-i18next" +import { formatProvider } from "../../../../../lib/format-provider" import { PlaceholderCell } from "../../common/placeholder-cell" type FulfillmentProvidersCellProps = { @@ -18,31 +18,22 @@ export const FulfillmentProvidersCell = ({ const displayValue = fulfillmentProviders .slice(0, 2) - .map((p) => p.id) + .map((p) => formatProvider(p.id)) .join(", ") - const additionalProviders = fulfillmentProviders.slice(2).map((c) => c.id) + const additionalProviders = fulfillmentProviders.slice(2).length + + const text = `${displayValue}${ + additionalProviders > 0 + ? ` ${t("general.plusCountMore", { + count: additionalProviders, + })}` + : "" + }` return ( -
    - {displayValue} - {additionalProviders.length > 0 && ( - - {additionalProviders.map((c) => ( -
  • {c}
  • - ))} - - } - > - - {t("general.plusCountMore", { - count: additionalProviders.length, - })} - -
    - )} +
    + {text}
    ) } @@ -51,8 +42,8 @@ export const FulfillmentProvidersHeader = () => { const { t } = useTranslation() return ( -
    - {t("fields.fulfillmentProviders")} +
    + {t("fields.fulfillmentProviders")}
    ) } diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/region/payment-providers-cell/payment-providers-cell.tsx b/packages/admin-next/dashboard/src/components/table/table-cells/region/payment-providers-cell/payment-providers-cell.tsx index b7bbfd4d8a..76e50cbe7a 100644 --- a/packages/admin-next/dashboard/src/components/table/table-cells/region/payment-providers-cell/payment-providers-cell.tsx +++ b/packages/admin-next/dashboard/src/components/table/table-cells/region/payment-providers-cell/payment-providers-cell.tsx @@ -1,6 +1,6 @@ import { PaymentProvider } from "@medusajs/medusa" -import { Tooltip } from "@medusajs/ui" import { useTranslation } from "react-i18next" +import { formatProvider } from "../../../../../lib/format-provider" import { PlaceholderCell } from "../../common/placeholder-cell" type PaymentProvidersCellProps = { @@ -18,31 +18,22 @@ export const PaymentProvidersCell = ({ const displayValue = paymentProviders .slice(0, 2) - .map((p) => p.id) + .map((p) => formatProvider(p.id)) .join(", ") - const additionalProviders = paymentProviders.slice(2).map((c) => c.id) + const additionalProviders = paymentProviders.slice(2).length + + const text = `${displayValue}${ + additionalProviders > 0 + ? ` ${t("general.plusCountMore", { + count: additionalProviders, + })}` + : "" + }` return ( -
    - {displayValue} - {additionalProviders.length > 0 && ( - - {additionalProviders.map((c) => ( -
  • {c}
  • - ))} - - } - > - - {t("general.plusCountMore", { - count: additionalProviders.length, - })} - -
    - )} +
    + {text}
    ) } @@ -51,8 +42,8 @@ export const PaymentProvidersHeader = () => { const { t } = useTranslation() return ( -
    - {t("fields.paymentProviders")} +
    + {t("fields.paymentProviders")}
    ) } diff --git a/packages/admin-next/dashboard/src/hooks/use-media-query.tsx b/packages/admin-next/dashboard/src/hooks/use-media-query.tsx new file mode 100644 index 0000000000..784f17830f --- /dev/null +++ b/packages/admin-next/dashboard/src/hooks/use-media-query.tsx @@ -0,0 +1,14 @@ +import { useEffect, useState } from "react" + +export const useMediaQuery = (query: string) => { + const mediaQuery = window.matchMedia(query) + const [matches, setMatches] = useState(mediaQuery.matches) + + useEffect(() => { + const handler = (e: MediaQueryListEvent) => setMatches(e.matches) + mediaQuery.addEventListener("change", handler) + return () => mediaQuery.removeEventListener("change", handler) + }, [mediaQuery]) + + return matches +} diff --git a/packages/admin-next/dashboard/src/lib/format-provider.ts b/packages/admin-next/dashboard/src/lib/format-provider.ts new file mode 100644 index 0000000000..ecd74fb1df --- /dev/null +++ b/packages/admin-next/dashboard/src/lib/format-provider.ts @@ -0,0 +1,12 @@ +/** + * Providers only have an ID to identify them. This function formats the ID + * into a human-readable string. + * @param id - The ID of the provider + * @returns A formatted string + */ +export const formatProvider = (id: string) => { + return id + .split("-") + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(" ") +} diff --git a/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx b/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx index 1bfe955510..31e18640a8 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx @@ -477,6 +477,25 @@ const router = createBrowserRouter([ path: "edit", lazy: () => import("../../routes/regions/region-edit"), }, + { + path: "countries/add", + lazy: () => + import("../../routes/regions/region-add-countries"), + }, + { + path: "shipping-options/:so_id/edit", + lazy: () => + import( + "../../routes/regions/region-edit-shipping-option" + ), + }, + { + path: "shipping-options/create", + lazy: () => + import( + "../../routes/regions/region-create-shipping-option" + ), + }, ], }, ], diff --git a/packages/admin-next/dashboard/src/routes/regions/region-add-countries/components/add-countries-form/add-countries-form.tsx b/packages/admin-next/dashboard/src/routes/regions/region-add-countries/components/add-countries-form/add-countries-form.tsx new file mode 100644 index 0000000000..12151a5b78 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/regions/region-add-countries/components/add-countries-form/add-countries-form.tsx @@ -0,0 +1,200 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { Country, Region } from "@medusajs/medusa" +import { + ColumnDef, + RowSelectionState, + createColumnHelper, +} from "@tanstack/react-table" +import { useEffect, useMemo, useState } from "react" +import { useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" +import * as zod from "zod" + +import { Button, Checkbox } from "@medusajs/ui" +import { useAdminUpdateRegion } from "medusa-react" +import { + RouteFocusModal, + useRouteModal, +} from "../../../../../components/route-modal" +import { DataTable } from "../../../../../components/table/data-table" +import { useDataTable } from "../../../../../hooks/use-data-table" +import { countries as staticCountries } from "../../../../../lib/countries" +import { useCountries } from "../../../shared/hooks/use-countries" +import { useCountryTableColumns } from "../../../shared/hooks/use-country-table-columns" +import { useCountryTableQuery } from "../../../shared/hooks/use-country-table-query" + +type AddCountriesFormProps = { + region: Region +} + +const AddCountriesSchema = zod.object({ + countries: zod.array(zod.string()).min(1), +}) + +const PAGE_SIZE = 50 +const PREFIX = "ac" + +export const AddCountriesForm = ({ region }: AddCountriesFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const [rowSelection, setRowSelection] = useState({}) + + const form = useForm>({ + defaultValues: { + countries: [], + }, + resolver: zodResolver(AddCountriesSchema), + }) + + const { setValue } = form + + useEffect(() => { + const ids = Object.keys(rowSelection).filter((k) => rowSelection[k]) + + setValue("countries", ids, { + shouldDirty: true, + shouldTouch: true, + }) + }, [rowSelection, setValue]) + + const { searchParams, raw } = useCountryTableQuery({ + pageSize: PAGE_SIZE, + prefix: PREFIX, + }) + const { countries, count } = useCountries({ + countries: staticCountries.map((c, i) => ({ + display_name: c.display_name, + name: c.name, + id: i, + iso_2: c.iso_2, + iso_3: c.iso_3, + num_code: c.num_code, + region_id: null, + region: {} as Region, + })), + ...searchParams, + }) + + const columns = useColumns() + + const { table } = useDataTable({ + data: countries || [], + columns, + count, + enablePagination: true, + enableRowSelection: (row) => { + return ( + region.countries?.findIndex((c) => c.iso_2 === row.original.iso_2) === + -1 + ) + }, + getRowId: (row) => row.iso_2, + pageSize: PAGE_SIZE, + rowSelection: { + state: rowSelection, + updater: setRowSelection, + }, + prefix: PREFIX, + }) + + const { mutateAsync, isLoading } = useAdminUpdateRegion(region.id) + + const handleSubmit = form.handleSubmit(async (values) => { + const payload = [ + ...region.countries.map((c) => c.iso_2), + ...values.countries, + ] + + await mutateAsync( + { + countries: payload, + }, + { + onSuccess: () => { + handleSuccess() + }, + } + ) + }) + + return ( + +
    + +
    + + + + +
    +
    + + + +
    +
    + ) +} + +const columnHelper = createColumnHelper() + +const useColumns = () => { + const base = useCountryTableColumns() + + return useMemo( + () => [ + columnHelper.display({ + id: "select", + header: ({ table }) => { + return ( + + table.toggleAllPageRowsSelected(!!value) + } + /> + ) + }, + cell: ({ row }) => { + const isPreselected = !row.getCanSelect() + + return ( + row.toggleSelected(!!value)} + onClick={(e) => { + e.stopPropagation() + }} + /> + ) + }, + }), + ...base, + ], + [base] + ) as ColumnDef[] +} diff --git a/packages/admin-next/dashboard/src/routes/regions/region-add-countries/components/add-countries-form/index.ts b/packages/admin-next/dashboard/src/routes/regions/region-add-countries/components/add-countries-form/index.ts new file mode 100644 index 0000000000..a94a3ce94b --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/regions/region-add-countries/components/add-countries-form/index.ts @@ -0,0 +1 @@ +export * from "./add-countries-form" diff --git a/packages/admin-next/dashboard/src/routes/regions/region-add-countries/index.ts b/packages/admin-next/dashboard/src/routes/regions/region-add-countries/index.ts new file mode 100644 index 0000000000..cfdcda9745 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/regions/region-add-countries/index.ts @@ -0,0 +1 @@ +export { RegionAddCountries as Component } from "./region-add-countries" diff --git a/packages/admin-next/dashboard/src/routes/regions/region-add-countries/region-add-countries.tsx b/packages/admin-next/dashboard/src/routes/regions/region-add-countries/region-add-countries.tsx new file mode 100644 index 0000000000..4bb3e92371 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/regions/region-add-countries/region-add-countries.tsx @@ -0,0 +1,20 @@ +import { useAdminRegion } from "medusa-react" +import { useParams } from "react-router-dom" +import { RouteFocusModal } from "../../../components/route-modal" +import { AddCountriesForm } from "./components/add-countries-form" + +export const RegionAddCountries = () => { + const { id } = useParams() + + const { region, isLoading, isError, error } = useAdminRegion(id!) + + if (isError) { + throw error + } + + return ( + + {!isLoading && region && } + + ) +} diff --git a/packages/admin-next/dashboard/src/routes/regions/region-create-shipping-option/components/create-shipping-option-form/create-shipping-option-form.tsx b/packages/admin-next/dashboard/src/routes/regions/region-create-shipping-option/components/create-shipping-option-form/create-shipping-option-form.tsx new file mode 100644 index 0000000000..95fdeea225 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/regions/region-create-shipping-option/components/create-shipping-option-form/create-shipping-option-form.tsx @@ -0,0 +1,558 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { + FulfillmentOption, + Region, + ShippingOptionRequirement, + ShippingProfile, +} from "@medusajs/medusa" +import { + Button, + CurrencyInput, + Heading, + Input, + RadioGroup, + Select, + Switch, + Text, + clx, +} from "@medusajs/ui" +import { useAdminCreateShippingOption } from "medusa-react" +import { useForm, useWatch } from "react-hook-form" +import { useTranslation } from "react-i18next" +import * as zod from "zod" +import { Form } from "../../../../../components/common/form" +import { IncludesTaxTooltip } from "../../../../../components/common/tax-badge/tax-badge" +import { + RouteFocusModal, + useRouteModal, +} from "../../../../../components/route-modal" +import { formatProvider } from "../../../../../lib/format-provider" +import { getDbAmount } from "../../../../../lib/money-amount-helpers" +import { ShippingOptionPriceType } from "../../../shared/constants" + +type CreateShippingOptionProps = { + region: Region + shippingProfiles: ShippingProfile[] + fulfillmentOptions: FulfillmentOption[] +} + +enum ShippingOptionType { + OUTBOUND = "outbound", + RETURN = "return", +} + +const CreateShippingOptionSchema = zod + .object({ + name: zod.string().min(1), + type: zod.nativeEnum(ShippingOptionType), + admin_only: zod.boolean(), + provider_id: zod.string().min(1), + profile_id: zod.string().min(1), + includes_tax: zod.boolean(), + price_type: zod.nativeEnum(ShippingOptionPriceType), + amount: zod + .union([zod.string(), zod.number()]) + .refine((value) => { + if (value === "") { + return false + } + + const num = Number(value) + + if (isNaN(num)) { + return false + } + + return num >= 0 + }, "Amount must be a positive number") + .optional(), + min_subtotal: zod + .union([zod.string(), zod.number()]) + .refine((value) => { + if (value === "") { + return true + } + + const num = Number(value) + + if (isNaN(num)) { + return false + } + + return num >= 0 + }, "Min. subtotal must be a positive number") + .optional(), + max_subtotal: zod + .union([zod.string(), zod.number()]) + .refine((value) => { + if (value === "") { + return true + } + + const num = Number(value) + + if (isNaN(num)) { + return false + } + + return num >= 0 + }, "Max. subtotal must be a positive number") + .optional(), + }) + .superRefine((data, ctx) => { + if (data.price_type === ShippingOptionPriceType.FLAT_RATE) { + if (typeof data.amount === "string" && data.amount === "") { + return ctx.addIssue({ + code: "custom", + message: "Amount is required", + path: ["amount"], + }) + } + } + }) + +export const CreateShippingOptionForm = ({ + region, + fulfillmentOptions, + shippingProfiles, +}: CreateShippingOptionProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const form = useForm>({ + defaultValues: { + name: "", + type: ShippingOptionType.OUTBOUND, + admin_only: false, + provider_id: "", + profile_id: "", + price_type: ShippingOptionPriceType.FLAT_RATE, + includes_tax: false, + amount: "", + min_subtotal: "", + max_subtotal: "", + }, + resolver: zodResolver(CreateShippingOptionSchema), + }) + + const watchedPriceType = useWatch({ + control: form.control, + name: "price_type", + defaultValue: ShippingOptionPriceType.FLAT_RATE, + }) + + const isFlatRate = watchedPriceType === ShippingOptionPriceType.FLAT_RATE + + const includesTax = useWatch({ + control: form.control, + name: "includes_tax", + defaultValue: false, + }) + + const { mutateAsync, isLoading } = useAdminCreateShippingOption() + + const getPricePayload = (amount?: string | number) => { + if (!amount) { + return undefined + } + + const amountValue = typeof amount === "string" ? Number(amount) : amount + return getDbAmount(amountValue, region.currency_code) + } + + const handleSubmit = form.handleSubmit(async (values) => { + const { type, amount, min_subtotal, max_subtotal, ...rest } = values + + const amountPayload = getPricePayload(amount) + + const minSubtotalPayload = getPricePayload(min_subtotal) + const maxSubtotalPayload = getPricePayload(max_subtotal) + + const minSubtotalRequirement = minSubtotalPayload + ? { + amount: minSubtotalPayload, + type: "min_subtotal", + } + : undefined + + const maxSubtotalRequirement = maxSubtotalPayload + ? { + amount: maxSubtotalPayload, + type: "max_subtotal", + } + : undefined + + const requirements = [ + minSubtotalRequirement, + maxSubtotalRequirement, + ].filter(Boolean) as ShippingOptionRequirement[] + + await mutateAsync( + { + region_id: region.id, + data: {}, + is_return: type === ShippingOptionType.RETURN, + amount: isFlatRate ? amountPayload : undefined, + requirements: requirements.length ? requirements : undefined, + ...rest, + }, + { + onSuccess: () => { + handleSuccess() + }, + onError: (error) => { + console.error(error) + }, + } + ) + }) + + return ( + +
    + +
    + + + + +
    +
    + +
    +
    +
    + + {t("regions.shippingOption.createShippingOption")} + + + {t("regions.shippingOption.createShippingOptionHint")} + +
    + { + return ( + + {t("fields.type")} + + + + + + + + ) + }} + /> +
    + { + return ( + +
    +
    + + {t("regions.shippingOption.availability.adminOnly")} + + + + +
    + + {t( + "regions.shippingOption.availability.adminOnlyHint" + )} + +
    + +
    + ) + }} + /> +
    + { + return ( + +
    +
    + + {t("fields.taxInclusivePricing")} + + + + +
    + {t("regions.taxInclusiveHint")} + +
    +
    + ) + }} + /> +
    +
    +
    + { + return ( + + {t("fields.name")} + + + + + + ) + }} + /> + { + return ( + + + {t("regions.shippingOption.priceType.label")} + + + + + + + ) + }} + /> + {isFlatRate && ( + { + return ( + + + } + > + {t("fields.price")} + + + + + + + ) + }} + /> + )} +
    +
    +
    +
    +
    + { + return ( + + {t("fields.shippingProfile")} + + + + + + ) + }} + /> + { + return ( + + + {t("fields.fulfillmentProvider")} + + + + + + + ) + }} + /> +
    +
    +
    +
    +
    + + {t("regions.shippingOption.requirements.label")} + + + {t("regions.shippingOption.requirements.hint")} + +
    +
    + { + return ( + + + } + optional + > + {t("fields.minSubtotal")} + + + + + + + ) + }} + /> + { + return ( + + + } + optional + > + {t("fields.maxSubtotal")} + + + + + + + ) + }} + /> +
    +
    +
    +
    + + + + ) +} diff --git a/packages/admin-next/dashboard/src/routes/regions/region-create-shipping-option/components/create-shipping-option-form/index.ts b/packages/admin-next/dashboard/src/routes/regions/region-create-shipping-option/components/create-shipping-option-form/index.ts new file mode 100644 index 0000000000..70cf97cc0c --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/regions/region-create-shipping-option/components/create-shipping-option-form/index.ts @@ -0,0 +1 @@ +export * from "./create-shipping-option-form" diff --git a/packages/admin-next/dashboard/src/routes/regions/region-create-shipping-option/index.ts b/packages/admin-next/dashboard/src/routes/regions/region-create-shipping-option/index.ts new file mode 100644 index 0000000000..b0de3f611e --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/regions/region-create-shipping-option/index.ts @@ -0,0 +1 @@ +export { RegionCreateShippingOption as Component } from "./region-create-shipping-option" diff --git a/packages/admin-next/dashboard/src/routes/regions/region-create-shipping-option/region-create-shipping-option.tsx b/packages/admin-next/dashboard/src/routes/regions/region-create-shipping-option/region-create-shipping-option.tsx new file mode 100644 index 0000000000..816ac59015 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/regions/region-create-shipping-option/region-create-shipping-option.tsx @@ -0,0 +1,59 @@ +import { + useAdminRegion, + useAdminRegionFulfillmentOptions, + useAdminShippingProfiles, +} from "medusa-react" +import { useParams } from "react-router-dom" +import { RouteFocusModal } from "../../../components/route-modal" +import { CreateShippingOptionForm } from "./components/create-shipping-option-form" + +export const RegionCreateShippingOption = () => { + const { id } = useParams() + + const { + region, + isLoading: isLoadingRegion, + isError: isRegionError, + error: regionError, + } = useAdminRegion(id!) + + const { + shipping_profiles, + isLoading: isLoadingProfiles, + isError: isProfilesError, + error: profileError, + } = useAdminShippingProfiles() + + const { + fulfillment_options, + isLoading: isLoadingOptions, + isError: isOptionsError, + error: optionsError, + } = useAdminRegionFulfillmentOptions(id!) + + const isLoading = isLoadingProfiles || isLoadingOptions || isLoadingRegion + + if (isRegionError) { + throw regionError + } + + if (isProfilesError) { + throw profileError + } + + if (isOptionsError) { + throw optionsError + } + + return ( + + {!isLoading && region && shipping_profiles && fulfillment_options && ( + + )} + + ) +} diff --git a/packages/admin-next/dashboard/src/routes/regions/region-create/components/create-region-form/create-region-form.tsx b/packages/admin-next/dashboard/src/routes/regions/region-create/components/create-region-form/create-region-form.tsx index f9bfb279c1..d466a58063 100644 --- a/packages/admin-next/dashboard/src/routes/regions/region-create/components/create-region-form/create-region-form.tsx +++ b/packages/admin-next/dashboard/src/routes/regions/region-create/components/create-region-form/create-region-form.tsx @@ -1,25 +1,82 @@ import { zodResolver } from "@hookform/resolvers/zod" -import { Button, Heading, Input, Select, Switch, Text } from "@medusajs/ui" -import { useAdminCreateRegion, useAdminStore } from "medusa-react" -import { useForm } from "react-hook-form" +import { XMarkMini } from "@medusajs/icons" +import { + Country, + Currency, + FulfillmentProvider, + PaymentProvider, +} from "@medusajs/medusa" +import { + Button, + Checkbox, + Heading, + Input, + Select, + Switch, + Text, + clx, +} from "@medusajs/ui" +import { RowSelectionState, createColumnHelper } from "@tanstack/react-table" +import { useAdminCreateRegion } from "medusa-react" +import { useMemo, useState } from "react" +import { useForm, useWatch } from "react-hook-form" import { useTranslation } from "react-i18next" -import { useNavigate } from "react-router-dom" import * as zod from "zod" + +import { Combobox } from "../../../../../components/common/combobox" import { Form } from "../../../../../components/common/form" +import { SplitView } from "../../../../../components/layout/split-view" +import { useRouteModal } from "../../../../../components/route-modal" import { RouteFocusModal } from "../../../../../components/route-modal/route-focus-modal" +import { DataTable } from "../../../../../components/table/data-table" +import { useDataTable } from "../../../../../hooks/use-data-table" +import { countries as staticCountries } from "../../../../../lib/countries" +import { formatProvider } from "../../../../../lib/format-provider" +import { useCountries } from "../../../shared/hooks/use-countries" +import { useCountryTableColumns } from "../../../shared/hooks/use-country-table-columns" +import { useCountryTableQuery } from "../../../shared/hooks/use-country-table-query" + +type CreateRegionFormProps = { + currencies: Currency[] + paymentProviders: PaymentProvider[] + fulfillmentProviders: FulfillmentProvider[] +} const CreateRegionSchema = zod.object({ name: zod.string().min(1), - currency_code: zod.string(), + currency_code: zod.string().min(2, "Select a currency"), includes_tax: zod.boolean(), - countries: zod.array(zod.string()), + countries: zod.array(zod.object({ code: zod.string(), name: zod.string() })), fulfillment_providers: zod.array(zod.string()).min(1), payment_providers: zod.array(zod.string()).min(1), - tax_rate: zod.number().min(0).max(1), + tax_rate: zod.union([zod.string(), zod.number()]).refine((value) => { + if (value === "") { + return false + } + + const num = Number(value) + + if (num >= 0 && num <= 100) { + return true + } + + return false + }, "Tax rate must be a number between 0 and 100"), tax_code: zod.string().optional(), }) -export const CreateRegionForm = () => { +const PREFIX = "cr" +const PAGE_SIZE = 50 + +export const CreateRegionForm = ({ + currencies, + paymentProviders, + fulfillmentProviders, +}: CreateRegionFormProps) => { + const [open, setOpen] = useState(false) + const [rowSelection, setRowSelection] = useState({}) + const { handleSuccess } = useRouteModal() + const form = useForm>({ defaultValues: { name: "", @@ -29,39 +86,129 @@ export const CreateRegionForm = () => { fulfillment_providers: [], payment_providers: [], tax_code: "", - tax_rate: 0, + tax_rate: "", }, resolver: zodResolver(CreateRegionSchema), }) + const selectedCountries = useWatch({ + control: form.control, + name: "countries", + defaultValue: [], + }) + const { t } = useTranslation() - const navigate = useNavigate() const { mutateAsync, isLoading } = useAdminCreateRegion() - const { store } = useAdminStore() - const storeCurrencies = store?.currencies ?? [] - const handleSubmit = form.handleSubmit(async (values) => { await mutateAsync( { name: values.name, - countries: values.countries, + countries: values.countries.map((c) => c.code), currency_code: values.currency_code, fulfillment_providers: values.fulfillment_providers, payment_providers: values.payment_providers, - tax_rate: values.tax_rate, + tax_rate: + typeof values.tax_rate === "string" + ? Number(values.tax_rate) + : values.tax_rate, tax_code: values.tax_code, includes_tax: values.includes_tax, }, { onSuccess: ({ region }) => { - navigate(`../${region.id}`) + handleSuccess(`../${region.id}`) }, } ) }) + const { searchParams, raw } = useCountryTableQuery({ + pageSize: PAGE_SIZE, + prefix: PREFIX, + }) + const { countries, count } = useCountries({ + countries: staticCountries.map((c, i) => ({ + display_name: c.display_name, + name: c.name, + id: i, + iso_2: c.iso_2, + iso_3: c.iso_3, + num_code: c.num_code, + region_id: null, + region: {} as any, + })), + ...searchParams, + }) + + const columns = useColumns() + + const { table } = useDataTable({ + data: countries || [], + columns, + count, + enablePagination: true, + enableRowSelection: true, + rowSelection: { + state: rowSelection, + updater: setRowSelection, + }, + getRowId: (row) => row.iso_2, + pageSize: PAGE_SIZE, + prefix: PREFIX, + }) + + const saveCountries = () => { + const selected = Object.keys(rowSelection).filter( + (key) => rowSelection[key] + ) + + form.setValue( + "countries", + selected.map((key) => ({ + code: key, + name: staticCountries.find((c) => c.iso_2 === key)!.display_name, + })), + { shouldDirty: true, shouldTouch: true } + ) + + setOpen(false) + } + + const onOpenChange = (open: boolean) => { + setOpen(open) + + if (!open) { + const ids = selectedCountries.reduce((acc, c) => { + acc[c.code] = true + return acc + }, {} as RowSelectionState) + + requestAnimationFrame(() => { + setRowSelection(ids) + }) + } + } + + const removeCountry = (code: string) => { + const update = selectedCountries.filter((c) => c.code !== code) + const ids = update + .map((c) => c.code) + .reduce((acc, c) => { + acc[c] = true + return acc + }, {} as RowSelectionState) + + form.setValue("countries", update, { shouldDirty: true, shouldTouch: true }) + setRowSelection(ids) + } + + const clearCountries = () => { + form.setValue("countries", [], { shouldDirty: true, shouldTouch: true }) + setRowSelection({}) + } + return (
    {
    - -
    -
    - {t("regions.createRegion")} - - {t("regions.createRegionHint")} - -
    -
    -
    - { - return ( - - {t("fields.name")} - - - - - - ) - }} - /> - { - return ( - - {t("fields.currency")} - - - - - - ) - }} - /> -
    -
    - { - return ( - - {t("fields.taxRate")} - - - - - - ) - }} - /> - { - return ( - - {t("fields.taxCode")} - - - - - - ) - }} - /> -
    -
    - { - return ( - -
    -
    - - {t("fields.taxInclusivePricing")} - - - - -
    - {t("regions.taxInclusiveHint")} - + + + +
    +
    +
    + {t("regions.createRegion")} + + {t("regions.createRegionHint")} + +
    +
    +
    + { + return ( + + {t("fields.name")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.currency")} + + + + + + ) + }} + />
    - - ) - }} - /> -
    -
    - - {t("fields.providers")} - - - {t("regions.providersHint")} - +
    + { + return ( + + {t("fields.taxRate")} + + + + + + ) + }} + /> + { + return ( + + + {t("fields.taxCode")} + + + + + + + ) + }} + /> +
    +
    + { + return ( + +
    +
    + + {t("fields.taxInclusivePricing")} + + + + +
    + + {t("regions.taxInclusiveHint")} + + +
    +
    + ) + }} + /> +
    +
    +
    + + {t("fields.countries")} + + + {t("regions.countriesHint")} + +
    + {selectedCountries.length > 0 && ( +
    + {selectedCountries.map((country) => ( + + ))} + +
    + )} +
    + + {t("regions.addCountries")} + +
    +
    +
    +
    +
    + + {t("fields.providers")} + + + {t("regions.providersHint")} + +
    +
    + { + return ( + + + {t("fields.paymentProviders")} + + + ({ + label: formatProvider(pp.id), + value: pp.id, + }))} + {...field} + /> + + + + ) + }} + /> + { + return ( + + + {t("fields.fulfillmentProviders")} + + + ({ + label: formatProvider(fp.id), + value: fp.id, + }))} + {...field} + /> + + + + ) + }} + /> +
    +
    +
    -
    -
    -
    + + +
    + +
    + + {t("actions.cancel")} + + +
    +
    +
    + ) } + +const columnHelper = createColumnHelper() + +const useColumns = () => { + const base = useCountryTableColumns() + + return useMemo( + () => [ + columnHelper.display({ + id: "select", + header: ({ table }) => { + return ( + + table.toggleAllPageRowsSelected(!!value) + } + /> + ) + }, + cell: ({ row }) => { + const isPreselected = !row.getCanSelect() + + return ( + row.toggleSelected(!!value)} + onClick={(e) => { + e.stopPropagation() + }} + /> + ) + }, + }), + ...base, + ], + [base] + ) +} + +const CountryTag = ({ + country, + onRemove, +}: { + country: { code: string; name: string } + onRemove: (code: string) => void +}) => { + return ( +
    +
    + {country.name} +
    + +
    + ) +} diff --git a/packages/admin-next/dashboard/src/routes/regions/region-create/region-create.tsx b/packages/admin-next/dashboard/src/routes/regions/region-create/region-create.tsx index 922932067a..261de930bb 100644 --- a/packages/admin-next/dashboard/src/routes/regions/region-create/region-create.tsx +++ b/packages/admin-next/dashboard/src/routes/regions/region-create/region-create.tsx @@ -1,10 +1,27 @@ +import { useAdminStore } from "medusa-react" import { RouteFocusModal } from "../../../components/route-modal/route-focus-modal" import { CreateRegionForm } from "./components/create-region-form" export const RegionCreate = () => { + const { store, isLoading, isError, error } = useAdminStore() + + const currencies = store?.currencies ?? [] + const paymentProviders = store?.payment_providers ?? [] + const fulfillmentProviders = store?.fulfillment_providers ?? [] + + if (isError) { + throw error + } + return ( - + {!isLoading && store && ( + + )} ) } diff --git a/packages/admin-next/dashboard/src/routes/regions/region-detail/components/region-country-section/index.ts b/packages/admin-next/dashboard/src/routes/regions/region-detail/components/region-country-section/index.ts new file mode 100644 index 0000000000..d83eafb692 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/regions/region-detail/components/region-country-section/index.ts @@ -0,0 +1 @@ +export * from "./region-country-section" diff --git a/packages/admin-next/dashboard/src/routes/regions/region-detail/components/region-country-section/region-country-section.tsx b/packages/admin-next/dashboard/src/routes/regions/region-detail/components/region-country-section/region-country-section.tsx new file mode 100644 index 0000000000..82bfad3967 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/regions/region-detail/components/region-country-section/region-country-section.tsx @@ -0,0 +1,239 @@ +import { PlusMini, Trash } from "@medusajs/icons" +import { Region } from "@medusajs/medusa" +import { Checkbox, Container, Heading, usePrompt } from "@medusajs/ui" +import { + ColumnDef, + RowSelectionState, + createColumnHelper, +} from "@tanstack/react-table" +import { useAdminUpdateRegion } from "medusa-react" +import { useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { ActionMenu } from "../../../../../components/common/action-menu" +import { DataTable } from "../../../../../components/table/data-table" +import { useDataTable } from "../../../../../hooks/use-data-table" +import { useCountries } from "../../../shared/hooks/use-countries" +import { useCountryTableColumns } from "../../../shared/hooks/use-country-table-columns" +import { useCountryTableQuery } from "../../../shared/hooks/use-country-table-query" + +type RegionCountrySectionProps = { + region: Region +} + +type Country = { + id: number + iso_2: string + iso_3: string + num_code: number + name: string + display_name: string +} + +const PREFIX = "c" +const PAGE_SIZE = 10 + +export const RegionCountrySection = ({ region }: RegionCountrySectionProps) => { + const { t } = useTranslation() + const prompt = usePrompt() + + const [rowSelection, setRowSelection] = useState({}) + + const { searchParams, raw } = useCountryTableQuery({ + pageSize: PAGE_SIZE, + prefix: PREFIX, + }) + const { countries, count } = useCountries({ + countries: region.countries || [], + ...searchParams, + }) + + const columns = useColumns() + + const { table } = useDataTable({ + data: countries || [], + columns, + count, + enablePagination: true, + enableRowSelection: true, + getRowId: (row) => row.iso_2, + pageSize: PAGE_SIZE, + rowSelection: { + state: rowSelection, + updater: setRowSelection, + }, + prefix: PREFIX, + meta: { + region, + }, + }) + + const { mutateAsync } = useAdminUpdateRegion(region.id) + + const handleRemoveCountries = async () => { + const ids = Object.keys(rowSelection).filter((k) => rowSelection[k]) + + const res = await prompt({ + title: t("general.areYouSure"), + description: t("regions.removeCountriesWarning", { + count: ids.length, + }), + verificationText: t("actions.remove"), + verificationInstruction: t("general.typeToConfirm"), + cancelText: t("actions.cancel"), + confirmText: t("actions.remove"), + }) + + if (!res) { + return + } + + const payload = region.countries + .filter((c) => !ids.includes(c.iso_2)) + .map((c) => c.iso_2) + + await mutateAsync({ + countries: payload, + }) + } + + return ( + +
    + {t("fields.countries")} + , + to: "countries/add", + }, + ], + }, + ]} + /> +
    + +
    + ) +} + +const CountryActions = ({ + country, + region, +}: { + country: Country + region: Region +}) => { + const { t } = useTranslation() + const prompt = usePrompt() + const { mutateAsync } = useAdminUpdateRegion(region.id) + + const payload = region.countries + ?.filter((c) => c.iso_2 !== country.iso_2) + .map((c) => c.iso_2) + + const handleRemove = async () => { + const res = await prompt({ + title: t("general.areYouSure"), + description: t("regions.removeCountryWarning", { + name: country.display_name, + }), + verificationText: country.display_name, + verificationInstruction: t("general.typeToConfirm"), + cancelText: t("actions.cancel"), + confirmText: t("actions.remove"), + }) + + if (!res) { + return + } + + await mutateAsync({ + countries: payload, + }) + } + + return ( + , + }, + ], + }, + ]} + /> + ) +} + +const columnHelper = createColumnHelper() + +const useColumns = () => { + const base = useCountryTableColumns() + + return useMemo( + () => [ + columnHelper.display({ + id: "select", + header: ({ table }) => { + return ( + + table.toggleAllPageRowsSelected(!!value) + } + /> + ) + }, + cell: ({ row }) => { + return ( + row.toggleSelected(!!value)} + onClick={(e) => { + e.stopPropagation() + }} + /> + ) + }, + }), + ...base, + columnHelper.display({ + id: "actions", + cell: ({ row, table }) => { + const { region } = table.options.meta as { region: Region } + + return + }, + }), + ], + [base] + ) as ColumnDef[] +} diff --git a/packages/admin-next/dashboard/src/routes/regions/region-detail/components/region-general-section/region-general-section.tsx b/packages/admin-next/dashboard/src/routes/regions/region-detail/components/region-general-section/region-general-section.tsx index 9f8e40d4a5..66b18e9b1b 100644 --- a/packages/admin-next/dashboard/src/routes/regions/region-detail/components/region-general-section/region-general-section.tsx +++ b/packages/admin-next/dashboard/src/routes/regions/region-detail/components/region-general-section/region-general-section.tsx @@ -1,21 +1,10 @@ -import { PencilSquare, Trash } from "@medusajs/icons" -import { Country, Region } from "@medusajs/medusa" -import { - Badge, - Button, - Container, - Drawer, - Heading, - StatusBadge, - Text, - Tooltip, - usePrompt, -} from "@medusajs/ui" -import { useAdminDeleteRegion, useAdminUpdateRegion } from "medusa-react" -import { useForm } from "react-hook-form" +import { BuildingTax, PencilSquare, Trash } from "@medusajs/icons" +import { Region } from "@medusajs/medusa" +import { Badge, Container, Heading, Text, usePrompt } from "@medusajs/ui" +import { useAdminDeleteRegion } from "medusa-react" import { useTranslation } from "react-i18next" -import * as zod from "zod" import { ActionMenu } from "../../../../../components/common/action-menu" +import { formatProvider } from "../../../../../lib/format-provider" type RegionGeneralSectionProps = { region: Region @@ -28,15 +17,8 @@ export const RegionGeneralSection = ({ region }: RegionGeneralSectionProps) => {
    {region.name} -
    -
    - - {t("fields.countries")} - - -
    {t("fields.currency")} @@ -50,24 +32,15 @@ export const RegionGeneralSection = ({ region }: RegionGeneralSectionProps) => {
    -
    - - {t("fields.taxInclusivePricing")} - - - {region.includes_tax ? t("general.enabled") : t("general.disabled")} - -
    {t("fields.paymentProviders")} {region.payment_providers.length > 0 - ? region.payment_providers.map((p) => p.id).join(", ") + ? region.payment_providers + .map((p) => formatProvider(p.id)) + .join(", ") : "-"}
    @@ -77,7 +50,9 @@ export const RegionGeneralSection = ({ region }: RegionGeneralSectionProps) => { {region.fulfillment_providers.length > 0 - ? region.fulfillment_providers.map((p) => p.id).join(", ") + ? region.fulfillment_providers + .map((p) => formatProvider(p.id)) + .join(", ") : "-"}
    @@ -119,6 +94,11 @@ const RegionActions = ({ region }: { region: Region }) => { label: t("actions.edit"), to: `/settings/regions/${region.id}/edit`, }, + { + icon: , + label: "Tax settings", + to: `/settings/taxes/${region.id}`, + }, ], }, { @@ -134,87 +114,3 @@ const RegionActions = ({ region }: { region: Region }) => { /> ) } - -const RegionCountries = ({ countries }: { countries: Country[] }) => { - const { t } = useTranslation() - - const countIsGreaterThanTwo = countries.length > 2 - - return ( -
    - - {countries - .slice(0, 2) - .map((c) => c.display_name) - .join(", ")} - {countIsGreaterThanTwo && ", "} - - {countIsGreaterThanTwo && ( - - {countries.slice(2).map((c) => ( -
  • {c.display_name}
  • - ))} - - } - > - - {t("general.plusCountMore", { - count: countries.length - 2, - })} - -
    - )} -
    - ) -} - -const EditRegionSchema = zod.object({ - name: zod.string().min(1), - includes_tax: zod.boolean(), - currency_code: zod.string(), - countries: zod.array(zod.string()), -}) - -const EditRegionDrawer = ({ region }: { region: Region }) => { - const { t } = useTranslation() - const { mutateAsync, isLoading } = useAdminUpdateRegion(region.id) - - const form = useForm>({ - defaultValues: { - name: region.name, - currency_code: region.currency_code, - includes_tax: region.includes_tax, - countries: region.countries.map((c) => c.iso_2), - }, - }) - - const handleSubmit = form.handleSubmit(async (values) => { - await mutateAsync({ - name: values.name, - currency_code: values.currency_code, - includes_tax: values.includes_tax, - }) - }) - - return ( - - - - {t("regions.editRegion")} - - - - - - - - - ) -} diff --git a/packages/admin-next/dashboard/src/routes/regions/region-detail/components/region-shipping-option-section/region-shipping-option-section.tsx b/packages/admin-next/dashboard/src/routes/regions/region-detail/components/region-shipping-option-section/region-shipping-option-section.tsx index b35f2af342..5ca24e4062 100644 --- a/packages/admin-next/dashboard/src/routes/regions/region-detail/components/region-shipping-option-section/region-shipping-option-section.tsx +++ b/packages/admin-next/dashboard/src/routes/regions/region-detail/components/region-shipping-option-section/region-shipping-option-section.tsx @@ -3,7 +3,9 @@ import { Container, Heading } from "@medusajs/ui" import { useAdminShippingOptions } from "medusa-react" import { useTranslation } from "react-i18next" +import { PlusMini } from "@medusajs/icons" import { PricedShippingOption } from "@medusajs/medusa/dist/types/pricing" +import { ActionMenu } from "../../../../../components/common/action-menu" import { DataTable } from "../../../../../components/table/data-table" import { useDataTable } from "../../../../../hooks/use-data-table" import { useShippingOptionColumns } from "./use-shipping-option-table-columns" @@ -53,8 +55,21 @@ export const RegionShippingOptionSection = ({ return ( -
    +
    {t("regions.shippingOptions")} + , + to: "shipping-options/create", + }, + ], + }, + ]} + />
    {
    ), }), + columnHelper.accessor("is_return", { + header: t("fields.type"), + cell: (cell) => { + const value = cell.getValue() + + return value ? t("regions.return") : t("regions.outbound") + }, + }), columnHelper.accessor("price_type", { header: t("regions.priceType"), cell: ({ getValue }) => { const type = getValue() - return ( - - {type === "flat_rate" - ? t("regions.flatRate") - : t("regions.calculated")} - - ) + return type === "flat_rate" + ? t("regions.flatRate") + : t("regions.calculated") }, }), columnHelper.accessor("price_incl_tax", { @@ -52,7 +60,11 @@ export const useShippingOptionColumns = () => { }), columnHelper.display({ id: "min_amount", - header: "Min.", + header: () => ( +
    + {t("fields.minSubtotal")} +
    + ), cell: ({ row }) => { const minAmountReq = row.original.requirements?.find( (r) => r.type === "min_subtotal" @@ -70,7 +82,11 @@ export const useShippingOptionColumns = () => { }), columnHelper.display({ id: "max_amount", - header: "Max.", + header: () => ( +
    + {t("fields.maxSubtotal")} +
    + ), cell: ({ row }) => { const maxAmountReq = row.original.requirements?.find( (r) => r.type === "max_subtotal" @@ -98,19 +114,66 @@ export const useShippingOptionColumns = () => { ) }, }), - columnHelper.accessor("is_return", { - header: t("fields.type"), - cell: (cell) => { - const value = cell.getValue() - - return ( - - {value ? t("regions.return") : t("regions.outbound")} - - ) + columnHelper.display({ + id: "actions", + cell: ({ row }) => { + return }, }), ], [t] ) } + +const ShippingOptionActions = ({ + shippingOption, +}: { + shippingOption: PricedShippingOption +}) => { + const { t } = useTranslation() + const prompt = usePrompt() + + const { mutateAsync } = useAdminDeleteShippingOption(shippingOption.id!) + + const handleDelete = async () => { + const res = await prompt({ + title: t("general.areYouSure"), + description: t("regions.deleteShippingOptionWarning", { + name: shippingOption.name, + }), + confirmText: t("actions.delete"), + cancelText: t("actions.cancel"), + }) + + if (!res) { + return + } + + await mutateAsync() + } + + return ( + , + }, + ], + }, + { + actions: [ + { + label: t("actions.delete"), + onClick: handleDelete, + icon: , + }, + ], + }, + ]} + /> + ) +} diff --git a/packages/admin-next/dashboard/src/routes/regions/region-detail/region-detail.tsx b/packages/admin-next/dashboard/src/routes/regions/region-detail/region-detail.tsx index b1ba0f7007..3b8f68cb99 100644 --- a/packages/admin-next/dashboard/src/routes/regions/region-detail/region-detail.tsx +++ b/packages/admin-next/dashboard/src/routes/regions/region-detail/region-detail.tsx @@ -1,30 +1,28 @@ import { useAdminRegion } from "medusa-react" -import { Outlet, json, useParams } from "react-router-dom" +import { Outlet, useParams } from "react-router-dom" import { JsonViewSection } from "../../../components/common/json-view-section" +import { RegionCountrySection } from "./components/region-country-section" import { RegionGeneralSection } from "./components/region-general-section" -import { RegionShippingOptionSection } from "./components/region-shipping-option-section/region-shipping-option-section" +import { RegionShippingOptionSection } from "./components/region-shipping-option-section" export const RegionDetail = () => { const { id } = useParams() const { region, isLoading, isError, error } = useAdminRegion(id!) // TODO: Move to loading.tsx and set as Suspense fallback for the route - if (isLoading) { - return
    Loading
    + if (isLoading || !region) { + return
    Loading...
    } - if (isError || !region) { - if (error) { - throw error - } - - throw json("An unknown error occurred", 500) + if (isError) { + throw error } return (
    + diff --git a/packages/admin-next/dashboard/src/routes/regions/region-edit-shipping-option/components/edit-shipping-option-form/edit-shipping-option-form.tsx b/packages/admin-next/dashboard/src/routes/regions/region-edit-shipping-option/components/edit-shipping-option-form/edit-shipping-option-form.tsx new file mode 100644 index 0000000000..c7dee64f4c --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/regions/region-edit-shipping-option/components/edit-shipping-option-form/edit-shipping-option-form.tsx @@ -0,0 +1,425 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { + Region, + ShippingOption, + ShippingOptionRequirement, +} from "@medusajs/medusa" +import { + Button, + CurrencyInput, + Input, + Select, + Switch, + Text, +} from "@medusajs/ui" +import { useAdminUpdateShippingOption } from "medusa-react" +import { useForm, useWatch } from "react-hook-form" +import { useTranslation } from "react-i18next" +import * as zod from "zod" + +import { Form } from "../../../../../components/common/form" +import { IncludesTaxTooltip } from "../../../../../components/common/tax-badge/tax-badge" +import { + RouteDrawer, + useRouteModal, +} from "../../../../../components/route-modal" +import { + getDbAmount, + getPresentationalAmount, +} from "../../../../../lib/money-amount-helpers" +import { ShippingOptionPriceType } from "../../../shared/constants" + +type EditShippingOptionFormProps = { + region: Region + shippingOption: ShippingOption +} + +const EditShippingOptionSchema = zod + .object({ + name: zod.string().min(1), + admin_only: zod.boolean(), + price_type: zod.nativeEnum(ShippingOptionPriceType), + includes_tax: zod.boolean(), + amount: zod + .union([zod.string(), zod.number()]) + .refine((value) => { + if (value === "") { + return false + } + + const num = Number(value) + + if (isNaN(num)) { + return false + } + + return num >= 0 + }, "Amount must be a positive number") + .optional(), + min_subtotal: zod + .union([zod.string(), zod.number()]) + .refine((value) => { + if (value === "") { + return true + } + + const num = Number(value) + + if (isNaN(num)) { + return false + } + + return num >= 0 + }, "Min. subtotal must be a positive number") + .optional(), + max_subtotal: zod + .union([zod.string(), zod.number()]) + .refine((value) => { + if (value === "") { + return true + } + + const num = Number(value) + + if (isNaN(num)) { + return false + } + + return num >= 0 + }, "Max. subtotal must be a positive number") + .optional(), + }) + .superRefine((data, ctx) => { + if (data.price_type === ShippingOptionPriceType.FLAT_RATE) { + if (typeof data.amount === "string" && data.amount === "") { + return ctx.addIssue({ + code: "custom", + message: "Amount is required", + path: ["amount"], + }) + } + } + }) + +export const EditShippingOptionForm = ({ + region, + shippingOption, +}: EditShippingOptionFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const defaultAmount = shippingOption.amount + ? getPresentationalAmount(shippingOption.amount, region.currency_code) + : "" + + const defaultMinSubtotal = shippingOption.requirements.find( + (r) => r.type === "min_subtotal" + ) + const defaultMinSubtotalAmount = defaultMinSubtotal + ? getPresentationalAmount(defaultMinSubtotal.amount, region.currency_code) + : "" + + const defaultMaxSubtotal = shippingOption.requirements.find( + (r) => r.type === "max_subtotal" + ) + const defaultMaxSubtotalAmount = defaultMaxSubtotal + ? getPresentationalAmount(defaultMaxSubtotal.amount, region.currency_code) + : "" + + const form = useForm>({ + defaultValues: { + admin_only: shippingOption.admin_only, + name: shippingOption.name, + amount: defaultAmount, + max_subtotal: defaultMaxSubtotalAmount, + min_subtotal: defaultMinSubtotalAmount, + price_type: shippingOption.price_type, + includes_tax: shippingOption.includes_tax, + }, + resolver: zodResolver(EditShippingOptionSchema), + }) + + const watchedPriceType = useWatch({ + control: form.control, + name: "price_type", + defaultValue: ShippingOptionPriceType.FLAT_RATE, + }) + + const isFlatRate = watchedPriceType === ShippingOptionPriceType.FLAT_RATE + + const includesTax = useWatch({ + control: form.control, + name: "includes_tax", + }) + + const { mutateAsync, isLoading } = useAdminUpdateShippingOption( + shippingOption.id + ) + + const getPricePayload = (amount?: string | number) => { + if (!amount) { + return undefined + } + + const amountValue = typeof amount === "string" ? Number(amount) : amount + return getDbAmount(amountValue, region.currency_code) + } + + const handleSubmit = form.handleSubmit(async (values) => { + const { amount, min_subtotal, max_subtotal, ...rest } = values + + const amountPayload = getPricePayload(amount) + + const minSubtotalPayload = getPricePayload(min_subtotal) + const maxSubtotalPayload = getPricePayload(max_subtotal) + + const minSubtotalRequirement = minSubtotalPayload + ? { + amount: minSubtotalPayload, + type: "min_subtotal", + } + : undefined + + const maxSubtotalRequirement = maxSubtotalPayload + ? { + amount: maxSubtotalPayload, + type: "max_subtotal", + } + : undefined + + const requirements = [ + minSubtotalRequirement, + maxSubtotalRequirement, + ].filter(Boolean) as ShippingOptionRequirement[] + + await mutateAsync( + { + amount: amountPayload, + requirements, + ...rest, + }, + { + onSuccess: () => { + handleSuccess() + }, + } + ) + }) + + return ( + +
    + + { + return ( + +
    +
    + + {t("regions.shippingOption.availability.adminOnly")} + + + + +
    + + {t("regions.shippingOption.availability.adminOnlyHint")} + +
    + +
    + ) + }} + /> +
    + { + return ( + +
    +
    + {t("fields.taxInclusivePricing")} + + + +
    + {t("regions.taxInclusiveHint")} + +
    +
    + ) + }} + /> +
    +
    + { + return ( + + {t("fields.name")} + + + + + + ) + }} + /> + { + return ( + + + {t("regions.shippingOption.priceType.label")} + + + + + + + ) + }} + /> + {isFlatRate && ( + { + return ( + + } + > + {t("fields.price")} + + + + + + + ) + }} + /> + )} +
    +
    +
    +
    + + {t("regions.shippingOption.requirements.label")} + + + {t("regions.shippingOption.requirements.hint")} + +
    +
    + { + return ( + + } + optional + > + {t("fields.minSubtotal")} + + + + + + + ) + }} + /> + { + return ( + + } + optional + > + {t("fields.maxSubtotal")} + + + + + + + ) + }} + /> +
    +
    + + +
    + + + + +
    +
    + + + ) +} diff --git a/packages/admin-next/dashboard/src/routes/regions/region-edit-shipping-option/components/edit-shipping-option-form/index.ts b/packages/admin-next/dashboard/src/routes/regions/region-edit-shipping-option/components/edit-shipping-option-form/index.ts new file mode 100644 index 0000000000..0c64e93b32 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/regions/region-edit-shipping-option/components/edit-shipping-option-form/index.ts @@ -0,0 +1 @@ +export * from "./edit-shipping-option-form" diff --git a/packages/admin-next/dashboard/src/routes/regions/region-edit-shipping-option/index.ts b/packages/admin-next/dashboard/src/routes/regions/region-edit-shipping-option/index.ts new file mode 100644 index 0000000000..359b8d4e83 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/regions/region-edit-shipping-option/index.ts @@ -0,0 +1 @@ +export { RegionEditShippingOption as Component } from "./region-edit-shipping-option" diff --git a/packages/admin-next/dashboard/src/routes/regions/region-edit-shipping-option/region-edit-shipping-option.tsx b/packages/admin-next/dashboard/src/routes/regions/region-edit-shipping-option/region-edit-shipping-option.tsx new file mode 100644 index 0000000000..366b65a321 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/regions/region-edit-shipping-option/region-edit-shipping-option.tsx @@ -0,0 +1,49 @@ +import { Heading } from "@medusajs/ui" +import { useAdminRegion, useAdminShippingOption } from "medusa-react" +import { useTranslation } from "react-i18next" +import { useParams } from "react-router-dom" +import { RouteDrawer } from "../../../components/route-modal" +import { EditShippingOptionForm } from "./components/edit-shipping-option-form" + +export const RegionEditShippingOption = () => { + const { id, so_id } = useParams() + const { t } = useTranslation() + + const { + region, + isLoading: isLoadingRegion, + isError: isRegionError, + error: regionError, + } = useAdminRegion(id!) + + const { + shipping_option, + isLoading: isLoadingOption, + isError: isOptionError, + error: optionError, + } = useAdminShippingOption(so_id!) + + const isLoading = isLoadingRegion || isLoadingOption + + if (isRegionError) { + throw regionError + } + + if (isOptionError) { + throw optionError + } + + return ( + + + {t("regions.shippingOption.editShippingOption")} + + {!isLoading && region && shipping_option && ( + + )} + + ) +} diff --git a/packages/admin-next/dashboard/src/routes/regions/region-edit/components/edit-region-form/edit-region-form.tsx b/packages/admin-next/dashboard/src/routes/regions/region-edit/components/edit-region-form/edit-region-form.tsx index 8c05aa461b..13ed33e53f 100644 --- a/packages/admin-next/dashboard/src/routes/regions/region-edit/components/edit-region-form/edit-region-form.tsx +++ b/packages/admin-next/dashboard/src/routes/regions/region-edit/components/edit-region-form/edit-region-form.tsx @@ -1,105 +1,190 @@ -import { Region } from "@medusajs/medusa" -import { Button, Drawer, Input, Switch } from "@medusajs/ui" +import { + Currency, + FulfillmentProvider, + PaymentProvider, + Region, +} from "@medusajs/medusa" +import { Button, Input, Select, Text } from "@medusajs/ui" import { useAdminUpdateRegion } from "medusa-react" import { useForm } from "react-hook-form" import { useTranslation } from "react-i18next" import * as zod from "zod" + +import { Combobox } from "../../../../../components/common/combobox" import { Form } from "../../../../../components/common/form" +import { + RouteDrawer, + useRouteModal, +} from "../../../../../components/route-modal" +import { formatProvider } from "../../../../../lib/format-provider" type EditRegionFormProps = { region: Region + currencies: Currency[] + paymentProviders: PaymentProvider[] + fulfillmentProviders: FulfillmentProvider[] } const EditRegionSchema = zod.object({ name: zod.string().min(1), - includes_tax: zod.boolean(), currency_code: zod.string(), - countries: zod.array(zod.string()), + payment_providers: zod.array(zod.string()), + fulfillment_providers: zod.array(zod.string()), }) -export const EditRegionForm = ({ region }: EditRegionFormProps) => { - const { mutateAsync, isLoading } = useAdminUpdateRegion(region.id) +export const EditRegionForm = ({ + region, + currencies, + paymentProviders, + fulfillmentProviders, +}: EditRegionFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() const form = useForm>({ defaultValues: { name: region.name, currency_code: region.currency_code, - includes_tax: region.includes_tax, - countries: region.countries.map((c) => c.iso_2), + fulfillment_providers: region.fulfillment_providers.map((fp) => fp.id), + payment_providers: region.payment_providers.map((pp) => pp.id), }, }) - const { t } = useTranslation() + const { mutateAsync, isLoading } = useAdminUpdateRegion(region.id) const handleSubmit = form.handleSubmit(async (values) => { - await mutateAsync({ - name: values.name, - currency_code: values.currency_code, - includes_tax: values.includes_tax, - }) + await mutateAsync( + { + name: values.name, + currency_code: values.currency_code, + fulfillment_providers: values.fulfillment_providers, + payment_providers: values.payment_providers, + }, + { + onSuccess: () => { + handleSuccess() + }, + } + ) }) return ( -
    + - -
    - { - return ( - - {t("fields.name")} - - - - - - ) - }} - /> - { - return ( - -
    -
    - - {t("fields.taxInclusivePricing")} - - {t("regions.taxInclusiveHint")} -
    + +
    +
    + { + return ( + + {t("fields.name")} - + + + + ) + }} + /> + { + return ( + + {t("fields.currency")} + + + + + + ) + }} + /> +
    +
    +
    + + Providers + + + {t("regions.providersHint")} + +
    + { + return ( + + {t("fields.paymentProviders")} + + ({ + label: formatProvider(pp.id), + value: pp.id, + }))} {...field} - checked={value} - onCheckedChange={onChange} /> -
    - - - ) - }} - /> + + + ) + }} + /> + { + return ( + + + {t("fields.fulfillmentProviders")} + + + ({ + label: formatProvider(fp.id), + value: fp.id, + }))} + {...field} + /> + + + + ) + }} + /> +
    - - + +
    - + - +
    -
    + - + ) } diff --git a/packages/admin-next/dashboard/src/routes/regions/region-edit/region-edit.tsx b/packages/admin-next/dashboard/src/routes/regions/region-edit/region-edit.tsx index 9cc8af616d..165a63676e 100644 --- a/packages/admin-next/dashboard/src/routes/regions/region-edit/region-edit.tsx +++ b/packages/admin-next/dashboard/src/routes/regions/region-edit/region-edit.tsx @@ -1,49 +1,56 @@ -import { Drawer, Heading } from "@medusajs/ui" -import { useAdminRegion } from "medusa-react" -import { useEffect, useState } from "react" +import { Heading } from "@medusajs/ui" +import { useAdminRegion, useAdminStore } from "medusa-react" import { useTranslation } from "react-i18next" -import { json, useNavigate, useParams } from "react-router-dom" +import { useParams } from "react-router-dom" +import { RouteDrawer } from "../../../components/route-modal" import { EditRegionForm } from "./components/edit-region-form" export const RegionEdit = () => { - const [open, setOpen] = useState(false) - const { id } = useParams() - const { region, isLoading, isError, error } = useAdminRegion(id!) - const navigate = useNavigate() - - useEffect(() => { - setOpen(true) - }, []) - - const onOpenChange = (open: boolean) => { - if (!open) { - setTimeout(() => { - navigate(`/settings/regions/${id}`, { replace: true }) - }, 200) - } - - setOpen(open) - } - const { t } = useTranslation() + const { id } = useParams() - if (isError) { - throw error + const { + region, + isLoading: isRegionLoading, + isError: isRegionError, + error: regionError, + } = useAdminRegion(id!) + + const { + store, + isLoading: isStoreLoading, + isError: isStoreError, + error: storeError, + } = useAdminStore() + + const isLoading = isRegionLoading || isStoreLoading + + const currencies = store?.currencies || [] + const paymentProviders = store?.payment_providers || [] + const fulfillmentProviders = store?.fulfillment_providers || [] + + if (isRegionError) { + throw regionError } - if (!region && !isLoading) { - throw json("An unknown error has occured", 500) + if (isStoreError) { + throw storeError } return ( - - - - {t("regions.editRegion")} - - {region && } - - + + + {t("regions.editRegion")} + + {!isLoading && region && ( + + )} + ) } diff --git a/packages/admin-next/dashboard/src/routes/regions/region-list/components/region-list-table/region-list-table.tsx b/packages/admin-next/dashboard/src/routes/regions/region-list/components/region-list-table/region-list-table.tsx index 51209128df..ab52fea0de 100644 --- a/packages/admin-next/dashboard/src/routes/regions/region-list/components/region-list-table/region-list-table.tsx +++ b/packages/admin-next/dashboard/src/routes/regions/region-list/components/region-list-table/region-list-table.tsx @@ -6,6 +6,7 @@ import { useAdminDeleteRegion, useAdminRegions } from "medusa-react" import { useMemo } from "react" import { useTranslation } from "react-i18next" import { Link } from "react-router-dom" + import { ActionMenu } from "../../../../../components/common/action-menu" import { DataTable } from "../../../../../components/table/data-table" import { useRegionTableColumns } from "../../../../../hooks/table/columns/use-region-table-columns" @@ -32,7 +33,7 @@ export const RegionListTable = () => { const columns = useColumns() const { table } = useDataTable({ - data: regions ?? [], + data: (regions ?? []) as Region[], columns, count, enablePagination: true, @@ -58,7 +59,7 @@ export const RegionListTable = () => { table={table} columns={columns} count={count} - rowCount={PAGE_SIZE} + pageSize={PAGE_SIZE} isLoading={isLoading} filters={filters} orderBy={["name", "created_at", "updated_at"]} diff --git a/packages/admin-next/dashboard/src/routes/regions/shared/constants.ts b/packages/admin-next/dashboard/src/routes/regions/shared/constants.ts new file mode 100644 index 0000000000..9e22314b0f --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/regions/shared/constants.ts @@ -0,0 +1,4 @@ +export enum ShippingOptionPriceType { + FLAT_RATE = "flat_rate", + CALCULATED = "calculated", +} diff --git a/packages/admin-next/dashboard/src/routes/regions/shared/hooks/use-countries.tsx b/packages/admin-next/dashboard/src/routes/regions/shared/hooks/use-countries.tsx new file mode 100644 index 0000000000..32ba79b90f --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/regions/shared/hooks/use-countries.tsx @@ -0,0 +1,67 @@ +import { Country } from "@medusajs/medusa" +import { json } from "react-router-dom" + +const acceptedOrderKeys = ["name", "code"] + +/** + * Since countries cannot be retrieved from the API, we need to create a hook + * that can be used to filter and sort the static list of countries. + */ +export const useCountries = ({ + countries, + q, + order = "name", + limit, + offset = 0, +}: { + countries: Country[] + limit: number + offset?: number + order?: string + q?: string +}) => { + const data = countries.slice(offset, offset + limit) + + if (order) { + const direction = order.startsWith("-") ? -1 : 1 + const key = order.replace("-", "") + + if (!acceptedOrderKeys.includes(key)) { + console.log("The key ${key} is not a valid order key") + throw json(`The key ${key} is not a valid order key`, 500) + } + + const sortKey: keyof Country = key === "code" ? "iso_2" : "name" + + data.sort((a, b) => { + if (a[sortKey] === null && b[sortKey] === null) { + return 0 + } + if (a[sortKey] === null) { + return direction + } + if (b[sortKey] === null) { + return -direction + } + return a[sortKey]! > b[sortKey]! ? direction : -direction + }) + } + + if (q) { + const query = q.toLowerCase() + const results = countries.filter( + (c) => + c.name.toLowerCase().includes(query) || + c.iso_2.toLowerCase().includes(query) + ) + return { + countries: results, + count: results.length, + } + } + + return { + countries: data, + count: countries.length, + } +} diff --git a/packages/admin-next/dashboard/src/routes/regions/shared/hooks/use-country-table-columns.tsx b/packages/admin-next/dashboard/src/routes/regions/shared/hooks/use-country-table-columns.tsx new file mode 100644 index 0000000000..9dbe7e46ef --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/regions/shared/hooks/use-country-table-columns.tsx @@ -0,0 +1,24 @@ +import { Country } from "@medusajs/medusa" +import { createColumnHelper } from "@tanstack/react-table" +import { useMemo } from "react" +import { useTranslation } from "react-i18next" + +const columnHelper = createColumnHelper() + +export const useCountryTableColumns = () => { + const { t } = useTranslation() + + return useMemo( + () => [ + columnHelper.accessor("display_name", { + header: t("fields.name"), + cell: ({ getValue }) => getValue(), + }), + columnHelper.accessor("iso_2", { + header: t("fields.code"), + cell: ({ getValue }) => {getValue()}, + }), + ], + [t] + ) +} diff --git a/packages/admin-next/dashboard/src/routes/regions/shared/hooks/use-country-table-query.tsx b/packages/admin-next/dashboard/src/routes/regions/shared/hooks/use-country-table-query.tsx new file mode 100644 index 0000000000..3727e01902 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/regions/shared/hooks/use-country-table-query.tsx @@ -0,0 +1,25 @@ +import { useQueryParams } from "../../../../hooks/use-query-params" + +export const useCountryTableQuery = ({ + pageSize, + prefix, +}: { + pageSize: number + prefix?: string +}) => { + const raw = useQueryParams(["order", "q", "offset"], prefix) + + const { offset, order, q } = raw + + const searchParams = { + limit: pageSize, + offset: offset ? parseInt(offset, 10) : 0, + order, + q, + } + + return { + searchParams, + raw, + } +} diff --git a/packages/admin-next/dashboard/tailwind.config.cjs b/packages/admin-next/dashboard/tailwind.config.cjs index 14a34c0c71..f5cd160d2c 100644 --- a/packages/admin-next/dashboard/tailwind.config.cjs +++ b/packages/admin-next/dashboard/tailwind.config.cjs @@ -24,5 +24,5 @@ module.exports = { theme: { extend: {}, }, - plugins: [require("@headlessui/tailwindcss")], + plugins: [], } diff --git a/packages/design-system/ui/src/components/radio-group/radio-group.tsx b/packages/design-system/ui/src/components/radio-group/radio-group.tsx index f643823c62..26b12362ea 100644 --- a/packages/design-system/ui/src/components/radio-group/radio-group.tsx +++ b/packages/design-system/ui/src/components/radio-group/radio-group.tsx @@ -110,14 +110,14 @@ const ChoiceBox = React.forwardRef<
    {description} diff --git a/yarn.lock b/yarn.lock index 534d26285c..aa2368ed78 100644 --- a/yarn.lock +++ b/yarn.lock @@ -233,6 +233,39 @@ __metadata: languageName: node linkType: hard +"@ariakit/core@npm:0.4.1": + version: 0.4.1 + resolution: "@ariakit/core@npm:0.4.1" + checksum: 2402e054f888d67b44bed81e80757a915ea0f943c3ac76e65f3a1f96973eaa291a344fb6edeeee09f007ede372ef745e43a058e1404ee745d2caf55b7290a8b8 + languageName: node + linkType: hard + +"@ariakit/react-core@npm:0.4.1": + version: 0.4.1 + resolution: "@ariakit/react-core@npm:0.4.1" + dependencies: + "@ariakit/core": 0.4.1 + "@floating-ui/dom": ^1.0.0 + use-sync-external-store: ^1.2.0 + peerDependencies: + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + checksum: a6388f8c46dd49f0a117639950261fdf7d38c0b395b5e2d87edd1cf9aa479ea0cab338b716a915b7d13142e88ad0430b85a95c9558dbc892c9a7b51bbc702092 + languageName: node + linkType: hard + +"@ariakit/react@npm:^0.4.1": + version: 0.4.1 + resolution: "@ariakit/react@npm:0.4.1" + dependencies: + "@ariakit/react-core": 0.4.1 + peerDependencies: + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + checksum: 31a2a8f63a8e3ab5037445669da4005ab61d5f7957c39780b32ef3ac77db478ac39b940a16a252c24f2babf95c44d363fc904667b7ddf86bb30332845080cddc + languageName: node + linkType: hard + "@atomico/rollup-plugin-sizes@npm:^1.1.4": version: 1.1.4 resolution: "@atomico/rollup-plugin-sizes@npm:1.1.4" @@ -3821,6 +3854,15 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.12.13, @babel/runtime@npm:^7.23.7, @babel/runtime@npm:^7.23.8": + version: 7.23.9 + resolution: "@babel/runtime@npm:7.23.9" + dependencies: + regenerator-runtime: ^0.14.0 + checksum: e71205fdd7082b2656512cc98e647d9ea7e222e4fe5c36e9e5adc026446fcc3ba7b3cdff8b0b694a0b78bb85db83e7b1e3d4c56ef90726682b74f13249cf952d + languageName: node + linkType: hard + "@babel/runtime@npm:^7.19.4, @babel/runtime@npm:^7.20.6": version: 7.22.11 resolution: "@babel/runtime@npm:7.22.11" @@ -3848,15 +3890,6 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.23.7": - version: 7.23.9 - resolution: "@babel/runtime@npm:7.23.9" - dependencies: - regenerator-runtime: ^0.14.0 - checksum: e71205fdd7082b2656512cc98e647d9ea7e222e4fe5c36e9e5adc026446fcc3ba7b3cdff8b0b694a0b78bb85db83e7b1e3d4c56ef90726682b74f13249cf952d - languageName: node - linkType: hard - "@babel/template@npm:^7.12.13, @babel/template@npm:^7.12.7, @babel/template@npm:^7.16.7, @babel/template@npm:^7.22.5, @babel/template@npm:^7.3.3": version: 7.22.5 resolution: "@babel/template@npm:7.22.5" @@ -5670,6 +5703,15 @@ __metadata: languageName: node linkType: hard +"@floating-ui/core@npm:^1.0.0": + version: 1.6.0 + resolution: "@floating-ui/core@npm:1.6.0" + dependencies: + "@floating-ui/utils": ^0.2.1 + checksum: 667a68036f7dd5ed19442c7792a6002ca02d1799221c4396691bbe0b6008b48f6ccad581225e81fa266bb91232f6c66838a5f825f554217e1ec886178b93381b + languageName: node + linkType: hard + "@floating-ui/core@npm:^1.3.1": version: 1.3.1 resolution: "@floating-ui/core@npm:1.3.1" @@ -5677,6 +5719,16 @@ __metadata: languageName: node linkType: hard +"@floating-ui/dom@npm:^1.0.0": + version: 1.6.3 + resolution: "@floating-ui/dom@npm:1.6.3" + dependencies: + "@floating-ui/core": ^1.0.0 + "@floating-ui/utils": ^0.2.0 + checksum: d6cac10877918ce5a8d1a24b21738d2eb130a0191043d7c0dd43bccac507844d3b4dc5d4107d3891d82f6007945ca8fb4207a1252506e91c37e211f0f73cf77e + languageName: node + linkType: hard + "@floating-ui/dom@npm:^1.0.1, @floating-ui/dom@npm:^1.3.0": version: 1.4.3 resolution: "@floating-ui/dom@npm:1.4.3" @@ -5698,6 +5750,13 @@ __metadata: languageName: node linkType: hard +"@floating-ui/utils@npm:^0.2.0, @floating-ui/utils@npm:^0.2.1": + version: 0.2.1 + resolution: "@floating-ui/utils@npm:0.2.1" + checksum: ee77756712cf5b000c6bacf11992ffb364f3ea2d0d51cc45197a7e646a17aeb86ea4b192c0b42f3fbb29487aee918a565e84f710b8c3645827767f406a6b4cc9 + languageName: node + linkType: hard + "@formatjs/ecma402-abstract@npm:1.17.0": version: 1.17.0 resolution: "@formatjs/ecma402-abstract@npm:1.17.0" @@ -6135,28 +6194,6 @@ __metadata: languageName: node linkType: hard -"@headlessui/react@npm:^1.7.18": - version: 1.7.18 - resolution: "@headlessui/react@npm:1.7.18" - dependencies: - "@tanstack/react-virtual": ^3.0.0-beta.60 - client-only: ^0.0.1 - peerDependencies: - react: ^16 || ^17 || ^18 - react-dom: ^16 || ^17 || ^18 - checksum: 2d88d10874879182d4b9ed9a7779266032214034481129ba544e858d3624c8d12333e6a9d9d8263f2f116bc823bcfd43a2d1f69800fbf6a47b34d989370346e5 - languageName: node - linkType: hard - -"@headlessui/tailwindcss@npm:^0.2.0": - version: 0.2.0 - resolution: "@headlessui/tailwindcss@npm:0.2.0" - peerDependencies: - tailwindcss: ^3.0 - checksum: b641bef150e4ee18afd068c1af8e83c582a3131903ed1a310abbdb463b6150428a9b4c38e42cb296469fb20857f5542ae34f681cbff62f47d52332fbc5117479 - languageName: node - linkType: hard - "@hookform/error-message@npm:^2.0.1": version: 2.0.1 resolution: "@hookform/error-message@npm:2.0.1" @@ -8076,8 +8113,7 @@ __metadata: version: 0.0.0-use.local resolution: "@medusajs/dashboard@workspace:packages/admin-next/dashboard" dependencies: - "@headlessui/react": ^1.7.18 - "@headlessui/tailwindcss": ^0.2.0 + "@ariakit/react": ^0.4.1 "@hookform/resolvers": 3.3.2 "@medusajs/icons": "workspace:^" "@medusajs/medusa": "workspace:^" @@ -8102,11 +8138,13 @@ __metadata: i18next: 23.7.11 i18next-browser-languagedetector: 7.2.0 i18next-http-backend: 2.4.2 + match-sorter: ^6.3.4 medusa-react: "workspace:^" postcss: ^8.4.33 prettier: ^3.1.1 react: 18.2.0 react-dom: 18.2.0 + react-focus-lock: ^2.11.1 react-hook-form: 7.49.1 react-i18next: 13.5.0 react-jwt: ^1.2.0 @@ -16828,18 +16866,6 @@ __metadata: languageName: node linkType: hard -"@tanstack/react-virtual@npm:^3.0.0-beta.60": - version: 3.0.1 - resolution: "@tanstack/react-virtual@npm:3.0.1" - dependencies: - "@tanstack/virtual-core": 3.0.0 - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - checksum: 2b9464dab8a975734b651211402a4eaf10e4ae6d9570138576891db350cfabe11e444fea8f17ef0b26ef8ede0adc230bec9c8fe859e61ca66147831076b77bb2 - languageName: node - linkType: hard - "@tanstack/react-virtual@npm:^3.0.4": version: 3.0.4 resolution: "@tanstack/react-virtual@npm:3.0.4" @@ -23127,13 +23153,6 @@ __metadata: languageName: node linkType: hard -"client-only@npm:^0.0.1": - version: 0.0.1 - resolution: "client-only@npm:0.0.1" - checksum: 9d6cfd0c19e1c96a434605added99dff48482152af791ec4172fb912a71cff9027ff174efd8cdb2160cc7f377543e0537ffc462d4f279bc4701de3f2a3c4b358 - languageName: node - linkType: hard - "client-sessions@npm:^0.8.0": version: 0.8.0 resolution: "client-sessions@npm:0.8.0" @@ -28610,6 +28629,15 @@ __metadata: languageName: node linkType: hard +"focus-lock@npm:^1.3.2": + version: 1.3.3 + resolution: "focus-lock@npm:1.3.3" + dependencies: + tslib: ^2.0.3 + checksum: 38b978ab30f5be6f061689c747b05193217e9cb9c5d5a41b9b322454c6bb14e84cbd6c928fa245c62e744f070cfa939820a0ae2dd51011da233de7c912cea412 + languageName: node + linkType: hard + "follow-redirects@npm:1.5.10": version: 1.5.10 resolution: "follow-redirects@npm:1.5.10" @@ -37250,6 +37278,16 @@ __metadata: languageName: node linkType: hard +"match-sorter@npm:^6.3.4": + version: 6.3.4 + resolution: "match-sorter@npm:6.3.4" + dependencies: + "@babel/runtime": ^7.23.8 + remove-accents: 0.5.0 + checksum: 35d2a6b6df003c677d9ec87ecd4683657638f5bce856f43f9cf90b03e357ed2f09813ebbac759defa7e7438706936dd34dc2bfe1a18771f7d2541f14d639b4ad + languageName: node + linkType: hard + "matcher-collection@npm:^2.0.0": version: 2.0.1 resolution: "matcher-collection@npm:2.0.1" @@ -43427,6 +43465,17 @@ __metadata: languageName: node linkType: hard +"react-clientside-effect@npm:^1.2.6": + version: 1.2.6 + resolution: "react-clientside-effect@npm:1.2.6" + dependencies: + "@babel/runtime": ^7.12.13 + peerDependencies: + react: ^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + checksum: aba0adb666018e5c64657c31f4914a8558be73f71d6e2210fa871ebfcab94d786c83082868d7c7fa66feddc2aec19e375745cf0903e0619f2efffef08b92d941 + languageName: node + linkType: hard + "react-collapsible@npm:^2.8.3": version: 2.10.0 resolution: "react-collapsible@npm:2.10.0" @@ -43697,6 +43746,26 @@ __metadata: languageName: node linkType: hard +"react-focus-lock@npm:^2.11.1": + version: 2.11.1 + resolution: "react-focus-lock@npm:2.11.1" + dependencies: + "@babel/runtime": ^7.0.0 + focus-lock: ^1.3.2 + prop-types: ^15.6.2 + react-clientside-effect: ^1.2.6 + use-callback-ref: ^1.3.0 + use-sidecar: ^1.1.2 + peerDependencies: + "@types/react": ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: b4905ec18bb6bd6732cbd2d3bd70853007dcc31363fa8d0700ea645eec2e2e685f2fe09443d070983c2077fc2d6c06279f2838fc9046c6218facc3e310e50279 + languageName: node + linkType: hard + "react-helmet-async@npm:^1.3.0": version: 1.3.0 resolution: "react-helmet-async@npm:1.3.0" @@ -44759,6 +44828,13 @@ __metadata: languageName: node linkType: hard +"remove-accents@npm:0.5.0": + version: 0.5.0 + resolution: "remove-accents@npm:0.5.0" + checksum: a75321aa1b53d9abe82637115a492770bfe42bb38ed258be748bf6795871202bc8b4badff22013494a7029f5a241057ad8d3f72adf67884dbe15a9e37e87adc4 + languageName: node + linkType: hard + "remove-bom-buffer@npm:^3.0.0": version: 3.0.0 resolution: "remove-bom-buffer@npm:3.0.0"