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:
86
packages/plugins/draft-order/README.md
Normal file
86
packages/plugins/draft-order/README.md
Normal 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/)
|
||||
101
packages/plugins/draft-order/package.json
Normal file
101
packages/plugins/draft-order/package.json
Normal 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"
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 "{searchValue}"
|
||||
</Text>
|
||||
</PrimitiveComboboxItem>
|
||||
</Fragment>
|
||||
)}
|
||||
</PrimitiveComboboxPopover>
|
||||
</PrimitiveComboboxProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export const Combobox = genericForwardRef(ComboboxImpl)
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./route-drawer"
|
||||
@@ -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,
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./route-focus-modal"
|
||||
@@ -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,
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./route-modal-form"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./route-provider"
|
||||
export * from "./use-route-modal"
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./stacked-drawer"
|
||||
@@ -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,
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./stacked-focus-modal"
|
||||
@@ -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,
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./stacked-modal-provider"
|
||||
export * from "./use-stacked-modal"
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
139
packages/plugins/draft-order/src/admin/hooks/api/customers.tsx
Normal file
139
packages/plugins/draft-order/src/admin/hooks/api/customers.tsx
Normal 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 }
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
457
packages/plugins/draft-order/src/admin/hooks/api/orders.tsx
Normal file
457
packages/plugins/draft-order/src/admin/hooks/api/orders.tsx
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
65
packages/plugins/draft-order/src/admin/hooks/api/regions.tsx
Normal file
65
packages/plugins/draft-order/src/admin/hooks/api/regions.tsx
Normal 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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
41
packages/plugins/draft-order/src/admin/hooks/api/users.tsx
Normal file
41
packages/plugins/draft-order/src/admin/hooks/api/users.tsx
Normal 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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
1767
packages/plugins/draft-order/src/admin/lib/data/countries.ts
Normal file
1767
packages/plugins/draft-order/src/admin/lib/data/countries.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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()}`
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const orderDetailQuery = (id: string) => ({
|
||||
queryKey: ordersQueryKeys.detail(id),
|
||||
queryFn: async () =>
|
||||
sdk.admin.order.retrieve(id, {
|
||||
fields: DEFAULT_FIELDS,
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import Medusa from "@medusajs/js-sdk"
|
||||
|
||||
export const sdk = new Medusa({
|
||||
baseUrl: "/",
|
||||
auth: {
|
||||
type: "session",
|
||||
},
|
||||
})
|
||||
@@ -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(),
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function convertNumber(value?: string | number) {
|
||||
return typeof value === "string" ? Number(value.replace(",", ".")) : value
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function pluralize(count: number, plural: string, singular: string) {
|
||||
return count === 1 ? singular : plural
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
24
packages/plugins/draft-order/src/admin/tsconfig.json
Normal file
24
packages/plugins/draft-order/src/admin/tsconfig.json
Normal 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": ["."]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
export interface AdminOrderResponse {
|
||||
draft_order: HttpTypes.AdminOrder
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
export type AdminOrderPreviewLineItem = HttpTypes.AdminOrderLineItem & {
|
||||
actions?: HttpTypes.AdminOrderChangeAction[]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
9
packages/plugins/draft-order/tailwind.config.ts
Normal file
9
packages/plugins/draft-order/tailwind.config.ts
Normal 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;
|
||||
37
packages/plugins/draft-order/tsconfig.json
Normal file
37
packages/plugins/draft-order/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user