feat(dashboard): Product create from - details (#7121)
**What** - First part of the product creation form. - New components: - ChipInput - Allows users to input chips into a input field. Chips are created by hitting the `,` or `Enter / Return` keys. Deleting a chip is done by hitting `Backspace` when the cursor is next to chip, or clicking the `X` button in the chip. Used for inputting option values. - SortableList - A sortable drag-n-drop list that allows the user to re-arrange the order of items. Used for re-arranging the ranking of variants. - ChipGroup - New re-usable component that is used to render a group of values as Chips. This should be used for SplitView form items. - CategoryCombobox - (WIP) Nested Combobox component for selecting multiple categories a product should be associated with. - New hooks: - useComboboxData - Hook for easily managing the state of comboboxes. - useDebouncedSearch - Hook for managing debounced search queries.
This commit is contained in:
committed by
GitHub
parent
e42308557e
commit
fdee748eed
@@ -18,6 +18,8 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@ariakit/react": "^0.4.1",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@hookform/resolvers": "3.3.2",
|
||||
"@medusajs/icons": "workspace:^",
|
||||
"@medusajs/ui": "workspace:^",
|
||||
@@ -36,10 +38,10 @@
|
||||
"match-sorter": "^6.3.4",
|
||||
"medusa-react": "workspace:^",
|
||||
"qs": "^6.12.0",
|
||||
"react": "18.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-country-flag": "^3.1.0",
|
||||
"react-currency-input-field": "^3.6.11",
|
||||
"react-dom": "18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "7.49.1",
|
||||
"react-i18next": "13.5.0",
|
||||
"react-jwt": "^1.2.0",
|
||||
@@ -53,8 +55,8 @@
|
||||
"@medusajs/ui-preset": "workspace:^",
|
||||
"@medusajs/vite-plugin-extension": "workspace:^",
|
||||
"@types/node": "^20.11.15",
|
||||
"@types/react": "18.2.43",
|
||||
"@types/react-dom": "18.2.17",
|
||||
"@types/react": "^18.2.79",
|
||||
"@types/react-dom": "^18.2.25",
|
||||
"@vitejs/plugin-react": "4.2.1",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"postcss": "^8.4.33",
|
||||
|
||||
@@ -41,14 +41,13 @@
|
||||
"typeToConfirm": "Please type {val} to confirm:",
|
||||
"noResultsTitle": "No results",
|
||||
"noResultsMessage": "Try changing the filters or search query",
|
||||
"noSearchResults": "No search results",
|
||||
"noSearchResultsFor": "No search results for <0>'{{query}}'</0>",
|
||||
"noRecordsTitle": "No records",
|
||||
"noRecordsMessage": "There are no records to show",
|
||||
"unsavedChangesTitle": "Are you sure you want to leave this page?",
|
||||
"unsavedChangesDescription": "You have unsaved changes that will be lost if you leave this page.",
|
||||
"includesTaxTooltip": "Enter the total amount including tax. The net amount excluding tax will be automatically calculated and saved.",
|
||||
"timeline": "Timeline",
|
||||
"success": "Success",
|
||||
"error": "Error"
|
||||
"includesTaxTooltip": "Enter the total amount including tax. The net amount excluding tax will be automatically calculated and saved."
|
||||
},
|
||||
"validation": {
|
||||
"mustBeInt": "The value must be a whole number.",
|
||||
@@ -162,8 +161,27 @@
|
||||
},
|
||||
"products": {
|
||||
"domain": "Products",
|
||||
"createProductTitle": "Create Product",
|
||||
"createProductHint": "Create a new product to sell in your store.",
|
||||
"create": {
|
||||
"header": "Create Product",
|
||||
"hint": "Create a new product to sell in your store.",
|
||||
"tabs": {
|
||||
"details": "Details",
|
||||
"variants": "Variants"
|
||||
},
|
||||
"variants": {
|
||||
"header": "Variants",
|
||||
"productVariants": {
|
||||
"label": "Product variants",
|
||||
"hint": "Variants left unchecked won't be created. This ranking will affect how the variants are ranked in your frontend.",
|
||||
"alert": "Add options to create variants."
|
||||
},
|
||||
"productOptions": {
|
||||
"label": "Product options",
|
||||
"hint": "Define the options for the product, e.g. color, size, etc."
|
||||
}
|
||||
},
|
||||
"successToast": "Product {{title}} was successfully created."
|
||||
},
|
||||
"deleteWarning": "You are about to delete the product {{title}}. This action cannot be undone.",
|
||||
"variants": "Variants",
|
||||
"attributes": "Attributes",
|
||||
@@ -296,10 +314,12 @@
|
||||
"options": {
|
||||
"header": "Options",
|
||||
"edit": {
|
||||
"header": "Edit Option"
|
||||
"header": "Edit Option",
|
||||
"successToast": "Option {{title}} was successfully updated."
|
||||
},
|
||||
"create": {
|
||||
"header": "Create Option"
|
||||
"header": "Create Option",
|
||||
"successToast": "Option {{title}} was successfully created."
|
||||
}
|
||||
},
|
||||
"toasts": {
|
||||
@@ -349,11 +369,11 @@
|
||||
"associatedVariants": "Associated variants",
|
||||
"manageLocations": "Manage locations",
|
||||
"deleteWarning": "You are about to delete an inventory item. This action cannot be undone.",
|
||||
"reservation": {
|
||||
"reservation": {
|
||||
"header": "Reservation of {{itemName}}",
|
||||
"editItemDetails": "Edit item details",
|
||||
"orderID": "Order ID",
|
||||
"description": "Description",
|
||||
"description": "Description",
|
||||
"location": "Location",
|
||||
"inStockAtLocation": "In stock at this location",
|
||||
"availableAtLocation": "Available at this location",
|
||||
@@ -1579,7 +1599,8 @@
|
||||
"unitPrice": "Unit price",
|
||||
"startDate": "Start date",
|
||||
"endDate": "End date",
|
||||
"draft": "Draft"
|
||||
"draft": "Draft",
|
||||
"values": "Values"
|
||||
},
|
||||
"metadata": {
|
||||
"warnings": {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { XMarkMini } from "@medusajs/icons"
|
||||
import { Button, clx } from "@medusajs/ui"
|
||||
import { Children, PropsWithChildren, createContext, useContext } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
type ChipGroupVariant = "base" | "component"
|
||||
|
||||
type ChipGroupProps = PropsWithChildren<{
|
||||
onClearAll?: () => void
|
||||
onRemove?: (index: number) => void
|
||||
variant?: ChipGroupVariant
|
||||
className?: string
|
||||
}>
|
||||
|
||||
type GroupContextValue = {
|
||||
onRemove?: (index: number) => void
|
||||
variant: ChipGroupVariant
|
||||
}
|
||||
|
||||
const GroupContext = createContext<GroupContextValue | null>(null)
|
||||
|
||||
const useGroupContext = () => {
|
||||
const context = useContext(GroupContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useGroupContext must be used within a ChipGroup component")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const Group = ({
|
||||
onClearAll,
|
||||
onRemove,
|
||||
variant = "component",
|
||||
className,
|
||||
children,
|
||||
}: ChipGroupProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const showClearAll = !!onClearAll && Children.count(children) > 0
|
||||
|
||||
return (
|
||||
<GroupContext.Provider value={{ onRemove, variant }}>
|
||||
<ul
|
||||
role="application"
|
||||
className={clx("flex flex-wrap items-center gap-2", className)}
|
||||
>
|
||||
{children}
|
||||
{showClearAll && (
|
||||
<li>
|
||||
<Button
|
||||
size="small"
|
||||
variant="transparent"
|
||||
type="button"
|
||||
onClick={onClearAll}
|
||||
className="text-ui-fg-muted active:text-ui-fg-subtle"
|
||||
>
|
||||
{t("actions.clearAll")}
|
||||
</Button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</GroupContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
type ChipProps = PropsWithChildren<{
|
||||
index: number
|
||||
className?: string
|
||||
}>
|
||||
|
||||
const Chip = ({ index, className, children }: ChipProps) => {
|
||||
const { onRemove, variant } = useGroupContext()
|
||||
|
||||
return (
|
||||
<li
|
||||
className={clx(
|
||||
"bg-ui-bg-component shadow-borders-base flex items-center divide-x overflow-hidden rounded-md",
|
||||
{
|
||||
"bg-ui-bg-component": variant === "component",
|
||||
"bg-ui-bg-base-": variant === "base",
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="txt-compact-small-plus flex items-center justify-center px-2 py-1">
|
||||
{children}
|
||||
</span>
|
||||
{!!onRemove && (
|
||||
<button
|
||||
onClick={() => onRemove(index)}
|
||||
type="button"
|
||||
className={clx(
|
||||
"text-ui-fg-muted active:text-ui-fg-subtle transition-fg flex items-center justify-center p-1",
|
||||
{
|
||||
"hover:bg-ui-bg-component-hover active:bg-ui-bg-component-pressed":
|
||||
variant === "component",
|
||||
"hover:bg-ui-bg-base-hover active:bg-ui-bg-base-pressed":
|
||||
variant === "base",
|
||||
}
|
||||
)}
|
||||
>
|
||||
<XMarkMini />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export const ChipGroup = Object.assign(Group, { Chip })
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./chip-group"
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./keypair"
|
||||
@@ -1,149 +0,0 @@
|
||||
import { Plus, Trash } from "@medusajs/icons"
|
||||
import { Button, Input, Table } from "@medusajs/ui"
|
||||
import { useState } from "react"
|
||||
|
||||
interface KeyPair {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface KeypairProps {
|
||||
labels: {
|
||||
add: string
|
||||
key?: string
|
||||
value?: string
|
||||
}
|
||||
value: KeyPair[]
|
||||
onChange: (value: KeyPair[]) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const Keypair = ({ labels, onChange, value }: KeypairProps) => {
|
||||
const addKeyPair = () => {
|
||||
onChange([...value, { key: ``, value: `` }])
|
||||
}
|
||||
|
||||
const deleteKeyPair = (index: number) => {
|
||||
return () => {
|
||||
onChange(value.filter((_, i) => i !== index))
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyChange = (index: number) => {
|
||||
return (key: string) => {
|
||||
const newArr = value.map((pair, i) => {
|
||||
if (i === index) {
|
||||
return { key, value: pair.value }
|
||||
}
|
||||
return pair
|
||||
})
|
||||
|
||||
onChange(newArr)
|
||||
}
|
||||
}
|
||||
|
||||
const onValueChange = (index: number) => {
|
||||
return (val: string) => {
|
||||
const newArr = value.map((pair, i) => {
|
||||
if (i === index) {
|
||||
return { key: pair.key, value: val }
|
||||
}
|
||||
return pair
|
||||
})
|
||||
|
||||
onChange(newArr)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Table className="w-full">
|
||||
<Table.Header className="border-t-0">
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>{labels.key}</Table.HeaderCell>
|
||||
<Table.HeaderCell>{labels.value}</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{value.map((pair, index) => {
|
||||
return (
|
||||
<Field
|
||||
labels={labels}
|
||||
field={pair}
|
||||
updateKey={onKeyChange(index)}
|
||||
updateValue={onValueChange(index)}
|
||||
onDelete={deleteKeyPair(index)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
type="button"
|
||||
className="w-full mt-4"
|
||||
onClick={addKeyPair}
|
||||
>
|
||||
<Plus />
|
||||
{labels.add}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type FieldProps = {
|
||||
field: KeyPair
|
||||
labels: {
|
||||
key?: string
|
||||
value?: string
|
||||
}
|
||||
updateKey: (key: string) => void
|
||||
updateValue: (value: string) => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
const Field: React.FC<FieldProps> = ({
|
||||
field,
|
||||
updateKey,
|
||||
updateValue,
|
||||
onDelete,
|
||||
}) => {
|
||||
const [key, setKey] = useState(field.key)
|
||||
const [value, setValue] = useState(field.value)
|
||||
|
||||
return (
|
||||
<Table.Row>
|
||||
<Table.Cell className="!p-0 h-0">
|
||||
<Input
|
||||
className="rounded-none bg-transparent"
|
||||
onBlur={() => updateKey(key)}
|
||||
value={key}
|
||||
onChange={(e) => {
|
||||
setKey(e.currentTarget.value)
|
||||
}}
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="!p-0 h-0">
|
||||
<Input
|
||||
className="rounded-none bg-transparent"
|
||||
onBlur={() => updateValue(value)}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.currentTarget.value)
|
||||
}}
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="!p-0 h-0 border-r">
|
||||
<Button
|
||||
variant="transparent"
|
||||
size="small"
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash />
|
||||
</Button>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./list"
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Checkbox, Text } from "@medusajs/ui"
|
||||
|
||||
export interface ListProps<T> {
|
||||
options: { title: string; value: T }[]
|
||||
value?: T[]
|
||||
onChange?: (value: T[]) => void
|
||||
compare?: (a: T, b: T) => boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const List = <T extends any>({
|
||||
options,
|
||||
onChange,
|
||||
value,
|
||||
compare,
|
||||
disabled,
|
||||
}: ListProps<T>) => {
|
||||
if (options.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-row justify-center border divide-y rounded-lg">
|
||||
{options.map((option) => {
|
||||
return (
|
||||
<div className="flex p-4 gap-x-4">
|
||||
{onChange && value !== undefined && (
|
||||
<Checkbox
|
||||
disabled={disabled}
|
||||
checked={value.some(
|
||||
(v) => compare?.(v, option.value) ?? v === option.value
|
||||
)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
onChange([...value, option.value])
|
||||
} else {
|
||||
onChange(
|
||||
value.filter(
|
||||
(v) =>
|
||||
!(compare?.(v, option.value) ?? v === option.value)
|
||||
)
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Text key={option.title}>{option.title}</Text>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./product-table-cells"
|
||||
-130
@@ -1,130 +0,0 @@
|
||||
import { SalesChannel } from "@medusajs/medusa"
|
||||
import {
|
||||
ProductCollectionDTO,
|
||||
ProductDTO,
|
||||
ProductVariantDTO,
|
||||
} from "@medusajs/types"
|
||||
import { StatusBadge, Text } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Thumbnail } from "../thumbnail"
|
||||
|
||||
export const ProductVariantCell = ({
|
||||
variants,
|
||||
}: {
|
||||
variants: ProductVariantDTO[] | null
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!variants || !variants.length) {
|
||||
return (
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
-
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Text size="small" className="text-ui-fg-base">
|
||||
{t("products.variantCount", {
|
||||
count: variants.length,
|
||||
})}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProductStatusCell = ({
|
||||
status,
|
||||
}: {
|
||||
status: ProductDTO["status"]
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const color = {
|
||||
draft: "grey",
|
||||
published: "green",
|
||||
rejected: "red",
|
||||
proposed: "blue",
|
||||
}[status] as "grey" | "green" | "red" | "blue"
|
||||
|
||||
return (
|
||||
<StatusBadge color={color}>
|
||||
{t(`products.productStatus.${status}`)}
|
||||
</StatusBadge>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProductAvailabilityCell = ({
|
||||
salesChannels,
|
||||
}: {
|
||||
salesChannels: SalesChannel[] | null
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!salesChannels || salesChannels.length === 0) {
|
||||
return (
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
-
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
if (salesChannels.length < 3) {
|
||||
return (
|
||||
<Text size="small" className="text-ui-fg-base">
|
||||
{salesChannels.map((sc) => sc.name).join(", ")}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Text size="small" className="text-ui-fg-base">
|
||||
<span>
|
||||
{salesChannels
|
||||
.slice(0, 2)
|
||||
.map((sc) => sc.name)
|
||||
.join(", ")}
|
||||
</span>{" "}
|
||||
<span>
|
||||
{t("general.plusCountMore", {
|
||||
count: salesChannels.length - 2,
|
||||
})}
|
||||
</span>
|
||||
</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProductTitleCell = ({ product }: { product: ProductDTO }) => {
|
||||
const thumbnail = product.thumbnail
|
||||
const title = product.title
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-3">
|
||||
<Thumbnail src={thumbnail} alt={`Thumbnail image of ${title}`} />
|
||||
<Text size="small" className="text-ui-fg-base">
|
||||
{title}
|
||||
</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProductCollectionCell = ({
|
||||
collection,
|
||||
}: {
|
||||
collection: ProductCollectionDTO | null
|
||||
}) => {
|
||||
if (!collection) {
|
||||
return (
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
-
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Text size="small" className="text-ui-fg-base">
|
||||
{collection.title}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./sortable-list"
|
||||
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
Active,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
DraggableSyntheticListeners,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
defaultDropAnimationSideEffects,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DropAnimation,
|
||||
type UniqueIdentifier,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { DotsSix } from "@medusajs/icons"
|
||||
import { IconButton, clx } from "@medusajs/ui"
|
||||
import {
|
||||
CSSProperties,
|
||||
Fragment,
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
type SortableBaseItem = {
|
||||
id: UniqueIdentifier
|
||||
}
|
||||
|
||||
interface SortableListProps<TItem extends SortableBaseItem> {
|
||||
items: TItem[]
|
||||
onChange: (items: TItem[]) => void
|
||||
renderItem: (item: TItem, index: number) => ReactNode
|
||||
}
|
||||
|
||||
const List = <TItem extends SortableBaseItem>({
|
||||
items,
|
||||
onChange,
|
||||
renderItem,
|
||||
}: SortableListProps<TItem>) => {
|
||||
const [active, setActive] = useState<Active | null>(null)
|
||||
|
||||
const [activeItem, activeIndex] = useMemo(() => {
|
||||
if (active === null) {
|
||||
return [null, null]
|
||||
}
|
||||
|
||||
const index = items.findIndex(({ id }) => id === active.id)
|
||||
|
||||
return [items[index], index]
|
||||
}, [active, items])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
const handleDragStart = ({ active }: DragStartEvent) => {
|
||||
setActive(active)
|
||||
}
|
||||
|
||||
const handleDragEnd = ({ active, over }: DragEndEvent) => {
|
||||
if (over && active.id !== over.id) {
|
||||
const activeIndex = items.findIndex(({ id }) => id === active.id)
|
||||
const overIndex = items.findIndex(({ id }) => id === over.id)
|
||||
|
||||
onChange(arrayMove(items, activeIndex, overIndex))
|
||||
}
|
||||
|
||||
setActive(null)
|
||||
}
|
||||
|
||||
const handleDragCancel = () => {
|
||||
setActive(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<Overlay>
|
||||
{activeItem && activeIndex !== null
|
||||
? renderItem(activeItem, activeIndex)
|
||||
: null}
|
||||
</Overlay>
|
||||
<SortableContext items={items}>
|
||||
<ul
|
||||
role="application"
|
||||
className="flex list-inside list-none list-image-none flex-col p-0"
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={item.id}>{renderItem(item, index)}</Fragment>
|
||||
))}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
const dropAnimationConfig: DropAnimation = {
|
||||
sideEffects: defaultDropAnimationSideEffects({
|
||||
styles: {
|
||||
active: {
|
||||
opacity: "0.4",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
type SortableOverlayProps = PropsWithChildren
|
||||
|
||||
const Overlay = ({ children }: SortableOverlayProps) => {
|
||||
return (
|
||||
<DragOverlay
|
||||
className="shadow-elevation-card-hover overflow-hidden rounded-md [&>li]:border-b-0"
|
||||
dropAnimation={dropAnimationConfig}
|
||||
>
|
||||
{children}
|
||||
</DragOverlay>
|
||||
)
|
||||
}
|
||||
|
||||
type SortableItemProps<TItem extends SortableBaseItem> = PropsWithChildren<{
|
||||
id: TItem["id"]
|
||||
className?: string
|
||||
}>
|
||||
|
||||
type SortableItemContextValue = {
|
||||
attributes: Record<string, any>
|
||||
listeners: DraggableSyntheticListeners
|
||||
ref: (node: HTMLElement | null) => void
|
||||
isDragging: boolean
|
||||
}
|
||||
|
||||
const SortableItemContext = createContext<SortableItemContextValue | null>(null)
|
||||
|
||||
const useSortableItemContext = () => {
|
||||
const context = useContext(SortableItemContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useSortableItemContext must be used within a SortableItemContext"
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const Item = <TItem extends SortableBaseItem>({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
}: SortableItemProps<TItem>) => {
|
||||
const {
|
||||
attributes,
|
||||
isDragging,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({ id })
|
||||
|
||||
const context = useMemo(
|
||||
() => ({
|
||||
attributes,
|
||||
listeners,
|
||||
ref: setActivatorNodeRef,
|
||||
isDragging,
|
||||
}),
|
||||
[attributes, listeners, setActivatorNodeRef, isDragging]
|
||||
)
|
||||
|
||||
const style: CSSProperties = {
|
||||
opacity: isDragging ? 0.4 : undefined,
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
return (
|
||||
<SortableItemContext.Provider value={context}>
|
||||
<li
|
||||
className={clx("transition-fg flex flex-1 list-none", className)}
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
</SortableItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const DragHandle = () => {
|
||||
const { attributes, listeners, ref } = useSortableItemContext()
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
variant="transparent"
|
||||
size="small"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
ref={ref}
|
||||
className="cursor-grab touch-none active:cursor-grabbing"
|
||||
>
|
||||
<DotsSix className="text-ui-fg-muted" />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
|
||||
export const SortableList = Object.assign(List, {
|
||||
Item,
|
||||
DragHandle,
|
||||
})
|
||||
@@ -5,8 +5,8 @@ import { z } from "zod"
|
||||
|
||||
import { Control } from "react-hook-form"
|
||||
import { AddressSchema } from "../../../lib/schemas"
|
||||
import { CountrySelect } from "../../common/country-select"
|
||||
import { Form } from "../../common/form"
|
||||
import { CountrySelect } from "../../inputs/country-select"
|
||||
|
||||
type AddressFieldValues = z.infer<typeof AddressSchema>
|
||||
|
||||
|
||||
+1
-1
@@ -16,9 +16,9 @@ import {
|
||||
getOrderPaymentStatus,
|
||||
} from "../../../lib/order-helpers"
|
||||
import { TransferOwnershipSchema } from "../../../lib/schemas"
|
||||
import { Combobox } from "../../common/combobox"
|
||||
import { Form } from "../../common/form"
|
||||
import { Skeleton } from "../../common/skeleton"
|
||||
import { Combobox } from "../../inputs/combobox"
|
||||
|
||||
type TransferOwnerShipFieldValues = z.infer<typeof TransferOwnershipSchema>
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { XMarkMini } from "@medusajs/icons"
|
||||
import { Badge, clx } from "@medusajs/ui"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import {
|
||||
FocusEvent,
|
||||
KeyboardEvent,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
type ChipInputProps = {
|
||||
value?: string[]
|
||||
onChange?: (value: string[]) => void
|
||||
onBlur?: () => void
|
||||
name?: string
|
||||
disabled?: boolean
|
||||
allowDuplicates?: boolean
|
||||
showRemove?: boolean
|
||||
variant?: "base" | "contrast"
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const ChipInput = forwardRef<HTMLInputElement, ChipInputProps>(
|
||||
(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled,
|
||||
name,
|
||||
showRemove = true,
|
||||
variant = "base",
|
||||
allowDuplicates = false,
|
||||
className,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const innerRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const isControlledRef = useRef(typeof value !== "undefined")
|
||||
const isControlled = isControlledRef.current
|
||||
|
||||
const [uncontrolledValue, setUncontrolledValue] = useState<string[]>([])
|
||||
|
||||
useImperativeHandle<HTMLInputElement | null, HTMLInputElement | null>(
|
||||
ref,
|
||||
() => innerRef.current
|
||||
)
|
||||
|
||||
const [duplicateIndex, setDuplicateIndex] = useState<number | null>(null)
|
||||
|
||||
const chips = isControlled ? (value as string[]) : uncontrolledValue
|
||||
|
||||
const handleAddChip = (chip: string) => {
|
||||
const cleanValue = chip.trim()
|
||||
|
||||
if (!cleanValue) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!allowDuplicates && chips.includes(cleanValue)) {
|
||||
setDuplicateIndex(chips.indexOf(cleanValue))
|
||||
|
||||
setTimeout(() => {
|
||||
setDuplicateIndex(null)
|
||||
}, 300)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
onChange?.([...chips, cleanValue])
|
||||
|
||||
if (!isControlled) {
|
||||
setUncontrolledValue([...chips, cleanValue])
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveChip = (chip: string) => {
|
||||
onChange?.(chips.filter((v) => v !== chip))
|
||||
|
||||
if (!isControlled) {
|
||||
setUncontrolledValue(chips.filter((v) => v !== chip))
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlur = (e: FocusEvent<HTMLInputElement>) => {
|
||||
onBlur?.()
|
||||
|
||||
if (e.target.value) {
|
||||
handleAddChip(e.target.value)
|
||||
e.target.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault()
|
||||
|
||||
if (!innerRef.current?.value) {
|
||||
return
|
||||
}
|
||||
|
||||
handleAddChip(innerRef.current?.value ?? "")
|
||||
innerRef.current.value = ""
|
||||
innerRef.current?.focus()
|
||||
}
|
||||
|
||||
if (e.key === "Backspace" && innerRef.current?.value === "") {
|
||||
handleRemoveChip(chips[chips.length - 1])
|
||||
}
|
||||
}
|
||||
|
||||
// create a shake animation using framer motion
|
||||
const shake = {
|
||||
x: [0, -2, 2, -2, 2, 0],
|
||||
transition: { duration: 0.3 },
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clx(
|
||||
"shadow-borders-base flex min-h-8 flex-wrap items-center gap-1 rounded-md px-2 py-1.5",
|
||||
"transition-fg focus-within:shadow-borders-interactive-with-active",
|
||||
"has-[input:disabled]:bg-ui-bg-disabled has-[input:disabled]:text-ui-fg-disabled has-[input:disabled]:cursor-not-allowed",
|
||||
{
|
||||
"bg-ui-bg-field-component hover:bg-ui-bg-field-component-hover":
|
||||
variant === "contrast",
|
||||
"bg-ui-bg-field hover:bg-ui-bg-field-hover": variant === "base",
|
||||
},
|
||||
className
|
||||
)}
|
||||
tabIndex={-1}
|
||||
onClick={() => innerRef.current?.focus()}
|
||||
>
|
||||
{chips.map((v, index) => {
|
||||
return (
|
||||
<AnimatePresence key={`${v}-${index}`}>
|
||||
<Badge
|
||||
size="2xsmall"
|
||||
className={clx("gap-x-0.5 pl-1.5 pr-1.5", {
|
||||
"transition-fg pr-1": showRemove,
|
||||
"shadow-borders-focus": index === duplicateIndex,
|
||||
})}
|
||||
asChild
|
||||
>
|
||||
<motion.div
|
||||
animate={index === duplicateIndex ? shake : undefined}
|
||||
>
|
||||
{v}
|
||||
{showRemove && (
|
||||
<button
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
onClick={() => handleRemoveChip(v)}
|
||||
className={clx(
|
||||
"text-ui-fg-subtle transition-fg outline-none"
|
||||
)}
|
||||
>
|
||||
<XMarkMini />
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
</Badge>
|
||||
</AnimatePresence>
|
||||
)
|
||||
})}
|
||||
<input
|
||||
className={clx(
|
||||
"caret-ui-fg-base text-ui-fg-base txt-compact-small flex-1 appearance-none bg-transparent",
|
||||
"disabled:text-ui-fg-disabled disabled:cursor-not-allowed",
|
||||
"focus:outline-none",
|
||||
"placeholder:text-ui-fg-muted"
|
||||
)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
name={name}
|
||||
ref={innerRef}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ChipInput.displayName = "ChipInput"
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./chip-input"
|
||||
+1
-1
@@ -30,7 +30,7 @@ import {
|
||||
} from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { genericForwardRef } from "../generic-forward-ref"
|
||||
import { genericForwardRef } from "../../common/generic-forward-ref"
|
||||
|
||||
type ComboboxOption = {
|
||||
value: string
|
||||
+5
@@ -183,6 +183,8 @@ export const DataTableRoot = <TData,>({
|
||||
const to = navigateTo ? navigateTo(row) : undefined
|
||||
const isRowDisabled = hasSelect && !row.getCanSelect()
|
||||
|
||||
const isOdd = row.depth % 2 !== 0
|
||||
|
||||
return (
|
||||
<Table.Row
|
||||
key={row.id}
|
||||
@@ -190,6 +192,7 @@ export const DataTableRoot = <TData,>({
|
||||
className={clx(
|
||||
"transition-fg group/row [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
|
||||
{
|
||||
"bg-ui-bg-subtle hover:bg-ui-bg-subtle-hover": isOdd,
|
||||
"cursor-pointer": !!to,
|
||||
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
|
||||
row.getIsSelected(),
|
||||
@@ -228,6 +231,8 @@ export const DataTableRoot = <TData,>({
|
||||
className={clx({
|
||||
"bg-ui-bg-base group-data-[selected=true]/row:bg-ui-bg-highlight group-data-[selected=true]/row:group-hover/row:bg-ui-bg-highlight-hover group-hover/row:bg-ui-bg-base-hover transition-fg sticky left-0 after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
|
||||
isStickyCell,
|
||||
"bg-ui-bg-subtle group-hover/row:bg-ui-bg-subtle-hover":
|
||||
isOdd && isStickyCell,
|
||||
"left-[68px]":
|
||||
isStickyCell && hasSelect && !isSelectCell,
|
||||
"after:bg-ui-border-base":
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { TriangleRightMini } from "@medusajs/icons"
|
||||
import { AdminProductCategoryResponse } from "@medusajs/types"
|
||||
import { IconButton, Text, clx } from "@medusajs/ui"
|
||||
import { createColumnHelper } from "@tanstack/react-table"
|
||||
import { useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { StatusCell } from "../../../components/table/table-cells/common/status-cell"
|
||||
import {
|
||||
TextCell,
|
||||
TextHeader,
|
||||
} from "../../../components/table/table-cells/common/text-cell"
|
||||
import {
|
||||
getCategoryPath,
|
||||
getIsActiveProps,
|
||||
getIsInternalProps,
|
||||
} from "../../../v2-routes/categories/common/utils"
|
||||
|
||||
const columnHelper =
|
||||
createColumnHelper<AdminProductCategoryResponse["product_category"]>()
|
||||
|
||||
export const useCategoryTableColumns = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
columnHelper.accessor("name", {
|
||||
header: () => <TextHeader text={t("fields.name")} />,
|
||||
cell: ({ getValue, row }) => {
|
||||
const expandHandler = row.getToggleExpandedHandler()
|
||||
|
||||
if (row.original.parent_category !== undefined) {
|
||||
const path = getCategoryPath(row.original)
|
||||
|
||||
return (
|
||||
<div className="flex size-full items-center gap-1 overflow-hidden">
|
||||
{path.map((chip, index) => (
|
||||
<div
|
||||
key={chip.id}
|
||||
className={clx("overflow-hidden", {
|
||||
"text-ui-fg-muted flex items-center gap-x-1":
|
||||
index !== path.length - 1,
|
||||
})}
|
||||
>
|
||||
<Text size="small" leading="compact" className="truncate">
|
||||
{chip.name}
|
||||
</Text>
|
||||
{index !== path.length - 1 && (
|
||||
<Text size="small" leading="compact">
|
||||
/
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full items-center gap-x-3 overflow-hidden">
|
||||
<div className="flex size-7 items-center justify-center">
|
||||
{row.getCanExpand() ? (
|
||||
<IconButton
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
|
||||
expandHandler()
|
||||
}}
|
||||
size="small"
|
||||
variant="transparent"
|
||||
className="text-ui-fg-subtle"
|
||||
>
|
||||
<TriangleRightMini
|
||||
className={clx({
|
||||
"rotate-90 transition-transform will-change-transform":
|
||||
row.getIsExpanded(),
|
||||
})}
|
||||
/>
|
||||
</IconButton>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="truncate">{getValue()}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("handle", {
|
||||
header: () => <TextHeader text={t("fields.handle")} />,
|
||||
cell: ({ getValue }) => {
|
||||
return <TextCell text={`/${getValue()}`} />
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("is_active", {
|
||||
header: () => <TextHeader text={t("fields.status")} />,
|
||||
cell: ({ getValue }) => {
|
||||
const { color, label } = getIsActiveProps(getValue(), t)
|
||||
|
||||
return <StatusCell color={color}>{label}</StatusCell>
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("is_internal", {
|
||||
header: () => <TextHeader text={t("categories.fields.visibility")} />,
|
||||
cell: ({ getValue }) => {
|
||||
const { color, label } = getIsInternalProps(getValue(), t)
|
||||
|
||||
return <StatusCell color={color}>{label}</StatusCell>
|
||||
},
|
||||
}),
|
||||
],
|
||||
[t]
|
||||
)
|
||||
}
|
||||
@@ -30,7 +30,6 @@ export const useProductTableFilters = (
|
||||
{
|
||||
limit: 1000,
|
||||
fields: "id,name",
|
||||
expand: "",
|
||||
},
|
||||
{
|
||||
enabled: !isSalesChannelExcluded,
|
||||
|
||||
@@ -1,71 +1,91 @@
|
||||
import { QueryKey, useInfiniteQuery } from "@tanstack/react-query"
|
||||
import debounce from "lodash/debounce"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import {
|
||||
QueryKey,
|
||||
keepPreviousData,
|
||||
useInfiniteQuery,
|
||||
useQuery,
|
||||
} from "@tanstack/react-query"
|
||||
import { useDebouncedSearch } from "./use-debounced-search"
|
||||
|
||||
type Params = {
|
||||
q: string
|
||||
limit: number
|
||||
type ComboboxExternalData = {
|
||||
offset: number
|
||||
}
|
||||
|
||||
type Page = {
|
||||
limit: number
|
||||
count: number
|
||||
offset: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
type UseComboboxDataProps<TParams extends Params, TRes extends Page> = {
|
||||
fetcher: (params: TParams) => Promise<TRes>
|
||||
params?: Omit<TParams, "q" | "limit" | "offset">
|
||||
queryKey: QueryKey
|
||||
type ComboboxQueryParams = {
|
||||
q?: string
|
||||
offset?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching infinite data for a combobox.
|
||||
*/
|
||||
export const useComboboxData = <TParams extends Params, TRes extends Page>({
|
||||
fetcher,
|
||||
params,
|
||||
export const useComboboxData = <
|
||||
TResponse extends ComboboxExternalData,
|
||||
TParams extends ComboboxQueryParams
|
||||
>({
|
||||
queryKey,
|
||||
}: UseComboboxDataProps<TParams, TRes>) => {
|
||||
const [query, setQuery] = useState("")
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("")
|
||||
queryFn,
|
||||
getOptions,
|
||||
defaultValue,
|
||||
defaultValueKey,
|
||||
pageSize = 10,
|
||||
}: {
|
||||
queryKey: QueryKey
|
||||
queryFn: (params: TParams) => Promise<TResponse>
|
||||
getOptions: (data: TResponse) => { label: string; value: string }[]
|
||||
defaultValueKey?: keyof TParams
|
||||
defaultValue?: string | string[]
|
||||
pageSize?: number
|
||||
}) => {
|
||||
const { searchValue, onSearchValueChange, query } = useDebouncedSearch()
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedUpdate = useCallback(
|
||||
debounce((query) => setDebouncedQuery(query), 300),
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
debouncedUpdate(query)
|
||||
|
||||
return () => debouncedUpdate.cancel()
|
||||
}, [query, debouncedUpdate])
|
||||
|
||||
const data = useInfiniteQuery(
|
||||
[...queryKey, debouncedQuery],
|
||||
async ({ pageParam = 0 }) => {
|
||||
const res = await fetcher({
|
||||
q: debouncedQuery,
|
||||
limit: 10,
|
||||
offset: pageParam,
|
||||
...params,
|
||||
const queryIntialDataBy = defaultValueKey || "id"
|
||||
const { data: initialData } = useQuery({
|
||||
queryKey: queryKey,
|
||||
queryFn: async () => {
|
||||
return queryFn({
|
||||
[queryIntialDataBy]: defaultValue,
|
||||
limit: Array.isArray(defaultValue) ? defaultValue.length : 1,
|
||||
} as TParams)
|
||||
return res
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => {
|
||||
const morePages = lastPage.count > lastPage.offset + lastPage.limit
|
||||
return morePages ? lastPage.offset + lastPage.limit : undefined
|
||||
},
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
enabled: !!defaultValue,
|
||||
})
|
||||
|
||||
const { data, ...rest } = useInfiniteQuery({
|
||||
queryKey: [...queryKey, query],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
return queryFn({
|
||||
q: query,
|
||||
limit: pageSize,
|
||||
offset: pageParam,
|
||||
} as TParams)
|
||||
},
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage) => {
|
||||
const moreItemsExist = lastPage.count > lastPage.offset + lastPage.limit
|
||||
return moreItemsExist ? lastPage.offset + lastPage.limit : undefined
|
||||
},
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
|
||||
const options = data?.pages.flatMap((page) => getOptions(page)) ?? []
|
||||
const defaultOptions = initialData ? getOptions(initialData) : []
|
||||
|
||||
/**
|
||||
* If there are no options and the query is empty, then the combobox should be disabled,
|
||||
* as there is no data to search for.
|
||||
*/
|
||||
const disabled = !rest.isPending && !options.length && !searchValue
|
||||
|
||||
// // make sure that the default value is included in the option, if its not in options already
|
||||
if (defaultValue && !options.find((o) => o.value === defaultValue)) {
|
||||
options.unshift(defaultOptions[0])
|
||||
}
|
||||
|
||||
return {
|
||||
...data,
|
||||
query,
|
||||
setQuery,
|
||||
options,
|
||||
searchValue,
|
||||
onSearchValueChange,
|
||||
disabled,
|
||||
...rest,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import debounce from "lodash/debounce"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
/**
|
||||
* Hook for debouncing search input
|
||||
* @returns searchValue, onSearchValueChange, query
|
||||
*/
|
||||
export const useDebouncedSearch = () => {
|
||||
const [searchValue, onSearchValueChange] = useState("")
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("")
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedUpdate = useCallback(
|
||||
debounce((query: string) => setDebouncedQuery(query), 300),
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
debouncedUpdate(searchValue)
|
||||
|
||||
return () => debouncedUpdate.cancel()
|
||||
}, [searchValue, debouncedUpdate])
|
||||
|
||||
return {
|
||||
searchValue,
|
||||
onSearchValueChange,
|
||||
query: debouncedQuery || undefined,
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ async function makeRequest<
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
|
||||
// Temp: Add a better error type
|
||||
throw new Error(`API error ${response.status}: ${errorData.message}`)
|
||||
}
|
||||
|
||||
+2
-2
@@ -15,9 +15,9 @@ import { useEffect, useMemo } from "react"
|
||||
import { Trans, useTranslation } from "react-i18next"
|
||||
|
||||
import { useWatch } from "react-hook-form"
|
||||
import { Combobox } from "../../../../../components/common/combobox"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import { PercentageInput } from "../../../../../components/common/percentage-input"
|
||||
import { Combobox } from "../../../../../components/inputs/combobox"
|
||||
import { PercentageInput } from "../../../../../components/inputs/percentage-input"
|
||||
import { getCurrencySymbol } from "../../../../../lib/currencies"
|
||||
import { CreateDiscountFormReturn } from "./create-discount-form"
|
||||
import { DiscountRuleType } from "./types"
|
||||
|
||||
+2
-2
@@ -13,9 +13,9 @@ import { useForm } from "react-hook-form"
|
||||
import { Trans, useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
|
||||
import { Combobox } from "../../../../../components/common/combobox"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import { PercentageInput } from "../../../../../components/common/percentage-input"
|
||||
import { Combobox } from "../../../../../components/inputs/combobox"
|
||||
import { PercentageInput } from "../../../../../components/inputs/percentage-input"
|
||||
import {
|
||||
RouteDrawer,
|
||||
useRouteModal,
|
||||
|
||||
+1
-1
@@ -7,8 +7,8 @@ import { useCallback, useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { json } from "react-router-dom"
|
||||
|
||||
import { Combobox } from "../../../../../../components/common/combobox"
|
||||
import { Form } from "../../../../../../components/common/form"
|
||||
import { Combobox } from "../../../../../../components/inputs/combobox"
|
||||
import { useCreateDraftOrder } from "../hooks"
|
||||
|
||||
export const CreateDraftOrderCustomerDetails = () => {
|
||||
|
||||
+1
-1
@@ -7,9 +7,9 @@ import { useTranslation } from "react-i18next"
|
||||
import { ShippingOption } from "@medusajs/medusa"
|
||||
import { CurrencyInput, Heading, Input } from "@medusajs/ui"
|
||||
import { json } from "react-router-dom"
|
||||
import { Combobox } from "../../../../../../components/common/combobox"
|
||||
import { ConditionalTooltip } from "../../../../../../components/common/conditional-tooltip"
|
||||
import { Form } from "../../../../../../components/common/form"
|
||||
import { Combobox } from "../../../../../../components/inputs/combobox"
|
||||
import { getLocaleAmount } from "../../../../../../lib/money-amount-helpers"
|
||||
import { useCreateDraftOrder } from "../hooks"
|
||||
|
||||
|
||||
+7
-7
@@ -1,18 +1,18 @@
|
||||
import { PricedVariant } from "@medusajs/client-types"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { OnChangeFn, RowSelectionState } from "@tanstack/react-table"
|
||||
import { useState } from "react"
|
||||
import { useAdminVariants } from "medusa-react"
|
||||
import { Button } from "@medusajs/ui"
|
||||
import { Order } from "@medusajs/medusa"
|
||||
import { Button } from "@medusajs/ui"
|
||||
import { OnChangeFn, RowSelectionState } from "@tanstack/react-table"
|
||||
import { useAdminVariants } from "medusa-react"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { SplitView } from "../../../../../components/layout/split-view"
|
||||
import { DataTable } from "../../../../../components/table/data-table"
|
||||
import { useDataTable } from "../../../../../hooks/use-data-table.tsx"
|
||||
import { SplitView } from "../../../../../components/layout/split-view"
|
||||
|
||||
import { useVariantTableQuery } from "./use-variant-table-query"
|
||||
import { useVariantTableColumns } from "./use-variant-table-columns"
|
||||
import { useVariantTableFilters } from "./use-variant-table-filters"
|
||||
import { useVariantTableQuery } from "./use-variant-table-query"
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
|
||||
+9
-88
@@ -1,25 +1,17 @@
|
||||
import { PencilSquare, TriangleRightMini } from "@medusajs/icons"
|
||||
import { PencilSquare } from "@medusajs/icons"
|
||||
import { AdminProductCategoryResponse } from "@medusajs/types"
|
||||
import { Container, Heading, IconButton, Text, clx } from "@medusajs/ui"
|
||||
import { Container, Heading } from "@medusajs/ui"
|
||||
import { keepPreviousData } from "@tanstack/react-query"
|
||||
import { createColumnHelper } from "@tanstack/react-table"
|
||||
import { useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { DataTable } from "../../../../../components/table/data-table"
|
||||
import { StatusCell } from "../../../../../components/table/table-cells/common/status-cell"
|
||||
import {
|
||||
TextCell,
|
||||
TextHeader,
|
||||
} from "../../../../../components/table/table-cells/common/text-cell"
|
||||
import { useCategories } from "../../../../../hooks/api/categories"
|
||||
import { useCategoryTableColumns } from "../../../../../hooks/table/columns/use-category-table-columns"
|
||||
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||
import { useCategoryTableQuery } from "../../../common/hooks/use-category-table-query"
|
||||
import {
|
||||
getCategoryPath,
|
||||
getIsActiveProps,
|
||||
getIsInternalProps,
|
||||
} from "../../../common/utils"
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
@@ -51,7 +43,7 @@ export const CategoryListTable = () => {
|
||||
}
|
||||
)
|
||||
|
||||
const columns = useCategoryTableColumns()
|
||||
const columns = useColumns()
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: product_categories || [],
|
||||
@@ -114,83 +106,12 @@ const CategoryRowActions = ({
|
||||
const columnHelper =
|
||||
createColumnHelper<AdminProductCategoryResponse["product_category"]>()
|
||||
|
||||
const useCategoryTableColumns = () => {
|
||||
const { t } = useTranslation()
|
||||
const useColumns = () => {
|
||||
const base = useCategoryTableColumns()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
columnHelper.accessor("name", {
|
||||
header: () => <TextHeader text={t("fields.name")} />,
|
||||
cell: ({ getValue, row }) => {
|
||||
const expandHandler = row.getToggleExpandedHandler()
|
||||
|
||||
console.log(row.original)
|
||||
|
||||
if (row.original.parent_category !== undefined) {
|
||||
const path = getCategoryPath(row.original)
|
||||
|
||||
return (
|
||||
<div className="flex size-full items-center">
|
||||
{path.map((chip) => (
|
||||
<div key={chip.id}>
|
||||
<Text>{chip.name}</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full items-center gap-x-3 overflow-hidden">
|
||||
<div className="flex size-7 items-center justify-center">
|
||||
{row.getCanExpand() ? (
|
||||
<IconButton
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
|
||||
expandHandler()
|
||||
}}
|
||||
size="small"
|
||||
variant="transparent"
|
||||
>
|
||||
<TriangleRightMini
|
||||
className={clx({
|
||||
"rotate-90 transition-transform will-change-transform":
|
||||
row.getIsExpanded(),
|
||||
})}
|
||||
/>
|
||||
</IconButton>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="truncate">{getValue()}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("handle", {
|
||||
header: () => <TextHeader text={t("fields.handle")} />,
|
||||
cell: ({ getValue }) => {
|
||||
return <TextCell text={`/${getValue()}`} />
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("is_active", {
|
||||
header: () => <TextHeader text={t("fields.status")} />,
|
||||
cell: ({ getValue }) => {
|
||||
const { color, label } = getIsActiveProps(getValue(), t)
|
||||
|
||||
return <StatusCell color={color}>{label}</StatusCell>
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("is_internal", {
|
||||
header: () => <TextHeader text={t("categories.fields.visibility")} />,
|
||||
cell: ({ getValue }) => {
|
||||
const { color, label } = getIsInternalProps(getValue(), t)
|
||||
|
||||
return <StatusCell color={color}>{label}</StatusCell>
|
||||
},
|
||||
}),
|
||||
...base,
|
||||
columnHelper.display({
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
@@ -198,6 +119,6 @@ const useCategoryTableColumns = () => {
|
||||
},
|
||||
}),
|
||||
],
|
||||
[t]
|
||||
[base]
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import { HandleInput } from "../../../../../components/common/handle-input"
|
||||
import { HandleInput } from "../../../../../components/inputs/handle-input"
|
||||
import {
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
|
||||
+4
-4
@@ -6,14 +6,14 @@ import {
|
||||
useRouteModal,
|
||||
} from "../../../../../../components/route-modal"
|
||||
|
||||
import { CountrySelect } from "../../../../../../components/common/country-select"
|
||||
import { Form } from "../../../../../../components/common/form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { InventoryNext } from "@medusajs/types"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useUpdateInventoryItem } from "../../../../../../hooks/api/inventory"
|
||||
import { z } from "zod"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Form } from "../../../../../../components/common/form"
|
||||
import { CountrySelect } from "../../../../../../components/inputs/country-select"
|
||||
import { useUpdateInventoryItem } from "../../../../../../hooks/api/inventory"
|
||||
|
||||
type EditInventoryItemAttributeFormProps = {
|
||||
item: InventoryNext.InventoryItemDTO
|
||||
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
import {
|
||||
ArrowUturnLeft,
|
||||
CheckMini,
|
||||
TriangleRightMini,
|
||||
TrianglesMini,
|
||||
XMarkMini,
|
||||
} from "@medusajs/icons"
|
||||
import { AdminProductCategoryResponse } from "@medusajs/types"
|
||||
import { Text, clx } from "@medusajs/ui"
|
||||
import * as Popover from "@radix-ui/react-popover"
|
||||
import {
|
||||
ComponentPropsWithoutRef,
|
||||
Fragment,
|
||||
MouseEvent,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { Trans, useTranslation } from "react-i18next"
|
||||
import { Divider } from "../../../../../components/common/divider"
|
||||
import { TextSkeleton } from "../../../../../components/common/skeleton"
|
||||
import { useCategories } from "../../../../../hooks/api/categories"
|
||||
import { useDebouncedSearch } from "../../../../../hooks/use-debounced-search"
|
||||
|
||||
interface CategoryComboboxProps
|
||||
extends Omit<
|
||||
ComponentPropsWithoutRef<"input">,
|
||||
"value" | "defaultValue" | "onChange"
|
||||
> {
|
||||
value: string[]
|
||||
onChange: (value: string[]) => void
|
||||
}
|
||||
|
||||
type Level = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export const CategoryCombobox = forwardRef<
|
||||
HTMLInputElement,
|
||||
CategoryComboboxProps
|
||||
>(({ value, onChange, className, ...props }, ref) => {
|
||||
const innerRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useImperativeHandle<HTMLInputElement | null, HTMLInputElement | null>(
|
||||
ref,
|
||||
() => innerRef.current,
|
||||
[]
|
||||
)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const { i18n, t } = useTranslation()
|
||||
|
||||
const [level, setLevel] = useState<Level[]>([])
|
||||
const { searchValue, onSearchValueChange, query } = useDebouncedSearch()
|
||||
|
||||
const { product_categories, isPending, isError, error } = useCategories(
|
||||
{
|
||||
q: query,
|
||||
parent_category_id: !searchValue ? getParentId(level) : undefined,
|
||||
include_descendants_tree: !searchValue ? true : false,
|
||||
},
|
||||
{
|
||||
enabled: open,
|
||||
}
|
||||
)
|
||||
|
||||
const [showLoading, setShowLoading] = useState(false)
|
||||
|
||||
/**
|
||||
* We add a small artificial delay to the end of the loading state,
|
||||
* this is done to prevent the popover from flickering too much when
|
||||
* navigating between levels or searching.
|
||||
*/
|
||||
useEffect(() => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
if (isPending) {
|
||||
setShowLoading(true)
|
||||
} else {
|
||||
timeoutId = setTimeout(() => {
|
||||
setShowLoading(false)
|
||||
}, 150)
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}, [isPending])
|
||||
|
||||
useEffect(() => {
|
||||
if (searchValue) {
|
||||
setLevel([])
|
||||
}
|
||||
}, [searchValue])
|
||||
|
||||
function handleLevelUp(e: MouseEvent<HTMLButtonElement>) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
setLevel(level.slice(0, level.length - 1))
|
||||
|
||||
innerRef.current?.focus()
|
||||
}
|
||||
|
||||
function handleLevelDown(option: ProductCategoryOption) {
|
||||
return (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
setLevel([...level, { id: option.value, label: option.label }])
|
||||
|
||||
innerRef.current?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(option: ProductCategoryOption) {
|
||||
return (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (isSelected(value, option.value)) {
|
||||
onChange(value.filter((v) => v !== option.value))
|
||||
} else {
|
||||
onChange([...value, option.value])
|
||||
}
|
||||
|
||||
innerRef.current?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
if (!open) {
|
||||
onSearchValueChange("")
|
||||
setLevel([])
|
||||
}
|
||||
|
||||
if (open) {
|
||||
requestAnimationFrame(() => {
|
||||
innerRef.current?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
setOpen(open)
|
||||
}
|
||||
|
||||
const options = getOptions(product_categories || [])
|
||||
|
||||
const showTag = value.length > 0 && !open
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={handleOpenChange}>
|
||||
<Popover.Trigger asChild>
|
||||
<div
|
||||
className={clx(
|
||||
"relative flex cursor-pointer items-center gap-x-2 overflow-hidden",
|
||||
"h-8 w-full rounded-md px-2 py-0.5",
|
||||
"bg-ui-bg-field transition-fg shadow-borders-base",
|
||||
"hover:bg-ui-bg-field-hover",
|
||||
"has-[input:focus]:shadow-borders-interactive-with-active",
|
||||
"has-[:invalid]:shadow-borders-error has-[[aria-invalid=true]]:shadow-borders-error",
|
||||
"has-[:disabled]:bg-ui-bg-disabled has-[:disabled]:text-ui-fg-disabled has-[:disabled]:cursor-not-allowed",
|
||||
{
|
||||
// Fake the focus state as long as the popover is open,
|
||||
// this prevents the styling from flickering when navigating
|
||||
// between levels.
|
||||
"shadow-borders-interactive-with-active": open,
|
||||
"pl-0.5": showTag,
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
{open ? (
|
||||
<input
|
||||
ref={innerRef}
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchValueChange(e.target.value)}
|
||||
className={clx(
|
||||
"txt-compact-small w-full appearance-none bg-transparent outline-none",
|
||||
"placeholder:text-ui-fg-muted"
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
) : showTag ? (
|
||||
<div className="flex w-full items-center gap-x-2">
|
||||
<div className="flex w-fit items-center gap-x-1">
|
||||
<div className="bg-ui-bg-base txt-compact-small-plus text-ui-fg-subtle focus-within:border-ui-fg-interactive relative flex h-[28px] items-center rounded-[4px] border py-[3px] pl-1.5 pr-1">
|
||||
<span>{value.length}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="size-fit outline-none"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
onChange([])
|
||||
}}
|
||||
>
|
||||
<XMarkMini className="text-ui-fg-muted" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Text size="small" leading="compact">
|
||||
{t("general.selected")}
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full"></div>
|
||||
)}
|
||||
<div className="flex size-5 items-center justify-center">
|
||||
<TrianglesMini className="text-ui-fg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
sideOffset={8}
|
||||
role="listbox"
|
||||
className={clx(
|
||||
"shadow-elevation-flyout bg-ui-bg-base -left-2 z-50 w-[var(--radix-popper-anchor-width)] rounded-[8px]",
|
||||
"max-h-[200px] overflow-y-auto",
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
|
||||
)}
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
{!searchValue && level.length > 0 && (
|
||||
<Fragment>
|
||||
<div className="p-1">
|
||||
<button
|
||||
className={clx(
|
||||
"transition-fg grid w-full appearance-none grid-cols-[20px_1fr] items-center justify-center gap-2 rounded-md px-2 py-1.5 text-left outline-none",
|
||||
"hover:bg-ui-bg-base-hover active:bg-ui-bg-base-pressed"
|
||||
)}
|
||||
type="button"
|
||||
onClick={handleLevelUp}
|
||||
>
|
||||
<ArrowUturnLeft className="text-ui-fg-muted" />
|
||||
<Text size="small" leading="compact">
|
||||
{getParentLabel(level)}
|
||||
</Text>
|
||||
</button>
|
||||
</div>
|
||||
<Divider />
|
||||
</Fragment>
|
||||
)}
|
||||
<div className="p-1">
|
||||
{options.length > 0 &&
|
||||
!showLoading &&
|
||||
options.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={clx(
|
||||
"transition-fg bg-ui-bg-base grid cursor-pointer grid-cols-1 items-center gap-2 overflow-hidden",
|
||||
{
|
||||
"grid-cols-[1fr_32px]":
|
||||
option.has_children && !searchValue,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
className={clx(
|
||||
"grid h-full w-full appearance-none grid-cols-[20px_1fr] items-center gap-2 overflow-hidden rounded-md px-2 py-1.5 text-left",
|
||||
"hover:bg-ui-bg-base-hover"
|
||||
)}
|
||||
onClick={handleSelect(option)}
|
||||
>
|
||||
<div className="flex size-5 items-center justify-center">
|
||||
{isSelected(value, option.value) && <CheckMini />}
|
||||
</div>
|
||||
<Text
|
||||
as="span"
|
||||
size="small"
|
||||
leading="compact"
|
||||
className="w-full truncate"
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</button>
|
||||
{option.has_children && !searchValue && (
|
||||
<button
|
||||
className={clx(
|
||||
"text-ui-fg-muted flex size-8 appearance-none items-center justify-center rounded-md outline-none",
|
||||
"hover:bg-ui-bg-base-hover active:bg-ui-bg-base-pressed"
|
||||
)}
|
||||
type="button"
|
||||
onClick={handleLevelDown(option)}
|
||||
>
|
||||
<TriangleRightMini />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{showLoading &&
|
||||
Array.from({ length: 5 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="grid grid-cols-[20px_1fr_20px] gap-2 px-2 py-1.5"
|
||||
>
|
||||
<div />
|
||||
<TextSkeleton size="small" leading="compact" />
|
||||
<div />
|
||||
</div>
|
||||
))}
|
||||
{options.length === 0 && !showLoading && (
|
||||
<div className="px-2 py-1.5">
|
||||
<Text size="small" leading="compact">
|
||||
{query ? (
|
||||
<Trans
|
||||
i18n={i18n}
|
||||
i18nKey={"general.noSearchResultsFor"}
|
||||
tOptions={{
|
||||
query: query,
|
||||
}}
|
||||
components={[
|
||||
<span className="font-medium" key="query" />,
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
t("general.noRecordsFound")
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
)
|
||||
})
|
||||
|
||||
CategoryCombobox.displayName = "CategoryCombobox"
|
||||
|
||||
type ProductCategoryOption = {
|
||||
value: string
|
||||
label: string
|
||||
has_children: boolean
|
||||
}
|
||||
|
||||
function getParentId(level: Level[]): string {
|
||||
if (!level.length) {
|
||||
return "null"
|
||||
}
|
||||
|
||||
return level[level.length - 1].id
|
||||
}
|
||||
|
||||
function getParentLabel(level: Level[]): string | null {
|
||||
if (!level.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return level[level.length - 1].label
|
||||
}
|
||||
|
||||
function getOptions(
|
||||
categories: AdminProductCategoryResponse["product_category"][]
|
||||
): ProductCategoryOption[] {
|
||||
return categories.map((cat) => {
|
||||
return {
|
||||
value: cat.id,
|
||||
label: cat.name,
|
||||
has_children: cat.category_children?.length > 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function isSelected(values: string[], value: string): boolean {
|
||||
return values.includes(value)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./category-combobox"
|
||||
+9
-9
@@ -1,18 +1,18 @@
|
||||
import { CurrencyDTO, ProductVariantDTO } from "@medusajs/types"
|
||||
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
|
||||
import { useMemo } from "react"
|
||||
import { UseFormReturn, useWatch } from "react-hook-form"
|
||||
import { CreateProductSchemaType } from "../product-create/schema"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { DataGrid } from "../../../components/grid/data-grid"
|
||||
import { CurrencyCell } from "../../../components/grid/grid-cells/common/currency-cell"
|
||||
import { ReadonlyCell } from "../../../components/grid/grid-cells/common/readonly-cell"
|
||||
import { DataGridMeta } from "../../../components/grid/types"
|
||||
import { useCurrencies } from "../../../hooks/api/currencies"
|
||||
import { useStore } from "../../../hooks/api/store"
|
||||
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
|
||||
import { CurrencyDTO, ProductVariantDTO } from "@medusajs/types"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useMemo } from "react"
|
||||
import { ReadonlyCell } from "../../../components/grid/grid-cells/common/readonly-cell"
|
||||
import { CurrencyCell } from "../../../components/grid/grid-cells/common/currency-cell"
|
||||
import { DataGridMeta } from "../../../components/grid/types"
|
||||
import { ProductCreateSchemaType } from "../product-create/schema"
|
||||
|
||||
type VariantPricingFormProps = {
|
||||
form: UseFormReturn<CreateProductSchemaType>
|
||||
form: UseFormReturn<ProductCreateSchemaType>
|
||||
}
|
||||
|
||||
export const VariantPricingForm = ({ form }: VariantPricingFormProps) => {
|
||||
|
||||
+1
-1
@@ -4,8 +4,8 @@ import { Button, Input } from "@medusajs/ui"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
import { CountrySelect } from "../../../../../components/common/country-select"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import { CountrySelect } from "../../../../../components/inputs/country-select"
|
||||
import {
|
||||
RouteDrawer,
|
||||
useRouteModal,
|
||||
|
||||
+20
-13
@@ -1,10 +1,12 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Product } from "@medusajs/medusa"
|
||||
import { Button, Input } from "@medusajs/ui"
|
||||
import { Button, Input, toast } from "@medusajs/ui"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { z } from "zod"
|
||||
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import { ChipInput } from "../../../../../components/inputs/chip-input"
|
||||
import {
|
||||
RouteDrawer,
|
||||
useRouteModal,
|
||||
@@ -34,13 +36,25 @@ export const CreateProductOptionForm = ({
|
||||
resolver: zodResolver(CreateProductOptionSchema),
|
||||
})
|
||||
|
||||
const { mutateAsync, isLoading } = useCreateProductOption(product.id)
|
||||
const { mutateAsync, isPending } = useCreateProductOption(product.id)
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
mutateAsync(values, {
|
||||
onSuccess: () => {
|
||||
onSuccess: ({ option }) => {
|
||||
toast.success(t("general.success"), {
|
||||
description: t("products.options.create.successToast", {
|
||||
title: option.title,
|
||||
}),
|
||||
dismissLabel: t("general.close"),
|
||||
})
|
||||
handleSuccess()
|
||||
},
|
||||
onError: async (err) => {
|
||||
toast.error(t("general.error"), {
|
||||
description: err.message,
|
||||
dismissLabel: t("general.close"),
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -71,21 +85,14 @@ export const CreateProductOptionForm = ({
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="values"
|
||||
render={({ field: { value, onChange, ...field } }) => {
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>
|
||||
{t("products.fields.options.variations")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input
|
||||
{...field}
|
||||
value={(value ?? []).join(",")}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
onChange(val.split(",").map((v) => v.trim()))
|
||||
}}
|
||||
/>
|
||||
<ChipInput {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
@@ -100,7 +107,7 @@ export const CreateProductOptionForm = ({
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteDrawer.Close>
|
||||
<Button type="submit" size="small" isLoading={isLoading}>
|
||||
<Button type="submit" size="small" isLoading={isPending}>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
+4
-4
@@ -1,22 +1,22 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Product, ProductVariant } from "@medusajs/medusa"
|
||||
import { Product } from "@medusajs/medusa"
|
||||
import { Button, Heading, Input, Switch } from "@medusajs/ui"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { z } from "zod"
|
||||
|
||||
import { Fragment } from "react"
|
||||
import { Combobox } from "../../../../../components/common/combobox"
|
||||
import { CountrySelect } from "../../../../../components/common/country-select"
|
||||
import { Divider } from "../../../../../components/common/divider"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import { Combobox } from "../../../../../components/inputs/combobox"
|
||||
import { CountrySelect } from "../../../../../components/inputs/country-select"
|
||||
import {
|
||||
RouteDrawer,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import { useCreateProductVariant } from "../../../../../hooks/api/products"
|
||||
import { castNumber } from "../../../../../lib/cast-number"
|
||||
import { optionalInt } from "../../../../../lib/validation"
|
||||
import { useCreateProductVariant } from "../../../../../hooks/api/products"
|
||||
|
||||
type CreateProductVariantFormProps = {
|
||||
product: Product
|
||||
|
||||
-801
@@ -1,801 +0,0 @@
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Heading,
|
||||
Input,
|
||||
Select,
|
||||
Switch,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@medusajs/ui"
|
||||
import { Trans, useTranslation } from "react-i18next"
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"
|
||||
|
||||
import { SalesChannel } from "@medusajs/medusa"
|
||||
import { RowSelectionState, createColumnHelper } from "@tanstack/react-table"
|
||||
import { Fragment, useMemo, useState } from "react"
|
||||
import { CountrySelect } from "../../../../components/common/country-select"
|
||||
import { Form } from "../../../../components/common/form"
|
||||
import { HandleInput } from "../../../../components/common/handle-input"
|
||||
import { DataTable } from "../../../../components/table/data-table"
|
||||
import { useSalesChannelTableColumns } from "../../../../hooks/table/columns/use-sales-channel-table-columns"
|
||||
import { useSalesChannelTableFilters } from "../../../../hooks/table/filters/use-sales-channel-table-filters"
|
||||
import { useSalesChannelTableQuery } from "../../../../hooks/table/query/use-sales-channel-table-query"
|
||||
import { useDataTable } from "../../../../hooks/use-data-table"
|
||||
import { Combobox } from "../../../../components/common/combobox"
|
||||
import { FileUpload } from "../../../../components/common/file-upload"
|
||||
import { List } from "../../../../components/common/list"
|
||||
import { useProductTypes } from "../../../../hooks/api/product-types"
|
||||
import { useCollections } from "../../../../hooks/api/collections"
|
||||
import { useSalesChannels } from "../../../../hooks/api/sales-channels"
|
||||
import { useCategories } from "../../../../hooks/api/categories"
|
||||
import { useTags } from "../../../../hooks/api/tags"
|
||||
import { Keypair } from "../../../../components/common/keypair"
|
||||
import { UseFormReturn } from "react-hook-form"
|
||||
import { CreateProductSchemaType } from "../schema"
|
||||
|
||||
type ProductAttributesProps = {
|
||||
form: UseFormReturn<CreateProductSchemaType>
|
||||
}
|
||||
|
||||
const SUPPORTED_FORMATS = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/heic",
|
||||
"image/svg+xml",
|
||||
]
|
||||
|
||||
const permutations = (
|
||||
data: { title: string; values: string[] }[]
|
||||
): { [key: string]: string }[] => {
|
||||
if (data.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (data.length === 1) {
|
||||
return data[0].values.map((value) => ({ [data[0].title]: value }))
|
||||
}
|
||||
|
||||
const toProcess = data[0]
|
||||
const rest = data.slice(1)
|
||||
|
||||
return toProcess.values.flatMap((value) => {
|
||||
return permutations(rest).map((permutation) => {
|
||||
return {
|
||||
[toProcess.title]: value,
|
||||
...permutation,
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const generateNameFromPermutation = (permutation: {
|
||||
[key: string]: string
|
||||
}) => {
|
||||
return Object.values(permutation).join(" / ")
|
||||
}
|
||||
|
||||
export const ProductAttributesForm = ({ form }: ProductAttributesProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, onOpenChange] = useState(false)
|
||||
const { product_types, isLoading: isLoadingTypes } = useProductTypes()
|
||||
const { product_tags, isLoading: isLoadingTags } = useTags()
|
||||
const { collections, isLoading: isLoadingCollections } = useCollections()
|
||||
const { sales_channels, isLoading: isLoadingSalesChannels } =
|
||||
useSalesChannels()
|
||||
const { product_categories, isLoading: isLoadingCategories } = useCategories()
|
||||
|
||||
const options = form.watch("options")
|
||||
const optionPermutations = permutations(options ?? [])
|
||||
|
||||
// const { append } = useFieldArray({
|
||||
// name: "images",
|
||||
// control: form.control,
|
||||
// // keyName: "field_id",
|
||||
// })
|
||||
|
||||
return (
|
||||
<PanelGroup
|
||||
direction="horizontal"
|
||||
className="flex h-full justify-center overflow-hidden"
|
||||
>
|
||||
<Panel
|
||||
className="flex h-full w-full flex-col items-center"
|
||||
minSize={open ? 50 : 100}
|
||||
>
|
||||
<div className="flex size-full flex-col items-center overflow-auto p-16">
|
||||
<div className="flex w-full max-w-[736px] flex-col justify-center px-2 pb-2">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<Heading>{t("products.createProductTitle")}</Heading>
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
{t("products.createProductHint")}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-8 divide-y [&>div]:pt-8">
|
||||
<div id="general" className="flex flex-col gap-y-8">
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div className="grid grid-cols-2 gap-x-4">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>
|
||||
{t("products.fields.title.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="subtitle"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.subtitle.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Form.Hint>
|
||||
<Trans
|
||||
i18nKey="products.fields.title.hint"
|
||||
t={t}
|
||||
components={[<br key="break" />]}
|
||||
/>
|
||||
</Form.Hint>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="handle"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label
|
||||
tooltip={t("products.fields.handle.tooltip")}
|
||||
optional
|
||||
>
|
||||
{t("fields.handle")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<HandleInput {...field} />
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.description.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Textarea {...field} />
|
||||
</Form.Control>
|
||||
<Form.Hint>
|
||||
<Trans
|
||||
i18nKey={"products.fields.description.hint"}
|
||||
components={[<br key="break" />]}
|
||||
/>
|
||||
</Form.Hint>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div id="organize" className="flex flex-col gap-y-8">
|
||||
<Heading level="h2">{t("products.organization")}</Heading>
|
||||
<div className="grid grid-cols-1 gap-x-4">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="discountable"
|
||||
render={({ field: { value, onChange, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<div className="flex items-center justify-between">
|
||||
<Form.Label optional>
|
||||
{t("products.fields.discountable.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Switch
|
||||
{...field}
|
||||
checked={!!value}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
</Form.Control>
|
||||
</div>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Hint>
|
||||
<Trans i18nKey={"products.fields.discountable.hint"} />
|
||||
</Form.Hint>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="type_id"
|
||||
render={({ field: { onChange, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.type.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Select
|
||||
disabled={isLoadingTypes}
|
||||
{...field}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
<Select.Trigger ref={field.ref}>
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{(product_types ?? []).map((type) => (
|
||||
<Select.Item key={type.id} value={type.id}>
|
||||
{type.value}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select>
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="collection_id"
|
||||
render={({ field: { onChange, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.collection.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Select
|
||||
disabled={isLoadingCollections}
|
||||
{...field}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
<Select.Trigger ref={field.ref}>
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{(collections ?? []).map((collection) => (
|
||||
<Select.Item
|
||||
key={collection.id}
|
||||
value={collection.id}
|
||||
>
|
||||
{collection.title}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select>
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="category_ids"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.categories.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Combobox
|
||||
disabled={isLoadingCategories}
|
||||
options={(product_categories ?? []).map(
|
||||
(category) => ({
|
||||
label: category.name,
|
||||
value: category.id,
|
||||
})
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="tags"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.tags.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Combobox
|
||||
disabled={isLoadingTags}
|
||||
options={(product_tags ?? []).map((tag) => ({
|
||||
label: tag.value,
|
||||
value: tag.id,
|
||||
}))}
|
||||
{...field}
|
||||
/>
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* TODO: Align to match designs */}
|
||||
<div className="grid grid-cols-1 gap-x-4">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="sales_channels"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.sales_channels.label")}
|
||||
</Form.Label>
|
||||
<Form.Hint>
|
||||
<Trans
|
||||
i18nKey={"products.fields.sales_channels.hint"}
|
||||
/>
|
||||
</Form.Hint>
|
||||
<Form.Control>
|
||||
<Combobox
|
||||
disabled={isLoadingSalesChannels}
|
||||
options={(sales_channels ?? []).map(
|
||||
(salesChannel) => ({
|
||||
label: salesChannel.name,
|
||||
value: salesChannel.id,
|
||||
})
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* <Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(!open)}
|
||||
>
|
||||
{t("actions.edit")}
|
||||
</Button> */}
|
||||
</div>
|
||||
<div id="variants" className="flex flex-col gap-y-8">
|
||||
<Heading level="h2">{t("products.variants")}</Heading>
|
||||
<div className="grid grid-cols-1 gap-x-4 gap-y-8">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="options"
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const normalizedValue = (value ?? []).map((v) => {
|
||||
return {
|
||||
key: v.title,
|
||||
value: v.values.join(","),
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.options.label")}
|
||||
</Form.Label>
|
||||
<Form.Hint>
|
||||
{t("products.fields.options.hint")}
|
||||
</Form.Hint>
|
||||
<Form.Control>
|
||||
<Keypair
|
||||
labels={{
|
||||
add: t("products.fields.options.add"),
|
||||
key: t("products.fields.options.optionTitle"),
|
||||
value: t("products.fields.options.variations"),
|
||||
}}
|
||||
value={normalizedValue}
|
||||
onChange={(newVal) =>
|
||||
onChange(
|
||||
newVal.map((v) => {
|
||||
return {
|
||||
title: v.key,
|
||||
values: v.value
|
||||
.split(",")
|
||||
.map((v) => v.trim()),
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-x-4 gap-y-8">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="variants"
|
||||
render={({ field: { value, onChange, ...field } }) => {
|
||||
const selectedOptions = (value ?? []).map(
|
||||
(v) => v.options
|
||||
)
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.variants.label")}
|
||||
</Form.Label>
|
||||
<Form.Hint>
|
||||
{t("products.fields.variants.hint")}
|
||||
</Form.Hint>
|
||||
<Form.Control>
|
||||
<List
|
||||
{...field}
|
||||
value={selectedOptions}
|
||||
onChange={(v) => {
|
||||
onChange(
|
||||
v.map((options, i) => {
|
||||
return {
|
||||
title:
|
||||
generateNameFromPermutation(options),
|
||||
variant_rank: i,
|
||||
options,
|
||||
}
|
||||
})
|
||||
)
|
||||
}}
|
||||
compare={(a, b) => {
|
||||
return (
|
||||
generateNameFromPermutation(a) ===
|
||||
generateNameFromPermutation(b)
|
||||
)
|
||||
}}
|
||||
options={optionPermutations.map((opt) => {
|
||||
return {
|
||||
title: generateNameFromPermutation(opt),
|
||||
value: opt,
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="attributes" className="flex flex-col gap-y-8">
|
||||
<Heading level="h2">{t("products.attributes")}</Heading>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-8">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="origin_country"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.countryOrigin.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<CountrySelect {...field} />
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="material"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.material.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-8">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="width"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.width.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} type="number" min={0} />
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="length"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.length.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} type="number" min={0} />
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="height"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.height.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} type="number" min={0} />
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="weight"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.weight.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} type="number" min={0} />
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="mid_code"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.mid_code.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="hs_code"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.hs_code.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div id="media" className="flex flex-col gap-y-8">
|
||||
<Heading level="h2">{t("products.media.label")}</Heading>
|
||||
<div className="grid grid-cols-1 gap-x-4 gap-y-8">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="images"
|
||||
render={() => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<Form.Label optional>
|
||||
{t("products.media.label")}
|
||||
</Form.Label>
|
||||
<Form.Hint>
|
||||
{t("products.media.editHint")}
|
||||
</Form.Hint>
|
||||
</div>
|
||||
<Form.Control>
|
||||
<FileUpload
|
||||
label={t("products.media.uploadImagesLabel")}
|
||||
hint={t("products.media.uploadImagesHint")}
|
||||
hasError={!!form.formState.errors.images}
|
||||
formats={SUPPORTED_FORMATS}
|
||||
onUploaded={() => {
|
||||
form.clearErrors("images")
|
||||
// if (hasInvalidFiles(files)) {
|
||||
// return
|
||||
// }
|
||||
|
||||
// files.forEach((f) => append(f))
|
||||
}}
|
||||
/>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</div>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{open && (
|
||||
<Fragment>
|
||||
<PanelResizeHandle className="bg-ui-bg-subtle group flex items-center justify-center border-x px-[4.5px] outline-none">
|
||||
{/* Is this meant to be resizable? And if so we need some kind of focus state for the handle cc: @ludvig18 */}
|
||||
<div className="bg-ui-fg-disabled group-focus-visible:bg-ui-fg-muted transition-fg h-6 w-[3px] rounded-full" />
|
||||
</PanelResizeHandle>
|
||||
<Panel defaultSize={50} maxSize={50} minSize={40}>
|
||||
<AddSalesChannelsDrawer onCancel={() => onOpenChange(false)} />
|
||||
</Panel>
|
||||
</Fragment>
|
||||
)}
|
||||
</PanelGroup>
|
||||
)
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const AddSalesChannelsDrawer = ({ onCancel }: { onCancel: () => void }) => {
|
||||
const { t } = useTranslation()
|
||||
const [selection, setSelection] = useState<RowSelectionState>({})
|
||||
|
||||
const { searchParams, raw } = useSalesChannelTableQuery({
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
const { sales_channels, count, isLoading, isError, error } =
|
||||
useAdminSalesChannels({
|
||||
...searchParams,
|
||||
})
|
||||
|
||||
const filters = useSalesChannelTableFilters()
|
||||
const columns = useColumns()
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: sales_channels ?? [],
|
||||
columns,
|
||||
count: sales_channels?.length ?? 0,
|
||||
enablePagination: true,
|
||||
enableRowSelection: true,
|
||||
getRowId: (row) => row.id,
|
||||
pageSize: PAGE_SIZE,
|
||||
rowSelection: {
|
||||
state: selection,
|
||||
updater: setSelection,
|
||||
},
|
||||
})
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-1 flex-col overflow-hidden">
|
||||
<div className="flex-1">
|
||||
<DataTable
|
||||
table={table}
|
||||
columns={columns}
|
||||
pageSize={PAGE_SIZE}
|
||||
filters={filters}
|
||||
isLoading={isLoading}
|
||||
layout="fill"
|
||||
orderBy={["name", "created_at", "updated_at"]}
|
||||
queryObject={raw}
|
||||
search
|
||||
pagination
|
||||
count={count}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-x-2 border-t px-6 py-4">
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button size="small" onClick={() => {}} type="button">
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<SalesChannel>()
|
||||
|
||||
const useColumns = () => {
|
||||
const base = useSalesChannelTableColumns()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
columnHelper.display({
|
||||
id: "select",
|
||||
header: ({ table }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsSomePageRowsSelected()
|
||||
? "indeterminate"
|
||||
: table.getIsAllPageRowsSelected()
|
||||
}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
...base,
|
||||
],
|
||||
[base]
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./product-create-details-attribute-section"
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
import { Heading, Input } from "@medusajs/ui"
|
||||
import { UseFormReturn } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { Form } from "../../../../../../../components/common/form"
|
||||
import { CountrySelect } from "../../../../../../../components/inputs/country-select"
|
||||
import { ProductCreateSchemaType } from "../../../../types"
|
||||
|
||||
type ProductCreateAttributeSectionProps = {
|
||||
form: UseFormReturn<ProductCreateSchemaType>
|
||||
}
|
||||
|
||||
export const ProductCreateAttributeSection = ({
|
||||
form,
|
||||
}: ProductCreateAttributeSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div id="attributes" className="flex flex-col gap-y-8">
|
||||
<Heading level="h2">{t("products.attributes")}</Heading>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-8">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="origin_country"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.countryOrigin.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<CountrySelect {...field} />
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="material"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.material.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-8">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="width"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.width.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} type="number" min={0} />
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="length"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.length.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} type="number" min={0} />
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="height"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.height.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} type="number" min={0} />
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="weight"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.weight.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} type="number" min={0} />
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="mid_code"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.mid_code.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="hs_code"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.hs_code.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export * from "./product-create-details-context"
|
||||
export * from "./use-product-create-details-context"
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { createContext } from "react"
|
||||
|
||||
type ProductCreateDetailsContextValue = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export const ProductCreateDetailsContext =
|
||||
createContext<ProductCreateDetailsContextValue | null>(null)
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { useContext } from "react"
|
||||
import { ProductCreateDetailsContext } from "./product-create-details-context"
|
||||
|
||||
export const useProductCreateDetailsContext = () => {
|
||||
const context = useContext(ProductCreateDetailsContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useProductCreateDetailsContext must be used within a ProductCreateDetailsContextProvider"
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./product-create-general-section"
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import { Input, Textarea } from "@medusajs/ui"
|
||||
import { UseFormReturn } from "react-hook-form"
|
||||
import { Trans, useTranslation } from "react-i18next"
|
||||
|
||||
import { Form } from "../../../../../../../components/common/form"
|
||||
import { HandleInput } from "../../../../../../../components/inputs/handle-input"
|
||||
import { ProductCreateSchemaType } from "../../../../types"
|
||||
|
||||
type ProductCreateGeneralSectionProps = {
|
||||
form: UseFormReturn<ProductCreateSchemaType>
|
||||
}
|
||||
|
||||
export const ProductCreateGeneralSection = ({
|
||||
form,
|
||||
}: ProductCreateGeneralSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div id="general" className="flex flex-col gap-y-8">
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div className="grid grid-cols-2 gap-x-4">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("products.fields.title.label")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="subtitle"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.subtitle.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Form.Hint>
|
||||
<Trans
|
||||
i18nKey="products.fields.title.hint"
|
||||
t={t}
|
||||
components={[<br key="break" />]}
|
||||
/>
|
||||
</Form.Hint>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="handle"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label
|
||||
tooltip={t("products.fields.handle.tooltip")}
|
||||
optional
|
||||
>
|
||||
{t("fields.handle")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<HandleInput {...field} />
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.description.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Textarea {...field} />
|
||||
</Form.Control>
|
||||
<Form.Hint>
|
||||
<Trans
|
||||
i18nKey={"products.fields.description.hint"}
|
||||
components={[<br key="break" />]}
|
||||
/>
|
||||
</Form.Hint>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./product-create-details-media-section"
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { Heading } from "@medusajs/ui"
|
||||
import { UseFormReturn } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { FileUpload } from "../../../../../../../components/common/file-upload"
|
||||
import { Form } from "../../../../../../../components/common/form"
|
||||
import { ProductCreateSchemaType } from "../../../../types"
|
||||
|
||||
type ProductCreateMediaSectionProps = {
|
||||
form: UseFormReturn<ProductCreateSchemaType>
|
||||
}
|
||||
|
||||
const SUPPORTED_FORMATS = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/heic",
|
||||
"image/svg+xml",
|
||||
]
|
||||
|
||||
export const ProductCreateMediaSection = ({
|
||||
form,
|
||||
}: ProductCreateMediaSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div id="media" className="flex flex-col gap-y-8">
|
||||
<Heading level="h2">{t("products.media.label")}</Heading>
|
||||
<div className="grid grid-cols-1 gap-x-4 gap-y-8">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="images"
|
||||
render={() => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<Form.Label optional>
|
||||
{t("products.media.label")}
|
||||
</Form.Label>
|
||||
<Form.Hint>{t("products.media.editHint")}</Form.Hint>
|
||||
</div>
|
||||
<Form.Control>
|
||||
<FileUpload
|
||||
label={t("products.media.uploadImagesLabel")}
|
||||
hint={t("products.media.uploadImagesHint")}
|
||||
hasError={!!form.formState.errors.images}
|
||||
formats={SUPPORTED_FORMATS}
|
||||
onUploaded={(files) => {
|
||||
form.clearErrors("images")
|
||||
// if (hasInvalidFiles(files)) {
|
||||
// return
|
||||
// }
|
||||
|
||||
// files.forEach((f) => append(f))
|
||||
}}
|
||||
/>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</div>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./product-create-details-organize-section"
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
import { Button, Heading, Switch } from "@medusajs/ui"
|
||||
import { UseFormReturn, useFieldArray } from "react-hook-form"
|
||||
import { Trans, useTranslation } from "react-i18next"
|
||||
|
||||
import { ChipGroup } from "../../../../../../../components/common/chip-group"
|
||||
import { Form } from "../../../../../../../components/common/form"
|
||||
import { Combobox } from "../../../../../../../components/inputs/combobox"
|
||||
import { useComboboxData } from "../../../../../../../hooks/use-combobox-data"
|
||||
import { client } from "../../../../../../../lib/client"
|
||||
import { CategoryCombobox } from "../../../../../common/components/category-combobox"
|
||||
import { ProductCreateSchemaType } from "../../../../types"
|
||||
import { useProductCreateDetailsContext } from "../product-create-details-context"
|
||||
|
||||
type ProductCreateOrganizationSectionProps = {
|
||||
form: UseFormReturn<ProductCreateSchemaType>
|
||||
}
|
||||
|
||||
export const ProductCreateOrganizationSection = ({
|
||||
form,
|
||||
}: ProductCreateOrganizationSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { onOpenChange } = useProductCreateDetailsContext()
|
||||
|
||||
const collections = useComboboxData({
|
||||
queryKey: ["product_collections"],
|
||||
queryFn: client.collections.list,
|
||||
getOptions: (data) =>
|
||||
data.collections.map((collection) => ({
|
||||
label: collection.title,
|
||||
value: collection.id,
|
||||
})),
|
||||
})
|
||||
|
||||
const types = useComboboxData({
|
||||
queryKey: ["product_types"],
|
||||
queryFn: client.productTypes.list,
|
||||
getOptions: (data) =>
|
||||
data.product_types.map((type) => ({
|
||||
label: type.value,
|
||||
value: type.id,
|
||||
})),
|
||||
})
|
||||
|
||||
const { fields, remove, replace } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "sales_channels",
|
||||
keyName: "key",
|
||||
})
|
||||
|
||||
const handleClearAllSalesChannels = () => {
|
||||
replace([])
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="organize" className="flex flex-col gap-y-8">
|
||||
<Heading level="h2">{t("products.organization")}</Heading>
|
||||
<div className="grid grid-cols-1 gap-x-4">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="discountable"
|
||||
render={({ field: { value, onChange, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<div className="flex items-center justify-between">
|
||||
<Form.Label optional>
|
||||
{t("products.fields.discountable.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Switch
|
||||
{...field}
|
||||
checked={!!value}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
</Form.Control>
|
||||
</div>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Hint>
|
||||
<Trans i18nKey={"products.fields.discountable.hint"} />
|
||||
</Form.Hint>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="type_id"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.type.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Combobox
|
||||
{...field}
|
||||
options={types.options}
|
||||
searchValue={types.searchValue}
|
||||
onSearchValueChange={types.onSearchValueChange}
|
||||
/>
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="collection_id"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.collection.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Combobox
|
||||
{...field}
|
||||
options={collections.options}
|
||||
searchValue={collections.searchValue}
|
||||
onSearchValueChange={collections.onSearchValueChange}
|
||||
/>
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="categories"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.categories.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<CategoryCombobox {...field} />
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-y-4">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="sales_channels"
|
||||
render={() => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<div className="flex items-start justify-between gap-x-4">
|
||||
<div>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.sales_channels.label")}
|
||||
</Form.Label>
|
||||
<Form.Hint>
|
||||
<Trans i18nKey={"products.fields.sales_channels.hint"} />
|
||||
</Form.Hint>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => onOpenChange(true)}
|
||||
>
|
||||
{t("actions.add")}
|
||||
</Button>
|
||||
</div>
|
||||
<Form.Control className="mt-0">
|
||||
{fields.length > 0 && (
|
||||
<ChipGroup
|
||||
onClearAll={handleClearAllSalesChannels}
|
||||
onRemove={remove}
|
||||
className="py-4"
|
||||
>
|
||||
{fields.map((field, index) => (
|
||||
<ChipGroup.Chip key={field.key} index={index}>
|
||||
{field.name}
|
||||
</ChipGroup.Chip>
|
||||
))}
|
||||
</ChipGroup>
|
||||
)}
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./product-create-details-variant-section"
|
||||
+428
@@ -0,0 +1,428 @@
|
||||
import { XMarkMini } from "@medusajs/icons"
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Heading,
|
||||
Hint,
|
||||
IconButton,
|
||||
Input,
|
||||
Label,
|
||||
Text,
|
||||
clx,
|
||||
} from "@medusajs/ui"
|
||||
import {
|
||||
Controller,
|
||||
FieldArrayWithId,
|
||||
UseFormReturn,
|
||||
useFieldArray,
|
||||
useWatch,
|
||||
} from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { Form } from "../../../../../../../components/common/form"
|
||||
import { SortableList } from "../../../../../../../components/common/sortable-list"
|
||||
import { ChipInput } from "../../../../../../../components/inputs/chip-input"
|
||||
import { ProductCreateSchemaType } from "../../../../types"
|
||||
|
||||
type ProductCreateVariantsSectionProps = {
|
||||
form: UseFormReturn<ProductCreateSchemaType>
|
||||
}
|
||||
|
||||
const getPermutations = (
|
||||
data: { title: string; values: string[] }[]
|
||||
): { [key: string]: string }[] => {
|
||||
if (data.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (data.length === 1) {
|
||||
return data[0].values.map((value) => ({ [data[0].title]: value }))
|
||||
}
|
||||
|
||||
const toProcess = data[0]
|
||||
const rest = data.slice(1)
|
||||
|
||||
return toProcess.values.flatMap((value) => {
|
||||
return getPermutations(rest).map((permutation) => {
|
||||
return {
|
||||
[toProcess.title]: value,
|
||||
...permutation,
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const getVariantName = (options: Record<string, string>) => {
|
||||
return Object.values(options).join(" / ")
|
||||
}
|
||||
|
||||
export const ProductCreateVariantsSection = ({
|
||||
form,
|
||||
}: ProductCreateVariantsSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const options = useFieldArray({
|
||||
control: form.control,
|
||||
name: "options",
|
||||
})
|
||||
|
||||
const variants = useFieldArray({
|
||||
control: form.control,
|
||||
name: "variants",
|
||||
})
|
||||
|
||||
const watchedOptions = useWatch({
|
||||
control: form.control,
|
||||
name: "options",
|
||||
defaultValue: [],
|
||||
})
|
||||
|
||||
const watchedVariants = useWatch({
|
||||
control: form.control,
|
||||
name: "variants",
|
||||
defaultValue: [],
|
||||
})
|
||||
|
||||
const handleOptionValueUpdate = (index: number, value: string[]) => {
|
||||
const newOptions = [...watchedOptions]
|
||||
newOptions[index].values = value
|
||||
|
||||
const permutations = getPermutations(newOptions)
|
||||
const oldVariants = [...watchedVariants]
|
||||
|
||||
const findMatchingPermutation = (options: Record<string, string>) => {
|
||||
return permutations.find((permutation) =>
|
||||
Object.keys(options).every((key) => options[key] === permutation[key])
|
||||
)
|
||||
}
|
||||
|
||||
const newVariants = oldVariants.reduce((variants, variant) => {
|
||||
const match = findMatchingPermutation(variant.options)
|
||||
|
||||
if (match) {
|
||||
variants.push({
|
||||
...variant,
|
||||
title: getVariantName(match),
|
||||
options: match,
|
||||
})
|
||||
}
|
||||
|
||||
return variants
|
||||
}, [] as typeof oldVariants)
|
||||
|
||||
const usedPermutations = new Set(
|
||||
newVariants.map((variant) => variant.options)
|
||||
)
|
||||
const unusedPermutations = permutations.filter(
|
||||
(permutation) => !usedPermutations.has(permutation)
|
||||
)
|
||||
|
||||
unusedPermutations.forEach((permutation) => {
|
||||
newVariants.push({
|
||||
title: getVariantName(permutation),
|
||||
options: permutation,
|
||||
should_create: false,
|
||||
variant_rank: newVariants.length,
|
||||
})
|
||||
})
|
||||
|
||||
form.setValue("variants", newVariants)
|
||||
}
|
||||
|
||||
const handleRemoveOption = (index: number) => {
|
||||
options.remove(index)
|
||||
|
||||
const newOptions = [...watchedOptions]
|
||||
newOptions.splice(index, 1)
|
||||
|
||||
const permutations = getPermutations(newOptions)
|
||||
const oldVariants = [...watchedVariants]
|
||||
|
||||
const findMatchingPermutation = (options: Record<string, string>) => {
|
||||
return permutations.find((permutation) =>
|
||||
Object.keys(options).every((key) => options[key] === permutation[key])
|
||||
)
|
||||
}
|
||||
|
||||
const newVariants = oldVariants.reduce((variants, variant) => {
|
||||
const match = findMatchingPermutation(variant.options)
|
||||
|
||||
if (match) {
|
||||
variants.push({
|
||||
...variant,
|
||||
title: getVariantName(match),
|
||||
options: match,
|
||||
})
|
||||
}
|
||||
|
||||
return variants
|
||||
}, [] as typeof oldVariants)
|
||||
|
||||
const usedPermutations = new Set(
|
||||
newVariants.map((variant) => variant.options)
|
||||
)
|
||||
const unusedPermutations = permutations.filter(
|
||||
(permutation) => !usedPermutations.has(permutation)
|
||||
)
|
||||
|
||||
unusedPermutations.forEach((permutation) => {
|
||||
newVariants.push({
|
||||
title: getVariantName(permutation),
|
||||
options: permutation,
|
||||
should_create: false,
|
||||
variant_rank: newVariants.length,
|
||||
})
|
||||
})
|
||||
|
||||
form.setValue("variants", newVariants)
|
||||
}
|
||||
|
||||
const handleRankChange = (
|
||||
items: FieldArrayWithId<ProductCreateSchemaType, "variants">[]
|
||||
) => {
|
||||
// Items in the SortableList are momorized, so we need to find the current
|
||||
// value to preserve any changes that have been made to `should_create`.
|
||||
const update = items.map((item, index) => {
|
||||
const variant = watchedVariants.find((v) => v.title === item.title)
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
...(variant || item),
|
||||
variant_rank: index,
|
||||
}
|
||||
})
|
||||
|
||||
variants.replace(update)
|
||||
}
|
||||
|
||||
const getCheckboxState = (variants: ProductCreateSchemaType["variants"]) => {
|
||||
if (variants.every((variant) => variant.should_create)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (variants.some((variant) => variant.should_create)) {
|
||||
return "indeterminate"
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const onCheckboxChange = (value: boolean | "indeterminate") => {
|
||||
switch (value) {
|
||||
case true: {
|
||||
const update = watchedVariants.map((variant) => {
|
||||
return {
|
||||
...variant,
|
||||
should_create: true,
|
||||
}
|
||||
})
|
||||
|
||||
form.setValue("variants", update)
|
||||
break
|
||||
}
|
||||
case false: {
|
||||
const update = watchedVariants.map((variant) => {
|
||||
return {
|
||||
...variant,
|
||||
should_create: false,
|
||||
}
|
||||
})
|
||||
|
||||
form.setValue("variants", update)
|
||||
break
|
||||
}
|
||||
case "indeterminate":
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="variants" className="flex flex-col gap-y-8">
|
||||
<Heading level="h2">{t("products.create.variants.header")}</Heading>
|
||||
<div className="flex flex-col gap-y-6">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="options"
|
||||
render={() => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="flex items-start justify-between gap-x-4">
|
||||
<div className="flex flex-col">
|
||||
<Form.Label>
|
||||
{t("products.create.variants.productOptions.label")}
|
||||
</Form.Label>
|
||||
<Form.Hint>
|
||||
{t("products.create.variants.productOptions.hint")}
|
||||
</Form.Hint>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
options.append({
|
||||
title: "",
|
||||
values: [],
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t("actions.add")}
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-y-4">
|
||||
{options.fields.map((option, index) => {
|
||||
return (
|
||||
<li
|
||||
key={option.id}
|
||||
className="bg-ui-bg-component shadow-elevation-card-rest grid grid-cols-[1fr_28px] items-center gap-1.5 rounded-xl p-1.5"
|
||||
>
|
||||
<div className="grid grid-cols-[min-content,1fr] items-center gap-1.5">
|
||||
<div className="flex items-center px-2 py-1.5">
|
||||
<Label
|
||||
size="xsmall"
|
||||
weight="plus"
|
||||
className="text-ui-fg-subtle"
|
||||
htmlFor={`options.${index}.title`}
|
||||
>
|
||||
{t("fields.title")}
|
||||
</Label>
|
||||
</div>
|
||||
<Input
|
||||
className="bg-ui-bg-field-component hover:bg-ui-bg-field-component-hover"
|
||||
{...form.register(
|
||||
`options.${index}.title` as const
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center px-2 py-1.5">
|
||||
<Label
|
||||
size="xsmall"
|
||||
weight="plus"
|
||||
className="text-ui-fg-subtle"
|
||||
htmlFor={`options.${index}.values`}
|
||||
>
|
||||
{t("fields.values")}
|
||||
</Label>
|
||||
</div>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name={`options.${index}.values` as const}
|
||||
render={({ field: { onChange, ...field } }) => {
|
||||
const handleValueChange = (value: string[]) => {
|
||||
handleOptionValueUpdate(index, value)
|
||||
onChange(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChipInput
|
||||
{...field}
|
||||
variant="contrast"
|
||||
onChange={handleValueChange}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
type="button"
|
||||
size="small"
|
||||
variant="transparent"
|
||||
className="text-ui-fg-muted"
|
||||
onClick={() => handleRemoveOption(index)}
|
||||
>
|
||||
<XMarkMini />
|
||||
</IconButton>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-x-4 gap-y-4">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<Label weight="plus">
|
||||
{t("products.create.variants.productVariants.label")}
|
||||
</Label>
|
||||
<Hint>{t("products.create.variants.productVariants.hint")}</Hint>
|
||||
</div>
|
||||
{variants.fields.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div
|
||||
className="bg-ui-bg-component text-ui-fg-subtle grid items-center gap-3 border-b px-6 py-3.5"
|
||||
style={{
|
||||
gridTemplateColumns: `20px 28px repeat(${watchedOptions.length}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={getCheckboxState(watchedVariants)}
|
||||
onCheckedChange={onCheckboxChange}
|
||||
/>
|
||||
</div>
|
||||
<div />
|
||||
{watchedOptions.map((option, index) => (
|
||||
<div key={index}>
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{option.title}
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<SortableList
|
||||
items={variants.fields}
|
||||
onChange={handleRankChange}
|
||||
renderItem={(item, index) => {
|
||||
return (
|
||||
<SortableList.Item
|
||||
id={item.id}
|
||||
className={clx("bg-ui-bg-base border-b", {
|
||||
"border-b-0": index === variants.fields.length - 1,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className="text-ui-fg-subtle grid w-full items-center gap-3 px-6 py-3.5"
|
||||
style={{
|
||||
gridTemplateColumns: `20px 28px repeat(${watchedOptions.length}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name={`variants.${index}.should_create` as const}
|
||||
render={({ field: { value, onChange, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Control>
|
||||
<Checkbox
|
||||
{...field}
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<SortableList.DragHandle />
|
||||
{Object.values(item.options).map((value, index) => (
|
||||
<Text key={index} size="small" leading="compact">
|
||||
{value}
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
</SortableList.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Alert>{t("products.create.variants.productVariants.alert")}</Alert>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./product-create-sales-channel-drawer"
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
import { AdminSalesChannelResponse } from "@medusajs/types"
|
||||
import { Button, Checkbox } from "@medusajs/ui"
|
||||
import {
|
||||
OnChangeFn,
|
||||
RowSelectionState,
|
||||
createColumnHelper,
|
||||
} from "@tanstack/react-table"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { UseFormReturn } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { SplitView } from "../../../../../../../components/layout/split-view"
|
||||
import { DataTable } from "../../../../../../../components/table/data-table"
|
||||
import { useSalesChannels } from "../../../../../../../hooks/api/sales-channels"
|
||||
import { useSalesChannelTableColumns } from "../../../../../../../hooks/table/columns/use-sales-channel-table-columns"
|
||||
import { useSalesChannelTableFilters } from "../../../../../../../hooks/table/filters/use-sales-channel-table-filters"
|
||||
import { useSalesChannelTableQuery } from "../../../../../../../hooks/table/query/use-sales-channel-table-query"
|
||||
import { useDataTable } from "../../../../../../../hooks/use-data-table"
|
||||
import { ProductCreateSchemaType } from "../../../../types"
|
||||
import { useProductCreateDetailsContext } from "../product-create-details-context"
|
||||
|
||||
type ProductCreateSalesChannelDrawerProps = {
|
||||
form: UseFormReturn<ProductCreateSchemaType>
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
const PREFIX = "sc"
|
||||
|
||||
export const ProductCreateSalesChannelDrawer = ({
|
||||
form,
|
||||
}: ProductCreateSalesChannelDrawerProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { open, onOpenChange } = useProductCreateDetailsContext()
|
||||
const { getValues, setValue } = form
|
||||
|
||||
const [selection, setSelection] = useState<RowSelectionState>({})
|
||||
const [state, setState] = useState<{ id: string; name: string }[]>([])
|
||||
|
||||
const { searchParams, raw } = useSalesChannelTableQuery({
|
||||
pageSize: PAGE_SIZE,
|
||||
prefix: PREFIX,
|
||||
})
|
||||
const { sales_channels, count, isLoading, isError, error } = useSalesChannels(
|
||||
{
|
||||
...searchParams,
|
||||
}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
const salesChannels = getValues("sales_channels")
|
||||
|
||||
if (salesChannels) {
|
||||
setState(
|
||||
salesChannels.map((channel) => ({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
}))
|
||||
)
|
||||
|
||||
setSelection(
|
||||
salesChannels.reduce(
|
||||
(acc, channel) => ({
|
||||
...acc,
|
||||
[channel.id]: true,
|
||||
}),
|
||||
{}
|
||||
)
|
||||
)
|
||||
}
|
||||
}, [open, getValues])
|
||||
|
||||
const updater: OnChangeFn<RowSelectionState> = (fn) => {
|
||||
const value = typeof fn === "function" ? fn(selection) : fn
|
||||
const ids = Object.keys(value)
|
||||
|
||||
const addedIdsSet = new Set(ids.filter((id) => value[id] && !selection[id]))
|
||||
|
||||
let addedSalesChannels: { id: string; name: string }[] = []
|
||||
|
||||
if (addedIdsSet.size > 0) {
|
||||
addedSalesChannels =
|
||||
sales_channels?.filter((channel) => addedIdsSet.has(channel.id)) ?? []
|
||||
}
|
||||
|
||||
setState((prev) => {
|
||||
const filteredPrev = prev.filter((channel) => value[channel.id])
|
||||
return Array.from(new Set([...filteredPrev, ...addedSalesChannels]))
|
||||
})
|
||||
setSelection(value)
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
setValue("sales_channels", state, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
})
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const filters = useSalesChannelTableFilters()
|
||||
const columns = useColumns()
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: sales_channels ?? [],
|
||||
columns,
|
||||
count: sales_channels?.length ?? 0,
|
||||
enablePagination: true,
|
||||
enableRowSelection: true,
|
||||
getRowId: (row) => row.id,
|
||||
pageSize: PAGE_SIZE,
|
||||
rowSelection: {
|
||||
state: selection,
|
||||
updater,
|
||||
},
|
||||
prefix: PREFIX,
|
||||
})
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-1 flex-col overflow-hidden">
|
||||
<div className="flex-1">
|
||||
<DataTable
|
||||
table={table}
|
||||
columns={columns}
|
||||
pageSize={PAGE_SIZE}
|
||||
filters={filters}
|
||||
isLoading={isLoading}
|
||||
layout="fill"
|
||||
orderBy={["name", "created_at", "updated_at"]}
|
||||
queryObject={raw}
|
||||
search
|
||||
pagination
|
||||
count={count}
|
||||
prefix={PREFIX}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-x-2 border-t px-6 py-4">
|
||||
<SplitView.Close asChild>
|
||||
<Button size="small" variant="secondary" type="button">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</SplitView.Close>
|
||||
<Button size="small" onClick={handleAdd} type="button">
|
||||
{t("actions.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const columnHelper =
|
||||
createColumnHelper<AdminSalesChannelResponse["sales_channel"]>()
|
||||
|
||||
const useColumns = () => {
|
||||
const base = useSalesChannelTableColumns()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
columnHelper.display({
|
||||
id: "select",
|
||||
header: ({ table }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsSomePageRowsSelected()
|
||||
? "indeterminate"
|
||||
: table.getIsAllPageRowsSelected()
|
||||
}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
...base,
|
||||
],
|
||||
[base]
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./product-create-details-form"
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { Heading, Text } from "@medusajs/ui"
|
||||
import { useState } from "react"
|
||||
import { UseFormReturn } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { Divider } from "../../../../../components/common/divider"
|
||||
import { SplitView } from "../../../../../components/layout/split-view"
|
||||
import { ProductCreateSchemaType } from "../../types"
|
||||
import { ProductCreateAttributeSection } from "./components/product-create-details-attribute-section"
|
||||
import { ProductCreateDetailsContext } from "./components/product-create-details-context"
|
||||
import { ProductCreateGeneralSection } from "./components/product-create-details-general-section"
|
||||
import { ProductCreateOrganizationSection } from "./components/product-create-details-organize-section"
|
||||
import { ProductCreateVariantsSection } from "./components/product-create-details-variant-section"
|
||||
import { ProductCreateSalesChannelDrawer } from "./components/product-create-sales-channel-drawer"
|
||||
|
||||
type ProductAttributesProps = {
|
||||
form: UseFormReturn<ProductCreateSchemaType>
|
||||
}
|
||||
|
||||
export const ProductCreateDetailsForm = ({ form }: ProductAttributesProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<ProductCreateDetailsContext.Provider
|
||||
value={{ open, onOpenChange: setOpen }}
|
||||
>
|
||||
<SplitView open={open} onOpenChange={setOpen}>
|
||||
<SplitView.Content>
|
||||
<div className="flex flex-col items-center p-16">
|
||||
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
|
||||
<Header />
|
||||
<ProductCreateGeneralSection form={form} />
|
||||
<Divider />
|
||||
<ProductCreateVariantsSection form={form} />
|
||||
<Divider />
|
||||
<ProductCreateOrganizationSection form={form} />
|
||||
<Divider />
|
||||
<ProductCreateAttributeSection form={form} />
|
||||
</div>
|
||||
</div>
|
||||
</SplitView.Content>
|
||||
<SplitView.Drawer>
|
||||
<ProductCreateSalesChannelDrawer form={form} />
|
||||
</SplitView.Drawer>
|
||||
</SplitView>
|
||||
</ProductCreateDetailsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const Header = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Heading>{t("products.create.header")}</Heading>
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
{t("products.create.hint")}
|
||||
</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./product-create-form"
|
||||
+50
-36
@@ -1,53 +1,71 @@
|
||||
import { Button, ProgressStatus, ProgressTabs } from "@medusajs/ui"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Button, ProgressStatus, ProgressTabs, toast } from "@medusajs/ui"
|
||||
import { useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import {
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
} from "../../../../components/route-modal"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
} from "../../../../../components/route-modal"
|
||||
import { useCreateProduct } from "../../../../../hooks/api/products"
|
||||
import { VariantPricingForm } from "../../../common/variant-pricing-form"
|
||||
import {
|
||||
CreateProductSchema,
|
||||
CreateProductSchemaType,
|
||||
defaults,
|
||||
normalize,
|
||||
} from "../schema"
|
||||
import { useCreateProduct } from "../../../../hooks/api/products"
|
||||
import { ProductAttributesForm } from "./product-attributes-form"
|
||||
import { VariantPricingForm } from "../../common/variant-pricing-form"
|
||||
PRODUCT_CREATE_FORM_DEFAULTS,
|
||||
ProductCreateSchema,
|
||||
} from "../../constants"
|
||||
import { ProductCreateSchemaType } from "../../types"
|
||||
import { normalizeProductFormValues } from "../../utils"
|
||||
import { ProductCreateDetailsForm } from "../product-create-details-form"
|
||||
|
||||
enum Tab {
|
||||
PRODUCT = "product",
|
||||
PRICE = "price",
|
||||
}
|
||||
|
||||
type TabState = Record<Tab, ProgressStatus>
|
||||
|
||||
export const CreateProductPage = () => {
|
||||
const { t } = useTranslation()
|
||||
const SAVE_DRAFT_BUTTON = "save-draft-button"
|
||||
|
||||
export const ProductCreateForm = () => {
|
||||
const [tab, setTab] = useState<Tab>(Tab.PRODUCT)
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
const form = useForm<CreateProductSchemaType>({
|
||||
defaultValues: defaults,
|
||||
resolver: zodResolver(CreateProductSchema),
|
||||
|
||||
const form = useForm<ProductCreateSchemaType>({
|
||||
defaultValues: PRODUCT_CREATE_FORM_DEFAULTS,
|
||||
resolver: zodResolver(ProductCreateSchema),
|
||||
})
|
||||
|
||||
const { mutateAsync, isLoading } = useCreateProduct()
|
||||
const { mutateAsync, isPending } = useCreateProduct()
|
||||
|
||||
const handleSubmit = form.handleSubmit(
|
||||
async (values, e) => {
|
||||
if (!(e?.nativeEvent instanceof SubmitEvent)) return
|
||||
if (!(e?.nativeEvent instanceof SubmitEvent)) {
|
||||
return
|
||||
}
|
||||
const submitter = e?.nativeEvent?.submitter as HTMLButtonElement
|
||||
if (!(submitter instanceof HTMLButtonElement)) return
|
||||
const isDraftSubmission = submitter.dataset.name === "save-draft-button"
|
||||
|
||||
if (!(submitter instanceof HTMLButtonElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
const isDraftSubmission = submitter.dataset.name === SAVE_DRAFT_BUTTON
|
||||
|
||||
await mutateAsync(
|
||||
normalize({
|
||||
normalizeProductFormValues({
|
||||
...values,
|
||||
status: (isDraftSubmission ? "draft" : "published") as any,
|
||||
}),
|
||||
{
|
||||
onSuccess: ({ product }) => {
|
||||
toast.success(t("general.success"), {
|
||||
dismissLabel: t("actions.close"),
|
||||
description: t("products.create.successToast", {
|
||||
title: product.title,
|
||||
}),
|
||||
})
|
||||
|
||||
handleSuccess(`../${product.id}`)
|
||||
},
|
||||
}
|
||||
@@ -79,13 +97,13 @@ export const CreateProductPage = () => {
|
||||
status={tabState.product}
|
||||
value={Tab.PRODUCT}
|
||||
>
|
||||
Products
|
||||
{t("products.create.tabs.details")}
|
||||
</ProgressTabs.Trigger>
|
||||
<ProgressTabs.Trigger
|
||||
status={tabState.price}
|
||||
value={Tab.PRICE}
|
||||
>
|
||||
Prices
|
||||
{t("products.create.tabs.variants")}
|
||||
</ProgressTabs.Trigger>
|
||||
</ProgressTabs.List>
|
||||
</div>
|
||||
@@ -96,32 +114,28 @@ export const CreateProductPage = () => {
|
||||
</Button>
|
||||
</RouteFocusModal.Close>
|
||||
<Button
|
||||
className="whitespace-nowrap"
|
||||
data-name="save-draft-button"
|
||||
variant="primary"
|
||||
data-name={SAVE_DRAFT_BUTTON}
|
||||
size="small"
|
||||
key="submit-button"
|
||||
type="submit"
|
||||
isLoading={isLoading}
|
||||
isLoading={isPending}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("actions.saveAsDraft")}
|
||||
</Button>
|
||||
<PrimaryButton
|
||||
tab={tab}
|
||||
next={() => setTab(Tab.PRICE)}
|
||||
isLoading={isLoading}
|
||||
isLoading={isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</RouteFocusModal.Header>
|
||||
<RouteFocusModal.Body className="size-full overflow-hidden">
|
||||
<ProgressTabs.Content
|
||||
className="size-full overflow-y-auto"
|
||||
className="size-full overflow-hidden"
|
||||
value={Tab.PRODUCT}
|
||||
>
|
||||
<div className="flex h-full w-full">
|
||||
<ProductAttributesForm form={form} />
|
||||
</div>
|
||||
<ProductCreateDetailsForm form={form} />
|
||||
</ProgressTabs.Content>
|
||||
<ProgressTabs.Content
|
||||
className="size-full overflow-y-auto"
|
||||
@@ -0,0 +1,78 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const ProductCreateSchema = z.object({
|
||||
title: z.string(),
|
||||
subtitle: z.string().optional(),
|
||||
handle: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
discountable: z.boolean(),
|
||||
type_id: z.string().optional(),
|
||||
collection_id: z.string().optional(),
|
||||
categories: z.array(z.string()),
|
||||
tags: z.array(z.string()).optional(),
|
||||
sales_channels: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
origin_country: z.string().optional(),
|
||||
material: z.string().optional(),
|
||||
width: z.string().optional(),
|
||||
length: z.string().optional(),
|
||||
height: z.string().optional(),
|
||||
weight: z.string().optional(),
|
||||
mid_code: z.string().optional(),
|
||||
hs_code: z.string().optional(),
|
||||
options: z.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
values: z.array(z.string()),
|
||||
})
|
||||
),
|
||||
variants: z.array(
|
||||
z.object({
|
||||
should_create: z.boolean(),
|
||||
title: z.string(),
|
||||
options: z.record(z.string(), z.string()),
|
||||
variant_rank: z.number(),
|
||||
prices: z.record(z.string(), z.string()).optional(),
|
||||
})
|
||||
),
|
||||
images: z.array(z.string()).optional(),
|
||||
thumbnail: z.string().optional(),
|
||||
})
|
||||
|
||||
export const PRODUCT_CREATE_FORM_DEFAULTS: Partial<
|
||||
z.infer<typeof ProductCreateSchema>
|
||||
> = {
|
||||
discountable: true,
|
||||
tags: [],
|
||||
sales_channels: [],
|
||||
options: [
|
||||
{
|
||||
title: "",
|
||||
values: [],
|
||||
},
|
||||
],
|
||||
variants: [],
|
||||
images: [],
|
||||
thumbnail: "",
|
||||
categories: [],
|
||||
collection_id: "",
|
||||
description: "",
|
||||
handle: "",
|
||||
height: "",
|
||||
hs_code: "",
|
||||
length: "",
|
||||
material: "",
|
||||
mid_code: "",
|
||||
origin_country: "",
|
||||
subtitle: "",
|
||||
title: "",
|
||||
type_id: "",
|
||||
weight: "",
|
||||
width: "",
|
||||
}
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
import { RouteFocusModal } from "../../../components/route-modal"
|
||||
import { CreateProductPage } from "./components/create-product"
|
||||
import { ProductCreateForm } from "./components/product-create-form/product-create-form"
|
||||
|
||||
export const ProductCreate = () => {
|
||||
return (
|
||||
<RouteFocusModal>
|
||||
<CreateProductPage />
|
||||
<ProductCreateForm />
|
||||
</RouteFocusModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { CreateProductDTO, CreateProductVariantDTO } from "@medusajs/types"
|
||||
import * as zod from "zod"
|
||||
|
||||
export const CreateProductSchema = zod.object({
|
||||
title: zod.string(),
|
||||
subtitle: zod.string().optional(),
|
||||
handle: zod.string().optional(),
|
||||
description: zod.string().optional(),
|
||||
discountable: zod.boolean(),
|
||||
type_id: zod.string().optional(),
|
||||
collection_id: zod.string().optional(),
|
||||
category_ids: zod.array(zod.string()).optional(),
|
||||
tags: zod.array(zod.string()).optional(),
|
||||
sales_channels: zod.array(zod.string()).optional(),
|
||||
origin_country: zod.string().optional(),
|
||||
material: zod.string().optional(),
|
||||
width: zod.string().optional(),
|
||||
length: zod.string().optional(),
|
||||
height: zod.string().optional(),
|
||||
weight: zod.string().optional(),
|
||||
mid_code: zod.string().optional(),
|
||||
hs_code: zod.string().optional(),
|
||||
options: zod.array(
|
||||
zod.object({
|
||||
title: zod.string(),
|
||||
values: zod.array(zod.string()),
|
||||
})
|
||||
),
|
||||
variants: zod.array(
|
||||
zod.object({
|
||||
title: zod.string(),
|
||||
options: zod.record(zod.string(), zod.string()),
|
||||
variant_rank: zod.number(),
|
||||
prices: zod.record(zod.string(), zod.string()).optional(),
|
||||
})
|
||||
),
|
||||
images: zod.array(zod.string()).optional(),
|
||||
thumbnail: zod.string().optional(),
|
||||
})
|
||||
|
||||
export const defaults = {
|
||||
discountable: true,
|
||||
tags: [],
|
||||
sales_channels: [],
|
||||
options: [],
|
||||
variants: [],
|
||||
images: [],
|
||||
}
|
||||
|
||||
export const normalize = (
|
||||
values: CreateProductSchemaType & { status: CreateProductDTO["status"] }
|
||||
) => {
|
||||
const reqData = {
|
||||
...values,
|
||||
is_giftcard: false,
|
||||
tags: values.tags?.map((tag) => ({ value: tag })),
|
||||
sales_channels: values.sales_channels?.map((sc) => ({ id: sc })),
|
||||
width: values.width ? parseFloat(values.width) : undefined,
|
||||
length: values.length ? parseFloat(values.length) : undefined,
|
||||
height: values.height ? parseFloat(values.height) : undefined,
|
||||
weight: values.weight ? parseFloat(values.weight) : undefined,
|
||||
variants: normalizeVariants(values.variants as any),
|
||||
} as any
|
||||
|
||||
return reqData
|
||||
}
|
||||
|
||||
export const normalizeVariants = (
|
||||
variants: (Partial<CreateProductVariantDTO> & {
|
||||
prices?: Record<string, string>
|
||||
})[]
|
||||
) => {
|
||||
return variants.map((variant) => ({
|
||||
...variant,
|
||||
prices: Object.entries(variant.prices || {}).map(([key, value]: any) => ({
|
||||
currency_code: key,
|
||||
amount: value ? parseFloat(value) : 0,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
export type CreateProductSchemaType = zod.infer<typeof CreateProductSchema>
|
||||
@@ -0,0 +1,4 @@
|
||||
import { z } from "zod"
|
||||
import { ProductCreateSchema } from "./constants"
|
||||
|
||||
export type ProductCreateSchemaType = z.infer<typeof ProductCreateSchema>
|
||||
@@ -0,0 +1,51 @@
|
||||
import { CreateProductDTO } from "@medusajs/types"
|
||||
import { ProductCreateSchemaType } from "./types"
|
||||
|
||||
export const normalizeProductFormValues = (
|
||||
values: ProductCreateSchemaType & { status: CreateProductDTO["status"] }
|
||||
) => {
|
||||
const reqData = {
|
||||
...values,
|
||||
is_giftcard: false,
|
||||
tags: values?.tags?.length
|
||||
? values.tags?.map((tag) => ({ value: tag }))
|
||||
: undefined,
|
||||
sales_channels: values?.sales_channels?.length
|
||||
? values.sales_channels?.map((sc) => ({ id: sc.id }))
|
||||
: undefined,
|
||||
images: values.images?.length ? values.images : undefined,
|
||||
collection_id: values.collection_id || undefined,
|
||||
categories: values.categories.map((id) => ({ id })),
|
||||
type_id: values.type_id || undefined,
|
||||
handle: values.handle || undefined,
|
||||
origin_country: values.origin_country || undefined,
|
||||
material: values.material || undefined,
|
||||
mid_code: values.mid_code || undefined,
|
||||
hs_code: values.hs_code || undefined,
|
||||
thumbnail: values.thumbnail || undefined,
|
||||
subtitle: values.subtitle || undefined,
|
||||
description: values.description || undefined,
|
||||
width: values.width ? parseFloat(values.width) : undefined,
|
||||
length: values.length ? parseFloat(values.length) : undefined,
|
||||
height: values.height ? parseFloat(values.height) : undefined,
|
||||
weight: values.weight ? parseFloat(values.weight) : undefined,
|
||||
variants: normalizeVariants(values.variants),
|
||||
}
|
||||
|
||||
return reqData
|
||||
}
|
||||
|
||||
export const normalizeVariants = (
|
||||
variants: ProductCreateSchemaType["variants"]
|
||||
) => {
|
||||
return variants
|
||||
.filter((variant) => variant.should_create)
|
||||
.map((variant) => ({
|
||||
title: Object.values(variant.options || {}).join(" / "),
|
||||
options: variant.options,
|
||||
prices: Object.entries(variant.prices || {}).map(([key, value]: any) => ({
|
||||
currency_code: key,
|
||||
amount: value ? parseFloat(value) : 0,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
+3
-3
@@ -6,17 +6,17 @@ import { useTranslation } from "react-i18next"
|
||||
import { z } from "zod"
|
||||
|
||||
import { Fragment } from "react"
|
||||
import { Combobox } from "../../../../../components/common/combobox"
|
||||
import { CountrySelect } from "../../../../../components/common/country-select"
|
||||
import { Divider } from "../../../../../components/common/divider"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import { Combobox } from "../../../../../components/inputs/combobox"
|
||||
import { CountrySelect } from "../../../../../components/inputs/country-select"
|
||||
import {
|
||||
RouteDrawer,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import { useUpdateProductVariant } from "../../../../../hooks/api/products"
|
||||
import { castNumber } from "../../../../../lib/cast-number"
|
||||
import { optionalInt } from "../../../../../lib/validation"
|
||||
import { useUpdateProductVariant } from "../../../../../hooks/api/products"
|
||||
|
||||
type ProductEditVariantFormProps = {
|
||||
product: Product
|
||||
|
||||
+7
-8
@@ -1,21 +1,20 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Product } from "@medusajs/medusa"
|
||||
import { Button, Input, Select } from "@medusajs/ui"
|
||||
import { Button, Select } from "@medusajs/ui"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
import { CountrySelect } from "../../../../../components/common/country-select"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import { Combobox } from "../../../../../components/inputs/combobox"
|
||||
import {
|
||||
RouteDrawer,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import { useUpdateProduct } from "../../../../../hooks/api/products"
|
||||
import { Combobox } from "../../../../../components/common/combobox"
|
||||
import { useProductTypes } from "../../../../../hooks/api/product-types"
|
||||
import { useTags } from "../../../../../hooks/api/tags"
|
||||
import { useCollections } from "../../../../../hooks/api/collections"
|
||||
import { useCategories } from "../../../../../hooks/api/categories"
|
||||
import { useCollections } from "../../../../../hooks/api/collections"
|
||||
import { useProductTypes } from "../../../../../hooks/api/product-types"
|
||||
import { useUpdateProduct } from "../../../../../hooks/api/products"
|
||||
import { useTags } from "../../../../../hooks/api/tags"
|
||||
|
||||
type ProductOrganizationFormProps = {
|
||||
product: Product
|
||||
@@ -58,7 +57,7 @@ export const ProductOrganizationForm = ({
|
||||
category_ids: data.category_ids || undefined,
|
||||
tags:
|
||||
data.tags?.map((t) => {
|
||||
id: t
|
||||
t
|
||||
}) || undefined,
|
||||
},
|
||||
{
|
||||
|
||||
+8
-8
@@ -1,13 +1,13 @@
|
||||
import { useUpdateProductVariant } from "../../../hooks/api/products"
|
||||
import { RouteFocusModal, useRouteModal } from "../../../components/route-modal"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { ExtendedProductDTO } from "../../../types/api-responses"
|
||||
import { VariantPricingForm } from "../common/variant-pricing-form"
|
||||
import { normalizeVariants } from "../product-create/schema"
|
||||
import { Button } from "@medusajs/ui"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
import { RouteFocusModal, useRouteModal } from "../../../components/route-modal"
|
||||
import { useUpdateProductVariant } from "../../../hooks/api/products"
|
||||
import { ExtendedProductDTO } from "../../../types/api-responses"
|
||||
import { VariantPricingForm } from "../common/variant-pricing-form"
|
||||
import { normalizeVariants } from "../product-create/utils"
|
||||
|
||||
export const UpdateVariantPricesSchema = zod.object({
|
||||
variants: zod.array(
|
||||
@@ -38,7 +38,7 @@ export const PricingEdit = ({ product }: { product: ExtendedProductDTO }) => {
|
||||
})
|
||||
|
||||
// TODO: Add batch update method here
|
||||
const { mutateAsync, isLoading } = useUpdateProductVariant(product.id, "")
|
||||
const { mutateAsync, isPending } = useUpdateProductVariant(product.id, "")
|
||||
|
||||
const handleSubmit = form.handleSubmit(
|
||||
async (values) => {
|
||||
@@ -69,7 +69,7 @@ export const PricingEdit = ({ product }: { product: ExtendedProductDTO }) => {
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="small"
|
||||
isLoading={isLoading}
|
||||
isLoading={isPending}
|
||||
>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
|
||||
+4
-4
@@ -7,8 +7,8 @@ import { Fragment, useState } from "react"
|
||||
import { useFieldArray, useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
import { Combobox } from "../../../../../../components/common/combobox"
|
||||
import { Form } from "../../../../../../components/common/form"
|
||||
import { Combobox } from "../../../../../../components/inputs/combobox"
|
||||
import { RouteDrawer } from "../../../../../../components/route-modal"
|
||||
import { usePromotionRuleValues } from "../../../../../../hooks/api/promotions"
|
||||
import { RuleTypeValues } from "../../edit-rules"
|
||||
@@ -162,7 +162,7 @@ export const RulesFormField = ({
|
||||
|
||||
return (
|
||||
<Fragment key={`${fieldRule.id}.${index}`}>
|
||||
<div className="flex flex-row gap-2 bg-ui-bg-subtle py-2 px-2 rounded-xl border border-ui-border-base">
|
||||
<div className="bg-ui-bg-subtle border-ui-border-base flex flex-row gap-2 rounded-xl border px-2 py-2">
|
||||
<div className="grow">
|
||||
<Form.Field
|
||||
key={`${identifier}.${scope}.${attributeFields.name}`}
|
||||
@@ -299,7 +299,7 @@ export const RulesFormField = ({
|
||||
|
||||
{index < fields.length - 1 && (
|
||||
<div className="relative px-6 py-3">
|
||||
<div className="absolute top-0 bottom-0 left-[40px] z-[-1] border-ui-border-strong w-px bg-[linear-gradient(var(--border-strong)_33%,rgba(255,255,255,0)_0%)] bg-[length:1px_3px] bg-repeat-y"></div>
|
||||
<div className="border-ui-border-strong absolute bottom-0 left-[40px] top-0 z-[-1] w-px bg-[linear-gradient(var(--border-strong)_33%,rgba(255,255,255,0)_0%)] bg-[length:1px_3px] bg-repeat-y"></div>
|
||||
|
||||
<Badge size="2xsmall" className=" text-xs">
|
||||
{t("promotions.form.and")}
|
||||
@@ -330,7 +330,7 @@ export const RulesFormField = ({
|
||||
<Button
|
||||
type="button"
|
||||
variant="transparent"
|
||||
className="inline-block text-ui-fg-muted hover:text-ui-fg-subtle ml-2"
|
||||
className="text-ui-fg-muted hover:text-ui-fg-subtle ml-2 inline-block"
|
||||
onClick={() => {
|
||||
const indicesToRemove = fields
|
||||
.map((field, index) => (field.required ? null : index))
|
||||
|
||||
+12
-12
@@ -21,7 +21,7 @@ import {
|
||||
RuleOperatorOptionsResponse,
|
||||
} from "@medusajs/types"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import { PercentageInput } from "../../../../../components/common/percentage-input"
|
||||
import { PercentageInput } from "../../../../../components/inputs/percentage-input"
|
||||
import {
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
@@ -358,7 +358,7 @@ export const CreatePromotionForm = ({
|
||||
</div>
|
||||
</RouteFocusModal.Header>
|
||||
|
||||
<RouteFocusModal.Body className="w-[800px] mx-auto my-20">
|
||||
<RouteFocusModal.Body className="mx-auto my-20 w-[800px]">
|
||||
<ProgressTabs.Content value={Tab.TYPE}>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
@@ -382,7 +382,7 @@ export const CreatePromotionForm = ({
|
||||
label={template.title}
|
||||
description={template.description}
|
||||
className={clx("", {
|
||||
"border-2 border-ui-border-interactive":
|
||||
"border-ui-border-interactive border-2":
|
||||
template.id === field.value,
|
||||
})}
|
||||
/>
|
||||
@@ -399,7 +399,7 @@ export const CreatePromotionForm = ({
|
||||
|
||||
<ProgressTabs.Content
|
||||
value={Tab.PROMOTION}
|
||||
className="flex flex-col gap-10 flex-1"
|
||||
className="flex flex-1 flex-col gap-10"
|
||||
>
|
||||
{form.formState.errors.root && (
|
||||
<Alert
|
||||
@@ -432,7 +432,7 @@ export const CreatePromotionForm = ({
|
||||
"promotions.form.method.code.description"
|
||||
)}
|
||||
className={clx("basis-1/2", {
|
||||
"border-2 border-ui-border-interactive":
|
||||
"border-ui-border-interactive border-2":
|
||||
"false" === field.value,
|
||||
})}
|
||||
/>
|
||||
@@ -443,7 +443,7 @@ export const CreatePromotionForm = ({
|
||||
"promotions.form.method.automatic.description"
|
||||
)}
|
||||
className={clx("basis-1/2", {
|
||||
"border-2 border-ui-border-interactive":
|
||||
"border-ui-border-interactive border-2":
|
||||
"true" === field.value,
|
||||
})}
|
||||
/>
|
||||
@@ -509,7 +509,7 @@ export const CreatePromotionForm = ({
|
||||
"promotions.form.value_type.fixed.description"
|
||||
)}
|
||||
className={clx("basis-1/2", {
|
||||
"border-2 border-ui-border-interactive":
|
||||
"border-ui-border-interactive border-2":
|
||||
"fixed" === field.value,
|
||||
})}
|
||||
/>
|
||||
@@ -523,7 +523,7 @@ export const CreatePromotionForm = ({
|
||||
"promotions.form.value_type.percentage.description"
|
||||
)}
|
||||
className={clx("basis-1/2", {
|
||||
"border-2 border-ui-border-interactive":
|
||||
"border-ui-border-interactive border-2":
|
||||
"percentage" === field.value,
|
||||
})}
|
||||
/>
|
||||
@@ -604,7 +604,7 @@ export const CreatePromotionForm = ({
|
||||
"promotions.form.type.standard.description"
|
||||
)}
|
||||
className={clx("basis-1/2", {
|
||||
"border-2 border-ui-border-interactive":
|
||||
"border-ui-border-interactive border-2":
|
||||
"standard" === field.value,
|
||||
})}
|
||||
/>
|
||||
@@ -616,7 +616,7 @@ export const CreatePromotionForm = ({
|
||||
"promotions.form.type.buyget.description"
|
||||
)}
|
||||
className={clx("basis-1/2", {
|
||||
"border-2 border-ui-border-interactive":
|
||||
"border-ui-border-interactive border-2":
|
||||
"buyget" === field.value,
|
||||
})}
|
||||
/>
|
||||
@@ -652,7 +652,7 @@ export const CreatePromotionForm = ({
|
||||
"promotions.form.allocation.each.description"
|
||||
)}
|
||||
className={clx("basis-1/2", {
|
||||
"border-2 border-ui-border-interactive":
|
||||
"border-ui-border-interactive border-2":
|
||||
"each" === field.value,
|
||||
})}
|
||||
/>
|
||||
@@ -666,7 +666,7 @@ export const CreatePromotionForm = ({
|
||||
"promotions.form.allocation.across.description"
|
||||
)}
|
||||
className={clx("basis-1/2", {
|
||||
"border-2 border-ui-border-interactive":
|
||||
"border-ui-border-interactive border-2":
|
||||
"across" === field.value,
|
||||
})}
|
||||
/>
|
||||
|
||||
+3
-3
@@ -13,10 +13,10 @@ import { Trans, useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import { PercentageInput } from "../../../../../components/common/percentage-input"
|
||||
import { PercentageInput } from "../../../../../components/inputs/percentage-input"
|
||||
import {
|
||||
RouteDrawer,
|
||||
useRouteModal,
|
||||
RouteDrawer,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import { useUpdatePromotion } from "../../../../../hooks/api/promotions"
|
||||
import { getCurrencySymbol } from "../../../../../lib/currencies"
|
||||
|
||||
+1
-1
@@ -19,8 +19,8 @@ import * as zod from "zod"
|
||||
|
||||
import { PaymentProviderDTO, RegionCountryDTO } from "@medusajs/types"
|
||||
|
||||
import { Combobox } from "../../../../../components/common/combobox"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import { Combobox } from "../../../../../components/inputs/combobox"
|
||||
import { SplitView } from "../../../../../components/layout/split-view"
|
||||
import {
|
||||
RouteFocusModal,
|
||||
|
||||
+4
-4
@@ -1,18 +1,18 @@
|
||||
import { PaymentProviderDTO, RegionDTO } from "@medusajs/types"
|
||||
import { Button, Input, Select, Text, toast } from "@medusajs/ui"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
import { PaymentProviderDTO, RegionDTO } from "@medusajs/types"
|
||||
|
||||
import { Combobox } from "../../../../../components/common/combobox"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import { Combobox } from "../../../../../components/inputs/combobox/index.ts"
|
||||
import {
|
||||
RouteDrawer,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import { formatProvider } from "../../../../../lib/format-provider"
|
||||
import { CurrencyInfo } from "../../../../../lib/currencies"
|
||||
import { useUpdateRegion } from "../../../../../hooks/api/regions.tsx"
|
||||
import { CurrencyInfo } from "../../../../../lib/currencies"
|
||||
import { formatProvider } from "../../../../../lib/format-provider"
|
||||
|
||||
type EditRegionFormProps = {
|
||||
region: RegionDTO
|
||||
|
||||
+7
-7
@@ -6,17 +6,17 @@ import {
|
||||
useRouteModal,
|
||||
} from "../../../../../../components/route-modal"
|
||||
|
||||
import { Combobox } from "../../../../../../components/common/combobox"
|
||||
import { Form } from "../../../../../../components/common/form"
|
||||
import { InventoryItemRes } from "../../../../../../types/api-responses"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { InventoryNext } from "@medusajs/types"
|
||||
import React from "react"
|
||||
import { useCreateReservationItem } from "../../../../../../hooks/api/reservations"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useInventoryItems } from "../../../../../../hooks/api/inventory"
|
||||
import { useStockLocations } from "../../../../../../hooks/api/stock-locations"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Form } from "../../../../../../components/common/form"
|
||||
import { Combobox } from "../../../../../../components/inputs/combobox"
|
||||
import { useInventoryItems } from "../../../../../../hooks/api/inventory"
|
||||
import { useCreateReservationItem } from "../../../../../../hooks/api/reservations"
|
||||
import { useStockLocations } from "../../../../../../hooks/api/stock-locations"
|
||||
import { InventoryItemRes } from "../../../../../../types/api-responses"
|
||||
|
||||
export const CreateReservationSchema = zod.object({
|
||||
inventory_item_id: zod.string().min(1),
|
||||
|
||||
+1
-1
@@ -3,8 +3,8 @@ import { Button, Heading, Input, Text, toast } from "@medusajs/ui"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
import { CountrySelect } from "../../../../../components/common/country-select"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import { CountrySelect } from "../../../../../components/inputs/country-select"
|
||||
import {
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
|
||||
+1
-1
@@ -4,8 +4,8 @@ import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
|
||||
import { CountrySelect } from "../../../../../components/common/country-select"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import { CountrySelect } from "../../../../../components/inputs/country-select"
|
||||
import {
|
||||
RouteDrawer,
|
||||
useRouteModal,
|
||||
|
||||
+3
-3
@@ -6,10 +6,10 @@ import * as zod from "zod"
|
||||
|
||||
import { TaxRegionResponse } from "@medusajs/types"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import { PercentageInput } from "../../../../../components/common/percentage-input"
|
||||
import { PercentageInput } from "../../../../../components/inputs/percentage-input"
|
||||
import {
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import { useCreateTaxRegion } from "../../../../../hooks/api/tax-regions"
|
||||
import { countries } from "../../../../../lib/countries"
|
||||
|
||||
+13
-13
@@ -1,13 +1,13 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import {
|
||||
Button,
|
||||
clx,
|
||||
DropdownMenu,
|
||||
Heading,
|
||||
Input,
|
||||
Select,
|
||||
Switch,
|
||||
Text,
|
||||
Button,
|
||||
clx,
|
||||
DropdownMenu,
|
||||
Heading,
|
||||
Input,
|
||||
Select,
|
||||
Switch,
|
||||
Text,
|
||||
} from "@medusajs/ui"
|
||||
import { useForm, useWatch } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
@@ -17,19 +17,19 @@ import { TaxRegionResponse } from "@medusajs/types"
|
||||
import { useState } from "react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import { PercentageInput } from "../../../../../components/common/percentage-input"
|
||||
import { PercentageInput } from "../../../../../components/inputs/percentage-input"
|
||||
import { SplitView } from "../../../../../components/layout/split-view"
|
||||
import {
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import { useCreateTaxRate } from "../../../../../hooks/api/tax-rates"
|
||||
import { useTaxRegions } from "../../../../../hooks/api/tax-regions"
|
||||
import { ConditionsDrawer } from "../../../common/components/conditions-drawer"
|
||||
import { ConditionEntities } from "../../../common/constants"
|
||||
import {
|
||||
ConditionEntitiesValues,
|
||||
ConditionsOption,
|
||||
ConditionEntitiesValues,
|
||||
ConditionsOption,
|
||||
} from "../../../common/types"
|
||||
import { Condition } from "../condition"
|
||||
|
||||
|
||||
+10
-10
@@ -1,12 +1,12 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import {
|
||||
Button,
|
||||
clx,
|
||||
DropdownMenu,
|
||||
Heading,
|
||||
Input,
|
||||
Switch,
|
||||
Text,
|
||||
Button,
|
||||
clx,
|
||||
DropdownMenu,
|
||||
Heading,
|
||||
Input,
|
||||
Switch,
|
||||
Text,
|
||||
} from "@medusajs/ui"
|
||||
import { useForm, useWatch } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
@@ -16,11 +16,11 @@ import { TaxRateResponse, TaxRegionResponse } from "@medusajs/types"
|
||||
import { useState } from "react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import { PercentageInput } from "../../../../../components/common/percentage-input"
|
||||
import { PercentageInput } from "../../../../../components/inputs/percentage-input"
|
||||
import { SplitView } from "../../../../../components/layout/split-view"
|
||||
import {
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import { useUpdateTaxRate } from "../../../../../hooks/api/tax-rates"
|
||||
import { ConditionsDrawer } from "../../../common/components/conditions-drawer"
|
||||
|
||||
@@ -4340,6 +4340,55 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@dnd-kit/accessibility@npm:^3.1.0":
|
||||
version: 3.1.0
|
||||
resolution: "@dnd-kit/accessibility@npm:3.1.0"
|
||||
dependencies:
|
||||
tslib: ^2.0.0
|
||||
peerDependencies:
|
||||
react: ">=16.8.0"
|
||||
checksum: 4f9d24e801d66d4fbb551ec389ed90424dd4c5bbdf527000a618e9abb9833cbd84d9a79e362f470ccbccfbd6d00217a9212c92f3cef66e01c951c7f79625b9d7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@dnd-kit/core@npm:^6.1.0":
|
||||
version: 6.1.0
|
||||
resolution: "@dnd-kit/core@npm:6.1.0"
|
||||
dependencies:
|
||||
"@dnd-kit/accessibility": ^3.1.0
|
||||
"@dnd-kit/utilities": ^3.2.2
|
||||
tslib: ^2.0.0
|
||||
peerDependencies:
|
||||
react: ">=16.8.0"
|
||||
react-dom: ">=16.8.0"
|
||||
checksum: c793eb97cb59285ca8937ebcdfcd27cff09d750ae06722e36ca5ed07925e41abc36a38cff98f9f6056f7a07810878d76909826142a2968330e7e22060e6be584
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@dnd-kit/sortable@npm:^8.0.0":
|
||||
version: 8.0.0
|
||||
resolution: "@dnd-kit/sortable@npm:8.0.0"
|
||||
dependencies:
|
||||
"@dnd-kit/utilities": ^3.2.2
|
||||
tslib: ^2.0.0
|
||||
peerDependencies:
|
||||
"@dnd-kit/core": ^6.1.0
|
||||
react: ">=16.8.0"
|
||||
checksum: a6066c652b892c6a11320c7d8f5c18fdf723e721e8eea37f4ab657dee1ac5e7ca710ac32ce0712a57fe968bc07c13bcea5d5599d90dfdd95619e162befd4d2fb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@dnd-kit/utilities@npm:^3.2.2":
|
||||
version: 3.2.2
|
||||
resolution: "@dnd-kit/utilities@npm:3.2.2"
|
||||
dependencies:
|
||||
tslib: ^2.0.0
|
||||
peerDependencies:
|
||||
react: ">=16.8.0"
|
||||
checksum: 9aa90526f3e3fd567b5acc1b625a63177b9e8d00e7e50b2bd0e08fa2bf4dba7e19529777e001fdb8f89a7ce69f30b190c8364d390212634e0afdfa8c395e85a0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@emotion/is-prop-valid@npm:1.2.1":
|
||||
version: 1.2.1
|
||||
resolution: "@emotion/is-prop-valid@npm:1.2.1"
|
||||
@@ -6828,6 +6877,8 @@ __metadata:
|
||||
resolution: "@medusajs/dashboard@workspace:packages/admin-next/dashboard"
|
||||
dependencies:
|
||||
"@ariakit/react": ^0.4.1
|
||||
"@dnd-kit/core": ^6.1.0
|
||||
"@dnd-kit/sortable": ^8.0.0
|
||||
"@hookform/resolvers": 3.3.2
|
||||
"@medusajs/icons": "workspace:^"
|
||||
"@medusajs/medusa": "workspace:^"
|
||||
@@ -6841,8 +6892,8 @@ __metadata:
|
||||
"@tanstack/react-table": 8.10.7
|
||||
"@tanstack/react-virtual": ^3.0.4
|
||||
"@types/node": ^20.11.15
|
||||
"@types/react": 18.2.43
|
||||
"@types/react-dom": 18.2.17
|
||||
"@types/react": ^18.2.79
|
||||
"@types/react-dom": ^18.2.25
|
||||
"@uiw/react-json-view": ^2.0.0-alpha.17
|
||||
"@vitejs/plugin-react": 4.2.1
|
||||
autoprefixer: ^10.4.17
|
||||
@@ -6857,10 +6908,10 @@ __metadata:
|
||||
postcss: ^8.4.33
|
||||
prettier: ^3.1.1
|
||||
qs: ^6.12.0
|
||||
react: 18.2.0
|
||||
react: ^18.2.0
|
||||
react-country-flag: ^3.1.0
|
||||
react-currency-input-field: ^3.6.11
|
||||
react-dom: 18.2.0
|
||||
react-dom: ^18.2.0
|
||||
react-hook-form: 7.49.1
|
||||
react-i18next: 13.5.0
|
||||
react-jwt: ^1.2.0
|
||||
@@ -14791,15 +14842,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react-dom@npm:18.2.17":
|
||||
version: 18.2.17
|
||||
resolution: "@types/react-dom@npm:18.2.17"
|
||||
dependencies:
|
||||
"@types/react": "*"
|
||||
checksum: 33b53078ed7e9e0cfc4dc691e938f7db1cc06353bc345947b41b581c3efe2b980c9e4eb6460dbf5ddc521dd91959194c970221a2bd4bfad9d23ebce338e12938
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react-dom@npm:^17.0.18":
|
||||
version: 17.0.20
|
||||
resolution: "@types/react-dom@npm:17.0.20"
|
||||
@@ -14827,6 +14869,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react-dom@npm:^18.2.25":
|
||||
version: 18.3.0
|
||||
resolution: "@types/react-dom@npm:18.3.0"
|
||||
dependencies:
|
||||
"@types/react": "*"
|
||||
checksum: 6c90d2ed72c5a0e440d2c75d99287e4b5df3e7b011838cdc03ae5cd518ab52164d86990e73246b9d812eaf02ec351d74e3b4f5bd325bf341e13bf980392fd53b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react@npm:*":
|
||||
version: 18.2.14
|
||||
resolution: "@types/react@npm:18.2.14"
|
||||
@@ -14838,17 +14889,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react@npm:18.2.43":
|
||||
version: 18.2.43
|
||||
resolution: "@types/react@npm:18.2.43"
|
||||
dependencies:
|
||||
"@types/prop-types": "*"
|
||||
"@types/scheduler": "*"
|
||||
csstype: ^3.0.2
|
||||
checksum: 10477a50fbd3c0cc5b8a2ade679f442717f68fb27c8460b2aa1d3256cd18c48f742bbe5b9ee37a8c4c5f832ffa37b3a23c09fd96dd880a8e3182d8929c05e803
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react@npm:>=16, @types/react@npm:^18.2.0, @types/react@npm:^18.2.14":
|
||||
version: 18.2.31
|
||||
resolution: "@types/react@npm:18.2.31"
|
||||
@@ -14871,6 +14911,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react@npm:^18.2.79":
|
||||
version: 18.2.79
|
||||
resolution: "@types/react@npm:18.2.79"
|
||||
dependencies:
|
||||
"@types/prop-types": "*"
|
||||
csstype: ^3.0.2
|
||||
checksum: c8a8a005d8830a48cc1ef93c3510c4935a2a03e5557dbecaa8f1038450cbfcb18eb206fa7fba7077d54b8da21faeb25577e897a333392770a7797f625b62c78a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/resolve@npm:1.17.1":
|
||||
version: 1.17.1
|
||||
resolution: "@types/resolve@npm:1.17.1"
|
||||
@@ -35579,18 +35629,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-dom@npm:18.2.0, react-dom@npm:^18.2.0":
|
||||
version: 18.2.0
|
||||
resolution: "react-dom@npm:18.2.0"
|
||||
dependencies:
|
||||
loose-envify: ^1.1.0
|
||||
scheduler: ^0.23.0
|
||||
peerDependencies:
|
||||
react: ^18.2.0
|
||||
checksum: 66dfc5f93e13d0674e78ef41f92ed21dfb80f9c4ac4ac25a4b51046d41d4d2186abc915b897f69d3d0ebbffe6184e7c5876f2af26bfa956f179225d921be713a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-dom@npm:^17.0.1, react-dom@npm:^17.0.2":
|
||||
version: 17.0.2
|
||||
resolution: "react-dom@npm:17.0.2"
|
||||
@@ -35604,6 +35642,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-dom@npm:^18.2.0":
|
||||
version: 18.2.0
|
||||
resolution: "react-dom@npm:18.2.0"
|
||||
dependencies:
|
||||
loose-envify: ^1.1.0
|
||||
scheduler: ^0.23.0
|
||||
peerDependencies:
|
||||
react: ^18.2.0
|
||||
checksum: 66dfc5f93e13d0674e78ef41f92ed21dfb80f9c4ac4ac25a4b51046d41d4d2186abc915b897f69d3d0ebbffe6184e7c5876f2af26bfa956f179225d921be713a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-element-to-jsx-string@npm:^14.3.4":
|
||||
version: 14.3.4
|
||||
resolution: "react-element-to-jsx-string@npm:14.3.4"
|
||||
@@ -35900,15 +35950,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react@npm:18.2.0, react@npm:^18.2.0":
|
||||
version: 18.2.0
|
||||
resolution: "react@npm:18.2.0"
|
||||
dependencies:
|
||||
loose-envify: ^1.1.0
|
||||
checksum: b562d9b569b0cb315e44b48099f7712283d93df36b19a39a67c254c6686479d3980b7f013dc931f4a5a3ae7645eae6386b4aa5eea933baa54ecd0f9acb0902b8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react@npm:^17.0.1, react@npm:^17.0.2":
|
||||
version: 17.0.2
|
||||
resolution: "react@npm:17.0.2"
|
||||
@@ -35919,6 +35960,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react@npm:^18.2.0":
|
||||
version: 18.2.0
|
||||
resolution: "react@npm:18.2.0"
|
||||
dependencies:
|
||||
loose-envify: ^1.1.0
|
||||
checksum: b562d9b569b0cb315e44b48099f7712283d93df36b19a39a67c254c6686479d3980b7f013dc931f4a5a3ae7645eae6386b4aa5eea933baa54ecd0f9acb0902b8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"read-cache@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "read-cache@npm:1.0.0"
|
||||
|
||||
Reference in New Issue
Block a user