add pricing calculator (#214)

This commit is contained in:
KM Koushik
2025-09-06 10:53:53 +10:00
committed by GitHub
parent 79d1ebaf36
commit 69470a4937
16 changed files with 454 additions and 63 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -1,13 +1,15 @@
import Image from "next/image";
import Link from "next/link";
import { SiteFooter } from "~/components/SiteFooter";
import { GitHubStarsButton } from "~/components/GitHubStarsButton";
import { Button } from "@usesend/ui/src/button";
import { TopNav } from "~/components/TopNav";
import { FeatureCard } from "~/components/FeatureCard";
import { FeatureCardPlain } from "~/components/FeatureCardPlain";
import { PricingCalculator } from "~/components/PricingCalculator";
import { CodeBlock } from "@usesend/ui/src/code-block";
const REPO = "unsend-dev/unsend";
const REPO = "usesend/usesend";
const REPO_URL = `https://github.com/${REPO}`;
const APP_URL = "https://app.usesend.com";
@@ -21,7 +23,7 @@ export default function Page() {
<CodeExample />
<Pricing />
<About />
<Footer />
<SiteFooter />
</main>
);
}
@@ -61,7 +63,7 @@ function Hero() {
<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"
src="/hero-light.webp"
alt="useSend product hero"
width={3456}
height={1914}
@@ -71,7 +73,7 @@ function Hero() {
priority={false}
/>
<Image
src="/hero-dark.png"
src="/hero-dark.webp"
alt="useSend product hero"
width={3456}
height={1914}
@@ -222,14 +224,16 @@ function Features() {
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"
imageLightSrc: "/emails-search-light.webp",
imageDarkSrc: "/emails-search-dark.webp",
},
{
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"
imageLightSrc: "/editor-light.webp",
imageDarkSrc: "/editor-dark.webp",
},
];
@@ -271,7 +275,8 @@ function Features() {
key={f.key}
title={f.title}
content={f.content}
imageSrc={f.imageSrc}
imageLightSrc={f.imageLightSrc}
imageDarkSrc={f.imageDarkSrc}
/>
))}
</div>
@@ -370,7 +375,7 @@ function Pricing() {
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
pay for what you use, the most affordable email platform
</p>
</div>
@@ -388,6 +393,10 @@ function Pricing() {
perks={paidPerks}
/>
</div>
<div className="mt-8">
<PricingCalculator />
</div>
</div>
</section>
);
@@ -469,50 +478,7 @@ function About() {
// 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>
);
}
// Footer moved to ~/components/SiteFooter
// Minimal inline icons (stroke-based, sleek)
function CheckIcon({ className = "" }: { className?: string }) {

View File

@@ -2,21 +2,47 @@
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({
title,
content,
imageLightSrc,
imageDarkSrc,
imageSrc,
}: {
title?: string;
content?: string;
imageSrc?: string;
}) {
}: FeatureCardProps) {
return (
<div className="rounded-[18px] bg-primary/20 p-1 ">
<div className="h-full rounded-[14px] bg-primary/20 p-0.5 shadow-sm">
<div className="bg-background rounded-xl h-full flex flex-col">
<div className="relative w-full aspect-[16/9] rounded-t-xl">
{imageSrc ? (
<div className="relative w-full aspect-[16/9] rounded-t-xl overflow-hidden">
{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
src={imageSrc}
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 className="p-5 flex-1 flex flex-col">

View File

@@ -1,6 +1,6 @@
import { Button } from "@usesend/ui/src/button";
const REPO = "unsend-dev/unsend";
const REPO = "usesend/usesend";
const REPO_URL = `https://github.com/${REPO}`;
const API_URL = `https://api.github.com/repos/${REPO}`;
const REVALIDATE_SECONDS = 60 * 60 * 24 * 7; // 7 days

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

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

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

View File

@@ -6,7 +6,7 @@ import { usePathname } from "next/navigation";
import { useState } from "react";
import { Button } from "@usesend/ui/src/button";
const REPO = "unsend-dev/unsend";
const REPO = "usesend/usesend";
const REPO_URL = `https://github.com/${REPO}`;
const APP_URL = "https://app.usesend.com";