fix(admin-ui): Gift Card manage page (#3532)
**What** - Updates GC manage page to use product page sections - Revamps Denomination section - Updates the location of several components to reflect that they are now shared between the GC and products domain   Resolves CORE-1089
This commit is contained in:
committed by
GitHub
parent
3171b0e518
commit
bfef22b33e
@@ -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
|
||||
Reference in New Issue
Block a user