From 972732ffa31754dc65ab79da79cbf0a31246e4ea Mon Sep 17 00:00:00 2001 From: zlshames Date: Fri, 18 Sep 2026 08:06:13 -0400 Subject: [PATCH 1/3] Add a hidden /pricing page for BlueBubbles Pro Creem validates that https://bluebubbles.app/pricing resolves, so the route exists now even though Pro is not announced. Hidden deliberately, four ways: not in NAV_LINKS or FOOTER_LINKS so nothing links to it, noindex so it stays out of search if the URL leaks, excluded from the sitemap, and NOT named in robots.txt -- a Disallow rule would publish the path to anyone who reads the file, which is the opposite of hiding it. Launch steps are in a comment at the top of the page. Three plans: $3/month, $30/year, $55 lifetime. The yearly saving is computed from the monthly price rather than asserted, so it cannot drift. The Pro feature list is taken from the server's capability catalogue -- every entry carries minimumTier: .pro in swift/Sources/BBPrivateAPICatalog/CapabilityCatalog.swift. Titles, summaries and macOS minimums are copied verbatim so the page and the server agree, and the source file is cited in src/data/pricing.ts. Nothing about the feature set is invented. Eleven capabilities are Pro: stickers, sticker reactions, pinning, muting, spam and junk, screening unknown senders, FaceTime from a client, Find My, Send Later, polls and conversation backgrounds. The twelve that stay free are listed too, since "Pro adds, it does not take away" is the more important message for existing users. Each feature shows its macOS minimum, which varies from 14 to 26 and materially affects what a subscriber actually gets. /pricing is asserted in verify-build.mjs: it is linked from nowhere, so a build that stopped emitting it would otherwise fail silently and break Creem's validation. Co-Authored-By: Claude Opus 5 --- astro.config.mjs | 4 +- scripts/verify-build.mjs | 3 + src/data/pricing.ts | 152 ++++++++++++++++++++++++++++++++++ src/pages/pricing/index.astro | 125 ++++++++++++++++++++++++++++ 4 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 src/data/pricing.ts create mode 100644 src/pages/pricing/index.astro diff --git a/astro.config.mjs b/astro.config.mjs index 439892f..55ea011 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -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 diff --git a/scripts/verify-build.mjs b/scripts/verify-build.mjs index 127fa39..402eb71 100644 --- a/scripts/verify-build.mjs +++ b/scripts/verify-build.mjs @@ -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', diff --git a/src/data/pricing.ts b/src/data/pricing.ts new file mode 100644 index 0000000..c61288f --- /dev/null +++ b/src/data/pricing.ts @@ -0,0 +1,152 @@ +/** 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. + * + * Sourced from the server's capability catalogue -- every entry below is a + * capability carrying `minimumTier: .pro` in + * `swift/Sources/BBPrivateAPICatalog/CapabilityCatalog.swift`. Titles, + * summaries and macOS minimums are copied from it rather than written here, so + * this page and the server agree. If a capability's tier changes there, change + * it here too. + */ +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: '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: 'pinning', + title: 'Pinning conversations', + summary: 'Pin a conversation to the top, in the same order as your other devices.', + macos: 14, + }, + { + id: 'muting', + title: 'Muting conversations', + summary: 'Silence a conversation, permanently or until a time you choose.', + macos: 14, + }, + { + 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: 'facetime', + title: 'FaceTime from a client', + summary: 'Answer or end a call, and create a link someone can join from anywhere.', + macos: 14, + }, + { + id: 'find-my', + title: 'Find My', + summary: 'See where your friends and devices are, and share your own location.', + macos: 14, + }, + { + 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', +] as const; diff --git a/src/pages/pricing/index.astro b/src/pages/pricing/index.astro new file mode 100644 index 0000000..36ac8ed --- /dev/null +++ b/src/pages/pricing/index.astro @@ -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'; +--- + + + + +
+
    + { + PLANS.map((plan) => ( +
  • + {plan.featured && ( + + Best value + + )} + +

    {plan.name}

    + +

    + ${plan.price} + {plan.cadence && {plan.cadence}} +

    + + {plan.note &&

    {plan.note}

    } + +

    {plan.description}

    +
  • + )) + } +
+ +
+

What Pro unlocks

+

+ Pro features run through the Private API on your own server Mac. Each one needs a + minimum macOS version, shown alongside it. +

+ +
    + { + PRO_FEATURES.map((feature) => ( +
  • +
    +

    {feature.title}

    + + macOS {feature.macos}+ + +
    +

    {feature.summary}

    +
  • + )) + } +
+
+ +
+

What stays free

+

+ Pro adds to BlueBubbles; it does not take anything away. These Private API features + remain free for everyone, as they always have been: +

+
    + { + FREE_FEATURES.map((name) => ( +
  • + {name} +
  • + )) + } +
+
+ +
+

How billing works

+

+ Purchases are handled by Creem 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. +

+

+ Questions? Ask us on Discord. +

+
+
+
From b398751ce57e70bfb991591a265d7af88f2a19af Mon Sep 17 00:00:00 2001 From: zlshames Date: Fri, 18 Sep 2026 08:17:58 -0400 Subject: [PATCH 2/3] Un-gate Find My on the pricing page Find My is a base-app feature and predates the tier -- it is listed as a working Private API feature in the site's own FAQ compatibility table, so gating it would have taken away something existing users already had. Moves it from the Pro list to the free list, leaving ten Pro capabilities. NOTE: this puts the website ahead of the server. The capability still carries minimumTier: .pro in swift/Sources/BBPrivateAPICatalog/CapabilityCatalog.swift, and the gate needs removing there before this page is published. The summary in swift/docs/SUBSCRIPTION_PLAN.md needs the same edit in that commit: "11 of 23" becomes "10 of 23", and Find My comes out of the gated list. Co-Authored-By: Claude Opus 5 --- src/data/pricing.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/data/pricing.ts b/src/data/pricing.ts index c61288f..d8245e7 100644 --- a/src/data/pricing.ts +++ b/src/data/pricing.ts @@ -108,12 +108,6 @@ export const PRO_FEATURES: readonly ProFeature[] = [ summary: 'Answer or end a call, and create a link someone can join from anywhere.', macos: 14, }, - { - id: 'find-my', - title: 'Find My', - summary: 'See where your friends and devices are, and share your own location.', - macos: 14, - }, { id: 'send-later', title: 'Send Later', @@ -149,4 +143,5 @@ export const FREE_FEATURES: readonly string[] = [ 'Emoji reactions', 'Text formatting', 'Managing groups', + 'Find My', ] as const; From edeb2c7713e75551b9d97a7a3a00300d56a8e25a Mon Sep 17 00:00:00 2001 From: zlshames Date: Fri, 18 Sep 2026 08:21:20 -0400 Subject: [PATCH 3/3] Lead the Pro list with FaceTime, and name pinning and muting for syncing FaceTime Calling moves to the top -- it is the most recognisable thing in the tier and was buried seventh. Pinning and muting are renamed to say what is actually being sold. The apps already pin and mute a chat; what Pro adds is that the state syncs across devices, which the old titles ("Pinning conversations", "Muting conversations") did not convey at all. They are now "Pinned Chat Syncing" and "Mute & Snooze Syncing", with summaries that lead on sync and, for muting, name snoozing explicitly. This means titles and summaries now deliberately diverge from the server's capability catalogue, where they are UI labels for the features card rather than copy for someone deciding whether to pay. The `id` and `macos` fields are still copied verbatim and remain the link back to the server, so a tier or macOS-floor change is still a one-to-one edit. The data file explains the split so nobody "restores" the catalogue strings later. FaceTime's summary also now says calls can be started, not just answered and ended, which the catalogue's own action list (dialFaceTime) supports. Co-Authored-By: Claude Opus 5 --- src/data/pricing.ts | 57 ++++++++++++++++++++++++++------------------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/src/data/pricing.ts b/src/data/pricing.ts index d8245e7..7820463 100644 --- a/src/data/pricing.ts +++ b/src/data/pricing.ts @@ -48,12 +48,18 @@ export const PLANS: readonly Plan[] = [ /** What Pro unlocks. * - * Sourced from the server's capability catalogue -- every entry below is a - * capability carrying `minimumTier: .pro` in - * `swift/Sources/BBPrivateAPICatalog/CapabilityCatalog.swift`. Titles, - * summaries and macOS minimums are copied from it rather than written here, so - * this page and the server agree. If a capability's tier changes there, change - * it here too. + * 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. */ @@ -65,6 +71,27 @@ export interface ProFeature { } 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', @@ -77,18 +104,6 @@ export const PRO_FEATURES: readonly ProFeature[] = [ summary: 'Send a sticker as a reaction to a message, the way a tapback attaches to it.', macos: 15, }, - { - id: 'pinning', - title: 'Pinning conversations', - summary: 'Pin a conversation to the top, in the same order as your other devices.', - macos: 14, - }, - { - id: 'muting', - title: 'Muting conversations', - summary: 'Silence a conversation, permanently or until a time you choose.', - macos: 14, - }, { id: 'junk-reporting', title: 'Spam and junk', @@ -102,12 +117,6 @@ export const PRO_FEATURES: readonly ProFeature[] = [ summary: 'See whether a sender is known, and accept one into your contacts.', macos: 26, }, - { - id: 'facetime', - title: 'FaceTime from a client', - summary: 'Answer or end a call, and create a link someone can join from anywhere.', - macos: 14, - }, { id: 'send-later', title: 'Send Later',