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:
1 parent
68d869607f
commit
c3f26a6826
70 files changed
+2737
-426
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/medusa": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix(medusa): Include `country` in draft orders' carts' default relations to allow properly displaying addresses.
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
"generate:static": "node ./scripts/generate-countries.js && prettier --write ./src/lib/countries.ts && node ./scripts/generate-currencies.js && prettier --write ./src/lib/currencies.ts",
|
"generate:static": "node ./scripts/generate-countries.js && prettier --write ./src/lib/countries.ts && node ./scripts/generate-currencies.js && prettier --write ./src/lib/currencies.ts",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||||
},
|
},
|
||||||
"main": "index.html",
|
"main": "index.html",
|
||||||
|
|||||||
@@ -91,10 +91,47 @@
|
|||||||
"defaultMessage": "An error occurred while rendering this page."
|
"defaultMessage": "An error occurred while rendering this page."
|
||||||
},
|
},
|
||||||
"addresses": {
|
"addresses": {
|
||||||
"shippingAddress": "Shipping Address",
|
"shippingAddress": {
|
||||||
"billingAddress": "Billing Address",
|
"header": "Shipping Address",
|
||||||
|
"editHeader": "Edit Shipping Address",
|
||||||
|
"editLabel": "Shipping address",
|
||||||
|
"label": "Shipping address"
|
||||||
|
},
|
||||||
|
"billingAddress": {
|
||||||
|
"header": "Billing Address",
|
||||||
|
"editHeader": "Edit Billing Address",
|
||||||
|
"editLabel": "Billing address",
|
||||||
|
"label": "Billing address",
|
||||||
"sameAsShipping": "Same as shipping address"
|
"sameAsShipping": "Same as shipping address"
|
||||||
},
|
},
|
||||||
|
"contactHeading": "Contact",
|
||||||
|
"locationHeading": "Location"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"editHeader": "Edit Email",
|
||||||
|
"editLabel": "Email",
|
||||||
|
"label": "Email"
|
||||||
|
},
|
||||||
|
"transferOwnership": {
|
||||||
|
"header": "Transfer Ownership",
|
||||||
|
"label": "Transfer ownership",
|
||||||
|
"details": {
|
||||||
|
"order": "Order details",
|
||||||
|
"draft": "Draft details"
|
||||||
|
},
|
||||||
|
"currentOwner": {
|
||||||
|
"label": "Current owner",
|
||||||
|
"hint": "The current owner of the order."
|
||||||
|
},
|
||||||
|
"newOwner": {
|
||||||
|
"label": "New owner",
|
||||||
|
"hint": "The new owner to transfer the order to."
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"mustBeDifferent": "The new owner must be different from the current owner.",
|
||||||
|
"required": "New owner is required."
|
||||||
|
}
|
||||||
|
},
|
||||||
"products": {
|
"products": {
|
||||||
"domain": "Products",
|
"domain": "Products",
|
||||||
"createProductTitle": "Create Product",
|
"createProductTitle": "Create Product",
|
||||||
@@ -257,6 +294,13 @@
|
|||||||
"draftOrders": {
|
"draftOrders": {
|
||||||
"domain": "Draft Orders",
|
"domain": "Draft Orders",
|
||||||
"deleteWarning": "You are about to delete the draft order {{id}}. This action cannot be undone.",
|
"deleteWarning": "You are about to delete the draft order {{id}}. This action cannot be undone.",
|
||||||
|
"paymentLinkLabel": "Payment link",
|
||||||
|
"cartIdLabel": "Cart ID",
|
||||||
|
"markAsPaid": {
|
||||||
|
"label": "Mark as paid",
|
||||||
|
"warningTitle": "Mark as Paid",
|
||||||
|
"warningDescription": "You are about to mark the draft order as paid. This action cannot be undone, and collecting payment will not be possible later."
|
||||||
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"open": "Open",
|
"open": "Open",
|
||||||
"completed": "Completed"
|
"completed": "Completed"
|
||||||
@@ -741,7 +785,8 @@
|
|||||||
"summary": "Summary",
|
"summary": "Summary",
|
||||||
"label": "Label",
|
"label": "Label",
|
||||||
"rate": "Rate",
|
"rate": "Rate",
|
||||||
"requiresShipping": "Requires shipping"
|
"requiresShipping": "Requires shipping",
|
||||||
|
"draft": "Draft"
|
||||||
},
|
},
|
||||||
"dateTime": {
|
"dateTime": {
|
||||||
"years_one": "Year",
|
"years_one": "Year",
|
||||||
|
|||||||
@@ -12,39 +12,52 @@ import * as Popover from "@radix-ui/react-popover"
|
|||||||
import { matchSorter } from "match-sorter"
|
import { matchSorter } from "match-sorter"
|
||||||
import {
|
import {
|
||||||
ComponentPropsWithoutRef,
|
ComponentPropsWithoutRef,
|
||||||
forwardRef,
|
ForwardedRef,
|
||||||
|
useCallback,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
useTransition,
|
||||||
} from "react"
|
} from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { genericForwardRef } from "../generic-forward-ref"
|
||||||
|
|
||||||
type ComboboxOption = {
|
type ComboboxOption = {
|
||||||
value: string
|
value: string
|
||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ComboboxProps
|
type Value = string[] | string
|
||||||
|
|
||||||
|
interface ComboboxProps<T extends Value = Value>
|
||||||
extends Omit<ComponentPropsWithoutRef<"input">, "onChange" | "value"> {
|
extends Omit<ComponentPropsWithoutRef<"input">, "onChange" | "value"> {
|
||||||
value?: string[]
|
value?: T
|
||||||
onChange?: (value?: string[]) => void
|
onChange?: (value?: T) => void
|
||||||
|
searchValue?: string
|
||||||
|
onSearchValueChange?: (value: string) => void
|
||||||
options: ComboboxOption[]
|
options: ComboboxOption[]
|
||||||
|
fetchNextPage?: () => void
|
||||||
|
isFetchingNextPage?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Combobox = forwardRef<HTMLInputElement, ComboboxProps>(
|
const ComboboxImpl = <T extends Value = string>(
|
||||||
(
|
|
||||||
{
|
{
|
||||||
value: controlledValue,
|
value: controlledValue,
|
||||||
onChange,
|
onChange,
|
||||||
|
searchValue: controlledSearchValue,
|
||||||
|
onSearchValueChange,
|
||||||
options,
|
options,
|
||||||
className,
|
className,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
fetchNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
...inputProps
|
...inputProps
|
||||||
},
|
}: ComboboxProps<T>,
|
||||||
ref
|
ref: ForwardedRef<HTMLInputElement>
|
||||||
) => {
|
) => {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const comboboxRef = useRef<HTMLInputElement>(null)
|
const comboboxRef = useRef<HTMLInputElement>(null)
|
||||||
@@ -52,27 +65,59 @@ export const Combobox = forwardRef<HTMLInputElement, ComboboxProps>(
|
|||||||
|
|
||||||
useImperativeHandle(ref, () => comboboxRef.current!)
|
useImperativeHandle(ref, () => comboboxRef.current!)
|
||||||
|
|
||||||
const isControlled = controlledValue !== undefined
|
const isValueControlled = controlledValue !== undefined
|
||||||
const [searchValue, setSearchValue] = useState("")
|
const isSearchControlled = controlledSearchValue !== undefined
|
||||||
const [uncontrolledValue, setUncontrolledValue] = useState<string[]>([])
|
|
||||||
|
|
||||||
const selectedValues = isControlled ? controlledValue : uncontrolledValue
|
const isArrayValue = Array.isArray(controlledValue)
|
||||||
|
const emptyState = (isArrayValue ? [] : "") as T
|
||||||
|
|
||||||
const handleValueChange = (newValues?: string[]) => {
|
const [uncontrolledSearchValue, setUncontrolledSearchValue] = useState(
|
||||||
if (!isControlled) {
|
controlledSearchValue || ""
|
||||||
setUncontrolledValue(newValues || [])
|
)
|
||||||
|
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 || "")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValueControlled) {
|
||||||
|
setUncontrolledValue(newValues || emptyState)
|
||||||
}
|
}
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
onChange(newValues)
|
onChange(newValues)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setUncontrolledSearchValue("")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearchChange = (query: string) => {
|
||||||
|
setUncontrolledSearchValue(query)
|
||||||
|
|
||||||
|
if (onSearchValueChange) {
|
||||||
|
onSearchValueChange(query)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter and sort the options based on the search value,
|
* Filter and sort the options based on the search value,
|
||||||
* and whether the value is already selected.
|
* and whether the value is already selected.
|
||||||
|
*
|
||||||
|
* This is only used when the search value is uncontrolled.
|
||||||
*/
|
*/
|
||||||
const matches = useMemo(() => {
|
const matches = useMemo(() => {
|
||||||
return matchSorter(options, searchValue, {
|
if (isSearchControlled) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchSorter(options, uncontrolledSearchValue, {
|
||||||
keys: ["label"],
|
keys: ["label"],
|
||||||
baseSort: (a, b) => {
|
baseSort: (a, b) => {
|
||||||
const aIndex = selectedValues.indexOf(a.item.value)
|
const aIndex = selectedValues.indexOf(a.item.value)
|
||||||
@@ -93,21 +138,57 @@ export const Combobox = forwardRef<HTMLInputElement, ComboboxProps>(
|
|||||||
return aIndex - bIndex
|
return aIndex - bIndex
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}, [options, searchValue, selectedValues])
|
}, [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 hasValues = selectedValues.length > 0
|
|
||||||
const showSelected = hasValues && !searchValue && !open
|
|
||||||
const hidePlaceholder = showSelected || open
|
const hidePlaceholder = showSelected || open
|
||||||
|
|
||||||
|
const results = isSearchControlled ? options : matches
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||||
<PrimitiveComboboxProvider
|
<PrimitiveComboboxProvider
|
||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
selectedValue={selectedValues}
|
selectedValue={selectedValues}
|
||||||
setSelectedValue={handleValueChange}
|
setSelectedValue={(value) => handleValueChange(value as T)}
|
||||||
setValue={(value) => {
|
value={uncontrolledSearchValue}
|
||||||
setSearchValue(value)
|
setValue={(query) => {
|
||||||
|
startTransition(() => handleSearchChange(query))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Popover.Anchor asChild>
|
<Popover.Anchor asChild>
|
||||||
@@ -121,12 +202,12 @@ export const Combobox = forwardRef<HTMLInputElement, ComboboxProps>(
|
|||||||
"has-[:invalid]:shadow-borders-error",
|
"has-[:invalid]:shadow-borders-error",
|
||||||
"has-[:disabled]:bg-ui-bg-disabled has-[:disabled]:text-ui-fg-disabled has-[:disabled]:cursor-not-allowed",
|
"has-[:disabled]:bg-ui-bg-disabled has-[:disabled]:text-ui-fg-disabled has-[:disabled]:cursor-not-allowed",
|
||||||
{
|
{
|
||||||
"pl-0.5": hasValues,
|
"pl-0.5": hasValue && isArrayValue,
|
||||||
},
|
},
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{hasValues && (
|
{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">
|
<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>
|
<span>{selectedValues.length}</span>
|
||||||
<button
|
<button
|
||||||
@@ -147,9 +228,21 @@ export const Combobox = forwardRef<HTMLInputElement, ComboboxProps>(
|
|||||||
{t("general.selected")}
|
{t("general.selected")}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
<PrimitiveCombobox
|
<PrimitiveCombobox
|
||||||
ref={comboboxRef}
|
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"
|
className={clx(
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
)}
|
||||||
placeholder={hidePlaceholder ? undefined : placeholder}
|
placeholder={hidePlaceholder ? undefined : placeholder}
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
/>
|
/>
|
||||||
@@ -179,6 +272,7 @@ export const Combobox = forwardRef<HTMLInputElement, ComboboxProps>(
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
aria-busy={isPending}
|
||||||
>
|
>
|
||||||
<PrimitiveComboboxList
|
<PrimitiveComboboxList
|
||||||
ref={listboxRef}
|
ref={listboxRef}
|
||||||
@@ -191,22 +285,29 @@ export const Combobox = forwardRef<HTMLInputElement, ComboboxProps>(
|
|||||||
"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"
|
"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 }) => (
|
{results.map(({ value, label }) => (
|
||||||
<PrimitiveComboboxItem
|
<PrimitiveComboboxItem
|
||||||
key={value}
|
key={value}
|
||||||
value={value}
|
value={value}
|
||||||
focusOnHover
|
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"
|
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">
|
<PrimitiveComboboxItemCheck className="flex !size-5 items-center justify-center">
|
||||||
<EllipseMiniSolid />
|
<EllipseMiniSolid />
|
||||||
</PrimitiveComboboxItemCheck>
|
</PrimitiveComboboxItemCheck>
|
||||||
<PrimitiveComboboxItemValue className="txt-compact-small group-aria-selected:txt-compact-small-plus">
|
<PrimitiveComboboxItemValue className="txt-compact-small">
|
||||||
{label}
|
{label}
|
||||||
</PrimitiveComboboxItemValue>
|
</PrimitiveComboboxItemValue>
|
||||||
</PrimitiveComboboxItem>
|
</PrimitiveComboboxItem>
|
||||||
))}
|
))}
|
||||||
{!matches.length && (
|
{!!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">
|
<div className="flex items-center gap-x-2 rounded-[4px] px-2 py-1.5">
|
||||||
<Text
|
<Text
|
||||||
size="small"
|
size="small"
|
||||||
@@ -223,6 +324,6 @@ export const Combobox = forwardRef<HTMLInputElement, ComboboxProps>(
|
|||||||
</PrimitiveComboboxProvider>
|
</PrimitiveComboboxProvider>
|
||||||
</Popover.Root>
|
</Popover.Root>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
|
||||||
Combobox.displayName = "Combobox"
|
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"
|
||||||
-46
@@ -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"
|
|
||||||
+7
@@ -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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clx(
|
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
|
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"
|
||||||
+261
@@ -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>
|
||||||
|
}
|
||||||
+2
-20
@@ -1,5 +1,6 @@
|
|||||||
import type { FulfillmentStatus } from "@medusajs/medusa"
|
import type { FulfillmentStatus } from "@medusajs/medusa"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { getOrderFulfillmentStatus } from "../../../../../lib/order-helpers"
|
||||||
import { StatusCell } from "../../common/status-cell"
|
import { StatusCell } from "../../common/status-cell"
|
||||||
|
|
||||||
type FulfillmentStatusCellProps = {
|
type FulfillmentStatusCellProps = {
|
||||||
@@ -11,26 +12,7 @@ export const FulfillmentStatusCell = ({
|
|||||||
}: FulfillmentStatusCellProps) => {
|
}: FulfillmentStatusCellProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const [label, color] = {
|
const { label, color } = getOrderFulfillmentStatus(t, status)
|
||||||
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"]
|
|
||||||
|
|
||||||
return <StatusCell color={color}>{label}</StatusCell>
|
return <StatusCell color={color}>{label}</StatusCell>
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-12
@@ -1,5 +1,6 @@
|
|||||||
import type { PaymentStatus } from "@medusajs/medusa"
|
import type { PaymentStatus } from "@medusajs/medusa"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { getOrderPaymentStatus } from "../../../../../lib/order-helpers"
|
||||||
import { StatusCell } from "../../common/status-cell"
|
import { StatusCell } from "../../common/status-cell"
|
||||||
|
|
||||||
type PaymentStatusCellProps = {
|
type PaymentStatusCellProps = {
|
||||||
@@ -9,18 +10,7 @@ type PaymentStatusCellProps = {
|
|||||||
export const PaymentStatusCell = ({ status }: PaymentStatusCellProps) => {
|
export const PaymentStatusCell = ({ status }: PaymentStatusCellProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const [label, color] = {
|
const { label, color } = getOrderPaymentStatus(t, status)
|
||||||
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"]
|
|
||||||
|
|
||||||
return <StatusCell color={color}>{label}</StatusCell>
|
return <StatusCell color={color}>{label}</StatusCell>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { FulfillmentStatus, PaymentStatus } from "@medusajs/medusa"
|
||||||
|
import { TFunction } from "i18next"
|
||||||
|
|
||||||
|
export const getOrderPaymentStatus = (
|
||||||
|
t: TFunction<"translation">,
|
||||||
|
status: PaymentStatus
|
||||||
|
) => {
|
||||||
|
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"]
|
||||||
|
|
||||||
|
return { label, color }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getOrderFulfillmentStatus = (
|
||||||
|
t: TFunction<"translation">,
|
||||||
|
status: FulfillmentStatus
|
||||||
|
) => {
|
||||||
|
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"]
|
||||||
|
|
||||||
|
return { label, color }
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import i18n from "i18next"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export const AddressSchema = z.object({
|
||||||
|
first_name: z.string().min(1),
|
||||||
|
last_name: z.string().min(1),
|
||||||
|
company: z.string().optional(),
|
||||||
|
address_1: z.string().min(1),
|
||||||
|
address_2: z.string().optional(),
|
||||||
|
city: z.string().min(1),
|
||||||
|
postal_code: z.string().min(1),
|
||||||
|
province: z.string().optional(),
|
||||||
|
country_code: z.string().min(1),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const EmailSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const TransferOwnershipSchema = z
|
||||||
|
.object({
|
||||||
|
current_owner_id: z.string().min(1),
|
||||||
|
new_owner_id: z
|
||||||
|
.string()
|
||||||
|
.min(1, i18n.t("transferOwnership.validation.required")),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.current_owner_id === data.new_owner_id) {
|
||||||
|
return ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["new_owner_id"],
|
||||||
|
message: i18n.t("transferOwnership.validation.mustBeDifferent"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
AdminCollectionsRes,
|
AdminCollectionsRes,
|
||||||
AdminCustomerGroupsRes,
|
AdminCustomerGroupsRes,
|
||||||
AdminCustomersRes,
|
AdminCustomersRes,
|
||||||
|
AdminDraftOrdersRes,
|
||||||
AdminGiftCardsRes,
|
AdminGiftCardsRes,
|
||||||
AdminOrdersRes,
|
AdminOrdersRes,
|
||||||
AdminProductsRes,
|
AdminProductsRes,
|
||||||
@@ -98,6 +99,27 @@ export const v1Routes: RouteObject[] = [
|
|||||||
crumb: (data: AdminOrdersRes) =>
|
crumb: (data: AdminOrdersRes) =>
|
||||||
`Order #${data.order.display_id}`,
|
`Order #${data.order.display_id}`,
|
||||||
},
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "shipping-address",
|
||||||
|
lazy: () =>
|
||||||
|
import("../../routes/orders/order-shipping-address"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "billing-address",
|
||||||
|
lazy: () =>
|
||||||
|
import("../../routes/orders/order-billing-address"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "email",
|
||||||
|
lazy: () => import("../../routes/orders/order-email"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "transfer-ownership",
|
||||||
|
lazy: () =>
|
||||||
|
import("../../routes/orders/order-transfer-ownership"),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -108,7 +130,7 @@ export const v1Routes: RouteObject[] = [
|
|||||||
},
|
},
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
index: true,
|
path: "",
|
||||||
lazy: () =>
|
lazy: () =>
|
||||||
import("../../routes/draft-orders/draft-order-list"),
|
import("../../routes/draft-orders/draft-order-list"),
|
||||||
},
|
},
|
||||||
@@ -116,6 +138,38 @@ export const v1Routes: RouteObject[] = [
|
|||||||
path: ":id",
|
path: ":id",
|
||||||
lazy: () =>
|
lazy: () =>
|
||||||
import("../../routes/draft-orders/draft-order-detail"),
|
import("../../routes/draft-orders/draft-order-detail"),
|
||||||
|
handle: {
|
||||||
|
crumb: (data: AdminDraftOrdersRes) =>
|
||||||
|
`Draft #${data.draft_order.display_id}`,
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "transfer-ownership",
|
||||||
|
lazy: () =>
|
||||||
|
import(
|
||||||
|
"../../routes/draft-orders/draft-order-transfer-ownership"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "shipping-address",
|
||||||
|
lazy: () =>
|
||||||
|
import(
|
||||||
|
"../../routes/draft-orders/draft-order-shipping-address"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "billing-address",
|
||||||
|
lazy: () =>
|
||||||
|
import(
|
||||||
|
"../../routes/draft-orders/draft-order-billing-address"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "email",
|
||||||
|
lazy: () =>
|
||||||
|
import("../../routes/draft-orders/draft-order-email"),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
+43
-4
@@ -1,7 +1,11 @@
|
|||||||
import { Customer } from "@medusajs/medusa"
|
import { ArrowPath } from "@medusajs/icons"
|
||||||
|
import { Customer, Order } from "@medusajs/medusa"
|
||||||
import { Button, Container, Heading } from "@medusajs/ui"
|
import { Button, Container, Heading } from "@medusajs/ui"
|
||||||
|
import { createColumnHelper } from "@tanstack/react-table"
|
||||||
import { useAdminOrders } from "medusa-react"
|
import { useAdminOrders } from "medusa-react"
|
||||||
|
import { useMemo } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
import { DataTable } from "../../../../../components/table/data-table"
|
import { DataTable } from "../../../../../components/table/data-table"
|
||||||
import { useOrderTableColumns } from "../../../../../hooks/table/columns/use-order-table-columns"
|
import { useOrderTableColumns } from "../../../../../hooks/table/columns/use-order-table-columns"
|
||||||
import { useOrderTableFilters } from "../../../../../hooks/table/filters/use-order-table-filters"
|
import { useOrderTableFilters } from "../../../../../hooks/table/filters/use-order-table-filters"
|
||||||
@@ -37,9 +41,7 @@ export const CustomerOrderSection = ({
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const columns = useOrderTableColumns({
|
const columns = useColumns()
|
||||||
exclude: ["customer"],
|
|
||||||
})
|
|
||||||
const filters = useOrderTableFilters()
|
const filters = useOrderTableFilters()
|
||||||
|
|
||||||
const { table } = useDataTable({
|
const { table } = useDataTable({
|
||||||
@@ -80,3 +82,40 @@ export const CustomerOrderSection = ({
|
|||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CustomerOrderActions = ({ order }: { order: Order }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionMenu
|
||||||
|
groups={[
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: t("transferOwnership.label"),
|
||||||
|
to: `${order.id}/transfer-ownership`,
|
||||||
|
icon: <ArrowPath />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<Order>()
|
||||||
|
|
||||||
|
const useColumns = () => {
|
||||||
|
const base = useOrderTableColumns({ exclude: ["customer"] })
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
...base,
|
||||||
|
columnHelper.display({
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => <CustomerOrderActions order={row.original} />,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[base]
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./transfer-customer-order-ownership-form"
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Order } from "@medusajs/medusa"
|
||||||
|
import { Button } from "@medusajs/ui"
|
||||||
|
import { useAdminUpdateOrder } from "medusa-react"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { TransferOwnerShipForm } from "../../../../../components/forms/transfer-ownership-form"
|
||||||
|
import {
|
||||||
|
RouteDrawer,
|
||||||
|
useRouteModal,
|
||||||
|
} from "../../../../../components/route-modal"
|
||||||
|
import { TransferOwnershipSchema } from "../../../../../lib/schemas"
|
||||||
|
|
||||||
|
type TransferCustomerOrderOwnershipFormProps = {
|
||||||
|
order: Order
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TransferCustomerOrderOwnershipForm = ({
|
||||||
|
order,
|
||||||
|
}: TransferCustomerOrderOwnershipFormProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { handleSuccess } = useRouteModal()
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof TransferOwnershipSchema>>({
|
||||||
|
defaultValues: {
|
||||||
|
current_owner_id: order.customer_id,
|
||||||
|
new_owner_id: "",
|
||||||
|
},
|
||||||
|
resolver: zodResolver(TransferOwnershipSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { mutateAsync, isLoading } = useAdminUpdateOrder(order.id)
|
||||||
|
|
||||||
|
const handleSubmit = form.handleSubmit(async (values) => {
|
||||||
|
mutateAsync(
|
||||||
|
{
|
||||||
|
customer_id: values.new_owner_id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
handleSuccess()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteDrawer.Form form={form}>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex size-full flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
<RouteDrawer.Body className="size-full flex-1 overflow-auto">
|
||||||
|
<TransferOwnerShipForm order={order} control={form.control} />
|
||||||
|
</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 type="submit" isLoading={isLoading} size="small">
|
||||||
|
{t("actions.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</RouteDrawer.Footer>
|
||||||
|
</form>
|
||||||
|
</RouteDrawer.Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
import { Heading } from "@medusajs/ui"
|
||||||
|
import { useAdminOrder } from "medusa-react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { useParams } from "react-router-dom"
|
||||||
|
import { RouteDrawer } from "../../../components/route-modal"
|
||||||
|
import { TransferCustomerOrderOwnershipForm } from "./components/transfer-customer-order-ownership-form"
|
||||||
|
|
||||||
|
export const CustomerTransferOwnership = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { order_id } = useParams()
|
||||||
|
|
||||||
|
const { order, isLoading, isError, error } = useAdminOrder(order_id!)
|
||||||
|
|
||||||
|
const ready = !isLoading && order
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteDrawer>
|
||||||
|
<RouteDrawer.Header>
|
||||||
|
<Heading>{t("transferOwnership.header")}</Heading>
|
||||||
|
</RouteDrawer.Header>
|
||||||
|
{ready && <TransferCustomerOrderOwnershipForm order={order} />}
|
||||||
|
</RouteDrawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { CustomerTransferOwnership as Component } from "./customer-transfer-ownership"
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Country, DraftOrder } from "@medusajs/medusa"
|
||||||
|
import { Button } from "@medusajs/ui"
|
||||||
|
import { useAdminUpdateDraftOrder } from "medusa-react"
|
||||||
|
import { DefaultValues, useForm } from "react-hook-form"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { AddressForm } from "../../../../components/forms/address-form"
|
||||||
|
import { RouteDrawer, useRouteModal } from "../../../../components/route-modal"
|
||||||
|
import { AddressSchema } from "../../../../lib/schemas"
|
||||||
|
|
||||||
|
type AddressType = "shipping" | "billing"
|
||||||
|
|
||||||
|
type EditDraftOrderAddressFormProps = {
|
||||||
|
draftOrder: DraftOrder
|
||||||
|
countries: Country[]
|
||||||
|
type: AddressType
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditDraftOrderAddressForm = ({
|
||||||
|
draftOrder,
|
||||||
|
countries,
|
||||||
|
type,
|
||||||
|
}: EditDraftOrderAddressFormProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { handleSuccess } = useRouteModal()
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof AddressSchema>>({
|
||||||
|
defaultValues: getDefaultValues(draftOrder, type),
|
||||||
|
resolver: zodResolver(AddressSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { mutateAsync, isLoading } = useAdminUpdateDraftOrder(draftOrder.id)
|
||||||
|
|
||||||
|
const handleSumbit = form.handleSubmit(async (values) => {
|
||||||
|
const update = {
|
||||||
|
[type === "shipping" ? "shipping_address" : "billing_address"]: values,
|
||||||
|
}
|
||||||
|
|
||||||
|
mutateAsync(update, {
|
||||||
|
onSuccess: () => {
|
||||||
|
handleSuccess()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteDrawer.Form form={form}>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSumbit}
|
||||||
|
className="flex size-full flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
<RouteDrawer.Body className="size-full flex-1 overflow-auto">
|
||||||
|
<AddressForm
|
||||||
|
control={form.control}
|
||||||
|
countries={countries}
|
||||||
|
layout="stack"
|
||||||
|
/>
|
||||||
|
</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 type="submit" isLoading={isLoading} size="small">
|
||||||
|
{t("actions.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</RouteDrawer.Footer>
|
||||||
|
</form>
|
||||||
|
</RouteDrawer.Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDefaultValues = (
|
||||||
|
draftOrder: DraftOrder,
|
||||||
|
type: AddressType
|
||||||
|
): DefaultValues<z.infer<typeof AddressSchema>> => {
|
||||||
|
const address =
|
||||||
|
type === "shipping"
|
||||||
|
? draftOrder.cart.shipping_address
|
||||||
|
: draftOrder.cart.billing_address
|
||||||
|
|
||||||
|
return {
|
||||||
|
first_name: address?.first_name ?? "",
|
||||||
|
last_name: address?.last_name ?? "",
|
||||||
|
address_1: address?.address_1 ?? "",
|
||||||
|
address_2: address?.address_2 ?? "",
|
||||||
|
city: address?.city ?? "",
|
||||||
|
postal_code: address?.postal_code ?? "",
|
||||||
|
province: address?.province ?? "",
|
||||||
|
country_code: address?.country_code ?? "",
|
||||||
|
phone: address?.phone ?? "",
|
||||||
|
company: address?.company ?? "",
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./edit-address-form"
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
import { Heading } from "@medusajs/ui"
|
||||||
|
import { useAdminDraftOrder, useAdminRegion } from "medusa-react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { useParams } from "react-router-dom"
|
||||||
|
import { RouteDrawer } from "../../../components/route-modal"
|
||||||
|
import { EditDraftOrderAddressForm } from "../common/edit-address-form"
|
||||||
|
|
||||||
|
export const DraftOrderBillingAddress = () => {
|
||||||
|
const { id } = useParams()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const { draft_order, isLoading, isError, error } = useAdminDraftOrder(id!)
|
||||||
|
|
||||||
|
const regionId = draft_order?.cart?.region_id
|
||||||
|
|
||||||
|
const {
|
||||||
|
region,
|
||||||
|
isLoading: isLoadingRegion,
|
||||||
|
isError: isErrorRegion,
|
||||||
|
error: errorRegion,
|
||||||
|
} = useAdminRegion(regionId!, {
|
||||||
|
enabled: !!regionId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const ready = !isLoading && draft_order && !isLoadingRegion && region
|
||||||
|
const countries = region?.countries || []
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isErrorRegion) {
|
||||||
|
throw errorRegion
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteDrawer>
|
||||||
|
<RouteDrawer.Header>
|
||||||
|
<Heading>{t("addresses.billingAddress.editHeader")}</Heading>
|
||||||
|
</RouteDrawer.Header>
|
||||||
|
{ready && (
|
||||||
|
<EditDraftOrderAddressForm
|
||||||
|
draftOrder={draft_order}
|
||||||
|
countries={countries}
|
||||||
|
type="billing"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</RouteDrawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { DraftOrderBillingAddress as Component } from "./draft-order-billing-address"
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
import { DraftOrder } from "@medusajs/medusa"
|
||||||
|
import { Container, Heading } from "@medusajs/ui"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import { ArrowPath, CurrencyDollar, Envelope, FlyingBox } from "@medusajs/icons"
|
||||||
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
|
import { CustomerInfo } from "../../../../../components/common/customer-info"
|
||||||
|
|
||||||
|
type DraftOrderCustomerSectionProps = {
|
||||||
|
draftOrder: DraftOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DraftOrderCustomerSection = ({
|
||||||
|
draftOrder,
|
||||||
|
}: DraftOrderCustomerSectionProps) => {
|
||||||
|
return (
|
||||||
|
<Container className="divide-y p-0">
|
||||||
|
<Header />
|
||||||
|
<CustomerInfo.ID data={draftOrder.cart} />
|
||||||
|
<CustomerInfo.Contact data={draftOrder.cart} />
|
||||||
|
<CustomerInfo.Company data={draftOrder.cart} />
|
||||||
|
<CustomerInfo.Addresses data={draftOrder.cart} />
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Header = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<Heading level="h2">{t("fields.customer")}</Heading>
|
||||||
|
<ActionMenu
|
||||||
|
groups={[
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: t("transferOwnership.label"),
|
||||||
|
to: `transfer-ownership`,
|
||||||
|
icon: <ArrowPath />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: t("addresses.shippingAddress.editLabel"),
|
||||||
|
to: "shipping-address",
|
||||||
|
icon: <FlyingBox />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("addresses.billingAddress.editLabel"),
|
||||||
|
to: "billing-address",
|
||||||
|
icon: <CurrencyDollar />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: t("email.editLabel"),
|
||||||
|
to: `email`,
|
||||||
|
icon: <Envelope />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./draft-order-customer-section"
|
||||||
+228
@@ -0,0 +1,228 @@
|
|||||||
|
import { CreditCard, Trash } from "@medusajs/icons"
|
||||||
|
import { DraftOrder, Order } from "@medusajs/medusa"
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Copy,
|
||||||
|
Heading,
|
||||||
|
StatusBadge,
|
||||||
|
Text,
|
||||||
|
usePrompt,
|
||||||
|
} from "@medusajs/ui"
|
||||||
|
import { format } from "date-fns"
|
||||||
|
import {
|
||||||
|
useAdminDeleteDraftOrder,
|
||||||
|
useAdminDraftOrderRegisterPayment,
|
||||||
|
useAdminStore,
|
||||||
|
} from "medusa-react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import { useNavigate } from "react-router-dom"
|
||||||
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
|
import { InlineLink } from "../../../../../components/common/inline-link"
|
||||||
|
import { Skeleton } from "../../../../../components/common/skeleton"
|
||||||
|
|
||||||
|
type DraftOrderGeneralSectionProps = {
|
||||||
|
draftOrder: DraftOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DraftOrderGeneralSection = ({
|
||||||
|
draftOrder,
|
||||||
|
}: DraftOrderGeneralSectionProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const prompt = usePrompt()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const { color, label } = {
|
||||||
|
open: {
|
||||||
|
color: "orange",
|
||||||
|
label: t("draftOrders.status.open"),
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
color: "green",
|
||||||
|
label: t("draftOrders.status.completed"),
|
||||||
|
},
|
||||||
|
}[draftOrder.status] as { color: "green" | "orange"; label: string }
|
||||||
|
|
||||||
|
const { mutateAsync } = useAdminDeleteDraftOrder(draftOrder.id)
|
||||||
|
const { mutateAsync: markAsPaid } = useAdminDraftOrderRegisterPayment(
|
||||||
|
draftOrder.id
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
const res = await prompt({
|
||||||
|
title: t("general.areYouSure"),
|
||||||
|
description: t("orders.cancelWarning", {
|
||||||
|
id: `#${draftOrder.display_id}`,
|
||||||
|
}),
|
||||||
|
confirmText: t("actions.continue"),
|
||||||
|
cancelText: t("actions.cancel"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await mutateAsync(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMarkAsPaid = async () => {
|
||||||
|
const res = await prompt({
|
||||||
|
title: t("draftOrders.markAsPaid.warningTitle"),
|
||||||
|
description: t("draftOrders.markAsPaid.warningDescription"),
|
||||||
|
confirmText: t("actions.continue"),
|
||||||
|
cancelText: t("actions.cancel"),
|
||||||
|
variant: "confirmation",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await markAsPaid(undefined, {
|
||||||
|
onSuccess: ({ order }) => {
|
||||||
|
navigate(`/orders/${order.id}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container className="divide-y p-0">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-x-1">
|
||||||
|
<Heading>#{draftOrder.display_id}</Heading>
|
||||||
|
<Copy
|
||||||
|
content={`#${draftOrder.display_id}`}
|
||||||
|
className="text-ui-fg-muted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Text size="small" className="text-ui-fg-subtle">
|
||||||
|
{format(new Date(draftOrder.created_at), "dd MMM, yyyy, HH:mm:ss")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<StatusBadge color={color}>{label}</StatusBadge>
|
||||||
|
<ActionMenu
|
||||||
|
groups={[
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: t("draftOrders.markAsPaid.label"),
|
||||||
|
onClick: handleMarkAsPaid,
|
||||||
|
icon: <CreditCard />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: t("actions.delete"),
|
||||||
|
onClick: handleDelete,
|
||||||
|
icon: <Trash />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<OrderLink order={draftOrder.order} />
|
||||||
|
{!draftOrder.order && <PaymentLink cartId={draftOrder.cart_id} />}
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const OrderLink = ({ order }: { order: Order | null }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-ui-fg-subtle grid grid-cols-2 items-center gap-x-4 px-6 py-4">
|
||||||
|
<Text size="small" leading="compact" weight="plus">
|
||||||
|
{t("fields.order")}
|
||||||
|
</Text>
|
||||||
|
<InlineLink to={`/orders/${order.id}`}>
|
||||||
|
<Text size="small">{`#${order.display_id}`}</Text>
|
||||||
|
</InlineLink>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPaymentLink = (paymentLink: string, cartId: string) => {
|
||||||
|
// Validate that the payment link template is valid
|
||||||
|
if (!/{.*?}/.test(paymentLink)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return paymentLink.replace(/{.*?}/, cartId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PaymentLink = ({ cartId }: { cartId: string }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { store, isLoading, isError, error } = useAdminStore()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the store has a payment link template, we format the payment link.
|
||||||
|
* Otherwise, we display the cart id.
|
||||||
|
*/
|
||||||
|
const paymentLink = store?.payment_link_template
|
||||||
|
? formatPaymentLink(store.payment_link_template, cartId)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="text-ui-fg-subtle grid grid-cols-2 items-center gap-x-4 px-6 py-4">
|
||||||
|
<Skeleton className="w-[120px]" />
|
||||||
|
<Skeleton className="w-full max-w-[190px]" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paymentLink) {
|
||||||
|
return (
|
||||||
|
<div className="text-ui-fg-subtle grid grid-cols-2 items-center gap-x-4 px-6 py-4">
|
||||||
|
<Text size="small" leading="compact" weight="plus">
|
||||||
|
{t("draftOrders.paymentLinkLabel")}
|
||||||
|
</Text>
|
||||||
|
<div className="flex w-full items-start gap-x-1 overflow-hidden">
|
||||||
|
<InlineLink
|
||||||
|
to={paymentLink}
|
||||||
|
rel="noopener"
|
||||||
|
target="_blank"
|
||||||
|
className="w-fit overflow-hidden"
|
||||||
|
>
|
||||||
|
<Text size="small" className="truncate">
|
||||||
|
{paymentLink}
|
||||||
|
</Text>
|
||||||
|
</InlineLink>
|
||||||
|
<Copy className="text-ui-fg-muted" content={cartId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-ui-fg-subtle grid grid-cols-2 items-start gap-x-4 px-6 py-4">
|
||||||
|
<Text size="small" leading="compact" weight="plus">
|
||||||
|
{t("draftOrders.cartIdLabel")}
|
||||||
|
</Text>
|
||||||
|
<div className="flex w-full items-start gap-x-1 overflow-hidden">
|
||||||
|
<Text size="small" className="truncate">
|
||||||
|
{cartId}
|
||||||
|
</Text>
|
||||||
|
<Copy className="text-ui-fg-muted" content={cartId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderContent()
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./draft-order-general-section"
|
||||||
+269
@@ -0,0 +1,269 @@
|
|||||||
|
import { Buildings } from "@medusajs/icons"
|
||||||
|
import { Cart, DraftOrder, LineItem } from "@medusajs/medusa"
|
||||||
|
import { ReservationItemDTO } from "@medusajs/types"
|
||||||
|
import { Container, Copy, Heading, StatusBadge, Text } from "@medusajs/ui"
|
||||||
|
import { useAdminReservations } from "medusa-react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
|
import { Thumbnail } from "../../../../../components/common/thumbnail"
|
||||||
|
import {
|
||||||
|
getLocaleAmount,
|
||||||
|
getStylizedAmount,
|
||||||
|
} from "../../../../../lib/money-amount-helpers"
|
||||||
|
|
||||||
|
type DraftOrderSummarySectionProps = {
|
||||||
|
draftOrder: DraftOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DraftOrderSummarySection = ({
|
||||||
|
draftOrder,
|
||||||
|
}: DraftOrderSummarySectionProps) => {
|
||||||
|
return (
|
||||||
|
<Container className="divide-y divide-dashed p-0">
|
||||||
|
<Header />
|
||||||
|
<ItemBreakdown draftOrder={draftOrder} />
|
||||||
|
<CostBreakdown draftOrder={draftOrder} />
|
||||||
|
<Total draftOrder={draftOrder} />
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Header = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<Heading level="h2">{t("fields.summary")}</Heading>
|
||||||
|
<ActionMenu
|
||||||
|
groups={[
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: t("orders.summary.allocateItems"),
|
||||||
|
to: "#", // TODO: Open modal to allocate items
|
||||||
|
icon: <Buildings />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Item = ({
|
||||||
|
item,
|
||||||
|
currencyCode,
|
||||||
|
reservation,
|
||||||
|
}: {
|
||||||
|
item: LineItem
|
||||||
|
currencyCode: string
|
||||||
|
reservation?: ReservationItemDTO | null
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="text-ui-fg-subtle grid grid-cols-2 items-start gap-x-4 px-6 py-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-x-4">
|
||||||
|
<Thumbnail src={item.thumbnail} />
|
||||||
|
<div>
|
||||||
|
<Text
|
||||||
|
size="small"
|
||||||
|
leading="compact"
|
||||||
|
weight="plus"
|
||||||
|
className="text-ui-fg-base"
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
{item.variant.sku && (
|
||||||
|
<div className="flex items-center gap-x-1">
|
||||||
|
<Text size="small">{item.variant.sku}</Text>
|
||||||
|
<Copy content={item.variant.sku} className="text-ui-fg-muted" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Text size="small">{item.variant.title}</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 items-center gap-x-4">
|
||||||
|
<div className="flex items-center justify-end gap-x-4">
|
||||||
|
<Text size="small">
|
||||||
|
{getLocaleAmount(item.unit_price, currencyCode)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<div className="w-fit min-w-[27px]">
|
||||||
|
<Text>
|
||||||
|
<span className="tabular-nums">{item.quantity}</span>x
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-visible">
|
||||||
|
<StatusBadge
|
||||||
|
color={reservation ? "green" : "orange"}
|
||||||
|
className="text-nowrap"
|
||||||
|
>
|
||||||
|
{reservation
|
||||||
|
? t("orders.reservations.allocatedLabel")
|
||||||
|
: t("orders.reservations.notAllocatedLabel")}
|
||||||
|
</StatusBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Text size="small">
|
||||||
|
{getLocaleAmount(item.subtotal || 0, currencyCode)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ItemBreakdown = ({ draftOrder }: { draftOrder: DraftOrder }) => {
|
||||||
|
const { reservations, isError, error } = useAdminReservations({
|
||||||
|
line_item_id: draftOrder.cart.items.map((i) => i.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{draftOrder.cart.items.map((item) => {
|
||||||
|
const reservation = reservations
|
||||||
|
? reservations.find((r) => r.line_item_id === item.id)
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Item
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
currencyCode={draftOrder.cart.region.currency_code}
|
||||||
|
reservation={reservation}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Cost = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
secondaryValue,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
secondaryValue: string
|
||||||
|
}) => (
|
||||||
|
<div className="grid grid-cols-3 items-center">
|
||||||
|
<Text size="small" leading="compact">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<div className="text-right">
|
||||||
|
<Text size="small" leading="compact">
|
||||||
|
{secondaryValue}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<Text size="small" leading="compact">
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const calculateCartTaxRate = (cart: Cart) => {
|
||||||
|
const {
|
||||||
|
tax_total,
|
||||||
|
subtotal = 0,
|
||||||
|
discount_total = 0,
|
||||||
|
gift_card_total = 0,
|
||||||
|
shipping_total = 0,
|
||||||
|
} = cart
|
||||||
|
|
||||||
|
const preTaxTotal =
|
||||||
|
subtotal - discount_total - gift_card_total + shipping_total
|
||||||
|
|
||||||
|
return ((tax_total || 0) / preTaxTotal) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
const CostBreakdown = ({ draftOrder }: { draftOrder: DraftOrder }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
// Calculate tax rate since it's not included in the cart
|
||||||
|
const taxRate = calculateCartTaxRate(draftOrder.cart)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-ui-fg-subtle flex flex-col gap-y-2 px-6 py-4">
|
||||||
|
<Cost
|
||||||
|
label={t("fields.subtotal")}
|
||||||
|
secondaryValue={t("general.items", {
|
||||||
|
count: draftOrder.cart.items.length,
|
||||||
|
})}
|
||||||
|
value={getLocaleAmount(
|
||||||
|
draftOrder.cart.subtotal || 0,
|
||||||
|
draftOrder.cart.region.currency_code
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Cost
|
||||||
|
label={t("fields.discount")}
|
||||||
|
secondaryValue={
|
||||||
|
draftOrder.cart.discounts.length > 0
|
||||||
|
? draftOrder.cart.discounts.map((d) => d.code).join(", ")
|
||||||
|
: "-"
|
||||||
|
}
|
||||||
|
value={
|
||||||
|
(draftOrder.cart.discount_total || 0) > 0
|
||||||
|
? `- ${getLocaleAmount(
|
||||||
|
draftOrder.cart.discount_total || 0,
|
||||||
|
draftOrder.cart.region.currency_code
|
||||||
|
)}`
|
||||||
|
: "-"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Cost
|
||||||
|
label={t("fields.shipping")}
|
||||||
|
secondaryValue={draftOrder.cart.shipping_methods
|
||||||
|
.map((sm) => sm.shipping_option.name)
|
||||||
|
.join(", ")}
|
||||||
|
value={getLocaleAmount(
|
||||||
|
draftOrder.cart.shipping_total || 0,
|
||||||
|
draftOrder.cart.region.currency_code
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Cost
|
||||||
|
label={t("fields.tax")}
|
||||||
|
secondaryValue={`${taxRate || 0}%`}
|
||||||
|
value={
|
||||||
|
draftOrder.cart.tax_total
|
||||||
|
? getLocaleAmount(
|
||||||
|
draftOrder.cart.tax_total || 0,
|
||||||
|
draftOrder.cart.region.currency_code
|
||||||
|
)
|
||||||
|
: "-"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Total = ({ draftOrder }: { draftOrder: DraftOrder }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-ui-fg-base flex items-center justify-between px-6 py-4">
|
||||||
|
<Text size="small" leading="compact" weight="plus">
|
||||||
|
{t("fields.total")}
|
||||||
|
</Text>
|
||||||
|
<Text size="small" leading="compact" weight="plus">
|
||||||
|
{getStylizedAmount(
|
||||||
|
draftOrder.cart.total || 0,
|
||||||
|
draftOrder.cart.region.currency_code
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./draft-order-summary-section"
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export const DraftOrderDetails = () => {
|
|
||||||
return <div>Draft Order Details</div>
|
|
||||||
}
|
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
import { useAdminDraftOrder } from "medusa-react"
|
||||||
|
import { Outlet, useLoaderData, useParams } from "react-router-dom"
|
||||||
|
import { JsonViewSection } from "../../../components/common/json-view-section"
|
||||||
|
import { DraftOrderCustomerSection } from "./components/draft-order-customer-section"
|
||||||
|
import { DraftOrderGeneralSection } from "./components/draft-order-general-section"
|
||||||
|
import { DraftOrderSummarySection } from "./components/draft-order-summary-section"
|
||||||
|
import { draftOrderLoader } from "./loader"
|
||||||
|
|
||||||
|
export const DraftOrderDetail = () => {
|
||||||
|
const { id } = useParams()
|
||||||
|
const initialData = useLoaderData() as Awaited<
|
||||||
|
ReturnType<typeof draftOrderLoader>
|
||||||
|
>
|
||||||
|
|
||||||
|
const { draft_order, isLoading, isError, error } = useAdminDraftOrder(id!, {
|
||||||
|
initialData,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading || !draft_order) {
|
||||||
|
return <div>Loading...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-x-4 xl:grid-cols-[1fr,400px]">
|
||||||
|
<div className="flex flex-col gap-y-2">
|
||||||
|
<DraftOrderGeneralSection draftOrder={draft_order} />
|
||||||
|
<DraftOrderSummarySection draftOrder={draft_order} />
|
||||||
|
<div className="flex flex-col gap-y-2 xl:hidden">
|
||||||
|
<DraftOrderCustomerSection draftOrder={draft_order} />
|
||||||
|
</div>
|
||||||
|
<JsonViewSection data={draft_order} />
|
||||||
|
</div>
|
||||||
|
<div className="hidden flex-col gap-y-2 xl:flex">
|
||||||
|
<DraftOrderCustomerSection draftOrder={draft_order} />
|
||||||
|
</div>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
export { DraftOrderDetails as Component } from "./details"
|
export { DraftOrderDetail as Component } from "./draft-order-detail"
|
||||||
|
export { draftOrderLoader as loader } from "./loader"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { AdminDraftOrdersRes } from "@medusajs/medusa"
|
||||||
|
import { Response } from "@medusajs/medusa-js"
|
||||||
|
import { adminDraftOrderKeys } from "medusa-react"
|
||||||
|
import { LoaderFunctionArgs } from "react-router-dom"
|
||||||
|
|
||||||
|
import { medusa, queryClient } from "../../../lib/medusa"
|
||||||
|
|
||||||
|
const draftOrderDetailQuery = (id: string) => ({
|
||||||
|
queryKey: adminDraftOrderKeys.detail(id),
|
||||||
|
queryFn: async () => medusa.admin.draftOrders.retrieve(id),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const draftOrderLoader = async ({ params }: LoaderFunctionArgs) => {
|
||||||
|
const id = params.id
|
||||||
|
const query = draftOrderDetailQuery(id!)
|
||||||
|
|
||||||
|
return (
|
||||||
|
queryClient.getQueryData<Response<AdminDraftOrdersRes>>(query.queryKey) ??
|
||||||
|
(await queryClient.fetchQuery(query))
|
||||||
|
)
|
||||||
|
}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { DraftOrder } from "@medusajs/medusa"
|
||||||
|
import { Button } from "@medusajs/ui"
|
||||||
|
import { useAdminUpdateDraftOrder } from "medusa-react"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { EmailForm } from "../../../../../components/forms/email-form"
|
||||||
|
import {
|
||||||
|
RouteDrawer,
|
||||||
|
useRouteModal,
|
||||||
|
} from "../../../../../components/route-modal"
|
||||||
|
import { EmailSchema } from "../../../../../lib/schemas"
|
||||||
|
|
||||||
|
type EditDraftOrderEmailFormProps = {
|
||||||
|
draftOrder: DraftOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditDraftOrderEmailForm = ({
|
||||||
|
draftOrder,
|
||||||
|
}: EditDraftOrderEmailFormProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { handleSuccess } = useRouteModal()
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof EmailSchema>>({
|
||||||
|
defaultValues: {
|
||||||
|
email: draftOrder.cart.email,
|
||||||
|
},
|
||||||
|
resolver: zodResolver(EmailSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { mutateAsync, isLoading } = useAdminUpdateDraftOrder(draftOrder.id)
|
||||||
|
|
||||||
|
const handleSumbit = form.handleSubmit(async (values) => {
|
||||||
|
mutateAsync(values, {
|
||||||
|
onSuccess: () => {
|
||||||
|
handleSuccess()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteDrawer.Form form={form}>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSumbit}
|
||||||
|
className="flex size-full flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
<RouteDrawer.Body className="size-full flex-1 overflow-auto">
|
||||||
|
<EmailForm control={form.control} layout="stack" />
|
||||||
|
</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 type="submit" isLoading={isLoading} size="small">
|
||||||
|
{t("actions.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</RouteDrawer.Footer>
|
||||||
|
</form>
|
||||||
|
</RouteDrawer.Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./edit-draft-order-email-form"
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
import { Heading } from "@medusajs/ui"
|
||||||
|
import { useAdminDraftOrder } from "medusa-react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { useParams } from "react-router-dom"
|
||||||
|
import { RouteDrawer } from "../../../components/route-modal"
|
||||||
|
import { EditDraftOrderEmailForm } from "./components/edit-draft-order-email-form/edit-draft-order-email-form"
|
||||||
|
|
||||||
|
export const DraftOrderEmail = () => {
|
||||||
|
const { id } = useParams()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const { draft_order, isLoading, isError, error } = useAdminDraftOrder(id!)
|
||||||
|
|
||||||
|
const ready = !isLoading && draft_order
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteDrawer>
|
||||||
|
<RouteDrawer.Header>
|
||||||
|
<Heading>{t("email.editHeader")}</Heading>
|
||||||
|
</RouteDrawer.Header>
|
||||||
|
{ready && <EditDraftOrderEmailForm draftOrder={draft_order} />}
|
||||||
|
</RouteDrawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { DraftOrderEmail as Component } from "./draft-order-email"
|
||||||
+1
-1
@@ -20,7 +20,7 @@ export const DraftOrderListTable = () => {
|
|||||||
useAdminDraftOrders(
|
useAdminDraftOrders(
|
||||||
{
|
{
|
||||||
...searchParams,
|
...searchParams,
|
||||||
expand: "cart,cart.customer",
|
// expand: "cart,cart.customer",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
|
|||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
import { Heading } from "@medusajs/ui"
|
||||||
|
import { useAdminDraftOrder, useAdminRegion } from "medusa-react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { useParams } from "react-router-dom"
|
||||||
|
import { RouteDrawer } from "../../../components/route-modal"
|
||||||
|
import { EditDraftOrderAddressForm } from "../common/edit-address-form"
|
||||||
|
|
||||||
|
export const DraftOrderShippingAddress = () => {
|
||||||
|
const { id } = useParams()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const { draft_order, isLoading, isError, error } = useAdminDraftOrder(id!)
|
||||||
|
|
||||||
|
const regionId = draft_order?.cart?.region_id
|
||||||
|
|
||||||
|
const {
|
||||||
|
region,
|
||||||
|
isLoading: isLoadingRegion,
|
||||||
|
isError: isErrorRegion,
|
||||||
|
error: errorRegion,
|
||||||
|
} = useAdminRegion(regionId!, {
|
||||||
|
enabled: !!regionId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const ready = !isLoading && draft_order && !isLoadingRegion && region
|
||||||
|
const countries = region?.countries || []
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isErrorRegion) {
|
||||||
|
throw errorRegion
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteDrawer>
|
||||||
|
<RouteDrawer.Header>
|
||||||
|
<Heading>{t("addresses.shippingAddress.editHeader")}</Heading>
|
||||||
|
</RouteDrawer.Header>
|
||||||
|
{ready && (
|
||||||
|
<EditDraftOrderAddressForm
|
||||||
|
draftOrder={draft_order}
|
||||||
|
countries={countries}
|
||||||
|
type="shipping"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</RouteDrawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { DraftOrderShippingAddress as Component } from "./draft-order-shipping-address"
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./transfer-draft-order-ownership-form"
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { DraftOrder } from "@medusajs/medusa"
|
||||||
|
import { Button } from "@medusajs/ui"
|
||||||
|
import { useAdminUpdateDraftOrder } from "medusa-react"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { TransferOwnerShipForm } from "../../../../../components/forms/transfer-ownership-form"
|
||||||
|
import {
|
||||||
|
RouteDrawer,
|
||||||
|
useRouteModal,
|
||||||
|
} from "../../../../../components/route-modal"
|
||||||
|
import { TransferOwnershipSchema } from "../../../../../lib/schemas"
|
||||||
|
|
||||||
|
type TransferDraftOrderOwnershipFormProps = {
|
||||||
|
draftOrder: DraftOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TransferDraftOrderOwnershipForm = ({
|
||||||
|
draftOrder,
|
||||||
|
}: TransferDraftOrderOwnershipFormProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { handleSuccess } = useRouteModal()
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof TransferOwnershipSchema>>({
|
||||||
|
defaultValues: {
|
||||||
|
current_owner_id: draftOrder.cart.customer_id,
|
||||||
|
new_owner_id: "",
|
||||||
|
},
|
||||||
|
resolver: zodResolver(TransferOwnershipSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { mutateAsync, isLoading } = useAdminUpdateDraftOrder(draftOrder.id)
|
||||||
|
|
||||||
|
const handleSubmit = form.handleSubmit(async (values) => {
|
||||||
|
mutateAsync(
|
||||||
|
{
|
||||||
|
customer_id: values.new_owner_id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
handleSuccess()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteDrawer.Form form={form}>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex size-full flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
<RouteDrawer.Body className="size-full flex-1 overflow-auto">
|
||||||
|
<TransferOwnerShipForm order={draftOrder} control={form.control} />
|
||||||
|
</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 type="submit" isLoading={isLoading} size="small">
|
||||||
|
{t("actions.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</RouteDrawer.Footer>
|
||||||
|
</form>
|
||||||
|
</RouteDrawer.Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
import { Heading } from "@medusajs/ui"
|
||||||
|
import { useAdminDraftOrder } from "medusa-react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { useParams } from "react-router-dom"
|
||||||
|
import { RouteDrawer } from "../../../components/route-modal"
|
||||||
|
import { TransferDraftOrderOwnershipForm } from "./components/transfer-draft-order-ownership-form"
|
||||||
|
|
||||||
|
export const DraftOrderTransferOwnership = () => {
|
||||||
|
const { id } = useParams()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const { draft_order, isLoading, isError, error } = useAdminDraftOrder(id!)
|
||||||
|
|
||||||
|
const ready = !isLoading && draft_order
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteDrawer>
|
||||||
|
<RouteDrawer.Header>
|
||||||
|
<Heading>{t("transferOwnership.header")}</Heading>
|
||||||
|
</RouteDrawer.Header>
|
||||||
|
{ready && <TransferDraftOrderOwnershipForm draftOrder={draft_order} />}
|
||||||
|
</RouteDrawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { DraftOrderTransferOwnership as Component } from "./draft-order-transfer-ownership"
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Country, Order } from "@medusajs/medusa"
|
||||||
|
import { Button } from "@medusajs/ui"
|
||||||
|
import { useAdminUpdateOrder } from "medusa-react"
|
||||||
|
import { DefaultValues, useForm } from "react-hook-form"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { AddressForm } from "../../../../components/forms/address-form"
|
||||||
|
import { RouteDrawer, useRouteModal } from "../../../../components/route-modal"
|
||||||
|
import { AddressSchema } from "../../../../lib/schemas"
|
||||||
|
|
||||||
|
type AddressType = "shipping" | "billing"
|
||||||
|
|
||||||
|
type EditOrderAddressFormProps = {
|
||||||
|
order: Order
|
||||||
|
countries: Country[]
|
||||||
|
type: AddressType
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditOrderAddressForm = ({
|
||||||
|
order,
|
||||||
|
countries,
|
||||||
|
type,
|
||||||
|
}: EditOrderAddressFormProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { handleSuccess } = useRouteModal()
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof AddressSchema>>({
|
||||||
|
defaultValues: getDefaultValues(order, type),
|
||||||
|
resolver: zodResolver(AddressSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { mutateAsync, isLoading } = useAdminUpdateOrder(order.id)
|
||||||
|
|
||||||
|
const handleSumbit = form.handleSubmit(async (values) => {
|
||||||
|
const update = {
|
||||||
|
[type === "shipping" ? "shipping_address" : "billing_address"]: values,
|
||||||
|
}
|
||||||
|
|
||||||
|
mutateAsync(update, {
|
||||||
|
onSuccess: () => {
|
||||||
|
handleSuccess()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteDrawer.Form form={form}>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSumbit}
|
||||||
|
className="flex size-full flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
<RouteDrawer.Body className="size-full flex-1 overflow-auto">
|
||||||
|
<AddressForm
|
||||||
|
control={form.control}
|
||||||
|
countries={countries}
|
||||||
|
layout="stack"
|
||||||
|
/>
|
||||||
|
</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 type="submit" isLoading={isLoading} size="small">
|
||||||
|
{t("actions.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</RouteDrawer.Footer>
|
||||||
|
</form>
|
||||||
|
</RouteDrawer.Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDefaultValues = (
|
||||||
|
order: Order,
|
||||||
|
type: AddressType
|
||||||
|
): DefaultValues<z.infer<typeof AddressSchema>> => {
|
||||||
|
const address =
|
||||||
|
type === "shipping" ? order.shipping_address : order.billing_address
|
||||||
|
|
||||||
|
return {
|
||||||
|
first_name: address?.first_name ?? "",
|
||||||
|
last_name: address?.last_name ?? "",
|
||||||
|
address_1: address?.address_1 ?? "",
|
||||||
|
address_2: address?.address_2 ?? "",
|
||||||
|
city: address?.city ?? "",
|
||||||
|
postal_code: address?.postal_code ?? "",
|
||||||
|
province: address?.province ?? "",
|
||||||
|
country_code: address?.country_code ?? "",
|
||||||
|
phone: address?.phone ?? "",
|
||||||
|
company: address?.company ?? "",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./edit-order-address-form"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { OrderBillingAddress as Component } from "./order-billing-address"
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
import { Heading } from "@medusajs/ui"
|
||||||
|
import { useAdminOrder, useAdminRegion } from "medusa-react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { useParams } from "react-router-dom"
|
||||||
|
import { RouteDrawer } from "../../../components/route-modal"
|
||||||
|
import { EditOrderAddressForm } from "../common/edit-address-form"
|
||||||
|
|
||||||
|
export const OrderBillingAddress = () => {
|
||||||
|
const { id } = useParams()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const { order, isLoading, isError, error } = useAdminOrder(id!)
|
||||||
|
|
||||||
|
const regionId = order?.region_id
|
||||||
|
|
||||||
|
const {
|
||||||
|
region,
|
||||||
|
isLoading: isLoadingRegion,
|
||||||
|
isError: isErrorRegion,
|
||||||
|
error: errorRegion,
|
||||||
|
} = useAdminRegion(regionId!, {
|
||||||
|
enabled: !!regionId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const ready = !isLoading && order && !isLoadingRegion && region
|
||||||
|
const countries = region?.countries || []
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isErrorRegion) {
|
||||||
|
throw errorRegion
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteDrawer>
|
||||||
|
<RouteDrawer.Header>
|
||||||
|
<Heading>{t("addresses.billingAddress.editHeader")}</Heading>
|
||||||
|
</RouteDrawer.Header>
|
||||||
|
{ready && (
|
||||||
|
<EditOrderAddressForm
|
||||||
|
order={order}
|
||||||
|
countries={countries}
|
||||||
|
type="billing"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</RouteDrawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
+17
-267
@@ -1,10 +1,10 @@
|
|||||||
import { Address as MedusaAddress, Order } from "@medusajs/medusa"
|
import { Order } from "@medusajs/medusa"
|
||||||
import { Avatar, Container, Copy, Heading, Text } from "@medusajs/ui"
|
import { Container, Heading } from "@medusajs/ui"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { Link } from "react-router-dom"
|
|
||||||
|
|
||||||
import { ArrowPath, CurrencyDollar, Envelope, FlyingBox } from "@medusajs/icons"
|
import { ArrowPath, CurrencyDollar, Envelope, FlyingBox } from "@medusajs/icons"
|
||||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
|
import { CustomerInfo } from "../../../../../components/common/customer-info"
|
||||||
|
|
||||||
type OrderCustomerSectionProps = {
|
type OrderCustomerSectionProps = {
|
||||||
order: Order
|
order: Order
|
||||||
@@ -13,16 +13,16 @@ type OrderCustomerSectionProps = {
|
|||||||
export const OrderCustomerSection = ({ order }: OrderCustomerSectionProps) => {
|
export const OrderCustomerSection = ({ order }: OrderCustomerSectionProps) => {
|
||||||
return (
|
return (
|
||||||
<Container className="divide-y p-0">
|
<Container className="divide-y p-0">
|
||||||
<Header order={order} />
|
<Header />
|
||||||
<ID order={order} />
|
<CustomerInfo.ID data={order} />
|
||||||
<Contact order={order} />
|
<CustomerInfo.Contact data={order} />
|
||||||
<Company order={order} />
|
<CustomerInfo.Company data={order} />
|
||||||
<Addresses order={order} />
|
<CustomerInfo.Addresses data={order} />
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const Header = ({ order }: { order: Order }) => {
|
const Header = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -33,8 +33,8 @@ const Header = ({ order }: { order: Order }) => {
|
|||||||
{
|
{
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
label: t("orders.customer.transferOwnership"),
|
label: t("transferOwnership.label"),
|
||||||
to: `#`, // TODO: Open modal to transfer ownership
|
to: `transfer-ownership`,
|
||||||
icon: <ArrowPath />,
|
icon: <ArrowPath />,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -42,13 +42,13 @@ const Header = ({ order }: { order: Order }) => {
|
|||||||
{
|
{
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
label: t("orders.customer.editShippingAddress"),
|
label: t("addresses.shippingAddress.editLabel"),
|
||||||
to: `#`, // TODO: Open modal to edit shipping address
|
to: "shipping-address",
|
||||||
icon: <FlyingBox />,
|
icon: <FlyingBox />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("orders.customer.editBillingAddress"),
|
label: t("addresses.billingAddress.editLabel"),
|
||||||
to: `#`, // TODO: Open modal to edit billing address
|
to: "billing-address",
|
||||||
icon: <CurrencyDollar />,
|
icon: <CurrencyDollar />,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -56,8 +56,8 @@ const Header = ({ order }: { order: Order }) => {
|
|||||||
{
|
{
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
label: t("orders.customer.editEmail"),
|
label: t("email.editLabel"),
|
||||||
to: `#`, // TODO: Open modal to edit email
|
to: `email`,
|
||||||
icon: <Envelope />,
|
icon: <Envelope />,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -67,253 +67,3 @@ const Header = ({ order }: { order: Order }) => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCustomerName = (order: Order) => {
|
|
||||||
const { first_name: sFirstName, last_name: sLastName } =
|
|
||||||
order.shipping_address || {}
|
|
||||||
const { first_name: bFirstName, last_name: bLastName } =
|
|
||||||
order.billing_address || {}
|
|
||||||
const { first_name: cFirstName, last_name: cLastName } = order.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
|
|
||||||
}
|
|
||||||
|
|
||||||
const ID = ({ order }: { order: Order }) => {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
|
|
||||||
const id = order.customer_id
|
|
||||||
const name = getCustomerName(order)
|
|
||||||
const email = order.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 = ({ order }: { order: Order }) => {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const company =
|
|
||||||
order.shipping_address?.company || order.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 = ({ order }: { order: Order }) => {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
|
|
||||||
const phone = order.shipping_address?.phone || order.billing_address?.phone
|
|
||||||
const email = order.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 isSameAddress = (a: MedusaAddress | null, b: MedusaAddress | 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: MedusaAddress }) => {
|
|
||||||
const {
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
company,
|
|
||||||
address_1,
|
|
||||||
address_2,
|
|
||||||
city,
|
|
||||||
postal_code,
|
|
||||||
province,
|
|
||||||
country,
|
|
||||||
} = 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
return formattedAddress
|
|
||||||
}
|
|
||||||
|
|
||||||
const Address = ({
|
|
||||||
address,
|
|
||||||
type,
|
|
||||||
}: {
|
|
||||||
address: MedusaAddress | 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")
|
|
||||||
: t("addresses.billingAddress")}
|
|
||||||
</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 = ({ order }: { order: Order }) => {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="divide-y">
|
|
||||||
<Address address={order.shipping_address} type="shipping" />
|
|
||||||
{!isSameAddress(order.shipping_address, order.billing_address) ? (
|
|
||||||
<Address address={order.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")}
|
|
||||||
</Text>
|
|
||||||
<Text size="small" leading="compact" className="text-ui-fg-muted">
|
|
||||||
{t("addresses.sameAsShipping")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+9
-32
@@ -12,6 +12,10 @@ import { format } from "date-fns"
|
|||||||
import { useAdminCancelOrder } from "medusa-react"
|
import { useAdminCancelOrder } from "medusa-react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
|
import {
|
||||||
|
getOrderFulfillmentStatus,
|
||||||
|
getOrderPaymentStatus,
|
||||||
|
} from "../../../../../lib/order-helpers"
|
||||||
|
|
||||||
type OrderGeneralSectionProps = {
|
type OrderGeneralSectionProps = {
|
||||||
order: Order
|
order: Order
|
||||||
@@ -80,26 +84,10 @@ export const OrderGeneralSection = ({ order }: OrderGeneralSectionProps) => {
|
|||||||
const FulfillmentBadge = ({ order }: { order: Order }) => {
|
const FulfillmentBadge = ({ order }: { order: Order }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const [label, color] = {
|
const { label, color } = getOrderFulfillmentStatus(
|
||||||
not_fulfilled: [t("orders.fulfillment.status.notFulfilled"), "red"],
|
t,
|
||||||
partially_fulfilled: [
|
order.fulfillment_status
|
||||||
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.requresAction"), "orange"],
|
|
||||||
}[order.fulfillment_status] as [string, "red" | "orange" | "green"]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StatusBadge color={color} className="text-nowrap">
|
<StatusBadge color={color} className="text-nowrap">
|
||||||
@@ -111,18 +99,7 @@ const FulfillmentBadge = ({ order }: { order: Order }) => {
|
|||||||
const PaymentBadge = ({ order }: { order: Order }) => {
|
const PaymentBadge = ({ order }: { order: Order }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const [label, color] = {
|
const { label, color } = getOrderPaymentStatus(t, order.payment_status)
|
||||||
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.requresAction"), "orange"],
|
|
||||||
}[order.payment_status] as [string, "red" | "orange" | "green"]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StatusBadge color={color} className="text-nowrap">
|
<StatusBadge color={color} className="text-nowrap">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useAdminOrder } from "medusa-react"
|
import { useAdminOrder } from "medusa-react"
|
||||||
import { useLoaderData, useParams } from "react-router-dom"
|
import { Outlet, useLoaderData, useParams } from "react-router-dom"
|
||||||
import { JsonViewSection } from "../../../components/common/json-view-section"
|
import { JsonViewSection } from "../../../components/common/json-view-section"
|
||||||
import { OrderCustomerSection } from "./components/order-customer-section"
|
import { OrderCustomerSection } from "./components/order-customer-section"
|
||||||
import { OrderFulfillmentSection } from "./components/order-fulfillment-section"
|
import { OrderFulfillmentSection } from "./components/order-fulfillment-section"
|
||||||
@@ -47,6 +47,7 @@ export const OrderDetail = () => {
|
|||||||
<div className="hidden flex-col gap-y-2 lg:flex">
|
<div className="hidden flex-col gap-y-2 lg:flex">
|
||||||
<OrderCustomerSection order={order} />
|
<OrderCustomerSection order={order} />
|
||||||
</div>
|
</div>
|
||||||
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Order } from "@medusajs/medusa"
|
||||||
|
import { Button } from "@medusajs/ui"
|
||||||
|
import { useAdminUpdateOrder } from "medusa-react"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
import { EmailForm } from "../../../../../components/forms/email-form"
|
||||||
|
import {
|
||||||
|
RouteDrawer,
|
||||||
|
useRouteModal,
|
||||||
|
} from "../../../../../components/route-modal"
|
||||||
|
import { EmailSchema } from "../../../../../lib/schemas"
|
||||||
|
|
||||||
|
type EditOrderEmailFormProps = {
|
||||||
|
order: Order
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditOrderEmailForm = ({ order }: EditOrderEmailFormProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { handleSuccess } = useRouteModal()
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof EmailSchema>>({
|
||||||
|
defaultValues: {
|
||||||
|
email: order.email,
|
||||||
|
},
|
||||||
|
resolver: zodResolver(EmailSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { mutateAsync, isLoading } = useAdminUpdateOrder(order.id)
|
||||||
|
|
||||||
|
const handleSumbit = form.handleSubmit(async (values) => {
|
||||||
|
mutateAsync(values, {
|
||||||
|
onSuccess: () => {
|
||||||
|
handleSuccess()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteDrawer.Form form={form}>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSumbit}
|
||||||
|
className="flex size-full flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
<RouteDrawer.Body className="size-full flex-1 overflow-auto">
|
||||||
|
<EmailForm control={form.control} layout="stack" />
|
||||||
|
</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 type="submit" isLoading={isLoading} size="small">
|
||||||
|
{t("actions.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</RouteDrawer.Footer>
|
||||||
|
</form>
|
||||||
|
</RouteDrawer.Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./edit-order-email-form"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { OrderEmail as Component } from "./order-email"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Heading } from "@medusajs/ui"
|
||||||
|
import { useAdminOrder } from "medusa-react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { useParams } from "react-router-dom"
|
||||||
|
import { RouteDrawer } from "../../../components/route-modal"
|
||||||
|
import { EditOrderEmailForm } from "./components/edit-order-email-form"
|
||||||
|
|
||||||
|
export const OrderEmail = () => {
|
||||||
|
const { id } = useParams()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const { order, isLoading, isError, error } = useAdminOrder(id!)
|
||||||
|
|
||||||
|
const ready = !isLoading && order
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteDrawer>
|
||||||
|
<RouteDrawer.Header>
|
||||||
|
<Heading>{t("email.editHeader")}</Heading>
|
||||||
|
</RouteDrawer.Header>
|
||||||
|
{ready && <EditOrderEmailForm order={order} />}
|
||||||
|
</RouteDrawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { OrderShippingAddress as Component } from "./order-shipping-address"
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
import { Heading } from "@medusajs/ui"
|
||||||
|
import { useAdminOrder, useAdminRegion } from "medusa-react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { useParams } from "react-router-dom"
|
||||||
|
import { RouteDrawer } from "../../../components/route-modal"
|
||||||
|
import { EditOrderAddressForm } from "../common/edit-address-form"
|
||||||
|
|
||||||
|
export const OrderShippingAddress = () => {
|
||||||
|
const { id } = useParams()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const { order, isLoading, isError, error } = useAdminOrder(id!)
|
||||||
|
|
||||||
|
const regionId = order?.region_id
|
||||||
|
|
||||||
|
const {
|
||||||
|
region,
|
||||||
|
isLoading: isLoadingRegion,
|
||||||
|
isError: isErrorRegion,
|
||||||
|
error: errorRegion,
|
||||||
|
} = useAdminRegion(regionId!, {
|
||||||
|
enabled: !!regionId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const ready = !isLoading && order && !isLoadingRegion && region
|
||||||
|
const countries = region?.countries || []
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isErrorRegion) {
|
||||||
|
throw errorRegion
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteDrawer>
|
||||||
|
<RouteDrawer.Header>
|
||||||
|
<Heading>{t("addresses.shippingAddress.editHeader")}</Heading>
|
||||||
|
</RouteDrawer.Header>
|
||||||
|
{ready && (
|
||||||
|
<EditOrderAddressForm
|
||||||
|
order={order}
|
||||||
|
countries={countries}
|
||||||
|
type="shipping"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</RouteDrawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./transfer-order-ownership-form"
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Order } from "@medusajs/medusa"
|
||||||
|
import { Button } from "@medusajs/ui"
|
||||||
|
import { useAdminUpdateOrder } from "medusa-react"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { TransferOwnerShipForm } from "../../../../../components/forms/transfer-ownership-form"
|
||||||
|
import {
|
||||||
|
RouteDrawer,
|
||||||
|
useRouteModal,
|
||||||
|
} from "../../../../../components/route-modal"
|
||||||
|
import { TransferOwnershipSchema } from "../../../../../lib/schemas"
|
||||||
|
|
||||||
|
type TransferOrderOwnershipFormProps = {
|
||||||
|
order: Order
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TransferOrderOwnershipForm = ({
|
||||||
|
order,
|
||||||
|
}: TransferOrderOwnershipFormProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { handleSuccess } = useRouteModal()
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof TransferOwnershipSchema>>({
|
||||||
|
defaultValues: {
|
||||||
|
current_owner_id: order.customer_id,
|
||||||
|
new_owner_id: "",
|
||||||
|
},
|
||||||
|
resolver: zodResolver(TransferOwnershipSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { mutateAsync, isLoading } = useAdminUpdateOrder(order.id)
|
||||||
|
|
||||||
|
const handleSubmit = form.handleSubmit(async (values) => {
|
||||||
|
mutateAsync(
|
||||||
|
{
|
||||||
|
customer_id: values.new_owner_id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
handleSuccess()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteDrawer.Form form={form}>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex size-full flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
<RouteDrawer.Body className="size-full flex-1 overflow-auto">
|
||||||
|
<TransferOwnerShipForm order={order} control={form.control} />
|
||||||
|
</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 type="submit" isLoading={isLoading} size="small">
|
||||||
|
{t("actions.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</RouteDrawer.Footer>
|
||||||
|
</form>
|
||||||
|
</RouteDrawer.Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { OrderTransferOwnership as Component } from "./order-transfer-ownership"
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
import { Heading } from "@medusajs/ui"
|
||||||
|
import { useAdminOrder } from "medusa-react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { useParams } from "react-router-dom"
|
||||||
|
import { RouteDrawer } from "../../../components/route-modal"
|
||||||
|
import { TransferOrderOwnershipForm } from "./components/transfer-order-ownership-form"
|
||||||
|
|
||||||
|
export const OrderTransferOwnership = () => {
|
||||||
|
const { id } = useParams()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const { order, isLoading, isError, error } = useAdminOrder(id!)
|
||||||
|
|
||||||
|
const ready = !isLoading && order
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteDrawer>
|
||||||
|
<RouteDrawer.Header>
|
||||||
|
<Heading>{t("transferOwnership.header")}</Heading>
|
||||||
|
</RouteDrawer.Header>
|
||||||
|
{ready && <TransferOrderOwnershipForm order={order} />}
|
||||||
|
</RouteDrawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -68,7 +68,9 @@ export const defaultAdminDraftOrdersCartRelations = [
|
|||||||
"items.adjustments",
|
"items.adjustments",
|
||||||
"payment",
|
"payment",
|
||||||
"shipping_address",
|
"shipping_address",
|
||||||
|
"shipping_address.country",
|
||||||
"billing_address",
|
"billing_address",
|
||||||
|
"billing_address.country",
|
||||||
"region.payment_providers",
|
"region.payment_providers",
|
||||||
"shipping_methods",
|
"shipping_methods",
|
||||||
"payment_sessions",
|
"payment_sessions",
|
||||||
|
|||||||
Reference in new issue
Block a user