add formatting

This commit is contained in:
NotII
2025-02-21 03:01:35 +00:00
parent 914a4658a9
commit 689a82705b
5 changed files with 1780 additions and 24 deletions

View File

@@ -17,7 +17,7 @@ interface PricingTiersProps {
export const PricingTiers = ({ export const PricingTiers = ({
pricing, pricing,
handleTierChange, handleTierChange,
handleRemoveTier, handleRemoveTier,
handleAddTier, handleAddTier,
}: PricingTiersProps) => { }: PricingTiersProps) => {
const formatNumber = (num: number) => { const formatNumber = (num: number) => {

View File

@@ -1,12 +1,14 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useRef } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Send } from "lucide-react"; import { Send, Bold, Italic, Code, Link as LinkIcon, Image as ImageIcon, X, Eye, EyeOff } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { apiRequest } from "@/lib/storeHelper"; import { apiRequest } from "@/lib/storeHelper";
import { cn } from "@/lib/utils";
import { Textarea } from "@/components/ui/textarea";
import ReactMarkdown from 'react-markdown';
interface BroadcastDialogProps { interface BroadcastDialogProps {
open: boolean; open: boolean;
@@ -15,25 +17,130 @@ interface BroadcastDialogProps {
export default function BroadcastDialog({ open, setOpen }: BroadcastDialogProps) { export default function BroadcastDialog({ open, setOpen }: BroadcastDialogProps) {
const [broadcastMessage, setBroadcastMessage] = useState(""); const [broadcastMessage, setBroadcastMessage] = useState("");
const [showPreview, setShowPreview] = useState(true);
const [isSending, setIsSending] = useState(false); 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);
// Set cursor position after the inserted text
const newCursorPos = start + insertText.length;
textarea.focus();
textarea.setSelectionRange(newCursorPos, newCursorPos);
};
const sendBroadcast = async () => { const sendBroadcast = async () => {
if (!broadcastMessage.trim()) { if (!broadcastMessage.trim() && !selectedImage) {
toast.warning("Broadcast message cannot be empty."); toast.warning("Please provide a message or image to broadcast.");
return; return;
} }
try { try {
setIsSending(true); setIsSending(true);
const response = await apiRequest("/storefront/broadcast", "POST", { message: broadcastMessage });
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_URL}/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); if (response.error) throw new Error(response.error);
toast.success(`Broadcast sent to ${response.totalUsers} users!`); toast.success(`Broadcast sent to ${response.totalUsers} users!`);
setBroadcastMessage(""); setBroadcastMessage("");
setSelectedImage(null);
setImagePreview(null);
setOpen(false); setOpen(false);
} catch (error) { } catch (error) {
toast.error("Failed to send broadcast message."); console.error("Broadcast error:", error);
toast.error(error instanceof Error ? error.message : "Failed to send broadcast message.");
} finally { } finally {
setIsSending(false); setIsSending(false);
} }
@@ -41,17 +148,128 @@ export default function BroadcastDialog({ open, setOpen }: BroadcastDialogProps)
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader className="flex flex-row items-center gap-2 pb-4">
<Send className="h-6 w-6 text-emerald-600" /> <Send className="h-6 w-6 text-emerald-600 flex-shrink-0" />
<DialogTitle>Global Broadcast Message</DialogTitle> <DialogTitle>Global Broadcast Message</DialogTitle>
</DialogHeader> </DialogHeader>
<Textarea <div className="space-y-4">
placeholder="Type your message here..." <div className="flex gap-2 items-center">
value={broadcastMessage} <Button
onChange={(e) => setBroadcastMessage(e.target.value)} 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> <DialogFooter>
<Button variant="secondary" onClick={() => setOpen(false)}> <Button variant="secondary" onClick={() => setOpen(false)}>
@@ -59,7 +277,7 @@ export default function BroadcastDialog({ open, setOpen }: BroadcastDialogProps)
</Button> </Button>
<Button <Button
onClick={sendBroadcast} onClick={sendBroadcast}
disabled={isSending} disabled={isSending || (broadcastMessage.length > 4096) || (!broadcastMessage.trim() && !selectedImage)}
className="bg-emerald-600 hover:bg-emerald-700" className="bg-emerald-600 hover:bg-emerald-700"
> >
{isSending ? "Sending..." : "Send Broadcast"} {isSending ? "Sending..." : "Send Broadcast"}

1547
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,7 @@
"cmdk": "1.0.4", "cmdk": "1.0.4",
"date-fns": "4.1.0", "date-fns": "4.1.0",
"embla-carousel-react": "8.5.1", "embla-carousel-react": "8.5.1",
"form-data": "^4.0.2",
"input-otp": "1.4.1", "input-otp": "1.4.1",
"lucide-react": "^0.454.0", "lucide-react": "^0.454.0",
"next": "14.2.16", "next": "14.2.16",
@@ -51,6 +52,7 @@
"react-day-picker": "8.10.1", "react-day-picker": "8.10.1",
"react-dom": "^18", "react-dom": "^18",
"react-hook-form": "^7.54.1", "react-hook-form": "^7.54.1",
"react-markdown": "^10.0.0",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"recharts": "2.15.0", "recharts": "2.15.0",
"sonner": "^1.7.4", "sonner": "^1.7.4",
@@ -60,6 +62,7 @@
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^22", "@types/node": "^22",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",

View File

@@ -58,6 +58,6 @@ const config: Config = {
} }
} }
}, },
plugins: [require("tailwindcss-animate")], plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
}; };
export default config; export default config;