diff --git a/resources/js/pages/auth/LoginForm.tsx b/resources/js/pages/auth/LoginForm.tsx index eaf661f..b011885 100644 --- a/resources/js/pages/auth/LoginForm.tsx +++ b/resources/js/pages/auth/LoginForm.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { usePage } from "@inertiajs/react"; import { useTranslation } from "react-i18next"; import toast from "react-hot-toast"; @@ -50,6 +50,20 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale, const resolvedLocale = locale ?? page.props.locale ?? "de"; const loginEndpoint = '/checkout/login'; + + const canUseGoogle = typeof packageId === "number" && !Number.isNaN(packageId); + const googleHref = useMemo(() => { + if (!canUseGoogle) { + return ""; + } + + const params = new URLSearchParams({ + package_id: String(packageId), + locale: resolvedLocale, + }); + + return `/checkout/auth/google?${params.toString()}`; + }, [canUseGoogle, packageId, resolvedLocale]); const [values, setValues] = useState({ identifier: "", @@ -58,6 +72,7 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale, }); const [errors, setErrors] = useState({}); const [isSubmitting, setIsSubmitting] = useState(false); + const [isRedirectingToGoogle, setIsRedirectingToGoogle] = useState(false); const [hasTriedSubmit, setHasTriedSubmit] = useState(false); const [shouldFocusError, setShouldFocusError] = useState(false); @@ -164,6 +179,15 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale, } }; + const handleGoogleLogin = () => { + if (!googleHref) { + return; + } + + setIsRedirectingToGoogle(true); + window.location.href = googleHref; + }; + return (
@@ -219,6 +243,30 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale,
+ {canUseGoogle && ( +
+
+ + {t("login.oauth_divider", "oder")} + +
+ +
+ )} + {Object.values(errors).some(Boolean) && (

{Object.values(errors).filter(Boolean).join(" \u2022 ")}

@@ -227,3 +275,14 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale, ); } + +function GoogleIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/resources/js/pages/auth/__tests__/LoginForm.test.tsx b/resources/js/pages/auth/__tests__/LoginForm.test.tsx new file mode 100644 index 0000000..c943669 --- /dev/null +++ b/resources/js/pages/auth/__tests__/LoginForm.test.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} + +global.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; + +vi.mock('@inertiajs/react', () => ({ + usePage: () => ({ props: { locale: 'de' } }), + Link: ({ children, ...props }: { children: React.ReactNode }) => {children}, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string | Record) => { + if (typeof fallback === 'string') { + return fallback; + } + if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') { + return fallback.defaultValue; + } + return key; + }, + }), +})); + +vi.mock('react-hot-toast', () => ({ + default: { + error: vi.fn(), + success: vi.fn(), + }, +})); + +import LoginForm from '../LoginForm'; + +describe('LoginForm', () => { + it('renders Google login option when packageId is provided', () => { + render(); + + expect(screen.getByText('login.google_cta')).toBeInTheDocument(); + }); + + it('does not render Google login option without packageId', () => { + render(); + + expect(screen.queryByText('login.google_cta')).not.toBeInTheDocument(); + }); +}); diff --git a/resources/js/pages/marketing/checkout/__tests__/AuthStep.google.test.tsx b/resources/js/pages/marketing/checkout/__tests__/AuthStep.google.test.tsx new file mode 100644 index 0000000..c7299af --- /dev/null +++ b/resources/js/pages/marketing/checkout/__tests__/AuthStep.google.test.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; + +vi.mock('@inertiajs/react', () => ({ + usePage: () => ({ props: { locale: 'de', googleAuth: {} } }), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string | Record) => { + if (typeof fallback === 'string') { + return fallback; + } + if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') { + return fallback.defaultValue; + } + return key; + }, + }), + Trans: ({ i18nKey }: { i18nKey: string }) => {i18nKey}, +})); + +vi.mock('react-hot-toast', () => ({ + default: { + error: vi.fn(), + success: vi.fn(), + }, +})); + +vi.mock('@/components/ui/button', () => ({ + Button: ({ children, onClick, ...props }: { children: React.ReactNode; onClick?: () => void }) => ( + + ), +})); + +vi.mock('@/components/ui/alert', () => ({ + Alert: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDescription: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('@/components/ui/collapsible', () => ({ + Collapsible: ({ children }: { children: React.ReactNode }) =>
{children}
, + CollapsibleContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + CollapsibleTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('@/lib/utils', () => ({ + cn: (...args: Array) => args.filter(Boolean).join(' '), +})); + +vi.mock('../../../auth/LoginForm', () => ({ + default: () =>
LoginForm
, +})); + +vi.mock('../../../auth/RegisterForm', () => ({ + default: () =>
RegisterForm
, +})); + +vi.mock('../WizardContext', () => ({ + useCheckoutWizard: () => ({ + isAuthenticated: false, + authUser: null, + setAuthUser: vi.fn(), + nextStep: vi.fn(), + selectedPackage: { id: 1, name: 'Starter', price: 0, type: 'endcustomer', features: [] }, + }), +})); + +vi.mock('lucide-react', () => ({ + ChevronDown: () => chevron, + LoaderCircle: () => loader, +})); + +import { AuthStep } from '../steps/AuthStep'; + +describe('AuthStep Google controls', () => { + it('hides the top Google button when switching to login mode', () => { + render(); + + expect(screen.getByText('checkout.auth_step.continue_with_google')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('checkout.auth_step.switch_to_login')); + + expect(screen.queryByText('checkout.auth_step.continue_with_google')).not.toBeInTheDocument(); + }); +}); diff --git a/resources/js/pages/marketing/checkout/steps/AuthStep.tsx b/resources/js/pages/marketing/checkout/steps/AuthStep.tsx index a23629b..ef6fe86 100644 --- a/resources/js/pages/marketing/checkout/steps/AuthStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/AuthStep.tsx @@ -46,6 +46,7 @@ export const AuthStep: React.FC = ({ privacyHtml, googleProfile, const [mode, setMode] = useState<'login' | 'register'>('register'); const [isRedirectingToGoogle, setIsRedirectingToGoogle] = useState(false); const [showGoogleHelper, setShowGoogleHelper] = useState(false); + const showGoogleControls = mode === 'register'; useEffect(() => { if (googleAuth?.status === 'signin') { @@ -60,6 +61,16 @@ export const AuthStep: React.FC = ({ privacyHtml, googleProfile, } }, [googleAuth?.error]); + useEffect(() => { + if (mode !== 'login') { + return; + } + + if (showGoogleHelper) { + setShowGoogleHelper(false); + } + }, [mode, showGoogleHelper]); + const handleLoginSuccess = (payload: AuthUserPayload | null) => { if (!payload) { return; @@ -148,39 +159,43 @@ export const AuthStep: React.FC = ({ privacyHtml, googleProfile, > {t('checkout.auth_step.switch_to_login')} -
- - - - -
+ {isRedirectingToGoogle ? ( + + ) : ( + + )} + {t('checkout.auth_step.continue_with_google')} + + + + +
+ )} - - - {t('checkout.auth_step.google_helper')} - - + {showGoogleControls && ( + + + {t('checkout.auth_step.google_helper')} + + + )} {googleAuth?.error && (