feat(admin-ui, medusa): admin UI metadata (#3644)
This commit is contained in:
committed by
GitHub
parent
4f4ccee7fb
commit
4342ac884b
@@ -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
|
||||
|
||||
+34
@@ -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>
|
||||
)
|
||||
}
|
||||
+159
@@ -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'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'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>
|
||||
)
|
||||
}
|
||||
|
||||
+10
-6
@@ -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 (
|
||||
|
||||
+9
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+26
@@ -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}
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+16
-4
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+10
-3
@@ -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,
|
||||
|
||||
+2
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+4
-4
@@ -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>
|
||||
|
||||
+10
-17
@@ -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>
|
||||
|
||||
+1
-1
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+35
-159
@@ -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 customer’s invoice.
|
||||
</span>
|
||||
<span>Uppercase letters and numbers only.</span>
|
||||
</div>
|
||||
<TextArea
|
||||
label="Description"
|
||||
required
|
||||
placeholder="Summer Sale 2022"
|
||||
rows={1}
|
||||
{...register("description", {
|
||||
required: "Description is required",
|
||||
})}
|
||||
/>
|
||||
</Modal.Content>
|
||||
<Modal.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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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>
|
||||
)}
|
||||
|
||||
+8
-1
@@ -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")}
|
||||
/>
|
||||
|
||||
+9
-10
@@ -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")}
|
||||
/>
|
||||
|
||||
+11
-3
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+14
-1
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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: () => {
|
||||
|
||||
+2
@@ -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: () => {
|
||||
|
||||
Reference in New Issue
Block a user