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:
committed by
GitHub
parent
0b9fcb6324
commit
44a5567d0d
5
.changeset/red-donkeys-speak.md
Normal file
5
.changeset/red-donkeys-speak.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@medusajs/ui": patch
|
||||
---
|
||||
|
||||
fix(ui): Left aligns text in RadioGroup.Choicebox component.
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./combobox"
|
||||
@@ -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")})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./split-view"
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
14
packages/admin-next/dashboard/src/hooks/use-media-query.tsx
Normal file
14
packages/admin-next/dashboard/src/hooks/use-media-query.tsx
Normal 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
|
||||
}
|
||||
12
packages/admin-next/dashboard/src/lib/format-provider.ts
Normal file
12
packages/admin-next/dashboard/src/lib/format-provider.ts
Normal 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(" ")
|
||||
}
|
||||
@@ -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"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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>[]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./add-countries-form"
|
||||
@@ -0,0 +1 @@
|
||||
export { RegionAddCountries as Component } from "./region-add-countries"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./create-shipping-option-form"
|
||||
@@ -0,0 +1 @@
|
||||
export { RegionCreateShippingOption as Component } from "./region-create-shipping-option"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./region-country-section"
|
||||
@@ -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>[]
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./edit-shipping-option-form"
|
||||
@@ -0,0 +1 @@
|
||||
export { RegionEditShippingOption as Component } from "./region-edit-shipping-option"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum ShippingOptionPriceType {
|
||||
FLAT_RATE = "flat_rate",
|
||||
CALCULATED = "calculated",
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -24,5 +24,5 @@ module.exports = {
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [require("@headlessui/tailwindcss")],
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
@@ -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
180
yarn.lock
@@ -233,6 +233,39 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ariakit/core@npm:0.4.1":
|
||||
version: 0.4.1
|
||||
resolution: "@ariakit/core@npm:0.4.1"
|
||||
checksum: 2402e054f888d67b44bed81e80757a915ea0f943c3ac76e65f3a1f96973eaa291a344fb6edeeee09f007ede372ef745e43a058e1404ee745d2caf55b7290a8b8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ariakit/react-core@npm:0.4.1":
|
||||
version: 0.4.1
|
||||
resolution: "@ariakit/react-core@npm:0.4.1"
|
||||
dependencies:
|
||||
"@ariakit/core": 0.4.1
|
||||
"@floating-ui/dom": ^1.0.0
|
||||
use-sync-external-store: ^1.2.0
|
||||
peerDependencies:
|
||||
react: ^17.0.0 || ^18.0.0
|
||||
react-dom: ^17.0.0 || ^18.0.0
|
||||
checksum: a6388f8c46dd49f0a117639950261fdf7d38c0b395b5e2d87edd1cf9aa479ea0cab338b716a915b7d13142e88ad0430b85a95c9558dbc892c9a7b51bbc702092
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ariakit/react@npm:^0.4.1":
|
||||
version: 0.4.1
|
||||
resolution: "@ariakit/react@npm:0.4.1"
|
||||
dependencies:
|
||||
"@ariakit/react-core": 0.4.1
|
||||
peerDependencies:
|
||||
react: ^17.0.0 || ^18.0.0
|
||||
react-dom: ^17.0.0 || ^18.0.0
|
||||
checksum: 31a2a8f63a8e3ab5037445669da4005ab61d5f7957c39780b32ef3ac77db478ac39b940a16a252c24f2babf95c44d363fc904667b7ddf86bb30332845080cddc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@atomico/rollup-plugin-sizes@npm:^1.1.4":
|
||||
version: 1.1.4
|
||||
resolution: "@atomico/rollup-plugin-sizes@npm:1.1.4"
|
||||
@@ -3821,6 +3854,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/runtime@npm:^7.12.13, @babel/runtime@npm:^7.23.7, @babel/runtime@npm:^7.23.8":
|
||||
version: 7.23.9
|
||||
resolution: "@babel/runtime@npm:7.23.9"
|
||||
dependencies:
|
||||
regenerator-runtime: ^0.14.0
|
||||
checksum: e71205fdd7082b2656512cc98e647d9ea7e222e4fe5c36e9e5adc026446fcc3ba7b3cdff8b0b694a0b78bb85db83e7b1e3d4c56ef90726682b74f13249cf952d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/runtime@npm:^7.19.4, @babel/runtime@npm:^7.20.6":
|
||||
version: 7.22.11
|
||||
resolution: "@babel/runtime@npm:7.22.11"
|
||||
@@ -3848,15 +3890,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/runtime@npm:^7.23.7":
|
||||
version: 7.23.9
|
||||
resolution: "@babel/runtime@npm:7.23.9"
|
||||
dependencies:
|
||||
regenerator-runtime: ^0.14.0
|
||||
checksum: e71205fdd7082b2656512cc98e647d9ea7e222e4fe5c36e9e5adc026446fcc3ba7b3cdff8b0b694a0b78bb85db83e7b1e3d4c56ef90726682b74f13249cf952d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/template@npm:^7.12.13, @babel/template@npm:^7.12.7, @babel/template@npm:^7.16.7, @babel/template@npm:^7.22.5, @babel/template@npm:^7.3.3":
|
||||
version: 7.22.5
|
||||
resolution: "@babel/template@npm:7.22.5"
|
||||
@@ -5670,6 +5703,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@floating-ui/core@npm:^1.0.0":
|
||||
version: 1.6.0
|
||||
resolution: "@floating-ui/core@npm:1.6.0"
|
||||
dependencies:
|
||||
"@floating-ui/utils": ^0.2.1
|
||||
checksum: 667a68036f7dd5ed19442c7792a6002ca02d1799221c4396691bbe0b6008b48f6ccad581225e81fa266bb91232f6c66838a5f825f554217e1ec886178b93381b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@floating-ui/core@npm:^1.3.1":
|
||||
version: 1.3.1
|
||||
resolution: "@floating-ui/core@npm:1.3.1"
|
||||
@@ -5677,6 +5719,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@floating-ui/dom@npm:^1.0.0":
|
||||
version: 1.6.3
|
||||
resolution: "@floating-ui/dom@npm:1.6.3"
|
||||
dependencies:
|
||||
"@floating-ui/core": ^1.0.0
|
||||
"@floating-ui/utils": ^0.2.0
|
||||
checksum: d6cac10877918ce5a8d1a24b21738d2eb130a0191043d7c0dd43bccac507844d3b4dc5d4107d3891d82f6007945ca8fb4207a1252506e91c37e211f0f73cf77e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@floating-ui/dom@npm:^1.0.1, @floating-ui/dom@npm:^1.3.0":
|
||||
version: 1.4.3
|
||||
resolution: "@floating-ui/dom@npm:1.4.3"
|
||||
@@ -5698,6 +5750,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@floating-ui/utils@npm:^0.2.0, @floating-ui/utils@npm:^0.2.1":
|
||||
version: 0.2.1
|
||||
resolution: "@floating-ui/utils@npm:0.2.1"
|
||||
checksum: ee77756712cf5b000c6bacf11992ffb364f3ea2d0d51cc45197a7e646a17aeb86ea4b192c0b42f3fbb29487aee918a565e84f710b8c3645827767f406a6b4cc9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/ecma402-abstract@npm:1.17.0":
|
||||
version: 1.17.0
|
||||
resolution: "@formatjs/ecma402-abstract@npm:1.17.0"
|
||||
@@ -6135,28 +6194,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@headlessui/react@npm:^1.7.18":
|
||||
version: 1.7.18
|
||||
resolution: "@headlessui/react@npm:1.7.18"
|
||||
dependencies:
|
||||
"@tanstack/react-virtual": ^3.0.0-beta.60
|
||||
client-only: ^0.0.1
|
||||
peerDependencies:
|
||||
react: ^16 || ^17 || ^18
|
||||
react-dom: ^16 || ^17 || ^18
|
||||
checksum: 2d88d10874879182d4b9ed9a7779266032214034481129ba544e858d3624c8d12333e6a9d9d8263f2f116bc823bcfd43a2d1f69800fbf6a47b34d989370346e5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@headlessui/tailwindcss@npm:^0.2.0":
|
||||
version: 0.2.0
|
||||
resolution: "@headlessui/tailwindcss@npm:0.2.0"
|
||||
peerDependencies:
|
||||
tailwindcss: ^3.0
|
||||
checksum: b641bef150e4ee18afd068c1af8e83c582a3131903ed1a310abbdb463b6150428a9b4c38e42cb296469fb20857f5542ae34f681cbff62f47d52332fbc5117479
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@hookform/error-message@npm:^2.0.1":
|
||||
version: 2.0.1
|
||||
resolution: "@hookform/error-message@npm:2.0.1"
|
||||
@@ -8076,8 +8113,7 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@medusajs/dashboard@workspace:packages/admin-next/dashboard"
|
||||
dependencies:
|
||||
"@headlessui/react": ^1.7.18
|
||||
"@headlessui/tailwindcss": ^0.2.0
|
||||
"@ariakit/react": ^0.4.1
|
||||
"@hookform/resolvers": 3.3.2
|
||||
"@medusajs/icons": "workspace:^"
|
||||
"@medusajs/medusa": "workspace:^"
|
||||
@@ -8102,11 +8138,13 @@ __metadata:
|
||||
i18next: 23.7.11
|
||||
i18next-browser-languagedetector: 7.2.0
|
||||
i18next-http-backend: 2.4.2
|
||||
match-sorter: ^6.3.4
|
||||
medusa-react: "workspace:^"
|
||||
postcss: ^8.4.33
|
||||
prettier: ^3.1.1
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0
|
||||
react-focus-lock: ^2.11.1
|
||||
react-hook-form: 7.49.1
|
||||
react-i18next: 13.5.0
|
||||
react-jwt: ^1.2.0
|
||||
@@ -16828,18 +16866,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tanstack/react-virtual@npm:^3.0.0-beta.60":
|
||||
version: 3.0.1
|
||||
resolution: "@tanstack/react-virtual@npm:3.0.1"
|
||||
dependencies:
|
||||
"@tanstack/virtual-core": 3.0.0
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
checksum: 2b9464dab8a975734b651211402a4eaf10e4ae6d9570138576891db350cfabe11e444fea8f17ef0b26ef8ede0adc230bec9c8fe859e61ca66147831076b77bb2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tanstack/react-virtual@npm:^3.0.4":
|
||||
version: 3.0.4
|
||||
resolution: "@tanstack/react-virtual@npm:3.0.4"
|
||||
@@ -23127,13 +23153,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"client-only@npm:^0.0.1":
|
||||
version: 0.0.1
|
||||
resolution: "client-only@npm:0.0.1"
|
||||
checksum: 9d6cfd0c19e1c96a434605added99dff48482152af791ec4172fb912a71cff9027ff174efd8cdb2160cc7f377543e0537ffc462d4f279bc4701de3f2a3c4b358
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"client-sessions@npm:^0.8.0":
|
||||
version: 0.8.0
|
||||
resolution: "client-sessions@npm:0.8.0"
|
||||
@@ -28610,6 +28629,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"focus-lock@npm:^1.3.2":
|
||||
version: 1.3.3
|
||||
resolution: "focus-lock@npm:1.3.3"
|
||||
dependencies:
|
||||
tslib: ^2.0.3
|
||||
checksum: 38b978ab30f5be6f061689c747b05193217e9cb9c5d5a41b9b322454c6bb14e84cbd6c928fa245c62e744f070cfa939820a0ae2dd51011da233de7c912cea412
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"follow-redirects@npm:1.5.10":
|
||||
version: 1.5.10
|
||||
resolution: "follow-redirects@npm:1.5.10"
|
||||
@@ -37250,6 +37278,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"match-sorter@npm:^6.3.4":
|
||||
version: 6.3.4
|
||||
resolution: "match-sorter@npm:6.3.4"
|
||||
dependencies:
|
||||
"@babel/runtime": ^7.23.8
|
||||
remove-accents: 0.5.0
|
||||
checksum: 35d2a6b6df003c677d9ec87ecd4683657638f5bce856f43f9cf90b03e357ed2f09813ebbac759defa7e7438706936dd34dc2bfe1a18771f7d2541f14d639b4ad
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"matcher-collection@npm:^2.0.0":
|
||||
version: 2.0.1
|
||||
resolution: "matcher-collection@npm:2.0.1"
|
||||
@@ -43427,6 +43465,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-clientside-effect@npm:^1.2.6":
|
||||
version: 1.2.6
|
||||
resolution: "react-clientside-effect@npm:1.2.6"
|
||||
dependencies:
|
||||
"@babel/runtime": ^7.12.13
|
||||
peerDependencies:
|
||||
react: ^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
|
||||
checksum: aba0adb666018e5c64657c31f4914a8558be73f71d6e2210fa871ebfcab94d786c83082868d7c7fa66feddc2aec19e375745cf0903e0619f2efffef08b92d941
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-collapsible@npm:^2.8.3":
|
||||
version: 2.10.0
|
||||
resolution: "react-collapsible@npm:2.10.0"
|
||||
@@ -43697,6 +43746,26 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-focus-lock@npm:^2.11.1":
|
||||
version: 2.11.1
|
||||
resolution: "react-focus-lock@npm:2.11.1"
|
||||
dependencies:
|
||||
"@babel/runtime": ^7.0.0
|
||||
focus-lock: ^1.3.2
|
||||
prop-types: ^15.6.2
|
||||
react-clientside-effect: ^1.2.6
|
||||
use-callback-ref: ^1.3.0
|
||||
use-sidecar: ^1.1.2
|
||||
peerDependencies:
|
||||
"@types/react": ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
peerDependenciesMeta:
|
||||
"@types/react":
|
||||
optional: true
|
||||
checksum: b4905ec18bb6bd6732cbd2d3bd70853007dcc31363fa8d0700ea645eec2e2e685f2fe09443d070983c2077fc2d6c06279f2838fc9046c6218facc3e310e50279
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-helmet-async@npm:^1.3.0":
|
||||
version: 1.3.0
|
||||
resolution: "react-helmet-async@npm:1.3.0"
|
||||
@@ -44759,6 +44828,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"remove-accents@npm:0.5.0":
|
||||
version: 0.5.0
|
||||
resolution: "remove-accents@npm:0.5.0"
|
||||
checksum: a75321aa1b53d9abe82637115a492770bfe42bb38ed258be748bf6795871202bc8b4badff22013494a7029f5a241057ad8d3f72adf67884dbe15a9e37e87adc4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"remove-bom-buffer@npm:^3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "remove-bom-buffer@npm:3.0.0"
|
||||
|
||||
Reference in New Issue
Block a user