codex has reworked checkout, but frontend doesnt work
This commit is contained in:
64
resources/js/hooks/useLocalizedRoutes.ts
Normal file
64
resources/js/hooks/useLocalizedRoutes.ts
Normal 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]);
|
||||
};
|
||||
@@ -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>© 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
89
resources/js/pages/marketing/checkout/CheckoutWizard.tsx
Normal file
89
resources/js/pages/marketing/checkout/CheckoutWizard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
110
resources/js/pages/marketing/checkout/WizardContext.tsx
Normal file
110
resources/js/pages/marketing/checkout/WizardContext.tsx
Normal 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;
|
||||
};
|
||||
102
resources/js/pages/marketing/checkout/steps/AuthStep.tsx
Normal file
102
resources/js/pages/marketing/checkout/steps/AuthStep.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
118
resources/js/pages/marketing/checkout/steps/PackageStep.tsx
Normal file
118
resources/js/pages/marketing/checkout/steps/PackageStep.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
80
resources/js/pages/marketing/checkout/steps/PaymentStep.tsx
Normal file
80
resources/js/pages/marketing/checkout/steps/PaymentStep.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
39
resources/js/pages/marketing/checkout/types.ts
Normal file
39
resources/js/pages/marketing/checkout/types.ts
Normal 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user