diff --git a/messages/en.json b/messages/en.json index 4369ee551f..d273e7ce26 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2305,6 +2305,8 @@ "store-release-validation-title-track-unknown": "Android store install detected, release track unknown.", "test-preview": "Test preview", "stripe-billing-portal-will-be-opened-in-a-new-tab": "Stripe billing portal will be opened in a new tab", + "stripe-checkout-will-be-opened-in-a-new-tab": "Stripe checkout will be opened in a new tab", + "popup-blocked-open-manually": "Your browser blocked the new tab. Confirm to open it.", "subscribed-events": "Subscribed Events", "subscribed": "Subscribed", "subscribed-within-7-days": "Subscribed (within 7 days)", diff --git a/playwright/e2e/subscription-checkout.spec.ts b/playwright/e2e/subscription-checkout.spec.ts index 8ac6398285..afd302a464 100644 --- a/playwright/e2e/subscription-checkout.spec.ts +++ b/playwright/e2e/subscription-checkout.spec.ts @@ -181,19 +181,6 @@ test.describe('Subscription Checkout', () => { await page.addInitScript((nextOrgId) => { localStorage.setItem('capgo_current_org_id', nextOrgId) }, orgId) - await page.addInitScript(() => { - ;(window as Window & { __lastOpenedUrl?: string | null }).__lastOpenedUrl = null - window.open = ((url?: string | URL | null) => { - const normalizedUrl = typeof url === 'string' - ? url - : url instanceof URL - ? url.toString() - : null - ;(window as Window & { __lastOpenedUrl?: string | null }).__lastOpenedUrl = normalizedUrl - return null - }) as typeof window.open - }) - await page.login('test@capgo.app', USER_PASSWORD) await page.goto('/settings/organization/plans') @@ -206,14 +193,16 @@ test.describe('Subscription Checkout', () => { await expect(planCard.getByRole('button', { name: 'Upgrade' })).toBeEnabled() await planCard.locator('[data-test="plan-action-button"]').click() + // Web checkout opens a confirm dialog with a real target=_blank link. + const confirmLink = page.getByRole('link', { name: 'Confirm' }) + await expect(confirmLink).toBeVisible() + const checkoutUrl = await confirmLink.getAttribute('href') + expect(checkoutUrl).toBeTruthy() + const escapedStripeOrigin = STRIPE_EMULATOR_URL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - let checkoutUrl = '' - await expect.poll(async () => { - checkoutUrl = await page.evaluate(() => (window as Window & { __lastOpenedUrl?: string | null }).__lastOpenedUrl ?? '') - return checkoutUrl - }).toMatch(new RegExp(`${escapedStripeOrigin}/checkout/`)) + expect(checkoutUrl!).toMatch(new RegExp(`${escapedStripeOrigin}/checkout/`)) - await page.goto(checkoutUrl) + await page.goto(checkoutUrl!) await expect(page).toHaveURL(new RegExp(`${escapedStripeOrigin}/checkout/`)) await page.getByRole('button', { name: /^Pay / }).click() diff --git a/src/components/DialogV2.vue b/src/components/DialogV2.vue index efbb30cd60..68b5d6e461 100644 --- a/src/components/DialogV2.vue +++ b/src/components/DialogV2.vue @@ -75,6 +75,12 @@ function handleButtonClick(button: DialogV2Button, event?: Event) { return } + // Let target=_blank open natively so browsers do not treat it as a blocked popup. + if (button.href && button.target === '_blank') { + close({ ...safeButton, skipNavigation: true }) + return + } + const shouldPreventNavigation = button.href && (!mouseEvent || (mouseEvent.button === 0 && !hasModifier)) if (shouldPreventNavigation) event?.preventDefault() diff --git a/src/pages/settings/organization/Plans.vue b/src/pages/settings/organization/Plans.vue index 094a5112b4..7e6238f125 100644 --- a/src/pages/settings/organization/Plans.vue +++ b/src/pages/settings/organization/Plans.vue @@ -142,15 +142,6 @@ const isCreditsOnly = computed(() => { return !org.paying && (org.trial_left ?? 0) <= 0 && (org.credit_available ?? 0) > 0 }) -function isSafariBrowser() { - if (Capacitor.getPlatform() !== 'web') - return false - if (typeof navigator === 'undefined') - return false - const ua = navigator.userAgent - return /Version\/[\d.]+/.test(ua) && /Safari\//.test(ua) && !/Chrome|CriOS|FxiOS|OPiOS|Edg|Chromium/.test(ua) -} - async function prefetchStripeCheckoutUrl(plan: Database['public']['Tables']['plans']['Row'], isYear: boolean) { if (!plan.stripe_id) return @@ -212,16 +203,18 @@ function trackPlanCheckoutStarted(plan: Database['public']['Tables']['plans']['R }).catch() } -async function openSafariStripeCheckout(plan: Database['public']['Tables']['plans']['Row'], isYear: boolean) { +async function openWebStripeCheckout(plan: Database['public']['Tables']['plans']['Row'], isYear: boolean) { const url = await prefetchStripeCheckoutUrl(plan, isYear) if (!url) { toast.error('Cannot get your checkout') return false } + // Confirm dialog with a real so checkout opens under a fresh user gesture. + // Avoids popup blockers after the async Stripe session create. dialogStore.openDialog({ title: t('open-in-new-tab'), - description: 'This will open Stripe to complete checkout.', + description: t('stripe-checkout-will-be-opened-in-a-new-tab'), buttons: [ { text: t('button-cancel'), @@ -234,7 +227,7 @@ async function openSafariStripeCheckout(plan: Database['public']['Tables']['plan href: url, target: '_blank', rel: 'noopener noreferrer', - handler: () => trackPlanCheckoutStarted(plan, isYear, 'safari_confirm'), + handler: () => trackPlanCheckoutStarted(plan, isYear, 'web_confirm'), }, ], }) @@ -255,8 +248,8 @@ async function openChangePlan(plan: Database['public']['Tables']['plans']['Row'] isSubscribeLoading.value[index] = true if (plan.stripe_id) { const checkoutIsYearly = hasYearlyDiscount(plan) ? isYearly.value : false - if (isSafariBrowser()) { - const shouldContinue = await openSafariStripeCheckout(plan, checkoutIsYearly) + if (Capacitor.getPlatform() === 'web') { + const shouldContinue = await openWebStripeCheckout(plan, checkoutIsYearly) if (!shouldContinue) { isSubscribeLoading.value[index] = false return @@ -592,7 +585,7 @@ function buttonStyle(p: Database['public']['Tables']['plans']['Row']) { :disabled="isDisabled(p)" @click="openChangePlan(p, index)" > - + diff --git a/src/services/stripe.ts b/src/services/stripe.ts index f4a97250f8..bed752cf25 100644 --- a/src/services/stripe.ts +++ b/src/services/stripe.ts @@ -28,13 +28,42 @@ async function presentActionSheetOpen(url: string) { }) return dialogStore.onDialogDismiss() } -export function openBlank(link: string) { +async function presentBlockedPopupFallback(url: string) { + const { t } = useI18n() + const dialogStore = useDialogV2Store() + + dialogStore.openDialog({ + title: t('open-in-new-tab'), + description: t('popup-blocked-open-manually'), + buttons: [ + { + text: t('button-cancel'), + role: 'cancel', + }, + { + text: t('button-confirm'), + id: 'confirm-button', + role: 'primary', + href: url, + target: '_blank', + rel: 'noopener noreferrer', + }, + ], + }) + return !(await dialogStore.onDialogDismiss()) +} + +export async function openBlank(link: string) { console.log('openBlank', link) if (Capacitor.getPlatform() === 'ios') { - presentActionSheetOpen(link) - return true + // presentActionSheetOpen resolves true when dismissed/canceled + return !(await presentActionSheetOpen(link)) } - return Boolean(globalThis.open(link, '_blank')) + const opened = globalThis.open(link, '_blank') + if (opened) + return true + // Async callers often lose the user-gesture; offer a confirm link fallback. + return presentBlockedPopupFallback(link) } export async function openPortal(orgId: string, t: ComposerTranslation) { let url = '' @@ -70,7 +99,7 @@ export async function openPortal(orgId: string, t: ComposerTranslation) { handler: async () => { await prem if (url) - openBlank(url) + await openBlank(url) else toast.error('Cannot open your portal') },