Introduces a reusable date picker component with support for single date, date range, and month selection. Updates the stock management page to allow exporting reports by daily, weekly, monthly, or custom date ranges using the new pickers. Refactors promotion form to use the new date picker for start and end dates. Adds more business quotes to the quotes config.
288 lines
9.2 KiB
TypeScript
288 lines
9.2 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 } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { apiRequest } from "@/lib/api";
|
|
import { cn } from "@/lib/utils/general";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import ReactMarkdown from '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 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) { // 10MB limit
|
|
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.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);
|
|
if (broadcastMessage.trim()) {
|
|
formData.append('message', broadcastMessage);
|
|
}
|
|
|
|
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 apiRequest("/storefront/broadcast", "POST", { message: broadcastMessage });
|
|
}
|
|
|
|
if (response.error) throw new Error(response.error);
|
|
|
|
toast.success(`Broadcast sent to ${response.totalUsers} users!`);
|
|
setBroadcastMessage("");
|
|
setSelectedImage(null);
|
|
setImagePreview(null);
|
|
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>
|
|
<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>
|
|
)}
|
|
</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.trim() && !selectedImage)}
|
|
className="bg-emerald-600 hover:bg-emerald-700"
|
|
>
|
|
{isSending ? "Sending..." : "Send Broadcast"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
} |