chore: format

This commit is contained in:
2025-10-10 16:43:21 +02:00
parent f0aabd63b6
commit 75c29e0ba4
551 changed files with 433948 additions and 94145 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"
/>
);
}

View File

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

View File

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

View File

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

View File

@@ -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";

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
export * from "./theme-provider"
export * from "./theme-provider";

View File

@@ -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,
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
});

View File

@@ -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";

View File

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

View File

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

View File

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

View File

@@ -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,
};
}

View File

@@ -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) ?? "",
};
}

View File

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

View File

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

View File

@@ -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
}

View File

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

View File

@@ -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>,
);

View File

@@ -1 +1 @@
export * from "./page"
export * from "./page";

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
export * from "./page"
export * from "./page";

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,2 +1,2 @@
export * from "./page"
export * from "./layout"
export * from "./page";
export * from "./layout";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"
/>
);
};

View File

@@ -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>
),
},
];

View File

@@ -1,2 +1,2 @@
export * from "./page"
export * from "./[id]"
export * from "./page";
export * from "./[id]";

View File

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

View File

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

View File

@@ -1 +1 @@
export * from "./dashboard"
export * from "./dashboard";

View File

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

View File

@@ -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";

View File

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

View File

@@ -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>
),
},
];

View File

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

View File

@@ -1 +1 @@
export * from "./page"
export * from "./page";

View File

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

View File

@@ -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"
/>
);
};

View File

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

View File

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

View File

@@ -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}
/>
),
},
];
};

View File

@@ -1 +1 @@
export * from "./page"
export * from "./page";

View File

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

View File

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

View File

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

View File

@@ -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"
/>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
export * from "./page"
export * from "./page";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
),
},
];

View File

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

View File

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

View File

@@ -1 +1 @@
export * from "./page"
export * from "./page";

View File

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

View File

@@ -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(),
});

View File

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

View File

@@ -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"
/>
);
};

View File

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

View File

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

View File

@@ -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>
);
},
},
];

View File

@@ -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