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..7820463 --- /dev/null +++ b/src/data/pricing.ts @@ -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; 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. +

+
+
+