@lodashventure/medusa-campaign 1.3.11 → 1.3.13
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 +3031 -6990
- package/.medusa/server/src/admin/index.mjs +3016 -6977
- package/.medusa/server/src/api/admin/campaigns/[id]/detail/route.js +67 -0
- package/.medusa/server/src/api/admin/campaigns/[id]/image/route.js +80 -0
- package/.medusa/server/src/api/admin/campaigns/[id]/thumbnail/route.js +80 -0
- package/.medusa/server/src/api/admin/campaigns/sync/route.js +8 -6
- package/.medusa/server/src/api/admin/flash-sales/[id]/route.js +22 -4
- package/.medusa/server/src/api/middlewares.js +24 -1
- package/.medusa/server/src/api/store/buy-x-get-y/[id]/route.js +1 -1
- package/.medusa/server/src/api/store/buy-x-get-y/products/[productId]/route.js +1 -1
- package/.medusa/server/src/api/store/buy-x-get-y/route.js +3 -1
- package/.medusa/server/src/api/store/campaigns/[id]/route.js +40 -12
- package/.medusa/server/src/modules/custom-campaigns/migrations/Migration20251024000000.js +53 -0
- package/.medusa/server/src/modules/custom-campaigns/migrations/Migration20251025000000.js +22 -0
- package/.medusa/server/src/modules/custom-campaigns/models/campaign-detail.js +26 -0
- package/.medusa/server/src/modules/custom-campaigns/service.js +3 -1
- package/.medusa/server/src/subscribers/order-placed.js +2 -2
- package/.medusa/server/src/workflows/buy-x-get-y/applyBuyXGetYToCartWorkflow.js +16 -4
- package/.medusa/server/src/workflows/campaign-detail/update-campaign-detail.js +55 -0
- package/.medusa/server/src/workflows/campaign-detail/upload-campaign-images.js +120 -0
- package/.medusa/server/src/workflows/custom-campaign/createBuyXGetYCampaignWorkflow.js +13 -14
- package/.medusa/server/src/workflows/index.js +6 -3
- package/package.json +23 -19
- package/src/admin/components/campaign-detail-form.tsx +407 -0
- package/src/admin/components/campaign-image-uploader.tsx +313 -0
- package/src/admin/components/markdown-editor.tsx +298 -0
- package/src/admin/routes/flash-sales/[id]/page.tsx +51 -14
- package/src/admin/widgets/campaign-detail-widget.tsx +299 -0
- package/src/admin/widgets/campaign-stats-widget.tsx +238 -0
- package/src/api/admin/campaigns/[id]/detail/route.ts +77 -0
- package/src/api/admin/campaigns/[id]/image/route.ts +87 -0
- package/src/api/admin/campaigns/[id]/thumbnail/route.ts +87 -0
- package/src/api/admin/campaigns/sync/route.ts +53 -28
- package/src/api/admin/flash-sales/[id]/route.ts +50 -19
- package/src/api/middlewares.ts +21 -0
- package/src/api/store/buy-x-get-y/[id]/route.ts +10 -10
- package/src/api/store/buy-x-get-y/products/[productId]/route.ts +11 -12
- package/src/api/store/buy-x-get-y/route.ts +12 -5
- package/src/api/store/campaigns/[id]/route.ts +54 -24
- package/src/modules/custom-campaigns/migrations/Migration20251024000000.ts +53 -0
- package/src/modules/custom-campaigns/migrations/Migration20251025000000.ts +19 -0
- package/src/modules/custom-campaigns/models/campaign-detail.ts +25 -0
- package/src/modules/custom-campaigns/service.ts +2 -0
- package/src/subscribers/order-placed.ts +0 -2
- package/src/types/index.d.ts +46 -0
- package/src/workflows/buy-x-get-y/applyBuyXGetYToCartWorkflow.ts +41 -18
- package/src/workflows/campaign-detail/update-campaign-detail.ts +85 -0
- package/src/workflows/campaign-detail/upload-campaign-images.ts +163 -0
- package/src/workflows/custom-campaign/createBuyXGetYCampaignWorkflow.ts +23 -22
- package/src/workflows/index.ts +3 -2
- package/.medusa/server/src/api/admin/campaigns/fix-dates/route.js +0 -103
- package/.medusa/server/src/api/admin/force-fix/route.js +0 -176
- package/.medusa/server/src/api/admin/test-campaign/route.js +0 -132
- package/src/api/admin/campaigns/fix-dates/route.ts +0 -107
- package/src/api/admin/force-fix/route.ts +0 -184
- package/src/api/admin/test-campaign/route.ts +0 -141
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { defineWidgetConfig } from "@medusajs/admin-sdk";
|
|
2
|
+
import {
|
|
3
|
+
Container,
|
|
4
|
+
Heading,
|
|
5
|
+
Text,
|
|
6
|
+
Badge,
|
|
7
|
+
Button,
|
|
8
|
+
Alert,
|
|
9
|
+
} from "@medusajs/ui";
|
|
10
|
+
import { Sparkles, PhotoSolid, PencilSquare, Eye } from "@medusajs/icons";
|
|
11
|
+
import { useState, useEffect } from "react";
|
|
12
|
+
import { useNavigate } from "react-router-dom";
|
|
13
|
+
|
|
14
|
+
interface CampaignDetail {
|
|
15
|
+
id: string;
|
|
16
|
+
campaign_id: string;
|
|
17
|
+
image_url?: string | null;
|
|
18
|
+
thumbnail_url?: string | null;
|
|
19
|
+
detail_content?: string | null;
|
|
20
|
+
terms_and_conditions?: string | null;
|
|
21
|
+
meta_title?: string | null;
|
|
22
|
+
meta_description?: string | null;
|
|
23
|
+
link_url?: string | null;
|
|
24
|
+
link_text?: string | null;
|
|
25
|
+
display_order: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Campaign {
|
|
29
|
+
id: string;
|
|
30
|
+
name: string;
|
|
31
|
+
description?: string;
|
|
32
|
+
campaign_budget?: {
|
|
33
|
+
type: string;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface CampaignDetailWidgetProps {
|
|
38
|
+
data: any; // Promotion data
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const CampaignDetailWidget = ({ data: promotion }: CampaignDetailWidgetProps) => {
|
|
42
|
+
const navigate = useNavigate();
|
|
43
|
+
const [campaign, setCampaign] = useState<Campaign | null>(null);
|
|
44
|
+
const [campaignDetail, setCampaignDetail] = useState<CampaignDetail | null>(null);
|
|
45
|
+
const [loading, setLoading] = useState(true);
|
|
46
|
+
const [error, setError] = useState<string | null>(null);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (promotion?.campaign_id) {
|
|
50
|
+
fetchCampaignAndDetail();
|
|
51
|
+
} else {
|
|
52
|
+
setLoading(false);
|
|
53
|
+
}
|
|
54
|
+
}, [promotion?.campaign_id]);
|
|
55
|
+
|
|
56
|
+
const fetchCampaignAndDetail = async () => {
|
|
57
|
+
if (!promotion?.campaign_id) return;
|
|
58
|
+
|
|
59
|
+
setLoading(true);
|
|
60
|
+
setError(null);
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
// Fetch campaign info
|
|
64
|
+
const campaignResponse = await fetch(
|
|
65
|
+
`/admin/flash-sales/${promotion.campaign_id}`,
|
|
66
|
+
{
|
|
67
|
+
credentials: "include",
|
|
68
|
+
}
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
if (campaignResponse.ok) {
|
|
72
|
+
const campaignData = await campaignResponse.json();
|
|
73
|
+
setCampaign(campaignData);
|
|
74
|
+
|
|
75
|
+
// Check if campaign detail exists in response
|
|
76
|
+
if (campaignData.campaign_detail) {
|
|
77
|
+
setCampaignDetail(campaignData.campaign_detail);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.error("Error fetching campaign:", err);
|
|
82
|
+
setError("Failed to load campaign information");
|
|
83
|
+
} finally {
|
|
84
|
+
setLoading(false);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const handleEditCampaign = () => {
|
|
89
|
+
if (campaign?.id) {
|
|
90
|
+
navigate(`/flash-sales/${campaign.id}`);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const handleViewCampaign = () => {
|
|
95
|
+
if (campaign?.id) {
|
|
96
|
+
// Navigate to campaign detail tab
|
|
97
|
+
navigate(`/flash-sales/${campaign.id}#details`);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Don't show widget if promotion is not part of a campaign
|
|
102
|
+
if (!promotion?.campaign_id) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (loading) {
|
|
107
|
+
return (
|
|
108
|
+
<Container className="divide-y px-0 pb-0 pt-0">
|
|
109
|
+
<div className="px-6 py-6">
|
|
110
|
+
<div className="flex items-center gap-3 mb-4">
|
|
111
|
+
<Sparkles className="text-ui-fg-subtle" />
|
|
112
|
+
<Heading level="h2">Campaign</Heading>
|
|
113
|
+
</div>
|
|
114
|
+
<Text className="text-ui-fg-subtle">Loading campaign...</Text>
|
|
115
|
+
</div>
|
|
116
|
+
</Container>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (error) {
|
|
121
|
+
return (
|
|
122
|
+
<Container className="divide-y px-0 pb-0 pt-0">
|
|
123
|
+
<div className="px-6 py-6">
|
|
124
|
+
<div className="flex items-center gap-3 mb-4">
|
|
125
|
+
<Sparkles className="text-ui-fg-subtle" />
|
|
126
|
+
<Heading level="h2">Campaign</Heading>
|
|
127
|
+
</div>
|
|
128
|
+
<Alert variant="error">{error}</Alert>
|
|
129
|
+
</div>
|
|
130
|
+
</Container>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!campaign) {
|
|
135
|
+
return (
|
|
136
|
+
<Container className="divide-y px-0 pb-0 pt-0">
|
|
137
|
+
<div className="px-6 py-6">
|
|
138
|
+
<div className="flex items-center gap-3 mb-4">
|
|
139
|
+
<Sparkles className="text-ui-fg-subtle" />
|
|
140
|
+
<Heading level="h2">Campaign</Heading>
|
|
141
|
+
</div>
|
|
142
|
+
<Text className="text-ui-fg-subtle">
|
|
143
|
+
This promotion is not part of a campaign
|
|
144
|
+
</Text>
|
|
145
|
+
</div>
|
|
146
|
+
</Container>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const hasCampaignDetail = !!campaignDetail;
|
|
151
|
+
const hasImages = campaignDetail?.image_url || campaignDetail?.thumbnail_url;
|
|
152
|
+
const hasContent = campaignDetail?.detail_content || campaignDetail?.terms_and_conditions;
|
|
153
|
+
const hasSEO = campaignDetail?.meta_title || campaignDetail?.meta_description;
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<Container className="divide-y px-0 pb-0 pt-0">
|
|
157
|
+
<div className="px-6 py-6">
|
|
158
|
+
<div className="flex items-center gap-3 mb-4">
|
|
159
|
+
<Sparkles className="text-ui-fg-subtle" />
|
|
160
|
+
<Heading level="h2">Campaign</Heading>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
{/* Campaign Info */}
|
|
164
|
+
<div className="mb-4 p-4 rounded-lg border bg-ui-bg-subtle">
|
|
165
|
+
<div className="flex items-start justify-between mb-3">
|
|
166
|
+
<div className="flex-1">
|
|
167
|
+
<Text className="font-semibold text-lg mb-1">{campaign.name}</Text>
|
|
168
|
+
{campaign.description && (
|
|
169
|
+
<Text className="text-sm text-ui-fg-subtle line-clamp-2">
|
|
170
|
+
{campaign.description}
|
|
171
|
+
</Text>
|
|
172
|
+
)}
|
|
173
|
+
</div>
|
|
174
|
+
<Badge color="purple" size="small">
|
|
175
|
+
<Sparkles className="mr-1" />
|
|
176
|
+
Campaign
|
|
177
|
+
</Badge>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{/* Campaign Detail Status */}
|
|
181
|
+
<div className="grid grid-cols-2 gap-2 mt-3 pt-3 border-t">
|
|
182
|
+
<div className="flex items-center gap-2">
|
|
183
|
+
<PhotoSolid className={`h-4 w-4 ${hasImages ? 'text-green-500' : 'text-ui-fg-muted'}`} />
|
|
184
|
+
<Text className="text-xs">
|
|
185
|
+
{hasImages ? 'Images added' : 'No images'}
|
|
186
|
+
</Text>
|
|
187
|
+
</div>
|
|
188
|
+
<div className="flex items-center gap-2">
|
|
189
|
+
<PencilSquare className={`h-4 w-4 ${hasContent ? 'text-green-500' : 'text-ui-fg-muted'}`} />
|
|
190
|
+
<Text className="text-xs">
|
|
191
|
+
{hasContent ? 'Content added' : 'No content'}
|
|
192
|
+
</Text>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
{/* Campaign Detail Preview */}
|
|
198
|
+
{hasCampaignDetail && (
|
|
199
|
+
<div className="space-y-3 mb-4">
|
|
200
|
+
{campaignDetail.thumbnail_url && (
|
|
201
|
+
<div>
|
|
202
|
+
<Text className="text-xs font-medium text-ui-fg-subtle mb-2">
|
|
203
|
+
Thumbnail
|
|
204
|
+
</Text>
|
|
205
|
+
<img
|
|
206
|
+
src={campaignDetail.thumbnail_url}
|
|
207
|
+
alt="Campaign thumbnail"
|
|
208
|
+
className="w-full h-32 object-cover rounded-lg border"
|
|
209
|
+
/>
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
{campaignDetail.detail_content && (
|
|
214
|
+
<div>
|
|
215
|
+
<Text className="text-xs font-medium text-ui-fg-subtle mb-1">
|
|
216
|
+
Content Preview
|
|
217
|
+
</Text>
|
|
218
|
+
<Text className="text-sm line-clamp-3 text-ui-fg-muted">
|
|
219
|
+
{campaignDetail.detail_content.substring(0, 150)}...
|
|
220
|
+
</Text>
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
223
|
+
|
|
224
|
+
{campaignDetail.link_url && (
|
|
225
|
+
<div className="flex items-center gap-2 text-xs">
|
|
226
|
+
<Badge size="xsmall" color="blue">CTA Link</Badge>
|
|
227
|
+
<Text className="text-ui-fg-subtle truncate">
|
|
228
|
+
{campaignDetail.link_text || campaignDetail.link_url}
|
|
229
|
+
</Text>
|
|
230
|
+
</div>
|
|
231
|
+
)}
|
|
232
|
+
|
|
233
|
+
{hasSEO && (
|
|
234
|
+
<div className="flex items-center gap-2 text-xs">
|
|
235
|
+
<Badge size="xsmall" color="green">SEO</Badge>
|
|
236
|
+
<Text className="text-ui-fg-subtle">
|
|
237
|
+
Meta fields configured
|
|
238
|
+
</Text>
|
|
239
|
+
</div>
|
|
240
|
+
)}
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
|
|
244
|
+
{/* No Campaign Detail Message */}
|
|
245
|
+
{!hasCampaignDetail && (
|
|
246
|
+
<Alert variant="info" className="mb-4">
|
|
247
|
+
<Text className="text-sm">
|
|
248
|
+
No campaign details added yet. Add images, content, and SEO to enhance this campaign.
|
|
249
|
+
</Text>
|
|
250
|
+
</Alert>
|
|
251
|
+
)}
|
|
252
|
+
|
|
253
|
+
{/* Actions */}
|
|
254
|
+
<div className="flex gap-2">
|
|
255
|
+
<Button
|
|
256
|
+
variant="secondary"
|
|
257
|
+
size="small"
|
|
258
|
+
onClick={handleEditCampaign}
|
|
259
|
+
>
|
|
260
|
+
<PencilSquare className="mr-1" />
|
|
261
|
+
Edit Campaign
|
|
262
|
+
</Button>
|
|
263
|
+
{hasCampaignDetail && (
|
|
264
|
+
<Button
|
|
265
|
+
variant="secondary"
|
|
266
|
+
size="small"
|
|
267
|
+
onClick={handleViewCampaign}
|
|
268
|
+
>
|
|
269
|
+
<Eye className="mr-1" />
|
|
270
|
+
View Details
|
|
271
|
+
</Button>
|
|
272
|
+
)}
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
{/* Quick Stats */}
|
|
276
|
+
{campaignDetail && (
|
|
277
|
+
<div className="mt-4 pt-4 border-t grid grid-cols-2 gap-3 text-xs">
|
|
278
|
+
<div>
|
|
279
|
+
<Text className="text-ui-fg-subtle">Display Order</Text>
|
|
280
|
+
<Text className="font-medium">{campaignDetail.display_order}</Text>
|
|
281
|
+
</div>
|
|
282
|
+
<div>
|
|
283
|
+
<Text className="text-ui-fg-subtle">Status</Text>
|
|
284
|
+
<Badge color={hasCampaignDetail ? "green" : "grey"} size="xsmall">
|
|
285
|
+
{hasCampaignDetail ? "Complete" : "Incomplete"}
|
|
286
|
+
</Badge>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
)}
|
|
290
|
+
</div>
|
|
291
|
+
</Container>
|
|
292
|
+
);
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
export const config = defineWidgetConfig({
|
|
296
|
+
zone: "promotion.details.side.after",
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
export default CampaignDetailWidget;
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { defineWidgetConfig } from "@medusajs/admin-sdk";
|
|
2
|
+
import { Container, Heading, Text, Badge } from "@medusajs/ui";
|
|
3
|
+
import {
|
|
4
|
+
Sparkles,
|
|
5
|
+
PhotoSolid,
|
|
6
|
+
CheckCircleSolid,
|
|
7
|
+
ClockSolid,
|
|
8
|
+
} from "@medusajs/icons";
|
|
9
|
+
import { useState, useEffect } from "react";
|
|
10
|
+
|
|
11
|
+
interface CampaignStats {
|
|
12
|
+
total: number;
|
|
13
|
+
active: number;
|
|
14
|
+
inactive: number;
|
|
15
|
+
with_images: number;
|
|
16
|
+
with_content: number;
|
|
17
|
+
complete: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const CampaignStatsWidget = () => {
|
|
21
|
+
const [stats, setStats] = useState<CampaignStats>({
|
|
22
|
+
total: 0,
|
|
23
|
+
active: 0,
|
|
24
|
+
inactive: 0,
|
|
25
|
+
with_images: 0,
|
|
26
|
+
with_content: 0,
|
|
27
|
+
complete: 0,
|
|
28
|
+
});
|
|
29
|
+
const [loading, setLoading] = useState(true);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
fetchStats();
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
const fetchStats = async () => {
|
|
36
|
+
setLoading(true);
|
|
37
|
+
try {
|
|
38
|
+
const response = await fetch("/admin/flash-sales?limit=100", {
|
|
39
|
+
credentials: "include",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (response.ok) {
|
|
43
|
+
const data = await response.json();
|
|
44
|
+
const campaigns = data.campaigns || [];
|
|
45
|
+
|
|
46
|
+
// Calculate stats
|
|
47
|
+
const now = new Date();
|
|
48
|
+
let active = 0;
|
|
49
|
+
let inactive = 0;
|
|
50
|
+
let withImages = 0;
|
|
51
|
+
let withContent = 0;
|
|
52
|
+
let complete = 0;
|
|
53
|
+
|
|
54
|
+
campaigns.forEach((campaign: any) => {
|
|
55
|
+
// Count active/inactive
|
|
56
|
+
if (campaign.ends_at && new Date(campaign.ends_at) > now) {
|
|
57
|
+
active++;
|
|
58
|
+
} else {
|
|
59
|
+
inactive++;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Count campaigns with details
|
|
63
|
+
if (campaign.campaign_detail) {
|
|
64
|
+
if (
|
|
65
|
+
campaign.campaign_detail.image_url ||
|
|
66
|
+
campaign.campaign_detail.thumbnail_url
|
|
67
|
+
) {
|
|
68
|
+
withImages++;
|
|
69
|
+
}
|
|
70
|
+
if (campaign.campaign_detail.detail_content) {
|
|
71
|
+
withContent++;
|
|
72
|
+
}
|
|
73
|
+
// Complete = has both images and content
|
|
74
|
+
if (
|
|
75
|
+
(campaign.campaign_detail.image_url ||
|
|
76
|
+
campaign.campaign_detail.thumbnail_url) &&
|
|
77
|
+
campaign.campaign_detail.detail_content
|
|
78
|
+
) {
|
|
79
|
+
complete++;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
setStats({
|
|
85
|
+
total: campaigns.length,
|
|
86
|
+
active,
|
|
87
|
+
inactive,
|
|
88
|
+
with_images: withImages,
|
|
89
|
+
with_content: withContent,
|
|
90
|
+
complete,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
} catch (err) {
|
|
94
|
+
console.error("Error fetching campaign stats:", err);
|
|
95
|
+
} finally {
|
|
96
|
+
setLoading(false);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
if (loading) {
|
|
101
|
+
return (
|
|
102
|
+
<Container className="px-6 py-6">
|
|
103
|
+
<div className="flex items-center gap-3 mb-4">
|
|
104
|
+
<Sparkles className="text-ui-fg-subtle" />
|
|
105
|
+
<Heading level="h2">Campaign Overview</Heading>
|
|
106
|
+
</div>
|
|
107
|
+
<Text className="text-ui-fg-subtle">Loading stats...</Text>
|
|
108
|
+
</Container>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const completionRate =
|
|
113
|
+
stats.total > 0 ? Math.round((stats.complete / stats.total) * 100) : 0;
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<Container className="px-6 py-6">
|
|
117
|
+
<div className="flex items-center gap-3 mb-6">
|
|
118
|
+
<Sparkles className="text-ui-fg-subtle" />
|
|
119
|
+
<Heading level="h2">Campaign Overview</Heading>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
{/* Main Stats Grid */}
|
|
123
|
+
<div className="grid grid-cols-3 gap-4 mb-6">
|
|
124
|
+
{/* Total Campaigns */}
|
|
125
|
+
<div className="p-4 rounded-lg border bg-ui-bg-subtle">
|
|
126
|
+
<div className="flex items-center justify-between mb-2">
|
|
127
|
+
<Text className="text-xs text-ui-fg-subtle">Total</Text>
|
|
128
|
+
<Sparkles className="h-4 w-4 text-ui-fg-subtle" />
|
|
129
|
+
</div>
|
|
130
|
+
<Text className="text-2xl font-bold">{stats.total}</Text>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{/* Active Campaigns */}
|
|
134
|
+
<div className="p-4 rounded-lg border bg-green-50">
|
|
135
|
+
<div className="flex items-center justify-between mb-2">
|
|
136
|
+
<Text className="text-xs text-green-700">Active</Text>
|
|
137
|
+
<CheckCircleSolid className="h-4 w-4 text-green-600" />
|
|
138
|
+
</div>
|
|
139
|
+
<Text className="text-2xl font-bold text-green-700">
|
|
140
|
+
{stats.active}
|
|
141
|
+
</Text>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
{/* Inactive Campaigns */}
|
|
145
|
+
<div className="p-4 rounded-lg border bg-gray-50">
|
|
146
|
+
<div className="flex items-center justify-between mb-2">
|
|
147
|
+
<Text className="text-xs text-gray-600">Inactive</Text>
|
|
148
|
+
<ClockSolid className="h-4 w-4 text-gray-500" />
|
|
149
|
+
</div>
|
|
150
|
+
<Text className="text-2xl font-bold text-gray-600">
|
|
151
|
+
{stats.inactive}
|
|
152
|
+
</Text>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
{/* Detail Stats */}
|
|
157
|
+
<div className="space-y-3 mb-6">
|
|
158
|
+
<div className="flex items-center justify-between p-3 rounded-lg bg-ui-bg-subtle">
|
|
159
|
+
<div className="flex items-center gap-2">
|
|
160
|
+
<PhotoSolid className="h-4 w-4 text-blue-600" />
|
|
161
|
+
<Text className="text-sm">With Images</Text>
|
|
162
|
+
</div>
|
|
163
|
+
<div className="flex items-center gap-2">
|
|
164
|
+
<Text className="text-sm font-medium">{stats.with_images}</Text>
|
|
165
|
+
<Badge size="xsmall" color="blue">
|
|
166
|
+
{stats.total > 0
|
|
167
|
+
? Math.round((stats.with_images / stats.total) * 100)
|
|
168
|
+
: 0}
|
|
169
|
+
%
|
|
170
|
+
</Badge>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
<div className="flex items-center justify-between p-3 rounded-lg bg-ui-bg-subtle">
|
|
175
|
+
<div className="flex items-center gap-2">
|
|
176
|
+
<CheckCircleSolid className="h-4 w-4 text-green-600" />
|
|
177
|
+
<Text className="text-sm">With Content</Text>
|
|
178
|
+
</div>
|
|
179
|
+
<div className="flex items-center gap-2">
|
|
180
|
+
<Text className="text-sm font-medium">{stats.with_content}</Text>
|
|
181
|
+
<Badge size="xsmall" color="green">
|
|
182
|
+
{stats.total > 0
|
|
183
|
+
? Math.round((stats.with_content / stats.total) * 100)
|
|
184
|
+
: 0}
|
|
185
|
+
%
|
|
186
|
+
</Badge>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<div className="flex items-center justify-between p-3 rounded-lg bg-ui-bg-subtle">
|
|
191
|
+
<div className="flex items-center gap-2">
|
|
192
|
+
<Sparkles className="h-4 w-4 text-purple-600" />
|
|
193
|
+
<Text className="text-sm">Complete Details</Text>
|
|
194
|
+
</div>
|
|
195
|
+
<div className="flex items-center gap-2">
|
|
196
|
+
<Text className="text-sm font-medium">{stats.complete}</Text>
|
|
197
|
+
<Badge size="xsmall" color="purple">
|
|
198
|
+
{completionRate}%
|
|
199
|
+
</Badge>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
{/* Completion Progress Bar */}
|
|
205
|
+
<div className="pt-4 border-t">
|
|
206
|
+
<div className="flex items-center justify-between mb-2">
|
|
207
|
+
<Text className="text-xs text-ui-fg-subtle">Campaign Completion</Text>
|
|
208
|
+
<Text className="text-xs font-medium">{completionRate}%</Text>
|
|
209
|
+
</div>
|
|
210
|
+
<div className="h-2 bg-ui-bg-subtle rounded-full overflow-hidden">
|
|
211
|
+
<div
|
|
212
|
+
className="h-full bg-gradient-to-r from-purple-500 to-blue-500 transition-all duration-300"
|
|
213
|
+
style={{ width: `${completionRate}%` }}
|
|
214
|
+
/>
|
|
215
|
+
</div>
|
|
216
|
+
<Text className="text-xs text-ui-fg-muted mt-2">
|
|
217
|
+
{stats.complete} of {stats.total} campaigns have complete details
|
|
218
|
+
</Text>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
{/* Quick Tip */}
|
|
222
|
+
{completionRate < 50 && stats.total > 0 && (
|
|
223
|
+
<div className="mt-4 p-3 rounded-lg bg-blue-50 border border-blue-200">
|
|
224
|
+
<Text className="text-xs text-blue-800">
|
|
225
|
+
💡 Tip: Add images and content to campaigns to improve customer
|
|
226
|
+
engagement!
|
|
227
|
+
</Text>
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
</Container>
|
|
231
|
+
);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
export const config = defineWidgetConfig({
|
|
235
|
+
zone: "campaign.list.before",
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
export default CampaignStatsWidget;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http";
|
|
2
|
+
import { ContainerRegistrationKeys } from "@medusajs/framework/utils";
|
|
3
|
+
import z from "zod";
|
|
4
|
+
import { updateCampaignDetailWorkflow } from "../../../../../workflows/campaign-detail/update-campaign-detail";
|
|
5
|
+
import { CUSTOM_CAMPAIGN_MODULE } from "../../../../../modules/custom-campaigns";
|
|
6
|
+
import CustomCampaignModuleService from "../../../../../modules/custom-campaigns/service";
|
|
7
|
+
|
|
8
|
+
export const updateCampaignDetailSchema = z.object({
|
|
9
|
+
detail_content: z.string().optional(),
|
|
10
|
+
terms_and_conditions: z.string().optional(),
|
|
11
|
+
meta_title: z.string().optional(),
|
|
12
|
+
meta_description: z.string().optional(),
|
|
13
|
+
meta_keywords: z.string().optional(),
|
|
14
|
+
link_url: z.string().url().optional().or(z.literal("")),
|
|
15
|
+
link_text: z.string().optional(),
|
|
16
|
+
display_order: z.number().optional(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export type UpdateCampaignDetailInput = z.infer<
|
|
20
|
+
typeof updateCampaignDetailSchema
|
|
21
|
+
>;
|
|
22
|
+
|
|
23
|
+
// GET campaign detail
|
|
24
|
+
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
|
|
25
|
+
const { id: campaign_id } = req.params;
|
|
26
|
+
const customCampaignModuleService =
|
|
27
|
+
req.scope.resolve<CustomCampaignModuleService>(CUSTOM_CAMPAIGN_MODULE);
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const [campaignDetail] =
|
|
31
|
+
await customCampaignModuleService.listCampaignDetails({
|
|
32
|
+
campaign_id,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (!campaignDetail) {
|
|
36
|
+
return res.status(404).json({
|
|
37
|
+
error: "Campaign detail not found",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
res.status(200).json({ campaign_detail: campaignDetail });
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error("Error fetching campaign detail:", error);
|
|
44
|
+
res.status(500).json({
|
|
45
|
+
error: "Failed to fetch campaign detail",
|
|
46
|
+
details: error instanceof Error ? error.message : String(error),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// PUT/POST update or create campaign detail
|
|
52
|
+
export const POST = async (
|
|
53
|
+
req: MedusaRequest<UpdateCampaignDetailInput>,
|
|
54
|
+
res: MedusaResponse,
|
|
55
|
+
) => {
|
|
56
|
+
const { id: campaign_id } = req.params;
|
|
57
|
+
const body = updateCampaignDetailSchema.parse(req.body);
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const { result } = await updateCampaignDetailWorkflow(req.scope).run({
|
|
61
|
+
input: {
|
|
62
|
+
campaign_id,
|
|
63
|
+
...body,
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
res.status(200).json({ campaign_detail: result });
|
|
68
|
+
} catch (error) {
|
|
69
|
+
console.error("Error updating campaign detail:", error);
|
|
70
|
+
res.status(500).json({
|
|
71
|
+
error: "Failed to update campaign detail",
|
|
72
|
+
details: error instanceof Error ? error.message : String(error),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const PUT = POST;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http";
|
|
2
|
+
import { uploadCampaignImagesWorkflow } from "../../../../../workflows/campaign-detail/upload-campaign-images";
|
|
3
|
+
import { deleteFilesWorkflow } from "@medusajs/medusa/core-flows";
|
|
4
|
+
import { CUSTOM_CAMPAIGN_MODULE } from "../../../../../modules/custom-campaigns";
|
|
5
|
+
import CustomCampaignModuleService from "../../../../../modules/custom-campaigns/service";
|
|
6
|
+
|
|
7
|
+
// POST upload campaign image
|
|
8
|
+
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
|
|
9
|
+
const { id: campaign_id } = req.params;
|
|
10
|
+
const file = (req as any).file;
|
|
11
|
+
|
|
12
|
+
if (!file) {
|
|
13
|
+
return res.status(400).json({
|
|
14
|
+
error: "No file provided",
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const { result } = await uploadCampaignImagesWorkflow(req.scope).run({
|
|
20
|
+
input: {
|
|
21
|
+
campaign_id,
|
|
22
|
+
files: [
|
|
23
|
+
{
|
|
24
|
+
filename: Buffer.from(file.originalname, "latin1").toString("utf8"),
|
|
25
|
+
mimeType: file.mimetype,
|
|
26
|
+
content: file.buffer.toString("binary"),
|
|
27
|
+
access: "public" as const,
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
image_type: "image",
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
res.status(200).json({ campaign_detail: result });
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.error("Error uploading campaign image:", error);
|
|
37
|
+
res.status(500).json({
|
|
38
|
+
error: "Failed to upload campaign image",
|
|
39
|
+
details: error instanceof Error ? error.message : String(error),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// DELETE campaign image
|
|
45
|
+
export const DELETE = async (req: MedusaRequest, res: MedusaResponse) => {
|
|
46
|
+
const { id: campaign_id } = req.params;
|
|
47
|
+
const customCampaignModuleService =
|
|
48
|
+
req.scope.resolve<CustomCampaignModuleService>(CUSTOM_CAMPAIGN_MODULE);
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const [campaignDetail] =
|
|
52
|
+
await customCampaignModuleService.listCampaignDetails({
|
|
53
|
+
campaign_id,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (!campaignDetail || !campaignDetail.image_file_id) {
|
|
57
|
+
return res.status(404).json({
|
|
58
|
+
error: "Campaign image not found",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Delete file
|
|
63
|
+
await deleteFilesWorkflow(req.scope).run({
|
|
64
|
+
input: {
|
|
65
|
+
ids: [campaignDetail.image_file_id],
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Update campaign detail
|
|
70
|
+
const [updatedDetail] =
|
|
71
|
+
await customCampaignModuleService.updateCampaignDetails([
|
|
72
|
+
{
|
|
73
|
+
id: campaignDetail.id,
|
|
74
|
+
image_url: null,
|
|
75
|
+
image_file_id: null,
|
|
76
|
+
},
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
res.status(200).json({ campaign_detail: updatedDetail });
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.error("Error deleting campaign image:", error);
|
|
82
|
+
res.status(500).json({
|
|
83
|
+
error: "Failed to delete campaign image",
|
|
84
|
+
details: error instanceof Error ? error.message : String(error),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
};
|