Skip to content

Commit 582803f

Browse files
committed
Add pricing page
1 parent 345ea77 commit 582803f

7 files changed

Lines changed: 277 additions & 12 deletions

File tree

apps/www/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"astro": "astro"
1313
},
1414
"dependencies": {
15+
"@livedot/shared": "workspace:*",
1516
"astro": "^6.0.8"
1617
},
1718
"devDependencies": {
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
---
2+
import { formatCount, formatHistory, formatRetention, pricingPlans } from "../data/pricing";
3+
---
4+
5+
<section class="mx-auto max-w-7xl px-6 py-28">
6+
<div class="mx-auto max-w-3xl text-center">
7+
<p class="mb-5 inline-flex items-center gap-3 border border-white/10 bg-white/4 px-4 py-2 text-xs uppercase tracking-[0.35em] text-on-surface-variant backdrop-blur">
8+
<span class="h-2 w-2 rounded-full bg-primary pulse-dot"></span>
9+
Pricing & limits
10+
</p>
11+
<h1 class="font-headline text-5xl font-bold leading-[0.92] tracking-[-0.08em] md:text-7xl">
12+
Pick the tier that fits your
13+
<span class="text-primary"> live traffic shape.</span>
14+
</h1>
15+
<p class="mx-auto mt-6 max-w-2xl text-lg leading-8 text-on-surface-variant">
16+
These plans are generated from the shared limit configuration used by Livedot itself, so the page reflects the actual site, connection, retention, and history ceilings defined in code.
17+
</p>
18+
</div>
19+
20+
<div class="mt-16 grid gap-5 lg:grid-cols-2 xl:grid-cols-4">
21+
{pricingPlans.map((plan) => (
22+
<article
23+
class:list={[
24+
"grid-panel relative flex h-full flex-col border p-8",
25+
plan.featured
26+
? "border-primary/35 bg-primary/[0.08] shadow-[0_0_40px_rgba(174,252,45,0.08)]"
27+
: "border-white/8 bg-surface-container",
28+
]}
29+
>
30+
<div class="relative z-10 flex h-full flex-col">
31+
<div class="flex items-start justify-between gap-4">
32+
<div>
33+
<p class="text-xs uppercase tracking-[0.32em] text-on-surface-variant">{plan.mode}</p>
34+
<h2 class="mt-3 font-headline text-3xl font-bold tracking-[-0.05em]">{plan.label}</h2>
35+
</div>
36+
{plan.featured && (
37+
<span class="rounded-full border border-primary/30 bg-primary/12 px-3 py-1 text-xs font-medium uppercase tracking-[0.25em] text-primary">
38+
Popular
39+
</span>
40+
)}
41+
</div>
42+
43+
<p class="mt-6 min-h-[3.5rem] text-lg leading-7 text-white">{plan.headline}</p>
44+
<p class="mt-3 min-h-[4.5rem] text-sm leading-6 text-on-surface-variant">{plan.summary}</p>
45+
46+
<ul class="mt-8 space-y-3 border-t border-white/8 pt-6 text-sm leading-6 text-on-surface-variant">
47+
<li>{formatCount(plan.config.maxWebsites, "websites")}</li>
48+
<li>{formatCount(plan.config.maxConnectionsPerSite, "connections per site")}</li>
49+
<li>{formatRetention(plan.config.eventRetentionMs)}</li>
50+
<li>{formatHistory(plan.config.historyMax)}</li>
51+
</ul>
52+
53+
<div class="mt-8 pt-2">
54+
<a
55+
href={plan.ctaHref}
56+
class:list={[
57+
"inline-flex w-full items-center justify-center rounded-md px-5 py-3 font-headline text-sm font-semibold tracking-[-0.03em] transition-all",
58+
plan.featured
59+
? "bg-primary text-on-primary-container hover:bg-primary-container"
60+
: "border border-outline-variant/60 bg-surface text-white hover:border-primary/40 hover:bg-surface-container-high",
61+
]}
62+
>
63+
{plan.ctaLabel}
64+
</a>
65+
</div>
66+
</div>
67+
</article>
68+
))}
69+
</div>
70+
71+
<div class="mt-12 grid gap-5 lg:grid-cols-[1.3fr_0.7fr]">
72+
<div class="grid-panel border border-white/8 bg-surface-container-high p-8">
73+
<p class="text-xs uppercase tracking-[0.32em] text-on-surface-variant">How to choose</p>
74+
<h2 class="mt-4 font-headline text-3xl font-bold tracking-[-0.05em]">Community for control, cloud tiers for managed capacity.</h2>
75+
<p class="mt-4 max-w-3xl text-base leading-7 text-on-surface-variant">
76+
The community edition stays uncapped because you operate the infrastructure yourself. The hosted tiers are segmented by how many sites, concurrent users, and retained events you need to watch in real time.
77+
</p>
78+
</div>
79+
80+
<div class="grid-panel border border-primary/20 bg-primary/[0.06] p-8">
81+
<p class="text-xs uppercase tracking-[0.32em] text-primary">Need more?</p>
82+
<h2 class="mt-4 font-headline text-3xl font-bold tracking-[-0.05em]">Custom rollout</h2>
83+
<p class="mt-4 text-base leading-7 text-on-surface-variant">
84+
If your traffic profile exceeds the published tiers, reach out for a custom plan and rollout support.
85+
</p>
86+
<a
87+
href="mailto:hello@livedot.dev"
88+
class="mt-8 inline-flex items-center justify-center rounded-md bg-primary px-5 py-3 font-headline text-sm font-semibold tracking-[-0.03em] text-on-primary-container transition-all hover:bg-primary-container"
89+
>
90+
Contact sales
91+
</a>
92+
</div>
93+
</div>
94+
</section>

apps/www/src/data/pricing.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { PLANS, planLabel, type PlanConfig, type PlanId } from "@livedot/shared/plans";
2+
3+
export type BillingMode = "Open source" | "Cloud";
4+
5+
export type PricingPlan = {
6+
id: PlanId;
7+
label: string;
8+
config: PlanConfig;
9+
mode: BillingMode;
10+
headline: string;
11+
summary: string;
12+
ctaLabel: string;
13+
ctaHref: string;
14+
featured?: boolean;
15+
};
16+
17+
const orderedPlanIds: PlanId[] = ["ce", "free", "pro", "max"];
18+
19+
const planDetails: Record<PlanId, Omit<PricingPlan, "id" | "label" | "config">> = {
20+
ce: {
21+
mode: "Open source",
22+
headline: "Unlimited limits for self-hosted teams.",
23+
summary: "Run Livedot yourself with no built-in caps on sites, concurrency, or retention.",
24+
ctaLabel: "View on GitHub",
25+
ctaHref: "https://github.com/mxvsh/livedot",
26+
},
27+
free: {
28+
mode: "Cloud",
29+
headline: "A lightweight cloud tier for one site.",
30+
summary: "Use it to try the live map, validate setup, and monitor one property with short retention.",
31+
ctaLabel: "Start Free",
32+
ctaHref: "https://cloud.livedot.dev",
33+
},
34+
pro: {
35+
mode: "Cloud",
36+
headline: "More headroom for growing products.",
37+
summary: "Built for teams that need deeper retention, higher concurrency, and room for a few sites.",
38+
ctaLabel: "Open Cloud",
39+
ctaHref: "https://cloud.livedot.dev",
40+
featured: true,
41+
},
42+
max: {
43+
mode: "Cloud",
44+
headline: "High-capacity limits for larger traffic.",
45+
summary: "Built for teams monitoring more sites, heavier bursts of traffic, and a full week of events.",
46+
ctaLabel: "Talk to Us",
47+
ctaHref: "mailto:hello@livedot.dev",
48+
},
49+
};
50+
51+
export const formatCount = (value: number, unit: string) => {
52+
if (value === 0) return `Unlimited ${unit}`;
53+
return `${new Intl.NumberFormat("en-US").format(value)} ${unit}`;
54+
};
55+
56+
export const formatRetention = (eventRetentionMs: number) => {
57+
if (eventRetentionMs === 0) return "Unlimited event retention";
58+
59+
const minutes = eventRetentionMs / 60_000;
60+
if (minutes < 60) return `${minutes} minutes of event retention`;
61+
62+
const hours = eventRetentionMs / 3_600_000;
63+
if (hours < 24) return `${hours} hours of event retention`;
64+
65+
const days = eventRetentionMs / 86_400_000;
66+
return `${days} days of event retention`;
67+
};
68+
69+
export const formatHistory = (historyMax: number) => {
70+
if (historyMax === 0) return "Unlimited live traffic history";
71+
72+
const totalSeconds = historyMax * 5;
73+
if (totalSeconds < 3600) {
74+
return `${Math.round(totalSeconds / 60)} minutes of live traffic`;
75+
}
76+
77+
if (totalSeconds < 86_400) {
78+
return `${Math.round(totalSeconds / 3600)} hours of live traffic`;
79+
}
80+
81+
return `${Math.round(totalSeconds / 86_400)} days of live traffic`;
82+
};
83+
84+
export const pricingPlans: PricingPlan[] = orderedPlanIds.map((id) => ({
85+
id,
86+
label: planLabel(id),
87+
config: PLANS[id],
88+
...planDetails[id],
89+
}));

apps/www/src/layouts/MainLayout.astro

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,23 @@ interface Props {
66
title: string;
77
description?: string;
88
pathname?: string;
9+
structuredData?: Record<string, unknown> | Array<Record<string, unknown>>;
10+
includeFaqSchema?: boolean;
911
}
1012
1113
const {
1214
title,
1315
description = siteMetadata.description,
1416
pathname = "/",
17+
structuredData = [],
18+
includeFaqSchema = false,
1519
} = Astro.props;
1620
1721
const canonicalUrl = new URL(pathname, Astro.site ?? Astro.url);
1822
const ogImageUrl = new URL(siteMetadata.ogImage, Astro.site ?? Astro.url);
1923
24+
const extraStructuredData = Array.isArray(structuredData) ? structuredData : [structuredData];
25+
2026
const pageJsonLd = {
2127
"@context": "https://schema.org",
2228
"@graph": [
@@ -39,17 +45,20 @@ const pageJsonLd = {
3945
priceCurrency: "USD",
4046
},
4147
},
42-
{
43-
"@type": "FAQPage",
44-
mainEntity: faqItems.map((item) => ({
45-
"@type": "Question",
46-
name: item.question,
47-
acceptedAnswer: {
48-
"@type": "Answer",
49-
text: item.answer,
50-
},
51-
})),
52-
},
48+
...(includeFaqSchema
49+
? [{
50+
"@type": "FAQPage",
51+
mainEntity: faqItems.map((item) => ({
52+
"@type": "Question",
53+
name: item.question,
54+
acceptedAnswer: {
55+
"@type": "Answer",
56+
text: item.answer,
57+
},
58+
})),
59+
}]
60+
: []),
61+
...extraStructuredData,
5362
],
5463
};
5564
---

apps/www/src/pages/index.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import FeaturesSection from "../components/FeaturesSection.astro";
88
import MainLayout from "../layouts/MainLayout.astro";
99
---
1010

11-
<MainLayout title="Livedot - Real-time User Visualization" pathname={Astro.url.pathname}>
11+
<MainLayout title="Livedot - Real-time User Visualization" pathname={Astro.url.pathname} includeFaqSchema={true}>
1212
<Header pathname={Astro.url.pathname} />
1313
<main>
1414
<HeroSection />

apps/www/src/pages/pricing.astro

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
import Footer from "../components/Footer.astro";
3+
import Header from "../components/Header.astro";
4+
import PricingSection from "../components/PricingSection.astro";
5+
import { formatCount, formatHistory, formatRetention, pricingPlans } from "../data/pricing";
6+
import { siteMetadata } from "../data/site";
7+
import MainLayout from "../layouts/MainLayout.astro";
8+
9+
const pricingPath = Astro.url.pathname;
10+
const pricingUrl = new URL(pricingPath, Astro.site ?? Astro.url).href;
11+
const homeUrl = new URL("/", Astro.site ?? Astro.url).href;
12+
13+
const pricingStructuredData = [
14+
{
15+
"@type": "WebPage",
16+
name: "Livedot Pricing",
17+
description: "Compare Livedot plan limits across the community and cloud tiers.",
18+
url: pricingUrl,
19+
isPartOf: {
20+
"@type": "WebSite",
21+
name: siteMetadata.name,
22+
url: homeUrl,
23+
},
24+
},
25+
{
26+
"@type": "BreadcrumbList",
27+
itemListElement: [
28+
{
29+
"@type": "ListItem",
30+
position: 1,
31+
name: "Home",
32+
item: homeUrl,
33+
},
34+
{
35+
"@type": "ListItem",
36+
position: 2,
37+
name: "Pricing",
38+
item: pricingUrl,
39+
},
40+
],
41+
},
42+
{
43+
"@type": "ItemList",
44+
name: "Livedot pricing plans",
45+
itemListElement: pricingPlans.map((plan, index) => ({
46+
"@type": "ListItem",
47+
position: index + 1,
48+
item: {
49+
"@type": "Service",
50+
name: `${siteMetadata.name} ${plan.label}`,
51+
serviceType: `${plan.mode} plan`,
52+
description: `${plan.summary} Includes ${formatCount(plan.config.maxWebsites, "websites")}, ${formatCount(plan.config.maxConnectionsPerSite, "connections per site")}, ${formatRetention(plan.config.eventRetentionMs).toLowerCase()}, and ${formatHistory(plan.config.historyMax).toLowerCase()}.`,
53+
url: pricingUrl,
54+
},
55+
})),
56+
},
57+
];
58+
---
59+
60+
<MainLayout
61+
title="Livedot Pricing - Plans for Live Traffic Monitoring"
62+
description="Compare Livedot plan limits across the community and cloud tiers, including websites, connections, event retention, and history capacity."
63+
pathname={pricingPath}
64+
structuredData={pricingStructuredData}
65+
>
66+
<Header pathname={Astro.url.pathname} />
67+
<main class="pt-20">
68+
<PricingSection />
69+
</main>
70+
<Footer />
71+
</MainLayout>

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)