From 47fc54dae3b201fcee9cc18ae119db3361f92f76 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Fri, 2 May 2025 15:12:43 -0500 Subject: [PATCH] Add ios pricing --- .../.well-known/apple-app-site-association | 12 ++ frontend/src-tauri/capabilities/default.json | 9 ++ .../src-tauri/capabilities/mobile-ios.json | 9 ++ frontend/src/billing/billingApi.ts | 90 +++++++++++ frontend/src/components/DeepLinkHandler.tsx | 70 +++++++-- frontend/src/routeTree.gen.ts | 52 +++++++ frontend/src/routes/login.tsx | 1 - frontend/src/routes/payment-canceled.tsx | 10 ++ frontend/src/routes/payment-success.tsx | 10 ++ frontend/src/routes/pricing.tsx | 141 +++++++++++++----- frontend/src/routes/signup.tsx | 1 - 11 files changed, 354 insertions(+), 51 deletions(-) create mode 100644 frontend/src/routes/payment-canceled.tsx create mode 100644 frontend/src/routes/payment-success.tsx diff --git a/frontend/public/.well-known/apple-app-site-association b/frontend/public/.well-known/apple-app-site-association index 199b35df7..4ed98f820 100644 --- a/frontend/public/.well-known/apple-app-site-association +++ b/frontend/public/.well-known/apple-app-site-association @@ -11,6 +11,18 @@ { "/": "/desktop-auth/*", "comment": "Matches any URL whose path starts with /desktop-auth/" + }, + { + "/": "/payment-success*", + "comment": "Matches URLs for successful payments" + }, + { + "/": "/payment-canceled*", + "comment": "Matches URLs for canceled payments" + }, + { + "/": "/pricing*", + "comment": "Matches URLs for pricing page with parameters" } ] } diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json index 1c519f1f5..d8a7b18c7 100644 --- a/frontend/src-tauri/capabilities/default.json +++ b/frontend/src-tauri/capabilities/default.json @@ -16,6 +16,15 @@ }, { "url": "https://trymaple.ai/*" + }, + { + "url": "https://*.stripe.com/*" + }, + { + "url": "https://*.stripe.network/*" + }, + { + "url": "https://*.zaprite.com/*" } ] }, diff --git a/frontend/src-tauri/capabilities/mobile-ios.json b/frontend/src-tauri/capabilities/mobile-ios.json index 188ce5fa9..6adf773ba 100644 --- a/frontend/src-tauri/capabilities/mobile-ios.json +++ b/frontend/src-tauri/capabilities/mobile-ios.json @@ -17,6 +17,15 @@ }, { "url": "https://trymaple.ai/*" + }, + { + "url": "https://*.stripe.com/*" + }, + { + "url": "https://*.stripe.network/*" + }, + { + "url": "https://*.zaprite.com/*" } ] }, diff --git a/frontend/src/billing/billingApi.ts b/frontend/src/billing/billingApi.ts index b654aeaff..ba9882b01 100644 --- a/frontend/src/billing/billingApi.ts +++ b/frontend/src/billing/billingApi.ts @@ -142,6 +142,51 @@ export async function createCheckoutSession( const { checkout_url } = await response.json(); console.log("Redirecting to checkout:", checkout_url); + + try { + // Check if we're in a Tauri environment + const isTauri = await import("@tauri-apps/api/core") + .then((m) => m.isTauri()) + .catch(() => false); + + if (isTauri) { + // Log the URL we're about to open + console.log("[Billing] Opening URL in external browser:", checkout_url); + + // For iOS, we need to force Safari to open with no fallback + const { type } = await import("@tauri-apps/plugin-os"); + const platform = await type(); + const { invoke } = await import("@tauri-apps/api/core"); + + if (platform === "ios") { + console.log("[Billing] iOS detected, using opener plugin to launch Safari"); + + // Use the opener plugin directly - with NO fallback for iOS + await invoke("plugin:opener|open_url", { url: checkout_url }) + .then(() => { + console.log("[Billing] Successfully opened URL in external browser"); + }) + .catch((error: Error) => { + console.error("[Billing] Failed to open external browser:", error); + throw new Error( + "Failed to open payment page in external browser. This is required for iOS payments." + ); + }); + + // Add a small delay to ensure the browser has time to open + await new Promise((resolve) => setTimeout(resolve, 300)); + return; + } else { + // For other platforms, maintain original behavior - use window.location directly + window.location.href = checkout_url; + return; + } + } + } catch (error) { + console.error("[Billing] Error opening URL with Tauri:", error); + } + + // Fall back to regular navigation if not on Tauri or if Tauri opener fails window.location.href = checkout_url; } @@ -179,6 +224,51 @@ export async function createZapriteCheckoutSession( const { checkout_url } = await response.json(); console.log("Redirecting to Zaprite checkout:", checkout_url); + + try { + // Check if we're in a Tauri environment + const isTauri = await import("@tauri-apps/api/core") + .then((m) => m.isTauri()) + .catch(() => false); + + if (isTauri) { + // Log the URL we're about to open + console.log("[Billing] Opening URL in external browser:", checkout_url); + + // For iOS, we need to force Safari to open with no fallback + const { type } = await import("@tauri-apps/plugin-os"); + const platform = await type(); + const { invoke } = await import("@tauri-apps/api/core"); + + if (platform === "ios") { + console.log("[Billing] iOS detected, using opener plugin to launch Safari"); + + // Use the opener plugin directly - with NO fallback for iOS + await invoke("plugin:opener|open_url", { url: checkout_url }) + .then(() => { + console.log("[Billing] Successfully opened URL in external browser"); + }) + .catch((error: Error) => { + console.error("[Billing] Failed to open external browser:", error); + throw new Error( + "Failed to open payment page in external browser. This is required for iOS payments." + ); + }); + + // Add a small delay to ensure the browser has time to open + await new Promise((resolve) => setTimeout(resolve, 300)); + return; + } else { + // For other platforms, maintain original behavior - use window.location directly + window.location.href = checkout_url; + return; + } + } + } catch (error) { + console.error("[Billing] Error opening URL with Tauri:", error); + } + + // Fall back to regular navigation if not on Tauri or if Tauri opener fails window.location.href = checkout_url; } diff --git a/frontend/src/components/DeepLinkHandler.tsx b/frontend/src/components/DeepLinkHandler.tsx index b25a16334..24e2cae61 100644 --- a/frontend/src/components/DeepLinkHandler.tsx +++ b/frontend/src/components/DeepLinkHandler.tsx @@ -21,23 +21,69 @@ export function DeepLinkHandler() { console.log("[Deep Link] Received URL:", url); try { - // Parse the URL to extract the tokens + // Parse the URL to extract parameters const urlObj = new URL(url); - // The URL will look like: cloud.opensecret.maple://auth?access_token=...&refresh_token=... - const accessToken = urlObj.searchParams.get("access_token"); - const refreshToken = urlObj.searchParams.get("refresh_token"); + // The URL path structure will be: cloud.opensecret.maple://path?params + const pathParts = urlObj.pathname.split("/").filter(Boolean); + const firstPathPart = pathParts[0] || ""; - if (accessToken && refreshToken) { - console.log("[Deep Link] Auth tokens received"); + // Handle different types of deep links + if (firstPathPart === "auth" || firstPathPart === "") { + // Handle auth deep links + const accessToken = urlObj.searchParams.get("access_token"); + const refreshToken = urlObj.searchParams.get("refresh_token"); - // Store the tokens in localStorage with consistent naming - localStorage.setItem("access_token", accessToken); - localStorage.setItem("refresh_token", refreshToken); + if (accessToken && refreshToken) { + console.log("[Deep Link] Auth tokens received"); - // Refresh the app state to reflect the logged-in status - window.location.href = "/"; // Reload the app + // Store the tokens in localStorage with consistent naming + localStorage.setItem("access_token", accessToken); + localStorage.setItem("refresh_token", refreshToken); + + // Refresh the app state to reflect the logged-in status + window.location.href = "/"; // Reload the app + } else { + console.error("[Deep Link] Missing tokens in auth deep link"); + } + } else if ( + firstPathPart === "payment" || + firstPathPart === "payment-success" || + firstPathPart === "payment-canceled" || + urlObj.searchParams.has("payment_success") || + urlObj.searchParams.has("success") + ) { + // Handle payment deep links from various sources + const isSuccess = + firstPathPart === "payment-success" || + urlObj.searchParams.get("success") === "true" || + urlObj.searchParams.get("payment_success") === "true"; + + const isCanceled = + firstPathPart === "payment-canceled" || + urlObj.searchParams.get("canceled") === "true" || + urlObj.searchParams.has("payment_canceled"); + + console.log("[Deep Link] Payment callback received:", { + isSuccess, + isCanceled, + path: firstPathPart, + source: urlObj.searchParams.get("source") + }); + + // Use window.location instead of navigate + if (isSuccess) { + // Navigate to the success page or show a success message + window.location.href = "/pricing?success=true"; + } else if (isCanceled) { + // Navigate to the canceled page or show a canceled message + window.location.href = "/pricing?canceled=true"; + } else { + // Handle unknown payment status + console.warn("[Deep Link] Unknown payment status in callback"); + window.location.href = "/pricing"; + } } else { - console.error("[Deep Link] Missing tokens in deep link"); + console.warn("[Deep Link] Unknown deep link type:", firstPathPart); } } catch (error) { console.error("[Deep Link] Failed to process deep link:", error); diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index d4b606a62..cbb327ebd 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -14,6 +14,8 @@ import { Route as rootRoute } from './routes/__root' import { Route as SignupImport } from './routes/signup' import { Route as ProofImport } from './routes/proof' import { Route as PricingImport } from './routes/pricing' +import { Route as PaymentSuccessImport } from './routes/payment-success' +import { Route as PaymentCanceledImport } from './routes/payment-canceled' import { Route as PasswordResetImport } from './routes/password-reset' import { Route as LoginImport } from './routes/login' import { Route as DownloadsImport } from './routes/downloads' @@ -45,6 +47,18 @@ const PricingRoute = PricingImport.update({ getParentRoute: () => rootRoute, } as any) +const PaymentSuccessRoute = PaymentSuccessImport.update({ + id: '/payment-success', + path: '/payment-success', + getParentRoute: () => rootRoute, +} as any) + +const PaymentCanceledRoute = PaymentCanceledImport.update({ + id: '/payment-canceled', + path: '/payment-canceled', + getParentRoute: () => rootRoute, +} as any) + const PasswordResetRoute = PasswordResetImport.update({ id: '/password-reset', path: '/password-reset', @@ -150,6 +164,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PasswordResetImport parentRoute: typeof rootRoute } + '/payment-canceled': { + id: '/payment-canceled' + path: '/payment-canceled' + fullPath: '/payment-canceled' + preLoaderRoute: typeof PaymentCanceledImport + parentRoute: typeof rootRoute + } + '/payment-success': { + id: '/payment-success' + path: '/payment-success' + fullPath: '/payment-success' + preLoaderRoute: typeof PaymentSuccessImport + parentRoute: typeof rootRoute + } '/pricing': { id: '/pricing' path: '/pricing' @@ -233,6 +261,8 @@ export interface FileRoutesByFullPath { '/downloads': typeof DownloadsRoute '/login': typeof LoginRoute '/password-reset': typeof PasswordResetRouteWithChildren + '/payment-canceled': typeof PaymentCanceledRoute + '/payment-success': typeof PaymentSuccessRoute '/pricing': typeof PricingRoute '/proof': typeof ProofRoute '/signup': typeof SignupRoute @@ -249,6 +279,8 @@ export interface FileRoutesByTo { '/downloads': typeof DownloadsRoute '/login': typeof LoginRoute '/password-reset': typeof PasswordResetRouteWithChildren + '/payment-canceled': typeof PaymentCanceledRoute + '/payment-success': typeof PaymentSuccessRoute '/pricing': typeof PricingRoute '/proof': typeof ProofRoute '/signup': typeof SignupRoute @@ -266,6 +298,8 @@ export interface FileRoutesById { '/downloads': typeof DownloadsRoute '/login': typeof LoginRoute '/password-reset': typeof PasswordResetRouteWithChildren + '/payment-canceled': typeof PaymentCanceledRoute + '/payment-success': typeof PaymentSuccessRoute '/pricing': typeof PricingRoute '/proof': typeof ProofRoute '/signup': typeof SignupRoute @@ -284,6 +318,8 @@ export interface FileRouteTypes { | '/downloads' | '/login' | '/password-reset' + | '/payment-canceled' + | '/payment-success' | '/pricing' | '/proof' | '/signup' @@ -299,6 +335,8 @@ export interface FileRouteTypes { | '/downloads' | '/login' | '/password-reset' + | '/payment-canceled' + | '/payment-success' | '/pricing' | '/proof' | '/signup' @@ -314,6 +352,8 @@ export interface FileRouteTypes { | '/downloads' | '/login' | '/password-reset' + | '/payment-canceled' + | '/payment-success' | '/pricing' | '/proof' | '/signup' @@ -331,6 +371,8 @@ export interface RootRouteChildren { DownloadsRoute: typeof DownloadsRoute LoginRoute: typeof LoginRoute PasswordResetRoute: typeof PasswordResetRouteWithChildren + PaymentCanceledRoute: typeof PaymentCanceledRoute + PaymentSuccessRoute: typeof PaymentSuccessRoute PricingRoute: typeof PricingRoute ProofRoute: typeof ProofRoute SignupRoute: typeof SignupRoute @@ -345,6 +387,8 @@ const rootRouteChildren: RootRouteChildren = { DownloadsRoute: DownloadsRoute, LoginRoute: LoginRoute, PasswordResetRoute: PasswordResetRouteWithChildren, + PaymentCanceledRoute: PaymentCanceledRoute, + PaymentSuccessRoute: PaymentSuccessRoute, PricingRoute: PricingRoute, ProofRoute: ProofRoute, SignupRoute: SignupRoute, @@ -368,6 +412,8 @@ export const routeTree = rootRoute "/downloads", "/login", "/password-reset", + "/payment-canceled", + "/payment-success", "/pricing", "/proof", "/signup", @@ -399,6 +445,12 @@ export const routeTree = rootRoute "/password-reset/confirm" ] }, + "/payment-canceled": { + "filePath": "payment-canceled.tsx" + }, + "/payment-success": { + "filePath": "payment-success.tsx" + }, "/pricing": { "filePath": "pricing.tsx" }, diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx index 527fd2043..a22e58551 100644 --- a/frontend/src/routes/login.tsx +++ b/frontend/src/routes/login.tsx @@ -201,7 +201,6 @@ function LoginPage() { // Apple requires the nonce to be hashed with SHA-256 const hashedNonce = bytesToHex(sha256(new TextEncoder().encode(rawNonce))); - // Invoke the Apple Sign in plugin // This will show the native Apple authentication UI const result = await invoke( diff --git a/frontend/src/routes/payment-canceled.tsx b/frontend/src/routes/payment-canceled.tsx new file mode 100644 index 000000000..f5fb8853b --- /dev/null +++ b/frontend/src/routes/payment-canceled.tsx @@ -0,0 +1,10 @@ +import { createFileRoute, Navigate } from "@tanstack/react-router"; + +export const Route = createFileRoute("/payment-canceled")({ + component: PaymentCanceledPage +}); + +function PaymentCanceledPage() { + // Just redirect to pricing page with canceled query parameter + return ; +} diff --git a/frontend/src/routes/payment-success.tsx b/frontend/src/routes/payment-success.tsx new file mode 100644 index 000000000..5ac4e49dd --- /dev/null +++ b/frontend/src/routes/payment-success.tsx @@ -0,0 +1,10 @@ +import { createFileRoute, Navigate } from "@tanstack/react-router"; + +export const Route = createFileRoute("/payment-success")({ + component: PaymentSuccessPage +}); + +function PaymentSuccessPage() { + // Just redirect to pricing page with success query parameter + return ; +} diff --git a/frontend/src/routes/pricing.tsx b/frontend/src/routes/pricing.tsx index 4702a76ac..05b272523 100644 --- a/frontend/src/routes/pricing.tsx +++ b/frontend/src/routes/pricing.tsx @@ -241,12 +241,10 @@ function PricingPage() { retry: 1 // Only retry once for faster error feedback }); - const getButtonText = (product: Product) => { - // For iOS, show "Coming Soon" for paid plans - if (isIOS && !product.name.toLowerCase().includes("free")) { - return "Coming Soon"; - } + // Handle payment callback status from URL params + const { success, canceled } = Route.useSearch(); + const getButtonText = (product: Product) => { if (loadingProductId === product.id) { return ( <> @@ -348,19 +346,69 @@ function PricingPage() { } } - if (useBitcoin) { - await billingService.createZapriteCheckoutSession( - email, - productId, - `${window.location.origin}/` - ); - } else { - await billingService.createCheckoutSession( - email, - productId, - `${window.location.origin}/`, - `${window.location.origin}/` - ); + try { + // Check if we're in a Tauri environment + const isTauri = await import("@tauri-apps/api/core") + .then((m) => m.isTauri()) + .catch(() => false); + + const isTauriIOS = + isTauri && + (await import("@tauri-apps/plugin-os") + .then((m) => m.type()) + .then((type) => type === "ios") + .catch(() => false)); + + // For iOS, use Universal Links that match the AASA configuration + if (isTauriIOS) { + if (useBitcoin) { + await billingService.createZapriteCheckoutSession( + email, + productId, + `https://trymaple.ai/payment-success?source=zaprite` + ); + } else { + await billingService.createCheckoutSession( + email, + productId, + `https://trymaple.ai/payment-success?source=stripe`, + `https://trymaple.ai/payment-canceled?source=stripe` + ); + } + } else { + // For web or desktop, use regular URLs + if (useBitcoin) { + await billingService.createZapriteCheckoutSession( + email, + productId, + `${window.location.origin}/pricing?success=true` + ); + } else { + await billingService.createCheckoutSession( + email, + productId, + `${window.location.origin}/pricing?success=true`, + `${window.location.origin}/pricing?canceled=true` + ); + } + } + } catch (error) { + console.error("Error determining platform:", error); + // Fall back to regular URLs if platform detection fails + if (useBitcoin) { + await billingService.createZapriteCheckoutSession( + email, + productId, + `${window.location.origin}/pricing?success=true` + ); + } else { + await billingService.createCheckoutSession( + email, + productId, + `${window.location.origin}/pricing?success=true`, + `${window.location.origin}/pricing?canceled=true` + ); + } } } catch (err) { console.error("Subscribe error:", err); @@ -374,11 +422,6 @@ function PricingPage() { const handleButtonClick = useCallback( (product: Product) => { - // For iOS, disable payment buttons except for free plan - if (isIOS && !product.name.toLowerCase().includes("free")) { - return; // Do nothing for paid plans on iOS - } - if (!isLoggedIn) { const targetPlanName = product.name.toLowerCase(); const isTeamPlan = targetPlanName.includes("team"); @@ -468,15 +511,8 @@ function PricingPage() { if (isLoggedIn && selected_plan && !isBillingStatusLoading) { if (loadingProductId) return; // Prevent multiple triggers const product = products?.find((p) => p.id === selected_plan); - if (product) { - // Skip automatic checkout for paid plans on iOS - if (isIOS && !product.name.toLowerCase().includes("free")) { - return; - } - - if (isSubscribed) { - handleButtonClick(product); - } + if (product && isSubscribed) { + handleButtonClick(product); } } @@ -587,6 +623,29 @@ function PricingPage() { } /> + {/* Payment Callback Status Messages */} + {success && ( +
+
+
+ +
+

Payment successful! Your subscription has been updated.

+
+
+ )} + + {canceled && ( +
+
+
+ +
+

Payment canceled. Your subscription remains unchanged.

+
+
+ )} + {!isIOS && (
@@ -719,9 +778,7 @@ function PricingPage() {