initial commit. gotta go
This commit is contained in:
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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;
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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 (
|
||||
|
@@ -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
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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}
|
||||
{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>
|
||||
|
@@ -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())}
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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())}
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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, {
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
@@ -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>
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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>
|
||||
|
@@ -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()}
|
||||
|
@@ -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>
|
||||
|
@@ -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 })}
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
);
|
||||
};
|
||||
|
@@ -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>
|
||||
);
|
||||
};
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
);
|
||||
|
@@ -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}
|
||||
|
@@ -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>
|
||||
|
@@ -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>;
|
||||
};
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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;
|
||||
}
|
||||
};
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
@@ -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>
|
||||
|
@@ -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 />
|
||||
|
@@ -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,
|
||||
|
@@ -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>
|
||||
|
@@ -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
|
||||
|
@@ -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}
|
||||
|
@@ -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 />
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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);
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -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>
|
||||
|
@@ -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 />
|
||||
|
@@ -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`);
|
||||
}}
|
||||
|
@@ -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>
|
||||
|
@@ -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}
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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
|
||||
|
@@ -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>
|
||||
);
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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())}
|
||||
|
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -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' });
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
|
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@@ -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,
|
||||
|
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -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);
|
||||
|
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
@@ -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">
|
||||
|
@@ -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({
|
||||
|
@@ -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>
|
||||
|
@@ -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();
|
||||
|
56
apps/web/src/app/login/svgs/gibsauth.tsx
Normal file
56
apps/web/src/app/login/svgs/gibsauth.tsx
Normal file
File diff suppressed because one or more lines are too long
@@ -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');
|
||||
}
|
||||
}
|
||||
|
@@ -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();
|
||||
|
@@ -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>
|
||||
|
@@ -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}
|
||||
|
@@ -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>
|
||||
|
@@ -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>;
|
||||
|
@@ -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
Reference in New Issue
Block a user