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:
@@ -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. ",
|
"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"
|
"zoneName": "Zone name"
|
||||||
},
|
},
|
||||||
"edit":{
|
"edit": {
|
||||||
"title": "Edit Service Zone"
|
"title": "Edit Service Zone"
|
||||||
},
|
},
|
||||||
"deleteWarning": "Are you sure you want to delete \"{{name}}\". This will also delete all assocciated shipping options.",
|
"deleteWarning": "Are you sure you want to delete \"{{name}}\". This will also delete all assocciated shipping options.",
|
||||||
@@ -761,7 +761,7 @@
|
|||||||
"provider": "Fulfillment provider"
|
"provider": "Fulfillment provider"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"returnOptions" : {
|
"returnOptions": {
|
||||||
"create": {
|
"create": {
|
||||||
"title": "Create a return option for {{zone}}"
|
"title": "Create a return option for {{zone}}"
|
||||||
}
|
}
|
||||||
@@ -968,6 +968,10 @@
|
|||||||
"new": {
|
"new": {
|
||||||
"title": "New Campaign",
|
"title": "New Campaign",
|
||||||
"description": "Would you like to create a new campaign with this promotion?"
|
"description": "Would you like to create a new campaign with this promotion?"
|
||||||
|
},
|
||||||
|
"none": {
|
||||||
|
"title": "Without Campaign",
|
||||||
|
"description": "Proceed without associating promotion with campaign"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
@@ -1032,21 +1036,58 @@
|
|||||||
"campaigns": {
|
"campaigns": {
|
||||||
"domain": "Campaigns",
|
"domain": "Campaigns",
|
||||||
"details": "Campaign details",
|
"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": {
|
"fields": {
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"identifier": "Identifier",
|
"identifier": "Identifier",
|
||||||
"start_date": "Start date",
|
"start_date": "Start date",
|
||||||
"end_date": "End date"
|
"end_date": "End date",
|
||||||
|
"total_spend": "Total spend",
|
||||||
|
"budget_limit": "Budget limit"
|
||||||
},
|
},
|
||||||
"budget": {
|
"budget": {
|
||||||
|
"create": {
|
||||||
|
"hint": "Create a budget for the campaign",
|
||||||
|
"header": "Campaign Budget"
|
||||||
|
},
|
||||||
"details": "Campaign budget",
|
"details": "Campaign budget",
|
||||||
"fields": {
|
"fields": {
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
"currency": "Currency",
|
"currency": "Currency",
|
||||||
"limit": "Limit",
|
"limit": "Limit",
|
||||||
"used": "Used"
|
"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": {
|
"pricing": {
|
||||||
"domain": "Pricing",
|
"domain": "Pricing",
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
Buildings,
|
Buildings,
|
||||||
ChevronDownMini,
|
ChevronDownMini,
|
||||||
CurrencyDollar,
|
CurrencyDollar,
|
||||||
Envelope,
|
|
||||||
MinusMini,
|
MinusMini,
|
||||||
ReceiptPercent,
|
ReceiptPercent,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
@@ -141,6 +140,12 @@ const useCoreRoutes = (): Omit<NavItemProps, "pathname">[] => {
|
|||||||
icon: <ReceiptPercent />,
|
icon: <ReceiptPercent />,
|
||||||
label: t("promotions.domain"),
|
label: t("promotions.domain"),
|
||||||
to: "/promotions",
|
to: "/promotions",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: t("campaigns.domain"),
|
||||||
|
to: "/campaigns",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <CurrencyDollar />,
|
icon: <CurrencyDollar />,
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
AdminCampaignListResponse,
|
||||||
|
AdminCampaignResponse,
|
||||||
|
} from "@medusajs/types"
|
||||||
import {
|
import {
|
||||||
QueryKey,
|
QueryKey,
|
||||||
UseMutationOptions,
|
UseMutationOptions,
|
||||||
@@ -9,25 +13,27 @@ import { client } from "../../lib/client"
|
|||||||
import { queryClient } from "../../lib/medusa"
|
import { queryClient } from "../../lib/medusa"
|
||||||
import { queryKeysFactory } from "../../lib/query-key-factory"
|
import { queryKeysFactory } from "../../lib/query-key-factory"
|
||||||
import { CreateCampaignReq, UpdateCampaignReq } from "../../types/api-payloads"
|
import { CreateCampaignReq, UpdateCampaignReq } from "../../types/api-payloads"
|
||||||
import {
|
import { CampaignDeleteRes } from "../../types/api-responses"
|
||||||
CampaignDeleteRes,
|
|
||||||
CampaignListRes,
|
|
||||||
CampaignRes,
|
|
||||||
} from "../../types/api-responses"
|
|
||||||
|
|
||||||
const REGIONS_QUERY_KEY = "campaigns" as const
|
const REGIONS_QUERY_KEY = "campaigns" as const
|
||||||
const campaignsQueryKeys = queryKeysFactory(REGIONS_QUERY_KEY)
|
export const campaignsQueryKeys = queryKeysFactory(REGIONS_QUERY_KEY)
|
||||||
|
|
||||||
export const useCampaign = (
|
export const useCampaign = (
|
||||||
id: string,
|
id: string,
|
||||||
|
query?: Record<string, any>,
|
||||||
options?: Omit<
|
options?: Omit<
|
||||||
UseQueryOptions<CampaignRes, Error, CampaignRes, QueryKey>,
|
UseQueryOptions<
|
||||||
|
AdminCampaignResponse,
|
||||||
|
Error,
|
||||||
|
AdminCampaignResponse,
|
||||||
|
QueryKey
|
||||||
|
>,
|
||||||
"queryFn" | "queryKey"
|
"queryFn" | "queryKey"
|
||||||
>
|
>
|
||||||
) => {
|
) => {
|
||||||
const { data, ...rest } = useQuery({
|
const { data, ...rest } = useQuery({
|
||||||
queryKey: campaignsQueryKeys.detail(id),
|
queryKey: campaignsQueryKeys.detail(id),
|
||||||
queryFn: async () => client.campaigns.retrieve(id),
|
queryFn: async () => client.campaigns.retrieve(id, query),
|
||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -37,7 +43,12 @@ export const useCampaign = (
|
|||||||
export const useCampaigns = (
|
export const useCampaigns = (
|
||||||
query?: Record<string, any>,
|
query?: Record<string, any>,
|
||||||
options?: Omit<
|
options?: Omit<
|
||||||
UseQueryOptions<CampaignListRes, Error, CampaignListRes, QueryKey>,
|
UseQueryOptions<
|
||||||
|
AdminCampaignListResponse,
|
||||||
|
Error,
|
||||||
|
AdminCampaignListResponse,
|
||||||
|
QueryKey
|
||||||
|
>,
|
||||||
"queryFn" | "queryKey"
|
"queryFn" | "queryKey"
|
||||||
>
|
>
|
||||||
) => {
|
) => {
|
||||||
@@ -51,7 +62,7 @@ export const useCampaigns = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useCreateCampaign = (
|
export const useCreateCampaign = (
|
||||||
options?: UseMutationOptions<CampaignRes, Error, CreateCampaignReq>
|
options?: UseMutationOptions<AdminCampaignResponse, Error, CreateCampaignReq>
|
||||||
) => {
|
) => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (payload) => client.campaigns.create(payload),
|
mutationFn: (payload) => client.campaigns.create(payload),
|
||||||
@@ -65,7 +76,7 @@ export const useCreateCampaign = (
|
|||||||
|
|
||||||
export const useUpdateCampaign = (
|
export const useUpdateCampaign = (
|
||||||
id: string,
|
id: string,
|
||||||
options?: UseMutationOptions<CampaignRes, Error, UpdateCampaignReq>
|
options?: UseMutationOptions<AdminCampaignResponse, Error, UpdateCampaignReq>
|
||||||
) => {
|
) => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (payload) => client.campaigns.update(id, payload),
|
mutationFn: (payload) => client.campaigns.update(id, payload),
|
||||||
|
|||||||
-6
@@ -26,12 +26,6 @@ export const usePromotionTableColumns = () => {
|
|||||||
cell: ({ row }) => <CodeCell code={row.original.code!} />,
|
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({
|
columnHelper.display({
|
||||||
id: "method",
|
id: "method",
|
||||||
header: () => <TextHeader text={t("promotions.fields.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 {
|
import {
|
||||||
CampaignDeleteRes,
|
AdminCampaignListResponse,
|
||||||
CampaignListRes,
|
AdminCampaignResponse,
|
||||||
CampaignRes,
|
CreateCampaignDTO,
|
||||||
} from "../../types/api-responses"
|
UpdateCampaignDTO,
|
||||||
|
} from "@medusajs/types"
|
||||||
|
import { CampaignDeleteRes } from "../../types/api-responses"
|
||||||
import { deleteRequest, getRequest, postRequest } from "./common"
|
import { deleteRequest, getRequest, postRequest } from "./common"
|
||||||
|
|
||||||
async function retrieveCampaign(id: string, query?: Record<string, any>) {
|
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>) {
|
async function listCampaigns(query?: Record<string, any>) {
|
||||||
return getRequest<CampaignListRes>(`/admin/campaigns`, query)
|
return getRequest<AdminCampaignListResponse>(`/admin/campaigns`, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createCampaign(payload: CreateCampaignDTO) {
|
async function createCampaign(payload: CreateCampaignDTO) {
|
||||||
return postRequest<CampaignRes>(`/admin/campaigns`, payload)
|
return postRequest<AdminCampaignResponse>(`/admin/campaigns`, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateCampaign(id: string, payload: UpdateCampaignDTO) {
|
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) {
|
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",
|
path: "/collections",
|
||||||
handle: {
|
handle: {
|
||||||
|
|||||||
+27
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+201
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./edit-campaign-budget-form"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { CampaignBudgetEdit as Component } from "./campaign-budget-edit"
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
import { RouteFocusModal } from "../../../components/route-modal"
|
||||||
|
import { CreateCampaignForm } from "./components/create-campaign-form"
|
||||||
|
|
||||||
|
export const CampaignCreate = () => {
|
||||||
|
return (
|
||||||
|
<RouteFocusModal>
|
||||||
|
<CreateCampaignForm />
|
||||||
|
</RouteFocusModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
+387
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./create-campaign-form"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { CampaignCreate as Component } from "./campaign-create"
|
||||||
+48
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+75
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./campaign-budget"
|
||||||
+161
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./campaign-general-section"
|
||||||
+246
@@ -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]
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./campaign-promotion-section"
|
||||||
+55
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+239
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+155
@@ -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]
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -2,8 +2,8 @@ import { Heading } from "@medusajs/ui"
|
|||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { useParams } from "react-router-dom"
|
import { useParams } from "react-router-dom"
|
||||||
import { RouteDrawer } from "../../../components/route-modal"
|
import { RouteDrawer } from "../../../components/route-modal"
|
||||||
import { EditCustomerForm } from "./components/edit-customer-form"
|
|
||||||
import { useCustomer } from "../../../hooks/api/customers"
|
import { useCustomer } from "../../../hooks/api/customers"
|
||||||
|
import { EditCustomerForm } from "./components/edit-customer-form"
|
||||||
|
|
||||||
export const CustomerEdit = () => {
|
export const CustomerEdit = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|||||||
+51
-33
@@ -30,13 +30,18 @@ export const AddCampaignPromotionFields = ({ form, campaigns }) => {
|
|||||||
name: "campaign_id",
|
name: "campaign_id",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const watchCampaignChoice = useWatch({
|
||||||
|
control: form.control,
|
||||||
|
name: "campaign_choice",
|
||||||
|
})
|
||||||
|
|
||||||
const selectedCampaign = campaigns.find((c) => c.id === watchCampaignId)
|
const selectedCampaign = campaigns.find((c) => c.id === watchCampaignId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-y-8">
|
<div className="flex h-full flex-col gap-y-8">
|
||||||
<Form.Field
|
<Form.Field
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="existing"
|
name="campaign_choice"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
@@ -49,23 +54,34 @@ export const AddCampaignPromotionFields = ({ form, campaigns }) => {
|
|||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
>
|
>
|
||||||
<RadioGroup.ChoiceBox
|
<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")}
|
label={t("promotions.form.campaign.existing.title")}
|
||||||
description={t(
|
description={t(
|
||||||
"promotions.form.campaign.existing.description"
|
"promotions.form.campaign.existing.description"
|
||||||
)}
|
)}
|
||||||
className={clx("", {
|
className={clx("", {
|
||||||
"border-2 border-ui-border-interactive":
|
"border-2 border-ui-border-interactive":
|
||||||
"true" === field.value,
|
"existing" === field.value,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RadioGroup.ChoiceBox
|
<RadioGroup.ChoiceBox
|
||||||
value={"false"}
|
value={"new"}
|
||||||
label={t("promotions.form.campaign.new.title")}
|
label={t("promotions.form.campaign.new.title")}
|
||||||
description={t("promotions.form.campaign.new.description")}
|
description={t("promotions.form.campaign.new.description")}
|
||||||
className={clx("", {
|
className={clx("", {
|
||||||
"border-2 border-ui-border-interactive":
|
"border-2 border-ui-border-interactive":
|
||||||
"false" === field.value,
|
"new" === field.value,
|
||||||
})}
|
})}
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
@@ -78,36 +94,38 @@ export const AddCampaignPromotionFields = ({ form, campaigns }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Form.Field
|
{watchCampaignChoice === "existing" && (
|
||||||
control={form.control}
|
<Form.Field
|
||||||
name="campaign_id"
|
control={form.control}
|
||||||
render={({ field: { onChange, ref, ...field } }) => {
|
name="campaign_id"
|
||||||
return (
|
render={({ field: { onChange, ref, ...field } }) => {
|
||||||
<Form.Item>
|
return (
|
||||||
<Form.Label>
|
<Form.Item>
|
||||||
{t("promotions.form.campaign.existing.title")}
|
<Form.Label>
|
||||||
</Form.Label>
|
{t("promotions.form.campaign.existing.title")}
|
||||||
|
</Form.Label>
|
||||||
|
|
||||||
<Form.Control>
|
<Form.Control>
|
||||||
<Select onValueChange={onChange} {...field}>
|
<Select onValueChange={onChange} {...field}>
|
||||||
<Select.Trigger ref={ref}>
|
<Select.Trigger ref={ref}>
|
||||||
<Select.Value />
|
<Select.Value />
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
|
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
{campaigns.map((c) => (
|
{campaigns.map((c) => (
|
||||||
<Select.Item key={c.id} value={c.id}>
|
<Select.Item key={c.id} value={c.id}>
|
||||||
{c.name?.toUpperCase()}
|
{c.name?.toUpperCase()}
|
||||||
</Select.Item>
|
</Select.Item>
|
||||||
))}
|
))}
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Control>
|
</Form.Control>
|
||||||
<Form.ErrorMessage />
|
<Form.ErrorMessage />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<CampaignDetails campaign={selectedCampaign} />
|
<CampaignDetails campaign={selectedCampaign} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+8
-3
@@ -76,7 +76,7 @@ export const CreatePromotionForm = ({
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
campaign_id: undefined,
|
campaign_id: undefined,
|
||||||
template_id: templates[0].id!,
|
template_id: templates[0].id!,
|
||||||
existing: "true",
|
campaign_choice: "none",
|
||||||
is_automatic: "false",
|
is_automatic: "false",
|
||||||
code: "",
|
code: "",
|
||||||
type: "standard",
|
type: "standard",
|
||||||
@@ -131,7 +131,7 @@ export const CreatePromotionForm = ({
|
|||||||
const handleSubmit = form.handleSubmit(
|
const handleSubmit = form.handleSubmit(
|
||||||
async (data) => {
|
async (data) => {
|
||||||
const {
|
const {
|
||||||
existing,
|
campaign_choice,
|
||||||
is_automatic,
|
is_automatic,
|
||||||
template_id,
|
template_id,
|
||||||
application_method,
|
application_method,
|
||||||
@@ -153,7 +153,10 @@ export const CreatePromotionForm = ({
|
|||||||
|
|
||||||
for (const rule of [...targetRulesData, ...buyRulesData]) {
|
for (const rule of [...targetRulesData, ...buyRulesData]) {
|
||||||
if (disguisedRuleAttributes.includes(rule.attribute)) {
|
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 (
|
return (
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Form.Label>{t("promotions.fields.type")}</Form.Label>
|
<Form.Label>{t("promotions.fields.type")}</Form.Label>
|
||||||
|
|
||||||
<Form.Control>
|
<Form.Control>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
key={"template_id"}
|
key={"template_id"}
|
||||||
@@ -418,6 +422,7 @@ export const CreatePromotionForm = ({
|
|||||||
return (
|
return (
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Form.Label>Method</Form.Label>
|
<Form.Label>Method</Form.Label>
|
||||||
|
|
||||||
<Form.Control>
|
<Form.Control>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
className="flex gap-y-3"
|
className="flex gap-y-3"
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ const RuleSchema = z.array(
|
|||||||
export const CreatePromotionSchema = z.object({
|
export const CreatePromotionSchema = z.object({
|
||||||
template_id: z.string().optional(),
|
template_id: z.string().optional(),
|
||||||
campaign_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(),
|
is_automatic: z.string().toLowerCase(),
|
||||||
code: z.string().min(1),
|
code: z.string().min(1),
|
||||||
type: z.enum(["buyget", "standard"]),
|
type: z.enum(["buyget", "standard"]),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { CampaignBudgetTypeValues } from "../../../promotion"
|
||||||
import { PaginatedResponse } from "../../common"
|
import { PaginatedResponse } from "../../common"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,8 +14,8 @@ export interface CampaignResponse {
|
|||||||
ends_at: string
|
ends_at: string
|
||||||
budget: {
|
budget: {
|
||||||
id: string
|
id: string
|
||||||
type: string
|
type: CampaignBudgetTypeValues
|
||||||
limit: number | null
|
limit: number
|
||||||
used: number
|
used: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ export interface CreateCampaignBudgetDTO {
|
|||||||
/**
|
/**
|
||||||
* The type of the campaign budget.
|
* The type of the campaign budget.
|
||||||
*/
|
*/
|
||||||
type: CampaignBudgetTypeValues
|
type?: CampaignBudgetTypeValues
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The limit of the campaign budget.
|
* The limit of the campaign budget.
|
||||||
*/
|
*/
|
||||||
limit: number | null
|
limit?: number
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How much is used of the campaign budget.
|
* How much is used of the campaign budget.
|
||||||
@@ -37,7 +37,7 @@ export interface UpdateCampaignBudgetDTO {
|
|||||||
/**
|
/**
|
||||||
* The limit of the campaign budget.
|
* The limit of the campaign budget.
|
||||||
*/
|
*/
|
||||||
limit?: number | null
|
limit?: number
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How much is used of the campaign budget.
|
* How much is used of the campaign budget.
|
||||||
@@ -72,12 +72,12 @@ export interface CreateCampaignDTO {
|
|||||||
/**
|
/**
|
||||||
* The start date of the campaign.
|
* The start date of the campaign.
|
||||||
*/
|
*/
|
||||||
starts_at: Date
|
starts_at?: Date
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The end date of the campaign.
|
* The end date of the campaign.
|
||||||
*/
|
*/
|
||||||
ends_at: Date
|
ends_at?: Date
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The associated campaign budget.
|
* The associated campaign budget.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { CampaignBudgetType } from "@medusajs/utils"
|
import { CampaignBudgetType } from "@medusajs/utils"
|
||||||
import { createFindParams, createSelectParams } from "../../utils/validators"
|
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
import { createFindParams, createSelectParams } from "../../utils/validators"
|
||||||
|
|
||||||
export const AdminGetCampaignParams = createSelectParams()
|
export const AdminGetCampaignParams = createSelectParams()
|
||||||
|
|
||||||
@@ -37,8 +37,8 @@ export const AdminCreateCampaign = z.object({
|
|||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
currency: z.string().optional(),
|
currency: z.string().optional(),
|
||||||
budget: CreateCampaignBudget.optional(),
|
budget: CreateCampaignBudget.optional(),
|
||||||
starts_at: z.coerce.date(),
|
starts_at: z.coerce.date().optional(),
|
||||||
ends_at: z.coerce.date(),
|
ends_at: z.coerce.date().optional(),
|
||||||
promotions: z.array(z.object({ id: z.string() })).optional(),
|
promotions: z.array(z.object({ id: z.string() })).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { EOL } from "os"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
|
|
||||||
import Logger from "../loaders/logger"
|
import Logger from "../loaders/logger"
|
||||||
import { resolveAdminCLI } from "./utils/resolve-admin-cli"
|
|
||||||
|
|
||||||
const defaultConfig = {
|
const defaultConfig = {
|
||||||
padding: 5,
|
padding: 5,
|
||||||
@@ -73,21 +72,6 @@ export default async function ({ port, directory }) {
|
|||||||
process.exit(1)
|
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
|
chokidar
|
||||||
.watch(`${directory}/src`, {
|
.watch(`${directory}/src`, {
|
||||||
ignored: `${directory}/src/admin`,
|
ignored: `${directory}/src/admin`,
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { CampaignBudgetTypeValues } from "@medusajs/types"
|
|||||||
import { Campaign } from "@models"
|
import { Campaign } from "@models"
|
||||||
|
|
||||||
export interface CreateCampaignBudgetDTO {
|
export interface CreateCampaignBudgetDTO {
|
||||||
type: CampaignBudgetTypeValues
|
type?: CampaignBudgetTypeValues
|
||||||
limit: number | null
|
limit?: number
|
||||||
used?: number
|
used?: number
|
||||||
campaign?: Campaign | string
|
campaign?: Campaign | string
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,6 @@ export interface CreateCampaignBudgetDTO {
|
|||||||
export interface UpdateCampaignBudgetDTO {
|
export interface UpdateCampaignBudgetDTO {
|
||||||
id: string
|
id: string
|
||||||
type?: CampaignBudgetTypeValues
|
type?: CampaignBudgetTypeValues
|
||||||
limit?: number | null
|
limit?: number
|
||||||
used?: number
|
used?: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ export interface CreateCampaignDTO {
|
|||||||
description?: string
|
description?: string
|
||||||
currency?: string
|
currency?: string
|
||||||
campaign_identifier: string
|
campaign_identifier: string
|
||||||
starts_at: Date
|
starts_at?: Date
|
||||||
ends_at: Date
|
ends_at?: Date
|
||||||
promotions?: (PromotionDTO | Promotion)[]
|
promotions?: (PromotionDTO | Promotion)[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user