checkout: buttons verbessert, paddle zahlungsschritt schicker gemacht, schritt 4 optimiert+schick gemacht. Dashboard: translations ergänzt. Startseite vom Event Admin optimiert.
This commit is contained in:
@@ -50,6 +50,7 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale }
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [hasTriedSubmit, setHasTriedSubmit] = useState(false);
|
||||
const [shouldFocusError, setShouldFocusError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasTriedSubmit) {
|
||||
@@ -63,7 +64,7 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale }
|
||||
}, [errors, hasTriedSubmit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasTriedSubmit) {
|
||||
if (!hasTriedSubmit || !shouldFocusError) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -75,11 +76,13 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale }
|
||||
const field = document.querySelector<HTMLInputElement>(`[name="${firstKey}"]`);
|
||||
field?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
field?.focus();
|
||||
}, [errors, hasTriedSubmit]);
|
||||
setShouldFocusError(false);
|
||||
}, [errors, hasTriedSubmit, shouldFocusError]);
|
||||
|
||||
const updateValue = (key: keyof typeof values, value: string | boolean) => {
|
||||
setValues((current) => ({ ...current, [key]: value }));
|
||||
setErrors((current) => ({ ...current, [key as string]: "" }));
|
||||
setShouldFocusError(false);
|
||||
};
|
||||
|
||||
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
@@ -129,11 +132,13 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale }
|
||||
});
|
||||
|
||||
setErrors(fieldErrors);
|
||||
setShouldFocusError(true);
|
||||
toast.error(t("login.failed_generic", "Ungueltige Anmeldedaten"));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(t("login.unexpected_error", "Beim Login ist ein Fehler aufgetreten."));
|
||||
setShouldFocusError(false);
|
||||
} catch (error) {
|
||||
console.error("Login request failed", error);
|
||||
toast.error(t("login.unexpected_error", "Beim Login ist ein Fehler aufgetreten."));
|
||||
@@ -205,4 +210,3 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale }
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -390,7 +390,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
||||
|
||||
<div className="md:col-span-1">
|
||||
<label htmlFor="password_confirmation" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('register.confirm_password')} {t('common:required')}
|
||||
{t('register.password_confirmation')} {t('common:required')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
@@ -410,7 +410,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
||||
}
|
||||
}}
|
||||
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.password_confirmation ? 'border-red-500' : 'border-gray-300'}`}
|
||||
placeholder={t('register.confirm_password_placeholder')}
|
||||
placeholder={t('register.password_confirmation_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
{errors.password_confirmation && <p className="text-sm text-red-600 mt-1">{errors.password_confirmation}</p>}
|
||||
@@ -442,7 +442,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
||||
onClick={() => setPrivacyOpen(true)}
|
||||
className="text-[#FFB6C1] hover:underline inline bg-transparent border-none cursor-pointer p-0 font-medium"
|
||||
>
|
||||
{t('register.privacy_policy')}
|
||||
{t('register.privacy_policy_link')}
|
||||
</button>.
|
||||
</label>
|
||||
{errors.privacy_consent && <p className="mt-2 text-sm text-red-600">{errors.privacy_consent}</p>}
|
||||
@@ -485,5 +485,3 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -265,7 +265,7 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
|
||||
|
||||
<div className="md:col-span-1">
|
||||
<label htmlFor="password_confirmation" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('register.confirm_password')} {t('common:required')}
|
||||
{t('register.password_confirmation')} {t('common:required')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
@@ -285,7 +285,7 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
|
||||
}
|
||||
}}
|
||||
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.password_confirmation ? 'border-red-500' : 'border-gray-300'}`}
|
||||
placeholder={t('register.confirm_password_placeholder')}
|
||||
placeholder={t('register.password_confirmation_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
{errors.password_confirmation && <p key={`error-password_confirmation`} className="text-sm text-red-600 mt-1">{errors.password_confirmation}</p>}
|
||||
@@ -313,7 +313,7 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
|
||||
onClick={() => setPrivacyOpen(true)}
|
||||
className="text-[#FFB6C1] hover:underline inline bg-transparent border-none cursor-pointer p-0 font-medium"
|
||||
>
|
||||
{t('register.privacy_policy')}
|
||||
{t('register.privacy_policy_link')}
|
||||
</button>.
|
||||
</label>
|
||||
{errors.privacy_consent && <p className="mt-2 text-sm text-red-600">{errors.privacy_consent}</p>}
|
||||
|
||||
@@ -3,8 +3,6 @@ import { Head, usePage } from "@inertiajs/react";
|
||||
import MarketingLayout from "@/layouts/mainWebsite";
|
||||
import type { CheckoutPackage, GoogleProfilePrefill } from "./checkout/types";
|
||||
import { CheckoutWizard } from "./checkout/CheckoutWizard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface CheckoutWizardPageProps {
|
||||
package: CheckoutPackage;
|
||||
@@ -50,19 +48,6 @@ const CheckoutWizardPage: React.FC<CheckoutWizardPageProps> = ({
|
||||
<Head title="Checkout Wizard" />
|
||||
<div className="min-h-screen bg-muted/20 py-12">
|
||||
<div className="mx-auto w-full max-w-4xl px-4">
|
||||
{/* Abbruch-Button oben rechts */}
|
||||
<div className="flex justify-end mb-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.location.href = '/packages'}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CheckoutWizard
|
||||
initialPackage={initialPackage}
|
||||
packageOptions={dedupedOptions}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import { PackageStep } from "./steps/PackageStep";
|
||||
import { AuthStep } from "./steps/AuthStep";
|
||||
import { ConfirmationStep } from "./steps/ConfirmationStep";
|
||||
import { useAnalytics } from '@/hooks/useAnalytics';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PaymentStep = lazy(() => import('./steps/PaymentStep').then((module) => ({ default: module.PaymentStep })));
|
||||
|
||||
@@ -69,6 +70,7 @@ const WizardBody: React.FC<{
|
||||
googleProfile?: GoogleProfilePrefill | null;
|
||||
onClearGoogleProfile?: () => void;
|
||||
}> = ({ privacyHtml, googleProfile, onClearGoogleProfile }) => {
|
||||
const primaryCtaClassName = "min-w-[160px] disabled:bg-muted disabled:text-muted-foreground disabled:hover:bg-muted disabled:hover:text-muted-foreground";
|
||||
const { t } = useTranslation('marketing');
|
||||
const {
|
||||
currentStep,
|
||||
@@ -160,13 +162,8 @@ const WizardBody: React.FC<{
|
||||
return true;
|
||||
}, [atLastStep, authUser, currentStep, isAuthenticated, isFreeSelected, paymentCompleted, selectedPackage]);
|
||||
|
||||
const shouldShowNextButton = useMemo(() => {
|
||||
if (currentStep !== 'payment') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isFreeSelected || paymentCompleted;
|
||||
}, [currentStep, isFreeSelected, paymentCompleted]);
|
||||
const shouldShowNextButton = useMemo(() => currentStep !== 'confirmation', [currentStep]);
|
||||
const highlightNextCta = currentStep === 'payment' && paymentCompleted;
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (!canProceedToNextStep) {
|
||||
@@ -200,8 +197,42 @@ const WizardBody: React.FC<{
|
||||
window.location.href = '/event-admin';
|
||||
}, []);
|
||||
|
||||
const primaryCta = useMemo(() => {
|
||||
if (currentStep === 'confirmation') {
|
||||
return {
|
||||
label: t('checkout.confirmation_step.to_admin'),
|
||||
onClick: handleGoToAdmin,
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!shouldShowNextButton) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: t('checkout.next'),
|
||||
onClick: handleNext,
|
||||
disabled: !canProceedToNextStep,
|
||||
};
|
||||
}, [currentStep, handleGoToAdmin, handleNext, shouldShowNextButton, t, canProceedToNextStep]);
|
||||
const ctaClassName = cn(primaryCtaClassName, highlightNextCta && 'animate-pulse ring-2 ring-primary/50 ring-offset-2 ring-offset-background');
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{primaryCta && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="lg"
|
||||
className={ctaClassName}
|
||||
onClick={primaryCta.onClick}
|
||||
disabled={primaryCta.disabled}
|
||||
>
|
||||
{primaryCta.label}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={progressRef} className="space-y-4">
|
||||
<Progress value={progress} />
|
||||
<Steps steps={stepConfig} currentStep={currentIndex >= 0 ? currentIndex : 0} />
|
||||
@@ -226,13 +257,18 @@ const WizardBody: React.FC<{
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Button variant="ghost" onClick={handlePrevious} disabled={currentIndex <= 0}>
|
||||
{t('checkout.back')}
|
||||
</Button>
|
||||
{shouldShowNextButton ? (
|
||||
<Button onClick={handleNext} disabled={!canProceedToNextStep}>
|
||||
{t('checkout.next')}
|
||||
{primaryCta ? (
|
||||
<Button
|
||||
size="lg"
|
||||
className={ctaClassName}
|
||||
onClick={primaryCta.onClick}
|
||||
disabled={primaryCta.disabled}
|
||||
>
|
||||
{primaryCta.label}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="h-10 min-w-[128px]" aria-hidden="true" />
|
||||
|
||||
@@ -64,8 +64,8 @@ describe('CheckoutWizard auth step navigation guard', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: 'checkout.next' });
|
||||
expect(nextButton).toBeDisabled();
|
||||
const nextButtons = screen.getAllByRole('button', { name: 'checkout.next' });
|
||||
nextButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
});
|
||||
|
||||
it('enables the next button once the user is authenticated on the auth step', () => {
|
||||
@@ -79,8 +79,8 @@ describe('CheckoutWizard auth step navigation guard', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: 'checkout.next' });
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
const nextButtons = screen.getAllByRole('button', { name: 'checkout.next' });
|
||||
nextButtons.forEach((button) => expect(button).not.toBeDisabled());
|
||||
});
|
||||
|
||||
it('only renders the next button on the payment step after the payment is completed', async () => {
|
||||
@@ -98,10 +98,12 @@ describe('CheckoutWizard auth step navigation guard', () => {
|
||||
|
||||
await screen.findByTestId('payment-step');
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'checkout.next' })).toBeNull();
|
||||
const nextButtons = screen.getAllByRole('button', { name: 'checkout.next' });
|
||||
nextButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'mark-complete' }));
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'checkout.next' })).toBeEnabled();
|
||||
const activatedButtons = await screen.findAllByRole('button', { name: 'checkout.next' });
|
||||
activatedButtons.forEach((button) => expect(button).toBeEnabled());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,9 @@ import LoginForm, { AuthUserPayload } from "../../../auth/LoginForm";
|
||||
import RegisterForm, { RegisterSuccessPayload } from "../../../auth/RegisterForm";
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import toast from 'react-hot-toast';
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import { ChevronDown, LoaderCircle } from "lucide-react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AuthStepProps {
|
||||
privacyHtml: string;
|
||||
@@ -43,6 +45,7 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml, googleProfile,
|
||||
const { isAuthenticated, authUser, setAuthUser, nextStep, selectedPackage } = useCheckoutWizard();
|
||||
const [mode, setMode] = useState<'login' | 'register'>('register');
|
||||
const [isRedirectingToGoogle, setIsRedirectingToGoogle] = useState(false);
|
||||
const [showGoogleHelper, setShowGoogleHelper] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (googleAuth?.status === 'signin') {
|
||||
@@ -131,33 +134,54 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml, googleProfile,
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant={mode === 'register' ? 'default' : 'outline'}
|
||||
onClick={() => setMode('register')}
|
||||
>
|
||||
{t('checkout.auth_step.switch_to_register')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === 'login' ? 'default' : 'outline'}
|
||||
onClick={() => setMode('login')}
|
||||
>
|
||||
{t('checkout.auth_step.switch_to_login')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={isRedirectingToGoogle}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{isRedirectingToGoogle ? (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<GoogleIcon className="h-4 w-4" />
|
||||
)}
|
||||
{t('checkout.auth_step.continue_with_google')}
|
||||
</Button>
|
||||
</div>
|
||||
<Collapsible open={showGoogleHelper} onOpenChange={setShowGoogleHelper} className="w-full space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant={mode === 'register' ? 'default' : 'outline'}
|
||||
onClick={() => setMode('register')}
|
||||
>
|
||||
{t('checkout.auth_step.switch_to_register')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === 'login' ? 'default' : 'outline'}
|
||||
onClick={() => setMode('login')}
|
||||
>
|
||||
{t('checkout.auth_step.switch_to_login')}
|
||||
</Button>
|
||||
<div className="flex flex-1 justify-start gap-2 sm:flex-none">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={isRedirectingToGoogle}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{isRedirectingToGoogle ? (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<GoogleIcon className="h-4 w-4" />
|
||||
)}
|
||||
{t('checkout.auth_step.continue_with_google')}
|
||||
</Button>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border border-muted-foreground/30 px-3 py-1 text-xs font-medium text-muted-foreground transition hover:border-muted-foreground/60 hover:text-foreground",
|
||||
showGoogleHelper && "bg-muted/60 text-foreground"
|
||||
)}
|
||||
>
|
||||
{t('checkout.auth_step.google_helper_badge')}
|
||||
<ChevronDown className={cn("h-3 w-3 transition-transform", showGoogleHelper && "rotate-180")} />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
</div>
|
||||
<CollapsibleContent>
|
||||
<Alert className="border-dashed border-muted/60 bg-muted/20 text-xs sm:text-sm">
|
||||
<AlertDescription>{t('checkout.auth_step.google_helper')}</AlertDescription>
|
||||
</Alert>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{googleAuth?.error && (
|
||||
<Alert variant="destructive">
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { useCheckoutWizard } from "../WizardContext";
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CalendarDays, QrCode, ClipboardList, Smartphone, Sparkles } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ConfirmationStepProps {
|
||||
onViewProfile?: () => void;
|
||||
@@ -30,29 +32,99 @@ export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfil
|
||||
|
||||
const packageName = selectedPackage?.name ?? '';
|
||||
|
||||
const onboardingItems = [
|
||||
{
|
||||
key: 'event',
|
||||
icon: CalendarDays,
|
||||
},
|
||||
{
|
||||
key: 'invites',
|
||||
icon: QrCode,
|
||||
},
|
||||
{
|
||||
key: 'tasks',
|
||||
icon: ClipboardList,
|
||||
},
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Alert className="border-primary/40 bg-primary/5">
|
||||
<AlertTitle className="text-xl font-semibold">
|
||||
{t('checkout.confirmation_step.welcome')}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="space-y-2 text-base leading-relaxed">
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="checkout.confirmation_step.package_summary"
|
||||
components={{ strong: <span className="font-semibold" /> }}
|
||||
values={{ name: packageName }}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('checkout.confirmation_step.email_followup')}
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="overflow-hidden rounded-2xl border bg-gradient-to-br from-primary via-primary/70 to-primary/60 p-6 text-primary-foreground shadow-lg">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="secondary" className="bg-white/15 text-white shadow-sm ring-1 ring-white/30 backdrop-blur">
|
||||
<Sparkles className="mr-1 h-3.5 w-3.5" />
|
||||
{t('checkout.confirmation_step.hero_badge')}
|
||||
</Badge>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-2xl font-semibold">{t('checkout.confirmation_step.hero_title')}</h3>
|
||||
<p className="text-sm text-white/80">
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="checkout.confirmation_step.package_summary"
|
||||
components={{ strong: <span className="font-semibold" /> }}
|
||||
values={{ name: packageName }}
|
||||
/>
|
||||
</p>
|
||||
<p className="text-sm text-white/80">{t('checkout.confirmation_step.hero_body')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/30 bg-white/10 px-5 py-4 text-sm text-white/90 shadow-inner backdrop-blur lg:max-w-sm">
|
||||
<p>{t('checkout.confirmation_step.hero_next')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card/60 p-6 shadow-sm">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('checkout.confirmation_step.onboarding_title')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t('checkout.confirmation_step.onboarding_subtitle')}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs font-medium">
|
||||
{t('checkout.confirmation_step.onboarding_badge')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-3">
|
||||
{onboardingItems.map(({ key, icon: Icon }) => (
|
||||
<div key={key} className="rounded-lg border bg-background/60 p-4 shadow-inner">
|
||||
<div className={cn("mb-3 inline-flex rounded-full bg-primary/10 p-2 text-primary")}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-sm font-semibold">
|
||||
{t(`checkout.confirmation_step.onboarding_items.${key}.title`)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t(`checkout.confirmation_step.onboarding_items.${key}.body`)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-muted/30 p-6 shadow-inner">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{t('checkout.confirmation_step.control_center_title')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('checkout.confirmation_step.control_center_body')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Smartphone className="h-4 w-4" />
|
||||
{t('checkout.confirmation_step.control_center_hint')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 justify-end">
|
||||
<Button variant="outline" onClick={handleProfile}>
|
||||
{t('checkout.confirmation_step.open_profile')}
|
||||
</Button>
|
||||
<Button onClick={handleAdmin}>{t('checkout.confirmation_step.to_admin')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { Check, Package as PackageIcon, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, Package as PackageIcon } from "lucide-react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useCheckoutWizard } from "../WizardContext";
|
||||
@@ -107,8 +106,7 @@ function PackageOption({ pkg, isActive, onSelect, t }: { pkg: CheckoutPackage; i
|
||||
|
||||
export const PackageStep: React.FC = () => {
|
||||
const { t } = useTranslation('marketing');
|
||||
const { selectedPackage, packageOptions, setSelectedPackage, resetPaymentState, nextStep } = useCheckoutWizard();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { selectedPackage, packageOptions, setSelectedPackage, resetPaymentState } = useCheckoutWizard();
|
||||
|
||||
|
||||
// Early return if no package is selected
|
||||
@@ -141,31 +139,10 @@ export const PackageStep: React.FC = () => {
|
||||
resetPaymentState();
|
||||
};
|
||||
|
||||
const handleNextStep = async () => {
|
||||
setIsLoading(true);
|
||||
// Kleine Verzögerung für bessere UX
|
||||
setTimeout(() => {
|
||||
nextStep();
|
||||
setIsLoading(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-8 lg:grid-cols-[2fr_1fr]">
|
||||
<div className="space-y-6">
|
||||
<PackageSummary pkg={selectedPackage} t={t} />
|
||||
<div className="flex justify-end">
|
||||
<Button size="lg" onClick={handleNextStep} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('checkout.package_step.loading')}
|
||||
</>
|
||||
) : (
|
||||
t('checkout.package_step.next_to_account')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<aside className="space-y-4">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
|
||||
@@ -2,12 +2,14 @@ import React, { FormEvent, useCallback, useEffect, useMemo, useRef, useState } f
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LoaderCircle, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { LoaderCircle, CheckCircle2, XCircle, ShieldCheck, Receipt, Headphones } from 'lucide-react';
|
||||
import { useCheckoutWizard } from '../WizardContext';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { previewCoupon as requestCouponPreview } from '@/lib/coupons';
|
||||
import type { CouponPreviewResponse } from '@/types/coupon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
type PaymentStatus = 'idle' | 'processing' | 'ready' | 'error';
|
||||
|
||||
@@ -27,6 +29,7 @@ declare global {
|
||||
|
||||
const PADDLE_SCRIPT_URL = 'https://cdn.paddle.com/paddle/v2/paddle.js';
|
||||
const PADDLE_SUPPORTED_LOCALES = ['en', 'de', 'fr', 'es', 'it', 'nl', 'pt', 'sv', 'da', 'fi', 'no'];
|
||||
const PRIMARY_CTA_STYLES = 'min-w-[200px] disabled:bg-muted disabled:text-muted-foreground disabled:hover:bg-muted disabled:hover:text-muted-foreground';
|
||||
|
||||
export function resolvePaddleLocale(rawLocale?: string | null): string {
|
||||
if (!rawLocale) {
|
||||
@@ -94,11 +97,16 @@ async function loadPaddle(environment: PaddleEnvironment): Promise<typeof window
|
||||
return configurePaddle(paddle, environment);
|
||||
}
|
||||
|
||||
const PaddleCta: React.FC<{ onCheckout: () => Promise<void>; disabled: boolean; isProcessing: boolean }> = ({ onCheckout, disabled, isProcessing }) => {
|
||||
const PaddleCta: React.FC<{ onCheckout: () => Promise<void>; disabled: boolean; isProcessing: boolean; className?: string }> = ({ onCheckout, disabled, isProcessing, className }) => {
|
||||
const { t } = useTranslation('marketing');
|
||||
|
||||
return (
|
||||
<Button size="lg" className="w-full sm:w-auto" disabled={disabled} onClick={onCheckout}>
|
||||
<Button
|
||||
size="lg"
|
||||
className={cn('w-full sm:w-auto', className)}
|
||||
disabled={disabled}
|
||||
onClick={onCheckout}
|
||||
>
|
||||
{isProcessing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('checkout.payment_step.pay_with_paddle')}
|
||||
</Button>
|
||||
@@ -130,6 +138,7 @@ export const PaymentStep: React.FC = () => {
|
||||
const [couponNotice, setCouponNotice] = useState<string | null>(null);
|
||||
const [couponLoading, setCouponLoading] = useState(false);
|
||||
const paddleRef = useRef<typeof window.Paddle | null>(null);
|
||||
const checkoutContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const eventCallbackRef = useRef<(event: any) => void>();
|
||||
const hasAutoAppliedCoupon = useRef(false);
|
||||
const checkoutContainerClass = 'paddle-checkout-container';
|
||||
@@ -297,6 +306,17 @@ export const PaymentStep: React.FC = () => {
|
||||
setInlineActive(true);
|
||||
setStatus('ready');
|
||||
setMessage(t('checkout.payment_step.paddle_overlay_ready'));
|
||||
if (typeof window !== 'undefined' && checkoutContainerRef.current) {
|
||||
window.requestAnimationFrame(() => {
|
||||
const rect = checkoutContainerRef.current?.getBoundingClientRect();
|
||||
if (!rect) {
|
||||
return;
|
||||
}
|
||||
const offset = 120;
|
||||
const target = Math.max(window.scrollY + rect.top - offset, 0);
|
||||
window.scrollTo({ top: target, behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -380,6 +400,7 @@ export const PaymentStep: React.FC = () => {
|
||||
setMessage(t('checkout.payment_step.paddle_overlay_ready'));
|
||||
setInlineActive(false);
|
||||
setPaymentCompleted(true);
|
||||
toast.success(t('checkout.payment_step.toast_success'));
|
||||
}
|
||||
|
||||
if (event.name === 'checkout.closed') {
|
||||
@@ -494,10 +515,60 @@ export const PaymentStep: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const TrustPill = ({ icon: Icon, label }: { icon: React.ElementType; label: string }) => (
|
||||
<div className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-medium text-white backdrop-blur-sm">
|
||||
<Icon className="h-4 w-4 text-white/80" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PaddleLogo = () => (
|
||||
<div className="flex items-center gap-3 rounded-full bg-white/15 px-4 py-2 text-white shadow-inner backdrop-blur-sm">
|
||||
<img
|
||||
src="/paddle.logo.svg"
|
||||
alt="Paddle"
|
||||
className="h-6 w-auto brightness-0 invert"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<span className="text-xs font-semibold">{t('checkout.payment_step.paddle_partner')}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border bg-card p-6 shadow-sm">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border bg-card p-6 shadow-sm">
|
||||
<div className="space-y-6">
|
||||
{!inlineActive && (
|
||||
<div className="overflow-hidden rounded-2xl border bg-gradient-to-br from-[#001835] via-[#002b55] to-[#00407c] p-6 text-white shadow-md">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-4">
|
||||
<PaddleLogo />
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-2xl font-semibold">{t('checkout.payment_step.guided_title')}</h3>
|
||||
<p className="text-sm text-white/80">{t('checkout.payment_step.guided_body')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<TrustPill icon={ShieldCheck} label={t('checkout.payment_step.trust_secure')} />
|
||||
<TrustPill icon={Receipt} label={t('checkout.payment_step.trust_tax')} />
|
||||
<TrustPill icon={Headphones} label={t('checkout.payment_step.trust_support')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-stretch gap-3 w-full max-w-sm">
|
||||
<PaddleCta
|
||||
onCheckout={startPaddleCheckout}
|
||||
disabled={status === 'processing'}
|
||||
isProcessing={status === 'processing'}
|
||||
className={cn('bg-white text-[#001835] hover:bg-white/90', PRIMARY_CTA_STYLES)}
|
||||
/>
|
||||
<p className="text-xs text-white/70 text-center">
|
||||
{t('checkout.payment_step.guided_cta_hint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<form className="flex flex-col gap-2 sm:flex-row" onSubmit={handleCouponSubmit}>
|
||||
<Input
|
||||
@@ -564,6 +635,7 @@ export const PaymentStep: React.FC = () => {
|
||||
onCheckout={startPaddleCheckout}
|
||||
disabled={status === 'processing'}
|
||||
isProcessing={status === 'processing'}
|
||||
className={PRIMARY_CTA_STYLES}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -586,7 +658,7 @@ export const PaymentStep: React.FC = () => {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className={`${checkoutContainerClass} min-h-[360px]`} />
|
||||
<div ref={checkoutContainerRef} className={`${checkoutContainerClass} min-h-[360px]`} />
|
||||
|
||||
<p className={`text-xs text-muted-foreground ${inlineActive ? 'text-center' : 'sm:text-right'}`}>
|
||||
{t('checkout.payment_step.paddle_disclaimer')}
|
||||
|
||||
Reference in New Issue
Block a user