feat(admin-ui, medusa): admin UI metadata (#3644)

This commit is contained in:
Kasper Fabricius Kristensen authored and GitHub committed 2023-03-31 12:07:24 +02:00
1 parent 4f4ccee7fb
commit 4342ac884b
59 files changed
+1904 -963

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
"@medusajs/admin-ui": patch
"@medusajs/medusa": patch
---
feat(admin-ui): Adds metadata forms to all applicable domains in the UI.
fix(medusa): Fixes an issue where metadata was not being set for order addresses using `setMetadata`.
+1
View File
@@ -86,6 +86,7 @@
"@testing-library/user-event": "^14.4.3", "@testing-library/user-event": "^14.4.3",
"@types/pluralize": "^0.0.29", "@types/pluralize": "^0.0.29",
"@types/react": "^18.0.27", "@types/react": "^18.0.27",
"@types/react-datepicker": "^4.10.0",
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.0.10",
"@types/react-table": "^7.7.9", "@types/react-table": "^7.7.9",
"typescript": "^4.9.3", "typescript": "^4.9.3",
@@ -11,7 +11,7 @@ import InputHeader from "../../fundamentals/input-header"
import CustomHeader from "./custom-header" import CustomHeader from "./custom-header"
import { DateTimePickerProps } from "./types" import { DateTimePickerProps } from "./types"
const getDateClassname = (d, tempDate) => { const getDateClassname = (d: Date, tempDate: Date) => {
return moment(d).format("YY,MM,DD") === moment(tempDate).format("YY,MM,DD") return moment(d).format("YY,MM,DD") === moment(tempDate).format("YY,MM,DD")
? "date chosen" ? "date chosen"
: `date ${ : `date ${
@@ -29,12 +29,18 @@ const DatePicker: React.FC<DateTimePickerProps> = ({
tooltipContent, tooltipContent,
tooltip, tooltip,
}) => { }) => {
const [tempDate, setTempDate] = useState(date) const [tempDate, setTempDate] = useState<Date | null>(date || null)
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
useEffect(() => setTempDate(date), [isOpen]) useEffect(() => setTempDate(date), [isOpen])
const submitDate = () => { const submitDate = () => {
if (!tempDate || !date) {
onSubmitDate(null)
setIsOpen(false)
return
}
// update only date, month and year // update only date, month and year
const newDate = new Date(date.getTime()) const newDate = new Date(date.getTime())
newDate.setUTCDate(tempDate.getUTCDate()) newDate.setUTCDate(tempDate.getUTCDate())
@@ -68,7 +74,9 @@ const DatePicker: React.FC<DateTimePickerProps> = ({
<ArrowDownIcon size={16} /> <ArrowDownIcon size={16} />
</div> </div>
<label className="w-full text-left"> <label className="w-full text-left">
{moment(date).format("ddd, DD MMM YYYY")} {date
? moment(date).format("ddd, DD MMM YYYY")
: "---, -- -- ----"}
</label> </label>
</InputContainer> </InputContainer>
</button> </button>
@@ -78,7 +86,10 @@ const DatePicker: React.FC<DateTimePickerProps> = ({
sideOffset={8} sideOffset={8}
className="rounded-rounded border-grey-20 bg-grey-0 shadow-dropdown w-full border px-8" className="rounded-rounded border-grey-20 bg-grey-0 shadow-dropdown w-full border px-8"
> >
<CalendarComponent date={tempDate} onChange={setTempDate} /> <CalendarComponent
date={tempDate}
onChange={(date) => setTempDate(date)}
/>
<div className="mb-8 mt-5 flex w-full"> <div className="mb-8 mt-5 flex w-full">
<Button <Button
variant="ghost" variant="ghost"
@@ -101,7 +112,18 @@ const DatePicker: React.FC<DateTimePickerProps> = ({
) )
} }
export const CalendarComponent = ({ date, onChange }) => ( type CalendarComponentProps = {
date: Date | null
onChange: (
date: Date | null,
event: React.SyntheticEvent<any, Event> | undefined
) => void
}
export const CalendarComponent = ({
date,
onChange,
}: CalendarComponentProps) => (
<ReactDatePicker <ReactDatePicker
selected={date} selected={date}
inline inline
@@ -70,7 +70,7 @@ const TimePicker: React.FC<DateTimePickerProps> = ({
<ClockIcon size={16} /> <ClockIcon size={16} />
<span className="mx-1">UTC</span> <span className="mx-1">UTC</span>
<span className="text-grey-90"> <span className="text-grey-90">
{moment.utc(date).format("HH:mm")} {date ? moment.utc(date).format("HH:mm") : "--:--"}
</span> </span>
</div> </div>
</InputContainer> </InputContainer>
@@ -84,13 +84,13 @@ const TimePicker: React.FC<DateTimePickerProps> = ({
<NumberScroller <NumberScroller
numbers={hourNumbers} numbers={hourNumbers}
selected={selectedHour} selected={selectedHour}
onSelect={(n) => setSelectedHour(n)} onSelect={(n) => setSelectedHour(n as number)}
className="pr-4" className="pr-4"
/> />
<NumberScroller <NumberScroller
numbers={minuteNumbers} numbers={minuteNumbers}
selected={selectedMinute} selected={selectedMinute}
onSelect={(n) => setSelectedMinute(n)} onSelect={(n) => setSelectedMinute(n as number)}
/> />
<div className="to-grey-0 h-xlarge absolute bottom-4 left-0 right-0 z-10 bg-gradient-to-b from-transparent" /> <div className="to-grey-0 h-xlarge absolute bottom-4 left-0 right-0 z-10 bg-gradient-to-b from-transparent" />
</PopoverPrimitive.Content> </PopoverPrimitive.Content>
@@ -1,6 +1,6 @@
import { InputHeaderProps } from "../../fundamentals/input-header" import { InputHeaderProps } from "../../fundamentals/input-header"
export type DateTimePickerProps = { export type DateTimePickerProps = {
date: Date date: Date | null
onSubmitDate: (newDate: Date) => void onSubmitDate: (newDate: Date | null) => void
} & InputHeaderProps } & InputHeaderProps
@@ -0,0 +1,34 @@
import FormValidator from "../../../../utils/form-validator"
import { NestedForm } from "../../../../utils/nested-form"
import InputField from "../../../molecules/input"
export type CustomerGroupGeneralFormType = {
name: string
}
type CustomerGroupGeneralFormProps = {
form: NestedForm<CustomerGroupGeneralFormType>
}
export const CustomerGroupGeneralForm = ({
form,
}: CustomerGroupGeneralFormProps) => {
const {
register,
path,
formState: { errors },
} = form
return (
<div>
<InputField
label="Name"
required
{...register(path("name"), {
required: FormValidator.required("Name"),
})}
errors={errors}
/>
</div>
)
}
@@ -0,0 +1,159 @@
import { Controller } from "react-hook-form"
import { NestedForm } from "../../../../utils/nested-form"
import DatePicker from "../../../atoms/date-picker/date-picker"
import TimePicker from "../../../atoms/date-picker/time-picker"
import AvailabilityDuration from "../../../molecules/availability-duration"
import InputField from "../../../molecules/input"
import SwitchableItem from "../../../molecules/switchable-item"
export type DiscountConfigurationFormType = {
starts_at: Date
ends_at: Date | null
usage_limit: number | null
valid_duration: string | null
}
type DiscountConfigurationFormProps = {
form: NestedForm<DiscountConfigurationFormType>
isDynamic?: boolean
}
const DiscountConfigurationForm = ({
form,
isDynamic,
}: DiscountConfigurationFormProps) => {
const { control, path } = form
return (
<div>
<div className="gap-y-large flex flex-col">
<Controller
name={path("starts_at")}
control={control}
render={({ field: { onChange, value } }) => {
return (
<SwitchableItem
open={!!value}
onSwitch={() => {
if (value) {
onChange(null)
} else {
onChange(new Date())
}
}}
title="Discount has a start date?"
description="Schedule the discount to activate in the future."
>
<div className="gap-x-xsmall flex items-center">
<DatePicker
date={value!}
label="Start date"
onSubmitDate={onChange}
/>
<TimePicker
label="Start time"
date={value!}
onSubmitDate={onChange}
/>
</div>
</SwitchableItem>
)
}}
/>
<Controller
name={path("ends_at")}
control={control}
render={({ field: { value, onChange } }) => {
return (
<SwitchableItem
open={!!value}
onSwitch={() => {
if (value) {
onChange(null)
} else {
onChange(
new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000)
)
}
}}
title="Discount has an expiry date?"
description="Schedule the discount to deactivate in the future."
>
<div className="gap-x-xsmall flex items-center">
<DatePicker
date={value!}
label="Expiry date"
onSubmitDate={onChange}
/>
<TimePicker
label="Expiry time"
date={value!}
onSubmitDate={onChange}
/>
</div>
</SwitchableItem>
)
}}
/>
<Controller
name={path("usage_limit")}
control={control}
render={({ field: { value, onChange } }) => {
return (
<SwitchableItem
open={!!value}
onSwitch={() => {
if (value) {
onChange(null)
} else {
onChange(10)
}
}}
title="Limit the number of redemtions?"
description="Limit applies across all customers, not per customer."
>
<InputField
label="Number of redemptions"
type="number"
placeholder="5"
min={1}
defaultValue={value ?? undefined}
onChange={(value) => onChange(value.target.valueAsNumber)}
/>
</SwitchableItem>
)
}}
/>
{isDynamic && (
<Controller
name={path("valid_duration")}
control={control}
render={({ field: { onChange, value } }) => {
return (
<SwitchableItem
open={!!value}
onSwitch={() => {
if (value) {
onChange(null)
} else {
onChange("P0Y0M0DT00H00M")
}
}}
title="Availability duration?"
description="Set the duration of the discount."
>
<AvailabilityDuration
value={value ?? undefined}
onChange={onChange}
/>
</SwitchableItem>
)
}}
/>
)}
</div>
</div>
)
}
export default DiscountConfigurationForm
@@ -0,0 +1,192 @@
import clsx from "clsx"
import { useAdminRegions } from "medusa-react"
import { useMemo } from "react"
import { Controller, useWatch } from "react-hook-form"
import { Option } from "../../../../types/shared"
import FormValidator from "../../../../utils/form-validator"
import { NestedForm } from "../../../../utils/nested-form"
import InputError from "../../../atoms/input-error"
import IconTooltip from "../../../molecules/icon-tooltip"
import IndeterminateCheckbox from "../../../molecules/indeterminate-checkbox"
import InputField from "../../../molecules/input"
import { NextSelect } from "../../../molecules/select/next-select"
import TextArea from "../../../molecules/textarea"
import PriceFormInput from "../../general/prices-form/price-form-input"
type DiscountRegionOption = Option & {
currency_code: string
}
enum DiscountRuleType {
FIXED = "fixed",
PERCENTAGE = "percentage",
FREE_SHIPPING = "free_shipping",
}
export type DiscountGeneralFormType = {
region_ids: DiscountRegionOption[]
code: string
value?: number
description: string
is_dynamic?: boolean
}
type DiscountGeneralFormProps = {
form: NestedForm<DiscountGeneralFormType>
type: DiscountRuleType
isEdit?: boolean
}
const DiscountGeneralForm = ({
form,
type,
isEdit,
}: DiscountGeneralFormProps) => {
const {
register,
path,
control,
formState: { errors },
} = form
const { regions } = useAdminRegions()
const regionOptions = useMemo(() => {
return (
regions?.map((r) => ({
value: r.id,
label: r.name,
currency_code: r.currency_code,
})) || []
)
}, [regions])
const selectedRegionCurrency = useWatch({
control,
name: path("region_ids.0.currency_code"),
defaultValue: "usd",
})
return (
<div className="gap-y-large flex flex-col">
<Controller
name={path("region_ids")}
control={control}
rules={{
required: FormValidator.required("Regions"),
}}
render={({ field: { value, onChange, name, ref } }) => {
return (
<NextSelect
name={name}
ref={ref}
value={value}
onChange={(value) => {
onChange(type === DiscountRuleType.FIXED ? [value] : value)
}}
label="Choose valid regions"
isMulti={type !== DiscountRuleType.FIXED}
selectAll={type !== DiscountRuleType.FIXED}
isSearchable
required
options={regionOptions}
errors={errors}
/>
)
}}
/>
<div>
<div
className={clsx("gap-small grid", {
"grid-cols-2":
type === DiscountRuleType.FIXED ||
type === DiscountRuleType.PERCENTAGE,
"grid-cols-1": type === DiscountRuleType.FREE_SHIPPING,
})}
>
<InputField
label="Code"
required
errors={errors}
{...register(path("code"), {
required: FormValidator.required("Code"),
})}
/>
{type === DiscountRuleType.FIXED ? (
<Controller
name={path("value")}
rules={{
required: FormValidator.required("Amount"),
shouldUnregister: true,
}}
render={({ field: { value, onChange } }) => {
return (
<PriceFormInput
label="Amount"
amount={value}
onChange={onChange}
currencyCode={selectedRegionCurrency}
errors={errors}
/>
)
}}
/>
) : type === DiscountRuleType.PERCENTAGE ? (
<InputField
label="Percentage"
placeholder="Percentage"
errors={errors}
prefix="%"
required
{...register(path("value"), {
valueAsNumber: true,
required: FormValidator.required("Percentage"),
shouldUnregister: true,
})}
/>
) : null}
</div>
<p className="inter-small-regular text-grey-50 mt-small max-w-[60%]">
The code your customers will enter during checkout. This will appear
on your customer&apos;s invoice. Uppercase letters and numbers only.
</p>
</div>
<div>
<TextArea
label="Description"
errors={errors}
required
{...register(path("description"), {
required: FormValidator.required("Description"),
})}
/>
</div>
{!isEdit && (
<div>
<Controller
name={path("is_dynamic")}
control={control}
render={({ field: { value, onChange, ref } }) => {
return (
<div>
<div className="flex items-center">
<IndeterminateCheckbox
checked={value}
onChange={onChange}
ref={ref}
/>
<p className="ml-small mr-xsmall">Template discount</p>
<IconTooltip content="Template discounts allow you to define a set of rules that can be used across a group of discounts. This is useful in campaigns that should generate unique codes for each user, but where the rules for all unique codes should be the same." />
</div>
<InputError errors={errors} name={path("is_dynamic")} />
</div>
)
}}
/>
</div>
)}
</div>
)
}
export default DiscountGeneralForm
@@ -0,0 +1,87 @@
import FormValidator from "../../../../utils/form-validator"
import { NestedForm } from "../../../../utils/nested-form"
import InputField from "../../../molecules/input"
export type AddressContactFormType = {
first_name: string
last_name: string
company: string | null
phone: string | null
}
type AddressContactFormProps = {
requireFields?: Partial<Record<keyof AddressContactFormType, boolean>>
form: NestedForm<AddressContactFormType>
}
/**
* Re-usable form for address contact information, used to create and edit addresses.
* Fields are optional, but can be required by passing in a requireFields object.
*/
const AddressContactForm = ({
form,
requireFields,
}: AddressContactFormProps) => {
const {
path,
register,
formState: { errors },
} = form
return (
<div>
<div className="gap-large grid grid-cols-2">
<InputField
{...register(path("first_name"), {
required: requireFields?.first_name
? FormValidator.required("First name")
: false,
pattern: FormValidator.whiteSpaceRule("First name"),
})}
placeholder="First Name"
label="First Name"
required={requireFields?.first_name}
errors={errors}
/>
<InputField
{...form.register(path("last_name"), {
required: requireFields?.last_name
? FormValidator.required("Last name")
: false,
pattern: FormValidator.whiteSpaceRule("Last name"),
})}
placeholder="Last Name"
label="Last Name"
required={requireFields?.last_name}
errors={errors}
/>
<InputField
{...form.register(path("company"), {
pattern: FormValidator.whiteSpaceRule("Company"),
required: requireFields?.company
? FormValidator.required("Company")
: false,
})}
placeholder="Company"
required={requireFields?.company}
label="Company"
errors={errors}
/>
<InputField
{...form.register(path("phone"), {
pattern: FormValidator.whiteSpaceRule("Phone"),
required: requireFields?.phone
? FormValidator.required("Phone")
: false,
})}
required={requireFields?.phone}
placeholder="Phone"
label="Phone"
errors={errors}
/>
</div>
</div>
)
}
export default AddressContactForm
@@ -0,0 +1,129 @@
import { Controller } from "react-hook-form"
import { Option } from "../../../../types/shared"
import FormValidator from "../../../../utils/form-validator"
import { NestedForm } from "../../../../utils/nested-form"
import InputField from "../../../molecules/input"
import { NextSelect } from "../../../molecules/select/next-select"
export type AddressLocationFormType = {
address_1: string
address_2: string | null
city: string
province: string | null
country_code: Option
postal_code: string
}
type AddressLocationFormProps = {
requireFields?: Partial<Record<keyof AddressLocationFormType, boolean>>
countryOptions?: Option[]
form: NestedForm<AddressLocationFormType>
}
/**
* Re-usable form for address location information, used to create and edit addresses.
* Fields are optional, but can be required by passing in a requireFields object.
*/
const AddressLocationForm = ({
form,
countryOptions,
requireFields,
}: AddressLocationFormProps) => {
const {
register,
path,
formState: { errors },
control,
} = form
return (
<div className="gap-large grid grid-cols-2">
<InputField
{...register(path("address_1"), {
required: requireFields?.address_1
? FormValidator.required("Address 1")
: false,
pattern: FormValidator.whiteSpaceRule("Address 1"),
})}
placeholder="Address 1"
label="Address 1"
required={requireFields?.address_1}
errors={errors}
/>
<InputField
{...register(path("address_2"), {
pattern: FormValidator.whiteSpaceRule("Address 2"),
required: requireFields?.address_2
? FormValidator.required("Address 2")
: false,
})}
placeholder="Address 2"
required={requireFields?.address_2}
label="Address 2"
errors={errors}
/>
<InputField
{...register(path("postal_code"), {
required: requireFields?.postal_code
? FormValidator.required("Postal code")
: false,
pattern: FormValidator.whiteSpaceRule("Postal code"),
})}
placeholder="Postal code"
label="Postal code"
required={requireFields?.postal_code}
autoComplete="off"
errors={errors}
/>
<InputField
placeholder="City"
label="City"
{...register(path("city"), {
required: requireFields?.city
? FormValidator.required("City")
: false,
pattern: FormValidator.whiteSpaceRule("City"),
})}
required={requireFields?.city}
errors={errors}
/>
<InputField
{...register(path("province"), {
pattern: FormValidator.whiteSpaceRule("Province"),
required: requireFields?.province
? FormValidator.required("Province")
: false,
})}
placeholder="Province"
label="Province"
required={requireFields?.province}
errors={errors}
/>
<Controller
control={control}
name={path("country_code")}
rules={{
required: requireFields?.country_code
? FormValidator.required("Country")
: false,
}}
render={({ field: { value, onChange } }) => {
return (
<NextSelect
label="Country"
required={requireFields?.country_code}
value={value}
options={countryOptions}
onChange={onChange}
name={path("country_code")}
errors={errors}
isClearable={!requireFields?.country_code}
/>
)
}}
/>
</div>
)
}
export default AddressLocationForm
@@ -0,0 +1,371 @@
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
import clsx from "clsx"
import React, { useMemo } from "react"
import { useFieldArray, useWatch } from "react-hook-form"
import { NestedForm } from "../../../../utils/nested-form"
import Button from "../../../fundamentals/button"
import ArrowDownIcon from "../../../fundamentals/icons/arrow-down-icon"
import ArrowUpIcon from "../../../fundamentals/icons/arrow-up-icon"
import DuplicateIcon from "../../../fundamentals/icons/duplicate-icon"
import EllipsisVerticalIcon from "../../../fundamentals/icons/ellipsis-vertical-icon"
import TrashIcon from "../../../fundamentals/icons/trash-icon"
import WarningCircleIcon from "../../../fundamentals/icons/warning-circle"
import XCircleIcon from "../../../fundamentals/icons/x-circle-icon"
export type MetadataField = {
key: string
value: string
state?: "existing" | "new"
}
export type MetadataFormType = {
entries: MetadataField[]
deleted?: string[]
ignored?: Record<string, any>
}
type MetadataProps = {
form: NestedForm<MetadataFormType>
}
const MetadataForm = ({ form }: MetadataProps) => {
const { control, path, register, setValue, getValues } = form
const { fields, remove, insert } = useFieldArray({
control,
name: path("entries"),
keyName: "fieldKey",
})
const handleInsertAbove = (index: number) => {
insert(index, { key: "", value: "" })
}
const handleInsertBelow = (index: number) => {
insert(index + 1, { key: "", value: "" })
}
const handleDelete = (index: number) => {
if (fields[index].state === "existing") {
const deleted = getValues(path(`deleted`)) || []
setValue(path(`deleted`), [...deleted, fields[index].key], {
shouldDirty: true,
})
}
if (index === 0 && fields.length === 1) {
setValue(path(`entries.${index}.value`), "", {
shouldDirty: true,
})
setValue(path(`entries.${index}.key`), "", {
shouldDirty: true,
})
} else {
remove(index)
}
}
const handleDuplicate = (index: number) => {
insert(index + 1, { ...fields[index], state: undefined })
}
const handleClearContents = (index: number) => {
setValue(path(`entries.${index}.value`), "", {
shouldDirty: true,
})
setValue(path(`entries.${index}.key`), "", {
shouldDirty: true,
})
}
const subscriber = useWatch({
control,
name: path("entries"),
})
const ignoredSubscriber = useWatch({
control,
name: path("ignored"),
defaultValue: {},
})
const ignoredLength = useMemo(() => {
return Object.keys(ignoredSubscriber || {}).length
}, [ignoredSubscriber])
// If there is only one row and it is empty or there are no rows, disable the delete button
const isDisabled = useMemo(() => {
if (!subscriber?.length) {
return true
}
if (subscriber?.length === 1) {
return (
(subscriber[0].key === "" && subscriber[0].value === "") ||
(subscriber[0].key === undefined && subscriber[0].value === undefined)
)
}
return false
}, [subscriber])
const rowClasses =
"divide-grey-20 grid grid-cols-[165px_1fr] divide-x divide-solid [&>div]:px-base [&>div]:py-xsmall"
return (
<>
<div className="rounded-rounded border-grey-20 divide-grey-20 inter-base-regular divide-y border">
<div
className={clsx(
"inter-small-semibold bg-grey-5 rounded-t-rounded",
rowClasses
)}
>
<div>
<p>Key</p>
</div>
<div className="">
<p>Value</p>
</div>
</div>
<div className="divide-grey-20 divide-y">
{!fields.length ? (
<MetadataRow
onClearContents={() => handleClearContents(0)}
onDelete={() => handleDelete(0)}
onDuplicate={() => handleDuplicate(0)}
onInsertAbove={() => handleInsertAbove(0)}
onInsertBelow={() => handleInsertBelow(0)}
isDisabled={isDisabled}
>
<div>
<MetadataInput
{...register(path(`entries.${0}.key`))}
placeholder="Key"
/>
</div>
<div>
<MetadataInput
{...register(path(`entries.${0}.value`))}
placeholder="Value"
/>
</div>
</MetadataRow>
) : (
fields.map((field, index) => {
return (
<MetadataRow
key={field.fieldKey}
onClearContents={() => handleClearContents(index)}
onDelete={() => handleDelete(index)}
onDuplicate={() => handleDuplicate(index)}
onInsertAbove={() => handleInsertAbove(index)}
onInsertBelow={() => handleInsertBelow(index)}
isDisabled={isDisabled}
>
<div>
<MetadataInput
{...register(path(`entries.${index}.key`))}
placeholder="Key"
/>
</div>
<div>
<MetadataInput
{...register(path(`entries.${index}.value`))}
placeholder="Value"
/>
</div>
</MetadataRow>
)
})
)}
</div>
</div>
{ignoredLength > 0 && (
<div className="rounded-rounded p-base gap-x-base mt-base flex items-start border border-[#FFD386] bg-[#FFECBC]">
<div>
<WarningCircleIcon
fillType="solid"
size={20}
className="text-[#FFB224]"
/>
</div>
<div>
<p className="inter-small-regular text-[#AD5700]">
This entities metadata contains complex values that we currently
don&apos;t support editing through the admin UI. Due to this{" "}
{Object.keys(ignoredLength)} keys are currently not being
displayed. You can still edit these values using the API.
</p>
</div>
</div>
)}
</>
)
}
export default MetadataForm
const MetadataInput = React.forwardRef<
HTMLInputElement,
React.ComponentPropsWithoutRef<"input">
>(({ className, ...props }, ref) => {
return (
<input
ref={ref}
className={clsx(
"placeholder:text-grey-40 placeholder:inter-base-regular w-full appearance-none outline-none",
className
)}
{...props}
/>
)
})
MetadataInput.displayName = "MetadataInput"
type MetadataRowProps = React.PropsWithChildren<{
onDuplicate: () => void
onInsertAbove: () => void
onInsertBelow: () => void
onDelete: () => void
onClearContents: () => void
isDisabled?: boolean
}>
const MetadataRow = ({
onDuplicate,
onInsertAbove,
onInsertBelow,
onDelete,
onClearContents,
isDisabled = false,
children,
}: MetadataRowProps) => {
const itemClasses =
"px-base py-[6px] outline-none flex items-center gap-x-xsmall hover:bg-grey-5 focus:bg-grey-10 transition-colors cursor-pointer"
return (
<div className="last-of-type:rounded-b-rounded group/metadata relative">
<div className="divide-grey-20 [&>div]:px-base [&>div]:py-xsmall grid grid-cols-[165px_1fr] divide-x divide-solid">
{children}
</div>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<Button
variant="secondary"
size="small"
className={clsx(
"h-xlarge w-large -right-small radix-state-open:opacity-100 absolute inset-y-1/2 -translate-y-1/2 transform opacity-0 transition-opacity group-hover/metadata:opacity-100"
)}
>
<EllipsisVerticalIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content asChild sideOffset={8}>
<div className="bg-grey-0 shadow-dropdown border-grey-20 rounded-rounded z-50 overflow-hidden border">
<DropdownMenu.Item onClick={onInsertAbove} className={itemClasses}>
<ArrowUpIcon size={20} />
<span>Insert above</span>
</DropdownMenu.Item>
<DropdownMenu.Item onClick={onInsertBelow} className={itemClasses}>
<ArrowDownIcon size={20} />
<span>Insert below</span>
</DropdownMenu.Item>
<DropdownMenu.Item onClick={onDuplicate} className={itemClasses}>
<DuplicateIcon size={20} />
<span>Duplicate</span>
</DropdownMenu.Item>
<DropdownMenu.Item
onClick={onClearContents}
className={itemClasses}
>
<XCircleIcon size={20} />
<span>Clear contents</span>
</DropdownMenu.Item>
<DropdownMenu.DropdownMenuSeparator className="bg-grey-20 h-px w-full" />
<DropdownMenu.Item
onClick={onDelete}
disabled={isDisabled}
className={clsx(
{
"text-grey-40": isDisabled,
"text-rose-50": !isDisabled,
},
itemClasses,
"hover:bg-grey-0"
)}
>
<TrashIcon size={20} />
<span>Delete</span>
</DropdownMenu.Item>
</div>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
)
}
export const getSubmittableMetadata = (
data: MetadataFormType
): Record<string, unknown> => {
const metadata = data.entries.reduce((acc, { key, value }) => {
if (key) {
acc[key] = value
}
return acc
}, {} as Record<string, unknown>)
if (data.deleted?.length) {
data.deleted.forEach((key) => {
metadata[key] = ""
})
}
// Preserve complex values that we don't support editing through the UI
if (data.ignored) {
Object.entries(data.ignored).forEach(([key, value]) => {
metadata[key] = value
})
}
return metadata
}
export const getMetadataFormValues = (
metadata?: Record<string, any> | null
): MetadataFormType => {
const data: MetadataFormType = {
entries: [],
deleted: [],
ignored: {},
}
if (metadata) {
Object.entries(metadata).forEach(([key, value]) => {
if (isPrimitive(value)) {
data.entries.push({
key,
value: value as string,
state: "existing",
})
} else {
data.ignored![key] = value
}
})
}
return data
}
const isPrimitive = (value: any): boolean => {
return (
value === null ||
value === undefined ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
)
}
@@ -1,8 +1,9 @@
import clsx from "clsx" import clsx from "clsx"
import { useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import AmountField from "react-currency-input-field" import AmountField from "react-currency-input-field"
import { currencies } from "../../../../utils/currencies" import { currencies } from "../../../../utils/currencies"
import InputError from "../../../atoms/input-error" import InputError from "../../../atoms/input-error"
import InputHeader from "../../../fundamentals/input-header"
type Props = { type Props = {
currencyCode: string currencyCode: string
@@ -10,6 +11,8 @@ type Props = {
onChange: (amount?: number) => void onChange: (amount?: number) => void
errors?: { [x: string]: unknown } errors?: { [x: string]: unknown }
name?: string name?: string
label?: string
required?: boolean
} }
const PriceFormInput = ({ const PriceFormInput = ({
@@ -18,13 +21,18 @@ const PriceFormInput = ({
errors, errors,
amount, amount,
onChange, onChange,
label,
required,
}: Props) => { }: Props) => {
const { symbol_native, decimal_digits } = const { symbol_native, decimal_digits } =
currencies[currencyCode.toUpperCase()] currencies[currencyCode.toUpperCase()]
const getFormattedValue = (value: number) => { const getFormattedValue = useCallback(
return `${value / 10 ** decimal_digits}` (value: number) => {
} return `${value / 10 ** decimal_digits}`
},
[decimal_digits]
)
const [formattedValue, setFormattedValue] = useState<string | undefined>( const [formattedValue, setFormattedValue] = useState<string | undefined>(
amount !== null && amount !== undefined amount !== null && amount !== undefined
@@ -36,7 +44,7 @@ const PriceFormInput = ({
if (amount) { if (amount) {
setFormattedValue(getFormattedValue(amount)) setFormattedValue(getFormattedValue(amount))
} }
}, [amount, decimal_digits]) }, [amount, decimal_digits, getFormattedValue])
const onAmountChange = (value?: string, floatValue?: number | null) => { const onAmountChange = (value?: string, floatValue?: number | null) => {
if (typeof floatValue === "number") { if (typeof floatValue === "number") {
@@ -52,6 +60,7 @@ const PriceFormInput = ({
return ( return (
<div> <div>
{label && <InputHeader {...{ label, required }} className="mb-xsmall" />}
<div <div
className={clsx( className={clsx(
"bg-grey-5 border-gray-20 px-small py-xsmall rounded-rounded focus-within:shadow-input focus-within:border-violet-60 flex h-10 w-full items-center border", "bg-grey-5 border-gray-20 px-small py-xsmall rounded-rounded focus-within:shadow-input focus-within:border-violet-60 flex h-10 w-full items-center border",
@@ -1,11 +1,12 @@
import { Controller, useFieldArray, useWatch } from "react-hook-form" import { Controller, useFieldArray, useWatch } from "react-hook-form"
import { currencies } from "../../../../utils/currencies" import { currencies } from "../../../../utils/currencies"
import { NestedForm } from "../../../../utils/nested-form" import { nestedForm, NestedForm } from "../../../../utils/nested-form"
import IncludesTaxTooltip from "../../../atoms/includes-tax-tooltip" import IncludesTaxTooltip from "../../../atoms/includes-tax-tooltip"
import InputError from "../../../atoms/input-error" import InputError from "../../../atoms/input-error"
import Switch from "../../../atoms/switch" import Switch from "../../../atoms/switch"
import CoinsIcon from "../../../fundamentals/icons/coins-icon" import CoinsIcon from "../../../fundamentals/icons/coins-icon"
import IconTooltip from "../../../molecules/icon-tooltip" import IconTooltip from "../../../molecules/icon-tooltip"
import MetadataForm, { MetadataFormType } from "../../general/metadata-form"
import PriceFormInput from "../../general/prices-form/price-form-input" import PriceFormInput from "../../general/prices-form/price-form-input"
type DenominationType = { type DenominationType = {
@@ -18,6 +19,7 @@ export type DenominationFormType = {
defaultDenomination: DenominationType defaultDenomination: DenominationType
currencyDenominations: DenominationType[] currencyDenominations: DenominationType[]
useSameValue: boolean useSameValue: boolean
metadata: MetadataFormType
} }
type Props = { type Props = {
@@ -51,7 +53,7 @@ const DenominationForm = ({ form }: Props) => {
<div className="gap-y-xlarge flex flex-col"> <div className="gap-y-xlarge flex flex-col">
<div> <div>
<div className="gap-x-2xsmall flex items-center"> <div className="gap-x-2xsmall flex items-center">
<h2 className="inter-large-semibold">Default currency</h2> <h2 className="inter-base-semibold">Default currency</h2>
<IconTooltip <IconTooltip
type="info" type="info"
content="The denomination in your store's default currency" content="The denomination in your store's default currency"
@@ -170,6 +172,10 @@ const DenominationForm = ({ form }: Props) => {
</div> </div>
</div> </div>
)} )}
<div>
<h2 className="inter-base-semibold mb-base">Metadata</h2>
<MetadataForm form={nestedForm(form, "metadata")} />
</div>
</div> </div>
) )
} }
@@ -3,13 +3,14 @@ import { useMemo } from "react"
import { import {
useAdminCollections, useAdminCollections,
useAdminProductCategories, useAdminProductCategories,
useAdminProductTypes useAdminProductTypes,
} from "medusa-react" } from "medusa-react"
import { NestedMultiselectOption } from "../../../../domain/categories/components/multiselect" import { NestedMultiselectOption } from "../../../../domain/categories/components/multiselect"
import { transformCategoryToNestedFormOptions } from "../../../../domain/categories/utils/transform-response" import { transformCategoryToNestedFormOptions } from "../../../../domain/categories/utils/transform-response"
import { import {
FeatureFlag, useFeatureFlag FeatureFlag,
useFeatureFlag,
} from "../../../../providers/feature-flag-provider" } from "../../../../providers/feature-flag-provider"
const useOrganizeData = () => { const useOrganizeData = () => {
@@ -19,12 +20,15 @@ const useOrganizeData = () => {
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
}) })
const { collections } = useAdminCollections() const { collections } = useAdminCollections()
const { product_categories: categories = [] } = const { product_categories: categories = [] } = useAdminProductCategories(
isFeatureEnabled(FeatureFlag.PRODUCT_CATEGORIES) && {
useAdminProductCategories({
parent_category_id: "null", parent_category_id: "null",
include_descendants_tree: true, include_descendants_tree: true,
}) },
{
enabled: isFeatureEnabled(FeatureFlag.PRODUCT_CATEGORIES),
}
)
const productTypeOptions = useMemo(() => { const productTypeOptions = useMemo(() => {
return ( return (
@@ -11,6 +11,7 @@ import { nestedForm } from "../../../../../utils/nested-form"
import IconTooltip from "../../../../molecules/icon-tooltip" import IconTooltip from "../../../../molecules/icon-tooltip"
import InputField from "../../../../molecules/input" import InputField from "../../../../molecules/input"
import Accordion from "../../../../organisms/accordion" import Accordion from "../../../../organisms/accordion"
import MetadataForm, { MetadataFormType } from "../../../general/metadata-form"
import { PricesFormType } from "../../../general/prices-form" import { PricesFormType } from "../../../general/prices-form"
import VariantPricesForm from "../variant-prices-form" import VariantPricesForm from "../variant-prices-form"
@@ -29,6 +30,7 @@ export type EditFlowVariantFormType = {
}[] }[]
customs: CustomsFormType customs: CustomsFormType
dimensions: DimensionsFormType dimensions: DimensionsFormType
metadata: MetadataFormType
} }
type Props = { type Props = {
@@ -125,6 +127,13 @@ const EditFlowVariantForm = ({ form, isEdit }: Props) => {
</div> </div>
)} )}
</Accordion.Item> </Accordion.Item>
<Accordion.Item title="Metadata" value="metadata">
<p className="inter-base-regular text-grey-50 mb-base">
Metadata can be used to store additional information about the
variant.
</p>
<MetadataForm form={nestedForm(form, "metadata")} />
</Accordion.Item>
</Accordion> </Accordion>
) )
} }
@@ -0,0 +1,26 @@
import IconProps from "../types/icon-type"
const EllipsisVerticalIcon = ({
size = 20,
color = "currentColor",
...attributes
}: IconProps) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...attributes}
>
<path
d="M10 3.75C10.1989 3.75 10.3897 3.82902 10.5303 3.96967C10.671 4.11032 10.75 4.30109 10.75 4.5C10.75 4.69891 10.671 4.88968 10.5303 5.03033C10.3897 5.17098 10.1989 5.25 10 5.25C9.80109 5.25 9.61032 5.17098 9.46967 5.03033C9.32902 4.88968 9.25 4.69891 9.25 4.5C9.25 4.30109 9.32902 4.11032 9.46967 3.96967C9.61032 3.82902 9.80109 3.75 10 3.75ZM10 9.25C10.1989 9.25 10.3897 9.32902 10.5303 9.46967C10.671 9.61032 10.75 9.80109 10.75 10C10.75 10.1989 10.671 10.3897 10.5303 10.5303C10.3897 10.671 10.1989 10.75 10 10.75C9.80109 10.75 9.61032 10.671 9.46967 10.5303C9.32902 10.3897 9.25 10.1989 9.25 10C9.25 9.80109 9.32902 9.61032 9.46967 9.46967C9.61032 9.32902 9.80109 9.25 10 9.25ZM10.5303 14.9697C10.671 15.1103 10.75 15.3011 10.75 15.5C10.75 15.6989 10.671 15.8897 10.5303 16.0303C10.3897 16.171 10.1989 16.25 10 16.25C9.80109 16.25 9.61032 16.171 9.46967 16.0303C9.32902 15.8897 9.25 15.6989 9.25 15.5C9.25 15.3011 9.32902 15.1103 9.46967 14.9697C9.61032 14.829 9.80109 14.75 10 14.75C10.1989 14.75 10.3897 14.829 10.5303 14.9697Z"
stroke={color}
strokeWidth="1.5"
/>
</svg>
)
}
export default EllipsisVerticalIcon
@@ -1,4 +1,3 @@
import InputHeader, { InputHeaderProps } from "../../fundamentals/input-header"
import React, { import React, {
ChangeEventHandler, ChangeEventHandler,
FocusEventHandler, FocusEventHandler,
@@ -6,11 +5,12 @@ import React, {
useImperativeHandle, useImperativeHandle,
useRef, useRef,
} from "react" } from "react"
import InputHeader, { InputHeaderProps } from "../../fundamentals/input-header"
import clsx from "clsx"
import InputError from "../../atoms/input-error" import InputError from "../../atoms/input-error"
import MinusIcon from "../../fundamentals/icons/minus-icon" import MinusIcon from "../../fundamentals/icons/minus-icon"
import PlusIcon from "../../fundamentals/icons/plus-icon" import PlusIcon from "../../fundamentals/icons/plus-icon"
import clsx from "clsx"
export type InputProps = Omit<React.ComponentPropsWithRef<"input">, "prefix"> & export type InputProps = Omit<React.ComponentPropsWithRef<"input">, "prefix"> &
InputHeaderProps & { InputHeaderProps & {
@@ -26,7 +26,6 @@ export type InputProps = Omit<React.ComponentPropsWithRef<"input">, "prefix"> &
props?: React.HTMLAttributes<HTMLDivElement> props?: React.HTMLAttributes<HTMLDivElement>
} }
// eslint-disable-next-line react/display-name
const InputField = React.forwardRef<HTMLInputElement, InputProps>( const InputField = React.forwardRef<HTMLInputElement, InputProps>(
( (
{ {
@@ -161,4 +160,6 @@ const InputField = React.forwardRef<HTMLInputElement, InputProps>(
} }
) )
InputField.displayName = "InputField"
export default InputField export default InputField
@@ -85,7 +85,7 @@ const Item: React.FC<AccordionItemProps> = ({
<AccordionPrimitive.Content <AccordionPrimitive.Content
forceMount={forceMountContent} forceMount={forceMountContent}
className={clsx( className={clsx(
"radix-state-closed:animate-accordion-close radix-state-open:animate-accordion-open overflow-hidden px-1" "radix-state-closed:animate-accordion-close radix-state-open:animate-accordion-open radix-state-closed:pointer-events-none px-1"
)} )}
> >
<div className="inter-base-regular group-radix-state-closed:animate-accordion-close"> <div className="inter-base-regular group-radix-state-closed:animate-accordion-close">
@@ -18,6 +18,8 @@ type BodyCardProps = {
status?: React.ReactNode status?: React.ReactNode
customHeader?: React.ReactNode customHeader?: React.ReactNode
compact?: boolean compact?: boolean
footerMinHeight?: number
setBorders?: boolean
} & React.HTMLAttributes<HTMLDivElement> } & React.HTMLAttributes<HTMLDivElement>
const BodyCard: React.FC<BodyCardProps> = ({ const BodyCard: React.FC<BodyCardProps> = ({
@@ -94,7 +96,7 @@ const BodyCard: React.FC<BodyCardProps> = ({
{children && ( {children && (
<div <div
className={clsx("flex flex-col", { className={clsx("flex flex-col", {
"my-large grow": !compact, grow: !compact,
})} })}
> >
{children} {children}
@@ -109,7 +109,7 @@ const EditDenominationsModal = ({
<Modal open={open} handleClose={handleClose}> <Modal open={open} handleClose={handleClose}>
<Modal.Body> <Modal.Body>
<Modal.Header handleClose={handleClose}> <Modal.Header handleClose={handleClose}>
<h1 className="inter-xlarge-semibold">Edit Denominations</h1> <h1 className="inter-xlarge-semibold">Edit Denomination</h1>
</Modal.Header> </Modal.Header>
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<Modal.Content> <Modal.Content>
@@ -6,13 +6,18 @@ import OrganizeForm, {
OrganizeFormType, OrganizeFormType,
} from "../../forms/product/organize-form" } from "../../forms/product/organize-form"
import Button from "../../fundamentals/button"
import Modal from "../../molecules/modal"
import { Product } from "@medusajs/medusa" import { Product } from "@medusajs/medusa"
import { nestedForm } from "../../../utils/nested-form"
import useEditProductActions from "../../../hooks/use-edit-product-actions"
import { useEffect } from "react" import { useEffect } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import useEditProductActions from "../../../hooks/use-edit-product-actions"
import { nestedForm } from "../../../utils/nested-form"
import MetadataForm, {
getMetadataFormValues,
getSubmittableMetadata,
MetadataFormType,
} from "../../forms/general/metadata-form"
import Button from "../../fundamentals/button"
import Modal from "../../molecules/modal"
type Props = { type Props = {
product: Product product: Product
@@ -24,6 +29,7 @@ type GeneralFormWrapper = {
general: GeneralFormType general: GeneralFormType
organize: OrganizeFormType organize: OrganizeFormType
discountable: DiscountableFormType discountable: DiscountableFormType
metadata: MetadataFormType
} }
const GeneralModal = ({ product, open, onClose }: Props) => { const GeneralModal = ({ product, open, onClose }: Props) => {
@@ -76,6 +82,7 @@ const GeneralModal = ({ product, open, onClose }: Props) => {
categories: data.organize.categories?.map((id) => ({ id })), categories: data.organize.categories?.map((id) => ({ id })),
discountable: data.discountable.value, discountable: data.discountable.value,
metadata: getSubmittableMetadata(data.metadata),
}, },
onReset onReset
) )
@@ -105,6 +112,10 @@ const GeneralModal = ({ product, open, onClose }: Props) => {
form={nestedForm(form, "discountable")} form={nestedForm(form, "discountable")}
isGiftCard={product.is_giftcard} isGiftCard={product.is_giftcard}
/> />
<div className="mt-xlarge">
<h2 className="inter-base-semibold mb-base">Metadata</h2>
<MetadataForm form={nestedForm(form, "metadata")} />
</div>
</Modal.Content> </Modal.Content>
<Modal.Footer> <Modal.Footer>
<div className="flex w-full justify-end gap-x-2"> <div className="flex w-full justify-end gap-x-2">
@@ -155,6 +166,7 @@ const getDefaultValues = (product: Product): GeneralFormWrapper => {
discountable: { discountable: {
value: product.discountable, value: product.discountable,
}, },
metadata: getMetadataFormValues(product.metadata),
} }
} }
@@ -141,6 +141,18 @@ const ProductDetails = ({ product }: Props) => {
title="Discountable" title="Discountable"
value={product.discountable ? "True" : "False"} value={product.discountable ? "True" : "False"}
/> />
<Detail
title="Metadata"
value={
Object.entries(product.metadata || {}).length > 0
? `${Object.entries(product.metadata || {}).length} ${
Object.keys(product.metadata || {}).length === 1
? "item"
: "items"
}`
: undefined
}
/>
</div> </div>
) )
} }
@@ -1,15 +1,16 @@
import { AdminPostProductsProductVariantsReq, Product } from "@medusajs/medusa" import { AdminPostProductsProductVariantsReq, Product } from "@medusajs/medusa"
import { useContext, useEffect } from "react" import { useEffect } from "react"
import EditFlowVariantForm, { import EditFlowVariantForm, {
EditFlowVariantFormType, EditFlowVariantFormType,
} from "../../forms/product/variant-form/edit-flow-variant-form" } from "../../forms/product/variant-form/edit-flow-variant-form"
import LayeredModal, { import LayeredModal, {
LayeredModalContext, useLayeredModal,
} from "../../molecules/modal/layered-modal" } from "../../molecules/modal/layered-modal"
import { useMedusa } from "medusa-react" import { useMedusa } from "medusa-react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import useEditProductActions from "../../../hooks/use-edit-product-actions" import useEditProductActions from "../../../hooks/use-edit-product-actions"
import { getSubmittableMetadata } from "../../forms/general/metadata-form"
import Button from "../../fundamentals/button" import Button from "../../fundamentals/button"
import Modal from "../../molecules/modal" import Modal from "../../molecules/modal"
@@ -20,7 +21,8 @@ type Props = {
} }
const AddVariantModal = ({ open, onClose, product }: Props) => { const AddVariantModal = ({ open, onClose, product }: Props) => {
const context = useContext(LayeredModalContext) const context = useLayeredModal()
const { client } = useMedusa() const { client } = useMedusa()
const form = useForm<EditFlowVariantFormType>({ const form = useForm<EditFlowVariantFormType>({
defaultValues: getDefaultValues(product), defaultValues: getDefaultValues(product),
@@ -160,6 +162,10 @@ const getDefaultValues = (product: Product): EditFlowVariantFormType => {
hs_code: null, hs_code: null,
origin_country: null, origin_country: null,
}, },
metadata: {
entries: [],
deleted: [],
},
} }
} }
@@ -192,6 +198,7 @@ export const createAddPayload = (
: null, : null,
// @ts-ignore // @ts-ignore
prices: priceArray, prices: priceArray,
metadata: getSubmittableMetadata(data.metadata),
title: data.general.title || `${options?.map((o) => o.value).join(" / ")}`, title: data.general.title || `${options?.map((o) => o.value).join(" / ")}`,
options: options.map((option) => ({ options: options.map((option) => ({
option_id: option.id, option_id: option.id,
@@ -11,6 +11,7 @@ import { useContext } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import useEditProductActions from "../../../hooks/use-edit-product-actions" import useEditProductActions from "../../../hooks/use-edit-product-actions"
import { countries } from "../../../utils/countries" import { countries } from "../../../utils/countries"
import { getMetadataFormValues } from "../../forms/general/metadata-form"
import Button from "../../fundamentals/button" import Button from "../../fundamentals/button"
import Modal from "../../molecules/modal" import Modal from "../../molecules/modal"
import { createAddPayload } from "./add-variant-modal" import { createAddPayload } from "./add-variant-modal"
@@ -204,6 +205,7 @@ export const getEditVariantDefaultValues = (
})), })),
}, },
options, options,
metadata: getMetadataFormValues(variant.metadata),
} }
} }
@@ -10,6 +10,7 @@ import EditFlowVariantForm, {
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import useEditProductActions from "../../../../hooks/use-edit-product-actions" import useEditProductActions from "../../../../hooks/use-edit-product-actions"
import { getSubmittableMetadata } from "../../../forms/general/metadata-form"
import Button from "../../../fundamentals/button" import Button from "../../../fundamentals/button"
import Modal from "../../../molecules/modal" import Modal from "../../../molecules/modal"
import { LayeredModalContext } from "../../../molecules/modal/layered-modal" import { LayeredModalContext } from "../../../molecules/modal/layered-modal"
@@ -117,6 +118,7 @@ export const createUpdatePayload = (
...stock, ...stock,
...dimensions, ...dimensions,
...customs, ...customs,
metadata: getSubmittableMetadata(data.metadata),
// @ts-ignore // @ts-ignore
origin_country: customs?.origin_country origin_country: customs?.origin_country
? customs.origin_country.value ? customs.origin_country.value
@@ -78,12 +78,12 @@ const EditVariantsModal = ({ open, onClose, product }: Props) => {
/> />
) )
}, },
[product] [moveCard, product]
) )
const handleFormReset = () => { const handleFormReset = useCallback(() => {
reset(getDefaultValues(product)) reset(getDefaultValues(product))
} }, [product, reset])
const resetAndClose = () => { const resetAndClose = () => {
handleFormReset() handleFormReset()
@@ -93,7 +93,7 @@ const EditVariantsModal = ({ open, onClose, product }: Props) => {
useEffect(() => { useEffect(() => {
handleFormReset() handleFormReset()
}, [product]) }, [product, handleFormReset])
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
onUpdate( onUpdate(
@@ -28,7 +28,7 @@ function RawJSON(props: RawJSONProps) {
return ( return (
<BodyCard className={"mb-4 h-auto min-h-0 w-full"} title={title}> <BodyCard className={"mb-4 h-auto min-h-0 w-full"} title={title}>
<div className="mt-4 flex flex-grow items-center"> <div className="flex flex-grow items-center">
<JSONView data={data} /> <JSONView data={data} />
</div> </div>
</BodyCard> </BodyCard>
@@ -1,7 +1,8 @@
import { Controller } from "react-hook-form" import { Controller } from "react-hook-form"
import { Option } from "../../types/shared" import { Option } from "../../types/shared"
import FormValidator from "../../utils/form-validator" import FormValidator from "../../utils/form-validator"
import { NestedForm } from "../../utils/nested-form" import { nestedForm, NestedForm } from "../../utils/nested-form"
import MetadataForm, { MetadataFormType } from "../forms/general/metadata-form"
import Input from "../molecules/input" import Input from "../molecules/input"
import { NextSelect } from "../molecules/select/next-select" import { NextSelect } from "../molecules/select/next-select"
@@ -16,6 +17,7 @@ export type AddressPayload = {
country_code: Option country_code: Option
postal_code: string postal_code: string
phone: string | null phone: string | null
metadata: MetadataFormType
} }
export enum AddressType { export enum AddressType {
@@ -173,6 +175,10 @@ const AddressForm = ({
}} }}
/> />
</div> </div>
<div className="mt-xlarge gap-y-base flex flex-col">
<span className="inter-base-semibold">Metadata</span>
<MetadataForm form={nestedForm(form, "metadata")} />
</div>
</div> </div>
) )
} }
@@ -3,15 +3,20 @@ import {
useAdminCreateCollection, useAdminCreateCollection,
useAdminUpdateCollection, useAdminUpdateCollection,
} from "medusa-react" } from "medusa-react"
import React, { useEffect, useState } from "react" import React, { useEffect } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import useNotification from "../../../hooks/use-notification" import useNotification from "../../../hooks/use-notification"
import { getErrorMessage } from "../../../utils/error-messages" import { getErrorMessage } from "../../../utils/error-messages"
import { nestedForm } from "../../../utils/nested-form"
import MetadataForm, {
getSubmittableMetadata,
MetadataFormType,
} from "../../forms/general/metadata-form"
import Button from "../../fundamentals/button" import Button from "../../fundamentals/button"
import IconTooltip from "../../molecules/icon-tooltip" import IconTooltip from "../../molecules/icon-tooltip"
import InputField from "../../molecules/input" import InputField from "../../molecules/input"
import Modal from "../../molecules/modal" import Modal from "../../molecules/modal"
import Metadata, { MetadataField } from "../../organisms/metadata" import { MetadataField } from "../../organisms/metadata"
type CollectionModalProps = { type CollectionModalProps = {
onClose: () => void onClose: () => void
@@ -23,6 +28,7 @@ type CollectionModalProps = {
type CollectionModalFormData = { type CollectionModalFormData = {
title: string title: string
handle: string | undefined handle: string | undefined
metadata: MetadataFormType
} }
const CollectionModal: React.FC<CollectionModalProps> = ({ const CollectionModal: React.FC<CollectionModalProps> = ({
@@ -35,51 +41,54 @@ const CollectionModal: React.FC<CollectionModalProps> = ({
) )
const { mutate: create, isLoading: creating } = useAdminCreateCollection() const { mutate: create, isLoading: creating } = useAdminCreateCollection()
const { register, handleSubmit, reset } = useForm<CollectionModalFormData>() const form = useForm<CollectionModalFormData>({
defaultValues: {
title: collection?.title,
handle: collection?.handle,
metadata: {
entries: Object.entries(collection?.metadata || {}).map(
([key, value]) => ({
key,
value: value as string,
state: "existing",
})
),
},
},
})
const { register, handleSubmit, reset } = form
useEffect(() => {
if (collection) {
reset({
title: collection.title,
handle: collection.handle,
metadata: {
entries: Object.entries(collection.metadata || {}).map(
([key, value]) => ({
key,
value: value as string,
state: "existing",
})
),
},
})
}
}, [collection, reset])
const notification = useNotification() const notification = useNotification()
const [metadata, setMetadata] = useState<MetadataField[]>([])
if (isEdit && !collection) { if (isEdit && !collection) {
throw new Error("Collection is required for edit") throw new Error("Collection is required for edit")
} }
useEffect(() => {
register("title", { required: true })
register("handle")
}, [])
useEffect(() => {
if (isEdit && collection) {
reset({
title: collection.title,
handle: collection.handle,
})
if (collection.metadata) {
Object.entries(collection.metadata).map(([key, value]) => {
if (typeof value === "string") {
const newMeta = metadata
newMeta.push({ key, value })
setMetadata(newMeta)
}
})
}
}
}, [collection, isEdit])
const submit = (data: CollectionModalFormData) => { const submit = (data: CollectionModalFormData) => {
if (isEdit) { if (isEdit) {
update( update(
{ {
title: data.title, title: data.title,
handle: data.handle, handle: data.handle,
metadata: metadata.reduce((acc, next) => { metadata: getSubmittableMetadata(data.metadata),
return {
...acc,
[next.key]: next.value,
}
}, {}),
}, },
{ {
onSuccess: () => { onSuccess: () => {
@@ -100,12 +109,7 @@ const CollectionModal: React.FC<CollectionModalProps> = ({
{ {
title: data.title, title: data.title,
handle: data.handle, handle: data.handle,
metadata: metadata.reduce((acc, next) => { metadata: getSubmittableMetadata(data.metadata),
return {
...acc,
[next.key]: next.value,
}
}, {}),
}, },
{ {
onSuccess: () => { onSuccess: () => {
@@ -159,8 +163,9 @@ const CollectionModal: React.FC<CollectionModalProps> = ({
/> />
</div> </div>
</div> </div>
<div className="mt-xlarge w-full"> <div className="mt-xlarge">
<Metadata setMetadata={setMetadata} metadata={metadata} /> <h2 className="inter-base-semibold mb-base">Metadata</h2>
<MetadataForm form={nestedForm(form, "metadata")} />
</div> </div>
</Modal.Content> </Modal.Content>
<Modal.Footer> <Modal.Footer>
@@ -1,6 +1,5 @@
import { CustomerGroup } from "@medusajs/medusa" import { CustomerGroup } from "@medusajs/medusa"
import { useAdminCustomerGroups } from "medusa-react" import { useAdminCustomerGroups } from "medusa-react"
import { useContext } from "react"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { import {
HeaderGroup, HeaderGroup,
@@ -11,13 +10,11 @@ import {
useSortBy, useSortBy,
useTable, useTable,
} from "react-table" } from "react-table"
import CustomerGroupContext, {
CustomerGroupContextContainer,
} from "../../../domain/customers/groups/context/customer-group-context"
import useQueryFilters from "../../../hooks/use-query-filters" import useQueryFilters from "../../../hooks/use-query-filters"
import useSetSearchParams from "../../../hooks/use-set-search-params" import useSetSearchParams from "../../../hooks/use-set-search-params"
import DetailsIcon from "../../fundamentals/details-icon" import DetailsIcon from "../../fundamentals/details-icon"
import EditIcon from "../../fundamentals/icons/edit-icon" import TrashIcon from "../../fundamentals/icons/trash-icon"
import { ActionType } from "../../molecules/actionables"
import Table from "../../molecules/table" import Table from "../../molecules/table"
import TableContainer from "../../organisms/table-container" import TableContainer from "../../organisms/table-container"
import { CUSTOMER_GROUPS_TABLE_COLUMNS } from "./config" import { CUSTOMER_GROUPS_TABLE_COLUMNS } from "./config"
@@ -92,19 +89,19 @@ function CustomerGroupsTableRow(props: CustomerGroupsTableRowProps) {
const { row } = props const { row } = props
const navigate = useNavigate() const navigate = useNavigate()
const { showModal } = useContext(CustomerGroupContext)
const actions = [ const actions: ActionType[] = [
{
label: "Edit",
onClick: showModal,
icon: <EditIcon size={20} />,
},
{ {
label: "Details", label: "Details",
onClick: () => navigate(row.original.id), onClick: () => navigate(row.original.id),
icon: <DetailsIcon size={20} />, icon: <DetailsIcon size={20} />,
}, },
{
label: "Delete",
onClick: () => {},
icon: <TrashIcon size={20} />,
variant: "danger",
},
] ]
return ( return (
@@ -223,11 +220,7 @@ function CustomerGroupsTable(props: CustomerGroupsTableProps) {
<Table.Body {...table.getTableBodyProps()}> <Table.Body {...table.getTableBodyProps()}>
{table.rows.map((row) => { {table.rows.map((row) => {
table.prepareRow(row) table.prepareRow(row)
return ( return <CustomerGroupsTableRow row={row} key={row.id} />
<CustomerGroupContextContainer key={row.id} group={row.original}>
<CustomerGroupsTableRow row={row} />
</CustomerGroupContextContainer>
)
})} })}
</Table.Body> </Table.Body>
</Table> </Table>
@@ -73,7 +73,7 @@ export const useCustomerOrdersColumns = (): Column<Order>[] => {
} }
return ( return (
<div className="gap-x-2xsmall flex"> <div className="gap-x-2xsmall flex items-center">
<div ref={containerRef} className="gap-x-xsmall flex"> <div ref={containerRef} className="gap-x-xsmall flex">
{visibleItems.map((item) => { {visibleItems.map((item) => {
return ( return (
@@ -2,12 +2,18 @@ import { Customer } from "@medusajs/medusa"
import { useAdminUpdateCustomer } from "medusa-react" import { useAdminUpdateCustomer } from "medusa-react"
import { useEffect } from "react" import { useEffect } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import MetadataForm, {
getMetadataFormValues,
getSubmittableMetadata,
MetadataFormType,
} from "../../../components/forms/general/metadata-form"
import Button from "../../../components/fundamentals/button" import Button from "../../../components/fundamentals/button"
import LockIcon from "../../../components/fundamentals/icons/lock-icon" import LockIcon from "../../../components/fundamentals/icons/lock-icon"
import InputField from "../../../components/molecules/input" import InputField from "../../../components/molecules/input"
import Modal from "../../../components/molecules/modal" import Modal from "../../../components/molecules/modal"
import useNotification from "../../../hooks/use-notification" import useNotification from "../../../hooks/use-notification"
import { getErrorMessage } from "../../../utils/error-messages" import { getErrorMessage } from "../../../utils/error-messages"
import { nestedForm } from "../../../utils/nested-form"
import { validateEmail } from "../../../utils/validate-email" import { validateEmail } from "../../../utils/validate-email"
type EditCustomerModalProps = { type EditCustomerModalProps = {
@@ -20,20 +26,23 @@ type EditCustomerFormType = {
last_name: string last_name: string
email: string email: string
phone: string | null phone: string | null
metadata: MetadataFormType
} }
const EditCustomerModal = ({ const EditCustomerModal = ({
handleClose, handleClose,
customer, customer,
}: EditCustomerModalProps) => { }: EditCustomerModalProps) => {
const form = useForm<EditCustomerFormType>({
defaultValues: getDefaultValues(customer),
})
const { const {
register, register,
reset, reset,
handleSubmit, handleSubmit,
formState: { isDirty }, formState: { isDirty },
} = useForm<EditCustomerFormType>({ } = form
defaultValues: getDefaultValues(customer),
})
const notification = useNotification() const notification = useNotification()
@@ -47,6 +56,7 @@ const EditCustomerModal = ({
// @ts-ignore // @ts-ignore
phone: data.phone, phone: data.phone,
email: data.email, email: data.email,
metadata: getSubmittableMetadata(data.metadata),
}, },
{ {
onSuccess: () => { onSuccess: () => {
@@ -72,48 +82,59 @@ const EditCustomerModal = ({
<span className="inter-xlarge-semibold">Customer Details</span> <span className="inter-xlarge-semibold">Customer Details</span>
</Modal.Header> </Modal.Header>
<Modal.Content> <Modal.Content>
<div className="inter-base-semibold text-grey-90 mb-4">General</div> <div className="gap-y-xlarge flex flex-col">
<div className="mb-4 flex w-full space-x-2"> <div>
<InputField <h2 className="inter-base-semibold text-grey-90 mb-4">General</h2>
label="First Name" <div className="flex w-full space-x-2">
{...register("first_name")} <InputField
placeholder="Lebron" label="First Name"
/> {...register("first_name")}
<InputField placeholder="Lebron"
label="Last Name" />
{...register("last_name")} <InputField
placeholder="James" label="Last Name"
/> {...register("last_name")}
</div> placeholder="James"
<div className="inter-base-semibold text-grey-90 mb-4">Contact</div> />
<div className="flex space-x-2"> </div>
<InputField </div>
label="Email" <div>
{...register("email", { <h2 className="inter-base-semibold text-grey-90 mb-4">Contact</h2>
validate: (value) => !!validateEmail(value), <div className="flex space-x-2">
disabled: customer.has_account, <InputField
})} label="Email"
prefix={ {...register("email", {
customer.has_account && ( validate: (value) => !!validateEmail(value),
<LockIcon size={16} className="text-grey-50" /> disabled: customer.has_account,
) })}
} prefix={
disabled={customer.has_account} customer.has_account && (
/> <LockIcon size={16} className="text-grey-50" />
<InputField )
label="Phone number" }
{...register("phone")} disabled={customer.has_account}
placeholder="+45 42 42 42 42" />
/> <InputField
label="Phone number"
{...register("phone")}
placeholder="+45 42 42 42 42"
/>
</div>
</div>
<div>
<h2 className="inter-base-semibold mb-base">Metadata</h2>
<MetadataForm form={nestedForm(form, "metadata")} />
</div>
</div> </div>
</Modal.Content> </Modal.Content>
<Modal.Footer> <Modal.Footer>
<div className="flex w-full justify-end"> <div className="flex w-full justify-end">
<Button <Button
variant="ghost" variant="secondary"
size="small" size="small"
onClick={handleClose} onClick={handleClose}
className="mr-2" className="mr-2"
type="button"
> >
Cancel Cancel
</Button> </Button>
@@ -121,11 +142,10 @@ const EditCustomerModal = ({
loading={updateCustomer.isLoading} loading={updateCustomer.isLoading}
disabled={!isDirty || updateCustomer.isLoading} disabled={!isDirty || updateCustomer.isLoading}
variant="primary" variant="primary"
className="min-w-[100px]"
size="small" size="small"
onClick={onSubmit} onClick={onSubmit}
> >
Save Save and close
</Button> </Button>
</div> </div>
</Modal.Footer> </Modal.Footer>
@@ -140,6 +160,7 @@ const getDefaultValues = (customer: Customer): EditCustomerFormType => {
email: customer.email, email: customer.email,
last_name: customer.last_name, last_name: customer.last_name,
phone: customer.phone, phone: customer.phone,
metadata: getMetadataFormValues(customer.metadata),
} }
} }
@@ -3,16 +3,16 @@ import moment from "moment"
import { useState } from "react" import { useState } from "react"
import { useParams } from "react-router-dom" import { useParams } from "react-router-dom"
import Avatar from "../../../components/atoms/avatar" import Avatar from "../../../components/atoms/avatar"
import BackButton from "../../../components/atoms/back-button"
import Spinner from "../../../components/atoms/spinner" import Spinner from "../../../components/atoms/spinner"
import EditIcon from "../../../components/fundamentals/icons/edit-icon" import EditIcon from "../../../components/fundamentals/icons/edit-icon"
import TrashIcon from "../../../components/fundamentals/icons/trash-icon"
import StatusDot from "../../../components/fundamentals/status-indicator" import StatusDot from "../../../components/fundamentals/status-indicator"
import Actionables, { import Actionables, {
ActionType, ActionType,
} from "../../../components/molecules/actionables" } from "../../../components/molecules/actionables"
import Breadcrumb from "../../../components/molecules/breadcrumb"
import BodyCard from "../../../components/organisms/body-card" import BodyCard from "../../../components/organisms/body-card"
import RawJSON from "../../../components/organisms/raw-json" import RawJSON from "../../../components/organisms/raw-json"
import Section from "../../../components/organisms/section"
import CustomerOrdersTable from "../../../components/templates/customer-orders-table" import CustomerOrdersTable from "../../../components/templates/customer-orders-table"
import EditCustomerModal from "./edit" import EditCustomerModal from "./edit"
@@ -36,87 +36,87 @@ const CustomerDetail = () => {
onClick: () => setShowEdit(true), onClick: () => setShowEdit(true),
icon: <EditIcon size={20} />, icon: <EditIcon size={20} />,
}, },
{
label: "Delete (not implemented yet)",
onClick: () => console.log("TODO: delete customer"),
variant: "danger",
icon: <TrashIcon size={20} />,
},
] ]
return ( return (
<div> <div>
<Breadcrumb <BackButton
currentPage={"Customer Details"} label="Back to Customers"
previousBreadcrumb={"Customers"} path="/a/customers"
previousRoute="/a/customers" className="mb-xsmall"
/> />
<BodyCard className={"relative mb-4 h-auto w-full pt-[100px]"}> <div className="gap-y-small flex flex-col">
<div className="from-fuschia-20 absolute inset-x-0 top-0 z-0 h-[120px] w-full bg-gradient-to-b" /> <Section>
<div className="flex grow flex-col overflow-y-auto"> <div className="flex w-full items-start justify-between">
<div className="mb-4 h-[64px] w-[64px]"> <div className="gap-x-base flex w-full items-center">
<Avatar <div className="h-[64px] w-[64px]">
user={customer} <Avatar
font="inter-2xlarge-semibold" user={customer}
color="bg-fuschia-40" font="inter-2xlarge-semibold w-full h-full"
/> color="bg-fuschia-40"
</div> />
<div className="flex items-center justify-between"> </div>
<h1 className="inter-xlarge-semibold text-grey-90 max-w-[50%] truncate"> <div className="flex grow flex-col">
{customerName()} <h1 className="inter-xlarge-semibold text-grey-90 max-w-[50%] truncate">
</h1> {customerName()}
<Actionables actions={actions} /> </h1>
</div> <h3 className="inter-small-regular text-grey-50">
<h3 className="inter-small-regular text-grey-50 pt-1.5"> {customer?.email}
{customer?.email} </h3>
</h3> </div>
</div>
<div className="mt-6 flex space-x-6 divide-x">
<div className="flex flex-col">
<div className="inter-smaller-regular text-grey-50 mb-1">
First seen
</div> </div>
<div>{moment(customer?.created_at).format("DD MMM YYYY")}</div> <Actionables actions={actions} forceDropdown />
</div> </div>
<div className="flex flex-col pl-6"> <div className="mt-6 flex space-x-6 divide-x">
<div className="inter-smaller-regular text-grey-50 mb-1">Phone</div> <div className="flex flex-col">
<div className="max-w-[200px] truncate"> <div className="inter-smaller-regular text-grey-50 mb-1">
{customer?.phone || "N/A"} First seen
</div>
<div>{moment(customer?.created_at).format("DD MMM YYYY")}</div>
</div>
<div className="flex flex-col pl-6">
<div className="inter-smaller-regular text-grey-50 mb-1">
Phone
</div>
<div className="max-w-[200px] truncate">
{customer?.phone || "N/A"}
</div>
</div>
<div className="flex flex-col pl-6">
<div className="inter-smaller-regular text-grey-50 mb-1">
Orders
</div>
<div>{customer?.orders.length}</div>
</div>
<div className="h-100 flex flex-col pl-6">
<div className="inter-smaller-regular text-grey-50 mb-1">
User
</div>
<div className="h-50 flex items-center justify-center">
<StatusDot
variant={customer?.has_account ? "success" : "danger"}
title={customer?.has_account ? "Registered" : "Guest"}
/>
</div>
</div> </div>
</div> </div>
<div className="flex flex-col pl-6"> </Section>
<div className="inter-smaller-regular text-grey-50 mb-1"> <BodyCard
Orders title={`Orders (${customer?.orders.length})`}
subtitle="An overview of Customer Orders"
>
{isLoading || !customer ? (
<div className="pt-2xlarge flex w-full items-center justify-center">
<Spinner size={"large"} variant={"secondary"} />
</div> </div>
<div>{customer?.orders.length}</div> ) : (
</div> <div className="flex grow flex-col">
<div className="h-100 flex flex-col pl-6"> <CustomerOrdersTable id={customer.id} />
<div className="inter-smaller-regular text-grey-50 mb-1">User</div>
<div className="h-50 flex items-center justify-center">
<StatusDot
variant={customer?.has_account ? "success" : "danger"}
title={customer?.has_account ? "True" : "False"}
/>
</div> </div>
</div> )}
</div> </BodyCard>
</BodyCard>
<BodyCard <RawJSON data={customer} title="Raw customer" />
title={`Orders (${customer?.orders.length})`}
subtitle="An overview of Customer Orders"
>
{isLoading || !customer ? (
<div className="pt-2xlarge flex w-full items-center justify-center">
<Spinner size={"large"} variant={"secondary"} />
</div>
) : (
<div className="mt-large flex grow flex-col pt-2">
<CustomerOrdersTable id={customer.id} />
</div>
)}
</BodyCard>
<div className="mt-large">
<RawJSON data={customer} title="Raw customer" rootName="customer" />
</div> </div>
{showEdit && customer && ( {showEdit && customer && (
@@ -1,81 +0,0 @@
import { createContext, PropsWithChildren, useState } from "react"
import { CustomerGroup } from "@medusajs/medusa"
import {
useAdminCreateCustomerGroup,
useAdminUpdateCustomerGroup,
} from "medusa-react"
import CustomerGroupModal from "../customer-group-modal"
import { getErrorMessage } from "../../../../utils/error-messages"
import useNotification from "../../../../hooks/use-notification"
type CustomerGroupContextT = {
group?: CustomerGroup
showModal: () => void
hideModal: () => void
}
const CustomerGroupContext = createContext<CustomerGroupContextT>()
type CustomerGroupContextContainerT = PropsWithChildren<{
group?: CustomerGroup
}>
/*
* A context provider which sets a display mode for `CustomerGroupModal` (create/edit)
* and provide form data inside the context.
*/
export function CustomerGroupContextContainer(
props: CustomerGroupContextContainerT
) {
const notification = useNotification()
const { mutate: createGroup } = useAdminCreateCustomerGroup()
const { mutate: updateGroup } = useAdminUpdateCustomerGroup(props.group?.id)
const [isModalVisible, setIsModalVisible] = useState(false)
const showModal = () => setIsModalVisible(true)
const hideModal = () => setIsModalVisible(false)
const handleSubmit = (data) => {
const isEdit = !!props.group
const method = isEdit ? updateGroup : createGroup
const message = `Successfully ${
isEdit ? "edited" : "created"
} the customer group`
method(data, {
onSuccess: () => {
notification("Success", message, "success")
hideModal()
},
onError: (err) => notification("Error", getErrorMessage(err), "error"),
})
}
const context = {
group: props.group,
isModalVisible,
showModal,
hideModal,
}
return (
<CustomerGroupContext.Provider value={context}>
{props.children}
{isModalVisible && (
<CustomerGroupModal
handleClose={hideModal}
handleSubmit={handleSubmit}
initialData={props.group}
/>
)}
</CustomerGroupContext.Provider>
)
}
export default CustomerGroupContext
@@ -1,114 +1,168 @@
import { CustomerGroup } from "@medusajs/medusa"
import { import {
AdminPostCustomerGroupsGroupReq, useAdminCreateCustomerGroup,
AdminPostCustomerGroupsReq, useAdminUpdateCustomerGroup,
CustomerGroup, } from "medusa-react"
} from "@medusajs/medusa" import { useEffect } from "react"
import { useState } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import {
CustomerGroupGeneralForm,
CustomerGroupGeneralFormType,
} from "../../../components/forms/customer-group/customer-group-general-form"
import MetadataForm, {
getMetadataFormValues,
getSubmittableMetadata,
MetadataFormType,
} from "../../../components/forms/general/metadata-form"
import Button from "../../../components/fundamentals/button" import Button from "../../../components/fundamentals/button"
import Input from "../../../components/molecules/input"
import Modal from "../../../components/molecules/modal" import Modal from "../../../components/molecules/modal"
import Metadata, { MetadataField } from "../../../components/organisms/metadata" import useNotification from "../../../hooks/use-notification"
import { getErrorMessage } from "../../../utils/error-messages"
import { nestedForm } from "../../../utils/nested-form"
type CustomerGroupModalProps = { type CustomerGroupModalProps = {
handleClose: () => void open: boolean
initialData?: CustomerGroup customerGroup?: CustomerGroup
handleSubmit: ( onClose: () => void
data: AdminPostCustomerGroupsReq | AdminPostCustomerGroupsGroupReq }
) => void
type CustomerGroupModalFormType = {
general: CustomerGroupGeneralFormType
metadata: MetadataFormType
} }
/* /*
* A modal for crating/editing customer groups. * A modal for crating/editing customer groups.
*/ */
function CustomerGroupModal(props: CustomerGroupModalProps) { function CustomerGroupModal({
const { initialData, handleSubmit, handleClose } = props customerGroup,
onClose,
const isEdit = !!initialData open,
}: CustomerGroupModalProps) {
const [metadata, setMetadata] = useState<MetadataField[]>( const form = useForm<CustomerGroupModalFormType>({
isEdit defaultValues: getDefaultValues(customerGroup),
? Object.keys(initialData.metadata || {}).map((k) => ({
key: k,
value: initialData.metadata[k],
}))
: []
)
const { register, handleSubmit: handleFromSubmit } = useForm({
defaultValues: initialData,
}) })
const onSubmit = (data) => { const { mutate: update, isLoading: isUpdating } = useAdminUpdateCustomerGroup(
const meta = {} customerGroup?.id!
const initial = props.initialData?.metadata || {} )
const { mutate: create, isLoading: isCreating } =
useAdminCreateCustomerGroup()
metadata.forEach((m) => (meta[m.key] = m.value)) const notification = useNotification()
for (const m in initial) { const { reset, handleSubmit: handleFormSubmit } = form
if (!(m in meta)) {
meta[m] = null useEffect(() => {
} if (open) {
reset(getDefaultValues(customerGroup))
}
}, [customerGroup, open, reset])
const onSubmit = handleFormSubmit((data) => {
const { general, metadata } = data
const onSuccess = () => {
const title = customerGroup ? "Group Updated" : "Group Created"
const msg = customerGroup
? "The customer group has been updated"
: "The customer group has been created"
notification(title, msg, "success")
onClose()
} }
const toSubmit = { const onError = (err: Error) => {
name: data.name, notification("Error", getErrorMessage(err), "error")
metadata: meta,
} }
handleSubmit(toSubmit)
} if (customerGroup) {
update(
{
name: general.name,
metadata: getSubmittableMetadata(metadata),
},
{
onSuccess,
onError,
}
)
} else {
create(
{
name: general.name,
metadata: getSubmittableMetadata(metadata),
},
{
onSuccess,
onError,
}
)
}
onClose()
})
return ( return (
<Modal handleClose={handleClose}> <Modal open={open} handleClose={onClose}>
<Modal.Body> <Modal.Body>
<Modal.Header handleClose={handleClose}> <Modal.Header handleClose={onClose}>
<span className="inter-xlarge-semibold"> <span className="inter-xlarge-semibold">
{props.initialData ? "Edit" : "Create a New"} Customer Group {customerGroup ? "Edit" : "Create a New"} Customer Group
</span> </span>
</Modal.Header> </Modal.Header>
<Modal.Content> <form onSubmit={onSubmit}>
<div className="space-y-4"> <Modal.Content>
<span className="inter-base-semibold">Details</span> <div className="gap-y-xlarge flex flex-col">
<div className="flex space-x-4"> <div>
<Input <h2 className="inter-base-semibold mb-base">Details</h2>
label="Title" <CustomerGroupGeneralForm form={nestedForm(form, "general")} />
{...register("name")} </div>
placeholder="Customer group name" <div>
required <h2 className="inter-base-semibold mb-base">Metadata</h2>
/> <MetadataForm form={nestedForm(form, "metadata")} />
</div>
</div> </div>
</div> </Modal.Content>
<div className="mt-8"> <Modal.Footer>
<Metadata metadata={metadata} setMetadata={setMetadata} /> <div className="gap-x-xsmall flex w-full justify-end">
</div> <Button
</Modal.Content> variant="secondary"
className="text-small mr-2 w-32 justify-center"
<Modal.Footer> size="small"
<div className="flex h-8 w-full justify-end"> type="button"
<Button onClick={onClose}
variant="ghost" >
className="text-small mr-2 w-32 justify-center" Cancel
size="large" </Button>
onClick={handleClose} <Button size="small" variant="primary" type="submit">
> <span>{customerGroup ? "Edit" : "Publish"} Group</span>
Cancel </Button>
</Button> </div>
<Button </Modal.Footer>
size="medium" </form>
className="text-small w-32 justify-center"
variant="primary"
onClick={handleFromSubmit(onSubmit)}
>
<span>{props.initialData ? "Edit" : "Publish"} Group</span>
</Button>
</div>
</Modal.Footer>
</Modal.Body> </Modal.Body>
</Modal> </Modal>
) )
} }
const getDefaultValues = (
initialData?: CustomerGroup
): CustomerGroupModalFormType | undefined => {
if (!initialData) {
return undefined
}
return {
general: {
name: initialData.name,
},
metadata: getMetadataFormValues(initialData.metadata),
}
}
export default CustomerGroupModal export default CustomerGroupModal
@@ -1,6 +1,5 @@
import { useContext, useEffect, useState } from "react"
import { difference } from "lodash"
import { CustomerGroup } from "@medusajs/medusa" import { CustomerGroup } from "@medusajs/medusa"
import { difference } from "lodash"
import { import {
useAdminAddCustomersToCustomerGroup, useAdminAddCustomersToCustomerGroup,
useAdminCustomerGroup, useAdminCustomerGroup,
@@ -8,20 +7,21 @@ import {
useAdminDeleteCustomerGroup, useAdminDeleteCustomerGroup,
useAdminRemoveCustomersFromCustomerGroup, useAdminRemoveCustomersFromCustomerGroup,
} from "medusa-react" } from "medusa-react"
import { useEffect, useState } from "react"
import BodyCard from "../../../components/organisms/body-card"
import EditIcon from "../../../components/fundamentals/icons/edit-icon"
import TrashIcon from "../../../components/fundamentals/icons/trash-icon"
import PlusIcon from "../../../components/fundamentals/icons/plus-icon"
import EditCustomersTable from "../../../components/templates/customer-group-table/edit-customers-table"
import CustomersListTable from "../../../components/templates/customer-group-table/customers-list-table"
import CustomerGroupContext, {
CustomerGroupContextContainer,
} from "./context/customer-group-context"
import useQueryFilters from "../../../hooks/use-query-filters"
import DeletePrompt from "../../../components/organisms/delete-prompt"
import { useNavigate, useParams } from "react-router-dom" import { useNavigate, useParams } from "react-router-dom"
import BackButton from "../../../components/atoms/back-button" import BackButton from "../../../components/atoms/back-button"
import EditIcon from "../../../components/fundamentals/icons/edit-icon"
import PlusIcon from "../../../components/fundamentals/icons/plus-icon"
import TrashIcon from "../../../components/fundamentals/icons/trash-icon"
import { ActionType } from "../../../components/molecules/actionables"
import BodyCard from "../../../components/organisms/body-card"
import DeletePrompt from "../../../components/organisms/delete-prompt"
import CustomersListTable from "../../../components/templates/customer-group-table/customers-list-table"
import EditCustomersTable from "../../../components/templates/customer-group-table/edit-customers-table"
import useQueryFilters from "../../../hooks/use-query-filters"
import useToggleState from "../../../hooks/use-toggle-state"
import CustomerGroupModal from "./customer-group-modal"
/** /**
* Default filtering config for querying customer group customers list endpoint. * Default filtering config for querying customer group customers list endpoint.
@@ -164,7 +164,6 @@ type CustomerGroupDetailsHeaderProps = {
* Customers groups details page header. * Customers groups details page header.
*/ */
function CustomerGroupDetailsHeader(props: CustomerGroupDetailsHeaderProps) { function CustomerGroupDetailsHeader(props: CustomerGroupDetailsHeaderProps) {
const { showModal } = useContext(CustomerGroupContext)
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false) const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
@@ -173,10 +172,12 @@ function CustomerGroupDetailsHeader(props: CustomerGroupDetailsHeaderProps) {
props.customerGroup.id props.customerGroup.id
) )
const actions = [ const { state, close, open } = useToggleState()
const actions: ActionType[] = [
{ {
label: "Edit", label: "Edit",
onClick: showModal, onClick: open,
icon: <EditIcon size={20} />, icon: <EditIcon size={20} />,
}, },
{ {
@@ -214,6 +215,11 @@ function CustomerGroupDetailsHeader(props: CustomerGroupDetailsHeaderProps) {
text="Are you sure you want to delete this customer group?" text="Are you sure you want to delete this customer group?"
/> />
)} )}
<CustomerGroupModal
open={state}
onClose={close}
customerGroup={props.customerGroup}
/>
</> </>
) )
} }
@@ -231,17 +237,15 @@ function CustomerGroupDetails() {
} }
return ( return (
<CustomerGroupContextContainer group={customer_group}> <div className="-mt-4 pb-4">
<div className="-mt-4 pb-4"> <BackButton
<BackButton path="/a/customers/groups"
path="/a/customers/groups" label="Back to customer groups"
label="Back to customer groups" className="mb-4"
className="mb-4" />
/> <CustomerGroupDetailsHeader customerGroup={customer_group} />
<CustomerGroupDetailsHeader customerGroup={customer_group} /> <CustomerGroupCustomersList group={customer_group} />
<CustomerGroupCustomersList group={customer_group} /> </div>
</div>
</CustomerGroupContextContainer>
) )
} }
@@ -1,24 +1,22 @@
import { useContext } from "react"
import BodyCard from "../../../components/organisms/body-card"
import PlusIcon from "../../../components/fundamentals/icons/plus-icon"
import CustomersPageTableHeader from "../header"
import Details from "./details"
import CustomerGroupContext, {
CustomerGroupContextContainer,
} from "./context/customer-group-context"
import CustomerGroupsTable from "../../../components/templates/customer-group-table/customer-groups-table"
import { Route, Routes } from "react-router-dom" import { Route, Routes } from "react-router-dom"
import PlusIcon from "../../../components/fundamentals/icons/plus-icon"
import BodyCard from "../../../components/organisms/body-card"
import CustomerGroupsTable from "../../../components/templates/customer-group-table/customer-groups-table"
import useToggleState from "../../../hooks/use-toggle-state"
import CustomersPageTableHeader from "../header"
import CustomerGroupModal from "./customer-group-modal"
import Details from "./details"
/* /*
* Customer groups index page * Customer groups index page
*/ */
function Index() { function Index() {
const { showModal } = useContext(CustomerGroupContext) const { state, open, close } = useToggleState()
const actions = [ const actions = [
{ {
label: "New group", label: "New group",
onClick: showModal, onClick: open,
icon: ( icon: (
<span className="text-grey-90"> <span className="text-grey-90">
<PlusIcon size={20} /> <PlusIcon size={20} />
@@ -28,16 +26,18 @@ function Index() {
] ]
return ( return (
<div className="flex h-full grow flex-col"> <>
<div className="flex w-full grow flex-col"> <div className="flex h-full grow flex-col">
<BodyCard <BodyCard
actionables={actions} actionables={actions}
className="h-auto"
customHeader={<CustomersPageTableHeader activeView="groups" />} customHeader={<CustomersPageTableHeader activeView="groups" />}
> >
<CustomerGroupsTable /> <CustomerGroupsTable />
</BodyCard> </BodyCard>
</div> </div>
</div> <CustomerGroupModal open={state} onClose={close} />
</>
) )
} }
@@ -46,12 +46,10 @@ function Index() {
*/ */
function CustomerGroups() { function CustomerGroups() {
return ( return (
<CustomerGroupContextContainer> <Routes>
<Routes> <Route index element={<Index />} />
<Route index element={<Index />} /> <Route path="/:id" element={<Details />} />
<Route path="/:id" element={<Details />} /> </Routes>
</Routes>
</CustomerGroupContextContainer>
) )
} }
@@ -1,53 +1,55 @@
import { Discount } from "@medusajs/medusa" import { Discount } from "@medusajs/medusa"
import { useAdminUpdateDiscount } from "medusa-react" import { useAdminUpdateDiscount } from "medusa-react"
import React, { useEffect } from "react" import React, { useEffect } from "react"
import { Controller, useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import DatePicker from "../../../../components/atoms/date-picker/date-picker" import DiscountConfigurationForm, {
import TimePicker from "../../../../components/atoms/date-picker/time-picker" DiscountConfigurationFormType,
} from "../../../../components/forms/discount/discount-configuration-form"
import Button from "../../../../components/fundamentals/button" import Button from "../../../../components/fundamentals/button"
import AvailabilityDuration from "../../../../components/molecules/availability-duration"
import InputField from "../../../../components/molecules/input"
import Modal from "../../../../components/molecules/modal" import Modal from "../../../../components/molecules/modal"
import SwitchableItem from "../../../../components/molecules/switchable-item"
import useNotification from "../../../../hooks/use-notification" import useNotification from "../../../../hooks/use-notification"
import { getErrorMessage } from "../../../../utils/error-messages" import { getErrorMessage } from "../../../../utils/error-messages"
import { nestedForm } from "../../../../utils/nested-form"
type EditConfigurationsProps = { type EditConfigurationsProps = {
discount: Discount discount: Discount
onClose: () => void onClose: () => void
open: boolean
} }
type ConfigurationsForm = { type ConfigurationsForm = {
starts_at: Date | null config: DiscountConfigurationFormType
ends_at: Date | null
usage_limit: number | null
valid_duration: string | null
} }
const EditConfigurations: React.FC<EditConfigurationsProps> = ({ const EditConfigurations: React.FC<EditConfigurationsProps> = ({
discount, discount,
onClose, onClose,
open,
}) => { }) => {
const { mutate, isLoading } = useAdminUpdateDiscount(discount.id) const { mutate, isLoading } = useAdminUpdateDiscount(discount.id)
const notification = useNotification() const notification = useNotification()
const { control, handleSubmit, reset } = useForm<ConfigurationsForm>({ const form = useForm<ConfigurationsForm>({
defaultValues: mapConfigurations(discount), defaultValues: getDefaultValues(discount),
}) })
const onSubmit = (data: ConfigurationsForm) => { const { handleSubmit, reset } = form
const onSubmit = handleSubmit((data) => {
mutate( mutate(
{ {
starts_at: data.starts_at ?? new Date(), starts_at: data.config.starts_at ?? new Date(),
ends_at: data.ends_at, ends_at: data.config.ends_at,
usage_limit: usage_limit:
data.usage_limit && data.usage_limit > 0 ? data.usage_limit : null, data.config.usage_limit && data.config.usage_limit > 0
valid_duration: data.valid_duration, ? data.config.usage_limit
: null,
valid_duration: data.config.valid_duration,
}, },
{ {
onSuccess: ({ discount }) => { onSuccess: ({ discount }) => {
notification("Success", "Discount updated successfully", "success") notification("Success", "Discount updated successfully", "success")
reset(mapConfigurations(discount)) reset(getDefaultValues(discount))
onClose() onClose()
}, },
onError: (error) => { onError: (error) => {
@@ -55,151 +57,23 @@ const EditConfigurations: React.FC<EditConfigurationsProps> = ({
}, },
} }
) )
} })
useEffect(() => { useEffect(() => {
reset(mapConfigurations(discount)) if (open) {
}, [discount]) reset(getDefaultValues(discount))
}
}, [discount, reset, open])
return ( return (
<Modal handleClose={onClose} isLargeModal> <Modal open={open} handleClose={onClose} isLargeModal>
<Modal.Body> <Modal.Body>
<Modal.Header handleClose={onClose}> <Modal.Header handleClose={onClose}>
<h1 className="inter-xlarge-semibold">Edit configurations</h1> <h1 className="inter-xlarge-semibold">Edit configurations</h1>
</Modal.Header> </Modal.Header>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={onSubmit}>
<Modal.Content> <Modal.Content>
<div className="gap-y-xlarge flex flex-col"> <DiscountConfigurationForm form={nestedForm(form, "config")} />
<Controller
name="starts_at"
defaultValue={discount.starts_at}
control={control}
render={({ field: { onChange, value } }) => {
return (
<SwitchableItem
open={!!value}
onSwitch={() => {
if (value) {
onChange(null)
} else {
onChange(new Date(discount.starts_at))
}
}}
title="Discount has a start date?"
description="Schedule the discount to activate in the future."
>
<div className="gap-x-xsmall flex items-center">
<DatePicker
date={value!}
label="Start date"
onSubmitDate={onChange}
/>
<TimePicker
label="Start time"
date={value!}
onSubmitDate={onChange}
/>
</div>
</SwitchableItem>
)
}}
/>
<Controller
name="ends_at"
control={control}
render={({ field: { value, onChange } }) => {
return (
<SwitchableItem
open={!!value}
onSwitch={() => {
if (value) {
onChange(null)
} else {
onChange(
new Date(
new Date().getTime() + 7 * 24 * 60 * 60 * 1000
)
)
}
}}
title="Discount has an expiry date?"
description="Schedule the discount to deactivate in the future."
>
<div className="gap-x-xsmall flex items-center">
<DatePicker
date={value!}
label="Expiry date"
onSubmitDate={onChange}
/>
<TimePicker
label="Expiry time"
date={value!}
onSubmitDate={onChange}
/>
</div>
</SwitchableItem>
)
}}
/>
<Controller
name="usage_limit"
control={control}
render={({ field: { value, onChange } }) => {
return (
<SwitchableItem
open={!!value}
onSwitch={() => {
if (value) {
onChange(null)
} else {
onChange(10)
}
}}
title="Limit the number of redemtions?"
description="Limit applies across all customers, not per customer."
>
<InputField
label="Number of redemptions"
type="number"
placeholder="5"
min={1}
defaultValue={value ?? undefined}
onChange={(value) =>
onChange(value.target.valueAsNumber)
}
/>
</SwitchableItem>
)
}}
/>
{discount.is_dynamic && (
<Controller
name="valid_duration"
control={control}
render={({ field: { onChange, value } }) => {
return (
<SwitchableItem
open={!!value}
onSwitch={() => {
if (value) {
onChange(null)
} else {
onChange("P0Y0M0DT00H00M")
}
}}
title="Availability duration?"
description="Set the duration of the discount."
>
<AvailabilityDuration
value={value ?? undefined}
onChange={onChange}
/>
</SwitchableItem>
)
}}
/>
)}
</div>
</Modal.Content> </Modal.Content>
<Modal.Footer> <Modal.Footer>
<div className="gap-x-xsmall flex w-full items-center justify-end"> <div className="gap-x-xsmall flex w-full items-center justify-end">
@@ -230,12 +104,14 @@ const EditConfigurations: React.FC<EditConfigurationsProps> = ({
) )
} }
const mapConfigurations = (discount: Discount): ConfigurationsForm => { const getDefaultValues = (discount: Discount): ConfigurationsForm => {
return { return {
starts_at: new Date(discount.starts_at), config: {
ends_at: discount.ends_at ? new Date(discount.ends_at) : null, starts_at: new Date(discount.starts_at),
usage_limit: discount.usage_limit, ends_at: discount.ends_at ? new Date(discount.ends_at) : null,
valid_duration: discount.valid_duration, usage_limit: discount.usage_limit,
valid_duration: discount.valid_duration,
},
} }
} }
@@ -1,8 +1,9 @@
import { Discount } from "@medusajs/medusa" import { Discount } from "@medusajs/medusa"
import React, { useState } from "react" import React from "react"
import EditIcon from "../../../../components/fundamentals/icons/edit-icon" import EditIcon from "../../../../components/fundamentals/icons/edit-icon"
import NumberedItem from "../../../../components/molecules/numbered-item" import NumberedItem from "../../../../components/molecules/numbered-item"
import BodyCard from "../../../../components/organisms/body-card" import BodyCard from "../../../../components/organisms/body-card"
import useToggleState from "../../../../hooks/use-toggle-state"
import EditConfigurations from "./edit-configurations" import EditConfigurations from "./edit-configurations"
import useDiscountConfigurations from "./use-discount-configurations" import useDiscountConfigurations from "./use-discount-configurations"
@@ -12,7 +13,7 @@ type ConfigurationsProps = {
const Configurations: React.FC<ConfigurationsProps> = ({ discount }) => { const Configurations: React.FC<ConfigurationsProps> = ({ discount }) => {
const configurations = useDiscountConfigurations(discount) const configurations = useDiscountConfigurations(discount)
const [showModal, setShowModal] = useState(false) const { state, open, close } = useToggleState()
return ( return (
<> <>
@@ -22,7 +23,7 @@ const Configurations: React.FC<ConfigurationsProps> = ({ discount }) => {
actionables={[ actionables={[
{ {
label: "Edit configurations", label: "Edit configurations",
onClick: () => setShowModal(true), onClick: open,
icon: <EditIcon size={20} />, icon: <EditIcon size={20} />,
}, },
]} ]}
@@ -47,12 +48,8 @@ const Configurations: React.FC<ConfigurationsProps> = ({ discount }) => {
))} ))}
</div> </div>
</BodyCard> </BodyCard>
{showModal && (
<EditConfigurations <EditConfigurations discount={discount} onClose={close} open={state} />
discount={discount}
onClose={() => setShowModal(false)}
/>
)}
</> </>
) )
} }
@@ -1,53 +1,62 @@
import { Discount } from "@medusajs/medusa" import { Discount } from "@medusajs/medusa"
import { useAdminRegions, useAdminUpdateDiscount } from "medusa-react" import { useAdminUpdateDiscount } from "medusa-react"
import React, { useEffect, useMemo } from "react" import React, { useEffect } from "react"
import { Controller, useForm, useWatch } from "react-hook-form" import { useForm } from "react-hook-form"
import DiscountGeneralForm, {
DiscountGeneralFormType,
} from "../../../../components/forms/discount/discount-general-form"
import MetadataForm, {
getMetadataFormValues,
getSubmittableMetadata,
MetadataFormType,
} from "../../../../components/forms/general/metadata-form"
import Button from "../../../../components/fundamentals/button" import Button from "../../../../components/fundamentals/button"
import InputField from "../../../../components/molecules/input"
import Modal from "../../../../components/molecules/modal" import Modal from "../../../../components/molecules/modal"
import { NextSelect } from "../../../../components/molecules/select/next-select"
import TextArea from "../../../../components/molecules/textarea"
import CurrencyInput from "../../../../components/organisms/currency-input"
import useNotification from "../../../../hooks/use-notification" import useNotification from "../../../../hooks/use-notification"
import { Option } from "../../../../types/shared"
import { getErrorMessage } from "../../../../utils/error-messages" import { getErrorMessage } from "../../../../utils/error-messages"
import { nestedForm } from "../../../../utils/nested-form"
type EditGeneralProps = { type EditGeneralProps = {
discount: Discount discount: Discount
open: boolean
onClose: () => void onClose: () => void
} }
type GeneralForm = { type GeneralForm = {
regions: Option[] general: DiscountGeneralFormType
code: string metadata: MetadataFormType
description: string
value: number
} }
const EditGeneral: React.FC<EditGeneralProps> = ({ discount, onClose }) => { const EditGeneral: React.FC<EditGeneralProps> = ({
discount,
open,
onClose,
}) => {
const { mutate, isLoading } = useAdminUpdateDiscount(discount.id) const { mutate, isLoading } = useAdminUpdateDiscount(discount.id)
const notification = useNotification() const notification = useNotification()
const { control, handleSubmit, reset, register } = useForm<GeneralForm>({ const form = useForm<GeneralForm>({
defaultValues: mapGeneral(discount), defaultValues: getDefaultValues(discount),
}) })
const onSubmit = (data: GeneralForm) => { const { handleSubmit, reset } = form
const onSubmit = handleSubmit((data) => {
mutate( mutate(
{ {
regions: data.regions.map((r) => r.value), regions: data.general.region_ids.map((r) => r.value),
code: data.code, code: data.general.code,
rule: { rule: {
id: discount.rule.id, id: discount.rule.id,
description: data.description, description: data.general.description,
value: data.value, value: data.general.value,
allocation: discount.rule.allocation, allocation: discount.rule.allocation,
}, },
metadata: getSubmittableMetadata(data.metadata),
}, },
{ {
onSuccess: ({ discount }) => { onSuccess: () => {
notification("Success", "Discount updated successfully", "success") notification("Success", "Discount updated successfully", "success")
reset(mapGeneral(discount))
onClose() onClose()
}, },
onError: (error) => { onError: (error) => {
@@ -55,152 +64,42 @@ const EditGeneral: React.FC<EditGeneralProps> = ({ discount, onClose }) => {
}, },
} }
) )
}
useEffect(() => {
reset(mapGeneral(discount))
}, [discount])
const type = discount.rule.type
const { regions } = useAdminRegions()
const regionOptions = useMemo(() => {
return regions
? regions.map((r) => ({
label: r.name,
value: r.id,
}))
: []
}, [regions])
const selectedRegions = useWatch({
control,
name: "regions",
}) })
const fixedCurrency = useMemo(() => { useEffect(() => {
if (type === "fixed" && selectedRegions?.length) { if (open) {
return regions?.find((r) => r.id === selectedRegions[0].value) reset(getDefaultValues(discount))
?.currency_code
} }
}, [selectedRegions, type, regions]) }, [discount, reset, open])
return ( return (
<Modal handleClose={onClose}> <Modal open={open} handleClose={onClose}>
<form onSubmit={handleSubmit(onSubmit)}> <Modal.Body>
<Modal.Body> <Modal.Header handleClose={onClose}>
<Modal.Header handleClose={onClose}> <h1 className="inter-xlarge-semibold">Edit general information</h1>
<h1 className="inter-xlarge-semibold">Edit general information</h1> </Modal.Header>
</Modal.Header> <form onSubmit={onSubmit}>
<Modal.Content> <Modal.Content>
<Controller <div className="gap-y-xlarge flex flex-col">
name="regions" <div>
control={control} <h2 className="inter-base-semibold mb-base">Details</h2>
rules={{ <DiscountGeneralForm
required: "Atleast one region is required", form={nestedForm(form, "general")}
validate: (value) => type={discount.rule.type}
Array.isArray(value) ? value.length > 0 : !!value, isEdit
}} />
render={({ field: { value, onChange } }) => { </div>
return ( <div>
<NextSelect <h2 className="inter-base-semibold mb-base">Metadata</h2>
value={value} <MetadataForm form={nestedForm(form, "metadata")} />
onChange={(value) => { </div>
onChange(type === "fixed" ? [value] : value)
}}
label="Choose valid regions"
isMulti={type !== "fixed"}
selectAll={type !== "fixed"}
isSearchable
required
options={regionOptions}
/>
)
}}
/>
<div className="gap-x-base gap-y-base my-base flex">
<InputField
label="Code"
className="flex-1"
placeholder="SUMMERSALE10"
required
{...register("code", { required: "Code is required" })}
/>
{type !== "free_shipping" && (
<>
{type === "fixed" ? (
<div className="flex-1">
<CurrencyInput.Root
size="small"
currentCurrency={fixedCurrency ?? "USD"}
readOnly
hideCurrency
>
<Controller
name="value"
control={control}
rules={{
required: "Amount is required",
min: 1,
}}
render={({ field: { value, onChange } }) => {
return (
<CurrencyInput.Amount
label={"Amount"}
required
amount={value}
onChange={onChange}
/>
)
}}
/>
</CurrencyInput.Root>
</div>
) : (
<div className="flex-1">
<InputField
label="Percentage"
min={0}
required
type="number"
placeholder="10"
prefix={"%"}
{...register("value", {
required: "Percentage is required",
valueAsNumber: true,
})}
/>
</div>
)}
</>
)}
</div> </div>
<div className="text-grey-50 inter-small-regular mb-6 flex flex-col">
<span>
The code your customers will enter during checkout. This will
appear on your customer’s invoice.
</span>
<span>Uppercase letters and numbers only.</span>
</div>
<TextArea
label="Description"
required
placeholder="Summer Sale 2022"
rows={1}
{...register("description", {
required: "Description is required",
})}
/>
</Modal.Content> </Modal.Content>
<Modal.Footer> <Modal.Footer>
<div className="gap-x-xsmall flex w-full items-center justify-end"> <div className="gap-x-xsmall flex w-full items-center justify-end">
<Button <Button
variant="ghost" variant="secondary"
size="small" size="small"
className="min-w-[128px]"
type="button" type="button"
onClick={onClose} onClick={onClose}
> >
@@ -209,27 +108,33 @@ const EditGeneral: React.FC<EditGeneralProps> = ({ discount, onClose }) => {
<Button <Button
variant="primary" variant="primary"
size="small" size="small"
className="min-w-[128px]"
type="submit" type="submit"
disabled={isLoading} disabled={isLoading}
loading={isLoading} loading={isLoading}
> >
Save Save and close
</Button> </Button>
</div> </div>
</Modal.Footer> </Modal.Footer>
</Modal.Body> </form>
</form> </Modal.Body>
</Modal> </Modal>
) )
} }
const mapGeneral = (discount: Discount): GeneralForm => { const getDefaultValues = (discount: Discount): GeneralForm | undefined => {
return { return {
regions: discount.regions.map((r) => ({ label: r.name, value: r.id })), general: {
code: discount.code, code: discount.code,
description: discount.rule.description, description: discount.rule.description,
value: discount.rule.value, region_ids: discount.regions.map((r) => ({
label: r.name,
value: r.id,
currency_code: r.currency_code,
})),
value: discount.rule.value,
},
metadata: getMetadataFormValues(discount.metadata),
} }
} }
@@ -1,6 +1,6 @@
import { Discount } from "@medusajs/medusa" import { Discount } from "@medusajs/medusa"
import { useAdminDeleteDiscount, useAdminUpdateDiscount } from "medusa-react" import { useAdminDeleteDiscount, useAdminUpdateDiscount } from "medusa-react"
import React, { useState } from "react" import React from "react"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import Badge from "../../../../components/fundamentals/badge" import Badge from "../../../../components/fundamentals/badge"
import EditIcon from "../../../../components/fundamentals/icons/edit-icon" import EditIcon from "../../../../components/fundamentals/icons/edit-icon"
@@ -10,6 +10,7 @@ import StatusSelector from "../../../../components/molecules/status-selector"
import BodyCard from "../../../../components/organisms/body-card" import BodyCard from "../../../../components/organisms/body-card"
import useImperativeDialog from "../../../../hooks/use-imperative-dialog" import useImperativeDialog from "../../../../hooks/use-imperative-dialog"
import useNotification from "../../../../hooks/use-notification" import useNotification from "../../../../hooks/use-notification"
import useToggleState from "../../../../hooks/use-toggle-state"
import { getErrorMessage } from "../../../../utils/error-messages" import { getErrorMessage } from "../../../../utils/error-messages"
import { formatAmountWithSymbol } from "../../../../utils/prices" import { formatAmountWithSymbol } from "../../../../utils/prices"
import EditGeneral from "./edit-general" import EditGeneral from "./edit-general"
@@ -24,7 +25,6 @@ const General: React.FC<GeneralProps> = ({ discount }) => {
const notification = useNotification() const notification = useNotification()
const updateDiscount = useAdminUpdateDiscount(discount.id) const updateDiscount = useAdminUpdateDiscount(discount.id)
const deletediscount = useAdminDeleteDiscount(discount.id) const deletediscount = useAdminDeleteDiscount(discount.id)
const [showmModal, setShowModal] = useState(false)
const onDelete = async () => { const onDelete = async () => {
const shouldDelete = await dialog({ const shouldDelete = await dialog({
@@ -65,10 +65,12 @@ const General: React.FC<GeneralProps> = ({ discount }) => {
) )
} }
const { state, open, close } = useToggleState()
const actionables: ActionType[] = [ const actionables: ActionType[] = [
{ {
label: "Edit general information", label: "Edit general information",
onClick: () => setShowModal(true), onClick: open,
icon: <EditIcon size={20} />, icon: <EditIcon size={20} />,
}, },
{ {
@@ -132,9 +134,8 @@ const General: React.FC<GeneralProps> = ({ discount }) => {
</div> </div>
</div> </div>
</BodyCard> </BodyCard>
{showmModal && (
<EditGeneral discount={discount} onClose={() => setShowModal(false)} /> <EditGeneral discount={discount} onClose={close} open={state} />
)}
</> </>
) )
} }
@@ -62,7 +62,7 @@ const AddConditionsModal = ({
) : ( ) : (
<div className="flex h-full flex-1 flex-col items-center justify-center"> <div className="flex h-full flex-1 flex-col items-center justify-center">
<span className="inter-base-regular text-grey-40"> <span className="inter-base-regular text-grey-40">
Can't add anymore conditions You cannot add any more conditions
</span> </span>
</div> </div>
)} )}
@@ -1,5 +1,10 @@
import React, { Dispatch, SetStateAction, useEffect, useState } from "react" import React, { Dispatch, SetStateAction, useEffect, useState } from "react"
import { FormProvider, useForm, useFormContext } from "react-hook-form" import {
FormProvider,
useForm,
useFormContext,
UseFormReturn,
} from "react-hook-form"
import { import {
AllocationType, AllocationType,
ConditionMap, ConditionMap,
@@ -176,6 +181,7 @@ export const DiscountFormProvider = ({
updateCondition, updateCondition,
setConditions, setConditions,
handleReset, handleReset,
form: methods,
}} }}
> >
{children} {children}
@@ -196,6 +202,7 @@ const DiscountFormContext = React.createContext<{
updateCondition: (props: UpdateConditionProps) => void updateCondition: (props: UpdateConditionProps) => void
setConditions: Dispatch<SetStateAction<ConditionMap>> setConditions: Dispatch<SetStateAction<ConditionMap>>
handleReset: () => void handleReset: () => void
form: UseFormReturn<DiscountFormValues>
} | null>(null) } | null>(null)
export const useDiscountForm = () => { export const useDiscountForm = () => {
@@ -1,10 +1,9 @@
import { import { AdminPostDiscountsReq, AdminUpsertCondition } from "@medusajs/medusa"
AdminPostDiscountsDiscountReq,
AdminPostDiscountsReq,
AdminUpsertCondition,
Discount,
} from "@medusajs/medusa"
import { FieldValues } from "react-hook-form" import { FieldValues } from "react-hook-form"
import {
getSubmittableMetadata,
MetadataFormType,
} from "../../../../../components/forms/general/metadata-form"
import { Option } from "../../../../../types/shared" import { Option } from "../../../../../types/shared"
import { AllocationType, ConditionMap, DiscountRuleType } from "../../../types" import { AllocationType, ConditionMap, DiscountRuleType } from "../../../types"
@@ -22,6 +21,7 @@ export interface DiscountFormValues extends FieldValues {
is_dynamic: boolean is_dynamic: boolean
valid_duration: string | null valid_duration: string | null
regions?: Option[] regions?: Option[]
metadata: MetadataFormType
} }
export enum DiscountConditionType { export enum DiscountConditionType {
@@ -32,26 +32,6 @@ export enum DiscountConditionType {
CUSTOMER_GROUPS = "customer_groups", CUSTOMER_GROUPS = "customer_groups",
} }
export const discountToFormValuesMapper = (
discount: Discount
): DiscountFormValues => {
return {
code: discount.code,
rule: {
value: discount.rule.value,
description: discount.rule.description,
type: discount.rule.type,
allocation: discount.rule.allocation,
},
starts_at: discount.starts_at && new Date(discount.starts_at),
ends_at: discount.ends_at && new Date(discount.ends_at),
is_dynamic: discount.is_dynamic,
usage_limit: discount.usage_limit,
valid_duration: discount.valid_duration,
regions: discount.regions.map((r) => ({ label: r.name, value: r.id })),
}
}
const mapConditionsToCreate = (map: ConditionMap) => { const mapConditionsToCreate = (map: ConditionMap) => {
const conditions: AdminUpsertCondition[] = [] const conditions: AdminUpsertCondition[] = []
@@ -89,7 +69,7 @@ export const formValuesToCreateDiscountMapper = (
}, },
is_dynamic: values.is_dynamic, is_dynamic: values.is_dynamic,
ends_at: values.ends_at ?? undefined, ends_at: values.ends_at ?? undefined,
regions: values.regions?.map((r) => r.value), regions: values.regions?.map((r) => r.value) || [],
starts_at: values.starts_at, starts_at: values.starts_at,
usage_limit: usage_limit:
values.usage_limit && values.usage_limit > 0 values.usage_limit && values.usage_limit > 0
@@ -99,52 +79,6 @@ export const formValuesToCreateDiscountMapper = (
values.is_dynamic && values.valid_duration?.length values.is_dynamic && values.valid_duration?.length
? values.valid_duration ? values.valid_duration
: undefined, : undefined,
} metadata: getSubmittableMetadata(values.metadata),
}
const mapConditionsToUpdate = (map: ConditionMap) => {
const conditions: AdminUpsertCondition[] = []
for (const [key, value] of Object.entries(map)) {
if (value && value.items.length) {
conditions.push({
id: value.id,
operator: value.operator,
[key]: value.items.map((i) => i.id),
})
}
}
if (!conditions.length) {
return undefined
}
return conditions
}
export const formValuesToUpdateDiscountMapper = (
ruleId: string,
values: DiscountFormValues,
conditions: ConditionMap
): AdminPostDiscountsDiscountReq => {
return {
code: values.code,
rule: {
allocation:
values.rule.type === "fixed"
? AllocationType.ITEM
: AllocationType.TOTAL,
id: ruleId,
value: values.rule.value,
description: values.rule.description,
conditions: mapConditionsToUpdate(conditions),
},
ends_at: values.ends_at,
regions: values.regions?.map((r) => r.value),
starts_at: values.starts_at,
usage_limit: values.usage_limit,
valid_duration: values.valid_duration?.length
? values.valid_duration
: undefined,
} }
} }
@@ -1,11 +1,13 @@
import { useWatch } from "react-hook-form" import { useWatch } from "react-hook-form"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import MetadataForm from "../../../../components/forms/general/metadata-form"
import Button from "../../../../components/fundamentals/button" import Button from "../../../../components/fundamentals/button"
import CrossIcon from "../../../../components/fundamentals/icons/cross-icon" import CrossIcon from "../../../../components/fundamentals/icons/cross-icon"
import FocusModal from "../../../../components/molecules/modal/focus-modal" import FocusModal from "../../../../components/molecules/modal/focus-modal"
import Accordion from "../../../../components/organisms/accordion" import Accordion from "../../../../components/organisms/accordion"
import useNotification from "../../../../hooks/use-notification" import useNotification from "../../../../hooks/use-notification"
import { getErrorMessage } from "../../../../utils/error-messages" import { getErrorMessage } from "../../../../utils/error-messages"
import { nestedForm } from "../../../../utils/nested-form"
import { DiscountRuleType } from "../../types" import { DiscountRuleType } from "../../types"
import { useDiscountForm } from "./form/discount-form-context" import { useDiscountForm } from "./form/discount-form-context"
import { DiscountFormValues } from "./form/mappers" import { DiscountFormValues } from "./form/mappers"
@@ -23,7 +25,7 @@ type DiscountFormProps = {
const DiscountForm = ({ closeForm }: DiscountFormProps) => { const DiscountForm = ({ closeForm }: DiscountFormProps) => {
const navigate = useNavigate() const navigate = useNavigate()
const notification = useNotification() const notification = useNotification()
const { handleSubmit, handleReset, control } = useDiscountForm() const { handleSubmit, handleReset, control, form } = useDiscountForm()
const { onSaveAsActive, onSaveAsInactive } = useFormActions() const { onSaveAsActive, onSaveAsInactive } = useFormActions()
@@ -145,6 +147,16 @@ const DiscountForm = ({ closeForm }: DiscountFormProps) => {
> >
<DiscountNewConditions /> <DiscountNewConditions />
</Accordion.Item> </Accordion.Item>
<Accordion.Item
title="Metadata"
subtitle="Metadata allows you to add additional information to your discount."
value="metadata"
forceMountContent
>
<div className="mt-small">
<MetadataForm form={nestedForm(form, "metadata")} />
</div>
</Accordion.Item>
</Accordion> </Accordion>
</div> </div>
</div> </div>
@@ -5,13 +5,22 @@ import {
Country, Country,
} from "@medusajs/medusa" } from "@medusajs/medusa"
import { MutateOptions } from "@tanstack/react-query" import { MutateOptions } from "@tanstack/react-query"
import { useEffect } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import AddressContactForm, {
AddressContactFormType,
} from "../../../components/forms/general/address-contact-form"
import AddressLocationForm, {
AddressLocationFormType,
} from "../../../components/forms/general/address-location-form"
import MetadataForm, {
getMetadataFormValues,
getSubmittableMetadata,
MetadataFormType,
} from "../../../components/forms/general/metadata-form"
import Button from "../../../components/fundamentals/button" import Button from "../../../components/fundamentals/button"
import Modal from "../../../components/molecules/modal" import Modal from "../../../components/molecules/modal"
import AddressForm, { import { AddressType } from "../../../components/templates/address-form"
AddressPayload,
AddressType,
} from "../../../components/templates/address-form"
import useNotification from "../../../hooks/use-notification" import useNotification from "../../../hooks/use-notification"
import { isoAlpha2Countries } from "../../../utils/countries" import { isoAlpha2Countries } from "../../../utils/countries"
import { getErrorMessage } from "../../../utils/error-messages" import { getErrorMessage } from "../../../utils/error-messages"
@@ -23,6 +32,12 @@ type AddressPayloadType =
| AdminPostDraftOrdersReq["shipping_address"] | AdminPostDraftOrdersReq["shipping_address"]
| Partial<AdminPostDraftOrdersReq["shipping_address"]> | Partial<AdminPostDraftOrdersReq["shipping_address"]>
type AddressModalFormType = {
contact: AddressContactFormType
location: AddressLocationFormType
metadata: MetadataFormType
}
type TVariables = { type TVariables = {
shipping_address?: AddressPayloadType shipping_address?: AddressPayloadType
billing_address?: AddressPayloadType billing_address?: AddressPayloadType
@@ -34,8 +49,9 @@ type MutateAction = <T extends TVariables>(
) => void ) => void
type AddressModalProps = { type AddressModalProps = {
handleClose: () => void onClose: () => void
submit: MutateAction onSave: MutateAction
open: boolean
submitting?: boolean submitting?: boolean
allowedCountries?: Country[] allowedCountries?: Country[]
address?: Address address?: Address
@@ -45,85 +61,112 @@ type AddressModalProps = {
const AddressModal = ({ const AddressModal = ({
address, address,
allowedCountries = [], allowedCountries = [],
handleClose, onClose,
submit, onSave,
open,
type, type,
submitting = false, submitting = false,
}: AddressModalProps) => { }: AddressModalProps) => {
const form = useForm<AddressPayload>({ const form = useForm<AddressModalFormType>({
defaultValues: mapAddressToFormData(address), defaultValues: getDefaultValues(address),
}) })
const { const {
reset,
formState: { isDirty }, formState: { isDirty },
handleSubmit,
} = form } = form
const notification = useNotification() const notification = useNotification()
useEffect(() => {
if (open) {
reset(getDefaultValues(address))
}
}, [address, open, reset])
const countryOptions = allowedCountries const countryOptions = allowedCountries
.map((c) => ({ label: c.display_name, value: c.iso_2 })) .map((c) => ({ label: c.display_name, value: c.iso_2 }))
.filter(Boolean) .filter(Boolean)
const handleUpdateAddress = (data: AddressPayload) => { const onSubmit = handleSubmit((data) => {
const updateObj: TVariables = {} const updateObj: TVariables = {}
const { contact, location, metadata } = data
const countryCode = location.country_code.value
const metadataEntries = getSubmittableMetadata(metadata)
if (type === "shipping") { if (type === "shipping") {
// @ts-ignore // @ts-ignore
updateObj["shipping_address"] = { updateObj["shipping_address"] = {
...data, ...contact,
country_code: data.country_code.value, ...location,
country_code: countryCode,
metadata: metadataEntries,
} }
} else { } else {
// @ts-ignore // @ts-ignore
updateObj["billing_address"] = { updateObj["billing_address"] = {
...data, ...contact,
country_code: data.country_code.value, ...location,
country_code: countryCode,
metadata: metadataEntries,
} }
} }
return submit(updateObj, { return onSave(updateObj, {
onSuccess: () => { onSuccess: () => {
notification("Success", "Successfully updated address", "success") notification("Success", "Successfully updated address", "success")
handleClose() onClose()
}, },
onError: (err) => notification("Error", getErrorMessage(err), "error"), onError: (err) => notification("Error", getErrorMessage(err), "error"),
}) })
} })
return ( return (
<Modal handleClose={handleClose} isLargeModal> <Modal open={open} handleClose={onClose}>
<form onSubmit={form.handleSubmit(handleUpdateAddress)}> <form onSubmit={onSubmit}>
<Modal.Body> <Modal.Body>
<Modal.Header handleClose={handleClose}> <Modal.Header handleClose={onClose}>
<span className="inter-xlarge-semibold"> <span className="inter-xlarge-semibold">
{type === AddressType.BILLING ? "Billing" : "Shipping"} Address {type === AddressType.BILLING ? "Billing" : "Shipping"} Address
</span> </span>
</Modal.Header> </Modal.Header>
<Modal.Content> <Modal.Content>
<AddressForm <div className="gap-y-xlarge flex flex-col">
form={nestedForm(form)} <div>
countryOptions={countryOptions} <h2 className="inter-base-semibold mb-base">Contact</h2>
type={type} <AddressContactForm form={nestedForm(form, "contact")} />
/> </div>
<div>
<h2 className="inter-base-semibold mb-base">Location</h2>
<AddressLocationForm
form={nestedForm(form, "location")}
countryOptions={countryOptions}
/>
</div>
<div>
<h2 className="inter-base-semibold mb-base">Metadata</h2>
<MetadataForm form={nestedForm(form, "metadata")} />
</div>
</div>
</Modal.Content> </Modal.Content>
<Modal.Footer> <Modal.Footer>
<div className="flex h-8 w-full justify-end"> <div className="gap-x-xsmall flex w-full justify-end">
<Button <Button
variant="ghost" variant="secondary"
className="text-small mr-2 w-32 justify-center" size="small"
size="large" onClick={onClose}
onClick={handleClose}
type="button" type="button"
> >
Cancel Cancel
</Button> </Button>
<Button <Button
size="large" size="small"
className="text-small w-32 justify-center"
variant="primary" variant="primary"
type="submit" type="submit"
loading={submitting} loading={submitting}
disabled={submitting || !isDirty} disabled={submitting || !isDirty}
> >
Save Save and close
</Button> </Button>
</div> </div>
</Modal.Footer> </Modal.Footer>
@@ -133,23 +176,31 @@ const AddressModal = ({
) )
} }
const mapAddressToFormData = (address?: Address): AddressPayload => { const getDefaultValues = (address?: Address): AddressModalFormType => {
const countryDisplayName = const countryDisplayName = address?.country_code
isoAlpha2Countries[address?.country_code?.toUpperCase()] ? isoAlpha2Countries[
address.country_code.toUpperCase() as keyof typeof isoAlpha2Countries
]
: ""
return { return {
first_name: address?.first_name || "", contact: {
last_name: address?.last_name || "", first_name: address?.first_name || "",
phone: address?.phone || null, last_name: address?.last_name || "",
company: address?.company || null, phone: address?.phone || "",
address_1: address?.address_1 || "", company: address?.company || null,
address_2: address?.address_2 || null, },
city: address?.city || "", location: {
province: address?.province || null, address_1: address?.address_1 || "",
country_code: address?.country_code address_2: address?.address_2 || null,
? { label: countryDisplayName, value: address.country_code } city: address?.city || "",
: { label: "", value: "" }, province: address?.province || null,
postal_code: address?.postal_code || "", country_code: address?.country_code
? { label: countryDisplayName, value: address.country_code }
: { label: "", value: "" },
postal_code: address?.postal_code || "",
},
metadata: getMetadataFormValues(address?.metadata),
} }
} }
@@ -5,16 +5,6 @@ import {
LineItem, LineItem,
Swap, Swap,
} from "@medusajs/medusa" } from "@medusajs/medusa"
import {
DisplayTotal,
FormattedAddress,
FormattedFulfillment,
FulfillmentStatusComponent,
OrderStatusComponent,
PaymentActionables,
PaymentStatusComponent,
} from "./templates"
import OrderEditProvider, { OrderEditContext } from "../edit/context"
import { import {
useAdminCancelOrder, useAdminCancelOrder,
useAdminCapturePayment, useAdminCapturePayment,
@@ -24,47 +14,57 @@ import {
useAdminUpdateOrder, useAdminUpdateOrder,
} from "medusa-react" } from "medusa-react"
import { useNavigate, useParams } from "react-router-dom" import { useNavigate, useParams } from "react-router-dom"
import OrderEditProvider, { OrderEditContext } from "../edit/context"
import {
DisplayTotal,
FormattedAddress,
FormattedFulfillment,
FulfillmentStatusComponent,
OrderStatusComponent,
PaymentActionables,
PaymentStatusComponent,
} from "./templates"
import { ActionType } from "../../../components/molecules/actionables" import { capitalize } from "lodash"
import AddressModal from "./address-modal" import moment from "moment"
import { AddressType } from "../../../components/templates/address-form" import { useEffect, useMemo, useState } from "react"
import { useHotkeys } from "react-hotkeys-hook"
import Avatar from "../../../components/atoms/avatar" import Avatar from "../../../components/atoms/avatar"
import BodyCard from "../../../components/organisms/body-card" import Spinner from "../../../components/atoms/spinner"
import Breadcrumb from "../../../components/molecules/breadcrumb" import Tooltip from "../../../components/atoms/tooltip"
import Button from "../../../components/fundamentals/button" import Button from "../../../components/fundamentals/button"
import DetailsIcon from "../../../components/fundamentals/details-icon"
import CancelIcon from "../../../components/fundamentals/icons/cancel-icon" import CancelIcon from "../../../components/fundamentals/icons/cancel-icon"
import ClipboardCopyIcon from "../../../components/fundamentals/icons/clipboard-copy-icon" import ClipboardCopyIcon from "../../../components/fundamentals/icons/clipboard-copy-icon"
import CornerDownRightIcon from "../../../components/fundamentals/icons/corner-down-right-icon" import CornerDownRightIcon from "../../../components/fundamentals/icons/corner-down-right-icon"
import CreateFulfillmentModal from "./create-fulfillment"
import CreateRefundModal from "./refund"
import DetailsIcon from "../../../components/fundamentals/details-icon"
import DollarSignIcon from "../../../components/fundamentals/icons/dollar-sign-icon" import DollarSignIcon from "../../../components/fundamentals/icons/dollar-sign-icon"
import EmailModal from "./email-modal"
import JSONView from "../../../components/molecules/json-view"
import MailIcon from "../../../components/fundamentals/icons/mail-icon" import MailIcon from "../../../components/fundamentals/icons/mail-icon"
import MarkShippedModal from "./mark-shipped"
import OrderEditModal from "../edit/modal"
import RawJSON from "../../../components/organisms/raw-json"
import RefreshIcon from "../../../components/fundamentals/icons/refresh-icon" import RefreshIcon from "../../../components/fundamentals/icons/refresh-icon"
import Spinner from "../../../components/atoms/spinner"
import SummaryCard from "./detail-cards/summary"
import Timeline from "../../../components/organisms/timeline"
import Tooltip from "../../../components/atoms/tooltip"
import TransferOrdersModal from "../../../components/templates/transfer-orders-modal"
import TruckIcon from "../../../components/fundamentals/icons/truck-icon" import TruckIcon from "../../../components/fundamentals/icons/truck-icon"
import { capitalize } from "lodash" import { ActionType } from "../../../components/molecules/actionables"
import extractCustomerName from "../../../utils/extract-customer-name" import Breadcrumb from "../../../components/molecules/breadcrumb"
import { formatAmountWithSymbol } from "../../../utils/prices" import JSONView from "../../../components/molecules/json-view"
import { getErrorMessage } from "../../../utils/error-messages" import BodyCard from "../../../components/organisms/body-card"
import { isoAlpha2Countries } from "../../../utils/countries" import RawJSON from "../../../components/organisms/raw-json"
import moment from "moment" import Timeline from "../../../components/organisms/timeline"
import { AddressType } from "../../../components/templates/address-form"
import TransferOrdersModal from "../../../components/templates/transfer-orders-modal"
import useClipboard from "../../../hooks/use-clipboard" import useClipboard from "../../../hooks/use-clipboard"
import { useHotkeys } from "react-hotkeys-hook"
import useImperativeDialog from "../../../hooks/use-imperative-dialog" import useImperativeDialog from "../../../hooks/use-imperative-dialog"
import useNotification from "../../../hooks/use-notification" import useNotification from "../../../hooks/use-notification"
import { useEffect, useMemo, useState } from "react"
import useToggleState from "../../../hooks/use-toggle-state" import useToggleState from "../../../hooks/use-toggle-state"
import { useFeatureFlag } from "../../../providers/feature-flag-provider" import { useFeatureFlag } from "../../../providers/feature-flag-provider"
import { isoAlpha2Countries } from "../../../utils/countries"
import { getErrorMessage } from "../../../utils/error-messages"
import extractCustomerName from "../../../utils/extract-customer-name"
import { formatAmountWithSymbol } from "../../../utils/prices"
import OrderEditModal from "../edit/modal"
import AddressModal from "./address-modal"
import CreateFulfillmentModal from "./create-fulfillment"
import SummaryCard from "./detail-cards/summary"
import EmailModal from "./email-modal"
import MarkShippedModal from "./mark-shipped"
import CreateRefundModal from "./refund"
type OrderDetailFulfillment = { type OrderDetailFulfillment = {
title: string title: string
@@ -148,6 +148,12 @@ const OrderDetails = () => {
const capturePayment = useAdminCapturePayment(id!) const capturePayment = useAdminCapturePayment(id!)
const cancelOrder = useAdminCancelOrder(id!) const cancelOrder = useAdminCancelOrder(id!)
const {
state: addressModalState,
close: closeAddressModal,
open: openAddressModal,
} = useToggleState()
const { mutate: updateOrder } = useAdminUpdateOrder(id!) const { mutate: updateOrder } = useAdminUpdateOrder(id!)
const { region } = useAdminRegion(order?.region_id!, { const { region } = useAdminRegion(order?.region_id!, {
@@ -227,11 +233,13 @@ const OrderDetails = () => {
customerActionables.push({ customerActionables.push({
label: "Edit Shipping Address", label: "Edit Shipping Address",
icon: <TruckIcon size={"20"} />, icon: <TruckIcon size={"20"} />,
onClick: () => onClick: () => {
setAddressModal({ setAddressModal({
address: order?.shipping_address, address: order?.shipping_address,
type: AddressType.SHIPPING, type: AddressType.SHIPPING,
}), })
openAddressModal()
},
}) })
customerActionables.push({ customerActionables.push({
@@ -242,6 +250,7 @@ const OrderDetails = () => {
address: order?.billing_address, address: order?.billing_address,
type: AddressType.BILLING, type: AddressType.BILLING,
}) })
openAddressModal()
}, },
}) })
@@ -522,15 +531,16 @@ const OrderDetails = () => {
</div> </div>
<Timeline orderId={order.id} /> <Timeline orderId={order.id} />
</div> </div>
{addressModal && (
<AddressModal <AddressModal
handleClose={() => setAddressModal(null)} onClose={closeAddressModal}
submit={updateOrder} open={addressModalState}
address={addressModal.address || undefined} onSave={updateOrder}
type={addressModal.type} address={addressModal?.address || undefined}
allowedCountries={region?.countries} type={addressModal?.type}
/> allowedCountries={region?.countries}
)} />
{emailModal && ( {emailModal && (
<EmailModal <EmailModal
handleClose={() => setEmailModal(null)} handleClose={() => setEmailModal(null)}
@@ -472,8 +472,8 @@ const DraftOrderDetails = () => {
)} )}
{addressModal && ( {addressModal && (
<AddressModal <AddressModal
handleClose={() => setAddressModal(null)} onClose={() => setAddressModal(null)}
submit={updateOrder.mutate} onSave={updateOrder.mutate}
address={addressModal.address} address={addressModal.address}
type={addressModal.type} type={addressModal.type}
allowedCountries={region?.countries} allowedCountries={region?.countries}
@@ -6,14 +6,15 @@ import {
useAdminCreateProductCategory, useAdminCreateProductCategory,
} from "medusa-react" } from "medusa-react"
import useNotification from "../../../hooks/use-notification" import { useQueryClient } from "@tanstack/react-query"
import FocusModal from "../../../components/molecules/modal/focus-modal"
import Button from "../../../components/fundamentals/button" import Button from "../../../components/fundamentals/button"
import CrossIcon from "../../../components/fundamentals/icons/cross-icon" import CrossIcon from "../../../components/fundamentals/icons/cross-icon"
import InputField from "../../../components/molecules/input" import InputField from "../../../components/molecules/input"
import Select from "../../../components/molecules/select" import FocusModal from "../../../components/molecules/modal/focus-modal"
import { NextSelect } from "../../../components/molecules/select/next-select"
import useNotification from "../../../hooks/use-notification"
import { getErrorMessage } from "../../../utils/error-messages"
import TreeCrumbs from "../components/tree-crumbs" import TreeCrumbs from "../components/tree-crumbs"
import { useQueryClient } from "@tanstack/react-query"
const visibilityOptions = [ const visibilityOptions = [
{ {
@@ -63,7 +64,7 @@ function CreateProductCategory(props: CreateProductCategoryProps) {
notification("Success", "Successfully created a category", "success") notification("Success", "Successfully created a category", "success")
} catch (e) { } catch (e) {
const errorMessage = const errorMessage =
e.response?.data?.message || "Failed to create a new category" getErrorMessage(e) || "Failed to create a new category"
notification("Error", errorMessage, "error") notification("Error", errorMessage, "error")
} }
} }
@@ -133,20 +134,18 @@ function CreateProductCategory(props: CreateProductCategoryProps) {
<div className="mb-8 flex justify-between gap-6"> <div className="mb-8 flex justify-between gap-6">
<div className="flex-1"> <div className="flex-1">
<Select <NextSelect
label="Status" label="Status"
options={statusOptions} options={statusOptions}
menuPortalStyles={{ zIndex: 300 }}
value={statusOptions[isActive ? 0 : 1]} value={statusOptions[isActive ? 0 : 1]}
onChange={(o) => setIsActive(o.value === "active")} onChange={(o) => setIsActive(o.value === "active")}
/> />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<Select <NextSelect
label="Visibility" label="Visibility"
options={visibilityOptions} options={visibilityOptions}
menuPortalStyles={{ zIndex: 300 }}
value={visibilityOptions[isPublic ? 0 : 1]} value={visibilityOptions[isPublic ? 0 : 1]}
onChange={(o) => setIsPublic(o.value === "public")} onChange={(o) => setIsPublic(o.value === "public")}
/> />
@@ -3,15 +3,17 @@ import { useEffect, useState } from "react"
import { ProductCategory } from "@medusajs/medusa" import { ProductCategory } from "@medusajs/medusa"
import { useAdminUpdateProductCategory } from "medusa-react" import { useAdminUpdateProductCategory } from "medusa-react"
import SideModal from "../../../components/molecules/modal/side-modal"
import Button from "../../../components/fundamentals/button" import Button from "../../../components/fundamentals/button"
import CrossIcon from "../../../components/fundamentals/icons/cross-icon" import CrossIcon from "../../../components/fundamentals/icons/cross-icon"
import InputField from "../../../components/molecules/input" import InputField from "../../../components/molecules/input"
import Select from "../../../components/molecules/select" import SideModal from "../../../components/molecules/modal/side-modal"
import { NextSelect } from "../../../components/molecules/select/next-select"
import useNotification from "../../../hooks/use-notification" import useNotification from "../../../hooks/use-notification"
import { Option } from "../../../types/shared"
import { getErrorMessage } from "../../../utils/error-messages"
import TreeCrumbs from "../components/tree-crumbs" import TreeCrumbs from "../components/tree-crumbs"
const visibilityOptions = [ const visibilityOptions: Option[] = [
{ {
label: "Public", label: "Public",
value: "public", value: "public",
@@ -19,7 +21,7 @@ const visibilityOptions = [
{ label: "Private", value: "private" }, { label: "Private", value: "private" },
] ]
const statusOptions = [ const statusOptions: Option[] = [
{ label: "Active", value: "active" }, { label: "Active", value: "active" },
{ label: "Inactive", value: "inactive" }, { label: "Inactive", value: "inactive" },
] ]
@@ -70,8 +72,7 @@ function EditProductCategoriesSideModal(
notification("Success", "Successfully updated the category", "success") notification("Success", "Successfully updated the category", "success")
close() close()
} catch (e) { } catch (e) {
const errorMessage = const errorMessage = getErrorMessage(e) || "Failed to update the category"
e.response?.data?.message || "Failed to update the category"
notification("Error", errorMessage, "error") notification("Error", errorMessage, "error")
} }
} }
@@ -129,19 +130,17 @@ function EditProductCategoriesSideModal(
onChange={(ev) => setHandle(ev.target.value)} onChange={(ev) => setHandle(ev.target.value)}
/> />
<Select <NextSelect
label="Status" label="Status"
options={statusOptions} options={statusOptions}
menuPortalStyles={{ zIndex: 300 }}
value={statusOptions[isActive ? 0 : 1]} value={statusOptions[isActive ? 0 : 1]}
onChange={(o) => setIsActive(o.value === "active")} onChange={(o) => setIsActive(o.value === "active")}
/> />
<Select <NextSelect
className="my-6" className="my-6"
label="Visibility" label="Visibility"
options={visibilityOptions} options={visibilityOptions}
menuPortalStyles={{ zIndex: 300 }}
value={visibilityOptions[isPublic ? 0 : 1]} value={visibilityOptions[isPublic ? 0 : 1]}
onChange={(o) => setIsPublic(o.value === "public")} onChange={(o) => setIsPublic(o.value === "public")}
/> />
@@ -2,6 +2,10 @@ import { ShippingOption } from "@medusajs/medusa"
import { useAdminUpdateShippingOption } from "medusa-react" import { useAdminUpdateShippingOption } from "medusa-react"
import { useEffect } from "react" import { useEffect } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import {
getMetadataFormValues,
getSubmittableMetadata,
} from "../../../../../components/forms/general/metadata-form"
import Button from "../../../../../components/fundamentals/button" import Button from "../../../../../components/fundamentals/button"
import Modal from "../../../../../components/molecules/modal" import Modal from "../../../../../components/molecules/modal"
import useNotification from "../../../../../hooks/use-notification" import useNotification from "../../../../../hooks/use-notification"
@@ -32,8 +36,10 @@ const EditModal = ({ open, onClose, option }: Props) => {
} = form } = form
useEffect(() => { useEffect(() => {
reset(getDefaultValues(option)) if (open) {
}, [option]) reset(getDefaultValues(option))
}
}, [option, reset, open])
const closeAndReset = () => { const closeAndReset = () => {
reset(getDefaultValues(option)) reset(getDefaultValues(option))
@@ -48,6 +54,7 @@ const EditModal = ({ open, onClose, option }: Props) => {
requirements: getRequirementsData(data), requirements: getRequirementsData(data),
admin_only: !data.store_option, admin_only: !data.store_option,
amount: data.amount!, amount: data.amount!,
metadata: getSubmittableMetadata(data.metadata),
}, },
{ {
onSuccess: () => { onSuccess: () => {
@@ -72,7 +79,7 @@ const EditModal = ({ open, onClose, option }: Props) => {
<div> <div>
<p className="inter-base-semibold">Fulfillment Method</p> <p className="inter-base-semibold">Fulfillment Method</p>
<p className="inter-base-regular text-grey-50"> <p className="inter-base-regular text-grey-50">
{option.data.id} via {option.provider_id} {option.data.id as string} via {option.provider_id}
</p> </p>
</div> </div>
<div className="bg-grey-20 my-xlarge h-px w-full" /> <div className="bg-grey-20 my-xlarge h-px w-full" />
@@ -133,6 +140,7 @@ const getDefaultValues = (option: ShippingOption): ShippingOptionFormType => {
: null, : null,
}, },
amount: option.amount, amount: option.amount,
metadata: getMetadataFormValues(option.metadata),
} }
} }
@@ -2,12 +2,16 @@ import { Region } from "@medusajs/medusa"
import { Controller, UseFormReturn } from "react-hook-form" import { Controller, UseFormReturn } from "react-hook-form"
import IncludesTaxTooltip from "../../../../../components/atoms/includes-tax-tooltip" import IncludesTaxTooltip from "../../../../../components/atoms/includes-tax-tooltip"
import Switch from "../../../../../components/atoms/switch" import Switch from "../../../../../components/atoms/switch"
import MetadataForm, {
MetadataFormType,
} from "../../../../../components/forms/general/metadata-form"
import PriceFormInput from "../../../../../components/forms/general/prices-form/price-form-input" import PriceFormInput from "../../../../../components/forms/general/prices-form/price-form-input"
import InputHeader from "../../../../../components/fundamentals/input-header" import InputHeader from "../../../../../components/fundamentals/input-header"
import InputField from "../../../../../components/molecules/input" import InputField from "../../../../../components/molecules/input"
import { NextSelect } from "../../../../../components/molecules/select/next-select" import { NextSelect } from "../../../../../components/molecules/select/next-select"
import { Option, ShippingOptionPriceType } from "../../../../../types/shared" import { Option, ShippingOptionPriceType } from "../../../../../types/shared"
import FormValidator from "../../../../../utils/form-validator" import FormValidator from "../../../../../utils/form-validator"
import { nestedForm } from "../../../../../utils/nested-form"
import { useShippingOptionFormData } from "./use-shipping-option-form-data" import { useShippingOptionFormData } from "./use-shipping-option-form-data"
type Requirement = { type Requirement = {
@@ -26,6 +30,7 @@ export type ShippingOptionFormType = {
min_subtotal: Requirement | null min_subtotal: Requirement | null
max_subtotal: Requirement | null max_subtotal: Requirement | null
} }
metadata: MetadataFormType
} }
type Props = { type Props = {
@@ -272,6 +277,11 @@ const ShippingOptionForm = ({ form, region, isEdit = false }: Props) => {
/> />
</div> </div>
</div> </div>
<div className="bg-grey-20 my-xlarge h-px w-full" />
<div>
<h3 className="inter-base-semibold mb-base">Metadata</h3>
<MetadataForm form={nestedForm(form, "metadata")} />
</div>
</div> </div>
) )
} }
@@ -2,6 +2,11 @@ import { AdminPostRegionsRegionReq, Region } from "@medusajs/medusa"
import { useAdminUpdateRegion } from "medusa-react" import { useAdminUpdateRegion } from "medusa-react"
import { useEffect } from "react" import { useEffect } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import MetadataForm, {
getMetadataFormValues,
getSubmittableMetadata,
MetadataFormType,
} from "../../../../../components/forms/general/metadata-form"
import Button from "../../../../../components/fundamentals/button" import Button from "../../../../../components/fundamentals/button"
import Modal from "../../../../../components/molecules/modal" import Modal from "../../../../../components/molecules/modal"
import useNotification from "../../../../../hooks/use-notification" import useNotification from "../../../../../hooks/use-notification"
@@ -27,6 +32,7 @@ type Props = {
type RegionEditFormType = { type RegionEditFormType = {
details: RegionDetailsFormType details: RegionDetailsFormType
providers: RegionProvidersFormType providers: RegionProvidersFormType
metadata: MetadataFormType
} }
const EditRegionModal = ({ region, onClose, open }: Props) => { const EditRegionModal = ({ region, onClose, open }: Props) => {
@@ -48,7 +54,7 @@ const EditRegionModal = ({ region, onClose, open }: Props) => {
useEffect(() => { useEffect(() => {
reset(getDefaultValues(region)) reset(getDefaultValues(region))
}, [region]) }, [region, reset])
const { mutate, isLoading } = useAdminUpdateRegion(region.id) const { mutate, isLoading } = useAdminUpdateRegion(region.id)
const notifcation = useNotification() const notifcation = useNotification()
@@ -62,6 +68,7 @@ const EditRegionModal = ({ region, onClose, open }: Props) => {
(fp) => fp.value (fp) => fp.value
), ),
countries: data.details.countries.map((c) => c.value), countries: data.details.countries.map((c) => c.value),
metadata: getSubmittableMetadata(data.metadata),
} }
if (isFeatureEnabled("tax_inclusive_pricing")) { if (isFeatureEnabled("tax_inclusive_pricing")) {
@@ -96,6 +103,11 @@ const EditRegionModal = ({ region, onClose, open }: Props) => {
<h3 className="inter-base-semibold mb-base">Providers</h3> <h3 className="inter-base-semibold mb-base">Providers</h3>
<RegionProvidersForm form={nestedForm(form, "providers")} /> <RegionProvidersForm form={nestedForm(form, "providers")} />
</div> </div>
<div className="bg-grey-20 my-xlarge h-px w-full" />
<div>
<h3 className="inter-base-semibold mb-base">Metadata</h3>
<MetadataForm form={nestedForm(form, "metadata")} />
</div>
</Modal.Content> </Modal.Content>
<Modal.Footer> <Modal.Footer>
<div className="gap-x-xsmall flex w-full items-center justify-end"> <div className="gap-x-xsmall flex w-full items-center justify-end">
@@ -152,6 +164,7 @@ const getDefaultValues = (region: Region): RegionEditFormType => {
? region.payment_providers.map((p) => paymentProvidersMapper(p.id)) ? region.payment_providers.map((p) => paymentProvidersMapper(p.id))
: [], : [],
}, },
metadata: getMetadataFormValues(region.metadata),
} }
} }
@@ -1,6 +1,7 @@
import { Region } from "@medusajs/medusa" import { Region } from "@medusajs/medusa"
import { useAdminCreateShippingOption } from "medusa-react" import { useAdminCreateShippingOption } from "medusa-react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { getSubmittableMetadata } from "../../../../../components/forms/general/metadata-form"
import Button from "../../../../../components/fundamentals/button" import Button from "../../../../../components/fundamentals/button"
import Modal from "../../../../../components/molecules/modal" import Modal from "../../../../../components/molecules/modal"
import useNotification from "../../../../../hooks/use-notification" import useNotification from "../../../../../hooks/use-notification"
@@ -51,6 +52,7 @@ const CreateReturnShippingOptionModal = ({ open, onClose, region }: Props) => {
admin_only: !data.store_option, admin_only: !data.store_option,
amount: data.amount!, amount: data.amount!,
requirements: getRequirementsData(data), requirements: getRequirementsData(data),
metadata: getSubmittableMetadata(data.metadata),
}, },
{ {
onSuccess: () => { onSuccess: () => {
@@ -1,6 +1,7 @@
import { Region } from "@medusajs/medusa" import { Region } from "@medusajs/medusa"
import { useAdminCreateShippingOption } from "medusa-react" import { useAdminCreateShippingOption } from "medusa-react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { getSubmittableMetadata } from "../../../../../components/forms/general/metadata-form"
import Button from "../../../../../components/fundamentals/button" import Button from "../../../../../components/fundamentals/button"
import Modal from "../../../../../components/molecules/modal" import Modal from "../../../../../components/molecules/modal"
import useNotification from "../../../../../hooks/use-notification" import useNotification from "../../../../../hooks/use-notification"
@@ -51,6 +52,7 @@ const CreateShippingOptionModal = ({ open, onClose, region }: Props) => {
admin_only: !data.store_option, admin_only: !data.store_option,
amount: data.amount!, amount: data.amount!,
requirements: getRequirementsData(data), requirements: getRequirementsData(data),
metadata: getSubmittableMetadata(data.metadata),
}, },
{ {
onSuccess: () => { onSuccess: () => {
+8
View File
@@ -957,8 +957,16 @@ class OrderService extends TransactionBaseService {
where: { id: order.billing_address_id }, where: { id: order.billing_address_id },
}) })
if (address.metadata) {
address.metadata = setMetadata(addr, address.metadata)
}
await addrRepo.save({ ...addr, ...address }) await addrRepo.save({ ...addr, ...address })
} else { } else {
if (address.metadata) {
address.metadata = setMetadata(null, address.metadata)
}
order.billing_address = addrRepo.create({ ...address }) order.billing_address = addrRepo.create({ ...address })
} }
} }
+3 -3
View File
@@ -2,15 +2,15 @@ import { MedusaError } from "medusa-core-utils/dist"
/** /**
* Dedicated method to set metadata. * Dedicated method to set metadata.
* @param obj - the entity to apply metadata to.
* @param metadata - the metadata to set * @param metadata - the metadata to set
* @param obj - the entity to apply metadata to.
* @return resolves to the updated result. * @return resolves to the updated result.
*/ */
export function setMetadata( export function setMetadata(
obj: { metadata: Record<string, unknown> | null }, obj: { metadata: Record<string, unknown> | null } | null | undefined,
metadata: Record<string, unknown> metadata: Record<string, unknown>
): Record<string, unknown> { ): Record<string, unknown> {
const existing = obj.metadata || {} const existing = obj?.metadata || {}
const newData = {} const newData = {}
for (const [key, value] of Object.entries(metadata)) { for (const [key, value] of Object.entries(metadata)) {
+15 -2
View File
@@ -5633,6 +5633,7 @@ __metadata:
"@testing-library/user-event": ^14.4.3 "@testing-library/user-event": ^14.4.3
"@types/pluralize": ^0.0.29 "@types/pluralize": ^0.0.29
"@types/react": ^18.0.27 "@types/react": ^18.0.27
"@types/react-datepicker": ^4.10.0
"@types/react-dom": ^18.0.10 "@types/react-dom": ^18.0.10
"@types/react-table": ^7.7.9 "@types/react-table": ^7.7.9
"@vitejs/plugin-react": ^3.1.0 "@vitejs/plugin-react": ^3.1.0
@@ -11500,6 +11501,18 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/react-datepicker@npm:^4.10.0":
version: 4.10.0
resolution: "@types/react-datepicker@npm:4.10.0"
dependencies:
"@popperjs/core": ^2.9.2
"@types/react": "*"
date-fns: ^2.0.1
react-popper: ^2.2.5
checksum: f1f18a89a2cd860ea74fbe08c14012295557c6b6e5cbcab5ff00c43a5fae77080f10d35d6799d56f0b800742b01184aa038ed73460ff8eb3a7461e5bd2956ee4
languageName: node
linkType: hard
"@types/react-dom@npm:^17.0.18": "@types/react-dom@npm:^17.0.18":
version: 17.0.18 version: 17.0.18
resolution: "@types/react-dom@npm:17.0.18" resolution: "@types/react-dom@npm:17.0.18"
@@ -17324,7 +17337,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"date-fns@npm:^2.24.0, date-fns@npm:^2.28.0": "date-fns@npm:^2.0.1, date-fns@npm:^2.24.0, date-fns@npm:^2.28.0":
version: 2.29.3 version: 2.29.3
resolution: "date-fns@npm:2.29.3" resolution: "date-fns@npm:2.29.3"
checksum: aa9128c876ef69a05988029d6aa3d7e5c47a1e978f18b77b48126683d1a2e6605a16c3f5293ca9f4ca790d0755b5061fcea5b469f097871cd53f6590a5c1adc4 checksum: aa9128c876ef69a05988029d6aa3d7e5c47a1e978f18b77b48126683d1a2e6605a16c3f5293ca9f4ca790d0755b5061fcea5b469f097871cd53f6590a5c1adc4
@@ -33982,7 +33995,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"react-popper@npm:^2.3.0": "react-popper@npm:^2.2.5, react-popper@npm:^2.3.0":
version: 2.3.0 version: 2.3.0
resolution: "react-popper@npm:2.3.0" resolution: "react-popper@npm:2.3.0"
dependencies: dependencies: