@lodashventure/medusa-campaign 1.4.1 → 1.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.medusa/server/src/admin/index.js +741 -306
  2. package/.medusa/server/src/admin/index.mjs +742 -307
  3. package/.medusa/server/src/api/admin/buy-x-get-y/[id]/route.js +2 -6
  4. package/.medusa/server/src/api/admin/coupons/[id]/route.js +76 -0
  5. package/.medusa/server/src/api/admin/coupons/route.js +88 -0
  6. package/.medusa/server/src/api/middlewares.js +32 -1
  7. package/.medusa/server/src/api/store/campaigns/route.js +78 -7
  8. package/.medusa/server/src/api/store/coupons/public/route.js +110 -0
  9. package/.medusa/server/src/api/store/customers/me/coupons/route.js +148 -0
  10. package/.medusa/server/src/modules/custom-campaigns/migrations/Migration20251024000000.js +2 -2
  11. package/.medusa/server/src/modules/custom-campaigns/migrations/Migration20251025000000.js +2 -2
  12. package/.medusa/server/src/modules/custom-campaigns/migrations/Migration20251101000000.js +16 -0
  13. package/.medusa/server/src/modules/custom-campaigns/types/campaign-type.enum.js +2 -1
  14. package/.medusa/server/src/workflows/custom-campaign/createCouponCampaignWorkflow.js +105 -0
  15. package/.medusa/server/src/workflows/custom-campaign/updateCouponCampaignWorkflow.js +59 -0
  16. package/.medusa/server/src/workflows/index.js +6 -2
  17. package/package.json +15 -30
  18. package/src/admin/components/BuyXGetYForm.tsx +24 -13
  19. package/src/admin/components/CouponForm.tsx +352 -0
  20. package/src/admin/components/CouponPage.tsx +104 -0
  21. package/src/admin/components/ProductSelector.tsx +22 -11
  22. package/src/admin/hooks/useCouponById.ts +36 -0
  23. package/src/admin/hooks/useCoupons.ts +46 -0
  24. package/src/admin/hooks/useFlashSaleById.ts +36 -10
  25. package/src/admin/hooks/useFlashSales.ts +36 -10
  26. package/src/admin/routes/coupons/[id]/page.tsx +147 -0
  27. package/src/admin/routes/coupons/create/page.tsx +49 -0
  28. package/src/admin/routes/coupons/page.tsx +15 -0
  29. package/src/admin/routes/flash-sales/[id]/page.tsx +2 -11
  30. package/src/admin/routes/flash-sales/create/page.tsx +0 -6
  31. package/src/admin/widgets/campaign-detail-widget.tsx +33 -26
  32. package/src/api/admin/buy-x-get-y/[id]/route.ts +11 -15
  33. package/src/api/admin/coupons/[id]/route.ts +98 -0
  34. package/src/api/admin/coupons/route.ts +109 -0
  35. package/src/api/middlewares.ts +34 -0
  36. package/src/api/store/campaigns/route.ts +107 -24
  37. package/src/api/store/coupons/public/route.ts +165 -0
  38. package/src/api/store/customers/me/coupons/route.ts +244 -0
  39. package/src/modules/custom-campaigns/migrations/Migration20251024000000.ts +1 -1
  40. package/src/modules/custom-campaigns/migrations/Migration20251025000000.ts +1 -1
  41. package/src/modules/custom-campaigns/migrations/Migration20251101000000.ts +21 -0
  42. package/src/modules/custom-campaigns/types/campaign-type.enum.ts +1 -0
  43. package/src/workflows/custom-campaign/createCouponCampaignWorkflow.ts +176 -0
  44. package/src/workflows/custom-campaign/updateCouponCampaignWorkflow.ts +105 -0
  45. package/src/workflows/index.ts +3 -1
  46. package/src/admin/widgets/campaign-stats-widget.tsx +0 -238
@@ -0,0 +1,109 @@
1
+ import { container, MedusaRequest, MedusaResponse } from "@medusajs/framework";
2
+ import {
3
+ MedusaError,
4
+ Modules,
5
+ } from "@medusajs/framework/utils";
6
+ import { createFindParams } from "@medusajs/medusa/api/utils/validators";
7
+ import z from "zod";
8
+ import { CampaignTypeEnum } from "../../../modules/custom-campaigns/types/campaign-type.enum";
9
+ import { createCouponCampaignWorkflow } from "../../../workflows/custom-campaign/createCouponCampaignWorkflow";
10
+ import { CUSTOM_CAMPAIGN_MODULE } from "../../../modules/custom-campaigns";
11
+ import CustomCampaignModuleService from "../../../modules/custom-campaigns/service";
12
+
13
+ export const createCouponCampaignSchema = z
14
+ .object({
15
+ name: z.string().min(1, "Name is required"),
16
+ description: z.string().min(1, "Description is required"),
17
+ type: z.literal(CampaignTypeEnum.Coupon),
18
+ code: z.string().min(1, "Coupon code is required"),
19
+ starts_at: z.coerce.date(),
20
+ ends_at: z.coerce.date(),
21
+ discount_type: z.enum(["percentage", "fixed"]),
22
+ discount_value: z.number().positive("Discount value must be positive"),
23
+ currency_code: z.string().optional(),
24
+ allocation: z.enum(["each", "total"]).optional(),
25
+ target_type: z.enum(["order", "items"]).optional(),
26
+ })
27
+ .superRefine((data, ctx) => {
28
+ if (data.ends_at <= data.starts_at) {
29
+ ctx.addIssue({
30
+ code: z.ZodIssueCode.custom,
31
+ message: "End date must be after start date",
32
+ path: ["ends_at"],
33
+ });
34
+ }
35
+
36
+ if (data.discount_type === "fixed" && !data.currency_code) {
37
+ ctx.addIssue({
38
+ code: z.ZodIssueCode.custom,
39
+ message: "currency_code is required for fixed discount type",
40
+ path: ["currency_code"],
41
+ });
42
+ }
43
+ });
44
+
45
+ export type CouponCampaignInput = z.infer<typeof createCouponCampaignSchema>;
46
+
47
+ export const GetCouponCampaignsSchema = createFindParams({
48
+ order: "-created_at",
49
+ });
50
+
51
+ export const POST = async (
52
+ req: MedusaRequest<CouponCampaignInput>,
53
+ res: MedusaResponse,
54
+ ) => {
55
+ const body = req.body;
56
+
57
+ if (body.type !== CampaignTypeEnum.Coupon) {
58
+ throw new MedusaError(
59
+ MedusaError.Types.INVALID_DATA,
60
+ "Invalid campaign type for coupons",
61
+ );
62
+ }
63
+
64
+ const { result } = await createCouponCampaignWorkflow.run({
65
+ input: {
66
+ ...body,
67
+ },
68
+ });
69
+
70
+ res.status(200).json(result);
71
+ };
72
+
73
+ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
74
+ const customCampaignModuleService =
75
+ container.resolve<CustomCampaignModuleService>(CUSTOM_CAMPAIGN_MODULE);
76
+ const promotionService = container.resolve(Modules.PROMOTION);
77
+
78
+ const customCampaignTypes =
79
+ await customCampaignModuleService.listCustomCampaignTypes({
80
+ type: CampaignTypeEnum.Coupon,
81
+ });
82
+
83
+ if (!customCampaignTypes.length) {
84
+ return res.status(200).json({
85
+ campaigns: [],
86
+ count: 0,
87
+ limit: 20,
88
+ offset: 0,
89
+ });
90
+ }
91
+
92
+ const campaignIds = customCampaignTypes.map((ct) => ct.campaign_id);
93
+
94
+ const campaigns = await promotionService.listCampaigns(
95
+ {
96
+ id: campaignIds,
97
+ },
98
+ {
99
+ relations: ["promotions", "promotions.application_method"],
100
+ },
101
+ );
102
+
103
+ res.status(200).json({
104
+ campaigns,
105
+ count: campaigns.length,
106
+ limit: campaigns.length,
107
+ offset: 0,
108
+ });
109
+ };
@@ -7,7 +7,12 @@ import {
7
7
  createCustomCampaignSchema,
8
8
  GetFlashSalesSchema,
9
9
  } from "./admin/flash-sales/route";
10
+ import {
11
+ createCouponCampaignSchema,
12
+ GetCouponCampaignsSchema,
13
+ } from "./admin/coupons/route";
10
14
  import multer from "multer";
15
+ import { collectCouponSchema } from "./store/customers/me/coupons/route";
11
16
 
12
17
  const upload = multer({
13
18
  storage: multer.memoryStorage(),
@@ -34,6 +39,30 @@ export default defineMiddlewares([
34
39
  }),
35
40
  ],
36
41
  },
42
+ {
43
+ methods: ["POST"],
44
+ matcher: "/admin/coupons",
45
+ middlewares: [
46
+ validateAndTransformBody(createCouponCampaignSchema as any),
47
+ ],
48
+ },
49
+ {
50
+ methods: ["PUT"],
51
+ matcher: "/admin/coupons/:id",
52
+ middlewares: [
53
+ validateAndTransformBody(createCouponCampaignSchema as any),
54
+ ],
55
+ },
56
+ {
57
+ methods: ["GET"],
58
+ matcher: "/admin/coupons",
59
+ middlewares: [
60
+ validateAndTransformQuery(GetCouponCampaignsSchema as any, {
61
+ defaults: ["*", "campaign.*"],
62
+ isList: true,
63
+ }),
64
+ ],
65
+ },
37
66
  {
38
67
  methods: ["POST"],
39
68
  matcher: "/admin/campaigns/:id/image",
@@ -50,4 +79,9 @@ export default defineMiddlewares([
50
79
  upload.single("file"),
51
80
  ],
52
81
  },
82
+ {
83
+ methods: ["POST"],
84
+ matcher: "/store/customers/me/coupons",
85
+ middlewares: [validateAndTransformBody(collectCouponSchema as any)],
86
+ },
53
87
  ]);
@@ -1,5 +1,5 @@
1
1
  import { container, MedusaRequest, MedusaResponse } from "@medusajs/framework";
2
- import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils";
2
+ import { Modules } from "@medusajs/framework/utils";
3
3
  import { CampaignTypeEnum } from "../../../modules/custom-campaigns/types/campaign-type.enum";
4
4
  import { CUSTOM_CAMPAIGN_MODULE } from "../../../modules/custom-campaigns";
5
5
  import CustomCampaignModuleService from "../../../modules/custom-campaigns/service";
@@ -11,6 +11,7 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
11
11
  const customCampaignModuleService =
12
12
  container.resolve<CustomCampaignModuleService>(CUSTOM_CAMPAIGN_MODULE);
13
13
  const promotionService = container.resolve(Modules.PROMOTION);
14
+ const productService = container.resolve(Modules.PRODUCT);
14
15
  const now = new Date();
15
16
 
16
17
  try {
@@ -65,9 +66,24 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
65
66
  const allCampaigns = await promotionService.listCampaigns({});
66
67
  console.log(`Total campaigns in system: ${allCampaigns.length}`);
67
68
 
68
- // Then fetch specific campaigns
69
+ // Then fetch specific campaigns with relations
69
70
  const campaigns = campaignIds.length > 0
70
- ? allCampaigns.filter((c: any) => campaignIds.includes(c.id))
71
+ ? await Promise.all(
72
+ campaignIds.map(async (campaignId) => {
73
+ try {
74
+ return await promotionService.retrieveCampaign(campaignId, {
75
+ relations: [
76
+ "promotions",
77
+ "promotions.application_method",
78
+ "promotions.application_method.target_rules",
79
+ ],
80
+ });
81
+ } catch (error) {
82
+ console.error(`Error fetching campaign ${campaignId}:`, error);
83
+ return null;
84
+ }
85
+ })
86
+ ).then((results) => results.filter((c) => c !== null))
71
87
  : [];
72
88
 
73
89
  console.log(`Found ${campaigns.length} matching campaigns`);
@@ -83,27 +99,94 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
83
99
  );
84
100
 
85
101
  // Process ALL campaigns first (without date filter) for debugging
86
- const allProcessedCampaigns = campaigns.map((campaign: any) => {
87
- const startsAt = new Date(campaign.starts_at);
88
- const endsAt = new Date(campaign.ends_at);
89
- const isActive = startsAt <= now && endsAt >= now;
90
-
91
- console.log(` Checking campaign "${campaign.name}":`);
92
- console.log(` Start: ${startsAt.toISOString()} (${startsAt <= now ? 'started' : 'not started'})`);
93
- console.log(` End: ${endsAt.toISOString()} (${endsAt >= now ? 'not ended' : 'ended'})`);
94
- console.log(` Active: ${isActive}`);
95
-
96
- return {
97
- id: campaign.id,
98
- name: campaign.name,
99
- description: campaign.description,
100
- campaign_identifier: campaign.campaign_identifier,
101
- starts_at: campaign.starts_at,
102
- ends_at: campaign.ends_at,
103
- campaign_type: campaignTypeMap.get(campaign.id),
104
- is_active: isActive
105
- };
106
- });
102
+ const allProcessedCampaigns = await Promise.all(
103
+ campaigns.map(async (campaign: any) => {
104
+ const startsAt = new Date(campaign.starts_at);
105
+ const endsAt = new Date(campaign.ends_at);
106
+ const isActive = startsAt <= now && endsAt >= now;
107
+
108
+ console.log(` Checking campaign "${campaign.name}":`);
109
+ console.log(` Start: ${startsAt.toISOString()} (${startsAt <= now ? 'started' : 'not started'})`);
110
+ console.log(` End: ${endsAt.toISOString()} (${endsAt >= now ? 'not ended' : 'ended'})`);
111
+ console.log(` Active: ${isActive}`);
112
+
113
+ const campaignType = campaignTypeMap.get(campaign.id);
114
+
115
+ // Fetch products for flash-sale campaigns
116
+ let products: any[] = [];
117
+ if (campaignType === CampaignTypeEnum.FlashSale) {
118
+ try {
119
+ // Fetch promotion usage limits for this campaign
120
+ const promotionUsageLimits =
121
+ await customCampaignModuleService.listPromotionUsageLimits({
122
+ campaign_id: campaign.id,
123
+ });
124
+
125
+ // Create a map of promotion usage limits by promotion ID
126
+ const promotionLimitsMap = new Map();
127
+ promotionUsageLimits.forEach((limit) => {
128
+ promotionLimitsMap.set(limit.promotion_id, limit);
129
+ });
130
+
131
+ // Process promotions to extract product information
132
+ for (const promotion of campaign.promotions ?? []) {
133
+ if (!promotion.application_method?.target_rules?.length) {
134
+ continue;
135
+ }
136
+
137
+ const promotionLimit = promotionLimitsMap.get(promotion.id);
138
+ if (!promotionLimit?.product_id) {
139
+ continue;
140
+ }
141
+
142
+ try {
143
+ const product = await productService.retrieveProduct(
144
+ promotionLimit.product_id,
145
+ {
146
+ select: ["id", "title", "thumbnail", "handle", "description"],
147
+ relations: ["images"],
148
+ },
149
+ );
150
+
151
+ if (promotion.application_method.value !== undefined) {
152
+ products.push({
153
+ product: {
154
+ id: product.id,
155
+ title: product.title,
156
+ thumbnail: product.thumbnail,
157
+ handle: product.handle,
158
+ description: product.description,
159
+ images: product.images,
160
+ },
161
+ discountType: promotion.application_method?.type as "percentage",
162
+ discountValue: promotion.application_method?.value,
163
+ maxQty: promotion.application_method?.max_quantity ?? 0,
164
+ limit: promotionLimit?.limit ?? 0,
165
+ used: promotionLimit?.used ?? 0,
166
+ });
167
+ }
168
+ } catch (error) {
169
+ console.error(`Error fetching product ${promotionLimit.product_id}:`, error);
170
+ }
171
+ }
172
+ } catch (error) {
173
+ console.error(`Error fetching products for campaign ${campaign.id}:`, error);
174
+ }
175
+ }
176
+
177
+ return {
178
+ id: campaign.id,
179
+ name: campaign.name,
180
+ description: campaign.description,
181
+ campaign_identifier: campaign.campaign_identifier,
182
+ starts_at: campaign.starts_at,
183
+ ends_at: campaign.ends_at,
184
+ campaign_type: campaignType,
185
+ is_active: isActive,
186
+ ...(products.length > 0 && { products }),
187
+ };
188
+ })
189
+ );
107
190
 
108
191
  // Filter for active campaigns
109
192
  const activeCampaigns = allProcessedCampaigns.filter(c => c.is_active);
@@ -0,0 +1,165 @@
1
+ import { MedusaRequest, MedusaResponse, container } from "@medusajs/framework";
2
+ import { Modules } from "@medusajs/framework/utils";
3
+ import { CampaignTypeEnum } from "../../../../modules/custom-campaigns/types/campaign-type.enum";
4
+ import { CUSTOM_CAMPAIGN_MODULE } from "../../../../modules/custom-campaigns";
5
+ import CustomCampaignModuleService from "../../../../modules/custom-campaigns/service";
6
+
7
+ type UsablePromoModuleService = {
8
+ listUsablePromotions: (selector: Record<string, unknown>) => Promise<
9
+ Array<{
10
+ id: string;
11
+ promotion_id: string;
12
+ enabled: boolean;
13
+ }>
14
+ >;
15
+ };
16
+
17
+ type PromotionCacheService = {
18
+ exists: (key: string) => Promise<boolean>;
19
+ };
20
+
21
+ const USABLE_PROMO_MODULE = "usable_promotion";
22
+ const PROMOTION_CACHE_MODULE = "promotion_cache";
23
+
24
+ const resolveOptional = <T>(token: string): T | null => {
25
+ try {
26
+ return container.resolve<T>(token);
27
+ } catch {
28
+ return null;
29
+ }
30
+ };
31
+
32
+ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
33
+ const customCampaignModuleService =
34
+ container.resolve<CustomCampaignModuleService>(CUSTOM_CAMPAIGN_MODULE);
35
+ const promotionService = container.resolve(Modules.PROMOTION);
36
+
37
+ const usablePromoService =
38
+ resolveOptional<UsablePromoModuleService>(USABLE_PROMO_MODULE);
39
+ const promotionCacheService = resolveOptional<PromotionCacheService>(
40
+ PROMOTION_CACHE_MODULE,
41
+ );
42
+
43
+ const now = new Date();
44
+ const customerId = (
45
+ req as unknown as { auth_context?: { actor_id?: string } }
46
+ )?.auth_context?.actor_id;
47
+
48
+ const parsedLimit = req.query?.limit ? Number(req.query.limit) : 20;
49
+ const parsedOffset = req.query?.offset ? Number(req.query.offset) : 0;
50
+
51
+ const limit = Number.isNaN(parsedLimit) ? 20 : parsedLimit;
52
+ const offset = Number.isNaN(parsedOffset) ? 0 : parsedOffset;
53
+
54
+ const couponCampaignTypes =
55
+ await customCampaignModuleService.listCustomCampaignTypes({
56
+ type: CampaignTypeEnum.Coupon,
57
+ });
58
+
59
+ if (!couponCampaignTypes.length) {
60
+ return res.status(200).json({
61
+ coupons: [],
62
+ count: 0,
63
+ limit,
64
+ offset,
65
+ });
66
+ }
67
+
68
+ const campaignIds = couponCampaignTypes.map((ct) => ct.campaign_id);
69
+
70
+ const promotions = await promotionService.listPromotions(
71
+ {
72
+ campaign_id: { $in: campaignIds },
73
+ } as any,
74
+ {
75
+ relations: ["campaign", "application_method"],
76
+ },
77
+ );
78
+
79
+ const campaignDetails = await customCampaignModuleService.listCampaignDetails(
80
+ {
81
+ campaign_id: campaignIds,
82
+ },
83
+ );
84
+
85
+ const detailMap = new Map(
86
+ campaignDetails.map((detail) => [detail.campaign_id, detail]),
87
+ );
88
+
89
+ const usablePromotions = usablePromoService
90
+ ? await usablePromoService.listUsablePromotions({
91
+ promotion_id: promotions.map((promo) => promo.id),
92
+ })
93
+ : [];
94
+
95
+ const usableMap = new Map(
96
+ usablePromotions.map((usable) => [usable.promotion_id, usable.enabled]),
97
+ );
98
+
99
+ const filteredPromotions = promotions.filter((promotion) => {
100
+ const campaign = promotion.campaign;
101
+ if (!campaign) {
102
+ return false;
103
+ }
104
+
105
+ const startsAt = campaign.starts_at ? new Date(campaign.starts_at) : null;
106
+ const endsAt = campaign.ends_at ? new Date(campaign.ends_at) : null;
107
+
108
+ const withinWindow =
109
+ (!startsAt || startsAt <= now) && (!endsAt || endsAt >= now);
110
+
111
+ const isCollectable = usableMap.has(promotion.id)
112
+ ? usableMap.get(promotion.id)!
113
+ : true;
114
+
115
+ return (
116
+ campaign &&
117
+ withinWindow &&
118
+ promotion.status === "active" &&
119
+ !promotion.is_automatic &&
120
+ isCollectable
121
+ );
122
+ });
123
+
124
+ const paginated = filteredPromotions.slice(offset, offset + limit);
125
+
126
+ const coupons = await Promise.all(
127
+ paginated.map(async (promotion) => {
128
+ const campaign = promotion.campaign!;
129
+ const detail = detailMap.get(campaign.id);
130
+ let isCollected = false;
131
+
132
+ if (customerId && promotionCacheService) {
133
+ const key = `user:${customerId}:promotion:${promotion.id}`;
134
+ isCollected = await promotionCacheService.exists(key);
135
+ }
136
+
137
+ return {
138
+ id: promotion.id,
139
+ code: promotion.code,
140
+ name: campaign.name,
141
+ description: campaign.description,
142
+ starts_at: campaign.starts_at,
143
+ ends_at: campaign.ends_at,
144
+ discount_type: promotion.application_method?.type,
145
+ discount_value: promotion.application_method?.value,
146
+ allocation: promotion.application_method?.allocation,
147
+ target_type: promotion.application_method?.target_type,
148
+ currency_code: promotion.application_method?.currency_code,
149
+ campaign_id: campaign.id,
150
+ is_collectable: usableMap.has(promotion.id)
151
+ ? usableMap.get(promotion.id)!
152
+ : true,
153
+ is_collected: isCollected,
154
+ detail: detail ?? null,
155
+ };
156
+ }),
157
+ );
158
+
159
+ res.status(200).json({
160
+ coupons,
161
+ count: filteredPromotions.length,
162
+ limit,
163
+ offset,
164
+ });
165
+ };