Skip to content
Merged

N/A #37

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ export default defineConfig({
icon({ iconDir: 'src/icons' }),
mdx(),
sitemap({
filter: (page) => !page.includes('/404'),
// /pricing is not public yet -- it exists only so the URL resolves for
// Creem's validation. Drop this exclusion when Pro launches.
filter: (page) => !page.includes('/404') && !page.includes('/pricing'),
serialize(item) {
// Under build.format 'preserve', @astrojs/sitemap does not append a
// trailing slash to directory-index routes. Normalize so canonical URLs
Expand Down
3 changes: 3 additions & 0 deletions scripts/verify-build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ const REQUIRED_PAGES = [
'install/index.html',
'faq/index.html',
'donate/index.html',
// Not linked from anywhere, but Creem validates this URL -- so a build
// that stops emitting it should fail rather than pass quietly.
'pricing/index.html',
// Exact casing matters: GitHub Pages is case-sensitive and this path is in
// the old sitemap.
'install/privateAPI/index.html',
Expand Down
156 changes: 156 additions & 0 deletions src/data/pricing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/** BlueBubbles Pro plans.
*
* Purchases are handled by Creem and managed by the server, not the client
* apps, so nothing here links to an app store or an in-app purchase flow.
*/

export interface Plan {
id: 'monthly' | 'yearly' | 'lifetime';
name: string;
/** Price in USD. */
price: number;
/** Rendered after the price, e.g. "/month". Omitted for one-off purchases. */
cadence?: string;
description: string;
/** Small note under the price, e.g. annual equivalent or saving. */
note?: string;
featured?: boolean;
}

const MONTHLY = 3;
const YEARLY = 30;

export const PLANS: readonly Plan[] = [
{
id: 'monthly',
name: 'Monthly',
price: MONTHLY,
cadence: '/month',
description: 'Billed monthly. Cancel any time.',
},
{
id: 'yearly',
name: 'Yearly',
price: YEARLY,
cadence: '/year',
// 12 x $3 = $36, so the yearly plan saves $6 -- derived, not a claim.
note: `Save $${MONTHLY * 12 - YEARLY} a year versus monthly`,
description: 'Billed once a year. Cancel any time.',
featured: true,
},
{
id: 'lifetime',
name: 'Lifetime',
price: 55,
description: 'One payment. Yours for good, with no recurring charge.',
},
] as const;

/** What Pro unlocks.
*
* Every entry is a capability carrying `minimumTier: .pro` in
* `swift/Sources/BBPrivateAPICatalog/CapabilityCatalog.swift`. The `id` and
* `macos` fields are copied from it verbatim and are the link back to the
* server -- if a capability's tier or macOS floor changes there, change it
* here too.
*
* `title` and `summary` are deliberately NOT copied. The catalogue's strings
* are UI labels for the server's own features card; these are the marketing
* line for someone deciding whether to pay, and they lead with what the tier
* actually buys. Pinning and muting are the clearest case: the capability is
* not pinning a chat, which the apps already do, but having that state sync
* across every device.
*/
export interface ProFeature {
/** Catalogue id, for cross-referencing the server. */
id: string;
title: string;
summary: string;
/** Minimum macOS major version on the server Mac. */
macos: number;
}

export const PRO_FEATURES: readonly ProFeature[] = [
{
id: 'facetime',
title: 'FaceTime Calling',
summary:
"Answer, end and start FaceTime calls from a client, and create a link someone can join from anywhere.",
macos: 14,
},
{
id: 'pinning',
title: 'Pinned Chat Syncing',
summary:
"Pin conversations from a client and have them stay in sync across all your devices, in the same order everywhere.",
macos: 14,
},
{
id: 'muting',
title: 'Mute & Snooze Syncing',
summary:
"Silence a conversation permanently or snooze it until a time you choose, kept in sync across your devices.",
macos: 14,
},
{
id: 'stickers',
title: 'Stickers',
summary: 'Send a sticker, and place it on a message the way Messages does.',
macos: 14,
},
{
id: 'sticker-reactions',
title: 'Sticker reactions',
summary: 'Send a sticker as a reaction to a message, the way a tapback attaches to it.',
macos: 15,
},
{
id: 'junk-reporting',
title: 'Spam and junk',
summary:
'Report a conversation as junk, mark it as spam, and move it out of Unknown Senders.',
macos: 14,
},
{
id: 'screen-unknown-senders',
title: 'Screen unknown senders',
summary: 'See whether a sender is known, and accept one into your contacts.',
macos: 26,
},
{
id: 'send-later',
title: 'Send Later',
summary:
"Hand a message to iMessage to deliver later. Apple sends it whether or not this Mac is running, unlike the server's own scheduled messages.",
macos: 15,
},
{
id: 'polls',
title: 'Polls',
summary: 'Create a poll in a group and collect votes from everyone in it.',
macos: 26,
},
{
id: 'chat-backgrounds',
title: 'Conversation backgrounds',
summary: 'Set a background image for a conversation, shared with everyone in it.',
macos: 26,
},
] as const;

/** Capabilities that stay free, by title, for the "still free" note. */
export const FREE_FEATURES: readonly string[] = [
'Improved message sending',
'Bubble and screen effects',
'Threaded replies',
'Mentions',
'Edit a sent message',
'Unsend a message',
'Typing indicators',
'Mark as read or unread',
'Reactions',
'Emoji reactions',
'Text formatting',
'Managing groups',
'Find My',
] as const;
125 changes: 125 additions & 0 deletions src/pages/pricing/index.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
---
/*
* BlueBubbles Pro pricing.
*
* NOT PUBLIC YET. This route exists so that https://bluebubbles.app/pricing
* resolves for Creem's validation. It is deliberately undiscoverable:
* - not in NAV_LINKS or FOOTER_LINKS, so nothing on the site links to it;
* - noindex, so it stays out of search results if the URL leaks;
* - excluded from the sitemap in astro.config.mjs.
*
* It is not listed in robots.txt on purpose -- a Disallow rule would publish
* the path to anyone who reads the file, which is the opposite of hiding it.
*
* To launch: add it to NAV_LINKS and/or FOOTER_LINKS in src/data/site.ts, drop
* the `noindex` below, and remove the sitemap exclusion.
*/
import BaseLayout from '@layouts/BaseLayout.astro';
import PageHeader from '@components/PageHeader.astro';
import { PLANS, PRO_FEATURES, FREE_FEATURES } from '@data/pricing';
import { LINKS } from '@data/site';
---

<BaseLayout
title="BlueBubbles Pro - Pricing"
description="Pricing for BlueBubbles Pro."
path="/pricing/"
noindex
>
<PageHeader
title="BlueBubbles Pro"
lead="Support continued development and unlock BlueBubbles Pro. Choose the plan that suits you."
/>

<div class="mx-auto max-w-5xl px-5 py-12">
<ul class="grid gap-5 md:grid-cols-3">
{
PLANS.map((plan) => (
<li
class:list={[
'relative flex flex-col rounded-card border bg-raised p-6',
plan.featured ? 'border-accent shadow-lg' : 'border-line',
]}
>
{plan.featured && (
<span class="absolute -top-3 left-6 rounded-full bg-accent-solid px-3 py-1 text-xs font-semibold text-white">
Best value
</span>
)}

<h2 class="text-lg font-semibold text-strong">{plan.name}</h2>

<p class="mt-4 flex items-baseline gap-1">
<span class="text-4xl font-extrabold tracking-tight text-strong">${plan.price}</span>
{plan.cadence && <span class="text-sm font-medium text-muted">{plan.cadence}</span>}
</p>

{plan.note && <p class="mt-1 text-sm font-medium text-accent">{plan.note}</p>}

<p class="mt-4 text-sm text-muted">{plan.description}</p>
</li>
))
}
</ul>

<section class="mt-14">
<h2 class="text-2xl font-bold tracking-tight text-strong">What Pro unlocks</h2>
<p class="mt-2 max-w-2xl text-muted">
Pro features run through the Private API on your own server Mac. Each one needs a
minimum macOS version, shown alongside it.
</p>

<ul class="mt-6 grid gap-4 sm:grid-cols-2">
{
PRO_FEATURES.map((feature) => (
<li class="rounded-card border border-line bg-raised p-5">
<div class="flex items-start justify-between gap-3">
<h3 class="font-semibold text-strong">{feature.title}</h3>
<span class="shrink-0 rounded-full border border-line px-2 py-0.5 text-xs font-medium text-muted">
macOS {feature.macos}+
</span>
</div>
<p class="mt-2 text-sm text-muted">{feature.summary}</p>
</li>
))
}
</ul>
</section>

<section class="mt-12 rounded-card border border-line bg-raised p-6">
<h2 class="text-base font-semibold text-strong">What stays free</h2>
<p class="mt-2 text-sm text-muted">
Pro adds to BlueBubbles; it does not take anything away. These Private API features
remain free for everyone, as they always have been:
</p>
<ul class="mt-4 flex flex-wrap gap-2">
{
FREE_FEATURES.map((name) => (
<li class="rounded-full border border-line bg-page px-3 py-1 text-xs font-medium text-muted">
{name}
</li>
))
}
</ul>
</section>

<div class="mt-12 rounded-card border border-line bg-raised p-6">
<h2 class="text-base font-semibold text-strong">How billing works</h2>
<p class="mt-2 text-sm text-muted">
Purchases are handled by <a
href="https://www.creem.io"
rel="noopener"
class="font-medium text-accent underline underline-offset-2">Creem</a
> and managed by your BlueBubbles Server. Your subscription is not tied to the Google
Play, Microsoft, Snap, or Flathub listing, so it applies wherever you use BlueBubbles.
</p>
<p class="mt-3 text-sm text-muted">
Questions? Ask us on <a
href={LINKS.discord}
rel="noopener"
class="font-medium text-accent underline underline-offset-2">Discord</a
>.
</p>
</div>
</div>
</BaseLayout>
Loading