diff --git a/apps/maple-research/README.md b/apps/maple-research/README.md index 8465952fc..37abe9164 100644 --- a/apps/maple-research/README.md +++ b/apps/maple-research/README.md @@ -203,6 +203,21 @@ not exposed on macOS: nix develop --no-update-lock-file .#android -c just android-build ``` +### Android subscription access + +Android is consumption-only: accounts can use existing subscriptions, team +access, API credits and subscription passes acquired elsewhere. The app does +not offer subscription checkout, credit purchases, paid upgrades, or billing +portal links that can start purchases. `/pricing` shows plan information without +mounting legacy checkout effects, including after an old `selected_plan` callback. +Payment API helpers also reject Android calls before requesting a checkout or +portal. This is an Android platform rule for all users, independent of app review +or server version flags; web, desktop and iOS keep their existing purchase flows. + +Android may explain that plans are managed on the website without linking to +checkout. Subscription support remains available by email. Google explicitly +allows [consumption-only apps and purchase communication outside the app](https://support.google.com/googleplay/android-developer/answer/10281818). + The CI scripts under `scripts/ci/` are the authority for PR and release-shaped platform builds. Some are platform-specific, remove build outputs or `node_modules`, and deliberately ignore local `.env*` files in favor of fixed diff --git a/apps/maple-research/frontend/src/billing/androidPurchasing.test.ts b/apps/maple-research/frontend/src/billing/androidPurchasing.test.ts new file mode 100644 index 000000000..685b83604 --- /dev/null +++ b/apps/maple-research/frontend/src/billing/androidPurchasing.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import * as platform from "@/utils/platform"; +import { + createCheckoutSession, + createZapriteCheckoutSession, + createZapriteUpgrade, + createZapriteUpgradeQuote, + fetchApiCreditBalance, + fetchBillingStatus, + fetchPortalUrl, + fetchTeamStatus, + openValidatedCheckoutUrl, + purchaseApiCredits, + purchaseApiCreditsZaprite +} from "./billingApi"; + +describe("Android consumption-only billing boundary", () => { + let android: ReturnType>; + let request: ReturnType>; + + beforeEach(() => { + android = spyOn(platform, "isAndroid").mockReturnValue(true); + request = spyOn(globalThis, "fetch").mockImplementation( + Object.assign( + async () => Response.json({ product_name: "Pro", can_chat: true, balance: 12345 }), + { preconnect: globalThis.fetch.preconnect } + ) + ); + }); + afterEach(() => { + request.mockRestore(); + android.mockRestore(); + }); + + const blocked: Array<[string, () => Promise]> = [ + ["card checkout", () => createCheckoutSession("token", "a@example.test", "pro", "/", "/")], + ["Bitcoin checkout", () => createZapriteCheckoutSession("token", "a@example.test", "pro", "/")], + ["card portal", () => fetchPortalUrl("token")], + ["Bitcoin quote", () => createZapriteUpgradeQuote("token", "max")], + ["Bitcoin upgrade", () => createZapriteUpgrade("token", "quote", "idempotency-key")], + ["existing checkout URL", () => openValidatedCheckoutUrl("https://pay.zaprite.com/checkout")], + [ + "card credits", + () => + purchaseApiCredits("token", { + credits: 10000, + email: "a@example.test", + success_url: "/", + cancel_url: "/" + }) + ], + [ + "Bitcoin credits", + () => + purchaseApiCreditsZaprite("token", { + credits: 10000, + email: "a@example.test", + success_url: "/" + }) + ] + ]; + + for (const [name, action] of blocked) { + test(`${name} is blocked before requesting or opening payment`, async () => { + await expect(action()).rejects.toThrow("Purchases are not available in the Android app."); + expect(request).not.toHaveBeenCalled(); + }); + } + + test("existing subscription, credits and team reads still reach billing", async () => { + expect((await fetchBillingStatus("token")).can_chat).toBe(true); + expect((await fetchApiCreditBalance("token")).balance).toBe(12345); + await fetchTeamStatus("token"); + expect(request).toHaveBeenCalledTimes(3); + }); +}); diff --git a/apps/maple-research/frontend/src/billing/billingApi.ts b/apps/maple-research/frontend/src/billing/billingApi.ts index 995a1b117..f8e340bb4 100644 --- a/apps/maple-research/frontend/src/billing/billingApi.ts +++ b/apps/maple-research/frontend/src/billing/billingApi.ts @@ -1,4 +1,4 @@ -import { isMobile, isTauri } from "@/utils/platform"; +import { isAndroid, isMobile, isTauri } from "@/utils/platform"; import { isAllowedZapriteCheckoutUrl, parseBillingApiError, @@ -12,6 +12,13 @@ export const MIN_PURCHASE_CREDITS = 10000; export const MIN_PURCHASE_AMOUNT = 10; export const MIN_PURCHASE_ERROR = `Minimum purchase is ${MIN_PURCHASE_CREDITS.toLocaleString()} credits ($${MIN_PURCHASE_AMOUNT})`; +/** Android consumes existing access; checkout and portals can offer new purchases. */ +function assertPurchasingAvailable(): void { + if (isAndroid()) { + throw new Error("Purchases are not available in the Android app."); + } +} + export type BillingPaymentProvider = "stripe" | "zaprite" | "subscription_pass" | "apple"; /** Management is independent of the provider selected to supply the current quota. */ @@ -167,6 +174,7 @@ export async function fetchProducts(version?: string): Promise } export async function fetchPortalUrl(thirdPartyToken: string): Promise { + assertPurchasingAvailable(); let returnUrl = window.location.origin; // For all Tauri platforms, use the actual website origin instead of tauri://localhost @@ -213,6 +221,7 @@ export async function createCheckoutSession( quantity?: number, assertCurrent?: () => void ): Promise { + assertPurchasingAvailable(); const requestBody = { email, product_id: productId, @@ -290,6 +299,7 @@ export async function createZapriteCheckoutSession( quantity?: number, assertCurrent?: () => void ): Promise { + assertPurchasingAvailable(); const requestBody = { email, product_id: productId, @@ -373,6 +383,7 @@ export async function createZapriteUpgradeQuote( thirdPartyToken: string, targetProductId: string ): Promise { + assertPurchasingAvailable(); const response = await fetch( `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/subscription/zaprite/upgrade-quotes`, { @@ -396,6 +407,7 @@ export async function createZapriteUpgrade( idempotencyKey: string, successUrl?: string ): Promise { + assertPurchasingAvailable(); const response = await fetch( `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/subscription/zaprite/upgrades`, { @@ -441,6 +453,7 @@ export async function fetchZapriteUpgradeStatus( } export async function openValidatedCheckoutUrl(checkoutUrl: string): Promise { + assertPurchasingAvailable(); if (!isAllowedZapriteCheckoutUrl(checkoutUrl)) { throw new Error("Checkout URL is not from the expected payment provider"); } @@ -860,6 +873,7 @@ export async function purchaseApiCredits( thirdPartyToken: string, data: PurchaseCreditsRequest ): Promise { + assertPurchasingAvailable(); const response = await fetch( `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/api-credits/purchase`, { @@ -891,6 +905,7 @@ export async function purchaseApiCreditsZaprite( thirdPartyToken: string, data: PurchaseCreditsZapriteRequest ): Promise { + assertPurchasingAvailable(); const response = await fetch( `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/api-credits/purchase-zaprite`, { diff --git a/apps/maple-research/frontend/src/components/AndroidNavigation.test.tsx b/apps/maple-research/frontend/src/components/AndroidNavigation.test.tsx new file mode 100644 index 000000000..cf76f3a96 --- /dev/null +++ b/apps/maple-research/frontend/src/components/AndroidNavigation.test.tsx @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { Children, isValidElement, type ReactNode } from "react"; +import * as platform from "@/utils/platform"; +import { Route as PrivacyRoute } from "@/routes/privacy"; +import { Route as TermsRoute } from "@/routes/terms"; +import { Footer } from "./Footer"; + +function destinations(node: ReactNode): string[] { + return Children.toArray(node).flatMap((child) => { + if (!isValidElement<{ href?: string; to?: string; children?: ReactNode }>(child)) return []; + const destination = child.props.href ?? child.props.to; + return [...(destination ? [destination] : []), ...destinations(child.props.children)]; + }); +} + +describe("Android information navigation", () => { + const cleanup: Array<() => void> = []; + function setAndroid(value: boolean) { + const androidSpy = spyOn(platform, "isAndroid").mockReturnValue(value); + const tauriSpy = spyOn(platform, "isTauri").mockReturnValue(value); + cleanup.push( + () => androidSpy.mockRestore(), + () => tauriSpy.mockRestore() + ); + } + afterEach(() => { + cleanup + .splice(0) + .reverse() + .forEach((restore) => restore()); + }); + + test("footer retains local legal pages and email support without marketing exits", () => { + setAndroid(true); + expect(destinations(Footer())).toEqual(["/privacy", "/terms", "mailto:support@trymaple.ai"]); + }); + + test("web footer preserves marketing and community navigation", () => { + setAndroid(false); + const links = destinations(Footer()); + expect(links).toContain("/pricing"); + expect(links).toContain("https://blog.trymaple.ai"); + expect(links).toContain("https://discord.gg/ch2gjZAMGy"); + }); + + for (const [name, route] of [ + ["Privacy", PrivacyRoute], + ["Terms", TermsRoute] + ] as const) { + test(`${name} keeps the website reference unlinked on Android`, () => { + const component = route.options.component as () => ReactNode; + setAndroid(true); + expect(destinations(component())).not.toContain("https://trymaple.ai"); + }); + test(`${name} preserves the website link outside Android`, () => { + const component = route.options.component as () => ReactNode; + setAndroid(false); + expect(destinations(component())).toContain("https://trymaple.ai"); + }); + } +}); diff --git a/apps/maple-research/frontend/src/components/Footer.tsx b/apps/maple-research/frontend/src/components/Footer.tsx index 3530096e5..d3643cd9b 100644 --- a/apps/maple-research/frontend/src/components/Footer.tsx +++ b/apps/maple-research/frontend/src/components/Footer.tsx @@ -3,12 +3,22 @@ import { Link } from "@tanstack/react-router"; import { DiscordIcon } from "./icons/DiscordIcon"; import { MARKETING_DOWNLOADS_URL } from "@/config/domains"; import { openExternalUrl } from "@/utils/openUrl"; -import { isTauri } from "@/utils/platform"; +import { isAndroid, isTauri } from "@/utils/platform"; import { GITHUB_REPOSITORY_URL } from "@/utils/githubRelease"; export function Footer() { const isTauriPlatform = isTauri(); + if (isAndroid()) { + return ( + + ); + } + return (
diff --git a/apps/maple-research/frontend/src/components/GuestPaymentWarningDialog.tsx b/apps/maple-research/frontend/src/components/GuestPaymentWarningDialog.tsx index 7c00c7ed5..9e1c01965 100644 --- a/apps/maple-research/frontend/src/components/GuestPaymentWarningDialog.tsx +++ b/apps/maple-research/frontend/src/components/GuestPaymentWarningDialog.tsx @@ -22,7 +22,7 @@ import { getBillingService } from "@/billing/billingService"; import { useChatRuntimeStore } from "@/contexts/ChatRuntimeContext"; import { beginAllChatRuntimeDeletionFence } from "@/services/chatRuntimeDeletionFence"; import { assertChatAccountCredential } from "@/services/chatAccountCredential"; -import { isIOS } from "@/utils/platform"; +import { isAndroid, isIOS } from "@/utils/platform"; interface GuestPaymentWarningDialogProps { open: boolean; @@ -145,9 +145,11 @@ export function GuestPaymentWarningDialog({ open, onOpenChange }: GuestPaymentWa Your anonymous account is not activated yet and cannot use the chat feature.

- {isIOS() - ? "You can subscribe with Apple or restore an existing Apple purchase. Keep your Maple Account ID and password so you can sign in again." - : "To start chatting with Maple AI, you need to subscribe to a paid plan. Anonymous accounts must pay for a full year using Bitcoin or redeem a subscription pass."} + {isAndroid() + ? "Purchases are not available in the Android app. Sign in with a Maple account that already has a subscription, including one purchased on the web, or redeem an existing subscription pass." + : isIOS() + ? "You can subscribe with Apple or restore an existing Apple purchase. Keep your Maple Account ID and password so you can sign in again." + : "To start chatting with Maple AI, you need to subscribe to a paid plan. Anonymous accounts must pay for a full year using Bitcoin or redeem a subscription pass."}

@@ -160,7 +162,7 @@ export function GuestPaymentWarningDialog({ open, onOpenChange }: GuestPaymentWa
+ )} + + + + + ); + } + return ( diff --git a/apps/maple-research/frontend/src/components/ZapriteUpgradeDialog.tsx b/apps/maple-research/frontend/src/components/ZapriteUpgradeDialog.tsx index 1dba4bb14..d79fa38cc 100644 --- a/apps/maple-research/frontend/src/components/ZapriteUpgradeDialog.tsx +++ b/apps/maple-research/frontend/src/components/ZapriteUpgradeDialog.tsx @@ -18,7 +18,7 @@ import { type ZapriteUpgradeQuote, type ZapriteUpgradeStatusResponse } from "@/billing/zapriteUpgrade"; -import { isIOS, isMobile, isTauri } from "@/utils/platform"; +import { isAndroid, isIOS, isMobile, isTauri } from "@/utils/platform"; type ZapriteUpgradeDialogProps = { open: boolean; @@ -65,7 +65,12 @@ function statusCopy(status: string, planName?: string): string { } } -export function ZapriteUpgradeDialog({ +export function ZapriteUpgradeDialog(props: ZapriteUpgradeDialogProps) { + if (isAndroid()) return null; + return ; +} + +function WebZapriteUpgradeDialog({ open, onOpenChange, userId, diff --git a/apps/maple-research/frontend/src/components/apikeys/ApiCreditsSection.test.tsx b/apps/maple-research/frontend/src/components/apikeys/ApiCreditsSection.test.tsx index ad2a816ae..acf6e1371 100644 --- a/apps/maple-research/frontend/src/components/apikeys/ApiCreditsSection.test.tsx +++ b/apps/maple-research/frontend/src/components/apikeys/ApiCreditsSection.test.tsx @@ -23,9 +23,12 @@ describe("API credits platform policy", () => { .forEach((restore) => restore()); }); - async function mount(ios: boolean) { - const iosSpy = spyOn(platform, "isIOS").mockReturnValue(ios); - const mobileSpy = spyOn(platform, "isMobile").mockReturnValue(ios); + async function mount(platformName: "ios" | "android" | "web" | "desktop") { + const iosSpy = spyOn(platform, "isIOS").mockReturnValue(platformName === "ios"); + const androidSpy = spyOn(platform, "isAndroid").mockReturnValue(platformName === "android"); + const mobileSpy = spyOn(platform, "isMobile").mockReturnValue( + platformName === "ios" || platformName === "android" + ); const authSpy = spyOn(sdk, "useOpenSecret").mockReturnValue({ auth: { user: { @@ -36,6 +39,7 @@ describe("API credits platform policy", () => { } as ReturnType); cleanup.push( () => iosSpy.mockRestore(), + () => androidSpy.mockRestore(), () => mobileSpy.mockRestore(), () => authSpy.mockRestore() ); @@ -53,20 +57,22 @@ describe("API credits platform policy", () => { }); } - test("iOS retains existing credit balance and consumption explanation without checkout", async () => { - await mount(true); - expect(textContent(renderer!.root)).toContain("Extra Credit Balance"); - expect(textContent(renderer!.root)).toContain("12,345"); - expect(textContent(renderer!.root)).toContain( - "Extends your subscription when plan credits run out" - ); - expect(textContent(renderer!.root)).not.toContain("Purchase Credits"); - expect(textContent(renderer!.root)).not.toContain("$1 per 1,000 credits"); - expect(renderer!.root.findAllByType("button")).toHaveLength(0); - }); + for (const platformName of ["ios", "android"] as const) { + test(`${platformName} retains existing credit balance and consumption explanation without checkout`, async () => { + await mount(platformName); + expect(textContent(renderer!.root)).toContain("Extra Credit Balance"); + expect(textContent(renderer!.root)).toContain("12,345"); + expect(textContent(renderer!.root)).toContain( + "Extends your subscription when plan credits run out" + ); + expect(textContent(renderer!.root)).not.toContain("Purchase Credits"); + expect(textContent(renderer!.root)).not.toContain("$1 per 1,000 credits"); + expect(renderer!.root.findAllByType("button")).toHaveLength(0); + }); + } test("other clients preserve credit packages, custom amounts, and both payment methods", async () => { - await mount(false); + await mount("web"); const content = textContent(renderer!.root); for (const text of [ "12,345", diff --git a/apps/maple-research/frontend/src/components/apikeys/ApiCreditsSection.tsx b/apps/maple-research/frontend/src/components/apikeys/ApiCreditsSection.tsx index d204c0068..3573cc191 100644 --- a/apps/maple-research/frontend/src/components/apikeys/ApiCreditsSection.tsx +++ b/apps/maple-research/frontend/src/components/apikeys/ApiCreditsSection.tsx @@ -7,7 +7,7 @@ import { Loader2, CreditCard, Bitcoin, Coins, CheckCircle, Edit } from "lucide-r import { useQuery } from "@tanstack/react-query"; import { getBillingService } from "@/billing/billingService"; import { useOpenSecret } from "@mapleai/sdk"; -import { isIOS, isMobile, isTauri } from "@/utils/platform"; +import { isMobile, isTauri } from "@/utils/platform"; import { MIN_PURCHASE_CREDITS, MIN_PURCHASE_AMOUNT, @@ -44,7 +44,7 @@ function CreditBalanceCard({ balance }: { balance: number }) { {formatCredits(balance)}

- {!isIOS() && "$1 per 1,000 credits • "} + {!isMobile() && "$1 per 1,000 credits • "} Extends your subscription when plan credits run out

@@ -95,8 +95,8 @@ export function ApiCreditsSection({ showSuccessMessage = false }: ApiCreditsSect }); const handlePurchase = async (method: "stripe" | "zaprite") => { - // Credits are consumed on iOS, but are not sold there in this IAP version. - if (isIOS()) return; + // Mobile apps can consume existing credits but do not sell credit top-ups. + if (isMobile()) return; // Clear any previous errors setPurchaseError(null); @@ -216,7 +216,7 @@ export function ApiCreditsSection({ showSuccessMessage = false }: ApiCreditsSect ); } - if (isIOS()) { + if (isMobile()) { return ; } diff --git a/apps/maple-research/frontend/src/components/billing/AndroidPlanPage.test.tsx b/apps/maple-research/frontend/src/components/billing/AndroidPlanPage.test.tsx new file mode 100644 index 000000000..6ea80dbe8 --- /dev/null +++ b/apps/maple-research/frontend/src/components/billing/AndroidPlanPage.test.tsx @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { createElement, type ComponentType, type ReactNode } from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import * as sdk from "@mapleai/sdk"; +import * as router from "@tanstack/react-router"; +import * as platform from "@/utils/platform"; +import * as billing from "@/billing/billingService"; +import { Route } from "@/routes/pricing"; + +describe("Android pricing route", () => { + const cleanup: Array<() => void> = []; + let renderer: ReactTestRenderer | undefined; + + beforeEach(async () => { + await platform.waitForPlatform(); + const android = spyOn(platform, "isAndroid").mockReturnValue(true); + const ios = spyOn(platform, "isIOS").mockReturnValue(false); + const links = spyOn(router, "Link").mockImplementation((({ + children, + to + }: { + children: ReactNode; + to: string; + }) => {children}) as typeof router.Link); + cleanup.push( + () => android.mockRestore(), + () => ios.mockRestore(), + () => links.mockRestore() + ); + }); + afterEach(() => { + if (renderer) act(() => renderer?.unmount()); + renderer = undefined; + cleanup + .splice(0) + .reverse() + .forEach((restore) => restore()); + }); + + for (const signedIn of [false, true]) { + test(`${signedIn ? "signed-in" : "signed-out"} users get plan information without legacy checkout effects`, () => { + const auth = spyOn(sdk, "useOpenSecret").mockReturnValue({ + auth: { user: signedIn ? { user: { id: "android-fixture" } } : undefined } + } as ReturnType); + const getService = spyOn(billing, "getBillingService"); + const search = spyOn(Route, "useSearch").mockReturnValue({ selected_plan: "pro" }); + cleanup.push( + () => auth.mockRestore(), + () => getService.mockRestore(), + () => search.mockRestore() + ); + act(() => { + renderer = create(createElement(Route.options.component as ComponentType)); + }); + const content = JSON.stringify(renderer!.toJSON()); + expect(content).toContain("Purchases and plan changes are not available in the Android app"); + const hrefs = renderer!.root.findAllByType("a").map((link) => link.props.href as string); + expect(hrefs).toContain(signedIn ? "/settings/billing" : "/login"); + expect(hrefs.includes("/redeem")).toBe(signedIn); + expect(hrefs.some((href) => /^https?:/.test(href))).toBe(false); + expect(search).not.toHaveBeenCalled(); + expect(getService).not.toHaveBeenCalled(); + }); + } +}); diff --git a/apps/maple-research/frontend/src/components/billing/AndroidPlanPage.tsx b/apps/maple-research/frontend/src/components/billing/AndroidPlanPage.tsx new file mode 100644 index 000000000..5d4aeafa9 --- /dev/null +++ b/apps/maple-research/frontend/src/components/billing/AndroidPlanPage.tsx @@ -0,0 +1,36 @@ +import { Link } from "@tanstack/react-router"; +import { useOpenSecret } from "@mapleai/sdk"; +import { FullPageMain } from "@/components/FullPageMain"; +import { Button } from "@/components/ui/button"; + +/** A plan-information destination for old upgrade links and auth callbacks. */ +export function AndroidPlanPage() { + const signedIn = !!useOpenSecret().auth.user; + return ( + +
+

Your Maple plan

+

+ Purchases and plan changes are not available in the Android app. You can manage your + subscription on the Maple website. +

+

+ Sign in with the same Maple account to use your existing subscription and credits here. +

+ + {signedIn && ( + + )} + +
+
+ ); +} diff --git a/apps/maple-research/frontend/src/components/settings/AboutSettings.test.tsx b/apps/maple-research/frontend/src/components/settings/AboutSettings.test.tsx index 6a10fffc3..421bf597e 100644 --- a/apps/maple-research/frontend/src/components/settings/AboutSettings.test.tsx +++ b/apps/maple-research/frontend/src/components/settings/AboutSettings.test.tsx @@ -1,11 +1,8 @@ -import { afterEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { act, create, type ReactTestInstance, type ReactTestRenderer } from "react-test-renderer"; - -mock.module("@tanstack/react-router", () => ({ - Link: ({ children, to }: { children: React.ReactNode; to: string }) => {children} -})); - -const { AboutSettings, resolveAboutSettingsGating } = await import("./AboutSettings"); +import * as router from "@tanstack/react-router"; +import * as platform from "@/utils/platform"; +import { AboutSettings, resolveAboutSettingsGating } from "./AboutSettings"; function textContent(node: ReactTestInstance): string { return node.children @@ -44,10 +41,24 @@ describe("resolveAboutSettingsGating", () => { describe("AboutSettings", () => { let renderer: ReactTestRenderer | null = null; + let restoreLink: (() => void) | undefined; + + beforeEach(() => { + const linkSpy = spyOn(router, "Link").mockImplementation((({ + children, + to + }: { + children: React.ReactNode; + to: string; + }) => {children}) as unknown as typeof router.Link); + restoreLink = () => linkSpy.mockRestore(); + }); afterEach(() => { if (renderer) act(() => renderer?.unmount()); renderer = null; + restoreLink?.(); + restoreLink = undefined; }); test("uses the live platform defaults in this web test environment", async () => { @@ -78,4 +89,24 @@ describe("AboutSettings", () => { expect(textContent(renderer!.root)).not.toContain("Automatic updates"); expect(textContent(renderer!.root)).not.toContain("Get the Maple app"); }); + + test("Android keeps legal pages inside the app and retains email support", () => { + const androidSpy = spyOn(platform, "isAndroid").mockReturnValue(true); + try { + act(() => { + renderer = create( + + ); + }); + const links = renderer!.root.findAllByType("a").map((link) => link.props.href); + expect(links).toContain("/privacy"); + expect(links).toContain("/terms"); + expect(links.some((href) => typeof href === "string" && href.startsWith("https://"))).toBe( + false + ); + expect(textContent(renderer!.root)).toContain("Contact us"); + } finally { + androidSpy.mockRestore(); + } + }); }); diff --git a/apps/maple-research/frontend/src/components/settings/AboutSettings.tsx b/apps/maple-research/frontend/src/components/settings/AboutSettings.tsx index e64a42e95..e2f65f09c 100644 --- a/apps/maple-research/frontend/src/components/settings/AboutSettings.tsx +++ b/apps/maple-research/frontend/src/components/settings/AboutSettings.tsx @@ -3,7 +3,7 @@ import { ExternalLink, FileText, Info, Mail, Shield } from "lucide-react"; import packageJson from "../../../package.json"; import { Button } from "@/components/ui/button"; import { openExternalUrl } from "@/utils/openUrl"; -import { isTauriDesktop, isWeb } from "@/utils/platform"; +import { isAndroid, isTauriDesktop, isWeb } from "@/utils/platform"; import { SettingsPage, SettingsSection } from "./SettingsPage"; import { DownloadSettings } from "./DownloadSettings"; import { UpdateSettings } from "./UpdateSettings"; @@ -74,8 +74,31 @@ export function AboutSettings({
- - + {isAndroid() ? ( + <> + + + + ) : ( + <> + + + + )}
diff --git a/apps/maple-research/frontend/src/components/settings/BillingSettings.tsx b/apps/maple-research/frontend/src/components/settings/BillingSettings.tsx index bb08b0222..d5c91ab78 100644 --- a/apps/maple-research/frontend/src/components/settings/BillingSettings.tsx +++ b/apps/maple-research/frontend/src/components/settings/BillingSettings.tsx @@ -5,7 +5,7 @@ import { billingPeriodDate } from "@/billing/subscriptionManagement"; import { useAppleBilling } from "@/billing/useAppleBilling"; import { Button } from "@/components/ui/button"; import { useBillingState } from "@/state/useLocalState"; -import { isIOS } from "@/utils/platform"; +import { isAndroid, isMobile } from "@/utils/platform"; import { SettingsPage, SettingsSection } from "./SettingsPage"; import { BillingSubscriptions } from "./BillingSubscriptions"; import { ApplePurchaseRecovery } from "./ApplePurchaseRecovery"; @@ -18,7 +18,9 @@ export function BillingSettings() { const productName = billingStatus?.product_name ?? ""; const normalizedProductName = productName.toLowerCase(); const showUpgrade = - !normalizedProductName.includes("max") && !normalizedProductName.includes("team"); + !isAndroid() && + !normalizedProductName.includes("max") && + !normalizedProductName.includes("team"); const periodLabel = billingStatus?.payment_provider === "subscription_pass" || @@ -63,7 +65,7 @@ export function BillingSettings() { = {}): AppleBilli describe("billing subscription settings", () => { let renderer: ReactTestRenderer | null = null; + let androidSpy: ReturnType>; + beforeEach(() => { + androidSpy = spyOn(platform, "isAndroid").mockReturnValue(false); + }); afterEach(() => { if (renderer) act(() => renderer?.unmount()); renderer = null; + androidSpy.mockRestore(); + }); + + test("Android shows every subscription but directs management to support without opening payment pages", async () => { + androidSpy.mockReturnValue(true); + const manageApple = mock(async () => {}); + const manageStripe = mock(async () => {}); + const manageSupport = mock(async () => {}); + act(() => { + renderer = create( + + ); + }); + expect(textContent(renderer!.root)).toContain("Supplies your current plan"); + expect(textContent(renderer!.root)).toContain("Max"); + expect(textContent(renderer!.root)).toContain("Pro"); + const buttons = renderer!.root.findAllByType("button"); + expect(buttons.map(textContent)).toEqual(["Contact support", "Contact support"]); + for (const button of buttons) { + await act(async () => button.props.onClick()); + } + expect(manageSupport).toHaveBeenCalledTimes(2); + expect(manageApple).not.toHaveBeenCalled(); + expect(manageStripe).not.toHaveBeenCalled(); }); test("renders selected Stripe and unselected Apple management with sandbox/conflict labels", async () => { diff --git a/apps/maple-research/frontend/src/components/settings/BillingSubscriptions.tsx b/apps/maple-research/frontend/src/components/settings/BillingSubscriptions.tsx index f0404bdb9..7b968a743 100644 --- a/apps/maple-research/frontend/src/components/settings/BillingSubscriptions.tsx +++ b/apps/maple-research/frontend/src/components/settings/BillingSubscriptions.tsx @@ -14,6 +14,7 @@ import { import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { openExternalUrl } from "@/utils/openUrl"; +import { isAndroid } from "@/utils/platform"; import { SettingsSection } from "./SettingsPage"; type Props = { @@ -41,11 +42,13 @@ export function BillingSubscriptions({ setOpening(index); setError(null); try { - await (action === "apple" - ? manageApple() - : action === "stripe" - ? manageStripe() - : manageSupport()); + await (isAndroid() + ? manageSupport() + : action === "apple" + ? manageApple() + : action === "stripe" + ? manageStripe() + : manageSupport()); } catch { setError("Unable to open subscription management. Please try again."); } finally { @@ -112,11 +115,13 @@ export function BillingSubscriptions({ {opening === index && } {opening === index ? "Opening..." - : action === "apple" - ? "Manage with Apple" - : action === "stripe" - ? "Manage card subscription" - : "Contact support"} + : isAndroid() + ? "Contact support" + : action === "apple" + ? "Manage with Apple" + : action === "stripe" + ? "Manage card subscription" + : "Contact support"} )} diff --git a/apps/maple-research/frontend/src/components/settings/api/ApiSettingsLayout.tsx b/apps/maple-research/frontend/src/components/settings/api/ApiSettingsLayout.tsx index 96bd0feca..1736a9b9b 100644 --- a/apps/maple-research/frontend/src/components/settings/api/ApiSettingsLayout.tsx +++ b/apps/maple-research/frontend/src/components/settings/api/ApiSettingsLayout.tsx @@ -14,7 +14,7 @@ import { getBillingService } from "@/billing/billingService"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { useBillingState } from "@/state/useLocalState"; -import { isIOS, isTauriDesktop } from "@/utils/platform"; +import { isAndroid, isIOS, isTauriDesktop } from "@/utils/platform"; import { cn } from "@/utils/utils"; import { SettingsPage, SettingsSection } from "../SettingsPage"; @@ -122,17 +122,21 @@ export function ApiSettingsLayout() { - + {!isAndroid() && ( + + )} ); diff --git a/apps/maple-research/frontend/src/components/settings/team/TeamInviteSettings.tsx b/apps/maple-research/frontend/src/components/settings/team/TeamInviteSettings.tsx index 961186844..4c3863487 100644 --- a/apps/maple-research/frontend/src/components/settings/team/TeamInviteSettings.tsx +++ b/apps/maple-research/frontend/src/components/settings/team/TeamInviteSettings.tsx @@ -23,7 +23,7 @@ import { } from "@/contexts/SettingsNavigationLockContext"; import { useBillingState } from "@/state/useLocalState"; import type { TeamStatus } from "@/types/team"; -import { isIOS } from "@/utils/platform"; +import { isAndroid, isMobile } from "@/utils/platform"; import { getTeamSeatMismatch } from "@/utils/teamSeats"; import { SettingsPage, SettingsSection } from "../SettingsPage"; @@ -111,9 +111,9 @@ export function TeamInviteSettings() { const isAdmin = teamStatus.role === "admin" || teamStatus.is_team_admin === true; const seatMismatch = getTeamSeatMismatch(teamStatus); const seatsAvailable = Math.max(0, teamStatus.seats_available ?? 0); - const canOpenBillingPortal = billingStatus - ? !!billingStatus.stripe_customer_id - : !!teamStatus.has_team_subscription; + const canOpenBillingPortal = + !isAndroid() && + (billingStatus ? !!billingStatus.stripe_customer_id : !!teamStatus.has_team_subscription); const handleManageSubscription = async () => { if (!canOpenBillingPortal) return; @@ -216,11 +216,16 @@ export function TeamInviteSettings() { - {isIOS() + {isMobile() ? "Team usage is paused while the team has more members than paid seats. Remove members before inviting anyone else." : "Team usage is paused while the team has more members than paid seats. Add seats or remove members before inviting anyone else."} + {isAndroid() && ( +

+ For help with your team subscription, contact support@trymaple.ai. +

+ )} {canOpenBillingPortal && ( - {canOpenBillingPortal && !isIOS() && ( + {canOpenBillingPortal && !isMobile() && (
+ {isAndroid() && ( +

+ For help with your team subscription, contact support@trymaple.ai. +

+ )} {!seatMismatch && portalError && ( @@ -581,7 +586,11 @@ export function TeamSettings() {