add rebrand landing page (#211)
This commit is contained in:
BIN
apps/marketing/public/hero-dark.png
Normal file
BIN
apps/marketing/public/hero-dark.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 MiB |
BIN
apps/marketing/public/hero-light.png
Normal file
BIN
apps/marketing/public/hero-light.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 MiB |
@@ -16,9 +16,35 @@ const jetbrainsMono = JetBrains_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "useSend - Open source email platform",
|
||||
description: "Open source email platform for everyone",
|
||||
title: "useSend – Open source email platform",
|
||||
description:
|
||||
"Open source email platform for everyone: SMTP, API, editor, analytics.",
|
||||
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
||||
metadataBase: new URL("https://usesend.com"),
|
||||
openGraph: {
|
||||
title: "useSend – Open source email platform",
|
||||
description:
|
||||
"Open source email platform for everyone: SMTP, API, editor, analytics.",
|
||||
url: "https://usesend.com",
|
||||
siteName: "useSend",
|
||||
images: [
|
||||
{
|
||||
url: "/logo-squircle.png",
|
||||
width: 512,
|
||||
height: 512,
|
||||
alt: "useSend",
|
||||
},
|
||||
],
|
||||
locale: "en_US",
|
||||
type: "website",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "useSend – Open source email platform",
|
||||
description:
|
||||
"Open source email platform for everyone: SMTP, API, editor, analytics.",
|
||||
images: ["/logo-squircle.png"],
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -27,11 +53,17 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning className="bg-sidebar-background">
|
||||
<html lang="en" suppressHydrationWarning className="scroll-smooth bg-background">
|
||||
<body
|
||||
className={`font-sans ${inter.variable} ${jetbrainsMono.variable} bg-sidebar-background`}
|
||||
className={`font-mono ${inter.variable} ${jetbrainsMono.variable} bg-background`}
|
||||
>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
{/* System theme with isolated storage to avoid stale overrides */}
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
storageKey="marketing-theme"
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
@@ -1,27 +1,532 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
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 { CodeBlock } from "@usesend/ui/src/code-block";
|
||||
|
||||
const REPO = "unsend-dev/unsend";
|
||||
const REPO_URL = `https://github.com/${REPO}`;
|
||||
const APP_URL = "https://app.usesend.com";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center p-6">
|
||||
<div className="max-w-2xl text-center space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/logo-squircle.png"
|
||||
alt="useSend logo"
|
||||
width={64}
|
||||
height={64}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-xl font-mono font-medium text-blue">useSend</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Open source email platform for everyone
|
||||
</p>
|
||||
<div className="mt-6 flex items-center justify-center gap-3">
|
||||
<GitHubStarsButton />
|
||||
</div>
|
||||
</div>
|
||||
<main className="min-h-screen bg-sidebar-background text-foreground">
|
||||
<TopNav />
|
||||
<Hero />
|
||||
<TrustedBy />
|
||||
<Features />
|
||||
<CodeExample />
|
||||
<Pricing />
|
||||
<About />
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// (Removed unused SectionHeading component)
|
||||
|
||||
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">
|
||||
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.{" "}
|
||||
<span className="text-primary font-normal">
|
||||
Pay only for what you send
|
||||
</span>{" "}
|
||||
and not for storing contacts. Open source and self-hostable.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||
<Button size="lg" className="px-6">
|
||||
<a href={APP_URL} target="_blank" rel="noopener noreferrer">
|
||||
Get started
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<GitHubStarsButton />
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-center text-xs text-muted-foreground">
|
||||
Open source • Self-host in minutes • Free tier
|
||||
</p>
|
||||
|
||||
<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 ">
|
||||
<Image
|
||||
src="/hero-light.png"
|
||||
alt="useSend product hero"
|
||||
width={3456}
|
||||
height={1914}
|
||||
className="w-full h-auto rounded-xl block dark:hidden"
|
||||
sizes="(min-width: 1024px) 900px, 100vw"
|
||||
loading="eager"
|
||||
priority={false}
|
||||
/>
|
||||
<Image
|
||||
src="/hero-dark.png"
|
||||
alt="useSend product hero"
|
||||
width={3456}
|
||||
height={1914}
|
||||
className="w-full h-auto rounded-xl hidden dark:block"
|
||||
sizes="(min-width: 1024px) 900px, 100vw"
|
||||
loading="eager"
|
||||
priority={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// TopNav moved to a dedicated client component in ~/components/TopNav
|
||||
|
||||
function TrustedBy() {
|
||||
const featured = [
|
||||
{
|
||||
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",
|
||||
image:
|
||||
"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",
|
||||
image:
|
||||
"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",
|
||||
image:
|
||||
"https://pbs.twimg.com/profile_images/1698447401781022720/b0DZSc_D_400x400.jpg",
|
||||
},
|
||||
{
|
||||
quote: "Thank you for making useSend!",
|
||||
author: "Andras Bacsai",
|
||||
company: "coolify.io",
|
||||
image:
|
||||
"https://pbs.twimg.com/profile_images/1884210412524027905/jW4NB4rx_400x400.jpg",
|
||||
},
|
||||
{
|
||||
quote: "I KNOW WHAT TO DO",
|
||||
author: "VicVijayakumar",
|
||||
company: "onetimefax.com",
|
||||
image:
|
||||
"https://pbs.twimg.com/profile_images/1665351804685524995/W4BpDx5Z_400x400.jpg",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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">
|
||||
{featured.map((t) => (
|
||||
<figure
|
||||
key={t.author + t.company}
|
||||
className="rounded-xl border border-primary/30 p-5 h-full"
|
||||
>
|
||||
<blockquote className="text-sm sm:text-base font-light font-sans ">
|
||||
{t.quote}
|
||||
</blockquote>
|
||||
<div className="mt-5 flex items-center gap-3">
|
||||
<Image
|
||||
src={t.image}
|
||||
alt={`${t.author} avatar`}
|
||||
width={32}
|
||||
height={32}
|
||||
className=" rounded-md border-2 border-primary/50"
|
||||
/>
|
||||
<figcaption className="text-sm">
|
||||
<span className="font-medium">{t.author}</span>
|
||||
<a
|
||||
href={`https://${t.company}`}
|
||||
target="_blank"
|
||||
className="text-muted-foreground hover:text-primary-light"
|
||||
>
|
||||
{" "}
|
||||
— {t.company}
|
||||
</a>{" "}
|
||||
</figcaption>
|
||||
</div>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom: 3 multi-line testimonials (same style as top) */}
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{quick.map((t) => (
|
||||
<figure
|
||||
key={t.author + t.company}
|
||||
className="rounded-xl border border-primary/30 p-5 h-full"
|
||||
>
|
||||
<blockquote className="text-sm sm:text-base font-light font-sans leading-relaxed">
|
||||
{t.quote}
|
||||
</blockquote>
|
||||
<div className="mt-5 flex items-center gap-3">
|
||||
<Image
|
||||
src={t.image}
|
||||
alt={`${t.author} avatar`}
|
||||
width={32}
|
||||
height={32}
|
||||
className=" rounded-md border-2 border-primary/50"
|
||||
/>
|
||||
<figcaption className="text-sm">
|
||||
<span className="font-medium">{t.author}</span>
|
||||
<a
|
||||
href={`https://${t.company}`}
|
||||
target="_blank"
|
||||
className="text-muted-foreground hover:text-primary-light"
|
||||
>
|
||||
{" "}
|
||||
— {t.company}
|
||||
</a>
|
||||
</figcaption>
|
||||
</div>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Features() {
|
||||
// Top: 2 cards (with image area) — Analytics, Editor
|
||||
const top = [
|
||||
{
|
||||
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.",
|
||||
imageSrc: "", // add an image like "/analytics.png"
|
||||
},
|
||||
{
|
||||
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.",
|
||||
imageSrc: "", // add an image like "/editor.png"
|
||||
},
|
||||
];
|
||||
|
||||
// Bottom: 3 cards (no images) — Contact Management, Suppression List, SMTP Relay Service
|
||||
const bottom = [
|
||||
{
|
||||
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.",
|
||||
},
|
||||
{
|
||||
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.",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<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">
|
||||
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">
|
||||
{top.map((f) => (
|
||||
<FeatureCard
|
||||
key={f.key}
|
||||
title={f.title}
|
||||
content={f.content}
|
||||
imageSrc={f.imageSrc}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom row: 3 cards without images */}
|
||||
<div className="mt-6 grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||
{bottom.map((f) => (
|
||||
<FeatureCardPlain key={f.key} title={f.title} content={f.content} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeExample() {
|
||||
const code = `import { Unsend } from "@unsend/sdk";
|
||||
|
||||
const unsend = new Unsend({ apiKey: process.env.UNSEND_API_KEY! });
|
||||
|
||||
await unsend.emails.send({
|
||||
from: "hi@example.com",
|
||||
to: "you@company.com",
|
||||
subject: "Welcome to useSend",
|
||||
template: "welcome", // or html/text
|
||||
data: { name: "Ada" },
|
||||
});`;
|
||||
|
||||
return (
|
||||
<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">
|
||||
Developers
|
||||
</div>
|
||||
<p className="mt-1 text-xs sm:text-sm text-muted-foreground max-w-2xl mx-auto">
|
||||
Typed SDKs and simple APIs, so you can focus on product not
|
||||
plumbing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 overflow-hidden">
|
||||
<div className=" py-2 text-xs text-muted-foreground">TypeScript</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">
|
||||
<CodeBlock
|
||||
lang="typescript"
|
||||
children={code}
|
||||
className="p-4 rounded-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-center gap-3">
|
||||
<Button size="lg" className="px-6">
|
||||
<a
|
||||
href="https://docs.usesend.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read the docs
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
];
|
||||
|
||||
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",
|
||||
];
|
||||
|
||||
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">
|
||||
PRICING
|
||||
</div>
|
||||
<p className="mt-1 text-xs sm:text-sm text-muted-foreground max-w-2xl mx-auto">
|
||||
pay for what you use, not for storing contacts
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<PricingCard
|
||||
title="Free"
|
||||
price="$0"
|
||||
note="per month"
|
||||
perks={freePerks}
|
||||
/>
|
||||
<PricingCard
|
||||
title="Paid"
|
||||
price="$10"
|
||||
note="minimum usage per month"
|
||||
perks={paidPerks}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type PricingCardProps = {
|
||||
title: string;
|
||||
price: string;
|
||||
note: string;
|
||||
perks: string[];
|
||||
};
|
||||
|
||||
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">
|
||||
{perks.map((perk) => (
|
||||
<li key={perk} className="flex items-start gap-2">
|
||||
<CheckIcon className="w-4 h-4 mt-0.5 text-primary" />
|
||||
<span>{perk}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-auto pt-6">
|
||||
<Button className="">
|
||||
<a
|
||||
href="https://app.usesend.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Get started
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function About() {
|
||||
return (
|
||||
<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">
|
||||
About
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 max-w-3xl mx-auto text-sm sm:text-base space-y-4">
|
||||
<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
|
||||
way to send emails reliably and cheaply with a great dashboard.
|
||||
</p>
|
||||
<p>
|
||||
useSend is bootstrapped and funded by the cloud offering and
|
||||
sponsors. If you self host useSend, please consider{" "}
|
||||
<a
|
||||
href="https://github.com/sponsors/KMKoushik"
|
||||
target="_blank"
|
||||
className="text-primary-light"
|
||||
>
|
||||
sponsoring us
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// FAQ section removed per request
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<footer className="py-10 border-t border-border">
|
||||
<div className="mx-auto max-w-6xl px-6 flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Image
|
||||
src="/logo-squircle.png"
|
||||
alt="useSend"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span className="text-primary font-mono">useSend</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="#features" className="hover:text-foreground">
|
||||
Features
|
||||
</Link>
|
||||
<Link href="/privacy" className="hover:text-foreground">
|
||||
Privacy
|
||||
</Link>
|
||||
<Link href="/terms" className="hover:text-foreground">
|
||||
Terms
|
||||
</Link>
|
||||
<a
|
||||
href={REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
<a
|
||||
href={APP_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground"
|
||||
>
|
||||
Get Started
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
// Minimal inline icons (stroke-based, sleek)
|
||||
function CheckIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
197
apps/marketing/src/app/privacy/page.tsx
Normal file
197
apps/marketing/src/app/privacy/page.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
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.",
|
||||
};
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-sidebar-background text-foreground">
|
||||
<TopNav />
|
||||
<div className="mx-auto max-w-3xl px-6 py-16">
|
||||
<h1 className="text-3xl font-semibold tracking-tight mb-6">
|
||||
Privacy Policy
|
||||
</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
This Privacy Policy explains how we collect, use, and share
|
||||
information when you visit or interact with the useSend marketing
|
||||
website at usesend.com. It also summarizes the limited information we
|
||||
process when you sign up for our product and receive transactional or
|
||||
occasional marketing emails.
|
||||
</p>
|
||||
|
||||
<section className="space-y-3 mb-8">
|
||||
<h2 className="text-xl font-medium">Who We Are</h2>
|
||||
<p className="text-muted-foreground">
|
||||
useSend ("we", "us") operates the marketing website at
|
||||
<span className="mx-1 font-mono">usesend.com</span>. The marketing
|
||||
site is hosted on Vercel. Our application is hosted on Railway. We
|
||||
are the controller of the information described in this policy for
|
||||
the marketing site. If you have questions about this policy or your
|
||||
data, contact us at
|
||||
<a
|
||||
href="mailto:hey@usesend.com"
|
||||
className="ml-1 underline decoration-dotted"
|
||||
>
|
||||
hey@usesend.com
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 mb-8">
|
||||
<h2 className="text-xl font-medium">What We Collect</h2>
|
||||
<ul className="list-disc pl-5 space-y-2 text-muted-foreground">
|
||||
<li>
|
||||
<span className="text-foreground">
|
||||
Usage and device data (marketing site):
|
||||
</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
|
||||
use cookies for tracking. Data is aggregated and not used to
|
||||
identify you.
|
||||
</li>
|
||||
<li>
|
||||
<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.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground">
|
||||
Account and email data (product):
|
||||
</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
|
||||
the link in those emails.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 mb-8">
|
||||
<h2 className="text-xl font-medium">How We Use Information</h2>
|
||||
<ul className="list-disc pl-5 space-y-2 text-muted-foreground">
|
||||
<li>Operate, secure, and maintain the marketing site and app.</li>
|
||||
<li>
|
||||
Understand aggregated usage to improve performance and content.
|
||||
</li>
|
||||
<li>
|
||||
Deliver transactional emails related to your account or use.
|
||||
</li>
|
||||
<li>Send occasional marketing emails to subscribers who opt in.</li>
|
||||
<li>Comply with legal obligations and enforce our terms.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 mb-8">
|
||||
<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
|
||||
interests to operate and secure our services and to measure
|
||||
aggregated site usage, and on your consent for marketing emails. We
|
||||
may rely on contract and legal obligation where relevant.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 mb-8">
|
||||
<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">
|
||||
<li>
|
||||
<span className="text-foreground">Hosting:</span> Vercel
|
||||
(marketing site) and Railway (application) for serving content,
|
||||
networking, and security.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground">Analytics:</span> Simple
|
||||
Analytics for aggregated, privacy‑friendly usage metrics on the
|
||||
marketing site.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground">Email delivery:</span> We send
|
||||
transactional emails and, for subscribers who opt in, occasional
|
||||
marketing emails.
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-muted-foreground">
|
||||
We do not sell your personal information. We may disclose
|
||||
information if required by law or to protect our rights, users, or
|
||||
the public.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 mb-8">
|
||||
<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
|
||||
purposes described in this policy, including security, analytics,
|
||||
and legal compliance. Aggregated analytics do not identify
|
||||
individuals.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 mb-8">
|
||||
<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
|
||||
of residence. Where required, we implement appropriate safeguards
|
||||
for cross‑border transfers.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 mb-8">
|
||||
<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,
|
||||
delete, or export your information; to object to or restrict certain
|
||||
processing; and to withdraw consent where processing is based on
|
||||
consent. To exercise these rights, contact us using the details on
|
||||
our website. We may ask you to verify your identity before acting on
|
||||
a request.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 mb-8">
|
||||
<h2 className="text-xl font-medium">Contact</h2>
|
||||
<p className="text-muted-foreground">
|
||||
For privacy requests or questions, email us at
|
||||
<a
|
||||
href="mailto:hey@usesend.com"
|
||||
className="ml-1 underline decoration-dotted"
|
||||
>
|
||||
hey@usesend.com
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 mb-8">
|
||||
<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
|
||||
collect personal information from children.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 mb-10">
|
||||
<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
|
||||
below reflects the most recent changes.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Last updated: {new Date().toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
124
apps/marketing/src/app/terms/page.tsx
Normal file
124
apps/marketing/src/app/terms/page.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
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.",
|
||||
};
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-sidebar-background text-foreground">
|
||||
<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>
|
||||
<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.
|
||||
By accessing or using our site or product, you agree to be bound by
|
||||
these Terms.
|
||||
</p>
|
||||
|
||||
<section className="space-y-3 mb-8">
|
||||
<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
|
||||
contract with useSend and are not barred from doing so under any
|
||||
applicable laws. You are responsible for maintaining the security of
|
||||
your account credentials and for all activity under your account.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 mb-8">
|
||||
<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">
|
||||
<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>
|
||||
<li>
|
||||
Uploading or transmitting malicious code, spam, or prohibited
|
||||
content.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 mb-8">
|
||||
<h2 className="text-xl font-medium">Intellectual Property</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Content on the site, including trademarks, logos, text, and
|
||||
graphics, is owned by or licensed to useSend and protected by
|
||||
intellectual property laws. You may not use our marks without our
|
||||
prior written permission.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 mb-8">
|
||||
<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
|
||||
not control. We are not responsible for their content or practices.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 mb-8">
|
||||
<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
|
||||
warranties of any kind, express or implied.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 mb-8">
|
||||
<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
|
||||
for any indirect, incidental, special, consequential or punitive
|
||||
damages, or any loss of profits or revenues.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 mb-8">
|
||||
<h2 className="text-xl font-medium">Indemnification</h2>
|
||||
<p className="text-muted-foreground">
|
||||
You agree to indemnify and hold harmless useSend from any claims,
|
||||
damages, liabilities, and expenses arising out of your use of the
|
||||
site or product or your violation of these Terms.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 mb-8">
|
||||
<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
|
||||
time. Changes are effective when posted. We may suspend or
|
||||
discontinue the site or product in whole or in part.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 mb-8">
|
||||
<h2 className="text-xl font-medium">Governing Law</h2>
|
||||
<p className="text-muted-foreground">
|
||||
These Terms are governed by applicable laws without regard to
|
||||
conflict‑of‑law principles. Where required, disputes will be subject
|
||||
to the jurisdiction of competent courts in your place of residence
|
||||
or as otherwise mandated by law.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 mb-10">
|
||||
<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>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p className="text-xs text-muted-foreground">Last updated: {new Date().toLocaleDateString()}</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
61
apps/marketing/src/components/FeatureCard.tsx
Normal file
61
apps/marketing/src/components/FeatureCard.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
|
||||
export function FeatureCard({
|
||||
title,
|
||||
content,
|
||||
imageSrc,
|
||||
}: {
|
||||
title?: string;
|
||||
content?: string;
|
||||
imageSrc?: 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="relative w-full aspect-[16/9] rounded-t-xl">
|
||||
{imageSrc ? (
|
||||
<Image
|
||||
src={imageSrc}
|
||||
alt={title || "Feature image"}
|
||||
fill
|
||||
className="object-cover rounded-t-xl"
|
||||
priority={false}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Image
|
||||
src="/hero-light.png"
|
||||
alt="Feature image"
|
||||
fill
|
||||
className="object-cover dark:hidden rounded-t-xl"
|
||||
priority={false}
|
||||
/>
|
||||
<Image
|
||||
src="/hero-dark.png"
|
||||
alt="Feature image"
|
||||
fill
|
||||
className="object-cover hidden dark:block rounded-t-xl"
|
||||
priority={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-5 flex-1 flex flex-col">
|
||||
<h3 className="text-base sm:text-lg text-primary font-sans">
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
28
apps/marketing/src/components/FeatureCardPlain.tsx
Normal file
28
apps/marketing/src/components/FeatureCardPlain.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
export function FeatureCardPlain({
|
||||
title,
|
||||
content,
|
||||
}: {
|
||||
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 || ""}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,42 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
|
||||
const REPO = "unsend-dev/unsend";
|
||||
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
|
||||
|
||||
export function GitHubStarsButton() {
|
||||
const [stars, setStars] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch(API_URL, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const json = await res.json();
|
||||
if (!cancelled && typeof json.stargazers_count === "number") {
|
||||
setStars(json.stargazers_count);
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore network errors; keep placeholder
|
||||
}
|
||||
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" },
|
||||
];
|
||||
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$/, "");
|
||||
return str + u.s;
|
||||
}
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
const formatted = stars?.toLocaleString() ?? "—";
|
||||
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",
|
||||
};
|
||||
if (process.env.GITHUB_TOKEN)
|
||||
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
||||
|
||||
let stars: number | null = null;
|
||||
try {
|
||||
const res = await fetch(API_URL, {
|
||||
headers,
|
||||
next: { revalidate: REVALIDATE_SECONDS },
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = (await res.json()) as { 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);
|
||||
|
||||
return (
|
||||
<Button variant="outline" size="lg" className="px-4 gap-2">
|
||||
@@ -48,7 +59,10 @@ export function GitHubStarsButton() {
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<GitHubIcon className="h-4 w-4" />
|
||||
<span>Star on GitHub</span>
|
||||
<span>GitHub</span>
|
||||
<span className="rounded-md bg-muted px-1.5 py-0.5 text-xs tabular-nums text-muted-foreground">
|
||||
{formatted}
|
||||
</span>
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
|
108
apps/marketing/src/components/TopNav.tsx
Normal file
108
apps/marketing/src/components/TopNav.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
"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";
|
||||
|
||||
const REPO = "unsend-dev/unsend";
|
||||
const REPO_URL = `https://github.com/${REPO}`;
|
||||
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";
|
||||
|
||||
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>
|
||||
</Link>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<nav className="hidden sm:flex items-center gap-4 text-muted-foreground">
|
||||
<Link href={pricingHref} className="hover:text-foreground">
|
||||
Pricing
|
||||
</Link>
|
||||
<a
|
||||
href="https://docs.usesend.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground"
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
<a
|
||||
href={REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
<Button size="sm" className="ml-2">
|
||||
<a href={APP_URL} target="_blank" rel="noopener noreferrer">
|
||||
Get started
|
||||
</a>
|
||||
</Button>
|
||||
</nav>
|
||||
|
||||
{/* 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"
|
||||
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">
|
||||
{open ? (
|
||||
<path d="M6 18 18 6M6 6l12 12" strokeLinecap="round" strokeLinejoin="round" />
|
||||
) : (
|
||||
<path d="M3 6h18M3 12h18M3 18h18" strokeLinecap="round" strokeLinejoin="round" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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)}>
|
||||
Pricing
|
||||
</Link>
|
||||
<a
|
||||
href="https://docs.usesend.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="py-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
<a
|
||||
href={REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="py-2 text-muted-foreground hover:text-foreground"
|
||||
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)}>
|
||||
Get started
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
253
apps/web/prisma/seed_dashboard.sql
Normal file
253
apps/web/prisma/seed_dashboard.sql
Normal file
@@ -0,0 +1,253 @@
|
||||
-- Seed data for a photogenic dashboard screenshot
|
||||
-- Postgres SQL compatible with the Prisma schema in apps/web/prisma/schema.prisma
|
||||
-- Usage:
|
||||
-- psql "$DATABASE_URL" -f apps/web/prisma/seed_dashboard.sql
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1) Create a team (allow duplicates for demo runs)
|
||||
INSERT INTO "Team" ("name", "plan", "isActive", "apiRateLimit", "createdAt", "updatedAt")
|
||||
VALUES ('Acme Inc', 'BASIC'::"Plan", TRUE, 10, NOW(), NOW());
|
||||
|
||||
-- 2) Ensure a verified domain for that team (upsert by unique name)
|
||||
INSERT INTO "Domain" (
|
||||
"name", "teamId", "status", "region", "clickTracking", "openTracking",
|
||||
"publicKey", "dkimSelector", "dmarcAdded", "createdAt", "updatedAt"
|
||||
)
|
||||
SELECT
|
||||
'mail.acme.test', id, 'SUCCESS'::"DomainStatus", 'us-east-1', TRUE, TRUE,
|
||||
'PUBLIC_KEY_SAMPLE', 'usesend', TRUE, NOW(), NOW()
|
||||
FROM "Team"
|
||||
WHERE "name" = 'Acme Inc'
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
ON CONFLICT ("name") DO UPDATE
|
||||
SET "updatedAt" = NOW();
|
||||
|
||||
-- 3) Cumulated totals to power headline KPIs (idempotent)
|
||||
INSERT INTO "CumulatedMetrics" ("teamId", "domainId", "delivered", "hardBounced", "complained")
|
||||
SELECT d."teamId", d.id, 125000, 750, 180
|
||||
FROM "Domain" d
|
||||
WHERE d."name" = 'mail.acme.test'
|
||||
ON CONFLICT ("teamId", "domainId") DO UPDATE
|
||||
SET "delivered" = EXCLUDED."delivered",
|
||||
"hardBounced" = EXCLUDED."hardBounced",
|
||||
"complained" = EXCLUDED."complained";
|
||||
|
||||
-- 4) 14 days of daily usage (TRANSACTIONAL) with internally consistent metrics
|
||||
INSERT INTO "DailyEmailUsage" (
|
||||
"teamId", "domainId", "date", "type",
|
||||
"sent", "delivered", "opened", "clicked",
|
||||
"bounced", "complained", "hardBounced",
|
||||
"createdAt", "updatedAt"
|
||||
)
|
||||
SELECT
|
||||
d."teamId",
|
||||
d.id AS "domainId",
|
||||
TO_CHAR((CURRENT_DATE - i)::date, 'YYYY-MM-DD') AS date,
|
||||
'TRANSACTIONAL'::"EmailUsageType" AS type,
|
||||
s.sent,
|
||||
s.delivered,
|
||||
s.opened,
|
||||
s.clicked,
|
||||
s.bounced,
|
||||
s.complained,
|
||||
s.hardBounced,
|
||||
NOW() - (i || ' days')::interval AS createdAt,
|
||||
NOW() - (i || ' days')::interval AS updatedAt
|
||||
FROM (
|
||||
SELECT id, "teamId" FROM "Domain" WHERE "name" = 'mail.acme.test' LIMIT 1
|
||||
) d,
|
||||
generate_series(0, 13) AS i,
|
||||
LATERAL (
|
||||
WITH season AS (
|
||||
SELECT
|
||||
CASE EXTRACT(DOW FROM (CURRENT_DATE - i))
|
||||
WHEN 0 THEN 0.80 -- Sunday
|
||||
WHEN 6 THEN 0.85 -- Saturday
|
||||
ELSE 1.00 -- Weekday
|
||||
END AS dow_factor,
|
||||
(1.0 + 0.06 * sin(2*pi() * ((13 - i)::float / 7.0))) AS wave
|
||||
), base AS (
|
||||
SELECT
|
||||
GREATEST(700, round((900 * dow_factor * wave) + 30*random()))::int AS sent_base
|
||||
FROM season
|
||||
), parts AS (
|
||||
SELECT
|
||||
sent_base AS sent,
|
||||
(0.006 + random()*0.007) AS bounce_frac
|
||||
FROM base
|
||||
)
|
||||
SELECT
|
||||
sent,
|
||||
(sent - floor(sent*bounce_frac)::int) AS delivered,
|
||||
floor((sent - floor(sent*bounce_frac)::int) * (0.60 + random()*0.15))::int AS opened,
|
||||
LEAST(
|
||||
floor((sent - floor(sent*bounce_frac)::int) * (0.18 + random()*0.12))::int,
|
||||
floor((sent - floor(sent*bounce_frac)::int) * 0.95)::int
|
||||
) AS clicked,
|
||||
floor(sent*bounce_frac)::int AS bounced,
|
||||
CASE WHEN random() < ((sent - floor(sent*bounce_frac)::int) * (0.0001 + random()*0.0002))
|
||||
THEN 1 ELSE 0 END AS complained,
|
||||
floor(floor(sent*bounce_frac)::int * (0.60 + random()*0.25))::int AS hardBounced
|
||||
FROM parts
|
||||
) s
|
||||
ON CONFLICT ("teamId", "domainId", "date", "type") DO NOTHING;
|
||||
|
||||
-- 5) 14 days of daily usage (MARKETING) with consistent metrics
|
||||
INSERT INTO "DailyEmailUsage" (
|
||||
"teamId", "domainId", "date", "type",
|
||||
"sent", "delivered", "opened", "clicked",
|
||||
"bounced", "complained", "hardBounced",
|
||||
"createdAt", "updatedAt"
|
||||
)
|
||||
SELECT
|
||||
d."teamId",
|
||||
d.id AS "domainId",
|
||||
TO_CHAR((CURRENT_DATE - i)::date, 'YYYY-MM-DD') AS date,
|
||||
'MARKETING'::"EmailUsageType" AS type,
|
||||
s.sent,
|
||||
s.delivered,
|
||||
s.opened,
|
||||
s.clicked,
|
||||
s.bounced,
|
||||
s.complained,
|
||||
s.hardBounced,
|
||||
NOW() - (i || ' days')::interval AS createdAt,
|
||||
NOW() - (i || ' days')::interval AS updatedAt
|
||||
FROM (
|
||||
SELECT id, "teamId" FROM "Domain" WHERE "name" = 'mail.acme.test' LIMIT 1
|
||||
) d,
|
||||
generate_series(0, 13) AS i,
|
||||
LATERAL (
|
||||
WITH season AS (
|
||||
SELECT
|
||||
CASE EXTRACT(DOW FROM (CURRENT_DATE - i))
|
||||
WHEN 0 THEN 0.75 -- Sunday
|
||||
WHEN 6 THEN 0.85 -- Saturday
|
||||
ELSE 1.00
|
||||
END AS dow_factor,
|
||||
(1.0 + 0.08 * sin(2*pi() * ((13 - i)::float / 7.0) + 0.6)) AS wave
|
||||
), base AS (
|
||||
SELECT
|
||||
GREATEST(500, round((700 * dow_factor * wave) + 40*random()))::int AS sent_base
|
||||
FROM season
|
||||
), parts AS (
|
||||
SELECT
|
||||
sent_base AS sent,
|
||||
(0.008 + random()*0.010) AS bounce_frac
|
||||
FROM base
|
||||
)
|
||||
SELECT
|
||||
sent,
|
||||
(sent - floor(sent*bounce_frac)::int) AS delivered,
|
||||
floor((sent - floor(sent*bounce_frac)::int) * (0.47 + random()*0.18))::int AS opened,
|
||||
LEAST(
|
||||
floor((sent - floor(sent*bounce_frac)::int) * (0.14 + random()*0.10))::int,
|
||||
floor((sent - floor(sent*bounce_frac)::int) * 0.90)::int
|
||||
) AS clicked,
|
||||
floor(sent*bounce_frac)::int AS bounced,
|
||||
CASE WHEN random() < ((sent - floor(sent*bounce_frac)::int) * (0.00005 + random()*0.00020))
|
||||
THEN 1 ELSE 0 END AS complained,
|
||||
floor(floor(sent*bounce_frac)::int * (0.60 + random()*0.25))::int AS hardBounced
|
||||
FROM parts
|
||||
) s
|
||||
ON CONFLICT ("teamId", "domainId", "date", "type") DO NOTHING;
|
||||
|
||||
-- 6) A recent campaign with healthy open/click, realistic bounce/complaint rates
|
||||
INSERT INTO "Campaign" (
|
||||
id, "name", "teamId", "from", cc, bcc, "replyTo", "domainId", subject, "previewText",
|
||||
html, content, "contactBookId", total, sent, delivered, opened, clicked, unsubscribed,
|
||||
bounced, "hardBounced", complained, status, "createdAt", "updatedAt"
|
||||
)
|
||||
SELECT
|
||||
('cmp_' || substr(md5(random()::text), 1, 12)) AS id,
|
||||
'August Promo – Back to Business' AS name,
|
||||
d."teamId" AS teamId,
|
||||
'Acme <noreply@mail.acme.test>' AS "from",
|
||||
ARRAY[]::text[] AS cc,
|
||||
ARRAY[]::text[] AS bcc,
|
||||
ARRAY['support@acme.test']::text[] AS "replyTo",
|
||||
d.id AS "domainId",
|
||||
'Save 30% on Pro' AS subject,
|
||||
'Limited-time offer for power users' AS "previewText",
|
||||
'<h1>Upgrade to Pro</h1><p>Unlock advanced features.</p>' AS html,
|
||||
NULL AS content,
|
||||
NULL AS "contactBookId",
|
||||
25000 AS total,
|
||||
24800 AS sent,
|
||||
24500 AS delivered,
|
||||
13600 AS opened,
|
||||
6200 AS clicked,
|
||||
90 AS unsubscribed,
|
||||
300 AS bounced,
|
||||
220 AS "hardBounced",
|
||||
5 AS complained,
|
||||
'SENT'::"CampaignStatus" AS status,
|
||||
NOW() - interval '2 days' AS "createdAt",
|
||||
NOW() - interval '1 days' AS "updatedAt"
|
||||
FROM "Domain" d
|
||||
JOIN "Team" t2 ON t2.id = d."teamId"
|
||||
WHERE d."name" = 'mail.acme.test'
|
||||
ORDER BY d.id DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- 7) A handful of recent emails (varied statuses) to make lists look alive
|
||||
DO $$
|
||||
DECLARE
|
||||
v_team_id INT;
|
||||
v_domain_id INT;
|
||||
BEGIN
|
||||
SELECT t.id, d.id INTO v_team_id, v_domain_id
|
||||
FROM "Team" t
|
||||
JOIN "Domain" d ON d."teamId" = t.id
|
||||
WHERE d."name" = 'mail.acme.test'
|
||||
ORDER BY d.id DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- delivered/opened
|
||||
INSERT INTO "Email" (
|
||||
id, "sesEmailId", "from", "to", "replyTo", cc, bcc, subject, text, html,
|
||||
"latestStatus", "teamId", "domainId", "createdAt", "updatedAt"
|
||||
) VALUES (
|
||||
('eml_' || substr(md5(random()::text), 1, 12)), NULL,
|
||||
'Acme <noreply@mail.acme.test>', ARRAY['user1@example.com'], ARRAY['support@acme.test'], ARRAY[]::text[], ARRAY[]::text[],
|
||||
'Welcome to Acme', 'Plaintext welcome', '<p>Welcome!</p>', 'OPENED'::"EmailStatus",
|
||||
v_team_id, v_domain_id, NOW() - interval '4 hours', NOW() - interval '3 hours'
|
||||
);
|
||||
|
||||
-- clicked
|
||||
INSERT INTO "Email" (
|
||||
id, "sesEmailId", "from", "to", "replyTo", cc, bcc, subject, text, html,
|
||||
"latestStatus", "teamId", "domainId", "createdAt", "updatedAt"
|
||||
) VALUES (
|
||||
('eml_' || substr(md5(random()::text), 1, 12)), NULL,
|
||||
'Acme <noreply@mail.acme.test>', ARRAY['user2@example.com'], ARRAY['support@acme.test'], ARRAY[]::text[], ARRAY[]::text[],
|
||||
'Get Started Guide', NULL, '<p>Click to learn more</p>', 'CLICKED'::"EmailStatus",
|
||||
v_team_id, v_domain_id, NOW() - interval '2 hours', NOW() - interval '1 hour'
|
||||
);
|
||||
|
||||
-- bounced
|
||||
INSERT INTO "Email" (
|
||||
id, "sesEmailId", "from", "to", "replyTo", cc, bcc, subject, text, html,
|
||||
"latestStatus", "teamId", "domainId", "createdAt", "updatedAt"
|
||||
) VALUES (
|
||||
('eml_' || substr(md5(random()::text), 1, 12)), NULL,
|
||||
'Acme <noreply@mail.acme.test>', ARRAY['bad@invalid.test'], ARRAY['support@acme.test'], ARRAY[]::text[], ARRAY[]::text[],
|
||||
'Delivery failed notice', NULL, '<p>Delivery failed</p>', 'BOUNCED'::"EmailStatus",
|
||||
v_team_id, v_domain_id, NOW() - interval '6 hours', NOW() - interval '5 hours'
|
||||
);
|
||||
|
||||
-- complained
|
||||
INSERT INTO "Email" (
|
||||
id, "sesEmailId", "from", "to", "replyTo", cc, bcc, subject, text, html,
|
||||
"latestStatus", "teamId", "domainId", "createdAt", "updatedAt"
|
||||
) VALUES (
|
||||
('eml_' || substr(md5(random()::text), 1, 12)), NULL,
|
||||
'Acme <noreply@mail.acme.test>', ARRAY['too.sensitive@example.com'], ARRAY['support@acme.test'], ARRAY[]::text[], ARRAY[]::text[],
|
||||
'Feedback request', 'We value your feedback', '<p>Please reply</p>', 'COMPLAINED'::"EmailStatus",
|
||||
v_team_id, v_domain_id, NOW() - interval '9 hours', NOW() - interval '8 hours'
|
||||
);
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
Rectangle,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
@@ -22,6 +23,37 @@ interface EmailChartProps {
|
||||
domain: string | null;
|
||||
}
|
||||
|
||||
const STACK_ORDER: string[] = [
|
||||
"delivered",
|
||||
"bounced",
|
||||
"complained",
|
||||
"opened",
|
||||
"clicked",
|
||||
] as const;
|
||||
|
||||
type StackKey = (typeof STACK_ORDER)[number];
|
||||
|
||||
function createRoundedTopShape(currentKey: StackKey) {
|
||||
const currentIndex = STACK_ORDER.indexOf(currentKey);
|
||||
return (props: any) => {
|
||||
const payload = props.payload as
|
||||
| Partial<Record<StackKey, number>>
|
||||
| undefined;
|
||||
let hasAbove = false;
|
||||
for (let i = currentIndex + 1; i < STACK_ORDER.length; i++) {
|
||||
const key = STACK_ORDER[i];
|
||||
const val = key ? (payload?.[key] ?? 0) : 0;
|
||||
if (val > 0) {
|
||||
hasAbove = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const radius = hasAbove ? [0, 0, 0, 0] : [2.5, 2.5, 0, 0];
|
||||
return <Rectangle {...props} radius={radius as any} />;
|
||||
};
|
||||
}
|
||||
|
||||
export default function EmailChart({ days, domain }: EmailChartProps) {
|
||||
const domainId = domain ? Number(domain) : undefined;
|
||||
const statusQuery = api.dashboard.emailTimeSeries.useQuery({
|
||||
@@ -197,15 +229,32 @@ export default function EmailChart({ days, domain }: EmailChartProps) {
|
||||
dataKey="delivered"
|
||||
stackId="a"
|
||||
fill={currentColors.delivered}
|
||||
shape={createRoundedTopShape("delivered")}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="bounced"
|
||||
stackId="a"
|
||||
fill={currentColors.bounced}
|
||||
shape={createRoundedTopShape("bounced")}
|
||||
/>
|
||||
<Bar dataKey="bounced" stackId="a" fill={currentColors.bounced} />
|
||||
<Bar
|
||||
dataKey="complained"
|
||||
stackId="a"
|
||||
fill={currentColors.complained}
|
||||
shape={createRoundedTopShape("complained")}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="opened"
|
||||
stackId="a"
|
||||
fill={currentColors.opened}
|
||||
shape={createRoundedTopShape("opened")}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="clicked"
|
||||
stackId="a"
|
||||
fill={currentColors.clicked}
|
||||
shape={createRoundedTopShape("clicked")}
|
||||
/>
|
||||
<Bar dataKey="opened" stackId="a" fill={currentColors.opened} />
|
||||
<Bar dataKey="clicked" stackId="a" fill={currentColors.clicked} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
@@ -94,7 +94,7 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
|
||||
<div className="w-1/2 border rounded-xl shadow p-4">
|
||||
<div className="flex justify-between">
|
||||
<div className=" flex items-center gap-2">
|
||||
<div className="text-muted-foreground">Bounce Rate</div>
|
||||
<div className="text-muted-foreground font-mono">Bounce Rate</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className=" h-3.5 w-3.5 text-muted-foreground" />
|
||||
@@ -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">
|
||||
<div className="text-2xl mt-2 font-mono">
|
||||
{metrics?.bounceRate.toFixed(2)}%
|
||||
</div>
|
||||
<StatusBadge status={bounceStatus} />
|
||||
@@ -242,7 +242,9 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
|
||||
</div>
|
||||
<div className="w-1/2 border rounded-xl shadow p-4">
|
||||
<div className=" flex items-center gap-2">
|
||||
<div className=" text-muted-foreground">Complaint Rate</div>
|
||||
<div className=" text-muted-foreground font-mono">
|
||||
Complaint Rate
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className=" h-3.5 w-3.5 text-muted-foreground" />
|
||||
@@ -254,7 +256,7 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-4">
|
||||
<div className="text-2xl mt-2">
|
||||
<div className="text-2xl mt-2 font-mono">
|
||||
{metrics?.complaintRate.toFixed(2)}%
|
||||
</div>
|
||||
<StatusBadge status={complaintStatus} />
|
||||
|
@@ -39,6 +39,7 @@ import { isSelfHosted } from "~/utils/common";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Badge } from "@usesend/ui/src/badge";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@usesend/ui/src/avatar";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
@@ -55,9 +55,9 @@ function getProviders() {
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
authorization: {
|
||||
params: {
|
||||
scope: 'read:user user:email'
|
||||
}
|
||||
}
|
||||
scope: "read:user user:email",
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
Reference in New Issue
Block a user