nicer package layout, also in checkout step 1, fixed missing registration language strings, registration error handling, email verification redirect, email verification error handling and messaging,

This commit is contained in:
Codex Agent
2025-11-19 20:21:54 +01:00
parent 91d3e61b0e
commit 8d2075bdd2
24 changed files with 1000 additions and 363 deletions

View File

@@ -37,11 +37,37 @@ type RegisterFormFields = {
package_id: number | null;
};
const getCookieValue = (name: string): string | null => {
if (typeof document === 'undefined') {
return null;
}
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
};
const resolveCsrfToken = (): string => {
if (typeof document === 'undefined') {
return '';
}
const metaToken = (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content;
if (metaToken && metaToken.length > 0) {
return metaToken;
}
return getCookieValue('XSRF-TOKEN') ?? '';
};
export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale, prefill, onClearGoogleProfile }: RegisterFormProps) {
const [privacyOpen, setPrivacyOpen] = useState(false);
const [hasTriedSubmit, setHasTriedSubmit] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [prefillApplied, setPrefillApplied] = useState(false);
const [serverError, setServerError] = useState<string | null>(null);
const [serverErrorType, setServerErrorType] = useState<'generic' | 'session-expired'>('generic');
const { t } = useTranslation(['auth', 'common']);
const page = usePage<{ errors: Record<string, string>; locale?: string; auth?: { user?: any | null } }>();
const resolvedLocale = locale ?? page.props.locale ?? 'de';
@@ -68,6 +94,30 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
const registerEndpoint = '/checkout/register';
const requiredStringFields: Array<keyof RegisterFormFields> = useMemo(() => (
['first_name', 'last_name', 'username', 'email', 'password', 'password_confirmation', 'address', 'phone']
), []);
const isFormValid = useMemo(() => {
const stringsValid = requiredStringFields.every((field) => {
const value = data[field];
if (typeof value !== 'string') {
return false;
}
return value.trim().length > 0;
});
if (!stringsValid) {
return false;
}
const emailValid = /.+@.+\..+/.test(data.email.trim());
const passwordsMatch = data.password === data.password_confirmation;
return emailValid && passwordsMatch && data.privacy_consent && data.terms;
}, [data, requiredStringFields]);
const namePrefill = useMemo(() => {
const rawFirst = prefill?.given_name ?? prefill?.name?.split(' ')[0] ?? '';
const remaining = prefill?.name ? prefill.name.split(' ').slice(1).join(' ') : '';
@@ -126,24 +176,28 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
const submit = async (event: React.FormEvent) => {
event.preventDefault();
setServerError(null);
setServerErrorType('generic');
setHasTriedSubmit(true);
setIsSubmitting(true);
clearErrors();
const csrfToken = resolveCsrfToken();
const body = {
...data,
locale: resolvedLocale,
package_id: data.package_id ?? packageId ?? null,
_token: csrfToken,
};
try {
const response = await fetch(registerEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content ?? '',
'X-CSRF-TOKEN': csrfToken,
'X-XSRF-TOKEN': csrfToken,
},
credentials: 'same-origin',
body: JSON.stringify(body),
@@ -179,10 +233,32 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
return;
}
toast.error(t('register.unexpected_error', 'Registrierung nicht moeglich.'));
if (response.status === 419) {
const expiredMessage = t('register.session_expired_message', 'Deine Sitzung ist abgelaufen. Bitte lade die Seite neu und versuche es erneut.');
setServerErrorType('session-expired');
setServerError(expiredMessage);
toast.error(expiredMessage);
return;
}
let message: string | null = null;
try {
const json = await response.clone().json();
message = json?.message ?? null;
} catch (error) {
message = null;
}
const fallbackMessage = message ?? t('register.server_error_message', 'Etwas ist schiefgelaufen. Bitte versuche es erneut.');
setServerErrorType('generic');
setServerError(fallbackMessage);
toast.error(fallbackMessage);
} catch (error) {
console.error('Register request failed', error);
toast.error(t('register.unexpected_error', 'Registrierung nicht moeglich.'));
const fallbackMessage = t('register.server_error_message', 'Etwas ist schiefgelaufen. Bitte versuche es erneut.');
setServerErrorType('generic');
setServerError(fallbackMessage);
toast.error(fallbackMessage);
} finally {
setIsSubmitting(false);
}
@@ -463,10 +539,21 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
</div>
)}
{serverError && (
<div className="mb-6 rounded-md border border-red-200 bg-red-50 p-4 text-sm text-red-800">
<p className="font-semibold">
{serverErrorType === 'session-expired'
? t('register.session_expired_title', 'Sicherheitsprüfung abgelaufen')
: t('register.server_error_title', 'Registrierung konnte nicht abgeschlossen werden')}
</p>
<p className="mt-1">{serverError}</p>
</div>
)}
<button
type="button"
onClick={submit}
disabled={isSubmitting}
disabled={isSubmitting || !isFormValid}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-[#FFB6C1] hover:bg-[#FF69B4] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#FFB6C1] transition duration-300 disabled:opacity-50"
>
{isSubmitting && <LoaderCircle className="h-4 w-4 animate-spin mr-2" />}

View File

@@ -1,6 +1,6 @@
import { FormEvent, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { Head, useForm } from '@inertiajs/react';
import { Head, useForm, usePage } from '@inertiajs/react';
import { useTranslation } from 'react-i18next';
import InputError from '@/components/input-error';
import TextLink from '@/components/text-link';
@@ -13,6 +13,7 @@ import AppLayout from '@/layouts/app/AppLayout';
import { register } from '@/routes';
import { request } from '@/routes/password';
import { LoaderCircle } from 'lucide-react';
import toast from 'react-hot-toast';
interface LoginProps {
status?: string;
@@ -24,6 +25,8 @@ export default function Login({ status, canResetPassword }: LoginProps) {
const [rawReturnTo, setRawReturnTo] = useState<string | null>(null);
const [isRedirectingToGoogle, setIsRedirectingToGoogle] = useState(false);
const { t } = useTranslation('auth');
const page = usePage<{ flash?: { verification?: { status: string; title?: string; message?: string } } }>();
const verificationFlash = page.props.flash?.verification;
const { data, setData, post, processing, errors, clearErrors } = useForm({
login: '',
@@ -50,7 +53,15 @@ export default function Login({ status, canResetPassword }: LoginProps) {
const searchParams = new URLSearchParams(window.location.search);
setRawReturnTo(searchParams.get('return_to'));
}, []);
if (searchParams.get('verified') === '1') {
toast.success(t('verification.toast_success', 'Email verified successfully.'));
searchParams.delete('verified');
const nextQuery = searchParams.toString();
const nextUrl = `${window.location.pathname}${nextQuery ? `?${nextQuery}` : ''}`;
window.history.replaceState({}, '', nextUrl);
}
}, [t]);
useEffect(() => {
setData('return_to', rawReturnTo ?? '');
@@ -213,6 +224,20 @@ export default function Login({ status, canResetPassword }: LoginProps) {
</div>
)}
{verificationFlash && (
<div
className={[
'rounded-2xl border p-3 text-center font-medium shadow-sm',
verificationFlash.status === 'success'
? 'border-emerald-200/70 bg-emerald-50/90 text-emerald-700'
: 'border-rose-200/70 bg-rose-50/90 text-rose-700',
].join(' ')}
>
<div className="font-semibold">{verificationFlash.title ?? ''}</div>
<div className="text-sm">{verificationFlash.message}</div>
</div>
)}
{hasErrors && (
<div
key={`general-errors-${errorKeys.join('-')}`}

View File

@@ -1,7 +1,7 @@
// Components
import { store } from '@/actions/App/Http/Controllers/Auth/EmailVerificationNotificationController';
import { logout } from '@/routes';
import { Form, Head } from '@inertiajs/react';
import { Form, Head, usePage } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import TextLink from '@/components/text-link';
@@ -13,11 +13,26 @@ export default function VerifyEmail({ status }: { status?: string }) {
const description = isNewRegistration
? 'Thanks! Please confirm your email address to access your dashboard.'
: 'Please verify your email address by clicking on the link we just emailed to you.';
const page = usePage<{ flash?: { verification?: { status: string; title?: string; message?: string } } }>();
const verificationFlash = page.props.flash?.verification;
return (
<AuthLayout title="Verify email" description={description}>
<Head title="Email verification" />
{verificationFlash && (
<div
className={`mb-6 rounded-md border p-4 text-left text-sm font-medium ${
verificationFlash.status === 'success'
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
: 'border-rose-200 bg-rose-50 text-rose-800'
}`}
>
<p className="font-semibold">{verificationFlash.title}</p>
<p className="mt-1 text-sm font-normal">{verificationFlash.message}</p>
</div>
)}
{isNewRegistration && (
<div className="mb-6 rounded-md bg-blue-50 p-4 text-left text-sm text-blue-900">
<p className="font-semibold">Almost there! Confirm your email address to start using Fotospiel.</p>