codex has reworked checkout, but frontend doesnt work

This commit is contained in:
Codex Agent
2025-10-05 20:39:30 +02:00
parent fdaa2bec62
commit d70faf7a9d
35 changed files with 2105 additions and 430 deletions

View File

@@ -0,0 +1,64 @@
import { useMemo } from 'react';
import { usePage } from '@inertiajs/react';
declare const route: ((name: string, params?: Record<string, unknown>, absolute?: boolean) => string) | undefined;
type PageProps = {
locale?: string;
url?: string;
};
export const useLocalizedRoutes = () => {
const page = usePage<PageProps>();
const currentUrl = page.url ?? (typeof window !== 'undefined' ? window.location.pathname : '/') ?? '/';
return useMemo(() => {
let locale = page.props.locale;
if (!locale) {
if (currentUrl.startsWith('/en')) {
locale = 'en';
} else if (currentUrl.startsWith('/de')) {
locale = 'de';
}
}
if (!locale) {
locale = 'de';
}
const localePrefix = locale ? `/${locale}` : '';
const localizedPath = (path = '/') => {
if (!path || path === '/') {
return localePrefix || '/';
}
const normalized = path.startsWith('/') ? path : `/${path}`;
const result = `${localePrefix}${normalized}`;
return result.replace(/\/+$/, '').replace(/\/+/g, '/');
};
const localizedRoute = (name: string, params: Record<string, unknown> = {}, absolute = false) => {
if (typeof route === 'function') {
const payload = locale ? { locale, ...params } : params;
try {
return route(name, payload, absolute);
} catch (error) {
console.warn('Failed to resolve route', name, error);
}
}
return localizedPath(name.startsWith('/') ? name : `/${name}`);
};
return {
locale,
localePrefix,
localizedPath,
localizedRoute,
};
}, [page.props.locale, currentUrl]);
};

View File

@@ -1,5 +1,6 @@
import React, { useEffect } from 'react';
import React, { useEffect } from 'react';
import { Head, Link, usePage, router } from '@inertiajs/react';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { useTranslation } from 'react-i18next';
interface MarketingLayoutProps {
@@ -8,38 +9,51 @@ interface MarketingLayoutProps {
}
const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) => {
const page = usePage<{ translations?: Record<string, Record<string, string>> }>();
const { url } = page;
const { t } = useTranslation('marketing');
const { url } = usePage();
const i18n = useTranslation();
const { locale, localizedPath } = useLocalizedRoutes();
useEffect(() => {
const locale = url.startsWith('/en/') ? 'en' : 'de';
if (i18n.i18n.language !== locale) {
i18n.i18n.changeLanguage(locale);
}
}, [url, i18n]);
const localeCandidate = locale || (url.startsWith('/en/') ? 'en' : 'de');
const { translations } = usePage().props as any;
const marketing = translations?.marketing || {};
if (localeCandidate && i18n.i18n.language !== localeCandidate) {
i18n.i18n.changeLanguage(localeCandidate);
}
}, [url, i18n, locale]);
const marketing = page.props.translations?.marketing ?? {};
const getString = (key: string, fallback: string) => {
const value = marketing[key];
return typeof value === 'string' ? value : fallback;
};
const currentLocale = url.startsWith('/en/') ? 'en' : 'de';
const alternateLocale = currentLocale === 'de' ? 'en' : 'de';
const path = url.replace(/^\/(de|en)?/, '');
const canonicalUrl = `https://fotospiel.app/${currentLocale}${path}`;
const alternateUrl = `https://fotospiel.app/${alternateLocale}${path}`;
const activeLocale = locale || (url.startsWith('/en/') ? 'en' : 'de');
const alternateLocale = activeLocale === 'de' ? 'en' : 'de';
const path = url.replace(/^\/(de|en)/, '');
const canonicalUrl = `https://fotospiel.app${localizedPath(path || '/')}`;
const handleLocaleChange = (nextLocale: string) => {
const normalizedPath = url.replace(/^\/(de|en)/, '') || '/';
const destination = normalizedPath === '/' ? `/${nextLocale}` : `/${nextLocale}${normalizedPath}`;
router.visit(destination);
};
return (
<>
<Head>
<title>{title || t('meta.title', getString('title', 'Fotospiel'))}</title>
<meta name="description" content={t('meta.description', getString('description', 'Sammle Gastfotos für Events mit QR-Codes'))} />
<meta
name="description"
content={t('meta.description', getString('description', 'Sammle Gastfotos für Events mit QR-Codes'))}
/>
<meta property="og:title" content={title || t('meta.title', getString('title', 'Fotospiel'))} />
<meta property="og:description" content={t('meta.description', getString('description', 'Sammle Gastfotos für Events mit QR-Codes'))} />
<meta
property="og:description"
content={t('meta.description', getString('description', 'Sammle Gastfotos für Events mit QR-Codes'))}
/>
<meta property="og:url" content={canonicalUrl} />
<link rel="canonical" href={canonicalUrl} />
<link rel="alternate" hrefLang="de" href={`https://fotospiel.app/de${path}`} />
@@ -50,31 +64,27 @@ const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) =>
<header className="bg-white shadow-sm">
<div className="container mx-auto px-4 py-4">
<nav className="flex justify-between items-center">
<Link href="/" className="text-xl font-bold text-gray-900">
<Link href={localizedPath('/')} className="text-xl font-bold text-gray-900">
Fotospiel
</Link>
<div className="hidden md:flex space-x-8">
<Link href="/" className="text-gray-700 hover:text-gray-900">
<Link href={localizedPath('/')} className="text-gray-700 hover:text-gray-900">
{t('nav.home')}
</Link>
<Link href="/packages" className="text-gray-700 hover:text-gray-900">
<Link href={localizedPath('/packages')} className="text-gray-700 hover:text-gray-900">
{t('nav.packages')}
</Link>
<Link href="/blog" className="text-gray-700 hover:text-gray-900">
<Link href={localizedPath('/blog')} className="text-gray-700 hover:text-gray-900">
{t('nav.blog')}
</Link>
<Link href="/kontakt" className="text-gray-700 hover:text-gray-900">
<Link href={localizedPath('/kontakt')} className="text-gray-700 hover:text-gray-900">
{t('nav.contact')}
</Link>
</div>
<div className="flex items-center space-x-4">
<select
value={currentLocale}
onChange={(e) => {
const newLocale = e.target.value;
const newPath = url.replace(/^\/(de|en)?/, `/${newLocale}`);
router.visit(newPath);
}}
value={activeLocale}
onChange={(event) => handleLocaleChange(event.target.value)}
className="border border-gray-300 rounded-md px-2 py-1"
>
<option value="de">DE</option>
@@ -91,13 +101,13 @@ const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) =>
<div className="container mx-auto px-4 text-center">
<p>&copy; 2025 Fotospiel. Alle Rechte vorbehalten.</p>
<div className="mt-4 space-x-4">
<Link href="/datenschutz" className="hover:underline">
<Link href={localizedPath('/datenschutz')} className="hover:underline">
{t('nav.privacy')}
</Link>
<Link href="/impressum" className="hover:underline">
<Link href={localizedPath('/impressum')} className="hover:underline">
{t('nav.impressum')}
</Link>
<Link href="/kontakt" className="hover:underline">
<Link href={localizedPath('/kontakt')} className="hover:underline">
{t('nav.contact')}
</Link>
</div>
@@ -108,4 +118,4 @@ const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) =>
);
};
export default MarketingLayout;
export default MarketingLayout;

View File

@@ -1,98 +1,181 @@
import React, { useEffect, useState } from 'react';
import { useForm, usePage } from '@inertiajs/react';
import { useTranslation } from 'react-i18next';
import toast from 'react-hot-toast';
import { LoaderCircle, Mail, Lock } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import InputError from '@/components/input-error';
import TextLink from '@/components/text-link';
import React, { useEffect, useMemo, useState } from "react";
import { usePage } from "@inertiajs/react";
import { useTranslation } from "react-i18next";
import toast from "react-hot-toast";
import { LoaderCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import InputError from "@/components/input-error";
import TextLink from "@/components/text-link";
interface LoginFormProps {
onSuccess?: (userData: any) => void;
canResetPassword?: boolean;
declare const route: (name: string, params?: Record<string, unknown>) => string;
export interface AuthUserPayload {
id?: number;
email?: string;
name?: string | null;
pending_purchase?: boolean;
}
export default function LoginForm({ onSuccess, canResetPassword = true }: LoginFormProps) {
const [hasTriedSubmit, setHasTriedSubmit] = useState(false);
const { t } = useTranslation('auth');
const { props } = usePage<{ errors: Record<string, string> }>();
interface LoginFormProps {
onSuccess?: (userData: AuthUserPayload | null) => void;
canResetPassword?: boolean;
locale?: string;
}
const { data, setData, post, processing, errors, clearErrors, reset } = useForm({
email: '',
password: '',
type SharedPageProps = {
locale?: string;
};
type FieldErrors = Record<string, string>;
const fallbackRoute = (locale: string) => `/${locale}/login`;
const csrfToken = () => (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content ?? "";
export default function LoginForm({ onSuccess, canResetPassword = true, locale }: LoginFormProps) {
const page = usePage<SharedPageProps>();
const { t } = useTranslation("auth");
const resolvedLocale = locale ?? page.props.locale ?? "de";
const loginEndpoint = useMemo(() => {
if (typeof route === "function") {
try {
return route("checkout.login");
} catch (error) {
// Ziggy might not be booted yet; fall back to locale-aware path.
}
}
return fallbackRoute(resolvedLocale);
}, [resolvedLocale]);
const [values, setValues] = useState({
email: "",
password: "",
remember: false,
});
useEffect(() => {
if (hasTriedSubmit && Object.keys(errors).length > 0) {
toast.error(Object.values(errors).join(' '));
}
}, [errors, hasTriedSubmit]);
const submit = (e: React.FormEvent) => {
e.preventDefault();
setHasTriedSubmit(true);
post('/login', {
preserveScroll: true,
onSuccess: () => {
if (onSuccess) {
onSuccess({ user: { email: data.email } }); // Pass basic user info; full user from props in parent
}
reset();
},
});
};
const [errors, setErrors] = useState<FieldErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [hasTriedSubmit, setHasTriedSubmit] = useState(false);
useEffect(() => {
if (!hasTriedSubmit) {
return;
}
const errorKeys = Object.keys(errors);
if (errorKeys.length === 0) {
return;
}
const field = document.querySelector<HTMLInputElement>(`[name="${errorKeys[0]}"]`);
if (field) {
field.scrollIntoView({ behavior: 'smooth', block: 'center' });
field.focus();
const collected = Object.values(errors).filter(Boolean);
if (collected.length > 0) {
toast.error(collected.join(" \u2022 "));
}
}, [errors, hasTriedSubmit]);
useEffect(() => {
if (!hasTriedSubmit) {
return;
}
const firstKey = Object.keys(errors).find((key) => Boolean(errors[key]));
if (!firstKey) {
return;
}
const field = document.querySelector<HTMLInputElement>(`[name="${firstKey}"]`);
field?.scrollIntoView({ behavior: "smooth", block: "center" });
field?.focus();
}, [errors, hasTriedSubmit]);
const updateValue = (key: keyof typeof values, value: string | boolean) => {
setValues((current) => ({ ...current, [key]: value }));
setErrors((current) => ({ ...current, [key as string]: "" }));
};
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setHasTriedSubmit(true);
setErrors({});
setIsSubmitting(true);
try {
const response = await fetch(loginEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"X-CSRF-TOKEN": csrfToken(),
},
credentials: "same-origin",
body: JSON.stringify({
email: values.email,
password: values.password,
remember: values.remember,
locale: resolvedLocale,
}),
});
if (response.ok) {
const payload = await response.json();
const nextUser: AuthUserPayload | null = payload?.user ?? payload?.data ?? null;
toast.success(t("login.success_toast", "Login erfolgreich"));
onSuccess?.(nextUser);
setValues((current) => ({ ...current, password: "" }));
setHasTriedSubmit(false);
return;
}
if (response.status === 422) {
const payload = await response.json();
const fieldErrors: FieldErrors = {};
const source = payload?.errors ?? {};
Object.entries(source).forEach(([key, message]) => {
if (Array.isArray(message) && message.length > 0) {
fieldErrors[key] = String(message[0]);
} else if (typeof message === "string") {
fieldErrors[key] = message;
}
});
setErrors(fieldErrors);
toast.error(t("login.failed_generic", "Ungueltige Anmeldedaten"));
return;
}
toast.error(t("login.unexpected_error", "Beim Login ist ein Fehler aufgetreten."));
} catch (error) {
console.error("Login request failed", error);
toast.error(t("login.unexpected_error", "Beim Login ist ein Fehler aufgetreten."));
} finally {
setIsSubmitting(false);
}
};
return (
<div className="flex flex-col gap-6">
<form onSubmit={submit} className="flex flex-col gap-6" noValidate>
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="email">{t('login.email')}</Label>
<Label htmlFor="email">{t("login.email")}</Label>
<Input
id="email"
type="email"
name="email"
required
autoFocus
placeholder={t('login.email_placeholder')}
value={data.email}
onChange={(e) => {
setData('email', e.target.value);
if (errors.email) {
clearErrors('email');
}
}}
placeholder={t("login.email_placeholder")}
value={values.email}
onChange={(event) => updateValue("email", event.target.value)}
/>
<InputError message={errors.email} />
</div>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">{t('login.password')}</Label>
<Label htmlFor="password">{t("login.password")}</Label>
{canResetPassword && (
<TextLink href="/forgot-password" className="ml-auto text-sm">
{t('login.forgot')}
<TextLink href={typeof route === "function" ? route("password.request") : "/forgot-password"} className="ml-auto text-sm">
{t("login.forgot")}
</TextLink>
)}
</div>
@@ -101,14 +184,9 @@ export default function LoginForm({ onSuccess, canResetPassword = true }: LoginF
type="password"
name="password"
required
placeholder={t('login.password_placeholder')}
value={data.password}
onChange={(e) => {
setData('password', e.target.value);
if (errors.password) {
clearErrors('password');
}
}}
placeholder={t("login.password_placeholder")}
value={values.password}
onChange={(event) => updateValue("password", event.target.value)}
/>
<InputError message={errors.password} />
</div>
@@ -117,25 +195,24 @@ export default function LoginForm({ onSuccess, canResetPassword = true }: LoginF
<Checkbox
id="remember"
name="remember"
checked={data.remember}
onCheckedChange={(checked) => setData('remember', Boolean(checked))}
checked={values.remember}
onCheckedChange={(checked) => updateValue("remember", Boolean(checked))}
/>
<Label htmlFor="remember">{t('login.remember')}</Label>
<Label htmlFor="remember">{t("login.remember")}</Label>
</div>
<Button type="button" onClick={submit} className="w-full" disabled={processing}>
{processing && <LoaderCircle className="h-4 w-4 animate-spin mr-2" />}
{t('login.submit')}
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting && <LoaderCircle className="h-4 w-4 animate-spin mr-2" />}
{t("login.submit")}
</Button>
</div>
{Object.keys(errors).length > 0 && (
{Object.values(errors).some(Boolean) && (
<div className="p-4 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-800">
{Object.values(errors).join(' ')}
</p>
<p className="text-sm text-red-800">{Object.values(errors).filter(Boolean).join(" \u2022 ")}</p>
</div>
)}
</div>
</form>
);
}
}

View File

@@ -1,23 +1,33 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { useForm, usePage } from '@inertiajs/react';
import { useTranslation } from 'react-i18next';
import toast from 'react-hot-toast';
import { LoaderCircle, User, Mail, Phone, Lock, MapPin } from 'lucide-react';
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog';
interface RegisterFormProps {
packageId?: number;
onSuccess?: (userData: any) => void;
privacyHtml: string;
declare const route: (name: string, params?: Record<string, unknown>) => string;
export interface RegisterSuccessPayload {
user: any | null;
redirect?: string | null;
pending_purchase?: boolean;
}
export default function RegisterForm({ packageId, onSuccess, privacyHtml }: RegisterFormProps) {
interface RegisterFormProps {
packageId?: number;
onSuccess?: (payload: RegisterSuccessPayload) => void;
privacyHtml: string;
locale?: string;
}
export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale }: RegisterFormProps) {
const [privacyOpen, setPrivacyOpen] = useState(false);
const [hasTriedSubmit, setHasTriedSubmit] = useState(false);
const { t } = useTranslation(['auth', 'common']);
const { props } = usePage<{ errors: Record<string, string> }>();
const page = usePage<{ errors: Record<string, string>; locale?: string; auth?: { user?: any | null } }>();
const resolvedLocale = locale ?? page.props.locale ?? 'de';
const { data, setData, post, processing, errors, clearErrors, reset } = useForm({
const { data, setData, errors, clearErrors, reset, setError } = useForm({
username: '',
email: '',
password: '',
@@ -36,18 +46,76 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml }: Regi
}
}, [errors, hasTriedSubmit]);
const submit = (e: React.FormEvent) => {
e.preventDefault();
const registerEndpoint = useMemo(() => {
if (typeof route === 'function') {
try {
return route('checkout.register');
} catch (error) {
// ignore ziggy errors and fall back
}
}
return `/${resolvedLocale}/register`;
}, [resolvedLocale]);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
setHasTriedSubmit(true);
post('/register', {
preserveScroll: true,
onSuccess: (page) => {
if (onSuccess) {
onSuccess((page as any).props.auth.user);
}
setIsSubmitting(true);
clearErrors();
const body = {
...data,
locale: resolvedLocale,
package_id: data.package_id ?? packageId ?? null,
};
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 ?? '',
},
credentials: 'same-origin',
body: JSON.stringify(body),
});
if (response.ok) {
const json = await response.json();
toast.success(t('register.success_toast', 'Registrierung erfolgreich'));
onSuccess?.({
user: json?.user ?? null,
redirect: json?.redirect ?? null,
pending_purchase: json?.pending_purchase ?? json?.user?.pending_purchase ?? false,
});
reset();
},
});
setHasTriedSubmit(false);
return;
}
if (response.status === 422) {
const json = await response.json();
const fieldErrors = json?.errors ?? {};
Object.entries(fieldErrors).forEach(([key, message]) => {
if (Array.isArray(message) && message.length > 0) {
setError(key, message[0] as string);
} else if (typeof message === 'string') {
setError(key, message);
}
});
toast.error(t('register.validation_failed', 'Bitte pruefen Sie Ihre Eingaben.'));
return;
}
toast.error(t('register.unexpected_error', 'Registrierung nicht moeglich.'));
} catch (error) {
console.error('Register request failed', error);
toast.error(t('register.unexpected_error', 'Registrierung nicht moeglich.'));
} finally {
setIsSubmitting(false);
}
};
useEffect(() => {
@@ -323,10 +391,10 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml }: Regi
<button
type="button"
onClick={submit}
disabled={processing}
disabled={isSubmitting}
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"
>
{processing && <LoaderCircle className="h-4 w-4 animate-spin mr-2" />}
{isSubmitting && <LoaderCircle className="h-4 w-4 animate-spin mr-2" />}
{t('register.submit')}
</button>
@@ -341,4 +409,12 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml }: Regi
</Dialog>
</div>
);
}
}

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Head, Link, usePage } from '@inertiajs/react';
import React from 'react';
import { Head, Link } from '@inertiajs/react';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { useTranslation } from 'react-i18next';
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
@@ -13,7 +14,8 @@ interface Props {
}
const Blog: React.FC<Props> = ({ posts }) => {
const { url } = usePage();
const { localizedPath } = useLocalizedRoutes();
const { t } = useTranslation('marketing');
const renderPagination = () => {
@@ -69,7 +71,7 @@ const Blog: React.FC<Props> = ({ posts }) => {
/>
)}
<h3 className="text-xl font-semibold mb-2 font-sans-marketing text-gray-900 dark:text-gray-100">
<Link href={`/blog/${post.slug}`} className="hover:text-[#FFB6C1]">
<Link href={`${localizedPath(`/blog/${post.slug}`)}`} className="hover:text-[#FFB6C1]">
{post.title}
</Link>
</h3>
@@ -78,7 +80,7 @@ const Blog: React.FC<Props> = ({ posts }) => {
{t('blog.by')} {post.author?.name || t('blog.team')} | {t('blog.published_at')} {post.published_at}
</p>
<Link
href={`/blog/${post.slug}`}
href={`${localizedPath(`/blog/${post.slug}`)}`}
className="text-[#FFB6C1] font-semibold font-sans-marketing hover:underline"
>
{t('blog.read_more')}

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Head, Link, usePage } from '@inertiajs/react';
import React from 'react';
import { Head, Link } from '@inertiajs/react';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { useTranslation } from 'react-i18next';
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
@@ -17,6 +18,7 @@ interface Props {
}
const BlogShow: React.FC<Props> = ({ post }) => {
const { localizedPath } = useLocalizedRoutes();
const { t } = useTranslation('blog_show');
return (
@@ -50,7 +52,7 @@ const BlogShow: React.FC<Props> = ({ post }) => {
<section className="py-10 px-4 bg-gray-50">
<div className="container mx-auto text-center">
<Link
href="/blog"
href={localizedPath('/blog')}
className="bg-[#FFB6C1] text-white px-8 py-3 rounded-full font-semibold hover:bg-[#FF69B4] transition"
>
{t('back_to_blog')}

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { Head, Link, useForm, usePage } from '@inertiajs/react';
import React from 'react';
import { Head, Link, useForm } from '@inertiajs/react';
import { useTranslation } from 'react-i18next';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
interface Package {
@@ -16,6 +17,7 @@ interface Props {
const Home: React.FC<Props> = ({ packages }) => {
const { t } = useTranslation('marketing');
const { localizedPath } = useLocalizedRoutes();
const { data, setData, post, processing, errors, reset } = useForm({
name: '',
email: '',
@@ -24,7 +26,7 @@ const Home: React.FC<Props> = ({ packages }) => {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
post('/kontakt', {
post(localizedPath('/kontakt'), {
onSuccess: () => reset(),
});
};
@@ -46,7 +48,7 @@ const Home: React.FC<Props> = ({ packages }) => {
<h1 className="text-4xl md:text-6xl font-bold mb-4 font-display">{t('home.hero_title')}</h1>
<p className="text-xl md:text-2xl mb-8 font-sans-marketing">{t('home.hero_description')}</p>
<Link
href="/packages"
href={localizedPath('/packages')}
className="bg-white dark:bg-gray-800 text-[#FFB6C1] px-8 py-4 rounded-full font-bold hover:bg-gray-100 dark:hover:bg-gray-700 transition duration-300 inline-block"
>
{t('home.cta_explore')}
@@ -123,14 +125,14 @@ const Home: React.FC<Props> = ({ packages }) => {
<h3 className="text-2xl font-bold mb-2">{pkg.name}</h3>
<p className="text-gray-600 dark:text-gray-300 mb-4">{pkg.description}</p>
<p className="text-3xl font-bold text-[#FFB6C1]">{pkg.price} {t('common.currency.euro')}</p>
<Link href={`/register?package_id=${pkg.id}`} className="mt-4 inline-block bg-[#FFB6C1] text-white px-6 py-2 rounded-full hover:bg-pink-600">
<Link href={`${localizedPath('/register')}?package_id=${pkg.id}`} className="mt-4 inline-block bg-[#FFB6C1] text-white px-6 py-2 rounded-full hover:bg-pink-600">
{t('home.view_details')}
</Link>
</div>
))}
</div>
<div className="text-center">
<Link href="/packages" className="bg-[#FFB6C1] text-white px-8 py-4 rounded-full font-bold hover:bg-pink-600 transition">
<Link href={localizedPath('/packages')} className="bg-[#FFB6C1] text-white px-8 py-4 rounded-full font-bold hover:bg-pink-600 transition">
{t('home.all_packages')}
</Link>
</div>

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React from 'react';
import { Head, Link, useForm, usePage } from '@inertiajs/react';
import { useTranslation } from 'react-i18next';
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
@@ -15,7 +15,7 @@ const Kontakt: React.FC = () => {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
post('/kontakt', {
post(localizedPath('/kontakt'), {
onSuccess: () => reset(),
});
};
@@ -85,7 +85,7 @@ const Kontakt: React.FC = () => {
</div>
)}
<div className="mt-8 text-center">
<Link href="/" className="text-[#FFB6C1] hover:underline font-sans-marketing">{t('kontakt.back_home')}</Link>
<Link href={localizedPath('/')} className="text-[#FFB6C1] hover:underline font-sans-marketing">{t('kontakt.back_home')}</Link>
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Head, usePage } from '@inertiajs/react';
import React from 'react';
import { Head, Link } from '@inertiajs/react';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { useTranslation } from 'react-i18next';
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
@@ -9,6 +10,7 @@ interface OccasionsProps {
const Occasions: React.FC<OccasionsProps> = ({ type }) => {
const { t } = useTranslation('marketing');
const { localizedPath } = useLocalizedRoutes();
const occasionsContent = {
hochzeit: {
@@ -56,9 +58,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
<div className="text-center mb-12">
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 dark:text-gray-100 mb-6 font-display">{content.title}</h1>
<p className="text-xl text-gray-600 dark:text-gray-300 mb-8 font-sans-marketing">{content.description}</p>
<a href="/packages" className="bg-[#FFB6C1] text-white px-8 py-4 rounded-full font-bold hover:bg-pink-600 transition">
<Link href={localizedPath('/packages')} className="bg-[#FFB6C1] text-white px-8 py-4 rounded-full font-bold hover:bg-pink-600 transition">
{content.cta}
</a>
</Link>
</div>
<div className="grid md:grid-cols-3 gap-8">
{content.features.map((feature, index) => (

View File

@@ -1,259 +1,51 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Head, useForm, usePage, router } from '@inertiajs/react';
import { useTranslation } from 'react-i18next';
import { Elements } from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
import { Steps } from '@/components/ui/Steps'; // Correct casing for Shadcn Steps
import { Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Loader2 } from 'lucide-react';
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
import RegisterForm from '../auth/RegisterForm'; // Extract Register form to separate component
import LoginForm from '../auth/LoginForm'; // Extract Login form
import PaymentForm from './PaymentForm'; // New component for Stripe payment
import SuccessStep from './SuccessStep'; // New component for success
import React from "react";
import { Head, usePage } from "@inertiajs/react";
import MarketingLayout from "@/layouts/marketing/MarketingLayout";
import type { CheckoutPackage } from "./checkout/types";
import { CheckoutWizard } from "./checkout/CheckoutWizard";
interface Package {
id: number;
name: string;
description: string;
price: number;
features: string[];
type?: 'endcustomer' | 'reseller';
trial_days?: number;
// Add other fields as needed
}
interface UserData {
id: number;
email: string;
pending_purchase?: boolean;
}
interface PurchaseWizardProps {
package: Package;
interface PurchaseWizardPageProps {
package: CheckoutPackage;
packageOptions: CheckoutPackage[];
stripePublishableKey: string;
privacyHtml: string;
}
const steps = [
{ id: 'package', title: 'Paket auswählen', description: 'Bestätigen Sie Ihr gewähltes Paket' },
{ id: 'auth', title: 'Anmelden oder Registrieren', description: 'Erstellen oder melden Sie sich an' },
{ id: 'payment', title: 'Zahlung', description: 'Sichern Sie Ihr Paket ab' },
{ id: 'success', title: 'Erfolg', description: 'Willkommen!' },
];
export default function PurchaseWizardPage({
package: initialPackage,
packageOptions,
stripePublishableKey,
privacyHtml,
}: PurchaseWizardPageProps) {
const page = usePage<{ auth?: { user?: { id: number; email: string; name?: string } | null } }>();
const currentUser = page.props.auth?.user ?? null;
export default function PurchaseWizard({ package: initialPackage, stripePublishableKey, privacyHtml }: PurchaseWizardProps) {
const STORAGE_KEY = 'fotospiel_wizard_state';
const STORAGE_TTL = 30 * 60 * 1000; // 30 minutes in ms
const [currentStep, setCurrentStep] = useState(0);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [authCompleted, setAuthCompleted] = useState(false);
const [authType, setAuthType] = useState<'register' | 'login'>('register'); // Toggle for auth step
const [wizardData, setWizardData] = useState<{ package: Package; user: UserData | null }>({ package: initialPackage, user: null });
const { t } = useTranslation(['marketing', 'auth']);
const { props } = usePage();
const { auth } = props as any;
// Load state from sessionStorage on mount
useEffect(() => {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
const { currentStep: savedCurrentStep, wizardData: savedWizardData, isAuthenticated: savedIsAuthenticated, authType: savedAuthType, timestamp } = parsed;
if (Date.now() - timestamp < STORAGE_TTL && savedWizardData?.package?.id === initialPackage.id) {
setCurrentStep(savedCurrentStep || 0);
setWizardData(savedWizardData || { package: initialPackage, user: null });
setIsAuthenticated(savedIsAuthenticated || false);
setAuthType(savedAuthType || 'register');
// If in payment step and pending purchase, reload client_secret if needed
if ((savedCurrentStep || 0) === 2 && savedWizardData?.user?.pending_purchase) {
// Optional: router.reload() or API call to refresh payment intent
}
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
} catch (e) {
console.error('Failed to load wizard state:', e);
sessionStorage.removeItem(STORAGE_KEY);
const dedupedOptions = React.useMemo(() => {
const ids = new Set<number>();
const list = [initialPackage, ...packageOptions];
return list.filter((pkg) => {
if (ids.has(pkg.id)) {
return false;
}
}
}, [initialPackage.id]);
// Save state to sessionStorage on changes
const saveState = useCallback(() => {
const state = {
currentStep,
wizardData: {
package: wizardData.package,
user: wizardData.user ? { id: wizardData.user.id, email: wizardData.user.email, pending_purchase: wizardData.user.pending_purchase } : null,
},
isAuthenticated,
authType,
timestamp: Date.now(),
};
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}, [currentStep, wizardData, isAuthenticated, authType]);
useEffect(() => {
saveState();
}, [saveState]);
// Cleanup on unmount or success
useEffect(() => {
return () => {
if (currentStep === 3) {
sessionStorage.removeItem(STORAGE_KEY);
}
};
}, [currentStep]);
useEffect(() => {
if (auth.user) {
setIsAuthenticated(true);
// Do not skip step, handle in render
// Update wizardData with auth.user if pending_purchase
if (auth.user.pending_purchase) {
setWizardData(prev => ({ ...prev, user: auth.user }));
}
}
}, [auth]);
const stripePromise = loadStripe(stripePublishableKey);
const nextStep = () => {
if (currentStep < steps.length - 1) {
setCurrentStep((prev) => prev + 1);
}
};
const prevStep = () => {
if (currentStep > 0) {
setCurrentStep((prev) => prev - 1);
}
};
const handleAuthSuccess = (userData: any) => {
setWizardData((prev) => ({ ...prev, user: userData }));
setIsAuthenticated(true);
setAuthCompleted(true);
// Show success message briefly then proceed
setTimeout(() => {
nextStep();
setAuthCompleted(false);
}, 2000);
};
const handlePaymentSuccess = () => {
// Call API to assign package
router.post('/api/purchase/complete', { package_id: initialPackage.id }, {
onSuccess: () => nextStep(),
ids.add(pkg.id);
return true;
});
};
const renderStepContent = () => {
switch (steps[currentStep].id) {
case 'package':
return (
<Card>
<CardHeader>
<CardTitle>{initialPackage.name}</CardTitle>
<CardDescription>{initialPackage.description}</CardDescription>
</CardHeader>
<CardContent>
<p>Preis: {initialPackage.price === 0 ? 'Kostenlos' : `${initialPackage.price}`}</p>
<ul>
{initialPackage.features.map((feature, index) => (
<li key={index}>{feature}</li>
))}
</ul>
<Button onClick={nextStep} className="w-full mt-4">Weiter</Button>
</CardContent>
</Card>
);
case 'auth':
if (isAuthenticated) {
if (authCompleted) {
return (
<div className="text-center py-8">
<h2 className="text-2xl font-bold mb-4">{t('auth.login_success')}</h2>
<p className="text-gray-600 mb-6">Sie sind nun eingeloggt und werden weitergeleitet...</p>
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
</div>
);
} else {
return (
<div className="text-center py-8">
<h2 className="text-2xl font-bold mb-4">{t('auth.already_logged_in')}</h2>
<Button onClick={nextStep} className="w-full">
Weiter zum Zahlungsschritt
</Button>
</div>
);
}
}
return (
<div>
<div className="flex justify-center mb-4">
<Button
variant={authType === 'register' ? 'default' : 'outline'}
onClick={() => setAuthType('register')}
>
Registrieren
</Button>
<Button
variant={authType === 'login' ? 'default' : 'outline'}
onClick={() => setAuthType('login')}
className="ml-2"
>
Anmelden
</Button>
</div>
{authType === 'register' ? (
<RegisterForm onSuccess={handleAuthSuccess} packageId={initialPackage.id} privacyHtml={privacyHtml} />
) : (
<LoginForm onSuccess={handleAuthSuccess} />
)}
</div>
);
case 'payment':
if (initialPackage.price === 0) {
// Skip for free, assign directly
router.post('/api/purchase/free', { package_id: initialPackage.id });
return <div>Free package assigned! Redirecting...</div>;
}
return (
<Elements stripe={stripePromise}>
<PaymentForm packageId={initialPackage.id} packagePrice={initialPackage.price} onSuccess={handlePaymentSuccess} />
</Elements>
);
case 'success':
return <SuccessStep package={initialPackage} />;
default:
return null;
}
};
}, [initialPackage, packageOptions]);
return (
<MarketingLayout title="Kauf-Wizard">
<Head title="Kauf-Wizard" />
<div className="min-h-screen bg-gray-50 py-12">
<div className="max-w-2xl mx-auto px-4">
<Progress value={(currentStep / (steps.length - 1)) * 100} className="mb-6" />
<Steps steps={steps} currentStep={currentStep} />
{renderStepContent()}
{currentStep > 0 && currentStep < 3 && (
<div className="flex justify-between mt-6">
<Button variant="outline" onClick={prevStep}>Zurück</Button>
{currentStep < 3 && <Button onClick={nextStep}>Weiter</Button>}
</div>
)}
<MarketingLayout title="Checkout Wizard">
<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">
<CheckoutWizard
initialPackage={initialPackage}
packageOptions={dedupedOptions}
stripePublishableKey={stripePublishableKey}
privacyHtml={privacyHtml}
initialAuthUser={currentUser ? { id: currentUser.id, email: currentUser.email ?? '', name: currentUser.name ?? undefined } : null}
/>
</div>
</div>
</MarketingLayout>
);
}
}

View File

@@ -1,17 +1,19 @@
import React from 'react';
import React from 'react';
import { usePage, router } from '@inertiajs/react';
import { Head } from '@inertiajs/react';
import { useTranslation } from 'react-i18next';
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
import { Loader } from 'lucide-react';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
const Success: React.FC = () => {
const { auth, flash } = usePage().props as any;
const { auth } = usePage().props as any;
const { t } = useTranslation('success');
const { localizedPath } = useLocalizedRoutes();
if (auth.user && auth.user.email_verified_at) {
// Redirect to admin
router.visit('/admin', { preserveState: false });
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50">
<div className="text-center">
@@ -28,12 +30,8 @@ const Success: React.FC = () => {
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
<div className="text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
{t('verify_email')}
</h2>
<p className="text-gray-600 mb-6">
{t('check_email')}
</p>
<h2 className="text-2xl font-bold text-gray-900 mb-4">{t('verify_email')}</h2>
<p className="text-gray-600 mb-6">{t('check_email')}</p>
<form method="POST" action="/email/verification-notification">
<button
type="submit"
@@ -43,7 +41,10 @@ const Success: React.FC = () => {
</button>
</form>
<p className="mt-4 text-sm text-gray-600">
{t('already_registered')} <a href="/login" className="text-blue-600 hover:text-blue-500">{t('login')}</a>
{t('already_registered')}{' '}
<a href={localizedPath('/login')} className="text-blue-600 hover:text-blue-500">
{t('login')}
</a>
</p>
</div>
</div>
@@ -57,20 +58,19 @@ const Success: React.FC = () => {
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
<div className="text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
{t('complete_purchase')}
</h2>
<p className="text-gray-600 mb-6">
{t('login_to_continue')}
</p>
<h2 className="text-2xl font-bold text-gray-900 mb-4">{t('complete_purchase')}</h2>
<p className="text-gray-600 mb-6">{t('login_to_continue')}</p>
<a
href="/login"
href={localizedPath('/login')}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md font-medium transition duration-300 block mb-2"
>
{t('login')}
</a>
<p className="text-sm text-gray-600">
{t('no_account')} <a href="/register" className="text-blue-600 hover:text-blue-500">{t('register')}</a>
{t('no_account')}{' '}
<a href={localizedPath('/register')} className="text-blue-600 hover:text-blue-500">
{t('register')}
</a>
</p>
</div>
</div>

View File

@@ -0,0 +1,89 @@
import React, { useMemo } from "react";
import { Steps } from "@/components/ui/Steps";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { CheckoutWizardProvider, useCheckoutWizard } from "./WizardContext";
import type { CheckoutPackage, CheckoutStepId } from "./types";
import { PackageStep } from "./steps/PackageStep";
import { AuthStep } from "./steps/AuthStep";
import { PaymentStep } from "./steps/PaymentStep";
import { ConfirmationStep } from "./steps/ConfirmationStep";
interface CheckoutWizardProps {
initialPackage: CheckoutPackage;
packageOptions: CheckoutPackage[];
stripePublishableKey: string;
privacyHtml: string;
initialAuthUser?: {
id: number;
email: string;
name?: string;
pending_purchase?: boolean;
} | null;
initialStep?: CheckoutStepId;
}
const stepConfig: { id: CheckoutStepId; title: string; description: string }[] = [
{ id: "package", title: "Paket", description: "Auswahl und Vergleich" },
{ id: "auth", title: "Konto", description: "Login oder Registrierung" },
{ id: "payment", title: "Zahlung", description: "Stripe oder PayPal" },
{ id: "confirmation", title: "Fertig", description: "Zugang aktiv" },
];
const WizardBody: React.FC<{ stripePublishableKey: string; privacyHtml: string }> = ({ stripePublishableKey, privacyHtml }) => {
const { currentStep, nextStep, previousStep } = useCheckoutWizard();
const currentIndex = useMemo(() => stepConfig.findIndex((step) => step.id === currentStep), [currentStep]);
const progress = useMemo(() => {
if (currentIndex < 0) {
return 0;
}
return (currentIndex / (stepConfig.length - 1)) * 100;
}, [currentIndex]);
return (
<div className="space-y-8">
<div className="space-y-4">
<Progress value={progress} />
<Steps steps={stepConfig} currentStep={currentIndex >= 0 ? currentIndex : 0} />
</div>
<div className="space-y-6">
{currentStep === "package" && <PackageStep />}
{currentStep === "auth" && <AuthStep privacyHtml={privacyHtml} />}
{currentStep === "payment" && <PaymentStep stripePublishableKey={stripePublishableKey} />}
{currentStep === "confirmation" && <ConfirmationStep />}
</div>
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={previousStep} disabled={currentIndex <= 0}>
Zurueck
</Button>
<Button onClick={nextStep} disabled={currentIndex >= stepConfig.length - 1}>
Weiter
</Button>
</div>
</div>
);
};
export const CheckoutWizard: React.FC<CheckoutWizardProps> = ({
initialPackage,
packageOptions,
stripePublishableKey,
privacyHtml,
initialAuthUser,
initialStep,
}) => {
return (
<CheckoutWizardProvider
initialPackage={initialPackage}
packageOptions={packageOptions}
initialStep={initialStep}
initialAuthUser={initialAuthUser ?? undefined}
initialIsAuthenticated={Boolean(initialAuthUser)}
>
<WizardBody stripePublishableKey={stripePublishableKey} privacyHtml={privacyHtml} />
</CheckoutWizardProvider>
);
};

View File

@@ -0,0 +1,110 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from "react";
import type { CheckoutPackage, CheckoutStepId, CheckoutWizardContextValue, CheckoutWizardState } from "./types";
interface CheckoutWizardProviderProps {
initialPackage: CheckoutPackage;
packageOptions: CheckoutPackage[];
initialStep?: CheckoutStepId;
initialAuthUser?: CheckoutWizardState['authUser'];
initialIsAuthenticated?: boolean;
children: React.ReactNode;
}
const CheckoutWizardContext = createContext<CheckoutWizardContextValue | undefined>(undefined);
export const CheckoutWizardProvider: React.FC<CheckoutWizardProviderProps> = ({
initialPackage,
packageOptions,
initialStep = 'package',
initialAuthUser = null,
initialIsAuthenticated,
children,
}) => {
const [state, setState] = useState<CheckoutWizardState>(() => ({
currentStep: initialStep,
selectedPackage: initialPackage,
packageOptions,
isAuthenticated: Boolean(initialIsAuthenticated || initialAuthUser),
authUser: initialAuthUser ?? null,
paymentProvider: undefined,
isProcessing: false,
}));
const setStep = useCallback((step: CheckoutStepId) => {
setState((prev) => ({ ...prev, currentStep: step }));
}, []);
const nextStep = useCallback(() => {
setState((prev) => {
const order: CheckoutStepId[] = ['package', 'auth', 'payment', 'confirmation'];
const currentIndex = order.indexOf(prev.currentStep);
const nextIndex = currentIndex === -1 ? 0 : Math.min(order.length - 1, currentIndex + 1);
return { ...prev, currentStep: order[nextIndex] };
});
}, []);
const previousStep = useCallback(() => {
setState((prev) => {
const order: CheckoutStepId[] = ['package', 'auth', 'payment', 'confirmation'];
const currentIndex = order.indexOf(prev.currentStep);
const nextIndex = currentIndex <= 0 ? 0 : currentIndex - 1;
return { ...prev, currentStep: order[nextIndex] };
});
}, []);
const setSelectedPackage = useCallback((pkg: CheckoutPackage) => {
setState((prev) => ({
...prev,
selectedPackage: pkg,
paymentProvider: undefined,
}));
}, []);
const markAuthenticated = useCallback<CheckoutWizardContextValue['markAuthenticated']>((user) => {
setState((prev) => ({
...prev,
isAuthenticated: Boolean(user),
authUser: user ?? null,
}));
}, []);
const setPaymentProvider = useCallback<CheckoutWizardContextValue['setPaymentProvider']>((provider) => {
setState((prev) => ({
...prev,
paymentProvider: provider,
}));
}, []);
const resetPaymentState = useCallback(() => {
setState((prev) => ({
...prev,
paymentProvider: undefined,
isProcessing: false,
}));
}, []);
const value = useMemo<CheckoutWizardContextValue>(() => ({
...state,
setStep,
nextStep,
previousStep,
setSelectedPackage,
markAuthenticated,
setPaymentProvider,
resetPaymentState,
}), [state, setStep, nextStep, previousStep, setSelectedPackage, markAuthenticated, setPaymentProvider, resetPaymentState]);
return (
<CheckoutWizardContext.Provider value={value}>
{children}
</CheckoutWizardContext.Provider>
);
};
export const useCheckoutWizard = () => {
const context = useContext(CheckoutWizardContext);
if (!context) {
throw new Error('useCheckoutWizard must be used within CheckoutWizardProvider');
}
return context;
};

View File

@@ -0,0 +1,102 @@
import React, { useState } from "react";
import { usePage } from "@inertiajs/react";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useCheckoutWizard } from "../WizardContext";
import LoginForm, { AuthUserPayload } from "../../auth/LoginForm";
import RegisterForm, { RegisterSuccessPayload } from "../../auth/RegisterForm";
interface AuthStepProps {
privacyHtml: string;
}
export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml }) => {
const page = usePage<{ locale?: string }>();
const locale = page.props.locale ?? "de";
const { isAuthenticated, authUser, markAuthenticated, nextStep, selectedPackage } = useCheckoutWizard();
const [mode, setMode] = useState<'login' | 'register'>('register');
const handleLoginSuccess = (payload: AuthUserPayload | null) => {
if (!payload) {
return;
}
markAuthenticated({
id: payload.id ?? 0,
email: payload.email ?? "",
name: payload.name ?? undefined,
pending_purchase: Boolean(payload.pending_purchase),
});
nextStep();
};
const handleRegisterSuccess = (result: RegisterSuccessPayload) => {
const nextUser = result?.user ?? null;
if (nextUser) {
markAuthenticated({
id: nextUser.id ?? 0,
email: nextUser.email ?? "",
name: nextUser.name ?? undefined,
pending_purchase: Boolean(result?.pending_purchase ?? nextUser.pending_purchase),
});
}
nextStep();
};
if (isAuthenticated && authUser) {
return (
<div className="space-y-6">
<Alert>
<AlertTitle>Bereits eingeloggt</AlertTitle>
<AlertDescription>
{authUser.email ? `Sie sind als ${authUser.email} angemeldet.` : "Sie sind bereits angemeldet."}
</AlertDescription>
</Alert>
<div className="flex justify-end">
<Button size="lg" onClick={nextStep}>
Weiter zur Zahlung
</Button>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-2">
<Button
variant={mode === 'register' ? 'default' : 'outline'}
onClick={() => setMode('register')}
>
Registrieren
</Button>
<Button
variant={mode === 'login' ? 'default' : 'outline'}
onClick={() => setMode('login')}
>
Anmelden
</Button>
<span className="text-xs text-muted-foreground">
Google Login folgt im Komfort-Delta.
</span>
</div>
<div className="rounded-lg border bg-card p-6 shadow-sm">
{mode === 'register' ? (
<RegisterForm
packageId={selectedPackage.id}
privacyHtml={privacyHtml}
locale={locale}
onSuccess={handleRegisterSuccess}
/>
) : (
<LoginForm
locale={locale}
onSuccess={handleLoginSuccess}
/>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,29 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useCheckoutWizard } from "../WizardContext";
interface ConfirmationStepProps {
onViewProfile?: () => void;
}
export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfile }) => {
const { selectedPackage } = useCheckoutWizard();
return (
<div className="space-y-6">
<Alert>
<AlertTitle>Willkommen bei FotoSpiel</AlertTitle>
<AlertDescription>
{Ihr Paket "" ist aktiviert. Wir haben Ihnen eine Bestaetigung per E-Mail gesendet.}
</AlertDescription>
</Alert>
<div className="flex flex-wrap gap-3 justify-end">
<Button variant="outline" onClick={onViewProfile}>
Profil oeffnen
</Button>
<Button>Zum Admin-Bereich</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,118 @@
import React, { useMemo } from "react";
import { Check, Package as PackageIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useCheckoutWizard } from "../WizardContext";
import type { CheckoutPackage } from "../types";
const currencyFormatter = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
});
function PackageSummary({ pkg }: { pkg: CheckoutPackage }) {
return (
<Card className="shadow-sm">
<CardHeader className="space-y-1">
<CardTitle className="flex items-center gap-3 text-2xl">
<PackageIcon className="h-6 w-6 text-primary" />
{pkg.name}
</CardTitle>
<CardDescription className="text-base text-muted-foreground">
{pkg.description}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-baseline gap-2">
<span className="text-3xl font-semibold">
{pkg.price === 0 ? "Kostenlos" : currencyFormatter.format(pkg.price)}
</span>
<Badge variant="secondary" className="uppercase tracking-wider text-xs">
{pkg.type === "reseller" ? "Reseller" : "Endkunde"}
</Badge>
</div>
{Array.isArray(pkg.features) && pkg.features.length > 0 && (
<ul className="space-y-3">
{pkg.features.map((feature, index) => (
<li key={index} className="flex items-start gap-3 text-sm text-muted-foreground">
<Check className="mt-0.5 h-4 w-4 text-primary" />
<span>{feature}</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}
function PackageOption({ pkg, isActive, onSelect }: { pkg: CheckoutPackage; isActive: boolean; onSelect: () => void }) {
return (
<button
type="button"
onClick={onSelect}
className={`w-full rounded-md border bg-background p-4 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 ${
isActive ? "border-primary shadow-sm" : "border-border hover:border-primary/40"
}`}
>
<div className="flex items-center justify-between text-sm font-medium">
<span>{pkg.name}</span>
<span className="text-muted-foreground">
{pkg.price === 0 ? "Kostenlos" : currencyFormatter.format(pkg.price)}
</span>
</div>
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{pkg.description}</p>
</button>
);
}
export const PackageStep: React.FC = () => {
const { selectedPackage, packageOptions, setSelectedPackage, resetPaymentState, nextStep } = useCheckoutWizard();
const comparablePackages = useMemo(() => {
return packageOptions.filter((pkg) => pkg.type === selectedPackage.type);
}, [packageOptions, selectedPackage.type]);
const handlePackageChange = (pkg: CheckoutPackage) => {
if (pkg.id === selectedPackage.id) {
return;
}
setSelectedPackage(pkg);
resetPaymentState();
};
return (
<div className="grid gap-8 lg:grid-cols-[2fr_1fr]">
<div className="space-y-6">
<PackageSummary pkg={selectedPackage} />
<div className="flex justify-end">
<Button size="lg" onClick={nextStep}>
Weiter zum Konto
</Button>
</div>
</div>
<aside className="space-y-4">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Alternative Pakete
</h3>
<div className="space-y-3">
{comparablePackages.map((pkg) => (
<PackageOption
key={pkg.id}
pkg={pkg}
isActive={pkg.id === selectedPackage.id}
onSelect={() => handlePackageChange(pkg)}
/>
))}
{comparablePackages.length === 0 && (
<p className="text-xs text-muted-foreground">
Keine weiteren Pakete in dieser Kategorie verfuegbar.
</p>
)}
</div>
</aside>
</div>
);
};

View File

@@ -0,0 +1,80 @@
import React, { useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useCheckoutWizard } from "../WizardContext";
interface PaymentStepProps {
stripePublishableKey: string;
}
export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey }) => {
const { selectedPackage, paymentProvider, setPaymentProvider, resetPaymentState, nextStep } = useCheckoutWizard();
useEffect(() => {
resetPaymentState();
}, [selectedPackage.id, resetPaymentState]);
const isFree = selectedPackage.price === 0;
if (isFree) {
return (
<div className="space-y-6">
<Alert>
<AlertTitle>Kostenloses Paket</AlertTitle>
<AlertDescription>
Dieses Paket ist kostenlos. Wir aktivieren es direkt nach der Bestaetigung.
</AlertDescription>
</Alert>
<div className="flex justify-end">
<Button size="lg" onClick={nextStep}>
Paket aktivieren
</Button>
</div>
</div>
);
}
return (
<div className="space-y-6">
<Tabs value={paymentProvider ?? 'stripe'} onValueChange={(value) => setPaymentProvider(value as 'stripe' | 'paypal')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="stripe">Stripe</TabsTrigger>
<TabsTrigger value="paypal">PayPal</TabsTrigger>
</TabsList>
<TabsContent value="stripe" className="mt-4">
<div className="rounded-lg border bg-card p-6 shadow-sm space-y-4">
<p className="text-sm text-muted-foreground">
Karten- oder SEPA-Zahlung via Stripe Elements. Wir erzeugen beim Fortfahren einen Payment Intent.
</p>
<Alert variant="secondary">
<AlertTitle>Integration folgt</AlertTitle>
<AlertDescription>
Stripe Elements wird im naechsten Schritt integriert. Aktuell dient dieser Block als Platzhalter fuer UI und API Hooks.
</AlertDescription>
</Alert>
<div className="flex justify-end">
<Button disabled>Stripe Zahlung starten</Button>
</div>
</div>
</TabsContent>
<TabsContent value="paypal" className="mt-4">
<div className="rounded-lg border bg-card p-6 shadow-sm space-y-4">
<p className="text-sm text-muted-foreground">
PayPal Express Checkout mit Rueckleitung zur Bestaetigung. Wir hinterlegen Paket- und Tenant-Daten im Order-Metadata.
</p>
<Alert variant="secondary">
<AlertTitle>Integration folgt</AlertTitle>
<AlertDescription>
PayPal Buttons werden im Folge-PR angebunden. Dieser Platzhalter zeigt den spaeteren Container fuer die Buttons.
</AlertDescription>
</Alert>
<div className="flex justify-end">
<Button disabled>PayPal Bestellung anlegen</Button>
</div>
</div>
</TabsContent>
</Tabs>
</div>
);
};

View File

@@ -0,0 +1,39 @@
export type CheckoutStepId = 'package' | 'auth' | 'payment' | 'confirmation';
export interface CheckoutPackage {
id: number;
name: string;
description: string;
price: number;
currency?: string;
type: 'endcustomer' | 'reseller';
features: string[];
limits?: Record<string, unknown>;
[key: string]: unknown;
}
export interface CheckoutWizardState {
currentStep: CheckoutStepId;
selectedPackage: CheckoutPackage;
packageOptions: CheckoutPackage[];
isAuthenticated: boolean;
authUser?: {
id: number;
email: string;
name?: string;
pending_purchase?: boolean;
} | null;
paymentProvider?: 'stripe' | 'paypal';
isProcessing?: boolean;
}
export interface CheckoutWizardContextValue extends CheckoutWizardState {
setStep: (step: CheckoutStepId) => void;
nextStep: () => void;
previousStep: () => void;
setSelectedPackage: (pkg: CheckoutPackage) => void;
markAuthenticated: (user: CheckoutWizardState['authUser']) => void;
setPaymentProvider: (provider: CheckoutWizardState['paymentProvider']) => void;
resetPaymentState: () => void;
}