-
+
+
+
{
+ return (
+
+
+ Media
+
+
+ handleMediaChange(field.onChange, next)}
+ />
+
+
+
+ );
+ }}
+ />
+
+ {(form.watch('media') ?? []).map(media => {
+ const isThumbnail = !!media.isThumbnail;
+ const isBanner = !!media.isBanner;
+
+ return (
+
+
+
+
+
+ {media.file.name}
+
+
+ {isThumbnail && }
+ {isBanner && }
+
+ {formatFileSize(media.file.size)}
+
+
+
+
+
+
+
+
+
+
+
+ {
+ form.setValue(
+ 'media',
+ promoteExclusive(form.getValues().media, media.id, 'isThumbnail'),
+ { shouldDirty: true }
+ );
+ }}
+ >
+
+
+ {isThumbnail ? 'Remove thumbnail' : 'Make thumbnail'}
+
+
+
+
+
+ {
+ form.setValue(
+ 'media',
+ promoteExclusive(form.getValues().media, media.id, 'isBanner'),
+ { shouldDirty: true }
+ );
+ }}
+ >
+
+
+ {isBanner ? 'Remove banner' : 'Make banner'}
+
+
+
+
+
+
+
+
+ form.setValue(
+ 'media',
+ form.getValues().media.filter(m => m.id !== media.id),
+ { shouldDirty: true }
+ )
+ }
+ >
+
+ Delete
+
+
+
+
+
+
+
+ );
+ })}
+
+
+ {form.watch().icon ? (
+
+
+
+
+
+ {form.watch().icon?.[0].file.name}
+
+
+
+ {formatFileSize(form.watch().icon?.[0].file.size ?? 0)}
+
+
+
+
+
+
+ ) : (
+
(
+
+ Icon
+
+
+
+
+
+ )}
+ />
+ )}
+
+
+ This icon will appear near the collection label on the storefront.
+
+
+
- )
+ );
+};
+
+function formatFileSize(bytes: number, decimalPlaces: number = 2): string {
+ if (bytes === 0) {
+ return '0 Bytes';
+ }
+
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(decimalPlaces)) + ' ' + sizes[i];
}
diff --git a/src/routes/categories/category-create/components/create-category-form/create-category-form.tsx b/src/routes/categories/category-create/components/create-category-form/create-category-form.tsx
index 14468c0f..f90923c6 100644
--- a/src/routes/categories/category-create/components/create-category-form/create-category-form.tsx
+++ b/src/routes/categories/category-create/components/create-category-form/create-category-form.tsx
@@ -1,56 +1,68 @@
-import { zodResolver } from "@hookform/resolvers/zod"
-import { Button, ProgressStatus, ProgressTabs, toast } from "@medusajs/ui"
-import { useForm } from "react-hook-form"
-import { useTranslation } from "react-i18next"
+import { zodResolver } from '@hookform/resolvers/zod';
+import { Button, ProgressStatus, ProgressTabs, toast } from '@medusajs/ui';
+import { useForm } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
-import { useState } from "react"
+import { useState } from 'react';
+import { RouteFocusModal, useRouteModal } from '../../../../../components/modals';
+import { KeyboundForm } from '../../../../../components/utilities/keybound-form';
import {
- RouteFocusModal,
- useRouteModal,
-} from "../../../../../components/modals"
-import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
-import { useCreateProductCategory } from "../../../../../hooks/api/categories"
-import { transformNullableFormData } from "../../../../../lib/form-helpers"
-import { CreateCategoryDetails } from "./create-category-details"
-import { CreateCategoryNesting } from "./create-category-nesting"
-import { CreateCategoryDetailsSchema, CreateCategorySchema } from "./schema"
-import { useDocumentDirection } from "../../../../../hooks/use-document-direction"
+ useCreateProductCategory,
+ useUpdateProductCategoryDetails
+} from '../../../../../hooks/api/categories';
+import { transformNullableFormData } from '../../../../../lib/form-helpers';
+import { CreateCategoryDetails } from './create-category-details';
+import { CreateCategoryNesting } from './create-category-nesting';
+import { CreateCategoryDetailsSchema, CreateCategorySchema } from './schema';
+import { useDocumentDirection } from '../../../../../hooks/use-document-direction';
+import { FileType } from '@components/common/file-upload';
+import { sdk } from '@lib/client';
+
+export type MediaItem = FileType & {
+ isThumbnail: boolean;
+ isBanner: boolean;
+};
+
+export type collectionMediaType = {
+ media: MediaItem[];
+ icon: FileType[] | null;
+};
type CreateCategoryFormProps = {
- parentCategoryId: string | null
-}
+ parentCategoryId: string | null;
+};
enum Tab {
- DETAILS = "details",
- ORGANIZE = "organize",
+ DETAILS = 'details',
+ ORGANIZE = 'organize'
}
-export const CreateCategoryForm = ({
- parentCategoryId,
-}: CreateCategoryFormProps) => {
- const { t } = useTranslation()
- const { handleSuccess } = useRouteModal()
- const direction = useDocumentDirection()
- const [activeTab, setActiveTab] = useState
(Tab.DETAILS)
- const [validDetails, setValidDetails] = useState(false)
- const [shouldFreeze, setShouldFreeze] = useState(false)
+export const CreateCategoryForm = ({ parentCategoryId }: CreateCategoryFormProps) => {
+ const { t } = useTranslation();
+ const { handleSuccess } = useRouteModal();
+ const direction = useDocumentDirection();
+ const [activeTab, setActiveTab] = useState(Tab.DETAILS);
+ const [validDetails, setValidDetails] = useState(false);
+ const [shouldFreeze, setShouldFreeze] = useState(false);
const form = useForm({
defaultValues: {
- name: "",
- description: "",
- handle: "",
- status: "active",
- visibility: "public",
+ name: '',
+ description: '',
+ handle: '',
+ status: 'active',
+ visibility: 'public',
+ media: [],
+ icon: null,
rank: parentCategoryId ? 0 : null,
- parent_category_id: parentCategoryId,
+ parent_category_id: parentCategoryId
},
- resolver: zodResolver(CreateCategorySchema),
- })
+ resolver: zodResolver(CreateCategorySchema)
+ });
const handleTabChange = (tab: Tab) => {
if (tab === Tab.ORGANIZE) {
- const { name, handle, description, status, visibility } = form.getValues()
+ const { name, handle, description, status, visibility, media, icon } = form.getValues();
const result = CreateCategoryDetailsSchema.safeParse({
name,
@@ -58,33 +70,37 @@ export const CreateCategoryForm = ({
description,
status,
visibility,
- })
+ media,
+ icon
+ });
if (!result.success) {
- result.error.errors.forEach((error) => {
- form.setError(error.path.join(".") as keyof CreateCategorySchema, {
- type: "manual",
- message: error.message,
- })
- })
-
- return
+ result.error.errors.forEach(error => {
+ form.setError(error.path.join('.') as keyof CreateCategorySchema, {
+ type: 'manual',
+ message: error.message
+ });
+ });
+
+ return;
}
- form.clearErrors()
- setValidDetails(true)
+ form.clearErrors();
+ setValidDetails(true);
}
- setActiveTab(tab)
- }
+ setActiveTab(tab);
+ };
+
+ const { mutateAsync, isPending } = useCreateProductCategory();
- const { mutateAsync, isPending } = useCreateProductCategory()
+ const { mutateAsync: postCategoryDetailsMutation } = useUpdateProductCategoryDetails();
- const handleSubmit = form.handleSubmit((data) => {
- const { visibility, status, parent_category_id, rank, name, ...rest } = data
- const parsedData = transformNullableFormData(rest, false)
+ const handleSubmit = form.handleSubmit(data => {
+ const { visibility, status, parent_category_id, rank, name, media, icon, ...rest } = data;
+ const parsedData = transformNullableFormData(rest, false);
- setShouldFreeze(true)
+ setShouldFreeze(true);
mutateAsync(
{
@@ -92,62 +108,127 @@ export const CreateCategoryForm = ({
...parsedData,
parent_category_id: parent_category_id ?? undefined,
rank: rank ?? undefined,
- is_active: status === "active",
- is_internal: visibility === "internal",
+ is_active: status === 'active',
+ is_internal: visibility === 'internal'
},
{
- onSuccess: ({ product_category }) => {
+ onSuccess: async ({ product_category }) => {
+ if (media.length > 0) {
+ const { files: uploads } = await sdk.admin.upload
+ .create({ files: media.map(m => m.file) as unknown as File[] })
+ .catch(() => {
+ return { files: [] };
+ });
+
+ const mediaToCreate = uploads.map((item: { id: string; url: string }) => ({
+ url: item.url,
+ alt_text: ''
+ }));
+
+ const thumbnailIndex = media.findIndex(m => !!m.isThumbnail);
+ const bannerIndex = media.find(m => !!m.isBanner)
+ ? media.findIndex(m => !!m.isBanner)
+ : undefined;
+
+ const thumbnail =
+ thumbnailIndex >= 0
+ ? { url: mediaToCreate[thumbnailIndex]?.url }
+ : { url: mediaToCreate[0]?.url };
+ const banner = bannerIndex ? { url: mediaToCreate[bannerIndex]?.url } : undefined;
+
+ let create = mediaToCreate;
+
+ const indexesToBeRemoved = [thumbnailIndex, bannerIndex].filter(Boolean);
+
+ while (indexesToBeRemoved.length) {
+ create.splice(indexesToBeRemoved.pop() as number, 1);
+ }
+
+ await postCategoryDetailsMutation({
+ id: product_category.id,
+ payload: {
+ media: { delete: [], create },
+ thumbnail,
+ banner
+ }
+ });
+ }
+
+ if (icon?.length) {
+ const { files: uploadedIcon } = await sdk.admin.upload
+ .create({
+ files: icon.map(i => i.file) as unknown as File[]
+ })
+ .catch(() => {
+ form.setError('media', {
+ type: 'invalid_file',
+ message: t('products.media.failedToUpload')
+ });
+ return { files: [] };
+ });
+ await postCategoryDetailsMutation({
+ id: product_category.id,
+ payload: {
+ media: { delete: [], create: [] },
+ icon: { url: uploadedIcon[0]?.url }
+ }
+ });
+ }
+
toast.success(
- t("categories.create.successToast", {
- name: product_category.name,
+ t('categories.create.successToast', {
+ name: product_category.name
})
- )
+ );
- handleSuccess(`/categories/${product_category.id}`)
- },
- onError: (error) => {
- toast.error(error.message)
- setShouldFreeze(false)
+ handleSuccess(`/categories/${product_category.id}`);
},
+ onError: error => {
+ toast.error(error.message);
+ setShouldFreeze(false);
+ }
}
- )
- })
+ );
+ });
const nestingStatus: ProgressStatus =
- form.getFieldState("parent_category_id")?.isDirty ||
- form.getFieldState("rank")?.isDirty ||
+ form.getFieldState('parent_category_id')?.isDirty ||
+ form.getFieldState('rank')?.isDirty ||
activeTab === Tab.ORGANIZE
- ? "in-progress"
- : "not-started"
+ ? 'in-progress'
+ : 'not-started';
- const detailsStatus: ProgressStatus = validDetails
- ? "completed"
- : "in-progress"
+ const detailsStatus: ProgressStatus = validDetails ? 'completed' : 'in-progress';
return (
-
+
- handleTabChange(tab as Tab)}
+ handleTabChange(tab as Tab)}
className="flex size-full flex-col"
>
-
+
-
- {t("categories.create.tabs.details")}
-
+ {t('categories.create.tabs.details')}
-
- {t("categories.create.tabs.organize")}
-
+ {t('categories.create.tabs.organize')}
-
-
+
+
-
+
-
{activeTab === Tab.ORGANIZE ? (
@@ -191,7 +283,7 @@ export const CreateCategoryForm = ({
isLoading={isPending}
data-testid="category-create-form-save-button"
>
- {t("actions.save")}
+ {t('actions.save')}
) : (
handleTabChange(Tab.ORGANIZE)}
data-testid="category-create-form-continue-button"
>
- {t("actions.continue")}
+ {t('actions.continue')}
)}
@@ -210,5 +302,5 @@ export const CreateCategoryForm = ({
- )
-}
+ );
+};
diff --git a/src/routes/categories/category-create/components/create-category-form/schema.ts b/src/routes/categories/category-create/components/create-category-form/schema.ts
index e3e7270e..1146f4c4 100644
--- a/src/routes/categories/category-create/components/create-category-form/schema.ts
+++ b/src/routes/categories/category-create/components/create-category-form/schema.ts
@@ -1,17 +1,37 @@
-import { z } from "zod"
+import { z } from 'zod';
export const CreateCategoryDetailsSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
handle: z.string().optional(),
- status: z.enum(["active", "inactive"]),
- visibility: z.enum(["public", "internal"]),
-})
+ status: z.enum(['active', 'inactive']),
+ visibility: z.enum(['public', 'internal']),
+ media: z.array(
+ z.object({
+ id: z.string(),
+ url: z.string(),
+ isThumbnail: z.boolean(),
+ isBanner: z.boolean(),
+ file: z.any()
+ })
+ ),
+ icon: z
+ .array(
+ z.object({
+ id: z.string(),
+ url: z.string(),
+ file: z.any()
+ })
+ )
+ .nullable()
+ .optional()
+});
-export type CreateCategorySchema = z.infer
export const CreateCategorySchema = z
.object({
rank: z.number().nullable(),
- parent_category_id: z.string().nullable(),
+ parent_category_id: z.string().nullable()
})
- .merge(CreateCategoryDetailsSchema)
+ .merge(CreateCategoryDetailsSchema);
+
+export type CreateCategorySchema = z.infer;
diff --git a/src/routes/categories/category-detail/category-detail.tsx b/src/routes/categories/category-detail/category-detail.tsx
index fc2b426e..d9b47141 100644
--- a/src/routes/categories/category-detail/category-detail.tsx
+++ b/src/routes/categories/category-detail/category-detail.tsx
@@ -1,30 +1,25 @@
-import { useLoaderData, useParams } from "react-router-dom"
-import { useProductCategory } from "../../../hooks/api/categories"
-import { CategoryGeneralSection } from "./components/category-general-section"
-import { CategoryOrganizeSection } from "./components/category-organize-section"
-import { CategoryProductSection } from "./components/category-product-section"
-import { categoryLoader } from "./loader"
-
-import { TwoColumnPageSkeleton } from "../../../components/common/skeleton"
-import { TwoColumnPage } from "../../../components/layout/pages"
-import { useExtension } from "../../../providers/extension-provider"
+import { useLoaderData, useParams } from 'react-router-dom';
+import { useProductCategory } from '../../../hooks/api/categories';
+import { CategoryGeneralSection } from './components/category-general-section';
+import { CategoryOrganizeSection } from './components/category-organize-section';
+import { CategoryProductSection } from './components/category-product-section';
+import { categoryLoader } from './loader';
+
+import { TwoColumnPageSkeleton } from '../../../components/common/skeleton';
+import { TwoColumnPage } from '../../../components/layout/pages';
+import { useExtension } from '../../../providers/extension-provider';
+import { CategoryMediaSection } from './components/category-media-section';
export const CategoryDetail = () => {
- const { id } = useParams()
+ const { id } = useParams();
- const initialData = useLoaderData() as Awaited<
- ReturnType
- >
+ const initialData = useLoaderData() as Awaited>;
- const { getWidgets } = useExtension()
+ const { getWidgets } = useExtension();
- const { product_category, isLoading, isError, error } = useProductCategory(
- id!,
- undefined,
- {
- initialData,
- }
- )
+ const { product_category, isLoading, isError, error } = useProductCategory(id!, undefined, {
+ initialData
+ });
if (isLoading || !product_category) {
return (
@@ -34,20 +29,20 @@ export const CategoryDetail = () => {
showJSON
showMetadata
/>
- )
+ );
}
if (isError) {
- throw error
+ throw error;
}
return (
{
>
+
- )
-}
+ );
+};
diff --git a/src/routes/categories/category-detail/components/category-media-section/category-mesia-section.tsx b/src/routes/categories/category-detail/components/category-media-section/category-mesia-section.tsx
new file mode 100644
index 00000000..eb161d3d
--- /dev/null
+++ b/src/routes/categories/category-detail/components/category-media-section/category-mesia-section.tsx
@@ -0,0 +1,171 @@
+import { ActionMenu } from '@components/common/action-menu';
+import { PencilSquare, ThumbnailBadge } from '@medusajs/icons';
+import { HttpTypes } from '@medusajs/types';
+import { Container, Heading, Tooltip, Text } from '@medusajs/ui';
+import { useTranslation } from 'react-i18next';
+import { Link } from 'react-router-dom';
+import { CategoryDetail } from '../../types';
+import { BannerIcon } from '@assets/icons/BannerIcon';
+
+
+type CategoryMediaSectionProps = {
+ category: HttpTypes.AdminProductCategory & {
+ category_detail?: CategoryDetail
+ }
+ }
+
+export const CategoryMediaSection = ({
+ category
+}: CategoryMediaSectionProps) => {
+ const { t } = useTranslation()
+
+ const iconId = category.category_detail?.icon_id
+ const iconUrl = category.category_detail?.media.find((m) => m.id === iconId)?.url
+ const thumbnailId = category.category_detail?.thumbnail_id
+ const bannerId = category.category_detail?.banner_id
+ const baseMedia = category.category_detail?.media.filter(item => item.url !== category.category_detail?.icon_id) ?? []
+
+ // If the icon is part of media, exclude it from the grid.
+ const media = iconId ? baseMedia.filter((m) => m.id !== iconId) : baseMedia
+
+ return (
+ <>
+
+
+
Media
+
,
+ },
+ ],
+ },
+ ]}
+ data-testid="collection-media-action-menu"
+ />
+
+
+ {media.length > 0 ? (
+
+ {media.map((i, index) => {
+ const isThumbnail = !!thumbnailId && i.id === thumbnailId
+ const isBanner = !!bannerId && i.id === bannerId
+
+ return (
+
+
+ {isThumbnail && (
+
+
+
+
+
+ )}
+ {isBanner && (
+
+
+
+
+
+ )}
+
+
+
+

+
+
+ )
+ })}
+
+ ) : (
+
+
+
+ {t("products.media.emptyState.header")}
+
+
+ Add media to showcase it on the storefront.
+
+
+
+ )}
+
+
+
+
Icon
+
,
+ },
+ ],
+ },
+ ]}
+ data-testid="collection-media-action-menu"
+ />
+
+ {iconUrl ? (
+
+
+

+
+
+ ) : (
+
+
+
+ No icon yet
+
+
+ Add icon to showcase it near the collection label on the storefront.
+
+
+
+ )}
+
+ >
+ )
+};
diff --git a/src/routes/categories/category-detail/components/category-media-section/index.ts b/src/routes/categories/category-detail/components/category-media-section/index.ts
new file mode 100644
index 00000000..dc58ca3b
--- /dev/null
+++ b/src/routes/categories/category-detail/components/category-media-section/index.ts
@@ -0,0 +1 @@
+export * from './category-mesia-section';
diff --git a/src/routes/categories/category-detail/loader.ts b/src/routes/categories/category-detail/loader.ts
index 27c62b55..cc2c4536 100644
--- a/src/routes/categories/category-detail/loader.ts
+++ b/src/routes/categories/category-detail/loader.ts
@@ -1,17 +1,17 @@
-import { LoaderFunctionArgs } from "react-router-dom"
+import { LoaderFunctionArgs } from 'react-router-dom';
-import { categoriesQueryKeys } from "../../../hooks/api/categories"
-import { sdk } from "../../../lib/client"
-import { queryClient } from "../../../lib/query-client"
+import { categoriesQueryKeys } from '../../../hooks/api/categories';
+import { sdk } from '../../../lib/client';
+import { queryClient } from '../../../lib/query-client';
const categoryDetailQuery = (id: string) => ({
queryKey: categoriesQueryKeys.detail(id),
- queryFn: async () => sdk.admin.productCategory.retrieve(id),
-})
+ queryFn: async () => sdk.admin.productCategory.retrieve(id)
+});
export const categoryLoader = async ({ params }: LoaderFunctionArgs) => {
- const id = params.id
- const query = categoryDetailQuery(id!)
+ const id = params.id;
+ const query = categoryDetailQuery(id!);
- return queryClient.ensureQueryData(query)
-}
+ return queryClient.ensureQueryData(query);
+};
diff --git a/src/routes/categories/category-detail/types.ts b/src/routes/categories/category-detail/types.ts
new file mode 100644
index 00000000..50414be9
--- /dev/null
+++ b/src/routes/categories/category-detail/types.ts
@@ -0,0 +1,15 @@
+export type CategoryDetail = {
+ id: string;
+ media: Media[];
+ thumbnail_id: string | null;
+ icon_id: string | null;
+ banner_id: string | null;
+};
+
+export type Media = {
+ id: string;
+ url: string;
+ alt_text: string | null;
+ created_at?: Date;
+ updated_at?: Date;
+};
diff --git a/src/routes/categories/category-list/components/category-list-table/use-category-table-columns.tsx b/src/routes/categories/category-list/components/category-list-table/use-category-table-columns.tsx
index 107e081c..951a9f86 100644
--- a/src/routes/categories/category-list/components/category-list-table/use-category-table-columns.tsx
+++ b/src/routes/categories/category-list/components/category-list-table/use-category-table-columns.tsx
@@ -1,59 +1,70 @@
-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 { TriangleRightMini } from '@medusajs/icons';
+import { AdminProductCategoryResponse, HttpTypes } 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 "../../../common/utils"
+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 '../../../common/utils';
+import { CategoryDetail } from '@routes/categories/category-detail/types';
+import { Thumbnail } from '@components/common/thumbnail';
-const columnHelper =
- createColumnHelper()
+const columnHelper = createColumnHelper();
export const useCategoryTableColumns = () => {
- const { t } = useTranslation()
+ const { t } = useTranslation();
return useMemo(
() => [
- columnHelper.accessor("name", {
- header: () => ,
+ columnHelper.accessor('name', {
+ header: () => ,
cell: ({ getValue, row }) => {
- const expandHandler = row.getToggleExpandedHandler()
+ const expandHandler = row.getToggleExpandedHandler();
+
+ const thumbnailId = (
+ row.original as HttpTypes.AdminProductCategory & { category_detail?: CategoryDetail }
+ ).category_detail?.thumbnail_id;
+ const thumbnailUrl = thumbnailId
+ ? (
+ row.original as HttpTypes.AdminProductCategory & {
+ category_detail?: CategoryDetail;
+ }
+ ).category_detail?.media.find(m => m.id === thumbnailId)?.url
+ : null;
if (row.original.parent_category !== undefined) {
- const path = getCategoryPath(row.original)
+ const path = getCategoryPath(row.original);
return (
{path.map((chip, index) => (
-
+
{chip.name}
{index !== path.length - 1 && (
-
+
/
)}
))}
- )
+ );
}
return (
@@ -62,11 +73,11 @@ export const useCategoryTableColumns = () => {
{row.getCanExpand() ? (
{
- e.stopPropagation()
- e.preventDefault()
+ onClick={e => {
+ e.stopPropagation();
+ e.preventDefault();
- expandHandler()
+ expandHandler();
}}
size="small"
variant="transparent"
@@ -74,43 +85,41 @@ export const useCategoryTableColumns = () => {
>
) : null}
+