feat(dashboard,medusa): Draft order detail (#6703)
**What** - Adds draft order details page - Adds Shipping and Billing address forms to both draft order and order pages - Adds Email form to both draft order and order pages - Adds transfer ownership form to draft order, order and customer pages - Update Combobox component allowing it to work with async data (`useInfiniteQuery`) **@medusajs/medusa** - Include country as a default relation of draft order addresses
This commit is contained in:
committed by
GitHub
parent
68d869607f
commit
c3f26a6826
@@ -12,217 +12,318 @@ import * as Popover from "@radix-ui/react-popover"
|
||||
import { matchSorter } from "match-sorter"
|
||||
import {
|
||||
ComponentPropsWithoutRef,
|
||||
forwardRef,
|
||||
ForwardedRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition,
|
||||
} from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { genericForwardRef } from "../generic-forward-ref"
|
||||
|
||||
type ComboboxOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface ComboboxProps
|
||||
type Value = string[] | string
|
||||
|
||||
interface ComboboxProps<T extends Value = Value>
|
||||
extends Omit<ComponentPropsWithoutRef<"input">, "onChange" | "value"> {
|
||||
value?: string[]
|
||||
onChange?: (value?: string[]) => void
|
||||
value?: T
|
||||
onChange?: (value?: T) => void
|
||||
searchValue?: string
|
||||
onSearchValueChange?: (value: string) => void
|
||||
options: ComboboxOption[]
|
||||
fetchNextPage?: () => void
|
||||
isFetchingNextPage?: boolean
|
||||
}
|
||||
|
||||
export const Combobox = forwardRef<HTMLInputElement, ComboboxProps>(
|
||||
(
|
||||
{
|
||||
value: controlledValue,
|
||||
onChange,
|
||||
options,
|
||||
className,
|
||||
placeholder,
|
||||
...inputProps
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const ComboboxImpl = <T extends Value = string>(
|
||||
{
|
||||
value: controlledValue,
|
||||
onChange,
|
||||
searchValue: controlledSearchValue,
|
||||
onSearchValueChange,
|
||||
options,
|
||||
className,
|
||||
placeholder,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
...inputProps
|
||||
}: ComboboxProps<T>,
|
||||
ref: ForwardedRef<HTMLInputElement>
|
||||
) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const comboboxRef = useRef<HTMLInputElement>(null)
|
||||
const listboxRef = useRef<HTMLDivElement>(null)
|
||||
const comboboxRef = useRef<HTMLInputElement>(null)
|
||||
const listboxRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useImperativeHandle(ref, () => comboboxRef.current!)
|
||||
useImperativeHandle(ref, () => comboboxRef.current!)
|
||||
|
||||
const isControlled = controlledValue !== undefined
|
||||
const [searchValue, setSearchValue] = useState("")
|
||||
const [uncontrolledValue, setUncontrolledValue] = useState<string[]>([])
|
||||
const isValueControlled = controlledValue !== undefined
|
||||
const isSearchControlled = controlledSearchValue !== undefined
|
||||
|
||||
const selectedValues = isControlled ? controlledValue : uncontrolledValue
|
||||
const isArrayValue = Array.isArray(controlledValue)
|
||||
const emptyState = (isArrayValue ? [] : "") as T
|
||||
|
||||
const handleValueChange = (newValues?: string[]) => {
|
||||
if (!isControlled) {
|
||||
setUncontrolledValue(newValues || [])
|
||||
}
|
||||
if (onChange) {
|
||||
onChange(newValues)
|
||||
}
|
||||
const [uncontrolledSearchValue, setUncontrolledSearchValue] = useState(
|
||||
controlledSearchValue || ""
|
||||
)
|
||||
const [uncontrolledValue, setUncontrolledValue] = useState<T>(emptyState)
|
||||
|
||||
const searchValue = isSearchControlled
|
||||
? controlledSearchValue
|
||||
: uncontrolledSearchValue
|
||||
const selectedValues = isValueControlled ? controlledValue : uncontrolledValue
|
||||
|
||||
const handleValueChange = (newValues?: T) => {
|
||||
if (!isArrayValue) {
|
||||
const label = options.find((o) => o.value === newValues)?.label
|
||||
|
||||
setUncontrolledSearchValue(label || "")
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (!isValueControlled) {
|
||||
setUncontrolledValue(newValues || emptyState)
|
||||
}
|
||||
if (onChange) {
|
||||
onChange(newValues)
|
||||
}
|
||||
|
||||
if (aIndex === -1 && bIndex === -1) {
|
||||
return 0
|
||||
}
|
||||
setUncontrolledSearchValue("")
|
||||
}
|
||||
|
||||
if (aIndex === -1) {
|
||||
return 1
|
||||
}
|
||||
const handleSearchChange = (query: string) => {
|
||||
setUncontrolledSearchValue(query)
|
||||
|
||||
if (bIndex === -1) {
|
||||
return -1
|
||||
}
|
||||
if (onSearchValueChange) {
|
||||
onSearchValueChange(query)
|
||||
}
|
||||
}
|
||||
|
||||
return aIndex - bIndex
|
||||
},
|
||||
})
|
||||
}, [options, searchValue, selectedValues])
|
||||
/**
|
||||
* Filter and sort the options based on the search value,
|
||||
* and whether the value is already selected.
|
||||
*
|
||||
* This is only used when the search value is uncontrolled.
|
||||
*/
|
||||
const matches = useMemo(() => {
|
||||
if (isSearchControlled) {
|
||||
return []
|
||||
}
|
||||
|
||||
const hasValues = selectedValues.length > 0
|
||||
const showSelected = hasValues && !searchValue && !open
|
||||
const hidePlaceholder = showSelected || open
|
||||
return matchSorter(options, uncontrolledSearchValue, {
|
||||
keys: ["label"],
|
||||
baseSort: (a, b) => {
|
||||
const aIndex = selectedValues.indexOf(a.item.value)
|
||||
const bIndex = selectedValues.indexOf(b.item.value)
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<PrimitiveComboboxProvider
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
selectedValue={selectedValues}
|
||||
setSelectedValue={handleValueChange}
|
||||
setValue={(value) => {
|
||||
setSearchValue(value)
|
||||
}}
|
||||
>
|
||||
<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
|
||||
if (aIndex === -1 && bIndex === -1) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (aIndex === -1) {
|
||||
return 1
|
||||
}
|
||||
|
||||
if (bIndex === -1) {
|
||||
return -1
|
||||
}
|
||||
|
||||
return aIndex - bIndex
|
||||
},
|
||||
})
|
||||
}, [options, uncontrolledSearchValue, selectedValues, isSearchControlled])
|
||||
|
||||
const observer = useRef(
|
||||
new IntersectionObserver(
|
||||
(entries) => {
|
||||
const first = entries[0]
|
||||
if (first.isIntersecting) {
|
||||
fetchNextPage?.()
|
||||
}
|
||||
},
|
||||
{ threshold: 1 }
|
||||
)
|
||||
)
|
||||
|
||||
const lastOptionRef = useCallback(
|
||||
(node: HTMLDivElement) => {
|
||||
if (isFetchingNextPage) {
|
||||
return
|
||||
}
|
||||
if (observer.current) {
|
||||
observer.current.disconnect()
|
||||
}
|
||||
if (node) {
|
||||
observer.current.observe(node)
|
||||
}
|
||||
},
|
||||
[isFetchingNextPage]
|
||||
)
|
||||
|
||||
const hasValue = selectedValues.length > 0
|
||||
|
||||
const showTag = hasValue && isArrayValue
|
||||
const showSelected = showTag && !searchValue && !open
|
||||
|
||||
const hideInput = !isArrayValue && !open
|
||||
const selectedLabel = options.find((o) => o.value === selectedValues)?.label
|
||||
|
||||
const hidePlaceholder = showSelected || open
|
||||
|
||||
const results = isSearchControlled ? options : matches
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<PrimitiveComboboxProvider
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
selectedValue={selectedValues}
|
||||
setSelectedValue={(value) => handleValueChange(value as T)}
|
||||
value={uncontrolledSearchValue}
|
||||
setValue={(query) => {
|
||||
startTransition(() => handleSearchChange(query))
|
||||
}}
|
||||
>
|
||||
<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": hasValue && isArrayValue,
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
{showTag && (
|
||||
<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>
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
{hideInput && (
|
||||
<div className="absolute inset-y-0 left-0 flex size-full items-center overflow-hidden">
|
||||
<Text size="small" leading="compact" className="truncate">
|
||||
{selectedLabel}
|
||||
</Text>
|
||||
</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"
|
||||
<PrimitiveCombobox
|
||||
ref={comboboxRef}
|
||||
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"
|
||||
"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",
|
||||
{
|
||||
"opacity-0": hideInput,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{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"
|
||||
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()
|
||||
}
|
||||
}}
|
||||
aria-busy={isPending}
|
||||
>
|
||||
<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"
|
||||
)}
|
||||
>
|
||||
{results.map(({ value, label }) => (
|
||||
<PrimitiveComboboxItem
|
||||
key={value}
|
||||
value={value}
|
||||
focusOnHover
|
||||
setValueOnClick={false}
|
||||
className="transition-fg bg-ui-bg-base data-[active-item=true]:bg-ui-bg-base-hover group flex cursor-pointer 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">
|
||||
{label}
|
||||
</PrimitiveComboboxItemValue>
|
||||
</PrimitiveComboboxItem>
|
||||
))}
|
||||
{!!fetchNextPage && <div ref={lastOptionRef} className="w-px" />}
|
||||
{isFetchingNextPage && (
|
||||
<div className="transition-fg bg-ui-bg-base flex items-center rounded-[4px] px-2 py-1.5">
|
||||
<div className="bg-ui-bg-component size-full h-5 w-full animate-pulse rounded-[4px]" />
|
||||
</div>
|
||||
)}
|
||||
{!results.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"
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
)
|
||||
Combobox.displayName = "Combobox"
|
||||
{t("general.noResultsTitle")}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</PrimitiveComboboxList>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</PrimitiveComboboxProvider>
|
||||
</Popover.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export const Combobox = genericForwardRef(ComboboxImpl)
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { Address, Cart, Order } from "@medusajs/medusa"
|
||||
import { Avatar, Copy, Text } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
const ID = ({ data }: { data: Cart | Order }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const id = data.customer_id
|
||||
const name = getCartOrOrderCustomer(data)
|
||||
const email = data.email
|
||||
const fallback = (name || email).charAt(0).toUpperCase()
|
||||
|
||||
return (
|
||||
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("fields.id")}
|
||||
</Text>
|
||||
<Link
|
||||
to={`/customers/${id}`}
|
||||
className="focus:shadow-borders-focus rounded-[4px] outline-none transition-shadow"
|
||||
>
|
||||
<div className="flex items-center gap-x-2 overflow-hidden">
|
||||
<Avatar size="2xsmall" fallback={fallback} />
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
className="text-ui-fg-subtle hover:text-ui-fg-base transition-fg truncate"
|
||||
>
|
||||
{name || email}
|
||||
</Text>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Company = ({ data }: { data: Order | Cart }) => {
|
||||
const { t } = useTranslation()
|
||||
const company =
|
||||
data.shipping_address?.company || data.billing_address?.company
|
||||
|
||||
if (!company) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("fields.company")}
|
||||
</Text>
|
||||
<Text size="small" leading="compact" className="truncate">
|
||||
{company}
|
||||
</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Contact = ({ data }: { data: Cart | Order }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const phone = data.shipping_address?.phone || data.billing_address?.phone
|
||||
const email = data.email
|
||||
|
||||
return (
|
||||
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("orders.customer.contactLabel")}
|
||||
</Text>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div className="grid grid-cols-[1fr_20px] items-start gap-x-2">
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
className="text-pretty break-all"
|
||||
>
|
||||
{email}
|
||||
</Text>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Copy content={email} className="text-ui-fg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
{phone && (
|
||||
<div className="grid grid-cols-[1fr_20px] items-start gap-x-2">
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
className="text-pretty break-all"
|
||||
>
|
||||
{phone}
|
||||
</Text>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Copy content={email} className="text-ui-fg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const AddressPrint = ({
|
||||
address,
|
||||
type,
|
||||
}: {
|
||||
address: Address | null
|
||||
type: "shipping" | "billing"
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{type === "shipping"
|
||||
? t("addresses.shippingAddress.label")
|
||||
: t("addresses.billingAddress.label")}
|
||||
</Text>
|
||||
{address ? (
|
||||
<div className="grid grid-cols-[1fr_20px] items-start gap-x-2">
|
||||
<Text size="small" leading="compact">
|
||||
{getFormattedAddress({ address }).map((line, i) => {
|
||||
return (
|
||||
<span key={i} className="break-words">
|
||||
{line}
|
||||
<br />
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</Text>
|
||||
<div className="flex justify-end">
|
||||
<Copy
|
||||
content={getFormattedAddress({ address }).join("\n")}
|
||||
className="text-ui-fg-muted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Text size="small" leading="compact">
|
||||
-
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Addresses = ({ data }: { data: Cart | Order }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="divide-y">
|
||||
<AddressPrint address={data.shipping_address} type="shipping" />
|
||||
{!isSameAddress(data.shipping_address, data.billing_address) ? (
|
||||
<AddressPrint address={data.billing_address} type="billing" />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 items-center px-6 py-4">
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
weight="plus"
|
||||
className="text-ui-fg-subtle"
|
||||
>
|
||||
{t("addresses.billingAddress.label")}
|
||||
</Text>
|
||||
<Text size="small" leading="compact" className="text-ui-fg-muted">
|
||||
{t("addresses.billingAddress.sameAsShipping")}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const CustomerInfo = Object.assign(
|
||||
{},
|
||||
{
|
||||
ID,
|
||||
Company,
|
||||
Contact,
|
||||
Addresses,
|
||||
}
|
||||
)
|
||||
|
||||
const isSameAddress = (a: Address | null, b: Address | null) => {
|
||||
if (!a || !b) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
a.first_name === b.first_name &&
|
||||
a.last_name === b.last_name &&
|
||||
a.address_1 === b.address_1 &&
|
||||
a.address_2 === b.address_2 &&
|
||||
a.city === b.city &&
|
||||
a.postal_code === b.postal_code &&
|
||||
a.province === b.province &&
|
||||
a.country_code === b.country_code
|
||||
)
|
||||
}
|
||||
|
||||
const getFormattedAddress = ({ address }: { address: Address }) => {
|
||||
const {
|
||||
first_name,
|
||||
last_name,
|
||||
company,
|
||||
address_1,
|
||||
address_2,
|
||||
city,
|
||||
postal_code,
|
||||
province,
|
||||
country,
|
||||
country_code,
|
||||
} = address
|
||||
|
||||
const name = [first_name, last_name].filter(Boolean).join(" ")
|
||||
|
||||
const formattedAddress = []
|
||||
|
||||
if (name) {
|
||||
formattedAddress.push(name)
|
||||
}
|
||||
|
||||
if (company) {
|
||||
formattedAddress.push(company)
|
||||
}
|
||||
|
||||
if (address_1) {
|
||||
formattedAddress.push(address_1)
|
||||
}
|
||||
|
||||
if (address_2) {
|
||||
formattedAddress.push(address_2)
|
||||
}
|
||||
|
||||
const cityProvincePostal = [city, province, postal_code]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
|
||||
if (cityProvincePostal) {
|
||||
formattedAddress.push(cityProvincePostal)
|
||||
}
|
||||
|
||||
if (country) {
|
||||
formattedAddress.push(country.display_name)
|
||||
} else if (country_code) {
|
||||
formattedAddress.push(country_code.toUpperCase())
|
||||
}
|
||||
|
||||
return formattedAddress
|
||||
}
|
||||
|
||||
const getCartOrOrderCustomer = (obj: Cart | Order) => {
|
||||
const { first_name: sFirstName, last_name: sLastName } =
|
||||
obj.shipping_address || {}
|
||||
const { first_name: bFirstName, last_name: bLastName } =
|
||||
obj.billing_address || {}
|
||||
const { first_name: cFirstName, last_name: cLastName } = obj.customer || {}
|
||||
|
||||
const customerName = [cFirstName, cLastName].filter(Boolean).join(" ")
|
||||
const shippingName = [sFirstName, sLastName].filter(Boolean).join(" ")
|
||||
const billingName = [bFirstName, bLastName].filter(Boolean).join(" ")
|
||||
|
||||
const name = customerName || shippingName || billingName
|
||||
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./customer-info"
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Input } from "@medusajs/ui"
|
||||
import { ComponentProps, useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
type DebouncedSearchProps = Omit<
|
||||
ComponentProps<typeof Input>,
|
||||
"value" | "defaultValue" | "onChange" | "type"
|
||||
> & {
|
||||
debounce?: number
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export const DebouncedSearch = ({
|
||||
value: initialValue,
|
||||
onChange,
|
||||
debounce = 500,
|
||||
size = "small",
|
||||
placeholder,
|
||||
...props
|
||||
}: DebouncedSearchProps) => {
|
||||
const [value, setValue] = useState<string>(initialValue)
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
setValue(initialValue)
|
||||
}, [initialValue])
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
onChange?.(value)
|
||||
}, debounce)
|
||||
|
||||
return () => clearTimeout(timeout)
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
placeholder={placeholder || t("general.search")}
|
||||
type="search"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./debounced-search"
|
||||
@@ -0,0 +1,7 @@
|
||||
import { ReactNode, Ref, RefAttributes, forwardRef } from "react"
|
||||
|
||||
export function genericForwardRef<T, P = {}>(
|
||||
render: (props: P, ref: Ref<T>) => ReactNode
|
||||
): (props: P & RefAttributes<T>) => ReactNode {
|
||||
return forwardRef(render) as any
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./generic-forward-ref"
|
||||
@@ -8,7 +8,7 @@ export const Skeleton = ({ className }: SkeletonProps) => {
|
||||
return (
|
||||
<div
|
||||
className={clx(
|
||||
"bg-ui-bg-component animate-pulse w-3 h-3 rounded-[4px]",
|
||||
"bg-ui-bg-component h-3 w-3 animate-pulse rounded-[4px]",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { Country } from "@medusajs/medusa"
|
||||
import { Heading, Input, Select, clx } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { z } from "zod"
|
||||
|
||||
import { Control } from "react-hook-form"
|
||||
import { AddressSchema } from "../../../lib/schemas"
|
||||
import { CountrySelect } from "../../common/country-select"
|
||||
import { Form } from "../../common/form"
|
||||
|
||||
type AddressFieldValues = z.infer<typeof AddressSchema>
|
||||
|
||||
type AddressFormProps = {
|
||||
control: Control<AddressFieldValues>
|
||||
countries?: Country[]
|
||||
layout: "grid" | "stack"
|
||||
}
|
||||
|
||||
export const AddressForm = ({
|
||||
control,
|
||||
countries,
|
||||
layout,
|
||||
}: AddressFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const style = clx("gap-4", {
|
||||
"flex flex-col": layout === "stack",
|
||||
"grid grid-cols-2": layout === "grid",
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-8">
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<Heading level="h2">{t("addresses.contactHeading")}</Heading>
|
||||
<fieldset className={style}>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="first_name"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.firstName")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="last_name"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.lastName")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="company"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>{t("fields.company")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="phone"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.phone")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<Heading level="h2">{t("addresses.locationHeading")}</Heading>
|
||||
<fieldset className={style}>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="address_1"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.address")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="address_2"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>{t("fields.address2")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="city"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.city")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="postal_code"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.postalCode")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="province"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>{t("fields.province")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="country_code"
|
||||
render={({ field: { ref, onChange, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.country")}</Form.Label>
|
||||
<Form.Control>
|
||||
{countries ? (
|
||||
<Select {...field} onValueChange={onChange}>
|
||||
<Select.Trigger ref={ref}>
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{countries.map((country) => (
|
||||
<Select.Item
|
||||
key={country.iso_2}
|
||||
value={country.iso_2}
|
||||
>
|
||||
{country.display_name}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select>
|
||||
) : (
|
||||
<CountrySelect {...field} ref={ref} onChange={onChange} /> // When no countries are provided, use the country select component that has a built-in list of all countries
|
||||
)}
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./address-form"
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Input, clx } from "@medusajs/ui"
|
||||
import { Control } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { z } from "zod"
|
||||
import { EmailSchema } from "../../../lib/schemas"
|
||||
import { Form } from "../../common/form"
|
||||
|
||||
type EmailFieldValues = z.infer<typeof EmailSchema>
|
||||
|
||||
type EmailFormProps = {
|
||||
control: Control<EmailFieldValues>
|
||||
layout?: "grid" | "stack"
|
||||
}
|
||||
|
||||
export const EmailForm = ({ control, layout = "stack" }: EmailFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clx("gap-4", {
|
||||
"flex flex-col": layout === "stack",
|
||||
"grid grid-cols-2": layout === "grid",
|
||||
})}
|
||||
>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.email")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./email-form"
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./transfer-ownership-form"
|
||||
@@ -0,0 +1,261 @@
|
||||
import { Customer, DraftOrder, Order } from "@medusajs/medusa"
|
||||
import { Select, Text, clx } from "@medusajs/ui"
|
||||
import { useInfiniteQuery } from "@tanstack/react-query"
|
||||
import { format } from "date-fns"
|
||||
import { debounce } from "lodash"
|
||||
import { useAdminCustomer } from "medusa-react"
|
||||
import { PropsWithChildren, useCallback, useEffect, useState } from "react"
|
||||
import { Control, useWatch } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { z } from "zod"
|
||||
import { medusa } from "../../../lib/medusa"
|
||||
import { getStylizedAmount } from "../../../lib/money-amount-helpers"
|
||||
import {
|
||||
getOrderFulfillmentStatus,
|
||||
getOrderPaymentStatus,
|
||||
} from "../../../lib/order-helpers"
|
||||
import { TransferOwnershipSchema } from "../../../lib/schemas"
|
||||
import { Combobox } from "../../common/combobox"
|
||||
import { Form } from "../../common/form"
|
||||
import { Skeleton } from "../../common/skeleton"
|
||||
|
||||
type TransferOwnerShipFieldValues = z.infer<typeof TransferOwnershipSchema>
|
||||
|
||||
type TransferOwnerShipFormProps = {
|
||||
/**
|
||||
* The Order or DraftOrder to transfer ownership of.
|
||||
*/
|
||||
order: Order | DraftOrder
|
||||
/**
|
||||
* React Hook Form control object.
|
||||
*/
|
||||
control: Control<TransferOwnerShipFieldValues>
|
||||
}
|
||||
|
||||
const isOrder = (order: Order | DraftOrder): order is Order => {
|
||||
return "customer" in order
|
||||
}
|
||||
|
||||
export const TransferOwnerShipForm = ({
|
||||
order,
|
||||
control,
|
||||
}: TransferOwnerShipFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [query, setQuery] = useState("")
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("")
|
||||
|
||||
const isOrderType = isOrder(order)
|
||||
const currentOwnerId = useWatch({
|
||||
control,
|
||||
name: "current_owner_id",
|
||||
})
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedUpdate = useCallback(
|
||||
debounce((query) => setDebouncedQuery(query), 300),
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
debouncedUpdate(query)
|
||||
|
||||
return () => debouncedUpdate.cancel()
|
||||
}, [query, debouncedUpdate])
|
||||
|
||||
const {
|
||||
customer: owner,
|
||||
isLoading: isLoadingOwner,
|
||||
isError: isOwnerError,
|
||||
error: ownerError,
|
||||
} = useAdminCustomer(currentOwnerId)
|
||||
|
||||
const { data, fetchNextPage, isFetchingNextPage } = useInfiniteQuery(
|
||||
["customers", debouncedQuery],
|
||||
async ({ pageParam = 0 }) => {
|
||||
const res = await medusa.admin.customers.list({
|
||||
q: debouncedQuery,
|
||||
limit: 10,
|
||||
offset: pageParam,
|
||||
has_account: true, // Only show customers with confirmed accounts
|
||||
})
|
||||
return res
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => {
|
||||
const moreCustomersExist =
|
||||
lastPage.count > lastPage.offset + lastPage.limit
|
||||
return moreCustomersExist ? lastPage.offset + lastPage.limit : undefined
|
||||
},
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
|
||||
const createLabel = (customer?: Customer) => {
|
||||
if (!customer) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const { first_name, last_name, email } = customer
|
||||
|
||||
const name = [first_name, last_name].filter(Boolean).join(" ")
|
||||
|
||||
if (name) {
|
||||
return `${name} (${email})`
|
||||
}
|
||||
|
||||
return email
|
||||
}
|
||||
|
||||
const ownerReady = !isLoadingOwner && owner
|
||||
|
||||
const options =
|
||||
data?.pages
|
||||
.map((p) =>
|
||||
p.customers.map((c) => ({
|
||||
label: createLabel(c),
|
||||
value: c.id,
|
||||
}))
|
||||
)
|
||||
.flat() || []
|
||||
|
||||
if (isOwnerError) {
|
||||
throw ownerError
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-8">
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{isOrderType
|
||||
? t("transferOwnership.details.order")
|
||||
: t("transferOwnership.details.draft")}
|
||||
</Text>
|
||||
{isOrderType ? (
|
||||
<OrderDetailsTable order={order} />
|
||||
) : (
|
||||
<DraftOrderDetailsTable draft={order} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div>
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("transferOwnership.currentOwner.label")}
|
||||
</Text>
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
{t("transferOwnership.currentOwner.hint")}
|
||||
</Text>
|
||||
</div>
|
||||
{ownerReady ? (
|
||||
<Select defaultValue={owner.id} disabled>
|
||||
<Select.Trigger>
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value={owner.id}>{createLabel(owner)}</Select.Item>
|
||||
</Select.Content>
|
||||
</Select>
|
||||
) : (
|
||||
<Skeleton className="h-8 w-full rounded-md" />
|
||||
)}
|
||||
</div>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="new_owner_id"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<div className="flex flex-col">
|
||||
<Form.Label>{t("transferOwnership.newOwner.label")}</Form.Label>
|
||||
<Form.Hint>{t("transferOwnership.newOwner.hint")}</Form.Hint>
|
||||
</div>
|
||||
<Form.Control>
|
||||
<Combobox
|
||||
{...field}
|
||||
searchValue={query}
|
||||
onSearchValueChange={setQuery}
|
||||
fetchNextPage={fetchNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
options={options}
|
||||
/>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const OrderDetailsTable = ({ order }: { order: Order }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { label: fulfillmentLabel } = getOrderFulfillmentStatus(
|
||||
t,
|
||||
order.fulfillment_status
|
||||
)
|
||||
|
||||
const { label: paymentLabel } = getOrderPaymentStatus(t, order.payment_status)
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<Row label={t("fields.order")} value={`#${order.display_id}`} />
|
||||
<DateRow date={order.created_at} />
|
||||
<Row label={t("fields.fulfillment")} value={fulfillmentLabel} />
|
||||
<Row label={t("fields.payment")} value={paymentLabel} />
|
||||
<TotalRow total={order.total || 0} currencyCode={order.currency_code} />
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
const DraftOrderDetailsTable = ({ draft }: { draft: DraftOrder }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<Row label={t("fields.draft")} value={`#${draft.display_id}`} />
|
||||
<DateRow date={draft.created_at} />
|
||||
<Row
|
||||
label={t("fields.status")}
|
||||
value={t(`draftOrders.status.${draft.status}`)}
|
||||
/>
|
||||
<TotalRow
|
||||
total={draft.cart.total || 0}
|
||||
currencyCode={draft.cart.region.currency_code}
|
||||
/>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
const DateRow = ({ date }: { date: string | Date }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const formattedDate = format(new Date(date), "dd MMM yyyy")
|
||||
|
||||
return <Row label={t("fields.date")} value={formattedDate} />
|
||||
}
|
||||
|
||||
const TotalRow = ({
|
||||
total,
|
||||
currencyCode,
|
||||
}: {
|
||||
total: number
|
||||
currencyCode: string
|
||||
}) => {
|
||||
return <Row label="Total" value={getStylizedAmount(total, currencyCode)} />
|
||||
}
|
||||
|
||||
const Row = ({ label, value }: { label: string; value: string }) => {
|
||||
return (
|
||||
<div className="txt-compact-small grid grid-cols-2 divide-x">
|
||||
<div className="text-ui-fg-muted px-2 py-1.5">{label}</div>
|
||||
<div className="text-ui-fg-subtle px-2 py-1.5">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Table = ({ children }: PropsWithChildren) => {
|
||||
return <div className={clx("divide-y rounded-lg border")}>{children}</div>
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FulfillmentStatus } from "@medusajs/medusa"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { getOrderFulfillmentStatus } from "../../../../../lib/order-helpers"
|
||||
import { StatusCell } from "../../common/status-cell"
|
||||
|
||||
type FulfillmentStatusCellProps = {
|
||||
@@ -11,26 +12,7 @@ export const FulfillmentStatusCell = ({
|
||||
}: FulfillmentStatusCellProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [label, color] = {
|
||||
not_fulfilled: [t("orders.fulfillment.status.notFulfilled"), "red"],
|
||||
partially_fulfilled: [
|
||||
t("orders.fulfillment.status.partiallyFulfilled"),
|
||||
"orange",
|
||||
],
|
||||
fulfilled: [t("orders.fulfillment.status.fulfilled"), "green"],
|
||||
partially_shipped: [
|
||||
t("orders.fulfillment.status.partiallyShipped"),
|
||||
"orange",
|
||||
],
|
||||
shipped: [t("orders.fulfillment.status.shipped"), "green"],
|
||||
partially_returned: [
|
||||
t("orders.fulfillment.status.partiallyReturned"),
|
||||
"orange",
|
||||
],
|
||||
returned: [t("orders.fulfillment.status.returned"), "green"],
|
||||
canceled: [t("orders.fulfillment.status.canceled"), "red"],
|
||||
requires_action: [t("orders.fulfillment.status.requiresAction"), "orange"],
|
||||
}[status] as [string, "red" | "orange" | "green"]
|
||||
const { label, color } = getOrderFulfillmentStatus(t, status)
|
||||
|
||||
return <StatusCell color={color}>{label}</StatusCell>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PaymentStatus } from "@medusajs/medusa"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { getOrderPaymentStatus } from "../../../../../lib/order-helpers"
|
||||
import { StatusCell } from "../../common/status-cell"
|
||||
|
||||
type PaymentStatusCellProps = {
|
||||
@@ -9,18 +10,7 @@ type PaymentStatusCellProps = {
|
||||
export const PaymentStatusCell = ({ status }: PaymentStatusCellProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [label, color] = {
|
||||
not_paid: [t("orders.payment.status.notPaid"), "red"],
|
||||
awaiting: [t("orders.payment.status.awaiting"), "orange"],
|
||||
captured: [t("orders.payment.status.captured"), "green"],
|
||||
refunded: [t("orders.payment.status.refunded"), "green"],
|
||||
partially_refunded: [
|
||||
t("orders.payment.status.partiallyRefunded"),
|
||||
"orange",
|
||||
],
|
||||
canceled: [t("orders.payment.status.canceled"), "red"],
|
||||
requires_action: [t("orders.payment.status.requiresAction"), "orange"],
|
||||
}[status] as [string, "red" | "orange" | "green"]
|
||||
const { label, color } = getOrderPaymentStatus(t, status)
|
||||
|
||||
return <StatusCell color={color}>{label}</StatusCell>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user