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

![image](https://user-images.githubusercontent.com/45367945/226584238-bb0786b1-d21c-4b90-b00b-29530af320f4.png)
![image](https://user-images.githubusercontent.com/45367945/226584362-80c0c9f8-4ec5-4e64-9075-110caa3b5137.png)

Resolves CORE-1089
This commit is contained in:
Kasper Fabricius Kristensen authored and GitHub committed 2023-03-23 08:29:29 +00:00
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&apos;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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"))
}
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}
/>
</>
)
}
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
}
@@ -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
@@ -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
@@ -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