feat(dashboard): add promotions to campaign UI (#7283)
* feat(core-flows,types,medusa): API to add promotions to campaign * chore: consolidate specs * chore: split workflows step into 2 * chore: fix tests * chore: fix specs * chore: add promotions to campaign UI * chore: fix bug wrt to not refreshing * chore: address review comments
This commit is contained in:
@@ -1086,6 +1086,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"promotions": {
|
||||
"remove": {
|
||||
"title": "Remove promotion from campaign",
|
||||
"description": "You are about to remove {{count}} promotion(s) from the campaign. This action cannot be undone."
|
||||
},
|
||||
"alreadyAdded": "This promotion has already been added to the campaign.",
|
||||
"toast": {
|
||||
"success": "Successfully added {{count}} promotion(s) to campaign"
|
||||
}
|
||||
},
|
||||
"deleteCampaignWarning": "You are about to delete the campaign {{name}}. This action cannot be undone.",
|
||||
"totalSpend": "<0>{{amount}}</0> <1>{{currency}}</1>"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
AdminCampaignListResponse,
|
||||
AdminCampaignResponse,
|
||||
LinkMethodRequest,
|
||||
} from "@medusajs/types"
|
||||
import {
|
||||
QueryKey,
|
||||
@@ -14,6 +15,7 @@ import { queryClient } from "../../lib/medusa"
|
||||
import { queryKeysFactory } from "../../lib/query-key-factory"
|
||||
import { CreateCampaignReq, UpdateCampaignReq } from "../../types/api-payloads"
|
||||
import { CampaignDeleteRes } from "../../types/api-responses"
|
||||
import { promotionsQueryKeys } from "./promotions"
|
||||
|
||||
const REGIONS_QUERY_KEY = "campaigns" as const
|
||||
export const campaignsQueryKeys = queryKeysFactory(REGIONS_QUERY_KEY)
|
||||
@@ -82,7 +84,7 @@ export const useUpdateCampaign = (
|
||||
mutationFn: (payload) => client.campaigns.update(id, payload),
|
||||
onSuccess: (data, variables, context) => {
|
||||
queryClient.invalidateQueries({ queryKey: campaignsQueryKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: campaignsQueryKeys.detail(id) })
|
||||
queryClient.invalidateQueries({ queryKey: campaignsQueryKeys.details() })
|
||||
|
||||
options?.onSuccess?.(data, variables, context)
|
||||
},
|
||||
@@ -98,10 +100,26 @@ export const useDeleteCampaign = (
|
||||
mutationFn: () => client.campaigns.delete(id),
|
||||
onSuccess: (data, variables, context) => {
|
||||
queryClient.invalidateQueries({ queryKey: campaignsQueryKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: campaignsQueryKeys.detail(id) })
|
||||
queryClient.invalidateQueries({ queryKey: campaignsQueryKeys.details() })
|
||||
|
||||
options?.onSuccess?.(data, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export const useAddOrRemoveCampaignPromotions = (
|
||||
id: string,
|
||||
options?: UseMutationOptions<AdminCampaignResponse, Error, LinkMethodRequest>
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: (payload) =>
|
||||
client.campaigns.addOrRemovePromotions(id, payload),
|
||||
onSuccess: (data, variables, context) => {
|
||||
queryClient.invalidateQueries({ queryKey: campaignsQueryKeys.details() })
|
||||
queryClient.invalidateQueries({ queryKey: promotionsQueryKeys.lists() })
|
||||
options?.onSuccess?.(data, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
AdminCampaignListResponse,
|
||||
AdminCampaignResponse,
|
||||
CreateCampaignDTO,
|
||||
LinkMethodRequest,
|
||||
UpdateCampaignDTO,
|
||||
} from "@medusajs/types"
|
||||
import { CampaignDeleteRes } from "../../types/api-responses"
|
||||
@@ -27,10 +28,21 @@ async function deleteCampaign(id: string) {
|
||||
return deleteRequest<CampaignDeleteRes>(`/admin/campaigns/${id}`)
|
||||
}
|
||||
|
||||
async function addOrRemoveCampaignPromotions(
|
||||
id: string,
|
||||
payload: LinkMethodRequest
|
||||
) {
|
||||
return postRequest<AdminCampaignResponse>(
|
||||
`/admin/campaigns/${id}/promotions`,
|
||||
payload
|
||||
)
|
||||
}
|
||||
|
||||
export const campaigns = {
|
||||
retrieve: retrieveCampaign,
|
||||
list: listCampaigns,
|
||||
create: createCampaign,
|
||||
update: updateCampaign,
|
||||
delete: deleteCampaign,
|
||||
addOrRemovePromotions: addOrRemoveCampaignPromotions,
|
||||
}
|
||||
|
||||
@@ -237,6 +237,13 @@ export const RouteMap: RouteObject[] = [
|
||||
lazy: () =>
|
||||
import("../../v2-routes/campaigns/campaign-budget-edit"),
|
||||
},
|
||||
{
|
||||
path: "add-promotions",
|
||||
lazy: () =>
|
||||
import(
|
||||
"../../v2-routes/campaigns/add-campaign-promotions"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useParams } from "react-router-dom"
|
||||
import { RouteFocusModal } from "../../../components/route-modal"
|
||||
import { useCampaign } from "../../../hooks/api/campaigns"
|
||||
import { AddCampaignPromotionsForm } from "./components"
|
||||
|
||||
export const AddCampaignPromotions = () => {
|
||||
const { id } = useParams()
|
||||
const { campaign, isError, error } = useCampaign(id!)
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteFocusModal>
|
||||
{campaign && <AddCampaignPromotionsForm campaign={campaign} />}
|
||||
</RouteFocusModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { CampaignResponse, PromotionDTO } from "@medusajs/types"
|
||||
import { Button, Checkbox, Hint, toast, Tooltip } from "@medusajs/ui"
|
||||
import { keepPreviousData } from "@tanstack/react-query"
|
||||
import {
|
||||
createColumnHelper,
|
||||
OnChangeFn,
|
||||
RowSelectionState,
|
||||
} from "@tanstack/react-table"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
import {
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
} from "../../../../components/route-modal"
|
||||
import { DataTable } from "../../../../components/table/data-table"
|
||||
import { useAddOrRemoveCampaignPromotions } from "../../../../hooks/api/campaigns"
|
||||
import { usePromotions } from "../../../../hooks/api/promotions"
|
||||
import { usePromotionTableColumns } from "../../../../hooks/table/columns-v2/use-promotion-table-columns"
|
||||
import { usePromotionTableFilters } from "../../../../hooks/table/filters/use-promotion-table-filters"
|
||||
import { usePromotionTableQuery } from "../../../../hooks/table/query-v2/use-promotion-table-query"
|
||||
import { useDataTable } from "../../../../hooks/use-data-table"
|
||||
|
||||
type AddCampaignPromotionsFormProps = {
|
||||
campaign: CampaignResponse
|
||||
}
|
||||
|
||||
const AddCampaignPromotionsSchema = zod.object({
|
||||
promotion_ids: zod.array(zod.string()).min(1),
|
||||
})
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export const AddCampaignPromotionsForm = ({
|
||||
campaign,
|
||||
}: AddCampaignPromotionsFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
|
||||
const form = useForm<zod.infer<typeof AddCampaignPromotionsSchema>>({
|
||||
defaultValues: { promotion_ids: [] },
|
||||
resolver: zodResolver(AddCampaignPromotionsSchema),
|
||||
})
|
||||
|
||||
const { setValue } = form
|
||||
const { mutateAsync, isPending } = useAddOrRemoveCampaignPromotions(
|
||||
campaign.id
|
||||
)
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
|
||||
const updater: OnChangeFn<RowSelectionState> = (fn) => {
|
||||
const state = typeof fn === "function" ? fn(rowSelection) : fn
|
||||
const ids = Object.keys(state)
|
||||
|
||||
setValue("promotion_ids", ids, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
})
|
||||
|
||||
setRowSelection(state)
|
||||
}
|
||||
|
||||
const { searchParams, raw } = usePromotionTableQuery({ pageSize: PAGE_SIZE })
|
||||
const {
|
||||
promotions,
|
||||
count,
|
||||
isPending: isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = usePromotions(
|
||||
{ ...searchParams, campaign_id: "null" },
|
||||
{ placeholderData: keepPreviousData }
|
||||
)
|
||||
|
||||
const columns = useColumns()
|
||||
const filters = usePromotionTableFilters()
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: promotions ?? [],
|
||||
columns,
|
||||
enableRowSelection: (row) => {
|
||||
return row.original.campaign_id !== campaign.id
|
||||
},
|
||||
enablePagination: true,
|
||||
getRowId: (row) => row.id,
|
||||
pageSize: PAGE_SIZE,
|
||||
count,
|
||||
rowSelection: {
|
||||
state: rowSelection,
|
||||
updater,
|
||||
},
|
||||
meta: { campaignId: campaign.id },
|
||||
})
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
await mutateAsync(
|
||||
{ add: values.promotion_ids },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t("general.success"), {
|
||||
description: t("campaigns.promotions.toast.success", {
|
||||
count: values.promotion_ids.length,
|
||||
}),
|
||||
dismissLabel: t("actions.close"),
|
||||
})
|
||||
handleSuccess()
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(t("general.error"), {
|
||||
description: error.message,
|
||||
dismissLabel: t("actions.close"),
|
||||
}),
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<RouteFocusModal.Form form={form}>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex h-full flex-col overflow-hidden"
|
||||
>
|
||||
<RouteFocusModal.Header>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
{form.formState.errors.promotion_ids && (
|
||||
<Hint variant="error">
|
||||
{form.formState.errors.promotion_ids.message}
|
||||
</Hint>
|
||||
)}
|
||||
<RouteFocusModal.Close asChild>
|
||||
<Button size="small" variant="secondary">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteFocusModal.Close>
|
||||
<Button size="small" type="submit" isLoading={isPending}>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</RouteFocusModal.Header>
|
||||
<RouteFocusModal.Body className="flex size-full flex-col overflow-y-auto">
|
||||
<DataTable
|
||||
table={table}
|
||||
count={count}
|
||||
columns={columns}
|
||||
pageSize={PAGE_SIZE}
|
||||
isLoading={isLoading}
|
||||
filters={filters}
|
||||
orderBy={["title", "status", "created_at", "updated_at"]}
|
||||
queryObject={raw}
|
||||
layout="fill"
|
||||
pagination
|
||||
search
|
||||
/>
|
||||
</RouteFocusModal.Body>
|
||||
</form>
|
||||
</RouteFocusModal.Form>
|
||||
)
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<PromotionDTO>()
|
||||
|
||||
const useColumns = () => {
|
||||
const base = usePromotionTableColumns()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
columnHelper.display({
|
||||
id: "select",
|
||||
header: ({ table }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsSomePageRowsSelected()
|
||||
? "indeterminate"
|
||||
: table.getIsAllPageRowsSelected()
|
||||
}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
cell: ({ row, table }) => {
|
||||
const { campaignId } = table.options.meta as {
|
||||
campaignId: string
|
||||
}
|
||||
|
||||
const isAdded = row.original.campaign_id === campaignId
|
||||
const isSelected = row.getIsSelected() || isAdded
|
||||
|
||||
const Component = (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={isAdded}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
if (isAdded) {
|
||||
return (
|
||||
<Tooltip
|
||||
content={t("campaigns.promotions.alreadyAdded")}
|
||||
side="right"
|
||||
>
|
||||
{Component}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return Component
|
||||
},
|
||||
}),
|
||||
...base,
|
||||
],
|
||||
[t, base]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./add-campaign-promotions-form"
|
||||
@@ -0,0 +1 @@
|
||||
export { AddCampaignPromotions as Component } from "./add-campaign-promotions"
|
||||
@@ -3,6 +3,7 @@ import { JsonViewSection } from "../../../components/common/json-view-section"
|
||||
import { useCampaign } from "../../../hooks/api/campaigns"
|
||||
import { CampaignBudget } from "./components/campaign-budget"
|
||||
import { CampaignGeneralSection } from "./components/campaign-general-section"
|
||||
import { CampaignPromotionSection } from "./components/campaign-promotion-section"
|
||||
import { CampaignSpend } from "./components/campaign-spend"
|
||||
import { campaignLoader } from "./loader"
|
||||
|
||||
@@ -31,8 +32,7 @@ export const CampaignDetail = () => {
|
||||
<div className="flex flex-col gap-x-4 xl:flex-row xl:items-start">
|
||||
<div className="flex w-full flex-col gap-y-2">
|
||||
<CampaignGeneralSection campaign={campaign} />
|
||||
{/* TODO: enable this when missing APIs are available */}
|
||||
{/* <CampaignPromotionSection campaign={campaign} /> */}
|
||||
<CampaignPromotionSection campaign={campaign} />
|
||||
<JsonViewSection data={campaign} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Link } from "react-router-dom"
|
||||
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { DataTable } from "../../../../../components/table/data-table"
|
||||
import { useRemovePromotionsFromCampaign } from "../../../../../hooks/api/campaigns"
|
||||
import { useAddOrRemoveCampaignPromotions } from "../../../../../hooks/api/campaigns"
|
||||
import { usePromotions } from "../../../../../hooks/api/promotions"
|
||||
import { usePromotionTableColumns } from "../../../../../hooks/table/columns-v2/use-promotion-table-columns"
|
||||
import { usePromotionTableFilters } from "../../../../../hooks/table/filters/use-promotion-table-filters"
|
||||
@@ -27,16 +27,14 @@ export const CampaignPromotionSection = ({
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const { t } = useTranslation()
|
||||
const prompt = usePrompt()
|
||||
|
||||
const columns = useColumns()
|
||||
const filters = usePromotionTableFilters()
|
||||
const { searchParams, raw } = usePromotionTableQuery({ pageSize: PAGE_SIZE })
|
||||
const { promotions, count, isLoading, isError, error } = usePromotions({
|
||||
...searchParams,
|
||||
// TODO: This should be scoped by currency_code
|
||||
campaign_id: campaign.id,
|
||||
})
|
||||
|
||||
const columns = useColumns()
|
||||
const filters = usePromotionTableFilters()
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: promotions ?? [],
|
||||
columns,
|
||||
@@ -49,16 +47,14 @@ export const CampaignPromotionSection = ({
|
||||
state: rowSelection,
|
||||
updater: setRowSelection,
|
||||
},
|
||||
meta: {
|
||||
campaignId: campaign.id,
|
||||
},
|
||||
meta: { campaignId: campaign.id },
|
||||
})
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const { mutateAsync } = useRemovePromotionsFromCampaign(campaign.id)
|
||||
const { mutateAsync } = useAddOrRemoveCampaignPromotions(campaign.id)
|
||||
|
||||
const handleRemove = async () => {
|
||||
const keys = Object.keys(rowSelection)
|
||||
@@ -77,14 +73,8 @@ export const CampaignPromotionSection = ({
|
||||
}
|
||||
|
||||
await mutateAsync(
|
||||
{
|
||||
remove: keys,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setRowSelection({})
|
||||
},
|
||||
}
|
||||
{ remove: keys },
|
||||
{ onSuccess: () => setRowSelection({}) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -138,7 +128,7 @@ const PromotionActions = ({
|
||||
campaignId: string
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { mutateAsync } = useRemovePromotionsFromCampaign(campaignId)
|
||||
const { mutateAsync } = useAddOrRemoveCampaignPromotions(campaignId)
|
||||
|
||||
const prompt = usePrompt()
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
|
||||
import { IPromotionModuleService, LinkWorkflowInput } from "@medusajs/types"
|
||||
import { StepResponse, WorkflowData, createStep } from "@medusajs/workflows-sdk"
|
||||
|
||||
export const addOrRemoveCampaignPromotionsStepId =
|
||||
"add-or-remove-campaign-promotions"
|
||||
export const addOrRemoveCampaignPromotionsStep = createStep(
|
||||
addOrRemoveCampaignPromotionsStepId,
|
||||
async (input: WorkflowData<LinkWorkflowInput>, { container }) => {
|
||||
const {
|
||||
id: campaignId,
|
||||
add: promotionIdsToAdd = [],
|
||||
remove: promotionIdsToRemove = [],
|
||||
} = input
|
||||
const promotionModule = container.resolve<IPromotionModuleService>(
|
||||
ModuleRegistrationName.PROMOTION
|
||||
)
|
||||
|
||||
if (promotionIdsToAdd.length) {
|
||||
await promotionModule.addPromotionsToCampaign({
|
||||
id: campaignId,
|
||||
promotion_ids: promotionIdsToAdd,
|
||||
})
|
||||
}
|
||||
|
||||
if (promotionIdsToRemove.length) {
|
||||
await promotionModule.removePromotionsFromCampaign({
|
||||
id: campaignId,
|
||||
promotion_ids: promotionIdsToRemove,
|
||||
})
|
||||
}
|
||||
|
||||
return new StepResponse(null, input)
|
||||
},
|
||||
async (data, { container }) => {
|
||||
if (!data) {
|
||||
return
|
||||
}
|
||||
|
||||
const {
|
||||
id: campaignId,
|
||||
add: promotionIdsToRemove = [],
|
||||
remove: promotionIdsToAdd = [],
|
||||
} = data
|
||||
|
||||
const promotionModule = container.resolve<IPromotionModuleService>(
|
||||
ModuleRegistrationName.PROMOTION
|
||||
)
|
||||
|
||||
if (promotionIdsToAdd.length) {
|
||||
promotionModule.addPromotionsToCampaign({
|
||||
id: campaignId,
|
||||
promotion_ids: promotionIdsToAdd,
|
||||
})
|
||||
}
|
||||
|
||||
if (promotionIdsToRemove.length) {
|
||||
promotionModule.removePromotionsFromCampaign({
|
||||
id: campaignId,
|
||||
promotion_ids: promotionIdsToRemove,
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -51,6 +51,11 @@ export interface PromotionDTO {
|
||||
*/
|
||||
rules?: PromotionRuleDTO[]
|
||||
|
||||
/**
|
||||
* The associated campaign.
|
||||
*/
|
||||
campaign_id?: string | null
|
||||
|
||||
/**
|
||||
* The associated campaign.
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from "../../../../../types/routing"
|
||||
|
||||
import { addOrRemoveCampaignPromotionsWorkflow } from "@medusajs/core-flows"
|
||||
import { LinkMethodRequest } from "@medusajs/types/src"
|
||||
import { LinkMethodRequest } from "@medusajs/types"
|
||||
import { refetchCampaign } from "../../helpers"
|
||||
|
||||
export const POST = async (
|
||||
|
||||
Reference in New Issue
Block a user