@lodashventure/medusa-campaign 1.1.5 → 1.1.7
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.
- package/.medusa/server/src/api/admin/buy-x-get-y/[id]/route.js +116 -0
- package/.medusa/server/src/api/admin/buy-x-get-y/route.js +83 -0
- package/.medusa/server/src/api/admin/campaigns/fix-dates/route.js +103 -0
- package/.medusa/server/src/api/admin/campaigns/sync/route.js +138 -0
- package/.medusa/server/src/api/admin/flash-sales/[id]/route.js +49 -34
- package/.medusa/server/src/api/admin/flash-sales/route.js +46 -19
- package/.medusa/server/src/api/admin/force-fix/route.js +176 -0
- package/.medusa/server/src/api/admin/test-campaign/route.js +132 -0
- package/.medusa/server/src/api/store/buy-x-get-y/[id]/route.js +109 -0
- package/.medusa/server/src/api/store/buy-x-get-y/products/[productId]/route.js +94 -0
- package/.medusa/server/src/api/store/buy-x-get-y/route.js +114 -0
- package/.medusa/server/src/api/store/campaigns/[id]/route.js +132 -70
- package/.medusa/server/src/api/store/campaigns/route.js +119 -26
- package/.medusa/server/src/index.js +15 -0
- package/.medusa/server/src/modules/custom-campaigns/migrations/Migration20251018000000.js +40 -0
- package/.medusa/server/src/modules/custom-campaigns/models/buy-x-get-y-config.js +20 -0
- package/.medusa/server/src/modules/custom-campaigns/service.js +3 -1
- package/.medusa/server/src/modules/custom-campaigns/types/campaign-type.enum.js +2 -1
- package/.medusa/server/src/subscribers/cart-updated.js +23 -0
- package/.medusa/server/src/subscribers/order-placed.js +9 -2
- package/.medusa/server/src/workflows/buy-x-get-y/applyBuyXGetYToCartWorkflow.js +150 -0
- package/.medusa/server/src/workflows/custom-campaign/createBuyXGetYCampaignWorkflow.js +127 -0
- package/.medusa/server/src/workflows/custom-campaign/updateBuyXGetYCampaignWorkflow.js +114 -0
- package/.medusa/server/src/workflows/custom-campaign/updateBuyXGetYUsageWorkflow.js +51 -0
- package/package.json +2 -2
- package/src/admin/components/BuyXGetYForm.tsx +422 -0
- package/src/api/admin/buy-x-get-y/[id]/route.ts +164 -0
- package/src/api/admin/buy-x-get-y/route.ts +104 -0
- package/src/api/admin/campaigns/fix-dates/route.ts +107 -0
- package/src/api/admin/campaigns/sync/route.ts +153 -0
- package/src/api/admin/flash-sales/[id]/route.ts +62 -36
- package/src/api/admin/flash-sales/route.ts +57 -21
- package/src/api/admin/force-fix/route.ts +184 -0
- package/src/api/admin/test-campaign/route.ts +141 -0
- package/src/api/store/buy-x-get-y/[id]/route.ts +146 -0
- package/src/api/store/buy-x-get-y/products/[productId]/route.ts +129 -0
- package/src/api/store/buy-x-get-y/route.ts +134 -0
- package/src/api/store/campaigns/[id]/route.ts +159 -79
- package/src/api/store/campaigns/route.ts +141 -30
- package/src/index.ts +10 -0
- package/src/modules/custom-campaigns/migrations/Migration20251018000000.ts +42 -0
- package/src/modules/custom-campaigns/models/buy-x-get-y-config.ts +19 -0
- package/src/modules/custom-campaigns/service.ts +2 -0
- package/src/modules/custom-campaigns/types/campaign-type.enum.ts +1 -0
- package/src/subscribers/cart-updated.ts +23 -0
- package/src/subscribers/order-placed.ts +9 -1
- package/src/workflows/buy-x-get-y/applyBuyXGetYToCartWorkflow.ts +222 -0
- package/src/workflows/custom-campaign/createBuyXGetYCampaignWorkflow.ts +210 -0
- package/src/workflows/custom-campaign/updateBuyXGetYCampaignWorkflow.ts +190 -0
- 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
|
+
};
|