feat: Add some of the missing pieces in the create product form (#6918)
The changes are still partial, there is more work to be done to have everything function properly. Also, refactored the file upload from the product media form to a separate component
This commit is contained in:
@@ -55,6 +55,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"save": "Save",
|
||||
"saveAsDraft": "Save as draft",
|
||||
"create": "Create",
|
||||
"delete": "Delete",
|
||||
"remove": "Remove",
|
||||
@@ -151,7 +152,7 @@
|
||||
"attributes": "Attributes",
|
||||
"editProduct": "Edit Product",
|
||||
"editAttributes": "Edit Attributes",
|
||||
"organization": "Organization",
|
||||
"organization": "Organize",
|
||||
"editOrganization": "Edit Organization",
|
||||
"options": "Options",
|
||||
"editOptions": "Edit Options",
|
||||
@@ -171,10 +172,7 @@
|
||||
"deleteImageLabel": "Delete current image",
|
||||
"noMediaLabel": "The product has no associated media."
|
||||
},
|
||||
"titleHint": "Give your product a short and clear title.<0/>50-60 characters is the recommended length for search engines.",
|
||||
"descriptionHint": "Give your product a short and clear description.<0/>120-160 characters is the recommended length for search engines.",
|
||||
"discountableHint": "When unchecked discounts will not be applied to this product.",
|
||||
"handleTooltip": "The handle is used to reference the product in your storefront. If not specified, the handle will be generated from the product title.",
|
||||
"availableInSalesChannels": "Available in <0>{{x}}</0> of <1>{{y}}</1> sales channels",
|
||||
"noSalesChannels": "Not available in any sales channels",
|
||||
"variantCount_one": "{{count}} variant",
|
||||
@@ -186,6 +184,61 @@
|
||||
"proposed": "Proposed",
|
||||
"rejected": "Rejected"
|
||||
},
|
||||
"fields": {
|
||||
"title": {
|
||||
"label": "Title",
|
||||
"hint": "Give your product a short and clear title.<0/>50-60 characters is the recommended length for search engines."
|
||||
},
|
||||
"subtitle": {
|
||||
"label": "Subtitle"
|
||||
},
|
||||
"handle": {
|
||||
"label": "Handle",
|
||||
"tooltip": "The handle is used to reference the product in your storefront. If not specified, the handle will be generated from the product title."
|
||||
},
|
||||
"description": {
|
||||
"label": "Description",
|
||||
"hint": "Give your product a short and clear description.<0/>120-160 characters is the recommended length for search engines."
|
||||
},
|
||||
"discountable": {
|
||||
"label": "Discountable",
|
||||
"hint": "When unchecked discounts will not be applied to this product"
|
||||
},
|
||||
"type": {
|
||||
"label": "Type"
|
||||
},
|
||||
"collection": {
|
||||
"label": "Collection"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Categories"
|
||||
},
|
||||
"tags": {
|
||||
"label": "Tags"
|
||||
},
|
||||
"sales_channels": {
|
||||
"label": "Sales channels",
|
||||
"hint": "This product will only be available in the default sales channel if left untouched"
|
||||
},
|
||||
"countryOrigin": {
|
||||
"label": "Country of origin"
|
||||
},
|
||||
"material": {
|
||||
"label": "Material"
|
||||
},
|
||||
"width": {
|
||||
"label": "Width"
|
||||
},
|
||||
"length": {
|
||||
"label": "Length"
|
||||
},
|
||||
"height": {
|
||||
"label": "Height"
|
||||
},
|
||||
"weight": {
|
||||
"label": "Weight"
|
||||
}
|
||||
},
|
||||
"variant": {
|
||||
"edit": {
|
||||
"header": "Edit Variant"
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { ArrowDownTray } from "@medusajs/icons"
|
||||
import { Text, clx } from "@medusajs/ui"
|
||||
import { ChangeEvent, DragEvent, useRef, useState } from "react"
|
||||
|
||||
export interface FileType {
|
||||
id: string
|
||||
url: string
|
||||
file: File
|
||||
}
|
||||
|
||||
export interface FileUploadProps {
|
||||
label: string
|
||||
hint?: string
|
||||
hasError?: boolean
|
||||
formats: string[]
|
||||
onUploaded: (files: FileType[]) => void
|
||||
}
|
||||
|
||||
export const FileUpload = ({
|
||||
label,
|
||||
hint,
|
||||
hasError,
|
||||
formats,
|
||||
onUploaded,
|
||||
}: FileUploadProps) => {
|
||||
const [isDragOver, setIsDragOver] = useState<boolean>(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const dropZoneRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const handleOpenFileSelector = () => {
|
||||
inputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleDragEnter = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const files = event.dataTransfer?.files
|
||||
if (!files) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDragOver(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (
|
||||
!dropZoneRef.current ||
|
||||
dropZoneRef.current.contains(event.relatedTarget as Node)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDragOver(false)
|
||||
}
|
||||
|
||||
const handleUploaded = (files: FileList | null) => {
|
||||
if (!files) {
|
||||
return
|
||||
}
|
||||
|
||||
const fileList = Array.from(files)
|
||||
const fileObj = fileList.map((file) => {
|
||||
const previewUrl = URL.createObjectURL(file)
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
url: previewUrl,
|
||||
file,
|
||||
}
|
||||
})
|
||||
|
||||
onUploaded(fileObj)
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
setIsDragOver(false)
|
||||
|
||||
handleUploaded(event.dataTransfer?.files)
|
||||
}
|
||||
|
||||
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
handleUploaded(event.target.files)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
ref={dropZoneRef}
|
||||
type="button"
|
||||
onClick={handleOpenFileSelector}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
className={clx(
|
||||
"bg-ui-bg-component border-ui-border-strong transition-fg group flex w-full flex-col items-center gap-y-2 rounded-lg border border-dashed p-8",
|
||||
"hover:border-ui-border-interactive focus:border-ui-border-interactive",
|
||||
"focus:shadow-borders-focus outline-none focus:border-solid",
|
||||
{
|
||||
"!border-ui-border-error": hasError,
|
||||
"!border-ui-border-interactive": isDragOver,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="text-ui-fg-subtle group-disabled:text-ui-fg-disabled flex items-center gap-x-2">
|
||||
<ArrowDownTray />
|
||||
<Text>{label}</Text>
|
||||
</div>
|
||||
{!!hint && (
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
className="text-ui-fg-muted group-disabled:text-ui-fg-disabled"
|
||||
>
|
||||
{hint}
|
||||
</Text>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
hidden
|
||||
ref={inputRef}
|
||||
onChange={handleFileChange}
|
||||
type="file"
|
||||
accept={formats.join(",")}
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./file-upload"
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import { v1Routes } from "./v1"
|
||||
import { v2Routes } from "./v2"
|
||||
|
||||
const V2_ENABLED = import.meta.env.VITE_MEDUSA_V2 || false
|
||||
const V2_ENABLED = import.meta.env.VITE_MEDUSA_V2 === "true"
|
||||
|
||||
const router = createBrowserRouter(V2_ENABLED ? v2Routes : v1Routes)
|
||||
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import { Button, Checkbox, Heading, Input, Text, Textarea } from "@medusajs/ui"
|
||||
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 { useAdminSalesChannels } from "medusa-react"
|
||||
import {
|
||||
useAdminCollections,
|
||||
useAdminProductCategories,
|
||||
useAdminProductTags,
|
||||
useAdminProductTypes,
|
||||
useAdminSalesChannels,
|
||||
} from "medusa-react"
|
||||
import { Fragment, useMemo, useState } from "react"
|
||||
import { CountrySelect } from "../../../../../components/common/country-select"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
@@ -15,14 +30,38 @@ import { useSalesChannelTableFilters } from "../../../../../hooks/table/filters/
|
||||
import { useSalesChannelTableQuery } from "../../../../../hooks/table/query/use-sales-channel-table-query"
|
||||
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||
import { CreateProductFormReturn } from "./create-product-form"
|
||||
import { Combobox } from "../../../../../components/common/combobox"
|
||||
import { FileUpload } from "../../../../../components/common/file-upload"
|
||||
|
||||
type CreateProductPropsProps = {
|
||||
form: CreateProductFormReturn
|
||||
}
|
||||
|
||||
const SUPPORTED_FORMATS = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/heic",
|
||||
"image/svg+xml",
|
||||
]
|
||||
|
||||
export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, onOpenChange] = useState(false)
|
||||
const { product_types, isLoading: isLoadingTypes } = useAdminProductTypes()
|
||||
const { product_tags, isLoading: isLoadingTags } = useAdminProductTags()
|
||||
const { collections, isLoading: isLoadingCollections } = useAdminCollections()
|
||||
const { sales_channels, isLoading: isLoadingSalesChannels } =
|
||||
useAdminSalesChannels()
|
||||
const { product_categories, isLoading: isLoadingCategories } =
|
||||
useAdminProductCategories()
|
||||
|
||||
// const { append } = useFieldArray({
|
||||
// name: "images",
|
||||
// control: form.control,
|
||||
// // keyName: "field_id",
|
||||
// })
|
||||
|
||||
return (
|
||||
<PanelGroup
|
||||
@@ -51,7 +90,9 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.title")}</Form.Label>
|
||||
<Form.Label>
|
||||
{t("products.fields.title.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
@@ -66,7 +107,7 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("fields.subtitle")}
|
||||
{t("products.fields.subtitle.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
@@ -76,17 +117,13 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
className="text-ui-fg-subtle"
|
||||
>
|
||||
<Form.Hint>
|
||||
<Trans
|
||||
i18nKey="products.titleHint"
|
||||
i18nKey="products.fields.title.hint"
|
||||
t={t}
|
||||
components={[<br key="break" />]}
|
||||
/>
|
||||
</Text>
|
||||
</Form.Hint>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4">
|
||||
<Form.Field
|
||||
@@ -96,7 +133,7 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label
|
||||
tooltip={t("products.handleTooltip")}
|
||||
tooltip={t("products.fields.handle.tooltip")}
|
||||
optional
|
||||
>
|
||||
{t("fields.handle")}
|
||||
@@ -108,22 +145,6 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="material"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("fields.material")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
@@ -132,14 +153,14 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("fields.description")}
|
||||
{t("products.fields.description.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Textarea {...field} />
|
||||
</Form.Control>
|
||||
<Form.Hint>
|
||||
<Trans
|
||||
i18nKey={"products.descriptionHint"}
|
||||
i18nKey={"products.fields.description.hint"}
|
||||
components={[<br key="break" />]}
|
||||
/>
|
||||
</Form.Hint>
|
||||
@@ -150,16 +171,224 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
|
||||
</div>
|
||||
<div id="organize" className="flex flex-col gap-y-8">
|
||||
<Heading level="h2">{t("products.organization")}</Heading>
|
||||
<Button
|
||||
<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>
|
||||
<Select disabled={isLoadingTypes} {...field}>
|
||||
<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 }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.collection.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Select disabled={isLoadingCollections} {...field}>
|
||||
<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>
|
||||
</Button> */}
|
||||
</div>
|
||||
<div id="variants" className="flex flex-col gap-y-8">
|
||||
<Heading level="h2">{t("products.variants")}</Heading>
|
||||
</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}
|
||||
@@ -167,7 +396,9 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>{t("fields.width")}</Form.Label>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.width.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} type="number" min={0} />
|
||||
</Form.Control>
|
||||
@@ -181,7 +412,9 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>{t("fields.length")}</Form.Label>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.length.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} type="number" min={0} />
|
||||
</Form.Control>
|
||||
@@ -195,7 +428,9 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>{t("fields.height")}</Form.Label>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.height.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} type="number" min={0} />
|
||||
</Form.Control>
|
||||
@@ -209,7 +444,9 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>{t("fields.weight")}</Form.Label>
|
||||
<Form.Label optional>
|
||||
{t("products.fields.weight.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} type="number" min={0} />
|
||||
</Form.Control>
|
||||
@@ -217,18 +454,45 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* TODO: Add missing attribute fields */}
|
||||
</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="origin_country"
|
||||
render={({ field }) => {
|
||||
name="images"
|
||||
render={() => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t("fields.countryOfOrigin")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<CountrySelect {...field} />
|
||||
</Form.Control>
|
||||
<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>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -15,15 +15,19 @@ const CreateProductSchema = zod.object({
|
||||
title: zod.string(),
|
||||
subtitle: zod.string().optional(),
|
||||
handle: zod.string().optional(),
|
||||
material: 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(),
|
||||
origin_country: zod.string().optional(),
|
||||
mid_code: zod.string().optional(),
|
||||
hs_code: zod.string().optional(),
|
||||
variants: zod.array(
|
||||
@@ -31,6 +35,8 @@ const CreateProductSchema = zod.object({
|
||||
variant_rank: zod.number(),
|
||||
})
|
||||
),
|
||||
images: zod.array(zod.string()).optional(),
|
||||
thumbnail: zod.string().optional(),
|
||||
})
|
||||
|
||||
type Schema = zod.infer<typeof CreateProductSchema>
|
||||
@@ -45,18 +51,12 @@ export const CreateProductForm = () => {
|
||||
title: "",
|
||||
subtitle: "",
|
||||
handle: "",
|
||||
material: "",
|
||||
description: "",
|
||||
discountable: true,
|
||||
height: "",
|
||||
length: "",
|
||||
weight: "",
|
||||
width: "",
|
||||
origin_country: "",
|
||||
mid_code: "",
|
||||
hs_code: "",
|
||||
tags: [],
|
||||
sales_channels: [],
|
||||
variants: [],
|
||||
images: [],
|
||||
},
|
||||
resolver: zodResolver(CreateProductSchema),
|
||||
})
|
||||
@@ -66,13 +66,15 @@ export const CreateProductForm = () => {
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
await mutateAsync(
|
||||
{
|
||||
title: values.title,
|
||||
discountable: values.discountable,
|
||||
...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: values.variants.map((v) => ({ title: "", prices: [] })),
|
||||
},
|
||||
{
|
||||
onSuccess: ({ product }) => {
|
||||
@@ -93,7 +95,7 @@ export const CreateProductForm = () => {
|
||||
</Button>
|
||||
</RouteFocusModal.Close>
|
||||
<Button type="submit" size="small" isLoading={isLoading}>
|
||||
{t("actions.save")}
|
||||
{t("actions.saveAsDraft")}
|
||||
</Button>
|
||||
</div>
|
||||
</RouteFocusModal.Header>
|
||||
|
||||
@@ -140,7 +140,7 @@ export const EditProductForm = ({ product }: EditProductFormProps) => {
|
||||
className="text-ui-fg-subtle"
|
||||
>
|
||||
<Trans
|
||||
i18nKey="products.titleHint"
|
||||
i18nKey="products.fields.title.hint"
|
||||
t={t}
|
||||
components={[<br key="break" />]}
|
||||
/>
|
||||
@@ -204,7 +204,7 @@ export const EditProductForm = ({ product }: EditProductFormProps) => {
|
||||
<Form.ErrorMessage />
|
||||
<Form.Hint>
|
||||
<Trans
|
||||
i18nKey="products.descriptionHint"
|
||||
i18nKey="products.fields.description.hint"
|
||||
t={t}
|
||||
components={[<br key="break" />]}
|
||||
/>
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import {
|
||||
ArrowDownTray,
|
||||
CheckMini,
|
||||
Spinner,
|
||||
ThumbnailBadge,
|
||||
} from "@medusajs/icons"
|
||||
import { CheckMini, Spinner, ThumbnailBadge } from "@medusajs/icons"
|
||||
import { Image, Product } from "@medusajs/medusa"
|
||||
import { Button, CommandBar, Text, Tooltip, clx } from "@medusajs/ui"
|
||||
import { Button, CommandBar, Tooltip, clx } from "@medusajs/ui"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import { useAdminUpdateProduct, useMedusa } from "medusa-react"
|
||||
import {
|
||||
ChangeEvent,
|
||||
DragEvent,
|
||||
Fragment,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { Fragment, useCallback, useState } from "react"
|
||||
import { useFieldArray, useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { z } from "zod"
|
||||
@@ -27,6 +15,10 @@ import {
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import {
|
||||
FileType,
|
||||
FileUpload,
|
||||
} from "../../../../../components/common/file-upload"
|
||||
|
||||
type ProductMediaViewProps = {
|
||||
product: Product
|
||||
@@ -65,11 +57,6 @@ const EditProductMediaSchema = z.object({
|
||||
|
||||
export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
|
||||
const [selection, setSelection] = useState<Record<string, true>>({})
|
||||
const [isDragOver, setIsDragOver] = useState<boolean>(false)
|
||||
|
||||
const dropZoneRef = useRef<HTMLButtonElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
|
||||
@@ -137,20 +124,16 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
|
||||
)
|
||||
})
|
||||
|
||||
const handleOpenFileSelector = () => {
|
||||
inputRef.current?.click()
|
||||
}
|
||||
|
||||
const hasInvalidFiles = (fileList: File[]) => {
|
||||
const hasInvalidFiles = (fileList: FileType[]) => {
|
||||
const invalidFile = fileList.find(
|
||||
(f) => !SUPPORTED_FORMATS.includes(f.type)
|
||||
(f) => !SUPPORTED_FORMATS.includes(f.file.type)
|
||||
)
|
||||
|
||||
if (invalidFile) {
|
||||
form.setError("media", {
|
||||
type: "invalid_file",
|
||||
message: t("products.media.invalidFileType", {
|
||||
name: invalidFile.name,
|
||||
name: invalidFile.file.name,
|
||||
types: SUPPORTED_FORMATS_FILE_EXTENSIONS.join(", "),
|
||||
}),
|
||||
})
|
||||
@@ -161,94 +144,6 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
|
||||
return false
|
||||
}
|
||||
|
||||
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files
|
||||
if (files) {
|
||||
const fileList = Array.from(files)
|
||||
|
||||
if (hasInvalidFiles(fileList)) {
|
||||
return
|
||||
}
|
||||
|
||||
form.clearErrors("media")
|
||||
|
||||
fileList.forEach((file) => {
|
||||
const preview = URL.createObjectURL(file)
|
||||
|
||||
append({
|
||||
id: crypto.randomUUID(),
|
||||
url: preview,
|
||||
isThumbnail: false,
|
||||
file,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnter = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const files = event.dataTransfer.files
|
||||
|
||||
if (files) {
|
||||
const fileList = Array.from(files)
|
||||
|
||||
console.log(fileList.map((f) => f.type))
|
||||
|
||||
if (hasInvalidFiles(fileList)) {
|
||||
console.log("invalid files")
|
||||
setIsDragOver(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsDragOver(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragLeave = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (
|
||||
!dropZoneRef.current ||
|
||||
dropZoneRef.current.contains(event.relatedTarget as Node)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDragOver(false)
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
setIsDragOver(false)
|
||||
form.clearErrors("media")
|
||||
|
||||
const files = event.dataTransfer.files
|
||||
|
||||
if (files) {
|
||||
const fileList = Array.from(files)
|
||||
|
||||
if (hasInvalidFiles(fileList)) {
|
||||
return
|
||||
}
|
||||
|
||||
fileList.forEach((file) => {
|
||||
const previewUrl = URL.createObjectURL(file)
|
||||
|
||||
append({
|
||||
id: crypto.randomUUID(),
|
||||
url: previewUrl,
|
||||
isThumbnail: false,
|
||||
file,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckedChange = useCallback(
|
||||
(id: string) => {
|
||||
return (val: boolean) => {
|
||||
@@ -353,49 +248,22 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
|
||||
<Form.Hint>{t("products.media.editHint")}</Form.Hint>
|
||||
</div>
|
||||
<Form.Control>
|
||||
<div>
|
||||
<button
|
||||
ref={dropZoneRef}
|
||||
type="button"
|
||||
onClick={handleOpenFileSelector}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
className={clx(
|
||||
"bg-ui-bg-component border-ui-border-strong transition-fg group flex w-full flex-col items-center gap-y-2 rounded-lg border border-dashed p-8",
|
||||
"hover:border-ui-border-interactive focus:border-ui-border-interactive",
|
||||
"focus:shadow-borders-focus outline-none focus:border-solid",
|
||||
{
|
||||
"!border-ui-border-error":
|
||||
!!form.formState.errors.media,
|
||||
"!border-ui-border-interactive": isDragOver,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="text-ui-fg-subtle group-disabled:text-ui-fg-disabled flex items-center gap-x-2">
|
||||
<ArrowDownTray />
|
||||
<Text>
|
||||
{t("products.media.uploadImagesLabel")}
|
||||
</Text>
|
||||
</div>
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
className="text-ui-fg-muted group-disabled:text-ui-fg-disabled"
|
||||
>
|
||||
{t("products.media.uploadImagesHint")}
|
||||
</Text>
|
||||
</button>
|
||||
<input
|
||||
hidden
|
||||
ref={inputRef}
|
||||
onChange={handleFileChange}
|
||||
type="file"
|
||||
accept={SUPPORTED_FORMATS.join(",")}
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
<FileUpload
|
||||
label={t("products.media.uploadImagesLabel")}
|
||||
hint={t("products.media.uploadImagesHint")}
|
||||
hasError={!!form.formState.errors.media}
|
||||
formats={SUPPORTED_FORMATS}
|
||||
onUploaded={(files) => {
|
||||
form.clearErrors("media")
|
||||
if (hasInvalidFiles(files)) {
|
||||
return
|
||||
}
|
||||
|
||||
files.forEach((f) =>
|
||||
append({ ...f, isThumbnail: false })
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user