@lodashventure/medusa-campaign 1.1.0 → 1.1.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.
Files changed (45) hide show
  1. package/.medusa/server/src/admin/index.js +1 -67
  2. package/.medusa/server/src/admin/index.mjs +1 -67
  3. package/.medusa/server/src/workflows/index.js +10 -0
  4. package/package.json +4 -4
  5. package/src/admin/README.md +31 -0
  6. package/src/admin/components/FlashSaleForm.tsx +379 -0
  7. package/src/admin/components/FlashSalePage.tsx +113 -0
  8. package/src/admin/components/ProductSelector.tsx +88 -0
  9. package/src/admin/hooks/useFlashSaleById.ts +21 -0
  10. package/src/admin/hooks/useFlashSales.ts +25 -0
  11. package/src/admin/lib/sdk.ts +10 -0
  12. package/src/admin/routes/flash-sales/[id]/page.tsx +105 -0
  13. package/src/admin/routes/flash-sales/create/page.tsx +51 -0
  14. package/src/admin/routes/flash-sales/page.tsx +15 -0
  15. package/src/admin/tsconfig.json +24 -0
  16. package/src/admin/types/campaign.ts +25 -0
  17. package/src/admin/vite-env.d.ts +1 -0
  18. package/src/api/README.md +133 -0
  19. package/src/api/admin/flash-sales/[id]/route.ts +164 -0
  20. package/src/api/admin/flash-sales/route.ts +87 -0
  21. package/src/api/middlewares.ts +32 -0
  22. package/src/api/store/campaigns/[id]/route.ts +133 -0
  23. package/src/api/store/campaigns/route.ts +36 -0
  24. package/src/jobs/README.md +36 -0
  25. package/src/links/README.md +26 -0
  26. package/src/links/campaign-type.ts +8 -0
  27. package/src/modules/README.md +116 -0
  28. package/src/modules/custom-campaigns/index.ts +8 -0
  29. package/src/modules/custom-campaigns/migrations/.snapshot-medusa-custom-campaign.json +235 -0
  30. package/src/modules/custom-campaigns/migrations/Migration20250524150901.ts +23 -0
  31. package/src/modules/custom-campaigns/migrations/Migration20250526010310.ts +20 -0
  32. package/src/modules/custom-campaigns/migrations/Migration20250529011904.ts +13 -0
  33. package/src/modules/custom-campaigns/models/custom-campaign-type.ts +10 -0
  34. package/src/modules/custom-campaigns/models/promotion-usage-limit.ts +14 -0
  35. package/src/modules/custom-campaigns/service.ts +10 -0
  36. package/src/modules/custom-campaigns/types/campaign-type.enum.ts +3 -0
  37. package/src/providers/README.md +30 -0
  38. package/src/subscribers/README.md +59 -0
  39. package/src/subscribers/order-placed.ts +17 -0
  40. package/src/workflows/README.md +79 -0
  41. package/src/workflows/custom-campaign/createCustomCampaignWorkflow.ts +181 -0
  42. package/src/workflows/custom-campaign/updateCustomFlashSaleWorkflow.ts +185 -0
  43. package/src/workflows/custom-campaign/updatePromotionUsageWorkflow.ts +70 -0
  44. package/src/workflows/hooks/deletePromotionOnCampaignDelete.ts +49 -0
  45. package/src/workflows/index.ts +3 -0
@@ -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
+ };
@@ -0,0 +1,10 @@
1
+ // src/admin/lib/sdk.ts
2
+ import Medusa from "@medusajs/js-sdk";
3
+
4
+ export const sdk = new Medusa({
5
+ baseUrl: import.meta.env.VITE_BACKEND_URL || "/",
6
+ debug: import.meta.env.DEV,
7
+ auth: {
8
+ type: "session",
9
+ },
10
+ });
@@ -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.