From 1c5d8a1bde7495520911c35b06c6e3179ddaab98 Mon Sep 17 00:00:00 2001 From: pfulara Date: Wed, 3 Dec 2025 09:24:00 +0100 Subject: [PATCH 1/8] Mercur Connect page --- src/assets/icons/MercurConnect.tsx | 105 ++++++++++++++++++ .../layout/main-layout/main-layout.tsx | 65 +++++++++-- src/dashboard-app/routes/get-route.map.tsx | 5 + .../mercur-connect-item.tsx | 75 +++++++++++++ .../mercur-connect-modal.tsx | 80 +++++++++++++ src/routes/mercur-connect/const/index.tsx | 20 ++++ src/routes/mercur-connect/index.tsx | 1 + src/routes/mercur-connect/mecrur-connect.tsx | 40 +++++++ 8 files changed, 383 insertions(+), 8 deletions(-) create mode 100644 src/assets/icons/MercurConnect.tsx create mode 100644 src/routes/mercur-connect/components/mercur-connect-item/mercur-connect-item.tsx create mode 100644 src/routes/mercur-connect/components/mercur-connect-modal/mercur-connect-modal.tsx create mode 100644 src/routes/mercur-connect/const/index.tsx create mode 100644 src/routes/mercur-connect/index.tsx create mode 100644 src/routes/mercur-connect/mecrur-connect.tsx diff --git a/src/assets/icons/MercurConnect.tsx b/src/assets/icons/MercurConnect.tsx new file mode 100644 index 00000000..0f567bc7 --- /dev/null +++ b/src/assets/icons/MercurConnect.tsx @@ -0,0 +1,105 @@ +export const MercurConnect = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/components/layout/main-layout/main-layout.tsx b/src/components/layout/main-layout/main-layout.tsx index e6b54b11..0c581303 100644 --- a/src/components/layout/main-layout/main-layout.tsx +++ b/src/components/layout/main-layout/main-layout.tsx @@ -23,6 +23,8 @@ import { Collapsible as RadixCollapsible } from "radix-ui"; import { useTranslation } from "react-i18next"; import { Link, useLocation, useNavigate } from "react-router-dom"; +import { MercurConnect } from "@assets/icons/MercurConnect"; + import { useLogout } from "../../../hooks/api"; import { useStore } from "../../../hooks/api/store"; import { useDocumentDirection } from "../../../hooks/use-document-direction"; @@ -61,6 +63,7 @@ const MainSidebar = () => {
+
@@ -95,7 +98,10 @@ const Logout = () => { }; return ( - +
{t("app.menus.actions.logout")} @@ -131,11 +137,19 @@ const Header = () => { data-testid="sidebar-header-dropdown-trigger" > {fallback ? ( - + ) : ( )} -
+
{name ? ( { {isLoaded && ( - -
- -
+ +
+ +
{
- + {t("app.nav.main.storeSettings")} @@ -379,6 +411,23 @@ const CoreRouteSection = () => { ); }; +const MercurConnectSection = () => { + return ( +
+
+ +
+
+ } + /> +
+
+ ); +}; + const ExtensionRouteSection = () => { const { t } = useTranslation(); const { getMenu } = useExtension(); diff --git a/src/dashboard-app/routes/get-route.map.tsx b/src/dashboard-app/routes/get-route.map.tsx index f0f02cff..fae05526 100644 --- a/src/dashboard-app/routes/get-route.map.tsx +++ b/src/dashboard-app/routes/get-route.map.tsx @@ -27,6 +27,11 @@ export function getRouteMap({ { element: , children: [ + { + path: "/mercur-connect", + errorElement: , + lazy: () => import("../../routes/mercur-connect"), + }, { path: "/", errorElement: , diff --git a/src/routes/mercur-connect/components/mercur-connect-item/mercur-connect-item.tsx b/src/routes/mercur-connect/components/mercur-connect-item/mercur-connect-item.tsx new file mode 100644 index 00000000..e2e08dcb --- /dev/null +++ b/src/routes/mercur-connect/components/mercur-connect-item/mercur-connect-item.tsx @@ -0,0 +1,75 @@ +import { Avatar, Button, Container, Heading, Text } from "@medusajs/ui"; + +import { Link } from "react-router-dom"; + +import { IconAvatar } from "@components/common/icon-avatar"; + +type ItemProps = { + name: string; + description: string; + enabled: boolean; + icon: React.ReactNode | string; + provider: string; +}; + +export const MercurConnectItem = ({ + item, + testId = "mercur-connect-item", + onOpenPrompt, +}: { + item: ItemProps; + testId?: string; + onOpenPrompt?: (open: boolean) => void; +}) => { + const fallback = item.name.charAt(0).toUpperCase(); + + const isIconString = typeof item.icon === "string"; + + return ( + <> + +
+
+ {isIconString ? ( + + ) : ( + + {item.icon} + + )} +
+
+ {item.name} + + {item.description} + +
+
+ +
+ {item.provider === "more" ? ( + + + + ) : ( + + )} +
+
+ + ); +}; diff --git a/src/routes/mercur-connect/components/mercur-connect-modal/mercur-connect-modal.tsx b/src/routes/mercur-connect/components/mercur-connect-modal/mercur-connect-modal.tsx new file mode 100644 index 00000000..4c966799 --- /dev/null +++ b/src/routes/mercur-connect/components/mercur-connect-modal/mercur-connect-modal.tsx @@ -0,0 +1,80 @@ +import { CurrencyDollar, XMarkMini } from "@medusajs/icons"; +import { Button, Kbd, Prompt, Text } from "@medusajs/ui"; + +import { Link } from "react-router-dom"; + +export const MercurConnectModal = ({ + open, + onOpenChange, + testId = "mercur-connect-modal", +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + testId?: string; +}) => { + return ( + + + +
+ esc + +
+
+ + + + Mercur Connect Feature + + + Your current plan doesn’t include this feature. Please contact our + sales team to enable it for your account. + + + + + + + +
+
+ ); +}; diff --git a/src/routes/mercur-connect/const/index.tsx b/src/routes/mercur-connect/const/index.tsx new file mode 100644 index 00000000..501d96ad --- /dev/null +++ b/src/routes/mercur-connect/const/index.tsx @@ -0,0 +1,20 @@ +import { ArrowUpTray } from "@medusajs/icons"; + +export const mercurConnectItems = [ + { + name: "Product Importer", + description: + "Allow your vendors to quickly add products to your marketplace by uploading CSV files, making catalog management fast and efficient.", + enabled: false, + icon: , + provider: "csv", + }, + { + name: "Shopify Connector", + description: + "Allow your vendors to connect their Shopify stores and seamlessly sync products, stock levels, prices, and orders in real time.", + enabled: false, + icon: "https://www.citypng.com/public/uploads/preview/shopify-bag-icon-symbol-logo-701751695132537nenecmhs0u.png", + provider: "shopify", + }, +]; diff --git a/src/routes/mercur-connect/index.tsx b/src/routes/mercur-connect/index.tsx new file mode 100644 index 00000000..19dd9361 --- /dev/null +++ b/src/routes/mercur-connect/index.tsx @@ -0,0 +1 @@ +export { MercurConnect as Component } from "./mecrur-connect"; diff --git a/src/routes/mercur-connect/mecrur-connect.tsx b/src/routes/mercur-connect/mecrur-connect.tsx new file mode 100644 index 00000000..5eaa946f --- /dev/null +++ b/src/routes/mercur-connect/mecrur-connect.tsx @@ -0,0 +1,40 @@ +import { useState } from "react"; + +import { RocketLaunch } from "@medusajs/icons"; + +import { MercurConnectItem } from "./components/mercur-connect-item/mercur-connect-item"; +import { MercurConnectModal } from "./components/mercur-connect-modal/mercur-connect-modal"; +import { mercurConnectItems } from "./const"; + +export const MercurConnect = () => { + const [openPrompt, setOpenPrompt] = useState(false); + + return ( + <> +
+ {mercurConnectItems.map((item) => ( + + ))} + , + provider: "more", + }} + /> +
+ + + ); +}; From 629709defb713daa9ad28556ad85d9f32db12076 Mon Sep 17 00:00:00 2001 From: pfulara Date: Wed, 3 Dec 2025 09:35:03 +0100 Subject: [PATCH 2/8] Provider enabled status --- .../mercur-connect-item/mercur-connect-item.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/routes/mercur-connect/components/mercur-connect-item/mercur-connect-item.tsx b/src/routes/mercur-connect/components/mercur-connect-item/mercur-connect-item.tsx index e2e08dcb..d2d17245 100644 --- a/src/routes/mercur-connect/components/mercur-connect-item/mercur-connect-item.tsx +++ b/src/routes/mercur-connect/components/mercur-connect-item/mercur-connect-item.tsx @@ -1,4 +1,11 @@ -import { Avatar, Button, Container, Heading, Text } from "@medusajs/ui"; +import { + Avatar, + Button, + Container, + Heading, + StatusBadge, + Text, +} from "@medusajs/ui"; import { Link } from "react-router-dom"; @@ -59,13 +66,15 @@ export const MercurConnectItem = ({ Contact us + ) : item.enabled ? ( + Enabled ) : ( )}
From 140993e0ed1b9cfe8d9da42280910641b26b9973 Mon Sep 17 00:00:00 2001 From: pfulara Date: Thu, 29 Jan 2026 08:48:56 +0100 Subject: [PATCH 3/8] url change --- .../components/mercur-connect-item/mercur-connect-item.tsx | 2 +- .../components/mercur-connect-modal/mercur-connect-modal.tsx | 2 +- tailwind.config.cjs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/routes/mercur-connect/components/mercur-connect-item/mercur-connect-item.tsx b/src/routes/mercur-connect/components/mercur-connect-item/mercur-connect-item.tsx index d2d17245..8a9e6469 100644 --- a/src/routes/mercur-connect/components/mercur-connect-item/mercur-connect-item.tsx +++ b/src/routes/mercur-connect/components/mercur-connect-item/mercur-connect-item.tsx @@ -61,7 +61,7 @@ export const MercurConnectItem = ({
{item.provider === "more" ? ( - + diff --git a/src/routes/mercur-connect/components/mercur-connect-modal/mercur-connect-modal.tsx b/src/routes/mercur-connect/components/mercur-connect-modal/mercur-connect-modal.tsx index 4c966799..969b0c53 100644 --- a/src/routes/mercur-connect/components/mercur-connect-modal/mercur-connect-modal.tsx +++ b/src/routes/mercur-connect/components/mercur-connect-modal/mercur-connect-modal.tsx @@ -61,7 +61,7 @@ export const MercurConnectModal = ({ data-testid={`${testId}-footer`} > diff --git a/tailwind.config.cjs b/tailwind.config.cjs index c5648b80..2c7d936a 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -15,4 +15,4 @@ module.exports = { extend: {}, }, plugins: [], -} +} \ No newline at end of file From 6af10e810fab9bcada0fd9770df46883183dde24 Mon Sep 17 00:00:00 2001 From: pfulara Date: Thu, 29 Jan 2026 08:51:47 +0100 Subject: [PATCH 4/8] data testid --- .../mercur-connect-item.tsx | 21 +++++++++++++++---- .../mercur-connect-modal.tsx | 11 +++++++--- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/routes/mercur-connect/components/mercur-connect-item/mercur-connect-item.tsx b/src/routes/mercur-connect/components/mercur-connect-item/mercur-connect-item.tsx index 8a9e6469..c3678697 100644 --- a/src/routes/mercur-connect/components/mercur-connect-item/mercur-connect-item.tsx +++ b/src/routes/mercur-connect/components/mercur-connect-item/mercur-connect-item.tsx @@ -52,8 +52,14 @@ export const MercurConnectItem = ({ )}
- {item.name} - + + {item.name} + + {item.description}
@@ -62,14 +68,21 @@ export const MercurConnectItem = ({
{item.provider === "more" ? ( - ) : item.enabled ? ( - Enabled + + Enabled + ) : (
- + Mercur Connect Feature - + Your current plan doesn’t include this feature. Please contact our sales team to enable it for your account. From bd22c6fcb4d3aae26844251d5d07e24e06624c9b Mon Sep 17 00:00:00 2001 From: pfulara Date: Thu, 19 Feb 2026 13:37:54 +0100 Subject: [PATCH 5/8] 2500/2501/2502/2505/2506 --- src/assets/icons/BannerIcon.tsx | 17 + src/assets/icons/MercurConnect.tsx | 4 +- src/assets/icons/ThumbnailIcon.tsx | 9 + .../common/file-upload/file-upload.tsx | 4 + src/dashboard-app/routes/get-route.map.tsx | 5 + src/hooks/api/collections.tsx | 190 +++++-- .../columns/use-collection-table-columns.tsx | 13 +- .../filters/use-product-table-filters.tsx | 20 +- .../table/query/use-product-table-query.tsx | 5 +- .../collection-create/collection-create.tsx | 4 +- .../create-collection-form.tsx | 431 +++++++++++---- .../create-collection-rank.tsx | 111 ++++ .../create-collection-modal.tsx | 284 ++++++++++ .../collection-detail/collection-detail.tsx | 10 + .../collection-media-section.tsx | 169 ++++++ .../collection-media-section/index.ts | 1 + .../collections/collection-detail/loader.ts | 44 +- .../collections/collection-detail/types.ts | 16 + .../collection-list-table.tsx | 2 +- .../collection-media/collection-media.tsx | 33 ++ .../collection-media-gallery.tsx | 287 ++++++++++ .../collection-media-gallery/index.ts | 1 + .../collection-media-view-context.tsx | 9 + .../collection-media-view.tsx | 58 ++ .../components/collection-media-view/index.ts | 1 + .../edit-collection-media-form.tsx | 518 ++++++++++++++++++ .../edit-collection-media-form/index.ts | 1 + .../collections/collection-media/constants.ts | 6 + .../collections/collection-media/index.ts | 1 + .../collections/collection-media/types.ts | 5 + .../collection-tree/collection-tree.tsx | 53 ++ src/routes/collections/common/types.ts | 5 + src/routes/collections/common/utils.ts | 20 + .../upload-media-form-item.tsx | 5 +- 34 files changed, 2163 insertions(+), 179 deletions(-) create mode 100644 src/assets/icons/BannerIcon.tsx create mode 100644 src/assets/icons/ThumbnailIcon.tsx create mode 100644 src/routes/collections/collection-create/components/create-collection-rank/create-collection-rank.tsx create mode 100644 src/routes/collections/collection-create/components/create-collelction-modal/create-collection-modal.tsx create mode 100644 src/routes/collections/collection-detail/components/collection-media-section/collection-media-section.tsx create mode 100644 src/routes/collections/collection-detail/components/collection-media-section/index.ts create mode 100644 src/routes/collections/collection-detail/types.ts create mode 100644 src/routes/collections/collection-media/collection-media.tsx create mode 100644 src/routes/collections/collection-media/components/collection-media-gallery/collection-media-gallery.tsx create mode 100644 src/routes/collections/collection-media/components/collection-media-gallery/index.ts create mode 100644 src/routes/collections/collection-media/components/collection-media-view/collection-media-view-context.tsx create mode 100644 src/routes/collections/collection-media/components/collection-media-view/collection-media-view.tsx create mode 100644 src/routes/collections/collection-media/components/collection-media-view/index.ts create mode 100644 src/routes/collections/collection-media/components/edit-collection-media-form/edit-collection-media-form.tsx create mode 100644 src/routes/collections/collection-media/components/edit-collection-media-form/index.ts create mode 100644 src/routes/collections/collection-media/constants.ts create mode 100644 src/routes/collections/collection-media/index.ts create mode 100644 src/routes/collections/collection-media/types.ts create mode 100644 src/routes/collections/common/components/collection-tree/collection-tree.tsx create mode 100644 src/routes/collections/common/types.ts create mode 100644 src/routes/collections/common/utils.ts diff --git a/src/assets/icons/BannerIcon.tsx b/src/assets/icons/BannerIcon.tsx new file mode 100644 index 00000000..378acd2f --- /dev/null +++ b/src/assets/icons/BannerIcon.tsx @@ -0,0 +1,17 @@ +export const BannerIcon = () => { + return ( + + + + + + + + + + + + + + ) +} \ No newline at end of file diff --git a/src/assets/icons/MercurConnect.tsx b/src/assets/icons/MercurConnect.tsx index 0f567bc7..8fe965a9 100644 --- a/src/assets/icons/MercurConnect.tsx +++ b/src/assets/icons/MercurConnect.tsx @@ -30,9 +30,9 @@ export const MercurConnect = () => { width="24" height="24" filterUnits="userSpaceOnUse" - color-interpolation-filters="sRGB" + colorInterpolationFilters="sRGB" > - + { + return ( + + + + + + ) +} \ No newline at end of file diff --git a/src/components/common/file-upload/file-upload.tsx b/src/components/common/file-upload/file-upload.tsx index f05c45ac..1e651296 100644 --- a/src/components/common/file-upload/file-upload.tsx +++ b/src/components/common/file-upload/file-upload.tsx @@ -9,6 +9,7 @@ export interface FileType { } export interface FileUploadProps { + disabled?: boolean label: string multiple?: boolean hint?: string @@ -18,6 +19,7 @@ export interface FileUploadProps { } export const FileUpload = ({ + disabled = false, label, hint, multiple = true, @@ -95,6 +97,7 @@ export const FileUpload = ({ return (
- - - - - - ) + {t('collections.createCollectionHint')} + +
+
+ { + return ( + + + {t('fields.title')} + + + + + + + ); + }} + /> + { + return ( + + + {t('fields.handle')} + + + + + + + ); + }} + /> +
+
+ ( + + 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: MediaItem) => 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/collections/collection-create/components/create-collection-rank/create-collection-rank.tsx b/src/routes/collections/collection-create/components/create-collection-rank/create-collection-rank.tsx new file mode 100644 index 00000000..9aa7b6a5 --- /dev/null +++ b/src/routes/collections/collection-create/components/create-collection-rank/create-collection-rank.tsx @@ -0,0 +1,111 @@ +import { UseFormReturn, useWatch } from 'react-hook-form'; +import { + collectionMediaType, + CreateCollectionSchema +} from '../create-collelction-modal/create-collection-modal'; +import * as zod from 'zod'; +import { useMemo, useState } from 'react'; +import { useCollections } from '@hooks/api'; +import { insertCollectionTreeItem } from '@routes/collections/common/utils'; +import { Badge } from '@medusajs/ui'; +import { useTranslation } from 'react-i18next'; +import { CollectionTree } from '@routes/collections/common/components/collection-tree/collection-tree'; + +type CollectionTreeItem = { + id: string; + title: string; + rank: number | null; +}; + +export const CreateCollectionRank = ({ + form, + shouldFreeze +}: { + form: UseFormReturn & collectionMediaType>; + shouldFreeze: boolean; +}) => { + const { t } = useTranslation(); + const [snapshot, setSnapshot] = useState([]); + + const ID = 'new-item'; + + const { collections, isPending } = useCollections({ + limit: 9999 + }); + + const watchedRank = useWatch({ + control: form.control, + name: 'rank' + }); + + const watchedTitle = useWatch({ + control: form.control, + name: 'title' + }); + + const value = useMemo(() => { + const temp = { + id: ID, + title: watchedTitle, + rank: watchedRank + }; + + return insertCollectionTreeItem((collections as unknown as CollectionTreeItem[]) ?? [], temp); + }, [collections, watchedTitle, watchedRank]); + + const ready = !isPending && !!collections?.length; + + const normalizeRanks = (list: CollectionTreeItem[]) => { + return list.map((item, index) => ({ ...item, rank: index })); + }; + + const handleChange = ( + { + index: _index + }: { + index: number; + }, + list: CollectionTreeItem[] + ) => { + const nextIndex = list.findIndex((i) => i.id === ID); + + form.setValue('rank', Math.max(0, nextIndex), { + shouldDirty: true, + shouldTouch: true + }); + + setSnapshot(normalizeRanks(list)); + }; + + return ( +
+ item.id === ID} + onChange={handleChange} + renderValue={item => { + if (item.id === ID) { + return ( +
+ {item.title} + + {t('categories.fields.new.label')} + +
+ ); + } + + return item.title; + }} + /> +
+ ); +}; diff --git a/src/routes/collections/collection-create/components/create-collelction-modal/create-collection-modal.tsx b/src/routes/collections/collection-create/components/create-collelction-modal/create-collection-modal.tsx new file mode 100644 index 00000000..42bcd4ce --- /dev/null +++ b/src/routes/collections/collection-create/components/create-collelction-modal/create-collection-modal.tsx @@ -0,0 +1,284 @@ +import { FileType } from '@components/common/file-upload'; +import { RouteFocusModal, useRouteModal } from '@components/modals'; +import { KeyboundForm } from '@components/utilities/keybound-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useDocumentDirection } from '@hooks/use-document-direction'; +import { Button, ProgressStatus, ProgressTabs, toast } from '@medusajs/ui'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import zod from 'zod'; +import { CreateCollectionForm } from '../create-collection-form'; +import { useCreateCollection, usePostCollectionDetails } from '@hooks/api'; +import { CreateCollectionRank } from '../create-collection-rank/create-collection-rank'; +import { HttpTypes } from '@medusajs/types'; +import { sdk } from '@lib/client'; + +export const CreateCollectionSchema = zod.object({ + title: zod.string().min(1), + handle: zod.string().optional(), + rank: zod.number().nullable().default(null), + media: zod + .array( + zod.object({ + id: zod.string(), + url: zod.string(), + isThumbnail: zod.boolean().optional(), + isBanner: zod.boolean().optional(), + file: zod.any() + }) + ) + .default([]), + icon: zod + .array( + zod.object({ + id: zod.string(), + url: zod.string(), + file: zod.any() + }) + ) + .nullable() + .default(null) +}); + +export type MediaItem = FileType & { + isThumbnail?: boolean; + isBanner?: boolean; +}; + +export type collectionMediaType = { + media: MediaItem[]; + icon: FileType[] | null; +}; + +enum Tab { + DETAILS = 'details', + ORGANIZE = 'organize' +} + +export const CreateCollectionModal = () => { + const { t } = useTranslation(); + const direction = useDocumentDirection(); + const [activeTab, setActiveTab] = useState(Tab.DETAILS); + const [validDetails, setValidDetails] = useState(false); + const [shouldFreeze, setShouldFreeze] = useState(false); + + const { handleSuccess } = useRouteModal(); + + const { mutateAsync, isPending } = useCreateCollection(); + + const { mutateAsync: postCollectionDetailsMutation } = usePostCollectionDetails(); + + const form = useForm & collectionMediaType>({ + defaultValues: { + title: '', + handle: '', + media: [], + icon: null, + rank: null + }, + resolver: zodResolver(CreateCollectionSchema) + }); + + const handleSubmit = form.handleSubmit(async data => { + setShouldFreeze(true); + const { title, handle, rank, media, icon } = data; + + await mutateAsync( + { title, handle }, + { + onSuccess: async ({ collection }) => { + if (rank !== null) { + await postCollectionDetailsMutation({ + id: collection.id, + payload: { media: { create: [], delete: [] }, rank } + }); + } + + 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.findIndex(m => !!m.isBanner); + + const thumbnail = + thumbnailIndex >= 0 + ? mediaToCreate[thumbnailIndex]?.url + : mediaToCreate[0]?.url; + const banner = bannerIndex >= 0 ? mediaToCreate[bannerIndex]?.url : undefined; + + await postCollectionDetailsMutation({ + id: collection.id, + payload: { + media: { delete: [], create: mediaToCreate }, + ...(thumbnail ? { thumbnail } : {}), + ...(banner ? { banner } : {}) + } + }); + } + + if (icon?.length) { + let uploadedIcon: HttpTypes.AdminFile[] = []; + const { files: uploads } = 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: [] }; + }); + uploadedIcon = uploads; + await postCollectionDetailsMutation({ + id: collection.id, + payload: { + media: { delete: [], create: [] }, + icon: uploadedIcon[0]?.url + } + }); + } + + handleSuccess(`/collections/${collection.id}`); + toast.success(t('collections.createSuccess')); + }, + onError: error => { + toast.error(error.message); + setShouldFreeze(false); + } + } + ); + }); + + const handleTabChange = (tab: Tab) => { + if (tab === Tab.ORGANIZE) { + const { title } = form.getValues(); + + const result = CreateCollectionSchema.safeParse({ + title + }); + + if (!result.success) { + result.error.errors.forEach(error => { + form.setError(error.path.join('.') as keyof zod.infer, { + type: 'manual', + message: error.message + }); + }); + + return; + } + + form.clearErrors(); + setValidDetails(true); + } + setActiveTab(tab); + }; + + const nestingStatus: ProgressStatus = activeTab === Tab.ORGANIZE ? 'in-progress' : 'not-started'; + + const detailsStatus: ProgressStatus = validDetails ? 'completed' : 'in-progress'; + + return ( + + + handleTabChange(tab as Tab)} + className="flex size-full flex-col" + > + +
+
+ + + {t('categories.create.tabs.details')} + + + {t('categories.create.tabs.organize')} + + +
+
+
+ + + + + + + + + + + + + {activeTab === Tab.ORGANIZE ? ( + + ) : ( + + )} + +
+
+
+ ); +}; diff --git a/src/routes/collections/collection-detail/collection-detail.tsx b/src/routes/collections/collection-detail/collection-detail.tsx index a9d1a923..a750bed1 100644 --- a/src/routes/collections/collection-detail/collection-detail.tsx +++ b/src/routes/collections/collection-detail/collection-detail.tsx @@ -5,8 +5,11 @@ import { SingleColumnPage } from "../../../components/layout/pages" import { useCollection } from "../../../hooks/api/collections" import { useExtension } from "../../../providers/extension-provider" import { CollectionGeneralSection } from "./components/collection-general-section" +import { CollectionMediaSection } from "./components/collection-media-section/collection-media-section" import { CollectionProductSection } from "./components/collection-product-section" import { collectionLoader } from "./loader" +import type { CollectionDetail as CollectionDetailType } from "./types" +import type { HttpTypes } from "@medusajs/types" export const CollectionDetail = () => { const initialData = useLoaderData() as Awaited< @@ -39,6 +42,13 @@ export const CollectionDetail = () => { data={collection} > + ) diff --git a/src/routes/collections/collection-detail/components/collection-media-section/collection-media-section.tsx b/src/routes/collections/collection-detail/components/collection-media-section/collection-media-section.tsx new file mode 100644 index 00000000..90c2b5c0 --- /dev/null +++ b/src/routes/collections/collection-detail/components/collection-media-section/collection-media-section.tsx @@ -0,0 +1,169 @@ +import type { HttpTypes } from "@medusajs/types" +import { PencilSquare, ThumbnailBadge } from "@medusajs/icons" +import { Container, Heading, Text, Tooltip } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { Link } from "react-router-dom" + +import { ActionMenu } from "@components/common/action-menu" + +import type { CollectionDetail } from "../../types" +import { BannerIcon } from "@assets/icons/BannerIcon" + +type CollectionMediaSectionProps = { + collection: HttpTypes.AdminCollection & { + collection_detail?: CollectionDetail + } +} + +export const CollectionMediaSection = ({ collection }: CollectionMediaSectionProps) => { + const { t } = useTranslation() + + const iconId = collection.collection_detail?.icon_id + const thumbnailId = collection.collection_detail?.thumbnail_id + const bannerId = collection.collection_detail?.banner_id + const baseMedia = collection.collection_detail?.media.filter(item => item.url !== collection.collection_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.url === thumbnailId + const isBanner = !!bannerId && i.url === bannerId + + return ( +
+
+ {isThumbnail && ( +
+ + + +
+ )} + {isBanner && ( +
+ + + +
+ )} +
+ + + {i.alt_text + +
+ ) + })} +
+ ) : ( +
+
+ + {t("products.media.emptyState.header")} + + + Add media to showcase it on the storefront. + +
+
+ )} +
+ +
+ Icon + , + }, + ], + }, + ]} + data-testid="collection-media-action-menu" + /> +
+ {iconId ? ( +
+
+ Collection icon +
+
+ ) : ( +
+
+ + No icon yet + + + Add icon to showcase it near the collection label on the storefront. + +
+
+ )} +
+ + ) +} diff --git a/src/routes/collections/collection-detail/components/collection-media-section/index.ts b/src/routes/collections/collection-detail/components/collection-media-section/index.ts new file mode 100644 index 00000000..c8c3ba17 --- /dev/null +++ b/src/routes/collections/collection-detail/components/collection-media-section/index.ts @@ -0,0 +1 @@ +export * from "./collection-media-section" diff --git a/src/routes/collections/collection-detail/loader.ts b/src/routes/collections/collection-detail/loader.ts index e9225572..0380ecd1 100644 --- a/src/routes/collections/collection-detail/loader.ts +++ b/src/routes/collections/collection-detail/loader.ts @@ -1,12 +1,54 @@ import { LoaderFunctionArgs } from "react-router-dom" import { collectionsQueryKeys } from "../../../hooks/api/collections" +import { FetchError } from "@medusajs/js-sdk" import { sdk } from "../../../lib/client" import { queryClient } from "../../../lib/query-client" +type AdminCollectionDetailMedia = { + id: string + url: string + alt_text: string | null +} + +type AdminCollectionDetail = { + id: string + media: AdminCollectionDetailMedia[] + thumbnail_id: string | null + icon_id: string | null + banner_id: string | null + rank: number +} + const collectionDetailQuery = (id: string) => ({ queryKey: collectionsQueryKeys.detail(id), - queryFn: async () => sdk.admin.productCollection.retrieve(id), + queryFn: async () => { + const { collection } = await sdk.admin.productCollection.retrieve(id) + + try { + const { collection_detail } = await sdk.client.fetch<{ + collection_detail: AdminCollectionDetail + }>(`/admin/collections/${id}/details`, { + method: "GET", + query: { + fields: "collection_detail.*,collection_detail.media.*", + }, + }) + + return { + collection: { + ...collection, + collection_detail, + }, + } + } catch (error) { + if (error instanceof FetchError && error.status === 404) { + return { collection } + } + + throw error + } + }, }) export const collectionLoader = async ({ params }: LoaderFunctionArgs) => { diff --git a/src/routes/collections/collection-detail/types.ts b/src/routes/collections/collection-detail/types.ts new file mode 100644 index 00000000..74627f1e --- /dev/null +++ b/src/routes/collections/collection-detail/types.ts @@ -0,0 +1,16 @@ +export type CollectionDetail = { + id: string + media: Media[] + thumbnail_id: string | null + icon_id: string | null + banner_id: string | null + rank: number +} + +export type Media = { + id: string + url: string + alt_text: string | null + created_at?: Date + updated_at?: Date +} diff --git a/src/routes/collections/collection-list/components/collection-list-table/collection-list-table.tsx b/src/routes/collections/collection-list/components/collection-list-table/collection-list-table.tsx index 23f8a927..c05f5707 100644 --- a/src/routes/collections/collection-list/components/collection-list-table/collection-list-table.tsx +++ b/src/routes/collections/collection-list/components/collection-list-table/collection-list-table.tsx @@ -22,7 +22,7 @@ export const CollectionListTable = () => { const { collections, count, isError, error, isLoading } = useCollections( { ...searchParams, - fields: "+products.id", + fields: "+products.id,collection_detail.*,collection_detail.media.*", }, { placeholderData: keepPreviousData, diff --git a/src/routes/collections/collection-media/collection-media.tsx b/src/routes/collections/collection-media/collection-media.tsx new file mode 100644 index 00000000..d8592c21 --- /dev/null +++ b/src/routes/collections/collection-media/collection-media.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from "react-i18next" +import { useParams } from "react-router-dom" + +import { RouteFocusModal } from "../../../components/modals" +import { useCollection } from "../../../hooks/api/collections" +import { CollectionMediaView } from "./components/collection-media-view" + +export const CollectionMedia = () => { + const { t } = useTranslation() + const { id } = useParams() + + const { collection, isLoading, isError, error } = useCollection(id!) + + const ready = !isLoading && collection + + if (isError) { + throw error + } + + return ( + + + {t("products.media.label")} + + + {t("products.media.editHint")} + + {ready && } + + ) +} + + diff --git a/src/routes/collections/collection-media/components/collection-media-gallery/collection-media-gallery.tsx b/src/routes/collections/collection-media/components/collection-media-gallery/collection-media-gallery.tsx new file mode 100644 index 00000000..4a71dfc5 --- /dev/null +++ b/src/routes/collections/collection-media/components/collection-media-gallery/collection-media-gallery.tsx @@ -0,0 +1,287 @@ +import type { HttpTypes } from '@medusajs/types'; +import { Button, clx, IconButton, Text, Tooltip } from '@medusajs/ui'; +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RouteFocusModal } from '@components/modals'; +import { + ArrowDownTray, + ThumbnailBadge, + Trash, + TriangleLeftMini, + TriangleRightMini +} from '@medusajs/icons'; +import { CollectionDetail } from '@routes/collections/collection-detail/types'; +import { useLocation, Link } from 'react-router-dom'; +import { usePostCollectionDetails } from '@hooks/api'; +import { BannerIcon } from '@assets/icons/BannerIcon'; + +type CollectionMediaGalleryProps = { + collection: HttpTypes.AdminCollection & { + collection_detail?: CollectionDetail; + }; +}; + +export const CollectionMediaGallery = ({ collection }: CollectionMediaGalleryProps) => { + const { t } = useTranslation(); + const { state } = useLocation(); + const [curr, setCurr] = useState(state?.curr || 0); + + const { mutate: postCollectionDetailsMutation } = usePostCollectionDetails(); + + const collectionDetail = collection?.collection_detail || { + media: [], + thumbnail_id: '', + icon_id: '', + banner_id: '' + }; + + const media = getMedia( + collectionDetail.media || [], + collectionDetail.thumbnail_id || null, + collectionDetail.banner_id || null, + collectionDetail.icon_id || null + ); + + const noMedia = !media.length; + + const handleDeleteCurrent = () => { + const idToDelete = collectionDetail.media[curr]?.id; + + if (!!idToDelete) { + const urlToDelete = idToDelete + ? collectionDetail.media.find(media => media.id === idToDelete)?.url + : ''; + + const isThumbnailToDelete = collectionDetail.thumbnail_id == urlToDelete; + const isIconToDelete = collectionDetail.icon_id == urlToDelete; + const isBannerToDelete = collectionDetail.banner_id == urlToDelete; + + postCollectionDetailsMutation({ + id: collection.id!, + payload: { + media: { delete: [idToDelete] }, + thumbnail: isThumbnailToDelete ? '' : collectionDetail.thumbnail_id, + icon: isIconToDelete ? '' : collectionDetail.icon_id, + banner: isBannerToDelete ? '' : collectionDetail.banner_id + } + }); + setCurr(0); + } + }; + + const handleDownloadCurrent = () => { + const a = document.createElement('a') as HTMLAnchorElement & { + download: string; + }; + + a.href = media[curr].url; + a.download = 'image'; + a.target = '_blank'; + + a.click(); + }; + + const next = useCallback(() => { + setCurr(prev => (prev + 1) % media.length); + }, [media]); + + const prev = useCallback(() => { + setCurr(prev => (prev - 1 + media.length) % media.length); + }, [media]); + + const goTo = useCallback((index: number) => { + setCurr(index); + }, []); + + return ( +
+ +
+ + + {t('products.media.deleteImageLabel')} + + + + {t('products.media.downloadImageLabel')} + + +
+
+ + + + +
+ ); +}; + +const Canvas = ({ media, curr }: { media: Media[]; curr: number }) => { + const { t } = useTranslation(); + + if (media.length === 0) { + return ( +
+
+ + {t('products.media.emptyState.header')} + + + {t('products.media.emptyState.description')} + +
+ +
+ ); + } + + return ( +
+
+
+
+ {media[curr].isThumbnail && ( + + + + )} + {media[curr].isBanner && ( + + + + )} +
+ +
+
+
+ ); +}; + +const MAX_VISIBLE_ITEMS = 8; + +const Preview = ({ + media, + curr, + prev, + next, + goTo +}: { + media: Media[]; + curr: number; + prev: () => void; + next: () => void; + goTo: (index: number) => void; +}) => { + if (!media.length) { + return null; + } + + const getVisibleItems = (media: Media[], index: number) => { + if (media.length <= MAX_VISIBLE_ITEMS) { + return media; + } + + const half = Math.floor(MAX_VISIBLE_ITEMS / 2); + const start = (index - half + media.length) % media.length; + const end = (start + MAX_VISIBLE_ITEMS) % media.length; + + if (end < start) { + return [...media.slice(start), ...media.slice(0, end)]; + } else { + return media.slice(start, end); + } + }; + + const visibleItems = getVisibleItems(media, curr); + + return ( +
+ + + +
+ {visibleItems.map(item => { + const isCurrentImage = item.id === media[curr].id; + const originalIndex = media.findIndex(i => i.id === item.id); + + return ( + + ); + })} +
+ + + +
+ ); +}; + +type Media = { + id: string; + url: string; + isThumbnail?: boolean; + isBanner?: boolean; +}; + +const getMedia = ( + images: CollectionDetail['media'], + thumbnail: string | null, + banner: string | null, + icon: string | null +) => { + const media: Media[] = + images?.map(image => ({ + id: image.id, + url: image.url, + isThumbnail: image.url === thumbnail, + isBanner: image.url === banner + })) || []; + + if (thumbnail && !media.some(mediaItem => mediaItem.isThumbnail)) { + media.unshift({ + id: 'thumbnail_only', + url: thumbnail, + isThumbnail: true + }); + } + + if (banner && !media.some(mediaItem => mediaItem.isBanner)) { + media.unshift({ + id: 'banner_only', + url: banner, + isBanner: true + }); + } + + return media.filter(mediaItem => mediaItem.url !== icon); +}; diff --git a/src/routes/collections/collection-media/components/collection-media-gallery/index.ts b/src/routes/collections/collection-media/components/collection-media-gallery/index.ts new file mode 100644 index 00000000..7c9ac947 --- /dev/null +++ b/src/routes/collections/collection-media/components/collection-media-gallery/index.ts @@ -0,0 +1 @@ +export * from "./collection-media-gallery" diff --git a/src/routes/collections/collection-media/components/collection-media-view/collection-media-view-context.tsx b/src/routes/collections/collection-media/components/collection-media-view/collection-media-view-context.tsx new file mode 100644 index 00000000..3ff62acc --- /dev/null +++ b/src/routes/collections/collection-media/components/collection-media-view/collection-media-view-context.tsx @@ -0,0 +1,9 @@ +import { createContext } from "react" + +type CollectionMediaViewContextValue = { + goToGallery: () => void + goToEdit: () => void +} + +export const CollectionMediaViewContext = + createContext(null) diff --git a/src/routes/collections/collection-media/components/collection-media-view/collection-media-view.tsx b/src/routes/collections/collection-media/components/collection-media-view/collection-media-view.tsx new file mode 100644 index 00000000..079d8f78 --- /dev/null +++ b/src/routes/collections/collection-media/components/collection-media-view/collection-media-view.tsx @@ -0,0 +1,58 @@ +import type { HttpTypes } from "@medusajs/types" +import { useSearchParams } from "react-router-dom" + +import { CollectionMediaGallery } from "../collection-media-gallery" +import { EditCollectionMediaForm } from "../edit-collection-media-form" +import { CollectionMediaViewContext } from "./collection-media-view-context" + +type CollectionMediaViewProps = { + collection: HttpTypes.AdminCollection +} + +enum View { + GALLERY = "gallery", + EDIT = "edit", +} + +const getView = (searchParams: URLSearchParams) => { + const view = searchParams.get("view") + + + if (view === View.EDIT) { + return View.EDIT + } + + return View.GALLERY +} + +export const CollectionMediaView = ({ collection }: CollectionMediaViewProps) => { + const [searchParams, setSearchParams] = useSearchParams() + const view = getView(searchParams) + const type = searchParams.get("type") + + const handleGoToView = (view: View) => { + return () => { + setSearchParams({ view }) + } + } + + return ( + + {renderView(view, collection, type)} + + ) +} + +const renderView = (view: View, collection: HttpTypes.AdminCollection, type: string | null) => { + switch (view) { + case View.GALLERY: + return + case View.EDIT: + return + } +} diff --git a/src/routes/collections/collection-media/components/collection-media-view/index.ts b/src/routes/collections/collection-media/components/collection-media-view/index.ts new file mode 100644 index 00000000..9c68d209 --- /dev/null +++ b/src/routes/collections/collection-media/components/collection-media-view/index.ts @@ -0,0 +1 @@ +export * from "./collection-media-view" diff --git a/src/routes/collections/collection-media/components/edit-collection-media-form/edit-collection-media-form.tsx b/src/routes/collections/collection-media/components/edit-collection-media-form/edit-collection-media-form.tsx new file mode 100644 index 00000000..cb61db67 --- /dev/null +++ b/src/routes/collections/collection-media/components/edit-collection-media-form/edit-collection-media-form.tsx @@ -0,0 +1,518 @@ +import type { HttpTypes } from "@medusajs/types" +import { + Button, + Checkbox, + clx, + CommandBar, + Tooltip, + toast, +} from "@medusajs/ui" +import { Fragment, useCallback, useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { Link } from "react-router-dom" + +import { + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + DropAnimation, + KeyboardSensor, + PointerSensor, + UniqueIdentifier, + defaultDropAnimationSideEffects, + useSensor, + useSensors, +} from "@dnd-kit/core" +import { + SortableContext, + arrayMove, + rectSortingStrategy, + sortableKeyboardCoordinates, + useSortable, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { zodResolver } from "@hookform/resolvers/zod" +import { useFieldArray, useForm } from "react-hook-form" + +import { ThumbnailBadge } from "@medusajs/icons" + +import { BannerIcon } from "@assets/icons/BannerIcon" +import { RouteFocusModal, useRouteModal } from "@components/modals" +import { KeyboundForm } from "@components/utilities/keybound-form" +import { usePostCollectionDetails } from "@hooks/api" +import { sdk } from "@lib/client" +import { CollectionDetail } from "@routes/collections/collection-detail/types" +import { UploadMediaFormItem } from "@routes/products/common/components/upload-media-form-item" +import { EditCollectionMediaSchema } from "../../constants" +import { EditCollectionMediaSchemaType } from "../../types" + +type EditCollectionMediaFormProps = { + collection: HttpTypes.AdminCollection & { + collection_detail?: CollectionDetail + } + type: string | null +} + +type MediaField = EditCollectionMediaSchemaType["media"][number] & { + field_id: string + isBanner?: boolean +} + +const dropAnimationConfig: DropAnimation = { + sideEffects: defaultDropAnimationSideEffects({ + styles: { + active: { + opacity: "0.4", + }, + }, + }), +} + +export const EditCollectionMediaForm = ({ + collection, + type, +}: EditCollectionMediaFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const [activeId, setActiveId] = useState(null) + const [selection, setSelection] = useState>({}) + const [mediaToDelete, setMediaToDelete] = useState([]) + + const isIconType = type === "icon" + + const { mutateAsync: postCollectionDetailsMutation, isPending } = + usePostCollectionDetails() + + const thumbnailId = collection.collection_detail?.thumbnail_id ?? null + const bannerId = collection.collection_detail?.banner_id ?? null + const iconId = collection.collection_detail?.icon_id ?? null + + const iconMedia = useMemo(() => { + if (!iconId) { + return undefined + } + + return collection.collection_detail?.media?.find((m) => m.url === iconId) + }, [collection.collection_detail?.media, iconId]) + + const defaultMedia: EditCollectionMediaSchemaType["media"] = useMemo(() => { + if (isIconType) { + return iconMedia + ? [ + { + id: iconMedia.id, + url: iconMedia.url, + isThumbnail: false, + file: null, + }, + ] + : [] + } + + return (collection.collection_detail?.media ?? []) + .filter((m) => m.url !== iconId) + .map((m) => ({ + id: m.id, + url: m.url, + isThumbnail: m.url === thumbnailId, + file: null, + })) + }, [bannerId, collection.collection_detail?.media, iconId, iconMedia, isIconType, thumbnailId]) + + const form = useForm({ + defaultValues: { + media: defaultMedia, + }, + resolver: zodResolver(EditCollectionMediaSchema), + }) + + const { fields, append, remove } = useFieldArray({ + name: "media", + control: form.control, + keyName: "field_id", + }) + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + const handleDragStart = (event: DragStartEvent) => { + setActiveId(event.active.id) + } + + const handleDragEnd = (event: DragEndEvent) => { + setActiveId(null) + const { active, over } = event + + if (active.id !== over?.id) { + const oldIndex = fields.findIndex((item) => item.field_id === active.id) + const newIndex = fields.findIndex((item) => item.field_id === over?.id) + + form.setValue("media", arrayMove(fields, oldIndex, newIndex), { + shouldDirty: true, + shouldTouch: true, + }) + } + } + + const handleDragCancel = () => { + setActiveId(null) + } + + const handleCheckedChange = useCallback( + (id: string) => { + return (val: boolean) => { + if (!val) { + const { [id]: _, ...rest } = selection + setSelection(rest) + } else { + setSelection((prev) => ({ ...prev, [id]: true })) + } + } + }, + [selection] + ) + + const handleDelete = () => { + const ids = Object.keys(selection) + const indices = ids.map((id) => fields.findIndex((m) => m.id === id)) + + setMediaToDelete(ids) + remove(indices) + setSelection({}) + } + + const handlePromoteToThumbnail = () => { + const id = Object.keys(selection)[0] + const url = collection.collection_detail?.media?.find((m) => m.id === id)?.url + + if (!url) { + return + } + + postCollectionDetailsMutation({ + id: collection.id, + payload: { media: { delete: [], create: [] }, thumbnail: url }, + }) + setSelection({}) + } + + const handlePromoteToBanner = () => { + const id = Object.keys(selection)[0] + const url = collection.collection_detail?.media?.find((m) => m.id === id)?.url + + if (!url) { + return + } + + postCollectionDetailsMutation({ + id: collection.id, + payload: { media: { delete: [], create: [] }, banner: url }, + }) + setSelection({}) + } + + const selectionCount = Object.keys(selection).length + + const handleSubmit = form.handleSubmit(async ({ media }) => { + const filesToUpload = !isIconType + ? media + .map((m, i) => ({ file: m.file, index: i })) + .filter((m) => !!m.file) + : [ + { + file: [...media].reverse().find((m) => !!m.file)?.file, + index: 0, + }, + ].filter((m) => !!m.file) + + let uploaded: HttpTypes.AdminFile[] = [] + + if (filesToUpload.length) { + const { files: uploads } = await sdk.admin.upload + .create({ files: filesToUpload.map((m) => m.file) }) + .catch(() => { + form.setError("media", { + type: "invalid_file", + message: t("products.media.failedToUpload"), + }) + return { files: [] } + }) + + uploaded = uploads + } + + const mediaToCreate = uploaded.map((item) => ({ url: item.url, alt_text: "" })) + + if (isIconType) { + const deleteIds = iconMedia?.id ? [iconMedia.id] : [] + await postCollectionDetailsMutation( + { + id: collection.id, + payload: { + media: { delete: deleteIds, create: mediaToCreate }, + icon: mediaToCreate[0]?.url ?? null, + }, + }, + { + onSuccess: () => { + toast.success(t("products.media.successToast")) + handleSuccess(`/collections/${collection.id}`) + }, + onError: (error: any) => { + toast.error(error.message) + }, + } as any + ) + return + } + + await postCollectionDetailsMutation( + { + id: collection.id, + payload: { + media: { delete: mediaToDelete, create: mediaToCreate }, + }, + }, + { + onSuccess: () => { + toast.success(t("products.media.successToast")) + handleSuccess(`/collections/${collection.id}`) + }, + onError: (error: any) => { + toast.error(error.message) + }, + } as any + ) + }) + + return ( + + + +
+ +
+
+ + +
+ +
+
+ m.field_id)} strategy={rectSortingStrategy}> + {fields.map((m) => { + const isBanner = !!bannerId && m.id === bannerId + + return ( + + ) + })} + + + + {activeId ? ( + m.field_id === activeId)! as any), + isBanner: !!bannerId && fields.find((m) => m.field_id === activeId)?.id === bannerId, + }} + checked={ + !!selection[fields.find((m) => m.field_id === activeId)!.id!] + } + /> + ) : null} + +
+
+
+ +
+ +
+
+
+ + + + + {t("general.countSelected", { count: selectionCount })} + + + {!isIconType && selectionCount === 1 && ( + <> + + + + + + + + + + )} + + + + + +
+ + + + +
+
+
+
+ ) +} + +const MediaGridItem = ({ + media, + checked, + onCheckedChange, +}: { + media: MediaField + checked: boolean + onCheckedChange: (value: boolean) => void +}) => { + const { t } = useTranslation() + + const handleToggle = useCallback( + (value: boolean) => { + onCheckedChange(value) + }, + [onCheckedChange] + ) + + const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = + useSortable({ id: media.field_id }) + + const style = { + opacity: isDragging ? 0.4 : undefined, + transform: CSS.Transform.toString(transform), + transition, + } + + return ( +
+ {(media.isThumbnail || media.isBanner) && ( +
+ {media.isThumbnail && ( + + + + )} + {media.isBanner && ( + + + + )} +
+ )} + +
+ +
+ { + e.stopPropagation() + }} + checked={checked} + onCheckedChange={handleToggle} + /> +
+ + +
+ ) +} + +export const MediaGridItemOverlay = ({ + media, + checked, +}: { + media: MediaField + checked: boolean +}) => { + return ( +
+ {(media.isThumbnail || media.isBanner) && ( +
+ {media.isThumbnail && } + {media.isBanner && } +
+ )} + +
+ +
+ + +
+ ) +} + diff --git a/src/routes/collections/collection-media/components/edit-collection-media-form/index.ts b/src/routes/collections/collection-media/components/edit-collection-media-form/index.ts new file mode 100644 index 00000000..7c8b50e7 --- /dev/null +++ b/src/routes/collections/collection-media/components/edit-collection-media-form/index.ts @@ -0,0 +1 @@ +export { EditCollectionMediaForm } from "./edit-collection-media-form" diff --git a/src/routes/collections/collection-media/constants.ts b/src/routes/collections/collection-media/constants.ts new file mode 100644 index 00000000..103c0181 --- /dev/null +++ b/src/routes/collections/collection-media/constants.ts @@ -0,0 +1,6 @@ +import { z } from "zod" +import { MediaSchema } from "@routes/products/product-create/constants" + +export const EditCollectionMediaSchema = z.object({ + media: z.array(MediaSchema), + }) \ No newline at end of file diff --git a/src/routes/collections/collection-media/index.ts b/src/routes/collections/collection-media/index.ts new file mode 100644 index 00000000..e406335b --- /dev/null +++ b/src/routes/collections/collection-media/index.ts @@ -0,0 +1 @@ +export { CollectionMedia as Component } from "./collection-media" diff --git a/src/routes/collections/collection-media/types.ts b/src/routes/collections/collection-media/types.ts new file mode 100644 index 00000000..6e531a47 --- /dev/null +++ b/src/routes/collections/collection-media/types.ts @@ -0,0 +1,5 @@ +import { z } from "zod" +import { EditCollectionMediaSchema } from "@routes/collections/collection-media/constants" + + +export type EditCollectionMediaSchemaType = z.infer diff --git a/src/routes/collections/common/components/collection-tree/collection-tree.tsx b/src/routes/collections/common/components/collection-tree/collection-tree.tsx new file mode 100644 index 00000000..19adfd3b --- /dev/null +++ b/src/routes/collections/common/components/collection-tree/collection-tree.tsx @@ -0,0 +1,53 @@ +import { UniqueIdentifier } from '@dnd-kit/core'; +import { ReactNode } from 'react'; +import { SortableTree } from '@components/common/sortable-tree'; +import { CollectionTreeItem } from '../../types'; + +export const CollectionTree = ({ + value, + isLoading = false, + onChange, + enableDrag = true, + renderValue +}: { + value: CollectionTreeItem[]; + isLoading: boolean; + onChange: ( + value: { + id: UniqueIdentifier; + parentId: UniqueIdentifier | null; + index: number; + }, + items: CollectionTreeItem[] + ) => void; + enableDrag?: boolean | ((item: CollectionTreeItem) => boolean); + renderValue: (item: CollectionTreeItem) => ReactNode; +}) => { + if (isLoading) { + return ( +
+ {Array.from({ length: 10 }).map((_, i) => ( + + ))} +
+ ); + } + + return ( + + ); +}; + +const CollectionLeafPlaceholder = () => { + return ( +
+ ); +}; diff --git a/src/routes/collections/common/types.ts b/src/routes/collections/common/types.ts new file mode 100644 index 00000000..8e22a47e --- /dev/null +++ b/src/routes/collections/common/types.ts @@ -0,0 +1,5 @@ +export type CollectionTreeItem = { + id: string + title: string + rank: number | null +} \ No newline at end of file diff --git a/src/routes/collections/common/utils.ts b/src/routes/collections/common/utils.ts new file mode 100644 index 00000000..0b14d7e0 --- /dev/null +++ b/src/routes/collections/common/utils.ts @@ -0,0 +1,20 @@ +import { CollectionTreeItem } from './types'; + +export const insertCollectionTreeItem = ( + collections: CollectionTreeItem[], + newItem: CollectionTreeItem +): CollectionTreeItem[] => { + const withoutNewItem = collections.filter((c) => c.id !== newItem.id); + + const targetIndexRaw = newItem.rank ?? 0; + const targetIndex = Math.max(0, Math.min(targetIndexRaw, withoutNewItem.length)); + + const next = [...withoutNewItem]; + next.splice(targetIndex, 0, newItem); + + next.forEach((item, index) => { + item.rank = index; + }); + + return next; +}; diff --git a/src/routes/products/common/components/upload-media-form-item/upload-media-form-item.tsx b/src/routes/products/common/components/upload-media-form-item/upload-media-form-item.tsx index 2d23cb8f..f676c87a 100644 --- a/src/routes/products/common/components/upload-media-form-item/upload-media-form-item.tsx +++ b/src/routes/products/common/components/upload-media-form-item/upload-media-form-item.tsx @@ -37,12 +37,14 @@ export const UploadMediaFormItem = ({ form, append, showHint = true, + multiple = true, }: { form: | UseFormReturn | UseFormReturn append: (value: Media) => void - showHint?: boolean + showHint?: boolean, + multiple?: boolean }) => { const { t } = useTranslation() @@ -104,6 +106,7 @@ export const UploadMediaFormItem = ({ hasError={!!form.formState.errors.media} formats={SUPPORTED_FORMATS} onUploaded={onUploaded} + multiple={multiple} /> From f3df4150a1cde45c13297da19145b3a19a521a99 Mon Sep 17 00:00:00 2001 From: pfulara Date: Thu, 19 Feb 2026 13:38:39 +0100 Subject: [PATCH 6/8] remove unused variables --- src/hooks/api/collections.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/api/collections.tsx b/src/hooks/api/collections.tsx index 0ce53729..24481d32 100644 --- a/src/hooks/api/collections.tsx +++ b/src/hooks/api/collections.tsx @@ -84,7 +84,7 @@ export const usePostCollectionDetails = () => { method: 'POST', body: payload }), - onSuccess: (data, variables, text) => { + onSuccess: (_, variables) => { queryClient.invalidateQueries({ queryKey: collectionsQueryKeys.detail(variables.id) }); } }); From b5e9ef4a6e918c15d94c791fce420c427a57db18 Mon Sep 17 00:00:00 2001 From: pfulara Date: Fri, 20 Feb 2026 14:00:32 +0100 Subject: [PATCH 7/8] FE changes for collections media --- src/hooks/api/collections.tsx | 11 +- .../category-media-section.tsx | 167 ++++++++++++++++++ .../category-media-section/index.ts | 1 + .../create-collection-modal.tsx | 37 ++-- .../collection-media-section.tsx | 9 +- .../collections/collection-detail/loader.ts | 3 - .../collection-list-table.tsx | 2 +- .../collection-media-gallery.tsx | 6 +- .../edit-collection-media-form.tsx | 36 ++-- 9 files changed, 217 insertions(+), 55 deletions(-) create mode 100644 src/routes/categories/category-detail/components/category-media-section/category-media-section.tsx create mode 100644 src/routes/categories/category-detail/components/category-media-section/index.ts diff --git a/src/hooks/api/collections.tsx b/src/hooks/api/collections.tsx index 24481d32..be45d0cb 100644 --- a/src/hooks/api/collections.tsx +++ b/src/hooks/api/collections.tsx @@ -42,9 +42,6 @@ const retrieveCollectionWithDetails = async (id: string) => { collection_detail: AdminCollectionDetail; }>(`/admin/collections/${id}/details`, { method: 'GET', - query: { - fields: 'collection_detail.*,collection_detail.media.*' - } }); return { @@ -72,10 +69,10 @@ export const usePostCollectionDetails = () => { id: string; payload: { media: { delete?: string[]; create?: { url: string; alt_text?: string }[] }; - thumbnail?: string | null; - icon?: string | null; - banner?: string | null; - rank?: number; + thumbnail?: { url: string} | string | null; + icon?: { url: string} | string |null; + banner?: { url: string} | string| null; + rank?: number | null; }; }) => sdk.client.fetch<{ diff --git a/src/routes/categories/category-detail/components/category-media-section/category-media-section.tsx b/src/routes/categories/category-detail/components/category-media-section/category-media-section.tsx new file mode 100644 index 00000000..3606d44a --- /dev/null +++ b/src/routes/categories/category-detail/components/category-media-section/category-media-section.tsx @@ -0,0 +1,167 @@ +import { BannerIcon } from "@assets/icons/BannerIcon" +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 { CategoryDetail } from "@routes/categories/common/types" +import { useTranslation } from "react-i18next" +import { Link } from "react-router-dom" + + +type CategoryMediaSectionProps = { + category: HttpTypes.AdminProductCategory & { category_detail?: CategoryDetail } + } + +export const CategoryMediaSection = ({ category }: CategoryMediaSectionProps) => { + const { t } = useTranslation() + console.log({ category }) + + const iconId = category?.category_detail?.icon_id ?? "" + 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.url === thumbnailId + const isBanner = !!bannerId && i.url === bannerId + + return ( +
+
+ {isThumbnail && ( +
+ + + +
+ )} + {isBanner && ( +
+ + + +
+ )} +
+ + + {i.alt_text + +
+ ) + })} +
+ ) : ( +
+
+ + {t("products.media.emptyState.header")} + + + Add media to showcase it on the storefront. + +
+
+ )} +
+ +
+ Icon + , + }, + ], + }, + ]} + data-testid="collection-media-action-menu" + /> +
+ {iconId ? ( +
+
+ Collection icon +
+
+ ) : ( +
+
+ + No icon yet + + + Add icon to showcase it near the collection label on the storefront. + +
+
+ )} +
+ + ) +} \ No newline at end of file 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..d269844e --- /dev/null +++ b/src/routes/categories/category-detail/components/category-media-section/index.ts @@ -0,0 +1 @@ +export * from "./category-media-section" \ No newline at end of file diff --git a/src/routes/collections/collection-create/components/create-collelction-modal/create-collection-modal.tsx b/src/routes/collections/collection-create/components/create-collelction-modal/create-collection-modal.tsx index 42bcd4ce..c7195965 100644 --- a/src/routes/collections/collection-create/components/create-collelction-modal/create-collection-modal.tsx +++ b/src/routes/collections/collection-create/components/create-collelction-modal/create-collection-modal.tsx @@ -88,13 +88,6 @@ export const CreateCollectionModal = () => { { title, handle }, { onSuccess: async ({ collection }) => { - if (rank !== null) { - await postCollectionDetailsMutation({ - id: collection.id, - payload: { media: { create: [], delete: [] }, rank } - }); - } - if (media.length > 0) { const { files: uploads } = await sdk.admin.upload .create({ files: media.map(m => m.file) as unknown as File[] }) @@ -108,27 +101,35 @@ export const CreateCollectionModal = () => { })); const thumbnailIndex = media.findIndex(m => !!m.isThumbnail); - const bannerIndex = media.findIndex(m => !!m.isBanner); + const bannerIndex = media.find(m => !!m.isBanner) ? media.findIndex(m => !!m.isBanner) : undefined; const thumbnail = thumbnailIndex >= 0 - ? mediaToCreate[thumbnailIndex]?.url - : mediaToCreate[0]?.url; - const banner = bannerIndex >= 0 ? mediaToCreate[bannerIndex]?.url : undefined; + ? { 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 postCollectionDetailsMutation({ id: collection.id, payload: { - media: { delete: [], create: mediaToCreate }, - ...(thumbnail ? { thumbnail } : {}), - ...(banner ? { banner } : {}) + media: { delete: [], create }, + rank: rank ?? 0, + thumbnail, + banner } }); } if (icon?.length) { - let uploadedIcon: HttpTypes.AdminFile[] = []; - const { files: uploads } = await sdk.admin.upload + const { files: uploadedIcon } = await sdk.admin.upload .create({ files: icon.map(i => i.file) as unknown as File[] }) @@ -139,12 +140,12 @@ export const CreateCollectionModal = () => { }); return { files: [] }; }); - uploadedIcon = uploads; await postCollectionDetailsMutation({ id: collection.id, payload: { media: { delete: [], create: [] }, - icon: uploadedIcon[0]?.url + rank: rank ?? 0, + icon: {url: uploadedIcon[0]?.url} } }); } diff --git a/src/routes/collections/collection-detail/components/collection-media-section/collection-media-section.tsx b/src/routes/collections/collection-detail/components/collection-media-section/collection-media-section.tsx index 90c2b5c0..d52ba2be 100644 --- a/src/routes/collections/collection-detail/components/collection-media-section/collection-media-section.tsx +++ b/src/routes/collections/collection-detail/components/collection-media-section/collection-media-section.tsx @@ -19,6 +19,7 @@ export const CollectionMediaSection = ({ collection }: CollectionMediaSectionPro const { t } = useTranslation() const iconId = collection.collection_detail?.icon_id + const iconUrl = collection.collection_detail?.media.find((m) => m.id === iconId)?.url const thumbnailId = collection.collection_detail?.thumbnail_id const bannerId = collection.collection_detail?.banner_id const baseMedia = collection.collection_detail?.media.filter(item => item.url !== collection.collection_detail?.icon_id) ?? [] @@ -53,8 +54,8 @@ export const CollectionMediaSection = ({ collection }: CollectionMediaSectionPro data-testid="collection-media-grid" > {media.map((i, index) => { - const isThumbnail = !!thumbnailId && i.url === thumbnailId - const isBanner = !!bannerId && i.url === bannerId + const isThumbnail = !!thumbnailId && i.id === thumbnailId + const isBanner = !!bannerId && i.id === bannerId return (
- {iconId ? ( + {iconUrl ? (
Collection icon ({ collection_detail: AdminCollectionDetail }>(`/admin/collections/${id}/details`, { method: "GET", - query: { - fields: "collection_detail.*,collection_detail.media.*", - }, }) return { diff --git a/src/routes/collections/collection-list/components/collection-list-table/collection-list-table.tsx b/src/routes/collections/collection-list/components/collection-list-table/collection-list-table.tsx index c05f5707..23f8a927 100644 --- a/src/routes/collections/collection-list/components/collection-list-table/collection-list-table.tsx +++ b/src/routes/collections/collection-list/components/collection-list-table/collection-list-table.tsx @@ -22,7 +22,7 @@ export const CollectionListTable = () => { const { collections, count, isError, error, isLoading } = useCollections( { ...searchParams, - fields: "+products.id,collection_detail.*,collection_detail.media.*", + fields: "+products.id", }, { placeholderData: keepPreviousData, diff --git a/src/routes/collections/collection-media/components/collection-media-gallery/collection-media-gallery.tsx b/src/routes/collections/collection-media/components/collection-media-gallery/collection-media-gallery.tsx index 4a71dfc5..f4131abb 100644 --- a/src/routes/collections/collection-media/components/collection-media-gallery/collection-media-gallery.tsx +++ b/src/routes/collections/collection-media/components/collection-media-gallery/collection-media-gallery.tsx @@ -263,8 +263,8 @@ const getMedia = ( images?.map(image => ({ id: image.id, url: image.url, - isThumbnail: image.url === thumbnail, - isBanner: image.url === banner + isThumbnail: image.id === thumbnail, + isBanner: image.id === banner })) || []; if (thumbnail && !media.some(mediaItem => mediaItem.isThumbnail)) { @@ -283,5 +283,5 @@ const getMedia = ( }); } - return media.filter(mediaItem => mediaItem.url !== icon); + return media.filter(mediaItem => mediaItem.id !== icon); }; diff --git a/src/routes/collections/collection-media/components/edit-collection-media-form/edit-collection-media-form.tsx b/src/routes/collections/collection-media/components/edit-collection-media-form/edit-collection-media-form.tsx index cb61db67..c9c5390f 100644 --- a/src/routes/collections/collection-media/components/edit-collection-media-form/edit-collection-media-form.tsx +++ b/src/routes/collections/collection-media/components/edit-collection-media-form/edit-collection-media-form.tsx @@ -94,7 +94,7 @@ export const EditCollectionMediaForm = ({ return undefined } - return collection.collection_detail?.media?.find((m) => m.url === iconId) + return collection.collection_detail?.media?.find((m) => m.id === iconId) }, [collection.collection_detail?.media, iconId]) const defaultMedia: EditCollectionMediaSchemaType["media"] = useMemo(() => { @@ -112,11 +112,11 @@ export const EditCollectionMediaForm = ({ } return (collection.collection_detail?.media ?? []) - .filter((m) => m.url !== iconId) + .filter((m) => m.id !== iconId) .map((m) => ({ id: m.id, url: m.url, - isThumbnail: m.url === thumbnailId, + isThumbnail: m.id === thumbnailId, file: null, })) }, [bannerId, collection.collection_detail?.media, iconId, iconMedia, isIconType, thumbnailId]) @@ -189,30 +189,20 @@ export const EditCollectionMediaForm = ({ const handlePromoteToThumbnail = () => { const id = Object.keys(selection)[0] - const url = collection.collection_detail?.media?.find((m) => m.id === id)?.url - - if (!url) { - return - } postCollectionDetailsMutation({ id: collection.id, - payload: { media: { delete: [], create: [] }, thumbnail: url }, + payload: { media: { delete: [], create: [] }, thumbnail: id }, }) setSelection({}) } const handlePromoteToBanner = () => { const id = Object.keys(selection)[0] - const url = collection.collection_detail?.media?.find((m) => m.id === id)?.url - - if (!url) { - return - } postCollectionDetailsMutation({ id: collection.id, - payload: { media: { delete: [], create: [] }, banner: url }, + payload: { media: { delete: [], create: [] }, banner: id }, }) setSelection({}) } @@ -251,13 +241,21 @@ export const EditCollectionMediaForm = ({ if (isIconType) { const deleteIds = iconMedia?.id ? [iconMedia.id] : [] + let payload = { + media: { delete: deleteIds, create: [] }, + } as { + media: { delete: string[]; create: { url: string; alt_text?: string }[] }; + icon?: { url: string} | string |null; + }; + + if (mediaToCreate[0]?.url) { + payload = { ...payload, icon: { url: mediaToCreate[0]?.url } } + } + await postCollectionDetailsMutation( { id: collection.id, - payload: { - media: { delete: deleteIds, create: mediaToCreate }, - icon: mediaToCreate[0]?.url ?? null, - }, + payload, }, { onSuccess: () => { From 515336598b1027e20d8f1348650832156a2375d7 Mon Sep 17 00:00:00 2001 From: pfulara Date: Fri, 20 Feb 2026 14:00:50 +0100 Subject: [PATCH 8/8] FE changes for collections media --- .../create-collelction-modal/create-collection-modal.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/routes/collections/collection-create/components/create-collelction-modal/create-collection-modal.tsx b/src/routes/collections/collection-create/components/create-collelction-modal/create-collection-modal.tsx index c7195965..2820aa87 100644 --- a/src/routes/collections/collection-create/components/create-collelction-modal/create-collection-modal.tsx +++ b/src/routes/collections/collection-create/components/create-collelction-modal/create-collection-modal.tsx @@ -11,7 +11,6 @@ import zod from 'zod'; import { CreateCollectionForm } from '../create-collection-form'; import { useCreateCollection, usePostCollectionDetails } from '@hooks/api'; import { CreateCollectionRank } from '../create-collection-rank/create-collection-rank'; -import { HttpTypes } from '@medusajs/types'; import { sdk } from '@lib/client'; export const CreateCollectionSchema = zod.object({