fix(admin-ui): Gift Card manage page (#3532)
**What** - Updates GC manage page to use product page sections - Revamps Denomination section - Updates the location of several components to reflect that they are now shared between the GC and products domain   Resolves CORE-1089
This commit is contained in:
1 parent
3171b0e518
commit
bfef22b33e
77 files changed
+1286
-1706
No files matched your search
@@ -1,25 +1,30 @@
|
||||
import * as RadixSwitch from "@radix-ui/react-switch"
|
||||
import clsx from "clsx"
|
||||
import React from "react"
|
||||
|
||||
/**
|
||||
* A controlled switch component atom.
|
||||
*/
|
||||
function Switch(props: RadixSwitch.SwitchProps) {
|
||||
return (
|
||||
<RadixSwitch.Root
|
||||
{...props}
|
||||
disabled={props.disabled}
|
||||
className={clsx(
|
||||
"transition-bg radix-state-checked:bg-violet-60 h-[18px] w-8 rounded-full bg-gray-300"
|
||||
)}
|
||||
>
|
||||
<RadixSwitch.Thumb
|
||||
const Switch = React.forwardRef<HTMLButtonElement, RadixSwitch.SwitchProps>(
|
||||
(props, ref) => {
|
||||
return (
|
||||
<RadixSwitch.Root
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={clsx(
|
||||
"radix-state-checked:translate-x-[19px] block h-2 w-2 translate-x-[5px] rounded-full bg-white transition-transform"
|
||||
"transition-bg radix-state-checked:bg-violet-60 h-[18px] w-8 rounded-full bg-gray-300"
|
||||
)}
|
||||
/>
|
||||
</RadixSwitch.Root>
|
||||
)
|
||||
}
|
||||
>
|
||||
<RadixSwitch.Thumb
|
||||
className={clsx(
|
||||
"radix-state-checked:translate-x-[19px] block h-2 w-2 translate-x-[5px] rounded-full bg-white transition-transform"
|
||||
)}
|
||||
/>
|
||||
</RadixSwitch.Root>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Switch.displayName = "Switch"
|
||||
|
||||
export default Switch
|
||||
@@ -42,7 +42,7 @@ const Tooltip = ({
|
||||
sideOffset={8}
|
||||
align="center"
|
||||
className={clsx(
|
||||
"inter-small-semibold text-grey-50",
|
||||
"inter-small-semibold text-grey-50 z-[999]",
|
||||
"bg-grey-0 shadow-dropdown rounded-rounded py-2 px-3",
|
||||
"border-grey-20 border border-solid",
|
||||
className
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useAdminRegions, useAdminStore } from "medusa-react"
|
||||
import { useEffect, useMemo } from "react"
|
||||
import { FieldArrayWithId, useFieldArray } from "react-hook-form"
|
||||
import { NestedForm } from "../../../../utils/nested-form"
|
||||
import NestedPrice from "./nested-price"
|
||||
|
||||
type PricePayload = {
|
||||
id: string | null
|
||||
amount: number | null
|
||||
currency_code: string
|
||||
region_id: string | null
|
||||
includes_tax?: boolean
|
||||
}
|
||||
|
||||
type PriceObject = FieldArrayWithId<
|
||||
{
|
||||
__nested__: PricesFormType
|
||||
},
|
||||
"__nested__.prices",
|
||||
"id"
|
||||
> & { index: number }
|
||||
|
||||
export type PricesFormType = {
|
||||
prices: PricePayload[]
|
||||
}
|
||||
|
||||
export type NestedPriceObject = {
|
||||
currencyPrice: PriceObject
|
||||
regionPrices: (PriceObject & { regionName: string })[]
|
||||
}
|
||||
|
||||
type Props = {
|
||||
form: NestedForm<PricesFormType>
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-usable nested form used to submit pricing information for products and their variants.
|
||||
* Fetches store currencies and regions from the backend, and allows the user to specifcy both
|
||||
* currency and region specific prices.
|
||||
* @example
|
||||
* <Pricing form={nestedForm(form, "prices")} />
|
||||
*/
|
||||
const PricesForm = ({ form }: Props) => {
|
||||
const { store } = useAdminStore()
|
||||
const { regions } = useAdminRegions()
|
||||
|
||||
const { control, path } = form
|
||||
|
||||
const { append, update, fields } = useFieldArray({
|
||||
control,
|
||||
name: path("prices"),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!regions || !store || !fields) {
|
||||
return
|
||||
}
|
||||
|
||||
regions.forEach((reg) => {
|
||||
if (!fields.some((field) => field.region_id === reg.id)) {
|
||||
append({
|
||||
id: null,
|
||||
region_id: reg.id,
|
||||
amount: null,
|
||||
currency_code: reg.currency_code,
|
||||
includes_tax: reg.includes_tax,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
store.currencies.forEach((cur) => {
|
||||
if (!fields.some((field) => field.currency_code === cur.code)) {
|
||||
append({
|
||||
id: null,
|
||||
currency_code: cur.code,
|
||||
amount: null,
|
||||
region_id: null,
|
||||
includes_tax: cur.includes_tax,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [regions, store, fields])
|
||||
|
||||
// Ensure that prices are up to date with their respective tax inclusion setting
|
||||
useEffect(() => {
|
||||
if (!regions || !fields || !store) {
|
||||
return
|
||||
}
|
||||
|
||||
regions.forEach((reg) => {
|
||||
const regionPrice = fields.findIndex(
|
||||
(field) => !!field && field.region_id === reg.id
|
||||
)
|
||||
|
||||
if (
|
||||
regionPrice !== -1 &&
|
||||
fields[regionPrice].includes_tax !== reg.includes_tax
|
||||
) {
|
||||
update(regionPrice, {
|
||||
...fields[regionPrice],
|
||||
includes_tax: reg.includes_tax,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
store.currencies.forEach((cur) => {
|
||||
const currencyPrice = fields.findIndex(
|
||||
(field) =>
|
||||
!!field && !field.region_id && field.currency_code === cur.code
|
||||
)
|
||||
|
||||
if (
|
||||
currencyPrice !== -1 &&
|
||||
fields[currencyPrice].includes_tax !== cur.includes_tax
|
||||
) {
|
||||
update(currencyPrice, {
|
||||
...fields[currencyPrice],
|
||||
includes_tax: cur.includes_tax,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [regions, store])
|
||||
|
||||
const priceObj = useMemo(() => {
|
||||
const obj: Record<string, NestedPriceObject> = {}
|
||||
|
||||
const currencyPrices = fields.filter((field) => field.region_id === null)
|
||||
const regionPrices = fields.filter((field) => field.region_id !== null)
|
||||
|
||||
currencyPrices.forEach((price) => {
|
||||
obj[price.currency_code!] = {
|
||||
currencyPrice: {
|
||||
...price,
|
||||
index: fields.indexOf(price),
|
||||
},
|
||||
regionPrices: regionPrices
|
||||
.filter(
|
||||
(regionPrice) => regionPrice.currency_code === price.currency_code
|
||||
)
|
||||
.map((rp) => ({
|
||||
...rp,
|
||||
regionName: regions?.find((r) => r.id === rp.region_id)?.name || "",
|
||||
index: fields.indexOf(rp),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
return obj
|
||||
}, [fields, regions])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
{Object.values(priceObj).map((po) => {
|
||||
return (
|
||||
<NestedPrice
|
||||
form={form}
|
||||
nestedPrice={po}
|
||||
key={po.currencyPrice.id}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PricesForm
|
||||
@@ -0,0 +1,122 @@
|
||||
import clsx from "clsx"
|
||||
import { Controller } from "react-hook-form"
|
||||
import { NestedPriceObject, PricesFormType } from "."
|
||||
import useToggleState from "../../../../hooks/use-toggle-state"
|
||||
import { currencies } from "../../../../utils/currencies"
|
||||
import { NestedForm } from "../../../../utils/nested-form"
|
||||
import IncludesTaxTooltip from "../../../atoms/includes-tax-tooltip"
|
||||
import CoinsIcon from "../../../fundamentals/icons/coins-icon"
|
||||
import MapPinIcon from "../../../fundamentals/icons/map-pin-icon"
|
||||
import TriangleRightIcon from "../../../fundamentals/icons/triangle-right-icon"
|
||||
import PriceFormInput from "./price-form-input"
|
||||
|
||||
type Props = {
|
||||
form: NestedForm<PricesFormType>
|
||||
nestedPrice: NestedPriceObject
|
||||
}
|
||||
|
||||
const NestedPrice = ({ form, nestedPrice }: Props) => {
|
||||
const { state, toggle } = useToggleState()
|
||||
|
||||
const { control, path } = form
|
||||
const { currencyPrice, regionPrices } = nestedPrice
|
||||
return (
|
||||
<div className="gap-y-2xsmall flex flex-col">
|
||||
<div className="gap-x-base p-2xsmall hover:bg-grey-5 focus-within:bg-grey-5 rounded-rounded relative grid grid-cols-[1fr_223px] justify-between pl-10 transition-colors">
|
||||
<button
|
||||
className={clsx(
|
||||
"left-xsmall text-grey-40 absolute top-1/2 -translate-y-1/2 transition-all",
|
||||
{
|
||||
"rotate-90": state,
|
||||
},
|
||||
{
|
||||
hidden: regionPrices.length === 0,
|
||||
}
|
||||
)}
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
disabled={regionPrices.length === 0}
|
||||
>
|
||||
<TriangleRightIcon />
|
||||
</button>
|
||||
<div className="gap-x-small flex items-center">
|
||||
<div className="bg-grey-10 rounded-rounded text-grey-50 flex h-10 w-10 items-center justify-center">
|
||||
<CoinsIcon size={20} />
|
||||
</div>
|
||||
<div className="gap-x-xsmall flex items-center">
|
||||
<span className="inter-base-semibold">
|
||||
{currencyPrice.currency_code.toUpperCase()}
|
||||
</span>
|
||||
<span className="inter-base-regular text-grey-50">
|
||||
{currencies[currencyPrice.currency_code.toUpperCase()].name}
|
||||
</span>
|
||||
<IncludesTaxTooltip includesTax={currencyPrice?.includes_tax} />
|
||||
</div>
|
||||
</div>
|
||||
<Controller
|
||||
name={path(`prices.${currencyPrice.index}.amount`)}
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, formState: { errors } }) => {
|
||||
return (
|
||||
<PriceFormInput
|
||||
onChange={onChange}
|
||||
amount={value !== null ? value : undefined}
|
||||
currencyCode={currencyPrice.currency_code}
|
||||
errors={errors}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ul
|
||||
className={clsx(
|
||||
"gap-y-2xsmall my-2xsmall flex flex-col overflow-hidden",
|
||||
{
|
||||
"max-h-0": !state,
|
||||
"max-h-[9999px]": state,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{regionPrices.map((rp) => {
|
||||
return (
|
||||
<div
|
||||
className="p-2xsmall hover:bg-grey-5 focus-within:bg-grey-5 rounded-rounded grid grid-cols-[1fr_223px] justify-between pl-10 transition-colors"
|
||||
key={rp.id}
|
||||
>
|
||||
<div className="gap-x-small flex items-center">
|
||||
<div className="bg-grey-10 rounded-rounded text-grey-50 flex h-10 w-10 items-center justify-center">
|
||||
<MapPinIcon size={20} />
|
||||
</div>
|
||||
<div className="gap-x-xsmall flex items-center">
|
||||
<span className="inter-base-regular text-grey-50">
|
||||
{rp.regionName}
|
||||
</span>
|
||||
<IncludesTaxTooltip includesTax={rp.includes_tax} />
|
||||
</div>
|
||||
</div>
|
||||
<Controller
|
||||
name={path(`prices.${rp.index}.amount`)}
|
||||
control={control}
|
||||
render={({
|
||||
field: { value, onChange },
|
||||
formState: { errors },
|
||||
}) => {
|
||||
return (
|
||||
<PriceFormInput
|
||||
onChange={onChange}
|
||||
amount={value !== null ? value : undefined}
|
||||
currencyCode={currencyPrice.currency_code}
|
||||
errors={errors}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NestedPrice
|
||||
@@ -0,0 +1,82 @@
|
||||
import clsx from "clsx"
|
||||
import { useEffect, useState } from "react"
|
||||
import AmountField from "react-currency-input-field"
|
||||
import { currencies } from "../../../../utils/currencies"
|
||||
import InputError from "../../../atoms/input-error"
|
||||
|
||||
type Props = {
|
||||
currencyCode: string
|
||||
amount?: number | null
|
||||
onChange: (amount?: number) => void
|
||||
errors?: { [x: string]: unknown }
|
||||
name?: string
|
||||
}
|
||||
|
||||
const PriceFormInput = ({
|
||||
name,
|
||||
currencyCode,
|
||||
errors,
|
||||
amount,
|
||||
onChange,
|
||||
}: Props) => {
|
||||
const { symbol_native, decimal_digits } =
|
||||
currencies[currencyCode.toUpperCase()]
|
||||
|
||||
const getFormattedValue = (value: number) => {
|
||||
return `${value / 10 ** decimal_digits}`
|
||||
}
|
||||
|
||||
const [formattedValue, setFormattedValue] = useState<string | undefined>(
|
||||
amount !== null && amount !== undefined
|
||||
? getFormattedValue(amount)
|
||||
: undefined
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (amount) {
|
||||
setFormattedValue(getFormattedValue(amount))
|
||||
}
|
||||
}, [amount, decimal_digits])
|
||||
|
||||
const onAmountChange = (value?: string, floatValue?: number | null) => {
|
||||
if (typeof floatValue === "number") {
|
||||
const numericalValue = Math.round(floatValue * 10 ** decimal_digits)
|
||||
onChange(numericalValue)
|
||||
} else {
|
||||
onChange(undefined)
|
||||
}
|
||||
setFormattedValue(value)
|
||||
}
|
||||
|
||||
const step = 10 ** -decimal_digits
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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",
|
||||
{
|
||||
"border-rose-50": errors && name && errors[name],
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span className="inter-base-regular text-grey-40">{symbol_native}</span>
|
||||
|
||||
<AmountField
|
||||
step={step}
|
||||
value={formattedValue}
|
||||
onValueChange={(value, _name, values) =>
|
||||
onAmountChange(value, values?.float)
|
||||
}
|
||||
allowNegativeValue={false}
|
||||
placeholder="-"
|
||||
decimalScale={decimal_digits}
|
||||
className="remove-number-spinner leading-base text-grey-90 caret-violet-60 placeholder-grey-40 w-full bg-transparent text-right font-normal outline-none outline-0"
|
||||
/>
|
||||
</div>
|
||||
<InputError name={name} errors={errors} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PriceFormInput
|
||||
@@ -0,0 +1,177 @@
|
||||
import { Controller, useFieldArray, useWatch } from "react-hook-form"
|
||||
import { currencies } from "../../../../utils/currencies"
|
||||
import { 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 PriceFormInput from "../../general/prices-form/price-form-input"
|
||||
|
||||
type DenominationType = {
|
||||
amount: number
|
||||
currency_code: string
|
||||
includes_tax?: boolean
|
||||
}
|
||||
|
||||
export type DenominationFormType = {
|
||||
defaultDenomination: DenominationType
|
||||
currencyDenominations: DenominationType[]
|
||||
useSameValue: boolean
|
||||
}
|
||||
|
||||
type Props = {
|
||||
form: NestedForm<DenominationFormType>
|
||||
}
|
||||
|
||||
const DenominationForm = ({ form }: Props) => {
|
||||
const {
|
||||
control,
|
||||
path,
|
||||
formState: { errors },
|
||||
} = form
|
||||
|
||||
const { fields } = useFieldArray({
|
||||
control,
|
||||
name: path("currencyDenominations"),
|
||||
keyName: "fieldKey",
|
||||
})
|
||||
|
||||
const defaultCurrency = useWatch({
|
||||
control,
|
||||
name: path("defaultDenomination"),
|
||||
})
|
||||
|
||||
const useSameValue = useWatch({
|
||||
control,
|
||||
name: path("useSameValue"),
|
||||
})
|
||||
|
||||
return (
|
||||
<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>
|
||||
<IconTooltip
|
||||
type="info"
|
||||
content="The denomination in your store's default currency"
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-small gap-x-base rounded-rounded relative grid grid-cols-[1fr_223px] justify-between transition-colors">
|
||||
<div className="gap-x-small flex items-center">
|
||||
<div className="bg-grey-10 rounded-rounded text-grey-50 flex h-10 w-10 items-center justify-center">
|
||||
<CoinsIcon size={20} />
|
||||
</div>
|
||||
<div className="gap-x-xsmall flex items-center">
|
||||
<span className="inter-base-semibold">
|
||||
{defaultCurrency.currency_code.toUpperCase()}
|
||||
</span>
|
||||
<span className="inter-base-regular text-grey-50">
|
||||
{currencies[defaultCurrency.currency_code.toUpperCase()].name}
|
||||
</span>
|
||||
<IncludesTaxTooltip includesTax={defaultCurrency.includes_tax} />
|
||||
</div>
|
||||
</div>
|
||||
<Controller
|
||||
name={path(`defaultDenomination.amount`)}
|
||||
control={control}
|
||||
rules={{
|
||||
required:
|
||||
"An amount for your store's default currency is required",
|
||||
}}
|
||||
render={({ field: { value, onChange, name } }) => {
|
||||
return (
|
||||
<>
|
||||
<PriceFormInput
|
||||
onChange={onChange}
|
||||
amount={value !== null ? value : undefined}
|
||||
currencyCode={defaultCurrency.currency_code}
|
||||
errors={errors}
|
||||
/>
|
||||
<InputError errors={errors} name={name} />
|
||||
</>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="gap-y-2xsmall flex flex-col">
|
||||
<h2 className="inter-base-semibold">
|
||||
Use value for all currencies?
|
||||
</h2>
|
||||
<p className="inter-small-regular text-grey-50 max-w-[60%]">
|
||||
If enabled the value used for the store's default currency
|
||||
code will also be applied to all other currencies in your store.
|
||||
</p>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name={path("useSameValue")}
|
||||
render={({ field: { value, onChange, ...rest } }) => {
|
||||
return (
|
||||
<Switch checked={value} onCheckedChange={onChange} {...rest} />
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!useSameValue && (
|
||||
<div>
|
||||
<div className="gap-x-2xsmall flex items-center">
|
||||
<h2 className="inter-base-semibold">Other currencies</h2>
|
||||
<IconTooltip
|
||||
type="info"
|
||||
content="The denomination in your store's other currencies"
|
||||
/>
|
||||
</div>
|
||||
<div className="gap-y-xsmall pt-small flex flex-col">
|
||||
{fields.map((denom, index) => {
|
||||
return (
|
||||
<div
|
||||
key={denom.fieldKey}
|
||||
className="gap-x-base rounded-rounded relative grid grid-cols-[1fr_223px] justify-between transition-colors"
|
||||
>
|
||||
<div className="gap-x-small flex items-center">
|
||||
<div className="bg-grey-10 rounded-rounded text-grey-50 flex h-10 w-10 items-center justify-center">
|
||||
<CoinsIcon size={20} />
|
||||
</div>
|
||||
<div className="gap-x-xsmall flex items-center">
|
||||
<span className="inter-base-semibold">
|
||||
{denom.currency_code.toUpperCase()}
|
||||
</span>
|
||||
<span className="inter-base-regular text-grey-50">
|
||||
{currencies[denom.currency_code.toUpperCase()].name}
|
||||
</span>
|
||||
<IncludesTaxTooltip includesTax={denom.includes_tax} />
|
||||
</div>
|
||||
</div>
|
||||
<Controller
|
||||
name={path(`currencyDenominations.${index}.amount`)}
|
||||
control={control}
|
||||
render={({
|
||||
field: { value, onChange },
|
||||
formState: { errors },
|
||||
}) => {
|
||||
return (
|
||||
<PriceFormInput
|
||||
onChange={onChange}
|
||||
amount={value !== null ? value : undefined}
|
||||
currencyCode={denom.currency_code}
|
||||
errors={errors}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DenominationForm
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Controller } from "react-hook-form"
|
||||
import { Option } from "../../../../types/shared"
|
||||
import { countries } from "../../../../utils/countries"
|
||||
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 CustomsFormType = {
|
||||
mid_code: string | null
|
||||
hs_code: string | null
|
||||
origin_country: Option | null
|
||||
}
|
||||
|
||||
type CustomsFormProps = {
|
||||
form: NestedForm<CustomsFormType>
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-usable nested form used to submit customs information for products and their variants.
|
||||
* @example
|
||||
* <CustomsForm form={nestedForm(form, "customs")} />
|
||||
*/
|
||||
const CustomsForm = ({ form }: CustomsFormProps) => {
|
||||
const {
|
||||
register,
|
||||
path,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = form
|
||||
|
||||
const countryOptions = countries.map((c) => ({
|
||||
label: c.name,
|
||||
value: c.alpha2,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="gap-large pb-2xsmall grid grid-cols-2">
|
||||
<InputField
|
||||
label="MID Code"
|
||||
placeholder="XDSKLAD9999..."
|
||||
{...register(path("mid_code"), {
|
||||
pattern: FormValidator.whiteSpaceRule("MID Code"),
|
||||
})}
|
||||
errors={errors}
|
||||
/>
|
||||
<InputField
|
||||
label="HS Code"
|
||||
placeholder="BDJSK39277W..."
|
||||
{...register(path("hs_code"), {
|
||||
pattern: FormValidator.whiteSpaceRule("HS Code"),
|
||||
})}
|
||||
errors={errors}
|
||||
/>
|
||||
<Controller
|
||||
name={path("origin_country")}
|
||||
control={control}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<NextSelect
|
||||
label="Country of origin"
|
||||
placeholder="Choose a country"
|
||||
options={countryOptions}
|
||||
isSearchable
|
||||
isClearable
|
||||
{...field}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomsForm
|
||||
@@ -0,0 +1,74 @@
|
||||
import FormValidator from "../../../../utils/form-validator"
|
||||
import { NestedForm } from "../../../../utils/nested-form"
|
||||
import InputField from "../../../molecules/input"
|
||||
|
||||
export type DimensionsFormType = {
|
||||
length: number | null
|
||||
width: number | null
|
||||
height: number | null
|
||||
weight: number | null
|
||||
}
|
||||
|
||||
type DimensionsFormProps = {
|
||||
form: NestedForm<DimensionsFormType>
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-usable nested form used to submit dimensions information for products and their variants.
|
||||
* @example
|
||||
* <DimensionsForm form={nestedForm(form, "dimensions")} />
|
||||
*/
|
||||
const DimensionsForm = ({ form }: DimensionsFormProps) => {
|
||||
const {
|
||||
register,
|
||||
path,
|
||||
formState: { errors },
|
||||
} = form
|
||||
|
||||
return (
|
||||
<div className="gap-x-large grid grid-cols-4">
|
||||
<InputField
|
||||
label="Width"
|
||||
placeholder="100..."
|
||||
type="number"
|
||||
{...register(path("width"), {
|
||||
min: FormValidator.nonNegativeNumberRule("Width"),
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
errors={errors}
|
||||
/>
|
||||
<InputField
|
||||
label="Length"
|
||||
placeholder="100..."
|
||||
type="number"
|
||||
{...register(path("length"), {
|
||||
min: FormValidator.nonNegativeNumberRule("Length"),
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
errors={errors}
|
||||
/>
|
||||
<InputField
|
||||
label="Height"
|
||||
placeholder="100..."
|
||||
type="number"
|
||||
{...register(path("height"), {
|
||||
min: FormValidator.nonNegativeNumberRule("Height"),
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
errors={errors}
|
||||
/>
|
||||
<InputField
|
||||
label="Weight"
|
||||
placeholder="100..."
|
||||
type="number"
|
||||
{...register(path("weight"), {
|
||||
min: FormValidator.nonNegativeNumberRule("Weight"),
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
errors={errors}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DimensionsForm
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Controller } from "react-hook-form"
|
||||
import { NestedForm } from "../../../../utils/nested-form"
|
||||
import Switch from "../../../atoms/switch"
|
||||
|
||||
export type DiscountableFormType = {
|
||||
value: boolean
|
||||
}
|
||||
|
||||
type Props = {
|
||||
form: NestedForm<DiscountableFormType>
|
||||
isGiftCard?: boolean
|
||||
}
|
||||
|
||||
const DiscountableForm = ({ form, isGiftCard }: Props) => {
|
||||
const { control, path } = form
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2xsmall flex items-center justify-between">
|
||||
<h2 className="inter-base-semibold">Discountable</h2>
|
||||
<Controller
|
||||
control={control}
|
||||
name={path("value")}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return <Switch checked={value} onCheckedChange={onChange} />
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="inter-base-regular text-grey-50">
|
||||
When unchecked discounts will not be applied to this{" "}
|
||||
{isGiftCard ? "gift card" : "product"}.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DiscountableForm
|
||||
@@ -0,0 +1,109 @@
|
||||
import FormValidator from "../../../../utils/form-validator"
|
||||
import { NestedForm } from "../../../../utils/nested-form"
|
||||
import InputField from "../../../molecules/input"
|
||||
import TextArea from "../../../molecules/textarea"
|
||||
|
||||
export type GeneralFormType = {
|
||||
title: string
|
||||
subtitle: string | null
|
||||
handle: string
|
||||
material: string | null
|
||||
description: string | null
|
||||
}
|
||||
|
||||
type Props = {
|
||||
form: NestedForm<GeneralFormType>
|
||||
requireHandle?: boolean
|
||||
isGiftCard?: boolean
|
||||
}
|
||||
|
||||
const GeneralForm = ({ form, requireHandle = true, isGiftCard }: Props) => {
|
||||
const {
|
||||
register,
|
||||
path,
|
||||
formState: { errors },
|
||||
} = form
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="gap-x-large mb-small grid grid-cols-2">
|
||||
<InputField
|
||||
label="Title"
|
||||
placeholder={isGiftCard ? "Gift Card" : "Winter Jacket"}
|
||||
required
|
||||
{...register(path("title"), {
|
||||
required: "Title is required",
|
||||
minLength: {
|
||||
value: 1,
|
||||
message: "Title must be at least 1 character",
|
||||
},
|
||||
pattern: FormValidator.whiteSpaceRule("Title"),
|
||||
})}
|
||||
errors={errors}
|
||||
/>
|
||||
<InputField
|
||||
label="Subtitle"
|
||||
placeholder="Warm and cozy..."
|
||||
{...register(path("subtitle"), {
|
||||
pattern: FormValidator.whiteSpaceRule("Subtitle"),
|
||||
})}
|
||||
errors={errors}
|
||||
/>
|
||||
</div>
|
||||
<p className="inter-base-regular text-grey-50 mb-large">
|
||||
Give your {isGiftCard ? "gift card" : "product"} a short and clear
|
||||
title.
|
||||
<br />
|
||||
50-60 characters is the recommended length for search engines.
|
||||
</p>
|
||||
<div className="gap-x-large mb-large grid grid-cols-2">
|
||||
<InputField
|
||||
label="Handle"
|
||||
tooltipContent={
|
||||
!requireHandle
|
||||
? `The handle is the part of the URL that identifies the ${
|
||||
isGiftCard ? "gift card" : "product"
|
||||
}. If not specified, it will be generated from the title.`
|
||||
: undefined
|
||||
}
|
||||
placeholder={isGiftCard ? "gift-card" : "winter-jacket"}
|
||||
required={requireHandle}
|
||||
{...register(path("handle"), {
|
||||
required: requireHandle ? "Handle is required" : undefined,
|
||||
minLength: FormValidator.minOneCharRule("Handle"),
|
||||
pattern: FormValidator.whiteSpaceRule("Handle"),
|
||||
})}
|
||||
prefix="/"
|
||||
errors={errors}
|
||||
/>
|
||||
<InputField
|
||||
label="Material"
|
||||
placeholder={isGiftCard ? "Paper" : "100% Cotton"}
|
||||
{...register(path("material"), {
|
||||
minLength: FormValidator.minOneCharRule("Material"),
|
||||
pattern: FormValidator.whiteSpaceRule("Material"),
|
||||
})}
|
||||
errors={errors}
|
||||
/>
|
||||
</div>
|
||||
<TextArea
|
||||
label="Description"
|
||||
placeholder={
|
||||
isGiftCard ? "The gift card is..." : "A warm and cozy jacket..."
|
||||
}
|
||||
rows={3}
|
||||
className="mb-small"
|
||||
{...register(path("description"))}
|
||||
errors={errors}
|
||||
/>
|
||||
<p className="inter-base-regular text-grey-50">
|
||||
Give your {isGiftCard ? "gift card" : "product"} a short and clear
|
||||
description.
|
||||
<br />
|
||||
120-160 characters is the recommended length for search engines.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GeneralForm
|
||||
@@ -0,0 +1,233 @@
|
||||
import clsx from "clsx"
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
Controller,
|
||||
FieldArrayWithId,
|
||||
useFieldArray,
|
||||
useWatch,
|
||||
} from "react-hook-form"
|
||||
import { FormImage } from "../../../../types/shared"
|
||||
import { NestedForm } from "../../../../utils/nested-form"
|
||||
import FileUploadField from "../../../atoms/file-upload-field"
|
||||
import Button from "../../../fundamentals/button"
|
||||
import CheckCircleFillIcon from "../../../fundamentals/icons/check-circle-fill-icon"
|
||||
import TrashIcon from "../../../fundamentals/icons/trash-icon"
|
||||
import Actionables, { ActionType } from "../../../molecules/actionables"
|
||||
|
||||
type ImageType = { selected: boolean } & FormImage
|
||||
|
||||
export type MediaFormType = {
|
||||
images: ImageType[]
|
||||
}
|
||||
|
||||
type Props = {
|
||||
form: NestedForm<MediaFormType>
|
||||
}
|
||||
|
||||
const MediaForm = ({ form }: Props) => {
|
||||
const { control, path, setValue } = form
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: control,
|
||||
name: path("images"),
|
||||
})
|
||||
|
||||
const handleFilesChosen = (files: File[]) => {
|
||||
if (files.length) {
|
||||
const toAppend = files.map((file) => ({
|
||||
url: URL.createObjectURL(file),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
nativeFile: file,
|
||||
selected: false,
|
||||
}))
|
||||
|
||||
append(toAppend)
|
||||
}
|
||||
}
|
||||
|
||||
const images = useWatch({
|
||||
control,
|
||||
name: path("images"),
|
||||
defaultValue: [],
|
||||
})
|
||||
|
||||
const selected = useMemo(() => {
|
||||
const selected: number[] = []
|
||||
|
||||
images.forEach((i, index) => {
|
||||
if (i.selected) {
|
||||
selected.push(index)
|
||||
}
|
||||
})
|
||||
|
||||
return selected
|
||||
}, [images])
|
||||
|
||||
const handleRemove = () => {
|
||||
remove(selected)
|
||||
}
|
||||
|
||||
const handleDeselect = () => {
|
||||
selected.forEach((i) => {
|
||||
setValue(path(`images.${i}.selected`), false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<div>
|
||||
<FileUploadField
|
||||
onFileChosen={handleFilesChosen}
|
||||
placeholder="1200 x 1600 (3:4) recommended, up to 10MB each"
|
||||
multiple
|
||||
filetypes={["image/gif", "image/jpeg", "image/png", "image/webp"]}
|
||||
className="py-large"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{fields.length > 0 && (
|
||||
<div className="mt-large">
|
||||
<div className="mb-small flex items-center justify-between">
|
||||
<h2 className="inter-large-semibold">Uploads</h2>
|
||||
<ModalActions
|
||||
number={selected.length}
|
||||
onDeselect={handleDeselect}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
</div>
|
||||
<div className="gap-y-2xsmall flex flex-col">
|
||||
{fields.map((field, index) => {
|
||||
return (
|
||||
<Image
|
||||
key={field.id}
|
||||
image={field}
|
||||
index={index}
|
||||
remove={remove}
|
||||
form={form}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ImageProps = {
|
||||
image: FieldArrayWithId<MediaFormType, "images", "id">
|
||||
index: number
|
||||
remove: (index: number) => void
|
||||
form: NestedForm<MediaFormType>
|
||||
}
|
||||
|
||||
const Image = ({ image, index, form, remove }: ImageProps) => {
|
||||
const { control, path } = form
|
||||
|
||||
const actions: ActionType[] = [
|
||||
{
|
||||
label: "Delete",
|
||||
onClick: () => remove(index),
|
||||
icon: <TrashIcon size={20} />,
|
||||
variant: "danger",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={path(`images.${index}.selected`)}
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
className={clsx(
|
||||
"px-base py-xsmall hover:bg-grey-5 rounded-rounded group flex items-center justify-between",
|
||||
{
|
||||
"bg-grey-5": value,
|
||||
}
|
||||
)}
|
||||
type="button"
|
||||
onClick={() => onChange(!value)}
|
||||
>
|
||||
<div className="gap-x-large flex items-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center">
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.name || "Uploaded image"}
|
||||
className="rounded-rounded max-h-[64px] max-w-[64px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="inter-small-regular flex flex-col text-left">
|
||||
<p>{image.name}</p>
|
||||
<p className="text-grey-50">
|
||||
{image.size ? `${(image.size / 1024).toFixed(2)} KB` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="gap-x-base flex items-center">
|
||||
<span
|
||||
className={clsx("hidden", {
|
||||
"!text-violet-60 !block": value,
|
||||
})}
|
||||
>
|
||||
<CheckCircleFillIcon size={24} />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div className="right-base absolute top-0 bottom-0 flex items-center">
|
||||
<Actionables actions={actions} forceDropdown />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type ModalActionsProps = {
|
||||
number: number
|
||||
onRemove: () => void
|
||||
onDeselect: () => void
|
||||
}
|
||||
|
||||
const ModalActions = ({ number, onRemove, onDeselect }: ModalActionsProps) => {
|
||||
return (
|
||||
<div className="flex h-10 items-center overflow-y-hidden pr-1">
|
||||
<div
|
||||
className={clsx(
|
||||
"gap-x-small flex items-center transition-all duration-200",
|
||||
{
|
||||
"translate-y-[-42px]": !number,
|
||||
"translate-y-[0px]": number,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span>{number} selected</span>
|
||||
<div className="bg-grey-20 h-5 w-px" />
|
||||
<div className="gap-x-xsmall flex items-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
type="button"
|
||||
onClick={onDeselect}
|
||||
>
|
||||
Deselect
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="small"
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MediaForm
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Controller } from "react-hook-form"
|
||||
import NestedMultiselect from "../../../../domain/categories/components/multiselect"
|
||||
import {
|
||||
FeatureFlag,
|
||||
useFeatureFlag,
|
||||
} from "../../../../providers/feature-flag-provider"
|
||||
import { Option } from "../../../../types/shared"
|
||||
import { NestedForm } from "../../../../utils/nested-form"
|
||||
import InputHeader from "../../../fundamentals/input-header"
|
||||
import {
|
||||
NextCreateableSelect,
|
||||
NextSelect,
|
||||
} from "../../../molecules/select/next-select"
|
||||
import TagInput from "../../../molecules/tag-input"
|
||||
import useOrganizeData from "./use-organize-data"
|
||||
|
||||
export type OrganizeFormType = {
|
||||
type: Option | null
|
||||
collection: Option | null
|
||||
tags: string[] | null
|
||||
categories: string[] | null
|
||||
}
|
||||
|
||||
type Props = {
|
||||
form: NestedForm<OrganizeFormType>
|
||||
}
|
||||
|
||||
const OrganizeForm = ({ form }: Props) => {
|
||||
const { control, path, setValue } = form
|
||||
const {
|
||||
productTypeOptions,
|
||||
collectionOptions,
|
||||
categoriesOptions = [],
|
||||
} = useOrganizeData()
|
||||
|
||||
const { isFeatureEnabled } = useFeatureFlag()
|
||||
|
||||
const typeOptions = productTypeOptions
|
||||
|
||||
const onCreateOption = (value: string) => {
|
||||
typeOptions.push({ label: value, value })
|
||||
setValue(
|
||||
path("type"),
|
||||
{ label: value, value },
|
||||
{
|
||||
shouldDirty: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-large gap-x-large grid grid-cols-2">
|
||||
<Controller
|
||||
name={path("type")}
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<NextCreateableSelect
|
||||
label="Type"
|
||||
onChange={onChange}
|
||||
options={productTypeOptions}
|
||||
value={value || null}
|
||||
placeholder="Choose a type"
|
||||
onCreateOption={onCreateOption}
|
||||
isClearable
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
name={path("collection")}
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<NextSelect
|
||||
label="Collection"
|
||||
onChange={onChange}
|
||||
options={collectionOptions}
|
||||
value={value}
|
||||
placeholder="Choose a collection"
|
||||
isClearable
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isFeatureEnabled(FeatureFlag.PRODUCT_CATEGORIES) &&
|
||||
categoriesOptions?.length ? (
|
||||
<>
|
||||
<InputHeader label="Categories" className="mb-2" />
|
||||
<Controller
|
||||
name={path("categories")}
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
const initiallySelected = (value || []).reduce((acc, val) => {
|
||||
acc[val] = true
|
||||
return acc
|
||||
}, {} as Record<string, true>)
|
||||
|
||||
return (
|
||||
<NestedMultiselect
|
||||
onSelect={onChange}
|
||||
options={categoriesOptions}
|
||||
initiallySelected={initiallySelected}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="mb-large" />
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name={path("tags")}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return <TagInput onChange={onChange} values={value || []} />
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrganizeForm
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useMemo } from "react"
|
||||
|
||||
import {
|
||||
useAdminCollections,
|
||||
useAdminProductCategories,
|
||||
useAdminProductTypes
|
||||
} from "medusa-react"
|
||||
|
||||
import { NestedMultiselectOption } from "../../../../domain/categories/components/multiselect"
|
||||
import { transformCategoryToNestedFormOptions } from "../../../../domain/categories/utils/transform-response"
|
||||
import {
|
||||
FeatureFlag, useFeatureFlag
|
||||
} from "../../../../providers/feature-flag-provider"
|
||||
|
||||
const useOrganizeData = () => {
|
||||
const { isFeatureEnabled } = useFeatureFlag()
|
||||
const { product_types } = useAdminProductTypes(undefined, {
|
||||
staleTime: 0,
|
||||
refetchOnWindowFocus: true,
|
||||
})
|
||||
const { collections } = useAdminCollections()
|
||||
const { product_categories: categories = [] } =
|
||||
isFeatureEnabled(FeatureFlag.PRODUCT_CATEGORIES) &&
|
||||
useAdminProductCategories({
|
||||
parent_category_id: "null",
|
||||
include_descendants_tree: true,
|
||||
})
|
||||
|
||||
const productTypeOptions = useMemo(() => {
|
||||
return (
|
||||
product_types?.map(({ id, value }) => ({
|
||||
value: id,
|
||||
label: value,
|
||||
})) || []
|
||||
)
|
||||
}, [product_types])
|
||||
|
||||
const collectionOptions = useMemo(() => {
|
||||
return (
|
||||
collections?.map(({ id, title }) => ({
|
||||
value: id,
|
||||
label: title,
|
||||
})) || []
|
||||
)
|
||||
}, [collections])
|
||||
|
||||
const categoriesOptions: NestedMultiselectOption[] | undefined = useMemo(
|
||||
() => categories?.map(transformCategoryToNestedFormOptions),
|
||||
[categories]
|
||||
)
|
||||
|
||||
return {
|
||||
productTypeOptions,
|
||||
collectionOptions,
|
||||
categoriesOptions,
|
||||
}
|
||||
}
|
||||
|
||||
export default useOrganizeData
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import { useAdminSalesChannels } from "medusa-react"
|
||||
import React, { useMemo, useState } from "react"
|
||||
import { usePagination, useRowSelect, useTable } from "react-table"
|
||||
import { useDebounce } from "../../../../hooks/use-debounce"
|
||||
import Button from "../../../fundamentals/button"
|
||||
import Modal from "../../../molecules/modal"
|
||||
import { useLayeredModal } from "../../../molecules/modal/layered-modal"
|
||||
import SalesChannelTable, { useSalesChannelsTableColumns } from "./table"
|
||||
import { useSalesChannelsModal } from "./use-sales-channels-modal"
|
||||
|
||||
const LIMIT = 15
|
||||
|
||||
const AddScreen = () => {
|
||||
const [columns] = useSalesChannelsTableColumns()
|
||||
const [query, setQuery] = useState<string | undefined>(undefined)
|
||||
const [offset, setOffset] = useState(0)
|
||||
|
||||
const deboucedQuery = useDebounce(query, 500)
|
||||
|
||||
const {
|
||||
sales_channels: salesChannels,
|
||||
count,
|
||||
isLoading,
|
||||
} = useAdminSalesChannels({
|
||||
q: deboucedQuery,
|
||||
limit: LIMIT,
|
||||
offset,
|
||||
})
|
||||
const { source, onClose, onSave } = useSalesChannelsModal()
|
||||
|
||||
const filteredData = React.useMemo(() => {
|
||||
const ids = source.map((channel) => channel.id) || []
|
||||
return salesChannels?.filter(({ id }) => !ids.includes(id)) || []
|
||||
}, [salesChannels, source])
|
||||
|
||||
const { pop, reset } = useLayeredModal()
|
||||
|
||||
const state = useTable(
|
||||
{
|
||||
// @ts-ignore
|
||||
columns,
|
||||
data: filteredData,
|
||||
manualPagination: true,
|
||||
initialState: {
|
||||
pageIndex: Math.floor(offset / LIMIT),
|
||||
pageSize: LIMIT,
|
||||
},
|
||||
autoResetPage: false,
|
||||
autoResetSelectedRows: false,
|
||||
getRowId: (row) => row.id,
|
||||
pageCount: Math.ceil((count || 0) / LIMIT),
|
||||
},
|
||||
usePagination,
|
||||
useRowSelect
|
||||
)
|
||||
|
||||
const saveAndClose = () => {
|
||||
const toSave = [
|
||||
...source,
|
||||
...state.selectedFlatRows.map((row) => row.original),
|
||||
]
|
||||
|
||||
onSave(toSave)
|
||||
reset()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const saveAndGoBack = () => {
|
||||
const toSave = [
|
||||
...source,
|
||||
...state.selectedFlatRows.map((row) => row.original),
|
||||
]
|
||||
|
||||
onSave(toSave)
|
||||
pop()
|
||||
}
|
||||
|
||||
const disableSave = useMemo(() => {
|
||||
return state.selectedFlatRows.length === 0
|
||||
}, [state.selectedFlatRows.length])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Content>
|
||||
<SalesChannelTable
|
||||
isLoading={isLoading}
|
||||
count={count || 0}
|
||||
limit={LIMIT}
|
||||
offset={offset}
|
||||
setOffset={setOffset}
|
||||
setQuery={setQuery}
|
||||
tableState={state}
|
||||
/>
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<div className="space-x-xsmall flex w-full justify-end">
|
||||
<Button variant="secondary" size="small" onClick={pop}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={saveAndGoBack}
|
||||
disabled={disableSave}
|
||||
>
|
||||
Save and go back
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={saveAndClose}
|
||||
disabled={disableSave}
|
||||
>
|
||||
Save and close
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const useAddChannelsModalScreen = () => {
|
||||
const { pop } = useLayeredModal()
|
||||
|
||||
return {
|
||||
title: "Add Sales Channels",
|
||||
onBack: pop,
|
||||
view: <AddScreen />,
|
||||
}
|
||||
}
|
||||
|
||||
export default AddScreen
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import React, { useState } from "react"
|
||||
import { usePagination, useRowSelect, useTable } from "react-table"
|
||||
import SalesChannelTable, {
|
||||
SalesChannelTableActions,
|
||||
useSalesChannelsTableColumns,
|
||||
} from "./table"
|
||||
|
||||
import { useDebounce } from "../../../../hooks/use-debounce"
|
||||
import Modal from "../../../molecules/modal"
|
||||
import { useSalesChannelsModal } from "./use-sales-channels-modal"
|
||||
|
||||
const LIMIT = 12
|
||||
|
||||
const AvailableScreen = () => {
|
||||
const { source, onSave } = useSalesChannelsModal()
|
||||
|
||||
const [columns] = useSalesChannelsTableColumns()
|
||||
const [query, setQuery] = useState<string | undefined>(undefined)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([])
|
||||
|
||||
const deboucedQuery = useDebounce(query, 500)
|
||||
|
||||
const filteredData = React.useMemo(() => {
|
||||
if (!deboucedQuery) {
|
||||
return source
|
||||
}
|
||||
|
||||
return source?.filter(({ name, description }) => {
|
||||
return (
|
||||
name.toLowerCase().includes(deboucedQuery.toLowerCase()) ||
|
||||
description?.toLowerCase().includes(deboucedQuery.toLowerCase())
|
||||
)
|
||||
})
|
||||
}, [source, deboucedQuery])
|
||||
|
||||
const state = useTable(
|
||||
{
|
||||
// @ts-ignore
|
||||
columns,
|
||||
data: filteredData,
|
||||
manualPagination: true,
|
||||
initialState: {
|
||||
pageIndex: Math.floor(offset / LIMIT),
|
||||
pageSize: LIMIT,
|
||||
},
|
||||
autoResetPage: false,
|
||||
autoResetSelectedRows: false,
|
||||
getRowId: (row) => row.id,
|
||||
pageCount: Math.ceil(filteredData.length / LIMIT),
|
||||
},
|
||||
usePagination,
|
||||
useRowSelect
|
||||
)
|
||||
|
||||
const onDeselect = () => {
|
||||
setSelectedRowIds([])
|
||||
state.toggleAllRowsSelected(false)
|
||||
}
|
||||
|
||||
const onRemove = () => {
|
||||
const channels = source.filter((sc) => !selectedRowIds.includes(sc.id))
|
||||
onSave(channels)
|
||||
onDeselect()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal.Content>
|
||||
<SalesChannelTable
|
||||
count={source.length}
|
||||
tableAction={
|
||||
<SalesChannelTableActions
|
||||
numberOfSelectedRows={selectedRowIds.length}
|
||||
onRemove={onRemove}
|
||||
onDeselect={onDeselect}
|
||||
/>
|
||||
}
|
||||
setSelectedRowIds={setSelectedRowIds}
|
||||
limit={LIMIT}
|
||||
offset={offset}
|
||||
setOffset={setOffset}
|
||||
setQuery={setQuery}
|
||||
tableState={state}
|
||||
/>
|
||||
</Modal.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export default AvailableScreen
|
||||
@@ -0,0 +1,55 @@
|
||||
import { SalesChannel } from "@medusajs/medusa"
|
||||
import Button from "../../../fundamentals/button"
|
||||
import Modal from "../../../molecules/modal"
|
||||
import LayeredModal, {
|
||||
useLayeredModal,
|
||||
} from "../../../molecules/modal/layered-modal"
|
||||
import AvailableScreen from "./available-screen"
|
||||
import { SalesChannelsModalContext } from "./use-sales-channels-modal"
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
source?: SalesChannel[]
|
||||
onClose: () => void
|
||||
onSave: (salesChannels: SalesChannel[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-usable Sales Channels Modal, used for adding and editing sales channels both when creating a new product and editing an existing product.
|
||||
*/
|
||||
const SalesChannelsModal = ({ open, source = [], onClose, onSave }: Props) => {
|
||||
const context = useLayeredModal()
|
||||
|
||||
return (
|
||||
<SalesChannelsModalContext.Provider
|
||||
value={{
|
||||
source,
|
||||
onClose,
|
||||
onSave,
|
||||
}}
|
||||
>
|
||||
<LayeredModal open={open} handleClose={onClose} context={context}>
|
||||
<Modal.Body>
|
||||
<Modal.Header handleClose={onClose}>
|
||||
<h1 className="inter-xlarge-semibold">Current Sales Channels</h1>
|
||||
</Modal.Header>
|
||||
<AvailableScreen />
|
||||
<Modal.Footer>
|
||||
<div className="flex w-full items-center justify-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</Modal.Body>
|
||||
</LayeredModal>
|
||||
</SalesChannelsModalContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default SalesChannelsModal
|
||||
@@ -0,0 +1,227 @@
|
||||
import { SalesChannel } from "@medusajs/medusa"
|
||||
import clsx from "clsx"
|
||||
import React, { useMemo } from "react"
|
||||
import { TableInstance } from "react-table"
|
||||
import Button from "../../../fundamentals/button"
|
||||
import PlusIcon from "../../../fundamentals/icons/plus-icon"
|
||||
import IndeterminateCheckbox from "../../../molecules/indeterminate-checkbox"
|
||||
import { useLayeredModal } from "../../../molecules/modal/layered-modal"
|
||||
import Table from "../../../molecules/table"
|
||||
import TableContainer from "../../../organisms/table-container"
|
||||
import { useAddChannelsModalScreen } from "./add-screen"
|
||||
|
||||
type SalesChannelsTableProps = {
|
||||
count: number
|
||||
limit: number
|
||||
offset: number
|
||||
setOffset: (offset: number) => void
|
||||
setQuery: (query: string) => void
|
||||
setSelectedRowIds?: (selectedRowIds: string[]) => void
|
||||
tableAction?: React.ReactNode
|
||||
tableState: TableInstance<SalesChannel>
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
type SalesChannelTableActionProps = {
|
||||
numberOfSelectedRows: number
|
||||
onDeselect: () => void
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
export const useSalesChannelsTableColumns = () => {
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
width: 30,
|
||||
id: "selection",
|
||||
Header: ({ getToggleAllPageRowsSelectedProps }) => (
|
||||
<span className="flex w-[30px] justify-center">
|
||||
<IndeterminateCheckbox {...getToggleAllPageRowsSelectedProps()} />
|
||||
</span>
|
||||
),
|
||||
Cell: ({ row }) => {
|
||||
return (
|
||||
<span
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-[30px] justify-center"
|
||||
>
|
||||
<IndeterminateCheckbox {...row.getToggleRowSelectedProps()} />
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Title",
|
||||
accessor: "name",
|
||||
},
|
||||
{
|
||||
Header: "Description",
|
||||
accessor: "description",
|
||||
},
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
return [columns] as const
|
||||
}
|
||||
|
||||
const SalesChannelTable = ({
|
||||
count,
|
||||
limit,
|
||||
offset,
|
||||
setOffset,
|
||||
setQuery,
|
||||
tableState,
|
||||
setSelectedRowIds,
|
||||
tableAction,
|
||||
isLoading,
|
||||
}: SalesChannelsTableProps) => {
|
||||
const {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
rows,
|
||||
prepareRow,
|
||||
canPreviousPage,
|
||||
canNextPage,
|
||||
pageCount,
|
||||
nextPage,
|
||||
previousPage,
|
||||
// Get the state from the instance
|
||||
state: { pageIndex, ...state },
|
||||
} = tableState
|
||||
|
||||
React.useEffect(() => {
|
||||
if (setSelectedRowIds) {
|
||||
setSelectedRowIds(Object.keys(state.selectedRowIds))
|
||||
}
|
||||
}, [Object.keys(state.selectedRowIds).length])
|
||||
|
||||
const handleNext = () => {
|
||||
if (canNextPage) {
|
||||
setOffset(offset + limit)
|
||||
nextPage()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
if (canPreviousPage) {
|
||||
setOffset(Math.max(offset - limit, 0))
|
||||
previousPage()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TableContainer
|
||||
hasPagination
|
||||
pagingState={{
|
||||
count: count,
|
||||
offset: offset,
|
||||
pageSize: offset + rows.length,
|
||||
title: "Sales Channels",
|
||||
currentPage: pageIndex + 1,
|
||||
pageCount: pageCount,
|
||||
nextPage: handleNext,
|
||||
prevPage: handlePrev,
|
||||
hasNext: canNextPage,
|
||||
hasPrev: canPreviousPage,
|
||||
}}
|
||||
numberOfRows={limit}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<Table
|
||||
{...getTableProps()}
|
||||
enableSearch
|
||||
handleSearch={setQuery}
|
||||
tableActions={tableAction}
|
||||
>
|
||||
<Table.Head>
|
||||
{headerGroups?.map((headerGroup) => (
|
||||
<Table.HeadRow {...headerGroup.getHeaderGroupProps()}>
|
||||
{headerGroup.headers.map((col) => (
|
||||
<Table.HeadCell {...col.getHeaderProps()}>
|
||||
{col.render("Header")}
|
||||
</Table.HeadCell>
|
||||
))}
|
||||
</Table.HeadRow>
|
||||
))}
|
||||
</Table.Head>
|
||||
<Table.Body {...getTableBodyProps()}>
|
||||
{rows.map((row) => {
|
||||
prepareRow(row)
|
||||
return (
|
||||
<Table.Row color={"inherit"} {...row.getRowProps()}>
|
||||
{row.cells.map((cell) => {
|
||||
return (
|
||||
<Table.Cell {...cell.getCellProps()}>
|
||||
{cell.render("Cell")}
|
||||
</Table.Cell>
|
||||
)
|
||||
})}
|
||||
</Table.Row>
|
||||
)
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export const SalesChannelTableActions = ({
|
||||
numberOfSelectedRows,
|
||||
onDeselect,
|
||||
onRemove,
|
||||
}: SalesChannelTableActionProps) => {
|
||||
const addChannelModalScreen = useAddChannelsModalScreen()
|
||||
|
||||
const showAddChannels = !!numberOfSelectedRows
|
||||
|
||||
const classes = {
|
||||
"translate-y-[-42px]": !showAddChannels,
|
||||
"translate-y-[0px]": showAddChannels,
|
||||
}
|
||||
|
||||
const { push } = useLayeredModal()
|
||||
|
||||
return (
|
||||
<div className="space-x-xsmall flex h-[34px] overflow-hidden">
|
||||
<div className={clsx("transition-all duration-200", classes)}>
|
||||
<div className="mb-2 flex h-[34px] items-center divide-x">
|
||||
<span className="inter-small-regular text-grey-50 mr-3">
|
||||
{numberOfSelectedRows} selected
|
||||
</span>
|
||||
<div className="space-x-xsmall flex pl-3">
|
||||
<Button
|
||||
onClick={onDeselect}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
className="border-grey-20 border"
|
||||
>
|
||||
Deselect
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onRemove}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
className="border-grey-20 border text-rose-50"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-[34px] justify-end">
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
className="border-grey-20 border"
|
||||
onClick={() => push(addChannelModalScreen)}
|
||||
>
|
||||
<PlusIcon size={20} /> Add Channels
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SalesChannelTable
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { SalesChannel } from "@medusajs/medusa"
|
||||
import React, { useContext } from "react"
|
||||
|
||||
type SalesChannelsModalContextType = {
|
||||
source: SalesChannel[]
|
||||
onClose: () => void
|
||||
onSave: (channels: SalesChannel[]) => void
|
||||
}
|
||||
|
||||
export const SalesChannelsModalContext =
|
||||
React.createContext<SalesChannelsModalContextType | null>(null)
|
||||
|
||||
export const useSalesChannelsModal = () => {
|
||||
const context = useContext(SalesChannelsModalContext)
|
||||
|
||||
if (context === null) {
|
||||
throw new Error(
|
||||
"useSalesChannelsModal must be used within a SalesChannelsModalProvider"
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { FieldArrayWithId, useFieldArray } from "react-hook-form"
|
||||
import { FormImage } from "../../../../types/shared"
|
||||
import { NestedForm } from "../../../../utils/nested-form"
|
||||
import FileUploadField from "../../../atoms/file-upload-field"
|
||||
import TrashIcon from "../../../fundamentals/icons/trash-icon"
|
||||
import Actionables, {
|
||||
ActionType
|
||||
} from "../../../molecules/actionables"
|
||||
|
||||
export type ThumbnailFormType = {
|
||||
images: FormImage[]
|
||||
}
|
||||
|
||||
type Props = {
|
||||
form: NestedForm<ThumbnailFormType>
|
||||
}
|
||||
|
||||
const ThumbnailForm = ({ form }: Props) => {
|
||||
const { control, path } = form
|
||||
|
||||
const { fields, remove, replace, append } = useFieldArray({
|
||||
control: control,
|
||||
name: path("images"),
|
||||
})
|
||||
|
||||
const handleFilesChosen = (files: File[]) => {
|
||||
const toAppend = files.map((file) => ({
|
||||
url: URL.createObjectURL(file),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
nativeFile: file,
|
||||
selected: false,
|
||||
}))
|
||||
|
||||
if (files.length) {
|
||||
replace(toAppend)
|
||||
} else {
|
||||
append(toAppend)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<div className="mt-large">
|
||||
<FileUploadField
|
||||
onFileChosen={handleFilesChosen}
|
||||
placeholder="1200 x 1600 (3:4) recommended, up to 10MB each"
|
||||
filetypes={["image/gif", "image/jpeg", "image/png", "image/webp"]}
|
||||
className="py-large"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{fields.length > 0 && (
|
||||
<div className="mt-large">
|
||||
<h2 className="inter-large-semibold mb-small">Upload</h2>
|
||||
|
||||
<div className="gap-y-2xsmall flex flex-col">
|
||||
{fields.map((field, index) => {
|
||||
return (
|
||||
<Image
|
||||
key={field.id}
|
||||
image={field}
|
||||
index={index}
|
||||
remove={remove}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ThumbnailProps = {
|
||||
image: FieldArrayWithId<ThumbnailFormType, "images", "id">
|
||||
index: number
|
||||
remove: (index: number) => void
|
||||
}
|
||||
|
||||
const Image = ({ image, index, remove }: ThumbnailProps) => {
|
||||
const actions: ActionType[] = [
|
||||
{
|
||||
label: "Delete",
|
||||
onClick: () => remove(index),
|
||||
icon: <TrashIcon size={20} />,
|
||||
variant: "danger",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="px-base py-xsmall hover:bg-grey-5 rounded-rounded group flex items-center justify-between">
|
||||
<div className="gap-x-large flex items-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center">
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.name || "Uploaded image"}
|
||||
className="rounded-rounded max-h-[64px] max-w-[64px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="inter-small-regular flex flex-col text-left">
|
||||
<p>{image.name}</p>
|
||||
<p className="text-grey-50">
|
||||
{image.size ? `${(image.size / 1024).toFixed(2)} KB` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Actionables actions={actions} forceDropdown />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ThumbnailForm
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import { UseFormReturn } from "react-hook-form"
|
||||
import { nestedForm } from "../../../../../utils/nested-form"
|
||||
import InputError from "../../../../atoms/input-error"
|
||||
import IconTooltip from "../../../../molecules/icon-tooltip"
|
||||
import Accordion from "../../../../organisms/accordion"
|
||||
import { PricesFormType } from "../../../general/prices-form"
|
||||
import CustomsForm, { CustomsFormType } from "../../customs-form"
|
||||
import DimensionsForm, { DimensionsFormType } from "../../dimensions-form"
|
||||
import VariantGeneralForm, {
|
||||
VariantGeneralFormType,
|
||||
} from "../variant-general-form"
|
||||
import VariantPricesForm from "../variant-prices-form"
|
||||
import VariantSelectOptionsForm, {
|
||||
VariantOptionValueType,
|
||||
VariantSelectOptionsFormType,
|
||||
} from "../variant-select-options-form"
|
||||
import VariantStockForm, { VariantStockFormType } from "../variant-stock-form"
|
||||
|
||||
export type CreateFlowVariantFormType = {
|
||||
/**
|
||||
* Used to identify the variant during product create flow. Will not be submitted to the backend.
|
||||
*/
|
||||
_internal_id?: string
|
||||
general: VariantGeneralFormType
|
||||
prices: PricesFormType
|
||||
stock: VariantStockFormType
|
||||
options: VariantSelectOptionsFormType
|
||||
customs: CustomsFormType
|
||||
dimensions: DimensionsFormType
|
||||
}
|
||||
|
||||
type Props = {
|
||||
form: UseFormReturn<CreateFlowVariantFormType, any>
|
||||
options: VariantOptionValueType[]
|
||||
onCreateOption: (optionId: string, value: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-usable Product Variant form used to add and edit product variants during the product create flow.
|
||||
* @example
|
||||
* const MyForm = () => {
|
||||
* const form = useForm<CreateFlowVariantFormType>()
|
||||
* const { handleSubmit } = form
|
||||
*
|
||||
* const onSubmit = handleSubmit((data) => {
|
||||
* // do something with data
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <form onSubmit={onSubmit}>
|
||||
* <CreateFlowVariantForm form={form} />
|
||||
* <Button type="submit">Submit</Button>
|
||||
* </form>
|
||||
* )
|
||||
* }
|
||||
*/
|
||||
const CreateFlowVariantForm = ({ form, options, onCreateOption }: Props) => {
|
||||
return (
|
||||
<Accordion type="multiple" defaultValue={["general"]}>
|
||||
<Accordion.Item title="General" value="general" required>
|
||||
<div>
|
||||
<VariantGeneralForm form={nestedForm(form, "general")} />
|
||||
<div className="mt-xlarge">
|
||||
<div className="gap-x-2xsmall mb-base flex items-center">
|
||||
<h3 className="inter-base-semibold">Options</h3>
|
||||
<IconTooltip
|
||||
type="info"
|
||||
content="Options are used to define the color, size, etc. of the variant."
|
||||
/>
|
||||
</div>
|
||||
<VariantSelectOptionsForm
|
||||
form={nestedForm(form, "options")}
|
||||
options={options}
|
||||
onCreateOption={onCreateOption}
|
||||
/>
|
||||
<InputError errors={form.formState.errors} name="options" />
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item title="Pricing" value="pricing">
|
||||
<VariantPricesForm form={nestedForm(form, "prices")} />
|
||||
</Accordion.Item>
|
||||
<Accordion.Item title="Stock & Inventory" value="stock">
|
||||
<VariantStockForm form={nestedForm(form, "stock")} />
|
||||
</Accordion.Item>
|
||||
<Accordion.Item title="Shipping" value="shipping">
|
||||
<p className="inter-base-regular text-grey-50">
|
||||
Shipping information can be required depending on your shipping
|
||||
provider, and whether or not you are shipping internationally.
|
||||
</p>
|
||||
<div className="mt-large">
|
||||
<h3 className="inter-base-semibold mb-2xsmall">Dimensions</h3>
|
||||
<p className="inter-base-regular text-grey-50 mb-large">
|
||||
Configure to calculate the most accurate shipping rates.
|
||||
</p>
|
||||
<DimensionsForm form={nestedForm(form, "dimensions")} />
|
||||
</div>
|
||||
<div className="mt-xlarge">
|
||||
<h3 className="inter-base-semibold mb-2xsmall">Customs</h3>
|
||||
<p className="inter-base-regular text-grey-50 mb-large">
|
||||
Configure if you are shipping internationally.
|
||||
</p>
|
||||
<CustomsForm form={nestedForm(form, "customs")} />
|
||||
</div>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateFlowVariantForm
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import { useFieldArray, UseFormReturn } from "react-hook-form"
|
||||
import CustomsForm, { CustomsFormType } from "../../customs-form"
|
||||
import DimensionsForm, { DimensionsFormType } from "../../dimensions-form"
|
||||
import VariantGeneralForm, {
|
||||
VariantGeneralFormType
|
||||
} from "../variant-general-form"
|
||||
import VariantStockForm, { VariantStockFormType } from "../variant-stock-form"
|
||||
|
||||
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
|
||||
import { nestedForm } from "../../../../../utils/nested-form"
|
||||
import IconTooltip from "../../../../molecules/icon-tooltip"
|
||||
import InputField from "../../../../molecules/input"
|
||||
import Accordion from "../../../../organisms/accordion"
|
||||
import { PricesFormType } from "../../../general/prices-form"
|
||||
import VariantPricesForm from "../variant-prices-form"
|
||||
|
||||
export type EditFlowVariantFormType = {
|
||||
/**
|
||||
* Used to identify the variant during product create flow. Will not be submitted to the backend.
|
||||
*/
|
||||
_internal_id?: string
|
||||
general: VariantGeneralFormType
|
||||
prices: PricesFormType
|
||||
stock: VariantStockFormType
|
||||
options: {
|
||||
title: string
|
||||
value: string
|
||||
id: string
|
||||
}[]
|
||||
customs: CustomsFormType
|
||||
dimensions: DimensionsFormType
|
||||
}
|
||||
|
||||
type Props = {
|
||||
form: UseFormReturn<EditFlowVariantFormType, any>
|
||||
isEdit?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-usable Product Variant form used to add and edit product variants.
|
||||
* @example
|
||||
* const MyForm = () => {
|
||||
* const form = useForm<VariantFormType>()
|
||||
* const { handleSubmit } = form
|
||||
*
|
||||
* const onSubmit = handleSubmit((data) => {
|
||||
* // do something with data
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <form onSubmit={onSubmit}>
|
||||
* <VariantForm form={form} />
|
||||
* <Button type="submit">Submit</Button>
|
||||
* </form>
|
||||
* )
|
||||
* }
|
||||
*/
|
||||
const EditFlowVariantForm = ({ form, isEdit }: Props) => {
|
||||
const { isFeatureEnabled } = useFeatureFlag()
|
||||
const { fields } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "options",
|
||||
})
|
||||
|
||||
const showStockAndInventory = !isEdit || !isFeatureEnabled("inventoryService")
|
||||
|
||||
return (
|
||||
<Accordion type="multiple" defaultValue={["general"]}>
|
||||
<Accordion.Item title="General" value="general" required>
|
||||
<div>
|
||||
<VariantGeneralForm form={nestedForm(form, "general")} />
|
||||
<div className="mt-xlarge">
|
||||
<div className="mb-base gap-x-2xsmall flex items-center">
|
||||
<h3 className="inter-base-semibold">Options</h3>
|
||||
<IconTooltip
|
||||
type="info"
|
||||
content="Options are used to define the color, size, etc. of the variant."
|
||||
/>
|
||||
</div>
|
||||
<div className="gap-large pb-2xsmall grid grid-cols-2">
|
||||
{fields.map((field, index) => {
|
||||
return (
|
||||
<InputField
|
||||
required
|
||||
key={field.id}
|
||||
label={field.title}
|
||||
{...form.register(`options.${index}.value`, {
|
||||
required: `Option value for ${field.title} is required`,
|
||||
})}
|
||||
errors={form.formState.errors}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item title="Pricing" value="pricing">
|
||||
<VariantPricesForm form={nestedForm(form, "prices")} />
|
||||
</Accordion.Item>
|
||||
{showStockAndInventory && (
|
||||
<Accordion.Item title="Stock & Inventory" value="stock">
|
||||
<VariantStockForm form={nestedForm(form, "stock")} />
|
||||
</Accordion.Item>
|
||||
)}
|
||||
<Accordion.Item title="Shipping" value="shipping">
|
||||
<p className="inter-base-regular text-grey-50">
|
||||
Shipping information can be required depending on your shipping
|
||||
provider, and whether or not you are shipping internationally.
|
||||
</p>
|
||||
<div className="mt-large">
|
||||
<h3 className="inter-base-semibold mb-2xsmall">Dimensions</h3>
|
||||
<p className="inter-base-regular text-grey-50 mb-large">
|
||||
Configure to calculate the most accurate shipping rates.
|
||||
</p>
|
||||
<DimensionsForm form={nestedForm(form, "dimensions")} />
|
||||
</div>
|
||||
<div className="mt-xlarge">
|
||||
<h3 className="inter-base-semibold mb-2xsmall">Customs</h3>
|
||||
<p className="inter-base-regular text-grey-50 mb-large">
|
||||
Configure if you are shipping internationally.
|
||||
</p>
|
||||
<CustomsForm form={nestedForm(form, "customs")} />
|
||||
</div>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditFlowVariantForm
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import FormValidator from "../../../../../utils/form-validator"
|
||||
import { NestedForm } from "../../../../../utils/nested-form"
|
||||
import InputField from "../../../../molecules/input"
|
||||
|
||||
export type VariantGeneralFormType = {
|
||||
title: string | null
|
||||
material: string | null
|
||||
}
|
||||
|
||||
type Props = {
|
||||
form: NestedForm<VariantGeneralFormType>
|
||||
}
|
||||
|
||||
const VariantGeneralForm = ({ form }: Props) => {
|
||||
const {
|
||||
path,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = form
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="inter-base-regular text-grey-50">
|
||||
Configure the general information for this variant.
|
||||
</p>
|
||||
<div className="pt-large">
|
||||
<div className="gap-x-large grid grid-cols-2">
|
||||
<InputField
|
||||
label="Custom title"
|
||||
placeholder="Green / XL..."
|
||||
{...register(path("title"), {
|
||||
pattern: FormValidator.whiteSpaceRule("Title"),
|
||||
})}
|
||||
errors={errors}
|
||||
/>
|
||||
<InputField
|
||||
label="Material"
|
||||
placeholder="80% wool, 20% cotton..."
|
||||
{...form.register(path("material"), {
|
||||
pattern: FormValidator.whiteSpaceRule("Material"),
|
||||
})}
|
||||
errors={errors}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VariantGeneralForm
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { NestedForm } from "../../../../../utils/nested-form"
|
||||
import PricesForm, { PricesFormType } from "../../../general/prices-form"
|
||||
|
||||
type Props = {
|
||||
form: NestedForm<PricesFormType>
|
||||
}
|
||||
|
||||
const VariantPricesForm = ({ form }: Props) => {
|
||||
return (
|
||||
<div>
|
||||
<p className="inter-base-regular text-grey-50">
|
||||
Configure the pricing for this variant.
|
||||
</p>
|
||||
<div className="pt-large">
|
||||
<PricesForm form={form} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VariantPricesForm
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import { isEqual } from "lodash"
|
||||
import { useMemo } from "react"
|
||||
import { useWatch } from "react-hook-form"
|
||||
import { VariantOptionValueType } from "."
|
||||
import { AddVariantsFormType } from "../../../../../domain/products/new/add-variants"
|
||||
import { NestedForm } from "../../../../../utils/nested-form"
|
||||
|
||||
const useCheckOptions = (variantForm: NestedForm<AddVariantsFormType>) => {
|
||||
const { control: variantControl, path: variantPath } = variantForm
|
||||
|
||||
const watchedEntries = useWatch({
|
||||
control: variantControl,
|
||||
name: variantPath("entries"),
|
||||
})
|
||||
|
||||
const watchedOptions = useWatch({
|
||||
control: variantControl,
|
||||
name: variantPath("options"),
|
||||
})
|
||||
|
||||
const existingCombinations = useMemo(() => {
|
||||
const completedVariants = watchedEntries?.filter((entry) =>
|
||||
entry.options.every((vo) => vo.option !== null)
|
||||
)
|
||||
|
||||
const existingVariants = completedVariants?.map((we) => ({
|
||||
id: we._internal_id,
|
||||
value_combo: we.options.map((vo) => vo.option),
|
||||
}))
|
||||
|
||||
return existingVariants
|
||||
}, [watchedEntries])
|
||||
|
||||
const checkForDuplicate = ({
|
||||
id,
|
||||
options,
|
||||
}: {
|
||||
id: string
|
||||
options: (VariantOptionValueType | null)[]
|
||||
}) => {
|
||||
if (!existingCombinations?.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const existingCombinationsToCheck = existingCombinations.filter(
|
||||
(ec) => ec.id !== id
|
||||
)
|
||||
|
||||
return existingCombinationsToCheck.some((existingCombination) => {
|
||||
return isEqual(
|
||||
existingCombination.value_combo.map((o) => o?.value),
|
||||
options.map((o) => o?.value)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const getOptions = () => {
|
||||
const options: VariantOptionValueType[] = watchedOptions
|
||||
?.map((o) => {
|
||||
return o.values.map((v) => ({
|
||||
option_id: o.id,
|
||||
value: v,
|
||||
label: v,
|
||||
}))
|
||||
})
|
||||
.flat()
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
return { checkForDuplicate, getOptions }
|
||||
}
|
||||
|
||||
export default useCheckOptions
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import { Controller, useFieldArray } from "react-hook-form"
|
||||
import { NestedForm } from "../../../../../utils/nested-form"
|
||||
import { NextCreateableSelect } from "../../../../molecules/select/next-select"
|
||||
|
||||
export type VariantOptionValueType = {
|
||||
option_id: string
|
||||
value: string
|
||||
label: string
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
export type VariantOptionType = {
|
||||
option_id: string
|
||||
title: string
|
||||
option: VariantOptionValueType | null
|
||||
}
|
||||
|
||||
export type VariantSelectOptionsFormType = VariantOptionType[]
|
||||
|
||||
type Props = {
|
||||
form: NestedForm<VariantSelectOptionsFormType>
|
||||
options: VariantOptionValueType[]
|
||||
onCreateOption: (optionId: string, value: string) => void
|
||||
}
|
||||
|
||||
const VariantSelectOptionsForm = ({ form, options, onCreateOption }: Props) => {
|
||||
const { control, path } = form
|
||||
|
||||
const { fields } = useFieldArray({
|
||||
control: form.control,
|
||||
name: path(),
|
||||
keyName: "fieldId",
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="gap-large pb-2xsmall grid grid-cols-2">
|
||||
{fields.map((field, index) => {
|
||||
return (
|
||||
<Controller
|
||||
key={field.fieldId}
|
||||
control={control}
|
||||
name={path(`${index}.option`)}
|
||||
render={({ field: { value, onChange, onBlur, ref } }) => {
|
||||
return (
|
||||
<NextCreateableSelect
|
||||
ref={ref}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
label={field.title}
|
||||
placeholder="Choose an option"
|
||||
required
|
||||
options={
|
||||
options.filter((o) => o.option_id === field.option_id) || []
|
||||
}
|
||||
onCreateOption={(value) => {
|
||||
const newOption = {
|
||||
option_id: field.option_id,
|
||||
value: value,
|
||||
label: value,
|
||||
}
|
||||
|
||||
onCreateOption(field.option_id, value)
|
||||
|
||||
onChange(newOption)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VariantSelectOptionsForm
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
import { InventoryLevelDTO, StockLocationDTO } from "@medusajs/medusa"
|
||||
import { Controller, useFieldArray } from "react-hook-form"
|
||||
|
||||
import clsx from "clsx"
|
||||
import { sum } from "lodash"
|
||||
import { useAdminStockLocations } from "medusa-react"
|
||||
import React from "react"
|
||||
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
|
||||
import { NestedForm } from "../../../../../utils/nested-form"
|
||||
import Switch from "../../../../atoms/switch"
|
||||
import Button from "../../../../fundamentals/button"
|
||||
import FeatureToggle from "../../../../fundamentals/feature-toggle"
|
||||
import IconBadge from "../../../../fundamentals/icon-badge"
|
||||
import BuildingsIcon from "../../../../fundamentals/icons/buildings-icon"
|
||||
import InputField from "../../../../molecules/input"
|
||||
import { LayeredModalContext } from "../../../../molecules/modal/layered-modal"
|
||||
import { ManageLocationsScreen } from "../../variant-inventory-form/variant-stock-form"
|
||||
|
||||
export type VariantStockFormType = {
|
||||
manage_inventory: boolean
|
||||
allow_backorder: boolean
|
||||
inventory_quantity: number | null
|
||||
sku: string | null
|
||||
ean: string | null
|
||||
upc: string | null
|
||||
barcode: string | null
|
||||
stock_location?: { stocked_quantity: number; location_id: string }[]
|
||||
}
|
||||
|
||||
type Props = {
|
||||
form: NestedForm<VariantStockFormType>
|
||||
}
|
||||
|
||||
const VariantStockForm = ({ form }: Props) => {
|
||||
const layeredModalContext = React.useContext(LayeredModalContext)
|
||||
|
||||
const { isFeatureEnabled } = useFeatureFlag()
|
||||
|
||||
const stockLocationEnabled = isFeatureEnabled("stockLocationService")
|
||||
|
||||
const { stock_locations, refetch } = useAdminStockLocations(
|
||||
{},
|
||||
{ enabled: stockLocationEnabled }
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (stockLocationEnabled) {
|
||||
refetch()
|
||||
}
|
||||
}, [stockLocationEnabled, refetch])
|
||||
|
||||
const stockLocationsMap = React.useMemo(() => {
|
||||
return new Map(stock_locations?.map((sl) => [sl.id, sl]))
|
||||
}, [stock_locations])
|
||||
|
||||
const {
|
||||
path,
|
||||
control,
|
||||
register,
|
||||
formState: { errors },
|
||||
watch,
|
||||
} = form
|
||||
|
||||
const {
|
||||
fields: selectedLocations,
|
||||
append,
|
||||
remove,
|
||||
} = useFieldArray({
|
||||
control,
|
||||
name: path("stock_location"),
|
||||
})
|
||||
|
||||
const locs = watch(
|
||||
selectedLocations?.map((sl, idx) =>
|
||||
path(`stock_location.${idx}.stocked_quantity`)
|
||||
)
|
||||
)
|
||||
const totalStockedQuantity = React.useMemo(() => {
|
||||
return sum(locs)
|
||||
}, [locs])
|
||||
|
||||
const addLocations = async (data) => {
|
||||
const removed = data.removed.map((r) =>
|
||||
selectedLocations.findIndex((sl) => sl.location_id === r.id)
|
||||
)
|
||||
|
||||
removed.forEach((r) => remove(r))
|
||||
|
||||
data.added.forEach((added) => {
|
||||
append({
|
||||
location_id: added,
|
||||
stocked_quantity: 0,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="inter-base-regular text-grey-50">
|
||||
Configure the inventory and stock for this variant.
|
||||
</p>
|
||||
<div className="gap-y-xlarge pt-large flex flex-col">
|
||||
<div className="gap-large grid grid-cols-2">
|
||||
<InputField
|
||||
label="Stock keeping unit (SKU)"
|
||||
placeholder="SUN-G, JK1234..."
|
||||
{...register(path("sku"))}
|
||||
/>
|
||||
<InputField
|
||||
label="Quantity in stock"
|
||||
type="number"
|
||||
placeholder="100..."
|
||||
errors={errors}
|
||||
{...register(path("inventory_quantity"), {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
<InputField
|
||||
label="EAN (Barcode)"
|
||||
placeholder="123456789102..."
|
||||
{...register(path("ean"))}
|
||||
/>
|
||||
<InputField
|
||||
label="UPC (Barcode)"
|
||||
placeholder="023456789104..."
|
||||
{...register(path("upc"))}
|
||||
/>
|
||||
<InputField
|
||||
label="Barcode"
|
||||
placeholder="123456789104..."
|
||||
{...register(path("barcode"))}
|
||||
/>
|
||||
</div>
|
||||
<div className="gap-y-2xsmall flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="inter-base-semibold mb-2xsmall">Manage inventory</h3>
|
||||
<Controller
|
||||
control={control}
|
||||
name={path("manage_inventory")}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return <Switch checked={value} onCheckedChange={onChange} />
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="inter-base-regular text-grey-50">
|
||||
When checked Medusa will regulate the inventory when orders and
|
||||
returns are made.
|
||||
</p>
|
||||
</div>
|
||||
<div className="gap-y-2xsmall flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="inter-base-semibold mb-2xsmall">Allow backorders</h3>
|
||||
<Controller
|
||||
control={control}
|
||||
name={path("allow_backorder")}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return <Switch checked={value} onCheckedChange={onChange} />
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="inter-base-regular text-grey-50">
|
||||
When checked the product will be available for purchase despite the
|
||||
product being sold out
|
||||
</p>
|
||||
</div>
|
||||
<FeatureToggle featureFlag="inventoryService">
|
||||
<div
|
||||
className={clsx({
|
||||
"pointer-events-none opacity-50 transition-opacity duration-100":
|
||||
!form.watch(path("manage_inventory")),
|
||||
})}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="gap-y-2xsmall mb-4 flex flex-col">
|
||||
<h3 className="inter-base-semibold mb-2xsmall">Quantity</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="inter-base-regular text-grey-50">Location</p>
|
||||
<p className="inter-base-regular text-grey-50">In Stock</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="gap-y-base flex flex-col pb-6">
|
||||
{selectedLocations.map((sl, i) => (
|
||||
<div key={sl.id} className="flex items-center">
|
||||
<div className="inter-base-regular flex items-center">
|
||||
<IconBadge className="mr-base">
|
||||
<BuildingsIcon />
|
||||
</IconBadge>
|
||||
{stockLocationsMap.get(sl.location_id)?.name}
|
||||
</div>
|
||||
<div className="ml-auto flex">
|
||||
<InputField
|
||||
placeholder={"0"}
|
||||
type="number"
|
||||
{...register(
|
||||
path(`stock_location.${i}.stocked_quantity`),
|
||||
{
|
||||
valueAsNumber: true,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!!selectedLocations.length && (
|
||||
<div className="text-grey-50 mb-6 flex items-center justify-between border-t border-dashed pt-6">
|
||||
<p>Total inventory at all locations</p>
|
||||
<p>{`${totalStockedQuantity} available`}</p>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
className="w-full border"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
layeredModalContext.push(
|
||||
// @ts-ignore
|
||||
ManageLocationsScreen(
|
||||
layeredModalContext.pop,
|
||||
selectedLocations as InventoryLevelDTO[],
|
||||
stock_locations as StockLocationDTO[],
|
||||
addLocations
|
||||
)
|
||||
)
|
||||
}}
|
||||
>
|
||||
Manage locations
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</FeatureToggle>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VariantStockForm
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { InventoryLevelDTO } from "@medusajs/medusa"
|
||||
import { UseFormReturn } from "react-hook-form"
|
||||
import { nestedForm } from "../../../../../utils/nested-form"
|
||||
import VariantStockForm, { VariantStockFormType } from "../variant-stock-form"
|
||||
|
||||
export type EditFlowVariantFormType = {
|
||||
stock: VariantStockFormType
|
||||
}
|
||||
|
||||
type Props = {
|
||||
form: UseFormReturn<EditFlowVariantFormType, any>
|
||||
locationLevels: InventoryLevelDTO[]
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-usable Product Variant form used to add and edit product variants.
|
||||
* @example
|
||||
* const MyForm = () => {
|
||||
* const form = useForm<VariantFormType>()
|
||||
* const { handleSubmit } = form
|
||||
*
|
||||
* const onSubmit = handleSubmit((data) => {
|
||||
* // do something with data
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <form onSubmit={onSubmit}>
|
||||
* <VariantForm form={form} />
|
||||
* <Button type="submit">Submit</Button>
|
||||
* </form>
|
||||
* )
|
||||
* }
|
||||
*/
|
||||
const EditFlowVariantForm = ({ form, isLoading, locationLevels }: Props) => {
|
||||
if (isLoading) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<VariantStockForm
|
||||
locationLevels={locationLevels}
|
||||
form={nestedForm(form, "stock")}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditFlowVariantForm
|
||||
+364
@@ -0,0 +1,364 @@
|
||||
import { InventoryLevelDTO, StockLocationDTO } from "@medusajs/medusa"
|
||||
import { useAdminStockLocations } from "medusa-react"
|
||||
import React, { useMemo, useState } from "react"
|
||||
import { Controller, useFieldArray } from "react-hook-form"
|
||||
import { NestedForm } from "../../../../../utils/nested-form"
|
||||
import Switch from "../../../../atoms/switch"
|
||||
import Button from "../../../../fundamentals/button"
|
||||
import IconBadge from "../../../../fundamentals/icon-badge"
|
||||
import BuildingsIcon from "../../../../fundamentals/icons/buildings-icon"
|
||||
import InputField from "../../../../molecules/input"
|
||||
import Modal from "../../../../molecules/modal"
|
||||
import { useLayeredModal } from "../../../../molecules/modal/layered-modal"
|
||||
|
||||
export type VariantStockFormType = {
|
||||
manage_inventory?: boolean
|
||||
allow_backorder: boolean
|
||||
inventory_quantity: number | null
|
||||
sku: string | null
|
||||
ean: string | null
|
||||
upc: string | null
|
||||
barcode: string | null
|
||||
location_levels?: Partial<InventoryLevelDTO>[] | null
|
||||
}
|
||||
|
||||
type Props = {
|
||||
locationLevels: InventoryLevelDTO[]
|
||||
form: NestedForm<VariantStockFormType>
|
||||
}
|
||||
|
||||
const VariantStockForm = ({ form, locationLevels }: Props) => {
|
||||
const locationLevelMap = useMemo(
|
||||
() => new Map(locationLevels.map((l) => [l.location_id, l])),
|
||||
[locationLevels]
|
||||
)
|
||||
|
||||
const layeredModalContext = useLayeredModal()
|
||||
|
||||
const { stock_locations: locations, isLoading } = useAdminStockLocations()
|
||||
|
||||
const { path, control, register, watch } = form
|
||||
|
||||
const manageInventory = watch(path("manage_inventory"))
|
||||
|
||||
const {
|
||||
fields: selectedLocations,
|
||||
append,
|
||||
remove,
|
||||
} = useFieldArray({
|
||||
control,
|
||||
name: path("location_levels"),
|
||||
})
|
||||
|
||||
const handleUpdateLocations = async (data: {
|
||||
added: string[]
|
||||
removed: string[]
|
||||
}) => {
|
||||
const removed = data.removed.map((r) =>
|
||||
selectedLocations.findIndex((sl) => sl.location_id === r)
|
||||
)
|
||||
|
||||
removed.forEach((r) => remove(r))
|
||||
|
||||
data.added.forEach((added) => {
|
||||
append({
|
||||
location_id: added,
|
||||
stocked_quantity: locationLevelMap.get(added)?.stocked_quantity ?? 0,
|
||||
reserved_quantity: locationLevelMap.get(added)?.reserved_quantity ?? 0,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="gap-y-xlarge flex flex-col">
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<h3 className="inter-base-semibold">General</h3>
|
||||
<div className="gap-large grid grid-cols-2">
|
||||
<InputField
|
||||
label="Stock keeping unit (SKU)"
|
||||
placeholder="SUN-G, JK1234..."
|
||||
{...register(path("sku"))}
|
||||
/>
|
||||
<InputField
|
||||
label="EAN (Barcode)"
|
||||
placeholder="123456789102..."
|
||||
{...register(path("ean"))}
|
||||
/>
|
||||
<InputField
|
||||
label="UPC (Barcode)"
|
||||
placeholder="023456789104..."
|
||||
{...register(path("upc"))}
|
||||
/>
|
||||
<InputField
|
||||
label="Barcode"
|
||||
placeholder="123456789104..."
|
||||
{...register(path("barcode"))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="gap-y-2xsmall flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="inter-base-semibold mb-2xsmall">Manage inventory</h3>
|
||||
<Controller
|
||||
control={control}
|
||||
name={path("manage_inventory")}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return <Switch checked={value} onCheckedChange={onChange} />
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="inter-base-regular text-grey-50">
|
||||
When checked Medusa will regulate the inventory when orders and
|
||||
returns are made.
|
||||
</p>
|
||||
</div>
|
||||
{manageInventory && (
|
||||
<>
|
||||
<div className="gap-y-2xsmall flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="inter-base-semibold mb-2xsmall">
|
||||
Allow backorders
|
||||
</h3>
|
||||
<Controller
|
||||
control={control}
|
||||
name={path("allow_backorder")}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return <Switch checked={value} onCheckedChange={onChange} />
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="inter-base-regular text-grey-50">
|
||||
When checked the product will be available for purchase despite
|
||||
the product being sold out
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-col text-base">
|
||||
<h3 className="inter-base-semibold mb-2xsmall">Quantity</h3>
|
||||
{!isLoading && locations && (
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="inter-base-regular text-grey-50 flex justify-between py-3">
|
||||
<div className="">Location</div>
|
||||
<div className="">In Stock</div>
|
||||
</div>
|
||||
{selectedLocations.map((level, i) => {
|
||||
console.log(level)
|
||||
const locationDetails = locations.find(
|
||||
(l: StockLocationDTO) => l.id === level.location_id
|
||||
)
|
||||
|
||||
return (
|
||||
<div key={level.id} className="flex items-center py-3">
|
||||
<div className="inter-base-regular flex items-center">
|
||||
<IconBadge className="mr-base">
|
||||
<BuildingsIcon />
|
||||
</IconBadge>
|
||||
{locationDetails?.name}
|
||||
</div>
|
||||
<div className="ml-auto flex">
|
||||
<div className="mr-base text-small text-grey-50 flex flex-col">
|
||||
<span className="whitespace-nowrap text-right">
|
||||
{`${level.reserved_quantity} reserved`}
|
||||
</span>
|
||||
<span className="whitespace-nowrap text-right">{`${
|
||||
level.stocked_quantity - level.reserved_quantity
|
||||
} available`}</span>
|
||||
</div>
|
||||
<InputField
|
||||
placeholder={"0"}
|
||||
type="number"
|
||||
{...register(
|
||||
path(`location_levels.${i}.stocked_quantity`),
|
||||
{ valueAsNumber: true }
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
layeredModalContext.push(
|
||||
// @ts-ignore
|
||||
ManageLocationsScreen(
|
||||
layeredModalContext.pop,
|
||||
selectedLocations,
|
||||
locations,
|
||||
handleUpdateLocations
|
||||
)
|
||||
)
|
||||
}}
|
||||
>
|
||||
Manage locations
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ManageLocationsScreen = (
|
||||
pop: () => void,
|
||||
levels: Partial<InventoryLevelDTO>[],
|
||||
locations: StockLocationDTO[],
|
||||
onSubmit: (value: any) => Promise<void>
|
||||
) => {
|
||||
return {
|
||||
title: "Manage locations",
|
||||
onBack: () => pop(),
|
||||
view: (
|
||||
<ManageLocationsForm
|
||||
existingLevels={levels}
|
||||
locationOptions={locations}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
type ManageLocationFormProps = {
|
||||
existingLevels: Partial<InventoryLevelDTO>[]
|
||||
locationOptions: StockLocationDTO[]
|
||||
onSubmit: (value: any) => Promise<void>
|
||||
}
|
||||
|
||||
const ManageLocationsForm = ({
|
||||
existingLevels,
|
||||
locationOptions,
|
||||
onSubmit,
|
||||
}: ManageLocationFormProps) => {
|
||||
const layeredModalContext = useLayeredModal()
|
||||
const { pop } = layeredModalContext
|
||||
|
||||
const existingLocations = useMemo(() => {
|
||||
return existingLevels.map((level) => level.location_id!)
|
||||
}, [existingLevels])
|
||||
|
||||
const [selectedLocations, setSelectedLocations] =
|
||||
useState<string[]>(existingLocations)
|
||||
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
const selectedIsExisting = selectedLocations.every((locationId) =>
|
||||
existingLocations.includes(locationId)
|
||||
)
|
||||
setIsDirty(
|
||||
!selectedIsExisting ||
|
||||
selectedLocations.length !== existingLocations.length
|
||||
)
|
||||
}, [existingLocations, selectedLocations])
|
||||
|
||||
const handleToggleLocation = (locationId: string) => {
|
||||
if (selectedLocations.includes(locationId)) {
|
||||
setSelectedLocations(selectedLocations.filter((id) => id !== locationId))
|
||||
} else {
|
||||
setSelectedLocations([...selectedLocations, locationId])
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: On submit, create location level and refetch locations if needed, so that object exists correctly
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
const newLevels = selectedLocations.filter(
|
||||
(locationId: string) => !existingLocations.includes(locationId)
|
||||
)
|
||||
const removedLevels = existingLocations.filter(
|
||||
(locationId) => !!locationId && !selectedLocations.includes(locationId)
|
||||
)
|
||||
|
||||
await onSubmit({
|
||||
added: newLevels,
|
||||
removed: removedLevels,
|
||||
}).then(() => {
|
||||
pop()
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelectAll = (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
setSelectedLocations(locationOptions.map((l) => l.id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Modal.Content>
|
||||
<div>
|
||||
<div className="border-grey-20 pb-base text-grey-50 flex w-full items-center justify-between border-b">
|
||||
<div className="">
|
||||
<p>Select locations that stock the selected variant</p>
|
||||
<p>{`(${selectedLocations.length} of ${locationOptions.length} selected)`}</p>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
className="border"
|
||||
onClick={handleSelectAll}
|
||||
>
|
||||
Select all
|
||||
</Button>
|
||||
</div>
|
||||
{locationOptions.map((loc) => {
|
||||
const existingLevel = selectedLocations.find((l) => l === loc.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border-grey-20 py-base flex items-center justify-between gap-6 border-b"
|
||||
key={loc.id}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<IconBadge className="mr-base">
|
||||
<BuildingsIcon />
|
||||
</IconBadge>
|
||||
<h3>{loc.name}</h3>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!!existingLevel}
|
||||
onCheckedChange={() => handleToggleLocation(loc.id)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<div className="gap-x-xsmall flex w-full justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
className="w-[112px]"
|
||||
onClick={() => pop()}
|
||||
type="button"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="nowrap w-[134px]"
|
||||
size="small"
|
||||
type="submit"
|
||||
disabled={!isDirty}
|
||||
>
|
||||
Save and go back
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VariantStockForm
|
||||
@@ -2,6 +2,7 @@ import clsx from "clsx"
|
||||
import React, { useImperativeHandle } from "react"
|
||||
|
||||
import CheckIcon from "../../fundamentals/icons/check-icon"
|
||||
import MinusIcon from "../../fundamentals/icons/minus-icon"
|
||||
|
||||
type IndeterminateCheckboxProps = {
|
||||
type?: "checkbox" | "radio"
|
||||
@@ -66,6 +67,7 @@ const IndeterminateCheckbox = React.forwardRef<
|
||||
>
|
||||
<span className="self-center">
|
||||
{checked && <CheckIcon size={16} />}
|
||||
{indeterminate && <MinusIcon size={16} />}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
import { Product } from "@medusajs/medusa"
|
||||
import { useAdminCreateVariant } from "medusa-react"
|
||||
import React from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import useNotification from "../../../hooks/use-notification"
|
||||
import { getErrorMessage } from "../../../utils/error-messages"
|
||||
import FormValidator from "../../../utils/form-validator"
|
||||
import Button from "../../fundamentals/button"
|
||||
import PlusIcon from "../../fundamentals/icons/plus-icon"
|
||||
import TrashIcon from "../../fundamentals/icons/trash-icon"
|
||||
import IconTooltip from "../../molecules/icon-tooltip"
|
||||
import Modal from "../../molecules/modal"
|
||||
import CurrencyInput from "../currency-input"
|
||||
import { useValuesFieldArray } from "./use-values-field-array"
|
||||
|
||||
type AddDenominationModalProps = {
|
||||
giftCard: Omit<Product, "beforeInsert">
|
||||
storeCurrency: string
|
||||
currencyCodes: string[]
|
||||
handleClose: () => void
|
||||
}
|
||||
|
||||
const AddDenominationModal: React.FC<AddDenominationModalProps> = ({
|
||||
giftCard,
|
||||
storeCurrency,
|
||||
currencyCodes,
|
||||
handleClose,
|
||||
}) => {
|
||||
const { watch, handleSubmit, control } = useForm<{
|
||||
default_price: number
|
||||
prices: {
|
||||
price: {
|
||||
amount: number
|
||||
currency_code: string
|
||||
}
|
||||
}[]
|
||||
}>()
|
||||
const notification = useNotification()
|
||||
const { mutate, isLoading } = useAdminCreateVariant(giftCard.id)
|
||||
|
||||
// passed to useValuesFieldArray so new prices are intialized with the currenct default price
|
||||
const defaultValue = watch("default_price", 10000)
|
||||
|
||||
const { fields, appendPrice, deletePrice, availableCurrencies } =
|
||||
useValuesFieldArray(
|
||||
currencyCodes,
|
||||
{
|
||||
control,
|
||||
name: "prices",
|
||||
keyName: "indexId",
|
||||
},
|
||||
{
|
||||
defaultAmount: defaultValue,
|
||||
defaultCurrencyCode: storeCurrency,
|
||||
}
|
||||
)
|
||||
|
||||
const onSubmit = async (data: any) => {
|
||||
const prices = [
|
||||
{
|
||||
amount: data.default_price,
|
||||
currency_code: storeCurrency,
|
||||
},
|
||||
]
|
||||
|
||||
if (data.prices) {
|
||||
data.prices.forEach((p) => {
|
||||
prices.push({
|
||||
amount: p.price.amount,
|
||||
currency_code: p.price.currency_code,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
mutate(
|
||||
{
|
||||
title: `${giftCard.variants.length}`,
|
||||
options: [
|
||||
{
|
||||
value: `${data.default_price}`,
|
||||
option_id: giftCard.options[0].id,
|
||||
},
|
||||
],
|
||||
prices,
|
||||
inventory_quantity: 0,
|
||||
manage_inventory: false,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notification("Success", "Denomination added successfully", "success")
|
||||
handleClose()
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = () => {
|
||||
// @ts-ignore
|
||||
if (error.response?.data?.type === "duplicate_error") {
|
||||
return `A denomination with that default value already exists`
|
||||
} else {
|
||||
return getErrorMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
notification("Error", errorMessage(), "error")
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal handleClose={handleClose} isLargeModal>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Modal.Body>
|
||||
<Modal.Header handleClose={handleClose}>
|
||||
<span className="inter-xlarge-semibold">Add Denomination</span>
|
||||
</Modal.Header>
|
||||
<Modal.Content>
|
||||
<div className="mb-xlarge flex-1">
|
||||
<div className="mb-base flex gap-x-2">
|
||||
<h3 className="inter-base-semibold">Default Value</h3>
|
||||
<IconTooltip content="This is the denomination in your store's default currency" />
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="default_price"
|
||||
rules={{
|
||||
required: "Default value is required",
|
||||
max: FormValidator.maxInteger("Default value", storeCurrency),
|
||||
}}
|
||||
render={({ field: { onChange, value, ref } }) => {
|
||||
return (
|
||||
<CurrencyInput.Root
|
||||
currentCurrency={storeCurrency}
|
||||
readOnly
|
||||
size="medium"
|
||||
>
|
||||
<CurrencyInput.Amount
|
||||
ref={ref}
|
||||
label="Amount"
|
||||
amount={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</CurrencyInput.Root>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-base flex gap-x-2">
|
||||
<h3 className="inter-base-semibold">Other Values</h3>
|
||||
<IconTooltip content="Here you can add values in other currencies" />
|
||||
</div>
|
||||
<div className="gap-y-xsmall flex flex-col">
|
||||
{fields.map((field, index) => {
|
||||
return (
|
||||
<div
|
||||
key={field.indexId}
|
||||
className="mb-xsmall flex items-end last:mb-0"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<Controller
|
||||
control={control}
|
||||
key={field.indexId}
|
||||
name={`prices.${index}.price`}
|
||||
rules={{
|
||||
required: FormValidator.required("Price"),
|
||||
validate: (val) => {
|
||||
return FormValidator.validateMaxInteger(
|
||||
"Price",
|
||||
val.amount,
|
||||
val.currency_code
|
||||
)
|
||||
},
|
||||
}}
|
||||
defaultValue={field.price}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const codes = [
|
||||
value?.currency_code,
|
||||
...availableCurrencies,
|
||||
]
|
||||
codes.sort()
|
||||
return (
|
||||
<CurrencyInput.Root
|
||||
currencyCodes={codes}
|
||||
currentCurrency={value?.currency_code}
|
||||
size="medium"
|
||||
readOnly={index === 0}
|
||||
onChange={(code) =>
|
||||
onChange({ ...value, currency_code: code })
|
||||
}
|
||||
>
|
||||
<CurrencyInput.Amount
|
||||
label="Amount"
|
||||
onChange={(amount) =>
|
||||
onChange({ ...value, amount })
|
||||
}
|
||||
amount={value?.amount}
|
||||
/>
|
||||
</CurrencyInput.Root>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
className="ml-large h-10 w-10"
|
||||
type="button"
|
||||
>
|
||||
<TrashIcon
|
||||
onClick={deletePrice(index)}
|
||||
className="text-grey-40"
|
||||
size="20"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-large mb-small">
|
||||
<Button
|
||||
onClick={appendPrice}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
disabled={availableCurrencies?.length === 0}
|
||||
>
|
||||
<PlusIcon size={20} />
|
||||
Add a price
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={handleClose}
|
||||
className="mr-2 min-w-[130px] justify-center"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
className="mr-2 min-w-[130px] justify-center"
|
||||
type="submit"
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</Modal.Body>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddDenominationModal
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
import { useFieldArray, UseFieldArrayOptions, useWatch } from "react-hook-form"
|
||||
|
||||
type UseValuesFieldArrayOptions = {
|
||||
defaultAmount: number
|
||||
defaultCurrencyCode: string
|
||||
}
|
||||
|
||||
type ValuesFormValue = {
|
||||
price: {
|
||||
currency_code: string
|
||||
amount: number
|
||||
}
|
||||
}
|
||||
|
||||
export const useValuesFieldArray = <TKeyName extends string = "id">(
|
||||
currencyCodes: string[],
|
||||
{ control, name, keyName }: UseFieldArrayOptions<TKeyName>,
|
||||
options: UseValuesFieldArrayOptions = {
|
||||
defaultAmount: 1000,
|
||||
defaultCurrencyCode: "usd",
|
||||
}
|
||||
) => {
|
||||
const { defaultAmount } = options
|
||||
const { fields, append, remove } = useFieldArray<ValuesFormValue, TKeyName>({
|
||||
control,
|
||||
name,
|
||||
keyName,
|
||||
})
|
||||
const watchedFields = useWatch({
|
||||
control,
|
||||
name,
|
||||
defaultValue: fields,
|
||||
})
|
||||
|
||||
const selectedCurrencies = watchedFields.map(
|
||||
(field) => field?.price?.currency_code
|
||||
)
|
||||
const availableCurrencies = currencyCodes?.filter(
|
||||
(currency) => !selectedCurrencies.includes(currency)
|
||||
)
|
||||
|
||||
const controlledFields = fields.map((field, index) => {
|
||||
return {
|
||||
...field,
|
||||
...watchedFields[index],
|
||||
}
|
||||
})
|
||||
|
||||
const appendPrice = () => {
|
||||
const newCurrency = availableCurrencies[0]
|
||||
append({
|
||||
price: {
|
||||
currency_code: newCurrency,
|
||||
amount: defaultAmount,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const deletePrice = (index) => {
|
||||
return () => {
|
||||
remove(index)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fields: controlledFields,
|
||||
appendPrice,
|
||||
deletePrice,
|
||||
availableCurrencies,
|
||||
selectedCurrencies,
|
||||
} as const
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import _ from "lodash"
|
||||
import * as React from "react"
|
||||
import { v4 as uuidv4 } from "uuid"
|
||||
import Button from "../../fundamentals/button"
|
||||
import PlusIcon from "../../fundamentals/icons/plus-icon"
|
||||
import TrashIcon from "../../fundamentals/icons/trash-icon"
|
||||
import IconTooltip from "../../molecules/icon-tooltip"
|
||||
import Modal from "../../molecules/modal"
|
||||
import CurrencyInput from "../../organisms/currency-input"
|
||||
|
||||
export type PriceType = {
|
||||
currency_code: string
|
||||
amount: number
|
||||
id?: string
|
||||
}
|
||||
|
||||
type EditDenominationsModalProps = {
|
||||
defaultDenominations: PriceType[]
|
||||
handleClose: () => void
|
||||
onSubmit: (denominations: PriceType[]) => void
|
||||
defaultNewAmount?: number
|
||||
defaultNewCurrencyCode?: string
|
||||
currencyCodes?: string[]
|
||||
}
|
||||
|
||||
const EditDenominationsModal = ({
|
||||
defaultDenominations = [],
|
||||
onSubmit,
|
||||
handleClose,
|
||||
currencyCodes = [],
|
||||
defaultNewAmount = 1000,
|
||||
}: EditDenominationsModalProps) => {
|
||||
const [denominations, setDenominations] = React.useState(
|
||||
augmentWithIds(defaultDenominations)
|
||||
)
|
||||
const selectedCurrencies = denominations.map(
|
||||
(denomination) => denomination.currency_code
|
||||
)
|
||||
const availableCurrencies = currencyCodes?.filter(
|
||||
(currency) => !selectedCurrencies.includes(currency)
|
||||
)
|
||||
|
||||
const onAmountChange = (index) => {
|
||||
return (amount) => {
|
||||
const newDenominations = denominations.slice()
|
||||
newDenominations[index] = { ...newDenominations[index], amount }
|
||||
setDenominations(newDenominations)
|
||||
}
|
||||
}
|
||||
|
||||
const onCurrencyChange = (index) => {
|
||||
return (currencyCode) => {
|
||||
const newDenominations = denominations.slice()
|
||||
newDenominations[index] = {
|
||||
...newDenominations[index],
|
||||
currency_code: currencyCode,
|
||||
}
|
||||
setDenominations(newDenominations)
|
||||
}
|
||||
}
|
||||
|
||||
const onClickDelete = (index) => {
|
||||
return () => {
|
||||
const newDenominations = denominations.slice()
|
||||
newDenominations.splice(index, 1)
|
||||
setDenominations(newDenominations)
|
||||
}
|
||||
}
|
||||
|
||||
const appendDenomination = () => {
|
||||
const newDenomination = {
|
||||
amount: defaultNewAmount,
|
||||
currency_code: availableCurrencies[0],
|
||||
}
|
||||
setDenominations([...denominations, augmentWithId(newDenomination)])
|
||||
}
|
||||
|
||||
const submitHandler = () => {
|
||||
const strippedDenominations = stripDenominationFromIndexId(denominations)
|
||||
|
||||
if (onSubmit) {
|
||||
onSubmit(strippedDenominations)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal handleClose={handleClose}>
|
||||
<Modal.Body>
|
||||
<Modal.Header handleClose={handleClose}>
|
||||
<span className="inter-xlarge-semibold">Edit Denominations</span>
|
||||
</Modal.Header>
|
||||
<Modal.Content>
|
||||
<div className="pt-1">
|
||||
<div className="flex items-center">
|
||||
<label className="inter-base-semibold text-grey-90 mr-1.5">
|
||||
Prices
|
||||
</label>
|
||||
<IconTooltip content={"Helpful denominations"} />
|
||||
</div>
|
||||
{denominations.map((field, index) => {
|
||||
return (
|
||||
<div
|
||||
key={field.indexId}
|
||||
className="mt-xsmall flex items-center first:mt-0"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<CurrencyInput.Root
|
||||
currencyCodes={currencyCodes}
|
||||
currentCurrency={field.currency_code}
|
||||
onChange={onCurrencyChange(index)}
|
||||
size="medium"
|
||||
>
|
||||
<CurrencyInput.Amount
|
||||
label="Amount"
|
||||
onChange={onAmountChange(index)}
|
||||
amount={field.amount}
|
||||
/>
|
||||
</CurrencyInput.Root>
|
||||
</div>
|
||||
<button className="ml-2xlarge">
|
||||
<TrashIcon
|
||||
onClick={onClickDelete(index)}
|
||||
className="text-grey-40"
|
||||
size="20"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-large">
|
||||
<Button
|
||||
onClick={appendDenomination}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
disabled={availableCurrencies.length === 0}
|
||||
>
|
||||
<PlusIcon size={20} />
|
||||
Add a price
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={handleClose}
|
||||
className="mr-2 min-w-[130px] justify-center"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
className="mr-2 min-w-[130px] justify-center"
|
||||
onClick={submitHandler}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditDenominationsModal
|
||||
|
||||
const augmentWithId = (obj) => ({ ...obj, indexId: uuidv4() })
|
||||
|
||||
const augmentWithIds = (list) => {
|
||||
return list.map(augmentWithId)
|
||||
}
|
||||
|
||||
const stripDenominationFromIndexId = (list) => {
|
||||
return list.map((element) => _.omit(element, "indexId"))
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
import { Product } from "@medusajs/medusa"
|
||||
import { useAdminCreateVariant, useAdminStore } from "medusa-react"
|
||||
import { useCallback, useMemo } 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 DenominationForm, {
|
||||
DenominationFormType,
|
||||
} from "../../forms/gift-card/denomination-form"
|
||||
import Button from "../../fundamentals/button"
|
||||
import Modal from "../../molecules/modal"
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Whether the modal is open or not
|
||||
*/
|
||||
open: boolean
|
||||
/**
|
||||
* Callback to close the modal
|
||||
*/
|
||||
onClose: () => void
|
||||
/**
|
||||
* Gift card
|
||||
*/
|
||||
giftCard: Product
|
||||
}
|
||||
|
||||
type AddDenominationModalFormType = {
|
||||
denominations: DenominationFormType
|
||||
}
|
||||
|
||||
const AddDenominationModal = ({ open, onClose, giftCard }: Props) => {
|
||||
const { mutate, isLoading: isMutating } = useAdminCreateVariant(giftCard.id)
|
||||
|
||||
const { store } = useAdminStore()
|
||||
|
||||
const defaultValues: AddDenominationModalFormType | undefined =
|
||||
useMemo(() => {
|
||||
if (!store) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
denominations: {
|
||||
defaultDenomination: {
|
||||
currency_code: store.default_currency_code,
|
||||
includes_tax: store.currencies.find(
|
||||
(c) => c.code === store.default_currency_code
|
||||
)?.includes_tax,
|
||||
},
|
||||
currencyDenominations: store.currencies
|
||||
.filter((c) => c.code !== store.default_currency_code)
|
||||
.map((currency) => {
|
||||
return {
|
||||
currency_code: currency.code,
|
||||
includes_tax: currency.includes_tax,
|
||||
}
|
||||
}),
|
||||
},
|
||||
} as AddDenominationModalFormType
|
||||
}, [store])
|
||||
|
||||
const form = useForm<AddDenominationModalFormType>({
|
||||
defaultValues,
|
||||
})
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isDirty },
|
||||
} = form
|
||||
|
||||
const notification = useNotification()
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
reset(defaultValues)
|
||||
onClose()
|
||||
}, [reset, defaultValues, onClose])
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
const payload = {
|
||||
title: `${giftCard.variants.length}`,
|
||||
options: [
|
||||
{
|
||||
value: `${data.denominations.defaultDenomination.amount}`,
|
||||
option_id: giftCard.options[0].id,
|
||||
},
|
||||
],
|
||||
prices: [
|
||||
{
|
||||
amount: data.denominations.defaultDenomination.amount,
|
||||
currency_code: data.denominations.defaultDenomination.currency_code,
|
||||
},
|
||||
],
|
||||
inventory_quantity: 0,
|
||||
manage_inventory: false,
|
||||
}
|
||||
|
||||
data.denominations.currencyDenominations.forEach((currency) => {
|
||||
if (
|
||||
(currency.amount !== null && currency.amount !== undefined) ||
|
||||
data.denominations.useSameValue
|
||||
) {
|
||||
payload.prices.push({
|
||||
amount: data.denominations.useSameValue
|
||||
? data.denominations.defaultDenomination.amount
|
||||
: currency.amount,
|
||||
currency_code: currency.currency_code,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
mutate(payload, {
|
||||
onSuccess: () => {
|
||||
notification(
|
||||
"Denomination added",
|
||||
"A new denomination was succesfully added",
|
||||
"success"
|
||||
)
|
||||
handleClose()
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = () => {
|
||||
// @ts-ignore
|
||||
if (error.response?.data?.type === "duplicate_error") {
|
||||
return `A denomination with that default value already exists`
|
||||
} else {
|
||||
return getErrorMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
notification("Error", errorMessage(), "error")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal open={open} handleClose={handleClose}>
|
||||
<Modal.Body>
|
||||
<Modal.Header handleClose={handleClose}>
|
||||
<h1 className="inter-xlarge-semibold">Add Denomination</h1>
|
||||
</Modal.Header>
|
||||
<form onSubmit={onSubmit}>
|
||||
<Modal.Content>
|
||||
<DenominationForm form={nestedForm(form, "denominations")} />
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<div className="gap-x-xsmall flex w-full items-center justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
type="submit"
|
||||
disabled={isMutating || !isDirty}
|
||||
loading={isMutating}
|
||||
>
|
||||
Save and close
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddDenominationModal
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { ProductVariant } from "@medusajs/medusa"
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import Table from "../../molecules/table"
|
||||
import { useDenominationColumns } from "./use-denominations-columns"
|
||||
|
||||
type DenominationsTableProps = {
|
||||
denominations: ProductVariant[]
|
||||
}
|
||||
|
||||
const DenominationsTable = ({ denominations }: DenominationsTableProps) => {
|
||||
const columns = useDenominationColumns()
|
||||
|
||||
const { getHeaderGroups, getRowModel } = useReactTable({
|
||||
columns,
|
||||
data: denominations,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
})
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<Table.Head>
|
||||
{getHeaderGroups().map((headerGroup) => {
|
||||
return (
|
||||
<Table.HeadRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<Table.HeadCell
|
||||
key={header.id}
|
||||
className="inter-small-semibold text-grey-50"
|
||||
style={{
|
||||
width: header.getSize(),
|
||||
maxWidth: header.getSize(),
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</Table.HeadCell>
|
||||
)
|
||||
})}
|
||||
</Table.HeadRow>
|
||||
)
|
||||
})}
|
||||
</Table.Head>
|
||||
<Table.Body>
|
||||
{getRowModel().rows.map((row) => {
|
||||
return (
|
||||
<Table.Row key={row.id} className="last-of-type:border-b-0">
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
return (
|
||||
<Table.Cell
|
||||
key={cell.id}
|
||||
style={{
|
||||
width: cell.column.getSize(),
|
||||
maxWidth: cell.column.getSize(),
|
||||
}}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</Table.Cell>
|
||||
)
|
||||
})}
|
||||
</Table.Row>
|
||||
)
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
export default DenominationsTable
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
import { MoneyAmount, ProductVariant, Store } from "@medusajs/medusa"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import {
|
||||
adminProductKeys,
|
||||
useAdminStore,
|
||||
useAdminUpdateVariant,
|
||||
} from "medusa-react"
|
||||
import { useCallback, 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 DenominationForm, {
|
||||
DenominationFormType,
|
||||
} from "../../forms/gift-card/denomination-form"
|
||||
import Button from "../../fundamentals/button"
|
||||
import Modal from "../../molecules/modal"
|
||||
|
||||
type EditDenominationsModalProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
denomination: ProductVariant
|
||||
}
|
||||
|
||||
type EditDenominationModalFormType = {
|
||||
denominations: DenominationFormType
|
||||
}
|
||||
|
||||
const EditDenominationsModal = ({
|
||||
denomination,
|
||||
onClose,
|
||||
open,
|
||||
}: EditDenominationsModalProps) => {
|
||||
const { store } = useAdminStore()
|
||||
const { mutate, isLoading } = useAdminUpdateVariant(denomination.product_id)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const form = useForm<EditDenominationModalFormType>({
|
||||
defaultValues: getDefaultValues(store, denomination.prices),
|
||||
})
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isDirty },
|
||||
} = form
|
||||
|
||||
const notification = useNotification()
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
reset(getDefaultValues(store, denomination.prices))
|
||||
}
|
||||
}, [open, store, denomination.prices, reset])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
reset()
|
||||
onClose()
|
||||
}, [reset, onClose])
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
const payload = {
|
||||
prices: [
|
||||
{
|
||||
amount: data.denominations.defaultDenomination.amount,
|
||||
currency_code: data.denominations.defaultDenomination.currency_code,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
data.denominations.currencyDenominations.forEach((currency) => {
|
||||
if (
|
||||
(currency.amount !== undefined && currency.amount !== null) ||
|
||||
data.denominations.useSameValue
|
||||
) {
|
||||
payload.prices.push({
|
||||
amount: data.denominations.useSameValue
|
||||
? data.denominations.defaultDenomination.amount
|
||||
: currency.amount,
|
||||
currency_code: currency.currency_code,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
mutate(
|
||||
{
|
||||
variant_id: denomination.id,
|
||||
...payload,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notification(
|
||||
"Denomination updated",
|
||||
"A new denomination was succesfully updated",
|
||||
"success"
|
||||
)
|
||||
queryClient.invalidateQueries(adminProductKeys.all)
|
||||
handleClose()
|
||||
},
|
||||
onError: (error) => {
|
||||
notification("Error", getErrorMessage(error), "error")
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal open={open} handleClose={handleClose}>
|
||||
<Modal.Body>
|
||||
<Modal.Header handleClose={handleClose}>
|
||||
<h1 className="inter-xlarge-semibold">Edit Denominations</h1>
|
||||
</Modal.Header>
|
||||
<form onSubmit={onSubmit}>
|
||||
<Modal.Content>
|
||||
<DenominationForm form={nestedForm(form, "denominations")} />
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<div className="gap-x-2xsmall flex w-full items-center justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
type="submit"
|
||||
loading={isLoading}
|
||||
disabled={!isDirty || isLoading}
|
||||
>
|
||||
Save and close
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const getDefaultValues = (store: Store | undefined, prices: MoneyAmount[]) => {
|
||||
if (!store) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const defaultValues = {
|
||||
denominations: {
|
||||
defaultDenomination: {
|
||||
currency_code: store.default_currency_code,
|
||||
includes_tax: store.currencies.find(
|
||||
(c) => c.code === store.default_currency_code
|
||||
)?.includes_tax,
|
||||
amount: findPrice(store.default_currency_code, prices),
|
||||
},
|
||||
currencyDenominations: store.currencies
|
||||
.filter((c) => c.code !== store.default_currency_code)
|
||||
.map((currency) => {
|
||||
return {
|
||||
currency_code: currency.code,
|
||||
includes_tax: currency.includes_tax,
|
||||
amount: findPrice(currency.code, prices),
|
||||
}
|
||||
}),
|
||||
},
|
||||
} as EditDenominationModalFormType
|
||||
|
||||
if (
|
||||
defaultValues.denominations.currencyDenominations.every(
|
||||
(c) => c.amount === defaultValues.denominations.defaultDenomination.amount
|
||||
)
|
||||
) {
|
||||
defaultValues.denominations.useSameValue = true
|
||||
}
|
||||
|
||||
return defaultValues
|
||||
}
|
||||
|
||||
const findPrice = (currencyCode: string, prices: MoneyAmount[]) => {
|
||||
return prices.find(
|
||||
(p) =>
|
||||
p.currency_code === currencyCode &&
|
||||
p.region_id === null &&
|
||||
p.price_list_id === null
|
||||
)?.amount
|
||||
}
|
||||
|
||||
export default EditDenominationsModal
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import { Product } from "@medusajs/medusa"
|
||||
import useToggleState from "../../../hooks/use-toggle-state"
|
||||
import PlusIcon from "../../fundamentals/icons/plus-icon"
|
||||
import Section from "../section"
|
||||
import AddDenominationModal from "./add-denominations-modal"
|
||||
import DenominationsTable from "./denominations-table"
|
||||
|
||||
type GiftCardDenominationsSectionProps = {
|
||||
giftCard: Product
|
||||
}
|
||||
|
||||
const GiftCardDenominationsSection = ({
|
||||
giftCard,
|
||||
}: GiftCardDenominationsSectionProps) => {
|
||||
const {
|
||||
state: addDenomination,
|
||||
close: closeAddDenomination,
|
||||
open: openAddDenomination,
|
||||
} = useToggleState()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section
|
||||
title="Denominations"
|
||||
forceDropdown
|
||||
actions={[
|
||||
{
|
||||
label: "Add Denomination",
|
||||
onClick: openAddDenomination,
|
||||
icon: <PlusIcon size={20} />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div className="mb-large mt-base">
|
||||
<DenominationsTable denominations={[...giftCard.variants]} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<AddDenominationModal
|
||||
giftCard={giftCard}
|
||||
open={addDenomination}
|
||||
onClose={closeAddDenomination}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default GiftCardDenominationsSection
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
import { MoneyAmount, ProductVariant } from "@medusajs/medusa"
|
||||
import { createColumnHelper } from "@tanstack/react-table"
|
||||
import { useAdminDeleteVariant, useAdminStore } from "medusa-react"
|
||||
import { useMemo } from "react"
|
||||
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 { normalizeAmount } from "../../../utils/prices"
|
||||
import Tooltip from "../../atoms/tooltip"
|
||||
import EditIcon from "../../fundamentals/icons/edit-icon"
|
||||
import TrashIcon from "../../fundamentals/icons/trash-icon"
|
||||
import Actionables, { ActionType } from "../../molecules/actionables"
|
||||
import EditDenominationsModal from "./edit-denominations-modal"
|
||||
|
||||
const columnHelper = createColumnHelper<ProductVariant>()
|
||||
|
||||
export const useDenominationColumns = () => {
|
||||
const { store } = useAdminStore()
|
||||
|
||||
const columns = useMemo(() => {
|
||||
if (!store) {
|
||||
return []
|
||||
}
|
||||
|
||||
const defaultCurrency = store.default_currency_code
|
||||
|
||||
return [
|
||||
columnHelper.display({
|
||||
header: "Denomination",
|
||||
id: "denomination",
|
||||
cell: ({ row }) => {
|
||||
const defaultDenomination = row.original.prices.find(
|
||||
(p) =>
|
||||
p.currency_code === defaultCurrency &&
|
||||
p.region_id === null &&
|
||||
p.price_list_id === null
|
||||
)
|
||||
|
||||
return defaultDenomination ? (
|
||||
<p>
|
||||
{normalizeAmount(defaultCurrency, defaultDenomination.amount)}{" "}
|
||||
<span className="text-grey-50">
|
||||
{defaultCurrency.toUpperCase()}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
"-"
|
||||
)
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
header: "In other currencies",
|
||||
id: "other_currencies",
|
||||
cell: ({ row }) => {
|
||||
const otherCurrencies = row.original.prices.filter(
|
||||
(p) =>
|
||||
p.currency_code !== defaultCurrency &&
|
||||
p.region_id === null &&
|
||||
p.price_list_id === null
|
||||
)
|
||||
|
||||
let remainder: MoneyAmount[] = []
|
||||
|
||||
if (otherCurrencies.length > 2) {
|
||||
remainder = otherCurrencies.splice(2)
|
||||
}
|
||||
|
||||
return otherCurrencies.length > 0 ? (
|
||||
<p>
|
||||
{otherCurrencies.map((p, index) => {
|
||||
return (
|
||||
<span key={index}>
|
||||
{normalizeAmount(p.currency_code, p.amount)}{" "}
|
||||
<span className="text-grey-50">
|
||||
{p.currency_code.toUpperCase()}
|
||||
</span>
|
||||
{index < otherCurrencies.length - 1 && ", "}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{remainder.length > 0 && (
|
||||
<Tooltip
|
||||
content={
|
||||
<ul>
|
||||
{remainder.map((p, index) => {
|
||||
return (
|
||||
<li key={index}>
|
||||
<p>
|
||||
{normalizeAmount(p.currency_code, p.amount)}{" "}
|
||||
<span className="text-grey-50">
|
||||
{p.currency_code.toUpperCase()}
|
||||
</span>
|
||||
</p>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
}
|
||||
>
|
||||
<span className="text-grey-50 cursor-default">{`, and ${remainder.length} more`}</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
"-"
|
||||
)
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "actions",
|
||||
cell: ({ row: { original } }) => <Actions original={original} />,
|
||||
}),
|
||||
]
|
||||
}, [store])
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
const Actions = ({ original }: { original: ProductVariant }) => {
|
||||
const { state, open, close } = useToggleState()
|
||||
|
||||
const { mutateAsync } = useAdminDeleteVariant(original.product_id)
|
||||
const notification = useNotification()
|
||||
|
||||
const dialog = useImperativeDialog()
|
||||
|
||||
const onDelete = async () => {
|
||||
const shouldDelete = await dialog({
|
||||
heading: "Delete denomination",
|
||||
text: "Are you sure you want to delete this denomination?",
|
||||
})
|
||||
|
||||
if (shouldDelete) {
|
||||
mutateAsync(original.id, {
|
||||
onSuccess: () => {
|
||||
notification(
|
||||
"Denomination deleted",
|
||||
"Denomination was successfully deleted",
|
||||
"success"
|
||||
)
|
||||
},
|
||||
onError: (error) => {
|
||||
notification("Error", getErrorMessage(error), "error")
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const actions: ActionType[] = [
|
||||
{
|
||||
label: "Edit",
|
||||
onClick: open,
|
||||
icon: <EditIcon size={20} />,
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
onClick: onDelete,
|
||||
icon: <TrashIcon size={20} />,
|
||||
variant: "danger",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-end">
|
||||
<Actionables actions={actions} forceDropdown />
|
||||
</div>
|
||||
<EditDenominationsModal
|
||||
open={state}
|
||||
onClose={close}
|
||||
denomination={original}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
import { Product } from "@medusajs/medusa"
|
||||
import { useEffect } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import useEditProductActions from "../../../hooks/use-edit-product-actions"
|
||||
import { countries } from "../../../utils/countries"
|
||||
import { nestedForm } from "../../../utils/nested-form"
|
||||
import CustomsForm, { CustomsFormType } from "../../forms/product/customs-form"
|
||||
import DimensionsForm, {
|
||||
DimensionsFormType,
|
||||
} from "../../forms/product/dimensions-form"
|
||||
import Button from "../../fundamentals/button"
|
||||
import Modal from "../../molecules/modal"
|
||||
|
||||
type Props = {
|
||||
product: Product
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type AttributesForm = {
|
||||
dimensions: DimensionsFormType
|
||||
customs: CustomsFormType
|
||||
}
|
||||
|
||||
const AttributeModal = ({ product, open, onClose }: Props) => {
|
||||
const { onUpdate, updating } = useEditProductActions(product.id)
|
||||
const form = useForm<AttributesForm>({
|
||||
defaultValues: getDefaultValues(product),
|
||||
})
|
||||
const {
|
||||
formState: { isDirty },
|
||||
handleSubmit,
|
||||
reset,
|
||||
} = form
|
||||
|
||||
useEffect(() => {
|
||||
reset(getDefaultValues(product))
|
||||
}, [product])
|
||||
|
||||
const onReset = () => {
|
||||
reset(getDefaultValues(product))
|
||||
onClose()
|
||||
}
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
onUpdate(
|
||||
{
|
||||
// @ts-ignore
|
||||
weight: data.dimensions.weight,
|
||||
// @ts-ignore
|
||||
width: data.dimensions.width,
|
||||
// @ts-ignore
|
||||
height: data.dimensions.height,
|
||||
// @ts-ignore
|
||||
length: data.dimensions.length,
|
||||
// @ts-ignore
|
||||
mid_code: data.customs.mid_code,
|
||||
// @ts-ignore
|
||||
hs_code: data.customs.hs_code,
|
||||
origin_country: data.customs.origin_country?.value,
|
||||
},
|
||||
onReset
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal open={open} handleClose={onReset} isLargeModal>
|
||||
<Modal.Body>
|
||||
<Modal.Header handleClose={onReset}>
|
||||
<h1 className="inter-xlarge-semibold m-0">Edit Attributes</h1>
|
||||
</Modal.Header>
|
||||
<form onSubmit={onSubmit}>
|
||||
<Modal.Content>
|
||||
<div className="mb-xlarge">
|
||||
<h2 className="inter-large-semibold mb-2xsmall">Dimensions</h2>
|
||||
<p className="inter-base-regular text-grey-50 mb-large">
|
||||
Configure to calculate the most accurate shipping rates
|
||||
</p>
|
||||
<DimensionsForm form={nestedForm(form, "dimensions")} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="inter-large-semibold mb-2xsmall">Customs</h2>
|
||||
<p className="inter-base-regular text-grey-50 mb-large">
|
||||
Configure to calculate the most accurate shipping rates
|
||||
</p>
|
||||
<CustomsForm form={nestedForm(form, "customs")} />
|
||||
</div>
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<div className="flex w-full justify-end gap-x-2">
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={!isDirty}
|
||||
loading={updating}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const getDefaultValues = (product: Product): AttributesForm => {
|
||||
const country = countries.find(
|
||||
(country) => country.alpha2 === product.origin_country
|
||||
)
|
||||
const countryOption = country
|
||||
? { label: country.name, value: country.alpha2 }
|
||||
: null
|
||||
|
||||
return {
|
||||
dimensions: {
|
||||
weight: product.weight,
|
||||
width: product.width,
|
||||
height: product.height,
|
||||
length: product.length,
|
||||
},
|
||||
customs: {
|
||||
mid_code: product.mid_code,
|
||||
hs_code: product.hs_code,
|
||||
origin_country: countryOption,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default AttributeModal
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Product } from "@medusajs/medusa"
|
||||
import useToggleState from "../../../hooks/use-toggle-state"
|
||||
import EditIcon from "../../fundamentals/icons/edit-icon"
|
||||
import { ActionType } from "../../molecules/actionables"
|
||||
import Section from "../section"
|
||||
import AttributeModal from "./attribute-modal"
|
||||
|
||||
type Props = {
|
||||
product: Product
|
||||
}
|
||||
|
||||
const ProductAttributesSection = ({ product }: Props) => {
|
||||
const { state, toggle, close } = useToggleState()
|
||||
|
||||
const actions: ActionType[] = [
|
||||
{
|
||||
label: "Edit Attributes",
|
||||
onClick: toggle,
|
||||
icon: <EditIcon size={20} />,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section title="Attributes" actions={actions} forceDropdown>
|
||||
<div className="gap-y-xsmall mb-large mt-base flex flex-col">
|
||||
<h2 className="inter-base-semibold">Dimensions</h2>
|
||||
<div className="gap-y-xsmall flex flex-col">
|
||||
<Attribute attribute="Height" value={product.height} />
|
||||
<Attribute attribute="Width" value={product.width} />
|
||||
<Attribute attribute="Length" value={product.length} />
|
||||
<Attribute attribute="Weight" value={product.weight} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="gap-y-xsmall flex flex-col">
|
||||
<h2 className="inter-base-semibold">Customs</h2>
|
||||
<div className="gap-y-xsmall flex flex-col">
|
||||
<Attribute attribute="MID Code" value={product.mid_code} />
|
||||
<Attribute attribute="HS Code" value={product.hs_code} />
|
||||
<Attribute
|
||||
attribute="Country of origin"
|
||||
value={product.origin_country}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<AttributeModal onClose={close} open={state} product={product} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type AttributeProps = {
|
||||
attribute: string
|
||||
value: string | number | null
|
||||
}
|
||||
|
||||
const Attribute = ({ attribute, value }: AttributeProps) => {
|
||||
return (
|
||||
<div className="inter-base-regular text-grey-50 flex w-full items-center justify-between">
|
||||
<p>{attribute}</p>
|
||||
<p>{value || "–"}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProductAttributesSection
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { Product, SalesChannel } from "@medusajs/medusa"
|
||||
import { useAdminUpdateProduct } from "medusa-react"
|
||||
import SalesChannelsModal from "../../forms/product/sales-channels-modal"
|
||||
|
||||
type Props = {
|
||||
product: Product
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ChannelsModal = ({ product, open, onClose }: Props) => {
|
||||
const { mutate } = useAdminUpdateProduct(product.id)
|
||||
|
||||
const onUpdate = (channels: SalesChannel[]) => {
|
||||
// @ts-ignore
|
||||
mutate({
|
||||
sales_channels: channels.map((c) => ({ id: c.id })),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<SalesChannelsModal
|
||||
onClose={onClose}
|
||||
open={open}
|
||||
source={product.sales_channels}
|
||||
onSave={onUpdate}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChannelsModal
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
import { Product } from "@medusajs/medusa"
|
||||
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 DiscountableForm, {
|
||||
DiscountableFormType,
|
||||
} from "../../forms/product/discountable-form"
|
||||
import GeneralForm, { GeneralFormType } from "../../forms/product/general-form"
|
||||
import OrganizeForm, {
|
||||
OrganizeFormType,
|
||||
} from "../../forms/product/organize-form"
|
||||
import Button from "../../fundamentals/button"
|
||||
import Modal from "../../molecules/modal"
|
||||
|
||||
type Props = {
|
||||
product: Product
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type GeneralFormWrapper = {
|
||||
general: GeneralFormType
|
||||
organize: OrganizeFormType
|
||||
discountable: DiscountableFormType
|
||||
}
|
||||
|
||||
const GeneralModal = ({ product, open, onClose }: Props) => {
|
||||
const { onUpdate, updating } = useEditProductActions(product.id)
|
||||
const form = useForm<GeneralFormWrapper>({
|
||||
defaultValues: getDefaultValues(product),
|
||||
})
|
||||
|
||||
const {
|
||||
formState: { isDirty },
|
||||
handleSubmit,
|
||||
reset,
|
||||
} = form
|
||||
|
||||
useEffect(() => {
|
||||
reset(getDefaultValues(product))
|
||||
}, [product, reset])
|
||||
|
||||
const onReset = () => {
|
||||
reset(getDefaultValues(product))
|
||||
onClose()
|
||||
}
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
onUpdate(
|
||||
{
|
||||
title: data.general.title,
|
||||
handle: data.general.handle,
|
||||
// @ts-ignore
|
||||
material: data.general.material,
|
||||
// @ts-ignore
|
||||
subtitle: data.general.subtitle,
|
||||
// @ts-ignore
|
||||
description: data.general.description,
|
||||
// @ts-ignore
|
||||
type: data.organize.type
|
||||
? {
|
||||
id: data.organize.type.value,
|
||||
value: data.organize.type.label,
|
||||
}
|
||||
: null,
|
||||
// @ts-ignore
|
||||
collection_id: data.organize.collection
|
||||
? data.organize.collection.value
|
||||
: null,
|
||||
// @ts-ignore
|
||||
tags: data.organize.tags
|
||||
? data.organize.tags.map((t) => ({ value: t }))
|
||||
: null,
|
||||
|
||||
categories: data.organize.categories?.map((id) => ({ id })),
|
||||
discountable: data.discountable.value,
|
||||
},
|
||||
onReset
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal open={open} handleClose={onReset} isLargeModal>
|
||||
<Modal.Body>
|
||||
<Modal.Header handleClose={onReset}>
|
||||
<h1 className="inter-xlarge-semibold m-0">
|
||||
Edit General Information
|
||||
</h1>
|
||||
</Modal.Header>
|
||||
<form onSubmit={onSubmit}>
|
||||
<Modal.Content>
|
||||
<GeneralForm
|
||||
form={nestedForm(form, "general")}
|
||||
isGiftCard={product.is_giftcard}
|
||||
/>
|
||||
<div className="my-xlarge">
|
||||
<h2 className="inter-base-semibold mb-base">
|
||||
Organize {product.is_giftcard ? "Gift Card" : "Product"}
|
||||
</h2>
|
||||
<OrganizeForm form={nestedForm(form, "organize")} />
|
||||
</div>
|
||||
<DiscountableForm
|
||||
form={nestedForm(form, "discountable")}
|
||||
isGiftCard={product.is_giftcard}
|
||||
/>
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<div className="flex w-full justify-end gap-x-2">
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={!isDirty}
|
||||
loading={updating}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const getDefaultValues = (product: Product): GeneralFormWrapper => {
|
||||
return {
|
||||
general: {
|
||||
title: product.title,
|
||||
subtitle: product.subtitle,
|
||||
material: product.material,
|
||||
handle: product.handle!,
|
||||
description: product.description || null,
|
||||
},
|
||||
organize: {
|
||||
collection: product.collection
|
||||
? { label: product.collection.title, value: product.collection.id }
|
||||
: null,
|
||||
type: product.type
|
||||
? { label: product.type.value, value: product.type.id }
|
||||
: null,
|
||||
tags: product.tags ? product.tags.map((t) => t.value) : null,
|
||||
categories: product?.categories?.map((c) => c.id),
|
||||
},
|
||||
discountable: {
|
||||
value: product.discountable,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default GeneralModal
|
||||
@@ -0,0 +1,177 @@
|
||||
import { Product } from "@medusajs/medusa"
|
||||
import useEditProductActions from "../../../hooks/use-edit-product-actions"
|
||||
import useToggleState from "../../../hooks/use-toggle-state"
|
||||
import {
|
||||
FeatureFlag,
|
||||
useFeatureFlag,
|
||||
} from "../../../providers/feature-flag-provider"
|
||||
import FeatureToggle from "../../fundamentals/feature-toggle"
|
||||
import ChannelsIcon from "../../fundamentals/icons/channels-icon"
|
||||
import EditIcon from "../../fundamentals/icons/edit-icon"
|
||||
import TrashIcon from "../../fundamentals/icons/trash-icon"
|
||||
import { ActionType } from "../../molecules/actionables"
|
||||
import DelimitedList from "../../molecules/delimited-list"
|
||||
import SalesChannelsDisplay from "../../molecules/sales-channels-display"
|
||||
import StatusSelector from "../../molecules/status-selector"
|
||||
import Section from "../section"
|
||||
import ChannelsModal from "./channels-modal"
|
||||
import GeneralModal from "./general-modal"
|
||||
|
||||
type Props = {
|
||||
product: Product
|
||||
}
|
||||
|
||||
const ProductGeneralSection = ({ product }: Props) => {
|
||||
const { onDelete, onStatusChange } = useEditProductActions(product.id)
|
||||
const {
|
||||
state: infoState,
|
||||
close: closeInfo,
|
||||
toggle: toggleInfo,
|
||||
} = useToggleState()
|
||||
|
||||
const {
|
||||
state: channelsState,
|
||||
close: closeChannels,
|
||||
toggle: toggleChannels,
|
||||
} = useToggleState(false)
|
||||
|
||||
const { isFeatureEnabled } = useFeatureFlag()
|
||||
|
||||
const actions: ActionType[] = [
|
||||
{
|
||||
label: "Edit General Information",
|
||||
onClick: toggleInfo,
|
||||
icon: <EditIcon size={20} />,
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
onClick: onDelete,
|
||||
variant: "danger",
|
||||
icon: <TrashIcon size={20} />,
|
||||
},
|
||||
]
|
||||
|
||||
if (isFeatureEnabled("sales_channels")) {
|
||||
actions.splice(1, 0, {
|
||||
label: "Edit Sales Channels",
|
||||
onClick: toggleChannels,
|
||||
icon: <ChannelsIcon size={20} />,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section
|
||||
title={product.title}
|
||||
actions={actions}
|
||||
forceDropdown
|
||||
status={
|
||||
<StatusSelector
|
||||
isDraft={product?.status === "draft"}
|
||||
activeState="Published"
|
||||
draftState="Draft"
|
||||
onChange={() => onStatusChange(product.status)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<p className="inter-base-regular text-grey-50 mt-2 whitespace-pre-wrap">
|
||||
{product.description}
|
||||
</p>
|
||||
<ProductTags product={product} />
|
||||
<ProductDetails product={product} />
|
||||
<ProductSalesChannels product={product} />
|
||||
</Section>
|
||||
|
||||
<GeneralModal product={product} open={infoState} onClose={closeInfo} />
|
||||
|
||||
<FeatureToggle featureFlag="sales_channels">
|
||||
<ChannelsModal
|
||||
product={product}
|
||||
open={channelsState}
|
||||
onClose={closeChannels}
|
||||
/>
|
||||
</FeatureToggle>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type DetailProps = {
|
||||
title: string
|
||||
value?: string[] | string | null
|
||||
}
|
||||
|
||||
const Detail = ({ title, value }: DetailProps) => {
|
||||
const DetailValue = () => {
|
||||
if (!Array.isArray(value)) {
|
||||
return <p>{value ? value : "–"}</p>
|
||||
}
|
||||
|
||||
if (value.length) {
|
||||
return <DelimitedList list={value} delimit={2} />
|
||||
}
|
||||
|
||||
return <p>–</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="inter-base-regular text-grey-50 flex items-center justify-between">
|
||||
<p>{title}</p>
|
||||
<DetailValue />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ProductDetails = ({ product }: Props) => {
|
||||
const { isFeatureEnabled } = useFeatureFlag()
|
||||
|
||||
return (
|
||||
<div className="mt-8 flex flex-col gap-y-3">
|
||||
<h2 className="inter-base-semibold">Details</h2>
|
||||
<Detail title="Subtitle" value={product.subtitle} />
|
||||
<Detail title="Handle" value={product.handle} />
|
||||
<Detail title="Type" value={product.type?.value} />
|
||||
<Detail title="Collection" value={product.collection?.title} />
|
||||
{isFeatureEnabled(FeatureFlag.PRODUCT_CATEGORIES) && (
|
||||
<Detail
|
||||
title="Category"
|
||||
value={product.categories.map((c) => c.name)}
|
||||
/>
|
||||
)}
|
||||
<Detail
|
||||
title="Discountable"
|
||||
value={product.discountable ? "True" : "False"}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ProductTags = ({ product }: Props) => {
|
||||
if (product.tags?.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="mt-4 flex flex-wrap items-center gap-1">
|
||||
{product.tags.map((t) => (
|
||||
<li key={t.id}>
|
||||
<div className="text-grey-50 bg-grey-10 inter-small-semibold rounded-rounded px-3 py-[6px]">
|
||||
{t.value}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
const ProductSalesChannels = ({ product }: Props) => {
|
||||
return (
|
||||
<FeatureToggle featureFlag="sales_channels">
|
||||
<div className="mt-xlarge">
|
||||
<h2 className="inter-base-semibold mb-xsmall">Sales channels</h2>
|
||||
<SalesChannelsDisplay channels={product.sales_channels} />
|
||||
</div>
|
||||
</FeatureToggle>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProductGeneralSection
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Product } from "@medusajs/medusa"
|
||||
import useToggleState from "../../../hooks/use-toggle-state"
|
||||
import { ActionType } from "../../molecules/actionables"
|
||||
import Section from "../../organisms/section"
|
||||
import MediaModal from "./media-modal"
|
||||
|
||||
type Props = {
|
||||
product: Product
|
||||
}
|
||||
|
||||
const ProductMediaSection = ({ product }: Props) => {
|
||||
const { state, close, toggle } = useToggleState()
|
||||
|
||||
const actions: ActionType[] = [
|
||||
{
|
||||
label: "Edit Media",
|
||||
onClick: toggle,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section title="Media" actions={actions}>
|
||||
{product.images && product.images.length > 0 && (
|
||||
<div className="gap-xsmall mt-base grid grid-cols-3">
|
||||
{product.images.map((image, index) => {
|
||||
return (
|
||||
<div
|
||||
key={image.id}
|
||||
className="flex aspect-square items-center justify-center"
|
||||
>
|
||||
<img
|
||||
src={image.url}
|
||||
alt={`Image ${index + 1}`}
|
||||
className="rounded-rounded max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<MediaModal product={product} open={state} onClose={close} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProductMediaSection
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Product } from "@medusajs/medusa"
|
||||
import { useEffect } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import useEditProductActions from "../../../hooks/use-edit-product-actions"
|
||||
import useNotification from "../../../hooks/use-notification"
|
||||
import { FormImage } from "../../../types/shared"
|
||||
import { prepareImages } from "../../../utils/images"
|
||||
import { nestedForm } from "../../../utils/nested-form"
|
||||
import MediaForm, { MediaFormType } from "../../forms/product/media-form"
|
||||
import Button from "../../fundamentals/button"
|
||||
import Modal from "../../molecules/modal"
|
||||
|
||||
type Props = {
|
||||
product: Product
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type MediaFormWrapper = {
|
||||
media: MediaFormType
|
||||
}
|
||||
|
||||
const MediaModal = ({ product, open, onClose }: Props) => {
|
||||
const { onUpdate, updating } = useEditProductActions(product.id)
|
||||
const form = useForm<MediaFormWrapper>({
|
||||
defaultValues: getDefaultValues(product),
|
||||
})
|
||||
|
||||
const {
|
||||
formState: { isDirty },
|
||||
handleSubmit,
|
||||
reset,
|
||||
} = form
|
||||
|
||||
const notification = useNotification()
|
||||
|
||||
useEffect(() => {
|
||||
reset(getDefaultValues(product))
|
||||
}, [product, reset])
|
||||
|
||||
const onReset = () => {
|
||||
reset(getDefaultValues(product))
|
||||
onClose()
|
||||
}
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
let preppedImages: FormImage[] = []
|
||||
|
||||
try {
|
||||
preppedImages = await prepareImages(data.media.images)
|
||||
} catch (error) {
|
||||
let errorMessage = "Something went wrong while trying to upload images."
|
||||
const response = (error as any).response as Response
|
||||
|
||||
if (response.status === 500) {
|
||||
errorMessage =
|
||||
errorMessage +
|
||||
" " +
|
||||
"You might not have a file service configured. Please contact your administrator"
|
||||
}
|
||||
|
||||
notification("Error", errorMessage, "error")
|
||||
return
|
||||
}
|
||||
const urls = preppedImages.map((image) => image.url)
|
||||
|
||||
onUpdate(
|
||||
{
|
||||
images: urls,
|
||||
},
|
||||
onReset
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal open={open} handleClose={onReset} isLargeModal>
|
||||
<Modal.Body>
|
||||
<Modal.Header handleClose={onReset}>
|
||||
<h1 className="inter-xlarge-semibold m-0">Edit Media</h1>
|
||||
</Modal.Header>
|
||||
<form onSubmit={onSubmit}>
|
||||
<Modal.Content>
|
||||
<div>
|
||||
<h2 className="inter-large-semibold mb-2xsmall">Media</h2>
|
||||
<p className="inter-base-regular text-grey-50 mb-large">
|
||||
Add images to your product.
|
||||
</p>
|
||||
<div>
|
||||
<MediaForm form={nestedForm(form, "media")} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<div className="flex w-full justify-end gap-x-2">
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={!isDirty}
|
||||
loading={updating}
|
||||
>
|
||||
Save and close
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const getDefaultValues = (product: Product): MediaFormWrapper => {
|
||||
return {
|
||||
media: {
|
||||
images:
|
||||
product.images?.map((image) => ({
|
||||
url: image.url,
|
||||
selected: false,
|
||||
})) || [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default MediaModal
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Product } from "@medusajs/medusa"
|
||||
import JSONView from "../../molecules/json-view"
|
||||
import Section from "../section"
|
||||
|
||||
type Props = {
|
||||
product: Product
|
||||
}
|
||||
|
||||
/** Temporary component, should be replaced with <RawJson /> but since the design is different we will use this to not break the existing design across admin. */
|
||||
const ProductRawSection = ({ product }: Props) => {
|
||||
return (
|
||||
<Section title={product.is_giftcard ? "Raw Gift Card" : "Raw Product"}>
|
||||
<div className="pt-base">
|
||||
<JSONView data={product} />
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProductRawSection
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Product } from "@medusajs/medusa"
|
||||
import clsx from "clsx"
|
||||
import useEditProductActions from "../../../hooks/use-edit-product-actions"
|
||||
import useNotification from "../../../hooks/use-notification"
|
||||
import useToggleState from "../../../hooks/use-toggle-state"
|
||||
import { getErrorMessage } from "../../../utils/error-messages"
|
||||
import TwoStepDelete from "../../atoms/two-step-delete"
|
||||
import Button from "../../fundamentals/button"
|
||||
import Section from "../../organisms/section"
|
||||
import ThumbnailModal from "./thumbnail-modal"
|
||||
|
||||
type Props = {
|
||||
product: Product
|
||||
}
|
||||
|
||||
const ProductThumbnailSection = ({ product }: Props) => {
|
||||
const { onUpdate, updating } = useEditProductActions(product.id)
|
||||
const { state, toggle, close } = useToggleState()
|
||||
|
||||
const notification = useNotification()
|
||||
|
||||
const handleDelete = () => {
|
||||
onUpdate(
|
||||
{
|
||||
// @ts-ignore
|
||||
thumbnail: null,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notification("Success", "Successfully deleted thumbnail", "success")
|
||||
},
|
||||
onError: (err) => {
|
||||
notification("Error", getErrorMessage(err), "error")
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section
|
||||
title="Thumbnail"
|
||||
customActions={
|
||||
<div className="gap-x-xsmall flex items-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
>
|
||||
{product.thumbnail ? "Edit" : "Upload"}
|
||||
</Button>
|
||||
{product.thumbnail && (
|
||||
<TwoStepDelete onDelete={handleDelete} deleting={updating} />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={clsx("gap-xsmall mt-base grid grid-cols-3", {
|
||||
hidden: !product.thumbnail,
|
||||
})}
|
||||
>
|
||||
{product.thumbnail && (
|
||||
<div className="flex aspect-square items-center justify-center">
|
||||
<img
|
||||
src={product.thumbnail}
|
||||
alt={`Thumbnail for ${product.title}`}
|
||||
className="rounded-rounded max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<ThumbnailModal product={product} open={state} onClose={close} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProductThumbnailSection
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import { Product } from "@medusajs/medusa"
|
||||
import { useEffect } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import useEditProductActions from "../../../hooks/use-edit-product-actions"
|
||||
import useNotification from "../../../hooks/use-notification"
|
||||
import { FormImage } from "../../../types/shared"
|
||||
import { prepareImages } from "../../../utils/images"
|
||||
import { nestedForm } from "../../../utils/nested-form"
|
||||
import ThumbnailForm, {
|
||||
ThumbnailFormType,
|
||||
} from "../../forms/product/thumbnail-form"
|
||||
import Button from "../../fundamentals/button"
|
||||
import Modal from "../../molecules/modal"
|
||||
|
||||
type Props = {
|
||||
product: Product
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type ThumbnailFormWrapper = {
|
||||
thumbnail: ThumbnailFormType
|
||||
}
|
||||
|
||||
const ThumbnailModal = ({ product, open, onClose }: Props) => {
|
||||
const { onUpdate, updating } = useEditProductActions(product.id)
|
||||
const form = useForm<ThumbnailFormWrapper>({
|
||||
defaultValues: getDefaultValues(product),
|
||||
})
|
||||
|
||||
const {
|
||||
formState: { isDirty },
|
||||
handleSubmit,
|
||||
reset,
|
||||
} = form
|
||||
|
||||
const notification = useNotification()
|
||||
|
||||
useEffect(() => {
|
||||
reset(getDefaultValues(product))
|
||||
}, [product, reset])
|
||||
|
||||
const onReset = () => {
|
||||
reset(getDefaultValues(product))
|
||||
onClose()
|
||||
}
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
let preppedImages: FormImage[] = []
|
||||
|
||||
try {
|
||||
preppedImages = await prepareImages(data.thumbnail.images)
|
||||
} catch (error) {
|
||||
let errorMessage =
|
||||
"Something went wrong while trying to upload the thumbnail."
|
||||
const response = (error as any).response as Response
|
||||
|
||||
if (response.status === 500) {
|
||||
errorMessage =
|
||||
errorMessage +
|
||||
" " +
|
||||
"You might not have a file service configured. Please contact your administrator"
|
||||
}
|
||||
|
||||
notification("Error", errorMessage, "error")
|
||||
return
|
||||
}
|
||||
const url = preppedImages?.[0]?.url
|
||||
|
||||
onUpdate(
|
||||
{
|
||||
// @ts-ignore
|
||||
thumbnail: url || null,
|
||||
},
|
||||
onReset
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal open={open} handleClose={onReset} isLargeModal>
|
||||
<Modal.Body>
|
||||
<Modal.Header handleClose={onReset}>
|
||||
<h1 className="inter-xlarge-semibold m-0">Upload Thumbnail</h1>
|
||||
</Modal.Header>
|
||||
<form onSubmit={onSubmit}>
|
||||
<Modal.Content>
|
||||
<h2 className="inter-large-semibold mb-2xsmall">Thumbnail</h2>
|
||||
<p className="inter-base-regular text-grey-50 mb-large">
|
||||
Used to represent your product during checkout, social sharing and
|
||||
more.
|
||||
</p>
|
||||
<ThumbnailForm form={nestedForm(form, "thumbnail")} />
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<div className="flex w-full justify-end gap-x-2">
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={!isDirty}
|
||||
loading={updating}
|
||||
>
|
||||
Save and close
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const getDefaultValues = (product: Product): ThumbnailFormWrapper => {
|
||||
return {
|
||||
thumbnail: {
|
||||
images: product.thumbnail
|
||||
? [
|
||||
{
|
||||
url: product.thumbnail,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default ThumbnailModal
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
import { AdminPostProductsProductVariantsReq, Product } from "@medusajs/medusa"
|
||||
import { useContext, useEffect } from "react"
|
||||
import EditFlowVariantForm, {
|
||||
EditFlowVariantFormType,
|
||||
} from "../../forms/product/variant-form/edit-flow-variant-form"
|
||||
import LayeredModal, {
|
||||
LayeredModalContext,
|
||||
} 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 Button from "../../fundamentals/button"
|
||||
import Modal from "../../molecules/modal"
|
||||
|
||||
type Props = {
|
||||
onClose: () => void
|
||||
open: boolean
|
||||
product: Product
|
||||
}
|
||||
|
||||
const AddVariantModal = ({ open, onClose, product }: Props) => {
|
||||
const context = useContext(LayeredModalContext)
|
||||
const { client } = useMedusa()
|
||||
const form = useForm<EditFlowVariantFormType>({
|
||||
defaultValues: getDefaultValues(product),
|
||||
})
|
||||
|
||||
const { onAddVariant, addingVariant } = useEditProductActions(product.id)
|
||||
|
||||
const { handleSubmit, reset } = form
|
||||
|
||||
useEffect(() => {
|
||||
reset(getDefaultValues(product))
|
||||
}, [product, reset])
|
||||
|
||||
const resetAndClose = () => {
|
||||
reset(getDefaultValues(product))
|
||||
onClose()
|
||||
}
|
||||
|
||||
const createStockLocationsForVariant = async (
|
||||
productRes: Product,
|
||||
stock_locations: { stocked_quantity: number; location_id: string }[]
|
||||
) => {
|
||||
const { variants } = productRes
|
||||
|
||||
const pvMap = new Map(product.variants.map((v) => [v.id, true]))
|
||||
const addedVariant = variants.find((variant) => !pvMap.get(variant.id))
|
||||
|
||||
if (!addedVariant) {
|
||||
return
|
||||
}
|
||||
|
||||
const inventory = await client.admin.variants.getInventory(addedVariant.id)
|
||||
|
||||
await Promise.all(
|
||||
inventory.variant.inventory
|
||||
.map(async (item) => {
|
||||
return Promise.all(
|
||||
stock_locations.map(async (stock_location) => {
|
||||
client.admin.inventoryItems.createLocationLevel(item.id!, {
|
||||
location_id: stock_location.location_id,
|
||||
stocked_quantity: stock_location.stocked_quantity,
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
.flat()
|
||||
)
|
||||
}
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
const {
|
||||
stock: { stock_location },
|
||||
} = data
|
||||
delete data.stock.stock_location
|
||||
|
||||
onAddVariant(createAddPayload(data), (productRes) => {
|
||||
if (typeof stock_location !== "undefined") {
|
||||
createStockLocationsForVariant(productRes, stock_location).then(() => {
|
||||
resetAndClose()
|
||||
})
|
||||
} else {
|
||||
resetAndClose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<LayeredModal context={context} open={open} handleClose={resetAndClose}>
|
||||
<Modal.Body>
|
||||
<Modal.Header handleClose={resetAndClose}>
|
||||
<h1 className="inter-xlarge-semibold">Add Variant</h1>
|
||||
</Modal.Header>
|
||||
<form onSubmit={onSubmit}>
|
||||
<Modal.Content>
|
||||
<EditFlowVariantForm isEdit={false} form={form} />
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<div className="gap-x-xsmall flex w-full items-center justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
type="button"
|
||||
onClick={resetAndClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
type="submit"
|
||||
loading={addingVariant}
|
||||
>
|
||||
Save and close
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</form>
|
||||
</Modal.Body>
|
||||
</LayeredModal>
|
||||
)
|
||||
}
|
||||
|
||||
const getDefaultValues = (product: Product): EditFlowVariantFormType => {
|
||||
const options = product.options.map((option) => ({
|
||||
title: option.title,
|
||||
id: option.id,
|
||||
value: "",
|
||||
}))
|
||||
|
||||
return {
|
||||
general: {
|
||||
title: null,
|
||||
material: null,
|
||||
},
|
||||
stock: {
|
||||
sku: null,
|
||||
ean: null,
|
||||
upc: null,
|
||||
barcode: null,
|
||||
inventory_quantity: null,
|
||||
manage_inventory: false,
|
||||
allow_backorder: false,
|
||||
stock_location: [],
|
||||
},
|
||||
options: options,
|
||||
prices: {
|
||||
prices: [],
|
||||
},
|
||||
dimensions: {
|
||||
weight: null,
|
||||
width: null,
|
||||
height: null,
|
||||
length: null,
|
||||
},
|
||||
customs: {
|
||||
mid_code: null,
|
||||
hs_code: null,
|
||||
origin_country: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const createAddPayload = (
|
||||
data: EditFlowVariantFormType
|
||||
): AdminPostProductsProductVariantsReq => {
|
||||
const { general, stock, options, prices, dimensions, customs } = data
|
||||
|
||||
const priceArray = prices.prices
|
||||
.filter((price) => typeof price.amount === "number")
|
||||
.map((price) => {
|
||||
return {
|
||||
amount: price.amount,
|
||||
currency_code: price.region_id ? undefined : price.currency_code,
|
||||
region_id: price.region_id,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
// @ts-ignore
|
||||
...general,
|
||||
...customs,
|
||||
...stock,
|
||||
inventory_quantity: stock.inventory_quantity || 0,
|
||||
...dimensions,
|
||||
...customs,
|
||||
// @ts-ignore
|
||||
origin_country: customs.origin_country
|
||||
? customs.origin_country.value
|
||||
: null,
|
||||
// @ts-ignore
|
||||
prices: priceArray,
|
||||
title: data.general.title || `${options?.map((o) => o.value).join(" / ")}`,
|
||||
options: options.map((option) => ({
|
||||
option_id: option.id,
|
||||
value: option.value,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export default AddVariantModal
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
import {
|
||||
InventoryLevelDTO,
|
||||
Product,
|
||||
ProductVariant,
|
||||
VariantInventory,
|
||||
} from "@medusajs/medusa"
|
||||
import { useAdminVariantsInventory, useMedusa } from "medusa-react"
|
||||
import { useContext } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import useEditProductActions from "../../../hooks/use-edit-product-actions"
|
||||
import { removeNullish } from "../../../utils/remove-nullish"
|
||||
import EditFlowVariantForm, {
|
||||
EditFlowVariantFormType,
|
||||
} from "../../forms/product/variant-form/edit-flow-variant-form"
|
||||
import Button from "../../fundamentals/button"
|
||||
import Modal from "../../molecules/modal"
|
||||
import LayeredModal, {
|
||||
LayeredModalContext,
|
||||
} from "../../molecules/modal/layered-modal"
|
||||
import { createUpdatePayload } from "./edit-variants-modal/edit-variant-screen"
|
||||
|
||||
type Props = {
|
||||
onClose: () => void
|
||||
product: Product
|
||||
variant: ProductVariant
|
||||
isDuplicate?: boolean
|
||||
}
|
||||
|
||||
const EditVariantInventoryModal = ({ onClose, product, variant }: Props) => {
|
||||
const { client } = useMedusa()
|
||||
const layeredModalContext = useContext(LayeredModalContext)
|
||||
const {
|
||||
// @ts-ignore
|
||||
variant: variantInventory,
|
||||
isLoading: isLoadingInventory,
|
||||
refetch,
|
||||
} = useAdminVariantsInventory(variant.id)
|
||||
|
||||
const variantInventoryItem = variantInventory?.inventory[0]
|
||||
const itemId = variantInventoryItem?.id
|
||||
|
||||
const handleClose = () => {
|
||||
onClose()
|
||||
}
|
||||
|
||||
const { onUpdateVariant, updatingVariant } = useEditProductActions(product.id)
|
||||
|
||||
const onSubmit = async (data: EditFlowVariantFormType) => {
|
||||
const locationLevels = data.stock.location_levels || []
|
||||
const manageInventory = data.stock.manage_inventory
|
||||
delete data.stock.manage_inventory
|
||||
delete data.stock.location_levels
|
||||
|
||||
let inventoryItemId: string | undefined = itemId
|
||||
|
||||
const upsertPayload = removeNullish(data.stock)
|
||||
|
||||
if (variantInventoryItem) {
|
||||
// variant inventory exists and we can remove location levels
|
||||
// (it's important to do this before potentially deleting the inventory item)
|
||||
const deleteLocations = manageInventory
|
||||
? variantInventoryItem?.location_levels?.filter(
|
||||
(itemLevel: InventoryLevelDTO) => {
|
||||
return !locationLevels.find(
|
||||
(level) => level.location_id === itemLevel.location_id
|
||||
)
|
||||
}
|
||||
) ?? []
|
||||
: []
|
||||
|
||||
if (inventoryItemId) {
|
||||
await Promise.all(
|
||||
deleteLocations.map(async (location: InventoryLevelDTO) => {
|
||||
await client.admin.inventoryItems.deleteLocationLevel(
|
||||
inventoryItemId!,
|
||||
location.id
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (!manageInventory) {
|
||||
// has an inventory item but no longer wants to manage inventory
|
||||
await client.admin.inventoryItems.delete(itemId!)
|
||||
inventoryItemId = undefined
|
||||
} else {
|
||||
// has an inventory item and wants to update inventory
|
||||
await client.admin.inventoryItems.update(itemId!, upsertPayload)
|
||||
}
|
||||
} else if (manageInventory) {
|
||||
// does not have an inventory item but wants to manage inventory
|
||||
const { inventory_item } = await client.admin.inventoryItems.create({
|
||||
variant_id: variant.id,
|
||||
...upsertPayload,
|
||||
})
|
||||
inventoryItemId = inventory_item.id
|
||||
}
|
||||
|
||||
// If some inventory Item exists update location levels
|
||||
if (inventoryItemId) {
|
||||
await Promise.all(
|
||||
locationLevels.map(async (level) => {
|
||||
if (!level.location_id) {
|
||||
return
|
||||
}
|
||||
if (level.id) {
|
||||
await client.admin.inventoryItems.updateLocationLevel(
|
||||
inventoryItemId!,
|
||||
level.location_id,
|
||||
{
|
||||
stocked_quantity: level.stocked_quantity,
|
||||
}
|
||||
)
|
||||
} else {
|
||||
await client.admin.inventoryItems.createLocationLevel(
|
||||
inventoryItemId!,
|
||||
{
|
||||
location_id: level.location_id,
|
||||
stocked_quantity: level.stocked_quantity!,
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
onUpdateVariant(variant.id, createUpdatePayload(data), () => {
|
||||
refetch()
|
||||
handleClose()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<LayeredModal context={layeredModalContext} handleClose={handleClose}>
|
||||
<Modal.Header handleClose={handleClose}>
|
||||
<h1 className="inter-xlarge-semibold">Edit stock & inventory</h1>
|
||||
</Modal.Header>
|
||||
{!isLoadingInventory && (
|
||||
<StockForm
|
||||
variantInventory={variantInventory!}
|
||||
onSubmit={onSubmit}
|
||||
isLoadingInventory={isLoadingInventory}
|
||||
handleClose={handleClose}
|
||||
updatingVariant={updatingVariant}
|
||||
/>
|
||||
)}
|
||||
</LayeredModal>
|
||||
)
|
||||
}
|
||||
|
||||
const StockForm = ({
|
||||
variantInventory,
|
||||
onSubmit,
|
||||
isLoadingInventory,
|
||||
handleClose,
|
||||
updatingVariant,
|
||||
}: {
|
||||
variantInventory: VariantInventory
|
||||
onSubmit: (data: EditFlowVariantFormType) => void
|
||||
isLoadingInventory: boolean
|
||||
handleClose: () => void
|
||||
updatingVariant: boolean
|
||||
}) => {
|
||||
const form = useForm<EditFlowVariantFormType>({
|
||||
defaultValues: getEditVariantDefaultValues(variantInventory),
|
||||
})
|
||||
|
||||
const {
|
||||
formState: { isDirty },
|
||||
handleSubmit,
|
||||
reset,
|
||||
} = form
|
||||
|
||||
const locationLevels = variantInventory.inventory[0]?.location_levels || []
|
||||
|
||||
const handleOnSubmit = handleSubmit((data) => {
|
||||
onSubmit(data)
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleOnSubmit} noValidate>
|
||||
<Modal.Content>
|
||||
<EditFlowVariantForm
|
||||
form={form}
|
||||
locationLevels={locationLevels}
|
||||
isLoading={isLoadingInventory}
|
||||
/>
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<div className="gap-x-xsmall flex w-full items-center justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
reset(getEditVariantDefaultValues(variantInventory))
|
||||
handleClose()
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
type="submit"
|
||||
disabled={!isDirty}
|
||||
loading={updatingVariant}
|
||||
>
|
||||
Save and close
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export const getEditVariantDefaultValues = (
|
||||
variantInventory?: any
|
||||
): EditFlowVariantFormType => {
|
||||
const inventoryItem = variantInventory?.inventory[0]
|
||||
if (!inventoryItem) {
|
||||
return {
|
||||
stock: {
|
||||
sku: null,
|
||||
ean: null,
|
||||
inventory_quantity: null,
|
||||
manage_inventory: false,
|
||||
allow_backorder: false,
|
||||
barcode: null,
|
||||
upc: null,
|
||||
location_levels: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stock: {
|
||||
sku: inventoryItem.sku,
|
||||
ean: inventoryItem.ean,
|
||||
inventory_quantity: inventoryItem.inventory_quantity,
|
||||
manage_inventory: !!inventoryItem,
|
||||
allow_backorder: inventoryItem.allow_backorder,
|
||||
barcode: inventoryItem.barcode,
|
||||
upc: inventoryItem.upc,
|
||||
location_levels: inventoryItem.location_levels,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default EditVariantInventoryModal
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
import { Product, ProductVariant } from "@medusajs/medusa"
|
||||
import EditFlowVariantForm, {
|
||||
EditFlowVariantFormType,
|
||||
} from "../../forms/product/variant-form/edit-flow-variant-form"
|
||||
import LayeredModal, {
|
||||
LayeredModalContext,
|
||||
} from "../../molecules/modal/layered-modal"
|
||||
|
||||
import { useMedusa } from "medusa-react"
|
||||
import { useContext } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import useEditProductActions from "../../../hooks/use-edit-product-actions"
|
||||
import { countries } from "../../../utils/countries"
|
||||
import Button from "../../fundamentals/button"
|
||||
import Modal from "../../molecules/modal"
|
||||
import { createAddPayload } from "./add-variant-modal"
|
||||
import { createUpdatePayload } from "./edit-variants-modal/edit-variant-screen"
|
||||
|
||||
type Props = {
|
||||
onClose: () => void
|
||||
product: Product
|
||||
variant: ProductVariant
|
||||
isDuplicate?: boolean
|
||||
}
|
||||
|
||||
const EditVariantModal = ({
|
||||
onClose,
|
||||
product,
|
||||
variant,
|
||||
isDuplicate = false,
|
||||
}: Props) => {
|
||||
const form = useForm<EditFlowVariantFormType>({
|
||||
// @ts-ignore
|
||||
defaultValues: getEditVariantDefaultValues(variant, product),
|
||||
})
|
||||
|
||||
const {
|
||||
formState: { isDirty },
|
||||
handleSubmit,
|
||||
reset,
|
||||
} = form
|
||||
|
||||
const handleClose = () => {
|
||||
reset(getEditVariantDefaultValues(variant, product))
|
||||
onClose()
|
||||
}
|
||||
|
||||
const { onUpdateVariant, onAddVariant, addingVariant, updatingVariant } =
|
||||
useEditProductActions(product.id)
|
||||
|
||||
const { client } = useMedusa()
|
||||
|
||||
const createStockLocationsForVariant = async (
|
||||
productRes: Product,
|
||||
stock_locations: { stocked_quantity: number; location_id: string }[]
|
||||
) => {
|
||||
const { variants } = productRes
|
||||
|
||||
const pvMap = new Map(product.variants.map((v) => [v.id, true]))
|
||||
const addedVariant = variants.find((variant) => !pvMap.get(variant.id))
|
||||
|
||||
if (!addedVariant) {
|
||||
return
|
||||
}
|
||||
|
||||
const inventory = await client.admin.variants.getInventory(addedVariant.id)
|
||||
|
||||
await Promise.all(
|
||||
inventory.variant.inventory
|
||||
.map(async (item) => {
|
||||
return Promise.all(
|
||||
stock_locations.map(async (stock_location) => {
|
||||
client.admin.inventoryItems.createLocationLevel(item.id!, {
|
||||
location_id: stock_location.location_id,
|
||||
stocked_quantity: stock_location.stocked_quantity,
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
.flat()
|
||||
)
|
||||
}
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
const {
|
||||
stock: { stock_location },
|
||||
} = data
|
||||
delete data.stock.stock_location
|
||||
|
||||
if (isDuplicate) {
|
||||
onAddVariant(createAddPayload(data), (productRes) => {
|
||||
if (typeof stock_location !== "undefined") {
|
||||
createStockLocationsForVariant(productRes, stock_location).then(
|
||||
() => {
|
||||
handleClose()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
handleClose()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// @ts-ignore
|
||||
onUpdateVariant(variant.id, createUpdatePayload(data), handleClose)
|
||||
}
|
||||
})
|
||||
|
||||
const layeredModalContext = useContext(LayeredModalContext)
|
||||
return (
|
||||
<LayeredModal context={layeredModalContext} handleClose={handleClose}>
|
||||
<Modal.Header handleClose={handleClose}>
|
||||
<h1 className="inter-xlarge-semibold">
|
||||
Edit Variant
|
||||
{variant.title && (
|
||||
<span className="inter-xlarge-regular text-grey-50">
|
||||
{" "}
|
||||
({variant.title})
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
</Modal.Header>
|
||||
<form onSubmit={onSubmit} noValidate>
|
||||
<Modal.Content>
|
||||
<EditFlowVariantForm isEdit={true} form={form} />
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<div className="gap-x-xsmall flex w-full items-center justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
type="submit"
|
||||
disabled={!isDirty && !isDuplicate}
|
||||
loading={addingVariant || updatingVariant}
|
||||
>
|
||||
Save and close
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</form>
|
||||
</LayeredModal>
|
||||
)
|
||||
}
|
||||
|
||||
export const getEditVariantDefaultValues = (
|
||||
variant: ProductVariant,
|
||||
product: Product
|
||||
): EditFlowVariantFormType => {
|
||||
const options = product.options.map((option) => ({
|
||||
title: option.title,
|
||||
id: option.id,
|
||||
value:
|
||||
variant.options.find((optionValue) => optionValue.option_id === option.id)
|
||||
?.value || "",
|
||||
}))
|
||||
|
||||
const country = countries.find(
|
||||
(country) =>
|
||||
country.name.toLowerCase() === variant.origin_country?.toLowerCase()
|
||||
)
|
||||
|
||||
const countryOption = country
|
||||
? { label: country.name, value: country.alpha2 }
|
||||
: null
|
||||
|
||||
return {
|
||||
general: {
|
||||
title: variant.title,
|
||||
material: variant.material,
|
||||
},
|
||||
stock: {
|
||||
sku: variant.sku,
|
||||
ean: variant.ean,
|
||||
inventory_quantity: variant.inventory_quantity,
|
||||
manage_inventory: variant.manage_inventory,
|
||||
allow_backorder: variant.allow_backorder,
|
||||
barcode: variant.barcode,
|
||||
upc: variant.upc,
|
||||
},
|
||||
customs: {
|
||||
hs_code: variant.hs_code,
|
||||
mid_code: variant.mid_code,
|
||||
origin_country: countryOption,
|
||||
},
|
||||
dimensions: {
|
||||
weight: variant.weight,
|
||||
width: variant.width,
|
||||
height: variant.height,
|
||||
length: variant.length,
|
||||
},
|
||||
prices: {
|
||||
prices: variant.prices.map((price) => ({
|
||||
id: price.id,
|
||||
amount: price.amount,
|
||||
currency_code: price.currency_code,
|
||||
region_id: price.region_id,
|
||||
})),
|
||||
},
|
||||
options,
|
||||
}
|
||||
}
|
||||
|
||||
export default EditVariantModal
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
AdminPostProductsProductVariantsVariantReq,
|
||||
Product,
|
||||
ProductVariant,
|
||||
} from "@medusajs/medusa"
|
||||
import React, { useContext, useEffect, useMemo } from "react"
|
||||
import EditFlowVariantForm, {
|
||||
EditFlowVariantFormType,
|
||||
} from "../../../forms/product/variant-form/edit-flow-variant-form"
|
||||
|
||||
import { useForm } from "react-hook-form"
|
||||
import useEditProductActions from "../../../../hooks/use-edit-product-actions"
|
||||
import Button from "../../../fundamentals/button"
|
||||
import Modal from "../../../molecules/modal"
|
||||
import { LayeredModalContext } from "../../../molecules/modal/layered-modal"
|
||||
import { getEditVariantDefaultValues } from "../edit-variant-modal"
|
||||
import { useEditVariantsModal } from "./use-edit-variants-modal"
|
||||
|
||||
type Props = {
|
||||
variant: ProductVariant
|
||||
product: Product
|
||||
}
|
||||
|
||||
const EditVariantScreen = ({ variant, product }: Props) => {
|
||||
const { onClose } = useEditVariantsModal()
|
||||
const form = useForm<EditFlowVariantFormType>({
|
||||
defaultValues: getEditVariantDefaultValues(variant, product),
|
||||
})
|
||||
|
||||
const { reset: formReset } = form
|
||||
|
||||
const { pop, reset } = useContext(LayeredModalContext)
|
||||
const { updatingVariant, onUpdateVariant } = useEditProductActions(product.id)
|
||||
|
||||
const popAndReset = () => {
|
||||
formReset(getEditVariantDefaultValues(variant, product))
|
||||
pop()
|
||||
}
|
||||
|
||||
const closeAndReset = () => {
|
||||
formReset(getEditVariantDefaultValues(variant, product))
|
||||
reset()
|
||||
onClose()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
formReset(getEditVariantDefaultValues(variant, product))
|
||||
}, [variant, product, formReset])
|
||||
|
||||
const onSubmitAndBack = form.handleSubmit((data) => {
|
||||
// @ts-ignore
|
||||
onUpdateVariant(variant.id, createUpdatePayload(data), popAndReset)
|
||||
})
|
||||
|
||||
const onSubmitAndClose = form.handleSubmit((data) => {
|
||||
// @ts-ignore
|
||||
onUpdateVariant(variant.id, createUpdatePayload(data), closeAndReset)
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<form noValidate>
|
||||
<Modal.Content>
|
||||
<EditFlowVariantForm isEdit={true} form={form} />
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<div className="gap-x-xsmall flex w-full items-center justify-end">
|
||||
<Button variant="secondary" size="small" type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
type="button"
|
||||
disabled={updatingVariant || !form.formState.isDirty}
|
||||
loading={updatingVariant}
|
||||
onClick={onSubmitAndBack}
|
||||
>
|
||||
Save and go back
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
type="button"
|
||||
disabled={updatingVariant || !form.formState.isDirty}
|
||||
loading={updatingVariant}
|
||||
onClick={onSubmitAndClose}
|
||||
>
|
||||
Save and close
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const createUpdatePayload = (
|
||||
data: EditFlowVariantFormType
|
||||
): AdminPostProductsProductVariantsVariantReq => {
|
||||
const { customs, dimensions, prices, options, general, stock } = data
|
||||
|
||||
const priceArray = prices?.prices
|
||||
.filter((price) => typeof price.amount === "number")
|
||||
.map((price) => {
|
||||
return {
|
||||
amount: price.amount,
|
||||
currency_code: price.region_id ? undefined : price.currency_code,
|
||||
region_id: price.region_id,
|
||||
id: price.id || undefined,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
...general,
|
||||
...customs,
|
||||
...stock,
|
||||
...dimensions,
|
||||
...customs,
|
||||
// @ts-ignore
|
||||
origin_country: customs?.origin_country
|
||||
? customs.origin_country.value
|
||||
: null,
|
||||
// @ts-ignore
|
||||
prices: priceArray,
|
||||
options: options?.map((option) => ({
|
||||
option_id: option.id,
|
||||
value: option.value,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export const useEditVariantScreen = (props: Props) => {
|
||||
const { pop } = React.useContext(LayeredModalContext)
|
||||
|
||||
const screen = useMemo(() => {
|
||||
return {
|
||||
title: "Edit Variant",
|
||||
subtitle: props.variant.title,
|
||||
onBack: pop,
|
||||
view: <EditVariantScreen {...props} />,
|
||||
}
|
||||
}, [props])
|
||||
|
||||
return screen
|
||||
}
|
||||
|
||||
export default EditVariantScreen
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
import { Product } from "@medusajs/medusa"
|
||||
import { useCallback, useContext, useEffect } from "react"
|
||||
import {
|
||||
FieldArrayWithId,
|
||||
FormProvider,
|
||||
useFieldArray,
|
||||
useForm,
|
||||
} from "react-hook-form"
|
||||
import useEditProductActions from "../../../../hooks/use-edit-product-actions"
|
||||
import Button from "../../../fundamentals/button"
|
||||
import Modal from "../../../molecules/modal"
|
||||
import LayeredModal, {
|
||||
LayeredModalContext,
|
||||
} from "../../../molecules/modal/layered-modal"
|
||||
import { EditVariantsModalContext } from "./use-edit-variants-modal"
|
||||
import { VariantCard } from "./variant-card"
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
product: Product
|
||||
}
|
||||
|
||||
export type VariantItem = {
|
||||
id: string
|
||||
title: string | null
|
||||
ean: string | null
|
||||
sku: string | null
|
||||
inventory_quantity: number
|
||||
}
|
||||
|
||||
export type EditVariantsForm = {
|
||||
variants: VariantItem[]
|
||||
}
|
||||
|
||||
const EditVariantsModal = ({ open, onClose, product }: Props) => {
|
||||
const context = useContext(LayeredModalContext)
|
||||
const { onUpdate, updating } = useEditProductActions(product.id)
|
||||
|
||||
const form = useForm<EditVariantsForm>({
|
||||
defaultValues: getDefaultValues(product),
|
||||
})
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isDirty },
|
||||
} = form
|
||||
|
||||
const { fields, move } = useFieldArray({
|
||||
control,
|
||||
name: "variants",
|
||||
keyName: "fieldId",
|
||||
})
|
||||
|
||||
const moveCard = useCallback((dragIndex: number, hoverIndex: number) => {
|
||||
move(dragIndex, hoverIndex)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const renderCard = useCallback(
|
||||
(
|
||||
card: FieldArrayWithId<EditVariantsForm, "variants", "fieldId">,
|
||||
index: number
|
||||
) => {
|
||||
return (
|
||||
<VariantCard
|
||||
key={card.fieldId}
|
||||
index={index}
|
||||
id={card.id}
|
||||
title={card.title}
|
||||
ean={card.ean}
|
||||
sku={card.sku}
|
||||
inventory_quantity={card.inventory_quantity}
|
||||
moveCard={moveCard}
|
||||
product={product}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[product]
|
||||
)
|
||||
|
||||
const handleFormReset = () => {
|
||||
reset(getDefaultValues(product))
|
||||
}
|
||||
|
||||
const resetAndClose = () => {
|
||||
handleFormReset()
|
||||
context.reset()
|
||||
onClose()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
handleFormReset()
|
||||
}, [product])
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
onUpdate(
|
||||
{
|
||||
// @ts-ignore
|
||||
variants: data.variants.map((variant) => {
|
||||
return {
|
||||
id: variant.id,
|
||||
inventory_quantity: variant.inventory_quantity,
|
||||
}
|
||||
}),
|
||||
},
|
||||
() => {
|
||||
resetAndClose()
|
||||
},
|
||||
"Variants were successfully updated"
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<EditVariantsModalContext.Provider
|
||||
value={{
|
||||
onClose,
|
||||
}}
|
||||
>
|
||||
<LayeredModal handleClose={resetAndClose} open={open} context={context}>
|
||||
<Modal.Body>
|
||||
<Modal.Header handleClose={resetAndClose}>
|
||||
<h1 className="inter-xlarge-semibold">Edit Variants</h1>
|
||||
</Modal.Header>
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<Modal.Content>
|
||||
<h2 className="inter-base-semibold mb-small">
|
||||
Product variants{" "}
|
||||
<span className="inter-base-regular text-grey-50">
|
||||
({product.variants.length})
|
||||
</span>
|
||||
</h2>
|
||||
<div className="pr-base inter-small-semibold text-grey-50 mb-small grid grid-cols-[1fr_1fr_48px]">
|
||||
<p className="col-start-1 col-end-1 text-left">Variant</p>
|
||||
<p className="col-start-2 col-end-2 text-right">Inventory</p>
|
||||
</div>
|
||||
<div>{fields.map((card, i) => renderCard(card, i))}</div>
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<div className="gap-x-xsmall flex w-full items-center justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
type="button"
|
||||
onClick={resetAndClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
type="submit"
|
||||
loading={updating}
|
||||
disabled={updating || !isDirty}
|
||||
>
|
||||
Save and close
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</Modal.Body>
|
||||
</LayeredModal>
|
||||
</EditVariantsModalContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const getDefaultValues = (product: Product): EditVariantsForm => {
|
||||
const variants = product.variants || []
|
||||
|
||||
const sortedVariants = variants.sort(
|
||||
(a, b) => a.variant_rank - b.variant_rank
|
||||
)
|
||||
|
||||
return {
|
||||
variants: sortedVariants.map((variant, i) => ({
|
||||
id: variant.id,
|
||||
title: variant.title,
|
||||
ean: variant.ean,
|
||||
sku: variant.sku,
|
||||
variant_rank: variant.variant_rank || i + 1,
|
||||
inventory_quantity: variant.inventory_quantity,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export default EditVariantsModal
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import React, { useContext } from "react"
|
||||
|
||||
type EditVariantsModalContextType = {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const EditVariantsModalContext =
|
||||
React.createContext<EditVariantsModalContextType | null>(null)
|
||||
|
||||
export const useEditVariantsModal = () => {
|
||||
const context = useContext(EditVariantsModalContext)
|
||||
|
||||
if (context === null) {
|
||||
throw new Error(
|
||||
"useEditVariantsModal must be used within a EditVariantsModalProvicer"
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
import { Product } from "@medusajs/medusa"
|
||||
import clsx from "clsx"
|
||||
import type { Identifier, XYCoord } from "dnd-core"
|
||||
import { useContext, useMemo, useRef } from "react"
|
||||
import { useDrag, useDrop } from "react-dnd"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { VariantItem } from "."
|
||||
import { DragItem } from "../../../../types/shared"
|
||||
import FormValidator from "../../../../utils/form-validator"
|
||||
import Button from "../../../fundamentals/button"
|
||||
import EditIcon from "../../../fundamentals/icons/edit-icon"
|
||||
import GripIcon from "../../../fundamentals/icons/grip-icon"
|
||||
import MoreHorizontalIcon from "../../../fundamentals/icons/more-horizontal-icon"
|
||||
import Actionables, { ActionType } from "../../../molecules/actionables"
|
||||
import InputField from "../../../molecules/input"
|
||||
import { LayeredModalContext } from "../../../molecules/modal/layered-modal"
|
||||
import { useEditVariantScreen } from "./edit-variant-screen"
|
||||
|
||||
const ItemTypes = {
|
||||
CARD: "card",
|
||||
}
|
||||
|
||||
export type VariantCardProps = {
|
||||
index: number
|
||||
moveCard: (dragIndex: number, hoverIndex: number) => void
|
||||
product: Product
|
||||
} & VariantItem
|
||||
|
||||
export const VariantCard = ({
|
||||
id,
|
||||
index,
|
||||
ean,
|
||||
sku,
|
||||
title,
|
||||
moveCard,
|
||||
product,
|
||||
}: VariantCardProps) => {
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useFormContext()
|
||||
|
||||
const editVariantScreen = useEditVariantScreen({
|
||||
product,
|
||||
variant: product.variants.find((v) => v.id === id)!,
|
||||
})
|
||||
const { push } = useContext(LayeredModalContext)
|
||||
|
||||
const actions: ActionType[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: "Edit Variant",
|
||||
icon: <EditIcon size={20} className="text-grey-50" />,
|
||||
onClick: () => push(editVariantScreen),
|
||||
},
|
||||
]
|
||||
}, [editVariantScreen, push])
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [{ handlerId }, drop] = useDrop<
|
||||
DragItem,
|
||||
void,
|
||||
{ handlerId: Identifier | null }
|
||||
>({
|
||||
accept: ItemTypes.CARD,
|
||||
collect(monitor) {
|
||||
return {
|
||||
handlerId: monitor.getHandlerId(),
|
||||
}
|
||||
},
|
||||
hover(item: DragItem, monitor) {
|
||||
if (!ref.current) {
|
||||
return
|
||||
}
|
||||
const dragIndex = item.index
|
||||
const hoverIndex = index
|
||||
|
||||
if (dragIndex === hoverIndex) {
|
||||
return
|
||||
}
|
||||
|
||||
const hoverBoundingRect = ref.current?.getBoundingClientRect()
|
||||
const hoverMiddleY =
|
||||
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2
|
||||
const clientOffset = monitor.getClientOffset()
|
||||
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top
|
||||
|
||||
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
|
||||
return
|
||||
}
|
||||
|
||||
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
|
||||
return
|
||||
}
|
||||
|
||||
moveCard(dragIndex, hoverIndex)
|
||||
|
||||
item.index = hoverIndex
|
||||
},
|
||||
})
|
||||
|
||||
const [{ isDragging }, drag, preview] = useDrag({
|
||||
type: ItemTypes.CARD,
|
||||
item: () => {
|
||||
return { id, index }
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
})
|
||||
|
||||
drag(drop(ref))
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={preview}
|
||||
data-handler-id={handlerId}
|
||||
className={clsx(
|
||||
"rounded-rounded hover:bg-grey-5 focus-within:bg-grey-5 py-xsmall pl-xsmall pr-base grid h-16 translate-y-0 translate-x-0 grid-cols-[32px_1fr_1fr_48px] transition-all",
|
||||
{
|
||||
"bg-grey-5 opacity-50": isDragging,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="text-grey-40 flex cursor-move items-center justify-center"
|
||||
ref={ref}
|
||||
>
|
||||
<GripIcon size={20} />
|
||||
</div>
|
||||
<div className="ml-base flex flex-col justify-center text-left">
|
||||
<p className="inter-base-semibold">
|
||||
{title}
|
||||
{sku && (
|
||||
<span className="text-grey-50 inter-base-regular">({sku})</span>
|
||||
)}
|
||||
</p>
|
||||
{ean && <span className="inter-base-regular text-grey-50">{ean}</span>}
|
||||
</div>
|
||||
<div className="flex items-center justify-end text-right">
|
||||
<InputField
|
||||
{...register(`variants.${index}.inventory_quantity`, {
|
||||
min: FormValidator.nonNegativeNumberRule("Inventory"),
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
type="number"
|
||||
placeholder="100..."
|
||||
className="max-w-[200px]"
|
||||
errors={errors}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-xlarge pr-base flex items-center justify-center">
|
||||
<Actionables
|
||||
forceDropdown
|
||||
actions={actions}
|
||||
customTrigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-xlarge h-xlarge text-grey-50 flex items-center justify-center p-0"
|
||||
>
|
||||
<MoreHorizontalIcon size={20} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { Product, ProductVariant } from "@medusajs/medusa"
|
||||
import OptionsProvider, { useOptionsContext } from "./options-provider"
|
||||
|
||||
import { useState } from "react"
|
||||
import useEditProductActions from "../../../hooks/use-edit-product-actions"
|
||||
import useToggleState from "../../../hooks/use-toggle-state"
|
||||
import EditIcon from "../../fundamentals/icons/edit-icon"
|
||||
import GearIcon from "../../fundamentals/icons/gear-icon"
|
||||
import PlusIcon from "../../fundamentals/icons/plus-icon"
|
||||
import { ActionType } from "../../molecules/actionables"
|
||||
import Section from "../../organisms/section"
|
||||
import AddVariantModal from "./add-variant-modal"
|
||||
import EditVariantInventoryModal from "./edit-variant-inventory-modal"
|
||||
import EditVariantModal from "./edit-variant-modal"
|
||||
import EditVariantsModal from "./edit-variants-modal"
|
||||
import OptionsModal from "./options-modal"
|
||||
import VariantsTable from "./table"
|
||||
|
||||
type Props = {
|
||||
product: Product
|
||||
}
|
||||
|
||||
const ProductVariantsSection = ({ product }: Props) => {
|
||||
const [variantToEdit, setVariantToEdit] = useState<
|
||||
| {
|
||||
base: ProductVariant
|
||||
isDuplicate: boolean
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
|
||||
const [variantInventoryToEdit, setVariantInventoryToEdit] = useState<
|
||||
{ base: ProductVariant } | undefined
|
||||
>(undefined)
|
||||
|
||||
const {
|
||||
state: optionState,
|
||||
close: closeOptions,
|
||||
toggle: toggleOptions,
|
||||
} = useToggleState()
|
||||
|
||||
const {
|
||||
state: addVariantState,
|
||||
close: closeAddVariant,
|
||||
toggle: toggleAddVariant,
|
||||
} = useToggleState()
|
||||
|
||||
const {
|
||||
state: editVariantsState,
|
||||
close: closeEditVariants,
|
||||
toggle: toggleEditVariants,
|
||||
} = useToggleState()
|
||||
|
||||
const actions: ActionType[] = [
|
||||
{
|
||||
label: "Add Variant",
|
||||
onClick: toggleAddVariant,
|
||||
icon: <PlusIcon size="20" />,
|
||||
},
|
||||
{
|
||||
label: "Edit Variants",
|
||||
onClick: toggleEditVariants,
|
||||
icon: <EditIcon size="20" />,
|
||||
},
|
||||
{
|
||||
label: "Edit Options",
|
||||
onClick: toggleOptions,
|
||||
icon: <GearIcon size="20" />,
|
||||
},
|
||||
]
|
||||
|
||||
const { onDeleteVariant } = useEditProductActions(product.id)
|
||||
|
||||
const handleDeleteVariant = (variantId: string) => {
|
||||
onDeleteVariant(variantId)
|
||||
}
|
||||
|
||||
const handleEditVariant = (variant: ProductVariant) => {
|
||||
setVariantToEdit({ base: variant, isDuplicate: false })
|
||||
}
|
||||
|
||||
const handleDuplicateVariant = (variant: ProductVariant) => {
|
||||
// @ts-ignore
|
||||
setVariantToEdit({ base: { ...variant, options: [] }, isDuplicate: true })
|
||||
}
|
||||
|
||||
const handleEditVariantInventory = (variant: ProductVariant) => {
|
||||
setVariantInventoryToEdit({ base: variant })
|
||||
}
|
||||
|
||||
return (
|
||||
<OptionsProvider product={product}>
|
||||
<Section title="Variants" actions={actions}>
|
||||
<ProductOptions />
|
||||
<div className="mt-xlarge">
|
||||
<h2 className="inter-large-semibold mb-base">
|
||||
Product variants{" "}
|
||||
<span className="inter-large-regular text-grey-50">
|
||||
({product.variants.length})
|
||||
</span>
|
||||
</h2>
|
||||
<VariantsTable
|
||||
variants={product.variants}
|
||||
actions={{
|
||||
deleteVariant: handleDeleteVariant,
|
||||
updateVariant: handleEditVariant,
|
||||
duplicateVariant: handleDuplicateVariant,
|
||||
updateVariantInventory: handleEditVariantInventory,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
<OptionsModal
|
||||
open={optionState}
|
||||
onClose={closeOptions}
|
||||
product={product}
|
||||
/>
|
||||
<AddVariantModal
|
||||
open={addVariantState}
|
||||
onClose={closeAddVariant}
|
||||
product={product}
|
||||
/>
|
||||
<EditVariantsModal
|
||||
open={editVariantsState}
|
||||
onClose={closeEditVariants}
|
||||
product={product}
|
||||
/>
|
||||
{variantToEdit && (
|
||||
<EditVariantModal
|
||||
variant={variantToEdit.base}
|
||||
isDuplicate={variantToEdit.isDuplicate}
|
||||
product={product}
|
||||
onClose={() => setVariantToEdit(undefined)}
|
||||
/>
|
||||
)}
|
||||
{variantInventoryToEdit && (
|
||||
<EditVariantInventoryModal
|
||||
variant={variantInventoryToEdit.base}
|
||||
product={product}
|
||||
onClose={() => setVariantInventoryToEdit(undefined)}
|
||||
/>
|
||||
)}
|
||||
</OptionsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const ProductOptions = () => {
|
||||
const { options, status } = useOptionsContext()
|
||||
|
||||
if (status === "error") {
|
||||
return null
|
||||
}
|
||||
|
||||
if (status === "loading" || !options) {
|
||||
return (
|
||||
<div className="mt-base grid grid-cols-3 gap-x-8">
|
||||
{Array.from(Array(2)).map((_, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<div className="mb-xsmall bg-grey-30 h-6 w-9 animate-pulse"></div>
|
||||
<ul className="flex flex-wrap items-center gap-1">
|
||||
{Array.from(Array(3)).map((_, j) => (
|
||||
<li key={j}>
|
||||
<div className="rounded-rounded bg-grey-10 text-grey-50 h-8 w-12 animate-pulse">
|
||||
{j}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-base flex flex-wrap items-center gap-8">
|
||||
{options.map((option) => {
|
||||
return (
|
||||
<div key={option.id}>
|
||||
<h3 className="inter-base-semibold mb-xsmall">{option.title}</h3>
|
||||
<ul className="flex flex-wrap items-center gap-1">
|
||||
{option.values
|
||||
?.map((val) => val.value)
|
||||
.filter((v, index, self) => self.indexOf(v) === index)
|
||||
.map((v, i) => (
|
||||
<li key={i}>
|
||||
<div className="inter-small-semibold rounded-rounded bg-grey-10 text-grey-50 whitespace-nowrap px-3 py-[6px]">
|
||||
{v}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProductVariantsSection
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
import { Product } from "@medusajs/medusa"
|
||||
import {
|
||||
useAdminCreateProductOption,
|
||||
useAdminDeleteProductOption,
|
||||
useAdminUpdateProductOption,
|
||||
} from "medusa-react"
|
||||
import { useEffect, useMemo } from "react"
|
||||
import { useFieldArray, useForm } from "react-hook-form"
|
||||
import useNotification from "../../../hooks/use-notification"
|
||||
import FormValidator from "../../../utils/form-validator"
|
||||
import Button from "../../fundamentals/button"
|
||||
import PlusIcon from "../../fundamentals/icons/plus-icon"
|
||||
import TrashIcon from "../../fundamentals/icons/trash-icon"
|
||||
import InputField from "../../molecules/input"
|
||||
import Modal from "../../molecules/modal"
|
||||
import { useOptionsContext } from "./options-provider"
|
||||
|
||||
type Props = {
|
||||
product: Product
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type Option = {
|
||||
id: string | null
|
||||
title: string
|
||||
}
|
||||
|
||||
type OptionsForm = {
|
||||
options: Option[]
|
||||
}
|
||||
|
||||
const OptionsModal = ({ product, open, onClose }: Props) => {
|
||||
const { mutate: update, isLoading: updating } = useAdminUpdateProductOption(
|
||||
product.id
|
||||
)
|
||||
const { mutate: create, isLoading: creating } = useAdminCreateProductOption(
|
||||
product.id
|
||||
)
|
||||
const { mutate: del, isLoading: deleting } = useAdminDeleteProductOption(
|
||||
product.id
|
||||
)
|
||||
|
||||
const { refetch } = useOptionsContext()
|
||||
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
reset,
|
||||
handleSubmit,
|
||||
formState: { isDirty, errors },
|
||||
} = useForm<OptionsForm>({
|
||||
defaultValues: getDefaultValues(product),
|
||||
})
|
||||
|
||||
const { fields, remove, append } = useFieldArray({
|
||||
name: "options",
|
||||
control,
|
||||
shouldUnregister: true,
|
||||
})
|
||||
|
||||
const notification = useNotification()
|
||||
|
||||
useEffect(() => {
|
||||
reset(getDefaultValues(product))
|
||||
}, [product])
|
||||
|
||||
const handleClose = () => {
|
||||
reset(getDefaultValues(product))
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleAddAnOption = () => {
|
||||
append({ title: "", id: null })
|
||||
}
|
||||
|
||||
const isSubmitting = useMemo(() => {
|
||||
return updating || creating || deleting
|
||||
}, [updating, creating, deleting])
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
const errors: string[] = []
|
||||
|
||||
const toCreate: Option[] = []
|
||||
const toUpdate: Option[] = []
|
||||
const toDelete: Option[] = product.options.filter(
|
||||
(o) => data.options.find((d) => d.id === o.id) === undefined
|
||||
)
|
||||
|
||||
data.options.forEach((option) => {
|
||||
if (option.id) {
|
||||
toUpdate.push(option)
|
||||
} else {
|
||||
toCreate.push(option)
|
||||
}
|
||||
})
|
||||
|
||||
toCreate.forEach((option) => {
|
||||
create(
|
||||
{
|
||||
title: option.title,
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
errors.push(`create ${option.title}`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
refetch()
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
toUpdate.forEach((option) => {
|
||||
update(
|
||||
{
|
||||
option_id: option.id!,
|
||||
title: option.title,
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
errors.push(`update ${option.title}`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
refetch()
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
toDelete.forEach((option) => {
|
||||
del(option.id!, {
|
||||
onError: () => {
|
||||
errors.push(`delete ${option.title}`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
refetch()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
if (errors.length === toCreate.length + toUpdate.length + toDelete.length) {
|
||||
notification("Error", "Failed to update product options", "error")
|
||||
return
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
notification(
|
||||
"Warning",
|
||||
"Failed to; " + errors.join(", ") + ".",
|
||||
"warning"
|
||||
)
|
||||
}
|
||||
|
||||
refetch()
|
||||
notification("Success", "Successfully updated product options", "success")
|
||||
handleClose()
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal open={open} handleClose={handleClose}>
|
||||
<Modal.Body>
|
||||
<Modal.Header handleClose={handleClose}>
|
||||
<h1 className="inter-xlarge-semibold">Edit Options</h1>
|
||||
</Modal.Header>
|
||||
<form onSubmit={onSubmit}>
|
||||
<Modal.Content>
|
||||
<h2 className="inter-large-semibold mb-base">Product options</h2>
|
||||
<div className="gap-y-small flex flex-col">
|
||||
<p className="inter-small-semibold text-grey-50">Option title</p>
|
||||
<div className="gap-y-xsmall flex flex-col">
|
||||
{fields.map((field, index) => {
|
||||
return (
|
||||
<div
|
||||
className="gap-x-xsmall grid grid-cols-[1fr,40px]"
|
||||
key={field.id}
|
||||
>
|
||||
<InputField
|
||||
key={field.id}
|
||||
placeholder="Color"
|
||||
{...register(`options.${index}.title`, {
|
||||
required: "Option title is required",
|
||||
minLength:
|
||||
FormValidator.minOneCharRule("Option title"),
|
||||
pattern: FormValidator.whiteSpaceRule("Option title"),
|
||||
})}
|
||||
errors={errors}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="max-h-[40px] px-2.5 py-2.5"
|
||||
type="button"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<TrashIcon className="text-grey-40" size="20" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="mt-base h-10 w-full"
|
||||
type="button"
|
||||
onClick={handleAddAnOption}
|
||||
>
|
||||
<PlusIcon size="20" /> Add an option
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<div className="gap-xsmall flex w-full items-center justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
type="submit"
|
||||
disabled={!isDirty}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Save and close
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const getDefaultValues = (product: Product) => {
|
||||
return {
|
||||
options: product.options.map((option) => ({
|
||||
title: option.title,
|
||||
id: option.id,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export default OptionsModal
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { Product, ProductOption } from "@medusajs/medusa"
|
||||
import { useAdminProducts } from "medusa-react"
|
||||
import React, { createContext, useContext, useMemo } from "react"
|
||||
|
||||
type OptionsContext = {
|
||||
options: ProductOption[] | undefined
|
||||
status: "loading" | "success" | "error" | "idle"
|
||||
refetch: () => void
|
||||
}
|
||||
|
||||
const OptionsContextType = createContext<OptionsContext | null>(null)
|
||||
|
||||
type Props = {
|
||||
product: Product
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const OptionsProvider = ({ product, children }: Props) => {
|
||||
const { products, status, refetch } = useAdminProducts({
|
||||
id: product.id,
|
||||
expand: "options,options.values",
|
||||
})
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (products && products.length > 0 && status !== "loading") {
|
||||
return products[0].options
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}, [products, status])
|
||||
|
||||
return (
|
||||
<OptionsContextType.Provider value={{ options, status, refetch }}>
|
||||
{children}
|
||||
</OptionsContextType.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useOptionsContext = () => {
|
||||
const context = useContext(OptionsContextType)
|
||||
if (!context) {
|
||||
throw new Error("useOptionsContext must be used within a OptionsProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export default OptionsProvider
|
||||
@@ -0,0 +1,190 @@
|
||||
import { Column, useTable } from "react-table"
|
||||
|
||||
import { ProductVariant } from "@medusajs/medusa"
|
||||
import { useMemo } from "react"
|
||||
import { useFeatureFlag } from "../../../providers/feature-flag-provider"
|
||||
import BuildingsIcon from "../../fundamentals/icons/buildings-icon"
|
||||
import DuplicateIcon from "../../fundamentals/icons/duplicate-icon"
|
||||
import EditIcon from "../../fundamentals/icons/edit-icon"
|
||||
import TrashIcon from "../../fundamentals/icons/trash-icon"
|
||||
import Actionables from "../../molecules/actionables"
|
||||
import Table from "../../molecules/table"
|
||||
|
||||
type Props = {
|
||||
variants: ProductVariant[]
|
||||
actions: {
|
||||
deleteVariant: (variantId: string) => void
|
||||
duplicateVariant: (variant: ProductVariant) => void
|
||||
updateVariant: (variant: ProductVariant) => void
|
||||
updateVariantInventory: (variant: ProductVariant) => void
|
||||
}
|
||||
}
|
||||
|
||||
export const useVariantsTableColumns = (inventoryIsEnabled = false) => {
|
||||
const columns = useMemo<Column<ProductVariant>[]>(() => {
|
||||
const quantityColumns = []
|
||||
if (!inventoryIsEnabled) {
|
||||
quantityColumns.push({
|
||||
Header: () => {
|
||||
return (
|
||||
<div className="text-right">
|
||||
<span>Inventory</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
id: "inventory",
|
||||
accessor: "inventory_quantity",
|
||||
maxWidth: 56,
|
||||
Cell: ({ cell }) => {
|
||||
return (
|
||||
<div className="text-right">
|
||||
<span>{cell.value}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
return [
|
||||
{
|
||||
Header: "Title",
|
||||
id: "title",
|
||||
accessor: "title",
|
||||
},
|
||||
{
|
||||
Header: "SKU",
|
||||
id: "sku",
|
||||
accessor: "sku",
|
||||
maxWidth: 264,
|
||||
Cell: ({ cell }) => {
|
||||
return cell.value ? (
|
||||
cell.value
|
||||
) : (
|
||||
<span className="text-grey-50">-</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "EAN",
|
||||
id: "ean",
|
||||
accessor: "ean",
|
||||
maxWidth: 264,
|
||||
Cell: ({ cell }) => {
|
||||
return cell.value ? (
|
||||
cell.value
|
||||
) : (
|
||||
<span className="text-grey-50">-</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
...quantityColumns,
|
||||
]
|
||||
}, [inventoryIsEnabled])
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
const VariantsTable = ({ variants, actions }: Props) => {
|
||||
const { isFeatureEnabled } = useFeatureFlag()
|
||||
const hasInventoryService = isFeatureEnabled("inventoryService")
|
||||
const columns = useVariantsTableColumns(hasInventoryService)
|
||||
|
||||
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
|
||||
useTable({
|
||||
columns,
|
||||
data: variants,
|
||||
defaultColumn: {
|
||||
width: "auto",
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
deleteVariant,
|
||||
updateVariant,
|
||||
duplicateVariant,
|
||||
updateVariantInventory,
|
||||
} = actions
|
||||
|
||||
const getTableRowActionables = (variant: ProductVariant) => {
|
||||
const inventoryManagementActions = []
|
||||
if (hasInventoryService) {
|
||||
inventoryManagementActions.push({
|
||||
label: "Manage inventory",
|
||||
icon: <BuildingsIcon size="20" />,
|
||||
onClick: () => updateVariantInventory(variant),
|
||||
})
|
||||
}
|
||||
return [
|
||||
{
|
||||
label: "Edit Variant",
|
||||
icon: <EditIcon size="20" />,
|
||||
onClick: () => updateVariant(variant),
|
||||
},
|
||||
...inventoryManagementActions,
|
||||
{
|
||||
label: "Duplicate Variant",
|
||||
onClick: () =>
|
||||
// @ts-ignore
|
||||
duplicateVariant({
|
||||
...variant,
|
||||
title: variant.title + " Copy",
|
||||
}),
|
||||
icon: <DuplicateIcon size="20" />,
|
||||
},
|
||||
{
|
||||
label: "Delete Variant",
|
||||
onClick: () => deleteVariant(variant.id),
|
||||
icon: <TrashIcon size="20" />,
|
||||
variant: "danger",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return (
|
||||
<Table {...getTableProps()} className="table-fixed">
|
||||
<Table.Head>
|
||||
{headerGroups?.map((headerGroup) => {
|
||||
const { key, ...rest } = headerGroup.getHeaderGroupProps()
|
||||
return (
|
||||
<Table.HeadRow key={key} {...rest}>
|
||||
{headerGroup.headers.map((col) => {
|
||||
const { key, ...rest } = col.getHeaderProps()
|
||||
|
||||
return (
|
||||
<Table.HeadCell key={key} {...rest}>
|
||||
{col.render("Header")}
|
||||
</Table.HeadCell>
|
||||
)
|
||||
})}
|
||||
</Table.HeadRow>
|
||||
)
|
||||
})}
|
||||
</Table.Head>
|
||||
<Table.Body {...getTableBodyProps()}>
|
||||
{rows.map((row) => {
|
||||
prepareRow(row)
|
||||
const actionables = getTableRowActionables(row.original)
|
||||
const { key, ...rest } = row.getRowProps()
|
||||
return (
|
||||
<Table.Row color={"inherit"} key={key} {...rest}>
|
||||
{row.cells.map((cell) => {
|
||||
const { key, ...rest } = cell.getCellProps()
|
||||
return (
|
||||
<Table.Cell key={key} {...rest}>
|
||||
{cell.render("Cell")}
|
||||
</Table.Cell>
|
||||
)
|
||||
})}
|
||||
<Table.Cell>
|
||||
<div className="float-right">
|
||||
<Actionables forceDropdown actions={actionables} />
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
)
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
export default VariantsTable
|
||||
Reference in new issue
Block a user