initial commit. gotta go

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

View File

@@ -4,9 +4,9 @@ REDIS_URL="redis://redis:6379"
# Postgres - required for docker-compose, not needed for just docker # Postgres - required for docker-compose, not needed for just docker
POSTGRES_USER="postgres" POSTGRES_USER="postgres"
POSTGRES_PASSWORD="postgres" POSTGRES_PASSWORD="postgres"
POSTGRES_DB="usesend" POSTGRES_DB="gibsend"
# Postgres - required # Postgres - required
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/usesend" DATABASE_URL="postgresql://postgres:postgres@postgres:5432/gibsend"
# NextAuth - required # NextAuth - required
NEXTAUTH_URL="http://localhost:3000" NEXTAUTH_URL="http://localhost:3000"
@@ -14,7 +14,7 @@ NEXTAUTH_SECRET=
#SMTP #SMTP
SMTP_HOST=smtp.mailtrap.io # Example SMTP host SMTP_HOST=smtp.mailtrap.io # Example SMTP host
SMTP_USER= "usesend" # Example SMTP user SMTP_USER= "gibsend" # Example SMTP user
## Auth providers any one is required ## Auth providers any one is required
# GitHub login - required # GitHub login - required
@@ -25,6 +25,11 @@ GITHUB_SECRET="<your-github-client-secret>"
GOOGLE_CLIENT_ID="<your-google-client-id>" GOOGLE_CLIENT_ID="<your-google-client-id>"
GOOGLE_CLIENT_SECRET="<your-google-client-secret>" GOOGLE_CLIENT_SECRET="<your-google-client-secret>"
# Gib's Auth Login
GIBS_AUTH_CLIENT_ID="<your-gibs-auth-client-id>"
GIBS_AUTH_CLIENT_SECRET="<your-gibs-auth-client-secret>"
GIBS_AUTH_ISSUER="<your-gibs-auth-issuer>"
# AWS details - required # AWS details - required
AWS_DEFAULT_REGION="us-east-1" AWS_DEFAULT_REGION="us-east-1"
AWS_SECRET_KEY="<your-aws-secret-key>" AWS_SECRET_KEY="<your-aws-secret-key>"

View File

@@ -97,41 +97,3 @@ For detailed instructions on how to configure and run the Docker container, plea
## Self Hosting ## Self Hosting
Checkout the [self-hosting guide](https://docs.usesend.com/self-hosting/overview) to learn how to run useSend on your own infrastructure. Checkout the [self-hosting guide](https://docs.usesend.com/self-hosting/overview) to learn how to run useSend on your own infrastructure.
## Self Hosting with Railway
Railway provides the quickest way to spin up useSend. Read the [Railway self-hosting guide](https://docs.usesend.com/self-hosting/railway) or deploy directly:
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.com/deploy/usesend?utm_medium=integration&utm_source=docs&utm_campaign=usesend)
## Star History
<a href="https://star-history.com/#usesend/usesend&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=usesend/usesend&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=usesend/usesend&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=usesend/usesend&type=Date" />
</picture>
</a>
## Sponsors
We are grateful for the support of our sponsors.
<a href="https://coderabbit.ai/?utm_source=useSend.com" target="_blank">
<img src="https://usesend.com/coderabbit-wordmark.png" alt="coderabbit.ai" style="width:200px;height:100px;">
</a>
### Other Sponsors
<a href="https://doras.to/?utm_source=useSend.com" target="_blank">
<img src="https://cdn.doras.to/doras/assets/05c5db48-cfba-49d7-82a1-5b4a3751aa40/49ca4647-65ed-412e-95c6-c475633d62af.png" alt="doras.to" style="width:60px;height:60px;">
</a>
<a href="https://github.com/anaclumos" target="_blank">
<img src="https://avatars.githubusercontent.com/u/31657298?v=4" alt="anaclumos" style="width:60px;height:60px;">
</a>
<a href="https://github.com/miguilimzero" target="_blank">
<img src="https://avatars.githubusercontent.com/u/35383529?v=4" alt="miguilimzero" style="width:60px;height:60px;">
</a>

View File

@@ -1,55 +1,55 @@
import "@usesend/ui/styles/globals.css"; import '@usesend/ui/styles/globals.css';
import { Inter } from "next/font/google"; import { Inter } from 'next/font/google';
import { JetBrains_Mono } from "next/font/google"; import { JetBrains_Mono } from 'next/font/google';
import type { Metadata } from "next"; import type { Metadata } from 'next';
import { ThemeProvider } from "@usesend/ui"; import { ThemeProvider } from '@usesend/ui';
import Script from "next/script"; import Script from 'next/script';
const inter = Inter({ const inter = Inter({
subsets: ["latin"], subsets: ['latin'],
variable: "--font-sans", variable: '--font-sans',
}); });
const jetbrainsMono = JetBrains_Mono({ const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"], subsets: ['latin'],
variable: "--font-mono", variable: '--font-mono',
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "useSend Open source email platform", title: 'useSend Open source email platform',
description: "Pay only for what you send, not for storing contacts", description: 'Pay only for what you send, not for storing contacts',
icons: [{ rel: "icon", url: "/favicon.ico" }], icons: [{ rel: 'icon', url: '/favicon.ico' }],
metadataBase: new URL("https://usesend.com"), metadataBase: new URL('https://usesend.com'),
openGraph: { openGraph: {
title: "useSend Open source email platform", title: 'useSend Open source email platform',
description: "Pay only for what you send, not for storing contacts", description: 'Pay only for what you send, not for storing contacts',
url: "https://usesend.com", url: 'https://usesend.com',
siteName: "useSend", siteName: 'useSend',
images: [ images: [
{ {
url: "https://uploads.usesend.com/logos/og.png", url: 'https://uploads.usesend.com/logos/og.png',
width: 1200, width: 1200,
height: 630, height: 630,
alt: "useSend Open source email platform", alt: 'useSend Open source email platform',
type: "image/png", type: 'image/png',
}, },
], ],
locale: "en_US", locale: 'en_US',
type: "website", type: 'website',
}, },
twitter: { twitter: {
card: "summary_large_image", card: 'summary_large_image',
title: "useSend Open source email platform", title: 'useSend Open source email platform',
description: "Pay only for what you send, not for storing contacts", description: 'Pay only for what you send, not for storing contacts',
images: ["https://uploads.usesend.com/logos/og.png"], images: ['https://uploads.usesend.com/logos/og.png'],
}, },
robots: { robots: {
index: true, index: true,
follow: true, follow: true,
}, },
alternates: { alternates: {
canonical: "https://usesend.com", canonical: 'https://usesend.com',
}, },
}; };
@@ -62,9 +62,9 @@ export default function RootLayout({
<html <html
lang="en" lang="en"
suppressHydrationWarning suppressHydrationWarning
className="scroll-smooth bg-background" className="bg-background scroll-smooth"
> >
{process.env.NODE_ENV === "production" && ( {process.env.NODE_ENV === 'production' && (
<Script src="https://scripts.simpleanalyticscdn.com/latest.js" /> <Script src="https://scripts.simpleanalyticscdn.com/latest.js" />
)} )}
<body <body

View File

@@ -1,21 +1,21 @@
import Image from "next/image"; import Image from 'next/image';
import Link from "next/link"; import Link from 'next/link';
import { SiteFooter } from "~/components/SiteFooter"; import { SiteFooter } from '~/components/SiteFooter';
import { GitHubStarsButton } from "~/components/GitHubStarsButton"; import { GitHubStarsButton } from '~/components/GitHubStarsButton';
import { Button } from "@usesend/ui/src/button"; import { Button } from '@usesend/ui/src/button';
import { TopNav } from "~/components/TopNav"; import { TopNav } from '~/components/TopNav';
import { FeatureCard } from "~/components/FeatureCard"; import { FeatureCard } from '~/components/FeatureCard';
import { FeatureCardPlain } from "~/components/FeatureCardPlain"; import { FeatureCardPlain } from '~/components/FeatureCardPlain';
import { PricingCalculator } from "~/components/PricingCalculator"; import { PricingCalculator } from '~/components/PricingCalculator';
import CodeExample from "~/components/CodeExample"; import CodeExample from '~/components/CodeExample';
const REPO = "usesend/usesend"; const REPO = 'usesend/usesend';
const REPO_URL = `https://github.com/${REPO}`; const REPO_URL = `https://github.com/${REPO}`;
const APP_URL = "https://app.usesend.com"; const APP_URL = 'https://app.usesend.com';
export default function Page() { export default function Page() {
return ( return (
<main className="min-h-screen text-foreground bg-background"> <main className="text-foreground bg-background min-h-screen">
<TopNav /> <TopNav />
<Hero /> <Hero />
<TrustedBy /> <TrustedBy />
@@ -34,18 +34,18 @@ function Hero() {
return ( return (
<section> <section>
<div className="mx-auto max-w-6xl px-6 py-16 sm:py-24"> <div className="mx-auto max-w-6xl px-6 py-16 sm:py-24">
<h1 className="mt-6 text-center text-2xl sm:text-4xl font-semibold text-primary font-sans"> <h1 className="text-primary mt-6 text-center font-sans text-2xl font-semibold sm:text-4xl">
The open source email platform for everyone The open source email platform for everyone
</h1> </h1>
<p className="mt-4 text-center text-base sm:text-lg font-sans max-w-2xl mx-auto"> <p className="mx-auto mt-4 max-w-2xl text-center font-sans text-base sm:text-lg">
Send product, transactional and marketing emails.{" "} Send product, transactional and marketing emails.{' '}
<span className="text-primary font-normal"> <span className="text-primary font-normal">
Pay only for what you send Pay only for what you send
</span>{" "} </span>{' '}
and not for storing contacts. and not for storing contacts.
</p> </p>
<div className="mt-8 flex flex-col sm:flex-row items-center justify-center gap-3"> <div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
<Button size="lg" className="px-6"> <Button size="lg" className="px-6">
<a href={APP_URL} target="_blank" rel="noopener noreferrer"> <a href={APP_URL} target="_blank" rel="noopener noreferrer">
Get started Get started
@@ -55,11 +55,11 @@ function Hero() {
<GitHubStarsButton /> <GitHubStarsButton />
</div> </div>
<p className="mt-3 text-center text-xs text-muted-foreground"> <p className="text-muted-foreground mt-3 text-center text-xs">
Open source Self-host in minutes Free tier Open source Self-host in minutes Free tier
</p> </p>
<div className="mt-12 text-center text-xs text-muted-foreground flex flex-col items-center justify-center gap-2"> <div className="text-muted-foreground mt-12 flex flex-col items-center justify-center gap-2 text-center text-xs">
<p className="text-xs">Proudly sponsored by</p> <p className="text-xs">Proudly sponsored by</p>
<a <a
href="https://coderabbit.ai/?utm_source=useSend.com" href="https://coderabbit.ai/?utm_source=useSend.com"
@@ -89,15 +89,15 @@ function Hero() {
</a> </a>
</div> </div>
<div className=" mt-32 mx-auto max-w-5xl"> <div className="mx-auto mt-32 max-w-5xl">
<div className="rounded-[18px] bg-primary/10 p-1 sm:p-1 "> <div className="bg-primary/10 rounded-[18px] p-1 sm:p-1">
<div className="rounded-2xl bg-primary/20 p-1 sm:p-1 "> <div className="bg-primary/20 rounded-2xl p-1 sm:p-1">
<Image <Image
src="/hero-light.webp" src="/hero-light.webp"
alt="useSend product hero" alt="useSend product hero"
width={3456} width={3456}
height={1914} height={1914}
className="w-full h-auto rounded-xl block dark:hidden" className="block h-auto w-full rounded-xl dark:hidden"
sizes="(min-width: 1024px) 900px, 100vw" sizes="(min-width: 1024px) 900px, 100vw"
loading="eager" loading="eager"
priority={false} priority={false}
@@ -107,7 +107,7 @@ function Hero() {
alt="useSend product hero" alt="useSend product hero"
width={3456} width={3456}
height={1914} height={1914}
className="w-full h-auto rounded-xl hidden dark:block" className="hidden h-auto w-full rounded-xl dark:block"
sizes="(min-width: 1024px) 900px, 100vw" sizes="(min-width: 1024px) 900px, 100vw"
loading="eager" loading="eager"
priority={false} priority={false}
@@ -127,61 +127,61 @@ function TrustedBy() {
{ {
quote: quote:
"Transitioned recently to open source email sender useSend for our 30k and growing newsletter. It's such a great product and amazing oss experience.", "Transitioned recently to open source email sender useSend for our 30k and growing newsletter. It's such a great product and amazing oss experience.",
author: "Marc Seitz", author: 'Marc Seitz',
company: "papermark.com", company: 'papermark.com',
image: image:
"https://pbs.twimg.com/profile_images/1176854646343852032/iYnUXJ-m_400x400.jpg", 'https://pbs.twimg.com/profile_images/1176854646343852032/iYnUXJ-m_400x400.jpg',
}, },
{ {
quote: quote:
"useSend was extremely easy to set up, and I love that it's open source. Koushik has been an absolute awesome person to deal with and helps us with any issues or feedback.", "useSend was extremely easy to set up, and I love that it's open source. Koushik has been an absolute awesome person to deal with and helps us with any issues or feedback.",
author: "Tommerty", author: 'Tommerty',
company: "doras.to", company: 'doras.to',
image: image:
"https://cdn.doras.to/doras/user/83bda65b-8d42-4011-9bf0-ab23402776f2-0.890688178917765.webp", 'https://cdn.doras.to/doras/user/83bda65b-8d42-4011-9bf0-ab23402776f2-0.890688178917765.webp',
}, },
]; ];
const quick = [ const quick = [
{ {
quote: "don't sleep on useSend", quote: "don't sleep on useSend",
author: "shellscape", author: 'shellscape',
company: "jsx.email", company: 'jsx.email',
image: image:
"https://pbs.twimg.com/profile_images/1698447401781022720/b0DZSc_D_400x400.jpg", 'https://pbs.twimg.com/profile_images/1698447401781022720/b0DZSc_D_400x400.jpg',
}, },
{ {
quote: "Thank you for making useSend!", quote: 'Thank you for making useSend!',
author: "Andras Bacsai", author: 'Andras Bacsai',
company: "coolify.io", company: 'coolify.io',
image: image:
"https://pbs.twimg.com/profile_images/1884210412524027905/jW4NB4rx_400x400.jpg", 'https://pbs.twimg.com/profile_images/1884210412524027905/jW4NB4rx_400x400.jpg',
}, },
{ {
quote: "I KNOW WHAT TO DO", quote: 'I KNOW WHAT TO DO',
author: "VicVijayakumar", author: 'VicVijayakumar',
company: "onetimefax.com", company: 'onetimefax.com',
image: image:
"https://pbs.twimg.com/profile_images/1665351804685524995/W4BpDx5Z_400x400.jpg", 'https://pbs.twimg.com/profile_images/1665351804685524995/W4BpDx5Z_400x400.jpg',
}, },
]; ];
return ( return (
<section className="py-10 sm:py-20 "> <section className="py-10 sm:py-20">
<div className="mx-auto max-w-6xl px-6"> <div className="mx-auto max-w-6xl px-6">
<div className="text-center tracking-wider text-muted-foreground"> <div className="text-muted-foreground text-center tracking-wider">
<span className="">Builders and open source teams love </span> <span className="">Builders and open source teams love </span>
<span className="text-primary font-bold">useSend</span> <span className="text-primary font-bold">useSend</span>
</div> </div>
{/* Top: 2 larger testimonials */} {/* Top: 2 larger testimonials */}
<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2">
{featured.map((t) => ( {featured.map((t) => (
<figure <figure
key={t.author + t.company} key={t.author + t.company}
className="rounded-xl border border-primary/30 p-5 h-full" className="border-primary/30 h-full rounded-xl border p-5"
> >
<blockquote className="text-sm sm:text-base font-light font-sans "> <blockquote className="font-sans text-sm font-light sm:text-base">
{t.quote} {t.quote}
</blockquote> </blockquote>
<div className="mt-5 flex items-center gap-3"> <div className="mt-5 flex items-center gap-3">
@@ -190,7 +190,7 @@ function TrustedBy() {
alt={`${t.author} avatar`} alt={`${t.author} avatar`}
width={32} width={32}
height={32} height={32}
className=" rounded-md border-2 border-primary/50" className="border-primary/50 rounded-md border-2"
/> />
<figcaption className="text-sm"> <figcaption className="text-sm">
<span className="font-medium">{t.author}</span> <span className="font-medium">{t.author}</span>
@@ -199,9 +199,9 @@ function TrustedBy() {
target="_blank" target="_blank"
className="text-muted-foreground hover:text-primary-light" className="text-muted-foreground hover:text-primary-light"
> >
{" "} {' '}
{t.company} {t.company}
</a>{" "} </a>{' '}
</figcaption> </figcaption>
</div> </div>
</figure> </figure>
@@ -209,13 +209,13 @@ function TrustedBy() {
</div> </div>
{/* Bottom: 3 multi-line testimonials (same style as top) */} {/* Bottom: 3 multi-line testimonials (same style as top) */}
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-4"> <div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-3">
{quick.map((t) => ( {quick.map((t) => (
<figure <figure
key={t.author + t.company} key={t.author + t.company}
className="rounded-xl border border-primary/30 p-5 h-full" className="border-primary/30 h-full rounded-xl border p-5"
> >
<blockquote className="text-sm sm:text-base font-light font-sans leading-relaxed"> <blockquote className="font-sans text-sm font-light leading-relaxed sm:text-base">
{t.quote} {t.quote}
</blockquote> </blockquote>
<div className="mt-5 flex items-center gap-3"> <div className="mt-5 flex items-center gap-3">
@@ -224,7 +224,7 @@ function TrustedBy() {
alt={`${t.author} avatar`} alt={`${t.author} avatar`}
width={32} width={32}
height={32} height={32}
className=" rounded-md border-2 border-primary/50" className="border-primary/50 rounded-md border-2"
/> />
<figcaption className="text-sm"> <figcaption className="text-sm">
<span className="font-medium">{t.author}</span> <span className="font-medium">{t.author}</span>
@@ -233,7 +233,7 @@ function TrustedBy() {
target="_blank" target="_blank"
className="text-muted-foreground hover:text-primary-light" className="text-muted-foreground hover:text-primary-light"
> >
{" "} {' '}
{t.company} {t.company}
</a> </a>
</figcaption> </figcaption>
@@ -250,42 +250,42 @@ function Features() {
// Top: 2 cards (with image area) — Analytics, Editor // Top: 2 cards (with image area) — Analytics, Editor
const top = [ const top = [
{ {
key: "feature-analytics", key: 'feature-analytics',
title: "Analytics", title: 'Analytics',
content: content:
"Track deliveries, opens, clicks, bounces and unsubscribes in real time with a simple, searchable log. Filter by domain, status, api key and export them. Track which campaigns perform best.", 'Track deliveries, opens, clicks, bounces and unsubscribes in real time with a simple, searchable log. Filter by domain, status, api key and export them. Track which campaigns perform best.',
imageLightSrc: "/emails-search-light.webp", imageLightSrc: '/emails-search-light.webp',
imageDarkSrc: "/emails-search-dark.webp", imageDarkSrc: '/emails-search-dark.webp',
}, },
{ {
key: "feature-editor", key: 'feature-editor',
title: "Marketing Email Editor", title: 'Marketing Email Editor',
content: content:
"Design beautiful campaigns without code using a visual, notion like WYSIWYG editor that works in major email clients. Reuse templates and brand styles, and personalize with variables.", 'Design beautiful campaigns without code using a visual, notion like WYSIWYG editor that works in major email clients. Reuse templates and brand styles, and personalize with variables.',
imageLightSrc: "/editor-light.webp", imageLightSrc: '/editor-light.webp',
imageDarkSrc: "/editor-dark.webp", imageDarkSrc: '/editor-dark.webp',
}, },
]; ];
// Bottom: 3 cards (no images) — Contact Management, Suppression List, SMTP Relay Service // Bottom: 3 cards (no images) — Contact Management, Suppression List, SMTP Relay Service
const bottom = [ const bottom = [
{ {
key: "feature-contacts", key: 'feature-contacts',
title: "Contact Management", title: 'Contact Management',
content: content:
"Manage contacts, lists, and consent in one place. Import and export easily, keep per-list subscription status. Contacts are automatically updated from bounces and complaints.", 'Manage contacts, lists, and consent in one place. Import and export easily, keep per-list subscription status. Contacts are automatically updated from bounces and complaints.',
}, },
{ {
key: "feature-suppression", key: 'feature-suppression',
title: "Suppression List", title: 'Suppression List',
content: content:
"Prevent accidental sends. Automatically populated from bounces and complaints, and manage via import/export or API. Works with transactional and marketing emails.", 'Prevent accidental sends. Automatically populated from bounces and complaints, and manage via import/export or API. Works with transactional and marketing emails.',
}, },
{ {
key: "feature-smtp", key: 'feature-smtp',
title: "SMTP Relay", title: 'SMTP Relay',
content: content:
"Drop-in SMTP relay that works with any app or framework. Do not get vendor lock-in. Comes in handy with services like Supabase", 'Drop-in SMTP relay that works with any app or framework. Do not get vendor lock-in. Comes in handy with services like Supabase',
}, },
]; ];
@@ -293,13 +293,13 @@ function Features() {
<section id="features" className="py-16 sm:py-20"> <section id="features" className="py-16 sm:py-20">
<div className="mx-auto max-w-6xl px-6"> <div className="mx-auto max-w-6xl px-6">
<div className="text-center"> <div className="text-center">
<div className="mb-2 text-sm uppercase tracking-wider text-primary"> <div className="text-primary mb-2 text-sm uppercase tracking-wider">
Features Features
</div> </div>
</div> </div>
{/* Top row: 2 side-by-side cards with images */} {/* Top row: 2 side-by-side cards with images */}
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 gap-6"> <div className="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2">
{top.map((f) => ( {top.map((f) => (
<FeatureCard <FeatureCard
key={f.key} key={f.key}
@@ -312,7 +312,7 @@ function Features() {
</div> </div>
{/* Bottom row: 3 cards without images */} {/* Bottom row: 3 cards without images */}
<div className="mt-6 grid grid-cols-1 sm:grid-cols-3 gap-6"> <div className="mt-6 grid grid-cols-1 gap-6 sm:grid-cols-3">
{bottom.map((f) => ( {bottom.map((f) => (
<FeatureCardPlain key={f.key} title={f.title} content={f.content} /> <FeatureCardPlain key={f.key} title={f.title} content={f.content} />
))} ))}
@@ -326,35 +326,35 @@ function Features() {
function Pricing() { function Pricing() {
const freePerks = [ const freePerks = [
"Send up to 3000 emails per month", 'Send up to 3000 emails per month',
"Send up to 100 emails per day", 'Send up to 100 emails per day',
"Can have 1 contact book", 'Can have 1 contact book',
"Can have 1 domain", 'Can have 1 domain',
"Can have 1 team member", 'Can have 1 team member',
]; ];
const paidPerks = [ const paidPerks = [
"$10 monthly usage credits", '$10 monthly usage credits',
"Send transactional emails at $0.0004 per email", 'Send transactional emails at $0.0004 per email',
"Send marketing emails at $0.001 per email", 'Send marketing emails at $0.001 per email',
"Can have unlimited contact books", 'Can have unlimited contact books',
"Can have unlimited domains", 'Can have unlimited domains',
"Can have unlimited team members", 'Can have unlimited team members',
]; ];
return ( return (
<section id="pricing" className="py-16 sm:py-20"> <section id="pricing" className="py-16 sm:py-20">
<div className="mx-auto max-w-6xl px-6"> <div className="mx-auto max-w-6xl px-6">
<div className="text-center"> <div className="text-center">
<div className="mb-2 text-sm uppercase tracking-wider text-primary"> <div className="text-primary mb-2 text-sm uppercase tracking-wider">
PRICING PRICING
</div> </div>
<p className="mt-1 text-xs sm:text-sm text-muted-foreground max-w-2xl mx-auto"> <p className="text-muted-foreground mx-auto mt-1 max-w-2xl text-xs sm:text-sm">
pay for what you use, the most affordable email platform pay for what you use, the most affordable email platform
</p> </p>
</div> </div>
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 gap-6"> <div className="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2">
<PricingCard <PricingCard
title="Free" title="Free"
price="$0" price="$0"
@@ -386,16 +386,16 @@ type PricingCardProps = {
function PricingCard({ title, price, note, perks }: PricingCardProps) { function PricingCard({ title, price, note, perks }: PricingCardProps) {
return ( return (
<div className="rounded-[18px] bg-primary/20 p-1"> <div className="bg-primary/20 rounded-[18px] p-1">
<div className="h-full rounded-[14px] bg-primary/20 p-0.5 shadow-sm"> <div className="bg-primary/20 h-full rounded-[14px] p-0.5 shadow-sm">
<div className="bg-background rounded-xl h-full flex flex-col p-5"> <div className="bg-background flex h-full flex-col rounded-xl p-5">
<h3 className=" font-medium">{title}</h3> <h3 className="font-medium">{title}</h3>
<div className="mt-2 text-4xl text-primary">{price}</div> <div className="text-primary mt-2 text-4xl">{price}</div>
<div className="text-xs text-muted-foreground">{note}</div> <div className="text-muted-foreground text-xs">{note}</div>
<ul className="mt-4 space-y-2 text-sm mb-20"> <ul className="mb-20 mt-4 space-y-2 text-sm">
{perks.map((perk) => ( {perks.map((perk) => (
<li key={perk} className="flex items-start gap-2"> <li key={perk} className="flex items-start gap-2">
<CheckIcon className="w-4 h-4 mt-0.5 text-primary" /> <CheckIcon className="text-primary mt-0.5 h-4 w-4" />
<span>{perk}</span> <span>{perk}</span>
</li> </li>
))} ))}
@@ -422,12 +422,12 @@ function About() {
<section id="about" className="py-16 sm:py-20"> <section id="about" className="py-16 sm:py-20">
<div className="mx-auto max-w-6xl px-6"> <div className="mx-auto max-w-6xl px-6">
<div className="text-center"> <div className="text-center">
<div className="mb-2 text-sm uppercase tracking-wider text-primary"> <div className="text-primary mb-2 text-sm uppercase tracking-wider">
About About
</div> </div>
</div> </div>
<div className="mt-8 max-w-3xl mx-auto text-sm sm:text-base space-y-4"> <div className="mx-auto mt-8 max-w-3xl space-y-4 text-sm sm:text-base">
<p> <p>
As most of email products out there, useSend also uses Amazon SES As most of email products out there, useSend also uses Amazon SES
under the hood to send emails. We provide an open and alternative under the hood to send emails. We provide an open and alternative
@@ -435,7 +435,7 @@ function About() {
</p> </p>
<p> <p>
useSend is bootstrapped and funded by the cloud offering and useSend is bootstrapped and funded by the cloud offering and
sponsors. If you self host useSend, please consider{" "} sponsors. If you self host useSend, please consider{' '}
<a <a
href="https://github.com/sponsors/KMKoushik" href="https://github.com/sponsors/KMKoushik"
target="_blank" target="_blank"
@@ -456,7 +456,7 @@ function About() {
// Footer moved to ~/components/SiteFooter // Footer moved to ~/components/SiteFooter
// Minimal inline icons (stroke-based, sleek) // Minimal inline icons (stroke-based, sleek)
function CheckIcon({ className = "" }: { className?: string }) { function CheckIcon({ className = '' }: { className?: string }) {
return ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

View File

@@ -1,17 +1,17 @@
import type { Metadata } from "next"; import type { Metadata } from 'next';
import { TopNav } from "~/components/TopNav"; import { TopNav } from '~/components/TopNav';
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Privacy Policy useSend", title: 'Privacy Policy useSend',
description: "Simple privacy policy for the useSend marketing site.", description: 'Simple privacy policy for the useSend marketing site.',
}; };
export default function PrivacyPage() { export default function PrivacyPage() {
return ( return (
<main className="min-h-screen bg-sidebar-background text-foreground"> <main className="bg-sidebar-background text-foreground min-h-screen">
<TopNav /> <TopNav />
<div className="mx-auto max-w-3xl px-6 py-16"> <div className="mx-auto max-w-3xl px-6 py-16">
<h1 className="text-3xl font-semibold tracking-tight mb-6"> <h1 className="mb-6 text-3xl font-semibold tracking-tight">
Privacy Policy Privacy Policy
</h1> </h1>
<p className="text-muted-foreground mb-8"> <p className="text-muted-foreground mb-8">
@@ -22,7 +22,7 @@ export default function PrivacyPage() {
occasional marketing emails. occasional marketing emails.
</p> </p>
<section className="space-y-3 mb-8"> <section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Who We Are</h2> <h2 className="text-xl font-medium">Who We Are</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
useSend ("we", "us") operates the marketing website at useSend ("we", "us") operates the marketing website at
@@ -41,13 +41,13 @@ export default function PrivacyPage() {
</p> </p>
</section> </section>
<section className="space-y-3 mb-8"> <section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">What We Collect</h2> <h2 className="text-xl font-medium">What We Collect</h2>
<ul className="list-disc pl-5 space-y-2 text-muted-foreground"> <ul className="text-muted-foreground list-disc space-y-2 pl-5">
<li> <li>
<span className="text-foreground"> <span className="text-foreground">
Usage and device data (marketing site): Usage and device data (marketing site):
</span>{" "} </span>{' '}
We use Simple Analytics to understand overall traffic and usage We use Simple Analytics to understand overall traffic and usage
patterns (e.g., pages visited, referrers, device type). Simple patterns (e.g., pages visited, referrers, device type). Simple
Analytics is a privacyfriendly analytics provider and does not Analytics is a privacyfriendly analytics provider and does not
@@ -55,7 +55,7 @@ export default function PrivacyPage() {
identify you. identify you.
</li> </li>
<li> <li>
<span className="text-foreground">Server and security logs:</span>{" "} <span className="text-foreground">Server and security logs:</span>{' '}
Our hosting providers (Vercel for the marketing site; Railway for Our hosting providers (Vercel for the marketing site; Railway for
the app) may process IP addresses and basic request metadata the app) may process IP addresses and basic request metadata
transiently for security, reliability, and debugging. transiently for security, reliability, and debugging.
@@ -63,7 +63,7 @@ export default function PrivacyPage() {
<li> <li>
<span className="text-foreground"> <span className="text-foreground">
Account and email data (product): Account and email data (product):
</span>{" "} </span>{' '}
If you sign up for useSend, we process your account information If you sign up for useSend, we process your account information
and send transactional emails. If you opt in, we may also send and send transactional emails. If you opt in, we may also send
occasional marketing emails. You can unsubscribe at any time via occasional marketing emails. You can unsubscribe at any time via
@@ -72,9 +72,9 @@ export default function PrivacyPage() {
</ul> </ul>
</section> </section>
<section className="space-y-3 mb-8"> <section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">How We Use Information</h2> <h2 className="text-xl font-medium">How We Use Information</h2>
<ul className="list-disc pl-5 space-y-2 text-muted-foreground"> <ul className="text-muted-foreground list-disc space-y-2 pl-5">
<li>Operate, secure, and maintain the marketing site and app.</li> <li>Operate, secure, and maintain the marketing site and app.</li>
<li> <li>
Understand aggregated usage to improve performance and content. Understand aggregated usage to improve performance and content.
@@ -87,7 +87,7 @@ export default function PrivacyPage() {
</ul> </ul>
</section> </section>
<section className="space-y-3 mb-8"> <section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Legal Bases</h2> <h2 className="text-xl font-medium">Legal Bases</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Where applicable (e.g., in the EEA/UK), we rely on legitimate Where applicable (e.g., in the EEA/UK), we rely on legitimate
@@ -97,13 +97,13 @@ export default function PrivacyPage() {
</p> </p>
</section> </section>
<section className="space-y-3 mb-8"> <section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Sharing and Processors</h2> <h2 className="text-xl font-medium">Sharing and Processors</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
We share information with service providers who process data on our We share information with service providers who process data on our
behalf, including: behalf, including:
</p> </p>
<ul className="list-disc pl-5 space-y-2 text-muted-foreground"> <ul className="text-muted-foreground list-disc space-y-2 pl-5">
<li> <li>
<span className="text-foreground">Hosting:</span> Vercel <span className="text-foreground">Hosting:</span> Vercel
(marketing site) and Railway (application) for serving content, (marketing site) and Railway (application) for serving content,
@@ -127,7 +127,7 @@ export default function PrivacyPage() {
</p> </p>
</section> </section>
<section className="space-y-3 mb-8"> <section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Retention</h2> <h2 className="text-xl font-medium">Retention</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
We retain information only for as long as necessary to fulfill the We retain information only for as long as necessary to fulfill the
@@ -137,7 +137,7 @@ export default function PrivacyPage() {
</p> </p>
</section> </section>
<section className="space-y-3 mb-8"> <section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">International Transfers</h2> <h2 className="text-xl font-medium">International Transfers</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Our providers may process data in locations outside of your country Our providers may process data in locations outside of your country
@@ -146,7 +146,7 @@ export default function PrivacyPage() {
</p> </p>
</section> </section>
<section className="space-y-3 mb-8"> <section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Your Rights</h2> <h2 className="text-xl font-medium">Your Rights</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Depending on your location, you may have rights to access, correct, Depending on your location, you may have rights to access, correct,
@@ -158,7 +158,7 @@ export default function PrivacyPage() {
</p> </p>
</section> </section>
<section className="space-y-3 mb-8"> <section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Contact</h2> <h2 className="text-xl font-medium">Contact</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
For privacy requests or questions, email us at For privacy requests or questions, email us at
@@ -172,7 +172,7 @@ export default function PrivacyPage() {
</p> </p>
</section> </section>
<section className="space-y-3 mb-8"> <section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Children</h2> <h2 className="text-xl font-medium">Children</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Our services are not directed to children, and we do not knowingly Our services are not directed to children, and we do not knowingly
@@ -180,7 +180,7 @@ export default function PrivacyPage() {
</p> </p>
</section> </section>
<section className="space-y-3 mb-10"> <section className="mb-10 space-y-3">
<h2 className="text-xl font-medium">Changes</h2> <h2 className="text-xl font-medium">Changes</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
We may update this policy from time to time. The "Last updated" date We may update this policy from time to time. The "Last updated" date
@@ -188,7 +188,7 @@ export default function PrivacyPage() {
</p> </p>
</section> </section>
<p className="text-xs text-muted-foreground"> <p className="text-muted-foreground text-xs">
Last updated: {new Date().toLocaleDateString()} Last updated: {new Date().toLocaleDateString()}
</p> </p>
</div> </div>

View File

@@ -1,17 +1,19 @@
import type { Metadata } from "next"; import type { Metadata } from 'next';
import { TopNav } from "~/components/TopNav"; import { TopNav } from '~/components/TopNav';
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Terms of Service useSend", title: 'Terms of Service useSend',
description: "Terms governing use of the useSend website and product.", description: 'Terms governing use of the useSend website and product.',
}; };
export default function TermsPage() { export default function TermsPage() {
return ( return (
<main className="min-h-screen bg-sidebar-background text-foreground"> <main className="bg-sidebar-background text-foreground min-h-screen">
<TopNav /> <TopNav />
<div className="mx-auto max-w-3xl px-6 py-16"> <div className="mx-auto max-w-3xl px-6 py-16">
<h1 className="text-3xl font-semibold tracking-tight mb-6">Terms of Service</h1> <h1 className="mb-6 text-3xl font-semibold tracking-tight">
Terms of Service
</h1>
<p className="text-muted-foreground mb-6"> <p className="text-muted-foreground mb-6">
These Terms of Service ("Terms") govern your access to and use of the These Terms of Service ("Terms") govern your access to and use of the
useSend marketing website at usesend.com and the useSend application. useSend marketing website at usesend.com and the useSend application.
@@ -19,7 +21,7 @@ export default function TermsPage() {
these Terms. these Terms.
</p> </p>
<section className="space-y-3 mb-8"> <section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Eligibility & Accounts</h2> <h2 className="text-xl font-medium">Eligibility & Accounts</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
You may use the site and product only if you can form a binding You may use the site and product only if you can form a binding
@@ -29,13 +31,13 @@ export default function TermsPage() {
</p> </p>
</section> </section>
<section className="space-y-3 mb-8"> <section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Acceptable Use</h2> <h2 className="text-xl font-medium">Acceptable Use</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
You agree not to misuse the site or product. Prohibited conduct You agree not to misuse the site or product. Prohibited conduct
includes, without limitation: includes, without limitation:
</p> </p>
<ul className="list-disc pl-5 space-y-2 text-muted-foreground"> <ul className="text-muted-foreground list-disc space-y-2 pl-5">
<li>Violating any applicable laws or regulations.</li> <li>Violating any applicable laws or regulations.</li>
<li>Infringing the rights of others or violating their privacy.</li> <li>Infringing the rights of others or violating their privacy.</li>
<li>Attempting to interfere with or disrupt the services.</li> <li>Attempting to interfere with or disrupt the services.</li>
@@ -46,7 +48,7 @@ export default function TermsPage() {
</ul> </ul>
</section> </section>
<section className="space-y-3 mb-8"> <section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Anti-Spam Enforcement</h2> <h2 className="text-xl font-medium">Anti-Spam Enforcement</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
To protect our community, we may suspend or block access for any To protect our community, we may suspend or block access for any
@@ -55,14 +57,14 @@ export default function TermsPage() {
may include immediate account suspension or termination. may include immediate account suspension or termination.
</p> </p>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Marketing communications sent via useSend must employ double Marketing communications sent via useSend must employ double opt-in
opt-in verification. Accounts that bypass double opt-in or misuse verification. Accounts that bypass double opt-in or misuse our
our transactional mail API for promotional campaigns may be transactional mail API for promotional campaigns may be suspended or
suspended or terminated without notice. terminated without notice.
</p> </p>
</section> </section>
<section className="space-y-3 mb-8"> <section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Intellectual Property</h2> <h2 className="text-xl font-medium">Intellectual Property</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Content on the site, including trademarks, logos, text, and Content on the site, including trademarks, logos, text, and
@@ -72,7 +74,7 @@ export default function TermsPage() {
</p> </p>
</section> </section>
<section className="space-y-3 mb-8"> <section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">ThirdParty Links</h2> <h2 className="text-xl font-medium">ThirdParty Links</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
The site may contain links to thirdparty websites or services we do The site may contain links to thirdparty websites or services we do
@@ -80,7 +82,7 @@ export default function TermsPage() {
</p> </p>
</section> </section>
<section className="space-y-3 mb-8"> <section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Disclaimer</h2> <h2 className="text-xl font-medium">Disclaimer</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
The site is provided on an "as is" and "as available" basis without The site is provided on an "as is" and "as available" basis without
@@ -88,7 +90,7 @@ export default function TermsPage() {
</p> </p>
</section> </section>
<section className="space-y-3 mb-8"> <section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Limitation of Liability</h2> <h2 className="text-xl font-medium">Limitation of Liability</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
To the fullest extent permitted by law, useSend shall not be liable To the fullest extent permitted by law, useSend shall not be liable
@@ -97,7 +99,7 @@ export default function TermsPage() {
</p> </p>
</section> </section>
<section className="space-y-3 mb-8"> <section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Indemnification</h2> <h2 className="text-xl font-medium">Indemnification</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
You agree to indemnify and hold harmless useSend from any claims, You agree to indemnify and hold harmless useSend from any claims,
@@ -106,7 +108,7 @@ export default function TermsPage() {
</p> </p>
</section> </section>
<section className="space-y-3 mb-8"> <section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Changes & Availability</h2> <h2 className="text-xl font-medium">Changes & Availability</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
We may modify these Terms and update the site or product at any We may modify these Terms and update the site or product at any
@@ -115,7 +117,7 @@ export default function TermsPage() {
</p> </p>
</section> </section>
<section className="space-y-3 mb-8"> <section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Governing Law</h2> <h2 className="text-xl font-medium">Governing Law</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
These Terms are governed by applicable laws without regard to These Terms are governed by applicable laws without regard to
@@ -125,15 +127,23 @@ export default function TermsPage() {
</p> </p>
</section> </section>
<section className="space-y-3 mb-10"> <section className="mb-10 space-y-3">
<h2 className="text-xl font-medium">Contact</h2> <h2 className="text-xl font-medium">Contact</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Questions about these Terms? Contact us at Questions about these Terms? Contact us at
<a href="mailto:hey@usesend.com" className="ml-1 underline decoration-dotted">hey@usesend.com</a>. <a
href="mailto:hey@usesend.com"
className="ml-1 underline decoration-dotted"
>
hey@usesend.com
</a>
.
</p> </p>
</section> </section>
<p className="text-xs text-muted-foreground">Last updated: {new Date().toLocaleDateString()}</p> <p className="text-muted-foreground text-xs">
Last updated: {new Date().toLocaleDateString()}
</p>
</div> </div>
</main> </main>
); );

View File

@@ -1,14 +1,10 @@
import type { ReactNode } from "react"; import type { ReactNode } from 'react';
import { SiteFooter } from "~/components/SiteFooter"; import { SiteFooter } from '~/components/SiteFooter';
import { TopNav } from "~/components/TopNav"; import { TopNav } from '~/components/TopNav';
export default function UpdateLayout({ export default function UpdateLayout({ children }: { children: ReactNode }) {
children,
}: {
children: ReactNode;
}) {
return ( return (
<main className="min-h-screen bg-background text-foreground"> <main className="bg-background text-foreground min-h-screen">
<TopNav /> <TopNav />
<div className="mx-auto w-full max-w-3xl px-6 py-16"> <div className="mx-auto w-full max-w-3xl px-6 py-16">
<article className="space-y-8">{children}</article> <article className="space-y-8">{children}</article>

View File

@@ -1,6 +1,6 @@
import { Button } from "@usesend/ui/src/button"; import { Button } from '@usesend/ui/src/button';
import { CodeBlock } from "@usesend/ui/src/code-block"; import { CodeBlock } from '@usesend/ui/src/code-block';
import { LangToggle } from "./CodeLangToggle"; import { LangToggle } from './CodeLangToggle';
const TS_CODE = `import { UseSend } from "usesend-js"; const TS_CODE = `import { UseSend } from "usesend-js";
@@ -82,34 +82,34 @@ if ($response === false) {
curl_close($ch);`; curl_close($ch);`;
export function CodeExample() { export function CodeExample() {
const containerId = "code-example"; const containerId = 'code-example';
const languages = [ const languages = [
{ {
key: "ts", key: 'ts',
label: "TypeScript", label: 'TypeScript',
kind: "ts", kind: 'ts',
shiki: "typescript" as const, shiki: 'typescript' as const,
code: TS_CODE, code: TS_CODE,
}, },
{ {
key: "py", key: 'py',
label: "Python", label: 'Python',
kind: "py", kind: 'py',
shiki: "python" as const, shiki: 'python' as const,
code: PY_CODE, code: PY_CODE,
}, },
{ {
key: "go", key: 'go',
label: "Go", label: 'Go',
kind: "go", kind: 'go',
shiki: "go" as const, shiki: 'go' as const,
code: GO_CODE, code: GO_CODE,
}, },
{ {
key: "php", key: 'php',
label: "PHP", label: 'PHP',
kind: "php", kind: 'php',
shiki: "php" as const, shiki: 'php' as const,
code: PHP_CODE, code: PHP_CODE,
}, },
]; ];
@@ -118,17 +118,17 @@ export function CodeExample() {
<section className="py-16 sm:py-20"> <section className="py-16 sm:py-20">
<div className="mx-auto max-w-6xl px-6"> <div className="mx-auto max-w-6xl px-6">
<div className="text-center"> <div className="text-center">
<div className="mb-2 text-sm uppercase tracking-wider text-primary"> <div className="text-primary mb-2 text-sm uppercase tracking-wider">
Developers Developers
</div> </div>
<p className="mt-1 text-xs sm:text-sm text-muted-foreground max-w-2xl mx-auto"> <p className="text-muted-foreground mx-auto mt-1 max-w-2xl text-xs sm:text-sm">
Typed SDKs and simple APIs, so you can focus on product not Typed SDKs and simple APIs, so you can focus on product not
plumbing. plumbing.
</p> </p>
</div> </div>
<div className="mt-8 overflow-hidden" id={containerId}> <div className="mt-8 overflow-hidden" id={containerId}>
<div className="flex items-center gap-2 justify-center py-2 text-xs text-muted-foreground mb-4"> <div className="text-muted-foreground mb-4 flex items-center justify-center gap-2 py-2 text-xs">
<LangToggle <LangToggle
containerId={containerId} containerId={containerId}
defaultLang="ts" defaultLang="ts"
@@ -139,19 +139,19 @@ export function CodeExample() {
}))} }))}
/> />
</div> </div>
<div className="rounded-[18px] bg-primary/20 p-1"> <div className="bg-primary/20 rounded-[18px] p-1">
<div className="rounded-[14px] bg-primary/20 p-0.5 shadow-sm"> <div className="bg-primary/20 rounded-[14px] p-0.5 shadow-sm">
<div className="bg-background rounded-xl overflow-hidden"> <div className="bg-background overflow-hidden rounded-xl">
{languages.map((l, idx) => ( {languages.map((l, idx) => (
<div <div
key={l.key} key={l.key}
data-lang-slot={l.key} data-lang-slot={l.key}
className={idx === 0 ? "block" : "hidden"} className={idx === 0 ? 'block' : 'hidden'}
> >
{/* Cast to any to align with shiki BundledLanguage without importing types here */} {/* Cast to any to align with shiki BundledLanguage without importing types here */}
<CodeBlock <CodeBlock
lang={l.shiki as any} lang={l.shiki as any}
className="p-4 rounded-[10px]" className="rounded-[10px] p-4"
> >
{l.code} {l.code}
</CodeBlock> </CodeBlock>

View File

@@ -1,13 +1,13 @@
"use client"; 'use client';
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react';
import Image from "next/image"; import Image from 'next/image';
import { Button } from "@usesend/ui/src/button"; import { Button } from '@usesend/ui/src/button';
type LangItem = { type LangItem = {
key: string; key: string;
label: string; label: string;
kind: "ts" | "py" | string; // used for icon selection kind: 'ts' | 'py' | string; // used for icon selection
}; };
export function LangToggle({ export function LangToggle({
@@ -26,38 +26,36 @@ export function LangToggle({
if (!container) return; if (!container) return;
const slots = Array.from( const slots = Array.from(
container.querySelectorAll<HTMLElement>("[data-lang-slot]") container.querySelectorAll<HTMLElement>('[data-lang-slot]'),
); );
for (const el of slots) { for (const el of slots) {
const key = el.getAttribute("data-lang-slot"); const key = el.getAttribute('data-lang-slot');
if (key === active) { if (key === active) {
el.classList.remove("hidden"); el.classList.remove('hidden');
el.classList.add("block"); el.classList.add('block');
} else { } else {
el.classList.add("hidden"); el.classList.add('hidden');
el.classList.remove("block"); el.classList.remove('block');
} }
} }
}, [active, containerId]); }, [active, containerId]);
return ( return (
<div className="flex items-center gap-2 justify-center"> <div className="flex items-center justify-center gap-2">
{languages.map((l) => ( {languages.map((l) => (
<Button <Button
key={l.key} key={l.key}
size="sm" size="sm"
variant="outline" variant="outline"
className={ className={
"px-3 bg-transparent hover:bg-transparent hover:text-inherit " + 'bg-transparent px-3 hover:bg-transparent hover:text-inherit ' +
(active === l.key (active === l.key ? 'border-primary' : 'border-input')
? "border-primary"
: "border-input")
} }
aria-pressed={active === l.key} aria-pressed={active === l.key}
onClick={() => setActive(l.key)} onClick={() => setActive(l.key)}
> >
<span className="inline-flex items-center"> <span className="inline-flex items-center">
<LangIcon kind={l.kind} className="h-4 w-4 mr-1" /> {l.label} <LangIcon kind={l.kind} className="mr-1 h-4 w-4" /> {l.label}
</span> </span>
</Button> </Button>
))} ))}
@@ -65,16 +63,27 @@ export function LangToggle({
); );
} }
function LangIcon({ kind, className = "h-4 w-4" }: { kind: string; className?: string }) { function LangIcon({
kind,
className = 'h-4 w-4',
}: {
kind: string;
className?: string;
}) {
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
if (failed) { if (failed) {
return ( return (
<svg viewBox="0 0 24 24" aria-hidden="true" className={className} role="img"> <svg
viewBox="0 0 24 24"
aria-hidden="true"
className={className}
role="img"
>
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.2" /> <circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.2" />
</svg> </svg>
); );
} }
if (kind === "ts") if (kind === 'ts')
return ( return (
<Image <Image
src="/typescript.svg" src="/typescript.svg"
@@ -86,7 +95,7 @@ function LangIcon({ kind, className = "h-4 w-4" }: { kind: string; className?: s
onError={() => setFailed(true)} onError={() => setFailed(true)}
/> />
); );
if (kind === "py") if (kind === 'py')
return ( return (
<Image <Image
src="/python.svg" src="/python.svg"
@@ -98,7 +107,7 @@ function LangIcon({ kind, className = "h-4 w-4" }: { kind: string; className?: s
onError={() => setFailed(true)} onError={() => setFailed(true)}
/> />
); );
if (kind === "go") if (kind === 'go')
return ( return (
<Image <Image
src="/go.svg" src="/go.svg"
@@ -110,7 +119,7 @@ function LangIcon({ kind, className = "h-4 w-4" }: { kind: string; className?: s
onError={() => setFailed(true)} onError={() => setFailed(true)}
/> />
); );
if (kind === "php") if (kind === 'php')
return ( return (
<Image <Image
src="/php.svg" src="/php.svg"
@@ -123,7 +132,12 @@ function LangIcon({ kind, className = "h-4 w-4" }: { kind: string; className?: s
/> />
); );
return ( return (
<svg viewBox="0 0 24 24" aria-hidden="true" className={className} role="img"> <svg
viewBox="0 0 24 24"
aria-hidden="true"
className={className}
role="img"
>
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.2" /> <circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.2" />
</svg> </svg>
); );

View File

@@ -1,6 +1,6 @@
"use client"; 'use client';
import Image from "next/image"; import Image from 'next/image';
type FeatureCardProps = { type FeatureCardProps = {
title?: string; title?: string;
@@ -21,33 +21,33 @@ export function FeatureCard({
imageSrc, imageSrc,
}: FeatureCardProps) { }: FeatureCardProps) {
return ( return (
<div className="rounded-[18px] bg-primary/20 p-1 "> <div className="bg-primary/20 rounded-[18px] p-1">
<div className="h-full rounded-[14px] bg-primary/20 p-0.5 shadow-sm"> <div className="bg-primary/20 h-full rounded-[14px] p-0.5 shadow-sm">
<div className="bg-background rounded-xl h-full flex flex-col"> <div className="bg-background flex h-full flex-col rounded-xl">
<div className="relative w-full aspect-[16/9] rounded-t-xl overflow-hidden"> <div className="relative aspect-[16/9] w-full overflow-hidden rounded-t-xl">
{imageLightSrc || imageDarkSrc ? ( {imageLightSrc || imageDarkSrc ? (
<> <>
<Image <Image
src={(imageLightSrc || imageDarkSrc)!} src={(imageLightSrc || imageDarkSrc)!}
alt={title || "Feature image"} alt={title || 'Feature image'}
fill fill
className="object-cover dark:hidden rounded-t-xl" className="rounded-t-xl object-cover dark:hidden"
priority={false} priority={false}
/> />
<Image <Image
src={(imageDarkSrc || imageLightSrc)!} src={(imageDarkSrc || imageLightSrc)!}
alt={title || "Feature image"} alt={title || 'Feature image'}
fill fill
className="object-cover hidden dark:block rounded-t-xl" className="hidden rounded-t-xl object-cover dark:block"
priority={false} priority={false}
/> />
</> </>
) : imageSrc ? ( ) : imageSrc ? (
<Image <Image
src={imageSrc} src={imageSrc}
alt={title || "Feature image"} alt={title || 'Feature image'}
fill fill
className="object-cover rounded-t-xl" className="rounded-t-xl object-cover"
priority={false} priority={false}
/> />
) : ( ) : (
@@ -56,29 +56,29 @@ export function FeatureCard({
src="/hero-light.png" src="/hero-light.png"
alt="Feature image" alt="Feature image"
fill fill
className="object-cover dark:hidden rounded-t-xl" className="rounded-t-xl object-cover dark:hidden"
priority={false} priority={false}
/> />
<Image <Image
src="/hero-dark.png" src="/hero-dark.png"
alt="Feature image" alt="Feature image"
fill fill
className="object-cover hidden dark:block rounded-t-xl" className="hidden rounded-t-xl object-cover dark:block"
priority={false} priority={false}
/> />
</> </>
)} )}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-12 sm:h-16 bg-gradient-to-b from-transparent via-background/60 to-background" /> <div className="via-background/60 to-background pointer-events-none absolute inset-x-0 bottom-0 h-12 bg-gradient-to-b from-transparent sm:h-16" />
</div> </div>
<div className="p-5 flex-1 flex flex-col"> <div className="flex flex-1 flex-col p-5">
<h3 className="text-base sm:text-lg text-primary font-sans"> <h3 className="text-primary font-sans text-base sm:text-lg">
{title || ""} {title || ''}
</h3> </h3>
{content ? ( {content ? (
<p className="mt-2 text-sm leading-relaxed">{content}</p> <p className="mt-2 text-sm leading-relaxed">{content}</p>
) : ( ) : (
<div className="mt-2 text-sm text-muted-foreground min-h-[1.5rem]"></div> <div className="text-muted-foreground mt-2 min-h-[1.5rem] text-sm"></div>
)} )}
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
"use client"; 'use client';
export function FeatureCardPlain({ export function FeatureCardPlain({
title, title,
@@ -7,19 +7,18 @@ export function FeatureCardPlain({
title?: string; title?: string;
content?: string; content?: string;
}) { }) {
return ( return (
<div className="rounded-[18px] bg-primary/20 p-1"> <div className="bg-primary/20 rounded-[18px] p-1">
<div className="h-full rounded-[14px] bg-primary/20 p-0.5 shadow-sm"> <div className="bg-primary/20 h-full rounded-[14px] p-0.5 shadow-sm">
<div className="bg-background rounded-xl h-full flex flex-col"> <div className="bg-background flex h-full flex-col rounded-xl">
<div className="p-5 flex-1 flex flex-col"> <div className="flex flex-1 flex-col p-5">
<h3 className="text-base sm:text-lg text-primary font-sans"> <h3 className="text-primary font-sans text-base sm:text-lg">
{title || ""} {title || ''}
</h3> </h3>
{content ? ( {content ? (
<p className="mt-2 text-sm leading-relaxed">{content}</p> <p className="mt-2 text-sm leading-relaxed">{content}</p>
) : ( ) : (
<div className="mt-2 text-sm text-muted-foreground min-h-[1.5rem]"></div> <div className="text-muted-foreground mt-2 min-h-[1.5rem] text-sm"></div>
)} )}
</div> </div>
</div> </div>

View File

@@ -1,6 +1,6 @@
import { Button } from "@usesend/ui/src/button"; import { Button } from '@usesend/ui/src/button';
const REPO = "usesend/usesend"; const REPO = 'usesend/usesend';
const REPO_URL = `https://github.com/${REPO}`; const REPO_URL = `https://github.com/${REPO}`;
const API_URL = `https://api.github.com/repos/${REPO}`; const API_URL = `https://api.github.com/repos/${REPO}`;
const REVALIDATE_SECONDS = 60 * 60 * 24 * 7; // 7 days const REVALIDATE_SECONDS = 60 * 60 * 24 * 7; // 7 days
@@ -8,15 +8,15 @@ const REVALIDATE_SECONDS = 60 * 60 * 24 * 7; // 7 days
function formatCompact(n: number): string { function formatCompact(n: number): string {
if (n < 1000) return n.toLocaleString(); if (n < 1000) return n.toLocaleString();
const units = [ const units = [
{ v: 1_000_000_000, s: " B" }, { v: 1_000_000_000, s: ' B' },
{ v: 1_000_000, s: " M" }, { v: 1_000_000, s: ' M' },
{ v: 1_000, s: " K" }, { v: 1_000, s: ' K' },
]; ];
for (const u of units) { for (const u of units) {
if (n >= u.v) { if (n >= u.v) {
const num = n / u.v; const num = n / u.v;
const rounded = Math.round(num * 10) / 10; // 1 decimal const rounded = Math.round(num * 10) / 10; // 1 decimal
const str = rounded.toFixed(1).replace(/\.0$/, ""); const str = rounded.toFixed(1).replace(/\.0$/, '');
return str + u.s; return str + u.s;
} }
} }
@@ -25,9 +25,9 @@ function formatCompact(n: number): string {
export async function GitHubStarsButton() { export async function GitHubStarsButton() {
const headers: Record<string, string> = { const headers: Record<string, string> = {
Accept: "application/vnd.github+json", Accept: 'application/vnd.github+json',
"X-GitHub-Api-Version": "2022-11-28", 'X-GitHub-Api-Version': '2022-11-28',
"User-Agent": "usesend-marketing", 'User-Agent': 'usesend-marketing',
}; };
if (process.env.GITHUB_TOKEN) if (process.env.GITHUB_TOKEN)
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
@@ -40,17 +40,17 @@ export async function GitHubStarsButton() {
}); });
if (res.ok) { if (res.ok) {
const json = (await res.json()) as { stargazers_count?: number }; const json = (await res.json()) as { stargazers_count?: number };
if (typeof json.stargazers_count === "number") if (typeof json.stargazers_count === 'number')
stars = json.stargazers_count; stars = json.stargazers_count;
} }
} catch { } catch {
// ignore network errors; show placeholder // ignore network errors; show placeholder
} }
const formatted = stars == null ? "—" : formatCompact(stars); const formatted = stars == null ? '—' : formatCompact(stars);
return ( return (
<Button variant="outline" size="lg" className="px-4 gap-2"> <Button variant="outline" size="lg" className="gap-2 px-4">
<a <a
href={REPO_URL} href={REPO_URL}
target="_blank" target="_blank"
@@ -60,7 +60,7 @@ export async function GitHubStarsButton() {
> >
<GitHubIcon className="h-4 w-4" /> <GitHubIcon className="h-4 w-4" />
<span>GitHub</span> <span>GitHub</span>
<span className="rounded-md bg-muted px-1.5 py-0.5 text-xs tabular-nums text-muted-foreground"> <span className="bg-muted text-muted-foreground rounded-md px-1.5 py-0.5 text-xs tabular-nums">
{formatted} {formatted}
</span> </span>
</a> </a>
@@ -68,7 +68,7 @@ export async function GitHubStarsButton() {
); );
} }
function GitHubIcon({ className = "" }: { className?: string }) { function GitHubIcon({ className = '' }: { className?: string }) {
return ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

View File

@@ -1,6 +1,6 @@
"use client"; 'use client';
import React from "react"; import React from 'react';
type SliderProps = { type SliderProps = {
label: string; label: string;
@@ -19,35 +19,35 @@ function Slider({
min = 0, min = 0,
max = 100000, max = 100000,
step = 500, step = 500,
suffix = "", suffix = '',
}: SliderProps) { }: SliderProps) {
const id = React.useId(); const id = React.useId();
const [dragging, setDragging] = React.useState(false); const [dragging, setDragging] = React.useState(false);
const percent = Math.max( const percent = Math.max(
0, 0,
Math.min(100, ((value - min) / (max - min)) * 100) Math.min(100, ((value - min) / (max - min)) * 100),
); );
React.useEffect(() => { React.useEffect(() => {
if (!dragging) return; if (!dragging) return;
const stop = () => setDragging(false); const stop = () => setDragging(false);
window.addEventListener("mouseup", stop); window.addEventListener('mouseup', stop);
window.addEventListener("touchend", stop); window.addEventListener('touchend', stop);
window.addEventListener("pointerup", stop); window.addEventListener('pointerup', stop);
return () => { return () => {
window.removeEventListener("mouseup", stop); window.removeEventListener('mouseup', stop);
window.removeEventListener("touchend", stop); window.removeEventListener('touchend', stop);
window.removeEventListener("pointerup", stop); window.removeEventListener('pointerup', stop);
}; };
}, [dragging]); }, [dragging]);
return ( return (
<div className="flex flex-col sm:flex-row gap-3 sm:items-center"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="w-full sm:w-56 md:w-72 shrink-0"> <div className="w-full shrink-0 sm:w-56 md:w-72">
<label htmlFor={id} className="text-sm font-medium block"> <label htmlFor={id} className="block text-sm font-medium">
{label} {label}
</label> </label>
<div className="mt-1 text-xs sm:text-sm text-muted-foreground tabular-nums truncate"> <div className="text-muted-foreground mt-1 truncate text-xs tabular-nums sm:text-sm">
{value.toLocaleString()} {suffix} {value.toLocaleString()} {suffix}
</div> </div>
</div> </div>
@@ -63,7 +63,7 @@ function Slider({
onMouseDown={() => setDragging(true)} onMouseDown={() => setDragging(true)}
onTouchStart={() => setDragging(true)} onTouchStart={() => setDragging(true)}
onPointerDown={() => setDragging(true)} onPointerDown={() => setDragging(true)}
className="w-full accent-primary" className="accent-primary w-full"
aria-label={label} aria-label={label}
aria-valuetext={`${value.toLocaleString()} ${suffix}`} aria-valuetext={`${value.toLocaleString()} ${suffix}`}
/> />
@@ -72,7 +72,7 @@ function Slider({
className="pointer-events-none absolute -top-9 left-0 -translate-x-1/2" className="pointer-events-none absolute -top-9 left-0 -translate-x-1/2"
style={{ left: `${percent}%` }} style={{ left: `${percent}%` }}
> >
<div className="rounded-md bg-foreground px-2 py-1 text-[11px] font-medium text-background tabular-nums shadow whitespace-nowrap"> <div className="bg-foreground text-background whitespace-nowrap rounded-md px-2 py-1 text-[11px] font-medium tabular-nums shadow">
{value.toLocaleString()} {suffix} {value.toLocaleString()} {suffix}
</div> </div>
</div> </div>
@@ -98,15 +98,15 @@ export function PricingCalculator() {
const totalDue = Math.max(subtotal, MINIMUM_SPEND); const totalDue = Math.max(subtotal, MINIMUM_SPEND);
return ( return (
<div className="rounded-[18px] bg-primary/20 p-1"> <div className="bg-primary/20 rounded-[18px] p-1">
<div className="rounded-[14px] bg-primary/20 p-0.5 shadow-sm"> <div className="bg-primary/20 rounded-[14px] p-0.5 shadow-sm">
<div className="bg-background rounded-xl p-5 pb-10"> <div className="bg-background rounded-xl p-5 pb-10">
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="text-center"> <div className="text-center">
<div className="text-sm uppercase tracking-wider text-primary"> <div className="text-primary text-sm uppercase tracking-wider">
Pricing Calculator Pricing Calculator
</div> </div>
<p className="mt-1 text-xs sm:text-sm text-muted-foreground"> <p className="text-muted-foreground mt-1 text-xs sm:text-sm">
Drag the sliders to estimate your monthly cost. Drag the sliders to estimate your monthly cost.
</p> </p>
</div> </div>
@@ -132,38 +132,38 @@ export function PricingCalculator() {
/> />
</div> </div>
<div className="mt-2 grid grid-cols-1 sm:grid-cols-3 gap-4 items-center"> <div className="mt-2 grid grid-cols-1 items-center gap-4 sm:grid-cols-3">
<div className="rounded-lg border border-primary/30 p-4"> <div className="border-primary/30 rounded-lg border p-4">
<div className="text-xs text-muted-foreground">Marketing</div> <div className="text-muted-foreground text-xs">Marketing</div>
<div className="text-lg font-medium"> <div className="text-lg font-medium">
${marketingCost.toFixed(2)} ${marketingCost.toFixed(2)}
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-muted-foreground text-xs">
@ ${MARKETING_RATE.toFixed(4)} each @ ${MARKETING_RATE.toFixed(4)} each
</div> </div>
</div> </div>
<div className="rounded-lg border border-primary/30 p-4"> <div className="border-primary/30 rounded-lg border p-4">
<div className="text-xs text-muted-foreground"> <div className="text-muted-foreground text-xs">
Transactional Transactional
</div> </div>
<div className="text-lg font-medium"> <div className="text-lg font-medium">
${transactionalCost.toFixed(2)} ${transactionalCost.toFixed(2)}
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-muted-foreground text-xs">
@ ${TRANSACTIONAL_RATE.toFixed(4)} each @ ${TRANSACTIONAL_RATE.toFixed(4)} each
</div> </div>
</div> </div>
<div className="rounded-lg border border-primary/30 p-4 bg-primary/10"> <div className="border-primary/30 bg-primary/10 rounded-lg border p-4">
<div className="text-xs text-muted-foreground"> <div className="text-muted-foreground text-xs">
Estimated Total Estimated Total
</div> </div>
<div className="text-3xl text-primary font-semibold"> <div className="text-primary text-3xl font-semibold">
${totalDue.toFixed(2)} ${totalDue.toFixed(2)}
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-muted-foreground text-xs">
{subtotal < MINIMUM_SPEND {subtotal < MINIMUM_SPEND
? "Minimum $10 applies" ? 'Minimum $10 applies'
: "before taxes"} : 'before taxes'}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,16 +1,16 @@
import Image from "next/image"; import Image from 'next/image';
import Link from "next/link"; import Link from 'next/link';
// Replaced StatusBadge with external status badge image // Replaced StatusBadge with external status badge image
const REPO = "usesend/usesend"; const REPO = 'usesend/usesend';
const REPO_URL = `https://github.com/${REPO}`; const REPO_URL = `https://github.com/${REPO}`;
const APP_URL = "https://app.usesend.com"; const APP_URL = 'https://app.usesend.com';
export function SiteFooter() { export function SiteFooter() {
return ( return (
<footer className="py-10 border-t border-border"> <footer className="border-border border-t py-10">
<div className="mx-auto max-w-6xl px-6"> <div className="mx-auto max-w-6xl px-6">
<div className="flex flex-col sm:flex-row sm:items-start gap-4"> <div className="flex flex-col gap-4 sm:flex-row sm:items-start">
<div className="flex items-center gap-2 sm:w-56"> <div className="flex items-center gap-2 sm:w-56">
<Image <Image
src="/logo-squircle.png" src="/logo-squircle.png"
@@ -21,13 +21,13 @@ export function SiteFooter() {
<span className="text-primary font-mono">useSend</span> <span className="text-primary font-mono">useSend</span>
</div> </div>
<div className="sm:ml-auto flex items-start gap-4"> <div className="flex items-start gap-4 sm:ml-auto">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-x-12 gap-y-2 text-sm"> <div className="grid grid-cols-2 gap-x-12 gap-y-2 text-sm sm:grid-cols-3">
<div> <div>
<div className="text-xs uppercase tracking-wider mb-2"> <div className="mb-2 text-xs uppercase tracking-wider">
Product Product
</div> </div>
<ul className="space-y-2 text-muted-foreground"> <ul className="text-muted-foreground space-y-2">
<li> <li>
<a <a
href={APP_URL} href={APP_URL}
@@ -62,10 +62,10 @@ export function SiteFooter() {
</div> </div>
<div> <div>
<div className="text-xs uppercase tracking-wider mb-2"> <div className="mb-2 text-xs uppercase tracking-wider">
Contact Contact
</div> </div>
<ul className="space-y-2 text-muted-foreground"> <ul className="text-muted-foreground space-y-2">
<li> <li>
<a <a
href="mailto:hey@usesend.com" href="mailto:hey@usesend.com"
@@ -118,10 +118,10 @@ export function SiteFooter() {
</div> </div>
<div> <div>
<div className="text-xs uppercase tracking-wider mb-2"> <div className="mb-2 text-xs uppercase tracking-wider">
Company Company
</div> </div>
<ul className="space-y-2 text-muted-foreground"> <ul className="text-muted-foreground space-y-2">
<li> <li>
<Link <Link
href="/privacy" href="/privacy"
@@ -160,7 +160,7 @@ export function SiteFooter() {
</div> </div>
</div> </div>
<div className="mt-6 text-xs text-muted-foreground mx-auto text-center"> <div className="text-muted-foreground mx-auto mt-6 text-center text-xs">
© {new Date().getFullYear()} useSend. All rights reserved. © {new Date().getFullYear()} useSend. All rights reserved.
</div> </div>
</div> </div>

View File

@@ -1,31 +1,38 @@
"use client"; 'use client';
import Image from "next/image"; import Image from 'next/image';
import Link from "next/link"; import Link from 'next/link';
import { usePathname } from "next/navigation"; import { usePathname } from 'next/navigation';
import { useState } from "react"; import { useState } from 'react';
import { Button } from "@usesend/ui/src/button"; import { Button } from '@usesend/ui/src/button';
const REPO = "usesend/usesend"; const REPO = 'usesend/usesend';
const REPO_URL = `https://github.com/${REPO}`; const REPO_URL = `https://github.com/${REPO}`;
const APP_URL = "https://app.usesend.com"; const APP_URL = 'https://app.usesend.com';
export function TopNav() { export function TopNav() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const pathname = usePathname(); const pathname = usePathname();
const isHome = pathname === "/"; const isHome = pathname === '/';
const pricingHref = isHome ? "#pricing" : "/#pricing"; const pricingHref = isHome ? '#pricing' : '/#pricing';
return ( return (
<header className="py-4 border-b border-border sticky top-0 z-20 backdrop-blur supports-[backdrop-filter]:bg-sidebar-background/80"> <header className="border-border supports-[backdrop-filter]:bg-sidebar-background/80 sticky top-0 z-20 border-b py-4 backdrop-blur">
<div className="mx-auto max-w-6xl px-6 flex items-center justify-between gap-4 text-sm"> <div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-6 text-sm">
<Link href="/" className="flex items-center gap-2 group"> <Link href="/" className="group flex items-center gap-2">
<Image src="/logo-squircle.png" alt="useSend" width={24} height={24} /> <Image
<span className="text-primary font-mono text-[16px] group-hover:opacity-90">useSend</span> src="/logo-squircle.png"
alt="useSend"
width={24}
height={24}
/>
<span className="text-primary font-mono text-[16px] group-hover:opacity-90">
useSend
</span>
</Link> </Link>
{/* Desktop nav */} {/* Desktop nav */}
<nav className="hidden sm:flex items-center gap-4 text-muted-foreground"> <nav className="text-muted-foreground hidden items-center gap-4 sm:flex">
<Link href={pricingHref} className="hover:text-foreground"> <Link href={pricingHref} className="hover:text-foreground">
Pricing Pricing
</Link> </Link>
@@ -55,14 +62,29 @@ export function TopNav() {
{/* Mobile hamburger */} {/* Mobile hamburger */}
<button <button
aria-label="Open menu" aria-label="Open menu"
className="sm:hidden inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-accent focus:outline-none focus:ring-2 focus:ring-border" className="text-muted-foreground hover:text-foreground hover:bg-accent focus:ring-border inline-flex items-center justify-center rounded-md p-2 focus:outline-none focus:ring-2 sm:hidden"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-6 w-6"> <svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="h-6 w-6"
>
{open ? ( {open ? (
<path d="M6 18 18 6M6 6l12 12" strokeLinecap="round" strokeLinejoin="round" /> <path
d="M6 18 18 6M6 6l12 12"
strokeLinecap="round"
strokeLinejoin="round"
/>
) : ( ) : (
<path d="M3 6h18M3 12h18M3 18h18" strokeLinecap="round" strokeLinejoin="round" /> <path
d="M3 6h18M3 12h18M3 18h18"
strokeLinecap="round"
strokeLinejoin="round"
/>
)} )}
</svg> </svg>
</button> </button>
@@ -70,16 +92,20 @@ export function TopNav() {
{/* Mobile menu panel */} {/* Mobile menu panel */}
{open ? ( {open ? (
<div className="sm:hidden border-t border-border bg-sidebar-background/95 backdrop-blur"> <div className="border-border bg-sidebar-background/95 border-t backdrop-blur sm:hidden">
<div className="mx-auto max-w-6xl px-6 py-3 flex flex-col gap-2"> <div className="mx-auto flex max-w-6xl flex-col gap-2 px-6 py-3">
<Link href={pricingHref} className="py-2 text-muted-foreground hover:text-foreground" onClick={() => setOpen(false)}> <Link
href={pricingHref}
className="text-muted-foreground hover:text-foreground py-2"
onClick={() => setOpen(false)}
>
Pricing Pricing
</Link> </Link>
<a <a
href="https://docs.usesend.com" href="https://docs.usesend.com"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="py-2 text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground py-2"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
Docs Docs
@@ -88,14 +114,19 @@ export function TopNav() {
href={REPO_URL} href={REPO_URL}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="py-2 text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground py-2"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
GitHub GitHub
</a> </a>
<div className="pt-2"> <div className="pt-2">
<Button className="w-full"> <Button className="w-full">
<a href={APP_URL} target="_blank" rel="noopener noreferrer" onClick={() => setOpen(false)}> <a
href={APP_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => setOpen(false)}
>
Get started Get started
</a> </a>
</Button> </Button>

View File

@@ -1,31 +1,31 @@
import type { MDXComponents } from "mdx/types"; import type { MDXComponents } from 'mdx/types';
const components = { const components = {
h1: ({ children }) => ( h1: ({ children }) => (
<h1 className="text-3xl font-semibold tracking-wide font-sans text-primary"> <h1 className="text-primary font-sans text-3xl font-semibold tracking-wide">
{children} {children}
</h1> </h1>
), ),
h2: ({ children }) => ( h2: ({ children }) => (
<h2 className="text-xl font-semibold tracking-wide font-sans text-primary"> <h2 className="text-primary font-sans text-xl font-semibold tracking-wide">
{children} {children}
</h2> </h2>
), ),
h3: ({ children }) => ( h3: ({ children }) => (
<h3 className="text-lg font-medium tracking-wide font-sans">{children}</h3> <h3 className="font-sans text-lg font-medium tracking-wide">{children}</h3>
), ),
p: ({ children }) => ( p: ({ children }) => (
<p className="text-base font-normal tracking-wide leading-relaxed font-sans"> <p className="font-sans text-base font-normal leading-relaxed tracking-wide">
{children} {children}
</p> </p>
), ),
ul: ({ children }) => ( ul: ({ children }) => (
<ul className="list-disc list-inside font-sans pl-4 space-y-1"> <ul className="list-inside list-disc space-y-1 pl-4 font-sans">
{children} {children}
</ul> </ul>
), ),
a: ({ children, href }) => ( a: ({ children, href }) => (
<a href={href} className=" text-primary-light"> <a href={href} className="text-primary-light">
{children} {children}
</a> </a>
), ),

View File

@@ -1,13 +1,12 @@
import { type Config } from "tailwindcss"; import { type Config } from 'tailwindcss';
import sharedConfig from "@usesend/tailwind-config/tailwind.config"; import sharedConfig from '@usesend/tailwind-config/tailwind.config';
import path from "path"; import path from 'path';
export default { export default {
...sharedConfig, ...sharedConfig,
content: [ content: [
"./src/**/*.tsx", './src/**/*.tsx',
`${path.join(require.resolve("@usesend/ui"), "..")}/**/*.{ts,tsx}`, `${path.join(require.resolve('@usesend/ui'), '..')}/**/*.{ts,tsx}`,
`${path.join(require.resolve("@usesend/email-editor"), "..")}/**/*.{ts,tsx}`, `${path.join(require.resolve('@usesend/email-editor'), '..')}/**/*.{ts,tsx}`,
], ],
} satisfies Config; } satisfies Config;

View File

@@ -1,16 +1,16 @@
import { SMTPServer, SMTPServerOptions, SMTPServerSession } from "smtp-server"; import { SMTPServer, SMTPServerOptions, SMTPServerSession } from 'smtp-server';
import { Readable } from "stream"; import { Readable } from 'stream';
import dotenv from "dotenv"; import dotenv from 'dotenv';
import { simpleParser } from "mailparser"; import { simpleParser } from 'mailparser';
import { readFileSync, watch, FSWatcher } from "fs"; import { readFileSync, watch, FSWatcher } from 'fs';
dotenv.config(); dotenv.config();
const AUTH_USERNAME = process.env.SMTP_AUTH_USERNAME ?? "usesend"; const AUTH_USERNAME = process.env.SMTP_AUTH_USERNAME ?? 'usesend';
const BASE_URL = const BASE_URL =
process.env.USESEND_BASE_URL ?? process.env.USESEND_BASE_URL ??
process.env.UNSEND_BASE_URL ?? process.env.UNSEND_BASE_URL ??
"https://app.usesend.com"; 'https://app.usesend.com';
const SSL_KEY_PATH = const SSL_KEY_PATH =
process.env.USESEND_API_KEY_PATH ?? process.env.UNSEND_API_KEY_PATH; process.env.USESEND_API_KEY_PATH ?? process.env.UNSEND_API_KEY_PATH;
const SSL_CERT_PATH = const SSL_CERT_PATH =
@@ -18,17 +18,17 @@ const SSL_CERT_PATH =
async function sendEmailToUseSend(emailData: any, apiKey: string) { async function sendEmailToUseSend(emailData: any, apiKey: string) {
try { try {
const apiEndpoint = "/api/v1/emails"; const apiEndpoint = '/api/v1/emails';
const url = new URL(apiEndpoint, BASE_URL); // Combine base URL with endpoint const url = new URL(apiEndpoint, BASE_URL); // Combine base URL with endpoint
console.log("Sending email to useSend API at:", url.href); // Debug statement console.log('Sending email to useSend API at:', url.href); // Debug statement
const emailDataText = JSON.stringify(emailData); const emailDataText = JSON.stringify(emailData);
const response = await fetch(url.href, { const response = await fetch(url.href, {
method: "POST", method: 'POST',
headers: { headers: {
Authorization: `Bearer ${apiKey}`, Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json", 'Content-Type': 'application/json',
}, },
body: emailDataText, body: emailDataText,
}); });
@@ -36,24 +36,24 @@ async function sendEmailToUseSend(emailData: any, apiKey: string) {
if (!response.ok) { if (!response.ok) {
const errorData = await response.text(); const errorData = await response.text();
console.error( console.error(
"useSend API error response: error:", 'useSend API error response: error:',
JSON.stringify(errorData, null, 4), JSON.stringify(errorData, null, 4),
`\nemail data: ${emailDataText}`, `\nemail data: ${emailDataText}`,
); );
throw new Error( throw new Error(
`Failed to send email: ${errorData || "Unknown error from server"}`, `Failed to send email: ${errorData || 'Unknown error from server'}`,
); );
} }
const responseData = await response.json(); const responseData = await response.json();
console.log("useSend API response:", responseData); console.log('useSend API response:', responseData);
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
console.error("Error message:", error.message); console.error('Error message:', error.message);
throw new Error(`Failed to send email: ${error.message}`); throw new Error(`Failed to send email: ${error.message}`);
} else { } else {
console.error("Unexpected error:", error); console.error('Unexpected error:', error);
throw new Error("Failed to send email: Unexpected error occurred"); throw new Error('Failed to send email: Unexpected error occurred');
} }
} }
} }
@@ -76,24 +76,24 @@ const serverOptions: SMTPServerOptions = {
session: SMTPServerSession, session: SMTPServerSession,
callback: (error?: Error) => void, callback: (error?: Error) => void,
) { ) {
console.log("Receiving email data..."); // Debug statement console.log('Receiving email data...'); // Debug statement
simpleParser(stream, (err, parsed) => { simpleParser(stream, (err, parsed) => {
if (err) { if (err) {
console.error("Failed to parse email data:", err.message); console.error('Failed to parse email data:', err.message);
return callback(err); return callback(err);
} }
if (!session.user) { if (!session.user) {
console.error("No API key found in session"); console.error('No API key found in session');
return callback(new Error("No API key found in session")); return callback(new Error('No API key found in session'));
} }
const emailObject = { const emailObject = {
to: Array.isArray(parsed.to) to: Array.isArray(parsed.to)
? parsed.to.map((addr) => addr.text).join(", ") ? parsed.to.map((addr) => addr.text).join(', ')
: parsed.to?.text, : parsed.to?.text,
from: Array.isArray(parsed.from) from: Array.isArray(parsed.from)
? parsed.from.map((addr) => addr.text).join(", ") ? parsed.from.map((addr) => addr.text).join(', ')
: parsed.from?.text, : parsed.from?.text,
subject: parsed.subject, subject: parsed.subject,
text: parsed.text, text: parsed.text,
@@ -103,20 +103,20 @@ const serverOptions: SMTPServerOptions = {
sendEmailToUseSend(emailObject, session.user) sendEmailToUseSend(emailObject, session.user)
.then(() => callback()) .then(() => callback())
.then(() => console.log("Email sent successfully to: ", emailObject.to)) .then(() => console.log('Email sent successfully to: ', emailObject.to))
.catch((error) => { .catch((error) => {
console.error("Failed to send email:", error.message); console.error('Failed to send email:', error.message);
callback(error); callback(error);
}); });
}); });
}, },
onAuth(auth, session: any, callback: (error?: Error, user?: any) => void) { onAuth(auth, session: any, callback: (error?: Error, user?: any) => void) {
if (auth.username === AUTH_USERNAME && auth.password) { if (auth.username === AUTH_USERNAME && auth.password) {
console.log("Authenticated successfully"); // Debug statement console.log('Authenticated successfully'); // Debug statement
callback(undefined, { user: auth.password }); callback(undefined, { user: auth.password });
} else { } else {
console.error("Invalid username or password"); console.error('Invalid username or password');
callback(new Error("Invalid username or password")); callback(new Error('Invalid username or password'));
} }
}, },
size: 10485760, size: 10485760,
@@ -137,7 +137,7 @@ function startServers() {
); );
}); });
server.on("error", (err) => { server.on('error', (err) => {
console.error(`Error occurred on port ${port}:`, err); console.error(`Error occurred on port ${port}:`, err);
}); });
@@ -153,7 +153,7 @@ function startServers() {
console.log(`STARTTLS SMTP server is listening on port ${port}`); console.log(`STARTTLS SMTP server is listening on port ${port}`);
}); });
server.on("error", (err) => { server.on('error', (err) => {
console.error(`Error occurred on port ${port}:`, err); console.error(`Error occurred on port ${port}:`, err);
}); });
@@ -166,10 +166,10 @@ function startServers() {
const { key, cert } = loadCertificates(); const { key, cert } = loadCertificates();
if (key && cert) { if (key && cert) {
servers.forEach((srv) => srv.updateSecureContext({ key, cert })); servers.forEach((srv) => srv.updateSecureContext({ key, cert }));
console.log("TLS certificates reloaded"); console.log('TLS certificates reloaded');
} }
} catch (err) { } catch (err) {
console.error("Failed to reload TLS certificates", err); console.error('Failed to reload TLS certificates', err);
} }
}; };
@@ -183,12 +183,12 @@ function startServers() {
const { servers, watchers } = startServers(); const { servers, watchers } = startServers();
function shutdown() { function shutdown() {
console.log("Shutting down SMTP server..."); console.log('Shutting down SMTP server...');
watchers.forEach((w) => w.close()); watchers.forEach((w) => w.close());
servers.forEach((s) => s.close()); servers.forEach((s) => s.close());
process.exit(0); process.exit(0);
} }
["SIGINT", "SIGTERM", "SIGQUIT"].forEach((signal) => { ['SIGINT', 'SIGTERM', 'SIGQUIT'].forEach((signal) => {
process.on(signal, shutdown); process.on(signal, shutdown);
}); });

View File

@@ -1,10 +1,10 @@
// eslint-disable-next-line import/no-extraneous-dependencies // eslint-disable-next-line import/no-extraneous-dependencies
import { defineConfig, Options } from "tsup"; import { defineConfig, Options } from 'tsup';
// eslint-disable-next-line import/no-default-export // eslint-disable-next-line import/no-default-export
export default defineConfig((options: Options) => ({ export default defineConfig((options: Options) => ({
entry: ["src/server.ts"], entry: ['src/server.ts'],
format: ["cjs"], format: ['cjs'],
dts: true, dts: true,
minify: true, minify: true,
clean: true, clean: true,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,18 @@
"use client"; 'use client';
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from 'date-fns';
import { api } from "~/trpc/react"; import { api } from '~/trpc/react';
import DeleteContactBook from "./delete-contact-book"; import DeleteContactBook from './delete-contact-book';
import Link from "next/link"; import Link from 'next/link';
import EditContactBook from "./edit-contact-book"; import EditContactBook from './edit-contact-book';
import { useRouter } from "next/navigation"; import { useRouter } from 'next/navigation';
import { motion } from "framer-motion"; import { motion } from 'framer-motion';
import { useUrlState } from "~/hooks/useUrlState"; import { useUrlState } from '~/hooks/useUrlState';
import { Input } from "@usesend/ui/src/input"; import { Input } from '@usesend/ui/src/input';
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from 'use-debounce';
export default function ContactBooksList() { export default function ContactBooksList() {
const [search, setSearch] = useUrlState("search"); const [search, setSearch] = useUrlState('search');
const contactBooksQuery = api.contacts.getContactBooks.useQuery({ const contactBooksQuery = api.contacts.getContactBooks.useQuery({
search: search ?? undefined, search: search ?? undefined,
}); });
@@ -27,40 +27,40 @@ export default function ContactBooksList() {
<div className="mt-10"> <div className="mt-10">
<Input <Input
placeholder="Search contact book" placeholder="Search contact book"
className="w-[300px] mr-4 mb-4" className="mb-4 mr-4 w-[300px]"
defaultValue={search ?? ""} defaultValue={search ?? ''}
onChange={(e) => debouncedSearch(e.target.value)} 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) => ( {contactBooksQuery.data?.map((contactBook) => (
<motion.div <motion.div
key={contactBook.id} key={contactBook.id}
whileHover={{ scale: 1.02 }} whileHover={{ scale: 1.02 }}
transition={{ type: "spring", stiffness: 200, damping: 10 }} transition={{ type: 'spring', stiffness: 200, damping: 10 }}
whileTap={{ scale: 0.99 }} 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"> <div className="flex flex-col">
<Link href={`/contacts/${contactBook.id}`} key={contactBook.id}> <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 className="flex items-center gap-2">
<div>{contactBook.emoji}</div> <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} {contactBook.name}
</div> </div>
</div> </div>
<div className="text-sm"> <div className="text-sm">
<span className="font-mono"> <span className="font-mono">
{contactBook._count.contacts} {contactBook._count.contacts}
</span>{" "} </span>{' '}
contacts contacts
</div> </div>
</div> </div>
</Link> </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 <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}`)} onClick={() => router.push(`/contacts/${contactBook.id}`)}
> >
{formatDistanceToNow(contactBook.createdAt, { {formatDistanceToNow(contactBook.createdAt, {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
"use client"; 'use client';
import Link from "next/link"; import Link from 'next/link';
import { usePathname } from "next/navigation"; import { usePathname } from 'next/navigation';
import React from "react"; import React from 'react';
export const SettingsNavButton: React.FC<{ export const SettingsNavButton: React.FC<{
href: string; href: string;
@@ -15,13 +15,13 @@ export const SettingsNavButton: React.FC<{
if (comingSoon) { if (comingSoon) {
return ( 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 <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} {children}
</div> </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 soon
</div> </div>
</div> </div>
@@ -31,7 +31,7 @@ export const SettingsNavButton: React.FC<{
return ( return (
<Link <Link
href={href} 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} {children}
</Link> </Link>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,30 +1,30 @@
import { DomainStatus } from "@prisma/client"; import { DomainStatus } from '@prisma/client';
export const DomainStatusBadge: React.FC<{ status: DomainStatus }> = ({ export const DomainStatusBadge: React.FC<{ status: DomainStatus }> = ({
status, 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) { switch (status) {
case DomainStatus.SUCCESS: case DomainStatus.SUCCESS:
badgeColor = "bg-green/15 text-green border border-green/25"; badgeColor = 'bg-green/15 text-green border border-green/25';
break; break;
case DomainStatus.FAILED: case DomainStatus.FAILED:
badgeColor = "bg-red/10 text-red border border-red/10"; badgeColor = 'bg-red/10 text-red border border-red/10';
break; break;
case DomainStatus.TEMPORARY_FAILURE: case DomainStatus.TEMPORARY_FAILURE:
case DomainStatus.PENDING: case DomainStatus.PENDING:
badgeColor = "bg-yellow/20 text-yellow border border-yellow/10"; badgeColor = 'bg-yellow/20 text-yellow border border-yellow/10';
break; break;
default: default:
badgeColor = "bg-gray/70 text-gray border border-gray/20"; badgeColor = 'bg-gray/70 text-gray border border-gray/20';
} }
return ( return (
<div <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"> <span className="text-xs">
{status === "SUCCESS" ? "Verified" : status.toLowerCase()} {status === 'SUCCESS' ? 'Verified' : status.toLowerCase()}
</span> </span>
</div> </div>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,39 +1,39 @@
import { EmailStatus } from "@prisma/client"; import { EmailStatus } from '@prisma/client';
export const EmailStatusBadge: React.FC<{ status: EmailStatus }> = ({ export const EmailStatusBadge: React.FC<{ status: EmailStatus }> = ({
status, 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) { switch (status) {
case "DELIVERED": case 'DELIVERED':
badgeColor = "bg-green/15 text-green border border-green/20"; badgeColor = 'bg-green/15 text-green border border-green/20';
break; break;
case "BOUNCED": case 'BOUNCED':
case "FAILED": case 'FAILED':
badgeColor = "bg-red/15 text-red border border-red/20"; badgeColor = 'bg-red/15 text-red border border-red/20';
break; break;
case "CLICKED": case 'CLICKED':
badgeColor = "bg-blue/15 text-blue border border-blue/20"; badgeColor = 'bg-blue/15 text-blue border border-blue/20';
break; break;
case "OPENED": case 'OPENED':
badgeColor = "bg-purple/15 text-purple border border-purple/20"; badgeColor = 'bg-purple/15 text-purple border border-purple/20';
break; break;
case "COMPLAINED": case 'COMPLAINED':
badgeColor = "bg-yellow/15 text-yellow border border-yellow/20"; badgeColor = 'bg-yellow/15 text-yellow border border-yellow/20';
break; break;
case "DELIVERY_DELAYED": case 'DELIVERY_DELAYED':
badgeColor = "bg-yellow/15 text-yellow border border-yellow/20"; badgeColor = 'bg-yellow/15 text-yellow border border-yellow/20';
break; break;
default: 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 ( return (
<div <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> </div>
); );
}; };
@@ -41,44 +41,44 @@ export const EmailStatusBadge: React.FC<{ status: EmailStatus }> = ({
export const EmailStatusIcon: React.FC<{ status: EmailStatus }> = ({ export const EmailStatusIcon: React.FC<{ status: EmailStatus }> = ({
status, status,
}) => { }) => {
let outsideColor = "bg-gray/30"; // Default let outsideColor = 'bg-gray/30'; // Default
let insideColor = "bg-gray"; // Default let insideColor = 'bg-gray'; // Default
switch (status) { switch (status) {
case "DELIVERED": case 'DELIVERED':
outsideColor = "bg-green/30"; outsideColor = 'bg-green/30';
insideColor = "bg-green"; insideColor = 'bg-green';
break; break;
case "BOUNCED": case 'BOUNCED':
case "FAILED": case 'FAILED':
outsideColor = "bg-red/30"; outsideColor = 'bg-red/30';
insideColor = "bg-red"; insideColor = 'bg-red';
break; break;
case "CLICKED": case 'CLICKED':
outsideColor = "bg-blue/30"; outsideColor = 'bg-blue/30';
insideColor = "bg-blue"; insideColor = 'bg-blue';
break; break;
case "OPENED": case 'OPENED':
outsideColor = "bg-purple/30"; outsideColor = 'bg-purple/30';
insideColor = "bg-purple"; insideColor = 'bg-purple';
break; break;
case "DELIVERY_DELAYED": case 'DELIVERY_DELAYED':
outsideColor = "bg-yellow/30"; outsideColor = 'bg-yellow/30';
insideColor = "bg-yellow"; insideColor = 'bg-yellow';
break; break;
case "COMPLAINED": case 'COMPLAINED':
outsideColor = "bg-yellow/30"; outsideColor = 'bg-yellow/30';
insideColor = "bg-yellow"; insideColor = 'bg-yellow';
break; break;
default: default:
// Using the default values defined above // Using the default values defined above
outsideColor = "bg-gray/30"; outsideColor = 'bg-gray/30';
insideColor = "bg-gray"; insideColor = 'bg-gray';
} }
return ( return (
<div <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 className={`h-2 w-2 rounded-full ${insideColor}`}></div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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