@lodashventure/medusa-campaign 1.3.12 → 1.4.0
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 +1238 -16
- package/.medusa/server/src/admin/index.mjs +1240 -18
- 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/package.json +7 -5
- 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/.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,313 @@
|
|
|
1
|
+
import { useState, useCallback } from "react";
|
|
2
|
+
import {
|
|
3
|
+
PhotoSolid,
|
|
4
|
+
Trash,
|
|
5
|
+
CloudArrowUp,
|
|
6
|
+
X,
|
|
7
|
+
} from "@medusajs/icons";
|
|
8
|
+
import { Button, Heading, Text, Alert, clx } from "@medusajs/ui";
|
|
9
|
+
|
|
10
|
+
interface CampaignImageUploaderProps {
|
|
11
|
+
campaignId: string;
|
|
12
|
+
imageType: "image" | "thumbnail";
|
|
13
|
+
currentImageUrl?: string | null;
|
|
14
|
+
onClose: () => void;
|
|
15
|
+
onSuccess?: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const CampaignImageUploader = ({
|
|
19
|
+
campaignId,
|
|
20
|
+
imageType,
|
|
21
|
+
currentImageUrl,
|
|
22
|
+
onClose,
|
|
23
|
+
onSuccess,
|
|
24
|
+
}: CampaignImageUploaderProps) => {
|
|
25
|
+
const [displayImage, setDisplayImage] = useState(currentImageUrl || null);
|
|
26
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
27
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
28
|
+
const [error, setError] = useState<string | null>(null);
|
|
29
|
+
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
|
30
|
+
|
|
31
|
+
const isThumbnail = imageType === "thumbnail";
|
|
32
|
+
const maxSize = 5; // 5MB for images
|
|
33
|
+
const allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
|
34
|
+
|
|
35
|
+
const formatFileTypes = () => {
|
|
36
|
+
return "JPEG, PNG, GIF, or WebP";
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const validateFile = (file: File): boolean => {
|
|
40
|
+
if (!allowedTypes.includes(file.type)) {
|
|
41
|
+
setError(`Please upload a valid image file (${formatFileTypes()})`);
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
if (file.size > maxSize * 1024 * 1024) {
|
|
45
|
+
setError(`File size must be less than ${maxSize}MB`);
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const handleFileUpload = async (file: File) => {
|
|
52
|
+
if (!validateFile(file)) return;
|
|
53
|
+
|
|
54
|
+
setError(null);
|
|
55
|
+
|
|
56
|
+
// Show preview immediately
|
|
57
|
+
const reader = new FileReader();
|
|
58
|
+
reader.onloadend = () => {
|
|
59
|
+
setPreviewUrl(reader.result as string);
|
|
60
|
+
};
|
|
61
|
+
reader.readAsDataURL(file);
|
|
62
|
+
|
|
63
|
+
// Upload to server
|
|
64
|
+
setIsUploading(true);
|
|
65
|
+
const formData = new FormData();
|
|
66
|
+
formData.append("file", file);
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const response = await fetch(
|
|
70
|
+
`/admin/campaigns/${campaignId}/${imageType}`,
|
|
71
|
+
{
|
|
72
|
+
method: "POST",
|
|
73
|
+
body: formData,
|
|
74
|
+
credentials: "include",
|
|
75
|
+
}
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
if (response.ok) {
|
|
79
|
+
const result = await response.json();
|
|
80
|
+
const newImageUrl =
|
|
81
|
+
imageType === "image"
|
|
82
|
+
? result.campaign_detail?.image_url
|
|
83
|
+
: result.campaign_detail?.thumbnail_url;
|
|
84
|
+
setDisplayImage(newImageUrl);
|
|
85
|
+
setPreviewUrl(null);
|
|
86
|
+
|
|
87
|
+
// Call onSuccess callback if provided
|
|
88
|
+
onSuccess?.();
|
|
89
|
+
|
|
90
|
+
// Show success for a moment before closing
|
|
91
|
+
setTimeout(() => {
|
|
92
|
+
onClose();
|
|
93
|
+
}, 1000);
|
|
94
|
+
} else {
|
|
95
|
+
const errorData = await response.json();
|
|
96
|
+
setError(errorData.error || `Failed to upload ${imageType}`);
|
|
97
|
+
setPreviewUrl(null);
|
|
98
|
+
}
|
|
99
|
+
} catch (err) {
|
|
100
|
+
setError(`Error uploading ${imageType}`);
|
|
101
|
+
setPreviewUrl(null);
|
|
102
|
+
console.error(`Error uploading ${imageType}:`, err);
|
|
103
|
+
} finally {
|
|
104
|
+
setIsUploading(false);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const handleDelete = async () => {
|
|
109
|
+
if (!confirm(`Are you sure you want to delete the ${imageType}?`)) return;
|
|
110
|
+
|
|
111
|
+
setIsUploading(true);
|
|
112
|
+
setError(null);
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const response = await fetch(
|
|
116
|
+
`/admin/campaigns/${campaignId}/${imageType}`,
|
|
117
|
+
{
|
|
118
|
+
method: "DELETE",
|
|
119
|
+
credentials: "include",
|
|
120
|
+
}
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
if (response.ok) {
|
|
124
|
+
setDisplayImage(null);
|
|
125
|
+
setPreviewUrl(null);
|
|
126
|
+
|
|
127
|
+
// Call onSuccess callback if provided
|
|
128
|
+
onSuccess?.();
|
|
129
|
+
|
|
130
|
+
setTimeout(() => {
|
|
131
|
+
onClose();
|
|
132
|
+
}, 500);
|
|
133
|
+
} else {
|
|
134
|
+
const errorData = await response.json();
|
|
135
|
+
setError(errorData.error || `Failed to delete ${imageType}`);
|
|
136
|
+
}
|
|
137
|
+
} catch (err) {
|
|
138
|
+
setError(`Error deleting ${imageType}`);
|
|
139
|
+
console.error(`Error deleting ${imageType}:`, err);
|
|
140
|
+
} finally {
|
|
141
|
+
setIsUploading(false);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
|
146
|
+
e.preventDefault();
|
|
147
|
+
e.stopPropagation();
|
|
148
|
+
setIsDragging(true);
|
|
149
|
+
}, []);
|
|
150
|
+
|
|
151
|
+
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
e.stopPropagation();
|
|
154
|
+
setIsDragging(false);
|
|
155
|
+
}, []);
|
|
156
|
+
|
|
157
|
+
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
158
|
+
e.preventDefault();
|
|
159
|
+
e.stopPropagation();
|
|
160
|
+
}, []);
|
|
161
|
+
|
|
162
|
+
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
163
|
+
e.preventDefault();
|
|
164
|
+
e.stopPropagation();
|
|
165
|
+
setIsDragging(false);
|
|
166
|
+
|
|
167
|
+
const file = e.dataTransfer.files?.[0];
|
|
168
|
+
if (file) {
|
|
169
|
+
handleFileUpload(file);
|
|
170
|
+
}
|
|
171
|
+
}, []);
|
|
172
|
+
|
|
173
|
+
const imageToDisplay = previewUrl || displayImage;
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
177
|
+
<div className="w-full max-w-2xl rounded-lg bg-ui-bg-base p-6">
|
|
178
|
+
<div className="mb-6 flex items-center justify-between">
|
|
179
|
+
<div>
|
|
180
|
+
<Heading level="h2">
|
|
181
|
+
Upload Campaign {isThumbnail ? "Thumbnail" : "Image"}
|
|
182
|
+
</Heading>
|
|
183
|
+
<Text className="text-ui-fg-subtle">
|
|
184
|
+
{isThumbnail
|
|
185
|
+
? "Thumbnail for campaign listing"
|
|
186
|
+
: "Main campaign image"}
|
|
187
|
+
</Text>
|
|
188
|
+
</div>
|
|
189
|
+
<Button variant="secondary" size="small" onClick={onClose}>
|
|
190
|
+
<X />
|
|
191
|
+
</Button>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
{error && (
|
|
195
|
+
<Alert variant="error" dismissible className="mb-4">
|
|
196
|
+
{error}
|
|
197
|
+
</Alert>
|
|
198
|
+
)}
|
|
199
|
+
|
|
200
|
+
<div className="space-y-4">
|
|
201
|
+
{imageToDisplay ? (
|
|
202
|
+
<div className="space-y-4">
|
|
203
|
+
<div className="relative overflow-hidden rounded-lg border border-ui-border-base bg-ui-bg-subtle">
|
|
204
|
+
<img
|
|
205
|
+
src={imageToDisplay}
|
|
206
|
+
alt={`Campaign ${imageType}`}
|
|
207
|
+
className={clx(
|
|
208
|
+
"w-full object-contain",
|
|
209
|
+
isThumbnail ? "h-32" : "h-64"
|
|
210
|
+
)}
|
|
211
|
+
/>
|
|
212
|
+
{isUploading && (
|
|
213
|
+
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
|
214
|
+
<div className="text-white">Uploading...</div>
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
</div>
|
|
218
|
+
<div className="flex gap-2">
|
|
219
|
+
<Button
|
|
220
|
+
variant="secondary"
|
|
221
|
+
disabled={isUploading}
|
|
222
|
+
onClick={() =>
|
|
223
|
+
document.getElementById(`${imageType}-file-input`)?.click()
|
|
224
|
+
}
|
|
225
|
+
>
|
|
226
|
+
<CloudArrowUp className="mr-2" />
|
|
227
|
+
Replace
|
|
228
|
+
</Button>
|
|
229
|
+
<Button
|
|
230
|
+
variant="danger"
|
|
231
|
+
disabled={isUploading}
|
|
232
|
+
onClick={handleDelete}
|
|
233
|
+
>
|
|
234
|
+
<Trash className="mr-2" />
|
|
235
|
+
Delete
|
|
236
|
+
</Button>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
) : (
|
|
240
|
+
<div
|
|
241
|
+
className={clx(
|
|
242
|
+
"flex flex-col items-center justify-center rounded-lg border-2 border-dashed transition-colors",
|
|
243
|
+
isThumbnail ? "h-48" : "h-64",
|
|
244
|
+
isDragging
|
|
245
|
+
? "border-ui-border-interactive bg-ui-bg-highlight"
|
|
246
|
+
: "border-ui-border-base bg-ui-bg-subtle",
|
|
247
|
+
!isUploading && "cursor-pointer"
|
|
248
|
+
)}
|
|
249
|
+
onDragEnter={handleDragEnter}
|
|
250
|
+
onDragLeave={handleDragLeave}
|
|
251
|
+
onDragOver={handleDragOver}
|
|
252
|
+
onDrop={handleDrop}
|
|
253
|
+
onClick={() =>
|
|
254
|
+
!isUploading &&
|
|
255
|
+
document.getElementById(`${imageType}-file-input`)?.click()
|
|
256
|
+
}
|
|
257
|
+
>
|
|
258
|
+
<PhotoSolid className="mb-4 h-12 w-12 text-ui-fg-subtle" />
|
|
259
|
+
<Text className="mb-2 text-lg font-medium">
|
|
260
|
+
{isDragging
|
|
261
|
+
? `Drop ${imageType} here`
|
|
262
|
+
: `Upload ${isThumbnail ? "Thumbnail" : "Image"}`}
|
|
263
|
+
</Text>
|
|
264
|
+
<Text className="mb-4 text-center text-ui-fg-subtle">
|
|
265
|
+
Drag and drop an image here, or click to select
|
|
266
|
+
</Text>
|
|
267
|
+
<Text className="text-sm text-ui-fg-subtle">
|
|
268
|
+
{formatFileTypes()} • Max {maxSize}MB
|
|
269
|
+
</Text>
|
|
270
|
+
{isThumbnail && (
|
|
271
|
+
<Text className="mt-2 text-xs text-ui-fg-subtle">
|
|
272
|
+
Recommended: 16:9 aspect ratio, minimum 400x225px
|
|
273
|
+
</Text>
|
|
274
|
+
)}
|
|
275
|
+
{!isThumbnail && (
|
|
276
|
+
<Text className="mt-2 text-xs text-ui-fg-subtle">
|
|
277
|
+
Recommended: High resolution, minimum 1200x600px
|
|
278
|
+
</Text>
|
|
279
|
+
)}
|
|
280
|
+
{isUploading && (
|
|
281
|
+
<div className="mt-4">
|
|
282
|
+
<Text>Uploading...</Text>
|
|
283
|
+
</div>
|
|
284
|
+
)}
|
|
285
|
+
</div>
|
|
286
|
+
)}
|
|
287
|
+
<input
|
|
288
|
+
id={`${imageType}-file-input`}
|
|
289
|
+
type="file"
|
|
290
|
+
accept={allowedTypes.join(",")}
|
|
291
|
+
onChange={(e) => {
|
|
292
|
+
const file = e.target.files?.[0];
|
|
293
|
+
if (file) handleFileUpload(file);
|
|
294
|
+
e.target.value = ""; // Reset input
|
|
295
|
+
}}
|
|
296
|
+
className="hidden"
|
|
297
|
+
/>
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
<div className="mt-6 flex items-center justify-between border-t pt-4">
|
|
301
|
+
<Text className="text-sm text-ui-fg-subtle">
|
|
302
|
+
{isThumbnail
|
|
303
|
+
? "Thumbnail will be displayed in campaign listings"
|
|
304
|
+
: "Main image for the campaign detail page"}
|
|
305
|
+
</Text>
|
|
306
|
+
<Button variant="secondary" onClick={onClose}>
|
|
307
|
+
Close
|
|
308
|
+
</Button>
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
);
|
|
313
|
+
};
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Label, Textarea, Button, Tabs, clx } from "@medusajs/ui";
|
|
3
|
+
import { CommandLine, InformationCircle } from "@medusajs/icons";
|
|
4
|
+
|
|
5
|
+
interface MarkdownEditorProps {
|
|
6
|
+
label: string;
|
|
7
|
+
value: string;
|
|
8
|
+
onChange: (value: string) => void;
|
|
9
|
+
placeholder?: string;
|
|
10
|
+
helpText?: string;
|
|
11
|
+
rows?: number;
|
|
12
|
+
showPreview?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const MarkdownEditor = ({
|
|
16
|
+
label,
|
|
17
|
+
value,
|
|
18
|
+
onChange,
|
|
19
|
+
placeholder,
|
|
20
|
+
helpText,
|
|
21
|
+
rows = 10,
|
|
22
|
+
showPreview = true,
|
|
23
|
+
}: MarkdownEditorProps) => {
|
|
24
|
+
const [activeTab, setActiveTab] = useState<"write" | "preview">("write");
|
|
25
|
+
|
|
26
|
+
const insertMarkdown = (before: string, after: string = "") => {
|
|
27
|
+
const textarea = document.getElementById(
|
|
28
|
+
`markdown-${label}`,
|
|
29
|
+
) as HTMLTextAreaElement;
|
|
30
|
+
if (!textarea) return;
|
|
31
|
+
|
|
32
|
+
const start = textarea.selectionStart;
|
|
33
|
+
const end = textarea.selectionEnd;
|
|
34
|
+
const selectedText = value.substring(start, end);
|
|
35
|
+
const newText =
|
|
36
|
+
value.substring(0, start) +
|
|
37
|
+
before +
|
|
38
|
+
selectedText +
|
|
39
|
+
after +
|
|
40
|
+
value.substring(end);
|
|
41
|
+
|
|
42
|
+
onChange(newText);
|
|
43
|
+
|
|
44
|
+
// Set cursor position after inserted text
|
|
45
|
+
setTimeout(() => {
|
|
46
|
+
textarea.focus();
|
|
47
|
+
textarea.setSelectionRange(
|
|
48
|
+
start + before.length,
|
|
49
|
+
start + before.length + selectedText.length,
|
|
50
|
+
);
|
|
51
|
+
}, 0);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const renderMarkdownPreview = (markdown: string) => {
|
|
55
|
+
// Simple markdown to HTML conversion for preview
|
|
56
|
+
let html = markdown;
|
|
57
|
+
|
|
58
|
+
// Headers
|
|
59
|
+
html = html.replace(/^### (.*$)/gim, "<h3>$1</h3>");
|
|
60
|
+
html = html.replace(/^## (.*$)/gim, "<h2>$1</h2>");
|
|
61
|
+
html = html.replace(/^# (.*$)/gim, "<h1>$1</h1>");
|
|
62
|
+
|
|
63
|
+
// Bold
|
|
64
|
+
html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
|
65
|
+
html = html.replace(/__(.+?)__/g, "<strong>$1</strong>");
|
|
66
|
+
|
|
67
|
+
// Italic
|
|
68
|
+
html = html.replace(/\*(.+?)\*/g, "<em>$1</em>");
|
|
69
|
+
html = html.replace(/_(.+?)_/g, "<em>$1</em>");
|
|
70
|
+
|
|
71
|
+
// Code blocks
|
|
72
|
+
html = html.replace(
|
|
73
|
+
/```(\w+)?\n([\s\S]+?)```/g,
|
|
74
|
+
'<pre class="bg-ui-bg-subtle p-4 rounded-lg overflow-x-auto"><code class="language-$1">$2</code></pre>',
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Inline code
|
|
78
|
+
html = html.replace(
|
|
79
|
+
/`([^`]+)`/g,
|
|
80
|
+
'<code class="bg-ui-bg-subtle px-1 py-0.5 rounded text-sm">$1</code>',
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Links
|
|
84
|
+
html = html.replace(
|
|
85
|
+
/\[([^\]]+)\]\(([^)]+)\)/g,
|
|
86
|
+
'<a href="$2" class="text-ui-fg-interactive underline">$1</a>',
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// Images
|
|
90
|
+
html = html.replace(
|
|
91
|
+
/!\[([^\]]*)\]\(([^)]+)\)/g,
|
|
92
|
+
'<img src="$2" alt="$1" class="max-w-full h-auto rounded-lg my-2" />',
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
// Lists
|
|
96
|
+
html = html.replace(/^\* (.+)$/gim, "<li>$1</li>");
|
|
97
|
+
html = html.replace(/^\- (.+)$/gim, "<li>$1</li>");
|
|
98
|
+
html = html.replace(
|
|
99
|
+
/(<li>.*<\/li>)/s,
|
|
100
|
+
"<ul class='list-disc ml-6'>$1</ul>",
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// Paragraphs
|
|
104
|
+
html = html.replace(/\n\n/g, "</p><p>");
|
|
105
|
+
html = `<p>${html}</p>`;
|
|
106
|
+
|
|
107
|
+
return html;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className="space-y-2">
|
|
112
|
+
<div className="flex items-center justify-between">
|
|
113
|
+
<Label htmlFor={`markdown-${label}`} className="font-medium">
|
|
114
|
+
{label}
|
|
115
|
+
</Label>
|
|
116
|
+
{helpText && (
|
|
117
|
+
<div className="flex items-center gap-1 text-xs text-ui-fg-subtle">
|
|
118
|
+
<InformationCircle className="h-4 w-4" />
|
|
119
|
+
<span>{helpText}</span>
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
{showPreview ? (
|
|
125
|
+
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as any)}>
|
|
126
|
+
<Tabs.List>
|
|
127
|
+
<Tabs.Trigger value="write">Write</Tabs.Trigger>
|
|
128
|
+
<Tabs.Trigger value="preview">Preview</Tabs.Trigger>
|
|
129
|
+
</Tabs.List>
|
|
130
|
+
|
|
131
|
+
<Tabs.Content value="write" className="mt-4">
|
|
132
|
+
<div className="space-y-2">
|
|
133
|
+
<div className="flex flex-wrap gap-2 border-b pb-2">
|
|
134
|
+
<Button
|
|
135
|
+
type="button"
|
|
136
|
+
variant="secondary"
|
|
137
|
+
size="small"
|
|
138
|
+
onClick={() => insertMarkdown("**", "**")}
|
|
139
|
+
title="Bold"
|
|
140
|
+
>
|
|
141
|
+
<strong>B</strong>
|
|
142
|
+
</Button>
|
|
143
|
+
<Button
|
|
144
|
+
type="button"
|
|
145
|
+
variant="secondary"
|
|
146
|
+
size="small"
|
|
147
|
+
onClick={() => insertMarkdown("*", "*")}
|
|
148
|
+
title="Italic"
|
|
149
|
+
>
|
|
150
|
+
<em>I</em>
|
|
151
|
+
</Button>
|
|
152
|
+
<Button
|
|
153
|
+
type="button"
|
|
154
|
+
variant="secondary"
|
|
155
|
+
size="small"
|
|
156
|
+
onClick={() => insertMarkdown("`", "`")}
|
|
157
|
+
title="Inline Code"
|
|
158
|
+
>
|
|
159
|
+
<CommandLine className="h-4 w-4" />
|
|
160
|
+
</Button>
|
|
161
|
+
<Button
|
|
162
|
+
type="button"
|
|
163
|
+
variant="secondary"
|
|
164
|
+
size="small"
|
|
165
|
+
onClick={() => insertMarkdown("## ")}
|
|
166
|
+
title="Heading"
|
|
167
|
+
>
|
|
168
|
+
H2
|
|
169
|
+
</Button>
|
|
170
|
+
<Button
|
|
171
|
+
type="button"
|
|
172
|
+
variant="secondary"
|
|
173
|
+
size="small"
|
|
174
|
+
onClick={() => insertMarkdown("### ")}
|
|
175
|
+
title="Heading"
|
|
176
|
+
>
|
|
177
|
+
H3
|
|
178
|
+
</Button>
|
|
179
|
+
<Button
|
|
180
|
+
type="button"
|
|
181
|
+
variant="secondary"
|
|
182
|
+
size="small"
|
|
183
|
+
onClick={() => insertMarkdown("[", "](url)")}
|
|
184
|
+
title="Link"
|
|
185
|
+
>
|
|
186
|
+
Link
|
|
187
|
+
</Button>
|
|
188
|
+
<Button
|
|
189
|
+
type="button"
|
|
190
|
+
variant="secondary"
|
|
191
|
+
size="small"
|
|
192
|
+
onClick={() => insertMarkdown("")}
|
|
193
|
+
title="Image"
|
|
194
|
+
>
|
|
195
|
+
Image
|
|
196
|
+
</Button>
|
|
197
|
+
<Button
|
|
198
|
+
type="button"
|
|
199
|
+
variant="secondary"
|
|
200
|
+
size="small"
|
|
201
|
+
onClick={() => insertMarkdown("```\n", "\n```")}
|
|
202
|
+
title="Code Block"
|
|
203
|
+
>
|
|
204
|
+
Code
|
|
205
|
+
</Button>
|
|
206
|
+
<Button
|
|
207
|
+
type="button"
|
|
208
|
+
variant="secondary"
|
|
209
|
+
size="small"
|
|
210
|
+
onClick={() => insertMarkdown("- ")}
|
|
211
|
+
title="List"
|
|
212
|
+
>
|
|
213
|
+
List
|
|
214
|
+
</Button>
|
|
215
|
+
</div>
|
|
216
|
+
<Textarea
|
|
217
|
+
id={`markdown-${label}`}
|
|
218
|
+
value={value}
|
|
219
|
+
onChange={(e) => onChange(e.target.value)}
|
|
220
|
+
placeholder={placeholder}
|
|
221
|
+
rows={rows}
|
|
222
|
+
className="font-mono text-sm"
|
|
223
|
+
/>
|
|
224
|
+
</div>
|
|
225
|
+
</Tabs.Content>
|
|
226
|
+
|
|
227
|
+
<Tabs.Content value="preview" className="mt-4">
|
|
228
|
+
<div
|
|
229
|
+
className={clx(
|
|
230
|
+
"min-h-[200px] rounded-lg border border-ui-border-base bg-ui-bg-subtle p-4",
|
|
231
|
+
"prose prose-sm max-w-none",
|
|
232
|
+
)}
|
|
233
|
+
dangerouslySetInnerHTML={{
|
|
234
|
+
__html: value
|
|
235
|
+
? renderMarkdownPreview(value)
|
|
236
|
+
: '<p class="text-ui-fg-subtle">Nothing to preview</p>',
|
|
237
|
+
}}
|
|
238
|
+
/>
|
|
239
|
+
</Tabs.Content>
|
|
240
|
+
</Tabs>
|
|
241
|
+
) : (
|
|
242
|
+
<div className="space-y-2">
|
|
243
|
+
<div className="flex flex-wrap gap-2 border-b pb-2">
|
|
244
|
+
<Button
|
|
245
|
+
type="button"
|
|
246
|
+
variant="secondary"
|
|
247
|
+
size="small"
|
|
248
|
+
onClick={() => insertMarkdown("**", "**")}
|
|
249
|
+
title="Bold"
|
|
250
|
+
>
|
|
251
|
+
<strong>B</strong>
|
|
252
|
+
</Button>
|
|
253
|
+
<Button
|
|
254
|
+
type="button"
|
|
255
|
+
variant="secondary"
|
|
256
|
+
size="small"
|
|
257
|
+
onClick={() => insertMarkdown("*", "*")}
|
|
258
|
+
title="Italic"
|
|
259
|
+
>
|
|
260
|
+
<em>I</em>
|
|
261
|
+
</Button>
|
|
262
|
+
<Button
|
|
263
|
+
type="button"
|
|
264
|
+
variant="secondary"
|
|
265
|
+
size="small"
|
|
266
|
+
onClick={() => insertMarkdown("`", "`")}
|
|
267
|
+
title="Inline Code"
|
|
268
|
+
>
|
|
269
|
+
<CommandLine className="h-4 w-4" />
|
|
270
|
+
</Button>
|
|
271
|
+
<Button
|
|
272
|
+
type="button"
|
|
273
|
+
variant="secondary"
|
|
274
|
+
size="small"
|
|
275
|
+
onClick={() => insertMarkdown("```\n", "\n```")}
|
|
276
|
+
title="Code Block"
|
|
277
|
+
>
|
|
278
|
+
Code
|
|
279
|
+
</Button>
|
|
280
|
+
</div>
|
|
281
|
+
<Textarea
|
|
282
|
+
id={`markdown-${label}`}
|
|
283
|
+
value={value}
|
|
284
|
+
onChange={(e) => onChange(e.target.value)}
|
|
285
|
+
placeholder={placeholder}
|
|
286
|
+
rows={rows}
|
|
287
|
+
className="font-mono text-sm"
|
|
288
|
+
/>
|
|
289
|
+
</div>
|
|
290
|
+
)}
|
|
291
|
+
|
|
292
|
+
<div className="text-xs text-ui-fg-subtle">
|
|
293
|
+
Supports Markdown formatting: **bold**, *italic*, `code`, ```code
|
|
294
|
+
blocks```, [links](url), , and lists
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
);
|
|
298
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineRouteConfig } from "@medusajs/admin-sdk";
|
|
2
2
|
import { Sparkles } from "@medusajs/icons";
|
|
3
|
-
import { Button, Container, toast } from "@medusajs/ui";
|
|
3
|
+
import { Button, Container, toast, Tabs } from "@medusajs/ui";
|
|
4
4
|
import { useQueryClient } from "@tanstack/react-query";
|
|
5
5
|
import axios from "axios";
|
|
6
6
|
import { FC, useState } from "react";
|
|
@@ -11,6 +11,9 @@ import {
|
|
|
11
11
|
FlashSaleForm,
|
|
12
12
|
} from "../../../components/FlashSaleForm";
|
|
13
13
|
import { useFlashSaleById } from "../../../hooks/useFlashSaleById";
|
|
14
|
+
// Note: In production, adjust this import path based on your build setup
|
|
15
|
+
// The components should be accessible from the admin-store build
|
|
16
|
+
import { CampaignDetailForm } from "../../../components/campaign-detail-form";
|
|
14
17
|
|
|
15
18
|
const FlashSaleDetail: FC = () => {
|
|
16
19
|
const { id } = useParams<{ id: string }>();
|
|
@@ -33,7 +36,7 @@ const FlashSaleDetail: FC = () => {
|
|
|
33
36
|
exact: false,
|
|
34
37
|
predicate(query) {
|
|
35
38
|
return ["flash-sales", "flash-sale"].includes(
|
|
36
|
-
query.queryKey[0] as string
|
|
39
|
+
query.queryKey[0] as string,
|
|
37
40
|
);
|
|
38
41
|
},
|
|
39
42
|
});
|
|
@@ -82,18 +85,52 @@ const FlashSaleDetail: FC = () => {
|
|
|
82
85
|
};
|
|
83
86
|
|
|
84
87
|
return (
|
|
85
|
-
|
|
86
|
-
<
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
88
|
+
<Container>
|
|
89
|
+
<div className="mb-6 flex items-center justify-between">
|
|
90
|
+
<div>
|
|
91
|
+
<h1 className="text-2xl font-semibold mb-1">{data.name}</h1>
|
|
92
|
+
<p className="text-ui-fg-subtle">
|
|
93
|
+
Manage campaign settings and content
|
|
94
|
+
</p>
|
|
95
|
+
</div>
|
|
96
|
+
<div className="flex gap-2">
|
|
97
|
+
<Button variant="secondary" onClick={() => navigate("/flash-sales")}>
|
|
98
|
+
Back to Campaigns
|
|
99
|
+
</Button>
|
|
100
|
+
<Button onClick={() => setIsEditing((prev) => !prev)}>
|
|
101
|
+
{isEditing ? "View Mode" : "Edit Mode"}
|
|
102
|
+
</Button>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<Tabs defaultValue="settings">
|
|
107
|
+
<Tabs.List>
|
|
108
|
+
<Tabs.Trigger value="settings">Campaign Settings</Tabs.Trigger>
|
|
109
|
+
<Tabs.Trigger value="details">Campaign Details</Tabs.Trigger>
|
|
110
|
+
</Tabs.List>
|
|
111
|
+
|
|
112
|
+
<Tabs.Content value="settings" className="mt-6">
|
|
113
|
+
<FlashSaleForm
|
|
114
|
+
initialData={campaignData}
|
|
115
|
+
initialProducts={
|
|
116
|
+
new Map(
|
|
117
|
+
data.products.map((product) => [product.product.id, product]),
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
onSubmit={handleSubmit}
|
|
121
|
+
onCancel={() => navigate("/flash-sales")}
|
|
122
|
+
disabled={!isEditing}
|
|
123
|
+
/>
|
|
124
|
+
</Tabs.Content>
|
|
125
|
+
|
|
126
|
+
<Tabs.Content value="details" className="mt-6">
|
|
127
|
+
<CampaignDetailForm
|
|
128
|
+
campaignId={id || ""}
|
|
129
|
+
campaignName={data.name || ""}
|
|
130
|
+
/>
|
|
131
|
+
</Tabs.Content>
|
|
132
|
+
</Tabs>
|
|
133
|
+
</Container>
|
|
97
134
|
);
|
|
98
135
|
};
|
|
99
136
|
|