feat: Add Draft Order plugin (#13291)

* feat: Add draft order plugin

* version draft order plugin

* update readme

* chore: Update scripts

* Create purple-dolls-cheer.md

* port over latest changes

* chore: Make package public
This commit is contained in:
Oli Juhl
2025-08-27 10:14:17 +02:00
committed by GitHub
parent 7c96bc4376
commit a0fca16570
93 changed files with 14526 additions and 4 deletions

View File

@@ -0,0 +1,86 @@
<p align="center">
<a href="https://www.medusajs.com">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/59018053/229103275-b5e482bb-4601-46e6-8142-244f531cebdb.svg">
<source media="(prefers-color-scheme: light)" srcset="https://user-images.githubusercontent.com/59018053/229103726-e5b529a3-9b3f-4970-8a1f-c6af37f087bf.svg">
<img alt="Medusa logo" src="https://user-images.githubusercontent.com/59018053/229103726-e5b529a3-9b3f-4970-8a1f-c6af37f087bf.svg">
</picture>
</a>
</p>
<h1 align="center">
Draft Order Plugin
</h1>
<h4 align="center">
<a href="https://docs.medusajs.com">Documentation</a> |
<a href="https://www.medusajs.com">Website</a>
</h4>
<p align="center">
Create and manage draft orders on behalf of customers in Medusa
</p>
<p align="center">
<a href="https://github.com/medusajs/medusa/blob/master/CONTRIBUTING.md">
<img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" alt="PRs welcome!" />
</a>
<a href="https://www.producthunt.com/posts/medusa"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Day-%23DA552E" alt="Product Hunt"></a>
<a href="https://discord.gg/xpCwq3Kfn8">
<img src="https://img.shields.io/badge/chat-on%20discord-7289DA.svg" alt="Discord Chat" />
</a>
<a href="https://twitter.com/intent/follow?screen_name=medusajs">
<img src="https://img.shields.io/twitter/follow/medusajs.svg?label=Follow%20@medusajs" alt="Follow @medusajs" />
</a>
</p>
## Overview
The Draft Order Plugin enables admin users to create and manage orders on behalf of customers. This is particularly useful for customer support scenarios or when customers place orders offline.
## Features
- **Create draft orders** from the Medusa Admin dashboard
- **Manage items** in draft orders (add, update, remove)
- **Add shipping methods** to draft orders
- **Associate customers** with draft orders
- **Convert draft orders** to regular orders for purchase completion
## Installation
1. Install the Draft Order plugin
```
yarn add @medusajs/draft-order
```
2. Configure the plugin in your medusa-config.ts
```
module.exports = defineConfig({
projectConfig: {
...
},
plugins: [
{
resolve: "@medusajs/draft-order",
options: {},
},
],
})
```
3. Start your server
## Requirements
- Medusa application version >= 2.4.0
## Support
## Community & Contributions
The community and core team are available in [GitHub Discussions](https://github.com/medusajs/medusa/discussions), where you can ask for support, discuss roadmap, and share ideas.
Join our [Discord server](https://discord.com/invite/medusajs) to meet other community members.
## Other channels
- [GitHub Issues](https://github.com/medusajs/medusa/issues)
- [Twitter](https://twitter.com/medusajs)
- [LinkedIn](https://www.linkedin.com/company/medusajs)
- [Medusa Blog](https://medusajs.com/blog/)

View File

@@ -0,0 +1,101 @@
{
"name": "@medusajs/draft-order",
"version": "2.9.0",
"description": "A starter for Medusa plugins.",
"author": "Medusa (https://medusajs.com)",
"license": "MIT",
"files": [
".medusa/server"
],
"exports": {
"./package.json": "./package.json",
"./workflows": "./.medusa/server/src/workflows/index.js",
"./.medusa/server/src/modules/*": "./.medusa/server/src/modules/*/index.js",
"./modules/*": "./.medusa/server/src/modules/*/index.js",
"./providers/*": "./.medusa/server/src/providers/*/index.js",
"./*": "./.medusa/server/src/*.js",
"./admin": {
"import": "./.medusa/server/src/admin/index.mjs",
"require": "./.medusa/server/src/admin/index.js",
"default": "./.medusa/server/src/admin/index.js"
}
},
"keywords": [
"medusa",
"plugin",
"medusa-plugin-other",
"medusa-plugin",
"medusa-v2"
],
"scripts": {
"build": "medusa plugin:build",
"dev": "medusa plugin:develop",
"prepare": "cross-env NODE_ENV=production yarn run build",
"link:watch": "medusa plugin:publish && medusa plugin:develop"
},
"dependencies": {
"@ariakit/react": "^0.4.15",
"@hookform/resolvers": "3.4.2",
"@medusajs/js-sdk": "2.9.0",
"@tanstack/react-query": "5.64.2",
"@uiw/react-json-view": "^2.0.0-alpha.17",
"date-fns": "^3.6.0",
"match-sorter": "^6.3.4",
"radix-ui": "1.1.2",
"react-hook-form": "7.49.1"
},
"devDependencies": {
"@medusajs/admin-sdk": "2.9.0",
"@medusajs/cli": "2.9.0",
"@medusajs/framework": "2.9.0",
"@medusajs/icons": "2.9.0",
"@medusajs/medusa": "2.9.0",
"@medusajs/test-utils": "2.9.0",
"@medusajs/types": "2.9.0",
"@medusajs/ui": "4.0.19",
"@medusajs/ui-preset": "2.9.0",
"@mikro-orm/cli": "6.4.3",
"@mikro-orm/core": "6.4.3",
"@mikro-orm/knex": "6.4.3",
"@mikro-orm/migrations": "6.4.3",
"@mikro-orm/postgresql": "6.4.3",
"@swc/core": "1.5.7",
"@types/lodash": "^4.17.15",
"@types/node": "^20.0.0",
"@types/react": "^18.3.2",
"@types/react-dom": "^18.2.25",
"awilix": "^8.0.1",
"lodash": "^4.17.21",
"pg": "^8.13.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "6.20.1",
"ts-node": "^10.9.2",
"tsup": "^8.4.0",
"typescript": "^5.6.2",
"vite": "^5.2.11",
"yalc": "^1.0.0-pre.53"
},
"peerDependencies": {
"@medusajs/admin-sdk": "2.9.0",
"@medusajs/cli": "2.9.0",
"@medusajs/framework": "2.9.0",
"@medusajs/icons": "2.9.0",
"@medusajs/medusa": "2.9.0",
"@medusajs/test-utils": "2.9.0",
"@medusajs/ui": "4.0.19",
"@mikro-orm/cli": "6.4.3",
"@mikro-orm/core": "6.4.3",
"@mikro-orm/knex": "6.4.3",
"@mikro-orm/migrations": "6.4.3",
"@mikro-orm/postgresql": "6.4.3",
"awilix": "^8.0.1",
"lodash": "^4.17.21",
"pg": "^8.13.0",
"react-router-dom": "6.20.1"
},
"engines": {
"node": ">=20"
},
"packageManager": "yarn@3.2.1"
}

View File

@@ -0,0 +1,123 @@
import { DropdownMenu, IconButton, clx } from "@medusajs/ui"
import { EllipsisHorizontal } from "@medusajs/icons"
import { PropsWithChildren, ReactNode } from "react"
import { Link } from "react-router-dom"
import { ConditionalTooltip } from "./conditional-tooltip"
export type Action = {
icon: ReactNode
label: string
disabled?: boolean
/**
* Optional tooltip to display when a disabled action is hovered.
*/
disabledTooltip?: string | ReactNode
} & (
| {
to: string
onClick?: never
}
| {
onClick: () => void
to?: never
}
)
export type ActionGroup = {
actions: Action[]
}
type ActionMenuProps = PropsWithChildren<{
groups: ActionGroup[]
variant?: "transparent" | "primary"
}>
export const ActionMenu = ({
groups,
variant = "transparent",
children,
}: ActionMenuProps) => {
const inner = children ?? (
<IconButton size="small" variant={variant}>
<EllipsisHorizontal />
</IconButton>
)
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild>{inner}</DropdownMenu.Trigger>
<DropdownMenu.Content>
{groups.map((group, index) => {
if (!group.actions.length) {
return null
}
const isLast = index === groups.length - 1
return (
<DropdownMenu.Group key={index}>
{group.actions.map((action, index) => {
const Wrapper = action.disabledTooltip
? ({ children }: { children: ReactNode }) => (
<ConditionalTooltip
showTooltip={action.disabled}
content={action.disabledTooltip}
side="right"
>
<div>{children}</div>
</ConditionalTooltip>
)
: "div"
if (action.onClick) {
return (
<Wrapper key={index}>
<DropdownMenu.Item
disabled={action.disabled}
onClick={(e) => {
e.stopPropagation()
action.onClick()
}}
className={clx(
"[&_svg]:text-ui-fg-subtle flex items-center gap-x-2",
{
"[&_svg]:text-ui-fg-disabled": action.disabled,
}
)}
>
{action.icon}
<span>{action.label}</span>
</DropdownMenu.Item>
</Wrapper>
)
}
return (
<Wrapper key={index}>
<DropdownMenu.Item
className={clx(
"[&_svg]:text-ui-fg-subtle flex items-center gap-x-2",
{
"[&_svg]:text-ui-fg-disabled": action.disabled,
}
)}
asChild
disabled={action.disabled}
>
<Link to={action.to} onClick={(e) => e.stopPropagation()}>
{action.icon}
<span>{action.label}</span>
</Link>
</DropdownMenu.Item>
</Wrapper>
)
})}
{!isLast && <DropdownMenu.Separator />}
</DropdownMenu.Group>
)
})}
</DropdownMenu.Content>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,84 @@
import { XMark } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Badge, IconButton, Skeleton, Text } from "@medusajs/ui"
import { useCustomerAddress } from "../../hooks/api/customers"
import { getFormattedAddress } from "../../lib/utils/address-utils"
interface AddressCardProps {
customerId: string
addressId: string
tag?: "shipping" | "billing"
onRemove?: () => void | Promise<void>
}
export const AddressCard = ({
customerId,
addressId,
tag = "shipping",
onRemove,
}: AddressCardProps) => {
const { address, isPending, isError, error } = useCustomerAddress(
customerId,
addressId
)
if (isError) {
throw error
}
const isReady = !isPending && !!address
return (
<div className="px-3 py-2 rounded-lg bg-ui-bg-component shadow-elevation-card-rest flex items-center gap-4">
{!isReady ? <LoadingState /> : <AddressInfo address={address} />}
<Badge size="2xsmall">
{tag === "shipping" ? "Shipping" : "Billing"}
</Badge>
{onRemove && (
<IconButton
className="shrink-0"
variant="transparent"
size="small"
onClick={onRemove}
type="button"
>
<XMark />
</IconButton>
)}
</div>
)
}
const LoadingState = () => {
return (
<div className="flex flex-col gap-0.5">
<Skeleton className="w-20 h-5" />
<Skeleton className="w-16 h-5" />
<Skeleton className="w-16 h-5" />
</div>
)
}
interface AddressInfoProps {
address: HttpTypes.AdminCustomerAddress
}
const AddressInfo = ({ address }: AddressInfoProps) => {
const addressSegments = getFormattedAddress(address)
return (
<div className="flex flex-col flex-1">
{address.address_name && (
<Text size="small" weight="plus" leading="compact">
{address.address_name}
</Text>
)}
{addressSegments.map((segment, idx) => (
<Text size="small" leading="compact" className="text-ui-fg-subtle">
{segment}
{idx < addressSegments.length - 1 && ", "}
</Text>
))}
</div>
)
}

View File

@@ -0,0 +1,20 @@
import { Tooltip } from "@medusajs/ui"
import { ComponentPropsWithoutRef, PropsWithChildren } from "react"
type ConditionalTooltipProps = PropsWithChildren<
ComponentPropsWithoutRef<typeof Tooltip> & {
showTooltip?: boolean
}
>
export const ConditionalTooltip = ({
children,
showTooltip = false,
...props
}: ConditionalTooltipProps) => {
if (showTooltip) {
return <Tooltip {...props}>{children}</Tooltip>
}
return children
}

View File

@@ -0,0 +1,80 @@
import { XMark } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Avatar, IconButton, Skeleton, Text } from "@medusajs/ui"
import { useCustomer } from "../../hooks/api/customers"
interface CustomerCardProps {
customerId: string
onRemove?: () => void | Promise<void>
}
export const CustomerCard = ({ customerId, onRemove }: CustomerCardProps) => {
const { customer, isPending, isError, error } = useCustomer(customerId)
if (isError) {
throw error
}
const isReady = !isPending && !!customer
return (
<div className="px-3 py-2 rounded-lg bg-ui-bg-component shadow-elevation-card-rest flex items-center gap-4">
{!isReady ? <LoadingState /> : <CustomerInfo customer={customer} />}
{onRemove && (
<IconButton
className="shrink-0"
variant="transparent"
size="small"
onClick={onRemove}
type="button"
>
<XMark />
</IconButton>
)}
</div>
)
}
const LoadingState = () => {
return (
<div className="flex items-center gap-4">
<Skeleton className="w-7 h-7 rounded-full" />
<div className="flex flex-col gap-y-1">
<Skeleton className="w-20 h-5" />
<Skeleton className="w-16 h-5" />
</div>
</div>
)
}
const CustomerInfo = ({ customer }: { customer: HttpTypes.AdminCustomer }) => {
const name = [customer.first_name, customer.last_name]
.filter(Boolean)
.join(" ")
const fallback = name ? name[0] : customer.email[0]
return (
<div className="flex items-center gap-4 flex-1">
<Avatar size="small" fallback={fallback} className="w-6 h-6" />
<div className="flex flex-col flex-1 overflow-hidden">
{name && (
<Text
leading="compact"
size="small"
weight="plus"
className="truncate"
>
{name}
</Text>
)}
<Text
leading="compact"
size="small"
className="text-ui-fg-subtle truncate"
>
{customer.email}
</Text>
</div>
</div>
)
}

View File

@@ -0,0 +1,391 @@
import {
Button,
clx,
DataTableColumnDef,
DataTableCommand,
DataTableEmptyStateProps,
DataTableFilter,
DataTableFilteringState,
DataTablePaginationState,
DataTableRow,
DataTableRowSelectionState,
DataTableSortingState,
Heading,
DataTable as Primitive,
Text,
useDataTable,
} from "@medusajs/ui"
import React, { ReactNode, useCallback, useState } from "react"
import { Link, useNavigate, useSearchParams } from "react-router-dom"
import { useQueryParams } from "../../hooks/common/use-query-params"
import { ActionMenu } from "./action-menu"
type DataTableActionProps = {
label: string
disabled?: boolean
} & (
| {
to: string
}
| {
onClick: () => void
}
)
type DataTableActionMenuActionProps = {
label: string
icon: ReactNode
disabled?: boolean
} & (
| {
to: string
}
| {
onClick: () => void
}
)
type DataTableActionMenuGroupProps = {
actions: DataTableActionMenuActionProps[]
}
type DataTableActionMenuProps = {
groups: DataTableActionMenuGroupProps[]
}
interface DataTableProps<TData> {
data?: TData[]
columns: DataTableColumnDef<TData, any>[]
filters?: DataTableFilter[]
commands?: DataTableCommand[]
action?: DataTableActionProps
actionMenu?: DataTableActionMenuProps
rowCount?: number
getRowId: (row: TData) => string
enablePagination?: boolean
enableSearch?: boolean
autoFocusSearch?: boolean
rowHref?: (row: TData) => string
emptyState?: DataTableEmptyStateProps
heading?: string
subHeading?: string
prefix?: string
pageSize?: number
isLoading?: boolean
rowSelection?: {
state: DataTableRowSelectionState
onRowSelectionChange: (value: DataTableRowSelectionState) => void
enableRowSelection?: boolean | ((row: DataTableRow<TData>) => boolean)
}
layout?: "fill" | "auto"
}
export const DataTable = <TData,>({
data = [],
columns,
filters,
commands,
action,
actionMenu,
getRowId,
rowCount = 0,
enablePagination = true,
enableSearch = true,
autoFocusSearch = false,
rowHref,
heading,
subHeading,
prefix,
pageSize = 10,
emptyState,
rowSelection,
isLoading = false,
layout = "auto",
}: DataTableProps<TData>) => {
const enableFiltering = filters && filters.length > 0
const enableCommands = commands && commands.length > 0
const enableSorting = columns.some((column) => column.enableSorting)
const filterIds = filters?.map((f) => f.id) ?? []
const prefixedFilterIds = filterIds.map((id) => getQueryParamKey(id, prefix))
const { offset, order, q, ...filterParams } = useQueryParams(
[
...filterIds,
...(enableSorting ? ["order"] : []),
...(enableSearch ? ["q"] : []),
...(enablePagination ? ["offset"] : []),
],
prefix
)
const [_, setSearchParams] = useSearchParams()
const [search, setSearch] = useState<string>(q ?? "")
const handleSearchChange = (value: string) => {
setSearch(value)
setSearchParams((prev) => {
if (value) {
prev.set(getQueryParamKey("q", prefix), value)
} else {
prev.delete(getQueryParamKey("q", prefix))
}
return prev
})
}
const [pagination, setPagination] = useState<DataTablePaginationState>(
offset ? parsePaginationState(offset, pageSize) : { pageIndex: 0, pageSize }
)
const handlePaginationChange = (value: DataTablePaginationState) => {
setPagination(value)
setSearchParams((prev) => {
if (value.pageIndex === 0) {
prev.delete(getQueryParamKey("offset", prefix))
} else {
prev.set(
getQueryParamKey("offset", prefix),
transformPaginationState(value).toString()
)
}
return prev
})
}
const [filtering, setFiltering] = useState<DataTableFilteringState>(
parseFilterState(filterIds, filterParams)
)
const handleFilteringChange = (value: DataTableFilteringState) => {
setFiltering(value)
setSearchParams((prev) => {
Array.from(prev.keys()).forEach((key) => {
if (prefixedFilterIds.includes(key) && !(key in value)) {
prev.delete(key)
}
})
Object.entries(value).forEach(([key, filter]) => {
if (
prefixedFilterIds.includes(getQueryParamKey(key, prefix)) &&
filter
) {
prev.set(getQueryParamKey(key, prefix), JSON.stringify(filter))
}
})
return prev
})
}
const [sorting, setSorting] = useState<DataTableSortingState | null>(
order ? parseSortingState(order) : null
)
const handleSortingChange = (value: DataTableSortingState) => {
setSorting(value)
setSearchParams((prev) => {
if (value) {
const valueToStore = transformSortingState(value)
prev.set(getQueryParamKey("order", prefix), valueToStore)
} else {
prev.delete(getQueryParamKey("order", prefix))
}
return prev
})
}
const navigate = useNavigate()
const onRowClick = useCallback(
(event: React.MouseEvent<HTMLTableRowElement, MouseEvent>, row: TData) => {
if (!rowHref) {
return
}
const href = rowHref(row)
if (event.metaKey || event.ctrlKey || event.button === 1) {
window.open(href, "_blank", "noreferrer")
return
}
if (event.shiftKey) {
window.open(href, undefined, "noreferrer")
return
}
navigate(href)
},
[navigate, rowHref]
)
const instance = useDataTable({
data,
columns,
filters,
commands,
rowCount,
getRowId,
onRowClick: rowHref ? onRowClick : undefined,
pagination: enablePagination
? {
state: pagination,
onPaginationChange: handlePaginationChange,
}
: undefined,
filtering: enableFiltering
? {
state: filtering,
onFilteringChange: handleFilteringChange,
}
: undefined,
sorting: enableSorting
? {
state: sorting,
onSortingChange: handleSortingChange,
}
: undefined,
search: enableSearch
? {
state: search,
onSearchChange: handleSearchChange,
}
: undefined,
rowSelection,
isLoading,
})
const shouldRenderHeading = heading || subHeading
return (
<Primitive
instance={instance}
className={clx({
"h-full [&_tr]:last-of-type:!border-b": layout === "fill",
})}
>
<Primitive.Toolbar className="flex flex-col items-start justify-between gap-2 md:flex-row md:items-center">
<div className="flex w-full items-center justify-between gap-2">
{shouldRenderHeading && (
<div>
{heading && <Heading>{heading}</Heading>}
{subHeading && (
<Text size="small" className="text-ui-fg-subtle">
{subHeading}
</Text>
)}
</div>
)}
<div className="flex items-center justify-end gap-x-2 md:hidden">
{enableFiltering && <Primitive.FilterMenu tooltip="Filter" />}
<Primitive.SortingMenu tooltip="Sort" />
{actionMenu && <ActionMenu variant="primary" {...actionMenu} />}
{action && <DataTableAction {...action} />}
</div>
</div>
<div className="flex w-full items-center gap-2 md:justify-end">
{enableSearch && (
<div className="w-full md:w-auto">
<Primitive.Search
data-modal-id="modal-search-input"
placeholder="Search"
autoFocus={autoFocusSearch}
/>
</div>
)}
<div className="hidden items-center gap-x-2 md:flex">
{enableFiltering && <Primitive.FilterMenu tooltip="Filter" />}
<Primitive.SortingMenu tooltip="Sort" />
{actionMenu && <ActionMenu variant="primary" {...actionMenu} />}
{action && <DataTableAction {...action} />}
</div>
</div>
</Primitive.Toolbar>
<Primitive.Table emptyState={emptyState} />
{enablePagination && <Primitive.Pagination />}
{enableCommands && (
<Primitive.CommandBar selectedLabel={(count) => `${count} selected`} />
)}
</Primitive>
)
}
function transformSortingState(value: DataTableSortingState) {
return value.desc ? `-${value.id}` : value.id
}
function parseSortingState(value: string) {
return value.startsWith("-")
? { id: value.slice(1), desc: true }
: { id: value, desc: false }
}
function transformPaginationState(value: DataTablePaginationState) {
return value.pageIndex * value.pageSize
}
function parsePaginationState(value: string, pageSize: number) {
const offset = parseInt(value)
return {
pageIndex: Math.floor(offset / pageSize),
pageSize,
}
}
function parseFilterState(
filterIds: string[],
value: Record<string, string | undefined>
) {
if (!value) {
return {}
}
const filters: DataTableFilteringState = {}
for (const id of filterIds) {
const filterValue = value[id]
if (filterValue) {
filters[id] = JSON.parse(filterValue)
}
}
return filters
}
function getQueryParamKey(key: string, prefix?: string) {
return prefix ? `${prefix}_${key}` : key
}
const DataTableAction = ({
label,
disabled,
...props
}: DataTableActionProps) => {
const buttonProps = {
size: "small" as const,
disabled: disabled ?? false,
type: "button" as const,
variant: "secondary" as const,
}
if ("to" in props) {
return (
<Button {...buttonProps} asChild>
<Link to={props.to}>{label}</Link>
</Button>
)
}
return (
<Button {...buttonProps} onClick={props.onClick}>
{label}
</Button>
)
}

View File

@@ -0,0 +1,238 @@
import { InformationCircleSolid } from "@medusajs/icons"
import {
Hint as HintComponent,
Label as LabelComponent,
Text,
Tooltip,
clx,
} from "@medusajs/ui"
import { Label as RadixLabel, Slot } from "radix-ui"
import React, {
ReactNode,
createContext,
forwardRef,
useContext,
useId,
} from "react"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
useFormState,
} from "react-hook-form"
const Provider = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const Field = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
type FormItemContextValue = {
id: string
}
const FormItemContext = createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const useFormField = () => {
const fieldContext = useContext(FormFieldContext)
const itemContext = useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within a FormField")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formLabelId: `${id}-form-item-label`,
formDescriptionId: `${id}-form-item-description`,
formErrorMessageId: `${id}-form-item-message`,
...fieldState,
}
}
const Item = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const id = useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
ref={ref}
className={clx("flex flex-col space-y-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
)
Item.displayName = "Form.Item"
const Label = forwardRef<
React.ElementRef<typeof RadixLabel.Root>,
React.ComponentPropsWithoutRef<typeof RadixLabel.Root> & {
variant?: "default" | "subtle"
optional?: boolean
tooltip?: ReactNode
icon?: ReactNode
}
>(
(
{
className,
optional = false,
tooltip,
icon,
variant = "default",
...props
},
ref
) => {
const { formLabelId, formItemId } = useFormField()
return (
<div className="flex items-center gap-x-1">
<LabelComponent
id={formLabelId}
ref={ref}
className={clx(
{
"text-ui-fg-subtle": variant === "subtle",
},
className
)}
htmlFor={formItemId}
size="small"
weight={variant === "default" ? "plus" : "regular"}
{...props}
/>
{tooltip && (
<Tooltip content={tooltip}>
<InformationCircleSolid className="text-ui-fg-muted" />
</Tooltip>
)}
{icon}
{optional && (
<Text size="small" leading="compact" className="text-ui-fg-muted">
(Optional)
</Text>
)}
</div>
)
}
)
Label.displayName = "Form.Label"
const Control = forwardRef<
React.ElementRef<typeof Slot.Root>,
React.ComponentPropsWithoutRef<typeof Slot.Root>
>(({ ...props }, ref) => {
const {
error,
formItemId,
formDescriptionId,
formErrorMessageId,
formLabelId,
} = useFormField()
return (
<Slot.Root
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formErrorMessageId}`
}
aria-invalid={!!error}
aria-labelledby={formLabelId}
{...props}
/>
)
})
Control.displayName = "Form.Control"
const Hint = forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<HintComponent
ref={ref}
id={formDescriptionId}
className={className}
{...props}
/>
)
})
Hint.displayName = "Form.Hint"
const ErrorMessage = forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formErrorMessageId } = useFormField()
const msg = error ? String(error?.message) : children
if (!msg || msg === "undefined") {
return null
}
return (
<HintComponent
ref={ref}
id={formErrorMessageId}
className={className}
variant={error ? "error" : "info"}
{...props}
>
{msg}
</HintComponent>
)
})
ErrorMessage.displayName = "Form.ErrorMessage"
const Form = Object.assign(Provider, {
Item,
Label,
Control,
Hint,
ErrorMessage,
Field,
})
export { Form }

View File

@@ -0,0 +1,57 @@
import { clx } from "@medusajs/ui"
import { ComponentPropsWithoutRef, forwardRef } from "react"
interface InlineTipProps extends ComponentPropsWithoutRef<"div"> {
/**
* The label to display in the tip.
*/
label?: string
/**
* The variant of the tip.
*/
variant?: "tip" | "warning"
}
/**
* A component for rendering inline tips. Useful for providing additional information or context.
*
* @example
* ```tsx
* <InlineTip label="Info">
* This is an info tip.
* </InlineTip>
* ```
*
* TODO: Move to `@medusajs/ui` package.
*/
export const InlineTip = forwardRef<HTMLDivElement, InlineTipProps>(
({ variant = "tip", label, className, children, ...props }, ref) => {
const labelValue = label || (variant === "warning" ? "Warning" : "Tip")
return (
<div
ref={ref}
className={clx(
"bg-ui-bg-component txt-small text-ui-fg-subtle grid grid-cols-[4px_1fr] items-start gap-3 rounded-lg border p-3",
className
)}
{...props}
>
<div
role="presentation"
className={clx("w-4px bg-ui-tag-neutral-icon h-full rounded-full", {
"bg-ui-tag-orange-icon": variant === "warning",
})}
/>
<div className="text-pretty">
<strong className="txt-small-plus text-ui-fg-base">
{labelValue}:
</strong>{" "}
{children}
</div>
</div>
)
}
)
InlineTip.displayName = "InlineTip"

View File

@@ -0,0 +1,42 @@
import React from "react"
/**
* A form that can only be submitted when using the meta or control key.
*/
export const KeyboundForm = React.forwardRef<
HTMLFormElement,
React.FormHTMLAttributes<HTMLFormElement>
>(({ onSubmit, onKeyDown, ...rest }, ref) => {
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
onSubmit?.(event)
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLFormElement>) => {
if (event.key === "Enter") {
if (
event.target instanceof HTMLTextAreaElement &&
!(event.metaKey || event.ctrlKey)
) {
return
}
event.preventDefault()
if (event.metaKey || event.ctrlKey) {
handleSubmit(event)
}
}
}
return (
<form
{...rest}
onSubmit={handleSubmit}
onKeyDown={onKeyDown ?? handleKeyDown}
ref={ref}
/>
)
})
KeyboundForm.displayName = "KeyboundForm"

View File

@@ -0,0 +1,66 @@
import { clx, Skeleton } from "@medusajs/ui"
interface PageSkeletonProps {
mainSections?: number
sidebarSections?: number
showJSON?: boolean
showMetadata?: boolean
}
export const PageSkeleton = ({
mainSections = 2,
sidebarSections = 1,
showJSON = false,
showMetadata = true,
}: PageSkeletonProps) => {
const showExtraData = showJSON || showMetadata
return (
<div className="flex flex-col gap-y-3">
<div className="flex flex-col gap-x-4 gap-y-3 xl:flex-row xl:items-start">
<div className="flex w-full flex-col gap-y-3">
{Array.from({ length: mainSections }, (_, i) => i).map((section) => {
return (
<Skeleton
key={section}
className={clx("h-full max-h-[460px] w-full rounded-lg", {
"max-h-[219px]": section === 0,
})}
/>
)
})}
{showExtraData && (
<div className="hidden flex-col gap-y-3 xl:flex">
{showMetadata && (
<Skeleton className="h-[60px] w-full rounded-lg" />
)}
{showJSON && <Skeleton className="h-[60px] w-full rounded-lg" />}
</div>
)}
</div>
<div className="flex w-full max-w-[100%] flex-col gap-y-3 xl:mt-0 xl:max-w-[440px]">
{Array.from({ length: sidebarSections }, (_, i) => i).map(
(section) => {
return (
<Skeleton
key={section}
className={clx("h-full max-h-[320px] w-full rounded-lg", {
"max-h-[140px]": section === 0,
})}
/>
)
}
)}
{showExtraData && (
<div className="flex flex-col gap-y-3 xl:hidden">
{showMetadata && (
<Skeleton className="h-[60px] w-full rounded-lg" />
)}
{showJSON && <Skeleton className="h-[60px] w-full rounded-lg" />}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,22 @@
import { Photo } from "@medusajs/icons"
interface ThumbnailProps {
thumbnail?: string | null
alt?: string | null
}
export const Thumbnail = ({ thumbnail, alt = "" }: ThumbnailProps) => {
return (
<div className="relative w-6 h-8 rounded overflow-hidden flex items-center justify-center bg-ui-bg-component">
{thumbnail ? (
<img
src={thumbnail}
className="w-full h-full object-cover"
alt={alt ?? undefined}
/>
) : (
<Photo />
)}
</div>
)
}

View File

@@ -0,0 +1,85 @@
import { ExclamationCircleSolid } from "@medusajs/icons"
import { Button, Container, Divider, Heading, Text, toast } from "@medusajs/ui"
import { useLocation } from "react-router-dom"
import { useDraftOrderCancelEdit } from "../../hooks/api/draft-orders"
import { useOrderPreview } from "../../hooks/api/orders"
const DETAILS_PAGE_REGEX = /\/draft-orders\/[a-zA-Z0-9_-]+\/?$/
interface ActiveOrderChangeProps {
orderId: string
}
export const ActiveOrderChange = ({ orderId }: ActiveOrderChangeProps) => {
const { order: preview } = useOrderPreview(orderId)
const location = useLocation()
const isPending = preview?.order_change?.status === "pending"
const isDetailsPage = DETAILS_PAGE_REGEX.test(location.pathname)
const { mutateAsync, isPending: isMutating } =
useDraftOrderCancelEdit(orderId)
if (!isPending || !isDetailsPage) {
return null
}
const onCancel = async () => {
await mutateAsync(undefined, {
onError: (e) => {
toast.error(e.message)
},
onSuccess: () => {
toast.success("Edit cancelled")
},
})
}
const actions = preview.order_change.actions
const noActions = !actions || actions.length === 0
return (
<div
className="border-b border-x px-3 py-3 -mx-4 -mt-3"
style={{
background:
"repeating-linear-gradient(-45deg, rgb(212, 212, 216, 0.15), rgb(212, 212, 216,.15) 10px, transparent 10px, transparent 20px)",
}}
>
<Container className="p-0 overflow-hidden">
<div className="px-6 py-4 flex items-center gap-x-2">
<ExclamationCircleSolid className="text-ui-fg-interactive" />
<Heading>Edit pending</Heading>
</div>
<Divider variant="dashed" />
<div className="px-6 py-4">
<Text className="text-pretty">
{noActions
? "There is a pending edit on this draft order with no changes. Click below to cancel it, or open one of the menus to start making changes."
: "There is a pending edit on this draft order with changes. Click below to cancel it, or continue to complete the edit."}
</Text>
</div>
<Divider variant="dashed" />
<div className="bg-ui-bg-component px-6 py-4 justify-end items-center flex gap-x-2">
{!noActions && (
<Button
size="small"
variant="secondary"
isLoading={isMutating}
onClick={onCancel}
>
Continue
</Button>
)}
<Button
size="small"
variant="secondary"
isLoading={isMutating}
onClick={onCancel}
>
Cancel
</Button>
</div>
</Container>
</div>
)
}

View File

@@ -0,0 +1,433 @@
import { HttpTypes } from "@medusajs/types"
import {
Avatar,
clx,
Container,
Heading,
Skeleton,
Text,
Tooltip,
} from "@medusajs/ui"
import { Collapsible } from "radix-ui"
import { ReactNode, useMemo, useState } from "react"
import { Link } from "react-router-dom"
import { useUser } from "../../hooks/api/users"
import { getFullDate, getRelativeDate } from "../../lib/utils/date-utils"
interface ActivitySectionProps {
order: HttpTypes.AdminOrder
changes: HttpTypes.AdminOrderChange[]
}
export const ActivitySection = ({ order, changes }: ActivitySectionProps) => {
const activityItems = useMemo(
() => getActivityItems(order, changes),
[order, changes]
)
return (
<Container className="p-0 overflow-hidden">
<div className="px-6 py-4">
<Heading>Activity</Heading>
</div>
<ActivityItemList items={activityItems} />
</Container>
)
}
interface ActivityItemListProps {
items: ActivityItem[]
}
const ActivityItemList = ({ items }: ActivityItemListProps) => {
if (items.length <= 3) {
return (
<div className="flex flex-col gap-y-0.5 px-6 pb-6">
{items.map((item, idx) => (
<ActivityItem
key={idx}
item={item}
isFirst={idx === items.length - 1}
/>
))}
</div>
)
}
const lastItems = items.slice(0, 2)
const collapsibleItems = items.slice(2, items.length - 1)
const firstItem = items[items.length - 1]
return (
<div className="flex flex-col gap-y-0.5 px-6 pb-6">
{lastItems.map((item, idx) => (
<ActivityItem key={idx} item={item} />
))}
<CollapsibleActivityItemList items={collapsibleItems} />
<ActivityItem key={items.length - 1} item={firstItem} isFirst />
</div>
)
}
interface CollapsibleActivityItemListProps {
items: ActivityItem[]
}
const CollapsibleActivityItemList = ({
items,
}: CollapsibleActivityItemListProps) => {
const [open, setOpen] = useState(false)
return (
<Collapsible.Root open={open} onOpenChange={setOpen}>
{!open && (
<div className="grid grid-cols-[20px_1fr] items-start gap-2">
<div className="flex size-full flex-col items-center">
<div className="border-ui-border-strong w-px flex-1 bg-[linear-gradient(var(--border-strong)_33%,rgba(255,255,255,0)_0%)] bg-[length:1px_3px] bg-right bg-repeat-y bg-clip-content" />
</div>
<Collapsible.Trigger className="text-left p-0 m-0 pb-4 text-ui-fg-muted hover:text-ui-fg-base focus:text-ui-fg-base outline-none transition-colors">
<Text size="small" leading="compact" weight="plus">
{`Show ${items.length} more ${
items.length === 1 ? "activity" : "activities"
}`}
</Text>
</Collapsible.Trigger>
</div>
)}
<Collapsible.Content>
<div className="flex flex-col gap-y-0.5">
{items.map((item, idx) => {
return <ActivityItem key={idx} item={item} />
})}
</div>
</Collapsible.Content>
</Collapsible.Root>
)
}
interface ActivityItem {
label: string
content?: ReactNode
timestamp: string
userId?: string | null
}
interface ActivityItemProps {
item: ActivityItem
isFirst?: boolean
}
const ActivityItem = ({ item, isFirst = false }: ActivityItemProps) => {
const { user, isPending, isError, error } = useUser(item.userId!, undefined, {
enabled: !!item.userId,
})
if (isError) {
throw error
}
const isUserLoaded = !isPending && !!user && !!item.userId
return (
<div
className={clx("grid grid-cols-[20px_1fr] items-start gap-x-2 w-full")}
>
<div className="flex flex-col items-center gap-0.5 h-full">
<div className="size-5 flex items-center justify-center">
<div className="size-2.5 rounded-full shadow-borders-base flex items-center justify-center">
<div className="size-1.5 rounded-full bg-ui-tag-neutral-icon" />
</div>
</div>
{!isFirst && (
<div className="flex flex-1 items-center justify-center">
<div className="h-full w-px bg-ui-border-base" />
</div>
)}
</div>
<div className={clx("flex flex-col", !isFirst && "pb-4")}>
<div className="flex items-center gap-x-2 justify-between">
<Text size="small" weight="plus" leading="compact">
{item.label}
</Text>
<Tooltip
content={getFullDate({ date: item.timestamp, includeTime: true })}
>
<Text size="small" leading="compact" className="cursor-default">
{getRelativeDate(item.timestamp)}
</Text>
</Tooltip>
</div>
{item.content && renderContent(item.content)}
{item.userId && (
<div className="pt-2 text-ui-fg-muted">
{isUserLoaded ? (
<Link to={`/settings/users/${user.id}`} className="w-fit">
<div className="flex items-center gap-x-1.5 w-fit">
<Text size="small">By</Text>
<Avatar
size="2xsmall"
fallback={[user.first_name, user.last_name]
.filter(Boolean)
.join("")
.slice(0, 1)}
/>
<Text size="small">
{user.first_name} {user.last_name}
</Text>
</div>
</Link>
) : (
<div className="flex items-center gap-x-1.5">
<Text size="small">By</Text>
<Skeleton className="rounded-full w-5 h-5" />
<Skeleton className="w-[75px] h-4" />
</div>
)}
</div>
)}
</div>
</div>
)
}
function renderContent(content: ReactNode) {
if (typeof content === "string") {
return (
<Text size="small" className="text-ui-fg-subtle">
{content}
</Text>
)
}
return content
}
function getEditActivityItems(
change: HttpTypes.AdminOrderChange
): ActivityItem[] {
const activityItems: ActivityItem[] = []
const counts = {
itemsAdded: 0,
itemsRemoved: 0,
shippingMethodsAdded: 0,
shippingMethodsRemoved: 0,
promotionsAdded: 0,
promotionsRemoved: 0,
}
for (const action of change.actions) {
if (!action.details) {
continue
}
switch (action.action) {
case "ITEM_ADD":
counts.itemsAdded += action.details.quantity as number
break
case "ITEM_UPDATE":
const diff = action.details.quantity_diff as number
diff > 0 ? (counts.itemsAdded += diff) : (counts.itemsRemoved += diff)
break
case "SHIPPING_ADD":
counts.shippingMethodsAdded += 1
break
case "SHIPPING_REMOVE":
counts.shippingMethodsRemoved += 1
break
case "PROMOTION_ADD":
counts.promotionsAdded += 1
break
case "PROMOTION_REMOVE":
counts.promotionsRemoved += 1
break
}
}
const createActivityItem = (
type: "items" | "shipping" | "promotions",
added: number,
removed: number
) => {
if (added === 0 && removed === 0) return
const getText = (count: number, singular: string, plural: string) =>
count === 1 ? `${count} ${singular}` : `${count} ${plural}`
const addedText = getText(
added,
type === "items"
? "item"
: type === "shipping"
? "shipping method"
: "promotion",
type === "items"
? "items"
: type === "shipping"
? "shipping methods"
: "promotions"
)
const removedText = getText(
Math.abs(removed),
type === "items"
? "item"
: type === "shipping"
? "shipping method"
: "promotion",
type === "items"
? "items"
: type === "shipping"
? "shipping methods"
: "promotions"
)
const content =
added && removed
? `Added ${addedText}, removed ${removedText}`
: added
? `Added ${addedText}`
: `Removed ${removedText}`
const label =
added && removed
? `${
type === "items"
? "Items"
: type === "shipping"
? "Shipping methods"
: "Promotions"
} updated`
: added
? `${
type === "items"
? "Items"
: type === "shipping"
? "Shipping methods"
: "Promotions"
} added`
: `${
type === "items"
? "Items"
: type === "shipping"
? "Shipping methods"
: "Promotions"
} removed`
activityItems.push({
label,
content,
timestamp: new Date(change.created_at).toISOString(),
userId: change.confirmed_by,
})
}
createActivityItem("items", counts.itemsAdded, counts.itemsRemoved)
createActivityItem(
"shipping",
counts.shippingMethodsAdded,
counts.shippingMethodsRemoved
)
createActivityItem(
"promotions",
counts.promotionsAdded,
counts.promotionsRemoved
)
return activityItems
}
function getTransferActivityItem(change: HttpTypes.AdminOrderChange) {
return {
label: "Transferred",
content: "Draft order transferred",
timestamp: new Date(change.created_at).toISOString(),
}
}
function getUpdateOrderActivityItem(change: HttpTypes.AdminOrderChange) {
const { details } = change.actions?.[0] || {}
if (!details) {
return null
}
switch (details.type) {
case "customer_id":
return {
label: "Customer updated",
timestamp: new Date(change.created_at).toISOString(),
userId: change.confirmed_by,
}
case "sales_channel_id":
return {
label: "Sales channel updated",
timestamp: new Date(change.created_at).toISOString(),
userId: change.confirmed_by,
}
case "billing_address":
return {
label: "Billing address updated",
timestamp: new Date(change.created_at).toISOString(),
userId: change.confirmed_by,
}
case "shipping_address":
return {
label: "Shipping address updated",
timestamp: new Date(change.created_at).toISOString(),
userId: change.confirmed_by,
}
case "email":
return {
label: "Email updated",
timestamp: new Date(change.created_at).toISOString(),
userId: change.confirmed_by,
}
default:
return null
}
}
function getActivityItems(
order: HttpTypes.AdminOrder,
changes: HttpTypes.AdminOrderChange[]
) {
const items: ActivityItem[] = []
if (order.created_at) {
items.push({
label: "Created",
content: "Draft order created",
timestamp: new Date(order.created_at).toISOString(),
})
}
changes.forEach((change) => {
if (!change.change_type || !change.confirmed_at) {
return
}
switch (change.change_type) {
case "edit": {
items.push(...getEditActivityItems(change))
break
}
case "transfer":
items.push(getTransferActivityItem(change))
break
case "update_order": {
const item = getUpdateOrderActivityItem(change)
if (item) {
items.push(item)
}
break
}
default:
break
}
})
return items.sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
)
}

View File

@@ -0,0 +1,211 @@
import { ArrowPath, CurrencyDollar, Envelope, FlyingBox } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Avatar, Container, Copy, Heading, Text } from "@medusajs/ui"
import { Link } from "react-router-dom"
import {
getFormattedAddress,
isSameAddress,
} from "../../lib/utils/address-utils"
import { getOrderCustomer } from "../../lib/utils/order-utils"
import { ActionMenu } from "../common/action-menu"
interface CustomerSectionProps {
order: HttpTypes.AdminOrder
}
export const CustomerSection = ({ order }: CustomerSectionProps) => {
return (
<Container className="p-0 divide-y">
<Header />
<ID order={order} />
<Contact order={order} />
<Addresses order={order} />
</Container>
)
}
const Header = () => {
return (
<div className="flex items-center justify-between px-6 py-4 gap-2">
<Heading level="h2">Customer</Heading>
<ActionMenu
groups={[
{
actions: [
{
label: "Transfer ownership",
to: "transfer-ownership",
icon: <ArrowPath />,
},
],
},
{
actions: [
{
label: "Edit shipping address",
to: "shipping-address",
icon: <FlyingBox />,
},
{
label: "Edit billing address",
to: "billing-address",
icon: <CurrencyDollar />,
},
],
},
{
actions: [
{
label: "Edit email",
to: `email`,
icon: <Envelope />,
},
],
},
]}
/>
</div>
)
}
const ID = ({ order }: CustomerSectionProps) => {
const id = order.customer_id
const name = getOrderCustomer(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">
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">
<Avatar size="2xsmall" fallback={fallback} />
<div className="flex flex-1 overflow-hidden">
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle hover:text-ui-fg-base transition-fg truncate"
>
{name || email}
</Text>
</div>
</div>
</Link>
</div>
)
}
const Contact = ({ order }: CustomerSectionProps) => {
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">
Contact
</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:
| HttpTypes.AdminOrder["shipping_address"]
| HttpTypes.AdminOrder["billing_address"]
type: "shipping" | "billing"
}) => {
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" ? "Shipping address" : "Billing address"}
</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 }: CustomerSectionProps) => {
return (
<div className="divide-y">
<AddressPrint address={order.shipping_address} type="shipping" />
{!isSameAddress(order.shipping_address, order.billing_address) ? (
<AddressPrint 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"
>
Billing address
</Text>
<Text size="small" leading="compact" className="text-ui-fg-muted">
Same as shipping address
</Text>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,91 @@
import { Channels, Trash } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import {
Badge,
Container,
Copy,
Heading,
Skeleton,
Text,
toast,
} from "@medusajs/ui"
import { Link, useNavigate } from "react-router-dom"
import { useRegion } from "../../hooks/api/regions"
import { getFullDate } from "../../lib/utils/date-utils"
import { ActionMenu } from "../common/action-menu"
import { useDeleteDraftOrder } from "../../hooks/api/draft-orders"
interface GeneralSectionProps {
order: HttpTypes.AdminOrder
}
export const GeneralSection = ({ order }: GeneralSectionProps) => {
const navigate = useNavigate()
const { mutateAsync: deleteDraftOrder, isPending: isDeleting } =
useDeleteDraftOrder()
const { region, isPending, isError, error } = useRegion(
order.region_id!,
undefined,
{
enabled: !!order.region_id,
}
)
const isRegionLoaded = !!region && !isPending
if (isError) {
throw error
}
return (
<Container className="flex items-center justify-between gap-4">
<div>
<div className="flex items-center gap-x-2">
<Heading>Draft Order #{order.display_id}</Heading>
<Copy content={`#${order.display_id}`} />
{isRegionLoaded ? (
<Badge size="2xsmall" rounded="full" asChild>
<Link to={`/settings/regions/${region?.id}`}>{region?.name}</Link>
</Badge>
) : (
<Skeleton className="w-14 h-5 rounded-full" />
)}
</div>
<Text size="small" className="text-ui-fg-subtle">
{`${getFullDate({
date: order.created_at,
includeTime: true,
})} from ${order.sales_channel?.name}`}
</Text>
</div>
<ActionMenu
groups={[
{
actions: [
{
label: "Edit sales channel",
icon: <Channels />,
to: "sales-channel",
},
{
label: "Delete draft order",
icon: <Trash />,
onClick: async () => {
try {
await deleteDraftOrder(order.id)
navigate("/draft-orders")
} catch (error: any) {
toast.error(error.message)
}
},
disabled: isDeleting,
},
],
},
]}
/>
</Container>
)
}

View File

@@ -0,0 +1,193 @@
import {
ArrowUpRightOnBox,
Check,
SquareTwoStack,
TriangleDownMini,
XMarkMini,
} from "@medusajs/icons"
import {
Badge,
Container,
Drawer,
Heading,
IconButton,
Kbd,
} from "@medusajs/ui"
import Primitive from "@uiw/react-json-view"
import { CSSProperties, MouseEvent, Suspense, useState } from "react"
type JsonViewSectionProps = {
data: object
title?: string
}
export const JsonViewSection = ({ data }: JsonViewSectionProps) => {
const numberOfKeys = Object.keys(data).length
return (
<Container className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-x-4">
<Heading level="h2">JSON</Heading>
<Badge size="2xsmall" rounded="full">
{`${numberOfKeys} ${numberOfKeys === 1 ? "key" : "keys"}`}
</Badge>
</div>
<Drawer>
<Drawer.Trigger asChild>
<IconButton
size="small"
variant="transparent"
className="text-ui-fg-muted hover:text-ui-fg-subtle"
>
<ArrowUpRightOnBox />
</IconButton>
</Drawer.Trigger>
<Drawer.Content className="bg-ui-contrast-bg-base text-ui-code-fg-subtle !shadow-elevation-commandbar overflow-hidden border border-none max-md:inset-x-2 max-md:max-w-[calc(100%-16px)]">
<div className="bg-ui-code-bg-base flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-x-4">
<Drawer.Title asChild>
<Heading className="text-ui-contrast-fg-primary">
JSON{" "}
<span key="count-span" className="text-ui-fg-subtle">
{numberOfKeys}
</span>
</Heading>
</Drawer.Title>
<Drawer.Description className="sr-only">
View the JSON representation of the draft order.
</Drawer.Description>
</div>
<div className="flex items-center gap-x-2">
<Kbd className="bg-ui-contrast-bg-subtle border-ui-contrast-border-base text-ui-contrast-fg-secondary">
esc
</Kbd>
<Drawer.Close asChild>
<IconButton
size="small"
variant="transparent"
className="text-ui-contrast-fg-secondary hover:text-ui-contrast-fg-primary hover:bg-ui-contrast-bg-base-hover active:bg-ui-contrast-bg-base-pressed focus-visible:bg-ui-contrast-bg-base-hover focus-visible:shadow-borders-interactive-with-active"
>
<XMarkMini />
</IconButton>
</Drawer.Close>
</div>
</div>
<Drawer.Body className="flex flex-1 flex-col overflow-hidden px-[5px] py-0 pb-[5px]">
<div className="bg-ui-contrast-bg-subtle flex-1 overflow-auto rounded-b-[4px] rounded-t-lg p-3">
<Suspense
fallback={<div className="flex size-full flex-col"></div>}
>
<Primitive
value={data}
displayDataTypes={false}
style={
{
"--w-rjv-font-family": "Roboto Mono, monospace",
"--w-rjv-line-color": "var(--contrast-border-base)",
"--w-rjv-curlybraces-color":
"var(--contrast-fg-secondary)",
"--w-rjv-brackets-color": "var(--contrast-fg-secondary)",
"--w-rjv-key-string": "var(--contrast-fg-primary)",
"--w-rjv-info-color": "var(--contrast-fg-secondary)",
"--w-rjv-type-string-color": "var(--tag-green-icon)",
"--w-rjv-quotes-string-color": "var(--tag-green-icon)",
"--w-rjv-type-boolean-color": "var(--tag-orange-icon)",
"--w-rjv-type-int-color": "var(--tag-orange-icon)",
"--w-rjv-type-float-color": "var(--tag-orange-icon)",
"--w-rjv-type-bigint-color": "var(--tag-orange-icon)",
"--w-rjv-key-number": "var(--contrast-fg-secondary)",
"--w-rjv-arrow-color": "var(--contrast-fg-secondary)",
"--w-rjv-copied-color": "var(--contrast-fg-secondary)",
"--w-rjv-copied-success-color":
"var(--contrast-fg-primary)",
"--w-rjv-colon-color": "var(--contrast-fg-primary)",
"--w-rjv-ellipsis-color": "var(--contrast-fg-secondary)",
} as CSSProperties
}
collapsed={1}
>
<Primitive.Quote render={() => <span />} />
<Primitive.Null
render={() => (
<span className="text-ui-tag-red-icon">null</span>
)}
/>
<Primitive.Undefined
render={() => (
<span className="text-ui-tag-blue-icon">undefined</span>
)}
/>
<Primitive.CountInfo
render={(_props, { value }) => {
return (
<span className="text-ui-contrast-fg-secondary ml-2">
{`${Object.keys(value as object).length} ${
Object.keys(value as object).length === 1
? "key"
: "keys"
}`}
</span>
)
}}
/>
<Primitive.Arrow>
<TriangleDownMini className="text-ui-contrast-fg-secondary -ml-[0.5px]" />
</Primitive.Arrow>
<Primitive.Colon>
<span className="mr-1">:</span>
</Primitive.Colon>
<Primitive.Copied
render={({ style }, { value }) => {
return <Copied style={style} value={value} />
}}
/>
</Primitive>
</Suspense>
</div>
</Drawer.Body>
</Drawer.Content>
</Drawer>
</Container>
)
}
type CopiedProps = {
style?: CSSProperties
value: object | undefined
}
const Copied = ({ style, value }: CopiedProps) => {
const [copied, setCopied] = useState(false)
const handler = (e: MouseEvent<HTMLSpanElement>) => {
e.stopPropagation()
setCopied(true)
if (typeof value === "string") {
navigator.clipboard.writeText(value)
} else {
const json = JSON.stringify(value, null, 2)
navigator.clipboard.writeText(json)
}
setTimeout(() => {
setCopied(false)
}, 2000)
}
const styl = { whiteSpace: "nowrap", width: "20px" }
if (copied) {
return (
<span style={{ ...style, ...styl }}>
<Check className="text-ui-contrast-fg-primary" />
</span>
)
}
return (
<span style={{ ...style, ...styl }} onClick={handler}>
<SquareTwoStack className="text-ui-contrast-fg-secondary" />
</span>
)
}

View File

@@ -0,0 +1,31 @@
import { ArrowUpRightOnBox } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Badge, Container, Heading, IconButton } from "@medusajs/ui"
import { Link } from "react-router-dom"
interface MetadataSectionProps {
order: HttpTypes.AdminOrder
}
export const MetadataSection = ({ order }: MetadataSectionProps) => {
return (
<Container className="flex items-center justify-between">
<div className="flex items-center gap-x-2">
<Heading level="h2">Metadata</Heading>
<Badge size="2xsmall" rounded="full">
{Object.keys(order.metadata || {}).length} keys
</Badge>
</div>
<IconButton
variant="transparent"
size="small"
className="text-ui-fg-muted hover:text-ui-fg-subtle"
asChild
>
<Link to="metadata">
<ArrowUpRightOnBox />
</Link>
</IconButton>
</Container>
)
}

View File

@@ -0,0 +1,400 @@
import {
Buildings,
Shopping,
TriangleRightMini,
TruckFast,
} from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import {
Badge,
Button,
clx,
Container,
Divider,
Heading,
IconButton,
StatusBadge,
Text,
Tooltip,
} from "@medusajs/ui"
import { Accordion } from "radix-ui"
import { Link } from "react-router-dom"
import { useShippingOptions } from "../../hooks/api/shipping-options"
import { getUniqueShippingProfiles } from "../../lib/utils/order-utils"
import { pluralize } from "../../lib/utils/string-utils"
import { Thumbnail } from "../common/thumbnail"
import { useMemo } from "react"
interface ShippingSectionProps {
order: HttpTypes.AdminOrder
}
export const ShippingSection = ({ order }: ShippingSectionProps) => {
const orderHasShipping = order.shipping_methods.length > 0
const {
shipping_options = [],
isPending,
isError,
error,
} = useShippingOptions(
{
id: order.shipping_methods
.map((method) => method.shipping_option_id)
.filter(Boolean) as string[],
fields:
"+service_zone.*,+service_zone.fulfillment_set.*,+service_zone.fulfillment_set.location.*,shipping_profile.*",
},
{
enabled: orderHasShipping,
}
)
if (isError) {
throw error
}
const ready = !orderHasShipping ? true : shipping_options && !isPending
const data = useMemo(() => {
const shippingProfilesData = getShippingProfileData(order.items)
// shipping profiles of the items on the order
const profileIdMap = new Map<string, boolean>()
shippingProfilesData.forEach((profile) => {
profileIdMap.set(profile.id, true)
})
// shipping profiles of the shipping methods on the order
const uniqueProfilesOfShippingMethods = Array.from(
new Set(shipping_options.map((option) => option.shipping_profile_id))
)
// prepare data of shipping profiles that are handed by the order shipping methods but not on needed by the order items
const additionalShippingProfilesData = uniqueProfilesOfShippingMethods
.filter((id) => !profileIdMap.has(id))
.map((id) => ({
id,
name:
shipping_options.find((option) => option.shipping_profile_id === id)
?.shipping_profile?.name || "",
items: [],
}))
const shippingProfileDisplayData = [
...shippingProfilesData,
...additionalShippingProfilesData,
]
return shippingProfileDisplayData.sort((a, b) =>
a.name.localeCompare(b.name)
)
}, [order.items, shipping_options])
const isSomeProfilesAssigned = data.some((profile) =>
shipping_options?.find(
(option) => option.shipping_profile_id === profile.id
)
)
return (
<Container className="p-0 overflow-hidden">
<div className="px-6 py-4">
<Heading>Shipping</Heading>
</div>
<Divider variant="dashed" />
<Accordion.Root type="multiple">
{ready &&
data.map((profile, idx) => (
<div key={profile.id}>
{renderShippingProfile(
profile,
shipping_options,
idx === data.length - 1
)}
</div>
))}
</Accordion.Root>
<Divider variant="dashed" />
<Footer isSomeProfilesAssigned={isSomeProfilesAssigned} />
</Container>
)
}
function renderShippingProfile(
profile: ShippingProfileData,
shippingOptions: HttpTypes.AdminShippingOption[],
isLast: boolean
) {
const shippingOption = shippingOptions.find(
(option) => option.shipping_profile_id === profile.id
)
if (shippingOption) {
return (
<ProfileWithShipping
profile={profile}
shippingOption={shippingOption}
isLast={isLast}
/>
)
}
return <ProfileWithoutShipping profile={profile} isLast={isLast} />
}
interface ProfileWithShipping {
profile: ShippingProfileData
shippingOption: HttpTypes.AdminShippingOption
isLast?: boolean
}
const ProfileWithShipping = ({
profile,
shippingOption,
isLast,
}: ProfileWithShipping) => {
const hasItems = profile.items.length > 0
return (
<div>
<Accordion.Item value={profile.id}>
<div className="flex items-center px-6 py-4 justify-between gap-x-3">
<div className="flex items-center gap-x-3 max-sm:items-start">
<Accordion.Trigger asChild>
<IconButton
size="2xsmall"
variant="transparent"
className="group/trigger"
disabled={!hasItems}
>
<TriangleRightMini className="group-data-[state=open]/trigger:rotate-90 transition-transform" />
</IconButton>
</Accordion.Trigger>
<div className="flex items-center gap-[5px] max-sm:flex-col max-sm:items-start flex-1 w-full overflow-hidden">
<Tooltip
content={
<ul>
{profile.items.map((item) => (
<li
key={item.id}
>{`${item.quantity}x ${item.variant?.product?.title} (${item.variant?.title})`}</li>
))}
</ul>
}
>
<Badge
className="flex items-center gap-x-[3px] overflow-hidden cursor-default"
size="xsmall"
>
<Shopping className="shrink-0" />
<span className="truncate">
{profile.items.reduce(
(acc, item) => acc + item.quantity,
0
)}
x {pluralize(profile.items.length, "items", "item")}
</span>
</Badge>
</Tooltip>
<Tooltip
content={
shippingOption.service_zone?.fulfillment_set?.location?.name
}
>
<Badge
className="flex items-center gap-x-[3px] overflow-hidden cursor-default"
size="xsmall"
>
<Buildings className="shrink-0" />
<span className="truncate">
{
shippingOption.service_zone?.fulfillment_set?.location
?.name
}
</span>
</Badge>
</Tooltip>
<Tooltip content={shippingOption.name}>
<Badge
className="flex items-center gap-x-[3px] overflow-hidden cursor-default"
size="xsmall"
>
<TruckFast className="shrink-0" />
<span className="truncate">{shippingOption.name}</span>
</Badge>
</Tooltip>
</div>
</div>
</div>
<ShippingProfileItems profile={profile} />
</Accordion.Item>
{!isLast && <Divider variant="dashed" />}
</div>
)
}
interface ProfileWithoutShippingProps {
profile: ShippingProfileData
isLast?: boolean
}
const ProfileWithoutShipping = ({
profile,
isLast,
}: ProfileWithoutShippingProps) => {
return (
<div>
<Accordion.Item value={profile.id}>
<div className="flex items-center gap-x-3 justify-between px-6 py-4">
<div className="flex items-center gap-x-3">
<Accordion.Trigger asChild>
<IconButton
size="2xsmall"
variant="transparent"
className="group/trigger"
>
<TriangleRightMini className="group-data-[state=open]/trigger:rotate-90 transition-transform" />
</IconButton>
</Accordion.Trigger>
<div className="flex-1 flex items-center gap-x-3">
<ShippingBadge />
<div>
<Text size="small" weight="plus" leading="compact">
{profile.name}
</Text>
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
{`${profile.items.length} ${pluralize(
profile.items.length,
"item",
"items"
)}`}
</Text>
</div>
</div>
</div>
<div>
<StatusBadge color="orange">Requires shipping</StatusBadge>
</div>
</div>
<ShippingProfileItems profile={profile} />
</Accordion.Item>
{!isLast && <Divider variant="dashed" />}
</div>
)
}
interface ShippingProfileItemsProps {
profile: ShippingProfileData
}
const ShippingProfileItems = ({ profile }: ShippingProfileItemsProps) => {
return (
<Accordion.Content>
<Divider variant="dashed" />
{profile.items.map((item, idx) => {
return (
<div key={item.id}>
<div className="px-6 flex items-center gap-x-3" key={item.id}>
<div className="w-5 h-[72px] flex flex-col justify-center items-center">
<Divider variant="dashed" orientation="vertical" />
</div>
<div className="py-4 flex items-center gap-x-3">
<div className="size-7 flex items-center justify-center tabular-nums">
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
{item.quantity}x
</Text>
</div>
<Thumbnail thumbnail={item.thumbnail} />
<div>
<Text size="small" leading="compact" weight="plus">
{item.variant?.product?.title} ({item.variant?.title})
</Text>
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
{item.variant?.options
?.map((option) => option.value)
.join(" · ")}
</Text>
</div>
</div>
</div>
{idx !== profile.items.length - 1 && <Divider variant="dashed" />}
</div>
)
})}
</Accordion.Content>
)
}
interface FooterProps {
isSomeProfilesAssigned: boolean
}
const Footer = ({ isSomeProfilesAssigned }: FooterProps) => {
return (
<div className="px-6 py-4 flex items-center justify-end bg-ui-bg-component">
<Button size="small" variant="secondary" asChild>
<Link to="shipping">
{isSomeProfilesAssigned ? "Edit shipping" : "Add shipping"}
</Link>
</Button>
</div>
)
}
interface ShippingBadgeProps {
className?: string
}
const ShippingBadge = ({ className }: ShippingBadgeProps) => {
return (
<div
className={clx(
"size-7 rounded-md shadow-borders-base flex items-center justify-center",
className
)}
>
<div className="size-6 rounded bg-ui-bg-component-hover flex items-center justify-center">
<Shopping className="text-ui-fg-subtle" />
</div>
</div>
)
}
interface ShippingProfileData {
id: string
name: string
items: HttpTypes.AdminOrderLineItem[]
}
function getShippingProfileData(
items: HttpTypes.AdminOrderLineItem[]
): ShippingProfileData[] {
const uniqueShippingProfiles = getUniqueShippingProfiles(items)
const output = uniqueShippingProfiles.map((profile) => {
return {
id: profile.id,
name: profile.name,
items: items.filter(
(item) => item.variant?.product?.shipping_profile?.id === profile.id
),
}
})
return output
}

View File

@@ -0,0 +1,288 @@
import { Plus, ReceiptPercent } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import {
Button,
clx,
Container,
Divider,
Heading,
Text,
toast,
usePrompt,
} from "@medusajs/ui"
import { Link, useNavigate } from "react-router-dom"
import { useConvertDraftOrder } from "../../hooks/api/draft-orders"
import { getLocaleAmount, getStylizedAmount } from "../../lib/data/currencies"
import { ActionMenu } from "../common/action-menu"
import { Thumbnail } from "../common/thumbnail"
interface SummarySectionProps {
order: HttpTypes.AdminOrder & {
promotions: HttpTypes.AdminPromotion[]
}
}
export const SummarySection = ({ order }: SummarySectionProps) => {
const promotions: HttpTypes.AdminPromotion[] | null = order.promotions || []
return (
<Container className="p-0 overflow-hidden">
<div className="px-6 py-4 flex items-center justify-between gap-x-4">
<Heading>Summary</Heading>
<ActionMenu
groups={[
{
actions: [
{
label: "Edit items",
icon: <Plus />,
to: "items",
},
],
},
{
actions: [
{
label: "Edit promotions",
icon: <ReceiptPercent />,
to: "promotions",
},
],
},
]}
/>
</div>
<Divider variant="dashed" />
<div>
{order.items
.sort((a, b) => {
// sort the items so items with no variant id go last
if (a.variant_id && !b.variant_id) {
return -1
}
return 1
})
.map((item, idx) => (
<div key={item.id}>
<Item item={item} currencyCode={order.currency_code} />
{idx !== order.items.length - 1 && <Divider variant="dashed" />}
</div>
))}
</div>
<Divider variant="dashed" />
<Total
currencyCode={order.currency_code}
total={order.total}
shippingSubtotal={order.shipping_subtotal}
discountTotal={order.discount_total}
promotions={promotions}
taxTotal={order.tax_total}
itemSubTotal={order.item_subtotal}
itemCount={
order.items?.reduce((acc, item) => acc + item.quantity, 0) || 0
}
/>
<Divider variant="dashed" />
<Footer order={order} />
</Container>
)
}
interface ItemProps {
item: HttpTypes.AdminOrderLineItem
currencyCode: string
}
const Item = ({ item, currencyCode }: ItemProps) => {
return (
<div className="px-6 py-4 grid grid-cols-2 gap-3">
<div className="flex items-center gap-x-3">
{/* Only display a thumbnail for non-custom items */}
{item.variant_id && (
<Thumbnail thumbnail={item.thumbnail} alt={item.title} />
)}
<div>
<div className="flex items-center gap-x-1">
<Text size="small" weight="plus" leading="compact">
{item.product_title || item.title}
</Text>
{item.variant_title && (
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
({item.variant_title})
</Text>
)}
</div>
<Text size="small" leading="compact" className="text-ui-fg-subtle">
{item.variant_sku}
</Text>
</div>
</div>
<div className="grid grid-cols-3 items-center gap-3 [&>div]:text-right text-ui-fg-subtle">
<div>
<Text>{getLocaleAmount(item.unit_price, currencyCode)}</Text>
</div>
<div>
<Text>{item.quantity}x</Text>
</div>
<div>
<Text>{getLocaleAmount(item.subtotal, currencyCode)}</Text>
</div>
</div>
</div>
)
}
interface TotalProps {
total: number
shippingSubtotal: number | null
discountTotal: number | null
promotions: HttpTypes.AdminPromotion[]
taxTotal: number | null
currencyCode: string
itemSubTotal: number
itemCount: number
}
const Total = ({
total,
discountTotal,
shippingSubtotal,
taxTotal,
currencyCode,
promotions,
itemSubTotal,
itemCount,
}: TotalProps) => {
return (
<div className="flex flex-col px-6 py-4 gap-y-2">
{itemCount > 0 && (
<div className="grid grid-cols-3 items-center justify-between gap-x-4 text-ui-fg-subtle">
<Text size="small" leading="compact">
Subtotal
</Text>
<div className="flex items-center justify-end">
<Text size="small" leading="compact">
{`${itemCount} ${itemCount === 1 ? "item" : "items"}`}
</Text>
</div>
<div className="flex items-center justify-end">
<Text size="small" leading="compact">
{getLocaleAmount(itemSubTotal, currencyCode)}
</Text>
</div>
</div>
)}
{shippingSubtotal !== null && (
<div className="flex items-center justify-between gap-x-4 text-ui-fg-subtle">
<Text size="small" leading="compact">
Shipping
</Text>
<Text size="small" leading="compact">
{getLocaleAmount(shippingSubtotal, currencyCode)}
</Text>
</div>
)}
{discountTotal !== null && (
<div
className={clx(
"grid grid-cols-2 items-center gap-x-4 text-ui-fg-subtle",
{
"grid-cols-3": !!promotions,
}
)}
>
<Text size="small" leading="compact">
Discount
</Text>
<div className="flex items-center justify-end gap-x-2">
{promotions.map((promotion) => (
<Link to={`/promotions/${promotion.id}`} key={promotion.id}>
<Text size="small" leading="compact">
{promotion.code}
</Text>
</Link>
))}
</div>
<div className="flex items-center justify-end">
<Text size="small" leading="compact">
{getLocaleAmount(discountTotal, currencyCode)}
</Text>
</div>
</div>
)}
{taxTotal !== null && (
<div className="flex items-center justify-between gap-x-4 text-ui-fg-subtle">
<Text size="small" leading="compact">
Tax
</Text>
<Text size="small" leading="compact">
{taxTotal > 0 ? getLocaleAmount(taxTotal, currencyCode) : "-"}
</Text>
</div>
)}
<div className="flex items-center justify-between gap-x-4">
<Text size="small" leading="compact" weight="plus">
Total
</Text>
<Text
size="small"
leading="compact"
weight="plus"
className="text-right"
>
{getStylizedAmount(total, currencyCode)}
</Text>
</div>
</div>
)
}
const Footer = ({ order }: { order: HttpTypes.AdminOrder }) => {
const navigate = useNavigate()
const prompt = usePrompt()
const { mutateAsync: convertDraftOrder, isPending } = useConvertDraftOrder(
order.id
)
const handleConvert = async () => {
const res = await prompt({
title: "Are you sure?",
description:
"You are about to convert this draft order to an order. This action cannot be undone.",
variant: "confirmation",
})
if (!res) {
return
}
await convertDraftOrder(undefined, {
onSuccess: () => {
toast.success("Draft order converted to order")
navigate(`/orders/${order.id}`)
},
onError: (error) => {
toast.error(error.message)
},
})
}
return (
<div className="px-6 py-4 flex items-center justify-end gap-x-2 bg-ui-bg-component">
<Button
size="small"
variant="secondary"
isLoading={isPending}
onClick={handleConvert}
>
Convert to order
</Button>
</div>
)
}

View File

@@ -0,0 +1,398 @@
import {
Combobox as PrimitiveCombobox,
ComboboxDisclosure as PrimitiveComboboxDisclosure,
ComboboxItem as PrimitiveComboboxItem,
ComboboxItemCheck as PrimitiveComboboxItemCheck,
ComboboxItemValue as PrimitiveComboboxItemValue,
ComboboxPopover as PrimitiveComboboxPopover,
ComboboxProvider as PrimitiveComboboxProvider,
Separator as PrimitiveSeparator,
} from "@ariakit/react"
import {
CheckMini,
EllipseMiniSolid,
PlusMini,
TrianglesMini,
XMarkMini,
} from "@medusajs/icons"
import { clx, Text } from "@medusajs/ui"
import { matchSorter } from "match-sorter"
import {
ComponentPropsWithoutRef,
CSSProperties,
ForwardedRef,
Fragment,
ReactNode,
useCallback,
useDeferredValue,
useImperativeHandle,
useMemo,
useRef,
useState,
useTransition,
} from "react"
import { genericForwardRef } from "../utilities/generic-forward-ref"
type ComboboxOption = {
value: string
label: string
disabled?: boolean
}
type Value = string[] | string
const TABLUAR_NUM_WIDTH = 8
const TAG_BASE_WIDTH = 28
interface ComboboxProps<T extends Value = Value>
extends Omit<ComponentPropsWithoutRef<"input">, "onChange" | "value"> {
value?: T
onChange?: (value?: T) => void
searchValue?: string
onSearchValueChange?: (value: string) => void
options: ComboboxOption[]
fetchNextPage?: () => void
isFetchingNextPage?: boolean
onCreateOption?: (value: string) => void
noResultsPlaceholder?: ReactNode
}
const ComboboxImpl = <T extends Value = string>(
{
value: controlledValue,
onChange,
searchValue: controlledSearchValue,
onSearchValueChange,
options,
className,
placeholder,
fetchNextPage,
isFetchingNextPage,
onCreateOption,
noResultsPlaceholder,
...inputProps
}: ComboboxProps<T>,
ref: ForwardedRef<HTMLInputElement>
) => {
const [open, setOpen] = useState(false)
const [isPending, startTransition] = useTransition()
const comboboxRef = useRef<HTMLInputElement>(null)
const listboxRef = useRef<HTMLDivElement>(null)
useImperativeHandle(ref, () => comboboxRef.current!)
const isValueControlled = controlledValue !== undefined
const isSearchControlled = controlledSearchValue !== undefined
const isArrayValue = Array.isArray(controlledValue)
const emptyState = (isArrayValue ? [] : "") as T
const [uncontrolledSearchValue, setUncontrolledSearchValue] = useState(
controlledSearchValue || ""
)
const defferedSearchValue = useDeferredValue(uncontrolledSearchValue)
const [uncontrolledValue, setUncontrolledValue] = useState<T>(emptyState)
const searchValue = isSearchControlled
? controlledSearchValue
: uncontrolledSearchValue
const selectedValues = isValueControlled ? controlledValue : uncontrolledValue
const handleValueChange = (newValues?: T) => {
// check if the value already exists in options
const exists = options
.filter((o) => !o.disabled)
.find((o) => {
if (isArrayValue) {
return newValues?.includes(o.value)
}
return o.value === newValues
})
// If the value does not exist in the options, and the component has a handler
// for creating new options, call it.
if (!exists && onCreateOption && newValues) {
onCreateOption(newValues as string)
}
if (!isValueControlled) {
setUncontrolledValue(newValues || emptyState)
}
if (onChange) {
onChange(newValues)
}
setUncontrolledSearchValue("")
}
const handleSearchChange = (query: string) => {
setUncontrolledSearchValue(query)
if (onSearchValueChange) {
onSearchValueChange(query)
}
}
/**
* Filter and sort the options based on the search value,
* and whether the value is already selected.
*
* This is only used when the search value is uncontrolled.
*/
const matches = useMemo(() => {
if (isSearchControlled) {
return []
}
return matchSorter(options, defferedSearchValue, {
keys: ["label"],
})
}, [options, defferedSearchValue, 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 handleOpenChange = (open: boolean) => {
if (!open) {
setUncontrolledSearchValue("")
}
setOpen(open)
}
const hasValue = selectedValues?.length > 0
const showTag = hasValue && isArrayValue
const showSelected = showTag && !searchValue && !open
const hideInput = !isArrayValue && hasValue && !open
const selectedLabel = options.find((o) => o.value === selectedValues)?.label
const hidePlaceholder = hasValue || showSelected || open
const tagWidth = useMemo(() => {
if (!Array.isArray(selectedValues)) {
return TAG_BASE_WIDTH + TABLUAR_NUM_WIDTH // There can only be a single digit
}
const count = selectedValues.length
const digits = count.toString().length
return TAG_BASE_WIDTH + digits * TABLUAR_NUM_WIDTH
}, [selectedValues])
const results = useMemo(() => {
return isSearchControlled ? options : matches
}, [matches, options, isSearchControlled])
return (
<PrimitiveComboboxProvider
open={open}
setOpen={handleOpenChange}
selectedValue={selectedValues}
setSelectedValue={(value) => handleValueChange(value as T)}
value={uncontrolledSearchValue}
setValue={(query) => {
startTransition(() => handleSearchChange(query))
}}
>
<div
className={clx(
"relative flex cursor-pointer items-center gap-x-2 overflow-hidden",
"h-8 w-full rounded-md",
"bg-ui-bg-field transition-fg shadow-borders-base",
"has-[input:focus]:shadow-borders-interactive-with-active",
"has-[:invalid]:shadow-borders-error has-[[aria-invalid=true]]:shadow-borders-error",
"has-[:disabled]:bg-ui-bg-disabled has-[:disabled]:text-ui-fg-disabled has-[:disabled]:cursor-not-allowed",
className
)}
style={
{
"--tag-width": `${tagWidth}px`,
} as CSSProperties
}
>
{showTag && (
<button
type="button"
onClick={(e) => {
e.preventDefault()
handleValueChange(undefined)
}}
className="bg-ui-bg-base hover:bg-ui-bg-base-hover txt-compact-small-plus text-ui-fg-subtle focus-within:border-ui-fg-interactive transition-fg absolute left-0.5 top-0.5 z-[1] flex h-[28px] items-center rounded-[4px] border py-[3px] pl-1.5 pr-1 outline-none"
>
<span className="tabular-nums">{selectedValues.length}</span>
<XMarkMini className="text-ui-fg-muted" />
</button>
)}
<div className="relative flex size-full items-center">
{showSelected && (
<div
className={clx(
"pointer-events-none absolute inset-y-0 flex size-full items-center",
{
"left-[calc(var(--tag-width)+8px)]": showTag,
"left-2": !showTag,
}
)}
>
<Text size="small" leading="compact">
Selected
</Text>
</div>
)}
{hideInput && (
<div
className={clx(
"pointer-events-none absolute inset-y-0 flex size-full items-center overflow-hidden pr-10",
{
"left-[calc(var(--tag-width)+8px)]": showTag,
"left-2": !showTag,
}
)}
>
<Text size="small" leading="compact" className="truncate">
{selectedLabel}
</Text>
</div>
)}
<PrimitiveCombobox
autoSelect
ref={comboboxRef}
onFocus={() => setOpen(true)}
className={clx(
"txt-compact-small text-ui-fg-base !placeholder:text-ui-fg-muted transition-fg size-full cursor-pointer bg-transparent pl-2 pr-8 outline-none focus:cursor-text",
"hover:bg-ui-bg-field-hover",
{
"pl-2": !showTag,
"pl-[calc(var(--tag-width)+8px)]": showTag,
"opacity-0": hideInput,
}
)}
placeholder={hidePlaceholder ? undefined : placeholder}
{...inputProps}
/>
</div>
<PrimitiveComboboxDisclosure
render={(props) => {
return (
<button
{...props}
type="button"
className="text-ui-fg-muted transition-fg hover:bg-ui-bg-field-hover absolute right-0 flex size-8 items-center justify-center rounded-r outline-none"
>
<TrianglesMini />
</button>
)
}}
/>
</div>
<PrimitiveComboboxPopover
gutter={4}
sameWidth
ref={listboxRef}
role="listbox"
className={clx(
"shadow-elevation-flyout bg-ui-bg-base z-50 rounded-[8px] p-1",
"max-h-[200px] overflow-y-auto",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
)}
style={{
pointerEvents: open ? "auto" : "none",
}}
aria-busy={isPending}
>
{results.map(({ value, label, disabled }) => (
<PrimitiveComboboxItem
key={value}
value={value}
focusOnHover
setValueOnClick={false}
disabled={disabled}
className={clx(
"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",
{
"text-ui-fg-disabled": disabled,
"bg-ui-bg-component": disabled,
}
)}
>
<PrimitiveComboboxItemCheck className="flex !size-5 items-center justify-center">
{isArrayValue ? <CheckMini /> : <EllipseMiniSolid />}
</PrimitiveComboboxItemCheck>
<PrimitiveComboboxItemValue className="txt-compact-small">
{label}
</PrimitiveComboboxItemValue>
</PrimitiveComboboxItem>
))}
{!!fetchNextPage && <div ref={lastOptionRef} className="w-px" />}
{isFetchingNextPage && (
<div className="transition-fg bg-ui-bg-base flex items-center rounded-[4px] px-2 py-1.5">
<div className="bg-ui-bg-component size-full h-5 w-full animate-pulse rounded-[4px]" />
</div>
)}
{!results.length &&
(noResultsPlaceholder && !searchValue?.length ? (
noResultsPlaceholder
) : (
<div className="flex items-center gap-x-2 rounded-[4px] px-2 py-1.5">
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
No results found
</Text>
</div>
))}
{!results.length && onCreateOption && (
<Fragment>
<PrimitiveSeparator className="bg-ui-border-base -mx-1" />
<PrimitiveComboboxItem
value={uncontrolledSearchValue}
focusOnHover
setValueOnClick={false}
className="transition-fg bg-ui-bg-base data-[active-item=true]:bg-ui-bg-base-hover group mt-1 flex cursor-pointer items-center gap-x-2 rounded-[4px] px-2 py-1.5"
>
<PlusMini className="text-ui-fg-subtle" />
<Text size="small" leading="compact">
Create &quot;{searchValue}&quot;
</Text>
</PrimitiveComboboxItem>
</Fragment>
)}
</PrimitiveComboboxPopover>
</PrimitiveComboboxProvider>
)
}
export const Combobox = genericForwardRef(ComboboxImpl)

View File

@@ -0,0 +1,76 @@
import {
ComponentPropsWithoutRef,
forwardRef,
useImperativeHandle,
useRef,
} from "react"
import { TrianglesMini } from "@medusajs/icons"
import { clx } from "@medusajs/ui"
import { countries } from "../../lib/data/countries"
export const CountrySelect = forwardRef<
HTMLSelectElement,
ComponentPropsWithoutRef<"select"> & {
placeholder?: string
value?: string
defaultValue?: string
}
>(
(
{ className, disabled, placeholder, value, defaultValue, ...props },
ref
) => {
const innerRef = useRef<HTMLSelectElement>(null)
useImperativeHandle(ref, () => innerRef.current as HTMLSelectElement)
const isPlaceholder = innerRef.current?.value === ""
return (
<div className="relative">
<TrianglesMini
className={clx(
"text-ui-fg-muted transition-fg pointer-events-none absolute right-2 top-1/2 -translate-y-1/2",
{
"text-ui-fg-disabled": disabled,
}
)}
/>
<select
value={value !== undefined ? value.toLowerCase() : undefined}
defaultValue={defaultValue ? defaultValue.toLowerCase() : undefined}
disabled={disabled}
className={clx(
"bg-ui-bg-field shadow-buttons-neutral transition-fg txt-compact-small flex w-full select-none appearance-none items-center justify-between rounded-md px-2 py-1.5 outline-none",
"placeholder:text-ui-fg-muted text-ui-fg-base",
"hover:bg-ui-bg-field-hover",
"focus-visible:shadow-borders-interactive-with-active data-[state=open]:!shadow-borders-interactive-with-active",
"aria-[invalid=true]:border-ui-border-error aria-[invalid=true]:shadow-borders-error",
"invalid::border-ui-border-error invalid:shadow-borders-error",
"disabled:!bg-ui-bg-disabled disabled:!text-ui-fg-disabled",
{
"text-ui-fg-muted": isPlaceholder,
},
className
)}
{...props}
ref={innerRef}
>
{/* Add an empty option so the first option is preselected */}
<option value="" disabled className="text-ui-fg-muted">
{placeholder || "Select country"}
</option>
{countries.map((country) => {
return (
<option key={country.iso_2} value={country.iso_2.toLowerCase()}>
{country.display_name}
</option>
)
})}
</select>
</div>
)
}
)
CountrySelect.displayName = "CountrySelect"

View File

@@ -0,0 +1,126 @@
import { Minus, Plus } from "@medusajs/icons"
import { clx } from "@medusajs/ui"
import { forwardRef, InputHTMLAttributes } from "react"
type InputProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
"type" | "value" | "onChange" | "size"
>
interface NumberInputProps extends InputProps {
value: number
onChange: (value: number) => void
size?: "base" | "small"
min?: number
max?: number
step?: number
}
export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
(
{
value,
onChange,
size = "base",
min = 0,
max = 100,
step = 1,
className,
disabled,
...props
},
ref
) => {
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newValue =
event.target.value === "" ? min : Number(event.target.value)
if (
!isNaN(newValue) &&
(max === undefined || newValue <= max) &&
(min === undefined || newValue >= min)
) {
onChange(newValue)
}
}
const handleIncrement = () => {
const newValue = value + step
if (max === undefined || newValue <= max) {
onChange(newValue)
}
}
const handleDecrement = () => {
const newValue = value - step
if (min === undefined || newValue >= min) {
onChange(newValue)
}
}
return (
<div
className={clx(
"inline-flex rounded-md bg-ui-bg-field shadow-borders-base overflow-hidden divide-x transition-fg",
"[&:has(input:focus)]:shadow-borders-interactive-with-active",
{
"h-7": size === "small",
"h-8": size === "base",
},
className
)}
>
<input
ref={ref}
type="number"
value={value}
onChange={handleChange}
min={min}
max={max}
step={step}
className={clx(
"flex-1 px-2 py-1 bg-transparent txt-compact-small text-ui-fg-base outline-none [appearance:textfield]",
"[&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none",
"placeholder:text-ui-fg-muted"
)}
{...props}
/>
<button
className={clx(
"flex items-center justify-center outline-none transition-fg",
"disabled:cursor-not-allowed disabled:text-ui-fg-muted",
"focus:bg-ui-bg-field-component-hover",
"hover:bg-ui-bg-field-component-hover",
{
"size-7": size === "small",
"size-8": size === "base",
}
)}
type="button"
onClick={handleDecrement}
disabled={(min !== undefined && value <= min) || disabled}
>
<Minus />
<span className="sr-only">{`Decrease by ${step}`}</span>
</button>
<button
className={clx(
"flex items-center justify-center outline-none transition-fg",
"disabled:cursor-not-allowed disabled:text-ui-fg-muted",
"focus:bg-ui-bg-field-hover",
"hover:bg-ui-bg-field-hover",
{
"size-7": size === "small",
"size-8": size === "base",
}
)}
type="button"
onClick={handleIncrement}
disabled={(max !== undefined && value >= max) || disabled}
>
<Plus />
<span className="sr-only">{`Increase by ${step}`}</span>
</button>
</div>
)
}
)

View File

@@ -0,0 +1,39 @@
import { Switch } from "@medusajs/ui"
import type { Control, FieldValues, Path } from "react-hook-form"
import { Form } from "../common/form"
interface SwitchBlockProps<TValues extends FieldValues> {
label: string
description: string
name: Path<TValues>
control: Control<TValues>
}
export const SwitchBlock = <TValues extends FieldValues>(
props: SwitchBlockProps<TValues>
) => {
return (
<Form.Field
name={props.name}
control={props.control}
render={({ field: { value, onChange, ...field } }) => (
<Form.Item>
<div className="flex items-start gap-3 bg-ui-bg-component shadow-elevation-card-rest rounded-lg p-3">
<Form.Control>
<Switch
size="small"
checked={value}
onCheckedChange={onChange}
{...field}
/>
</Form.Control>
<div className="flex flex-col">
<Form.Label>{props.label}</Form.Label>
<Form.Hint>{props.description}</Form.Hint>
</div>
</div>
</Form.Item>
)}
/>
)
}

View File

@@ -0,0 +1,7 @@
export { RouteDrawer } from "./route-drawer/route-drawer"
export { RouteFocusModal } from "./route-focus-modal"
export { useRouteModal } from "./route-modal-provider"
export { StackedDrawer } from "./stacked-drawer"
export { StackedFocusModal } from "./stacked-focus-modal"
export { useStackedModal } from "./stacked-modal-provider"

View File

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

View File

@@ -0,0 +1,88 @@
import { Drawer, clx } from "@medusajs/ui"
import { PropsWithChildren, useEffect, useState } from "react"
import { useNavigate } from "react-router-dom"
import { RouteModalForm } from "../route-modal-form"
import { RouteModalProvider } from "../route-modal-provider/route-provider"
import { StackedModalProvider } from "../stacked-modal-provider"
type RouteDrawerProps = PropsWithChildren<{
prev?: string
onClose?: (() => boolean | Promise<boolean>) | undefined
}>
const Root = ({ prev = "..", onClose, children }: RouteDrawerProps) => {
const navigate = useNavigate()
const [open, setOpen] = useState(false)
const [stackedModalOpen, onStackedModalOpen] = useState(false)
/**
* Open the modal when the component mounts. This
* ensures that the entry animation is played.
*/
useEffect(() => {
setOpen(true)
return () => {
setOpen(false)
onStackedModalOpen(false)
}
}, [])
const handleOpenChange = async (open: boolean) => {
if (!open) {
if (onClose) {
const ret = await onClose()
if (!ret) {
return
}
}
document.body.style.pointerEvents = "auto"
navigate(prev, { replace: true })
return
}
setOpen(open)
}
return (
<Drawer open={open} onOpenChange={handleOpenChange}>
<RouteModalProvider prev={prev}>
<StackedModalProvider onOpenChange={onStackedModalOpen}>
<Drawer.Content
aria-describedby={undefined}
className={clx({
"!bg-ui-bg-disabled !inset-y-5 !right-5": stackedModalOpen,
})}
>
{children}
</Drawer.Content>
</StackedModalProvider>
</RouteModalProvider>
</Drawer>
)
}
const Header = Drawer.Header
const Title = Drawer.Title
const Description = Drawer.Description
const Body = Drawer.Body
const Footer = Drawer.Footer
const Close = Drawer.Close
const Form = RouteModalForm
/**
* Drawer that is used to render a form on a separate route.
*
* Typically used for forms editing a resource.
*/
export const RouteDrawer = Object.assign(Root, {
Header,
Title,
Body,
Description,
Footer,
Close,
Form,
})

View File

@@ -0,0 +1 @@
export * from "./route-focus-modal"

View File

@@ -0,0 +1,110 @@
import { FocusModal, clx } from "@medusajs/ui"
import { PropsWithChildren, useEffect, useState } from "react"
import { useNavigate } from "react-router-dom"
import { RouteModalForm } from "../route-modal-form"
import { useRouteModal } from "../route-modal-provider"
import { RouteModalProvider } from "../route-modal-provider/route-provider"
import { StackedModalProvider } from "../stacked-modal-provider"
type RouteFocusModalProps = PropsWithChildren<{
prev?: string
onClose?: (() => boolean | Promise<boolean>) | undefined
}>
const Root = ({ prev = "..", onClose, children }: RouteFocusModalProps) => {
const navigate = useNavigate()
const [open, setOpen] = useState(false)
const [stackedModalOpen, onStackedModalOpen] = useState(false)
/**
* Open the modal when the component mounts. This
* ensures that the entry animation is played.
*/
useEffect(() => {
setOpen(true)
return () => {
setOpen(false)
onStackedModalOpen(false)
}
}, [])
const handleOpenChange = async (open: boolean) => {
if (!open) {
if (onClose) {
const ret = await onClose()
if (!ret) {
return
}
}
document.body.style.pointerEvents = "auto"
navigate(prev, { replace: true })
return
}
setOpen(open)
}
return (
<FocusModal open={open} onOpenChange={handleOpenChange}>
<RouteModalProvider prev={prev}>
<StackedModalProvider onOpenChange={onStackedModalOpen}>
<Content stackedModalOpen={stackedModalOpen}>{children}</Content>
</StackedModalProvider>
</RouteModalProvider>
</FocusModal>
)
}
type ContentProps = PropsWithChildren<{
stackedModalOpen: boolean
}>
const Content = ({ stackedModalOpen, children }: ContentProps) => {
const { __internal } = useRouteModal()
const shouldPreventClose = !__internal.closeOnEscape
return (
<FocusModal.Content
onEscapeKeyDown={
shouldPreventClose
? (e) => {
e.preventDefault()
}
: undefined
}
className={clx({
"!bg-ui-bg-disabled !inset-x-5 !inset-y-3": stackedModalOpen,
})}
>
{children}
</FocusModal.Content>
)
}
const Header = FocusModal.Header
const Title = FocusModal.Title
const Description = FocusModal.Description
const Footer = FocusModal.Footer
const Body = FocusModal.Body
const Close = FocusModal.Close
const Form = RouteModalForm
/**
* FocusModal that is used to render a form on a separate route.
*
* Typically used for forms creating a resource or forms that require
* a lot of space.
*/
export const RouteFocusModal = Object.assign(Root, {
Header,
Title,
Body,
Description,
Footer,
Close,
Form,
})

View File

@@ -0,0 +1 @@
export * from "./route-modal-form"

View File

@@ -0,0 +1,85 @@
import { Prompt } from "@medusajs/ui"
import { PropsWithChildren } from "react"
import { FieldValues, UseFormReturn } from "react-hook-form"
import { useBlocker } from "react-router-dom"
import { Form } from "../../common/form"
type RouteModalFormProps<TFieldValues extends FieldValues> = PropsWithChildren<{
form: UseFormReturn<TFieldValues>
blockSearchParams?: boolean
onClose?: (isSubmitSuccessful: boolean) => void
}>
export const RouteModalForm = <TFieldValues extends FieldValues = any>({
form,
blockSearchParams: blockSearch = false,
children,
onClose,
}: RouteModalFormProps<TFieldValues>) => {
const {
formState: { isDirty },
} = form
const blocker = useBlocker(({ currentLocation, nextLocation }) => {
const { isSubmitSuccessful } = nextLocation.state || {}
if (isSubmitSuccessful) {
onClose?.(true)
return false
}
const isPathChanged = currentLocation.pathname !== nextLocation.pathname
const isSearchChanged = currentLocation.search !== nextLocation.search
if (blockSearch) {
const ret = isDirty && (isPathChanged || isSearchChanged)
if (!ret) {
onClose?.(isSubmitSuccessful)
}
return ret
}
const ret = isDirty && isPathChanged
if (!ret) {
onClose?.(isSubmitSuccessful)
}
return ret
})
const handleCancel = () => {
blocker?.reset?.()
}
const handleContinue = () => {
blocker?.proceed?.()
onClose?.(false)
}
return (
<Form {...form}>
{children}
<Prompt open={blocker.state === "blocked"} variant="confirmation">
<Prompt.Content>
<Prompt.Header>
<Prompt.Title>Unsaved Changes</Prompt.Title>
<Prompt.Description>
You have unsaved changes. Are you sure you want to leave?
</Prompt.Description>
</Prompt.Header>
<Prompt.Footer>
<Prompt.Cancel onClick={handleCancel} type="button">
Cancel
</Prompt.Cancel>
<Prompt.Action onClick={handleContinue} type="button">
Continue
</Prompt.Action>
</Prompt.Footer>
</Prompt.Content>
</Prompt>
</Form>
)
}

View File

@@ -0,0 +1,2 @@
export * from "./route-provider"
export * from "./use-route-modal"

View File

@@ -0,0 +1,12 @@
import { createContext } from "react"
type RouteModalProviderState = {
handleSuccess: (path?: string) => void
setCloseOnEscape: (value: boolean) => void
__internal: {
closeOnEscape: boolean
}
}
export const RouteModalProviderContext =
createContext<RouteModalProviderState | null>(null)

View File

@@ -0,0 +1,39 @@
import { PropsWithChildren, useCallback, useMemo, useState } from "react"
import { useNavigate } from "react-router-dom"
import { RouteModalProviderContext } from "./route-modal-context"
type RouteModalProviderProps = PropsWithChildren<{
prev: string
}>
export const RouteModalProvider = ({
prev,
children,
}: RouteModalProviderProps) => {
const navigate = useNavigate()
const [closeOnEscape, setCloseOnEscape] = useState(true)
const handleSuccess = useCallback(
(path?: string) => {
const to = path || prev
navigate(to, { replace: true, state: { isSubmitSuccessful: true } })
},
[navigate, prev]
)
const value = useMemo(
() => ({
handleSuccess,
setCloseOnEscape,
__internal: { closeOnEscape },
}),
[handleSuccess, setCloseOnEscape, closeOnEscape]
)
return (
<RouteModalProviderContext.Provider value={value}>
{children}
</RouteModalProviderContext.Provider>
)
}

View File

@@ -0,0 +1,12 @@
import { useContext } from "react"
import { RouteModalProviderContext } from "./route-modal-context"
export const useRouteModal = () => {
const context = useContext(RouteModalProviderContext)
if (!context) {
throw new Error("useRouteModal must be used within a RouteModalProvider")
}
return context
}

View File

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

View File

@@ -0,0 +1,85 @@
import { Drawer, clx } from "@medusajs/ui"
import {
ComponentPropsWithoutRef,
PropsWithChildren,
forwardRef,
useEffect,
} from "react"
import { useStackedModal } from "../stacked-modal-provider"
type StackedDrawerProps = PropsWithChildren<{
/**
* A unique identifier for the modal. This is used to differentiate stacked modals,
* when multiple stacked modals are registered to the same parent modal.
*/
id: string
}>
/**
* A stacked modal that can be rendered above a parent modal.
*/
export const Root = ({ id, children }: StackedDrawerProps) => {
const { register, unregister, getIsOpen, setIsOpen } = useStackedModal()
useEffect(() => {
register(id)
return () => unregister(id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<Drawer open={getIsOpen(id)} onOpenChange={(open) => setIsOpen(id, open)}>
{children}
</Drawer>
)
}
const Close = Drawer.Close
Close.displayName = "StackedDrawer.Close"
const Header = Drawer.Header
Header.displayName = "StackedDrawer.Header"
const Body = Drawer.Body
Body.displayName = "StackedDrawer.Body"
const Trigger = Drawer.Trigger
Trigger.displayName = "StackedDrawer.Trigger"
const Footer = Drawer.Footer
Footer.displayName = "StackedDrawer.Footer"
const Title = Drawer.Title
Title.displayName = "StackedDrawer.Title"
const Description = Drawer.Description
Description.displayName = "StackedDrawer.Description"
const Content = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<typeof Drawer.Content>
>(({ className, ...props }, ref) => {
return (
<Drawer.Content
ref={ref}
className={clx(className)}
overlayProps={{
className: "bg-transparent",
}}
{...props}
/>
)
})
Content.displayName = "StackedDrawer.Content"
export const StackedDrawer = Object.assign(Root, {
Close,
Header,
Body,
Content,
Trigger,
Footer,
Description,
Title,
})

View File

@@ -0,0 +1 @@
export * from "./stacked-focus-modal"

View File

@@ -0,0 +1,98 @@
import { FocusModal, clx } from "@medusajs/ui"
import {
ComponentPropsWithoutRef,
PropsWithChildren,
forwardRef,
useEffect,
} from "react"
import { useStackedModal } from "../stacked-modal-provider"
type StackedFocusModalProps = PropsWithChildren<{
/**
* A unique identifier for the modal. This is used to differentiate stacked modals,
* when multiple stacked modals are registered to the same parent modal.
*/
id: string
/**
* An optional callback that is called when the modal is opened or closed.
*/
onOpenChangeCallback?: (open: boolean) => void
}>
/**
* A stacked modal that can be rendered above a parent modal.
*/
export const Root = ({
id,
onOpenChangeCallback,
children,
}: StackedFocusModalProps) => {
const { register, unregister, getIsOpen, setIsOpen } = useStackedModal()
useEffect(() => {
register(id)
return () => unregister(id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleOpenChange = (open: boolean) => {
setIsOpen(id, open)
onOpenChangeCallback?.(open)
}
return (
<FocusModal open={getIsOpen(id)} onOpenChange={handleOpenChange}>
{children}
</FocusModal>
)
}
const Close = FocusModal.Close
Close.displayName = "StackedFocusModal.Close"
const Header = FocusModal.Header
Header.displayName = "StackedFocusModal.Header"
const Body = FocusModal.Body
Body.displayName = "StackedFocusModal.Body"
const Trigger = FocusModal.Trigger
Trigger.displayName = "StackedFocusModal.Trigger"
const Footer = FocusModal.Footer
Footer.displayName = "StackedFocusModal.Footer"
const Title = FocusModal.Title
Title.displayName = "StackedFocusModal.Title"
const Description = FocusModal.Description
Description.displayName = "StackedFocusModal.Description"
const Content = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<typeof FocusModal.Content>
>(({ className, ...props }, ref) => {
return (
<FocusModal.Content
ref={ref}
className={clx("!top-6", className)}
overlayProps={{
className: "bg-transparent",
}}
{...props}
/>
)
})
Content.displayName = "StackedFocusModal.Content"
export const StackedFocusModal = Object.assign(Root, {
Close,
Header,
Body,
Content,
Trigger,
Footer,
Description,
Title,
})

View File

@@ -0,0 +1,2 @@
export * from "./stacked-modal-provider"
export * from "./use-stacked-modal"

View File

@@ -0,0 +1,10 @@
import { createContext } from "react"
type StackedModalState = {
getIsOpen: (id: string) => boolean
setIsOpen: (id: string, open: boolean) => void
register: (id: string) => void
unregister: (id: string) => void
}
export const StackedModalContext = createContext<StackedModalState | null>(null)

View File

@@ -0,0 +1,54 @@
import { PropsWithChildren, useState } from "react"
import { StackedModalContext } from "./stacked-modal-context"
type StackedModalProviderProps = PropsWithChildren<{
onOpenChange: (open: boolean) => void
}>
export const StackedModalProvider = ({
children,
onOpenChange,
}: StackedModalProviderProps) => {
const [state, setState] = useState<Record<string, boolean>>({})
const getIsOpen = (id: string) => {
return state[id] || false
}
const setIsOpen = (id: string, open: boolean) => {
setState((prevState) => ({
...prevState,
[id]: open,
}))
onOpenChange(open)
}
const register = (id: string) => {
setState((prevState) => ({
...prevState,
[id]: false,
}))
}
const unregister = (id: string) => {
setState((prevState) => {
const newState = { ...prevState }
delete newState[id]
return newState
})
}
return (
<StackedModalContext.Provider
value={{
getIsOpen,
setIsOpen,
register,
unregister,
}}
>
{children}
</StackedModalContext.Provider>
)
}

View File

@@ -0,0 +1,14 @@
import { useContext } from "react"
import { StackedModalContext } from "./stacked-modal-context"
export const useStackedModal = () => {
const context = useContext(StackedModalContext)
if (!context) {
throw new Error(
"useStackedModal must be used within a StackedModalProvider"
)
}
return context
}

View File

@@ -0,0 +1,13 @@
import {
PropsWithoutRef,
ReactNode,
Ref,
RefAttributes,
forwardRef,
} from "react"
export function genericForwardRef<T, P>(
render: (props: PropsWithoutRef<P>, ref: Ref<T>) => ReactNode
): (props: P & RefAttributes<T>) => ReactNode {
return forwardRef(render) as any
}

View File

@@ -0,0 +1,139 @@
import { FetchError } from "@medusajs/js-sdk"
import { HttpTypes } from "@medusajs/types"
import { QueryKey, UseQueryOptions, useQuery } from "@tanstack/react-query"
import { sdk } from "../../lib/queries/sdk"
const CUSTOMER_QUERY_KEY = "customers"
export const customersQueryKeys = {
list: (query?: Record<string, any>) => [
CUSTOMER_QUERY_KEY,
query ? query : undefined,
],
detail: (id: string, query?: Record<string, any>) => [
CUSTOMER_QUERY_KEY,
id,
query ? query : undefined,
],
addresses: (id: string, query?: Record<string, any>) => [
CUSTOMER_QUERY_KEY,
id,
"addresses",
query ? query : undefined,
],
address: (id: string, addressId: string, query?: Record<string, any>) => [
CUSTOMER_QUERY_KEY,
id,
"addresses",
addressId,
query ? query : undefined,
],
}
export const useCustomer = (
id: string,
query?: HttpTypes.SelectParams,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminCustomerResponse,
FetchError,
HttpTypes.AdminCustomerResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryKey: customersQueryKeys.detail(id, query),
queryFn: async () => sdk.admin.customer.retrieve(id, query),
...options,
})
return { ...data, ...rest }
}
export const useCustomers = (
query?: HttpTypes.AdminCustomerFilters,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminCustomerListResponse,
FetchError,
HttpTypes.AdminCustomerListResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryKey: customersQueryKeys.list(query),
queryFn: async () => sdk.admin.customer.list(query),
...options,
})
return { ...data, ...rest }
}
export const useCustomerAddresses = (
id: string,
query?: HttpTypes.FindParams & HttpTypes.AdminCustomerAddressFilters,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminCustomerAddressListResponse,
FetchError,
HttpTypes.AdminCustomerAddressListResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryKey: customersQueryKeys.addresses(id, query),
queryFn: async () => {
const response = await sdk.client.fetch(
"/admin/customers/" + id + "/addresses",
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
}
)
return response as HttpTypes.AdminCustomerAddressListResponse
},
...options,
})
return { ...data, ...rest }
}
export const useCustomerAddress = (
id: string,
addressId: string,
query?: HttpTypes.FindParams & HttpTypes.AdminCustomerAddressFilters,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminCustomerAddressResponse,
FetchError,
HttpTypes.AdminCustomerAddressResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryKey: customersQueryKeys.address(id, addressId, query),
queryFn: async () => {
const response = await sdk.client.fetch(
"/admin/customers/" + id + "/addresses/" + addressId
)
return response as HttpTypes.AdminCustomerAddressResponse
},
...options,
})
return { ...data, ...rest }
}

View File

@@ -0,0 +1,581 @@
import { FetchError } from "@medusajs/js-sdk"
import { HttpTypes } from "@medusajs/types"
import {
QueryKey,
useMutation,
UseMutationOptions,
useQuery,
useQueryClient,
UseQueryOptions,
} from "@tanstack/react-query"
import { sdk } from "../../lib/queries/sdk"
import { ordersQueryKeys } from "./orders"
import { shippingOptionsQueryKeys } from "./shipping-options"
const DRAFT_ORDERS_QUERY_KEY = "draft-orders"
export const draftOrdersQueryKeys = {
detail: (id: string, query?: Record<string, any>) => [
DRAFT_ORDERS_QUERY_KEY,
"details",
id,
query ? { query } : undefined,
],
details: () => [DRAFT_ORDERS_QUERY_KEY, "details"],
list: (query?: Record<string, any>) => [
DRAFT_ORDERS_QUERY_KEY,
"lists",
query ? { query } : undefined,
],
lists: () => [DRAFT_ORDERS_QUERY_KEY, "lists"],
}
export const useDraftOrders = (
query?: HttpTypes.AdminOrderFilters,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminDraftOrderListResponse,
FetchError,
HttpTypes.AdminDraftOrderListResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryFn: async () => {
return await sdk.admin.draftOrder.list(query)
},
queryKey: draftOrdersQueryKeys.list(query),
...options,
})
return { ...data, ...rest }
}
export const useDraftOrder = (
id: string,
query?: HttpTypes.AdminDraftOrderParams,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminDraftOrderResponse,
FetchError,
HttpTypes.AdminDraftOrderResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryFn: async () => {
return await sdk.admin.draftOrder.retrieve(id, query)
},
queryKey: draftOrdersQueryKeys.detail(id, query),
...options,
})
return { ...data, ...rest }
}
export const useCreateDraftOrder = (
options?: Omit<
UseMutationOptions<
HttpTypes.AdminDraftOrderResponse,
FetchError,
HttpTypes.AdminCreateDraftOrder
>,
"mutationFn" | "mutationKey"
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload) => {
return await sdk.admin.draftOrder.create(payload)
},
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: draftOrdersQueryKeys.lists(),
})
// NOTE: Invalidate shipping options since we have a lot of places where we enable SO fetching
// depending on a condition but RQ will return stale data from cache which will render wrong UI.
queryClient.invalidateQueries({
queryKey: shippingOptionsQueryKeys.list(),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDeleteDraftOrder = (
options?: Omit<
UseMutationOptions<
HttpTypes.DeleteResponse<"draft-order">,
FetchError,
string
>,
"mutationFn" | "mutationKey"
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (id: string) => {
return await sdk.admin.draftOrder.delete(id)
},
onSuccess: (data, undefined, context) => {
queryClient.invalidateQueries({
queryKey: draftOrdersQueryKeys.lists(),
})
options?.onSuccess?.(data, undefined, context)
},
...options,
})
}
export const useUpdateDraftOrder = (
id: string,
options?: Omit<
UseMutationOptions<
HttpTypes.AdminDraftOrderResponse,
FetchError,
HttpTypes.AdminUpdateDraftOrder
>,
"mutationFn" | "mutationKey"
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload) => {
return await sdk.admin.draftOrder.update(id, payload)
},
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: draftOrdersQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: draftOrdersQueryKeys.lists(),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useConvertDraftOrder = (
id: string,
options?: UseMutationOptions<HttpTypes.AdminOrderResponse, FetchError, void>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => sdk.admin.draftOrder.convertToOrder(id),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: draftOrdersQueryKeys.detail(id),
})
queryClient.invalidateQueries({
queryKey: draftOrdersQueryKeys.lists(),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.detail(id),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.lists(),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDraftOrderAddItems = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminDraftOrderPreviewResponse,
FetchError,
HttpTypes.AdminAddDraftOrderItems
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (payload) => sdk.admin.draftOrder.addItems(id, payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDraftOrderUpdateItem = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminDraftOrderPreviewResponse,
FetchError,
HttpTypes.AdminUpdateDraftOrderItem & { item_id: string }
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ item_id, ...payload }) =>
sdk.admin.draftOrder.updateItem(id, item_id, payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDraftOrderRemoveActionItem = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminDraftOrderPreviewResponse,
FetchError,
string
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (action_id: string) =>
sdk.admin.draftOrder.removeActionItem(id, action_id),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDraftOrderUpdateActionItem = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminDraftOrderPreviewResponse,
FetchError,
HttpTypes.AdminUpdateDraftOrderItem & { action_id: string }
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ action_id, ...payload }) =>
sdk.admin.draftOrder.updateActionItem(id, action_id, payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDraftOrderAddPromotions = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminDraftOrderPreviewResponse,
FetchError,
HttpTypes.AdminAddDraftOrderPromotions
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (payload) => sdk.admin.draftOrder.addPromotions(id, payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDraftOrderRemovePromotions = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminDraftOrderPreviewResponse,
FetchError,
HttpTypes.AdminRemoveDraftOrderPromotions
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (payload) => sdk.admin.draftOrder.removePromotions(id, payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDraftOrderAddShippingMethod = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminDraftOrderPreviewResponse,
FetchError,
HttpTypes.AdminAddDraftOrderShippingMethod
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (payload) =>
sdk.admin.draftOrder.addShippingMethod(id, payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDraftOrderUpdateActionShippingMethod = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminDraftOrderPreviewResponse,
FetchError,
HttpTypes.AdminUpdateDraftOrderActionShippingMethod & { action_id: string }
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ action_id, ...payload }) =>
sdk.admin.draftOrder.updateActionShippingMethod(id, action_id, payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDraftOrderRemoveActionShippingMethod = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminDraftOrderPreviewResponse,
FetchError,
string
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (action_id: string) =>
sdk.admin.draftOrder.removeActionShippingMethod(id, action_id),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDraftOrderRemoveShippingMethod = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminDraftOrderPreviewResponse,
FetchError,
string
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (shipping_method_id: string) =>
sdk.admin.draftOrder.removeShippingMethod(id, shipping_method_id),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDraftOrderUpdateShippingMethod = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminDraftOrderPreviewResponse,
FetchError,
HttpTypes.AdminUpdateDraftOrderShippingMethod & { method_id: string }
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ method_id, ...payload }) =>
sdk.admin.draftOrder.updateShippingMethod(id, method_id, payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDraftOrderBeginEdit = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminDraftOrderPreviewResponse,
FetchError,
void
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => sdk.admin.draftOrder.beginEdit(id),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDraftOrderCancelEdit = (
id: string,
options?: UseMutationOptions<
HttpTypes.DeleteResponse<"draft-order-edit">,
FetchError,
void
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => sdk.admin.draftOrder.cancelEdit(id),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: draftOrdersQueryKeys.details(),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDraftOrderRequestEdit = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminDraftOrderPreviewResponse,
FetchError,
void
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => sdk.admin.draftOrder.requestEdit(id),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDraftOrderConfirmEdit = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminDraftOrderPreviewResponse,
FetchError,
void
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => sdk.admin.draftOrder.confirmEdit(id),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.changes(id),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: draftOrdersQueryKeys.detail(id),
})
queryClient.invalidateQueries({
queryKey: draftOrdersQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: draftOrdersQueryKeys.lists(),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}

View File

@@ -0,0 +1,457 @@
import {
QueryKey,
useMutation,
useQuery,
useQueryClient,
UseQueryOptions,
} from "@tanstack/react-query"
import { FetchError } from "@medusajs/js-sdk"
import { HttpTypes } from "@medusajs/types"
import { UseMutationOptions } from "@tanstack/react-query"
import {
AdminOrderEditAddShippingMethod,
AdminOrderEditUpdateShippingMethod,
} from "../../../types/http/orders/requests"
import { sdk } from "../../lib/queries/sdk"
import { draftOrdersQueryKeys } from "./draft-orders"
const ORDERS_QUERY_KEY = "orders"
export const ordersQueryKeys = {
detail: (id: string, query?: Record<string, any>) => [
ORDERS_QUERY_KEY,
"details",
id,
query ? { query } : undefined,
],
details: () => [ORDERS_QUERY_KEY, "details"],
list: (query?: Record<string, any>) => [
ORDERS_QUERY_KEY,
"lists",
query ? { query } : undefined,
],
lists: () => [ORDERS_QUERY_KEY, "lists"],
preview: (id: string) => [ORDERS_QUERY_KEY, "preview", id],
changes: (id: string) => [ORDERS_QUERY_KEY, "changes", id],
}
export const useOrder = (
id: string,
query?: HttpTypes.AdminOrderFilters,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminOrderResponse,
FetchError,
HttpTypes.AdminOrderResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryKey: ordersQueryKeys.detail(id, query),
queryFn: async () => sdk.admin.order.retrieve(id, query),
...options,
})
return { ...data, ...rest }
}
export const useOrderChanges = (
id: string,
query?: HttpTypes.AdminOrderChangesFilters,
options?: Omit<
UseQueryOptions<
HttpTypes.PaginatedResponse<HttpTypes.AdminOrderChangesResponse>,
FetchError,
HttpTypes.PaginatedResponse<HttpTypes.AdminOrderChangesResponse>,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryFn: async () => sdk.admin.order.listChanges(id, query),
queryKey: ordersQueryKeys.changes(id),
...options,
})
return { ...data, ...rest }
}
export const useUpdateOrder = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminOrderResponse,
FetchError,
HttpTypes.AdminUpdateOrder
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (payload) => sdk.admin.order.update(id, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.detail(id),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.changes(id),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.lists(),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useOrderPreview = (
id: string,
query?: HttpTypes.AdminOrderFilters,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminOrderPreviewResponse,
FetchError,
HttpTypes.AdminOrderPreviewResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryFn: async () => sdk.admin.order.retrievePreview(id, query),
queryKey: ordersQueryKeys.preview(id),
...options,
})
return { ...data, ...rest }
}
export const useOrderEditCreate = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminOrderEditResponse,
FetchError,
HttpTypes.AdminInitiateOrderEditRequest
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload) => sdk.admin.orderEdit.initiateRequest(payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.detail(id),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useOrderEditCancel = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminOrderEditDeleteResponse,
FetchError,
void
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async () => await sdk.admin.orderEdit.cancelRequest(id),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.changes(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useOrderEditRequest = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminOrderEditPreviewResponse,
FetchError,
void
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => sdk.admin.orderEdit.request(id),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.changes(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useOrderEditConfirm = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminOrderEditPreviewResponse,
FetchError,
void
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => sdk.admin.orderEdit.confirm(id),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: draftOrdersQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.changes(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useOrderEditAddItems = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminOrderEditPreviewResponse,
FetchError,
HttpTypes.AdminAddOrderEditItems
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (payload: HttpTypes.AdminAddOrderEditItems) =>
sdk.admin.orderEdit.addItems(id, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.changes(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useOrderEditAddShippingMethod = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminOrderEditPreviewResponse,
FetchError,
AdminOrderEditAddShippingMethod
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (payload) => {
return sdk.client.fetch(`/admin/order-edits/${id}/shipping-method`, {
method: "POST",
body: payload,
headers: {
"Content-Type": "application/json",
},
credentials: "include",
})
},
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useOrderEditDeleteShippingMethod = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminOrderEditPreviewResponse,
FetchError,
string
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (action_id: string) => {
return sdk.client.fetch(
`/admin/order-edits/${id}/shipping-method/${action_id}`,
{
method: "DELETE",
credentials: "include",
}
) as Promise<HttpTypes.AdminOrderEditPreviewResponse>
},
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useOrderEditUpdateShippingMethod = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminOrderEditPreviewResponse,
FetchError,
AdminOrderEditUpdateShippingMethod & { action_id: string }
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ action_id, ...payload }) => {
return sdk.client.fetch(
`/admin/order-edits/${id}/shipping-method/${action_id}`,
{
method: "POST",
body: payload,
headers: {
"Content-Type": "application/json",
},
credentials: "include",
}
)
},
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useOrderEditUpdateActionItem = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminOrderEditPreviewResponse,
FetchError,
HttpTypes.AdminUpdateOrderEditItem & {
action_id: string
unit_price?: number | null
compare_at_unit_price?: number | null
}
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ action_id, ...payload }) => {
return sdk.admin.orderEdit.updateAddedItem(id, action_id, payload)
},
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useOrderEditUpdateOriginalItem = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminOrderEditPreviewResponse,
FetchError,
HttpTypes.AdminUpdateOrderEditItem & {
item_id: string
unit_price?: number | null
compare_at_unit_price?: number | null
}
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ item_id, ...payload }) => {
return sdk.admin.orderEdit.updateOriginalItem(id, item_id, payload)
},
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useOrderEditRemoveActionItem = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminOrderEditPreviewResponse,
FetchError,
string
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (action_id: string) =>
sdk.admin.orderEdit.removeAddedItem(id, action_id),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}

View File

@@ -0,0 +1,35 @@
import { FetchError } from "@medusajs/js-sdk"
import { HttpTypes } from "@medusajs/types"
import { QueryKey, UseQueryOptions, useQuery } from "@tanstack/react-query"
import { sdk } from "../../lib/queries/sdk"
const PRODUCT_VARIANTS_QUERY_KEY = "product-variants"
export const productVariantsQueryKeys = {
list: (query?: Record<string, any>) => [
PRODUCT_VARIANTS_QUERY_KEY,
query ? query : undefined,
],
}
export const useProductVariants = (
query?: HttpTypes.AdminProductVariantParams,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminProductVariantListResponse,
FetchError,
HttpTypes.AdminProductVariantListResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryKey: productVariantsQueryKeys.list(query),
queryFn: async () => await sdk.admin.productVariant.list(query),
...options,
})
return { ...data, ...rest }
}

View File

@@ -0,0 +1,16 @@
import { useQuery } from "@tanstack/react-query"
import { sdk } from "../../lib/queries/sdk"
export const useProducts = (query: Record<string, any>) => {
const { data, ...rest } = useQuery({
queryKey: ["products", query],
queryFn: () => {
return sdk.admin.product.list(query)
},
})
return {
...data,
...rest,
}
}

View File

@@ -0,0 +1,39 @@
import { FetchError } from "@medusajs/js-sdk"
import { HttpTypes } from "@medusajs/types"
import { QueryKey, useQuery, UseQueryOptions } from "@tanstack/react-query"
import { sdk } from "../../lib/queries/sdk"
const PROMOTION_QUERY_KEY = "promotions"
export const promotionsQueryKeys = {
list: (query?: Record<string, any>) => [
PROMOTION_QUERY_KEY,
query ? query : undefined,
],
detail: (id: string, query?: Record<string, any>) => [
PROMOTION_QUERY_KEY,
id,
query ? query : undefined,
],
}
export const usePromotions = (
query?: HttpTypes.AdminGetPromotionsParams,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminPromotionListResponse,
FetchError,
HttpTypes.AdminPromotionListResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryKey: promotionsQueryKeys.list(query),
queryFn: async () => sdk.admin.promotion.list(query),
...options,
})
return { ...data, ...rest }
}

View File

@@ -0,0 +1,65 @@
import { FetchError } from "@medusajs/js-sdk"
import { HttpTypes } from "@medusajs/types"
import { QueryKey, useQuery, UseQueryOptions } from "@tanstack/react-query"
import { sdk } from "../../lib/queries/sdk"
const REGION_QUERY_KEY = "regions"
export const regionsQueryKeys = {
list: (query?: Record<string, any>) => [
REGION_QUERY_KEY,
query ? query : undefined,
],
detail: (id: string, query?: Record<string, any>) => [
REGION_QUERY_KEY,
id,
query ? query : undefined,
],
}
export const useRegion = (
id: string,
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminRegionResponse,
FetchError,
HttpTypes.AdminRegionResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryKey: regionsQueryKeys.detail(id, query),
queryFn: async () => sdk.admin.region.retrieve(id, query),
...options,
})
return { ...data, ...rest }
}
export const useRegions = (
query?: HttpTypes.FindParams & HttpTypes.AdminRegionFilters,
options?: Omit<
UseQueryOptions<
HttpTypes.PaginatedResponse<{
regions: HttpTypes.AdminRegion[]
}>,
FetchError,
HttpTypes.PaginatedResponse<{
regions: HttpTypes.AdminRegion[]
}>,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryKey: regionsQueryKeys.list(query),
queryFn: async () => sdk.admin.region.list(query),
...options,
})
return { ...data, ...rest }
}

View File

@@ -0,0 +1,61 @@
import { FetchError } from "@medusajs/js-sdk"
import { HttpTypes } from "@medusajs/types"
import { QueryKey, useQuery, UseQueryOptions } from "@tanstack/react-query"
import { sdk } from "../../lib/queries/sdk"
const SALES_CHANNEL_QUERY_KEY = "sales-channels"
export const salesChannelsQueryKeys = {
list: (query?: Record<string, any>) => [
SALES_CHANNEL_QUERY_KEY,
query ? query : undefined,
],
detail: (id: string, query?: Record<string, any>) => [
SALES_CHANNEL_QUERY_KEY,
id,
query ? query : undefined,
],
}
export const useSalesChannel = (
id: string,
query?: HttpTypes.SelectParams,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminSalesChannelResponse,
FetchError,
HttpTypes.AdminSalesChannelResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryKey: salesChannelsQueryKeys.detail(id, query),
queryFn: async () => sdk.admin.salesChannel.retrieve(id, query),
...options,
})
return { ...data, ...rest }
}
export const useSalesChannels = (
query?: HttpTypes.AdminSalesChannelListParams,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminSalesChannelListResponse,
FetchError,
HttpTypes.AdminSalesChannelListResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryKey: salesChannelsQueryKeys.list(query),
queryFn: async () => sdk.admin.salesChannel.list(query),
...options,
})
return { ...data, ...rest }
}

View File

@@ -0,0 +1,61 @@
import { FetchError } from "@medusajs/js-sdk"
import { HttpTypes } from "@medusajs/types"
import { QueryKey, UseQueryOptions, useQuery } from "@tanstack/react-query"
import { sdk } from "../../lib/queries/sdk"
const SHIPPING_OPTION_QUERY_KEY = "shipping-options"
export const shippingOptionsQueryKeys = {
list: (query?: Record<string, any>) => [
SHIPPING_OPTION_QUERY_KEY,
query ? query : undefined,
],
detail: (id: string, query?: Record<string, any>) => [
SHIPPING_OPTION_QUERY_KEY,
id,
query ? query : undefined,
],
}
export const useShippingOption = (
id: string,
query?: HttpTypes.SelectParams,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminShippingOptionResponse,
FetchError,
HttpTypes.AdminShippingOptionResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryKey: shippingOptionsQueryKeys.detail(id, query),
queryFn: async () => sdk.admin.shippingOption.retrieve(id, query),
...options,
})
return { ...data, ...rest }
}
export const useShippingOptions = (
query?: HttpTypes.AdminShippingOptionListParams,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminShippingOptionListResponse,
FetchError,
HttpTypes.AdminShippingOptionListResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryKey: shippingOptionsQueryKeys.list(query),
queryFn: async () => sdk.admin.shippingOption.list(query),
...options,
})
return { ...data, ...rest }
}

View File

@@ -0,0 +1,41 @@
import { FetchError } from "@medusajs/js-sdk"
import { HttpTypes } from "@medusajs/types"
import { QueryKey, UseQueryOptions, useQuery } from "@tanstack/react-query"
import { sdk } from "../../lib/queries/sdk"
const USER_QUERY_KEY = "users"
export const usersQueryKeys = {
list: (query?: Record<string, any>) => [
USER_QUERY_KEY,
query ? query : undefined,
],
detail: (id: string, query?: Record<string, any>) => [
USER_QUERY_KEY,
id,
query ? query : undefined,
],
}
export const useUser = (
id: string,
query?: HttpTypes.SelectParams,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminUserResponse,
FetchError,
HttpTypes.AdminUserResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryKey: usersQueryKeys.detail(id, query),
queryFn: async () => sdk.admin.user.retrieve(id, query),
...options,
})
return { ...data, ...rest }
}

View File

@@ -0,0 +1,98 @@
import {
QueryKey,
keepPreviousData,
useInfiniteQuery,
useQuery,
} from "@tanstack/react-query"
import { useDebouncedSearch } from "./use-debounced-search"
type ComboboxExternalData = {
offset: number
limit: number
count: number
}
type ComboboxQueryParams = {
q?: string
offset?: number
limit?: number
}
export const useComboboxData = <
TResponse extends ComboboxExternalData,
TParams extends ComboboxQueryParams
>({
queryKey,
queryFn,
getOptions,
defaultValue,
defaultValueKey,
pageSize = 10,
enabled = true,
}: {
queryKey: QueryKey
queryFn: (params: TParams) => Promise<TResponse>
getOptions: (data: TResponse) => { label: string; value: string }[]
defaultValueKey?: keyof TParams
defaultValue?: string | string[]
pageSize?: number
enabled?: boolean
}) => {
const { searchValue, onSearchValueChange, query } = useDebouncedSearch()
const queryInitialDataBy = defaultValueKey || "id"
const { data: initialData } = useQuery({
queryKey: queryKey,
queryFn: async () => {
return queryFn({
[queryInitialDataBy]: defaultValue,
limit: Array.isArray(defaultValue) ? defaultValue.length : 1,
} as TParams)
},
enabled: !!defaultValue,
})
const { data, ...rest } = useInfiniteQuery({
queryKey: [...queryKey, query],
queryFn: async ({ pageParam = 0 }) => {
return await queryFn({
q: query,
limit: pageSize,
offset: pageParam,
} as TParams)
},
initialPageParam: 0,
getNextPageParam: (lastPage) => {
const moreItemsExist = lastPage.count > lastPage.offset + lastPage.limit
return moreItemsExist ? lastPage.offset + lastPage.limit : undefined
},
placeholderData: keepPreviousData,
enabled,
})
const options = data?.pages.flatMap((page) => getOptions(page)) ?? []
const defaultOptions = initialData ? getOptions(initialData) : []
/**
* If there are no options and the query is empty, then the combobox should be disabled,
* as there is no data to search for.
*/
const disabled = !rest.isPending && !options.length && !searchValue
// make sure that the default value is included in the options
if (defaultValue && defaultOptions.length && !searchValue) {
defaultOptions.forEach((option) => {
if (!options.find((o) => o.value === option.value)) {
options.unshift(option)
}
})
}
return {
options,
searchValue,
onSearchValueChange,
disabled,
...rest,
}
}

View File

@@ -0,0 +1,90 @@
import { createDataTableFilterHelper } from "@medusajs/ui"
import { subDays, subMonths } from "date-fns"
import { useMemo } from "react"
import { getFullDate } from "../../lib/utils/date-utils"
const filterHelper = createDataTableFilterHelper<any>()
const useDateFilterOptions = () => {
const today = useMemo(() => {
const date = new Date()
date.setHours(0, 0, 0, 0)
return date
}, [])
return useMemo(() => {
return [
{
label: "Today",
value: {
$gte: today.toISOString(),
},
},
{
label: "Last 7 days",
value: {
$gte: subDays(today, 7).toISOString(), // 7 days ago
},
},
{
label: "Last 30 days",
value: {
$gte: subDays(today, 30).toISOString(), // 30 days ago
},
},
{
label: "Last 90 days",
value: {
$gte: subDays(today, 90).toISOString(), // 90 days ago
},
},
{
label: "Last 12 months",
value: {
$gte: subMonths(today, 12).toISOString(), // 12 months ago
},
},
]
}, [today])
}
export const useDataTableDateFilters = (disableRangeOption?: boolean) => {
const dateFilterOptions = useDateFilterOptions()
const rangeOptions = useMemo(() => {
if (disableRangeOption) {
return {
disableRangeOption: true,
}
}
return {
rangeOptionStartLabel: "Starting",
rangeOptionEndLabel: "Ending",
rangeOptionLabel: "Custom",
options: dateFilterOptions,
}
}, [disableRangeOption, dateFilterOptions])
return useMemo(() => {
return [
filterHelper.accessor("created_at", {
type: "date",
label: "Created at",
format: "date",
formatDateValue: (date) => getFullDate({ date }),
options: dateFilterOptions,
...rangeOptions,
}),
filterHelper.accessor("updated_at", {
type: "date",
label: "Updated at",
format: "date",
formatDateValue: (date) => getFullDate({ date }),
options: dateFilterOptions,
...rangeOptions,
}),
]
}, [dateFilterOptions, getFullDate, rangeOptions])
}

View File

@@ -0,0 +1,29 @@
import debounce from "lodash/debounce"
import { useCallback, useEffect, useState } from "react"
/**
* Hook for debouncing search input
* @returns searchValue, onSearchValueChange, query
*/
export const useDebouncedSearch = () => {
const [searchValue, onSearchValueChange] = useState("")
const [debouncedQuery, setDebouncedQuery] = useState("")
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedUpdate = useCallback(
debounce((query: string) => setDebouncedQuery(query), 300),
[]
)
useEffect(() => {
debouncedUpdate(searchValue)
return () => debouncedUpdate.cancel()
}, [searchValue, debouncedUpdate])
return {
searchValue,
onSearchValueChange,
query: debouncedQuery,
}
}

View File

@@ -0,0 +1,24 @@
import { useSearchParams } from "react-router-dom"
type QueryParams<T extends string> = {
[key in T]: string | undefined
}
export function useQueryParams<T extends string>(
keys: T[],
prefix?: string
): QueryParams<T> {
const [params] = useSearchParams()
// Use a type assertion to initialize the result
const result = {} as QueryParams<T>
keys.forEach((key) => {
const prefixedKey = prefix ? `${prefix}_${key}` : key
const value = params.get(prefixedKey) || undefined
result[key] = value
})
return result
}

View File

@@ -0,0 +1,33 @@
import { HttpTypes } from "@medusajs/types"
import { toast } from "@medusajs/ui"
import { useCallback } from "react"
import { useDraftOrderCancelEdit } from "../api/draft-orders"
interface UseCancelOrderEditProps {
preview?: HttpTypes.AdminOrderPreview
}
export const useCancelOrderEdit = ({ preview }: UseCancelOrderEditProps) => {
const { mutateAsync: cancelOrderEdit } = useDraftOrderCancelEdit(preview?.id!)
const onCancel = useCallback(async () => {
if (!preview) {
return true
}
let res = false
await cancelOrderEdit(undefined, {
onError: (e) => {
toast.error(e.message)
},
onSuccess: () => {
res = true
},
})
return res
}, [preview, cancelOrderEdit])
return { onCancel }
}

View File

@@ -0,0 +1,47 @@
import { HttpTypes } from "@medusajs/types"
import { toast } from "@medusajs/ui"
import { useEffect } from "react"
import { useNavigate } from "react-router-dom"
import { useDraftOrderBeginEdit } from "../api/draft-orders"
let IS_REQUEST_RUNNING = false
interface UseInitiateOrderEditProps {
preview?: HttpTypes.AdminOrderPreview
}
export const useInitiateOrderEdit = ({
preview,
}: UseInitiateOrderEditProps) => {
const navigate = useNavigate()
const { mutateAsync } = useDraftOrderBeginEdit(preview?.id!)
useEffect(() => {
async function run() {
if (IS_REQUEST_RUNNING || !preview) {
return
}
// If an order edit is already in progress, don't try to create a new one.
if (preview.order_change) {
return
}
IS_REQUEST_RUNNING = true
await mutateAsync(undefined, {
onError: (e) => {
toast.error(e.message)
navigate(`/draft-orders/${preview.id}`, { replace: true })
return
},
})
IS_REQUEST_RUNNING = false
}
run()
}, [preview, navigate, mutateAsync])
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
export const getLocaleAmount = (amount: number, currencyCode: string) => {
const formatter = new Intl.NumberFormat([], {
style: "currency",
currencyDisplay: "narrowSymbol",
currency: currencyCode,
})
return formatter.format(amount)
}
export const getNativeSymbol = (currencyCode: string) => {
const formatted = new Intl.NumberFormat([], {
style: "currency",
currency: currencyCode,
currencyDisplay: "narrowSymbol",
}).format(0)
return formatted.replace(/\d/g, "").replace(/[.,]/g, "").trim()
}
export const getDecimalDigits = (currencyCode: string) => {
const formatter = new Intl.NumberFormat(undefined, {
style: "currency",
currency: currencyCode,
})
return formatter.resolvedOptions().maximumFractionDigits
}
export const getStylizedAmount = (amount: number, currencyCode: string) => {
const symbol = getNativeSymbol(currencyCode)
const decimalDigits = getDecimalDigits(currencyCode)
const total = amount.toLocaleString(undefined, {
minimumFractionDigits: decimalDigits,
maximumFractionDigits: decimalDigits,
})
return `${symbol} ${total} ${currencyCode.toUpperCase()}`
}

View File

@@ -0,0 +1,7 @@
const orderDetailQuery = (id: string) => ({
queryKey: ordersQueryKeys.detail(id),
queryFn: async () =>
sdk.admin.order.retrieve(id, {
fields: DEFAULT_FIELDS,
}),
})

View File

@@ -0,0 +1,8 @@
import Medusa from "@medusajs/js-sdk"
export const sdk = new Medusa({
baseUrl: "/",
auth: {
type: "session",
},
})

View File

@@ -0,0 +1,14 @@
import { z } from "zod"
export const addressSchema = z.object({
country_code: z.string().min(1),
first_name: z.string().min(1),
last_name: z.string().min(1),
address_1: z.string().min(1),
address_2: z.string().optional(),
company: z.string().optional(),
city: z.string().min(1),
province: z.string().optional(),
postal_code: z.string().min(1),
phone: z.string().optional(),
})

View File

@@ -0,0 +1,88 @@
import { HttpTypes } from "@medusajs/types"
import { getCountryByIso2 } from "../data/countries"
export function isSameAddress(
a?: HttpTypes.AdminOrderAddress | null,
b?: HttpTypes.AdminOrderAddress | 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 &&
a.phone === b.phone &&
a.company === b.company
)
}
export function getFormattedAddress(
address?: HttpTypes.AdminOrderAddress | HttpTypes.AdminCustomerAddress | null
) {
if (!address) {
return []
}
const {
first_name,
last_name,
company,
address_1,
address_2,
city,
postal_code,
province,
country_code,
} = address
const country = "country" in address ? address.country : null
const name = [first_name, last_name].filter(Boolean).join(" ")
const formattedAddress: string[] = []
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) {
const country = getCountryByIso2(country_code)
if (country) {
formattedAddress.push(country.display_name)
} else {
formattedAddress.push(country_code.toUpperCase())
}
}
return formattedAddress
}

View File

@@ -0,0 +1,33 @@
import { format, formatDistance, sub } from "date-fns"
import { enUS } from "date-fns/locale"
const LOCALE = enUS
export function getRelativeDate(date: string | Date): string {
const now = new Date()
return formatDistance(sub(new Date(date), { minutes: 0 }), now, {
addSuffix: true,
locale: LOCALE,
})
}
export const getFullDate = ({
date,
includeTime = false,
}: {
date: string | Date
includeTime?: boolean
}) => {
const ensuredDate = new Date(date)
if (isNaN(ensuredDate.getTime())) {
return ""
}
const timeFormat = includeTime ? "p" : ""
return format(ensuredDate, `PP ${timeFormat}`, {
locale: LOCALE,
})
}

View File

@@ -0,0 +1,3 @@
export function convertNumber(value?: string | number) {
return typeof value === "string" ? Number(value.replace(",", ".")) : value
}

View File

@@ -0,0 +1,40 @@
import { HttpTypes } from "@medusajs/types"
export function getUniqueShippingProfiles(
items: HttpTypes.AdminOrderLineItem[]
): HttpTypes.AdminShippingProfile[] {
const profiles = new Map<string, HttpTypes.AdminShippingProfile>()
items.forEach((item) => {
const profile = item.variant?.product?.shipping_profile
if (profile) {
profiles.set(profile.id, profile)
}
})
return Array.from(profiles.values())
}
export function getItemsWithShippingProfile(
shipping_profile_id: string,
items: HttpTypes.AdminOrderLineItem[]
) {
return items.filter(
(item) =>
item.variant?.product?.shipping_profile?.id === shipping_profile_id
)
}
export function getOrderCustomer(obj: HttpTypes.AdminOrder) {
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
}

View File

@@ -0,0 +1,3 @@
export function pluralize(count: number, plural: string, singular: string) {
return count === 1 ? singular : plural
}

View File

@@ -0,0 +1,780 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { HttpTypes } from "@medusajs/types"
import {
Button,
Divider,
Heading,
Hint,
Input,
Label,
Switch,
toast,
} from "@medusajs/ui"
import { Fragment, useCallback } from "react"
import { Control, useForm, UseFormSetValue, useWatch } from "react-hook-form"
import { z } from "zod"
import { AddressCard } from "../../../components/common/address-card"
import { ConditionalTooltip } from "../../../components/common/conditional-tooltip"
import { CustomerCard } from "../../../components/common/customer-card"
import { Form } from "../../../components/common/form"
import { KeyboundForm } from "../../../components/common/keybound-form"
import { Combobox } from "../../../components/inputs/combobox"
import { CountrySelect } from "../../../components/inputs/country-select"
import { RouteFocusModal, useRouteModal } from "../../../components/modals"
import { useCreateDraftOrder } from "../../../hooks/api/draft-orders"
import { useComboboxData } from "../../../hooks/common/use-combobox-data"
import { sdk } from "../../../lib/queries/sdk"
import { addressSchema } from "../../../lib/schemas/address"
import { getFormattedAddress } from "../../../lib/utils/address-utils"
import { useCustomer } from "../../../hooks/api/customers"
const Create = () => {
return (
<RouteFocusModal>
<CreateForm />
</RouteFocusModal>
)
}
const CreateForm = () => {
const { handleSuccess } = useRouteModal()
const form = useForm<z.infer<typeof schema>>({
defaultValues: {
region_id: "",
sales_channel_id: "",
customer_id: "",
email: "",
shipping_address_id: "",
shipping_address: initialAddress,
billing_address_id: "",
billing_address: null,
same_as_shipping: true,
},
resolver: zodResolver(schema),
})
const regions = useComboboxData({
queryFn: async (params) => {
return await sdk.admin.region.list(params)
},
queryKey: ["regions"],
getOptions: (data) => {
return data.regions.map((region) => ({
label: region.name,
value: region.id,
}))
},
})
const salesChannels = useComboboxData({
queryFn: async (params) => {
return await sdk.admin.salesChannel.list(params)
},
queryKey: ["sales-channels"],
getOptions: (data) => {
return data.sales_channels.map((salesChannel) => ({
label: salesChannel.name,
value: salesChannel.id,
}))
},
})
const { mutateAsync } = useCreateDraftOrder()
const onSubmit = form.handleSubmit(
async (data) => {
const billingAddress = data.same_as_shipping
? data.shipping_address
: data.billing_address
await mutateAsync(
{
region_id: data.region_id,
sales_channel_id: data.sales_channel_id,
customer_id: data.customer_id || undefined,
email: !data.customer_id ? data.email : undefined,
shipping_address: data.shipping_address,
billing_address: billingAddress!,
},
{
onSuccess: (response) => {
handleSuccess(`/draft-orders/${response.draft_order.id}`)
},
onError: (error) => {
toast.error(error.message)
},
}
)
},
(error) => {
toast.error(JSON.stringify(error, null, 2))
}
)
if (regions.isError) {
throw regions.error
}
if (salesChannels.isError) {
throw salesChannels.error
}
return (
<RouteFocusModal.Form form={form}>
<KeyboundForm
className="flex h-full flex-col overflow-hidden"
onSubmit={onSubmit}
>
<RouteFocusModal.Header />
<RouteFocusModal.Body className="flex flex-1 flex-col overflow-hidden">
<div className="flex flex-1 flex-col items-center overflow-y-auto">
<div className="flex w-full max-w-[720px] flex-col gap-y-6 px-2 py-16">
<div>
<RouteFocusModal.Title asChild>
<Heading>Create Draft Order</Heading>
</RouteFocusModal.Title>
<RouteFocusModal.Description asChild>
<span className="sr-only">Create a new draft order</span>
</RouteFocusModal.Description>
</div>
<Divider variant="dashed" />
<div>
<Form.Field
control={form.control}
name="region_id"
render={({ field }) => {
return (
<Form.Item>
<div className="grid grid-cols-2 gap-x-3">
<div>
<Form.Label>Region</Form.Label>
<Form.Hint>Choose region</Form.Hint>
</div>
<div>
<Form.Control>
<Combobox
options={regions.options}
fetchNextPage={regions.fetchNextPage}
isFetchingNextPage={regions.isFetchingNextPage}
searchValue={regions.searchValue}
onSearchValueChange={
regions.onSearchValueChange
}
placeholder="Select region"
{...field}
autoComplete="off"
/>
</Form.Control>
<Form.ErrorMessage />
</div>
</div>
</Form.Item>
)
}}
/>
</div>
<Divider variant="dashed" />
<div>
<Form.Field
control={form.control}
name="sales_channel_id"
render={({ field }) => {
return (
<Form.Item>
<div className="grid grid-cols-2 gap-x-3">
<div>
<Form.Label>Sales Channel</Form.Label>
<Form.Hint>Choose sales channel</Form.Hint>
</div>
<div>
<Form.Control>
<Combobox
options={salesChannels.options}
fetchNextPage={salesChannels.fetchNextPage}
isFetchingNextPage={
salesChannels.isFetchingNextPage
}
searchValue={salesChannels.searchValue}
onSearchValueChange={
salesChannels.onSearchValueChange
}
placeholder="Select sales channel"
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</div>
</div>
</Form.Item>
)
}}
/>
</div>
<Divider variant="dashed" />
<CustomerField control={form.control} setValue={form.setValue} />
<Divider variant="dashed" />
<EmailField control={form.control} />
<Divider variant="dashed" />
<AddressField
type="shipping_address"
control={form.control}
setValue={form.setValue}
/>
<Divider variant="dashed" />
<AddressField
type="billing_address"
control={form.control}
setValue={form.setValue}
/>
</div>
</div>
</RouteFocusModal.Body>
<RouteFocusModal.Footer>
<div className="flex justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button variant="secondary" size="small">
Cancel
</Button>
</RouteFocusModal.Close>
<Button size="small">Save</Button>
</div>
</RouteFocusModal.Footer>
</KeyboundForm>
</RouteFocusModal.Form>
)
}
interface EmailFieldProps {
control: Control<z.infer<typeof schema>>
}
const EmailField = ({ control }: EmailFieldProps) => {
const customerId = useWatch({ control, name: "customer_id" })
return (
<Form.Field
control={control}
name="email"
render={({ field }) => {
return (
<Form.Item>
<div className="grid grid-cols-2 gap-x-3">
<div>
<Form.Label>Email</Form.Label>
<Form.Hint>Input a email to associate with the order</Form.Hint>
</div>
<ConditionalTooltip
content="You cannot change the email when a customer is selected"
showTooltip={!!customerId}
>
<div>
<Form.Control>
<Input
{...field}
placeholder="john@doe.com"
disabled={field.disabled || !!customerId}
/>
</Form.Control>
<Form.ErrorMessage />
</div>
</ConditionalTooltip>
</div>
</Form.Item>
)
}}
/>
)
}
interface CustomerFieldProps {
control: Control<z.infer<typeof schema>>
setValue: UseFormSetValue<z.infer<typeof schema>>
}
const CustomerField = ({ control, setValue }: CustomerFieldProps) => {
const email = useWatch({ control, name: "email" })
const customerId = useWatch({ control, name: "customer_id" })
const customers = useComboboxData({
queryFn: async (params) => {
return await sdk.admin.customer.list(params)
},
queryKey: ["customers"],
getOptions: (data) => {
return data.customers.map((customer) => {
const name = [customer.first_name, customer.last_name]
.filter(Boolean)
.join(" ")
return {
label: name ? `${name} (${customer.email})` : customer.email,
value: customer.id,
}
})
},
})
const onPropagateEmail = useCallback(
(value?: string) => {
const label = customers.options.find(
(option) => option.value === value
)?.label
const customerEmail = label?.match(/\((.*@.*)\)$/)?.[1] || label
if (!email && customerEmail) {
setValue("email", customerEmail, {
shouldDirty: true,
shouldTouch: true,
})
}
},
[email, setValue, customers.options]
)
if (customers.isError) {
throw customers.error
}
return (
<Form.Field
control={control}
name="customer_id"
render={({ field: { onChange, ...field } }) => {
const onRemove = () => {
onChange("")
// If the customer is removed, we need to clear the shipping address id
setValue("shipping_address_id", "")
}
return (
<Form.Item>
<div className="grid grid-cols-2 gap-x-3">
<div>
<Form.Label optional>Customer</Form.Label>
<Form.Hint>Choose an existing customer</Form.Hint>
</div>
<Form.Control>
{customerId ? (
<CustomerCard customerId={customerId} onRemove={onRemove} />
) : (
<Combobox
options={customers.options}
fetchNextPage={customers.fetchNextPage}
isFetchingNextPage={customers.isFetchingNextPage}
searchValue={customers.searchValue}
onSearchValueChange={customers.onSearchValueChange}
placeholder="Select customer"
onChange={(value) => {
onPropagateEmail(value)
onChange(value)
}}
{...field}
/>
)}
</Form.Control>
</div>
</Form.Item>
)
}}
/>
)
}
interface AddressFieldProps {
type: "shipping_address" | "billing_address"
control: Control<z.infer<typeof schema>>
setValue: UseFormSetValue<z.infer<typeof schema>>
}
const AddressField = ({ type, control, setValue }: AddressFieldProps) => {
const customerId = useWatch({ control, name: "customer_id" })
const addressId = useWatch({ control, name: `${type}_id` })
const sameAsShipping = useWatch({ control, name: "same_as_shipping" })
const { customer } = useCustomer(
customerId!,
{},
{
enabled: !!customerId,
}
)
const addresses = useComboboxData({
queryFn: async (params) => {
const response = await sdk.client.fetch(
"/admin/customers/" + customerId + "/addresses",
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
query: params,
credentials: "include",
}
)
return response as HttpTypes.AdminCustomerAddressListResponse
},
queryKey: [type, customerId],
getOptions: (data) => {
return data.addresses.map((address) => {
const formattedAddress = getFormattedAddress(address).join(",\n")
return {
label: formattedAddress,
value: address.id,
}
})
},
enabled: !!customerId,
})
const onSelectAddress = async (addressId?: string) => {
if (!addressId || !customerId) {
return
}
const response = (await sdk.client.fetch(
"/admin/customers/" + customerId + "/addresses/" + addressId,
{
method: "GET",
credentials: "include",
}
)) as HttpTypes.AdminCustomerAddressResponse
const address = response.address
setValue(type, {
...address,
first_name: address.first_name || customer?.first_name,
last_name: address.last_name || customer?.last_name,
} as z.infer<typeof addressSchema>)
}
const showFields = type === "billing_address" ? !sameAsShipping : true
return (
<div className="grid grid-cols-2 gap-x-3">
<div className="flex flex-col gap-y-1">
<Label size="small" weight="plus">
{type === "shipping_address" ? "Shipping address" : "Billing address"}
</Label>
<Hint>
Address used for{" "}
{type === "shipping_address" ? "shipping" : "billing"}
</Hint>
</div>
<div className="flex flex-col gap-y-3">
{type === "billing_address" && (
<Form.Field
control={control}
name="same_as_shipping"
render={({ field: { value, onChange, ...field } }) => {
const onCheckedChange = (checked: boolean) => {
if (!checked) {
setValue("billing_address", initialAddress)
} else {
setValue("billing_address_id", "")
setValue("billing_address", null)
}
onChange(checked)
}
return (
<Form.Item>
<div className="grid grid-cols-[28px_1fr] items-start gap-3">
<Form.Control>
<Switch
size="small"
{...field}
checked={value}
onCheckedChange={onCheckedChange}
/>
</Form.Control>
<div className="flex flex-col">
<Form.Label>Same as shipping address</Form.Label>
<Form.Hint>
Use the same address for billing and shipping
</Form.Hint>
</div>
</div>
</Form.Item>
)
}}
/>
)}
{showFields && (
<div className="flex flex-col gap-y-3">
{customerId && (
<div className="flex flex-col gap-y-3">
<Form.Field
control={control}
name={`${type}_id`}
render={({ field: { onChange, ...field } }) => {
const onRemove = () => {
onChange("")
}
return (
<Form.Item>
{addressId ? (
<AddressCard
customerId={customerId}
addressId={addressId}
tag={
type === "shipping_address"
? "shipping"
: "billing"
}
onRemove={onRemove}
/>
) : (
<Fragment>
<Form.Label optional variant="subtle">
Saved addresses
</Form.Label>
<Form.Hint>
Choose one of the customers saved addresses.
</Form.Hint>
<Form.Control>
<Combobox
options={addresses.options}
fetchNextPage={addresses.fetchNextPage}
isFetchingNextPage={
addresses.isFetchingNextPage
}
searchValue={addresses.searchValue}
onSearchValueChange={
addresses.onSearchValueChange
}
placeholder={
type === "shipping_address"
? "Select shipping address"
: "Select billing address"
}
onChange={(value) => {
onSelectAddress(value)
onChange(value)
}}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Fragment>
)}
</Form.Item>
)
}}
/>
<Divider variant="dashed" />
</div>
)}
{!addressId && (
<div className="flex flex-col gap-y-3">
<Form.Field
control={control}
name={`${type}.country_code`}
render={({ field }) => (
<Form.Item>
<Form.Label variant="subtle">Country</Form.Label>
<Form.Control>
<CountrySelect {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
<div className="grid grid-cols-2 gap-4">
<Form.Field
control={control}
name={`${type}.first_name`}
render={({ field }) => (
<Form.Item>
<Form.Label variant="subtle">First name</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
<Form.Field
control={control}
name={`${type}.last_name`}
render={({ field }) => (
<Form.Item>
<Form.Label variant="subtle">Last name</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
</div>
<Form.Field
control={control}
name={`${type}.company`}
render={({ field }) => (
<Form.Item>
<Form.Label optional variant="subtle">
Company
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
<Form.Field
control={control}
name={`${type}.address_1`}
render={({ field }) => (
<Form.Item>
<Form.Label variant="subtle">Address</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
<Form.Field
control={control}
name={`${type}.address_2`}
render={({ field }) => (
<Form.Item>
<Form.Label optional variant="subtle">
Apartment, suite, etc.
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
<div className="grid grid-cols-2 gap-4">
<Form.Field
control={control}
name={`${type}.postal_code`}
render={({ field }) => (
<Form.Item>
<Form.Label variant="subtle">Postal code</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
<Form.Field
control={control}
name={`${type}.city`}
render={({ field }) => (
<Form.Item>
<Form.Label variant="subtle">City</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
</div>
<Form.Field
control={control}
name={`${type}.province`}
render={({ field }) => (
<Form.Item>
<Form.Label optional variant="subtle">
Province / State
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
<Form.Field
control={control}
name={`${type}.phone`}
render={({ field }) => (
<Form.Item>
<Form.Label optional variant="subtle">
Phone
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
</div>
)}
</div>
)}
</div>
</div>
)
}
const initialAddress = {
country_code: "",
first_name: "",
last_name: "",
address_1: "",
address_2: "",
city: "",
province: "",
postal_code: "",
phone: "",
company: "",
}
const schema = z
.object({
region_id: z.string().min(1),
sales_channel_id: z.string().min(1),
customer_id: z.string().optional(),
email: z.string().email().optional(),
shipping_address_id: z.string().optional(),
shipping_address: addressSchema,
billing_address_id: z.string().optional(),
billing_address: addressSchema.nullable(),
same_as_shipping: z.boolean().default(true),
})
.superRefine((data, ctx) => {
if (!data.customer_id && !data.email) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Either a customer or email must be provided",
path: ["customer_id", "email"],
})
}
if (!data.shipping_address && !data.shipping_address_id) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Shipping address is required",
path: ["shipping_address"],
})
}
if (data.same_as_shipping === false) {
if (!data.billing_address && !data.billing_address_id) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Billing address is required",
path: ["billing_address"],
})
}
}
})
export default Create

View File

@@ -0,0 +1,246 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { HttpTypes } from "@medusajs/types"
import { Button, Heading, Input, toast } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useParams } from "react-router-dom"
import { z } from "zod"
import { Form } from "../../../../components/common/form"
import { KeyboundForm } from "../../../../components/common/keybound-form"
import { CountrySelect } from "../../../../components/inputs/country-select"
import { RouteDrawer, useRouteModal } from "../../../../components/modals"
import { useUpdateDraftOrder } from "../../../../hooks/api/draft-orders"
import { useOrder } from "../../../../hooks/api/orders"
import { addressSchema } from "../../../../lib/schemas/address"
const BillingAddress = () => {
const { id } = useParams()
const { order, isPending, isError, error } = useOrder(id!, {
fields: "+billing_address",
})
if (isError) {
throw error
}
const isReady = !isPending && !!order
return (
<RouteDrawer>
<RouteDrawer.Header>
<RouteDrawer.Title asChild>
<Heading>Edit Billing Address</Heading>
</RouteDrawer.Title>
<RouteDrawer.Description asChild>
<span className="sr-only">
Edit the billing address for the draft order
</span>
</RouteDrawer.Description>
</RouteDrawer.Header>
{isReady && <BillingAddressForm order={order} />}
</RouteDrawer>
)
}
interface BillingAddressFormProps {
order: HttpTypes.AdminOrder
}
const BillingAddressForm = ({ order }: BillingAddressFormProps) => {
const form = useForm<z.infer<typeof schema>>({
defaultValues: {
first_name: order.billing_address?.first_name ?? "",
last_name: order.billing_address?.last_name ?? "",
company: order.billing_address?.company ?? "",
address_1: order.billing_address?.address_1 ?? "",
address_2: order.billing_address?.address_2 ?? "",
city: order.billing_address?.city ?? "",
province: order.billing_address?.province ?? "",
country_code: order.billing_address?.country_code ?? "",
postal_code: order.billing_address?.postal_code ?? "",
phone: order.billing_address?.phone ?? "",
},
resolver: zodResolver(schema),
})
const { mutateAsync, isPending } = useUpdateDraftOrder(order.id)
const { handleSuccess } = useRouteModal()
const onSubmit = form.handleSubmit(async (data) => {
await mutateAsync(
{ billing_address: data },
{
onSuccess: () => {
handleSuccess()
},
onError: (error) => {
toast.error(error.message)
},
}
)
})
return (
<RouteDrawer.Form form={form}>
<KeyboundForm
className="flex flex-1 flex-col overflow-hidden"
onSubmit={onSubmit}
>
<RouteDrawer.Body className="flex flex-col gap-y-6 overflow-y-auto">
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="country_code"
render={({ field }) => (
<Form.Item>
<Form.Label>Country</Form.Label>
<Form.Control>
<CountrySelect {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
<div className="grid grid-cols-2 gap-4">
<Form.Field
control={form.control}
name="first_name"
render={({ field }) => (
<Form.Item>
<Form.Label>First name</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
<Form.Field
control={form.control}
name="last_name"
render={({ field }) => (
<Form.Item>
<Form.Label>Last name</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
</div>
<Form.Field
control={form.control}
name="company"
render={({ field }) => (
<Form.Item>
<Form.Label optional>Company</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
<Form.Field
control={form.control}
name="address_1"
render={({ field }) => (
<Form.Item>
<Form.Label>Address</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
<Form.Field
control={form.control}
name="address_2"
render={({ field }) => (
<Form.Item>
<Form.Label optional>Apartment, suite, etc.</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
<div className="grid grid-cols-2 gap-4">
<Form.Field
control={form.control}
name="postal_code"
render={({ field }) => (
<Form.Item>
<Form.Label>Postal code</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
<Form.Field
control={form.control}
name="city"
render={({ field }) => (
<Form.Item>
<Form.Label>City</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
</div>
<Form.Field
control={form.control}
name="province"
render={({ field }) => (
<Form.Item>
<Form.Label optional>Province / State</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
<Form.Field
control={form.control}
name="phone"
render={({ field }) => (
<Form.Item>
<Form.Label optional>Phone</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
</div>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex justify-end gap-2">
<RouteDrawer.Close asChild>
<Button size="small" variant="secondary">
Cancel
</Button>
</RouteDrawer.Close>
<Button size="small" type="submit" isLoading={isPending}>
Save
</Button>
</div>
</RouteDrawer.Footer>
</KeyboundForm>
</RouteDrawer.Form>
)
}
const schema = addressSchema
export default BillingAddress

View File

@@ -0,0 +1,51 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Button, Heading } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { KeyboundForm } from "../../../../components/common/keybound-form"
import { RouteDrawer } from "../../../../components/modals"
const CustomItems = () => {
return (
<RouteDrawer>
<RouteDrawer.Header>
<RouteDrawer.Title asChild>
<Heading>Edit Custom Items</Heading>
</RouteDrawer.Title>
</RouteDrawer.Header>
<CustomItemsForm />
</RouteDrawer>
)
}
const CustomItemsForm = () => {
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
})
return (
<RouteDrawer.Form form={form}>
<KeyboundForm className="flex flex-1 flex-col">
<RouteDrawer.Body></RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex justify-end gap-2">
<RouteDrawer.Close asChild>
<Button size="small" variant="secondary">
Cancel
</Button>
</RouteDrawer.Close>
<Button size="small" type="submit">
Save
</Button>
</div>
</RouteDrawer.Footer>
</KeyboundForm>
</RouteDrawer.Form>
)
}
const schema = z.object({
email: z.string().email(),
})
export default CustomItems

View File

@@ -0,0 +1,111 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { HttpTypes } from "@medusajs/types"
import { Button, Heading, Input, toast } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useParams } from "react-router-dom"
import { z } from "zod"
import { Form } from "../../../../components/common/form"
import { KeyboundForm } from "../../../../components/common/keybound-form"
import { RouteDrawer, useRouteModal } from "../../../../components/modals"
import { useUpdateDraftOrder } from "../../../../hooks/api/draft-orders"
import { useOrder } from "../../../../hooks/api/orders"
const Email = () => {
const { id } = useParams()
const { order, isPending, isError, error } = useOrder(id!, {
fields: "+email",
})
if (isError) {
throw error
}
const isReady = !isPending && !!order
return (
<RouteDrawer>
<RouteDrawer.Header>
<RouteDrawer.Title asChild>
<Heading>Edit Email</Heading>
</RouteDrawer.Title>
<RouteDrawer.Description asChild>
<span className="sr-only">Edit the email for the draft order</span>
</RouteDrawer.Description>
</RouteDrawer.Header>
{isReady && <EmailForm order={order} />}
</RouteDrawer>
)
}
interface EmailFormProps {
order: HttpTypes.AdminOrder
}
const EmailForm = ({ order }: EmailFormProps) => {
const form = useForm<z.infer<typeof schema>>({
defaultValues: {
email: order.email ?? "",
},
resolver: zodResolver(schema),
})
const { mutateAsync, isPending } = useUpdateDraftOrder(order.id)
const { handleSuccess } = useRouteModal()
const onSubmit = form.handleSubmit(async (data) => {
await mutateAsync(
{ email: data.email },
{
onSuccess: () => {
handleSuccess()
},
onError: (error) => {
toast.error(error.message)
},
}
)
})
return (
<RouteDrawer.Form form={form}>
<KeyboundForm
className="flex flex-1 flex-col overflow-hidden"
onSubmit={onSubmit}
>
<RouteDrawer.Body className="flex flex-col gap-y-6 overflow-y-auto">
<Form.Field
control={form.control}
name="email"
render={({ field }) => (
<Form.Item>
<Form.Label>Email</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex justify-end gap-2">
<RouteDrawer.Close asChild>
<Button size="small" variant="secondary">
Cancel
</Button>
</RouteDrawer.Close>
<Button size="small" type="submit" isLoading={isPending}>
Save
</Button>
</div>
</RouteDrawer.Footer>
</KeyboundForm>
</RouteDrawer.Form>
)
}
const schema = z.object({
email: z.string().email(),
})
export default Email

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,420 @@
import { zodResolver } from "@hookform/resolvers/zod"
import {
ArrowDownMini,
ArrowUpMini,
EllipsisVertical,
Trash,
} from "@medusajs/icons"
import {
Button,
DropdownMenu,
Heading,
IconButton,
Skeleton,
clx,
toast,
} from "@medusajs/ui"
import { ComponentPropsWithoutRef, forwardRef } from "react"
import { useFieldArray, useForm } from "react-hook-form"
import { z } from "zod"
import { useParams } from "react-router-dom"
import { ConditionalTooltip } from "../../../../components/common/conditional-tooltip"
import { Form } from "../../../../components/common/form"
import { InlineTip } from "../../../../components/common/inline-tip"
import { KeyboundForm } from "../../../../components/common/keybound-form"
import { RouteDrawer, useRouteModal } from "../../../../components/modals"
import { useUpdateDraftOrder } from "../../../../hooks/api/draft-orders"
import { useOrder } from "../../../../hooks/api/orders"
const MetadataFieldSchema = z.object({
key: z.string(),
disabled: z.boolean().optional(),
value: z.any(),
})
const MetadataSchema = z.object({
metadata: z.array(MetadataFieldSchema),
})
const Metadata = () => {
const { id } = useParams()
const { order, isPending, isError, error } = useOrder(id!, {
fields: "metadata",
})
if (isError) {
throw error
}
const isReady = !isPending && !!order
return (
<RouteDrawer>
<RouteDrawer.Header>
<RouteDrawer.Title asChild>
<Heading>Metadata</Heading>
</RouteDrawer.Title>
<RouteDrawer.Description asChild>
<span className="sr-only">Add metadata to the draft order.</span>
</RouteDrawer.Description>
</RouteDrawer.Header>
{!isReady ? (
<PlaceholderInner />
) : (
<MetadataForm orderId={id!} metadata={order?.metadata} />
)}
</RouteDrawer>
)
}
const METADATA_KEY_LABEL_ID = "metadata-form-key-label"
const METADATA_VALUE_LABEL_ID = "metadata-form-value-label"
interface MetadataFormProps {
orderId: string
metadata?: Record<string, any> | null
}
const MetadataForm = ({ orderId, metadata }: MetadataFormProps) => {
const { handleSuccess } = useRouteModal()
const hasUneditableRows = getHasUneditableRows(metadata)
const { mutateAsync, isPending } = useUpdateDraftOrder(orderId)
const form = useForm<z.infer<typeof MetadataSchema>>({
defaultValues: {
metadata: getDefaultValues(metadata),
},
resolver: zodResolver(MetadataSchema),
})
const handleSubmit = form.handleSubmit(async (data) => {
const parsedData = parseValues(data)
await mutateAsync(
{
metadata: parsedData,
},
{
onSuccess: () => {
toast.success("Metadata updated")
handleSuccess()
},
onError: (error) => {
toast.error(error.message)
},
}
)
})
const { fields, insert, remove } = useFieldArray({
control: form.control,
name: "metadata",
})
function deleteRow(index: number) {
remove(index)
// If the last row is deleted, add a new blank row
if (fields.length === 1) {
insert(0, {
key: "",
value: "",
disabled: false,
})
}
}
function insertRow(index: number, position: "above" | "below") {
insert(index + (position === "above" ? 0 : 1), {
key: "",
value: "",
disabled: false,
})
}
return (
<RouteDrawer.Form form={form}>
<KeyboundForm
onSubmit={handleSubmit}
className="flex flex-1 flex-col overflow-hidden"
>
<RouteDrawer.Body className="flex flex-1 flex-col gap-y-8 overflow-y-auto">
<div className="bg-ui-bg-base shadow-elevation-card-rest grid grid-cols-1 divide-y rounded-lg">
<div className="bg-ui-bg-subtle grid grid-cols-2 divide-x rounded-t-lg">
<div className="txt-compact-small-plus text-ui-fg-subtle px-2 py-1.5">
<label id={METADATA_KEY_LABEL_ID}>Key</label>
</div>
<div className="txt-compact-small-plus text-ui-fg-subtle px-2 py-1.5">
<label id={METADATA_VALUE_LABEL_ID}>Value</label>
</div>
</div>
{fields.map((field, index) => {
const isDisabled = field.disabled || false
let placeholder = "-"
if (typeof field.value === "object") {
placeholder = "{ ... }"
}
if (Array.isArray(field.value)) {
placeholder = "[ ... ]"
}
return (
<ConditionalTooltip
showTooltip={isDisabled}
content="This row is disabled because it contains non-primitive data."
key={field.id}
>
<div className="group/table relative">
<div
className={clx("grid grid-cols-2 divide-x", {
"overflow-hidden rounded-b-lg":
index === fields.length - 1,
})}
>
<Form.Field
control={form.control}
name={`metadata.${index}.key`}
render={({ field }) => {
return (
<Form.Item>
<Form.Control>
<GridInput
aria-labelledby={METADATA_KEY_LABEL_ID}
{...field}
disabled={isDisabled}
placeholder="Key"
/>
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name={`metadata.${index}.value`}
render={({ field: { value, ...field } }) => {
return (
<Form.Item>
<Form.Control>
<GridInput
aria-labelledby={METADATA_VALUE_LABEL_ID}
{...field}
value={isDisabled ? placeholder : value}
disabled={isDisabled}
placeholder="Value"
/>
</Form.Control>
</Form.Item>
)
}}
/>
</div>
<DropdownMenu>
<DropdownMenu.Trigger
className={clx(
"invisible absolute inset-y-0 -right-2.5 my-auto group-hover/table:visible data-[state='open']:visible",
{
hidden: isDisabled,
}
)}
disabled={isDisabled}
asChild
>
<IconButton size="2xsmall">
<EllipsisVertical />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item
className="gap-x-2"
onClick={() => insertRow(index, "above")}
>
<ArrowUpMini className="text-ui-fg-subtle" />
Insert row above
</DropdownMenu.Item>
<DropdownMenu.Item
className="gap-x-2"
onClick={() => insertRow(index, "below")}
>
<ArrowDownMini className="text-ui-fg-subtle" />
Insert row below
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
className="gap-x-2"
onClick={() => deleteRow(index)}
>
<Trash className="text-ui-fg-subtle" />
Delete row
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
</div>
</ConditionalTooltip>
)
})}
</div>
{hasUneditableRows && (
<InlineTip variant="warning" label={"Some rows are disabled"}>
This object contains non-primitive metadata, such as arrays or
objects, that can't be edited here. To edit the disabled rows, use
the API directly.
</InlineTip>
)}
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button size="small" variant="secondary" type="button">
Cancel
</Button>
</RouteDrawer.Close>
<Button size="small" type="submit" isLoading={isPending}>
Save
</Button>
</div>
</RouteDrawer.Footer>
</KeyboundForm>
</RouteDrawer.Form>
)
}
const GridInput = forwardRef<
HTMLInputElement,
ComponentPropsWithoutRef<"input">
>(({ className, ...props }, ref) => {
return (
<input
ref={ref}
{...props}
autoComplete="off"
className={clx(
"txt-compact-small text-ui-fg-base placeholder:text-ui-fg-muted disabled:text-ui-fg-disabled disabled:bg-ui-bg-base bg-transparent px-2 py-1.5 outline-none",
className
)}
/>
)
})
GridInput.displayName = "MetadataForm.GridInput"
const PlaceholderInner = () => {
return (
<div className="flex flex-1 flex-col overflow-hidden">
<RouteDrawer.Body>
<Skeleton className="h-[148ox] w-full rounded-lg" />
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<Skeleton className="h-7 w-12 rounded-md" />
<Skeleton className="h-7 w-12 rounded-md" />
</div>
</RouteDrawer.Footer>
</div>
)
}
const EDITABLE_TYPES = ["string", "number", "boolean"]
function getDefaultValues(
metadata?: Record<string, any> | null
): z.infer<typeof MetadataFieldSchema>[] {
if (!metadata || !Object.keys(metadata).length) {
return [
{
key: "",
value: "",
disabled: false,
},
]
}
return Object.entries(metadata).map(([key, value]) => {
if (!EDITABLE_TYPES.includes(typeof value)) {
return {
key,
value: value,
disabled: true,
}
}
let stringValue = value
if (typeof value !== "string") {
stringValue = JSON.stringify(value)
}
return {
key,
value: stringValue,
original_key: key,
}
})
}
function parseValues(
values: z.infer<typeof MetadataSchema>
): Record<string, any> | null {
const metadata = values.metadata
const isEmpty =
!metadata.length ||
(metadata.length === 1 && !metadata[0].key && !metadata[0].value)
if (isEmpty) {
return null
}
const update: Record<string, any> = {}
metadata.forEach((field) => {
let key = field.key
let value = field.value
const disabled = field.disabled
if (!key || !value) {
return
}
if (disabled) {
update[key] = value
return
}
key = key.trim()
value = value.trim()
// We try to cast the value to a boolean or number if possible
if (value === "true") {
update[key] = true
} else if (value === "false") {
update[key] = false
} else {
const parsedNumber = parseFloat(value)
if (!isNaN(parsedNumber)) {
update[key] = parsedNumber
} else {
update[key] = value
}
}
})
return update
}
function getHasUneditableRows(metadata?: Record<string, any> | null) {
if (!metadata) {
return false
}
return Object.values(metadata).some(
(value) => !EDITABLE_TYPES.includes(typeof value)
)
}
export default Metadata

View File

@@ -0,0 +1,369 @@
import { XMark } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import {
Button,
clx,
Divider,
Heading,
Hint,
IconButton,
Label,
Text,
toast,
} from "@medusajs/ui"
import { useState } from "react"
import { useParams } from "react-router-dom"
import { KeyboundForm } from "../../../../components/common/keybound-form"
import { Combobox } from "../../../../components/inputs/combobox"
import { RouteDrawer, useRouteModal } from "../../../../components/modals"
import {
useDraftOrderAddPromotions,
useDraftOrderConfirmEdit,
useDraftOrderRemovePromotions,
} from "../../../../hooks/api/draft-orders"
import {
useOrderEditRequest,
useOrderPreview,
} from "../../../../hooks/api/orders"
import { usePromotions } from "../../../../hooks/api/promotions"
import { useComboboxData } from "../../../../hooks/common/use-combobox-data"
import { useCancelOrderEdit } from "../../../../hooks/order-edits/use-cancel-order-edit"
import { useInitiateOrderEdit } from "../../../../hooks/order-edits/use-initiate-order-edit"
import { getLocaleAmount } from "../../../../lib/data/currencies"
import { sdk } from "../../../../lib/queries/sdk"
const Promotions = () => {
const { id } = useParams()
const {
order: preview,
isError: isPreviewError,
error: previewError,
} = useOrderPreview(id!, undefined)
useInitiateOrderEdit({ preview })
const { onCancel } = useCancelOrderEdit({ preview })
if (isPreviewError) {
throw previewError
}
const isReady = !!preview
return (
<RouteDrawer onClose={onCancel}>
<RouteDrawer.Header>
<RouteDrawer.Title asChild>
<Heading>Edit Promotions</Heading>
</RouteDrawer.Title>
</RouteDrawer.Header>
{isReady && <PromotionForm preview={preview} />}
</RouteDrawer>
)
}
interface PromotionFormProps {
preview: HttpTypes.AdminOrderPreview
}
const PromotionForm = ({ preview }: PromotionFormProps) => {
const { items, shipping_methods } = preview
const [isSubmitting, setIsSubmitting] = useState(false)
const [comboboxValue, setComboboxValue] = useState("")
const { handleSuccess } = useRouteModal()
const { mutateAsync: addPromotions, isPending: isAddingPromotions } =
useDraftOrderAddPromotions(preview.id)
const promoCodes = getPromotionCodes(items, shipping_methods)
const { promotions, isPending, isError, error } = usePromotions(
{
code: promoCodes,
},
{
enabled: !!promoCodes.length,
}
)
const comboboxData = useComboboxData({
queryKey: ["promotions", "combobox", promoCodes],
queryFn: async (params) => {
return await sdk.admin.promotion.list({
...params,
code: {
$nin: promoCodes,
},
})
},
getOptions: (data) => {
return data.promotions.map((promotion) => ({
label: promotion.code!,
value: promotion.code!,
}))
},
})
const add = async (value?: string) => {
if (!value) {
return
}
addPromotions(
{
promo_codes: [value],
},
{
onError: (e) => {
toast.error(e.message)
comboboxData.onSearchValueChange("")
setComboboxValue("")
},
onSuccess: () => {
comboboxData.onSearchValueChange("")
setComboboxValue("")
},
}
)
}
const { mutateAsync: confirmOrderEdit } = useDraftOrderConfirmEdit(preview.id)
const { mutateAsync: requestOrderEdit } = useOrderEditRequest(preview.id)
const onSubmit = async () => {
setIsSubmitting(true)
let requestSucceeded = false
await requestOrderEdit(undefined, {
onError: (e) => {
toast.error(e.message)
},
onSuccess: () => {
requestSucceeded = true
},
})
if (!requestSucceeded) {
setIsSubmitting(false)
return
}
await confirmOrderEdit(undefined, {
onError: (e) => {
toast.error(e.message)
},
onSuccess: () => {
handleSuccess()
},
onSettled: () => {
setIsSubmitting(false)
},
})
}
if (isError) {
throw error
}
return (
<KeyboundForm className="flex flex-1 flex-col" onSubmit={onSubmit}>
<RouteDrawer.Body>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3">
<div className="flex flex-col">
<Label size="small" weight="plus" htmlFor="promotion-combobox">
Apply promotions
</Label>
<Hint id="promotion-combobox-hint">
Manage promotions that should be applied to the order.
</Hint>
</div>
<Combobox
id="promotion-combobox"
aria-describedby="promotion-combobox-hint"
isFetchingNextPage={comboboxData.isFetchingNextPage}
fetchNextPage={comboboxData.fetchNextPage}
options={comboboxData.options}
onSearchValueChange={comboboxData.onSearchValueChange}
searchValue={comboboxData.searchValue}
disabled={comboboxData.disabled || isAddingPromotions}
onChange={add}
value={comboboxValue}
/>
</div>
<Divider variant="dashed" />
<div className="flex flex-col gap-2">
{promotions?.map((promotion) => (
<PromotionItem
key={promotion.id}
promotion={promotion}
orderId={preview.id}
isLoading={isPending}
/>
))}
</div>
</div>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex justify-end gap-2">
<RouteDrawer.Close asChild>
<Button size="small" variant="secondary">
Cancel
</Button>
</RouteDrawer.Close>
<Button
size="small"
type="submit"
isLoading={isSubmitting || isAddingPromotions}
>
Save
</Button>
</div>
</RouteDrawer.Footer>
</KeyboundForm>
)
}
interface PromotionItemProps {
promotion: HttpTypes.AdminPromotion
orderId: string
isLoading: boolean
}
const PromotionItem = ({
promotion,
orderId,
isLoading,
}: PromotionItemProps) => {
const { mutateAsync: removePromotions, isPending } =
useDraftOrderRemovePromotions(orderId)
const onRemove = async () => {
removePromotions(
{
promo_codes: [promotion.code!],
},
{
onError: (e) => {
toast.error(e.message)
},
}
)
}
const displayValue = getDisplayValue(promotion)
return (
<div
key={promotion.id}
className={clx(
"px-3 py-2 rounded-lg bg-ui-bg-component shadow-elevation-card-rest flex items-center justify-between",
{
"animate-pulse": isLoading,
}
)}
>
<div>
<Text size="small" weight="plus" leading="compact">
{promotion.code}
</Text>
<div className="flex items-center gap-1.5 text-ui-fg-subtle">
{displayValue && (
<div className="flex items-center gap-1.5">
<Text size="small" leading="compact">
{displayValue}
</Text>
<Text size="small" leading="compact">
·
</Text>
</div>
)}
<Text size="small" leading="compact" className="capitalize">
{promotion.application_method?.allocation!}
</Text>
</div>
</div>
<IconButton
size="small"
type="button"
variant="transparent"
onClick={onRemove}
isLoading={isPending || isLoading}
>
<XMark />
</IconButton>
</div>
)
}
function getDisplayValue(promotion: HttpTypes.AdminPromotion) {
const value = promotion.application_method?.value
if (!value) {
return null
}
if (promotion.application_method?.type === "fixed") {
const currency = promotion.application_method?.currency_code
if (!currency) {
return null
}
return getLocaleAmount(value, currency)
} else if (promotion.application_method?.type === "percentage") {
return formatPercentage(value)
}
return null
}
const formatter = new Intl.NumberFormat([], {
style: "percent",
minimumFractionDigits: 2,
})
const formatPercentage = (value?: number | null, isPercentageValue = false) => {
let val = value || 0
if (!isPercentageValue) {
val = val / 100
}
return formatter.format(val)
}
function getPromotionCodes(
items: HttpTypes.AdminOrderPreview["items"],
shippingMethods: HttpTypes.AdminOrderPreview["shipping_methods"]
) {
const codes = new Set<string>()
for (const item of items) {
if (item.adjustments) {
for (const adjustment of item.adjustments) {
if (adjustment.code) {
codes.add(adjustment.code)
}
}
}
}
for (const shippingMethod of shippingMethods) {
if (shippingMethod.adjustments) {
for (const adjustment of shippingMethod.adjustments) {
if (adjustment.code) {
codes.add(adjustment.code)
}
}
}
}
return Array.from(codes)
}
export default Promotions

View File

@@ -0,0 +1,164 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { HttpTypes } from "@medusajs/types"
import { Button, Heading, toast } from "@medusajs/ui"
import { Control, useForm } from "react-hook-form"
import { useParams } from "react-router-dom"
import { z } from "zod"
import { Form } from "../../../../components/common/form"
import { KeyboundForm } from "../../../../components/common/keybound-form"
import { Combobox } from "../../../../components/inputs/combobox"
import { RouteDrawer, useRouteModal } from "../../../../components/modals"
import {
useDraftOrder,
useUpdateDraftOrder,
} from "../../../../hooks/api/draft-orders"
import { useComboboxData } from "../../../../hooks/common/use-combobox-data"
import { sdk } from "../../../../lib/queries/sdk"
const SalesChannel = () => {
const { id } = useParams()
const { draft_order, isPending, isError, error } = useDraftOrder(
id!,
{
fields: "+sales_channel_id",
},
{
enabled: !!id,
}
)
if (isError) {
throw error
}
const ISrEADY = !!draft_order && !isPending
return (
<RouteDrawer>
<RouteDrawer.Header>
<RouteDrawer.Title asChild>
<Heading>Edit Sales Channel</Heading>
</RouteDrawer.Title>
<RouteDrawer.Description asChild>
<span className="sr-only">
Update which sales channel the draft order is associated with
</span>
</RouteDrawer.Description>
</RouteDrawer.Header>
{ISrEADY && <SalesChannelForm order={draft_order} />}
</RouteDrawer>
)
}
interface SalesChannelFormProps {
order: HttpTypes.AdminOrder
}
const SalesChannelForm = ({ order }: SalesChannelFormProps) => {
const form = useForm<z.infer<typeof schema>>({
defaultValues: {
sales_channel_id: order.sales_channel_id || "",
},
resolver: zodResolver(schema),
})
const { mutateAsync, isPending } = useUpdateDraftOrder(order.id)
const { handleSuccess } = useRouteModal()
const onSubmit = form.handleSubmit(async (data) => {
await mutateAsync(
{
sales_channel_id: data.sales_channel_id,
},
{
onSuccess: () => {
toast.success("Sales channel updated")
handleSuccess()
},
onError: (error) => {
toast.error(error.message)
},
}
)
})
return (
<RouteDrawer.Form form={form}>
<KeyboundForm
className="flex flex-1 flex-col overflow-hidden"
onSubmit={onSubmit}
>
<RouteDrawer.Body className="flex flex-col gap-y-6 overflow-y-auto">
<SalesChannelField control={form.control} order={order} />
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex justify-end gap-2">
<RouteDrawer.Close asChild>
<Button size="small" variant="secondary">
Cancel
</Button>
</RouteDrawer.Close>
<Button size="small" type="submit" isLoading={isPending}>
Save
</Button>
</div>
</RouteDrawer.Footer>
</KeyboundForm>
</RouteDrawer.Form>
)
}
interface SalesChannelFieldProps {
order: HttpTypes.AdminOrder
control: Control<z.infer<typeof schema>>
}
const SalesChannelField = ({ control, order }: SalesChannelFieldProps) => {
const salesChannels = useComboboxData({
queryFn: async (params) => {
return await sdk.admin.salesChannel.list(params)
},
queryKey: ["sales-channels"],
getOptions: (data) => {
return data.sales_channels.map((salesChannel) => ({
label: salesChannel.name,
value: salesChannel.id,
}))
},
defaultValue: order.sales_channel_id || undefined,
})
return (
<Form.Field
control={control}
name="sales_channel_id"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>Sales Channel</Form.Label>
<Form.Control>
<Combobox
options={salesChannels.options}
fetchNextPage={salesChannels.fetchNextPage}
isFetchingNextPage={salesChannels.isFetchingNextPage}
searchValue={salesChannels.searchValue}
onSearchValueChange={salesChannels.onSearchValueChange}
placeholder="Select sales channel"
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
)
}
const schema = z.object({
sales_channel_id: z.string().min(1),
})
export default SalesChannel

View File

@@ -0,0 +1,259 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { HttpTypes } from "@medusajs/types"
import { Button, Heading, Input, toast } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useParams } from "react-router-dom"
import { z } from "zod"
import { Form } from "../../../../components/common/form"
import { KeyboundForm } from "../../../../components/common/keybound-form"
import { CountrySelect } from "../../../../components/inputs/country-select"
import { RouteDrawer, useRouteModal } from "../../../../components/modals"
import { useUpdateDraftOrder } from "../../../../hooks/api/draft-orders"
import { useOrder } from "../../../../hooks/api/orders"
import { addressSchema } from "../../../../lib/schemas/address"
const ShippingAddress = () => {
const { id } = useParams()
const { order, isPending, isError, error } = useOrder(id!, {
fields: "+shipping_address",
})
if (isError) {
throw error
}
const isReady = !isPending && !!order
return (
<RouteDrawer>
<RouteDrawer.Header>
<RouteDrawer.Title asChild>
<Heading>Edit Shipping Address</Heading>
</RouteDrawer.Title>
<RouteDrawer.Description asChild>
<span className="sr-only">
Edit the shipping address for the draft order
</span>
</RouteDrawer.Description>
</RouteDrawer.Header>
{isReady && <ShippingAddressForm order={order} />}
</RouteDrawer>
)
}
interface ShippingAddressFormProps {
order: HttpTypes.AdminOrder
}
const ShippingAddressForm = ({ order }: ShippingAddressFormProps) => {
const form = useForm<z.infer<typeof schema>>({
defaultValues: {
first_name: order.shipping_address?.first_name ?? "",
last_name: order.shipping_address?.last_name ?? "",
company: order.shipping_address?.company ?? "",
address_1: order.shipping_address?.address_1 ?? "",
address_2: order.shipping_address?.address_2 ?? "",
city: order.shipping_address?.city ?? "",
province: order.shipping_address?.province ?? "",
country_code: order.shipping_address?.country_code ?? "",
postal_code: order.shipping_address?.postal_code ?? "",
phone: order.shipping_address?.phone ?? "",
},
resolver: zodResolver(schema),
})
const { mutateAsync, isPending } = useUpdateDraftOrder(order.id)
const { handleSuccess } = useRouteModal()
const onSubmit = form.handleSubmit(async (data) => {
await mutateAsync(
{
shipping_address: {
first_name: data.first_name,
last_name: data.last_name,
company: data.company,
address_1: data.address_1,
address_2: data.address_2,
city: data.city,
province: data.province,
country_code: data.country_code,
postal_code: data.postal_code,
phone: data.phone,
},
},
{
onSuccess: () => {
handleSuccess()
},
onError: (error) => {
toast.error(error.message)
},
}
)
})
return (
<RouteDrawer.Form form={form}>
<KeyboundForm
className="flex flex-1 flex-col overflow-hidden"
onSubmit={onSubmit}
>
<RouteDrawer.Body className="flex flex-col gap-y-6 overflow-y-auto">
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="country_code"
render={({ field }) => (
<Form.Item>
<Form.Label>Country</Form.Label>
<Form.Control>
<CountrySelect {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
<div className="grid grid-cols-2 gap-4">
<Form.Field
control={form.control}
name="first_name"
render={({ field }) => (
<Form.Item>
<Form.Label>First name</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
<Form.Field
control={form.control}
name="last_name"
render={({ field }) => (
<Form.Item>
<Form.Label>Last name</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
</div>
<Form.Field
control={form.control}
name="company"
render={({ field }) => (
<Form.Item>
<Form.Label optional>Company</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
<Form.Field
control={form.control}
name="address_1"
render={({ field }) => (
<Form.Item>
<Form.Label>Address</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
<Form.Field
control={form.control}
name="address_2"
render={({ field }) => (
<Form.Item>
<Form.Label optional>Apartment, suite, etc.</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
<div className="grid grid-cols-2 gap-4">
<Form.Field
control={form.control}
name="postal_code"
render={({ field }) => (
<Form.Item>
<Form.Label>Postal code</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
<Form.Field
control={form.control}
name="city"
render={({ field }) => (
<Form.Item>
<Form.Label>City</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
</div>
<Form.Field
control={form.control}
name="province"
render={({ field }) => (
<Form.Item>
<Form.Label optional>Province / State</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
<Form.Field
control={form.control}
name="phone"
render={({ field }) => (
<Form.Item>
<Form.Label optional>Phone</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
</div>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex justify-end gap-2">
<RouteDrawer.Close asChild>
<Button size="small" variant="secondary">
Cancel
</Button>
</RouteDrawer.Close>
<Button size="small" type="submit" isLoading={isPending}>
Save
</Button>
</div>
</RouteDrawer.Footer>
</KeyboundForm>
</RouteDrawer.Form>
)
}
const schema = addressSchema
export default ShippingAddress

View File

@@ -0,0 +1,480 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { HttpTypes } from "@medusajs/types"
import { Button, Heading, Hint, Label, Select, toast } from "@medusajs/ui"
import { Control, useForm } from "react-hook-form"
import { useParams } from "react-router-dom"
import { z } from "zod"
import { Form } from "../../../../components/common/form"
import { KeyboundForm } from "../../../../components/common/keybound-form"
import { Combobox } from "../../../../components/inputs/combobox"
import { RouteDrawer, useRouteModal } from "../../../../components/modals"
import {
useDraftOrder,
useUpdateDraftOrder,
} from "../../../../hooks/api/draft-orders"
import { useComboboxData } from "../../../../hooks/common/use-combobox-data"
import { sdk } from "../../../../lib/queries/sdk"
const TransferOwnership = () => {
const { id } = useParams()
const { draft_order, isPending, isError, error } = useDraftOrder(id!, {
fields: "id,customer_id,customer.*",
})
if (isError) {
throw error
}
const isReady = !isPending && !!draft_order
return (
<RouteDrawer>
<RouteDrawer.Header>
<RouteDrawer.Title asChild>
<Heading>Transfer Ownership</Heading>
</RouteDrawer.Title>
<RouteDrawer.Description asChild>
<span className="sr-only">
Transfer the ownership of this draft order to a new customer
</span>
</RouteDrawer.Description>
</RouteDrawer.Header>
{isReady && <TransferOwnershipForm order={draft_order} />}
</RouteDrawer>
)
}
interface TransferOwnershipFormProps {
order: HttpTypes.AdminDraftOrder
}
const TransferOwnershipForm = ({ order }: TransferOwnershipFormProps) => {
const form = useForm<z.infer<typeof schema>>({
defaultValues: {
customer_id: order.customer_id || "",
},
resolver: zodResolver(schema),
})
const { mutateAsync, isPending } = useUpdateDraftOrder(order.id)
const { handleSuccess } = useRouteModal()
const name = [order.customer?.first_name, order.customer?.last_name]
.filter(Boolean)
.join(" ")
const currentCustomer = order.customer
? {
label: name
? `${name} (${order.customer.email})`
: order.customer.email,
value: order.customer.id,
}
: null
const onSubmit = form.handleSubmit(async (data) => {
await mutateAsync(
{ customer_id: data.customer_id },
{
onSuccess: () => {
toast.success("Customer updated")
handleSuccess()
},
onError: (error) => {
toast.error(error.message)
},
}
)
})
return (
<RouteDrawer.Form form={form}>
<KeyboundForm
className="flex flex-1 flex-col overflow-hidden"
onSubmit={onSubmit}
>
<RouteDrawer.Body className="flex flex-col gap-y-6 overflow-y-auto">
<div className="flex items-center justify-center bg-ui-bg-component rounded-md border">
<Illustration />
</div>
{currentCustomer && (
<div className="flex flex-col space-y-3">
<div className="flex flex-col">
<Label size="small" weight="plus" htmlFor="current-customer">
Current owner
</Label>
<Hint>
The customer that is currently associated with this draft
order.
</Hint>
</div>
<Select disabled value={currentCustomer.value}>
<Select.Trigger id="current-customer">
<Select.Value />
</Select.Trigger>
<Select.Content>
<Select.Item value={currentCustomer.value}>
{currentCustomer.label}
</Select.Item>
</Select.Content>
</Select>
</div>
)}
<CustomerField
control={form.control}
currentCustomerId={order.customer_id}
/>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button variant="secondary" size="small">
Cancel
</Button>
</RouteDrawer.Close>
<Button size="small" type="submit" isLoading={isPending}>
Save
</Button>
</div>
</RouteDrawer.Footer>
</KeyboundForm>
</RouteDrawer.Form>
)
}
interface CustomerFieldProps {
currentCustomerId: string | null
control: Control<z.infer<typeof schema>>
}
const CustomerField = ({ control, currentCustomerId }: CustomerFieldProps) => {
const customers = useComboboxData({
queryFn: async (params) => {
return await sdk.admin.customer.list({
...params,
id: currentCustomerId ? { $nin: [currentCustomerId] } : undefined,
})
},
queryKey: ["customers"],
getOptions: (data) => {
return data.customers.map((customer) => {
const name = [customer.first_name, customer.last_name]
.filter(Boolean)
.join(" ")
return {
label: name ? `${name} (${customer.email})` : customer.email,
value: customer.id,
}
})
},
})
return (
<Form.Field
name="customer_id"
control={control}
render={({ field }) => (
<Form.Item className="space-y-3">
<div className="flex flex-col">
<Form.Label>New customer</Form.Label>
<Form.Hint>The customer to transfer this draft order to.</Form.Hint>
</div>
<Form.Control>
<Combobox
options={customers.options}
fetchNextPage={customers.fetchNextPage}
isFetchingNextPage={customers.isFetchingNextPage}
searchValue={customers.searchValue}
onSearchValueChange={customers.onSearchValueChange}
placeholder="Select customer"
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
)
}
const Illustration = () => {
return (
<svg
width="280"
height="180"
viewBox="0 0 280 180"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="0.00428286"
y="-0.742904"
width="33.5"
height="65.5"
rx="6.75"
transform="matrix(0.865865 0.500278 -0.871576 0.490261 189.756 88.438)"
fill="#D4D4D8"
stroke="#52525B"
strokeWidth="1.5"
/>
<rect
x="0.00428286"
y="-0.742904"
width="33.5"
height="65.5"
rx="6.75"
transform="matrix(0.865865 0.500278 -0.871576 0.490261 189.756 85.4381)"
fill="white"
stroke="#52525B"
strokeWidth="1.5"
/>
<path
d="M180.579 107.142L179.126 107.959"
stroke="#52525B"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
opacity="0.88"
d="M182.305 109.546L180.257 109.534"
stroke="#52525B"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
opacity="0.75"
d="M180.551 111.93L179.108 111.096"
stroke="#52525B"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
opacity="0.63"
d="M176.347 112.897L176.354 111.73"
stroke="#52525B"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
opacity="0.5"
d="M172.153 111.881L173.606 111.064"
stroke="#52525B"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
opacity="0.38"
d="M170.428 109.478L172.476 109.489"
stroke="#52525B"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
opacity="0.25"
d="M172.181 107.094L173.624 107.928"
stroke="#52525B"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
opacity="0.13"
d="M176.386 106.126L176.379 107.294"
stroke="#52525B"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<rect
width="12"
height="3"
rx="1.5"
transform="matrix(0.865865 0.500278 -0.871576 0.490261 196.447 92.2925)"
fill="#D4D4D8"
/>
<rect
x="0.00428286"
y="-0.742904"
width="33.5"
height="65.5"
rx="6.75"
transform="matrix(0.865865 0.500278 -0.871576 0.490261 117.023 46.4147)"
fill="#D4D4D8"
stroke="#52525B"
strokeWidth="1.5"
/>
<rect
x="0.00428286"
y="-0.742904"
width="33.5"
height="65.5"
rx="6.75"
transform="matrix(0.865865 0.500278 -0.871576 0.490261 117.023 43.4147)"
fill="white"
stroke="#52525B"
strokeWidth="1.5"
/>
<rect
width="12"
height="3"
rx="1.5"
transform="matrix(0.865865 0.500278 -0.871576 0.490261 123.714 50.2691)"
fill="#D4D4D8"
/>
<rect
width="17"
height="3"
rx="1.5"
transform="matrix(0.865865 0.500278 -0.871576 0.490261 97.5557 66.958)"
fill="#D4D4D8"
/>
<rect
width="12"
height="3"
rx="1.5"
transform="matrix(0.865865 0.500278 -0.871576 0.490261 93.1978 69.4093)"
fill="#D4D4D8"
/>
<path
d="M92.3603 63.9563C90.9277 63.1286 88.59 63.1152 87.148 63.9263C85.7059 64.7374 85.6983 66.0702 87.1308 66.8979C88.5634 67.7256 90.9011 67.7391 92.3432 66.928C93.7852 66.1168 93.7929 64.784 92.3603 63.9563ZM88.4382 66.1625C87.7221 65.7488 87.726 65.0822 88.4468 64.6767C89.1676 64.2713 90.3369 64.278 91.0529 64.6917C91.769 65.1055 91.7652 65.7721 91.0444 66.1775C90.3236 66.583 89.1543 66.5762 88.4382 66.1625Z"
fill="#A1A1AA"
/>
<rect
width="17"
height="3"
rx="1.5"
transform="matrix(0.865865 0.500278 -0.871576 0.490261 109.758 60.0944)"
fill="#A1A1AA"
/>
<rect
width="12"
height="3"
rx="1.5"
transform="matrix(0.865865 0.500278 -0.871576 0.490261 105.4 62.5457)"
fill="#A1A1AA"
/>
<path
d="M104.562 57.0927C103.13 56.265 100.792 56.2515 99.3501 57.0626C97.9081 57.8738 97.9004 59.2065 99.333 60.0343C100.766 60.862 103.103 60.8754 104.545 60.0643C105.987 59.2532 105.995 57.9204 104.562 57.0927ZM103.858 58.8972L100.815 59.1265C100.683 59.1367 100.55 59.1134 100.449 59.063C100.44 59.0585 100.432 59.0545 100.425 59.05C100.339 59.0005 100.29 58.9336 100.291 58.8637L100.294 58.1201C100.294 57.9752 100.501 57.8585 100.756 57.86C101.01 57.8615 101.217 57.98 101.216 58.1256L101.214 58.5669L103.732 58.3769C103.984 58.3578 104.217 58.4584 104.251 58.603C104.286 58.7468 104.11 58.8788 103.858 58.8977L103.858 58.8972Z"
fill="#52525B"
/>
<g clipPath="url(#clip0_20915_38670)">
<path
d="M133.106 81.8022L140.49 81.8447L140.515 77.6349"
stroke="#A1A1AA"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
<g clipPath="url(#clip1_20915_38670)">
<path
d="M143.496 87.8055L150.881 87.8481L150.905 83.6383"
stroke="#A1A1AA"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
<g clipPath="url(#clip2_20915_38670)">
<path
d="M153.887 93.8088L161.271 93.8514L161.295 89.6416"
stroke="#A1A1AA"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
<g clipPath="url(#clip3_20915_38670)">
<path
d="M126.114 89.1912L118.729 89.1486L118.705 93.3584"
stroke="#A1A1AA"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
<g clipPath="url(#clip4_20915_38670)">
<path
d="M136.504 95.1945L129.12 95.1519L129.095 99.3617"
stroke="#A1A1AA"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
<g clipPath="url(#clip5_20915_38670)">
<path
d="M146.894 101.198L139.51 101.155L139.486 105.365"
stroke="#A1A1AA"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_20915_38670">
<rect
width="12"
height="12"
fill="white"
transform="matrix(0.865865 0.500278 -0.871576 0.490261 138.36 74.6508)"
/>
</clipPath>
<clipPath id="clip1_20915_38670">
<rect
width="12"
height="12"
fill="white"
transform="matrix(0.865865 0.500278 -0.871576 0.490261 148.75 80.6541)"
/>
</clipPath>
<clipPath id="clip2_20915_38670">
<rect
width="12"
height="12"
fill="white"
transform="matrix(0.865865 0.500278 -0.871576 0.490261 159.141 86.6575)"
/>
</clipPath>
<clipPath id="clip3_20915_38670">
<rect
width="12"
height="12"
fill="white"
transform="matrix(0.865865 0.500278 -0.871576 0.490261 120.928 84.4561)"
/>
</clipPath>
<clipPath id="clip4_20915_38670">
<rect
width="12"
height="12"
fill="white"
transform="matrix(0.865865 0.500278 -0.871576 0.490261 131.318 90.4594)"
/>
</clipPath>
<clipPath id="clip5_20915_38670">
<rect
width="12"
height="12"
fill="white"
transform="matrix(0.865865 0.500278 -0.871576 0.490261 141.709 96.4627)"
/>
</clipPath>
</defs>
</svg>
)
}
const schema = z.object({
customer_id: z.string().min(1),
})
export default TransferOwnership

View File

@@ -0,0 +1,109 @@
import { Fragment } from "react"
import {
LoaderFunctionArgs,
Outlet,
UIMatch,
useParams,
} from "react-router-dom"
import { HttpTypes } from "@medusajs/types"
import { PageSkeleton } from "../../../components/common/page-skeleton"
import { ActiveOrderChange } from "../../../components/draft-orders/active-order-changes"
import { ActivitySection } from "../../../components/draft-orders/activity-section"
import { CustomerSection } from "../../../components/draft-orders/customer-section"
import { GeneralSection } from "../../../components/draft-orders/general-section"
import { JsonViewSection } from "../../../components/draft-orders/json-view-section"
import { MetadataSection } from "../../../components/draft-orders/metadata-section"
import { ShippingSection } from "../../../components/draft-orders/shipping-section"
import { SummarySection } from "../../../components/draft-orders/summary-section"
import { useOrder, useOrderChanges } from "../../../hooks/api/orders"
import { sdk } from "../../../lib/queries/sdk"
type AdminDraftOrderSummary = HttpTypes.AdminOrder & {
promotions: HttpTypes.AdminPromotion[]
}
export async function loader({ params }: LoaderFunctionArgs) {
const { id } = params
const data = await sdk.admin.order.retrieve(id!, {
fields: "id,display_id",
})
return data
}
export const handle = {
breadcrumb: (match: UIMatch<HttpTypes.AdminOrderResponse>) =>
`#${match.data.order.display_id}`,
}
const ID = () => {
const { id } = useParams()
const { order, isPending, isError, error } = useOrder(id!, {
fields:
"+customer.*,+sales_channel.*,+region.*,+email,+items.*,+items.variant.*,+items.variant.product.*,+items.variant.product.shipping_profile.*,+items.variant.options.*,+currency_code,+promotions.*",
})
const {
order_changes,
isPending: isOrderChangesPending,
isError: isOrderChangesError,
error: orderChangesError,
} = useOrderChanges(id!, {
change_type: ["edit", "transfer", "update_order"],
})
if (isError) {
throw error
}
if (isOrderChangesError) {
throw orderChangesError
}
const isReady =
!isPending && !isOrderChangesPending && !!order && !!order_changes
if (!isReady) {
return (
<PageSkeleton
mainSections={3}
sidebarSections={2}
showJSON
showMetadata
/>
)
}
return (
<Fragment>
<div className="flex w-full flex-col gap-y-3">
<div className="flex w-full flex-col items-start gap-x-4 gap-y-3 xl:grid xl:grid-cols-[minmax(0,_1fr)_440px]">
<div className="flex w-full min-w-0 flex-col gap-y-3">
<ActiveOrderChange orderId={order.id} />
<GeneralSection order={order} />
<SummarySection order={order as AdminDraftOrderSummary} />
<ShippingSection order={order} />
<div className="hidden flex-col gap-y-3 xl:flex">
<MetadataSection order={order} />
<JsonViewSection data={order} />
</div>
</div>
<div className="flex w-full flex-col gap-y-3 xl:mt-0">
<CustomerSection order={order} />
<ActivitySection order={order} changes={order_changes} />
<div className="flex flex-col gap-y-3 xl:hidden">
<MetadataSection order={order} />
<JsonViewSection data={order} />
</div>
</div>
</div>
</div>
<Outlet />
</Fragment>
)
}
export default ID

View File

@@ -0,0 +1,245 @@
import { defineRouteConfig } from "@medusajs/admin-sdk"
import type { HttpTypes } from "@medusajs/types"
import {
Container,
createDataTableColumnHelper,
DataTableFilter,
Tooltip,
} from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query"
import { Fragment, useMemo } from "react"
import { Outlet } from "react-router-dom"
import { DataTable } from "../../components/common/data-table"
import { useCustomers } from "../../hooks/api/customers"
import { useDraftOrders } from "../../hooks/api/draft-orders"
import { useRegions } from "../../hooks/api/regions"
import { useSalesChannels } from "../../hooks/api/sales-channels"
import { useDataTableDateFilters } from "../../hooks/common/use-data-table-date-filters"
import { useQueryParams } from "../../hooks/common/use-query-params"
import { getFullDate } from "../../lib/utils/date-utils"
const PAGE_SIZE = 20
export const handle = {
breadcrumb: () => "Draft Orders",
}
const List = () => {
const queryParams = useDraftOrderTableQuery({
pageSize: PAGE_SIZE,
})
const { draft_orders, count, isPending, isError, error } = useDraftOrders(
{
...queryParams,
order: queryParams.order ?? "-created_at",
fields:
"+customer.*,+sales_channel.*,+email,+display_id,+total,+currency_code,+shipping_total,+tax_total,+discount_total,+items.*,+items.variant.*,+items.variant.product.*,+items.variant.product.shipping_profile.*,+items.variant.options.*,+region.*",
},
{
placeholderData: keepPreviousData,
}
)
const columns = useColumns()
const filters = useFilters()
if (isError) {
throw error
}
return (
<Fragment>
<Container className="p-0">
<DataTable
data={draft_orders}
getRowId={(row) => row.id}
columns={columns}
filters={filters}
isLoading={isPending}
pageSize={PAGE_SIZE}
rowCount={count}
heading="Draft Orders"
action={{
label: "Create",
to: "create",
}}
rowHref={(row) => `${row.id}`}
emptyState={{
empty: {
heading: "No draft orders found",
description: "Create a new draft order to get started.",
},
filtered: {
heading: "No results found",
description: "No draft orders match your filter criteria.",
},
}}
/>
</Container>
<Outlet />
</Fragment>
)
}
export const config = defineRouteConfig({
label: "Drafts",
nested: "/orders",
})
const columnHelper = createDataTableColumnHelper<HttpTypes.AdminOrder>()
const useColumns = () => {
return useMemo(
() => [
columnHelper.accessor("display_id", {
header: "Display ID",
cell: ({ getValue }) => {
return `#${getValue()}`
},
enableSorting: true,
}),
columnHelper.accessor("created_at", {
header: "Date",
cell: ({ getValue }) => {
return (
<Tooltip
content={getFullDate({ date: getValue(), includeTime: true })}
>
<span>{getFullDate({ date: getValue() })}</span>
</Tooltip>
)
},
enableSorting: true,
}),
columnHelper.accessor("customer_id", {
header: "Customer",
cell: ({ row }) => {
return row.original.customer?.email || "-"
},
enableSorting: true,
}),
columnHelper.accessor("sales_channel_id", {
header: "Sales Channel",
cell: ({ row }) => {
return row.original.sales_channel?.name || "-"
},
enableSorting: true,
}),
columnHelper.accessor("region_id", {
header: "Region",
cell: ({ row }) => {
return row.original.region?.name || "-"
},
enableSorting: true,
}),
],
[]
)
}
const useFilters = (): DataTableFilter[] => {
const dateFilterOptions = useDataTableDateFilters()
const { customers } = useCustomers(
{
limit: 1000,
fields: "id,email",
},
{
throwOnError: true,
}
)
const { sales_channels } = useSalesChannels(
{
limit: 1000,
fields: "id,name",
},
{
throwOnError: true,
}
)
const { regions } = useRegions(
{
limit: 1000,
fields: "id,name",
},
{ throwOnError: true }
)
return useMemo(() => {
return [
{
id: "customer_id",
label: "Customer",
options:
customers?.map((customer) => ({
label: customer.email,
value: customer.id,
})) ?? [],
type: "select",
},
{
id: "sales_channel_id",
label: "Sales Channel",
options:
sales_channels?.map((sales_channel) => ({
label: sales_channel.name,
value: sales_channel.id,
})) ?? [],
type: "select",
},
{
id: "region_id",
label: "Region",
options:
regions?.map((region) => ({
label: region.name,
value: region.id,
})) ?? [],
type: "select",
},
...dateFilterOptions,
] satisfies DataTableFilter[]
}, [customers, sales_channels, regions, dateFilterOptions])
}
type UseDraftOrderTableQueryProps = {
prefix?: string
pageSize?: number
}
export const useDraftOrderTableQuery = ({
prefix,
pageSize = 20,
}: UseDraftOrderTableQueryProps) => {
const queryObject = useQueryParams(
[
"offset",
"q",
"order",
"customer_id",
"region_id",
"created_at",
"updated_at",
],
prefix
)
const { offset, created_at, updated_at, ...rest } = queryObject
const searchParams: HttpTypes.AdminDraftOrderListParams = {
limit: pageSize,
offset: offset ? Number(offset) : 0,
created_at: created_at ? JSON.parse(created_at) : undefined,
updated_at: updated_at ? JSON.parse(updated_at) : undefined,
...rest,
}
return searchParams
}
export default List

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["."]
}

View File

@@ -0,0 +1,26 @@
interface AdminAddressPayload {
first_name: string
last_name: string
address_1: string
address_2?: string | null
city: string
country_code: string
province?: string | null
postal_code: string
phone?: string | null
company?: string | null
}
export interface AdminCreateDraftOrder {
sales_channel_id?: string | null
email?: string | null
customer_id?: string | null
region_id: string
promo_codes?: string[] | null
currency_code?: string | null
billing_address?: AdminAddressPayload | null
shipping_address?: AdminAddressPayload | null
no_notification_order?: boolean | null
shipping_methods?: string[] | null
metadata?: Record<string, unknown> | null
}

View File

@@ -0,0 +1,5 @@
import { HttpTypes } from "@medusajs/types"
export interface AdminOrderResponse {
draft_order: HttpTypes.AdminOrder
}

View File

@@ -0,0 +1,5 @@
import { HttpTypes } from "@medusajs/types"
export type AdminOrderPreviewLineItem = HttpTypes.AdminOrderLineItem & {
actions?: HttpTypes.AdminOrderChangeAction[]
}

View File

@@ -0,0 +1,13 @@
export interface AdminOrderEditAddShippingMethod {
shipping_option_id: string
custom_amount?: number | undefined
description?: string | undefined
internal_note?: string | undefined
metadata?: Record<string, unknown> | undefined
}
export interface AdminOrderEditUpdateShippingMethod {
custom_amount?: number | null | undefined
internal_note?: string | null | undefined
metadata?: Record<string, unknown> | null | undefined
}

View File

@@ -0,0 +1,9 @@
import preset from "@medusajs/ui-preset";
import { Config } from "tailwindcss";
const config: Config = {
content: ["./src/admin/**/*.{ts,tsx}"],
presets: [preset],
};
export default config;

View File

@@ -0,0 +1,37 @@
{
"compilerOptions": {
"target": "ES2021",
"esModuleInterop": true,
"module": "Node16",
"moduleResolution": "Node16",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"declaration": false,
"sourceMap": false,
"inlineSourceMap": true,
"outDir": "./.medusa/server",
"rootDir": "./",
"baseUrl": ".",
"jsx": "react-jsx",
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"checkJs": false,
"strictNullChecks": true
},
"ts-node": {
"swc": true
},
"include": [
"**/*",
".medusa/types/*"
],
"exclude": [
"node_modules",
".medusa/server",
".medusa/admin",
"src/admin",
".cache"
]
}