Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ const nextConfig: NextConfig = {
cleanDistDir: true,
env: {
NEXT_PUBLIC_PAGESPEED_API_KEY: process.env.NEXT_PUBLIC_PAGESPEED_API_KEY,
// Deploy workflows omit NEXT_PUBLIC_BASE_PATH and rely on detect-project.js,
// so the resolved basePath must be injected here or client code that builds
// URLs (auth redirects) sees '' in production (issue #154).
NEXT_PUBLIC_BASE_PATH: basePath,
},
webpack: (config, { isServer }) => {
// Optimize code splitting for better performance
Expand Down
3 changes: 2 additions & 1 deletion src/app/reset-password/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import React from 'react';
import ResetPasswordForm from '@/components/auth/ResetPasswordForm';
import { getInternalUrl } from '@/config/project.config';

export default function ResetPasswordPage() {
return (
Expand All @@ -12,7 +13,7 @@ export default function ResetPasswordPage() {
</h1>

<ResetPasswordForm
onSuccess={() => (window.location.href = '/sign-in')}
onSuccess={() => (window.location.href = getInternalUrl('/sign-in'))}
/>
</div>
</main>
Expand Down
5 changes: 4 additions & 1 deletion src/app/sign-up/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import React, { useState, useEffect } from 'react';
import SignUpForm from '@/components/auth/SignUpForm';
import OAuthButtons from '@/components/auth/OAuthButtons';
import Link from 'next/link';
import { getInternalUrl } from '@/config/project.config';

function isSafeRedirectUrl(url: string): boolean {
if (!url || !url.startsWith('/')) return false;
Expand Down Expand Up @@ -36,7 +37,9 @@ export default function SignUpPage() {
</h1>

<SignUpForm
onSuccess={() => (window.location.href = '/verify-email')}
onSuccess={() =>
(window.location.href = getInternalUrl('/verify-email'))
}
/>

<div className="divider my-6">OR</div>
Expand Down
3 changes: 2 additions & 1 deletion src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import errorHandler, {
ErrorSeverity,
ErrorCategory,
} from '@/utils/error-handler';
import { getInternalUrl } from '@/config/project.config';

interface Props {
children: ReactNode;
Expand Down Expand Up @@ -280,7 +281,7 @@ class ErrorBoundary extends Component<Props, State> {
</button>
{level === 'page' && (
<button
onClick={() => (window.location.href = '/')}
onClick={() => (window.location.href = getInternalUrl('/'))}
className="btn btn-ghost btn-sm"
>
Go Home
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '@/lib/auth/rate-limit-check';
import { validateEmail } from '@/lib/auth/email-validator';
import { logAuthEvent } from '@/lib/auth/audit-logger';
import { getRedirectUrl } from '@/config/project.config';

export interface ForgotPasswordFormProps {
/** Callback on success */
Expand Down Expand Up @@ -63,7 +64,7 @@ export default function ForgotPasswordForm({
const { error: resetError } = await supabase.auth.resetPasswordForEmail(
email,
{
redirectTo: `${window.location.origin}/reset-password`,
redirectTo: getRedirectUrl('/reset-password'),
}
);

Expand Down
3 changes: 2 additions & 1 deletion src/components/auth/OAuthButtons/OAuthButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import React, { useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { createLogger } from '@/lib/logger/logger';
import { getRedirectUrl } from '@/config/project.config';

const logger = createLogger('components:auth:OAuthButtons');

Expand Down Expand Up @@ -30,7 +31,7 @@ export default function OAuthButtons({ className = '' }: OAuthButtonsProps) {
await supabase.auth.signInWithOAuth({
provider,
options: {
redirectTo: `${window.location.origin}/auth/callback`,
redirectTo: getRedirectUrl('/auth/callback'),
},
});
} catch (error) {
Expand Down
3 changes: 2 additions & 1 deletion src/components/auth/SignInForm/SignInForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '@/lib/auth/rate-limit-check';
import { validateEmail } from '@/lib/auth/email-validator';
import { logAuthEvent } from '@/lib/auth/audit-logger';
import { getInternalUrl } from '@/config/project.config';
import { createLogger } from '@/lib/logger/logger';

const logger = createLogger('components:auth:SignInForm');
Expand Down Expand Up @@ -91,7 +92,7 @@ export default function SignInForm({
if (signInError) {
// Check if email needs verification
if (signInError.message.toLowerCase().includes('email not confirmed')) {
window.location.href = '/verify-email';
window.location.href = getInternalUrl('/verify-email');
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ vi.mock('@/hooks/usePaymentButton', () => ({
consentReady: true,
selectProvider: vi.fn(),
initiatePayment: vi.fn(),
mountPayPalButtons: vi.fn(),
clearError: vi.fn(),
})),
}));
Expand Down Expand Up @@ -65,6 +66,7 @@ describe('PaymentButton Accessibility', () => {
consentReady: true,
selectProvider: vi.fn(),
initiatePayment: vi.fn(),
mountPayPalButtons: vi.fn(),
clearError: vi.fn(),
});

Expand All @@ -84,6 +86,7 @@ describe('PaymentButton Accessibility', () => {
consentReady: true,
selectProvider: vi.fn(),
initiatePayment: vi.fn(),
mountPayPalButtons: vi.fn(),
clearError: vi.fn(),
});

Expand All @@ -103,6 +106,7 @@ describe('PaymentButton Accessibility', () => {
consentReady: true,
selectProvider: vi.fn(),
initiatePayment: vi.fn(),
mountPayPalButtons: vi.fn(),
clearError: vi.fn(),
});

Expand All @@ -122,6 +126,7 @@ describe('PaymentButton Accessibility', () => {
consentReady: true,
selectProvider: vi.fn(),
initiatePayment: vi.fn(),
mountPayPalButtons: vi.fn(),
clearError: vi.fn(),
});

Expand All @@ -141,6 +146,7 @@ describe('PaymentButton Accessibility', () => {
consentReady: true,
selectProvider: vi.fn(),
initiatePayment: vi.fn(),
mountPayPalButtons: vi.fn(),
clearError: vi.fn(),
});

Expand Down
6 changes: 6 additions & 0 deletions src/components/payment/PaymentButton/PaymentButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ vi.mock('@/hooks/usePaymentButton', () => ({
consentReady: true,
selectProvider: vi.fn(),
initiatePayment: vi.fn(),
mountPayPalButtons: vi.fn(),
clearError: vi.fn(),
})),
}));
Expand Down Expand Up @@ -152,6 +153,7 @@ describe('PaymentButton', () => {
consentReady: true,
selectProvider: vi.fn(),
initiatePayment: vi.fn(),
mountPayPalButtons: vi.fn(),
clearError: vi.fn(),
});

Expand All @@ -173,6 +175,7 @@ describe('PaymentButton', () => {
consentReady: true,
selectProvider: vi.fn(),
initiatePayment: vi.fn(),
mountPayPalButtons: vi.fn(),
clearError: vi.fn(),
});

Expand All @@ -191,6 +194,7 @@ describe('PaymentButton', () => {
consentReady: true,
selectProvider: vi.fn(),
initiatePayment: vi.fn(),
mountPayPalButtons: vi.fn(),
clearError: vi.fn(),
});

Expand All @@ -211,6 +215,7 @@ describe('PaymentButton', () => {
consentReady: true,
selectProvider: vi.fn(),
initiatePayment: vi.fn(),
mountPayPalButtons: vi.fn(),
clearError: vi.fn(),
});

Expand All @@ -231,6 +236,7 @@ describe('PaymentButton', () => {
consentReady: true,
selectProvider: vi.fn(),
initiatePayment: vi.fn(),
mountPayPalButtons: vi.fn(),
clearError: vi.fn(),
});

Expand Down
77 changes: 55 additions & 22 deletions src/components/payment/PaymentButton/PaymentButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

'use client';

import React from 'react';
import React, { useEffect, useId, useRef } from 'react';
import {
usePaymentButton,
UsePaymentButtonOptions,
Expand Down Expand Up @@ -52,11 +52,33 @@ export const PaymentButton: React.FC<PaymentButtonProps> = ({
consentReady,
selectProvider,
initiatePayment,
mountPayPalButtons,
clearError,
} = usePaymentButton(options);

const formattedAmount = formatPaymentAmount(options.amount, options.currency);

// PayPal renders its own SDK Buttons (approval happens in PayPal's popup),
// so when the PayPal tab is active + consent is granted, mount them into a
// stable per-instance container instead of showing the generic pay button.
const rawId = useId();
const paypalContainerId = `paypal-buttons-${rawId.replace(/[:]/g, '')}`;
const paypalMounted = useRef(false);
const showPayPalButtons =
selectedProvider === 'paypal' && consentReady && hasConsent;

useEffect(() => {
if (showPayPalButtons && !paypalMounted.current) {
paypalMounted.current = true;
void mountPayPalButtons(paypalContainerId);
}
if (!showPayPalButtons) {
paypalMounted.current = false;
}
// mountPayPalButtons is stable per render cycle; container id is stable.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showPayPalButtons, paypalContainerId]);

// Feature-flag gate: if no providers are configured (neither
// NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY nor NEXT_PUBLIC_PAYPAL_CLIENT_ID is
// set), render a clear "not configured" message instead of an unclickable
Expand Down Expand Up @@ -226,31 +248,42 @@ export const PaymentButton: React.FC<PaymentButtonProps> = ({
</div>
)}

{/* Payment Button */}
<button
type="button"
className={`btn btn-primary ${sizeClasses[size]} ${isProcessing ? 'loading' : ''}`}
onClick={initiatePayment}
disabled={!selectedProvider || isProcessing || !hasConsent}
aria-label={buttonText || `Pay ${formattedAmount}`}
aria-busy={isProcessing}
>
{isProcessing ? (
<>
<span className="loading loading-spinner"></span>
Processing...
</>
) : (
buttonText || `Pay ${formattedAmount}`
)}
</button>
{/* PayPal renders its own SDK Buttons here; Stripe uses the generic
button below. Container is always in the DOM so the SDK has a mount
point, but only visible when PayPal is the active provider. */}
<div
id={paypalContainerId}
data-testid="paypal-buttons"
className={showPayPalButtons ? '' : 'hidden'}
/>

{/* Payment Button (Stripe / generic redirect flow) */}
{!showPayPalButtons && (
<button
type="button"
className={`btn btn-primary ${sizeClasses[size]} ${isProcessing ? 'loading' : ''}`}
onClick={initiatePayment}
disabled={!selectedProvider || isProcessing || !hasConsent}
aria-label={buttonText || `Pay ${formattedAmount}`}
aria-busy={isProcessing}
>
{isProcessing ? (
<>
<span className="loading loading-spinner"></span>
Processing...
</>
) : (
buttonText || `Pay ${formattedAmount}`
)}
</button>
)}

{/* Provider Info */}
{selectedProvider && (
<p className="text-base-content/85 text-center text-sm">
You will be redirected to{' '}
{selectedProvider === 'stripe' ? 'Stripe' : 'PayPal'} to complete your
payment securely.
{selectedProvider === 'stripe'
? 'You will be redirected to Stripe to complete your payment securely.'
: 'Complete your payment securely in the PayPal window.'}
</p>
)}
</div>
Expand Down
48 changes: 48 additions & 0 deletions src/config/__tests__/project.config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
getProjectConfig,
isGitHubPages,
getAssetUrl,
getInternalUrl,
getRedirectUrl,
generateManifest,
projectConfig,
} from '../project.config';
Expand Down Expand Up @@ -178,6 +180,52 @@ describe('Project Configuration', () => {
});
});

describe('getInternalUrl / getRedirectUrl (issue #154)', () => {
it('prefixes basePath and canonicalizes the trailing slash', () => {
process.env.NEXT_PUBLIC_BASE_PATH = '/RescueDogs';

expect(getInternalUrl('/')).toBe('/RescueDogs/');
expect(getInternalUrl('/auth/callback')).toBe(
'/RescueDogs/auth/callback/'
);
expect(getInternalUrl('/auth/callback/')).toBe(
'/RescueDogs/auth/callback/'
);
expect(getInternalUrl('auth/callback')).toBe(
'/RescueDogs/auth/callback/'
);
});

it('degrades to the bare path when basePath is unset', () => {
delete process.env.NEXT_PUBLIC_BASE_PATH;

expect(getInternalUrl('/')).toBe('/');
expect(getInternalUrl('/verify-email')).toBe('/verify-email/');
});

it('leaves paths with a query or hash unsuffixed', () => {
process.env.NEXT_PUBLIC_BASE_PATH = '/RescueDogs';

expect(getInternalUrl('/sign-in?error=auth_callback_failed')).toBe(
'/RescueDogs/sign-in?error=auth_callback_failed'
);
expect(getInternalUrl('/page#section')).toBe('/RescueDogs/page#section');
});

it('builds absolute Supabase redirect URLs from the runtime origin plus basePath', () => {
process.env.NEXT_PUBLIC_BASE_PATH = '/RescueDogs';

expect(getRedirectUrl('/auth/callback')).toBe(
'http://localhost:3000/RescueDogs/auth/callback/'
);

delete process.env.NEXT_PUBLIC_BASE_PATH;
expect(getRedirectUrl('/auth/callback')).toBe(
'http://localhost:3000/auth/callback/'
);
});
});

describe('generateManifest', () => {
it('should generate a valid PWA manifest', () => {
const manifest = generateManifest();
Expand Down
Loading
Loading