feat(dashboard): Draft orders create (#6680)

**What**
- Adds Create draft order form
- Updates draft order details page to also display "custom" items.

**Note**
- Currently, the form is missing a way to input a discount code. Need to rethink this a bit, as the we can't implement the design in Figma.
- The current design is missing a way to select from a customers existing shipping addresses, we should add that to keep the features we have today.
- This PR uses `useInfiniteQuery` which does not work on our staging (due to duplicate dependencies as a result of building straight from the monorepo), so you will need to test locally.
This commit is contained in:
Kasper Fabricius Kristensen
2024-03-25 17:18:24 +00:00
committed by GitHub
parent 20132d7cea
commit 26531c5a38
54 changed files with 3414 additions and 536 deletions
@@ -0,0 +1,130 @@
import i18n from "i18next"
import { z } from "zod"
import { castNumber } from "../../../../../lib/cast-number"
export const AddressPayload = z.object({
first_name: z.string().min(1),
last_name: z.string().min(1),
address_1: z.string().min(1),
address_2: z.string().optional(),
city: z.string().min(1),
province: z.string().optional(),
postal_code: z.string().min(1),
country_code: z.string().min(1),
phone: z.string().optional(),
company: z.string().optional(),
})
export const ExistingItemSchema = z
.object({
product_title: z.string().optional(),
thumbnail: z.string().optional(),
variant_title: z.string().optional(),
variant_id: z.string().min(1),
sku: z.string().optional(),
quantity: z.number().min(1),
unit_price: z.number().min(0),
custom_unit_price: z.union([z.number(), z.string()]).optional(),
})
.superRefine((data, ctx) => {
if (data.custom_unit_price && isNaN(castNumber(data.custom_unit_price))) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid custom unit price",
path: ["custom_unit_price"],
})
}
})
export const CustomItemSchema = z
.object({
title: z.string().min(1),
quantity: z.number().min(1),
unit_price: z.union([z.number(), z.string()]),
})
.superRefine((data, ctx) => {
if (
typeof data.unit_price === "string" &&
isNaN(castNumber(data.unit_price))
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid unit price",
path: ["unit_price"],
})
}
})
export const ShippingMethodSchema = z
.object({
option_id: z.string().min(1),
option_title: z.string(),
amount: z.union([z.number(), z.string()]).optional(),
custom_amount: z.union([z.number(), z.string()]).optional(),
})
.superRefine((data, ctx) => {
if (data.custom_amount && isNaN(castNumber(data.custom_amount))) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid custom amount",
path: ["custom_amount"],
})
}
})
export const CreateDraftOrderSchema = z
.object({
email: z.string().optional(),
region_id: z.string().min(1),
customer_id: z.string().optional(),
shipping_address: AddressPayload,
billing_address: AddressPayload.nullable(),
existing_items: z.array(ExistingItemSchema).optional(),
custom_items: z.array(CustomItemSchema).optional(),
shipping_method: ShippingMethodSchema,
notification_order: z.boolean().optional(),
})
.superRefine((data, ctx) => {
if (!data.existing_items?.length && !data.custom_items?.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: i18n.t("draftOrders.validation.requiredItems"),
path: ["custom_items"],
})
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: i18n.t("draftOrders.validation.requiredItems"),
path: ["existing_items"],
})
}
if (!data.email && !data.customer_id) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: i18n.t("draftOrders.validation.requiredEmailOrCustomer"),
path: ["customer_id"],
})
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: i18n.t("draftOrders.validation.requiredEmailOrCustomer"),
path: ["email"],
})
} else if (!data.customer_id && data.email) {
const parsedEmail = z.string().email().safeParse(data.email)
if (!parsedEmail.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: i18n.t("draftOrders.validation.invalidEmail"),
path: ["email"],
})
}
}
})
export enum View {
EXISTING_ITEMS = "existing_items",
CUSTOM_ITEMS = "custom_items",
}
@@ -0,0 +1,5 @@
import { createContext } from "react"
import { CreateDraftOrderContextValue } from "./types"
export const CreateDraftOrderContext =
createContext<CreateDraftOrderContextValue | null>(null)
@@ -0,0 +1,276 @@
import { Region } from "@medusajs/medusa"
import { Checkbox, Heading, Input, Label, Select, Text } from "@medusajs/ui"
import * as Collapsible from "@radix-ui/react-collapsible"
import { Control } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
import { ConditionalTooltip } from "../../../../../../components/common/conditional-tooltip"
import { Form } from "../../../../../../components/common/form"
import { CreateDraftOrderSchema } from "../constants"
import { useCreateDraftOrder } from "../hooks"
export const CreateDraftOrderAddressDetails = () => {
const { t } = useTranslation()
const { form, region, sameAsShipping, setSameAsShipping } =
useCreateDraftOrder()
return (
<div className="flex flex-col gap-y-8">
<div className="flex flex-col gap-y-4">
<Heading level="h2">{t("fields.address")}</Heading>
<Text size="small" leading="compact" weight="plus">
{t("addresses.shippingAddress.label")}
</Text>
<AddressFieldset
field="shipping_address"
region={region}
control={form.control}
/>
</div>
<div className="flex flex-col gap-y-4">
<div className="flex flex-col gap-y-2">
<Text size="small" leading="compact" weight="plus">
{t("addresses.billingAddress.label")}
</Text>
<Label className="flex cursor-pointer items-center gap-x-2">
<Checkbox
checked={sameAsShipping}
onCheckedChange={(checked) => setSameAsShipping(checked === true)}
/>
{t("addresses.billingAddress.sameAsShipping")}
</Label>
</div>
<Collapsible.Root open={!sameAsShipping}>
<Collapsible.Content>
<AddressFieldset
field="billing_address"
region={region}
control={form.control}
/>
</Collapsible.Content>
</Collapsible.Root>
</div>
</div>
)
}
const AddressFieldset = ({
field,
control,
region,
}: {
field: "shipping_address" | "billing_address"
region: Region | null
control: Control<z.infer<typeof CreateDraftOrderSchema>>
}) => {
const { t } = useTranslation()
return (
<fieldset className="flex flex-col gap-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Form.Field
control={control}
name={`${field}.first_name`}
render={({ field }) => {
return (
<Form.Item>
<Form.Label className="text-ui-fg-subtle font-normal">
{t("fields.firstName")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={control}
name={`${field}.last_name`}
render={({ field }) => {
return (
<Form.Item>
<Form.Label className="text-ui-fg-subtle font-normal">
{t("fields.lastName")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Form.Field
control={control}
name={`${field}.company`}
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional className="text-ui-fg-subtle font-normal">
{t("fields.company")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={control}
name={`${field}.phone`}
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional className="text-ui-fg-subtle font-normal">
{t("fields.phone")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Form.Field
control={control}
name={`${field}.address_1`}
render={({ field }) => {
return (
<Form.Item>
<Form.Label className="text-ui-fg-subtle font-normal">
{t("fields.address")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={control}
name={`${field}.address_2`}
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional className="text-ui-fg-subtle font-normal">
{t("fields.address2")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Form.Field
control={control}
name={`${field}.city`}
render={({ field }) => {
return (
<Form.Item>
<Form.Label className="text-ui-fg-subtle font-normal">
{t("fields.city")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={control}
name={`${field}.postal_code`}
render={({ field }) => {
return (
<Form.Item>
<Form.Label className="text-ui-fg-subtle font-normal">
{t("fields.postalCode")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Form.Field
control={control}
name={`${field}.province`}
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional className="text-ui-fg-subtle font-normal">
{t("fields.province")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={control}
name={`${field}.country_code`}
render={({
field: { onChange, ref, disabled, ...field },
fieldState: { error },
}) => {
return (
<ConditionalTooltip
showTooltip={!region}
content={t("draftOrders.create.chooseRegionTooltip")}
>
<Form.Item>
<Form.Label className="text-ui-fg-subtle font-normal">
{t("fields.country")}
</Form.Label>
<Form.Control>
<Select
disabled={!region || disabled}
onValueChange={onChange}
{...field}
>
<Select.Trigger aria-invalid={!!error} ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{region?.countries.map((c) => (
<Select.Item key={c.iso_2} value={c.iso_2}>
{c.display_name}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
</ConditionalTooltip>
)
}}
/>
</div>
</fieldset>
)
}
@@ -0,0 +1,164 @@
import { Customer } from "@medusajs/medusa"
import { Checkbox, Heading, Input, Label } from "@medusajs/ui"
import { useInfiniteQuery } from "@tanstack/react-query"
import { debounce } from "lodash"
import { useMedusa } from "medusa-react"
import { useCallback, useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { json } from "react-router-dom"
import { Combobox } from "../../../../../../components/common/combobox"
import { Form } from "../../../../../../components/common/form"
import { useCreateDraftOrder } from "../hooks"
export const CreateDraftOrderCustomerDetails = () => {
const [useExistingCustomer, setUseExistingCustomer] = useState(true)
const [query, setQuery] = useState("")
const [debouncedQuery, setDebouncedQuery] = useState("")
const { t } = useTranslation()
const { form, setCustomer } = useCreateDraftOrder()
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedUpdate = useCallback(
debounce((query) => setDebouncedQuery(query), 300),
[]
)
useEffect(() => {
debouncedUpdate(query)
return () => debouncedUpdate.cancel()
}, [query, debouncedUpdate])
const { client } = useMedusa()
const { data, fetchNextPage, isFetchingNextPage } = useInfiniteQuery(
["customers", debouncedQuery],
async ({ pageParam = 0 }) => {
const res = await client.admin.customers.list({
q: debouncedQuery,
limit: 10,
offset: pageParam,
has_account: true, // Only show customers with confirmed accounts
})
return res
},
{
getNextPageParam: (lastPage) => {
const moreCustomersExist =
lastPage.count > lastPage.offset + lastPage.limit
return moreCustomersExist ? lastPage.offset + lastPage.limit : undefined
},
keepPreviousData: true,
}
)
const createLabel = (customer?: Customer) => {
if (!customer) {
return ""
}
const { first_name, last_name, email } = customer
const name = [first_name, last_name].filter(Boolean).join(" ")
if (name) {
return `${name} (${email})`
}
return email
}
const handleCustomerChange = (cusId: string | undefined) => {
if (!cusId) {
setCustomer(null)
return
}
const customer = data?.pages
.flatMap((page) => page.customers)
.find((c) => c.id === cusId)
if (!customer) {
throw json({ message: "Customer not found" }, 400)
}
form.setValue("email", customer.email, {
shouldDirty: true,
shouldTouch: true,
})
setCustomer(customer)
}
const options =
data?.pages.flatMap((page) =>
page.customers.map((c) => ({ label: createLabel(c), value: c.id }))
) ?? []
return (
<div className="flex flex-col gap-y-4">
<Heading level="h2">{t("fields.customer")}</Heading>
<fieldset className="grid grid-cols-1 gap-4 md:grid-cols-2">
{useExistingCustomer ? (
<Form.Field
key="customer-input"
control={form.control}
name="customer_id"
render={({ field: { onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label className="text-ui-fg-subtle !font-normal">
{t("fields.customer")}
</Form.Label>
<Form.Control>
<Combobox
{...field}
onChange={(val) => {
onChange(val)
handleCustomerChange(val)
}}
searchValue={query}
onSearchValueChange={setQuery}
fetchNextPage={fetchNextPage}
isFetchingNextPage={isFetchingNextPage}
options={options}
autoComplete="false"
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
) : (
<Form.Field
key="email-input"
control={form.control}
name="email"
render={({ field }) => {
return (
<Form.Item>
<Form.Label className="text-ui-fg-subtle !font-normal">
{t("fields.email")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
)}
</fieldset>
<Label className="flex w-fit items-center gap-x-2">
<Checkbox
checked={useExistingCustomer}
onCheckedChange={(val) => setUseExistingCustomer(!!val)}
/>
<span>{t("draftOrders.create.useExistingCustomerLabel")}</span>
</Label>
</div>
)
}
@@ -0,0 +1,34 @@
import { Heading, Text } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { Divider } from "../../../../../../components/common/divider"
import { CreateDraftOrderAddressDetails } from "./create-draft-order-address-details"
import { CreateDraftOrderCustomerDetails } from "./create-draft-order-customer-details"
import { CreateDraftOrderItemsDetails } from "./create-draft-order-items-details"
import { CreateDraftOrderRegionDetails } from "./create-draft-order-region-details"
import { CreateDraftOrderShippingMethodDetails } from "./create-draft-order-shipping-method-details"
export const CreateDraftOrderDetails = () => {
const { t } = useTranslation()
return (
<div className="flex flex-col items-center p-16">
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
<div>
<Heading>{t("draftOrders.create.createDraftOrder")}</Heading>
<Text size="small" className="text-ui-fg-subtle">
{t("draftOrders.create.createDraftOrderHint")}
</Text>
</div>
<CreateDraftOrderRegionDetails />
<Divider />
<CreateDraftOrderCustomerDetails />
<Divider />
<CreateDraftOrderItemsDetails />
<Divider />
<CreateDraftOrderShippingMethodDetails />
<Divider />
<CreateDraftOrderAddressDetails />
</div>
</div>
)
}
@@ -0,0 +1,321 @@
import { Trash } from "@medusajs/icons"
import { Button, CurrencyInput, Input, Text } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../../components/common/action-menu"
import { ConditionalTooltip } from "../../../../../../components/common/conditional-tooltip"
import { Form } from "../../../../../../components/common/form"
import { Thumbnail } from "../../../../../../components/common/thumbnail"
import { getStylizedAmount } from "../../../../../../lib/money-amount-helpers"
import { View } from "../constants"
import { useCreateDraftOrder } from "../hooks"
export const CreateDraftOrderItemsDetails = () => {
const { t } = useTranslation()
const { region, variants, custom, form, onOpenDrawer } = useCreateDraftOrder()
const { currency_code, currency } = region || {}
return (
<div className="flex flex-col gap-y-8">
<fieldset className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="existing_items"
render={({ field: { name } }) => {
return (
<Form.Item className="flex flex-col gap-y-4">
<div>
<Form.Label>
{t("draftOrders.create.existingItemsLabel")}
</Form.Label>
<Form.Hint>
{t("draftOrders.create.existingItemsHint")}
</Form.Hint>
</div>
{variants.items.length > 0 ? (
variants.items.map((item, index) => {
return (
<div
key={item.ei_id}
className="bg-ui-bg-component shadow-elevation-card-rest divide-y rounded-xl"
>
<div className="flex items-center justify-between p-3">
<div className="flex items-center gap-x-2">
<div className="shadow-borders-base size-fit overflow-hidden rounded-[4px]">
<Thumbnail src={item.thumbnail} />
</div>
<div>
<div className="flex items-center gap-x-1">
<Text
size="small"
leading="compact"
weight="plus"
>
{item.product_title}
</Text>
{item.sku && (
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
({item.sku})
</Text>
)}
</div>
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
{item.variant_title}
</Text>
</div>
</div>
<div className="flex items-center gap-x-3">
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
{getStylizedAmount(
item.unit_price,
currency_code!
)}
</Text>
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.remove"),
onClick: () => variants.remove(index),
icon: <Trash />,
},
],
},
]}
/>
</div>
</div>
<fieldset className="grid grid-cols-1 gap-3 p-3 md:grid-cols-2">
<Form.Field
control={form.control}
name={`${name}.${index}.quantity`}
render={({ field: { onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label>
{t("fields.quantity")}
</Form.Label>
<Form.Control>
<Input
className="!bg-ui-bg-field-component hover:!bg-ui-bg-field-component-hover"
type="number"
onChange={(e) =>
onChange(Number(e.target.value))
}
{...field}
/>
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name={`${name}.${index}.custom_unit_price`}
render={({ field: { onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label optional>
{t(
"draftOrders.create.unitPriceOverrideLabel"
)}
</Form.Label>
<Form.Control>
<CurrencyInput
className="!bg-ui-bg-field-component hover:!bg-ui-bg-field-component-hover"
code={currency_code!}
symbol={currency?.symbol_native!}
onValueChange={onChange}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</fieldset>
</div>
)
})
) : (
<div className="flex items-center justify-center px-2 py-3">
<Text
size="small"
leading="compact"
className="text-ui-fg-muted"
>
{t("draftOrders.create.noExistingItemsAddedLabel")}
</Text>
</div>
)}
<div className="flex items-center justify-end">
<ConditionalTooltip
content={t("draftOrders.create.chooseRegionTooltip")}
showTooltip={!region}
>
<Button
disabled={!region}
variant="secondary"
size="small"
type="button"
onClick={() => onOpenDrawer(View.EXISTING_ITEMS)}
>
{t("draftOrders.create.addExistingItemsAction")}
</Button>
</ConditionalTooltip>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</fieldset>
<div className="md:grid-grid-cols-2 grid grid-cols-1 gap-3 p-3">
<Form.Field
control={form.control}
name="custom_items"
render={({ field: { name } }) => {
return (
<Form.Item className="flex flex-col gap-y-4">
<div>
<Form.Label>
{t("draftOrders.create.customItemsLabel")}
</Form.Label>
<Form.Hint>
{t("draftOrders.create.customItemsHint")}
</Form.Hint>
</div>
{custom.items.length > 0 ? (
custom.items.map((item, index) => {
return (
<div
key={item.ci_id}
className="bg-ui-bg-component shadow-elevation-card-rest divide-y rounded-xl"
>
<div className="flex items-center justify-between p-3">
<div>
<div className="flex items-center gap-x-1">
<Text
size="small"
leading="compact"
weight="plus"
>
{item.title}
</Text>
</div>
</div>
<div className="flex items-center">
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.remove"),
onClick: () => custom.remove(index),
icon: <Trash />,
},
],
},
]}
/>
</div>
</div>
<fieldset className="grid grid-cols-1 gap-3 p-3 md:grid-cols-2">
<Form.Field
control={form.control}
name={`${name}.${index}.quantity`}
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("fields.quantity")}
</Form.Label>
<Form.Control>
<Input
className="!bg-ui-bg-field-component hover:!bg-ui-bg-field-component-hover"
type="number"
{...field}
/>
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name={`${name}.${index}.unit_price`}
render={({ field: { onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label>
{t("fields.unitPrice")}
</Form.Label>
<Form.Control>
<CurrencyInput
className="!bg-ui-bg-field-component hover:!bg-ui-bg-field-component-hover"
code={currency_code!}
symbol={currency?.symbol_native!}
onValueChange={onChange}
{...field}
/>
</Form.Control>
</Form.Item>
)
}}
/>
</fieldset>
</div>
)
})
) : (
<div className="flex items-center justify-center px-2 py-3">
<Text
size="small"
leading="compact"
className="text-ui-fg-muted"
>
{t("draftOrders.create.noCustomItemsAddedLabel")}
</Text>
</div>
)}
<div className="flex items-center justify-end">
<ConditionalTooltip
content={t("draftOrders.create.chooseRegionTooltip")}
showTooltip={!region}
>
<Button
variant="secondary"
size="small"
type="button"
disabled={!region}
onClick={() => onOpenDrawer(View.CUSTOM_ITEMS)}
>
{t("draftOrders.create.addCustomItemAction")}
</Button>
</ConditionalTooltip>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
)
}
@@ -0,0 +1,114 @@
import { Select } from "@medusajs/ui"
import { useAdminRegions, useMedusa } from "medusa-react"
import { useTranslation } from "react-i18next"
import { json } from "react-router-dom"
import { useWatch } from "react-hook-form"
import { Form } from "../../../../../../components/common/form"
import { useCreateDraftOrder } from "../hooks"
export const CreateDraftOrderRegionDetails = () => {
const { t } = useTranslation()
const {
form,
setRegion,
variants: { rebase },
} = useCreateDraftOrder()
const { client } = useMedusa()
const existingItems = useWatch({
control: form.control,
name: "existing_items",
})
const { regions, isLoading, isError, error } = useAdminRegions({
limit: 1000,
fields: "id,name,currency_code",
})
const handleRebaseUnitPrices = async (regionId: string) => {
if (!existingItems?.length) {
return
}
const { variants } = await client.admin.variants
.list({
region_id: regionId,
id: existingItems.map((i) => i.variant_id),
})
.catch((_err) => {
// Show toast with error message
return { variants: [] }
})
rebase(variants)
}
const handleResetShippingDetails = () => {
form.resetField("shipping_method")
form.resetField("shipping_address")
form.resetField("billing_address")
}
const handleRegionChange = (regId: string) => {
const region = regions?.find((r) => r.id === regId)
if (!region) {
throw json({ message: "Region not found" }, 400)
}
setRegion(region)
}
const onValueChange = (fn: (...event: any[]) => void) => {
return async (id: string) => {
fn(id)
await handleRebaseUnitPrices(id)
handleResetShippingDetails()
handleRegionChange(id)
}
}
if (isError) {
throw error
}
return (
<fieldset className="grid grid-cols-2 gap-4">
<Form.Field
control={form.control}
name="region_id"
render={({
field: { ref, onChange, disabled, ...field },
fieldState: { error },
}) => {
return (
<Form.Item>
<Form.Label className="!h2-core">{t("fields.region")}</Form.Label>
<Form.Hint>{t("draftOrders.create.chooseRegionHint")}</Form.Hint>
<Form.Control>
<Select
{...field}
onValueChange={onValueChange(onChange)}
disabled={isLoading || disabled}
>
<Select.Trigger aria-invalid={!!error} ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{regions?.map((r) => (
<Select.Item key={r.id} value={r.id}>
{r.name}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</fieldset>
)
}
@@ -0,0 +1,185 @@
import { useInfiniteQuery } from "@tanstack/react-query"
import debounce from "lodash/debounce"
import { useMedusa } from "medusa-react"
import { useCallback, useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { ShippingOption } from "@medusajs/medusa"
import { CurrencyInput, Heading, Input } from "@medusajs/ui"
import { json } from "react-router-dom"
import { Combobox } from "../../../../../../components/common/combobox"
import { ConditionalTooltip } from "../../../../../../components/common/conditional-tooltip"
import { Form } from "../../../../../../components/common/form"
import { getLocaleAmount } from "../../../../../../lib/money-amount-helpers"
import { useCreateDraftOrder } from "../hooks"
export const CreateDraftOrderShippingMethodDetails = () => {
const [query, setQuery] = useState("")
const [debouncedQuery, setDebouncedQuery] = useState("")
const { t } = useTranslation()
const { form, region } = useCreateDraftOrder()
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedUpdate = useCallback(
debounce((query) => setDebouncedQuery(query), 300),
[]
)
useEffect(() => {
debouncedUpdate(query)
return () => debouncedUpdate.cancel()
}, [query, debouncedUpdate])
const { client } = useMedusa()
const { data, fetchNextPage, isFetchingNextPage } = useInfiniteQuery(
["shipping_options", region?.id, debouncedQuery],
async ({ pageParam = 0 }) => {
const res = await client.admin.shippingOptions.list({
q: debouncedQuery || undefined,
limit: 10,
offset: pageParam,
is_return: false,
region_id: region?.id,
})
return res
},
{
getNextPageParam: (lastPage) => {
const moreCustomersExist =
lastPage.count > lastPage.offset + lastPage.limit
return moreCustomersExist ? lastPage.offset + lastPage.limit : undefined
},
enabled: !!region?.id,
keepPreviousData: true,
}
)
const createLabel = (shippingOption?: ShippingOption) => {
if (!shippingOption) {
return ""
}
return `${shippingOption.name} - ${getLocaleAmount(
shippingOption.amount || 0,
region?.currency_code!
)}`
}
const options =
data?.pages
.flatMap((page) => page.shipping_options)
.map((so) => ({
label: createLabel(so),
value: so.id,
})) || []
const handleShippingMethodChange = (optionId: string | undefined) => {
if (!optionId) {
return
}
const option = data?.pages
.flatMap((page) => page.shipping_options)
.find((so) => so.id === optionId)
if (!option) {
throw json({ message: "Shipping option not found" }, 400)
}
form.setValue("shipping_method.option_title", option.name, {
shouldDirty: true,
shouldTouch: true,
})
form.setValue("shipping_method.amount", option.amount || 0, {
shouldDirty: true,
shouldTouch: true,
})
}
return (
<div className="flex flex-col gap-y-4">
<Heading level="h2">{t("fields.shipping")}</Heading>
<fieldset className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Form.Field
control={form.control}
name="shipping_method.option_id"
render={({ field: { onChange, disabled, ...field } }) => {
return (
<ConditionalTooltip
showTooltip={!region}
content={t("draftOrders.create.chooseRegionTooltip")}
>
<Form.Item>
<div>
<Form.Label>
{t("draftOrders.create.shippingOptionLabel")}
</Form.Label>
<Form.Hint>
{t("draftOrders.create.shippingOptionHint")}
</Form.Hint>
</div>
<Form.Control>
<Combobox
{...field}
onChange={(val) => {
handleShippingMethodChange(val)
onChange(val)
}}
disabled={!region || disabled}
searchValue={query}
onSearchValueChange={setQuery}
fetchNextPage={fetchNextPage}
isFetchingNextPage={isFetchingNextPage}
options={options}
autoComplete="false"
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
</ConditionalTooltip>
)
}}
/>
<Form.Field
control={form.control}
name="shipping_method.custom_amount"
render={({ field: { onChange, ...field } }) => {
return (
<ConditionalTooltip
showTooltip={!region}
content={t("draftOrders.create.chooseRegionTooltip")}
>
<Form.Item>
<div>
<Form.Label optional>
{t("draftOrders.create.shippingPriceOverrideLabel")}
</Form.Label>
<Form.Hint>
{t("draftOrders.create.shippingPriceOverrideHint")}
</Form.Hint>
</div>
<Form.Control>
{region ? (
<CurrencyInput
{...field}
onValueChange={onChange}
code={region.currency.code}
symbol={region.currency.symbol_native}
/>
) : (
<Input disabled />
)}
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
</ConditionalTooltip>
)
}}
/>
</fieldset>
</div>
)
}
@@ -0,0 +1,125 @@
import { Button, CurrencyInput, Hint, Input, Label } from "@medusajs/ui"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { z } from "zod"
import { SplitView } from "../../../../../../components/layout/split-view"
import { castNumber } from "../../../../../../lib/cast-number"
import { CustomItemSchema } from "../constants"
import { useCreateDraftOrder } from "../hooks"
import { CustomItem } from "../types"
export const AddCustomItemDrawer = () => {
const { region, custom } = useCreateDraftOrder()
const { currency } = region || {}
const currencyCode = currency?.code || ""
const nativeSymbol = currency?.symbol_native || ""
const { t } = useTranslation()
const [item, setItem] = useState<Partial<CustomItem>>({
title: "",
quantity: 1,
unit_price: undefined,
})
const [errors, setErrors] = useState<z.ZodError<
z.infer<typeof CustomItemSchema>
> | null>(null)
const handleSave = () => {
const parsed = CustomItemSchema.safeParse(item)
if (!parsed.success) {
setErrors(parsed.error)
return
}
custom.update(parsed.data)
}
return (
<div className="flex size-full flex-col overflow-hidden">
<div className="size-full flex-1 overflow-auto px-6 py-4">
<div className="flex flex-col gap-y-4 [&>div]:flex [&>div]:flex-col [&>div]:gap-y-2">
<div>
<Label weight="plus" size="small">
{t("fields.title")}
</Label>
<Input
value={item.title}
onChange={(e) =>
setItem((prev) => ({ ...prev, title: e.target.value }))
}
/>
<ErrorMessage errors={errors} field="title" />
</div>
<div>
<Label weight="plus" size="small">
{t("fields.quantity")}
</Label>
<Input
value={item.quantity}
type="number"
step={1}
onChange={(e) => {
const val = castNumber(e.target.value)
setItem((prev) => ({ ...prev, quantity: val }))
}}
/>
<ErrorMessage errors={errors} field="quantity" />
</div>
<div>
<Label weight="plus" size="small">
{t("fields.unitPrice")}
</Label>
<CurrencyInput
code={currencyCode}
symbol={nativeSymbol}
value={item.unit_price}
onValueChange={(value) => {
setItem((prev) => ({ ...prev, unit_price: value }))
}}
/>
<ErrorMessage errors={errors} field="unit_price" />
</div>
</div>
</div>
<div className="flex items-center justify-end gap-x-2 border-t p-4">
<SplitView.Close type="button" asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</SplitView.Close>
<Button size="small" type="button" onClick={handleSave}>
{t("actions.add")}
</Button>
</div>
</div>
)
}
const getFirstErrorMessage = (
errors: z.ZodError<z.infer<typeof CustomItemSchema>> | null,
field: string
): string | null => {
if (!errors) {
return null
}
const fieldError = errors.errors.find((error) => error.path[0] === field)
return fieldError ? fieldError.message : null
}
const ErrorMessage = ({
field,
errors,
}: {
errors: z.ZodError<z.infer<typeof CustomItemSchema>> | null
field: string
}) => {
const message = getFirstErrorMessage(errors, field)
return message ? <Hint variant="error">{message}</Hint> : null
}
@@ -0,0 +1,245 @@
import { PricedVariant } from "@medusajs/medusa/dist/types/pricing"
import { Button, Checkbox } from "@medusajs/ui"
import {
OnChangeFn,
RowSelectionState,
createColumnHelper,
} from "@tanstack/react-table"
import { useAdminVariants } from "medusa-react"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { SplitView } from "../../../../../../components/layout/split-view"
import { DataTable } from "../../../../../../components/table/data-table"
import { MoneyAmountCell } from "../../../../../../components/table/table-cells/common/money-amount-cell"
import { PlaceholderCell } from "../../../../../../components/table/table-cells/common/placeholder-cell"
import { ProductCell } from "../../../../../../components/table/table-cells/product/product-cell"
import { useDataTable } from "../../../../../../hooks/use-data-table"
import { useProductVariantTableFilters } from "../../../../../products/product-detail/components/product-variant-section/use-variant-table-filters"
import { useProductVariantTableQuery } from "../../../../../products/product-detail/components/product-variant-section/use-variant-table-query"
import { useCreateDraftOrder } from "../hooks"
import { ExistingItem } from "../types"
const PAGE_SIZE = 50
const PREFIX = "av"
const initRowState = (items: ExistingItem[]): RowSelectionState => {
return items.reduce((acc, curr) => {
acc[curr.variant_id] = true
return acc
}, {} as RowSelectionState)
}
export const AddVariantDrawer = () => {
const { region, customer, variants: existing } = useCreateDraftOrder()
const { currency_code } = region || {}
const [rowSelection, setRowSelection] = useState<RowSelectionState>(
initRowState(existing.items)
)
const [intermediate, setIntermediate] = useState<ExistingItem[]>(
existing.items
)
const { t } = useTranslation()
const { searchParams, raw } = useProductVariantTableQuery({
pageSize: PAGE_SIZE,
prefix: PREFIX,
})
const { variants, count, isLoading, isError, error } = useAdminVariants({
region_id: region?.id,
customer_id: customer?.id,
...searchParams,
})
const updater: OnChangeFn<RowSelectionState> = (fn) => {
const newState: RowSelectionState =
typeof fn === "function" ? fn(rowSelection) : fn
const diff = Object.keys(newState).filter(
(k) => newState[k] !== rowSelection[k]
)
const addedVariants = variants?.filter((p) => diff.includes(p.id!)) ?? []
const newVariants: ExistingItem[] = addedVariants.map((v) => ({
variant_id: v.id!,
variant_title: v.title!,
unit_price: v.original_price!,
sku: v.sku ?? undefined,
product_title: v.product?.title,
thumbnail: v.product?.thumbnail ?? undefined,
quantity: 1,
}))
setIntermediate((prev) => {
const filteredPrev = prev.filter((p) =>
Object.keys(newState).includes(p.variant_id)
)
const update = Array.from(new Set([...filteredPrev, ...newVariants]))
return update
})
setRowSelection(newState)
}
const handleSave = () => {
existing.update(intermediate)
}
const columns = useVariantTableColumns()
const filters = useProductVariantTableFilters()
const { table } = useDataTable({
data: variants || [],
columns,
count,
pageSize: PAGE_SIZE,
enablePagination: true,
enableRowSelection: true,
getRowId: (row) => row.id!,
rowSelection: {
state: rowSelection,
updater,
},
meta: {
currencyCode: currency_code,
},
prefix: PREFIX,
})
if (isError) {
throw error
}
return (
<div className="flex size-full flex-col overflow-hidden">
<DataTable
table={table}
columns={columns}
count={count}
isLoading={isLoading}
queryObject={raw}
pageSize={PAGE_SIZE}
filters={filters}
orderBy={["title", "created_at", "updated_at"]}
pagination
search
layout="fill"
prefix={PREFIX}
/>
<div className="flex items-center justify-end gap-x-2 border-t p-4">
<SplitView.Close type="button" asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</SplitView.Close>
<Button size="small" type="button" onClick={handleSave}>
{t("actions.add")}
</Button>
</div>
</div>
)
}
const columnHelper = createColumnHelper<PricedVariant>()
const useVariantTableColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row }) => {
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
/>
)
},
}),
columnHelper.accessor("product", {
header: t("fields.product"),
cell: ({ getValue }) => {
const product = getValue()
if (!product) {
return <PlaceholderCell />
}
return <ProductCell product={product} />
},
}),
columnHelper.accessor("options", {
header: t("fields.variant"),
cell: ({ getValue }) => {
const options = getValue()
const displayValue = options?.map((o) => o.value).join(" · ")
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{displayValue}</span>
</div>
)
},
}),
columnHelper.accessor("sku", {
header: t("fields.sku"),
cell: ({ getValue }) => {
const sku = getValue()
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{sku ?? "-"}</span>
</div>
)
},
}),
columnHelper.accessor("original_price", {
header: () => (
<div className="flex size-full items-center justify-end overflow-hidden text-right">
<span className="truncate">{t("fields.unitPrice")}</span>
</div>
),
cell: ({ getValue, table }) => {
const price = getValue()
const { currencyCode } = table.options.meta as {
currencyCode?: string
}
if (!price || !currencyCode) {
return <PlaceholderCell />
}
return (
<MoneyAmountCell
align="right"
amount={price}
currencyCode={currencyCode}
/>
)
},
}),
],
[t]
)
}
@@ -0,0 +1,14 @@
import { View } from "../constants"
import { AddCustomItemDrawer } from "./add-custom-item-drawer"
import { AddVariantDrawer } from "./add-variant-drawer"
export const CreateDraftOrderDrawer = ({ view }: { view: View | null }) => {
switch (view) {
case View.EXISTING_ITEMS:
return <AddVariantDrawer />
case View.CUSTOM_ITEMS:
return <AddCustomItemDrawer />
default:
return null
}
}
@@ -0,0 +1,455 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Customer, Region } from "@medusajs/medusa"
import { Button, ProgressTabs } from "@medusajs/ui"
import { useAdminCreateDraftOrder } from "medusa-react"
import { useCallback, useMemo, useState } from "react"
import { useFieldArray, useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { useSearchParams } from "react-router-dom"
import { z } from "zod"
import { SplitView } from "../../../../../components/layout/split-view"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/route-modal"
import { getDbAmount } from "../../../../../lib/money-amount-helpers"
import { PricedVariant } from "@medusajs/medusa/dist/types/pricing"
import { castNumber } from "../../../../../lib/cast-number"
import { CreateDraftOrderSchema, View } from "./constants"
import { CreateDraftOrderContext } from "./context"
import { CreateDraftOrderDetails } from "./create-draft-order-details"
import { CreateDraftOrderDrawer } from "./create-draft-order-drawer"
import { CreateDraftOrderSummary } from "./create-draft-order-summary"
import { CustomItem, ExistingItem } from "./types"
enum Tab {
DETAILS = "details",
SUMMARY = "summary",
}
export const CreateDraftOrderForm = () => {
const [open, setOpen] = useState(false)
const [view, setView] = useState<View | null>(null)
const [tab, setTab] = useState<Tab>(Tab.DETAILS)
const [detailsValidated, setDetailsValidated] = useState(false)
const [region, setRegion] = useState<Region | null>(null)
const [customer, setCustomer] = useState<Customer | null>(null)
const [sameAsShipping, setSameAsShipping] = useState(true)
const { t } = useTranslation()
const [, setSearchParams] = useSearchParams()
const { handleSuccess } = useRouteModal()
const form = useForm<z.infer<typeof CreateDraftOrderSchema>>({
defaultValues: {
email: "",
region_id: "",
shipping_method: {
amount: "",
custom_amount: "",
option_id: "",
option_title: "",
},
shipping_address: {
address_1: "",
address_2: "",
city: "",
company: "",
country_code: "",
first_name: "",
last_name: "",
phone: "",
postal_code: "",
province: "",
},
billing_address: null,
existing_items: [],
custom_items: [],
customer_id: "",
notification_order: true,
},
resolver: zodResolver(CreateDraftOrderSchema),
})
const {
clearErrors,
formState: { isDirty },
} = form
const {
append: createCustomItem,
remove: deleteCustomItem,
fields: customItems,
} = useFieldArray({
control: form.control,
name: "custom_items",
keyName: "ci_id",
})
const {
append: createExistingItem,
remove: deleteExistingItem,
update: updateExistingItem,
fields: existingItems,
} = useFieldArray({
control: form.control,
name: "existing_items",
keyName: "ei_id",
})
const { mutateAsync, isLoading } = useAdminCreateDraftOrder()
const handleSubmit = form.handleSubmit(async (values) => {
let {
shipping_address,
billing_address,
existing_items,
custom_items,
shipping_method,
email,
notification_order,
...rest
} = values
const emailValue = email || customer?.email
if (!emailValue) {
form.setError("email", {
type: "manual",
message: "Email is required",
})
form.setError("customer_id", {
type: "manual",
message: "Customer is required",
})
return
}
if (!billing_address) {
billing_address = shipping_address
}
const preparedExistingItems =
existing_items?.map((item) => {
const { custom_unit_price, variant_id, quantity } = item
const customUnitPriceCast = Number(custom_unit_price)
const customUnitPriceValue = !isNaN(customUnitPriceCast)
? getDbAmount(customUnitPriceCast, region?.currency_code!)
: undefined
return {
variant_id,
quantity,
unit_price: customUnitPriceValue,
}
}) || []
const preparedCustomItems =
custom_items?.map((item) => {
const { unit_price, quantity, title } = item
const unitPriceCast = castNumber(unit_price)
const unitPriceValue = !isNaN(unitPriceCast)
? getDbAmount(unitPriceCast, region?.currency_code!)
: undefined
return {
title,
quantity,
unit_price: unitPriceValue,
}
}) || []
const items = [...preparedExistingItems, ...preparedCustomItems]
const preparedShippingMethods = [
{
option_id: shipping_method.option_id,
price: shipping_method.custom_amount
? getDbAmount(
castNumber(shipping_method.custom_amount),
region?.currency_code!
)
: undefined,
},
]
await mutateAsync(
{
...rest,
email: emailValue,
shipping_address,
billing_address,
items: items,
shipping_methods: preparedShippingMethods,
no_notification_order: !notification_order,
},
{
onSuccess: ({ draft_order }) => {
handleSuccess(`../${draft_order.id}`)
},
}
)
})
const clearItemErrors = useCallback(() => {
clearErrors("custom_items")
clearErrors("existing_items")
}, [clearErrors])
const handleUpdateExistingItems = useCallback(
(items: ExistingItem[]) => {
handleUpdateEntities(
items,
existingItems,
deleteExistingItem,
createExistingItem,
"variant_id"
)
setView(null)
setOpen(false)
clearItemErrors()
},
[createExistingItem, deleteExistingItem, existingItems, clearItemErrors]
)
const handleCreateCustomItem = useCallback(
(item: CustomItem) => {
createCustomItem(item)
setView(null)
setOpen(false)
clearItemErrors()
},
[createCustomItem, clearItemErrors]
)
const handleOpenDrawer = (view: View) => {
setView(view)
setOpen(true)
}
const handleOpenChange = (open: boolean) => {
if (!open) {
setView(null)
setSearchParams(
{},
{
replace: true,
}
)
}
setOpen(open)
}
const handleContinue = form.handleSubmit(() => {
setTab(Tab.SUMMARY)
setDetailsValidated(true)
})
const handleRebaseUnitPrices = useCallback(
(variants: PricedVariant[]) => {
if (!variants.length) {
return
}
for (const variant of variants) {
const index = existingItems.findIndex(
(item) => item.variant_id === variant.id
)
if (index === -1) {
continue
}
updateExistingItem(index, {
...existingItems[index],
unit_price: variant.original_price!,
})
}
},
[updateExistingItem, existingItems]
)
const handleTabChange = (tab: Tab) => {
switch (tab) {
case Tab.DETAILS:
setDetailsValidated(false)
setTab(tab)
break
case Tab.SUMMARY:
handleContinue()
break
}
}
const detailsProgress = useMemo(() => {
if (detailsValidated) {
return "completed"
}
if (isDirty) {
return "in-progress"
}
return "not-started"
}, [detailsValidated, isDirty])
return (
<CreateDraftOrderContext.Provider
value={useMemo(
() => ({
custom: {
items: customItems,
remove: deleteCustomItem,
update: handleCreateCustomItem,
},
variants: {
items: existingItems,
remove: deleteExistingItem,
update: handleUpdateExistingItems,
rebase: handleRebaseUnitPrices,
},
form,
region,
setRegion,
customer,
setCustomer,
sameAsShipping,
setSameAsShipping,
onOpenDrawer: handleOpenDrawer,
}),
[
customItems,
deleteCustomItem,
handleCreateCustomItem,
existingItems,
deleteExistingItem,
handleUpdateExistingItems,
handleRebaseUnitPrices,
form,
customer,
region,
sameAsShipping,
]
)}
>
<RouteFocusModal.Form form={form}>
<form
className="flex h-full flex-col overflow-hidden"
onSubmit={handleSubmit}
>
<ProgressTabs
value={tab}
onValueChange={(tab) => handleTabChange(tab as Tab)}
className="flex h-full flex-col overflow-hidden"
>
<RouteFocusModal.Header>
<div className="flex w-full items-center justify-between gap-x-4">
<div className="-my-2 w-full max-w-[400px] border-l">
<ProgressTabs.List className="grid w-full grid-cols-2">
<ProgressTabs.Trigger
className="w-full"
value={Tab.DETAILS}
status={detailsProgress}
>
{t("fields.details")}
</ProgressTabs.Trigger>
<ProgressTabs.Trigger
className="w-full"
value={Tab.SUMMARY}
>
{t("fields.summary")}
</ProgressTabs.Trigger>
</ProgressTabs.List>
</div>
<div className="flex items-center gap-x-2">
<RouteFocusModal.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
{tab === Tab.SUMMARY ? (
<Button
key="save-btn"
type="submit"
size="small"
isLoading={isLoading}
>
{t("actions.save")}
</Button>
) : (
<Button
key="continue-btn"
type="button"
onClick={handleContinue}
size="small"
>
{t("actions.continue")}
</Button>
)}
</div>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body className="size-full overflow-hidden">
<ProgressTabs.Content
value={Tab.DETAILS}
className="size-full overflow-hidden"
>
<SplitView open={open} onOpenChange={handleOpenChange}>
<SplitView.Content>
<CreateDraftOrderDetails />
</SplitView.Content>
<SplitView.Drawer>
<CreateDraftOrderDrawer view={view} />
</SplitView.Drawer>
</SplitView>
</ProgressTabs.Content>
<ProgressTabs.Content
value={Tab.SUMMARY}
className="flex h-full w-full flex-col items-center overflow-hidden"
>
<CreateDraftOrderSummary />
</ProgressTabs.Content>
</RouteFocusModal.Body>
</ProgressTabs>
</form>
</RouteFocusModal.Form>
</CreateDraftOrderContext.Provider>
)
}
const handleUpdateEntities = <T,>(
newEntities: T[],
existingEntities: T[],
deleteEntity: (indices: number[]) => void,
createEntity: (entities: T[]) => void,
entityIdKey: keyof T
) => {
const newEntitiesIdMap = newEntities.map((e) => e[entityIdKey])
const existingEntitiesIdMap = existingEntities.map((e) => e[entityIdKey])
const indicesToDelete = existingEntities.reduce((acc, e, i) => {
if (!newEntitiesIdMap.includes(e[entityIdKey])) {
acc.push(i)
}
return acc
}, [] as number[])
const entitiesToAdd = newEntities.filter(
(e) => !existingEntitiesIdMap.includes(e[entityIdKey])
)
deleteEntity(indicesToDelete)
createEntity(entitiesToAdd)
}
@@ -0,0 +1,62 @@
import { Text } from "@medusajs/ui"
import { Divider } from "../../../../../../components/common/divider"
import { castNumber } from "../../../../../../lib/cast-number"
import {
getDbAmount,
getLocaleAmount,
} from "../../../../../../lib/money-amount-helpers"
import { useCreateDraftOrder } from "../hooks"
export const CreateDraftOrderCustomItemsSummary = () => {
const { form, region } = useCreateDraftOrder()
const { currency_code } = region || {}
const items = form.getValues("custom_items") || []
if (!items.length) {
return null
}
return (
<div className="grid grid-cols-1 gap-4">
{items.map((item, index) => {
const price = item.unit_price
? getDbAmount(castNumber(item.unit_price), currency_code!)
: item.unit_price
const subtotal = price * item.quantity
return (
<div key={index} className="grid grid-cols-2 items-start gap-4">
<Text
size="small"
leading="compact"
weight="plus"
className="text-ui-fg-base"
>
{item.title}
</Text>
<div className="grid grid-cols-3 items-center gap-x-4">
<div className="flex items-center justify-end gap-x-2">
<Text size="small">
{getLocaleAmount(price, currency_code!)}
</Text>
</div>
<div className="min-w-[27px] text-right">
<Text>
<span className="tabular-nums">{item.quantity}</span>x
</Text>
</div>
<div className="flex items-center justify-end">
<Text size="small">
{getLocaleAmount(subtotal, currency_code!)}
</Text>
</div>
</div>
</div>
)
})}
<Divider variant="dashed" />
</div>
)
}
@@ -0,0 +1,105 @@
import { Avatar, Text } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { getFormattedAddress } from "../../../../../../lib/addresses"
import { useCreateDraftOrder } from "../hooks"
export const CreateDraftOrderCustomerSummary = () => {
const { form, customer, sameAsShipping, region } = useCreateDraftOrder()
const { countries } = region || {}
const { t } = useTranslation()
const shippingAddress = form.getValues("shipping_address")
const shippingAddressCountry = countries?.find(
(c) => c.iso_2 === shippingAddress?.country_code
)
const billingAddress = form.getValues("billing_address")
const billingAddressCountry = countries?.find(
(c) => c.iso_2 === billingAddress?.country_code
)
const email = form.getValues("email")
const phone =
shippingAddress?.phone || billingAddress?.phone || customer?.phone
const { first_name, last_name } = customer || shippingAddress || {}
const name = [first_name, last_name].filter(Boolean).join(" ")
const fallback = name
? name[0].toUpperCase()
: email?.[0].toUpperCase() || "?"
return (
<div className="text-ui-fg-subtle grid grid-cols-1 gap-2">
{customer && (
<div className="grid grid-cols-2">
<Text size="small" leading="compact">
{t("fields.id")}
</Text>
<div className="flex items-center gap-x-2">
<Avatar fallback={fallback} size="2xsmall" />
<Text size="small" leading="compact">
{name || email}
</Text>
</div>
</div>
)}
<div className="grid grid-cols-2">
<Text size="small" leading="compact">
{t("fields.email")}
</Text>
<Text size="small" leading="compact">
{email}
</Text>
</div>
<div className="grid grid-cols-2">
<Text size="small" leading="compact">
{t("fields.phone")}
</Text>
<Text size="small" leading="compact">
{phone || "-"}
</Text>
</div>
<div className="grid grid-cols-2">
<Text size="small" leading="compact">
{t("addresses.shippingAddress.label")}
</Text>
<Text size="small" leading="compact">
{getFormattedAddress({
address: { ...shippingAddress, country: shippingAddressCountry },
}).map((line, i) => {
return (
<span key={i} className="break-words">
{line}
<br />
</span>
)
})}
</Text>
</div>
<div className="grid grid-cols-2">
<Text size="small" leading="compact">
{t("addresses.billingAddress.label")}
</Text>
{sameAsShipping ? (
<Text size="small" leading="compact" className="text-ui-fg-muted">
{t("addresses.billingAddress.sameAsShipping")}
</Text>
) : (
<Text size="small" leading="compact">
{getFormattedAddress({
address: { ...billingAddress, country: billingAddressCountry },
}).map((line, i) => {
return (
<span key={i} className="break-words">
{line}
<br />
</span>
)
})}
</Text>
)}
</div>
</div>
)
}
@@ -0,0 +1,42 @@
import { Switch } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { Form } from "../../../../../../components/common/form"
import { useCreateDraftOrder } from "../hooks"
export const CreateDraftOrderFieldsSummary = () => {
const { t } = useTranslation()
const { form } = useCreateDraftOrder()
return (
<div>
<Form.Field
control={form.control}
name="notification_order"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div className="flex flex-col gap-y-1">
<div className="flex items-center justify-between">
<Form.Label>
{t("draftOrders.create.sendNotificationLabel")}
</Form.Label>
<Form.Control>
<Switch
{...field}
onCheckedChange={onChange}
checked={value}
/>
</Form.Control>
</div>
<Form.Hint>
{t("draftOrders.create.sendNotificationHint")}
</Form.Hint>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
)
}
@@ -0,0 +1,30 @@
import { Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { Divider } from "../../../../../../components/common/divider"
import { CreateDraftOrderCustomItemsSummary } from "./create-draft-order-custom-items-summary"
import { CreateDraftOrderCustomerSummary } from "./create-draft-order-customer-summary"
import { CreateDraftOrderFieldsSummary } from "./create-draft-order-fields-summary"
import { CreateDraftOrderTotalSummary } from "./create-draft-order-total-summary"
import { CreateDraftOrderVariantItemsSummary } from "./create-draft-order-variant-items-summary"
export const CreateDraftOrderSummary = () => {
const { t } = useTranslation()
return (
<div className="flex size-full flex-col items-center overflow-auto p-16">
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
<Heading>{t("fields.summary")}</Heading>
<CreateDraftOrderCustomerSummary />
<div className="flex flex-col gap-y-4">
<Divider variant="dashed" />
<CreateDraftOrderVariantItemsSummary />
<CreateDraftOrderCustomItemsSummary />
<CreateDraftOrderTotalSummary />
<Divider variant="dashed" />
</div>
<CreateDraftOrderFieldsSummary />
</div>
</div>
)
}
@@ -0,0 +1,121 @@
import { Text } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { Divider } from "../../../../../../components/common/divider"
import { castNumber } from "../../../../../../lib/cast-number"
import {
getDbAmount,
getLocaleAmount,
getStylizedAmount,
} from "../../../../../../lib/money-amount-helpers"
import { useCreateDraftOrder } from "../hooks"
import { CustomItem, ExistingItem, ShippingMethod } from "../types"
export const CreateDraftOrderTotalSummary = () => {
const { t } = useTranslation()
const { form, region } = useCreateDraftOrder()
const { currency_code } = region || {}
const variantItems = form.getValues("existing_items") || []
const variantItemsSubtotal = variantItems.reduce((acc, item) => {
const amount = getExistingItemSubtotal(item, currency_code!)
return (acc += amount)
}, 0)
const customItems = form.getValues("custom_items") || []
const customItemsSubtotal = customItems.reduce((acc, item) => {
const amount = getCustomItemSubtotal(item, currency_code!)
return (acc += amount)
}, 0)
const count = variantItems.length + customItems.length
const subtotal = variantItemsSubtotal + customItemsSubtotal
const shippingMethod = form.getValues("shipping_method")
const shippingSubtotal = getShippingSubtotal(shippingMethod, currency_code!)
const total = subtotal + shippingSubtotal
return (
<div className="text-ui-fg-subtle flex flex-col gap-y-4">
<div className="flex flex-col gap-y-2">
<div className="grid grid-cols-3 gap-4">
<Text size="small" leading="compact">
{t("fields.subtotal")}
</Text>
<Text size="small" leading="compact" className="text-right">
{t("general.items", { count })}
</Text>
<Text size="small" leading="compact" className="text-right">
{getLocaleAmount(subtotal, currency_code!)}
</Text>
</div>
<div className="grid grid-cols-3 gap-4">
<Text size="small" leading="compact">
{t("fields.shipping")}
</Text>
<Text size="small" leading="compact" className="text-right">
{shippingMethod.option_title}
</Text>
<div className="flex items-center justify-end gap-x-2">
{shippingMethod.custom_amount && (
<Text size="small" className="text-ui-fg-muted line-through">
{getLocaleAmount(
castNumber(shippingMethod.amount || 0),
currency_code!
)}
</Text>
)}
<Text size="small" leading="compact" className="text-right">
{getLocaleAmount(shippingSubtotal, currency_code!)}
</Text>
</div>
</div>
</div>
<Divider variant="dashed" />
<div className="grid grid-cols-2 gap-4">
<Text size="small" leading="compact">
{t("fields.totalExclTax")}
</Text>
<Text size="small" leading="compact" className="text-right">
{getStylizedAmount(total, currency_code!)}
</Text>
</div>
</div>
)
}
const getExistingItemSubtotal = (item: ExistingItem, currency_code: string) => {
if (item.custom_unit_price) {
const customUnitPrice = castNumber(item.custom_unit_price)
return getDbAmount(customUnitPrice, currency_code) * item.quantity
}
return item.unit_price * item.quantity
}
const getCustomItemSubtotal = (item: CustomItem, currency_code: string) => {
return getDbAmount(castNumber(item.unit_price), currency_code) * item.quantity
}
const getShippingSubtotal = (
shippingMethod: ShippingMethod,
currency_code: string
) => {
if (shippingMethod.custom_amount) {
const customAmount = castNumber(shippingMethod.custom_amount)
return getDbAmount(customAmount, currency_code)
}
if (shippingMethod.amount) {
const amount =
typeof shippingMethod.amount === "string"
? Number(shippingMethod.amount.replace(",", "."))
: shippingMethod.amount
return amount
}
return 0
}
@@ -0,0 +1,83 @@
import { Copy, Text } from "@medusajs/ui"
import { Divider } from "../../../../../../components/common/divider"
import { Thumbnail } from "../../../../../../components/common/thumbnail"
import { castNumber } from "../../../../../../lib/cast-number"
import {
getDbAmount,
getLocaleAmount,
} from "../../../../../../lib/money-amount-helpers"
import { useCreateDraftOrder } from "../hooks"
export const CreateDraftOrderVariantItemsSummary = () => {
const { form, region } = useCreateDraftOrder()
const { currency_code } = region || {}
const items = form.getValues("existing_items") || []
if (!items.length) {
return null
}
return (
<div className="grid grid-cols-1 gap-4">
{items.map((item) => {
const price = item.custom_unit_price
? getDbAmount(castNumber(item.custom_unit_price), currency_code!)
: item.unit_price
const subtotal = price * item.quantity
return (
<div
key={item.variant_id}
className="grid grid-cols-2 items-start gap-4"
>
<div className="flex items-start gap-x-4">
<Thumbnail src={item.thumbnail} />
<div>
<Text
size="small"
leading="compact"
weight="plus"
className="text-ui-fg-base"
>
{item.product_title}
</Text>
{item.sku && (
<div className="flex items-center gap-x-1">
<Text size="small">{item.sku}</Text>
<Copy content={item.sku} className="text-ui-fg-muted" />
</div>
)}
<Text size="small">{item.variant_title}</Text>
</div>
</div>
<div className="grid grid-cols-3 items-center gap-x-4">
<div className="flex items-center justify-end gap-x-2">
{item.custom_unit_price && (
<Text size="small" className="text-ui-fg-muted line-through">
{getLocaleAmount(item.unit_price, currency_code!)}
</Text>
)}
<Text size="small">
{getLocaleAmount(price, currency_code!)}
</Text>
</div>
<div className="min-w-[27px] text-right">
<Text>
<span className="tabular-nums">{item.quantity}</span>x
</Text>
</div>
<div className="flex items-center justify-end">
<Text size="small">
{getLocaleAmount(subtotal, currency_code!)}
</Text>
</div>
</div>
</div>
)
})}
<Divider variant="dashed" />
</div>
)
}
@@ -0,0 +1,14 @@
import { useContext } from "react"
import { CreateDraftOrderContext } from "./context"
export const useCreateDraftOrder = () => {
const context = useContext(CreateDraftOrderContext)
if (!context) {
throw new Error(
"useCreateDraftOrder must be used within a CreateDraftOrderProvider"
)
}
return context
}
@@ -0,0 +1 @@
export * from "./create-draft-order-form"
@@ -0,0 +1,46 @@
import { Customer, Region } from "@medusajs/medusa"
import { FieldArrayWithId, UseFormReturn } from "react-hook-form"
import { z } from "zod"
import { PricedVariant } from "@medusajs/medusa/dist/types/pricing"
import {
CreateDraftOrderSchema,
CustomItemSchema,
ExistingItemSchema,
ShippingMethodSchema,
View,
} from "./constants"
export type ExistingItem = z.infer<typeof ExistingItemSchema>
export type CustomItem = z.infer<typeof CustomItemSchema>
export type ShippingMethod = z.infer<typeof ShippingMethodSchema>
export type CreateDraftOrderContextValue = {
form: UseFormReturn<z.infer<typeof CreateDraftOrderSchema>>
region: Region | null
setRegion: (region: Region | null) => void
customer: Customer | null
setCustomer: (customer: Customer | null) => void
sameAsShipping: boolean
setSameAsShipping: (sameAsShipping: boolean) => void
variants: {
items: FieldArrayWithId<
z.infer<typeof CreateDraftOrderSchema>,
"existing_items",
"ei_id"
>[]
remove: (index: number) => void
update: (items: ExistingItem[]) => void
rebase: (variants: PricedVariant[]) => void
}
custom: {
items: FieldArrayWithId<
z.infer<typeof CreateDraftOrderSchema>,
"custom_items",
"ci_id"
>[]
remove: (index: number) => void
update: (items: CustomItem) => void
}
onOpenDrawer: (view: View) => void
}
@@ -0,0 +1,10 @@
import { RouteFocusModal } from "../../../components/route-modal"
import { CreateDraftOrderForm } from "./components/create-draft-order-form/create-draft-order-form"
export const DraftOrderCreate = () => {
return (
<RouteFocusModal>
<CreateDraftOrderForm />
</RouteFocusModal>
)
}
@@ -0,0 +1 @@
export { DraftOrderCreate as Component } from "./draft-order-create"
@@ -5,6 +5,7 @@ import { Container, Copy, Heading, StatusBadge, Text } from "@medusajs/ui"
import { useAdminReservations } from "medusa-react"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { Divider } from "../../../../../components/common/divider"
import { Thumbnail } from "../../../../../components/common/thumbnail"
import {
getLocaleAmount,
@@ -51,6 +52,69 @@ const Header = () => {
)
}
const CustomItem = ({
item,
currencyCode,
reservation,
}: {
item: {
id: string
title: string
unit_price: number
subtotal: number
quantity: number
}
currencyCode: string
reservation?: ReservationItemDTO | null
}) => {
const { t } = useTranslation()
return (
<div
key={item.id}
className="text-ui-fg-subtle grid grid-cols-2 items-start gap-x-4 px-6 py-4"
>
<Text
size="small"
leading="compact"
weight="plus"
className="text-ui-fg-base"
>
{item.title}
</Text>
<div className="grid grid-cols-3 items-center gap-x-4">
<div className="flex items-center justify-end gap-x-4">
<Text size="small">
{getLocaleAmount(item.unit_price, currencyCode)}
</Text>
</div>
<div className="flex items-center gap-x-2">
<div className="w-fit min-w-[27px]">
<Text>
<span className="tabular-nums">{item.quantity}</span>x
</Text>
</div>
<div className="overflow-visible">
<StatusBadge
color={reservation ? "green" : "orange"}
className="text-nowrap"
>
{reservation
? t("orders.reservations.allocatedLabel")
: t("orders.reservations.notAllocatedLabel")}
</StatusBadge>
</div>
</div>
<div className="flex items-center justify-end">
<Text size="small">
{getLocaleAmount(item.subtotal || 0, currencyCode)}
</Text>
</div>
</div>
</div>
)
}
const Item = ({
item,
currencyCode,
@@ -129,9 +193,19 @@ const ItemBreakdown = ({ draftOrder }: { draftOrder: DraftOrder }) => {
throw error
}
const variantBasedItems = draftOrder.cart.items.filter(
(i) => i.variant_id !== null
)
const customItems = draftOrder.cart.items.filter((i) => i.variant_id === null)
const showDivider = variantBasedItems.length > 0 && customItems.length > 0
console.log("customItems", customItems)
return (
<div>
{draftOrder.cart.items.map((item) => {
{variantBasedItems.map((item) => {
const reservation = reservations
? reservations.find((r) => r.line_item_id === item.id)
: null
@@ -145,6 +219,27 @@ const ItemBreakdown = ({ draftOrder }: { draftOrder: DraftOrder }) => {
/>
)
})}
{showDivider && <Divider variant="dashed" />}
{customItems.map((item) => {
const reservation = reservations
? reservations.find((r) => r.line_item_id === item.id)
: null
return (
<CustomItem
key={item.id}
item={{
id: item.id,
title: item.title,
unit_price: item.unit_price,
subtotal: item.unit_price * item.quantity,
quantity: item.quantity,
}}
currencyCode={draftOrder.cart.region.currency_code}
reservation={reservation}
/>
)
})}
</div>
)
}
@@ -194,7 +289,7 @@ const CostBreakdown = ({ draftOrder }: { draftOrder: DraftOrder }) => {
const { t } = useTranslation()
// Calculate tax rate since it's not included in the cart
const taxRate = calculateCartTaxRate(draftOrder.cart)
const taxRate = calculateCartTaxRate(draftOrder.cart).toFixed(2)
return (
<div className="text-ui-fg-subtle flex flex-col gap-y-2 px-6 py-4">
@@ -236,7 +331,7 @@ const CostBreakdown = ({ draftOrder }: { draftOrder: DraftOrder }) => {
/>
<Cost
label={t("fields.tax")}
secondaryValue={`${taxRate || 0}%`}
secondaryValue={`${taxRate || 0} %`}
value={
draftOrder.cart.tax_total
? getLocaleAmount(
@@ -1,9 +1,11 @@
import { Outlet } from "react-router-dom"
import { DraftOrderListTable } from "./components/draft-order-list-table"
export const DraftOrderList = () => {
return (
<div className="flex flex-col gap-y-2">
<DraftOrderListTable />
<Outlet />
</div>
)
}