@lodashventure/medusa-campaign 1.4.1 → 1.4.2
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/admin/index.js +939 -504
- package/.medusa/server/src/admin/index.mjs +941 -506
- package/.medusa/server/src/api/admin/buy-x-get-y/[id]/route.js +2 -6
- package/.medusa/server/src/api/admin/coupons/[id]/route.js +76 -0
- package/.medusa/server/src/api/admin/coupons/route.js +88 -0
- package/.medusa/server/src/api/middlewares.js +32 -1
- package/.medusa/server/src/api/store/campaigns/route.js +78 -7
- package/.medusa/server/src/api/store/coupons/public/route.js +110 -0
- package/.medusa/server/src/api/store/customers/me/coupons/route.js +148 -0
- package/.medusa/server/src/modules/custom-campaigns/migrations/Migration20251024000000.js +2 -2
- package/.medusa/server/src/modules/custom-campaigns/migrations/Migration20251025000000.js +2 -2
- package/.medusa/server/src/modules/custom-campaigns/migrations/Migration20251101000000.js +16 -0
- package/.medusa/server/src/modules/custom-campaigns/types/campaign-type.enum.js +2 -1
- package/.medusa/server/src/workflows/custom-campaign/createCouponCampaignWorkflow.js +105 -0
- package/.medusa/server/src/workflows/custom-campaign/updateCouponCampaignWorkflow.js +59 -0
- package/.medusa/server/src/workflows/index.js +6 -2
- package/package.json +15 -30
- package/src/admin/components/BuyXGetYForm.tsx +24 -13
- package/src/admin/components/CouponForm.tsx +352 -0
- package/src/admin/components/CouponPage.tsx +104 -0
- package/src/admin/components/ProductSelector.tsx +22 -11
- package/src/admin/hooks/useCouponById.ts +36 -0
- package/src/admin/hooks/useCoupons.ts +46 -0
- package/src/admin/hooks/useFlashSaleById.ts +36 -10
- package/src/admin/hooks/useFlashSales.ts +36 -10
- package/src/admin/routes/coupons/[id]/page.tsx +147 -0
- package/src/admin/routes/coupons/create/page.tsx +49 -0
- package/src/admin/routes/coupons/page.tsx +15 -0
- package/src/admin/routes/flash-sales/[id]/page.tsx +2 -11
- package/src/admin/routes/flash-sales/create/page.tsx +0 -6
- package/src/admin/widgets/campaign-detail-widget.tsx +33 -26
- package/src/api/admin/buy-x-get-y/[id]/route.ts +11 -15
- package/src/api/admin/coupons/[id]/route.ts +98 -0
- package/src/api/admin/coupons/route.ts +109 -0
- package/src/api/middlewares.ts +34 -0
- package/src/api/store/campaigns/route.ts +107 -24
- package/src/api/store/coupons/public/route.ts +165 -0
- package/src/api/store/customers/me/coupons/route.ts +244 -0
- package/src/modules/custom-campaigns/migrations/Migration20251024000000.ts +1 -1
- package/src/modules/custom-campaigns/migrations/Migration20251025000000.ts +1 -1
- package/src/modules/custom-campaigns/migrations/Migration20251101000000.ts +21 -0
- package/src/modules/custom-campaigns/types/campaign-type.enum.ts +1 -0
- package/src/workflows/custom-campaign/createCouponCampaignWorkflow.ts +176 -0
- package/src/workflows/custom-campaign/updateCouponCampaignWorkflow.ts +105 -0
- package/src/workflows/index.ts +3 -1
- package/src/admin/widgets/campaign-stats-widget.tsx +0 -238
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Container,
|
|
5
|
+
Input,
|
|
6
|
+
Label,
|
|
7
|
+
Select,
|
|
8
|
+
Textarea,
|
|
9
|
+
toast,
|
|
10
|
+
} from "@medusajs/ui";
|
|
11
|
+
import dayjs from "dayjs";
|
|
12
|
+
import { FC } from "react";
|
|
13
|
+
import { Controller, useForm } from "react-hook-form";
|
|
14
|
+
import z from "zod";
|
|
15
|
+
|
|
16
|
+
const couponSchema = z
|
|
17
|
+
.object({
|
|
18
|
+
name: z.string().min(1, "Name is required"),
|
|
19
|
+
description: z.string().min(1, "Description is required"),
|
|
20
|
+
type: z.literal("coupon"),
|
|
21
|
+
code: z.string().min(1, "Coupon code is required"),
|
|
22
|
+
discount_type: z.enum(["percentage", "fixed"]),
|
|
23
|
+
discount_value: z.number().positive("Discount must be positive"),
|
|
24
|
+
currency_code: z.string().optional(),
|
|
25
|
+
starts_at: z.string().min(1, "Start date is required"),
|
|
26
|
+
ends_at: z.string().min(1, "End date is required"),
|
|
27
|
+
allocation: z.enum(["each", "total"]).optional(),
|
|
28
|
+
target_type: z.enum(["order", "items"]).optional(),
|
|
29
|
+
})
|
|
30
|
+
.superRefine((data, ctx) => {
|
|
31
|
+
if (new Date(data.ends_at) < new Date(data.starts_at)) {
|
|
32
|
+
ctx.addIssue({
|
|
33
|
+
code: z.ZodIssueCode.custom,
|
|
34
|
+
path: ["ends_at"],
|
|
35
|
+
message: "End date must be after start date",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (data.discount_type === "fixed" && !data.currency_code) {
|
|
40
|
+
ctx.addIssue({
|
|
41
|
+
code: z.ZodIssueCode.custom,
|
|
42
|
+
path: ["currency_code"],
|
|
43
|
+
message: "Currency is required for fixed discount",
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export type CouponFormData = z.infer<typeof couponSchema>;
|
|
49
|
+
|
|
50
|
+
interface CouponFormProps {
|
|
51
|
+
initialData?: Partial<CouponFormData>;
|
|
52
|
+
onSubmit: (data: CouponFormData) => void;
|
|
53
|
+
onCancel: () => void;
|
|
54
|
+
disabled?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const CouponForm: FC<CouponFormProps> = ({
|
|
58
|
+
initialData,
|
|
59
|
+
onSubmit,
|
|
60
|
+
onCancel,
|
|
61
|
+
disabled = false,
|
|
62
|
+
}) => {
|
|
63
|
+
const {
|
|
64
|
+
register,
|
|
65
|
+
control,
|
|
66
|
+
handleSubmit,
|
|
67
|
+
watch,
|
|
68
|
+
setValue,
|
|
69
|
+
formState: { errors },
|
|
70
|
+
} = useForm<CouponFormData>({
|
|
71
|
+
resolver: zodResolver(couponSchema),
|
|
72
|
+
defaultValues: {
|
|
73
|
+
name: initialData?.name ?? "",
|
|
74
|
+
description: initialData?.description ?? "",
|
|
75
|
+
type: "coupon",
|
|
76
|
+
code: initialData?.code ?? "",
|
|
77
|
+
discount_type: initialData?.discount_type ?? "percentage",
|
|
78
|
+
discount_value: initialData?.discount_value ?? 0,
|
|
79
|
+
currency_code: initialData?.currency_code ?? "",
|
|
80
|
+
starts_at:
|
|
81
|
+
initialData?.starts_at ??
|
|
82
|
+
dayjs().startOf("day").format("YYYY-MM-DDTHH:mm"),
|
|
83
|
+
ends_at:
|
|
84
|
+
initialData?.ends_at ??
|
|
85
|
+
dayjs().endOf("day").format("YYYY-MM-DDTHH:mm"),
|
|
86
|
+
allocation: initialData?.allocation ?? "total",
|
|
87
|
+
target_type: initialData?.target_type ?? "order",
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const discountType = watch("discount_type");
|
|
92
|
+
const startsAt = watch("starts_at");
|
|
93
|
+
const endsAt = watch("ends_at");
|
|
94
|
+
|
|
95
|
+
const handleDateTimeChange = (
|
|
96
|
+
field: "starts_at" | "ends_at",
|
|
97
|
+
type: "date" | "time",
|
|
98
|
+
value: string,
|
|
99
|
+
) => {
|
|
100
|
+
const currentValue = watch(field);
|
|
101
|
+
|
|
102
|
+
if (type === "date") {
|
|
103
|
+
const time = currentValue
|
|
104
|
+
? dayjs(currentValue).format("HH:mm")
|
|
105
|
+
: field === "starts_at"
|
|
106
|
+
? "00:00"
|
|
107
|
+
: "23:59";
|
|
108
|
+
setValue(field, `${value}T${time}`);
|
|
109
|
+
} else {
|
|
110
|
+
const date = currentValue
|
|
111
|
+
? dayjs(currentValue).format("YYYY-MM-DD")
|
|
112
|
+
: dayjs().format("YYYY-MM-DD");
|
|
113
|
+
setValue(field, `${date}T${value}`);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const onFormSubmit = (data: CouponFormData) => {
|
|
118
|
+
onSubmit(data);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const onFormError = () => {
|
|
122
|
+
const errorMessages = Object.values(errors)
|
|
123
|
+
.map((err) => err?.message)
|
|
124
|
+
.filter(Boolean)
|
|
125
|
+
.join(", ");
|
|
126
|
+
|
|
127
|
+
toast.error("Invalid coupon data", {
|
|
128
|
+
description: errorMessages,
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<Container>
|
|
134
|
+
<div className="flex items-center justify-between">
|
|
135
|
+
<h1 className="text-xl font-semibold">Coupon</h1>
|
|
136
|
+
<Button variant="transparent" onClick={onCancel}>
|
|
137
|
+
Cancel
|
|
138
|
+
</Button>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<form
|
|
142
|
+
onSubmit={handleSubmit(onFormSubmit, onFormError)}
|
|
143
|
+
className="space-y-6 my-8"
|
|
144
|
+
>
|
|
145
|
+
<div className="space-y-2">
|
|
146
|
+
<Label>Name</Label>
|
|
147
|
+
<Input {...register("name")} disabled={disabled} />
|
|
148
|
+
{errors.name && (
|
|
149
|
+
<p className="text-red-500 text-sm mt-1">{errors.name.message}</p>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div className="space-y-2">
|
|
154
|
+
<Label>Description</Label>
|
|
155
|
+
<Textarea {...register("description")} disabled={disabled} />
|
|
156
|
+
{errors.description && (
|
|
157
|
+
<p className="text-red-500 text-sm mt-1">
|
|
158
|
+
{errors.description.message}
|
|
159
|
+
</p>
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<div className="grid grid-cols-2 gap-4">
|
|
164
|
+
<div className="space-y-2">
|
|
165
|
+
<Label>Start Date</Label>
|
|
166
|
+
<div className="flex gap-2">
|
|
167
|
+
<Input
|
|
168
|
+
type="date"
|
|
169
|
+
value={startsAt ? dayjs(startsAt).format("YYYY-MM-DD") : ""}
|
|
170
|
+
onChange={(e) =>
|
|
171
|
+
handleDateTimeChange("starts_at", "date", e.target.value)
|
|
172
|
+
}
|
|
173
|
+
disabled={disabled}
|
|
174
|
+
className="flex-1"
|
|
175
|
+
/>
|
|
176
|
+
<Input
|
|
177
|
+
type="time"
|
|
178
|
+
value={startsAt ? dayjs(startsAt).format("HH:mm") : ""}
|
|
179
|
+
onChange={(e) =>
|
|
180
|
+
handleDateTimeChange("starts_at", "time", e.target.value)
|
|
181
|
+
}
|
|
182
|
+
disabled={disabled}
|
|
183
|
+
className="flex-1"
|
|
184
|
+
/>
|
|
185
|
+
</div>
|
|
186
|
+
{errors.starts_at && (
|
|
187
|
+
<p className="text-red-500 text-sm mt-1">
|
|
188
|
+
{errors.starts_at.message}
|
|
189
|
+
</p>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<div className="space-y-2">
|
|
194
|
+
<Label>End Date</Label>
|
|
195
|
+
<div className="flex gap-2">
|
|
196
|
+
<Input
|
|
197
|
+
type="date"
|
|
198
|
+
value={endsAt ? dayjs(endsAt).format("YYYY-MM-DD") : ""}
|
|
199
|
+
onChange={(e) =>
|
|
200
|
+
handleDateTimeChange("ends_at", "date", e.target.value)
|
|
201
|
+
}
|
|
202
|
+
disabled={disabled}
|
|
203
|
+
className="flex-1"
|
|
204
|
+
/>
|
|
205
|
+
<Input
|
|
206
|
+
type="time"
|
|
207
|
+
value={endsAt ? dayjs(endsAt).format("HH:mm") : ""}
|
|
208
|
+
onChange={(e) =>
|
|
209
|
+
handleDateTimeChange("ends_at", "time", e.target.value)
|
|
210
|
+
}
|
|
211
|
+
disabled={disabled}
|
|
212
|
+
className="flex-1"
|
|
213
|
+
/>
|
|
214
|
+
</div>
|
|
215
|
+
{errors.ends_at && (
|
|
216
|
+
<p className="text-red-500 text-sm mt-1">
|
|
217
|
+
{errors.ends_at.message}
|
|
218
|
+
</p>
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
<div className="grid grid-cols-2 gap-4">
|
|
224
|
+
<div className="space-y-2">
|
|
225
|
+
<Label>Coupon Code</Label>
|
|
226
|
+
<Input {...register("code")} disabled={disabled} />
|
|
227
|
+
{errors.code && (
|
|
228
|
+
<p className="text-red-500 text-sm mt-1">{errors.code.message}</p>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
<div className="space-y-2">
|
|
233
|
+
<Label>Discount Type</Label>
|
|
234
|
+
<Controller
|
|
235
|
+
name="discount_type"
|
|
236
|
+
control={control}
|
|
237
|
+
render={({ field }) => (
|
|
238
|
+
<Select
|
|
239
|
+
value={field.value}
|
|
240
|
+
onValueChange={field.onChange}
|
|
241
|
+
disabled={disabled}
|
|
242
|
+
>
|
|
243
|
+
<Select.Trigger>
|
|
244
|
+
<Select.Value placeholder="Select discount type" />
|
|
245
|
+
</Select.Trigger>
|
|
246
|
+
<Select.Content>
|
|
247
|
+
<Select.Item value="percentage">Percentage</Select.Item>
|
|
248
|
+
<Select.Item value="fixed">Fixed Amount</Select.Item>
|
|
249
|
+
</Select.Content>
|
|
250
|
+
</Select>
|
|
251
|
+
)}
|
|
252
|
+
/>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
<div className="grid grid-cols-2 gap-4">
|
|
257
|
+
<div className="space-y-2">
|
|
258
|
+
<Label>Discount Value</Label>
|
|
259
|
+
<Controller
|
|
260
|
+
name="discount_value"
|
|
261
|
+
control={control}
|
|
262
|
+
render={({ field }) => (
|
|
263
|
+
<Input
|
|
264
|
+
type="number"
|
|
265
|
+
value={field.value}
|
|
266
|
+
min={0}
|
|
267
|
+
step={field.value % 1 === 0 ? 1 : 0.01}
|
|
268
|
+
onChange={(e) => field.onChange(Number(e.target.value))}
|
|
269
|
+
disabled={disabled}
|
|
270
|
+
/>
|
|
271
|
+
)}
|
|
272
|
+
/>
|
|
273
|
+
{errors.discount_value && (
|
|
274
|
+
<p className="text-red-500 text-sm mt-1">
|
|
275
|
+
{errors.discount_value.message}
|
|
276
|
+
</p>
|
|
277
|
+
)}
|
|
278
|
+
</div>
|
|
279
|
+
|
|
280
|
+
{discountType === "fixed" && (
|
|
281
|
+
<div className="space-y-2">
|
|
282
|
+
<Label>Currency Code</Label>
|
|
283
|
+
<Input {...register("currency_code")} disabled={disabled} />
|
|
284
|
+
{errors.currency_code && (
|
|
285
|
+
<p className="text-red-500 text-sm mt-1">
|
|
286
|
+
{errors.currency_code.message}
|
|
287
|
+
</p>
|
|
288
|
+
)}
|
|
289
|
+
</div>
|
|
290
|
+
)}
|
|
291
|
+
</div>
|
|
292
|
+
|
|
293
|
+
<div className="grid grid-cols-2 gap-4">
|
|
294
|
+
<div className="space-y-2">
|
|
295
|
+
<Label>Allocation</Label>
|
|
296
|
+
<Controller
|
|
297
|
+
name="allocation"
|
|
298
|
+
control={control}
|
|
299
|
+
render={({ field }) => (
|
|
300
|
+
<Select
|
|
301
|
+
value={field.value}
|
|
302
|
+
onValueChange={field.onChange}
|
|
303
|
+
disabled={disabled}
|
|
304
|
+
>
|
|
305
|
+
<Select.Trigger>
|
|
306
|
+
<Select.Value placeholder="Select allocation" />
|
|
307
|
+
</Select.Trigger>
|
|
308
|
+
<Select.Content>
|
|
309
|
+
<Select.Item value="total">Order Total</Select.Item>
|
|
310
|
+
<Select.Item value="each">Each Item</Select.Item>
|
|
311
|
+
</Select.Content>
|
|
312
|
+
</Select>
|
|
313
|
+
)}
|
|
314
|
+
/>
|
|
315
|
+
</div>
|
|
316
|
+
|
|
317
|
+
<div className="space-y-2">
|
|
318
|
+
<Label>Target Type</Label>
|
|
319
|
+
<Controller
|
|
320
|
+
name="target_type"
|
|
321
|
+
control={control}
|
|
322
|
+
render={({ field }) => (
|
|
323
|
+
<Select
|
|
324
|
+
value={field.value}
|
|
325
|
+
onValueChange={field.onChange}
|
|
326
|
+
disabled={disabled}
|
|
327
|
+
>
|
|
328
|
+
<Select.Trigger>
|
|
329
|
+
<Select.Value placeholder="Select target" />
|
|
330
|
+
</Select.Trigger>
|
|
331
|
+
<Select.Content>
|
|
332
|
+
<Select.Item value="order">Order</Select.Item>
|
|
333
|
+
<Select.Item value="items">Items</Select.Item>
|
|
334
|
+
</Select.Content>
|
|
335
|
+
</Select>
|
|
336
|
+
)}
|
|
337
|
+
/>
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
|
|
341
|
+
<div className="flex justify-end gap-2">
|
|
342
|
+
<Button variant="secondary" type="button" onClick={onCancel}>
|
|
343
|
+
Cancel
|
|
344
|
+
</Button>
|
|
345
|
+
<Button type="submit" disabled={disabled}>
|
|
346
|
+
Save Coupon
|
|
347
|
+
</Button>
|
|
348
|
+
</div>
|
|
349
|
+
</form>
|
|
350
|
+
</Container>
|
|
351
|
+
);
|
|
352
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { CampaignDTO, PromotionDTO } from "@medusajs/framework/types";
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
Button,
|
|
5
|
+
Container,
|
|
6
|
+
createDataTableColumnHelper,
|
|
7
|
+
DataTable,
|
|
8
|
+
DataTablePaginationState,
|
|
9
|
+
useDataTable,
|
|
10
|
+
} from "@medusajs/ui";
|
|
11
|
+
import dayjs from "dayjs";
|
|
12
|
+
import { useMemo, useState } from "react";
|
|
13
|
+
import { useNavigate } from "react-router-dom";
|
|
14
|
+
import { useCoupons } from "../hooks/useCoupons";
|
|
15
|
+
|
|
16
|
+
type CouponCampaign = CampaignDTO & {
|
|
17
|
+
promotions?: PromotionDTO[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const columnHelper = createDataTableColumnHelper<CouponCampaign>();
|
|
21
|
+
|
|
22
|
+
const useCouponColumns = () =>
|
|
23
|
+
useMemo(
|
|
24
|
+
() => [
|
|
25
|
+
columnHelper.accessor("name", {
|
|
26
|
+
header: "Name",
|
|
27
|
+
cell: (info) => info.getValue(),
|
|
28
|
+
}),
|
|
29
|
+
columnHelper.display({
|
|
30
|
+
id: "code",
|
|
31
|
+
header: "Code",
|
|
32
|
+
cell: (info) => info.row.original.promotions?.[0]?.code ?? "-",
|
|
33
|
+
}),
|
|
34
|
+
columnHelper.display({
|
|
35
|
+
id: "status",
|
|
36
|
+
header: "Status",
|
|
37
|
+
cell: (info) => {
|
|
38
|
+
const endsAt = info.row.original.ends_at;
|
|
39
|
+
if (!endsAt) {
|
|
40
|
+
return "";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const isActive = dayjs(endsAt).isAfter(dayjs());
|
|
44
|
+
return (
|
|
45
|
+
<Badge color={isActive ? "green" : "grey"}>
|
|
46
|
+
{isActive ? "Active" : "Inactive"}
|
|
47
|
+
</Badge>
|
|
48
|
+
);
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
columnHelper.accessor("starts_at", {
|
|
52
|
+
header: "Start Date",
|
|
53
|
+
cell: (info) =>
|
|
54
|
+
info.getValue() ? dayjs(info.getValue()).format("YYYY-MM-DD HH:mm") : "",
|
|
55
|
+
}),
|
|
56
|
+
columnHelper.accessor("ends_at", {
|
|
57
|
+
header: "End Date",
|
|
58
|
+
cell: (info) =>
|
|
59
|
+
info.getValue() ? dayjs(info.getValue()).format("YYYY-MM-DD HH:mm") : "",
|
|
60
|
+
}),
|
|
61
|
+
],
|
|
62
|
+
[],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
export const CouponPage = () => {
|
|
66
|
+
const navigate = useNavigate();
|
|
67
|
+
const limit = 20;
|
|
68
|
+
const [pagination, setPagination] = useState<DataTablePaginationState>({
|
|
69
|
+
pageSize: limit,
|
|
70
|
+
pageIndex: 0,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const offset = pagination.pageIndex * limit;
|
|
74
|
+
|
|
75
|
+
const { data } = useCoupons({ limit, offset });
|
|
76
|
+
const columns = useCouponColumns();
|
|
77
|
+
|
|
78
|
+
const table = useDataTable({
|
|
79
|
+
data: (data?.campaigns as CouponCampaign[]) ?? [],
|
|
80
|
+
columns,
|
|
81
|
+
getRowId: (row: CouponCampaign) => row.id,
|
|
82
|
+
pagination: {
|
|
83
|
+
state: pagination,
|
|
84
|
+
onPaginationChange: setPagination,
|
|
85
|
+
},
|
|
86
|
+
rowCount: data?.count ?? 0,
|
|
87
|
+
onRowClick: (_, row) => {
|
|
88
|
+
navigate(`/coupons/${row.id}`);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<Container>
|
|
94
|
+
<div className="flex justify-between items-center">
|
|
95
|
+
<h1 className="text-xl font-semibold">Coupons</h1>
|
|
96
|
+
<Button onClick={() => navigate("/coupons/create")}>Create Coupon</Button>
|
|
97
|
+
</div>
|
|
98
|
+
<DataTable instance={table}>
|
|
99
|
+
<DataTable.Table />
|
|
100
|
+
<DataTable.Pagination />
|
|
101
|
+
</DataTable>
|
|
102
|
+
</Container>
|
|
103
|
+
);
|
|
104
|
+
};
|
|
@@ -7,9 +7,8 @@ import {
|
|
|
7
7
|
Text,
|
|
8
8
|
useDataTable,
|
|
9
9
|
} from "@medusajs/ui";
|
|
10
|
-
import { useQuery } from "@tanstack/react-query";
|
|
11
10
|
import { debounce } from "lodash";
|
|
12
|
-
import { useMemo, useState } from "react";
|
|
11
|
+
import { useEffect, useMemo, useState } from "react";
|
|
13
12
|
import { sdk } from "../lib/sdk";
|
|
14
13
|
|
|
15
14
|
interface ProductSelectorProps {
|
|
@@ -48,18 +47,30 @@ export const ProductSelector = ({
|
|
|
48
47
|
setSearch(value);
|
|
49
48
|
}, 500);
|
|
50
49
|
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
50
|
+
const [products, setProducts] = useState<any>(null);
|
|
51
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const fetchProducts = async () => {
|
|
55
|
+
setIsLoading(true);
|
|
56
|
+
try {
|
|
57
|
+
const result = await sdk.admin.product.list({
|
|
58
|
+
q: search,
|
|
59
|
+
limit: 100,
|
|
60
|
+
});
|
|
61
|
+
setProducts(result);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error("Failed to fetch products:", error);
|
|
64
|
+
} finally {
|
|
65
|
+
setIsLoading(false);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
fetchProducts();
|
|
69
|
+
}, [selectedProductIds, search]);
|
|
59
70
|
|
|
60
71
|
const selectableProducts = useMemo(() => {
|
|
61
72
|
return products?.products?.filter(
|
|
62
|
-
(product) => !selectedProductIds.includes(product.id)
|
|
73
|
+
(product) => !selectedProductIds.includes(product.id),
|
|
63
74
|
);
|
|
64
75
|
}, [products?.products, selectedProductIds]);
|
|
65
76
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
|
|
4
|
+
export const useCouponById = (id: string) => {
|
|
5
|
+
const [data, setData] = useState<any | null>(null);
|
|
6
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
7
|
+
const [error, setError] = useState<Error | null>(null);
|
|
8
|
+
|
|
9
|
+
const fetchCoupon = async () => {
|
|
10
|
+
if (!id) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
setIsLoading(true);
|
|
15
|
+
setError(null);
|
|
16
|
+
try {
|
|
17
|
+
const response = await axios.get(`/admin/coupons/${id}`);
|
|
18
|
+
setData(response.data);
|
|
19
|
+
} catch (err) {
|
|
20
|
+
setError(err instanceof Error ? err : new Error("Failed to fetch coupon"));
|
|
21
|
+
} finally {
|
|
22
|
+
setIsLoading(false);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
fetchCoupon();
|
|
28
|
+
}, [id]);
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
data,
|
|
32
|
+
isLoading,
|
|
33
|
+
error,
|
|
34
|
+
refetch: fetchCoupon,
|
|
35
|
+
};
|
|
36
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
|
|
4
|
+
export type CouponCampaignResponse = {
|
|
5
|
+
campaigns: any[];
|
|
6
|
+
count: number;
|
|
7
|
+
limit: number;
|
|
8
|
+
offset: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
interface Pagination {
|
|
12
|
+
limit: number;
|
|
13
|
+
offset: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const useCoupons = ({ limit, offset }: Pagination) => {
|
|
17
|
+
const [data, setData] = useState<CouponCampaignResponse | null>(null);
|
|
18
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
19
|
+
const [error, setError] = useState<Error | null>(null);
|
|
20
|
+
|
|
21
|
+
const fetchCoupons = async () => {
|
|
22
|
+
setIsLoading(true);
|
|
23
|
+
setError(null);
|
|
24
|
+
try {
|
|
25
|
+
const response = await axios.get<CouponCampaignResponse>("/admin/coupons", {
|
|
26
|
+
params: { limit, offset },
|
|
27
|
+
});
|
|
28
|
+
setData(response.data);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
setError(err instanceof Error ? err : new Error("Failed to fetch coupons"));
|
|
31
|
+
} finally {
|
|
32
|
+
setIsLoading(false);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
fetchCoupons();
|
|
38
|
+
}, [limit, offset]);
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
data,
|
|
42
|
+
isLoading,
|
|
43
|
+
error,
|
|
44
|
+
refetch: fetchCoupons,
|
|
45
|
+
};
|
|
46
|
+
};
|
|
@@ -1,21 +1,47 @@
|
|
|
1
|
-
import { useQuery } from "@tanstack/react-query";
|
|
2
1
|
import axios from "axios";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
3
|
import { CampaignProduct, CustomCampaign } from "../components/FlashSaleForm";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Hook to fetch a single flash sale by ID
|
|
7
7
|
*/
|
|
8
8
|
export const useFlashSaleById = (id: string) => {
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
const [data, setData] = useState<
|
|
10
|
+
(CustomCampaign & { products: CampaignProduct[] }) | null
|
|
11
|
+
>(null);
|
|
12
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
13
|
+
const [error, setError] = useState<Error | null>(null);
|
|
14
|
+
|
|
15
|
+
const fetchFlashSale = async () => {
|
|
16
|
+
if (!id) {
|
|
17
|
+
setIsLoading(false);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
setIsLoading(true);
|
|
22
|
+
setError(null);
|
|
23
|
+
try {
|
|
24
|
+
const response = await axios.get<
|
|
13
25
|
CustomCampaign & { products: CampaignProduct[] }
|
|
14
26
|
>(`/admin/flash-sales/${id}`);
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
|
|
27
|
+
setData(response.data);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
setError(
|
|
30
|
+
err instanceof Error ? err : new Error("Failed to fetch flash sale"),
|
|
31
|
+
);
|
|
32
|
+
} finally {
|
|
33
|
+
setIsLoading(false);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
fetchFlashSale();
|
|
39
|
+
}, [id]);
|
|
19
40
|
|
|
20
|
-
return
|
|
41
|
+
return {
|
|
42
|
+
data,
|
|
43
|
+
isLoading,
|
|
44
|
+
error,
|
|
45
|
+
refetch: fetchFlashSale,
|
|
46
|
+
};
|
|
21
47
|
};
|