initial commit. gotta go

This commit is contained in:
2025-09-26 14:30:57 -05:00
parent b342335502
commit eb0b35bb7f
299 changed files with 6902 additions and 6741 deletions

View File

@@ -1,17 +1,17 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Button } from '@usesend/ui/src/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
} from '@usesend/ui/src/dialog';
import { Plus } from "lucide-react";
import { useState } from "react";
import { AddSesSettingsForm } from "~/components/settings/AddSesSettings";
import { Plus } from 'lucide-react';
import { useState } from 'react';
import { AddSesSettingsForm } from '~/components/settings/AddSesSettings';
export default function AddSesConfiguration() {
const [open, setOpen] = useState(false);
@@ -23,7 +23,7 @@ export default function AddSesConfiguration() {
>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-1" />
<Plus className="mr-1 h-4 w-4" />
Add SES configuration
</Button>
</DialogTrigger>

View File

@@ -1,17 +1,17 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Button } from '@usesend/ui/src/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
} from '@usesend/ui/src/dialog';
import { Edit } from "lucide-react";
import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Edit } from 'lucide-react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Form,
FormControl,
@@ -20,14 +20,14 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { api } from "~/trpc/react";
import { Input } from "@usesend/ui/src/input";
import { toast } from "@usesend/ui/src/toaster";
import Spinner from "@usesend/ui/src/spinner";
import { SesSetting } from "@prisma/client";
} from '@usesend/ui/src/form';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { api } from '~/trpc/react';
import { Input } from '@usesend/ui/src/input';
import { toast } from '@usesend/ui/src/toaster';
import Spinner from '@usesend/ui/src/spinner';
import { SesSetting } from '@prisma/client';
const FormSchema = z.object({
settingsId: z.string(),
@@ -96,7 +96,7 @@ export const EditSesSettingsForm: React.FC<SesSettingsProps> = ({
onSuccess?.();
},
onError: (e) => {
toast.error("Failed to update", {
toast.error('Failed to update', {
description: e.message,
});
},
@@ -107,7 +107,7 @@ export const EditSesSettingsForm: React.FC<SesSettingsProps> = ({
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className=" flex flex-col gap-8 w-full"
className="flex w-full flex-col gap-8"
>
<FormField
control={form.control}
@@ -151,12 +151,12 @@ export const EditSesSettingsForm: React.FC<SesSettingsProps> = ({
<Button
type="submit"
disabled={updateSesSettings.isPending}
className="w-[200px] mx-auto"
className="mx-auto w-[200px]"
>
{updateSesSettings.isPending ? (
<Spinner className="w-5 h-5" />
<Spinner className="h-5 w-5" />
) : (
"Update"
'Update'
)}
</Button>
</form>

View File

@@ -1,4 +1,4 @@
export const timeframeOptions = [
{ label: "Today", value: "today" },
{ label: "This month", value: "thisMonth" },
{ label: 'Today', value: 'today' },
{ label: 'This month', value: 'thisMonth' },
] as const;

View File

@@ -1,16 +1,16 @@
"use client";
'use client';
import { useMemo, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@usesend/ui/src/card";
import { Label } from "@usesend/ui/src/label";
import { Switch } from "@usesend/ui/src/switch";
import { useMemo, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@usesend/ui/src/card';
import { Label } from '@usesend/ui/src/label';
import { Switch } from '@usesend/ui/src/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@usesend/ui/src/select";
} from '@usesend/ui/src/select';
import {
Table,
TableBody,
@@ -18,17 +18,17 @@ import {
TableHead,
TableHeader,
TableRow,
} from "@usesend/ui/src/table";
import Spinner from "@usesend/ui/src/spinner";
import { api } from "~/trpc/react";
import { isCloud } from "~/utils/common";
import { timeframeOptions } from "./constants";
import { keepPreviousData } from "@tanstack/react-query";
} from '@usesend/ui/src/table';
import Spinner from '@usesend/ui/src/spinner';
import { api } from '~/trpc/react';
import { isCloud } from '~/utils/common';
import { timeframeOptions } from './constants';
import { keepPreviousData } from '@tanstack/react-query';
export default function AdminEmailAnalyticsPage() {
const isCloudEnv = isCloud();
const [timeframe, setTimeframe] =
useState<(typeof timeframeOptions)[number]["value"]>("today");
useState<(typeof timeframeOptions)[number]['value']>('today');
const [paidOnly, setPaidOnly] = useState(false);
const analyticsQuery = api.admin.getEmailAnalytics.useQuery(
@@ -36,7 +36,7 @@ export default function AdminEmailAnalyticsPage() {
timeframe,
paidOnly,
},
{ enabled: isCloudEnv, placeholderData: keepPreviousData }
{ enabled: isCloudEnv, placeholderData: keepPreviousData },
);
const data = analyticsQuery.data;
@@ -55,7 +55,7 @@ export default function AdminEmailAnalyticsPage() {
if (!isCloudEnv) {
return (
<div className="rounded-lg border bg-muted/30 p-6 text-sm text-muted-foreground">
<div className="bg-muted/30 text-muted-foreground rounded-lg border p-6 text-sm">
Email analytics are available only in the cloud deployment.
</div>
);
@@ -70,7 +70,7 @@ export default function AdminEmailAnalyticsPage() {
<Select
value={timeframe}
onValueChange={(value) =>
setTimeframe(value as (typeof timeframeOptions)[number]["value"])
setTimeframe(value as (typeof timeframeOptions)[number]['value'])
}
>
<SelectTrigger id="timeframe">
@@ -106,8 +106,8 @@ export default function AdminEmailAnalyticsPage() {
<div>
<CardTitle>Usage by team</CardTitle>
{data ? (
<p className="text-sm text-muted-foreground">
Since {data.timeframe === "today" ? "today" : data.periodStart}
<p className="text-muted-foreground text-sm">
Since {data.timeframe === 'today' ? 'today' : data.periodStart}
</p>
) : null}
</div>
@@ -174,7 +174,7 @@ function SummaryCard({ label, value }: { label: string; value: number }) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
<CardTitle className="text-muted-foreground text-sm font-medium">
{label}
</CardTitle>
</CardHeader>

View File

@@ -1,7 +1,7 @@
"use client";
'use client';
import { SettingsNavButton } from "../dev-settings/settings-nav-button";
import { isCloud } from "~/utils/common";
import { SettingsNavButton } from '../dev-settings/settings-nav-button';
import { isCloud } from '~/utils/common';
export default function AdminLayout({
children,
@@ -12,13 +12,9 @@ export default function AdminLayout({
<div>
<h1 className="text-lg font-bold">Admin</h1>
<div className="mt-4 flex gap-4">
<SettingsNavButton href="/admin">
SES Configurations
</SettingsNavButton>
<SettingsNavButton href="/admin">SES Configurations</SettingsNavButton>
{isCloud() ? (
<SettingsNavButton href="/admin/teams">
Teams
</SettingsNavButton>
<SettingsNavButton href="/admin/teams">Teams</SettingsNavButton>
) : null}
{isCloud() ? (
<SettingsNavButton href="/admin/email-analytics">
@@ -26,9 +22,7 @@ export default function AdminLayout({
</SettingsNavButton>
) : null}
{isCloud() ? (
<SettingsNavButton href="/admin/waitlist">
Waitlist
</SettingsNavButton>
<SettingsNavButton href="/admin/waitlist">Waitlist</SettingsNavButton>
) : null}
</div>
<div className="mt-8">{children}</div>

View File

@@ -1,7 +1,7 @@
"use client";
'use client';
import AddSesConfiguration from "./add-ses-configuration";
import SesConfigurations from "./ses-configurations";
import AddSesConfiguration from './add-ses-configuration';
import SesConfigurations from './ses-configurations';
export default function AdminSesPage() {
return (

View File

@@ -1,4 +1,4 @@
"use client";
'use client';
import {
Table,
@@ -7,22 +7,22 @@ import {
TableHead,
TableHeader,
TableRow,
} from "@usesend/ui/src/table";
import { formatDistanceToNow } from "date-fns";
import { api } from "~/trpc/react";
import Spinner from "@usesend/ui/src/spinner";
import EditSesConfiguration from "./edit-ses-configuration";
import { TextWithCopyButton } from "@usesend/ui/src/text-with-copy";
} from '@usesend/ui/src/table';
import { formatDistanceToNow } from 'date-fns';
import { api } from '~/trpc/react';
import Spinner from '@usesend/ui/src/spinner';
import EditSesConfiguration from './edit-ses-configuration';
import { TextWithCopyButton } from '@usesend/ui/src/text-with-copy';
export default function SesConfigurations() {
const sesSettingsQuery = api.admin.getSesSettings.useQuery();
return (
<div className="">
<div className="border rounded-xl shadow">
<div className="rounded-xl border shadow">
<Table className="">
<TableHeader className="">
<TableRow className=" bg-muted/30">
<TableRow className="bg-muted/30">
<TableHead className="rounded-tl-xl">Region</TableHead>
<TableHead>Prefix Key</TableHead>
<TableHead>Callback URL</TableHead>
@@ -36,16 +36,16 @@ export default function SesConfigurations() {
<TableBody>
{sesSettingsQuery.isLoading ? (
<TableRow className="h-32">
<TableCell colSpan={6} className="text-center py-4">
<TableCell colSpan={6} className="py-4 text-center">
<Spinner
className="w-6 h-6 mx-auto"
className="mx-auto h-6 w-6"
innerSvgClass="stroke-primary"
/>
</TableCell>
</TableRow>
) : sesSettingsQuery.data?.length === 0 ? (
<TableRow className="h-32">
<TableCell colSpan={6} className="text-center py-4">
<TableCell colSpan={6} className="py-4 text-center">
<p>No SES configurations added</p>
</TableCell>
</TableRow>
@@ -63,7 +63,7 @@ export default function SesConfigurations() {
</div>
</TableCell>
<TableCell>
{sesSetting.callbackSuccess ? "Success" : "Failed"}
{sesSetting.callbackSuccess ? 'Success' : 'Failed'}
</TableCell>
<TableCell>
{formatDistanceToNow(sesSetting.createdAt)} ago

View File

@@ -1,10 +1,10 @@
"use client";
'use client';
import { useEffect, useState } from "react";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { Button } from "@usesend/ui/src/button";
import { useEffect, useState } from 'react';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Button } from '@usesend/ui/src/button';
import {
Form,
FormControl,
@@ -12,43 +12,45 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { Input } from "@usesend/ui/src/input";
import { Switch } from "@usesend/ui/src/switch";
import Spinner from "@usesend/ui/src/spinner";
import { toast } from "@usesend/ui/src/toaster";
import { Badge } from "@usesend/ui/src/badge";
} from '@usesend/ui/src/form';
import { Input } from '@usesend/ui/src/input';
import { Switch } from '@usesend/ui/src/switch';
import Spinner from '@usesend/ui/src/spinner';
import { toast } from '@usesend/ui/src/toaster';
import { Badge } from '@usesend/ui/src/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@usesend/ui/src/select";
import { formatDistanceToNow } from "date-fns";
} from '@usesend/ui/src/select';
import { formatDistanceToNow } from 'date-fns';
import { api } from "~/trpc/react";
import type { AppRouter } from "~/server/api/root";
import type { inferRouterOutputs } from "@trpc/server";
import { isCloud } from "~/utils/common";
import { api } from '~/trpc/react';
import type { AppRouter } from '~/server/api/root';
import type { inferRouterOutputs } from '@trpc/server';
import { isCloud } from '~/utils/common';
const searchSchema = z.object({
query: z
.string({ required_error: "Enter a team ID, name, domain, or member email" })
.string({
required_error: 'Enter a team ID, name, domain, or member email',
})
.trim()
.min(1, "Enter a team ID, name, domain, or member email"),
.min(1, 'Enter a team ID, name, domain, or member email'),
});
type SearchInput = z.infer<typeof searchSchema>;
type RouterOutputs = inferRouterOutputs<AppRouter>;
type TeamAdmin = NonNullable<RouterOutputs["admin"]["findTeam"]>;
type TeamAdmin = NonNullable<RouterOutputs['admin']['findTeam']>;
const updateSchema = z.object({
apiRateLimit: z.coerce.number().int().min(1).max(10_000),
dailyEmailLimit: z.coerce.number().int().min(0).max(10_000_000),
isBlocked: z.boolean(),
plan: z.enum(["FREE", "BASIC"]),
plan: z.enum(['FREE', 'BASIC']),
});
type UpdateInput = z.infer<typeof updateSchema>;
@@ -59,7 +61,7 @@ export default function AdminTeamsPage() {
const searchForm = useForm<SearchInput>({
resolver: zodResolver(searchSchema),
defaultValues: { query: "" },
defaultValues: { query: '' },
});
const updateForm = useForm<UpdateInput>({
@@ -68,7 +70,7 @@ export default function AdminTeamsPage() {
apiRateLimit: 1,
dailyEmailLimit: 0,
isBlocked: false,
plan: "FREE",
plan: 'FREE',
},
});
@@ -85,7 +87,7 @@ export default function AdminTeamsPage() {
if (!isCloud()) {
return (
<div className="rounded-lg border bg-muted/30 p-6 text-sm text-muted-foreground">
<div className="bg-muted/30 text-muted-foreground rounded-lg border p-6 text-sm">
Team administration tools are available only in the cloud deployment.
</div>
);
@@ -96,13 +98,13 @@ export default function AdminTeamsPage() {
setHasSearched(true);
if (!data) {
setTeam(null);
toast.info("No team found for that query");
toast.info('No team found for that query');
return;
}
setTeam(data);
},
onError: (error) => {
toast.error(error.message ?? "Unable to search for team");
toast.error(error.message ?? 'Unable to search for team');
},
});
@@ -115,10 +117,10 @@ export default function AdminTeamsPage() {
isBlocked: updated.isBlocked,
plan: updated.plan,
});
toast.success("Team settings updated");
toast.success('Team settings updated');
},
onError: (error) => {
toast.error(error.message ?? "Unable to update team settings");
toast.error(error.message ?? 'Unable to update team settings');
},
});
@@ -166,7 +168,7 @@ export default function AdminTeamsPage() {
<Spinner className="mr-2 h-4 w-4" /> Searching...
</>
) : (
"Lookup team"
'Lookup team'
)}
</Button>
</form>
@@ -174,7 +176,7 @@ export default function AdminTeamsPage() {
</div>
{findTeam.isPending ? null : hasSearched && !team ? (
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
<div className="text-muted-foreground rounded-lg border border-dashed p-6 text-sm">
No team matched that query. Try another search.
</div>
) : null}
@@ -183,75 +185,97 @@ export default function AdminTeamsPage() {
<div className="space-y-6 rounded-lg border p-6 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm text-muted-foreground">Team</p>
<p className="text-muted-foreground text-sm">Team</p>
<p className="text-xl font-semibold">{team.name}</p>
<p className="text-xs text-muted-foreground">
ID #{team.id} Created {formatDistanceToNow(new Date(team.createdAt), { addSuffix: true })}
<p className="text-muted-foreground text-xs">
ID #{team.id} Created{' '}
{formatDistanceToNow(new Date(team.createdAt), {
addSuffix: true,
})}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">Plan: {team.plan}</Badge>
<Badge variant={team.isBlocked ? "destructive" : "outline"}>
{team.isBlocked ? "Blocked" : "Active"}
<Badge variant={team.isBlocked ? 'destructive' : 'outline'}>
{team.isBlocked ? 'Blocked' : 'Active'}
</Badge>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground">Members</h3>
<div className="space-y-2 rounded-lg border bg-muted/20 p-3">
<h3 className="text-muted-foreground text-sm font-medium">
Members
</h3>
<div className="bg-muted/20 space-y-2 rounded-lg border p-3">
{team.teamUsers.length ? (
team.teamUsers.map((member) => (
<div
key={member.user.id}
className="flex items-center justify-between rounded-md bg-background px-3 py-2 text-sm"
className="bg-background flex items-center justify-between rounded-md px-3 py-2 text-sm"
>
<div>
<p className="font-medium">{member.user.name ?? member.user.email}</p>
<p className="text-xs text-muted-foreground">{member.user.email}</p>
<p className="font-medium">
{member.user.name ?? member.user.email}
</p>
<p className="text-muted-foreground text-xs">
{member.user.email}
</p>
</div>
<Badge variant="outline">{member.role}</Badge>
</div>
))
) : (
<p className="text-xs text-muted-foreground">No members found.</p>
<p className="text-muted-foreground text-xs">
No members found.
</p>
)}
</div>
</div>
<div className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground">Domains</h3>
<div className="space-y-2 rounded-lg border bg-muted/20 p-3">
<h3 className="text-muted-foreground text-sm font-medium">
Domains
</h3>
<div className="bg-muted/20 space-y-2 rounded-lg border p-3">
{team.domains.length ? (
team.domains.map((domain) => (
<div
key={domain.id}
className="flex items-center justify-between rounded-md bg-background px-3 py-2 text-sm"
className="bg-background flex items-center justify-between rounded-md px-3 py-2 text-sm"
>
<span>{domain.name}</span>
<Badge variant={domain.status === "SUCCESS" ? "outline" : "secondary"}>
{domain.status === "SUCCESS"
? "Verified"
<Badge
variant={
domain.status === 'SUCCESS' ? 'outline' : 'secondary'
}
>
{domain.status === 'SUCCESS'
? 'Verified'
: domain.status.toLowerCase()}
</Badge>
</div>
))
) : (
<p className="text-xs text-muted-foreground">No domains connected.</p>
<p className="text-muted-foreground text-xs">
No domains connected.
</p>
)}
</div>
</div>
</div>
<div className="rounded-lg border bg-muted/10 p-4">
<p className="text-sm text-muted-foreground">
Billing contact: {team.billingEmail ?? "Not set"}
<div className="bg-muted/10 rounded-lg border p-4">
<p className="text-muted-foreground text-sm">
Billing contact: {team.billingEmail ?? 'Not set'}
</p>
</div>
<div className="rounded-lg border p-6">
<Form {...updateForm}>
<form onSubmit={updateForm.handleSubmit(onUpdateSubmit)} className="grid gap-6 lg:grid-cols-2">
<form
onSubmit={updateForm.handleSubmit(onUpdateSubmit)}
className="grid gap-6 lg:grid-cols-2"
>
<FormField
control={updateForm.control}
name="apiRateLimit"
@@ -336,8 +360,8 @@ export default function AdminTeamsPage() {
onCheckedChange={field.onChange}
disabled={updateTeam.isPending}
/>
<span className="text-sm text-muted-foreground">
{field.value ? "Team is blocked" : "Team is active"}
<span className="text-muted-foreground text-sm">
{field.value ? 'Team is blocked' : 'Team is active'}
</span>
</div>
</FormControl>
@@ -345,14 +369,14 @@ export default function AdminTeamsPage() {
</FormItem>
)}
/>
<div className="lg:col-span-2 flex justify-end">
<div className="flex justify-end lg:col-span-2">
<Button type="submit" disabled={updateTeam.isPending}>
{updateTeam.isPending ? (
<>
<Spinner className="mr-2 h-4 w-4" /> Saving...
</>
) : (
"Update team"
'Update team'
)}
</Button>
</div>

View File

@@ -1,10 +1,10 @@
"use client";
'use client';
import { useState } from "react";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { Button } from "@usesend/ui/src/button";
import { useState } from 'react';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Button } from '@usesend/ui/src/button';
import {
Form,
FormControl,
@@ -12,30 +12,30 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { Input } from "@usesend/ui/src/input";
import Spinner from "@usesend/ui/src/spinner";
import { toast } from "@usesend/ui/src/toaster";
import { Switch } from "@usesend/ui/src/switch";
import { Badge } from "@usesend/ui/src/badge";
import { formatDistanceToNow } from "date-fns";
} from '@usesend/ui/src/form';
import { Input } from '@usesend/ui/src/input';
import Spinner from '@usesend/ui/src/spinner';
import { toast } from '@usesend/ui/src/toaster';
import { Switch } from '@usesend/ui/src/switch';
import { Badge } from '@usesend/ui/src/badge';
import { formatDistanceToNow } from 'date-fns';
import { api } from "~/trpc/react";
import { isCloud } from "~/utils/common";
import type { AppRouter } from "~/server/api/root";
import type { inferRouterOutputs } from "@trpc/server";
import { api } from '~/trpc/react';
import { isCloud } from '~/utils/common';
import type { AppRouter } from '~/server/api/root';
import type { inferRouterOutputs } from '@trpc/server';
const searchSchema = z.object({
email: z
.string({ required_error: "Email is required" })
.string({ required_error: 'Email is required' })
.trim()
.email("Enter a valid email address"),
.email('Enter a valid email address'),
});
type SearchInput = z.infer<typeof searchSchema>;
type RouterOutputs = inferRouterOutputs<AppRouter>;
type WaitlistUser = NonNullable<RouterOutputs["admin"]["findUserByEmail"]>;
type WaitlistUser = NonNullable<RouterOutputs['admin']['findUserByEmail']>;
export default function AdminWaitlistPage() {
const [userResult, setUserResult] = useState<WaitlistUser | null>(null);
@@ -44,7 +44,7 @@ export default function AdminWaitlistPage() {
const form = useForm<SearchInput>({
resolver: zodResolver(searchSchema),
defaultValues: {
email: "",
email: '',
},
});
@@ -53,14 +53,14 @@ export default function AdminWaitlistPage() {
setHasSearched(true);
if (!data) {
setUserResult(null);
toast.info("No user found for that email");
toast.info('No user found for that email');
return;
}
setUserResult(data);
},
onError: (error) => {
toast.error(error.message ?? "Unable to search for user");
toast.error(error.message ?? 'Unable to search for user');
},
});
@@ -69,12 +69,12 @@ export default function AdminWaitlistPage() {
setUserResult(updated);
toast.success(
updated.isWaitlisted
? "User marked as waitlisted"
: "User removed from waitlist",
? 'User marked as waitlisted'
: 'User removed from waitlist',
);
},
onError: (error) => {
toast.error(error.message ?? "Unable to update waitlist flag");
toast.error(error.message ?? 'Unable to update waitlist flag');
},
});
@@ -91,7 +91,7 @@ export default function AdminWaitlistPage() {
if (!isCloud()) {
return (
<div className="rounded-lg border bg-muted/30 p-6 text-sm text-muted-foreground">
<div className="bg-muted/30 text-muted-foreground rounded-lg border p-6 text-sm">
Waitlist tooling is available only in the cloud deployment.
</div>
);
@@ -101,7 +101,11 @@ export default function AdminWaitlistPage() {
<div className="space-y-6">
<div className="rounded-lg border p-6 shadow-sm">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" noValidate>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
noValidate
>
<FormField
control={form.control}
name="email"
@@ -126,7 +130,7 @@ export default function AdminWaitlistPage() {
<Spinner className="mr-2 h-4 w-4" /> Searching...
</>
) : (
"Lookup user"
'Lookup user'
)}
</Button>
</form>
@@ -134,7 +138,7 @@ export default function AdminWaitlistPage() {
</div>
{findUser.isPending ? null : hasSearched && !userResult ? (
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
<div className="text-muted-foreground rounded-lg border border-dashed p-6 text-sm">
No user matched that email. Try another search.
</div>
) : null}
@@ -143,18 +147,20 @@ export default function AdminWaitlistPage() {
<div className="space-y-4 rounded-lg border p-6 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm text-muted-foreground">Email</p>
<p className="text-muted-foreground text-sm">Email</p>
<p className="text-base font-medium">{userResult.email}</p>
</div>
<Badge variant={userResult.isWaitlisted ? "destructive" : "outline"}>
{userResult.isWaitlisted ? "Waitlisted" : "Active"}
<Badge
variant={userResult.isWaitlisted ? 'destructive' : 'outline'}
>
{userResult.isWaitlisted ? 'Waitlisted' : 'Active'}
</Badge>
</div>
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<p className="text-muted-foreground">Name</p>
<p>{userResult.name ?? "—"}</p>
<p>{userResult.name ?? '—'}</p>
</div>
<div>
<p className="text-muted-foreground">Joined</p>
@@ -169,7 +175,7 @@ export default function AdminWaitlistPage() {
<div className="flex flex-wrap items-center justify-between gap-4 border-t pt-4">
<div>
<p className="text-sm font-medium">Waitlist access</p>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
Toggle to control whether the user remains on the waitlist.
</p>
</div>

View File

@@ -1,18 +1,18 @@
"use client";
'use client';
import { api } from "~/trpc/react";
import { Spinner } from "@usesend/ui/src/spinner";
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Editor } from "@usesend/email-editor";
import { use, useState } from "react";
import { Campaign } from "@prisma/client";
import { api } from '~/trpc/react';
import { Spinner } from '@usesend/ui/src/spinner';
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import { Editor } from '@usesend/email-editor';
import { use, useState } from 'react';
import { Campaign } from '@prisma/client';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@usesend/ui/src/select";
} from '@usesend/ui/src/select';
import {
Dialog,
DialogContent,
@@ -20,10 +20,10 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
} from '@usesend/ui/src/dialog';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Form,
FormControl,
@@ -31,16 +31,16 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { toast } from "@usesend/ui/src/toaster";
import { useDebouncedCallback } from "use-debounce";
import { formatDistanceToNow } from "date-fns";
} from '@usesend/ui/src/form';
import { toast } from '@usesend/ui/src/toaster';
import { useDebouncedCallback } from 'use-debounce';
import { formatDistanceToNow } from 'date-fns';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@usesend/ui/src/accordion";
} from '@usesend/ui/src/accordion';
const sendSchema = z.object({
confirmation: z.string(),
@@ -68,15 +68,15 @@ export default function EditCampaignPage({
if (isLoading) {
return (
<div className="flex justify-center items-center h-full">
<Spinner className="w-6 h-6" />
<div className="flex h-full items-center justify-center">
<Spinner className="h-6 w-6" />
</div>
);
}
if (error) {
return (
<div className="flex justify-center items-center h-full">
<div className="flex h-full items-center justify-center">
<p className="text-red-500">Failed to load campaign</p>
</div>
);
@@ -140,9 +140,9 @@ function CampaignEditor({
async function onSendCampaign(values: z.infer<typeof sendSchema>) {
if (
values.confirmation?.toLocaleLowerCase() !== "Send".toLocaleLowerCase()
values.confirmation?.toLocaleLowerCase() !== 'Send'.toLocaleLowerCase()
) {
sendForm.setError("confirmation", {
sendForm.setError('confirmation', {
message: "Please type 'Send' to confirm",
});
return;
@@ -171,7 +171,7 @@ function CampaignEditor({
);
}
console.log("file type: ", file.type);
console.log('file type: ', file.type);
const { uploadUrl, imageUrl } = await getUploadUrl.mutateAsync({
name: file.name,
@@ -180,32 +180,32 @@ function CampaignEditor({
});
const response = await fetch(uploadUrl, {
method: "PUT",
method: 'PUT',
body: file,
});
if (!response.ok) {
throw new Error("Failed to upload file");
throw new Error('Failed to upload file');
}
return imageUrl;
};
const confirmation = sendForm.watch("confirmation");
const confirmation = sendForm.watch('confirmation');
const contactBook = contactBooksQuery.data?.find(
(book) => book.id === contactBookId,
);
return (
<div className="p-4 container mx-auto ">
<div className="container mx-auto p-4">
<div className="mx-auto">
<div className="mb-4 flex justify-between items-center w-[700px] mx-auto">
<div className="mx-auto mb-4 flex w-[700px] items-center justify-between">
<Input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className=" border-0 focus:ring-0 focus:outline-none px-0.5 w-[300px]"
className="w-[300px] border-0 px-0.5 focus:outline-none focus:ring-0"
onBlur={() => {
if (name === campaign.name || !name) {
return;
@@ -227,12 +227,12 @@ function CampaignEditor({
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
{isSaving ? (
<div className="h-2 w-2 bg-yellow-500 rounded-full" />
<div className="h-2 w-2 rounded-full bg-yellow-500" />
) : (
<div className="h-2 w-2 bg-emerald-500 rounded-full" />
<div className="h-2 w-2 rounded-full bg-emerald-500" />
)}
{formatDistanceToNow(campaign.updatedAt) === "less than a minute"
? "just now"
{formatDistanceToNow(campaign.updatedAt) === 'less than a minute'
? 'just now'
: `${formatDistanceToNow(campaign.updatedAt)} ago`}
</div>
<Dialog open={openSendDialog} onOpenChange={setOpenSendDialog}>
@@ -272,12 +272,12 @@ function CampaignEditor({
disabled={
sendCampaignMutation.isPending ||
confirmation?.toLocaleLowerCase() !==
"Send".toLocaleLowerCase()
'Send'.toLocaleLowerCase()
}
>
{sendCampaignMutation.isPending
? "Sending..."
: "Send"}
? 'Sending...'
: 'Send'}
</Button>
</div>
</form>
@@ -290,9 +290,9 @@ function CampaignEditor({
<Accordion type="single" collapsible>
<AccordionItem value="item-1">
<div className="flex flex-col border shadow rounded-lg mt-12 mb-12 p-4 w-[700px] mx-auto z-50">
<div className="z-50 mx-auto mb-12 mt-12 flex w-[700px] flex-col rounded-lg border p-4 shadow">
<div className="flex items-center gap-4">
<label className="block text-sm w-[80px] text-muted-foreground">
<label className="text-muted-foreground block w-[80px] text-sm">
Subject
</label>
<input
@@ -318,14 +318,14 @@ function CampaignEditor({
},
);
}}
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent focus:border-border bg-transparent"
className="focus:border-border mt-1 block w-full border-b border-transparent bg-transparent py-1 text-sm outline-none"
/>
<AccordionTrigger className="py-0"></AccordionTrigger>
</div>
<AccordionContent className=" flex flex-col gap-4">
<div className=" flex items-center gap-4 mt-4">
<label className=" text-sm w-[80px] text-muted-foreground">
<AccordionContent className="flex flex-col gap-4">
<div className="mt-4 flex items-center gap-4">
<label className="text-muted-foreground w-[80px] text-sm">
From
</label>
<input
@@ -334,7 +334,7 @@ function CampaignEditor({
onChange={(e) => {
setFrom(e.target.value);
}}
className="mt-1 py-1 w-full text-sm outline-none border-b border-transparent focus:border-border bg-transparent"
className="focus:border-border mt-1 w-full border-b border-transparent bg-transparent py-1 text-sm outline-none"
placeholder="Friendly name<hello@example.com>"
onBlur={() => {
if (from === campaign.from || !from) {
@@ -356,7 +356,7 @@ function CampaignEditor({
/>
</div>
<div className="flex items-center gap-4">
<label className="block text-sm w-[80px] text-muted-foreground">
<label className="text-muted-foreground block w-[80px] text-sm">
Reply To
</label>
<input
@@ -365,7 +365,7 @@ function CampaignEditor({
onChange={(e) => {
setReplyTo(e.target.value);
}}
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent bg-transparent focus:border-border"
className="focus:border-border mt-1 block w-full border-b border-transparent bg-transparent py-1 text-sm outline-none"
placeholder="hello@example.com"
onBlur={() => {
if (replyTo === campaign.replyTo[0]) {
@@ -388,7 +388,7 @@ function CampaignEditor({
</div>
<div className="flex items-center gap-4">
<label className="block text-sm w-[80px] text-muted-foreground">
<label className="text-muted-foreground block w-[80px] text-sm">
Preview
</label>
<input
@@ -412,23 +412,23 @@ function CampaignEditor({
{
onError: (e) => {
toast.error(`${e.message}. Reverting changes.`);
setPreviewText(campaign.previewText ?? "");
setPreviewText(campaign.previewText ?? '');
},
},
);
}}
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent bg-transparent focus:border-border"
className="focus:border-border mt-1 block w-full border-b border-transparent bg-transparent py-1 text-sm outline-none"
/>
</div>
<div className=" flex items-center gap-2">
<label className="block text-sm w-[80px] text-muted-foreground">
<div className="flex items-center gap-2">
<label className="text-muted-foreground block w-[80px] text-sm">
To
</label>
{contactBooksQuery.isLoading ? (
<Spinner className="w-6 h-6" />
<Spinner className="h-6 w-6" />
) : (
<Select
value={contactBookId ?? ""}
value={contactBookId ?? ''}
onValueChange={(val) => {
// Update the campaign's contactBookId
updateCampaignMutation.mutate(
@@ -448,14 +448,14 @@ function CampaignEditor({
<SelectTrigger className="w-[300px]">
{contactBook
? `${contactBook.emoji} ${contactBook.name}`
: "Select a contact book"}
: 'Select a contact book'}
</SelectTrigger>
<SelectContent>
{contactBooksQuery.data?.map((book) => (
<SelectItem key={book.id} value={book.id}>
{book.emoji} {book.name}{" "}
<span className="text-xs text-muted-foreground ml-4">
{" "}
{book.emoji} {book.name}{' '}
<span className="text-muted-foreground ml-4 text-xs">
{' '}
{book._count.contacts} contacts
</span>
</SelectItem>
@@ -469,8 +469,8 @@ function CampaignEditor({
</AccordionItem>
</Accordion>
<div className=" rounded-lg bg-gray-50 w-[700px] mx-auto p-10">
<div className="w-[600px] mx-auto">
<div className="mx-auto w-[700px] rounded-lg bg-gray-50 p-10">
<div className="mx-auto w-[600px]">
<Editor
initialContent={json}
onUpdate={(content) => {
@@ -478,7 +478,7 @@ function CampaignEditor({
setIsSaving(true);
deboucedUpdateCampaign();
}}
variables={["email", "firstName", "lastName"]}
variables={['email', 'firstName', 'lastName']}
uploadImage={
campaign.imageUploadSupported ? handleFileChange : undefined
}

View File

@@ -1,4 +1,4 @@
"use client";
'use client';
import {
Breadcrumb,
@@ -7,13 +7,13 @@ import {
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@usesend/ui/src/breadcrumb";
import Link from "next/link";
import { H2 } from "@usesend/ui";
} from '@usesend/ui/src/breadcrumb';
import Link from 'next/link';
import { H2 } from '@usesend/ui';
import Spinner from "@usesend/ui/src/spinner";
import { api } from "~/trpc/react";
import { use } from "react";
import Spinner from '@usesend/ui/src/spinner';
import { api } from '~/trpc/react';
import { use } from 'react';
export default function CampaignDetailsPage({
params,
@@ -28,8 +28,8 @@ export default function CampaignDetailsPage({
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen">
<Spinner className="w-5 h-5 text-foreground" />
<div className="flex h-screen items-center justify-center">
<Spinner className="text-foreground h-5 w-5" />
</div>
);
}
@@ -40,22 +40,22 @@ export default function CampaignDetailsPage({
const statusCards = [
{
status: "delivered",
status: 'delivered',
count: campaign.delivered,
percentage: 100,
},
{
status: "unsubscribed",
status: 'unsubscribed',
count: campaign.unsubscribed,
percentage: (campaign.unsubscribed / campaign.delivered) * 100,
},
{
status: "clicked",
status: 'clicked',
count: campaign.clicked,
percentage: (campaign.clicked / campaign.delivered) * 100,
},
{
status: "opened",
status: 'opened',
count: campaign.opened,
percentage: (campaign.opened / campaign.delivered) * 100,
},
@@ -74,32 +74,30 @@ export default function CampaignDetailsPage({
</BreadcrumbItem>
<BreadcrumbSeparator className="text-lg" />
<BreadcrumbItem>
<BreadcrumbPage className="text-lg ">
{campaign.name}
</BreadcrumbPage>
<BreadcrumbPage className="text-lg">{campaign.name}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="mt-10">
<H2 className="mb-4"> Statistics</H2>
<div className="flex gap-4">
<div className="flex gap-4">
{statusCards.map((card) => (
<div
key={card.status}
className="h-[100px] w-1/4 bg-secondary/10 border rounded-lg shadow p-4 flex flex-col gap-3"
className="bg-secondary/10 flex h-[100px] w-1/4 flex-col gap-3 rounded-lg border p-4 shadow"
>
<div className="flex items-center gap-3">
{card.status !== "total" ? (
{card.status !== 'total' ? (
<CampaignStatusBadge status={card.status} />
) : null}
<div className="capitalize">{card.status.toLowerCase()}</div>
</div>
<div className="flex justify-between items-end">
<div className="text-foreground font-light text-2xl font-mono">
<div className="flex items-end justify-between">
<div className="text-foreground font-mono text-2xl font-light">
{card.count}
</div>
{card.status !== "total" ? (
<div className="text-sm pb-1">
{card.status !== 'total' ? (
<div className="pb-1 text-sm">
{card.percentage.toFixed(1)}%
</div>
) : null}
@@ -110,34 +108,34 @@ export default function CampaignDetailsPage({
</div>
{campaign.html && (
<div className=" rounded-lg mt-16">
<div className="mt-16 rounded-lg">
<H2 className="mb-4">Email</H2>
<div className="p-2 rounded-lg border shadow flex flex-col gap-4 w-full">
<div className="flex w-full flex-col gap-4 rounded-lg border p-2 shadow">
<div className="flex flex-col gap-3 px-4 py-1">
<div className=" flex text-sm">
<div className="w-[70px] text-muted-foreground">Subject</div>
<div className="flex text-sm">
<div className="text-muted-foreground w-[70px]">Subject</div>
<div> {campaign.subject}</div>
</div>
<div className="flex text-sm">
<div className="w-[70px] text-muted-foreground">From</div>
<div className="flex text-sm">
<div className="text-muted-foreground w-[70px]">From</div>
<div> {campaign.from}</div>
</div>
<div className="flex text-sm items-center">
<div className="w-[70px] text-muted-foreground">Contact</div>
<div className="flex items-center text-sm">
<div className="text-muted-foreground w-[70px]">Contact</div>
<Link
href={`/contacts/${campaign.contactBookId}`}
target="_blank"
>
<div className="bg-secondary p-0.5 px-2 rounded-md ">
<div className="bg-secondary rounded-md p-0.5 px-2">
{campaign.contactBook?.emoji} &nbsp;
{campaign.contactBook?.name}
</div>
</Link>
</div>
</div>
<div className=" dark:bg-slate-50 overflow-auto text-black rounded py-8 border-t">
<div dangerouslySetInnerHTML={{ __html: campaign.html ?? "" }} />
<div className="overflow-auto rounded border-t py-8 text-black dark:bg-slate-50">
<div dangerouslySetInnerHTML={{ __html: campaign.html ?? '' }} />
</div>
</div>
</div>
@@ -147,40 +145,40 @@ export default function CampaignDetailsPage({
}
const CampaignStatusBadge: React.FC<{ status: string }> = ({ status }) => {
let outsideColor = "bg-gray";
let insideColor = "bg-gray/50";
let outsideColor = 'bg-gray';
let insideColor = 'bg-gray/50';
switch (status) {
case "delivered":
outsideColor = "bg-green/30";
insideColor = "bg-green";
case 'delivered':
outsideColor = 'bg-green/30';
insideColor = 'bg-green';
break;
case "bounced":
case "unsubscribed":
outsideColor = "bg-red/30";
insideColor = "bg-red";
case 'bounced':
case 'unsubscribed':
outsideColor = 'bg-red/30';
insideColor = 'bg-red';
break;
case "clicked":
outsideColor = "bg-blue/30";
insideColor = "bg-blue";
case 'clicked':
outsideColor = 'bg-blue/30';
insideColor = 'bg-blue';
break;
case "opened":
outsideColor = "bg-purple/30";
insideColor = "bg-purple";
case 'opened':
outsideColor = 'bg-purple/30';
insideColor = 'bg-purple';
break;
case "complained":
outsideColor = "bg-yellow/30";
insideColor = "bg-yellow";
case 'complained':
outsideColor = 'bg-yellow/30';
insideColor = 'bg-yellow';
break;
default:
outsideColor = "bg-gray/40";
insideColor = "bg-gray";
outsideColor = 'bg-gray/40';
insideColor = 'bg-gray';
}
return (
<div
className={`flex justify-center items-center p-1.5 ${outsideColor} rounded-full`}
className={`flex items-center justify-center p-1.5 ${outsideColor} rounded-full`}
>
<div className={`h-2 w-2 rounded-full ${insideColor}`}></div>
</div>

View File

@@ -1,4 +1,4 @@
"use client";
'use client';
import {
Table,
@@ -7,26 +7,26 @@ import {
TableHead,
TableBody,
TableCell,
} from "@usesend/ui/src/table";
import { api } from "~/trpc/react";
import { useUrlState } from "~/hooks/useUrlState";
import { Button } from "@usesend/ui/src/button";
import Spinner from "@usesend/ui/src/spinner";
import { formatDistanceToNow } from "date-fns";
import { CampaignStatus } from "@prisma/client";
import DeleteCampaign from "./delete-campaign";
import Link from "next/link";
import DuplicateCampaign from "./duplicate-campaign";
} from '@usesend/ui/src/table';
import { api } from '~/trpc/react';
import { useUrlState } from '~/hooks/useUrlState';
import { Button } from '@usesend/ui/src/button';
import Spinner from '@usesend/ui/src/spinner';
import { formatDistanceToNow } from 'date-fns';
import { CampaignStatus } from '@prisma/client';
import DeleteCampaign from './delete-campaign';
import Link from 'next/link';
import DuplicateCampaign from './duplicate-campaign';
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
} from "@usesend/ui/src/select";
} from '@usesend/ui/src/select';
export default function CampaignList() {
const [page, setPage] = useUrlState("page", "1");
const [status, setStatus] = useUrlState("status");
const [page, setPage] = useUrlState('page', '1');
const [status, setStatus] = useUrlState('status');
const pageNumber = Number(page);
@@ -39,35 +39,32 @@ export default function CampaignList() {
<div className="mt-10 flex flex-col gap-4">
<div className="flex justify-end">
<Select
value={status ?? "all"}
onValueChange={(val) => setStatus(val === "all" ? null : val)}
value={status ?? 'all'}
onValueChange={(val) => setStatus(val === 'all' ? null : val)}
>
<SelectTrigger className="w-[180px] capitalize">
{status ? status.toLowerCase() : "All statuses"}
{status ? status.toLowerCase() : 'All statuses'}
</SelectTrigger>
<SelectContent>
<SelectItem value="all" className=" capitalize">
<SelectItem value="all" className="capitalize">
All statuses
</SelectItem>
<SelectItem value={CampaignStatus.DRAFT} className=" capitalize">
<SelectItem value={CampaignStatus.DRAFT} className="capitalize">
Draft
</SelectItem>
<SelectItem
value={CampaignStatus.SCHEDULED}
className=" capitalize"
>
<SelectItem value={CampaignStatus.SCHEDULED} className="capitalize">
Scheduled
</SelectItem>
<SelectItem value={CampaignStatus.SENT} className=" capitalize">
<SelectItem value={CampaignStatus.SENT} className="capitalize">
Sent
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col rounded-xl border border-border shadow">
<div className="border-border flex flex-col rounded-xl border shadow">
<Table className="">
<TableHeader className="">
<TableRow className=" bg-muted/30">
<TableRow className="bg-muted/30">
<TableHead className="rounded-tl-xl">Name</TableHead>
<TableHead>Status</TableHead>
<TableHead className="">Created At</TableHead>
@@ -77,9 +74,9 @@ export default function CampaignList() {
<TableBody>
{campaignsQuery.isLoading ? (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
<TableCell colSpan={4} className="py-4 text-center">
<Spinner
className="w-6 h-6 mx-auto"
className="mx-auto h-6 w-6"
innerSvgClass="stroke-primary"
/>
</TableCell>
@@ -89,7 +86,7 @@ export default function CampaignList() {
<TableRow key={campaign.id} className="">
<TableCell className="font-medium">
<Link
className="underline underline-offset-4 decoration-dashed text-foreground hover:text-foreground"
className="text-foreground hover:text-foreground underline decoration-dashed underline-offset-4"
href={
campaign.status === CampaignStatus.DRAFT
? `/campaigns/${campaign.id}/edit`
@@ -101,12 +98,12 @@ export default function CampaignList() {
</TableCell>
<TableCell>
<div
className={`text-center w-[130px] rounded capitalize py-1 text-xs ${
className={`w-[130px] rounded py-1 text-center text-xs capitalize ${
campaign.status === CampaignStatus.DRAFT
? "bg-gray/15 text-gray border border-gray/25"
? 'bg-gray/15 text-gray border-gray/25 border'
: campaign.status === CampaignStatus.SENT
? "bg-green/15 text-green border border-green/25"
: "bg-yellow/15 text-yellow border border-yellow/25"
? 'bg-green/15 text-green border-green/25 border'
: 'bg-yellow/15 text-yellow border-yellow/25 border'
}`}
>
{campaign.status.toLowerCase()}
@@ -127,7 +124,7 @@ export default function CampaignList() {
))
) : (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
<TableCell colSpan={4} className="py-4 text-center">
No campaigns found
</TableCell>
</TableRow>
@@ -135,7 +132,7 @@ export default function CampaignList() {
</TableBody>
</Table>
</div>
<div className="flex gap-4 justify-end">
<div className="flex justify-end gap-4">
<Button
size="sm"
onClick={() => setPage((pageNumber - 1).toString())}

View File

@@ -1,14 +1,14 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
} from '@usesend/ui/src/dialog';
import {
Form,
FormControl,
@@ -16,27 +16,27 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
} from '@usesend/ui/src/form';
import { api } from "~/trpc/react";
import { useState } from "react";
import { Plus } from "lucide-react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@usesend/ui/src/toaster";
import { useRouter } from "next/navigation";
import Spinner from "@usesend/ui/src/spinner";
import { api } from '~/trpc/react';
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from '@usesend/ui/src/toaster';
import { useRouter } from 'next/navigation';
import Spinner from '@usesend/ui/src/spinner';
const campaignSchema = z.object({
name: z.string({ required_error: "Name is required" }).min(1, {
message: "Name is required",
name: z.string({ required_error: 'Name is required' }).min(1, {
message: 'Name is required',
}),
from: z.string({ required_error: "From email is required" }).min(1, {
message: "From email is required",
from: z.string({ required_error: 'From email is required' }).min(1, {
message: 'From email is required',
}),
subject: z.string({ required_error: "Subject is required" }).min(1, {
message: "Subject is required",
subject: z.string({ required_error: 'Subject is required' }).min(1, {
message: 'Subject is required',
}),
});
@@ -49,9 +49,9 @@ export default function CreateCampaign() {
const campaignForm = useForm<z.infer<typeof campaignSchema>>({
resolver: zodResolver(campaignSchema),
defaultValues: {
name: "",
from: "",
subject: "",
name: '',
from: '',
subject: '',
},
});
@@ -68,13 +68,13 @@ export default function CreateCampaign() {
onSuccess: async (data) => {
utils.campaign.getCampaigns.invalidate();
router.push(`/campaigns/${data.id}/edit`);
toast.success("Campaign created successfully");
toast.success('Campaign created successfully');
setOpen(false);
},
onError: async (error) => {
toast.error(error.message);
},
}
},
);
}
@@ -85,7 +85,7 @@ export default function CreateCampaign() {
>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-1" />
<Plus className="mr-1 h-4 w-4" />
Create Campaign
</Button>
</DialogTrigger>
@@ -146,14 +146,14 @@ export default function CreateCampaign() {
</p>
<div className="flex justify-end">
<Button
className=" w-[100px]"
className="w-[100px]"
type="submit"
disabled={createCampaignMutation.isPending}
>
{createCampaignMutation.isPending ? (
<Spinner className="w-4 h-4" />
<Spinner className="h-4 w-4" />
) : (
"Create"
'Create'
)}
</Button>
</div>

View File

@@ -1,7 +1,7 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
@@ -9,14 +9,14 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import { api } from "~/trpc/react";
import React, { useState } from "react";
import { toast } from "@usesend/ui/src/toaster";
import { Trash2 } from "lucide-react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
} from '@usesend/ui/src/dialog';
import { api } from '~/trpc/react';
import React, { useState } from 'react';
import { toast } from '@usesend/ui/src/toaster';
import { Trash2 } from 'lucide-react';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Form,
FormControl,
@@ -25,8 +25,8 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { Campaign } from "@prisma/client";
} from '@usesend/ui/src/form';
import { Campaign } from '@prisma/client';
const campaignSchema = z.object({
name: z.string(),
@@ -46,8 +46,8 @@ export const DeleteCampaign: React.FC<{
async function onCampaignDelete(values: z.infer<typeof campaignSchema>) {
if (values.name !== campaign.name) {
campaignForm.setError("name", {
message: "Name does not match",
campaignForm.setError('name', {
message: 'Name does not match',
});
return;
}
@@ -66,7 +66,7 @@ export const DeleteCampaign: React.FC<{
);
}
const name = campaignForm.watch("name");
const name = campaignForm.watch('name');
return (
<Dialog
@@ -75,15 +75,15 @@ export const DeleteCampaign: React.FC<{
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent">
<Trash2 className="h-[18px] w-[18px] text-red/80" />
<Trash2 className="text-red/80 h-[18px] w-[18px]" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Campaign</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">
Are you sure you want to delete{' '}
<span className="text-foreground font-semibold">
{campaign.name}
</span>
? You can't reverse this.
@@ -107,7 +107,7 @@ export const DeleteCampaign: React.FC<{
{formState.errors.name ? (
<FormMessage />
) : (
<FormDescription className=" text-transparent">
<FormDescription className="text-transparent">
.
</FormDescription>
)}
@@ -122,7 +122,7 @@ export const DeleteCampaign: React.FC<{
deleteCampaignMutation.isPending || campaign.name !== name
}
>
{deleteCampaignMutation.isPending ? "Deleting..." : "Delete"}
{deleteCampaignMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</div>
</form>

View File

@@ -1,6 +1,6 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Button } from '@usesend/ui/src/button';
import {
Dialog,
DialogContent,
@@ -8,12 +8,12 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import { api } from "~/trpc/react";
import React, { useState } from "react";
import { toast } from "@usesend/ui/src/toaster";
import { Copy } from "lucide-react";
import { Campaign } from "@prisma/client";
} from '@usesend/ui/src/dialog';
import { api } from '~/trpc/react';
import React, { useState } from 'react';
import { toast } from '@usesend/ui/src/toaster';
import { Copy } from 'lucide-react';
import { Campaign } from '@prisma/client';
export const DuplicateCampaign: React.FC<{
campaign: Partial<Campaign> & { id: string };
@@ -46,15 +46,15 @@ export const DuplicateCampaign: React.FC<{
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent">
<Copy className="h-[18px] w-[18px] text-blue/80" />
<Copy className="text-blue/80 h-[18px] w-[18px]" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Duplicate Campaign</DialogTitle>
<DialogDescription>
Are you sure you want to duplicate{" "}
<span className="font-semibold text-foreground">
Are you sure you want to duplicate{' '}
<span className="text-foreground font-semibold">
{campaign.name}
</span>
?
@@ -68,8 +68,8 @@ export const DuplicateCampaign: React.FC<{
disabled={duplicateCampaignMutation.isPending}
>
{duplicateCampaignMutation.isPending
? "Duplicating..."
: "Duplicate"}
? 'Duplicating...'
: 'Duplicate'}
</Button>
</div>
</div>

View File

@@ -1,13 +1,13 @@
"use client";
'use client';
import CampaignList from "./campaign-list";
import CreateCampaign from "./create-campaign";
import { H1 } from "@usesend/ui";
import CampaignList from './campaign-list';
import CreateCampaign from './create-campaign';
import { H1 } from '@usesend/ui';
export default function ContactsPage() {
return (
<div>
<div className="flex justify-between items-center">
<div className="flex items-center justify-between">
<H1>Campaigns</H1>
<CreateCampaign />
</div>

View File

@@ -1,14 +1,14 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Textarea } from "@usesend/ui/src/textarea";
import { Button } from '@usesend/ui/src/button';
import { Textarea } from '@usesend/ui/src/textarea';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
} from '@usesend/ui/src/dialog';
import {
Form,
FormControl,
@@ -17,20 +17,20 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
} from '@usesend/ui/src/form';
import { api } from "~/trpc/react";
import { useState } from "react";
import { Plus } from "lucide-react";
import { useRouter } from "next/navigation";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@usesend/ui/src/toaster";
import { api } from '~/trpc/react';
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from '@usesend/ui/src/toaster';
const contactsSchema = z.object({
contacts: z.string({ required_error: "Contacts are required" }).min(1, {
message: "Contacts are required",
contacts: z.string({ required_error: 'Contacts are required' }).min(1, {
message: 'Contacts are required',
}),
});
@@ -46,14 +46,14 @@ export default function AddContact({
const contactsForm = useForm<z.infer<typeof contactsSchema>>({
resolver: zodResolver(contactsSchema),
defaultValues: {
contacts: "",
contacts: '',
},
});
const utils = api.useUtils();
async function onContactsAdd(values: z.infer<typeof contactsSchema>) {
const contactsArray = values.contacts.split(",").map((email) => ({
const contactsArray = values.contacts.split(',').map((email) => ({
email: email.trim(),
}));
@@ -66,12 +66,12 @@ export default function AddContact({
onSuccess: async () => {
utils.contacts.contacts.invalidate();
setOpen(false);
toast.success("Contacts added successfully");
toast.success('Contacts added successfully');
},
onError: async (error) => {
toast.error(error.message);
},
}
},
);
}
@@ -82,7 +82,7 @@ export default function AddContact({
>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-1" />
<Plus className="mr-1 h-4 w-4" />
Add Contacts
</Button>
</DialogTrigger>
@@ -120,11 +120,11 @@ export default function AddContact({
/>
<div className="flex justify-end">
<Button
className=" w-[100px]"
className="w-[100px]"
type="submit"
disabled={addContactsMutation.isPending}
>
{addContactsMutation.isPending ? "Adding..." : "Add"}
{addContactsMutation.isPending ? 'Adding...' : 'Add'}
</Button>
</div>
</form>

View File

@@ -1,13 +1,13 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Button } from '@usesend/ui/src/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@usesend/ui/src/select";
import Spinner from "@usesend/ui/src/spinner";
} from '@usesend/ui/src/select';
import Spinner from '@usesend/ui/src/spinner';
import {
Table,
TableBody,
@@ -15,34 +15,34 @@ import {
TableHead,
TableHeader,
TableRow,
} from "@usesend/ui/src/table";
import { formatDistanceToNow } from "date-fns";
import Image from "next/image";
import { useUrlState } from "~/hooks/useUrlState";
import { api } from "~/trpc/react";
import { getGravatarUrl } from "~/utils/gravatar-utils";
import DeleteContact from "./delete-contact";
import EditContact from "./edit-contact";
import { Input } from "@usesend/ui/src/input";
import { useDebouncedCallback } from "use-debounce";
} from '@usesend/ui/src/table';
import { formatDistanceToNow } from 'date-fns';
import Image from 'next/image';
import { useUrlState } from '~/hooks/useUrlState';
import { api } from '~/trpc/react';
import { getGravatarUrl } from '~/utils/gravatar-utils';
import DeleteContact from './delete-contact';
import EditContact from './edit-contact';
import { Input } from '@usesend/ui/src/input';
import { useDebouncedCallback } from 'use-debounce';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@usesend/ui/src/tooltip";
import { UnsubscribeReason } from "@prisma/client";
} from '@usesend/ui/src/tooltip';
import { UnsubscribeReason } from '@prisma/client';
function getUnsubscribeReason(reason: UnsubscribeReason) {
switch (reason) {
case UnsubscribeReason.BOUNCED:
return "Email bounced";
return 'Email bounced';
case UnsubscribeReason.COMPLAINED:
return "User complained";
return 'User complained';
case UnsubscribeReason.UNSUBSCRIBED:
return "User unsubscribed";
return 'User unsubscribed';
default:
return "User unsubscribed";
return 'User unsubscribed';
}
}
@@ -51,9 +51,9 @@ export default function ContactList({
}: {
contactBookId: string;
}) {
const [page, setPage] = useUrlState("page", "1");
const [status, setStatus] = useUrlState("status");
const [search, setSearch] = useUrlState("search");
const [page, setPage] = useUrlState('page', '1');
const [status, setStatus] = useUrlState('status');
const [search, setSearch] = useUrlState('search');
const pageNumber = Number(page);
@@ -62,9 +62,9 @@ export default function ContactList({
page: pageNumber,
search: search ?? undefined,
subscribed:
status === "Subscribed"
status === 'Subscribed'
? true
: status === "Unsubscribed"
: status === 'Unsubscribed'
? false
: undefined,
});
@@ -80,35 +80,35 @@ export default function ContactList({
<div>
<Input
placeholder="Search by email or name"
className="w-[350px] mr-4"
defaultValue={search ?? ""}
className="mr-4 w-[350px]"
defaultValue={search ?? ''}
onChange={(e) => debouncedSearch(e.target.value)}
/>
</div>
<Select
value={status ?? "All"}
onValueChange={(val) => setStatus(val === "All" ? null : val)}
value={status ?? 'All'}
onValueChange={(val) => setStatus(val === 'All' ? null : val)}
>
<SelectTrigger className="w-[180px] capitalize">
{status || "All statuses"}
{status || 'All statuses'}
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className=" capitalize">
<SelectItem value="All" className="capitalize">
All statuses
</SelectItem>
<SelectItem value="Subscribed" className=" capitalize">
<SelectItem value="Subscribed" className="capitalize">
Subscribed
</SelectItem>
<SelectItem value="Unsubscribed" className=" capitalize">
<SelectItem value="Unsubscribed" className="capitalize">
Unsubscribed
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col rounded-xl border border-broder shadow">
<div className="border-broder flex flex-col rounded-xl border shadow">
<Table className="">
<TableHeader className="">
<TableRow className=" bg-muted/30">
<TableRow className="bg-muted/30">
<TableHead className="rounded-tl-xl">Email</TableHead>
<TableHead>Status</TableHead>
<TableHead className="">Created At</TableHead>
@@ -118,9 +118,9 @@ export default function ContactList({
<TableBody>
{contactsQuery.isLoading ? (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
<TableCell colSpan={4} className="py-4 text-center">
<Spinner
className="w-6 h-6 mx-auto"
className="mx-auto h-6 w-6"
innerSvgClass="stroke-primary"
/>
</TableCell>
@@ -133,7 +133,7 @@ export default function ContactList({
<Image
src={getGravatarUrl(contact.email, {
size: 75,
defaultImage: "robohash",
defaultImage: 'robohash',
})}
alt={contact.email + "'s gravatar"}
width={35}
@@ -144,7 +144,7 @@ export default function ContactList({
<span className="text-sm font-medium">
{contact.email}
</span>
<span className="text-xs text-muted-foreground">
<span className="text-muted-foreground text-xs">
{contact.firstName} {contact.lastName}
</span>
</div>
@@ -152,13 +152,13 @@ export default function ContactList({
</TableCell>
<TableCell>
{contact.subscribed ? (
<div className="text-center w-[130px] rounded capitalize py-1 text-xs bg-green/15 text-green border border-green/25">
<div className="bg-green/15 text-green border-green/25 w-[130px] rounded border py-1 text-center text-xs capitalize">
Subscribed
</div>
) : (
<Tooltip>
<TooltipTrigger>
<div className="text-center w-[130px] rounded capitalize py-1 text-xs bg-red/10 text-red border border-red/10">
<div className="bg-red/10 text-red border-red/10 w-[130px] rounded border py-1 text-center text-xs capitalize">
Unsubscribed
</div>
</TooltipTrigger>
@@ -188,7 +188,7 @@ export default function ContactList({
))
) : (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
<TableCell colSpan={4} className="py-4 text-center">
No contacts found
</TableCell>
</TableRow>
@@ -196,7 +196,7 @@ export default function ContactList({
</TableBody>
</Table>
</div>
<div className="flex gap-4 justify-end">
<div className="flex justify-end gap-4">
<Button
size="sm"
onClick={() => setPage((pageNumber - 1).toString())}

View File

@@ -1,7 +1,7 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
@@ -9,14 +9,14 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import { api } from "~/trpc/react";
import React, { useState } from "react";
import { toast } from "@usesend/ui/src/toaster";
import { Trash2 } from "lucide-react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
} from '@usesend/ui/src/dialog';
import { api } from '~/trpc/react';
import React, { useState } from 'react';
import { toast } from '@usesend/ui/src/toaster';
import { Trash2 } from 'lucide-react';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Form,
FormControl,
@@ -25,8 +25,8 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { Contact } from "@prisma/client";
} from '@usesend/ui/src/form';
import { Contact } from '@prisma/client';
const contactSchema = z.object({
email: z.string().email(),
@@ -46,8 +46,8 @@ export const DeleteContact: React.FC<{
async function onContactDelete(values: z.infer<typeof contactSchema>) {
if (values.email !== contact.email) {
contactForm.setError("email", {
message: "Email does not match",
contactForm.setError('email', {
message: 'Email does not match',
});
return;
}
@@ -70,7 +70,7 @@ export const DeleteContact: React.FC<{
);
}
const email = contactForm.watch("email");
const email = contactForm.watch('email');
return (
<Dialog
@@ -79,15 +79,15 @@ export const DeleteContact: React.FC<{
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4 text-red/80" />
<Trash2 className="text-red/80 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Contact</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">
Are you sure you want to delete{' '}
<span className="text-foreground font-semibold">
{contact.email}
</span>
? You can't reverse this.
@@ -111,7 +111,7 @@ export const DeleteContact: React.FC<{
{formState.errors.email ? (
<FormMessage />
) : (
<FormDescription className=" text-transparent">
<FormDescription className="text-transparent">
.
</FormDescription>
)}
@@ -126,7 +126,7 @@ export const DeleteContact: React.FC<{
deleteContactMutation.isPending || contact.email !== email
}
>
{deleteContactMutation.isPending ? "Deleting..." : "Delete"}
{deleteContactMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</div>
</form>

View File

@@ -1,14 +1,14 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
} from '@usesend/ui/src/dialog';
import {
Form,
FormControl,
@@ -17,21 +17,21 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
} from '@usesend/ui/src/form';
import { api } from "~/trpc/react";
import { useState } from "react";
import { Edit } from "lucide-react";
import { useRouter } from "next/navigation";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@usesend/ui/src/toaster";
import { Switch } from "@usesend/ui/src/switch";
import { Contact } from "@prisma/client";
import { api } from '~/trpc/react';
import { useState } from 'react';
import { Edit } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from '@usesend/ui/src/toaster';
import { Switch } from '@usesend/ui/src/switch';
import { Contact } from '@prisma/client';
const contactSchema = z.object({
email: z.string().email({ message: "Invalid email address" }),
email: z.string().email({ message: 'Invalid email address' }),
firstName: z.string().optional(),
lastName: z.string().optional(),
subscribed: z.boolean().optional(),
@@ -49,9 +49,9 @@ export const EditContact: React.FC<{
const contactForm = useForm<z.infer<typeof contactSchema>>({
resolver: zodResolver(contactSchema),
defaultValues: {
email: contact.email || "",
firstName: contact.firstName || "",
lastName: contact.lastName || "",
email: contact.email || '',
firstName: contact.firstName || '',
lastName: contact.lastName || '',
subscribed: contact.subscribed || false,
},
});
@@ -67,12 +67,12 @@ export const EditContact: React.FC<{
onSuccess: async () => {
utils.contacts.contacts.invalidate();
setOpen(false);
toast.success("Contact updated successfully");
toast.success('Contact updated successfully');
},
onError: async (error) => {
toast.error(error.message);
},
}
},
);
}
@@ -153,11 +153,11 @@ export const EditContact: React.FC<{
/>
<div className="flex justify-end">
<Button
className=" w-[100px] "
className="w-[100px]"
type="submit"
disabled={updateContactMutation.isPending}
>
{updateContactMutation.isPending ? "Updating..." : "Update"}
{updateContactMutation.isPending ? 'Updating...' : 'Update'}
</Button>
</div>
</form>

View File

@@ -1,6 +1,6 @@
"use client";
'use client';
import { api } from "~/trpc/react";
import { api } from '~/trpc/react';
import {
Breadcrumb,
BreadcrumbItem,
@@ -8,21 +8,21 @@ import {
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@usesend/ui/src/breadcrumb";
import Link from "next/link";
import AddContact from "./add-contact";
import ContactList from "./contact-list";
import { TextWithCopyButton } from "@usesend/ui/src/text-with-copy";
import { formatDistanceToNow } from "date-fns";
import EmojiPicker, { Theme } from "emoji-picker-react";
} from '@usesend/ui/src/breadcrumb';
import Link from 'next/link';
import AddContact from './add-contact';
import ContactList from './contact-list';
import { TextWithCopyButton } from '@usesend/ui/src/text-with-copy';
import { formatDistanceToNow } from 'date-fns';
import EmojiPicker, { Theme } from 'emoji-picker-react';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@usesend/ui/src/popover";
import { Button } from "@usesend/ui/src/button";
import { useTheme } from "@usesend/ui";
import { use } from "react";
} from '@usesend/ui/src/popover';
import { Button } from '@usesend/ui/src/button';
import { useTheme } from '@usesend/ui';
import { use } from 'react';
export default function ContactsPage({
params,
@@ -63,8 +63,8 @@ export default function ContactsPage({
return (
<div>
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
@@ -83,7 +83,7 @@ export default function ContactsPage({
<PopoverTrigger asChild>
<Button
variant="ghost"
className="p-0 hover:bg-transparent text-lg"
className="p-0 text-lg hover:bg-transparent"
type="button"
>
{contactBookDetailQuery.data?.emoji}
@@ -100,9 +100,9 @@ export default function ContactsPage({
});
}}
theme={
theme === "system"
theme === 'system'
? Theme.AUTO
: theme === "dark"
: theme === 'dark'
? Theme.DARK
: Theme.LIGHT
}
@@ -124,9 +124,9 @@ export default function ContactsPage({
</div>
</div>
<div className="mt-16">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6 lg:gap-8">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3 lg:gap-8">
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
<p className="font-semibold mb-1">Metrics</p>
<p className="mb-1 font-semibold">Metrics</p>
<div className="flex items-center gap-2">
<div className="text-muted-foreground w-[130px] text-sm">
Total Contacts
@@ -134,7 +134,7 @@ export default function ContactsPage({
<div className="font-mono text-sm">
{contactBookDetailQuery.data?.totalContacts !== undefined
? contactBookDetailQuery.data?.totalContacts
: "--"}
: '--'}
</div>
</div>
<div className="flex items-center gap-2">
@@ -144,7 +144,7 @@ export default function ContactsPage({
<div className="font-mono text-sm">
{contactBookDetailQuery.data?.unsubscribedContacts !== undefined
? contactBookDetailQuery.data?.unsubscribedContacts
: "--"}
: '--'}
</div>
</div>
</div>
@@ -157,7 +157,7 @@ export default function ContactsPage({
<TextWithCopyButton
value={contactBookId}
alwaysShowCopy
className="text-sm w-[130px] overflow-hidden text-ellipsis font-mono"
className="w-[130px] overflow-hidden text-ellipsis font-mono text-sm"
/>
</div>
<div className="flex items-center gap-2">
@@ -169,7 +169,7 @@ export default function ContactsPage({
? formatDistanceToNow(contactBookDetailQuery.data.createdAt, {
addSuffix: true,
})
: "--"}
: '--'}
</div>
</div>
</div>
@@ -184,7 +184,7 @@ export default function ContactsPage({
{contactBookDetailQuery.data?.campaigns.map((campaign) => (
<div key={campaign.id} className="flex items-center gap-2">
<Link href={`/campaigns/${campaign.id}`}>
<div className="text-sm hover:underline hover:decoration-dashed text-nowrap w-[200px] overflow-hidden text-ellipsis">
<div className="w-[200px] overflow-hidden text-ellipsis text-nowrap text-sm hover:underline hover:decoration-dashed">
{campaign.name}
</div>
</Link>

View File

@@ -1,22 +1,22 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
} from '@usesend/ui/src/dialog';
import { api } from "~/trpc/react";
import { useState } from "react";
import { Plus } from "lucide-react";
import { toast } from "@usesend/ui/src/toaster";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { api } from '~/trpc/react';
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { toast } from '@usesend/ui/src/toaster';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Form,
FormControl,
@@ -25,13 +25,13 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { useUpgradeModalStore } from "~/store/upgradeModalStore";
import { LimitReason } from "~/lib/constants/plans";
} from '@usesend/ui/src/form';
import { useUpgradeModalStore } from '~/store/upgradeModalStore';
import { LimitReason } from '~/lib/constants/plans';
const contactBookSchema = z.object({
name: z.string({ required_error: "Name is required" }).min(1, {
message: "Name is required",
name: z.string({ required_error: 'Name is required' }).min(1, {
message: 'Name is required',
}),
});
@@ -50,7 +50,7 @@ export default function AddContactBook() {
const contactBookForm = useForm<z.infer<typeof contactBookSchema>>({
resolver: zodResolver(contactBookSchema),
defaultValues: {
name: "",
name: '',
},
});
@@ -69,7 +69,7 @@ export default function AddContactBook() {
utils.contacts.getContactBooks.invalidate();
contactBookForm.reset();
setOpen(false);
toast.success("Contact book created successfully");
toast.success('Contact book created successfully');
},
},
);
@@ -91,7 +91,7 @@ export default function AddContactBook() {
>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-1" />
<Plus className="mr-1 h-4 w-4" />
Add Contact Book
</Button>
</DialogTrigger>
@@ -127,15 +127,15 @@ export default function AddContactBook() {
/>
<div className="flex justify-end">
<Button
className=" w-[100px]"
className="w-[100px]"
type="submit"
disabled={
createContactBookMutation.isPending || limitsQuery.isLoading
}
>
{createContactBookMutation.isPending
? "Creating..."
: "Create"}
? 'Creating...'
: 'Create'}
</Button>
</div>
</form>

View File

@@ -1,18 +1,18 @@
"use client";
'use client';
import { formatDistanceToNow } from "date-fns";
import { api } from "~/trpc/react";
import DeleteContactBook from "./delete-contact-book";
import Link from "next/link";
import EditContactBook from "./edit-contact-book";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
import { useUrlState } from "~/hooks/useUrlState";
import { Input } from "@usesend/ui/src/input";
import { useDebouncedCallback } from "use-debounce";
import { formatDistanceToNow } from 'date-fns';
import { api } from '~/trpc/react';
import DeleteContactBook from './delete-contact-book';
import Link from 'next/link';
import EditContactBook from './edit-contact-book';
import { useRouter } from 'next/navigation';
import { motion } from 'framer-motion';
import { useUrlState } from '~/hooks/useUrlState';
import { Input } from '@usesend/ui/src/input';
import { useDebouncedCallback } from 'use-debounce';
export default function ContactBooksList() {
const [search, setSearch] = useUrlState("search");
const [search, setSearch] = useUrlState('search');
const contactBooksQuery = api.contacts.getContactBooks.useQuery({
search: search ?? undefined,
});
@@ -27,40 +27,40 @@ export default function ContactBooksList() {
<div className="mt-10">
<Input
placeholder="Search contact book"
className="w-[300px] mr-4 mb-4"
defaultValue={search ?? ""}
className="mb-4 mr-4 w-[300px]"
defaultValue={search ?? ''}
onChange={(e) => debouncedSearch(e.target.value)}
/>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 ">
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
{contactBooksQuery.data?.map((contactBook) => (
<motion.div
key={contactBook.id}
whileHover={{ scale: 1.02 }}
transition={{ type: "spring", stiffness: 200, damping: 10 }}
transition={{ type: 'spring', stiffness: 200, damping: 10 }}
whileTap={{ scale: 0.99 }}
className="border rounded-xl shadow hover:shadow-lg"
className="rounded-xl border shadow hover:shadow-lg"
>
<div className="flex flex-col">
<Link href={`/contacts/${contactBook.id}`} key={contactBook.id}>
<div className="flex justify-between items-center p-4 mb-4">
<div className="mb-4 flex items-center justify-between p-4">
<div className="flex items-center gap-2">
<div>{contactBook.emoji}</div>
<div className="font-semibold truncate whitespace-nowrap overflow-ellipsis w-[180px]">
<div className="w-[180px] truncate overflow-ellipsis whitespace-nowrap font-semibold">
{contactBook.name}
</div>
</div>
<div className="text-sm">
<span className="font-mono">
{contactBook._count.contacts}
</span>{" "}
</span>{' '}
contacts
</div>
</div>
</Link>
<div className="flex justify-between items-center border-t bg-muted/50">
<div className="bg-muted/50 flex items-center justify-between border-t">
<div
className="text-muted-foreground text-xs cursor-pointer w-full py-3 pl-4"
className="text-muted-foreground w-full cursor-pointer py-3 pl-4 text-xs"
onClick={() => router.push(`/contacts/${contactBook.id}`)}
>
{formatDistanceToNow(contactBook.createdAt, {

View File

@@ -1,7 +1,7 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
@@ -9,14 +9,14 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import { api } from "~/trpc/react";
import React, { useState } from "react";
import { toast } from "@usesend/ui/src/toaster";
import { Trash2 } from "lucide-react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
} from '@usesend/ui/src/dialog';
import { api } from '~/trpc/react';
import React, { useState } from 'react';
import { toast } from '@usesend/ui/src/toaster';
import { Trash2 } from 'lucide-react';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Form,
FormControl,
@@ -25,8 +25,8 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { ContactBook } from "@prisma/client";
} from '@usesend/ui/src/form';
import { ContactBook } from '@prisma/client';
const contactBookSchema = z.object({
name: z.string(),
@@ -49,8 +49,8 @@ export const DeleteContactBook: React.FC<{
values: z.infer<typeof contactBookSchema>,
) {
if (values.name !== contactBook.name) {
contactBookForm.setError("name", {
message: "Name does not match",
contactBookForm.setError('name', {
message: 'Name does not match',
});
return;
}
@@ -69,7 +69,7 @@ export const DeleteContactBook: React.FC<{
);
}
const name = contactBookForm.watch("name");
const name = contactBookForm.watch('name');
return (
<Dialog
@@ -77,16 +77,16 @@ export const DeleteContactBook: React.FC<{
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent ">
<Trash2 className="h-[18px] w-[18px] text-red/80 hover:text-red/70" />
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent">
<Trash2 className="text-red/80 hover:text-red/70 h-[18px] w-[18px]" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Contact Book</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">
Are you sure you want to delete{' '}
<span className="text-foreground font-semibold">
{contactBook.name}
</span>
? You can't reverse this.
@@ -110,7 +110,7 @@ export const DeleteContactBook: React.FC<{
{formState.errors.name ? (
<FormMessage />
) : (
<FormDescription className=" text-transparent">
<FormDescription className="text-transparent">
.
</FormDescription>
)}
@@ -127,8 +127,8 @@ export const DeleteContactBook: React.FC<{
}
>
{deleteContactBookMutation.isPending
? "Deleting..."
: "Delete"}
? 'Deleting...'
: 'Delete'}
</Button>
</div>
</form>

View File

@@ -1,14 +1,14 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
} from '@usesend/ui/src/dialog';
import {
Form,
FormControl,
@@ -16,17 +16,17 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { api } from "~/trpc/react";
import { useState } from "react";
import { Edit } from "lucide-react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@usesend/ui/src/toaster";
} from '@usesend/ui/src/form';
import { api } from '~/trpc/react';
import { useState } from 'react';
import { Edit } from 'lucide-react';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from '@usesend/ui/src/toaster';
const contactBookSchema = z.object({
name: z.string().min(1, { message: "Name is required" }),
name: z.string().min(1, { message: 'Name is required' }),
});
export const EditContactBook: React.FC<{
@@ -41,12 +41,12 @@ export const EditContactBook: React.FC<{
const contactBookForm = useForm<z.infer<typeof contactBookSchema>>({
resolver: zodResolver(contactBookSchema),
defaultValues: {
name: contactBook.name || "",
name: contactBook.name || '',
},
});
async function onContactBookUpdate(
values: z.infer<typeof contactBookSchema>
values: z.infer<typeof contactBookSchema>,
) {
updateContactBookMutation.mutate(
{
@@ -57,12 +57,12 @@ export const EditContactBook: React.FC<{
onSuccess: async () => {
utils.contacts.getContactBooks.invalidate();
setOpen(false);
toast.success("Contact book updated successfully");
toast.success('Contact book updated successfully');
},
onError: async (error) => {
toast.error(error.message);
},
}
},
);
}
@@ -78,7 +78,7 @@ export const EditContactBook: React.FC<{
className="p-0 hover:bg-transparent"
onClick={(e) => e.stopPropagation()}
>
<Edit className="h-4 w-4 text-foreground/80 hover:text-foreground/70" />
<Edit className="text-foreground/80 hover:text-foreground/70 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
@@ -106,13 +106,13 @@ export const EditContactBook: React.FC<{
/>
<div className="flex justify-end">
<Button
className=" w-[100px]"
className="w-[100px]"
type="submit"
disabled={updateContactBookMutation.isPending}
>
{updateContactBookMutation.isPending
? "Updating..."
: "Update"}
? 'Updating...'
: 'Update'}
</Button>
</div>
</form>

View File

@@ -1,13 +1,13 @@
"use client";
'use client';
import AddContactBook from "./add-contact-book";
import ContactBooksList from "./contact-books-list";
import { H1 } from "@usesend/ui";
import AddContactBook from './add-contact-book';
import ContactBooksList from './contact-books-list';
import { H1 } from '@usesend/ui';
export default function ContactsPage() {
return (
<div>
<div className="flex justify-between items-center">
<div className="flex items-center justify-between">
<H1>Contact books</H1>
<AddContactBook />
</div>

View File

@@ -1,22 +1,22 @@
"use client";
'use client';
import { AppSidebar } from "~/components/AppSideBar";
import { SidebarInset, SidebarTrigger } from "@usesend/ui/src/sidebar";
import { SidebarProvider } from "@usesend/ui/src/sidebar";
import { useIsMobile } from "@usesend/ui/src/hooks/use-mobile";
import { UpgradeModal } from "~/components/payments/UpgradeModal";
import { AppSidebar } from '~/components/AppSideBar';
import { SidebarInset, SidebarTrigger } from '@usesend/ui/src/sidebar';
import { SidebarProvider } from '@usesend/ui/src/sidebar';
import { useIsMobile } from '@usesend/ui/src/hooks/use-mobile';
import { UpgradeModal } from '~/components/payments/UpgradeModal';
export function DashboardLayout({ children }: { children: React.ReactNode }) {
const isMobile = useIsMobile();
return (
<div className="h-full bg-sidebar-background">
<div className="bg-sidebar-background h-full">
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<main className="flex-1 overflow-auto h-full p-4 xl:px-40">
<main className="h-full flex-1 overflow-auto p-4 xl:px-40">
{isMobile ? (
<SidebarTrigger className="h-5 w-5 text-muted-foreground" />
<SidebarTrigger className="text-muted-foreground h-5 w-5" />
) : null}
{children}
</main>

View File

@@ -1,13 +1,13 @@
import React from "react";
import { Tabs, TabsList, TabsTrigger } from "@usesend/ui/src/tabs";
import { useUrlState } from "~/hooks/useUrlState";
import React from 'react';
import { Tabs, TabsList, TabsTrigger } from '@usesend/ui/src/tabs';
import { useUrlState } from '~/hooks/useUrlState';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@usesend/ui/src/select";
import { api } from "~/trpc/react";
} from '@usesend/ui/src/select';
import { api } from '~/trpc/react';
interface DashboardFiltersProps {
days: string;
@@ -25,37 +25,42 @@ export default function DashboardFilters({
const { data: domainsQuery } = api.domain.domains.useQuery();
const handleDomain = (val: string) => {
setDomain(val === "All Domains" ? null : val);
setDomain(val === 'All Domains' ? null : val);
};
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Select value={domain ?? "All Domains"} onValueChange={(val) => handleDomain(val)}>
<SelectTrigger className="w-full sm:w-[180px]">
{domain ? domainsQuery?.find((d) => d.id === Number(domain))?.name : "All Domains"}
</SelectTrigger>
<SelectContent>
<SelectItem value="All Domains" className="capitalize">
All Domains
</SelectItem>
{domainsQuery &&
domainsQuery.map((domain) => (
<SelectItem key={domain.id} value={domain.id.toString()}>
{domain.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Tabs value={days || "7"} onValueChange={(value) => setDays(value)}>
<TabsList className="w-full sm:w-auto">
<TabsTrigger value="7" className="flex-1 sm:flex-none">
7 Days
</TabsTrigger>
<TabsTrigger value="30" className="flex-1 sm:flex-none">
30 Days
</TabsTrigger>
</TabsList>
</Tabs>
</div>
);
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Select
value={domain ?? 'All Domains'}
onValueChange={(val) => handleDomain(val)}
>
<SelectTrigger className="w-full sm:w-[180px]">
{domain
? domainsQuery?.find((d) => d.id === Number(domain))?.name
: 'All Domains'}
</SelectTrigger>
<SelectContent>
<SelectItem value="All Domains" className="capitalize">
All Domains
</SelectItem>
{domainsQuery &&
domainsQuery.map((domain) => (
<SelectItem key={domain.id} value={domain.id.toString()}>
{domain.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Tabs value={days || '7'} onValueChange={(value) => setDays(value)}>
<TabsList className="w-full sm:w-auto">
<TabsTrigger value="7" className="flex-1 sm:flex-none">
7 Days
</TabsTrigger>
<TabsTrigger value="30" className="flex-1 sm:flex-none">
30 Days
</TabsTrigger>
</TabsList>
</Tabs>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import React from "react";
import React from 'react';
import {
BarChart,
Bar,
@@ -10,13 +10,13 @@ import {
CartesianGrid,
AreaChart,
Area,
} from "recharts";
import { EmailStatusIcon } from "../emails/email-status-badge";
import { EmailStatus } from "@prisma/client";
import { api } from "~/trpc/react";
import Spinner from "@usesend/ui/src/spinner";
import { useTheme } from "@usesend/ui";
import { useColors } from "./hooks/useColors";
} from 'recharts';
import { EmailStatusIcon } from '../emails/email-status-badge';
import { EmailStatus } from '@prisma/client';
import { api } from '~/trpc/react';
import Spinner from '@usesend/ui/src/spinner';
import { useTheme } from '@usesend/ui';
import { useColors } from './hooks/useColors';
interface EmailChartProps {
days: number;
@@ -24,11 +24,11 @@ interface EmailChartProps {
}
const STACK_ORDER: string[] = [
"delivered",
"bounced",
"complained",
"opened",
"clicked",
'delivered',
'bounced',
'complained',
'opened',
'clicked',
] as const;
type StackKey = (typeof STACK_ORDER)[number];
@@ -66,13 +66,13 @@ export default function EmailChart({ days, domain }: EmailChartProps) {
return (
<div className="flex flex-col gap-16">
{!statusQuery.isLoading && statusQuery.data ? (
<div className="w-full h-[450px] border shadow rounded-xl p-4">
<div className="p-2 overflow-x-auto">
<div className="h-[450px] w-full rounded-xl border p-4 shadow">
<div className="overflow-x-auto p-2">
{/* <div className="mb-4 text-sm">Emails</div> */}
<div className="flex gap-10">
<EmailChartItem
status={"total"}
status={'total'}
count={statusQuery.data.totalCounts.sent}
percentage={100}
/>
@@ -140,82 +140,82 @@ export default function EmailChart({ days, domain }: EmailChartProps) {
<Tooltip
content={({ payload }) => {
const data = payload?.[0]?.payload as Record<
| "sent"
| "delivered"
| "opened"
| "clicked"
| "bounced"
| "complained",
| 'sent'
| 'delivered'
| 'opened'
| 'clicked'
| 'bounced'
| 'complained',
number
> & { date: string };
if (!data || data.sent === 0) return null;
return (
<div className=" bg-background border shadow-lg p-2 rounded-xl flex flex-col gap-2 px-4">
<p className="text-sm text-muted-foreground">
<div className="bg-background flex flex-col gap-2 rounded-xl border p-2 px-4 shadow-lg">
<p className="text-muted-foreground text-sm">
{data.date}
</p>
{data.delivered ? (
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-[2px]"
className="h-2.5 w-2.5 rounded-[2px]"
style={{ backgroundColor: currentColors.delivered }}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
<p className="text-muted-foreground w-[70px] text-xs">
Delivered
</p>
<p className="text-xs font-mono">{data.delivered}</p>
<p className="font-mono text-xs">{data.delivered}</p>
</div>
) : null}
{data.bounced ? (
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-[2px]"
className="h-2.5 w-2.5 rounded-[2px]"
style={{ backgroundColor: currentColors.bounced }}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
<p className="text-muted-foreground w-[70px] text-xs">
Bounced
</p>
<p className="text-xs font-mono">{data.bounced}</p>
<p className="font-mono text-xs">{data.bounced}</p>
</div>
) : null}
{data.complained ? (
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-[2px]"
className="h-2.5 w-2.5 rounded-[2px]"
style={{
backgroundColor: currentColors.complained,
}}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
<p className="text-muted-foreground w-[70px] text-xs">
Complained
</p>
<p className="text-xs font-mono">{data.complained}</p>
<p className="font-mono text-xs">{data.complained}</p>
</div>
) : null}
{data.opened ? (
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-[2px]"
className="h-2.5 w-2.5 rounded-[2px]"
style={{ backgroundColor: currentColors.opened }}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
<p className="text-muted-foreground w-[70px] text-xs">
Opened
</p>
<p className="text-xs font-mono">{data.opened}</p>
<p className="font-mono text-xs">{data.opened}</p>
</div>
) : null}
{data.clicked ? (
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-[2px]"
className="h-2.5 w-2.5 rounded-[2px]"
style={{ backgroundColor: currentColors.clicked }}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
<p className="text-muted-foreground w-[70px] text-xs">
Clicked
</p>
<p className="text-xs font-mono">{data.clicked}</p>
<p className="font-mono text-xs">{data.clicked}</p>
</div>
) : null}
</div>
@@ -229,31 +229,31 @@ export default function EmailChart({ days, domain }: EmailChartProps) {
dataKey="delivered"
stackId="a"
fill={currentColors.delivered}
shape={createRoundedTopShape("delivered")}
shape={createRoundedTopShape('delivered')}
/>
<Bar
dataKey="bounced"
stackId="a"
fill={currentColors.bounced}
shape={createRoundedTopShape("bounced")}
shape={createRoundedTopShape('bounced')}
/>
<Bar
dataKey="complained"
stackId="a"
fill={currentColors.complained}
shape={createRoundedTopShape("complained")}
shape={createRoundedTopShape('complained')}
/>
<Bar
dataKey="opened"
stackId="a"
fill={currentColors.opened}
shape={createRoundedTopShape("opened")}
shape={createRoundedTopShape('opened')}
/>
<Bar
dataKey="clicked"
stackId="a"
fill={currentColors.clicked}
shape={createRoundedTopShape("clicked")}
shape={createRoundedTopShape('clicked')}
/>
</BarChart>
</ResponsiveContainer>
@@ -266,7 +266,7 @@ export default function EmailChart({ days, domain }: EmailChartProps) {
}
type DashboardItemCardProps = {
status: EmailStatus | "total";
status: EmailStatus | 'total';
count: number;
percentage: number;
};
@@ -277,17 +277,17 @@ const DashboardItemCard: React.FC<DashboardItemCardProps> = ({
percentage,
}) => {
return (
<div className="h-[100px] w-[16%] min-w-[170px] bg-secondary/10 border shadow rounded-xl p-4 flex flex-col gap-3">
<div className="bg-secondary/10 flex h-[100px] w-[16%] min-w-[170px] flex-col gap-3 rounded-xl border p-4 shadow">
<div className="flex items-center gap-3">
{status !== "total" ? <EmailStatusIcon status={status} /> : null}
<div className=" capitalize">{status.toLowerCase()}</div>
{status !== 'total' ? <EmailStatusIcon status={status} /> : null}
<div className="capitalize">{status.toLowerCase()}</div>
</div>
<div className="flex justify-between items-end">
<div className="text-foreground font-light text-2xl font-mono">
<div className="flex items-end justify-between">
<div className="text-foreground font-mono text-2xl font-light">
{count}
</div>
{status !== "total" ? (
<div className="text-sm pb-1">
{status !== 'total' ? (
<div className="pb-1 text-sm">
{count > 0 ? (percentage * 100).toFixed(0) : 0}%
</div>
) : null}
@@ -303,41 +303,41 @@ const EmailChartItem: React.FC<DashboardItemCardProps> = ({
}) => {
const currentColors = useColors();
const getColorForStatus = (status: EmailStatus | "total"): string => {
const getColorForStatus = (status: EmailStatus | 'total'): string => {
switch (status) {
case "DELIVERED":
case 'DELIVERED':
return currentColors.delivered;
case "BOUNCED":
case 'BOUNCED':
return currentColors.bounced;
case "COMPLAINED":
case 'COMPLAINED':
return currentColors.complained;
case "OPENED":
case 'OPENED':
return currentColors.opened;
case "CLICKED":
case 'CLICKED':
return currentColors.clicked;
case "total":
case 'total':
default:
return "#6b7280"; // gray-500 for total and other statuses
return '#6b7280'; // gray-500 for total and other statuses
}
};
return (
<div className="flex gap-3 items-stretch font-mono">
<div className="flex items-stretch gap-3 font-mono">
<div>
<div className=" flex items-center gap-2">
<div className="flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-[3px]"
className="h-2.5 w-2.5 rounded-[3px]"
style={{ backgroundColor: getColorForStatus(status) }}
></div>
<div className="text-xs uppercase text-muted-foreground ">
<div className="text-muted-foreground text-xs uppercase">
{status.toLowerCase()}
</div>
</div>
<div className="mt-1 -ml-0.5 ">
<span className="text-xl font-mono">{count}</span>
<span className="text-xs ml-2 font-mono">
{status !== "total"
<div className="-ml-0.5 mt-1">
<span className="font-mono text-xl">{count}</span>
<span className="ml-2 font-mono text-xs">
{status !== 'total'
? `(${count > 0 ? (percentage * 100).toFixed(0) : 0}%)`
: null}
</span>

View File

@@ -1,27 +1,27 @@
import { useTheme } from "@usesend/ui";
import { useTheme } from '@usesend/ui';
export function useColors() {
const { resolvedTheme } = useTheme();
const lightColors = {
delivered: "#40a02b",
bounced: "#d20f39",
complained: "#df8e1d",
opened: "#8839ef",
clicked: "#04a5e5",
xaxis: "#6D6F84",
delivered: '#40a02b',
bounced: '#d20f39',
complained: '#df8e1d',
opened: '#8839ef',
clicked: '#04a5e5',
xaxis: '#6D6F84',
};
const darkColors = {
delivered: "#a6e3a1",
bounced: "#f38ba8",
complained: "#F9E2AF",
opened: "#cba6f7",
clicked: "#93c5fd",
xaxis: "#AAB1CD",
delivered: '#a6e3a1',
bounced: '#f38ba8',
complained: '#F9E2AF',
opened: '#cba6f7',
clicked: '#93c5fd',
xaxis: '#AAB1CD',
};
const currentColors = resolvedTheme === "dark" ? darkColors : lightColors;
const currentColors = resolvedTheme === 'dark' ? darkColors : lightColors;
return currentColors;
}

View File

@@ -1,31 +1,31 @@
"use client";
'use client';
import EmailChart from "./email-chart";
import DashboardFilters from "./dashboard-filters";
import { H1 } from "@usesend/ui";
import { useUrlState } from "~/hooks/useUrlState";
import { ReputationMetrics } from "./reputation-metrics";
import EmailChart from './email-chart';
import DashboardFilters from './dashboard-filters';
import { H1 } from '@usesend/ui';
import { useUrlState } from '~/hooks/useUrlState';
import { ReputationMetrics } from './reputation-metrics';
export default function Dashboard() {
const [days, setDays] = useUrlState("days", "7");
const [domain, setDomain] = useUrlState("domain");
const [days, setDays] = useUrlState('days', '7');
const [domain, setDomain] = useUrlState('domain');
return (
<div>
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<div className="mb-10 flex items-center justify-between">
<H1>Analytics</H1>
<DashboardFilters
days={days ?? "7"}
days={days ?? '7'}
setDays={setDays}
domain={domain}
setDomain={setDomain}
/>
</div>
<div className=" space-y-12">
<EmailChart days={Number(days ?? "7")} domain={domain} />
<div className="space-y-12">
<EmailChart days={Number(days ?? '7')} domain={domain} />
<ReputationMetrics days={Number(days ?? "7")} domain={domain} />
<ReputationMetrics days={Number(days ?? '7')} domain={domain} />
</div>
</div>
</div>

View File

@@ -3,14 +3,14 @@ import {
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@usesend/ui/src/tooltip";
} from '@usesend/ui/src/tooltip';
import {
CheckCircle2,
CheckCircle2Icon,
InfoIcon,
OctagonAlertIcon,
TriangleAlertIcon,
} from "lucide-react";
} from 'lucide-react';
import {
Bar,
BarChart,
@@ -19,15 +19,15 @@ import {
Tooltip as RechartsTooltip,
CartesianGrid,
YAxis,
} from "recharts";
} from 'recharts';
import {
HARD_BOUNCE_RISK_RATE,
HARD_BOUNCE_WARNING_RATE,
COMPLAINED_WARNING_RATE,
COMPLAINED_RISK_RATE,
} from "~/lib/constants";
import { api } from "~/trpc/react";
import { useColors } from "./hooks/useColors";
} from '~/lib/constants';
import { api } from '~/trpc/react';
import { useColors } from './hooks/useColors';
interface ReputationMetricsProps {
days: number;
@@ -35,9 +35,9 @@ interface ReputationMetricsProps {
}
enum ACCOUNT_STATUS {
HEALTHY = "HEALTHY",
WARNING = "WARNING",
RISK = "RISK",
HEALTHY = 'HEALTHY',
WARNING = 'WARNING',
RISK = 'RISK',
}
const CustomLabel = ({ value, stroke }: { value: string; stroke: string }) => {
@@ -59,7 +59,7 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
const bouncedMetric = metrics
? [
{
name: "Bounce Rate",
name: 'Bounce Rate',
value: metrics.bounceRate,
},
]
@@ -68,7 +68,7 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
const complaintMetric = metrics
? [
{
name: "Complaint Rate",
name: 'Complaint Rate',
value: metrics.complaintRate,
},
]
@@ -90,14 +90,14 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
return (
<TooltipProvider>
<div className="flex flex-col sm:flex-row gap-10 w-full">
<div className="w-full sm:w-1/2 border rounded-xl shadow p-4">
<div className="flex w-full flex-col gap-10 sm:flex-row">
<div className="w-full rounded-xl border p-4 shadow sm:w-1/2">
<div className="flex justify-between">
<div className=" flex items-center gap-2">
<div className="flex items-center gap-2">
<div className="text-muted-foreground font-mono">Bounce Rate</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className=" h-3.5 w-3.5 text-muted-foreground" />
<InfoIcon className="text-muted-foreground h-3.5 w-3.5" />
</TooltipTrigger>
<TooltipContent className="w-[300px]">
The percentage of emails sent from your account that resulted
@@ -108,7 +108,7 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
<div></div>
</div>
<div className="flex items-baseline gap-4">
<div className="text-2xl mt-2 font-mono">
<div className="mt-2 font-mono text-2xl">
{metrics?.bounceRate.toFixed(2)}%
</div>
<StatusBadge status={bounceStatus} />
@@ -147,8 +147,8 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
y={HARD_BOUNCE_WARNING_RATE}
stroke={`${colors.complained}A0`}
label={{
value: "",
position: "insideBottomLeft",
value: '',
position: 'insideBottomLeft',
fill: colors.complained,
fontSize: 12,
}}
@@ -169,7 +169,7 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
stroke={`${colors.bounced}A0`}
label={{
value: ``,
position: "insideBottomLeft",
position: 'insideBottomLeft',
fill: colors.bounced,
fontSize: 12,
}}
@@ -185,43 +185,43 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
if (!data) return null;
return (
<div className="bg-background border shadow-lg p-2 rounded-xl flex flex-col gap-2 px-4">
<p className="text-sm text-muted-foreground">
<div className="bg-background flex flex-col gap-2 rounded-xl border p-2 px-4 shadow-lg">
<p className="text-muted-foreground text-sm">
{data.name}
</p>
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-[2px]"
className="h-2.5 w-2.5 rounded-[2px]"
style={{ background: colors.clicked }}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
<p className="text-muted-foreground w-[70px] text-xs">
Current
</p>
<p className="text-xs font-mono">
<p className="font-mono text-xs">
{data.value.toFixed(2)}%
</p>
</div>
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-[2px]"
className="h-2.5 w-2.5 rounded-[2px]"
style={{ background: colors.complained }}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
<p className="text-muted-foreground w-[70px] text-xs">
Warning at
</p>
<p className="text-xs font-mono">
<p className="font-mono text-xs">
{HARD_BOUNCE_WARNING_RATE}%
</p>
</div>
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-[2px]"
className="h-2.5 w-2.5 rounded-[2px]"
style={{ background: colors.bounced }}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
<p className="text-muted-foreground w-[70px] text-xs">
Risk at
</p>
<p className="text-xs font-mono">
<p className="font-mono text-xs">
{HARD_BOUNCE_RISK_RATE}%
</p>
</div>
@@ -240,14 +240,14 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
</BarChart>
</ResponsiveContainer>
</div>
<div className="w-full sm:w-1/2 border rounded-xl shadow p-4">
<div className=" flex items-center gap-2">
<div className=" text-muted-foreground font-mono">
<div className="w-full rounded-xl border p-4 shadow sm:w-1/2">
<div className="flex items-center gap-2">
<div className="text-muted-foreground font-mono">
Complaint Rate
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className=" h-3.5 w-3.5 text-muted-foreground" />
<InfoIcon className="text-muted-foreground h-3.5 w-3.5" />
</TooltipTrigger>
<TooltipContent className="w-[300px]">
The percentage of emails sent from your account that resulted in
@@ -256,7 +256,7 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
</Tooltip>
</div>
<div className="flex items-baseline gap-4">
<div className="text-2xl mt-2 font-mono">
<div className="mt-2 font-mono text-2xl">
{metrics?.complaintRate.toFixed(2)}%
</div>
<StatusBadge status={complaintStatus} />
@@ -289,8 +289,8 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
y={COMPLAINED_WARNING_RATE}
stroke={`${colors.complained}A0`}
label={{
value: "",
position: "insideBottomLeft",
value: '',
position: 'insideBottomLeft',
fill: colors.complained,
fontSize: 12,
}}
@@ -308,7 +308,7 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
stroke={`${colors.bounced}A0`}
label={{
value: ``,
position: "insideBottomLeft",
position: 'insideBottomLeft',
fill: colors.bounced,
fontSize: 12,
}}
@@ -324,43 +324,43 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
if (!data) return null;
return (
<div className="bg-background border shadow-lg p-2 rounded-xl flex flex-col gap-2 px-4">
<p className="text-sm text-muted-foreground">
<div className="bg-background flex flex-col gap-2 rounded-xl border p-2 px-4 shadow-lg">
<p className="text-muted-foreground text-sm">
{data.name}
</p>
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-[2px]"
className="h-2.5 w-2.5 rounded-[2px]"
style={{ background: colors.clicked }}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
<p className="text-muted-foreground w-[70px] text-xs">
Current
</p>
<p className="text-xs font-mono">
<p className="font-mono text-xs">
{data.value.toFixed(2)}%
</p>
</div>
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-[2px]"
className="h-2.5 w-2.5 rounded-[2px]"
style={{ background: colors.complained }}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
<p className="text-muted-foreground w-[70px] text-xs">
Warning at
</p>
<p className="text-xs font-mono">
<p className="font-mono text-xs">
{COMPLAINED_WARNING_RATE}%
</p>
</div>
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-[2px]"
className="h-2.5 w-2.5 rounded-[2px]"
style={{ background: colors.bounced }}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
<p className="text-muted-foreground w-[70px] text-xs">
Risk at
</p>
<p className="text-xs font-mono">
<p className="font-mono text-xs">
{COMPLAINED_RISK_RATE}%
</p>
</div>
@@ -388,22 +388,22 @@ export const StatusBadge: React.FC<{ status: ACCOUNT_STATUS }> = ({
status,
}) => {
const className =
status === "HEALTHY"
? " text-success border-success"
: status === "WARNING"
? " text-warning border-warning"
: " text-destructive border-destructive";
status === 'HEALTHY'
? ' text-success border-success'
: status === 'WARNING'
? ' text-warning border-warning'
: ' text-destructive border-destructive';
const StatusIcon =
status === "HEALTHY"
status === 'HEALTHY'
? CheckCircle2Icon
: status === "WARNING"
: status === 'WARNING'
? TriangleAlertIcon
: OctagonAlertIcon;
return (
<div
className={` capitalize text-xs ${className} flex gap-1 items-center rounded-lg`}
className={`text-xs capitalize ${className} flex items-center gap-1 rounded-lg`}
>
<StatusIcon className="h-3.5 w-3.5" />
{status.toLowerCase()}

View File

@@ -1,7 +1,7 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
@@ -9,15 +9,15 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
} from '@usesend/ui/src/dialog';
import { api } from "~/trpc/react";
import { useState } from "react";
import { CheckIcon, ClipboardCopy, Eye, EyeOff, Plus } from "lucide-react";
import { toast } from "@usesend/ui/src/toaster";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { api } from '~/trpc/react';
import { useState } from 'react';
import { CheckIcon, ClipboardCopy, Eye, EyeOff, Plus } from 'lucide-react';
import { toast } from '@usesend/ui/src/toaster';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Form,
FormControl,
@@ -26,25 +26,25 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
} from '@usesend/ui/src/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@usesend/ui/src/select";
} from '@usesend/ui/src/select';
const apiKeySchema = z.object({
name: z.string({ required_error: "Name is required" }).min(1, {
message: "Name is required",
name: z.string({ required_error: 'Name is required' }).min(1, {
message: 'Name is required',
}),
domainId: z.string().optional(),
});
export default function AddApiKey() {
const [open, setOpen] = useState(false);
const [apiKey, setApiKey] = useState("");
const [apiKey, setApiKey] = useState('');
const createApiKeyMutation = api.apiKey.createToken.useMutation();
const [isCopied, setIsCopied] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);
@@ -56,8 +56,8 @@ export default function AddApiKey() {
const apiKeyForm = useForm<z.infer<typeof apiKeySchema>>({
resolver: zodResolver(apiKeySchema),
defaultValues: {
name: "",
domainId: "all",
name: '',
domainId: 'all',
},
});
@@ -65,9 +65,9 @@ export default function AddApiKey() {
createApiKeyMutation.mutate(
{
name: values.name,
permission: "FULL",
permission: 'FULL',
domainId:
values.domainId === "all" ? undefined : Number(values.domainId),
values.domainId === 'all' ? undefined : Number(values.domainId),
},
{
onSuccess: (data) => {
@@ -75,7 +75,7 @@ export default function AddApiKey() {
setApiKey(data);
apiKeyForm.reset();
},
}
},
);
}
@@ -89,10 +89,10 @@ export default function AddApiKey() {
function copyAndClose() {
handleCopy();
setApiKey("");
setApiKey('');
setOpen(false);
setShowApiKey(false);
toast.success("API key copied to clipboard");
toast.success('API key copied to clipboard');
}
return (
@@ -102,7 +102,7 @@ export default function AddApiKey() {
>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-1" />
<Plus className="mr-1 h-4 w-4" />
Add API Key
</Button>
</DialogTrigger>
@@ -111,7 +111,7 @@ export default function AddApiKey() {
<DialogHeader>
<DialogTitle>Copy API key</DialogTitle>
</DialogHeader>
<div className="py-1 bg-secondary rounded-lg px-4 flex items-center justify-between mt-2">
<div className="bg-secondary mt-2 flex items-center justify-between rounded-lg px-4 py-1">
<div>
{showApiKey ? (
<p className="text-sm">{apiKey}</p>
@@ -120,7 +120,7 @@ export default function AddApiKey() {
{Array.from({ length: 40 }).map((_, index) => (
<div
key={index}
className="w-1 h-1 bg-muted-foreground rounded-lg"
className="bg-muted-foreground h-1 w-1 rounded-lg"
/>
))}
</div>
@@ -129,7 +129,7 @@ export default function AddApiKey() {
<div className="flex gap-4">
<Button
variant="ghost"
className="hover:bg-transparent p-0 cursor-pointer group-hover:opacity-100"
className="cursor-pointer p-0 hover:bg-transparent group-hover:opacity-100"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? (
@@ -141,11 +141,11 @@ export default function AddApiKey() {
<Button
variant="ghost"
className="hover:bg-transparent p-0 cursor-pointer group-hover:opacity-100"
className="cursor-pointer p-0 hover:bg-transparent group-hover:opacity-100"
onClick={handleCopy}
>
{isCopied ? (
<CheckIcon className="h-4 w-4 text-green" />
<CheckIcon className="text-green h-4 w-4" />
) : (
<ClipboardCopy className="h-4 w-4" />
)}
@@ -218,7 +218,7 @@ export default function AddApiKey() {
>
{domain.name}
</SelectItem>
)
),
)}
</SelectContent>
</Select>
@@ -230,11 +230,11 @@ export default function AddApiKey() {
/>
<div className="flex justify-end">
<Button
className=" w-[100px] hover:bg-gray-100 focus:bg-gray-100"
className="w-[100px] hover:bg-gray-100 focus:bg-gray-100"
type="submit"
disabled={createApiKeyMutation.isPending}
>
{createApiKeyMutation.isPending ? "Creating..." : "Create"}
{createApiKeyMutation.isPending ? 'Creating...' : 'Create'}
</Button>
</div>
</form>

View File

@@ -1,4 +1,4 @@
"use client";
'use client';
import {
Table,
@@ -7,21 +7,21 @@ import {
TableHead,
TableHeader,
TableRow,
} from "@usesend/ui/src/table";
import { formatDistanceToNow } from "date-fns";
import { api } from "~/trpc/react";
import DeleteApiKey from "./delete-api-key";
import Spinner from "@usesend/ui/src/spinner";
} from '@usesend/ui/src/table';
import { formatDistanceToNow } from 'date-fns';
import { api } from '~/trpc/react';
import DeleteApiKey from './delete-api-key';
import Spinner from '@usesend/ui/src/spinner';
export default function ApiList() {
const apiKeysQuery = api.apiKey.getApiKeys.useQuery();
return (
<div className="mt-10">
<div className="border rounded-xl shadow">
<div className="rounded-xl border shadow">
<Table className="">
<TableHeader className="">
<TableRow className=" bg-muted/30">
<TableRow className="bg-muted/30">
<TableHead className="rounded-tl-xl">Name</TableHead>
<TableHead>Token</TableHead>
<TableHead>Permission</TableHead>
@@ -34,16 +34,16 @@ export default function ApiList() {
<TableBody>
{apiKeysQuery.isLoading ? (
<TableRow className="h-32">
<TableCell colSpan={7} className="text-center py-4">
<TableCell colSpan={7} className="py-4 text-center">
<Spinner
className="w-6 h-6 mx-auto"
className="mx-auto h-6 w-6"
innerSvgClass="stroke-primary"
/>
</TableCell>
</TableRow>
) : apiKeysQuery.data?.length === 0 ? (
<TableRow className="h-32">
<TableCell colSpan={7} className="text-center py-4">
<TableCell colSpan={7} className="py-4 text-center">
<p>No API keys added</p>
</TableCell>
</TableRow>
@@ -55,13 +55,15 @@ export default function ApiList() {
<TableCell>{apiKey.permission}</TableCell>
<TableCell>
{apiKey.domainId
? apiKey.domain?.name ?? "Domain removed"
: "All domains"}
? (apiKey.domain?.name ?? 'Domain removed')
: 'All domains'}
</TableCell>
<TableCell>
{apiKey.lastUsed
? formatDistanceToNow(apiKey.lastUsed, { addSuffix: true })
: "Never"}
? formatDistanceToNow(apiKey.lastUsed, {
addSuffix: true,
})
: 'Never'}
</TableCell>
<TableCell>
{formatDistanceToNow(apiKey.createdAt, { addSuffix: true })}

View File

@@ -1,7 +1,7 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
@@ -9,15 +9,15 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import { api } from "~/trpc/react";
import React, { useState } from "react";
import { ApiKey } from "@prisma/client";
import { toast } from "@usesend/ui/src/toaster";
import { Trash2 } from "lucide-react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
} from '@usesend/ui/src/dialog';
import { api } from '~/trpc/react';
import React, { useState } from 'react';
import { ApiKey } from '@prisma/client';
import { toast } from '@usesend/ui/src/toaster';
import { Trash2 } from 'lucide-react';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Form,
FormControl,
@@ -26,7 +26,7 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
} from '@usesend/ui/src/form';
const apiKeySchema = z.object({
name: z.string(),
@@ -46,8 +46,8 @@ export const DeleteApiKey: React.FC<{
async function onDomainDelete(values: z.infer<typeof apiKeySchema>) {
if (values.name !== apiKey.name) {
apiKeyForm.setError("name", {
message: "Name does not match",
apiKeyForm.setError('name', {
message: 'Name does not match',
});
return;
}
@@ -66,7 +66,7 @@ export const DeleteApiKey: React.FC<{
);
}
const name = apiKeyForm.watch("name");
const name = apiKeyForm.watch('name');
return (
<Dialog
@@ -75,15 +75,15 @@ export const DeleteApiKey: React.FC<{
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4 text-red/80" />
<Trash2 className="text-red/80 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete API key</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{apiKey.name}</span>
Are you sure you want to delete{' '}
<span className="text-foreground font-semibold">{apiKey.name}</span>
? You can't reverse this.
</DialogDescription>
</DialogHeader>
@@ -105,7 +105,7 @@ export const DeleteApiKey: React.FC<{
{formState.errors.name ? (
<FormMessage />
) : (
<FormDescription className=" text-transparent">
<FormDescription className="text-transparent">
.
</FormDescription>
)}
@@ -120,7 +120,7 @@ export const DeleteApiKey: React.FC<{
deleteApiKeyMutation.isPending || apiKey.name !== name
}
>
{deleteApiKeyMutation.isPending ? "Deleting..." : "Delete"}
{deleteApiKeyMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</div>
</form>

View File

@@ -1,13 +1,13 @@
"use client";
'use client';
import AddApiKey from "./add-api-key";
import ApiList from "./api-list";
import { H1 } from "@usesend/ui";
import AddApiKey from './add-api-key';
import ApiList from './api-list';
import { H1 } from '@usesend/ui';
export default function ApiKeysPage() {
return (
<div>
<div className="flex justify-between items-center">
<div className="flex items-center justify-between">
<H1>API Keys</H1>
<AddApiKey />
</div>

View File

@@ -1,8 +1,8 @@
"use client";
'use client';
import { SettingsNavButton } from "./settings-nav-button";
import { SettingsNavButton } from './settings-nav-button';
export const dynamic = "force-static";
export const dynamic = 'force-static';
export default function ApiKeysPage({
children,
@@ -11,8 +11,8 @@ export default function ApiKeysPage({
}) {
return (
<div>
<h1 className="font-bold text-lg">Developer settings</h1>
<div className="flex gap-4 mt-4">
<h1 className="text-lg font-bold">Developer settings</h1>
<div className="mt-4 flex gap-4">
<SettingsNavButton href="/dev-settings">API Keys</SettingsNavButton>
<SettingsNavButton href="/dev-settings/smtp">SMTP</SettingsNavButton>
</div>

View File

@@ -1,13 +1,13 @@
"use client";
'use client';
import AddApiKey from "./api-keys/add-api-key";
import ApiList from "./api-keys/api-list";
import { H1 } from "@usesend/ui";
import AddApiKey from './api-keys/add-api-key';
import ApiList from './api-keys/api-list';
import { H1 } from '@usesend/ui';
export default function ApiKeysPage() {
return (
<div>
<div className="flex justify-between items-center">
<div className="flex items-center justify-between">
<H1>API Keys</H1>
<AddApiKey />
</div>

View File

@@ -1,8 +1,8 @@
"use client";
'use client';
import Link from "next/link";
import { usePathname } from "next/navigation";
import React from "react";
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import React from 'react';
export const SettingsNavButton: React.FC<{
href: string;
@@ -15,13 +15,13 @@ export const SettingsNavButton: React.FC<{
if (comingSoon) {
return (
<div className="flex items-center justify-between hover:text-foreground cursor-not-allowed mt-1">
<div className="hover:text-foreground mt-1 flex cursor-not-allowed items-center justify-between">
<div
className={`flex items-center gap-3 rounded-lg px-3 py-2 transition-all hover:text-foreground cursor-not-allowed ${isActive ? " bg-secondary" : "text-muted-foreground"}`}
className={`hover:text-foreground flex cursor-not-allowed items-center gap-3 rounded-lg px-3 py-2 transition-all ${isActive ? 'bg-secondary' : 'text-muted-foreground'}`}
>
{children}
</div>
<div className="text-muted-foreground px-4 py-0.5 text-xs bg-muted rounded-full">
<div className="text-muted-foreground bg-muted rounded-full px-4 py-0.5 text-xs">
soon
</div>
</div>
@@ -31,7 +31,7 @@ export const SettingsNavButton: React.FC<{
return (
<Link
href={href}
className={`flex text-sm items-center mt-1 gap-3 rounded px-2 py-1 transition-all hover:text-foreground ${isActive ? " bg-accent" : "text-muted-foreground"}`}
className={`hover:text-foreground mt-1 flex items-center gap-3 rounded px-2 py-1 text-sm transition-all ${isActive ? 'bg-accent' : 'text-muted-foreground'}`}
>
{children}
</Link>

View File

@@ -1,15 +1,15 @@
import * as React from "react";
import * as React from 'react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@usesend/ui/src/card";
import { TextWithCopyButton } from "@usesend/ui/src/text-with-copy";
import { env } from "~/env";
} from '@usesend/ui/src/card';
import { TextWithCopyButton } from '@usesend/ui/src/text-with-copy';
import { env } from '~/env';
export const dynamic = "force-dynamic";
export const dynamic = 'force-dynamic';
export default function ExampleCard() {
const host = env.SMTP_HOST;
@@ -29,35 +29,35 @@ export default function ExampleCard() {
<div>
<strong>Host:</strong>
<TextWithCopyButton
className="ml-1 border bg-primary/10 rounded-lg mt-1 p-2 w-full "
className="bg-primary/10 ml-1 mt-1 w-full rounded-lg border p-2"
value={host}
></TextWithCopyButton>
</div>
<div>
<strong>Port:</strong>
<TextWithCopyButton
className="ml-1 rounded-lg mt-1 p-2 w-full bg-primary/10 font-mono"
value={"465"}
className="bg-primary/10 ml-1 mt-1 w-full rounded-lg p-2 font-mono"
value={'465'}
></TextWithCopyButton>
<p className="ml-1 mt-1 text-zinc-500 text-sm ">
For encrypted/TLS connections use{" "}
<strong className="font-mono">2465</strong>,{" "}
<strong className="font-mono">587</strong> or{" "}
<p className="ml-1 mt-1 text-sm text-zinc-500">
For encrypted/TLS connections use{' '}
<strong className="font-mono">2465</strong>,{' '}
<strong className="font-mono">587</strong> or{' '}
<strong className="font-mono">2587</strong>
</p>
</div>
<div>
<strong>User:</strong>
<TextWithCopyButton
className="ml-1 rounded-lg mt-1 p-2 w-full bg-primary/10"
className="bg-primary/10 ml-1 mt-1 w-full rounded-lg p-2"
value={user}
></TextWithCopyButton>
</div>
<div>
<strong>Password:</strong>
<TextWithCopyButton
className="ml-1 rounded-lg mt-1 p-2 w-full bg-primary/10"
value={"YOUR_API_KEY"}
className="bg-primary/10 ml-1 mt-1 w-full rounded-lg p-2"
value={'YOUR_API_KEY'}
></TextWithCopyButton>
</div>
</div>

View File

@@ -1,7 +1,7 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
@@ -9,7 +9,7 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
} from '@usesend/ui/src/dialog';
import {
Form,
@@ -19,16 +19,16 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
} from '@usesend/ui/src/form';
import { api } from "~/trpc/react";
import React, { useState } from "react";
import { Domain } from "@prisma/client";
import { useRouter } from "next/navigation";
import { toast } from "@usesend/ui/src/toaster";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { api } from '~/trpc/react';
import React, { useState } from 'react';
import { Domain } from '@prisma/client';
import { useRouter } from 'next/navigation';
import { toast } from '@usesend/ui/src/toaster';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const domainSchema = z.object({
domain: z.string(),
@@ -36,7 +36,7 @@ const domainSchema = z.object({
export const DeleteDomain: React.FC<{ domain: Domain }> = ({ domain }) => {
const [open, setOpen] = useState(false);
const [domainName, setDomainName] = useState("");
const [domainName, setDomainName] = useState('');
const deleteDomainMutation = api.domain.deleteDomain.useMutation();
const domainForm = useForm<z.infer<typeof domainSchema>>({
@@ -49,8 +49,8 @@ export const DeleteDomain: React.FC<{ domain: Domain }> = ({ domain }) => {
async function onDomainDelete(values: z.infer<typeof domainSchema>) {
if (values.domain !== domain.name) {
domainForm.setError("domain", {
message: "Domain name does not match",
domainForm.setError('domain', {
message: 'Domain name does not match',
});
return;
}
@@ -64,7 +64,7 @@ export const DeleteDomain: React.FC<{ domain: Domain }> = ({ domain }) => {
utils.domain.domains.invalidate();
setOpen(false);
toast.success(`Domain ${domain.name} deleted`);
router.replace("/domains");
router.replace('/domains');
},
},
);
@@ -84,8 +84,8 @@ export const DeleteDomain: React.FC<{ domain: Domain }> = ({ domain }) => {
<DialogHeader>
<DialogTitle>Delete domain</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{domain.name}</span>
Are you sure you want to delete{' '}
<span className="text-foreground font-semibold">{domain.name}</span>
? You can't reverse this.
</DialogDescription>
</DialogHeader>
@@ -106,7 +106,7 @@ export const DeleteDomain: React.FC<{ domain: Domain }> = ({ domain }) => {
{formState.errors.domain ? (
<FormMessage />
) : (
<FormDescription className=" text-transparent">
<FormDescription className="text-transparent">
.
</FormDescription>
)}
@@ -119,7 +119,7 @@ export const DeleteDomain: React.FC<{ domain: Domain }> = ({ domain }) => {
variant="destructive"
disabled={deleteDomainMutation.isPending}
>
{deleteDomainMutation.isPending ? "Deleting..." : "Delete"}
{deleteDomainMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</div>
</form>

View File

@@ -1,7 +1,7 @@
"use client";
'use client';
import { api } from "~/trpc/react";
import { Domain, DomainStatus } from "@prisma/client";
import { api } from '~/trpc/react';
import { Domain, DomainStatus } from '@prisma/client';
import {
Breadcrumb,
BreadcrumbItem,
@@ -9,8 +9,8 @@ import {
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@usesend/ui/src/breadcrumb";
import { DomainStatusBadge } from "../domain-badge";
} from '@usesend/ui/src/breadcrumb';
import { DomainStatusBadge } from '../domain-badge';
import {
Table,
TableBody,
@@ -18,16 +18,16 @@ import {
TableHead,
TableHeader,
TableRow,
} from "@usesend/ui/src/table";
import { TextWithCopyButton } from "@usesend/ui/src/text-with-copy";
import React, { use } from "react";
import { Switch } from "@usesend/ui/src/switch";
import DeleteDomain from "./delete-domain";
import SendTestMail from "./send-test-mail";
import { Button } from "@usesend/ui/src/button";
import Link from "next/link";
import { toast } from "@usesend/ui/src/toaster";
import { H1 } from "@usesend/ui";
} from '@usesend/ui/src/table';
import { TextWithCopyButton } from '@usesend/ui/src/text-with-copy';
import React, { use } from 'react';
import { Switch } from '@usesend/ui/src/switch';
import DeleteDomain from './delete-domain';
import SendTestMail from './send-test-mail';
import { Button } from '@usesend/ui/src/button';
import Link from 'next/link';
import { toast } from '@usesend/ui/src/toaster';
import { H1 } from '@usesend/ui';
export default function DomainItemPage({
params,
@@ -65,8 +65,8 @@ export default function DomainItemPage({
<p>Loading...</p>
) : (
<div className="flex flex-col gap-8">
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{/* <div className="flex items-center gap-4">
<H1>{domainQuery.data?.name}</H1>
</div> */}
@@ -81,7 +81,7 @@ export default function DomainItemPage({
</BreadcrumbItem>
<BreadcrumbSeparator className="text-lg" />
<BreadcrumbItem>
<BreadcrumbPage className="text-lg ">
<BreadcrumbPage className="text-lg">
{domainQuery.data?.name}
</BreadcrumbPage>
</BreadcrumbItem>
@@ -98,10 +98,10 @@ export default function DomainItemPage({
<div>
<Button variant="outline" onClick={handleVerify}>
{domainQuery.data?.isVerifying
? "Verifying..."
? 'Verifying...'
: domainQuery.data?.status === DomainStatus.SUCCESS
? "Verify again"
: "Verify domain"}
? 'Verify again'
: 'Verify domain'}
</Button>
</div>
{domainQuery.data ? (
@@ -110,8 +110,8 @@ export default function DomainItemPage({
</div>
</div>
<div className=" border rounded-lg p-4 shadow">
<p className="font-semibold text-xl">DNS records</p>
<div className="rounded-lg border p-4 shadow">
<p className="text-xl font-semibold">DNS records</p>
<Table className="mt-2">
<TableHeader className="">
<TableRow className="">
@@ -128,7 +128,7 @@ export default function DomainItemPage({
<TableCell className="">MX</TableCell>
<TableCell>
<TextWithCopyButton
value={`mail${domainQuery.data?.subdomain ? `.${domainQuery.data.subdomain}` : ""}`}
value={`mail${domainQuery.data?.subdomain ? `.${domainQuery.data.subdomain}` : ''}`}
/>
</TableCell>
<TableCell className="">
@@ -144,7 +144,7 @@ export default function DomainItemPage({
<TableCell className="">10</TableCell>
<TableCell className="">
<DnsVerificationStatus
status={domainQuery.data?.spfDetails ?? "NOT_STARTED"}
status={domainQuery.data?.spfDetails ?? 'NOT_STARTED'}
/>
</TableCell>
</TableRow>
@@ -152,7 +152,7 @@ export default function DomainItemPage({
<TableCell className="">TXT</TableCell>
<TableCell>
<TextWithCopyButton
value={`${domainQuery.data?.dkimSelector ?? "unsend"}._domainkey${domainQuery.data?.subdomain ? `.${domainQuery.data.subdomain}` : ""}`}
value={`${domainQuery.data?.dkimSelector ?? 'unsend'}._domainkey${domainQuery.data?.subdomain ? `.${domainQuery.data.subdomain}` : ''}`}
/>
</TableCell>
<TableCell className="">
@@ -165,7 +165,7 @@ export default function DomainItemPage({
<TableCell className=""></TableCell>
<TableCell className="">
<DnsVerificationStatus
status={domainQuery.data?.dkimStatus ?? "NOT_STARTED"}
status={domainQuery.data?.dkimStatus ?? 'NOT_STARTED'}
/>
</TableCell>
</TableRow>
@@ -173,7 +173,7 @@ export default function DomainItemPage({
<TableCell className="">TXT</TableCell>
<TableCell>
<TextWithCopyButton
value={`mail${domainQuery.data?.subdomain ? `.${domainQuery.data.subdomain}` : ""}`}
value={`mail${domainQuery.data?.subdomain ? `.${domainQuery.data.subdomain}` : ''}`}
/>
</TableCell>
<TableCell className="">
@@ -186,15 +186,15 @@ export default function DomainItemPage({
<TableCell className=""></TableCell>
<TableCell className="">
<DnsVerificationStatus
status={domainQuery.data?.spfDetails ?? "NOT_STARTED"}
status={domainQuery.data?.spfDetails ?? 'NOT_STARTED'}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="">TXT</TableCell>
<TableCell>
<div className="flex gap-2 items-center">
<span className="text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">
(recommended)
</span>
<TextWithCopyButton value="_dmarc" />
@@ -211,7 +211,7 @@ export default function DomainItemPage({
<TableCell className="">
<DnsVerificationStatus
status={
domainQuery.data?.dmarcAdded ? "SUCCESS" : "NOT_STARTED"
domainQuery.data?.dmarcAdded ? 'SUCCESS' : 'NOT_STARTED'
}
/>
</TableCell>
@@ -244,7 +244,7 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
{
onSuccess: () => {
utils.domain.invalidate();
toast.success("Click tracking updated");
toast.success('Click tracking updated');
},
},
);
@@ -257,18 +257,18 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
{
onSuccess: () => {
utils.domain.invalidate();
toast.success("Open tracking updated");
toast.success('Open tracking updated');
},
},
);
}
return (
<div className="rounded-lg shadow p-4 border flex flex-col gap-6">
<p className="font-semibold text-xl">Settings</p>
<div className="flex flex-col gap-6 rounded-lg border p-4 shadow">
<p className="text-xl font-semibold">Settings</p>
<div className="flex flex-col gap-1">
<div className="font-semibold">Click tracking</div>
<p className=" text-muted-foreground text-sm">
Track any links in your emails content.{" "}
<p className="text-muted-foreground text-sm">
Track any links in your emails content.{' '}
</p>
<Switch
checked={clickTracking}
@@ -279,7 +279,7 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
<div className="flex flex-col gap-1">
<div className="font-semibold">Open tracking</div>
<p className=" text-muted-foreground text-sm">
<p className="text-muted-foreground text-sm">
Unsend adds a tracking pixel to every email you send. This allows you
to see how many people open your emails. This will affect the delivery
rate of your emails.
@@ -292,7 +292,7 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
</div>
<div className="flex flex-col gap-2">
<p className="font-semibold text-lg text-destructive">Danger</p>
<p className="text-destructive text-lg font-semibold">Danger</p>
<p className="text-destructive text-sm font-semibold">
Deleting a domain will stop sending emails with this domain.
@@ -304,27 +304,27 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
};
const DnsVerificationStatus: React.FC<{ status: string }> = ({ status }) => {
let badgeColor = "bg-gray/10 text-gray border-gray/10"; // Default color
let badgeColor = 'bg-gray/10 text-gray border-gray/10'; // Default color
switch (status) {
case DomainStatus.SUCCESS:
badgeColor = "bg-green/15 text-green border border-green/25";
badgeColor = 'bg-green/15 text-green border border-green/25';
break;
case DomainStatus.FAILED:
badgeColor = "bg-red/10 text-red border border-red/10";
badgeColor = 'bg-red/10 text-red border border-red/10';
break;
case DomainStatus.TEMPORARY_FAILURE:
case DomainStatus.PENDING:
badgeColor = "bg-yellow/20 text-yellow border border-yellow/10";
badgeColor = 'bg-yellow/20 text-yellow border border-yellow/10';
break;
default:
badgeColor = "bg-gray/10 text-gray border border-gray/20";
badgeColor = 'bg-gray/10 text-gray border border-gray/20';
}
return (
<div
className={` text-xs text-center min-w-[70px] capitalize rounded-md py-1 justify-center flex items-center ${badgeColor}`}
className={`flex min-w-[70px] items-center justify-center rounded-md py-1 text-center text-xs capitalize ${badgeColor}`}
>
{status.split("_").join(" ").toLowerCase()}
{status.split('_').join(' ').toLowerCase()}
</div>
);
};

View File

@@ -1,11 +1,11 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { api } from "~/trpc/react";
import React from "react";
import { Domain } from "@prisma/client";
import { toast } from "@usesend/ui/src/toaster";
import { SendHorizonal } from "lucide-react";
import { Button } from '@usesend/ui/src/button';
import { api } from '~/trpc/react';
import React from 'react';
import { Domain } from '@prisma/client';
import { toast } from '@usesend/ui/src/toaster';
import { SendHorizonal } from 'lucide-react';
// Removed dialog and example code. Clicking the button now sends the email directly.
export const SendTestMail: React.FC<{ domain: Domain }> = ({ domain }) => {
@@ -25,7 +25,7 @@ export const SendTestMail: React.FC<{ domain: Domain }> = ({ domain }) => {
toast.success(`Test email sent`);
},
onError: (err) => {
toast.error(err.message || "Failed to send test email");
toast.error(err.message || 'Failed to send test email');
},
},
);
@@ -36,10 +36,10 @@ export const SendTestMail: React.FC<{ domain: Domain }> = ({ domain }) => {
onClick={handleSendTestEmail}
disabled={sendTestEmailFromDomainMutation.isPending}
>
<SendHorizonal className="h-4 w-4 mr-2" />
<SendHorizonal className="mr-2 h-4 w-4" />
{sendTestEmailFromDomainMutation.isPending
? "Sending email..."
: "Send test email"}
? 'Sending email...'
: 'Send test email'}
</Button>
);
};

View File

@@ -1,14 +1,14 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
} from '@usesend/ui/src/dialog';
import {
Form,
FormControl,
@@ -17,31 +17,31 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
} from '@usesend/ui/src/form';
import { api } from "~/trpc/react";
import { useEffect, useState } from "react";
import { Plus } from "lucide-react";
import { useRouter } from "next/navigation";
import * as tldts from "tldts";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { api } from '~/trpc/react';
import { useEffect, useState } from 'react';
import { Plus } from 'lucide-react';
import { useRouter } from 'next/navigation';
import * as tldts from 'tldts';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@usesend/ui/src/select";
import { toast } from "@usesend/ui/src/toaster";
import { useUpgradeModalStore } from "~/store/upgradeModalStore";
import { LimitReason } from "~/lib/constants/plans";
} from '@usesend/ui/src/select';
import { toast } from '@usesend/ui/src/toaster';
import { useUpgradeModalStore } from '~/store/upgradeModalStore';
import { LimitReason } from '~/lib/constants/plans';
const domainSchema = z.object({
region: z.string().optional(),
domain: z.string({ required_error: "Domain is required" }).min(1, {
message: "Domain is required",
domain: z.string({ required_error: 'Domain is required' }).min(1, {
message: 'Domain is required',
}),
});
@@ -58,8 +58,8 @@ export default function AddDomain() {
const domainForm = useForm<z.infer<typeof domainSchema>>({
resolver: zodResolver(domainSchema),
defaultValues: {
region: "",
domain: "",
region: '',
domain: '',
},
});
@@ -74,16 +74,16 @@ export default function AddDomain() {
async function onDomainAdd(values: z.infer<typeof domainSchema>) {
const domain = tldts.getDomain(values.domain);
if (!domain) {
domainForm.setError("domain", {
message: "Invalid domain",
domainForm.setError('domain', {
message: 'Invalid domain',
});
return;
}
if (!values.region && !singleRegion) {
domainForm.setError("region", {
message: "Region is required",
domainForm.setError('region', {
message: 'Region is required',
});
return;
}
@@ -96,7 +96,7 @@ export default function AddDomain() {
addDomainMutation.mutate(
{
name: values.domain,
region: singleRegion ?? values.region ?? "",
region: singleRegion ?? values.region ?? '',
},
{
onSuccess: async (data) => {
@@ -107,7 +107,7 @@ export default function AddDomain() {
onError: async (error) => {
toast.error(error.message);
},
}
},
);
}
@@ -127,7 +127,7 @@ export default function AddDomain() {
>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-1" />
<Plus className="mr-1 h-4 w-4" />
Add domain
</Button>
</DialogTrigger>
@@ -155,7 +155,7 @@ export default function AddDomain() {
) : (
<FormDescription>
Use subdomains to separate transactional and marketing
emails.{" "}
emails.{' '}
</FormDescription>
)}
</FormItem>
@@ -191,7 +191,7 @@ export default function AddDomain() {
<FormMessage />
) : (
<FormDescription>
Select the region from where the email is sent{" "}
Select the region from where the email is sent{' '}
</FormDescription>
)}
</FormItem>
@@ -201,13 +201,13 @@ export default function AddDomain() {
<div className="flex justify-end">
<Button
className=" w-[100px]"
className="w-[100px]"
type="submit"
disabled={
addDomainMutation.isPending || limitsQuery.isLoading
}
>
{addDomainMutation.isPending ? "Adding..." : "Add"}
{addDomainMutation.isPending ? 'Adding...' : 'Add'}
</Button>
</div>
</form>

View File

@@ -1,30 +1,30 @@
import { DomainStatus } from "@prisma/client";
import { DomainStatus } from '@prisma/client';
export const DomainStatusBadge: React.FC<{ status: DomainStatus }> = ({
status,
}) => {
let badgeColor = "bg-gray/10 text-gray border-gray/10"; // Default color
let badgeColor = 'bg-gray/10 text-gray border-gray/10'; // Default color
switch (status) {
case DomainStatus.SUCCESS:
badgeColor = "bg-green/15 text-green border border-green/25";
badgeColor = 'bg-green/15 text-green border border-green/25';
break;
case DomainStatus.FAILED:
badgeColor = "bg-red/10 text-red border border-red/10";
badgeColor = 'bg-red/10 text-red border border-red/10';
break;
case DomainStatus.TEMPORARY_FAILURE:
case DomainStatus.PENDING:
badgeColor = "bg-yellow/20 text-yellow border border-yellow/10";
badgeColor = 'bg-yellow/20 text-yellow border border-yellow/10';
break;
default:
badgeColor = "bg-gray/70 text-gray border border-gray/20";
badgeColor = 'bg-gray/70 text-gray border border-gray/20';
}
return (
<div
className={` text-center w-[120px] capitalize rounded-md py-1 justify-center flex items-center ${badgeColor}`}
className={`flex w-[120px] items-center justify-center rounded-md py-1 text-center capitalize ${badgeColor}`}
>
<span className="text-xs">
{status === "SUCCESS" ? "Verified" : status.toLowerCase()}
{status === 'SUCCESS' ? 'Verified' : status.toLowerCase()}
</span>
</div>
);

View File

@@ -1,14 +1,14 @@
"use client";
'use client';
import { Domain } from "@prisma/client";
import { formatDistanceToNow } from "date-fns";
import Link from "next/link";
import { Switch } from "@usesend/ui/src/switch";
import { api } from "~/trpc/react";
import React from "react";
import { StatusIndicator } from "./status-indicator";
import { DomainStatusBadge } from "./domain-badge";
import Spinner from "@usesend/ui/src/spinner";
import { Domain } from '@prisma/client';
import { formatDistanceToNow } from 'date-fns';
import Link from 'next/link';
import { Switch } from '@usesend/ui/src/switch';
import { api } from '~/trpc/react';
import React from 'react';
import { StatusIndicator } from './status-indicator';
import { DomainStatusBadge } from './domain-badge';
import Spinner from '@usesend/ui/src/spinner';
export default function DomainsList() {
const domainsQuery = api.domain.domains.useQuery();
@@ -17,9 +17,9 @@ export default function DomainsList() {
<div className="mt-10">
<div className="flex flex-col gap-6">
{domainsQuery.isLoading ? (
<div className="flex justify-center mt-10">
<div className="mt-10 flex justify-center">
<Spinner
className="w-6 h-6 mx-auto"
className="mx-auto h-6 w-6"
innerSvgClass="stroke-primary"
/>
</div>
@@ -28,7 +28,7 @@ export default function DomainsList() {
<DomainItem key={domain.id} domain={domain} />
))
) : (
<div className="text-center mt-20">No domains Added</div>
<div className="mt-20 text-center">No domains Added</div>
)}
</div>
</div>
@@ -40,7 +40,7 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
const utils = api.useUtils();
const [clickTracking, setClickTracking] = React.useState(
domain.clickTracking
domain.clickTracking,
);
const [openTracking, setOpenTracking] = React.useState(domain.openTracking);
@@ -52,7 +52,7 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
onSuccess: () => {
utils.domain.domains.invalidate();
},
}
},
);
}
@@ -64,19 +64,19 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
onSuccess: () => {
utils.domain.domains.invalidate();
},
}
},
);
}
return (
<div key={domain.id}>
<div className=" pr-8 border rounded-lg flex items-stretch shadow">
<div className="flex items-stretch rounded-lg border pr-8 shadow">
<StatusIndicator status={domain.status} />
<div className="flex justify-between w-full pl-8 py-4">
<div className="flex flex-col gap-4 w-1/5">
<div className="flex w-full justify-between py-4 pl-8">
<div className="flex w-1/5 flex-col gap-4">
<Link
href={`/domains/${domain.id}`}
className="text-lg font-medium underline underline-offset-4 decoration-dashed"
className="text-lg font-medium underline decoration-dashed underline-offset-4"
>
{domain.name}
</Link>
@@ -85,7 +85,7 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
<div className="flex flex-col gap-4">
<div>
<p className="text-sm text-muted-foreground">Created at</p>
<p className="text-muted-foreground text-sm">Created at</p>
<p className="text-sm">
{formatDistanceToNow(new Date(domain.createdAt), {
addSuffix: true,
@@ -93,13 +93,13 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Region</p>
<p className="text-muted-foreground text-sm">Region</p>
<p className="text-sm flex items-center gap-2">{domain.region}</p>
<p className="flex items-center gap-2 text-sm">{domain.region}</p>
</div>
</div>
<div className="flex flex-col gap-6">
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<p className="text-sm">Click tracking</p>
<Switch
checked={clickTracking}
@@ -107,7 +107,7 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
className="data-[state=checked]:bg-success"
/>
</div>
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<p className="text-sm">Open tracking</p>
<Switch
checked={openTracking}

View File

@@ -1,13 +1,13 @@
"use client";
'use client';
import DomainsList from "./domain-list";
import AddDomain from "./add-domain";
import { H1 } from "@usesend/ui";
import DomainsList from './domain-list';
import AddDomain from './add-domain';
import { H1 } from '@usesend/ui';
export default function DomainsPage() {
return (
<div>
<div className="flex justify-between items-center">
<div className="flex items-center justify-between">
<H1>Domains</H1>
<AddDomain />
</div>

View File

@@ -1,26 +1,26 @@
import { DomainStatus } from "@prisma/client";
import { DomainStatus } from '@prisma/client';
export const StatusIndicator: React.FC<{ status: DomainStatus }> = ({
status,
}) => {
let badgeColor = "bg-gray"; // Default color
let badgeColor = 'bg-gray'; // Default color
switch (status) {
case DomainStatus.NOT_STARTED:
badgeColor = "bg-gray";
badgeColor = 'bg-gray';
break;
case DomainStatus.SUCCESS:
badgeColor = "bg-green";
badgeColor = 'bg-green';
break;
case DomainStatus.FAILED:
badgeColor = "bg-red";
badgeColor = 'bg-red';
break;
case DomainStatus.TEMPORARY_FAILURE:
case DomainStatus.PENDING:
badgeColor = "bg-yellow";
badgeColor = 'bg-yellow';
break;
default:
badgeColor = "bg-gray";
badgeColor = 'bg-gray';
}
return <div className={` w-[2px] ${badgeColor} my-1.5 rounded-full`}></div>;
return <div className={`w-[2px] ${badgeColor} my-1.5 rounded-full`}></div>;
};

View File

@@ -1,7 +1,7 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
@@ -9,14 +9,14 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import { api } from "~/trpc/react";
import React, { useState } from "react";
import { toast } from "@usesend/ui/src/toaster";
import { Trash2 } from "lucide-react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
} from '@usesend/ui/src/dialog';
import { api } from '~/trpc/react';
import React, { useState } from 'react';
import { toast } from '@usesend/ui/src/toaster';
import { Trash2 } from 'lucide-react';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Form,
FormControl,
@@ -25,7 +25,7 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
} from '@usesend/ui/src/form';
const cancelSchema = z.object({
confirmation: z.string(),
@@ -44,9 +44,9 @@ export const CancelEmail: React.FC<{
});
async function onEmailCancel(values: z.infer<typeof cancelSchema>) {
if (values.confirmation !== "cancel") {
cancelForm.setError("confirmation", {
message: "Confirmation does not match",
if (values.confirmation !== 'cancel') {
cancelForm.setError('confirmation', {
message: 'Confirmation does not match',
});
return;
}
@@ -68,7 +68,7 @@ export const CancelEmail: React.FC<{
);
}
const confirmation = cancelForm.watch("confirmation");
const confirmation = cancelForm.watch('confirmation');
return (
<Dialog
@@ -77,7 +77,7 @@ export const CancelEmail: React.FC<{
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4 text-red" />
<Trash2 className="text-red h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
@@ -118,12 +118,12 @@ export const CancelEmail: React.FC<{
type="submit"
variant="destructive"
disabled={
cancelEmailMutation.isPending || confirmation !== "cancel"
cancelEmailMutation.isPending || confirmation !== 'cancel'
}
>
{cancelEmailMutation.isPending
? "Cancelling..."
: "Cancel Email"}
? 'Cancelling...'
: 'Cancel Email'}
</Button>
</div>
</form>

View File

@@ -1,27 +1,27 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import * as chrono from "chrono-node";
import { api } from "~/trpc/react";
import { useRef, useState } from "react";
import { Edit3 } from "lucide-react";
import { useRouter } from "next/navigation";
import { toast } from "@usesend/ui/src/toaster";
} from '@usesend/ui/src/dialog';
import * as chrono from 'chrono-node';
import { api } from '~/trpc/react';
import { useRef, useState } from 'react';
import { Edit3 } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { toast } from '@usesend/ui/src/toaster';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSubContent,
DropdownMenuTrigger,
} from "@usesend/ui/src/dropdown-menu";
} from '@usesend/ui/src/dropdown-menu';
import {
Command,
CommandDialog,
@@ -31,7 +31,7 @@ import {
CommandItem,
CommandList,
CommandSeparator,
} from "@usesend/ui/src/command";
} from '@usesend/ui/src/command';
export const EditSchedule: React.FC<{
emailId: string;
@@ -39,9 +39,9 @@ export const EditSchedule: React.FC<{
}> = ({ emailId, scheduledAt }) => {
const [open, setOpen] = useState(false);
const [openSuggestions, setOpenSuggestions] = useState(true);
const [scheduleInput, setScheduleInput] = useState(scheduledAt || "");
const [scheduleInput, setScheduleInput] = useState(scheduledAt || '');
const [scheduledAtTime, setScheduledAtTime] = useState<Date | null>(
scheduledAt ? new Date(scheduledAt) : null
scheduledAt ? new Date(scheduledAt) : null,
);
const updateEmailScheduledAtMutation =
api.email.updateEmailScheduledAt.useMutation();
@@ -53,7 +53,7 @@ export const EditSchedule: React.FC<{
const handleScheduleUpdate = () => {
const parsedDate = chrono.parseDate(scheduleInput);
if (!parsedDate) {
toast.error("Invalid date and time");
toast.error('Invalid date and time');
return;
}
@@ -66,12 +66,12 @@ export const EditSchedule: React.FC<{
onSuccess: () => {
utils.email.getEmail.invalidate({ id: emailId });
setOpen(false);
toast.success("Email schedule updated successfully");
toast.success('Email schedule updated successfully');
},
onError: (error) => {
toast.error(error.message);
},
}
},
);
};
@@ -100,7 +100,7 @@ export const EditSchedule: React.FC<{
<div className="py-2">
<div className="space-y-4">
<div>
<label htmlFor="scheduleInput" className="block mb-2">
<label htmlFor="scheduleInput" className="mb-2 block">
Schedule at
</label>
{/* <Input
@@ -155,8 +155,8 @@ export const EditSchedule: React.FC<{
disabled={updateEmailScheduledAtMutation.isPending}
>
{updateEmailScheduledAtMutation.isPending
? "Updating..."
: "Update"}
? 'Updating...'
: 'Update'}
</Button>
</div>
</div>

View File

@@ -1,42 +1,42 @@
"use client";
'use client';
import { UAParser } from "ua-parser-js";
import { api } from "~/trpc/react";
import { Separator } from "@usesend/ui/src/separator";
import { EmailStatusBadge, EmailStatusIcon } from "./email-status-badge";
import { formatDate } from "date-fns";
import { motion } from "framer-motion";
import { EmailStatus } from "@prisma/client";
import { JsonValue } from "@prisma/client/runtime/library";
import { UAParser } from 'ua-parser-js';
import { api } from '~/trpc/react';
import { Separator } from '@usesend/ui/src/separator';
import { EmailStatusBadge, EmailStatusIcon } from './email-status-badge';
import { formatDate } from 'date-fns';
import { motion } from 'framer-motion';
import { EmailStatus } from '@prisma/client';
import { JsonValue } from '@prisma/client/runtime/library';
import {
SesBounce,
SesClick,
SesComplaint,
SesDeliveryDelay,
SesOpen,
} from "~/types/aws-types";
} from '~/types/aws-types';
import {
BOUNCE_ERROR_MESSAGES,
COMPLAINT_ERROR_MESSAGES,
DELIVERY_DELAY_ERRORS,
} from "~/lib/constants/ses-errors";
import CancelEmail from "./cancel-email";
import { useEffect } from "react";
import { useState } from "react";
} from '~/lib/constants/ses-errors';
import CancelEmail from './cancel-email';
import { useEffect } from 'react';
import { useState } from 'react';
export default function EmailDetails({ emailId }: { emailId: string }) {
const emailQuery = api.email.getEmail.useQuery({ id: emailId });
return (
<div className="h-full overflow-auto px-4 no-scrollbar">
<div className="flex justify-between items-center">
<div className="flex gap-4 items-center">
<div className="no-scrollbar h-full overflow-auto px-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<h1 className="font-bold">{emailQuery.data?.to}</h1>
<EmailStatusBadge status={emailQuery.data?.latestStatus ?? "SENT"} />
<EmailStatusBadge status={emailQuery.data?.latestStatus ?? 'SENT'} />
</div>
</div>
<div className="flex flex-col mt-8 items-start gap-8">
<div className="p-2 rounded-lg border flex flex-col gap-2 w-full shadow">
<div className="mt-8 flex flex-col items-start gap-8">
<div className="flex w-full flex-col gap-2 rounded-lg border p-2 shadow">
{/* <div className="flex gap-2">
<span className="w-[100px] text-muted-foreground text-sm">
From
@@ -59,23 +59,23 @@ export default function EmailDetails({ emailId }: { emailId: string }) {
{/* <div className=" text-[15px] font-medium">
{emailQuery.data?.to}
</div> */}
<div className=" text-sm">Subject: {emailQuery.data?.subject}</div>
<div className="text-sm">Subject: {emailQuery.data?.subject}</div>
<div className="text-muted-foreground text-xs">
From: {emailQuery.data?.from}
</div>
</div>
{emailQuery.data?.latestStatus === "SCHEDULED" &&
{emailQuery.data?.latestStatus === 'SCHEDULED' &&
emailQuery.data?.scheduledAt ? (
<>
<Separator />
<div className="flex gap-2 items-center px-4">
<span className="w-[100px] text-muted-foreground text-sm ">
<div className="flex items-center gap-2 px-4">
<span className="text-muted-foreground w-[100px] text-sm">
Scheduled at
</span>
<span className="text-sm">
{formatDate(
emailQuery.data?.scheduledAt,
"MMM dd'th', hh:mm a"
"MMM dd'th', hh:mm a",
)}
</span>
<div className="ml-4">
@@ -90,32 +90,32 @@ export default function EmailDetails({ emailId }: { emailId: string }) {
animate={{ opacity: 1 }}
transition={{ duration: 0.2, delay: 0.3 }}
>
<EmailPreview html={emailQuery.data?.html ?? ""} />
<EmailPreview html={emailQuery.data?.html ?? ''} />
</motion.div>
</div>
{emailQuery.data?.latestStatus !== "SCHEDULED" ? (
<div className=" border rounded-lg w-full shadow mb-2 ">
<div className=" p-4 flex flex-col gap-8 w-full">
{emailQuery.data?.latestStatus !== 'SCHEDULED' ? (
<div className="mb-2 w-full rounded-lg border shadow">
<div className="flex w-full flex-col gap-8 p-4">
<div className="font-medium">Events History</div>
<div className="flex items-stretch px-4 w-full">
<div className="border-r border-gray-300 dark:border-gray-700 border-dashed" />
<div className="flex flex-col gap-12 w-full">
<div className="flex w-full items-stretch px-4">
<div className="border-r border-dashed border-gray-300 dark:border-gray-700" />
<div className="flex w-full flex-col gap-12">
{emailQuery.data?.emailEvents.map((evt) => (
<div
key={evt.status}
className="flex gap-5 items-start w-full"
className="flex w-full items-start gap-5"
>
<div className=" -ml-2.5">
<div className="-ml-2.5">
<EmailStatusIcon status={evt.status} />
</div>
<div className="-mt-[0.125rem] w-full">
<div className=" capitalize font-medium">
<div className="font-medium capitalize">
<EmailStatusBadge status={evt.status} />
</div>
<div className="text-xs text-muted-foreground mt-2">
{formatDate(evt.createdAt, "MMM dd, hh:mm a")}
<div className="text-muted-foreground mt-2 text-xs">
{formatDate(evt.createdAt, 'MMM dd, hh:mm a')}
</div>
<div className="mt-1 text-foreground/80">
<div className="text-foreground/80 mt-1">
<EmailStatusText
status={evt.status}
data={evt.data}
@@ -147,14 +147,14 @@ const EmailPreview = ({ html }: { html: string }) => {
if (!show) {
return (
<div className="dark:bg-slate-200 h-[350px] overflow-visible rounded border-t"></div>
<div className="h-[350px] overflow-visible rounded border-t dark:bg-slate-200"></div>
);
}
return (
<div className="dark:bg-slate-200 h-[350px] overflow-visible rounded border-t">
<div className="h-[350px] overflow-visible rounded border-t dark:bg-slate-200">
<iframe
className="w-full h-full"
className="h-full w-full"
srcDoc={html}
sandbox="allow-same-origin"
/>
@@ -169,106 +169,106 @@ const EmailStatusText = ({
status: EmailStatus;
data: JsonValue;
}) => {
if (status === "SENT") {
if (status === 'SENT') {
return (
<div>
We received your request and sent the email to recipient's server.
</div>
);
} else if (status === "DELIVERED") {
} else if (status === 'DELIVERED') {
return <div>Mail is successfully delivered to the recipient.</div>;
} else if (status === "DELIVERY_DELAYED") {
} else if (status === 'DELIVERY_DELAYED') {
const _errorData = data as unknown as SesDeliveryDelay;
const errorMessage = DELIVERY_DELAY_ERRORS[_errorData.delayType];
return <div>{errorMessage}</div>;
} else if (status === "BOUNCED") {
} else if (status === 'BOUNCED') {
const _errorData = data as unknown as SesBounce;
_errorData.bounceType;
return (
<div className="flex flex-col gap-4 w-full">
<div className="flex w-full flex-col gap-4">
<p>{getErrorMessage(_errorData)}</p>
<div className="rounded-xl p-4 bg-muted/30 flex flex-col gap-4">
<div className="flex gap-2 w-full">
<div className="bg-muted/30 flex flex-col gap-4 rounded-xl p-4">
<div className="flex w-full gap-2">
<div className="w-1/2">
<p className="text-sm text-muted-foreground">Type</p>
<p className="text-muted-foreground text-sm">Type</p>
<p>{_errorData.bounceType}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Sub Type</p>
<p className="text-muted-foreground text-sm">Sub Type</p>
<p>{_errorData.bounceSubType}</p>
</div>
</div>
<div>
<p className="text-sm text-muted-foreground">SMTP response</p>
<p className="text-muted-foreground text-sm">SMTP response</p>
<p>{_errorData.bouncedRecipients[0]?.diagnosticCode}</p>
</div>
</div>
</div>
);
} else if (status === "FAILED") {
} else if (status === 'FAILED') {
const _errorData = data as unknown as { error: string };
return <div>{_errorData.error}</div>;
} else if (status === "OPENED") {
} else if (status === 'OPENED') {
const _data = data as unknown as SesOpen;
const userAgent = getUserAgent(_data.userAgent);
return (
<div className="w-full rounded-xl p-4 bg-muted/30 mt-4">
<div className="flex w-full ">
<div className="bg-muted/30 mt-4 w-full rounded-xl p-4">
<div className="flex w-full">
{userAgent.os.name ? (
<div className="w-1/2">
<p className="text-sm text-muted-foreground">OS</p>
<p className="text-muted-foreground text-sm">OS</p>
<p>{userAgent.os.name}</p>
</div>
) : null}
{userAgent.browser.name ? (
<div>
<p className="text-sm text-muted-foreground">Browser</p>
<p className="text-muted-foreground text-sm">Browser</p>
<p>{userAgent.browser.name}</p>
</div>
) : null}
</div>
</div>
);
} else if (status === "CLICKED") {
} else if (status === 'CLICKED') {
const _data = data as unknown as SesClick;
const userAgent = getUserAgent(_data.userAgent);
return (
<div className="w-full mt-4 flex flex-col gap-4 rounded-xl p-4 bg-muted/30">
<div className="flex w-full ">
<div className="bg-muted/30 mt-4 flex w-full flex-col gap-4 rounded-xl p-4">
<div className="flex w-full">
{userAgent.os.name ? (
<div className="w-1/2">
<p className="text-sm text-muted-foreground">OS </p>
<p className="text-muted-foreground text-sm">OS </p>
<p>{userAgent.os.name}</p>
</div>
) : null}
{userAgent.browser.name ? (
<div>
<p className="text-sm text-muted-foreground">Browser </p>
<p className="text-muted-foreground text-sm">Browser </p>
<p>{userAgent.browser.name}</p>
</div>
) : null}
</div>
<div className="w-full">
<p className="text-sm text-muted-foreground">URL</p>
<p className="text-muted-foreground text-sm">URL</p>
<p>{_data.link}</p>
</div>
</div>
);
} else if (status === "COMPLAINED") {
} else if (status === 'COMPLAINED') {
const _errorData = data as unknown as SesComplaint;
return (
<div className="flex flex-col gap-4 w-full">
<div className="flex w-full flex-col gap-4">
<p>{getComplaintMessage(_errorData.complaintFeedbackType)}</p>
</div>
);
} else if (status === "CANCELLED") {
} else if (status === 'CANCELLED') {
return <div>This scheduled email was cancelled</div>;
} else if (status === "SUPPRESSED") {
} else if (status === 'SUPPRESSED') {
return (
<div>
This email was suppressed because this email is previously either
@@ -281,24 +281,24 @@ const EmailStatusText = ({
};
const getErrorMessage = (data: SesBounce) => {
if (data.bounceType === "Permanent") {
if (data.bounceType === 'Permanent') {
return BOUNCE_ERROR_MESSAGES[data.bounceType][
data.bounceSubType as
| "General"
| "NoEmail"
| "Suppressed"
| "OnAccountSuppressionList"
| 'General'
| 'NoEmail'
| 'Suppressed'
| 'OnAccountSuppressionList'
];
} else if (data.bounceType === "Transient") {
} else if (data.bounceType === 'Transient') {
return BOUNCE_ERROR_MESSAGES[data.bounceType][
data.bounceSubType as
| "General"
| "MailboxFull"
| "MessageTooLarge"
| "ContentRejected"
| "AttachmentRejected"
| 'General'
| 'MailboxFull'
| 'MessageTooLarge'
| 'ContentRejected'
| 'AttachmentRejected'
];
} else if (data.bounceType === "Undetermined") {
} else if (data.bounceType === 'Undetermined') {
return BOUNCE_ERROR_MESSAGES.Undetermined;
}
};

View File

@@ -1,4 +1,4 @@
"use client";
'use client';
import {
Table,
@@ -7,8 +7,8 @@ import {
TableHead,
TableBody,
TableCell,
} from "@usesend/ui/src/table";
import { api } from "~/trpc/react";
} from '@usesend/ui/src/table';
import { api } from '~/trpc/react';
import {
Mail,
MailCheck,
@@ -17,51 +17,51 @@ import {
MailWarning,
MailX,
Download,
} from "lucide-react";
import { formatDate, formatDistanceToNow } from "date-fns";
import { EmailStatus } from "@prisma/client";
import { EmailStatusBadge } from "./email-status-badge";
import EmailDetails from "./email-details";
import dynamic from "next/dynamic";
import { useUrlState } from "~/hooks/useUrlState";
import { Button } from "@usesend/ui/src/button";
} from 'lucide-react';
import { formatDate, formatDistanceToNow } from 'date-fns';
import { EmailStatus } from '@prisma/client';
import { EmailStatusBadge } from './email-status-badge';
import EmailDetails from './email-details';
import dynamic from 'next/dynamic';
import { useUrlState } from '~/hooks/useUrlState';
import { Button } from '@usesend/ui/src/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@usesend/ui/src/select";
import Spinner from "@usesend/ui/src/spinner";
} from '@usesend/ui/src/select';
import Spinner from '@usesend/ui/src/spinner';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@usesend/ui/src/tooltip";
import { Input } from "@usesend/ui/src/input";
import { DEFAULT_QUERY_LIMIT } from "~/lib/constants";
import { useDebouncedCallback } from "use-debounce";
import { useState } from "react";
import { SheetTitle, SheetDescription } from "@usesend/ui/src/sheet";
} from '@usesend/ui/src/tooltip';
import { Input } from '@usesend/ui/src/input';
import { DEFAULT_QUERY_LIMIT } from '~/lib/constants';
import { useDebouncedCallback } from 'use-debounce';
import { useState } from 'react';
import { SheetTitle, SheetDescription } from '@usesend/ui/src/sheet';
/* Stupid hydrating error. And I so stupid to understand the stupid NextJS docs */
const DynamicSheetWithNoSSR = dynamic(
() => import("@usesend/ui/src/sheet").then((mod) => mod.Sheet),
() => import('@usesend/ui/src/sheet').then((mod) => mod.Sheet),
{ ssr: false },
);
const DynamicSheetContentWithNoSSR = dynamic(
() => import("@usesend/ui/src/sheet").then((mod) => mod.SheetContent),
() => import('@usesend/ui/src/sheet').then((mod) => mod.SheetContent),
{ ssr: false },
);
export default function EmailsList() {
const [selectedEmail, setSelectedEmail] = useUrlState("emailId");
const [page, setPage] = useUrlState("page", "1");
const [status, setStatus] = useUrlState("status");
const [search, setSearch] = useUrlState("search");
const [domain, setDomain] = useUrlState("domain");
const [apiKey, setApiKey] = useUrlState("apikey");
const [selectedEmail, setSelectedEmail] = useUrlState('emailId');
const [page, setPage] = useUrlState('page', '1');
const [status, setStatus] = useUrlState('status');
const [search, setSearch] = useUrlState('search');
const [domain, setDomain] = useUrlState('domain');
const [apiKey, setApiKey] = useUrlState('apikey');
const pageNumber = Number(page);
const domainId = domain ? Number(domain) : undefined;
@@ -93,11 +93,11 @@ export default function EmailsList() {
};
const handleDomain = (val: string) => {
setDomain(val === "All Domains" ? null : val);
setDomain(val === 'All Domains' ? null : val);
};
const handleApiKey = (val: string) => {
setApiKey(val === "All API Keys" ? null : val);
setApiKey(val === 'All API Keys' ? null : val);
};
const handleSheetChange = (isOpen: boolean) => {
@@ -116,21 +116,21 @@ export default function EmailsList() {
if (!resp.data) return;
const escape = (val: unknown) => {
const s = String(val ?? "");
const s = String(val ?? '');
const startsRisky = /^\s*[=+\-@]/.test(s);
const safe = (startsRisky ? "'" : "") + s.replace(/"/g, '""');
const safe = (startsRisky ? "'" : '') + s.replace(/"/g, '""');
return /[",\r\n]/.test(safe) ? `"${safe}"` : safe;
};
const header = [
"To",
"Status",
"Subject",
"Sent At",
"Bounce Type",
"Bounce Subtype",
"Bounce Reason",
].join(",");
'To',
'Status',
'Subject',
'Sent At',
'Bounce Type',
'Bounce Subtype',
'Bounce Reason',
].join(',');
const rows = resp.data.map((e) =>
[
e.to,
@@ -142,45 +142,45 @@ export default function EmailsList() {
e.bounceReason,
]
.map(escape)
.join(","),
.join(','),
);
const csv = [header, ...rows].join("\n");
const csv = [header, ...rows].join('\n');
const blob = new Blob(["\uFEFF" + csv], {
type: "text/csv;charset=utf-8",
const blob = new Blob(['\uFEFF' + csv], {
type: 'text/csv;charset=utf-8',
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
const a = document.createElement('a');
a.href = url;
a.download = `emails-${new Date().toISOString().split("T")[0]}.csv`;
a.download = `emails-${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (err) {
console.error("Export failed", err);
console.error('Export failed', err);
}
};
return (
<div className="mt-10 flex flex-col gap-4">
<div className="flex justify-between items-center">
<div className="flex items-center justify-between">
<Input
placeholder="Search by subject or email"
className="w-[350px] mr-4"
defaultValue={search ?? ""}
className="mr-4 w-[350px]"
defaultValue={search ?? ''}
onChange={(e) => debouncedSearch(e.target.value)}
/>
<div className="flex justify-center items-center gap-x-3">
<div className="flex items-center justify-center gap-x-3">
<Select
value={apiKey ?? "All API Keys"}
value={apiKey ?? 'All API Keys'}
onValueChange={(val) => handleApiKey(val)}
>
<SelectTrigger className="w-[180px]">
{apiKey
? apiKeysQuery?.find((apikey) => apikey.id === Number(apiKey))
?.name
: "All API Keys"}
: 'All API Keys'}
</SelectTrigger>
<SelectContent>
<SelectItem value="All API Keys">All API Keys</SelectItem>
@@ -193,16 +193,16 @@ export default function EmailsList() {
</SelectContent>
</Select>
<Select
value={domain ?? "All Domains"}
value={domain ?? 'All Domains'}
onValueChange={(val) => handleDomain(val)}
>
<SelectTrigger className="w-[180px]">
{domain
? domainsQuery?.find((d) => d.id === Number(domain))?.name
: "All Domains"}
: 'All Domains'}
</SelectTrigger>
<SelectContent>
<SelectItem value="All Domains" className=" capitalize">
<SelectItem value="All Domains" className="capitalize">
All Domains
</SelectItem>
{domainsQuery &&
@@ -214,32 +214,32 @@ export default function EmailsList() {
</SelectContent>
</Select>
<Select
value={status ?? "All statuses"}
value={status ?? 'All statuses'}
onValueChange={(val) =>
setStatus(val === "All statuses" ? null : val)
setStatus(val === 'All statuses' ? null : val)
}
>
<SelectTrigger className="w-[180px] capitalize">
{status ? status.toLowerCase().replace("_", " ") : "All statuses"}
{status ? status.toLowerCase().replace('_', ' ') : 'All statuses'}
</SelectTrigger>
<SelectContent>
<SelectItem value="All statuses" className=" capitalize">
<SelectItem value="All statuses" className="capitalize">
All statuses
</SelectItem>
{Object.values([
"SENT",
"SCHEDULED",
"QUEUED",
"DELIVERED",
"BOUNCED",
"CLICKED",
"OPENED",
"DELIVERY_DELAYED",
"COMPLAINED",
"SUPPRESSED",
'SENT',
'SCHEDULED',
'QUEUED',
'DELIVERED',
'BOUNCED',
'CLICKED',
'OPENED',
'DELIVERY_DELAYED',
'COMPLAINED',
'SUPPRESSED',
]).map((status) => (
<SelectItem key={status} value={status} className=" capitalize">
{status.toLowerCase().replace("_", " ")}
<SelectItem key={status} value={status} className="capitalize">
{status.toLowerCase().replace('_', ' ')}
</SelectItem>
))}
</SelectContent>
@@ -249,7 +249,7 @@ export default function EmailsList() {
onClick={handleExport}
disabled={exportQuery.isFetching}
>
<Download className="h-4 w-4 mr-2" />
<Download className="mr-2 h-4 w-4" />
Export
</Button>
</div>
@@ -257,11 +257,11 @@ export default function EmailsList() {
<div className="flex flex-col rounded-xl border shadow">
<Table className="">
<TableHeader className="">
<TableRow className=" bg-muted dark:bg-muted/70">
<TableRow className="bg-muted dark:bg-muted/70">
<TableHead className="rounded-tl-xl">To</TableHead>
<TableHead>Status</TableHead>
<TableHead>Subject</TableHead>
<TableHead className="text-right rounded-tr-xl">
<TableHead className="rounded-tr-xl text-right">
Sent at
</TableHead>
</TableRow>
@@ -269,9 +269,9 @@ export default function EmailsList() {
<TableBody>
{emailsQuery.isLoading ? (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
<TableCell colSpan={4} className="py-4 text-center">
<Spinner
className="w-6 h-6 mx-auto"
className="mx-auto h-6 w-6"
innerSvgClass="stroke-primary"
/>
</TableCell>
@@ -281,25 +281,25 @@ export default function EmailsList() {
<TableRow
key={email.id}
onClick={() => handleSelectEmail(email.id)}
className=" cursor-pointer"
className="cursor-pointer"
>
<TableCell className="font-medium">
<div className="flex gap-4 items-center">
<div className="flex items-center gap-4">
{/* <EmailIcon status={email.latestStatus ?? "Sent"} /> */}
<p> {email.to}</p>
</div>
</TableCell>
<TableCell>
{email.latestStatus === "SCHEDULED" && email.scheduledAt ? (
{email.latestStatus === 'SCHEDULED' && email.scheduledAt ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<EmailStatusBadge
status={email.latestStatus ?? "Sent"}
status={email.latestStatus ?? 'Sent'}
/>
</TooltipTrigger>
<TooltipContent>
Scheduled at{" "}
Scheduled at{' '}
{formatDate(
email.scheduledAt,
"MMM dd'th', hh:mm a",
@@ -308,25 +308,25 @@ export default function EmailsList() {
</Tooltip>
</TooltipProvider>
) : (
<EmailStatusBadge status={email.latestStatus ?? "Sent"} />
<EmailStatusBadge status={email.latestStatus ?? 'Sent'} />
)}
</TableCell>
<TableCell className="">
<div className=" max-w-xs truncate">{email.subject}</div>
<div className="max-w-xs truncate">{email.subject}</div>
</TableCell>
<TableCell className="text-right">
{email.latestStatus !== "SCHEDULED"
{email.latestStatus !== 'SCHEDULED'
? formatDate(
email.scheduledAt ?? email.createdAt,
"MMM do, hh:mm a",
'MMM do, hh:mm a',
)
: "--"}
: '--'}
</TableCell>
</TableRow>
))
) : (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
<TableCell colSpan={4} className="py-4 text-center">
No emails found
</TableCell>
</TableRow>
@@ -338,7 +338,7 @@ export default function EmailsList() {
open={!!selectedEmail}
onOpenChange={handleSheetChange}
>
<DynamicSheetContentWithNoSSR className="sm:max-w-3xl overflow-y-auto no-scrollbar">
<DynamicSheetContentWithNoSSR className="no-scrollbar overflow-y-auto sm:max-w-3xl">
<SheetTitle className="sr-only">Email Details</SheetTitle>
<SheetDescription className="sr-only">
Detailed view of the selected email.
@@ -347,7 +347,7 @@ export default function EmailsList() {
</DynamicSheetContentWithNoSSR>
</DynamicSheetWithNoSSR>
</div>
<div className="flex gap-4 justify-end">
<div className="flex justify-end gap-4">
<Button
size="sm"
onClick={() => setPage((pageNumber - 1).toString())}
@@ -369,48 +369,48 @@ export default function EmailsList() {
const EmailIcon: React.FC<{ status: EmailStatus }> = ({ status }) => {
switch (status) {
case "SENT":
case 'SENT':
return (
// <div className="border border-gray-400/60 p-2 rounded-lg bg-gray-400/10">
<Mail className="w-6 h-6 text-gray" />
<Mail className="text-gray h-6 w-6" />
// </div>
);
case "DELIVERED":
case 'DELIVERED':
return (
// <div className="border border-emerald-600/60 p-2 rounded-lg bg-emerald-500/10">
<MailCheck className="w-6 h-6 text-green" />
<MailCheck className="text-green h-6 w-6" />
// </div>
);
case "BOUNCED":
case "FAILED":
case 'BOUNCED':
case 'FAILED':
return (
// <div className="border border-red-600/60 p-2 rounded-lg bg-red-500/10">
<MailX className="w-6 h-6 text-red" />
<MailX className="text-red h-6 w-6" />
// </div>
);
case "CLICKED":
case 'CLICKED':
return (
// <div className="border border-cyan-600/60 p-2 rounded-lg bg-cyan-500/10">
<MailSearch className="w-6 h-6 text-blue" />
<MailSearch className="text-blue h-6 w-6" />
// </div>
);
case "OPENED":
case 'OPENED':
return (
// <div className="border border-indigo-600/60 p-2 rounded-lg bg-indigo-500/10">
<MailOpen className="w-6 h-6 text-purple" />
<MailOpen className="text-purple h-6 w-6" />
// </div>
);
case "DELIVERY_DELAYED":
case "COMPLAINED":
case 'DELIVERY_DELAYED':
case 'COMPLAINED':
return (
// <div className="border border-yellow-600/60 p-2 rounded-lg bg-yellow-500/10">
<MailWarning className="w-6 h-6 text-yellow" />
<MailWarning className="text-yellow h-6 w-6" />
// </div>
);
default:
return (
// <div className="border border-gray-400/60 p-2 rounded-lg">
<Mail className="w-6 h-6" />
<Mail className="h-6 w-6" />
// </div>
);
}

View File

@@ -1,39 +1,39 @@
import { EmailStatus } from "@prisma/client";
import { EmailStatus } from '@prisma/client';
export const EmailStatusBadge: React.FC<{ status: EmailStatus }> = ({
status,
}) => {
let badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10"; // Default color
let badgeColor = 'bg-gray-700/10 text-gray-400 border border-gray-400/10'; // Default color
switch (status) {
case "DELIVERED":
badgeColor = "bg-green/15 text-green border border-green/20";
case 'DELIVERED':
badgeColor = 'bg-green/15 text-green border border-green/20';
break;
case "BOUNCED":
case "FAILED":
badgeColor = "bg-red/15 text-red border border-red/20";
case 'BOUNCED':
case 'FAILED':
badgeColor = 'bg-red/15 text-red border border-red/20';
break;
case "CLICKED":
badgeColor = "bg-blue/15 text-blue border border-blue/20";
case 'CLICKED':
badgeColor = 'bg-blue/15 text-blue border border-blue/20';
break;
case "OPENED":
badgeColor = "bg-purple/15 text-purple border border-purple/20";
case 'OPENED':
badgeColor = 'bg-purple/15 text-purple border border-purple/20';
break;
case "COMPLAINED":
badgeColor = "bg-yellow/15 text-yellow border border-yellow/20";
case 'COMPLAINED':
badgeColor = 'bg-yellow/15 text-yellow border border-yellow/20';
break;
case "DELIVERY_DELAYED":
badgeColor = "bg-yellow/15 text-yellow border border-yellow/20";
case 'DELIVERY_DELAYED':
badgeColor = 'bg-yellow/15 text-yellow border border-yellow/20';
break;
default:
badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10"; // Default color
badgeColor = 'bg-gray-700/10 text-gray-400 border border-gray-400/10'; // Default color
}
return (
<div
className={` text-center w-[130px] rounded capitalize py-1 text-xs ${badgeColor}`}
className={`w-[130px] rounded py-1 text-center text-xs capitalize ${badgeColor}`}
>
{status.toLowerCase().split("_").join(" ")}
{status.toLowerCase().split('_').join(' ')}
</div>
);
};
@@ -41,44 +41,44 @@ export const EmailStatusBadge: React.FC<{ status: EmailStatus }> = ({
export const EmailStatusIcon: React.FC<{ status: EmailStatus }> = ({
status,
}) => {
let outsideColor = "bg-gray/30"; // Default
let insideColor = "bg-gray"; // Default
let outsideColor = 'bg-gray/30'; // Default
let insideColor = 'bg-gray'; // Default
switch (status) {
case "DELIVERED":
outsideColor = "bg-green/30";
insideColor = "bg-green";
case 'DELIVERED':
outsideColor = 'bg-green/30';
insideColor = 'bg-green';
break;
case "BOUNCED":
case "FAILED":
outsideColor = "bg-red/30";
insideColor = "bg-red";
case 'BOUNCED':
case 'FAILED':
outsideColor = 'bg-red/30';
insideColor = 'bg-red';
break;
case "CLICKED":
outsideColor = "bg-blue/30";
insideColor = "bg-blue";
case 'CLICKED':
outsideColor = 'bg-blue/30';
insideColor = 'bg-blue';
break;
case "OPENED":
outsideColor = "bg-purple/30";
insideColor = "bg-purple";
case 'OPENED':
outsideColor = 'bg-purple/30';
insideColor = 'bg-purple';
break;
case "DELIVERY_DELAYED":
outsideColor = "bg-yellow/30";
insideColor = "bg-yellow";
case 'DELIVERY_DELAYED':
outsideColor = 'bg-yellow/30';
insideColor = 'bg-yellow';
break;
case "COMPLAINED":
outsideColor = "bg-yellow/30";
insideColor = "bg-yellow";
case 'COMPLAINED':
outsideColor = 'bg-yellow/30';
insideColor = 'bg-yellow';
break;
default:
// Using the default values defined above
outsideColor = "bg-gray/30";
insideColor = "bg-gray";
outsideColor = 'bg-gray/30';
insideColor = 'bg-gray';
}
return (
<div
className={`flex justify-center items-center p-1.5 ${outsideColor} rounded-full`}
className={`flex items-center justify-center p-1.5 ${outsideColor} rounded-full`}
>
<div className={`h-2 w-2 rounded-full ${insideColor}`}></div>
</div>

View File

@@ -1,12 +1,12 @@
"use client";
'use client';
import EmailList from "./email-list";
import { H1 } from "@usesend/ui";
import EmailList from './email-list';
import { H1 } from '@usesend/ui';
export default function EmailsPage() {
return (
<div>
<div className="flex justify-between items-center">
<div className="flex items-center justify-between">
<H1>Emails</H1>
</div>
<EmailList />

View File

@@ -1,8 +1,8 @@
import { DashboardProvider } from "~/providers/dashboard-provider";
import { NextAuthProvider } from "~/providers/next-auth";
import { DashboardLayout } from "./dasboard-layout";
import { DashboardProvider } from '~/providers/dashboard-provider';
import { NextAuthProvider } from '~/providers/next-auth';
import { DashboardLayout } from './dasboard-layout';
export const dynamic = "force-static";
export const dynamic = 'force-static';
export default function AuthenticatedDashboardLayout({
children,

View File

@@ -1,22 +1,22 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import Spinner from "@usesend/ui/src/spinner";
import { CheckCircle2 } from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { api } from "~/trpc/react";
import { H1 } from "@usesend/ui";
import { Button } from '@usesend/ui/src/button';
import Spinner from '@usesend/ui/src/spinner';
import { CheckCircle2 } from 'lucide-react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { api } from '~/trpc/react';
import { H1 } from '@usesend/ui';
export default function PaymentsPage() {
const searchParams = useSearchParams();
const success = searchParams.get("success");
const canceled = searchParams.get("canceled");
const success = searchParams.get('success');
const canceled = searchParams.get('canceled');
return (
<div className="container mx-auto py-10">
<H1>Payment {success ? "Success" : canceled ? "Canceled" : "Unknown"}</H1>
<H1>Payment {success ? 'Success' : canceled ? 'Canceled' : 'Unknown'}</H1>
{canceled ? (
<Link href="/settings/billing">
<Button>Go to billing</Button>
@@ -32,11 +32,11 @@ function VerifySuccess() {
refetchInterval: 3000,
});
if (teams?.[0]?.plan !== "FREE") {
if (teams?.[0]?.plan !== 'FREE') {
return (
<div>
<div className="flex gap-2 items-center">
<CheckCircle2 className="h-4 w-4 text-green flex-shrink-0" />
<div className="flex items-center gap-2">
<CheckCircle2 className="text-green h-4 w-4 flex-shrink-0" />
<p>Your account has been upgraded to the paid plan.</p>
</div>
<Link href="/settings/billing" className="mt-8">
@@ -47,9 +47,9 @@ function VerifySuccess() {
}
return (
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<Spinner
className="h-5 w-5 stroke-muted-foreground"
className="stroke-muted-foreground h-5 w-5"
innerSvgClass=" stroke-muted-foreground"
/>
<p className="text-muted-foreground">Verifying payment</p>

View File

@@ -1,14 +1,14 @@
"use client";
'use client';
import { useState } from "react";
import { Button } from "@usesend/ui/src/button";
import { Card } from "@usesend/ui/src/card";
import { Spinner } from "@usesend/ui/src/spinner";
import { format } from "date-fns";
import { useTeam } from "~/providers/team-context";
import { api } from "~/trpc/react";
import { PlanDetails } from "~/components/payments/PlanDetails";
import { UpgradeButton } from "~/components/payments/UpgradeButton";
import { useState } from 'react';
import { Button } from '@usesend/ui/src/button';
import { Card } from '@usesend/ui/src/card';
import { Spinner } from '@usesend/ui/src/spinner';
import { format } from 'date-fns';
import { useTeam } from '~/providers/team-context';
import { api } from '~/trpc/react';
import { PlanDetails } from '~/components/payments/PlanDetails';
import { UpgradeButton } from '~/components/payments/UpgradeButton';
export default function SettingsPage() {
const { currentTeam, currentIsAdmin } = useTeam();
@@ -19,7 +19,7 @@ export default function SettingsPage() {
const { data: subscription } = api.billing.getSubscriptionDetails.useQuery();
const [isEditingEmail, setIsEditingEmail] = useState(false);
const [billingEmail, setBillingEmail] = useState(
currentTeam?.billingEmail || "",
currentTeam?.billingEmail || '',
);
const apiUtils = api.useUtils();
@@ -32,7 +32,7 @@ export default function SettingsPage() {
};
const handleEditEmail = () => {
setBillingEmail(currentTeam?.billingEmail || "");
setBillingEmail(currentTeam?.billingEmail || '');
setIsEditingEmail(true);
};
@@ -42,12 +42,12 @@ export default function SettingsPage() {
await apiUtils.team.getTeams.invalidate();
setIsEditingEmail(false);
} catch (error) {
console.error("Failed to update billing email:", error);
console.error('Failed to update billing email:', error);
}
};
const paymentMethod =
subscription?.paymentMethod && subscription.paymentMethod !== "null"
subscription?.paymentMethod && subscription.paymentMethod !== 'null'
? JSON.parse(subscription.paymentMethod)
: {};
@@ -57,27 +57,27 @@ export default function SettingsPage() {
if (!currentTeam?.plan) {
return (
<div className="flex justify-center items-center h-full">
<Spinner className="w-4 h-4" />
<div className="flex h-full items-center justify-center">
<Spinner className="h-4 w-4" />
</div>
);
}
return (
<div className="space-y-8">
<Card className=" rounded-xl mt-10 p-8 px-8">
<Card className="mt-10 rounded-xl p-8 px-8">
<PlanDetails />
<div className="mt-4">
{currentTeam?.plan !== "FREE" ? (
{currentTeam?.plan !== 'FREE' ? (
<Button
onClick={onManageClick}
className="mt-4 w-[120px]"
disabled={manageSessionUrl.isPending}
>
{manageSessionUrl.isPending ? (
<Spinner className="w-4 h-4" />
<Spinner className="h-4 w-4" />
) : (
"Manage"
'Manage'
)}
</Button>
) : (
@@ -85,44 +85,44 @@ export default function SettingsPage() {
)}
</div>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mt-8">
<div className="mt-8 grid grid-cols-1 gap-8 md:grid-cols-2">
<Card className="p-6">
<div>
<div className="text-sm text-muted-foreground">Payment Method</div>
<div className="text-muted-foreground text-sm">Payment Method</div>
{subscription ? (
<div className="mt-2">
<div className="text-lg font-mono uppercase flex items-center gap-2">
<div className="flex items-center gap-2 font-mono text-lg uppercase">
{subscription.paymentMethod &&
subscription.paymentMethod !== "null" ? (
subscription.paymentMethod !== 'null' ? (
<>
<span>💳</span>
<span className="capitalize">
{paymentMethod?.card?.brand || ""} {" "}
{paymentMethod?.card?.last4 || ""}
{paymentMethod?.card?.brand || ''} {' '}
{paymentMethod?.card?.last4 || ''}
</span>
{paymentMethod?.card && (
<span className="text-sm text-muted-foreground lowercase">
<span className="text-muted-foreground text-sm lowercase">
(Expires: {paymentMethod.card.exp_month}/
{paymentMethod.card.exp_year})
</span>
)}
</>
) : (
"No Payment Method"
'No Payment Method'
)}
</div>
<div className="text-sm text-muted-foreground mt-1">
Next billing date:{" "}
<div className="text-muted-foreground mt-1 text-sm">
Next billing date:{' '}
{subscription.currentPeriodEnd
? format(
new Date(subscription.currentPeriodEnd),
"MMM dd, yyyy",
'MMM dd, yyyy',
)
: "N/A"}
: 'N/A'}
</div>
</div>
) : (
<div className="text-sm text-muted-foreground mt-2">
<div className="text-muted-foreground mt-2 text-sm">
No active subscription
</div>
)}
@@ -131,7 +131,7 @@ export default function SettingsPage() {
<Card className="p-6">
<div>
<div className="text-sm text-muted-foreground">Billing Email</div>
<div className="text-muted-foreground text-sm">Billing Email</div>
{isEditingEmail ? (
<div className="mt-2">
<div className="flex items-center gap-2">
@@ -139,7 +139,7 @@ export default function SettingsPage() {
type="email"
value={billingEmail}
onChange={(e) => setBillingEmail(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Enter billing email"
/>
<Button
@@ -148,9 +148,9 @@ export default function SettingsPage() {
size="sm"
>
{updateBillingEmailMutation.isPending ? (
<Spinner className="w-4 h-4" />
<Spinner className="h-4 w-4" />
) : (
"Save"
'Save'
)}
</Button>
<Button
@@ -166,7 +166,7 @@ export default function SettingsPage() {
<div className="mt-2">
<div className="flex items-center gap-2">
<div className="font-mono">
{currentTeam?.billingEmail || "No billing email set"}
{currentTeam?.billingEmail || 'No billing email set'}
</div>
<Button onClick={handleEditEmail} variant="default" size="sm">
Edit

View File

@@ -1,10 +1,10 @@
"use client";
'use client';
import { useTeam } from "~/providers/team-context";
import { SettingsNavButton } from "../dev-settings/settings-nav-button";
import { isCloud } from "~/utils/common";
import { useTeam } from '~/providers/team-context';
import { SettingsNavButton } from '../dev-settings/settings-nav-button';
import { isCloud } from '~/utils/common';
export const dynamic = "force-static";
export const dynamic = 'force-static';
export default function ApiKeysPage({
children,
@@ -15,8 +15,8 @@ export default function ApiKeysPage({
return (
<div>
<h1 className="font-bold text-lg">Settings</h1>
<div className="flex gap-4 mt-4">
<h1 className="text-lg font-bold">Settings</h1>
<div className="mt-4 flex gap-4">
{isCloud() ? (
<SettingsNavButton href="/settings">Usage</SettingsNavButton>
) : null}

View File

@@ -1,16 +1,16 @@
"use client";
'use client';
import { isCloud } from "~/utils/common";
import UsagePage from "./usage/usage";
import InviteTeamMember from "./team/invite-team-member";
import TeamMembersList from "./team/team-members-list";
import { isCloud } from '~/utils/common';
import UsagePage from './usage/usage';
import InviteTeamMember from './team/invite-team-member';
import TeamMembersList from './team/team-members-list';
export default function SettingsPage() {
if (!isCloud()) {
return (
<div>
<div>
<div className="flex justify-end ">
<div className="flex justify-end">
<InviteTeamMember />
</div>
<TeamMembersList />

View File

@@ -1,6 +1,6 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Button } from '@usesend/ui/src/button';
import {
Dialog,
DialogContent,
@@ -8,11 +8,11 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import { api } from "~/trpc/react";
import { useState } from "react";
import { toast } from "@usesend/ui/src/toaster";
import { Trash2 } from "lucide-react";
} from '@usesend/ui/src/dialog';
import { api } from '~/trpc/react';
import { useState } from 'react';
import { toast } from '@usesend/ui/src/toaster';
import { Trash2 } from 'lucide-react';
export const DeleteTeamInvite: React.FC<{
invite: { id: string; email: string };
@@ -31,7 +31,7 @@ export const DeleteTeamInvite: React.FC<{
onSuccess: async () => {
utils.team.getTeamInvites.invalidate();
setOpen(false);
toast.success("Invite cancelled successfully");
toast.success('Invite cancelled successfully');
},
onError: async (error) => {
toast.error(error.message);
@@ -47,21 +47,21 @@ export const DeleteTeamInvite: React.FC<{
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4 text-red/80" />
<Trash2 className="text-red/80 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Cancel Invite</DialogTitle>
<DialogDescription>
Are you sure you want to cancel the invite for{" "}
<span className="font-semibold text-foreground">
Are you sure you want to cancel the invite for{' '}
<span className="text-foreground font-semibold">
{invite.email}
</span>
?
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-4 mt-6">
<div className="mt-6 flex justify-end gap-4">
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>

View File

@@ -1,6 +1,6 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Button } from '@usesend/ui/src/button';
import {
Dialog,
DialogContent,
@@ -8,12 +8,12 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import { api } from "~/trpc/react";
import { useState } from "react";
import { toast } from "@usesend/ui/src/toaster";
import { Role } from "@prisma/client";
import { LogOut, Trash2 } from "lucide-react";
} from '@usesend/ui/src/dialog';
import { api } from '~/trpc/react';
import { useState } from 'react';
import { toast } from '@usesend/ui/src/toaster';
import { Role } from '@prisma/client';
import { LogOut, Trash2 } from 'lucide-react';
export const DeleteTeamMember: React.FC<{
teamUser: { userId: string; role: Role; email: string };
@@ -33,7 +33,7 @@ export const DeleteTeamMember: React.FC<{
onSuccess: async () => {
utils.team.getTeamUsers.invalidate();
setOpen(false);
toast.success("Team member removed successfully");
toast.success('Team member removed successfully');
},
onError: async (error) => {
toast.error(error.message);
@@ -50,24 +50,24 @@ export const DeleteTeamMember: React.FC<{
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
{self ? (
<LogOut className="h-4 w-4 text-red/80" />
<LogOut className="text-red/80 h-4 w-4" />
) : (
<Trash2 className="h-4 w-4 text-red/80" />
<Trash2 className="text-red/80 h-4 w-4" />
)}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{self ? "Leave Team" : "Remove Team Member"}
{self ? 'Leave Team' : 'Remove Team Member'}
</DialogTitle>
<DialogDescription>
{self
? "Are you sure you want to leave the team? This action cannot be undone."
? 'Are you sure you want to leave the team? This action cannot be undone.'
: `Are you sure you want to remove ${teamUser.email} from the team? This action cannot be undone.`}
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-4 mt-6">
<div className="mt-6 flex justify-end gap-4">
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
@@ -77,7 +77,7 @@ export const DeleteTeamMember: React.FC<{
isLoading={deleteTeamUserMutation.isPending}
className="w-[150px]"
>
{self ? "Leave" : "Remove"}
{self ? 'Leave' : 'Remove'}
</Button>
</div>
</DialogContent>

View File

@@ -1,13 +1,13 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Button } from '@usesend/ui/src/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
} from '@usesend/ui/src/dialog';
import {
Form,
FormControl,
@@ -15,26 +15,26 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
} from '@usesend/ui/src/form';
import { api } from "~/trpc/react";
import { useState } from "react";
import { PencilIcon } from "lucide-react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@usesend/ui/src/toaster";
import { Role } from "@prisma/client";
import { api } from '~/trpc/react';
import { useState } from 'react';
import { PencilIcon } from 'lucide-react';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from '@usesend/ui/src/toaster';
import { Role } from '@prisma/client';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@usesend/ui/src/select";
} from '@usesend/ui/src/select';
const teamUserSchema = z.object({
role: z.enum(["MEMBER", "ADMIN"]),
role: z.enum(['MEMBER', 'ADMIN']),
});
export const EditTeamMember: React.FC<{
@@ -62,12 +62,12 @@ export const EditTeamMember: React.FC<{
onSuccess: async () => {
utils.team.getTeamUsers.invalidate();
setOpen(false);
toast.success("Team member role updated successfully");
toast.success('Team member role updated successfully');
},
onError: async (error) => {
toast.error(error.message);
},
}
},
);
}

View File

@@ -1,28 +1,28 @@
"use client";
'use client';
import { useState } from "react";
import { Button } from "@usesend/ui/src/button";
import { PlusIcon } from "lucide-react";
import { useState } from 'react';
import { Button } from '@usesend/ui/src/button';
import { PlusIcon } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
} from '@usesend/ui/src/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@usesend/ui/src/select";
import { Input } from "@usesend/ui/src/input";
import { useForm } from "react-hook-form";
import { api } from "~/trpc/react";
import { toast } from "@usesend/ui/src/toaster";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
} from '@usesend/ui/src/select';
import { Input } from '@usesend/ui/src/input';
import { useForm } from 'react-hook-form';
import { api } from '~/trpc/react';
import { toast } from '@usesend/ui/src/toaster';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Form,
FormControl,
@@ -31,18 +31,18 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { useTeam } from "~/providers/team-context";
import { isCloud, isSelfHosted } from "~/utils/common";
import { useUpgradeModalStore } from "~/store/upgradeModalStore";
import { LimitReason } from "~/lib/constants/plans";
} from '@usesend/ui/src/form';
import { useTeam } from '~/providers/team-context';
import { isCloud, isSelfHosted } from '~/utils/common';
import { useUpgradeModalStore } from '~/store/upgradeModalStore';
import { LimitReason } from '~/lib/constants/plans';
const inviteTeamMemberSchema = z.object({
email: z
.string({ required_error: "Email is required" })
.email("Invalid email address"),
role: z.enum(["ADMIN", "MEMBER"], {
required_error: "Please select a role",
.string({ required_error: 'Email is required' })
.email('Invalid email address'),
role: z.enum(['ADMIN', 'MEMBER'], {
required_error: 'Please select a role',
}),
});
@@ -62,8 +62,8 @@ export default function InviteTeamMember() {
const form = useForm<FormData>({
resolver: zodResolver(inviteTeamMemberSchema),
defaultValues: {
email: "",
role: "MEMBER",
email: '',
role: 'MEMBER',
},
});
@@ -88,11 +88,11 @@ export default function InviteTeamMember() {
form.reset();
setOpen(false);
void utils.team.getTeamInvites.invalidate();
toast.success("Invitation sent successfully");
toast.success('Invitation sent successfully');
},
onError: (error) => {
console.error(error);
toast.error(error.message || "Failed to send invitation");
toast.error(error.message || 'Failed to send invitation');
},
},
);
@@ -106,8 +106,8 @@ export default function InviteTeamMember() {
createInvite.mutate(
{
email: form.getValues("email"),
role: form.getValues("role"),
email: form.getValues('email'),
role: form.getValues('role'),
sendEmail: false,
},
{
@@ -118,11 +118,11 @@ export default function InviteTeamMember() {
);
form.reset();
setOpen(false);
toast.success("Invitation link copied to clipboard");
toast.success('Invitation link copied to clipboard');
},
onError: (error) => {
console.error(error);
toast.error(error.message || "Failed to copy invitation link");
toast.error(error.message || 'Failed to copy invitation link');
},
},
);
@@ -152,7 +152,7 @@ export default function InviteTeamMember() {
Invite Member
</Button>
</DialogTrigger>
<DialogContent className=" max-w-lg">
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Invite Team Member</DialogTitle>
</DialogHeader>
@@ -197,13 +197,13 @@ export default function InviteTeamMember() {
<SelectContent>
<SelectItem value="ADMIN">
<div>Admin</div>
<div className="text-xs text-muted-foreground">
<div className="text-muted-foreground text-xs">
Manage users, update payments
</div>
</SelectItem>
<SelectItem value="MEMBER">
<div>Member</div>
<div className="text-xs text-muted-foreground">
<div className="text-muted-foreground text-xs">
Manage emails, domains and contacts
</div>
</SelectItem>
@@ -214,8 +214,8 @@ export default function InviteTeamMember() {
)}
/>
{isSelfHosted() && domains?.length ? (
<div className="text-sm text-muted-foreground">
Will use{" "}
<div className="text-muted-foreground text-sm">
Will use{' '}
<span className="font-bold">hello@{domains[0]?.name}</span> to
send invitation
</div>

View File

@@ -1,12 +1,12 @@
"use client";
'use client';
import InviteTeamMember from "./invite-team-member";
import TeamMembersList from "./team-members-list";
import InviteTeamMember from './invite-team-member';
import TeamMembersList from './team-members-list';
export default function TeamsPage() {
return (
<div>
<div className="flex justify-end ">
<div className="flex justify-end">
<InviteTeamMember />
</div>
<TeamMembersList />

View File

@@ -1,16 +1,16 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { api } from "~/trpc/react";
import { toast } from "@usesend/ui/src/toaster";
import { Copy, RotateCw } from "lucide-react";
import { Button } from '@usesend/ui/src/button';
import { api } from '~/trpc/react';
import { toast } from '@usesend/ui/src/toaster';
import { Copy, RotateCw } from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@usesend/ui/src/tooltip";
import { isSelfHosted } from "~/utils/common";
} from '@usesend/ui/src/tooltip';
import { isSelfHosted } from '~/utils/common';
export const ResendTeamInvite: React.FC<{
invite: { id: string; email: string };
@@ -29,7 +29,7 @@ export const ResendTeamInvite: React.FC<{
onError: async (error) => {
toast.error(error.message);
},
}
},
);
}
@@ -54,7 +54,7 @@ export const ResendTeamInvite: React.FC<{
size="sm"
onClick={() => {
navigator.clipboard.writeText(
`${location.origin}/join-team?inviteId=${invite.id}`
`${location.origin}/join-team?inviteId=${invite.id}`,
);
toast.success(`Invite link copied to clipboard`);
}}

View File

@@ -1,4 +1,4 @@
"use client";
'use client';
import {
Table,
@@ -7,18 +7,18 @@ import {
TableHead,
TableBody,
TableCell,
} from "@usesend/ui/src/table";
import { api } from "~/trpc/react";
import { Button } from "@usesend/ui/src/button";
import Spinner from "@usesend/ui/src/spinner";
import { formatDistanceToNow } from "date-fns";
import { Role } from "@prisma/client";
import { EditTeamMember } from "./edit-team-member";
import { DeleteTeamMember } from "./delete-team-member";
import { ResendTeamInvite } from "./resend-team-invite";
import { DeleteTeamInvite } from "./delete-team-invite";
import { useTeam } from "~/providers/team-context";
import { useSession } from "next-auth/react";
} from '@usesend/ui/src/table';
import { api } from '~/trpc/react';
import { Button } from '@usesend/ui/src/button';
import Spinner from '@usesend/ui/src/spinner';
import { formatDistanceToNow } from 'date-fns';
import { Role } from '@prisma/client';
import { EditTeamMember } from './edit-team-member';
import { DeleteTeamMember } from './delete-team-member';
import { ResendTeamInvite } from './resend-team-invite';
import { DeleteTeamInvite } from './delete-team-invite';
import { useTeam } from '~/providers/team-context';
import { useSession } from 'next-auth/react';
export default function TeamMembersList() {
const { currentIsAdmin } = useTeam();
@@ -34,7 +34,7 @@ export default function TeamMembersList() {
return (
<div className="mt-10 flex flex-col gap-4">
<div className="flex flex-col rounded-xl border border-border shadow">
<div className="border-border flex flex-col rounded-xl border shadow">
<Table>
<TableHeader>
<TableRow className="bg-muted/30">
@@ -48,9 +48,9 @@ export default function TeamMembersList() {
<TableBody>
{isLoading ? (
<TableRow className="h-32">
<TableCell colSpan={5} className="text-center py-4">
<TableCell colSpan={5} className="py-4 text-center">
<Spinner
className="w-6 h-6 mx-auto"
className="mx-auto h-6 w-6"
innerSvgClass="stroke-primary"
/>
</TableCell>
@@ -59,15 +59,15 @@ export default function TeamMembersList() {
teamMembers.map((member) => (
<TableRow key={member.userId} className="">
<TableCell className="font-medium">
{member.user?.email || "Unknown user"}
{member.user?.email || 'Unknown user'}
</TableCell>
<TableCell>
<div className=" rounded capitalize py-1 text-xs">
<div className="rounded py-1 text-xs capitalize">
{member.role.toLowerCase()}
</div>
</TableCell>
<TableCell>
<div className="text-center w-[100px] rounded capitalize py-1 text-xs bg-green/15 text-green border border-green/25">
<div className="bg-green/15 text-green border-green/25 w-[100px] rounded border py-1 text-center text-xs capitalize">
Active
</div>
</TableCell>
@@ -91,7 +91,7 @@ export default function TeamMembersList() {
teamUser={{
userId: String(member.userId),
role: member.role,
email: member.user?.email || "Unknown user",
email: member.user?.email || 'Unknown user',
}}
self={session?.user.id == member.userId}
/>
@@ -102,7 +102,7 @@ export default function TeamMembersList() {
))
) : (
<TableRow className="h-32">
<TableCell colSpan={5} className="text-center py-4">
<TableCell colSpan={5} className="py-4 text-center">
No team members found
</TableCell>
</TableRow>
@@ -117,12 +117,12 @@ export default function TeamMembersList() {
{invite.email}
</TableCell>
<TableCell>
<div className=" w-[100px] rounded capitalize py-1 text-xs">
<div className="w-[100px] rounded py-1 text-xs capitalize">
{invite.role.toLowerCase()}
</div>
</TableCell>
<TableCell>
<div className="text-center w-[100px] rounded capitalize py-1 text-xs bg-yellow/15 text-yellow border border-yellow/25">
<div className="bg-yellow/15 text-yellow border-yellow/25 w-[100px] rounded border py-1 text-center text-xs capitalize">
Pending
</div>
</TableCell>

View File

@@ -1,20 +1,20 @@
"use client";
'use client';
import { api } from "~/trpc/react";
import { Card } from "@usesend/ui/src/card";
import Spinner from "@usesend/ui/src/spinner";
import { format } from "date-fns";
import { api } from '~/trpc/react';
import { Card } from '@usesend/ui/src/card';
import Spinner from '@usesend/ui/src/spinner';
import { format } from 'date-fns';
import {
getCost,
PLAN_CREDIT_UNITS,
UNIT_PRICE,
USAGE_UNIT_PRICE,
} from "~/lib/usage";
import { useTeam } from "~/providers/team-context";
import { EmailUsageType } from "@prisma/client";
import { PlanDetails } from "~/components/payments/PlanDetails";
import { UpgradeButton } from "~/components/payments/UpgradeButton";
import { Progress } from "@usesend/ui/src/progress";
} from '~/lib/usage';
import { useTeam } from '~/providers/team-context';
import { EmailUsageType } from '@prisma/client';
import { PlanDetails } from '~/components/payments/PlanDetails';
import { UpgradeButton } from '~/components/payments/UpgradeButton';
import { Progress } from '@usesend/ui/src/progress';
const FREE_PLAN_LIMIT = 3000;
@@ -36,20 +36,20 @@ function FreePlanUsage({
return (
<Card className="p-6">
<div className="flex w-full">
<div className="space-y-4 w-full">
<div className="w-full space-y-4">
{usage?.map((item) => (
<div
key={item.type}
className="flex justify-between items-center border-b pb-3 last:border-0 last:pb-0"
className="flex items-center justify-between border-b pb-3 last:border-0 last:pb-0"
>
<div>
<div className="font-medium capitalize">
{item.type.toLowerCase()}
</div>
<div className="text-sm text-muted-foreground mt-1">
{item.type === "TRANSACTIONAL"
? "Mails sent using the send api or SMTP"
: "Mails designed sent from useSend editor"}
<div className="text-muted-foreground mt-1 text-sm">
{item.type === 'TRANSACTIONAL'
? 'Mails sent using the send api or SMTP'
: 'Mails designed sent from useSend editor'}
</div>
</div>
<div className="font-mono font-medium">
@@ -57,30 +57,30 @@ function FreePlanUsage({
</div>
</div>
))}
<div className="flex justify-between items-center pt-3 ">
<div className="flex items-center justify-between pt-3">
<div className="font-medium">Total</div>
<div className="font-mono font-medium">
{usage
?.reduce((acc, item) => acc + item.sent, 0)
.toLocaleString()}{" "}
.toLocaleString()}{' '}
emails
</div>
</div>
</div>
<div className="w-full flex justify-center items-center">
<div className="flex w-full items-center justify-center">
<div className="w-[300px] space-y-8">
<div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<div className="flex items-center justify-between">
<div className="">Monthly Limit</div>
<div className="font-mono font-medium">
{totalSent.toLocaleString()}/
{FREE_PLAN_LIMIT.toLocaleString()}
</div>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div className="bg-secondary h-2 overflow-hidden rounded-full">
<div
className="h-full bg-primary transition-all duration-300 ease-in-out"
className="bg-primary h-full transition-all duration-300 ease-in-out"
style={{
width: `${Math.min(monthlyPercentageUsed, 100)}%`,
}}
@@ -91,15 +91,15 @@ function FreePlanUsage({
<div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<div className="flex items-center justify-between">
<div className="">Daily Limit</div>
<div className="font-mono">
{dailyUsage.toLocaleString()}/{DAILY_LIMIT.toLocaleString()}
</div>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div className="bg-secondary h-2 overflow-hidden rounded-full">
<div
className="h-full bg-primary transition-all duration-300 ease-in-out"
className="bg-primary h-full transition-all duration-300 ease-in-out"
style={{ width: `${Math.min(dailyPercentageUsed, 100)}%` }}
/>
</div>
@@ -119,7 +119,7 @@ function PaidPlanUsage({
}) {
const { currentTeam } = useTeam();
if (currentTeam?.plan === "FREE") return null;
if (currentTeam?.plan === 'FREE') return null;
const totalCost =
usage?.reduce((acc, item) => acc + getCost(item.sent, item.type), 0) || 0;
@@ -128,24 +128,24 @@ function PaidPlanUsage({
return (
<Card className="p-6">
<div className="flex w-full">
<div className="space-y-4 w-full">
<div className="w-full space-y-4">
{usage?.map((item) => (
<div
key={item.type}
className="flex justify-between items-center border-b pb-3 last:border-0 last:pb-0"
className="flex items-center justify-between border-b pb-3 last:border-0 last:pb-0"
>
<div>
<div className="font-medium capitalize">
{item.type.toLowerCase()}
</div>
<div className="text-sm text-muted-foreground mt-1">
<div className="text-muted-foreground mt-1 text-sm">
<span className="font-mono">
{item.sent.toLocaleString()}
</span>{" "}
emails at{" "}
</span>{' '}
emails at{' '}
<span className="font-mono">
${USAGE_UNIT_PRICE[item.type]}
</span>{" "}
</span>{' '}
each
</div>
</div>
@@ -155,16 +155,16 @@ function PaidPlanUsage({
</div>
))}
<div>
<div className="flex justify-between items-center border-b pb-3 last:border-0 last:pb-0">
<div className="flex items-center justify-between border-b pb-3 last:border-0 last:pb-0">
<div>
<div className="font-medium capitalize">Available credit</div>
<div className="text-sm text-muted-foreground mt-1">
<div className="text-muted-foreground mt-1 text-sm">
{currentTeam?.plan}
</div>
</div>
<div className="font-mono font-medium">
{totalCost > planCreditCost
? "0"
? '0'
: `$${(planCreditCost - totalCost).toFixed(2)}`}
</div>
</div>
@@ -173,11 +173,11 @@ function PaidPlanUsage({
/>
</div>
</div>
<div className="w-full flex justify-center items-center">
<div className="flex w-full items-center justify-center">
<div>
<div className="font-medium">Amount Due</div>
<div className="">
<div className="text-2xl font-mono">
<div className="font-mono text-2xl">
{planCreditCost < totalCost
? `$${(totalCost - planCreditCost).toFixed(2)}`
: `$${(0.0).toFixed(2)}`}
@@ -200,8 +200,8 @@ export default function UsagePage() {
const today = new Date();
const billingPeriod =
subscription?.currentPeriodStart && subscription?.currentPeriodEnd
? `${format(new Date(subscription.currentPeriodStart), "MMM dd")} - ${format(new Date(subscription.currentPeriodEnd), "MMM dd")}`
: `${format(new Date(today.getFullYear(), today.getMonth(), 1), "MMM dd")} - ${format(new Date(today.getFullYear(), today.getMonth() + 1, 1), "MMM dd")}`;
? `${format(new Date(subscription.currentPeriodStart), 'MMM dd')} - ${format(new Date(subscription.currentPeriodEnd), 'MMM dd')}`
: `${format(new Date(today.getFullYear(), today.getMonth(), 1), 'MMM dd')} - ${format(new Date(today.getFullYear(), today.getMonth() + 1, 1), 'MMM dd')}`;
return (
<div>
@@ -209,7 +209,7 @@ export default function UsagePage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Usage</h1>
<div className="text-sm text-muted-foreground mt-1">
<div className="text-muted-foreground mt-1 text-sm">
<span className="font-medium">{billingPeriod}</span>
</div>
</div>
@@ -217,13 +217,13 @@ export default function UsagePage() {
{isLoading ? (
<div className="flex justify-center py-8">
<Spinner className="w-8 h-8" innerSvgClass="stroke-primary" />
<Spinner className="h-8 w-8" innerSvgClass="stroke-primary" />
</div>
) : usage?.month.length === 0 ? (
<Card className="p-6 text-center text-muted-foreground">
<Card className="text-muted-foreground p-6 text-center">
No usage data available
</Card>
) : currentTeam?.plan === "FREE" ? (
) : currentTeam?.plan === 'FREE' ? (
<FreePlanUsage
usage={usage?.month ?? []}
dayUsage={usage?.day ?? []}
@@ -233,10 +233,10 @@ export default function UsagePage() {
)}
</div>
{currentTeam?.plan ? (
<Card className=" rounded-xl mt-10 p-4 px-8">
<Card className="mt-10 rounded-xl p-4 px-8">
<PlanDetails />
<div className="mt-4">
{currentTeam?.plan === "FREE" ? <UpgradeButton /> : null}
{currentTeam?.plan === 'FREE' ? <UpgradeButton /> : null}
</div>
</Card>
) : null}

View File

@@ -1,8 +1,8 @@
"use client";
'use client';
import { useState } from "react";
import { api } from "~/trpc/react";
import { SuppressionReason } from "@prisma/client";
import { useState } from 'react';
import { api } from '~/trpc/react';
import { SuppressionReason } from '@prisma/client';
import {
Dialog,
DialogContent,
@@ -10,17 +10,17 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@usesend/ui/src/dialog";
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Label } from "@usesend/ui/src/label";
} from '@usesend/ui/src/dialog';
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import { Label } from '@usesend/ui/src/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@usesend/ui/src/select";
} from '@usesend/ui/src/select';
interface AddSuppressionDialogProps {
open: boolean;
@@ -31,9 +31,9 @@ export default function AddSuppressionDialog({
open,
onOpenChange,
}: AddSuppressionDialogProps) {
const [email, setEmail] = useState("");
const [email, setEmail] = useState('');
const [reason, setReason] = useState<SuppressionReason>(
SuppressionReason.MANUAL
SuppressionReason.MANUAL,
);
const [error, setError] = useState<string | null>(null);
@@ -54,11 +54,11 @@ export default function AddSuppressionDialog({
{ email: email.trim() },
{
enabled: false,
}
},
);
const handleClose = () => {
setEmail("");
setEmail('');
setReason(SuppressionReason.MANUAL);
setError(null);
onOpenChange(false);
@@ -71,14 +71,14 @@ export default function AddSuppressionDialog({
const trimmedEmail = email.trim().toLowerCase();
if (!trimmedEmail) {
setError("Email address is required");
setError('Email address is required');
return;
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(trimmedEmail)) {
setError("Please enter a valid email address");
setError('Please enter a valid email address');
return;
}
@@ -86,7 +86,7 @@ export default function AddSuppressionDialog({
try {
const { data: isAlreadySuppressed } = await checkMutation.refetch();
if (isAlreadySuppressed) {
setError("This email is already suppressed");
setError('This email is already suppressed');
return;
}
} catch (error) {
@@ -142,7 +142,7 @@ export default function AddSuppressionDialog({
</div>
{error && (
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
<div className="text-destructive bg-destructive/10 rounded-md p-3 text-sm">
{error}
</div>
)}
@@ -160,7 +160,7 @@ export default function AddSuppressionDialog({
type="submit"
disabled={addMutation.isPending || !email.trim()}
>
{addMutation.isPending ? "Adding..." : "Add Suppression"}
{addMutation.isPending ? 'Adding...' : 'Add Suppression'}
</Button>
</DialogFooter>
</form>

View File

@@ -1,8 +1,8 @@
"use client";
'use client';
import { useState } from "react";
import { api } from "~/trpc/react";
import { SuppressionReason } from "@prisma/client";
import { useState } from 'react';
import { api } from '~/trpc/react';
import { SuppressionReason } from '@prisma/client';
import {
Dialog,
DialogContent,
@@ -10,19 +10,19 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@usesend/ui/src/dialog";
import { Button } from "@usesend/ui/src/button";
import { Label } from "@usesend/ui/src/label";
import { Textarea } from "@usesend/ui/src/textarea";
} from '@usesend/ui/src/dialog';
import { Button } from '@usesend/ui/src/button';
import { Label } from '@usesend/ui/src/label';
import { Textarea } from '@usesend/ui/src/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@usesend/ui/src/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@usesend/ui/src/tabs";
import { Upload, FileText } from "lucide-react";
} from '@usesend/ui/src/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@usesend/ui/src/tabs';
import { Upload, FileText } from 'lucide-react';
interface BulkAddSuppressionsDialogProps {
open: boolean;
@@ -33,9 +33,9 @@ export default function BulkAddSuppressionsDialog({
open,
onOpenChange,
}: BulkAddSuppressionsDialogProps) {
const [emails, setEmails] = useState("");
const [emails, setEmails] = useState('');
const [reason, setReason] = useState<SuppressionReason>(
SuppressionReason.MANUAL
SuppressionReason.MANUAL,
);
const [error, setError] = useState<string | null>(null);
const [processing, setProcessing] = useState(false);
@@ -57,7 +57,7 @@ export default function BulkAddSuppressionsDialog({
});
const handleClose = () => {
setEmails("");
setEmails('');
setReason(SuppressionReason.MANUAL);
setError(null);
setProcessing(false);
@@ -69,7 +69,7 @@ export default function BulkAddSuppressionsDialog({
const emailList = text
.split(/[\n,;]+/)
.map((email) => email.trim().toLowerCase())
.filter((email) => email && email.includes("@"));
.filter((email) => email && email.includes('@'));
// Remove duplicates
return Array.from(new Set(emailList));
@@ -82,8 +82,8 @@ export default function BulkAddSuppressionsDialog({
const processFile = (file: File) => {
// Validate file type
if (!file.name.endsWith(".txt") && !file.name.endsWith(".csv")) {
setError("Please upload a .txt or .csv file");
if (!file.name.endsWith('.txt') && !file.name.endsWith('.csv')) {
setError('Please upload a .txt or .csv file');
return;
}
@@ -131,7 +131,7 @@ export default function BulkAddSuppressionsDialog({
setProcessing(true);
if (!emails.trim()) {
setError("Please enter email addresses");
setError('Please enter email addresses');
setProcessing(false);
return;
}
@@ -139,7 +139,7 @@ export default function BulkAddSuppressionsDialog({
const emailList = parseEmails(emails);
if (emailList.length === 0) {
setError("No valid email addresses found");
setError('No valid email addresses found');
setProcessing(false);
return;
}
@@ -147,13 +147,13 @@ export default function BulkAddSuppressionsDialog({
const validEmails = validateEmails(emailList);
if (validEmails.length === 0) {
setError("No valid email addresses found");
setError('No valid email addresses found');
setProcessing(false);
return;
}
if (validEmails.length > 1000) {
setError("Maximum 1000 email addresses allowed per upload");
setError('Maximum 1000 email addresses allowed per upload');
setProcessing(false);
return;
}
@@ -191,11 +191,11 @@ export default function BulkAddSuppressionsDialog({
<Tabs defaultValue="text" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="text">
<FileText className="h-4 w-4 mr-2" />
<FileText className="mr-2 h-4 w-4" />
Text Input
</TabsTrigger>
<TabsTrigger value="file">
<Upload className="h-4 w-4 mr-2" />
<Upload className="mr-2 h-4 w-4" />
File Upload
</TabsTrigger>
</TabsList>
@@ -218,10 +218,10 @@ export default function BulkAddSuppressionsDialog({
<div className="space-y-2">
<Label htmlFor="file">Upload File</Label>
<div
className={`border-2 border-dashed rounded-lg p-6 transition-colors ${
className={`rounded-lg border-2 border-dashed p-6 transition-colors ${
isDragOver
? "border-primary bg-primary/5"
: "border-muted-foreground/25"
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@@ -238,23 +238,23 @@ export default function BulkAddSuppressionsDialog({
<div className="text-center">
<Upload
className={`mx-auto h-12 w-12 ${
isDragOver ? "text-primary" : "text-muted-foreground"
isDragOver ? 'text-primary' : 'text-muted-foreground'
}`}
/>
<div className="mt-2">
<Button
type="button"
variant="outline"
onClick={() => document.getElementById("file")?.click()}
onClick={() => document.getElementById('file')?.click()}
disabled={processing}
>
Choose File
</Button>
</div>
<p className="mt-2 text-sm text-muted-foreground">
<p className="text-muted-foreground mt-2 text-sm">
{isDragOver
? "Drop your file here"
: "Upload a .txt or .csv file with email addresses or drag and drop here"}
? 'Drop your file here'
: 'Upload a .txt or .csv file with email addresses or drag and drop here'}
</p>
</div>
</div>
@@ -289,7 +289,7 @@ export default function BulkAddSuppressionsDialog({
</div>
{emailList.length > 0 && (
<div className="text-sm text-muted-foreground bg-muted/50 p-3 rounded-md">
<div className="text-muted-foreground bg-muted/50 rounded-md p-3 text-sm">
<div>Found {emailList.length} email addresses</div>
<div>Valid: {validEmails.length}</div>
{validEmails.length !== emailList.length && (
@@ -301,7 +301,7 @@ export default function BulkAddSuppressionsDialog({
)}
{error && (
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
<div className="text-destructive bg-destructive/10 rounded-md p-3 text-sm">
{error}
</div>
)}
@@ -320,7 +320,7 @@ export default function BulkAddSuppressionsDialog({
disabled={processing || validEmails.length === 0}
>
{processing
? "Adding..."
? 'Adding...'
: `Add ${validEmails.length} Suppressions`}
</Button>
</DialogFooter>

View File

@@ -1,13 +1,13 @@
"use client";
'use client';
import { useState } from "react";
import AddSuppressionDialog from "./add-suppression";
import BulkAddSuppressionsDialog from "./bulk-add-suppressions";
import SuppressionList from "./suppression-list";
import SuppressionStats from "./suppression-stats";
import { Button } from "@usesend/ui/src/button";
import { Plus, Upload } from "lucide-react";
import { H1 } from "@usesend/ui";
import { useState } from 'react';
import AddSuppressionDialog from './add-suppression';
import BulkAddSuppressionsDialog from './bulk-add-suppressions';
import SuppressionList from './suppression-list';
import SuppressionStats from './suppression-stats';
import { Button } from '@usesend/ui/src/button';
import { Plus, Upload } from 'lucide-react';
import { H1 } from '@usesend/ui';
export default function SuppressionsPage() {
const [showAddDialog, setShowAddDialog] = useState(false);
@@ -16,15 +16,15 @@ export default function SuppressionsPage() {
return (
<div>
{/* Header */}
<div className="flex justify-between items-center mb-10">
<div className="mb-10 flex items-center justify-between">
<H1>Suppression List</H1>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setShowBulkAddDialog(true)}>
<Upload className="h-4 w-4 mr-2" />
<Upload className="mr-2 h-4 w-4" />
Bulk Add
</Button>
<Button onClick={() => setShowAddDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
<Plus className="mr-2 h-4 w-4" />
Add Suppression
</Button>
</div>

View File

@@ -1,4 +1,4 @@
"use client";
'use client';
import {
Dialog,
@@ -7,8 +7,8 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@usesend/ui/src/dialog";
import { Button } from "@usesend/ui/src/button";
} from '@usesend/ui/src/dialog';
import { Button } from '@usesend/ui/src/button';
interface RemoveSuppressionDialogProps {
email: string | null;
@@ -49,7 +49,7 @@ export default function RemoveSuppressionDialog({
onClick={onConfirm}
disabled={isLoading}
>
{isLoading ? "Removing..." : "Remove"}
{isLoading ? 'Removing...' : 'Remove'}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -1,20 +1,20 @@
"use client";
'use client';
import { useState } from "react";
import { api } from "~/trpc/react";
import { useUrlState } from "~/hooks/useUrlState";
import { useDebouncedCallback } from "use-debounce";
import { SuppressionReason } from "@prisma/client";
import { formatDistanceToNow } from "date-fns";
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { useState } from 'react';
import { api } from '~/trpc/react';
import { useUrlState } from '~/hooks/useUrlState';
import { useDebouncedCallback } from 'use-debounce';
import { SuppressionReason } from '@prisma/client';
import { formatDistanceToNow } from 'date-fns';
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@usesend/ui/src/select";
} from '@usesend/ui/src/select';
import {
Table,
TableBody,
@@ -22,30 +22,30 @@ import {
TableHead,
TableHeader,
TableRow,
} from "@usesend/ui/src/table";
import { Trash2, Download } from "lucide-react";
import RemoveSuppressionDialog from "./remove-suppression";
import Spinner from "@usesend/ui/src/spinner";
} from '@usesend/ui/src/table';
import { Trash2, Download } from 'lucide-react';
import RemoveSuppressionDialog from './remove-suppression';
import Spinner from '@usesend/ui/src/spinner';
const reasonLabels = {
HARD_BOUNCE: "Hard Bounce",
COMPLAINT: "Complaint",
MANUAL: "Manual",
HARD_BOUNCE: 'Hard Bounce',
COMPLAINT: 'Complaint',
MANUAL: 'Manual',
} as const;
export default function SuppressionList() {
const [search, setSearch] = useUrlState("search");
const [reason, setReason] = useUrlState("reason");
const [page, setPage] = useUrlState("page", "1");
const [search, setSearch] = useUrlState('search');
const [reason, setReason] = useUrlState('reason');
const [page, setPage] = useUrlState('page', '1');
const [emailToRemove, setEmailToRemove] = useState<string | null>(null);
const suppressionsQuery = api.suppression.getSuppressions.useQuery({
page: parseInt(page || "1"),
page: parseInt(page || '1'),
limit: 20,
search: search || undefined,
reason: reason as SuppressionReason | undefined,
sortBy: "createdAt",
sortOrder: "desc",
sortBy: 'createdAt',
sortOrder: 'desc',
});
const exportQuery = api.suppression.exportSuppressions.useQuery(
@@ -53,7 +53,7 @@ export default function SuppressionList() {
search: search || undefined,
reason: reason as SuppressionReason | undefined,
},
{ enabled: false }
{ enabled: false },
);
const utils = api.useUtils();
@@ -68,12 +68,12 @@ export default function SuppressionList() {
const debouncedSearch = useDebouncedCallback((value: string) => {
setSearch(value || null);
setPage("1");
setPage('1');
}, 1000);
const handleReasonFilter = (value: string) => {
setReason(value === "all" ? null : value);
setPage("1");
setReason(value === 'all' ? null : value);
setPage('1');
};
const handleExport = async () => {
@@ -81,18 +81,18 @@ export default function SuppressionList() {
if (resp.data) {
const csv = [
"Email,Reason,Created At",
'Email,Reason,Created At',
...resp.data.map(
(suppression) =>
`${suppression.email},${suppression.reason},${suppression.createdAt}`
`${suppression.email},${suppression.reason},${suppression.createdAt}`,
),
].join("\n");
].join('\n');
const blob = new Blob([csv], { type: "text/csv" });
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
const a = document.createElement('a');
a.href = url;
a.download = `suppressions-${new Date().toISOString().split("T")[0]}.csv`;
a.download = `suppressions-${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
@@ -113,16 +113,16 @@ export default function SuppressionList() {
return (
<div className="mt-10 flex flex-col gap-4">
{/* Header and Export */}
<div className="flex justify-between items-center">
<div className="flex items-center justify-between">
{/* Filters */}
<div className="flex gap-4">
<Input
placeholder="Search by email address..."
className="max-w-sm"
defaultValue={search || ""}
defaultValue={search || ''}
onChange={(e) => debouncedSearch(e.target.value)}
/>
<Select value={reason || "all"} onValueChange={handleReasonFilter}>
<Select value={reason || 'all'} onValueChange={handleReasonFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter by reason" />
</SelectTrigger>
@@ -133,13 +133,13 @@ export default function SuppressionList() {
<SelectItem value="MANUAL">Manual</SelectItem>
</SelectContent>
</Select>
</div>{" "}
</div>{' '}
<Button
variant="outline"
onClick={handleExport}
disabled={exportQuery.isFetching}
>
<Download className="h-4 w-4 mr-2" />
<Download className="mr-2 h-4 w-4" />
Export
</Button>
</div>
@@ -148,7 +148,7 @@ export default function SuppressionList() {
<div className="flex flex-col rounded-xl border shadow">
<Table className="">
<TableHeader className="">
<TableRow className=" bg-muted/30">
<TableRow className="bg-muted/30">
<TableHead className="rounded-tl-xl">Email</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Added</TableHead>
@@ -158,16 +158,16 @@ export default function SuppressionList() {
<TableBody>
{suppressionsQuery.isLoading ? (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
<TableCell colSpan={4} className="py-4 text-center">
<Spinner
className="w-6 h-6 mx-auto"
className="mx-auto h-6 w-6"
innerSvgClass="stroke-primary"
/>
</TableCell>
</TableRow>
) : suppressionsQuery.data?.suppressions.length === 0 ? (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
<TableCell colSpan={4} className="py-4 text-center">
No suppressed emails found
</TableCell>
</TableRow>
@@ -179,12 +179,12 @@ export default function SuppressionList() {
</TableCell>
<TableCell>
<div
className={`text-center w-[130px] rounded capitalize py-1 text-xs ${
suppression.reason === "HARD_BOUNCE"
? "bg-red/15 text-red border border-red/20"
: suppression.reason === "COMPLAINT"
? "bg-yellow/15 text-yellow border border-yellow/20"
: "bg-blue/15 text-blue border border-blue/20"
className={`w-[130px] rounded py-1 text-center text-xs capitalize ${
suppression.reason === 'HARD_BOUNCE'
? 'bg-red/15 text-red border-red/20 border'
: suppression.reason === 'COMPLAINT'
? 'bg-yellow/15 text-yellow border-yellow/20 border'
: 'bg-blue/15 text-blue border-blue/20 border'
}`}
>
{reasonLabels[suppression.reason]}
@@ -214,17 +214,17 @@ export default function SuppressionList() {
</div>
{/* Pagination */}
<div className="flex gap-4 justify-end">
<div className="flex justify-end gap-4">
<Button
size="sm"
onClick={() => setPage(String(parseInt(page || "1") - 1))}
disabled={parseInt(page || "1") === 1}
onClick={() => setPage(String(parseInt(page || '1') - 1))}
disabled={parseInt(page || '1') === 1}
>
Previous
</Button>
<Button
size="sm"
onClick={() => setPage(String(parseInt(page || "1") + 1))}
onClick={() => setPage(String(parseInt(page || '1') + 1))}
disabled={!suppressionsQuery.data?.pagination?.hasNext}
>
Next

View File

@@ -1,6 +1,6 @@
"use client";
'use client';
import { api } from "~/trpc/react";
import { api } from '~/trpc/react';
export default function SuppressionStats() {
const { data: stats, isLoading } =
@@ -8,14 +8,14 @@ export default function SuppressionStats() {
if (isLoading) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6 lg:gap-8">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-4 lg:gap-8">
{[...Array(4)].map((_, i) => (
<div
key={i}
className="flex flex-col gap-2 rounded-lg border p-4 shadow"
>
<div className="h-4 bg-muted animate-pulse rounded mb-1" />
<div className="h-8 bg-muted animate-pulse rounded" />
<div className="bg-muted mb-1 h-4 animate-pulse rounded" />
<div className="bg-muted h-8 animate-pulse rounded" />
</div>
))}
</div>
@@ -27,29 +27,29 @@ export default function SuppressionStats() {
: 0;
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6 lg:gap-8">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-4 lg:gap-8">
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
<p className="font-semibold mb-1">Total Suppressions</p>
<div className="text-2xl font-mono">{totalSuppressions}</div>
<p className="mb-1 font-semibold">Total Suppressions</p>
<div className="font-mono text-2xl">{totalSuppressions}</div>
</div>
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
<p className="font-semibold mb-1">Hard Bounces</p>
<div className="text-2xl font-mono text-red">
<p className="mb-1 font-semibold">Hard Bounces</p>
<div className="text-red font-mono text-2xl">
{stats?.HARD_BOUNCE ?? 0}
</div>
</div>
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
<p className="font-semibold mb-1">Complaints</p>
<div className="text-2xl font-mono text-yellow">
<p className="mb-1 font-semibold">Complaints</p>
<div className="text-yellow font-mono text-2xl">
{stats?.COMPLAINT ?? 0}
</div>
</div>
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
<p className="font-semibold mb-1">Manual</p>
<div className="text-2xl font-mono text-blue">{stats?.MANUAL ?? 0}</div>
<p className="mb-1 font-semibold">Manual</p>
<div className="text-blue font-mono text-2xl">{stats?.MANUAL ?? 0}</div>
</div>
</div>
);

View File

@@ -1,17 +1,17 @@
"use client";
'use client';
import { api } from "~/trpc/react";
import { Spinner } from "@usesend/ui/src/spinner";
import { Input } from "@usesend/ui/src/input";
import { Editor } from "@usesend/email-editor";
import { useState } from "react";
import { Template } from "@prisma/client";
import { toast } from "@usesend/ui/src/toaster";
import { useDebouncedCallback } from "use-debounce";
import { formatDistanceToNow } from "date-fns";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { use } from "react";
import { api } from '~/trpc/react';
import { Spinner } from '@usesend/ui/src/spinner';
import { Input } from '@usesend/ui/src/input';
import { Editor } from '@usesend/email-editor';
import { useState } from 'react';
import { Template } from '@prisma/client';
import { toast } from '@usesend/ui/src/toaster';
import { useDebouncedCallback } from 'use-debounce';
import { formatDistanceToNow } from 'date-fns';
import { ArrowLeft } from 'lucide-react';
import Link from 'next/link';
import { use } from 'react';
const IMAGE_SIZE_LIMIT = 10 * 1024 * 1024;
export default function EditTemplatePage({
@@ -34,15 +34,15 @@ export default function EditTemplatePage({
if (isLoading) {
return (
<div className="flex justify-center items-center h-full">
<Spinner className="w-6 h-6" />
<div className="flex h-full items-center justify-center">
<Spinner className="h-6 w-6" />
</div>
);
}
if (error) {
return (
<div className="flex justify-center items-center h-full">
<div className="flex h-full items-center justify-center">
<p className="text-red-500">Failed to load template</p>
</div>
);
@@ -96,7 +96,7 @@ function TemplateEditor({
);
}
console.log("file type: ", file.type);
console.log('file type: ', file.type);
const { uploadUrl, imageUrl } = await getUploadUrl.mutateAsync({
name: file.name,
@@ -105,21 +105,21 @@ function TemplateEditor({
});
const response = await fetch(uploadUrl, {
method: "PUT",
method: 'PUT',
body: file,
});
if (!response.ok) {
throw new Error("Failed to upload file");
throw new Error('Failed to upload file');
}
return imageUrl;
};
return (
<div className="p-4 container mx-auto">
<div className="container mx-auto p-4">
<div className="mx-auto">
<div className="mb-4 flex justify-between items-center w-[700px] mx-auto">
<div className="mx-auto mb-4 flex w-[700px] items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/templates">
<ArrowLeft className="h-4 w-4" />
@@ -128,7 +128,7 @@ function TemplateEditor({
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className=" border-0 focus:ring-0 focus:outline-none px-0.5 w-[300px]"
className="w-[300px] border-0 px-0.5 focus:outline-none focus:ring-0"
onBlur={() => {
if (name === template.name || !name) {
return;
@@ -152,20 +152,20 @@ function TemplateEditor({
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
{isSaving ? (
<div className="h-2 w-2 bg-yellow rounded-full" />
<div className="bg-yellow h-2 w-2 rounded-full" />
) : (
<div className="h-2 w-2 bg-green rounded-full" />
<div className="bg-green h-2 w-2 rounded-full" />
)}
{formatDistanceToNow(template.updatedAt) === "less than a minute"
? "just now"
{formatDistanceToNow(template.updatedAt) === 'less than a minute'
? 'just now'
: `${formatDistanceToNow(template.updatedAt)} ago`}
</div>
</div>
</div>
<div className="flex flex-col mt-4 mb-4 p-4 w-[700px] mx-auto z-50">
<div className="z-50 mx-auto mb-4 mt-4 flex w-[700px] flex-col p-4">
<div className="flex items-center gap-4">
<label className="block text-sm w-[80px] text-muted-foreground">
<label className="text-muted-foreground block w-[80px] text-sm">
Subject
</label>
<input
@@ -191,13 +191,13 @@ function TemplateEditor({
},
);
}}
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent focus:border-border bg-transparent"
className="focus:border-border mt-1 block w-full border-b border-transparent bg-transparent py-1 text-sm outline-none"
/>
</div>
</div>
<div className=" rounded-lg bg-gray-50 w-[700px] mx-auto p-10">
<div className="w-[600px] mx-auto">
<div className="mx-auto w-[700px] rounded-lg bg-gray-50 p-10">
<div className="mx-auto w-[600px]">
<Editor
initialContent={json}
onUpdate={(content) => {
@@ -205,7 +205,7 @@ function TemplateEditor({
setIsSaving(true);
deboucedUpdateTemplate();
}}
variables={["email", "firstName", "lastName"]}
variables={['email', 'firstName', 'lastName']}
uploadImage={
template.imageUploadSupported ? handleFileChange : undefined
}

View File

@@ -1,14 +1,14 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
} from '@usesend/ui/src/dialog';
import {
Form,
FormControl,
@@ -16,24 +16,24 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
} from '@usesend/ui/src/form';
import { api } from "~/trpc/react";
import { useState } from "react";
import { Plus } from "lucide-react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@usesend/ui/src/toaster";
import { useRouter } from "next/navigation";
import Spinner from "@usesend/ui/src/spinner";
import { api } from '~/trpc/react';
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from '@usesend/ui/src/toaster';
import { useRouter } from 'next/navigation';
import Spinner from '@usesend/ui/src/spinner';
const templateSchema = z.object({
name: z.string({ required_error: "Name is required" }).min(1, {
message: "Name is required",
name: z.string({ required_error: 'Name is required' }).min(1, {
message: 'Name is required',
}),
subject: z.string({ required_error: "Subject is required" }).min(1, {
message: "Subject is required",
subject: z.string({ required_error: 'Subject is required' }).min(1, {
message: 'Subject is required',
}),
});
@@ -46,8 +46,8 @@ export default function CreateTemplate() {
const templateForm = useForm<z.infer<typeof templateSchema>>({
resolver: zodResolver(templateSchema),
defaultValues: {
name: "",
subject: "",
name: '',
subject: '',
},
});
@@ -63,13 +63,13 @@ export default function CreateTemplate() {
onSuccess: async (data) => {
utils.template.getTemplates.invalidate();
router.push(`/templates/${data.id}/edit`);
toast.success("Template created successfully");
toast.success('Template created successfully');
setOpen(false);
},
onError: async (error) => {
toast.error(error.message);
},
}
},
);
}
@@ -80,7 +80,7 @@ export default function CreateTemplate() {
>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-1" />
<Plus className="mr-1 h-4 w-4" />
Create Template
</Button>
</DialogTrigger>
@@ -125,14 +125,14 @@ export default function CreateTemplate() {
</p>
<div className="flex justify-end">
<Button
className=" w-[100px]"
className="w-[100px]"
type="submit"
disabled={createTemplateMutation.isPending}
>
{createTemplateMutation.isPending ? (
<Spinner className="w-4 h-4" />
<Spinner className="h-4 w-4" />
) : (
"Create"
'Create'
)}
</Button>
</div>

View File

@@ -1,7 +1,7 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
@@ -9,14 +9,14 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import { api } from "~/trpc/react";
import React, { useState } from "react";
import { toast } from "@usesend/ui/src/toaster";
import { Trash2 } from "lucide-react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
} from '@usesend/ui/src/dialog';
import { api } from '~/trpc/react';
import React, { useState } from 'react';
import { toast } from '@usesend/ui/src/toaster';
import { Trash2 } from 'lucide-react';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Form,
FormControl,
@@ -25,8 +25,8 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { Template } from "@prisma/client";
} from '@usesend/ui/src/form';
import { Template } from '@prisma/client';
const templateSchema = z.object({
name: z.string(),
@@ -46,8 +46,8 @@ export const DeleteTemplate: React.FC<{
async function onTemplateDelete(values: z.infer<typeof templateSchema>) {
if (values.name !== template.name) {
templateForm.setError("name", {
message: "Name does not match",
templateForm.setError('name', {
message: 'Name does not match',
});
return;
}
@@ -66,7 +66,7 @@ export const DeleteTemplate: React.FC<{
);
}
const name = templateForm.watch("name");
const name = templateForm.watch('name');
return (
<Dialog
@@ -75,15 +75,15 @@ export const DeleteTemplate: React.FC<{
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent">
<Trash2 className="h-[18px] w-[18px] text-red/80" />
<Trash2 className="text-red/80 h-[18px] w-[18px]" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Template</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">
Are you sure you want to delete{' '}
<span className="text-foreground font-semibold">
{template.name}
</span>
? You can't reverse this.
@@ -107,7 +107,7 @@ export const DeleteTemplate: React.FC<{
{formState.errors.name ? (
<FormMessage />
) : (
<FormDescription className=" text-transparent">
<FormDescription className="text-transparent">
.
</FormDescription>
)}
@@ -122,7 +122,7 @@ export const DeleteTemplate: React.FC<{
deleteTemplateMutation.isPending || template.name !== name
}
>
{deleteTemplateMutation.isPending ? "Deleting..." : "Delete"}
{deleteTemplateMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</div>
</form>

View File

@@ -1,6 +1,6 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Button } from '@usesend/ui/src/button';
import {
Dialog,
DialogContent,
@@ -8,12 +8,12 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import { api } from "~/trpc/react";
import React, { useState } from "react";
import { toast } from "@usesend/ui/src/toaster";
import { Copy } from "lucide-react";
import { Template } from "@prisma/client";
} from '@usesend/ui/src/dialog';
import { api } from '~/trpc/react';
import React, { useState } from 'react';
import { toast } from '@usesend/ui/src/toaster';
import { Copy } from 'lucide-react';
import { Template } from '@prisma/client';
export const DuplicateTemplate: React.FC<{
template: Partial<Template> & { id: string };
@@ -46,15 +46,15 @@ export const DuplicateTemplate: React.FC<{
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent">
<Copy className="h-[18px] w-[18px] text-blue/80" />
<Copy className="text-blue/80 h-[18px] w-[18px]" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Duplicate Template</DialogTitle>
<DialogDescription>
Are you sure you want to duplicate{" "}
<span className="font-semibold text-foreground">
Are you sure you want to duplicate{' '}
<span className="text-foreground font-semibold">
{template.name}
</span>
?
@@ -68,8 +68,8 @@ export const DuplicateTemplate: React.FC<{
disabled={duplicateTemplateMutation.isPending}
>
{duplicateTemplateMutation.isPending
? "Duplicating..."
: "Duplicate"}
? 'Duplicating...'
: 'Duplicate'}
</Button>
</div>
</div>

View File

@@ -1,13 +1,13 @@
"use client";
'use client';
import TemplateList from "./template-list";
import CreateTemplate from "./create-template";
import { H1 } from "@usesend/ui";
import TemplateList from './template-list';
import CreateTemplate from './create-template';
import { H1 } from '@usesend/ui';
export default function TemplatesPage() {
return (
<div>
<div className="flex justify-between items-center">
<div className="flex items-center justify-between">
<H1>Templates</H1>
<CreateTemplate />
</div>

View File

@@ -1,4 +1,4 @@
"use client";
'use client';
import {
Table,
@@ -7,22 +7,22 @@ import {
TableHead,
TableBody,
TableCell,
} from "@usesend/ui/src/table";
import { api } from "~/trpc/react";
import { useUrlState } from "~/hooks/useUrlState";
import { Button } from "@usesend/ui/src/button";
import Spinner from "@usesend/ui/src/spinner";
import { formatDistanceToNow } from "date-fns";
} from '@usesend/ui/src/table';
import { api } from '~/trpc/react';
import { useUrlState } from '~/hooks/useUrlState';
import { Button } from '@usesend/ui/src/button';
import Spinner from '@usesend/ui/src/spinner';
import { formatDistanceToNow } from 'date-fns';
// import DeleteCampaign from "./delete-campaign";
import Link from "next/link";
import Link from 'next/link';
// import DuplicateCampaign from "./duplicate-campaign";
import { TextWithCopyButton } from "@usesend/ui/src/text-with-copy";
import DeleteTemplate from "./delete-template";
import DuplicateTemplate from "./duplicate-template";
import { TextWithCopyButton } from '@usesend/ui/src/text-with-copy';
import DeleteTemplate from './delete-template';
import DuplicateTemplate from './duplicate-template';
export default function TemplateList() {
const [page, setPage] = useUrlState("page", "1");
const [page, setPage] = useUrlState('page', '1');
const pageNumber = Number(page);
@@ -32,10 +32,10 @@ export default function TemplateList() {
return (
<div className="mt-10 flex flex-col gap-4">
<div className="flex flex-col rounded-xl border border-border shadow">
<div className="border-border flex flex-col rounded-xl border shadow">
<Table className="">
<TableHeader className="">
<TableRow className=" bg-muted/30">
<TableRow className="bg-muted/30">
<TableHead className="rounded-tl-xl">Name</TableHead>
<TableHead className="">ID</TableHead>
<TableHead className="">Created At</TableHead>
@@ -45,9 +45,9 @@ export default function TemplateList() {
<TableBody>
{templateQuery.isLoading ? (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
<TableCell colSpan={4} className="py-4 text-center">
<Spinner
className="w-6 h-6 mx-auto"
className="mx-auto h-6 w-6"
innerSvgClass="stroke-primary"
/>
</TableCell>
@@ -57,7 +57,7 @@ export default function TemplateList() {
<TableRow key={template.id} className="">
<TableCell className="font-medium">
<Link
className="underline underline-offset-4 decoration-dashed text-foreground hover:text-foreground"
className="text-foreground hover:text-foreground underline decoration-dashed underline-offset-4"
href={`/templates/${template.id}/edit`}
>
{template.name}
@@ -84,7 +84,7 @@ export default function TemplateList() {
))
) : (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
<TableCell colSpan={4} className="py-4 text-center">
No templates found
</TableCell>
</TableRow>
@@ -92,7 +92,7 @@ export default function TemplateList() {
</TableBody>
</Table>
</div>
<div className="flex gap-4 justify-end">
<div className="flex justify-end gap-4">
<Button
size="sm"
onClick={() => setPage((pageNumber - 1).toString())}

View File

@@ -1,9 +1,9 @@
import NextAuth from "next-auth";
import NextAuth from 'next-auth';
import { authOptions } from "~/server/auth";
import { env } from "~/env";
import { getRedis } from "~/server/redis";
import { logger } from "~/server/logger/log";
import { authOptions } from '~/server/auth';
import { env } from '~/env';
import { getRedis } from '~/server/redis';
import { logger } from '~/server/logger/log';
const handler = NextAuth(authOptions);
@@ -12,29 +12,29 @@ export { handler as GET };
function getClientIp(req: Request): string | null {
const h = req.headers;
const direct =
h.get("x-forwarded-for") ??
h.get("x-real-ip") ??
h.get("cf-connecting-ip") ??
h.get("x-client-ip") ??
h.get("true-client-ip") ??
h.get("fastly-client-ip") ??
h.get("x-cluster-client-ip") ??
h.get('x-forwarded-for') ??
h.get('x-real-ip') ??
h.get('cf-connecting-ip') ??
h.get('x-client-ip') ??
h.get('true-client-ip') ??
h.get('fastly-client-ip') ??
h.get('x-cluster-client-ip') ??
null;
let ip = direct?.split(",")[0]?.trim() ?? "";
let ip = direct?.split(',')[0]?.trim() ?? '';
if (!ip) {
const fwd = h.get("forwarded");
const fwd = h.get('forwarded');
if (fwd) {
const first = fwd.split(",")[0];
const first = fwd.split(',')[0];
const match = first?.match(/for=([^;]+)/i);
if (match && match[1]) {
const raw = match[1].trim().replace(/^"|"$/g, "");
if (raw.startsWith("[")) {
const end = raw.indexOf("]");
const raw = match[1].trim().replace(/^"|"$/g, '');
if (raw.startsWith('[')) {
const end = raw.indexOf(']');
ip = end !== -1 ? raw.slice(1, end) : raw;
} else {
const parts = raw.split(":");
const parts = raw.split(':');
if (parts.length > 0 && parts[0]) {
ip =
parts.length === 2 && /^\d+(?:\.\d+){3}$/.test(parts[0])
@@ -52,11 +52,11 @@ function getClientIp(req: Request): string | null {
export async function POST(req: Request, ctx: any) {
if (env.AUTH_EMAIL_RATE_LIMIT > 0) {
const url = new URL(req.url);
if (url.pathname.endsWith("/signin/email")) {
if (url.pathname.endsWith('/signin/email')) {
try {
const ip = getClientIp(req);
if (!ip) {
logger.warn("Auth email rate limit skipped: missing client IP");
logger.warn('Auth email rate limit skipped: missing client IP');
return handler(req, ctx);
}
const redis = getRedis();
@@ -65,19 +65,19 @@ export async function POST(req: Request, ctx: any) {
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, ttl);
if (count > env.AUTH_EMAIL_RATE_LIMIT) {
logger.warn({ ip }, "Auth email rate limit exceeded");
logger.warn({ ip }, 'Auth email rate limit exceeded');
return Response.json(
{
error: {
code: "RATE_LIMITED",
message: "Too many requests",
code: 'RATE_LIMITED',
message: 'Too many requests',
},
},
{ status: 429 }
{ status: 429 },
);
}
} catch (error) {
logger.error({ err: error }, "Auth email rate limit failed");
logger.error({ err: error }, 'Auth email rate limit failed');
}
}
}

View File

@@ -1,71 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { NextRequest, NextResponse } from 'next/server';
import {
renderOtpEmail,
renderTeamInviteEmail,
renderUsageWarningEmail,
renderUsageLimitReachedEmail,
} from "~/server/email-templates";
} from '~/server/email-templates';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const type = searchParams.get("type") || "otp";
const type = searchParams.get('type') || 'otp';
if (process.env.NODE_ENV !== "development") {
return NextResponse.json({ error: "Not Found" }, { status: 404 });
if (process.env.NODE_ENV !== 'development') {
return NextResponse.json({ error: 'Not Found' }, { status: 404 });
}
try {
let html: string;
if (type === "otp") {
if (type === 'otp') {
html = await renderOtpEmail({
otpCode: "ABC123",
loginUrl: "https://app.usesend.com/login?token=abc123",
hostName: "useSend",
otpCode: 'ABC123',
loginUrl: 'https://app.usesend.com/login?token=abc123',
hostName: 'useSend',
});
} else if (type === "invite") {
} else if (type === 'invite') {
html = await renderTeamInviteEmail({
teamName: "My Awesome Team",
inviteUrl: "https://app.usesend.com/join-team?inviteId=123",
inviterName: "John Doe",
role: "admin",
teamName: 'My Awesome Team',
inviteUrl: 'https://app.usesend.com/join-team?inviteId=123',
inviterName: 'John Doe',
role: 'admin',
});
} else if (type === "usage-warning") {
const isPaidPlan = searchParams.get("isPaidPlan") === "true";
const period = searchParams.get("period") || "daily";
} else if (type === 'usage-warning') {
const isPaidPlan = searchParams.get('isPaidPlan') === 'true';
const period = searchParams.get('period') || 'daily';
html = await renderUsageWarningEmail({
teamName: "Acme Inc",
teamName: 'Acme Inc',
used: 8000,
limit: 10000,
period: period as "daily" | "monthly",
manageUrl: "https://app.usesend.com/settings/billing",
period: period as 'daily' | 'monthly',
manageUrl: 'https://app.usesend.com/settings/billing',
isPaidPlan: isPaidPlan,
});
} else if (type === "usage-limit") {
const isPaidPlan = searchParams.get("isPaidPlan") === "true";
const period = searchParams.get("period") || "daily";
} else if (type === 'usage-limit') {
const isPaidPlan = searchParams.get('isPaidPlan') === 'true';
const period = searchParams.get('period') || 'daily';
html = await renderUsageLimitReachedEmail({
teamName: "Acme Inc",
teamName: 'Acme Inc',
limit: 10000,
period: period as "daily" | "monthly",
manageUrl: "https://app.usesend.com/settings/billing",
period: period as 'daily' | 'monthly',
manageUrl: 'https://app.usesend.com/settings/billing',
isPaidPlan: isPaidPlan,
});
} else {
return NextResponse.json({ error: "Invalid type" }, { status: 400 });
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
}
return new NextResponse(html, {
headers: {
"Content-Type": "text/html",
'Content-Type': 'text/html',
},
});
} catch (error) {
console.error("Error rendering email template:", error);
console.error('Error rendering email template:', error);
return NextResponse.json(
{ error: "Failed to render email template" },
{ status: 500 }
{ error: 'Failed to render email template' },
{ status: 500 },
);
}
}

View File

@@ -1,5 +1,5 @@
export const dynamic = "force-dynamic";
export const dynamic = 'force-dynamic';
export async function GET() {
return Response.json({ data: "Healthy" });
return Response.json({ data: 'Healthy' });
}

View File

@@ -1,14 +1,14 @@
import { env } from "~/env";
import { db } from "~/server/db";
import { logger } from "~/server/logger/log";
import { parseSesHook, SesHookParser } from "~/server/service/ses-hook-parser";
import { SesSettingsService } from "~/server/service/ses-settings-service";
import { SnsNotificationMessage } from "~/types/aws-types";
import { env } from '~/env';
import { db } from '~/server/db';
import { logger } from '~/server/logger/log';
import { parseSesHook, SesHookParser } from '~/server/service/ses-hook-parser';
import { SesSettingsService } from '~/server/service/ses-settings-service';
import { SnsNotificationMessage } from '~/types/aws-types';
export const dynamic = "force-dynamic";
export const dynamic = 'force-dynamic';
export async function GET() {
return Response.json({ data: "Hello" });
return Response.json({ data: 'Hello' });
}
export async function POST(req: Request) {
@@ -18,32 +18,32 @@ export async function POST(req: Request) {
const isEventValid = await checkEventValidity(data);
console.log("Is event valid: ", isEventValid);
console.log('Is event valid: ', isEventValid);
if (!isEventValid) {
return Response.json({ data: "Event is not valid" });
return Response.json({ data: 'Event is not valid' });
}
if (data.Type === "SubscriptionConfirmation") {
if (data.Type === 'SubscriptionConfirmation') {
return handleSubscription(data);
}
let message = null;
try {
message = JSON.parse(data.Message || "{}");
message = JSON.parse(data.Message || '{}');
const status = await SesHookParser.queue({
event: message,
messageId: data.MessageId,
});
if (!status) {
return Response.json({ data: "Error in parsing hook" });
return Response.json({ data: 'Error in parsing hook' });
}
return Response.json({ data: "Success" });
return Response.json({ data: 'Success' });
} catch (e) {
console.error(e);
return Response.json({ data: "Error is parsing hook" });
return Response.json({ data: 'Error is parsing hook' });
}
}
@@ -52,7 +52,7 @@ export async function POST(req: Request) {
*/
async function handleSubscription(message: any) {
await fetch(message.SubscribeURL, {
method: "GET",
method: 'GET',
});
const topicArn = message.TopicArn as string;
@@ -63,7 +63,7 @@ async function handleSubscription(message: any) {
});
if (!setting) {
return Response.json({ data: "Setting not found" });
return Response.json({ data: 'Setting not found' });
}
await db.sesSetting.update({
@@ -77,14 +77,14 @@ async function handleSubscription(message: any) {
SesSettingsService.invalidateCache();
return Response.json({ data: "Success" });
return Response.json({ data: 'Success' });
}
/**
* A simple check to ensure that the event is from the correct topic
*/
async function checkEventValidity(message: SnsNotificationMessage) {
if (env.NODE_ENV === "development") {
if (env.NODE_ENV === 'development') {
return true;
}

View File

@@ -1,6 +1,6 @@
import { EmailRenderer } from "@usesend/email-editor/src/renderer";
import { EmailRenderer } from '@usesend/email-editor/src/renderer';
export const dynamic = "force-dynamic";
export const dynamic = 'force-dynamic';
export async function POST(req: Request) {
const data = await req.json();
@@ -11,29 +11,29 @@ export async function POST(req: Request) {
const html = await renderer.render({
shouldReplaceVariableValues: true,
linkValues: {
"{{usesend_unsubscribe_url}}": "https://usesend.com/unsubscribe",
"{{unsend_unsubscribe_url}}": "https://usesend.com/unsubscribe",
'{{usesend_unsubscribe_url}}': 'https://usesend.com/unsubscribe',
'{{unsend_unsubscribe_url}}': 'https://usesend.com/unsubscribe',
},
});
console.log(`Time taken: ${Date.now() - time}ms`);
return new Response(JSON.stringify({ data: html }), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
} catch (e) {
console.error(e);
return new Response(
JSON.stringify({ data: "Error in converting to html" }),
JSON.stringify({ data: 'Error in converting to html' }),
{
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
},
);
@@ -43,9 +43,9 @@ export async function POST(req: Request) {
export function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}

View File

@@ -1,9 +1,9 @@
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { type NextRequest } from "next/server";
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { type NextRequest } from 'next/server';
import { env } from "~/env";
import { appRouter } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/trpc";
import { env } from '~/env';
import { appRouter } from '~/server/api/root';
import { createTRPCContext } from '~/server/api/trpc';
/**
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
@@ -17,15 +17,15 @@ const createContext = async (req: NextRequest) => {
const handler = (req: NextRequest) =>
fetchRequestHandler({
endpoint: "/api/trpc",
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => createContext(req),
onError:
env.NODE_ENV === "development"
env.NODE_ENV === 'development'
? ({ path, error }) => {
console.error(
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`
`❌ tRPC failed on ${path ?? '<no-path>'}: ${error.message}`,
);
}
: undefined,

View File

@@ -1,20 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { unsubscribeContactFromLink } from "~/server/service/campaign-service";
import { logger } from "~/server/logger/log";
import { NextRequest, NextResponse } from 'next/server';
import { unsubscribeContactFromLink } from '~/server/service/campaign-service';
import { logger } from '~/server/logger/log';
export async function POST(request: NextRequest) {
try {
const url = new URL(request.url);
const id = url.searchParams.get("id");
const hash = url.searchParams.get("hash");
const id = url.searchParams.get('id');
const hash = url.searchParams.get('hash');
if (!id || !hash) {
logger.warn(
`One-click unsubscribe: Missing id or hash id: ${id} hash: ${hash} url: ${request.url}`
`One-click unsubscribe: Missing id or hash id: ${id} hash: ${hash} url: ${request.url}`,
);
return NextResponse.json(
{ error: "Invalid unsubscribe link" },
{ status: 400 }
{ error: 'Invalid unsubscribe link' },
{ status: 400 },
);
}
@@ -22,28 +22,28 @@ export async function POST(request: NextRequest) {
const contact = await unsubscribeContactFromLink(id, hash);
logger.info(
{ contactId: contact.id, campaignId: id.split("-")[1] },
"One-click unsubscribe successful"
{ contactId: contact.id, campaignId: id.split('-')[1] },
'One-click unsubscribe successful',
);
// Return success response for email clients
return NextResponse.json(
{
success: true,
message: "Successfully unsubscribed",
message: 'Successfully unsubscribed',
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : error },
"One-click unsubscribe failed"
'One-click unsubscribe failed',
);
// Return error response
return NextResponse.json(
{ error: "Failed to process unsubscribe request" },
{ status: 500 }
{ error: 'Failed to process unsubscribe request' },
{ status: 500 },
);
}
}

View File

@@ -1,5 +1,5 @@
import { handle } from "hono/vercel";
import { app } from "~/server/public-api";
import { handle } from 'hono/vercel';
import { app } from '~/server/public-api';
export const GET = handle(app);
export const POST = handle(app);

View File

@@ -1,42 +1,42 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { env } from "~/env";
import { getStripe, syncStripeData } from "~/server/billing/payments";
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { env } from '~/env';
import { getStripe, syncStripeData } from '~/server/billing/payments';
const allowedEvents: Stripe.Event.Type[] = [
"checkout.session.completed",
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"customer.subscription.paused",
"customer.subscription.resumed",
"customer.subscription.pending_update_applied",
"customer.subscription.pending_update_expired",
"customer.subscription.trial_will_end",
"invoice.paid",
"invoice.payment_failed",
"invoice.payment_action_required",
"invoice.upcoming",
"invoice.marked_uncollectible",
"invoice.payment_succeeded",
"payment_intent.succeeded",
"payment_intent.payment_failed",
"payment_intent.canceled",
'checkout.session.completed',
'customer.subscription.created',
'customer.subscription.updated',
'customer.subscription.deleted',
'customer.subscription.paused',
'customer.subscription.resumed',
'customer.subscription.pending_update_applied',
'customer.subscription.pending_update_expired',
'customer.subscription.trial_will_end',
'invoice.paid',
'invoice.payment_failed',
'invoice.payment_action_required',
'invoice.upcoming',
'invoice.marked_uncollectible',
'invoice.payment_succeeded',
'payment_intent.succeeded',
'payment_intent.payment_failed',
'payment_intent.canceled',
];
export async function POST(req: Request) {
const body = await req.text();
const signature = (await headers()).get("Stripe-Signature");
const signature = (await headers()).get('Stripe-Signature');
if (!signature) {
console.error("No signature");
return new NextResponse("No signature", { status: 400 });
console.error('No signature');
return new NextResponse('No signature', { status: 400 });
}
if (!env.STRIPE_WEBHOOK_SECRET) {
console.error("No webhook secret");
return new NextResponse("No webhook secret", { status: 400 });
console.error('No webhook secret');
return new NextResponse('No webhook secret', { status: 400 });
}
const stripe = getStripe();
@@ -45,11 +45,11 @@ export async function POST(req: Request) {
const event = stripe.webhooks.constructEvent(
body,
signature,
env.STRIPE_WEBHOOK_SECRET
env.STRIPE_WEBHOOK_SECRET,
);
if (!allowedEvents.includes(event.type)) {
return new NextResponse("OK", { status: 200 });
return new NextResponse('OK', { status: 200 });
}
// All the events I track have a customerId
@@ -58,17 +58,17 @@ export async function POST(req: Request) {
};
// This helps make it typesafe and also lets me know if my assumption is wrong
if (typeof customerId !== "string") {
if (typeof customerId !== 'string') {
throw new Error(
`[STRIPE HOOK][CANCER] ID isn't string.\nEvent type: ${event.type}`
`[STRIPE HOOK][CANCER] ID isn't string.\nEvent type: ${event.type}`,
);
}
await syncStripeData(customerId);
return new NextResponse("OK", { status: 200 });
return new NextResponse('OK', { status: 200 });
} catch (err) {
console.error("Error processing webhook:", err);
return new NextResponse("Webhook error", { status: 400 });
console.error('Error processing webhook:', err);
return new NextResponse('Webhook error', { status: 400 });
}
}

View File

@@ -1,8 +1,8 @@
import JoinTeam from "~/components/team/JoinTeam";
import { Suspense } from "react";
import Spinner from "@usesend/ui/src/spinner";
import { getServerAuthSession } from "~/server/auth";
import { redirect } from "next/navigation";
import JoinTeam from '~/components/team/JoinTeam';
import { Suspense } from 'react';
import Spinner from '@usesend/ui/src/spinner';
import { getServerAuthSession } from '~/server/auth';
import { redirect } from 'next/navigation';
export default async function CreateTeam({
searchParams,
@@ -14,12 +14,12 @@ export default async function CreateTeam({
if (!session) {
const inviteId = params?.inviteId;
redirect(`/login${inviteId ? `?inviteId=${inviteId}` : ""}`);
redirect(`/login${inviteId ? `?inviteId=${inviteId}` : ''}`);
}
return (
<div className="flex items-center justify-center min-h-screen ">
<div className=" w-[300px] flex flex-col gap-8">
<div className="flex min-h-screen items-center justify-center">
<div className="flex w-[300px] flex-col gap-8">
<Suspense
fallback={
<div className="flex justify-center">

View File

@@ -1,27 +1,27 @@
import "@usesend/ui/styles/globals.css";
import '@usesend/ui/styles/globals.css';
import { Inter } from "next/font/google";
import { JetBrains_Mono } from "next/font/google";
import { ThemeProvider } from "@usesend/ui";
import { Toaster } from "@usesend/ui/src/toaster";
import { Inter } from 'next/font/google';
import { JetBrains_Mono } from 'next/font/google';
import { ThemeProvider } from '@usesend/ui';
import { Toaster } from '@usesend/ui/src/toaster';
import { TRPCReactProvider } from "~/trpc/react";
import { Metadata } from "next";
import { TRPCReactProvider } from '~/trpc/react';
import { Metadata } from 'next';
const inter = Inter({
subsets: ["latin"],
variable: "--font-sans",
subsets: ['latin'],
variable: '--font-sans',
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-mono",
subsets: ['latin'],
variable: '--font-mono',
});
export const metadata: Metadata = {
title: "useSend",
description: "Open source email platoform",
icons: [{ rel: "icon", url: "/favicon.ico" }],
title: 'useSend',
description: 'Open source email platoform',
icons: [{ rel: 'icon', url: '/favicon.ico' }],
};
export default async function RootLayout({

View File

@@ -1,12 +1,12 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import Image from "next/image";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { ClientSafeProvider, LiteralUnion, signIn } from "next-auth/react";
import { Button } from '@usesend/ui/src/button';
import Image from 'next/image';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useState } from 'react';
import { ClientSafeProvider, LiteralUnion, signIn } from 'next-auth/react';
import {
Form,
FormControl,
@@ -14,30 +14,31 @@ import {
FormField,
FormItem,
FormMessage,
} from "@usesend/ui/src/form";
} from '@usesend/ui/src/form';
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
REGEXP_ONLY_DIGITS_AND_CHARS,
} from "@usesend/ui/src/input-otp";
import { Input } from "@usesend/ui/src/input";
import { BuiltInProviderType } from "next-auth/providers/index";
import Spinner from "@usesend/ui/src/spinner";
import Link from "next/link";
import { useTheme } from "@usesend/ui";
import { useSearchParams as useNextSearchParams } from "next/navigation";
} from '@usesend/ui/src/input-otp';
import { Input } from '@usesend/ui/src/input';
import { BuiltInProviderType } from 'next-auth/providers/index';
import Spinner from '@usesend/ui/src/spinner';
import Link from 'next/link';
import { useTheme } from '@usesend/ui';
import { useSearchParams as useNextSearchParams } from 'next/navigation';
import { GibsAuthSVG } from './svgs/gibsauth';
const emailSchema = z.object({
email: z
.string({ required_error: "Email is required" })
.email({ message: "Invalid email" }),
.string({ required_error: 'Email is required' })
.email({ message: 'Invalid email' }),
});
const otpSchema = z.object({
otp: z
.string({ required_error: "OTP is required" })
.length(5, { message: "Invalid OTP" }),
.string({ required_error: 'OTP is required' })
.length(5, { message: 'Invalid OTP' }),
});
const providerSvgs = {
@@ -45,7 +46,7 @@ const providerSvgs = {
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 496 512"
className="h-5 w-5 fill-primary-foreground "
className="fill-primary-foreground h-5 w-5"
>
<path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z" />
</svg>
@@ -54,11 +55,12 @@ const providerSvgs = {
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 488 512"
className="h-5 w-5 fill-primary-foreground"
className="fill-primary-foreground h-5 w-5"
>
<path d="M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4z" />
</svg>
),
gibsauth: <GibsAuthSVG />,
};
export default function LoginPage({
@@ -69,8 +71,8 @@ export default function LoginPage({
isSignup?: boolean;
}) {
const [emailStatus, setEmailStatus] = useState<
"idle" | "sending" | "success"
>("idle");
'idle' | 'sending' | 'success'
>('idle');
const emailForm = useForm<z.infer<typeof emailSchema>>({
resolver: zodResolver(emailSchema),
@@ -81,76 +83,76 @@ export default function LoginPage({
});
async function onEmailSubmit(values: z.infer<typeof emailSchema>) {
setEmailStatus("sending");
await signIn("email", {
setEmailStatus('sending');
await signIn('email', {
email: values.email.toLowerCase(),
redirect: false,
});
setEmailStatus("success");
setEmailStatus('success');
}
async function onOTPSubmit(values: z.infer<typeof otpSchema>) {
const { origin: callbackUrl } = window.location;
const email = emailForm.getValues().email;
console.log("email", email);
console.log('email', email);
const finalCallbackUrl = inviteId
? `/join-team?inviteId=${inviteId}`
: `${callbackUrl}/dashboard`;
window.location.href = `/api/auth/callback/email?email=${encodeURIComponent(
email.toLowerCase()
email.toLowerCase(),
)}&token=${values.otp.toLowerCase()}&callbackUrl=${encodeURIComponent(finalCallbackUrl)}`;
}
const emailProvider = providers?.find(
(provider) => provider.type === "email"
(provider) => provider.type === 'email',
);
const [submittedProvider, setSubmittedProvider] =
useState<LiteralUnion<BuiltInProviderType> | null>(null);
const searchParams = useNextSearchParams();
const inviteId = searchParams.get("inviteId");
const inviteId = searchParams.get('inviteId');
const handleSubmit = (provider: LiteralUnion<BuiltInProviderType>) => {
setSubmittedProvider(provider);
const callbackUrl = inviteId
? `/join-team?inviteId=${inviteId}`
: "/dashboard";
: '/dashboard';
signIn(provider, { callbackUrl });
};
const { resolvedTheme } = useTheme();
return (
<main className="h-screen flex justify-center items-center">
<main className="flex h-screen items-center justify-center">
<div className="flex flex-col gap-6">
<Image
src={"/logo-squircle.png"}
src={'/logo-squircle.png'}
alt="useSend"
width={50}
height={50}
className="mx-auto"
/>
<div>
<p className="text-2xl text-center font-semibold">
{isSignup ? "Create new account" : "Sign into useSend"}
<p className="text-center text-2xl font-semibold">
{isSignup ? 'Create new account' : 'Sign into useSend'}
</p>
<p className="text-center mt-2 text-sm text-muted-foreground">
{isSignup ? "Already have an account?" : "New to useSend?"}
<p className="text-muted-foreground mt-2 text-center text-sm">
{isSignup ? 'Already have an account?' : 'New to useSend?'}
<Link
href={isSignup ? "/login" : "/signup"}
className=" text-foreground hover:underline ml-1"
href={isSignup ? '/login' : '/signup'}
className="text-foreground ml-1 hover:underline"
>
{isSignup ? "Sign in" : "Create new account"}
{isSignup ? 'Sign in' : 'Create new account'}
</Link>
</p>
</div>
<div className="flex flex-col gap-8 mt-8 border p-8 rounded-lg shadow">
<div className="mt-8 flex flex-col gap-8 rounded-lg border p-8 shadow">
{providers &&
Object.values(providers).map((provider) => {
if (provider.type === "email") return null;
if (provider.type === 'email') return null;
return (
<Button
key={provider.id}
@@ -159,12 +161,12 @@ export default function LoginPage({
onClick={() => handleSubmit(provider.id)}
>
{submittedProvider === provider.id ? (
<Spinner className="w-5 h-5" />
<Spinner className="h-5 w-5" />
) : (
providerSvgs[provider.id as keyof typeof providerSvgs]
)}
<span className="ml-4">
{isSignup ? "Sign up with" : "Continue with"}{" "}
{isSignup ? 'Sign up with' : 'Continue with'}{' '}
{provider.name}
</span>
</Button>
@@ -172,15 +174,15 @@ export default function LoginPage({
})}
{emailProvider && (
<>
<div className=" flex w-[350px] items-center justify-between gap-2">
<p className=" z-10 ml-[175px] -translate-x-1/2 bg-background px-4 text-sm">
<div className="flex w-[350px] items-center justify-between gap-2">
<p className="bg-background z-10 ml-[175px] -translate-x-1/2 px-4 text-sm">
or
</p>
<div className="absolute h-[1px] w-[350px] bg-gradient-to-l from-zinc-300 via-zinc-800 to-zinc-300"></div>
<div className="absolute h-[1px] w-[350px] bg-gradient-to-l from-zinc-300 via-zinc-800 to-zinc-300"></div>
</div>
{emailStatus === "success" ? (
{emailStatus === 'success' ? (
<>
<p className=" w-[350px] text-center text-sm">
<p className="w-[350px] text-center text-sm">
We have sent an email with the OTP. Please check your inbox
</p>
<Form {...otpForm}>
@@ -231,7 +233,7 @@ export default function LoginPage({
)}
/>
<Button size="lg" className=" mt-9 w-[350px]">
<Button size="lg" className="mt-9 w-[350px]">
Submit
</Button>
</form>
@@ -252,7 +254,7 @@ export default function LoginPage({
<FormControl>
<Input
placeholder="Enter your email"
className=" w-[350px]"
className="w-[350px]"
type="email"
{...field}
/>
@@ -263,13 +265,13 @@ export default function LoginPage({
)}
/>
<Button
className=" w-[350px] "
className="w-[350px]"
size="lg"
disabled={emailStatus === "sending"}
disabled={emailStatus === 'sending'}
>
{emailStatus === "sending"
? "Sending..."
: "Continue with email"}
{emailStatus === 'sending'
? 'Sending...'
: 'Continue with email'}
</Button>
</form>
</Form>

View File

@@ -1,13 +1,13 @@
import { redirect } from "next/navigation";
import { getServerAuthSession } from "~/server/auth";
import LoginPage from "./login-page";
import { getProviders } from "next-auth/react";
import { redirect } from 'next/navigation';
import { getServerAuthSession } from '~/server/auth';
import LoginPage from './login-page';
import { getProviders } from 'next-auth/react';
export default async function Login() {
const session = await getServerAuthSession();
if (session) {
redirect("/dashboard");
redirect('/dashboard');
}
const providers = await getProviders();

File diff suppressed because one or more lines are too long

View File

@@ -1,16 +1,16 @@
import { getServerAuthSession } from "~/server/auth";
import { redirect } from "next/navigation";
import { getServerAuthSession } from '~/server/auth';
import { redirect } from 'next/navigation';
export default async function Home() {
const session = await getServerAuthSession();
if (!session?.user) {
redirect("/login");
redirect('/login');
}
if (session.user.isWaitlisted) {
redirect("/wait-list");
redirect('/wait-list');
} else {
redirect("/dashboard");
redirect('/dashboard');
}
}

View File

@@ -1,13 +1,13 @@
import { redirect } from "next/navigation";
import { getServerAuthSession } from "~/server/auth";
import LoginPage from "../login/login-page";
import { getProviders } from "next-auth/react";
import { redirect } from 'next/navigation';
import { getServerAuthSession } from '~/server/auth';
import LoginPage from '../login/login-page';
import { getProviders } from 'next-auth/react';
export default async function Login() {
const session = await getServerAuthSession();
if (session) {
redirect("/dashboard");
redirect('/dashboard');
}
const providers = await getProviders();

View File

@@ -1,7 +1,7 @@
import { unsubscribeContactFromLink } from "~/server/service/campaign-service";
import ReSubscribe from "./re-subscribe";
import { unsubscribeContactFromLink } from '~/server/service/campaign-service';
import ReSubscribe from './re-subscribe';
export const dynamic = "force-dynamic";
export const dynamic = 'force-dynamic';
async function UnsubscribePage({
searchParams,
@@ -15,9 +15,9 @@ async function UnsubscribePage({
if (!id || !hash) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="max-w-md w-full space-y-8 p-10 shadow rounded-xl">
<h2 className="mt-6 text-center text-3xl font-extrabold ">
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-md space-y-8 rounded-xl p-10 shadow">
<h2 className="mt-6 text-center text-3xl font-extrabold">
Unsubscribe
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
@@ -31,12 +31,12 @@ async function UnsubscribePage({
const contact = await unsubscribeContactFromLink(id, hash);
return (
<div className="min-h-screen flex items-center justify-center ">
<div className="flex min-h-screen items-center justify-center">
<ReSubscribe id={id} hash={hash} contact={contact} />
<div className=" fixed bottom-10 p-4">
<div className="fixed bottom-10 p-4">
<p>
Powered by{" "}
Powered by{' '}
<a href="https://usesend.com" className="font-bold" target="_blank">
useSend
</a>

View File

@@ -1,11 +1,11 @@
"use client";
'use client';
import { Contact } from "@prisma/client";
import { Button } from "@usesend/ui/src/button";
import Spinner from "@usesend/ui/src/spinner";
import { toast } from "@usesend/ui/src/toaster";
import { useState } from "react";
import { api } from "~/trpc/react";
import { Contact } from '@prisma/client';
import { Button } from '@usesend/ui/src/button';
import Spinner from '@usesend/ui/src/spinner';
import { toast } from '@usesend/ui/src/toaster';
import { useState } from 'react';
import { api } from '~/trpc/react';
export default function ReSubscribe({
id,
@@ -20,7 +20,7 @@ export default function ReSubscribe({
const reSubscribe = api.campaign.reSubscribeContact.useMutation({
onSuccess: () => {
toast.success("You have been subscribed again");
toast.success('You have been subscribed again');
setSubscribed(true);
},
onError: (e) => {
@@ -29,14 +29,14 @@ export default function ReSubscribe({
});
return (
<div className="max-w-xl w-full space-y-8 p-10 border shadow rounded-xl">
<h2 className=" text-center text-xl font-extrabold ">
{subscribed ? "You have subscribed again" : "You have unsubscribed"}
<div className="w-full max-w-xl space-y-8 rounded-xl border p-10 shadow">
<h2 className="text-center text-xl font-extrabold">
{subscribed ? 'You have subscribed again' : 'You have unsubscribed'}
</h2>
<div>
{subscribed
? "You have been added to our mailing list and will receive all emails at"
: "You have been removed from our mailing list and won't receive any emails at"}{" "}
? 'You have been added to our mailing list and will receive all emails at'
: "You have been removed from our mailing list and won't receive any emails at"}{' '}
<span className="font-bold">{contact.email}</span>.
</div>
@@ -48,9 +48,9 @@ export default function ReSubscribe({
disabled={reSubscribe.isPending}
>
{reSubscribe.isPending ? (
<Spinner className="w-4 h-4" />
<Spinner className="h-4 w-4" />
) : (
"Subscribe Again"
'Subscribe Again'
)}
</Button>
) : null}

View File

@@ -1,28 +1,28 @@
import { Rocket } from "lucide-react";
import { Rocket } from 'lucide-react';
import { getServerAuthSession } from "~/server/auth";
import { WaitListForm } from "./waitlist-form";
import { redirect } from "next/navigation";
import { getServerAuthSession } from '~/server/auth';
import { WaitListForm } from './waitlist-form';
import { redirect } from 'next/navigation';
export default async function WaitListPage() {
const session = await getServerAuthSession();
if (!session?.user) {
redirect("/login");
redirect('/login');
}
const userEmail = session.user.email ?? "";
const userEmail = session.user.email ?? '';
return (
<div className="flex min-h-screen items-center justify-center px-4 py-8">
<div className="flex w-full max-w-xl flex-col gap-6 rounded-lg border bg-card p-8 shadow-lg">
<div className="bg-card flex w-full max-w-xl flex-col gap-6 rounded-lg border p-8 shadow-lg">
<div className="flex items-center gap-3">
<span className="rounded-full bg-primary/10 p-2 text-primary">
<span className="bg-primary/10 text-primary rounded-full p-2">
<Rocket className="h-5 w-5" />
</span>
<div>
<h1 className="text-2xl font-semibold">You're on the waitlist</h1>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
Share a bit more context so we can prioritize your access.
</p>
</div>

View File

@@ -1,29 +1,26 @@
import { z } from "zod";
import { z } from 'zod';
export const WAITLIST_EMAIL_TYPES = [
"transactional",
"marketing",
] as const;
export const WAITLIST_EMAIL_TYPES = ['transactional', 'marketing'] as const;
export const waitlistSubmissionSchema = z.object({
domain: z
.string({ required_error: "Domain is required" })
.string({ required_error: 'Domain is required' })
.trim()
.min(1, "Domain is required")
.max(255, "Domain must be 255 characters or fewer"),
.min(1, 'Domain is required')
.max(255, 'Domain must be 255 characters or fewer'),
emailTypes: z
.array(z.enum(WAITLIST_EMAIL_TYPES))
.min(1, "Select at least one email type"),
.min(1, 'Select at least one email type'),
emailVolume: z
.string({ required_error: "Share your expected volume" })
.string({ required_error: 'Share your expected volume' })
.trim()
.min(1, "Tell us how many emails you expect to send")
.max(500, "Keep the volume details under 500 characters"),
.min(1, 'Tell us how many emails you expect to send')
.max(500, 'Keep the volume details under 500 characters'),
description: z
.string({ required_error: "Provide a short description" })
.string({ required_error: 'Provide a short description' })
.trim()
.min(10, "Please share a bit more detail")
.max(2000, "Description must be under 2000 characters"),
.min(10, 'Please share a bit more detail')
.max(2000, 'Description must be under 2000 characters'),
});
export type WaitlistSubmissionInput = z.infer<typeof waitlistSubmissionSchema>;

View File

@@ -1,9 +1,9 @@
"use client";
'use client';
import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { Button } from "@usesend/ui/src/button";
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Button } from '@usesend/ui/src/button';
import {
Form,
FormControl,
@@ -11,37 +11,38 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { Input } from "@usesend/ui/src/input";
import { Textarea } from "@usesend/ui/src/textarea";
import Spinner from "@usesend/ui/src/spinner";
import { toast } from "@usesend/ui/src/toaster";
} from '@usesend/ui/src/form';
import { Input } from '@usesend/ui/src/input';
import { Textarea } from '@usesend/ui/src/textarea';
import Spinner from '@usesend/ui/src/spinner';
import { toast } from '@usesend/ui/src/toaster';
import {
WAITLIST_EMAIL_TYPES,
waitlistSubmissionSchema,
type WaitlistSubmissionInput,
} from "./schema";
import { api } from "~/trpc/react";
import { signOut } from "next-auth/react";
} from './schema';
import { api } from '~/trpc/react';
import { signOut } from 'next-auth/react';
type WaitListFormProps = {
userEmail: string;
};
const EMAIL_TYPE_LABEL: Record<(typeof WAITLIST_EMAIL_TYPES)[number], string> = {
transactional: "Transactional",
marketing: "Marketing",
};
const EMAIL_TYPE_LABEL: Record<(typeof WAITLIST_EMAIL_TYPES)[number], string> =
{
transactional: 'Transactional',
marketing: 'Marketing',
};
export function WaitListForm({ userEmail }: WaitListFormProps) {
const form = useForm<WaitlistSubmissionInput>({
resolver: zodResolver(waitlistSubmissionSchema),
defaultValues: {
domain: "",
domain: '',
emailTypes: [],
emailVolume: "",
description: "",
emailVolume: '',
description: '',
},
});
@@ -53,7 +54,7 @@ export function WaitListForm({ userEmail }: WaitListFormProps) {
form.reset();
},
onError: (error) => {
toast.error(error.message ?? "Something went wrong");
toast.error(error.message ?? 'Something went wrong');
},
});
@@ -63,9 +64,9 @@ export function WaitListForm({ userEmail }: WaitListFormProps) {
const handleLogout = () => {
setIsLoggingOut(true);
signOut({ callbackUrl: "/login" }).catch(() => {
signOut({ callbackUrl: '/login' }).catch(() => {
setIsLoggingOut(false);
toast.error("Unable to log out. Please try again.");
toast.error('Unable to log out. Please try again.');
});
};
@@ -97,8 +98,8 @@ export function WaitListForm({ userEmail }: WaitListFormProps) {
<div>
<p className="text-sm font-medium">Contact email</p>
<p className="mt-1 rounded-md border bg-muted/30 px-3 py-2 text-sm">
{userEmail || "Unknown"}
<p className="bg-muted/30 mt-1 rounded-md border px-3 py-2 text-sm">
{userEmail || 'Unknown'}
</p>
</div>
@@ -108,7 +109,7 @@ export function WaitListForm({ userEmail }: WaitListFormProps) {
render={({ field }) => {
const selected = field.value ?? [];
const handleToggle = (
option: (typeof WAITLIST_EMAIL_TYPES)[number]
option: (typeof WAITLIST_EMAIL_TYPES)[number],
) => {
if (selected.includes(option)) {
field.onChange(selected.filter((value) => value !== option));
@@ -127,11 +128,11 @@ export function WaitListForm({ userEmail }: WaitListFormProps) {
return (
<label
key={option}
className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm shadow-sm transition hover:bg-muted/40"
className="hover:bg-muted/40 flex items-center gap-2 rounded-md border px-3 py-2 text-sm shadow-sm transition"
>
<input
type="checkbox"
className="h-4 w-4 rounded border-muted-foreground/40"
className="border-muted-foreground/40 h-4 w-4 rounded"
checked={checked}
onChange={() => handleToggle(option)}
/>
@@ -186,7 +187,7 @@ export function WaitListForm({ userEmail }: WaitListFormProps) {
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
We'll come back usually within 4 hours.
</p>
<div className="flex gap-2">
@@ -201,7 +202,7 @@ export function WaitListForm({ userEmail }: WaitListFormProps) {
<Spinner className="mr-2 h-4 w-4" /> Logging out...
</>
) : (
"Log out"
'Log out'
)}
</Button>
<Button type="submit" disabled={submitRequest.isPending}>
@@ -210,7 +211,7 @@ export function WaitListForm({ userEmail }: WaitListFormProps) {
<Spinner className="mr-2 h-4 w-4" /> Sending...
</>
) : (
"Request Access"
'Request Access'
)}
</Button>
</div>

Some files were not shown because too many files have changed in this diff Show More