feat(dashboard): added details page for promotions + edit sliders (#6882)

* chore: added details page for promotions

* chore: add edit rules, edit details and edit campaign pages

* chore: change to type button

* chore: connection for rules

* chore: listing rule labels of multiple modules

* chore: add badge summary list

* chore: fix campaigns
This commit is contained in:
Riqwan Thamir authored and GitHub committed 2024-04-06 13:20:31 +02:00
1 parent 07fb058d96
commit 5e30b8cce6
60 files changed
+2803 -153

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
"medusa-react": patch
"@medusajs/medusa": patch
---
feat(dashboard): added details page for promotions
@@ -194,7 +194,7 @@ medusaIntegrationTestRunner({
`/admin/pricing/rule-types/${ruleType.id}`, `/admin/pricing/rule-types/${ruleType.id}`,
adminHeaders adminHeaders
) )
console.log("response.data -- ", response.data)
expect(response.status).toEqual(200) expect(response.status).toEqual(200)
expect(response.data).toEqual({ expect(response.data).toEqual({
id: ruleType.id, id: ruleType.id,
@@ -140,6 +140,15 @@
}, },
"required": ["domain"] "required": ["domain"]
}, },
"campaigns": {
"type": "object",
"properties": {
"domain": {
"type": "string"
}
},
"required": ["domain"]
},
"giftCards": { "giftCards": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -30,6 +30,7 @@
"items_one": "{{count}} item", "items_one": "{{count}} item",
"items_other": "{{count}} items", "items_other": "{{count}} items",
"countSelected": "{{count}} selected", "countSelected": "{{count}} selected",
"plusCount": "+ {{count}}",
"plusCountMore": "+ {{count}} more", "plusCountMore": "+ {{count}} more",
"areYouSure": "Are you sure?", "areYouSure": "Are you sure?",
"noRecordsFound": "No records found", "noRecordsFound": "No records found",
@@ -611,12 +612,116 @@
"domain": "Promotions", "domain": "Promotions",
"fields": { "fields": {
"method": "Method", "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.", "deleteWarning": "You are about to delete the promotion {{code}}. This action cannot be undone.",
"createPromotionTitle": "Create Promotion", "createPromotionTitle": "Create Promotion",
"type": "Promotion type" "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": { "pricing": {
"domain": "Pricing", "domain": "Pricing",
"deletePriceListWarning": "You are about to delete the price list {{name}}. This action cannot be undone.", "deletePriceListWarning": "You are about to delete the price list {{name}}. This action cannot be undone.",
@@ -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 (
<div
className={clx(
"ml-2 text-ui-fg-subtle txt-compact-small gap-x-2 overflow-hidden",
{
"inline-flex": inline,
flex: !inline,
},
className
)}
>
{list.slice(0, n).map((item) => {
return (
<Badge key={item} size="2xsmall">
{item}
</Badge>
)
})}
{list.length > n && (
<div className="whitespace-nowrap">
<Tooltip
content={
<ul>
{list.slice(n).map((c) => (
<li key={c}>{c}</li>
))}
</ul>
}
>
<Badge size="2xsmall" className="cursor-default whitespace-nowrap">
{title}
</Badge>
</Tooltip>
</div>
)}
</div>
)
}
@@ -0,0 +1 @@
export * from "./badge-list-summary"
@@ -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 { Button, Text, clx } from "@medusajs/ui"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
@@ -32,21 +32,44 @@ export const NoResults = ({ title, message, className }: NoResultsProps) => {
) )
} }
type NoRecordsProps = { type ActionProps = {
title?: string
message?: string
action?: { action?: {
to: string to: string
label: string label: string
} }
className?: string
} }
type NoRecordsProps = {
title?: string
message?: string
className?: string
buttonVariant?: string
} & ActionProps
const DefaultButton = ({ action }: ActionProps) =>
action && (
<Link to={action.to}>
<Button variant="secondary" size="small">
{action.label}
</Button>
</Link>
)
const TransparentIconLeftButton = ({ action }: ActionProps) =>
action && (
<Link to={action.to}>
<Button variant="transparent" className="text-ui-fg-interactive">
<PlusMini /> {action.label}
</Button>
</Link>
)
export const NoRecords = ({ export const NoRecords = ({
title, title,
message, message,
action, action,
className, className,
buttonVariant = "default",
}: NoRecordsProps) => { }: NoRecordsProps) => {
const { t } = useTranslation() const { t } = useTranslation()
@@ -59,19 +82,19 @@ export const NoRecords = ({
> >
<div className="flex flex-col items-center gap-y-2"> <div className="flex flex-col items-center gap-y-2">
<ExclamationCircle /> <ExclamationCircle />
<Text size="small" leading="compact" weight="plus"> <Text size="small" leading="compact" weight="plus">
{title ?? t("general.noRecordsTitle")} {title ?? t("general.noRecordsTitle")}
</Text> </Text>
<Text size="small" className="text-ui-fg-muted"> <Text size="small" className="text-ui-fg-muted">
{message ?? t("general.noRecordsMessage")} {message ?? t("general.noRecordsMessage")}
</Text> </Text>
</div> </div>
{action && (
<Link to={action.to}> {buttonVariant === "default" && <DefaultButton action={action} />}
<Button variant="secondary" size="small"> {buttonVariant === "transparentIconLeft" && (
{action.label} <TransparentIconLeftButton action={action} />
</Button>
</Link>
)} )}
</div> </div>
) )
@@ -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<CampaignRes, Error, CampaignRes, QueryKey>,
"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<string, any>,
options?: Omit<
UseQueryOptions<CampaignListRes, Error, CampaignListRes, QueryKey>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryFn: () => client.campaigns.list(query),
queryKey: campaignsQueryKeys.list(query),
...options,
})
return { ...data, ...rest }
}
export const useCreateCampaign = (
options?: UseMutationOptions<CampaignRes, Error, CreateCampaignReq>
) => {
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<CampaignRes, Error, UpdateCampaignReq>
) => {
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<CampaignDeleteRes, Error, void>
) => {
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,
})
}
@@ -1,12 +1,47 @@
import { QueryKey, UseQueryOptions, useQuery } from "@tanstack/react-query"
import { AdminGetPromotionsParams } from "@medusajs/medusa" import { AdminGetPromotionsParams } from "@medusajs/medusa"
import {
QueryKey,
useMutation,
UseMutationOptions,
useQuery,
UseQueryOptions,
} from "@tanstack/react-query"
import { client } from "../../lib/client" import { client } from "../../lib/client"
import { queryClient } from "../../lib/medusa"
import { queryKeysFactory } from "../../lib/query-key-factory" 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 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 = ( export const usePromotion = (
id: string, id: string,
@@ -24,6 +59,28 @@ export const usePromotion = (
return { ...data, ...rest } 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 = ( export const usePromotions = (
query?: AdminGetPromotionsParams, query?: AdminGetPromotionsParams,
options?: Omit< options?: Omit<
@@ -39,3 +96,185 @@ export const usePromotions = (
return { ...data, ...rest } 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<PromotionDeleteRes, Error, void>
) => {
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<PromotionRes, Error, CreatePromotionReq>
) => {
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<PromotionRes, Error, UpdatePromotionReq>
) => {
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<PromotionRes, Error, BatchAddPromotionRulesReq>
) => {
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,
})
}
@@ -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)
)
}
@@ -1,3 +1,2 @@
export * from "./auth" export * from "./auth"
export * from "./promotion"
export * from "./store" export * from "./store"
@@ -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<AdminPromotionsListRes>,
Error,
ReturnType<PromotionQueryKey["list"]>
>
) => {
const { data, ...rest } = useAdminCustomQuery<
AdminGetPromotionsParams,
AdminPromotionsListRes
>("/promotions", adminPromotionKeys.list(query), query, options as any)
return { ...data, ...rest }
}
@@ -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<string, any>) {
return getRequest<CampaignRes>(`/admin/campaigns/${id}`, query)
}
async function listCampaigns(query?: Record<string, any>) {
return getRequest<CampaignListRes>(`/admin/campaigns`, query)
}
async function createCampaign(payload: CreateCampaignDTO) {
return postRequest<CampaignRes>(`/admin/campaigns`, payload)
}
async function updateCampaign(id: string, payload: UpdateCampaignDTO) {
return postRequest<CampaignRes>(`/admin/campaigns/${id}`, payload)
}
async function deleteCampaign(id: string) {
return deleteRequest<CampaignDeleteRes>(`/admin/campaigns/${id}`)
}
export const campaigns = {
retrieve: retrieveCampaign,
list: listCampaigns,
create: createCampaign,
update: updateCampaign,
delete: deleteCampaign,
}
@@ -1,5 +1,6 @@
import { apiKeys } from "./api-keys" import { apiKeys } from "./api-keys"
import { auth } from "./auth" import { auth } from "./auth"
import { campaigns } from "./campaigns"
import { categories } from "./categories" import { categories } from "./categories"
import { collections } from "./collections" import { collections } from "./collections"
import { currencies } from "./currencies" import { currencies } from "./currencies"
@@ -19,6 +20,7 @@ import { workflowExecutions } from "./workflow-executions"
export const client = { export const client = {
auth: auth, auth: auth,
apiKeys: apiKeys, apiKeys: apiKeys,
campaigns: campaigns,
categories: categories, categories: categories,
customers: customers, customers: customers,
currencies: currencies, currencies: currencies,
@@ -1,23 +1,113 @@
import { AdminGetPromotionsParams } from "@medusajs/medusa" import { AdminGetPromotionsParams } from "@medusajs/medusa"
import { PromotionListRes, PromotionRes } from "../../types/api-responses" import {
import { getRequest } from "./common" 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 ( async function retrievePromotion(id: string, query?: AdminGetPromotionsParams) {
id: string,
query?: AdminGetPromotionsParams
) => {
return getRequest<PromotionRes, AdminGetPromotionsParams>( return getRequest<PromotionRes, AdminGetPromotionsParams>(
`/admin/promotions/${id}`, `/admin/promotions/${id}`,
query query
) )
} }
const listPromotions = async (query?: AdminGetPromotionsParams) => { async function listPromotions(query?: AdminGetPromotionsParams) {
return getRequest<PromotionListRes>(`/admin/promotions`, query) return getRequest<PromotionListRes>(`/admin/promotions`, query)
} }
async function deletePromotion(id: string) {
return deleteRequest<PromotionDeleteRes>(`/admin/promotions/${id}`)
}
async function updatePromotion(id: string, payload: UpdatePromotionReq) {
return postRequest<PromotionRes>(`/admin/promotions/${id}`, payload)
}
async function createPromotion(payload: CreatePromotionReq) {
return postRequest<PromotionRes>(`/admin/promotions`, payload)
}
async function addPromotionRules(
id: string,
ruleType: string,
payload: BatchAddPromotionRulesReq
) {
return postRequest<PromotionRes>(
`/admin/promotions/${id}/${ruleType}/batch/add`,
payload
)
}
async function updatePromotionRules(
id: string,
ruleType: string,
payload: BatchUpdatePromotionRulesReq
) {
return postRequest<PromotionRes>(
`/admin/promotions/${id}/${ruleType}/batch/update`,
payload
)
}
async function removePromotionRules(
id: string,
ruleType: string,
payload: BatchRemovePromotionRulesReq
) {
return postRequest<PromotionRes>(
`/admin/promotions/${id}/${ruleType}/batch/remove`,
payload
)
}
async function listPromotionRules(id: string, ruleType: string) {
return getRequest<PromotionRuleAttributesListRes>(
`/admin/promotions/${id}/${ruleType}`
)
}
async function listPromotionRuleAttributes(ruleType: string) {
return getRequest<PromotionRuleAttributesListRes>(
`/admin/promotions/rule-attribute-options/${ruleType}`
)
}
async function listPromotionRuleValues(ruleType: string, ruleValue: string) {
return getRequest<PromotionRuleValuesListRes>(
`/admin/promotions/rule-value-options/${ruleType}/${ruleValue}`
)
}
async function listPromotionRuleOperators() {
return getRequest<PromotionRuleOperatorsListRes>(
`/admin/promotions/rule-operator-options`
)
}
export const promotions = { export const promotions = {
retrieve: retrievePromotion, retrieve: retrievePromotion,
list: listPromotions, list: listPromotions,
delete: deletePromotion,
update: updatePromotion,
create: createPromotion,
addRules: addPromotionRules,
removeRules: removePromotionRules,
updateRules: updatePromotionRules,
listRules: listPromotionRules,
listRuleAttributes: listPromotionRuleAttributes,
listRuleOperators: listPromotionRuleOperators,
listRuleValues: listPromotionRuleValues,
} }
@@ -150,6 +150,34 @@ export const v2Routes: RouteObject[] = [
path: "", path: "",
lazy: () => import("../../v2-routes/promotions/promotion-list"), 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"),
},
],
},
], ],
}, },
{ {
@@ -4,15 +4,21 @@
import { import {
CreateApiKeyDTO, CreateApiKeyDTO,
CreateCampaignDTO,
CreateCustomerDTO, CreateCustomerDTO,
CreateInviteDTO, CreateInviteDTO,
CreateProductCollectionDTO, CreateProductCollectionDTO,
CreatePromotionDTO,
CreatePromotionRuleDTO,
CreateRegionDTO, CreateRegionDTO,
CreateSalesChannelDTO, CreateSalesChannelDTO,
CreateStockLocationInput, CreateStockLocationInput,
UpdateApiKeyDTO, UpdateApiKeyDTO,
UpdateCampaignDTO,
UpdateCustomerDTO, UpdateCustomerDTO,
UpdateProductCollectionDTO, UpdateProductCollectionDTO,
UpdatePromotionDTO,
UpdatePromotionRuleDTO,
UpdateRegionDTO, UpdateRegionDTO,
UpdateSalesChannelDTO, UpdateSalesChannelDTO,
UpdateStockLocationInput, UpdateStockLocationInput,
@@ -57,3 +63,14 @@ export type UpdateStockLocationReq = UpdateStockLocationInput
// Product Collections // Product Collections
export type CreateProductCollectionReq = CreateProductCollectionDTO export type CreateProductCollectionReq = CreateProductCollectionDTO
export type UpdateProductCollectionReq = UpdateProductCollectionDTO 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
@@ -4,6 +4,7 @@
import { import {
ApiKeyDTO, ApiKeyDTO,
CampaignDTO,
CurrencyDTO, CurrencyDTO,
CustomerDTO, CustomerDTO,
InviteDTO, InviteDTO,
@@ -45,6 +46,10 @@ export type CustomerListRes = { customers: CustomerDTO[] } & ListRes
// Promotions // Promotions
export type PromotionRes = { promotion: PromotionDTO } export type PromotionRes = { promotion: PromotionDTO }
export type PromotionListRes = { promotions: PromotionDTO[] } & ListRes export type PromotionListRes = { promotions: PromotionDTO[] } & ListRes
export type PromotionRuleAttributesListRes = { attributes: Record<any, any>[] }
export type PromotionRuleOperatorsListRes = { operators: Record<any, any>[] }
export type PromotionRuleValuesListRes = { values: Record<any, any>[] }
export type PromotionRulesListRes = { rules: Record<any, any>[] }
export type PromotionDeleteRes = DeleteRes export type PromotionDeleteRes = DeleteRes
// Users // Users
@@ -65,6 +70,11 @@ export type RegionRes = { region: RegionDTO }
export type RegionListRes = { regions: RegionDTO[] } & ListRes export type RegionListRes = { regions: RegionDTO[] } & ListRes
export type RegionDeleteRes = DeleteRes export type RegionDeleteRes = DeleteRes
// Campaigns
export type CampaignRes = { campaign: CampaignDTO }
export type CampaignListRes = { campaigns: CampaignDTO[] } & ListRes
export type CampaignDeleteRes = DeleteRes
// API Keys // API Keys
export type ExtendedApiKeyDTO = ApiKeyDTO & { export type ExtendedApiKeyDTO = ApiKeyDTO & {
sales_channels: SalesChannelDTO[] | null sales_channels: SalesChannelDTO[] | null
@@ -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<zod.infer<typeof EditRules>>({
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<any, any> = {}
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 (
<RouteDrawer.Form form={form}>
<form onSubmit={handleSubmit} className="flex h-full flex-col">
<RouteDrawer.Body>
<div className="flex flex-col">
<Heading level="h2" className="mb-2">
{t(`promotions.fields.conditions.${ruleType}.title`)}
</Heading>
<Text className="text-ui-fg-subtle txt-small mb-10">
{t(`promotions.fields.conditions.${ruleType}.description`)}
</Text>
{fields.map((fieldRule, index) => {
const identifier = fieldRule.id
const { ref: attributeRef, ...attributeFields } = form.register(
`rules.${index}.attribute`
)
const { ref: operatorRef, ...operatorFields } = form.register(
`rules.${index}.operator`
)
const { ref: valuesRef, ...valuesFields } = form.register(
`rules.${index}.values`
)
return (
<Fragment key={index}>
<div className="flex flex-row gap-2 bg-ui-bg-subtle py-2 px-2 rounded-xl border border-ui-border-base">
<div className="grow">
<Form.Field
key={`${identifier}.${attributeFields.name}`}
{...attributeFields}
render={({ field: { onChange, ref, ...field } }) => {
const existingAttributes =
fields?.map((field) => field.attribute) || []
const attributeOptions =
attributes?.filter((attr) => {
if (attr.value === fieldRule.attribute) {
return true
}
return !existingAttributes.includes(attr.value)
}) || []
return (
<Form.Item className="mb-2">
{fieldRule.required && (
<p className="text text-ui-fg-muted txt-small">
Required
</p>
)}
<Form.Control>
<Select
{...field}
onValueChange={(e) => {
update(index, { ...fieldRule, values: [] })
onChange(e)
}}
disabled={fieldRule.required}
>
<Select.Trigger
ref={attributeRef}
className="bg-ui-bg-base"
>
<Select.Value placeholder="Select Attribute" />
</Select.Trigger>
<Select.Content>
{attributeOptions?.map((c, i) => (
<Select.Item
key={`${identifier}-attribute-option-${i}`}
value={c.value}
>
<span className="text-ui-fg-subtle">
{c.label}
</span>
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<div className="flex gap-2">
<Form.Field
key={`${identifier}.${operatorFields.name}`}
{...operatorFields}
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item className="basis-1/2">
<Form.Control>
<Select
{...field}
onValueChange={onChange}
disabled={fieldRule.required}
>
<Select.Trigger
ref={operatorRef}
className="bg-ui-bg-base"
>
<Select.Value placeholder="Select Operator" />
</Select.Trigger>
<Select.Content>
{operators?.map((c, i) => (
<Select.Item
key={`${identifier}-operator-option-${i}`}
value={c.value}
>
<span className="text-ui-fg-subtle">
{c.label}
</span>
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
key={`${identifier}.${valuesFields.name}-${fieldRule.attribute}`}
{...valuesFields}
render={({ field: { onChange, ref, ...field } }) => {
if (fieldRule.field_type === "number") {
return (
<Form.Item className="basis-1/2">
<Form.Control>
<Input
{...field}
type="number"
onChange={onChange}
className="bg-ui-bg-base"
ref={valuesRef}
min={1}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
} else if (fieldRule.field_type === "text") {
return (
<Form.Item className="basis-1/2">
<Form.Control>
<Input
{...field}
onChange={onChange}
className="bg-ui-bg-base"
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
} else {
const attribute = attributes?.find(
(attr) => attr.value === fieldRule.attribute
)
const options = attribute
? fetchOptionsForRule(ruleType, attribute.id)
: []
return (
<Form.Item className="basis-1/2">
<Form.Control>
<Combobox
{...field}
placeholder="Select Values"
options={options}
onChange={onChange}
className="bg-ui-bg-base"
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}
}}
/>
</div>
</div>
<div className="flex-none self-center px-1">
<XMarkMini
className={`text-ui-fg-muted cursor-pointer ${
fieldRule.required ? "invisible" : "visible"
}`}
onClick={() => {
if (!fieldRule.required) {
if (fieldRule.id) {
setRulesToRemove([...rulesToRemove, fieldRule])
}
remove(index)
}
}}
/>
</div>
</div>
{index < fields.length - 1 && (
<div className="relative px-6 py-3">
<div className="absolute top-0 bottom-0 left-[40px] z-[-1] border-ui-border-strong w-px bg-[linear-gradient(var(--border-strong)_33%,rgba(255,255,255,0)_0%)] bg-[length:1px_3px] bg-repeat-y"></div>
<Badge size="2xsmall" className=" text-xs">
AND
</Badge>
</div>
)}
</Fragment>
)
})}
<div className="mt-8">
<Button
type="button"
variant="secondary"
className="inline-block"
onClick={() => {
append({
attribute: "",
operator: "",
values: [],
required: false,
})
}}
>
Add condition
</Button>
<Button
type="button"
variant="transparent"
className="inline-block text-ui-fg-muted hover:text-ui-fg-subtle ml-2"
onClick={() => {
const indicesToRemove = fields
.map((field, index) => (field.required ? null : index))
.filter((f) => f !== null)
setRulesToRemove(fields.filter((f) => !f.required))
remove(indicesToRemove)
}}
>
Clear all
</Button>
</div>
</div>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button size="small" type="submit">
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</form>
</RouteDrawer.Form>
)
}
@@ -0,0 +1 @@
export * from "./edit-rules-form"
@@ -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 },
],
},
]
}
}
@@ -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 (
<RouteDrawer>
<RouteDrawer.Header>
<Heading>{t(`promotions.edit.${ruleType}.title`)}</Heading>
</RouteDrawer.Header>
{!isLoading && promotion && attributes && operators && (
<EditRulesForm
promotion={promotion}
rules={rules}
ruleType={ruleType}
attributes={attributes}
operators={operators}
/>
)}
</RouteDrawer>
)
}
@@ -0,0 +1 @@
export { EditRules as Component } from "./edit-rules"
@@ -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<zod.infer<typeof EditPromotionSchema>>({
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 (
<RouteDrawer.Form form={form}>
<form onSubmit={handleSubmit} className="flex h-full flex-col">
<RouteDrawer.Body>
<div className="flex h-full flex-col gap-y-8">
<Form.Field
control={form.control}
name="existing"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>Method</Form.Label>
<Form.Control>
<RadioGroup
className="flex-col gap-y-3"
{...field}
value={field.value}
onValueChange={field.onChange}
>
<RadioGroup.ChoiceBox
value={"true"}
label={t("promotions.form.campaign.existing.title")}
description={t(
"promotions.form.campaign.existing.description"
)}
/>
<RadioGroup.ChoiceBox
value={"false"}
label={t("promotions.form.campaign.new.title")}
description={t(
"promotions.form.campaign.new.description"
)}
disabled
/>
</RadioGroup>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="campaign_id"
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item>
<Form.Label>
{t("promotions.form.campaign.existing.title")}
</Form.Label>
<Form.Control>
<Select onValueChange={onChange} {...field}>
<Select.Trigger ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{campaigns.map((c) => (
<Select.Item key={c.id} value={c.id}>
{c.name?.toUpperCase()}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<CampaignDetails campaign={selectedCampaign} />
</div>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button size="small" type="submit" isLoading={isPending}>
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</form>
</RouteDrawer.Form>
)
}
@@ -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 (
<Fragment>
<div>
<Heading level="h2" className="mb-4">
{t("campaigns.details")}
</Heading>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center py-1">
<Text className="txt-small-plus font-">
{t("campaigns.fields.identifier")}
</Text>
<div className="flex items-center gap-1">
<Text className="txt-small">
{campaign.campaign_identifier || "-"}
</Text>
</div>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center py-1">
<Text className="txt-small-plus">{t("fields.description")}</Text>
<div className="flex items-center gap-1">
<Text className="txt-small">{campaign.description || "-"}</Text>
</div>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center py-1">
<Text className="txt-small-plus">
{t("campaigns.fields.start_date")}
</Text>
<div className="flex items-center gap-1">
<Text className="txt-small">
{campaign.starts_at?.toString() || "-"}
</Text>
</div>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center py-1">
<Text className="txt-small-plus">
{t("campaigns.fields.end_date")}
</Text>
<div className="flex items-center gap-1">
<Text className="txt-small">
{campaign.ends_at?.toString() || "-"}
</Text>
</div>
</div>
</div>
<div>
<Heading level="h2" className="mb-4">
{t("campaigns.budget.details")}
</Heading>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center py-1">
<Text className="txt-small-plus font-">
{t("campaigns.budget.fields.type")}
</Text>
<div className="flex items-center gap-1">
<Text className="txt-small">{campaign.budget?.type || "-"}</Text>
</div>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center py-1">
<Text className="txt-small-plus">
{t("campaigns.budget.fields.currency")}
</Text>
<div className="flex items-center gap-1">
<Text className="txt-small">{campaign.currency || "-"}</Text>
</div>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center py-1">
<Text className="txt-small-plus">
{t("campaigns.budget.fields.limit")}
</Text>
<div className="flex items-center gap-1">
<Text className="txt-small">{campaign.budget?.limit || "-"}</Text>
</div>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center py-1">
<Text className="txt-small-plus">
{t("campaigns.budget.fields.used")}
</Text>
<div className="flex items-center gap-1">
<Text className="txt-small">{campaign.budget?.used || "-"}</Text>
</div>
</div>
</div>
</Fragment>
)
}
@@ -0,0 +1 @@
export * from "./add-campaign-promotion-form"
@@ -0,0 +1 @@
export { PromotionAddCampaign as Component } from "./promotion-add-campaign"
@@ -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 (
<RouteDrawer>
<RouteDrawer.Header>
<Heading>{t("promotions.addToCampaign.title")}</Heading>
</RouteDrawer.Header>
{!isPending && !areCampaignsLoading && promotion && campaigns && (
<AddCampaignPromotionForm promotion={promotion} campaigns={campaigns} />
)}
</RouteDrawer>
)
}
@@ -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 (
<Fragment>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("campaigns.fields.name")}
</Text>
<div className="flex items-center gap-1">
<Text size="small" leading="compact" className="text-pretty">
{campaign?.name}
</Text>
</div>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("campaigns.fields.identifier")}
</Text>
<div className="flex items-center gap-1">
<Text size="small" leading="compact" className="text-pretty">
{campaign?.campaign_identifier}
</Text>
</div>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("campaigns.fields.start_date")}
</Text>
<div className="flex items-center gap-1">
<Text size="small" leading="compact" className="text-pretty">
{formatDate(campaign?.starts_at)}
</Text>
</div>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("campaigns.fields.end_date") || "-"}
</Text>
<div className="flex items-center gap-1">
<Text size="small" leading="compact" className="text-pretty">
{formatDate(campaign?.ends_at)}
</Text>
</div>
</div>
</Fragment>
)
}
export const CampaignSection = ({ campaign }: { campaign: CampaignDTO }) => {
const { t } = useTranslation()
const { id } = useParams()
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("promotions.fields.campaign")}</Heading>
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.edit"),
to: "add-to-campaign",
icon: <PencilSquare />,
},
],
},
]}
/>
</div>
{campaign ? (
<CampaignDetailSection campaign={campaign} />
) : (
<NoRecords
className="h-[180px] p-6 text-center"
title="Not part of a campaign"
message="Add this promotion to an existing campaign"
action={{
to: `/promotions/${id}/add-to-campaign`,
label: "Add to Campaign",
}}
buttonVariant="transparentIconLeft"
/>
)}
</Container>
)
}
@@ -0,0 +1 @@
export * from "./campaign-section.tsx"
@@ -0,0 +1 @@
export * from "./promotion-conditions-section"
@@ -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 (
<div className="bg-ui-bg-subtle shadow-borders-base flex justify-around rounded-md p-2 align-center">
<div className="flex text-ui-fg-subtle txt-compact-xsmall whitespace-nowrap items-center">
<Badge
size="2xsmall"
key="rule-attribute"
className="mx-1 txt-compact-xsmall-plus tag-neutral-text truncate inline-block"
>
{rule.attribute_label}
</Badge>
<span className="mx-1 txt-compact-2xsmall inline-block">
{rule.operator_label}
</span>
<BadgeListSummary
inline
className="!txt-compact-small-plus"
list={rule.values.map((v) => v.label)}
/>
</div>
</div>
)
}
type PromotionConditionsSectionProps = {
rules: AdminGetPromotionRulesRes
ruleType: PromotionRuleTypes
}
export const PromotionConditionsSection = ({
rules,
ruleType,
}: PromotionConditionsSectionProps) => {
const { t } = useTranslation()
return (
<Container className="p-0">
<div className="flex items-center justify-between px-6 py-4">
<div className="flex flex-col">
<Heading>
{t(`promotions.fields.conditions.${ruleType}.title`)}
</Heading>
</div>
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("actions.edit"),
to: `${ruleType}/edit`,
},
],
},
]}
/>
</div>
<div className="text-ui-fg-subtle flex flex-col gap-2 px-6 pb-4 pt-2">
{!rules.length && (
<NoRecords
className="h-[180px]"
title="No records yet."
message="Please check back later or add a target condition today"
action={{ to: "/promotions", label: "Add condition" }}
buttonVariant="transparentIconLeft"
/>
)}
{rules.map((rule) => (
<RuleBlock key={`${rule.id}-${rule.attribute}`} rule={rule} />
))}
</div>
</Container>
)
}
@@ -0,0 +1 @@
export * from "./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 (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<div className="flex flex-col">
<Heading>{promotion.code}</Heading>
</div>
<div className="flex items-center gap-x-2">
<StatusBadge color={color}>{text}</StatusBadge>
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("actions.edit"),
to: `/promotions/${promotion.id}/edit`,
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("actions.delete"),
onClick: handleDelete,
},
],
},
]}
/>
</div>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("promotions.fields.method")}
</Text>
<Text size="small" leading="compact" className="text-pretty">
{promotion.is_automatic ? "Promotion code" : "Automatic"}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("fields.code")}
</Text>
<div className="flex items-center gap-1">
<Text
size="small"
weight="plus"
leading="compact"
className="text-pretty"
>
{promotion.code}
</Text>
<Copy content={promotion.code!} variant="mini" />
</div>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("promotions.fields.type")}
</Text>
<Text size="small" leading="compact" className="text-pretty capitalize">
{promotion.type}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("promotions.fields.value")}
</Text>
<Text size="small" leading="compact" className="text-pretty">
{promotion.application_method?.value}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("promotions.fields.allocation")}
</Text>
<Text size="small" leading="compact" className="text-pretty capitalize">
{promotion.application_method?.allocation!}
</Text>
</div>
</Container>
)
}
@@ -0,0 +1,2 @@
export { promotionLoader as loader } from "./loader"
export { PromotionDetail as Component } from "./promotion-detail.tsx"
@@ -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<Response<AdminPromotionRes>>(query.queryKey) ??
(await queryClient.fetchQuery(query))
)
}
@@ -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<typeof promotionLoader>
>
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 <div>Loading...</div>
}
return (
<div className="flex flex-col gap-y-2">
{before.widgets.map((w, i) => {
return (
<div key={i}>
<w.Component />
</div>
)
})}
<div className="flex flex-col gap-x-4 xl:flex-row xl:items-start">
<div className="flex w-full flex-col gap-y-2">
<PromotionGeneralSection promotion={promotion} />
<PromotionConditionsSection rules={rules || []} ruleType={"rules"} />
<PromotionConditionsSection
rules={targetRules || []}
ruleType={"target-rules"}
/>
{promotion.type === "buyget" && (
<PromotionConditionsSection
rules={buyRules || []}
ruleType={"buy-rules"}
/>
)}
<div className="flex w-full flex-col gap-y-2 xl:hidden">
<CampaignSection campaign={promotion.campaign!} />
</div>
{after.widgets.map((w, i) => {
return (
<div key={i}>
<w.Component />
</div>
)
})}
<JsonViewSection data={promotion as any} />
</div>
<div className="hidden w-full max-w-[400px] flex-col gap-y-2 xl:flex">
<CampaignSection campaign={promotion.campaign!} />
</div>
</div>
<Outlet />
</div>
)
}
@@ -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<zod.infer<typeof EditPromotionSchema>>({
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 (
<RouteDrawer.Form form={form}>
<form onSubmit={handleSubmit} className="flex h-full flex-col">
<RouteDrawer.Body>
<div className="flex h-full flex-col gap-y-8">
<Form.Field
control={form.control}
name="is_automatic"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>Method</Form.Label>
<Form.Control>
<RadioGroup
className="flex-col gap-y-3"
{...field}
value={field.value}
onValueChange={field.onChange}
>
<RadioGroup.ChoiceBox
value={"false"}
label={t("promotions.form.method.code.title")}
description={t(
"promotions.form.method.code.description"
)}
/>
<RadioGroup.ChoiceBox
value={"true"}
label={t("promotions.form.method.automatic.title")}
description={t(
"promotions.form.method.automatic.description"
)}
/>
</RadioGroup>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("promotions.form.code.title")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
<Trans
t={t}
i18nKey="promotions.form.code.description"
components={[<br key="break" />]}
/>
</Text>
</div>
<Form.Field
control={form.control}
name="value_type"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("promotions.fields.value_type")}</Form.Label>
<Form.Control>
<RadioGroup
className="flex-col gap-y-3"
{...field}
onValueChange={field.onChange}
>
<RadioGroup.ChoiceBox
value={"fixed"}
label={t("promotions.form.value_type.fixed.title")}
description={t(
"promotions.form.value_type.fixed.description"
)}
/>
<RadioGroup.ChoiceBox
value={"percentage"}
label={t(
"promotions.form.value_type.percentage.title"
)}
description={t(
"promotions.form.value_type.percentage.description"
)}
/>
</RadioGroup>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="value"
render={({ field: { onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label>
{isFixedValueType
? t("fields.amount")
: t("fields.percentage")}
</Form.Label>
<Form.Control>
{isFixedValueType ? (
<CurrencyInput
min={0}
onValueChange={onChange}
code={"USD"}
symbol={getCurrencySymbol("USD")}
{...field}
value={Number(field.value)}
/>
) : (
<PercentageInput
key="amount"
min={0}
max={100}
{...field}
value={field.value || ""}
onChange={(e) => {
onChange(
e.target.value === "" ? null : e.target.value
)
}}
/>
)}
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="allocation"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("promotions.fields.allocation")}</Form.Label>
<Form.Control>
<RadioGroup
className="flex-col gap-y-3"
{...field}
onValueChange={field.onChange}
>
<RadioGroup.ChoiceBox
value={"each"}
label={t("promotions.form.allocation.each.title")}
description={t(
"promotions.form.allocation.each.description"
)}
/>
<RadioGroup.ChoiceBox
value={"across"}
label={t("promotions.form.allocation.across.title")}
description={t(
"promotions.form.allocation.across.description"
)}
/>
</RadioGroup>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button size="small" type="submit" isLoading={isPending}>
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</form>
</RouteDrawer.Form>
)
}
@@ -0,0 +1 @@
export * from "./edit-promotion-details-form"
@@ -0,0 +1 @@
export { PromotionEditDetails as Component } from "./promotion-edit-details"
@@ -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 (
<RouteDrawer>
<RouteDrawer.Header>
<Heading>{t("promotions.edit.title")}</Heading>
</RouteDrawer.Header>
{!isLoading && promotion && (
<EditPromotionDetailsForm promotion={promotion} />
)}
</RouteDrawer>
)
}
@@ -1,15 +1,18 @@
import { PencilSquare } from "@medusajs/icons" import { PencilSquare, Trash } from "@medusajs/icons"
import { PromotionDTO } from "@medusajs/types" 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 { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react" import { useMemo } from "react"
import { useTranslation } from "react-i18next" 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 { keepPreviousData } from "@tanstack/react-query"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import { DataTable } from "../../../../../components/table/data-table" 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 { usePromotionTableColumns } from "../../../../../hooks/table/columns-v2/use-promotion-table-columns"
import { usePromotionTableFilters } from "../../../../../hooks/table/filters-v2/use-promotion-table-filters" import { usePromotionTableFilters } from "../../../../../hooks/table/filters-v2/use-promotion-table-filters"
import { usePromotionTableQuery } from "../../../../../hooks/table/query-v2/use-promotion-table-query" import { usePromotionTableQuery } from "../../../../../hooks/table/query-v2/use-promotion-table-query"
@@ -79,6 +82,36 @@ export const PromotionListTable = () => {
const PromotionActions = ({ promotion }: { promotion: PromotionDTO }) => { const PromotionActions = ({ promotion }: { promotion: PromotionDTO }) => {
const { t } = useTranslation() 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 ( return (
<ActionMenu <ActionMenu
@@ -90,6 +123,11 @@ const PromotionActions = ({ promotion }: { promotion: PromotionDTO }) => {
label: t("actions.edit"), label: t("actions.edit"),
to: `/promotions/${promotion.id}/edit`, to: `/promotions/${promotion.id}/edit`,
}, },
{
icon: <Trash />,
label: t("actions.delete"),
onClick: handleDelete,
},
], ],
}, },
]} ]}
@@ -4,7 +4,7 @@ import {
} from "@medusajs/medusa" } from "@medusajs/medusa"
import { Response } from "@medusajs/medusa-js" import { Response } from "@medusajs/medusa-js"
import { QueryClient } from "@tanstack/react-query" import { QueryClient } from "@tanstack/react-query"
import { adminPromotionKeys } from "../../../lib/api-v2" import { promotionsQueryKeys } from "../../../hooks/api/promotions"
import { medusa, queryClient } from "../../../lib/medusa" import { medusa, queryClient } from "../../../lib/medusa"
const params = { const params = {
@@ -13,7 +13,7 @@ const params = {
} }
const promotionsListQuery = () => ({ const promotionsListQuery = () => ({
queryKey: adminPromotionKeys.list(params), queryKey: promotionsQueryKeys.list(params),
queryFn: async () => queryFn: async () =>
medusa.admin.custom.get<AdminGetPromotionsParams, AdminPromotionsListRes>( medusa.admin.custom.get<AdminGetPromotionsParams, AdminPromotionsListRes>(
"/promotions", "/promotions",
@@ -5,25 +5,25 @@ import { UseQueryOptionsWrapper } from "../../../types"
/** /**
* This hook sends a `GET` request to a custom API Route. * This hook sends a `GET` request to a custom API Route.
* *
* @typeParam TQuery - The type of accepted query parameters which defaults to `Record<string, any>`. * @typeParam TQuery - The type of accepted query parameters which defaults to `Record<string, any>`.
* @typeParam TResponse - The type of response which defaults to `any`. * @typeParam TResponse - The type of response which defaults to `any`.
* @typeParamDefinition TQuery - The query parameters based on the type specified for `TQuery`. * @typeParamDefinition TQuery - The query parameters based on the type specified for `TQuery`.
* @typeParamDefinition TResponse - The response based on the type specified for `TResponse`. * @typeParamDefinition TResponse - The response based on the type specified for `TResponse`.
* *
* @example * @example
* import React from "react" * import React from "react"
* import { useAdminCustomQuery } from "medusa-react" * import { useAdminCustomQuery } from "medusa-react"
* import Post from "./models/Post" * import Post from "./models/Post"
* *
* type RequestQuery = { * type RequestQuery = {
* title: string * title: string
* } * }
* *
* type ResponseData = { * type ResponseData = {
* posts: Post * posts: Post
* } * }
* *
* const Custom = () => { * const Custom = () => {
* const { data, isLoading } = useAdminCustomQuery * const { data, isLoading } = useAdminCustomQuery
* <RequestQuery, ResponseData>( * <RequestQuery, ResponseData>(
@@ -33,7 +33,7 @@ import { UseQueryOptionsWrapper } from "../../../types"
* title: "My post" * title: "My post"
* } * }
* ) * )
* *
* return ( * return (
* <div> * <div>
* {isLoading && <span>Loading...</span>} * {isLoading && <span>Loading...</span>}
@@ -50,9 +50,9 @@ import { UseQueryOptionsWrapper } from "../../../types"
* </div> * </div>
* ) * )
* } * }
* *
* export default Custom * export default Custom
* *
* @customNamespace Hooks.Admin.Custom * @customNamespace Hooks.Admin.Custom
* @category Mutations * @category Mutations
*/ */
@@ -0,0 +1,2 @@
export * from "./types"
export * from "./validators"
@@ -0,0 +1,9 @@
import { CampaignDTO, PaginatedResponse } from "@medusajs/types"
export type AdminCampaignsListRes = PaginatedResponse<{
campaigns: CampaignDTO[]
}>
export type AdminCampaignRes = {
campaign: CampaignDTO
}
@@ -1 +1,2 @@
export * from "./campaigns"
export * from "./promotions" export * from "./promotions"
@@ -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<string, string>(
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,
})
}
@@ -45,6 +45,16 @@ export const adminPromotionRoutesMiddlewares: MiddlewareRoute[] = [
), ),
], ],
}, },
{
method: ["GET"],
matcher: "/admin/promotions/:id/:rule_type",
middlewares: [
transformQuery(
AdminGetPromotionsPromotionParams,
QueryConfig.retrieveNewTransformQueryConfig
),
],
},
{ {
method: ["POST"], method: ["POST"],
matcher: "/admin/promotions/:id", matcher: "/admin/promotions/:id",
@@ -21,25 +21,35 @@ export const defaultAdminPromotionFields = [
"deleted_at", "deleted_at",
"campaign.id", "campaign.id",
"campaign.name", "campaign.name",
"campaign.campaign_identifier",
"campaign.starts_at",
"campaign.ends_at",
"application_method.value", "application_method.value",
"application_method.type", "application_method.type",
"application_method.max_quantity", "application_method.max_quantity",
"application_method.apply_to_quantity",
"application_method.buy_rules_min_quantity",
"application_method.target_type", "application_method.target_type",
"application_method.allocation", "application_method.allocation",
"application_method.created_at", "application_method.created_at",
"application_method.updated_at", "application_method.updated_at",
"application_method.deleted_at", "application_method.deleted_at",
"application_method.buy_rules.id",
"application_method.buy_rules.attribute", "application_method.buy_rules.attribute",
"application_method.buy_rules.operator", "application_method.buy_rules.operator",
"application_method.buy_rules.values.value", "application_method.buy_rules.values.value",
"application_method.target_rules.id",
"application_method.target_rules.attribute", "application_method.target_rules.attribute",
"application_method.target_rules.operator", "application_method.target_rules.operator",
"application_method.target_rules.values.value", "application_method.target_rules.values.value",
"rules.id",
"rules.attribute", "rules.attribute",
"rules.operator", "rules.operator",
"rules.values.value", "rules.values.value",
] ]
const defaults = [...defaultAdminPromotionFields]
export const retrieveTransformQueryConfig = { export const retrieveTransformQueryConfig = {
defaultFields: defaultAdminPromotionFields, defaultFields: defaultAdminPromotionFields,
defaultRelations: defaultAdminPromotionRelations, defaultRelations: defaultAdminPromotionRelations,
@@ -57,3 +67,9 @@ export const listRuleValueTransformQueryConfig = {
allowed: [], allowed: [],
isList: true, isList: true,
} }
// TODO: replace this with the old one
export const retrieveNewTransformQueryConfig = {
defaults,
isList: false,
}
@@ -1,32 +1,14 @@
import { RuleOperator } from "@medusajs/utils"
import { import {
AuthenticatedMedusaRequest, AuthenticatedMedusaRequest,
MedusaResponse, MedusaResponse,
} from "../../../../types/routing" } from "../../../../types/routing"
import { operatorsMap } from "../utils"
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",
},
]
export const GET = async ( export const GET = async (
req: AuthenticatedMedusaRequest, req: AuthenticatedMedusaRequest,
res: MedusaResponse res: MedusaResponse
) => { ) => {
res.json({ res.json({
operators, operators: Object.values(operatorsMap),
}) })
} }
@@ -6,67 +6,18 @@ import {
AuthenticatedMedusaRequest, AuthenticatedMedusaRequest,
MedusaResponse, MedusaResponse,
} from "../../../../../../types/routing" } from "../../../../../../types/routing"
import { validateRuleAttribute, validateRuleType } from "../../../utils" import {
ruleQueryConfigurations,
const queryConfigurations = { validateRuleAttribute,
region: { validateRuleType,
entryPoint: "region", } from "../../../utils"
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",
},
}
export const GET = async ( export const GET = async (
req: AuthenticatedMedusaRequest, req: AuthenticatedMedusaRequest,
res: MedusaResponse res: MedusaResponse
) => { ) => {
const { rule_type: ruleType, rule_attribute_id: ruleAttributeId } = req.params 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) const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
validateRuleType(ruleType) validateRuleType(ruleType)
@@ -3,3 +3,7 @@ import { PaginatedResponse, PromotionDTO } from "@medusajs/types"
export type AdminPromotionsListRes = PaginatedResponse<{ export type AdminPromotionsListRes = PaginatedResponse<{
promotions: PromotionDTO[] promotions: PromotionDTO[]
}> }>
export type AdminPromotionRes = {
promotion: PromotionDTO
}
@@ -1,3 +1,5 @@
export * from "./operators-map"
export * from "./rule-attributes-map" export * from "./rule-attributes-map"
export * from "./rule-query-configuration"
export * from "./validate-rule-attribute" export * from "./validate-rule-attribute"
export * from "./validate-rule-type" export * from "./validate-rule-type"
@@ -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",
},
}
@@ -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 = [ const ruleAttributes = [
{ {
id: "currency", id: "currency",
@@ -66,20 +80,24 @@ const commonAttributes = [
const buyRuleAttributes = [ const buyRuleAttributes = [
{ {
id: "buy_rules_min_quantity", id: DisguisedRule.BUY_RULES_MIN_QUANTITY,
value: "buy_rules_min_quantity", value: DisguisedRule.BUY_RULES_MIN_QUANTITY,
label: "Minimum quantity of items", label: "Minimum quantity of items",
field_type: "number",
required: true, required: true,
disguised: true,
}, },
...commonAttributes, ...commonAttributes,
] ]
const targetRuleAttributes = [ const targetRuleAttributes = [
{ {
id: "apply_to_quantity", id: DisguisedRule.APPLY_TO_QUANTITY,
value: "apply_to_quantity", value: DisguisedRule.APPLY_TO_QUANTITY,
label: "Quantity of items promotion will apply to", label: "Quantity of items promotion will apply to",
field_type: "number",
required: true, required: true,
disguised: true,
}, },
...commonAttributes, ...commonAttributes,
] ]
@@ -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",
},
}
@@ -29,6 +29,7 @@ import { XorConstraint } from "../../../types/validators/xor"
import { AdminPostCampaignsReq } from "../campaigns/validators" import { AdminPostCampaignsReq } from "../campaigns/validators"
export class AdminGetPromotionsPromotionParams extends FindParams {} export class AdminGetPromotionsPromotionParams extends FindParams {}
export class AdminGetPromotionRules extends FindParams {}
export class AdminGetPromotionsRuleValueParams extends extendedFindParamsMixin({ export class AdminGetPromotionsRuleValueParams extends extendedFindParamsMixin({
limit: 100, limit: 100,
+10
View File
@@ -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
}[]
+1
View File
@@ -1,4 +1,5 @@
export * from "./common" export * from "./common"
export * from "./http"
export * from "./mutations" export * from "./mutations"
export * from "./service" export * from "./service"
export * from "./workflows" export * from "./workflows"