feat(dashboard,core,medusa,promotion): add campaigns UI (#7269)
* feat(dashboard,core,medusa,promotion): add campaigns UI * chore: add without campaign choice to promotion ui * chore: fix builds and types * chore: fix design issues * chore: address pr reviews
This commit is contained in:
7
.changeset/healthy-shirts-lick.md
Normal file
7
.changeset/healthy-shirts-lick.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@medusajs/promotion": patch
|
||||
"@medusajs/types": patch
|
||||
"@medusajs/medusa": patch
|
||||
---
|
||||
|
||||
feat(dashboard,core,medusa,promotion): add campaigns UI
|
||||
@@ -711,7 +711,7 @@
|
||||
"description": "A service zone is a geographical region that can be shipped to from a specific location. You can later on add any number of shipping options to this zone. ",
|
||||
"zoneName": "Zone name"
|
||||
},
|
||||
"edit":{
|
||||
"edit": {
|
||||
"title": "Edit Service Zone"
|
||||
},
|
||||
"deleteWarning": "Are you sure you want to delete \"{{name}}\". This will also delete all assocciated shipping options.",
|
||||
@@ -761,7 +761,7 @@
|
||||
"provider": "Fulfillment provider"
|
||||
}
|
||||
},
|
||||
"returnOptions" : {
|
||||
"returnOptions": {
|
||||
"create": {
|
||||
"title": "Create a return option for {{zone}}"
|
||||
}
|
||||
@@ -968,6 +968,10 @@
|
||||
"new": {
|
||||
"title": "New Campaign",
|
||||
"description": "Would you like to create a new campaign with this promotion?"
|
||||
},
|
||||
"none": {
|
||||
"title": "Without Campaign",
|
||||
"description": "Proceed without associating promotion with campaign"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
@@ -1032,21 +1036,58 @@
|
||||
"campaigns": {
|
||||
"domain": "Campaigns",
|
||||
"details": "Campaign details",
|
||||
"status": {
|
||||
"active": "active",
|
||||
"expired": "expired",
|
||||
"scheduled": "scheduled"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Are you sure?",
|
||||
"description": "You are about to delete the campaign '{{name}}'. This action cannot be undone.",
|
||||
"successToast": "Campaign '{{name}}' was successfully created."
|
||||
},
|
||||
"edit": {
|
||||
"header": "Edit Campaign",
|
||||
"successToast": "Campaign '{{name}}' was successfully updated."
|
||||
},
|
||||
"create": {
|
||||
"hint": "Create a promotional campaign",
|
||||
"header": "Create Campaign",
|
||||
"successToast": "Campaign '{{name}}' was successfully created."
|
||||
},
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"identifier": "Identifier",
|
||||
"start_date": "Start date",
|
||||
"end_date": "End date"
|
||||
"end_date": "End date",
|
||||
"total_spend": "Total spend",
|
||||
"budget_limit": "Budget limit"
|
||||
},
|
||||
"budget": {
|
||||
"create": {
|
||||
"hint": "Create a budget for the campaign",
|
||||
"header": "Campaign Budget"
|
||||
},
|
||||
"details": "Campaign budget",
|
||||
"fields": {
|
||||
"type": "Type",
|
||||
"currency": "Currency",
|
||||
"limit": "Limit",
|
||||
"used": "Used"
|
||||
},
|
||||
"type": {
|
||||
"spend": {
|
||||
"title": "Spend",
|
||||
"description": "Limit usage based on a currency value"
|
||||
},
|
||||
"usage": {
|
||||
"title": "Usage",
|
||||
"description": "Limit usage based on how many times its used"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteCampaignWarning": "You are about to delete the campaign {{name}}. This action cannot be undone.",
|
||||
"totalSpend": "<0>{{amount}}</0> <1>{{currency}}</1>"
|
||||
},
|
||||
"pricing": {
|
||||
"domain": "Pricing",
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
Buildings,
|
||||
ChevronDownMini,
|
||||
CurrencyDollar,
|
||||
Envelope,
|
||||
MinusMini,
|
||||
ReceiptPercent,
|
||||
ShoppingCart,
|
||||
@@ -141,6 +140,12 @@ const useCoreRoutes = (): Omit<NavItemProps, "pathname">[] => {
|
||||
icon: <ReceiptPercent />,
|
||||
label: t("promotions.domain"),
|
||||
to: "/promotions",
|
||||
items: [
|
||||
{
|
||||
label: t("campaigns.domain"),
|
||||
to: "/campaigns",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <CurrencyDollar />,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
AdminCampaignListResponse,
|
||||
AdminCampaignResponse,
|
||||
} from "@medusajs/types"
|
||||
import {
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
@@ -9,25 +13,27 @@ 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"
|
||||
import { CampaignDeleteRes } from "../../types/api-responses"
|
||||
|
||||
const REGIONS_QUERY_KEY = "campaigns" as const
|
||||
const campaignsQueryKeys = queryKeysFactory(REGIONS_QUERY_KEY)
|
||||
export const campaignsQueryKeys = queryKeysFactory(REGIONS_QUERY_KEY)
|
||||
|
||||
export const useCampaign = (
|
||||
id: string,
|
||||
query?: Record<string, any>,
|
||||
options?: Omit<
|
||||
UseQueryOptions<CampaignRes, Error, CampaignRes, QueryKey>,
|
||||
UseQueryOptions<
|
||||
AdminCampaignResponse,
|
||||
Error,
|
||||
AdminCampaignResponse,
|
||||
QueryKey
|
||||
>,
|
||||
"queryFn" | "queryKey"
|
||||
>
|
||||
) => {
|
||||
const { data, ...rest } = useQuery({
|
||||
queryKey: campaignsQueryKeys.detail(id),
|
||||
queryFn: async () => client.campaigns.retrieve(id),
|
||||
queryFn: async () => client.campaigns.retrieve(id, query),
|
||||
...options,
|
||||
})
|
||||
|
||||
@@ -37,7 +43,12 @@ export const useCampaign = (
|
||||
export const useCampaigns = (
|
||||
query?: Record<string, any>,
|
||||
options?: Omit<
|
||||
UseQueryOptions<CampaignListRes, Error, CampaignListRes, QueryKey>,
|
||||
UseQueryOptions<
|
||||
AdminCampaignListResponse,
|
||||
Error,
|
||||
AdminCampaignListResponse,
|
||||
QueryKey
|
||||
>,
|
||||
"queryFn" | "queryKey"
|
||||
>
|
||||
) => {
|
||||
@@ -51,7 +62,7 @@ export const useCampaigns = (
|
||||
}
|
||||
|
||||
export const useCreateCampaign = (
|
||||
options?: UseMutationOptions<CampaignRes, Error, CreateCampaignReq>
|
||||
options?: UseMutationOptions<AdminCampaignResponse, Error, CreateCampaignReq>
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: (payload) => client.campaigns.create(payload),
|
||||
@@ -65,7 +76,7 @@ export const useCreateCampaign = (
|
||||
|
||||
export const useUpdateCampaign = (
|
||||
id: string,
|
||||
options?: UseMutationOptions<CampaignRes, Error, UpdateCampaignReq>
|
||||
options?: UseMutationOptions<AdminCampaignResponse, Error, UpdateCampaignReq>
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: (payload) => client.campaigns.update(id, payload),
|
||||
|
||||
@@ -26,12 +26,6 @@ export const usePromotionTableColumns = () => {
|
||||
cell: ({ row }) => <CodeCell code={row.original.code!} />,
|
||||
}),
|
||||
|
||||
columnHelper.display({
|
||||
id: "campaign",
|
||||
header: () => <TextHeader text={t("promotions.fields.campaign")} />,
|
||||
cell: ({ row }) => <TextCell text={row.original.campaign?.name} />,
|
||||
}),
|
||||
|
||||
columnHelper.display({
|
||||
id: "method",
|
||||
header: () => <TextHeader text={t("promotions.fields.method")} />,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createColumnHelper } from "@tanstack/react-table"
|
||||
|
||||
import { CampaignResponse } from "@medusajs/types"
|
||||
import { useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { DateCell } from "../../../components/table/table-cells/common/date-cell"
|
||||
import {
|
||||
TextCell,
|
||||
TextHeader,
|
||||
} from "../../../components/table/table-cells/common/text-cell"
|
||||
import {
|
||||
DescriptionCell,
|
||||
DescriptionHeader,
|
||||
} from "../../../components/table/table-cells/sales-channel/description-cell"
|
||||
import {
|
||||
NameCell,
|
||||
NameHeader,
|
||||
} from "../../../components/table/table-cells/sales-channel/name-cell"
|
||||
|
||||
const columnHelper = createColumnHelper<CampaignResponse>()
|
||||
|
||||
export const useCampaignTableColumns = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
columnHelper.accessor("name", {
|
||||
header: () => <NameHeader />,
|
||||
cell: ({ getValue }) => <NameCell name={getValue()} />,
|
||||
}),
|
||||
columnHelper.accessor("description", {
|
||||
header: () => <DescriptionHeader />,
|
||||
cell: ({ getValue }) => <DescriptionCell description={getValue()} />,
|
||||
}),
|
||||
columnHelper.accessor("campaign_identifier", {
|
||||
header: () => <TextHeader text={t("campaigns.fields.identifier")} />,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue()
|
||||
return <TextCell text={value} />
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("starts_at", {
|
||||
header: () => <TextHeader text={t("campaigns.fields.start_date")} />,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue()
|
||||
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
|
||||
return <DateCell date={date} />
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("ends_at", {
|
||||
header: () => <TextHeader text={t("campaigns.fields.end_date")} />,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue()
|
||||
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
|
||||
return <DateCell date={date} />
|
||||
},
|
||||
}),
|
||||
],
|
||||
[t]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Filter } from "../../../components/table/data-table"
|
||||
|
||||
export const usePromotionTableFilters = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
let filters: Filter[] = [
|
||||
{ label: t("fields.createdAt"), key: "created_at", type: "date" },
|
||||
{ label: t("fields.updatedAt"), key: "updated_at", type: "date" },
|
||||
]
|
||||
|
||||
return filters
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useQueryParams } from "../../use-query-params"
|
||||
|
||||
type UseCampaignTableQueryProps = {
|
||||
prefix?: string
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export const useCampaignTableQuery = ({
|
||||
prefix,
|
||||
pageSize = 20,
|
||||
}: UseCampaignTableQueryProps) => {
|
||||
const queryObject = useQueryParams(
|
||||
["offset", "q", "order", "created_at", "updated_at"],
|
||||
prefix
|
||||
)
|
||||
|
||||
const { offset, q, order, created_at, updated_at } = queryObject
|
||||
const searchParams = {
|
||||
limit: pageSize,
|
||||
offset: offset ? Number(offset) : 0,
|
||||
order,
|
||||
created_at: created_at ? JSON.parse(created_at) : undefined,
|
||||
updated_at: updated_at ? JSON.parse(updated_at) : undefined,
|
||||
q,
|
||||
}
|
||||
|
||||
return {
|
||||
searchParams,
|
||||
raw: queryObject,
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,26 @@
|
||||
import { CreateCampaignDTO, UpdateCampaignDTO } from "@medusajs/types"
|
||||
import {
|
||||
CampaignDeleteRes,
|
||||
CampaignListRes,
|
||||
CampaignRes,
|
||||
} from "../../types/api-responses"
|
||||
AdminCampaignListResponse,
|
||||
AdminCampaignResponse,
|
||||
CreateCampaignDTO,
|
||||
UpdateCampaignDTO,
|
||||
} from "@medusajs/types"
|
||||
import { CampaignDeleteRes } 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)
|
||||
return getRequest<AdminCampaignResponse>(`/admin/campaigns/${id}`, query)
|
||||
}
|
||||
|
||||
async function listCampaigns(query?: Record<string, any>) {
|
||||
return getRequest<CampaignListRes>(`/admin/campaigns`, query)
|
||||
return getRequest<AdminCampaignListResponse>(`/admin/campaigns`, query)
|
||||
}
|
||||
|
||||
async function createCampaign(payload: CreateCampaignDTO) {
|
||||
return postRequest<CampaignRes>(`/admin/campaigns`, payload)
|
||||
return postRequest<AdminCampaignResponse>(`/admin/campaigns`, payload)
|
||||
}
|
||||
|
||||
async function updateCampaign(id: string, payload: UpdateCampaignDTO) {
|
||||
return postRequest<CampaignRes>(`/admin/campaigns/${id}`, payload)
|
||||
return postRequest<AdminCampaignResponse>(`/admin/campaigns/${id}`, payload)
|
||||
}
|
||||
|
||||
async function deleteCampaign(id: string) {
|
||||
|
||||
@@ -209,6 +209,38 @@ export const RouteMap: RouteObject[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/campaigns",
|
||||
handle: { crumb: () => "Campaigns" },
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
lazy: () => import("../../v2-routes/campaigns/campaign-list"),
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
path: "create",
|
||||
lazy: () => import("../../v2-routes/campaigns/campaign-create"),
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
lazy: () => import("../../v2-routes/campaigns/campaign-detail"),
|
||||
handle: { crumb: (data: any) => data.campaign.name },
|
||||
children: [
|
||||
{
|
||||
path: "edit",
|
||||
lazy: () =>
|
||||
import("../../v2-routes/campaigns/campaign-edit"),
|
||||
},
|
||||
{
|
||||
path: "edit-budget",
|
||||
lazy: () =>
|
||||
import("../../v2-routes/campaigns/campaign-budget-edit"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/collections",
|
||||
handle: {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Heading } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { RouteDrawer } from "../../../components/route-modal"
|
||||
import { useCampaign } from "../../../hooks/api/campaigns"
|
||||
import { EditCampaignBudgetForm } from "./components/edit-campaign-budget-form"
|
||||
|
||||
export const CampaignBudgetEdit = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { id } = useParams()
|
||||
const { campaign, isLoading, isError, error } = useCampaign(id!)
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteDrawer>
|
||||
<RouteDrawer.Header>
|
||||
<Heading>{t("campaigns.edit.header")}</Heading>
|
||||
</RouteDrawer.Header>
|
||||
|
||||
{!isLoading && campaign && <EditCampaignBudgetForm campaign={campaign} />}
|
||||
</RouteDrawer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { CampaignResponse } from "@medusajs/types"
|
||||
import {
|
||||
Button,
|
||||
clx,
|
||||
CurrencyInput,
|
||||
Input,
|
||||
RadioGroup,
|
||||
toast,
|
||||
} from "@medusajs/ui"
|
||||
import { useForm, useWatch } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import {
|
||||
RouteDrawer,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import { useUpdateCampaign } from "../../../../../hooks/api/campaigns"
|
||||
import { getCurrencySymbol } from "../../../../../lib/currencies"
|
||||
|
||||
type EditCampaignBudgetFormProps = {
|
||||
campaign: CampaignResponse
|
||||
}
|
||||
|
||||
const EditCampaignSchema = zod.object({
|
||||
limit: zod.number().min(0),
|
||||
type: zod.enum(["spend", "usage"]).optional(),
|
||||
})
|
||||
|
||||
export const EditCampaignBudgetForm = ({
|
||||
campaign,
|
||||
}: EditCampaignBudgetFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
|
||||
const form = useForm<zod.infer<typeof EditCampaignSchema>>({
|
||||
defaultValues: {
|
||||
limit: campaign?.budget?.limit,
|
||||
type: campaign?.budget?.type || "spend",
|
||||
},
|
||||
resolver: zodResolver(EditCampaignSchema),
|
||||
})
|
||||
|
||||
const { mutateAsync, isPending } = useUpdateCampaign(campaign.id)
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (data) => {
|
||||
await mutateAsync(
|
||||
{
|
||||
id: campaign.id,
|
||||
budget: {
|
||||
limit: data.limit,
|
||||
type: data.type,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: ({ campaign }) => {
|
||||
toast.success(t("general.success"), {
|
||||
description: t("campaigns.edit.successToast", {
|
||||
name: campaign.name,
|
||||
}),
|
||||
dismissLabel: t("actions.close"),
|
||||
})
|
||||
|
||||
handleSuccess()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(t("general.error"), {
|
||||
description: error.message,
|
||||
dismissLabel: t("actions.close"),
|
||||
})
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const watchValueType = useWatch({
|
||||
control: form.control,
|
||||
name: "type",
|
||||
})
|
||||
|
||||
const isTypeSpend = watchValueType === "spend"
|
||||
|
||||
return (
|
||||
<RouteDrawer.Form form={form}>
|
||||
<form onSubmit={handleSubmit} className="flex flex-1 flex-col">
|
||||
<RouteDrawer.Body>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("campaigns.budget.fields.type")}</Form.Label>
|
||||
|
||||
<Form.Control>
|
||||
<RadioGroup
|
||||
className="flex-col gap-y-3"
|
||||
{...field}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<RadioGroup.ChoiceBox
|
||||
className={clx("basis-1/2", {
|
||||
"border-2 border-ui-border-interactive":
|
||||
"spend" === field.value,
|
||||
})}
|
||||
value={"spend"}
|
||||
label={t("campaigns.budget.type.spend.title")}
|
||||
description={t(
|
||||
"campaigns.budget.type.spend.description"
|
||||
)}
|
||||
/>
|
||||
|
||||
<RadioGroup.ChoiceBox
|
||||
className={clx("basis-1/2", {
|
||||
"border-2 border-ui-border-interactive":
|
||||
"usage" === field.value,
|
||||
})}
|
||||
value={"usage"}
|
||||
label={t("campaigns.budget.type.usage.title")}
|
||||
description={t(
|
||||
"campaigns.budget.type.usage.description"
|
||||
)}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="limit"
|
||||
render={({ field: { onChange, value, ...field } }) => {
|
||||
return (
|
||||
<Form.Item className="basis-1/2">
|
||||
<Form.Label>
|
||||
{t("campaigns.budget.fields.limit")}
|
||||
</Form.Label>
|
||||
|
||||
<Form.Control>
|
||||
{isTypeSpend ? (
|
||||
<CurrencyInput
|
||||
min={0}
|
||||
onValueChange={(value) =>
|
||||
onChange(value ? parseInt(value) : "")
|
||||
}
|
||||
code={campaign.currency}
|
||||
symbol={getCurrencySymbol(campaign.currency)}
|
||||
{...field}
|
||||
value={value}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
key="usage"
|
||||
min={0}
|
||||
{...field}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(
|
||||
e.target.value === ""
|
||||
? null
|
||||
: parseInt(e.target.value)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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 variant="secondary" size="small">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteDrawer.Close>
|
||||
|
||||
<Button
|
||||
isLoading={isPending}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="small"
|
||||
>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</RouteDrawer.Footer>
|
||||
</form>
|
||||
</RouteDrawer.Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./edit-campaign-budget-form"
|
||||
@@ -0,0 +1 @@
|
||||
export { CampaignBudgetEdit as Component } from "./campaign-budget-edit"
|
||||
@@ -0,0 +1,10 @@
|
||||
import { RouteFocusModal } from "../../../components/route-modal"
|
||||
import { CreateCampaignForm } from "./components/create-campaign-form"
|
||||
|
||||
export const CampaignCreate = () => {
|
||||
return (
|
||||
<RouteFocusModal>
|
||||
<CreateCampaignForm />
|
||||
</RouteFocusModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import {
|
||||
Button,
|
||||
clx,
|
||||
CurrencyInput,
|
||||
DatePicker,
|
||||
Heading,
|
||||
Input,
|
||||
RadioGroup,
|
||||
Select,
|
||||
Text,
|
||||
toast,
|
||||
} from "@medusajs/ui"
|
||||
import { useForm, useWatch } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import {
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import { useCreateCampaign } from "../../../../../hooks/api/campaigns"
|
||||
import { currencies, getCurrencySymbol } from "../../../../../lib/currencies"
|
||||
|
||||
const CreateCampaignSchema = zod.object({
|
||||
name: zod.string(),
|
||||
description: zod.string().optional(),
|
||||
currency: zod.string(),
|
||||
campaign_identifier: zod.string(),
|
||||
starts_at: zod.date().optional(),
|
||||
ends_at: zod.date().optional(),
|
||||
budget: zod.object({
|
||||
limit: zod.number().min(0),
|
||||
type: zod.enum(["spend", "usage"]).optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
export const CreateCampaignForm = () => {
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
|
||||
const { mutateAsync, isPending } = useCreateCampaign()
|
||||
|
||||
const form = useForm<zod.infer<typeof CreateCampaignSchema>>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
currency: "",
|
||||
campaign_identifier: "",
|
||||
starts_at: undefined,
|
||||
ends_at: undefined,
|
||||
budget: {
|
||||
type: "spend",
|
||||
limit: undefined,
|
||||
},
|
||||
},
|
||||
resolver: zodResolver(CreateCampaignSchema),
|
||||
})
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (data) => {
|
||||
await mutateAsync(
|
||||
{
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
currency: data.currency,
|
||||
campaign_identifier: data.campaign_identifier,
|
||||
starts_at: data.starts_at,
|
||||
ends_at: data.ends_at,
|
||||
budget: {
|
||||
type: data.budget.type,
|
||||
limit: data.budget.limit,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: ({ campaign }) => {
|
||||
toast.success(t("general.success"), {
|
||||
description: t("campaigns.create.successToast", {
|
||||
name: campaign.name,
|
||||
}),
|
||||
dismissLabel: t("actions.close"),
|
||||
})
|
||||
handleSuccess(`/campaigns/${campaign.id}`)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(t("general.error"), {
|
||||
description: error.message,
|
||||
dismissLabel: t("actions.close"),
|
||||
})
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const watchValueType = useWatch({
|
||||
control: form.control,
|
||||
name: "budget.type",
|
||||
})
|
||||
|
||||
const isTypeSpend = watchValueType === "spend"
|
||||
|
||||
const currencyValue = useWatch({
|
||||
control: form.control,
|
||||
name: "currency",
|
||||
})
|
||||
|
||||
return (
|
||||
<RouteFocusModal.Form form={form}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<RouteFocusModal.Header>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<RouteFocusModal.Close asChild>
|
||||
<Button size="small" variant="secondary">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteFocusModal.Close>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isLoading={isPending}
|
||||
>
|
||||
{t("actions.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</RouteFocusModal.Header>
|
||||
|
||||
<RouteFocusModal.Body className="flex flex-col items-center py-16">
|
||||
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
|
||||
<div>
|
||||
<Heading>{t("campaigns.create.header")}</Heading>
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
{t("campaigns.create.hint")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.name")}</Form.Label>
|
||||
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.description")}</Form.Label>
|
||||
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="campaign_identifier"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>
|
||||
{t("campaigns.fields.identifier")}
|
||||
</Form.Label>
|
||||
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="currency"
|
||||
render={({ field: { onChange, ref, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.currency")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Select {...field} onValueChange={onChange}>
|
||||
<Select.Trigger ref={ref}>
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
|
||||
<Select.Content>
|
||||
{Object.values(currencies).map((currency) => (
|
||||
<Select.Item
|
||||
value={currency.code}
|
||||
key={currency.code}
|
||||
>
|
||||
{currency.name}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="starts_at"
|
||||
render={({
|
||||
field: { value, onChange, ref: _ref, ...field },
|
||||
}) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>
|
||||
{t("campaigns.fields.start_date")}
|
||||
</Form.Label>
|
||||
|
||||
<Form.Control>
|
||||
<DatePicker
|
||||
showTimePicker
|
||||
value={value ?? undefined}
|
||||
onChange={(v) => {
|
||||
onChange(v ?? null)
|
||||
}}
|
||||
{...field}
|
||||
/>
|
||||
</Form.Control>
|
||||
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="ends_at"
|
||||
render={({
|
||||
field: { value, onChange, ref: _ref, ...field },
|
||||
}) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("campaigns.fields.end_date")}</Form.Label>
|
||||
|
||||
<Form.Control>
|
||||
<DatePicker
|
||||
showTimePicker
|
||||
value={value ?? undefined}
|
||||
onChange={(v) => onChange(v ?? null)}
|
||||
{...field}
|
||||
/>
|
||||
</Form.Control>
|
||||
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Heading>{t("campaigns.budget.create.header")}</Heading>
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
{t("campaigns.budget.create.hint")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="budget.type"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("campaigns.budget.fields.type")}</Form.Label>
|
||||
|
||||
<Form.Control>
|
||||
<RadioGroup
|
||||
className="flex gap-y-3"
|
||||
{...field}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<RadioGroup.ChoiceBox
|
||||
className={clx("basis-1/2", {
|
||||
"border-2 border-ui-border-interactive":
|
||||
"spend" === field.value,
|
||||
})}
|
||||
value={"spend"}
|
||||
label={t("campaigns.budget.type.spend.title")}
|
||||
description={t(
|
||||
"campaigns.budget.type.spend.description"
|
||||
)}
|
||||
/>
|
||||
|
||||
<RadioGroup.ChoiceBox
|
||||
className={clx("basis-1/2", {
|
||||
"border-2 border-ui-border-interactive":
|
||||
"usage" === field.value,
|
||||
})}
|
||||
value={"usage"}
|
||||
label={t("campaigns.budget.type.usage.title")}
|
||||
description={t(
|
||||
"campaigns.budget.type.usage.description"
|
||||
)}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="budget.limit"
|
||||
render={({ field: { onChange, value, ...field } }) => {
|
||||
return (
|
||||
<Form.Item className="basis-1/2">
|
||||
<Form.Label>
|
||||
{t("campaigns.budget.fields.limit")}
|
||||
</Form.Label>
|
||||
|
||||
<Form.Control>
|
||||
{isTypeSpend ? (
|
||||
<CurrencyInput
|
||||
min={0}
|
||||
onValueChange={(value) =>
|
||||
onChange(value ? parseInt(value) : "")
|
||||
}
|
||||
code={currencyValue}
|
||||
symbol={
|
||||
currencyValue
|
||||
? getCurrencySymbol(currencyValue)
|
||||
: ""
|
||||
}
|
||||
{...field}
|
||||
value={value}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
key="usage"
|
||||
min={0}
|
||||
{...field}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(
|
||||
e.target.value === ""
|
||||
? null
|
||||
: parseInt(e.target.value)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</RouteFocusModal.Body>
|
||||
</form>
|
||||
</RouteFocusModal.Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./create-campaign-form"
|
||||
@@ -0,0 +1 @@
|
||||
export { CampaignCreate as Component } from "./campaign-create"
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Outlet, useLoaderData, useParams } from "react-router-dom"
|
||||
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 { CampaignSpend } from "./components/campaign-spend"
|
||||
import { campaignLoader } from "./loader"
|
||||
|
||||
export const CampaignDetail = () => {
|
||||
const initialData = useLoaderData() as Awaited<
|
||||
ReturnType<typeof campaignLoader>
|
||||
>
|
||||
|
||||
const { id } = useParams()
|
||||
const { campaign, isLoading, isError, error } = useCampaign(
|
||||
id!,
|
||||
{ fields: "+promotions.id" },
|
||||
{ initialData }
|
||||
)
|
||||
|
||||
if (isLoading || !campaign) {
|
||||
return <div>Loading...</div>
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<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} /> */}
|
||||
<JsonViewSection data={campaign} />
|
||||
</div>
|
||||
|
||||
<div className="hidden w-full max-w-[400px] flex-col gap-y-2 xl:flex">
|
||||
<CampaignSpend campaign={campaign} />
|
||||
<CampaignBudget campaign={campaign} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ChartPie, PencilSquare } from "@medusajs/icons"
|
||||
import { CampaignResponse } from "@medusajs/types"
|
||||
import { Container, Heading, Text } from "@medusajs/ui"
|
||||
import { Trans, useTranslation } from "react-i18next"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
|
||||
type CampaignBudgetProps = {
|
||||
campaign: CampaignResponse
|
||||
}
|
||||
|
||||
export const CampaignBudget = ({ campaign }: CampaignBudgetProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Container className="flex flex-col gap-y-4 px-6 py-4">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex-grow">
|
||||
<div className="float-left bg-ui-bg-base shadow-borders-base flex size-7 items-center justify-center rounded-md">
|
||||
<div className="bg-ui-bg-component flex size-6 items-center justify-center rounded-[4px]">
|
||||
<ChartPie className="text-ui-fg-subtle" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Heading
|
||||
className="ml-10 mt-[1.5px] font-normal text-ui-fg-subtle"
|
||||
level="h3"
|
||||
>
|
||||
{t("campaigns.fields.budget_limit")}
|
||||
</Heading>
|
||||
</div>
|
||||
|
||||
<ActionMenu
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
icon: <PencilSquare />,
|
||||
label: t("actions.edit"),
|
||||
to: `edit-budget`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text
|
||||
className="text-ui-fg-subtle border-l-4 border-ui-border-strong pl-3"
|
||||
size="small"
|
||||
leading="compact"
|
||||
>
|
||||
<Trans
|
||||
i18nKey="campaigns.totalSpend"
|
||||
values={{
|
||||
amount: campaign?.budget?.limit || 0,
|
||||
currency:
|
||||
campaign?.budget?.type === "spend" ? campaign.currency : "",
|
||||
}}
|
||||
components={[
|
||||
<span
|
||||
key="amount"
|
||||
className="text-ui-fg-base txt-compact-medium-plus text-lg"
|
||||
/>,
|
||||
<span
|
||||
key="currency"
|
||||
className="text-ui-fg-base txt-compact-medium-plus text-lg"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</Text>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./campaign-budget"
|
||||
@@ -0,0 +1,161 @@
|
||||
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { AdminCampaignResponse } from "@medusajs/types"
|
||||
import {
|
||||
Badge,
|
||||
Container,
|
||||
Heading,
|
||||
StatusBadge,
|
||||
Text,
|
||||
toast,
|
||||
usePrompt,
|
||||
} from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { formatDate } from "../../../../../components/common/date"
|
||||
import { useDeleteCampaign } from "../../../../../hooks/api/campaigns"
|
||||
import { currencies } from "../../../../../lib/currencies"
|
||||
import {
|
||||
campaignStatus,
|
||||
statusColor,
|
||||
} from "../../../common/utils/campaign-status"
|
||||
|
||||
type CampaignGeneralSectionProps = {
|
||||
campaign: AdminCampaignResponse["campaign"]
|
||||
}
|
||||
|
||||
export const CampaignGeneralSection = ({
|
||||
campaign,
|
||||
}: CampaignGeneralSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const prompt = usePrompt()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { mutateAsync } = useDeleteCampaign(campaign.id)
|
||||
|
||||
const handleDelete = async () => {
|
||||
const res = await prompt({
|
||||
title: t("campaigns.delete.title"),
|
||||
description: t("campaigns.delete.description", {
|
||||
name: campaign.name,
|
||||
}),
|
||||
confirmText: t("actions.delete"),
|
||||
cancelText: t("actions.cancel"),
|
||||
})
|
||||
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
|
||||
await mutateAsync(undefined, {
|
||||
onSuccess: () => {
|
||||
toast.success(t("general.success"), {
|
||||
description: t("campaigns.delete.successToast", {
|
||||
name: campaign.name,
|
||||
}),
|
||||
dismissLabel: t("actions.close"),
|
||||
})
|
||||
|
||||
navigate("/campaigns", { replace: true })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(t("general.error"), {
|
||||
description: error.message,
|
||||
dismissLabel: t("actions.close"),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const status = campaignStatus(campaign)
|
||||
|
||||
return (
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<Heading>{campaign.name}</Heading>
|
||||
|
||||
<div className="flex items-center gap-x-4">
|
||||
<StatusBadge color={statusColor(status)}>
|
||||
{t(`campaigns.status.${status}`)}
|
||||
</StatusBadge>
|
||||
|
||||
<ActionMenu
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
icon: <PencilSquare />,
|
||||
label: t("actions.edit"),
|
||||
to: `/campaigns/${campaign.id}/edit`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
icon: <Trash />,
|
||||
label: t("actions.delete"),
|
||||
onClick: handleDelete,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("campaigns.fields.identifier")}
|
||||
</Text>
|
||||
|
||||
<Text size="small" leading="compact">
|
||||
{campaign.campaign_identifier}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("fields.description")}
|
||||
</Text>
|
||||
|
||||
<Text size="small" leading="compact">
|
||||
{campaign.description || "-"}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("fields.currency")}
|
||||
</Text>
|
||||
|
||||
<div>
|
||||
<Badge size="xsmall">{campaign.currency}</Badge>
|
||||
<Text className="inline pl-3" size="small" leading="compact">
|
||||
{currencies[campaign.currency]?.name}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("campaigns.fields.start_date")}
|
||||
</Text>
|
||||
|
||||
<Text size="small" leading="compact">
|
||||
{campaign.starts_at ? formatDate(campaign.starts_at) : "-"}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("campaigns.fields.end_date")}
|
||||
</Text>
|
||||
|
||||
<Text size="small" leading="compact">
|
||||
{campaign.starts_at ? formatDate(campaign.ends_at) : "-"}
|
||||
</Text>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./campaign-general-section"
|
||||
@@ -0,0 +1,246 @@
|
||||
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { CampaignResponse, PromotionReponse } from "@medusajs/types"
|
||||
import { Button, Checkbox, Container, Heading, usePrompt } from "@medusajs/ui"
|
||||
import { RowSelectionState, createColumnHelper } from "@tanstack/react-table"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
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 { 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 CampaignPromotionSectionProps = {
|
||||
campaign: CampaignResponse
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
export const CampaignPromotionSection = ({
|
||||
campaign,
|
||||
}: CampaignPromotionSectionProps) => {
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const { t } = useTranslation()
|
||||
const prompt = usePrompt()
|
||||
|
||||
const { searchParams, raw } = usePromotionTableQuery({ pageSize: PAGE_SIZE })
|
||||
const { promotions, count, isLoading, isError, error } = usePromotions({
|
||||
...searchParams,
|
||||
// TODO: This should be scoped by currency_code
|
||||
})
|
||||
|
||||
const columns = useColumns()
|
||||
const filters = usePromotionTableFilters()
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: promotions ?? [],
|
||||
columns,
|
||||
count,
|
||||
getRowId: (row) => row.id,
|
||||
enablePagination: true,
|
||||
enableRowSelection: true,
|
||||
pageSize: PAGE_SIZE,
|
||||
rowSelection: {
|
||||
state: rowSelection,
|
||||
updater: setRowSelection,
|
||||
},
|
||||
meta: {
|
||||
campaignId: campaign.id,
|
||||
},
|
||||
})
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const { mutateAsync } = useRemovePromotionsFromCampaign(campaign.id)
|
||||
|
||||
const handleRemove = async () => {
|
||||
const keys = Object.keys(rowSelection)
|
||||
|
||||
const res = await prompt({
|
||||
title: t("campaigns.promotions.remove.title", { count: keys.length }),
|
||||
description: t("campaigns.promotions.remove.description", {
|
||||
count: keys.length,
|
||||
}),
|
||||
confirmText: t("actions.continue"),
|
||||
cancelText: t("actions.cancel"),
|
||||
})
|
||||
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
|
||||
await mutateAsync(
|
||||
{
|
||||
remove: keys,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setRowSelection({})
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<Heading level="h2">{t("promotions.domain")}</Heading>
|
||||
<Link to={`/campaigns/${campaign.id}/add-promotions`}>
|
||||
<Button variant="secondary" size="small">
|
||||
{t("general.add")}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
table={table}
|
||||
columns={columns}
|
||||
pageSize={PAGE_SIZE}
|
||||
isLoading={isLoading}
|
||||
count={count}
|
||||
navigateTo={(row) => `/promotions/${row.id}`}
|
||||
filters={filters}
|
||||
search
|
||||
pagination
|
||||
orderBy={[
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"has_account",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]}
|
||||
queryObject={raw}
|
||||
commands={[
|
||||
{
|
||||
action: handleRemove,
|
||||
label: t("actions.remove"),
|
||||
shortcut: "r",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const PromotionActions = ({
|
||||
promotion,
|
||||
campaignId,
|
||||
}: {
|
||||
promotion: PromotionReponse
|
||||
campaignId: string
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { mutateAsync } = useRemovePromotionsFromCampaign(campaignId)
|
||||
|
||||
const prompt = usePrompt()
|
||||
|
||||
const handleRemove = async () => {
|
||||
const res = await prompt({
|
||||
title: t("campaigns.promotions.remove.title", {
|
||||
count: 1,
|
||||
}),
|
||||
description: t("campaigns.promotions.remove.description", {
|
||||
count: 1,
|
||||
}),
|
||||
confirmText: t("actions.continue"),
|
||||
cancelText: t("actions.cancel"),
|
||||
})
|
||||
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
|
||||
await mutateAsync({
|
||||
remove: [promotion.id],
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionMenu
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
icon: <PencilSquare />,
|
||||
label: t("actions.edit"),
|
||||
to: `/promotions/${promotion.id}/edit`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
icon: <Trash />,
|
||||
label: t("actions.remove"),
|
||||
onClick: handleRemove,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<PromotionReponse>()
|
||||
|
||||
const useColumns = () => {
|
||||
const columns = usePromotionTableColumns()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
columnHelper.display({
|
||||
id: "select",
|
||||
header: ({ table }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsSomePageRowsSelected()
|
||||
? "indeterminate"
|
||||
: table.getIsAllPageRowsSelected()
|
||||
}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
...columns,
|
||||
columnHelper.display({
|
||||
id: "actions",
|
||||
cell: ({ row, table }) => {
|
||||
const { campaignId } = table.options.meta as {
|
||||
campaignId: string
|
||||
}
|
||||
|
||||
return (
|
||||
<PromotionActions
|
||||
promotion={row.original}
|
||||
campaignId={campaignId}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
],
|
||||
[columns]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./campaign-promotion-section"
|
||||
@@ -0,0 +1,55 @@
|
||||
import { CurrencyDollar } from "@medusajs/icons"
|
||||
import { CampaignResponse } from "@medusajs/types"
|
||||
import { Container, Heading, Text } from "@medusajs/ui"
|
||||
import { Trans, useTranslation } from "react-i18next"
|
||||
|
||||
type CampaignSpendProps = {
|
||||
campaign: CampaignResponse
|
||||
}
|
||||
|
||||
export const CampaignSpend = ({ campaign }: CampaignSpendProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Container className="flex flex-col gap-y-4 px-6 py-4">
|
||||
<div className="grid grid-cols-[28px_1fr] items-center gap-x-3 mb-2">
|
||||
<div className="bg-ui-bg-base shadow-borders-base flex size-7 items-center justify-center rounded-md">
|
||||
<div className="bg-ui-bg-component flex size-6 items-center justify-center rounded-[4px]">
|
||||
<CurrencyDollar className="text-ui-fg-subtle" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Heading level="h3" className="font-normal text-ui-fg-subtle">
|
||||
{t("campaigns.fields.total_spend")}
|
||||
</Heading>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text
|
||||
className="text-ui-fg-subtle border-l-4 border-ui-border-strong pl-3"
|
||||
size="small"
|
||||
leading="compact"
|
||||
>
|
||||
<Trans
|
||||
i18nKey="campaigns.totalSpend"
|
||||
values={{
|
||||
amount: campaign?.budget?.used || 0,
|
||||
currency:
|
||||
campaign?.budget?.type === "spend" ? campaign.currency : "",
|
||||
}}
|
||||
components={[
|
||||
<span
|
||||
key="amount"
|
||||
className="text-ui-fg-base txt-compact-medium-plus text-lg"
|
||||
/>,
|
||||
<span
|
||||
key="currency"
|
||||
className="text-ui-fg-base txt-compact-medium-plus text-lg"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</Text>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./campaign-spend"
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CampaignDetail as Component } from "./campaign-detail"
|
||||
export { campaignLoader as loader } from "./loader"
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Response } from "@medusajs/medusa-js"
|
||||
import { AdminCampaignResponse } from "@medusajs/types"
|
||||
import { LoaderFunctionArgs } from "react-router-dom"
|
||||
import { campaignsQueryKeys } from "../../../hooks/api/campaigns"
|
||||
import { client } from "../../../lib/client"
|
||||
import { queryClient } from "../../../lib/medusa"
|
||||
|
||||
const campaignDetailQuery = (id: string) => ({
|
||||
queryKey: campaignsQueryKeys.detail(id),
|
||||
queryFn: async () =>
|
||||
client.campaigns.retrieve(id, {
|
||||
fields: "+promotions.id",
|
||||
}),
|
||||
})
|
||||
|
||||
export const campaignLoader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const id = params.id
|
||||
const query = campaignDetailQuery(id!)
|
||||
|
||||
return (
|
||||
queryClient.getQueryData<Response<AdminCampaignResponse>>(query.queryKey) ??
|
||||
(await queryClient.fetchQuery(query))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Heading } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { RouteDrawer } from "../../../components/route-modal"
|
||||
import { useCampaign } from "../../../hooks/api/campaigns"
|
||||
import { EditCampaignForm } from "./components/edit-campaign-form"
|
||||
|
||||
export const CampaignEdit = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { id } = useParams()
|
||||
const { campaign, isLoading, isError, error } = useCampaign(id!)
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteDrawer>
|
||||
<RouteDrawer.Header>
|
||||
<Heading>{t("campaigns.edit.header")}</Heading>
|
||||
</RouteDrawer.Header>
|
||||
|
||||
{!isLoading && campaign && <EditCampaignForm campaign={campaign} />}
|
||||
</RouteDrawer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { CampaignResponse } from "@medusajs/types"
|
||||
import { Button, DatePicker, Input, Select, toast } from "@medusajs/ui"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import {
|
||||
RouteDrawer,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import { useUpdateCampaign } from "../../../../../hooks/api/campaigns"
|
||||
import { currencies } from "../../../../../lib/currencies"
|
||||
|
||||
type EditCampaignFormProps = {
|
||||
campaign: CampaignResponse
|
||||
}
|
||||
|
||||
const EditCampaignSchema = zod.object({
|
||||
name: zod.string(),
|
||||
description: zod.string().optional(),
|
||||
currency: zod.string().optional(),
|
||||
campaign_identifier: zod.string().optional(),
|
||||
starts_at: zod.date().optional(),
|
||||
ends_at: zod.date().optional(),
|
||||
})
|
||||
|
||||
export const EditCampaignForm = ({ campaign }: EditCampaignFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
|
||||
const form = useForm<zod.infer<typeof EditCampaignSchema>>({
|
||||
defaultValues: {
|
||||
name: campaign.name || "",
|
||||
description: campaign.description || "",
|
||||
currency: campaign.currency || "",
|
||||
campaign_identifier: campaign.campaign_identifier || "",
|
||||
starts_at: campaign.starts_at ? new Date(campaign.starts_at) : undefined,
|
||||
ends_at: campaign.ends_at ? new Date(campaign.ends_at) : undefined,
|
||||
},
|
||||
resolver: zodResolver(EditCampaignSchema),
|
||||
})
|
||||
|
||||
const { mutateAsync, isPending } = useUpdateCampaign(campaign.id)
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (data) => {
|
||||
await mutateAsync(
|
||||
{
|
||||
id: campaign.id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
currency: data.currency,
|
||||
campaign_identifier: data.campaign_identifier,
|
||||
starts_at: data.starts_at,
|
||||
ends_at: data.ends_at,
|
||||
},
|
||||
{
|
||||
onSuccess: ({ campaign }) => {
|
||||
toast.success(t("general.success"), {
|
||||
description: t("campaigns.edit.successToast", {
|
||||
name: campaign.name,
|
||||
}),
|
||||
dismissLabel: t("actions.close"),
|
||||
})
|
||||
|
||||
handleSuccess()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(t("general.error"), {
|
||||
description: error.message,
|
||||
dismissLabel: t("actions.close"),
|
||||
})
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<RouteDrawer.Form form={form}>
|
||||
<form onSubmit={handleSubmit} className="flex flex-1 flex-col">
|
||||
<RouteDrawer.Body>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.name")}</Form.Label>
|
||||
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.description")}</Form.Label>
|
||||
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="campaign_identifier"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("campaigns.fields.identifier")}</Form.Label>
|
||||
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="currency"
|
||||
render={({ field: { onChange, ref, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.currency")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Select {...field} onValueChange={onChange}>
|
||||
<Select.Trigger ref={ref}>
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
|
||||
<Select.Content>
|
||||
{Object.values(currencies).map((currency) => (
|
||||
<Select.Item
|
||||
value={currency.code}
|
||||
key={currency.code}
|
||||
>
|
||||
{currency.name}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="starts_at"
|
||||
render={({ field: { value, onChange, ref: _ref, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("campaigns.fields.start_date")}</Form.Label>
|
||||
|
||||
<Form.Control>
|
||||
<DatePicker
|
||||
showTimePicker
|
||||
value={value ?? undefined}
|
||||
onChange={(v) => {
|
||||
onChange(v ?? null)
|
||||
}}
|
||||
{...field}
|
||||
/>
|
||||
</Form.Control>
|
||||
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="ends_at"
|
||||
render={({ field: { value, onChange, ref: _ref, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("campaigns.fields.end_date")}</Form.Label>
|
||||
|
||||
<Form.Control>
|
||||
<DatePicker
|
||||
showTimePicker
|
||||
value={value ?? undefined}
|
||||
onChange={(v) => onChange(v ?? null)}
|
||||
{...field}
|
||||
/>
|
||||
</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 variant="secondary" size="small">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteDrawer.Close>
|
||||
|
||||
<Button
|
||||
isLoading={isPending}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="small"
|
||||
>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</RouteDrawer.Footer>
|
||||
</form>
|
||||
</RouteDrawer.Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./edit-campaign-form"
|
||||
@@ -0,0 +1 @@
|
||||
export { CampaignEdit as Component } from "./campaign-edit"
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Outlet } from "react-router-dom"
|
||||
import { CampaignListTable } from "./components/campaign-list-table"
|
||||
|
||||
export const CampaignList = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<CampaignListTable />
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { CampaignResponse } from "@medusajs/types"
|
||||
import { Button, Container, Heading, toast, usePrompt } from "@medusajs/ui"
|
||||
import { keepPreviousData } from "@tanstack/react-query"
|
||||
import { createColumnHelper } from "@tanstack/react-table"
|
||||
import { useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link } from "react-router-dom"
|
||||
import { ActionMenu } from "../../../../components/common/action-menu"
|
||||
import { DataTable } from "../../../../components/table/data-table"
|
||||
import {
|
||||
useCampaigns,
|
||||
useDeleteCampaign,
|
||||
} from "../../../../hooks/api/campaigns"
|
||||
import { useCampaignTableColumns } from "../../../../hooks/table/columns/use-campaign-table-columns"
|
||||
import { useCampaignTableQuery } from "../../../../hooks/table/query/use-campaign-table-query"
|
||||
import { useDataTable } from "../../../../hooks/use-data-table"
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export const CampaignListTable = () => {
|
||||
const { t } = useTranslation()
|
||||
const { raw, searchParams } = useCampaignTableQuery({ pageSize: PAGE_SIZE })
|
||||
|
||||
const {
|
||||
campaigns,
|
||||
count,
|
||||
isPending: isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useCampaigns(searchParams, {
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
|
||||
const columns = useColumns()
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: campaigns ?? [],
|
||||
columns,
|
||||
count,
|
||||
enablePagination: true,
|
||||
getRowId: (row) => row.id,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<Heading level="h2">{t("campaigns.domain")}</Heading>
|
||||
<Link to="/campaigns/create">
|
||||
<Button size="small" variant="secondary">
|
||||
{t("actions.create")}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
table={table}
|
||||
columns={columns}
|
||||
count={count}
|
||||
pageSize={PAGE_SIZE}
|
||||
pagination
|
||||
search
|
||||
navigateTo={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
queryObject={raw}
|
||||
orderBy={["name", "created_at", "updated_at"]}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const CampaignActions = ({ campaign }: { campaign: CampaignResponse }) => {
|
||||
const { t } = useTranslation()
|
||||
const prompt = usePrompt()
|
||||
const { mutateAsync } = useDeleteCampaign(campaign.id)
|
||||
|
||||
const handleDelete = async () => {
|
||||
const confirm = await prompt({
|
||||
title: t("general.areYouSure"),
|
||||
description: t("campaigns.deleteCampaignWarning", {
|
||||
name: campaign.name,
|
||||
}),
|
||||
verificationInstruction: t("general.typeToConfirm"),
|
||||
verificationText: campaign.name,
|
||||
confirmText: t("actions.delete"),
|
||||
cancelText: t("actions.cancel"),
|
||||
})
|
||||
|
||||
if (!confirm) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await mutateAsync()
|
||||
toast.success(t("general.success"), {
|
||||
description: t("campaigns.toast.delete"),
|
||||
dismissLabel: t("actions.close"),
|
||||
})
|
||||
} catch (e) {
|
||||
toast.error(t("general.error"), {
|
||||
description: e.message,
|
||||
dismissLabel: t("actions.close"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionMenu
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
icon: <PencilSquare />,
|
||||
label: t("actions.edit"),
|
||||
to: `/campaigns/${campaign.id}/edit`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
icon: <Trash />,
|
||||
label: t("actions.delete"),
|
||||
onClick: handleDelete,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<CampaignResponse>()
|
||||
|
||||
const useColumns = () => {
|
||||
const base = useCampaignTableColumns()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
...base,
|
||||
columnHelper.display({
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
return <CampaignActions campaign={row.original} />
|
||||
},
|
||||
}),
|
||||
],
|
||||
[base]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./campaign-list-table"
|
||||
@@ -0,0 +1 @@
|
||||
export { CampaignList as Component } from "./campaign-list"
|
||||
@@ -0,0 +1,31 @@
|
||||
import { CampaignResponse } from "@medusajs/types"
|
||||
import { isAfter, isBefore } from "date-fns"
|
||||
|
||||
export function campaignStatus(campaign: CampaignResponse) {
|
||||
if (campaign.ends_at) {
|
||||
if (isBefore(new Date(campaign.ends_at), new Date())) {
|
||||
return "expired"
|
||||
}
|
||||
}
|
||||
|
||||
if (campaign.starts_at) {
|
||||
if (isAfter(new Date(campaign.starts_at), new Date())) {
|
||||
return "scheduled"
|
||||
}
|
||||
}
|
||||
|
||||
return "active"
|
||||
}
|
||||
|
||||
export const statusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "expired":
|
||||
return "red"
|
||||
case "scheduled":
|
||||
return "orange"
|
||||
case "active":
|
||||
return "green"
|
||||
default:
|
||||
return "grey"
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import { Heading } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { RouteDrawer } from "../../../components/route-modal"
|
||||
import { EditCustomerForm } from "./components/edit-customer-form"
|
||||
import { useCustomer } from "../../../hooks/api/customers"
|
||||
import { EditCustomerForm } from "./components/edit-customer-form"
|
||||
|
||||
export const CustomerEdit = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -30,13 +30,18 @@ export const AddCampaignPromotionFields = ({ form, campaigns }) => {
|
||||
name: "campaign_id",
|
||||
})
|
||||
|
||||
const watchCampaignChoice = useWatch({
|
||||
control: form.control,
|
||||
name: "campaign_choice",
|
||||
})
|
||||
|
||||
const selectedCampaign = campaigns.find((c) => c.id === watchCampaignId)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-y-8">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="existing"
|
||||
name="campaign_choice"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
@@ -49,23 +54,34 @@ export const AddCampaignPromotionFields = ({ form, campaigns }) => {
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<RadioGroup.ChoiceBox
|
||||
value={"true"}
|
||||
value={"none"}
|
||||
label={t("promotions.form.campaign.none.title")}
|
||||
description={t("promotions.form.campaign.none.description")}
|
||||
className={clx("", {
|
||||
"border-2 border-ui-border-interactive":
|
||||
"none" === field.value,
|
||||
})}
|
||||
/>
|
||||
|
||||
<RadioGroup.ChoiceBox
|
||||
value={"existing"}
|
||||
label={t("promotions.form.campaign.existing.title")}
|
||||
description={t(
|
||||
"promotions.form.campaign.existing.description"
|
||||
)}
|
||||
className={clx("", {
|
||||
"border-2 border-ui-border-interactive":
|
||||
"true" === field.value,
|
||||
"existing" === field.value,
|
||||
})}
|
||||
/>
|
||||
|
||||
<RadioGroup.ChoiceBox
|
||||
value={"false"}
|
||||
value={"new"}
|
||||
label={t("promotions.form.campaign.new.title")}
|
||||
description={t("promotions.form.campaign.new.description")}
|
||||
className={clx("", {
|
||||
"border-2 border-ui-border-interactive":
|
||||
"false" === field.value,
|
||||
"new" === field.value,
|
||||
})}
|
||||
disabled
|
||||
/>
|
||||
@@ -78,36 +94,38 @@ export const AddCampaignPromotionFields = ({ form, campaigns }) => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<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>
|
||||
{watchCampaignChoice === "existing" && (
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -76,7 +76,7 @@ export const CreatePromotionForm = ({
|
||||
defaultValues: {
|
||||
campaign_id: undefined,
|
||||
template_id: templates[0].id!,
|
||||
existing: "true",
|
||||
campaign_choice: "none",
|
||||
is_automatic: "false",
|
||||
code: "",
|
||||
type: "standard",
|
||||
@@ -131,7 +131,7 @@ export const CreatePromotionForm = ({
|
||||
const handleSubmit = form.handleSubmit(
|
||||
async (data) => {
|
||||
const {
|
||||
existing,
|
||||
campaign_choice,
|
||||
is_automatic,
|
||||
template_id,
|
||||
application_method,
|
||||
@@ -153,7 +153,10 @@ export const CreatePromotionForm = ({
|
||||
|
||||
for (const rule of [...targetRulesData, ...buyRulesData]) {
|
||||
if (disguisedRuleAttributes.includes(rule.attribute)) {
|
||||
attr[rule.attribute] = rule.values
|
||||
attr[rule.attribute] =
|
||||
rule.field_type === "number"
|
||||
? parseInt(rule.values as string)
|
||||
: rule.values
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,6 +370,7 @@ export const CreatePromotionForm = ({
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("promotions.fields.type")}</Form.Label>
|
||||
|
||||
<Form.Control>
|
||||
<RadioGroup
|
||||
key={"template_id"}
|
||||
@@ -418,6 +422,7 @@ export const CreatePromotionForm = ({
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>Method</Form.Label>
|
||||
|
||||
<Form.Control>
|
||||
<RadioGroup
|
||||
className="flex gap-y-3"
|
||||
|
||||
@@ -19,7 +19,7 @@ const RuleSchema = z.array(
|
||||
export const CreatePromotionSchema = z.object({
|
||||
template_id: z.string().optional(),
|
||||
campaign_id: z.string().optional(),
|
||||
existing: z.string().toLowerCase().optional(),
|
||||
campaign_choice: z.enum(["none", "existing", "new"]).optional(),
|
||||
is_automatic: z.string().toLowerCase(),
|
||||
code: z.string().min(1),
|
||||
type: z.enum(["buyget", "standard"]),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CampaignBudgetTypeValues } from "../../../promotion"
|
||||
import { PaginatedResponse } from "../../common"
|
||||
|
||||
/**
|
||||
@@ -13,8 +14,8 @@ export interface CampaignResponse {
|
||||
ends_at: string
|
||||
budget: {
|
||||
id: string
|
||||
type: string
|
||||
limit: number | null
|
||||
type: CampaignBudgetTypeValues
|
||||
limit: number
|
||||
used: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,12 @@ export interface CreateCampaignBudgetDTO {
|
||||
/**
|
||||
* The type of the campaign budget.
|
||||
*/
|
||||
type: CampaignBudgetTypeValues
|
||||
type?: CampaignBudgetTypeValues
|
||||
|
||||
/**
|
||||
* The limit of the campaign budget.
|
||||
*/
|
||||
limit: number | null
|
||||
limit?: number
|
||||
|
||||
/**
|
||||
* How much is used of the campaign budget.
|
||||
@@ -37,7 +37,7 @@ export interface UpdateCampaignBudgetDTO {
|
||||
/**
|
||||
* The limit of the campaign budget.
|
||||
*/
|
||||
limit?: number | null
|
||||
limit?: number
|
||||
|
||||
/**
|
||||
* How much is used of the campaign budget.
|
||||
@@ -72,12 +72,12 @@ export interface CreateCampaignDTO {
|
||||
/**
|
||||
* The start date of the campaign.
|
||||
*/
|
||||
starts_at: Date
|
||||
starts_at?: Date
|
||||
|
||||
/**
|
||||
* The end date of the campaign.
|
||||
*/
|
||||
ends_at: Date
|
||||
ends_at?: Date
|
||||
|
||||
/**
|
||||
* The associated campaign budget.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CampaignBudgetType } from "@medusajs/utils"
|
||||
import { createFindParams, createSelectParams } from "../../utils/validators"
|
||||
import { z } from "zod"
|
||||
import { createFindParams, createSelectParams } from "../../utils/validators"
|
||||
|
||||
export const AdminGetCampaignParams = createSelectParams()
|
||||
|
||||
@@ -37,8 +37,8 @@ export const AdminCreateCampaign = z.object({
|
||||
description: z.string().optional(),
|
||||
currency: z.string().optional(),
|
||||
budget: CreateCampaignBudget.optional(),
|
||||
starts_at: z.coerce.date(),
|
||||
ends_at: z.coerce.date(),
|
||||
starts_at: z.coerce.date().optional(),
|
||||
ends_at: z.coerce.date().optional(),
|
||||
promotions: z.array(z.object({ id: z.string() })).optional(),
|
||||
})
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { EOL } from "os"
|
||||
import path from "path"
|
||||
|
||||
import Logger from "../loaders/logger"
|
||||
import { resolveAdminCLI } from "./utils/resolve-admin-cli"
|
||||
|
||||
const defaultConfig = {
|
||||
padding: 5,
|
||||
@@ -73,21 +72,6 @@ export default async function ({ port, directory }) {
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
const { cli, binExists } = resolveAdminCLI()
|
||||
|
||||
if (binExists) {
|
||||
const adminChild = fork(cli, [`develop`], {
|
||||
cwd: directory,
|
||||
env: process.env,
|
||||
stdio: ["pipe", process.stdout, process.stderr, "ipc"],
|
||||
})
|
||||
|
||||
adminChild.on("error", function (err) {
|
||||
console.log("Error ", err)
|
||||
adminChild.kill("SIGINT") // Only kill admin in case of error
|
||||
})
|
||||
}
|
||||
|
||||
chokidar
|
||||
.watch(`${directory}/src`, {
|
||||
ignored: `${directory}/src/admin`,
|
||||
|
||||
@@ -2,8 +2,8 @@ import { CampaignBudgetTypeValues } from "@medusajs/types"
|
||||
import { Campaign } from "@models"
|
||||
|
||||
export interface CreateCampaignBudgetDTO {
|
||||
type: CampaignBudgetTypeValues
|
||||
limit: number | null
|
||||
type?: CampaignBudgetTypeValues
|
||||
limit?: number
|
||||
used?: number
|
||||
campaign?: Campaign | string
|
||||
}
|
||||
@@ -11,6 +11,6 @@ export interface CreateCampaignBudgetDTO {
|
||||
export interface UpdateCampaignBudgetDTO {
|
||||
id: string
|
||||
type?: CampaignBudgetTypeValues
|
||||
limit?: number | null
|
||||
limit?: number
|
||||
used?: number
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ export interface CreateCampaignDTO {
|
||||
description?: string
|
||||
currency?: string
|
||||
campaign_identifier: string
|
||||
starts_at: Date
|
||||
ends_at: Date
|
||||
starts_at?: Date
|
||||
ends_at?: Date
|
||||
promotions?: (PromotionDTO | Promotion)[]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user