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 = ({
>
) : (
)}
>