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_USER="postgres"
POSTGRES_PASSWORD="postgres"
POSTGRES_DB="usesend"
POSTGRES_DB="gibsend"
# Postgres - required
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/usesend"
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/gibsend"
# NextAuth - required
NEXTAUTH_URL="http://localhost:3000"
@@ -14,7 +14,7 @@ NEXTAUTH_SECRET=
#SMTP
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
# GitHub login - required
@@ -25,13 +25,18 @@ GITHUB_SECRET="<your-github-client-secret>"
GOOGLE_CLIENT_ID="<your-google-client-id>"
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_DEFAULT_REGION="us-east-1"
AWS_SECRET_KEY="<your-aws-secret-key>"
AWS_ACCESS_KEY="<your-aws-access-key>"
DOCKER_OUTPUT=1
API_RATE_LIMIT=1
AUTH_EMAIL_RATE_LIMIT=5

View File

@@ -97,41 +97,3 @@ For detailed instructions on how to configure and run the Docker container, plea
## Self Hosting
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

@@ -24,7 +24,7 @@ mintlify dev
### Publishing Changes
Install our GitHub App to auto propagate changes from your repo to your deployment. Changes will be deployed to production automatically after pushing to the default branch. Find the link to install on your dashboard.
Install our GitHub App to auto propagate changes from your repo to your deployment. Changes will be deployed to production automatically after pushing to the default branch. Find the link to install on your dashboard.
#### Troubleshooting

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

View File

@@ -1,21 +1,21 @@
import Image from "next/image";
import Link from "next/link";
import { SiteFooter } from "~/components/SiteFooter";
import { GitHubStarsButton } from "~/components/GitHubStarsButton";
import { Button } from "@usesend/ui/src/button";
import { TopNav } from "~/components/TopNav";
import { FeatureCard } from "~/components/FeatureCard";
import { FeatureCardPlain } from "~/components/FeatureCardPlain";
import { PricingCalculator } from "~/components/PricingCalculator";
import CodeExample from "~/components/CodeExample";
import Image from 'next/image';
import Link from 'next/link';
import { SiteFooter } from '~/components/SiteFooter';
import { GitHubStarsButton } from '~/components/GitHubStarsButton';
import { Button } from '@usesend/ui/src/button';
import { TopNav } from '~/components/TopNav';
import { FeatureCard } from '~/components/FeatureCard';
import { FeatureCardPlain } from '~/components/FeatureCardPlain';
import { PricingCalculator } from '~/components/PricingCalculator';
import CodeExample from '~/components/CodeExample';
const REPO = "usesend/usesend";
const REPO = 'usesend/usesend';
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() {
return (
<main className="min-h-screen text-foreground bg-background">
<main className="text-foreground bg-background min-h-screen">
<TopNav />
<Hero />
<TrustedBy />
@@ -34,18 +34,18 @@ function Hero() {
return (
<section>
<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
</h1>
<p className="mt-4 text-center text-base sm:text-lg font-sans max-w-2xl mx-auto">
Send product, transactional and marketing emails.{" "}
<p className="mx-auto mt-4 max-w-2xl text-center font-sans text-base sm:text-lg">
Send product, transactional and marketing emails.{' '}
<span className="text-primary font-normal">
Pay only for what you send
</span>{" "}
</span>{' '}
and not for storing contacts.
</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">
<a href={APP_URL} target="_blank" rel="noopener noreferrer">
Get started
@@ -55,11 +55,11 @@ function Hero() {
<GitHubStarsButton />
</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
</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>
<a
href="https://coderabbit.ai/?utm_source=useSend.com"
@@ -89,15 +89,15 @@ function Hero() {
</a>
</div>
<div className=" mt-32 mx-auto max-w-5xl">
<div className="rounded-[18px] bg-primary/10 p-1 sm:p-1 ">
<div className="rounded-2xl bg-primary/20 p-1 sm:p-1 ">
<div className="mx-auto mt-32 max-w-5xl">
<div className="bg-primary/10 rounded-[18px] p-1 sm:p-1">
<div className="bg-primary/20 rounded-2xl p-1 sm:p-1">
<Image
src="/hero-light.webp"
alt="useSend product hero"
width={3456}
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"
loading="eager"
priority={false}
@@ -107,7 +107,7 @@ function Hero() {
alt="useSend product hero"
width={3456}
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"
loading="eager"
priority={false}
@@ -127,61 +127,61 @@ function TrustedBy() {
{
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.",
author: "Marc Seitz",
company: "papermark.com",
author: 'Marc Seitz',
company: 'papermark.com',
image:
"https://pbs.twimg.com/profile_images/1176854646343852032/iYnUXJ-m_400x400.jpg",
'https://pbs.twimg.com/profile_images/1176854646343852032/iYnUXJ-m_400x400.jpg',
},
{
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.",
author: "Tommerty",
company: "doras.to",
author: 'Tommerty',
company: 'doras.to',
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 = [
{
quote: "don't sleep on useSend",
author: "shellscape",
company: "jsx.email",
author: 'shellscape',
company: 'jsx.email',
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!",
author: "Andras Bacsai",
company: "coolify.io",
quote: 'Thank you for making useSend!',
author: 'Andras Bacsai',
company: 'coolify.io',
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",
author: "VicVijayakumar",
company: "onetimefax.com",
quote: 'I KNOW WHAT TO DO',
author: 'VicVijayakumar',
company: 'onetimefax.com',
image:
"https://pbs.twimg.com/profile_images/1665351804685524995/W4BpDx5Z_400x400.jpg",
'https://pbs.twimg.com/profile_images/1665351804685524995/W4BpDx5Z_400x400.jpg',
},
];
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="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="text-primary font-bold">useSend</span>
</div>
{/* 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) => (
<figure
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}
</blockquote>
<div className="mt-5 flex items-center gap-3">
@@ -190,7 +190,7 @@ function TrustedBy() {
alt={`${t.author} avatar`}
width={32}
height={32}
className=" rounded-md border-2 border-primary/50"
className="border-primary/50 rounded-md border-2"
/>
<figcaption className="text-sm">
<span className="font-medium">{t.author}</span>
@@ -199,9 +199,9 @@ function TrustedBy() {
target="_blank"
className="text-muted-foreground hover:text-primary-light"
>
{" "}
{' '}
{t.company}
</a>{" "}
</a>{' '}
</figcaption>
</div>
</figure>
@@ -209,13 +209,13 @@ function TrustedBy() {
</div>
{/* 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) => (
<figure
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}
</blockquote>
<div className="mt-5 flex items-center gap-3">
@@ -224,7 +224,7 @@ function TrustedBy() {
alt={`${t.author} avatar`}
width={32}
height={32}
className=" rounded-md border-2 border-primary/50"
className="border-primary/50 rounded-md border-2"
/>
<figcaption className="text-sm">
<span className="font-medium">{t.author}</span>
@@ -233,7 +233,7 @@ function TrustedBy() {
target="_blank"
className="text-muted-foreground hover:text-primary-light"
>
{" "}
{' '}
{t.company}
</a>
</figcaption>
@@ -250,42 +250,42 @@ function Features() {
// Top: 2 cards (with image area) — Analytics, Editor
const top = [
{
key: "feature-analytics",
title: "Analytics",
key: 'feature-analytics',
title: 'Analytics',
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.",
imageLightSrc: "/emails-search-light.webp",
imageDarkSrc: "/emails-search-dark.webp",
'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',
imageDarkSrc: '/emails-search-dark.webp',
},
{
key: "feature-editor",
title: "Marketing Email Editor",
key: 'feature-editor',
title: 'Marketing Email Editor',
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.",
imageLightSrc: "/editor-light.webp",
imageDarkSrc: "/editor-dark.webp",
'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',
imageDarkSrc: '/editor-dark.webp',
},
];
// Bottom: 3 cards (no images) — Contact Management, Suppression List, SMTP Relay Service
const bottom = [
{
key: "feature-contacts",
title: "Contact Management",
key: 'feature-contacts',
title: 'Contact Management',
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",
title: "Suppression List",
key: 'feature-suppression',
title: 'Suppression List',
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",
title: "SMTP Relay",
key: 'feature-smtp',
title: 'SMTP Relay',
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">
<div className="mx-auto max-w-6xl px-6">
<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
</div>
</div>
{/* 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) => (
<FeatureCard
key={f.key}
@@ -312,7 +312,7 @@ function Features() {
</div>
{/* 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) => (
<FeatureCardPlain key={f.key} title={f.title} content={f.content} />
))}
@@ -326,35 +326,35 @@ function Features() {
function Pricing() {
const freePerks = [
"Send up to 3000 emails per month",
"Send up to 100 emails per day",
"Can have 1 contact book",
"Can have 1 domain",
"Can have 1 team member",
'Send up to 3000 emails per month',
'Send up to 100 emails per day',
'Can have 1 contact book',
'Can have 1 domain',
'Can have 1 team member',
];
const paidPerks = [
"$10 monthly usage credits",
"Send transactional emails at $0.0004 per email",
"Send marketing emails at $0.001 per email",
"Can have unlimited contact books",
"Can have unlimited domains",
"Can have unlimited team members",
'$10 monthly usage credits',
'Send transactional emails at $0.0004 per email',
'Send marketing emails at $0.001 per email',
'Can have unlimited contact books',
'Can have unlimited domains',
'Can have unlimited team members',
];
return (
<section id="pricing" className="py-16 sm:py-20">
<div className="mx-auto max-w-6xl px-6">
<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
</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
</p>
</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
title="Free"
price="$0"
@@ -386,16 +386,16 @@ type PricingCardProps = {
function PricingCard({ title, price, note, perks }: PricingCardProps) {
return (
<div className="rounded-[18px] bg-primary/20 p-1">
<div className="h-full rounded-[14px] bg-primary/20 p-0.5 shadow-sm">
<div className="bg-background rounded-xl h-full flex flex-col p-5">
<h3 className=" font-medium">{title}</h3>
<div className="mt-2 text-4xl text-primary">{price}</div>
<div className="text-xs text-muted-foreground">{note}</div>
<ul className="mt-4 space-y-2 text-sm mb-20">
<div className="bg-primary/20 rounded-[18px] p-1">
<div className="bg-primary/20 h-full rounded-[14px] p-0.5 shadow-sm">
<div className="bg-background flex h-full flex-col rounded-xl p-5">
<h3 className="font-medium">{title}</h3>
<div className="text-primary mt-2 text-4xl">{price}</div>
<div className="text-muted-foreground text-xs">{note}</div>
<ul className="mb-20 mt-4 space-y-2 text-sm">
{perks.map((perk) => (
<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>
</li>
))}
@@ -422,12 +422,12 @@ function About() {
<section id="about" className="py-16 sm:py-20">
<div className="mx-auto max-w-6xl px-6">
<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
</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>
As most of email products out there, useSend also uses Amazon SES
under the hood to send emails. We provide an open and alternative
@@ -435,7 +435,7 @@ function About() {
</p>
<p>
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
href="https://github.com/sponsors/KMKoushik"
target="_blank"
@@ -456,7 +456,7 @@ function About() {
// Footer moved to ~/components/SiteFooter
// Minimal inline icons (stroke-based, sleek)
function CheckIcon({ className = "" }: { className?: string }) {
function CheckIcon({ className = '' }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"

View File

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

View File

@@ -1,17 +1,19 @@
import type { Metadata } from "next";
import { TopNav } from "~/components/TopNav";
import type { Metadata } from 'next';
import { TopNav } from '~/components/TopNav';
export const metadata: Metadata = {
title: "Terms of Service useSend",
description: "Terms governing use of the useSend website and product.",
title: 'Terms of Service useSend',
description: 'Terms governing use of the useSend website and product.',
};
export default function TermsPage() {
return (
<main className="min-h-screen bg-sidebar-background text-foreground">
<main className="bg-sidebar-background text-foreground min-h-screen">
<TopNav />
<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">
These Terms of Service ("Terms") govern your access to and use of the
useSend marketing website at usesend.com and the useSend application.
@@ -19,7 +21,7 @@ export default function TermsPage() {
these Terms.
</p>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Eligibility & Accounts</h2>
<p className="text-muted-foreground">
You may use the site and product only if you can form a binding
@@ -29,13 +31,13 @@ export default function TermsPage() {
</p>
</section>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Acceptable Use</h2>
<p className="text-muted-foreground">
You agree not to misuse the site or product. Prohibited conduct
includes, without limitation:
</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>Infringing the rights of others or violating their privacy.</li>
<li>Attempting to interfere with or disrupt the services.</li>
@@ -46,7 +48,7 @@ export default function TermsPage() {
</ul>
</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>
<p className="text-muted-foreground">
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.
</p>
<p className="text-muted-foreground">
Marketing communications sent via useSend must employ double
opt-in verification. Accounts that bypass double opt-in or misuse
our transactional mail API for promotional campaigns may be
suspended or terminated without notice.
Marketing communications sent via useSend must employ double opt-in
verification. Accounts that bypass double opt-in or misuse our
transactional mail API for promotional campaigns may be suspended or
terminated without notice.
</p>
</section>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Intellectual Property</h2>
<p className="text-muted-foreground">
Content on the site, including trademarks, logos, text, and
@@ -72,7 +74,7 @@ export default function TermsPage() {
</p>
</section>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">ThirdParty Links</h2>
<p className="text-muted-foreground">
The site may contain links to thirdparty websites or services we do
@@ -80,7 +82,7 @@ export default function TermsPage() {
</p>
</section>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Disclaimer</h2>
<p className="text-muted-foreground">
The site is provided on an "as is" and "as available" basis without
@@ -88,7 +90,7 @@ export default function TermsPage() {
</p>
</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>
<p className="text-muted-foreground">
To the fullest extent permitted by law, useSend shall not be liable
@@ -97,7 +99,7 @@ export default function TermsPage() {
</p>
</section>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Indemnification</h2>
<p className="text-muted-foreground">
You agree to indemnify and hold harmless useSend from any claims,
@@ -106,7 +108,7 @@ export default function TermsPage() {
</p>
</section>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Changes & Availability</h2>
<p className="text-muted-foreground">
We may modify these Terms and update the site or product at any
@@ -115,7 +117,7 @@ export default function TermsPage() {
</p>
</section>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Governing Law</h2>
<p className="text-muted-foreground">
These Terms are governed by applicable laws without regard to
@@ -125,15 +127,23 @@ export default function TermsPage() {
</p>
</section>
<section className="space-y-3 mb-10">
<section className="mb-10 space-y-3">
<h2 className="text-xl font-medium">Contact</h2>
<p className="text-muted-foreground">
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>
</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>
</main>
);

View File

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

View File

@@ -1,6 +1,6 @@
import { Button } from "@usesend/ui/src/button";
import { CodeBlock } from "@usesend/ui/src/code-block";
import { LangToggle } from "./CodeLangToggle";
import { Button } from '@usesend/ui/src/button';
import { CodeBlock } from '@usesend/ui/src/code-block';
import { LangToggle } from './CodeLangToggle';
const TS_CODE = `import { UseSend } from "usesend-js";
@@ -82,34 +82,34 @@ if ($response === false) {
curl_close($ch);`;
export function CodeExample() {
const containerId = "code-example";
const containerId = 'code-example';
const languages = [
{
key: "ts",
label: "TypeScript",
kind: "ts",
shiki: "typescript" as const,
key: 'ts',
label: 'TypeScript',
kind: 'ts',
shiki: 'typescript' as const,
code: TS_CODE,
},
{
key: "py",
label: "Python",
kind: "py",
shiki: "python" as const,
key: 'py',
label: 'Python',
kind: 'py',
shiki: 'python' as const,
code: PY_CODE,
},
{
key: "go",
label: "Go",
kind: "go",
shiki: "go" as const,
key: 'go',
label: 'Go',
kind: 'go',
shiki: 'go' as const,
code: GO_CODE,
},
{
key: "php",
label: "PHP",
kind: "php",
shiki: "php" as const,
key: 'php',
label: 'PHP',
kind: 'php',
shiki: 'php' as const,
code: PHP_CODE,
},
];
@@ -118,17 +118,17 @@ export function CodeExample() {
<section className="py-16 sm:py-20">
<div className="mx-auto max-w-6xl px-6">
<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
</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
plumbing.
</p>
</div>
<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
containerId={containerId}
defaultLang="ts"
@@ -139,19 +139,19 @@ export function CodeExample() {
}))}
/>
</div>
<div className="rounded-[18px] bg-primary/20 p-1">
<div className="rounded-[14px] bg-primary/20 p-0.5 shadow-sm">
<div className="bg-background rounded-xl overflow-hidden">
<div className="bg-primary/20 rounded-[18px] p-1">
<div className="bg-primary/20 rounded-[14px] p-0.5 shadow-sm">
<div className="bg-background overflow-hidden rounded-xl">
{languages.map((l, idx) => (
<div
key={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 */}
<CodeBlock
lang={l.shiki as any}
className="p-4 rounded-[10px]"
className="rounded-[10px] p-4"
>
{l.code}
</CodeBlock>

View File

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

View File

@@ -1,6 +1,6 @@
"use client";
'use client';
import Image from "next/image";
import Image from 'next/image';
type FeatureCardProps = {
title?: string;
@@ -21,33 +21,33 @@ export function FeatureCard({
imageSrc,
}: FeatureCardProps) {
return (
<div className="rounded-[18px] bg-primary/20 p-1 ">
<div className="h-full rounded-[14px] bg-primary/20 p-0.5 shadow-sm">
<div className="bg-background rounded-xl h-full flex flex-col">
<div className="relative w-full aspect-[16/9] rounded-t-xl overflow-hidden">
<div className="bg-primary/20 rounded-[18px] p-1">
<div className="bg-primary/20 h-full rounded-[14px] p-0.5 shadow-sm">
<div className="bg-background flex h-full flex-col rounded-xl">
<div className="relative aspect-[16/9] w-full overflow-hidden rounded-t-xl">
{imageLightSrc || imageDarkSrc ? (
<>
<Image
src={(imageLightSrc || imageDarkSrc)!}
alt={title || "Feature image"}
alt={title || 'Feature image'}
fill
className="object-cover dark:hidden rounded-t-xl"
className="rounded-t-xl object-cover dark:hidden"
priority={false}
/>
<Image
src={(imageDarkSrc || imageLightSrc)!}
alt={title || "Feature image"}
alt={title || 'Feature image'}
fill
className="object-cover hidden dark:block rounded-t-xl"
className="hidden rounded-t-xl object-cover dark:block"
priority={false}
/>
</>
) : imageSrc ? (
<Image
src={imageSrc}
alt={title || "Feature image"}
alt={title || 'Feature image'}
fill
className="object-cover rounded-t-xl"
className="rounded-t-xl object-cover"
priority={false}
/>
) : (
@@ -56,29 +56,29 @@ export function FeatureCard({
src="/hero-light.png"
alt="Feature image"
fill
className="object-cover dark:hidden rounded-t-xl"
className="rounded-t-xl object-cover dark:hidden"
priority={false}
/>
<Image
src="/hero-dark.png"
alt="Feature image"
fill
className="object-cover hidden dark:block rounded-t-xl"
className="hidden rounded-t-xl object-cover dark:block"
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 className="p-5 flex-1 flex flex-col">
<h3 className="text-base sm:text-lg text-primary font-sans">
{title || ""}
<div className="flex flex-1 flex-col p-5">
<h3 className="text-primary font-sans text-base sm:text-lg">
{title || ''}
</h3>
{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>

View File

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

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 API_URL = `https://api.github.com/repos/${REPO}`;
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 {
if (n < 1000) return n.toLocaleString();
const units = [
{ v: 1_000_000_000, s: " B" },
{ v: 1_000_000, s: " M" },
{ v: 1_000, s: " K" },
{ v: 1_000_000_000, s: ' B' },
{ v: 1_000_000, s: ' M' },
{ v: 1_000, s: ' K' },
];
for (const u of units) {
if (n >= u.v) {
const num = n / u.v;
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;
}
}
@@ -25,9 +25,9 @@ function formatCompact(n: number): string {
export async function GitHubStarsButton() {
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "usesend-marketing",
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'usesend-marketing',
};
if (process.env.GITHUB_TOKEN)
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
@@ -40,17 +40,17 @@ export async function GitHubStarsButton() {
});
if (res.ok) {
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;
}
} catch {
// ignore network errors; show placeholder
}
const formatted = stars == null ? "—" : formatCompact(stars);
const formatted = stars == null ? '—' : formatCompact(stars);
return (
<Button variant="outline" size="lg" className="px-4 gap-2">
<Button variant="outline" size="lg" className="gap-2 px-4">
<a
href={REPO_URL}
target="_blank"
@@ -60,7 +60,7 @@ export async function GitHubStarsButton() {
>
<GitHubIcon className="h-4 w-4" />
<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}
</span>
</a>
@@ -68,7 +68,7 @@ export async function GitHubStarsButton() {
);
}
function GitHubIcon({ className = "" }: { className?: string }) {
function GitHubIcon({ className = '' }: { className?: string }) {
return (
<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 = {
label: string;
@@ -19,35 +19,35 @@ function Slider({
min = 0,
max = 100000,
step = 500,
suffix = "",
suffix = '',
}: SliderProps) {
const id = React.useId();
const [dragging, setDragging] = React.useState(false);
const percent = Math.max(
0,
Math.min(100, ((value - min) / (max - min)) * 100)
Math.min(100, ((value - min) / (max - min)) * 100),
);
React.useEffect(() => {
if (!dragging) return;
const stop = () => setDragging(false);
window.addEventListener("mouseup", stop);
window.addEventListener("touchend", stop);
window.addEventListener("pointerup", stop);
window.addEventListener('mouseup', stop);
window.addEventListener('touchend', stop);
window.addEventListener('pointerup', stop);
return () => {
window.removeEventListener("mouseup", stop);
window.removeEventListener("touchend", stop);
window.removeEventListener("pointerup", stop);
window.removeEventListener('mouseup', stop);
window.removeEventListener('touchend', stop);
window.removeEventListener('pointerup', stop);
};
}, [dragging]);
return (
<div className="flex flex-col sm:flex-row gap-3 sm:items-center">
<div className="w-full sm:w-56 md:w-72 shrink-0">
<label htmlFor={id} className="text-sm font-medium block">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="w-full shrink-0 sm:w-56 md:w-72">
<label htmlFor={id} className="block text-sm font-medium">
{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}
</div>
</div>
@@ -63,7 +63,7 @@ function Slider({
onMouseDown={() => setDragging(true)}
onTouchStart={() => setDragging(true)}
onPointerDown={() => setDragging(true)}
className="w-full accent-primary"
className="accent-primary w-full"
aria-label={label}
aria-valuetext={`${value.toLocaleString()} ${suffix}`}
/>
@@ -72,7 +72,7 @@ function Slider({
className="pointer-events-none absolute -top-9 left-0 -translate-x-1/2"
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}
</div>
</div>
@@ -98,15 +98,15 @@ export function PricingCalculator() {
const totalDue = Math.max(subtotal, MINIMUM_SPEND);
return (
<div className="rounded-[18px] bg-primary/20 p-1">
<div className="rounded-[14px] bg-primary/20 p-0.5 shadow-sm">
<div className="bg-primary/20 rounded-[18px] p-1">
<div className="bg-primary/20 rounded-[14px] p-0.5 shadow-sm">
<div className="bg-background rounded-xl p-5 pb-10">
<div className="flex flex-col gap-6">
<div className="text-center">
<div className="text-sm uppercase tracking-wider text-primary">
<div className="text-primary text-sm uppercase tracking-wider">
Pricing Calculator
</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.
</p>
</div>
@@ -132,38 +132,38 @@ export function PricingCalculator() {
/>
</div>
<div className="mt-2 grid grid-cols-1 sm:grid-cols-3 gap-4 items-center">
<div className="rounded-lg border border-primary/30 p-4">
<div className="text-xs text-muted-foreground">Marketing</div>
<div className="mt-2 grid grid-cols-1 items-center gap-4 sm:grid-cols-3">
<div className="border-primary/30 rounded-lg border p-4">
<div className="text-muted-foreground text-xs">Marketing</div>
<div className="text-lg font-medium">
${marketingCost.toFixed(2)}
</div>
<div className="text-xs text-muted-foreground">
<div className="text-muted-foreground text-xs">
@ ${MARKETING_RATE.toFixed(4)} each
</div>
</div>
<div className="rounded-lg border border-primary/30 p-4">
<div className="text-xs text-muted-foreground">
<div className="border-primary/30 rounded-lg border p-4">
<div className="text-muted-foreground text-xs">
Transactional
</div>
<div className="text-lg font-medium">
${transactionalCost.toFixed(2)}
</div>
<div className="text-xs text-muted-foreground">
<div className="text-muted-foreground text-xs">
@ ${TRANSACTIONAL_RATE.toFixed(4)} each
</div>
</div>
<div className="rounded-lg border border-primary/30 p-4 bg-primary/10">
<div className="text-xs text-muted-foreground">
<div className="border-primary/30 bg-primary/10 rounded-lg border p-4">
<div className="text-muted-foreground text-xs">
Estimated Total
</div>
<div className="text-3xl text-primary font-semibold">
<div className="text-primary text-3xl font-semibold">
${totalDue.toFixed(2)}
</div>
<div className="text-xs text-muted-foreground">
<div className="text-muted-foreground text-xs">
{subtotal < MINIMUM_SPEND
? "Minimum $10 applies"
: "before taxes"}
? 'Minimum $10 applies'
: 'before taxes'}
</div>
</div>
</div>

View File

@@ -1,16 +1,16 @@
import Image from "next/image";
import Link from "next/link";
import Image from 'next/image';
import Link from 'next/link';
// Replaced StatusBadge with external status badge image
const REPO = "usesend/usesend";
const REPO = 'usesend/usesend';
const REPO_URL = `https://github.com/${REPO}`;
const APP_URL = "https://app.usesend.com";
const APP_URL = 'https://app.usesend.com';
export function SiteFooter() {
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="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">
<Image
src="/logo-squircle.png"
@@ -21,13 +21,13 @@ export function SiteFooter() {
<span className="text-primary font-mono">useSend</span>
</div>
<div className="sm:ml-auto flex items-start gap-4">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-x-12 gap-y-2 text-sm">
<div className="flex items-start gap-4 sm:ml-auto">
<div className="grid grid-cols-2 gap-x-12 gap-y-2 text-sm sm:grid-cols-3">
<div>
<div className="text-xs uppercase tracking-wider mb-2">
<div className="mb-2 text-xs uppercase tracking-wider">
Product
</div>
<ul className="space-y-2 text-muted-foreground">
<ul className="text-muted-foreground space-y-2">
<li>
<a
href={APP_URL}
@@ -62,10 +62,10 @@ export function SiteFooter() {
</div>
<div>
<div className="text-xs uppercase tracking-wider mb-2">
<div className="mb-2 text-xs uppercase tracking-wider">
Contact
</div>
<ul className="space-y-2 text-muted-foreground">
<ul className="text-muted-foreground space-y-2">
<li>
<a
href="mailto:hey@usesend.com"
@@ -118,10 +118,10 @@ export function SiteFooter() {
</div>
<div>
<div className="text-xs uppercase tracking-wider mb-2">
<div className="mb-2 text-xs uppercase tracking-wider">
Company
</div>
<ul className="space-y-2 text-muted-foreground">
<ul className="text-muted-foreground space-y-2">
<li>
<Link
href="/privacy"
@@ -160,7 +160,7 @@ export function SiteFooter() {
</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.
</div>
</div>

View File

@@ -1,31 +1,38 @@
"use client";
'use client';
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { Button } from "@usesend/ui/src/button";
import Image from 'next/image';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useState } from 'react';
import { Button } from '@usesend/ui/src/button';
const REPO = "usesend/usesend";
const REPO = 'usesend/usesend';
const REPO_URL = `https://github.com/${REPO}`;
const APP_URL = "https://app.usesend.com";
const APP_URL = 'https://app.usesend.com';
export function TopNav() {
const [open, setOpen] = useState(false);
const pathname = usePathname();
const isHome = pathname === "/";
const pricingHref = isHome ? "#pricing" : "/#pricing";
const isHome = pathname === '/';
const pricingHref = isHome ? '#pricing' : '/#pricing';
return (
<header className="py-4 border-b border-border sticky top-0 z-20 backdrop-blur supports-[backdrop-filter]:bg-sidebar-background/80">
<div className="mx-auto max-w-6xl px-6 flex items-center justify-between gap-4 text-sm">
<Link href="/" className="flex items-center gap-2 group">
<Image src="/logo-squircle.png" alt="useSend" width={24} height={24} />
<span className="text-primary font-mono text-[16px] group-hover:opacity-90">useSend</span>
<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 flex max-w-6xl items-center justify-between gap-4 px-6 text-sm">
<Link href="/" className="group flex items-center gap-2">
<Image
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>
{/* 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">
Pricing
</Link>
@@ -55,14 +62,29 @@ export function TopNav() {
{/* Mobile hamburger */}
<button
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)}
>
<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 ? (
<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>
</button>
@@ -70,16 +92,20 @@ export function TopNav() {
{/* Mobile menu panel */}
{open ? (
<div className="sm:hidden border-t border-border bg-sidebar-background/95 backdrop-blur">
<div className="mx-auto max-w-6xl px-6 py-3 flex flex-col gap-2">
<Link href={pricingHref} className="py-2 text-muted-foreground hover:text-foreground" onClick={() => setOpen(false)}>
<div className="border-border bg-sidebar-background/95 border-t backdrop-blur sm:hidden">
<div className="mx-auto flex max-w-6xl flex-col gap-2 px-6 py-3">
<Link
href={pricingHref}
className="text-muted-foreground hover:text-foreground py-2"
onClick={() => setOpen(false)}
>
Pricing
</Link>
<a
href="https://docs.usesend.com"
target="_blank"
rel="noopener noreferrer"
className="py-2 text-muted-foreground hover:text-foreground"
className="text-muted-foreground hover:text-foreground py-2"
onClick={() => setOpen(false)}
>
Docs
@@ -88,14 +114,19 @@ export function TopNav() {
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="py-2 text-muted-foreground hover:text-foreground"
className="text-muted-foreground hover:text-foreground py-2"
onClick={() => setOpen(false)}
>
GitHub
</a>
<div className="pt-2">
<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
</a>
</Button>

View File

@@ -1,31 +1,31 @@
import type { MDXComponents } from "mdx/types";
import type { MDXComponents } from 'mdx/types';
const components = {
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}
</h1>
),
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}
</h2>
),
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 className="text-base font-normal tracking-wide leading-relaxed font-sans">
<p className="font-sans text-base font-normal leading-relaxed tracking-wide">
{children}
</p>
),
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}
</ul>
),
a: ({ children, href }) => (
<a href={href} className=" text-primary-light">
<a href={href} className="text-primary-light">
{children}
</a>
),

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
// 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
export default defineConfig((options: Options) => ({
entry: ["src/server.ts"],
format: ["cjs"],
entry: ['src/server.ts'],
format: ['cjs'],
dts: true,
minify: 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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
} from '@usesend/ui/src/dialog';
import { Plus } from "lucide-react";
import { useState } from "react";
import { AddSesSettingsForm } from "~/components/settings/AddSesSettings";
import { Plus } from 'lucide-react';
import { useState } from 'react';
import { AddSesSettingsForm } from '~/components/settings/AddSesSettings';
export default function AddSesConfiguration() {
const [open, setOpen] = useState(false);
@@ -23,7 +23,7 @@ export default function AddSesConfiguration() {
>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-1" />
<Plus className="mr-1 h-4 w-4" />
Add SES configuration
</Button>
</DialogTrigger>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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