add rebrand landing page (#211)

This commit is contained in:
KM Koushik
2025-09-05 22:32:56 +10:00
committed by GitHub
parent 1c8bb550d9
commit 3e6d4d12df
24 changed files with 1508 additions and 513 deletions

View File

@@ -1,13 +0,0 @@
You are a Staff Engineer and an Expert in ReactJS, NextJS, JavaScript, TypeScript, HTML, CSS, NodeJS, Prisma, Postgres, and modern UI/UX frameworks (e.g., TailwindCSS, Shadcn, Radix). You are also great at scalling things. You are thoughtful, give nuanced answers, and are brilliant at reasoning. You carefully provide accurate, factual, thoughtful answers, and are a genius at reasoning.
- Follow the users requirements carefully & to the letter.
- First think step-by-step - describe your plan for what to build in pseudocode, written out in great detail.
- Always write correct, best practice, bug free, fully functional and working code also it should be aligned to listed rules down below at Code Implementation Guidelines .
- Focus on easy and readability code, over being performant.
- Fully implement all requested functionality.
- Leave NO todos, placeholders or missing pieces.
- Ensure code is complete! Verify thoroughly finalised.
- Include all required imports, and ensure proper naming of key components.
- Be concise Minimize any other prose.
- If you think there might not be a correct answer, you say so.
- If you do not know the answer, say so, instead of guessing.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -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>

View File

@@ -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>
);
}

View 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 privacyfriendly 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, privacyfriendly 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 crossborder 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>
);
}

View 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">ThirdParty Links</h2>
<p className="text-muted-foreground">
The site may contain links to thirdparty 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
conflictoflaw 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);

View 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>
);
}

View 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;

View File

@@ -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>

View File

@@ -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} />

View File

@@ -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,

View File

@@ -55,9 +55,9 @@ function getProviders() {
allowDangerousEmailAccountLinking: true,
authorization: {
params: {
scope: 'read:user user:email'
}
}
scope: "read:user user:email",
},
},
})
);
}

View File

@@ -107,6 +107,9 @@ const config = {
gray: {
DEFAULT: "hsl(var(--gray))",
},
"primary-light": {
DEFAULT: "hsl(var(--primary-light))",
},
},
borderRadius: {
lg: "var(--radius)",

View File

@@ -1,148 +0,0 @@
const codeTheme: {
[key: string]: React.CSSProperties;
} = {
hljs: {
display: "block",
overflowX: "auto",
padding: "0.5em",
background: "#070808",
color: "#d6deeb",
},
"hljs-keyword": {
color: "#c792ea",
fontStyle: "italic",
},
"hljs-built_in": {
color: "#addb67",
fontStyle: "italic",
},
"hljs-type": {
color: "#82aaff",
},
"hljs-literal": {
color: "#ff5874",
},
"hljs-number": {
color: "#F78C6C",
},
"hljs-regexp": {
color: "#5ca7e4",
},
"hljs-string": {
color: "#ecc48d",
},
"hljs-subst": {
color: "#d3423e",
},
"hljs-symbol": {
color: "#82aaff",
},
"hljs-class": {
color: "#ffcb8b",
},
"hljs-function": {
color: "#82AAFF",
},
"hljs-title": {
color: "#DCDCAA",
fontStyle: "italic",
},
"hljs-params": {
color: "#7fdbca",
},
"hljs-comment": {
color: "#637777",
fontStyle: "italic",
},
"hljs-doctag": {
color: "#7fdbca",
},
"hljs-meta": {
color: "#82aaff",
},
"hljs-meta-keyword": {
color: "#82aaff",
},
"hljs-meta-string": {
color: "#ecc48d",
},
"hljs-section": {
color: "#82b1ff",
},
"hljs-tag": {
color: "#7fdbca",
},
"hljs-name": {
color: "#7fdbca",
},
"hljs-builtin-name": {
color: "#7fdbca",
},
"hljs-attr": {
color: "#7fdbca",
},
"hljs-attribute": {
color: "#80cbc4",
},
"hljs-variable": {
color: "#addb67",
},
"hljs-bullet": {
color: "#d9f5dd",
},
"hljs-code": {
color: "#80CBC4",
},
"hljs-emphasis": {
color: "#c792ea",
fontStyle: "italic",
},
"hljs-strong": {
color: "#addb67",
fontWeight: "bold",
},
"hljs-formula": {
color: "#c792ea",
},
"hljs-link": {
color: "#ff869a",
},
"hljs-quote": {
color: "#697098",
fontStyle: "italic",
},
"hljs-selector-tag": {
color: "#ff6363",
},
"hljs-selector-id": {
color: "#fad430",
},
"hljs-selector-class": {
color: "#addb67",
fontStyle: "italic",
},
"hljs-selector-attr": {
color: "#c792ea",
fontStyle: "italic",
},
"hljs-selector-pseudo": {
color: "#c792ea",
fontStyle: "italic",
},
"hljs-template-tag": {
color: "#c792ea",
},
"hljs-template-variable": {
color: "#addb67",
},
"hljs-addition": {
color: "#addb67ff",
fontStyle: "italic",
},
"hljs-deletion": {
color: "#EF535090",
fontStyle: "italic",
},
};
export default codeTheme;

View File

@@ -16,7 +16,6 @@
"@types/node": "^22.15.2",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@types/react-syntax-highlighter": "^15.5.13",
"@usesend/eslint-config": "workspace:*",
"@usesend/tailwind-config": "workspace:*",
"@usesend/typescript-config": "workspace:*",
@@ -48,16 +47,17 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"framer-motion": "^12.9.2",
"hast-util-to-jsx-runtime": "^2.3.6",
"input-otp": "^1.4.2",
"lucide-react": "^0.503.0",
"next-themes": "^0.4.6",
"pnpm": "^10.9.0",
"react-hook-form": "^7.56.1",
"react-syntax-highlighter": "^15.6.1",
"recharts": "^2.15.3",
"shiki": "^3.3.0",
"sonner": "^2.0.3",
"tailwind-merge": "^3.2.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.3"
}
}
}

View File

@@ -0,0 +1,36 @@
import { BundledLanguage, codeToHast } from "shiki";
import { toJsxRuntime } from "hast-util-to-jsx-runtime";
import { Fragment } from "react";
import { jsx, jsxs } from "react/jsx-runtime";
import { cn } from "../lib/utils";
interface Props {
children: string;
lang: BundledLanguage;
className?: string;
}
export async function CodeBlock(props: Props) {
const out = await codeToHast(props.children, {
lang: props.lang,
themes: {
dark: "catppuccin-mocha",
light: "catppuccin-latte",
},
});
return toJsxRuntime(out, {
Fragment,
jsx,
jsxs,
components: {
// your custom `pre` element
pre: (nodeProps) => (
<pre
{...nodeProps}
className={cn(nodeProps.className, props.className)}
/>
),
},
}) as React.JSX.Element;
}

View File

@@ -1,101 +0,0 @@
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import js from "react-syntax-highlighter/dist/esm/languages/hljs/javascript";
import ruby from "react-syntax-highlighter/dist/esm/languages/hljs/ruby";
import php from "react-syntax-highlighter/dist/esm/languages/hljs/php";
import python from "react-syntax-highlighter/dist/esm/languages/hljs/python";
// import { nightOwl } from "react-syntax-highlighter/dist/esm/styles/hljs";
import codeTheme from "../code-theme";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs";
import { Button } from "./button";
import { ClipboardCopy, Check } from "lucide-react";
import { useState } from "react";
import { cn } from "../lib/utils";
export type Language = "js" | "ruby" | "php" | "python" | "curl";
export type CodeBlock = {
language: Language;
title?: string;
code: string;
};
type CodeProps = {
codeBlocks: CodeBlock[];
codeClassName?: string;
};
SyntaxHighlighter.registerLanguage("js", js);
SyntaxHighlighter.registerLanguage("ruby", ruby);
SyntaxHighlighter.registerLanguage("php", php);
SyntaxHighlighter.registerLanguage("python", python);
export const Code: React.FC<CodeProps> = ({ codeBlocks, codeClassName }) => {
const [selectedTab, setSelectedTab] = useState(
codeBlocks[0]?.language ?? "js"
);
const [isCopied, setIsCopied] = useState(false);
const copyToClipboard = async (code: string) => {
try {
await navigator.clipboard.writeText(code);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000); // Reset the icon back to clipboard after 2 seconds
} catch (err) {
alert("Failed to copy code");
}
};
return (
<div className="rounded-xl bg-background border shadow-[#1e293b] shadow-lg">
<Tabs
defaultValue={codeBlocks[0]?.language}
onValueChange={(val) => setSelectedTab(val as Language)}
>
<div className="flex justify-between items-center border-b py-1 px-2">
<TabsList className="w-full rounded-none justify-start bg-transparent h-12">
<div className="">
{codeBlocks.map((block) => (
<TabsTrigger
key={block.language}
value={block.language}
className="data-[state=active]:bg-accent py-0.5 px-4 "
>
{block.title || block.language}
</TabsTrigger>
))}
</div>
</TabsList>
<Button
size="icon"
variant="icon"
onClick={() =>
copyToClipboard(
codeBlocks.find((block) => block.language === selectedTab)
?.code || ""
)
}
>
{isCopied ? (
<Check className="h-4 w-4" />
) : (
<ClipboardCopy className="h-4 w-4" />
)}
</Button>
</div>
{codeBlocks.map((block) => (
<TabsContent
key={block.language}
value={block.language}
className="mt-0"
>
<div className={cn("overflow-auto rounded-b-xl", codeClassName)}>
<SyntaxHighlighter language={block.language} style={codeTheme}>
{block.code}
</SyntaxHighlighter>
</div>
</TabsContent>
))}
</Tabs>
</div>
);
};

View File

@@ -14,7 +14,7 @@ export const H1 = React.forwardRef<HTMLHeadingElement, TypographyProps>(
ref={ref}
className={cn(
// font-mono, larger and a bit bolder
" font-mono text-xl font-medium",
" font-mono text-xl font-medium text-primary",
className
)}
{...props}

View File

@@ -14,7 +14,7 @@
--popover: 220 2% 96%;
--popover-foreground: 234 16% 35%;
--primary: 200 65% 14%;
--primary: 167 34% 20%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
@@ -63,6 +63,7 @@
--purple: 266 85% 58%;
--yellow: 35 77% 49%;
--gray: 220 9% 46%;
--primary-light: 183 74% 35%;
}
.dark {
@@ -75,7 +76,7 @@
--popover: 240 21% 15%;
--popover-foreground: 226 64% 88%;
--primary: 220 23% 95%;
--primary: 167 64% 94%;
--primary-foreground: 240 23% 9%;
--secondary: 217.2 32.6% 17.5%;
@@ -122,6 +123,7 @@
--purple: 267 84% 81%;
--yellow: 41 86% 83%;
--gray: 218 11% 65%;
--primary-light: 170 57% 73%;
}
}
@@ -137,6 +139,18 @@
}
}
@media (prefers-color-scheme: dark) {
.shiki,
.shiki span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
/* Optional, if you also want font styles */
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
}
/* .app,
::before,
::after {

188
pnpm-lock.yaml generated
View File

@@ -283,7 +283,7 @@ importers:
version: 10.0.4(react@19.1.0)
usesend:
specifier: workspace:*
version: link:../../packages/sdk
version: link:../..
zod:
specifier: ^3.24.3
version: 3.24.3
@@ -608,6 +608,9 @@ importers:
framer-motion:
specifier: ^12.9.2
version: 12.9.2(react-dom@19.1.0)(react@19.1.0)
hast-util-to-jsx-runtime:
specifier: ^2.3.6
version: 2.3.6
input-otp:
specifier: ^1.4.2
version: 1.4.2(react-dom@19.1.0)(react@19.1.0)
@@ -623,12 +626,12 @@ importers:
react-hook-form:
specifier: ^7.56.1
version: 7.56.1(react@19.1.0)
react-syntax-highlighter:
specifier: ^15.6.1
version: 15.6.1(react@19.1.0)
recharts:
specifier: ^2.15.3
version: 2.15.3(react-dom@19.1.0)(react@19.1.0)
shiki:
specifier: ^3.3.0
version: 3.3.0
sonner:
specifier: ^2.0.3
version: 2.0.3(react-dom@19.1.0)(react@19.1.0)
@@ -654,9 +657,6 @@ importers:
'@types/react-dom':
specifier: ^19.1.2
version: 19.1.2(@types/react@19.1.2)
'@types/react-syntax-highlighter':
specifier: ^15.5.13
version: 15.5.13
'@usesend/eslint-config':
specifier: workspace:*
version: link:../eslint-config
@@ -7591,7 +7591,6 @@ packages:
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
dependencies:
'@types/ms': 2.1.0
dev: true
/@types/es-aggregate-error@1.0.6:
resolution: {integrity: sha512-qJ7LIFp06h1QE1aVxbVd+zJP2wdaugYXYfd6JxsyRMrYHaxb6itXPogW2tz+ylUJ1n1b+JF1PHyYCfYHm0dvUg==}
@@ -7610,7 +7609,6 @@ packages:
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
dependencies:
'@types/estree': 1.0.7
dev: true
/@types/estree@1.0.7:
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
@@ -7619,6 +7617,7 @@ packages:
resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==}
dependencies:
'@types/unist': 2.0.11
dev: true
/@types/hast@3.0.4:
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
@@ -7682,7 +7681,6 @@ packages:
/@types/ms@2.1.0:
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
dev: true
/@types/nlcst@2.0.3:
resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==}
@@ -7722,12 +7720,6 @@ packages:
dependencies:
'@types/react': 19.1.2
/@types/react-syntax-highlighter@15.5.13:
resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==}
dependencies:
'@types/react': 19.1.2
dev: true
/@types/react@19.1.2:
resolution: {integrity: sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==}
dependencies:
@@ -9023,28 +9015,14 @@ packages:
/character-entities-html4@2.1.0:
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
/character-entities-legacy@1.1.4:
resolution: {integrity: sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==}
dev: false
/character-entities-legacy@3.0.0:
resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
/character-entities@1.2.4:
resolution: {integrity: sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==}
dev: false
/character-entities@2.0.2:
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
dev: true
/character-reference-invalid@1.1.4:
resolution: {integrity: sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==}
dev: false
/character-reference-invalid@2.0.1:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
dev: true
/chardet@0.7.0:
resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==}
@@ -9232,10 +9210,6 @@ packages:
dependencies:
delayed-stream: 1.0.0
/comma-separated-tokens@1.0.8:
resolution: {integrity: sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==}
dev: false
/comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
@@ -9555,7 +9529,6 @@ packages:
resolution: {integrity: sha512-Wy+JTSbFThEOXQIR2L6mxJvEs+veIzpmqD7ynWxMXGpnk3smkHQOp6forLdHsKpAMW9iJpaBBIxz285t1n1C3w==}
dependencies:
character-entities: 2.0.2
dev: true
/decode-uri-component@0.4.1:
resolution: {integrity: sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==}
@@ -10706,7 +10679,6 @@ packages:
/estree-util-is-identifier-name@3.0.0:
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
dev: true
/estree-util-scope@1.0.0:
resolution: {integrity: sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==}
@@ -10908,12 +10880,6 @@ packages:
dependencies:
reusify: 1.1.0
/fault@1.0.4:
resolution: {integrity: sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==}
dependencies:
format: 0.2.2
dev: false
/fault@2.0.1:
resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==}
dependencies:
@@ -11058,6 +11024,7 @@ packages:
/format@0.2.2:
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
engines: {node: '>=0.4.x'}
dev: true
/forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
@@ -11524,10 +11491,6 @@ packages:
hast-util-whitespace: 3.0.0
unist-util-is: 6.0.0
/hast-util-parse-selector@2.2.5:
resolution: {integrity: sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==}
dev: false
/hast-util-parse-selector@3.1.1:
resolution: {integrity: sha512-jdlwBjEexy1oGz0aJ2f4GKMaVKkA9jwjr4MjAAI22E5fM/TXVZHuS5OpONtdeIkRKqAaryQ2E9xNQxijoThSZA==}
dependencies:
@@ -11627,7 +11590,6 @@ packages:
vfile-message: 4.0.2
transitivePeerDependencies:
- supports-color
dev: true
/hast-util-to-mdast@10.1.2:
resolution: {integrity: sha512-FiCRI7NmOvM4y+f5w32jPRzcxDIz+PUqDwEqn1A+1q2cdp3B8Gx7aVrXORdOKjMNDQsD1ogOr896+0jJHW1EFQ==}
@@ -11667,16 +11629,6 @@ packages:
dependencies:
'@types/hast': 3.0.4
/hastscript@6.0.0:
resolution: {integrity: sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==}
dependencies:
'@types/hast': 2.3.10
comma-separated-tokens: 1.0.8
hast-util-parse-selector: 2.2.5
property-information: 5.6.0
space-separated-tokens: 1.1.5
dev: false
/hastscript@7.2.0:
resolution: {integrity: sha512-TtYPq24IldU8iKoJQqvZOuhi5CyCQRAbvDOX0x1eW6rsHSxa/1i2CCiptNTotGHJ3VoHRGmqiv6/D3q113ikkw==}
dependencies:
@@ -11705,14 +11657,6 @@ packages:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
dev: false
/highlight.js@10.7.3:
resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
dev: false
/highlightjs-vue@1.0.0:
resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==}
dev: false
/hono@4.7.7:
resolution: {integrity: sha512-2PCpQRbN87Crty8/L/7akZN3UyZIAopSoRxCwRbJgUuV1+MHNFHzYFxZTg4v/03cXUm+jce/qa2VSBZpKBm3Qw==}
engines: {node: '>=16.9.0'}
@@ -11956,27 +11900,14 @@ packages:
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dev: true
/is-alphabetical@1.0.4:
resolution: {integrity: sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==}
dev: false
/is-alphabetical@2.0.1:
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
dev: true
/is-alphanumerical@1.0.4:
resolution: {integrity: sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==}
dependencies:
is-alphabetical: 1.0.4
is-decimal: 1.0.4
dev: false
/is-alphanumerical@2.0.1:
resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
dependencies:
is-alphabetical: 2.0.1
is-decimal: 2.0.1
dev: true
/is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
@@ -12072,13 +12003,8 @@ packages:
has-tostringtag: 1.0.2
dev: true
/is-decimal@1.0.4:
resolution: {integrity: sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==}
dev: false
/is-decimal@2.0.1:
resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
dev: true
/is-docker@2.2.1:
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
@@ -12121,13 +12047,8 @@ packages:
dependencies:
is-extglob: 2.1.1
/is-hexadecimal@1.0.4:
resolution: {integrity: sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==}
dev: false
/is-hexadecimal@2.0.1:
resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
dev: true
/is-interactive@2.0.0:
resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==}
@@ -12682,7 +12603,6 @@ packages:
/longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
dev: true
/loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
@@ -12695,13 +12615,6 @@ packages:
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dev: true
/lowlight@1.20.0:
resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==}
dependencies:
fault: 1.0.4
highlight.js: 10.7.3
dev: false
/lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -12830,7 +12743,6 @@ packages:
unist-util-stringify-position: 4.0.0
transitivePeerDependencies:
- supports-color
dev: true
/mdast-util-frontmatter@2.0.1:
resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==}
@@ -12939,7 +12851,6 @@ packages:
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
dev: true
/mdast-util-mdx-jsx@3.2.0:
resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==}
@@ -12958,7 +12869,6 @@ packages:
vfile-message: 4.0.2
transitivePeerDependencies:
- supports-color
dev: true
/mdast-util-mdx@3.0.0:
resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==}
@@ -12983,14 +12893,12 @@ packages:
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
dev: true
/mdast-util-phrasing@4.1.0:
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
dependencies:
'@types/mdast': 4.0.4
unist-util-is: 6.0.0
dev: true
/mdast-util-to-hast@13.2.0:
resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==}
@@ -13017,13 +12925,11 @@ packages:
micromark-util-decode-string: 2.0.1
unist-util-visit: 5.0.0
zwitch: 2.0.4
dev: true
/mdast-util-to-string@4.0.0:
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
dependencies:
'@types/mdast': 4.0.4
dev: true
/mdast@3.0.0:
resolution: {integrity: sha512-xySmf8g4fPKMeC07jXGz971EkLbWAJ83s4US2Tj9lEdnZ142UP5grN73H1Xd3HzrdbU5o9GYYP/y8F9ZSwLE9g==}
@@ -13071,7 +12977,6 @@ packages:
micromark-util-subtokenize: 2.1.0
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
dev: true
/micromark-extension-frontmatter@2.0.0:
resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==}
@@ -13233,7 +13138,6 @@ packages:
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
dev: true
/micromark-factory-label@2.0.1:
resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==}
@@ -13242,7 +13146,6 @@ packages:
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
dev: true
/micromark-factory-mdx-expression@2.0.3:
resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==}
@@ -13263,7 +13166,6 @@ packages:
dependencies:
micromark-util-character: 2.1.1
micromark-util-types: 2.0.2
dev: true
/micromark-factory-title@2.0.1:
resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==}
@@ -13272,7 +13174,6 @@ packages:
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
dev: true
/micromark-factory-whitespace@2.0.1:
resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==}
@@ -13281,7 +13182,6 @@ packages:
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
dev: true
/micromark-util-character@2.1.1:
resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
@@ -13293,7 +13193,6 @@ packages:
resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==}
dependencies:
micromark-util-symbol: 2.0.1
dev: true
/micromark-util-classify-character@2.0.1:
resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==}
@@ -13301,20 +13200,17 @@ packages:
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
dev: true
/micromark-util-combine-extensions@2.0.1:
resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==}
dependencies:
micromark-util-chunked: 2.0.1
micromark-util-types: 2.0.2
dev: true
/micromark-util-decode-numeric-character-reference@2.0.2:
resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==}
dependencies:
micromark-util-symbol: 2.0.1
dev: true
/micromark-util-decode-string@2.0.1:
resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==}
@@ -13323,7 +13219,6 @@ packages:
micromark-util-character: 2.1.1
micromark-util-decode-numeric-character-reference: 2.0.2
micromark-util-symbol: 2.0.1
dev: true
/micromark-util-encode@2.0.1:
resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
@@ -13342,19 +13237,16 @@ packages:
/micromark-util-html-tag-name@2.0.1:
resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==}
dev: true
/micromark-util-normalize-identifier@2.0.1:
resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==}
dependencies:
micromark-util-symbol: 2.0.1
dev: true
/micromark-util-resolve-all@2.0.1:
resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==}
dependencies:
micromark-util-types: 2.0.2
dev: true
/micromark-util-sanitize-uri@2.0.1:
resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
@@ -13370,7 +13262,6 @@ packages:
micromark-util-chunked: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
dev: true
/micromark-util-symbol@2.0.1:
resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
@@ -13400,7 +13291,6 @@ packages:
micromark-util-types: 2.0.2
transitivePeerDependencies:
- supports-color
dev: true
/micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
@@ -14180,17 +14070,6 @@ packages:
dependencies:
callsites: 3.1.0
/parse-entities@2.0.0:
resolution: {integrity: sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==}
dependencies:
character-entities: 1.2.4
character-entities-legacy: 1.1.4
character-reference-invalid: 1.1.4
is-alphanumerical: 1.0.4
is-decimal: 1.0.4
is-hexadecimal: 1.0.4
dev: false
/parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
dependencies:
@@ -14201,7 +14080,6 @@ packages:
is-alphanumerical: 2.0.1
is-decimal: 2.0.1
is-hexadecimal: 2.0.1
dev: true
/parse-json@5.2.0:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
@@ -14634,16 +14512,6 @@ packages:
- supports-color
dev: false
/prismjs@1.27.0:
resolution: {integrity: sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==}
engines: {node: '>=6'}
dev: false
/prismjs@1.30.0:
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
engines: {node: '>=6'}
dev: false
/process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
dev: false
@@ -14660,12 +14528,6 @@ packages:
object-assign: 4.1.1
react-is: 16.13.1
/property-information@5.6.0:
resolution: {integrity: sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==}
dependencies:
xtend: 4.0.2
dev: false
/property-information@6.5.0:
resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==}
dev: true
@@ -15125,20 +14987,6 @@ packages:
tslib: 2.8.1
dev: false
/react-syntax-highlighter@15.6.1(react@19.1.0):
resolution: {integrity: sha512-OqJ2/vL7lEeV5zTJyG7kmARppUjiB9h9udl4qHQjjgEos66z00Ia0OckwYfRxCSFrW8RJIBnsBwQsHZbVPspqg==}
peerDependencies:
react: '>= 0.14.0'
dependencies:
'@babel/runtime': 7.27.0
highlight.js: 10.7.3
highlightjs-vue: 1.0.0
lowlight: 1.20.0
prismjs: 1.30.0
react: 19.1.0
refractor: 3.6.0
dev: false
/react-transition-group@4.4.5(react-dom@19.1.0)(react@19.1.0):
resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
peerDependencies:
@@ -15302,14 +15150,6 @@ packages:
which-builtin-type: 1.2.1
dev: true
/refractor@3.6.0:
resolution: {integrity: sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==}
dependencies:
hastscript: 6.0.0
parse-entities: 2.0.0
prismjs: 1.27.0
dev: false
/refractor@4.9.0:
resolution: {integrity: sha512-nEG1SPXFoGGx+dcjftjv8cAjEusIh6ED1xhf5DG3C0x/k+rmZ2duKnc3QLpt6qeHv5fPb8uwN3VWN2BT7fr3Og==}
dependencies:
@@ -16300,10 +16140,6 @@ packages:
whatwg-url: 7.1.0
dev: true
/space-separated-tokens@1.1.5:
resolution: {integrity: sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==}
dev: false
/space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
@@ -16533,7 +16369,6 @@ packages:
resolution: {integrity: sha512-/Q6ld50hKYPH3d/r6nr117TZkHR0w0kGGIVfpG9N6D8NymRPM9RqCUv4pRpJ62E5DqOYx2AFpbZMyCPnjQCnOw==}
dependencies:
style-to-object: 1.0.8
dev: true
/style-to-object@1.0.8:
resolution: {integrity: sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==}
@@ -17596,11 +17431,6 @@ packages:
engines: {node: '>=4.0'}
dev: true
/xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
dev: false
/y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}