diff --git a/.changeset/quick-jeans-push.md b/.changeset/quick-jeans-push.md new file mode 100644 index 0000000000..59ce3bc9c4 --- /dev/null +++ b/.changeset/quick-jeans-push.md @@ -0,0 +1,6 @@ +--- +"medusa-react": patch +"@medusajs/medusa": patch +--- + +feat(dashboard): added details page for promotions diff --git a/integration-tests/modules/__tests__/pricing/admin/rule-types.spec.ts b/integration-tests/modules/__tests__/pricing/admin/rule-types.spec.ts index 190840cf0c..d33c11078d 100644 --- a/integration-tests/modules/__tests__/pricing/admin/rule-types.spec.ts +++ b/integration-tests/modules/__tests__/pricing/admin/rule-types.spec.ts @@ -194,7 +194,7 @@ medusaIntegrationTestRunner({ `/admin/pricing/rule-types/${ruleType.id}`, adminHeaders ) - console.log("response.data -- ", response.data) + expect(response.status).toEqual(200) expect(response.data).toEqual({ id: ruleType.id, diff --git a/packages/admin-next/dashboard/public/locales/$schema.json b/packages/admin-next/dashboard/public/locales/$schema.json index d391cfc9bd..98af3952a9 100644 --- a/packages/admin-next/dashboard/public/locales/$schema.json +++ b/packages/admin-next/dashboard/public/locales/$schema.json @@ -140,6 +140,15 @@ }, "required": ["domain"] }, + "campaigns": { + "type": "object", + "properties": { + "domain": { + "type": "string" + } + }, + "required": ["domain"] + }, "giftCards": { "type": "object", "properties": { 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 9cd31cc4f1..a0b8c7cab4 100644 --- a/packages/admin-next/dashboard/public/locales/en-US/translation.json +++ b/packages/admin-next/dashboard/public/locales/en-US/translation.json @@ -30,6 +30,7 @@ "items_one": "{{count}} item", "items_other": "{{count}} items", "countSelected": "{{count}} selected", + "plusCount": "+ {{count}}", "plusCountMore": "+ {{count}} more", "areYouSure": "Are you sure?", "noRecordsFound": "No records found", @@ -611,12 +612,116 @@ "domain": "Promotions", "fields": { "method": "Method", - "campaign": "Campaign" + "type": "Type", + "value_type": "Value Type", + "value": "Value", + "campaign": "Campaign", + "allocation": "Allocation", + "conditions": { + "rules": { + "title": "Who can use this code?", + "description": "Is the customer allowed to add the promotion code? Discount code can be used by all customers if left untouched. Choose between attributes, operators, and values to set up the conditions." + }, + "target-rules": { + "title": "What needs to be in the cart to unlock the promotion?", + "description": "If these conditions match, we enable a promotion action on the target items. Choose between attributes, operators, and values to set up the conditions." + }, + "buy-rules": { + "title": "What will the promotion be applied to?", + "description": "The promotion will be applied to items that match these conditions" + } + } + }, + "edit": { + "title": "Edit Promotion Details", + "rules": { + "title": "Edit rules" + }, + "target-rules": { + "title": "Edit target rules" + }, + "buy-rules": { + "title": "Edit buy rules" + } + }, + "addToCampaign": { + "title": "Add Promotion To Campaign" + }, + "form": { + "campaign": { + "existing": { + "title": "Existing Campaign", + "description": "Would you like to add promotion to an existing campaign?" + }, + "new": { + "title": "New Campaign", + "description": "Would you like to create a new campaign with this promotion?" + } + }, + "status": { + "title": "Status" + }, + "method": { + "code": { + "title": "Promotion code", + "description": "Customers must enter this at checkout" + }, + "automatic": { + "title": "Automatic", + "description": "Customers will automatically see this during checkout" + } + }, + "allocation": { + "each": { + "title": "Each", + "description": "Applies value on each product" + }, + "across": { + "title": "Across", + "description": "Applies value proportionally across products" + } + }, + "code": { + "title": "Code", + "description": "The code your customers will enter during checkout." + }, + "value": { + "title": "Value" + }, + "value_type": { + "fixed": { + "title": "Fixed amount", + "description": "eg. 100" + }, + "percentage": { + "title": "Percentage", + "description": "eg. 8%" + } + } }, "deleteWarning": "You are about to delete the promotion {{code}}. This action cannot be undone.", "createPromotionTitle": "Create Promotion", "type": "Promotion type" }, + "campaigns": { + "domain": "Campaigns", + "details": "Campaign details", + "fields": { + "name": "Name", + "identifier": "Identifier", + "start_date": "Start date", + "end_date": "End date" + }, + "budget": { + "details": "Campaign budget", + "fields": { + "type": "Type", + "currency": "Currency", + "limit": "Limit", + "used": "Used" + } + } + }, "pricing": { "domain": "Pricing", "deletePriceListWarning": "You are about to delete the price list {{name}}. This action cannot be undone.", diff --git a/packages/admin-next/dashboard/src/components/common/badge-list-summary/badge-list-summary.tsx b/packages/admin-next/dashboard/src/components/common/badge-list-summary/badge-list-summary.tsx new file mode 100644 index 0000000000..eb0887e210 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/badge-list-summary/badge-list-summary.tsx @@ -0,0 +1,73 @@ +import { Badge, Tooltip, clx } from "@medusajs/ui" +import { useTranslation } from "react-i18next" + +type BadgeListSummaryProps = { + /** + * Number of initial items to display + * @default 2 + */ + n?: number + /** + * List of strings to display as abbreviated list + */ + list: string[] + /** + * Is the summary displayed inline. + * Determines whether the center text is truncated if there is no space in the container + */ + inline?: boolean + + className?: string +} + +export const BadgeListSummary = ({ + list, + className, + inline, + n = 2, +}: BadgeListSummaryProps) => { + const { t } = useTranslation() + + const title = t("general.plusCount", { + count: list.length - n, + }) + + return ( +
+ {list.slice(0, n).map((item) => { + return ( + + {item} + + ) + })} + + {list.length > n && ( +
+ + {list.slice(n).map((c) => ( +
  • {c}
  • + ))} + + } + > + + {title} + +
    +
    + )} +
    + ) +} diff --git a/packages/admin-next/dashboard/src/components/common/badge-list-summary/index.ts b/packages/admin-next/dashboard/src/components/common/badge-list-summary/index.ts new file mode 100644 index 0000000000..a156dab659 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/badge-list-summary/index.ts @@ -0,0 +1 @@ +export * from "./badge-list-summary" diff --git a/packages/admin-next/dashboard/src/components/common/empty-table-content/empty-table-content.tsx b/packages/admin-next/dashboard/src/components/common/empty-table-content/empty-table-content.tsx index 76a50895c0..5aac770ac7 100644 --- a/packages/admin-next/dashboard/src/components/common/empty-table-content/empty-table-content.tsx +++ b/packages/admin-next/dashboard/src/components/common/empty-table-content/empty-table-content.tsx @@ -1,4 +1,4 @@ -import { ExclamationCircle, MagnifyingGlass } from "@medusajs/icons" +import { ExclamationCircle, MagnifyingGlass, PlusMini } from "@medusajs/icons" import { Button, Text, clx } from "@medusajs/ui" import { useTranslation } from "react-i18next" import { Link } from "react-router-dom" @@ -32,21 +32,44 @@ export const NoResults = ({ title, message, className }: NoResultsProps) => { ) } -type NoRecordsProps = { - title?: string - message?: string +type ActionProps = { action?: { to: string label: string } - className?: string } +type NoRecordsProps = { + title?: string + message?: string + className?: string + buttonVariant?: string +} & ActionProps + +const DefaultButton = ({ action }: ActionProps) => + action && ( + + + + ) + +const TransparentIconLeftButton = ({ action }: ActionProps) => + action && ( + + + + ) + export const NoRecords = ({ title, message, action, className, + buttonVariant = "default", }: NoRecordsProps) => { const { t } = useTranslation() @@ -59,19 +82,19 @@ export const NoRecords = ({ >
    + {title ?? t("general.noRecordsTitle")} + {message ?? t("general.noRecordsMessage")}
    - {action && ( - - - + + {buttonVariant === "default" && } + {buttonVariant === "transparentIconLeft" && ( + )} ) diff --git a/packages/admin-next/dashboard/src/hooks/api/campaigns.tsx b/packages/admin-next/dashboard/src/hooks/api/campaigns.tsx new file mode 100644 index 0000000000..53c20c3721 --- /dev/null +++ b/packages/admin-next/dashboard/src/hooks/api/campaigns.tsx @@ -0,0 +1,96 @@ +import { + QueryKey, + UseMutationOptions, + UseQueryOptions, + useMutation, + useQuery, +} from "@tanstack/react-query" +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" + +const REGIONS_QUERY_KEY = "campaigns" as const +const campaignsQueryKeys = queryKeysFactory(REGIONS_QUERY_KEY) + +export const useCampaign = ( + id: string, + options?: Omit< + UseQueryOptions, + "queryFn" | "queryKey" + > +) => { + const { data, ...rest } = useQuery({ + queryKey: campaignsQueryKeys.detail(id), + queryFn: async () => client.campaigns.retrieve(id), + ...options, + }) + + return { ...data, ...rest } +} + +export const useCampaigns = ( + query?: Record, + options?: Omit< + UseQueryOptions, + "queryFn" | "queryKey" + > +) => { + const { data, ...rest } = useQuery({ + queryFn: () => client.campaigns.list(query), + queryKey: campaignsQueryKeys.list(query), + ...options, + }) + + return { ...data, ...rest } +} + +export const useCreateCampaign = ( + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: (payload) => client.campaigns.create(payload), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: campaignsQueryKeys.lists() }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useUpdateCampaign = ( + id: string, + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: (payload) => client.campaigns.update(id, payload), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: campaignsQueryKeys.lists() }) + queryClient.invalidateQueries({ queryKey: campaignsQueryKeys.detail(id) }) + + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useDeleteCampaign = ( + id: string, + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: () => client.campaigns.delete(id), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: campaignsQueryKeys.lists() }) + queryClient.invalidateQueries({ queryKey: campaignsQueryKeys.detail(id) }) + + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/packages/admin-next/dashboard/src/hooks/api/promotions.tsx b/packages/admin-next/dashboard/src/hooks/api/promotions.tsx index 1ee3ade187..81b3820b4b 100644 --- a/packages/admin-next/dashboard/src/hooks/api/promotions.tsx +++ b/packages/admin-next/dashboard/src/hooks/api/promotions.tsx @@ -1,12 +1,47 @@ -import { QueryKey, UseQueryOptions, useQuery } from "@tanstack/react-query" - import { AdminGetPromotionsParams } from "@medusajs/medusa" +import { + QueryKey, + useMutation, + UseMutationOptions, + useQuery, + UseQueryOptions, +} from "@tanstack/react-query" import { client } from "../../lib/client" +import { queryClient } from "../../lib/medusa" import { queryKeysFactory } from "../../lib/query-key-factory" -import { PromotionListRes, PromotionRes } from "../../types/api-responses" +import { + BatchAddPromotionRulesReq, + BatchRemovePromotionRulesReq, + BatchUpdatePromotionRulesReq, + CreatePromotionReq, + UpdatePromotionReq, +} from "../../types/api-payloads" +import { + PromotionDeleteRes, + PromotionListRes, + PromotionRes, + PromotionRuleAttributesListRes, + PromotionRuleOperatorsListRes, + PromotionRulesListRes, + PromotionRuleValuesListRes, +} from "../../types/api-responses" const PROMOTIONS_QUERY_KEY = "promotions" as const -const promotionsQueryKeys = queryKeysFactory(PROMOTIONS_QUERY_KEY) +export const promotionsQueryKeys = { + ...queryKeysFactory(PROMOTIONS_QUERY_KEY), + listRules: (id: string, ruleType: string) => [ + PROMOTIONS_QUERY_KEY, + id, + ruleType, + ], + listRuleAttributes: (ruleType: string) => [PROMOTIONS_QUERY_KEY, ruleType], + listRuleValues: (ruleType: string, ruleValue: string) => [ + PROMOTIONS_QUERY_KEY, + ruleType, + ruleValue, + ], + listRuleOperators: () => [PROMOTIONS_QUERY_KEY], +} export const usePromotion = ( id: string, @@ -24,6 +59,28 @@ export const usePromotion = ( return { ...data, ...rest } } +export const usePromotionRules = ( + id: string, + ruleType: string, + options?: Omit< + UseQueryOptions< + PromotionRulesListRes, + Error, + PromotionRulesListRes, + QueryKey + >, + "queryFn" | "queryKey" + > +) => { + const { data, ...rest } = useQuery({ + queryKey: promotionsQueryKeys.listRules(id, ruleType), + queryFn: async () => client.promotions.listRules(id, ruleType), + ...options, + }) + + return { ...data, ...rest } +} + export const usePromotions = ( query?: AdminGetPromotionsParams, options?: Omit< @@ -39,3 +96,185 @@ export const usePromotions = ( return { ...data, ...rest } } + +export const usePromotionRuleOperators = ( + options?: Omit< + UseQueryOptions< + PromotionListRes, + Error, + PromotionRuleOperatorsListRes, + QueryKey + >, + "queryFn" | "queryKey" + > +) => { + const { data, ...rest } = useQuery({ + queryKey: promotionsQueryKeys.listRuleOperators(), + queryFn: async () => client.promotions.listRuleOperators(), + ...options, + }) + + return { ...data, ...rest } +} + +export const usePromotionRuleAttributes = ( + ruleType: string, + options?: Omit< + UseQueryOptions< + PromotionListRes, + Error, + PromotionRuleAttributesListRes, + QueryKey + >, + "queryFn" | "queryKey" + > +) => { + const { data, ...rest } = useQuery({ + queryKey: promotionsQueryKeys.listRuleAttributes(ruleType), + queryFn: async () => client.promotions.listRuleAttributes(ruleType), + ...options, + }) + + return { ...data, ...rest } +} + +export const usePromotionRuleValues = ( + ruleType: string, + ruleValue: string, + options?: Omit< + UseQueryOptions< + PromotionListRes, + Error, + PromotionRuleValuesListRes, + QueryKey + >, + "queryFn" | "queryKey" + > +) => { + const { data, ...rest } = useQuery({ + queryKey: promotionsQueryKeys.listRuleValues(ruleType, ruleValue), + queryFn: async () => client.promotions.listRuleValues(ruleType, ruleValue), + ...options, + }) + + return { ...data, ...rest } +} + +export const useDeletePromotion = ( + id: string, + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: () => client.promotions.delete(id), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: promotionsQueryKeys.lists() }) + queryClient.invalidateQueries({ + queryKey: promotionsQueryKeys.detail(id), + }) + + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useCreatePromotion = ( + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: (payload) => client.promotions.create(payload), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: promotionsQueryKeys.lists() }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useUpdatePromotion = ( + id: string, + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: (payload) => client.promotions.update(id, payload), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: promotionsQueryKeys.lists() }) + queryClient.invalidateQueries({ + queryKey: promotionsQueryKeys.detail(id), + }) + + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const usePromotionAddRules = ( + id: string, + ruleType: string, + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: (payload) => client.promotions.addRules(id, ruleType, payload), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: promotionsQueryKeys.lists() }) + queryClient.invalidateQueries({ + queryKey: promotionsQueryKeys.detail(id), + }) + + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const usePromotionRemoveRules = ( + id: string, + ruleType: string, + options?: UseMutationOptions< + PromotionRes, + Error, + BatchRemovePromotionRulesReq + > +) => { + return useMutation({ + mutationFn: (payload) => + client.promotions.removeRules(id, ruleType, payload), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: promotionsQueryKeys.lists() }) + queryClient.invalidateQueries({ + queryKey: promotionsQueryKeys.detail(id), + }) + + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const usePromotionUpdateRules = ( + id: string, + ruleType: string, + options?: UseMutationOptions< + PromotionRes, + Error, + BatchUpdatePromotionRulesReq + > +) => { + return useMutation({ + mutationFn: (payload) => + client.promotions.updateRules(id, ruleType, payload), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: promotionsQueryKeys.lists() }) + queryClient.invalidateQueries({ + queryKey: promotionsQueryKeys.listRules(id, ruleType), + }) + queryClient.invalidateQueries({ + queryKey: promotionsQueryKeys.detail(id), + }) + + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/packages/admin-next/dashboard/src/lib/api-v2/campaign.ts b/packages/admin-next/dashboard/src/lib/api-v2/campaign.ts new file mode 100644 index 0000000000..ed50e576f9 --- /dev/null +++ b/packages/admin-next/dashboard/src/lib/api-v2/campaign.ts @@ -0,0 +1,57 @@ +import { + AdminCampaignRes, + AdminCampaignsListRes, + AdminGetCampaignsCampaignParams, + AdminGetCampaignsParams, + AdminPostCampaignsCampaignReq, +} from "@medusajs/medusa" +import { useMutation } from "@tanstack/react-query" +import { queryKeysFactory, useAdminCustomQuery } from "medusa-react" +import { medusa } from "../medusa" + +const QUERY_KEY = "admin_campaigns" +export const adminCampaignKeys = queryKeysFactory< + typeof QUERY_KEY, + AdminGetCampaignsParams +>(QUERY_KEY) + +export const adminCampaignQueryFns = { + list: (query: AdminGetCampaignsParams) => + medusa.admin.custom.get(`/admin/campaigns`, query), + detail: (id: string) => medusa.admin.custom.get(`/admin/campaigns/${id}`), +} + +export const useV2Campaigns = ( + query?: AdminGetCampaignsParams, + options?: object +) => { + const { data, ...rest } = useAdminCustomQuery< + AdminGetCampaignsParams, + AdminCampaignsListRes + >("/admin/campaigns", adminCampaignKeys.list(query), query, options) + + return { ...data, ...rest } +} + +export const useV2Campaign = ( + id: string, + query?: AdminGetCampaignsParams, + options?: object +) => { + const { data, ...rest } = useAdminCustomQuery< + AdminGetCampaignsCampaignParams, + AdminCampaignRes + >(`/admin/campaigns/${id}`, adminCampaignKeys.detail(id), query, options) + + return { ...data, ...rest } +} + +export const useV2DeleteCampaign = (id: string) => { + return useMutation(() => medusa.admin.custom.delete(`/admin/campaigns/${id}`)) +} + +export const useV2PostCampaign = (id: string) => { + return useMutation((args: AdminPostCampaignsCampaignReq) => + medusa.client.request("POST", `/admin/campaigns/${id}`, args) + ) +} diff --git a/packages/admin-next/dashboard/src/lib/api-v2/index.ts b/packages/admin-next/dashboard/src/lib/api-v2/index.ts index 1bb9826de0..4b23d2e698 100644 --- a/packages/admin-next/dashboard/src/lib/api-v2/index.ts +++ b/packages/admin-next/dashboard/src/lib/api-v2/index.ts @@ -1,3 +1,2 @@ export * from "./auth" -export * from "./promotion" export * from "./store" diff --git a/packages/admin-next/dashboard/src/lib/api-v2/promotion.ts b/packages/admin-next/dashboard/src/lib/api-v2/promotion.ts deleted file mode 100644 index 84c3426659..0000000000 --- a/packages/admin-next/dashboard/src/lib/api-v2/promotion.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { - AdminGetPromotionsParams, - AdminPromotionsListRes, -} from "@medusajs/medusa" -import { Response } from "@medusajs/medusa-js" -import { - UseQueryOptionsWrapper, - queryKeysFactory, - useAdminCustomQuery, -} from "medusa-react" - -const ADMIN_PROMOTIONS_QUERY_KEY = "admin_promotions" - -export const adminPromotionKeys = queryKeysFactory< - typeof ADMIN_PROMOTIONS_QUERY_KEY, - AdminGetPromotionsParams ->(ADMIN_PROMOTIONS_QUERY_KEY) - -type PromotionQueryKey = typeof adminPromotionKeys - -export const useV2Promotions = ( - query?: AdminGetPromotionsParams, - options?: UseQueryOptionsWrapper< - Response, - Error, - ReturnType - > -) => { - const { data, ...rest } = useAdminCustomQuery< - AdminGetPromotionsParams, - AdminPromotionsListRes - >("/promotions", adminPromotionKeys.list(query), query, options as any) - - return { ...data, ...rest } -} diff --git a/packages/admin-next/dashboard/src/lib/client/campaigns.ts b/packages/admin-next/dashboard/src/lib/client/campaigns.ts new file mode 100644 index 0000000000..eacb8aee0a --- /dev/null +++ b/packages/admin-next/dashboard/src/lib/client/campaigns.ts @@ -0,0 +1,35 @@ +import { CreateCampaignDTO, UpdateCampaignDTO } from "@medusajs/types" +import { + CampaignDeleteRes, + CampaignListRes, + CampaignRes, +} from "../../types/api-responses" +import { deleteRequest, getRequest, postRequest } from "./common" + +async function retrieveCampaign(id: string, query?: Record) { + return getRequest(`/admin/campaigns/${id}`, query) +} + +async function listCampaigns(query?: Record) { + return getRequest(`/admin/campaigns`, query) +} + +async function createCampaign(payload: CreateCampaignDTO) { + return postRequest(`/admin/campaigns`, payload) +} + +async function updateCampaign(id: string, payload: UpdateCampaignDTO) { + return postRequest(`/admin/campaigns/${id}`, payload) +} + +async function deleteCampaign(id: string) { + return deleteRequest(`/admin/campaigns/${id}`) +} + +export const campaigns = { + retrieve: retrieveCampaign, + list: listCampaigns, + create: createCampaign, + update: updateCampaign, + delete: deleteCampaign, +} diff --git a/packages/admin-next/dashboard/src/lib/client/client.ts b/packages/admin-next/dashboard/src/lib/client/client.ts index f721ded8be..6de8dee44f 100644 --- a/packages/admin-next/dashboard/src/lib/client/client.ts +++ b/packages/admin-next/dashboard/src/lib/client/client.ts @@ -1,5 +1,6 @@ import { apiKeys } from "./api-keys" import { auth } from "./auth" +import { campaigns } from "./campaigns" import { categories } from "./categories" import { collections } from "./collections" import { currencies } from "./currencies" @@ -19,6 +20,7 @@ import { workflowExecutions } from "./workflow-executions" export const client = { auth: auth, apiKeys: apiKeys, + campaigns: campaigns, categories: categories, customers: customers, currencies: currencies, diff --git a/packages/admin-next/dashboard/src/lib/client/promotions.ts b/packages/admin-next/dashboard/src/lib/client/promotions.ts index 0912f61532..2b607474d0 100644 --- a/packages/admin-next/dashboard/src/lib/client/promotions.ts +++ b/packages/admin-next/dashboard/src/lib/client/promotions.ts @@ -1,23 +1,113 @@ import { AdminGetPromotionsParams } from "@medusajs/medusa" -import { PromotionListRes, PromotionRes } from "../../types/api-responses" -import { getRequest } from "./common" +import { + BatchAddPromotionRulesReq, + BatchRemovePromotionRulesReq, + BatchUpdatePromotionRulesReq, + CreatePromotionReq, + UpdatePromotionReq, +} from "../../types/api-payloads" +import { + PromotionDeleteRes, + PromotionListRes, + PromotionRes, + PromotionRuleAttributesListRes, + PromotionRuleOperatorsListRes, + PromotionRuleValuesListRes, +} from "../../types/api-responses" +import { deleteRequest, getRequest, postRequest } from "./common" -const retrievePromotion = async ( - id: string, - query?: AdminGetPromotionsParams -) => { +async function retrievePromotion(id: string, query?: AdminGetPromotionsParams) { return getRequest( `/admin/promotions/${id}`, query ) } -const listPromotions = async (query?: AdminGetPromotionsParams) => { +async function listPromotions(query?: AdminGetPromotionsParams) { return getRequest(`/admin/promotions`, query) } +async function deletePromotion(id: string) { + return deleteRequest(`/admin/promotions/${id}`) +} + +async function updatePromotion(id: string, payload: UpdatePromotionReq) { + return postRequest(`/admin/promotions/${id}`, payload) +} + +async function createPromotion(payload: CreatePromotionReq) { + return postRequest(`/admin/promotions`, payload) +} + +async function addPromotionRules( + id: string, + ruleType: string, + payload: BatchAddPromotionRulesReq +) { + return postRequest( + `/admin/promotions/${id}/${ruleType}/batch/add`, + payload + ) +} + +async function updatePromotionRules( + id: string, + ruleType: string, + payload: BatchUpdatePromotionRulesReq +) { + return postRequest( + `/admin/promotions/${id}/${ruleType}/batch/update`, + payload + ) +} + +async function removePromotionRules( + id: string, + ruleType: string, + payload: BatchRemovePromotionRulesReq +) { + return postRequest( + `/admin/promotions/${id}/${ruleType}/batch/remove`, + payload + ) +} + +async function listPromotionRules(id: string, ruleType: string) { + return getRequest( + `/admin/promotions/${id}/${ruleType}` + ) +} + +async function listPromotionRuleAttributes(ruleType: string) { + return getRequest( + `/admin/promotions/rule-attribute-options/${ruleType}` + ) +} + +async function listPromotionRuleValues(ruleType: string, ruleValue: string) { + return getRequest( + `/admin/promotions/rule-value-options/${ruleType}/${ruleValue}` + ) +} + +async function listPromotionRuleOperators() { + return getRequest( + `/admin/promotions/rule-operator-options` + ) +} + export const promotions = { retrieve: retrievePromotion, list: listPromotions, + delete: deletePromotion, + update: updatePromotion, + create: createPromotion, + addRules: addPromotionRules, + removeRules: removePromotionRules, + updateRules: updatePromotionRules, + listRules: listPromotionRules, + listRuleAttributes: listPromotionRuleAttributes, + listRuleOperators: listPromotionRuleOperators, + listRuleValues: listPromotionRuleValues, } diff --git a/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx b/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx index d1d88e64c0..cf56b7063d 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx @@ -150,6 +150,34 @@ export const v2Routes: RouteObject[] = [ path: "", lazy: () => import("../../v2-routes/promotions/promotion-list"), }, + { + path: ":id", + lazy: () => + import("../../v2-routes/promotions/promotion-detail"), + handle: { + crumb: (data: AdminPromotionRes) => data.promotion?.code, + }, + children: [ + { + path: "edit", + lazy: () => + import( + "../../v2-routes/promotions/promotion-edit-details" + ), + }, + { + path: "add-to-campaign", + lazy: () => + import( + "../../v2-routes/promotions/promotion-add-campaign" + ), + }, + { + path: ":ruleType/edit", + lazy: () => import("../../v2-routes/promotions/edit-rules"), + }, + ], + }, ], }, { diff --git a/packages/admin-next/dashboard/src/types/api-payloads.ts b/packages/admin-next/dashboard/src/types/api-payloads.ts index 425a4021f7..7c42a99115 100644 --- a/packages/admin-next/dashboard/src/types/api-payloads.ts +++ b/packages/admin-next/dashboard/src/types/api-payloads.ts @@ -4,15 +4,21 @@ import { CreateApiKeyDTO, + CreateCampaignDTO, CreateCustomerDTO, CreateInviteDTO, CreateProductCollectionDTO, + CreatePromotionDTO, + CreatePromotionRuleDTO, CreateRegionDTO, CreateSalesChannelDTO, CreateStockLocationInput, UpdateApiKeyDTO, + UpdateCampaignDTO, UpdateCustomerDTO, UpdateProductCollectionDTO, + UpdatePromotionDTO, + UpdatePromotionRuleDTO, UpdateRegionDTO, UpdateSalesChannelDTO, UpdateStockLocationInput, @@ -57,3 +63,14 @@ export type UpdateStockLocationReq = UpdateStockLocationInput // Product Collections export type CreateProductCollectionReq = CreateProductCollectionDTO export type UpdateProductCollectionReq = UpdateProductCollectionDTO + +// Promotion +export type CreatePromotionReq = CreatePromotionDTO +export type UpdatePromotionReq = UpdatePromotionDTO +export type BatchAddPromotionRulesReq = { rules: CreatePromotionRuleDTO[] } +export type BatchRemovePromotionRulesReq = { rule_ids: string[] } +export type BatchUpdatePromotionRulesReq = { rules: UpdatePromotionRuleDTO[] } + +// Campaign +export type CreateCampaignReq = CreateCampaignDTO +export type UpdateCampaignReq = UpdateCampaignDTO diff --git a/packages/admin-next/dashboard/src/types/api-responses.ts b/packages/admin-next/dashboard/src/types/api-responses.ts index b5bc86d0d9..cd64f62357 100644 --- a/packages/admin-next/dashboard/src/types/api-responses.ts +++ b/packages/admin-next/dashboard/src/types/api-responses.ts @@ -4,6 +4,7 @@ import { ApiKeyDTO, + CampaignDTO, CurrencyDTO, CustomerDTO, InviteDTO, @@ -45,6 +46,10 @@ export type CustomerListRes = { customers: CustomerDTO[] } & ListRes // Promotions export type PromotionRes = { promotion: PromotionDTO } export type PromotionListRes = { promotions: PromotionDTO[] } & ListRes +export type PromotionRuleAttributesListRes = { attributes: Record[] } +export type PromotionRuleOperatorsListRes = { operators: Record[] } +export type PromotionRuleValuesListRes = { values: Record[] } +export type PromotionRulesListRes = { rules: Record[] } export type PromotionDeleteRes = DeleteRes // Users @@ -65,6 +70,11 @@ export type RegionRes = { region: RegionDTO } export type RegionListRes = { regions: RegionDTO[] } & ListRes export type RegionDeleteRes = DeleteRes +// Campaigns +export type CampaignRes = { campaign: CampaignDTO } +export type CampaignListRes = { campaigns: CampaignDTO[] } & ListRes +export type CampaignDeleteRes = DeleteRes + // API Keys export type ExtendedApiKeyDTO = ApiKeyDTO & { sales_channels: SalesChannelDTO[] | null diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/components/edit-rules-form/edit-rules-form.tsx b/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/components/edit-rules-form/edit-rules-form.tsx new file mode 100644 index 0000000000..c93a3eb04f --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/components/edit-rules-form/edit-rules-form.tsx @@ -0,0 +1,442 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { XMarkMini } from "@medusajs/icons" +import { PromotionDTO, PromotionRuleDTO } from "@medusajs/types" +import { Badge, Button, Heading, Input, Select, Text } from "@medusajs/ui" +import { Fragment, useState } from "react" +import { useFieldArray, useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" +import * as zod from "zod" +import { Combobox } from "../../../../../components/common/combobox" +import { Form } from "../../../../../components/common/form" +import { + RouteDrawer, + useRouteModal, +} from "../../../../../components/route-modal" +import { + usePromotionAddRules, + usePromotionRemoveRules, + usePromotionRuleValues, + usePromotionUpdateRules, + useUpdatePromotion, +} from "../../../../../hooks/api/promotions" +import { RuleTypeValues } from "../../edit-rules" +import { getDisguisedRules } from "./utils" + +type EditPromotionFormProps = { + promotion: PromotionDTO + rules: PromotionRuleDTO[] + ruleType: RuleTypeValues + attributes: any[] + operators: any[] +} + +const EditRules = zod.object({ + rules: zod.array( + zod.object({ + id: zod.string().optional(), + attribute: zod.string().min(1, { message: "Required field" }), + operator: zod.string().min(1, { message: "Required field" }), + values: zod.union([ + zod.number().min(1, { message: "Required field" }), + zod.string().min(1, { message: "Required field" }), + zod.array(zod.string()).min(1, { message: "Required field" }), + ]), + required: zod.boolean().optional(), + field_type: zod.string().optional(), + }) + ), +}) + +const fetchOptionsForRule = (ruleType: string, attribute: string) => { + const { values } = usePromotionRuleValues(ruleType, attribute) + + return values || [] +} + +export const EditRulesForm = ({ + promotion, + rules, + ruleType, + attributes, + operators, +}: EditPromotionFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + const requiredAttributes = attributes?.filter((ra) => ra.required) || [] + const requiredAttributeValues = requiredAttributes?.map((ra) => ra.value) + const disguisedRules = + getDisguisedRules(promotion, requiredAttributes, ruleType) || [] + const [rulesToRemove, setRulesToRemove] = useState([]) + + const form = useForm>({ + defaultValues: { + rules: [...disguisedRules, ...rules].map((rule) => ({ + id: rule.id, + required: requiredAttributeValues.includes(rule.attribute), + field_type: rule.field_type, + attribute: rule.attribute!, + operator: rule.operator!, + values: rule?.values?.map((v: { value: string }) => v.value!), + })), + }, + resolver: zodResolver(EditRules), + }) + + const { fields, append, remove, update } = useFieldArray({ + control: form.control, + name: "rules", + keyName: "rules_id", + }) + + const { mutateAsync: updatePromotion } = useUpdatePromotion(promotion.id) + const { mutateAsync: addPromotionRules } = usePromotionAddRules( + promotion.id, + ruleType + ) + + const { mutateAsync: removePromotionRules } = usePromotionRemoveRules( + promotion.id, + ruleType + ) + + const { mutateAsync: updatePromotionRules } = usePromotionUpdateRules( + promotion.id, + ruleType + ) + + const handleSubmit = form.handleSubmit(async (data) => { + const applicationMethodData: Record = {} + const { rules: allRules = [] } = data + + const disguisedRulesData = allRules.filter((rule) => + disguisedRules.map((maskedRule) => maskedRule.id).includes(rule.id!) + ) + + // For all the rules that were disguised, convert them to actual values in the + // database, they are currently all under application_method. If more of these are coming + // up, abstract this away. + for (const rule of disguisedRulesData) { + applicationMethodData[rule.id!] = parseInt(rule.values as string) + } + + // This variable will contain the rules that are actual rule objects, without the disguised + // objects + const rulesData = allRules.filter( + (rule) => + !disguisedRules.map((maskedRule) => maskedRule.id).includes(rule.id!) + ) + + const rulesToCreate = rulesData.filter((rule) => !("id" in rule)) + const rulesToUpdate = rulesData.filter( + (rule) => typeof rule.id === "string" + ) + + if (Object.keys(applicationMethodData).length) { + await updatePromotion({ application_method: applicationMethodData }) + } + + rulesToCreate.length && + (await addPromotionRules({ + rules: rulesToCreate.map((rule) => { + return { + attribute: rule.attribute, + operator: rule.operator, + values: rule.values, + } as any + }), + })) + + rulesToRemove.length && + (await removePromotionRules({ + rule_ids: rulesToRemove.map((r) => r.id!), + })) + + rulesToUpdate.length && + (await updatePromotionRules({ + rules: rulesToUpdate.map((rule) => { + return { + id: rule.id!, + attribute: rule.attribute, + operator: rule.operator, + values: rule.values, + } as any + }), + })) + + handleSuccess() + }) + + return ( + +
    + +
    + + {t(`promotions.fields.conditions.${ruleType}.title`)} + + + + {t(`promotions.fields.conditions.${ruleType}.description`)} + + + {fields.map((fieldRule, index) => { + const identifier = fieldRule.id + const { ref: attributeRef, ...attributeFields } = form.register( + `rules.${index}.attribute` + ) + const { ref: operatorRef, ...operatorFields } = form.register( + `rules.${index}.operator` + ) + const { ref: valuesRef, ...valuesFields } = form.register( + `rules.${index}.values` + ) + + return ( + +
    +
    + { + const existingAttributes = + fields?.map((field) => field.attribute) || [] + const attributeOptions = + attributes?.filter((attr) => { + if (attr.value === fieldRule.attribute) { + return true + } + + return !existingAttributes.includes(attr.value) + }) || [] + + return ( + + {fieldRule.required && ( +

    + Required +

    + )} + + + + + +
    + ) + }} + /> + +
    + { + return ( + + + + + + + ) + }} + /> + + { + if (fieldRule.field_type === "number") { + return ( + + + + + + + ) + } else if (fieldRule.field_type === "text") { + return ( + + + + + + + ) + } else { + const attribute = attributes?.find( + (attr) => attr.value === fieldRule.attribute + ) + const options = attribute + ? fetchOptionsForRule(ruleType, attribute.id) + : [] + + return ( + + + + + + + + ) + } + }} + /> +
    +
    + +
    + { + if (!fieldRule.required) { + if (fieldRule.id) { + setRulesToRemove([...rulesToRemove, fieldRule]) + } + + remove(index) + } + }} + /> +
    +
    + + {index < fields.length - 1 && ( +
    +
    + + + AND + +
    + )} +
    + ) + })} + +
    + + + +
    +
    +
    + + +
    + + + + + +
    +
    +
    +
    + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/components/edit-rules-form/index.ts b/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/components/edit-rules-form/index.ts new file mode 100644 index 0000000000..b3e2998403 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/components/edit-rules-form/index.ts @@ -0,0 +1 @@ +export * from "./edit-rules-form" diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/components/edit-rules-form/utils.ts b/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/components/edit-rules-form/utils.ts new file mode 100644 index 0000000000..af13209435 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/components/edit-rules-form/utils.ts @@ -0,0 +1,52 @@ +import { PromotionDTO } from "@medusajs/types" +import { RuleType } from "../../edit-rules" + +// We are disguising couple of database columns as rules here, namely +// apply_to_quantity and buy_rules_min_quantity. +// We need to transform the database value into a disugised "rule" shape +// for the form +export function getDisguisedRules( + promotion: PromotionDTO, + requiredAttributes: any[], + ruleType: string +) { + if (ruleType === RuleType.RULES && !requiredAttributes?.length) { + return [] + } + + const applyToQuantityRule = requiredAttributes.find( + (attr) => attr.id === "apply_to_quantity" + ) + + const buyRulesMinQuantityRule = requiredAttributes.find( + (attr) => attr.id === "buy_rules_min_quantity" + ) + + if (ruleType === RuleType.TARGET_RULES) { + return [ + { + id: "apply_to_quantity", + attribute: "apply_to_quantity", + operator: "eq", + required: applyToQuantityRule?.required, + field_type: applyToQuantityRule?.field_type, + values: [{ value: promotion?.application_method?.apply_to_quantity }], + }, + ] + } + + if (ruleType === RuleType.BUY_RULES) { + return [ + { + id: "buy_rules_min_quantity", + attribute: "buy_rules_min_quantity", + operator: "eq", + required: buyRulesMinQuantityRule?.required, + field_type: buyRulesMinQuantityRule?.field_type, + values: [ + { value: promotion?.application_method?.buy_rules_min_quantity }, + ], + }, + ] + } +} diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/edit-rules.tsx b/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/edit-rules.tsx new file mode 100644 index 0000000000..969dabb1ba --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/edit-rules.tsx @@ -0,0 +1,79 @@ +import { PromotionRuleDTO } from "@medusajs/types" +import { Heading } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { useParams } from "react-router-dom" +import { RouteDrawer } from "../../../components/route-modal" +import { + usePromotion, + usePromotionRuleAttributes, + usePromotionRuleOperators, +} from "../../../hooks/api/promotions" +import { EditRulesForm } from "./components/edit-rules-form" + +export enum RuleType { + RULES = "rules", + BUY_RULES = "buy-rules", + TARGET_RULES = "target-rules", +} +export type RuleTypeValues = "rules" | "buy-rules" | "target-rules" + +export const EditRules = () => { + const params = useParams() + const allowedParams: string[] = [ + RuleType.RULES, + RuleType.BUY_RULES, + RuleType.TARGET_RULES, + ] + + if (!allowedParams.includes(params.ruleType!)) { + throw "invalid page" + } + + const { t } = useTranslation() + const ruleType = params.ruleType as RuleTypeValues + const id = params.id as string + const rules: PromotionRuleDTO[] = [] + const { promotion, isLoading, isError, error } = usePromotion(id) + const { + attributes, + isError: isAttributesError, + error: attributesError, + } = usePromotionRuleAttributes(ruleType!) + const { + operators, + isError: isOperatorsError, + error: operatorsError, + } = usePromotionRuleOperators() + + if (promotion) { + if (ruleType === RuleType.RULES) { + rules.push(...(promotion.rules || [])) + } else if (ruleType === RuleType.TARGET_RULES) { + rules.push(...(promotion?.application_method?.target_rules || [])) + } else if (ruleType === RuleType.BUY_RULES) { + rules.push(...(promotion.application_method?.buy_rules || [])) + } + } + + if (isError || isAttributesError || isOperatorsError) { + throw error || attributesError || operatorsError + } + + return ( + + + {t(`promotions.edit.${ruleType}.title`)} + + + {!isLoading && promotion && attributes && operators && ( + + )} + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/index.ts b/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/index.ts new file mode 100644 index 0000000000..f905988305 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/promotions/edit-rules/index.ts @@ -0,0 +1 @@ +export { EditRules as Component } from "./edit-rules" 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 new file mode 100644 index 0000000000..a17bc289f0 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/components/add-campaign-promotion-form/add-campaign-promotion-form.tsx @@ -0,0 +1,151 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { CampaignDTO, PromotionDTO } from "@medusajs/types" +import { Button, RadioGroup, Select } from "@medusajs/ui" +import { useForm, useWatch } from "react-hook-form" +import { useTranslation } from "react-i18next" +import * as zod from "zod" +import { CampaignDetails } from "./campaign-details" + +import { Form } from "../../../../../components/common/form" +import { + RouteDrawer, + useRouteModal, +} from "../../../../../components/route-modal" +import { useUpdatePromotion } from "../../../../../hooks/api/promotions" + +type EditPromotionFormProps = { + promotion: PromotionDTO + campaigns: CampaignDTO[] +} + +const EditPromotionSchema = zod.object({ + campaign_id: zod.string().optional(), + existing: zod.string().toLowerCase(), +}) + +export const AddCampaignPromotionForm = ({ + promotion, + campaigns, +}: EditPromotionFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + const { campaign } = promotion + + const form = useForm>({ + defaultValues: { + campaign_id: campaign?.id, + existing: "true", + }, + resolver: zodResolver(EditPromotionSchema), + }) + + const watchCampaignId = useWatch({ + control: form.control, + name: "campaign_id", + }) + + const selectedCampaign = campaigns.find((c) => c.id === watchCampaignId) + const { mutateAsync, isPending } = useUpdatePromotion(promotion.id) + + const handleSubmit = form.handleSubmit(async (data) => { + await mutateAsync( + { campaign_id: data.campaign_id }, + { onSuccess: () => handleSuccess() } + ) + }) + + return ( + +
    + +
    + { + return ( + + Method + + + + + + + + + + ) + }} + /> + + { + return ( + + + {t("promotions.form.campaign.existing.title")} + + + + + + + + ) + }} + /> + + +
    +
    + + +
    + + + + + +
    +
    +
    +
    + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/components/add-campaign-promotion-form/campaign-details.tsx b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/components/add-campaign-promotion-form/campaign-details.tsx new file mode 100644 index 0000000000..c84a0a2cf0 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/components/add-campaign-promotion-form/campaign-details.tsx @@ -0,0 +1,116 @@ +import { CampaignDTO } from "@medusajs/types" +import { Heading, Text } from "@medusajs/ui" +import { Fragment } from "react" +import { useTranslation } from "react-i18next" + +type CampaignDetailsProps = { + campaign?: CampaignDTO +} + +export const CampaignDetails = ({ campaign }: CampaignDetailsProps) => { + const { t } = useTranslation() + + if (!campaign) { + return + } + + return ( + +
    + + {t("campaigns.details")} + + +
    + + {t("campaigns.fields.identifier")} + + +
    + + {campaign.campaign_identifier || "-"} + +
    +
    + +
    + {t("fields.description")} + +
    + {campaign.description || "-"} +
    +
    + +
    + + {t("campaigns.fields.start_date")} + + +
    + + {campaign.starts_at?.toString() || "-"} + +
    +
    + +
    + + {t("campaigns.fields.end_date")} + + +
    + + {campaign.ends_at?.toString() || "-"} + +
    +
    +
    + +
    + + {t("campaigns.budget.details")} + + +
    + + {t("campaigns.budget.fields.type")} + + +
    + {campaign.budget?.type || "-"} +
    +
    + +
    + + {t("campaigns.budget.fields.currency")} + + +
    + {campaign.currency || "-"} +
    +
    + +
    + + {t("campaigns.budget.fields.limit")} + + +
    + {campaign.budget?.limit || "-"} +
    +
    + +
    + + {t("campaigns.budget.fields.used")} + + +
    + {campaign.budget?.used || "-"} +
    +
    +
    +
    + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/components/add-campaign-promotion-form/index.ts b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/components/add-campaign-promotion-form/index.ts new file mode 100644 index 0000000000..c7fb969a7d --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/components/add-campaign-promotion-form/index.ts @@ -0,0 +1 @@ +export * from "./add-campaign-promotion-form" diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/index.ts b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/index.ts new file mode 100644 index 0000000000..3836868e22 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/index.ts @@ -0,0 +1 @@ +export { PromotionAddCampaign as Component } from "./promotion-add-campaign" diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/promotion-add-campaign.tsx b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/promotion-add-campaign.tsx new file mode 100644 index 0000000000..7c283480e9 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-add-campaign/promotion-add-campaign.tsx @@ -0,0 +1,36 @@ +import { Heading } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { useParams } from "react-router-dom" + +import { RouteDrawer } from "../../../components/route-modal" +import { useCampaigns } from "../../../hooks/api/campaigns" +import { usePromotion } from "../../../hooks/api/promotions" +import { AddCampaignPromotionForm } from "./components/add-campaign-promotion-form" + +export const PromotionAddCampaign = () => { + const { id } = useParams() + const { t } = useTranslation() + const { promotion, isPending, isError, error } = usePromotion(id!) + const { + campaigns, + isPending: areCampaignsLoading, + isError: isCampaignError, + error: campaignError, + } = useCampaigns() + + if (isError || isCampaignError) { + throw error || campaignError + } + + return ( + + + {t("promotions.addToCampaign.title")} + + + {!isPending && !areCampaignsLoading && promotion && campaigns && ( + + )} + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/campaign-section/campaign-section.tsx b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/campaign-section/campaign-section.tsx new file mode 100644 index 0000000000..094eb0a719 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/campaign-section/campaign-section.tsx @@ -0,0 +1,116 @@ +import { PencilSquare } from "@medusajs/icons" +import { CampaignDTO } from "@medusajs/types" +import { Container, Heading, Text } from "@medusajs/ui" +import { format } from "date-fns" +import { Fragment } from "react" +import { useTranslation } from "react-i18next" +import { useParams } from "react-router-dom" + +import { ActionMenu } from "../../../../../components/common/action-menu" +import { NoRecords } from "../../../../../components/common/empty-table-content" + +function formatDate(date?: string | Date) { + if (!date) { + return "-" + } + + return format(new Date(date), "dd MMM yyyy") +} + +const CampaignDetailSection = ({ campaign }: { campaign: CampaignDTO }) => { + const { t } = useTranslation() + + return ( + +
    + + {t("campaigns.fields.name")} + + +
    + + {campaign?.name} + +
    +
    + +
    + + {t("campaigns.fields.identifier")} + + +
    + + {campaign?.campaign_identifier} + +
    +
    + +
    + + {t("campaigns.fields.start_date")} + + +
    + + {formatDate(campaign?.starts_at)} + +
    +
    + +
    + + {t("campaigns.fields.end_date") || "-"} + + +
    + + {formatDate(campaign?.ends_at)} + +
    +
    +
    + ) +} + +export const CampaignSection = ({ campaign }: { campaign: CampaignDTO }) => { + const { t } = useTranslation() + const { id } = useParams() + + return ( + +
    + {t("promotions.fields.campaign")} + + , + }, + ], + }, + ]} + /> +
    + + {campaign ? ( + + ) : ( + + )} +
    + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/campaign-section/index.ts b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/campaign-section/index.ts new file mode 100644 index 0000000000..52b96902ac --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/campaign-section/index.ts @@ -0,0 +1 @@ +export * from "./campaign-section.tsx" diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/promotion-conditions-section/index.ts b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/promotion-conditions-section/index.ts new file mode 100644 index 0000000000..260e4a4ffb --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/promotion-conditions-section/index.ts @@ -0,0 +1 @@ +export * from "./promotion-conditions-section" diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/promotion-conditions-section/promotion-conditions-section.tsx b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/promotion-conditions-section/promotion-conditions-section.tsx new file mode 100644 index 0000000000..f041c3fa5d --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/promotion-conditions-section/promotion-conditions-section.tsx @@ -0,0 +1,92 @@ +import { PencilSquare } from "@medusajs/icons" +import { AdminGetPromotionRulesRes, PromotionRuleTypes } from "@medusajs/types" +import { Badge, Container, Heading } from "@medusajs/ui" +import { useTranslation } from "react-i18next" + +import { ActionMenu } from "../../../../../components/common/action-menu" +import { BadgeListSummary } from "../../../../../components/common/badge-list-summary" +import { NoRecords } from "../../../../../components/common/empty-table-content" + +type RuleProps = { + rule: AdminGetPromotionRulesRes +} + +function RuleBlock({ rule }: RuleProps) { + return ( +
    +
    + + {rule.attribute_label} + + + + {rule.operator_label} + + + v.label)} + /> +
    +
    + ) +} + +type PromotionConditionsSectionProps = { + rules: AdminGetPromotionRulesRes + ruleType: PromotionRuleTypes +} + +export const PromotionConditionsSection = ({ + rules, + ruleType, +}: PromotionConditionsSectionProps) => { + const { t } = useTranslation() + + return ( + +
    +
    + + {t(`promotions.fields.conditions.${ruleType}.title`)} + +
    + + , + label: t("actions.edit"), + to: `${ruleType}/edit`, + }, + ], + }, + ]} + /> +
    + +
    + {!rules.length && ( + + )} + + {rules.map((rule) => ( + + ))} +
    +
    + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/promotion-general-section/index.ts b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/promotion-general-section/index.ts new file mode 100644 index 0000000000..555494d37a --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/promotion-general-section/index.ts @@ -0,0 +1 @@ +export * from "./promotion-general-section.tsx" diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/promotion-general-section/promotion-general-section.tsx b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/promotion-general-section/promotion-general-section.tsx new file mode 100644 index 0000000000..ff0f5542cb --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/components/promotion-general-section/promotion-general-section.tsx @@ -0,0 +1,160 @@ +import { PencilSquare, Trash } from "@medusajs/icons" +import { PromotionDTO } from "@medusajs/types" +import { + Container, + Copy, + Heading, + StatusBadge, + Text, + usePrompt, +} from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { useNavigate } from "react-router-dom" + +import { ActionMenu } from "../../../../../components/common/action-menu" +import { useDeletePromotion } from "../../../../../hooks/api/promotions" +import { + getPromotionStatus, + PromotionStatus, +} from "../../../../../lib/promotions" + +type PromotionGeneralSectionProps = { + promotion: PromotionDTO +} + +export const PromotionGeneralSection = ({ + promotion, +}: PromotionGeneralSectionProps) => { + const { t } = useTranslation() + const prompt = usePrompt() + const navigate = useNavigate() + const { mutateAsync } = useDeletePromotion(promotion.id) + + const handleDelete = async () => { + const confirm = await prompt({ + title: t("general.areYouSure"), + description: t("promotions.deleteWarning", { + code: promotion.code, + }), + verificationInstruction: t("general.typeToConfirm"), + verificationText: promotion.code, + confirmText: t("actions.delete"), + cancelText: t("actions.cancel"), + }) + + if (!confirm) { + return + } + + await mutateAsync(undefined, { + onSuccess: () => { + navigate("/promotions", { replace: true }) + }, + }) + } + + const [color, text] = { + [PromotionStatus.DISABLED]: ["grey", t("statuses.disabled")], + [PromotionStatus.ACTIVE]: ["green", t("statuses.active")], + [PromotionStatus.SCHEDULED]: ["orange", t("statuses.scheduled")], + [PromotionStatus.EXPIRED]: ["red", t("statuses.expired")], + }[getPromotionStatus(promotion)] as [ + "grey" | "orange" | "green" | "red", + string, + ] + + return ( + +
    +
    + {promotion.code} +
    + +
    + {text} + , + label: t("actions.edit"), + to: `/promotions/${promotion.id}/edit`, + }, + ], + }, + { + actions: [ + { + icon: , + label: t("actions.delete"), + onClick: handleDelete, + }, + ], + }, + ]} + /> +
    +
    + +
    + + {t("promotions.fields.method")} + + + + {promotion.is_automatic ? "Promotion code" : "Automatic"} + +
    + +
    + + {t("fields.code")} + + +
    + + {promotion.code} + + + +
    +
    + +
    + + {t("promotions.fields.type")} + + + + {promotion.type} + +
    + +
    + + {t("promotions.fields.value")} + + + + {promotion.application_method?.value} + +
    + +
    + + {t("promotions.fields.allocation")} + + + + {promotion.application_method?.allocation!} + +
    +
    + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/index.ts b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/index.ts new file mode 100644 index 0000000000..961854b02b --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/index.ts @@ -0,0 +1,2 @@ +export { promotionLoader as loader } from "./loader" +export { PromotionDetail as Component } from "./promotion-detail.tsx" diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/loader.ts b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/loader.ts new file mode 100644 index 0000000000..7925981513 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/loader.ts @@ -0,0 +1,21 @@ +import { AdminPromotionRes } from "@medusajs/medusa" +import { Response } from "@medusajs/medusa-js" +import { LoaderFunctionArgs } from "react-router-dom" +import { promotionsQueryKeys } from "../../../hooks/api/promotions" +import { client } from "../../../lib/client" +import { queryClient } from "../../../lib/medusa" + +const promotionDetailQuery = (id: string) => ({ + queryKey: promotionsQueryKeys.detail(id), + queryFn: () => client.promotions.retrieve(id), +}) + +export const promotionLoader = async ({ params }: LoaderFunctionArgs) => { + const id = params.id + const query = promotionDetailQuery(id!) + + return ( + queryClient.getQueryData>(query.queryKey) ?? + (await queryClient.fetchQuery(query)) + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/promotion-detail.tsx b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/promotion-detail.tsx new file mode 100644 index 0000000000..6412740bff --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-detail/promotion-detail.tsx @@ -0,0 +1,78 @@ +import { Outlet, useLoaderData, useParams } from "react-router-dom" + +import { JsonViewSection } from "../../../components/common/json-view-section" +import { usePromotion, usePromotionRules } from "../../../hooks/api/promotions" +import { CampaignSection } from "./components/campaign-section" +import { PromotionConditionsSection } from "./components/promotion-conditions-section" +import { PromotionGeneralSection } from "./components/promotion-general-section" +import { promotionLoader } from "./loader" + +import after from "medusa-admin:widgets/promotion/details/after" +import before from "medusa-admin:widgets/promotion/details/before" + +export const PromotionDetail = () => { + const initialData = useLoaderData() as Awaited< + ReturnType + > + + const { id } = useParams() + const { promotion, isLoading } = usePromotion(id!, { initialData }) + const { rules } = usePromotionRules(id!, "rules") + const { rules: targetRules } = usePromotionRules(id!, "target-rules") + const { rules: buyRules } = usePromotionRules(id!, "buy-rules") + + if (isLoading || !promotion) { + return
    Loading...
    + } + + return ( +
    + {before.widgets.map((w, i) => { + return ( +
    + +
    + ) + })} + +
    +
    + + + + + + + {promotion.type === "buyget" && ( + + )} + +
    + +
    + + {after.widgets.map((w, i) => { + return ( +
    + +
    + ) + })} + + +
    + +
    + +
    +
    + +
    + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-edit-details/components/edit-promotion-form/edit-promotion-details-form.tsx b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-edit-details/components/edit-promotion-form/edit-promotion-details-form.tsx new file mode 100644 index 0000000000..7fbe9ec95c --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-edit-details/components/edit-promotion-form/edit-promotion-details-form.tsx @@ -0,0 +1,280 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { PromotionDTO } from "@medusajs/types" +import { Button, CurrencyInput, Input, RadioGroup, Text } from "@medusajs/ui" +import { useForm, useWatch } from "react-hook-form" +import { Trans, useTranslation } from "react-i18next" +import * as zod from "zod" + +import { Form } from "../../../../../components/common/form" +import { PercentageInput } from "../../../../../components/common/percentage-input" +import { + RouteDrawer, + useRouteModal, +} from "../../../../../components/route-modal" +import { useUpdatePromotion } from "../../../../../hooks/api/promotions" +import { getCurrencySymbol } from "../../../../../lib/currencies" + +type EditPromotionFormProps = { + promotion: PromotionDTO +} + +const EditPromotionSchema = zod.object({ + is_automatic: zod.string().toLowerCase(), + code: zod.string().min(1), + value_type: zod.enum(["fixed", "percentage"]), + value: zod.string(), + allocation: zod.enum(["each", "across"]), +}) + +export const EditPromotionDetailsForm = ({ + promotion, +}: EditPromotionFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const form = useForm>({ + defaultValues: { + is_automatic: promotion.is_automatic!.toString(), + code: promotion.code, + value: promotion.application_method!.value?.toString(), + allocation: promotion.application_method!.allocation, + value_type: promotion.application_method!.type, + }, + resolver: zodResolver(EditPromotionSchema), + }) + + const watchValueType = useWatch({ + control: form.control, + name: "value_type", + }) + + const isFixedValueType = watchValueType === "fixed" + + const { mutateAsync, isPending } = useUpdatePromotion(promotion.id) + + const handleSubmit = form.handleSubmit(async (data) => { + console.log("data ------ ", data) + await mutateAsync( + { + is_automatic: data.is_automatic === "true", + code: data.code, + application_method: { + value: data.value, + type: data.value_type as any, + allocation: data.allocation as any, + }, + }, + { + onSuccess: () => { + handleSuccess() + }, + } + ) + }) + + return ( + +
    + +
    + { + return ( + + Method + + + + + + + + + ) + }} + /> + +
    + { + return ( + + {t("promotions.form.code.title")} + + + + + + ) + }} + /> + + + ]} + /> + +
    + + { + return ( + + {t("promotions.fields.value_type")} + + + + + + + + + + ) + }} + /> + + { + return ( + + + {isFixedValueType + ? t("fields.amount") + : t("fields.percentage")} + + + {isFixedValueType ? ( + + ) : ( + { + onChange( + e.target.value === "" ? null : e.target.value + ) + }} + /> + )} + + + + ) + }} + /> + + { + return ( + + {t("promotions.fields.allocation")} + + + + + + + + + + ) + }} + /> +
    +
    + + +
    + + + + + +
    +
    +
    +
    + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-edit-details/components/edit-promotion-form/index.ts b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-edit-details/components/edit-promotion-form/index.ts new file mode 100644 index 0000000000..94c686f950 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-edit-details/components/edit-promotion-form/index.ts @@ -0,0 +1 @@ +export * from "./edit-promotion-details-form" diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-edit-details/index.ts b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-edit-details/index.ts new file mode 100644 index 0000000000..a49ed0b088 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-edit-details/index.ts @@ -0,0 +1 @@ +export { PromotionEditDetails as Component } from "./promotion-edit-details" diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-edit-details/promotion-edit-details.tsx b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-edit-details/promotion-edit-details.tsx new file mode 100644 index 0000000000..5734de5895 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-edit-details/promotion-edit-details.tsx @@ -0,0 +1,30 @@ +import { Heading } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { useParams } from "react-router-dom" + +import { RouteDrawer } from "../../../components/route-modal" +import { usePromotion } from "../../../hooks/api/promotions" +import { EditPromotionDetailsForm } from "./components/edit-promotion-form" + +export const PromotionEditDetails = () => { + const { id } = useParams() + const { t } = useTranslation() + + const { promotion, isLoading, isError, error } = usePromotion(id!) + + if (isError) { + throw error + } + + return ( + + + {t("promotions.edit.title")} + + + {!isLoading && promotion && ( + + )} + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-list/components/promotion-list-table/promotion-list-table.tsx b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-list/components/promotion-list-table/promotion-list-table.tsx index b525b5ad4e..aec4fcc4c3 100644 --- a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-list/components/promotion-list-table/promotion-list-table.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-list/components/promotion-list-table/promotion-list-table.tsx @@ -1,15 +1,18 @@ -import { PencilSquare } from "@medusajs/icons" +import { PencilSquare, Trash } from "@medusajs/icons" import { PromotionDTO } from "@medusajs/types" -import { Button, Container, Heading } from "@medusajs/ui" +import { Button, Container, Heading, usePrompt } from "@medusajs/ui" import { createColumnHelper } from "@tanstack/react-table" import { useMemo } from "react" import { useTranslation } from "react-i18next" -import { Link, Outlet, useLoaderData } from "react-router-dom" +import { Link, Outlet, useLoaderData, useNavigate } from "react-router-dom" import { keepPreviousData } from "@tanstack/react-query" import { ActionMenu } from "../../../../../components/common/action-menu" import { DataTable } from "../../../../../components/table/data-table" -import { usePromotions } from "../../../../../hooks/api/promotions" +import { + useDeletePromotion, + usePromotions, +} from "../../../../../hooks/api/promotions" import { usePromotionTableColumns } from "../../../../../hooks/table/columns-v2/use-promotion-table-columns" import { usePromotionTableFilters } from "../../../../../hooks/table/filters-v2/use-promotion-table-filters" import { usePromotionTableQuery } from "../../../../../hooks/table/query-v2/use-promotion-table-query" @@ -79,6 +82,36 @@ export const PromotionListTable = () => { const PromotionActions = ({ promotion }: { promotion: PromotionDTO }) => { const { t } = useTranslation() + const prompt = usePrompt() + const navigate = useNavigate() + const { mutateAsync } = useDeletePromotion(promotion.id) + + const handleDelete = async () => { + const res = await prompt({ + title: t("general.areYouSure"), + description: t("promotions.deleteWarning", { code: promotion.code! }), + confirmText: t("actions.delete"), + cancelText: t("actions.cancel"), + verificationInstruction: t("general.typeToConfirm"), + verificationText: promotion.code, + }) + + if (!res) { + return + } + + try { + await mutateAsync(undefined, { + onSuccess: () => { + navigate("/promotions", { replace: true }) + }, + }) + } catch { + throw new Error( + `Promotion with code ${promotion.code} could not be deleted` + ) + } + } return ( { label: t("actions.edit"), to: `/promotions/${promotion.id}/edit`, }, + { + icon: , + label: t("actions.delete"), + onClick: handleDelete, + }, ], }, ]} diff --git a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-list/loader.ts b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-list/loader.ts index c499254a25..b1a5a90499 100644 --- a/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-list/loader.ts +++ b/packages/admin-next/dashboard/src/v2-routes/promotions/promotion-list/loader.ts @@ -4,7 +4,7 @@ import { } from "@medusajs/medusa" import { Response } from "@medusajs/medusa-js" import { QueryClient } from "@tanstack/react-query" -import { adminPromotionKeys } from "../../../lib/api-v2" +import { promotionsQueryKeys } from "../../../hooks/api/promotions" import { medusa, queryClient } from "../../../lib/medusa" const params = { @@ -13,7 +13,7 @@ const params = { } const promotionsListQuery = () => ({ - queryKey: adminPromotionKeys.list(params), + queryKey: promotionsQueryKeys.list(params), queryFn: async () => medusa.admin.custom.get( "/promotions", diff --git a/packages/medusa-react/src/hooks/admin/custom/queries.ts b/packages/medusa-react/src/hooks/admin/custom/queries.ts index d71a44f05f..79dda86402 100644 --- a/packages/medusa-react/src/hooks/admin/custom/queries.ts +++ b/packages/medusa-react/src/hooks/admin/custom/queries.ts @@ -5,25 +5,25 @@ import { UseQueryOptionsWrapper } from "../../../types" /** * This hook sends a `GET` request to a custom API Route. - * + * * @typeParam TQuery - The type of accepted query parameters which defaults to `Record`. * @typeParam TResponse - The type of response which defaults to `any`. * @typeParamDefinition TQuery - The query parameters based on the type specified for `TQuery`. * @typeParamDefinition TResponse - The response based on the type specified for `TResponse`. - * + * * @example * import React from "react" * import { useAdminCustomQuery } from "medusa-react" * import Post from "./models/Post" - * + * * type RequestQuery = { * title: string * } - * + * * type ResponseData = { * posts: Post * } - * + * * const Custom = () => { * const { data, isLoading } = useAdminCustomQuery * ( @@ -33,7 +33,7 @@ import { UseQueryOptionsWrapper } from "../../../types" * title: "My post" * } * ) - * + * * return ( *
    * {isLoading && Loading...} @@ -50,9 +50,9 @@ import { UseQueryOptionsWrapper } from "../../../types" *
    * ) * } - * + * * export default Custom - * + * * @customNamespace Hooks.Admin.Custom * @category Mutations */ diff --git a/packages/medusa/src/api-v2/admin/campaigns/index.ts b/packages/medusa/src/api-v2/admin/campaigns/index.ts new file mode 100644 index 0000000000..1bb71ae474 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/campaigns/index.ts @@ -0,0 +1,2 @@ +export * from "./types" +export * from "./validators" diff --git a/packages/medusa/src/api-v2/admin/campaigns/types.ts b/packages/medusa/src/api-v2/admin/campaigns/types.ts new file mode 100644 index 0000000000..33bac922d1 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/campaigns/types.ts @@ -0,0 +1,9 @@ +import { CampaignDTO, PaginatedResponse } from "@medusajs/types" + +export type AdminCampaignsListRes = PaginatedResponse<{ + campaigns: CampaignDTO[] +}> + +export type AdminCampaignRes = { + campaign: CampaignDTO +} diff --git a/packages/medusa/src/api-v2/admin/index.ts b/packages/medusa/src/api-v2/admin/index.ts index b579f7910f..fe7cd6873d 100644 --- a/packages/medusa/src/api-v2/admin/index.ts +++ b/packages/medusa/src/api-v2/admin/index.ts @@ -1 +1,2 @@ +export * from "./campaigns" export * from "./promotions" diff --git a/packages/medusa/src/api-v2/admin/promotions/[id]/[rule_type]/route.ts b/packages/medusa/src/api-v2/admin/promotions/[id]/[rule_type]/route.ts new file mode 100644 index 0000000000..2159ea5d49 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/promotions/[id]/[rule_type]/route.ts @@ -0,0 +1,115 @@ +import { AdminGetPromotionRulesRes } from "@medusajs/types" +import { + ContainerRegistrationKeys, + remoteQueryObjectFromString, + RuleOperator, + RuleType, +} from "@medusajs/utils" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../../../types/routing" +import { + operatorsMap, + ruleAttributesMap, + ruleQueryConfigurations, + validateRuleType, +} from "../../utils" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { id, rule_type: ruleType } = req.params + const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) + + validateRuleType(ruleType) + + const dasherizedRuleType = ruleType.split("-").join("_") + const queryObject = remoteQueryObjectFromString({ + entryPoint: "promotion", + variables: { id }, + fields: req.remoteQueryConfig.fields, + }) + + const [promotion] = await remoteQuery(queryObject) + const ruleAttributes = ruleAttributesMap[ruleType] + const promotionRules: any[] = [] + + if (dasherizedRuleType === RuleType.RULES) { + promotionRules.push(...(promotion?.rules || [])) + } else if (dasherizedRuleType === RuleType.TARGET_RULES) { + promotionRules.push(...(promotion.application_method?.target_rules || [])) + } else if (dasherizedRuleType === RuleType.BUY_RULES) { + promotionRules.push(...(promotion.application_method?.buy_rules || [])) + } + + const transformedRules: AdminGetPromotionRulesRes = [] + const disguisedRules = ruleAttributes.filter((attr) => !!attr.disguised) + + for (const disguisedRule of disguisedRules) { + const value = promotion.application_method?.[disguisedRule.id] + const values = [{ label: value, value }] + + transformedRules.push({ + id: undefined, + attribute: disguisedRule.id, + attribute_label: disguisedRule.label, + operator: RuleOperator.EQ, + operator_label: operatorsMap[RuleOperator.EQ].label, + values, + disguised: true, + required: true, + }) + + continue + } + + for (const promotionRule of promotionRules) { + const currentRuleAttribute = ruleAttributes.find( + (attr) => attr.value === promotionRule.attribute + ) + + if (!currentRuleAttribute) { + continue + } + + const queryConfig = ruleQueryConfigurations[currentRuleAttribute.id] + const rows = await remoteQuery( + remoteQueryObjectFromString({ + entryPoint: queryConfig.entryPoint, + variables: { + filters: { + [queryConfig.valueAttr]: promotionRule.values.map((v) => v.value), + }, + }, + fields: [queryConfig.labelAttr, queryConfig.valueAttr], + }) + ) + + const valueLabelMap = new Map( + rows.map((row) => [ + row[queryConfig.valueAttr], + row[queryConfig.labelAttr], + ]) + ) + + promotionRule.values = promotionRule.values.map((value) => ({ + value: value.value, + label: valueLabelMap.get(value.value) || value.value, + })) + + transformedRules.push({ + ...promotionRule, + attribute_label: currentRuleAttribute.label, + operator_label: + operatorsMap[promotionRule.operator]?.label || promotionRule.operator, + disguised: false, + required: currentRuleAttribute.required || false, + }) + } + + res.json({ + rules: transformedRules, + }) +} diff --git a/packages/medusa/src/api-v2/admin/promotions/middlewares.ts b/packages/medusa/src/api-v2/admin/promotions/middlewares.ts index 742b7d8763..4eba8e1579 100644 --- a/packages/medusa/src/api-v2/admin/promotions/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/promotions/middlewares.ts @@ -45,6 +45,16 @@ export const adminPromotionRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["GET"], + matcher: "/admin/promotions/:id/:rule_type", + middlewares: [ + transformQuery( + AdminGetPromotionsPromotionParams, + QueryConfig.retrieveNewTransformQueryConfig + ), + ], + }, { method: ["POST"], matcher: "/admin/promotions/:id", diff --git a/packages/medusa/src/api-v2/admin/promotions/query-config.ts b/packages/medusa/src/api-v2/admin/promotions/query-config.ts index a6d94bd360..e0144f0f35 100644 --- a/packages/medusa/src/api-v2/admin/promotions/query-config.ts +++ b/packages/medusa/src/api-v2/admin/promotions/query-config.ts @@ -21,25 +21,35 @@ export const defaultAdminPromotionFields = [ "deleted_at", "campaign.id", "campaign.name", + "campaign.campaign_identifier", + "campaign.starts_at", + "campaign.ends_at", "application_method.value", "application_method.type", "application_method.max_quantity", + "application_method.apply_to_quantity", + "application_method.buy_rules_min_quantity", "application_method.target_type", "application_method.allocation", "application_method.created_at", "application_method.updated_at", "application_method.deleted_at", + "application_method.buy_rules.id", "application_method.buy_rules.attribute", "application_method.buy_rules.operator", "application_method.buy_rules.values.value", + "application_method.target_rules.id", "application_method.target_rules.attribute", "application_method.target_rules.operator", "application_method.target_rules.values.value", + "rules.id", "rules.attribute", "rules.operator", "rules.values.value", ] +const defaults = [...defaultAdminPromotionFields] + export const retrieveTransformQueryConfig = { defaultFields: defaultAdminPromotionFields, defaultRelations: defaultAdminPromotionRelations, @@ -57,3 +67,9 @@ export const listRuleValueTransformQueryConfig = { allowed: [], isList: true, } + +// TODO: replace this with the old one +export const retrieveNewTransformQueryConfig = { + defaults, + isList: false, +} diff --git a/packages/medusa/src/api-v2/admin/promotions/rule-operator-options/route.ts b/packages/medusa/src/api-v2/admin/promotions/rule-operator-options/route.ts index c5317f6c9b..7bd25b91fa 100644 --- a/packages/medusa/src/api-v2/admin/promotions/rule-operator-options/route.ts +++ b/packages/medusa/src/api-v2/admin/promotions/rule-operator-options/route.ts @@ -1,32 +1,14 @@ -import { RuleOperator } from "@medusajs/utils" import { AuthenticatedMedusaRequest, MedusaResponse, } from "../../../../types/routing" - -const operators = [ - { - id: RuleOperator.IN, - value: RuleOperator.IN, - label: "In", - }, - { - id: RuleOperator.EQ, - value: RuleOperator.EQ, - label: "Equals", - }, - { - id: RuleOperator.NE, - value: RuleOperator.NE, - label: "Not In", - }, -] +import { operatorsMap } from "../utils" export const GET = async ( req: AuthenticatedMedusaRequest, res: MedusaResponse ) => { res.json({ - operators, + operators: Object.values(operatorsMap), }) } diff --git a/packages/medusa/src/api-v2/admin/promotions/rule-value-options/[rule_type]/[rule_attribute_id]/route.ts b/packages/medusa/src/api-v2/admin/promotions/rule-value-options/[rule_type]/[rule_attribute_id]/route.ts index 8f2d009cd7..794c59e977 100644 --- a/packages/medusa/src/api-v2/admin/promotions/rule-value-options/[rule_type]/[rule_attribute_id]/route.ts +++ b/packages/medusa/src/api-v2/admin/promotions/rule-value-options/[rule_type]/[rule_attribute_id]/route.ts @@ -6,67 +6,18 @@ import { AuthenticatedMedusaRequest, MedusaResponse, } from "../../../../../../types/routing" -import { validateRuleAttribute, validateRuleType } from "../../../utils" - -const queryConfigurations = { - region: { - entryPoint: "region", - labelAttr: "name", - valueAttr: "id", - }, - currency: { - entryPoint: "currency", - labelAttr: "name", - valueAttr: "code", - }, - customer_group: { - entryPoint: "customer_group", - labelAttr: "name", - valueAttr: "id", - }, - sales_channel: { - entryPoint: "sales_channel", - labelAttr: "name", - valueAttr: "id", - }, - country: { - entryPoint: "country", - labelAttr: "display_name", - valueAttr: "iso_2", - }, - product: { - entryPoint: "product", - labelAttr: "title", - valueAttr: "id", - }, - product_category: { - entryPoint: "product_category", - labelAttr: "name", - valueAttr: "id", - }, - product_collection: { - entryPoint: "product_collection", - labelAttr: "title", - valueAttr: "id", - }, - product_type: { - entryPoint: "product_type", - labelAttr: "value", - valueAttr: "id", - }, - product_tag: { - entryPoint: "product_tag", - labelAttr: "value", - valueAttr: "id", - }, -} +import { + ruleQueryConfigurations, + validateRuleAttribute, + validateRuleType, +} from "../../../utils" export const GET = async ( req: AuthenticatedMedusaRequest, res: MedusaResponse ) => { const { rule_type: ruleType, rule_attribute_id: ruleAttributeId } = req.params - const queryConfig = queryConfigurations[ruleAttributeId] + const queryConfig = ruleQueryConfigurations[ruleAttributeId] const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) validateRuleType(ruleType) diff --git a/packages/medusa/src/api-v2/admin/promotions/types.ts b/packages/medusa/src/api-v2/admin/promotions/types.ts index df356c69b9..15d2608c44 100644 --- a/packages/medusa/src/api-v2/admin/promotions/types.ts +++ b/packages/medusa/src/api-v2/admin/promotions/types.ts @@ -3,3 +3,7 @@ import { PaginatedResponse, PromotionDTO } from "@medusajs/types" export type AdminPromotionsListRes = PaginatedResponse<{ promotions: PromotionDTO[] }> + +export type AdminPromotionRes = { + promotion: PromotionDTO +} diff --git a/packages/medusa/src/api-v2/admin/promotions/utils/index.ts b/packages/medusa/src/api-v2/admin/promotions/utils/index.ts index 193881239c..b9837dfc39 100644 --- a/packages/medusa/src/api-v2/admin/promotions/utils/index.ts +++ b/packages/medusa/src/api-v2/admin/promotions/utils/index.ts @@ -1,3 +1,5 @@ +export * from "./operators-map" export * from "./rule-attributes-map" +export * from "./rule-query-configuration" export * from "./validate-rule-attribute" export * from "./validate-rule-type" diff --git a/packages/medusa/src/api-v2/admin/promotions/utils/operators-map.ts b/packages/medusa/src/api-v2/admin/promotions/utils/operators-map.ts new file mode 100644 index 0000000000..254f6b6dde --- /dev/null +++ b/packages/medusa/src/api-v2/admin/promotions/utils/operators-map.ts @@ -0,0 +1,19 @@ +import { RuleOperator } from "@medusajs/utils" + +export const operatorsMap = { + [RuleOperator.IN]: { + id: RuleOperator.IN, + value: RuleOperator.IN, + label: "In", + }, + [RuleOperator.EQ]: { + id: RuleOperator.EQ, + value: RuleOperator.EQ, + label: "Equals", + }, + [RuleOperator.NE]: { + id: RuleOperator.NE, + value: RuleOperator.NE, + label: "Not In", + }, +} diff --git a/packages/medusa/src/api-v2/admin/promotions/utils/rule-attributes-map.ts b/packages/medusa/src/api-v2/admin/promotions/utils/rule-attributes-map.ts index 9e41712c0d..7fb2e989e0 100644 --- a/packages/medusa/src/api-v2/admin/promotions/utils/rule-attributes-map.ts +++ b/packages/medusa/src/api-v2/admin/promotions/utils/rule-attributes-map.ts @@ -1,3 +1,17 @@ +export enum DisguisedRule { + APPLY_TO_QUANTITY = "apply_to_quantity", + BUY_RULES_MIN_QUANTITY = "buy_rules_min_quantity", +} + +export const disguisedRulesMap = { + [DisguisedRule.APPLY_TO_QUANTITY]: { + relation: "application_method", + }, + [DisguisedRule.BUY_RULES_MIN_QUANTITY]: { + relation: "application_method", + }, +} + const ruleAttributes = [ { id: "currency", @@ -66,20 +80,24 @@ const commonAttributes = [ const buyRuleAttributes = [ { - id: "buy_rules_min_quantity", - value: "buy_rules_min_quantity", + id: DisguisedRule.BUY_RULES_MIN_QUANTITY, + value: DisguisedRule.BUY_RULES_MIN_QUANTITY, label: "Minimum quantity of items", + field_type: "number", required: true, + disguised: true, }, ...commonAttributes, ] const targetRuleAttributes = [ { - id: "apply_to_quantity", - value: "apply_to_quantity", + id: DisguisedRule.APPLY_TO_QUANTITY, + value: DisguisedRule.APPLY_TO_QUANTITY, label: "Quantity of items promotion will apply to", + field_type: "number", required: true, + disguised: true, }, ...commonAttributes, ] diff --git a/packages/medusa/src/api-v2/admin/promotions/utils/rule-query-configuration.ts b/packages/medusa/src/api-v2/admin/promotions/utils/rule-query-configuration.ts new file mode 100644 index 0000000000..61e9a1de9d --- /dev/null +++ b/packages/medusa/src/api-v2/admin/promotions/utils/rule-query-configuration.ts @@ -0,0 +1,52 @@ +export const ruleQueryConfigurations = { + region: { + entryPoint: "region", + labelAttr: "name", + valueAttr: "id", + }, + currency: { + entryPoint: "currency", + labelAttr: "name", + valueAttr: "code", + }, + customer_group: { + entryPoint: "customer_group", + labelAttr: "name", + valueAttr: "id", + }, + sales_channel: { + entryPoint: "sales_channel", + labelAttr: "name", + valueAttr: "id", + }, + country: { + entryPoint: "country", + labelAttr: "display_name", + valueAttr: "iso_2", + }, + product: { + entryPoint: "product", + labelAttr: "title", + valueAttr: "id", + }, + product_category: { + entryPoint: "product_category", + labelAttr: "name", + valueAttr: "id", + }, + product_collection: { + entryPoint: "product_collection", + labelAttr: "title", + valueAttr: "id", + }, + product_type: { + entryPoint: "product_type", + labelAttr: "value", + valueAttr: "id", + }, + product_tag: { + entryPoint: "product_tag", + labelAttr: "value", + valueAttr: "id", + }, +} diff --git a/packages/medusa/src/api-v2/admin/promotions/validators.ts b/packages/medusa/src/api-v2/admin/promotions/validators.ts index 17906ec6ee..c3da8a0fd2 100644 --- a/packages/medusa/src/api-v2/admin/promotions/validators.ts +++ b/packages/medusa/src/api-v2/admin/promotions/validators.ts @@ -29,6 +29,7 @@ import { XorConstraint } from "../../../types/validators/xor" import { AdminPostCampaignsReq } from "../campaigns/validators" export class AdminGetPromotionsPromotionParams extends FindParams {} +export class AdminGetPromotionRules extends FindParams {} export class AdminGetPromotionsRuleValueParams extends extendedFindParamsMixin({ limit: 100, diff --git a/packages/types/src/promotion/http.ts b/packages/types/src/promotion/http.ts new file mode 100644 index 0000000000..8942ade390 --- /dev/null +++ b/packages/types/src/promotion/http.ts @@ -0,0 +1,10 @@ +export type AdminGetPromotionRulesRes = { + id?: string + attribute: string + attribute_label: string + operator: string + operator_label: string + values: { label?: string; value: string }[] + disguised?: boolean + required?: boolean +}[] diff --git a/packages/types/src/promotion/index.ts b/packages/types/src/promotion/index.ts index dfae9af8a2..897a509125 100644 --- a/packages/types/src/promotion/index.ts +++ b/packages/types/src/promotion/index.ts @@ -1,4 +1,5 @@ export * from "./common" +export * from "./http" export * from "./mutations" export * from "./service" export * from "./workflows"