Replaces all usages of clientFetch with the new apiRequest utility across dashboard pages, modal components, and the profit analytics service. This standardizes API interaction and improves consistency in request handling.
353 lines
12 KiB
TypeScript
353 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
|
import { Send, Bold, Italic, Code, Link as LinkIcon, Image as ImageIcon, X, Eye, EyeOff, Package } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { clientFetch } from "@/lib/api";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import ReactMarkdown from 'react-markdown';
|
|
import ProductSelector from "./product-selector";
|
|
|
|
interface BroadcastDialogProps {
|
|
open: boolean;
|
|
setOpen: (open: boolean) => void;
|
|
}
|
|
|
|
export default function BroadcastDialog({ open, setOpen }: BroadcastDialogProps) {
|
|
const [broadcastMessage, setBroadcastMessage] = useState("");
|
|
const [showPreview, setShowPreview] = useState(true);
|
|
const [isSending, setIsSending] = useState(false);
|
|
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
|
const [selectedProducts, setSelectedProducts] = useState<string[]>([]);
|
|
const [showProductSelector, setShowProductSelector] = useState(false);
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const handleImageSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = event.target.files?.[0];
|
|
if (file) {
|
|
if (file.size > 10 * 1024 * 1024) {
|
|
toast.error("Image size must be less than 10MB");
|
|
return;
|
|
}
|
|
setSelectedImage(file);
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
setImagePreview(reader.result as string);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
};
|
|
|
|
const removeImage = () => {
|
|
setSelectedImage(null);
|
|
setImagePreview(null);
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
};
|
|
|
|
const insertMarkdown = (type: 'bold' | 'italic' | 'code' | 'link') => {
|
|
const textarea = textareaRef.current;
|
|
if (!textarea) return;
|
|
|
|
const start = textarea.selectionStart;
|
|
const end = textarea.selectionEnd;
|
|
const selectedText = textarea.value.substring(start, end);
|
|
let insertText = '';
|
|
|
|
switch (type) {
|
|
case 'bold':
|
|
insertText = `**${selectedText || 'bold text'}**`;
|
|
break;
|
|
case 'italic':
|
|
insertText = `__${selectedText || 'italic text'}__`;
|
|
break;
|
|
case 'code':
|
|
insertText = `\`${selectedText || 'code'}\``;
|
|
break;
|
|
case 'link':
|
|
insertText = `[${selectedText || 'link text'}](URL)`;
|
|
break;
|
|
}
|
|
|
|
const newText = textarea.value.substring(0, start) + insertText + textarea.value.substring(end);
|
|
setBroadcastMessage(newText);
|
|
|
|
const newCursorPos = start + insertText.length;
|
|
textarea.focus();
|
|
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
|
};
|
|
|
|
const sendBroadcast = async () => {
|
|
if ((!broadcastMessage || !broadcastMessage.trim()) && !selectedImage) {
|
|
toast.warning("Please provide a message or image to broadcast.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsSending(true);
|
|
|
|
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
|
if (!API_URL) throw new Error("API URL not configured");
|
|
|
|
// Get auth token from cookie
|
|
const authToken = document.cookie
|
|
.split("; ")
|
|
.find((row) => row.startsWith("Authorization="))
|
|
?.split("=")[1];
|
|
|
|
if (!authToken) {
|
|
document.location.href = "/login";
|
|
throw new Error("No authentication token found");
|
|
}
|
|
|
|
let response;
|
|
|
|
if (selectedImage) {
|
|
const formData = new FormData();
|
|
formData.append('file', selectedImage);
|
|
// Always append message, even if empty (backend will validate)
|
|
formData.append('message', broadcastMessage || '');
|
|
if (selectedProducts.length > 0) {
|
|
formData.append('productIds', JSON.stringify(selectedProducts));
|
|
}
|
|
|
|
const res = await fetch(`/api/storefront/broadcast`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${authToken}`,
|
|
},
|
|
body: formData,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const errorData = await res.json().catch(() => ({}));
|
|
throw new Error(errorData.error || 'Failed to send broadcast');
|
|
}
|
|
|
|
response = await res.json();
|
|
} else {
|
|
response = await clientFetch("/storefront/broadcast", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
message: broadcastMessage,
|
|
productIds: selectedProducts
|
|
}),
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
}
|
|
|
|
if (response.error) throw new Error(response.error);
|
|
|
|
toast.success(`Broadcast sent to ${response.totalUsers} users!`);
|
|
setBroadcastMessage("");
|
|
setSelectedImage(null);
|
|
setImagePreview(null);
|
|
setSelectedProducts([]);
|
|
setOpen(false);
|
|
} catch (error) {
|
|
console.error("Broadcast error:", error);
|
|
toast.error(error instanceof Error ? error.message : "Failed to send broadcast message.");
|
|
} finally {
|
|
setIsSending(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader className="flex flex-row items-center gap-2 pb-4">
|
|
<Send className="h-6 w-6 text-emerald-600 flex-shrink-0" />
|
|
<DialogTitle>Global Broadcast Message</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
<div className="flex gap-2 items-center">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => insertMarkdown('bold')}
|
|
title="Bold (*text*)"
|
|
>
|
|
<Bold className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => insertMarkdown('italic')}
|
|
title="Italic (_text_)"
|
|
>
|
|
<Italic className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => insertMarkdown('code')}
|
|
title="Code (`text`)"
|
|
>
|
|
<Code className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => insertMarkdown('link')}
|
|
title="Link [text](URL)"
|
|
>
|
|
<LinkIcon className="h-4 w-4" />
|
|
</Button>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={handleImageSelect}
|
|
className="hidden"
|
|
ref={fileInputRef}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
title="Add Image"
|
|
>
|
|
<ImageIcon className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => setShowProductSelector(!showProductSelector)}
|
|
title="Add Products"
|
|
className={selectedProducts.length > 0 ? "bg-blue-100 dark:bg-blue-900" : ""}
|
|
>
|
|
<Package className="h-4 w-4" />
|
|
</Button>
|
|
<div className="ml-auto">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => setShowPreview(!showPreview)}
|
|
title={showPreview ? "Hide Preview" : "Show Preview"}
|
|
>
|
|
{showPreview ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<Textarea
|
|
ref={textareaRef}
|
|
placeholder="Type your message here... Use Telegram markdown formatting:
|
|
**bold text**
|
|
__italic text__
|
|
`code`
|
|
[text](URL)"
|
|
value={broadcastMessage}
|
|
onChange={(e) => setBroadcastMessage(e.target.value)}
|
|
className="min-h-[150px] font-mono"
|
|
/>
|
|
|
|
{showPreview && broadcastMessage.trim() && (
|
|
<div className="border rounded-lg p-4 bg-background/50">
|
|
<div className="prose prose-sm dark:prose-invert max-w-none">
|
|
<ReactMarkdown>{broadcastMessage}</ReactMarkdown>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{imagePreview && (
|
|
<div className="relative">
|
|
<img
|
|
src={imagePreview}
|
|
alt="Preview"
|
|
className="max-h-[200px] rounded-md object-contain"
|
|
/>
|
|
<Button
|
|
variant="destructive"
|
|
size="icon"
|
|
className="absolute top-2 right-2"
|
|
onClick={removeImage}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{showProductSelector && (
|
|
<div className="border rounded-lg p-3">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h4 className="font-medium text-sm">Select Products to Include</h4>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setShowProductSelector(false)}
|
|
>
|
|
Done
|
|
</Button>
|
|
</div>
|
|
<ProductSelector
|
|
selectedProducts={selectedProducts}
|
|
onSelectionChange={setSelectedProducts}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{selectedProducts.length > 0 && !showProductSelector && (
|
|
<div className="border rounded-lg p-3 bg-blue-50 dark:bg-blue-950/20">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<Package className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
|
<span className="text-sm font-medium">Selected Products ({selectedProducts.length})</span>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setShowProductSelector(true)}
|
|
className="text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
|
>
|
|
Edit
|
|
</Button>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
Products will be added as interactive buttons in the broadcast message.
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="text-sm text-gray-500">
|
|
{broadcastMessage.length} characters
|
|
{broadcastMessage.length > 4096 && (
|
|
<span className="text-red-500 ml-2">
|
|
Message exceeds Telegram's 4096 character limit
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="secondary" onClick={() => setOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={sendBroadcast}
|
|
disabled={isSending || (broadcastMessage.length > 4096) || ((!broadcastMessage || !broadcastMessage.trim()) && !selectedImage)}
|
|
className="bg-emerald-600 hover:bg-emerald-700"
|
|
>
|
|
{isSending ? "Sending..." : "Send Broadcast"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
} |