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,
+ }
+ )
+ }
}