chore: format
This commit is contained in:
@@ -1,61 +1,61 @@
|
||||
import { Route, Routes, BrowserRouter } from "react-router"
|
||||
import { Route, Routes, BrowserRouter } from "react-router";
|
||||
import {
|
||||
DashboardPage,
|
||||
DashboardLayout,
|
||||
SubscribersPage,
|
||||
CampaignsPage,
|
||||
EditCampaignPage,
|
||||
TemplatesPage,
|
||||
SettingsPage,
|
||||
AnalyticsPage,
|
||||
ListsPage,
|
||||
OnboardingPage,
|
||||
MessagesPage,
|
||||
EditCampaignLayout,
|
||||
UnsubscribePage,
|
||||
AuthPage,
|
||||
NotFoundPage,
|
||||
VerifyEmailPage,
|
||||
} from "./pages"
|
||||
import { scan } from "react-scan"
|
||||
DashboardPage,
|
||||
DashboardLayout,
|
||||
SubscribersPage,
|
||||
CampaignsPage,
|
||||
EditCampaignPage,
|
||||
TemplatesPage,
|
||||
SettingsPage,
|
||||
AnalyticsPage,
|
||||
ListsPage,
|
||||
OnboardingPage,
|
||||
MessagesPage,
|
||||
EditCampaignLayout,
|
||||
UnsubscribePage,
|
||||
AuthPage,
|
||||
NotFoundPage,
|
||||
VerifyEmailPage,
|
||||
} from "./pages";
|
||||
import { scan } from "react-scan";
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
scan({
|
||||
enabled: true,
|
||||
log: true,
|
||||
})
|
||||
scan({
|
||||
enabled: true,
|
||||
log: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<AuthPage />} />
|
||||
<Route path="dashboard" element={<DashboardLayout />}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="subscribers" element={<SubscribersPage />} />
|
||||
<Route path="campaigns">
|
||||
<Route index element={<CampaignsPage />} />
|
||||
<Route
|
||||
path=":id"
|
||||
element={
|
||||
<EditCampaignLayout>
|
||||
<EditCampaignPage />
|
||||
</EditCampaignLayout>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="templates" element={<TemplatesPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="analytics" element={<AnalyticsPage />} />
|
||||
<Route path="lists" element={<ListsPage />} />
|
||||
<Route path="messages" element={<MessagesPage />} />
|
||||
</Route>
|
||||
<Route path="/onboarding" element={<OnboardingPage />} />
|
||||
<Route path="/unsubscribe" element={<UnsubscribePage />} />
|
||||
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<AuthPage />} />
|
||||
<Route path="dashboard" element={<DashboardLayout />}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="subscribers" element={<SubscribersPage />} />
|
||||
<Route path="campaigns">
|
||||
<Route index element={<CampaignsPage />} />
|
||||
<Route
|
||||
path=":id"
|
||||
element={
|
||||
<EditCampaignLayout>
|
||||
<EditCampaignPage />
|
||||
</EditCampaignLayout>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="templates" element={<TemplatesPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="analytics" element={<AnalyticsPage />} />
|
||||
<Route path="lists" element={<ListsPage />} />
|
||||
<Route path="messages" element={<MessagesPage />} />
|
||||
</Route>
|
||||
<Route path="/onboarding" element={<OnboardingPage />} />
|
||||
<Route path="/unsubscribe" element={<UnsubscribePage />} />
|
||||
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@repo/ui"
|
||||
import { ReactNode } from "react"
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@repo/ui";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface AlertDialogConfirmationProps {
|
||||
trigger: ReactNode
|
||||
title: string
|
||||
description: string
|
||||
cancelText?: string
|
||||
confirmText?: string
|
||||
onConfirm: () => void
|
||||
variant?: "default" | "destructive"
|
||||
trigger: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
cancelText?: string;
|
||||
confirmText?: string;
|
||||
onConfirm: () => void;
|
||||
variant?: "default" | "destructive";
|
||||
}
|
||||
|
||||
export function AlertDialogConfirmation({
|
||||
trigger,
|
||||
title,
|
||||
description,
|
||||
cancelText = "Cancel",
|
||||
confirmText = "Confirm",
|
||||
onConfirm,
|
||||
variant = "default",
|
||||
trigger,
|
||||
title,
|
||||
description,
|
||||
cancelText = "Cancel",
|
||||
confirmText = "Confirm",
|
||||
onConfirm,
|
||||
variant = "default",
|
||||
}: AlertDialogConfirmationProps) {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{cancelText}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={
|
||||
variant === "destructive"
|
||||
? "bg-destructive hover:bg-destructive/90 text-white"
|
||||
: undefined
|
||||
}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{cancelText}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={
|
||||
variant === "destructive"
|
||||
? "bg-destructive hover:bg-destructive/90 text-white"
|
||||
: undefined
|
||||
}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Skeleton } from "@repo/ui"
|
||||
import { Skeleton } from "@repo/ui";
|
||||
|
||||
export const CardSkeleton = () => (
|
||||
<div className="grid gap-2">
|
||||
<Skeleton className="w-[150px] h-6" />
|
||||
<Skeleton className="w-[80px] h-4" />
|
||||
</div>
|
||||
)
|
||||
<div className="grid gap-2">
|
||||
<Skeleton className="w-[150px] h-6" />
|
||||
<Skeleton className="w-[80px] h-4" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export function CenteredLoader() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen w-full">
|
||||
<Loader2 className="animate-spin h-8 w-8 text-primary" />
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen w-full">
|
||||
<Loader2 className="animate-spin h-8 w-8 text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import { Check, Copy } from "lucide-react"
|
||||
import { Button } from "@repo/ui"
|
||||
import { useState } from "react"
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { Button } from "@repo/ui";
|
||||
import { useState } from "react";
|
||||
|
||||
interface CopyButtonProps {
|
||||
onCopy: () => void | Promise<void>
|
||||
className?: string
|
||||
onCopy: () => void | Promise<void>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CopyButton({ onCopy, className }: CopyButtonProps) {
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await onCopy()
|
||||
setIsCopied(true)
|
||||
setTimeout(() => setIsCopied(false), 2000)
|
||||
}
|
||||
const handleCopy = async () => {
|
||||
await onCopy();
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
className={className}
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
className={className}
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
import { cn } from "@repo/ui"
|
||||
import { useEffect, useRef } from "react"
|
||||
import { cn } from "@repo/ui";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface EmailPreviewProps {
|
||||
content: string
|
||||
className?: string
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmailPreview({ content, className = "" }: EmailPreviewProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!iframeRef.current) return
|
||||
useEffect(() => {
|
||||
if (!iframeRef.current) return;
|
||||
|
||||
const doc = iframeRef.current.contentDocument
|
||||
if (!doc) return
|
||||
const doc = iframeRef.current.contentDocument;
|
||||
if (!doc) return;
|
||||
|
||||
// Modify the tracking pixel URL for preview
|
||||
// Regex to match /img/any-id/img.png
|
||||
const trackingPixelRegex = /\/img\/[^/]+\/img\.png/g
|
||||
const modifiedContent = content.replace(
|
||||
trackingPixelRegex,
|
||||
"#invalid-tracking-pixel-for-preview"
|
||||
)
|
||||
// Modify the tracking pixel URL for preview
|
||||
// Regex to match /img/any-id/img.png
|
||||
const trackingPixelRegex = /\/img\/[^/]+\/img\.png/g;
|
||||
const modifiedContent = content.replace(
|
||||
trackingPixelRegex,
|
||||
"#invalid-tracking-pixel-for-preview",
|
||||
);
|
||||
|
||||
// Write the content to the iframe
|
||||
doc.open()
|
||||
doc.write(modifiedContent)
|
||||
doc.close()
|
||||
// Write the content to the iframe
|
||||
doc.open();
|
||||
doc.write(modifiedContent);
|
||||
doc.close();
|
||||
|
||||
// Make links open in new tab
|
||||
const links = doc.getElementsByTagName("a")
|
||||
for (const link of links) {
|
||||
link.target = "_blank"
|
||||
link.rel = "noopener noreferrer"
|
||||
}
|
||||
}, [content])
|
||||
// Make links open in new tab
|
||||
const links = doc.getElementsByTagName("a");
|
||||
for (const link of links) {
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className={cn("w-full scroll-hidden rounded-md bg-white", className)}
|
||||
sandbox="allow-same-origin"
|
||||
title="Email Preview"
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className={cn("w-full scroll-hidden rounded-md bg-white", className)}
|
||||
sandbox="allow-same-origin"
|
||||
title="Email Preview"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
import { Component, ErrorInfo, ReactNode } from "react"
|
||||
import { Button } from "@repo/ui"
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { Component, ErrorInfo, ReactNode } from "react";
|
||||
import { Button } from "@repo/ui";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error?: Error
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
hasError: false,
|
||||
}
|
||||
public state: State = {
|
||||
hasError: false,
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error("Uncaught error:", error, errorInfo)
|
||||
}
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error("Uncaught error:", error, errorInfo);
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center p-8 max-w-2xl mx-auto">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="h-12 w-12 rounded-full bg-destructive/10 flex items-center justify-center">
|
||||
<AlertCircle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold mb-2">Something went wrong</h1>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
An unexpected error has occurred.
|
||||
</p>
|
||||
<div className="bg-muted/50 rounded-lg p-4 mb-6">
|
||||
<pre className="text-sm text-muted-foreground break-words whitespace-pre-wrap">
|
||||
{this.state.error?.message}
|
||||
</pre>
|
||||
</div>
|
||||
<Button onClick={() => window.location.reload()}>Try Again</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center p-8 max-w-2xl mx-auto">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="h-12 w-12 rounded-full bg-destructive/10 flex items-center justify-center">
|
||||
<AlertCircle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold mb-2">Something went wrong</h1>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
An unexpected error has occurred.
|
||||
</p>
|
||||
<div className="bg-muted/50 rounded-lg p-4 mb-6">
|
||||
<pre className="text-sm text-muted-foreground break-words whitespace-pre-wrap">
|
||||
{this.state.error?.message}
|
||||
</pre>
|
||||
</div>
|
||||
<Button onClick={() => window.location.reload()}>Try Again</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
import { Control, FieldPath, FieldValues } from "react-hook-form"
|
||||
import type { ComponentProps } from "react"
|
||||
import { Control, FieldPath, FieldValues } from "react-hook-form";
|
||||
import type { ComponentProps } from "react";
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
Input,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
} from "@repo/ui"
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
Input,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
} from "@repo/ui";
|
||||
|
||||
interface FormControlledInputProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
> {
|
||||
control: Control<TFieldValues>
|
||||
name: FieldPath<TFieldValues>
|
||||
label: string
|
||||
description?: string
|
||||
inputProps?: ComponentProps<typeof Input>
|
||||
control: Control<TFieldValues>;
|
||||
name: FieldPath<TFieldValues>;
|
||||
label: string;
|
||||
description?: string;
|
||||
inputProps?: ComponentProps<typeof Input>;
|
||||
}
|
||||
|
||||
export function FormControlledInput<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
inputProps,
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
inputProps,
|
||||
}: FormControlledInputProps<TFieldValues>) {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...inputProps} {...field} />
|
||||
</FormControl>
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...inputProps} {...field} />
|
||||
</FormControl>
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,182 +1,182 @@
|
||||
import { useState } from "react"
|
||||
import { Upload, FileText, AlertCircle, Check } from "lucide-react"
|
||||
import { useState } from "react";
|
||||
import { Upload, FileText, AlertCircle, Check } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Button,
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@repo/ui"
|
||||
import { trpc } from "@/trpc"
|
||||
import { useSession } from "@/hooks"
|
||||
import { WithTooltip } from "./with-tooltip"
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Button,
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@repo/ui";
|
||||
import { trpc } from "@/trpc";
|
||||
import { useSession } from "@/hooks";
|
||||
import { WithTooltip } from "./with-tooltip";
|
||||
|
||||
const VALID_HEADERS = [
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"phone",
|
||||
"company",
|
||||
"job_title",
|
||||
"city",
|
||||
"country",
|
||||
"subscribed_at",
|
||||
"tags",
|
||||
] as const
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"phone",
|
||||
"company",
|
||||
"job_title",
|
||||
"city",
|
||||
"country",
|
||||
"subscribed_at",
|
||||
"tags",
|
||||
] as const;
|
||||
|
||||
export type ValidHeader = (typeof VALID_HEADERS)[number]
|
||||
export type ValidHeader = (typeof VALID_HEADERS)[number];
|
||||
|
||||
interface ImportSubscribersDialogProps {
|
||||
onSuccess?: () => void
|
||||
listId?: string
|
||||
onSuccess?: () => void;
|
||||
listId?: string;
|
||||
}
|
||||
|
||||
export function ImportSubscribersDialog({
|
||||
onSuccess,
|
||||
listId,
|
||||
onSuccess,
|
||||
listId,
|
||||
}: ImportSubscribersDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { organization } = useSession()
|
||||
const [open, setOpen] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { organization } = useSession();
|
||||
|
||||
const importMutation = trpc.subscriber.import.useMutation({
|
||||
onSuccess: () => {
|
||||
setOpen(false)
|
||||
setFile(null)
|
||||
setError(null)
|
||||
onSuccess?.()
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(error.message)
|
||||
},
|
||||
})
|
||||
const importMutation = trpc.subscriber.import.useMutation({
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
setFile(null);
|
||||
setError(null);
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.name.endsWith(".csv")) {
|
||||
setError("Please upload a CSV file")
|
||||
return
|
||||
}
|
||||
if (!file.name.endsWith(".csv")) {
|
||||
setError("Please upload a CSV file");
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(file)
|
||||
setError(null)
|
||||
}
|
||||
setFile(file);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file || !organization?.id) return
|
||||
const handleImport = async () => {
|
||||
if (!file || !organization?.id) return;
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
formData.append("organizationId", organization.id)
|
||||
if (listId) {
|
||||
formData.append("listId", listId)
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("organizationId", organization.id);
|
||||
if (listId) {
|
||||
formData.append("listId", listId);
|
||||
}
|
||||
|
||||
importMutation.mutate({
|
||||
file: formData,
|
||||
organizationId: organization.id,
|
||||
listId,
|
||||
})
|
||||
}
|
||||
importMutation.mutate({
|
||||
file: formData,
|
||||
organizationId: organization.id,
|
||||
listId,
|
||||
});
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const headers = VALID_HEADERS.join(",")
|
||||
const blob = new Blob([headers], { type: "text/csv" })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = "subscribers_template.csv"
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
const downloadTemplate = () => {
|
||||
const headers = VALID_HEADERS.join(",");
|
||||
const blob = new Blob([headers], { type: "text/csv" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "subscribers_template.csv";
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<WithTooltip content="Import subscribers">
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Upload className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</WithTooltip>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Subscribers</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload a CSV file with subscriber data. Download the template to see
|
||||
the correct format.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<WithTooltip content="Import subscribers">
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Upload className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</WithTooltip>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Subscribers</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload a CSV file with subscriber data. Download the template to see
|
||||
the correct format.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Button variant="outline" onClick={downloadTemplate}>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Download Template
|
||||
</Button>
|
||||
<div className="space-y-4">
|
||||
<Button variant="outline" onClick={downloadTemplate}>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Download Template
|
||||
</Button>
|
||||
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<label
|
||||
htmlFor="file"
|
||||
className="border-2 border-dashed rounded-lg p-8 text-center cursor-pointer hover:border-primary/50 transition-colors"
|
||||
>
|
||||
{file ? (
|
||||
<div className="flex items-center justify-center gap-2 text-sm">
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
{file.name}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Click to upload or drag and drop
|
||||
<br />
|
||||
CSV files only
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
id="file"
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<label
|
||||
htmlFor="file"
|
||||
className="border-2 border-dashed rounded-lg p-8 text-center cursor-pointer hover:border-primary/50 transition-colors"
|
||||
>
|
||||
{file ? (
|
||||
<div className="flex items-center justify-center gap-2 text-sm">
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
{file.name}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Click to upload or drag and drop
|
||||
<br />
|
||||
CSV files only
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
id="file"
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!file || importMutation.isPending}
|
||||
>
|
||||
{importMutation.isPending ? (
|
||||
<>
|
||||
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
Importing...
|
||||
</>
|
||||
) : (
|
||||
"Import"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!file || importMutation.isPending}
|
||||
>
|
||||
{importMutation.isPending ? (
|
||||
<>
|
||||
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
Importing...
|
||||
</>
|
||||
) : (
|
||||
"Import"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
export * from "./copy-button"
|
||||
export * from "./error-boundary"
|
||||
export * from "./import-subscribers-dialog"
|
||||
export * from "./with-tooltip"
|
||||
export * from "./email-preview"
|
||||
export * from "./theme-toggle"
|
||||
export * from "./theme-provider"
|
||||
export * from "./card-skeleton"
|
||||
export * from "./stat-card"
|
||||
export * from "./alert-dialog-confirmation"
|
||||
export * from "./pagination"
|
||||
export * from "./centered-loader"
|
||||
export * from "./loader"
|
||||
export * from "./letter-space-text"
|
||||
export * from "./form-controlled-input"
|
||||
export * from "./copy-button";
|
||||
export * from "./error-boundary";
|
||||
export * from "./import-subscribers-dialog";
|
||||
export * from "./with-tooltip";
|
||||
export * from "./email-preview";
|
||||
export * from "./theme-toggle";
|
||||
export * from "./theme-provider";
|
||||
export * from "./card-skeleton";
|
||||
export * from "./stat-card";
|
||||
export * from "./alert-dialog-confirmation";
|
||||
export * from "./pagination";
|
||||
export * from "./centered-loader";
|
||||
export * from "./loader";
|
||||
export * from "./letter-space-text";
|
||||
export * from "./form-controlled-input";
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { cn } from "@repo/ui"
|
||||
import { cn } from "@repo/ui";
|
||||
|
||||
type LetterSpaceTextProps = {
|
||||
className?: string
|
||||
as?: React.ElementType
|
||||
}
|
||||
className?: string;
|
||||
as?: React.ElementType;
|
||||
};
|
||||
|
||||
export const LetterSpaceText = ({
|
||||
className,
|
||||
as = "p",
|
||||
className,
|
||||
as = "p",
|
||||
}: LetterSpaceTextProps) => {
|
||||
const Comp = as
|
||||
const Comp = as;
|
||||
|
||||
return (
|
||||
<Comp className={cn("text-lg font-semibold", className)}>
|
||||
<span>Letter</span>
|
||||
<span className="text-brand-primary">Space</span>
|
||||
</Comp>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Comp className={cn("text-lg font-semibold", className)}>
|
||||
<span>Letter</span>
|
||||
<span className="text-brand-primary">Space</span>
|
||||
</Comp>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
type LoaderProps = {
|
||||
size?: number
|
||||
text?: string
|
||||
className?: string
|
||||
height?: string
|
||||
}
|
||||
size?: number;
|
||||
text?: string;
|
||||
className?: string;
|
||||
height?: string;
|
||||
};
|
||||
|
||||
export function Loader({
|
||||
size = 8,
|
||||
text,
|
||||
className = "",
|
||||
height = "h-[300px]",
|
||||
size = 8,
|
||||
text,
|
||||
className = "",
|
||||
height = "h-[300px]",
|
||||
}: LoaderProps) {
|
||||
return (
|
||||
<div className={`flex ${height} items-center justify-center ${className}`}>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className={`h-${size} w-${size} animate-spin text-primary`} />
|
||||
{text && <p className="text-sm text-muted-foreground">{text}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div className={`flex ${height} items-center justify-center ${className}`}>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className={`h-${size} w-${size} animate-spin text-primary`} />
|
||||
{text && <p className="text-sm text-muted-foreground">{text}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,113 +1,117 @@
|
||||
import { Pagination as UIPagination, PaginationContent, Button } from "@repo/ui"
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
import {
|
||||
Pagination as UIPagination,
|
||||
PaginationContent,
|
||||
Button,
|
||||
} from "@repo/ui";
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
|
||||
type PaginationProps = {
|
||||
page: number
|
||||
totalPages: number
|
||||
onPageChange: (page: number) => void
|
||||
hasNextPage: boolean
|
||||
}
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
hasNextPage: boolean;
|
||||
};
|
||||
|
||||
export function Pagination({
|
||||
page,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
hasNextPage,
|
||||
page,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
hasNextPage,
|
||||
}: PaginationProps) {
|
||||
const totalButtons = 7
|
||||
const totalButtons = 7;
|
||||
|
||||
const renderPageButton = (pageNumber: number) => (
|
||||
<Button
|
||||
key={pageNumber}
|
||||
variant={page === pageNumber ? "default" : "outline"}
|
||||
size="icon"
|
||||
onClick={() => onPageChange(pageNumber)}
|
||||
>
|
||||
{pageNumber}
|
||||
</Button>
|
||||
)
|
||||
const renderPageButton = (pageNumber: number) => (
|
||||
<Button
|
||||
key={pageNumber}
|
||||
variant={page === pageNumber ? "default" : "outline"}
|
||||
size="icon"
|
||||
onClick={() => onPageChange(pageNumber)}
|
||||
>
|
||||
{pageNumber}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const renderEllipsis = (key: string) => (
|
||||
<Button key={key} variant="outline" size="icon" disabled>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
)
|
||||
const renderEllipsis = (key: string) => (
|
||||
<Button key={key} variant="outline" size="icon" disabled>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
const renderPaginationButtons = () => {
|
||||
const buttons = []
|
||||
const renderPaginationButtons = () => {
|
||||
const buttons = [];
|
||||
|
||||
if (totalPages <= totalButtons) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
buttons.push(renderPageButton(i))
|
||||
}
|
||||
} else {
|
||||
buttons.push(renderPageButton(1))
|
||||
if (totalPages <= totalButtons) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
buttons.push(renderPageButton(i));
|
||||
}
|
||||
} else {
|
||||
buttons.push(renderPageButton(1));
|
||||
|
||||
if (page > 3) {
|
||||
buttons.push(renderEllipsis("start-ellipsis"))
|
||||
}
|
||||
if (page > 3) {
|
||||
buttons.push(renderEllipsis("start-ellipsis"));
|
||||
}
|
||||
|
||||
let start = Math.max(2, page - 1)
|
||||
let end = Math.min(totalPages - 1, page + 1)
|
||||
let start = Math.max(2, page - 1);
|
||||
let end = Math.min(totalPages - 1, page + 1);
|
||||
|
||||
if (page <= 3) {
|
||||
end = Math.min(totalPages - 1, totalButtons - 2)
|
||||
}
|
||||
if (page <= 3) {
|
||||
end = Math.min(totalPages - 1, totalButtons - 2);
|
||||
}
|
||||
|
||||
if (page >= totalPages - 2) {
|
||||
start = Math.max(2, totalPages - (totalButtons - 2))
|
||||
}
|
||||
if (page >= totalPages - 2) {
|
||||
start = Math.max(2, totalPages - (totalButtons - 2));
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
buttons.push(renderPageButton(i))
|
||||
}
|
||||
for (let i = start; i <= end; i++) {
|
||||
buttons.push(renderPageButton(i));
|
||||
}
|
||||
|
||||
if (page < totalPages - 2) {
|
||||
buttons.push(renderEllipsis("end-ellipsis"))
|
||||
}
|
||||
if (page < totalPages - 2) {
|
||||
buttons.push(renderEllipsis("end-ellipsis"));
|
||||
}
|
||||
|
||||
buttons.push(renderPageButton(totalPages))
|
||||
}
|
||||
buttons.push(renderPageButton(totalPages));
|
||||
}
|
||||
|
||||
while (buttons.length < totalButtons) {
|
||||
buttons.push(
|
||||
<Button
|
||||
key={`filler-${buttons.length}`}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled
|
||||
>
|
||||
{" "}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
while (buttons.length < totalButtons) {
|
||||
buttons.push(
|
||||
<Button
|
||||
key={`filler-${buttons.length}`}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled
|
||||
>
|
||||
{" "}
|
||||
</Button>,
|
||||
);
|
||||
}
|
||||
|
||||
return buttons
|
||||
}
|
||||
return buttons;
|
||||
};
|
||||
|
||||
return (
|
||||
<UIPagination className="select-none">
|
||||
<PaginationContent className="flex items-center justify-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous page</span>
|
||||
</Button>
|
||||
{renderPaginationButtons()}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
|
||||
disabled={!hasNextPage}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next page</span>
|
||||
</Button>
|
||||
</PaginationContent>
|
||||
</UIPagination>
|
||||
)
|
||||
return (
|
||||
<UIPagination className="select-none">
|
||||
<PaginationContent className="flex items-center justify-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous page</span>
|
||||
</Button>
|
||||
{renderPaginationButtons()}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
|
||||
disabled={!hasNextPage}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next page</span>
|
||||
</Button>
|
||||
</PaginationContent>
|
||||
</UIPagination>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,68 +1,68 @@
|
||||
import { TrendingUp } from "lucide-react"
|
||||
import { TrendingDown } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@repo/ui"
|
||||
import { LucideIcon } from "lucide-react"
|
||||
import { CardSkeleton } from "./card-skeleton"
|
||||
import { TrendingUp } from "lucide-react";
|
||||
import { TrendingDown } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@repo/ui";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
import { CardSkeleton } from "./card-skeleton";
|
||||
|
||||
export function StatCard({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
change,
|
||||
subtitle,
|
||||
isLoading,
|
||||
smallTitle,
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
change,
|
||||
subtitle,
|
||||
isLoading,
|
||||
smallTitle,
|
||||
}: {
|
||||
title: string
|
||||
value: string | number
|
||||
icon: LucideIcon
|
||||
change?: number
|
||||
subtitle?: string
|
||||
isLoading: boolean
|
||||
smallTitle?: string
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: LucideIcon;
|
||||
change?: number;
|
||||
subtitle?: string;
|
||||
isLoading: boolean;
|
||||
smallTitle?: string;
|
||||
}) {
|
||||
const showChange = change !== undefined && !isNaN(change)
|
||||
const showChange = change !== undefined && !isNaN(change);
|
||||
|
||||
return (
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{title}{" "}
|
||||
{smallTitle && (
|
||||
<small className="text-xs text-muted-foreground">
|
||||
({smallTitle})
|
||||
</small>
|
||||
)}
|
||||
</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
{showChange && (
|
||||
<div
|
||||
className={`flex items-center text-sm ${
|
||||
change >= 0 ? "text-emerald-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{change >= 0 ? (
|
||||
<TrendingUp className="mr-1 h-4 w-4" />
|
||||
) : (
|
||||
<TrendingDown className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{change >= 0 ? "+" : "-"}
|
||||
{Math.abs(change).toFixed(1)}% from last month
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="text-xs text-muted-foreground">{subtitle}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
return (
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{title}{" "}
|
||||
{smallTitle && (
|
||||
<small className="text-xs text-muted-foreground">
|
||||
({smallTitle})
|
||||
</small>
|
||||
)}
|
||||
</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
{showChange && (
|
||||
<div
|
||||
className={`flex items-center text-sm ${
|
||||
change >= 0 ? "text-emerald-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{change >= 0 ? (
|
||||
<TrendingUp className="mr-1 h-4 w-4" />
|
||||
) : (
|
||||
<TrendingDown className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{change >= 0 ? "+" : "-"}
|
||||
{Math.abs(change).toFixed(1)}% from last month
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="text-xs text-muted-foreground">{subtitle}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "./theme-provider"
|
||||
export * from "./theme-provider";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ThemeProviderState } from "./theme-provider"
|
||||
import { ThemeProviderState } from "./theme-provider";
|
||||
|
||||
export const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
}
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext } from "react"
|
||||
import { ThemeProviderState } from "./theme-provider"
|
||||
import { initialState } from "./state"
|
||||
import { createContext } from "react";
|
||||
import { ThemeProviderState } from "./theme-provider";
|
||||
import { initialState } from "./state";
|
||||
|
||||
export const ThemeProviderContext =
|
||||
createContext<ThemeProviderState>(initialState)
|
||||
createContext<ThemeProviderState>(initialState);
|
||||
|
||||
@@ -1,58 +1,58 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { ThemeProviderContext } from "./theme-context"
|
||||
import { useEffect, useState } from "react";
|
||||
import { ThemeProviderContext } from "./theme-context";
|
||||
|
||||
export type Theme = "dark" | "light" | "system"
|
||||
export type Theme = "dark" | "light" | "system";
|
||||
|
||||
export type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
}
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
};
|
||||
|
||||
export type ThemeProviderState = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
};
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
)
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove("light", "dark")
|
||||
root.classList.remove("light", "dark");
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light"
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
root.classList.add(systemTheme)
|
||||
return
|
||||
}
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(theme)
|
||||
}, [theme])
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme)
|
||||
setTheme(theme)
|
||||
},
|
||||
}
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
import { Moon, Sun, Laptop } from "lucide-react"
|
||||
import { Button } from "@repo/ui"
|
||||
import { useTheme } from "@/hooks"
|
||||
import { Moon, Sun, Laptop } from "lucide-react";
|
||||
import { Button } from "@repo/ui";
|
||||
import { useTheme } from "@/hooks";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@repo/ui"
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@repo/ui";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme } = useTheme()
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
<span>Light</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
<Moon className="mr-2 h-4 w-4" />
|
||||
<span>Dark</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
<Laptop className="mr-2 h-4 w-4" />
|
||||
<span>System</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
<span>Light</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
<Moon className="mr-2 h-4 w-4" />
|
||||
<span>Dark</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
<Laptop className="mr-2 h-4 w-4" />
|
||||
<span>System</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@repo/ui"
|
||||
import { ReactNode } from "react"
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@repo/ui";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface WithTooltipProps {
|
||||
content: string
|
||||
children: ReactNode
|
||||
content: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function WithTooltip({ content, children }: WithTooltipProps) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{content}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{content}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { z } from "zod"
|
||||
import { z } from "zod";
|
||||
|
||||
export const constants = z
|
||||
.object({
|
||||
VITE_API_URL: z.string().optional(),
|
||||
isDev: z.boolean(),
|
||||
GITHUB_URL: z.string(),
|
||||
})
|
||||
.transform((env) => ({
|
||||
...env,
|
||||
trpcUrl: import.meta.env.DEV ? `${env.VITE_API_URL}/trpc` : "/trpc",
|
||||
}))
|
||||
.parse({
|
||||
...import.meta.env,
|
||||
isDev: import.meta.env.DEV,
|
||||
GITHUB_URL: "https://github.com/dcodesdev/letterspace",
|
||||
})
|
||||
.object({
|
||||
VITE_API_URL: z.string().optional(),
|
||||
isDev: z.boolean(),
|
||||
GITHUB_URL: z.string(),
|
||||
})
|
||||
.transform((env) => ({
|
||||
...env,
|
||||
trpcUrl: import.meta.env.DEV ? `${env.VITE_API_URL}/trpc` : "/trpc",
|
||||
}))
|
||||
.parse({
|
||||
...import.meta.env,
|
||||
isDev: import.meta.env.DEV,
|
||||
GITHUB_URL: "https://github.com/dcodesdev/letterspace",
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export * from "./useSession"
|
||||
export * from "./useDebounce"
|
||||
export * from "./useTheme"
|
||||
export * from "./useUpdateEffect"
|
||||
export * from "./useIsMounted"
|
||||
export * from "./usePagination"
|
||||
export * from "./useQueryState"
|
||||
export * from "./use-update-check"
|
||||
export * from "./useSession";
|
||||
export * from "./useDebounce";
|
||||
export * from "./useTheme";
|
||||
export * from "./useUpdateEffect";
|
||||
export * from "./useIsMounted";
|
||||
export * from "./usePagination";
|
||||
export * from "./useQueryState";
|
||||
export * from "./use-update-check";
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { APP_VERSION } from "@repo/shared"
|
||||
import semver from "semver"
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { APP_VERSION } from "@repo/shared";
|
||||
import semver from "semver";
|
||||
|
||||
type UpdateInfo = {
|
||||
hasUpdate: boolean
|
||||
latestVersion: string
|
||||
}
|
||||
hasUpdate: boolean;
|
||||
latestVersion: string;
|
||||
};
|
||||
|
||||
type GithubRelease = {
|
||||
tag_name: string
|
||||
}
|
||||
tag_name: string;
|
||||
};
|
||||
|
||||
async function fetchLatestRelease(): Promise<GithubRelease> {
|
||||
const res = await fetch(
|
||||
"https://api.github.com/repos/dcodesdev/LetterSpace/releases/latest"
|
||||
)
|
||||
if (!res.ok) throw new Error("Failed to fetch release")
|
||||
return res.json()
|
||||
const res = await fetch(
|
||||
"https://api.github.com/repos/dcodesdev/LetterSpace/releases/latest",
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to fetch release");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function useUpdateCheck(): UpdateInfo {
|
||||
const { data } = useQuery({
|
||||
queryKey: ["latest-release"],
|
||||
queryFn: fetchLatestRelease,
|
||||
staleTime: 1000 * 60 * 10,
|
||||
retry: false,
|
||||
refetchInterval: 1000 * 60, // Every minute
|
||||
})
|
||||
const { data } = useQuery({
|
||||
queryKey: ["latest-release"],
|
||||
queryFn: fetchLatestRelease,
|
||||
staleTime: 1000 * 60 * 10,
|
||||
retry: false,
|
||||
refetchInterval: 1000 * 60, // Every minute
|
||||
});
|
||||
|
||||
const latestVersion = data?.tag_name?.replace("v", "") || APP_VERSION
|
||||
const hasUpdate = semver.gt(latestVersion, APP_VERSION)
|
||||
const latestVersion = data?.tag_name?.replace("v", "") || APP_VERSION;
|
||||
const hasUpdate = semver.gt(latestVersion, APP_VERSION);
|
||||
|
||||
return { hasUpdate, latestVersion }
|
||||
return { hasUpdate, latestVersion };
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useDebounce<T>(value: T, delay?: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay || 500)
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [value, delay])
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useIsMounted() {
|
||||
const [isMounted, setIsMounted] = useState(false)
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true)
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
|
||||
return () => {
|
||||
setIsMounted(false)
|
||||
}
|
||||
}, [])
|
||||
return () => {
|
||||
setIsMounted(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isMounted
|
||||
return isMounted;
|
||||
}
|
||||
|
||||
@@ -1,95 +1,95 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useSearchParams } from "react-router"
|
||||
import { useDebounce } from "./useDebounce"
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useDebounce } from "./useDebounce";
|
||||
|
||||
type Pagination = {
|
||||
page: number
|
||||
perPage: number
|
||||
totalPages: number
|
||||
}
|
||||
page: number;
|
||||
perPage: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
export function usePagination(
|
||||
initialState: Pagination = {
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
totalPages: 1,
|
||||
}
|
||||
initialState: Pagination = {
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
totalPages: 1,
|
||||
},
|
||||
) {
|
||||
return useState<Pagination>(initialState)
|
||||
return useState<Pagination>(initialState);
|
||||
}
|
||||
|
||||
type Key = "page" | "perPage" | "search" | "totalPages"
|
||||
type Key = "page" | "perPage" | "search" | "totalPages";
|
||||
|
||||
type Value<T extends Key> = T extends "page" | "perPage"
|
||||
? number
|
||||
: T extends "totalPages"
|
||||
? number | undefined
|
||||
: T extends "search"
|
||||
? string
|
||||
: never
|
||||
? number
|
||||
: T extends "totalPages"
|
||||
? number | undefined
|
||||
: T extends "search"
|
||||
? string
|
||||
: never;
|
||||
|
||||
type Options = {
|
||||
perPage?: number
|
||||
}
|
||||
perPage?: number;
|
||||
};
|
||||
|
||||
export function usePaginationWithQueryState(
|
||||
initialState: Options = {
|
||||
perPage: 10,
|
||||
}
|
||||
initialState: Options = {
|
||||
perPage: 10,
|
||||
},
|
||||
) {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [search, setSearch] = useState(() => searchParams.get("search") ?? "")
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [search, setSearch] = useState(() => searchParams.get("search") ?? "");
|
||||
|
||||
const debouncedSearch = useDebounce(search, 500)
|
||||
const debouncedSearch = useDebounce(search, 500);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchParams((prev) => {
|
||||
const search = prev.get("search") || ""
|
||||
const hasChanged = search !== debouncedSearch
|
||||
if (!hasChanged) return prev
|
||||
useEffect(() => {
|
||||
setSearchParams((prev) => {
|
||||
const search = prev.get("search") || "";
|
||||
const hasChanged = search !== debouncedSearch;
|
||||
if (!hasChanged) return prev;
|
||||
|
||||
if (!debouncedSearch) {
|
||||
prev.delete("page")
|
||||
prev.delete("search")
|
||||
} else {
|
||||
prev.set("search", debouncedSearch)
|
||||
prev.set("page", "1")
|
||||
}
|
||||
if (!debouncedSearch) {
|
||||
prev.delete("page");
|
||||
prev.delete("search");
|
||||
} else {
|
||||
prev.set("search", debouncedSearch);
|
||||
prev.set("page", "1");
|
||||
}
|
||||
|
||||
return prev
|
||||
})
|
||||
}, [debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
return prev;
|
||||
});
|
||||
}, [debouncedSearch]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const setPagination = <T extends Key>(key: T, value: Value<T>) => {
|
||||
if (value === undefined) return
|
||||
const setPagination = <T extends Key>(key: T, value: Value<T>) => {
|
||||
if (value === undefined) return;
|
||||
|
||||
if (key === "totalPages") {
|
||||
setTotalPages(value as number)
|
||||
return
|
||||
}
|
||||
if (key === "totalPages") {
|
||||
setTotalPages(value as number);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "search") {
|
||||
setSearch(value as string)
|
||||
return
|
||||
}
|
||||
if (key === "search") {
|
||||
setSearch(value as string);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchParams((prev) => {
|
||||
prev.set(key, value.toString())
|
||||
return prev
|
||||
})
|
||||
}
|
||||
setSearchParams((prev) => {
|
||||
prev.set(key, value.toString());
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
pagination: {
|
||||
page: parseInt(searchParams.get("page") ?? "1"),
|
||||
perPage: parseInt(
|
||||
searchParams.get("perPage") ?? initialState.perPage?.toString() ?? "10"
|
||||
),
|
||||
totalPages,
|
||||
search,
|
||||
searchQuery: searchParams.get("search") ?? "",
|
||||
},
|
||||
setPagination,
|
||||
}
|
||||
return {
|
||||
pagination: {
|
||||
page: parseInt(searchParams.get("page") ?? "1"),
|
||||
perPage: parseInt(
|
||||
searchParams.get("perPage") ?? initialState.perPage?.toString() ?? "10",
|
||||
),
|
||||
totalPages,
|
||||
search,
|
||||
searchQuery: searchParams.get("search") ?? "",
|
||||
},
|
||||
setPagination,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useSearchParams } from "react-router"
|
||||
import { useDebounceValue } from "usehooks-ts"
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useDebounceValue } from "usehooks-ts";
|
||||
|
||||
export function useQueryState(queryKey: string, delay = 500) {
|
||||
const [queryParams, setQueryParams] = useSearchParams()
|
||||
const [queryState, setQueryState] = useState(queryParams.get(queryKey) ?? "")
|
||||
const [debouncedQuery] = useDebounceValue(queryState, delay)
|
||||
const [queryParams, setQueryParams] = useSearchParams();
|
||||
const [queryState, setQueryState] = useState(queryParams.get(queryKey) ?? "");
|
||||
const [debouncedQuery] = useDebounceValue(queryState, delay);
|
||||
|
||||
const setQuery = useCallback((query: string) => {
|
||||
setQueryState(query)
|
||||
}, [])
|
||||
const setQuery = useCallback((query: string) => {
|
||||
setQueryState(query);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setQueryParams({ [queryKey]: debouncedQuery })
|
||||
}, [debouncedQuery, setQueryParams, queryKey])
|
||||
useEffect(() => {
|
||||
setQueryParams({ [queryKey]: debouncedQuery });
|
||||
}, [debouncedQuery, setQueryParams, queryKey]);
|
||||
|
||||
return {
|
||||
query: queryState,
|
||||
setQuery,
|
||||
search: queryParams.get(queryKey) ?? "",
|
||||
}
|
||||
return {
|
||||
query: queryState,
|
||||
setQuery,
|
||||
search: queryParams.get(queryKey) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
import { trpc } from "@/trpc"
|
||||
import Cookies from "js-cookie"
|
||||
import { useCallback, useEffect, useMemo } from "react"
|
||||
import { useNavigate } from "react-router"
|
||||
import { useLocalStorage } from "usehooks-ts"
|
||||
import { trpc } from "@/trpc";
|
||||
import Cookies from "js-cookie";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useLocalStorage } from "usehooks-ts";
|
||||
|
||||
export function useSession() {
|
||||
const [orgId, setOrgId, removeOrgId] = useLocalStorage("orgId", "")
|
||||
const user = trpc.user.me.useQuery()
|
||||
const [orgId, setOrgId, removeOrgId] = useLocalStorage("orgId", "");
|
||||
const user = trpc.user.me.useQuery();
|
||||
|
||||
const organization = useMemo(() => {
|
||||
return (
|
||||
user.data?.UserOrganizations.find((uo) => uo.organizationId === orgId)
|
||||
?.Organization || null
|
||||
)
|
||||
}, [user.data, orgId])
|
||||
const organization = useMemo(() => {
|
||||
return (
|
||||
user.data?.UserOrganizations.find((uo) => uo.organizationId === orgId)
|
||||
?.Organization || null
|
||||
);
|
||||
}, [user.data, orgId]);
|
||||
|
||||
const navigate = useNavigate()
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!user.isLoading && !user.data) {
|
||||
navigate("/")
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!user.isLoading && !user.data) {
|
||||
navigate("/");
|
||||
}
|
||||
|
||||
if (!orgId) {
|
||||
navigate("/")
|
||||
}
|
||||
}, [orgId, user.data, navigate, user.isLoading])
|
||||
if (!orgId) {
|
||||
navigate("/");
|
||||
}
|
||||
}, [orgId, user.data, navigate, user.isLoading]);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
removeOrgId()
|
||||
Cookies.remove("token")
|
||||
window.location.href = "/"
|
||||
}, [removeOrgId])
|
||||
const logout = useCallback(() => {
|
||||
removeOrgId();
|
||||
Cookies.remove("token");
|
||||
window.location.href = "/";
|
||||
}, [removeOrgId]);
|
||||
|
||||
return { orgId, setOrgId, user, organization, logout }
|
||||
return { orgId, setOrgId, user, organization, logout };
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ThemeProviderContext } from "@/components/theme-provider/theme-context"
|
||||
import { useContext } from "react"
|
||||
import { ThemeProviderContext } from "@/components/theme-provider/theme-context";
|
||||
import { useContext } from "react";
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider")
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
|
||||
return context
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function useUpdateEffect(effect: () => void, deps: unknown[]) {
|
||||
const hasMounted = useRef(false)
|
||||
const hasMounted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasMounted.current) {
|
||||
effect()
|
||||
} else {
|
||||
hasMounted.current = true
|
||||
}
|
||||
}, deps) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
if (hasMounted.current) {
|
||||
effect();
|
||||
} else {
|
||||
hasMounted.current = true;
|
||||
}
|
||||
}, deps); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}
|
||||
|
||||
@@ -3,112 +3,112 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
--radius: 0.5rem;
|
||||
--brand-primary: 177 59% 58%;
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
--radius: 0.5rem;
|
||||
--brand-primary: 177 59% 58%;
|
||||
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 90.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 90.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
}
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 2%;
|
||||
--foreground: 213 31% 91%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--popover: 224 71% 4%;
|
||||
--popover-foreground: 215 20.2% 65.1%;
|
||||
--border: 240 6.3% 25.1%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--card: 240 6.7% 8.8%;
|
||||
--card-foreground: 213 31% 91%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 1.2%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--ring: 240 6.3% 25.1%;
|
||||
--brand-primary: 177 59% 58%;
|
||||
.dark {
|
||||
--background: 0 0% 2%;
|
||||
--foreground: 213 31% 91%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--popover: 224 71% 4%;
|
||||
--popover-foreground: 215 20.2% 65.1%;
|
||||
--border: 240 6.3% 25.1%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--card: 240 6.7% 8.8%;
|
||||
--card-foreground: 213 31% 91%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 1.2%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--ring: 240 6.3% 25.1%;
|
||||
--brand-primary: 177 59% 58%;
|
||||
|
||||
--sidebar-background: 240 9.1% 6.5%;
|
||||
--sidebar-foreground: 213 31% 91%;
|
||||
--sidebar-primary: 240 4.6% 16%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 12.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
--sidebar-background: 240 9.1% 6.5%;
|
||||
--sidebar-foreground: 213 31% 91%;
|
||||
--sidebar-primary: 240 4.6% 16%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 12.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
|
||||
/* Hide autofill background color */
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
input:-webkit-autofill:active {
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
background: transparent !important;
|
||||
-webkit-text-fill-color: hsl(var(--foreground)) !important;
|
||||
-webkit-box-shadow: 0 0 0px 1000px transparent inset;
|
||||
box-shadow: 0 0 0px 1000px transparent inset;
|
||||
}
|
||||
/* Hide autofill background color */
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
input:-webkit-autofill:active {
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
background: transparent !important;
|
||||
-webkit-text-fill-color: hsl(var(--foreground)) !important;
|
||||
-webkit-box-shadow: 0 0 0px 1000px transparent inset;
|
||||
box-shadow: 0 0 0px 1000px transparent inset;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply font-sans antialiased bg-background text-foreground;
|
||||
}
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply font-sans antialiased bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.scroll-hidden {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.scroll-hidden {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.scroll-hidden::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.scroll-hidden::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import "./index.css"
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { App } from "./app.tsx"
|
||||
import { ErrorBoundary } from "./components/error-boundary"
|
||||
import { TrpcProvider } from "./trpc-provider.tsx"
|
||||
import { Toaster } from "sonner"
|
||||
import { ThemeProvider } from "./components/theme-provider/theme-provider.tsx"
|
||||
import "./index.css";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./app.tsx";
|
||||
import { ErrorBoundary } from "./components/error-boundary";
|
||||
import { TrpcProvider } from "./trpc-provider.tsx";
|
||||
import { Toaster } from "sonner";
|
||||
import { ThemeProvider } from "./components/theme-provider/theme-provider.tsx";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<TrpcProvider>
|
||||
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
||||
<App />
|
||||
<Toaster position="top-center" />
|
||||
</ThemeProvider>
|
||||
</TrpcProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>
|
||||
)
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<TrpcProvider>
|
||||
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
||||
<App />
|
||||
<Toaster position="top-center" />
|
||||
</ThemeProvider>
|
||||
</TrpcProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "./page"
|
||||
export * from "./page";
|
||||
|
||||
@@ -1,155 +1,155 @@
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
Button,
|
||||
} from "@repo/ui"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
import { trpc } from "@/trpc"
|
||||
import Cookies from "js-cookie"
|
||||
import { useNavigate } from "react-router"
|
||||
import { useLocalStorage } from "usehooks-ts"
|
||||
import { Eye, EyeOff, Mail } from "lucide-react"
|
||||
import { Input } from "@repo/ui"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
Button,
|
||||
} from "@repo/ui";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { trpc } from "@/trpc";
|
||||
import Cookies from "js-cookie";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useLocalStorage } from "usehooks-ts";
|
||||
import { Eye, EyeOff, Mail } from "lucide-react";
|
||||
import { Input } from "@repo/ui";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
})
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
export const Login = () => {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const hasCookie = useMemo(() => {
|
||||
return Cookies.get("token")
|
||||
}, [])
|
||||
const hasCookie = useMemo(() => {
|
||||
return Cookies.get("token");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasCookie) {
|
||||
navigate("/dashboard", { replace: true })
|
||||
}
|
||||
}, [hasCookie, navigate])
|
||||
useEffect(() => {
|
||||
if (hasCookie) {
|
||||
navigate("/dashboard", { replace: true });
|
||||
}
|
||||
}, [hasCookie, navigate]);
|
||||
|
||||
const loginForm = useForm<z.infer<typeof loginSchema>>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
})
|
||||
const loginForm = useForm<z.infer<typeof loginSchema>>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const login = trpc.user.login.useMutation()
|
||||
const [, setOrgId] = useLocalStorage("orgId", "")
|
||||
const login = trpc.user.login.useMutation();
|
||||
const [, setOrgId] = useLocalStorage("orgId", "");
|
||||
|
||||
/**
|
||||
* Handles user login form submission by authenticating credentials and managing post-login navigation.
|
||||
*
|
||||
* On successful authentication, stores the received token in a cookie and navigates the user to the dashboard if they belong to an organization, or to onboarding otherwise. Displays an error message on authentication failure.
|
||||
*
|
||||
* @param values - The email and password entered by the user.
|
||||
*/
|
||||
function onLoginSubmit(values: z.infer<typeof loginSchema>) {
|
||||
login.mutate(values, {
|
||||
onSuccess: (data) => {
|
||||
Cookies.set("token", data.token)
|
||||
if (data.user.UserOrganizations[0]) {
|
||||
setOrgId(data.user.UserOrganizations[0].organizationId)
|
||||
navigate("/dashboard")
|
||||
} else {
|
||||
navigate("/onboarding")
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
loginForm.setError("root", { message: error.message })
|
||||
},
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Handles user login form submission by authenticating credentials and managing post-login navigation.
|
||||
*
|
||||
* On successful authentication, stores the received token in a cookie and navigates the user to the dashboard if they belong to an organization, or to onboarding otherwise. Displays an error message on authentication failure.
|
||||
*
|
||||
* @param values - The email and password entered by the user.
|
||||
*/
|
||||
function onLoginSubmit(values: z.infer<typeof loginSchema>) {
|
||||
login.mutate(values, {
|
||||
onSuccess: (data) => {
|
||||
Cookies.set("token", data.token);
|
||||
if (data.user.UserOrganizations[0]) {
|
||||
setOrgId(data.user.UserOrganizations[0].organizationId);
|
||||
navigate("/dashboard");
|
||||
} else {
|
||||
navigate("/onboarding");
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
loginForm.setError("root", { message: error.message });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...loginForm}>
|
||||
<form
|
||||
onSubmit={loginForm.handleSubmit(onLoginSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<CardContent className="space-y-4">
|
||||
{loginForm.formState.errors.root && (
|
||||
<div className="text-sm text-destructive">
|
||||
{loginForm.formState.errors.root.message}
|
||||
</div>
|
||||
)}
|
||||
<FormField
|
||||
control={loginForm.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="m@example.com"
|
||||
type="email"
|
||||
className="pl-10"
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={loginForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<div className="relative">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="pr-10"
|
||||
placeholder="********"
|
||||
/>
|
||||
</FormControl>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-3 text-muted-foreground"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={login.isPending}
|
||||
disabled={login.isPending}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Form {...loginForm}>
|
||||
<form
|
||||
onSubmit={loginForm.handleSubmit(onLoginSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<CardContent className="space-y-4">
|
||||
{loginForm.formState.errors.root && (
|
||||
<div className="text-sm text-destructive">
|
||||
{loginForm.formState.errors.root.message}
|
||||
</div>
|
||||
)}
|
||||
<FormField
|
||||
control={loginForm.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="m@example.com"
|
||||
type="email"
|
||||
className="pl-10"
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={loginForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<div className="relative">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="pr-10"
|
||||
placeholder="********"
|
||||
/>
|
||||
</FormControl>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-3 text-muted-foreground"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={login.isPending}
|
||||
disabled={login.isPending}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { Card, CardHeader, CardTitle, CardDescription } from "@repo/ui"
|
||||
import { trpc } from "@/trpc"
|
||||
import { Signup } from "./signup"
|
||||
import { Login } from "./login"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { Card, CardHeader, CardTitle, CardDescription } from "@repo/ui";
|
||||
import { trpc } from "@/trpc";
|
||||
import { Signup } from "./signup";
|
||||
import { Login } from "./login";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export function AuthPage() {
|
||||
const { data: isFirstUser, isLoading } = trpc.user.isFirstUser.useQuery()
|
||||
const { data: isFirstUser, isLoading } = trpc.user.isFirstUser.useQuery();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-center">
|
||||
{isFirstUser ? "Create an account" : "Login"}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
{isFirstUser
|
||||
? "Enter your information to create your account"
|
||||
: "Enter your email and password to access your account"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-center">
|
||||
{isFirstUser ? "Create an account" : "Login"}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
{isFirstUser
|
||||
? "Enter your information to create your account"
|
||||
: "Enter your email and password to access your account"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
{isFirstUser ? <Signup /> : <Login />}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
{isFirstUser ? <Signup /> : <Login />}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,142 +1,142 @@
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
Button,
|
||||
} from "@repo/ui"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
import { trpc } from "@/trpc"
|
||||
import Cookies from "js-cookie"
|
||||
import { useNavigate } from "react-router"
|
||||
import { Eye, EyeOff, Mail, User } from "lucide-react"
|
||||
import { Input } from "@repo/ui"
|
||||
import { useState } from "react"
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
Button,
|
||||
} from "@repo/ui";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { trpc } from "@/trpc";
|
||||
import Cookies from "js-cookie";
|
||||
import { useNavigate } from "react-router";
|
||||
import { Eye, EyeOff, Mail, User } from "lucide-react";
|
||||
import { Input } from "@repo/ui";
|
||||
import { useState } from "react";
|
||||
|
||||
const signUpSchema = z.object({
|
||||
name: z.string().min(2),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
})
|
||||
name: z.string().min(2),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
export const Signup = () => {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const signupForm = useForm<z.infer<typeof signUpSchema>>({
|
||||
resolver: zodResolver(signUpSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
})
|
||||
const signup = trpc.user.signup.useMutation()
|
||||
function onSignupSubmit(values: z.infer<typeof signUpSchema>) {
|
||||
signup.mutate(values, {
|
||||
onSuccess(data) {
|
||||
Cookies.set("token", data.token)
|
||||
navigate("/onboarding")
|
||||
},
|
||||
})
|
||||
}
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const signupForm = useForm<z.infer<typeof signUpSchema>>({
|
||||
resolver: zodResolver(signUpSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
const signup = trpc.user.signup.useMutation();
|
||||
function onSignupSubmit(values: z.infer<typeof signUpSchema>) {
|
||||
signup.mutate(values, {
|
||||
onSuccess(data) {
|
||||
Cookies.set("token", data.token);
|
||||
navigate("/onboarding");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...signupForm}>
|
||||
<form
|
||||
onSubmit={signupForm.handleSubmit(onSignupSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={signupForm.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Full Name</FormLabel>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="John Doe"
|
||||
className="pl-10"
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={signupForm.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="john@example.com"
|
||||
type="email"
|
||||
className="pl-10"
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={signupForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<div className="relative">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="pr-10"
|
||||
placeholder="********"
|
||||
/>
|
||||
</FormControl>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-3 text-muted-foreground"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col space-y-4">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={signup.isPending}
|
||||
disabled={signup.isPending}
|
||||
>
|
||||
Create account
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Form {...signupForm}>
|
||||
<form
|
||||
onSubmit={signupForm.handleSubmit(onSignupSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={signupForm.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Full Name</FormLabel>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="John Doe"
|
||||
className="pl-10"
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={signupForm.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="john@example.com"
|
||||
type="email"
|
||||
className="pl-10"
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={signupForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<div className="relative">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="pr-10"
|
||||
placeholder="********"
|
||||
/>
|
||||
</FormControl>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-3 text-muted-foreground"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col space-y-4">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={signup.isPending}
|
||||
disabled={signup.isPending}
|
||||
>
|
||||
Create account
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "./page"
|
||||
export * from "./page";
|
||||
|
||||
@@ -1,344 +1,344 @@
|
||||
import { Line, LineChart } from "recharts"
|
||||
import { ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
|
||||
import { Line, LineChart } from "recharts";
|
||||
import { ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
BarChart2,
|
||||
Mail,
|
||||
MousePointerClick,
|
||||
Users,
|
||||
} from "lucide-react"
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
BarChart2,
|
||||
Mail,
|
||||
MousePointerClick,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@repo/ui"
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@repo/ui";
|
||||
|
||||
// Sample data for charts
|
||||
const subscriberData = [
|
||||
{ name: "Jan", count: 2400 },
|
||||
{ name: "Feb", count: 1398 },
|
||||
{ name: "Mar", count: 9800 },
|
||||
{ name: "Apr", count: 3908 },
|
||||
{ name: "May", count: 4800 },
|
||||
{ name: "Jun", count: 3800 },
|
||||
{ name: "Jul", count: 4300 },
|
||||
]
|
||||
{ name: "Jan", count: 2400 },
|
||||
{ name: "Feb", count: 1398 },
|
||||
{ name: "Mar", count: 9800 },
|
||||
{ name: "Apr", count: 3908 },
|
||||
{ name: "May", count: 4800 },
|
||||
{ name: "Jun", count: 3800 },
|
||||
{ name: "Jul", count: 4300 },
|
||||
];
|
||||
|
||||
const openRateData = [
|
||||
{ name: "Jan", rate: 45 },
|
||||
{ name: "Feb", rate: 52 },
|
||||
{ name: "Mar", rate: 49 },
|
||||
{ name: "Apr", rate: 63 },
|
||||
{ name: "May", rate: 58 },
|
||||
{ name: "Jun", rate: 48 },
|
||||
{ name: "Jul", rate: 54 },
|
||||
]
|
||||
{ name: "Jan", rate: 45 },
|
||||
{ name: "Feb", rate: 52 },
|
||||
{ name: "Mar", rate: 49 },
|
||||
{ name: "Apr", rate: 63 },
|
||||
{ name: "May", rate: 58 },
|
||||
{ name: "Jun", rate: 48 },
|
||||
{ name: "Jul", rate: 54 },
|
||||
];
|
||||
|
||||
const clickRateData = [
|
||||
{ name: "Jan", rate: 12 },
|
||||
{ name: "Feb", rate: 15 },
|
||||
{ name: "Mar", rate: 18 },
|
||||
{ name: "Apr", rate: 22 },
|
||||
{ name: "May", rate: 20 },
|
||||
{ name: "Jun", rate: 17 },
|
||||
{ name: "Jul", rate: 19 },
|
||||
]
|
||||
{ name: "Jan", rate: 12 },
|
||||
{ name: "Feb", rate: 15 },
|
||||
{ name: "Mar", rate: 18 },
|
||||
{ name: "Apr", rate: 22 },
|
||||
{ name: "May", rate: 20 },
|
||||
{ name: "Jun", rate: 17 },
|
||||
{ name: "Jul", rate: 19 },
|
||||
];
|
||||
|
||||
export function AnalyticsPage() {
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Analytics</h2>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select defaultValue="7d">
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Select a timeframe" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7d">Last 7 days</SelectItem>
|
||||
<SelectItem value="30d">Last 30 days</SelectItem>
|
||||
<SelectItem value="3m">Last 3 months</SelectItem>
|
||||
<SelectItem value="12m">Last 12 months</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button>Download Report</Button>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Analytics</h2>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select defaultValue="7d">
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Select a timeframe" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7d">Last 7 days</SelectItem>
|
||||
<SelectItem value="30d">Last 30 days</SelectItem>
|
||||
<SelectItem value="3m">Last 3 months</SelectItem>
|
||||
<SelectItem value="12m">Last 12 months</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button>Download Report</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="subscribers">Subscribers</TabsTrigger>
|
||||
<TabsTrigger value="engagement">Engagement</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Subscribers
|
||||
</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">14,247</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="text-emerald-500 inline-flex items-center">
|
||||
<ArrowUp className="mr-1 h-4 w-4" />
|
||||
+12.5%
|
||||
</span>{" "}
|
||||
from last month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Avg. Open Rate
|
||||
</CardTitle>
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">54.3%</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="text-emerald-500 inline-flex items-center">
|
||||
<ArrowUp className="mr-1 h-4 w-4" />
|
||||
+2.1%
|
||||
</span>{" "}
|
||||
from last month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Avg. Click Rate
|
||||
</CardTitle>
|
||||
<MousePointerClick className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">18.2%</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="text-rose-500 inline-flex items-center">
|
||||
<ArrowDown className="mr-1 h-4 w-4" />
|
||||
-0.3%
|
||||
</span>{" "}
|
||||
from last month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Campaigns
|
||||
</CardTitle>
|
||||
<BarChart2 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">243</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="text-emerald-500 inline-flex items-center">
|
||||
<ArrowUp className="mr-1 h-4 w-4" />
|
||||
+5
|
||||
</span>{" "}
|
||||
from last month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
<Card className="col-span-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Subscriber Growth</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pl-2">
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<LineChart data={subscriberData}>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}`}
|
||||
/>
|
||||
<Tooltip />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="#8884d8"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>Open Rates</CardTitle>
|
||||
<CardDescription>
|
||||
Average email open rates by month
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pl-2">
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<LineChart data={openRateData}>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
/>
|
||||
<Tooltip />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="rate"
|
||||
stroke="#82ca9d"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="subscribers" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Subscriber Growth</CardTitle>
|
||||
<CardDescription>New subscribers over time</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pl-2">
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<LineChart data={subscriberData}>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}`}
|
||||
/>
|
||||
<Tooltip />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="#8884d8"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="engagement" className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Open Rates</CardTitle>
|
||||
<CardDescription>
|
||||
Average email open rates by month
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pl-2">
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<LineChart data={openRateData}>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
/>
|
||||
<Tooltip />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="rate"
|
||||
stroke="#82ca9d"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Click Rates</CardTitle>
|
||||
<CardDescription>
|
||||
Average email click rates by month
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pl-2">
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<LineChart data={clickRateData}>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
/>
|
||||
<Tooltip />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="rate"
|
||||
stroke="#82ca9d"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
<Tabs defaultValue="overview" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="subscribers">Subscribers</TabsTrigger>
|
||||
<TabsTrigger value="engagement">Engagement</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Subscribers
|
||||
</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">14,247</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="text-emerald-500 inline-flex items-center">
|
||||
<ArrowUp className="mr-1 h-4 w-4" />
|
||||
+12.5%
|
||||
</span>{" "}
|
||||
from last month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Avg. Open Rate
|
||||
</CardTitle>
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">54.3%</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="text-emerald-500 inline-flex items-center">
|
||||
<ArrowUp className="mr-1 h-4 w-4" />
|
||||
+2.1%
|
||||
</span>{" "}
|
||||
from last month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Avg. Click Rate
|
||||
</CardTitle>
|
||||
<MousePointerClick className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">18.2%</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="text-rose-500 inline-flex items-center">
|
||||
<ArrowDown className="mr-1 h-4 w-4" />
|
||||
-0.3%
|
||||
</span>{" "}
|
||||
from last month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Campaigns
|
||||
</CardTitle>
|
||||
<BarChart2 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">243</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="text-emerald-500 inline-flex items-center">
|
||||
<ArrowUp className="mr-1 h-4 w-4" />
|
||||
+5
|
||||
</span>{" "}
|
||||
from last month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
<Card className="col-span-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Subscriber Growth</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pl-2">
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<LineChart data={subscriberData}>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}`}
|
||||
/>
|
||||
<Tooltip />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="#8884d8"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>Open Rates</CardTitle>
|
||||
<CardDescription>
|
||||
Average email open rates by month
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pl-2">
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<LineChart data={openRateData}>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
/>
|
||||
<Tooltip />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="rate"
|
||||
stroke="#82ca9d"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="subscribers" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Subscriber Growth</CardTitle>
|
||||
<CardDescription>New subscribers over time</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pl-2">
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<LineChart data={subscriberData}>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}`}
|
||||
/>
|
||||
<Tooltip />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="#8884d8"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="engagement" className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Open Rates</CardTitle>
|
||||
<CardDescription>
|
||||
Average email open rates by month
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pl-2">
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<LineChart data={openRateData}>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
/>
|
||||
<Tooltip />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="rate"
|
||||
stroke="#82ca9d"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Click Rates</CardTitle>
|
||||
<CardDescription>
|
||||
Average email click rates by month
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pl-2">
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<LineChart data={clickRateData}>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
/>
|
||||
<Tooltip />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="rate"
|
||||
stroke="#82ca9d"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,228 +1,228 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogTrigger,
|
||||
Button,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
Badge,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@repo/ui"
|
||||
import { X, AlertTriangle } from "lucide-react"
|
||||
import { useCampaignContext } from "./useCampaignContext"
|
||||
import { trpc } from "@/trpc"
|
||||
import { toast } from "sonner"
|
||||
import { useNavigate } from "react-router"
|
||||
import { toastError } from "@/utils"
|
||||
import { useSession } from "@/hooks"
|
||||
import { useParams } from "react-router"
|
||||
import { useMemo, useState } from "react"
|
||||
AlertDialog,
|
||||
AlertDialogTrigger,
|
||||
Button,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
Badge,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@repo/ui";
|
||||
import { X, AlertTriangle } from "lucide-react";
|
||||
import { useCampaignContext } from "./useCampaignContext";
|
||||
import { trpc } from "@/trpc";
|
||||
import { toast } from "sonner";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toastError } from "@/utils";
|
||||
import { useSession } from "@/hooks";
|
||||
import { useParams } from "react-router";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export const CampaignActions = () => {
|
||||
const { campaignQuery, form } = useCampaignContext()
|
||||
const navigate = useNavigate()
|
||||
const { organization } = useSession()
|
||||
const { id } = useParams()
|
||||
const [showUnsubscribeWarning, setShowUnsubscribeWarning] = useState(false)
|
||||
const recipientCount = campaignQuery.data?.campaign.uniqueRecipientCount || 0
|
||||
const { campaignQuery, form } = useCampaignContext();
|
||||
const navigate = useNavigate();
|
||||
const { organization } = useSession();
|
||||
const { id } = useParams();
|
||||
const [showUnsubscribeWarning, setShowUnsubscribeWarning] = useState(false);
|
||||
const recipientCount = campaignQuery.data?.campaign.uniqueRecipientCount || 0;
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const startCampaignMutation = trpc.campaign.start.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Campaign started successfully")
|
||||
utils.campaign.invalidate()
|
||||
utils.message.list.invalidate()
|
||||
navigate("/dashboard/campaigns")
|
||||
},
|
||||
onError: (error) => {
|
||||
toastError("Error starting campaign", error)
|
||||
},
|
||||
})
|
||||
const utils = trpc.useUtils();
|
||||
const startCampaignMutation = trpc.campaign.start.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Campaign started successfully");
|
||||
utils.campaign.invalidate();
|
||||
utils.message.list.invalidate();
|
||||
navigate("/dashboard/campaigns");
|
||||
},
|
||||
onError: (error) => {
|
||||
toastError("Error starting campaign", error);
|
||||
},
|
||||
});
|
||||
|
||||
const cancelCampaignMutation = trpc.campaign.cancel.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Campaign cancelled successfully")
|
||||
utils.campaign.get.invalidate()
|
||||
},
|
||||
onError: (error) => {
|
||||
toastError("Error cancelling campaign", error)
|
||||
},
|
||||
})
|
||||
const cancelCampaignMutation = trpc.campaign.cancel.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Campaign cancelled successfully");
|
||||
utils.campaign.get.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toastError("Error cancelling campaign", error);
|
||||
},
|
||||
});
|
||||
|
||||
const updateCampaign = trpc.campaign.update.useMutation()
|
||||
const updateCampaign = trpc.campaign.update.useMutation();
|
||||
|
||||
const handleSubmitCampaign = () => {
|
||||
if (!organization?.id || !id) return
|
||||
const handleSubmitCampaign = () => {
|
||||
if (!organization?.id || !id) return;
|
||||
|
||||
form.handleSubmit((values) => {
|
||||
updateCampaign.mutate(
|
||||
{
|
||||
id,
|
||||
organizationId: organization.id,
|
||||
...values,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
startCampaignMutation.mutate({
|
||||
id,
|
||||
organizationId: organization.id,
|
||||
})
|
||||
},
|
||||
}
|
||||
)
|
||||
})()
|
||||
}
|
||||
form.handleSubmit((values) => {
|
||||
updateCampaign.mutate(
|
||||
{
|
||||
id,
|
||||
organizationId: organization.id,
|
||||
...values,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
startCampaignMutation.mutate({
|
||||
id,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
})();
|
||||
};
|
||||
|
||||
const content = form.watch("content") || ""
|
||||
const content = form.watch("content") || "";
|
||||
|
||||
const finalContent = useMemo(() => {
|
||||
const template =
|
||||
campaignQuery.data?.campaign.Template?.content || "{{content}}"
|
||||
const final = template.replace(/{{content}}/g, content)
|
||||
return final
|
||||
}, [campaignQuery.data?.campaign.Template?.content, content])
|
||||
const finalContent = useMemo(() => {
|
||||
const template =
|
||||
campaignQuery.data?.campaign.Template?.content || "{{content}}";
|
||||
const final = template.replace(/{{content}}/g, content);
|
||||
return final;
|
||||
}, [campaignQuery.data?.campaign.Template?.content, content]);
|
||||
|
||||
const hasUnsubscribeLink = useMemo(
|
||||
() => finalContent.includes("{{unsubscribe_link}}"),
|
||||
[finalContent]
|
||||
)
|
||||
const hasUnsubscribeLink = useMemo(
|
||||
() => finalContent.includes("{{unsubscribe_link}}"),
|
||||
[finalContent],
|
||||
);
|
||||
|
||||
const handleStartCampaign = () => {
|
||||
if (!hasUnsubscribeLink) {
|
||||
setShowUnsubscribeWarning(true)
|
||||
return
|
||||
}
|
||||
const handleStartCampaign = () => {
|
||||
if (!hasUnsubscribeLink) {
|
||||
setShowUnsubscribeWarning(true);
|
||||
return;
|
||||
}
|
||||
|
||||
handleSubmitCampaign()
|
||||
}
|
||||
handleSubmitCampaign();
|
||||
};
|
||||
|
||||
const hasNoTemplateSelected = campaignQuery.data?.campaign.Template === null
|
||||
const hasNoTemplateSelected = campaignQuery.data?.campaign.Template === null;
|
||||
|
||||
const warning = useMemo(() => {
|
||||
if (hasNoTemplateSelected) {
|
||||
return "No template selected"
|
||||
}
|
||||
const warning = useMemo(() => {
|
||||
if (hasNoTemplateSelected) {
|
||||
return "No template selected";
|
||||
}
|
||||
|
||||
return "Missing unsubscribe link in email content"
|
||||
}, [hasNoTemplateSelected])
|
||||
return "Missing unsubscribe link in email content";
|
||||
}, [hasNoTemplateSelected]);
|
||||
|
||||
const handleCancelCampaign = () => {
|
||||
if (!organization?.id || !id) return
|
||||
const handleCancelCampaign = () => {
|
||||
if (!organization?.id || !id) return;
|
||||
|
||||
cancelCampaignMutation.mutate({
|
||||
id,
|
||||
organizationId: organization.id,
|
||||
})
|
||||
}
|
||||
cancelCampaignMutation.mutate({
|
||||
id,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
};
|
||||
|
||||
const startCampaignDisabled =
|
||||
startCampaignMutation.isPending ||
|
||||
updateCampaign.isPending ||
|
||||
form.formState.isDirty
|
||||
const startCampaignDisabled =
|
||||
startCampaignMutation.isPending ||
|
||||
updateCampaign.isPending ||
|
||||
form.formState.isDirty;
|
||||
|
||||
switch (campaignQuery.data?.campaign?.status) {
|
||||
case "DRAFT":
|
||||
return (
|
||||
<>
|
||||
<AlertDialog>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{recipientCount} recipients</Badge>
|
||||
{!hasUnsubscribeLink && (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={50}>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-500 cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="cursor-pointer">
|
||||
<p>{warning}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
disabled={startCampaignDisabled}
|
||||
loading={startCampaignMutation.isPending}
|
||||
>
|
||||
Start Campaign
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
</div>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Start Campaign</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to start this campaign? This will begin
|
||||
sending emails to all selected subscribers.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleStartCampaign}>
|
||||
Start Campaign
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
switch (campaignQuery.data?.campaign?.status) {
|
||||
case "DRAFT":
|
||||
return (
|
||||
<>
|
||||
<AlertDialog>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{recipientCount} recipients</Badge>
|
||||
{!hasUnsubscribeLink && (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={50}>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-500 cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="cursor-pointer">
|
||||
<p>{warning}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
disabled={startCampaignDisabled}
|
||||
loading={startCampaignMutation.isPending}
|
||||
>
|
||||
Start Campaign
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
</div>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Start Campaign</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to start this campaign? This will begin
|
||||
sending emails to all selected subscribers.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleStartCampaign}>
|
||||
Start Campaign
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog
|
||||
open={showUnsubscribeWarning}
|
||||
onOpenChange={setShowUnsubscribeWarning}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Missing Unsubscribe Link</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Your email content does not include an unsubscribe link. It's
|
||||
recommended to add {`{{unsubscribe_link}}`} to your template
|
||||
or content. Do you want to continue anyway?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setShowUnsubscribeWarning(false)
|
||||
handleSubmitCampaign()
|
||||
}}
|
||||
>
|
||||
Continue Anyway
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
case "CREATING":
|
||||
case "SENDING":
|
||||
case "SCHEDULED":
|
||||
return (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleCancelCampaign}
|
||||
disabled={cancelCampaignMutation.isPending}
|
||||
>
|
||||
{cancelCampaignMutation.isPending ? (
|
||||
<>
|
||||
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
Cancelling...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Cancel Campaign
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
<AlertDialog
|
||||
open={showUnsubscribeWarning}
|
||||
onOpenChange={setShowUnsubscribeWarning}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Missing Unsubscribe Link</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Your email content does not include an unsubscribe link. It's
|
||||
recommended to add {`{{unsubscribe_link}}`} to your template
|
||||
or content. Do you want to continue anyway?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setShowUnsubscribeWarning(false);
|
||||
handleSubmitCampaign();
|
||||
}}
|
||||
>
|
||||
Continue Anyway
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
case "CREATING":
|
||||
case "SENDING":
|
||||
case "SCHEDULED":
|
||||
return (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleCancelCampaign}
|
||||
disabled={cancelCampaignMutation.isPending}
|
||||
>
|
||||
{cancelCampaignMutation.isPending ? (
|
||||
<>
|
||||
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
Cancelling...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Cancel Campaign
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { createContext } from "react"
|
||||
import { UseFormReturn } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
import { campaignSchema, UpdateCampaignOptions } from "./schema"
|
||||
import { AppRouter } from "backend"
|
||||
import { GetTRPCQueryResult } from "@/types"
|
||||
import { createContext } from "react";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { campaignSchema, UpdateCampaignOptions } from "./schema";
|
||||
import { AppRouter } from "backend";
|
||||
import { GetTRPCQueryResult } from "@/types";
|
||||
|
||||
type CampaignContextType = {
|
||||
form: UseFormReturn<z.infer<typeof campaignSchema>>
|
||||
campaignQuery: GetTRPCQueryResult<AppRouter["campaign"]["get"]>
|
||||
isEditable: boolean
|
||||
updateCampaign: (options?: UpdateCampaignOptions) => void
|
||||
updatePending: boolean
|
||||
}
|
||||
form: UseFormReturn<z.infer<typeof campaignSchema>>;
|
||||
campaignQuery: GetTRPCQueryResult<AppRouter["campaign"]["get"]>;
|
||||
isEditable: boolean;
|
||||
updateCampaign: (options?: UpdateCampaignOptions) => void;
|
||||
updatePending: boolean;
|
||||
};
|
||||
|
||||
export const CampaignContext = createContext<CampaignContextType | null>(null)
|
||||
export const CampaignContext = createContext<CampaignContextType | null>(null);
|
||||
|
||||
@@ -1,122 +1,122 @@
|
||||
import { Button, Input } from "@repo/ui"
|
||||
import { useCampaignContext } from "./useCampaignContext"
|
||||
import { toast } from "sonner"
|
||||
import { Send } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { trpc } from "@/trpc"
|
||||
import { useSession } from "@/hooks"
|
||||
import { useParams } from "react-router"
|
||||
import { Button, Input } from "@repo/ui";
|
||||
import { useCampaignContext } from "./useCampaignContext";
|
||||
import { toast } from "sonner";
|
||||
import { Send } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { trpc } from "@/trpc";
|
||||
import { useSession } from "@/hooks";
|
||||
import { useParams } from "react-router";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@repo/ui"
|
||||
import { useLocalStorage } from "usehooks-ts"
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@repo/ui";
|
||||
import { useLocalStorage } from "usehooks-ts";
|
||||
|
||||
export const EditorActions = () => {
|
||||
const { updatePending, isEditable, updateCampaign, form } =
|
||||
useCampaignContext()
|
||||
const [testEmail, setTestEmail] = useLocalStorage("test-smtp-email", "")
|
||||
const [isTestDialogOpen, setIsTestDialogOpen] = useState(false)
|
||||
const { organization } = useSession()
|
||||
const { id } = useParams()
|
||||
const { updatePending, isEditable, updateCampaign, form } =
|
||||
useCampaignContext();
|
||||
const [testEmail, setTestEmail] = useLocalStorage("test-smtp-email", "");
|
||||
const [isTestDialogOpen, setIsTestDialogOpen] = useState(false);
|
||||
const { organization } = useSession();
|
||||
const { id } = useParams();
|
||||
|
||||
const testEmailMutation = trpc.campaign.sendTestEmail.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Test email sent successfully")
|
||||
setIsTestDialogOpen(false)
|
||||
setTestEmail("")
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
const testEmailMutation = trpc.campaign.sendTestEmail.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Test email sent successfully");
|
||||
setIsTestDialogOpen(false);
|
||||
setTestEmail("");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const onSave = () => {
|
||||
updateCampaign({
|
||||
onSuccess() {
|
||||
toast.success("Campaign updated successfully")
|
||||
},
|
||||
})
|
||||
}
|
||||
const onSave = () => {
|
||||
updateCampaign({
|
||||
onSuccess() {
|
||||
toast.success("Campaign updated successfully");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSendTest = () => {
|
||||
if (!organization?.id || !id) return
|
||||
const handleSendTest = () => {
|
||||
if (!organization?.id || !id) return;
|
||||
|
||||
testEmailMutation.mutate({
|
||||
campaignId: id,
|
||||
organizationId: organization.id,
|
||||
email: testEmail,
|
||||
})
|
||||
}
|
||||
testEmailMutation.mutate({
|
||||
campaignId: id,
|
||||
organizationId: organization.id,
|
||||
email: testEmail,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditable && (
|
||||
<>
|
||||
{form.formState.isDirty && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
You have unsaved changes
|
||||
</span>
|
||||
)}
|
||||
<Dialog open={isTestDialogOpen} onOpenChange={setIsTestDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline">
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
Send Test
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Send Test Email</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send a test email to verify your campaign content
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Enter email address"
|
||||
value={testEmail}
|
||||
onChange={(e) => setTestEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsTestDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSendTest}
|
||||
disabled={!testEmail || testEmailMutation.isPending}
|
||||
loading={testEmailMutation.isPending}
|
||||
>
|
||||
Send Test
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditable && (
|
||||
<>
|
||||
{form.formState.isDirty && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
You have unsaved changes
|
||||
</span>
|
||||
)}
|
||||
<Dialog open={isTestDialogOpen} onOpenChange={setIsTestDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline">
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
Send Test
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Send Test Email</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send a test email to verify your campaign content
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Enter email address"
|
||||
value={testEmail}
|
||||
onChange={(e) => setTestEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsTestDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSendTest}
|
||||
disabled={!testEmail || testEmailMutation.isPending}
|
||||
loading={testEmailMutation.isPending}
|
||||
>
|
||||
Send Test
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Button type="button" onClick={onSave} disabled={updatePending}>
|
||||
{updatePending ? (
|
||||
<>
|
||||
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
"Save Changes"
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button type="button" onClick={onSave} disabled={updatePending}>
|
||||
{updatePending ? (
|
||||
<>
|
||||
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
"Save Changes"
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* <Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
|
||||
{/* <Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
@@ -130,6 +130,6 @@ export const EditorActions = () => {
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog> */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./page"
|
||||
export * from "./layout"
|
||||
export * from "./page";
|
||||
export * from "./layout";
|
||||
|
||||
@@ -1,100 +1,100 @@
|
||||
import { CampaignContext } from "./context"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
import { campaignSchema, UpdateCampaignOptions } from "./schema"
|
||||
import { useParams } from "react-router"
|
||||
import { useSession } from "@/hooks"
|
||||
import { trpc } from "@/trpc"
|
||||
import { useCallback } from "react"
|
||||
import { toastError } from "@/utils"
|
||||
import { CampaignContext } from "./context";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { campaignSchema, UpdateCampaignOptions } from "./schema";
|
||||
import { useParams } from "react-router";
|
||||
import { useSession } from "@/hooks";
|
||||
import { trpc } from "@/trpc";
|
||||
import { useCallback } from "react";
|
||||
import { toastError } from "@/utils";
|
||||
|
||||
export const EditCampaignLayout: React.FC<{
|
||||
children: React.ReactNode
|
||||
children: React.ReactNode;
|
||||
}> = ({ children }) => {
|
||||
const { id } = useParams()
|
||||
const { orgId } = useSession()
|
||||
const utils = trpc.useUtils()
|
||||
const { id } = useParams();
|
||||
const { orgId } = useSession();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const campaignQuery = trpc.campaign.get.useQuery(
|
||||
{
|
||||
id: id ?? "",
|
||||
organizationId: orgId ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!id && !!orgId,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
}
|
||||
)
|
||||
const campaignQuery = trpc.campaign.get.useQuery(
|
||||
{
|
||||
id: id ?? "",
|
||||
organizationId: orgId ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!id && !!orgId,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
);
|
||||
|
||||
const form = useForm<z.infer<typeof campaignSchema>>({
|
||||
resolver: zodResolver(campaignSchema),
|
||||
values: {
|
||||
title: campaignQuery.data?.campaign?.title ?? "",
|
||||
description: campaignQuery.data?.campaign?.description ?? "",
|
||||
subject: campaignQuery.data?.campaign?.subject ?? "",
|
||||
templateId: campaignQuery.data?.campaign?.templateId ?? "",
|
||||
listIds:
|
||||
campaignQuery.data?.campaign?.CampaignLists?.map(
|
||||
(list) => list.listId
|
||||
) ?? [],
|
||||
openTracking: campaignQuery.data?.campaign?.openTracking ?? false,
|
||||
content: campaignQuery.data?.campaign?.content ?? "",
|
||||
},
|
||||
})
|
||||
const form = useForm<z.infer<typeof campaignSchema>>({
|
||||
resolver: zodResolver(campaignSchema),
|
||||
values: {
|
||||
title: campaignQuery.data?.campaign?.title ?? "",
|
||||
description: campaignQuery.data?.campaign?.description ?? "",
|
||||
subject: campaignQuery.data?.campaign?.subject ?? "",
|
||||
templateId: campaignQuery.data?.campaign?.templateId ?? "",
|
||||
listIds:
|
||||
campaignQuery.data?.campaign?.CampaignLists?.map(
|
||||
(list) => list.listId,
|
||||
) ?? [],
|
||||
openTracking: campaignQuery.data?.campaign?.openTracking ?? false,
|
||||
content: campaignQuery.data?.campaign?.content ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const isEditable = campaignQuery.data?.campaign?.status === "DRAFT"
|
||||
const isEditable = campaignQuery.data?.campaign?.status === "DRAFT";
|
||||
|
||||
const updateCampaignMutation = trpc.campaign.update.useMutation()
|
||||
const updateCampaignMutation = trpc.campaign.update.useMutation();
|
||||
|
||||
const updateCampaign = useCallback(
|
||||
(options: UpdateCampaignOptions = {}) => {
|
||||
if (!orgId || !id) return
|
||||
const updateCampaign = useCallback(
|
||||
(options: UpdateCampaignOptions = {}) => {
|
||||
if (!orgId || !id) return;
|
||||
|
||||
const values = form.getValues()
|
||||
const values = form.getValues();
|
||||
|
||||
updateCampaignMutation.mutate(
|
||||
{
|
||||
id,
|
||||
organizationId: orgId,
|
||||
...values,
|
||||
templateId: values.templateId === "" ? null : values.templateId,
|
||||
},
|
||||
{
|
||||
onSuccess({ campaign }) {
|
||||
form.reset({
|
||||
content: campaign.content || "",
|
||||
description: campaign.description || "",
|
||||
listIds: campaign.CampaignLists.map((list) => list.listId),
|
||||
openTracking: campaign.openTracking,
|
||||
subject: campaign.subject || "",
|
||||
templateId: campaign.templateId || "",
|
||||
title: campaign.title,
|
||||
})
|
||||
updateCampaignMutation.mutate(
|
||||
{
|
||||
id,
|
||||
organizationId: orgId,
|
||||
...values,
|
||||
templateId: values.templateId === "" ? null : values.templateId,
|
||||
},
|
||||
{
|
||||
onSuccess({ campaign }) {
|
||||
form.reset({
|
||||
content: campaign.content || "",
|
||||
description: campaign.description || "",
|
||||
listIds: campaign.CampaignLists.map((list) => list.listId),
|
||||
openTracking: campaign.openTracking,
|
||||
subject: campaign.subject || "",
|
||||
templateId: campaign.templateId || "",
|
||||
title: campaign.title,
|
||||
});
|
||||
|
||||
utils.campaign.get.invalidate()
|
||||
options.onSuccess?.()
|
||||
},
|
||||
onError(error) {
|
||||
toastError("Error updating campaign", error)
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
[id, orgId, updateCampaignMutation, form, utils]
|
||||
)
|
||||
utils.campaign.get.invalidate();
|
||||
options.onSuccess?.();
|
||||
},
|
||||
onError(error) {
|
||||
toastError("Error updating campaign", error);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[id, orgId, updateCampaignMutation, form, utils],
|
||||
);
|
||||
|
||||
return (
|
||||
<CampaignContext.Provider
|
||||
value={{
|
||||
form,
|
||||
campaignQuery,
|
||||
isEditable,
|
||||
updateCampaign,
|
||||
updatePending: updateCampaignMutation.isPending,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CampaignContext.Provider>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<CampaignContext.Provider
|
||||
value={{
|
||||
form,
|
||||
campaignQuery,
|
||||
isEditable,
|
||||
updateCampaign,
|
||||
updatePending: updateCampaignMutation.isPending,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CampaignContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import { Button } from "@repo/ui"
|
||||
import { useNavigate } from "react-router"
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Button } from "@repo/ui";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
export const Loading = () => {
|
||||
const navigate = useNavigate()
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate("/dashboard/campaigns")}
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Loading Campaign...</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate("/dashboard/campaigns")}
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Loading Campaign...</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import { Button } from "@repo/ui"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import { useNavigate } from "react-router"
|
||||
import { Button } from "@repo/ui";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
export const NotFound = () => {
|
||||
const navigate = useNavigate()
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate("/dashboard/campaigns")}
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-destructive">
|
||||
Campaign Not Found
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate("/dashboard/campaigns")}
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-destructive">
|
||||
Campaign Not Found
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,111 +1,111 @@
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import { useNavigate } from "react-router"
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useNavigate } from "react-router";
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@repo/ui"
|
||||
import { useEffect } from "react"
|
||||
import { EditorTab } from "./tabs/editor-tab/editor-tab"
|
||||
import { useCampaignContext } from "./useCampaignContext"
|
||||
import { NotFound } from "./not-found"
|
||||
import { Loading } from "./loading"
|
||||
import { Stats } from "./stats"
|
||||
import { SettingsTab } from "./tabs/settings-tab"
|
||||
import { EditorActions } from "./editor-actions"
|
||||
import { CampaignActions } from "./campaign-actions"
|
||||
Button,
|
||||
Form,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@repo/ui";
|
||||
import { useEffect } from "react";
|
||||
import { EditorTab } from "./tabs/editor-tab/editor-tab";
|
||||
import { useCampaignContext } from "./useCampaignContext";
|
||||
import { NotFound } from "./not-found";
|
||||
import { Loading } from "./loading";
|
||||
import { Stats } from "./stats";
|
||||
import { SettingsTab } from "./tabs/settings-tab";
|
||||
import { EditorActions } from "./editor-actions";
|
||||
import { CampaignActions } from "./campaign-actions";
|
||||
|
||||
export function EditCampaignPage() {
|
||||
const navigate = useNavigate()
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
campaignQuery: { data: campaign, isLoading: isCampaignLoading },
|
||||
updateCampaign,
|
||||
form,
|
||||
isEditable,
|
||||
} = useCampaignContext()
|
||||
const {
|
||||
campaignQuery: { data: campaign, isLoading: isCampaignLoading },
|
||||
updateCampaign,
|
||||
form,
|
||||
isEditable,
|
||||
} = useCampaignContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (campaign?.campaign) {
|
||||
form.reset({
|
||||
title: campaign.campaign.title,
|
||||
description: campaign.campaign.description ?? "",
|
||||
subject: campaign.campaign.subject ?? "",
|
||||
templateId: campaign.campaign.templateId ?? "",
|
||||
listIds: campaign.campaign.CampaignLists?.map((cl) => cl.List.id) ?? [],
|
||||
openTracking: campaign.campaign.openTracking ?? true,
|
||||
content: campaign.campaign.content ?? "",
|
||||
})
|
||||
}
|
||||
}, [campaign]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
if (campaign?.campaign) {
|
||||
form.reset({
|
||||
title: campaign.campaign.title,
|
||||
description: campaign.campaign.description ?? "",
|
||||
subject: campaign.campaign.subject ?? "",
|
||||
templateId: campaign.campaign.templateId ?? "",
|
||||
listIds: campaign.campaign.CampaignLists?.map((cl) => cl.List.id) ?? [],
|
||||
openTracking: campaign.campaign.openTracking ?? true,
|
||||
content: campaign.campaign.content ?? "",
|
||||
});
|
||||
}
|
||||
}, [campaign]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (isCampaignLoading) {
|
||||
return <Loading />
|
||||
}
|
||||
if (isCampaignLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (!campaign) {
|
||||
return <NotFound />
|
||||
}
|
||||
if (!campaign) {
|
||||
return <NotFound />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate(-1)}
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">
|
||||
{isEditable ? "Edit Campaign" : campaign.campaign?.title}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{isEditable
|
||||
? "Configure your campaign settings"
|
||||
: `Campaign sent on ${new Date(
|
||||
campaign.campaign?.createdAt ?? ""
|
||||
).toLocaleDateString()}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<CampaignActions />
|
||||
</div>
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate(-1)}
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">
|
||||
{isEditable ? "Edit Campaign" : campaign.campaign?.title}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{isEditable
|
||||
? "Configure your campaign settings"
|
||||
: `Campaign sent on ${new Date(
|
||||
campaign.campaign?.createdAt ?? "",
|
||||
).toLocaleDateString()}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<CampaignActions />
|
||||
</div>
|
||||
|
||||
{!isEditable ? (
|
||||
<Stats />
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(() => updateCampaign())}
|
||||
className="space-y-4"
|
||||
>
|
||||
<Tabs defaultValue="settings">
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||
<TabsTrigger value="editor">Content Editor</TabsTrigger>
|
||||
</TabsList>
|
||||
{isEditable && <EditorActions />}
|
||||
</div>
|
||||
{!isEditable ? (
|
||||
<Stats />
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(() => updateCampaign())}
|
||||
className="space-y-4"
|
||||
>
|
||||
<Tabs defaultValue="settings">
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||
<TabsTrigger value="editor">Content Editor</TabsTrigger>
|
||||
</TabsList>
|
||||
{isEditable && <EditorActions />}
|
||||
</div>
|
||||
|
||||
<TabsContent value="settings">
|
||||
<SettingsTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="settings">
|
||||
<SettingsTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="editor">
|
||||
<EditorTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
<TabsContent value="editor">
|
||||
<EditorTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { z } from "zod"
|
||||
import { z } from "zod";
|
||||
|
||||
export const campaignSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
subject: z.string().optional(),
|
||||
templateId: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((val) => (val === "" ? null : val)),
|
||||
listIds: z.array(z.string()),
|
||||
content: z.string().optional(),
|
||||
openTracking: z.boolean().optional(),
|
||||
})
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
subject: z.string().optional(),
|
||||
templateId: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((val) => (val === "" ? null : val)),
|
||||
listIds: z.array(z.string()),
|
||||
content: z.string().optional(),
|
||||
openTracking: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type UpdateCampaignOptions = {
|
||||
onSuccess?: () => void
|
||||
}
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
@@ -1,240 +1,240 @@
|
||||
import {
|
||||
Badge,
|
||||
Progress,
|
||||
DataTable,
|
||||
Card,
|
||||
CardContent,
|
||||
CardTitle,
|
||||
CardHeader,
|
||||
} from "@repo/ui"
|
||||
import { Mail } from "lucide-react"
|
||||
import { Link, useParams } from "react-router"
|
||||
import { trpc } from "@/trpc"
|
||||
import { useSession, usePaginationWithQueryState } from "@/hooks"
|
||||
import { useState, useEffect } from "react"
|
||||
import { columns } from "../../messages/columns"
|
||||
import { Pagination } from "@/components"
|
||||
Badge,
|
||||
Progress,
|
||||
DataTable,
|
||||
Card,
|
||||
CardContent,
|
||||
CardTitle,
|
||||
CardHeader,
|
||||
} from "@repo/ui";
|
||||
import { Mail } from "lucide-react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { trpc } from "@/trpc";
|
||||
import { useSession, usePaginationWithQueryState } from "@/hooks";
|
||||
import { useState, useEffect } from "react";
|
||||
import { columns } from "../../messages/columns";
|
||||
import { Pagination } from "@/components";
|
||||
|
||||
export const Stats = () => {
|
||||
const { id } = useParams()
|
||||
const { orgId } = useSession()
|
||||
const [openPreviews, setOpenPreviews] = useState<Record<string, boolean>>({})
|
||||
const [openErrors, setOpenErrors] = useState<Record<string, boolean>>({})
|
||||
const { pagination, setPagination } = usePaginationWithQueryState({
|
||||
perPage: 100,
|
||||
})
|
||||
const { id } = useParams();
|
||||
const { orgId } = useSession();
|
||||
const [openPreviews, setOpenPreviews] = useState<Record<string, boolean>>({});
|
||||
const [openErrors, setOpenErrors] = useState<Record<string, boolean>>({});
|
||||
const { pagination, setPagination } = usePaginationWithQueryState({
|
||||
perPage: 100,
|
||||
});
|
||||
|
||||
const { data: campaign } = trpc.campaign.get.useQuery(
|
||||
{
|
||||
id: id ?? "",
|
||||
organizationId: orgId ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!id && !!orgId,
|
||||
}
|
||||
)
|
||||
const { data: campaign } = trpc.campaign.get.useQuery(
|
||||
{
|
||||
id: id ?? "",
|
||||
organizationId: orgId ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!id && !!orgId,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: messages, isLoading } = trpc.message.list.useQuery(
|
||||
{
|
||||
organizationId: orgId ?? "",
|
||||
campaignId: id ?? "",
|
||||
page: pagination.page,
|
||||
perPage: pagination.perPage,
|
||||
},
|
||||
{
|
||||
enabled: !!id && !!orgId,
|
||||
}
|
||||
)
|
||||
const { data: messages, isLoading } = trpc.message.list.useQuery(
|
||||
{
|
||||
organizationId: orgId ?? "",
|
||||
campaignId: id ?? "",
|
||||
page: pagination.page,
|
||||
perPage: pagination.perPage,
|
||||
},
|
||||
{
|
||||
enabled: !!id && !!orgId,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPagination("totalPages", messages?.pagination.totalPages)
|
||||
}, [messages, setPagination])
|
||||
useEffect(() => {
|
||||
setPagination("totalPages", messages?.pagination.totalPages);
|
||||
}, [messages, setPagination]);
|
||||
|
||||
const handleOpenPreview = (messageId: string) => {
|
||||
setOpenPreviews((prev) => ({ ...prev, [messageId]: true }))
|
||||
}
|
||||
const handleOpenPreview = (messageId: string) => {
|
||||
setOpenPreviews((prev) => ({ ...prev, [messageId]: true }));
|
||||
};
|
||||
|
||||
const handleClosePreview = (messageId: string) => {
|
||||
setOpenPreviews((prev) => ({ ...prev, [messageId]: false }))
|
||||
}
|
||||
const handleClosePreview = (messageId: string) => {
|
||||
setOpenPreviews((prev) => ({ ...prev, [messageId]: false }));
|
||||
};
|
||||
|
||||
const handleOpenError = (messageId: string) => {
|
||||
setOpenErrors((prev) => ({ ...prev, [messageId]: true }))
|
||||
}
|
||||
const handleOpenError = (messageId: string) => {
|
||||
setOpenErrors((prev) => ({ ...prev, [messageId]: true }));
|
||||
};
|
||||
|
||||
const handleCloseError = (messageId: string) => {
|
||||
setOpenErrors((prev) => ({ ...prev, [messageId]: false }))
|
||||
}
|
||||
const handleCloseError = (messageId: string) => {
|
||||
setOpenErrors((prev) => ({ ...prev, [messageId]: false }));
|
||||
};
|
||||
|
||||
if (!campaign) {
|
||||
return null
|
||||
}
|
||||
if (!campaign) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Progress</CardTitle>
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{campaign.stats.processed.toLocaleString()} Processed
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{campaign.stats.queuedMessages.toLocaleString()} Remaining
|
||||
</p>
|
||||
<br />
|
||||
return (
|
||||
<div className="flex flex-col space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Progress</CardTitle>
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{campaign.stats.processed.toLocaleString()} Processed
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{campaign.stats.queuedMessages.toLocaleString()} Remaining
|
||||
</p>
|
||||
<br />
|
||||
|
||||
<Progress
|
||||
value={
|
||||
(campaign.stats.processed / campaign.stats.totalMessages) * 100
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Progress
|
||||
value={
|
||||
(campaign.stats.processed / campaign.stats.totalMessages) * 100
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Delivery Status
|
||||
</CardTitle>
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="text-2xl font-bold">
|
||||
{(campaign.stats.totalMessages > 0
|
||||
? (campaign.stats.sentMessages /
|
||||
campaign.stats.totalMessages) *
|
||||
100
|
||||
: 0
|
||||
).toFixed(1)}
|
||||
%
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
<span className="font-bold text-primary">
|
||||
{campaign.stats.sentMessages.toLocaleString()}
|
||||
</span>{" "}
|
||||
out of {campaign.stats.totalMessages.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-destructive">
|
||||
{campaign.stats.failedMessages.toLocaleString()} failed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Delivery Status
|
||||
</CardTitle>
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="text-2xl font-bold">
|
||||
{(campaign.stats.totalMessages > 0
|
||||
? (campaign.stats.sentMessages /
|
||||
campaign.stats.totalMessages) *
|
||||
100
|
||||
: 0
|
||||
).toFixed(1)}
|
||||
%
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
<span className="font-bold text-primary">
|
||||
{campaign.stats.sentMessages.toLocaleString()}
|
||||
</span>{" "}
|
||||
out of {campaign.stats.totalMessages.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-destructive">
|
||||
{campaign.stats.failedMessages.toLocaleString()} failed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Click Rate</CardTitle>
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{campaign.stats.clickRate.toFixed(1)}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-bold text-primary">
|
||||
{campaign.stats.clicked.toLocaleString()}
|
||||
</span>{" "}
|
||||
total clicks out of {campaign.stats.sentMessages.toLocaleString()}{" "}
|
||||
messages
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Click Rate</CardTitle>
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{campaign.stats.clickRate.toFixed(1)}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-bold text-primary">
|
||||
{campaign.stats.clicked.toLocaleString()}
|
||||
</span>{" "}
|
||||
total clicks out of {campaign.stats.sentMessages.toLocaleString()}{" "}
|
||||
messages
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Open Rate</CardTitle>
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{campaign.stats.openRate.toFixed(1)}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-bold text-primary">
|
||||
{campaign.stats.opened.toLocaleString()}
|
||||
</span>{" "}
|
||||
out of {campaign.stats.sentMessages.toLocaleString()} messages
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Open Rate</CardTitle>
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{campaign.stats.openRate.toFixed(1)}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-bold text-primary">
|
||||
{campaign.stats.opened.toLocaleString()}
|
||||
</span>{" "}
|
||||
out of {campaign.stats.sentMessages.toLocaleString()} messages
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Unsubscribes</CardTitle>
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{campaign.campaign.unsubscribedCount?.toLocaleString() ?? 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Total unsubscribes for this campaign
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Unsubscribes</CardTitle>
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{campaign.campaign.unsubscribedCount?.toLocaleString() ?? 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Total unsubscribes for this campaign
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Campaign Details Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Campaign Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Email Subject</h4>
|
||||
<p className="text-muted-foreground">
|
||||
{campaign.campaign?.subject}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Recipient Lists</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{campaign.campaign?.CampaignLists?.map((cl) => (
|
||||
<Link key={cl.List.id} to="/dashboard/lists">
|
||||
<Badge variant="secondary">{cl.List.name}</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Campaign Details Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Campaign Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Email Subject</h4>
|
||||
<p className="text-muted-foreground">
|
||||
{campaign.campaign?.subject}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Recipient Lists</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{campaign.campaign?.CampaignLists?.map((cl) => (
|
||||
<Link key={cl.List.id} to="/dashboard/lists">
|
||||
<Badge variant="secondary">{cl.List.name}</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DataTable
|
||||
title="Messages"
|
||||
columns={columns({
|
||||
onOpenPreview: handleOpenPreview,
|
||||
onOpenError: handleOpenError,
|
||||
onClosePreview: handleClosePreview,
|
||||
onCloseError: handleCloseError,
|
||||
openPreviews,
|
||||
openErrors,
|
||||
})}
|
||||
data={messages?.messages ?? []}
|
||||
className="h-[500px]"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{messages?.pagination.total ?? 0} total messages
|
||||
</div>
|
||||
<Pagination
|
||||
page={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={(page) => setPagination("page", page)}
|
||||
hasNextPage={pagination.page < pagination.totalPages}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<DataTable
|
||||
title="Messages"
|
||||
columns={columns({
|
||||
onOpenPreview: handleOpenPreview,
|
||||
onOpenError: handleOpenError,
|
||||
onClosePreview: handleClosePreview,
|
||||
onCloseError: handleCloseError,
|
||||
openPreviews,
|
||||
openErrors,
|
||||
})}
|
||||
data={messages?.messages ?? []}
|
||||
className="h-[500px]"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{messages?.pagination.total ?? 0} total messages
|
||||
</div>
|
||||
<Pagination
|
||||
page={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={(page) => setPagination("page", page)}
|
||||
hasNextPage={pagination.page < pagination.totalPages}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,158 +1,158 @@
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
Textarea,
|
||||
} from "@repo/ui"
|
||||
import { useCampaignContext } from "../../useCampaignContext"
|
||||
import { useState } from "react"
|
||||
import { Eye, Wand2, Link2 } from "lucide-react"
|
||||
import { EmailPreview } from "@/components"
|
||||
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@repo/ui"
|
||||
import { trpc } from "@/trpc"
|
||||
import { useSession } from "@/hooks"
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
Textarea,
|
||||
} from "@repo/ui";
|
||||
import { useCampaignContext } from "../../useCampaignContext";
|
||||
import { useState } from "react";
|
||||
import { Eye, Wand2, Link2 } from "lucide-react";
|
||||
import { EmailPreview } from "@/components";
|
||||
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@repo/ui";
|
||||
import { trpc } from "@/trpc";
|
||||
import { useSession } from "@/hooks";
|
||||
|
||||
export const EditorTab = () => {
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
const { organization } = useSession()
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const { organization } = useSession();
|
||||
|
||||
const { form, isEditable } = useCampaignContext()
|
||||
const { form, isEditable } = useCampaignContext();
|
||||
|
||||
const content = form.watch("content")
|
||||
const templateId = form.watch("templateId")
|
||||
const content = form.watch("content");
|
||||
const templateId = form.watch("templateId");
|
||||
|
||||
const { data: template } = trpc.template.get.useQuery(
|
||||
{
|
||||
id: templateId ?? "",
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!templateId && !!organization?.id && previewOpen,
|
||||
}
|
||||
)
|
||||
const { data: template } = trpc.template.get.useQuery(
|
||||
{
|
||||
id: templateId ?? "",
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!templateId && !!organization?.id && previewOpen,
|
||||
},
|
||||
);
|
||||
|
||||
let previewContent = content ?? "<p>No content available.</p>"
|
||||
if (previewOpen && template?.content && content) {
|
||||
previewContent = template.content.replace("{{content}}", content)
|
||||
} else if (previewOpen && template?.content && !content) {
|
||||
previewContent = template.content.replace(
|
||||
"{{content}}",
|
||||
"<p>No campaign content entered yet. This is where it will appear.</p>"
|
||||
)
|
||||
}
|
||||
let previewContent = content ?? "<p>No content available.</p>";
|
||||
if (previewOpen && template?.content && content) {
|
||||
previewContent = template.content.replace("{{content}}", content);
|
||||
} else if (previewOpen && template?.content && !content) {
|
||||
previewContent = template.content.replace(
|
||||
"{{content}}",
|
||||
"<p>No campaign content entered yet. This is where it will appear.</p>",
|
||||
);
|
||||
}
|
||||
|
||||
const handleInsertUnsubscribeLink = () => {
|
||||
const textarea = document.querySelector(
|
||||
'textarea[name="content"]'
|
||||
) as HTMLTextAreaElement
|
||||
if (!textarea) return
|
||||
const handleInsertUnsubscribeLink = () => {
|
||||
const textarea = document.querySelector(
|
||||
'textarea[name="content"]',
|
||||
) as HTMLTextAreaElement;
|
||||
if (!textarea) return;
|
||||
|
||||
const { selectionStart, selectionEnd } = textarea
|
||||
const currentContent = textarea.value
|
||||
const newContent =
|
||||
currentContent.slice(0, selectionStart) +
|
||||
"{{unsubscribe_link}}" +
|
||||
currentContent.slice(selectionEnd)
|
||||
const { selectionStart, selectionEnd } = textarea;
|
||||
const currentContent = textarea.value;
|
||||
const newContent =
|
||||
currentContent.slice(0, selectionStart) +
|
||||
"{{unsubscribe_link}}" +
|
||||
currentContent.slice(selectionEnd);
|
||||
|
||||
form.setValue("content", newContent)
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(
|
||||
selectionStart + "{{unsubscribe_link}}".length,
|
||||
selectionStart + "{{unsubscribe_link}}".length
|
||||
)
|
||||
}
|
||||
form.setValue("content", newContent);
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(
|
||||
selectionStart + "{{unsubscribe_link}}".length,
|
||||
selectionStart + "{{unsubscribe_link}}".length,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Email Content</CardTitle>
|
||||
<CardDescription>
|
||||
Edit your email content using either the rich text editor or plain
|
||||
text
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline">
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Preview
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[900px] w-[90vw] p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between border-b pb-4">
|
||||
<DialogTitle className="text-lg font-semibold">
|
||||
Email Preview
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<div className="relative min-h-[70vh] max-h-[80vh] overflow-y-auto scroll-hidden">
|
||||
{previewOpen && (templateId ? template : true) ? (
|
||||
<EmailPreview
|
||||
content={previewContent}
|
||||
className="h-full cursor-default rounded-md bg-white scroll-hidden"
|
||||
/>
|
||||
) : previewOpen && templateId && !template ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-muted-foreground">
|
||||
Loading template...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-muted-foreground">
|
||||
No content to preview. Select a template if you wish
|
||||
to see it populated.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleInsertUnsubscribeLink}
|
||||
disabled={!isEditable}
|
||||
>
|
||||
<Link2 className="w-4 h-4 mr-2" />
|
||||
Insert Unsubscribe Link
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 bg-muted/50 px-3 py-1.5 rounded-md text-sm text-muted-foreground">
|
||||
<Wand2 className="w-4 h-4" />
|
||||
Rich text editor coming soon
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Email Content</CardTitle>
|
||||
<CardDescription>
|
||||
Edit your email content using either the rich text editor or plain
|
||||
text
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline">
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Preview
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[900px] w-[90vw] p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between border-b pb-4">
|
||||
<DialogTitle className="text-lg font-semibold">
|
||||
Email Preview
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<div className="relative min-h-[70vh] max-h-[80vh] overflow-y-auto scroll-hidden">
|
||||
{previewOpen && (templateId ? template : true) ? (
|
||||
<EmailPreview
|
||||
content={previewContent}
|
||||
className="h-full cursor-default rounded-md bg-white scroll-hidden"
|
||||
/>
|
||||
) : previewOpen && templateId && !template ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-muted-foreground">
|
||||
Loading template...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-muted-foreground">
|
||||
No content to preview. Select a template if you wish
|
||||
to see it populated.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleInsertUnsubscribeLink}
|
||||
disabled={!isEditable}
|
||||
>
|
||||
<Link2 className="w-4 h-4 mr-2" />
|
||||
Insert Unsubscribe Link
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 bg-muted/50 px-3 py-1.5 rounded-md text-sm text-muted-foreground">
|
||||
<Wand2 className="w-4 h-4" />
|
||||
Rich text editor coming soon
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter plain text content here..."
|
||||
className="min-h-[400px] font-mono mt-4"
|
||||
{...field}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter plain text content here..."
|
||||
className="min-h-[400px] font-mono mt-4"
|
||||
{...field}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,283 +1,283 @@
|
||||
import {
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
Input,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
FormItem,
|
||||
CardDescription,
|
||||
FormField,
|
||||
Textarea,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
cn,
|
||||
Switch,
|
||||
} from "@repo/ui"
|
||||
import { LayoutTemplate, Mail, Pencil, Users, Eye } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@repo/ui"
|
||||
import { useSession } from "@/hooks"
|
||||
import { trpc } from "@/trpc"
|
||||
import { useCampaignContext } from "../useCampaignContext"
|
||||
import { useEffect } from "react"
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
Input,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
FormItem,
|
||||
CardDescription,
|
||||
FormField,
|
||||
Textarea,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
cn,
|
||||
Switch,
|
||||
} from "@repo/ui";
|
||||
import { LayoutTemplate, Mail, Pencil, Users, Eye } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@repo/ui";
|
||||
import { useSession } from "@/hooks";
|
||||
import { trpc } from "@/trpc";
|
||||
import { useCampaignContext } from "../useCampaignContext";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const SettingsTab = () => {
|
||||
const { orgId } = useSession()
|
||||
const { orgId } = useSession();
|
||||
|
||||
const { form, isEditable, campaignQuery } = useCampaignContext()
|
||||
const { form, isEditable, campaignQuery } = useCampaignContext();
|
||||
|
||||
const { data: templates } = trpc.template.list.useQuery(
|
||||
{
|
||||
organizationId: orgId ?? "",
|
||||
page: 1,
|
||||
perPage: 100,
|
||||
},
|
||||
{
|
||||
enabled: !!orgId,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
}
|
||||
)
|
||||
const { data: templates } = trpc.template.list.useQuery(
|
||||
{
|
||||
organizationId: orgId ?? "",
|
||||
page: 1,
|
||||
perPage: 100,
|
||||
},
|
||||
{
|
||||
enabled: !!orgId,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: lists } = trpc.list.list.useQuery(
|
||||
{
|
||||
organizationId: orgId ?? "",
|
||||
page: 1,
|
||||
perPage: 100,
|
||||
},
|
||||
{
|
||||
enabled: !!orgId,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
}
|
||||
)
|
||||
const { data: lists } = trpc.list.list.useQuery(
|
||||
{
|
||||
organizationId: orgId ?? "",
|
||||
page: 1,
|
||||
perPage: 100,
|
||||
},
|
||||
{
|
||||
enabled: !!orgId,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
title: campaignQuery.data?.campaign?.title || "",
|
||||
description: campaignQuery.data?.campaign?.description || "",
|
||||
subject: campaignQuery.data?.campaign?.subject || "",
|
||||
templateId: campaignQuery.data?.campaign?.templateId || "",
|
||||
openTracking: campaignQuery.data?.campaign?.openTracking || false,
|
||||
listIds:
|
||||
campaignQuery.data?.campaign?.CampaignLists?.map(
|
||||
(list) => list.listId
|
||||
) || [],
|
||||
content: campaignQuery.data?.campaign?.content || "",
|
||||
})
|
||||
}, [templates, campaignQuery.data, form])
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
title: campaignQuery.data?.campaign?.title || "",
|
||||
description: campaignQuery.data?.campaign?.description || "",
|
||||
subject: campaignQuery.data?.campaign?.subject || "",
|
||||
templateId: campaignQuery.data?.campaign?.templateId || "",
|
||||
openTracking: campaignQuery.data?.campaign?.openTracking || false,
|
||||
listIds:
|
||||
campaignQuery.data?.campaign?.CampaignLists?.map(
|
||||
(list) => list.listId,
|
||||
) || [],
|
||||
content: campaignQuery.data?.campaign?.content || "",
|
||||
});
|
||||
}, [templates, campaignQuery.data, form]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{/* Campaign Details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Pencil className="h-5 w-5 text-primary" />
|
||||
Campaign Details
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Basic information about your campaign
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Campaign Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Monthly Newsletter"
|
||||
{...field}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Internal name for your campaign
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{/* Campaign Details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Pencil className="h-5 w-5 text-primary" />
|
||||
Campaign Details
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Basic information about your campaign
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Campaign Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Monthly Newsletter"
|
||||
{...field}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Internal name for your campaign
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Campaign details and notes..."
|
||||
className="resize-none"
|
||||
{...field}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Campaign details and notes..."
|
||||
className="resize-none"
|
||||
{...field}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Email Content */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5 text-primary" />
|
||||
Email Content
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure your email content and template
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subject"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email Subject</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Your Monthly Newsletter is Here!"
|
||||
{...field}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The subject line recipients will see
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* Email Content */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5 text-primary" />
|
||||
Email Content
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure your email content and template
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subject"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email Subject</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Your Monthly Newsletter is Here!"
|
||||
{...field}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The subject line recipients will see
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="templateId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Template</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === "none" ? "" : value)
|
||||
}
|
||||
value={field.value || "none"}
|
||||
disabled={!isEditable}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a template" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
<div className="flex items-center gap-2">
|
||||
No Template
|
||||
</div>
|
||||
</SelectItem>
|
||||
{templates?.templates.map((template) => (
|
||||
<SelectItem key={template.id} value={template.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<LayoutTemplate className="h-4 w-4" />
|
||||
{template.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="templateId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Template</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === "none" ? "" : value)
|
||||
}
|
||||
value={field.value || "none"}
|
||||
disabled={!isEditable}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a template" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
<div className="flex items-center gap-2">
|
||||
No Template
|
||||
</div>
|
||||
</SelectItem>
|
||||
{templates?.templates.map((template) => (
|
||||
<SelectItem key={template.id} value={template.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<LayoutTemplate className="h-4 w-4" />
|
||||
{template.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="openTracking"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye className="w-4 h-4" />
|
||||
Open Tracking
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Track when recipients open your emails
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="openTracking"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye className="w-4 h-4" />
|
||||
Open Tracking
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Track when recipients open your emails
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recipients */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
Recipients
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Choose which subscriber lists will receive this campaign
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="listIds"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 cursor-pointer">
|
||||
{lists?.lists.map((list) => (
|
||||
<div
|
||||
key={list.id}
|
||||
className={cn(
|
||||
"relative flex items-center rounded-lg border-2 p-4 transition-colors",
|
||||
field.value.includes(list.id)
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50",
|
||||
!isEditable && "pointer-events-none opacity-60"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isEditable) return
|
||||
const newValue = field.value.includes(list.id)
|
||||
? field.value.filter((id) => id !== list.id)
|
||||
: [...field.value, list.id]
|
||||
field.onChange(newValue)
|
||||
}}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium">{list.name}</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{list._count.ListSubscribers.toLocaleString()}{" "}
|
||||
subscribers
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{/* Recipients */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
Recipients
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Choose which subscriber lists will receive this campaign
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="listIds"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 cursor-pointer">
|
||||
{lists?.lists.map((list) => (
|
||||
<div
|
||||
key={list.id}
|
||||
className={cn(
|
||||
"relative flex items-center rounded-lg border-2 p-4 transition-colors",
|
||||
field.value.includes(list.id)
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50",
|
||||
!isEditable && "pointer-events-none opacity-60",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isEditable) return;
|
||||
const newValue = field.value.includes(list.id)
|
||||
? field.value.filter((id) => id !== list.id)
|
||||
: [...field.value, list.id];
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium">{list.name}</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{list._count.ListSubscribers.toLocaleString()}{" "}
|
||||
subscribers
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useContext } from "react"
|
||||
import { CampaignContext } from "./context"
|
||||
import { useContext } from "react";
|
||||
import { CampaignContext } from "./context";
|
||||
|
||||
export function useCampaignContext() {
|
||||
const editor = useContext(CampaignContext)
|
||||
const editor = useContext(CampaignContext);
|
||||
|
||||
if (!editor) {
|
||||
throw new Error(
|
||||
"useEditorContext must be used within a EditorContext Provider"
|
||||
)
|
||||
}
|
||||
if (!editor) {
|
||||
throw new Error(
|
||||
"useEditorContext must be used within a EditorContext Provider",
|
||||
);
|
||||
}
|
||||
|
||||
return editor
|
||||
return editor;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Input } from "@repo/ui"
|
||||
import { usePaginationWithQueryState } from "@/hooks/usePagination"
|
||||
import { Input } from "@repo/ui";
|
||||
import { usePaginationWithQueryState } from "@/hooks/usePagination";
|
||||
|
||||
export const CampaignSearch = () => {
|
||||
const { pagination, setPagination } = usePaginationWithQueryState()
|
||||
const { pagination, setPagination } = usePaginationWithQueryState();
|
||||
|
||||
return (
|
||||
<Input
|
||||
placeholder="Search campaigns..."
|
||||
value={pagination.search}
|
||||
onChange={(event) => setPagination("search", event.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
placeholder="Search campaigns..."
|
||||
value={pagination.search}
|
||||
onChange={(event) => setPagination("search", event.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,136 +1,136 @@
|
||||
import { ColumnDef } from "@tanstack/react-table"
|
||||
import { Button, cn } from "@repo/ui"
|
||||
import { Trash, Copy } from "lucide-react"
|
||||
import { CampaignStatus } from "backend"
|
||||
import { Link } from "react-router"
|
||||
import { displayDateTime } from "@/utils"
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Button, cn } from "@repo/ui";
|
||||
import { Trash, Copy } from "lucide-react";
|
||||
import { CampaignStatus } from "backend";
|
||||
import { Link } from "react-router";
|
||||
import { displayDateTime } from "@/utils";
|
||||
|
||||
const statusConfig: Record<
|
||||
CampaignStatus,
|
||||
{ label: string; className: string }
|
||||
CampaignStatus,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
DRAFT: {
|
||||
label: "Draft",
|
||||
className:
|
||||
"bg-primary text-primary-foreground dark:bg-primary dark:text-primary-foreground",
|
||||
},
|
||||
SCHEDULED: {
|
||||
label: "Scheduled",
|
||||
className: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
||||
},
|
||||
CREATING: {
|
||||
label: "Creating",
|
||||
className: "bg-gray-200 text-gray-800 dark:bg-gray-700 dark:text-gray-200",
|
||||
},
|
||||
SENDING: {
|
||||
label: "Sending",
|
||||
className:
|
||||
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
|
||||
},
|
||||
COMPLETED: {
|
||||
label: "Sent",
|
||||
className:
|
||||
"bg-emerald-600 text-gray-100 dark:bg-success dark:text-success-foreground",
|
||||
},
|
||||
CANCELLED: {
|
||||
label: "Cancelled",
|
||||
className:
|
||||
"bg-destructive text-destructive-foreground dark:bg-destructive dark:text-destructive-foreground",
|
||||
},
|
||||
}
|
||||
DRAFT: {
|
||||
label: "Draft",
|
||||
className:
|
||||
"bg-primary text-primary-foreground dark:bg-primary dark:text-primary-foreground",
|
||||
},
|
||||
SCHEDULED: {
|
||||
label: "Scheduled",
|
||||
className: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
||||
},
|
||||
CREATING: {
|
||||
label: "Creating",
|
||||
className: "bg-gray-200 text-gray-800 dark:bg-gray-700 dark:text-gray-200",
|
||||
},
|
||||
SENDING: {
|
||||
label: "Sending",
|
||||
className:
|
||||
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
|
||||
},
|
||||
COMPLETED: {
|
||||
label: "Sent",
|
||||
className:
|
||||
"bg-emerald-600 text-gray-100 dark:bg-success dark:text-success-foreground",
|
||||
},
|
||||
CANCELLED: {
|
||||
label: "Cancelled",
|
||||
className:
|
||||
"bg-destructive text-destructive-foreground dark:bg-destructive dark:text-destructive-foreground",
|
||||
},
|
||||
};
|
||||
|
||||
type Campaign = {
|
||||
id: string
|
||||
title: string
|
||||
status: CampaignStatus
|
||||
scheduledAt: Date | null
|
||||
_count: {
|
||||
Messages: number
|
||||
}
|
||||
}
|
||||
id: string;
|
||||
title: string;
|
||||
status: CampaignStatus;
|
||||
scheduledAt: Date | null;
|
||||
_count: {
|
||||
Messages: number;
|
||||
};
|
||||
};
|
||||
|
||||
type ColumnsProps = {
|
||||
onDelete: (id: string) => void
|
||||
onDuplicate: (id: string) => void
|
||||
}
|
||||
onDelete: (id: string) => void;
|
||||
onDuplicate: (id: string) => void;
|
||||
};
|
||||
|
||||
export const columns = ({
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
}: ColumnsProps): ColumnDef<Campaign>[] => [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: "Campaign Name",
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
className="underline"
|
||||
to={`/dashboard/campaigns/${row.original.id}`}
|
||||
>
|
||||
{row.original.title}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("status") as CampaignStatus
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
|
||||
statusConfig[status].className
|
||||
)}
|
||||
>
|
||||
{statusConfig[status].label}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "scheduledAt",
|
||||
header: "Sent Date",
|
||||
cell: ({ row }) => {
|
||||
const date = row.original.scheduledAt
|
||||
return date ? displayDateTime(date) : "-"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "_count.Messages",
|
||||
header: "Recipients",
|
||||
cell: ({ row }) => row.original._count.Messages.toLocaleString(),
|
||||
},
|
||||
// {
|
||||
// accessorKey: "openRate",
|
||||
// header: "Open Rate",
|
||||
// cell: ({ row }) => row.original.openRate.toLocaleString(),
|
||||
// },
|
||||
// {
|
||||
// accessorKey: "clickRate",
|
||||
// header: "Click Rate",
|
||||
// cell: ({ row }) => row.original.clickRate.toLocaleString(),
|
||||
// },
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex space-x-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDuplicate(row.original.id)}
|
||||
className="text-primary hover:text-primary/80"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDelete(row.original.id)}
|
||||
className="text-destructive hover:text-destructive/80"
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: "Campaign Name",
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
className="underline"
|
||||
to={`/dashboard/campaigns/${row.original.id}`}
|
||||
>
|
||||
{row.original.title}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("status") as CampaignStatus;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
|
||||
statusConfig[status].className,
|
||||
)}
|
||||
>
|
||||
{statusConfig[status].label}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "scheduledAt",
|
||||
header: "Sent Date",
|
||||
cell: ({ row }) => {
|
||||
const date = row.original.scheduledAt;
|
||||
return date ? displayDateTime(date) : "-";
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "_count.Messages",
|
||||
header: "Recipients",
|
||||
cell: ({ row }) => row.original._count.Messages.toLocaleString(),
|
||||
},
|
||||
// {
|
||||
// accessorKey: "openRate",
|
||||
// header: "Open Rate",
|
||||
// cell: ({ row }) => row.original.openRate.toLocaleString(),
|
||||
// },
|
||||
// {
|
||||
// accessorKey: "clickRate",
|
||||
// header: "Click Rate",
|
||||
// cell: ({ row }) => row.original.clickRate.toLocaleString(),
|
||||
// },
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex space-x-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDuplicate(row.original.id)}
|
||||
className="text-primary hover:text-primary/80"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDelete(row.original.id)}
|
||||
className="text-destructive hover:text-destructive/80"
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./page"
|
||||
export * from "./[id]"
|
||||
export * from "./page";
|
||||
export * from "./[id]";
|
||||
|
||||
@@ -1,448 +1,448 @@
|
||||
import { ArrowUp, Mail, Plus, ArrowDown, Eye, Send } from "lucide-react"
|
||||
import { ArrowUp, Mail, Plus, ArrowDown, Eye, Send } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
Textarea,
|
||||
FormMessage,
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
cn,
|
||||
DataTable,
|
||||
} from "@repo/ui"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useNavigate } from "react-router"
|
||||
import { usePaginationWithQueryState, useSession } from "@/hooks"
|
||||
import { trpc } from "@/trpc"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { toast } from "sonner"
|
||||
import z from "zod"
|
||||
import { CardSkeleton, Pagination } from "@/components"
|
||||
import { columns as getColumns } from "./columns"
|
||||
import { CampaignSearch } from "./campaign-search"
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
Textarea,
|
||||
FormMessage,
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
cn,
|
||||
DataTable,
|
||||
} from "@repo/ui";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { usePaginationWithQueryState, useSession } from "@/hooks";
|
||||
import { trpc } from "@/trpc";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import { CardSkeleton, Pagination } from "@/components";
|
||||
import { columns as getColumns } from "./columns";
|
||||
import { CampaignSearch } from "./campaign-search";
|
||||
|
||||
const createCampaignSchema = z.object({
|
||||
title: z.string().min(1, "Campaign title is required"),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
title: z.string().min(1, "Campaign title is required"),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export function CampaignsPage() {
|
||||
const [campaignToDelete, setCampaignToDelete] = useState<string | null>(null)
|
||||
const navigate = useNavigate()
|
||||
const { organization } = useSession()
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
const [campaignToDelete, setCampaignToDelete] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const { organization } = useSession();
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
|
||||
const { pagination, setPagination } = usePaginationWithQueryState()
|
||||
const { pagination, setPagination } = usePaginationWithQueryState();
|
||||
|
||||
const form = useForm<z.infer<typeof createCampaignSchema>>({
|
||||
resolver: zodResolver(createCampaignSchema),
|
||||
defaultValues: {
|
||||
title: "",
|
||||
description: "",
|
||||
},
|
||||
})
|
||||
const form = useForm<z.infer<typeof createCampaignSchema>>({
|
||||
resolver: zodResolver(createCampaignSchema),
|
||||
defaultValues: {
|
||||
title: "",
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
|
||||
const createCampaignMutation = trpc.campaign.create.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setIsCreateDialogOpen(false)
|
||||
form.reset()
|
||||
navigate(`/dashboard/campaigns/${data.campaign.id}`)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
const createCampaignMutation = trpc.campaign.create.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setIsCreateDialogOpen(false);
|
||||
form.reset();
|
||||
navigate(`/dashboard/campaigns/${data.campaign.id}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const deleteCampaignMutation = trpc.campaign.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Campaign deleted!")
|
||||
setCampaignToDelete(null)
|
||||
utils.campaign.list.invalidate()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
const deleteCampaignMutation = trpc.campaign.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Campaign deleted!");
|
||||
setCampaignToDelete(null);
|
||||
utils.campaign.list.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const duplicateCampaignMutation = trpc.campaign.duplicate.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success("Campaign duplicated!")
|
||||
utils.campaign.list.invalidate()
|
||||
navigate(`/dashboard/campaigns/${data.campaign.id}`)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
const duplicateCampaignMutation = trpc.campaign.duplicate.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success("Campaign duplicated!");
|
||||
utils.campaign.list.invalidate();
|
||||
navigate(`/dashboard/campaigns/${data.campaign.id}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (values: z.infer<typeof createCampaignSchema>) => {
|
||||
if (!organization?.id) return
|
||||
createCampaignMutation.mutate({
|
||||
...values,
|
||||
organizationId: organization.id,
|
||||
})
|
||||
}
|
||||
const onSubmit = (values: z.infer<typeof createCampaignSchema>) => {
|
||||
if (!organization?.id) return;
|
||||
createCampaignMutation.mutate({
|
||||
...values,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
};
|
||||
|
||||
const { data, isLoading } = trpc.campaign.list.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
page: pagination.page,
|
||||
perPage: pagination.perPage,
|
||||
search: pagination.searchQuery,
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
}
|
||||
)
|
||||
const { data, isLoading } = trpc.campaign.list.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
page: pagination.page,
|
||||
perPage: pagination.perPage,
|
||||
search: pagination.searchQuery,
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPagination("totalPages", data?.pagination.totalPages)
|
||||
}, [data]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
setPagination("totalPages", data?.pagination.totalPages);
|
||||
}, [data]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const campaigns = data?.campaigns ?? []
|
||||
const campaigns = data?.campaigns ?? [];
|
||||
|
||||
const { data: analytics, isLoading: analyticsLoading } =
|
||||
trpc.stats.getStats.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
}
|
||||
)
|
||||
const { data: analytics, isLoading: analyticsLoading } =
|
||||
trpc.stats.getStats.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
},
|
||||
);
|
||||
|
||||
const handleDeleteCampaign = (id: string) => {
|
||||
if (!organization?.id) return
|
||||
const handleDeleteCampaign = (id: string) => {
|
||||
if (!organization?.id) return;
|
||||
|
||||
deleteCampaignMutation.mutate({
|
||||
id,
|
||||
organizationId: organization.id,
|
||||
})
|
||||
}
|
||||
deleteCampaignMutation.mutate({
|
||||
id,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDuplicateCampaign = useCallback(
|
||||
(id: string) => {
|
||||
if (!organization?.id) return
|
||||
const handleDuplicateCampaign = useCallback(
|
||||
(id: string) => {
|
||||
if (!organization?.id) return;
|
||||
|
||||
duplicateCampaignMutation.mutate({
|
||||
id,
|
||||
organizationId: organization.id,
|
||||
})
|
||||
},
|
||||
[organization, duplicateCampaignMutation]
|
||||
)
|
||||
duplicateCampaignMutation.mutate({
|
||||
id,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
},
|
||||
[organization, duplicateCampaignMutation],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
getColumns({
|
||||
onDelete: setCampaignToDelete,
|
||||
onDuplicate: handleDuplicateCampaign,
|
||||
}),
|
||||
[handleDuplicateCampaign, setCampaignToDelete]
|
||||
)
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
getColumns({
|
||||
onDelete: setCampaignToDelete,
|
||||
onDuplicate: handleDuplicateCampaign,
|
||||
}),
|
||||
[handleDuplicateCampaign, setCampaignToDelete],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Campaigns</h2>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Campaigns</h2>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Campaigns
|
||||
</CardTitle>
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{analyticsLoading || !analytics ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{analytics.campaigns.total.toLocaleString()}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center",
|
||||
analytics.campaigns.comparison >= 0
|
||||
? "text-emerald-500"
|
||||
: "text-rose-500"
|
||||
)}
|
||||
>
|
||||
{analytics.campaigns.comparison >= 0 ? (
|
||||
<ArrowUp className="mr-1 h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDown className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{analytics.campaigns.comparison >= 0 ? "+" : "-"}
|
||||
{Math.abs(analytics.campaigns.comparison)} vs last month
|
||||
</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Stats Overview */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Campaigns
|
||||
</CardTitle>
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{analyticsLoading || !analytics ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{analytics.campaigns.total.toLocaleString()}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center",
|
||||
analytics.campaigns.comparison >= 0
|
||||
? "text-emerald-500"
|
||||
: "text-rose-500",
|
||||
)}
|
||||
>
|
||||
{analytics.campaigns.comparison >= 0 ? (
|
||||
<ArrowUp className="mr-1 h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDown className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{analytics.campaigns.comparison >= 0 ? "+" : "-"}
|
||||
{Math.abs(analytics.campaigns.comparison)} vs last month
|
||||
</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Average Open Rate{" "}
|
||||
<small className="text-xs text-muted-foreground">
|
||||
(Last 30 days)
|
||||
</small>
|
||||
</CardTitle>
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{analyticsLoading || !analytics ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{analytics.openRate.thisMonth.toFixed(1)}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center",
|
||||
analytics.openRate.comparison >= 0
|
||||
? "text-emerald-500"
|
||||
: "text-rose-500"
|
||||
)}
|
||||
>
|
||||
{analytics.openRate.comparison >= 0 ? (
|
||||
<ArrowUp className="mr-1 h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDown className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{analytics.openRate.comparison >= 0 ? "+" : "-"}
|
||||
{Math.abs(analytics.openRate.comparison).toFixed(1)}%
|
||||
</span>
|
||||
vs last month
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Average Open Rate{" "}
|
||||
<small className="text-xs text-muted-foreground">
|
||||
(Last 30 days)
|
||||
</small>
|
||||
</CardTitle>
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{analyticsLoading || !analytics ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{analytics.openRate.thisMonth.toFixed(1)}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center",
|
||||
analytics.openRate.comparison >= 0
|
||||
? "text-emerald-500"
|
||||
: "text-rose-500",
|
||||
)}
|
||||
>
|
||||
{analytics.openRate.comparison >= 0 ? (
|
||||
<ArrowUp className="mr-1 h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDown className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{analytics.openRate.comparison >= 0 ? "+" : "-"}
|
||||
{Math.abs(analytics.openRate.comparison).toFixed(1)}%
|
||||
</span>
|
||||
vs last month
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Average Click Rate{" "}
|
||||
<small className="text-xs text-muted-foreground">
|
||||
(Last 30 days)
|
||||
</small>
|
||||
</CardTitle>
|
||||
<Send className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{analyticsLoading || !analytics ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{analytics.clickRate.thisMonth.rate.toFixed(1)}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center",
|
||||
analytics.clickRate.comparison >= 0
|
||||
? "text-emerald-500"
|
||||
: "text-rose-500"
|
||||
)}
|
||||
>
|
||||
{analytics.clickRate.comparison >= 0 ? (
|
||||
<ArrowUp className="mr-1 h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDown className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{analytics.clickRate.comparison >= 0 ? "+" : "-"}
|
||||
{Math.abs(analytics.clickRate.comparison).toFixed(1)}%
|
||||
</span>
|
||||
vs last month
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Average Click Rate{" "}
|
||||
<small className="text-xs text-muted-foreground">
|
||||
(Last 30 days)
|
||||
</small>
|
||||
</CardTitle>
|
||||
<Send className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{analyticsLoading || !analytics ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{analytics.clickRate.thisMonth.rate.toFixed(1)}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center",
|
||||
analytics.clickRate.comparison >= 0
|
||||
? "text-emerald-500"
|
||||
: "text-rose-500",
|
||||
)}
|
||||
>
|
||||
{analytics.clickRate.comparison >= 0 ? (
|
||||
<ArrowUp className="mr-1 h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDown className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{analytics.clickRate.comparison >= 0 ? "+" : "-"}
|
||||
{Math.abs(analytics.clickRate.comparison).toFixed(1)}%
|
||||
</span>
|
||||
vs last month
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-1 items-center space-x-2">
|
||||
<CampaignSearch />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-1 items-center space-x-2">
|
||||
<CampaignSearch />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* <WithTooltip content="Download campaigns">
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* <WithTooltip content="Download campaigns">
|
||||
<Button variant="outline" size="icon">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</WithTooltip> */}
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Dialog
|
||||
open={isCreateDialogOpen}
|
||||
onOpenChange={setIsCreateDialogOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Campaign
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Campaign</DialogTitle>
|
||||
<DialogDescription>
|
||||
Start by giving your campaign a name and description. You
|
||||
can configure the details after creation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Campaign Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter campaign title"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is for your internal reference only.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter campaign description"
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={createCampaignMutation.isPending}
|
||||
>
|
||||
{createCampaignMutation.isPending
|
||||
? "Creating..."
|
||||
: "Create Campaign"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={campaigns}
|
||||
title="Campaigns"
|
||||
className="h-[calc(100vh-440px)]"
|
||||
isLoading={isLoading}
|
||||
NoResultsContent={
|
||||
<div className="flex flex-col items-center justify-center h-full my-10 gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No campaigns found.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create a new campaign to get started.
|
||||
</p>
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||
Create a Campaign <Plus className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
{data?.pagination.total ?? 0} total campaigns
|
||||
</div>
|
||||
<Pagination
|
||||
page={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={(page) => setPagination("page", page)}
|
||||
hasNextPage={pagination.page < pagination.totalPages}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Dialog
|
||||
open={isCreateDialogOpen}
|
||||
onOpenChange={setIsCreateDialogOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Campaign
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Campaign</DialogTitle>
|
||||
<DialogDescription>
|
||||
Start by giving your campaign a name and description. You
|
||||
can configure the details after creation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Campaign Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter campaign title"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is for your internal reference only.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter campaign description"
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={createCampaignMutation.isPending}
|
||||
>
|
||||
{createCampaignMutation.isPending
|
||||
? "Creating..."
|
||||
: "Create Campaign"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={campaigns}
|
||||
title="Campaigns"
|
||||
className="h-[calc(100vh-440px)]"
|
||||
isLoading={isLoading}
|
||||
NoResultsContent={
|
||||
<div className="flex flex-col items-center justify-center h-full my-10 gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No campaigns found.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create a new campaign to get started.
|
||||
</p>
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||
Create a Campaign <Plus className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
{data?.pagination.total ?? 0} total campaigns
|
||||
</div>
|
||||
<Pagination
|
||||
page={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={(page) => setPagination("page", page)}
|
||||
hasNextPage={pagination.page < pagination.totalPages}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
open={!!campaignToDelete}
|
||||
onOpenChange={(open) => !open && setCampaignToDelete(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
campaign from our servers.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() =>
|
||||
campaignToDelete && handleDeleteCampaign(campaignToDelete)
|
||||
}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
<AlertDialog
|
||||
open={!!campaignToDelete}
|
||||
onOpenChange={(open) => !open && setCampaignToDelete(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
campaign from our servers.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() =>
|
||||
campaignToDelete && handleDeleteCampaign(campaignToDelete)
|
||||
}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,166 +1,164 @@
|
||||
import {
|
||||
Mail,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Database,
|
||||
Users,
|
||||
Clock,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Eye,
|
||||
MousePointer,
|
||||
RefreshCw,
|
||||
} from "lucide-react"
|
||||
Mail,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Database,
|
||||
Users,
|
||||
Clock,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Eye,
|
||||
MousePointer,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Button,
|
||||
cn,
|
||||
} from "@repo/ui"
|
||||
import { trpc } from "@/trpc"
|
||||
import { useSession } from "@/hooks"
|
||||
import { Link } from "react-router"
|
||||
import { CardSkeleton, WithTooltip, CenteredLoader } from "@/components"
|
||||
import dayjs from "dayjs"
|
||||
import { IconExclamationCircle } from "@tabler/icons-react"
|
||||
import { SubscriberGrowthChart } from "./subscriber-growth-chart"
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Button,
|
||||
cn,
|
||||
} from "@repo/ui";
|
||||
import { trpc } from "@/trpc";
|
||||
import { useSession } from "@/hooks";
|
||||
import { Link } from "react-router";
|
||||
import { CardSkeleton, WithTooltip, CenteredLoader } from "@/components";
|
||||
import dayjs from "dayjs";
|
||||
import { IconExclamationCircle } from "@tabler/icons-react";
|
||||
import { SubscriberGrowthChart } from "./subscriber-growth-chart";
|
||||
|
||||
const statusConfig = {
|
||||
QUEUED: {
|
||||
icon: Clock,
|
||||
textClassName: "text-primary",
|
||||
},
|
||||
PENDING: {
|
||||
icon: Loader2,
|
||||
textClassName: "text-foreground",
|
||||
},
|
||||
SENT: {
|
||||
icon: CheckCircle,
|
||||
textClassName: "text-emerald-500",
|
||||
},
|
||||
FAILED: {
|
||||
icon: XCircle,
|
||||
textClassName: "text-destructive",
|
||||
},
|
||||
OPENED: {
|
||||
icon: Eye,
|
||||
textClassName: "text-blue-800",
|
||||
},
|
||||
CLICKED: {
|
||||
icon: MousePointer,
|
||||
textClassName: "text-indigo-800",
|
||||
},
|
||||
RETRYING: {
|
||||
icon: RefreshCw,
|
||||
textClassName: "text-yellow-500",
|
||||
},
|
||||
}
|
||||
QUEUED: {
|
||||
icon: Clock,
|
||||
textClassName: "text-primary",
|
||||
},
|
||||
PENDING: {
|
||||
icon: Loader2,
|
||||
textClassName: "text-foreground",
|
||||
},
|
||||
SENT: {
|
||||
icon: CheckCircle,
|
||||
textClassName: "text-emerald-500",
|
||||
},
|
||||
FAILED: {
|
||||
icon: XCircle,
|
||||
textClassName: "text-destructive",
|
||||
},
|
||||
OPENED: {
|
||||
icon: Eye,
|
||||
textClassName: "text-blue-800",
|
||||
},
|
||||
CLICKED: {
|
||||
icon: MousePointer,
|
||||
textClassName: "text-indigo-800",
|
||||
},
|
||||
RETRYING: {
|
||||
icon: RefreshCw,
|
||||
textClassName: "text-yellow-500",
|
||||
},
|
||||
};
|
||||
|
||||
export function DashboardPage() {
|
||||
const { organization } = useSession()
|
||||
const { organization } = useSession();
|
||||
|
||||
const { data: analytics, isLoading: analyticsLoading } =
|
||||
trpc.stats.getStats.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
}
|
||||
)
|
||||
const { data: analytics, isLoading: analyticsLoading } =
|
||||
trpc.stats.getStats.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: dashboard } = trpc.dashboard.getStats.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
}
|
||||
)
|
||||
const { data: dashboard } = trpc.dashboard.getStats.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
},
|
||||
);
|
||||
|
||||
if (!dashboard) {
|
||||
return <CenteredLoader />
|
||||
}
|
||||
if (!dashboard) {
|
||||
return <CenteredLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Subscribers
|
||||
</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{analyticsLoading || !analytics ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{analytics.subscribers.allTime.toLocaleString()}
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center text-sm ${
|
||||
analytics.subscribers.newThisMonth >= 0
|
||||
? "text-emerald-600"
|
||||
: "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{analytics.subscribers.newThisMonth >= 0 ? (
|
||||
<ArrowUp className="mr-1 h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDown className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
+{analytics.subscribers.newThisMonth} from last month
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Stats Overview */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Subscribers
|
||||
</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{analyticsLoading || !analytics ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{analytics.subscribers.allTime.toLocaleString()}
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center text-sm ${
|
||||
analytics.subscribers.newThisMonth >= 0
|
||||
? "text-emerald-600"
|
||||
: "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{analytics.subscribers.newThisMonth >= 0 ? (
|
||||
<ArrowUp className="mr-1 h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDown className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
+{analytics.subscribers.newThisMonth} from last month
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Messages
|
||||
</CardTitle>
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{analyticsLoading || !analytics ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{analytics.messages.total.toLocaleString()}
|
||||
</div>
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Messages
|
||||
</CardTitle>
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{analyticsLoading || !analytics ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{analytics.messages.total.toLocaleString()}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
<span className="text-emerald-500 inline-flex items-center">
|
||||
<ArrowUp className="mr-1 h-4 w-4" />
|
||||
{analytics.messages.last30Days >= 0 ? "+" : "-"}
|
||||
{Math.abs(
|
||||
analytics.messages.last30Days
|
||||
).toLocaleString()}{" "}
|
||||
This month
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
<span className="text-emerald-500 inline-flex items-center">
|
||||
<ArrowUp className="mr-1 h-4 w-4" />
|
||||
{analytics.messages.last30Days >= 0 ? "+" : "-"}
|
||||
{Math.abs(analytics.messages.last30Days).toLocaleString()}{" "}
|
||||
This month
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* <StatCard
|
||||
{/* <StatCard
|
||||
isLoading={analyticsLoading}
|
||||
smallTitle="Last 30 days"
|
||||
title="Delivery Rate"
|
||||
@@ -170,7 +168,7 @@ export function DashboardPage() {
|
||||
subtitle={`${analytics?.deliveryRate.thisMonth.delivered.toLocaleString()} out of ${analytics?.messages.Last30Days.toLocaleString()} total messages`}
|
||||
/> */}
|
||||
|
||||
{/*
|
||||
{/*
|
||||
<StatCard
|
||||
isLoading={analyticsLoading}
|
||||
smallTitle="Last 30 days"
|
||||
@@ -180,185 +178,185 @@ export function DashboardPage() {
|
||||
change={analytics?.clickRate.comparison}
|
||||
/> */}
|
||||
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Completed Campaigns{" "}
|
||||
</CardTitle>
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{analyticsLoading || !analytics ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{analytics.completedCampaigns.total}
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center text-sm ${
|
||||
analytics.completedCampaigns.comparison >= 0
|
||||
? "text-emerald-600"
|
||||
: "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{analytics.completedCampaigns.comparison >= 0 ? (
|
||||
<ArrowUp className="mr-1 h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDown className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
+{analytics.completedCampaigns.comparison} from last month
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Completed Campaigns{" "}
|
||||
</CardTitle>
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{analyticsLoading || !analytics ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{analytics.completedCampaigns.total}
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center text-sm ${
|
||||
analytics.completedCampaigns.comparison >= 0
|
||||
? "text-emerald-600"
|
||||
: "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{analytics.completedCampaigns.comparison >= 0 ? (
|
||||
<ArrowUp className="mr-1 h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDown className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
+{analytics.completedCampaigns.comparison} from last month
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-1">
|
||||
Storage Used
|
||||
<WithTooltip content="Estimated storage used by this organization">
|
||||
<IconExclamationCircle className="h-4 w-4 text-muted-foreground cursor-pointer" />
|
||||
</WithTooltip>
|
||||
</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!dashboard ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{Number(dashboard.dbSize?.total_size_mb || "0").toFixed(2) ??
|
||||
"0.00"}
|
||||
MB
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{Number(
|
||||
dashboard.dbSize?.message_count ?? "0"
|
||||
).toLocaleString()}{" "}
|
||||
messages
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-1">
|
||||
Storage Used
|
||||
<WithTooltip content="Estimated storage used by this organization">
|
||||
<IconExclamationCircle className="h-4 w-4 text-muted-foreground cursor-pointer" />
|
||||
</WithTooltip>
|
||||
</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!dashboard ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{Number(dashboard.dbSize?.total_size_mb || "0").toFixed(2) ??
|
||||
"0.00"}
|
||||
MB
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{Number(
|
||||
dashboard.dbSize?.message_count ?? "0",
|
||||
).toLocaleString()}{" "}
|
||||
messages
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
<SubscriberGrowthChart />
|
||||
{/* Charts */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
<SubscriberGrowthChart />
|
||||
|
||||
<Card hoverEffect className="col-span-4 md:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>Message Status</CardTitle>
|
||||
<CardDescription>Delivery status of your messages</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{Object.keys(dashboard.messageStats ?? {}).length > 0 ? (
|
||||
Object.entries(dashboard.messageStats ?? {}).map(
|
||||
([status, count]) => {
|
||||
const item = statusConfig[
|
||||
status as keyof typeof statusConfig
|
||||
] || {
|
||||
icon: Mail,
|
||||
className: "bg-muted text-muted-foreground",
|
||||
}
|
||||
<Card hoverEffect className="col-span-4 md:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>Message Status</CardTitle>
|
||||
<CardDescription>Delivery status of your messages</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{Object.keys(dashboard.messageStats ?? {}).length > 0 ? (
|
||||
Object.entries(dashboard.messageStats ?? {}).map(
|
||||
([status, count]) => {
|
||||
const item = statusConfig[
|
||||
status as keyof typeof statusConfig
|
||||
] || {
|
||||
icon: Mail,
|
||||
className: "bg-muted text-muted-foreground",
|
||||
};
|
||||
|
||||
const Icon = item.icon
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={status}
|
||||
hoverEffect
|
||||
className="hover:bg-accent"
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
item.textClassName
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<p className="text-sm font-medium">{status}</p>
|
||||
</div>
|
||||
<div className="font-medium">{count}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-24 items-center justify-center text-sm text-muted-foreground">
|
||||
No message status data available.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
return (
|
||||
<Card
|
||||
key={status}
|
||||
hoverEffect
|
||||
className="hover:bg-accent"
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
item.textClassName,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<p className="text-sm font-medium">{status}</p>
|
||||
</div>
|
||||
<div className="font-medium">{count}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-24 items-center justify-center text-sm text-muted-foreground">
|
||||
No message status data available.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent Campaigns */}
|
||||
<Card hoverEffect>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Campaigns</CardTitle>
|
||||
<CardDescription>
|
||||
Your latest newsletter campaigns and their performance
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-2">
|
||||
{dashboard.recentCampaigns.length > 0 ? (
|
||||
dashboard.recentCampaigns.map((campaign) => (
|
||||
<Link
|
||||
key={campaign.id}
|
||||
to={`/dashboard/campaigns/${campaign.id}`}
|
||||
>
|
||||
<div className="flex items-center justify-between space-x-4 rounded-lg border p-4 hover:bg-accent duration-200">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="font-medium">{campaign.title}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sent to {campaign.sentMessages.toLocaleString()}{" "}
|
||||
subscribers
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 text-sm">
|
||||
<div className="flex flex-col items-end space-y-1">
|
||||
<p className="font-medium">
|
||||
{campaign.deliveryRate.toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Delivery rate
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end space-y-1">
|
||||
<p className="font-medium">
|
||||
{dayjs(campaign.completedAt).format("DD MMM YYYY")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Completed date
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-24 items-center justify-center text-sm text-muted-foreground">
|
||||
No recent campaigns yet. Start one to see stats here.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
{/* Recent Campaigns */}
|
||||
<Card hoverEffect>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Campaigns</CardTitle>
|
||||
<CardDescription>
|
||||
Your latest newsletter campaigns and their performance
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-2">
|
||||
{dashboard.recentCampaigns.length > 0 ? (
|
||||
dashboard.recentCampaigns.map((campaign) => (
|
||||
<Link
|
||||
key={campaign.id}
|
||||
to={`/dashboard/campaigns/${campaign.id}`}
|
||||
>
|
||||
<div className="flex items-center justify-between space-x-4 rounded-lg border p-4 hover:bg-accent duration-200">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="font-medium">{campaign.title}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sent to {campaign.sentMessages.toLocaleString()}{" "}
|
||||
subscribers
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 text-sm">
|
||||
<div className="flex flex-col items-end space-y-1">
|
||||
<p className="font-medium">
|
||||
{campaign.deliveryRate.toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Delivery rate
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end space-y-1">
|
||||
<p className="font-medium">
|
||||
{dayjs(campaign.completedAt).format("DD MMM YYYY")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Completed date
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-24 items-center justify-center text-sm text-muted-foreground">
|
||||
No recent campaigns yet. Start one to see stats here.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "./dashboard"
|
||||
export * from "./dashboard";
|
||||
|
||||
@@ -1,143 +1,143 @@
|
||||
import { useSession } from "@/hooks"
|
||||
import { trpc } from "@/trpc"
|
||||
import { useSession } from "@/hooks";
|
||||
import { trpc } from "@/trpc";
|
||||
import {
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardTitle,
|
||||
CardHeader,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartConfig,
|
||||
CardFooter,
|
||||
Card,
|
||||
Skeleton,
|
||||
} from "@repo/ui"
|
||||
import dayjs from "dayjs"
|
||||
import { ArrowDown, TrendingUp } from "lucide-react"
|
||||
import { useMemo } from "react"
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardTitle,
|
||||
CardHeader,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartConfig,
|
||||
CardFooter,
|
||||
Card,
|
||||
Skeleton,
|
||||
} from "@repo/ui";
|
||||
import dayjs from "dayjs";
|
||||
import { ArrowDown, TrendingUp } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
|
||||
const chartConfig = {
|
||||
count: {
|
||||
label: "Total Subscribers",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
count: {
|
||||
label: "Total Subscribers",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export const SubscriberGrowthChart = () => {
|
||||
const { organization } = useSession()
|
||||
const { organization } = useSession();
|
||||
|
||||
const { data: analytics, isLoading: analyticsLoading } =
|
||||
trpc.stats.getStats.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
}
|
||||
)
|
||||
const { data: analytics, isLoading: analyticsLoading } =
|
||||
trpc.stats.getStats.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: dashboard, isLoading: dashboardLoading } =
|
||||
trpc.dashboard.getStats.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
}
|
||||
)
|
||||
const { data: dashboard, isLoading: dashboardLoading } =
|
||||
trpc.dashboard.getStats.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
},
|
||||
);
|
||||
|
||||
const isLoading = analyticsLoading || dashboardLoading
|
||||
const isLoading = analyticsLoading || dashboardLoading;
|
||||
|
||||
const countMapped = useMemo(
|
||||
() => dashboard?.subscriberGrowth.map((item) => item.count) || [],
|
||||
[dashboard?.subscriberGrowth]
|
||||
)
|
||||
const maxCount = useMemo(() => Math.max(...countMapped), [countMapped])
|
||||
const minCount = useMemo(() => Math.min(...countMapped), [countMapped])
|
||||
const countMapped = useMemo(
|
||||
() => dashboard?.subscriberGrowth.map((item) => item.count) || [],
|
||||
[dashboard?.subscriberGrowth],
|
||||
);
|
||||
const maxCount = useMemo(() => Math.max(...countMapped), [countMapped]);
|
||||
const minCount = useMemo(() => Math.min(...countMapped), [countMapped]);
|
||||
|
||||
return (
|
||||
<Card hoverEffect className="col-span-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Subscriber Growth</CardTitle>
|
||||
<CardDescription>
|
||||
New subscribers over time{" "}
|
||||
<span className="text-xs text-muted-foreground">(Daily)</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-[200px] w-full" />
|
||||
) : (
|
||||
<ChartContainer config={chartConfig}>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={dashboard?.subscriberGrowth || []}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => dayjs(value).format("DD MMM")}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(value) => value.toLocaleString()}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
domain={[minCount, maxCount]}
|
||||
allowDataOverflow={true}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator="dot" />}
|
||||
/>
|
||||
<Area
|
||||
dataKey="count"
|
||||
type="natural"
|
||||
fill="var(--color-count)"
|
||||
fillOpacity={0.4}
|
||||
stroke="var(--color-count)"
|
||||
stackId="a"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-10 w-full" />
|
||||
) : (
|
||||
<div className="flex w-full items-start gap-2 text-sm">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center gap-2 font-medium leading-none">
|
||||
{(analytics?.subscribers?.newThisMonth || 0) >= 0 ? (
|
||||
<>
|
||||
Trending up by {analytics?.subscribers.newThisMonth}% this
|
||||
month <TrendingUp className="h-4 w-4" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Trending down by{" "}
|
||||
{Math.abs(analytics?.subscribers.newThisMonth || 0)}% this
|
||||
month <ArrowDown className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 leading-none text-muted-foreground">
|
||||
Last 30 days
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Card hoverEffect className="col-span-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Subscriber Growth</CardTitle>
|
||||
<CardDescription>
|
||||
New subscribers over time{" "}
|
||||
<span className="text-xs text-muted-foreground">(Daily)</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-[200px] w-full" />
|
||||
) : (
|
||||
<ChartContainer config={chartConfig}>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={dashboard?.subscriberGrowth || []}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => dayjs(value).format("DD MMM")}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(value) => value.toLocaleString()}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
domain={[minCount, maxCount]}
|
||||
allowDataOverflow={true}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator="dot" />}
|
||||
/>
|
||||
<Area
|
||||
dataKey="count"
|
||||
type="natural"
|
||||
fill="var(--color-count)"
|
||||
fillOpacity={0.4}
|
||||
stroke="var(--color-count)"
|
||||
stackId="a"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-10 w-full" />
|
||||
) : (
|
||||
<div className="flex w-full items-start gap-2 text-sm">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center gap-2 font-medium leading-none">
|
||||
{(analytics?.subscribers?.newThisMonth || 0) >= 0 ? (
|
||||
<>
|
||||
Trending up by {analytics?.subscribers.newThisMonth}% this
|
||||
month <TrendingUp className="h-4 w-4" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Trending down by{" "}
|
||||
{Math.abs(analytics?.subscribers.newThisMonth || 0)}% this
|
||||
month <ArrowDown className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 leading-none text-muted-foreground">
|
||||
Last 30 days
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export * from "./layout"
|
||||
export * from "./dashboard"
|
||||
export * from "./subscribers"
|
||||
export * from "./campaigns"
|
||||
export * from "./templates"
|
||||
export * from "./analytics"
|
||||
export * from "./settings"
|
||||
export * from "./lists"
|
||||
export * from "./messages"
|
||||
export * from "./layout";
|
||||
export * from "./dashboard";
|
||||
export * from "./subscribers";
|
||||
export * from "./campaigns";
|
||||
export * from "./templates";
|
||||
export * from "./analytics";
|
||||
export * from "./settings";
|
||||
export * from "./lists";
|
||||
export * from "./messages";
|
||||
|
||||
@@ -1,113 +1,113 @@
|
||||
import {
|
||||
ScrollArea,
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
cn,
|
||||
} from "@repo/ui"
|
||||
import { Link, Navigate, Outlet } from "react-router"
|
||||
ScrollArea,
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
cn,
|
||||
} from "@repo/ui";
|
||||
import { Link, Navigate, Outlet } from "react-router";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Mail,
|
||||
Users,
|
||||
Settings,
|
||||
FileText,
|
||||
User,
|
||||
ListOrdered,
|
||||
Building2,
|
||||
LogOut,
|
||||
LucideIcon,
|
||||
ArrowUpCircle,
|
||||
} from "lucide-react"
|
||||
import { useSession } from "@/hooks"
|
||||
import { useLocation } from "react-router"
|
||||
LayoutDashboard,
|
||||
Mail,
|
||||
Users,
|
||||
Settings,
|
||||
FileText,
|
||||
User,
|
||||
ListOrdered,
|
||||
Building2,
|
||||
LogOut,
|
||||
LucideIcon,
|
||||
ArrowUpCircle,
|
||||
} from "lucide-react";
|
||||
import { useSession } from "@/hooks";
|
||||
import { useLocation } from "react-router";
|
||||
import {
|
||||
CenteredLoader,
|
||||
ThemeToggle,
|
||||
WithTooltip,
|
||||
LetterSpaceText,
|
||||
} from "@/components"
|
||||
import { APP_VERSION } from "@repo/shared"
|
||||
import { useUpdateCheck } from "@/hooks/use-update-check"
|
||||
CenteredLoader,
|
||||
ThemeToggle,
|
||||
WithTooltip,
|
||||
LetterSpaceText,
|
||||
} from "@/components";
|
||||
import { APP_VERSION } from "@repo/shared";
|
||||
import { useUpdateCheck } from "@/hooks/use-update-check";
|
||||
|
||||
const sidebarItems = [
|
||||
{
|
||||
title: "Dashboard",
|
||||
icon: LayoutDashboard,
|
||||
url: "/dashboard",
|
||||
},
|
||||
{
|
||||
title: "Campaigns",
|
||||
icon: Mail,
|
||||
url: "/dashboard/campaigns",
|
||||
},
|
||||
{
|
||||
title: "Subscribers",
|
||||
icon: Users,
|
||||
url: "/dashboard/subscribers",
|
||||
},
|
||||
{
|
||||
title: "Lists",
|
||||
icon: ListOrdered,
|
||||
url: "/dashboard/lists",
|
||||
},
|
||||
{
|
||||
title: "Templates",
|
||||
icon: FileText,
|
||||
url: "/dashboard/templates",
|
||||
},
|
||||
{
|
||||
title: "Messages",
|
||||
icon: Mail,
|
||||
url: "/dashboard/messages",
|
||||
},
|
||||
// TODO: Add analytics page
|
||||
// {
|
||||
// title: "Analytics",
|
||||
// icon: BarChart,
|
||||
// url: "/dashboard/analytics",
|
||||
// },
|
||||
{
|
||||
title: "Settings",
|
||||
icon: Settings,
|
||||
url: "/dashboard/settings",
|
||||
},
|
||||
]
|
||||
{
|
||||
title: "Dashboard",
|
||||
icon: LayoutDashboard,
|
||||
url: "/dashboard",
|
||||
},
|
||||
{
|
||||
title: "Campaigns",
|
||||
icon: Mail,
|
||||
url: "/dashboard/campaigns",
|
||||
},
|
||||
{
|
||||
title: "Subscribers",
|
||||
icon: Users,
|
||||
url: "/dashboard/subscribers",
|
||||
},
|
||||
{
|
||||
title: "Lists",
|
||||
icon: ListOrdered,
|
||||
url: "/dashboard/lists",
|
||||
},
|
||||
{
|
||||
title: "Templates",
|
||||
icon: FileText,
|
||||
url: "/dashboard/templates",
|
||||
},
|
||||
{
|
||||
title: "Messages",
|
||||
icon: Mail,
|
||||
url: "/dashboard/messages",
|
||||
},
|
||||
// TODO: Add analytics page
|
||||
// {
|
||||
// title: "Analytics",
|
||||
// icon: BarChart,
|
||||
// url: "/dashboard/analytics",
|
||||
// },
|
||||
{
|
||||
title: "Settings",
|
||||
icon: Settings,
|
||||
url: "/dashboard/settings",
|
||||
},
|
||||
];
|
||||
|
||||
function NavItem({
|
||||
to,
|
||||
Icon,
|
||||
children,
|
||||
isActive = false,
|
||||
to,
|
||||
Icon,
|
||||
children,
|
||||
isActive = false,
|
||||
}: {
|
||||
to: string
|
||||
Icon?: LucideIcon
|
||||
children: React.ReactNode
|
||||
isActive?: boolean
|
||||
to: string;
|
||||
Icon?: LucideIcon;
|
||||
children: React.ReactNode;
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={cn(
|
||||
"flex items-center px-3 py-2 text-sm rounded-md transition-colors text-sidebar-foreground hover:bg-sidebar-accent",
|
||||
{
|
||||
"text-sidebar-primary-foreground bg-sidebar-primary hover:bg-sidebar-primary":
|
||||
isActive,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4 mr-3 flex-shrink-0" />}
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={cn(
|
||||
"flex items-center px-3 py-2 text-sm rounded-md transition-colors text-sidebar-foreground hover:bg-sidebar-accent",
|
||||
{
|
||||
"text-sidebar-primary-foreground bg-sidebar-primary hover:bg-sidebar-primary":
|
||||
isActive,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4 mr-3 flex-shrink-0" />}
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,120 +116,120 @@ function NavItem({
|
||||
* Redirects to the root path if the user is not authenticated or organization data is missing. Displays a loading state while user data is loading. The sidebar includes navigation links, user and organization info, theme toggle, logout button, and app version. The main area renders nested routes.
|
||||
*/
|
||||
export function DashboardLayout() {
|
||||
const { orgId, user, organization, logout } = useSession()
|
||||
const location = useLocation()
|
||||
const { hasUpdate, latestVersion } = useUpdateCheck()
|
||||
const { orgId, user, organization, logout } = useSession();
|
||||
const location = useLocation();
|
||||
const { hasUpdate, latestVersion } = useUpdateCheck();
|
||||
|
||||
// Helper function to check if a menu item is active
|
||||
const isActive = (itemUrl: string) => {
|
||||
// Exact match for dashboard
|
||||
if (itemUrl === "/dashboard") {
|
||||
return location.pathname === itemUrl
|
||||
}
|
||||
// For other routes, check if the current path starts with the item URL
|
||||
return location.pathname.startsWith(itemUrl)
|
||||
}
|
||||
// Helper function to check if a menu item is active
|
||||
const isActive = (itemUrl: string) => {
|
||||
// Exact match for dashboard
|
||||
if (itemUrl === "/dashboard") {
|
||||
return location.pathname === itemUrl;
|
||||
}
|
||||
// For other routes, check if the current path starts with the item URL
|
||||
return location.pathname.startsWith(itemUrl);
|
||||
};
|
||||
|
||||
if (!orgId) {
|
||||
return <Navigate to="/" />
|
||||
}
|
||||
if (!orgId) {
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
if (user.isLoading) {
|
||||
return <CenteredLoader />
|
||||
}
|
||||
if (user.isLoading) {
|
||||
return <CenteredLoader />;
|
||||
}
|
||||
|
||||
if (!user.data) {
|
||||
return <Navigate to="/" />
|
||||
}
|
||||
if (!user.data) {
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<div className="flex h-screen w-full">
|
||||
<Sidebar>
|
||||
<SidebarHeader>
|
||||
<div className="px-4 py-4">
|
||||
<LetterSpaceText as="h2" />
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Menu</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{sidebarItems.map((item) => (
|
||||
<SidebarMenuItem
|
||||
key={item.title}
|
||||
className={"transition-colors"}
|
||||
>
|
||||
<SidebarMenuButton isActive={isActive(item.url)} asChild>
|
||||
<NavItem
|
||||
to={item.url}
|
||||
Icon={item.icon}
|
||||
isActive={isActive(item.url)}
|
||||
>
|
||||
{item.title}
|
||||
</NavItem>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 hover:bg-muted duration-200 rounded-lg w-full p-2 cursor-pointer text-sm">
|
||||
<User className="h-4 w-4" />
|
||||
<span>{user.data?.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
{organization?.name && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 hover:bg-muted duration-200 rounded-lg w-full p-2 cursor-pointer">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{organization.name}
|
||||
</span>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="flex items-center gap-2 hover:bg-muted duration-200 rounded-lg w-full p-2 cursor-pointer text-sm"
|
||||
onClick={logout}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>Logout</span>
|
||||
</div>
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<div className="flex h-screen w-full">
|
||||
<Sidebar>
|
||||
<SidebarHeader>
|
||||
<div className="px-4 py-4">
|
||||
<LetterSpaceText as="h2" />
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Menu</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{sidebarItems.map((item) => (
|
||||
<SidebarMenuItem
|
||||
key={item.title}
|
||||
className={"transition-colors"}
|
||||
>
|
||||
<SidebarMenuButton isActive={isActive(item.url)} asChild>
|
||||
<NavItem
|
||||
to={item.url}
|
||||
Icon={item.icon}
|
||||
isActive={isActive(item.url)}
|
||||
>
|
||||
{item.title}
|
||||
</NavItem>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 hover:bg-muted duration-200 rounded-lg w-full p-2 cursor-pointer text-sm">
|
||||
<User className="h-4 w-4" />
|
||||
<span>{user.data?.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
{organization?.name && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 hover:bg-muted duration-200 rounded-lg w-full p-2 cursor-pointer">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{organization.name}
|
||||
</span>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="flex items-center gap-2 hover:bg-muted duration-200 rounded-lg w-full p-2 cursor-pointer text-sm"
|
||||
onClick={logout}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>Logout</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 px-2">
|
||||
<WithTooltip content="Current version">
|
||||
<span className="text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||
v{APP_VERSION}
|
||||
</span>
|
||||
</WithTooltip>
|
||||
{hasUpdate && (
|
||||
<a
|
||||
href="https://github.com/dcodesdev/LetterSpace/releases/latest"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-emerald-600 hover:text-emerald-500 transition-colors"
|
||||
>
|
||||
<ArrowUpCircle className="h-3 w-3" />
|
||||
<span>Update available v{latestVersion}</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
<main className="flex-1 overflow-y-auto w-full bg-background text-foreground">
|
||||
<ScrollArea className="h-full">
|
||||
<Outlet />
|
||||
</ScrollArea>
|
||||
</main>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
)
|
||||
<div className="flex items-center justify-between gap-2 px-2">
|
||||
<WithTooltip content="Current version">
|
||||
<span className="text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||
v{APP_VERSION}
|
||||
</span>
|
||||
</WithTooltip>
|
||||
{hasUpdate && (
|
||||
<a
|
||||
href="https://github.com/dcodesdev/LetterSpace/releases/latest"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-emerald-600 hover:text-emerald-500 transition-colors"
|
||||
>
|
||||
<ArrowUpCircle className="h-3 w-3" />
|
||||
<span>Update available v{latestVersion}</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
<main className="flex-1 overflow-y-auto w-full bg-background text-foreground">
|
||||
<ScrollArea className="h-full">
|
||||
<Outlet />
|
||||
</ScrollArea>
|
||||
</main>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,64 +1,64 @@
|
||||
import { ColumnDef } from "@tanstack/react-table"
|
||||
import { Button } from "@repo/ui"
|
||||
import { PencilIcon, Trash2Icon } from "lucide-react"
|
||||
import { IDField } from "./id-field"
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Button } from "@repo/ui";
|
||||
import { PencilIcon, Trash2Icon } from "lucide-react";
|
||||
import { IDField } from "./id-field";
|
||||
|
||||
type List = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
subscriberCount: number
|
||||
}
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
subscriberCount: number;
|
||||
};
|
||||
|
||||
type ColumnsProps = {
|
||||
onDelete: (id: string) => void
|
||||
onEdit: (list: List) => void
|
||||
}
|
||||
onDelete: (id: string) => void;
|
||||
onEdit: (list: List) => void;
|
||||
};
|
||||
|
||||
export const columns = ({
|
||||
onDelete,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onEdit,
|
||||
}: ColumnsProps): ColumnDef<List>[] => [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "ID",
|
||||
cell: ({ row }) => <IDField id={row.original.id} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium">{row.getValue("name")}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "Description",
|
||||
},
|
||||
{
|
||||
accessorKey: "subscriberCount",
|
||||
header: "Subscribers",
|
||||
cell: ({ row }) => row.original.subscriberCount,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onEdit(row.original)}
|
||||
>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDelete(row.original.id)}
|
||||
>
|
||||
<Trash2Icon className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "ID",
|
||||
cell: ({ row }) => <IDField id={row.original.id} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium">{row.getValue("name")}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "Description",
|
||||
},
|
||||
{
|
||||
accessorKey: "subscriberCount",
|
||||
header: "Subscribers",
|
||||
cell: ({ row }) => row.original.subscriberCount,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onEdit(row.original)}
|
||||
>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDelete(row.original.id)}
|
||||
>
|
||||
<Trash2Icon className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
import { WithTooltip } from "@/components"
|
||||
import { Badge } from "@repo/ui"
|
||||
import { CheckIcon, CopyIcon } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { WithTooltip } from "@/components";
|
||||
import { Badge } from "@repo/ui";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export const IDField = ({ id }: { id: string }) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const shortId = id.slice(0, 8)
|
||||
const shortId = id.slice(0, 8);
|
||||
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(id)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(id);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{copied ? (
|
||||
<CheckIcon className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<CopyIcon
|
||||
onClick={copyToClipboard}
|
||||
className="h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{copied ? (
|
||||
<CheckIcon className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<CopyIcon
|
||||
onClick={copyToClipboard}
|
||||
className="h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
|
||||
<WithTooltip content={id}>
|
||||
<Badge className="cursor-pointer" variant="secondary">
|
||||
{shortId}
|
||||
</Badge>
|
||||
</WithTooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<WithTooltip content={id}>
|
||||
<Badge className="cursor-pointer" variant="secondary">
|
||||
{shortId}
|
||||
</Badge>
|
||||
</WithTooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "./page"
|
||||
export * from "./page";
|
||||
|
||||
@@ -1,104 +1,104 @@
|
||||
import {
|
||||
Button,
|
||||
FormField,
|
||||
FormMessage,
|
||||
Textarea,
|
||||
FormDescription,
|
||||
FormControl,
|
||||
Form,
|
||||
FormLabel,
|
||||
FormItem,
|
||||
Input,
|
||||
} from "@repo/ui"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { trpc } from "@/trpc"
|
||||
import { useSession } from "@/hooks"
|
||||
Button,
|
||||
FormField,
|
||||
FormMessage,
|
||||
Textarea,
|
||||
FormDescription,
|
||||
FormControl,
|
||||
Form,
|
||||
FormLabel,
|
||||
FormItem,
|
||||
Input,
|
||||
} from "@repo/ui";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { trpc } from "@/trpc";
|
||||
import { useSession } from "@/hooks";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "List name must be at least 3 characters.",
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
name: z.string().min(3, {
|
||||
message: "List name must be at least 3 characters.",
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export function CreateListForm({ onSuccess }: { onSuccess: () => void }) {
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
},
|
||||
})
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
|
||||
const createList = trpc.list.create.useMutation()
|
||||
const { orgId } = useSession()
|
||||
const utils = trpc.useUtils()
|
||||
const createList = trpc.list.create.useMutation();
|
||||
const { orgId } = useSession();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
createList.mutate(
|
||||
{
|
||||
...values,
|
||||
organizationId: orgId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
form.reset()
|
||||
onSuccess()
|
||||
utils.list.invalidate()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
createList.mutate(
|
||||
{
|
||||
...values,
|
||||
organizationId: orgId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
onSuccess();
|
||||
utils.list.invalidate();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>List Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Awesome List" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Choose a name for your new list.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Describe the purpose of this list."
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Provide a brief description of your list.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" disabled={createList.isPending}>
|
||||
{createList.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Create List
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>List Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Awesome List" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Choose a name for your new list.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Describe the purpose of this list."
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Provide a brief description of your list.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" disabled={createList.isPending}>
|
||||
{createList.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Create List
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Input } from "@repo/ui"
|
||||
import { usePaginationWithQueryState } from "@/hooks/usePagination"
|
||||
import { Input } from "@repo/ui";
|
||||
import { usePaginationWithQueryState } from "@/hooks/usePagination";
|
||||
|
||||
export const ListSearch = () => {
|
||||
const { pagination, setPagination } = usePaginationWithQueryState()
|
||||
const { pagination, setPagination } = usePaginationWithQueryState();
|
||||
|
||||
return (
|
||||
<Input
|
||||
placeholder="Search lists..."
|
||||
value={pagination.search}
|
||||
onChange={(event) => setPagination("search", event.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
placeholder="Search lists..."
|
||||
value={pagination.search}
|
||||
onChange={(event) => setPagination("search", event.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,200 +1,200 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Button,
|
||||
DataTable,
|
||||
} from "@repo/ui"
|
||||
import { Plus } from "lucide-react"
|
||||
import { CreateListForm } from "./list-form"
|
||||
import { useSession, usePaginationWithQueryState } from "@/hooks"
|
||||
import { trpc } from "@/trpc"
|
||||
import { columns } from "./columns"
|
||||
import { Pagination } from "@/components"
|
||||
import { UpdateListForm } from "./update-list-form"
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Button,
|
||||
DataTable,
|
||||
} from "@repo/ui";
|
||||
import { Plus } from "lucide-react";
|
||||
import { CreateListForm } from "./list-form";
|
||||
import { useSession, usePaginationWithQueryState } from "@/hooks";
|
||||
import { trpc } from "@/trpc";
|
||||
import { columns } from "./columns";
|
||||
import { Pagination } from "@/components";
|
||||
import { UpdateListForm } from "./update-list-form";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogCancel,
|
||||
} from "@repo/ui"
|
||||
import { ListSearch } from "./list-search"
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogCancel,
|
||||
} from "@repo/ui";
|
||||
import { ListSearch } from "./list-search";
|
||||
|
||||
export function ListsPage() {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [listToDelete, setListToDelete] = useState<string | null>(null)
|
||||
const [editDialogStates, setEditDialogStates] = useState<
|
||||
Record<string, boolean>
|
||||
>({})
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [listToDelete, setListToDelete] = useState<string | null>(null);
|
||||
const [editDialogStates, setEditDialogStates] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const { pagination, setPagination } = usePaginationWithQueryState()
|
||||
const { pagination, setPagination } = usePaginationWithQueryState();
|
||||
|
||||
const { orgId } = useSession()
|
||||
const utils = trpc.useUtils()
|
||||
const { orgId } = useSession();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data, isLoading } = trpc.list.list.useQuery(
|
||||
{
|
||||
organizationId: orgId,
|
||||
page: pagination.page,
|
||||
perPage: pagination.perPage,
|
||||
search: pagination.searchQuery,
|
||||
},
|
||||
{
|
||||
enabled: !!orgId,
|
||||
}
|
||||
)
|
||||
const { data, isLoading } = trpc.list.list.useQuery(
|
||||
{
|
||||
organizationId: orgId,
|
||||
page: pagination.page,
|
||||
perPage: pagination.perPage,
|
||||
search: pagination.searchQuery,
|
||||
},
|
||||
{
|
||||
enabled: !!orgId,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPagination("totalPages", data?.pagination.totalPages)
|
||||
}, [data]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
setPagination("totalPages", data?.pagination.totalPages);
|
||||
}, [data]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const deleteList = trpc.list.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.list.invalidate()
|
||||
},
|
||||
})
|
||||
const deleteList = trpc.list.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.list.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const lists = data?.lists.map((list) => ({
|
||||
id: list.id,
|
||||
name: list.name,
|
||||
description: list.description ?? "",
|
||||
subscriberCount: list._count.ListSubscribers,
|
||||
}))
|
||||
const lists = data?.lists.map((list) => ({
|
||||
id: list.id,
|
||||
name: list.name,
|
||||
description: list.description ?? "",
|
||||
subscriberCount: list._count.ListSubscribers,
|
||||
}));
|
||||
|
||||
const toggleEditDialog = (listId: string, isOpen: boolean) => {
|
||||
setEditDialogStates((prev) => ({
|
||||
...prev,
|
||||
[listId]: isOpen,
|
||||
}))
|
||||
}
|
||||
const toggleEditDialog = (listId: string, isOpen: boolean) => {
|
||||
setEditDialogStates((prev) => ({
|
||||
...prev,
|
||||
[listId]: isOpen,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Lists</h2>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Lists</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-1 items-center space-x-2">
|
||||
<ListSearch />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* <Button variant="outline" size="icon">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-1 items-center space-x-2">
|
||||
<ListSearch />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* <Button variant="outline" size="icon">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button> */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" /> Create New List
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a New List</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new list to organize your subscribers.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<CreateListForm onSuccess={() => setIsDialogOpen(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" /> Create New List
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a New List</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new list to organize your subscribers.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<CreateListForm onSuccess={() => setIsDialogOpen(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
title="Lists"
|
||||
columns={columns({
|
||||
onDelete: (id) => setListToDelete(id),
|
||||
onEdit: (list) => toggleEditDialog(list.id, true),
|
||||
})}
|
||||
data={lists ?? []}
|
||||
className="h-[calc(100vh-290px)]"
|
||||
isLoading={isLoading}
|
||||
NoResultsContent={
|
||||
<div className="flex flex-col items-center justify-center h-full my-10 gap-3">
|
||||
<p className="text-sm text-muted-foreground">No lists found.</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create a new list to get started.
|
||||
</p>
|
||||
<Button onClick={() => setIsDialogOpen(true)}>
|
||||
Create a List <Plus className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<DataTable
|
||||
title="Lists"
|
||||
columns={columns({
|
||||
onDelete: (id) => setListToDelete(id),
|
||||
onEdit: (list) => toggleEditDialog(list.id, true),
|
||||
})}
|
||||
data={lists ?? []}
|
||||
className="h-[calc(100vh-290px)]"
|
||||
isLoading={isLoading}
|
||||
NoResultsContent={
|
||||
<div className="flex flex-col items-center justify-center h-full my-10 gap-3">
|
||||
<p className="text-sm text-muted-foreground">No lists found.</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create a new list to get started.
|
||||
</p>
|
||||
<Button onClick={() => setIsDialogOpen(true)}>
|
||||
Create a List <Plus className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{data?.pagination.total ?? 0} total lists
|
||||
</div>
|
||||
<Pagination
|
||||
page={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={(page) => setPagination("page", page)}
|
||||
hasNextPage={pagination.page < pagination.totalPages}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{data?.pagination.total ?? 0} total lists
|
||||
</div>
|
||||
<Pagination
|
||||
page={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={(page) => setPagination("page", page)}
|
||||
hasNextPage={pagination.page < pagination.totalPages}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete List Alert Dialog */}
|
||||
<AlertDialog
|
||||
open={!!listToDelete}
|
||||
onOpenChange={() => setListToDelete(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
list and remove all subscriber associations.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (listToDelete) {
|
||||
deleteList.mutate({ id: listToDelete })
|
||||
setListToDelete(null)
|
||||
}
|
||||
}}
|
||||
disabled={deleteList.isPending}
|
||||
>
|
||||
{deleteList.isPending ? "Deleting..." : "Delete List"}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{/* Delete List Alert Dialog */}
|
||||
<AlertDialog
|
||||
open={!!listToDelete}
|
||||
onOpenChange={() => setListToDelete(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
list and remove all subscriber associations.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (listToDelete) {
|
||||
deleteList.mutate({ id: listToDelete });
|
||||
setListToDelete(null);
|
||||
}
|
||||
}}
|
||||
disabled={deleteList.isPending}
|
||||
>
|
||||
{deleteList.isPending ? "Deleting..." : "Delete List"}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Edit List Dialogs */}
|
||||
{lists?.map((list) => (
|
||||
<Dialog
|
||||
key={list.id}
|
||||
open={editDialogStates[list.id] ?? false}
|
||||
onOpenChange={(open) => toggleEditDialog(list.id, open)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit List</DialogTitle>
|
||||
</DialogHeader>
|
||||
<UpdateListForm
|
||||
list={list}
|
||||
onSuccess={() => toggleEditDialog(list.id, false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
{/* Edit List Dialogs */}
|
||||
{lists?.map((list) => (
|
||||
<Dialog
|
||||
key={list.id}
|
||||
open={editDialogStates[list.id] ?? false}
|
||||
onOpenChange={(open) => toggleEditDialog(list.id, open)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit List</DialogTitle>
|
||||
</DialogHeader>
|
||||
<UpdateListForm
|
||||
list={list}
|
||||
onSuccess={() => toggleEditDialog(list.id, false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,108 +1,108 @@
|
||||
import {
|
||||
Button,
|
||||
FormField,
|
||||
FormMessage,
|
||||
Textarea,
|
||||
FormDescription,
|
||||
FormControl,
|
||||
Form,
|
||||
FormLabel,
|
||||
FormItem,
|
||||
Input,
|
||||
} from "@repo/ui"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { trpc } from "@/trpc"
|
||||
Button,
|
||||
FormField,
|
||||
FormMessage,
|
||||
Textarea,
|
||||
FormDescription,
|
||||
FormControl,
|
||||
Form,
|
||||
FormLabel,
|
||||
FormItem,
|
||||
Input,
|
||||
} from "@repo/ui";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { trpc } from "@/trpc";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "List name must be at least 3 characters.",
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
name: z.string().min(3, {
|
||||
message: "List name must be at least 3 characters.",
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
type UpdateListFormProps = {
|
||||
list: {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
onSuccess: () => void
|
||||
}
|
||||
list: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
export function UpdateListForm({ list, onSuccess }: UpdateListFormProps) {
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: list.name,
|
||||
description: list.description,
|
||||
},
|
||||
})
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: list.name,
|
||||
description: list.description,
|
||||
},
|
||||
});
|
||||
|
||||
const updateList = trpc.list.update.useMutation()
|
||||
const utils = trpc.useUtils()
|
||||
const updateList = trpc.list.update.useMutation();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
updateList.mutate(
|
||||
{
|
||||
id: list.id,
|
||||
...values,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onSuccess()
|
||||
utils.list.invalidate()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
updateList.mutate(
|
||||
{
|
||||
id: list.id,
|
||||
...values,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onSuccess();
|
||||
utils.list.invalidate();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>List Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Awesome List" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Choose a name for your list.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Describe the purpose of this list."
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Provide a brief description of your list.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" disabled={updateList.isPending}>
|
||||
{updateList.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Update List
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>List Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Awesome List" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Choose a name for your list.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Describe the purpose of this list."
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Provide a brief description of your list.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" disabled={updateList.isPending}>
|
||||
{updateList.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Update List
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,87 +1,87 @@
|
||||
import { ColumnDef } from "@tanstack/react-table"
|
||||
import { Link } from "react-router"
|
||||
import { MessageStatusBadge } from "./message-status-badge"
|
||||
import { Message } from "backend"
|
||||
import { displayDateTime } from "@/utils"
|
||||
import { MessageActions } from "./message-actions"
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Link } from "react-router";
|
||||
import { MessageStatusBadge } from "./message-status-badge";
|
||||
import { Message } from "backend";
|
||||
import { displayDateTime } from "@/utils";
|
||||
import { MessageActions } from "./message-actions";
|
||||
|
||||
type MessageWithRelations = Message & {
|
||||
Subscriber: {
|
||||
name: string | null
|
||||
email: string
|
||||
}
|
||||
Campaign: {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
}
|
||||
Subscriber: {
|
||||
name: string | null;
|
||||
email: string;
|
||||
};
|
||||
Campaign: {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ColumnsProps = {
|
||||
onOpenPreview: (id: string) => void
|
||||
onOpenError: (id: string) => void
|
||||
openPreviews: Record<string, boolean>
|
||||
openErrors: Record<string, boolean>
|
||||
onClosePreview: (id: string) => void
|
||||
onCloseError: (id: string) => void
|
||||
}
|
||||
onOpenPreview: (id: string) => void;
|
||||
onOpenError: (id: string) => void;
|
||||
openPreviews: Record<string, boolean>;
|
||||
openErrors: Record<string, boolean>;
|
||||
onClosePreview: (id: string) => void;
|
||||
onCloseError: (id: string) => void;
|
||||
};
|
||||
|
||||
export const columns = ({
|
||||
onOpenPreview,
|
||||
onOpenError,
|
||||
openPreviews,
|
||||
openErrors,
|
||||
onClosePreview,
|
||||
onCloseError,
|
||||
onOpenPreview,
|
||||
onOpenError,
|
||||
openPreviews,
|
||||
openErrors,
|
||||
onClosePreview,
|
||||
onCloseError,
|
||||
}: ColumnsProps): ColumnDef<MessageWithRelations>[] => {
|
||||
return [
|
||||
{
|
||||
accessorKey: "recipient",
|
||||
header: "Recipient",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium">{row.original.Subscriber.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{row.original.Subscriber.email}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "campaign",
|
||||
header: "Campaign",
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
to={`/dashboard/campaigns/${row.original.Campaign.id}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{row.original.Campaign.title}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => <MessageStatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "sentAt",
|
||||
header: "Sent At",
|
||||
cell: ({ row }) =>
|
||||
row.original.sentAt ? displayDateTime(row.original.sentAt) : "-",
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<MessageActions
|
||||
message={row.original}
|
||||
onOpenPreview={onOpenPreview}
|
||||
onClosePreview={onClosePreview}
|
||||
onOpenError={onOpenError}
|
||||
onCloseError={onCloseError}
|
||||
openPreviews={openPreviews}
|
||||
openErrors={openErrors}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
accessorKey: "recipient",
|
||||
header: "Recipient",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium">{row.original.Subscriber.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{row.original.Subscriber.email}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "campaign",
|
||||
header: "Campaign",
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
to={`/dashboard/campaigns/${row.original.Campaign.id}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{row.original.Campaign.title}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => <MessageStatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "sentAt",
|
||||
header: "Sent At",
|
||||
cell: ({ row }) =>
|
||||
row.original.sentAt ? displayDateTime(row.original.sentAt) : "-",
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<MessageActions
|
||||
message={row.original}
|
||||
onOpenPreview={onOpenPreview}
|
||||
onClosePreview={onClosePreview}
|
||||
onOpenError={onOpenError}
|
||||
onCloseError={onCloseError}
|
||||
openPreviews={openPreviews}
|
||||
openErrors={openErrors}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "./page"
|
||||
export * from "./page";
|
||||
|
||||
@@ -1,142 +1,142 @@
|
||||
import {
|
||||
Button,
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@repo/ui"
|
||||
import { Eye, AlertCircle, Send, Loader2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { trpc } from "@/trpc"
|
||||
import { useSession } from "@/hooks"
|
||||
import type { Message } from "backend" // Assuming Message type includes relations after backend change
|
||||
import { MessagePreviewDialog } from "./message-preview-dialog"
|
||||
import { MessageErrorDialog } from "./message-error-dialog"
|
||||
Button,
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@repo/ui";
|
||||
import { Eye, AlertCircle, Send, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/trpc";
|
||||
import { useSession } from "@/hooks";
|
||||
import type { Message } from "backend"; // Assuming Message type includes relations after backend change
|
||||
import { MessagePreviewDialog } from "./message-preview-dialog";
|
||||
import { MessageErrorDialog } from "./message-error-dialog";
|
||||
|
||||
type MessageWithRelations = Message & {
|
||||
Subscriber: {
|
||||
name: string | null
|
||||
email: string
|
||||
}
|
||||
Campaign: {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
}
|
||||
Subscriber: {
|
||||
name: string | null;
|
||||
email: string;
|
||||
};
|
||||
Campaign: {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
};
|
||||
|
||||
interface MessageActionsProps {
|
||||
message: MessageWithRelations
|
||||
onOpenPreview: (id: string) => void
|
||||
onClosePreview: (id: string) => void
|
||||
onOpenError: (id: string) => void
|
||||
onCloseError: (id: string) => void
|
||||
openPreviews: Record<string, boolean>
|
||||
openErrors: Record<string, boolean>
|
||||
message: MessageWithRelations;
|
||||
onOpenPreview: (id: string) => void;
|
||||
onClosePreview: (id: string) => void;
|
||||
onOpenError: (id: string) => void;
|
||||
onCloseError: (id: string) => void;
|
||||
openPreviews: Record<string, boolean>;
|
||||
openErrors: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export function MessageActions({
|
||||
message,
|
||||
onOpenPreview,
|
||||
onClosePreview,
|
||||
onOpenError,
|
||||
onCloseError,
|
||||
openPreviews,
|
||||
openErrors,
|
||||
message,
|
||||
onOpenPreview,
|
||||
onClosePreview,
|
||||
onOpenError,
|
||||
onCloseError,
|
||||
openPreviews,
|
||||
openErrors,
|
||||
}: MessageActionsProps) {
|
||||
const { organization } = useSession()
|
||||
const utils = trpc.useUtils()
|
||||
const { organization } = useSession();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const resendMutation = trpc.message.resend.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(`Message queued for resending.`)
|
||||
utils.message.list.invalidate()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to resend message: ${error.message}`)
|
||||
},
|
||||
})
|
||||
const resendMutation = trpc.message.resend.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(`Message queued for resending.`);
|
||||
utils.message.list.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to resend message: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleResend = () => {
|
||||
if (resendMutation.isPending) {
|
||||
return
|
||||
}
|
||||
const handleResend = () => {
|
||||
if (resendMutation.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!organization?.id) {
|
||||
toast.error("Organization ID not found. Cannot resend.")
|
||||
return
|
||||
}
|
||||
resendMutation.mutate({
|
||||
messageId: message.id,
|
||||
organizationId: organization.id,
|
||||
})
|
||||
}
|
||||
if (!organization?.id) {
|
||||
toast.error("Organization ID not found. Cannot resend.");
|
||||
return;
|
||||
}
|
||||
resendMutation.mutate({
|
||||
messageId: message.id,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" title="Resend Message">
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action will attempt to resend the message to{" "}
|
||||
{message.Subscriber.email}.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleResend}>
|
||||
{resendMutation.isPending && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Confirm Resend
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" title="Resend Message">
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action will attempt to resend the message to{" "}
|
||||
{message.Subscriber.email}.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleResend}>
|
||||
{resendMutation.isPending && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Confirm Resend
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Preview Message"
|
||||
onClick={() => onOpenPreview(message.id)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
{message.error && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="View Error"
|
||||
className="text-destructive"
|
||||
onClick={() => onOpenError(message.id)}
|
||||
>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<MessagePreviewDialog
|
||||
message={message}
|
||||
open={openPreviews[message.id] ?? false}
|
||||
onOpenChange={(open) =>
|
||||
open ? onOpenPreview(message.id) : onClosePreview(message.id)
|
||||
}
|
||||
/>
|
||||
<MessageErrorDialog
|
||||
message={message}
|
||||
open={openErrors[message.id] ?? false}
|
||||
onOpenChange={(open) =>
|
||||
open ? onOpenError(message.id) : onCloseError(message.id)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Preview Message"
|
||||
onClick={() => onOpenPreview(message.id)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
{message.error && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="View Error"
|
||||
className="text-destructive"
|
||||
onClick={() => onOpenError(message.id)}
|
||||
>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<MessagePreviewDialog
|
||||
message={message}
|
||||
open={openPreviews[message.id] ?? false}
|
||||
onOpenChange={(open) =>
|
||||
open ? onOpenPreview(message.id) : onClosePreview(message.id)
|
||||
}
|
||||
/>
|
||||
<MessageErrorDialog
|
||||
message={message}
|
||||
open={openErrors[message.id] ?? false}
|
||||
onOpenChange={(open) =>
|
||||
open ? onOpenError(message.id) : onCloseError(message.id)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@repo/ui"
|
||||
import { Message } from "backend"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@repo/ui";
|
||||
import { Message } from "backend";
|
||||
|
||||
interface MessageErrorDialogProps {
|
||||
message: Message
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
message: Message;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function MessageErrorDialog({
|
||||
message,
|
||||
open,
|
||||
onOpenChange,
|
||||
message,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: MessageErrorDialogProps) {
|
||||
if (!message.error) return null
|
||||
if (!message.error) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Message Error Details</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium mb-2">Error Message:</div>
|
||||
<div className="p-4 rounded-md bg-destructive/10 text-destructive">
|
||||
{message.error}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div>Message ID: {message.id}</div>
|
||||
<div>Status: {message.status}</div>
|
||||
<div>Time: {new Date(message.updatedAt).toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Message Error Details</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium mb-2">Error Message:</div>
|
||||
<div className="p-4 rounded-md bg-destructive/10 text-destructive">
|
||||
{message.error}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div>Message ID: {message.id}</div>
|
||||
<div>Status: {message.status}</div>
|
||||
<div>Time: {new Date(message.updatedAt).toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@repo/ui"
|
||||
import { Message } from "backend"
|
||||
import { EmailPreview } from "@/components"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@repo/ui";
|
||||
import { Message } from "backend";
|
||||
import { EmailPreview } from "@/components";
|
||||
|
||||
interface MessagePreviewDialogProps {
|
||||
message: Message & { Campaign: { title: string } }
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
message: Message & { Campaign: { title: string } };
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function MessagePreviewDialog({
|
||||
message,
|
||||
open,
|
||||
onOpenChange,
|
||||
message,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: MessagePreviewDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[800px] h-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Message Preview - {message.Campaign.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<EmailPreview
|
||||
content={message?.content ?? "No content available"}
|
||||
className="h-[450px]"
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[800px] h-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Message Preview - {message.Campaign.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<EmailPreview
|
||||
content={message?.content ?? "No content available"}
|
||||
className="h-[450px]"
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Input } from "@repo/ui"
|
||||
import { usePaginationWithQueryState } from "@/hooks/usePagination"
|
||||
import { Input } from "@repo/ui";
|
||||
import { usePaginationWithQueryState } from "@/hooks/usePagination";
|
||||
|
||||
export const MessageSearch = () => {
|
||||
const { pagination, setPagination } = usePaginationWithQueryState()
|
||||
const { pagination, setPagination } = usePaginationWithQueryState();
|
||||
|
||||
return (
|
||||
<Input
|
||||
placeholder="Search messages..."
|
||||
value={pagination.search}
|
||||
onChange={(event) => setPagination("search", event.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
placeholder="Search messages..."
|
||||
value={pagination.search}
|
||||
onChange={(event) => setPagination("search", event.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,70 +1,70 @@
|
||||
import { cn } from "@repo/ui"
|
||||
import { MessageStatus } from "backend"
|
||||
import { cn } from "@repo/ui";
|
||||
import { MessageStatus } from "backend";
|
||||
|
||||
const statusConfig: Record<
|
||||
MessageStatus,
|
||||
{ label: string; className: string }
|
||||
MessageStatus,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
QUEUED: {
|
||||
label: "Queued",
|
||||
className:
|
||||
"bg-primary text-primary-foreground dark:bg-primary dark:text-primary-foreground",
|
||||
},
|
||||
PENDING: {
|
||||
label: "Pending",
|
||||
className:
|
||||
"bg-secondary text-secondary-foreground dark:bg-secondary dark:text-secondary-foreground",
|
||||
},
|
||||
SENT: {
|
||||
label: "Sent",
|
||||
className:
|
||||
"bg-success text-success-foreground dark:bg-success dark:text-success-foreground",
|
||||
},
|
||||
FAILED: {
|
||||
label: "Failed",
|
||||
className:
|
||||
"bg-destructive text-destructive-foreground dark:bg-destructive dark:text-destructive-foreground",
|
||||
},
|
||||
OPENED: {
|
||||
label: "Opened",
|
||||
className: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
||||
},
|
||||
CLICKED: {
|
||||
label: "Clicked",
|
||||
className:
|
||||
"bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200",
|
||||
},
|
||||
RETRYING: {
|
||||
label: "Retrying",
|
||||
className:
|
||||
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
|
||||
},
|
||||
CANCELLED: {
|
||||
label: "Cancelled",
|
||||
className: "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200",
|
||||
},
|
||||
}
|
||||
QUEUED: {
|
||||
label: "Queued",
|
||||
className:
|
||||
"bg-primary text-primary-foreground dark:bg-primary dark:text-primary-foreground",
|
||||
},
|
||||
PENDING: {
|
||||
label: "Pending",
|
||||
className:
|
||||
"bg-secondary text-secondary-foreground dark:bg-secondary dark:text-secondary-foreground",
|
||||
},
|
||||
SENT: {
|
||||
label: "Sent",
|
||||
className:
|
||||
"bg-success text-success-foreground dark:bg-success dark:text-success-foreground",
|
||||
},
|
||||
FAILED: {
|
||||
label: "Failed",
|
||||
className:
|
||||
"bg-destructive text-destructive-foreground dark:bg-destructive dark:text-destructive-foreground",
|
||||
},
|
||||
OPENED: {
|
||||
label: "Opened",
|
||||
className: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
||||
},
|
||||
CLICKED: {
|
||||
label: "Clicked",
|
||||
className:
|
||||
"bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200",
|
||||
},
|
||||
RETRYING: {
|
||||
label: "Retrying",
|
||||
className:
|
||||
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
|
||||
},
|
||||
CANCELLED: {
|
||||
label: "Cancelled",
|
||||
className: "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200",
|
||||
},
|
||||
};
|
||||
|
||||
interface MessageStatusBadgeProps {
|
||||
status: MessageStatus
|
||||
className?: string
|
||||
status: MessageStatus;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MessageStatusBadge({
|
||||
status,
|
||||
className,
|
||||
status,
|
||||
className,
|
||||
}: MessageStatusBadgeProps) {
|
||||
const config = statusConfig[status]
|
||||
const config = statusConfig[status];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
|
||||
config.className,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
|
||||
config.className,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,59 +1,59 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@repo/ui"
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@repo/ui";
|
||||
|
||||
// Values from prisma schema
|
||||
const messageStatuses = [
|
||||
"QUEUED",
|
||||
"PENDING",
|
||||
"SENT",
|
||||
"OPENED",
|
||||
"CLICKED",
|
||||
"FAILED",
|
||||
"RETRYING",
|
||||
] as const
|
||||
"QUEUED",
|
||||
"PENDING",
|
||||
"SENT",
|
||||
"OPENED",
|
||||
"CLICKED",
|
||||
"FAILED",
|
||||
"RETRYING",
|
||||
] as const;
|
||||
|
||||
export type MessageStatus = (typeof messageStatuses)[number]
|
||||
export type MessageStatus = (typeof messageStatuses)[number];
|
||||
|
||||
interface MessageStatusFilterProps {
|
||||
selectedStatus: MessageStatus | undefined
|
||||
onStatusChange: (status: MessageStatus | undefined) => void
|
||||
selectedStatus: MessageStatus | undefined;
|
||||
onStatusChange: (status: MessageStatus | undefined) => void;
|
||||
}
|
||||
|
||||
export function MessageStatusFilter({
|
||||
selectedStatus,
|
||||
onStatusChange,
|
||||
selectedStatus,
|
||||
onStatusChange,
|
||||
}: MessageStatusFilterProps) {
|
||||
const handleValueChange = (value: string) => {
|
||||
if (value === "ALL_STATUSES") {
|
||||
onStatusChange(undefined)
|
||||
} else {
|
||||
onStatusChange(value as MessageStatus)
|
||||
}
|
||||
}
|
||||
const handleValueChange = (value: string) => {
|
||||
if (value === "ALL_STATUSES") {
|
||||
onStatusChange(undefined);
|
||||
} else {
|
||||
onStatusChange(value as MessageStatus);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={selectedStatus ?? "ALL_STATUSES"}
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL_STATUSES">All Statuses</SelectItem>
|
||||
{messageStatuses.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1).toLowerCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
return (
|
||||
<Select
|
||||
value={selectedStatus ?? "ALL_STATUSES"}
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL_STATUSES">All Statuses</SelectItem>
|
||||
{messageStatuses.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1).toLowerCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,216 +1,214 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle, DataTable } from "@repo/ui"
|
||||
import { Mail, Send, Users, ArrowUp } from "lucide-react"
|
||||
import { useSession, usePaginationWithQueryState } from "@/hooks"
|
||||
import { trpc } from "@/trpc"
|
||||
import { CardSkeleton, StatCard, Pagination } from "@/components"
|
||||
import { columns as getColumns } from "./columns"
|
||||
import { MessageSearch } from "./message-search"
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, DataTable } from "@repo/ui";
|
||||
import { Mail, Send, Users, ArrowUp } from "lucide-react";
|
||||
import { useSession, usePaginationWithQueryState } from "@/hooks";
|
||||
import { trpc } from "@/trpc";
|
||||
import { CardSkeleton, StatCard, Pagination } from "@/components";
|
||||
import { columns as getColumns } from "./columns";
|
||||
import { MessageSearch } from "./message-search";
|
||||
import {
|
||||
MessageStatusFilter,
|
||||
type MessageStatus,
|
||||
} from "./message-status-filter"
|
||||
MessageStatusFilter,
|
||||
type MessageStatus,
|
||||
} from "./message-status-filter";
|
||||
|
||||
export function MessagesPage() {
|
||||
const [openPreviews, setOpenPreviews] = useState<Record<string, boolean>>({})
|
||||
const [openErrors, setOpenErrors] = useState<Record<string, boolean>>({})
|
||||
const [statusFilter, setStatusFilter] = useState<MessageStatus | undefined>()
|
||||
const { pagination, setPagination } = usePaginationWithQueryState({
|
||||
perPage: 100,
|
||||
})
|
||||
const [openPreviews, setOpenPreviews] = useState<Record<string, boolean>>({});
|
||||
const [openErrors, setOpenErrors] = useState<Record<string, boolean>>({});
|
||||
const [statusFilter, setStatusFilter] = useState<MessageStatus | undefined>();
|
||||
const { pagination, setPagination } = usePaginationWithQueryState({
|
||||
perPage: 100,
|
||||
});
|
||||
|
||||
const { organization } = useSession()
|
||||
const { organization } = useSession();
|
||||
|
||||
const { data, isLoading } = trpc.message.list.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
page: pagination.page,
|
||||
perPage: pagination.perPage,
|
||||
search: pagination.searchQuery,
|
||||
status: statusFilter,
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
refetchInterval: 60_000,
|
||||
}
|
||||
)
|
||||
const { data, isLoading } = trpc.message.list.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
page: pagination.page,
|
||||
perPage: pagination.perPage,
|
||||
search: pagination.searchQuery,
|
||||
status: statusFilter,
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
refetchInterval: 60_000,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPagination("totalPages", data?.pagination.totalPages)
|
||||
}, [data]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
setPagination("totalPages", data?.pagination.totalPages);
|
||||
}, [data]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleOpenPreview = useCallback(
|
||||
(messageId: string) => {
|
||||
setOpenPreviews((prev) => ({ ...prev, [messageId]: true }))
|
||||
},
|
||||
[setOpenPreviews]
|
||||
)
|
||||
const handleOpenPreview = useCallback(
|
||||
(messageId: string) => {
|
||||
setOpenPreviews((prev) => ({ ...prev, [messageId]: true }));
|
||||
},
|
||||
[setOpenPreviews],
|
||||
);
|
||||
|
||||
const handleClosePreview = useCallback(
|
||||
(messageId: string) => {
|
||||
setOpenPreviews((prev) => ({ ...prev, [messageId]: false }))
|
||||
},
|
||||
[setOpenPreviews]
|
||||
)
|
||||
const handleClosePreview = useCallback(
|
||||
(messageId: string) => {
|
||||
setOpenPreviews((prev) => ({ ...prev, [messageId]: false }));
|
||||
},
|
||||
[setOpenPreviews],
|
||||
);
|
||||
|
||||
const handleOpenError = useCallback(
|
||||
(messageId: string) => {
|
||||
setOpenErrors((prev) => ({ ...prev, [messageId]: true }))
|
||||
},
|
||||
[setOpenErrors]
|
||||
)
|
||||
const handleOpenError = useCallback(
|
||||
(messageId: string) => {
|
||||
setOpenErrors((prev) => ({ ...prev, [messageId]: true }));
|
||||
},
|
||||
[setOpenErrors],
|
||||
);
|
||||
|
||||
const handleCloseError = useCallback(
|
||||
(messageId: string) => {
|
||||
setOpenErrors((prev) => ({ ...prev, [messageId]: false }))
|
||||
},
|
||||
[setOpenErrors]
|
||||
)
|
||||
const handleCloseError = useCallback(
|
||||
(messageId: string) => {
|
||||
setOpenErrors((prev) => ({ ...prev, [messageId]: false }));
|
||||
},
|
||||
[setOpenErrors],
|
||||
);
|
||||
|
||||
const { data: analytics, isLoading: analyticsLoading } =
|
||||
trpc.stats.getStats.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
}
|
||||
)
|
||||
const { data: analytics, isLoading: analyticsLoading } =
|
||||
trpc.stats.getStats.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
},
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
getColumns({
|
||||
onOpenPreview: handleOpenPreview,
|
||||
onOpenError: handleOpenError,
|
||||
onClosePreview: handleClosePreview,
|
||||
onCloseError: handleCloseError,
|
||||
openPreviews,
|
||||
openErrors,
|
||||
}),
|
||||
[
|
||||
handleOpenPreview,
|
||||
handleOpenError,
|
||||
handleClosePreview,
|
||||
handleCloseError,
|
||||
openPreviews,
|
||||
openErrors,
|
||||
]
|
||||
)
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
getColumns({
|
||||
onOpenPreview: handleOpenPreview,
|
||||
onOpenError: handleOpenError,
|
||||
onClosePreview: handleClosePreview,
|
||||
onCloseError: handleCloseError,
|
||||
openPreviews,
|
||||
openErrors,
|
||||
}),
|
||||
[
|
||||
handleOpenPreview,
|
||||
handleOpenError,
|
||||
handleClosePreview,
|
||||
handleCloseError,
|
||||
openPreviews,
|
||||
openErrors,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Messages</h2>
|
||||
</div>
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Messages</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Messages
|
||||
</CardTitle>
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{analyticsLoading || !analytics ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{analytics.messages.total.toLocaleString()}
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Messages
|
||||
</CardTitle>
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{analyticsLoading || !analytics ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{analytics.messages.total.toLocaleString()}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
<span className="text-emerald-500 inline-flex items-center">
|
||||
<ArrowUp className="mr-1 h-4 w-4" />
|
||||
{analytics.messages.last30Days >= 0 ? "+" : "-"}
|
||||
{Math.abs(
|
||||
analytics.messages.last30Days
|
||||
).toLocaleString()}{" "}
|
||||
This month
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Recipients</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{analyticsLoading || !analytics ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{Number(analytics?.recipients.allTime).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
<span className="text-emerald-500 inline-flex items-center">
|
||||
<ArrowUp className="mr-1 h-4 w-4" />
|
||||
{Number(analytics.recipients.comparison) >= 0 ? "+" : "-"}
|
||||
{Math.abs(Number(analytics.recipients.comparison))} vs
|
||||
last period
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StatCard
|
||||
isLoading={analyticsLoading}
|
||||
smallTitle="Last 30 days"
|
||||
title="Delivery Rate"
|
||||
value={`${analytics?.deliveryRate.thisMonth.rate.toFixed(1)}%`}
|
||||
icon={Send}
|
||||
change={analytics?.deliveryRate.comparison}
|
||||
subtitle={`${analytics?.deliveryRate.thisMonth.delivered.toLocaleString()} out of ${analytics?.messages.last30Days.toLocaleString()} total messages`}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
<span className="text-emerald-500 inline-flex items-center">
|
||||
<ArrowUp className="mr-1 h-4 w-4" />
|
||||
{analytics.messages.last30Days >= 0 ? "+" : "-"}
|
||||
{Math.abs(analytics.messages.last30Days).toLocaleString()}{" "}
|
||||
This month
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Recipients</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{analyticsLoading || !analytics ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{Number(analytics?.recipients.allTime).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
<span className="text-emerald-500 inline-flex items-center">
|
||||
<ArrowUp className="mr-1 h-4 w-4" />
|
||||
{Number(analytics.recipients.comparison) >= 0 ? "+" : "-"}
|
||||
{Math.abs(Number(analytics.recipients.comparison))} vs
|
||||
last period
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StatCard
|
||||
isLoading={analyticsLoading}
|
||||
smallTitle="Last 30 days"
|
||||
title="Delivery Rate"
|
||||
value={`${analytics?.deliveryRate.thisMonth.rate.toFixed(1)}%`}
|
||||
icon={Send}
|
||||
change={analytics?.deliveryRate.comparison}
|
||||
subtitle={`${analytics?.deliveryRate.thisMonth.delivered.toLocaleString()} out of ${analytics?.messages.last30Days.toLocaleString()} total messages`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSearch />
|
||||
<MessageStatusFilter
|
||||
selectedStatus={statusFilter}
|
||||
onStatusChange={setStatusFilter}
|
||||
/>
|
||||
{/* <Button variant="outline" size="icon" className="ml-auto">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSearch />
|
||||
<MessageStatusFilter
|
||||
selectedStatus={statusFilter}
|
||||
onStatusChange={setStatusFilter}
|
||||
/>
|
||||
{/* <Button variant="outline" size="icon" className="ml-auto">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
title="Messages"
|
||||
columns={columns}
|
||||
data={data?.messages ?? []}
|
||||
className="h-[calc(100vh-452px)]"
|
||||
isLoading={isLoading}
|
||||
NoResultsContent={
|
||||
<div className="flex flex-col items-center justify-center h-full my-10 gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No messages found.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<DataTable
|
||||
title="Messages"
|
||||
columns={columns}
|
||||
data={data?.messages ?? []}
|
||||
className="h-[calc(100vh-452px)]"
|
||||
isLoading={isLoading}
|
||||
NoResultsContent={
|
||||
<div className="flex flex-col items-center justify-center h-full my-10 gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No messages found.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{data?.pagination.total ?? 0} total messages
|
||||
</div>
|
||||
<Pagination
|
||||
page={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={(page) => setPagination("page", page)}
|
||||
hasNextPage={pagination.page < pagination.totalPages}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{data?.pagination.total ?? 0} total messages
|
||||
</div>
|
||||
<Pagination
|
||||
page={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={(page) => setPagination("page", page)}
|
||||
hasNextPage={pagination.page < pagination.totalPages}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,359 +1,359 @@
|
||||
import { useState } from "react"
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
Button,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableCell,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
Input,
|
||||
FormMessage,
|
||||
Calendar,
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
FormDescription,
|
||||
cn,
|
||||
} from "@repo/ui"
|
||||
import { Plus, Trash2, Key, CalendarIcon, Eye, EyeOff } from "lucide-react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
import { trpc } from "@/trpc"
|
||||
import { useSession } from "@/hooks"
|
||||
import { toast } from "sonner"
|
||||
import { CopyButton, Loader } from "@/components"
|
||||
import { format } from "date-fns"
|
||||
import dayjs from "dayjs"
|
||||
import relativeTime from "dayjs/plugin/relativeTime"
|
||||
import { AlertDialogConfirmation } from "@/components"
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
Button,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableCell,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
Input,
|
||||
FormMessage,
|
||||
Calendar,
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
FormDescription,
|
||||
cn,
|
||||
} from "@repo/ui";
|
||||
import { Plus, Trash2, Key, CalendarIcon, Eye, EyeOff } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { trpc } from "@/trpc";
|
||||
import { useSession } from "@/hooks";
|
||||
import { toast } from "sonner";
|
||||
import { CopyButton, Loader } from "@/components";
|
||||
import { format } from "date-fns";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { AlertDialogConfirmation } from "@/components";
|
||||
|
||||
// Initialize dayjs plugins
|
||||
dayjs.extend(relativeTime)
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const createApiKeySchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
expiresAt: z.date().optional(),
|
||||
})
|
||||
name: z.string().min(1, "Name is required"),
|
||||
expiresAt: z.date().optional(),
|
||||
});
|
||||
|
||||
export function ApiKeys() {
|
||||
const { organization } = useSession()
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [newKey, setNewKey] = useState<string | null>(null)
|
||||
const [showKey, setShowKey] = useState(false)
|
||||
const { organization } = useSession();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newKey, setNewKey] = useState<string | null>(null);
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
|
||||
const { data: apiKeys, isLoading } = trpc.settings.listApiKeys.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
}
|
||||
)
|
||||
const { data: apiKeys, isLoading } = trpc.settings.listApiKeys.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
},
|
||||
);
|
||||
|
||||
const form = useForm<z.infer<typeof createApiKeySchema>>({
|
||||
resolver: zodResolver(createApiKeySchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
expiresAt: undefined,
|
||||
},
|
||||
})
|
||||
const form = useForm<z.infer<typeof createApiKeySchema>>({
|
||||
resolver: zodResolver(createApiKeySchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
expiresAt: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const createApiKey = trpc.settings.createApiKey.useMutation({
|
||||
onSuccess: ({ key }) => {
|
||||
setNewKey(key)
|
||||
utils.settings.listApiKeys.invalidate()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
const createApiKey = trpc.settings.createApiKey.useMutation({
|
||||
onSuccess: ({ key }) => {
|
||||
setNewKey(key);
|
||||
utils.settings.listApiKeys.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteApiKey = trpc.settings.deleteApiKey.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("API key deleted")
|
||||
utils.settings.listApiKeys.invalidate()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
const deleteApiKey = trpc.settings.deleteApiKey.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("API key deleted");
|
||||
utils.settings.listApiKeys.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (values: z.infer<typeof createApiKeySchema>) => {
|
||||
if (!organization?.id) return
|
||||
createApiKey.mutate({
|
||||
organizationId: organization.id,
|
||||
name: values.name,
|
||||
expiresAt: values.expiresAt ? values.expiresAt.toISOString() : undefined,
|
||||
})
|
||||
}
|
||||
const onSubmit = (values: z.infer<typeof createApiKeySchema>) => {
|
||||
if (!organization?.id) return;
|
||||
createApiKey.mutate({
|
||||
organizationId: organization.id,
|
||||
name: values.name,
|
||||
expiresAt: values.expiresAt ? values.expiresAt.toISOString() : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>API Keys</CardTitle>
|
||||
</div>
|
||||
<Dialog
|
||||
open={isCreating}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setNewKey(null)
|
||||
form.reset()
|
||||
}
|
||||
setIsCreating(open)
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create API Key
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create API Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new API key for accessing the API
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{newKey ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm font-medium">Your API Key:</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<code
|
||||
className={cn(
|
||||
"flex-1 break-all rounded bg-muted px-2 py-1 text-sm",
|
||||
!showKey && "blur-sm select-none"
|
||||
)}
|
||||
>
|
||||
{newKey}
|
||||
</code>
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>API Keys</CardTitle>
|
||||
</div>
|
||||
<Dialog
|
||||
open={isCreating}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setNewKey(null);
|
||||
form.reset();
|
||||
}
|
||||
setIsCreating(open);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create API Key
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create API Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new API key for accessing the API
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{newKey ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm font-medium">Your API Key:</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<code
|
||||
className={cn(
|
||||
"flex-1 break-all rounded bg-muted px-2 py-1 text-sm",
|
||||
!showKey && "blur-sm select-none",
|
||||
)}
|
||||
>
|
||||
{newKey}
|
||||
</code>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
>
|
||||
{showKey ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
>
|
||||
{showKey ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<CopyButton
|
||||
onCopy={() => navigator.clipboard.writeText(newKey)}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Make sure to copy your API key now. You won't be able to
|
||||
see it again!
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsCreating(false)
|
||||
setNewKey(null)
|
||||
setShowKey(false)
|
||||
form.reset()
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My API Key" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="expiresAt"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>Expires At (Optional)</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full pl-3 text-left font-normal",
|
||||
!field.value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value ? (
|
||||
format(field.value, "PPP")
|
||||
) : (
|
||||
<span>Pick a date</span>
|
||||
)}
|
||||
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-auto p-0"
|
||||
align="start"
|
||||
>
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={field.value}
|
||||
onSelect={field.onChange}
|
||||
disabled={(date) =>
|
||||
date < new Date() ||
|
||||
date < new Date("1900-01-01")
|
||||
}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormDescription>
|
||||
When this API key should expire. If not set, the key
|
||||
will never expire.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsCreating(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createApiKey.isPending}>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-[400px] flex-col items-center justify-center rounded-lg border border-dashed">
|
||||
<Loader />
|
||||
</div>
|
||||
) : !apiKeys?.length ? (
|
||||
<div className="flex min-h-[400px] flex-col items-center justify-center rounded-lg border border-dashed">
|
||||
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted">
|
||||
<Key className="h-10 w-10" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-semibold">No API keys</h3>
|
||||
<p className="mb-4 mt-2 text-sm text-muted-foreground">
|
||||
You haven't created any API keys yet. Create one to get started
|
||||
with the API.
|
||||
</p>
|
||||
<Button onClick={() => setIsCreating(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create API Key
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Last Used</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apiKeys?.map((key) => (
|
||||
<TableRow key={key.id}>
|
||||
<TableCell>{key.name}</TableCell>
|
||||
<TableCell>
|
||||
{key.lastUsed ? dayjs(key.lastUsed).fromNow() : "Never"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{key.expiresAt ? (
|
||||
<div className="space-y-1">
|
||||
<div>{format(new Date(key.expiresAt), "PPP")}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{dayjs(key.expiresAt).fromNow()}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
"Never"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div>{format(new Date(key.createdAt), "PPP")}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{dayjs(key.createdAt).fromNow()}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AlertDialogConfirmation
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
title="Delete API Key"
|
||||
description="Are you sure you want to delete this API key? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
variant="destructive"
|
||||
onConfirm={() => {
|
||||
deleteApiKey.mutate({
|
||||
id: key.id,
|
||||
organizationId: organization?.id ?? "",
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
<CopyButton
|
||||
onCopy={() => navigator.clipboard.writeText(newKey)}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Make sure to copy your API key now. You won't be able to
|
||||
see it again!
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsCreating(false);
|
||||
setNewKey(null);
|
||||
setShowKey(false);
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My API Key" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="expiresAt"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>Expires At (Optional)</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full pl-3 text-left font-normal",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{field.value ? (
|
||||
format(field.value, "PPP")
|
||||
) : (
|
||||
<span>Pick a date</span>
|
||||
)}
|
||||
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-auto p-0"
|
||||
align="start"
|
||||
>
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={field.value}
|
||||
onSelect={field.onChange}
|
||||
disabled={(date) =>
|
||||
date < new Date() ||
|
||||
date < new Date("1900-01-01")
|
||||
}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormDescription>
|
||||
When this API key should expire. If not set, the key
|
||||
will never expire.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsCreating(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createApiKey.isPending}>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-[400px] flex-col items-center justify-center rounded-lg border border-dashed">
|
||||
<Loader />
|
||||
</div>
|
||||
) : !apiKeys?.length ? (
|
||||
<div className="flex min-h-[400px] flex-col items-center justify-center rounded-lg border border-dashed">
|
||||
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted">
|
||||
<Key className="h-10 w-10" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-semibold">No API keys</h3>
|
||||
<p className="mb-4 mt-2 text-sm text-muted-foreground">
|
||||
You haven't created any API keys yet. Create one to get started
|
||||
with the API.
|
||||
</p>
|
||||
<Button onClick={() => setIsCreating(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create API Key
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Last Used</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apiKeys?.map((key) => (
|
||||
<TableRow key={key.id}>
|
||||
<TableCell>{key.name}</TableCell>
|
||||
<TableCell>
|
||||
{key.lastUsed ? dayjs(key.lastUsed).fromNow() : "Never"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{key.expiresAt ? (
|
||||
<div className="space-y-1">
|
||||
<div>{format(new Date(key.expiresAt), "PPP")}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{dayjs(key.expiresAt).fromNow()}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
"Never"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div>{format(new Date(key.createdAt), "PPP")}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{dayjs(key.createdAt).fromNow()}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AlertDialogConfirmation
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
title="Delete API Key"
|
||||
description="Are you sure you want to delete this API key? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
variant="destructive"
|
||||
onConfirm={() => {
|
||||
deleteApiKey.mutate({
|
||||
id: key.id,
|
||||
organizationId: organization?.id ?? "",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,265 +1,265 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Separator,
|
||||
} from "@repo/ui"
|
||||
import { toast } from "sonner"
|
||||
import { trpc } from "@/trpc"
|
||||
import { useSession } from "@/hooks"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Save } from "lucide-react"
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Separator,
|
||||
} from "@repo/ui";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/trpc";
|
||||
import { useSession } from "@/hooks";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Save } from "lucide-react";
|
||||
const emailSchema = z.object({
|
||||
rateLimit: z.coerce.number().min(1, "Rate limit is required"),
|
||||
rateWindow: z.coerce.number().min(1, "Rate window is required"),
|
||||
maxRetries: z.coerce.number().min(0, "Max retries must be 0 or greater"),
|
||||
retryDelay: z.coerce.number().min(1, "Retry delay is required"),
|
||||
concurrency: z.coerce.number().min(1, "Concurrency must be at least 1"),
|
||||
connectionTimeout: z.coerce
|
||||
.number()
|
||||
.min(1, "Connection timeout must be at least 1"),
|
||||
})
|
||||
rateLimit: z.coerce.number().min(1, "Rate limit is required"),
|
||||
rateWindow: z.coerce.number().min(1, "Rate window is required"),
|
||||
maxRetries: z.coerce.number().min(0, "Max retries must be 0 or greater"),
|
||||
retryDelay: z.coerce.number().min(1, "Retry delay is required"),
|
||||
concurrency: z.coerce.number().min(1, "Concurrency must be at least 1"),
|
||||
connectionTimeout: z.coerce
|
||||
.number()
|
||||
.min(1, "Connection timeout must be at least 1"),
|
||||
});
|
||||
|
||||
type EmailSettings = z.infer<typeof emailSchema>
|
||||
type EmailSettings = z.infer<typeof emailSchema>;
|
||||
|
||||
export function EmailSettings() {
|
||||
const { organization } = useSession()
|
||||
const utils = trpc.useUtils()
|
||||
const [initialLoad, setInitialLoad] = useState(true)
|
||||
const { organization } = useSession();
|
||||
const utils = trpc.useUtils();
|
||||
const [initialLoad, setInitialLoad] = useState(true);
|
||||
|
||||
const { data: settings } = trpc.settings.getEmailDelivery.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
}
|
||||
)
|
||||
const { data: settings } = trpc.settings.getEmailDelivery.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
},
|
||||
);
|
||||
|
||||
const form = useForm<EmailSettings>({
|
||||
resolver: zodResolver(emailSchema),
|
||||
defaultValues: {
|
||||
rateLimit: 100,
|
||||
rateWindow: 3600,
|
||||
maxRetries: 3,
|
||||
retryDelay: 300,
|
||||
concurrency: 5,
|
||||
connectionTimeout: 30000,
|
||||
},
|
||||
})
|
||||
const form = useForm<EmailSettings>({
|
||||
resolver: zodResolver(emailSchema),
|
||||
defaultValues: {
|
||||
rateLimit: 100,
|
||||
rateWindow: 3600,
|
||||
maxRetries: 3,
|
||||
retryDelay: 300,
|
||||
concurrency: 5,
|
||||
connectionTimeout: 30000,
|
||||
},
|
||||
});
|
||||
|
||||
const { isDirty } = form.formState
|
||||
const { isDirty } = form.formState;
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialLoad || !settings) return
|
||||
useEffect(() => {
|
||||
if (!initialLoad || !settings) return;
|
||||
|
||||
form.reset({
|
||||
rateLimit: settings.rateLimit,
|
||||
rateWindow: settings.rateWindow,
|
||||
maxRetries: settings.maxRetries,
|
||||
retryDelay: settings.retryDelay,
|
||||
concurrency: settings.concurrency,
|
||||
connectionTimeout: settings.connectionTimeout,
|
||||
})
|
||||
setInitialLoad(false)
|
||||
}, [settings, form, initialLoad])
|
||||
form.reset({
|
||||
rateLimit: settings.rateLimit,
|
||||
rateWindow: settings.rateWindow,
|
||||
maxRetries: settings.maxRetries,
|
||||
retryDelay: settings.retryDelay,
|
||||
concurrency: settings.concurrency,
|
||||
connectionTimeout: settings.connectionTimeout,
|
||||
});
|
||||
setInitialLoad(false);
|
||||
}, [settings, form, initialLoad]);
|
||||
|
||||
const updateSettings = trpc.settings.updateEmailDelivery.useMutation({
|
||||
onSuccess: ({ settings }) => {
|
||||
form.reset(settings)
|
||||
utils.settings.getEmailDelivery.invalidate()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
const updateSettings = trpc.settings.updateEmailDelivery.useMutation({
|
||||
onSuccess: ({ settings }) => {
|
||||
form.reset(settings);
|
||||
utils.settings.getEmailDelivery.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: EmailSettings) {
|
||||
if (!organization?.id) return
|
||||
updateSettings.mutate({
|
||||
organizationId: organization.id,
|
||||
...values,
|
||||
})
|
||||
}
|
||||
function onSubmit(values: EmailSettings) {
|
||||
if (!organization?.id) return;
|
||||
updateSettings.mutate({
|
||||
organizationId: organization.id,
|
||||
...values,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Email Delivery Settings</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{isDirty && (
|
||||
<p className="text-sm text-muted-foreground">Unsaved changes</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form="email-form"
|
||||
disabled={updateSettings.isPending}
|
||||
loading={updateSettings.isPending}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="email-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-8"
|
||||
>
|
||||
{/* Rate Limiting */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Rate Limiting</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Control how many emails can be sent within a time period
|
||||
</p>
|
||||
<Separator className="my-4" />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rateLimit"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Rate Limit</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Maximum number of emails to send within the rate window
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Email Delivery Settings</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{isDirty && (
|
||||
<p className="text-sm text-muted-foreground">Unsaved changes</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form="email-form"
|
||||
disabled={updateSettings.isPending}
|
||||
loading={updateSettings.isPending}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="email-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-8"
|
||||
>
|
||||
{/* Rate Limiting */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Rate Limiting</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Control how many emails can be sent within a time period
|
||||
</p>
|
||||
<Separator className="my-4" />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rateLimit"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Rate Limit</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Maximum number of emails to send within the rate window
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rateWindow"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Rate Window (seconds)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Time window in seconds for the rate limit
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rateWindow"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Rate Window (seconds)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Time window in seconds for the rate limit
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Retry Settings */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Retry Settings</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Configure how failed emails should be retried
|
||||
</p>
|
||||
<Separator className="my-4" />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="maxRetries"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Max Retries</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Maximum number of retry attempts for failed emails
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* Retry Settings */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Retry Settings</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Configure how failed emails should be retried
|
||||
</p>
|
||||
<Separator className="my-4" />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="maxRetries"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Max Retries</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Maximum number of retry attempts for failed emails
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="retryDelay"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Retry Delay (seconds)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Delay between retry attempts in seconds
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="retryDelay"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Retry Delay (seconds)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Delay between retry attempts in seconds
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Settings */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Advanced Settings</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Additional configuration options for email delivery
|
||||
</p>
|
||||
<Separator className="my-4" />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="concurrency"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Concurrency</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Number of emails to send in parallel
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* Advanced Settings */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Advanced Settings</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Additional configuration options for email delivery
|
||||
</p>
|
||||
<Separator className="my-4" />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="concurrency"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Concurrency</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Number of emails to send in parallel
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="connectionTimeout"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connection Timeout (ms)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Time in milliseconds to wait for SMTP connection
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="connectionTimeout"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connection Timeout (ms)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Time in milliseconds to wait for SMTP connection
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,206 +1,206 @@
|
||||
import { useEffect } from "react"
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
Button,
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
Input,
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from "@repo/ui"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
import { trpc } from "@/trpc"
|
||||
import { useSession } from "@/hooks"
|
||||
import { toast } from "sonner"
|
||||
import { LinkIcon, Save } from "lucide-react"
|
||||
import { WithTooltip, FormControlledInput } from "@/components"
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
Button,
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
Input,
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from "@repo/ui";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { trpc } from "@/trpc";
|
||||
import { useSession } from "@/hooks";
|
||||
import { toast } from "sonner";
|
||||
import { LinkIcon, Save } from "lucide-react";
|
||||
import { WithTooltip, FormControlledInput } from "@/components";
|
||||
|
||||
const generalSettingsSchema = z.object({
|
||||
defaultFromEmail: z.string().email().optional().or(z.literal("")),
|
||||
defaultFromName: z.string().optional(),
|
||||
baseURL: z.string().url().optional().or(z.literal("")),
|
||||
cleanupInterval: z.coerce.number().int().min(1).optional(),
|
||||
})
|
||||
defaultFromEmail: z.string().email().optional().or(z.literal("")),
|
||||
defaultFromName: z.string().optional(),
|
||||
baseURL: z.string().url().optional().or(z.literal("")),
|
||||
cleanupInterval: z.coerce.number().int().min(1).optional(),
|
||||
});
|
||||
|
||||
export function GeneralSettings() {
|
||||
const { organization } = useSession()
|
||||
const utils = trpc.useUtils()
|
||||
const { organization } = useSession();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: settings } = trpc.settings.getGeneral.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
}
|
||||
)
|
||||
const { data: settings } = trpc.settings.getGeneral.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
},
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(generalSettingsSchema),
|
||||
defaultValues: {
|
||||
defaultFromEmail: "",
|
||||
defaultFromName: "",
|
||||
baseURL: "",
|
||||
cleanupInterval: 90,
|
||||
},
|
||||
})
|
||||
const form = useForm({
|
||||
resolver: zodResolver(generalSettingsSchema),
|
||||
defaultValues: {
|
||||
defaultFromEmail: "",
|
||||
defaultFromName: "",
|
||||
baseURL: "",
|
||||
cleanupInterval: 90,
|
||||
},
|
||||
});
|
||||
|
||||
const { isDirty } = form.formState
|
||||
const { isDirty } = form.formState;
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
form.reset({
|
||||
defaultFromEmail: settings.defaultFromEmail ?? "",
|
||||
defaultFromName: settings.defaultFromName ?? "",
|
||||
baseURL: settings.baseURL ?? "",
|
||||
cleanupInterval: settings.cleanupInterval ?? 90,
|
||||
})
|
||||
}
|
||||
}, [settings, form])
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
form.reset({
|
||||
defaultFromEmail: settings.defaultFromEmail ?? "",
|
||||
defaultFromName: settings.defaultFromName ?? "",
|
||||
baseURL: settings.baseURL ?? "",
|
||||
cleanupInterval: settings.cleanupInterval ?? 90,
|
||||
});
|
||||
}
|
||||
}, [settings, form]);
|
||||
|
||||
const updateSettings = trpc.settings.updateGeneral.useMutation({
|
||||
onSuccess: ({ settings }) => {
|
||||
form.reset({
|
||||
defaultFromEmail: settings.defaultFromEmail ?? "",
|
||||
defaultFromName: settings.defaultFromName ?? "",
|
||||
baseURL: settings.baseURL ?? "",
|
||||
cleanupInterval: settings.cleanupInterval ?? 90,
|
||||
})
|
||||
utils.settings.getGeneral.invalidate()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
const updateSettings = trpc.settings.updateGeneral.useMutation({
|
||||
onSuccess: ({ settings }) => {
|
||||
form.reset({
|
||||
defaultFromEmail: settings.defaultFromEmail ?? "",
|
||||
defaultFromName: settings.defaultFromName ?? "",
|
||||
baseURL: settings.baseURL ?? "",
|
||||
cleanupInterval: settings.cleanupInterval ?? 90,
|
||||
});
|
||||
utils.settings.getGeneral.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (values: z.infer<typeof generalSettingsSchema>) => {
|
||||
if (!organization?.id) return
|
||||
updateSettings.mutate({
|
||||
organizationId: organization.id,
|
||||
...values,
|
||||
})
|
||||
}
|
||||
const onSubmit = (values: z.infer<typeof generalSettingsSchema>) => {
|
||||
if (!organization?.id) return;
|
||||
updateSettings.mutate({
|
||||
organizationId: organization.id,
|
||||
...values,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>General Settings</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{isDirty && (
|
||||
<p className="text-sm text-muted-foreground">Unsaved changes</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form="general-settings-form"
|
||||
disabled={updateSettings.isPending}
|
||||
loading={updateSettings.isPending}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="general-settings-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-8"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="defaultFromEmail"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Default From Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="default@example.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>General Settings</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{isDirty && (
|
||||
<p className="text-sm text-muted-foreground">Unsaved changes</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form="general-settings-form"
|
||||
disabled={updateSettings.isPending}
|
||||
loading={updateSettings.isPending}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="general-settings-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-8"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="defaultFromEmail"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Default From Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="default@example.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="defaultFromName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Default From Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Default Sender Name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="defaultFromName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Default From Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Default Sender Name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="baseURL"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Base URL</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input placeholder="https://your-domain.com" {...field} />
|
||||
<WithTooltip content="Set current domain">
|
||||
<Button
|
||||
onClick={() =>
|
||||
form.setValue("baseURL", window.location.origin, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
type="button"
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</WithTooltip>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The base URL for your application (used for tracking links
|
||||
and unsubscribe pages)
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="baseURL"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Base URL</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input placeholder="https://your-domain.com" {...field} />
|
||||
<WithTooltip content="Set current domain">
|
||||
<Button
|
||||
onClick={() =>
|
||||
form.setValue("baseURL", window.location.origin, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
type="button"
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</WithTooltip>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The base URL for your application (used for tracking links
|
||||
and unsubscribe pages)
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormControlledInput
|
||||
control={form.control}
|
||||
name="cleanupInterval"
|
||||
label="Cleanup Interval (days)"
|
||||
description="Number of days after which to cleanup old data (e.g. sent messages, logs). Default is 90 days."
|
||||
inputProps={{
|
||||
type: "number",
|
||||
placeholder: "90",
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
<FormControlledInput
|
||||
control={form.control}
|
||||
name="cleanupInterval"
|
||||
label="Cleanup Interval (days)"
|
||||
description="Number of days after which to cleanup old data (e.g. sent messages, logs). Default is 90 days."
|
||||
inputProps={{
|
||||
type: "number",
|
||||
placeholder: "90",
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "./page"
|
||||
export * from "./page";
|
||||
|
||||
@@ -1,141 +1,141 @@
|
||||
import { useEffect } from "react"
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
Button,
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
Input,
|
||||
FormMessage,
|
||||
Textarea,
|
||||
} from "@repo/ui"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
import { trpc } from "@/trpc"
|
||||
import { useSession } from "@/hooks"
|
||||
import { toast } from "sonner"
|
||||
import { Save } from "lucide-react"
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
Button,
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
Input,
|
||||
FormMessage,
|
||||
Textarea,
|
||||
} from "@repo/ui";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { trpc } from "@/trpc";
|
||||
import { useSession } from "@/hooks";
|
||||
import { toast } from "sonner";
|
||||
import { Save } from "lucide-react";
|
||||
|
||||
const organizationSettingsSchema = z.object({
|
||||
name: z.string().min(1, "Organization name is required."),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
name: z.string().min(1, "Organization name is required."),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export function OrganizationSettings() {
|
||||
const { organization } = useSession()
|
||||
const utils = trpc.useUtils()
|
||||
const { organization } = useSession();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const form = useForm<z.infer<typeof organizationSettingsSchema>>({
|
||||
resolver: zodResolver(organizationSettingsSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
},
|
||||
})
|
||||
const form = useForm<z.infer<typeof organizationSettingsSchema>>({
|
||||
resolver: zodResolver(organizationSettingsSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
|
||||
const { isDirty } = form.formState
|
||||
const { isDirty } = form.formState;
|
||||
|
||||
useEffect(() => {
|
||||
if (organization) {
|
||||
form.reset({
|
||||
name: organization.name ?? "",
|
||||
description: organization.description ?? "",
|
||||
})
|
||||
}
|
||||
}, [organization, form])
|
||||
useEffect(() => {
|
||||
if (organization) {
|
||||
form.reset({
|
||||
name: organization.name ?? "",
|
||||
description: organization.description ?? "",
|
||||
});
|
||||
}
|
||||
}, [organization, form]);
|
||||
|
||||
const updateMutation = trpc.organization.update.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
if (data?.organization) {
|
||||
form.reset({
|
||||
name: data.organization.name ?? "",
|
||||
description: data.organization.description ?? "",
|
||||
})
|
||||
toast.success("Organization settings updated successfully.")
|
||||
}
|
||||
utils.organization.getById.invalidate({ id: organization?.id ?? "" })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to update organization settings.")
|
||||
},
|
||||
})
|
||||
const updateMutation = trpc.organization.update.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
if (data?.organization) {
|
||||
form.reset({
|
||||
name: data.organization.name ?? "",
|
||||
description: data.organization.description ?? "",
|
||||
});
|
||||
toast.success("Organization settings updated successfully.");
|
||||
}
|
||||
utils.organization.getById.invalidate({ id: organization?.id ?? "" });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to update organization settings.");
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (values: z.infer<typeof organizationSettingsSchema>) => {
|
||||
if (!organization?.id) return
|
||||
updateMutation.mutate({
|
||||
id: organization.id,
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
})
|
||||
}
|
||||
const onSubmit = (values: z.infer<typeof organizationSettingsSchema>) => {
|
||||
if (!organization?.id) return;
|
||||
updateMutation.mutate({
|
||||
id: organization.id,
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Organization Details</CardTitle>
|
||||
<div className="flex items-center gap-4">
|
||||
{isDirty && (
|
||||
<p className="text-sm text-muted-foreground">Unsaved changes</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form="organization-settings-form"
|
||||
disabled={updateMutation.isPending || !isDirty}
|
||||
loading={updateMutation.isPending}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="organization-settings-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Organization Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Your Company, Inc." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Tell us a little bit about your organization."
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Organization Details</CardTitle>
|
||||
<div className="flex items-center gap-4">
|
||||
{isDirty && (
|
||||
<p className="text-sm text-muted-foreground">Unsaved changes</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form="organization-settings-form"
|
||||
disabled={updateMutation.isPending || !isDirty}
|
||||
loading={updateMutation.isPending}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="organization-settings-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Organization Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Your Company, Inc." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Tell us a little bit about your organization."
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,83 +1,83 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { TabsContent, TabsTrigger, TabsList, Tabs } from "@repo/ui"
|
||||
import { SmtpSettings } from "./smtp-settings"
|
||||
import { GeneralSettings } from "./general-settings"
|
||||
import { ApiKeys } from "./api-keys"
|
||||
import { EmailSettings } from "./email-delivery-settings"
|
||||
import { OrganizationSettings } from "./organization-settings"
|
||||
import { trpc } from "@/trpc"
|
||||
import { useSession } from "@/hooks"
|
||||
import { Loader } from "@/components"
|
||||
import { ProfileSettings } from "./profile-settings"
|
||||
import { TabsContent, TabsTrigger, TabsList, Tabs } from "@repo/ui";
|
||||
import { SmtpSettings } from "./smtp-settings";
|
||||
import { GeneralSettings } from "./general-settings";
|
||||
import { ApiKeys } from "./api-keys";
|
||||
import { EmailSettings } from "./email-delivery-settings";
|
||||
import { OrganizationSettings } from "./organization-settings";
|
||||
import { trpc } from "@/trpc";
|
||||
import { useSession } from "@/hooks";
|
||||
import { Loader } from "@/components";
|
||||
import { ProfileSettings } from "./profile-settings";
|
||||
|
||||
export function SettingsPage() {
|
||||
const { organization } = useSession()
|
||||
const { isLoading } = trpc.settings.getSmtp.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
}
|
||||
)
|
||||
const { organization } = useSession();
|
||||
const { isLoading } = trpc.settings.getSmtp.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
},
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Settings</h2>
|
||||
</div>
|
||||
<Loader text="Loading settings..." />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Settings</h2>
|
||||
</div>
|
||||
<Loader text="Loading settings..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Settings</h2>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Settings</h2>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="profile" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="profile">Profile</TabsTrigger>
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="organization">Organization</TabsTrigger>
|
||||
<TabsTrigger value="smtp">SMTP</TabsTrigger>
|
||||
<TabsTrigger value="email">Email Delivery</TabsTrigger>
|
||||
<TabsTrigger value="api">API Keys</TabsTrigger>
|
||||
{/* <TabsTrigger value="webhooks">Webhooks</TabsTrigger> */}
|
||||
</TabsList>
|
||||
<TabsContent value="profile">
|
||||
<ProfileSettings />
|
||||
</TabsContent>
|
||||
<TabsContent value="general">
|
||||
<div className="max-w-4xl">
|
||||
<GeneralSettings />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="organization">
|
||||
<OrganizationSettings />
|
||||
</TabsContent>
|
||||
<TabsContent value="smtp" className="space-y-4">
|
||||
<div className="max-w-4xl">
|
||||
<SmtpSettings />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="email" className="space-y-4">
|
||||
<div className="max-w-4xl">
|
||||
<EmailSettings />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="api">
|
||||
<ApiKeys />
|
||||
</TabsContent>
|
||||
{/* <TabsContent value="webhooks">
|
||||
<Tabs defaultValue="profile" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="profile">Profile</TabsTrigger>
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="organization">Organization</TabsTrigger>
|
||||
<TabsTrigger value="smtp">SMTP</TabsTrigger>
|
||||
<TabsTrigger value="email">Email Delivery</TabsTrigger>
|
||||
<TabsTrigger value="api">API Keys</TabsTrigger>
|
||||
{/* <TabsTrigger value="webhooks">Webhooks</TabsTrigger> */}
|
||||
</TabsList>
|
||||
<TabsContent value="profile">
|
||||
<ProfileSettings />
|
||||
</TabsContent>
|
||||
<TabsContent value="general">
|
||||
<div className="max-w-4xl">
|
||||
<GeneralSettings />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="organization">
|
||||
<OrganizationSettings />
|
||||
</TabsContent>
|
||||
<TabsContent value="smtp" className="space-y-4">
|
||||
<div className="max-w-4xl">
|
||||
<SmtpSettings />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="email" className="space-y-4">
|
||||
<div className="max-w-4xl">
|
||||
<EmailSettings />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="api">
|
||||
<ApiKeys />
|
||||
</TabsContent>
|
||||
{/* <TabsContent value="webhooks">
|
||||
<WebhookSettings />
|
||||
</TabsContent> */}
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,255 +1,255 @@
|
||||
import { useEffect } from "react"
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
Button,
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
Input,
|
||||
FormMessage,
|
||||
} from "@repo/ui"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
import { trpc } from "@/trpc"
|
||||
import { useSession } from "@/hooks"
|
||||
import { toast } from "sonner"
|
||||
import { Save, KeyRound } from "lucide-react"
|
||||
import Cookies from "js-cookie"
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
Button,
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
Input,
|
||||
FormMessage,
|
||||
} from "@repo/ui";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { trpc } from "@/trpc";
|
||||
import { useSession } from "@/hooks";
|
||||
import { toast } from "sonner";
|
||||
import { Save, KeyRound } from "lucide-react";
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
const profileSettingsSchema = z.object({
|
||||
name: z.string().min(1, "Name is required."),
|
||||
email: z.string().email("Invalid email address."),
|
||||
})
|
||||
name: z.string().min(1, "Name is required."),
|
||||
email: z.string().email("Invalid email address."),
|
||||
});
|
||||
|
||||
const changePasswordSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(1, "Current password is required."),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(8, "New password must be at least 8 characters."),
|
||||
confirmNewPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmNewPassword, {
|
||||
message: "New passwords do not match.",
|
||||
path: ["confirmNewPassword"],
|
||||
})
|
||||
.object({
|
||||
currentPassword: z.string().min(1, "Current password is required."),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(8, "New password must be at least 8 characters."),
|
||||
confirmNewPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmNewPassword, {
|
||||
message: "New passwords do not match.",
|
||||
path: ["confirmNewPassword"],
|
||||
});
|
||||
|
||||
export function ProfileSettings() {
|
||||
const { user: { data: user } = {} } = useSession()
|
||||
const utils = trpc.useUtils()
|
||||
const { user: { data: user } = {} } = useSession();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const profileForm = useForm<z.infer<typeof profileSettingsSchema>>({
|
||||
resolver: zodResolver(profileSettingsSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
email: "",
|
||||
},
|
||||
})
|
||||
const profileForm = useForm<z.infer<typeof profileSettingsSchema>>({
|
||||
resolver: zodResolver(profileSettingsSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
email: "",
|
||||
},
|
||||
});
|
||||
|
||||
const passwordForm = useForm<z.infer<typeof changePasswordSchema>>({
|
||||
resolver: zodResolver(changePasswordSchema),
|
||||
defaultValues: {
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmNewPassword: "",
|
||||
},
|
||||
})
|
||||
const passwordForm = useForm<z.infer<typeof changePasswordSchema>>({
|
||||
resolver: zodResolver(changePasswordSchema),
|
||||
defaultValues: {
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmNewPassword: "",
|
||||
},
|
||||
});
|
||||
|
||||
const { isDirty } = profileForm.formState
|
||||
const { isDirty } = profileForm.formState;
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
profileForm.reset({
|
||||
name: user.name ?? "",
|
||||
email: user.email ?? "",
|
||||
})
|
||||
}
|
||||
}, [user, profileForm])
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
profileForm.reset({
|
||||
name: user.name ?? "",
|
||||
email: user.email ?? "",
|
||||
});
|
||||
}
|
||||
}, [user, profileForm]);
|
||||
|
||||
const updateProfileMutation = trpc.user.updateProfile.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
if (data?.user) {
|
||||
profileForm.reset({
|
||||
name: data.user.name ?? "",
|
||||
email: data.user.email ?? "",
|
||||
})
|
||||
toast.success("Profile updated successfully.")
|
||||
}
|
||||
utils.user.me.invalidate()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to update profile.")
|
||||
},
|
||||
})
|
||||
const updateProfileMutation = trpc.user.updateProfile.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
if (data?.user) {
|
||||
profileForm.reset({
|
||||
name: data.user.name ?? "",
|
||||
email: data.user.email ?? "",
|
||||
});
|
||||
toast.success("Profile updated successfully.");
|
||||
}
|
||||
utils.user.me.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to update profile.");
|
||||
},
|
||||
});
|
||||
|
||||
const changePasswordMutation = trpc.user.changePassword.useMutation({
|
||||
onSuccess: (data) => {
|
||||
Cookies.set("token", data.token)
|
||||
toast.success("Password changed successfully.")
|
||||
passwordForm.reset()
|
||||
utils.user.me.invalidate()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to change password.")
|
||||
},
|
||||
})
|
||||
const changePasswordMutation = trpc.user.changePassword.useMutation({
|
||||
onSuccess: (data) => {
|
||||
Cookies.set("token", data.token);
|
||||
toast.success("Password changed successfully.");
|
||||
passwordForm.reset();
|
||||
utils.user.me.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to change password.");
|
||||
},
|
||||
});
|
||||
|
||||
const onProfileSubmit = (values: z.infer<typeof profileSettingsSchema>) => {
|
||||
updateProfileMutation.mutate(values)
|
||||
}
|
||||
const onProfileSubmit = (values: z.infer<typeof profileSettingsSchema>) => {
|
||||
updateProfileMutation.mutate(values);
|
||||
};
|
||||
|
||||
const onChangePasswordSubmit = (
|
||||
values: z.infer<typeof changePasswordSchema>
|
||||
) => {
|
||||
changePasswordMutation.mutate({
|
||||
currentPassword: values.currentPassword,
|
||||
newPassword: values.newPassword,
|
||||
})
|
||||
}
|
||||
const onChangePasswordSubmit = (
|
||||
values: z.infer<typeof changePasswordSchema>,
|
||||
) => {
|
||||
changePasswordMutation.mutate({
|
||||
currentPassword: values.currentPassword,
|
||||
newPassword: values.newPassword,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Profile Settings</CardTitle>
|
||||
<div className="flex items-center gap-4">
|
||||
{isDirty && (
|
||||
<p className="text-sm text-muted-foreground">Unsaved changes</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form="profile-settings-form"
|
||||
disabled={updateProfileMutation.isPending || !isDirty}
|
||||
loading={updateProfileMutation.isPending}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...profileForm}>
|
||||
<form
|
||||
id="profile-settings-form"
|
||||
onSubmit={profileForm.handleSubmit(onProfileSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={profileForm.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Full Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Your Name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={profileForm.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Profile Settings</CardTitle>
|
||||
<div className="flex items-center gap-4">
|
||||
{isDirty && (
|
||||
<p className="text-sm text-muted-foreground">Unsaved changes</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form="profile-settings-form"
|
||||
disabled={updateProfileMutation.isPending || !isDirty}
|
||||
loading={updateProfileMutation.isPending}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...profileForm}>
|
||||
<form
|
||||
id="profile-settings-form"
|
||||
onSubmit={profileForm.handleSubmit(onProfileSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={profileForm.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Full Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Your Name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={profileForm.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Change Password</CardTitle>
|
||||
<div className="flex items-center gap-4">
|
||||
{passwordForm.formState.isDirty && (
|
||||
<p className="text-sm text-muted-foreground">Unsaved changes</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form="change-password-form"
|
||||
disabled={
|
||||
changePasswordMutation.isPending ||
|
||||
!passwordForm.formState.isDirty
|
||||
}
|
||||
loading={changePasswordMutation.isPending}
|
||||
>
|
||||
<KeyRound className="mr-2 h-4 w-4" />
|
||||
Update Password
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...passwordForm}>
|
||||
<form
|
||||
id="change-password-form"
|
||||
onSubmit={passwordForm.handleSubmit(onChangePasswordSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={passwordForm.control}
|
||||
name="currentPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Current Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={passwordForm.control}
|
||||
name="newPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>New Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={passwordForm.control}
|
||||
name="confirmNewPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm New Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Change Password</CardTitle>
|
||||
<div className="flex items-center gap-4">
|
||||
{passwordForm.formState.isDirty && (
|
||||
<p className="text-sm text-muted-foreground">Unsaved changes</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form="change-password-form"
|
||||
disabled={
|
||||
changePasswordMutation.isPending ||
|
||||
!passwordForm.formState.isDirty
|
||||
}
|
||||
loading={changePasswordMutation.isPending}
|
||||
>
|
||||
<KeyRound className="mr-2 h-4 w-4" />
|
||||
Update Password
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...passwordForm}>
|
||||
<form
|
||||
id="change-password-form"
|
||||
onSubmit={passwordForm.handleSubmit(onChangePasswordSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={passwordForm.control}
|
||||
name="currentPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Current Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={passwordForm.control}
|
||||
name="newPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>New Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={passwordForm.control}
|
||||
name="confirmNewPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm New Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,345 +1,345 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Switch,
|
||||
Separator,
|
||||
} from "@repo/ui"
|
||||
import { toast } from "sonner"
|
||||
import { trpc } from "@/trpc"
|
||||
import { useSession } from "@/hooks"
|
||||
import { TestSmtpDialog } from "./test-smtp-dialog"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Save } from "lucide-react"
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Switch,
|
||||
Separator,
|
||||
} from "@repo/ui";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/trpc";
|
||||
import { useSession } from "@/hooks";
|
||||
import { TestSmtpDialog } from "./test-smtp-dialog";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Save } from "lucide-react";
|
||||
|
||||
const smtpSchema = z.object({
|
||||
host: z.string().min(1, "SMTP host is required"),
|
||||
port: z.coerce.number().min(1, "Port is required"),
|
||||
username: z.string().min(1, "Username is required"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
fromEmail: z.string().email("Invalid email address"),
|
||||
fromName: z.string().min(1, "From name is required"),
|
||||
secure: z.boolean(),
|
||||
encryption: z.enum(["STARTTLS", "SSL_TLS", "NONE"]),
|
||||
})
|
||||
host: z.string().min(1, "SMTP host is required"),
|
||||
port: z.coerce.number().min(1, "Port is required"),
|
||||
username: z.string().min(1, "Username is required"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
fromEmail: z.string().email("Invalid email address"),
|
||||
fromName: z.string().min(1, "From name is required"),
|
||||
secure: z.boolean(),
|
||||
encryption: z.enum(["STARTTLS", "SSL_TLS", "NONE"]),
|
||||
});
|
||||
|
||||
type SmtpSettings = z.infer<typeof smtpSchema>
|
||||
type SmtpSettings = z.infer<typeof smtpSchema>;
|
||||
|
||||
export function SmtpSettings() {
|
||||
const { organization } = useSession()
|
||||
const utils = trpc.useUtils()
|
||||
const { organization } = useSession();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: settings } = trpc.settings.getSmtp.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
staleTime: 1000 * 60 * 5, // Consider data fresh for 5 minutes
|
||||
}
|
||||
)
|
||||
const { data: settings } = trpc.settings.getSmtp.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
staleTime: 1000 * 60 * 5, // Consider data fresh for 5 minutes
|
||||
},
|
||||
);
|
||||
|
||||
const form = useForm<SmtpSettings>({
|
||||
resolver: zodResolver(smtpSchema),
|
||||
defaultValues: {
|
||||
host: "",
|
||||
port: 587,
|
||||
username: "",
|
||||
password: "",
|
||||
fromEmail: "",
|
||||
fromName: "",
|
||||
secure: true,
|
||||
encryption: "STARTTLS",
|
||||
},
|
||||
})
|
||||
const form = useForm<SmtpSettings>({
|
||||
resolver: zodResolver(smtpSchema),
|
||||
defaultValues: {
|
||||
host: "",
|
||||
port: 587,
|
||||
username: "",
|
||||
password: "",
|
||||
fromEmail: "",
|
||||
fromName: "",
|
||||
secure: true,
|
||||
encryption: "STARTTLS",
|
||||
},
|
||||
});
|
||||
|
||||
const { isDirty } = form.formState
|
||||
const [initialLoad, setInitialLoad] = useState(true)
|
||||
const { isDirty } = form.formState;
|
||||
const [initialLoad, setInitialLoad] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings || !initialLoad) return
|
||||
useEffect(() => {
|
||||
if (!settings || !initialLoad) return;
|
||||
|
||||
form.reset({
|
||||
host: settings.host,
|
||||
port: settings.port,
|
||||
username: settings.username,
|
||||
password: settings.password,
|
||||
fromEmail: settings.fromEmail ?? "",
|
||||
fromName: settings.fromName ?? "",
|
||||
secure: settings.secure,
|
||||
encryption: settings.encryption,
|
||||
})
|
||||
setInitialLoad(false)
|
||||
}, [settings, form, initialLoad])
|
||||
form.reset({
|
||||
host: settings.host,
|
||||
port: settings.port,
|
||||
username: settings.username,
|
||||
password: settings.password,
|
||||
fromEmail: settings.fromEmail ?? "",
|
||||
fromName: settings.fromName ?? "",
|
||||
secure: settings.secure,
|
||||
encryption: settings.encryption,
|
||||
});
|
||||
setInitialLoad(false);
|
||||
}, [settings, form, initialLoad]);
|
||||
|
||||
const updateSettings = trpc.settings.updateSmtp.useMutation({
|
||||
onSuccess: ({ settings }) => {
|
||||
utils.settings.getSmtp.invalidate()
|
||||
form.reset({
|
||||
...settings,
|
||||
fromEmail: settings.fromEmail ?? "",
|
||||
fromName: settings.fromName ?? "",
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
const updateSettings = trpc.settings.updateSmtp.useMutation({
|
||||
onSuccess: ({ settings }) => {
|
||||
utils.settings.getSmtp.invalidate();
|
||||
form.reset({
|
||||
...settings,
|
||||
fromEmail: settings.fromEmail ?? "",
|
||||
fromName: settings.fromName ?? "",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: SmtpSettings) {
|
||||
if (!organization?.id) return
|
||||
updateSettings.mutate({
|
||||
organizationId: organization.id,
|
||||
...values,
|
||||
})
|
||||
}
|
||||
function onSubmit(values: SmtpSettings) {
|
||||
if (!organization?.id) return;
|
||||
updateSettings.mutate({
|
||||
organizationId: organization.id,
|
||||
...values,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>SMTP Settings</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{isDirty && (
|
||||
<p className="text-sm text-muted-foreground">Unsaved changes</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form="smtp-form"
|
||||
disabled={updateSettings.isPending}
|
||||
loading={updateSettings.isPending}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
Save
|
||||
</Button>
|
||||
<TestSmtpDialog
|
||||
organizationId={organization?.id ?? ""}
|
||||
trigger={
|
||||
<Button variant="outline" disabled={!settings}>
|
||||
Test Settings
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="smtp-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-8"
|
||||
>
|
||||
{/* Server Settings */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Server Settings</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Configure your SMTP server connection details
|
||||
</p>
|
||||
<Separator className="my-4" />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SMTP Host</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="smtp.example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The hostname of your SMTP server
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>SMTP Settings</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{isDirty && (
|
||||
<p className="text-sm text-muted-foreground">Unsaved changes</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form="smtp-form"
|
||||
disabled={updateSettings.isPending}
|
||||
loading={updateSettings.isPending}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
Save
|
||||
</Button>
|
||||
<TestSmtpDialog
|
||||
organizationId={organization?.id ?? ""}
|
||||
trigger={
|
||||
<Button variant="outline" disabled={!settings}>
|
||||
Test Settings
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="smtp-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-8"
|
||||
>
|
||||
{/* Server Settings */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Server Settings</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Configure your SMTP server connection details
|
||||
</p>
|
||||
<Separator className="my-4" />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SMTP Host</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="smtp.example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The hostname of your SMTP server
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="587" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Common ports: 25, 465, 587, 2525
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="587" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Common ports: 25, 465, 587, 2525
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="encryption"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Encryption</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select encryption" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="STARTTLS">STARTTLS</SelectItem>
|
||||
<SelectItem value="SSL_TLS">SSL/TLS</SelectItem>
|
||||
<SelectItem value="NONE">None</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose the encryption method for your SMTP connection
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="encryption"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Encryption</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select encryption" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="STARTTLS">STARTTLS</SelectItem>
|
||||
<SelectItem value="SSL_TLS">SSL/TLS</SelectItem>
|
||||
<SelectItem value="NONE">None</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose the encryption method for your SMTP connection
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="secure"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
Require Secure Connection
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Use TLS when connecting to server
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="secure"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
Require Secure Connection
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Use TLS when connecting to server
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Authentication */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Authentication</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Credentials used to authenticate with your SMTP server
|
||||
</p>
|
||||
<Separator className="my-4" />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your SMTP server username
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* Authentication */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Authentication</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Credentials used to authenticate with your SMTP server
|
||||
</p>
|
||||
<Separator className="my-4" />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your SMTP server username
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your SMTP server password
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your SMTP server password
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sender Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Sender Information</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Default sender details for outgoing emails
|
||||
</p>
|
||||
<Separator className="my-4" />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fromEmail"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>From Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="noreply@example.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The email address emails will be sent from
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* Sender Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Sender Information</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Default sender details for outgoing emails
|
||||
</p>
|
||||
<Separator className="my-4" />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fromEmail"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>From Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="noreply@example.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The email address emails will be sent from
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fromName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>From Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Your Company Name" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The name that will appear in the from field
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fromName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>From Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Your Company Name" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The name that will appear in the from field
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,120 +1,120 @@
|
||||
import { useState } from "react"
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogTrigger,
|
||||
Button,
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
Input,
|
||||
FormMessage,
|
||||
} from "@repo/ui"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
import { trpc } from "@/trpc"
|
||||
import { toast } from "sonner"
|
||||
import { useLocalStorage } from "usehooks-ts"
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogTrigger,
|
||||
Button,
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
Input,
|
||||
FormMessage,
|
||||
} from "@repo/ui";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { trpc } from "@/trpc";
|
||||
import { toast } from "sonner";
|
||||
import { useLocalStorage } from "usehooks-ts";
|
||||
|
||||
const testSchema = z.object({
|
||||
email: z.string().email("Please enter a valid email address"),
|
||||
})
|
||||
email: z.string().email("Please enter a valid email address"),
|
||||
});
|
||||
|
||||
type TestSmtpDialogProps = {
|
||||
trigger: React.ReactNode
|
||||
organizationId: string
|
||||
}
|
||||
trigger: React.ReactNode;
|
||||
organizationId: string;
|
||||
};
|
||||
|
||||
export function TestSmtpDialog({
|
||||
trigger,
|
||||
organizationId,
|
||||
trigger,
|
||||
organizationId,
|
||||
}: TestSmtpDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [emailLocalStorage, setEmailLocalStorage] = useLocalStorage(
|
||||
"test-smtp-email",
|
||||
""
|
||||
)
|
||||
const [open, setOpen] = useState(false);
|
||||
const [emailLocalStorage, setEmailLocalStorage] = useLocalStorage(
|
||||
"test-smtp-email",
|
||||
"",
|
||||
);
|
||||
|
||||
const form = useForm<z.infer<typeof testSchema>>({
|
||||
resolver: zodResolver(testSchema),
|
||||
defaultValues: {
|
||||
email: emailLocalStorage,
|
||||
},
|
||||
mode: "onChange",
|
||||
})
|
||||
const form = useForm<z.infer<typeof testSchema>>({
|
||||
resolver: zodResolver(testSchema),
|
||||
defaultValues: {
|
||||
email: emailLocalStorage,
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const testSmtp = trpc.settings.testSmtp.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Test email sent successfully")
|
||||
setOpen(false)
|
||||
form.reset()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
const testSmtp = trpc.settings.testSmtp.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Test email sent successfully");
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (values: z.infer<typeof testSchema>) => {
|
||||
testSmtp.mutate({
|
||||
email: values.email,
|
||||
organizationId,
|
||||
})
|
||||
}
|
||||
const onSubmit = (values: z.infer<typeof testSchema>) => {
|
||||
testSmtp.mutate({
|
||||
email: values.email,
|
||||
organizationId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Test SMTP Settings</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send a test email to verify your SMTP configuration
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Test Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="Enter email address"
|
||||
type="email"
|
||||
onChange={(e) => {
|
||||
field.onChange(e)
|
||||
setEmailLocalStorage(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={testSmtp.isPending}>
|
||||
{testSmtp.isPending ? "Sending..." : "Send Test Email"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Test SMTP Settings</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send a test email to verify your SMTP configuration
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Test Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="Enter email address"
|
||||
type="email"
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
setEmailLocalStorage(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={testSmtp.isPending}>
|
||||
{testSmtp.isPending ? "Sending..." : "Send Test Email"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,298 +1,298 @@
|
||||
import { useState } from "react"
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
Button,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableBody,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
Input,
|
||||
FormMessage,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
Switch,
|
||||
} from "@repo/ui"
|
||||
import { Plus, Webhook } from "lucide-react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
import { trpc } from "@/trpc"
|
||||
import { useSession } from "@/hooks"
|
||||
import { toast } from "sonner"
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
Button,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableBody,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
Input,
|
||||
FormMessage,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
Switch,
|
||||
} from "@repo/ui";
|
||||
import { Plus, Webhook } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { trpc } from "@/trpc";
|
||||
import { useSession } from "@/hooks";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const createWebhookSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
url: z.string().url("Must be a valid URL"),
|
||||
events: z.array(z.string()).min(1, "At least one event must be selected"),
|
||||
isActive: z.boolean(),
|
||||
secret: z.string().min(1, "Secret is required"),
|
||||
})
|
||||
name: z.string().min(1, "Name is required"),
|
||||
url: z.string().url("Must be a valid URL"),
|
||||
events: z.array(z.string()).min(1, "At least one event must be selected"),
|
||||
isActive: z.boolean(),
|
||||
secret: z.string().min(1, "Secret is required"),
|
||||
});
|
||||
|
||||
export function WebhookSettings() {
|
||||
const { organization } = useSession()
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const { organization } = useSession();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const { data: webhooks } = trpc.settings.listWebhooks.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
}
|
||||
)
|
||||
const { data: webhooks } = trpc.settings.listWebhooks.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
},
|
||||
);
|
||||
|
||||
const form = useForm<z.infer<typeof createWebhookSchema>>({
|
||||
resolver: zodResolver(createWebhookSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
url: "",
|
||||
events: [],
|
||||
isActive: true,
|
||||
secret: "",
|
||||
},
|
||||
})
|
||||
const form = useForm<z.infer<typeof createWebhookSchema>>({
|
||||
resolver: zodResolver(createWebhookSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
url: "",
|
||||
events: [],
|
||||
isActive: true,
|
||||
secret: "",
|
||||
},
|
||||
});
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const createWebhook = trpc.settings.createWebhook.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Webhook created successfully")
|
||||
setIsCreating(false)
|
||||
form.reset()
|
||||
utils.settings.listWebhooks.invalidate()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
const createWebhook = trpc.settings.createWebhook.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Webhook created successfully");
|
||||
setIsCreating(false);
|
||||
form.reset();
|
||||
utils.settings.listWebhooks.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
// const deleteWebhook = trpc.settings.deleteWebhook.useMutation({
|
||||
// onSuccess: () => {
|
||||
// toast.success("Webhook deleted")
|
||||
// utils.settings.listWebhooks.invalidate()
|
||||
// },
|
||||
// onError: (error) => {
|
||||
// toast.error(error.message)
|
||||
// },
|
||||
// })
|
||||
// const deleteWebhook = trpc.settings.deleteWebhook.useMutation({
|
||||
// onSuccess: () => {
|
||||
// toast.success("Webhook deleted")
|
||||
// utils.settings.listWebhooks.invalidate()
|
||||
// },
|
||||
// onError: (error) => {
|
||||
// toast.error(error.message)
|
||||
// },
|
||||
// })
|
||||
|
||||
const onSubmit = (values: z.infer<typeof createWebhookSchema>) => {
|
||||
if (!organization?.id) return
|
||||
createWebhook.mutate({
|
||||
organizationId: organization.id,
|
||||
...values,
|
||||
})
|
||||
}
|
||||
const onSubmit = (values: z.infer<typeof createWebhookSchema>) => {
|
||||
if (!organization?.id) return;
|
||||
createWebhook.mutate({
|
||||
organizationId: organization.id,
|
||||
...values,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Webhooks</CardTitle>
|
||||
<CardDescription>
|
||||
Manage webhook endpoints for real-time event notifications
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Dialog
|
||||
open={isCreating}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
form.reset()
|
||||
}
|
||||
setIsCreating(open)
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Webhook
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Webhook</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new webhook endpoint to receive event notifications
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Webhook" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Webhooks</CardTitle>
|
||||
<CardDescription>
|
||||
Manage webhook endpoints for real-time event notifications
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Dialog
|
||||
open={isCreating}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
}
|
||||
setIsCreating(open);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Webhook
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Webhook</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new webhook endpoint to receive event notifications
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Webhook" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/webhook"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/webhook"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="events"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Events</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
field.onChange([...field.value, value])
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select events" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="email.sent">
|
||||
Email Sent
|
||||
</SelectItem>
|
||||
<SelectItem value="email.delivered">
|
||||
Email Delivered
|
||||
</SelectItem>
|
||||
<SelectItem value="email.failed">
|
||||
Email Failed
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="events"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Events</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
field.onChange([...field.value, value])
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select events" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="email.sent">
|
||||
Email Sent
|
||||
</SelectItem>
|
||||
<SelectItem value="email.delivered">
|
||||
Email Delivered
|
||||
</SelectItem>
|
||||
<SelectItem value="email.failed">
|
||||
Email Failed
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="secret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Webhook secret"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="secret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Webhook secret"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Active</FormLabel>
|
||||
<CardDescription>
|
||||
Receive events for this webhook
|
||||
</CardDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Active</FormLabel>
|
||||
<CardDescription>
|
||||
Receive events for this webhook
|
||||
</CardDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsCreating(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createWebhook.isPending}>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!webhooks?.length ? (
|
||||
<div className="flex min-h-[400px] flex-col items-center justify-center rounded-lg border border-dashed">
|
||||
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted">
|
||||
<Webhook className="h-10 w-10" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-semibold">No webhooks</h3>
|
||||
<p className="mb-4 mt-2 text-sm text-muted-foreground">
|
||||
You haven't created any webhooks yet. Add one to start receiving
|
||||
event notifications.
|
||||
</p>
|
||||
<Button onClick={() => setIsCreating(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Webhook
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>URL</TableHead>
|
||||
<TableHead>Events</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{/* {webhooks?.map((webhook) => (
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsCreating(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createWebhook.isPending}>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!webhooks?.length ? (
|
||||
<div className="flex min-h-[400px] flex-col items-center justify-center rounded-lg border border-dashed">
|
||||
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted">
|
||||
<Webhook className="h-10 w-10" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-semibold">No webhooks</h3>
|
||||
<p className="mb-4 mt-2 text-sm text-muted-foreground">
|
||||
You haven't created any webhooks yet. Add one to start receiving
|
||||
event notifications.
|
||||
</p>
|
||||
<Button onClick={() => setIsCreating(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Webhook
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>URL</TableHead>
|
||||
<TableHead>Events</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{/* {webhooks?.map((webhook) => (
|
||||
<TableRow key={webhook.id}>
|
||||
<TableCell>{webhook.name}</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
@@ -336,10 +336,10 @@ export function WebhookSettings() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))} */}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,278 +1,278 @@
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Switch,
|
||||
} from "@repo/ui"
|
||||
import { useForm, useFieldArray } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import * as z from "zod"
|
||||
import { addSubscriberSchema } from "./schemas"
|
||||
import { trpc } from "@/trpc"
|
||||
import { toast } from "sonner"
|
||||
import { useSession } from "@/hooks"
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Switch,
|
||||
} from "@repo/ui";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { addSubscriberSchema } from "./schemas";
|
||||
import { trpc } from "@/trpc";
|
||||
import { toast } from "sonner";
|
||||
import { useSession } from "@/hooks";
|
||||
|
||||
interface AddSubscriberDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess: () => void
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function AddSubscriberDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: AddSubscriberDialogProps) {
|
||||
const { organization } = useSession()
|
||||
const utils = trpc.useUtils()
|
||||
const lists = trpc.list.list.useQuery({
|
||||
organizationId: organization?.id ?? "",
|
||||
})
|
||||
const { organization } = useSession();
|
||||
const utils = trpc.useUtils();
|
||||
const lists = trpc.list.list.useQuery({
|
||||
organizationId: organization?.id ?? "",
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof addSubscriberSchema>>({
|
||||
resolver: zodResolver(addSubscriberSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
name: "",
|
||||
listIds: [],
|
||||
emailVerified: false,
|
||||
metadata: [],
|
||||
},
|
||||
})
|
||||
const form = useForm<z.infer<typeof addSubscriberSchema>>({
|
||||
resolver: zodResolver(addSubscriberSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
name: "",
|
||||
listIds: [],
|
||||
emailVerified: false,
|
||||
metadata: [],
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
fields: metadataFields,
|
||||
append: appendMetadata,
|
||||
remove: removeMetadata,
|
||||
} = useFieldArray({
|
||||
control: form.control,
|
||||
name: "metadata",
|
||||
})
|
||||
const {
|
||||
fields: metadataFields,
|
||||
append: appendMetadata,
|
||||
remove: removeMetadata,
|
||||
} = useFieldArray({
|
||||
control: form.control,
|
||||
name: "metadata",
|
||||
});
|
||||
|
||||
const addSubscriber = trpc.subscriber.create.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Subscriber added successfully")
|
||||
onOpenChange(false)
|
||||
form.reset()
|
||||
utils.list.invalidate()
|
||||
utils.subscriber.invalidate()
|
||||
onSuccess()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
const addSubscriber = trpc.subscriber.create.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Subscriber added successfully");
|
||||
onOpenChange(false);
|
||||
form.reset();
|
||||
utils.list.invalidate();
|
||||
utils.subscriber.invalidate();
|
||||
onSuccess();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddSubscriber = (values: z.infer<typeof addSubscriberSchema>) => {
|
||||
addSubscriber.mutate({
|
||||
...values,
|
||||
organizationId: organization?.id ?? "",
|
||||
})
|
||||
}
|
||||
const handleAddSubscriber = (values: z.infer<typeof addSubscriberSchema>) => {
|
||||
addSubscriber.mutate({
|
||||
...values,
|
||||
organizationId: organization?.id ?? "",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
onOpenChange(isOpen)
|
||||
if (!isOpen) {
|
||||
form.reset()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Subscriber
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Subscriber</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new subscriber to your newsletter list.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleAddSubscriber)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="listIds"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Lists</FormLabel>
|
||||
<FormControl>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{lists.data?.lists.map((list) => (
|
||||
<Button
|
||||
key={list.id}
|
||||
type="button"
|
||||
variant={
|
||||
field.value?.includes(list.id)
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => {
|
||||
const newValue = field.value?.includes(list.id)
|
||||
? field.value?.filter((id) => id !== list.id)
|
||||
: [...(field.value ?? []), list.id]
|
||||
field.onChange(newValue)
|
||||
}}
|
||||
>
|
||||
{list.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emailVerified"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Email Verified</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<FormLabel className="text-base">Metadata</FormLabel>
|
||||
{metadataFields.length > 0 ? (
|
||||
<>
|
||||
{metadataFields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex items-center gap-2 mt-2"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`metadata.${index}.key`}
|
||||
render={({ field: keyField }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input placeholder="Key" {...keyField} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`metadata.${index}.value`}
|
||||
render={({ field: valueField }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input placeholder="Value" {...valueField} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeMetadata(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col mt-2 mb-2 border rounded-md p-4">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
No metadata added yet
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => appendMetadata({ key: "", value: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Metadata
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{metadataFields.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => appendMetadata({ key: "", value: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Metadata
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
loading={addSubscriber.isPending}
|
||||
type="submit"
|
||||
disabled={addSubscriber.isPending}
|
||||
>
|
||||
Add Subscriber
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
onOpenChange(isOpen);
|
||||
if (!isOpen) {
|
||||
form.reset();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Subscriber
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Subscriber</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new subscriber to your newsletter list.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleAddSubscriber)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="listIds"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Lists</FormLabel>
|
||||
<FormControl>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{lists.data?.lists.map((list) => (
|
||||
<Button
|
||||
key={list.id}
|
||||
type="button"
|
||||
variant={
|
||||
field.value?.includes(list.id)
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => {
|
||||
const newValue = field.value?.includes(list.id)
|
||||
? field.value?.filter((id) => id !== list.id)
|
||||
: [...(field.value ?? []), list.id];
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
>
|
||||
{list.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emailVerified"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Email Verified</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<FormLabel className="text-base">Metadata</FormLabel>
|
||||
{metadataFields.length > 0 ? (
|
||||
<>
|
||||
{metadataFields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex items-center gap-2 mt-2"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`metadata.${index}.key`}
|
||||
render={({ field: keyField }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input placeholder="Key" {...keyField} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`metadata.${index}.value`}
|
||||
render={({ field: valueField }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input placeholder="Value" {...valueField} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeMetadata(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col mt-2 mb-2 border rounded-md p-4">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
No metadata added yet
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => appendMetadata({ key: "", value: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Metadata
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{metadataFields.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => appendMetadata({ key: "", value: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Metadata
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
loading={addSubscriber.isPending}
|
||||
type="submit"
|
||||
disabled={addSubscriber.isPending}
|
||||
>
|
||||
Add Subscriber
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,117 +1,117 @@
|
||||
import { memo, useState } from "react"
|
||||
import { memo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Badge,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Switch,
|
||||
cn,
|
||||
} from "@repo/ui"
|
||||
import { trpc } from "@/trpc"
|
||||
import { toast } from "sonner"
|
||||
import { displayDate } from "backend/shared"
|
||||
import { RouterOutput } from "@/types"
|
||||
Button,
|
||||
Badge,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Switch,
|
||||
cn,
|
||||
} from "@repo/ui";
|
||||
import { trpc } from "@/trpc";
|
||||
import { toast } from "sonner";
|
||||
import { displayDate } from "backend/shared";
|
||||
import { RouterOutput } from "@/types";
|
||||
|
||||
interface ListCellProps {
|
||||
subscriber: RouterOutput["subscriber"]["list"]["subscribers"][number]
|
||||
organizationId: string
|
||||
subscriber: RouterOutput["subscriber"]["list"]["subscribers"][number];
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export const ListCell = memo(
|
||||
({ subscriber, organizationId }: ListCellProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
({ subscriber, organizationId }: ListCellProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const lists = subscriber.ListSubscribers
|
||||
const lists = subscriber.ListSubscribers;
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const onOpenChange = (open: boolean) => {
|
||||
setOpen(open)
|
||||
const onOpenChange = (open: boolean) => {
|
||||
setOpen(open);
|
||||
|
||||
if (!open) {
|
||||
utils.subscriber.list.invalidate()
|
||||
}
|
||||
}
|
||||
if (!open) {
|
||||
utils.subscriber.list.invalidate();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="hover:bg-transparent">
|
||||
<Badge variant="secondary" className="rounded-sm">
|
||||
{lists?.length ?? 0} list{lists?.length === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Lists for {subscriber.name}</DialogTitle>
|
||||
<DialogDescription>Manage subscription status</DialogDescription>
|
||||
</DialogHeader>
|
||||
{subscriber.ListSubscribers.map((list) => (
|
||||
<ListItem
|
||||
key={list.id}
|
||||
list={list}
|
||||
organizationId={organizationId}
|
||||
/>
|
||||
))}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
)
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="hover:bg-transparent">
|
||||
<Badge variant="secondary" className="rounded-sm">
|
||||
{lists?.length ?? 0} list{lists?.length === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Lists for {subscriber.name}</DialogTitle>
|
||||
<DialogDescription>Manage subscription status</DialogDescription>
|
||||
</DialogHeader>
|
||||
{subscriber.ListSubscribers.map((list) => (
|
||||
<ListItem
|
||||
key={list.id}
|
||||
list={list}
|
||||
organizationId={organizationId}
|
||||
/>
|
||||
))}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface ListItemProps {
|
||||
list: RouterOutput["subscriber"]["list"]["subscribers"][number]["ListSubscribers"][number]
|
||||
organizationId: string
|
||||
list: RouterOutput["subscriber"]["list"]["subscribers"][number]["ListSubscribers"][number];
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
const ListItem = memo(({ list, organizationId }: ListItemProps) => {
|
||||
const [subbed, setSubbed] = useState(!list.unsubscribedAt)
|
||||
const [subbed, setSubbed] = useState(!list.unsubscribedAt);
|
||||
|
||||
const toggleSubscription = trpc.subscriber.unsubscribeToggle.useMutation({
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
setSubbed(!subbed)
|
||||
},
|
||||
})
|
||||
const toggleSubscription = trpc.subscriber.unsubscribeToggle.useMutation({
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
setSubbed(!subbed);
|
||||
},
|
||||
});
|
||||
|
||||
const onChange = async () => {
|
||||
setSubbed((prev) => !prev) // optimistic update
|
||||
const onChange = async () => {
|
||||
setSubbed((prev) => !prev); // optimistic update
|
||||
|
||||
toggleSubscription.mutate({
|
||||
listSubscriberId: list.id,
|
||||
organizationId,
|
||||
})
|
||||
}
|
||||
toggleSubscription.mutate({
|
||||
listSubscriberId: list.id,
|
||||
organizationId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={list.id} className="space-y-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-bold">{list.List.name}</p>
|
||||
<small
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
!subbed && "text-destructive"
|
||||
)}
|
||||
>
|
||||
{!subbed ? "Unsubscribed" : "Subscribed"}
|
||||
</small>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Added {displayDate(list.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={subbed} onCheckedChange={onChange} />
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Last updated: {list.updatedAt ? displayDate(list.updatedAt) : "Never"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
return (
|
||||
<div key={list.id} className="space-y-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-bold">{list.List.name}</p>
|
||||
<small
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
!subbed && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{!subbed ? "Unsubscribed" : "Subscribed"}
|
||||
</small>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Added {displayDate(list.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={subbed} onCheckedChange={onChange} />
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Last updated: {list.updatedAt ? displayDate(list.updatedAt) : "Never"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,135 +1,135 @@
|
||||
import { Edit, MoreHorizontal, Trash, View } from "lucide-react"
|
||||
import { Edit, MoreHorizontal, Trash, View } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Badge,
|
||||
} from "@repo/ui"
|
||||
import { ColumnDef } from "@tanstack/react-table"
|
||||
import { ListCell } from "./cells/list-cell"
|
||||
import { displayDateTime } from "@/utils"
|
||||
import { RouterOutput } from "@/types"
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Badge,
|
||||
} from "@repo/ui";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { ListCell } from "./cells/list-cell";
|
||||
import { displayDateTime } from "@/utils";
|
||||
import { RouterOutput } from "@/types";
|
||||
|
||||
interface ColumnActions {
|
||||
onDelete: (id: string) => void
|
||||
onEdit: (subscriber: Data) => void
|
||||
onViewDetails: (subscriber: Data) => void
|
||||
onDelete: (id: string) => void;
|
||||
onEdit: (subscriber: Data) => void;
|
||||
onViewDetails: (subscriber: Data) => void;
|
||||
}
|
||||
|
||||
type Data = RouterOutput["subscriber"]["list"]["subscribers"][number]
|
||||
type Data = RouterOutput["subscriber"]["list"]["subscribers"][number];
|
||||
|
||||
export const columns = ({
|
||||
onDelete,
|
||||
onEdit,
|
||||
onViewDetails,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onViewDetails,
|
||||
}: ColumnActions): ColumnDef<Data>[] => [
|
||||
// {
|
||||
// id: "select",
|
||||
// header: ({ table }) => (
|
||||
// <Checkbox
|
||||
// checked={
|
||||
// table.getIsAllPageRowsSelected() ||
|
||||
// (table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
// }
|
||||
// onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
// aria-label="Select all"
|
||||
// />
|
||||
// ),
|
||||
// cell: ({ row }) => (
|
||||
// <Checkbox
|
||||
// checked={row.getIsSelected()}
|
||||
// onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
// aria-label="Select row"
|
||||
// />
|
||||
// ),
|
||||
// },
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<div className="font-medium">{row.getValue("name")}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{row.original.email}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Joined at",
|
||||
cell: ({ row }) => displayDateTime(row.original.createdAt),
|
||||
},
|
||||
{
|
||||
accessorKey: "emailVerified",
|
||||
header: "Email Status",
|
||||
cell: ({ row }) => {
|
||||
const isVerified = row.original.emailVerified
|
||||
return (
|
||||
<Badge variant={isVerified ? "default" : "secondary"}>
|
||||
{isVerified ? "Verified" : "Unverified"}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "ListSubscribers",
|
||||
header: "Subscription Status",
|
||||
cell: ({ row }) => {
|
||||
const listSubscribers = row.original.ListSubscribers
|
||||
if (!listSubscribers || listSubscribers.length === 0) {
|
||||
return <Badge variant="outline">No Lists</Badge>
|
||||
}
|
||||
const isSubscribedToAnyList = listSubscribers.some(
|
||||
(ls) => ls.unsubscribedAt === null
|
||||
)
|
||||
return (
|
||||
<Badge variant={isSubscribedToAnyList ? "default" : "secondary"}>
|
||||
{isSubscribedToAnyList ? "Subscribed" : "Unsubscribed"}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "lists",
|
||||
header: "Lists",
|
||||
cell: ({ row }) => (
|
||||
<ListCell
|
||||
subscriber={row.original}
|
||||
organizationId={row.original.organizationId}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onViewDetails(row.original)}>
|
||||
<View className="mr-2 h-4 w-4" />
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onEdit(row.original)}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-red-600"
|
||||
onClick={() => onDelete(row.original.id)}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
]
|
||||
// {
|
||||
// id: "select",
|
||||
// header: ({ table }) => (
|
||||
// <Checkbox
|
||||
// checked={
|
||||
// table.getIsAllPageRowsSelected() ||
|
||||
// (table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
// }
|
||||
// onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
// aria-label="Select all"
|
||||
// />
|
||||
// ),
|
||||
// cell: ({ row }) => (
|
||||
// <Checkbox
|
||||
// checked={row.getIsSelected()}
|
||||
// onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
// aria-label="Select row"
|
||||
// />
|
||||
// ),
|
||||
// },
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<div className="font-medium">{row.getValue("name")}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{row.original.email}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Joined at",
|
||||
cell: ({ row }) => displayDateTime(row.original.createdAt),
|
||||
},
|
||||
{
|
||||
accessorKey: "emailVerified",
|
||||
header: "Email Status",
|
||||
cell: ({ row }) => {
|
||||
const isVerified = row.original.emailVerified;
|
||||
return (
|
||||
<Badge variant={isVerified ? "default" : "secondary"}>
|
||||
{isVerified ? "Verified" : "Unverified"}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "ListSubscribers",
|
||||
header: "Subscription Status",
|
||||
cell: ({ row }) => {
|
||||
const listSubscribers = row.original.ListSubscribers;
|
||||
if (!listSubscribers || listSubscribers.length === 0) {
|
||||
return <Badge variant="outline">No Lists</Badge>;
|
||||
}
|
||||
const isSubscribedToAnyList = listSubscribers.some(
|
||||
(ls) => ls.unsubscribedAt === null,
|
||||
);
|
||||
return (
|
||||
<Badge variant={isSubscribedToAnyList ? "default" : "secondary"}>
|
||||
{isSubscribedToAnyList ? "Subscribed" : "Unsubscribed"}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "lists",
|
||||
header: "Lists",
|
||||
cell: ({ row }) => (
|
||||
<ListCell
|
||||
subscriber={row.original}
|
||||
organizationId={row.original.organizationId}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onViewDetails(row.original)}>
|
||||
<View className="mr-2 h-4 w-4" />
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onEdit(row.original)}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-red-600"
|
||||
onClick={() => onDelete(row.original.id)}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,60 +1,60 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogFooter,
|
||||
AlertDialogDescription,
|
||||
} from "@repo/ui"
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogFooter,
|
||||
AlertDialogDescription,
|
||||
} from "@repo/ui";
|
||||
|
||||
interface DeleteSubscriberAlertDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: () => void
|
||||
subscriberToDelete: string | null
|
||||
isPending: boolean
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
subscriberToDelete: string | null;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
export function DeleteSubscriberAlertDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
subscriberToDelete,
|
||||
isPending,
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
subscriberToDelete,
|
||||
isPending,
|
||||
}: DeleteSubscriberAlertDialogProps) {
|
||||
const numSubscribers = subscriberToDelete?.split(",").length ?? 0
|
||||
const isMultiple = subscriberToDelete?.includes(",")
|
||||
const numSubscribers = subscriberToDelete?.split(",").length ?? 0;
|
||||
const isMultiple = subscriberToDelete?.includes(",");
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{isMultiple
|
||||
? `This action cannot be undone. This will permanently delete ${
|
||||
numSubscribers
|
||||
} subscribers and remove their data from our servers.`
|
||||
: "This action cannot be undone. This will permanently delete the subscriber and remove their data from our servers."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirm}
|
||||
disabled={isPending}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isPending
|
||||
? "Deleting..."
|
||||
: isMultiple
|
||||
? `Delete ${numSubscribers} subscribers`
|
||||
: "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{isMultiple
|
||||
? `This action cannot be undone. This will permanently delete ${
|
||||
numSubscribers
|
||||
} subscribers and remove their data from our servers.`
|
||||
: "This action cannot be undone. This will permanently delete the subscriber and remove their data from our servers."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirm}
|
||||
disabled={isPending}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isPending
|
||||
? "Deleting..."
|
||||
: isMultiple
|
||||
? `Delete ${numSubscribers} subscribers`
|
||||
: "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,312 +1,312 @@
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Switch,
|
||||
} from "@repo/ui"
|
||||
import { useForm, useFieldArray } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import * as z from "zod"
|
||||
import { editSubscriberSchema } from "./schemas"
|
||||
import { trpc } from "@/trpc"
|
||||
import { toast } from "sonner"
|
||||
import { useSession } from "@/hooks"
|
||||
import { useEffect } from "react"
|
||||
import { RouterOutput } from "@/types"
|
||||
import { EditSubscriberDialogState } from "./types"
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Switch,
|
||||
} from "@repo/ui";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { editSubscriberSchema } from "./schemas";
|
||||
import { trpc } from "@/trpc";
|
||||
import { toast } from "sonner";
|
||||
import { useSession } from "@/hooks";
|
||||
import { useEffect } from "react";
|
||||
import { RouterOutput } from "@/types";
|
||||
import { EditSubscriberDialogState } from "./types";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
|
||||
interface EditSubscriberDialogProps extends EditSubscriberDialogState {
|
||||
onOpenChange: (open: boolean) => void
|
||||
lists: RouterOutput["list"]["list"] | undefined
|
||||
onOpenChange: (open: boolean) => void;
|
||||
lists: RouterOutput["list"]["list"] | undefined;
|
||||
}
|
||||
|
||||
export function EditSubscriberDialog({
|
||||
open,
|
||||
subscriber,
|
||||
onOpenChange,
|
||||
lists,
|
||||
open,
|
||||
subscriber,
|
||||
onOpenChange,
|
||||
lists,
|
||||
}: EditSubscriberDialogProps) {
|
||||
const { organization } = useSession()
|
||||
const utils = trpc.useUtils()
|
||||
const { organization } = useSession();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const form = useForm<z.infer<typeof editSubscriberSchema>>({
|
||||
resolver: zodResolver(editSubscriberSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
name: "",
|
||||
listIds: [],
|
||||
emailVerified: false,
|
||||
metadata: [],
|
||||
},
|
||||
})
|
||||
const form = useForm<z.infer<typeof editSubscriberSchema>>({
|
||||
resolver: zodResolver(editSubscriberSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
name: "",
|
||||
listIds: [],
|
||||
emailVerified: false,
|
||||
metadata: [],
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
fields: metadataFields,
|
||||
append: appendMetadata,
|
||||
remove: removeMetadata,
|
||||
replace: replaceMetadata,
|
||||
} = useFieldArray({
|
||||
control: form.control,
|
||||
name: "metadata",
|
||||
})
|
||||
const {
|
||||
fields: metadataFields,
|
||||
append: appendMetadata,
|
||||
remove: removeMetadata,
|
||||
replace: replaceMetadata,
|
||||
} = useFieldArray({
|
||||
control: form.control,
|
||||
name: "metadata",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (subscriber) {
|
||||
form.reset({
|
||||
email: subscriber.email,
|
||||
name: subscriber.name ?? "",
|
||||
listIds: subscriber.ListSubscribers.map((ls) => ls.List.id),
|
||||
emailVerified: subscriber.emailVerified ?? false,
|
||||
metadata:
|
||||
subscriber.Metadata?.map((m) => ({ key: m.key, value: m.value })) ||
|
||||
[],
|
||||
})
|
||||
replaceMetadata(
|
||||
subscriber.Metadata?.map((m) => ({ key: m.key, value: m.value })) || []
|
||||
)
|
||||
} else {
|
||||
form.reset({
|
||||
email: "",
|
||||
name: "",
|
||||
listIds: [],
|
||||
emailVerified: false,
|
||||
metadata: [],
|
||||
})
|
||||
replaceMetadata([])
|
||||
}
|
||||
}, [subscriber, form, replaceMetadata])
|
||||
useEffect(() => {
|
||||
if (subscriber) {
|
||||
form.reset({
|
||||
email: subscriber.email,
|
||||
name: subscriber.name ?? "",
|
||||
listIds: subscriber.ListSubscribers.map((ls) => ls.List.id),
|
||||
emailVerified: subscriber.emailVerified ?? false,
|
||||
metadata:
|
||||
subscriber.Metadata?.map((m) => ({ key: m.key, value: m.value })) ||
|
||||
[],
|
||||
});
|
||||
replaceMetadata(
|
||||
subscriber.Metadata?.map((m) => ({ key: m.key, value: m.value })) || [],
|
||||
);
|
||||
} else {
|
||||
form.reset({
|
||||
email: "",
|
||||
name: "",
|
||||
listIds: [],
|
||||
emailVerified: false,
|
||||
metadata: [],
|
||||
});
|
||||
replaceMetadata([]);
|
||||
}
|
||||
}, [subscriber, form, replaceMetadata]);
|
||||
|
||||
const updateSubscriber = trpc.subscriber.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Subscriber updated successfully")
|
||||
onOpenChange(false)
|
||||
form.reset()
|
||||
utils.list.invalidate()
|
||||
utils.subscriber.invalidate()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
const updateSubscriber = trpc.subscriber.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Subscriber updated successfully");
|
||||
onOpenChange(false);
|
||||
form.reset();
|
||||
utils.list.invalidate();
|
||||
utils.subscriber.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleEditSubscriber = (
|
||||
values: z.infer<typeof editSubscriberSchema>
|
||||
) => {
|
||||
if (!subscriber || !organization?.id) return
|
||||
const handleEditSubscriber = (
|
||||
values: z.infer<typeof editSubscriberSchema>,
|
||||
) => {
|
||||
if (!subscriber || !organization?.id) return;
|
||||
|
||||
updateSubscriber.mutate({
|
||||
id: subscriber.id,
|
||||
organizationId: organization.id,
|
||||
...values,
|
||||
})
|
||||
}
|
||||
updateSubscriber.mutate({
|
||||
id: subscriber.id,
|
||||
organizationId: organization.id,
|
||||
...values,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
onOpenChange(isOpen)
|
||||
if (!isOpen) {
|
||||
form.reset()
|
||||
replaceMetadata([])
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Subscriber</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update subscriber details and list assignments.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleEditSubscriber)}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter subscriber's name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter subscriber's email"
|
||||
type="email"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="listIds"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Lists</FormLabel>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{lists?.lists.map((list) => (
|
||||
<Button
|
||||
key={list.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={
|
||||
field.value.includes(list.id)
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => {
|
||||
const newValue = field.value.includes(list.id)
|
||||
? field.value.filter((id) => id !== list.id)
|
||||
: [...field.value, list.id]
|
||||
field.onChange(newValue)
|
||||
}}
|
||||
className="h-8"
|
||||
>
|
||||
{list.name}
|
||||
{field.value.includes(list.id) && (
|
||||
<span className="ml-1">✓</span>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
{lists?.lists.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No lists available. Create a list first.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emailVerified"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Email Verified</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<FormLabel className="text-base">Metadata</FormLabel>
|
||||
{metadataFields.length > 0 ? (
|
||||
<>
|
||||
{metadataFields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex items-center gap-2 mt-2"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`metadata.${index}.key`}
|
||||
render={({ field: keyField }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input placeholder="Key" {...keyField} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`metadata.${index}.value`}
|
||||
render={({ field: valueField }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input placeholder="Value" {...valueField} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeMetadata(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col mt-2 mb-2 border rounded-md p-4">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
No metadata added yet
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => appendMetadata({ key: "", value: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Metadata
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{metadataFields.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => appendMetadata({ key: "", value: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Metadata
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={updateSubscriber.isPending}>
|
||||
{updateSubscriber.isPending
|
||||
? "Updating..."
|
||||
: "Update Subscriber"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
onOpenChange(isOpen);
|
||||
if (!isOpen) {
|
||||
form.reset();
|
||||
replaceMetadata([]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Subscriber</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update subscriber details and list assignments.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleEditSubscriber)}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter subscriber's name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter subscriber's email"
|
||||
type="email"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="listIds"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Lists</FormLabel>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{lists?.lists.map((list) => (
|
||||
<Button
|
||||
key={list.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={
|
||||
field.value.includes(list.id)
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => {
|
||||
const newValue = field.value.includes(list.id)
|
||||
? field.value.filter((id) => id !== list.id)
|
||||
: [...field.value, list.id];
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
className="h-8"
|
||||
>
|
||||
{list.name}
|
||||
{field.value.includes(list.id) && (
|
||||
<span className="ml-1">✓</span>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
{lists?.lists.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No lists available. Create a list first.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emailVerified"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Email Verified</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<FormLabel className="text-base">Metadata</FormLabel>
|
||||
{metadataFields.length > 0 ? (
|
||||
<>
|
||||
{metadataFields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex items-center gap-2 mt-2"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`metadata.${index}.key`}
|
||||
render={({ field: keyField }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input placeholder="Key" {...keyField} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`metadata.${index}.value`}
|
||||
render={({ field: valueField }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input placeholder="Value" {...valueField} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeMetadata(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col mt-2 mb-2 border rounded-md p-4">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
No metadata added yet
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => appendMetadata({ key: "", value: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Metadata
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{metadataFields.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => appendMetadata({ key: "", value: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Metadata
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={updateSubscriber.isPending}>
|
||||
{updateSubscriber.isPending
|
||||
? "Updating..."
|
||||
: "Update Subscriber"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "./page"
|
||||
export * from "./page";
|
||||
|
||||
@@ -1,172 +1,172 @@
|
||||
import { Plus, Trash } from "lucide-react"
|
||||
import { Plus, Trash } from "lucide-react";
|
||||
import {
|
||||
type ColumnFiltersState,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { Button } from "@repo/ui"
|
||||
import { columns } from "./columns"
|
||||
import { toast } from "sonner"
|
||||
import { useState, useEffect } from "react"
|
||||
import { trpc } from "@/trpc"
|
||||
import { useSession, usePaginationWithQueryState } from "@/hooks"
|
||||
import { DataTable } from "@repo/ui"
|
||||
import { Pagination } from "@/components"
|
||||
import { SubscriberSearch } from "./subscriber-search"
|
||||
import { EditSubscriberDialogState } from "./types"
|
||||
import { SubscriberStats } from "./subscriber-stats"
|
||||
import { AddSubscriberDialog } from "./add-subscriber-dialog"
|
||||
import { EditSubscriberDialog } from "./edit-subscriber-dialog"
|
||||
import { DeleteSubscriberAlertDialog } from "./delete-subscriber-alert-dialog"
|
||||
import { SubscriberDetailsDialog } from "./subscriber-details-dialog"
|
||||
type ColumnFiltersState,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { Button } from "@repo/ui";
|
||||
import { columns } from "./columns";
|
||||
import { toast } from "sonner";
|
||||
import { useState, useEffect } from "react";
|
||||
import { trpc } from "@/trpc";
|
||||
import { useSession, usePaginationWithQueryState } from "@/hooks";
|
||||
import { DataTable } from "@repo/ui";
|
||||
import { Pagination } from "@/components";
|
||||
import { SubscriberSearch } from "./subscriber-search";
|
||||
import { EditSubscriberDialogState } from "./types";
|
||||
import { SubscriberStats } from "./subscriber-stats";
|
||||
import { AddSubscriberDialog } from "./add-subscriber-dialog";
|
||||
import { EditSubscriberDialog } from "./edit-subscriber-dialog";
|
||||
import { DeleteSubscriberAlertDialog } from "./delete-subscriber-alert-dialog";
|
||||
import { SubscriberDetailsDialog } from "./subscriber-details-dialog";
|
||||
|
||||
export function SubscribersPage() {
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [isAddingSubscriber, setIsAddingSubscriber] = useState(false)
|
||||
const [subscriberToDelete, setSubscriberToDelete] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
const [editDialog, setEditDialog] = useState<EditSubscriberDialogState>({
|
||||
open: false,
|
||||
subscriber: null,
|
||||
})
|
||||
const [detailsDialog, setDetailsDialog] = useState<{
|
||||
open: boolean
|
||||
subscriberId: string | null
|
||||
}>({
|
||||
open: false,
|
||||
subscriberId: null,
|
||||
})
|
||||
const { pagination, setPagination } = usePaginationWithQueryState({
|
||||
perPage: 8,
|
||||
})
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [isAddingSubscriber, setIsAddingSubscriber] = useState(false);
|
||||
const [subscriberToDelete, setSubscriberToDelete] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [editDialog, setEditDialog] = useState<EditSubscriberDialogState>({
|
||||
open: false,
|
||||
subscriber: null,
|
||||
});
|
||||
const [detailsDialog, setDetailsDialog] = useState<{
|
||||
open: boolean;
|
||||
subscriberId: string | null;
|
||||
}>({
|
||||
open: false,
|
||||
subscriberId: null,
|
||||
});
|
||||
const { pagination, setPagination } = usePaginationWithQueryState({
|
||||
perPage: 8,
|
||||
});
|
||||
|
||||
const { organization } = useSession()
|
||||
const { organization } = useSession();
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data, isLoading } = trpc.subscriber.list.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
page: pagination.page,
|
||||
perPage: pagination.perPage,
|
||||
search: pagination.searchQuery,
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
}
|
||||
)
|
||||
const { data, isLoading } = trpc.subscriber.list.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
page: pagination.page,
|
||||
perPage: pagination.perPage,
|
||||
search: pagination.searchQuery,
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: subscriberDetails } = trpc.subscriber.get.useQuery(
|
||||
{
|
||||
id: detailsDialog.subscriberId ?? "",
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!detailsDialog.subscriberId && !!organization?.id,
|
||||
}
|
||||
)
|
||||
const { data: subscriberDetails } = trpc.subscriber.get.useQuery(
|
||||
{
|
||||
id: detailsDialog.subscriberId ?? "",
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!detailsDialog.subscriberId && !!organization?.id,
|
||||
},
|
||||
);
|
||||
|
||||
const deleteSubscriber = trpc.subscriber.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
// invalidate the lists so that on the campaign/:id page
|
||||
// the number of recipients get updated
|
||||
utils.list.invalidate()
|
||||
utils.subscriber.invalidate()
|
||||
table.toggleAllRowsSelected(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
const deleteSubscriber = trpc.subscriber.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
// invalidate the lists so that on the campaign/:id page
|
||||
// the number of recipients get updated
|
||||
utils.list.invalidate();
|
||||
utils.subscriber.invalidate();
|
||||
table.toggleAllRowsSelected(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDeleteClick = (id: string) => {
|
||||
setSubscriberToDelete(id)
|
||||
}
|
||||
const handleDeleteClick = (id: string) => {
|
||||
setSubscriberToDelete(id);
|
||||
};
|
||||
|
||||
const table = useReactTable({
|
||||
data: data?.subscribers ?? [],
|
||||
columns: columns({
|
||||
onDelete: handleDeleteClick,
|
||||
onEdit: (subscriber) => setEditDialog({ open: true, subscriber }),
|
||||
onViewDetails: (subscriber) =>
|
||||
setDetailsDialog({ open: true, subscriberId: subscriber.id }),
|
||||
}),
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
enableRowSelection: true,
|
||||
enableMultiRowSelection: true,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
},
|
||||
})
|
||||
const table = useReactTable({
|
||||
data: data?.subscribers ?? [],
|
||||
columns: columns({
|
||||
onDelete: handleDeleteClick,
|
||||
onEdit: (subscriber) => setEditDialog({ open: true, subscriber }),
|
||||
onViewDetails: (subscriber) =>
|
||||
setDetailsDialog({ open: true, subscriberId: subscriber.id }),
|
||||
}),
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
enableRowSelection: true,
|
||||
enableMultiRowSelection: true,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
},
|
||||
});
|
||||
|
||||
const lists = trpc.list.list.useQuery({
|
||||
organizationId: organization?.id ?? "",
|
||||
})
|
||||
const lists = trpc.list.list.useQuery({
|
||||
organizationId: organization?.id ?? "",
|
||||
});
|
||||
|
||||
const handleDeleteSubscriber = () => {
|
||||
if (!subscriberToDelete) return
|
||||
const handleDeleteSubscriber = () => {
|
||||
if (!subscriberToDelete) return;
|
||||
|
||||
const ids = subscriberToDelete.split(",")
|
||||
const ids = subscriberToDelete.split(",");
|
||||
|
||||
// TODO: Send with one request
|
||||
ids.forEach((id) => {
|
||||
deleteSubscriber.mutate({
|
||||
id,
|
||||
organizationId: organization?.id ?? "",
|
||||
})
|
||||
})
|
||||
// TODO: Send with one request
|
||||
ids.forEach((id) => {
|
||||
deleteSubscriber.mutate({
|
||||
id,
|
||||
organizationId: organization?.id ?? "",
|
||||
});
|
||||
});
|
||||
|
||||
setSubscriberToDelete(null)
|
||||
}
|
||||
setSubscriberToDelete(null);
|
||||
};
|
||||
|
||||
const { data: analytics, isLoading: analyticsLoading } =
|
||||
trpc.stats.getStats.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
}
|
||||
)
|
||||
const { data: analytics, isLoading: analyticsLoading } =
|
||||
trpc.stats.getStats.useQuery(
|
||||
{
|
||||
organizationId: organization?.id ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!organization?.id,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPagination("totalPages", data?.pagination.totalPages)
|
||||
}, [data]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
setPagination("totalPages", data?.pagination.totalPages);
|
||||
}, [data]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Subscribers</h2>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Subscribers</h2>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<SubscriberStats analytics={analytics} isLoading={analyticsLoading} />
|
||||
{/* Stats Overview */}
|
||||
<SubscriberStats analytics={analytics} isLoading={analyticsLoading} />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-1 items-center space-x-2">
|
||||
<SubscriberSearch />
|
||||
{/* Filter */}
|
||||
{/* <DropdownMenu>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-1 items-center space-x-2">
|
||||
<SubscriberSearch />
|
||||
{/* Filter */}
|
||||
{/* <DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
@@ -192,16 +192,16 @@ export function SubscribersPage() {
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu> */}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AddSubscriberDialog
|
||||
open={isAddingSubscriber}
|
||||
onOpenChange={setIsAddingSubscriber}
|
||||
onSuccess={() => {
|
||||
// Optionally, you can add any specific logic needed on success in the parent page
|
||||
}}
|
||||
/>
|
||||
{/* <ImportSubscribersDialog
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AddSubscriberDialog
|
||||
open={isAddingSubscriber}
|
||||
onOpenChange={setIsAddingSubscriber}
|
||||
onSuccess={() => {
|
||||
// Optionally, you can add any specific logic needed on success in the parent page
|
||||
}}
|
||||
/>
|
||||
{/* <ImportSubscribersDialog
|
||||
onSuccess={() => {
|
||||
utils.subscriber.list.invalidate()
|
||||
}}
|
||||
@@ -216,90 +216,90 @@ export function SubscribersPage() {
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</WithTooltip> */}
|
||||
</div>
|
||||
</div>
|
||||
{Object.keys(rowSelection).length > 0 && (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-600"
|
||||
onClick={() => {
|
||||
const selectedIds = table
|
||||
.getSelectedRowModel()
|
||||
.rows.map((row) => row.original.id)
|
||||
setSubscriberToDelete(selectedIds.join(","))
|
||||
}}
|
||||
>
|
||||
<Trash className="h-4 w-4 mr-2" />
|
||||
Delete {Object.keys(rowSelection).length} subscriber
|
||||
{Object.keys(rowSelection).length === 1 ? "" : "s"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<DataTable
|
||||
title="Subscribers"
|
||||
columns={columns({
|
||||
onDelete: handleDeleteClick,
|
||||
onEdit: (subscriber) => setEditDialog({ open: true, subscriber }),
|
||||
onViewDetails: (subscriber) =>
|
||||
setDetailsDialog({ open: true, subscriberId: subscriber.id }),
|
||||
})}
|
||||
data={data?.subscribers ?? []}
|
||||
className="h-[calc(100vh-440px)]"
|
||||
isLoading={isLoading}
|
||||
NoResultsContent={
|
||||
<div className="flex flex-col items-center justify-center h-full my-10 gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No subscribers found.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add a new subscriber to get started.
|
||||
</p>
|
||||
<Button onClick={() => setIsAddingSubscriber(true)}>
|
||||
Add a Subscriber <Plus className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
{data?.pagination.total ?? 0} total subscribers
|
||||
</div>
|
||||
<Pagination
|
||||
page={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={(page) => setPagination("page", page)}
|
||||
hasNextPage={pagination.page < pagination.totalPages}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{Object.keys(rowSelection).length > 0 && (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-600"
|
||||
onClick={() => {
|
||||
const selectedIds = table
|
||||
.getSelectedRowModel()
|
||||
.rows.map((row) => row.original.id);
|
||||
setSubscriberToDelete(selectedIds.join(","));
|
||||
}}
|
||||
>
|
||||
<Trash className="h-4 w-4 mr-2" />
|
||||
Delete {Object.keys(rowSelection).length} subscriber
|
||||
{Object.keys(rowSelection).length === 1 ? "" : "s"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<DataTable
|
||||
title="Subscribers"
|
||||
columns={columns({
|
||||
onDelete: handleDeleteClick,
|
||||
onEdit: (subscriber) => setEditDialog({ open: true, subscriber }),
|
||||
onViewDetails: (subscriber) =>
|
||||
setDetailsDialog({ open: true, subscriberId: subscriber.id }),
|
||||
})}
|
||||
data={data?.subscribers ?? []}
|
||||
className="h-[calc(100vh-440px)]"
|
||||
isLoading={isLoading}
|
||||
NoResultsContent={
|
||||
<div className="flex flex-col items-center justify-center h-full my-10 gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No subscribers found.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add a new subscriber to get started.
|
||||
</p>
|
||||
<Button onClick={() => setIsAddingSubscriber(true)}>
|
||||
Add a Subscriber <Plus className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
{data?.pagination.total ?? 0} total subscribers
|
||||
</div>
|
||||
<Pagination
|
||||
page={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={(page) => setPagination("page", page)}
|
||||
hasNextPage={pagination.page < pagination.totalPages}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Subscriber Alert Dialog */}
|
||||
<DeleteSubscriberAlertDialog
|
||||
open={subscriberToDelete !== null}
|
||||
onOpenChange={(open) => !open && setSubscriberToDelete(null)}
|
||||
onConfirm={handleDeleteSubscriber}
|
||||
subscriberToDelete={subscriberToDelete}
|
||||
isPending={deleteSubscriber.isPending}
|
||||
/>
|
||||
{/* Delete Subscriber Alert Dialog */}
|
||||
<DeleteSubscriberAlertDialog
|
||||
open={subscriberToDelete !== null}
|
||||
onOpenChange={(open) => !open && setSubscriberToDelete(null)}
|
||||
onConfirm={handleDeleteSubscriber}
|
||||
subscriberToDelete={subscriberToDelete}
|
||||
isPending={deleteSubscriber.isPending}
|
||||
/>
|
||||
|
||||
{/* Edit Subscriber Dialog */}
|
||||
<EditSubscriberDialog
|
||||
open={editDialog.open}
|
||||
subscriber={editDialog.subscriber}
|
||||
onOpenChange={(open) => {
|
||||
setEditDialog((prev) => ({ ...prev, open }))
|
||||
}}
|
||||
lists={lists.data}
|
||||
/>
|
||||
{/* Edit Subscriber Dialog */}
|
||||
<EditSubscriberDialog
|
||||
open={editDialog.open}
|
||||
subscriber={editDialog.subscriber}
|
||||
onOpenChange={(open) => {
|
||||
setEditDialog((prev) => ({ ...prev, open }));
|
||||
}}
|
||||
lists={lists.data}
|
||||
/>
|
||||
|
||||
{/* Subscriber Details Dialog */}
|
||||
<SubscriberDetailsDialog
|
||||
open={detailsDialog.open}
|
||||
onOpenChange={(open) => setDetailsDialog({ open, subscriberId: null })}
|
||||
subscriber={subscriberDetails}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
{/* Subscriber Details Dialog */}
|
||||
<SubscriberDetailsDialog
|
||||
open={detailsDialog.open}
|
||||
onOpenChange={(open) => setDetailsDialog({ open, subscriberId: null })}
|
||||
subscriber={subscriberDetails}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
import * as z from "zod"
|
||||
import * as z from "zod";
|
||||
|
||||
export const addSubscriberSchema = z.object({
|
||||
email: z.string().email({
|
||||
message: "Please enter a valid email address.",
|
||||
}),
|
||||
name: z.string().optional(),
|
||||
listIds: z.array(z.string()),
|
||||
emailVerified: z.boolean().optional(),
|
||||
metadata: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string().min(1, "Key cannot be empty"),
|
||||
value: z.string().min(1, "Value cannot be empty"),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
email: z.string().email({
|
||||
message: "Please enter a valid email address.",
|
||||
}),
|
||||
name: z.string().optional(),
|
||||
listIds: z.array(z.string()),
|
||||
emailVerified: z.boolean().optional(),
|
||||
metadata: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string().min(1, "Key cannot be empty"),
|
||||
value: z.string().min(1, "Value cannot be empty"),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const editSubscriberSchema = z.object({
|
||||
email: z.string().email({
|
||||
message: "Please enter a valid email address.",
|
||||
}),
|
||||
name: z.string().optional(),
|
||||
listIds: z.array(z.string()),
|
||||
emailVerified: z.boolean().optional(),
|
||||
metadata: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string().min(1, "Key cannot be empty"),
|
||||
value: z.string().min(1, "Value cannot be empty"),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
email: z.string().email({
|
||||
message: "Please enter a valid email address.",
|
||||
}),
|
||||
name: z.string().optional(),
|
||||
listIds: z.array(z.string()),
|
||||
emailVerified: z.boolean().optional(),
|
||||
metadata: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string().min(1, "Key cannot be empty"),
|
||||
value: z.string().min(1, "Value cannot be empty"),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
@@ -1,115 +1,115 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@repo/ui"
|
||||
import { RouterOutput } from "@/types"
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@repo/ui";
|
||||
import { RouterOutput } from "@/types";
|
||||
|
||||
interface SubscriberDetailsDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
subscriber: RouterOutput["subscriber"]["get"] | null | undefined
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
subscriber: RouterOutput["subscriber"]["get"] | null | undefined;
|
||||
}
|
||||
|
||||
export function SubscriberDetailsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
subscriber,
|
||||
open,
|
||||
onOpenChange,
|
||||
subscriber,
|
||||
}: SubscriberDetailsDialogProps) {
|
||||
if (!subscriber) return null
|
||||
if (!subscriber) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Subscriber Details</DialogTitle>
|
||||
<DialogDescription>
|
||||
Viewing details for {subscriber.email}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">General Information</h3>
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2 mt-2">
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
Name
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">{subscriber.name || "N/A"}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
Email
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">{subscriber.email}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
Email Verified
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{subscriber.emailVerified ? "Yes" : "No"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
Created At
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{new Date(subscriber.createdAt).toLocaleDateString()}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Subscriber Details</DialogTitle>
|
||||
<DialogDescription>
|
||||
Viewing details for {subscriber.email}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">General Information</h3>
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2 mt-2">
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
Name
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">{subscriber.name || "N/A"}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
Email
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">{subscriber.email}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
Email Verified
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{subscriber.emailVerified ? "Yes" : "No"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
Created At
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{new Date(subscriber.createdAt).toLocaleDateString()}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{subscriber.Metadata && subscriber.Metadata.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mt-4">Metadata</h3>
|
||||
<Table className="mt-2">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Key</TableHead>
|
||||
<TableHead>Value</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{subscriber.Metadata.map((meta) => (
|
||||
<TableRow key={meta.id}>
|
||||
<TableCell>{meta.key}</TableCell>
|
||||
<TableCell>{meta.value}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
{subscriber.Metadata && subscriber.Metadata.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mt-4">Metadata</h3>
|
||||
<Table className="mt-2">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Key</TableHead>
|
||||
<TableHead>Value</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{subscriber.Metadata.map((meta) => (
|
||||
<TableRow key={meta.id}>
|
||||
<TableCell>{meta.key}</TableCell>
|
||||
<TableCell>{meta.value}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subscriber.ListSubscribers &&
|
||||
subscriber.ListSubscribers.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mt-4">Lists</h3>
|
||||
<div className="mt-2 space-y-1">
|
||||
{subscriber.ListSubscribers.map((listSub) => (
|
||||
<div
|
||||
key={listSub.List.id}
|
||||
className="text-sm p-2 border rounded-md"
|
||||
>
|
||||
{listSub.List.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
{subscriber.ListSubscribers &&
|
||||
subscriber.ListSubscribers.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mt-4">Lists</h3>
|
||||
<div className="mt-2 space-y-1">
|
||||
{subscriber.ListSubscribers.map((listSub) => (
|
||||
<div
|
||||
key={listSub.List.id}
|
||||
className="text-sm p-2 border rounded-md"
|
||||
>
|
||||
{listSub.List.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Input } from "@repo/ui"
|
||||
import { usePaginationWithQueryState } from "@/hooks/usePagination"
|
||||
import { Input } from "@repo/ui";
|
||||
import { usePaginationWithQueryState } from "@/hooks/usePagination";
|
||||
|
||||
export const SubscriberSearch = () => {
|
||||
const { pagination, setPagination } = usePaginationWithQueryState()
|
||||
const { pagination, setPagination } = usePaginationWithQueryState();
|
||||
|
||||
return (
|
||||
<Input
|
||||
placeholder="Search subscribers..."
|
||||
value={pagination.search}
|
||||
onChange={(event) => setPagination("search", event.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
placeholder="Search subscribers..."
|
||||
value={pagination.search}
|
||||
onChange={(event) => setPagination("search", event.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,117 +1,117 @@
|
||||
import { ArrowDown, ArrowUp, Users } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@repo/ui"
|
||||
import { CardSkeleton } from "@/components"
|
||||
import { RouterOutput } from "@/types"
|
||||
import { ArrowDown, ArrowUp, Users } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@repo/ui";
|
||||
import { CardSkeleton } from "@/components";
|
||||
import { RouterOutput } from "@/types";
|
||||
|
||||
interface SubscriberStatsProps {
|
||||
analytics: RouterOutput["stats"]["getStats"] | undefined
|
||||
isLoading: boolean
|
||||
analytics: RouterOutput["stats"]["getStats"] | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function SubscriberStats({
|
||||
analytics,
|
||||
isLoading,
|
||||
analytics,
|
||||
isLoading,
|
||||
}: SubscriberStatsProps) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Active Subscribers
|
||||
</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading || !analytics ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{analytics.subscribers.allTime.toLocaleString()}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
<span className="text-emerald-500 inline-flex items-center">
|
||||
<ArrowUp className="mr-1 h-4 w-4" />+
|
||||
{analytics.subscribers.newThisMonth.toLocaleString()}
|
||||
</span>{" "}
|
||||
this month
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Average Open Rate{" "}
|
||||
<small className="text-xs text-muted-foreground">
|
||||
(Last 30 days)
|
||||
</small>
|
||||
</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading || !analytics ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{analytics.openRate.thisMonth.toFixed(1)}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
{analytics.openRate.comparison >= 0 ? (
|
||||
<span className="text-emerald-500 inline-flex items-center">
|
||||
<ArrowUp className="mr-1 h-4 w-4" />+
|
||||
{analytics.openRate.comparison.toFixed(1)}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-rose-500 inline-flex items-center">
|
||||
<ArrowDown className="mr-1 h-4 w-4" />
|
||||
{analytics.openRate.comparison.toFixed(1)}%
|
||||
</span>
|
||||
)}{" "}
|
||||
vs last month
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Unsubscribed{" "}
|
||||
<small className="text-xs text-muted-foreground">
|
||||
(Last 30 days)
|
||||
</small>
|
||||
</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading || !analytics ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{analytics.unsubscribed.thisMonth.toLocaleString()}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
{analytics.unsubscribed.comparison <= 0 ? (
|
||||
<span className="text-emerald-500 inline-flex items-center">
|
||||
<ArrowDown className="mr-1 h-4 w-4" />-
|
||||
{Math.abs(analytics.unsubscribed.comparison)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-rose-500 inline-flex items-center">
|
||||
<ArrowUp className="mr-1 h-4 w-4" />+
|
||||
{Math.abs(analytics.unsubscribed.comparison)}
|
||||
</span>
|
||||
)}{" "}
|
||||
vs last month
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Active Subscribers
|
||||
</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading || !analytics ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{analytics.subscribers.allTime.toLocaleString()}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
<span className="text-emerald-500 inline-flex items-center">
|
||||
<ArrowUp className="mr-1 h-4 w-4" />+
|
||||
{analytics.subscribers.newThisMonth.toLocaleString()}
|
||||
</span>{" "}
|
||||
this month
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Average Open Rate{" "}
|
||||
<small className="text-xs text-muted-foreground">
|
||||
(Last 30 days)
|
||||
</small>
|
||||
</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading || !analytics ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{analytics.openRate.thisMonth.toFixed(1)}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
{analytics.openRate.comparison >= 0 ? (
|
||||
<span className="text-emerald-500 inline-flex items-center">
|
||||
<ArrowUp className="mr-1 h-4 w-4" />+
|
||||
{analytics.openRate.comparison.toFixed(1)}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-rose-500 inline-flex items-center">
|
||||
<ArrowDown className="mr-1 h-4 w-4" />
|
||||
{analytics.openRate.comparison.toFixed(1)}%
|
||||
</span>
|
||||
)}{" "}
|
||||
vs last month
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card hoverEffect>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Unsubscribed{" "}
|
||||
<small className="text-xs text-muted-foreground">
|
||||
(Last 30 days)
|
||||
</small>
|
||||
</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading || !analytics ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{analytics.unsubscribed.thisMonth.toLocaleString()}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
{analytics.unsubscribed.comparison <= 0 ? (
|
||||
<span className="text-emerald-500 inline-flex items-center">
|
||||
<ArrowDown className="mr-1 h-4 w-4" />-
|
||||
{Math.abs(analytics.unsubscribed.comparison)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-rose-500 inline-flex items-center">
|
||||
<ArrowUp className="mr-1 h-4 w-4" />+
|
||||
{Math.abs(analytics.unsubscribed.comparison)}
|
||||
</span>
|
||||
)}{" "}
|
||||
vs last month
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { RouterOutput } from "@/types"
|
||||
import { ListSubscriber, Subscriber } from "backend"
|
||||
import { RouterOutput } from "@/types";
|
||||
import { ListSubscriber, Subscriber } from "backend";
|
||||
|
||||
export type PopulatedSubscriber = Subscriber & {
|
||||
ListSubscribers: (ListSubscriber & {
|
||||
List: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
})[]
|
||||
}
|
||||
ListSubscribers: (ListSubscriber & {
|
||||
List: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
})[];
|
||||
};
|
||||
|
||||
export interface EditSubscriberDialogState {
|
||||
open: boolean
|
||||
subscriber: RouterOutput["subscriber"]["list"]["subscribers"][number] | null
|
||||
open: boolean;
|
||||
subscriber: RouterOutput["subscriber"]["list"]["subscribers"][number] | null;
|
||||
}
|
||||
|
||||
@@ -1,108 +1,108 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { MoreHorizontal, Trash, Eye, Edit } from "lucide-react"
|
||||
import { MoreHorizontal, Trash, Eye, Edit } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@repo/ui"
|
||||
import { ColumnDef } from "@tanstack/react-table"
|
||||
import { Template } from "backend"
|
||||
import { UpdateTemplateDialog } from "./update-template-dialog"
|
||||
import { ViewTemplateDialog } from "./view-template-dialog"
|
||||
import { displayDateTime } from "@/utils"
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@repo/ui";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Template } from "backend";
|
||||
import { UpdateTemplateDialog } from "./update-template-dialog";
|
||||
import { ViewTemplateDialog } from "./view-template-dialog";
|
||||
import { displayDateTime } from "@/utils";
|
||||
|
||||
interface ColumnActions {
|
||||
onDelete: (id: string) => void
|
||||
onDuplicate: (template: Template) => void
|
||||
organizationId: string
|
||||
onDelete: (id: string) => void;
|
||||
onDuplicate: (template: Template) => void;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export const columns = (actions: ColumnActions): ColumnDef<Template>[] => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<div className="font-medium">{row.original.name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{row.original.description}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "subject",
|
||||
header: "Subject",
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Created At",
|
||||
cell: ({ row }) => {
|
||||
return displayDateTime(row.original.createdAt)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "updatedAt",
|
||||
header: "Updated At",
|
||||
cell: ({ row }) => displayDateTime(row.original.updatedAt),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const template = row.original
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<div className="font-medium">{row.original.name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{row.original.description}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "subject",
|
||||
header: "Subject",
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Created At",
|
||||
cell: ({ row }) => {
|
||||
return displayDateTime(row.original.createdAt);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "updatedAt",
|
||||
header: "Updated At",
|
||||
cell: ({ row }) => displayDateTime(row.original.updatedAt),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const template = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<ViewTemplateDialog
|
||||
template={template}
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<UpdateTemplateDialog
|
||||
template={template}
|
||||
organizationId={actions.organizationId}
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(template.id)}
|
||||
>
|
||||
Copy ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Edit Template</DropdownMenuItem>
|
||||
<DropdownMenuItem>Duplicate Template</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => actions.onDelete(template.id)}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
Delete Template
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<ViewTemplateDialog
|
||||
template={template}
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<UpdateTemplateDialog
|
||||
template={template}
|
||||
organizationId={actions.organizationId}
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(template.id)}
|
||||
>
|
||||
Copy ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Edit Template</DropdownMenuItem>
|
||||
<DropdownMenuItem>Duplicate Template</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => actions.onDelete(template.id)}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
Delete Template
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,188 +1,188 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import * as z from "zod"
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Textarea,
|
||||
} from "@repo/ui"
|
||||
import { TextCursor } from "lucide-react"
|
||||
import { trpc } from "@/trpc"
|
||||
import { toast } from "sonner"
|
||||
import { useSession } from "@/hooks"
|
||||
import { useState } from "react"
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Textarea,
|
||||
} from "@repo/ui";
|
||||
import { TextCursor } from "lucide-react";
|
||||
import { trpc } from "@/trpc";
|
||||
import { toast } from "sonner";
|
||||
import { useSession } from "@/hooks";
|
||||
import { useState } from "react";
|
||||
|
||||
const templateSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
description: z.string().optional(),
|
||||
content: z
|
||||
.string()
|
||||
.min(1, "HTML content is required")
|
||||
.refine(
|
||||
(content) => content.includes("{{content}}"),
|
||||
"Content must include the {{content}} placeholder"
|
||||
),
|
||||
})
|
||||
name: z.string().min(1, "Name is required"),
|
||||
description: z.string().optional(),
|
||||
content: z
|
||||
.string()
|
||||
.min(1, "HTML content is required")
|
||||
.refine(
|
||||
(content) => content.includes("{{content}}"),
|
||||
"Content must include the {{content}} placeholder",
|
||||
),
|
||||
});
|
||||
|
||||
export type CreateTemplateFormData = z.infer<typeof templateSchema>
|
||||
export type CreateTemplateFormData = z.infer<typeof templateSchema>;
|
||||
|
||||
interface CreateTemplateFormProps {
|
||||
children: React.ReactNode
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CreateTemplateForm({ children }: CreateTemplateFormProps) {
|
||||
const form = useForm<CreateTemplateFormData>({
|
||||
resolver: zodResolver(templateSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
content: "",
|
||||
},
|
||||
})
|
||||
const { orgId } = useSession()
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const form = useForm<CreateTemplateFormData>({
|
||||
resolver: zodResolver(templateSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
content: "",
|
||||
},
|
||||
});
|
||||
const { orgId } = useSession();
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
const insertContentPlaceholder = () => {
|
||||
const textarea = document.getElementById("content") as HTMLTextAreaElement
|
||||
if (!textarea) return
|
||||
const insertContentPlaceholder = () => {
|
||||
const textarea = document.getElementById("content") as HTMLTextAreaElement;
|
||||
if (!textarea) return;
|
||||
|
||||
const cursorPos = textarea.selectionStart
|
||||
const textBefore = textarea.value.substring(0, cursorPos)
|
||||
const textAfter = textarea.value.substring(cursorPos)
|
||||
const cursorPos = textarea.selectionStart;
|
||||
const textBefore = textarea.value.substring(0, cursorPos);
|
||||
const textAfter = textarea.value.substring(cursorPos);
|
||||
|
||||
const newValue = `${textBefore}{{content}}${textAfter}`
|
||||
form.setValue("content", newValue)
|
||||
const newValue = `${textBefore}{{content}}${textAfter}`;
|
||||
form.setValue("content", newValue);
|
||||
|
||||
// Reset cursor position after the placeholder
|
||||
setTimeout(() => {
|
||||
textarea.focus()
|
||||
const newCursorPos = cursorPos + 11 // length of "{{content}}"
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
}, 0)
|
||||
}
|
||||
// Reset cursor position after the placeholder
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
const newCursorPos = cursorPos + 11; // length of "{{content}}"
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const createTemplateMutation = trpc.template.create.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Template created successfully")
|
||||
setIsDialogOpen(false)
|
||||
form.reset()
|
||||
utils.template.invalidate()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
const createTemplateMutation = trpc.template.create.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Template created successfully");
|
||||
setIsDialogOpen(false);
|
||||
form.reset();
|
||||
utils.template.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: CreateTemplateFormData) => {
|
||||
if (!orgId) return
|
||||
const onSubmit = (data: CreateTemplateFormData) => {
|
||||
if (!orgId) return;
|
||||
|
||||
createTemplateMutation.mutate({
|
||||
...data,
|
||||
organizationId: orgId,
|
||||
description: data.description || null,
|
||||
})
|
||||
}
|
||||
createTemplateMutation.mutate({
|
||||
...data,
|
||||
organizationId: orgId,
|
||||
description: data.description || null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[625px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Template</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new email template for your campaigns.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
return (
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[625px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Template</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new email template for your campaigns.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter template name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter template description"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>HTML Content</FormLabel>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={insertContentPlaceholder}
|
||||
>
|
||||
<TextCursor className="mr-2 h-4 w-4" />
|
||||
Insert Content Placeholder
|
||||
</Button>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
id="content"
|
||||
placeholder="Enter HTML content"
|
||||
className="min-h-[200px] font-mono"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={createTemplateMutation.isPending}>
|
||||
{createTemplateMutation.isPending
|
||||
? "Creating..."
|
||||
: "Create Template"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter template name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter template description"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>HTML Content</FormLabel>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={insertContentPlaceholder}
|
||||
>
|
||||
<TextCursor className="mr-2 h-4 w-4" />
|
||||
Insert Content Placeholder
|
||||
</Button>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
id="content"
|
||||
placeholder="Enter HTML content"
|
||||
className="min-h-[200px] font-mono"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={createTemplateMutation.isPending}>
|
||||
{createTemplateMutation.isPending
|
||||
? "Creating..."
|
||||
: "Create Template"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user