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:
Riqwan Thamir
2024-05-09 10:00:28 +02:00
committed by GitHub
parent 5952fddad5
commit 6da2964998
48 changed files with 2027 additions and 100 deletions

View File

@@ -0,0 +1,7 @@
---
"@medusajs/promotion": patch
"@medusajs/types": patch
"@medusajs/medusa": patch
---
feat(dashboard,core,medusa,promotion): add campaigns UI

View File

@@ -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",

View File

@@ -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 />,

View File

@@ -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),

View File

@@ -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")} />,

View File

@@ -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]
)
}

View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -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) {

View File

@@ -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: {

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./edit-campaign-budget-form"

View File

@@ -0,0 +1 @@
export { CampaignBudgetEdit as Component } from "./campaign-budget-edit"

View File

@@ -0,0 +1,10 @@
import { RouteFocusModal } from "../../../components/route-modal"
import { CreateCampaignForm } from "./components/create-campaign-form"
export const CampaignCreate = () => {
return (
<RouteFocusModal>
<CreateCampaignForm />
</RouteFocusModal>
)
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./create-campaign-form"

View File

@@ -0,0 +1 @@
export { CampaignCreate as Component } from "./campaign-create"

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./campaign-budget"

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./campaign-general-section"

View File

@@ -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]
)
}

View File

@@ -0,0 +1 @@
export * from "./campaign-promotion-section"

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./campaign-spend"

View File

@@ -0,0 +1,2 @@
export { CampaignDetail as Component } from "./campaign-detail"
export { campaignLoader as loader } from "./loader"

View File

@@ -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))
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./edit-campaign-form"

View File

@@ -0,0 +1 @@
export { CampaignEdit as Component } from "./campaign-edit"

View File

@@ -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>
)
}

View File

@@ -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]
)
}

View File

@@ -0,0 +1 @@
export * from "./campaign-list-table"

View File

@@ -0,0 +1 @@
export { CampaignList as Component } from "./campaign-list"

View File

@@ -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"
}
}

View File

@@ -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()

View File

@@ -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>

View File

@@ -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"

View File

@@ -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"]),

View File

@@ -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
}
}

View File

@@ -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.

View File

@@ -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(),
})

View File

@@ -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`,

View File

@@ -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
}

View File

@@ -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)[]
}