initial commit. gotta go
This commit is contained in:
@@ -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,6 +25,11 @@ 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>"
|
||||
|
38
README.md
38
README.md
@@ -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:
|
||||
|
||||
[](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>
|
||||
|
@@ -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
|
||||
|
@@ -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"
|
||||
|
@@ -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 privacy‑friendly 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>
|
||||
|
@@ -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">Third‑Party Links</h2>
|
||||
<p className="text-muted-foreground">
|
||||
The site may contain links to third‑party 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>
|
||||
);
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
);
|
||||
|
@@ -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>
|
||||
) : (
|
||||
<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>
|
||||
|
@@ -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>
|
||||
|
@@ -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"
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
),
|
||||
|
@@ -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;
|
||||
|
||||
|
@@ -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);
|
||||
});
|
||||
|
@@ -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,
|
||||
|
@@ -1,17 +1,17 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { AddSesSettingsForm } from "~/components/settings/AddSesSettings";
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { AddSesSettingsForm } from '~/components/settings/AddSesSettings';
|
||||
|
||||
export default function AddSesConfiguration() {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -23,7 +23,7 @@ export default function AddSesConfiguration() {
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Add SES configuration
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
@@ -1,17 +1,17 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
|
||||
import { Edit } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Edit } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -20,14 +20,14 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@usesend/ui/src/form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import Spinner from "@usesend/ui/src/spinner";
|
||||
import { SesSetting } from "@prisma/client";
|
||||
} from '@usesend/ui/src/form';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { api } from '~/trpc/react';
|
||||
import { Input } from '@usesend/ui/src/input';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import Spinner from '@usesend/ui/src/spinner';
|
||||
import { SesSetting } from '@prisma/client';
|
||||
|
||||
const FormSchema = z.object({
|
||||
settingsId: z.string(),
|
||||
@@ -96,7 +96,7 @@ export const EditSesSettingsForm: React.FC<SesSettingsProps> = ({
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (e) => {
|
||||
toast.error("Failed to update", {
|
||||
toast.error('Failed to update', {
|
||||
description: e.message,
|
||||
});
|
||||
},
|
||||
@@ -107,7 +107,7 @@ export const EditSesSettingsForm: React.FC<SesSettingsProps> = ({
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className=" flex flex-col gap-8 w-full"
|
||||
className="flex w-full flex-col gap-8"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -151,12 +151,12 @@ export const EditSesSettingsForm: React.FC<SesSettingsProps> = ({
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={updateSesSettings.isPending}
|
||||
className="w-[200px] mx-auto"
|
||||
className="mx-auto w-[200px]"
|
||||
>
|
||||
{updateSesSettings.isPending ? (
|
||||
<Spinner className="w-5 h-5" />
|
||||
<Spinner className="h-5 w-5" />
|
||||
) : (
|
||||
"Update"
|
||||
'Update'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
@@ -1,4 +1,4 @@
|
||||
export const timeframeOptions = [
|
||||
{ label: "Today", value: "today" },
|
||||
{ label: "This month", value: "thisMonth" },
|
||||
{ label: 'Today', value: 'today' },
|
||||
{ label: 'This month', value: 'thisMonth' },
|
||||
] as const;
|
||||
|
@@ -1,16 +1,16 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@usesend/ui/src/card";
|
||||
import { Label } from "@usesend/ui/src/label";
|
||||
import { Switch } from "@usesend/ui/src/switch";
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@usesend/ui/src/card';
|
||||
import { Label } from '@usesend/ui/src/label';
|
||||
import { Switch } from '@usesend/ui/src/switch';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@usesend/ui/src/select";
|
||||
} from '@usesend/ui/src/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -18,17 +18,17 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@usesend/ui/src/table";
|
||||
import Spinner from "@usesend/ui/src/spinner";
|
||||
import { api } from "~/trpc/react";
|
||||
import { isCloud } from "~/utils/common";
|
||||
import { timeframeOptions } from "./constants";
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
} from '@usesend/ui/src/table';
|
||||
import Spinner from '@usesend/ui/src/spinner';
|
||||
import { api } from '~/trpc/react';
|
||||
import { isCloud } from '~/utils/common';
|
||||
import { timeframeOptions } from './constants';
|
||||
import { keepPreviousData } from '@tanstack/react-query';
|
||||
|
||||
export default function AdminEmailAnalyticsPage() {
|
||||
const isCloudEnv = isCloud();
|
||||
const [timeframe, setTimeframe] =
|
||||
useState<(typeof timeframeOptions)[number]["value"]>("today");
|
||||
useState<(typeof timeframeOptions)[number]['value']>('today');
|
||||
const [paidOnly, setPaidOnly] = useState(false);
|
||||
|
||||
const analyticsQuery = api.admin.getEmailAnalytics.useQuery(
|
||||
@@ -36,7 +36,7 @@ export default function AdminEmailAnalyticsPage() {
|
||||
timeframe,
|
||||
paidOnly,
|
||||
},
|
||||
{ enabled: isCloudEnv, placeholderData: keepPreviousData }
|
||||
{ enabled: isCloudEnv, placeholderData: keepPreviousData },
|
||||
);
|
||||
|
||||
const data = analyticsQuery.data;
|
||||
@@ -55,7 +55,7 @@ export default function AdminEmailAnalyticsPage() {
|
||||
|
||||
if (!isCloudEnv) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/30 p-6 text-sm text-muted-foreground">
|
||||
<div className="bg-muted/30 text-muted-foreground rounded-lg border p-6 text-sm">
|
||||
Email analytics are available only in the cloud deployment.
|
||||
</div>
|
||||
);
|
||||
@@ -70,7 +70,7 @@ export default function AdminEmailAnalyticsPage() {
|
||||
<Select
|
||||
value={timeframe}
|
||||
onValueChange={(value) =>
|
||||
setTimeframe(value as (typeof timeframeOptions)[number]["value"])
|
||||
setTimeframe(value as (typeof timeframeOptions)[number]['value'])
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="timeframe">
|
||||
@@ -106,8 +106,8 @@ export default function AdminEmailAnalyticsPage() {
|
||||
<div>
|
||||
<CardTitle>Usage by team</CardTitle>
|
||||
{data ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Since {data.timeframe === "today" ? "today" : data.periodStart}
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Since {data.timeframe === 'today' ? 'today' : data.periodStart}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -174,7 +174,7 @@ function SummaryCard({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||
{label}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { SettingsNavButton } from "../dev-settings/settings-nav-button";
|
||||
import { isCloud } from "~/utils/common";
|
||||
import { SettingsNavButton } from '../dev-settings/settings-nav-button';
|
||||
import { isCloud } from '~/utils/common';
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
@@ -12,13 +12,9 @@ export default function AdminLayout({
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">Admin</h1>
|
||||
<div className="mt-4 flex gap-4">
|
||||
<SettingsNavButton href="/admin">
|
||||
SES Configurations
|
||||
</SettingsNavButton>
|
||||
<SettingsNavButton href="/admin">SES Configurations</SettingsNavButton>
|
||||
{isCloud() ? (
|
||||
<SettingsNavButton href="/admin/teams">
|
||||
Teams
|
||||
</SettingsNavButton>
|
||||
<SettingsNavButton href="/admin/teams">Teams</SettingsNavButton>
|
||||
) : null}
|
||||
{isCloud() ? (
|
||||
<SettingsNavButton href="/admin/email-analytics">
|
||||
@@ -26,9 +22,7 @@ export default function AdminLayout({
|
||||
</SettingsNavButton>
|
||||
) : null}
|
||||
{isCloud() ? (
|
||||
<SettingsNavButton href="/admin/waitlist">
|
||||
Waitlist
|
||||
</SettingsNavButton>
|
||||
<SettingsNavButton href="/admin/waitlist">Waitlist</SettingsNavButton>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-8">{children}</div>
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import AddSesConfiguration from "./add-ses-configuration";
|
||||
import SesConfigurations from "./ses-configurations";
|
||||
import AddSesConfiguration from './add-ses-configuration';
|
||||
import SesConfigurations from './ses-configurations';
|
||||
|
||||
export default function AdminSesPage() {
|
||||
return (
|
||||
|
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Table,
|
||||
@@ -7,22 +7,22 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@usesend/ui/src/table";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { api } from "~/trpc/react";
|
||||
import Spinner from "@usesend/ui/src/spinner";
|
||||
import EditSesConfiguration from "./edit-ses-configuration";
|
||||
import { TextWithCopyButton } from "@usesend/ui/src/text-with-copy";
|
||||
} from '@usesend/ui/src/table';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { api } from '~/trpc/react';
|
||||
import Spinner from '@usesend/ui/src/spinner';
|
||||
import EditSesConfiguration from './edit-ses-configuration';
|
||||
import { TextWithCopyButton } from '@usesend/ui/src/text-with-copy';
|
||||
|
||||
export default function SesConfigurations() {
|
||||
const sesSettingsQuery = api.admin.getSesSettings.useQuery();
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<div className="border rounded-xl shadow">
|
||||
<div className="rounded-xl border shadow">
|
||||
<Table className="">
|
||||
<TableHeader className="">
|
||||
<TableRow className=" bg-muted/30">
|
||||
<TableRow className="bg-muted/30">
|
||||
<TableHead className="rounded-tl-xl">Region</TableHead>
|
||||
<TableHead>Prefix Key</TableHead>
|
||||
<TableHead>Callback URL</TableHead>
|
||||
@@ -36,16 +36,16 @@ export default function SesConfigurations() {
|
||||
<TableBody>
|
||||
{sesSettingsQuery.isLoading ? (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={6} className="text-center py-4">
|
||||
<TableCell colSpan={6} className="py-4 text-center">
|
||||
<Spinner
|
||||
className="w-6 h-6 mx-auto"
|
||||
className="mx-auto h-6 w-6"
|
||||
innerSvgClass="stroke-primary"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : sesSettingsQuery.data?.length === 0 ? (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={6} className="text-center py-4">
|
||||
<TableCell colSpan={6} className="py-4 text-center">
|
||||
<p>No SES configurations added</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -63,7 +63,7 @@ export default function SesConfigurations() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{sesSetting.callbackSuccess ? "Success" : "Failed"}
|
||||
{sesSetting.callbackSuccess ? 'Success' : 'Failed'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatDistanceToNow(sesSetting.createdAt)} ago
|
||||
|
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { useEffect, useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -12,43 +12,45 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@usesend/ui/src/form";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { Switch } from "@usesend/ui/src/switch";
|
||||
import Spinner from "@usesend/ui/src/spinner";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { Badge } from "@usesend/ui/src/badge";
|
||||
} from '@usesend/ui/src/form';
|
||||
import { Input } from '@usesend/ui/src/input';
|
||||
import { Switch } from '@usesend/ui/src/switch';
|
||||
import Spinner from '@usesend/ui/src/spinner';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import { Badge } from '@usesend/ui/src/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@usesend/ui/src/select";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
} from '@usesend/ui/src/select';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import type { AppRouter } from "~/server/api/root";
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
import { isCloud } from "~/utils/common";
|
||||
import { api } from '~/trpc/react';
|
||||
import type { AppRouter } from '~/server/api/root';
|
||||
import type { inferRouterOutputs } from '@trpc/server';
|
||||
import { isCloud } from '~/utils/common';
|
||||
|
||||
const searchSchema = z.object({
|
||||
query: z
|
||||
.string({ required_error: "Enter a team ID, name, domain, or member email" })
|
||||
.string({
|
||||
required_error: 'Enter a team ID, name, domain, or member email',
|
||||
})
|
||||
.trim()
|
||||
.min(1, "Enter a team ID, name, domain, or member email"),
|
||||
.min(1, 'Enter a team ID, name, domain, or member email'),
|
||||
});
|
||||
|
||||
type SearchInput = z.infer<typeof searchSchema>;
|
||||
|
||||
type RouterOutputs = inferRouterOutputs<AppRouter>;
|
||||
type TeamAdmin = NonNullable<RouterOutputs["admin"]["findTeam"]>;
|
||||
type TeamAdmin = NonNullable<RouterOutputs['admin']['findTeam']>;
|
||||
|
||||
const updateSchema = z.object({
|
||||
apiRateLimit: z.coerce.number().int().min(1).max(10_000),
|
||||
dailyEmailLimit: z.coerce.number().int().min(0).max(10_000_000),
|
||||
isBlocked: z.boolean(),
|
||||
plan: z.enum(["FREE", "BASIC"]),
|
||||
plan: z.enum(['FREE', 'BASIC']),
|
||||
});
|
||||
|
||||
type UpdateInput = z.infer<typeof updateSchema>;
|
||||
@@ -59,7 +61,7 @@ export default function AdminTeamsPage() {
|
||||
|
||||
const searchForm = useForm<SearchInput>({
|
||||
resolver: zodResolver(searchSchema),
|
||||
defaultValues: { query: "" },
|
||||
defaultValues: { query: '' },
|
||||
});
|
||||
|
||||
const updateForm = useForm<UpdateInput>({
|
||||
@@ -68,7 +70,7 @@ export default function AdminTeamsPage() {
|
||||
apiRateLimit: 1,
|
||||
dailyEmailLimit: 0,
|
||||
isBlocked: false,
|
||||
plan: "FREE",
|
||||
plan: 'FREE',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -85,7 +87,7 @@ export default function AdminTeamsPage() {
|
||||
|
||||
if (!isCloud()) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/30 p-6 text-sm text-muted-foreground">
|
||||
<div className="bg-muted/30 text-muted-foreground rounded-lg border p-6 text-sm">
|
||||
Team administration tools are available only in the cloud deployment.
|
||||
</div>
|
||||
);
|
||||
@@ -96,13 +98,13 @@ export default function AdminTeamsPage() {
|
||||
setHasSearched(true);
|
||||
if (!data) {
|
||||
setTeam(null);
|
||||
toast.info("No team found for that query");
|
||||
toast.info('No team found for that query');
|
||||
return;
|
||||
}
|
||||
setTeam(data);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message ?? "Unable to search for team");
|
||||
toast.error(error.message ?? 'Unable to search for team');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -115,10 +117,10 @@ export default function AdminTeamsPage() {
|
||||
isBlocked: updated.isBlocked,
|
||||
plan: updated.plan,
|
||||
});
|
||||
toast.success("Team settings updated");
|
||||
toast.success('Team settings updated');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message ?? "Unable to update team settings");
|
||||
toast.error(error.message ?? 'Unable to update team settings');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -166,7 +168,7 @@ export default function AdminTeamsPage() {
|
||||
<Spinner className="mr-2 h-4 w-4" /> Searching...
|
||||
</>
|
||||
) : (
|
||||
"Lookup team"
|
||||
'Lookup team'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -174,7 +176,7 @@ export default function AdminTeamsPage() {
|
||||
</div>
|
||||
|
||||
{findTeam.isPending ? null : hasSearched && !team ? (
|
||||
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed p-6 text-sm">
|
||||
No team matched that query. Try another search.
|
||||
</div>
|
||||
) : null}
|
||||
@@ -183,75 +185,97 @@ export default function AdminTeamsPage() {
|
||||
<div className="space-y-6 rounded-lg border p-6 shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Team</p>
|
||||
<p className="text-muted-foreground text-sm">Team</p>
|
||||
<p className="text-xl font-semibold">{team.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
ID #{team.id} • Created {formatDistanceToNow(new Date(team.createdAt), { addSuffix: true })}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
ID #{team.id} • Created{' '}
|
||||
{formatDistanceToNow(new Date(team.createdAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">Plan: {team.plan}</Badge>
|
||||
<Badge variant={team.isBlocked ? "destructive" : "outline"}>
|
||||
{team.isBlocked ? "Blocked" : "Active"}
|
||||
<Badge variant={team.isBlocked ? 'destructive' : 'outline'}>
|
||||
{team.isBlocked ? 'Blocked' : 'Active'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Members</h3>
|
||||
<div className="space-y-2 rounded-lg border bg-muted/20 p-3">
|
||||
<h3 className="text-muted-foreground text-sm font-medium">
|
||||
Members
|
||||
</h3>
|
||||
<div className="bg-muted/20 space-y-2 rounded-lg border p-3">
|
||||
{team.teamUsers.length ? (
|
||||
team.teamUsers.map((member) => (
|
||||
<div
|
||||
key={member.user.id}
|
||||
className="flex items-center justify-between rounded-md bg-background px-3 py-2 text-sm"
|
||||
className="bg-background flex items-center justify-between rounded-md px-3 py-2 text-sm"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{member.user.name ?? member.user.email}</p>
|
||||
<p className="text-xs text-muted-foreground">{member.user.email}</p>
|
||||
<p className="font-medium">
|
||||
{member.user.name ?? member.user.email}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{member.user.email}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">{member.role}</Badge>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No members found.</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
No members found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Domains</h3>
|
||||
<div className="space-y-2 rounded-lg border bg-muted/20 p-3">
|
||||
<h3 className="text-muted-foreground text-sm font-medium">
|
||||
Domains
|
||||
</h3>
|
||||
<div className="bg-muted/20 space-y-2 rounded-lg border p-3">
|
||||
{team.domains.length ? (
|
||||
team.domains.map((domain) => (
|
||||
<div
|
||||
key={domain.id}
|
||||
className="flex items-center justify-between rounded-md bg-background px-3 py-2 text-sm"
|
||||
className="bg-background flex items-center justify-between rounded-md px-3 py-2 text-sm"
|
||||
>
|
||||
<span>{domain.name}</span>
|
||||
<Badge variant={domain.status === "SUCCESS" ? "outline" : "secondary"}>
|
||||
{domain.status === "SUCCESS"
|
||||
? "Verified"
|
||||
<Badge
|
||||
variant={
|
||||
domain.status === 'SUCCESS' ? 'outline' : 'secondary'
|
||||
}
|
||||
>
|
||||
{domain.status === 'SUCCESS'
|
||||
? 'Verified'
|
||||
: domain.status.toLowerCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No domains connected.</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
No domains connected.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-muted/10 p-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Billing contact: {team.billingEmail ?? "Not set"}
|
||||
<div className="bg-muted/10 rounded-lg border p-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Billing contact: {team.billingEmail ?? 'Not set'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-6">
|
||||
<Form {...updateForm}>
|
||||
<form onSubmit={updateForm.handleSubmit(onUpdateSubmit)} className="grid gap-6 lg:grid-cols-2">
|
||||
<form
|
||||
onSubmit={updateForm.handleSubmit(onUpdateSubmit)}
|
||||
className="grid gap-6 lg:grid-cols-2"
|
||||
>
|
||||
<FormField
|
||||
control={updateForm.control}
|
||||
name="apiRateLimit"
|
||||
@@ -336,8 +360,8 @@ export default function AdminTeamsPage() {
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={updateTeam.isPending}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{field.value ? "Team is blocked" : "Team is active"}
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{field.value ? 'Team is blocked' : 'Team is active'}
|
||||
</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
@@ -345,14 +369,14 @@ export default function AdminTeamsPage() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="lg:col-span-2 flex justify-end">
|
||||
<div className="flex justify-end lg:col-span-2">
|
||||
<Button type="submit" disabled={updateTeam.isPending}>
|
||||
{updateTeam.isPending ? (
|
||||
<>
|
||||
<Spinner className="mr-2 h-4 w-4" /> Saving...
|
||||
</>
|
||||
) : (
|
||||
"Update team"
|
||||
'Update team'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -12,30 +12,30 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@usesend/ui/src/form";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import Spinner from "@usesend/ui/src/spinner";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { Switch } from "@usesend/ui/src/switch";
|
||||
import { Badge } from "@usesend/ui/src/badge";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
} from '@usesend/ui/src/form';
|
||||
import { Input } from '@usesend/ui/src/input';
|
||||
import Spinner from '@usesend/ui/src/spinner';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import { Switch } from '@usesend/ui/src/switch';
|
||||
import { Badge } from '@usesend/ui/src/badge';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { isCloud } from "~/utils/common";
|
||||
import type { AppRouter } from "~/server/api/root";
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
import { api } from '~/trpc/react';
|
||||
import { isCloud } from '~/utils/common';
|
||||
import type { AppRouter } from '~/server/api/root';
|
||||
import type { inferRouterOutputs } from '@trpc/server';
|
||||
|
||||
const searchSchema = z.object({
|
||||
email: z
|
||||
.string({ required_error: "Email is required" })
|
||||
.string({ required_error: 'Email is required' })
|
||||
.trim()
|
||||
.email("Enter a valid email address"),
|
||||
.email('Enter a valid email address'),
|
||||
});
|
||||
|
||||
type SearchInput = z.infer<typeof searchSchema>;
|
||||
|
||||
type RouterOutputs = inferRouterOutputs<AppRouter>;
|
||||
type WaitlistUser = NonNullable<RouterOutputs["admin"]["findUserByEmail"]>;
|
||||
type WaitlistUser = NonNullable<RouterOutputs['admin']['findUserByEmail']>;
|
||||
|
||||
export default function AdminWaitlistPage() {
|
||||
const [userResult, setUserResult] = useState<WaitlistUser | null>(null);
|
||||
@@ -44,7 +44,7 @@ export default function AdminWaitlistPage() {
|
||||
const form = useForm<SearchInput>({
|
||||
resolver: zodResolver(searchSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
email: '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -53,14 +53,14 @@ export default function AdminWaitlistPage() {
|
||||
setHasSearched(true);
|
||||
if (!data) {
|
||||
setUserResult(null);
|
||||
toast.info("No user found for that email");
|
||||
toast.info('No user found for that email');
|
||||
return;
|
||||
}
|
||||
|
||||
setUserResult(data);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message ?? "Unable to search for user");
|
||||
toast.error(error.message ?? 'Unable to search for user');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -69,12 +69,12 @@ export default function AdminWaitlistPage() {
|
||||
setUserResult(updated);
|
||||
toast.success(
|
||||
updated.isWaitlisted
|
||||
? "User marked as waitlisted"
|
||||
: "User removed from waitlist",
|
||||
? 'User marked as waitlisted'
|
||||
: 'User removed from waitlist',
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message ?? "Unable to update waitlist flag");
|
||||
toast.error(error.message ?? 'Unable to update waitlist flag');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function AdminWaitlistPage() {
|
||||
|
||||
if (!isCloud()) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/30 p-6 text-sm text-muted-foreground">
|
||||
<div className="bg-muted/30 text-muted-foreground rounded-lg border p-6 text-sm">
|
||||
Waitlist tooling is available only in the cloud deployment.
|
||||
</div>
|
||||
);
|
||||
@@ -101,7 +101,11 @@ export default function AdminWaitlistPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border p-6 shadow-sm">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
noValidate
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
@@ -126,7 +130,7 @@ export default function AdminWaitlistPage() {
|
||||
<Spinner className="mr-2 h-4 w-4" /> Searching...
|
||||
</>
|
||||
) : (
|
||||
"Lookup user"
|
||||
'Lookup user'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -134,7 +138,7 @@ export default function AdminWaitlistPage() {
|
||||
</div>
|
||||
|
||||
{findUser.isPending ? null : hasSearched && !userResult ? (
|
||||
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed p-6 text-sm">
|
||||
No user matched that email. Try another search.
|
||||
</div>
|
||||
) : null}
|
||||
@@ -143,18 +147,20 @@ export default function AdminWaitlistPage() {
|
||||
<div className="space-y-4 rounded-lg border p-6 shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Email</p>
|
||||
<p className="text-muted-foreground text-sm">Email</p>
|
||||
<p className="text-base font-medium">{userResult.email}</p>
|
||||
</div>
|
||||
<Badge variant={userResult.isWaitlisted ? "destructive" : "outline"}>
|
||||
{userResult.isWaitlisted ? "Waitlisted" : "Active"}
|
||||
<Badge
|
||||
variant={userResult.isWaitlisted ? 'destructive' : 'outline'}
|
||||
>
|
||||
{userResult.isWaitlisted ? 'Waitlisted' : 'Active'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Name</p>
|
||||
<p>{userResult.name ?? "—"}</p>
|
||||
<p>{userResult.name ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Joined</p>
|
||||
@@ -169,7 +175,7 @@ export default function AdminWaitlistPage() {
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 border-t pt-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Waitlist access</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Toggle to control whether the user remains on the waitlist.
|
||||
</p>
|
||||
</div>
|
||||
|
@@ -1,18 +1,18 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { Spinner } from "@usesend/ui/src/spinner";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { Editor } from "@usesend/email-editor";
|
||||
import { use, useState } from "react";
|
||||
import { Campaign } from "@prisma/client";
|
||||
import { api } from '~/trpc/react';
|
||||
import { Spinner } from '@usesend/ui/src/spinner';
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import { Input } from '@usesend/ui/src/input';
|
||||
import { Editor } from '@usesend/email-editor';
|
||||
import { use, useState } from 'react';
|
||||
import { Campaign } from '@prisma/client';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@usesend/ui/src/select";
|
||||
} from '@usesend/ui/src/select';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -20,10 +20,10 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
import { z } from 'zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -31,16 +31,16 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@usesend/ui/src/form";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
} from '@usesend/ui/src/form';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@usesend/ui/src/accordion";
|
||||
} from '@usesend/ui/src/accordion';
|
||||
|
||||
const sendSchema = z.object({
|
||||
confirmation: z.string(),
|
||||
@@ -68,15 +68,15 @@ export default function EditCampaignPage({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-full">
|
||||
<Spinner className="w-6 h-6" />
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-full">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-red-500">Failed to load campaign</p>
|
||||
</div>
|
||||
);
|
||||
@@ -140,9 +140,9 @@ function CampaignEditor({
|
||||
|
||||
async function onSendCampaign(values: z.infer<typeof sendSchema>) {
|
||||
if (
|
||||
values.confirmation?.toLocaleLowerCase() !== "Send".toLocaleLowerCase()
|
||||
values.confirmation?.toLocaleLowerCase() !== 'Send'.toLocaleLowerCase()
|
||||
) {
|
||||
sendForm.setError("confirmation", {
|
||||
sendForm.setError('confirmation', {
|
||||
message: "Please type 'Send' to confirm",
|
||||
});
|
||||
return;
|
||||
@@ -171,7 +171,7 @@ function CampaignEditor({
|
||||
);
|
||||
}
|
||||
|
||||
console.log("file type: ", file.type);
|
||||
console.log('file type: ', file.type);
|
||||
|
||||
const { uploadUrl, imageUrl } = await getUploadUrl.mutateAsync({
|
||||
name: file.name,
|
||||
@@ -180,32 +180,32 @@ function CampaignEditor({
|
||||
});
|
||||
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: "PUT",
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to upload file");
|
||||
throw new Error('Failed to upload file');
|
||||
}
|
||||
|
||||
return imageUrl;
|
||||
};
|
||||
|
||||
const confirmation = sendForm.watch("confirmation");
|
||||
const confirmation = sendForm.watch('confirmation');
|
||||
|
||||
const contactBook = contactBooksQuery.data?.find(
|
||||
(book) => book.id === contactBookId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-4 container mx-auto ">
|
||||
<div className="container mx-auto p-4">
|
||||
<div className="mx-auto">
|
||||
<div className="mb-4 flex justify-between items-center w-[700px] mx-auto">
|
||||
<div className="mx-auto mb-4 flex w-[700px] items-center justify-between">
|
||||
<Input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className=" border-0 focus:ring-0 focus:outline-none px-0.5 w-[300px]"
|
||||
className="w-[300px] border-0 px-0.5 focus:outline-none focus:ring-0"
|
||||
onBlur={() => {
|
||||
if (name === campaign.name || !name) {
|
||||
return;
|
||||
@@ -227,12 +227,12 @@ function CampaignEditor({
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
{isSaving ? (
|
||||
<div className="h-2 w-2 bg-yellow-500 rounded-full" />
|
||||
<div className="h-2 w-2 rounded-full bg-yellow-500" />
|
||||
) : (
|
||||
<div className="h-2 w-2 bg-emerald-500 rounded-full" />
|
||||
<div className="h-2 w-2 rounded-full bg-emerald-500" />
|
||||
)}
|
||||
{formatDistanceToNow(campaign.updatedAt) === "less than a minute"
|
||||
? "just now"
|
||||
{formatDistanceToNow(campaign.updatedAt) === 'less than a minute'
|
||||
? 'just now'
|
||||
: `${formatDistanceToNow(campaign.updatedAt)} ago`}
|
||||
</div>
|
||||
<Dialog open={openSendDialog} onOpenChange={setOpenSendDialog}>
|
||||
@@ -272,12 +272,12 @@ function CampaignEditor({
|
||||
disabled={
|
||||
sendCampaignMutation.isPending ||
|
||||
confirmation?.toLocaleLowerCase() !==
|
||||
"Send".toLocaleLowerCase()
|
||||
'Send'.toLocaleLowerCase()
|
||||
}
|
||||
>
|
||||
{sendCampaignMutation.isPending
|
||||
? "Sending..."
|
||||
: "Send"}
|
||||
? 'Sending...'
|
||||
: 'Send'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -290,9 +290,9 @@ function CampaignEditor({
|
||||
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="item-1">
|
||||
<div className="flex flex-col border shadow rounded-lg mt-12 mb-12 p-4 w-[700px] mx-auto z-50">
|
||||
<div className="z-50 mx-auto mb-12 mt-12 flex w-[700px] flex-col rounded-lg border p-4 shadow">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="block text-sm w-[80px] text-muted-foreground">
|
||||
<label className="text-muted-foreground block w-[80px] text-sm">
|
||||
Subject
|
||||
</label>
|
||||
<input
|
||||
@@ -318,14 +318,14 @@ function CampaignEditor({
|
||||
},
|
||||
);
|
||||
}}
|
||||
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent focus:border-border bg-transparent"
|
||||
className="focus:border-border mt-1 block w-full border-b border-transparent bg-transparent py-1 text-sm outline-none"
|
||||
/>
|
||||
<AccordionTrigger className="py-0"></AccordionTrigger>
|
||||
</div>
|
||||
|
||||
<AccordionContent className=" flex flex-col gap-4">
|
||||
<div className=" flex items-center gap-4 mt-4">
|
||||
<label className=" text-sm w-[80px] text-muted-foreground">
|
||||
<AccordionContent className="flex flex-col gap-4">
|
||||
<div className="mt-4 flex items-center gap-4">
|
||||
<label className="text-muted-foreground w-[80px] text-sm">
|
||||
From
|
||||
</label>
|
||||
<input
|
||||
@@ -334,7 +334,7 @@ function CampaignEditor({
|
||||
onChange={(e) => {
|
||||
setFrom(e.target.value);
|
||||
}}
|
||||
className="mt-1 py-1 w-full text-sm outline-none border-b border-transparent focus:border-border bg-transparent"
|
||||
className="focus:border-border mt-1 w-full border-b border-transparent bg-transparent py-1 text-sm outline-none"
|
||||
placeholder="Friendly name<hello@example.com>"
|
||||
onBlur={() => {
|
||||
if (from === campaign.from || !from) {
|
||||
@@ -356,7 +356,7 @@ function CampaignEditor({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="block text-sm w-[80px] text-muted-foreground">
|
||||
<label className="text-muted-foreground block w-[80px] text-sm">
|
||||
Reply To
|
||||
</label>
|
||||
<input
|
||||
@@ -365,7 +365,7 @@ function CampaignEditor({
|
||||
onChange={(e) => {
|
||||
setReplyTo(e.target.value);
|
||||
}}
|
||||
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent bg-transparent focus:border-border"
|
||||
className="focus:border-border mt-1 block w-full border-b border-transparent bg-transparent py-1 text-sm outline-none"
|
||||
placeholder="hello@example.com"
|
||||
onBlur={() => {
|
||||
if (replyTo === campaign.replyTo[0]) {
|
||||
@@ -388,7 +388,7 @@ function CampaignEditor({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="block text-sm w-[80px] text-muted-foreground">
|
||||
<label className="text-muted-foreground block w-[80px] text-sm">
|
||||
Preview
|
||||
</label>
|
||||
<input
|
||||
@@ -412,23 +412,23 @@ function CampaignEditor({
|
||||
{
|
||||
onError: (e) => {
|
||||
toast.error(`${e.message}. Reverting changes.`);
|
||||
setPreviewText(campaign.previewText ?? "");
|
||||
setPreviewText(campaign.previewText ?? '');
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent bg-transparent focus:border-border"
|
||||
className="focus:border-border mt-1 block w-full border-b border-transparent bg-transparent py-1 text-sm outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className=" flex items-center gap-2">
|
||||
<label className="block text-sm w-[80px] text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-muted-foreground block w-[80px] text-sm">
|
||||
To
|
||||
</label>
|
||||
{contactBooksQuery.isLoading ? (
|
||||
<Spinner className="w-6 h-6" />
|
||||
<Spinner className="h-6 w-6" />
|
||||
) : (
|
||||
<Select
|
||||
value={contactBookId ?? ""}
|
||||
value={contactBookId ?? ''}
|
||||
onValueChange={(val) => {
|
||||
// Update the campaign's contactBookId
|
||||
updateCampaignMutation.mutate(
|
||||
@@ -448,14 +448,14 @@ function CampaignEditor({
|
||||
<SelectTrigger className="w-[300px]">
|
||||
{contactBook
|
||||
? `${contactBook.emoji} ${contactBook.name}`
|
||||
: "Select a contact book"}
|
||||
: 'Select a contact book'}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{contactBooksQuery.data?.map((book) => (
|
||||
<SelectItem key={book.id} value={book.id}>
|
||||
{book.emoji} {book.name}{" "}
|
||||
<span className="text-xs text-muted-foreground ml-4">
|
||||
{" "}
|
||||
{book.emoji} {book.name}{' '}
|
||||
<span className="text-muted-foreground ml-4 text-xs">
|
||||
{' '}
|
||||
{book._count.contacts} contacts
|
||||
</span>
|
||||
</SelectItem>
|
||||
@@ -469,8 +469,8 @@ function CampaignEditor({
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className=" rounded-lg bg-gray-50 w-[700px] mx-auto p-10">
|
||||
<div className="w-[600px] mx-auto">
|
||||
<div className="mx-auto w-[700px] rounded-lg bg-gray-50 p-10">
|
||||
<div className="mx-auto w-[600px]">
|
||||
<Editor
|
||||
initialContent={json}
|
||||
onUpdate={(content) => {
|
||||
@@ -478,7 +478,7 @@ function CampaignEditor({
|
||||
setIsSaving(true);
|
||||
deboucedUpdateCampaign();
|
||||
}}
|
||||
variables={["email", "firstName", "lastName"]}
|
||||
variables={['email', 'firstName', 'lastName']}
|
||||
uploadImage={
|
||||
campaign.imageUploadSupported ? handleFileChange : undefined
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@usesend/ui/src/breadcrumb";
|
||||
import Link from "next/link";
|
||||
import { H2 } from "@usesend/ui";
|
||||
} from '@usesend/ui/src/breadcrumb';
|
||||
import Link from 'next/link';
|
||||
import { H2 } from '@usesend/ui';
|
||||
|
||||
import Spinner from "@usesend/ui/src/spinner";
|
||||
import { api } from "~/trpc/react";
|
||||
import { use } from "react";
|
||||
import Spinner from '@usesend/ui/src/spinner';
|
||||
import { api } from '~/trpc/react';
|
||||
import { use } from 'react';
|
||||
|
||||
export default function CampaignDetailsPage({
|
||||
params,
|
||||
@@ -28,8 +28,8 @@ export default function CampaignDetailsPage({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<Spinner className="w-5 h-5 text-foreground" />
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Spinner className="text-foreground h-5 w-5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,22 +40,22 @@ export default function CampaignDetailsPage({
|
||||
|
||||
const statusCards = [
|
||||
{
|
||||
status: "delivered",
|
||||
status: 'delivered',
|
||||
count: campaign.delivered,
|
||||
percentage: 100,
|
||||
},
|
||||
{
|
||||
status: "unsubscribed",
|
||||
status: 'unsubscribed',
|
||||
count: campaign.unsubscribed,
|
||||
percentage: (campaign.unsubscribed / campaign.delivered) * 100,
|
||||
},
|
||||
{
|
||||
status: "clicked",
|
||||
status: 'clicked',
|
||||
count: campaign.clicked,
|
||||
percentage: (campaign.clicked / campaign.delivered) * 100,
|
||||
},
|
||||
{
|
||||
status: "opened",
|
||||
status: 'opened',
|
||||
count: campaign.opened,
|
||||
percentage: (campaign.opened / campaign.delivered) * 100,
|
||||
},
|
||||
@@ -74,9 +74,7 @@ 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>
|
||||
@@ -86,20 +84,20 @@ export default function CampaignDetailsPage({
|
||||
{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="text-muted-foreground w-[70px]">From</div>
|
||||
<div> {campaign.from}</div>
|
||||
</div>
|
||||
<div className="flex text-sm items-center">
|
||||
<div className="w-[70px] text-muted-foreground">Contact</div>
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="text-muted-foreground w-[70px]">Contact</div>
|
||||
<Link
|
||||
href={`/contacts/${campaign.contactBookId}`}
|
||||
target="_blank"
|
||||
>
|
||||
<div className="bg-secondary p-0.5 px-2 rounded-md ">
|
||||
<div className="bg-secondary rounded-md p-0.5 px-2">
|
||||
{campaign.contactBook?.emoji}
|
||||
{campaign.contactBook?.name}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className=" dark:bg-slate-50 overflow-auto text-black rounded py-8 border-t">
|
||||
<div dangerouslySetInnerHTML={{ __html: campaign.html ?? "" }} />
|
||||
<div className="overflow-auto rounded border-t py-8 text-black dark:bg-slate-50">
|
||||
<div dangerouslySetInnerHTML={{ __html: campaign.html ?? '' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,40 +145,40 @@ export default function CampaignDetailsPage({
|
||||
}
|
||||
|
||||
const CampaignStatusBadge: React.FC<{ status: string }> = ({ status }) => {
|
||||
let outsideColor = "bg-gray";
|
||||
let insideColor = "bg-gray/50";
|
||||
let outsideColor = 'bg-gray';
|
||||
let insideColor = 'bg-gray/50';
|
||||
|
||||
switch (status) {
|
||||
case "delivered":
|
||||
outsideColor = "bg-green/30";
|
||||
insideColor = "bg-green";
|
||||
case 'delivered':
|
||||
outsideColor = 'bg-green/30';
|
||||
insideColor = 'bg-green';
|
||||
break;
|
||||
case "bounced":
|
||||
case "unsubscribed":
|
||||
outsideColor = "bg-red/30";
|
||||
insideColor = "bg-red";
|
||||
case 'bounced':
|
||||
case 'unsubscribed':
|
||||
outsideColor = 'bg-red/30';
|
||||
insideColor = 'bg-red';
|
||||
break;
|
||||
case "clicked":
|
||||
outsideColor = "bg-blue/30";
|
||||
insideColor = "bg-blue";
|
||||
case 'clicked':
|
||||
outsideColor = 'bg-blue/30';
|
||||
insideColor = 'bg-blue';
|
||||
break;
|
||||
case "opened":
|
||||
outsideColor = "bg-purple/30";
|
||||
insideColor = "bg-purple";
|
||||
case 'opened':
|
||||
outsideColor = 'bg-purple/30';
|
||||
insideColor = 'bg-purple';
|
||||
break;
|
||||
|
||||
case "complained":
|
||||
outsideColor = "bg-yellow/30";
|
||||
insideColor = "bg-yellow";
|
||||
case 'complained':
|
||||
outsideColor = 'bg-yellow/30';
|
||||
insideColor = 'bg-yellow';
|
||||
break;
|
||||
default:
|
||||
outsideColor = "bg-gray/40";
|
||||
insideColor = "bg-gray";
|
||||
outsideColor = 'bg-gray/40';
|
||||
insideColor = 'bg-gray';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex justify-center items-center p-1.5 ${outsideColor} rounded-full`}
|
||||
className={`flex items-center justify-center p-1.5 ${outsideColor} rounded-full`}
|
||||
>
|
||||
<div className={`h-2 w-2 rounded-full ${insideColor}`}></div>
|
||||
</div>
|
||||
|
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Table,
|
||||
@@ -7,26 +7,26 @@ import {
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableCell,
|
||||
} from "@usesend/ui/src/table";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useUrlState } from "~/hooks/useUrlState";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import Spinner from "@usesend/ui/src/spinner";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { CampaignStatus } from "@prisma/client";
|
||||
import DeleteCampaign from "./delete-campaign";
|
||||
import Link from "next/link";
|
||||
import DuplicateCampaign from "./duplicate-campaign";
|
||||
} from '@usesend/ui/src/table';
|
||||
import { api } from '~/trpc/react';
|
||||
import { useUrlState } from '~/hooks/useUrlState';
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import Spinner from '@usesend/ui/src/spinner';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { CampaignStatus } from '@prisma/client';
|
||||
import DeleteCampaign from './delete-campaign';
|
||||
import Link from 'next/link';
|
||||
import DuplicateCampaign from './duplicate-campaign';
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@usesend/ui/src/select";
|
||||
} from '@usesend/ui/src/select';
|
||||
|
||||
export default function CampaignList() {
|
||||
const [page, setPage] = useUrlState("page", "1");
|
||||
const [status, setStatus] = useUrlState("status");
|
||||
const [page, setPage] = useUrlState('page', '1');
|
||||
const [status, setStatus] = useUrlState('status');
|
||||
|
||||
const pageNumber = Number(page);
|
||||
|
||||
@@ -39,35 +39,32 @@ export default function CampaignList() {
|
||||
<div className="mt-10 flex flex-col gap-4">
|
||||
<div className="flex justify-end">
|
||||
<Select
|
||||
value={status ?? "all"}
|
||||
onValueChange={(val) => setStatus(val === "all" ? null : val)}
|
||||
value={status ?? 'all'}
|
||||
onValueChange={(val) => setStatus(val === 'all' ? null : val)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] capitalize">
|
||||
{status ? status.toLowerCase() : "All statuses"}
|
||||
{status ? status.toLowerCase() : 'All statuses'}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all" className=" capitalize">
|
||||
<SelectItem value="all" className="capitalize">
|
||||
All statuses
|
||||
</SelectItem>
|
||||
<SelectItem value={CampaignStatus.DRAFT} className=" capitalize">
|
||||
<SelectItem value={CampaignStatus.DRAFT} className="capitalize">
|
||||
Draft
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value={CampaignStatus.SCHEDULED}
|
||||
className=" capitalize"
|
||||
>
|
||||
<SelectItem value={CampaignStatus.SCHEDULED} className="capitalize">
|
||||
Scheduled
|
||||
</SelectItem>
|
||||
<SelectItem value={CampaignStatus.SENT} className=" capitalize">
|
||||
<SelectItem value={CampaignStatus.SENT} className="capitalize">
|
||||
Sent
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col rounded-xl border border-border shadow">
|
||||
<div className="border-border flex flex-col rounded-xl border shadow">
|
||||
<Table className="">
|
||||
<TableHeader className="">
|
||||
<TableRow className=" bg-muted/30">
|
||||
<TableRow className="bg-muted/30">
|
||||
<TableHead className="rounded-tl-xl">Name</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="">Created At</TableHead>
|
||||
@@ -77,9 +74,9 @@ export default function CampaignList() {
|
||||
<TableBody>
|
||||
{campaignsQuery.isLoading ? (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={4} className="text-center py-4">
|
||||
<TableCell colSpan={4} className="py-4 text-center">
|
||||
<Spinner
|
||||
className="w-6 h-6 mx-auto"
|
||||
className="mx-auto h-6 w-6"
|
||||
innerSvgClass="stroke-primary"
|
||||
/>
|
||||
</TableCell>
|
||||
@@ -89,7 +86,7 @@ export default function CampaignList() {
|
||||
<TableRow key={campaign.id} className="">
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
className="underline underline-offset-4 decoration-dashed text-foreground hover:text-foreground"
|
||||
className="text-foreground hover:text-foreground underline decoration-dashed underline-offset-4"
|
||||
href={
|
||||
campaign.status === CampaignStatus.DRAFT
|
||||
? `/campaigns/${campaign.id}/edit`
|
||||
@@ -101,12 +98,12 @@ export default function CampaignList() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div
|
||||
className={`text-center w-[130px] rounded capitalize py-1 text-xs ${
|
||||
className={`w-[130px] rounded py-1 text-center text-xs capitalize ${
|
||||
campaign.status === CampaignStatus.DRAFT
|
||||
? "bg-gray/15 text-gray border border-gray/25"
|
||||
? 'bg-gray/15 text-gray border-gray/25 border'
|
||||
: campaign.status === CampaignStatus.SENT
|
||||
? "bg-green/15 text-green border border-green/25"
|
||||
: "bg-yellow/15 text-yellow border border-yellow/25"
|
||||
? 'bg-green/15 text-green border-green/25 border'
|
||||
: 'bg-yellow/15 text-yellow border-yellow/25 border'
|
||||
}`}
|
||||
>
|
||||
{campaign.status.toLowerCase()}
|
||||
@@ -127,7 +124,7 @@ export default function CampaignList() {
|
||||
))
|
||||
) : (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={4} className="text-center py-4">
|
||||
<TableCell colSpan={4} className="py-4 text-center">
|
||||
No campaigns found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -135,7 +132,7 @@ export default function CampaignList() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex gap-4 justify-end">
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setPage((pageNumber - 1).toString())}
|
||||
|
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import { Input } from '@usesend/ui/src/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -16,27 +16,27 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@usesend/ui/src/form";
|
||||
} from '@usesend/ui/src/form';
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Spinner from "@usesend/ui/src/spinner";
|
||||
import { api } from '~/trpc/react';
|
||||
import { useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Spinner from '@usesend/ui/src/spinner';
|
||||
|
||||
const campaignSchema = z.object({
|
||||
name: z.string({ required_error: "Name is required" }).min(1, {
|
||||
message: "Name is required",
|
||||
name: z.string({ required_error: 'Name is required' }).min(1, {
|
||||
message: 'Name is required',
|
||||
}),
|
||||
from: z.string({ required_error: "From email is required" }).min(1, {
|
||||
message: "From email is required",
|
||||
from: z.string({ required_error: 'From email is required' }).min(1, {
|
||||
message: 'From email is required',
|
||||
}),
|
||||
subject: z.string({ required_error: "Subject is required" }).min(1, {
|
||||
message: "Subject is required",
|
||||
subject: z.string({ required_error: 'Subject is required' }).min(1, {
|
||||
message: 'Subject is required',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -49,9 +49,9 @@ export default function CreateCampaign() {
|
||||
const campaignForm = useForm<z.infer<typeof campaignSchema>>({
|
||||
resolver: zodResolver(campaignSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
from: "",
|
||||
subject: "",
|
||||
name: '',
|
||||
from: '',
|
||||
subject: '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -68,13 +68,13 @@ export default function CreateCampaign() {
|
||||
onSuccess: async (data) => {
|
||||
utils.campaign.getCampaigns.invalidate();
|
||||
router.push(`/campaigns/${data.id}/edit`);
|
||||
toast.success("Campaign created successfully");
|
||||
toast.success('Campaign created successfully');
|
||||
setOpen(false);
|
||||
},
|
||||
onError: async (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function CreateCampaign() {
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Create Campaign
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@@ -146,14 +146,14 @@ export default function CreateCampaign() {
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className=" w-[100px]"
|
||||
className="w-[100px]"
|
||||
type="submit"
|
||||
disabled={createCampaignMutation.isPending}
|
||||
>
|
||||
{createCampaignMutation.isPending ? (
|
||||
<Spinner className="w-4 h-4" />
|
||||
<Spinner className="h-4 w-4" />
|
||||
) : (
|
||||
"Create"
|
||||
'Create'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import { Input } from '@usesend/ui/src/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -9,14 +9,14 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
import { api } from "~/trpc/react";
|
||||
import React, { useState } from "react";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
import { api } from '~/trpc/react';
|
||||
import React, { useState } from 'react';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -25,8 +25,8 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@usesend/ui/src/form";
|
||||
import { Campaign } from "@prisma/client";
|
||||
} from '@usesend/ui/src/form';
|
||||
import { Campaign } from '@prisma/client';
|
||||
|
||||
const campaignSchema = z.object({
|
||||
name: z.string(),
|
||||
@@ -46,8 +46,8 @@ export const DeleteCampaign: React.FC<{
|
||||
|
||||
async function onCampaignDelete(values: z.infer<typeof campaignSchema>) {
|
||||
if (values.name !== campaign.name) {
|
||||
campaignForm.setError("name", {
|
||||
message: "Name does not match",
|
||||
campaignForm.setError('name', {
|
||||
message: 'Name does not match',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export const DeleteCampaign: React.FC<{
|
||||
);
|
||||
}
|
||||
|
||||
const name = campaignForm.watch("name");
|
||||
const name = campaignForm.watch('name');
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -75,15 +75,15 @@ export const DeleteCampaign: React.FC<{
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent">
|
||||
<Trash2 className="h-[18px] w-[18px] text-red/80" />
|
||||
<Trash2 className="text-red/80 h-[18px] w-[18px]" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Campaign</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
Are you sure you want to delete{' '}
|
||||
<span className="text-foreground font-semibold">
|
||||
{campaign.name}
|
||||
</span>
|
||||
? You can't reverse this.
|
||||
@@ -107,7 +107,7 @@ export const DeleteCampaign: React.FC<{
|
||||
{formState.errors.name ? (
|
||||
<FormMessage />
|
||||
) : (
|
||||
<FormDescription className=" text-transparent">
|
||||
<FormDescription className="text-transparent">
|
||||
.
|
||||
</FormDescription>
|
||||
)}
|
||||
@@ -122,7 +122,7 @@ export const DeleteCampaign: React.FC<{
|
||||
deleteCampaignMutation.isPending || campaign.name !== name
|
||||
}
|
||||
>
|
||||
{deleteCampaignMutation.isPending ? "Deleting..." : "Delete"}
|
||||
{deleteCampaignMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
import { api } from "~/trpc/react";
|
||||
import React, { useState } from "react";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { Copy } from "lucide-react";
|
||||
import { Campaign } from "@prisma/client";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
import { api } from '~/trpc/react';
|
||||
import React, { useState } from 'react';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { Campaign } from '@prisma/client';
|
||||
|
||||
export const DuplicateCampaign: React.FC<{
|
||||
campaign: Partial<Campaign> & { id: string };
|
||||
@@ -46,15 +46,15 @@ export const DuplicateCampaign: React.FC<{
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent">
|
||||
<Copy className="h-[18px] w-[18px] text-blue/80" />
|
||||
<Copy className="text-blue/80 h-[18px] w-[18px]" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Duplicate Campaign</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to duplicate{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
Are you sure you want to duplicate{' '}
|
||||
<span className="text-foreground font-semibold">
|
||||
{campaign.name}
|
||||
</span>
|
||||
?
|
||||
@@ -68,8 +68,8 @@ export const DuplicateCampaign: React.FC<{
|
||||
disabled={duplicateCampaignMutation.isPending}
|
||||
>
|
||||
{duplicateCampaignMutation.isPending
|
||||
? "Duplicating..."
|
||||
: "Duplicate"}
|
||||
? 'Duplicating...'
|
||||
: 'Duplicate'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import CampaignList from "./campaign-list";
|
||||
import CreateCampaign from "./create-campaign";
|
||||
import { H1 } from "@usesend/ui";
|
||||
import CampaignList from './campaign-list';
|
||||
import CreateCampaign from './create-campaign';
|
||||
import { H1 } from '@usesend/ui';
|
||||
|
||||
export default function ContactsPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<H1>Campaigns</H1>
|
||||
<CreateCampaign />
|
||||
</div>
|
||||
|
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Textarea } from "@usesend/ui/src/textarea";
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import { Textarea } from '@usesend/ui/src/textarea';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -17,20 +17,20 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@usesend/ui/src/form";
|
||||
} from '@usesend/ui/src/form';
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { api } from '~/trpc/react';
|
||||
import { useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { z } from 'zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
|
||||
const contactsSchema = z.object({
|
||||
contacts: z.string({ required_error: "Contacts are required" }).min(1, {
|
||||
message: "Contacts are required",
|
||||
contacts: z.string({ required_error: 'Contacts are required' }).min(1, {
|
||||
message: 'Contacts are required',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -46,14 +46,14 @@ export default function AddContact({
|
||||
const contactsForm = useForm<z.infer<typeof contactsSchema>>({
|
||||
resolver: zodResolver(contactsSchema),
|
||||
defaultValues: {
|
||||
contacts: "",
|
||||
contacts: '',
|
||||
},
|
||||
});
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
async function onContactsAdd(values: z.infer<typeof contactsSchema>) {
|
||||
const contactsArray = values.contacts.split(",").map((email) => ({
|
||||
const contactsArray = values.contacts.split(',').map((email) => ({
|
||||
email: email.trim(),
|
||||
}));
|
||||
|
||||
@@ -66,12 +66,12 @@ export default function AddContact({
|
||||
onSuccess: async () => {
|
||||
utils.contacts.contacts.invalidate();
|
||||
setOpen(false);
|
||||
toast.success("Contacts added successfully");
|
||||
toast.success('Contacts added successfully');
|
||||
},
|
||||
onError: async (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function AddContact({
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Add Contacts
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@@ -120,11 +120,11 @@ export default function AddContact({
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className=" w-[100px]"
|
||||
className="w-[100px]"
|
||||
type="submit"
|
||||
disabled={addContactsMutation.isPending}
|
||||
>
|
||||
{addContactsMutation.isPending ? "Adding..." : "Add"}
|
||||
{addContactsMutation.isPending ? 'Adding...' : 'Add'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@usesend/ui/src/select";
|
||||
import Spinner from "@usesend/ui/src/spinner";
|
||||
} from '@usesend/ui/src/select';
|
||||
import Spinner from '@usesend/ui/src/spinner';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -15,34 +15,34 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@usesend/ui/src/table";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import Image from "next/image";
|
||||
import { useUrlState } from "~/hooks/useUrlState";
|
||||
import { api } from "~/trpc/react";
|
||||
import { getGravatarUrl } from "~/utils/gravatar-utils";
|
||||
import DeleteContact from "./delete-contact";
|
||||
import EditContact from "./edit-contact";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
} from '@usesend/ui/src/table';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import Image from 'next/image';
|
||||
import { useUrlState } from '~/hooks/useUrlState';
|
||||
import { api } from '~/trpc/react';
|
||||
import { getGravatarUrl } from '~/utils/gravatar-utils';
|
||||
import DeleteContact from './delete-contact';
|
||||
import EditContact from './edit-contact';
|
||||
import { Input } from '@usesend/ui/src/input';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@usesend/ui/src/tooltip";
|
||||
import { UnsubscribeReason } from "@prisma/client";
|
||||
} from '@usesend/ui/src/tooltip';
|
||||
import { UnsubscribeReason } from '@prisma/client';
|
||||
|
||||
function getUnsubscribeReason(reason: UnsubscribeReason) {
|
||||
switch (reason) {
|
||||
case UnsubscribeReason.BOUNCED:
|
||||
return "Email bounced";
|
||||
return 'Email bounced';
|
||||
case UnsubscribeReason.COMPLAINED:
|
||||
return "User complained";
|
||||
return 'User complained';
|
||||
case UnsubscribeReason.UNSUBSCRIBED:
|
||||
return "User unsubscribed";
|
||||
return 'User unsubscribed';
|
||||
default:
|
||||
return "User unsubscribed";
|
||||
return 'User unsubscribed';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,9 +51,9 @@ export default function ContactList({
|
||||
}: {
|
||||
contactBookId: string;
|
||||
}) {
|
||||
const [page, setPage] = useUrlState("page", "1");
|
||||
const [status, setStatus] = useUrlState("status");
|
||||
const [search, setSearch] = useUrlState("search");
|
||||
const [page, setPage] = useUrlState('page', '1');
|
||||
const [status, setStatus] = useUrlState('status');
|
||||
const [search, setSearch] = useUrlState('search');
|
||||
|
||||
const pageNumber = Number(page);
|
||||
|
||||
@@ -62,9 +62,9 @@ export default function ContactList({
|
||||
page: pageNumber,
|
||||
search: search ?? undefined,
|
||||
subscribed:
|
||||
status === "Subscribed"
|
||||
status === 'Subscribed'
|
||||
? true
|
||||
: status === "Unsubscribed"
|
||||
: status === 'Unsubscribed'
|
||||
? false
|
||||
: undefined,
|
||||
});
|
||||
@@ -80,35 +80,35 @@ export default function ContactList({
|
||||
<div>
|
||||
<Input
|
||||
placeholder="Search by email or name"
|
||||
className="w-[350px] mr-4"
|
||||
defaultValue={search ?? ""}
|
||||
className="mr-4 w-[350px]"
|
||||
defaultValue={search ?? ''}
|
||||
onChange={(e) => debouncedSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={status ?? "All"}
|
||||
onValueChange={(val) => setStatus(val === "All" ? null : val)}
|
||||
value={status ?? 'All'}
|
||||
onValueChange={(val) => setStatus(val === 'All' ? null : val)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] capitalize">
|
||||
{status || "All statuses"}
|
||||
{status || 'All statuses'}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className=" capitalize">
|
||||
<SelectItem value="All" className="capitalize">
|
||||
All statuses
|
||||
</SelectItem>
|
||||
<SelectItem value="Subscribed" className=" capitalize">
|
||||
<SelectItem value="Subscribed" className="capitalize">
|
||||
Subscribed
|
||||
</SelectItem>
|
||||
<SelectItem value="Unsubscribed" className=" capitalize">
|
||||
<SelectItem value="Unsubscribed" className="capitalize">
|
||||
Unsubscribed
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col rounded-xl border border-broder shadow">
|
||||
<div className="border-broder flex flex-col rounded-xl border shadow">
|
||||
<Table className="">
|
||||
<TableHeader className="">
|
||||
<TableRow className=" bg-muted/30">
|
||||
<TableRow className="bg-muted/30">
|
||||
<TableHead className="rounded-tl-xl">Email</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="">Created At</TableHead>
|
||||
@@ -118,9 +118,9 @@ export default function ContactList({
|
||||
<TableBody>
|
||||
{contactsQuery.isLoading ? (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={4} className="text-center py-4">
|
||||
<TableCell colSpan={4} className="py-4 text-center">
|
||||
<Spinner
|
||||
className="w-6 h-6 mx-auto"
|
||||
className="mx-auto h-6 w-6"
|
||||
innerSvgClass="stroke-primary"
|
||||
/>
|
||||
</TableCell>
|
||||
@@ -133,7 +133,7 @@ export default function ContactList({
|
||||
<Image
|
||||
src={getGravatarUrl(contact.email, {
|
||||
size: 75,
|
||||
defaultImage: "robohash",
|
||||
defaultImage: 'robohash',
|
||||
})}
|
||||
alt={contact.email + "'s gravatar"}
|
||||
width={35}
|
||||
@@ -144,7 +144,7 @@ export default function ContactList({
|
||||
<span className="text-sm font-medium">
|
||||
{contact.email}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{contact.firstName} {contact.lastName}
|
||||
</span>
|
||||
</div>
|
||||
@@ -152,13 +152,13 @@ export default function ContactList({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{contact.subscribed ? (
|
||||
<div className="text-center w-[130px] rounded capitalize py-1 text-xs bg-green/15 text-green border border-green/25">
|
||||
<div className="bg-green/15 text-green border-green/25 w-[130px] rounded border py-1 text-center text-xs capitalize">
|
||||
Subscribed
|
||||
</div>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="text-center w-[130px] rounded capitalize py-1 text-xs bg-red/10 text-red border border-red/10">
|
||||
<div className="bg-red/10 text-red border-red/10 w-[130px] rounded border py-1 text-center text-xs capitalize">
|
||||
Unsubscribed
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
@@ -188,7 +188,7 @@ export default function ContactList({
|
||||
))
|
||||
) : (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={4} className="text-center py-4">
|
||||
<TableCell colSpan={4} className="py-4 text-center">
|
||||
No contacts found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -196,7 +196,7 @@ export default function ContactList({
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex gap-4 justify-end">
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setPage((pageNumber - 1).toString())}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import { Input } from '@usesend/ui/src/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -9,14 +9,14 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
import { api } from "~/trpc/react";
|
||||
import React, { useState } from "react";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
import { api } from '~/trpc/react';
|
||||
import React, { useState } from 'react';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -25,8 +25,8 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@usesend/ui/src/form";
|
||||
import { Contact } from "@prisma/client";
|
||||
} from '@usesend/ui/src/form';
|
||||
import { Contact } from '@prisma/client';
|
||||
|
||||
const contactSchema = z.object({
|
||||
email: z.string().email(),
|
||||
@@ -46,8 +46,8 @@ export const DeleteContact: React.FC<{
|
||||
|
||||
async function onContactDelete(values: z.infer<typeof contactSchema>) {
|
||||
if (values.email !== contact.email) {
|
||||
contactForm.setError("email", {
|
||||
message: "Email does not match",
|
||||
contactForm.setError('email', {
|
||||
message: 'Email does not match',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export const DeleteContact: React.FC<{
|
||||
);
|
||||
}
|
||||
|
||||
const email = contactForm.watch("email");
|
||||
const email = contactForm.watch('email');
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -79,15 +79,15 @@ export const DeleteContact: React.FC<{
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Trash2 className="h-4 w-4 text-red/80" />
|
||||
<Trash2 className="text-red/80 h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Contact</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
Are you sure you want to delete{' '}
|
||||
<span className="text-foreground font-semibold">
|
||||
{contact.email}
|
||||
</span>
|
||||
? You can't reverse this.
|
||||
@@ -111,7 +111,7 @@ export const DeleteContact: React.FC<{
|
||||
{formState.errors.email ? (
|
||||
<FormMessage />
|
||||
) : (
|
||||
<FormDescription className=" text-transparent">
|
||||
<FormDescription className="text-transparent">
|
||||
.
|
||||
</FormDescription>
|
||||
)}
|
||||
@@ -126,7 +126,7 @@ export const DeleteContact: React.FC<{
|
||||
deleteContactMutation.isPending || contact.email !== email
|
||||
}
|
||||
>
|
||||
{deleteContactMutation.isPending ? "Deleting..." : "Delete"}
|
||||
{deleteContactMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import { Input } from '@usesend/ui/src/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -17,21 +17,21 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@usesend/ui/src/form";
|
||||
} from '@usesend/ui/src/form';
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { useState } from "react";
|
||||
import { Edit } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { Switch } from "@usesend/ui/src/switch";
|
||||
import { Contact } from "@prisma/client";
|
||||
import { api } from '~/trpc/react';
|
||||
import { useState } from 'react';
|
||||
import { Edit } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { z } from 'zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import { Switch } from '@usesend/ui/src/switch';
|
||||
import { Contact } from '@prisma/client';
|
||||
|
||||
const contactSchema = z.object({
|
||||
email: z.string().email({ message: "Invalid email address" }),
|
||||
email: z.string().email({ message: 'Invalid email address' }),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
subscribed: z.boolean().optional(),
|
||||
@@ -49,9 +49,9 @@ export const EditContact: React.FC<{
|
||||
const contactForm = useForm<z.infer<typeof contactSchema>>({
|
||||
resolver: zodResolver(contactSchema),
|
||||
defaultValues: {
|
||||
email: contact.email || "",
|
||||
firstName: contact.firstName || "",
|
||||
lastName: contact.lastName || "",
|
||||
email: contact.email || '',
|
||||
firstName: contact.firstName || '',
|
||||
lastName: contact.lastName || '',
|
||||
subscribed: contact.subscribed || false,
|
||||
},
|
||||
});
|
||||
@@ -67,12 +67,12 @@ export const EditContact: React.FC<{
|
||||
onSuccess: async () => {
|
||||
utils.contacts.contacts.invalidate();
|
||||
setOpen(false);
|
||||
toast.success("Contact updated successfully");
|
||||
toast.success('Contact updated successfully');
|
||||
},
|
||||
onError: async (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,11 +153,11 @@ export const EditContact: React.FC<{
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className=" w-[100px] "
|
||||
className="w-[100px]"
|
||||
type="submit"
|
||||
disabled={updateContactMutation.isPending}
|
||||
>
|
||||
{updateContactMutation.isPending ? "Updating..." : "Update"}
|
||||
{updateContactMutation.isPending ? 'Updating...' : 'Update'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { api } from '~/trpc/react';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -8,21 +8,21 @@ import {
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@usesend/ui/src/breadcrumb";
|
||||
import Link from "next/link";
|
||||
import AddContact from "./add-contact";
|
||||
import ContactList from "./contact-list";
|
||||
import { TextWithCopyButton } from "@usesend/ui/src/text-with-copy";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import EmojiPicker, { Theme } from "emoji-picker-react";
|
||||
} from '@usesend/ui/src/breadcrumb';
|
||||
import Link from 'next/link';
|
||||
import AddContact from './add-contact';
|
||||
import ContactList from './contact-list';
|
||||
import { TextWithCopyButton } from '@usesend/ui/src/text-with-copy';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import EmojiPicker, { Theme } from 'emoji-picker-react';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@usesend/ui/src/popover";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { useTheme } from "@usesend/ui";
|
||||
import { use } from "react";
|
||||
} from '@usesend/ui/src/popover';
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import { useTheme } from '@usesend/ui';
|
||||
import { use } from 'react';
|
||||
|
||||
export default function ContactsPage({
|
||||
params,
|
||||
@@ -63,7 +63,7 @@ export default function ContactsPage({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
@@ -83,7 +83,7 @@ export default function ContactsPage({
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-0 hover:bg-transparent text-lg"
|
||||
className="p-0 text-lg hover:bg-transparent"
|
||||
type="button"
|
||||
>
|
||||
{contactBookDetailQuery.data?.emoji}
|
||||
@@ -100,9 +100,9 @@ export default function ContactsPage({
|
||||
});
|
||||
}}
|
||||
theme={
|
||||
theme === "system"
|
||||
theme === 'system'
|
||||
? Theme.AUTO
|
||||
: theme === "dark"
|
||||
: theme === 'dark'
|
||||
? Theme.DARK
|
||||
: Theme.LIGHT
|
||||
}
|
||||
@@ -124,9 +124,9 @@ export default function ContactsPage({
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-16">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6 lg:gap-8">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3 lg:gap-8">
|
||||
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
|
||||
<p className="font-semibold mb-1">Metrics</p>
|
||||
<p className="mb-1 font-semibold">Metrics</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-muted-foreground w-[130px] text-sm">
|
||||
Total Contacts
|
||||
@@ -134,7 +134,7 @@ export default function ContactsPage({
|
||||
<div className="font-mono text-sm">
|
||||
{contactBookDetailQuery.data?.totalContacts !== undefined
|
||||
? contactBookDetailQuery.data?.totalContacts
|
||||
: "--"}
|
||||
: '--'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -144,7 +144,7 @@ export default function ContactsPage({
|
||||
<div className="font-mono text-sm">
|
||||
{contactBookDetailQuery.data?.unsubscribedContacts !== undefined
|
||||
? contactBookDetailQuery.data?.unsubscribedContacts
|
||||
: "--"}
|
||||
: '--'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,7 +157,7 @@ export default function ContactsPage({
|
||||
<TextWithCopyButton
|
||||
value={contactBookId}
|
||||
alwaysShowCopy
|
||||
className="text-sm w-[130px] overflow-hidden text-ellipsis font-mono"
|
||||
className="w-[130px] overflow-hidden text-ellipsis font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -169,7 +169,7 @@ export default function ContactsPage({
|
||||
? formatDistanceToNow(contactBookDetailQuery.data.createdAt, {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "--"}
|
||||
: '--'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,7 +184,7 @@ export default function ContactsPage({
|
||||
{contactBookDetailQuery.data?.campaigns.map((campaign) => (
|
||||
<div key={campaign.id} className="flex items-center gap-2">
|
||||
<Link href={`/campaigns/${campaign.id}`}>
|
||||
<div className="text-sm hover:underline hover:decoration-dashed text-nowrap w-[200px] overflow-hidden text-ellipsis">
|
||||
<div className="w-[200px] overflow-hidden text-ellipsis text-nowrap text-sm hover:underline hover:decoration-dashed">
|
||||
{campaign.name}
|
||||
</div>
|
||||
</Link>
|
||||
|
@@ -1,22 +1,22 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import { Input } from '@usesend/ui/src/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { api } from '~/trpc/react';
|
||||
import { useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import { z } from 'zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -25,13 +25,13 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@usesend/ui/src/form";
|
||||
import { useUpgradeModalStore } from "~/store/upgradeModalStore";
|
||||
import { LimitReason } from "~/lib/constants/plans";
|
||||
} from '@usesend/ui/src/form';
|
||||
import { useUpgradeModalStore } from '~/store/upgradeModalStore';
|
||||
import { LimitReason } from '~/lib/constants/plans';
|
||||
|
||||
const contactBookSchema = z.object({
|
||||
name: z.string({ required_error: "Name is required" }).min(1, {
|
||||
message: "Name is required",
|
||||
name: z.string({ required_error: 'Name is required' }).min(1, {
|
||||
message: 'Name is required',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function AddContactBook() {
|
||||
const contactBookForm = useForm<z.infer<typeof contactBookSchema>>({
|
||||
resolver: zodResolver(contactBookSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
name: '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function AddContactBook() {
|
||||
utils.contacts.getContactBooks.invalidate();
|
||||
contactBookForm.reset();
|
||||
setOpen(false);
|
||||
toast.success("Contact book created successfully");
|
||||
toast.success('Contact book created successfully');
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -91,7 +91,7 @@ export default function AddContactBook() {
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Add Contact Book
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@@ -127,15 +127,15 @@ export default function AddContactBook() {
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className=" w-[100px]"
|
||||
className="w-[100px]"
|
||||
type="submit"
|
||||
disabled={
|
||||
createContactBookMutation.isPending || limitsQuery.isLoading
|
||||
}
|
||||
>
|
||||
{createContactBookMutation.isPending
|
||||
? "Creating..."
|
||||
: "Create"}
|
||||
? 'Creating...'
|
||||
: 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
@@ -1,18 +1,18 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { api } from "~/trpc/react";
|
||||
import DeleteContactBook from "./delete-contact-book";
|
||||
import Link from "next/link";
|
||||
import EditContactBook from "./edit-contact-book";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { useUrlState } from "~/hooks/useUrlState";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { api } from '~/trpc/react';
|
||||
import DeleteContactBook from './delete-contact-book';
|
||||
import Link from 'next/link';
|
||||
import EditContactBook from './edit-contact-book';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useUrlState } from '~/hooks/useUrlState';
|
||||
import { Input } from '@usesend/ui/src/input';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
export default function ContactBooksList() {
|
||||
const [search, setSearch] = useUrlState("search");
|
||||
const [search, setSearch] = useUrlState('search');
|
||||
const contactBooksQuery = api.contacts.getContactBooks.useQuery({
|
||||
search: search ?? undefined,
|
||||
});
|
||||
@@ -27,40 +27,40 @@ export default function ContactBooksList() {
|
||||
<div className="mt-10">
|
||||
<Input
|
||||
placeholder="Search contact book"
|
||||
className="w-[300px] mr-4 mb-4"
|
||||
defaultValue={search ?? ""}
|
||||
className="mb-4 mr-4 w-[300px]"
|
||||
defaultValue={search ?? ''}
|
||||
onChange={(e) => debouncedSearch(e.target.value)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 ">
|
||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{contactBooksQuery.data?.map((contactBook) => (
|
||||
<motion.div
|
||||
key={contactBook.id}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 10 }}
|
||||
transition={{ type: 'spring', stiffness: 200, damping: 10 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
className="border rounded-xl shadow hover:shadow-lg"
|
||||
className="rounded-xl border shadow hover:shadow-lg"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<Link href={`/contacts/${contactBook.id}`} key={contactBook.id}>
|
||||
<div className="flex justify-between items-center p-4 mb-4">
|
||||
<div className="mb-4 flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div>{contactBook.emoji}</div>
|
||||
<div className="font-semibold truncate whitespace-nowrap overflow-ellipsis w-[180px]">
|
||||
<div className="w-[180px] truncate overflow-ellipsis whitespace-nowrap font-semibold">
|
||||
{contactBook.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="font-mono">
|
||||
{contactBook._count.contacts}
|
||||
</span>{" "}
|
||||
</span>{' '}
|
||||
contacts
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex justify-between items-center border-t bg-muted/50">
|
||||
<div className="bg-muted/50 flex items-center justify-between border-t">
|
||||
<div
|
||||
className="text-muted-foreground text-xs cursor-pointer w-full py-3 pl-4"
|
||||
className="text-muted-foreground w-full cursor-pointer py-3 pl-4 text-xs"
|
||||
onClick={() => router.push(`/contacts/${contactBook.id}`)}
|
||||
>
|
||||
{formatDistanceToNow(contactBook.createdAt, {
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import { Input } from '@usesend/ui/src/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -9,14 +9,14 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
import { api } from "~/trpc/react";
|
||||
import React, { useState } from "react";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
import { api } from '~/trpc/react';
|
||||
import React, { useState } from 'react';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -25,8 +25,8 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@usesend/ui/src/form";
|
||||
import { ContactBook } from "@prisma/client";
|
||||
} from '@usesend/ui/src/form';
|
||||
import { ContactBook } from '@prisma/client';
|
||||
|
||||
const contactBookSchema = z.object({
|
||||
name: z.string(),
|
||||
@@ -49,8 +49,8 @@ export const DeleteContactBook: React.FC<{
|
||||
values: z.infer<typeof contactBookSchema>,
|
||||
) {
|
||||
if (values.name !== contactBook.name) {
|
||||
contactBookForm.setError("name", {
|
||||
message: "Name does not match",
|
||||
contactBookForm.setError('name', {
|
||||
message: 'Name does not match',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export const DeleteContactBook: React.FC<{
|
||||
);
|
||||
}
|
||||
|
||||
const name = contactBookForm.watch("name");
|
||||
const name = contactBookForm.watch('name');
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -77,16 +77,16 @@ export const DeleteContactBook: React.FC<{
|
||||
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent ">
|
||||
<Trash2 className="h-[18px] w-[18px] text-red/80 hover:text-red/70" />
|
||||
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent">
|
||||
<Trash2 className="text-red/80 hover:text-red/70 h-[18px] w-[18px]" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Contact Book</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
Are you sure you want to delete{' '}
|
||||
<span className="text-foreground font-semibold">
|
||||
{contactBook.name}
|
||||
</span>
|
||||
? You can't reverse this.
|
||||
@@ -110,7 +110,7 @@ export const DeleteContactBook: React.FC<{
|
||||
{formState.errors.name ? (
|
||||
<FormMessage />
|
||||
) : (
|
||||
<FormDescription className=" text-transparent">
|
||||
<FormDescription className="text-transparent">
|
||||
.
|
||||
</FormDescription>
|
||||
)}
|
||||
@@ -127,8 +127,8 @@ export const DeleteContactBook: React.FC<{
|
||||
}
|
||||
>
|
||||
{deleteContactBookMutation.isPending
|
||||
? "Deleting..."
|
||||
: "Delete"}
|
||||
? 'Deleting...'
|
||||
: 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import { Input } from '@usesend/ui/src/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -16,17 +16,17 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@usesend/ui/src/form";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useState } from "react";
|
||||
import { Edit } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
} from '@usesend/ui/src/form';
|
||||
import { api } from '~/trpc/react';
|
||||
import { useState } from 'react';
|
||||
import { Edit } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
|
||||
const contactBookSchema = z.object({
|
||||
name: z.string().min(1, { message: "Name is required" }),
|
||||
name: z.string().min(1, { message: 'Name is required' }),
|
||||
});
|
||||
|
||||
export const EditContactBook: React.FC<{
|
||||
@@ -41,12 +41,12 @@ export const EditContactBook: React.FC<{
|
||||
const contactBookForm = useForm<z.infer<typeof contactBookSchema>>({
|
||||
resolver: zodResolver(contactBookSchema),
|
||||
defaultValues: {
|
||||
name: contactBook.name || "",
|
||||
name: contactBook.name || '',
|
||||
},
|
||||
});
|
||||
|
||||
async function onContactBookUpdate(
|
||||
values: z.infer<typeof contactBookSchema>
|
||||
values: z.infer<typeof contactBookSchema>,
|
||||
) {
|
||||
updateContactBookMutation.mutate(
|
||||
{
|
||||
@@ -57,12 +57,12 @@ export const EditContactBook: React.FC<{
|
||||
onSuccess: async () => {
|
||||
utils.contacts.getContactBooks.invalidate();
|
||||
setOpen(false);
|
||||
toast.success("Contact book updated successfully");
|
||||
toast.success('Contact book updated successfully');
|
||||
},
|
||||
onError: async (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export const EditContactBook: React.FC<{
|
||||
className="p-0 hover:bg-transparent"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Edit className="h-4 w-4 text-foreground/80 hover:text-foreground/70" />
|
||||
<Edit className="text-foreground/80 hover:text-foreground/70 h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
@@ -106,13 +106,13 @@ export const EditContactBook: React.FC<{
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className=" w-[100px]"
|
||||
className="w-[100px]"
|
||||
type="submit"
|
||||
disabled={updateContactBookMutation.isPending}
|
||||
>
|
||||
{updateContactBookMutation.isPending
|
||||
? "Updating..."
|
||||
: "Update"}
|
||||
? 'Updating...'
|
||||
: 'Update'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import AddContactBook from "./add-contact-book";
|
||||
import ContactBooksList from "./contact-books-list";
|
||||
import { H1 } from "@usesend/ui";
|
||||
import AddContactBook from './add-contact-book';
|
||||
import ContactBooksList from './contact-books-list';
|
||||
import { H1 } from '@usesend/ui';
|
||||
|
||||
export default function ContactsPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<H1>Contact books</H1>
|
||||
<AddContactBook />
|
||||
</div>
|
||||
|
@@ -1,22 +1,22 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { AppSidebar } from "~/components/AppSideBar";
|
||||
import { SidebarInset, SidebarTrigger } from "@usesend/ui/src/sidebar";
|
||||
import { SidebarProvider } from "@usesend/ui/src/sidebar";
|
||||
import { useIsMobile } from "@usesend/ui/src/hooks/use-mobile";
|
||||
import { UpgradeModal } from "~/components/payments/UpgradeModal";
|
||||
import { AppSidebar } from '~/components/AppSideBar';
|
||||
import { SidebarInset, SidebarTrigger } from '@usesend/ui/src/sidebar';
|
||||
import { SidebarProvider } from '@usesend/ui/src/sidebar';
|
||||
import { useIsMobile } from '@usesend/ui/src/hooks/use-mobile';
|
||||
import { UpgradeModal } from '~/components/payments/UpgradeModal';
|
||||
|
||||
export function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<div className="h-full bg-sidebar-background">
|
||||
<div className="bg-sidebar-background h-full">
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<main className="flex-1 overflow-auto h-full p-4 xl:px-40">
|
||||
<main className="h-full flex-1 overflow-auto p-4 xl:px-40">
|
||||
{isMobile ? (
|
||||
<SidebarTrigger className="h-5 w-5 text-muted-foreground" />
|
||||
<SidebarTrigger className="text-muted-foreground h-5 w-5" />
|
||||
) : null}
|
||||
{children}
|
||||
</main>
|
||||
|
@@ -1,13 +1,13 @@
|
||||
import React from "react";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@usesend/ui/src/tabs";
|
||||
import { useUrlState } from "~/hooks/useUrlState";
|
||||
import React from 'react';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@usesend/ui/src/tabs';
|
||||
import { useUrlState } from '~/hooks/useUrlState';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@usesend/ui/src/select";
|
||||
import { api } from "~/trpc/react";
|
||||
} from '@usesend/ui/src/select';
|
||||
import { api } from '~/trpc/react';
|
||||
|
||||
interface DashboardFiltersProps {
|
||||
days: string;
|
||||
@@ -25,14 +25,19 @@ 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)}>
|
||||
<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"}
|
||||
{domain
|
||||
? domainsQuery?.find((d) => d.id === Number(domain))?.name
|
||||
: 'All Domains'}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All Domains" className="capitalize">
|
||||
@@ -46,7 +51,7 @@ export default function DashboardFilters({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Tabs value={days || "7"} onValueChange={(value) => setDays(value)}>
|
||||
<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
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React from 'react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
@@ -10,13 +10,13 @@ import {
|
||||
CartesianGrid,
|
||||
AreaChart,
|
||||
Area,
|
||||
} from "recharts";
|
||||
import { EmailStatusIcon } from "../emails/email-status-badge";
|
||||
import { EmailStatus } from "@prisma/client";
|
||||
import { api } from "~/trpc/react";
|
||||
import Spinner from "@usesend/ui/src/spinner";
|
||||
import { useTheme } from "@usesend/ui";
|
||||
import { useColors } from "./hooks/useColors";
|
||||
} from 'recharts';
|
||||
import { EmailStatusIcon } from '../emails/email-status-badge';
|
||||
import { EmailStatus } from '@prisma/client';
|
||||
import { api } from '~/trpc/react';
|
||||
import Spinner from '@usesend/ui/src/spinner';
|
||||
import { useTheme } from '@usesend/ui';
|
||||
import { useColors } from './hooks/useColors';
|
||||
|
||||
interface EmailChartProps {
|
||||
days: number;
|
||||
@@ -24,11 +24,11 @@ interface EmailChartProps {
|
||||
}
|
||||
|
||||
const STACK_ORDER: string[] = [
|
||||
"delivered",
|
||||
"bounced",
|
||||
"complained",
|
||||
"opened",
|
||||
"clicked",
|
||||
'delivered',
|
||||
'bounced',
|
||||
'complained',
|
||||
'opened',
|
||||
'clicked',
|
||||
] as const;
|
||||
|
||||
type StackKey = (typeof STACK_ORDER)[number];
|
||||
@@ -66,13 +66,13 @@ export default function EmailChart({ days, domain }: EmailChartProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-16">
|
||||
{!statusQuery.isLoading && statusQuery.data ? (
|
||||
<div className="w-full h-[450px] border shadow rounded-xl p-4">
|
||||
<div className="p-2 overflow-x-auto">
|
||||
<div className="h-[450px] w-full rounded-xl border p-4 shadow">
|
||||
<div className="overflow-x-auto p-2">
|
||||
{/* <div className="mb-4 text-sm">Emails</div> */}
|
||||
|
||||
<div className="flex gap-10">
|
||||
<EmailChartItem
|
||||
status={"total"}
|
||||
status={'total'}
|
||||
count={statusQuery.data.totalCounts.sent}
|
||||
percentage={100}
|
||||
/>
|
||||
@@ -140,82 +140,82 @@ export default function EmailChart({ days, domain }: EmailChartProps) {
|
||||
<Tooltip
|
||||
content={({ payload }) => {
|
||||
const data = payload?.[0]?.payload as Record<
|
||||
| "sent"
|
||||
| "delivered"
|
||||
| "opened"
|
||||
| "clicked"
|
||||
| "bounced"
|
||||
| "complained",
|
||||
| 'sent'
|
||||
| 'delivered'
|
||||
| 'opened'
|
||||
| 'clicked'
|
||||
| 'bounced'
|
||||
| 'complained',
|
||||
number
|
||||
> & { date: string };
|
||||
|
||||
if (!data || data.sent === 0) return null;
|
||||
|
||||
return (
|
||||
<div className=" bg-background border shadow-lg p-2 rounded-xl flex flex-col gap-2 px-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<div className="bg-background flex flex-col gap-2 rounded-xl border p-2 px-4 shadow-lg">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{data.date}
|
||||
</p>
|
||||
{data.delivered ? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-[2px]"
|
||||
className="h-2.5 w-2.5 rounded-[2px]"
|
||||
style={{ backgroundColor: currentColors.delivered }}
|
||||
></div>
|
||||
<p className="text-xs text-muted-foreground w-[70px]">
|
||||
<p className="text-muted-foreground w-[70px] text-xs">
|
||||
Delivered
|
||||
</p>
|
||||
<p className="text-xs font-mono">{data.delivered}</p>
|
||||
<p className="font-mono text-xs">{data.delivered}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{data.bounced ? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-[2px]"
|
||||
className="h-2.5 w-2.5 rounded-[2px]"
|
||||
style={{ backgroundColor: currentColors.bounced }}
|
||||
></div>
|
||||
<p className="text-xs text-muted-foreground w-[70px]">
|
||||
<p className="text-muted-foreground w-[70px] text-xs">
|
||||
Bounced
|
||||
</p>
|
||||
<p className="text-xs font-mono">{data.bounced}</p>
|
||||
<p className="font-mono text-xs">{data.bounced}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{data.complained ? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-[2px]"
|
||||
className="h-2.5 w-2.5 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: currentColors.complained,
|
||||
}}
|
||||
></div>
|
||||
<p className="text-xs text-muted-foreground w-[70px]">
|
||||
<p className="text-muted-foreground w-[70px] text-xs">
|
||||
Complained
|
||||
</p>
|
||||
<p className="text-xs font-mono">{data.complained}</p>
|
||||
<p className="font-mono text-xs">{data.complained}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{data.opened ? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-[2px]"
|
||||
className="h-2.5 w-2.5 rounded-[2px]"
|
||||
style={{ backgroundColor: currentColors.opened }}
|
||||
></div>
|
||||
<p className="text-xs text-muted-foreground w-[70px]">
|
||||
<p className="text-muted-foreground w-[70px] text-xs">
|
||||
Opened
|
||||
</p>
|
||||
<p className="text-xs font-mono">{data.opened}</p>
|
||||
<p className="font-mono text-xs">{data.opened}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{data.clicked ? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-[2px]"
|
||||
className="h-2.5 w-2.5 rounded-[2px]"
|
||||
style={{ backgroundColor: currentColors.clicked }}
|
||||
></div>
|
||||
<p className="text-xs text-muted-foreground w-[70px]">
|
||||
<p className="text-muted-foreground w-[70px] text-xs">
|
||||
Clicked
|
||||
</p>
|
||||
<p className="text-xs font-mono">{data.clicked}</p>
|
||||
<p className="font-mono text-xs">{data.clicked}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -229,31 +229,31 @@ export default function EmailChart({ days, domain }: EmailChartProps) {
|
||||
dataKey="delivered"
|
||||
stackId="a"
|
||||
fill={currentColors.delivered}
|
||||
shape={createRoundedTopShape("delivered")}
|
||||
shape={createRoundedTopShape('delivered')}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="bounced"
|
||||
stackId="a"
|
||||
fill={currentColors.bounced}
|
||||
shape={createRoundedTopShape("bounced")}
|
||||
shape={createRoundedTopShape('bounced')}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="complained"
|
||||
stackId="a"
|
||||
fill={currentColors.complained}
|
||||
shape={createRoundedTopShape("complained")}
|
||||
shape={createRoundedTopShape('complained')}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="opened"
|
||||
stackId="a"
|
||||
fill={currentColors.opened}
|
||||
shape={createRoundedTopShape("opened")}
|
||||
shape={createRoundedTopShape('opened')}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="clicked"
|
||||
stackId="a"
|
||||
fill={currentColors.clicked}
|
||||
shape={createRoundedTopShape("clicked")}
|
||||
shape={createRoundedTopShape('clicked')}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -266,7 +266,7 @@ export default function EmailChart({ days, domain }: EmailChartProps) {
|
||||
}
|
||||
|
||||
type DashboardItemCardProps = {
|
||||
status: EmailStatus | "total";
|
||||
status: EmailStatus | 'total';
|
||||
count: number;
|
||||
percentage: number;
|
||||
};
|
||||
@@ -277,17 +277,17 @@ const DashboardItemCard: React.FC<DashboardItemCardProps> = ({
|
||||
percentage,
|
||||
}) => {
|
||||
return (
|
||||
<div className="h-[100px] w-[16%] min-w-[170px] bg-secondary/10 border shadow rounded-xl p-4 flex flex-col gap-3">
|
||||
<div className="bg-secondary/10 flex h-[100px] w-[16%] min-w-[170px] flex-col gap-3 rounded-xl border p-4 shadow">
|
||||
<div className="flex items-center gap-3">
|
||||
{status !== "total" ? <EmailStatusIcon status={status} /> : null}
|
||||
<div className=" capitalize">{status.toLowerCase()}</div>
|
||||
{status !== 'total' ? <EmailStatusIcon status={status} /> : null}
|
||||
<div className="capitalize">{status.toLowerCase()}</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-end">
|
||||
<div className="text-foreground font-light text-2xl font-mono">
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="text-foreground font-mono text-2xl font-light">
|
||||
{count}
|
||||
</div>
|
||||
{status !== "total" ? (
|
||||
<div className="text-sm pb-1">
|
||||
{status !== 'total' ? (
|
||||
<div className="pb-1 text-sm">
|
||||
{count > 0 ? (percentage * 100).toFixed(0) : 0}%
|
||||
</div>
|
||||
) : null}
|
||||
@@ -303,41 +303,41 @@ const EmailChartItem: React.FC<DashboardItemCardProps> = ({
|
||||
}) => {
|
||||
const currentColors = useColors();
|
||||
|
||||
const getColorForStatus = (status: EmailStatus | "total"): string => {
|
||||
const getColorForStatus = (status: EmailStatus | 'total'): string => {
|
||||
switch (status) {
|
||||
case "DELIVERED":
|
||||
case 'DELIVERED':
|
||||
return currentColors.delivered;
|
||||
case "BOUNCED":
|
||||
case 'BOUNCED':
|
||||
return currentColors.bounced;
|
||||
case "COMPLAINED":
|
||||
case 'COMPLAINED':
|
||||
return currentColors.complained;
|
||||
case "OPENED":
|
||||
case 'OPENED':
|
||||
return currentColors.opened;
|
||||
case "CLICKED":
|
||||
case 'CLICKED':
|
||||
return currentColors.clicked;
|
||||
case "total":
|
||||
case 'total':
|
||||
default:
|
||||
return "#6b7280"; // gray-500 for total and other statuses
|
||||
return '#6b7280'; // gray-500 for total and other statuses
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 items-stretch font-mono">
|
||||
<div className="flex items-stretch gap-3 font-mono">
|
||||
<div>
|
||||
<div className=" flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-[3px]"
|
||||
className="h-2.5 w-2.5 rounded-[3px]"
|
||||
style={{ backgroundColor: getColorForStatus(status) }}
|
||||
></div>
|
||||
|
||||
<div className="text-xs uppercase text-muted-foreground ">
|
||||
<div className="text-muted-foreground text-xs uppercase">
|
||||
{status.toLowerCase()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 -ml-0.5 ">
|
||||
<span className="text-xl font-mono">{count}</span>
|
||||
<span className="text-xs ml-2 font-mono">
|
||||
{status !== "total"
|
||||
<div className="-ml-0.5 mt-1">
|
||||
<span className="font-mono text-xl">{count}</span>
|
||||
<span className="ml-2 font-mono text-xs">
|
||||
{status !== 'total'
|
||||
? `(${count > 0 ? (percentage * 100).toFixed(0) : 0}%)`
|
||||
: null}
|
||||
</span>
|
||||
|
@@ -1,27 +1,27 @@
|
||||
import { useTheme } from "@usesend/ui";
|
||||
import { useTheme } from '@usesend/ui';
|
||||
|
||||
export function useColors() {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const lightColors = {
|
||||
delivered: "#40a02b",
|
||||
bounced: "#d20f39",
|
||||
complained: "#df8e1d",
|
||||
opened: "#8839ef",
|
||||
clicked: "#04a5e5",
|
||||
xaxis: "#6D6F84",
|
||||
delivered: '#40a02b',
|
||||
bounced: '#d20f39',
|
||||
complained: '#df8e1d',
|
||||
opened: '#8839ef',
|
||||
clicked: '#04a5e5',
|
||||
xaxis: '#6D6F84',
|
||||
};
|
||||
|
||||
const darkColors = {
|
||||
delivered: "#a6e3a1",
|
||||
bounced: "#f38ba8",
|
||||
complained: "#F9E2AF",
|
||||
opened: "#cba6f7",
|
||||
clicked: "#93c5fd",
|
||||
xaxis: "#AAB1CD",
|
||||
delivered: '#a6e3a1',
|
||||
bounced: '#f38ba8',
|
||||
complained: '#F9E2AF',
|
||||
opened: '#cba6f7',
|
||||
clicked: '#93c5fd',
|
||||
xaxis: '#AAB1CD',
|
||||
};
|
||||
|
||||
const currentColors = resolvedTheme === "dark" ? darkColors : lightColors;
|
||||
const currentColors = resolvedTheme === 'dark' ? darkColors : lightColors;
|
||||
|
||||
return currentColors;
|
||||
}
|
||||
|
@@ -1,31 +1,31 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import EmailChart from "./email-chart";
|
||||
import DashboardFilters from "./dashboard-filters";
|
||||
import { H1 } from "@usesend/ui";
|
||||
import { useUrlState } from "~/hooks/useUrlState";
|
||||
import { ReputationMetrics } from "./reputation-metrics";
|
||||
import EmailChart from './email-chart';
|
||||
import DashboardFilters from './dashboard-filters';
|
||||
import { H1 } from '@usesend/ui';
|
||||
import { useUrlState } from '~/hooks/useUrlState';
|
||||
import { ReputationMetrics } from './reputation-metrics';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [days, setDays] = useUrlState("days", "7");
|
||||
const [domain, setDomain] = useUrlState("domain");
|
||||
const [days, setDays] = useUrlState('days', '7');
|
||||
const [domain, setDomain] = useUrlState('domain');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<div className="mb-10 flex items-center justify-between">
|
||||
<H1>Analytics</H1>
|
||||
<DashboardFilters
|
||||
days={days ?? "7"}
|
||||
days={days ?? '7'}
|
||||
setDays={setDays}
|
||||
domain={domain}
|
||||
setDomain={setDomain}
|
||||
/>
|
||||
</div>
|
||||
<div className=" space-y-12">
|
||||
<EmailChart days={Number(days ?? "7")} domain={domain} />
|
||||
<div className="space-y-12">
|
||||
<EmailChart days={Number(days ?? '7')} domain={domain} />
|
||||
|
||||
<ReputationMetrics days={Number(days ?? "7")} domain={domain} />
|
||||
<ReputationMetrics days={Number(days ?? '7')} domain={domain} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -3,14 +3,14 @@ import {
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@usesend/ui/src/tooltip";
|
||||
} from '@usesend/ui/src/tooltip';
|
||||
import {
|
||||
CheckCircle2,
|
||||
CheckCircle2Icon,
|
||||
InfoIcon,
|
||||
OctagonAlertIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react";
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
@@ -19,15 +19,15 @@ import {
|
||||
Tooltip as RechartsTooltip,
|
||||
CartesianGrid,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
} from 'recharts';
|
||||
import {
|
||||
HARD_BOUNCE_RISK_RATE,
|
||||
HARD_BOUNCE_WARNING_RATE,
|
||||
COMPLAINED_WARNING_RATE,
|
||||
COMPLAINED_RISK_RATE,
|
||||
} from "~/lib/constants";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useColors } from "./hooks/useColors";
|
||||
} from '~/lib/constants';
|
||||
import { api } from '~/trpc/react';
|
||||
import { useColors } from './hooks/useColors';
|
||||
|
||||
interface ReputationMetricsProps {
|
||||
days: number;
|
||||
@@ -35,9 +35,9 @@ interface ReputationMetricsProps {
|
||||
}
|
||||
|
||||
enum ACCOUNT_STATUS {
|
||||
HEALTHY = "HEALTHY",
|
||||
WARNING = "WARNING",
|
||||
RISK = "RISK",
|
||||
HEALTHY = 'HEALTHY',
|
||||
WARNING = 'WARNING',
|
||||
RISK = 'RISK',
|
||||
}
|
||||
|
||||
const CustomLabel = ({ value, stroke }: { value: string; stroke: string }) => {
|
||||
@@ -59,7 +59,7 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
|
||||
const bouncedMetric = metrics
|
||||
? [
|
||||
{
|
||||
name: "Bounce Rate",
|
||||
name: 'Bounce Rate',
|
||||
value: metrics.bounceRate,
|
||||
},
|
||||
]
|
||||
@@ -68,7 +68,7 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
|
||||
const complaintMetric = metrics
|
||||
? [
|
||||
{
|
||||
name: "Complaint Rate",
|
||||
name: 'Complaint Rate',
|
||||
value: metrics.complaintRate,
|
||||
},
|
||||
]
|
||||
@@ -90,14 +90,14 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-col sm:flex-row gap-10 w-full">
|
||||
<div className="w-full sm:w-1/2 border rounded-xl shadow p-4">
|
||||
<div className="flex w-full flex-col gap-10 sm:flex-row">
|
||||
<div className="w-full rounded-xl border p-4 shadow sm:w-1/2">
|
||||
<div className="flex justify-between">
|
||||
<div className=" flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-muted-foreground font-mono">Bounce Rate</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className=" h-3.5 w-3.5 text-muted-foreground" />
|
||||
<InfoIcon className="text-muted-foreground h-3.5 w-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="w-[300px]">
|
||||
The percentage of emails sent from your account that resulted
|
||||
@@ -108,7 +108,7 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
|
||||
<div></div>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-4">
|
||||
<div className="text-2xl mt-2 font-mono">
|
||||
<div className="mt-2 font-mono text-2xl">
|
||||
{metrics?.bounceRate.toFixed(2)}%
|
||||
</div>
|
||||
<StatusBadge status={bounceStatus} />
|
||||
@@ -147,8 +147,8 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
|
||||
y={HARD_BOUNCE_WARNING_RATE}
|
||||
stroke={`${colors.complained}A0`}
|
||||
label={{
|
||||
value: "",
|
||||
position: "insideBottomLeft",
|
||||
value: '',
|
||||
position: 'insideBottomLeft',
|
||||
fill: colors.complained,
|
||||
fontSize: 12,
|
||||
}}
|
||||
@@ -169,7 +169,7 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
|
||||
stroke={`${colors.bounced}A0`}
|
||||
label={{
|
||||
value: ``,
|
||||
position: "insideBottomLeft",
|
||||
position: 'insideBottomLeft',
|
||||
fill: colors.bounced,
|
||||
fontSize: 12,
|
||||
}}
|
||||
@@ -185,43 +185,43 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-background border shadow-lg p-2 rounded-xl flex flex-col gap-2 px-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<div className="bg-background flex flex-col gap-2 rounded-xl border p-2 px-4 shadow-lg">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{data.name}
|
||||
</p>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-[2px]"
|
||||
className="h-2.5 w-2.5 rounded-[2px]"
|
||||
style={{ background: colors.clicked }}
|
||||
></div>
|
||||
<p className="text-xs text-muted-foreground w-[70px]">
|
||||
<p className="text-muted-foreground w-[70px] text-xs">
|
||||
Current
|
||||
</p>
|
||||
<p className="text-xs font-mono">
|
||||
<p className="font-mono text-xs">
|
||||
{data.value.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-[2px]"
|
||||
className="h-2.5 w-2.5 rounded-[2px]"
|
||||
style={{ background: colors.complained }}
|
||||
></div>
|
||||
<p className="text-xs text-muted-foreground w-[70px]">
|
||||
<p className="text-muted-foreground w-[70px] text-xs">
|
||||
Warning at
|
||||
</p>
|
||||
<p className="text-xs font-mono">
|
||||
<p className="font-mono text-xs">
|
||||
{HARD_BOUNCE_WARNING_RATE}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-[2px]"
|
||||
className="h-2.5 w-2.5 rounded-[2px]"
|
||||
style={{ background: colors.bounced }}
|
||||
></div>
|
||||
<p className="text-xs text-muted-foreground w-[70px]">
|
||||
<p className="text-muted-foreground w-[70px] text-xs">
|
||||
Risk at
|
||||
</p>
|
||||
<p className="text-xs font-mono">
|
||||
<p className="font-mono text-xs">
|
||||
{HARD_BOUNCE_RISK_RATE}%
|
||||
</p>
|
||||
</div>
|
||||
@@ -240,14 +240,14 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="w-full sm:w-1/2 border rounded-xl shadow p-4">
|
||||
<div className=" flex items-center gap-2">
|
||||
<div className=" text-muted-foreground font-mono">
|
||||
<div className="w-full rounded-xl border p-4 shadow sm:w-1/2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-muted-foreground font-mono">
|
||||
Complaint Rate
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className=" h-3.5 w-3.5 text-muted-foreground" />
|
||||
<InfoIcon className="text-muted-foreground h-3.5 w-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="w-[300px]">
|
||||
The percentage of emails sent from your account that resulted in
|
||||
@@ -256,7 +256,7 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-4">
|
||||
<div className="text-2xl mt-2 font-mono">
|
||||
<div className="mt-2 font-mono text-2xl">
|
||||
{metrics?.complaintRate.toFixed(2)}%
|
||||
</div>
|
||||
<StatusBadge status={complaintStatus} />
|
||||
@@ -289,8 +289,8 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
|
||||
y={COMPLAINED_WARNING_RATE}
|
||||
stroke={`${colors.complained}A0`}
|
||||
label={{
|
||||
value: "",
|
||||
position: "insideBottomLeft",
|
||||
value: '',
|
||||
position: 'insideBottomLeft',
|
||||
fill: colors.complained,
|
||||
fontSize: 12,
|
||||
}}
|
||||
@@ -308,7 +308,7 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
|
||||
stroke={`${colors.bounced}A0`}
|
||||
label={{
|
||||
value: ``,
|
||||
position: "insideBottomLeft",
|
||||
position: 'insideBottomLeft',
|
||||
fill: colors.bounced,
|
||||
fontSize: 12,
|
||||
}}
|
||||
@@ -324,43 +324,43 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-background border shadow-lg p-2 rounded-xl flex flex-col gap-2 px-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<div className="bg-background flex flex-col gap-2 rounded-xl border p-2 px-4 shadow-lg">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{data.name}
|
||||
</p>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-[2px]"
|
||||
className="h-2.5 w-2.5 rounded-[2px]"
|
||||
style={{ background: colors.clicked }}
|
||||
></div>
|
||||
<p className="text-xs text-muted-foreground w-[70px]">
|
||||
<p className="text-muted-foreground w-[70px] text-xs">
|
||||
Current
|
||||
</p>
|
||||
<p className="text-xs font-mono">
|
||||
<p className="font-mono text-xs">
|
||||
{data.value.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-[2px]"
|
||||
className="h-2.5 w-2.5 rounded-[2px]"
|
||||
style={{ background: colors.complained }}
|
||||
></div>
|
||||
<p className="text-xs text-muted-foreground w-[70px]">
|
||||
<p className="text-muted-foreground w-[70px] text-xs">
|
||||
Warning at
|
||||
</p>
|
||||
<p className="text-xs font-mono">
|
||||
<p className="font-mono text-xs">
|
||||
{COMPLAINED_WARNING_RATE}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-[2px]"
|
||||
className="h-2.5 w-2.5 rounded-[2px]"
|
||||
style={{ background: colors.bounced }}
|
||||
></div>
|
||||
<p className="text-xs text-muted-foreground w-[70px]">
|
||||
<p className="text-muted-foreground w-[70px] text-xs">
|
||||
Risk at
|
||||
</p>
|
||||
<p className="text-xs font-mono">
|
||||
<p className="font-mono text-xs">
|
||||
{COMPLAINED_RISK_RATE}%
|
||||
</p>
|
||||
</div>
|
||||
@@ -388,22 +388,22 @@ export const StatusBadge: React.FC<{ status: ACCOUNT_STATUS }> = ({
|
||||
status,
|
||||
}) => {
|
||||
const className =
|
||||
status === "HEALTHY"
|
||||
? " text-success border-success"
|
||||
: status === "WARNING"
|
||||
? " text-warning border-warning"
|
||||
: " text-destructive border-destructive";
|
||||
status === 'HEALTHY'
|
||||
? ' text-success border-success'
|
||||
: status === 'WARNING'
|
||||
? ' text-warning border-warning'
|
||||
: ' text-destructive border-destructive';
|
||||
|
||||
const StatusIcon =
|
||||
status === "HEALTHY"
|
||||
status === 'HEALTHY'
|
||||
? CheckCircle2Icon
|
||||
: status === "WARNING"
|
||||
: status === 'WARNING'
|
||||
? TriangleAlertIcon
|
||||
: OctagonAlertIcon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={` capitalize text-xs ${className} flex gap-1 items-center rounded-lg`}
|
||||
className={`text-xs capitalize ${className} flex items-center gap-1 rounded-lg`}
|
||||
>
|
||||
<StatusIcon className="h-3.5 w-3.5" />
|
||||
{status.toLowerCase()}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import { Input } from '@usesend/ui/src/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -9,15 +9,15 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { useState } from "react";
|
||||
import { CheckIcon, ClipboardCopy, Eye, EyeOff, Plus } from "lucide-react";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { api } from '~/trpc/react';
|
||||
import { useState } from 'react';
|
||||
import { CheckIcon, ClipboardCopy, Eye, EyeOff, Plus } from 'lucide-react';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import { z } from 'zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -26,25 +26,25 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@usesend/ui/src/form";
|
||||
} from '@usesend/ui/src/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@usesend/ui/src/select";
|
||||
} from '@usesend/ui/src/select';
|
||||
|
||||
const apiKeySchema = z.object({
|
||||
name: z.string({ required_error: "Name is required" }).min(1, {
|
||||
message: "Name is required",
|
||||
name: z.string({ required_error: 'Name is required' }).min(1, {
|
||||
message: 'Name is required',
|
||||
}),
|
||||
domainId: z.string().optional(),
|
||||
});
|
||||
|
||||
export default function AddApiKey() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const createApiKeyMutation = api.apiKey.createToken.useMutation();
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
@@ -56,8 +56,8 @@ export default function AddApiKey() {
|
||||
const apiKeyForm = useForm<z.infer<typeof apiKeySchema>>({
|
||||
resolver: zodResolver(apiKeySchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
domainId: "all",
|
||||
name: '',
|
||||
domainId: 'all',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -65,9 +65,9 @@ export default function AddApiKey() {
|
||||
createApiKeyMutation.mutate(
|
||||
{
|
||||
name: values.name,
|
||||
permission: "FULL",
|
||||
permission: 'FULL',
|
||||
domainId:
|
||||
values.domainId === "all" ? undefined : Number(values.domainId),
|
||||
values.domainId === 'all' ? undefined : Number(values.domainId),
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
@@ -75,7 +75,7 @@ export default function AddApiKey() {
|
||||
setApiKey(data);
|
||||
apiKeyForm.reset();
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,10 +89,10 @@ export default function AddApiKey() {
|
||||
|
||||
function copyAndClose() {
|
||||
handleCopy();
|
||||
setApiKey("");
|
||||
setApiKey('');
|
||||
setOpen(false);
|
||||
setShowApiKey(false);
|
||||
toast.success("API key copied to clipboard");
|
||||
toast.success('API key copied to clipboard');
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -102,7 +102,7 @@ export default function AddApiKey() {
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Add API Key
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@@ -111,7 +111,7 @@ export default function AddApiKey() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Copy API key</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-1 bg-secondary rounded-lg px-4 flex items-center justify-between mt-2">
|
||||
<div className="bg-secondary mt-2 flex items-center justify-between rounded-lg px-4 py-1">
|
||||
<div>
|
||||
{showApiKey ? (
|
||||
<p className="text-sm">{apiKey}</p>
|
||||
@@ -120,7 +120,7 @@ export default function AddApiKey() {
|
||||
{Array.from({ length: 40 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="w-1 h-1 bg-muted-foreground rounded-lg"
|
||||
className="bg-muted-foreground h-1 w-1 rounded-lg"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -129,7 +129,7 @@ export default function AddApiKey() {
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="hover:bg-transparent p-0 cursor-pointer group-hover:opacity-100"
|
||||
className="cursor-pointer p-0 hover:bg-transparent group-hover:opacity-100"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
>
|
||||
{showApiKey ? (
|
||||
@@ -141,11 +141,11 @@ export default function AddApiKey() {
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="hover:bg-transparent p-0 cursor-pointer group-hover:opacity-100"
|
||||
className="cursor-pointer p-0 hover:bg-transparent group-hover:opacity-100"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{isCopied ? (
|
||||
<CheckIcon className="h-4 w-4 text-green" />
|
||||
<CheckIcon className="text-green h-4 w-4" />
|
||||
) : (
|
||||
<ClipboardCopy className="h-4 w-4" />
|
||||
)}
|
||||
@@ -218,7 +218,7 @@ export default function AddApiKey() {
|
||||
>
|
||||
{domain.name}
|
||||
</SelectItem>
|
||||
)
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -230,11 +230,11 @@ export default function AddApiKey() {
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className=" w-[100px] hover:bg-gray-100 focus:bg-gray-100"
|
||||
className="w-[100px] hover:bg-gray-100 focus:bg-gray-100"
|
||||
type="submit"
|
||||
disabled={createApiKeyMutation.isPending}
|
||||
>
|
||||
{createApiKeyMutation.isPending ? "Creating..." : "Create"}
|
||||
{createApiKeyMutation.isPending ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Table,
|
||||
@@ -7,21 +7,21 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@usesend/ui/src/table";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { api } from "~/trpc/react";
|
||||
import DeleteApiKey from "./delete-api-key";
|
||||
import Spinner from "@usesend/ui/src/spinner";
|
||||
} from '@usesend/ui/src/table';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { api } from '~/trpc/react';
|
||||
import DeleteApiKey from './delete-api-key';
|
||||
import Spinner from '@usesend/ui/src/spinner';
|
||||
|
||||
export default function ApiList() {
|
||||
const apiKeysQuery = api.apiKey.getApiKeys.useQuery();
|
||||
|
||||
return (
|
||||
<div className="mt-10">
|
||||
<div className="border rounded-xl shadow">
|
||||
<div className="rounded-xl border shadow">
|
||||
<Table className="">
|
||||
<TableHeader className="">
|
||||
<TableRow className=" bg-muted/30">
|
||||
<TableRow className="bg-muted/30">
|
||||
<TableHead className="rounded-tl-xl">Name</TableHead>
|
||||
<TableHead>Token</TableHead>
|
||||
<TableHead>Permission</TableHead>
|
||||
@@ -34,16 +34,16 @@ export default function ApiList() {
|
||||
<TableBody>
|
||||
{apiKeysQuery.isLoading ? (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={7} className="text-center py-4">
|
||||
<TableCell colSpan={7} className="py-4 text-center">
|
||||
<Spinner
|
||||
className="w-6 h-6 mx-auto"
|
||||
className="mx-auto h-6 w-6"
|
||||
innerSvgClass="stroke-primary"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : apiKeysQuery.data?.length === 0 ? (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={7} className="text-center py-4">
|
||||
<TableCell colSpan={7} className="py-4 text-center">
|
||||
<p>No API keys added</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -55,13 +55,15 @@ export default function ApiList() {
|
||||
<TableCell>{apiKey.permission}</TableCell>
|
||||
<TableCell>
|
||||
{apiKey.domainId
|
||||
? apiKey.domain?.name ?? "Domain removed"
|
||||
: "All domains"}
|
||||
? (apiKey.domain?.name ?? 'Domain removed')
|
||||
: 'All domains'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{apiKey.lastUsed
|
||||
? formatDistanceToNow(apiKey.lastUsed, { addSuffix: true })
|
||||
: "Never"}
|
||||
? formatDistanceToNow(apiKey.lastUsed, {
|
||||
addSuffix: true,
|
||||
})
|
||||
: 'Never'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatDistanceToNow(apiKey.createdAt, { addSuffix: true })}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import { Input } from '@usesend/ui/src/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -9,15 +9,15 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
import { api } from "~/trpc/react";
|
||||
import React, { useState } from "react";
|
||||
import { ApiKey } from "@prisma/client";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
import { api } from '~/trpc/react';
|
||||
import React, { useState } from 'react';
|
||||
import { ApiKey } from '@prisma/client';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@usesend/ui/src/form";
|
||||
} from '@usesend/ui/src/form';
|
||||
|
||||
const apiKeySchema = z.object({
|
||||
name: z.string(),
|
||||
@@ -46,8 +46,8 @@ export const DeleteApiKey: React.FC<{
|
||||
|
||||
async function onDomainDelete(values: z.infer<typeof apiKeySchema>) {
|
||||
if (values.name !== apiKey.name) {
|
||||
apiKeyForm.setError("name", {
|
||||
message: "Name does not match",
|
||||
apiKeyForm.setError('name', {
|
||||
message: 'Name does not match',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export const DeleteApiKey: React.FC<{
|
||||
);
|
||||
}
|
||||
|
||||
const name = apiKeyForm.watch("name");
|
||||
const name = apiKeyForm.watch('name');
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -75,15 +75,15 @@ export const DeleteApiKey: React.FC<{
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Trash2 className="h-4 w-4 text-red/80" />
|
||||
<Trash2 className="text-red/80 h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete API key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-foreground">{apiKey.name}</span>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className="text-foreground font-semibold">{apiKey.name}</span>
|
||||
? You can't reverse this.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -105,7 +105,7 @@ export const DeleteApiKey: React.FC<{
|
||||
{formState.errors.name ? (
|
||||
<FormMessage />
|
||||
) : (
|
||||
<FormDescription className=" text-transparent">
|
||||
<FormDescription className="text-transparent">
|
||||
.
|
||||
</FormDescription>
|
||||
)}
|
||||
@@ -120,7 +120,7 @@ export const DeleteApiKey: React.FC<{
|
||||
deleteApiKeyMutation.isPending || apiKey.name !== name
|
||||
}
|
||||
>
|
||||
{deleteApiKeyMutation.isPending ? "Deleting..." : "Delete"}
|
||||
{deleteApiKeyMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import AddApiKey from "./add-api-key";
|
||||
import ApiList from "./api-list";
|
||||
import { H1 } from "@usesend/ui";
|
||||
import AddApiKey from './add-api-key';
|
||||
import ApiList from './api-list';
|
||||
import { H1 } from '@usesend/ui';
|
||||
|
||||
export default function ApiKeysPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<H1>API Keys</H1>
|
||||
<AddApiKey />
|
||||
</div>
|
||||
|
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { SettingsNavButton } from "./settings-nav-button";
|
||||
import { SettingsNavButton } from './settings-nav-button';
|
||||
|
||||
export const dynamic = "force-static";
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export default function ApiKeysPage({
|
||||
children,
|
||||
@@ -11,8 +11,8 @@ export default function ApiKeysPage({
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="font-bold text-lg">Developer settings</h1>
|
||||
<div className="flex gap-4 mt-4">
|
||||
<h1 className="text-lg font-bold">Developer settings</h1>
|
||||
<div className="mt-4 flex gap-4">
|
||||
<SettingsNavButton href="/dev-settings">API Keys</SettingsNavButton>
|
||||
<SettingsNavButton href="/dev-settings/smtp">SMTP</SettingsNavButton>
|
||||
</div>
|
||||
|
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import AddApiKey from "./api-keys/add-api-key";
|
||||
import ApiList from "./api-keys/api-list";
|
||||
import { H1 } from "@usesend/ui";
|
||||
import AddApiKey from './api-keys/add-api-key';
|
||||
import ApiList from './api-keys/api-list';
|
||||
import { H1 } from '@usesend/ui';
|
||||
|
||||
export default function ApiKeysPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<H1>API Keys</H1>
|
||||
<AddApiKey />
|
||||
</div>
|
||||
|
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React from "react";
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import React from 'react';
|
||||
|
||||
export const SettingsNavButton: React.FC<{
|
||||
href: string;
|
||||
@@ -15,13 +15,13 @@ export const SettingsNavButton: React.FC<{
|
||||
|
||||
if (comingSoon) {
|
||||
return (
|
||||
<div className="flex items-center justify-between hover:text-foreground cursor-not-allowed mt-1">
|
||||
<div className="hover:text-foreground mt-1 flex cursor-not-allowed items-center justify-between">
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-lg px-3 py-2 transition-all hover:text-foreground cursor-not-allowed ${isActive ? " bg-secondary" : "text-muted-foreground"}`}
|
||||
className={`hover:text-foreground flex cursor-not-allowed items-center gap-3 rounded-lg px-3 py-2 transition-all ${isActive ? 'bg-secondary' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div className="text-muted-foreground px-4 py-0.5 text-xs bg-muted rounded-full">
|
||||
<div className="text-muted-foreground bg-muted rounded-full px-4 py-0.5 text-xs">
|
||||
soon
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,7 +31,7 @@ export const SettingsNavButton: React.FC<{
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`flex text-sm items-center mt-1 gap-3 rounded px-2 py-1 transition-all hover:text-foreground ${isActive ? " bg-accent" : "text-muted-foreground"}`}
|
||||
className={`hover:text-foreground mt-1 flex items-center gap-3 rounded px-2 py-1 text-sm transition-all ${isActive ? 'bg-accent' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
|
@@ -1,15 +1,15 @@
|
||||
import * as React from "react";
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@usesend/ui/src/card";
|
||||
import { TextWithCopyButton } from "@usesend/ui/src/text-with-copy";
|
||||
import { env } from "~/env";
|
||||
} from '@usesend/ui/src/card';
|
||||
import { TextWithCopyButton } from '@usesend/ui/src/text-with-copy';
|
||||
import { env } from '~/env';
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function ExampleCard() {
|
||||
const host = env.SMTP_HOST;
|
||||
@@ -29,35 +29,35 @@ export default function ExampleCard() {
|
||||
<div>
|
||||
<strong>Host:</strong>
|
||||
<TextWithCopyButton
|
||||
className="ml-1 border bg-primary/10 rounded-lg mt-1 p-2 w-full "
|
||||
className="bg-primary/10 ml-1 mt-1 w-full rounded-lg border p-2"
|
||||
value={host}
|
||||
></TextWithCopyButton>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Port:</strong>
|
||||
<TextWithCopyButton
|
||||
className="ml-1 rounded-lg mt-1 p-2 w-full bg-primary/10 font-mono"
|
||||
value={"465"}
|
||||
className="bg-primary/10 ml-1 mt-1 w-full rounded-lg p-2 font-mono"
|
||||
value={'465'}
|
||||
></TextWithCopyButton>
|
||||
<p className="ml-1 mt-1 text-zinc-500 text-sm ">
|
||||
For encrypted/TLS connections use{" "}
|
||||
<strong className="font-mono">2465</strong>,{" "}
|
||||
<strong className="font-mono">587</strong> or{" "}
|
||||
<p className="ml-1 mt-1 text-sm text-zinc-500">
|
||||
For encrypted/TLS connections use{' '}
|
||||
<strong className="font-mono">2465</strong>,{' '}
|
||||
<strong className="font-mono">587</strong> or{' '}
|
||||
<strong className="font-mono">2587</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>User:</strong>
|
||||
<TextWithCopyButton
|
||||
className="ml-1 rounded-lg mt-1 p-2 w-full bg-primary/10"
|
||||
className="bg-primary/10 ml-1 mt-1 w-full rounded-lg p-2"
|
||||
value={user}
|
||||
></TextWithCopyButton>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Password:</strong>
|
||||
<TextWithCopyButton
|
||||
className="ml-1 rounded-lg mt-1 p-2 w-full bg-primary/10"
|
||||
value={"YOUR_API_KEY"}
|
||||
className="bg-primary/10 ml-1 mt-1 w-full rounded-lg p-2"
|
||||
value={'YOUR_API_KEY'}
|
||||
></TextWithCopyButton>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import { Input } from '@usesend/ui/src/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
|
||||
import {
|
||||
Form,
|
||||
@@ -19,16 +19,16 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@usesend/ui/src/form";
|
||||
} from '@usesend/ui/src/form';
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import React, { useState } from "react";
|
||||
import { Domain } from "@prisma/client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { api } from '~/trpc/react';
|
||||
import React, { useState } from 'react';
|
||||
import { Domain } from '@prisma/client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import { z } from 'zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
|
||||
const domainSchema = z.object({
|
||||
domain: z.string(),
|
||||
@@ -36,7 +36,7 @@ const domainSchema = z.object({
|
||||
|
||||
export const DeleteDomain: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [domainName, setDomainName] = useState("");
|
||||
const [domainName, setDomainName] = useState('');
|
||||
const deleteDomainMutation = api.domain.deleteDomain.useMutation();
|
||||
|
||||
const domainForm = useForm<z.infer<typeof domainSchema>>({
|
||||
@@ -49,8 +49,8 @@ export const DeleteDomain: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
|
||||
async function onDomainDelete(values: z.infer<typeof domainSchema>) {
|
||||
if (values.domain !== domain.name) {
|
||||
domainForm.setError("domain", {
|
||||
message: "Domain name does not match",
|
||||
domainForm.setError('domain', {
|
||||
message: 'Domain name does not match',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -64,7 +64,7 @@ export const DeleteDomain: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
utils.domain.domains.invalidate();
|
||||
setOpen(false);
|
||||
toast.success(`Domain ${domain.name} deleted`);
|
||||
router.replace("/domains");
|
||||
router.replace('/domains');
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -84,8 +84,8 @@ export const DeleteDomain: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete domain</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-foreground">{domain.name}</span>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className="text-foreground font-semibold">{domain.name}</span>
|
||||
? You can't reverse this.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -106,7 +106,7 @@ export const DeleteDomain: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
{formState.errors.domain ? (
|
||||
<FormMessage />
|
||||
) : (
|
||||
<FormDescription className=" text-transparent">
|
||||
<FormDescription className="text-transparent">
|
||||
.
|
||||
</FormDescription>
|
||||
)}
|
||||
@@ -119,7 +119,7 @@ export const DeleteDomain: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
variant="destructive"
|
||||
disabled={deleteDomainMutation.isPending}
|
||||
>
|
||||
{deleteDomainMutation.isPending ? "Deleting..." : "Delete"}
|
||||
{deleteDomainMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { Domain, DomainStatus } from "@prisma/client";
|
||||
import { api } from '~/trpc/react';
|
||||
import { Domain, DomainStatus } from '@prisma/client';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@usesend/ui/src/breadcrumb";
|
||||
import { DomainStatusBadge } from "../domain-badge";
|
||||
} from '@usesend/ui/src/breadcrumb';
|
||||
import { DomainStatusBadge } from '../domain-badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -18,16 +18,16 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@usesend/ui/src/table";
|
||||
import { TextWithCopyButton } from "@usesend/ui/src/text-with-copy";
|
||||
import React, { use } from "react";
|
||||
import { Switch } from "@usesend/ui/src/switch";
|
||||
import DeleteDomain from "./delete-domain";
|
||||
import SendTestMail from "./send-test-mail";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import Link from "next/link";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { H1 } from "@usesend/ui";
|
||||
} from '@usesend/ui/src/table';
|
||||
import { TextWithCopyButton } from '@usesend/ui/src/text-with-copy';
|
||||
import React, { use } from 'react';
|
||||
import { Switch } from '@usesend/ui/src/switch';
|
||||
import DeleteDomain from './delete-domain';
|
||||
import SendTestMail from './send-test-mail';
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import Link from 'next/link';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import { H1 } from '@usesend/ui';
|
||||
|
||||
export default function DomainItemPage({
|
||||
params,
|
||||
@@ -65,7 +65,7 @@ export default function DomainItemPage({
|
||||
<p>Loading...</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* <div className="flex items-center gap-4">
|
||||
<H1>{domainQuery.data?.name}</H1>
|
||||
@@ -81,7 +81,7 @@ export default function DomainItemPage({
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="text-lg" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="text-lg ">
|
||||
<BreadcrumbPage className="text-lg">
|
||||
{domainQuery.data?.name}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
@@ -98,10 +98,10 @@ export default function DomainItemPage({
|
||||
<div>
|
||||
<Button variant="outline" onClick={handleVerify}>
|
||||
{domainQuery.data?.isVerifying
|
||||
? "Verifying..."
|
||||
? 'Verifying...'
|
||||
: domainQuery.data?.status === DomainStatus.SUCCESS
|
||||
? "Verify again"
|
||||
: "Verify domain"}
|
||||
? 'Verify again'
|
||||
: 'Verify domain'}
|
||||
</Button>
|
||||
</div>
|
||||
{domainQuery.data ? (
|
||||
@@ -110,8 +110,8 @@ export default function DomainItemPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className=" border rounded-lg p-4 shadow">
|
||||
<p className="font-semibold text-xl">DNS records</p>
|
||||
<div className="rounded-lg border p-4 shadow">
|
||||
<p className="text-xl font-semibold">DNS records</p>
|
||||
<Table className="mt-2">
|
||||
<TableHeader className="">
|
||||
<TableRow className="">
|
||||
@@ -128,7 +128,7 @@ export default function DomainItemPage({
|
||||
<TableCell className="">MX</TableCell>
|
||||
<TableCell>
|
||||
<TextWithCopyButton
|
||||
value={`mail${domainQuery.data?.subdomain ? `.${domainQuery.data.subdomain}` : ""}`}
|
||||
value={`mail${domainQuery.data?.subdomain ? `.${domainQuery.data.subdomain}` : ''}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="">
|
||||
@@ -144,7 +144,7 @@ export default function DomainItemPage({
|
||||
<TableCell className="">10</TableCell>
|
||||
<TableCell className="">
|
||||
<DnsVerificationStatus
|
||||
status={domainQuery.data?.spfDetails ?? "NOT_STARTED"}
|
||||
status={domainQuery.data?.spfDetails ?? 'NOT_STARTED'}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -152,7 +152,7 @@ export default function DomainItemPage({
|
||||
<TableCell className="">TXT</TableCell>
|
||||
<TableCell>
|
||||
<TextWithCopyButton
|
||||
value={`${domainQuery.data?.dkimSelector ?? "unsend"}._domainkey${domainQuery.data?.subdomain ? `.${domainQuery.data.subdomain}` : ""}`}
|
||||
value={`${domainQuery.data?.dkimSelector ?? 'unsend'}._domainkey${domainQuery.data?.subdomain ? `.${domainQuery.data.subdomain}` : ''}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="">
|
||||
@@ -165,7 +165,7 @@ export default function DomainItemPage({
|
||||
<TableCell className=""></TableCell>
|
||||
<TableCell className="">
|
||||
<DnsVerificationStatus
|
||||
status={domainQuery.data?.dkimStatus ?? "NOT_STARTED"}
|
||||
status={domainQuery.data?.dkimStatus ?? 'NOT_STARTED'}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -173,7 +173,7 @@ export default function DomainItemPage({
|
||||
<TableCell className="">TXT</TableCell>
|
||||
<TableCell>
|
||||
<TextWithCopyButton
|
||||
value={`mail${domainQuery.data?.subdomain ? `.${domainQuery.data.subdomain}` : ""}`}
|
||||
value={`mail${domainQuery.data?.subdomain ? `.${domainQuery.data.subdomain}` : ''}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="">
|
||||
@@ -186,15 +186,15 @@ export default function DomainItemPage({
|
||||
<TableCell className=""></TableCell>
|
||||
<TableCell className="">
|
||||
<DnsVerificationStatus
|
||||
status={domainQuery.data?.spfDetails ?? "NOT_STARTED"}
|
||||
status={domainQuery.data?.spfDetails ?? 'NOT_STARTED'}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="">TXT</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2 items-center">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
(recommended)
|
||||
</span>
|
||||
<TextWithCopyButton value="_dmarc" />
|
||||
@@ -211,7 +211,7 @@ export default function DomainItemPage({
|
||||
<TableCell className="">
|
||||
<DnsVerificationStatus
|
||||
status={
|
||||
domainQuery.data?.dmarcAdded ? "SUCCESS" : "NOT_STARTED"
|
||||
domainQuery.data?.dmarcAdded ? 'SUCCESS' : 'NOT_STARTED'
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
@@ -244,7 +244,7 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
{
|
||||
onSuccess: () => {
|
||||
utils.domain.invalidate();
|
||||
toast.success("Click tracking updated");
|
||||
toast.success('Click tracking updated');
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -257,18 +257,18 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
{
|
||||
onSuccess: () => {
|
||||
utils.domain.invalidate();
|
||||
toast.success("Open tracking updated");
|
||||
toast.success('Open tracking updated');
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded-lg shadow p-4 border flex flex-col gap-6">
|
||||
<p className="font-semibold text-xl">Settings</p>
|
||||
<div className="flex flex-col gap-6 rounded-lg border p-4 shadow">
|
||||
<p className="text-xl font-semibold">Settings</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-semibold">Click tracking</div>
|
||||
<p className=" text-muted-foreground text-sm">
|
||||
Track any links in your emails content.{" "}
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Track any links in your emails content.{' '}
|
||||
</p>
|
||||
<Switch
|
||||
checked={clickTracking}
|
||||
@@ -279,7 +279,7 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-semibold">Open tracking</div>
|
||||
<p className=" text-muted-foreground text-sm">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Unsend adds a tracking pixel to every email you send. This allows you
|
||||
to see how many people open your emails. This will affect the delivery
|
||||
rate of your emails.
|
||||
@@ -292,7 +292,7 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="font-semibold text-lg text-destructive">Danger</p>
|
||||
<p className="text-destructive text-lg font-semibold">Danger</p>
|
||||
|
||||
<p className="text-destructive text-sm font-semibold">
|
||||
Deleting a domain will stop sending emails with this domain.
|
||||
@@ -304,27 +304,27 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
};
|
||||
|
||||
const DnsVerificationStatus: React.FC<{ status: string }> = ({ status }) => {
|
||||
let badgeColor = "bg-gray/10 text-gray border-gray/10"; // Default color
|
||||
let badgeColor = 'bg-gray/10 text-gray border-gray/10'; // Default color
|
||||
switch (status) {
|
||||
case DomainStatus.SUCCESS:
|
||||
badgeColor = "bg-green/15 text-green border border-green/25";
|
||||
badgeColor = 'bg-green/15 text-green border border-green/25';
|
||||
break;
|
||||
case DomainStatus.FAILED:
|
||||
badgeColor = "bg-red/10 text-red border border-red/10";
|
||||
badgeColor = 'bg-red/10 text-red border border-red/10';
|
||||
break;
|
||||
case DomainStatus.TEMPORARY_FAILURE:
|
||||
case DomainStatus.PENDING:
|
||||
badgeColor = "bg-yellow/20 text-yellow border border-yellow/10";
|
||||
badgeColor = 'bg-yellow/20 text-yellow border border-yellow/10';
|
||||
break;
|
||||
default:
|
||||
badgeColor = "bg-gray/10 text-gray border border-gray/20";
|
||||
badgeColor = 'bg-gray/10 text-gray border border-gray/20';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={` text-xs text-center min-w-[70px] capitalize rounded-md py-1 justify-center flex items-center ${badgeColor}`}
|
||||
className={`flex min-w-[70px] items-center justify-center rounded-md py-1 text-center text-xs capitalize ${badgeColor}`}
|
||||
>
|
||||
{status.split("_").join(" ").toLowerCase()}
|
||||
{status.split('_').join(' ').toLowerCase()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { api } from "~/trpc/react";
|
||||
import React from "react";
|
||||
import { Domain } from "@prisma/client";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { SendHorizonal } from "lucide-react";
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import { api } from '~/trpc/react';
|
||||
import React from 'react';
|
||||
import { Domain } from '@prisma/client';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import { SendHorizonal } from 'lucide-react';
|
||||
// Removed dialog and example code. Clicking the button now sends the email directly.
|
||||
|
||||
export const SendTestMail: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
@@ -25,7 +25,7 @@ export const SendTestMail: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
toast.success(`Test email sent`);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message || "Failed to send test email");
|
||||
toast.error(err.message || 'Failed to send test email');
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -36,10 +36,10 @@ export const SendTestMail: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
onClick={handleSendTestEmail}
|
||||
disabled={sendTestEmailFromDomainMutation.isPending}
|
||||
>
|
||||
<SendHorizonal className="h-4 w-4 mr-2" />
|
||||
<SendHorizonal className="mr-2 h-4 w-4" />
|
||||
{sendTestEmailFromDomainMutation.isPending
|
||||
? "Sending email..."
|
||||
: "Send test email"}
|
||||
? 'Sending email...'
|
||||
: 'Send test email'}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import { Input } from '@usesend/ui/src/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -17,31 +17,31 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@usesend/ui/src/form";
|
||||
} from '@usesend/ui/src/form';
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as tldts from "tldts";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { api } from '~/trpc/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import * as tldts from 'tldts';
|
||||
import { z } from 'zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@usesend/ui/src/select";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { useUpgradeModalStore } from "~/store/upgradeModalStore";
|
||||
import { LimitReason } from "~/lib/constants/plans";
|
||||
} from '@usesend/ui/src/select';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import { useUpgradeModalStore } from '~/store/upgradeModalStore';
|
||||
import { LimitReason } from '~/lib/constants/plans';
|
||||
|
||||
const domainSchema = z.object({
|
||||
region: z.string().optional(),
|
||||
domain: z.string({ required_error: "Domain is required" }).min(1, {
|
||||
message: "Domain is required",
|
||||
domain: z.string({ required_error: 'Domain is required' }).min(1, {
|
||||
message: 'Domain is required',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -58,8 +58,8 @@ export default function AddDomain() {
|
||||
const domainForm = useForm<z.infer<typeof domainSchema>>({
|
||||
resolver: zodResolver(domainSchema),
|
||||
defaultValues: {
|
||||
region: "",
|
||||
domain: "",
|
||||
region: '',
|
||||
domain: '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -74,16 +74,16 @@ export default function AddDomain() {
|
||||
async function onDomainAdd(values: z.infer<typeof domainSchema>) {
|
||||
const domain = tldts.getDomain(values.domain);
|
||||
if (!domain) {
|
||||
domainForm.setError("domain", {
|
||||
message: "Invalid domain",
|
||||
domainForm.setError('domain', {
|
||||
message: 'Invalid domain',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!values.region && !singleRegion) {
|
||||
domainForm.setError("region", {
|
||||
message: "Region is required",
|
||||
domainForm.setError('region', {
|
||||
message: 'Region is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export default function AddDomain() {
|
||||
addDomainMutation.mutate(
|
||||
{
|
||||
name: values.domain,
|
||||
region: singleRegion ?? values.region ?? "",
|
||||
region: singleRegion ?? values.region ?? '',
|
||||
},
|
||||
{
|
||||
onSuccess: async (data) => {
|
||||
@@ -107,7 +107,7 @@ export default function AddDomain() {
|
||||
onError: async (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ export default function AddDomain() {
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Add domain
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@@ -155,7 +155,7 @@ export default function AddDomain() {
|
||||
) : (
|
||||
<FormDescription>
|
||||
Use subdomains to separate transactional and marketing
|
||||
emails.{" "}
|
||||
emails.{' '}
|
||||
</FormDescription>
|
||||
)}
|
||||
</FormItem>
|
||||
@@ -191,7 +191,7 @@ export default function AddDomain() {
|
||||
<FormMessage />
|
||||
) : (
|
||||
<FormDescription>
|
||||
Select the region from where the email is sent{" "}
|
||||
Select the region from where the email is sent{' '}
|
||||
</FormDescription>
|
||||
)}
|
||||
</FormItem>
|
||||
@@ -201,13 +201,13 @@ export default function AddDomain() {
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className=" w-[100px]"
|
||||
className="w-[100px]"
|
||||
type="submit"
|
||||
disabled={
|
||||
addDomainMutation.isPending || limitsQuery.isLoading
|
||||
}
|
||||
>
|
||||
{addDomainMutation.isPending ? "Adding..." : "Add"}
|
||||
{addDomainMutation.isPending ? 'Adding...' : 'Add'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
@@ -1,30 +1,30 @@
|
||||
import { DomainStatus } from "@prisma/client";
|
||||
import { DomainStatus } from '@prisma/client';
|
||||
|
||||
export const DomainStatusBadge: React.FC<{ status: DomainStatus }> = ({
|
||||
status,
|
||||
}) => {
|
||||
let badgeColor = "bg-gray/10 text-gray border-gray/10"; // Default color
|
||||
let badgeColor = 'bg-gray/10 text-gray border-gray/10'; // Default color
|
||||
switch (status) {
|
||||
case DomainStatus.SUCCESS:
|
||||
badgeColor = "bg-green/15 text-green border border-green/25";
|
||||
badgeColor = 'bg-green/15 text-green border border-green/25';
|
||||
break;
|
||||
case DomainStatus.FAILED:
|
||||
badgeColor = "bg-red/10 text-red border border-red/10";
|
||||
badgeColor = 'bg-red/10 text-red border border-red/10';
|
||||
break;
|
||||
case DomainStatus.TEMPORARY_FAILURE:
|
||||
case DomainStatus.PENDING:
|
||||
badgeColor = "bg-yellow/20 text-yellow border border-yellow/10";
|
||||
badgeColor = 'bg-yellow/20 text-yellow border border-yellow/10';
|
||||
break;
|
||||
default:
|
||||
badgeColor = "bg-gray/70 text-gray border border-gray/20";
|
||||
badgeColor = 'bg-gray/70 text-gray border border-gray/20';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={` text-center w-[120px] capitalize rounded-md py-1 justify-center flex items-center ${badgeColor}`}
|
||||
className={`flex w-[120px] items-center justify-center rounded-md py-1 text-center capitalize ${badgeColor}`}
|
||||
>
|
||||
<span className="text-xs">
|
||||
{status === "SUCCESS" ? "Verified" : status.toLowerCase()}
|
||||
{status === 'SUCCESS' ? 'Verified' : status.toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Domain } from "@prisma/client";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import Link from "next/link";
|
||||
import { Switch } from "@usesend/ui/src/switch";
|
||||
import { api } from "~/trpc/react";
|
||||
import React from "react";
|
||||
import { StatusIndicator } from "./status-indicator";
|
||||
import { DomainStatusBadge } from "./domain-badge";
|
||||
import Spinner from "@usesend/ui/src/spinner";
|
||||
import { Domain } from '@prisma/client';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import Link from 'next/link';
|
||||
import { Switch } from '@usesend/ui/src/switch';
|
||||
import { api } from '~/trpc/react';
|
||||
import React from 'react';
|
||||
import { StatusIndicator } from './status-indicator';
|
||||
import { DomainStatusBadge } from './domain-badge';
|
||||
import Spinner from '@usesend/ui/src/spinner';
|
||||
|
||||
export default function DomainsList() {
|
||||
const domainsQuery = api.domain.domains.useQuery();
|
||||
@@ -17,9 +17,9 @@ export default function DomainsList() {
|
||||
<div className="mt-10">
|
||||
<div className="flex flex-col gap-6">
|
||||
{domainsQuery.isLoading ? (
|
||||
<div className="flex justify-center mt-10">
|
||||
<div className="mt-10 flex justify-center">
|
||||
<Spinner
|
||||
className="w-6 h-6 mx-auto"
|
||||
className="mx-auto h-6 w-6"
|
||||
innerSvgClass="stroke-primary"
|
||||
/>
|
||||
</div>
|
||||
@@ -28,7 +28,7 @@ export default function DomainsList() {
|
||||
<DomainItem key={domain.id} domain={domain} />
|
||||
))
|
||||
) : (
|
||||
<div className="text-center mt-20">No domains Added</div>
|
||||
<div className="mt-20 text-center">No domains Added</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,7 +40,7 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
const utils = api.useUtils();
|
||||
|
||||
const [clickTracking, setClickTracking] = React.useState(
|
||||
domain.clickTracking
|
||||
domain.clickTracking,
|
||||
);
|
||||
const [openTracking, setOpenTracking] = React.useState(domain.openTracking);
|
||||
|
||||
@@ -52,7 +52,7 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
onSuccess: () => {
|
||||
utils.domain.domains.invalidate();
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,19 +64,19 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
onSuccess: () => {
|
||||
utils.domain.domains.invalidate();
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={domain.id}>
|
||||
<div className=" pr-8 border rounded-lg flex items-stretch shadow">
|
||||
<div className="flex items-stretch rounded-lg border pr-8 shadow">
|
||||
<StatusIndicator status={domain.status} />
|
||||
<div className="flex justify-between w-full pl-8 py-4">
|
||||
<div className="flex flex-col gap-4 w-1/5">
|
||||
<div className="flex w-full justify-between py-4 pl-8">
|
||||
<div className="flex w-1/5 flex-col gap-4">
|
||||
<Link
|
||||
href={`/domains/${domain.id}`}
|
||||
className="text-lg font-medium underline underline-offset-4 decoration-dashed"
|
||||
className="text-lg font-medium underline decoration-dashed underline-offset-4"
|
||||
>
|
||||
{domain.name}
|
||||
</Link>
|
||||
@@ -85,7 +85,7 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Created at</p>
|
||||
<p className="text-muted-foreground text-sm">Created at</p>
|
||||
<p className="text-sm">
|
||||
{formatDistanceToNow(new Date(domain.createdAt), {
|
||||
addSuffix: true,
|
||||
@@ -93,13 +93,13 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Region</p>
|
||||
<p className="text-muted-foreground text-sm">Region</p>
|
||||
|
||||
<p className="text-sm flex items-center gap-2">{domain.region}</p>
|
||||
<p className="flex items-center gap-2 text-sm">{domain.region}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm">Click tracking</p>
|
||||
<Switch
|
||||
checked={clickTracking}
|
||||
@@ -107,7 +107,7 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
className="data-[state=checked]:bg-success"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm">Open tracking</p>
|
||||
<Switch
|
||||
checked={openTracking}
|
||||
|
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import DomainsList from "./domain-list";
|
||||
import AddDomain from "./add-domain";
|
||||
import { H1 } from "@usesend/ui";
|
||||
import DomainsList from './domain-list';
|
||||
import AddDomain from './add-domain';
|
||||
import { H1 } from '@usesend/ui';
|
||||
|
||||
export default function DomainsPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<H1>Domains</H1>
|
||||
<AddDomain />
|
||||
</div>
|
||||
|
@@ -1,26 +1,26 @@
|
||||
import { DomainStatus } from "@prisma/client";
|
||||
import { DomainStatus } from '@prisma/client';
|
||||
|
||||
export const StatusIndicator: React.FC<{ status: DomainStatus }> = ({
|
||||
status,
|
||||
}) => {
|
||||
let badgeColor = "bg-gray"; // Default color
|
||||
let badgeColor = 'bg-gray'; // Default color
|
||||
switch (status) {
|
||||
case DomainStatus.NOT_STARTED:
|
||||
badgeColor = "bg-gray";
|
||||
badgeColor = 'bg-gray';
|
||||
break;
|
||||
case DomainStatus.SUCCESS:
|
||||
badgeColor = "bg-green";
|
||||
badgeColor = 'bg-green';
|
||||
break;
|
||||
case DomainStatus.FAILED:
|
||||
badgeColor = "bg-red";
|
||||
badgeColor = 'bg-red';
|
||||
break;
|
||||
case DomainStatus.TEMPORARY_FAILURE:
|
||||
case DomainStatus.PENDING:
|
||||
badgeColor = "bg-yellow";
|
||||
badgeColor = 'bg-yellow';
|
||||
break;
|
||||
default:
|
||||
badgeColor = "bg-gray";
|
||||
badgeColor = 'bg-gray';
|
||||
}
|
||||
|
||||
return <div className={` w-[2px] ${badgeColor} my-1.5 rounded-full`}></div>;
|
||||
return <div className={`w-[2px] ${badgeColor} my-1.5 rounded-full`}></div>;
|
||||
};
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import { Input } from '@usesend/ui/src/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -9,14 +9,14 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
import { api } from "~/trpc/react";
|
||||
import React, { useState } from "react";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
import { api } from '~/trpc/react';
|
||||
import React, { useState } from 'react';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@usesend/ui/src/form";
|
||||
} from '@usesend/ui/src/form';
|
||||
|
||||
const cancelSchema = z.object({
|
||||
confirmation: z.string(),
|
||||
@@ -44,9 +44,9 @@ export const CancelEmail: React.FC<{
|
||||
});
|
||||
|
||||
async function onEmailCancel(values: z.infer<typeof cancelSchema>) {
|
||||
if (values.confirmation !== "cancel") {
|
||||
cancelForm.setError("confirmation", {
|
||||
message: "Confirmation does not match",
|
||||
if (values.confirmation !== 'cancel') {
|
||||
cancelForm.setError('confirmation', {
|
||||
message: 'Confirmation does not match',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -68,7 +68,7 @@ export const CancelEmail: React.FC<{
|
||||
);
|
||||
}
|
||||
|
||||
const confirmation = cancelForm.watch("confirmation");
|
||||
const confirmation = cancelForm.watch('confirmation');
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -77,7 +77,7 @@ export const CancelEmail: React.FC<{
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Trash2 className="h-4 w-4 text-red" />
|
||||
<Trash2 className="text-red h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
@@ -118,12 +118,12 @@ export const CancelEmail: React.FC<{
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
disabled={
|
||||
cancelEmailMutation.isPending || confirmation !== "cancel"
|
||||
cancelEmailMutation.isPending || confirmation !== 'cancel'
|
||||
}
|
||||
>
|
||||
{cancelEmailMutation.isPending
|
||||
? "Cancelling..."
|
||||
: "Cancel Email"}
|
||||
? 'Cancelling...'
|
||||
: 'Cancel Email'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
@@ -1,27 +1,27 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import { Input } from '@usesend/ui/src/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
import * as chrono from "chrono-node";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useRef, useState } from "react";
|
||||
import { Edit3 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
import * as chrono from 'chrono-node';
|
||||
import { api } from '~/trpc/react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Edit3 } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@usesend/ui/src/dropdown-menu";
|
||||
} from '@usesend/ui/src/dropdown-menu';
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@usesend/ui/src/command";
|
||||
} from '@usesend/ui/src/command';
|
||||
|
||||
export const EditSchedule: React.FC<{
|
||||
emailId: string;
|
||||
@@ -39,9 +39,9 @@ export const EditSchedule: React.FC<{
|
||||
}> = ({ emailId, scheduledAt }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [openSuggestions, setOpenSuggestions] = useState(true);
|
||||
const [scheduleInput, setScheduleInput] = useState(scheduledAt || "");
|
||||
const [scheduleInput, setScheduleInput] = useState(scheduledAt || '');
|
||||
const [scheduledAtTime, setScheduledAtTime] = useState<Date | null>(
|
||||
scheduledAt ? new Date(scheduledAt) : null
|
||||
scheduledAt ? new Date(scheduledAt) : null,
|
||||
);
|
||||
const updateEmailScheduledAtMutation =
|
||||
api.email.updateEmailScheduledAt.useMutation();
|
||||
@@ -53,7 +53,7 @@ export const EditSchedule: React.FC<{
|
||||
const handleScheduleUpdate = () => {
|
||||
const parsedDate = chrono.parseDate(scheduleInput);
|
||||
if (!parsedDate) {
|
||||
toast.error("Invalid date and time");
|
||||
toast.error('Invalid date and time');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -66,12 +66,12 @@ export const EditSchedule: React.FC<{
|
||||
onSuccess: () => {
|
||||
utils.email.getEmail.invalidate({ id: emailId });
|
||||
setOpen(false);
|
||||
toast.success("Email schedule updated successfully");
|
||||
toast.success('Email schedule updated successfully');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -100,7 +100,7 @@ export const EditSchedule: React.FC<{
|
||||
<div className="py-2">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="scheduleInput" className="block mb-2">
|
||||
<label htmlFor="scheduleInput" className="mb-2 block">
|
||||
Schedule at
|
||||
</label>
|
||||
{/* <Input
|
||||
@@ -155,8 +155,8 @@ export const EditSchedule: React.FC<{
|
||||
disabled={updateEmailScheduledAtMutation.isPending}
|
||||
>
|
||||
{updateEmailScheduledAtMutation.isPending
|
||||
? "Updating..."
|
||||
: "Update"}
|
||||
? 'Updating...'
|
||||
: 'Update'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,42 +1,42 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Separator } from "@usesend/ui/src/separator";
|
||||
import { EmailStatusBadge, EmailStatusIcon } from "./email-status-badge";
|
||||
import { formatDate } from "date-fns";
|
||||
import { motion } from "framer-motion";
|
||||
import { EmailStatus } from "@prisma/client";
|
||||
import { JsonValue } from "@prisma/client/runtime/library";
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import { api } from '~/trpc/react';
|
||||
import { Separator } from '@usesend/ui/src/separator';
|
||||
import { EmailStatusBadge, EmailStatusIcon } from './email-status-badge';
|
||||
import { formatDate } from 'date-fns';
|
||||
import { motion } from 'framer-motion';
|
||||
import { EmailStatus } from '@prisma/client';
|
||||
import { JsonValue } from '@prisma/client/runtime/library';
|
||||
import {
|
||||
SesBounce,
|
||||
SesClick,
|
||||
SesComplaint,
|
||||
SesDeliveryDelay,
|
||||
SesOpen,
|
||||
} from "~/types/aws-types";
|
||||
} from '~/types/aws-types';
|
||||
import {
|
||||
BOUNCE_ERROR_MESSAGES,
|
||||
COMPLAINT_ERROR_MESSAGES,
|
||||
DELIVERY_DELAY_ERRORS,
|
||||
} from "~/lib/constants/ses-errors";
|
||||
import CancelEmail from "./cancel-email";
|
||||
import { useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
} from '~/lib/constants/ses-errors';
|
||||
import CancelEmail from './cancel-email';
|
||||
import { useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function EmailDetails({ emailId }: { emailId: string }) {
|
||||
const emailQuery = api.email.getEmail.useQuery({ id: emailId });
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto px-4 no-scrollbar">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="no-scrollbar h-full overflow-auto px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="font-bold">{emailQuery.data?.to}</h1>
|
||||
<EmailStatusBadge status={emailQuery.data?.latestStatus ?? "SENT"} />
|
||||
<EmailStatusBadge status={emailQuery.data?.latestStatus ?? 'SENT'} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col mt-8 items-start gap-8">
|
||||
<div className="p-2 rounded-lg border flex flex-col gap-2 w-full shadow">
|
||||
<div className="mt-8 flex flex-col items-start gap-8">
|
||||
<div className="flex w-full flex-col gap-2 rounded-lg border p-2 shadow">
|
||||
{/* <div className="flex gap-2">
|
||||
<span className="w-[100px] text-muted-foreground text-sm">
|
||||
From
|
||||
@@ -59,23 +59,23 @@ export default function EmailDetails({ emailId }: { emailId: string }) {
|
||||
{/* <div className=" text-[15px] font-medium">
|
||||
{emailQuery.data?.to}
|
||||
</div> */}
|
||||
<div className=" text-sm">Subject: {emailQuery.data?.subject}</div>
|
||||
<div className="text-sm">Subject: {emailQuery.data?.subject}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
From: {emailQuery.data?.from}
|
||||
</div>
|
||||
</div>
|
||||
{emailQuery.data?.latestStatus === "SCHEDULED" &&
|
||||
{emailQuery.data?.latestStatus === 'SCHEDULED' &&
|
||||
emailQuery.data?.scheduledAt ? (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex gap-2 items-center px-4">
|
||||
<span className="w-[100px] text-muted-foreground text-sm ">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<span className="text-muted-foreground w-[100px] text-sm">
|
||||
Scheduled at
|
||||
</span>
|
||||
<span className="text-sm">
|
||||
{formatDate(
|
||||
emailQuery.data?.scheduledAt,
|
||||
"MMM dd'th', hh:mm a"
|
||||
"MMM dd'th', hh:mm a",
|
||||
)}
|
||||
</span>
|
||||
<div className="ml-4">
|
||||
@@ -90,32 +90,32 @@ export default function EmailDetails({ emailId }: { emailId: string }) {
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2, delay: 0.3 }}
|
||||
>
|
||||
<EmailPreview html={emailQuery.data?.html ?? ""} />
|
||||
<EmailPreview html={emailQuery.data?.html ?? ''} />
|
||||
</motion.div>
|
||||
</div>
|
||||
{emailQuery.data?.latestStatus !== "SCHEDULED" ? (
|
||||
<div className=" border rounded-lg w-full shadow mb-2 ">
|
||||
<div className=" p-4 flex flex-col gap-8 w-full">
|
||||
{emailQuery.data?.latestStatus !== 'SCHEDULED' ? (
|
||||
<div className="mb-2 w-full rounded-lg border shadow">
|
||||
<div className="flex w-full flex-col gap-8 p-4">
|
||||
<div className="font-medium">Events History</div>
|
||||
<div className="flex items-stretch px-4 w-full">
|
||||
<div className="border-r border-gray-300 dark:border-gray-700 border-dashed" />
|
||||
<div className="flex flex-col gap-12 w-full">
|
||||
<div className="flex w-full items-stretch px-4">
|
||||
<div className="border-r border-dashed border-gray-300 dark:border-gray-700" />
|
||||
<div className="flex w-full flex-col gap-12">
|
||||
{emailQuery.data?.emailEvents.map((evt) => (
|
||||
<div
|
||||
key={evt.status}
|
||||
className="flex gap-5 items-start w-full"
|
||||
className="flex w-full items-start gap-5"
|
||||
>
|
||||
<div className=" -ml-2.5">
|
||||
<div className="-ml-2.5">
|
||||
<EmailStatusIcon status={evt.status} />
|
||||
</div>
|
||||
<div className="-mt-[0.125rem] w-full">
|
||||
<div className=" capitalize font-medium">
|
||||
<div className="font-medium capitalize">
|
||||
<EmailStatusBadge status={evt.status} />
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
{formatDate(evt.createdAt, "MMM dd, hh:mm a")}
|
||||
<div className="text-muted-foreground mt-2 text-xs">
|
||||
{formatDate(evt.createdAt, 'MMM dd, hh:mm a')}
|
||||
</div>
|
||||
<div className="mt-1 text-foreground/80">
|
||||
<div className="text-foreground/80 mt-1">
|
||||
<EmailStatusText
|
||||
status={evt.status}
|
||||
data={evt.data}
|
||||
@@ -147,14 +147,14 @@ const EmailPreview = ({ html }: { html: string }) => {
|
||||
|
||||
if (!show) {
|
||||
return (
|
||||
<div className="dark:bg-slate-200 h-[350px] overflow-visible rounded border-t"></div>
|
||||
<div className="h-[350px] overflow-visible rounded border-t dark:bg-slate-200"></div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dark:bg-slate-200 h-[350px] overflow-visible rounded border-t">
|
||||
<div className="h-[350px] overflow-visible rounded border-t dark:bg-slate-200">
|
||||
<iframe
|
||||
className="w-full h-full"
|
||||
className="h-full w-full"
|
||||
srcDoc={html}
|
||||
sandbox="allow-same-origin"
|
||||
/>
|
||||
@@ -169,106 +169,106 @@ const EmailStatusText = ({
|
||||
status: EmailStatus;
|
||||
data: JsonValue;
|
||||
}) => {
|
||||
if (status === "SENT") {
|
||||
if (status === 'SENT') {
|
||||
return (
|
||||
<div>
|
||||
We received your request and sent the email to recipient's server.
|
||||
</div>
|
||||
);
|
||||
} else if (status === "DELIVERED") {
|
||||
} else if (status === 'DELIVERED') {
|
||||
return <div>Mail is successfully delivered to the recipient.</div>;
|
||||
} else if (status === "DELIVERY_DELAYED") {
|
||||
} else if (status === 'DELIVERY_DELAYED') {
|
||||
const _errorData = data as unknown as SesDeliveryDelay;
|
||||
const errorMessage = DELIVERY_DELAY_ERRORS[_errorData.delayType];
|
||||
|
||||
return <div>{errorMessage}</div>;
|
||||
} else if (status === "BOUNCED") {
|
||||
} else if (status === 'BOUNCED') {
|
||||
const _errorData = data as unknown as SesBounce;
|
||||
_errorData.bounceType;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<p>{getErrorMessage(_errorData)}</p>
|
||||
<div className="rounded-xl p-4 bg-muted/30 flex flex-col gap-4">
|
||||
<div className="flex gap-2 w-full">
|
||||
<div className="bg-muted/30 flex flex-col gap-4 rounded-xl p-4">
|
||||
<div className="flex w-full gap-2">
|
||||
<div className="w-1/2">
|
||||
<p className="text-sm text-muted-foreground">Type</p>
|
||||
<p className="text-muted-foreground text-sm">Type</p>
|
||||
<p>{_errorData.bounceType}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Sub Type</p>
|
||||
<p className="text-muted-foreground text-sm">Sub Type</p>
|
||||
<p>{_errorData.bounceSubType}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">SMTP response</p>
|
||||
<p className="text-muted-foreground text-sm">SMTP response</p>
|
||||
<p>{_errorData.bouncedRecipients[0]?.diagnosticCode}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (status === "FAILED") {
|
||||
} else if (status === 'FAILED') {
|
||||
const _errorData = data as unknown as { error: string };
|
||||
return <div>{_errorData.error}</div>;
|
||||
} else if (status === "OPENED") {
|
||||
} else if (status === 'OPENED') {
|
||||
const _data = data as unknown as SesOpen;
|
||||
const userAgent = getUserAgent(_data.userAgent);
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-xl p-4 bg-muted/30 mt-4">
|
||||
<div className="flex w-full ">
|
||||
<div className="bg-muted/30 mt-4 w-full rounded-xl p-4">
|
||||
<div className="flex w-full">
|
||||
{userAgent.os.name ? (
|
||||
<div className="w-1/2">
|
||||
<p className="text-sm text-muted-foreground">OS</p>
|
||||
<p className="text-muted-foreground text-sm">OS</p>
|
||||
<p>{userAgent.os.name}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{userAgent.browser.name ? (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Browser</p>
|
||||
<p className="text-muted-foreground text-sm">Browser</p>
|
||||
<p>{userAgent.browser.name}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (status === "CLICKED") {
|
||||
} else if (status === 'CLICKED') {
|
||||
const _data = data as unknown as SesClick;
|
||||
const userAgent = getUserAgent(_data.userAgent);
|
||||
|
||||
return (
|
||||
<div className="w-full mt-4 flex flex-col gap-4 rounded-xl p-4 bg-muted/30">
|
||||
<div className="flex w-full ">
|
||||
<div className="bg-muted/30 mt-4 flex w-full flex-col gap-4 rounded-xl p-4">
|
||||
<div className="flex w-full">
|
||||
{userAgent.os.name ? (
|
||||
<div className="w-1/2">
|
||||
<p className="text-sm text-muted-foreground">OS </p>
|
||||
<p className="text-muted-foreground text-sm">OS </p>
|
||||
<p>{userAgent.os.name}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{userAgent.browser.name ? (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Browser </p>
|
||||
<p className="text-muted-foreground text-sm">Browser </p>
|
||||
<p>{userAgent.browser.name}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<p className="text-sm text-muted-foreground">URL</p>
|
||||
<p className="text-muted-foreground text-sm">URL</p>
|
||||
<p>{_data.link}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (status === "COMPLAINED") {
|
||||
} else if (status === 'COMPLAINED') {
|
||||
const _errorData = data as unknown as SesComplaint;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<p>{getComplaintMessage(_errorData.complaintFeedbackType)}</p>
|
||||
</div>
|
||||
);
|
||||
} else if (status === "CANCELLED") {
|
||||
} else if (status === 'CANCELLED') {
|
||||
return <div>This scheduled email was cancelled</div>;
|
||||
} else if (status === "SUPPRESSED") {
|
||||
} else if (status === 'SUPPRESSED') {
|
||||
return (
|
||||
<div>
|
||||
This email was suppressed because this email is previously either
|
||||
@@ -281,24 +281,24 @@ const EmailStatusText = ({
|
||||
};
|
||||
|
||||
const getErrorMessage = (data: SesBounce) => {
|
||||
if (data.bounceType === "Permanent") {
|
||||
if (data.bounceType === 'Permanent') {
|
||||
return BOUNCE_ERROR_MESSAGES[data.bounceType][
|
||||
data.bounceSubType as
|
||||
| "General"
|
||||
| "NoEmail"
|
||||
| "Suppressed"
|
||||
| "OnAccountSuppressionList"
|
||||
| 'General'
|
||||
| 'NoEmail'
|
||||
| 'Suppressed'
|
||||
| 'OnAccountSuppressionList'
|
||||
];
|
||||
} else if (data.bounceType === "Transient") {
|
||||
} else if (data.bounceType === 'Transient') {
|
||||
return BOUNCE_ERROR_MESSAGES[data.bounceType][
|
||||
data.bounceSubType as
|
||||
| "General"
|
||||
| "MailboxFull"
|
||||
| "MessageTooLarge"
|
||||
| "ContentRejected"
|
||||
| "AttachmentRejected"
|
||||
| 'General'
|
||||
| 'MailboxFull'
|
||||
| 'MessageTooLarge'
|
||||
| 'ContentRejected'
|
||||
| 'AttachmentRejected'
|
||||
];
|
||||
} else if (data.bounceType === "Undetermined") {
|
||||
} else if (data.bounceType === 'Undetermined') {
|
||||
return BOUNCE_ERROR_MESSAGES.Undetermined;
|
||||
}
|
||||
};
|
||||
|
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Table,
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableCell,
|
||||
} from "@usesend/ui/src/table";
|
||||
import { api } from "~/trpc/react";
|
||||
} from '@usesend/ui/src/table';
|
||||
import { api } from '~/trpc/react';
|
||||
import {
|
||||
Mail,
|
||||
MailCheck,
|
||||
@@ -17,51 +17,51 @@ import {
|
||||
MailWarning,
|
||||
MailX,
|
||||
Download,
|
||||
} from "lucide-react";
|
||||
import { formatDate, formatDistanceToNow } from "date-fns";
|
||||
import { EmailStatus } from "@prisma/client";
|
||||
import { EmailStatusBadge } from "./email-status-badge";
|
||||
import EmailDetails from "./email-details";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useUrlState } from "~/hooks/useUrlState";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
} from 'lucide-react';
|
||||
import { formatDate, formatDistanceToNow } from 'date-fns';
|
||||
import { EmailStatus } from '@prisma/client';
|
||||
import { EmailStatusBadge } from './email-status-badge';
|
||||
import EmailDetails from './email-details';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useUrlState } from '~/hooks/useUrlState';
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@usesend/ui/src/select";
|
||||
import Spinner from "@usesend/ui/src/spinner";
|
||||
} from '@usesend/ui/src/select';
|
||||
import Spinner from '@usesend/ui/src/spinner';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@usesend/ui/src/tooltip";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { DEFAULT_QUERY_LIMIT } from "~/lib/constants";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { useState } from "react";
|
||||
import { SheetTitle, SheetDescription } from "@usesend/ui/src/sheet";
|
||||
} from '@usesend/ui/src/tooltip';
|
||||
import { Input } from '@usesend/ui/src/input';
|
||||
import { DEFAULT_QUERY_LIMIT } from '~/lib/constants';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { useState } from 'react';
|
||||
import { SheetTitle, SheetDescription } from '@usesend/ui/src/sheet';
|
||||
|
||||
/* Stupid hydrating error. And I so stupid to understand the stupid NextJS docs */
|
||||
const DynamicSheetWithNoSSR = dynamic(
|
||||
() => import("@usesend/ui/src/sheet").then((mod) => mod.Sheet),
|
||||
() => import('@usesend/ui/src/sheet').then((mod) => mod.Sheet),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const DynamicSheetContentWithNoSSR = dynamic(
|
||||
() => import("@usesend/ui/src/sheet").then((mod) => mod.SheetContent),
|
||||
() => import('@usesend/ui/src/sheet').then((mod) => mod.SheetContent),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export default function EmailsList() {
|
||||
const [selectedEmail, setSelectedEmail] = useUrlState("emailId");
|
||||
const [page, setPage] = useUrlState("page", "1");
|
||||
const [status, setStatus] = useUrlState("status");
|
||||
const [search, setSearch] = useUrlState("search");
|
||||
const [domain, setDomain] = useUrlState("domain");
|
||||
const [apiKey, setApiKey] = useUrlState("apikey");
|
||||
const [selectedEmail, setSelectedEmail] = useUrlState('emailId');
|
||||
const [page, setPage] = useUrlState('page', '1');
|
||||
const [status, setStatus] = useUrlState('status');
|
||||
const [search, setSearch] = useUrlState('search');
|
||||
const [domain, setDomain] = useUrlState('domain');
|
||||
const [apiKey, setApiKey] = useUrlState('apikey');
|
||||
|
||||
const pageNumber = Number(page);
|
||||
const domainId = domain ? Number(domain) : undefined;
|
||||
@@ -93,11 +93,11 @@ export default function EmailsList() {
|
||||
};
|
||||
|
||||
const handleDomain = (val: string) => {
|
||||
setDomain(val === "All Domains" ? null : val);
|
||||
setDomain(val === 'All Domains' ? null : val);
|
||||
};
|
||||
|
||||
const handleApiKey = (val: string) => {
|
||||
setApiKey(val === "All API Keys" ? null : val);
|
||||
setApiKey(val === 'All API Keys' ? null : val);
|
||||
};
|
||||
|
||||
const handleSheetChange = (isOpen: boolean) => {
|
||||
@@ -116,21 +116,21 @@ export default function EmailsList() {
|
||||
if (!resp.data) return;
|
||||
|
||||
const escape = (val: unknown) => {
|
||||
const s = String(val ?? "");
|
||||
const s = String(val ?? '');
|
||||
const startsRisky = /^\s*[=+\-@]/.test(s);
|
||||
const safe = (startsRisky ? "'" : "") + s.replace(/"/g, '""');
|
||||
const safe = (startsRisky ? "'" : '') + s.replace(/"/g, '""');
|
||||
return /[",\r\n]/.test(safe) ? `"${safe}"` : safe;
|
||||
};
|
||||
|
||||
const header = [
|
||||
"To",
|
||||
"Status",
|
||||
"Subject",
|
||||
"Sent At",
|
||||
"Bounce Type",
|
||||
"Bounce Subtype",
|
||||
"Bounce Reason",
|
||||
].join(",");
|
||||
'To',
|
||||
'Status',
|
||||
'Subject',
|
||||
'Sent At',
|
||||
'Bounce Type',
|
||||
'Bounce Subtype',
|
||||
'Bounce Reason',
|
||||
].join(',');
|
||||
const rows = resp.data.map((e) =>
|
||||
[
|
||||
e.to,
|
||||
@@ -142,45 +142,45 @@ export default function EmailsList() {
|
||||
e.bounceReason,
|
||||
]
|
||||
.map(escape)
|
||||
.join(","),
|
||||
.join(','),
|
||||
);
|
||||
const csv = [header, ...rows].join("\n");
|
||||
const csv = [header, ...rows].join('\n');
|
||||
|
||||
const blob = new Blob(["\uFEFF" + csv], {
|
||||
type: "text/csv;charset=utf-8",
|
||||
const blob = new Blob(['\uFEFF' + csv], {
|
||||
type: 'text/csv;charset=utf-8',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `emails-${new Date().toISOString().split("T")[0]}.csv`;
|
||||
a.download = `emails-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error("Export failed", err);
|
||||
console.error('Export failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-10 flex flex-col gap-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<Input
|
||||
placeholder="Search by subject or email"
|
||||
className="w-[350px] mr-4"
|
||||
defaultValue={search ?? ""}
|
||||
className="mr-4 w-[350px]"
|
||||
defaultValue={search ?? ''}
|
||||
onChange={(e) => debouncedSearch(e.target.value)}
|
||||
/>
|
||||
<div className="flex justify-center items-center gap-x-3">
|
||||
<div className="flex items-center justify-center gap-x-3">
|
||||
<Select
|
||||
value={apiKey ?? "All API Keys"}
|
||||
value={apiKey ?? 'All API Keys'}
|
||||
onValueChange={(val) => handleApiKey(val)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
{apiKey
|
||||
? apiKeysQuery?.find((apikey) => apikey.id === Number(apiKey))
|
||||
?.name
|
||||
: "All API Keys"}
|
||||
: 'All API Keys'}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All API Keys">All API Keys</SelectItem>
|
||||
@@ -193,16 +193,16 @@ export default function EmailsList() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={domain ?? "All Domains"}
|
||||
value={domain ?? 'All Domains'}
|
||||
onValueChange={(val) => handleDomain(val)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
{domain
|
||||
? domainsQuery?.find((d) => d.id === Number(domain))?.name
|
||||
: "All Domains"}
|
||||
: 'All Domains'}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All Domains" className=" capitalize">
|
||||
<SelectItem value="All Domains" className="capitalize">
|
||||
All Domains
|
||||
</SelectItem>
|
||||
{domainsQuery &&
|
||||
@@ -214,32 +214,32 @@ export default function EmailsList() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={status ?? "All statuses"}
|
||||
value={status ?? 'All statuses'}
|
||||
onValueChange={(val) =>
|
||||
setStatus(val === "All statuses" ? null : val)
|
||||
setStatus(val === 'All statuses' ? null : val)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] capitalize">
|
||||
{status ? status.toLowerCase().replace("_", " ") : "All statuses"}
|
||||
{status ? status.toLowerCase().replace('_', ' ') : 'All statuses'}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All statuses" className=" capitalize">
|
||||
<SelectItem value="All statuses" className="capitalize">
|
||||
All statuses
|
||||
</SelectItem>
|
||||
{Object.values([
|
||||
"SENT",
|
||||
"SCHEDULED",
|
||||
"QUEUED",
|
||||
"DELIVERED",
|
||||
"BOUNCED",
|
||||
"CLICKED",
|
||||
"OPENED",
|
||||
"DELIVERY_DELAYED",
|
||||
"COMPLAINED",
|
||||
"SUPPRESSED",
|
||||
'SENT',
|
||||
'SCHEDULED',
|
||||
'QUEUED',
|
||||
'DELIVERED',
|
||||
'BOUNCED',
|
||||
'CLICKED',
|
||||
'OPENED',
|
||||
'DELIVERY_DELAYED',
|
||||
'COMPLAINED',
|
||||
'SUPPRESSED',
|
||||
]).map((status) => (
|
||||
<SelectItem key={status} value={status} className=" capitalize">
|
||||
{status.toLowerCase().replace("_", " ")}
|
||||
<SelectItem key={status} value={status} className="capitalize">
|
||||
{status.toLowerCase().replace('_', ' ')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -249,7 +249,7 @@ export default function EmailsList() {
|
||||
onClick={handleExport}
|
||||
disabled={exportQuery.isFetching}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
@@ -257,11 +257,11 @@ export default function EmailsList() {
|
||||
<div className="flex flex-col rounded-xl border shadow">
|
||||
<Table className="">
|
||||
<TableHeader className="">
|
||||
<TableRow className=" bg-muted dark:bg-muted/70">
|
||||
<TableRow className="bg-muted dark:bg-muted/70">
|
||||
<TableHead className="rounded-tl-xl">To</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Subject</TableHead>
|
||||
<TableHead className="text-right rounded-tr-xl">
|
||||
<TableHead className="rounded-tr-xl text-right">
|
||||
Sent at
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
@@ -269,9 +269,9 @@ export default function EmailsList() {
|
||||
<TableBody>
|
||||
{emailsQuery.isLoading ? (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={4} className="text-center py-4">
|
||||
<TableCell colSpan={4} className="py-4 text-center">
|
||||
<Spinner
|
||||
className="w-6 h-6 mx-auto"
|
||||
className="mx-auto h-6 w-6"
|
||||
innerSvgClass="stroke-primary"
|
||||
/>
|
||||
</TableCell>
|
||||
@@ -281,25 +281,25 @@ export default function EmailsList() {
|
||||
<TableRow
|
||||
key={email.id}
|
||||
onClick={() => handleSelectEmail(email.id)}
|
||||
className=" cursor-pointer"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* <EmailIcon status={email.latestStatus ?? "Sent"} /> */}
|
||||
<p> {email.to}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{email.latestStatus === "SCHEDULED" && email.scheduledAt ? (
|
||||
{email.latestStatus === 'SCHEDULED' && email.scheduledAt ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<EmailStatusBadge
|
||||
status={email.latestStatus ?? "Sent"}
|
||||
status={email.latestStatus ?? 'Sent'}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Scheduled at{" "}
|
||||
Scheduled at{' '}
|
||||
{formatDate(
|
||||
email.scheduledAt,
|
||||
"MMM dd'th', hh:mm a",
|
||||
@@ -308,25 +308,25 @@ export default function EmailsList() {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<EmailStatusBadge status={email.latestStatus ?? "Sent"} />
|
||||
<EmailStatusBadge status={email.latestStatus ?? 'Sent'} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="">
|
||||
<div className=" max-w-xs truncate">{email.subject}</div>
|
||||
<div className="max-w-xs truncate">{email.subject}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{email.latestStatus !== "SCHEDULED"
|
||||
{email.latestStatus !== 'SCHEDULED'
|
||||
? formatDate(
|
||||
email.scheduledAt ?? email.createdAt,
|
||||
"MMM do, hh:mm a",
|
||||
'MMM do, hh:mm a',
|
||||
)
|
||||
: "--"}
|
||||
: '--'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={4} className="text-center py-4">
|
||||
<TableCell colSpan={4} className="py-4 text-center">
|
||||
No emails found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -338,7 +338,7 @@ export default function EmailsList() {
|
||||
open={!!selectedEmail}
|
||||
onOpenChange={handleSheetChange}
|
||||
>
|
||||
<DynamicSheetContentWithNoSSR className="sm:max-w-3xl overflow-y-auto no-scrollbar">
|
||||
<DynamicSheetContentWithNoSSR className="no-scrollbar overflow-y-auto sm:max-w-3xl">
|
||||
<SheetTitle className="sr-only">Email Details</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
Detailed view of the selected email.
|
||||
@@ -347,7 +347,7 @@ export default function EmailsList() {
|
||||
</DynamicSheetContentWithNoSSR>
|
||||
</DynamicSheetWithNoSSR>
|
||||
</div>
|
||||
<div className="flex gap-4 justify-end">
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setPage((pageNumber - 1).toString())}
|
||||
@@ -369,48 +369,48 @@ export default function EmailsList() {
|
||||
|
||||
const EmailIcon: React.FC<{ status: EmailStatus }> = ({ status }) => {
|
||||
switch (status) {
|
||||
case "SENT":
|
||||
case 'SENT':
|
||||
return (
|
||||
// <div className="border border-gray-400/60 p-2 rounded-lg bg-gray-400/10">
|
||||
<Mail className="w-6 h-6 text-gray" />
|
||||
<Mail className="text-gray h-6 w-6" />
|
||||
// </div>
|
||||
);
|
||||
case "DELIVERED":
|
||||
case 'DELIVERED':
|
||||
return (
|
||||
// <div className="border border-emerald-600/60 p-2 rounded-lg bg-emerald-500/10">
|
||||
<MailCheck className="w-6 h-6 text-green" />
|
||||
<MailCheck className="text-green h-6 w-6" />
|
||||
// </div>
|
||||
);
|
||||
case "BOUNCED":
|
||||
case "FAILED":
|
||||
case 'BOUNCED':
|
||||
case 'FAILED':
|
||||
return (
|
||||
// <div className="border border-red-600/60 p-2 rounded-lg bg-red-500/10">
|
||||
<MailX className="w-6 h-6 text-red" />
|
||||
<MailX className="text-red h-6 w-6" />
|
||||
// </div>
|
||||
);
|
||||
case "CLICKED":
|
||||
case 'CLICKED':
|
||||
return (
|
||||
// <div className="border border-cyan-600/60 p-2 rounded-lg bg-cyan-500/10">
|
||||
<MailSearch className="w-6 h-6 text-blue" />
|
||||
<MailSearch className="text-blue h-6 w-6" />
|
||||
// </div>
|
||||
);
|
||||
case "OPENED":
|
||||
case 'OPENED':
|
||||
return (
|
||||
// <div className="border border-indigo-600/60 p-2 rounded-lg bg-indigo-500/10">
|
||||
<MailOpen className="w-6 h-6 text-purple" />
|
||||
<MailOpen className="text-purple h-6 w-6" />
|
||||
// </div>
|
||||
);
|
||||
case "DELIVERY_DELAYED":
|
||||
case "COMPLAINED":
|
||||
case 'DELIVERY_DELAYED':
|
||||
case 'COMPLAINED':
|
||||
return (
|
||||
// <div className="border border-yellow-600/60 p-2 rounded-lg bg-yellow-500/10">
|
||||
<MailWarning className="w-6 h-6 text-yellow" />
|
||||
<MailWarning className="text-yellow h-6 w-6" />
|
||||
// </div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
// <div className="border border-gray-400/60 p-2 rounded-lg">
|
||||
<Mail className="w-6 h-6" />
|
||||
<Mail className="h-6 w-6" />
|
||||
// </div>
|
||||
);
|
||||
}
|
||||
|
@@ -1,39 +1,39 @@
|
||||
import { EmailStatus } from "@prisma/client";
|
||||
import { EmailStatus } from '@prisma/client';
|
||||
|
||||
export const EmailStatusBadge: React.FC<{ status: EmailStatus }> = ({
|
||||
status,
|
||||
}) => {
|
||||
let badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10"; // Default color
|
||||
let badgeColor = 'bg-gray-700/10 text-gray-400 border border-gray-400/10'; // Default color
|
||||
switch (status) {
|
||||
case "DELIVERED":
|
||||
badgeColor = "bg-green/15 text-green border border-green/20";
|
||||
case 'DELIVERED':
|
||||
badgeColor = 'bg-green/15 text-green border border-green/20';
|
||||
break;
|
||||
case "BOUNCED":
|
||||
case "FAILED":
|
||||
badgeColor = "bg-red/15 text-red border border-red/20";
|
||||
case 'BOUNCED':
|
||||
case 'FAILED':
|
||||
badgeColor = 'bg-red/15 text-red border border-red/20';
|
||||
break;
|
||||
case "CLICKED":
|
||||
badgeColor = "bg-blue/15 text-blue border border-blue/20";
|
||||
case 'CLICKED':
|
||||
badgeColor = 'bg-blue/15 text-blue border border-blue/20';
|
||||
break;
|
||||
case "OPENED":
|
||||
badgeColor = "bg-purple/15 text-purple border border-purple/20";
|
||||
case 'OPENED':
|
||||
badgeColor = 'bg-purple/15 text-purple border border-purple/20';
|
||||
break;
|
||||
case "COMPLAINED":
|
||||
badgeColor = "bg-yellow/15 text-yellow border border-yellow/20";
|
||||
case 'COMPLAINED':
|
||||
badgeColor = 'bg-yellow/15 text-yellow border border-yellow/20';
|
||||
break;
|
||||
case "DELIVERY_DELAYED":
|
||||
badgeColor = "bg-yellow/15 text-yellow border border-yellow/20";
|
||||
case 'DELIVERY_DELAYED':
|
||||
badgeColor = 'bg-yellow/15 text-yellow border border-yellow/20';
|
||||
break;
|
||||
|
||||
default:
|
||||
badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10"; // Default color
|
||||
badgeColor = 'bg-gray-700/10 text-gray-400 border border-gray-400/10'; // Default color
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={` text-center w-[130px] rounded capitalize py-1 text-xs ${badgeColor}`}
|
||||
className={`w-[130px] rounded py-1 text-center text-xs capitalize ${badgeColor}`}
|
||||
>
|
||||
{status.toLowerCase().split("_").join(" ")}
|
||||
{status.toLowerCase().split('_').join(' ')}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -41,44 +41,44 @@ export const EmailStatusBadge: React.FC<{ status: EmailStatus }> = ({
|
||||
export const EmailStatusIcon: React.FC<{ status: EmailStatus }> = ({
|
||||
status,
|
||||
}) => {
|
||||
let outsideColor = "bg-gray/30"; // Default
|
||||
let insideColor = "bg-gray"; // Default
|
||||
let outsideColor = 'bg-gray/30'; // Default
|
||||
let insideColor = 'bg-gray'; // Default
|
||||
|
||||
switch (status) {
|
||||
case "DELIVERED":
|
||||
outsideColor = "bg-green/30";
|
||||
insideColor = "bg-green";
|
||||
case 'DELIVERED':
|
||||
outsideColor = 'bg-green/30';
|
||||
insideColor = 'bg-green';
|
||||
break;
|
||||
case "BOUNCED":
|
||||
case "FAILED":
|
||||
outsideColor = "bg-red/30";
|
||||
insideColor = "bg-red";
|
||||
case 'BOUNCED':
|
||||
case 'FAILED':
|
||||
outsideColor = 'bg-red/30';
|
||||
insideColor = 'bg-red';
|
||||
break;
|
||||
case "CLICKED":
|
||||
outsideColor = "bg-blue/30";
|
||||
insideColor = "bg-blue";
|
||||
case 'CLICKED':
|
||||
outsideColor = 'bg-blue/30';
|
||||
insideColor = 'bg-blue';
|
||||
break;
|
||||
case "OPENED":
|
||||
outsideColor = "bg-purple/30";
|
||||
insideColor = "bg-purple";
|
||||
case 'OPENED':
|
||||
outsideColor = 'bg-purple/30';
|
||||
insideColor = 'bg-purple';
|
||||
break;
|
||||
case "DELIVERY_DELAYED":
|
||||
outsideColor = "bg-yellow/30";
|
||||
insideColor = "bg-yellow";
|
||||
case 'DELIVERY_DELAYED':
|
||||
outsideColor = 'bg-yellow/30';
|
||||
insideColor = 'bg-yellow';
|
||||
break;
|
||||
case "COMPLAINED":
|
||||
outsideColor = "bg-yellow/30";
|
||||
insideColor = "bg-yellow";
|
||||
case 'COMPLAINED':
|
||||
outsideColor = 'bg-yellow/30';
|
||||
insideColor = 'bg-yellow';
|
||||
break;
|
||||
default:
|
||||
// Using the default values defined above
|
||||
outsideColor = "bg-gray/30";
|
||||
insideColor = "bg-gray";
|
||||
outsideColor = 'bg-gray/30';
|
||||
insideColor = 'bg-gray';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex justify-center items-center p-1.5 ${outsideColor} rounded-full`}
|
||||
className={`flex items-center justify-center p-1.5 ${outsideColor} rounded-full`}
|
||||
>
|
||||
<div className={`h-2 w-2 rounded-full ${insideColor}`}></div>
|
||||
</div>
|
||||
|
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import EmailList from "./email-list";
|
||||
import { H1 } from "@usesend/ui";
|
||||
import EmailList from './email-list';
|
||||
import { H1 } from '@usesend/ui';
|
||||
|
||||
export default function EmailsPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<H1>Emails</H1>
|
||||
</div>
|
||||
<EmailList />
|
||||
|
@@ -1,8 +1,8 @@
|
||||
import { DashboardProvider } from "~/providers/dashboard-provider";
|
||||
import { NextAuthProvider } from "~/providers/next-auth";
|
||||
import { DashboardLayout } from "./dasboard-layout";
|
||||
import { DashboardProvider } from '~/providers/dashboard-provider';
|
||||
import { NextAuthProvider } from '~/providers/next-auth';
|
||||
import { DashboardLayout } from './dasboard-layout';
|
||||
|
||||
export const dynamic = "force-static";
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export default function AuthenticatedDashboardLayout({
|
||||
children,
|
||||
|
@@ -1,22 +1,22 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import Spinner from "@usesend/ui/src/spinner";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { api } from "~/trpc/react";
|
||||
import { H1 } from "@usesend/ui";
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import Spinner from '@usesend/ui/src/spinner';
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { api } from '~/trpc/react';
|
||||
import { H1 } from '@usesend/ui';
|
||||
|
||||
export default function PaymentsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const success = searchParams.get("success");
|
||||
const canceled = searchParams.get("canceled");
|
||||
const success = searchParams.get('success');
|
||||
const canceled = searchParams.get('canceled');
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-10">
|
||||
<H1>Payment {success ? "Success" : canceled ? "Canceled" : "Unknown"}</H1>
|
||||
<H1>Payment {success ? 'Success' : canceled ? 'Canceled' : 'Unknown'}</H1>
|
||||
{canceled ? (
|
||||
<Link href="/settings/billing">
|
||||
<Button>Go to billing</Button>
|
||||
@@ -32,11 +32,11 @@ function VerifySuccess() {
|
||||
refetchInterval: 3000,
|
||||
});
|
||||
|
||||
if (teams?.[0]?.plan !== "FREE") {
|
||||
if (teams?.[0]?.plan !== 'FREE') {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<CheckCircle2 className="h-4 w-4 text-green flex-shrink-0" />
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="text-green h-4 w-4 flex-shrink-0" />
|
||||
<p>Your account has been upgraded to the paid plan.</p>
|
||||
</div>
|
||||
<Link href="/settings/billing" className="mt-8">
|
||||
@@ -47,9 +47,9 @@ function VerifySuccess() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner
|
||||
className="h-5 w-5 stroke-muted-foreground"
|
||||
className="stroke-muted-foreground h-5 w-5"
|
||||
innerSvgClass=" stroke-muted-foreground"
|
||||
/>
|
||||
<p className="text-muted-foreground">Verifying payment</p>
|
||||
|
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Card } from "@usesend/ui/src/card";
|
||||
import { Spinner } from "@usesend/ui/src/spinner";
|
||||
import { format } from "date-fns";
|
||||
import { useTeam } from "~/providers/team-context";
|
||||
import { api } from "~/trpc/react";
|
||||
import { PlanDetails } from "~/components/payments/PlanDetails";
|
||||
import { UpgradeButton } from "~/components/payments/UpgradeButton";
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import { Card } from '@usesend/ui/src/card';
|
||||
import { Spinner } from '@usesend/ui/src/spinner';
|
||||
import { format } from 'date-fns';
|
||||
import { useTeam } from '~/providers/team-context';
|
||||
import { api } from '~/trpc/react';
|
||||
import { PlanDetails } from '~/components/payments/PlanDetails';
|
||||
import { UpgradeButton } from '~/components/payments/UpgradeButton';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { currentTeam, currentIsAdmin } = useTeam();
|
||||
@@ -19,7 +19,7 @@ export default function SettingsPage() {
|
||||
const { data: subscription } = api.billing.getSubscriptionDetails.useQuery();
|
||||
const [isEditingEmail, setIsEditingEmail] = useState(false);
|
||||
const [billingEmail, setBillingEmail] = useState(
|
||||
currentTeam?.billingEmail || "",
|
||||
currentTeam?.billingEmail || '',
|
||||
);
|
||||
|
||||
const apiUtils = api.useUtils();
|
||||
@@ -32,7 +32,7 @@ export default function SettingsPage() {
|
||||
};
|
||||
|
||||
const handleEditEmail = () => {
|
||||
setBillingEmail(currentTeam?.billingEmail || "");
|
||||
setBillingEmail(currentTeam?.billingEmail || '');
|
||||
setIsEditingEmail(true);
|
||||
};
|
||||
|
||||
@@ -42,12 +42,12 @@ export default function SettingsPage() {
|
||||
await apiUtils.team.getTeams.invalidate();
|
||||
setIsEditingEmail(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to update billing email:", error);
|
||||
console.error('Failed to update billing email:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const paymentMethod =
|
||||
subscription?.paymentMethod && subscription.paymentMethod !== "null"
|
||||
subscription?.paymentMethod && subscription.paymentMethod !== 'null'
|
||||
? JSON.parse(subscription.paymentMethod)
|
||||
: {};
|
||||
|
||||
@@ -57,27 +57,27 @@ export default function SettingsPage() {
|
||||
|
||||
if (!currentTeam?.plan) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-full">
|
||||
<Spinner className="w-4 h-4" />
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spinner className="h-4 w-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Card className=" rounded-xl mt-10 p-8 px-8">
|
||||
<Card className="mt-10 rounded-xl p-8 px-8">
|
||||
<PlanDetails />
|
||||
<div className="mt-4">
|
||||
{currentTeam?.plan !== "FREE" ? (
|
||||
{currentTeam?.plan !== 'FREE' ? (
|
||||
<Button
|
||||
onClick={onManageClick}
|
||||
className="mt-4 w-[120px]"
|
||||
disabled={manageSessionUrl.isPending}
|
||||
>
|
||||
{manageSessionUrl.isPending ? (
|
||||
<Spinner className="w-4 h-4" />
|
||||
<Spinner className="h-4 w-4" />
|
||||
) : (
|
||||
"Manage"
|
||||
'Manage'
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
@@ -85,44 +85,44 @@ export default function SettingsPage() {
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mt-8">
|
||||
<div className="mt-8 grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||
<Card className="p-6">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Payment Method</div>
|
||||
<div className="text-muted-foreground text-sm">Payment Method</div>
|
||||
{subscription ? (
|
||||
<div className="mt-2">
|
||||
<div className="text-lg font-mono uppercase flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 font-mono text-lg uppercase">
|
||||
{subscription.paymentMethod &&
|
||||
subscription.paymentMethod !== "null" ? (
|
||||
subscription.paymentMethod !== 'null' ? (
|
||||
<>
|
||||
<span>💳</span>
|
||||
<span className="capitalize">
|
||||
{paymentMethod?.card?.brand || ""} ••••{" "}
|
||||
{paymentMethod?.card?.last4 || ""}
|
||||
{paymentMethod?.card?.brand || ''} ••••{' '}
|
||||
{paymentMethod?.card?.last4 || ''}
|
||||
</span>
|
||||
{paymentMethod?.card && (
|
||||
<span className="text-sm text-muted-foreground lowercase">
|
||||
<span className="text-muted-foreground text-sm lowercase">
|
||||
(Expires: {paymentMethod.card.exp_month}/
|
||||
{paymentMethod.card.exp_year})
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
"No Payment Method"
|
||||
'No Payment Method'
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
Next billing date:{" "}
|
||||
<div className="text-muted-foreground mt-1 text-sm">
|
||||
Next billing date:{' '}
|
||||
{subscription.currentPeriodEnd
|
||||
? format(
|
||||
new Date(subscription.currentPeriodEnd),
|
||||
"MMM dd, yyyy",
|
||||
'MMM dd, yyyy',
|
||||
)
|
||||
: "N/A"}
|
||||
: 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground mt-2">
|
||||
<div className="text-muted-foreground mt-2 text-sm">
|
||||
No active subscription
|
||||
</div>
|
||||
)}
|
||||
@@ -131,7 +131,7 @@ export default function SettingsPage() {
|
||||
|
||||
<Card className="p-6">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Billing Email</div>
|
||||
<div className="text-muted-foreground text-sm">Billing Email</div>
|
||||
{isEditingEmail ? (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -139,7 +139,7 @@ export default function SettingsPage() {
|
||||
type="email"
|
||||
value={billingEmail}
|
||||
onChange={(e) => setBillingEmail(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Enter billing email"
|
||||
/>
|
||||
<Button
|
||||
@@ -148,9 +148,9 @@ export default function SettingsPage() {
|
||||
size="sm"
|
||||
>
|
||||
{updateBillingEmailMutation.isPending ? (
|
||||
<Spinner className="w-4 h-4" />
|
||||
<Spinner className="h-4 w-4" />
|
||||
) : (
|
||||
"Save"
|
||||
'Save'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -166,7 +166,7 @@ export default function SettingsPage() {
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-mono">
|
||||
{currentTeam?.billingEmail || "No billing email set"}
|
||||
{currentTeam?.billingEmail || 'No billing email set'}
|
||||
</div>
|
||||
<Button onClick={handleEditEmail} variant="default" size="sm">
|
||||
Edit
|
||||
|
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useTeam } from "~/providers/team-context";
|
||||
import { SettingsNavButton } from "../dev-settings/settings-nav-button";
|
||||
import { isCloud } from "~/utils/common";
|
||||
import { useTeam } from '~/providers/team-context';
|
||||
import { SettingsNavButton } from '../dev-settings/settings-nav-button';
|
||||
import { isCloud } from '~/utils/common';
|
||||
|
||||
export const dynamic = "force-static";
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export default function ApiKeysPage({
|
||||
children,
|
||||
@@ -15,8 +15,8 @@ export default function ApiKeysPage({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="font-bold text-lg">Settings</h1>
|
||||
<div className="flex gap-4 mt-4">
|
||||
<h1 className="text-lg font-bold">Settings</h1>
|
||||
<div className="mt-4 flex gap-4">
|
||||
{isCloud() ? (
|
||||
<SettingsNavButton href="/settings">Usage</SettingsNavButton>
|
||||
) : null}
|
||||
|
@@ -1,16 +1,16 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { isCloud } from "~/utils/common";
|
||||
import UsagePage from "./usage/usage";
|
||||
import InviteTeamMember from "./team/invite-team-member";
|
||||
import TeamMembersList from "./team/team-members-list";
|
||||
import { isCloud } from '~/utils/common';
|
||||
import UsagePage from './usage/usage';
|
||||
import InviteTeamMember from './team/invite-team-member';
|
||||
import TeamMembersList from './team/team-members-list';
|
||||
|
||||
export default function SettingsPage() {
|
||||
if (!isCloud()) {
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<div className="flex justify-end ">
|
||||
<div className="flex justify-end">
|
||||
<InviteTeamMember />
|
||||
</div>
|
||||
<TeamMembersList />
|
||||
|
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { Trash2 } from "lucide-react";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
import { api } from '~/trpc/react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
|
||||
export const DeleteTeamInvite: React.FC<{
|
||||
invite: { id: string; email: string };
|
||||
@@ -31,7 +31,7 @@ export const DeleteTeamInvite: React.FC<{
|
||||
onSuccess: async () => {
|
||||
utils.team.getTeamInvites.invalidate();
|
||||
setOpen(false);
|
||||
toast.success("Invite cancelled successfully");
|
||||
toast.success('Invite cancelled successfully');
|
||||
},
|
||||
onError: async (error) => {
|
||||
toast.error(error.message);
|
||||
@@ -47,21 +47,21 @@ export const DeleteTeamInvite: React.FC<{
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Trash2 className="h-4 w-4 text-red/80" />
|
||||
<Trash2 className="text-red/80 h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cancel Invite</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to cancel the invite for{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
Are you sure you want to cancel the invite for{' '}
|
||||
<span className="text-foreground font-semibold">
|
||||
{invite.email}
|
||||
</span>
|
||||
?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-4 mt-6">
|
||||
<div className="mt-6 flex justify-end gap-4">
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { Role } from "@prisma/client";
|
||||
import { LogOut, Trash2 } from "lucide-react";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
import { api } from '~/trpc/react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import { Role } from '@prisma/client';
|
||||
import { LogOut, Trash2 } from 'lucide-react';
|
||||
|
||||
export const DeleteTeamMember: React.FC<{
|
||||
teamUser: { userId: string; role: Role; email: string };
|
||||
@@ -33,7 +33,7 @@ export const DeleteTeamMember: React.FC<{
|
||||
onSuccess: async () => {
|
||||
utils.team.getTeamUsers.invalidate();
|
||||
setOpen(false);
|
||||
toast.success("Team member removed successfully");
|
||||
toast.success('Team member removed successfully');
|
||||
},
|
||||
onError: async (error) => {
|
||||
toast.error(error.message);
|
||||
@@ -50,24 +50,24 @@ export const DeleteTeamMember: React.FC<{
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
{self ? (
|
||||
<LogOut className="h-4 w-4 text-red/80" />
|
||||
<LogOut className="text-red/80 h-4 w-4" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4 text-red/80" />
|
||||
<Trash2 className="text-red/80 h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{self ? "Leave Team" : "Remove Team Member"}
|
||||
{self ? 'Leave Team' : 'Remove Team Member'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{self
|
||||
? "Are you sure you want to leave the team? This action cannot be undone."
|
||||
? 'Are you sure you want to leave the team? This action cannot be undone.'
|
||||
: `Are you sure you want to remove ${teamUser.email} from the team? This action cannot be undone.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-4 mt-6">
|
||||
<div className="mt-6 flex justify-end gap-4">
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -77,7 +77,7 @@ export const DeleteTeamMember: React.FC<{
|
||||
isLoading={deleteTeamUserMutation.isPending}
|
||||
className="w-[150px]"
|
||||
>
|
||||
{self ? "Leave" : "Remove"}
|
||||
{self ? 'Leave' : 'Remove'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -15,26 +15,26 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@usesend/ui/src/form";
|
||||
} from '@usesend/ui/src/form';
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { useState } from "react";
|
||||
import { PencilIcon } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { Role } from "@prisma/client";
|
||||
import { api } from '~/trpc/react';
|
||||
import { useState } from 'react';
|
||||
import { PencilIcon } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import { Role } from '@prisma/client';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@usesend/ui/src/select";
|
||||
} from '@usesend/ui/src/select';
|
||||
|
||||
const teamUserSchema = z.object({
|
||||
role: z.enum(["MEMBER", "ADMIN"]),
|
||||
role: z.enum(['MEMBER', 'ADMIN']),
|
||||
});
|
||||
|
||||
export const EditTeamMember: React.FC<{
|
||||
@@ -62,12 +62,12 @@ export const EditTeamMember: React.FC<{
|
||||
onSuccess: async () => {
|
||||
utils.team.getTeamUsers.invalidate();
|
||||
setOpen(false);
|
||||
toast.success("Team member role updated successfully");
|
||||
toast.success('Team member role updated successfully');
|
||||
},
|
||||
onError: async (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -1,28 +1,28 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import { PlusIcon } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@usesend/ui/src/select";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
} from '@usesend/ui/src/select';
|
||||
import { Input } from '@usesend/ui/src/input';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { api } from '~/trpc/react';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -31,18 +31,18 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@usesend/ui/src/form";
|
||||
import { useTeam } from "~/providers/team-context";
|
||||
import { isCloud, isSelfHosted } from "~/utils/common";
|
||||
import { useUpgradeModalStore } from "~/store/upgradeModalStore";
|
||||
import { LimitReason } from "~/lib/constants/plans";
|
||||
} from '@usesend/ui/src/form';
|
||||
import { useTeam } from '~/providers/team-context';
|
||||
import { isCloud, isSelfHosted } from '~/utils/common';
|
||||
import { useUpgradeModalStore } from '~/store/upgradeModalStore';
|
||||
import { LimitReason } from '~/lib/constants/plans';
|
||||
|
||||
const inviteTeamMemberSchema = z.object({
|
||||
email: z
|
||||
.string({ required_error: "Email is required" })
|
||||
.email("Invalid email address"),
|
||||
role: z.enum(["ADMIN", "MEMBER"], {
|
||||
required_error: "Please select a role",
|
||||
.string({ required_error: 'Email is required' })
|
||||
.email('Invalid email address'),
|
||||
role: z.enum(['ADMIN', 'MEMBER'], {
|
||||
required_error: 'Please select a role',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -62,8 +62,8 @@ export default function InviteTeamMember() {
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(inviteTeamMemberSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
role: "MEMBER",
|
||||
email: '',
|
||||
role: 'MEMBER',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -88,11 +88,11 @@ export default function InviteTeamMember() {
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
void utils.team.getTeamInvites.invalidate();
|
||||
toast.success("Invitation sent successfully");
|
||||
toast.success('Invitation sent successfully');
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
toast.error(error.message || "Failed to send invitation");
|
||||
toast.error(error.message || 'Failed to send invitation');
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -106,8 +106,8 @@ export default function InviteTeamMember() {
|
||||
|
||||
createInvite.mutate(
|
||||
{
|
||||
email: form.getValues("email"),
|
||||
role: form.getValues("role"),
|
||||
email: form.getValues('email'),
|
||||
role: form.getValues('role'),
|
||||
sendEmail: false,
|
||||
},
|
||||
{
|
||||
@@ -118,11 +118,11 @@ export default function InviteTeamMember() {
|
||||
);
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
toast.success("Invitation link copied to clipboard");
|
||||
toast.success('Invitation link copied to clipboard');
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
toast.error(error.message || "Failed to copy invitation link");
|
||||
toast.error(error.message || 'Failed to copy invitation link');
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -152,7 +152,7 @@ export default function InviteTeamMember() {
|
||||
Invite Member
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className=" max-w-lg">
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Invite Team Member</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -197,13 +197,13 @@ export default function InviteTeamMember() {
|
||||
<SelectContent>
|
||||
<SelectItem value="ADMIN">
|
||||
<div>Admin</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Manage users, update payments
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="MEMBER">
|
||||
<div>Member</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Manage emails, domains and contacts
|
||||
</div>
|
||||
</SelectItem>
|
||||
@@ -214,8 +214,8 @@ export default function InviteTeamMember() {
|
||||
)}
|
||||
/>
|
||||
{isSelfHosted() && domains?.length ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Will use{" "}
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Will use{' '}
|
||||
<span className="font-bold">hello@{domains[0]?.name}</span> to
|
||||
send invitation
|
||||
</div>
|
||||
|
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import InviteTeamMember from "./invite-team-member";
|
||||
import TeamMembersList from "./team-members-list";
|
||||
import InviteTeamMember from './invite-team-member';
|
||||
import TeamMembersList from './team-members-list';
|
||||
|
||||
export default function TeamsPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-end ">
|
||||
<div className="flex justify-end">
|
||||
<InviteTeamMember />
|
||||
</div>
|
||||
<TeamMembersList />
|
||||
|
@@ -1,16 +1,16 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { Copy, RotateCw } from "lucide-react";
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import { api } from '~/trpc/react';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import { Copy, RotateCw } from 'lucide-react';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@usesend/ui/src/tooltip";
|
||||
import { isSelfHosted } from "~/utils/common";
|
||||
} from '@usesend/ui/src/tooltip';
|
||||
import { isSelfHosted } from '~/utils/common';
|
||||
|
||||
export const ResendTeamInvite: React.FC<{
|
||||
invite: { id: string; email: string };
|
||||
@@ -29,7 +29,7 @@ export const ResendTeamInvite: React.FC<{
|
||||
onError: async (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export const ResendTeamInvite: React.FC<{
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${location.origin}/join-team?inviteId=${invite.id}`
|
||||
`${location.origin}/join-team?inviteId=${invite.id}`,
|
||||
);
|
||||
toast.success(`Invite link copied to clipboard`);
|
||||
}}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Table,
|
||||
@@ -7,18 +7,18 @@ import {
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableCell,
|
||||
} from "@usesend/ui/src/table";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import Spinner from "@usesend/ui/src/spinner";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Role } from "@prisma/client";
|
||||
import { EditTeamMember } from "./edit-team-member";
|
||||
import { DeleteTeamMember } from "./delete-team-member";
|
||||
import { ResendTeamInvite } from "./resend-team-invite";
|
||||
import { DeleteTeamInvite } from "./delete-team-invite";
|
||||
import { useTeam } from "~/providers/team-context";
|
||||
import { useSession } from "next-auth/react";
|
||||
} from '@usesend/ui/src/table';
|
||||
import { api } from '~/trpc/react';
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import Spinner from '@usesend/ui/src/spinner';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Role } from '@prisma/client';
|
||||
import { EditTeamMember } from './edit-team-member';
|
||||
import { DeleteTeamMember } from './delete-team-member';
|
||||
import { ResendTeamInvite } from './resend-team-invite';
|
||||
import { DeleteTeamInvite } from './delete-team-invite';
|
||||
import { useTeam } from '~/providers/team-context';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
export default function TeamMembersList() {
|
||||
const { currentIsAdmin } = useTeam();
|
||||
@@ -34,7 +34,7 @@ export default function TeamMembersList() {
|
||||
|
||||
return (
|
||||
<div className="mt-10 flex flex-col gap-4">
|
||||
<div className="flex flex-col rounded-xl border border-border shadow">
|
||||
<div className="border-border flex flex-col rounded-xl border shadow">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/30">
|
||||
@@ -48,9 +48,9 @@ export default function TeamMembersList() {
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={5} className="text-center py-4">
|
||||
<TableCell colSpan={5} className="py-4 text-center">
|
||||
<Spinner
|
||||
className="w-6 h-6 mx-auto"
|
||||
className="mx-auto h-6 w-6"
|
||||
innerSvgClass="stroke-primary"
|
||||
/>
|
||||
</TableCell>
|
||||
@@ -59,15 +59,15 @@ export default function TeamMembersList() {
|
||||
teamMembers.map((member) => (
|
||||
<TableRow key={member.userId} className="">
|
||||
<TableCell className="font-medium">
|
||||
{member.user?.email || "Unknown user"}
|
||||
{member.user?.email || 'Unknown user'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className=" rounded capitalize py-1 text-xs">
|
||||
<div className="rounded py-1 text-xs capitalize">
|
||||
{member.role.toLowerCase()}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-center w-[100px] rounded capitalize py-1 text-xs bg-green/15 text-green border border-green/25">
|
||||
<div className="bg-green/15 text-green border-green/25 w-[100px] rounded border py-1 text-center text-xs capitalize">
|
||||
Active
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -91,7 +91,7 @@ export default function TeamMembersList() {
|
||||
teamUser={{
|
||||
userId: String(member.userId),
|
||||
role: member.role,
|
||||
email: member.user?.email || "Unknown user",
|
||||
email: member.user?.email || 'Unknown user',
|
||||
}}
|
||||
self={session?.user.id == member.userId}
|
||||
/>
|
||||
@@ -102,7 +102,7 @@ export default function TeamMembersList() {
|
||||
))
|
||||
) : (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={5} className="text-center py-4">
|
||||
<TableCell colSpan={5} className="py-4 text-center">
|
||||
No team members found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -117,12 +117,12 @@ export default function TeamMembersList() {
|
||||
{invite.email}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className=" w-[100px] rounded capitalize py-1 text-xs">
|
||||
<div className="w-[100px] rounded py-1 text-xs capitalize">
|
||||
{invite.role.toLowerCase()}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-center w-[100px] rounded capitalize py-1 text-xs bg-yellow/15 text-yellow border border-yellow/25">
|
||||
<div className="bg-yellow/15 text-yellow border-yellow/25 w-[100px] rounded border py-1 text-center text-xs capitalize">
|
||||
Pending
|
||||
</div>
|
||||
</TableCell>
|
||||
|
@@ -1,20 +1,20 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { Card } from "@usesend/ui/src/card";
|
||||
import Spinner from "@usesend/ui/src/spinner";
|
||||
import { format } from "date-fns";
|
||||
import { api } from '~/trpc/react';
|
||||
import { Card } from '@usesend/ui/src/card';
|
||||
import Spinner from '@usesend/ui/src/spinner';
|
||||
import { format } from 'date-fns';
|
||||
import {
|
||||
getCost,
|
||||
PLAN_CREDIT_UNITS,
|
||||
UNIT_PRICE,
|
||||
USAGE_UNIT_PRICE,
|
||||
} from "~/lib/usage";
|
||||
import { useTeam } from "~/providers/team-context";
|
||||
import { EmailUsageType } from "@prisma/client";
|
||||
import { PlanDetails } from "~/components/payments/PlanDetails";
|
||||
import { UpgradeButton } from "~/components/payments/UpgradeButton";
|
||||
import { Progress } from "@usesend/ui/src/progress";
|
||||
} from '~/lib/usage';
|
||||
import { useTeam } from '~/providers/team-context';
|
||||
import { EmailUsageType } from '@prisma/client';
|
||||
import { PlanDetails } from '~/components/payments/PlanDetails';
|
||||
import { UpgradeButton } from '~/components/payments/UpgradeButton';
|
||||
import { Progress } from '@usesend/ui/src/progress';
|
||||
|
||||
const FREE_PLAN_LIMIT = 3000;
|
||||
|
||||
@@ -36,20 +36,20 @@ function FreePlanUsage({
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex w-full">
|
||||
<div className="space-y-4 w-full">
|
||||
<div className="w-full space-y-4">
|
||||
{usage?.map((item) => (
|
||||
<div
|
||||
key={item.type}
|
||||
className="flex justify-between items-center border-b pb-3 last:border-0 last:pb-0"
|
||||
className="flex items-center justify-between border-b pb-3 last:border-0 last:pb-0"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium capitalize">
|
||||
{item.type.toLowerCase()}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
{item.type === "TRANSACTIONAL"
|
||||
? "Mails sent using the send api or SMTP"
|
||||
: "Mails designed sent from useSend editor"}
|
||||
<div className="text-muted-foreground mt-1 text-sm">
|
||||
{item.type === 'TRANSACTIONAL'
|
||||
? 'Mails sent using the send api or SMTP'
|
||||
: 'Mails designed sent from useSend editor'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-mono font-medium">
|
||||
@@ -57,30 +57,30 @@ function FreePlanUsage({
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between items-center pt-3 ">
|
||||
<div className="flex items-center justify-between pt-3">
|
||||
<div className="font-medium">Total</div>
|
||||
<div className="font-mono font-medium">
|
||||
{usage
|
||||
?.reduce((acc, item) => acc + item.sent, 0)
|
||||
.toLocaleString()}{" "}
|
||||
.toLocaleString()}{' '}
|
||||
emails
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex justify-center items-center">
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<div className="w-[300px] space-y-8">
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="">Monthly Limit</div>
|
||||
<div className="font-mono font-medium">
|
||||
{totalSent.toLocaleString()}/
|
||||
{FREE_PLAN_LIMIT.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 bg-secondary rounded-full overflow-hidden">
|
||||
<div className="bg-secondary h-2 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300 ease-in-out"
|
||||
className="bg-primary h-full transition-all duration-300 ease-in-out"
|
||||
style={{
|
||||
width: `${Math.min(monthlyPercentageUsed, 100)}%`,
|
||||
}}
|
||||
@@ -91,15 +91,15 @@ function FreePlanUsage({
|
||||
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="">Daily Limit</div>
|
||||
<div className="font-mono">
|
||||
{dailyUsage.toLocaleString()}/{DAILY_LIMIT.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 bg-secondary rounded-full overflow-hidden">
|
||||
<div className="bg-secondary h-2 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300 ease-in-out"
|
||||
className="bg-primary h-full transition-all duration-300 ease-in-out"
|
||||
style={{ width: `${Math.min(dailyPercentageUsed, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -119,7 +119,7 @@ function PaidPlanUsage({
|
||||
}) {
|
||||
const { currentTeam } = useTeam();
|
||||
|
||||
if (currentTeam?.plan === "FREE") return null;
|
||||
if (currentTeam?.plan === 'FREE') return null;
|
||||
|
||||
const totalCost =
|
||||
usage?.reduce((acc, item) => acc + getCost(item.sent, item.type), 0) || 0;
|
||||
@@ -128,24 +128,24 @@ function PaidPlanUsage({
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex w-full">
|
||||
<div className="space-y-4 w-full">
|
||||
<div className="w-full space-y-4">
|
||||
{usage?.map((item) => (
|
||||
<div
|
||||
key={item.type}
|
||||
className="flex justify-between items-center border-b pb-3 last:border-0 last:pb-0"
|
||||
className="flex items-center justify-between border-b pb-3 last:border-0 last:pb-0"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium capitalize">
|
||||
{item.type.toLowerCase()}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
<div className="text-muted-foreground mt-1 text-sm">
|
||||
<span className="font-mono">
|
||||
{item.sent.toLocaleString()}
|
||||
</span>{" "}
|
||||
emails at{" "}
|
||||
</span>{' '}
|
||||
emails at{' '}
|
||||
<span className="font-mono">
|
||||
${USAGE_UNIT_PRICE[item.type]}
|
||||
</span>{" "}
|
||||
</span>{' '}
|
||||
each
|
||||
</div>
|
||||
</div>
|
||||
@@ -155,16 +155,16 @@ function PaidPlanUsage({
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<div className="flex justify-between items-center border-b pb-3 last:border-0 last:pb-0">
|
||||
<div className="flex items-center justify-between border-b pb-3 last:border-0 last:pb-0">
|
||||
<div>
|
||||
<div className="font-medium capitalize">Available credit</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
<div className="text-muted-foreground mt-1 text-sm">
|
||||
{currentTeam?.plan}
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-mono font-medium">
|
||||
{totalCost > planCreditCost
|
||||
? "0"
|
||||
? '0'
|
||||
: `$${(planCreditCost - totalCost).toFixed(2)}`}
|
||||
</div>
|
||||
</div>
|
||||
@@ -173,11 +173,11 @@ function PaidPlanUsage({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex justify-center items-center">
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<div>
|
||||
<div className="font-medium">Amount Due</div>
|
||||
<div className="">
|
||||
<div className="text-2xl font-mono">
|
||||
<div className="font-mono text-2xl">
|
||||
{planCreditCost < totalCost
|
||||
? `$${(totalCost - planCreditCost).toFixed(2)}`
|
||||
: `$${(0.0).toFixed(2)}`}
|
||||
@@ -200,8 +200,8 @@ export default function UsagePage() {
|
||||
const today = new Date();
|
||||
const billingPeriod =
|
||||
subscription?.currentPeriodStart && subscription?.currentPeriodEnd
|
||||
? `${format(new Date(subscription.currentPeriodStart), "MMM dd")} - ${format(new Date(subscription.currentPeriodEnd), "MMM dd")}`
|
||||
: `${format(new Date(today.getFullYear(), today.getMonth(), 1), "MMM dd")} - ${format(new Date(today.getFullYear(), today.getMonth() + 1, 1), "MMM dd")}`;
|
||||
? `${format(new Date(subscription.currentPeriodStart), 'MMM dd')} - ${format(new Date(subscription.currentPeriodEnd), 'MMM dd')}`
|
||||
: `${format(new Date(today.getFullYear(), today.getMonth(), 1), 'MMM dd')} - ${format(new Date(today.getFullYear(), today.getMonth() + 1, 1), 'MMM dd')}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -209,7 +209,7 @@ export default function UsagePage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Usage</h1>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
<div className="text-muted-foreground mt-1 text-sm">
|
||||
<span className="font-medium">{billingPeriod}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,13 +217,13 @@ export default function UsagePage() {
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Spinner className="w-8 h-8" innerSvgClass="stroke-primary" />
|
||||
<Spinner className="h-8 w-8" innerSvgClass="stroke-primary" />
|
||||
</div>
|
||||
) : usage?.month.length === 0 ? (
|
||||
<Card className="p-6 text-center text-muted-foreground">
|
||||
<Card className="text-muted-foreground p-6 text-center">
|
||||
No usage data available
|
||||
</Card>
|
||||
) : currentTeam?.plan === "FREE" ? (
|
||||
) : currentTeam?.plan === 'FREE' ? (
|
||||
<FreePlanUsage
|
||||
usage={usage?.month ?? []}
|
||||
dayUsage={usage?.day ?? []}
|
||||
@@ -233,10 +233,10 @@ export default function UsagePage() {
|
||||
)}
|
||||
</div>
|
||||
{currentTeam?.plan ? (
|
||||
<Card className=" rounded-xl mt-10 p-4 px-8">
|
||||
<Card className="mt-10 rounded-xl p-4 px-8">
|
||||
<PlanDetails />
|
||||
<div className="mt-4">
|
||||
{currentTeam?.plan === "FREE" ? <UpgradeButton /> : null}
|
||||
{currentTeam?.plan === 'FREE' ? <UpgradeButton /> : null}
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { SuppressionReason } from "@prisma/client";
|
||||
import { useState } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { SuppressionReason } from '@prisma/client';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,17 +10,17 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { Label } from "@usesend/ui/src/label";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import { Input } from '@usesend/ui/src/input';
|
||||
import { Label } from '@usesend/ui/src/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@usesend/ui/src/select";
|
||||
} from '@usesend/ui/src/select';
|
||||
|
||||
interface AddSuppressionDialogProps {
|
||||
open: boolean;
|
||||
@@ -31,9 +31,9 @@ export default function AddSuppressionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: AddSuppressionDialogProps) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [email, setEmail] = useState('');
|
||||
const [reason, setReason] = useState<SuppressionReason>(
|
||||
SuppressionReason.MANUAL
|
||||
SuppressionReason.MANUAL,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -54,11 +54,11 @@ export default function AddSuppressionDialog({
|
||||
{ email: email.trim() },
|
||||
{
|
||||
enabled: false,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const handleClose = () => {
|
||||
setEmail("");
|
||||
setEmail('');
|
||||
setReason(SuppressionReason.MANUAL);
|
||||
setError(null);
|
||||
onOpenChange(false);
|
||||
@@ -71,14 +71,14 @@ export default function AddSuppressionDialog({
|
||||
const trimmedEmail = email.trim().toLowerCase();
|
||||
|
||||
if (!trimmedEmail) {
|
||||
setError("Email address is required");
|
||||
setError('Email address is required');
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(trimmedEmail)) {
|
||||
setError("Please enter a valid email address");
|
||||
setError('Please enter a valid email address');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export default function AddSuppressionDialog({
|
||||
try {
|
||||
const { data: isAlreadySuppressed } = await checkMutation.refetch();
|
||||
if (isAlreadySuppressed) {
|
||||
setError("This email is already suppressed");
|
||||
setError('This email is already suppressed');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -142,7 +142,7 @@ export default function AddSuppressionDialog({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
|
||||
<div className="text-destructive bg-destructive/10 rounded-md p-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -160,7 +160,7 @@ export default function AddSuppressionDialog({
|
||||
type="submit"
|
||||
disabled={addMutation.isPending || !email.trim()}
|
||||
>
|
||||
{addMutation.isPending ? "Adding..." : "Add Suppression"}
|
||||
{addMutation.isPending ? 'Adding...' : 'Add Suppression'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { SuppressionReason } from "@prisma/client";
|
||||
import { useState } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { SuppressionReason } from '@prisma/client';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,19 +10,19 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Label } from "@usesend/ui/src/label";
|
||||
import { Textarea } from "@usesend/ui/src/textarea";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import { Label } from '@usesend/ui/src/label';
|
||||
import { Textarea } from '@usesend/ui/src/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@usesend/ui/src/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@usesend/ui/src/tabs";
|
||||
import { Upload, FileText } from "lucide-react";
|
||||
} from '@usesend/ui/src/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@usesend/ui/src/tabs';
|
||||
import { Upload, FileText } from 'lucide-react';
|
||||
|
||||
interface BulkAddSuppressionsDialogProps {
|
||||
open: boolean;
|
||||
@@ -33,9 +33,9 @@ export default function BulkAddSuppressionsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: BulkAddSuppressionsDialogProps) {
|
||||
const [emails, setEmails] = useState("");
|
||||
const [emails, setEmails] = useState('');
|
||||
const [reason, setReason] = useState<SuppressionReason>(
|
||||
SuppressionReason.MANUAL
|
||||
SuppressionReason.MANUAL,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
@@ -57,7 +57,7 @@ export default function BulkAddSuppressionsDialog({
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
setEmails("");
|
||||
setEmails('');
|
||||
setReason(SuppressionReason.MANUAL);
|
||||
setError(null);
|
||||
setProcessing(false);
|
||||
@@ -69,7 +69,7 @@ export default function BulkAddSuppressionsDialog({
|
||||
const emailList = text
|
||||
.split(/[\n,;]+/)
|
||||
.map((email) => email.trim().toLowerCase())
|
||||
.filter((email) => email && email.includes("@"));
|
||||
.filter((email) => email && email.includes('@'));
|
||||
|
||||
// Remove duplicates
|
||||
return Array.from(new Set(emailList));
|
||||
@@ -82,8 +82,8 @@ export default function BulkAddSuppressionsDialog({
|
||||
|
||||
const processFile = (file: File) => {
|
||||
// Validate file type
|
||||
if (!file.name.endsWith(".txt") && !file.name.endsWith(".csv")) {
|
||||
setError("Please upload a .txt or .csv file");
|
||||
if (!file.name.endsWith('.txt') && !file.name.endsWith('.csv')) {
|
||||
setError('Please upload a .txt or .csv file');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ export default function BulkAddSuppressionsDialog({
|
||||
setProcessing(true);
|
||||
|
||||
if (!emails.trim()) {
|
||||
setError("Please enter email addresses");
|
||||
setError('Please enter email addresses');
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
@@ -139,7 +139,7 @@ export default function BulkAddSuppressionsDialog({
|
||||
const emailList = parseEmails(emails);
|
||||
|
||||
if (emailList.length === 0) {
|
||||
setError("No valid email addresses found");
|
||||
setError('No valid email addresses found');
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
@@ -147,13 +147,13 @@ export default function BulkAddSuppressionsDialog({
|
||||
const validEmails = validateEmails(emailList);
|
||||
|
||||
if (validEmails.length === 0) {
|
||||
setError("No valid email addresses found");
|
||||
setError('No valid email addresses found');
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (validEmails.length > 1000) {
|
||||
setError("Maximum 1000 email addresses allowed per upload");
|
||||
setError('Maximum 1000 email addresses allowed per upload');
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
@@ -191,11 +191,11 @@ export default function BulkAddSuppressionsDialog({
|
||||
<Tabs defaultValue="text" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="text">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Text Input
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="file">
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
File Upload
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -218,10 +218,10 @@ export default function BulkAddSuppressionsDialog({
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="file">Upload File</Label>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-6 transition-colors ${
|
||||
className={`rounded-lg border-2 border-dashed p-6 transition-colors ${
|
||||
isDragOver
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-muted-foreground/25"
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted-foreground/25'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -238,23 +238,23 @@ export default function BulkAddSuppressionsDialog({
|
||||
<div className="text-center">
|
||||
<Upload
|
||||
className={`mx-auto h-12 w-12 ${
|
||||
isDragOver ? "text-primary" : "text-muted-foreground"
|
||||
isDragOver ? 'text-primary' : 'text-muted-foreground'
|
||||
}`}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => document.getElementById("file")?.click()}
|
||||
onClick={() => document.getElementById('file')?.click()}
|
||||
disabled={processing}
|
||||
>
|
||||
Choose File
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
{isDragOver
|
||||
? "Drop your file here"
|
||||
: "Upload a .txt or .csv file with email addresses or drag and drop here"}
|
||||
? 'Drop your file here'
|
||||
: 'Upload a .txt or .csv file with email addresses or drag and drop here'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -289,7 +289,7 @@ export default function BulkAddSuppressionsDialog({
|
||||
</div>
|
||||
|
||||
{emailList.length > 0 && (
|
||||
<div className="text-sm text-muted-foreground bg-muted/50 p-3 rounded-md">
|
||||
<div className="text-muted-foreground bg-muted/50 rounded-md p-3 text-sm">
|
||||
<div>Found {emailList.length} email addresses</div>
|
||||
<div>Valid: {validEmails.length}</div>
|
||||
{validEmails.length !== emailList.length && (
|
||||
@@ -301,7 +301,7 @@ export default function BulkAddSuppressionsDialog({
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
|
||||
<div className="text-destructive bg-destructive/10 rounded-md p-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -320,7 +320,7 @@ export default function BulkAddSuppressionsDialog({
|
||||
disabled={processing || validEmails.length === 0}
|
||||
>
|
||||
{processing
|
||||
? "Adding..."
|
||||
? 'Adding...'
|
||||
: `Add ${validEmails.length} Suppressions`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import AddSuppressionDialog from "./add-suppression";
|
||||
import BulkAddSuppressionsDialog from "./bulk-add-suppressions";
|
||||
import SuppressionList from "./suppression-list";
|
||||
import SuppressionStats from "./suppression-stats";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Plus, Upload } from "lucide-react";
|
||||
import { H1 } from "@usesend/ui";
|
||||
import { useState } from 'react';
|
||||
import AddSuppressionDialog from './add-suppression';
|
||||
import BulkAddSuppressionsDialog from './bulk-add-suppressions';
|
||||
import SuppressionList from './suppression-list';
|
||||
import SuppressionStats from './suppression-stats';
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import { Plus, Upload } from 'lucide-react';
|
||||
import { H1 } from '@usesend/ui';
|
||||
|
||||
export default function SuppressionsPage() {
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
@@ -16,15 +16,15 @@ export default function SuppressionsPage() {
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<div className="mb-10 flex items-center justify-between">
|
||||
<H1>Suppression List</H1>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setShowBulkAddDialog(true)}>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Bulk Add
|
||||
</Button>
|
||||
<Button onClick={() => setShowAddDialog(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Suppression
|
||||
</Button>
|
||||
</div>
|
||||
|
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
|
||||
interface RemoveSuppressionDialogProps {
|
||||
email: string | null;
|
||||
@@ -49,7 +49,7 @@ export default function RemoveSuppressionDialog({
|
||||
onClick={onConfirm}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Removing..." : "Remove"}
|
||||
{isLoading ? 'Removing...' : 'Remove'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
@@ -1,20 +1,20 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useUrlState } from "~/hooks/useUrlState";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { SuppressionReason } from "@prisma/client";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { useState } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { useUrlState } from '~/hooks/useUrlState';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { SuppressionReason } from '@prisma/client';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import { Input } from '@usesend/ui/src/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@usesend/ui/src/select";
|
||||
} from '@usesend/ui/src/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -22,30 +22,30 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@usesend/ui/src/table";
|
||||
import { Trash2, Download } from "lucide-react";
|
||||
import RemoveSuppressionDialog from "./remove-suppression";
|
||||
import Spinner from "@usesend/ui/src/spinner";
|
||||
} from '@usesend/ui/src/table';
|
||||
import { Trash2, Download } from 'lucide-react';
|
||||
import RemoveSuppressionDialog from './remove-suppression';
|
||||
import Spinner from '@usesend/ui/src/spinner';
|
||||
|
||||
const reasonLabels = {
|
||||
HARD_BOUNCE: "Hard Bounce",
|
||||
COMPLAINT: "Complaint",
|
||||
MANUAL: "Manual",
|
||||
HARD_BOUNCE: 'Hard Bounce',
|
||||
COMPLAINT: 'Complaint',
|
||||
MANUAL: 'Manual',
|
||||
} as const;
|
||||
|
||||
export default function SuppressionList() {
|
||||
const [search, setSearch] = useUrlState("search");
|
||||
const [reason, setReason] = useUrlState("reason");
|
||||
const [page, setPage] = useUrlState("page", "1");
|
||||
const [search, setSearch] = useUrlState('search');
|
||||
const [reason, setReason] = useUrlState('reason');
|
||||
const [page, setPage] = useUrlState('page', '1');
|
||||
const [emailToRemove, setEmailToRemove] = useState<string | null>(null);
|
||||
|
||||
const suppressionsQuery = api.suppression.getSuppressions.useQuery({
|
||||
page: parseInt(page || "1"),
|
||||
page: parseInt(page || '1'),
|
||||
limit: 20,
|
||||
search: search || undefined,
|
||||
reason: reason as SuppressionReason | undefined,
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "desc",
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'desc',
|
||||
});
|
||||
|
||||
const exportQuery = api.suppression.exportSuppressions.useQuery(
|
||||
@@ -53,7 +53,7 @@ export default function SuppressionList() {
|
||||
search: search || undefined,
|
||||
reason: reason as SuppressionReason | undefined,
|
||||
},
|
||||
{ enabled: false }
|
||||
{ enabled: false },
|
||||
);
|
||||
|
||||
const utils = api.useUtils();
|
||||
@@ -68,12 +68,12 @@ export default function SuppressionList() {
|
||||
|
||||
const debouncedSearch = useDebouncedCallback((value: string) => {
|
||||
setSearch(value || null);
|
||||
setPage("1");
|
||||
setPage('1');
|
||||
}, 1000);
|
||||
|
||||
const handleReasonFilter = (value: string) => {
|
||||
setReason(value === "all" ? null : value);
|
||||
setPage("1");
|
||||
setReason(value === 'all' ? null : value);
|
||||
setPage('1');
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
@@ -81,18 +81,18 @@ export default function SuppressionList() {
|
||||
|
||||
if (resp.data) {
|
||||
const csv = [
|
||||
"Email,Reason,Created At",
|
||||
'Email,Reason,Created At',
|
||||
...resp.data.map(
|
||||
(suppression) =>
|
||||
`${suppression.email},${suppression.reason},${suppression.createdAt}`
|
||||
`${suppression.email},${suppression.reason},${suppression.createdAt}`,
|
||||
),
|
||||
].join("\n");
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `suppressions-${new Date().toISOString().split("T")[0]}.csv`;
|
||||
a.download = `suppressions-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
@@ -113,16 +113,16 @@ export default function SuppressionList() {
|
||||
return (
|
||||
<div className="mt-10 flex flex-col gap-4">
|
||||
{/* Header and Export */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Filters */}
|
||||
<div className="flex gap-4">
|
||||
<Input
|
||||
placeholder="Search by email address..."
|
||||
className="max-w-sm"
|
||||
defaultValue={search || ""}
|
||||
defaultValue={search || ''}
|
||||
onChange={(e) => debouncedSearch(e.target.value)}
|
||||
/>
|
||||
<Select value={reason || "all"} onValueChange={handleReasonFilter}>
|
||||
<Select value={reason || 'all'} onValueChange={handleReasonFilter}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Filter by reason" />
|
||||
</SelectTrigger>
|
||||
@@ -133,13 +133,13 @@ export default function SuppressionList() {
|
||||
<SelectItem value="MANUAL">Manual</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>{" "}
|
||||
</div>{' '}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleExport}
|
||||
disabled={exportQuery.isFetching}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
@@ -148,7 +148,7 @@ export default function SuppressionList() {
|
||||
<div className="flex flex-col rounded-xl border shadow">
|
||||
<Table className="">
|
||||
<TableHeader className="">
|
||||
<TableRow className=" bg-muted/30">
|
||||
<TableRow className="bg-muted/30">
|
||||
<TableHead className="rounded-tl-xl">Email</TableHead>
|
||||
<TableHead>Reason</TableHead>
|
||||
<TableHead>Added</TableHead>
|
||||
@@ -158,16 +158,16 @@ export default function SuppressionList() {
|
||||
<TableBody>
|
||||
{suppressionsQuery.isLoading ? (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={4} className="text-center py-4">
|
||||
<TableCell colSpan={4} className="py-4 text-center">
|
||||
<Spinner
|
||||
className="w-6 h-6 mx-auto"
|
||||
className="mx-auto h-6 w-6"
|
||||
innerSvgClass="stroke-primary"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : suppressionsQuery.data?.suppressions.length === 0 ? (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={4} className="text-center py-4">
|
||||
<TableCell colSpan={4} className="py-4 text-center">
|
||||
No suppressed emails found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -179,12 +179,12 @@ export default function SuppressionList() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div
|
||||
className={`text-center w-[130px] rounded capitalize py-1 text-xs ${
|
||||
suppression.reason === "HARD_BOUNCE"
|
||||
? "bg-red/15 text-red border border-red/20"
|
||||
: suppression.reason === "COMPLAINT"
|
||||
? "bg-yellow/15 text-yellow border border-yellow/20"
|
||||
: "bg-blue/15 text-blue border border-blue/20"
|
||||
className={`w-[130px] rounded py-1 text-center text-xs capitalize ${
|
||||
suppression.reason === 'HARD_BOUNCE'
|
||||
? 'bg-red/15 text-red border-red/20 border'
|
||||
: suppression.reason === 'COMPLAINT'
|
||||
? 'bg-yellow/15 text-yellow border-yellow/20 border'
|
||||
: 'bg-blue/15 text-blue border-blue/20 border'
|
||||
}`}
|
||||
>
|
||||
{reasonLabels[suppression.reason]}
|
||||
@@ -214,17 +214,17 @@ export default function SuppressionList() {
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex gap-4 justify-end">
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setPage(String(parseInt(page || "1") - 1))}
|
||||
disabled={parseInt(page || "1") === 1}
|
||||
onClick={() => setPage(String(parseInt(page || '1') - 1))}
|
||||
disabled={parseInt(page || '1') === 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setPage(String(parseInt(page || "1") + 1))}
|
||||
onClick={() => setPage(String(parseInt(page || '1') + 1))}
|
||||
disabled={!suppressionsQuery.data?.pagination?.hasNext}
|
||||
>
|
||||
Next
|
||||
|
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { api } from '~/trpc/react';
|
||||
|
||||
export default function SuppressionStats() {
|
||||
const { data: stats, isLoading } =
|
||||
@@ -8,14 +8,14 @@ export default function SuppressionStats() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6 lg:gap-8">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-4 lg:gap-8">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col gap-2 rounded-lg border p-4 shadow"
|
||||
>
|
||||
<div className="h-4 bg-muted animate-pulse rounded mb-1" />
|
||||
<div className="h-8 bg-muted animate-pulse rounded" />
|
||||
<div className="bg-muted mb-1 h-4 animate-pulse rounded" />
|
||||
<div className="bg-muted h-8 animate-pulse rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -27,29 +27,29 @@ export default function SuppressionStats() {
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6 lg:gap-8">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-4 lg:gap-8">
|
||||
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
|
||||
<p className="font-semibold mb-1">Total Suppressions</p>
|
||||
<div className="text-2xl font-mono">{totalSuppressions}</div>
|
||||
<p className="mb-1 font-semibold">Total Suppressions</p>
|
||||
<div className="font-mono text-2xl">{totalSuppressions}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
|
||||
<p className="font-semibold mb-1">Hard Bounces</p>
|
||||
<div className="text-2xl font-mono text-red">
|
||||
<p className="mb-1 font-semibold">Hard Bounces</p>
|
||||
<div className="text-red font-mono text-2xl">
|
||||
{stats?.HARD_BOUNCE ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
|
||||
<p className="font-semibold mb-1">Complaints</p>
|
||||
<div className="text-2xl font-mono text-yellow">
|
||||
<p className="mb-1 font-semibold">Complaints</p>
|
||||
<div className="text-yellow font-mono text-2xl">
|
||||
{stats?.COMPLAINT ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
|
||||
<p className="font-semibold mb-1">Manual</p>
|
||||
<div className="text-2xl font-mono text-blue">{stats?.MANUAL ?? 0}</div>
|
||||
<p className="mb-1 font-semibold">Manual</p>
|
||||
<div className="text-blue font-mono text-2xl">{stats?.MANUAL ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@@ -1,17 +1,17 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { Spinner } from "@usesend/ui/src/spinner";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { Editor } from "@usesend/email-editor";
|
||||
import { useState } from "react";
|
||||
import { Template } from "@prisma/client";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { use } from "react";
|
||||
import { api } from '~/trpc/react';
|
||||
import { Spinner } from '@usesend/ui/src/spinner';
|
||||
import { Input } from '@usesend/ui/src/input';
|
||||
import { Editor } from '@usesend/email-editor';
|
||||
import { useState } from 'react';
|
||||
import { Template } from '@prisma/client';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { use } from 'react';
|
||||
const IMAGE_SIZE_LIMIT = 10 * 1024 * 1024;
|
||||
|
||||
export default function EditTemplatePage({
|
||||
@@ -34,15 +34,15 @@ export default function EditTemplatePage({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-full">
|
||||
<Spinner className="w-6 h-6" />
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-full">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-red-500">Failed to load template</p>
|
||||
</div>
|
||||
);
|
||||
@@ -96,7 +96,7 @@ function TemplateEditor({
|
||||
);
|
||||
}
|
||||
|
||||
console.log("file type: ", file.type);
|
||||
console.log('file type: ', file.type);
|
||||
|
||||
const { uploadUrl, imageUrl } = await getUploadUrl.mutateAsync({
|
||||
name: file.name,
|
||||
@@ -105,21 +105,21 @@ function TemplateEditor({
|
||||
});
|
||||
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: "PUT",
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to upload file");
|
||||
throw new Error('Failed to upload file');
|
||||
}
|
||||
|
||||
return imageUrl;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 container mx-auto">
|
||||
<div className="container mx-auto p-4">
|
||||
<div className="mx-auto">
|
||||
<div className="mb-4 flex justify-between items-center w-[700px] mx-auto">
|
||||
<div className="mx-auto mb-4 flex w-[700px] items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/templates">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
@@ -128,7 +128,7 @@ function TemplateEditor({
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className=" border-0 focus:ring-0 focus:outline-none px-0.5 w-[300px]"
|
||||
className="w-[300px] border-0 px-0.5 focus:outline-none focus:ring-0"
|
||||
onBlur={() => {
|
||||
if (name === template.name || !name) {
|
||||
return;
|
||||
@@ -152,20 +152,20 @@ function TemplateEditor({
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
{isSaving ? (
|
||||
<div className="h-2 w-2 bg-yellow rounded-full" />
|
||||
<div className="bg-yellow h-2 w-2 rounded-full" />
|
||||
) : (
|
||||
<div className="h-2 w-2 bg-green rounded-full" />
|
||||
<div className="bg-green h-2 w-2 rounded-full" />
|
||||
)}
|
||||
{formatDistanceToNow(template.updatedAt) === "less than a minute"
|
||||
? "just now"
|
||||
{formatDistanceToNow(template.updatedAt) === 'less than a minute'
|
||||
? 'just now'
|
||||
: `${formatDistanceToNow(template.updatedAt)} ago`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col mt-4 mb-4 p-4 w-[700px] mx-auto z-50">
|
||||
<div className="z-50 mx-auto mb-4 mt-4 flex w-[700px] flex-col p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="block text-sm w-[80px] text-muted-foreground">
|
||||
<label className="text-muted-foreground block w-[80px] text-sm">
|
||||
Subject
|
||||
</label>
|
||||
<input
|
||||
@@ -191,13 +191,13 @@ function TemplateEditor({
|
||||
},
|
||||
);
|
||||
}}
|
||||
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent focus:border-border bg-transparent"
|
||||
className="focus:border-border mt-1 block w-full border-b border-transparent bg-transparent py-1 text-sm outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className=" rounded-lg bg-gray-50 w-[700px] mx-auto p-10">
|
||||
<div className="w-[600px] mx-auto">
|
||||
<div className="mx-auto w-[700px] rounded-lg bg-gray-50 p-10">
|
||||
<div className="mx-auto w-[600px]">
|
||||
<Editor
|
||||
initialContent={json}
|
||||
onUpdate={(content) => {
|
||||
@@ -205,7 +205,7 @@ function TemplateEditor({
|
||||
setIsSaving(true);
|
||||
deboucedUpdateTemplate();
|
||||
}}
|
||||
variables={["email", "firstName", "lastName"]}
|
||||
variables={['email', 'firstName', 'lastName']}
|
||||
uploadImage={
|
||||
template.imageUploadSupported ? handleFileChange : undefined
|
||||
}
|
||||
|
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import { Input } from '@usesend/ui/src/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -16,24 +16,24 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@usesend/ui/src/form";
|
||||
} from '@usesend/ui/src/form';
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Spinner from "@usesend/ui/src/spinner";
|
||||
import { api } from '~/trpc/react';
|
||||
import { useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Spinner from '@usesend/ui/src/spinner';
|
||||
|
||||
const templateSchema = z.object({
|
||||
name: z.string({ required_error: "Name is required" }).min(1, {
|
||||
message: "Name is required",
|
||||
name: z.string({ required_error: 'Name is required' }).min(1, {
|
||||
message: 'Name is required',
|
||||
}),
|
||||
subject: z.string({ required_error: "Subject is required" }).min(1, {
|
||||
message: "Subject is required",
|
||||
subject: z.string({ required_error: 'Subject is required' }).min(1, {
|
||||
message: 'Subject is required',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -46,8 +46,8 @@ export default function CreateTemplate() {
|
||||
const templateForm = useForm<z.infer<typeof templateSchema>>({
|
||||
resolver: zodResolver(templateSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
subject: "",
|
||||
name: '',
|
||||
subject: '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -63,13 +63,13 @@ export default function CreateTemplate() {
|
||||
onSuccess: async (data) => {
|
||||
utils.template.getTemplates.invalidate();
|
||||
router.push(`/templates/${data.id}/edit`);
|
||||
toast.success("Template created successfully");
|
||||
toast.success('Template created successfully');
|
||||
setOpen(false);
|
||||
},
|
||||
onError: async (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function CreateTemplate() {
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Create Template
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@@ -125,14 +125,14 @@ export default function CreateTemplate() {
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className=" w-[100px]"
|
||||
className="w-[100px]"
|
||||
type="submit"
|
||||
disabled={createTemplateMutation.isPending}
|
||||
>
|
||||
{createTemplateMutation.isPending ? (
|
||||
<Spinner className="w-4 h-4" />
|
||||
<Spinner className="h-4 w-4" />
|
||||
) : (
|
||||
"Create"
|
||||
'Create'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import { Input } from '@usesend/ui/src/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -9,14 +9,14 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
import { api } from "~/trpc/react";
|
||||
import React, { useState } from "react";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
import { api } from '~/trpc/react';
|
||||
import React, { useState } from 'react';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -25,8 +25,8 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@usesend/ui/src/form";
|
||||
import { Template } from "@prisma/client";
|
||||
} from '@usesend/ui/src/form';
|
||||
import { Template } from '@prisma/client';
|
||||
|
||||
const templateSchema = z.object({
|
||||
name: z.string(),
|
||||
@@ -46,8 +46,8 @@ export const DeleteTemplate: React.FC<{
|
||||
|
||||
async function onTemplateDelete(values: z.infer<typeof templateSchema>) {
|
||||
if (values.name !== template.name) {
|
||||
templateForm.setError("name", {
|
||||
message: "Name does not match",
|
||||
templateForm.setError('name', {
|
||||
message: 'Name does not match',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export const DeleteTemplate: React.FC<{
|
||||
);
|
||||
}
|
||||
|
||||
const name = templateForm.watch("name");
|
||||
const name = templateForm.watch('name');
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -75,15 +75,15 @@ export const DeleteTemplate: React.FC<{
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent">
|
||||
<Trash2 className="h-[18px] w-[18px] text-red/80" />
|
||||
<Trash2 className="text-red/80 h-[18px] w-[18px]" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Template</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
Are you sure you want to delete{' '}
|
||||
<span className="text-foreground font-semibold">
|
||||
{template.name}
|
||||
</span>
|
||||
? You can't reverse this.
|
||||
@@ -107,7 +107,7 @@ export const DeleteTemplate: React.FC<{
|
||||
{formState.errors.name ? (
|
||||
<FormMessage />
|
||||
) : (
|
||||
<FormDescription className=" text-transparent">
|
||||
<FormDescription className="text-transparent">
|
||||
.
|
||||
</FormDescription>
|
||||
)}
|
||||
@@ -122,7 +122,7 @@ export const DeleteTemplate: React.FC<{
|
||||
deleteTemplateMutation.isPending || template.name !== name
|
||||
}
|
||||
>
|
||||
{deleteTemplateMutation.isPending ? "Deleting..." : "Delete"}
|
||||
{deleteTemplateMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
import { api } from "~/trpc/react";
|
||||
import React, { useState } from "react";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { Copy } from "lucide-react";
|
||||
import { Template } from "@prisma/client";
|
||||
} from '@usesend/ui/src/dialog';
|
||||
import { api } from '~/trpc/react';
|
||||
import React, { useState } from 'react';
|
||||
import { toast } from '@usesend/ui/src/toaster';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { Template } from '@prisma/client';
|
||||
|
||||
export const DuplicateTemplate: React.FC<{
|
||||
template: Partial<Template> & { id: string };
|
||||
@@ -46,15 +46,15 @@ export const DuplicateTemplate: React.FC<{
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent">
|
||||
<Copy className="h-[18px] w-[18px] text-blue/80" />
|
||||
<Copy className="text-blue/80 h-[18px] w-[18px]" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Duplicate Template</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to duplicate{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
Are you sure you want to duplicate{' '}
|
||||
<span className="text-foreground font-semibold">
|
||||
{template.name}
|
||||
</span>
|
||||
?
|
||||
@@ -68,8 +68,8 @@ export const DuplicateTemplate: React.FC<{
|
||||
disabled={duplicateTemplateMutation.isPending}
|
||||
>
|
||||
{duplicateTemplateMutation.isPending
|
||||
? "Duplicating..."
|
||||
: "Duplicate"}
|
||||
? 'Duplicating...'
|
||||
: 'Duplicate'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import TemplateList from "./template-list";
|
||||
import CreateTemplate from "./create-template";
|
||||
import { H1 } from "@usesend/ui";
|
||||
import TemplateList from './template-list';
|
||||
import CreateTemplate from './create-template';
|
||||
import { H1 } from '@usesend/ui';
|
||||
|
||||
export default function TemplatesPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<H1>Templates</H1>
|
||||
<CreateTemplate />
|
||||
</div>
|
||||
|
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Table,
|
||||
@@ -7,22 +7,22 @@ import {
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableCell,
|
||||
} from "@usesend/ui/src/table";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useUrlState } from "~/hooks/useUrlState";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import Spinner from "@usesend/ui/src/spinner";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
} from '@usesend/ui/src/table';
|
||||
import { api } from '~/trpc/react';
|
||||
import { useUrlState } from '~/hooks/useUrlState';
|
||||
import { Button } from '@usesend/ui/src/button';
|
||||
import Spinner from '@usesend/ui/src/spinner';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
// import DeleteCampaign from "./delete-campaign";
|
||||
import Link from "next/link";
|
||||
import Link from 'next/link';
|
||||
// import DuplicateCampaign from "./duplicate-campaign";
|
||||
|
||||
import { TextWithCopyButton } from "@usesend/ui/src/text-with-copy";
|
||||
import DeleteTemplate from "./delete-template";
|
||||
import DuplicateTemplate from "./duplicate-template";
|
||||
import { TextWithCopyButton } from '@usesend/ui/src/text-with-copy';
|
||||
import DeleteTemplate from './delete-template';
|
||||
import DuplicateTemplate from './duplicate-template';
|
||||
|
||||
export default function TemplateList() {
|
||||
const [page, setPage] = useUrlState("page", "1");
|
||||
const [page, setPage] = useUrlState('page', '1');
|
||||
|
||||
const pageNumber = Number(page);
|
||||
|
||||
@@ -32,10 +32,10 @@ export default function TemplateList() {
|
||||
|
||||
return (
|
||||
<div className="mt-10 flex flex-col gap-4">
|
||||
<div className="flex flex-col rounded-xl border border-border shadow">
|
||||
<div className="border-border flex flex-col rounded-xl border shadow">
|
||||
<Table className="">
|
||||
<TableHeader className="">
|
||||
<TableRow className=" bg-muted/30">
|
||||
<TableRow className="bg-muted/30">
|
||||
<TableHead className="rounded-tl-xl">Name</TableHead>
|
||||
<TableHead className="">ID</TableHead>
|
||||
<TableHead className="">Created At</TableHead>
|
||||
@@ -45,9 +45,9 @@ export default function TemplateList() {
|
||||
<TableBody>
|
||||
{templateQuery.isLoading ? (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={4} className="text-center py-4">
|
||||
<TableCell colSpan={4} className="py-4 text-center">
|
||||
<Spinner
|
||||
className="w-6 h-6 mx-auto"
|
||||
className="mx-auto h-6 w-6"
|
||||
innerSvgClass="stroke-primary"
|
||||
/>
|
||||
</TableCell>
|
||||
@@ -57,7 +57,7 @@ export default function TemplateList() {
|
||||
<TableRow key={template.id} className="">
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
className="underline underline-offset-4 decoration-dashed text-foreground hover:text-foreground"
|
||||
className="text-foreground hover:text-foreground underline decoration-dashed underline-offset-4"
|
||||
href={`/templates/${template.id}/edit`}
|
||||
>
|
||||
{template.name}
|
||||
@@ -84,7 +84,7 @@ export default function TemplateList() {
|
||||
))
|
||||
) : (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={4} className="text-center py-4">
|
||||
<TableCell colSpan={4} className="py-4 text-center">
|
||||
No templates found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -92,7 +92,7 @@ export default function TemplateList() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex gap-4 justify-end">
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setPage((pageNumber - 1).toString())}
|
||||
|
@@ -1,9 +1,9 @@
|
||||
import NextAuth from "next-auth";
|
||||
import NextAuth from 'next-auth';
|
||||
|
||||
import { authOptions } from "~/server/auth";
|
||||
import { env } from "~/env";
|
||||
import { getRedis } from "~/server/redis";
|
||||
import { logger } from "~/server/logger/log";
|
||||
import { authOptions } from '~/server/auth';
|
||||
import { env } from '~/env';
|
||||
import { getRedis } from '~/server/redis';
|
||||
import { logger } from '~/server/logger/log';
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
@@ -12,29 +12,29 @@ export { handler as GET };
|
||||
function getClientIp(req: Request): string | null {
|
||||
const h = req.headers;
|
||||
const direct =
|
||||
h.get("x-forwarded-for") ??
|
||||
h.get("x-real-ip") ??
|
||||
h.get("cf-connecting-ip") ??
|
||||
h.get("x-client-ip") ??
|
||||
h.get("true-client-ip") ??
|
||||
h.get("fastly-client-ip") ??
|
||||
h.get("x-cluster-client-ip") ??
|
||||
h.get('x-forwarded-for') ??
|
||||
h.get('x-real-ip') ??
|
||||
h.get('cf-connecting-ip') ??
|
||||
h.get('x-client-ip') ??
|
||||
h.get('true-client-ip') ??
|
||||
h.get('fastly-client-ip') ??
|
||||
h.get('x-cluster-client-ip') ??
|
||||
null;
|
||||
|
||||
let ip = direct?.split(",")[0]?.trim() ?? "";
|
||||
let ip = direct?.split(',')[0]?.trim() ?? '';
|
||||
|
||||
if (!ip) {
|
||||
const fwd = h.get("forwarded");
|
||||
const fwd = h.get('forwarded');
|
||||
if (fwd) {
|
||||
const first = fwd.split(",")[0];
|
||||
const first = fwd.split(',')[0];
|
||||
const match = first?.match(/for=([^;]+)/i);
|
||||
if (match && match[1]) {
|
||||
const raw = match[1].trim().replace(/^"|"$/g, "");
|
||||
if (raw.startsWith("[")) {
|
||||
const end = raw.indexOf("]");
|
||||
const raw = match[1].trim().replace(/^"|"$/g, '');
|
||||
if (raw.startsWith('[')) {
|
||||
const end = raw.indexOf(']');
|
||||
ip = end !== -1 ? raw.slice(1, end) : raw;
|
||||
} else {
|
||||
const parts = raw.split(":");
|
||||
const parts = raw.split(':');
|
||||
if (parts.length > 0 && parts[0]) {
|
||||
ip =
|
||||
parts.length === 2 && /^\d+(?:\.\d+){3}$/.test(parts[0])
|
||||
@@ -52,11 +52,11 @@ function getClientIp(req: Request): string | null {
|
||||
export async function POST(req: Request, ctx: any) {
|
||||
if (env.AUTH_EMAIL_RATE_LIMIT > 0) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname.endsWith("/signin/email")) {
|
||||
if (url.pathname.endsWith('/signin/email')) {
|
||||
try {
|
||||
const ip = getClientIp(req);
|
||||
if (!ip) {
|
||||
logger.warn("Auth email rate limit skipped: missing client IP");
|
||||
logger.warn('Auth email rate limit skipped: missing client IP');
|
||||
return handler(req, ctx);
|
||||
}
|
||||
const redis = getRedis();
|
||||
@@ -65,19 +65,19 @@ export async function POST(req: Request, ctx: any) {
|
||||
const count = await redis.incr(key);
|
||||
if (count === 1) await redis.expire(key, ttl);
|
||||
if (count > env.AUTH_EMAIL_RATE_LIMIT) {
|
||||
logger.warn({ ip }, "Auth email rate limit exceeded");
|
||||
logger.warn({ ip }, 'Auth email rate limit exceeded');
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
code: "RATE_LIMITED",
|
||||
message: "Too many requests",
|
||||
code: 'RATE_LIMITED',
|
||||
message: 'Too many requests',
|
||||
},
|
||||
},
|
||||
{ status: 429 }
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Auth email rate limit failed");
|
||||
logger.error({ err: error }, 'Auth email rate limit failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,71 +1,71 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import {
|
||||
renderOtpEmail,
|
||||
renderTeamInviteEmail,
|
||||
renderUsageWarningEmail,
|
||||
renderUsageLimitReachedEmail,
|
||||
} from "~/server/email-templates";
|
||||
} from '~/server/email-templates';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get("type") || "otp";
|
||||
const type = searchParams.get('type') || 'otp';
|
||||
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
return NextResponse.json({ error: "Not Found" }, { status: 404 });
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
return NextResponse.json({ error: 'Not Found' }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
let html: string;
|
||||
|
||||
if (type === "otp") {
|
||||
if (type === 'otp') {
|
||||
html = await renderOtpEmail({
|
||||
otpCode: "ABC123",
|
||||
loginUrl: "https://app.usesend.com/login?token=abc123",
|
||||
hostName: "useSend",
|
||||
otpCode: 'ABC123',
|
||||
loginUrl: 'https://app.usesend.com/login?token=abc123',
|
||||
hostName: 'useSend',
|
||||
});
|
||||
} else if (type === "invite") {
|
||||
} else if (type === 'invite') {
|
||||
html = await renderTeamInviteEmail({
|
||||
teamName: "My Awesome Team",
|
||||
inviteUrl: "https://app.usesend.com/join-team?inviteId=123",
|
||||
inviterName: "John Doe",
|
||||
role: "admin",
|
||||
teamName: 'My Awesome Team',
|
||||
inviteUrl: 'https://app.usesend.com/join-team?inviteId=123',
|
||||
inviterName: 'John Doe',
|
||||
role: 'admin',
|
||||
});
|
||||
} else if (type === "usage-warning") {
|
||||
const isPaidPlan = searchParams.get("isPaidPlan") === "true";
|
||||
const period = searchParams.get("period") || "daily";
|
||||
} else if (type === 'usage-warning') {
|
||||
const isPaidPlan = searchParams.get('isPaidPlan') === 'true';
|
||||
const period = searchParams.get('period') || 'daily';
|
||||
|
||||
html = await renderUsageWarningEmail({
|
||||
teamName: "Acme Inc",
|
||||
teamName: 'Acme Inc',
|
||||
used: 8000,
|
||||
limit: 10000,
|
||||
period: period as "daily" | "monthly",
|
||||
manageUrl: "https://app.usesend.com/settings/billing",
|
||||
period: period as 'daily' | 'monthly',
|
||||
manageUrl: 'https://app.usesend.com/settings/billing',
|
||||
isPaidPlan: isPaidPlan,
|
||||
});
|
||||
} else if (type === "usage-limit") {
|
||||
const isPaidPlan = searchParams.get("isPaidPlan") === "true";
|
||||
const period = searchParams.get("period") || "daily";
|
||||
} else if (type === 'usage-limit') {
|
||||
const isPaidPlan = searchParams.get('isPaidPlan') === 'true';
|
||||
const period = searchParams.get('period') || 'daily';
|
||||
html = await renderUsageLimitReachedEmail({
|
||||
teamName: "Acme Inc",
|
||||
teamName: 'Acme Inc',
|
||||
limit: 10000,
|
||||
period: period as "daily" | "monthly",
|
||||
manageUrl: "https://app.usesend.com/settings/billing",
|
||||
period: period as 'daily' | 'monthly',
|
||||
manageUrl: 'https://app.usesend.com/settings/billing',
|
||||
isPaidPlan: isPaidPlan,
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json({ error: "Invalid type" }, { status: 400 });
|
||||
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
|
||||
}
|
||||
|
||||
return new NextResponse(html, {
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
'Content-Type': 'text/html',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error rendering email template:", error);
|
||||
console.error('Error rendering email template:', error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to render email template" },
|
||||
{ status: 500 }
|
||||
{ error: 'Failed to render email template' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user