Files
ember-market-frontend/components/modals/broadcast-dialog.tsx
g 07dcaf55c0 Refactor API calls to use apiRequest instead of clientFetch
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.
2025-12-12 20:05:26 +00:00

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>
);
}