From 6da2964998c12bd9bcf8536e2c6fc25fab76e84a Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Thu, 9 May 2024 10:00:28 +0200 Subject: [PATCH] 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 --- .changeset/healthy-shirts-lick.md | 7 + .../public/locales/en-US/translation.json | 49 ++- .../layout/main-layout/main-layout.tsx | 7 +- .../dashboard/src/hooks/api/campaigns.tsx | 33 +- .../use-promotion-table-columns.tsx | 6 - .../columns/use-campaign-table-columns.tsx | 73 ++++ .../filters/use-promotion-table-filters.tsx | 13 + .../table/query/use-campaign-table-query.tsx | 31 ++ .../dashboard/src/lib/client/campaigns.ts | 19 +- .../providers/router-provider/route-map.tsx | 32 ++ .../campaign-budget-edit.tsx | 27 ++ .../edit-campaign-budget-form.tsx | 201 +++++++++ .../edit-campaign-budget-form/index.ts | 1 + .../campaigns/campaign-budget-edit/index.ts | 1 + .../campaign-create/campaign-create.tsx | 10 + .../create-campaign-form.tsx | 387 ++++++++++++++++++ .../components/create-campaign-form/index.ts | 1 + .../campaigns/campaign-create/index.ts | 1 + .../campaign-detail/campaign-detail.tsx | 48 +++ .../campaign-budget/campaign-budget.tsx | 75 ++++ .../components/campaign-budget/index.ts | 1 + .../campaign-general-section.tsx | 161 ++++++++ .../campaign-general-section/index.ts | 1 + .../campaign-promotion-section.tsx | 246 +++++++++++ .../campaign-promotion-section/index.ts | 1 + .../campaign-spend/campaign-spend.tsx | 55 +++ .../components/campaign-spend/index.ts | 1 + .../campaigns/campaign-detail/index.ts | 2 + .../campaigns/campaign-detail/loader.ts | 24 ++ .../campaigns/campaign-edit/campaign-edit.tsx | 27 ++ .../edit-campaign-form/edit-campaign-form.tsx | 239 +++++++++++ .../components/edit-campaign-form/index.ts | 1 + .../campaigns/campaign-edit/index.ts | 1 + .../campaigns/campaign-list/campaign-list.tsx | 11 + .../components/campaign-list-table.tsx | 155 +++++++ .../campaign-list/components/index.ts | 1 + .../campaigns/campaign-list/index.ts | 1 + .../campaigns/common/utils/campaign-status.ts | 31 ++ .../customers/customer-edit/customer-edit.tsx | 2 +- .../add-campaign-promotion-form.tsx | 84 ++-- .../create-promotion-form.tsx | 11 +- .../create-promotion-form/form-schema.ts | 2 +- .../types/src/http/campaign/admin/campaign.ts | 5 +- .../core/types/src/promotion/mutations.ts | 10 +- .../src/api-v2/admin/campaigns/validators.ts | 6 +- packages/medusa/src/commands/develop.js | 16 - .../promotion/src/types/campaign-budget.ts | 6 +- .../modules/promotion/src/types/campaign.ts | 4 +- 48 files changed, 2027 insertions(+), 100 deletions(-) create mode 100644 .changeset/healthy-shirts-lick.md create mode 100644 packages/admin-next/dashboard/src/hooks/table/columns/use-campaign-table-columns.tsx create mode 100644 packages/admin-next/dashboard/src/hooks/table/filters/use-promotion-table-filters.tsx create mode 100644 packages/admin-next/dashboard/src/hooks/table/query/use-campaign-table-query.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-budget-edit/campaign-budget-edit.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-budget-edit/components/edit-campaign-budget-form/edit-campaign-budget-form.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-budget-edit/components/edit-campaign-budget-form/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-budget-edit/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-create/campaign-create.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-create/components/create-campaign-form/create-campaign-form.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-create/components/create-campaign-form/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-create/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/campaign-detail.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-budget/campaign-budget.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-budget/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-general-section/campaign-general-section.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-general-section/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-promotion-section/campaign-promotion-section.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-promotion-section/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-spend/campaign-spend.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-spend/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/loader.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-edit/campaign-edit.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-edit/components/edit-campaign-form/edit-campaign-form.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-edit/components/edit-campaign-form/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-edit/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-list/campaign-list.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-list/components/campaign-list-table.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-list/components/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-list/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/common/utils/campaign-status.ts diff --git a/.changeset/healthy-shirts-lick.md b/.changeset/healthy-shirts-lick.md new file mode 100644 index 0000000000..2e71228a77 --- /dev/null +++ b/.changeset/healthy-shirts-lick.md @@ -0,0 +1,7 @@ +--- +"@medusajs/promotion": patch +"@medusajs/types": patch +"@medusajs/medusa": patch +--- + +feat(dashboard,core,medusa,promotion): add campaigns UI diff --git a/packages/admin-next/dashboard/public/locales/en-US/translation.json b/packages/admin-next/dashboard/public/locales/en-US/translation.json index 1a6698762d..591ba613e4 100644 --- a/packages/admin-next/dashboard/public/locales/en-US/translation.json +++ b/packages/admin-next/dashboard/public/locales/en-US/translation.json @@ -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}} <1>{{currency}}" }, "pricing": { "domain": "Pricing", diff --git a/packages/admin-next/dashboard/src/components/layout/main-layout/main-layout.tsx b/packages/admin-next/dashboard/src/components/layout/main-layout/main-layout.tsx index 52abfc5c8c..68cfb2649f 100644 --- a/packages/admin-next/dashboard/src/components/layout/main-layout/main-layout.tsx +++ b/packages/admin-next/dashboard/src/components/layout/main-layout/main-layout.tsx @@ -2,7 +2,6 @@ import { Buildings, ChevronDownMini, CurrencyDollar, - Envelope, MinusMini, ReceiptPercent, ShoppingCart, @@ -141,6 +140,12 @@ const useCoreRoutes = (): Omit[] => { icon: , label: t("promotions.domain"), to: "/promotions", + items: [ + { + label: t("campaigns.domain"), + to: "/campaigns", + }, + ], }, { icon: , diff --git a/packages/admin-next/dashboard/src/hooks/api/campaigns.tsx b/packages/admin-next/dashboard/src/hooks/api/campaigns.tsx index 53c20c3721..d7add98267 100644 --- a/packages/admin-next/dashboard/src/hooks/api/campaigns.tsx +++ b/packages/admin-next/dashboard/src/hooks/api/campaigns.tsx @@ -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, options?: Omit< - UseQueryOptions, + 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, options?: Omit< - UseQueryOptions, + UseQueryOptions< + AdminCampaignListResponse, + Error, + AdminCampaignListResponse, + QueryKey + >, "queryFn" | "queryKey" > ) => { @@ -51,7 +62,7 @@ export const useCampaigns = ( } export const useCreateCampaign = ( - options?: UseMutationOptions + options?: UseMutationOptions ) => { return useMutation({ mutationFn: (payload) => client.campaigns.create(payload), @@ -65,7 +76,7 @@ export const useCreateCampaign = ( export const useUpdateCampaign = ( id: string, - options?: UseMutationOptions + options?: UseMutationOptions ) => { return useMutation({ mutationFn: (payload) => client.campaigns.update(id, payload), diff --git a/packages/admin-next/dashboard/src/hooks/table/columns-v2/use-promotion-table-columns.tsx b/packages/admin-next/dashboard/src/hooks/table/columns-v2/use-promotion-table-columns.tsx index 94cbef0390..9ebc41bbc1 100644 --- a/packages/admin-next/dashboard/src/hooks/table/columns-v2/use-promotion-table-columns.tsx +++ b/packages/admin-next/dashboard/src/hooks/table/columns-v2/use-promotion-table-columns.tsx @@ -26,12 +26,6 @@ export const usePromotionTableColumns = () => { cell: ({ row }) => , }), - columnHelper.display({ - id: "campaign", - header: () => , - cell: ({ row }) => , - }), - columnHelper.display({ id: "method", header: () => , diff --git a/packages/admin-next/dashboard/src/hooks/table/columns/use-campaign-table-columns.tsx b/packages/admin-next/dashboard/src/hooks/table/columns/use-campaign-table-columns.tsx new file mode 100644 index 0000000000..c53a482df4 --- /dev/null +++ b/packages/admin-next/dashboard/src/hooks/table/columns/use-campaign-table-columns.tsx @@ -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() + +export const useCampaignTableColumns = () => { + const { t } = useTranslation() + + return useMemo( + () => [ + columnHelper.accessor("name", { + header: () => , + cell: ({ getValue }) => , + }), + columnHelper.accessor("description", { + header: () => , + cell: ({ getValue }) => , + }), + columnHelper.accessor("campaign_identifier", { + header: () => , + cell: ({ getValue }) => { + const value = getValue() + return + }, + }), + columnHelper.accessor("starts_at", { + header: () => , + cell: ({ getValue }) => { + const value = getValue() + + if (!value) { + return + } + + const date = new Date(value) + + return + }, + }), + columnHelper.accessor("ends_at", { + header: () => , + cell: ({ getValue }) => { + const value = getValue() + + if (!value) { + return + } + + const date = new Date(value) + + return + }, + }), + ], + [t] + ) +} diff --git a/packages/admin-next/dashboard/src/hooks/table/filters/use-promotion-table-filters.tsx b/packages/admin-next/dashboard/src/hooks/table/filters/use-promotion-table-filters.tsx new file mode 100644 index 0000000000..246191a7cc --- /dev/null +++ b/packages/admin-next/dashboard/src/hooks/table/filters/use-promotion-table-filters.tsx @@ -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 +} diff --git a/packages/admin-next/dashboard/src/hooks/table/query/use-campaign-table-query.tsx b/packages/admin-next/dashboard/src/hooks/table/query/use-campaign-table-query.tsx new file mode 100644 index 0000000000..d7951431b0 --- /dev/null +++ b/packages/admin-next/dashboard/src/hooks/table/query/use-campaign-table-query.tsx @@ -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, + } +} diff --git a/packages/admin-next/dashboard/src/lib/client/campaigns.ts b/packages/admin-next/dashboard/src/lib/client/campaigns.ts index eacb8aee0a..50b17a0571 100644 --- a/packages/admin-next/dashboard/src/lib/client/campaigns.ts +++ b/packages/admin-next/dashboard/src/lib/client/campaigns.ts @@ -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) { - return getRequest(`/admin/campaigns/${id}`, query) + return getRequest(`/admin/campaigns/${id}`, query) } async function listCampaigns(query?: Record) { - return getRequest(`/admin/campaigns`, query) + return getRequest(`/admin/campaigns`, query) } async function createCampaign(payload: CreateCampaignDTO) { - return postRequest(`/admin/campaigns`, payload) + return postRequest(`/admin/campaigns`, payload) } async function updateCampaign(id: string, payload: UpdateCampaignDTO) { - return postRequest(`/admin/campaigns/${id}`, payload) + return postRequest(`/admin/campaigns/${id}`, payload) } async function deleteCampaign(id: string) { diff --git a/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx b/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx index fe0cf44d9c..4128f1ed09 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx @@ -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: { diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-budget-edit/campaign-budget-edit.tsx b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-budget-edit/campaign-budget-edit.tsx new file mode 100644 index 0000000000..286414017e --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-budget-edit/campaign-budget-edit.tsx @@ -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 ( + + + {t("campaigns.edit.header")} + + + {!isLoading && campaign && } + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-budget-edit/components/edit-campaign-budget-form/edit-campaign-budget-form.tsx b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-budget-edit/components/edit-campaign-budget-form/edit-campaign-budget-form.tsx new file mode 100644 index 0000000000..7257e1a008 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-budget-edit/components/edit-campaign-budget-form/edit-campaign-budget-form.tsx @@ -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>({ + 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 ( + +
+ +
+ { + return ( + + {t("campaigns.budget.fields.type")} + + + + + + + + + + + ) + }} + /> + + { + return ( + + + {t("campaigns.budget.fields.limit")} + + + + {isTypeSpend ? ( + + onChange(value ? parseInt(value) : "") + } + code={campaign.currency} + symbol={getCurrencySymbol(campaign.currency)} + {...field} + value={value} + /> + ) : ( + { + onChange( + e.target.value === "" + ? null + : parseInt(e.target.value) + ) + }} + /> + )} + + + + ) + }} + /> +
+
+ + +
+ + + + + +
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-budget-edit/components/edit-campaign-budget-form/index.ts b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-budget-edit/components/edit-campaign-budget-form/index.ts new file mode 100644 index 0000000000..4dcee0c541 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-budget-edit/components/edit-campaign-budget-form/index.ts @@ -0,0 +1 @@ +export * from "./edit-campaign-budget-form" diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-budget-edit/index.ts b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-budget-edit/index.ts new file mode 100644 index 0000000000..e99fc306d9 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-budget-edit/index.ts @@ -0,0 +1 @@ +export { CampaignBudgetEdit as Component } from "./campaign-budget-edit" diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-create/campaign-create.tsx b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-create/campaign-create.tsx new file mode 100644 index 0000000000..d6a0a9e559 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-create/campaign-create.tsx @@ -0,0 +1,10 @@ +import { RouteFocusModal } from "../../../components/route-modal" +import { CreateCampaignForm } from "./components/create-campaign-form" + +export const CampaignCreate = () => { + return ( + + + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-create/components/create-campaign-form/create-campaign-form.tsx b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-create/components/create-campaign-form/create-campaign-form.tsx new file mode 100644 index 0000000000..ce6042c943 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-create/components/create-campaign-form/create-campaign-form.tsx @@ -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>({ + 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 ( + +
+ +
+ + + + + +
+
+ + +
+
+ {t("campaigns.create.header")} + + {t("campaigns.create.hint")} + +
+ +
+ { + return ( + + {t("fields.name")} + + + + + + + + ) + }} + /> + + { + return ( + + {t("fields.description")} + + + + + + + + ) + }} + /> + + { + return ( + + + {t("campaigns.fields.identifier")} + + + + + + + + + ) + }} + /> + + { + return ( + + {t("fields.currency")} + + + + + + ) + }} + /> + + { + return ( + + + {t("campaigns.fields.start_date")} + + + + { + onChange(v ?? null) + }} + {...field} + /> + + + + + ) + }} + /> + + { + return ( + + {t("campaigns.fields.end_date")} + + + onChange(v ?? null)} + {...field} + /> + + + + + ) + }} + /> +
+ +
+ {t("campaigns.budget.create.header")} + + {t("campaigns.budget.create.hint")} + +
+ + { + return ( + + {t("campaigns.budget.fields.type")} + + + + + + + + + + + ) + }} + /> + +
+ { + return ( + + + {t("campaigns.budget.fields.limit")} + + + + {isTypeSpend ? ( + + onChange(value ? parseInt(value) : "") + } + code={currencyValue} + symbol={ + currencyValue + ? getCurrencySymbol(currencyValue) + : "" + } + {...field} + value={value} + /> + ) : ( + { + onChange( + e.target.value === "" + ? null + : parseInt(e.target.value) + ) + }} + /> + )} + + + + ) + }} + /> +
+
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-create/components/create-campaign-form/index.ts b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-create/components/create-campaign-form/index.ts new file mode 100644 index 0000000000..1de5889634 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-create/components/create-campaign-form/index.ts @@ -0,0 +1 @@ +export * from "./create-campaign-form" diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-create/index.ts b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-create/index.ts new file mode 100644 index 0000000000..10ac939f0f --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-create/index.ts @@ -0,0 +1 @@ +export { CampaignCreate as Component } from "./campaign-create" diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/campaign-detail.tsx b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/campaign-detail.tsx new file mode 100644 index 0000000000..6120e12c96 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/campaign-detail.tsx @@ -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 + > + + const { id } = useParams() + const { campaign, isLoading, isError, error } = useCampaign( + id!, + { fields: "+promotions.id" }, + { initialData } + ) + + if (isLoading || !campaign) { + return
Loading...
+ } + + if (isError) { + throw error + } + + return ( +
+
+
+ + {/* TODO: enable this when missing APIs are available */} + {/* */} + +
+ +
+ + +
+
+ + +
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-budget/campaign-budget.tsx b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-budget/campaign-budget.tsx new file mode 100644 index 0000000000..7fa9bc297b --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-budget/campaign-budget.tsx @@ -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 ( + +
+
+
+
+ +
+
+ + + {t("campaigns.fields.budget_limit")} + +
+ + , + label: t("actions.edit"), + to: `edit-budget`, + }, + ], + }, + ]} + /> +
+ +
+ + , + , + ]} + /> + +
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-budget/index.ts b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-budget/index.ts new file mode 100644 index 0000000000..bcf0c7fe7d --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-budget/index.ts @@ -0,0 +1 @@ +export * from "./campaign-budget" diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-general-section/campaign-general-section.tsx b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-general-section/campaign-general-section.tsx new file mode 100644 index 0000000000..0549b53821 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-general-section/campaign-general-section.tsx @@ -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 ( + +
+ {campaign.name} + +
+ + {t(`campaigns.status.${status}`)} + + + , + label: t("actions.edit"), + to: `/campaigns/${campaign.id}/edit`, + }, + ], + }, + { + actions: [ + { + icon: , + label: t("actions.delete"), + onClick: handleDelete, + }, + ], + }, + ]} + /> +
+
+ +
+ + {t("campaigns.fields.identifier")} + + + + {campaign.campaign_identifier} + +
+ +
+ + {t("fields.description")} + + + + {campaign.description || "-"} + +
+ +
+ + {t("fields.currency")} + + +
+ {campaign.currency} + + {currencies[campaign.currency]?.name} + +
+
+ +
+ + {t("campaigns.fields.start_date")} + + + + {campaign.starts_at ? formatDate(campaign.starts_at) : "-"} + +
+ +
+ + {t("campaigns.fields.end_date")} + + + + {campaign.starts_at ? formatDate(campaign.ends_at) : "-"} + +
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-general-section/index.ts b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-general-section/index.ts new file mode 100644 index 0000000000..0e0d68d08a --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-general-section/index.ts @@ -0,0 +1 @@ +export * from "./campaign-general-section" diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-promotion-section/campaign-promotion-section.tsx b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-promotion-section/campaign-promotion-section.tsx new file mode 100644 index 0000000000..8f4f6d9868 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-promotion-section/campaign-promotion-section.tsx @@ -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({}) + 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 ( + +
+ {t("promotions.domain")} + + + +
+ + `/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", + }, + ]} + /> +
+ ) +} + +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 ( + , + label: t("actions.edit"), + to: `/promotions/${promotion.id}/edit`, + }, + ], + }, + { + actions: [ + { + icon: , + label: t("actions.remove"), + onClick: handleRemove, + }, + ], + }, + ]} + /> + ) +} + +const columnHelper = createColumnHelper() + +const useColumns = () => { + const columns = usePromotionTableColumns() + + return useMemo( + () => [ + columnHelper.display({ + id: "select", + header: ({ table }) => { + return ( + + table.toggleAllPageRowsSelected(!!value) + } + /> + ) + }, + cell: ({ row }) => { + return ( + row.toggleSelected(!!value)} + onClick={(e) => { + e.stopPropagation() + }} + /> + ) + }, + }), + ...columns, + columnHelper.display({ + id: "actions", + cell: ({ row, table }) => { + const { campaignId } = table.options.meta as { + campaignId: string + } + + return ( + + ) + }, + }), + ], + [columns] + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-promotion-section/index.ts b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-promotion-section/index.ts new file mode 100644 index 0000000000..7dcc64f5f6 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-promotion-section/index.ts @@ -0,0 +1 @@ +export * from "./campaign-promotion-section" diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-spend/campaign-spend.tsx b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-spend/campaign-spend.tsx new file mode 100644 index 0000000000..989c544a03 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-spend/campaign-spend.tsx @@ -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 ( + +
+
+
+ +
+
+ + + {t("campaigns.fields.total_spend")} + +
+ +
+ + , + , + ]} + /> + +
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-spend/index.ts b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-spend/index.ts new file mode 100644 index 0000000000..a2cb55ae2b --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-spend/index.ts @@ -0,0 +1 @@ +export * from "./campaign-spend" diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/index.ts b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/index.ts new file mode 100644 index 0000000000..d44b9e434d --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/index.ts @@ -0,0 +1,2 @@ +export { CampaignDetail as Component } from "./campaign-detail" +export { campaignLoader as loader } from "./loader" diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/loader.ts b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/loader.ts new file mode 100644 index 0000000000..1e71362ce6 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/loader.ts @@ -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>(query.queryKey) ?? + (await queryClient.fetchQuery(query)) + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-edit/campaign-edit.tsx b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-edit/campaign-edit.tsx new file mode 100644 index 0000000000..30ad9cf63e --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-edit/campaign-edit.tsx @@ -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 ( + + + {t("campaigns.edit.header")} + + + {!isLoading && campaign && } + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-edit/components/edit-campaign-form/edit-campaign-form.tsx b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-edit/components/edit-campaign-form/edit-campaign-form.tsx new file mode 100644 index 0000000000..34a0fce6fc --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-edit/components/edit-campaign-form/edit-campaign-form.tsx @@ -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>({ + 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 ( + +
+ +
+ { + return ( + + {t("fields.name")} + + + + + + + + ) + }} + /> + + { + return ( + + {t("fields.description")} + + + + + + + + ) + }} + /> + + { + return ( + + {t("campaigns.fields.identifier")} + + + + + + + + ) + }} + /> + + { + return ( + + {t("fields.currency")} + + + + + + ) + }} + /> + + { + return ( + + {t("campaigns.fields.start_date")} + + + { + onChange(v ?? null) + }} + {...field} + /> + + + + + ) + }} + /> + + { + return ( + + {t("campaigns.fields.end_date")} + + + onChange(v ?? null)} + {...field} + /> + + + + + ) + }} + /> +
+
+ + +
+ + + + + +
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-edit/components/edit-campaign-form/index.ts b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-edit/components/edit-campaign-form/index.ts new file mode 100644 index 0000000000..3008a60d00 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-edit/components/edit-campaign-form/index.ts @@ -0,0 +1 @@ +export * from "./edit-campaign-form" diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-edit/index.ts b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-edit/index.ts new file mode 100644 index 0000000000..75d8a5df3f --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-edit/index.ts @@ -0,0 +1 @@ +export { CampaignEdit as Component } from "./campaign-edit" diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-list/campaign-list.tsx b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-list/campaign-list.tsx new file mode 100644 index 0000000000..1c9177279e --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-list/campaign-list.tsx @@ -0,0 +1,11 @@ +import { Outlet } from "react-router-dom" +import { CampaignListTable } from "./components/campaign-list-table" + +export const CampaignList = () => { + return ( +
+ + +
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-list/components/campaign-list-table.tsx b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-list/components/campaign-list-table.tsx new file mode 100644 index 0000000000..aa37a71e20 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-list/components/campaign-list-table.tsx @@ -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 ( + +
+ {t("campaigns.domain")} + + + +
+ + row.id} + isLoading={isLoading} + queryObject={raw} + orderBy={["name", "created_at", "updated_at"]} + /> +
+ ) +} + +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 ( + , + label: t("actions.edit"), + to: `/campaigns/${campaign.id}/edit`, + }, + ], + }, + { + actions: [ + { + icon: , + label: t("actions.delete"), + onClick: handleDelete, + }, + ], + }, + ]} + /> + ) +} + +const columnHelper = createColumnHelper() + +const useColumns = () => { + const base = useCampaignTableColumns() + + return useMemo( + () => [ + ...base, + columnHelper.display({ + id: "actions", + cell: ({ row }) => { + return + }, + }), + ], + [base] + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-list/components/index.ts b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-list/components/index.ts new file mode 100644 index 0000000000..fbd56f6bea --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-list/components/index.ts @@ -0,0 +1 @@ +export * from "./campaign-list-table" diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-list/index.ts b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-list/index.ts new file mode 100644 index 0000000000..9cf85088aa --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-list/index.ts @@ -0,0 +1 @@ +export { CampaignList as Component } from "./campaign-list" diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/common/utils/campaign-status.ts b/packages/admin-next/dashboard/src/v2-routes/campaigns/common/utils/campaign-status.ts new file mode 100644 index 0000000000..bfe15a78b2 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/common/utils/campaign-status.ts @@ -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" + } +} diff --git a/packages/admin-next/dashboard/src/v2-routes/customers/customer-edit/customer-edit.tsx b/packages/admin-next/dashboard/src/v2-routes/customers/customer-edit/customer-edit.tsx index 6eca5eb4cf..8834b8cbfd 100644 --- a/packages/admin-next/dashboard/src/v2-routes/customers/customer-edit/customer-edit.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/customers/customer-edit/customer-edit.tsx @@ -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() diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/components/add-campaign-promotion-form/add-campaign-promotion-form.tsx b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/components/add-campaign-promotion-form/add-campaign-promotion-form.tsx index adaeb1acad..752e1e1487 100644 --- a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/components/add-campaign-promotion-form/add-campaign-promotion-form.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/components/add-campaign-promotion-form/add-campaign-promotion-form.tsx @@ -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 (
{ return ( @@ -49,23 +54,34 @@ export const AddCampaignPromotionFields = ({ form, campaigns }) => { onValueChange={field.onChange} > + + + @@ -78,36 +94,38 @@ export const AddCampaignPromotionFields = ({ form, campaigns }) => { }} /> - { - return ( - - - {t("promotions.form.campaign.existing.title")} - + {watchCampaignChoice === "existing" && ( + { + return ( + + + {t("promotions.form.campaign.existing.title")} + - - + + + - - {campaigns.map((c) => ( - - {c.name?.toUpperCase()} - - ))} - - - - - - ) - }} - /> + + {campaigns.map((c) => ( + + {c.name?.toUpperCase()} + + ))} + + + + + + ) + }} + /> + )}
diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx index 6074a5462a..3c9ab80493 100644 --- a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx @@ -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 ( {t("promotions.fields.type")} + Method +