diff --git a/src/app/payment-result/page.tsx b/src/app/payment-result/page.tsx index 8f7ce99b..ae8078d0 100644 --- a/src/app/payment-result/page.tsx +++ b/src/app/payment-result/page.tsx @@ -14,6 +14,7 @@ import ProtectedRoute from '@/components/auth/ProtectedRoute'; import { PaymentStatusDisplay } from '@/components/payment/PaymentStatusDisplay/PaymentStatusDisplay'; import { OfflineRetryBanner } from '@/components/payment/OfflineRetryBanner'; import { featureFlags } from '@/config/payment'; +import { getInternalUrl } from '@/config/project.config'; import { getPaymentStatus } from '@/lib/payments/payment-service'; type ResultState = @@ -253,8 +254,12 @@ function PaymentResultContent() { paymentResultId={state.resultId} showDetails onRetrySuccess={(newIntentId) => { - // Navigate to the new intent's result page - window.location.href = `/payment-result?id=${newIntentId}`; + // Navigate to the new intent's result page. Raw window.location does + // NOT get the basePath prefixed (unlike next/link), so wrap it in + // getInternalUrl or a project-site fork escapes to the domain root (#159). + window.location.href = getInternalUrl( + `/payment-result?id=${newIntentId}` + ); }} /> diff --git a/src/components/payment/PaymentConsentModal/PaymentConsentModal.test.tsx b/src/components/payment/PaymentConsentModal/PaymentConsentModal.test.tsx index f533f1e9..96a8626f 100644 --- a/src/components/payment/PaymentConsentModal/PaymentConsentModal.test.tsx +++ b/src/components/payment/PaymentConsentModal/PaymentConsentModal.test.tsx @@ -223,4 +223,15 @@ describe('PaymentConsentModal', () => { expect(acceptButton).toHaveFocus(); }); }); + + it('routes the privacy-policy link through next/link so it inherits the base path (#159)', () => { + render(); + + const link = screen.getByRole('link', { name: /read privacy policy/i }); + // next/link renders an and prepends the runtime + // basePath in the real app (jsdom doesn't populate it). Was a raw , + // which would have escaped to the domain root on a project-site fork. + expect(link.tagName).toBe('A'); + expect(link).toHaveAttribute('href', '/privacy'); + }); }); diff --git a/src/components/payment/PaymentConsentModal/PaymentConsentModal.tsx b/src/components/payment/PaymentConsentModal/PaymentConsentModal.tsx index 2a2a058d..b03f158e 100644 --- a/src/components/payment/PaymentConsentModal/PaymentConsentModal.tsx +++ b/src/components/payment/PaymentConsentModal/PaymentConsentModal.tsx @@ -6,6 +6,7 @@ 'use client'; import React, { useEffect, useRef } from 'react'; +import Link from 'next/link'; import { usePaymentConsent } from '@/hooks/usePaymentConsent'; export interface PaymentConsentModalProps { @@ -192,13 +193,13 @@ export const PaymentConsentModal: React.FC = ({ By accepting, you agree to our payment processing terms.
Read our{' '} -
Privacy Policy - {' '} + {' '} for more details.

diff --git a/src/components/payment/PaymentStatusDisplay/PaymentStatusDisplay.test.tsx b/src/components/payment/PaymentStatusDisplay/PaymentStatusDisplay.test.tsx index b3e1ffe7..01a2021d 100644 --- a/src/components/payment/PaymentStatusDisplay/PaymentStatusDisplay.test.tsx +++ b/src/components/payment/PaymentStatusDisplay/PaymentStatusDisplay.test.tsx @@ -2,7 +2,7 @@ * PaymentStatusDisplay Component Tests */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { PaymentStatusDisplay } from './PaymentStatusDisplay'; @@ -424,11 +424,24 @@ describe('PaymentStatusDisplay', () => { describe('US3+US4 — switch provider + recovery disclosure (#43)', () => { // Stub SwitchProviderPanel to a sentinel so we can assert wiring without - // re-mounting the whole sub-tree (it has its own dedicated tests). + // re-mounting the whole sub-tree (it has its own dedicated tests). The + // "fire switch success" button lets tests drive onSwitchSuccess so the + // parent's navigation (window.location.href) is exercised (#159). vi.mock('@/components/payment/SwitchProviderPanel', () => ({ SwitchProviderPanel: (props: Record) => (
switch panel for {String(props.parentIntentId)} +
), })); @@ -586,4 +599,85 @@ describe('PaymentStatusDisplay', () => { expect(retryStep).toHaveClass('line-through'); }); }); + + describe('basePath-aware navigation (issue #159)', () => { + const originalEnv = process.env; + + beforeEach(() => { + // Vitest doesn't load .env.local, but Docker injects .env into the + // container's process.env — so set the base path explicitly per case + // rather than relying on the ambient value. + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + function setupRecoverable() { + const failed = createMockResult('failed'); + failed.error_code = 'card_declined'; + vi.mocked(usePaymentRealtime).mockReturnValue({ + paymentResult: failed, + loading: false, + error: null, + }); + } + + /** jsdom location stub that records href assignments instead of navigating. */ + function stubLocation(): { href: string } { + const loc = { href: '' } as { href: string }; + Object.defineProperty(window, 'location', { + value: loc, + writable: true, + configurable: true, + }); + return loc; + } + + async function openSwitchPanelAndFire() { + const user = userEvent.setup(); + render(); + await user.click( + screen.getByRole('button', { + name: /use a different payment method/i, + }) + ); + await user.click(screen.getByTestId('fire-switch-success')); + } + + it('prefixes the retry-success navigation with the base path', async () => { + process.env.NEXT_PUBLIC_BASE_PATH = '/ScriptHammer'; + setupRecoverable(); + const loc = stubLocation(); + await openSwitchPanelAndFire(); + expect(loc.href).toBe('/ScriptHammer/payment-result?id=new-intent-999'); + }); + + it('navigates to the unprefixed path when no base path is set', async () => { + delete process.env.NEXT_PUBLIC_BASE_PATH; + setupRecoverable(); + const loc = stubLocation(); + await openSwitchPanelAndFire(); + expect(loc.href).toBe('/payment-result?id=new-intent-999'); + }); + + it('routes the support link through next/link so it inherits the base path', () => { + const failed = createMockResult('failed'); + failed.error_code = 'expired_card'; // non-recoverable → button-style link + vi.mocked(usePaymentRealtime).mockReturnValue({ + paymentResult: failed, + loading: false, + error: null, + }); + render(); + const link = screen.getByRole('link', { name: /contact support/i }); + // next/link renders an ; it prepends the runtime + // basePath (__NEXT_ROUTER_BASEPATH) in the real app, which jsdom doesn't + // populate — so we assert the anchor points at the app-relative route. + // The prefixed form is pinned end-to-end by the basePath E2E project (#157). + expect(link.tagName).toBe('A'); + expect(link).toHaveAttribute('href', '/contact'); + }); + }); }); diff --git a/src/components/payment/PaymentStatusDisplay/PaymentStatusDisplay.tsx b/src/components/payment/PaymentStatusDisplay/PaymentStatusDisplay.tsx index 20ead254..9c4c2b6b 100644 --- a/src/components/payment/PaymentStatusDisplay/PaymentStatusDisplay.tsx +++ b/src/components/payment/PaymentStatusDisplay/PaymentStatusDisplay.tsx @@ -6,6 +6,7 @@ 'use client'; import React from 'react'; +import Link from 'next/link'; import { usePaymentRealtime } from '@/hooks/usePaymentRealtime'; import { usePaymentRetryStatus } from '@/hooks/usePaymentRetryStatus'; import { @@ -17,6 +18,7 @@ import { } from '@/lib/payments/payment-service'; import { categorizePaymentError } from '@/lib/payments/error-categorization'; import { SwitchProviderPanel } from '@/components/payment/SwitchProviderPanel'; +import { getInternalUrl } from '@/config/project.config'; import type { Currency } from '@/types/payment'; export interface PaymentStatusDisplayProps { @@ -433,7 +435,11 @@ export const PaymentStatusDisplay: React.FC = ({ { - window.location.href = `/payment-result?id=${newIntentId}`; + // Raw window.location isn't basePath-prefixed; wrap + // it so a project-site fork stays in-app (#159). + window.location.href = getInternalUrl( + `/payment-result?id=${newIntentId}` + ); }} /> @@ -474,9 +480,9 @@ export const PaymentStatusDisplay: React.FC = ({ : '' } > - + Contact support - {' '} + {' '} with the transaction reference above. @@ -485,13 +491,13 @@ export const PaymentStatusDisplay: React.FC = ({ ) : ( )}