@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,105 @@
|
|
|
1
|
+
import { defineRouteConfig } from "@medusajs/admin-sdk";
|
|
2
|
+
import { Sparkles } from "@medusajs/icons";
|
|
3
|
+
import { Button, Container, toast } from "@medusajs/ui";
|
|
4
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
5
|
+
import axios from "axios";
|
|
6
|
+
import { FC, useState } from "react";
|
|
7
|
+
import { useNavigate, useParams } from "react-router-dom";
|
|
8
|
+
import {
|
|
9
|
+
CampaignData,
|
|
10
|
+
CustomCampaign,
|
|
11
|
+
FlashSaleForm,
|
|
12
|
+
} from "../../../components/FlashSaleForm";
|
|
13
|
+
import { useFlashSaleById } from "../../../hooks/useFlashSaleById";
|
|
14
|
+
|
|
15
|
+
const FlashSaleDetail: FC = () => {
|
|
16
|
+
const { id } = useParams<{ id: string }>();
|
|
17
|
+
const navigate = useNavigate();
|
|
18
|
+
const queryClient = useQueryClient();
|
|
19
|
+
const { data, isLoading } = useFlashSaleById(id || "");
|
|
20
|
+
|
|
21
|
+
const [isEditing, setIsEditing] = useState(false);
|
|
22
|
+
|
|
23
|
+
async function handleSubmit(campaignData: CampaignData) {
|
|
24
|
+
try {
|
|
25
|
+
await axios.put(`/admin/flash-sales/${id}`, {
|
|
26
|
+
...campaignData,
|
|
27
|
+
starts_at: new Date(campaignData.starts_at).toUTCString(),
|
|
28
|
+
ends_at: new Date(campaignData.ends_at).toUTCString(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
toast.success("Flash sale updated successfully");
|
|
32
|
+
queryClient.invalidateQueries({
|
|
33
|
+
exact: false,
|
|
34
|
+
predicate(query) {
|
|
35
|
+
return ["flash-sales", "flash-sale"].includes(
|
|
36
|
+
query.queryKey[0] as string
|
|
37
|
+
);
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
navigate("/flash-sales");
|
|
41
|
+
} catch (error) {
|
|
42
|
+
let message: string;
|
|
43
|
+
|
|
44
|
+
if (axios.isAxiosError(error)) {
|
|
45
|
+
console.log(error);
|
|
46
|
+
message = error.response?.data.message;
|
|
47
|
+
} else if (error instanceof Error) {
|
|
48
|
+
message = error.message;
|
|
49
|
+
} else {
|
|
50
|
+
message = "Failed to update flash sale";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
toast.error("Failed to update flash sale", {
|
|
54
|
+
description: message,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (isLoading) {
|
|
60
|
+
return (
|
|
61
|
+
<Container className="flex items-center justify-center h-screen">
|
|
62
|
+
<div className="animate-spin h-10 w-10 border-4 border-primary rounded-full border-t-transparent"></div>
|
|
63
|
+
</Container>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!data) {
|
|
68
|
+
return (
|
|
69
|
+
<Container>
|
|
70
|
+
<p>Flash sale not found</p>
|
|
71
|
+
</Container>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Convert campaign data to the format expected by the form
|
|
76
|
+
const campaignData: CustomCampaign = {
|
|
77
|
+
name: data.name || "",
|
|
78
|
+
description: data.description || "",
|
|
79
|
+
type: "flash-sale",
|
|
80
|
+
starts_at: data.starts_at,
|
|
81
|
+
ends_at: data.ends_at,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<>
|
|
86
|
+
<FlashSaleForm
|
|
87
|
+
initialData={campaignData}
|
|
88
|
+
initialProducts={
|
|
89
|
+
new Map(data.products.map((product) => [product.product.id, product]))
|
|
90
|
+
}
|
|
91
|
+
onSubmit={handleSubmit}
|
|
92
|
+
onCancel={() => navigate("/flash-sales")}
|
|
93
|
+
disabled={!isEditing}
|
|
94
|
+
/>
|
|
95
|
+
<Button onClick={() => setIsEditing((prev) => !prev)}>Toggle Edit</Button>
|
|
96
|
+
</>
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const config = defineRouteConfig({
|
|
101
|
+
label: "Flash Sale Detail",
|
|
102
|
+
icon: Sparkles,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
export default FlashSaleDetail;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { toast } from "@medusajs/ui";
|
|
2
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import axios from "axios";
|
|
4
|
+
import { FC } from "react";
|
|
5
|
+
import { useNavigate } from "react-router-dom";
|
|
6
|
+
import { CampaignData, FlashSaleForm } from "../../../components/FlashSaleForm";
|
|
7
|
+
|
|
8
|
+
const FlashSaleCreate: FC = () => {
|
|
9
|
+
const navigate = useNavigate();
|
|
10
|
+
const queryClient = useQueryClient();
|
|
11
|
+
|
|
12
|
+
async function handleSubmit(campaignData: CampaignData) {
|
|
13
|
+
try {
|
|
14
|
+
await axios.post("/admin/flash-sales", {
|
|
15
|
+
...campaignData,
|
|
16
|
+
starts_at: new Date(campaignData.starts_at).toUTCString(),
|
|
17
|
+
ends_at: new Date(campaignData.ends_at).toUTCString(),
|
|
18
|
+
});
|
|
19
|
+
toast.success("Flash sale created successfully");
|
|
20
|
+
queryClient.invalidateQueries({
|
|
21
|
+
exact: false,
|
|
22
|
+
queryKey: ["flash-sales"],
|
|
23
|
+
});
|
|
24
|
+
navigate("/flash-sales");
|
|
25
|
+
} catch (error) {
|
|
26
|
+
let message: string;
|
|
27
|
+
|
|
28
|
+
if (axios.isAxiosError(error)) {
|
|
29
|
+
console.log(error);
|
|
30
|
+
message = error.response?.data.message;
|
|
31
|
+
} else if (error instanceof Error) {
|
|
32
|
+
message = error.message;
|
|
33
|
+
} else {
|
|
34
|
+
message = "Failed to create flash sale";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
toast.error("Failed to create flash sale", {
|
|
38
|
+
description: message,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<FlashSaleForm
|
|
45
|
+
onSubmit={handleSubmit}
|
|
46
|
+
onCancel={() => navigate("/flash-sales")}
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export default FlashSaleCreate;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineRouteConfig } from "@medusajs/admin-sdk";
|
|
2
|
+
import { Sparkles } from "@medusajs/icons";
|
|
3
|
+
import { FC } from "react";
|
|
4
|
+
import { FlashSalePage } from "../../components/FlashSalePage";
|
|
5
|
+
|
|
6
|
+
const FlashSale: FC = () => {
|
|
7
|
+
return <FlashSalePage />;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const config = defineRouteConfig({
|
|
11
|
+
label: "Flash Sale",
|
|
12
|
+
icon: Sparkles,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export default FlashSale;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
|
|
9
|
+
/* Bundler mode */
|
|
10
|
+
"moduleResolution": "bundler",
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"noEmit": true,
|
|
15
|
+
"jsx": "react-jsx",
|
|
16
|
+
|
|
17
|
+
/* Linting */
|
|
18
|
+
"strict": true,
|
|
19
|
+
"noUnusedLocals": true,
|
|
20
|
+
"noUnusedParameters": true,
|
|
21
|
+
"noFallthroughCasesInSwitch": true
|
|
22
|
+
},
|
|
23
|
+
"include": ["."]
|
|
24
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface Campaign {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
description: string;
|
|
5
|
+
start_date: string;
|
|
6
|
+
end_date: string;
|
|
7
|
+
status: "draft" | "active" | "completed" | "cancelled";
|
|
8
|
+
created_at: string;
|
|
9
|
+
updated_at: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface CampaignFilters {
|
|
13
|
+
status?: string[];
|
|
14
|
+
q?: string;
|
|
15
|
+
start_date?: string;
|
|
16
|
+
end_date?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CampaignFormValues {
|
|
20
|
+
name: string;
|
|
21
|
+
description: string;
|
|
22
|
+
start_date: string;
|
|
23
|
+
end_date: string;
|
|
24
|
+
status: string;
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Custom API Routes
|
|
2
|
+
|
|
3
|
+
An API Route is a REST API endpoint.
|
|
4
|
+
|
|
5
|
+
An API Route is created in a TypeScript or JavaScript file under the `/src/api` directory of your Medusa application. The file’s name must be `route.ts` or `route.js`.
|
|
6
|
+
|
|
7
|
+
For example, to create a `GET` API Route at `/store/hello-world`, create the file `src/api/store/hello-world/route.ts` with the following content:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http";
|
|
11
|
+
|
|
12
|
+
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
|
13
|
+
res.json({
|
|
14
|
+
message: "Hello world!",
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Supported HTTP methods
|
|
20
|
+
|
|
21
|
+
The file based routing supports the following HTTP methods:
|
|
22
|
+
|
|
23
|
+
- GET
|
|
24
|
+
- POST
|
|
25
|
+
- PUT
|
|
26
|
+
- PATCH
|
|
27
|
+
- DELETE
|
|
28
|
+
- OPTIONS
|
|
29
|
+
- HEAD
|
|
30
|
+
|
|
31
|
+
You can define a handler for each of these methods by exporting a function with the name of the method in the paths `route.ts` file.
|
|
32
|
+
|
|
33
|
+
For example:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http";
|
|
37
|
+
|
|
38
|
+
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
|
39
|
+
// Handle GET requests
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
|
43
|
+
// Handle POST requests
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function PUT(req: MedusaRequest, res: MedusaResponse) {
|
|
47
|
+
// Handle PUT requests
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Parameters
|
|
52
|
+
|
|
53
|
+
To create an API route that accepts a path parameter, create a directory within the route's path whose name is of the format `[param]`.
|
|
54
|
+
|
|
55
|
+
For example, if you want to define a route that takes a `productId` parameter, you can do so by creating a file called `/api/products/[productId]/route.ts`:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import type {
|
|
59
|
+
MedusaRequest,
|
|
60
|
+
MedusaResponse,
|
|
61
|
+
} from "@medusajs/framework/http"
|
|
62
|
+
|
|
63
|
+
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
|
64
|
+
const { productId } = req.params;
|
|
65
|
+
|
|
66
|
+
res.json({
|
|
67
|
+
message: `You're looking for product ${productId}`
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
To create an API route that accepts multiple path parameters, create within the file's path multiple directories whose names are of the format `[param]`.
|
|
73
|
+
|
|
74
|
+
For example, if you want to define a route that takes both a `productId` and a `variantId` parameter, you can do so by creating a file called `/api/products/[productId]/variants/[variantId]/route.ts`.
|
|
75
|
+
|
|
76
|
+
## Using the container
|
|
77
|
+
|
|
78
|
+
The Medusa container is available on `req.scope`. Use it to access modules' main services and other registered resources:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import type {
|
|
82
|
+
MedusaRequest,
|
|
83
|
+
MedusaResponse,
|
|
84
|
+
} from "@medusajs/framework/http"
|
|
85
|
+
|
|
86
|
+
export const GET = async (
|
|
87
|
+
req: MedusaRequest,
|
|
88
|
+
res: MedusaResponse
|
|
89
|
+
) => {
|
|
90
|
+
const productModuleService = req.scope.resolve("product")
|
|
91
|
+
|
|
92
|
+
const [, count] = await productModuleService.listAndCount()
|
|
93
|
+
|
|
94
|
+
res.json({
|
|
95
|
+
count,
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Middleware
|
|
101
|
+
|
|
102
|
+
You can apply middleware to your routes by creating a file called `/api/middlewares.ts`. This file must export a configuration object with what middleware you want to apply to which routes.
|
|
103
|
+
|
|
104
|
+
For example, if you want to apply a custom middleware function to the `/store/custom` route, you can do so by adding the following to your `/api/middlewares.ts` file:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { defineMiddlewares } from "@medusajs/framework/http"
|
|
108
|
+
import type {
|
|
109
|
+
MedusaRequest,
|
|
110
|
+
MedusaResponse,
|
|
111
|
+
MedusaNextFunction,
|
|
112
|
+
} from "@medusajs/framework/http";
|
|
113
|
+
|
|
114
|
+
async function logger(
|
|
115
|
+
req: MedusaRequest,
|
|
116
|
+
res: MedusaResponse,
|
|
117
|
+
next: MedusaNextFunction
|
|
118
|
+
) {
|
|
119
|
+
console.log("Request received");
|
|
120
|
+
next();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export default defineMiddlewares({
|
|
124
|
+
routes: [
|
|
125
|
+
{
|
|
126
|
+
matcher: "/store/custom",
|
|
127
|
+
middlewares: [logger],
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
})
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The `matcher` property can be either a string or a regular expression. The `middlewares` property accepts an array of middleware functions.
|
|
@@ -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 { CustomCampaign, createCustomCampaignSchema } from "../route";
|
|
9
|
+
import { updateCustomFlashSaleWorkflow } from "../../../../workflows/custom-campaign/updateCustomFlashSaleWorkflow";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* GET handler for fetching a specific flash sale by ID
|
|
13
|
+
*/
|
|
14
|
+
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
|
|
15
|
+
const { id } = req.params;
|
|
16
|
+
|
|
17
|
+
if (!id) {
|
|
18
|
+
throw new MedusaError(
|
|
19
|
+
MedusaError.Types.INVALID_DATA,
|
|
20
|
+
"Campaign ID is required"
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const query = container.resolve(ContainerRegistrationKeys.QUERY);
|
|
25
|
+
const productService = container.resolve(Modules.PRODUCT);
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
// First, find the custom campaign type by campaign ID
|
|
29
|
+
const {
|
|
30
|
+
data: [customCampaignTypes],
|
|
31
|
+
} = await query.graph({
|
|
32
|
+
entity: "custom_campaign_type",
|
|
33
|
+
fields: [
|
|
34
|
+
"id",
|
|
35
|
+
"campaign.*",
|
|
36
|
+
"campaign.promotions.*",
|
|
37
|
+
"campaign.promotions.application_method.*",
|
|
38
|
+
"campaign.promotions.application_method.target_rules.*",
|
|
39
|
+
],
|
|
40
|
+
filters: {
|
|
41
|
+
type: CampaignTypeEnum.FlashSale,
|
|
42
|
+
campaign_id: id,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const campaign = customCampaignTypes.campaign;
|
|
47
|
+
|
|
48
|
+
if (!campaign) {
|
|
49
|
+
throw new MedusaError(
|
|
50
|
+
MedusaError.Types.NOT_FOUND,
|
|
51
|
+
`Flash sale with ID ${id} not found`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Fetch promotion usage limits for the campaign
|
|
56
|
+
const { data: promotionUsageLimits } = await query.graph({
|
|
57
|
+
entity: "promotion_usage_limit",
|
|
58
|
+
fields: ["id", "promotion_id", "product_id", "limit", "used"],
|
|
59
|
+
filters: {
|
|
60
|
+
campaign_id: id,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Create a map of promotion usage limits by promotion ID
|
|
65
|
+
const promotionLimitsMap = new Map();
|
|
66
|
+
promotionUsageLimits.forEach((limit) => {
|
|
67
|
+
promotionLimitsMap.set(limit.promotion_id, limit);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Process promotions to extract product information
|
|
71
|
+
const products: CustomCampaign["products"] = [];
|
|
72
|
+
for await (const promotion of campaign.promotions ?? []) {
|
|
73
|
+
if (!promotion.application_method?.target_rules?.length) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const productRule = promotion.application_method.target_rules.find(
|
|
78
|
+
(rule) =>
|
|
79
|
+
rule.attribute === "items.product.id" && rule.operator === "eq"
|
|
80
|
+
);
|
|
81
|
+
const promotionLimit = promotionLimitsMap.get(promotion.id);
|
|
82
|
+
|
|
83
|
+
const product = await productService.retrieveProduct(
|
|
84
|
+
promotionLimit?.product_id
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
if (productRule && promotion.application_method.value) {
|
|
88
|
+
products.push({
|
|
89
|
+
product: {
|
|
90
|
+
id: product.id,
|
|
91
|
+
title: product.title,
|
|
92
|
+
},
|
|
93
|
+
discountType: promotion.application_method?.type,
|
|
94
|
+
discountValue: promotion.application_method?.value,
|
|
95
|
+
maxQty: promotion.application_method?.max_quantity,
|
|
96
|
+
limit: promotionLimitsMap.get(promotion.id)?.limit,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
res.status(200).json({
|
|
102
|
+
...campaign,
|
|
103
|
+
products,
|
|
104
|
+
} satisfies CustomCampaign);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error instanceof MedusaError) {
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
console.error("Error fetching flash sale:", error);
|
|
111
|
+
throw new MedusaError(
|
|
112
|
+
MedusaError.Types.UNEXPECTED_STATE,
|
|
113
|
+
"An error occurred while fetching the flash sale"
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* PUT handler for updating a specific flash sale by ID
|
|
120
|
+
*/
|
|
121
|
+
export const PUT = async (
|
|
122
|
+
req: MedusaRequest<CustomCampaign>,
|
|
123
|
+
res: MedusaResponse
|
|
124
|
+
) => {
|
|
125
|
+
const { id } = req.params;
|
|
126
|
+
const body = req.body;
|
|
127
|
+
|
|
128
|
+
if (!id) {
|
|
129
|
+
throw new MedusaError(
|
|
130
|
+
MedusaError.Types.INVALID_DATA,
|
|
131
|
+
"Campaign ID is required"
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
// Check if start date is before end date
|
|
137
|
+
if (new Date(body.ends_at) < new Date(body.starts_at)) {
|
|
138
|
+
throw new MedusaError(
|
|
139
|
+
MedusaError.Types.INVALID_DATA,
|
|
140
|
+
"End date must be after start date"
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Update the flash sale
|
|
145
|
+
const result = await updateCustomFlashSaleWorkflow.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 flash sale:", error);
|
|
159
|
+
throw new MedusaError(
|
|
160
|
+
MedusaError.Types.UNEXPECTED_STATE,
|
|
161
|
+
"An error occurred while updating the flash sale"
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
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 { createCustomCampaignWorkflow } from "../../../workflows/custom-campaign/createCustomCampaignWorkflow";
|
|
10
|
+
|
|
11
|
+
export const createCustomCampaignSchema = z.object({
|
|
12
|
+
name: z.string().min(1, "Name is required"),
|
|
13
|
+
description: z.string().min(1, "Description is required"),
|
|
14
|
+
type: z.nativeEnum(CampaignTypeEnum),
|
|
15
|
+
starts_at: z.coerce.date(),
|
|
16
|
+
ends_at: z.coerce.date(),
|
|
17
|
+
products: z
|
|
18
|
+
.array(
|
|
19
|
+
z.object({
|
|
20
|
+
product: z.object({
|
|
21
|
+
id: z.string(),
|
|
22
|
+
title: z.string(),
|
|
23
|
+
}),
|
|
24
|
+
discountType: z.enum([
|
|
25
|
+
"percentage",
|
|
26
|
+
// need to handle fixed discount and currency
|
|
27
|
+
// "fixed",
|
|
28
|
+
]),
|
|
29
|
+
discountValue: z.number().min(1),
|
|
30
|
+
limit: z.number().min(1),
|
|
31
|
+
maxQty: z.number().min(1),
|
|
32
|
+
})
|
|
33
|
+
)
|
|
34
|
+
.min(1, "At least one product is required"),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export type CustomCampaign = z.infer<typeof createCustomCampaignSchema>;
|
|
38
|
+
|
|
39
|
+
export const GetFlashSalesSchema = createFindParams({
|
|
40
|
+
order: "-created_at",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const POST = async (
|
|
44
|
+
req: MedusaRequest<CustomCampaign>,
|
|
45
|
+
res: MedusaResponse
|
|
46
|
+
) => {
|
|
47
|
+
const body = req.body;
|
|
48
|
+
|
|
49
|
+
if (new Date(body.ends_at) < new Date(body.starts_at)) {
|
|
50
|
+
throw new MedusaError(
|
|
51
|
+
MedusaError.Types.INVALID_DATA,
|
|
52
|
+
"End date must be after start date"
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const customCampaign = await createCustomCampaignWorkflow.run({
|
|
57
|
+
input: body,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
res.status(200).json(customCampaign);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
|
|
64
|
+
const query = container.resolve(ContainerRegistrationKeys.QUERY);
|
|
65
|
+
const {
|
|
66
|
+
data: customCampaigns,
|
|
67
|
+
metadata: { count, take, skip } = {
|
|
68
|
+
count: 0,
|
|
69
|
+
take: 20,
|
|
70
|
+
skip: 0,
|
|
71
|
+
},
|
|
72
|
+
} = await query.graph({
|
|
73
|
+
entity: "custom_campaign_type",
|
|
74
|
+
...req.queryConfig,
|
|
75
|
+
fields: ["id", "campaign.*", "campaign.promotions.*"],
|
|
76
|
+
filters: { type: CampaignTypeEnum.FlashSale },
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const campaigns = customCampaigns.map((campaign) => campaign.campaign);
|
|
80
|
+
|
|
81
|
+
res.status(200).json({
|
|
82
|
+
campaigns,
|
|
83
|
+
count,
|
|
84
|
+
limit: take,
|
|
85
|
+
offset: skip,
|
|
86
|
+
});
|
|
87
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineMiddlewares,
|
|
3
|
+
validateAndTransformBody,
|
|
4
|
+
validateAndTransformQuery,
|
|
5
|
+
} from "@medusajs/framework";
|
|
6
|
+
import {
|
|
7
|
+
createCustomCampaignSchema,
|
|
8
|
+
GetFlashSalesSchema,
|
|
9
|
+
} from "./admin/flash-sales/route";
|
|
10
|
+
|
|
11
|
+
export default defineMiddlewares([
|
|
12
|
+
{
|
|
13
|
+
methods: ["POST"],
|
|
14
|
+
matcher: "/admin/flash-sales",
|
|
15
|
+
middlewares: [validateAndTransformBody(createCustomCampaignSchema as any)],
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
methods: ["PUT"],
|
|
19
|
+
matcher: "/admin/flash-sales/:id",
|
|
20
|
+
middlewares: [validateAndTransformBody(createCustomCampaignSchema as any)],
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
methods: ["GET"],
|
|
24
|
+
matcher: "/admin/flash-sales",
|
|
25
|
+
middlewares: [
|
|
26
|
+
validateAndTransformQuery(GetFlashSalesSchema as any, {
|
|
27
|
+
defaults: ["*", "campaign.*"],
|
|
28
|
+
isList: true,
|
|
29
|
+
}),
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
]);
|