Some checks failed
Build Frontend / build (push) Failing after 7s
Replaces imports from 'components/ui' with 'components/common' across the app and dashboard pages, and updates model and API imports to use new paths under 'lib'. Removes redundant authentication checks from several dashboard pages. Adds new dashboard components and utility files, and reorganizes hooks and services into the 'lib' directory for improved structure.
370 lines
12 KiB
TypeScript
370 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, lazy, Suspense } from "react";
|
|
import { Button } from "@/components/common/button";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/common/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/common/textarea";
|
|
import Image from "next/image";
|
|
import ProductSelector from "./product-selector";
|
|
|
|
const ReactMarkdown = lazy(() => import('react-markdown'));
|
|
|
|
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 () => {
|
|
// Prevent duplicate sends
|
|
if (isSending) {
|
|
return;
|
|
}
|
|
|
|
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 = "/auth/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">
|
|
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading preview...</div>}>
|
|
<ReactMarkdown>{broadcastMessage}</ReactMarkdown>
|
|
</Suspense>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{imagePreview && (
|
|
<div className="relative">
|
|
<Image
|
|
src={imagePreview}
|
|
alt="Broadcast preview"
|
|
width={400}
|
|
height={200}
|
|
priority // Load immediately since it's a preview
|
|
placeholder="blur"
|
|
blurDataURL="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PC9zdmc+"
|
|
quality={85}
|
|
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>
|
|
);
|
|
}
|
|
|