@lodashventure/medusa-campaign 1.1.4 → 1.1.6

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 (50) hide show
  1. package/.medusa/server/src/api/admin/buy-x-get-y/[id]/route.js +116 -0
  2. package/.medusa/server/src/api/admin/buy-x-get-y/route.js +83 -0
  3. package/.medusa/server/src/api/admin/campaigns/fix-dates/route.js +103 -0
  4. package/.medusa/server/src/api/admin/campaigns/sync/route.js +138 -0
  5. package/.medusa/server/src/api/admin/flash-sales/[id]/route.js +49 -34
  6. package/.medusa/server/src/api/admin/flash-sales/route.js +46 -19
  7. package/.medusa/server/src/api/admin/force-fix/route.js +176 -0
  8. package/.medusa/server/src/api/admin/test-campaign/route.js +132 -0
  9. package/.medusa/server/src/api/store/buy-x-get-y/[id]/route.js +109 -0
  10. package/.medusa/server/src/api/store/buy-x-get-y/products/[productId]/route.js +94 -0
  11. package/.medusa/server/src/api/store/buy-x-get-y/route.js +114 -0
  12. package/.medusa/server/src/api/store/campaigns/[id]/route.js +132 -70
  13. package/.medusa/server/src/api/store/campaigns/route.js +119 -26
  14. package/.medusa/server/src/index.js +15 -0
  15. package/.medusa/server/src/modules/custom-campaigns/migrations/Migration20251018000000.js +40 -0
  16. package/.medusa/server/src/modules/custom-campaigns/models/buy-x-get-y-config.js +20 -0
  17. package/.medusa/server/src/modules/custom-campaigns/service.js +3 -1
  18. package/.medusa/server/src/modules/custom-campaigns/types/campaign-type.enum.js +2 -1
  19. package/.medusa/server/src/subscribers/cart-updated.js +23 -0
  20. package/.medusa/server/src/subscribers/order-placed.js +9 -2
  21. package/.medusa/server/src/workflows/buy-x-get-y/applyBuyXGetYToCartWorkflow.js +150 -0
  22. package/.medusa/server/src/workflows/custom-campaign/createBuyXGetYCampaignWorkflow.js +127 -0
  23. package/.medusa/server/src/workflows/custom-campaign/updateBuyXGetYCampaignWorkflow.js +114 -0
  24. package/.medusa/server/src/workflows/custom-campaign/updateBuyXGetYUsageWorkflow.js +51 -0
  25. package/package.json +2 -2
  26. package/src/admin/components/BuyXGetYForm.tsx +422 -0
  27. package/src/api/admin/buy-x-get-y/[id]/route.ts +164 -0
  28. package/src/api/admin/buy-x-get-y/route.ts +104 -0
  29. package/src/api/admin/campaigns/fix-dates/route.ts +107 -0
  30. package/src/api/admin/campaigns/sync/route.ts +153 -0
  31. package/src/api/admin/flash-sales/[id]/route.ts +62 -36
  32. package/src/api/admin/flash-sales/route.ts +57 -21
  33. package/src/api/admin/force-fix/route.ts +184 -0
  34. package/src/api/admin/test-campaign/route.ts +141 -0
  35. package/src/api/store/buy-x-get-y/[id]/route.ts +146 -0
  36. package/src/api/store/buy-x-get-y/products/[productId]/route.ts +129 -0
  37. package/src/api/store/buy-x-get-y/route.ts +134 -0
  38. package/src/api/store/campaigns/[id]/route.ts +159 -79
  39. package/src/api/store/campaigns/route.ts +141 -30
  40. package/src/index.ts +10 -0
  41. package/src/modules/custom-campaigns/migrations/Migration20251018000000.ts +42 -0
  42. package/src/modules/custom-campaigns/models/buy-x-get-y-config.ts +19 -0
  43. package/src/modules/custom-campaigns/service.ts +2 -0
  44. package/src/modules/custom-campaigns/types/campaign-type.enum.ts +1 -0
  45. package/src/subscribers/cart-updated.ts +23 -0
  46. package/src/subscribers/order-placed.ts +9 -1
  47. package/src/workflows/buy-x-get-y/applyBuyXGetYToCartWorkflow.ts +222 -0
  48. package/src/workflows/custom-campaign/createBuyXGetYCampaignWorkflow.ts +210 -0
  49. package/src/workflows/custom-campaign/updateBuyXGetYCampaignWorkflow.ts +190 -0
  50. package/src/workflows/custom-campaign/updateBuyXGetYUsageWorkflow.ts +86 -0
@@ -0,0 +1,422 @@
1
+ import { Trash } from "@medusajs/icons";
2
+ import {
3
+ Button,
4
+ Container,
5
+ Input,
6
+ Label,
7
+ Select,
8
+ Table,
9
+ Textarea,
10
+ toast,
11
+ } from "@medusajs/ui";
12
+ import dayjs from "dayjs";
13
+ import { FC, useState } from "react";
14
+ import { Controller, useFieldArray, useForm } from "react-hook-form";
15
+ import { zodResolver } from "@hookform/resolvers/zod";
16
+ import z from "zod";
17
+ import { ProductSelector } from "./ProductSelector";
18
+
19
+ const buyXGetYCampaignSchema = z.object({
20
+ name: z.string().min(1, "Name is required"),
21
+ description: z.string().min(1, "Description is required"),
22
+ type: z.literal("buy-x-get-y"),
23
+ starts_at: z.string().min(1, "Start date is required"),
24
+ ends_at: z.string().min(1, "End date is required"),
25
+ });
26
+
27
+ const buyXGetYRuleSchema = z.object({
28
+ triggerProduct: z.object({
29
+ id: z.string(),
30
+ title: z.string(),
31
+ }),
32
+ triggerQuantity: z.number().min(1, "Trigger quantity must be at least 1"),
33
+ rewardProduct: z.object({
34
+ id: z.string(),
35
+ title: z.string(),
36
+ }),
37
+ rewardQuantity: z.number().min(1, "Reward quantity must be at least 1"),
38
+ rewardType: z.enum(["free", "percentage", "fixed"]),
39
+ rewardValue: z.number().optional(),
40
+ limit: z.number().min(1).optional(),
41
+ });
42
+
43
+ export type BuyXGetYRule = z.infer<typeof buyXGetYRuleSchema>;
44
+
45
+ export const buyXGetYDataSchema = buyXGetYCampaignSchema
46
+ .extend({
47
+ rules: z
48
+ .array(buyXGetYRuleSchema)
49
+ .min(1, "At least one rule is required"),
50
+ })
51
+ .refine(
52
+ (data) => new Date(data.starts_at) < new Date(data.ends_at),
53
+ "End date must be after start date"
54
+ );
55
+
56
+ export type BuyXGetYData = z.infer<typeof buyXGetYDataSchema>;
57
+
58
+ interface BuyXGetYFormProps {
59
+ initialData?: z.infer<typeof buyXGetYCampaignSchema>;
60
+ initialRules?: BuyXGetYRule[];
61
+ onSubmit: (data: BuyXGetYData) => void;
62
+ onCancel: () => void;
63
+ disabled?: boolean;
64
+ }
65
+
66
+ export const BuyXGetYForm: FC<BuyXGetYFormProps> = ({
67
+ initialData,
68
+ initialRules,
69
+ onSubmit,
70
+ onCancel,
71
+ disabled = false,
72
+ }) => {
73
+ const {
74
+ control,
75
+ register,
76
+ handleSubmit,
77
+ watch,
78
+ setValue,
79
+ formState: { errors },
80
+ } = useForm<BuyXGetYData>({
81
+ resolver: zodResolver(buyXGetYDataSchema),
82
+ defaultValues: {
83
+ name: initialData?.name || "",
84
+ description: initialData?.description || "",
85
+ type: "buy-x-get-y",
86
+ starts_at: initialData?.starts_at || "",
87
+ ends_at: initialData?.ends_at || "",
88
+ rules: initialRules || [],
89
+ },
90
+ });
91
+
92
+ const { fields, append, remove } = useFieldArray({
93
+ control,
94
+ name: "rules",
95
+ });
96
+
97
+ const [openTriggerProductModal, setOpenTriggerProductModal] = useState<number | null>(null);
98
+ const [openRewardProductModal, setOpenRewardProductModal] = useState<number | null>(null);
99
+
100
+ const startsAt = watch("starts_at");
101
+ const endsAt = watch("ends_at");
102
+
103
+ const handleDateTimeChange = (
104
+ field: "starts_at" | "ends_at",
105
+ type: "date" | "time",
106
+ value: string
107
+ ) => {
108
+ const currentValue = watch(field);
109
+
110
+ if (type === "date") {
111
+ const time = currentValue
112
+ ? dayjs(currentValue).format("HH:mm")
113
+ : field === "starts_at"
114
+ ? "00:00"
115
+ : "23:59";
116
+ setValue(field, `${value}T${time}`);
117
+ } else {
118
+ const date = currentValue
119
+ ? dayjs(currentValue).format("YYYY-MM-DD")
120
+ : dayjs().format("YYYY-MM-DD");
121
+ setValue(field, `${date}T${value}`);
122
+ }
123
+ };
124
+
125
+ const onFormSubmit = (data: BuyXGetYData) => {
126
+ onSubmit(data);
127
+ };
128
+
129
+ const onFormError = () => {
130
+ const errorMessages = Object.entries(errors)
131
+ .map(([key, value]) => {
132
+ if (key === "rules" && Array.isArray(value)) {
133
+ return value
134
+ .map((item, index) =>
135
+ item
136
+ ? `Rule ${index + 1}: ${Object.values(item).map(v => v?.message).filter(Boolean).join(", ")}`
137
+ : ""
138
+ )
139
+ .filter(Boolean)
140
+ .join(", ");
141
+ }
142
+ return value?.message;
143
+ })
144
+ .filter(Boolean)
145
+ .join(", ");
146
+
147
+ toast.error("Invalid data", {
148
+ description: errorMessages,
149
+ });
150
+ };
151
+
152
+ const addNewRule = () => {
153
+ append({
154
+ triggerProduct: { id: "", title: "" },
155
+ triggerQuantity: 1,
156
+ rewardProduct: { id: "", title: "" },
157
+ rewardQuantity: 1,
158
+ rewardType: "free",
159
+ rewardValue: undefined,
160
+ limit: undefined,
161
+ });
162
+ };
163
+
164
+ return (
165
+ <Container>
166
+ <div className="flex items-center justify-between">
167
+ <h1 className="text-xl font-semibold">Buy X Get Y Campaign</h1>
168
+ <Button variant="transparent" onClick={onCancel}>
169
+ Cancel
170
+ </Button>
171
+ </div>
172
+ <form
173
+ onSubmit={handleSubmit(onFormSubmit, onFormError)}
174
+ className="space-y-4 my-8"
175
+ >
176
+ <div>
177
+ <Label>Name</Label>
178
+ <Input {...register("name")} disabled={disabled} />
179
+ {errors.name && (
180
+ <p className="text-red-500 text-sm mt-1">{errors.name.message}</p>
181
+ )}
182
+ </div>
183
+
184
+ <div>
185
+ <Label>Description</Label>
186
+ <Textarea {...register("description")} disabled={disabled} />
187
+ {errors.description && (
188
+ <p className="text-red-500 text-sm mt-1">
189
+ {errors.description.message}
190
+ </p>
191
+ )}
192
+ </div>
193
+
194
+ <div className="grid grid-cols-2 gap-4">
195
+ <div>
196
+ <Label>Start Date</Label>
197
+ <div className="flex gap-2">
198
+ <Input
199
+ type="date"
200
+ value={startsAt ? dayjs(startsAt).format("YYYY-MM-DD") : ""}
201
+ onChange={(e) =>
202
+ handleDateTimeChange("starts_at", "date", e.target.value)
203
+ }
204
+ disabled={disabled}
205
+ className="flex-1"
206
+ />
207
+ <Input
208
+ type="time"
209
+ value={startsAt ? dayjs(startsAt).format("HH:mm") : ""}
210
+ onChange={(e) =>
211
+ handleDateTimeChange("starts_at", "time", e.target.value)
212
+ }
213
+ disabled={disabled}
214
+ className="w-32"
215
+ />
216
+ </div>
217
+ {errors.starts_at && (
218
+ <p className="text-red-500 text-sm mt-1">
219
+ {errors.starts_at.message}
220
+ </p>
221
+ )}
222
+ </div>
223
+ <div>
224
+ <Label>End Date</Label>
225
+ <div className="flex gap-2">
226
+ <Input
227
+ type="date"
228
+ value={endsAt ? dayjs(endsAt).format("YYYY-MM-DD") : ""}
229
+ onChange={(e) =>
230
+ handleDateTimeChange("ends_at", "date", e.target.value)
231
+ }
232
+ disabled={disabled}
233
+ className="flex-1"
234
+ />
235
+ <Input
236
+ type="time"
237
+ value={endsAt ? dayjs(endsAt).format("HH:mm") : ""}
238
+ onChange={(e) =>
239
+ handleDateTimeChange("ends_at", "time", e.target.value)
240
+ }
241
+ disabled={disabled}
242
+ className="w-32"
243
+ />
244
+ </div>
245
+ {errors.ends_at && (
246
+ <p className="text-red-500 text-sm mt-1">
247
+ {errors.ends_at.message}
248
+ </p>
249
+ )}
250
+ </div>
251
+ </div>
252
+
253
+ <div className="flex justify-between items-center">
254
+ <Label>Rules (Buy X Get Y)</Label>
255
+ <Button
256
+ type="button"
257
+ variant="secondary"
258
+ onClick={addNewRule}
259
+ disabled={disabled}
260
+ >
261
+ Add Rule
262
+ </Button>
263
+ </div>
264
+
265
+ {fields.length > 0 && (
266
+ <Table>
267
+ <Table.Header>
268
+ <Table.Row>
269
+ <Table.HeaderCell>Trigger Product</Table.HeaderCell>
270
+ <Table.HeaderCell>Qty</Table.HeaderCell>
271
+ <Table.HeaderCell>Reward Product</Table.HeaderCell>
272
+ <Table.HeaderCell>Qty</Table.HeaderCell>
273
+ <Table.HeaderCell>Reward Type</Table.HeaderCell>
274
+ <Table.HeaderCell>Value</Table.HeaderCell>
275
+ <Table.HeaderCell>Limit</Table.HeaderCell>
276
+ <Table.HeaderCell></Table.HeaderCell>
277
+ </Table.Row>
278
+ </Table.Header>
279
+ <Table.Body>
280
+ {fields.map((field, index) => {
281
+ const rewardType = watch(`rules.${index}.rewardType`);
282
+
283
+ return (
284
+ <Table.Row key={field.id}>
285
+ <Table.Cell>
286
+ <div className="flex flex-col">
287
+ <Input
288
+ value={watch(`rules.${index}.triggerProduct.title`) || "Select product"}
289
+ onClick={() => setOpenTriggerProductModal(index)}
290
+ readOnly
291
+ disabled={disabled}
292
+ />
293
+ {openTriggerProductModal === index && (
294
+ <ProductSelector
295
+ onSelect={(product) => {
296
+ setValue(`rules.${index}.triggerProduct`, {
297
+ id: product.id,
298
+ title: product.title,
299
+ });
300
+ setOpenTriggerProductModal(null);
301
+ }}
302
+ onClose={() => setOpenTriggerProductModal(null)}
303
+ />
304
+ )}
305
+ </div>
306
+ </Table.Cell>
307
+ <Table.Cell>
308
+ <Input
309
+ type="number"
310
+ {...register(`rules.${index}.triggerQuantity`, {
311
+ valueAsNumber: true,
312
+ })}
313
+ disabled={disabled}
314
+ className="w-20"
315
+ />
316
+ </Table.Cell>
317
+ <Table.Cell>
318
+ <div className="flex flex-col">
319
+ <Input
320
+ value={watch(`rules.${index}.rewardProduct.title`) || "Select product"}
321
+ onClick={() => setOpenRewardProductModal(index)}
322
+ readOnly
323
+ disabled={disabled}
324
+ />
325
+ {openRewardProductModal === index && (
326
+ <ProductSelector
327
+ onSelect={(product) => {
328
+ setValue(`rules.${index}.rewardProduct`, {
329
+ id: product.id,
330
+ title: product.title,
331
+ });
332
+ setOpenRewardProductModal(null);
333
+ }}
334
+ onClose={() => setOpenRewardProductModal(null)}
335
+ />
336
+ )}
337
+ </div>
338
+ </Table.Cell>
339
+ <Table.Cell>
340
+ <Input
341
+ type="number"
342
+ {...register(`rules.${index}.rewardQuantity`, {
343
+ valueAsNumber: true,
344
+ })}
345
+ disabled={disabled}
346
+ className="w-20"
347
+ />
348
+ </Table.Cell>
349
+ <Table.Cell>
350
+ <Controller
351
+ control={control}
352
+ name={`rules.${index}.rewardType`}
353
+ render={({ field }) => (
354
+ <Select {...field} disabled={disabled}>
355
+ <option value="free">Free</option>
356
+ <option value="percentage">Percentage Off</option>
357
+ <option value="fixed">Fixed Discount</option>
358
+ </Select>
359
+ )}
360
+ />
361
+ </Table.Cell>
362
+ <Table.Cell>
363
+ {rewardType !== "free" && (
364
+ <Input
365
+ type="number"
366
+ {...register(`rules.${index}.rewardValue`, {
367
+ valueAsNumber: true,
368
+ })}
369
+ disabled={disabled}
370
+ placeholder={rewardType === "percentage" ? "%" : "$"}
371
+ className="w-24"
372
+ />
373
+ )}
374
+ </Table.Cell>
375
+ <Table.Cell>
376
+ <Input
377
+ type="number"
378
+ {...register(`rules.${index}.limit`, {
379
+ valueAsNumber: true,
380
+ })}
381
+ disabled={disabled}
382
+ placeholder="Unlimited"
383
+ className="w-24"
384
+ />
385
+ </Table.Cell>
386
+ <Table.Cell>
387
+ <Button
388
+ type="button"
389
+ variant="transparent"
390
+ onClick={() => remove(index)}
391
+ disabled={disabled}
392
+ >
393
+ <Trash />
394
+ </Button>
395
+ </Table.Cell>
396
+ </Table.Row>
397
+ );
398
+ })}
399
+ </Table.Body>
400
+ </Table>
401
+ )}
402
+
403
+ {errors.rules && (
404
+ <p className="text-red-500 text-sm mt-1">
405
+ {typeof errors.rules === "object" && "message" in errors.rules
406
+ ? errors.rules.message
407
+ : "Please check your rules configuration"}
408
+ </p>
409
+ )}
410
+
411
+ <div className="flex justify-end gap-2 mt-6">
412
+ <Button type="button" variant="secondary" onClick={onCancel}>
413
+ Cancel
414
+ </Button>
415
+ <Button type="submit" disabled={disabled}>
416
+ {initialData ? "Update Campaign" : "Create Campaign"}
417
+ </Button>
418
+ </div>
419
+ </form>
420
+ </Container>
421
+ );
422
+ };
@@ -0,0 +1,164 @@
1
+ import { container, MedusaRequest, MedusaResponse } from "@medusajs/framework";
2
+ import {
3
+ ContainerRegistrationKeys,
4
+ MedusaError,
5
+ Modules,
6
+ } from "@medusajs/framework/utils";
7
+ import { CampaignTypeEnum } from "../../../../modules/custom-campaigns/types/campaign-type.enum";
8
+ import { BuyXGetYCampaign } from "../../../../workflows/custom-campaign/createBuyXGetYCampaignWorkflow";
9
+ import { updateBuyXGetYCampaignWorkflow } from "../../../../workflows/custom-campaign/updateBuyXGetYCampaignWorkflow";
10
+ import { CUSTOM_CAMPAIGN_MODULE } from "../../../../modules/custom-campaigns";
11
+ import CustomCampaignModuleService from "../../../../modules/custom-campaigns/service";
12
+
13
+ /**
14
+ * GET handler for fetching a specific Buy X Get Y campaign by ID
15
+ */
16
+ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
17
+ const { id } = req.params;
18
+
19
+ if (!id) {
20
+ throw new MedusaError(
21
+ MedusaError.Types.INVALID_DATA,
22
+ "Campaign ID is required"
23
+ );
24
+ }
25
+
26
+ const query = container.resolve(ContainerRegistrationKeys.QUERY);
27
+ const productService = container.resolve(Modules.PRODUCT);
28
+ const customCampaignModuleService =
29
+ container.resolve<CustomCampaignModuleService>(CUSTOM_CAMPAIGN_MODULE);
30
+
31
+ try {
32
+ // Find the custom campaign type by campaign ID
33
+ const {
34
+ data: [customCampaignTypes],
35
+ } = await query.graph({
36
+ entity: "custom_campaign_type",
37
+ fields: [
38
+ "id",
39
+ "campaign.*",
40
+ "campaign.promotions.*",
41
+ ],
42
+ filters: {
43
+ type: CampaignTypeEnum.BuyXGetY,
44
+ campaign_id: id,
45
+ },
46
+ });
47
+
48
+ const campaign = customCampaignTypes?.campaign;
49
+
50
+ if (!campaign) {
51
+ throw new MedusaError(
52
+ MedusaError.Types.NOT_FOUND,
53
+ `Buy X Get Y campaign with ID ${id} not found`
54
+ );
55
+ }
56
+
57
+ // Fetch Buy X Get Y configurations for the campaign
58
+ const buyXGetYConfigs =
59
+ await customCampaignModuleService.listBuyXGetYConfigs({
60
+ campaign_id: id,
61
+ });
62
+
63
+ // Process configurations to extract product information
64
+ const rules: BuyXGetYCampaign["rules"] = [];
65
+ for (const config of buyXGetYConfigs) {
66
+ const triggerProduct = await productService.retrieveProduct(
67
+ config.trigger_product_id
68
+ );
69
+ const rewardProduct = await productService.retrieveProduct(
70
+ config.reward_product_id
71
+ );
72
+
73
+ rules.push({
74
+ triggerProduct: {
75
+ id: triggerProduct.id,
76
+ title: triggerProduct.title,
77
+ },
78
+ triggerQuantity: config.trigger_quantity,
79
+ rewardProduct: {
80
+ id: rewardProduct.id,
81
+ title: rewardProduct.title,
82
+ },
83
+ rewardQuantity: config.reward_quantity,
84
+ rewardType: config.reward_type as "free" | "percentage" | "fixed",
85
+ rewardValue: config.reward_value ?? undefined,
86
+ limit: config.limit ?? undefined,
87
+ });
88
+ }
89
+
90
+ res.status(200).json({
91
+ ...campaign,
92
+ type: CampaignTypeEnum.BuyXGetY,
93
+ rules,
94
+ } satisfies BuyXGetYCampaign);
95
+ } catch (error) {
96
+ if (error instanceof MedusaError) {
97
+ throw error;
98
+ }
99
+
100
+ console.error("Error fetching Buy X Get Y campaign:", error);
101
+ throw new MedusaError(
102
+ MedusaError.Types.UNEXPECTED_STATE,
103
+ "An error occurred while fetching the Buy X Get Y campaign"
104
+ );
105
+ }
106
+ };
107
+
108
+ /**
109
+ * PUT handler for updating a specific Buy X Get Y campaign by ID
110
+ */
111
+ export const PUT = async (
112
+ req: MedusaRequest<BuyXGetYCampaign>,
113
+ res: MedusaResponse
114
+ ) => {
115
+ const { id } = req.params;
116
+ const body = req.body;
117
+
118
+ if (!id) {
119
+ throw new MedusaError(
120
+ MedusaError.Types.INVALID_DATA,
121
+ "Campaign ID is required"
122
+ );
123
+ }
124
+
125
+ try {
126
+ // Check if start date is before end date
127
+ if (new Date(body.ends_at) < new Date(body.starts_at)) {
128
+ throw new MedusaError(
129
+ MedusaError.Types.INVALID_DATA,
130
+ "End date must be after start date"
131
+ );
132
+ }
133
+
134
+ // Validate reward types
135
+ for (const rule of body.rules) {
136
+ if (rule.rewardType !== "free" && !rule.rewardValue) {
137
+ throw new MedusaError(
138
+ MedusaError.Types.INVALID_DATA,
139
+ "Reward value is required for percentage and fixed reward types"
140
+ );
141
+ }
142
+ }
143
+
144
+ // Update the Buy X Get Y campaign
145
+ const result = await updateBuyXGetYCampaignWorkflow.run({
146
+ input: {
147
+ ...body,
148
+ id,
149
+ },
150
+ });
151
+
152
+ res.status(200).json(result.result);
153
+ } catch (error) {
154
+ if (error instanceof MedusaError) {
155
+ throw error;
156
+ }
157
+
158
+ console.error("Error updating Buy X Get Y campaign:", error);
159
+ throw new MedusaError(
160
+ MedusaError.Types.UNEXPECTED_STATE,
161
+ "An error occurred while updating the Buy X Get Y campaign"
162
+ );
163
+ }
164
+ };
@@ -0,0 +1,104 @@
1
+ import { container, MedusaRequest, MedusaResponse } from "@medusajs/framework";
2
+ import {
3
+ ContainerRegistrationKeys,
4
+ MedusaError,
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 {
10
+ BuyXGetYCampaign,
11
+ createBuyXGetYCampaignWorkflow,
12
+ } from "../../../workflows/custom-campaign/createBuyXGetYCampaignWorkflow";
13
+
14
+ export const createBuyXGetYCampaignSchema = z.object({
15
+ name: z.string().min(1, "Name is required"),
16
+ description: z.string().min(1, "Description is required"),
17
+ type: z.literal(CampaignTypeEnum.BuyXGetY),
18
+ starts_at: z.coerce.date(),
19
+ ends_at: z.coerce.date(),
20
+ rules: z
21
+ .array(
22
+ z.object({
23
+ triggerProduct: z.object({
24
+ id: z.string(),
25
+ title: z.string(),
26
+ }),
27
+ triggerQuantity: z.number().min(1, "Trigger quantity must be at least 1"),
28
+ rewardProduct: z.object({
29
+ id: z.string(),
30
+ title: z.string(),
31
+ }),
32
+ rewardQuantity: z.number().min(1, "Reward quantity must be at least 1"),
33
+ rewardType: z.enum(["free", "percentage", "fixed"]),
34
+ rewardValue: z.number().optional(),
35
+ limit: z.number().min(1).optional(),
36
+ })
37
+ )
38
+ .min(1, "At least one rule is required"),
39
+ });
40
+
41
+ export const GetBuyXGetYCampaignsSchema = createFindParams({
42
+ order: "-created_at",
43
+ });
44
+
45
+ export const POST = async (
46
+ req: MedusaRequest<BuyXGetYCampaign>,
47
+ res: MedusaResponse
48
+ ) => {
49
+ const body = req.body;
50
+
51
+ if (new Date(body.ends_at) < new Date(body.starts_at)) {
52
+ throw new MedusaError(
53
+ MedusaError.Types.INVALID_DATA,
54
+ "End date must be after start date"
55
+ );
56
+ }
57
+
58
+ // Validate reward types
59
+ for (const rule of body.rules) {
60
+ if (rule.rewardType !== "free" && !rule.rewardValue) {
61
+ throw new MedusaError(
62
+ MedusaError.Types.INVALID_DATA,
63
+ "Reward value is required for percentage and fixed reward types"
64
+ );
65
+ }
66
+ }
67
+
68
+ const campaign = await createBuyXGetYCampaignWorkflow.run({
69
+ input: body,
70
+ });
71
+
72
+ res.status(200).json(campaign);
73
+ };
74
+
75
+ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
76
+ const query = container.resolve(ContainerRegistrationKeys.QUERY);
77
+ const {
78
+ data: customCampaigns,
79
+ metadata: { count, take, skip } = {
80
+ count: 0,
81
+ take: 20,
82
+ skip: 0,
83
+ },
84
+ } = await query.graph({
85
+ entity: "custom_campaign_type",
86
+ ...req.queryConfig,
87
+ fields: [
88
+ "id",
89
+ "campaign.*",
90
+ "campaign.promotions.*",
91
+ "campaign.promotions.application_method.*",
92
+ ],
93
+ filters: { type: CampaignTypeEnum.BuyXGetY },
94
+ });
95
+
96
+ const campaigns = customCampaigns.map((campaign) => campaign.campaign);
97
+
98
+ res.status(200).json({
99
+ campaigns,
100
+ count,
101
+ limit: take,
102
+ offset: skip,
103
+ });
104
+ };