@lodashventure/medusa-campaign 1.0.0 → 1.1.1
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 +2203 -273
- package/.medusa/server/src/admin/index.mjs +2196 -267
- package/.medusa/server/src/workflows/index.js +10 -0
- package/package.json +4 -4
- package/src/admin/README.md +31 -0
- package/src/admin/components/FlashSaleForm.tsx +379 -0
- package/src/admin/components/FlashSalePage.tsx +113 -0
- package/src/admin/components/ProductSelector.tsx +88 -0
- package/src/admin/hooks/useFlashSaleById.ts +21 -0
- package/src/admin/hooks/useFlashSales.ts +25 -0
- package/src/admin/lib/sdk.ts +10 -0
- package/src/admin/routes/flash-sales/[id]/page.tsx +105 -0
- package/src/admin/routes/flash-sales/create/page.tsx +51 -0
- package/src/admin/routes/flash-sales/page.tsx +15 -0
- package/src/admin/tsconfig.json +24 -0
- package/src/admin/types/campaign.ts +25 -0
- package/src/admin/vite-env.d.ts +1 -0
- package/src/api/README.md +133 -0
- package/src/api/admin/flash-sales/[id]/route.ts +164 -0
- package/src/api/admin/flash-sales/route.ts +87 -0
- package/src/api/middlewares.ts +32 -0
- package/src/api/store/campaigns/[id]/route.ts +133 -0
- package/src/api/store/campaigns/route.ts +36 -0
- package/src/jobs/README.md +36 -0
- package/src/links/README.md +26 -0
- package/src/links/campaign-type.ts +8 -0
- package/src/modules/README.md +116 -0
- package/src/modules/custom-campaigns/index.ts +8 -0
- package/src/modules/custom-campaigns/migrations/.snapshot-medusa-custom-campaign.json +235 -0
- package/src/modules/custom-campaigns/migrations/Migration20250524150901.ts +23 -0
- package/src/modules/custom-campaigns/migrations/Migration20250526010310.ts +20 -0
- package/src/modules/custom-campaigns/migrations/Migration20250529011904.ts +13 -0
- package/src/modules/custom-campaigns/models/custom-campaign-type.ts +10 -0
- package/src/modules/custom-campaigns/models/promotion-usage-limit.ts +14 -0
- package/src/modules/custom-campaigns/service.ts +10 -0
- package/src/modules/custom-campaigns/types/campaign-type.enum.ts +3 -0
- package/src/providers/README.md +30 -0
- package/src/subscribers/README.md +59 -0
- package/src/subscribers/order-placed.ts +17 -0
- package/src/workflows/README.md +79 -0
- package/src/workflows/custom-campaign/createCustomCampaignWorkflow.ts +181 -0
- package/src/workflows/custom-campaign/updateCustomFlashSaleWorkflow.ts +185 -0
- package/src/workflows/custom-campaign/updatePromotionUsageWorkflow.ts +70 -0
- package/src/workflows/hooks/deletePromotionOnCampaignDelete.ts +49 -0
- package/src/workflows/index.ts +3 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.updatePromotionUsageWorkflow = exports.updateCustomFlashSaleWorkflow = exports.createCustomFlashSaleWorkflow = void 0;
|
|
4
|
+
var createCustomCampaignWorkflow_1 = require("./custom-campaign/createCustomCampaignWorkflow");
|
|
5
|
+
Object.defineProperty(exports, "createCustomFlashSaleWorkflow", { enumerable: true, get: function () { return createCustomCampaignWorkflow_1.createCustomFlashSaleWorkflow; } });
|
|
6
|
+
var updateCustomFlashSaleWorkflow_1 = require("./custom-campaign/updateCustomFlashSaleWorkflow");
|
|
7
|
+
Object.defineProperty(exports, "updateCustomFlashSaleWorkflow", { enumerable: true, get: function () { return updateCustomFlashSaleWorkflow_1.updateCustomFlashSaleWorkflow; } });
|
|
8
|
+
var updatePromotionUsageWorkflow_1 = require("./custom-campaign/updatePromotionUsageWorkflow");
|
|
9
|
+
Object.defineProperty(exports, "updatePromotionUsageWorkflow", { enumerable: true, get: function () { return updatePromotionUsageWorkflow_1.updatePromotionUsageWorkflow; } });
|
|
10
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi8uLi9zcmMvd29ya2Zsb3dzL2luZGV4LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiI7OztBQUFBLCtGQUErRjtBQUF0Riw2SUFBQSw2QkFBNkIsT0FBQTtBQUN0QyxpR0FBZ0c7QUFBdkYsOElBQUEsNkJBQTZCLE9BQUE7QUFDdEMsK0ZBQThGO0FBQXJGLDRJQUFBLDRCQUE0QixPQUFBIn0=
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lodashventure/medusa-campaign",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "A starter for Medusa plugins.",
|
|
5
5
|
"author": "Medusa (https://medusajs.com)",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"files": [
|
|
8
|
-
".medusa/server"
|
|
8
|
+
".medusa/server",
|
|
9
|
+
"src"
|
|
9
10
|
],
|
|
10
11
|
"exports": {
|
|
11
12
|
"./package.json": "./package.json",
|
|
@@ -25,8 +26,7 @@
|
|
|
25
26
|
],
|
|
26
27
|
"scripts": {
|
|
27
28
|
"build": "medusa plugin:build",
|
|
28
|
-
"dev": "medusa plugin:develop"
|
|
29
|
-
"prepublishOnly": "medusa plugin:build"
|
|
29
|
+
"dev": "medusa plugin:develop"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@medusajs/admin-sdk": "2.10.0",
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Admin Customizations
|
|
2
|
+
|
|
3
|
+
You can extend the Medusa Admin to add widgets and new pages. Your customizations interact with API routes to provide merchants with custom functionalities.
|
|
4
|
+
|
|
5
|
+
## Example: Create a Widget
|
|
6
|
+
|
|
7
|
+
A widget is a React component that can be injected into an existing page in the admin dashboard.
|
|
8
|
+
|
|
9
|
+
For example, create the file `src/admin/widgets/product-widget.tsx` with the following content:
|
|
10
|
+
|
|
11
|
+
```tsx title="src/admin/widgets/product-widget.tsx"
|
|
12
|
+
import { defineWidgetConfig } from "@medusajs/admin-sdk"
|
|
13
|
+
|
|
14
|
+
// The widget
|
|
15
|
+
const ProductWidget = () => {
|
|
16
|
+
return (
|
|
17
|
+
<div>
|
|
18
|
+
<h2>Product Widget</h2>
|
|
19
|
+
</div>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The widget's configurations
|
|
24
|
+
export const config = defineWidgetConfig({
|
|
25
|
+
zone: "product.details.after",
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
export default ProductWidget
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
This inserts a widget with the text “Product Widget” at the end of a product’s details page.
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { Trash } from "@medusajs/icons";
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Container,
|
|
5
|
+
FocusModal,
|
|
6
|
+
Input,
|
|
7
|
+
Label,
|
|
8
|
+
Select,
|
|
9
|
+
Table,
|
|
10
|
+
Textarea,
|
|
11
|
+
toast,
|
|
12
|
+
} from "@medusajs/ui";
|
|
13
|
+
import dayjs from "dayjs";
|
|
14
|
+
import { FC, useState } from "react";
|
|
15
|
+
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
|
16
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
17
|
+
import z from "zod";
|
|
18
|
+
import { ProductSelector } from "./ProductSelector";
|
|
19
|
+
|
|
20
|
+
const customCampaignSchema = z.object({
|
|
21
|
+
name: z.string().min(1, "Name is required"),
|
|
22
|
+
description: z.string().min(1, "Description is required"),
|
|
23
|
+
type: z.literal("flash-sale"),
|
|
24
|
+
starts_at: z.string().min(1, "Start date is required"),
|
|
25
|
+
ends_at: z.string().min(1, "End date is required"),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export type CustomCampaign = z.infer<typeof customCampaignSchema>;
|
|
29
|
+
|
|
30
|
+
const campaignProductSchema = z.object({
|
|
31
|
+
product: z.object({
|
|
32
|
+
id: z.string(),
|
|
33
|
+
title: z.string(),
|
|
34
|
+
}),
|
|
35
|
+
discountType: z.enum([
|
|
36
|
+
"percentage",
|
|
37
|
+
// , "fixed"
|
|
38
|
+
]),
|
|
39
|
+
discountValue: z.number().min(1),
|
|
40
|
+
limit: z.number().min(1),
|
|
41
|
+
maxQty: z.number().min(1),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export type CampaignProduct = z.infer<typeof campaignProductSchema>;
|
|
45
|
+
|
|
46
|
+
export const campaignDataSchema = customCampaignSchema
|
|
47
|
+
.extend({
|
|
48
|
+
products: z
|
|
49
|
+
.array(campaignProductSchema)
|
|
50
|
+
.min(1, "At least one product is required"),
|
|
51
|
+
})
|
|
52
|
+
.refine(
|
|
53
|
+
(data) => new Date(data.starts_at) < new Date(data.ends_at),
|
|
54
|
+
"End date must be after start date",
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
export type CampaignData = z.infer<typeof campaignDataSchema>;
|
|
58
|
+
|
|
59
|
+
interface FlashSaleFormProps {
|
|
60
|
+
initialData?: CustomCampaign;
|
|
61
|
+
initialProducts?: Map<string, CampaignProduct>;
|
|
62
|
+
onSubmit: (data: CampaignData) => void;
|
|
63
|
+
onCancel: () => void;
|
|
64
|
+
disabled?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const FlashSaleForm: FC<FlashSaleFormProps> = ({
|
|
68
|
+
initialData,
|
|
69
|
+
initialProducts,
|
|
70
|
+
onSubmit,
|
|
71
|
+
onCancel,
|
|
72
|
+
disabled = false,
|
|
73
|
+
}) => {
|
|
74
|
+
const {
|
|
75
|
+
control,
|
|
76
|
+
register,
|
|
77
|
+
handleSubmit,
|
|
78
|
+
watch,
|
|
79
|
+
setValue,
|
|
80
|
+
formState: { errors },
|
|
81
|
+
} = useForm<CampaignData>({
|
|
82
|
+
resolver: zodResolver(campaignDataSchema),
|
|
83
|
+
defaultValues: {
|
|
84
|
+
name: initialData?.name || "",
|
|
85
|
+
description: initialData?.description || "",
|
|
86
|
+
type: "flash-sale",
|
|
87
|
+
starts_at: initialData?.starts_at || "",
|
|
88
|
+
ends_at: initialData?.ends_at || "",
|
|
89
|
+
products: initialProducts ? Array.from(initialProducts.values()) : [],
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const { fields, append, remove, update } = useFieldArray({
|
|
94
|
+
control,
|
|
95
|
+
name: "products",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const [openProductModal, setOpenProductModal] = useState(false);
|
|
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: CampaignData) => {
|
|
126
|
+
onSubmit(data);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const onFormError = () => {
|
|
130
|
+
const errorMessages = Object.entries(errors)
|
|
131
|
+
.map(([key, value]) => {
|
|
132
|
+
if (key === "products" && Array.isArray(value)) {
|
|
133
|
+
return value
|
|
134
|
+
.map((item, index) =>
|
|
135
|
+
item
|
|
136
|
+
? `Product ${index + 1}: ${Object.values(item).join(", ")}`
|
|
137
|
+
: "",
|
|
138
|
+
)
|
|
139
|
+
.filter(Boolean)
|
|
140
|
+
.join(", ");
|
|
141
|
+
}
|
|
142
|
+
return value?.message || value?.root?.message;
|
|
143
|
+
})
|
|
144
|
+
.filter(Boolean)
|
|
145
|
+
.join(", ");
|
|
146
|
+
|
|
147
|
+
toast.error("Invalid data", {
|
|
148
|
+
description: errorMessages,
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<Container>
|
|
154
|
+
<div className="flex items-center justify-between">
|
|
155
|
+
<h1 className="text-xl font-semibold">Flash sale campaign</h1>
|
|
156
|
+
<Button variant="transparent" onClick={onCancel}>
|
|
157
|
+
Cancel
|
|
158
|
+
</Button>
|
|
159
|
+
</div>
|
|
160
|
+
<form
|
|
161
|
+
onSubmit={handleSubmit(onFormSubmit, onFormError)}
|
|
162
|
+
className="space-y-4 my-8"
|
|
163
|
+
>
|
|
164
|
+
<div>
|
|
165
|
+
<Label>Name</Label>
|
|
166
|
+
<Input {...register("name")} disabled={disabled} />
|
|
167
|
+
{errors.name && (
|
|
168
|
+
<p className="text-red-500 text-sm mt-1">{errors.name.message}</p>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<div>
|
|
173
|
+
<Label>Description</Label>
|
|
174
|
+
<Textarea {...register("description")} disabled={disabled} />
|
|
175
|
+
{errors.description && (
|
|
176
|
+
<p className="text-red-500 text-sm mt-1">
|
|
177
|
+
{errors.description.message}
|
|
178
|
+
</p>
|
|
179
|
+
)}
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<div className="grid grid-cols-2 gap-4">
|
|
183
|
+
<div>
|
|
184
|
+
<Label>Start Date</Label>
|
|
185
|
+
<div className="flex gap-2">
|
|
186
|
+
<Input
|
|
187
|
+
type="date"
|
|
188
|
+
value={startsAt ? dayjs(startsAt).format("YYYY-MM-DD") : ""}
|
|
189
|
+
onChange={(e) =>
|
|
190
|
+
handleDateTimeChange("starts_at", "date", e.target.value)
|
|
191
|
+
}
|
|
192
|
+
disabled={disabled}
|
|
193
|
+
className="flex-1"
|
|
194
|
+
/>
|
|
195
|
+
<Input
|
|
196
|
+
type="time"
|
|
197
|
+
value={startsAt ? dayjs(startsAt).format("HH:mm") : ""}
|
|
198
|
+
onChange={(e) =>
|
|
199
|
+
handleDateTimeChange("starts_at", "time", e.target.value)
|
|
200
|
+
}
|
|
201
|
+
disabled={disabled}
|
|
202
|
+
className="w-32"
|
|
203
|
+
/>
|
|
204
|
+
</div>
|
|
205
|
+
{errors.starts_at && (
|
|
206
|
+
<p className="text-red-500 text-sm mt-1">
|
|
207
|
+
{errors.starts_at.message}
|
|
208
|
+
</p>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
<div>
|
|
212
|
+
<Label>End Date</Label>
|
|
213
|
+
<div className="flex gap-2">
|
|
214
|
+
<Input
|
|
215
|
+
type="date"
|
|
216
|
+
value={endsAt ? dayjs(endsAt).format("YYYY-MM-DD") : ""}
|
|
217
|
+
onChange={(e) =>
|
|
218
|
+
handleDateTimeChange("ends_at", "date", e.target.value)
|
|
219
|
+
}
|
|
220
|
+
disabled={disabled}
|
|
221
|
+
className="flex-1"
|
|
222
|
+
/>
|
|
223
|
+
<Input
|
|
224
|
+
type="time"
|
|
225
|
+
value={endsAt ? dayjs(endsAt).format("HH:mm") : ""}
|
|
226
|
+
onChange={(e) =>
|
|
227
|
+
handleDateTimeChange("ends_at", "time", e.target.value)
|
|
228
|
+
}
|
|
229
|
+
disabled={disabled}
|
|
230
|
+
className="w-32"
|
|
231
|
+
/>
|
|
232
|
+
</div>
|
|
233
|
+
{errors.ends_at && (
|
|
234
|
+
<p className="text-red-500 text-sm mt-1">
|
|
235
|
+
{errors.ends_at.message}
|
|
236
|
+
</p>
|
|
237
|
+
)}
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<div className="flex justify-between items-center">
|
|
242
|
+
<Label>Products</Label>
|
|
243
|
+
<Button
|
|
244
|
+
type="button"
|
|
245
|
+
variant="secondary"
|
|
246
|
+
onClick={() => setOpenProductModal(true)}
|
|
247
|
+
disabled={disabled}
|
|
248
|
+
>
|
|
249
|
+
Add Product
|
|
250
|
+
</Button>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
{errors.products?.root && (
|
|
254
|
+
<p className="text-red-500 text-sm">{errors.products.root.message}</p>
|
|
255
|
+
)}
|
|
256
|
+
|
|
257
|
+
<Table>
|
|
258
|
+
<Table.Header>
|
|
259
|
+
<Table.Row>
|
|
260
|
+
<Table.HeaderCell>Product</Table.HeaderCell>
|
|
261
|
+
<Table.HeaderCell>Discount Type</Table.HeaderCell>
|
|
262
|
+
<Table.HeaderCell>Discount Value</Table.HeaderCell>
|
|
263
|
+
<Table.HeaderCell>Limit</Table.HeaderCell>
|
|
264
|
+
<Table.HeaderCell>Max Qty per Order</Table.HeaderCell>
|
|
265
|
+
<Table.HeaderCell>Actions</Table.HeaderCell>
|
|
266
|
+
</Table.Row>
|
|
267
|
+
</Table.Header>
|
|
268
|
+
<Table.Body>
|
|
269
|
+
{fields.map((field, index) => (
|
|
270
|
+
<Table.Row key={field.id}>
|
|
271
|
+
<Table.Cell>{field.product.title}</Table.Cell>
|
|
272
|
+
<Table.Cell>
|
|
273
|
+
<Controller
|
|
274
|
+
name={`products.${index}.discountType`}
|
|
275
|
+
control={control}
|
|
276
|
+
render={({ field }) => (
|
|
277
|
+
<Select
|
|
278
|
+
value={field.value}
|
|
279
|
+
onValueChange={field.onChange}
|
|
280
|
+
disabled={disabled}
|
|
281
|
+
>
|
|
282
|
+
<Select.Trigger>
|
|
283
|
+
<Select.Value placeholder="Select discount type" />
|
|
284
|
+
</Select.Trigger>
|
|
285
|
+
<Select.Content>
|
|
286
|
+
<Select.Item value="percentage">
|
|
287
|
+
Percentage
|
|
288
|
+
</Select.Item>
|
|
289
|
+
{/* <Select.Item value="fixed">Fixed</Select.Item> */}
|
|
290
|
+
</Select.Content>
|
|
291
|
+
</Select>
|
|
292
|
+
)}
|
|
293
|
+
/>
|
|
294
|
+
</Table.Cell>
|
|
295
|
+
<Table.Cell>
|
|
296
|
+
<Controller
|
|
297
|
+
name={`products.${index}.discountValue`}
|
|
298
|
+
control={control}
|
|
299
|
+
render={({ field }) => (
|
|
300
|
+
<Input
|
|
301
|
+
type="number"
|
|
302
|
+
value={field.value}
|
|
303
|
+
onChange={(e) => field.onChange(Number(e.target.value))}
|
|
304
|
+
disabled={disabled}
|
|
305
|
+
/>
|
|
306
|
+
)}
|
|
307
|
+
/>
|
|
308
|
+
</Table.Cell>
|
|
309
|
+
<Table.Cell>
|
|
310
|
+
<Controller
|
|
311
|
+
name={`products.${index}.limit`}
|
|
312
|
+
control={control}
|
|
313
|
+
render={({ field }) => (
|
|
314
|
+
<Input
|
|
315
|
+
type="number"
|
|
316
|
+
value={field.value}
|
|
317
|
+
onChange={(e) => field.onChange(Number(e.target.value))}
|
|
318
|
+
disabled={disabled}
|
|
319
|
+
/>
|
|
320
|
+
)}
|
|
321
|
+
/>
|
|
322
|
+
</Table.Cell>
|
|
323
|
+
<Table.Cell>
|
|
324
|
+
<Controller
|
|
325
|
+
name={`products.${index}.maxQty`}
|
|
326
|
+
control={control}
|
|
327
|
+
render={({ field }) => (
|
|
328
|
+
<Input
|
|
329
|
+
type="number"
|
|
330
|
+
value={field.value}
|
|
331
|
+
onChange={(e) => field.onChange(Number(e.target.value))}
|
|
332
|
+
disabled={disabled}
|
|
333
|
+
/>
|
|
334
|
+
)}
|
|
335
|
+
/>
|
|
336
|
+
</Table.Cell>
|
|
337
|
+
<Table.Cell>
|
|
338
|
+
<Button
|
|
339
|
+
type="button"
|
|
340
|
+
variant="danger"
|
|
341
|
+
onClick={() => remove(index)}
|
|
342
|
+
disabled={disabled}
|
|
343
|
+
>
|
|
344
|
+
<Trash />
|
|
345
|
+
</Button>
|
|
346
|
+
</Table.Cell>
|
|
347
|
+
</Table.Row>
|
|
348
|
+
))}
|
|
349
|
+
</Table.Body>
|
|
350
|
+
</Table>
|
|
351
|
+
|
|
352
|
+
<Button type="submit" disabled={disabled}>
|
|
353
|
+
Save
|
|
354
|
+
</Button>
|
|
355
|
+
</form>
|
|
356
|
+
|
|
357
|
+
<FocusModal open={openProductModal} onOpenChange={setOpenProductModal}>
|
|
358
|
+
<FocusModal.Content>
|
|
359
|
+
<FocusModal.Header title="Add Product" />
|
|
360
|
+
<FocusModal.Body>
|
|
361
|
+
<ProductSelector
|
|
362
|
+
selectedProductIds={fields.map((f) => f.product.id)}
|
|
363
|
+
onSelectProduct={(product) => {
|
|
364
|
+
append({
|
|
365
|
+
product,
|
|
366
|
+
discountType: "percentage",
|
|
367
|
+
discountValue: 10,
|
|
368
|
+
limit: 10,
|
|
369
|
+
maxQty: 1,
|
|
370
|
+
});
|
|
371
|
+
setOpenProductModal(false);
|
|
372
|
+
}}
|
|
373
|
+
/>
|
|
374
|
+
</FocusModal.Body>
|
|
375
|
+
</FocusModal.Content>
|
|
376
|
+
</FocusModal>
|
|
377
|
+
</Container>
|
|
378
|
+
);
|
|
379
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { CampaignDTO } 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 { useFlashSales } from "../hooks/useFlashSales";
|
|
15
|
+
|
|
16
|
+
export const FlashSalePage = () => {
|
|
17
|
+
const navigate = useNavigate();
|
|
18
|
+
const limit = 20;
|
|
19
|
+
const [pagination, setPagination] = useState<DataTablePaginationState>({
|
|
20
|
+
pageSize: limit,
|
|
21
|
+
pageIndex: 0,
|
|
22
|
+
});
|
|
23
|
+
const offset = useMemo(() => {
|
|
24
|
+
return pagination.pageIndex * limit;
|
|
25
|
+
}, [pagination]);
|
|
26
|
+
|
|
27
|
+
const columnHelper = createDataTableColumnHelper<CampaignDTO>();
|
|
28
|
+
|
|
29
|
+
const columns = [
|
|
30
|
+
columnHelper.accessor("name", {
|
|
31
|
+
header: "Name",
|
|
32
|
+
cell: (info) => info.getValue(),
|
|
33
|
+
}),
|
|
34
|
+
columnHelper.accessor("ends_at", {
|
|
35
|
+
header: "Status",
|
|
36
|
+
cell: (info) => {
|
|
37
|
+
const date = info.getValue();
|
|
38
|
+
if (!date) {
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
const isActive = dayjs(date).isAfter(dayjs());
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Badge color={isActive ? "green" : "grey"}>
|
|
45
|
+
{isActive ? "Active" : "Inactive"}
|
|
46
|
+
</Badge>
|
|
47
|
+
);
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
columnHelper.accessor("starts_at", {
|
|
51
|
+
header: "Start Date",
|
|
52
|
+
cell: (info) => {
|
|
53
|
+
const date = info.getValue();
|
|
54
|
+
if (!date) {
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
return dayjs(date).format("YYYY-MM-DD HH:mm");
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
columnHelper.accessor("ends_at", {
|
|
61
|
+
header: "End Date",
|
|
62
|
+
cell: (info) => {
|
|
63
|
+
const date = info.getValue();
|
|
64
|
+
if (!date) {
|
|
65
|
+
return "";
|
|
66
|
+
}
|
|
67
|
+
return dayjs(date).format("YYYY-MM-DD HH:mm");
|
|
68
|
+
},
|
|
69
|
+
}),
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
const { data } = useFlashSales({ limit, offset });
|
|
73
|
+
|
|
74
|
+
const table = useDataTable({
|
|
75
|
+
data: data?.campaigns || [],
|
|
76
|
+
columns,
|
|
77
|
+
getRowId: (campaign: CampaignDTO) => campaign.id,
|
|
78
|
+
pagination: {
|
|
79
|
+
state: pagination,
|
|
80
|
+
onPaginationChange: setPagination,
|
|
81
|
+
},
|
|
82
|
+
rowCount: data?.count || 0,
|
|
83
|
+
onRowClick: (_, row) => {
|
|
84
|
+
navigate(`/flash-sales/${row.id}`);
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<Container>
|
|
90
|
+
<div className="flex justify-between items-center">
|
|
91
|
+
<h1 className="text-xl font-semibold">Campaigns</h1>
|
|
92
|
+
<Button onClick={() => navigate("/flash-sales/create")}>
|
|
93
|
+
Create Campaign
|
|
94
|
+
</Button>
|
|
95
|
+
</div>
|
|
96
|
+
<DataTable instance={table}>
|
|
97
|
+
<DataTable.Table />
|
|
98
|
+
<DataTable.Pagination />
|
|
99
|
+
</DataTable>
|
|
100
|
+
</Container>
|
|
101
|
+
);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// Campaign
|
|
105
|
+
// limit (global)
|
|
106
|
+
// period
|
|
107
|
+
|
|
108
|
+
// Products
|
|
109
|
+
// limit (per product global)
|
|
110
|
+
//
|
|
111
|
+
|
|
112
|
+
// promotion
|
|
113
|
+
//
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { AdminProduct } from "@medusajs/framework/types";
|
|
2
|
+
import {
|
|
3
|
+
Container,
|
|
4
|
+
createDataTableColumnHelper,
|
|
5
|
+
DataTable,
|
|
6
|
+
Input,
|
|
7
|
+
Text,
|
|
8
|
+
useDataTable,
|
|
9
|
+
} from "@medusajs/ui";
|
|
10
|
+
import { useQuery } from "@tanstack/react-query";
|
|
11
|
+
import { debounce } from "lodash";
|
|
12
|
+
import { useMemo, useState } from "react";
|
|
13
|
+
import { sdk } from "../lib/sdk";
|
|
14
|
+
|
|
15
|
+
interface ProductSelectorProps {
|
|
16
|
+
selectedProductIds: string[];
|
|
17
|
+
onSelectProduct?: (product: AdminProduct) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const columnHelper = createDataTableColumnHelper<AdminProduct>();
|
|
21
|
+
|
|
22
|
+
const columns = [
|
|
23
|
+
columnHelper.accessor("title", {
|
|
24
|
+
header: "Title",
|
|
25
|
+
cell: (info) => (
|
|
26
|
+
<Text size="large" className="py-2">
|
|
27
|
+
{info.getValue()}
|
|
28
|
+
</Text>
|
|
29
|
+
),
|
|
30
|
+
}),
|
|
31
|
+
columnHelper.accessor("description", {
|
|
32
|
+
header: "Description",
|
|
33
|
+
cell: (info) => (
|
|
34
|
+
<Text size="large" className="py-2">
|
|
35
|
+
{info.getValue()}
|
|
36
|
+
</Text>
|
|
37
|
+
),
|
|
38
|
+
}),
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
export const ProductSelector = ({
|
|
42
|
+
selectedProductIds,
|
|
43
|
+
onSelectProduct,
|
|
44
|
+
}: ProductSelectorProps) => {
|
|
45
|
+
const [search, setSearch] = useState("");
|
|
46
|
+
|
|
47
|
+
const debouncedSearchHandler = debounce((value: string) => {
|
|
48
|
+
setSearch(value);
|
|
49
|
+
}, 500);
|
|
50
|
+
|
|
51
|
+
const { data: products } = useQuery({
|
|
52
|
+
queryKey: ["products", selectedProductIds, search],
|
|
53
|
+
queryFn: () =>
|
|
54
|
+
sdk.admin.product.list({
|
|
55
|
+
q: search,
|
|
56
|
+
limit: 100,
|
|
57
|
+
}),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const selectableProducts = useMemo(() => {
|
|
61
|
+
return products?.products?.filter(
|
|
62
|
+
(product) => !selectedProductIds.includes(product.id)
|
|
63
|
+
);
|
|
64
|
+
}, [products?.products, selectedProductIds]);
|
|
65
|
+
|
|
66
|
+
const table = useDataTable({
|
|
67
|
+
data: selectableProducts || [],
|
|
68
|
+
columns,
|
|
69
|
+
getRowId: (product) => product.id,
|
|
70
|
+
onRowClick: (_, row) => {
|
|
71
|
+
// @ts-ignore
|
|
72
|
+
onSelectProduct?.(row.original);
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<Container>
|
|
78
|
+
<Input
|
|
79
|
+
className="text-lg py-2"
|
|
80
|
+
placeholder="Search products..."
|
|
81
|
+
onChange={(e) => debouncedSearchHandler(e.target.value)}
|
|
82
|
+
/>
|
|
83
|
+
<DataTable instance={table}>
|
|
84
|
+
<DataTable.Table />
|
|
85
|
+
</DataTable>
|
|
86
|
+
</Container>
|
|
87
|
+
);
|
|
88
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import axios from "axios";
|
|
3
|
+
import { CampaignProduct, CustomCampaign } from "../components/FlashSaleForm";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hook to fetch a single flash sale by ID
|
|
7
|
+
*/
|
|
8
|
+
export const useFlashSaleById = (id: string) => {
|
|
9
|
+
const query = useQuery({
|
|
10
|
+
queryKey: ["flash-sale", id],
|
|
11
|
+
queryFn: async () => {
|
|
12
|
+
const { data } = await axios.get<
|
|
13
|
+
CustomCampaign & { products: CampaignProduct[] }
|
|
14
|
+
>(`/admin/flash-sales/${id}`);
|
|
15
|
+
return data;
|
|
16
|
+
},
|
|
17
|
+
enabled: !!id,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
return query;
|
|
21
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { CampaignDTO } from "@medusajs/framework/types";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import axios from "axios";
|
|
4
|
+
|
|
5
|
+
export const useFlashSales = (pagination: {
|
|
6
|
+
limit: number;
|
|
7
|
+
offset: number;
|
|
8
|
+
}) => {
|
|
9
|
+
const query = useQuery({
|
|
10
|
+
queryKey: ["flash-sales", pagination],
|
|
11
|
+
queryFn: async () => {
|
|
12
|
+
const { data } = await axios.get<{
|
|
13
|
+
campaigns: CampaignDTO[];
|
|
14
|
+
count: number;
|
|
15
|
+
limit: number;
|
|
16
|
+
offset: number;
|
|
17
|
+
}>("/admin/flash-sales", {
|
|
18
|
+
params: pagination,
|
|
19
|
+
});
|
|
20
|
+
return data;
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return query;
|
|
25
|
+
};
|