feat(dashboard): Regions domain (#6534)

**What**
- Implements new Region domain design
- Adds new SplitView component for managing adding nested relations in FocusModals, eg. adding countries to a region.
- Adds new Combobox component for multi select fields in forms

**medusajs/ui**
- Fix styling of RadioGroup.Choicebox component

CLOSES CORE-1650, CORE-1671
This commit is contained in:
Kasper Fabricius Kristensen
2024-02-29 14:16:14 +01:00
committed by GitHub
parent 0b9fcb6324
commit 44a5567d0d
46 changed files with 3163 additions and 885 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/ui": patch
---
fix(ui): Left aligns text in RadioGroup.Choicebox component.

View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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<ComponentPropsWithoutRef<"input">, "onChange" | "value"> {
value?: string[]
onChange?: (value?: string[]) => void
options: ComboboxOption[]
value: string
}
export const Combobox = ({ size = "base" }: ComboboxProps) => {
const [product, setProduct] = useState<Product | null>(null)
const [query, setQuery] = useState("")
const { products, count, isLoading } = useAdminProducts(
{
q: query,
},
{
keepPreviousData: true,
}
)
return (
<Primitive by="id" value={product} onChange={setProduct}>
<div className="relative">
<div className="relative w-full">
<Primitive.Input
onChange={(e) => 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",
}
)}
/>
<Primitive.Button className="absolute inset-y-0 right-0 flex items-center pr-2">
<TrianglesMini className="text-ui-fg-muted" aria-hidden="true" />
</Primitive.Button>
</div>
<Primitive.Options className="absolute mt-2 max-h-[200px] w-full overflow-auto z-10 bg-ui-bg-base text-ui-fg-base shadow-elevation-flyout rounded-lg p-1">
{products?.map((p) => (
<Primitive.Option
key={p.id}
value={p}
className={clx(
"bg-ui-bg-base grid cursor-pointer grid-cols-[20px_1fr] gap-x-2 items-center rounded-md px-3 py-2 outline-none transition-colors",
"ui-active:bg-ui-bg-base-hover",
{
"txt-compact-medium data-[state=checked]:txt-compact-medium-plus":
size === "base",
"txt-compact-small data-[state=checked]:txt-compact-medium-plus":
size === "small",
}
)}
>
<div
className="w-5 h-5 flex items-center justify-center"
aria-hidden="true"
>
<EllipseMiniSolid className="ui-selected:block hidden" />
</div>
<span className="block truncate ui-selected:font-medium">
{p.title}
</span>
</Primitive.Option>
))}
</Primitive.Options>
</div>
</Primitive>
)
}
type ComboboxContextValue = {
size: "base" | "small"
open: boolean
setOpen: (open: boolean) => void
}
const ComboboxContext = createContext<ComboboxContextValue | null>(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<typeof Primitive>,
Omit<ComponentPropsWithoutRef<typeof Primitive>, "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 (
<ComboboxContext.Provider value={value}>
<Primitive {...props} ref={ref}>
<Popover.Root open={open} onOpenChange={setOpen}>
{children}
</Popover.Root>
</Primitive>
</ComboboxContext.Provider>
)
})
Root.displayName = "Combobox"
const Trigger = forwardRef<HTMLDivElement, ComponentPropsWithoutRef<"div">>(
({ className, children, ...props }, ref) => {
const { size } = useComboboxContext()
return (
<Popover.Trigger asChild>
<div
className={clx(
"relative bg-ui-bg-field shadow-buttons-neutral transition-fg w-full select-none items-center justify-between rounded-md outline-none",
"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",
},
className
)}
{...props}
ref={ref}
>
{children}
<Primitive.Button className="absolute inset-y-0 right-0 flex items-center pr-2">
<TrianglesMini className="text-ui-fg-muted" aria-hidden="true" />
</Primitive.Button>
</div>
</Popover.Trigger>
)
}
)
Trigger.displayName = "Combobox.Trigger"
const Value = forwardRef<
ElementRef<typeof Primitive.Input>,
ComponentPropsWithoutRef<typeof Primitive.Input>
>(({ className, ...props }, ref) => {
const { size } = useComboboxContext()
return (
<Primitive.Input
{...props}
ref={ref}
className={clx(
"placeholder:text-ui-fg-muted text-ui-fg-base outline-none bg-transparent w-full",
"disabled:!text-ui-fg-disabled",
{
" txt-compact-small": size === "base",
"txt-compact-small": size === "small",
},
className
)}
/>
)
})
Value.displayName = "Combobox.Value"
const Item = forwardRef<
ElementRef<typeof Primitive.Option>,
Omit<ComponentPropsWithoutRef<typeof Primitive.Option>, "children"> & {
children?: ReactNode
}
>(({ children, className, ...props }, ref) => {
const { size } = useComboboxContext()
return (
<Primitive.Option
{...props}
ref={ref}
className={clx(
"bg-ui-bg-base grid cursor-pointer grid-cols-[20px_1fr] gap-x-2 items-center rounded-md px-2 py-1.5 outline-none transition-colors",
"ui-active:bg-ui-bg-base-hover",
{
"txt-compact-medium data-[state=checked]:txt-compact-medium-plus":
size === "base",
"txt-compact-small data-[state=checked]:txt-compact-medium-plus":
size === "small",
},
className
)}
>
<div
className="w-5 h-5 flex items-center justify-center"
aria-hidden="true"
>
<EllipseMiniSolid className="ui-selected:block hidden" />
</div>
{children}
</Primitive.Option>
)
})
Item.displayName = "Combobox.Item"
const NoResults = forwardRef<
ElementRef<"span">,
ComponentPropsWithoutRef<"span">
>(({ children, className, ...props }, ref) => {
const { size } = useComboboxContext()
const { t } = useTranslation()
return (
<span
{...props}
ref={ref}
className={clx(
"bg-ui-bg-base items-center flex w-full justify-center rounded-md px-2 py-1.5 outline-none transition-colors",
"ui-active:bg-ui-bg-base-hover",
{
"txt-compact-medium data-[state=checked]:txt-compact-medium-plus":
size === "base",
"txt-compact-small data-[state=checked]:txt-compact-medium-plus":
size === "small",
},
className
)}
>
{children ?? t("general.noResultsTitle")}
</span>
)
})
Item.displayName = "Combobox.NoResults"
const Content = forwardRef<
ElementRef<typeof Popover.Content>,
ComponentPropsWithoutRef<typeof Popover.Content>
>(
export const Combobox = forwardRef<HTMLInputElement, ComboboxProps>(
(
{
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<HTMLInputElement>(null)
const listboxRef = useRef<HTMLDivElement>(null)
useImperativeHandle(ref, () => comboboxRef.current!)
const isControlled = controlledValue !== undefined
const [searchValue, setSearchValue] = useState("")
const [uncontrolledValue, setUncontrolledValue] = useState<string[]>([])
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 (
<Popover.Portal>
<Popover.Content
ref={ref}
className={clx(
"bg-ui-bg-base text-ui-fg-base shadow-elevation-flyout relative max-h-[120px] h-full min-w-[var(--radix-popper-anchor-width)] overflow-hidden rounded-lg flex flex-col divide-y",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
side={side}
sideOffset={sideOffset}
collisionPadding={collisionPadding}
{...props}
<Popover.Root open={open} onOpenChange={setOpen}>
<PrimitiveComboboxProvider
open={open}
setOpen={setOpen}
selectedValue={selectedValues}
setSelectedValue={handleValueChange}
setValue={(value) => {
setSearchValue(value)
}}
>
<Primitive.Options
static={true}
className={clx("p-1 flex-1 overflow-auto")}
>
{children}
</Primitive.Options>
</Popover.Content>
</Popover.Portal>
<Popover.Anchor asChild>
<div
className={clx(
"relative flex cursor-pointer items-center gap-x-2 overflow-hidden",
"h-8 w-full rounded-md px-2 py-0.5",
"bg-ui-bg-field transition-fg shadow-borders-base",
"hover:bg-ui-bg-field-hover",
"has-[input:focus]:shadow-borders-interactive-with-active",
"has-[:invalid]:shadow-borders-error",
"has-[:disabled]:bg-ui-bg-disabled has-[:disabled]:text-ui-fg-disabled has-[:disabled]:cursor-not-allowed",
{
"pl-0.5": hasValues,
},
className
)}
>
{hasValues && (
<div className="bg-ui-bg-base txt-compact-small-plus text-ui-fg-subtle focus-within:border-ui-fg-interactive relative flex h-[28px] items-center rounded-[4px] border py-[3px] pl-1.5 pr-1">
<span>{selectedValues.length}</span>
<button
type="button"
className="size-fit outline-none"
onClick={(e) => {
e.preventDefault()
handleValueChange(undefined)
}}
>
<XMarkMini className="text-ui-fg-muted" />
</button>
</div>
)}
<div className="relative flex size-full items-center">
{showSelected && (
<Text size="small" leading="compact">
{t("general.selected")}
</Text>
)}
<PrimitiveCombobox
ref={comboboxRef}
className="txt-compact-small text-ui-fg-base placeholder:text-ui-fg-subtle size-full cursor-pointer bg-transparent pr-7 outline-none focus:cursor-text"
placeholder={hidePlaceholder ? undefined : placeholder}
{...inputProps}
/>
</div>
<button
type="button"
tabIndex={-1}
className="text-ui-fg-muted pointer-events-none absolute right-2 size-fit outline-none"
>
<TrianglesMini />
</button>
</div>
</Popover.Anchor>
<Popover.Portal>
<Popover.Content
asChild
align="center"
side="bottom"
sideOffset={8}
onOpenAutoFocus={(event) => 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()
}
}}
>
<PrimitiveComboboxList
ref={listboxRef}
role="listbox"
className={clx(
"shadow-elevation-flyout bg-ui-bg-base w-[var(--radix-popper-anchor-width)] rounded-[8px] p-1",
"max-h-[200px] overflow-y-auto",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
)}
>
{matches.map(({ value, label }) => (
<PrimitiveComboboxItem
key={value}
value={value}
focusOnHover
className="transition-fg bg-ui-bg-base data-[active-item=true]:bg-ui-bg-base-hover group flex items-center gap-x-2 rounded-[4px] px-2 py-1.5"
>
<PrimitiveComboboxItemCheck className="flex !size-5 items-center justify-center">
<EllipseMiniSolid />
</PrimitiveComboboxItemCheck>
<PrimitiveComboboxItemValue className="txt-compact-small group-aria-selected:txt-compact-small-plus">
{label}
</PrimitiveComboboxItemValue>
</PrimitiveComboboxItem>
))}
{!matches.length && (
<div className="flex items-center gap-x-2 rounded-[4px] px-2 py-1.5">
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
{t("general.noResultsTitle")}
</Text>
</div>
)}
</PrimitiveComboboxList>
</Popover.Content>
</Popover.Portal>
</PrimitiveComboboxProvider>
</Popover.Root>
)
}
)
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<IntersectionObserver | null>(null)
const innerRef = useRef<HTMLDivElement>(null)
// Merge innerRef and ref
useImperativeHandle<HTMLDivElement | null, HTMLDivElement | null>(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 <div ref={innerRef} className="bg-transparent w-px h-px" {...props} />
})
Pagination.displayName = "Combobox.Pagination"
const Combo = Object.assign(Root, {
Trigger,
Value,
Item,
NoResults,
Pagination,
Content,
})
export const TestCombobox = () => {
const [product, setProduct] = useState<Product[]>([])
const [query, setQuery] = useState("")
const { products, count, isLoading } = useAdminProducts(
{
q: query,
},
{
keepPreviousData: true,
}
)
return (
<Combo value={product} onChange={setProduct}>
<Combo.Trigger>
<Combo.Value
onChange={(e) => setQuery(e.target.value)}
displayValue={(value: Product[]) => `${value?.length}`}
/>
</Combo.Trigger>
<Combo.Content>
{!products?.length && <Combo.NoResults />}
<Combo.Pagination isLoading onPaginate={() => console.log("Heyo!")} />
{products?.map((p) => (
<Combo.Item key={p.id} value={p}>
{p.title}
</Combo.Item>
))}
</Combo.Content>
</Combo>
)
}
Combobox.displayName = "Combobox"

View File

@@ -0,0 +1 @@
export * from "./combobox"

View File

@@ -100,8 +100,9 @@ const Label = forwardRef<
React.ComponentPropsWithoutRef<typeof LabelPrimitives.Root> & {
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<
<InformationCircleSolid className="text-ui-fg-muted" />
</Tooltip>
)}
{icon}
{optional && (
<Text size="small" leading="compact" className="text-ui-fg-muted">
({t("fields.optional")})

View File

@@ -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 (
<Tooltip content={t("general.includesTaxTooltip")}>
<BuildingTax className="text-ui-fg-muted" />
</Tooltip>
)
}

View File

@@ -0,0 +1 @@
export * from "./split-view"

View File

@@ -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<SplitViewContextValue | null>(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<HTMLDivElement>(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 (
<SplitViewContext.Provider value={{ open, onOpenChange: handleOpenChange }}>
<div
ref={containerRef}
className="relative flex size-full overflow-hidden"
>
{children}
</div>
</SplitViewContext.Provider>
)
}
const Content = ({ children }: PropsWithChildren) => {
const { open, onOpenChange } = useSplitViewContext()
const isLargeScreenSize = useMediaQuery("(min-width: 1024px)")
const contentWidth = !isLargeScreenSize ? "100%" : open ? "50%" : "100%"
return (
<motion.div
initial={{ width: "100%" }}
animate={{ width: contentWidth }}
transition={isLargeScreenSize ? { duration: 0.3 } : undefined}
className="relative h-full overflow-y-auto"
>
<AnimatePresence>
{open && (
<motion.div
key="overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 0.6 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
className="bg-ui-bg-base absolute inset-0 z-[1000] h-full cursor-pointer"
tabIndex={-1}
onClick={() => onOpenChange(false)}
/>
)}
</AnimatePresence>
{children}
</motion.div>
)
}
const MotionFocusLock = motion(FocusLock)
const Drawer = ({ children }: PropsWithChildren) => {
const { open } = useSplitViewContext()
const isLargeScreenSize = useMediaQuery("(min-width: 1024px)")
return (
<AnimatePresence mode={isLargeScreenSize ? "popLayout" : undefined}>
{open && (
<MotionFocusLock
key="drawer"
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ duration: 0.3 }}
className={clx(
"bg-ui-bg-base absolute right-0 top-0 z-[9999] flex h-full w-4/5 overflow-hidden border-l lg:static lg:z-auto lg:w-1/2"
)}
>
{children}
</MotionFocusLock>
)}
</AnimatePresence>
)
}
const Close = ({
variant = "secondary",
size = "small",
onClick,
children,
...props
}: ComponentPropsWithoutRef<typeof Button>) => {
const { onOpenChange } = useSplitViewContext()
const handleClick = onClick ?? (() => onOpenChange(false))
return (
<Button size={size} variant={variant} onClick={handleClick} {...props}>
{children}
</Button>
)
}
const Open = ({
variant = "secondary",
size = "small",
onClick,
children,
...props
}: ComponentPropsWithoutRef<typeof Button>) => {
const { onOpenChange } = useSplitViewContext()
const handleClick = onClick ?? (() => onOpenChange(true))
return (
<Button
id="split-view-open"
size={size}
variant={variant}
onClick={handleClick}
{...props}
>
{children}
</Button>
)
}
/**
* 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,
})

View File

@@ -181,6 +181,7 @@ export const DataTableRoot = <TData,>({
<Table.Body className="border-b-0">
{table.getRowModel().rows.map((row) => {
const to = navigateTo ? navigateTo(row) : undefined
const isRowDisabled = hasSelect && !row.getCanSelect()
return (
<Table.Row
key={row.id}
@@ -192,6 +193,7 @@ export const DataTableRoot = <TData,>({
"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 = <TData,>({
isStickyCell && hasSelect && !isSelectCell,
"after:bg-ui-border-base":
showStickyBorder && isStickyCell && !isSelectCell,
"bg-ui-bg-subtle hover:bg-ui-bg-subtle":
isRowDisabled,
})}
>
{flexRender(

View File

@@ -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 (
<div className="flex size-full items-center gap-x-1">
<span>{displayValue}</span>
{additionalCountries.length > 0 && (
<Tooltip
content={
<ul>
{additionalCountries.map((c) => (
<li key={c}>{c}</li>
))}
</ul>
}
>
<span>
{t("general.plusCountMore", {
count: additionalCountries.length,
})}
</span>
</Tooltip>
)}
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{text}</span>
</div>
)
}

View File

@@ -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 (
<div className="flex size-full items-center gap-x-1">
<span>{displayValue}</span>
{additionalProviders.length > 0 && (
<Tooltip
content={
<ul>
{additionalProviders.map((c) => (
<li key={c}>{c}</li>
))}
</ul>
}
>
<span>
{t("general.plusCountMore", {
count: additionalProviders.length,
})}
</span>
</Tooltip>
)}
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{text}</span>
</div>
)
}
@@ -51,8 +42,8 @@ export const FulfillmentProvidersHeader = () => {
const { t } = useTranslation()
return (
<div className="flex size-full items-center">
<span>{t("fields.fulfillmentProviders")}</span>
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{t("fields.fulfillmentProviders")}</span>
</div>
)
}

View File

@@ -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 (
<div className="flex size-full items-center gap-x-1">
<span>{displayValue}</span>
{additionalProviders.length > 0 && (
<Tooltip
content={
<ul>
{additionalProviders.map((c) => (
<li key={c}>{c}</li>
))}
</ul>
}
>
<span>
{t("general.plusCountMore", {
count: additionalProviders.length,
})}
</span>
</Tooltip>
)}
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{text}</span>
</div>
)
}
@@ -51,8 +42,8 @@ export const PaymentProvidersHeader = () => {
const { t } = useTranslation()
return (
<div className="flex size-full items-center">
<span>{t("fields.paymentProviders")}</span>
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{t("fields.paymentProviders")}</span>
</div>
)
}

View File

@@ -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
}

View File

@@ -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(" ")
}

View File

@@ -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"
),
},
],
},
],

View File

@@ -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<RowSelectionState>({})
const form = useForm<zod.infer<typeof AddCountriesSchema>>({
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 (
<RouteFocusModal.Form form={form}>
<form
onSubmit={handleSubmit}
className="flex h-full flex-col overflow-hidden"
>
<RouteFocusModal.Header>
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button size="small" isLoading={isLoading} type="submit">
{t("actions.save")}
</Button>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body className="overflow-hidden">
<DataTable
table={table}
columns={columns}
pageSize={PAGE_SIZE}
count={count}
search
pagination
layout="fill"
orderBy={["name", "code"]}
queryObject={raw}
prefix={PREFIX}
/>
</RouteFocusModal.Body>
</form>
</RouteFocusModal.Form>
)
}
const columnHelper = createColumnHelper<Country>()
const useColumns = () => {
const base = useCountryTableColumns()
return useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row }) => {
const isPreselected = !row.getCanSelect()
return (
<Checkbox
checked={row.getIsSelected() || isPreselected}
disabled={isPreselected}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
}}
/>
)
},
}),
...base,
],
[base]
) as ColumnDef<Country>[]
}

View File

@@ -0,0 +1 @@
export * from "./add-countries-form"

View File

@@ -0,0 +1 @@
export { RegionAddCountries as Component } from "./region-add-countries"

View File

@@ -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 (
<RouteFocusModal>
{!isLoading && region && <AddCountriesForm region={region} />}
</RouteFocusModal>
)
}

View File

@@ -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<zod.infer<typeof CreateShippingOptionSchema>>({
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 (
<RouteFocusModal.Form form={form}>
<form
className="flex h-full flex-col overflow-hidden"
onSubmit={handleSubmit}
>
<RouteFocusModal.Header>
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button size="small" type="submit" isLoading={isLoading}>
{t("actions.save")}
</Button>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body className="overflow-hidden">
<div
className={clx(
"flex h-full w-full flex-col items-center overflow-y-auto p-16"
)}
id="form-section"
>
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
<div>
<Heading>
{t("regions.shippingOption.createShippingOption")}
</Heading>
<Text size="small" className="text-ui-fg-subtle">
{t("regions.shippingOption.createShippingOptionHint")}
</Text>
</div>
<Form.Field
control={form.control}
name="type"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.type")}</Form.Label>
<Form.Control>
<RadioGroup
{...field}
onValueChange={field.onChange}
className="grid grid-cols-1 gap-4 md:grid-cols-2"
>
<RadioGroup.ChoiceBox
value={ShippingOptionType.OUTBOUND}
label={t("regions.shippingOption.type.outbound")}
description={t(
"regions.shippingOption.type.outboundHint"
)}
/>
<RadioGroup.ChoiceBox
value={ShippingOptionType.RETURN}
label={t("regions.shippingOption.type.return")}
description={t(
"regions.shippingOption.type.returnHint"
)}
/>
</RadioGroup>
</Form.Control>
</Form.Item>
)
}}
/>
<div className="bg-ui-border-base h-px w-full" />
<Form.Field
control={form.control}
name="admin_only"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div>
<div className="flex items-center justify-between">
<Form.Label>
{t("regions.shippingOption.availability.adminOnly")}
</Form.Label>
<Form.Control>
<Switch
checked={value}
onCheckedChange={onChange}
{...field}
/>
</Form.Control>
</div>
<Form.Hint>
{t(
"regions.shippingOption.availability.adminOnlyHint"
)}
</Form.Hint>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<div className="bg-ui-border-base h-px w-full" />
<Form.Field
control={form.control}
name="includes_tax"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div>
<div className="flex items-start justify-between">
<Form.Label>
{t("fields.taxInclusivePricing")}
</Form.Label>
<Form.Control>
<Switch
{...field}
checked={value}
onCheckedChange={onChange}
/>
</Form.Control>
</div>
<Form.Hint>{t("regions.taxInclusiveHint")}</Form.Hint>
<Form.ErrorMessage />
</div>
</Form.Item>
)
}}
/>
<div className="bg-ui-border-base h-px w-full" />
<div className="flex flex-col gap-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Form.Field
control={form.control}
name="name"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.name")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="price_type"
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item>
<Form.Label>
{t("regions.shippingOption.priceType.label")}
</Form.Label>
<Form.Control>
<Select onValueChange={onChange} {...field}>
<Select.Trigger ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
<Select.Item
value={ShippingOptionPriceType.FLAT_RATE}
>
{t(
"regions.shippingOption.priceType.flatRate"
)}
</Select.Item>
<Select.Item
value={ShippingOptionPriceType.CALCULATED}
>
{t(
"regions.shippingOption.priceType.calculated"
)}
</Select.Item>
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
{isFlatRate && (
<Form.Field
control={form.control}
name="amount"
shouldUnregister
render={({ field }) => {
return (
<Form.Item>
<Form.Label
icon={
<IncludesTaxTooltip includesTax={includesTax} />
}
>
{t("fields.price")}
</Form.Label>
<Form.Control>
<CurrencyInput
code={region.currency_code}
symbol={region.currency.symbol_native}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
)}
</div>
</div>
<div className="bg-ui-border-base h-px w-full" />
<div className="flex flex-col gap-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Form.Field
control={form.control}
name="profile_id"
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("fields.shippingProfile")}</Form.Label>
<Form.Control>
<Select onValueChange={onChange} {...field}>
<Select.Trigger ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{shippingProfiles.map((profile) => (
<Select.Item
key={profile.id}
value={profile.id}
>
{profile.name}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="provider_id"
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item>
<Form.Label>
{t("fields.fulfillmentProvider")}
</Form.Label>
<Form.Control>
<Select onValueChange={onChange} {...field}>
<Select.Trigger ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{fulfillmentOptions.map((option) => (
<Select.Item
key={option.provider_id}
value={option.provider_id}
>
{formatProvider(option.provider_id)}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
<div className="bg-ui-border-base h-px w-full" />
<div className="flex flex-col gap-y-4">
<div>
<Text size="small" leading="compact" weight="plus">
{t("regions.shippingOption.requirements.label")}
</Text>
<Text size="small" className="text-ui-fg-subtle">
{t("regions.shippingOption.requirements.hint")}
</Text>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Form.Field
control={form.control}
name="min_subtotal"
shouldUnregister
render={({ field }) => {
return (
<Form.Item>
<Form.Label
icon={
<IncludesTaxTooltip includesTax={includesTax} />
}
optional
>
{t("fields.minSubtotal")}
</Form.Label>
<Form.Control>
<CurrencyInput
code={region.currency_code}
symbol={region.currency.symbol_native}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="max_subtotal"
shouldUnregister
render={({ field }) => {
return (
<Form.Item>
<Form.Label
icon={
<IncludesTaxTooltip includesTax={includesTax} />
}
optional
>
{t("fields.maxSubtotal")}
</Form.Label>
<Form.Control>
<CurrencyInput
code={region.currency_code}
symbol={region.currency.symbol_native}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
</div>
</div>
</RouteFocusModal.Body>
</form>
</RouteFocusModal.Form>
)
}

View File

@@ -0,0 +1 @@
export * from "./create-shipping-option-form"

View File

@@ -0,0 +1 @@
export { RegionCreateShippingOption as Component } from "./region-create-shipping-option"

View File

@@ -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 (
<RouteFocusModal>
{!isLoading && region && shipping_profiles && fulfillment_options && (
<CreateShippingOptionForm
region={region}
fulfillmentOptions={fulfillment_options}
shippingProfiles={shipping_profiles}
/>
)}
</RouteFocusModal>
)
}

View File

@@ -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<RowSelectionState>({})
const { handleSuccess } = useRouteModal()
const form = useForm<zod.infer<typeof CreateRegionSchema>>({
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 (
<RouteFocusModal.Form form={form}>
<form
@@ -80,134 +227,323 @@ export const CreateRegionForm = () => {
</Button>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body className="flex h-full w-full flex-col items-center overflow-y-auto py-16">
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
<div>
<Heading>{t("regions.createRegion")}</Heading>
<Text size="small" className="text-ui-fg-subtle">
{t("regions.createRegionHint")}
</Text>
</div>
<div className="flex flex-col gap-y-4">
<div className="grid grid-cols-2 gap-4">
<Form.Field
control={form.control}
name="name"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.name")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="currency_code"
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("fields.currency")}</Form.Label>
<Form.Control>
<Select {...field} onValueChange={onChange}>
<Select.Trigger ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{storeCurrencies.map((currency) => (
<Select.Item
value={currency.code}
key={currency.code}
>
{currency.name}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Form.Field
control={form.control}
name="tax_rate"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.taxRate")}</Form.Label>
<Form.Control>
<Input type="number" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="tax_code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.taxCode")}</Form.Label>
<Form.Control>
<Input type="number" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
<Form.Field
control={form.control}
name="includes_tax"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div>
<div className="flex items-start justify-between">
<Form.Label>
{t("fields.taxInclusivePricing")}
</Form.Label>
<Form.Control>
<Switch
{...field}
checked={value}
onCheckedChange={onChange}
/>
</Form.Control>
</div>
<Form.Hint>{t("regions.taxInclusiveHint")}</Form.Hint>
<Form.ErrorMessage />
<RouteFocusModal.Body className="flex overflow-hidden">
<SplitView open={open} onOpenChange={onOpenChange}>
<SplitView.Content>
<div
className={clx(
"flex h-full w-full flex-col items-center overflow-y-auto p-16"
)}
id="form-section"
>
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
<div>
<Heading>{t("regions.createRegion")}</Heading>
<Text size="small" className="text-ui-fg-subtle">
{t("regions.createRegionHint")}
</Text>
</div>
<div className="flex flex-col gap-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Form.Field
control={form.control}
name="name"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.name")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="currency_code"
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("fields.currency")}</Form.Label>
<Form.Control>
<Select {...field} onValueChange={onChange}>
<Select.Trigger ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{currencies.map((currency) => (
<Select.Item
value={currency.code}
key={currency.code}
>
{currency.name}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</Form.Item>
)
}}
/>
<div className="flex flex-col gap-y-4">
<div>
<Text size="small" leading="compact" weight="plus">
{t("fields.providers")}
</Text>
<Text size="small" className="text-ui-fg-subtle">
{t("regions.providersHint")}
</Text>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Form.Field
control={form.control}
name="tax_rate"
render={({ field: { value, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("fields.taxRate")}</Form.Label>
<Form.Control>
<Input type="number" value={value} {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="tax_code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("fields.taxCode")}
</Form.Label>
<Form.Control>
<Input type="number" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
<Form.Field
control={form.control}
name="includes_tax"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div>
<div className="flex items-start justify-between">
<Form.Label>
{t("fields.taxInclusivePricing")}
</Form.Label>
<Form.Control>
<Switch
{...field}
checked={value}
onCheckedChange={onChange}
/>
</Form.Control>
</div>
<Form.Hint>
{t("regions.taxInclusiveHint")}
</Form.Hint>
<Form.ErrorMessage />
</div>
</Form.Item>
)
}}
/>
<div className="bg-ui-border-base h-px w-full" />
<div className="flex flex-col gap-y-4">
<div>
<Text size="small" leading="compact" weight="plus">
{t("fields.countries")}
</Text>
<Text size="small" className="text-ui-fg-subtle">
{t("regions.countriesHint")}
</Text>
</div>
{selectedCountries.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedCountries.map((country) => (
<CountryTag
key={country.code}
country={country}
onRemove={removeCountry}
/>
))}
<Button
variant="transparent"
size="small"
className="text-ui-fg-muted hover:text-ui-fg-subtle"
onClick={clearCountries}
>
{t("actions.clearAll")}
</Button>
</div>
)}
<div className="flex items-center justify-end">
<SplitView.Open type="button">
{t("regions.addCountries")}
</SplitView.Open>
</div>
</div>
<div className="bg-ui-border-base h-px w-full" />
<div className="flex flex-col gap-y-4">
<div>
<Text size="small" leading="compact" weight="plus">
{t("fields.providers")}
</Text>
<Text size="small" className="text-ui-fg-subtle">
{t("regions.providersHint")}
</Text>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Form.Field
control={form.control}
name="payment_providers"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("fields.paymentProviders")}
</Form.Label>
<Form.Control>
<Combobox
options={paymentProviders.map((pp) => ({
label: formatProvider(pp.id),
value: pp.id,
}))}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="fulfillment_providers"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("fields.fulfillmentProviders")}
</Form.Label>
<Form.Control>
<Combobox
options={fulfillmentProviders.map((fp) => ({
label: formatProvider(fp.id),
value: fp.id,
}))}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4"></div>
</div>
</div>
</SplitView.Content>
<SplitView.Drawer>
<div className="flex size-full flex-col overflow-hidden">
<DataTable
table={table}
columns={columns}
count={count}
pageSize={PAGE_SIZE}
orderBy={["name", "code"]}
pagination
search
layout="fill"
queryObject={raw}
prefix={PREFIX}
/>
<div className="flex items-center justify-end gap-x-2 border-t p-4">
<SplitView.Close type="button">
{t("actions.cancel")}
</SplitView.Close>
<Button size="small" type="button" onClick={saveCountries}>
{t("actions.save")}
</Button>
</div>
</div>
</SplitView.Drawer>
</SplitView>
</RouteFocusModal.Body>
</form>
</RouteFocusModal.Form>
)
}
const columnHelper = createColumnHelper<Country>()
const useColumns = () => {
const base = useCountryTableColumns()
return useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row }) => {
const isPreselected = !row.getCanSelect()
return (
<Checkbox
checked={row.getIsSelected() || isPreselected}
disabled={isPreselected}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
}}
/>
)
},
}),
...base,
],
[base]
)
}
const CountryTag = ({
country,
onRemove,
}: {
country: { code: string; name: string }
onRemove: (code: string) => void
}) => {
return (
<div className="bg-ui-bg-field shadow-borders-base transition-fg hover:bg-ui-bg-field-hover flex h-7 items-center overflow-hidden rounded-md">
<div className="txt-compact-small-plus flex h-full select-none items-center justify-center px-2 py-0.5">
{country.name}
</div>
<button
type="button"
onClick={() => onRemove(country.code)}
className="focus-visible:bg-ui-bg-field-hover transition-fg hover:bg-ui-bg-field-hover flex h-full w-7 items-center justify-center border-l outline-none"
>
<XMarkMini className="text-ui-fg-muted" />
</button>
</div>
)
}

View File

@@ -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 (
<RouteFocusModal>
<CreateRegionForm />
{!isLoading && store && (
<CreateRegionForm
currencies={currencies}
fulfillmentProviders={fulfillmentProviders}
paymentProviders={paymentProviders}
/>
)}
</RouteFocusModal>
)
}

View File

@@ -0,0 +1 @@
export * from "./region-country-section"

View File

@@ -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<RowSelectionState>({})
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 (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("fields.countries")}</Heading>
<ActionMenu
groups={[
{
actions: [
{
label: t("regions.addCountries"),
icon: <PlusMini />,
to: "countries/add",
},
],
},
]}
/>
</div>
<DataTable
table={table}
columns={columns}
pageSize={PAGE_SIZE}
count={count}
orderBy={["name", "code"]}
search
pagination
queryObject={raw}
prefix={PREFIX}
commands={[
{
action: handleRemoveCountries,
label: t("actions.remove"),
shortcut: "r",
},
]}
/>
</Container>
)
}
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 (
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.remove"),
onClick: handleRemove,
icon: <Trash />,
},
],
},
]}
/>
)
}
const columnHelper = createColumnHelper<Country>()
const useColumns = () => {
const base = useCountryTableColumns()
return useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row }) => {
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
}}
/>
)
},
}),
...base,
columnHelper.display({
id: "actions",
cell: ({ row, table }) => {
const { region } = table.options.meta as { region: Region }
return <CountryActions country={row.original} region={region} />
},
}),
],
[base]
) as ColumnDef<Country>[]
}

View File

@@ -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) => {
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading>{region.name}</Heading>
<RegionActions region={region} />
</div>
<div className="grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("fields.countries")}
</Text>
<RegionCountries countries={region.countries} />
</div>
<div className="grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("fields.currency")}
@@ -50,24 +32,15 @@ export const RegionGeneralSection = ({ region }: RegionGeneralSectionProps) => {
</Text>
</div>
</div>
<div className="grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("fields.taxInclusivePricing")}
</Text>
<StatusBadge
color={region.includes_tax ? "green" : "red"}
className="w-fit"
>
{region.includes_tax ? t("general.enabled") : t("general.disabled")}
</StatusBadge>
</div>
<div className="grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("fields.paymentProviders")}
</Text>
<Text size="small" leading="compact">
{region.payment_providers.length > 0
? region.payment_providers.map((p) => p.id).join(", ")
? region.payment_providers
.map((p) => formatProvider(p.id))
.join(", ")
: "-"}
</Text>
</div>
@@ -77,7 +50,9 @@ export const RegionGeneralSection = ({ region }: RegionGeneralSectionProps) => {
</Text>
<Text size="small" leading="compact">
{region.fulfillment_providers.length > 0
? region.fulfillment_providers.map((p) => p.id).join(", ")
? region.fulfillment_providers
.map((p) => formatProvider(p.id))
.join(", ")
: "-"}
</Text>
</div>
@@ -119,6 +94,11 @@ const RegionActions = ({ region }: { region: Region }) => {
label: t("actions.edit"),
to: `/settings/regions/${region.id}/edit`,
},
{
icon: <BuildingTax />,
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 (
<div className="text-ui-fg-subtle flex items-center gap-x-2">
<Text leading="compact" size="small">
{countries
.slice(0, 2)
.map((c) => c.display_name)
.join(", ")}
{countIsGreaterThanTwo && ", "}
</Text>
{countIsGreaterThanTwo && (
<Tooltip
content={
<ul>
{countries.slice(2).map((c) => (
<li key={c.id}>{c.display_name}</li>
))}
</ul>
}
>
<Text
leading="compact"
size="small"
weight="plus"
className="cursor-default"
>
{t("general.plusCountMore", {
count: countries.length - 2,
})}
</Text>
</Tooltip>
)}
</div>
)
}
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<zod.infer<typeof EditRegionSchema>>({
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 (
<Drawer>
<Drawer.Content>
<Drawer.Header>
<Heading>{t("regions.editRegion")}</Heading>
</Drawer.Header>
<Drawer.Body></Drawer.Body>
<Drawer.Footer className="flex items-center justify-end gap-x-2">
<Button variant="secondary">{t("actions.cancel")}</Button>
<Button>{t("actions.save")}</Button>
</Drawer.Footer>
</Drawer.Content>
</Drawer>
)
}

View File

@@ -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 (
<Container className="divide-y p-0">
<div className="px-6 py-4">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("regions.shippingOptions")}</Heading>
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.create"),
icon: <PlusMini />,
to: "shipping-options/create",
},
],
},
]}
/>
</div>
<DataTable
table={table}
@@ -71,7 +86,7 @@ export const RegionShippingOptionSection = ({
"updated_at",
]}
isLoading={isLoading}
rowCount={PAGE_SIZE}
pageSize={PAGE_SIZE}
pagination
search
queryObject={raw}

View File

@@ -1,7 +1,11 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { PricedShippingOption } from "@medusajs/medusa/dist/types/pricing"
import { usePrompt } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"
import { useAdminDeleteShippingOption } from "medusa-react"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { MoneyAmountCell } from "../../../../../components/table/table-cells/common/money-amount-cell"
import { PlaceholderCell } from "../../../../../components/table/table-cells/common/placeholder-cell"
import { StatusCell } from "../../../../../components/table/table-cells/common/status-cell"
@@ -21,18 +25,22 @@ export const useShippingOptionColumns = () => {
</div>
),
}),
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 (
<StatusCell color={type === "flat_rate" ? "green" : "blue"}>
{type === "flat_rate"
? t("regions.flatRate")
: t("regions.calculated")}
</StatusCell>
)
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: () => (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{t("fields.minSubtotal")}</span>
</div>
),
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: () => (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{t("fields.maxSubtotal")}</span>
</div>
),
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 (
<StatusCell color={value ? "blue" : "green"}>
{value ? t("regions.return") : t("regions.outbound")}
</StatusCell>
)
columnHelper.display({
id: "actions",
cell: ({ row }) => {
return <ShippingOptionActions shippingOption={row.original} />
},
}),
],
[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 (
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.edit"),
to: `shipping-options/${shippingOption.id}/edit`,
icon: <PencilSquare />,
},
],
},
{
actions: [
{
label: t("actions.delete"),
onClick: handleDelete,
icon: <Trash />,
},
],
},
]}
/>
)
}

View File

@@ -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 <div>Loading</div>
if (isLoading || !region) {
return <div>Loading...</div>
}
if (isError || !region) {
if (error) {
throw error
}
throw json("An unknown error occurred", 500)
if (isError) {
throw error
}
return (
<div className="flex flex-col gap-y-2">
<RegionGeneralSection region={region} />
<RegionCountrySection region={region} />
<RegionShippingOptionSection region={region} />
<JsonViewSection data={region} />
<Outlet />

View File

@@ -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<zod.infer<typeof EditShippingOptionSchema>>({
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 (
<RouteDrawer.Form form={form}>
<form
onSubmit={handleSubmit}
className="flex flex-1 flex-col overflow-hidden"
>
<RouteDrawer.Body className="flex flex-1 flex-col gap-y-8 overflow-y-auto">
<Form.Field
control={form.control}
name="admin_only"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div>
<div className="flex items-center justify-between">
<Form.Label>
{t("regions.shippingOption.availability.adminOnly")}
</Form.Label>
<Form.Control>
<Switch
checked={value}
onCheckedChange={onChange}
{...field}
/>
</Form.Control>
</div>
<Form.Hint>
{t("regions.shippingOption.availability.adminOnlyHint")}
</Form.Hint>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<div className="bg-ui-border-base h-px w-full" />
<Form.Field
control={form.control}
name="includes_tax"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div>
<div className="flex items-start justify-between">
<Form.Label>{t("fields.taxInclusivePricing")}</Form.Label>
<Form.Control>
<Switch
{...field}
checked={value}
onCheckedChange={onChange}
/>
</Form.Control>
</div>
<Form.Hint>{t("regions.taxInclusiveHint")}</Form.Hint>
<Form.ErrorMessage />
</div>
</Form.Item>
)
}}
/>
<div className="bg-ui-border-base h-px w-full" />
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="name"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.name")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="price_type"
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item>
<Form.Label>
{t("regions.shippingOption.priceType.label")}
</Form.Label>
<Form.Control>
<Select onValueChange={onChange} {...field}>
<Select.Trigger ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
<Select.Item
value={ShippingOptionPriceType.FLAT_RATE}
>
{t("regions.shippingOption.priceType.flatRate")}
</Select.Item>
<Select.Item
value={ShippingOptionPriceType.CALCULATED}
>
{t("regions.shippingOption.priceType.calculated")}
</Select.Item>
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
{isFlatRate && (
<Form.Field
control={form.control}
name="amount"
shouldUnregister
render={({ field }) => {
return (
<Form.Item>
<Form.Label
icon={<IncludesTaxTooltip includesTax={includesTax} />}
>
{t("fields.price")}
</Form.Label>
<Form.Control>
<CurrencyInput
code={region.currency_code}
symbol={region.currency.symbol_native}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
)}
</div>
<div className="bg-ui-border-base h-px w-full" />
<div className="flex flex-col gap-y-4">
<div>
<Text size="small" leading="compact" weight="plus">
{t("regions.shippingOption.requirements.label")}
</Text>
<Text size="small" className="text-ui-fg-subtle">
{t("regions.shippingOption.requirements.hint")}
</Text>
</div>
<div className="grid grid-cols-1 gap-4">
<Form.Field
control={form.control}
name="min_subtotal"
shouldUnregister
render={({ field }) => {
return (
<Form.Item>
<Form.Label
icon={<IncludesTaxTooltip includesTax={includesTax} />}
optional
>
{t("fields.minSubtotal")}
</Form.Label>
<Form.Control>
<CurrencyInput
code={region.currency_code}
symbol={region.currency.symbol_native}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="max_subtotal"
shouldUnregister
render={({ field }) => {
return (
<Form.Item>
<Form.Label
icon={<IncludesTaxTooltip includesTax={includesTax} />}
optional
>
{t("fields.maxSubtotal")}
</Form.Label>
<Form.Control>
<CurrencyInput
code={region.currency_code}
symbol={region.currency.symbol_native}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button size="small" type="submit" isLoading={isLoading}>
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</form>
</RouteDrawer.Form>
)
}

View File

@@ -0,0 +1 @@
export * from "./edit-shipping-option-form"

View File

@@ -0,0 +1 @@
export { RegionEditShippingOption as Component } from "./region-edit-shipping-option"

View File

@@ -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 (
<RouteDrawer>
<RouteDrawer.Header>
<Heading>{t("regions.shippingOption.editShippingOption")}</Heading>
</RouteDrawer.Header>
{!isLoading && region && shipping_option && (
<EditShippingOptionForm
region={region}
shippingOption={shipping_option}
/>
)}
</RouteDrawer>
)
}

View File

@@ -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<zod.infer<typeof EditRegionSchema>>({
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 (
<Form {...form}>
<RouteDrawer.Form form={form}>
<form onSubmit={handleSubmit} className="flex flex-1 flex-col">
<Drawer.Body>
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="name"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.name")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="includes_tax"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div className="flex items-start justify-between">
<div>
<Form.Label>
{t("fields.taxInclusivePricing")}
</Form.Label>
<Form.Hint>{t("regions.taxInclusiveHint")}</Form.Hint>
</div>
<RouteDrawer.Body>
<div className="flex flex-col gap-y-8">
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="name"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.name")}</Form.Label>
<Form.Control>
<Switch
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="currency_code"
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("fields.currency")}</Form.Label>
<Form.Control>
<Select onValueChange={onChange} {...field}>
<Select.Trigger ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{currencies.map((c) => (
<Select.Item key={c.code} value={c.code}>
{c.code.toUpperCase()}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<div className="flex flex-col gap-y-4">
<div>
<Text size="small" leading="compact" weight="plus">
Providers
</Text>
<Text size="small" className="text-ui-fg-subtle">
{t("regions.providersHint")}
</Text>
</div>
<Form.Field
control={form.control}
name="payment_providers"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.paymentProviders")}</Form.Label>
<Form.Control>
<Combobox
options={paymentProviders.map((pp) => ({
label: formatProvider(pp.id),
value: pp.id,
}))}
{...field}
checked={value}
onCheckedChange={onChange}
/>
</Form.Control>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="fulfillment_providers"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("fields.fulfillmentProviders")}
</Form.Label>
<Form.Control>
<Combobox
options={fulfillmentProviders.map((fp) => ({
label: formatProvider(fp.id),
value: fp.id,
}))}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
</Drawer.Body>
<Drawer.Footer>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center gap-x-2">
<Drawer.Close asChild>
<RouteDrawer.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</Drawer.Close>
</RouteDrawer.Close>
<Button size="small" type="submit" isLoading={isLoading}>
{t("actions.save")}
</Button>
</div>
</Drawer.Footer>
</RouteDrawer.Footer>
</form>
</Form>
</RouteDrawer.Form>
)
}

View File

@@ -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 (
<Drawer open={open} onOpenChange={onOpenChange}>
<Drawer.Content>
<Drawer.Header>
<Heading>{t("regions.editRegion")}</Heading>
</Drawer.Header>
{region && <EditRegionForm region={region} />}
</Drawer.Content>
</Drawer>
<RouteDrawer>
<RouteDrawer.Header>
<Heading>{t("regions.editRegion")}</Heading>
</RouteDrawer.Header>
{!isLoading && region && (
<EditRegionForm
region={region}
currencies={currencies}
paymentProviders={paymentProviders}
fulfillmentProviders={fulfillmentProviders}
/>
)}
</RouteDrawer>
)
}

View File

@@ -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"]}

View File

@@ -0,0 +1,4 @@
export enum ShippingOptionPriceType {
FLAT_RATE = "flat_rate",
CALCULATED = "calculated",
}

View File

@@ -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,
}
}

View File

@@ -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<Country>()
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 }) => <span className="uppercase">{getValue()}</span>,
}),
],
[t]
)
}

View File

@@ -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,
}
}

View File

@@ -24,5 +24,5 @@ module.exports = {
theme: {
extend: {},
},
plugins: [require("@headlessui/tailwindcss")],
plugins: [],
}

View File

@@ -110,14 +110,14 @@ const ChoiceBox = React.forwardRef<
<div className="flex flex-col items-start">
<Label
htmlFor={id}
size="base"
size="small"
weight="plus"
className="group-disabled:text-ui-fg-disabled cursor-pointer group-disabled:cursor-not-allowed"
>
{label}
</Label>
<Hint
className="txt-compact-medium text-ui-fg-subtle group-disabled:text-ui-fg-disabled"
className="txt-compact-medium text-ui-fg-subtle group-disabled:text-ui-fg-disabled text-left"
id={descriptionId}
>
{description}

180
yarn.lock
View File

@@ -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"