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

This commit is contained in:
Kasper Fabricius Kristensen
2023-03-31 12:07:24 +02:00
committed by GitHub
parent 4f4ccee7fb
commit 4342ac884b
59 changed files with 1904 additions and 963 deletions
@@ -11,7 +11,7 @@ import InputHeader from "../../fundamentals/input-header"
import CustomHeader from "./custom-header"
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")
? "date chosen"
: `date ${
@@ -29,12 +29,18 @@ const DatePicker: React.FC<DateTimePickerProps> = ({
tooltipContent,
tooltip,
}) => {
const [tempDate, setTempDate] = useState(date)
const [tempDate, setTempDate] = useState<Date | null>(date || null)
const [isOpen, setIsOpen] = useState(false)
useEffect(() => setTempDate(date), [isOpen])
const submitDate = () => {
if (!tempDate || !date) {
onSubmitDate(null)
setIsOpen(false)
return
}
// update only date, month and year
const newDate = new Date(date.getTime())
newDate.setUTCDate(tempDate.getUTCDate())
@@ -68,7 +74,9 @@ const DatePicker: React.FC<DateTimePickerProps> = ({
<ArrowDownIcon size={16} />
</div>
<label className="w-full text-left">
{moment(date).format("ddd, DD MMM YYYY")}
{date
? moment(date).format("ddd, DD MMM YYYY")
: "---, -- -- ----"}
</label>
</InputContainer>
</button>
@@ -78,7 +86,10 @@ const DatePicker: React.FC<DateTimePickerProps> = ({
sideOffset={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">
<Button
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
selected={date}
inline
@@ -70,7 +70,7 @@ const TimePicker: React.FC<DateTimePickerProps> = ({
<ClockIcon size={16} />
<span className="mx-1">UTC</span>
<span className="text-grey-90">
{moment.utc(date).format("HH:mm")}
{date ? moment.utc(date).format("HH:mm") : "--:--"}
</span>
</div>
</InputContainer>
@@ -84,13 +84,13 @@ const TimePicker: React.FC<DateTimePickerProps> = ({
<NumberScroller
numbers={hourNumbers}
selected={selectedHour}
onSelect={(n) => setSelectedHour(n)}
onSelect={(n) => setSelectedHour(n as number)}
className="pr-4"
/>
<NumberScroller
numbers={minuteNumbers}
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" />
</PopoverPrimitive.Content>
@@ -1,6 +1,6 @@
import { InputHeaderProps } from "../../fundamentals/input-header"
export type DateTimePickerProps = {
date: Date
onSubmitDate: (newDate: Date) => void
date: Date | null
onSubmitDate: (newDate: Date | null) => void
} & 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 { useEffect, useState } from "react"
import { useCallback, useEffect, useState } from "react"
import AmountField from "react-currency-input-field"
import { currencies } from "../../../../utils/currencies"
import InputError from "../../../atoms/input-error"
import InputHeader from "../../../fundamentals/input-header"
type Props = {
currencyCode: string
@@ -10,6 +11,8 @@ type Props = {
onChange: (amount?: number) => void
errors?: { [x: string]: unknown }
name?: string
label?: string
required?: boolean
}
const PriceFormInput = ({
@@ -18,13 +21,18 @@ const PriceFormInput = ({
errors,
amount,
onChange,
label,
required,
}: Props) => {
const { symbol_native, decimal_digits } =
currencies[currencyCode.toUpperCase()]
const getFormattedValue = (value: number) => {
return `${value / 10 ** decimal_digits}`
}
const getFormattedValue = useCallback(
(value: number) => {
return `${value / 10 ** decimal_digits}`
},
[decimal_digits]
)
const [formattedValue, setFormattedValue] = useState<string | undefined>(
amount !== null && amount !== undefined
@@ -36,7 +44,7 @@ const PriceFormInput = ({
if (amount) {
setFormattedValue(getFormattedValue(amount))
}
}, [amount, decimal_digits])
}, [amount, decimal_digits, getFormattedValue])
const onAmountChange = (value?: string, floatValue?: number | null) => {
if (typeof floatValue === "number") {
@@ -52,6 +60,7 @@ const PriceFormInput = ({
return (
<div>
{label && <InputHeader {...{ label, required }} className="mb-xsmall" />}
<div
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",
@@ -1,11 +1,12 @@
import { Controller, useFieldArray, useWatch } from "react-hook-form"
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 InputError from "../../../atoms/input-error"
import Switch from "../../../atoms/switch"
import CoinsIcon from "../../../fundamentals/icons/coins-icon"
import IconTooltip from "../../../molecules/icon-tooltip"
import MetadataForm, { MetadataFormType } from "../../general/metadata-form"
import PriceFormInput from "../../general/prices-form/price-form-input"
type DenominationType = {
@@ -18,6 +19,7 @@ export type DenominationFormType = {
defaultDenomination: DenominationType
currencyDenominations: DenominationType[]
useSameValue: boolean
metadata: MetadataFormType
}
type Props = {
@@ -51,7 +53,7 @@ const DenominationForm = ({ form }: Props) => {
<div className="gap-y-xlarge flex flex-col">
<div>
<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
type="info"
content="The denomination in your store's default currency"
@@ -170,6 +172,10 @@ const DenominationForm = ({ form }: Props) => {
</div>
</div>
)}
<div>
<h2 className="inter-base-semibold mb-base">Metadata</h2>
<MetadataForm form={nestedForm(form, "metadata")} />
</div>
</div>
)
}
@@ -3,13 +3,14 @@ import { useMemo } from "react"
import {
useAdminCollections,
useAdminProductCategories,
useAdminProductTypes
useAdminProductTypes,
} from "medusa-react"
import { NestedMultiselectOption } from "../../../../domain/categories/components/multiselect"
import { transformCategoryToNestedFormOptions } from "../../../../domain/categories/utils/transform-response"
import {
FeatureFlag, useFeatureFlag
FeatureFlag,
useFeatureFlag,
} from "../../../../providers/feature-flag-provider"
const useOrganizeData = () => {
@@ -19,12 +20,15 @@ const useOrganizeData = () => {
refetchOnWindowFocus: true,
})
const { collections } = useAdminCollections()
const { product_categories: categories = [] } =
isFeatureEnabled(FeatureFlag.PRODUCT_CATEGORIES) &&
useAdminProductCategories({
const { product_categories: categories = [] } = useAdminProductCategories(
{
parent_category_id: "null",
include_descendants_tree: true,
})
},
{
enabled: isFeatureEnabled(FeatureFlag.PRODUCT_CATEGORIES),
}
)
const productTypeOptions = useMemo(() => {
return (
@@ -11,6 +11,7 @@ import { nestedForm } from "../../../../../utils/nested-form"
import IconTooltip from "../../../../molecules/icon-tooltip"
import InputField from "../../../../molecules/input"
import Accordion from "../../../../organisms/accordion"
import MetadataForm, { MetadataFormType } from "../../../general/metadata-form"
import { PricesFormType } from "../../../general/prices-form"
import VariantPricesForm from "../variant-prices-form"
@@ -29,6 +30,7 @@ export type EditFlowVariantFormType = {
}[]
customs: CustomsFormType
dimensions: DimensionsFormType
metadata: MetadataFormType
}
type Props = {
@@ -125,6 +127,13 @@ const EditFlowVariantForm = ({ form, isEdit }: Props) => {
</div>
)}
</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>
)
}
@@ -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, {
ChangeEventHandler,
FocusEventHandler,
@@ -6,11 +5,12 @@ import React, {
useImperativeHandle,
useRef,
} from "react"
import InputHeader, { InputHeaderProps } from "../../fundamentals/input-header"
import clsx from "clsx"
import InputError from "../../atoms/input-error"
import MinusIcon from "../../fundamentals/icons/minus-icon"
import PlusIcon from "../../fundamentals/icons/plus-icon"
import clsx from "clsx"
export type InputProps = Omit<React.ComponentPropsWithRef<"input">, "prefix"> &
InputHeaderProps & {
@@ -26,7 +26,6 @@ export type InputProps = Omit<React.ComponentPropsWithRef<"input">, "prefix"> &
props?: React.HTMLAttributes<HTMLDivElement>
}
// eslint-disable-next-line react/display-name
const InputField = React.forwardRef<HTMLInputElement, InputProps>(
(
{
@@ -161,4 +160,6 @@ const InputField = React.forwardRef<HTMLInputElement, InputProps>(
}
)
InputField.displayName = "InputField"
export default InputField
@@ -85,7 +85,7 @@ const Item: React.FC<AccordionItemProps> = ({
<AccordionPrimitive.Content
forceMount={forceMountContent}
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">
@@ -18,6 +18,8 @@ type BodyCardProps = {
status?: React.ReactNode
customHeader?: React.ReactNode
compact?: boolean
footerMinHeight?: number
setBorders?: boolean
} & React.HTMLAttributes<HTMLDivElement>
const BodyCard: React.FC<BodyCardProps> = ({
@@ -94,7 +96,7 @@ const BodyCard: React.FC<BodyCardProps> = ({
{children && (
<div
className={clsx("flex flex-col", {
"my-large grow": !compact,
grow: !compact,
})}
>
{children}
@@ -109,7 +109,7 @@ const EditDenominationsModal = ({
<Modal open={open} handleClose={handleClose}>
<Modal.Body>
<Modal.Header handleClose={handleClose}>
<h1 className="inter-xlarge-semibold">Edit Denominations</h1>
<h1 className="inter-xlarge-semibold">Edit Denomination</h1>
</Modal.Header>
<form onSubmit={onSubmit}>
<Modal.Content>
@@ -6,13 +6,18 @@ import OrganizeForm, {
OrganizeFormType,
} from "../../forms/product/organize-form"
import Button from "../../fundamentals/button"
import Modal from "../../molecules/modal"
import { Product } from "@medusajs/medusa"
import { nestedForm } from "../../../utils/nested-form"
import useEditProductActions from "../../../hooks/use-edit-product-actions"
import { useEffect } from "react"
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 = {
product: Product
@@ -24,6 +29,7 @@ type GeneralFormWrapper = {
general: GeneralFormType
organize: OrganizeFormType
discountable: DiscountableFormType
metadata: MetadataFormType
}
const GeneralModal = ({ product, open, onClose }: Props) => {
@@ -76,6 +82,7 @@ const GeneralModal = ({ product, open, onClose }: Props) => {
categories: data.organize.categories?.map((id) => ({ id })),
discountable: data.discountable.value,
metadata: getSubmittableMetadata(data.metadata),
},
onReset
)
@@ -105,6 +112,10 @@ const GeneralModal = ({ product, open, onClose }: Props) => {
form={nestedForm(form, "discountable")}
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.Footer>
<div className="flex w-full justify-end gap-x-2">
@@ -155,6 +166,7 @@ const getDefaultValues = (product: Product): GeneralFormWrapper => {
discountable: {
value: product.discountable,
},
metadata: getMetadataFormValues(product.metadata),
}
}
@@ -141,6 +141,18 @@ const ProductDetails = ({ product }: Props) => {
title="Discountable"
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>
)
}
@@ -1,15 +1,16 @@
import { AdminPostProductsProductVariantsReq, Product } from "@medusajs/medusa"
import { useContext, useEffect } from "react"
import { useEffect } from "react"
import EditFlowVariantForm, {
EditFlowVariantFormType,
} from "../../forms/product/variant-form/edit-flow-variant-form"
import LayeredModal, {
LayeredModalContext,
useLayeredModal,
} from "../../molecules/modal/layered-modal"
import { useMedusa } from "medusa-react"
import { useForm } from "react-hook-form"
import useEditProductActions from "../../../hooks/use-edit-product-actions"
import { getSubmittableMetadata } from "../../forms/general/metadata-form"
import Button from "../../fundamentals/button"
import Modal from "../../molecules/modal"
@@ -20,7 +21,8 @@ type Props = {
}
const AddVariantModal = ({ open, onClose, product }: Props) => {
const context = useContext(LayeredModalContext)
const context = useLayeredModal()
const { client } = useMedusa()
const form = useForm<EditFlowVariantFormType>({
defaultValues: getDefaultValues(product),
@@ -160,6 +162,10 @@ const getDefaultValues = (product: Product): EditFlowVariantFormType => {
hs_code: null,
origin_country: null,
},
metadata: {
entries: [],
deleted: [],
},
}
}
@@ -192,6 +198,7 @@ export const createAddPayload = (
: null,
// @ts-ignore
prices: priceArray,
metadata: getSubmittableMetadata(data.metadata),
title: data.general.title || `${options?.map((o) => o.value).join(" / ")}`,
options: options.map((option) => ({
option_id: option.id,
@@ -11,6 +11,7 @@ import { useContext } from "react"
import { useForm } from "react-hook-form"
import useEditProductActions from "../../../hooks/use-edit-product-actions"
import { countries } from "../../../utils/countries"
import { getMetadataFormValues } from "../../forms/general/metadata-form"
import Button from "../../fundamentals/button"
import Modal from "../../molecules/modal"
import { createAddPayload } from "./add-variant-modal"
@@ -204,6 +205,7 @@ export const getEditVariantDefaultValues = (
})),
},
options,
metadata: getMetadataFormValues(variant.metadata),
}
}
@@ -10,6 +10,7 @@ import EditFlowVariantForm, {
import { useForm } from "react-hook-form"
import useEditProductActions from "../../../../hooks/use-edit-product-actions"
import { getSubmittableMetadata } from "../../../forms/general/metadata-form"
import Button from "../../../fundamentals/button"
import Modal from "../../../molecules/modal"
import { LayeredModalContext } from "../../../molecules/modal/layered-modal"
@@ -117,6 +118,7 @@ export const createUpdatePayload = (
...stock,
...dimensions,
...customs,
metadata: getSubmittableMetadata(data.metadata),
// @ts-ignore
origin_country: customs?.origin_country
? 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))
}
}, [product, reset])
const resetAndClose = () => {
handleFormReset()
@@ -93,7 +93,7 @@ const EditVariantsModal = ({ open, onClose, product }: Props) => {
useEffect(() => {
handleFormReset()
}, [product])
}, [product, handleFormReset])
const onSubmit = handleSubmit((data) => {
onUpdate(
@@ -28,7 +28,7 @@ function RawJSON(props: RawJSONProps) {
return (
<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} />
</div>
</BodyCard>
@@ -1,7 +1,8 @@
import { Controller } from "react-hook-form"
import { Option } from "../../types/shared"
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 { NextSelect } from "../molecules/select/next-select"
@@ -16,6 +17,7 @@ export type AddressPayload = {
country_code: Option
postal_code: string
phone: string | null
metadata: MetadataFormType
}
export enum AddressType {
@@ -173,6 +175,10 @@ const AddressForm = ({
}}
/>
</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>
)
}
@@ -3,15 +3,20 @@ import {
useAdminCreateCollection,
useAdminUpdateCollection,
} from "medusa-react"
import React, { useEffect, useState } from "react"
import React, { useEffect } from "react"
import { useForm } from "react-hook-form"
import useNotification from "../../../hooks/use-notification"
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 IconTooltip from "../../molecules/icon-tooltip"
import InputField from "../../molecules/input"
import Modal from "../../molecules/modal"
import Metadata, { MetadataField } from "../../organisms/metadata"
import { MetadataField } from "../../organisms/metadata"
type CollectionModalProps = {
onClose: () => void
@@ -23,6 +28,7 @@ type CollectionModalProps = {
type CollectionModalFormData = {
title: string
handle: string | undefined
metadata: MetadataFormType
}
const CollectionModal: React.FC<CollectionModalProps> = ({
@@ -35,51 +41,54 @@ const CollectionModal: React.FC<CollectionModalProps> = ({
)
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 [metadata, setMetadata] = useState<MetadataField[]>([])
if (isEdit && !collection) {
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) => {
if (isEdit) {
update(
{
title: data.title,
handle: data.handle,
metadata: metadata.reduce((acc, next) => {
return {
...acc,
[next.key]: next.value,
}
}, {}),
metadata: getSubmittableMetadata(data.metadata),
},
{
onSuccess: () => {
@@ -100,12 +109,7 @@ const CollectionModal: React.FC<CollectionModalProps> = ({
{
title: data.title,
handle: data.handle,
metadata: metadata.reduce((acc, next) => {
return {
...acc,
[next.key]: next.value,
}
}, {}),
metadata: getSubmittableMetadata(data.metadata),
},
{
onSuccess: () => {
@@ -159,8 +163,9 @@ const CollectionModal: React.FC<CollectionModalProps> = ({
/>
</div>
</div>
<div className="mt-xlarge w-full">
<Metadata setMetadata={setMetadata} metadata={metadata} />
<div className="mt-xlarge">
<h2 className="inter-base-semibold mb-base">Metadata</h2>
<MetadataForm form={nestedForm(form, "metadata")} />
</div>
</Modal.Content>
<Modal.Footer>
@@ -1,6 +1,5 @@
import { CustomerGroup } from "@medusajs/medusa"
import { useAdminCustomerGroups } from "medusa-react"
import { useContext } from "react"
import { useNavigate } from "react-router-dom"
import {
HeaderGroup,
@@ -11,13 +10,11 @@ import {
useSortBy,
useTable,
} from "react-table"
import CustomerGroupContext, {
CustomerGroupContextContainer,
} from "../../../domain/customers/groups/context/customer-group-context"
import useQueryFilters from "../../../hooks/use-query-filters"
import useSetSearchParams from "../../../hooks/use-set-search-params"
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 TableContainer from "../../organisms/table-container"
import { CUSTOMER_GROUPS_TABLE_COLUMNS } from "./config"
@@ -92,19 +89,19 @@ function CustomerGroupsTableRow(props: CustomerGroupsTableRowProps) {
const { row } = props
const navigate = useNavigate()
const { showModal } = useContext(CustomerGroupContext)
const actions = [
{
label: "Edit",
onClick: showModal,
icon: <EditIcon size={20} />,
},
const actions: ActionType[] = [
{
label: "Details",
onClick: () => navigate(row.original.id),
icon: <DetailsIcon size={20} />,
},
{
label: "Delete",
onClick: () => {},
icon: <TrashIcon size={20} />,
variant: "danger",
},
]
return (
@@ -223,11 +220,7 @@ function CustomerGroupsTable(props: CustomerGroupsTableProps) {
<Table.Body {...table.getTableBodyProps()}>
{table.rows.map((row) => {
table.prepareRow(row)
return (
<CustomerGroupContextContainer key={row.id} group={row.original}>
<CustomerGroupsTableRow row={row} />
</CustomerGroupContextContainer>
)
return <CustomerGroupsTableRow row={row} key={row.id} />
})}
</Table.Body>
</Table>
@@ -73,7 +73,7 @@ export const useCustomerOrdersColumns = (): Column<Order>[] => {
}
return (
<div className="gap-x-2xsmall flex">
<div className="gap-x-2xsmall flex items-center">
<div ref={containerRef} className="gap-x-xsmall flex">
{visibleItems.map((item) => {
return (
@@ -2,12 +2,18 @@ import { Customer } from "@medusajs/medusa"
import { useAdminUpdateCustomer } from "medusa-react"
import { useEffect } from "react"
import { useForm } from "react-hook-form"
import MetadataForm, {
getMetadataFormValues,
getSubmittableMetadata,
MetadataFormType,
} from "../../../components/forms/general/metadata-form"
import Button from "../../../components/fundamentals/button"
import LockIcon from "../../../components/fundamentals/icons/lock-icon"
import InputField from "../../../components/molecules/input"
import Modal from "../../../components/molecules/modal"
import useNotification from "../../../hooks/use-notification"
import { getErrorMessage } from "../../../utils/error-messages"
import { nestedForm } from "../../../utils/nested-form"
import { validateEmail } from "../../../utils/validate-email"
type EditCustomerModalProps = {
@@ -20,20 +26,23 @@ type EditCustomerFormType = {
last_name: string
email: string
phone: string | null
metadata: MetadataFormType
}
const EditCustomerModal = ({
handleClose,
customer,
}: EditCustomerModalProps) => {
const form = useForm<EditCustomerFormType>({
defaultValues: getDefaultValues(customer),
})
const {
register,
reset,
handleSubmit,
formState: { isDirty },
} = useForm<EditCustomerFormType>({
defaultValues: getDefaultValues(customer),
})
} = form
const notification = useNotification()
@@ -47,6 +56,7 @@ const EditCustomerModal = ({
// @ts-ignore
phone: data.phone,
email: data.email,
metadata: getSubmittableMetadata(data.metadata),
},
{
onSuccess: () => {
@@ -72,48 +82,59 @@ const EditCustomerModal = ({
<span className="inter-xlarge-semibold">Customer Details</span>
</Modal.Header>
<Modal.Content>
<div className="inter-base-semibold text-grey-90 mb-4">General</div>
<div className="mb-4 flex w-full space-x-2">
<InputField
label="First Name"
{...register("first_name")}
placeholder="Lebron"
/>
<InputField
label="Last Name"
{...register("last_name")}
placeholder="James"
/>
</div>
<div className="inter-base-semibold text-grey-90 mb-4">Contact</div>
<div className="flex space-x-2">
<InputField
label="Email"
{...register("email", {
validate: (value) => !!validateEmail(value),
disabled: customer.has_account,
})}
prefix={
customer.has_account && (
<LockIcon size={16} className="text-grey-50" />
)
}
disabled={customer.has_account}
/>
<InputField
label="Phone number"
{...register("phone")}
placeholder="+45 42 42 42 42"
/>
<div className="gap-y-xlarge flex flex-col">
<div>
<h2 className="inter-base-semibold text-grey-90 mb-4">General</h2>
<div className="flex w-full space-x-2">
<InputField
label="First Name"
{...register("first_name")}
placeholder="Lebron"
/>
<InputField
label="Last Name"
{...register("last_name")}
placeholder="James"
/>
</div>
</div>
<div>
<h2 className="inter-base-semibold text-grey-90 mb-4">Contact</h2>
<div className="flex space-x-2">
<InputField
label="Email"
{...register("email", {
validate: (value) => !!validateEmail(value),
disabled: customer.has_account,
})}
prefix={
customer.has_account && (
<LockIcon size={16} className="text-grey-50" />
)
}
disabled={customer.has_account}
/>
<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>
</Modal.Content>
<Modal.Footer>
<div className="flex w-full justify-end">
<Button
variant="ghost"
variant="secondary"
size="small"
onClick={handleClose}
className="mr-2"
type="button"
>
Cancel
</Button>
@@ -121,11 +142,10 @@ const EditCustomerModal = ({
loading={updateCustomer.isLoading}
disabled={!isDirty || updateCustomer.isLoading}
variant="primary"
className="min-w-[100px]"
size="small"
onClick={onSubmit}
>
Save
Save and close
</Button>
</div>
</Modal.Footer>
@@ -140,6 +160,7 @@ const getDefaultValues = (customer: Customer): EditCustomerFormType => {
email: customer.email,
last_name: customer.last_name,
phone: customer.phone,
metadata: getMetadataFormValues(customer.metadata),
}
}
@@ -3,16 +3,16 @@ import moment from "moment"
import { useState } from "react"
import { useParams } from "react-router-dom"
import Avatar from "../../../components/atoms/avatar"
import BackButton from "../../../components/atoms/back-button"
import Spinner from "../../../components/atoms/spinner"
import EditIcon from "../../../components/fundamentals/icons/edit-icon"
import TrashIcon from "../../../components/fundamentals/icons/trash-icon"
import StatusDot from "../../../components/fundamentals/status-indicator"
import Actionables, {
ActionType,
} from "../../../components/molecules/actionables"
import Breadcrumb from "../../../components/molecules/breadcrumb"
import BodyCard from "../../../components/organisms/body-card"
import RawJSON from "../../../components/organisms/raw-json"
import Section from "../../../components/organisms/section"
import CustomerOrdersTable from "../../../components/templates/customer-orders-table"
import EditCustomerModal from "./edit"
@@ -36,87 +36,87 @@ const CustomerDetail = () => {
onClick: () => setShowEdit(true),
icon: <EditIcon size={20} />,
},
{
label: "Delete (not implemented yet)",
onClick: () => console.log("TODO: delete customer"),
variant: "danger",
icon: <TrashIcon size={20} />,
},
]
return (
<div>
<Breadcrumb
currentPage={"Customer Details"}
previousBreadcrumb={"Customers"}
previousRoute="/a/customers"
<BackButton
label="Back to Customers"
path="/a/customers"
className="mb-xsmall"
/>
<BodyCard className={"relative mb-4 h-auto w-full pt-[100px]"}>
<div className="from-fuschia-20 absolute inset-x-0 top-0 z-0 h-[120px] w-full bg-gradient-to-b" />
<div className="flex grow flex-col overflow-y-auto">
<div className="mb-4 h-[64px] w-[64px]">
<Avatar
user={customer}
font="inter-2xlarge-semibold"
color="bg-fuschia-40"
/>
</div>
<div className="flex items-center justify-between">
<h1 className="inter-xlarge-semibold text-grey-90 max-w-[50%] truncate">
{customerName()}
</h1>
<Actionables actions={actions} />
</div>
<h3 className="inter-small-regular text-grey-50 pt-1.5">
{customer?.email}
</h3>
</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 className="gap-y-small flex flex-col">
<Section>
<div className="flex w-full items-start justify-between">
<div className="gap-x-base flex w-full items-center">
<div className="h-[64px] w-[64px]">
<Avatar
user={customer}
font="inter-2xlarge-semibold w-full h-full"
color="bg-fuschia-40"
/>
</div>
<div className="flex grow flex-col">
<h1 className="inter-xlarge-semibold text-grey-90 max-w-[50%] truncate">
{customerName()}
</h1>
<h3 className="inter-small-regular text-grey-50">
{customer?.email}
</h3>
</div>
</div>
<div>{moment(customer?.created_at).format("DD MMM YYYY")}</div>
<Actionables actions={actions} forceDropdown />
</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 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>{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 className="flex flex-col pl-6">
<div className="inter-smaller-regular text-grey-50 mb-1">
Orders
</Section>
<BodyCard
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>{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 ? "True" : "False"}
/>
) : (
<div className="flex grow flex-col">
<CustomerOrdersTable id={customer.id} />
</div>
</div>
</div>
</BodyCard>
<BodyCard
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" />
)}
</BodyCard>
<RawJSON data={customer} title="Raw customer" />
</div>
{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 {
AdminPostCustomerGroupsGroupReq,
AdminPostCustomerGroupsReq,
CustomerGroup,
} from "@medusajs/medusa"
import { useState } from "react"
useAdminCreateCustomerGroup,
useAdminUpdateCustomerGroup,
} from "medusa-react"
import { useEffect } from "react"
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 Input from "../../../components/molecules/input"
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 = {
handleClose: () => void
initialData?: CustomerGroup
handleSubmit: (
data: AdminPostCustomerGroupsReq | AdminPostCustomerGroupsGroupReq
) => void
open: boolean
customerGroup?: CustomerGroup
onClose: () => void
}
type CustomerGroupModalFormType = {
general: CustomerGroupGeneralFormType
metadata: MetadataFormType
}
/*
* A modal for crating/editing customer groups.
*/
function CustomerGroupModal(props: CustomerGroupModalProps) {
const { initialData, handleSubmit, handleClose } = props
const isEdit = !!initialData
const [metadata, setMetadata] = useState<MetadataField[]>(
isEdit
? Object.keys(initialData.metadata || {}).map((k) => ({
key: k,
value: initialData.metadata[k],
}))
: []
)
const { register, handleSubmit: handleFromSubmit } = useForm({
defaultValues: initialData,
function CustomerGroupModal({
customerGroup,
onClose,
open,
}: CustomerGroupModalProps) {
const form = useForm<CustomerGroupModalFormType>({
defaultValues: getDefaultValues(customerGroup),
})
const onSubmit = (data) => {
const meta = {}
const initial = props.initialData?.metadata || {}
const { mutate: update, isLoading: isUpdating } = useAdminUpdateCustomerGroup(
customerGroup?.id!
)
const { mutate: create, isLoading: isCreating } =
useAdminCreateCustomerGroup()
metadata.forEach((m) => (meta[m.key] = m.value))
const notification = useNotification()
for (const m in initial) {
if (!(m in meta)) {
meta[m] = null
}
const { reset, handleSubmit: handleFormSubmit } = form
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 = {
name: data.name,
metadata: meta,
const onError = (err: Error) => {
notification("Error", getErrorMessage(err), "error")
}
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 (
<Modal handleClose={handleClose}>
<Modal open={open} handleClose={onClose}>
<Modal.Body>
<Modal.Header handleClose={handleClose}>
<Modal.Header handleClose={onClose}>
<span className="inter-xlarge-semibold">
{props.initialData ? "Edit" : "Create a New"} Customer Group
{customerGroup ? "Edit" : "Create a New"} Customer Group
</span>
</Modal.Header>
<Modal.Content>
<div className="space-y-4">
<span className="inter-base-semibold">Details</span>
<div className="flex space-x-4">
<Input
label="Title"
{...register("name")}
placeholder="Customer group name"
required
/>
<form onSubmit={onSubmit}>
<Modal.Content>
<div className="gap-y-xlarge flex flex-col">
<div>
<h2 className="inter-base-semibold mb-base">Details</h2>
<CustomerGroupGeneralForm form={nestedForm(form, "general")} />
</div>
<div>
<h2 className="inter-base-semibold mb-base">Metadata</h2>
<MetadataForm form={nestedForm(form, "metadata")} />
</div>
</div>
</div>
</Modal.Content>
<div className="mt-8">
<Metadata metadata={metadata} setMetadata={setMetadata} />
</div>
</Modal.Content>
<Modal.Footer>
<div className="flex h-8 w-full justify-end">
<Button
variant="ghost"
className="text-small mr-2 w-32 justify-center"
size="large"
onClick={handleClose}
>
Cancel
</Button>
<Button
size="medium"
className="text-small w-32 justify-center"
variant="primary"
onClick={handleFromSubmit(onSubmit)}
>
<span>{props.initialData ? "Edit" : "Publish"} Group</span>
</Button>
</div>
</Modal.Footer>
<Modal.Footer>
<div className="gap-x-xsmall flex w-full justify-end">
<Button
variant="secondary"
className="text-small mr-2 w-32 justify-center"
size="small"
type="button"
onClick={onClose}
>
Cancel
</Button>
<Button size="small" variant="primary" type="submit">
<span>{customerGroup ? "Edit" : "Publish"} Group</span>
</Button>
</div>
</Modal.Footer>
</form>
</Modal.Body>
</Modal>
)
}
const getDefaultValues = (
initialData?: CustomerGroup
): CustomerGroupModalFormType | undefined => {
if (!initialData) {
return undefined
}
return {
general: {
name: initialData.name,
},
metadata: getMetadataFormValues(initialData.metadata),
}
}
export default CustomerGroupModal
@@ -1,6 +1,5 @@
import { useContext, useEffect, useState } from "react"
import { difference } from "lodash"
import { CustomerGroup } from "@medusajs/medusa"
import { difference } from "lodash"
import {
useAdminAddCustomersToCustomerGroup,
useAdminCustomerGroup,
@@ -8,20 +7,21 @@ import {
useAdminDeleteCustomerGroup,
useAdminRemoveCustomersFromCustomerGroup,
} 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 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.
@@ -164,7 +164,6 @@ type CustomerGroupDetailsHeaderProps = {
* Customers groups details page header.
*/
function CustomerGroupDetailsHeader(props: CustomerGroupDetailsHeaderProps) {
const { showModal } = useContext(CustomerGroupContext)
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false)
const navigate = useNavigate()
@@ -173,10 +172,12 @@ function CustomerGroupDetailsHeader(props: CustomerGroupDetailsHeaderProps) {
props.customerGroup.id
)
const actions = [
const { state, close, open } = useToggleState()
const actions: ActionType[] = [
{
label: "Edit",
onClick: showModal,
onClick: open,
icon: <EditIcon size={20} />,
},
{
@@ -214,6 +215,11 @@ function CustomerGroupDetailsHeader(props: CustomerGroupDetailsHeaderProps) {
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 (
<CustomerGroupContextContainer group={customer_group}>
<div className="-mt-4 pb-4">
<BackButton
path="/a/customers/groups"
label="Back to customer groups"
className="mb-4"
/>
<CustomerGroupDetailsHeader customerGroup={customer_group} />
<CustomerGroupCustomersList group={customer_group} />
</div>
</CustomerGroupContextContainer>
<div className="-mt-4 pb-4">
<BackButton
path="/a/customers/groups"
label="Back to customer groups"
className="mb-4"
/>
<CustomerGroupDetailsHeader customerGroup={customer_group} />
<CustomerGroupCustomersList group={customer_group} />
</div>
)
}
@@ -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 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
*/
function Index() {
const { showModal } = useContext(CustomerGroupContext)
const { state, open, close } = useToggleState()
const actions = [
{
label: "New group",
onClick: showModal,
onClick: open,
icon: (
<span className="text-grey-90">
<PlusIcon size={20} />
@@ -28,16 +26,18 @@ function Index() {
]
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
actionables={actions}
className="h-auto"
customHeader={<CustomersPageTableHeader activeView="groups" />}
>
<CustomerGroupsTable />
</BodyCard>
</div>
</div>
<CustomerGroupModal open={state} onClose={close} />
</>
)
}
@@ -46,12 +46,10 @@ function Index() {
*/
function CustomerGroups() {
return (
<CustomerGroupContextContainer>
<Routes>
<Route index element={<Index />} />
<Route path="/:id" element={<Details />} />
</Routes>
</CustomerGroupContextContainer>
<Routes>
<Route index element={<Index />} />
<Route path="/:id" element={<Details />} />
</Routes>
)
}
@@ -1,53 +1,55 @@
import { Discount } from "@medusajs/medusa"
import { useAdminUpdateDiscount } from "medusa-react"
import React, { useEffect } from "react"
import { Controller, useForm } from "react-hook-form"
import DatePicker from "../../../../components/atoms/date-picker/date-picker"
import TimePicker from "../../../../components/atoms/date-picker/time-picker"
import { useForm } from "react-hook-form"
import DiscountConfigurationForm, {
DiscountConfigurationFormType,
} from "../../../../components/forms/discount/discount-configuration-form"
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 SwitchableItem from "../../../../components/molecules/switchable-item"
import useNotification from "../../../../hooks/use-notification"
import { getErrorMessage } from "../../../../utils/error-messages"
import { nestedForm } from "../../../../utils/nested-form"
type EditConfigurationsProps = {
discount: Discount
onClose: () => void
open: boolean
}
type ConfigurationsForm = {
starts_at: Date | null
ends_at: Date | null
usage_limit: number | null
valid_duration: string | null
config: DiscountConfigurationFormType
}
const EditConfigurations: React.FC<EditConfigurationsProps> = ({
discount,
onClose,
open,
}) => {
const { mutate, isLoading } = useAdminUpdateDiscount(discount.id)
const notification = useNotification()
const { control, handleSubmit, reset } = useForm<ConfigurationsForm>({
defaultValues: mapConfigurations(discount),
const form = useForm<ConfigurationsForm>({
defaultValues: getDefaultValues(discount),
})
const onSubmit = (data: ConfigurationsForm) => {
const { handleSubmit, reset } = form
const onSubmit = handleSubmit((data) => {
mutate(
{
starts_at: data.starts_at ?? new Date(),
ends_at: data.ends_at,
starts_at: data.config.starts_at ?? new Date(),
ends_at: data.config.ends_at,
usage_limit:
data.usage_limit && data.usage_limit > 0 ? data.usage_limit : null,
valid_duration: data.valid_duration,
data.config.usage_limit && data.config.usage_limit > 0
? data.config.usage_limit
: null,
valid_duration: data.config.valid_duration,
},
{
onSuccess: ({ discount }) => {
notification("Success", "Discount updated successfully", "success")
reset(mapConfigurations(discount))
reset(getDefaultValues(discount))
onClose()
},
onError: (error) => {
@@ -55,151 +57,23 @@ const EditConfigurations: React.FC<EditConfigurationsProps> = ({
},
}
)
}
})
useEffect(() => {
reset(mapConfigurations(discount))
}, [discount])
if (open) {
reset(getDefaultValues(discount))
}
}, [discount, reset, open])
return (
<Modal handleClose={onClose} isLargeModal>
<Modal open={open} handleClose={onClose} isLargeModal>
<Modal.Body>
<Modal.Header handleClose={onClose}>
<h1 className="inter-xlarge-semibold">Edit configurations</h1>
</Modal.Header>
<form onSubmit={handleSubmit(onSubmit)}>
<form onSubmit={onSubmit}>
<Modal.Content>
<div className="gap-y-xlarge flex flex-col">
<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>
<DiscountConfigurationForm form={nestedForm(form, "config")} />
</Modal.Content>
<Modal.Footer>
<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 {
starts_at: new Date(discount.starts_at),
ends_at: discount.ends_at ? new Date(discount.ends_at) : null,
usage_limit: discount.usage_limit,
valid_duration: discount.valid_duration,
config: {
starts_at: new Date(discount.starts_at),
ends_at: discount.ends_at ? new Date(discount.ends_at) : null,
usage_limit: discount.usage_limit,
valid_duration: discount.valid_duration,
},
}
}
@@ -1,8 +1,9 @@
import { Discount } from "@medusajs/medusa"
import React, { useState } from "react"
import React from "react"
import EditIcon from "../../../../components/fundamentals/icons/edit-icon"
import NumberedItem from "../../../../components/molecules/numbered-item"
import BodyCard from "../../../../components/organisms/body-card"
import useToggleState from "../../../../hooks/use-toggle-state"
import EditConfigurations from "./edit-configurations"
import useDiscountConfigurations from "./use-discount-configurations"
@@ -12,7 +13,7 @@ type ConfigurationsProps = {
const Configurations: React.FC<ConfigurationsProps> = ({ discount }) => {
const configurations = useDiscountConfigurations(discount)
const [showModal, setShowModal] = useState(false)
const { state, open, close } = useToggleState()
return (
<>
@@ -22,7 +23,7 @@ const Configurations: React.FC<ConfigurationsProps> = ({ discount }) => {
actionables={[
{
label: "Edit configurations",
onClick: () => setShowModal(true),
onClick: open,
icon: <EditIcon size={20} />,
},
]}
@@ -47,12 +48,8 @@ const Configurations: React.FC<ConfigurationsProps> = ({ discount }) => {
))}
</div>
</BodyCard>
{showModal && (
<EditConfigurations
discount={discount}
onClose={() => setShowModal(false)}
/>
)}
<EditConfigurations discount={discount} onClose={close} open={state} />
</>
)
}
@@ -1,53 +1,62 @@
import { Discount } from "@medusajs/medusa"
import { useAdminRegions, useAdminUpdateDiscount } from "medusa-react"
import React, { useEffect, useMemo } from "react"
import { Controller, useForm, useWatch } from "react-hook-form"
import { useAdminUpdateDiscount } from "medusa-react"
import React, { useEffect } from "react"
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 InputField from "../../../../components/molecules/input"
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 { Option } from "../../../../types/shared"
import { getErrorMessage } from "../../../../utils/error-messages"
import { nestedForm } from "../../../../utils/nested-form"
type EditGeneralProps = {
discount: Discount
open: boolean
onClose: () => void
}
type GeneralForm = {
regions: Option[]
code: string
description: string
value: number
general: DiscountGeneralFormType
metadata: MetadataFormType
}
const EditGeneral: React.FC<EditGeneralProps> = ({ discount, onClose }) => {
const EditGeneral: React.FC<EditGeneralProps> = ({
discount,
open,
onClose,
}) => {
const { mutate, isLoading } = useAdminUpdateDiscount(discount.id)
const notification = useNotification()
const { control, handleSubmit, reset, register } = useForm<GeneralForm>({
defaultValues: mapGeneral(discount),
const form = useForm<GeneralForm>({
defaultValues: getDefaultValues(discount),
})
const onSubmit = (data: GeneralForm) => {
const { handleSubmit, reset } = form
const onSubmit = handleSubmit((data) => {
mutate(
{
regions: data.regions.map((r) => r.value),
code: data.code,
regions: data.general.region_ids.map((r) => r.value),
code: data.general.code,
rule: {
id: discount.rule.id,
description: data.description,
value: data.value,
description: data.general.description,
value: data.general.value,
allocation: discount.rule.allocation,
},
metadata: getSubmittableMetadata(data.metadata),
},
{
onSuccess: ({ discount }) => {
onSuccess: () => {
notification("Success", "Discount updated successfully", "success")
reset(mapGeneral(discount))
onClose()
},
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(() => {
if (type === "fixed" && selectedRegions?.length) {
return regions?.find((r) => r.id === selectedRegions[0].value)
?.currency_code
useEffect(() => {
if (open) {
reset(getDefaultValues(discount))
}
}, [selectedRegions, type, regions])
}, [discount, reset, open])
return (
<Modal handleClose={onClose}>
<form onSubmit={handleSubmit(onSubmit)}>
<Modal.Body>
<Modal.Header handleClose={onClose}>
<h1 className="inter-xlarge-semibold">Edit general information</h1>
</Modal.Header>
<Modal open={open} handleClose={onClose}>
<Modal.Body>
<Modal.Header handleClose={onClose}>
<h1 className="inter-xlarge-semibold">Edit general information</h1>
</Modal.Header>
<form onSubmit={onSubmit}>
<Modal.Content>
<Controller
name="regions"
control={control}
rules={{
required: "Atleast one region is required",
validate: (value) =>
Array.isArray(value) ? value.length > 0 : !!value,
}}
render={({ field: { value, onChange } }) => {
return (
<NextSelect
value={value}
onChange={(value) => {
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 className="gap-y-xlarge flex flex-col">
<div>
<h2 className="inter-base-semibold mb-base">Details</h2>
<DiscountGeneralForm
form={nestedForm(form, "general")}
type={discount.rule.type}
isEdit
/>
</div>
<div>
<h2 className="inter-base-semibold mb-base">Metadata</h2>
<MetadataForm form={nestedForm(form, "metadata")} />
</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 customers 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.Footer>
<div className="gap-x-xsmall flex w-full items-center justify-end">
<Button
variant="ghost"
variant="secondary"
size="small"
className="min-w-[128px]"
type="button"
onClick={onClose}
>
@@ -209,27 +108,33 @@ const EditGeneral: React.FC<EditGeneralProps> = ({ discount, onClose }) => {
<Button
variant="primary"
size="small"
className="min-w-[128px]"
type="submit"
disabled={isLoading}
loading={isLoading}
>
Save
Save and close
</Button>
</div>
</Modal.Footer>
</Modal.Body>
</form>
</form>
</Modal.Body>
</Modal>
)
}
const mapGeneral = (discount: Discount): GeneralForm => {
const getDefaultValues = (discount: Discount): GeneralForm | undefined => {
return {
regions: discount.regions.map((r) => ({ label: r.name, value: r.id })),
code: discount.code,
description: discount.rule.description,
value: discount.rule.value,
general: {
code: discount.code,
description: discount.rule.description,
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 { useAdminDeleteDiscount, useAdminUpdateDiscount } from "medusa-react"
import React, { useState } from "react"
import React from "react"
import { useNavigate } from "react-router-dom"
import Badge from "../../../../components/fundamentals/badge"
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 useImperativeDialog from "../../../../hooks/use-imperative-dialog"
import useNotification from "../../../../hooks/use-notification"
import useToggleState from "../../../../hooks/use-toggle-state"
import { getErrorMessage } from "../../../../utils/error-messages"
import { formatAmountWithSymbol } from "../../../../utils/prices"
import EditGeneral from "./edit-general"
@@ -24,7 +25,6 @@ const General: React.FC<GeneralProps> = ({ discount }) => {
const notification = useNotification()
const updateDiscount = useAdminUpdateDiscount(discount.id)
const deletediscount = useAdminDeleteDiscount(discount.id)
const [showmModal, setShowModal] = useState(false)
const onDelete = async () => {
const shouldDelete = await dialog({
@@ -65,10 +65,12 @@ const General: React.FC<GeneralProps> = ({ discount }) => {
)
}
const { state, open, close } = useToggleState()
const actionables: ActionType[] = [
{
label: "Edit general information",
onClick: () => setShowModal(true),
onClick: open,
icon: <EditIcon size={20} />,
},
{
@@ -132,9 +134,8 @@ const General: React.FC<GeneralProps> = ({ discount }) => {
</div>
</div>
</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">
<span className="inter-base-regular text-grey-40">
Can't add anymore conditions
You cannot add any more conditions
</span>
</div>
)}
@@ -1,5 +1,10 @@
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 {
AllocationType,
ConditionMap,
@@ -176,6 +181,7 @@ export const DiscountFormProvider = ({
updateCondition,
setConditions,
handleReset,
form: methods,
}}
>
{children}
@@ -196,6 +202,7 @@ const DiscountFormContext = React.createContext<{
updateCondition: (props: UpdateConditionProps) => void
setConditions: Dispatch<SetStateAction<ConditionMap>>
handleReset: () => void
form: UseFormReturn<DiscountFormValues>
} | null>(null)
export const useDiscountForm = () => {
@@ -1,10 +1,9 @@
import {
AdminPostDiscountsDiscountReq,
AdminPostDiscountsReq,
AdminUpsertCondition,
Discount,
} from "@medusajs/medusa"
import { AdminPostDiscountsReq, AdminUpsertCondition } from "@medusajs/medusa"
import { FieldValues } from "react-hook-form"
import {
getSubmittableMetadata,
MetadataFormType,
} from "../../../../../components/forms/general/metadata-form"
import { Option } from "../../../../../types/shared"
import { AllocationType, ConditionMap, DiscountRuleType } from "../../../types"
@@ -22,6 +21,7 @@ export interface DiscountFormValues extends FieldValues {
is_dynamic: boolean
valid_duration: string | null
regions?: Option[]
metadata: MetadataFormType
}
export enum DiscountConditionType {
@@ -32,26 +32,6 @@ export enum DiscountConditionType {
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 conditions: AdminUpsertCondition[] = []
@@ -89,7 +69,7 @@ export const formValuesToCreateDiscountMapper = (
},
is_dynamic: values.is_dynamic,
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,
usage_limit:
values.usage_limit && values.usage_limit > 0
@@ -99,52 +79,6 @@ export const formValuesToCreateDiscountMapper = (
values.is_dynamic && values.valid_duration?.length
? values.valid_duration
: undefined,
}
}
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,
metadata: getSubmittableMetadata(values.metadata),
}
}
@@ -1,11 +1,13 @@
import { useWatch } from "react-hook-form"
import { useNavigate } from "react-router-dom"
import MetadataForm from "../../../../components/forms/general/metadata-form"
import Button from "../../../../components/fundamentals/button"
import CrossIcon from "../../../../components/fundamentals/icons/cross-icon"
import FocusModal from "../../../../components/molecules/modal/focus-modal"
import Accordion from "../../../../components/organisms/accordion"
import useNotification from "../../../../hooks/use-notification"
import { getErrorMessage } from "../../../../utils/error-messages"
import { nestedForm } from "../../../../utils/nested-form"
import { DiscountRuleType } from "../../types"
import { useDiscountForm } from "./form/discount-form-context"
import { DiscountFormValues } from "./form/mappers"
@@ -23,7 +25,7 @@ type DiscountFormProps = {
const DiscountForm = ({ closeForm }: DiscountFormProps) => {
const navigate = useNavigate()
const notification = useNotification()
const { handleSubmit, handleReset, control } = useDiscountForm()
const { handleSubmit, handleReset, control, form } = useDiscountForm()
const { onSaveAsActive, onSaveAsInactive } = useFormActions()
@@ -145,6 +147,16 @@ const DiscountForm = ({ closeForm }: DiscountFormProps) => {
>
<DiscountNewConditions />
</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>
</div>
</div>
@@ -5,13 +5,22 @@ import {
Country,
} from "@medusajs/medusa"
import { MutateOptions } from "@tanstack/react-query"
import { useEffect } from "react"
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 Modal from "../../../components/molecules/modal"
import AddressForm, {
AddressPayload,
AddressType,
} from "../../../components/templates/address-form"
import { AddressType } from "../../../components/templates/address-form"
import useNotification from "../../../hooks/use-notification"
import { isoAlpha2Countries } from "../../../utils/countries"
import { getErrorMessage } from "../../../utils/error-messages"
@@ -23,6 +32,12 @@ type AddressPayloadType =
| AdminPostDraftOrdersReq["shipping_address"]
| Partial<AdminPostDraftOrdersReq["shipping_address"]>
type AddressModalFormType = {
contact: AddressContactFormType
location: AddressLocationFormType
metadata: MetadataFormType
}
type TVariables = {
shipping_address?: AddressPayloadType
billing_address?: AddressPayloadType
@@ -34,8 +49,9 @@ type MutateAction = <T extends TVariables>(
) => void
type AddressModalProps = {
handleClose: () => void
submit: MutateAction
onClose: () => void
onSave: MutateAction
open: boolean
submitting?: boolean
allowedCountries?: Country[]
address?: Address
@@ -45,85 +61,112 @@ type AddressModalProps = {
const AddressModal = ({
address,
allowedCountries = [],
handleClose,
submit,
onClose,
onSave,
open,
type,
submitting = false,
}: AddressModalProps) => {
const form = useForm<AddressPayload>({
defaultValues: mapAddressToFormData(address),
const form = useForm<AddressModalFormType>({
defaultValues: getDefaultValues(address),
})
const {
reset,
formState: { isDirty },
handleSubmit,
} = form
const notification = useNotification()
useEffect(() => {
if (open) {
reset(getDefaultValues(address))
}
}, [address, open, reset])
const countryOptions = allowedCountries
.map((c) => ({ label: c.display_name, value: c.iso_2 }))
.filter(Boolean)
const handleUpdateAddress = (data: AddressPayload) => {
const onSubmit = handleSubmit((data) => {
const updateObj: TVariables = {}
const { contact, location, metadata } = data
const countryCode = location.country_code.value
const metadataEntries = getSubmittableMetadata(metadata)
if (type === "shipping") {
// @ts-ignore
updateObj["shipping_address"] = {
...data,
country_code: data.country_code.value,
...contact,
...location,
country_code: countryCode,
metadata: metadataEntries,
}
} else {
// @ts-ignore
updateObj["billing_address"] = {
...data,
country_code: data.country_code.value,
...contact,
...location,
country_code: countryCode,
metadata: metadataEntries,
}
}
return submit(updateObj, {
return onSave(updateObj, {
onSuccess: () => {
notification("Success", "Successfully updated address", "success")
handleClose()
onClose()
},
onError: (err) => notification("Error", getErrorMessage(err), "error"),
})
}
})
return (
<Modal handleClose={handleClose} isLargeModal>
<form onSubmit={form.handleSubmit(handleUpdateAddress)}>
<Modal open={open} handleClose={onClose}>
<form onSubmit={onSubmit}>
<Modal.Body>
<Modal.Header handleClose={handleClose}>
<Modal.Header handleClose={onClose}>
<span className="inter-xlarge-semibold">
{type === AddressType.BILLING ? "Billing" : "Shipping"} Address
</span>
</Modal.Header>
<Modal.Content>
<AddressForm
form={nestedForm(form)}
countryOptions={countryOptions}
type={type}
/>
<div className="gap-y-xlarge flex flex-col">
<div>
<h2 className="inter-base-semibold mb-base">Contact</h2>
<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.Footer>
<div className="flex h-8 w-full justify-end">
<div className="gap-x-xsmall flex w-full justify-end">
<Button
variant="ghost"
className="text-small mr-2 w-32 justify-center"
size="large"
onClick={handleClose}
variant="secondary"
size="small"
onClick={onClose}
type="button"
>
Cancel
</Button>
<Button
size="large"
className="text-small w-32 justify-center"
size="small"
variant="primary"
type="submit"
loading={submitting}
disabled={submitting || !isDirty}
>
Save
Save and close
</Button>
</div>
</Modal.Footer>
@@ -133,23 +176,31 @@ const AddressModal = ({
)
}
const mapAddressToFormData = (address?: Address): AddressPayload => {
const countryDisplayName =
isoAlpha2Countries[address?.country_code?.toUpperCase()]
const getDefaultValues = (address?: Address): AddressModalFormType => {
const countryDisplayName = address?.country_code
? isoAlpha2Countries[
address.country_code.toUpperCase() as keyof typeof isoAlpha2Countries
]
: ""
return {
first_name: address?.first_name || "",
last_name: address?.last_name || "",
phone: address?.phone || null,
company: address?.company || null,
address_1: address?.address_1 || "",
address_2: address?.address_2 || null,
city: address?.city || "",
province: address?.province || null,
country_code: address?.country_code
? { label: countryDisplayName, value: address.country_code }
: { label: "", value: "" },
postal_code: address?.postal_code || "",
contact: {
first_name: address?.first_name || "",
last_name: address?.last_name || "",
phone: address?.phone || "",
company: address?.company || null,
},
location: {
address_1: address?.address_1 || "",
address_2: address?.address_2 || null,
city: address?.city || "",
province: address?.province || null,
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,
Swap,
} from "@medusajs/medusa"
import {
DisplayTotal,
FormattedAddress,
FormattedFulfillment,
FulfillmentStatusComponent,
OrderStatusComponent,
PaymentActionables,
PaymentStatusComponent,
} from "./templates"
import OrderEditProvider, { OrderEditContext } from "../edit/context"
import {
useAdminCancelOrder,
useAdminCapturePayment,
@@ -24,47 +14,57 @@ import {
useAdminUpdateOrder,
} from "medusa-react"
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 AddressModal from "./address-modal"
import { AddressType } from "../../../components/templates/address-form"
import { capitalize } from "lodash"
import moment from "moment"
import { useEffect, useMemo, useState } from "react"
import { useHotkeys } from "react-hotkeys-hook"
import Avatar from "../../../components/atoms/avatar"
import BodyCard from "../../../components/organisms/body-card"
import Breadcrumb from "../../../components/molecules/breadcrumb"
import Spinner from "../../../components/atoms/spinner"
import Tooltip from "../../../components/atoms/tooltip"
import Button from "../../../components/fundamentals/button"
import DetailsIcon from "../../../components/fundamentals/details-icon"
import CancelIcon from "../../../components/fundamentals/icons/cancel-icon"
import ClipboardCopyIcon from "../../../components/fundamentals/icons/clipboard-copy-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 EmailModal from "./email-modal"
import JSONView from "../../../components/molecules/json-view"
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 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 { capitalize } from "lodash"
import extractCustomerName from "../../../utils/extract-customer-name"
import { formatAmountWithSymbol } from "../../../utils/prices"
import { getErrorMessage } from "../../../utils/error-messages"
import { isoAlpha2Countries } from "../../../utils/countries"
import moment from "moment"
import { ActionType } from "../../../components/molecules/actionables"
import Breadcrumb from "../../../components/molecules/breadcrumb"
import JSONView from "../../../components/molecules/json-view"
import BodyCard from "../../../components/organisms/body-card"
import RawJSON from "../../../components/organisms/raw-json"
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 { useHotkeys } from "react-hotkeys-hook"
import useImperativeDialog from "../../../hooks/use-imperative-dialog"
import useNotification from "../../../hooks/use-notification"
import { useEffect, useMemo, useState } from "react"
import useToggleState from "../../../hooks/use-toggle-state"
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 = {
title: string
@@ -148,6 +148,12 @@ const OrderDetails = () => {
const capturePayment = useAdminCapturePayment(id!)
const cancelOrder = useAdminCancelOrder(id!)
const {
state: addressModalState,
close: closeAddressModal,
open: openAddressModal,
} = useToggleState()
const { mutate: updateOrder } = useAdminUpdateOrder(id!)
const { region } = useAdminRegion(order?.region_id!, {
@@ -227,11 +233,13 @@ const OrderDetails = () => {
customerActionables.push({
label: "Edit Shipping Address",
icon: <TruckIcon size={"20"} />,
onClick: () =>
onClick: () => {
setAddressModal({
address: order?.shipping_address,
type: AddressType.SHIPPING,
}),
})
openAddressModal()
},
})
customerActionables.push({
@@ -242,6 +250,7 @@ const OrderDetails = () => {
address: order?.billing_address,
type: AddressType.BILLING,
})
openAddressModal()
},
})
@@ -522,15 +531,16 @@ const OrderDetails = () => {
</div>
<Timeline orderId={order.id} />
</div>
{addressModal && (
<AddressModal
handleClose={() => setAddressModal(null)}
submit={updateOrder}
address={addressModal.address || undefined}
type={addressModal.type}
allowedCountries={region?.countries}
/>
)}
<AddressModal
onClose={closeAddressModal}
open={addressModalState}
onSave={updateOrder}
address={addressModal?.address || undefined}
type={addressModal?.type}
allowedCountries={region?.countries}
/>
{emailModal && (
<EmailModal
handleClose={() => setEmailModal(null)}
@@ -472,8 +472,8 @@ const DraftOrderDetails = () => {
)}
{addressModal && (
<AddressModal
handleClose={() => setAddressModal(null)}
submit={updateOrder.mutate}
onClose={() => setAddressModal(null)}
onSave={updateOrder.mutate}
address={addressModal.address}
type={addressModal.type}
allowedCountries={region?.countries}
@@ -6,14 +6,15 @@ import {
useAdminCreateProductCategory,
} from "medusa-react"
import useNotification from "../../../hooks/use-notification"
import FocusModal from "../../../components/molecules/modal/focus-modal"
import { useQueryClient } from "@tanstack/react-query"
import Button from "../../../components/fundamentals/button"
import CrossIcon from "../../../components/fundamentals/icons/cross-icon"
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 { useQueryClient } from "@tanstack/react-query"
const visibilityOptions = [
{
@@ -63,7 +64,7 @@ function CreateProductCategory(props: CreateProductCategoryProps) {
notification("Success", "Successfully created a category", "success")
} catch (e) {
const errorMessage =
e.response?.data?.message || "Failed to create a new category"
getErrorMessage(e) || "Failed to create a new category"
notification("Error", errorMessage, "error")
}
}
@@ -133,20 +134,18 @@ function CreateProductCategory(props: CreateProductCategoryProps) {
<div className="mb-8 flex justify-between gap-6">
<div className="flex-1">
<Select
<NextSelect
label="Status"
options={statusOptions}
menuPortalStyles={{ zIndex: 300 }}
value={statusOptions[isActive ? 0 : 1]}
onChange={(o) => setIsActive(o.value === "active")}
/>
</div>
<div className="flex-1">
<Select
<NextSelect
label="Visibility"
options={visibilityOptions}
menuPortalStyles={{ zIndex: 300 }}
value={visibilityOptions[isPublic ? 0 : 1]}
onChange={(o) => setIsPublic(o.value === "public")}
/>
@@ -3,15 +3,17 @@ import { useEffect, useState } from "react"
import { ProductCategory } from "@medusajs/medusa"
import { useAdminUpdateProductCategory } from "medusa-react"
import SideModal from "../../../components/molecules/modal/side-modal"
import Button from "../../../components/fundamentals/button"
import CrossIcon from "../../../components/fundamentals/icons/cross-icon"
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 { Option } from "../../../types/shared"
import { getErrorMessage } from "../../../utils/error-messages"
import TreeCrumbs from "../components/tree-crumbs"
const visibilityOptions = [
const visibilityOptions: Option[] = [
{
label: "Public",
value: "public",
@@ -19,7 +21,7 @@ const visibilityOptions = [
{ label: "Private", value: "private" },
]
const statusOptions = [
const statusOptions: Option[] = [
{ label: "Active", value: "active" },
{ label: "Inactive", value: "inactive" },
]
@@ -70,8 +72,7 @@ function EditProductCategoriesSideModal(
notification("Success", "Successfully updated the category", "success")
close()
} catch (e) {
const errorMessage =
e.response?.data?.message || "Failed to update the category"
const errorMessage = getErrorMessage(e) || "Failed to update the category"
notification("Error", errorMessage, "error")
}
}
@@ -129,19 +130,17 @@ function EditProductCategoriesSideModal(
onChange={(ev) => setHandle(ev.target.value)}
/>
<Select
<NextSelect
label="Status"
options={statusOptions}
menuPortalStyles={{ zIndex: 300 }}
value={statusOptions[isActive ? 0 : 1]}
onChange={(o) => setIsActive(o.value === "active")}
/>
<Select
<NextSelect
className="my-6"
label="Visibility"
options={visibilityOptions}
menuPortalStyles={{ zIndex: 300 }}
value={visibilityOptions[isPublic ? 0 : 1]}
onChange={(o) => setIsPublic(o.value === "public")}
/>
@@ -2,6 +2,10 @@ import { ShippingOption } from "@medusajs/medusa"
import { useAdminUpdateShippingOption } from "medusa-react"
import { useEffect } from "react"
import { useForm } from "react-hook-form"
import {
getMetadataFormValues,
getSubmittableMetadata,
} from "../../../../../components/forms/general/metadata-form"
import Button from "../../../../../components/fundamentals/button"
import Modal from "../../../../../components/molecules/modal"
import useNotification from "../../../../../hooks/use-notification"
@@ -32,8 +36,10 @@ const EditModal = ({ open, onClose, option }: Props) => {
} = form
useEffect(() => {
reset(getDefaultValues(option))
}, [option])
if (open) {
reset(getDefaultValues(option))
}
}, [option, reset, open])
const closeAndReset = () => {
reset(getDefaultValues(option))
@@ -48,6 +54,7 @@ const EditModal = ({ open, onClose, option }: Props) => {
requirements: getRequirementsData(data),
admin_only: !data.store_option,
amount: data.amount!,
metadata: getSubmittableMetadata(data.metadata),
},
{
onSuccess: () => {
@@ -72,7 +79,7 @@ const EditModal = ({ open, onClose, option }: Props) => {
<div>
<p className="inter-base-semibold">Fulfillment Method</p>
<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>
</div>
<div className="bg-grey-20 my-xlarge h-px w-full" />
@@ -133,6 +140,7 @@ const getDefaultValues = (option: ShippingOption): ShippingOptionFormType => {
: null,
},
amount: option.amount,
metadata: getMetadataFormValues(option.metadata),
}
}
@@ -2,12 +2,16 @@ import { Region } from "@medusajs/medusa"
import { Controller, UseFormReturn } from "react-hook-form"
import IncludesTaxTooltip from "../../../../../components/atoms/includes-tax-tooltip"
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 InputHeader from "../../../../../components/fundamentals/input-header"
import InputField from "../../../../../components/molecules/input"
import { NextSelect } from "../../../../../components/molecules/select/next-select"
import { Option, ShippingOptionPriceType } from "../../../../../types/shared"
import FormValidator from "../../../../../utils/form-validator"
import { nestedForm } from "../../../../../utils/nested-form"
import { useShippingOptionFormData } from "./use-shipping-option-form-data"
type Requirement = {
@@ -26,6 +30,7 @@ export type ShippingOptionFormType = {
min_subtotal: Requirement | null
max_subtotal: Requirement | null
}
metadata: MetadataFormType
}
type Props = {
@@ -272,6 +277,11 @@ const ShippingOptionForm = ({ form, region, isEdit = false }: Props) => {
/>
</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>
)
}
@@ -2,6 +2,11 @@ import { AdminPostRegionsRegionReq, Region } from "@medusajs/medusa"
import { useAdminUpdateRegion } from "medusa-react"
import { useEffect } from "react"
import { useForm } from "react-hook-form"
import MetadataForm, {
getMetadataFormValues,
getSubmittableMetadata,
MetadataFormType,
} from "../../../../../components/forms/general/metadata-form"
import Button from "../../../../../components/fundamentals/button"
import Modal from "../../../../../components/molecules/modal"
import useNotification from "../../../../../hooks/use-notification"
@@ -27,6 +32,7 @@ type Props = {
type RegionEditFormType = {
details: RegionDetailsFormType
providers: RegionProvidersFormType
metadata: MetadataFormType
}
const EditRegionModal = ({ region, onClose, open }: Props) => {
@@ -48,7 +54,7 @@ const EditRegionModal = ({ region, onClose, open }: Props) => {
useEffect(() => {
reset(getDefaultValues(region))
}, [region])
}, [region, reset])
const { mutate, isLoading } = useAdminUpdateRegion(region.id)
const notifcation = useNotification()
@@ -62,6 +68,7 @@ const EditRegionModal = ({ region, onClose, open }: Props) => {
(fp) => fp.value
),
countries: data.details.countries.map((c) => c.value),
metadata: getSubmittableMetadata(data.metadata),
}
if (isFeatureEnabled("tax_inclusive_pricing")) {
@@ -96,6 +103,11 @@ const EditRegionModal = ({ region, onClose, open }: Props) => {
<h3 className="inter-base-semibold mb-base">Providers</h3>
<RegionProvidersForm form={nestedForm(form, "providers")} />
</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.Footer>
<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))
: [],
},
metadata: getMetadataFormValues(region.metadata),
}
}
@@ -1,6 +1,7 @@
import { Region } from "@medusajs/medusa"
import { useAdminCreateShippingOption } from "medusa-react"
import { useForm } from "react-hook-form"
import { getSubmittableMetadata } from "../../../../../components/forms/general/metadata-form"
import Button from "../../../../../components/fundamentals/button"
import Modal from "../../../../../components/molecules/modal"
import useNotification from "../../../../../hooks/use-notification"
@@ -51,6 +52,7 @@ const CreateReturnShippingOptionModal = ({ open, onClose, region }: Props) => {
admin_only: !data.store_option,
amount: data.amount!,
requirements: getRequirementsData(data),
metadata: getSubmittableMetadata(data.metadata),
},
{
onSuccess: () => {
@@ -1,6 +1,7 @@
import { Region } from "@medusajs/medusa"
import { useAdminCreateShippingOption } from "medusa-react"
import { useForm } from "react-hook-form"
import { getSubmittableMetadata } from "../../../../../components/forms/general/metadata-form"
import Button from "../../../../../components/fundamentals/button"
import Modal from "../../../../../components/molecules/modal"
import useNotification from "../../../../../hooks/use-notification"
@@ -51,6 +52,7 @@ const CreateShippingOptionModal = ({ open, onClose, region }: Props) => {
admin_only: !data.store_option,
amount: data.amount!,
requirements: getRequirementsData(data),
metadata: getSubmittableMetadata(data.metadata),
},
{
onSuccess: () => {