add pricing calculator (#214)
BIN
apps/marketing/public/editor-dark.webp
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
apps/marketing/public/editor-light.webp
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
apps/marketing/public/emails-search-dark.webp
Normal file
After Width: | Height: | Size: 36 KiB |
BIN
apps/marketing/public/emails-search-light.webp
Normal file
After Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 1.1 MiB |
BIN
apps/marketing/public/hero-dark.webp
Normal file
After Width: | Height: | Size: 82 KiB |
Before Width: | Height: | Size: 1.2 MiB |
BIN
apps/marketing/public/hero-light.webp
Normal file
After Width: | Height: | Size: 76 KiB |
@@ -1,13 +1,15 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { SiteFooter } from "~/components/SiteFooter";
|
||||||
import { GitHubStarsButton } from "~/components/GitHubStarsButton";
|
import { GitHubStarsButton } from "~/components/GitHubStarsButton";
|
||||||
import { Button } from "@usesend/ui/src/button";
|
import { Button } from "@usesend/ui/src/button";
|
||||||
import { TopNav } from "~/components/TopNav";
|
import { TopNav } from "~/components/TopNav";
|
||||||
import { FeatureCard } from "~/components/FeatureCard";
|
import { FeatureCard } from "~/components/FeatureCard";
|
||||||
import { FeatureCardPlain } from "~/components/FeatureCardPlain";
|
import { FeatureCardPlain } from "~/components/FeatureCardPlain";
|
||||||
|
import { PricingCalculator } from "~/components/PricingCalculator";
|
||||||
import { CodeBlock } from "@usesend/ui/src/code-block";
|
import { CodeBlock } from "@usesend/ui/src/code-block";
|
||||||
|
|
||||||
const REPO = "unsend-dev/unsend";
|
const REPO = "usesend/usesend";
|
||||||
const REPO_URL = `https://github.com/${REPO}`;
|
const REPO_URL = `https://github.com/${REPO}`;
|
||||||
const APP_URL = "https://app.usesend.com";
|
const APP_URL = "https://app.usesend.com";
|
||||||
|
|
||||||
@@ -21,7 +23,7 @@ export default function Page() {
|
|||||||
<CodeExample />
|
<CodeExample />
|
||||||
<Pricing />
|
<Pricing />
|
||||||
<About />
|
<About />
|
||||||
<Footer />
|
<SiteFooter />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -61,7 +63,7 @@ function Hero() {
|
|||||||
<div className="rounded-[18px] bg-primary/10 p-1 sm:p-1 ">
|
<div className="rounded-[18px] bg-primary/10 p-1 sm:p-1 ">
|
||||||
<div className="rounded-2xl bg-primary/20 p-1 sm:p-1 ">
|
<div className="rounded-2xl bg-primary/20 p-1 sm:p-1 ">
|
||||||
<Image
|
<Image
|
||||||
src="/hero-light.png"
|
src="/hero-light.webp"
|
||||||
alt="useSend product hero"
|
alt="useSend product hero"
|
||||||
width={3456}
|
width={3456}
|
||||||
height={1914}
|
height={1914}
|
||||||
@@ -71,7 +73,7 @@ function Hero() {
|
|||||||
priority={false}
|
priority={false}
|
||||||
/>
|
/>
|
||||||
<Image
|
<Image
|
||||||
src="/hero-dark.png"
|
src="/hero-dark.webp"
|
||||||
alt="useSend product hero"
|
alt="useSend product hero"
|
||||||
width={3456}
|
width={3456}
|
||||||
height={1914}
|
height={1914}
|
||||||
@@ -222,14 +224,16 @@ function Features() {
|
|||||||
title: "Analytics",
|
title: "Analytics",
|
||||||
content:
|
content:
|
||||||
"Track deliveries, opens, clicks, bounces and unsubscribes in real time with a simple, searchable log. Filter by domain, status, api key and export them. Track which campaigns perform best.",
|
"Track deliveries, opens, clicks, bounces and unsubscribes in real time with a simple, searchable log. Filter by domain, status, api key and export them. Track which campaigns perform best.",
|
||||||
imageSrc: "", // add an image like "/analytics.png"
|
imageLightSrc: "/emails-search-light.webp",
|
||||||
|
imageDarkSrc: "/emails-search-dark.webp",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "feature-editor",
|
key: "feature-editor",
|
||||||
title: "Marketing Email Editor",
|
title: "Marketing Email Editor",
|
||||||
content:
|
content:
|
||||||
"Design beautiful campaigns without code using a visual, notion like WYSIWYG editor that works in major email clients. Reuse templates and brand styles, and personalize with variables.",
|
"Design beautiful campaigns without code using a visual, notion like WYSIWYG editor that works in major email clients. Reuse templates and brand styles, and personalize with variables.",
|
||||||
imageSrc: "", // add an image like "/editor.png"
|
imageLightSrc: "/editor-light.webp",
|
||||||
|
imageDarkSrc: "/editor-dark.webp",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -271,7 +275,8 @@ function Features() {
|
|||||||
key={f.key}
|
key={f.key}
|
||||||
title={f.title}
|
title={f.title}
|
||||||
content={f.content}
|
content={f.content}
|
||||||
imageSrc={f.imageSrc}
|
imageLightSrc={f.imageLightSrc}
|
||||||
|
imageDarkSrc={f.imageDarkSrc}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -370,7 +375,7 @@ function Pricing() {
|
|||||||
PRICING
|
PRICING
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs sm:text-sm text-muted-foreground max-w-2xl mx-auto">
|
<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
|
pay for what you use, the most affordable email platform
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -388,6 +393,10 @@ function Pricing() {
|
|||||||
perks={paidPerks}
|
perks={paidPerks}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<PricingCalculator />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -469,50 +478,7 @@ function About() {
|
|||||||
|
|
||||||
// FAQ section removed per request
|
// FAQ section removed per request
|
||||||
|
|
||||||
function Footer() {
|
// Footer moved to ~/components/SiteFooter
|
||||||
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)
|
// Minimal inline icons (stroke-based, sleek)
|
||||||
function CheckIcon({ className = "" }: { className?: string }) {
|
function CheckIcon({ className = "" }: { className?: string }) {
|
||||||
|
@@ -2,21 +2,47 @@
|
|||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
|
type FeatureCardProps = {
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
imageLightSrc?: string;
|
||||||
|
imageDarkSrc?: string;
|
||||||
|
/**
|
||||||
|
* Deprecated: prefer imageLightSrc and imageDarkSrc
|
||||||
|
*/
|
||||||
|
imageSrc?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function FeatureCard({
|
export function FeatureCard({
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
|
imageLightSrc,
|
||||||
|
imageDarkSrc,
|
||||||
imageSrc,
|
imageSrc,
|
||||||
}: {
|
}: FeatureCardProps) {
|
||||||
title?: string;
|
|
||||||
content?: string;
|
|
||||||
imageSrc?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-[18px] bg-primary/20 p-1 ">
|
<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="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="bg-background rounded-xl h-full flex flex-col">
|
||||||
<div className="relative w-full aspect-[16/9] rounded-t-xl">
|
<div className="relative w-full aspect-[16/9] rounded-t-xl overflow-hidden">
|
||||||
{imageSrc ? (
|
{imageLightSrc || imageDarkSrc ? (
|
||||||
|
<>
|
||||||
|
<Image
|
||||||
|
src={(imageLightSrc || imageDarkSrc)!}
|
||||||
|
alt={title || "Feature image"}
|
||||||
|
fill
|
||||||
|
className="object-cover dark:hidden rounded-t-xl"
|
||||||
|
priority={false}
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
src={(imageDarkSrc || imageLightSrc)!}
|
||||||
|
alt={title || "Feature image"}
|
||||||
|
fill
|
||||||
|
className="object-cover hidden dark:block rounded-t-xl"
|
||||||
|
priority={false}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : imageSrc ? (
|
||||||
<Image
|
<Image
|
||||||
src={imageSrc}
|
src={imageSrc}
|
||||||
alt={title || "Feature image"}
|
alt={title || "Feature image"}
|
||||||
@@ -42,6 +68,7 @@ export function FeatureCard({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-12 sm:h-16 bg-gradient-to-b from-transparent via-background/60 to-background" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-5 flex-1 flex flex-col">
|
<div className="p-5 flex-1 flex flex-col">
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import { Button } from "@usesend/ui/src/button";
|
import { Button } from "@usesend/ui/src/button";
|
||||||
|
|
||||||
const REPO = "unsend-dev/unsend";
|
const REPO = "usesend/usesend";
|
||||||
const REPO_URL = `https://github.com/${REPO}`;
|
const REPO_URL = `https://github.com/${REPO}`;
|
||||||
const API_URL = `https://api.github.com/repos/${REPO}`;
|
const API_URL = `https://api.github.com/repos/${REPO}`;
|
||||||
const REVALIDATE_SECONDS = 60 * 60 * 24 * 7; // 7 days
|
const REVALIDATE_SECONDS = 60 * 60 * 24 * 7; // 7 days
|
||||||
|
177
apps/marketing/src/components/PricingCalculator.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type SliderProps = {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
onChange: (v: number) => void;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
suffix?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Slider({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
min = 0,
|
||||||
|
max = 100000,
|
||||||
|
step = 500,
|
||||||
|
suffix = "",
|
||||||
|
}: SliderProps) {
|
||||||
|
const id = React.useId();
|
||||||
|
const [dragging, setDragging] = React.useState(false);
|
||||||
|
const percent = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(100, ((value - min) / (max - min)) * 100)
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!dragging) return;
|
||||||
|
const stop = () => setDragging(false);
|
||||||
|
window.addEventListener("mouseup", stop);
|
||||||
|
window.addEventListener("touchend", stop);
|
||||||
|
window.addEventListener("pointerup", stop);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mouseup", stop);
|
||||||
|
window.removeEventListener("touchend", stop);
|
||||||
|
window.removeEventListener("pointerup", stop);
|
||||||
|
};
|
||||||
|
}, [dragging]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-40 sm:w-56 md:w-72 shrink-0">
|
||||||
|
<label htmlFor={id} className="text-sm font-medium block">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 text-xs sm:text-sm text-muted-foreground tabular-nums truncate">
|
||||||
|
{value.toLocaleString()} {suffix}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
onMouseDown={() => setDragging(true)}
|
||||||
|
onTouchStart={() => setDragging(true)}
|
||||||
|
onPointerDown={() => setDragging(true)}
|
||||||
|
className="w-full accent-primary"
|
||||||
|
aria-label={label}
|
||||||
|
aria-valuetext={`${value.toLocaleString()} ${suffix}`}
|
||||||
|
/>
|
||||||
|
{dragging && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute -top-9 left-0 -translate-x-1/2"
|
||||||
|
style={{ left: `${percent}%` }}
|
||||||
|
>
|
||||||
|
<div className="rounded-md bg-foreground px-2 py-1 text-[11px] font-medium text-background tabular-nums shadow whitespace-nowrap">
|
||||||
|
{value.toLocaleString()} {suffix}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PricingCalculator() {
|
||||||
|
// Rates from pricing copy
|
||||||
|
const MARKETING_RATE = 0.001; // $ per marketing email
|
||||||
|
const TRANSACTIONAL_RATE = 0.0004; // $ per transactional email
|
||||||
|
const MINIMUM_SPEND = 10; // $ minimum monthly spend
|
||||||
|
|
||||||
|
// Defaults chosen to total $10: 8000*$0.001 + 5000*$0.0004 = 10
|
||||||
|
const [marketing, setMarketing] = React.useState<number>(5000);
|
||||||
|
const [transactional, setTransactional] = React.useState<number>(12500);
|
||||||
|
|
||||||
|
const marketingCost = marketing * MARKETING_RATE;
|
||||||
|
const transactionalCost = transactional * TRANSACTIONAL_RATE;
|
||||||
|
const subtotal = marketingCost + transactionalCost;
|
||||||
|
const totalDue = Math.max(subtotal, MINIMUM_SPEND);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-[18px] bg-primary/20 p-1">
|
||||||
|
<div className="rounded-[14px] bg-primary/20 p-0.5 shadow-sm">
|
||||||
|
<div className="bg-background rounded-xl p-5 pb-10">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-sm uppercase tracking-wider text-primary">
|
||||||
|
Pricing Calculator
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs sm:text-sm text-muted-foreground">
|
||||||
|
Drag the sliders to estimate your monthly cost.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6">
|
||||||
|
<Slider
|
||||||
|
label="Marketing emails / month"
|
||||||
|
value={marketing}
|
||||||
|
onChange={setMarketing}
|
||||||
|
min={0}
|
||||||
|
max={3000000}
|
||||||
|
step={500}
|
||||||
|
suffix="emails"
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
label="Transactional emails / month"
|
||||||
|
value={transactional}
|
||||||
|
onChange={setTransactional}
|
||||||
|
min={0}
|
||||||
|
max={3000000}
|
||||||
|
step={500}
|
||||||
|
suffix="emails"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 grid grid-cols-1 sm:grid-cols-3 gap-4 items-center">
|
||||||
|
<div className="rounded-lg border border-primary/30 p-4">
|
||||||
|
<div className="text-xs text-muted-foreground">Marketing</div>
|
||||||
|
<div className="text-lg font-medium">
|
||||||
|
${marketingCost.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
@ ${MARKETING_RATE.toFixed(4)} each
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-primary/30 p-4">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Transactional
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-medium">
|
||||||
|
${transactionalCost.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
@ ${TRANSACTIONAL_RATE.toFixed(4)} each
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-primary/30 p-4 bg-primary/10">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Estimated Total
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl text-primary font-semibold">
|
||||||
|
${totalDue.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{subtotal < MINIMUM_SPEND
|
||||||
|
? "Minimum $10 applies"
|
||||||
|
: "before taxes"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PricingCalculator;
|
90
apps/marketing/src/components/SiteFooter.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { StatusBadge } from "~/components/StatusBadge";
|
||||||
|
|
||||||
|
const REPO = "usesend/usesend";
|
||||||
|
const REPO_URL = `https://github.com/${REPO}`;
|
||||||
|
const APP_URL = "https://app.usesend.com";
|
||||||
|
|
||||||
|
export function SiteFooter() {
|
||||||
|
return (
|
||||||
|
<footer className="py-10 border-t border-border">
|
||||||
|
<div className="mx-auto max-w-6xl px-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
|
||||||
|
<div className="flex items-center gap-2 sm:w-56">
|
||||||
|
<Image src="/logo-squircle.png" alt="useSend" width={24} height={24} />
|
||||||
|
<span className="text-primary font-mono">useSend</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:ml-auto flex items-start gap-4">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-2">Product</div>
|
||||||
|
<ul className="space-y-2 text-muted-foreground">
|
||||||
|
<li>
|
||||||
|
<a href={APP_URL} target="_blank" rel="noopener noreferrer" className="hover:text-foreground">
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href={REPO_URL} target="_blank" rel="noopener noreferrer" className="hover:text-foreground">
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://docs.usesend.com" target="_blank" rel="noopener noreferrer" className="hover:text-foreground">
|
||||||
|
Docs
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-2">Contact</div>
|
||||||
|
<ul className="space-y-2 text-muted-foreground">
|
||||||
|
<li>
|
||||||
|
<a href="mailto:hey@usesend.com" className="hover:text-foreground">Email</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://x.com/useSend_com" target="_blank" rel="noopener noreferrer" className="hover:text-foreground">
|
||||||
|
X (Twitter)
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://www.linkedin.com/company/use-send/" target="_blank" rel="noopener noreferrer" className="hover:text-foreground">
|
||||||
|
LinkedIn
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://bsky.app/profile/usesend.com" target="_blank" rel="noopener noreferrer" className="hover:text-foreground">
|
||||||
|
Bluesky
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-2">Company</div>
|
||||||
|
<ul className="space-y-2 text-muted-foreground">
|
||||||
|
<li>
|
||||||
|
<Link href="/privacy" className="hover:text-foreground">Privacy</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/terms" className="hover:text-foreground">Terms</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StatusBadge />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 text-xs text-muted-foreground mx-auto text-center">
|
||||||
|
© {new Date().getFullYear()} useSend. All rights reserved.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
131
apps/marketing/src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
type StatusState = "operational" | "degraded" | "down" | "unknown";
|
||||||
|
|
||||||
|
// Best-effort fetcher for Uptime Kuma public status page JSON.
|
||||||
|
// Falls back gracefully if the endpoint or CORS is not available.
|
||||||
|
async function fetchUptimeStatus(baseUrl: string): Promise<StatusState> {
|
||||||
|
const candidates = [
|
||||||
|
"/api/status-page/heartbeat/default", // specific uptime-kuma status page slug
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const path of candidates) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(baseUrl.replace(/\/$/, "") + path, {
|
||||||
|
// Always fetch from the browser; avoid caching too aggressively
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!res.ok) continue;
|
||||||
|
const data: any = await res.json().catch(() => null);
|
||||||
|
if (!data) continue;
|
||||||
|
|
||||||
|
// Heuristics across possible Kuma payloads
|
||||||
|
// 1) overallStatus or status fields
|
||||||
|
const overall = (data.overallStatus || data.status || "")
|
||||||
|
.toString()
|
||||||
|
.toLowerCase();
|
||||||
|
if (
|
||||||
|
overall.includes("up") ||
|
||||||
|
overall.includes("ok") ||
|
||||||
|
overall.includes("oper")
|
||||||
|
)
|
||||||
|
return "operational";
|
||||||
|
if (overall.includes("degrad") || overall.includes("partial"))
|
||||||
|
return "degraded";
|
||||||
|
if (
|
||||||
|
overall.includes("down") ||
|
||||||
|
overall.includes("outage") ||
|
||||||
|
overall.includes("incident")
|
||||||
|
)
|
||||||
|
return "down";
|
||||||
|
|
||||||
|
// 2) heartbeat style: if any monitor is down
|
||||||
|
if (Array.isArray(data.monitors) && Array.isArray(data.heartbeatList)) {
|
||||||
|
// If any lastHeartbeatStatus === 0 (down) => down
|
||||||
|
const isAnyDown = Object.values<any>(data.heartbeatList).some(
|
||||||
|
(arr: any[]) =>
|
||||||
|
Array.isArray(arr) && arr.some((hb) => hb?.status === 0)
|
||||||
|
);
|
||||||
|
if (isAnyDown) return "down";
|
||||||
|
return "operational";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Generic boolean hints
|
||||||
|
if (typeof data.allUp === "boolean")
|
||||||
|
return data.allUp ? "operational" : "down";
|
||||||
|
|
||||||
|
// Unknown but successful response
|
||||||
|
return "unknown";
|
||||||
|
} catch {
|
||||||
|
// Try next candidate
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge({
|
||||||
|
baseUrl = "https://status.usesend.com",
|
||||||
|
}: {
|
||||||
|
baseUrl?: string;
|
||||||
|
}) {
|
||||||
|
const [status, setStatus] = useState<StatusState>("unknown");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
const load = async () => {
|
||||||
|
const s = await fetchUptimeStatus(baseUrl);
|
||||||
|
if (mounted) setStatus(s);
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
const id = setInterval(load, 60_000); // refresh every 60s
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
clearInterval(id);
|
||||||
|
};
|
||||||
|
}, [baseUrl]);
|
||||||
|
|
||||||
|
const dotClass = useMemo(() => {
|
||||||
|
switch (status) {
|
||||||
|
case "operational":
|
||||||
|
return "bg-green";
|
||||||
|
case "degraded":
|
||||||
|
return "bg-yellow";
|
||||||
|
case "down":
|
||||||
|
return "bg-red";
|
||||||
|
default:
|
||||||
|
return "bg-muted-foreground";
|
||||||
|
}
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
const label = useMemo(() => {
|
||||||
|
switch (status) {
|
||||||
|
case "operational":
|
||||||
|
return "operational";
|
||||||
|
case "degraded":
|
||||||
|
return "degraded";
|
||||||
|
case "down":
|
||||||
|
return "outage";
|
||||||
|
default:
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={baseUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-primary/30 bg-primary/10 px-3 py-1 text-xs font-medium text-primary hover:bg-primary/20 transition-colors"
|
||||||
|
aria-label={`Service status: ${label}`}
|
||||||
|
title={`Status: ${label}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-2 w-2 rounded-full ${dotClass} shadow-[0_0_0_2px] shadow-background`}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
@@ -6,7 +6,7 @@ import { usePathname } from "next/navigation";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@usesend/ui/src/button";
|
import { Button } from "@usesend/ui/src/button";
|
||||||
|
|
||||||
const REPO = "unsend-dev/unsend";
|
const REPO = "usesend/usesend";
|
||||||
const REPO_URL = `https://github.com/${REPO}`;
|
const REPO_URL = `https://github.com/${REPO}`;
|
||||||
const APP_URL = "https://app.usesend.com";
|
const APP_URL = "https://app.usesend.com";
|
||||||
|
|
||||||
|
@@ -9,7 +9,7 @@
|
|||||||
--foreground: 234 16% 35%;
|
--foreground: 234 16% 35%;
|
||||||
|
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 222.2 84% 4.9%;
|
--card-foreground: 234 16% 35%;
|
||||||
|
|
||||||
--popover: 220 2% 96%;
|
--popover: 220 2% 96%;
|
||||||
--popover-foreground: 234 16% 35%;
|
--popover-foreground: 234 16% 35%;
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
--foreground: 226 64% 88%;
|
--foreground: 226 64% 88%;
|
||||||
|
|
||||||
--card: 222.2 84% 4.9%;
|
--card: 222.2 84% 4.9%;
|
||||||
--card-foreground: 210 40% 98%;
|
--card-foreground: 226 64% 88%;
|
||||||
|
|
||||||
--popover: 240 21% 15%;
|
--popover: 240 21% 15%;
|
||||||
--popover-foreground: 226 64% 88%;
|
--popover-foreground: 226 64% 88%;
|
||||||
|