fix(admin-ui): Gift Card manage page (#3532)

**What**
- Updates GC manage page to use product page sections
- Revamps Denomination section
- Updates the location of several components to reflect that they are now shared between the GC and products domain

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

Resolves CORE-1089
This commit is contained in:
Kasper Fabricius Kristensen
2023-03-23 09:29:29 +01:00
committed by GitHub
parent 3171b0e518
commit bfef22b33e
77 changed files with 1286 additions and 1706 deletions

View File

@@ -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

View File

@@ -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
}

View File

@@ -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"))
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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}
/>
</>
)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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