diff --git a/.changeset/quick-jeans-push.md b/.changeset/quick-jeans-push.md
new file mode 100644
index 0000000000..59ce3bc9c4
--- /dev/null
+++ b/.changeset/quick-jeans-push.md
@@ -0,0 +1,6 @@
+---
+"medusa-react": patch
+"@medusajs/medusa": patch
+---
+
+feat(dashboard): added details page for promotions
diff --git a/integration-tests/modules/__tests__/pricing/admin/rule-types.spec.ts b/integration-tests/modules/__tests__/pricing/admin/rule-types.spec.ts
index 190840cf0c..d33c11078d 100644
--- a/integration-tests/modules/__tests__/pricing/admin/rule-types.spec.ts
+++ b/integration-tests/modules/__tests__/pricing/admin/rule-types.spec.ts
@@ -194,7 +194,7 @@ medusaIntegrationTestRunner({
`/admin/pricing/rule-types/${ruleType.id}`,
adminHeaders
)
- console.log("response.data -- ", response.data)
+
expect(response.status).toEqual(200)
expect(response.data).toEqual({
id: ruleType.id,
diff --git a/packages/admin-next/dashboard/public/locales/$schema.json b/packages/admin-next/dashboard/public/locales/$schema.json
index d391cfc9bd..98af3952a9 100644
--- a/packages/admin-next/dashboard/public/locales/$schema.json
+++ b/packages/admin-next/dashboard/public/locales/$schema.json
@@ -140,6 +140,15 @@
},
"required": ["domain"]
},
+ "campaigns": {
+ "type": "object",
+ "properties": {
+ "domain": {
+ "type": "string"
+ }
+ },
+ "required": ["domain"]
+ },
"giftCards": {
"type": "object",
"properties": {
diff --git a/packages/admin-next/dashboard/public/locales/en-US/translation.json b/packages/admin-next/dashboard/public/locales/en-US/translation.json
index 9cd31cc4f1..a0b8c7cab4 100644
--- a/packages/admin-next/dashboard/public/locales/en-US/translation.json
+++ b/packages/admin-next/dashboard/public/locales/en-US/translation.json
@@ -30,6 +30,7 @@
"items_one": "{{count}} item",
"items_other": "{{count}} items",
"countSelected": "{{count}} selected",
+ "plusCount": "+ {{count}}",
"plusCountMore": "+ {{count}} more",
"areYouSure": "Are you sure?",
"noRecordsFound": "No records found",
@@ -611,12 +612,116 @@
"domain": "Promotions",
"fields": {
"method": "Method",
- "campaign": "Campaign"
+ "type": "Type",
+ "value_type": "Value Type",
+ "value": "Value",
+ "campaign": "Campaign",
+ "allocation": "Allocation",
+ "conditions": {
+ "rules": {
+ "title": "Who can use this code?",
+ "description": "Is the customer allowed to add the promotion code? Discount code can be used by all customers if left untouched. Choose between attributes, operators, and values to set up the conditions."
+ },
+ "target-rules": {
+ "title": "What needs to be in the cart to unlock the promotion?",
+ "description": "If these conditions match, we enable a promotion action on the target items. Choose between attributes, operators, and values to set up the conditions."
+ },
+ "buy-rules": {
+ "title": "What will the promotion be applied to?",
+ "description": "The promotion will be applied to items that match these conditions"
+ }
+ }
+ },
+ "edit": {
+ "title": "Edit Promotion Details",
+ "rules": {
+ "title": "Edit rules"
+ },
+ "target-rules": {
+ "title": "Edit target rules"
+ },
+ "buy-rules": {
+ "title": "Edit buy rules"
+ }
+ },
+ "addToCampaign": {
+ "title": "Add Promotion To Campaign"
+ },
+ "form": {
+ "campaign": {
+ "existing": {
+ "title": "Existing Campaign",
+ "description": "Would you like to add promotion to an existing campaign?"
+ },
+ "new": {
+ "title": "New Campaign",
+ "description": "Would you like to create a new campaign with this promotion?"
+ }
+ },
+ "status": {
+ "title": "Status"
+ },
+ "method": {
+ "code": {
+ "title": "Promotion code",
+ "description": "Customers must enter this at checkout"
+ },
+ "automatic": {
+ "title": "Automatic",
+ "description": "Customers will automatically see this during checkout"
+ }
+ },
+ "allocation": {
+ "each": {
+ "title": "Each",
+ "description": "Applies value on each product"
+ },
+ "across": {
+ "title": "Across",
+ "description": "Applies value proportionally across products"
+ }
+ },
+ "code": {
+ "title": "Code",
+ "description": "The code your customers will enter during checkout."
+ },
+ "value": {
+ "title": "Value"
+ },
+ "value_type": {
+ "fixed": {
+ "title": "Fixed amount",
+ "description": "eg. 100"
+ },
+ "percentage": {
+ "title": "Percentage",
+ "description": "eg. 8%"
+ }
+ }
},
"deleteWarning": "You are about to delete the promotion {{code}}. This action cannot be undone.",
"createPromotionTitle": "Create Promotion",
"type": "Promotion type"
},
+ "campaigns": {
+ "domain": "Campaigns",
+ "details": "Campaign details",
+ "fields": {
+ "name": "Name",
+ "identifier": "Identifier",
+ "start_date": "Start date",
+ "end_date": "End date"
+ },
+ "budget": {
+ "details": "Campaign budget",
+ "fields": {
+ "type": "Type",
+ "currency": "Currency",
+ "limit": "Limit",
+ "used": "Used"
+ }
+ }
+ },
"pricing": {
"domain": "Pricing",
"deletePriceListWarning": "You are about to delete the price list {{name}}. This action cannot be undone.",
diff --git a/packages/admin-next/dashboard/src/components/common/badge-list-summary/badge-list-summary.tsx b/packages/admin-next/dashboard/src/components/common/badge-list-summary/badge-list-summary.tsx
new file mode 100644
index 0000000000..eb0887e210
--- /dev/null
+++ b/packages/admin-next/dashboard/src/components/common/badge-list-summary/badge-list-summary.tsx
@@ -0,0 +1,73 @@
+import { Badge, Tooltip, clx } from "@medusajs/ui"
+import { useTranslation } from "react-i18next"
+
+type BadgeListSummaryProps = {
+ /**
+ * Number of initial items to display
+ * @default 2
+ */
+ n?: number
+ /**
+ * List of strings to display as abbreviated list
+ */
+ list: string[]
+ /**
+ * Is the summary displayed inline.
+ * Determines whether the center text is truncated if there is no space in the container
+ */
+ inline?: boolean
+
+ className?: string
+}
+
+export const BadgeListSummary = ({
+ list,
+ className,
+ inline,
+ n = 2,
+}: BadgeListSummaryProps) => {
+ const { t } = useTranslation()
+
+ const title = t("general.plusCount", {
+ count: list.length - n,
+ })
+
+ return (
+
+ {list.slice(0, n).map((item) => {
+ return (
+
+ {item}
+
+ )
+ })}
+
+ {list.length > n && (
+
+
+ {list.slice(n).map((c) => (
+ {c}
+ ))}
+
+ }
+ >
+
+ {title}
+
+
+
+ )}
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/components/common/badge-list-summary/index.ts b/packages/admin-next/dashboard/src/components/common/badge-list-summary/index.ts
new file mode 100644
index 0000000000..a156dab659
--- /dev/null
+++ b/packages/admin-next/dashboard/src/components/common/badge-list-summary/index.ts
@@ -0,0 +1 @@
+export * from "./badge-list-summary"
diff --git a/packages/admin-next/dashboard/src/components/common/empty-table-content/empty-table-content.tsx b/packages/admin-next/dashboard/src/components/common/empty-table-content/empty-table-content.tsx
index 76a50895c0..5aac770ac7 100644
--- a/packages/admin-next/dashboard/src/components/common/empty-table-content/empty-table-content.tsx
+++ b/packages/admin-next/dashboard/src/components/common/empty-table-content/empty-table-content.tsx
@@ -1,4 +1,4 @@
-import { ExclamationCircle, MagnifyingGlass } from "@medusajs/icons"
+import { ExclamationCircle, MagnifyingGlass, PlusMini } from "@medusajs/icons"
import { Button, Text, clx } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
@@ -32,21 +32,44 @@ export const NoResults = ({ title, message, className }: NoResultsProps) => {
)
}
-type NoRecordsProps = {
- title?: string
- message?: string
+type ActionProps = {
action?: {
to: string
label: string
}
- className?: string
}
+type NoRecordsProps = {
+ title?: string
+ message?: string
+ className?: string
+ buttonVariant?: string
+} & ActionProps
+
+const DefaultButton = ({ action }: ActionProps) =>
+ action && (
+
+
+
+ )
+
+const TransparentIconLeftButton = ({ action }: ActionProps) =>
+ action && (
+
+
+
+ )
+
export const NoRecords = ({
title,
message,
action,
className,
+ buttonVariant = "default",
}: NoRecordsProps) => {
const { t } = useTranslation()
@@ -59,19 +82,19 @@ export const NoRecords = ({
>
+
{title ?? t("general.noRecordsTitle")}
+
{message ?? t("general.noRecordsMessage")}
- {action && (
-
-
-
+
+ {buttonVariant === "default" && }
+ {buttonVariant === "transparentIconLeft" && (
+
)}
)
diff --git a/packages/admin-next/dashboard/src/hooks/api/campaigns.tsx b/packages/admin-next/dashboard/src/hooks/api/campaigns.tsx
new file mode 100644
index 0000000000..53c20c3721
--- /dev/null
+++ b/packages/admin-next/dashboard/src/hooks/api/campaigns.tsx
@@ -0,0 +1,96 @@
+import {
+ QueryKey,
+ UseMutationOptions,
+ UseQueryOptions,
+ useMutation,
+ useQuery,
+} from "@tanstack/react-query"
+import { client } from "../../lib/client"
+import { queryClient } from "../../lib/medusa"
+import { queryKeysFactory } from "../../lib/query-key-factory"
+import { CreateCampaignReq, UpdateCampaignReq } from "../../types/api-payloads"
+import {
+ CampaignDeleteRes,
+ CampaignListRes,
+ CampaignRes,
+} from "../../types/api-responses"
+
+const REGIONS_QUERY_KEY = "campaigns" as const
+const campaignsQueryKeys = queryKeysFactory(REGIONS_QUERY_KEY)
+
+export const useCampaign = (
+ id: string,
+ options?: Omit<
+ UseQueryOptions,
+ "queryFn" | "queryKey"
+ >
+) => {
+ const { data, ...rest } = useQuery({
+ queryKey: campaignsQueryKeys.detail(id),
+ queryFn: async () => client.campaigns.retrieve(id),
+ ...options,
+ })
+
+ return { ...data, ...rest }
+}
+
+export const useCampaigns = (
+ query?: Record,
+ options?: Omit<
+ UseQueryOptions,
+ "queryFn" | "queryKey"
+ >
+) => {
+ const { data, ...rest } = useQuery({
+ queryFn: () => client.campaigns.list(query),
+ queryKey: campaignsQueryKeys.list(query),
+ ...options,
+ })
+
+ return { ...data, ...rest }
+}
+
+export const useCreateCampaign = (
+ options?: UseMutationOptions
+) => {
+ return useMutation({
+ mutationFn: (payload) => client.campaigns.create(payload),
+ onSuccess: (data, variables, context) => {
+ queryClient.invalidateQueries({ queryKey: campaignsQueryKeys.lists() })
+ options?.onSuccess?.(data, variables, context)
+ },
+ ...options,
+ })
+}
+
+export const useUpdateCampaign = (
+ id: string,
+ options?: UseMutationOptions
+) => {
+ return useMutation({
+ mutationFn: (payload) => client.campaigns.update(id, payload),
+ onSuccess: (data, variables, context) => {
+ queryClient.invalidateQueries({ queryKey: campaignsQueryKeys.lists() })
+ queryClient.invalidateQueries({ queryKey: campaignsQueryKeys.detail(id) })
+
+ options?.onSuccess?.(data, variables, context)
+ },
+ ...options,
+ })
+}
+
+export const useDeleteCampaign = (
+ id: string,
+ options?: UseMutationOptions
+) => {
+ return useMutation({
+ mutationFn: () => client.campaigns.delete(id),
+ onSuccess: (data, variables, context) => {
+ queryClient.invalidateQueries({ queryKey: campaignsQueryKeys.lists() })
+ queryClient.invalidateQueries({ queryKey: campaignsQueryKeys.detail(id) })
+
+ options?.onSuccess?.(data, variables, context)
+ },
+ ...options,
+ })
+}
diff --git a/packages/admin-next/dashboard/src/hooks/api/promotions.tsx b/packages/admin-next/dashboard/src/hooks/api/promotions.tsx
index 1ee3ade187..81b3820b4b 100644
--- a/packages/admin-next/dashboard/src/hooks/api/promotions.tsx
+++ b/packages/admin-next/dashboard/src/hooks/api/promotions.tsx
@@ -1,12 +1,47 @@
-import { QueryKey, UseQueryOptions, useQuery } from "@tanstack/react-query"
-
import { AdminGetPromotionsParams } from "@medusajs/medusa"
+import {
+ QueryKey,
+ useMutation,
+ UseMutationOptions,
+ useQuery,
+ UseQueryOptions,
+} from "@tanstack/react-query"
import { client } from "../../lib/client"
+import { queryClient } from "../../lib/medusa"
import { queryKeysFactory } from "../../lib/query-key-factory"
-import { PromotionListRes, PromotionRes } from "../../types/api-responses"
+import {
+ BatchAddPromotionRulesReq,
+ BatchRemovePromotionRulesReq,
+ BatchUpdatePromotionRulesReq,
+ CreatePromotionReq,
+ UpdatePromotionReq,
+} from "../../types/api-payloads"
+import {
+ PromotionDeleteRes,
+ PromotionListRes,
+ PromotionRes,
+ PromotionRuleAttributesListRes,
+ PromotionRuleOperatorsListRes,
+ PromotionRulesListRes,
+ PromotionRuleValuesListRes,
+} from "../../types/api-responses"
const PROMOTIONS_QUERY_KEY = "promotions" as const
-const promotionsQueryKeys = queryKeysFactory(PROMOTIONS_QUERY_KEY)
+export const promotionsQueryKeys = {
+ ...queryKeysFactory(PROMOTIONS_QUERY_KEY),
+ listRules: (id: string, ruleType: string) => [
+ PROMOTIONS_QUERY_KEY,
+ id,
+ ruleType,
+ ],
+ listRuleAttributes: (ruleType: string) => [PROMOTIONS_QUERY_KEY, ruleType],
+ listRuleValues: (ruleType: string, ruleValue: string) => [
+ PROMOTIONS_QUERY_KEY,
+ ruleType,
+ ruleValue,
+ ],
+ listRuleOperators: () => [PROMOTIONS_QUERY_KEY],
+}
export const usePromotion = (
id: string,
@@ -24,6 +59,28 @@ export const usePromotion = (
return { ...data, ...rest }
}
+export const usePromotionRules = (
+ id: string,
+ ruleType: string,
+ options?: Omit<
+ UseQueryOptions<
+ PromotionRulesListRes,
+ Error,
+ PromotionRulesListRes,
+ QueryKey
+ >,
+ "queryFn" | "queryKey"
+ >
+) => {
+ const { data, ...rest } = useQuery({
+ queryKey: promotionsQueryKeys.listRules(id, ruleType),
+ queryFn: async () => client.promotions.listRules(id, ruleType),
+ ...options,
+ })
+
+ return { ...data, ...rest }
+}
+
export const usePromotions = (
query?: AdminGetPromotionsParams,
options?: Omit<
@@ -39,3 +96,185 @@ export const usePromotions = (
return { ...data, ...rest }
}
+
+export const usePromotionRuleOperators = (
+ options?: Omit<
+ UseQueryOptions<
+ PromotionListRes,
+ Error,
+ PromotionRuleOperatorsListRes,
+ QueryKey
+ >,
+ "queryFn" | "queryKey"
+ >
+) => {
+ const { data, ...rest } = useQuery({
+ queryKey: promotionsQueryKeys.listRuleOperators(),
+ queryFn: async () => client.promotions.listRuleOperators(),
+ ...options,
+ })
+
+ return { ...data, ...rest }
+}
+
+export const usePromotionRuleAttributes = (
+ ruleType: string,
+ options?: Omit<
+ UseQueryOptions<
+ PromotionListRes,
+ Error,
+ PromotionRuleAttributesListRes,
+ QueryKey
+ >,
+ "queryFn" | "queryKey"
+ >
+) => {
+ const { data, ...rest } = useQuery({
+ queryKey: promotionsQueryKeys.listRuleAttributes(ruleType),
+ queryFn: async () => client.promotions.listRuleAttributes(ruleType),
+ ...options,
+ })
+
+ return { ...data, ...rest }
+}
+
+export const usePromotionRuleValues = (
+ ruleType: string,
+ ruleValue: string,
+ options?: Omit<
+ UseQueryOptions<
+ PromotionListRes,
+ Error,
+ PromotionRuleValuesListRes,
+ QueryKey
+ >,
+ "queryFn" | "queryKey"
+ >
+) => {
+ const { data, ...rest } = useQuery({
+ queryKey: promotionsQueryKeys.listRuleValues(ruleType, ruleValue),
+ queryFn: async () => client.promotions.listRuleValues(ruleType, ruleValue),
+ ...options,
+ })
+
+ return { ...data, ...rest }
+}
+
+export const useDeletePromotion = (
+ id: string,
+ options?: UseMutationOptions
+) => {
+ return useMutation({
+ mutationFn: () => client.promotions.delete(id),
+ onSuccess: (data, variables, context) => {
+ queryClient.invalidateQueries({ queryKey: promotionsQueryKeys.lists() })
+ queryClient.invalidateQueries({
+ queryKey: promotionsQueryKeys.detail(id),
+ })
+
+ options?.onSuccess?.(data, variables, context)
+ },
+ ...options,
+ })
+}
+
+export const useCreatePromotion = (
+ options?: UseMutationOptions
+) => {
+ return useMutation({
+ mutationFn: (payload) => client.promotions.create(payload),
+ onSuccess: (data, variables, context) => {
+ queryClient.invalidateQueries({ queryKey: promotionsQueryKeys.lists() })
+ options?.onSuccess?.(data, variables, context)
+ },
+ ...options,
+ })
+}
+
+export const useUpdatePromotion = (
+ id: string,
+ options?: UseMutationOptions
+) => {
+ return useMutation({
+ mutationFn: (payload) => client.promotions.update(id, payload),
+ onSuccess: (data, variables, context) => {
+ queryClient.invalidateQueries({ queryKey: promotionsQueryKeys.lists() })
+ queryClient.invalidateQueries({
+ queryKey: promotionsQueryKeys.detail(id),
+ })
+
+ options?.onSuccess?.(data, variables, context)
+ },
+ ...options,
+ })
+}
+
+export const usePromotionAddRules = (
+ id: string,
+ ruleType: string,
+ options?: UseMutationOptions
+) => {
+ return useMutation({
+ mutationFn: (payload) => client.promotions.addRules(id, ruleType, payload),
+ onSuccess: (data, variables, context) => {
+ queryClient.invalidateQueries({ queryKey: promotionsQueryKeys.lists() })
+ queryClient.invalidateQueries({
+ queryKey: promotionsQueryKeys.detail(id),
+ })
+
+ options?.onSuccess?.(data, variables, context)
+ },
+ ...options,
+ })
+}
+
+export const usePromotionRemoveRules = (
+ id: string,
+ ruleType: string,
+ options?: UseMutationOptions<
+ PromotionRes,
+ Error,
+ BatchRemovePromotionRulesReq
+ >
+) => {
+ return useMutation({
+ mutationFn: (payload) =>
+ client.promotions.removeRules(id, ruleType, payload),
+ onSuccess: (data, variables, context) => {
+ queryClient.invalidateQueries({ queryKey: promotionsQueryKeys.lists() })
+ queryClient.invalidateQueries({
+ queryKey: promotionsQueryKeys.detail(id),
+ })
+
+ options?.onSuccess?.(data, variables, context)
+ },
+ ...options,
+ })
+}
+
+export const usePromotionUpdateRules = (
+ id: string,
+ ruleType: string,
+ options?: UseMutationOptions<
+ PromotionRes,
+ Error,
+ BatchUpdatePromotionRulesReq
+ >
+) => {
+ return useMutation({
+ mutationFn: (payload) =>
+ client.promotions.updateRules(id, ruleType, payload),
+ onSuccess: (data, variables, context) => {
+ queryClient.invalidateQueries({ queryKey: promotionsQueryKeys.lists() })
+ queryClient.invalidateQueries({
+ queryKey: promotionsQueryKeys.listRules(id, ruleType),
+ })
+ queryClient.invalidateQueries({
+ queryKey: promotionsQueryKeys.detail(id),
+ })
+
+ options?.onSuccess?.(data, variables, context)
+ },
+ ...options,
+ })
+}
diff --git a/packages/admin-next/dashboard/src/lib/api-v2/campaign.ts b/packages/admin-next/dashboard/src/lib/api-v2/campaign.ts
new file mode 100644
index 0000000000..ed50e576f9
--- /dev/null
+++ b/packages/admin-next/dashboard/src/lib/api-v2/campaign.ts
@@ -0,0 +1,57 @@
+import {
+ AdminCampaignRes,
+ AdminCampaignsListRes,
+ AdminGetCampaignsCampaignParams,
+ AdminGetCampaignsParams,
+ AdminPostCampaignsCampaignReq,
+} from "@medusajs/medusa"
+import { useMutation } from "@tanstack/react-query"
+import { queryKeysFactory, useAdminCustomQuery } from "medusa-react"
+import { medusa } from "../medusa"
+
+const QUERY_KEY = "admin_campaigns"
+export const adminCampaignKeys = queryKeysFactory<
+ typeof QUERY_KEY,
+ AdminGetCampaignsParams
+>(QUERY_KEY)
+
+export const adminCampaignQueryFns = {
+ list: (query: AdminGetCampaignsParams) =>
+ medusa.admin.custom.get(`/admin/campaigns`, query),
+ detail: (id: string) => medusa.admin.custom.get(`/admin/campaigns/${id}`),
+}
+
+export const useV2Campaigns = (
+ query?: AdminGetCampaignsParams,
+ options?: object
+) => {
+ const { data, ...rest } = useAdminCustomQuery<
+ AdminGetCampaignsParams,
+ AdminCampaignsListRes
+ >("/admin/campaigns", adminCampaignKeys.list(query), query, options)
+
+ return { ...data, ...rest }
+}
+
+export const useV2Campaign = (
+ id: string,
+ query?: AdminGetCampaignsParams,
+ options?: object
+) => {
+ const { data, ...rest } = useAdminCustomQuery<
+ AdminGetCampaignsCampaignParams,
+ AdminCampaignRes
+ >(`/admin/campaigns/${id}`, adminCampaignKeys.detail(id), query, options)
+
+ return { ...data, ...rest }
+}
+
+export const useV2DeleteCampaign = (id: string) => {
+ return useMutation(() => medusa.admin.custom.delete(`/admin/campaigns/${id}`))
+}
+
+export const useV2PostCampaign = (id: string) => {
+ return useMutation((args: AdminPostCampaignsCampaignReq) =>
+ medusa.client.request("POST", `/admin/campaigns/${id}`, args)
+ )
+}
diff --git a/packages/admin-next/dashboard/src/lib/api-v2/index.ts b/packages/admin-next/dashboard/src/lib/api-v2/index.ts
index 1bb9826de0..4b23d2e698 100644
--- a/packages/admin-next/dashboard/src/lib/api-v2/index.ts
+++ b/packages/admin-next/dashboard/src/lib/api-v2/index.ts
@@ -1,3 +1,2 @@
export * from "./auth"
-export * from "./promotion"
export * from "./store"
diff --git a/packages/admin-next/dashboard/src/lib/api-v2/promotion.ts b/packages/admin-next/dashboard/src/lib/api-v2/promotion.ts
deleted file mode 100644
index 84c3426659..0000000000
--- a/packages/admin-next/dashboard/src/lib/api-v2/promotion.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import {
- AdminGetPromotionsParams,
- AdminPromotionsListRes,
-} from "@medusajs/medusa"
-import { Response } from "@medusajs/medusa-js"
-import {
- UseQueryOptionsWrapper,
- queryKeysFactory,
- useAdminCustomQuery,
-} from "medusa-react"
-
-const ADMIN_PROMOTIONS_QUERY_KEY = "admin_promotions"
-
-export const adminPromotionKeys = queryKeysFactory<
- typeof ADMIN_PROMOTIONS_QUERY_KEY,
- AdminGetPromotionsParams
->(ADMIN_PROMOTIONS_QUERY_KEY)
-
-type PromotionQueryKey = typeof adminPromotionKeys
-
-export const useV2Promotions = (
- query?: AdminGetPromotionsParams,
- options?: UseQueryOptionsWrapper<
- Response,
- Error,
- ReturnType
- >
-) => {
- const { data, ...rest } = useAdminCustomQuery<
- AdminGetPromotionsParams,
- AdminPromotionsListRes
- >("/promotions", adminPromotionKeys.list(query), query, options as any)
-
- return { ...data, ...rest }
-}
diff --git a/packages/admin-next/dashboard/src/lib/client/campaigns.ts b/packages/admin-next/dashboard/src/lib/client/campaigns.ts
new file mode 100644
index 0000000000..eacb8aee0a
--- /dev/null
+++ b/packages/admin-next/dashboard/src/lib/client/campaigns.ts
@@ -0,0 +1,35 @@
+import { CreateCampaignDTO, UpdateCampaignDTO } from "@medusajs/types"
+import {
+ CampaignDeleteRes,
+ CampaignListRes,
+ CampaignRes,
+} from "../../types/api-responses"
+import { deleteRequest, getRequest, postRequest } from "./common"
+
+async function retrieveCampaign(id: string, query?: Record) {
+ return getRequest(`/admin/campaigns/${id}`, query)
+}
+
+async function listCampaigns(query?: Record) {
+ return getRequest(`/admin/campaigns`, query)
+}
+
+async function createCampaign(payload: CreateCampaignDTO) {
+ return postRequest(`/admin/campaigns`, payload)
+}
+
+async function updateCampaign(id: string, payload: UpdateCampaignDTO) {
+ return postRequest(`/admin/campaigns/${id}`, payload)
+}
+
+async function deleteCampaign(id: string) {
+ return deleteRequest(`/admin/campaigns/${id}`)
+}
+
+export const campaigns = {
+ retrieve: retrieveCampaign,
+ list: listCampaigns,
+ create: createCampaign,
+ update: updateCampaign,
+ delete: deleteCampaign,
+}
diff --git a/packages/admin-next/dashboard/src/lib/client/client.ts b/packages/admin-next/dashboard/src/lib/client/client.ts
index f721ded8be..6de8dee44f 100644
--- a/packages/admin-next/dashboard/src/lib/client/client.ts
+++ b/packages/admin-next/dashboard/src/lib/client/client.ts
@@ -1,5 +1,6 @@
import { apiKeys } from "./api-keys"
import { auth } from "./auth"
+import { campaigns } from "./campaigns"
import { categories } from "./categories"
import { collections } from "./collections"
import { currencies } from "./currencies"
@@ -19,6 +20,7 @@ import { workflowExecutions } from "./workflow-executions"
export const client = {
auth: auth,
apiKeys: apiKeys,
+ campaigns: campaigns,
categories: categories,
customers: customers,
currencies: currencies,
diff --git a/packages/admin-next/dashboard/src/lib/client/promotions.ts b/packages/admin-next/dashboard/src/lib/client/promotions.ts
index 0912f61532..2b607474d0 100644
--- a/packages/admin-next/dashboard/src/lib/client/promotions.ts
+++ b/packages/admin-next/dashboard/src/lib/client/promotions.ts
@@ -1,23 +1,113 @@
import { AdminGetPromotionsParams } from "@medusajs/medusa"
-import { PromotionListRes, PromotionRes } from "../../types/api-responses"
-import { getRequest } from "./common"
+import {
+ BatchAddPromotionRulesReq,
+ BatchRemovePromotionRulesReq,
+ BatchUpdatePromotionRulesReq,
+ CreatePromotionReq,
+ UpdatePromotionReq,
+} from "../../types/api-payloads"
+import {
+ PromotionDeleteRes,
+ PromotionListRes,
+ PromotionRes,
+ PromotionRuleAttributesListRes,
+ PromotionRuleOperatorsListRes,
+ PromotionRuleValuesListRes,
+} from "../../types/api-responses"
+import { deleteRequest, getRequest, postRequest } from "./common"
-const retrievePromotion = async (
- id: string,
- query?: AdminGetPromotionsParams
-) => {
+async function retrievePromotion(id: string, query?: AdminGetPromotionsParams) {
return getRequest(
`/admin/promotions/${id}`,
query
)
}
-const listPromotions = async (query?: AdminGetPromotionsParams) => {
+async function listPromotions(query?: AdminGetPromotionsParams) {
return getRequest(`/admin/promotions`, query)
}
+async function deletePromotion(id: string) {
+ return deleteRequest(`/admin/promotions/${id}`)
+}
+
+async function updatePromotion(id: string, payload: UpdatePromotionReq) {
+ return postRequest(`/admin/promotions/${id}`, payload)
+}
+
+async function createPromotion(payload: CreatePromotionReq) {
+ return postRequest(`/admin/promotions`, payload)
+}
+
+async function addPromotionRules(
+ id: string,
+ ruleType: string,
+ payload: BatchAddPromotionRulesReq
+) {
+ return postRequest(
+ `/admin/promotions/${id}/${ruleType}/batch/add`,
+ payload
+ )
+}
+
+async function updatePromotionRules(
+ id: string,
+ ruleType: string,
+ payload: BatchUpdatePromotionRulesReq
+) {
+ return postRequest(
+ `/admin/promotions/${id}/${ruleType}/batch/update`,
+ payload
+ )
+}
+
+async function removePromotionRules(
+ id: string,
+ ruleType: string,
+ payload: BatchRemovePromotionRulesReq
+) {
+ return postRequest(
+ `/admin/promotions/${id}/${ruleType}/batch/remove`,
+ payload
+ )
+}
+
+async function listPromotionRules(id: string, ruleType: string) {
+ return getRequest(
+ `/admin/promotions/${id}/${ruleType}`
+ )
+}
+
+async function listPromotionRuleAttributes(ruleType: string) {
+ return getRequest(
+ `/admin/promotions/rule-attribute-options/${ruleType}`
+ )
+}
+
+async function listPromotionRuleValues(ruleType: string, ruleValue: string) {
+ return getRequest(
+ `/admin/promotions/rule-value-options/${ruleType}/${ruleValue}`
+ )
+}
+
+async function listPromotionRuleOperators() {
+ return getRequest(
+ `/admin/promotions/rule-operator-options`
+ )
+}
+
export const promotions = {
retrieve: retrievePromotion,
list: listPromotions,
+ delete: deletePromotion,
+ update: updatePromotion,
+ create: createPromotion,
+ addRules: addPromotionRules,
+ removeRules: removePromotionRules,
+ updateRules: updatePromotionRules,
+ listRules: listPromotionRules,
+ listRuleAttributes: listPromotionRuleAttributes,
+ listRuleOperators: listPromotionRuleOperators,
+ listRuleValues: listPromotionRuleValues,
}
diff --git a/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx b/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx
index d1d88e64c0..cf56b7063d 100644
--- a/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx
+++ b/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx
@@ -150,6 +150,34 @@ export const v2Routes: RouteObject[] = [
path: "",
lazy: () => import("../../v2-routes/promotions/promotion-list"),
},
+ {
+ path: ":id",
+ lazy: () =>
+ import("../../v2-routes/promotions/promotion-detail"),
+ handle: {
+ crumb: (data: AdminPromotionRes) => data.promotion?.code,
+ },
+ children: [
+ {
+ path: "edit",
+ lazy: () =>
+ import(
+ "../../v2-routes/promotions/promotion-edit-details"
+ ),
+ },
+ {
+ path: "add-to-campaign",
+ lazy: () =>
+ import(
+ "../../v2-routes/promotions/promotion-add-campaign"
+ ),
+ },
+ {
+ path: ":ruleType/edit",
+ lazy: () => import("../../v2-routes/promotions/edit-rules"),
+ },
+ ],
+ },
],
},
{
diff --git a/packages/admin-next/dashboard/src/types/api-payloads.ts b/packages/admin-next/dashboard/src/types/api-payloads.ts
index 425a4021f7..7c42a99115 100644
--- a/packages/admin-next/dashboard/src/types/api-payloads.ts
+++ b/packages/admin-next/dashboard/src/types/api-payloads.ts
@@ -4,15 +4,21 @@
import {
CreateApiKeyDTO,
+ CreateCampaignDTO,
CreateCustomerDTO,
CreateInviteDTO,
CreateProductCollectionDTO,
+ CreatePromotionDTO,
+ CreatePromotionRuleDTO,
CreateRegionDTO,
CreateSalesChannelDTO,
CreateStockLocationInput,
UpdateApiKeyDTO,
+ UpdateCampaignDTO,
UpdateCustomerDTO,
UpdateProductCollectionDTO,
+ UpdatePromotionDTO,
+ UpdatePromotionRuleDTO,
UpdateRegionDTO,
UpdateSalesChannelDTO,
UpdateStockLocationInput,
@@ -57,3 +63,14 @@ export type UpdateStockLocationReq = UpdateStockLocationInput
// Product Collections
export type CreateProductCollectionReq = CreateProductCollectionDTO
export type UpdateProductCollectionReq = UpdateProductCollectionDTO
+
+// Promotion
+export type CreatePromotionReq = CreatePromotionDTO
+export type UpdatePromotionReq = UpdatePromotionDTO
+export type BatchAddPromotionRulesReq = { rules: CreatePromotionRuleDTO[] }
+export type BatchRemovePromotionRulesReq = { rule_ids: string[] }
+export type BatchUpdatePromotionRulesReq = { rules: UpdatePromotionRuleDTO[] }
+
+// Campaign
+export type CreateCampaignReq = CreateCampaignDTO
+export type UpdateCampaignReq = UpdateCampaignDTO
diff --git a/packages/admin-next/dashboard/src/types/api-responses.ts b/packages/admin-next/dashboard/src/types/api-responses.ts
index b5bc86d0d9..cd64f62357 100644
--- a/packages/admin-next/dashboard/src/types/api-responses.ts
+++ b/packages/admin-next/dashboard/src/types/api-responses.ts
@@ -4,6 +4,7 @@
import {
ApiKeyDTO,
+ CampaignDTO,
CurrencyDTO,
CustomerDTO,
InviteDTO,
@@ -45,6 +46,10 @@ export type CustomerListRes = { customers: CustomerDTO[] } & ListRes
// Promotions
export type PromotionRes = { promotion: PromotionDTO }
export type PromotionListRes = { promotions: PromotionDTO[] } & ListRes
+export type PromotionRuleAttributesListRes = { attributes: Record[] }
+export type PromotionRuleOperatorsListRes = { operators: Record[] }
+export type PromotionRuleValuesListRes = { values: Record[] }
+export type PromotionRulesListRes = { rules: Record[] }
export type PromotionDeleteRes = DeleteRes
// Users
@@ -65,6 +70,11 @@ export type RegionRes = { region: RegionDTO }
export type RegionListRes = { regions: RegionDTO[] } & ListRes
export type RegionDeleteRes = DeleteRes
+// Campaigns
+export type CampaignRes = { campaign: CampaignDTO }
+export type CampaignListRes = { campaigns: CampaignDTO[] } & ListRes
+export type CampaignDeleteRes = DeleteRes
+
// API Keys
export type ExtendedApiKeyDTO = ApiKeyDTO & {
sales_channels: SalesChannelDTO[] | null
diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/components/edit-rules-form/edit-rules-form.tsx b/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/components/edit-rules-form/edit-rules-form.tsx
new file mode 100644
index 0000000000..c93a3eb04f
--- /dev/null
+++ b/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/components/edit-rules-form/edit-rules-form.tsx
@@ -0,0 +1,442 @@
+import { zodResolver } from "@hookform/resolvers/zod"
+import { XMarkMini } from "@medusajs/icons"
+import { PromotionDTO, PromotionRuleDTO } from "@medusajs/types"
+import { Badge, Button, Heading, Input, Select, Text } from "@medusajs/ui"
+import { Fragment, useState } from "react"
+import { useFieldArray, useForm } from "react-hook-form"
+import { useTranslation } from "react-i18next"
+import * as zod from "zod"
+import { Combobox } from "../../../../../components/common/combobox"
+import { Form } from "../../../../../components/common/form"
+import {
+ RouteDrawer,
+ useRouteModal,
+} from "../../../../../components/route-modal"
+import {
+ usePromotionAddRules,
+ usePromotionRemoveRules,
+ usePromotionRuleValues,
+ usePromotionUpdateRules,
+ useUpdatePromotion,
+} from "../../../../../hooks/api/promotions"
+import { RuleTypeValues } from "../../edit-rules"
+import { getDisguisedRules } from "./utils"
+
+type EditPromotionFormProps = {
+ promotion: PromotionDTO
+ rules: PromotionRuleDTO[]
+ ruleType: RuleTypeValues
+ attributes: any[]
+ operators: any[]
+}
+
+const EditRules = zod.object({
+ rules: zod.array(
+ zod.object({
+ id: zod.string().optional(),
+ attribute: zod.string().min(1, { message: "Required field" }),
+ operator: zod.string().min(1, { message: "Required field" }),
+ values: zod.union([
+ zod.number().min(1, { message: "Required field" }),
+ zod.string().min(1, { message: "Required field" }),
+ zod.array(zod.string()).min(1, { message: "Required field" }),
+ ]),
+ required: zod.boolean().optional(),
+ field_type: zod.string().optional(),
+ })
+ ),
+})
+
+const fetchOptionsForRule = (ruleType: string, attribute: string) => {
+ const { values } = usePromotionRuleValues(ruleType, attribute)
+
+ return values || []
+}
+
+export const EditRulesForm = ({
+ promotion,
+ rules,
+ ruleType,
+ attributes,
+ operators,
+}: EditPromotionFormProps) => {
+ const { t } = useTranslation()
+ const { handleSuccess } = useRouteModal()
+ const requiredAttributes = attributes?.filter((ra) => ra.required) || []
+ const requiredAttributeValues = requiredAttributes?.map((ra) => ra.value)
+ const disguisedRules =
+ getDisguisedRules(promotion, requiredAttributes, ruleType) || []
+ const [rulesToRemove, setRulesToRemove] = useState([])
+
+ const form = useForm>({
+ defaultValues: {
+ rules: [...disguisedRules, ...rules].map((rule) => ({
+ id: rule.id,
+ required: requiredAttributeValues.includes(rule.attribute),
+ field_type: rule.field_type,
+ attribute: rule.attribute!,
+ operator: rule.operator!,
+ values: rule?.values?.map((v: { value: string }) => v.value!),
+ })),
+ },
+ resolver: zodResolver(EditRules),
+ })
+
+ const { fields, append, remove, update } = useFieldArray({
+ control: form.control,
+ name: "rules",
+ keyName: "rules_id",
+ })
+
+ const { mutateAsync: updatePromotion } = useUpdatePromotion(promotion.id)
+ const { mutateAsync: addPromotionRules } = usePromotionAddRules(
+ promotion.id,
+ ruleType
+ )
+
+ const { mutateAsync: removePromotionRules } = usePromotionRemoveRules(
+ promotion.id,
+ ruleType
+ )
+
+ const { mutateAsync: updatePromotionRules } = usePromotionUpdateRules(
+ promotion.id,
+ ruleType
+ )
+
+ const handleSubmit = form.handleSubmit(async (data) => {
+ const applicationMethodData: Record = {}
+ const { rules: allRules = [] } = data
+
+ const disguisedRulesData = allRules.filter((rule) =>
+ disguisedRules.map((maskedRule) => maskedRule.id).includes(rule.id!)
+ )
+
+ // For all the rules that were disguised, convert them to actual values in the
+ // database, they are currently all under application_method. If more of these are coming
+ // up, abstract this away.
+ for (const rule of disguisedRulesData) {
+ applicationMethodData[rule.id!] = parseInt(rule.values as string)
+ }
+
+ // This variable will contain the rules that are actual rule objects, without the disguised
+ // objects
+ const rulesData = allRules.filter(
+ (rule) =>
+ !disguisedRules.map((maskedRule) => maskedRule.id).includes(rule.id!)
+ )
+
+ const rulesToCreate = rulesData.filter((rule) => !("id" in rule))
+ const rulesToUpdate = rulesData.filter(
+ (rule) => typeof rule.id === "string"
+ )
+
+ if (Object.keys(applicationMethodData).length) {
+ await updatePromotion({ application_method: applicationMethodData })
+ }
+
+ rulesToCreate.length &&
+ (await addPromotionRules({
+ rules: rulesToCreate.map((rule) => {
+ return {
+ attribute: rule.attribute,
+ operator: rule.operator,
+ values: rule.values,
+ } as any
+ }),
+ }))
+
+ rulesToRemove.length &&
+ (await removePromotionRules({
+ rule_ids: rulesToRemove.map((r) => r.id!),
+ }))
+
+ rulesToUpdate.length &&
+ (await updatePromotionRules({
+ rules: rulesToUpdate.map((rule) => {
+ return {
+ id: rule.id!,
+ attribute: rule.attribute,
+ operator: rule.operator,
+ values: rule.values,
+ } as any
+ }),
+ }))
+
+ handleSuccess()
+ })
+
+ return (
+
+
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/components/edit-rules-form/index.ts b/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/components/edit-rules-form/index.ts
new file mode 100644
index 0000000000..b3e2998403
--- /dev/null
+++ b/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/components/edit-rules-form/index.ts
@@ -0,0 +1 @@
+export * from "./edit-rules-form"
diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/components/edit-rules-form/utils.ts b/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/components/edit-rules-form/utils.ts
new file mode 100644
index 0000000000..af13209435
--- /dev/null
+++ b/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/components/edit-rules-form/utils.ts
@@ -0,0 +1,52 @@
+import { PromotionDTO } from "@medusajs/types"
+import { RuleType } from "../../edit-rules"
+
+// We are disguising couple of database columns as rules here, namely
+// apply_to_quantity and buy_rules_min_quantity.
+// We need to transform the database value into a disugised "rule" shape
+// for the form
+export function getDisguisedRules(
+ promotion: PromotionDTO,
+ requiredAttributes: any[],
+ ruleType: string
+) {
+ if (ruleType === RuleType.RULES && !requiredAttributes?.length) {
+ return []
+ }
+
+ const applyToQuantityRule = requiredAttributes.find(
+ (attr) => attr.id === "apply_to_quantity"
+ )
+
+ const buyRulesMinQuantityRule = requiredAttributes.find(
+ (attr) => attr.id === "buy_rules_min_quantity"
+ )
+
+ if (ruleType === RuleType.TARGET_RULES) {
+ return [
+ {
+ id: "apply_to_quantity",
+ attribute: "apply_to_quantity",
+ operator: "eq",
+ required: applyToQuantityRule?.required,
+ field_type: applyToQuantityRule?.field_type,
+ values: [{ value: promotion?.application_method?.apply_to_quantity }],
+ },
+ ]
+ }
+
+ if (ruleType === RuleType.BUY_RULES) {
+ return [
+ {
+ id: "buy_rules_min_quantity",
+ attribute: "buy_rules_min_quantity",
+ operator: "eq",
+ required: buyRulesMinQuantityRule?.required,
+ field_type: buyRulesMinQuantityRule?.field_type,
+ values: [
+ { value: promotion?.application_method?.buy_rules_min_quantity },
+ ],
+ },
+ ]
+ }
+}
diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/edit-rules.tsx b/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/edit-rules.tsx
new file mode 100644
index 0000000000..969dabb1ba
--- /dev/null
+++ b/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/edit-rules.tsx
@@ -0,0 +1,79 @@
+import { PromotionRuleDTO } from "@medusajs/types"
+import { Heading } from "@medusajs/ui"
+import { useTranslation } from "react-i18next"
+import { useParams } from "react-router-dom"
+import { RouteDrawer } from "../../../components/route-modal"
+import {
+ usePromotion,
+ usePromotionRuleAttributes,
+ usePromotionRuleOperators,
+} from "../../../hooks/api/promotions"
+import { EditRulesForm } from "./components/edit-rules-form"
+
+export enum RuleType {
+ RULES = "rules",
+ BUY_RULES = "buy-rules",
+ TARGET_RULES = "target-rules",
+}
+export type RuleTypeValues = "rules" | "buy-rules" | "target-rules"
+
+export const EditRules = () => {
+ const params = useParams()
+ const allowedParams: string[] = [
+ RuleType.RULES,
+ RuleType.BUY_RULES,
+ RuleType.TARGET_RULES,
+ ]
+
+ if (!allowedParams.includes(params.ruleType!)) {
+ throw "invalid page"
+ }
+
+ const { t } = useTranslation()
+ const ruleType = params.ruleType as RuleTypeValues
+ const id = params.id as string
+ const rules: PromotionRuleDTO[] = []
+ const { promotion, isLoading, isError, error } = usePromotion(id)
+ const {
+ attributes,
+ isError: isAttributesError,
+ error: attributesError,
+ } = usePromotionRuleAttributes(ruleType!)
+ const {
+ operators,
+ isError: isOperatorsError,
+ error: operatorsError,
+ } = usePromotionRuleOperators()
+
+ if (promotion) {
+ if (ruleType === RuleType.RULES) {
+ rules.push(...(promotion.rules || []))
+ } else if (ruleType === RuleType.TARGET_RULES) {
+ rules.push(...(promotion?.application_method?.target_rules || []))
+ } else if (ruleType === RuleType.BUY_RULES) {
+ rules.push(...(promotion.application_method?.buy_rules || []))
+ }
+ }
+
+ if (isError || isAttributesError || isOperatorsError) {
+ throw error || attributesError || operatorsError
+ }
+
+ return (
+
+
+ {t(`promotions.edit.${ruleType}.title`)}
+
+
+ {!isLoading && promotion && attributes && operators && (
+
+ )}
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/index.ts b/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/index.ts
new file mode 100644
index 0000000000..f905988305
--- /dev/null
+++ b/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/index.ts
@@ -0,0 +1 @@
+export { EditRules as Component } from "./edit-rules"
diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/components/add-campaign-promotion-form/add-campaign-promotion-form.tsx b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/components/add-campaign-promotion-form/add-campaign-promotion-form.tsx
new file mode 100644
index 0000000000..a17bc289f0
--- /dev/null
+++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/components/add-campaign-promotion-form/add-campaign-promotion-form.tsx
@@ -0,0 +1,151 @@
+import { zodResolver } from "@hookform/resolvers/zod"
+import { CampaignDTO, PromotionDTO } from "@medusajs/types"
+import { Button, RadioGroup, Select } from "@medusajs/ui"
+import { useForm, useWatch } from "react-hook-form"
+import { useTranslation } from "react-i18next"
+import * as zod from "zod"
+import { CampaignDetails } from "./campaign-details"
+
+import { Form } from "../../../../../components/common/form"
+import {
+ RouteDrawer,
+ useRouteModal,
+} from "../../../../../components/route-modal"
+import { useUpdatePromotion } from "../../../../../hooks/api/promotions"
+
+type EditPromotionFormProps = {
+ promotion: PromotionDTO
+ campaigns: CampaignDTO[]
+}
+
+const EditPromotionSchema = zod.object({
+ campaign_id: zod.string().optional(),
+ existing: zod.string().toLowerCase(),
+})
+
+export const AddCampaignPromotionForm = ({
+ promotion,
+ campaigns,
+}: EditPromotionFormProps) => {
+ const { t } = useTranslation()
+ const { handleSuccess } = useRouteModal()
+ const { campaign } = promotion
+
+ const form = useForm>({
+ defaultValues: {
+ campaign_id: campaign?.id,
+ existing: "true",
+ },
+ resolver: zodResolver(EditPromotionSchema),
+ })
+
+ const watchCampaignId = useWatch({
+ control: form.control,
+ name: "campaign_id",
+ })
+
+ const selectedCampaign = campaigns.find((c) => c.id === watchCampaignId)
+ const { mutateAsync, isPending } = useUpdatePromotion(promotion.id)
+
+ const handleSubmit = form.handleSubmit(async (data) => {
+ await mutateAsync(
+ { campaign_id: data.campaign_id },
+ { onSuccess: () => handleSuccess() }
+ )
+ })
+
+ return (
+
+
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/components/add-campaign-promotion-form/campaign-details.tsx b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/components/add-campaign-promotion-form/campaign-details.tsx
new file mode 100644
index 0000000000..c84a0a2cf0
--- /dev/null
+++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/components/add-campaign-promotion-form/campaign-details.tsx
@@ -0,0 +1,116 @@
+import { CampaignDTO } from "@medusajs/types"
+import { Heading, Text } from "@medusajs/ui"
+import { Fragment } from "react"
+import { useTranslation } from "react-i18next"
+
+type CampaignDetailsProps = {
+ campaign?: CampaignDTO
+}
+
+export const CampaignDetails = ({ campaign }: CampaignDetailsProps) => {
+ const { t } = useTranslation()
+
+ if (!campaign) {
+ return
+ }
+
+ return (
+
+
+
+ {t("campaigns.details")}
+
+
+
+
+ {t("campaigns.fields.identifier")}
+
+
+
+
+ {campaign.campaign_identifier || "-"}
+
+
+
+
+
+
{t("fields.description")}
+
+
+ {campaign.description || "-"}
+
+
+
+
+
+ {t("campaigns.fields.start_date")}
+
+
+
+
+ {campaign.starts_at?.toString() || "-"}
+
+
+
+
+
+
+ {t("campaigns.fields.end_date")}
+
+
+
+
+ {campaign.ends_at?.toString() || "-"}
+
+
+
+
+
+
+
+ {t("campaigns.budget.details")}
+
+
+
+
+ {t("campaigns.budget.fields.type")}
+
+
+
+ {campaign.budget?.type || "-"}
+
+
+
+
+
+ {t("campaigns.budget.fields.currency")}
+
+
+
+ {campaign.currency || "-"}
+
+
+
+
+
+ {t("campaigns.budget.fields.limit")}
+
+
+
+ {campaign.budget?.limit || "-"}
+
+
+
+
+
+ {t("campaigns.budget.fields.used")}
+
+
+
+ {campaign.budget?.used || "-"}
+
+
+
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/components/add-campaign-promotion-form/index.ts b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/components/add-campaign-promotion-form/index.ts
new file mode 100644
index 0000000000..c7fb969a7d
--- /dev/null
+++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/components/add-campaign-promotion-form/index.ts
@@ -0,0 +1 @@
+export * from "./add-campaign-promotion-form"
diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/index.ts b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/index.ts
new file mode 100644
index 0000000000..3836868e22
--- /dev/null
+++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/index.ts
@@ -0,0 +1 @@
+export { PromotionAddCampaign as Component } from "./promotion-add-campaign"
diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/promotion-add-campaign.tsx b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/promotion-add-campaign.tsx
new file mode 100644
index 0000000000..7c283480e9
--- /dev/null
+++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/promotion-add-campaign.tsx
@@ -0,0 +1,36 @@
+import { Heading } from "@medusajs/ui"
+import { useTranslation } from "react-i18next"
+import { useParams } from "react-router-dom"
+
+import { RouteDrawer } from "../../../components/route-modal"
+import { useCampaigns } from "../../../hooks/api/campaigns"
+import { usePromotion } from "../../../hooks/api/promotions"
+import { AddCampaignPromotionForm } from "./components/add-campaign-promotion-form"
+
+export const PromotionAddCampaign = () => {
+ const { id } = useParams()
+ const { t } = useTranslation()
+ const { promotion, isPending, isError, error } = usePromotion(id!)
+ const {
+ campaigns,
+ isPending: areCampaignsLoading,
+ isError: isCampaignError,
+ error: campaignError,
+ } = useCampaigns()
+
+ if (isError || isCampaignError) {
+ throw error || campaignError
+ }
+
+ return (
+
+
+ {t("promotions.addToCampaign.title")}
+
+
+ {!isPending && !areCampaignsLoading && promotion && campaigns && (
+
+ )}
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/campaign-section/campaign-section.tsx b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/campaign-section/campaign-section.tsx
new file mode 100644
index 0000000000..094eb0a719
--- /dev/null
+++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/campaign-section/campaign-section.tsx
@@ -0,0 +1,116 @@
+import { PencilSquare } from "@medusajs/icons"
+import { CampaignDTO } from "@medusajs/types"
+import { Container, Heading, Text } from "@medusajs/ui"
+import { format } from "date-fns"
+import { Fragment } from "react"
+import { useTranslation } from "react-i18next"
+import { useParams } from "react-router-dom"
+
+import { ActionMenu } from "../../../../../components/common/action-menu"
+import { NoRecords } from "../../../../../components/common/empty-table-content"
+
+function formatDate(date?: string | Date) {
+ if (!date) {
+ return "-"
+ }
+
+ return format(new Date(date), "dd MMM yyyy")
+}
+
+const CampaignDetailSection = ({ campaign }: { campaign: CampaignDTO }) => {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+ {t("campaigns.fields.name")}
+
+
+
+
+ {campaign?.name}
+
+
+
+
+
+
+ {t("campaigns.fields.identifier")}
+
+
+
+
+ {campaign?.campaign_identifier}
+
+
+
+
+
+
+ {t("campaigns.fields.start_date")}
+
+
+
+
+ {formatDate(campaign?.starts_at)}
+
+
+
+
+
+
+ {t("campaigns.fields.end_date") || "-"}
+
+
+
+
+ {formatDate(campaign?.ends_at)}
+
+
+
+
+ )
+}
+
+export const CampaignSection = ({ campaign }: { campaign: CampaignDTO }) => {
+ const { t } = useTranslation()
+ const { id } = useParams()
+
+ return (
+
+
+
{t("promotions.fields.campaign")}
+
+
,
+ },
+ ],
+ },
+ ]}
+ />
+
+
+ {campaign ? (
+
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/campaign-section/index.ts b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/campaign-section/index.ts
new file mode 100644
index 0000000000..52b96902ac
--- /dev/null
+++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/campaign-section/index.ts
@@ -0,0 +1 @@
+export * from "./campaign-section.tsx"
diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/promotion-conditions-section/index.ts b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/promotion-conditions-section/index.ts
new file mode 100644
index 0000000000..260e4a4ffb
--- /dev/null
+++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/promotion-conditions-section/index.ts
@@ -0,0 +1 @@
+export * from "./promotion-conditions-section"
diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/promotion-conditions-section/promotion-conditions-section.tsx b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/promotion-conditions-section/promotion-conditions-section.tsx
new file mode 100644
index 0000000000..f041c3fa5d
--- /dev/null
+++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/promotion-conditions-section/promotion-conditions-section.tsx
@@ -0,0 +1,92 @@
+import { PencilSquare } from "@medusajs/icons"
+import { AdminGetPromotionRulesRes, PromotionRuleTypes } from "@medusajs/types"
+import { Badge, Container, Heading } from "@medusajs/ui"
+import { useTranslation } from "react-i18next"
+
+import { ActionMenu } from "../../../../../components/common/action-menu"
+import { BadgeListSummary } from "../../../../../components/common/badge-list-summary"
+import { NoRecords } from "../../../../../components/common/empty-table-content"
+
+type RuleProps = {
+ rule: AdminGetPromotionRulesRes
+}
+
+function RuleBlock({ rule }: RuleProps) {
+ return (
+
+
+
+ {rule.attribute_label}
+
+
+
+ {rule.operator_label}
+
+
+ v.label)}
+ />
+
+
+ )
+}
+
+type PromotionConditionsSectionProps = {
+ rules: AdminGetPromotionRulesRes
+ ruleType: PromotionRuleTypes
+}
+
+export const PromotionConditionsSection = ({
+ rules,
+ ruleType,
+}: PromotionConditionsSectionProps) => {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+
+ {t(`promotions.fields.conditions.${ruleType}.title`)}
+
+
+
+
,
+ label: t("actions.edit"),
+ to: `${ruleType}/edit`,
+ },
+ ],
+ },
+ ]}
+ />
+
+
+
+ {!rules.length && (
+
+ )}
+
+ {rules.map((rule) => (
+
+ ))}
+
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/promotion-general-section/index.ts b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/promotion-general-section/index.ts
new file mode 100644
index 0000000000..555494d37a
--- /dev/null
+++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/promotion-general-section/index.ts
@@ -0,0 +1 @@
+export * from "./promotion-general-section.tsx"
diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/promotion-general-section/promotion-general-section.tsx b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/promotion-general-section/promotion-general-section.tsx
new file mode 100644
index 0000000000..ff0f5542cb
--- /dev/null
+++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/promotion-general-section/promotion-general-section.tsx
@@ -0,0 +1,160 @@
+import { PencilSquare, Trash } from "@medusajs/icons"
+import { PromotionDTO } from "@medusajs/types"
+import {
+ Container,
+ Copy,
+ Heading,
+ StatusBadge,
+ Text,
+ usePrompt,
+} from "@medusajs/ui"
+import { useTranslation } from "react-i18next"
+import { useNavigate } from "react-router-dom"
+
+import { ActionMenu } from "../../../../../components/common/action-menu"
+import { useDeletePromotion } from "../../../../../hooks/api/promotions"
+import {
+ getPromotionStatus,
+ PromotionStatus,
+} from "../../../../../lib/promotions"
+
+type PromotionGeneralSectionProps = {
+ promotion: PromotionDTO
+}
+
+export const PromotionGeneralSection = ({
+ promotion,
+}: PromotionGeneralSectionProps) => {
+ const { t } = useTranslation()
+ const prompt = usePrompt()
+ const navigate = useNavigate()
+ const { mutateAsync } = useDeletePromotion(promotion.id)
+
+ const handleDelete = async () => {
+ const confirm = await prompt({
+ title: t("general.areYouSure"),
+ description: t("promotions.deleteWarning", {
+ code: promotion.code,
+ }),
+ verificationInstruction: t("general.typeToConfirm"),
+ verificationText: promotion.code,
+ confirmText: t("actions.delete"),
+ cancelText: t("actions.cancel"),
+ })
+
+ if (!confirm) {
+ return
+ }
+
+ await mutateAsync(undefined, {
+ onSuccess: () => {
+ navigate("/promotions", { replace: true })
+ },
+ })
+ }
+
+ const [color, text] = {
+ [PromotionStatus.DISABLED]: ["grey", t("statuses.disabled")],
+ [PromotionStatus.ACTIVE]: ["green", t("statuses.active")],
+ [PromotionStatus.SCHEDULED]: ["orange", t("statuses.scheduled")],
+ [PromotionStatus.EXPIRED]: ["red", t("statuses.expired")],
+ }[getPromotionStatus(promotion)] as [
+ "grey" | "orange" | "green" | "red",
+ string,
+ ]
+
+ return (
+
+
+
+ {promotion.code}
+
+
+
+
{text}
+
,
+ label: t("actions.edit"),
+ to: `/promotions/${promotion.id}/edit`,
+ },
+ ],
+ },
+ {
+ actions: [
+ {
+ icon:
,
+ label: t("actions.delete"),
+ onClick: handleDelete,
+ },
+ ],
+ },
+ ]}
+ />
+
+
+
+
+
+ {t("promotions.fields.method")}
+
+
+
+ {promotion.is_automatic ? "Promotion code" : "Automatic"}
+
+
+
+
+
+ {t("fields.code")}
+
+
+
+
+ {promotion.code}
+
+
+
+
+
+
+
+
+ {t("promotions.fields.type")}
+
+
+
+ {promotion.type}
+
+
+
+
+
+ {t("promotions.fields.value")}
+
+
+
+ {promotion.application_method?.value}
+
+
+
+
+
+ {t("promotions.fields.allocation")}
+
+
+
+ {promotion.application_method?.allocation!}
+
+
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/index.ts b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/index.ts
new file mode 100644
index 0000000000..961854b02b
--- /dev/null
+++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/index.ts
@@ -0,0 +1,2 @@
+export { promotionLoader as loader } from "./loader"
+export { PromotionDetail as Component } from "./promotion-detail.tsx"
diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/loader.ts b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/loader.ts
new file mode 100644
index 0000000000..7925981513
--- /dev/null
+++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/loader.ts
@@ -0,0 +1,21 @@
+import { AdminPromotionRes } from "@medusajs/medusa"
+import { Response } from "@medusajs/medusa-js"
+import { LoaderFunctionArgs } from "react-router-dom"
+import { promotionsQueryKeys } from "../../../hooks/api/promotions"
+import { client } from "../../../lib/client"
+import { queryClient } from "../../../lib/medusa"
+
+const promotionDetailQuery = (id: string) => ({
+ queryKey: promotionsQueryKeys.detail(id),
+ queryFn: () => client.promotions.retrieve(id),
+})
+
+export const promotionLoader = async ({ params }: LoaderFunctionArgs) => {
+ const id = params.id
+ const query = promotionDetailQuery(id!)
+
+ return (
+ queryClient.getQueryData>(query.queryKey) ??
+ (await queryClient.fetchQuery(query))
+ )
+}
diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/promotion-detail.tsx b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/promotion-detail.tsx
new file mode 100644
index 0000000000..6412740bff
--- /dev/null
+++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/promotion-detail.tsx
@@ -0,0 +1,78 @@
+import { Outlet, useLoaderData, useParams } from "react-router-dom"
+
+import { JsonViewSection } from "../../../components/common/json-view-section"
+import { usePromotion, usePromotionRules } from "../../../hooks/api/promotions"
+import { CampaignSection } from "./components/campaign-section"
+import { PromotionConditionsSection } from "./components/promotion-conditions-section"
+import { PromotionGeneralSection } from "./components/promotion-general-section"
+import { promotionLoader } from "./loader"
+
+import after from "medusa-admin:widgets/promotion/details/after"
+import before from "medusa-admin:widgets/promotion/details/before"
+
+export const PromotionDetail = () => {
+ const initialData = useLoaderData() as Awaited<
+ ReturnType
+ >
+
+ const { id } = useParams()
+ const { promotion, isLoading } = usePromotion(id!, { initialData })
+ const { rules } = usePromotionRules(id!, "rules")
+ const { rules: targetRules } = usePromotionRules(id!, "target-rules")
+ const { rules: buyRules } = usePromotionRules(id!, "buy-rules")
+
+ if (isLoading || !promotion) {
+ return Loading...
+ }
+
+ return (
+
+ {before.widgets.map((w, i) => {
+ return (
+
+
+
+ )
+ })}
+
+
+
+
+
+
+
+
+
+ {promotion.type === "buyget" && (
+
+ )}
+
+
+
+
+
+ {after.widgets.map((w, i) => {
+ return (
+
+
+
+ )
+ })}
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-edit-details/components/edit-promotion-form/edit-promotion-details-form.tsx b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-edit-details/components/edit-promotion-form/edit-promotion-details-form.tsx
new file mode 100644
index 0000000000..7fbe9ec95c
--- /dev/null
+++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-edit-details/components/edit-promotion-form/edit-promotion-details-form.tsx
@@ -0,0 +1,280 @@
+import { zodResolver } from "@hookform/resolvers/zod"
+import { PromotionDTO } from "@medusajs/types"
+import { Button, CurrencyInput, Input, RadioGroup, Text } from "@medusajs/ui"
+import { useForm, useWatch } from "react-hook-form"
+import { Trans, useTranslation } from "react-i18next"
+import * as zod from "zod"
+
+import { Form } from "../../../../../components/common/form"
+import { PercentageInput } from "../../../../../components/common/percentage-input"
+import {
+ RouteDrawer,
+ useRouteModal,
+} from "../../../../../components/route-modal"
+import { useUpdatePromotion } from "../../../../../hooks/api/promotions"
+import { getCurrencySymbol } from "../../../../../lib/currencies"
+
+type EditPromotionFormProps = {
+ promotion: PromotionDTO
+}
+
+const EditPromotionSchema = zod.object({
+ is_automatic: zod.string().toLowerCase(),
+ code: zod.string().min(1),
+ value_type: zod.enum(["fixed", "percentage"]),
+ value: zod.string(),
+ allocation: zod.enum(["each", "across"]),
+})
+
+export const EditPromotionDetailsForm = ({
+ promotion,
+}: EditPromotionFormProps) => {
+ const { t } = useTranslation()
+ const { handleSuccess } = useRouteModal()
+
+ const form = useForm>({
+ defaultValues: {
+ is_automatic: promotion.is_automatic!.toString(),
+ code: promotion.code,
+ value: promotion.application_method!.value?.toString(),
+ allocation: promotion.application_method!.allocation,
+ value_type: promotion.application_method!.type,
+ },
+ resolver: zodResolver(EditPromotionSchema),
+ })
+
+ const watchValueType = useWatch({
+ control: form.control,
+ name: "value_type",
+ })
+
+ const isFixedValueType = watchValueType === "fixed"
+
+ const { mutateAsync, isPending } = useUpdatePromotion(promotion.id)
+
+ const handleSubmit = form.handleSubmit(async (data) => {
+ console.log("data ------ ", data)
+ await mutateAsync(
+ {
+ is_automatic: data.is_automatic === "true",
+ code: data.code,
+ application_method: {
+ value: data.value,
+ type: data.value_type as any,
+ allocation: data.allocation as any,
+ },
+ },
+ {
+ onSuccess: () => {
+ handleSuccess()
+ },
+ }
+ )
+ })
+
+ return (
+
+
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-edit-details/components/edit-promotion-form/index.ts b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-edit-details/components/edit-promotion-form/index.ts
new file mode 100644
index 0000000000..94c686f950
--- /dev/null
+++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-edit-details/components/edit-promotion-form/index.ts
@@ -0,0 +1 @@
+export * from "./edit-promotion-details-form"
diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-edit-details/index.ts b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-edit-details/index.ts
new file mode 100644
index 0000000000..a49ed0b088
--- /dev/null
+++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-edit-details/index.ts
@@ -0,0 +1 @@
+export { PromotionEditDetails as Component } from "./promotion-edit-details"
diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-edit-details/promotion-edit-details.tsx b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-edit-details/promotion-edit-details.tsx
new file mode 100644
index 0000000000..5734de5895
--- /dev/null
+++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-edit-details/promotion-edit-details.tsx
@@ -0,0 +1,30 @@
+import { Heading } from "@medusajs/ui"
+import { useTranslation } from "react-i18next"
+import { useParams } from "react-router-dom"
+
+import { RouteDrawer } from "../../../components/route-modal"
+import { usePromotion } from "../../../hooks/api/promotions"
+import { EditPromotionDetailsForm } from "./components/edit-promotion-form"
+
+export const PromotionEditDetails = () => {
+ const { id } = useParams()
+ const { t } = useTranslation()
+
+ const { promotion, isLoading, isError, error } = usePromotion(id!)
+
+ if (isError) {
+ throw error
+ }
+
+ return (
+
+
+ {t("promotions.edit.title")}
+
+
+ {!isLoading && promotion && (
+
+ )}
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-list/components/promotion-list-table/promotion-list-table.tsx b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-list/components/promotion-list-table/promotion-list-table.tsx
index b525b5ad4e..aec4fcc4c3 100644
--- a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-list/components/promotion-list-table/promotion-list-table.tsx
+++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-list/components/promotion-list-table/promotion-list-table.tsx
@@ -1,15 +1,18 @@
-import { PencilSquare } from "@medusajs/icons"
+import { PencilSquare, Trash } from "@medusajs/icons"
import { PromotionDTO } from "@medusajs/types"
-import { Button, Container, Heading } from "@medusajs/ui"
+import { Button, Container, Heading, usePrompt } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
-import { Link, Outlet, useLoaderData } from "react-router-dom"
+import { Link, Outlet, useLoaderData, useNavigate } from "react-router-dom"
import { keepPreviousData } from "@tanstack/react-query"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { DataTable } from "../../../../../components/table/data-table"
-import { usePromotions } from "../../../../../hooks/api/promotions"
+import {
+ useDeletePromotion,
+ usePromotions,
+} from "../../../../../hooks/api/promotions"
import { usePromotionTableColumns } from "../../../../../hooks/table/columns-v2/use-promotion-table-columns"
import { usePromotionTableFilters } from "../../../../../hooks/table/filters-v2/use-promotion-table-filters"
import { usePromotionTableQuery } from "../../../../../hooks/table/query-v2/use-promotion-table-query"
@@ -79,6 +82,36 @@ export const PromotionListTable = () => {
const PromotionActions = ({ promotion }: { promotion: PromotionDTO }) => {
const { t } = useTranslation()
+ const prompt = usePrompt()
+ const navigate = useNavigate()
+ const { mutateAsync } = useDeletePromotion(promotion.id)
+
+ const handleDelete = async () => {
+ const res = await prompt({
+ title: t("general.areYouSure"),
+ description: t("promotions.deleteWarning", { code: promotion.code! }),
+ confirmText: t("actions.delete"),
+ cancelText: t("actions.cancel"),
+ verificationInstruction: t("general.typeToConfirm"),
+ verificationText: promotion.code,
+ })
+
+ if (!res) {
+ return
+ }
+
+ try {
+ await mutateAsync(undefined, {
+ onSuccess: () => {
+ navigate("/promotions", { replace: true })
+ },
+ })
+ } catch {
+ throw new Error(
+ `Promotion with code ${promotion.code} could not be deleted`
+ )
+ }
+ }
return (
{
label: t("actions.edit"),
to: `/promotions/${promotion.id}/edit`,
},
+ {
+ icon: ,
+ label: t("actions.delete"),
+ onClick: handleDelete,
+ },
],
},
]}
diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-list/loader.ts b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-list/loader.ts
index c499254a25..b1a5a90499 100644
--- a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-list/loader.ts
+++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-list/loader.ts
@@ -4,7 +4,7 @@ import {
} from "@medusajs/medusa"
import { Response } from "@medusajs/medusa-js"
import { QueryClient } from "@tanstack/react-query"
-import { adminPromotionKeys } from "../../../lib/api-v2"
+import { promotionsQueryKeys } from "../../../hooks/api/promotions"
import { medusa, queryClient } from "../../../lib/medusa"
const params = {
@@ -13,7 +13,7 @@ const params = {
}
const promotionsListQuery = () => ({
- queryKey: adminPromotionKeys.list(params),
+ queryKey: promotionsQueryKeys.list(params),
queryFn: async () =>
medusa.admin.custom.get(
"/promotions",
diff --git a/packages/medusa-react/src/hooks/admin/custom/queries.ts b/packages/medusa-react/src/hooks/admin/custom/queries.ts
index d71a44f05f..79dda86402 100644
--- a/packages/medusa-react/src/hooks/admin/custom/queries.ts
+++ b/packages/medusa-react/src/hooks/admin/custom/queries.ts
@@ -5,25 +5,25 @@ import { UseQueryOptionsWrapper } from "../../../types"
/**
* This hook sends a `GET` request to a custom API Route.
- *
+ *
* @typeParam TQuery - The type of accepted query parameters which defaults to `Record`.
* @typeParam TResponse - The type of response which defaults to `any`.
* @typeParamDefinition TQuery - The query parameters based on the type specified for `TQuery`.
* @typeParamDefinition TResponse - The response based on the type specified for `TResponse`.
- *
+ *
* @example
* import React from "react"
* import { useAdminCustomQuery } from "medusa-react"
* import Post from "./models/Post"
- *
+ *
* type RequestQuery = {
* title: string
* }
- *
+ *
* type ResponseData = {
* posts: Post
* }
- *
+ *
* const Custom = () => {
* const { data, isLoading } = useAdminCustomQuery
* (
@@ -33,7 +33,7 @@ import { UseQueryOptionsWrapper } from "../../../types"
* title: "My post"
* }
* )
- *
+ *
* return (
*
* {isLoading && Loading...}
@@ -50,9 +50,9 @@ import { UseQueryOptionsWrapper } from "../../../types"
*
* )
* }
- *
+ *
* export default Custom
- *
+ *
* @customNamespace Hooks.Admin.Custom
* @category Mutations
*/
diff --git a/packages/medusa/src/api-v2/admin/campaigns/index.ts b/packages/medusa/src/api-v2/admin/campaigns/index.ts
new file mode 100644
index 0000000000..1bb71ae474
--- /dev/null
+++ b/packages/medusa/src/api-v2/admin/campaigns/index.ts
@@ -0,0 +1,2 @@
+export * from "./types"
+export * from "./validators"
diff --git a/packages/medusa/src/api-v2/admin/campaigns/types.ts b/packages/medusa/src/api-v2/admin/campaigns/types.ts
new file mode 100644
index 0000000000..33bac922d1
--- /dev/null
+++ b/packages/medusa/src/api-v2/admin/campaigns/types.ts
@@ -0,0 +1,9 @@
+import { CampaignDTO, PaginatedResponse } from "@medusajs/types"
+
+export type AdminCampaignsListRes = PaginatedResponse<{
+ campaigns: CampaignDTO[]
+}>
+
+export type AdminCampaignRes = {
+ campaign: CampaignDTO
+}
diff --git a/packages/medusa/src/api-v2/admin/index.ts b/packages/medusa/src/api-v2/admin/index.ts
index b579f7910f..fe7cd6873d 100644
--- a/packages/medusa/src/api-v2/admin/index.ts
+++ b/packages/medusa/src/api-v2/admin/index.ts
@@ -1 +1,2 @@
+export * from "./campaigns"
export * from "./promotions"
diff --git a/packages/medusa/src/api-v2/admin/promotions/[id]/[rule_type]/route.ts b/packages/medusa/src/api-v2/admin/promotions/[id]/[rule_type]/route.ts
new file mode 100644
index 0000000000..2159ea5d49
--- /dev/null
+++ b/packages/medusa/src/api-v2/admin/promotions/[id]/[rule_type]/route.ts
@@ -0,0 +1,115 @@
+import { AdminGetPromotionRulesRes } from "@medusajs/types"
+import {
+ ContainerRegistrationKeys,
+ remoteQueryObjectFromString,
+ RuleOperator,
+ RuleType,
+} from "@medusajs/utils"
+import {
+ AuthenticatedMedusaRequest,
+ MedusaResponse,
+} from "../../../../../types/routing"
+import {
+ operatorsMap,
+ ruleAttributesMap,
+ ruleQueryConfigurations,
+ validateRuleType,
+} from "../../utils"
+
+export const GET = async (
+ req: AuthenticatedMedusaRequest,
+ res: MedusaResponse
+) => {
+ const { id, rule_type: ruleType } = req.params
+ const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
+
+ validateRuleType(ruleType)
+
+ const dasherizedRuleType = ruleType.split("-").join("_")
+ const queryObject = remoteQueryObjectFromString({
+ entryPoint: "promotion",
+ variables: { id },
+ fields: req.remoteQueryConfig.fields,
+ })
+
+ const [promotion] = await remoteQuery(queryObject)
+ const ruleAttributes = ruleAttributesMap[ruleType]
+ const promotionRules: any[] = []
+
+ if (dasherizedRuleType === RuleType.RULES) {
+ promotionRules.push(...(promotion?.rules || []))
+ } else if (dasherizedRuleType === RuleType.TARGET_RULES) {
+ promotionRules.push(...(promotion.application_method?.target_rules || []))
+ } else if (dasherizedRuleType === RuleType.BUY_RULES) {
+ promotionRules.push(...(promotion.application_method?.buy_rules || []))
+ }
+
+ const transformedRules: AdminGetPromotionRulesRes = []
+ const disguisedRules = ruleAttributes.filter((attr) => !!attr.disguised)
+
+ for (const disguisedRule of disguisedRules) {
+ const value = promotion.application_method?.[disguisedRule.id]
+ const values = [{ label: value, value }]
+
+ transformedRules.push({
+ id: undefined,
+ attribute: disguisedRule.id,
+ attribute_label: disguisedRule.label,
+ operator: RuleOperator.EQ,
+ operator_label: operatorsMap[RuleOperator.EQ].label,
+ values,
+ disguised: true,
+ required: true,
+ })
+
+ continue
+ }
+
+ for (const promotionRule of promotionRules) {
+ const currentRuleAttribute = ruleAttributes.find(
+ (attr) => attr.value === promotionRule.attribute
+ )
+
+ if (!currentRuleAttribute) {
+ continue
+ }
+
+ const queryConfig = ruleQueryConfigurations[currentRuleAttribute.id]
+ const rows = await remoteQuery(
+ remoteQueryObjectFromString({
+ entryPoint: queryConfig.entryPoint,
+ variables: {
+ filters: {
+ [queryConfig.valueAttr]: promotionRule.values.map((v) => v.value),
+ },
+ },
+ fields: [queryConfig.labelAttr, queryConfig.valueAttr],
+ })
+ )
+
+ const valueLabelMap = new Map(
+ rows.map((row) => [
+ row[queryConfig.valueAttr],
+ row[queryConfig.labelAttr],
+ ])
+ )
+
+ promotionRule.values = promotionRule.values.map((value) => ({
+ value: value.value,
+ label: valueLabelMap.get(value.value) || value.value,
+ }))
+
+ transformedRules.push({
+ ...promotionRule,
+ attribute_label: currentRuleAttribute.label,
+ operator_label:
+ operatorsMap[promotionRule.operator]?.label || promotionRule.operator,
+ disguised: false,
+ required: currentRuleAttribute.required || false,
+ })
+ }
+
+ res.json({
+ rules: transformedRules,
+ })
+}
diff --git a/packages/medusa/src/api-v2/admin/promotions/middlewares.ts b/packages/medusa/src/api-v2/admin/promotions/middlewares.ts
index 742b7d8763..4eba8e1579 100644
--- a/packages/medusa/src/api-v2/admin/promotions/middlewares.ts
+++ b/packages/medusa/src/api-v2/admin/promotions/middlewares.ts
@@ -45,6 +45,16 @@ export const adminPromotionRoutesMiddlewares: MiddlewareRoute[] = [
),
],
},
+ {
+ method: ["GET"],
+ matcher: "/admin/promotions/:id/:rule_type",
+ middlewares: [
+ transformQuery(
+ AdminGetPromotionsPromotionParams,
+ QueryConfig.retrieveNewTransformQueryConfig
+ ),
+ ],
+ },
{
method: ["POST"],
matcher: "/admin/promotions/:id",
diff --git a/packages/medusa/src/api-v2/admin/promotions/query-config.ts b/packages/medusa/src/api-v2/admin/promotions/query-config.ts
index a6d94bd360..e0144f0f35 100644
--- a/packages/medusa/src/api-v2/admin/promotions/query-config.ts
+++ b/packages/medusa/src/api-v2/admin/promotions/query-config.ts
@@ -21,25 +21,35 @@ export const defaultAdminPromotionFields = [
"deleted_at",
"campaign.id",
"campaign.name",
+ "campaign.campaign_identifier",
+ "campaign.starts_at",
+ "campaign.ends_at",
"application_method.value",
"application_method.type",
"application_method.max_quantity",
+ "application_method.apply_to_quantity",
+ "application_method.buy_rules_min_quantity",
"application_method.target_type",
"application_method.allocation",
"application_method.created_at",
"application_method.updated_at",
"application_method.deleted_at",
+ "application_method.buy_rules.id",
"application_method.buy_rules.attribute",
"application_method.buy_rules.operator",
"application_method.buy_rules.values.value",
+ "application_method.target_rules.id",
"application_method.target_rules.attribute",
"application_method.target_rules.operator",
"application_method.target_rules.values.value",
+ "rules.id",
"rules.attribute",
"rules.operator",
"rules.values.value",
]
+const defaults = [...defaultAdminPromotionFields]
+
export const retrieveTransformQueryConfig = {
defaultFields: defaultAdminPromotionFields,
defaultRelations: defaultAdminPromotionRelations,
@@ -57,3 +67,9 @@ export const listRuleValueTransformQueryConfig = {
allowed: [],
isList: true,
}
+
+// TODO: replace this with the old one
+export const retrieveNewTransformQueryConfig = {
+ defaults,
+ isList: false,
+}
diff --git a/packages/medusa/src/api-v2/admin/promotions/rule-operator-options/route.ts b/packages/medusa/src/api-v2/admin/promotions/rule-operator-options/route.ts
index c5317f6c9b..7bd25b91fa 100644
--- a/packages/medusa/src/api-v2/admin/promotions/rule-operator-options/route.ts
+++ b/packages/medusa/src/api-v2/admin/promotions/rule-operator-options/route.ts
@@ -1,32 +1,14 @@
-import { RuleOperator } from "@medusajs/utils"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../../types/routing"
-
-const operators = [
- {
- id: RuleOperator.IN,
- value: RuleOperator.IN,
- label: "In",
- },
- {
- id: RuleOperator.EQ,
- value: RuleOperator.EQ,
- label: "Equals",
- },
- {
- id: RuleOperator.NE,
- value: RuleOperator.NE,
- label: "Not In",
- },
-]
+import { operatorsMap } from "../utils"
export const GET = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
res.json({
- operators,
+ operators: Object.values(operatorsMap),
})
}
diff --git a/packages/medusa/src/api-v2/admin/promotions/rule-value-options/[rule_type]/[rule_attribute_id]/route.ts b/packages/medusa/src/api-v2/admin/promotions/rule-value-options/[rule_type]/[rule_attribute_id]/route.ts
index 8f2d009cd7..794c59e977 100644
--- a/packages/medusa/src/api-v2/admin/promotions/rule-value-options/[rule_type]/[rule_attribute_id]/route.ts
+++ b/packages/medusa/src/api-v2/admin/promotions/rule-value-options/[rule_type]/[rule_attribute_id]/route.ts
@@ -6,67 +6,18 @@ import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../../../../types/routing"
-import { validateRuleAttribute, validateRuleType } from "../../../utils"
-
-const queryConfigurations = {
- region: {
- entryPoint: "region",
- labelAttr: "name",
- valueAttr: "id",
- },
- currency: {
- entryPoint: "currency",
- labelAttr: "name",
- valueAttr: "code",
- },
- customer_group: {
- entryPoint: "customer_group",
- labelAttr: "name",
- valueAttr: "id",
- },
- sales_channel: {
- entryPoint: "sales_channel",
- labelAttr: "name",
- valueAttr: "id",
- },
- country: {
- entryPoint: "country",
- labelAttr: "display_name",
- valueAttr: "iso_2",
- },
- product: {
- entryPoint: "product",
- labelAttr: "title",
- valueAttr: "id",
- },
- product_category: {
- entryPoint: "product_category",
- labelAttr: "name",
- valueAttr: "id",
- },
- product_collection: {
- entryPoint: "product_collection",
- labelAttr: "title",
- valueAttr: "id",
- },
- product_type: {
- entryPoint: "product_type",
- labelAttr: "value",
- valueAttr: "id",
- },
- product_tag: {
- entryPoint: "product_tag",
- labelAttr: "value",
- valueAttr: "id",
- },
-}
+import {
+ ruleQueryConfigurations,
+ validateRuleAttribute,
+ validateRuleType,
+} from "../../../utils"
export const GET = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
const { rule_type: ruleType, rule_attribute_id: ruleAttributeId } = req.params
- const queryConfig = queryConfigurations[ruleAttributeId]
+ const queryConfig = ruleQueryConfigurations[ruleAttributeId]
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
validateRuleType(ruleType)
diff --git a/packages/medusa/src/api-v2/admin/promotions/types.ts b/packages/medusa/src/api-v2/admin/promotions/types.ts
index df356c69b9..15d2608c44 100644
--- a/packages/medusa/src/api-v2/admin/promotions/types.ts
+++ b/packages/medusa/src/api-v2/admin/promotions/types.ts
@@ -3,3 +3,7 @@ import { PaginatedResponse, PromotionDTO } from "@medusajs/types"
export type AdminPromotionsListRes = PaginatedResponse<{
promotions: PromotionDTO[]
}>
+
+export type AdminPromotionRes = {
+ promotion: PromotionDTO
+}
diff --git a/packages/medusa/src/api-v2/admin/promotions/utils/index.ts b/packages/medusa/src/api-v2/admin/promotions/utils/index.ts
index 193881239c..b9837dfc39 100644
--- a/packages/medusa/src/api-v2/admin/promotions/utils/index.ts
+++ b/packages/medusa/src/api-v2/admin/promotions/utils/index.ts
@@ -1,3 +1,5 @@
+export * from "./operators-map"
export * from "./rule-attributes-map"
+export * from "./rule-query-configuration"
export * from "./validate-rule-attribute"
export * from "./validate-rule-type"
diff --git a/packages/medusa/src/api-v2/admin/promotions/utils/operators-map.ts b/packages/medusa/src/api-v2/admin/promotions/utils/operators-map.ts
new file mode 100644
index 0000000000..254f6b6dde
--- /dev/null
+++ b/packages/medusa/src/api-v2/admin/promotions/utils/operators-map.ts
@@ -0,0 +1,19 @@
+import { RuleOperator } from "@medusajs/utils"
+
+export const operatorsMap = {
+ [RuleOperator.IN]: {
+ id: RuleOperator.IN,
+ value: RuleOperator.IN,
+ label: "In",
+ },
+ [RuleOperator.EQ]: {
+ id: RuleOperator.EQ,
+ value: RuleOperator.EQ,
+ label: "Equals",
+ },
+ [RuleOperator.NE]: {
+ id: RuleOperator.NE,
+ value: RuleOperator.NE,
+ label: "Not In",
+ },
+}
diff --git a/packages/medusa/src/api-v2/admin/promotions/utils/rule-attributes-map.ts b/packages/medusa/src/api-v2/admin/promotions/utils/rule-attributes-map.ts
index 9e41712c0d..7fb2e989e0 100644
--- a/packages/medusa/src/api-v2/admin/promotions/utils/rule-attributes-map.ts
+++ b/packages/medusa/src/api-v2/admin/promotions/utils/rule-attributes-map.ts
@@ -1,3 +1,17 @@
+export enum DisguisedRule {
+ APPLY_TO_QUANTITY = "apply_to_quantity",
+ BUY_RULES_MIN_QUANTITY = "buy_rules_min_quantity",
+}
+
+export const disguisedRulesMap = {
+ [DisguisedRule.APPLY_TO_QUANTITY]: {
+ relation: "application_method",
+ },
+ [DisguisedRule.BUY_RULES_MIN_QUANTITY]: {
+ relation: "application_method",
+ },
+}
+
const ruleAttributes = [
{
id: "currency",
@@ -66,20 +80,24 @@ const commonAttributes = [
const buyRuleAttributes = [
{
- id: "buy_rules_min_quantity",
- value: "buy_rules_min_quantity",
+ id: DisguisedRule.BUY_RULES_MIN_QUANTITY,
+ value: DisguisedRule.BUY_RULES_MIN_QUANTITY,
label: "Minimum quantity of items",
+ field_type: "number",
required: true,
+ disguised: true,
},
...commonAttributes,
]
const targetRuleAttributes = [
{
- id: "apply_to_quantity",
- value: "apply_to_quantity",
+ id: DisguisedRule.APPLY_TO_QUANTITY,
+ value: DisguisedRule.APPLY_TO_QUANTITY,
label: "Quantity of items promotion will apply to",
+ field_type: "number",
required: true,
+ disguised: true,
},
...commonAttributes,
]
diff --git a/packages/medusa/src/api-v2/admin/promotions/utils/rule-query-configuration.ts b/packages/medusa/src/api-v2/admin/promotions/utils/rule-query-configuration.ts
new file mode 100644
index 0000000000..61e9a1de9d
--- /dev/null
+++ b/packages/medusa/src/api-v2/admin/promotions/utils/rule-query-configuration.ts
@@ -0,0 +1,52 @@
+export const ruleQueryConfigurations = {
+ region: {
+ entryPoint: "region",
+ labelAttr: "name",
+ valueAttr: "id",
+ },
+ currency: {
+ entryPoint: "currency",
+ labelAttr: "name",
+ valueAttr: "code",
+ },
+ customer_group: {
+ entryPoint: "customer_group",
+ labelAttr: "name",
+ valueAttr: "id",
+ },
+ sales_channel: {
+ entryPoint: "sales_channel",
+ labelAttr: "name",
+ valueAttr: "id",
+ },
+ country: {
+ entryPoint: "country",
+ labelAttr: "display_name",
+ valueAttr: "iso_2",
+ },
+ product: {
+ entryPoint: "product",
+ labelAttr: "title",
+ valueAttr: "id",
+ },
+ product_category: {
+ entryPoint: "product_category",
+ labelAttr: "name",
+ valueAttr: "id",
+ },
+ product_collection: {
+ entryPoint: "product_collection",
+ labelAttr: "title",
+ valueAttr: "id",
+ },
+ product_type: {
+ entryPoint: "product_type",
+ labelAttr: "value",
+ valueAttr: "id",
+ },
+ product_tag: {
+ entryPoint: "product_tag",
+ labelAttr: "value",
+ valueAttr: "id",
+ },
+}
diff --git a/packages/medusa/src/api-v2/admin/promotions/validators.ts b/packages/medusa/src/api-v2/admin/promotions/validators.ts
index 17906ec6ee..c3da8a0fd2 100644
--- a/packages/medusa/src/api-v2/admin/promotions/validators.ts
+++ b/packages/medusa/src/api-v2/admin/promotions/validators.ts
@@ -29,6 +29,7 @@ import { XorConstraint } from "../../../types/validators/xor"
import { AdminPostCampaignsReq } from "../campaigns/validators"
export class AdminGetPromotionsPromotionParams extends FindParams {}
+export class AdminGetPromotionRules extends FindParams {}
export class AdminGetPromotionsRuleValueParams extends extendedFindParamsMixin({
limit: 100,
diff --git a/packages/types/src/promotion/http.ts b/packages/types/src/promotion/http.ts
new file mode 100644
index 0000000000..8942ade390
--- /dev/null
+++ b/packages/types/src/promotion/http.ts
@@ -0,0 +1,10 @@
+export type AdminGetPromotionRulesRes = {
+ id?: string
+ attribute: string
+ attribute_label: string
+ operator: string
+ operator_label: string
+ values: { label?: string; value: string }[]
+ disguised?: boolean
+ required?: boolean
+}[]
diff --git a/packages/types/src/promotion/index.ts b/packages/types/src/promotion/index.ts
index dfae9af8a2..897a509125 100644
--- a/packages/types/src/promotion/index.ts
+++ b/packages/types/src/promotion/index.ts
@@ -1,4 +1,5 @@
export * from "./common"
+export * from "./http"
export * from "./mutations"
export * from "./service"
export * from "./workflows"