diff --git a/.changeset/shaggy-parents-guess.md b/.changeset/shaggy-parents-guess.md new file mode 100644 index 0000000000000..d168314a81594 --- /dev/null +++ b/.changeset/shaggy-parents-guess.md @@ -0,0 +1,6 @@ +--- +"@medusajs/admin-shared": minor +"@medusajs/dashboard": minor +--- + +feat(dashboard,admin-shared): product option redesign (client-side) diff --git a/.changeset/tall-guests-change.md b/.changeset/tall-guests-change.md new file mode 100644 index 0000000000000..4d89b32c2d169 --- /dev/null +++ b/.changeset/tall-guests-change.md @@ -0,0 +1,5 @@ +--- +"@medusajs/js-sdk": minor +--- + +feat(js-sdk): product option redesign (client-side) diff --git a/packages/admin/admin-shared/src/extensions/widgets/constants.ts b/packages/admin/admin-shared/src/extensions/widgets/constants.ts index eab257487e85b..453c782bc405f 100644 --- a/packages/admin/admin-shared/src/extensions/widgets/constants.ts +++ b/packages/admin/admin-shared/src/extensions/widgets/constants.ts @@ -55,6 +55,15 @@ const PRODUCT_CATEGORY_INJECTION_ZONES = [ "product_category.list.after", ] as const +const PRODUCT_OPTION_INJECTION_ZONES = [ + "product_option.details.before", + "product_option.details.after", + "product_option.details.side.before", + "product_option.details.side.after", + "product_option.list.before", + "product_option.list.after", +] as const + const SHIPPING_OPTION_TYPE_INJECTION_ZONES = [ "shipping_option_type.details.before", "shipping_option_type.details.after", @@ -216,6 +225,7 @@ export const INJECTION_ZONES = [ ...PRODUCT_COLLECTION_INJECTION_ZONES, ...PRODUCT_CATEGORY_INJECTION_ZONES, ...PRODUCT_TYPE_INJECTION_ZONES, + ...PRODUCT_OPTION_INJECTION_ZONES, ...SHIPPING_OPTION_TYPE_INJECTION_ZONES, ...PRODUCT_TAG_INJECTION_ZONES, ...PRICE_LIST_INJECTION_ZONES, diff --git a/packages/admin/dashboard/src/components/layout/main-layout/main-layout.tsx b/packages/admin/dashboard/src/components/layout/main-layout/main-layout.tsx index 01aa7b07019e0..20e13af708af0 100644 --- a/packages/admin/dashboard/src/components/layout/main-layout/main-layout.tsx +++ b/packages/admin/dashboard/src/components/layout/main-layout/main-layout.tsx @@ -1,6 +1,6 @@ import { - BuildingStorefront, Buildings, + BuildingStorefront, ChevronDownMini, CogSixTooth, CurrencyDollar, @@ -14,7 +14,7 @@ import { Tag, Users, } from "@medusajs/icons" -import { Avatar, Divider, DropdownMenu, Text, clx } from "@medusajs/ui" +import { Avatar, clx, Divider, DropdownMenu, Text } from "@medusajs/ui" import { Collapsible as RadixCollapsible } from "radix-ui" import { useTranslation } from "react-i18next" @@ -107,8 +107,7 @@ const Header = () => { return (
- + [] => { label: t("categories.domain"), to: "/categories", }, + { + label: t("productOptions.domain"), + to: "/product-options", + }, // TODO: Enable when domin is introduced // { // label: t("giftCards.domain"), diff --git a/packages/admin/dashboard/src/dashboard-app/routes/get-route.map.tsx b/packages/admin/dashboard/src/dashboard-app/routes/get-route.map.tsx index 28e591fa7179d..77d7aa74433e8 100644 --- a/packages/admin/dashboard/src/dashboard-app/routes/get-route.map.tsx +++ b/packages/admin/dashboard/src/dashboard-app/routes/get-route.map.tsx @@ -136,18 +136,6 @@ export function getRouteMap({ lazy: () => import("../../routes/products/product-prices"), }, - { - path: "options/create", - lazy: () => - import( - "../../routes/products/product-create-option" - ), - }, - { - path: "options/:option_id/edit", - lazy: () => - import("../../routes/products/product-edit-option"), - }, { path: "variants/create", lazy: () => @@ -165,6 +153,13 @@ export function getRouteMap({ lazy: () => import("../../routes/products/product-metadata"), }, + { + path: "options/manage", + lazy: () => + import( + "../../routes/products/product-options-manage" + ), + }, ], }, { @@ -225,6 +220,63 @@ export function getRouteMap({ }, ], }, + { + path: "/product-options", + errorElement: , + handle: { + breadcrumb: () => t("productOptions.domain"), + }, + children: [ + { + path: "", + lazy: () => + import("../../routes/product-options/product-option-list"), + children: [ + { + path: "create", + lazy: () => + import( + "../../routes/product-options/product-option-create" + ), + }, + ], + }, + { + path: ":id", + lazy: async () => { + const { Component, Breadcrumb, loader } = await import( + "../../routes/product-options/product-option-detail" + ) + + return { + Component, + loader, + handle: { + breadcrumb: (match: UIMatch) => ( + + ), + }, + } + }, + children: [ + { + path: "edit", + lazy: () => + import( + "../../routes/product-options/product-option-edit" + ), + }, + { + path: "metadata/edit", + lazy: () => + import( + "../../routes/product-options/product-option-metadata" + ), + }, + ], + }, + ], + }, { path: "/categories", errorElement: , diff --git a/packages/admin/dashboard/src/hooks/api/index.ts b/packages/admin/dashboard/src/hooks/api/index.ts index aed6edadeb95c..c94fec5f113ea 100644 --- a/packages/admin/dashboard/src/hooks/api/index.ts +++ b/packages/admin/dashboard/src/hooks/api/index.ts @@ -17,6 +17,7 @@ export * from "./payment-collections" export * from "./payments" export * from "./plugins" export * from "./price-lists" +export * from "./product-options" export * from "./product-types" export * from "./product-variants" export * from "./products" diff --git a/packages/admin/dashboard/src/hooks/api/product-options.tsx b/packages/admin/dashboard/src/hooks/api/product-options.tsx new file mode 100644 index 0000000000000..da96183eb4fde --- /dev/null +++ b/packages/admin/dashboard/src/hooks/api/product-options.tsx @@ -0,0 +1,151 @@ +import { FetchError } from "@medusajs/js-sdk" +import { + QueryKey, + useMutation, + UseMutationOptions, + useQuery, + UseQueryOptions, +} from "@tanstack/react-query" +import { sdk } from "../../lib/client" +import { queryKeysFactory } from "../../lib/query-key-factory" +import { HttpTypes } from "@medusajs/types" +import { queryClient } from "../../lib/query-client.ts" + +const PRODUCT_OPTIONS_QUERY_KEY = "product_options" as const +export const productOptionsQueryKeys = queryKeysFactory( + PRODUCT_OPTIONS_QUERY_KEY +) + +export const useProductOption = ( + id: string, + query?: HttpTypes.AdminProductOptionParams, + options?: Omit< + UseQueryOptions< + HttpTypes.AdminProductOptionResponse, + FetchError, + HttpTypes.AdminProductOptionResponse, + QueryKey + >, + "queryFn" | "queryKey" + > +) => { + const { data, ...rest } = useQuery({ + queryKey: productOptionsQueryKeys.detail(id, query), + queryFn: () => sdk.admin.productOption.retrieve(id, query), + ...options, + }) + + return { ...data, ...rest } +} + +export const useProductOptions = ( + query?: HttpTypes.AdminProductOptionListParams, + options?: Omit< + UseQueryOptions< + HttpTypes.AdminProductOptionListResponse, + FetchError, + HttpTypes.AdminProductOptionListResponse, + QueryKey + >, + "queryFn" | "queryKey" + > +) => { + const { data, ...rest } = useQuery({ + queryFn: () => sdk.admin.productOption.list(query), + queryKey: productOptionsQueryKeys.list(query), + ...options, + }) + + return { ...data, ...rest } +} + +export const useCreateProductOption = ( + options?: UseMutationOptions< + HttpTypes.AdminProductOptionResponse, + FetchError, + HttpTypes.AdminCreateProductOption + > +) => { + return useMutation({ + mutationFn: (payload) => sdk.admin.productOption.create(payload), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ + queryKey: productOptionsQueryKeys.lists(), + }) + + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useUpdateProductOption = ( + id: string, + options?: UseMutationOptions< + HttpTypes.AdminProductOptionResponse, + FetchError, + HttpTypes.AdminUpdateProductOption + > +) => { + return useMutation({ + mutationFn: (payload) => sdk.admin.productOption.update(id, payload), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ + queryKey: productOptionsQueryKeys.lists(), + }) + queryClient.invalidateQueries({ + queryKey: productOptionsQueryKeys.detail(id), + }) + + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useDeleteProductOption = ( + id: string, + options?: UseMutationOptions< + HttpTypes.AdminProductOptionDeleteResponse, + FetchError, + void + > +) => { + return useMutation({ + mutationFn: () => sdk.admin.productOption.delete(id), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ + queryKey: productOptionsQueryKeys.lists(), + }) + queryClient.invalidateQueries({ + queryKey: productOptionsQueryKeys.detail(id), + }) + + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useDeleteProductOptionLazy = ( + options?: UseMutationOptions< + HttpTypes.AdminProductOptionDeleteResponse, + FetchError, + string + > +) => { + return useMutation({ + mutationFn: (id: string) => sdk.admin.productOption.delete(id), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ + queryKey: productOptionsQueryKeys.lists(), + }) + queryClient.invalidateQueries({ + queryKey: productOptionsQueryKeys.details(), + }) + + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/packages/admin/dashboard/src/hooks/api/products.tsx b/packages/admin/dashboard/src/hooks/api/products.tsx index 58282568fc7b4..40fb831e84e75 100644 --- a/packages/admin/dashboard/src/hooks/api/products.tsx +++ b/packages/admin/dashboard/src/hooks/api/products.tsx @@ -11,6 +11,7 @@ import { sdk } from "../../lib/client" import { queryClient } from "../../lib/query-client" import { queryKeysFactory } from "../../lib/query-key-factory" import { inventoryItemsQueryKeys } from "./inventory.tsx" +import { productOptionsQueryKeys } from "./product-options.tsx" const PRODUCTS_QUERY_KEY = "products" as const export const productsQueryKeys = queryKeysFactory(PRODUCTS_QUERY_KEY) @@ -18,72 +19,6 @@ export const productsQueryKeys = queryKeysFactory(PRODUCTS_QUERY_KEY) const VARIANTS_QUERY_KEY = "product_variants" as const export const variantsQueryKeys = queryKeysFactory(VARIANTS_QUERY_KEY) -const OPTIONS_QUERY_KEY = "product_options" as const -export const optionsQueryKeys = queryKeysFactory(OPTIONS_QUERY_KEY) - -export const useCreateProductOption = ( - productId: string, - options?: UseMutationOptions -) => { - return useMutation({ - mutationFn: (payload: HttpTypes.AdminCreateProductOption) => - sdk.admin.product.createOption(productId, payload), - onSuccess: (data: any, variables: any, context: any) => { - queryClient.invalidateQueries({ queryKey: optionsQueryKeys.lists() }) - queryClient.invalidateQueries({ - queryKey: productsQueryKeys.detail(productId), - }) - options?.onSuccess?.(data, variables, context) - }, - ...options, - }) -} - -export const useUpdateProductOption = ( - productId: string, - optionId: string, - options?: UseMutationOptions -) => { - return useMutation({ - mutationFn: (payload: HttpTypes.AdminUpdateProductOption) => - sdk.admin.product.updateOption(productId, optionId, payload), - onSuccess: (data: any, variables: any, context: any) => { - queryClient.invalidateQueries({ queryKey: optionsQueryKeys.lists() }) - queryClient.invalidateQueries({ - queryKey: optionsQueryKeys.detail(optionId), - }) - queryClient.invalidateQueries({ - queryKey: productsQueryKeys.detail(productId), - }) - - options?.onSuccess?.(data, variables, context) - }, - ...options, - }) -} - -export const useDeleteProductOption = ( - productId: string, - optionId: string, - options?: UseMutationOptions -) => { - return useMutation({ - mutationFn: () => sdk.admin.product.deleteOption(productId, optionId), - onSuccess: (data: any, variables: any, context: any) => { - queryClient.invalidateQueries({ queryKey: optionsQueryKeys.lists() }) - queryClient.invalidateQueries({ - queryKey: optionsQueryKeys.detail(optionId), - }) - queryClient.invalidateQueries({ - queryKey: productsQueryKeys.detail(productId), - }) - - options?.onSuccess?.(data, variables, context) - }, - ...options, - }) -} - export const useProductVariant = ( productId: string, variantId: string, @@ -375,6 +310,31 @@ export const useDeleteProduct = ( }) } +export const useLinkProductOptions = ( + productId: string, + options?: UseMutationOptions< + HttpTypes.AdminProductResponse, + FetchError, + HttpTypes.AdminLinkProductOptions + > +) => { + return useMutation({ + mutationFn: (payload) => sdk.admin.product.linkOptions(productId, payload), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: productsQueryKeys.lists() }) + queryClient.invalidateQueries({ + queryKey: productsQueryKeys.detail(productId), + }) + queryClient.invalidateQueries({ + queryKey: productOptionsQueryKeys.lists(), + }) + + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + export const useExportProducts = ( query?: HttpTypes.AdminProductListParams, options?: UseMutationOptions< diff --git a/packages/admin/dashboard/src/hooks/table/columns/use-product-option-table-columns.tsx b/packages/admin/dashboard/src/hooks/table/columns/use-product-option-table-columns.tsx new file mode 100644 index 0000000000000..6551db1ff65ca --- /dev/null +++ b/packages/admin/dashboard/src/hooks/table/columns/use-product-option-table-columns.tsx @@ -0,0 +1,44 @@ +import { createColumnHelper } from "@tanstack/react-table" +import { useMemo } from "react" +import { useTranslation } from "react-i18next" +import { TextCell } from "../../../components/table/table-cells/common/text-cell" +import { HttpTypes } from "@medusajs/types" +import { Badge } from "@medusajs/ui" + +const columnHelper = createColumnHelper() + +export const useProductOptionTableColumns = () => { + const { t } = useTranslation() + + return useMemo( + () => [ + columnHelper.accessor("title", { + header: t("fields.title"), + cell: ({ getValue }) => , + }), + columnHelper.accessor("values", { + header: t("fields.values"), + cell: ({ getValue }) => { + const values = getValue() + const count = values?.length || 0 + const displayText = + count > 0 ? `${count} ${count === 1 ? "value" : "values"}` : "-" + + return + }, + }), + columnHelper.accessor("is_exclusive", { + header: t("fields.status"), + cell: ({ getValue }) => { + const isExclusive = getValue() + return ( + + {t(`general.${isExclusive ? "exclusive" : "global"}`)} + + ) + }, + }), + ], + [t] + ) +} diff --git a/packages/admin/dashboard/src/hooks/table/filters/index.ts b/packages/admin/dashboard/src/hooks/table/filters/index.ts index d98d694931800..954f90647ba8c 100644 --- a/packages/admin/dashboard/src/hooks/table/filters/index.ts +++ b/packages/admin/dashboard/src/hooks/table/filters/index.ts @@ -4,6 +4,7 @@ export * from "./use-customer-table-filters" export * from "./use-date-table-filters" export * from "./use-order-table-filters" export * from "./use-product-table-filters" +export * from "./use-product-option-table-filters" export * from "./use-product-tag-table-filters" export * from "./use-product-type-table-filters" export * from "./use-promotion-table-filters" diff --git a/packages/admin/dashboard/src/hooks/table/filters/use-product-option-table-filters.tsx b/packages/admin/dashboard/src/hooks/table/filters/use-product-option-table-filters.tsx new file mode 100644 index 0000000000000..695e01886d1f3 --- /dev/null +++ b/packages/admin/dashboard/src/hooks/table/filters/use-product-option-table-filters.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from "react-i18next" +import { createDataTableFilterHelper } from "@medusajs/ui" +import { HttpTypes } from "@medusajs/types" +import { useDataTableDateFilters } from "../../../components/data-table/helpers/general/use-data-table-date-filters.tsx" +import { useMemo } from "react" + +const filterHelper = createDataTableFilterHelper() + +export const useProductOptionTableFilters = () => { + const { t } = useTranslation() + const dateFilters = useDataTableDateFilters() + + return useMemo( + () => [ + filterHelper.accessor("is_exclusive", { + label: t("fields.type"), + type: "radio", + options: [ + { + label: t("general.exclusive"), + value: "true", + }, + { + label: t("general.global"), + value: "false", + }, + ], + }), + ...dateFilters, + ], + [dateFilters, t] + ) +} diff --git a/packages/admin/dashboard/src/hooks/table/query/use-product-option-table-query.tsx b/packages/admin/dashboard/src/hooks/table/query/use-product-option-table-query.tsx new file mode 100644 index 0000000000000..800f4386bfd47 --- /dev/null +++ b/packages/admin/dashboard/src/hooks/table/query/use-product-option-table-query.tsx @@ -0,0 +1,34 @@ +import { HttpTypes } from "@medusajs/types" +import { useQueryParams } from "../../use-query-params" + +type UseProductOptionTableQueryProps = { + prefix?: string + pageSize?: number +} + +export const useProductOptionTableQuery = ({ + prefix, + pageSize = 20, +}: UseProductOptionTableQueryProps) => { + const queryObject = useQueryParams( + ["offset", "q", "order", "created_at", "updated_at", "is_exclusive"], + prefix + ) + + const { offset, created_at, updated_at, q, order, is_exclusive } = queryObject + + const searchParams: HttpTypes.AdminProductOptionListParams = { + limit: pageSize, + offset: offset ? Number(offset) : 0, + order, + created_at: created_at ? JSON.parse(created_at) : undefined, + updated_at: updated_at ? JSON.parse(updated_at) : undefined, + is_exclusive: is_exclusive === "true" ? true : is_exclusive === "false" ? false : undefined, + q, + } + + return { + searchParams, + raw: queryObject, + } +} \ No newline at end of file diff --git a/packages/admin/dashboard/src/i18n/translations/$schema.json b/packages/admin/dashboard/src/i18n/translations/$schema.json index f413a59c52ace..567f7dbfb07e2 100644 --- a/packages/admin/dashboard/src/i18n/translations/$schema.json +++ b/packages/admin/dashboard/src/i18n/translations/$schema.json @@ -178,6 +178,12 @@ }, "noMoreData": { "type": "string" + }, + "exclusive": { + "type": "string" + }, + "global": { + "type": "string" } }, "required": [ @@ -237,7 +243,9 @@ "unsavedChangesDescription", "includesTaxTooltip", "excludesTaxTooltip", - "noMoreData" + "noMoreData", + "exclusive", + "global" ], "additionalProperties": false }, @@ -509,6 +517,9 @@ }, "cannotUndo": { "type": "string" + }, + "manage": { + "type": "string" } }, "required": [ @@ -555,7 +566,8 @@ "hide", "export", "import", - "cannotUndo" + "cannotUndo", + "manage" ], "additionalProperties": false }, @@ -1692,6 +1704,9 @@ }, "required": ["label", "hint"], "additionalProperties": false + }, + "selectValuesHint": { + "type": "string" } }, "required": [ @@ -1701,7 +1716,8 @@ "optionTitle", "optionValues", "productVariants", - "productOptions" + "productOptions", + "selectValuesHint" ], "additionalProperties": false }, @@ -2008,6 +2024,7 @@ "uploadImagesLabel", "uploadImagesHint", "invalidFileType", + "fileTooLarge", "failedToUpload", "deleteWarning_one", "deleteWarning_other", @@ -2651,11 +2668,39 @@ "required": ["header", "successToast"], "additionalProperties": false }, + "manage": { + "type": "object", + "properties": { + "header": { + "type": "string" + }, + "description": { + "type": "string" + }, + "label": { + "type": "string" + }, + "hint": { + "type": "string" + }, + "placeholder": { + "type": "string" + } + }, + "required": [ + "header", + "description", + "label", + "hint", + "placeholder" + ], + "additionalProperties": false + }, "deleteWarning": { "type": "string" } }, - "required": ["header", "edit", "create", "deleteWarning"], + "required": ["header", "edit", "create", "manage", "deleteWarning"], "additionalProperties": false }, "organization": { @@ -3156,6 +3201,128 @@ ], "additionalProperties": false }, + "productOptions": { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "subtitle": { + "type": "string" + }, + "values": { + "type": "object", + "properties": { + "header": { + "type": "string" + } + }, + "required": ["header"], + "additionalProperties": false + }, + "create": { + "type": "object", + "properties": { + "header": { + "type": "string" + }, + "hint": { + "type": "string" + }, + "successToast": { + "type": "string" + }, + "tabs": { + "type": "object", + "properties": { + "details": { + "type": "string" + }, + "organize": { + "type": "string" + } + }, + "required": ["details", "organize"], + "additionalProperties": false + } + }, + "required": ["header", "hint", "successToast", "tabs"], + "additionalProperties": false + }, + "edit": { + "type": "object", + "properties": { + "header": { + "type": "string" + }, + "description": { + "type": "string" + }, + "successToast": { + "type": "string" + } + }, + "required": ["header", "description", "successToast"], + "additionalProperties": false + }, + "delete": { + "type": "object", + "properties": { + "confirmation": { + "type": "string" + }, + "successToast": { + "type": "string" + } + }, + "required": ["confirmation", "successToast"], + "additionalProperties": false + }, + "fields": { + "type": "object", + "properties": { + "title": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "placeholder": { + "type": "string" + } + }, + "required": ["label", "placeholder"], + "additionalProperties": false + }, + "values": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "placeholder": { + "type": "string" + } + }, + "required": ["label", "placeholder"], + "additionalProperties": false + } + }, + "required": ["title", "values"], + "additionalProperties": false + } + }, + "required": [ + "domain", + "subtitle", + "values", + "create", + "edit", + "delete", + "fields" + ], + "additionalProperties": false + }, "inventory": { "type": "object", "properties": { @@ -12038,6 +12205,7 @@ "products", "collections", "categories", + "productOptions", "inventory", "giftCards", "customers", diff --git a/packages/admin/dashboard/src/i18n/translations/en.json b/packages/admin/dashboard/src/i18n/translations/en.json index 945ba1ec0183a..57c4cf753649a 100644 --- a/packages/admin/dashboard/src/i18n/translations/en.json +++ b/packages/admin/dashboard/src/i18n/translations/en.json @@ -57,7 +57,9 @@ "unsavedChangesDescription": "You have unsaved changes that will be lost if you exit this form.", "includesTaxTooltip": "Prices in this column are tax inclusive.", "excludesTaxTooltip": "Prices in this column are tax exclusive.", - "noMoreData": "No more data" + "noMoreData": "No more data", + "exclusive": "Product-specific", + "global": "Global" }, "json": { "header": "JSON", @@ -141,7 +143,8 @@ "hide": "Hide", "export": "Export", "import": "Import", - "cannotUndo": "This action cannot be undone" + "cannotUndo": "This action cannot be undone", + "manage": "Manage" }, "operators": { "in": "In" @@ -448,7 +451,8 @@ "productOptions": { "label": "Product options", "hint": "Define the options for the product, e.g. color, size, etc." - } + }, + "selectValuesHint": "Select which values to use for each option" }, "successToast": "Product {{title}} was successfully created." }, @@ -705,6 +709,13 @@ "header": "Create Option", "successToast": "Option {{title}} was successfully created." }, + "manage": { + "header": "Manage Product Options", + "description": "Associate or disassociate product options from this product.", + "label": "Product Options", + "hint": "Select which options should be associated to this product.", + "placeholder": "Select options" + }, "deleteWarning": "You are about to delete the product option: {{title}}. This action cannot be undone." }, "organization": { @@ -841,6 +852,41 @@ } } }, + "productOptions": { + "domain": "Options", + "subtitle": "Manage product options and their associated values.", + "values": { + "header": "Values" + }, + "create": { + "header": "Create Product Option", + "hint": "Create a new product option and manage its values.", + "successToast": "Product option \"{{title}}\" was successfully created.", + "tabs": { + "details": "Details", + "organize": "Organize" + } + }, + "edit": { + "header": "Edit Product Option", + "description": "Edit the product option to update its details and associated values.", + "successToast": "Product option \"{{title}}\" was successfully updated." + }, + "delete": { + "confirmation": "You are about to delete the product option \"{{title}}\". This action cannot be undone.", + "successToast": "Product option was successfully deleted." + }, + "fields": { + "title": { + "label": "Title", + "placeholder": "Size" + }, + "values": { + "label": "Values", + "placeholder": "S, M, L" + } + } + }, "inventory": { "domain": "Inventory", "subtitle": "Manage your inventory items", diff --git a/packages/admin/dashboard/src/routes/product-options/common/hooks/use-delete-product-option-action.tsx b/packages/admin/dashboard/src/routes/product-options/common/hooks/use-delete-product-option-action.tsx new file mode 100644 index 0000000000000..ca54e2a003212 --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/common/hooks/use-delete-product-option-action.tsx @@ -0,0 +1,41 @@ +import { useNavigate } from "react-router-dom" +import { toast, usePrompt } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { useDeleteProductOption } from "../../../../hooks/api" +import { HttpTypes } from "@medusajs/types" + +export const useDeleteProductOptionAction = ({ + id, + title, +}: HttpTypes.AdminProductOption) => { + const { t } = useTranslation() + const prompt = usePrompt() + const navigate = useNavigate() + + const { mutateAsync } = useDeleteProductOption(id) + + return async () => { + const result = await prompt({ + title: t("general.areYouSure"), + description: t("productOptions.delete.confirmation", { title }), + confirmText: t("actions.delete"), + cancelText: t("actions.cancel"), + }) + + if (!result) { + return + } + + await mutateAsync(undefined, { + onSuccess: () => { + navigate("/product-options", { + replace: true, + }) + toast.success(t("productOptions.delete.successToast", { title })) + }, + onError: (e) => { + toast.error(e.message) + }, + }) + } +} diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-create/components/create-product-option-form/create-product-option-details.tsx b/packages/admin/dashboard/src/routes/product-options/product-option-create/components/create-product-option-form/create-product-option-details.tsx new file mode 100644 index 0000000000000..d8466bb1f3d7d --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-create/components/create-product-option-form/create-product-option-details.tsx @@ -0,0 +1,71 @@ +import { Heading, Input, Text } from "@medusajs/ui" +import { UseFormReturn } from "react-hook-form" +import { useTranslation } from "react-i18next" + +import { Form } from "../../../../../components/common/form" +import { ChipInput } from "../../../../../components/inputs/chip-input" +import { CreateProductOptionSchema } from "./schema" + +type CreateProductOptionDetailsProps = { + form: UseFormReturn +} + +export const CreateProductOptionDetails = ({ + form, +}: CreateProductOptionDetailsProps) => { + const { t } = useTranslation() + + return ( +
+
+
+ {t("productOptions.create.header")} + + {t("productOptions.create.hint")} + +
+ { + return ( + + + {t("productOptions.fields.title.label")} + + + + + + + ) + }} + /> + { + return ( + + + {t("productOptions.fields.values.label")} + + + + + + + ) + }} + /> +
+
+ ) +} diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-create/components/create-product-option-form/create-product-option-form.tsx b/packages/admin/dashboard/src/routes/product-options/product-option-create/components/create-product-option-form/create-product-option-form.tsx new file mode 100644 index 0000000000000..ba2af53ff677e --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-create/components/create-product-option-form/create-product-option-form.tsx @@ -0,0 +1,192 @@ +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 { + RouteFocusModal, + useRouteModal, +} from "../../../../../components/modals" +import { KeyboundForm } from "../../../../../components/utilities/keybound-form" +import { useCreateProductOption } from "../../../../../hooks/api" +import { CreateProductOptionDetails } from "./create-product-option-details" +import { CreateProductOptionOrganize } from "./create-product-option-organize" +import { + CreateProductOptionDetailsSchema, + CreateProductOptionSchema, +} from "./schema" +import { useDocumentDirection } from "../../../../../hooks/use-document-direction" + +enum Tab { + DETAILS = "details", + ORGANIZE = "organize", +} + +export const CreateProductOptionForm = () => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + const direction = useDocumentDirection() + const [activeTab, setActiveTab] = useState(Tab.DETAILS) + const [validDetails, setValidDetails] = useState(false) + + const form = useForm({ + defaultValues: { + title: "", + values: [], + value_ranks: {}, + }, + resolver: zodResolver(CreateProductOptionSchema), + }) + + const handleTabChange = (tab: Tab) => { + if (tab === Tab.ORGANIZE) { + const { title, values } = form.getValues() + + const result = CreateProductOptionDetailsSchema.safeParse({ + title, + values, + }) + + if (!result.success) { + result.error.errors.forEach((error) => { + form.setError( + error.path.join(".") as keyof CreateProductOptionSchema, + { + type: "manual", + message: error.message, + } + ) + }) + + return + } + + form.clearErrors() + setValidDetails(true) + } + + setActiveTab(tab) + } + + const { mutateAsync, isPending } = useCreateProductOption() + + const handleSubmit = form.handleSubmit((data) => { + const { title, values, value_ranks } = data + + mutateAsync( + { + title, + values, + ranks: value_ranks, + }, + { + onSuccess: ({ product_option }) => { + toast.success( + t("productOptions.create.successToast", { + title: product_option.title, + }) + ) + + handleSuccess(`/product-options/${product_option.id}`) + }, + onError: (error) => { + toast.error(error.message) + }, + } + ) + }) + + const organizeStatus: ProgressStatus = + form.getFieldState("value_ranks")?.isDirty || 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("productOptions.create.tabs.details")} + + + + + {t("productOptions.create.tabs.organize")} + + + +
+
+
+ + + + + + + + + +
+ + + + {activeTab === Tab.ORGANIZE ? ( + + ) : ( + + )} +
+
+
+
+
+ ) +} diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-create/components/create-product-option-form/create-product-option-organize.tsx b/packages/admin/dashboard/src/routes/product-options/product-option-create/components/create-product-option-form/create-product-option-organize.tsx new file mode 100644 index 0000000000000..05c6f71cb3ff1 --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-create/components/create-product-option-form/create-product-option-organize.tsx @@ -0,0 +1,77 @@ +import { Text } from "@medusajs/ui" +import { useMemo } from "react" +import { UseFormReturn, useWatch } from "react-hook-form" +import { SortableList } from "../../../../../components/common/sortable-list" +import { CreateProductOptionSchema } from "./schema" + +type CreateProductOptionOrganizeProps = { + form: UseFormReturn +} + +type ValueItem = { + id: string + value: string + rank: number +} + +export const CreateProductOptionOrganize = ({ + form, +}: CreateProductOptionOrganizeProps) => { + const values = useWatch({ + control: form.control, + name: "values", + }) + + const valueRanks = useWatch({ + control: form.control, + name: "value_ranks", + }) + + const items = useMemo(() => { + if (!values || values.length === 0) { + return [] + } + + return values + .map((value, index) => ({ + id: value, + value, + rank: valueRanks?.[value] ?? index, + })) + .sort((a, b) => a.rank - b.rank) + }, [values, valueRanks]) + + const handleChange = (newItems: ValueItem[]) => { + const newRanks: Record = {} + newItems.forEach((item, index) => { + newRanks[item.value] = index + }) + + form.setValue("value_ranks", newRanks, { + shouldDirty: true, + shouldTouch: true, + }) + } + + if (!values || values.length === 0) { + return null + } + + return ( + ( + +
+ + {item.value} +
+
+ )} + /> + ) +} diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-create/components/create-product-option-form/index.ts b/packages/admin/dashboard/src/routes/product-options/product-option-create/components/create-product-option-form/index.ts new file mode 100644 index 0000000000000..4f441a2376dd3 --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-create/components/create-product-option-form/index.ts @@ -0,0 +1 @@ +export { CreateProductOptionForm } from "./create-product-option-form" diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-create/components/create-product-option-form/schema.ts b/packages/admin/dashboard/src/routes/product-options/product-option-create/components/create-product-option-form/schema.ts new file mode 100644 index 0000000000000..e1b97b33a7e19 --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-create/components/create-product-option-form/schema.ts @@ -0,0 +1,15 @@ +import { z } from "zod" + +export const CreateProductOptionDetailsSchema = z.object({ + title: z.string().min(1), + values: z.array(z.string()).min(1, "At least one value is required"), +}) + +export type CreateProductOptionSchema = z.infer< + typeof CreateProductOptionSchema +> +export const CreateProductOptionSchema = z + .object({ + value_ranks: z.record(z.string(), z.number()).optional(), + }) + .merge(CreateProductOptionDetailsSchema) diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-create/index.ts b/packages/admin/dashboard/src/routes/product-options/product-option-create/index.ts new file mode 100644 index 0000000000000..a0b92f82cc0d2 --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-create/index.ts @@ -0,0 +1 @@ +export { ProductOptionCreate as Component } from "./product-option-create" \ No newline at end of file diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-create/product-option-create.tsx b/packages/admin/dashboard/src/routes/product-options/product-option-create/product-option-create.tsx new file mode 100644 index 0000000000000..aeb76bddf029c --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-create/product-option-create.tsx @@ -0,0 +1,10 @@ +import { RouteFocusModal } from "../../../components/modals" +import { CreateProductOptionForm } from "./components/create-product-option-form/create-product-option-form" + +export const ProductOptionCreate = () => { + return ( + + + + ) +} \ No newline at end of file diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-detail/breadcrumb.tsx b/packages/admin/dashboard/src/routes/product-options/product-option-detail/breadcrumb.tsx new file mode 100644 index 0000000000000..a204c3bd8c061 --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-detail/breadcrumb.tsx @@ -0,0 +1,14 @@ +import { HttpTypes } from "@medusajs/types" +import { UIMatch } from "react-router-dom" + +export const ProductOptionBreadcrumb = ( + match: UIMatch +) => { + const productOption = match.data.product_option + + if (!productOption) { + return null + } + + return {productOption.title} +} diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-detail/components/product-option-general-section/index.ts b/packages/admin/dashboard/src/routes/product-options/product-option-detail/components/product-option-general-section/index.ts new file mode 100644 index 0000000000000..f7ec9c2441866 --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-detail/components/product-option-general-section/index.ts @@ -0,0 +1 @@ +export * from "./product-option-general-section.tsx" diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-detail/components/product-option-general-section/product-option-general-section.tsx b/packages/admin/dashboard/src/routes/product-options/product-option-detail/components/product-option-general-section/product-option-general-section.tsx new file mode 100644 index 0000000000000..90d4d28905f25 --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-detail/components/product-option-general-section/product-option-general-section.tsx @@ -0,0 +1,81 @@ +import { PencilSquare, Trash } from "@medusajs/icons" +import { HttpTypes } from "@medusajs/types" +import { Badge, Container, Heading } from "@medusajs/ui" +import { useMemo } from "react" +import { useTranslation } from "react-i18next" +import { ActionMenu } from "../../../../../components/common/action-menu" +import { useDeleteProductOptionAction } from "../../../common/hooks/use-delete-product-option-action.tsx" + +export const ProductOptionGeneralSection = ({ + productOption, +}: { + productOption: HttpTypes.AdminProductOption +}) => { + const { t } = useTranslation() + + const handleDelete = useDeleteProductOptionAction(productOption) + + const sortedValues = useMemo(() => { + if (!productOption.values) { + return [] + } + + return [...productOption.values].sort((a: any, b: any) => { + const rankA = a.rank ?? Number.MAX_VALUE + const rankB = b.rank ?? Number.MAX_VALUE + return rankA - rankB + }) + }, [productOption.values]) + + return ( + +
+ {productOption.title} +
+ + {t( + `general.${productOption.is_exclusive ? "exclusive" : "global"}` + )} + + , + to: "edit", + }, + ], + }, + { + actions: [ + { + label: t("actions.delete"), + icon: , + onClick: handleDelete, + }, + ], + }, + ]} + /> +
+
+
+ +
+
+ ) +} + +const ValuesDisplay = ({ values }: { values: any[] }) => { + return ( +
+ {values.map((value) => ( + + {value.value} + + ))} +
+ ) +} diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-detail/components/product-option-product-section/index.ts b/packages/admin/dashboard/src/routes/product-options/product-option-detail/components/product-option-product-section/index.ts new file mode 100644 index 0000000000000..b20c9f0866c02 --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-detail/components/product-option-product-section/index.ts @@ -0,0 +1 @@ +export { ProductOptionProductSection } from "./product-option-product-section" \ No newline at end of file diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-detail/components/product-option-product-section/product-option-product-section.tsx b/packages/admin/dashboard/src/routes/product-options/product-option-detail/components/product-option-product-section/product-option-product-section.tsx new file mode 100644 index 0000000000000..01a1d51f8ecef --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-detail/components/product-option-product-section/product-option-product-section.tsx @@ -0,0 +1,74 @@ +import { HttpTypes } from "@medusajs/types" +import { Container } from "@medusajs/ui" +import { keepPreviousData } from "@tanstack/react-query" +import { useMemo } from "react" +import { useTranslation } from "react-i18next" +import { DataTable } from "../../../../../components/data-table" +import { useProducts } from "../../../../../hooks/api" +import { useProductTableColumns } from "../../../../../hooks/table/columns" +import { useProductTableQuery } from "../../../../../hooks/table/query" + +type ProductOptionProductSectionProps = { + productOption: HttpTypes.AdminProductOption +} + +const PAGE_SIZE = 10 + +export const ProductOptionProductSection = ({ + productOption, +}: ProductOptionProductSectionProps) => { + const { t } = useTranslation() + + const { searchParams } = useProductTableQuery({ pageSize: PAGE_SIZE }) + + const productIds = productOption.products?.map((p: any) => p.id) || [] + + const { products, count, isLoading, isError, error } = useProducts( + { + limit: PAGE_SIZE, + ...searchParams, + id: productIds.length > 0 ? productIds : undefined, + }, + { + placeholderData: keepPreviousData, + enabled: productIds.length > 0, + } + ) + + const columns = useColumns() + + if (isError) { + throw error + } + + return ( + + row.id} + heading={t("products.domain")} + emptyState={{ + empty: { + heading: t("general.noRecordsMessage"), + }, + filtered: { + heading: t("general.noRecordsMessage"), + description: t("general.noRecordsMessageFiltered"), + }, + }} + isLoading={isLoading} + enableSearch={true} + rowHref={(row) => `/products/${row.id}`} + /> + + ) +} + +const useColumns = () => { + const columns = useProductTableColumns() + + return useMemo(() => [...columns], [columns]) +} diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-detail/index.ts b/packages/admin/dashboard/src/routes/product-options/product-option-detail/index.ts new file mode 100644 index 0000000000000..3460c624a407f --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-detail/index.ts @@ -0,0 +1,3 @@ +export { ProductOptionBreadcrumb as Breadcrumb } from "./breadcrumb" +export { ProductOptionDetail as Component } from "./product-option-detail" +export { productOptionLoader as loader } from "./loader" diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-detail/loader.ts b/packages/admin/dashboard/src/routes/product-options/product-option-detail/loader.ts new file mode 100644 index 0000000000000..3032f37444373 --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-detail/loader.ts @@ -0,0 +1,17 @@ +import { LoaderFunctionArgs } from "react-router-dom" + +import { sdk } from "../../../lib/client" +import { queryClient } from "../../../lib/query-client" +import { productOptionsQueryKeys } from "../../../hooks/api/product-options.tsx" + +const productOptionDetailQuery = (id: string) => ({ + queryKey: productOptionsQueryKeys.detail(id), + queryFn: async () => sdk.admin.productOption.retrieve(id), +}) + +export const productOptionLoader = async ({ params }: LoaderFunctionArgs) => { + const id = params.id + const query = productOptionDetailQuery(id!) + + return queryClient.ensureQueryData(query) +} diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-detail/product-option-detail.tsx b/packages/admin/dashboard/src/routes/product-options/product-option-detail/product-option-detail.tsx new file mode 100644 index 0000000000000..2ece692855fb0 --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-detail/product-option-detail.tsx @@ -0,0 +1,50 @@ +import { useLoaderData, useParams } from "react-router-dom" + +import { SingleColumnPageSkeleton } from "../../../components/common/skeleton" +import { SingleColumnPage } from "../../../components/layout/pages" +import { useExtension } from "../../../providers/extension-provider" +import { useProductOption } from "../../../hooks/api" +import { productOptionLoader } from "./loader.ts" +import { ProductOptionGeneralSection } from "./components/product-option-general-section" +import { ProductOptionProductSection } from "./components/product-option-product-section" + +export const ProductOptionDetail = () => { + const { id } = useParams() + + const initialData = useLoaderData() as Awaited< + ReturnType + > + + const { getWidgets } = useExtension() + + const { product_option, isLoading, isError, error } = useProductOption( + id!, + undefined, + { + initialData, + } + ) + + if (isLoading || !product_option) { + return + } + + if (isError) { + throw error + } + + return ( + + + + + ) +} diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-edit/components/edit-product-option-form/edit-product-option-details.tsx b/packages/admin/dashboard/src/routes/product-options/product-option-edit/components/edit-product-option-form/edit-product-option-details.tsx new file mode 100644 index 0000000000000..8f64c068d2999 --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-edit/components/edit-product-option-form/edit-product-option-details.tsx @@ -0,0 +1,94 @@ +import { Input } from "@medusajs/ui" +import { useEffect } from "react" +import { UseFormReturn, useWatch } from "react-hook-form" +import { useTranslation } from "react-i18next" + +import { Form } from "../../../../../components/common/form" +import { ChipInput } from "../../../../../components/inputs/chip-input" +import { EditProductOptionSchema } from "./schema" + +type EditProductOptionDetailsProps = { + form: UseFormReturn +} + +export const EditProductOptionDetails = ({ + form, +}: EditProductOptionDetailsProps) => { + const { t } = useTranslation() + + const values = useWatch({ + control: form.control, + name: "values", + }) + + const valueRanks = useWatch({ + control: form.control, + name: "value_ranks", + }) + + useEffect(() => { + if (!values || !valueRanks) { + return + } + + const validValueSet = new Set(values) + const currentRanks = { ...valueRanks } + let hasStaleEntries = false + + Object.keys(currentRanks).forEach((key) => { + if (!validValueSet.has(key)) { + delete currentRanks[key] + hasStaleEntries = true + } + }) + + if (hasStaleEntries) { + form.setValue("value_ranks", currentRanks, { + shouldDirty: true, + shouldTouch: true, + }) + } + }, [values, valueRanks, form]) + + return ( +
+ { + return ( + + {t("productOptions.fields.title.label")} + + + + + + ) + }} + /> + { + return ( + + {t("productOptions.fields.values.label")} + + + + + + ) + }} + /> +
+ ) +} diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-edit/components/edit-product-option-form/edit-product-option-form.tsx b/packages/admin/dashboard/src/routes/product-options/product-option-edit/components/edit-product-option-form/edit-product-option-form.tsx new file mode 100644 index 0000000000000..6547e08fd1cb4 --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-edit/components/edit-product-option-form/edit-product-option-form.tsx @@ -0,0 +1,116 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { HttpTypes } from "@medusajs/types" +import { Button, toast } from "@medusajs/ui" +import { useMemo } from "react" +import { useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" + +import { RouteDrawer, useRouteModal } from "../../../../../components/modals" +import { KeyboundForm } from "../../../../../components/utilities/keybound-form" +import { useUpdateProductOption } from "../../../../../hooks/api" +import { EditProductOptionDetails } from "./edit-product-option-details" +import { EditProductOptionOrganize } from "./edit-product-option-organize" +import { EditProductOptionSchema } from "./schema" + +type EditProductOptionFormProps = { + productOption: HttpTypes.AdminProductOption +} + +export const EditProductOptionForm = ({ + productOption, +}: EditProductOptionFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const { sortedValues, existingRanks } = useMemo(() => { + if (!productOption.values) { + return { sortedValues: [], existingRanks: {} } + } + + const ranks: Record = {} + productOption.values.forEach((v: any) => { + if (v.rank !== undefined && v.rank !== null) { + ranks[v.value] = v.rank + } + }) + + const sorted = [...productOption.values].sort((a: any, b: any) => { + const rankA = a.rank ?? Number.MAX_VALUE + const rankB = b.rank ?? Number.MAX_VALUE + return rankA - rankB + }) + + return { + sortedValues: sorted.map((v: any) => v.value), + existingRanks: ranks, + } + }, [productOption.values]) + + const form = useForm({ + defaultValues: { + title: productOption.title, + values: sortedValues, + value_ranks: existingRanks, + }, + resolver: zodResolver(EditProductOptionSchema), + }) + + const { mutateAsync, isPending } = useUpdateProductOption(productOption.id) + + const handleSubmit = form.handleSubmit((data) => { + const { title, values, value_ranks } = data + + mutateAsync( + { + title, + values, + ranks: value_ranks, + }, + { + onSuccess: ({ product_option }) => { + toast.success( + t("productOptions.edit.successToast", { + title: product_option.title, + }) + ) + + handleSuccess() + }, + onError: (error) => { + toast.error(error.message) + }, + } + ) + }) + + return ( + + + + + + + +
+ + + + +
+
+
+
+ ) +} diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-edit/components/edit-product-option-form/edit-product-option-organize.tsx b/packages/admin/dashboard/src/routes/product-options/product-option-edit/components/edit-product-option-form/edit-product-option-organize.tsx new file mode 100644 index 0000000000000..abc0b13ba1fa9 --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-edit/components/edit-product-option-form/edit-product-option-organize.tsx @@ -0,0 +1,77 @@ +import { Text } from "@medusajs/ui" +import { useMemo } from "react" +import { UseFormReturn, useWatch } from "react-hook-form" +import { SortableList } from "../../../../../components/common/sortable-list" +import { EditProductOptionSchema } from "./schema" + +type EditProductOptionOrganizeProps = { + form: UseFormReturn +} + +type ValueItem = { + id: string + value: string + rank: number +} + +export const EditProductOptionOrganize = ({ + form, +}: EditProductOptionOrganizeProps) => { + const values = useWatch({ + control: form.control, + name: "values", + }) + + const valueRanks = useWatch({ + control: form.control, + name: "value_ranks", + }) + + const items = useMemo(() => { + if (!values || values.length === 0) { + return [] + } + + return values + .map((value, index) => ({ + id: value, + value, + rank: valueRanks?.[value] ?? index, + })) + .sort((a, b) => a.rank - b.rank) + }, [values, valueRanks]) + + const handleChange = (newItems: ValueItem[]) => { + const newRanks: Record = {} + newItems.forEach((item, index) => { + newRanks[item.value] = index + }) + + form.setValue("value_ranks", newRanks, { + shouldDirty: true, + shouldTouch: true, + }) + } + + if (!values || values.length === 0) { + return null + } + + return ( + ( + +
+ + {item.value} +
+
+ )} + /> + ) +} diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-edit/components/edit-product-option-form/index.ts b/packages/admin/dashboard/src/routes/product-options/product-option-edit/components/edit-product-option-form/index.ts new file mode 100644 index 0000000000000..d2a48f9ceb6c0 --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-edit/components/edit-product-option-form/index.ts @@ -0,0 +1 @@ +export { EditProductOptionForm } from "./edit-product-option-form" diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-edit/components/edit-product-option-form/schema.ts b/packages/admin/dashboard/src/routes/product-options/product-option-edit/components/edit-product-option-form/schema.ts new file mode 100644 index 0000000000000..801aa501308bd --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-edit/components/edit-product-option-form/schema.ts @@ -0,0 +1,13 @@ +import { z } from "zod" + +export const EditProductOptionDetailsSchema = z.object({ + title: z.string().min(1), + values: z.array(z.string()).min(1, "At least one value is required"), +}) + +export type EditProductOptionSchema = z.infer +export const EditProductOptionSchema = z + .object({ + value_ranks: z.record(z.string(), z.number()).optional(), + }) + .merge(EditProductOptionDetailsSchema) \ No newline at end of file diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-edit/index.ts b/packages/admin/dashboard/src/routes/product-options/product-option-edit/index.ts new file mode 100644 index 0000000000000..c09c411991e7d --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-edit/index.ts @@ -0,0 +1 @@ +export { ProductOptionEdit as Component } from "./product-option-edit" diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-edit/product-option-edit.tsx b/packages/admin/dashboard/src/routes/product-options/product-option-edit/product-option-edit.tsx new file mode 100644 index 0000000000000..20715b3f93a75 --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-edit/product-option-edit.tsx @@ -0,0 +1,34 @@ +import { Heading } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { useParams } from "react-router-dom" + +import { RouteDrawer } from "../../../components/modals" +import { useProductOption } from "../../../hooks/api" +import { EditProductOptionForm } from "./components/edit-product-option-form" + +export const ProductOptionEdit = () => { + const { id } = useParams() + const { t } = useTranslation() + + const { product_option, isPending, isError, error } = useProductOption(id!) + + const ready = !isPending && !!product_option + + if (isError) { + throw error + } + + return ( + + + + {t("productOptions.edit.header")} + + + {t("productOptions.edit.description")} + + + {ready && } + + ) +} diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-list/components/product-option-list-table/index.ts b/packages/admin/dashboard/src/routes/product-options/product-option-list/components/product-option-list-table/index.ts new file mode 100644 index 0000000000000..4ee77c56f4b9d --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-list/components/product-option-list-table/index.ts @@ -0,0 +1 @@ +export { ProductOptionListTable } from "./product-option-list-table" \ No newline at end of file diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-list/components/product-option-list-table/product-option-list-table.tsx b/packages/admin/dashboard/src/routes/product-options/product-option-list/components/product-option-list-table/product-option-list-table.tsx new file mode 100644 index 0000000000000..5d9cd7e9f367e --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-list/components/product-option-list-table/product-option-list-table.tsx @@ -0,0 +1,131 @@ +import { Container, createDataTableColumnHelper, toast, usePrompt, } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { DataTable } from "../../../../../components/data-table" + +import { keepPreviousData } from "@tanstack/react-query" +import { useCallback, useMemo } from "react" +import { useDeleteProductOptionLazy, useProductOptions, } from "../../../../../hooks/api/product-options" +import { useProductOptionTableColumns } from "../../../../../hooks/table/columns/use-product-option-table-columns" +import { useProductOptionTableFilters } from "../../../../../hooks/table/filters" +import { useProductOptionTableQuery } from "../../../../../hooks/table/query/use-product-option-table-query" +import { HttpTypes } from "@medusajs/types" +import { useNavigate } from "react-router-dom" +import { PencilSquare, Trash } from "@medusajs/icons" + +const PAGE_SIZE = 20 + +export const ProductOptionListTable = () => { + const { t } = useTranslation() + + const { searchParams } = useProductOptionTableQuery({ + pageSize: PAGE_SIZE, + }) + const { product_options, count, isError, error, isLoading } = + useProductOptions(searchParams, { + placeholderData: keepPreviousData, + }) + + const filters = useProductOptionTableFilters() + const columns = useColumns() + + if (isError) { + throw error + } + + return ( + + row.id} + heading={t("productOptions.domain")} + subHeading={t("productOptions.subtitle")} + emptyState={{ + empty: { + heading: t("general.noRecordsMessage"), + }, + filtered: { + heading: t("general.noRecordsMessage"), + description: t("general.noRecordsMessageFiltered"), + }, + }} + actions={[ + { + label: t("actions.create"), + to: "create", + }, + ]} + isLoading={isLoading} + enableSearch={true} + rowHref={(row) => `/product-options/${row.id}`} + /> + + ) +} + +const columnHelper = createDataTableColumnHelper() + +const useColumns = () => { + const { t } = useTranslation() + const prompt = usePrompt() + const navigate = useNavigate() + const base = useProductOptionTableColumns() + + const { mutateAsync } = useDeleteProductOptionLazy() + + const handleDelete = useCallback( + async (productOption: HttpTypes.AdminProductOption) => { + const confirm = await prompt({ + title: t("general.areYouSure"), + description: t("productOptions.delete.confirmation", { + title: productOption.title, + }), + confirmText: t("actions.delete"), + cancelText: t("actions.cancel"), + }) + + if (!confirm) { + return + } + + await mutateAsync(productOption.id, { + onSuccess: () => { + toast.success(t("productOptions.delete.successToast")) + }, + onError: (e) => { + toast.error(e.message) + }, + }) + }, + [t, prompt, mutateAsync] + ) + + return useMemo( + () => [ + ...base, + columnHelper.action({ + actions: (ctx) => [ + [ + { + icon: , + label: t("actions.edit"), + onClick: () => + navigate(`/product-options/${ctx.row.original.id}/edit`), + }, + ], + [ + { + icon: , + label: t("actions.delete"), + onClick: () => handleDelete(ctx.row.original), + }, + ], + ], + }), + ], + [base, handleDelete, navigate, t] + ) +} diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-list/index.ts b/packages/admin/dashboard/src/routes/product-options/product-option-list/index.ts new file mode 100644 index 0000000000000..06c213288d143 --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-list/index.ts @@ -0,0 +1 @@ +export { ProductOptionList as Component } from "./product-option-list" \ No newline at end of file diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-list/product-option-list.tsx b/packages/admin/dashboard/src/routes/product-options/product-option-list/product-option-list.tsx new file mode 100644 index 0000000000000..6a6ae9a14821c --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-list/product-option-list.tsx @@ -0,0 +1,19 @@ +import { SingleColumnPage } from "../../../components/layout/pages" +import { useExtension } from "../../../providers/extension-provider" +import { ProductOptionListTable } from "./components/product-option-list-table" + +export const ProductOptionList = () => { + const { getWidgets } = useExtension() + + return ( + + + + ) +} \ No newline at end of file diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-metadata/index.ts b/packages/admin/dashboard/src/routes/product-options/product-option-metadata/index.ts new file mode 100644 index 0000000000000..843087c19e26b --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-metadata/index.ts @@ -0,0 +1 @@ +export { ProductOptionMetadata as Component } from "./product-option-metadata.tsx" diff --git a/packages/admin/dashboard/src/routes/product-options/product-option-metadata/product-option-metadata.tsx b/packages/admin/dashboard/src/routes/product-options/product-option-metadata/product-option-metadata.tsx new file mode 100644 index 0000000000000..5757e173e4bec --- /dev/null +++ b/packages/admin/dashboard/src/routes/product-options/product-option-metadata/product-option-metadata.tsx @@ -0,0 +1,27 @@ +import { useParams } from "react-router-dom" + +import { useProductOption, useUpdateProductOption } from "../../../hooks/api" +import { MetadataForm } from "../../../components/forms/metadata-form" +import { RouteDrawer } from "../../../components/modals" + +export const ProductOptionMetadata = () => { + const { id } = useParams() + + const { product_option, isPending, isError, error } = useProductOption(id!) + const { mutateAsync, isPending: isMutating } = useUpdateProductOption(id!) + + if (isError) { + throw error + } + + return ( + + + + ) +} diff --git a/packages/admin/dashboard/src/routes/products/product-create-option/components/create-product-option-form/create-product-option-form.tsx b/packages/admin/dashboard/src/routes/products/product-create-option/components/create-product-option-form/create-product-option-form.tsx deleted file mode 100644 index 05c8b85c05e81..0000000000000 --- a/packages/admin/dashboard/src/routes/products/product-create-option/components/create-product-option-form/create-product-option-form.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod" -import { Button, Input, toast } from "@medusajs/ui" -import { useForm } from "react-hook-form" -import { useTranslation } from "react-i18next" -import { z } from "zod" - -import { HttpTypes } from "@medusajs/types" -import { Form } from "../../../../../components/common/form" -import { ChipInput } from "../../../../../components/inputs/chip-input" -import { RouteDrawer, useRouteModal } from "../../../../../components/modals" -import { KeyboundForm } from "../../../../../components/utilities/keybound-form" -import { useCreateProductOption } from "../../../../../hooks/api/products" - -type EditProductOptionsFormProps = { - product: HttpTypes.AdminProduct -} - -const CreateProductOptionSchema = z.object({ - title: z.string().min(1), - values: z.array(z.string()).optional(), -}) - -export const CreateProductOptionForm = ({ - product, -}: EditProductOptionsFormProps) => { - const { t } = useTranslation() - const { handleSuccess } = useRouteModal() - - const form = useForm>({ - defaultValues: { - title: "", - values: [], - }, - resolver: zodResolver(CreateProductOptionSchema), - }) - - const { mutateAsync, isPending } = useCreateProductOption(product.id) - - const handleSubmit = form.handleSubmit(async (values) => { - mutateAsync(values, { - onSuccess: () => { - toast.success( - t("products.options.create.successToast", { - title: values.title, - }) - ) - handleSuccess() - }, - onError: async (err) => { - toast.error(err.message) - }, - }) - }) - - return ( - - - - { - return ( - - - {t("products.fields.options.optionTitle")} - - - - - - - ) - }} - /> - { - return ( - - - {t("products.fields.options.variations")} - - - - - - - ) - }} - /> - - -
- - - - -
-
-
-
- ) -} diff --git a/packages/admin/dashboard/src/routes/products/product-create-option/components/create-product-option-form/index.ts b/packages/admin/dashboard/src/routes/products/product-create-option/components/create-product-option-form/index.ts deleted file mode 100644 index 72faecda046c8..0000000000000 --- a/packages/admin/dashboard/src/routes/products/product-create-option/components/create-product-option-form/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./create-product-option-form" diff --git a/packages/admin/dashboard/src/routes/products/product-create-option/index.ts b/packages/admin/dashboard/src/routes/products/product-create-option/index.ts deleted file mode 100644 index b75822a2d9688..0000000000000 --- a/packages/admin/dashboard/src/routes/products/product-create-option/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ProductCreateOption as Component } from "./product-create-option" diff --git a/packages/admin/dashboard/src/routes/products/product-create-option/product-create-option.tsx b/packages/admin/dashboard/src/routes/products/product-create-option/product-create-option.tsx deleted file mode 100644 index 3ad22b671430b..0000000000000 --- a/packages/admin/dashboard/src/routes/products/product-create-option/product-create-option.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Heading } from "@medusajs/ui" -import { useTranslation } from "react-i18next" -import { useParams } from "react-router-dom" -import { RouteDrawer } from "../../../components/modals" -import { useProduct } from "../../../hooks/api/products" -import { CreateProductOptionForm } from "./components/create-product-option-form" - -export const ProductCreateOption = () => { - const { id } = useParams() - const { t } = useTranslation() - - const { product, isLoading, isError, error } = useProduct(id!) - - if (isError) { - throw error - } - - return ( - - - {t("products.options.create.header")} - - {!isLoading && product && } - - ) -} diff --git a/packages/admin/dashboard/src/routes/products/product-create/components/product-create-details-form/components/product-create-details-variant-section/product-create-details-variant-section.tsx b/packages/admin/dashboard/src/routes/products/product-create/components/product-create-details-form/components/product-create-details-variant-section/product-create-details-variant-section.tsx index 67fbbc062b901..6a7c013c74cb9 100644 --- a/packages/admin/dashboard/src/routes/products/product-create/components/product-create-details-form/components/product-create-details-variant-section/product-create-details-variant-section.tsx +++ b/packages/admin/dashboard/src/routes/products/product-create/components/product-create-details-form/components/product-create-details-variant-section/product-create-details-variant-section.tsx @@ -1,32 +1,30 @@ -import { XMarkMini } from "@medusajs/icons" import { Alert, - Button, Checkbox, clx, Heading, Hint, - IconButton, InlineTip, - Input, Label, Text, } from "@medusajs/ui" import { - Controller, FieldArrayWithId, useFieldArray, UseFormReturn, useWatch, } from "react-hook-form" import { useTranslation } from "react-i18next" +import { useMemo, useState } from "react" import { Form } from "../../../../../../../components/common/form" import { SortableList } from "../../../../../../../components/common/sortable-list" import { SwitchBox } from "../../../../../../../components/common/switch-box" -import { ChipInput } from "../../../../../../../components/inputs/chip-input" +import { Combobox } from "../../../../../../../components/inputs/combobox" import { ProductCreateSchemaType } from "../../../../types" import { decorateVariantsWithDefaultValues } from "../../../../utils" +import { useProductOptions } from "../../../../../../../hooks/api" +import { AdminProductOption } from "@medusajs/types" type ProductCreateVariantsSectionProps = { form: UseFormReturn @@ -65,11 +63,6 @@ export const ProductCreateVariantsSection = ({ }: ProductCreateVariantsSectionProps) => { const { t } = useTranslation() - const options = useFieldArray({ - control: form.control, - name: "options", - }) - const variants = useFieldArray({ control: form.control, name: "variants", @@ -97,90 +90,172 @@ export const ProductCreateVariantsSection = ({ const showInvalidVariantsMessage = form.formState.errors.variants?.root?.message === "invalid_length" - const handleOptionValueUpdate = (index: number, value: string[]) => { - const { isTouched: hasUserSelectedVariants } = - form.getFieldState("variants") - - const newOptions = [...watchedOptions] - newOptions[index].values = value + const { product_options = [], isLoading } = useProductOptions({ + is_exclusive: false, + }) - const permutations = getPermutations( - newOptions.filter(({ values }) => values.length) + const productOptionChoices = useMemo(() => { + return product_options.map((option) => ({ + value: option.id, + label: option.title, + })) + }, [product_options]) + + const [selectedOptionIds, setSelectedOptionIds] = useState([]) + const [selectedOptionValues, setSelectedOptionValues] = useState< + Record + >({}) + const [customValues, setCustomValues] = useState< + Record> + >({}) + + const handleProductOptionSelect = (optionIds: string[]) => { + setSelectedOptionIds(optionIds) + + // Initialize selected values for new options (select all by default) + const newSelectedValues: Record = {} + const selectedProductOptions = product_options.filter((option) => + optionIds.includes(option.id) ) - const oldVariants = [...watchedVariants] - const findMatchingPermutation = (options: Record) => { - return permutations.find((permutation) => - Object.keys(options).every((key) => options[key] === permutation[key]) - ) - } + selectedProductOptions.forEach((option) => { + // If option was already selected, keep its current value selection + if (selectedOptionValues[option.id]) { + newSelectedValues[option.id] = selectedOptionValues[option.id] + } else { + // New option - select all values by default + newSelectedValues[option.id] = option.values?.map((v) => v.id) || [] + } + }) - const newVariants = oldVariants.reduce((variants, variant) => { - const match = findMatchingPermutation(variant.options) + setSelectedOptionValues(newSelectedValues) + updateFormWithSelectedValues(selectedProductOptions, newSelectedValues) + } - if (match) { - variants.push({ - ...variant, - title: getVariantName(match), - options: match, - }) + const handleValueChange = (optionId: string, valueIds: string[]) => { + // Ensure at least one value is selected + if (valueIds.length === 0) { + return + } + + // Detect new custom values that aren't in the options yet + const allValues = getAllValuesForOption(optionId) + const existingValueIds = new Set(allValues.map((v) => v.id)) + + const validValueIds: string[] = [] + const newCustomValues: string[] = [] + valueIds.forEach((id) => { + if (existingValueIds.has(id)) { + validValueIds.push(id) + } else { + newCustomValues.push(id) } + }) - return variants - }, [] as typeof oldVariants) + let updatedCustomValues = customValues + const updatedValidValueIds = [...validValueIds] + newCustomValues.forEach((newValue) => { + const tempId = `custom-${Date.now()}-${Math.random()}-${newValue}` - const usedPermutations = new Set( - newVariants.map((variant) => variant.options) - ) - const unusedPermutations = permutations.filter( - (permutation) => !usedPermutations.has(permutation) - ) + const existingCustom = updatedCustomValues[optionId] || [] + const option = product_options.find((opt) => opt.id === optionId) + const existingValuesCount = + (option?.values?.length || 0) + existingCustom.length + + const newCustomValue = { + id: tempId, + value: newValue, + rank: existingValuesCount + newCustomValues.indexOf(newValue), + } - unusedPermutations.forEach((permutation) => { - newVariants.push({ - title: getVariantName(permutation), - options: permutation, - should_create: hasUserSelectedVariants ? false : true, - variant_rank: newVariants.length, - // NOTE - prepare inventory array here for now so we prevent rendering issue if we append the items later - inventory: [{ inventory_item_id: "", required_quantity: "" }], - }) + updatedCustomValues = { + ...updatedCustomValues, + [optionId]: [...(updatedCustomValues[optionId] || []), newCustomValue], + } + + updatedValidValueIds.push(tempId) }) - form.setValue("variants", newVariants) - } + if (newCustomValues.length > 0) { + setCustomValues(updatedCustomValues) + } - const handleRemoveOption = (index: number) => { - if (index === 0) { - return + const updatedSelectedValues = { + ...selectedOptionValues, + [optionId]: updatedValidValueIds, } - options.remove(index) + setSelectedOptionValues(updatedSelectedValues) - const newOptions = [...watchedOptions] - newOptions.splice(index, 1) - const validOptionTitles = new Set(newOptions.map((option) => option.title)) + const selectedProductOptions = product_options.filter((option) => + selectedOptionIds.includes(option.id) + ) + updateFormWithSelectedValues( + selectedProductOptions, + updatedSelectedValues, + newCustomValues.length > 0 ? updatedCustomValues : undefined + ) + } - const permutations = getPermutations(newOptions) - const oldVariants = [...watchedVariants] + const getAllValuesForOption = ( + optionId: string, + customVals?: Record< + string, + Array<{ id: string; value: string; rank: number }> + > + ) => { + const option = product_options.find((opt) => opt.id === optionId) + const existingValues = option?.values || [] + const customForOption = (customVals || customValues)[optionId] || [] - const newVariants = permutations.reduce((variants, permutation) => { - const variant = oldVariants.find(({ options }) => - Object.keys(options) - .filter((option) => validOptionTitles.has(option)) - .every((key) => options[key] === permutation[key]) - ) + return [...existingValues, ...customForOption] + } - if (variant) { - variants.push({ - ...variant, - title: variant.title, - options: permutation, + const updateFormWithSelectedValues = ( + selectedProductOptions: AdminProductOption[], + valueSelections: Record, + customVals?: Record< + string, + Array<{ + id: string + value: string + rank: number + }> + > + ) => { + const newOptions = selectedProductOptions.map((option) => { + const selectedValueIds = valueSelections[option.id] || [] + const allValues = getAllValuesForOption(option.id, customVals) + + const selectedValues = allValues + .filter((v) => selectedValueIds.includes(v.id)) + .sort((a, b) => { + const rankA = a.rank ?? Number.MAX_VALUE + const rankB = b.rank ?? Number.MAX_VALUE + return rankA - rankB }) + .map((v) => v.value) + + return { + id: option.id, + title: option.title, + values: selectedValues, } + }) - return variants - }, [] as typeof oldVariants) + form.setValue("options", newOptions) + + const permutations = getPermutations( + newOptions.filter(({ values }) => values.length) + ) + + const newVariants = permutations.map((permutation, index) => ({ + title: getVariantName(permutation), + options: permutation, + should_create: true, + variant_rank: index, + inventory: [{ inventory_item_id: "", required_quantity: "" }], + })) form.setValue("variants", newVariants) } @@ -295,132 +370,73 @@ export const ProductCreateVariantsSection = ({ {watchedAreVariantsEnabled && ( <>
- { - return ( - -
-
-
- - {t("products.create.variants.productOptions.label")} - - - {t("products.create.variants.productOptions.hint")} - -
- -
- {showInvalidOptionsMessage && ( - - {t("products.create.errors.options")} - - )} -
    - {options.fields.map((option, index) => { - const hasError = - !!form.formState.errors.options?.[index] - return ( -
  • -
    -
    - -
    - -
    - -
    - { - const handleValueChange = ( - value: string[] - ) => { - handleOptionValueUpdate(index, value) - onChange(value) - } - - return ( - - ) - }} - /> -
    - handleRemoveOption(index)} - > - - -
  • - ) - })} -
-
-
- ) - }} +
+ + {t("products.create.variants.productOptions.hint")} +
+ {showInvalidOptionsMessage && ( + + {t("products.create.errors.options")} + + )} + handleProductOptionSelect(value as string[])} + options={productOptionChoices} + placeholder={t("products.fields.options.optionTitlePlaceholder")} + disabled={isLoading} />
+ {selectedOptionIds.length > 0 && ( +
+
+ + {t("products.create.variants.selectValuesHint")} +
+
+ {product_options + .filter((option) => selectedOptionIds.includes(option.id)) + .map((option) => { + // Get all values (existing + custom) for this option + const allValues = getAllValuesForOption(option.id) + + const valueOptions = allValues + .sort((a, b) => { + const rankA = a.rank ?? Number.MAX_VALUE + const rankB = b.rank ?? Number.MAX_VALUE + return rankA - rankB + }) + .map((v) => ({ + value: v.id, + label: v.value, + })) + + return ( +
+ + + handleValueChange(option.id, value as string[]) + } + onCreateOption={(_) => { + // Todo + }} + options={valueOptions} + placeholder={t( + "products.fields.options.variantionsPlaceholder" + )} + /> +
+ ) + })} +
+
+ )}
diff --git a/packages/admin/dashboard/src/routes/products/product-create/components/product-create-form/product-create-form.tsx b/packages/admin/dashboard/src/routes/products/product-create/components/product-create-form/product-create-form.tsx index 604978387fb07..2ed35505def2a 100644 --- a/packages/admin/dashboard/src/routes/products/product-create/components/product-create-form/product-create-form.tsx +++ b/packages/admin/dashboard/src/routes/products/product-create/components/product-create-form/product-create-form.tsx @@ -78,13 +78,10 @@ export const ProductCreateForm = ({ return {} } - return regions.reduce( - (acc, reg) => { - acc[reg.id] = reg.currency_code - return acc - }, - {} as Record - ) + return regions.reduce((acc, reg) => { + acc[reg.id] = reg.currency_code + return acc + }, {} as Record) }, [regions]) /** diff --git a/packages/admin/dashboard/src/routes/products/product-create/constants.ts b/packages/admin/dashboard/src/routes/products/product-create/constants.ts index 0668e3f04ddb9..f1a624b1598aa 100644 --- a/packages/admin/dashboard/src/routes/products/product-create/constants.ts +++ b/packages/admin/dashboard/src/routes/products/product-create/constants.ts @@ -47,6 +47,7 @@ export type ProductCreateVariantSchema = z.infer< > const ProductCreateOptionSchema = z.object({ + id: z.string().optional(), title: z.string(), values: z.array(z.string()).min(1), }) diff --git a/packages/admin/dashboard/src/routes/products/product-create/utils.ts b/packages/admin/dashboard/src/routes/products/product-create/utils.ts index 31c74fd954393..e7da9be47dcd7 100644 --- a/packages/admin/dashboard/src/routes/products/product-create/utils.ts +++ b/packages/admin/dashboard/src/routes/products/product-create/utils.ts @@ -13,6 +13,8 @@ export const normalizeProductFormValues = ( ?.filter((media) => !media.isThumbnail) .map((media) => ({ url: media.url })) + const options = values.options.filter((o) => o.title) // clean temp. values + return { status: values.status, is_giftcard: false, @@ -41,7 +43,10 @@ export const normalizeProductFormValues = ( length: values.length ? parseFloat(values.length) : undefined, height: values.height ? parseFloat(values.height) : undefined, weight: values.weight ? parseFloat(values.weight) : undefined, - options: values.options.filter((o) => o.title), // clean temp. values + options: options.map((option) => { + const { id, ...rest } = option + return id ? { id } : rest + }), variants: normalizeVariants( values.variants.filter((variant) => variant.should_create), values.regionsCurrencyMap diff --git a/packages/admin/dashboard/src/routes/products/product-detail/components/product-option-section/product-option-section.tsx b/packages/admin/dashboard/src/routes/products/product-detail/components/product-option-section/product-option-section.tsx index 6e037c959c540..971220e3825ab 100644 --- a/packages/admin/dashboard/src/routes/products/product-detail/components/product-option-section/product-option-section.tsx +++ b/packages/admin/dashboard/src/routes/products/product-detail/components/product-option-section/product-option-section.tsx @@ -1,38 +1,16 @@ -import { PencilSquare, Plus, Trash } from "@medusajs/icons" -import { Badge, Container, Heading, usePrompt } from "@medusajs/ui" +import { PencilSquare } from "@medusajs/icons" +import { Badge, Container, Heading } from "@medusajs/ui" import { useTranslation } from "react-i18next" import { ActionMenu } from "../../../../../components/common/action-menu" import { SectionRow } from "../../../../../components/common/section" -import { useDeleteProductOption } from "../../../../../hooks/api/products" import { HttpTypes } from "@medusajs/types" const OptionActions = ({ - product, option, }: { - product: HttpTypes.AdminProduct option: HttpTypes.AdminProductOption }) => { const { t } = useTranslation() - const { mutateAsync } = useDeleteProductOption(product.id, option.id) - const prompt = usePrompt() - - const handleDelete = async () => { - const res = await prompt({ - title: t("general.areYouSure"), - description: t("products.options.deleteWarning", { - title: option.title, - }), - confirmText: t("actions.delete"), - cancelText: t("actions.cancel"), - }) - - if (!res) { - return - } - - await mutateAsync() - } return ( , }, ], }, - { - actions: [ - { - label: t("actions.delete"), - onClick: handleDelete, - icon: , - }, - ], - }, ]} /> ) @@ -78,9 +47,9 @@ export const ProductOptionSection = ({ { actions: [ { - label: t("actions.create"), - to: "options/create", - icon: , + label: t("actions.manage"), + to: "options/manage", + icon: , }, ], }, @@ -104,7 +73,7 @@ export const ProductOptionSection = ({ ) })} - actions={} + actions={} /> ) })} diff --git a/packages/admin/dashboard/src/routes/products/product-edit-option/components/edit-product-option-form/edit-product-option-form.tsx b/packages/admin/dashboard/src/routes/products/product-edit-option/components/edit-product-option-form/edit-product-option-form.tsx deleted file mode 100644 index e6d62d2b02ada..0000000000000 --- a/packages/admin/dashboard/src/routes/products/product-edit-option/components/edit-product-option-form/edit-product-option-form.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod" -import { Button, Input } from "@medusajs/ui" -import { useForm } from "react-hook-form" -import { useTranslation } from "react-i18next" -import { z } from "zod" - -import { HttpTypes } from "@medusajs/types" -import { Form } from "../../../../../components/common/form" -import { ChipInput } from "../../../../../components/inputs/chip-input" -import { RouteDrawer, useRouteModal } from "../../../../../components/modals" -import { KeyboundForm } from "../../../../../components/utilities/keybound-form" -import { useUpdateProductOption } from "../../../../../hooks/api/products" - -type EditProductOptionFormProps = { - option: HttpTypes.AdminProductOption -} - -const CreateProductOptionSchema = z.object({ - title: z.string().min(1), - values: z.array(z.string()).optional(), -}) - -export const CreateProductOptionForm = ({ - option, -}: EditProductOptionFormProps) => { - const { t } = useTranslation() - const { handleSuccess } = useRouteModal() - - const form = useForm>({ - defaultValues: { - title: option.title, - values: option.values.map((v: any) => v.value), - }, - resolver: zodResolver(CreateProductOptionSchema), - }) - - const { mutateAsync, isPending } = useUpdateProductOption( - option.product_id, - option.id - ) - - const handleSubmit = form.handleSubmit(async (values) => { - mutateAsync( - { - id: option.id, - ...values, - }, - { - onSuccess: () => { - handleSuccess() - }, - } - ) - }) - - return ( - - - - { - return ( - - - {t("products.fields.options.optionTitle")} - - - - - - - ) - }} - /> - { - return ( - - - {t("products.fields.options.variations")} - - - - - - - ) - }} - /> - - -
- - - - -
-
-
-
- ) -} diff --git a/packages/admin/dashboard/src/routes/products/product-edit-option/components/edit-product-option-form/index.ts b/packages/admin/dashboard/src/routes/products/product-edit-option/components/edit-product-option-form/index.ts deleted file mode 100644 index 280d44cc3c55f..0000000000000 --- a/packages/admin/dashboard/src/routes/products/product-edit-option/components/edit-product-option-form/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./edit-product-option-form" diff --git a/packages/admin/dashboard/src/routes/products/product-edit-option/index.ts b/packages/admin/dashboard/src/routes/products/product-edit-option/index.ts deleted file mode 100644 index 0d70a2c5527aa..0000000000000 --- a/packages/admin/dashboard/src/routes/products/product-edit-option/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ProductEditOption as Component } from "./product-edit-option" diff --git a/packages/admin/dashboard/src/routes/products/product-edit-option/product-edit-option.tsx b/packages/admin/dashboard/src/routes/products/product-edit-option/product-edit-option.tsx deleted file mode 100644 index 2b29a3a4aaba1..0000000000000 --- a/packages/admin/dashboard/src/routes/products/product-edit-option/product-edit-option.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Heading } from "@medusajs/ui" -import { useTranslation } from "react-i18next" -import { json, useParams } from "react-router-dom" -import { RouteDrawer } from "../../../components/modals" -import { useProduct } from "../../../hooks/api/products" -import { CreateProductOptionForm } from "./components/edit-product-option-form" - -export const ProductEditOption = () => { - const { id, option_id } = useParams() - const { t } = useTranslation() - - const { product, isPending, isFetching, isError, error } = useProduct(id!) - - const option = product?.options.find((o) => o.id === option_id) - - if (!isPending && !isFetching && !option) { - throw json({ message: `An option with ID ${option_id} was not found` }, 404) - } - - if (isError) { - throw error - } - - return ( - - - {t("products.options.edit.header")} - - {option && } - - ) -} diff --git a/packages/admin/dashboard/src/routes/products/product-options-manage/components/product-options-manage-form/index.ts b/packages/admin/dashboard/src/routes/products/product-options-manage/components/product-options-manage-form/index.ts new file mode 100644 index 0000000000000..5401e245939e2 --- /dev/null +++ b/packages/admin/dashboard/src/routes/products/product-options-manage/components/product-options-manage-form/index.ts @@ -0,0 +1 @@ +export { ProductOptionsManageForm } from "./product-options-manage-form" \ No newline at end of file diff --git a/packages/admin/dashboard/src/routes/products/product-options-manage/components/product-options-manage-form/product-options-manage-form.tsx b/packages/admin/dashboard/src/routes/products/product-options-manage/components/product-options-manage-form/product-options-manage-form.tsx new file mode 100644 index 0000000000000..597d65a554b08 --- /dev/null +++ b/packages/admin/dashboard/src/routes/products/product-options-manage/components/product-options-manage-form/product-options-manage-form.tsx @@ -0,0 +1,147 @@ +import { HttpTypes } from "@medusajs/types" +import { Button, toast } from "@medusajs/ui" +import { useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import * as zod from "zod" + +import { Form } from "../../../../../components/common/form" +import { Combobox } from "../../../../../components/inputs/combobox" +import { RouteDrawer, useRouteModal } from "../../../../../components/modals" +import { KeyboundForm } from "../../../../../components/utilities/keybound-form" +import { useExtendableForm } from "../../../../../dashboard-app" +import { + useLinkProductOptions, + useProductOptions, +} from "../../../../../hooks/api" +import { useExtension } from "../../../../../providers/extension-provider" + +type ProductOptionsManageFormProps = { + product: HttpTypes.AdminProduct +} + +const ProductOptionsManageSchema = zod.object({ + option_ids: zod.array(zod.string()), +}) + +export const ProductOptionsManageForm = ({ + product, +}: ProductOptionsManageFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + const { getFormConfigs } = useExtension() + const configs = getFormConfigs("product", "edit") + + const { product_options = [], isLoading } = useProductOptions({ + is_exclusive: false, + }) + + const productOptionChoices = useMemo(() => { + return product_options.map((option) => ({ + value: option.id, + label: option.title, + })) + }, [product_options]) + + const [selectedOptionIds, setSelectedOptionIds] = useState( + product.options?.map((opt) => opt.id) || [] + ) + + const form = useExtendableForm({ + defaultValues: { + option_ids: product.options?.map((opt) => opt.id) || [], + }, + schema: ProductOptionsManageSchema, + configs: configs, + data: product, + }) + + const { mutateAsync, isPending } = useLinkProductOptions(product.id) + + const handleProductOptionSelect = (optionIds: string[]) => { + setSelectedOptionIds(optionIds) + form.setValue("option_ids", optionIds) + } + + const handleSubmit = form.handleSubmit(async (data) => { + const currentOptionIds = product.options?.map((opt) => opt.id) || [] + const newOptionIds = data.option_ids + + // Determine which options to add and remove + const optionsToAdd = newOptionIds.filter( + (id) => !currentOptionIds.includes(id) + ) + const optionsToRemove = currentOptionIds.filter( + (id) => !newOptionIds.includes(id) + ) + + await mutateAsync( + { + add: optionsToAdd, + remove: optionsToRemove, + }, + { + onSuccess: ({ product }) => { + toast.success( + t("products.organization.edit.toasts.success", { + title: product.title, + }) + ) + handleSuccess() + }, + onError: (error) => { + toast.error(error.message) + }, + } + ) + }) + + return ( + + + +
+ { + return ( + + + {t("products.options.manage.label")} + + {t("products.options.manage.hint")} + + + handleProductOptionSelect(value as string[]) + } + options={productOptionChoices} + placeholder={t("products.options.manage.placeholder")} + disabled={isLoading} + /> + + + + ) + }} + /> +
+
+ +
+ + + + +
+
+
+
+ ) +} diff --git a/packages/admin/dashboard/src/routes/products/product-options-manage/index.ts b/packages/admin/dashboard/src/routes/products/product-options-manage/index.ts new file mode 100644 index 0000000000000..7878b669e2a18 --- /dev/null +++ b/packages/admin/dashboard/src/routes/products/product-options-manage/index.ts @@ -0,0 +1 @@ +export { ProductOptionsManage as Component } from "./product-options-manage" \ No newline at end of file diff --git a/packages/admin/dashboard/src/routes/products/product-options-manage/product-options-manage.tsx b/packages/admin/dashboard/src/routes/products/product-options-manage/product-options-manage.tsx new file mode 100644 index 0000000000000..f501a9a517dde --- /dev/null +++ b/packages/admin/dashboard/src/routes/products/product-options-manage/product-options-manage.tsx @@ -0,0 +1,35 @@ +import { Heading } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { useParams } from "react-router-dom" + +import { RouteDrawer } from "../../../components/modals" +import { useProduct } from "../../../hooks/api" +import { PRODUCT_DETAIL_FIELDS } from "../product-detail/constants" +import { ProductOptionsManageForm } from "./components/product-options-manage-form" + +export const ProductOptionsManage = () => { + const { id } = useParams() + const { t } = useTranslation() + + const { product, isLoading, isError, error } = useProduct(id!, { + fields: PRODUCT_DETAIL_FIELDS, + }) + + if (isError) { + throw error + } + + return ( + + + + {t("products.options.manage.header")} + + + {t("products.options.manage.description")} + + + {!isLoading && product && } + + ) +} diff --git a/packages/core/js-sdk/src/admin/index.ts b/packages/core/js-sdk/src/admin/index.ts index bc300e5571b35..6175a48d83eae 100644 --- a/packages/core/js-sdk/src/admin/index.ts +++ b/packages/core/js-sdk/src/admin/index.ts @@ -23,6 +23,7 @@ import { PricePreference } from "./price-preference" import { Product } from "./product" import { ProductCategory } from "./product-category" import { ProductCollection } from "./product-collection" +import { ProductOption } from "./product-option" import { ProductTag } from "./product-tag" import { ProductType } from "./product-type" import { ProductVariant } from "./product-variant" @@ -63,6 +64,10 @@ export class Admin { * @tags product */ public productCategory: ProductCategory + /** + * @tags product + */ + public productOption: ProductOption /** * @tags pricing */ @@ -238,6 +243,7 @@ export class Admin { this.customer = new Customer(client) this.productCollection = new ProductCollection(client) this.productCategory = new ProductCategory(client) + this.productOption = new ProductOption(client) this.priceList = new PriceList(client) this.pricePreference = new PricePreference(client) this.product = new Product(client) diff --git a/packages/core/js-sdk/src/admin/product-option.ts b/packages/core/js-sdk/src/admin/product-option.ts new file mode 100644 index 0000000000000..365e61c48c1c6 --- /dev/null +++ b/packages/core/js-sdk/src/admin/product-option.ts @@ -0,0 +1,185 @@ +import { Client } from "../client" +import { ClientHeaders } from "../types" +import { HttpTypes, SelectParams } from "@medusajs/types" + +export class ProductOption { + /** + * @ignore + */ + private client: Client + /** + * @ignore + */ + constructor(client: Client) { + this.client = client + } + + /** + * This method creates a product option. It sends a request to the + * [Create Option](TODO) + * API route. + * + * @param body - The details of the option to create. + * @param query - Configure the fields to retrieve in the option. + * @param headers - Headers to pass in the request + * @returns The option's details. + * + * @example + * sdk.admin.productOption.create({ + * title: "Size", + * values: ["S", "M"] + * }) + * .then(({ product_option }) => { + * console.log(product_option) + * }) + */ + async create( + body: HttpTypes.AdminCreateProductOption, + query?: HttpTypes.AdminProductOptionParams, + headers?: ClientHeaders + ) { + return this.client.fetch( + `/admin/product-options`, + { + method: "POST", + headers, + body, + query, + } + ) + } + + /** + * This method updates a product option. It sends a request to the + * [Update Option](TODO) + * API route. + * + * @param id - The product option's ID. + * @param body - The data to update in the option. + * @param query - Configure the fields to retrieve in the option. + * @param headers - Headers to pass in the request + * @returns The option's details. + * + * @example + * sdk.admin.productOption.update("opt_123", { + * title: "Size" + * }) + * .then(({ product_option }) => { + * console.log(product_option) + * }) + */ + async update( + id: string, + body: HttpTypes.AdminUpdateProductOption, + query?: HttpTypes.AdminProductOptionParams, + headers?: ClientHeaders + ) { + return this.client.fetch( + `/admin/product-options/${id}`, + { + method: "POST", + headers, + body, + query, + } + ) + } + + /** + * This method retrieves a paginated list of product options. It sends a request to the + * List Product Options API route. + * + * @param queryParams - Filters and pagination configurations. + * @param headers - Headers to pass in the request. + * @returns The paginated list of product options. + * + * @example + * To retrieve the list of product options: + * + * ```ts + * sdk.admin.productOption.list() + * .then(({ product_options, count, limit, offset }) => { + * console.log(product_options) + * }) + * ``` + * + * To configure the pagination, pass the `limit` and `offset` query parameters. + * + * For example, to retrieve only 10 items and skip 10 items: + * + * ```ts + * sdk.admin.productOption.list({ + * limit: 10, + * offset: 10 + * }) + * .then(({ product_options, count, limit, offset }) => { + * console.log(product_options) + * }) + * ``` + */ + async list( + queryParams?: HttpTypes.AdminProductOptionListParams, + headers?: ClientHeaders + ) { + return this.client.fetch( + `/admin/product-options`, + { + headers, + query: queryParams, + } + ) + } + + /** + * This method retrieves a product option by its ID. It sends a request to the + * Get Product Option API route. + * + * @param id - The product option's ID. + * @param query - Configure the fields to retrieve in the product option. + * @param headers - Headers to pass in the request + * @returns The product option's details. + * + * @example + * To retrieve a product option by its ID: + * + * ```ts + * sdk.admin.productOption.retrieve("opt_123") + * .then(({ product_option }) => { + * console.log(product_option) + * }) + * ``` + */ + async retrieve(id: string, query?: SelectParams, headers?: ClientHeaders) { + return this.client.fetch( + `/admin/product-options/${id}`, + { + query, + headers, + } + ) + } + + /** + * This method deletes a product option. It sends a request to the + * Delete Product Option API route. + * + * @param id - The product option's ID. + * @param headers - Headers to pass in the request + * @returns The deletion's details. + * + * @example + * sdk.admin.productOption.delete("opt_123") + * .then(({ deleted }) => { + * console.log(deleted) + * }) + */ + async delete(id: string, headers?: ClientHeaders) { + return this.client.fetch( + `/admin/product-options/${id}`, + { + method: "DELETE", + headers, + } + ) + } +} diff --git a/packages/core/js-sdk/src/admin/product.ts b/packages/core/js-sdk/src/admin/product.ts index c9b9792e09a75..c87d611a48f08 100644 --- a/packages/core/js-sdk/src/admin/product.ts +++ b/packages/core/js-sdk/src/admin/product.ts @@ -1080,7 +1080,7 @@ export class Product { * This method manages image-variant associations for a specific image. It sends a request to the * [Batch Image Variants](https://docs.medusajs.com/api/admin#products_postproductsidimagesimage_idvariantsbatch) * API route. - * + * * @since 2.11.2 * * @param productId - The product's ID. @@ -1118,7 +1118,7 @@ export class Product { * This method manages variant-image associations for a specific variant. It sends a request to the * [Batch Variant Images](https://docs.medusajs.com/api/admin#products_postproductsidvariantsvariant_idimagesbatch) * API route. - * + * * @since 2.11.2 * * @param productId - The product's ID. @@ -1151,4 +1151,42 @@ export class Product { } ) } + + /** + * This method links product options to a product. It allows adding new options + * or removing existing ones. It sends a request to the + * [Link Product Options](TODO) + * API route. + * + * @param productId - The product's ID. + * @param body - The options to add or remove. + * @param query - Configure the fields to retrieve in the product. + * @param headers - Headers to pass in the request + * @returns The product's details. + * + * @example + * sdk.admin.product.linkOptions("prod_123", { + * add: ["prodopt_123", "prodopt_456"], + * remove: ["prodopt_789"] + * }) + * .then(({ product }) => { + * console.log(product) + * }) + */ + async linkOptions( + productId: string, + body: HttpTypes.AdminLinkProductOptions, + query?: SelectParams, + headers?: ClientHeaders + ) { + return await this.client.fetch( + `/admin/products/${productId}/options`, + { + method: "POST", + headers, + body: body, + query, + } + ) + } }